The brief's rule is to verify a URL resolves before shipping it. The table that did that was written in August against a corpus of 426 links, while the map now ships 3,603 - and the UI matched exactly TWO of them. Two silent causes: its eBible keys were http:// while every shipped URL now passes through httpsify, and its 306 film keys were the old ?languageId= URLs that were later replaced wholesale by Arclight shareUrl deep links. Links that HAD been verified displayed as "unchecked", which a reader cannot tell apart from "nobody looked". The old note said the film page was unverifiable by fetch, and for the URL shape it was written against that was right: ?languageId=13562 and ?languageId=99999999 return byte-identical HTML, so a status check there is a false green. The shareUrl migration changed that. A path discriminates: /watch/jesus/german-standard -> 200, 944,516 bytes /watch/jesus/pashto-yusufzai -> 200, 810,741 bytes (distinct body) /watch/jesus/definitely-not-xyzzy -> 404 So 2,049 film links became checkable for the first time. The 5 remaining ?languageId= URLs stay "unknown" with that reason - never verified, never broken. Every host was probed with a known-good and a known-bad before being trusted, and all answer HEAD and discriminate: ebible.org, png.bible, alkitab.pw, jesusfilm.org, arc.gt, git.door43.org, zume.training. HEAD is deliberate - a GET over 2,049 film pages is ~1.6GB of someone else's bandwidth to learn a status code. Five negative and five positive controls run in band and the script aborts rather than write a table if either arm misbehaves. Result over 3,603 unique URLs in 365s: 3,596 verified, 5 unknown, and 2 genuinely dead - arc.gt/092vn (Yakpa, 2 pins) and /watch/jesus/4101 (Zhuang Liujiang, 1 pin), both re-confirmed 404 with a browser UA and a GET. The builder now drops any link verification proved dead BEFORE tiers are computed, so a code whose only film is a 404 has no film and its tier says so. The gap filter moves 3,457 -> 3,460, which is exactly those three pins. Also fixed: the verification fetch hung off openPin's country branch, so a tap landing before the country index had loaded left the whole session reading "unchecked". It is now fetched unconditionally. Lookup coverage goes from 2/3,605 to 3,603/3,603. New gate clause K fails below a 95% coverage floor or if any shipped URL is recorded broken; poison self-test now 11/11. Verified in a browser on the exact case that failed - one immediate pin tap - which now fetches the table and renders "Scripture - link checked" and "Film - link checked"; screenshot read to confirm both chips are green where they were grey.
232 lines
14 KiB
JavaScript
232 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-gospel-coverage-consistent.mjs — the gospel-resources surfaces must agree.
|
|
*
|
|
* WHY THIS EXISTS. The same defect was found by hand THREE times in one flight, and each
|
|
* time it was invisible until someone compared two surfaces:
|
|
* 1. the country sheet listed 36 groups for Indonesia while the map drew 669 pins there,
|
|
* because the sheet read a curated 2,106-record subset and the map read the pins;
|
|
* 2. the resource table silently dropped 506 real resources across 377 languages,
|
|
* because its generator skipped any code no pin speaks — in a table titled
|
|
* "every link";
|
|
* 3. resources attached to `und` ("Undetermined") told 14 people groups there was
|
|
* Scripture in a language the record explicitly does not identify.
|
|
* None of these threw. Every page rendered. Only a comparison caught them, so the
|
|
* comparison is now a gate rather than a habit.
|
|
*
|
|
* WHAT IT ASSERTS, all static — no browser, no server, runs in well under a second:
|
|
* A. no ISO 639-3 special/reserved code carries a resource (und/mul/mis/zxx/qaa-qtz);
|
|
* B. every code carrying a resource has a language NAME — an unnamed code renders as a
|
|
* bare ISO in the table and tells a reader nothing;
|
|
* C. the resource table's reach columns match an independent recount off the pins;
|
|
* D. the table covers EVERY code that has a resource — the "every link" claim;
|
|
* E. every resource_url in the table exists in the pin index, so the table cannot
|
|
* invent a link the map does not hold;
|
|
* F. each pin's tier agrees with the URLs its code actually carries;
|
|
* I. a row's TYPE agrees with the host it points at — Film only on jesusfilm.org or
|
|
* arc.gt, Bible Stories only on door43 and only under that name, Scripture never on
|
|
* a film host. Clause H catches the one mislabel that actually shipped; this catches
|
|
* the general shape, because the 70-row defect was invisible for seven ticks
|
|
* precisely because NOTHING compared a row's label to where it points. Audited at
|
|
* the time of writing: 3,550 rows, 0 violations — so this is a guard, not a fix.
|
|
* H. nothing named "Open Bible Stories" is TYPED as Scripture. OBS is 50 illustrated
|
|
* narratives drawn from the canon, not a translation, and this repo's standing rule
|
|
* is that the two are never blurred. It broke silently: owner disambiguation changed
|
|
* the label to "Open Bible Stories (OBS-TLF)" and the type test was an exact ===
|
|
* match, so 70 story sets were typed Scripture and stayed that way for seven ticks.
|
|
* An exact match against a label that can grow a suffix is the whole defect.
|
|
* G. no URL is plain http. Every host we ship serves https directly and redirects
|
|
* http there anyway, so a plain link only buys a redirect hop and a downgraded
|
|
* scheme in a field worker's browser. 1,237 of them shipped that way until the
|
|
* normalisation moved to the point where a URL ENTERS the index — doing it
|
|
* per-source had left the 120 curated links behind, since fill-never-overwrite
|
|
* preserves those and they never passed through a fetcher.
|
|
*
|
|
* ★ SELF-TEST: run with --poison to inject each defect in memory and confirm the clause
|
|
* catches it. A check that has never been shown to fail proves nothing, and five of the
|
|
* six clauses here are asserting a NEGATIVE, which is exactly the shape that passes
|
|
* vacuously when the data it reads is empty or mis-keyed.
|
|
*/
|
|
import { readFileSync } from 'node:fs'
|
|
import { resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const POISON = process.argv.includes('--poison')
|
|
|
|
const read = (p) => JSON.parse(readFileSync(resolve(REPO, p), 'utf8'))
|
|
let pins, table, verif
|
|
try {
|
|
pins = read('data/tables/people-group-pins.json')
|
|
table = read('data/tables/gospel-resource-links.json')
|
|
try { verif = read('data/tables/gospel-link-verification.json') } catch { verif = null }
|
|
} catch (e) {
|
|
console.error(`COVERAGE-CONSISTENT: SKIP — inputs missing (${e.message}).`)
|
|
console.error(' Run: node plugin-designs/gospel-resources/scripts/build-people-group-pins.mjs')
|
|
process.exit(0)
|
|
}
|
|
|
|
const codes = { ...pins.codes }
|
|
let rows = pins.pins
|
|
let trows = table.rows
|
|
|
|
// A gate that reads an empty corpus passes every negative clause while measuring nothing.
|
|
if (!rows?.length || !trows?.length || !Object.keys(codes).length) {
|
|
console.error('COVERAGE-CONSISTENT: FAIL — an input is empty; every clause below would pass vacuously.')
|
|
process.exit(1)
|
|
}
|
|
|
|
if (POISON) {
|
|
// one injected defect per clause, in memory only
|
|
codes.und = { l: 'Undetermined', d: [{ s: 'Open Bible Stories', u: 'https://example.invalid/und' }] } // A
|
|
codes.zzq = { l: '', s: 'http://ebible.org/zzq/' } // B + G
|
|
trows = [{ ...trows[0], people_groups: (trows[0].people_groups || 0) + 999 }, // C
|
|
{ ...trows[0], resource: 'Open Bible Stories (X)', type: 'Scripture' }, // H
|
|
{ ...trows[0], type: 'Film', resource_url: 'https://ebible.org/xyz/' }, // I
|
|
...trows.slice(1, -1),
|
|
{ ...trows[trows.length - 1], resource_url: 'https://example.invalid/invented' }] // E
|
|
rows = [[...rows[0].slice(0, 6), 2], ...rows.slice(1)] // F (forced tier 2)
|
|
codes.zzj = { l: 'Both at once', z: 'https://zume.training/en', za: { u: 'https://zume.training/pus', l: 'Pashto', n: 'x' } } // J
|
|
codes.zzk = { l: 'Ships a known-dead link', f: 'https://jesusfilm.org/watch/jesus/4101' } // K
|
|
}
|
|
|
|
const SPECIAL = (c) => c === 'und' || c === 'mul' || c === 'mis' || c === 'zxx' || (c >= 'qaa' && c <= 'qtz')
|
|
const hasRes = (v) => !!(v && (v.s || v.f || (v.d && v.d.length)))
|
|
const fails = []
|
|
const add = (clause, msg) => fails.push(` [${clause}] ${msg}`)
|
|
|
|
// A — no resource on a code that is not a language
|
|
for (const [c, v] of Object.entries(codes)) {
|
|
if (SPECIAL(c) && hasRes(v)) add('A', `${c} is an ISO reserved code and carries a resource`)
|
|
}
|
|
// B — a code with a resource must be named
|
|
for (const [c, v] of Object.entries(codes)) {
|
|
if (hasRes(v) && !String(v.l || '').trim()) add('B', `${c} carries a resource but has no language name`)
|
|
}
|
|
// C — reach columns recounted off the pins
|
|
const groups = {}, speakers = {}
|
|
for (const r of rows) { groups[r[4]] = (groups[r[4]] || 0) + 1; speakers[r[4]] = (speakers[r[4]] || 0) + (r[5] || 0) }
|
|
let cBad = 0
|
|
for (const t of trows) {
|
|
if ((groups[t.iso639_3] || 0) !== t.people_groups) cBad++
|
|
else if ((speakers[t.iso639_3] || 0) !== t.speakers) cBad++
|
|
}
|
|
if (cBad) add('C', `${cBad} table row(s) disagree with a pin recount of people_groups/speakers`)
|
|
// D — every code with a resource is represented in the table
|
|
const inTable = new Set(trows.map((t) => t.iso639_3))
|
|
const missing = Object.keys(codes).filter((c) => hasRes(codes[c]) && !inTable.has(c))
|
|
if (missing.length) add('D', `${missing.length} code(s) carry a resource but appear in no table row (e.g. ${missing.slice(0, 3).join(', ')})`)
|
|
// E — the table cannot invent a URL
|
|
const known = new Set()
|
|
for (const v of Object.values(codes)) {
|
|
if (v.s) known.add(v.s); if (v.f) known.add(v.f); if (v.z) known.add(v.z); if (v.za) known.add(v.za.u)
|
|
for (const d of v.d || []) known.add(d.u)
|
|
}
|
|
const invented = trows.filter((t) => t.resource_url && !known.has(t.resource_url))
|
|
if (invented.length) add('E', `${invented.length} table URL(s) exist in no pin record (e.g. ${invented[0].resource_url})`)
|
|
// I — type must agree with the host
|
|
const FILM_HOST = /(^|\.)(jesusfilm\.org|arc\.gt)$/i
|
|
const hostOf = (u) => (/^https?:\/\/([^/]+)/i.exec(u || '') || [, ''])[1].toLowerCase()
|
|
const typeBad = []
|
|
for (const t of trows) {
|
|
const h = hostOf(t.resource_url)
|
|
if (!h) continue
|
|
const film = FILM_HOST.test(h)
|
|
if (t.type === 'Film' && !film) typeBad.push(`Film on ${h}`)
|
|
else if (t.type === 'Scripture' && film) typeBad.push(`Scripture on film host ${h}`)
|
|
else if (t.type === 'Bible Stories' && !/(^|\.)git\.door43\.org$/i.test(h)) typeBad.push(`Bible Stories on ${h}`)
|
|
else if (t.type === 'Bible Stories' && !/^Open Bible Stories/.test(t.resource || '')) typeBad.push(`Bible Stories named "${t.resource}"`)
|
|
// Training is a COURSE, and the only publisher of it here is Zume. Without this the
|
|
// type fell through every branch above and was checked by nothing at all.
|
|
else if (t.type === 'Discipleship training' && !/(^|\.)zume\.training$/i.test(h)) typeBad.push(`Discipleship training on ${h}`)
|
|
else if (/(^|\.)zume\.training$/i.test(h) && t.type !== 'Discipleship training') typeBad.push(`${t.type} on zume.training`)
|
|
}
|
|
if (typeBad.length) add('I', `${typeBad.length} row(s) whose type disagrees with their host (e.g. ${typeBad[0]})`)
|
|
|
|
// H — a story set must never be typed as Scripture
|
|
const misTyped = trows.filter((t) => /open bible stories/i.test(t.resource || '') && /^scripture$/i.test(t.type || ''))
|
|
if (misTyped.length) add('H', `${misTyped.length} row(s) named "Open Bible Stories" are typed as Scripture (e.g. ${misTyped[0].language})`)
|
|
|
|
// G — no plain-http URL
|
|
const httpUrls = []
|
|
for (const [c, v] of Object.entries(codes)) {
|
|
for (const u of [v.s, v.f, v.z, v.za && v.za.u, ...(v.d || []).map((d) => d.u)]) {
|
|
if (u && /^http:\/\//i.test(u)) httpUrls.push(`${c} ${u}`)
|
|
}
|
|
}
|
|
if (httpUrls.length) add('G', `${httpUrls.length} plain-http URL(s) (e.g. ${httpUrls[0]})`)
|
|
|
|
// F — tier agrees with the URLs the code holds
|
|
let fBad = 0
|
|
for (const r of rows) {
|
|
const v = codes[r[4]] || {}
|
|
const want = v.s && v.f ? 2 : (v.s || v.f || (v.d && v.d.length)) ? 1 : 0
|
|
if (want !== r[6]) fBad++
|
|
}
|
|
if (fBad) add('F', `${fBad} pin(s) carry a tier that disagrees with their code's URLs`)
|
|
|
|
// J — an approximate match must never sit alongside a confirmed one on the same code.
|
|
// The map styles them differently and says different things, so a code holding both would
|
|
// render the qualified claim and the confirmed one as if they were the same resource, and
|
|
// the qualification is the entire point of keeping them apart.
|
|
const both = Object.entries(codes).filter(([, v]) => v.z && v.za).map(([c]) => c)
|
|
if (both.length) add('J', `${both.length} code(s) carry BOTH a confirmed and an approximate training link (e.g. ${both[0]})`)
|
|
// An approximate entry must still be a real, complete record rather than a bare URL.
|
|
const thin = Object.entries(codes).filter(([, v]) => v.za && !(v.za.u && v.za.l && v.za.n)).map(([c]) => c)
|
|
if (thin.length) add('J', `${thin.length} approximate entr(ies) missing a url, language or note (e.g. ${thin[0]})`)
|
|
|
|
// K — verification must actually cover what is shipped, and nothing shipped may be known
|
|
// dead. The previous table matched TWO of 3,605 links because its keys were written http://
|
|
// and against a URL shape the index had since replaced; the map showed "unchecked" on links
|
|
// that had in fact been verified and nobody could tell that from never-looked-at. A coverage
|
|
// floor is the only thing that makes that failure loud.
|
|
if (verif && verif.links) {
|
|
const V = verif.links
|
|
const shipped = new Set()
|
|
for (const v of Object.values(codes)) {
|
|
if (v.s) shipped.add(v.s)
|
|
if (v.f) shipped.add(v.f)
|
|
if (v.z) shipped.add(v.z)
|
|
if (v.za && v.za.u) shipped.add(v.za.u)
|
|
for (const d of v.d || []) if (d.u) shipped.add(d.u)
|
|
}
|
|
const known = [...shipped].filter((u) => V[u])
|
|
const cover = shipped.size ? known.length / shipped.size : 0
|
|
if (cover < 0.95) {
|
|
add('K', `only ${known.length}/${shipped.size} shipped URL(s) (${(100 * cover).toFixed(1)}%) appear in the verification table — below the 95% floor`)
|
|
}
|
|
const dead = [...shipped].filter((u) => V[u] && V[u].state === 'broken')
|
|
if (dead.length) add('K', `${dead.length} shipped URL(s) are recorded BROKEN and must not ship (e.g. ${dead[0]})`)
|
|
}
|
|
|
|
const clauses = new Set(fails.map((f) => f.slice(3, 4)))
|
|
if (POISON) {
|
|
// D is included deliberately: it is the clause that would have caught the 506
|
|
// resources dropped from a table titled "every link", and the most valuable clause is
|
|
// the worst one to leave unproven. The injected codes above (und, zzq) are absent from
|
|
// the table, so they poison D as well as A and B.
|
|
const want = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K']
|
|
const caught = want.filter((c) => clauses.has(c))
|
|
console.log(`COVERAGE-CONSISTENT --poison: ${caught.length}/${want.length} clause(s) caught their injected defect (${caught.join(',')})`)
|
|
if (caught.length !== want.length) {
|
|
console.error(` MISSED: ${want.filter((c) => !clauses.has(c)).join(',')} — those clauses cannot fail and prove nothing.`)
|
|
process.exit(1)
|
|
}
|
|
console.log('COVERAGE-CONSISTENT: SELF-TEST PASS — every poisoned clause fails as designed.')
|
|
process.exit(0)
|
|
}
|
|
|
|
if (fails.length) {
|
|
console.error(`COVERAGE-CONSISTENT: FAIL — ${fails.length} problem(s):`)
|
|
for (const f of fails.slice(0, 20)) console.error(f)
|
|
process.exit(1)
|
|
}
|
|
console.log(`COVERAGE-CONSISTENT: PASS — ${rows.length} pins, ${Object.keys(codes).length} codes, ${trows.length} table rows agree.`)
|
|
// Name every clause. A summary that lists four of nine reads as a narrower check than it
|
|
// is, and the next reader calibrates their trust on this line rather than the source.
|
|
console.log(' A no resource on an ISO reserved code B every resourced code is named')
|
|
console.log(' C reach recounted off the pins D every resourced code is in the table')
|
|
console.log(' E no table URL the pins do not hold F pin tiers match their code\'s URLs')
|
|
console.log(' G no plain-http URL H no story set typed as Scripture')
|
|
console.log(' I row type agrees with its host J approximate never reads as confirmed')
|
|
console.log(' K links are verified, and none known-broken ships')
|