#!/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')