diff --git a/internal/check-label-key-coverage.mjs b/internal/check-label-key-coverage.mjs new file mode 100644 index 000000000..968970ba1 --- /dev/null +++ b/internal/check-label-key-coverage.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// check-label-key-coverage — an English-to-key lookup table must cover every label its companion +// table can produce. +// +// ★ WHAT THIS CAUGHT. poster-wall.vue translates its section headings by LOOKING THE KEY UP BY THE +// ENGLISH: +// const REGION_OF = { 'brazil': 'Americas', 'europe': 'Europe', ... } // id -> English +// const REGION_KEY = { 'Middle East': 'region_middle_east', ... } // English -> key +// const regionLabel = (english) => { const key = REGION_KEY[english] +// return key ? t(`poster.${key}`) : english } +// REGION_KEY had no entry for 'Americas' or 'Europe', and the fall-through arm returns the RAW +// ENGLISH. Both were reachable — four zone ids map to them — so two headings on the poster wall +// printed in English in all eleven locales. (upg100 found and fixed this in their own plugin; the +// gate exists so the next table cannot repeat it.) +// +// ⚠ WHY THE HARDCODED-ENGLISH GATE CANNOT SEE THIS. There it is one string that is a LOOKUP INDEX, +// not a rendered label, and the key is COMPUTED — `t('poster.' + key)` — so a scan that reads keys +// written literally at the call site finds nothing to check. Resolving arbitrary key expressions is +// a static-analysis problem. This gate does the one decidable part instead: two object literals in +// one file, where the values of the first are the keys of the second, and coverage is a set +// difference. Narrow on purpose. +// +// ⚠ THE VALUES, NOT THE KEYS, ARE WHAT MUST BE COVERED. Reading REGION_OF's keys (the zone ids) +// against REGION_KEY finds nothing wrong and feels like a check. The ids are never translated; it is +// the English on the right-hand side that reaches a reader. +// +// ⚠ AND A LITERAL ARGUMENT COUNTS AS PRODUCED. Three of poster-wall's nine headings never appear in +// REGION_OF at all — they are passed straight in, `regionLabel('China + Mongolia')`. A gate that +// reads only the table misses a third of the surface, and 'Other' is exactly the kind of catch-all +// that gets added last and keyed never. +// +// Reads real source. Headless, no browser, no network. Exit 0 pass · 1 fail. +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync, readdirSync, existsSync } from 'node:fs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const read = (p) => readFileSync(resolve(ROOT, p), 'utf8') + +const fails = [] +const ok = (cond, msg) => { if (!cond) fails.push(msg) } + +// ── the tables this gate knows about ─────────────────────────────────────────────────────────── +// Declared rather than discovered: a heuristic that hunted for "any two objects that look related" +// would be guessing about code it has never seen, and a wrong guess here reports a defect that is +// not there. Each entry names the file, the table that PRODUCES labels, the table that maps a label +// to a key, the helper that reads them, and the area those keys live in. +const TABLES = [ + { + file: 'plugin-designs/my-upg-100-list/poster-wall.vue', + produces: 'REGION_OF', + maps: 'REGION_KEY', + via: 'regionLabel', + area: 'poster', + }, +] + +/** The body of `const NAME = { ... }`, matched to its first closing brace at column 0. */ +function objectBody(src, name) { + const m = new RegExp(`const ${name}\\s*=\\s*\\{([\\s\\S]*?)\\n\\}`).exec(src) + return m ? m[1] : null +} +/** Quoted values on the right of a colon — the English labels a table produces. */ +const valuesOf = (body) => new Set([...body.matchAll(/:\s*'([^']+)'/g)].map((m) => m[1])) +/** Quoted keys on the left of a colon — the English labels a table covers. */ +const keysOf = (body) => new Set([...body.matchAll(/'([^']+)'\s*:/g)].map((m) => m[1])) + +for (const t of TABLES) { + if (!existsSync(resolve(ROOT, t.file))) { + ok(false, `${t.file} is missing — a table this gate watches moved or was renamed`) + continue + } + const src = read(t.file) + const produces = objectBody(src, t.produces) + const maps = objectBody(src, t.maps) + // ★ A MISSING TABLE IS A BLIND GATE, NOT A PASS. If either object is renamed away, every set below + // is empty and the coverage check passes while measuring nothing — the failure mode a gate must + // never have. So absence is the loud case. + if (!produces) { ok(false, `${t.file}: no \`const ${t.produces} = {\` — this gate has gone blind`); continue } + if (!maps) { ok(false, `${t.file}: no \`const ${t.maps} = {\` — this gate has gone blind`); continue } + + const fromTable = valuesOf(produces) + const fromCalls = new Set([...src.matchAll(new RegExp(`${t.via}\\('([^']+)'\\)`, 'g'))].map((m) => m[1])) + const produced = new Set([...fromTable, ...fromCalls]) + const covered = keysOf(maps) + + ok(produced.size > 0, `${t.file}: ${t.produces} yielded no labels at all — the shape it is parsed with no longer matches`) + ok(covered.size > 0, `${t.file}: ${t.maps} yielded no keys at all — the shape it is parsed with no longer matches`) + + const uncovered = [...produced].filter((label) => !covered.has(label)).sort() + ok(uncovered.length === 0, + `${t.file}: ${t.maps} has no entry for ${uncovered.map((s) => `'${s}'`).join(', ')} — ` + + `${t.via}() falls through to the raw English, so ${uncovered.length === 1 ? 'that heading prints' : 'those headings print'} ` + + `in English in every locale`) + + // Covering a label with a key that does not exist is the same defect one step later: the lookup + // succeeds, t() gets a key the catalogue has never heard of, and what the reader sees is a raw + // key or the English again depending on the fallback. + const locales = 'library/i18n/locales' + const localeDirs = existsSync(resolve(ROOT, locales)) + ? readdirSync(resolve(ROOT, locales), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort() + : [] + for (const [label, key] of [...maps.matchAll(/'([^']+)'\s*:\s*'([^']+)'/g)].map((m) => [m[1], m[2]])) { + const missing = localeDirs.filter((loc) => { + const p = resolve(ROOT, locales, loc, `${t.area}.json`) + if (!existsSync(p)) return true + try { return JSON.parse(readFileSync(p, 'utf8'))[key] === undefined } catch { return true } + }) + ok(missing.length === 0, + `${t.file}: ${t.maps}['${label}'] points at ${t.area}.${key}, which is missing in ${missing.join(', ')}`) + } +} + +// ── SELF-TEST ────────────────────────────────────────────────────────────────────────────────── +// ★ THE CHECK MUST BITE. A coverage test built on two regexes passes loudly when either stops +// matching — it compares two empty sets and calls it agreement. So the parsing is exercised on +// planted source whose answer is known, including the shape that actually shipped. +{ + const t = [] + const probe = (src, via) => { + const p = objectBody(src, 'OF'), m = objectBody(src, 'KEY') + if (!p || !m) return null + const produced = new Set([...valuesOf(p), ...[...src.matchAll(new RegExp(`${via}\\('([^']+)'\\)`, 'g'))].map((x) => x[1])]) + return [...produced].filter((l) => !keysOf(m).has(l)).sort() + } + const COVERED = `const OF = {\n 'a': 'Africa', 'b': 'Asia',\n}\nconst KEY = {\n 'Africa': 'k_africa',\n 'Asia': 'k_asia',\n}\nlabel('Africa')\n` + const GAP = `const OF = {\n 'a': 'Africa', 'b': 'Americas',\n}\nconst KEY = {\n 'Africa': 'k_africa',\n}\n` + const VIA_CALL = `const OF = {\n 'a': 'Africa',\n}\nconst KEY = {\n 'Africa': 'k_africa',\n}\nlabel('Other')\n` + t.push([JSON.stringify(probe(COVERED, 'label')) === '[]', 'a fully covered table reports nothing']) + t.push([JSON.stringify(probe(GAP, 'label')) === '["Americas"]', 'a value with no key is reported']) + // the shape that shipped: the gap was reachable only through a literal argument, never the table + t.push([JSON.stringify(probe(VIA_CALL, 'label')) === '["Other"]', 'a label passed as a literal argument is reported']) + // and the blind-gate case: a renamed table must not read as agreement + t.push([objectBody(`const OTHER = {\n 'a': 'b',\n}`, 'OF') === null, 'a renamed table is detected, not silently skipped']) + const bad = t.filter(([pass]) => !pass) + for (const [, msg] of bad) fails.push(`SELF-TEST: ${msg}`) +} + +if (fails.length) { + console.error('LABEL-KEY-COVERAGE: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`LABEL-KEY-COVERAGE: PASS — ${TABLES.length} lookup table(s): every label the companion table can produce, ` + + 'including the ones passed as literal arguments, has a key, and every key exists in every locale.') diff --git a/package.json b/package.json index eff3e38d0..9429400ca 100644 --- a/package.json +++ b/package.json @@ -214,6 +214,7 @@ "check:selector-fields:self-test": "node internal/check-selector-fields.mjs --self-test", "check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs", "check:packet-variants": "node internal/check-packet-variants.mjs", + "check:label-key-coverage": "node internal/check-label-key-coverage.mjs", "check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs", "check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs", "check:evangelical-share": "node internal/check-evangelical-share.mjs", @@ -316,7 +317,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:country-names check:reduced-motion check:no-dead-library check:universal-props", - "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:label-key-coverage", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -491,6 +492,7 @@ "check:packet-render:expect-funnel": "check:packet-render with the page-2 FUNNEL declared REQUIRED. Exists because of a real near-miss: a merge resolved with `git checkout --ours` deleted the printPacket call site that supplies the funnel data (60 lines) and EVERY GATE STAYED GREEN — it had to, because the funnel page is OPTIONAL by design (buildPacket omits it when the arg is null), so a packet without page 2 is not malformed, just smaller. Always requiring it would be the wrong fix: the 11 pre-funnel laos packets and any host with no funnel data would go red, and a false red costs as much as a false green. So the CALLER, which knows whether this packet was built with funnel data, declares it — an absence is then either declared (no flag) or a failure (flag passed, page missing), never a silent nothing. Pass it for any packet from a profile that wires packetFunnel(). Browser gate.", "check:packet-strings-parity": "the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record.", "check:packet-variants": "the poster wall's Prayer List prints the PACKET (not the columnar list), and every variant it offers exists in packet-render's VARIANTS. A variant may drop fixed pages but never people: lightweight keeps every group. By the UPG-100 owner; mutation-tested. Headless; in gates:pre.", + "check:label-key-coverage": "node internal/check-label-key-coverage.mjs", "check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.", "check:universal-props": "every plugin reads the props the Plugin Studio header sets for all of them through the library: map engine, locale, Mapbox token and token URL, and the people-group list. It reads each bundle's source AND its shipped bundle and fails on a bypass (a mapbox:// style literal, a private token global, a list fetched around the library); a by-design exception is ALLOWed by name with its reason. --report also prints the matrix. Headless.", "check:zone-title-tier-a": "generated zone titles ship for ONE-country zones only: the 3 such zones produce a localized, non-code title in en/es/ar that differs across languages, AND every other zone declines so its hand-written label survives unchanged (a multi-country zone has a name that is not a list of its members). Two-sided, and NOT MEASURED when the country-name supplier cannot resolve a known code.",