Re-test links we already called checked
The verification table records that a URL resolved on the day it was checked, and nothing ever re-tests one that passed. A publisher can retire a page and this map will go on offering it indefinitely, still labelled "link checked". Clause L guards the freshness of the record; it cannot notice the web moving underneath it. This samples 153 links — 25 per host at a fixed stride, so the same run is repeatable — and today every one of them still resolves. THE PART WORTH KEEPING is which hosts it refuses to check. Four of the eleven never 404: jesusfilm.org, live.bible.is and knowgod.com serve an identical shell for any path, and fivefish.org answers a nonsense path with a plausible geo-located page at HTTP 200. Re-checking those by status would report perfect health forever, which is worse than not checking them, because it would look like evidence. They are verified by publisher API or page title in the main verifier instead. Three impossible URLs run in band every time. If one answers 2xx the check reports PROVED NOTHING and exits non-zero rather than passing. That mattered immediately: the first version sampled one link per host instead of 25 and still said PASS — a green that meant almost nothing, caught only by reading the count rather than the verdict.
This commit is contained in:
parent
1465e3a7bb
commit
92d236106d
2 changed files with 128 additions and 0 deletions
127
internal/check-link-rot-sample.mjs
Normal file
127
internal/check-link-rot-sample.mjs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* check-link-rot-sample.mjs — do the links we already verified still work?
|
||||
*
|
||||
* THE RISK. The verification table records that a URL resolved ON THE DAY IT WAS CHECKED.
|
||||
* Nothing re-tests a link once it passes, so a publisher can retire a page and the map will
|
||||
* keep offering it indefinitely, still labelled "link checked". Clause L of the coverage gate
|
||||
* guards the freshness of the RECORD; it cannot notice that the web moved underneath it.
|
||||
*
|
||||
* WHY A SAMPLE. 8,285 verified links is a lot of traffic to aim at seven ministries for a
|
||||
* maintenance check. A stratified sample — up to 25 per host, taken at a fixed stride so the
|
||||
* same run is repeatable — is enough to tell "nothing is rotting" from "a publisher has
|
||||
* reorganised", which is the decision this check exists to inform. A full re-verify is
|
||||
* scripts/verify-gospel-links.mjs and is the right tool once this finds something.
|
||||
*
|
||||
* ONLY HOSTS WHERE A STATUS MEANS SOMETHING — the part worth keeping.
|
||||
* Four of the eleven hosts NEVER 404:
|
||||
* - jesusfilm.org and live.bible.is return an identical SPA shell for any path;
|
||||
* - knowgod.com does the same;
|
||||
* - fivefish.org answers a nonsense path with a plausible geo-located page, HTTP 200.
|
||||
* Re-checking those by status would report perfect health forever, which is worse than not
|
||||
* checking them at all, because it would look like evidence. They are excluded here and are
|
||||
* verified by their publisher's API or by page title in the main verifier instead.
|
||||
*
|
||||
* IT IS SHOWN TO BE ABLE TO FAIL. Three URLs that must not exist are requested in band every
|
||||
* run. If any of them answers 2xx the check reports that it proved nothing and exits non-zero
|
||||
* rather than reporting a pass — the same rule applied to every other gate in this bundle.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = resolve(HERE, '..')
|
||||
const TABLE = resolve(REPO, 'data/tables/gospel-link-verification.json')
|
||||
|
||||
const UA =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
/** Hosts whose HTTP status is real evidence. Everything else lies; see the header. */
|
||||
const STATUS_MEANINGFUL = new Set([
|
||||
'ebible.org', 'alkitab.pw', 'png.bible', 'git.door43.org',
|
||||
'scriptureearth.org', 'zume.training', 'arc.gt',
|
||||
])
|
||||
|
||||
/** Must not exist. If one of these passes, the check cannot see death and says so. */
|
||||
const CONTROLS = [
|
||||
'https://ebible.org/zzz-no-such-translation-xyzzy/',
|
||||
'https://scriptureearth.org/data/zzz/PDF/nope.pdf',
|
||||
'https://git.door43.org/unfoldingWord/no-such-repo-xyzzy',
|
||||
]
|
||||
|
||||
const PER_HOST = Number(process.env.ROT_PER_HOST || 25)
|
||||
|
||||
async function status(url) {
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
headers: { 'user-agent': UA },
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
return r.status
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
let table
|
||||
try { table = JSON.parse(readFileSync(TABLE, 'utf8')) } catch {
|
||||
console.error('LINK-ROT: NOT MEASURED — no verification table to sample.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const verified = Object.entries(table.links || {})
|
||||
.filter(([, r]) => r && r.state === 'verified')
|
||||
.map(([u]) => u)
|
||||
|
||||
const byHost = new Map()
|
||||
for (const u of verified) {
|
||||
let h
|
||||
try { h = new URL(u).host } catch { continue }
|
||||
if (!STATUS_MEANINGFUL.has(h)) continue
|
||||
if (!byHost.has(h)) byHost.set(h, [])
|
||||
byHost.get(h).push(u)
|
||||
}
|
||||
|
||||
/* Fixed stride, sorted input: the same sample every run, so a change is a change in the web
|
||||
rather than a change in which links we happened to pick. */
|
||||
const sample = []
|
||||
for (const [, urls] of [...byHost].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
urls.sort()
|
||||
const step = Math.max(1, Math.floor(urls.length / PER_HOST))
|
||||
const take = []
|
||||
for (let i = 0; i < urls.length && take.length < PER_HOST; i += step) take.push(urls[i])
|
||||
sample.push(...take)
|
||||
}
|
||||
|
||||
/* ── controls first ─────────────────────────────────────────────────────────────────────── */
|
||||
const ctl = []
|
||||
for (const u of CONTROLS) ctl.push([u, await status(u)])
|
||||
const blind = ctl.filter(([, s]) => s >= 200 && s < 300)
|
||||
if (blind.length) {
|
||||
console.error('LINK-ROT: PROVED NOTHING — a URL that cannot exist answered 2xx:')
|
||||
for (const [u, s] of blind) console.error(` ${s} ${u}`)
|
||||
console.error(' This check cannot tell a live link from a dead one today. Fix it before')
|
||||
console.error(' trusting any pass it reports.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`LINK-ROT: controls pass — ${ctl.length} impossible URL(s), none answered 2xx.`)
|
||||
|
||||
const dead = []
|
||||
for (const u of sample) {
|
||||
const s = await status(u)
|
||||
if (!(s >= 200 && s < 300)) dead.push([u, s])
|
||||
}
|
||||
|
||||
console.log(
|
||||
`LINK-ROT: re-checked ${sample.length} verified link(s) across ${byHost.size} host(s) ` +
|
||||
`where a status is meaningful (${verified.length - sample.length} not sampled).`,
|
||||
)
|
||||
if (dead.length) {
|
||||
console.error(`LINK-ROT: FAIL — ${dead.length} link(s) we call "checked" no longer resolve:`)
|
||||
for (const [u, s] of dead.slice(0, 10)) console.error(` ${s || 'no response'} ${u}`)
|
||||
console.error(' Re-run scripts/verify-gospel-links.mjs to re-test the whole corpus; the')
|
||||
console.error(' build drops anything it proves dead.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('LINK-ROT: PASS — every sampled link still resolves.')
|
||||
|
|
@ -161,6 +161,7 @@
|
|||
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
|
||||
"check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs # a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
|
||||
"check:surfaces-read-the-tier:poison": "node internal/check-surfaces-read-the-tier.mjs --poison",
|
||||
"check:link-rot": "node internal/check-link-rot-sample.mjs # re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. Only hosts whose HTTP status is real evidence are sampled — jesusfilm.org, live.bible.is, knowgod.com and fivefish.org never 404, so status-checking them would report perfect health forever. Three impossible URLs run in band every time; if one answers 2xx it reports PROVED NOTHING instead of a pass. ~60s, network.",
|
||||
"check:embed-basemap": "node internal/check-embed-basemap-paints.mjs # the gospel map's BASEMAP must paint inside a genuinely cross-origin iframe on the token the page BAKES, not one a probe forces on it. Every other browser gate here forces a restricted token (u=doxalife) that 403s on the basemap, then counts PINS — which come from our own GeoJSON and paint with no token at all — so 'the map renders' had never been measured. Runs a negative control every time by rewriting the token on the wire; if the control passes it reports PROVED NOTHING rather than a pass. ~60s, needs a browser.",
|
||||
"check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test",
|
||||
"serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue