The brief names zume.training every tick and an earlier pass recorded the list as unobtainable. That was wrong. robots.txt Disallows /api/ and the homepage switcher is client-rendered, both true, but robots.txt also DECLARES a sitemap, and the publisher maintains /articles/zume-languages for exactly this purpose. Neither is behind a Disallow. The real figure is 62 locales, not the ~40 estimated: 54 serve a landing page that answers 200. The two sources disagree in both directions - Dari and Pashto ship sites but are missing from the article's table, and eight table rows serve no page yet - so fetch-zume-index.mjs takes the union and then fetches every landing URL. A locale ships a link only if its own page answered. Naming and linking stay separate facts. Pashto is deliberately left unjoined: pus is a macrolanguage, the people-group data carries pbu, pbt and pst, and nothing the publisher gives selects one. Training is not Scripture and not film, so it sits in its own field and its own type and moves NO tier. Measured: the 52 joined languages reach 3,479 pins and 4.78bn people, and none of them are tier 0 - every Zume language already has Scripture or film. Folding training into the tiers would have cut the gap count without anyone having translated anything. The gap filter still cuts 12,379 features to 3,457. Both gates had a hole this exposed. The coverage gate harvested URLs from s, f and d only, so the 52 new URLs existed in no pin record it could see; and clause I checked Film, Scripture and Bible Stories by host but let any other type fall through unchecked. Both now cover training, and a poisoned row typed Scripture on zume.training is caught. The browser gate screened 3,550 links and silently ignored the new ones; it now screens all 3,602. Verified in a browser with a negative control: deu opens with a chip linking https://zume.training/de, kri opens with "not in Zume's 54" and no href, no page errors.
194 lines
11 KiB
JavaScript
194 lines
11 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
|
|
try {
|
|
pins = read('data/tables/people-group-pins.json')
|
|
table = read('data/tables/gospel-resource-links.json')
|
|
} 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)
|
|
}
|
|
|
|
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)
|
|
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.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`)
|
|
|
|
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']
|
|
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')
|