diff --git a/documentation/README-SECTION-3-cli-commands.md b/documentation/README-SECTION-3-cli-commands.md index eed1d03e1..5bc7a69a8 100644 --- a/documentation/README-SECTION-3-cli-commands.md +++ b/documentation/README-SECTION-3-cli-commands.md @@ -25,7 +25,7 @@ The scripts these commands are grounded in (from this branch's `package.json`): // claiming to be ground truth, and this one had silently stopped being it. "dev": "bun x vite", "build": "bash internal/build-lock.sh", -"build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && bun run check:doc-scripts && bun run library-index && bun run check:library-index && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", +"build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:coverage-consistent && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && bun run check:doc-scripts && bun run library-index && bun run check:library-index && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", "preview": "bun x vite preview", "serve": "node internal/serve-strict.mjs", // 404s a directory, like production "cap:sync": "./node_modules/.bin/cap sync android", diff --git a/internal/check-gospel-coverage-consistent.mjs b/internal/check-gospel-coverage-consistent.mjs new file mode 100644 index 000000000..16557ee3c --- /dev/null +++ b/internal/check-gospel-coverage-consistent.mjs @@ -0,0 +1,137 @@ +#!/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. + * + * ★ 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 + trows = [{ ...trows[0], people_groups: (trows[0].people_groups || 0) + 999 }, // C + ...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) + 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})`) +// 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'] + 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.`) +console.log(' No reserved code carries a resource; every resourced code is named; reach columns,') +console.log(' table coverage, URL provenance and pin tiers all reconcile against the pin index.') diff --git a/package.json b/package.json index af4578fec..d24c0b604 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "check:facet-changes-map:self-test": "node internal/check-facet-changes-map.mjs --self-test # reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.", "check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).", "check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.", - "build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && bun run check:doc-scripts && bun run library-index && bun run check:library-index && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", + "build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:coverage-consistent && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && bun run check:doc-scripts && bun run library-index && bun run check:library-index && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", "check:template-literal": "node internal/template-literal-check.mjs", "verify:map-paint": "node internal/verify-map-paint.mjs", "check:sri": "node internal/sri-consistency-check.mjs", @@ -121,6 +121,7 @@ "check:spa-tab-order": "node internal/check-spa-tab-order.mjs", "check:backlog-status": "node internal/check-backlog-status-enum.mjs", "check:mjs-parse": "node internal/check-mjs-parses.mjs", + "check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs # the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: --poison, 6/6 clauses must fail. Written after the same drift defect was found by hand three times.", "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.", "check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs",