From 2a533b68a5298669ba6edea7653cf0981088ea56 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 14:46:51 +0000 Subject: [PATCH 1/6] Two readers of one private field, and nothing was holding them together internal/lib/facet-walk.mjs's __srcFeatures (injected into the page by check:facet-changes-map) and library/maps/pin-selection.js's readSourceFeatures (the packet and zoomToLegendRow) answer the same question about the same private GeoJSON field. They are two copies because one must be a string evaluated in a browser and the other an ES module, so they cannot be one function -- but nothing tested that they still agreed, and they had already stopped. THE CROSS-CHECK FOUND A DIVERGENCE ON ITS FIRST RUN, which is the argument for it. A source may hold a single Feature rather than a FeatureCollection; both engines accept that. The product's reader handled it and the gate's did not, so the gate answered "cannot measure" about a payload it could read perfectly well. Nobody reading either file would have noticed: each is correct on its own terms. __srcFeatures now handles it and the two agree. ONE DIFFERENCE IS DELIBERATE AND IS NOW PINNED AS SUCH RATHER THAN WAIVED: on a url-backed source the gate returns null while the product returns [] and warns. Both are right for their caller -- a gate whose job is to tell "the map drew nothing" from "I could not look" needs a third answer, while a selection returning null would break the Set arithmetic every caller does on it. Asserted in both directions, so neither side can quietly adopt the other's and call it a fix. AND NEITHER MAY BE SILENT ABOUT A SHAPE IT DOES NOT KNOW. maplibre has a THIRD _data shape: updateData() stores { updateable } (maplibre-gl-dev.js:49001). No product code calls updateData today, so this is a landmine rather than a bug -- and it is armed the moment someone adopts partial updates for performance, at which point the product reader would have gone back to returning [] in silence: "the map drew nothing", stated as fact about a payload nobody recognised, one line below the url warning that exists to prevent exactly that. Both readers now name the unrecognised shape by its keys, and a fixture holds it, so adopting updateData() trips a test instead of blanking a map. Negative-tested: dropping the single-Feature case, silencing either reader, or making the gate adopt the product's url answer each turn it red; restored, 14/14 green. check:pin-selection still passes, so the product reader is unchanged where it matters. --- internal/check-facet-shape.mjs | 76 ++++++++++++++++++++++++++++++++-- internal/lib/facet-walk.mjs | 17 +++++++- library/maps/pin-selection.js | 12 ++++++ 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/internal/check-facet-shape.mjs b/internal/check-facet-shape.mjs index 8df2d2999..ed3aa2b1f 100644 --- a/internal/check-facet-shape.mjs +++ b/internal/check-facet-shape.mjs @@ -6,6 +6,7 @@ * EXIT 0 pass · 1 fail */ import { WALK } from './lib/facet-walk.mjs' +import { readSourceFeatures } from '../library/maps/pin-selection.js' // ── SHAPE TEST (headless, no browser) ──────────────────────────────────────────────────────────── // ★ NEGATIVE-TESTS __srcFeatures AGAINST BOTH ENGINE SHAPES, in sr's check-pin-selection style. @@ -25,8 +26,14 @@ const cases = [ // drew nothing", which is the finding this gate exists to report. ['remote (_data = { url }) -> null, not 0', () => srcFeatures(mapWith({ _data: { url: 'https://x.invalid/a.json' } }), 'rd-bubbles'), null], ['absent source -> null', () => srcFeatures(mapWith(null), 'rd-bubbles'), null], + // ★ maplibre's THIRD shape. updateData() stores _data = { updateable } (maplibre-gl :49001). Nothing + // calls updateData today, so this fixture is here to make ADOPTING it trip a test instead of blanking + // the map: neither reader understands the shape, and the one thing both must not do is answer 0. + ['maplibre updateData (_data = { updateable }) -> null, not 0', + () => srcFeatures(mapWith({ _data: { updateable: new Map() } }), 'rd-bubbles'), null], ] let ok = 0 +let total = cases.length for (const [name, run, want] of cases) { const got = run() const n = got === null || got === undefined ? null : got.length @@ -34,7 +41,68 @@ for (const [name, run, want] of cases) { console.log(` ${pass ? '✓' : '✗'} ${name} -> ${n === null ? 'null' : n + ' feature(s)'}`) if (pass) ok++ } -console.log(ok === cases.length - ? `FACET-SHAPE-TEST: PASS — ${ok}/${cases.length}, both engine shapes measured` - : `FACET-SHAPE-TEST: FAIL — ${ok}/${cases.length}`) -process.exit(ok === cases.length ? 0 : 1) +// ── THE TWO READERS MUST NOT DRIFT ─────────────────────────────────────────────────────────────── +// This gate's helper (__srcFeatures, injected into the page) and the PRODUCT's reader +// (library/maps/pin-selection.js readSourceFeatures, used by the packet and zoomToLegendRow) answer the +// same question about the same private field, and they are two separate copies because one must be a +// string evaluated in a browser and the other an ES module. Nothing but this block holds them together. +// +// ★ ONE DIFFERENCE IS DELIBERATE AND IS ASSERTED AS SUCH, not waived. On a url-backed source the gate +// returns null ("cannot measure") while the product returns [] and warns. Both are right for their +// caller: a gate whose whole job is to distinguish "the map drew nothing" from "I could not look" must +// have a third answer, while a selection returning null would break the Set arithmetic every caller +// does on it. Pinning the fork here means the next person to touch either reader is told that it is a +// decision, and any OTHER disagreement is drift and fails. +const agree = [ + ['mapbox (_data = FeatureCollection)', { _data: FC }, 2], + ['maplibre (_data = { geojson })', { _data: { geojson: FC } }, 2], + ['serialize() fallback', { serialize: () => ({ data: FC }) }, 2], + ['single Feature, not a collection', { _data: { type: 'Feature', properties: { slug: 'a' } } }, 1], + ['unrecognised shape ({ updateable })', { _data: { updateable: new Map() } }, 0], +] +for (const [name, src, wantCount] of agree) { + const a = srcFeatures(mapWith(src), 'rd-bubbles') + const b = readSourceFeatures(mapWith(src), 'rd-bubbles') + const na = a === null || a === undefined ? 0 : a.length + const nb = b === null || b === undefined ? 0 : b.length + const pass = na === nb && na === wantCount + console.log(` ${pass ? '✓' : '✗'} both readers agree: ${name} -> gate ${na}, product ${nb}`) + if (pass) ok++ + total++ +} +{ + // The documented fork, asserted in BOTH directions so neither side can quietly adopt the other's. + const src = { _data: { url: 'https://x.invalid/a.json' } } + const a = srcFeatures(mapWith(src), 'rd-bubbles') + const b = readSourceFeatures(mapWith(src), 'rd-bubbles') + const pass = a === null && Array.isArray(b) && b.length === 0 + console.log(` ${pass ? '✓' : '✗'} documented fork on a url source: gate null ("cannot measure"), product [] + warn`) + if (pass) ok++ + total++ +} +// ── AND NEITHER MAY BE SILENT ABOUT A SHAPE IT DOES NOT KNOW ──────────────────────────────────── +// "Returns 0" and "returns 0 while saying it did not understand the payload" are different products. +// Only the second lets someone adopting updateData() find out from a console instead of from a map. +for (const [label, run] of [ + ['gate helper', () => srcFeatures(mapWith({ _data: { updateable: new Map() } }), 'rd-bubbles')], + // A map whose getSource answers to ANY id: mapWith() only knows 'rd-bubbles', so asking it for a + // fresh id returns no source at all and the reader exits before it can warn — the assertion would + // have passed for the wrong reason on a reader that never warns. The fresh id is still needed, + // because the warning is once-per-source by design. + ['product reader', () => readSourceFeatures( + { getSource: () => ({ _data: { updateable: new Map() } }) }, 'a-source-not-yet-warned-about')], +]) { + const said = [] + const real = console.warn + console.warn = (...a) => said.push(a.join(' ')) + try { run() } finally { console.warn = real } + const pass = said.some((m) => /updateable/.test(m)) + console.log(` ${pass ? '✓' : '✗'} ${label} names the unrecognised shape rather than failing silently`) + if (pass) ok++ + total++ +} + +console.log(ok === total + ? `FACET-SHAPE-TEST: PASS — ${ok}/${total}, both engine shapes measured, the gate's reader and the product's reader agree everywhere except their one documented fork, and neither is silent about a shape it does not know` + : `FACET-SHAPE-TEST: FAIL — ${ok}/${total}`) +process.exit(ok === total ? 0 : 1) diff --git a/internal/lib/facet-walk.mjs b/internal/lib/facet-walk.mjs index 4e6cdac03..899c92db3 100644 --- a/internal/lib/facet-walk.mjs +++ b/internal/lib/facet-walk.mjs @@ -57,7 +57,22 @@ function __srcFeatures(m, id) { // catch. null means "could not measure", and the caller must not treat it as zero. else if (data.url !== undefined) return null; } - return (data && data.features) || null; + if (data && data.features) return data.features; + // A SOURCE MAY HOLD ONE FEATURE, NOT A COLLECTION — both engines accept it, and the product's reader + // already did. This helper did not, so it answered "cannot measure" for a payload it could read + // perfectly well. Found by the cross-check in check-facet-shape, not by anyone reading either file. + if (data && data.type === 'Feature') return [data]; + // ★ AN UNRECOGNISED SHAPE MUST SAY SO BY NAME, not read as "the map drew nothing". maplibre has a + // THIRD _data shape this reader does not handle: updateData() stores { updateable } (maplibre-gl + // :49001), and nothing in the product calls updateData today — so the day someone adopts partial + // updates for performance, this would have gone quietly back to reporting an empty map about a map + // full of dots, which is the exact defect this gate exists to report. Naming the keys makes the + // next reader's first question ("what shape IS it?") answered by the message itself. + try { + console.warn('[facet-walk] source "' + id + '": unrecognised _data shape {' + + Object.keys(data || {}).join(',') + '} — cannot measure, NOT zero features'); + } catch (e) {} + return null; } function __live() { const m = window.__rdashMap; diff --git a/library/maps/pin-selection.js b/library/maps/pin-selection.js index bbc853514..19ba453b0 100644 --- a/library/maps/pin-selection.js +++ b/library/maps/pin-selection.js @@ -163,6 +163,18 @@ export function readSourceFeatures(map, sourceId) { if (!data) return []; if (Array.isArray(data?.features)) return data.features; if (data?.type === 'Feature') return [data]; + // ★ THE SAME RULE AS THE url BRANCH, FOR THE SHAPES WE HAVE NOT MET YET. Falling off the end used + // to return [] in silence, which is "the map drew nothing" stated as fact about a payload nobody + // recognised — the precise failure the url warning above exists to prevent, left in place one line + // below it. maplibre already HAS a third shape: updateData() stores _data = { updateable } + // (maplibre-gl :49001). No product code calls updateData today, so this is a landmine rather than + // a bug, and it is armed the moment someone adopts partial updates. Warned once per source, with + // the keys named, so the message identifies the shape instead of merely reporting a miss. + if (data && typeof data === 'object' && !_warnedUrlSources.has(sourceId)) { + _warnedUrlSources.add(sourceId); + // eslint-disable-next-line no-console + console.warn(`[pin-selection] source "${sourceId}": unrecognised data shape {${Object.keys(data).join(',')}} — nothing can be selected from it; this is NOT a confirmed empty source.`); + } return []; } From 457ff3acbb1be1815d85e26bc55e5784e5ed042b Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 14:57:02 +0000 Subject: [PATCH 2/6] Number({value:"1"}) is NaN, so the whole reachedness sentence vanished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live API sends imb_gsec as { value, label }, not a number. describeGroup read it with Number(), got NaN, matched no GSEC level, and dropped the level sentence — not an error, not a fallback, simply absent, so the row described a people group nobody has a reachedness figure for. upg100 hit it and fixed their own normalizer; that fixes their caller and nobody else's, and every other consumer handing this function a RAW API record lost the sentence the same silent way. The unwrap belongs at the READ, because this function's contract is "an API or normalized row" and both spellings are inside that contract. pick() now flattens { value, label } to its value, exactly as normalize.js:22 does. THE LANGUAGE CLAUSE WAS GOING THE SAME WAY, FOR THE SAME REASON, and fixing only GSEC would have left it. On a raw record the language NAME is at primary_language.label; the field itself holds an ISO code. The existing read deliberately refuses that code -- it would print "and speak abf" -- so a raw record simply had no language and fell to the country-only sentence. labelOf() takes the display half of the same pair, and ONLY for a nested object: consulting a flattened primary_language would print the code, which is the defect the refusal exists to prevent. Same for country_code, whose nested label is also the English fall-back when no resolver is injected. A raw record now produces a string identical to the normalized one, which is the assertion that makes the rest of it hard to regress. Negative-tested: removing the unwrap, dropping either nested-label read, or letting labelOf read bare strings (so the code prints) each turn it red; restored, green. --- internal/check-describe-group.mjs | 34 ++++++++++++++++++++++++++++++- library/packets/describeGroup.js | 32 ++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/internal/check-describe-group.mjs b/internal/check-describe-group.mjs index 084ca1c0a..441b584b5 100644 --- a/internal/check-describe-group.mjs +++ b/internal/check-describe-group.mjs @@ -91,6 +91,38 @@ eq('no resolver, label present -> the label', describeGroup({ ...G, country_code 'The Abai Sungai live in Malaysia and speak Abai Sungai. Fewer than 2% are evangelical, and no one has been planting churches.') has('resolver returning nothing falls through to the code', describeGroup(G, 'en', { strings: S, countryName: () => '' }), 'live in MYS') +// 3e. ★ A RAW API RECORD MUST DESCRIBE THE SAME PEOPLE AS A NORMALIZED ONE. The live API sends several +// fields as { value, label } pairs, and Number({value:'1'}) is NaN — so on a raw record the GSEC sentence +// did not fall back, it silently VANISHED, and the row read as a people group with no reachedness figure +// at all. upg100 fixed their own normalizer; every other caller passing the raw record lost it the same +// way, which is why the unwrap belongs at the read. The language clause went the same way for the same +// reason: the NAME is at primary_language.label on a raw record. +const RAW = { + name: 'Abai Sungai', + country_code: { value: 'MYS', label: 'Malaysia' }, + primary_language: { value: 'abf', label: 'Abai Sungai' }, + imb_gsec: { value: '1', label: 'Fewer than 2% evangelical' }, +} +eq('a raw record reads exactly like the normalized one', describeGroup(RAW, 'en', O), describeGroup(G, 'en', O)) +has('the level sentence survives a nested imb_gsec', describeGroup(RAW, 'en', O), 'Fewer than 2% are evangelical') +has('the language NAME comes from the nested label, not the code', describeGroup(RAW, 'en', O), 'speak Abai Sungai.') +hasNot('and the nested code is never printed', describeGroup(RAW, 'en', O), 'abf') +has('a nested country_code still localizes from its value', describeGroup(RAW, 'ar', O), 'ماليزيا') +// The nested label is also the English fall-back when no resolver is injected — the same ladder, reached +// through the raw shape rather than through country_code_label. +has('no resolver -> the nested country label, not the bare code', + describeGroup(RAW, 'en', { strings: S }), 'live in Malaysia') +// An inverted name arrives inverted in the nested shape too, and must still be un-inverted. +has('a nested inverted label is un-inverted', + describeGroup({ ...RAW, primary_language: { value: 'ssh', label: 'Arabic, Shihhi' } }, 'en', O), 'speak Shihhi Arabic.') +// ...and the rule that a MISSING field drops its clause is unchanged by any of this. +eq('a nested pair with an empty label drops the clause rather than printing nothing', + describeGroup({ ...RAW, primary_language: { value: 'abf', label: '' } }, 'en', O), + 'The Abai Sungai live in Malaysia. Fewer than 2% are evangelical, and no one has been planting churches.') +eq('a nested imb_gsec with no value drops the level, not the row', + describeGroup({ ...RAW, imb_gsec: { value: '', label: '' } }, 'en', O), + 'The Abai Sungai live in Malaysia and speak Abai Sungai.') + // 4. missing or nonsense GSEC omits the level rather than inventing one or throwing hasNot('no gsec -> no level sentence', describeGroup({ ...G, imb_gsec: undefined }, 'en', O), 'evangelical') hasNot('gsec 9 is not a level', describeGroup({ ...G, imb_gsec: 9 }, 'en', O), 'evangelical') @@ -114,4 +146,4 @@ if (fails.length) { for (const f of fails) console.error(` ✗ ${f}`) process.exit(1) } -console.log('DESCRIBE-GROUP: PASS — every GSEC level states its own fact, an index-ordered language label is un-inverted without shredding a parenthetical, a missing clause is dropped rather than blanked, the country is localized from its code, and an unknown locale falls back per key.') +console.log('DESCRIBE-GROUP: PASS — every GSEC level states its own fact, a raw { value, label } record describes the same people as a normalized one, an index-ordered language label is un-inverted without shredding a parenthetical, a missing clause is dropped rather than blanked, the country is localized from its code, and an unknown locale falls back per key.') diff --git a/library/packets/describeGroup.js b/library/packets/describeGroup.js index b97278553..0d94c8080 100644 --- a/library/packets/describeGroup.js +++ b/library/packets/describeGroup.js @@ -40,15 +40,37 @@ import { naturalLanguageLabel } from '../utils/languageLabel.js' const LEVELS = [0, 1, 2, 3, 4, 5, 6] +/** + * Unwrap the API's { value, label } pair to its VALUE — the same flattening normalize.js:22 applies, done + * here so a caller that hands us a RAW record gets the same answer as one that hands us a normalized one. + * + * ★ WITHOUT THIS, Number({value:'1'}) IS NaN AND THE WHOLE GSEC SENTENCE DISAPPEARS. Not an error, not a + * fallback: the level clause is simply absent, and the row reads as a people group nobody has a reached- + * ness figure for. upg100 hit it and fixed their own normalizer (3b644893), which fixes their caller and + * nobody else's — every other consumer passing the raw record loses the sentence the same silent way. The + * unwrap belongs here, at the read, because this function's contract is "an API/normalized row" and both + * spellings are in that contract. + */ +const unwrap = (v) => (v && typeof v === 'object' && !Array.isArray(v) && 'value' in v ? v.value : v) + /** The first field present, trimmed; '' when none is. Accepts both the API and normalized spellings. */ const pick = (group, ...names) => { for (const n of names) { - const v = group && group[n] + const v = unwrap(group && group[n]) if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim() } return '' } +/** + * The DISPLAY half of the same pair, and only when the field really is one. A raw record carries the + * human name at `.label`; a normalized one moved it to `_label` and left `` holding a + * CODE. So this returns a label ONLY for a nested object and '' for a bare string — consulting a + * flattened `primary_language` would print "and speak abf", which is the defect the field comment below + * exists to prevent. Same rule, opposite half: pick() takes the value, labelOf() takes the name. + */ +const labelOf = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? String(v.label ?? '').trim() : '') + const fill = (tpl, params) => String(tpl).replace(/\{(\w+)\}/g, (m, k) => (params[k] !== undefined ? params[k] : m)) @@ -98,7 +120,9 @@ export function describeGroup(group, locale, opts = {}) { // always renders. A missing injection is an English country name, never an empty row. const iso = pick(group, 'country_code', 'countryIso', 'ISOAlpha3', 'countryCode') const country = (iso && opts.countryName ? opts.countryName(iso, lc) : '') - || pick(group, 'country_code_label', 'country', 'CountryName', 'countryName') + || pick(group, 'country_code_label') + || labelOf(group && group.country_code) + || pick(group, 'country', 'CountryName', 'countryName') || iso // A language NAME is a proper noun; it is not translated, and there is no source that would. // @@ -112,7 +136,9 @@ export function describeGroup(group, locale, opts = {}) { // 725 of 12,379 rows carry one. naturalLanguageLabel un-inverts it, sharing its split with the // language-family legend so the two readings of the same field cannot drift apart. const language = naturalLanguageLabel( - pick(group, 'primary_language_label', 'LanguageName', 'languageName'), + pick(group, 'primary_language_label') + || labelOf(group && group.primary_language) + || pick(group, 'LanguageName', 'languageName'), ) const whereKey = language && country ? 'desc_where_both' From 12b4d09e32cbf6765618bbeb33d4b159d2f87704 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 14:57:02 +0000 Subject: [PATCH 3/6] The language tree's layout becomes library's; its gospel classification does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driver brief §B, BUILD-LIBRARY-CHART-LANGUAGE-TREE: promote gospel-internet's language tree so a fork can build one from its OWN rows. gospel's scripts/build-language-tree.mjs does two jobs in one file. The generic half is the radial layout, the circle packing and the inverse Web Mercator projection. The other half reads gospel-internet.json, ebible-index.json, gospel-resource-links.json and FCBH verse scopes to classify each language g/y/r, and emits a second, purely gospel output. Moving the file whole would have put eBible and FCBH inside library/, and a fork building from its own roster has no eBible index -- so the literal move is the one shape that would NOT deliver the reuse the brief asks for. So the classification stays with its owner and arrives as `statusOf`, returning an OPAQUE string. Nothing here knows what 'g' means; a consumer colouring by speaker count substitutes one function instead of forking a data pipeline. The status is written on LEAVES ONLY, which is not an omission. Rolling a parent's colour up from its descendants is a RUNTIME decision the chart makes against the reader's current scope -- and the scope changes as the reader filters, so baking it would freeze one answer into the data. gospel confirmed the rollup is policy rather than geometry: nothing in the drawing depends on a parent being the worst of its children. The measured constants keep their reasons: the enclosing circle is the tree's real extent plus room for one label (sized by the deepest ring, packing wasted a third of the board); the poster layout drops dialects (2,653 dialect slots squeezed the 858 languages until ~40% of names could not be printed); and the share of the Mercator square is a parameter with a measured default, because it is what decides how far a reader can zoom out below the opening view. check-language-tree-layout asserts INVARIANTS rather than a golden file -- a snapshot of 4,000 coordinates fails on every legitimate change and says nothing about which property broke. Family circles do not overlap, every roster language is reachable, a missing one warns rather than vanishing, the drawing stays inside its extent budget, the poster layout excludes dialects, the status letter is carried through unexamined, and the same input builds the same tree twice. gospel's own script is untouched: they switch to this and drop their copy in the single owner-land already planned, so nothing of theirs breaks in between. --- internal/check-language-tree-layout.mjs | 105 +++++++++++++ library/scripts/build-language-tree.mjs | 199 ++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 internal/check-language-tree-layout.mjs create mode 100644 library/scripts/build-language-tree.mjs diff --git a/internal/check-language-tree-layout.mjs b/internal/check-language-tree-layout.mjs new file mode 100644 index 000000000..e90115616 --- /dev/null +++ b/internal/check-language-tree-layout.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * check-language-tree-layout — the library language-tree layout, asserted headlessly. + * + * WHAT THIS PROTECTS. library/scripts/build-language-tree.mjs computes, at BUILD time, the drawing a + * fork's whole language tree is made of: every family a roster belongs to, radially laid out and packed + * into one non-overlapping tapestry, projected to lng/lat so a map engine draws it undistorted. Nobody + * proofreads 4,000 node positions. Every failure here is silent and looks like a design choice: + * overlapping families read as a messy drawing, a dropped roster language reads as a shorter list, and a + * tapestry that outgrows its share of the Mercator square just means the reader cannot zoom out as far. + * + * ★ THE GEOMETRY IS ASSERTED AS INVARIANTS, NOT AS A GOLDEN FILE. A snapshot of 4,000 coordinates fails + * on every legitimate change and tells you nothing about which property broke. These check the things + * that must be true of ANY correct layout: circles do not overlap, every roster language is reachable, + * the drawing stays inside its budget, the poster layout excludes dialects, and the same input gives the + * same output twice. + * + * ★ AND THE STATUS IS ASSERTED TO BE OPAQUE. The whole reason the layout is in library/ and gospel's + * eBible/FCBH classification is not: a fork substitutes one function. A test that only ever passes 'g' + * would not notice library growing an opinion about what the letters mean. + * + * Headless, no browser, no network. Exit 0 pass · 1 fail. + */ +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const { buildLanguageTree } = await import(resolve(ROOT, 'library/scripts/build-language-tree.mjs')) + +const fails = [] +const ok = (what, cond, detail = '') => { if (!cond) fails.push(`${what}${detail ? `\n ${detail}` : ''}`) } + +// Three families of different shapes: a deep one, a broad one, and a single-language isolate — the +// isolate matters because a one-node tree is the case a radial layout most easily divides by zero on. +const languoids = [ + { id: 'fam-a', name: 'Alpha', level: 'family', iso: '', parent: '' }, + { id: 'br-a1', name: 'A-One', level: 'family', iso: '', parent: 'fam-a' }, + { id: 'lg-a1', name: 'Aa', level: 'language', iso: 'aaa', parent: 'br-a1' }, + { id: 'lg-a2', name: 'Ab', level: 'language', iso: 'aab', parent: 'br-a1' }, + { id: 'dl-a1', name: 'Aa-north', level: 'dialect', iso: 'aac', parent: 'lg-a1' }, + { id: 'fam-b', name: 'Beta', level: 'family', iso: '', parent: '' }, + { id: 'lg-b1', name: 'Ba', level: 'language', iso: 'bba', parent: 'fam-b' }, + { id: 'lg-b2', name: 'Bb', level: 'language', iso: 'bbb', parent: 'fam-b' }, + { id: 'lg-b3', name: 'Bc', level: 'language', iso: 'bbc', parent: 'fam-b' }, + { id: 'fam-c', name: 'Gamma', level: 'family', iso: 'ccc', parent: '' }, +] +const roster = new Map([['aaa', ['g1']], ['aab', ['g2']], ['bba', ['g3']], ['ccc', ['g4']], ['aac', ['g5']]]) +const build = (over = {}) => buildLanguageTree({ languoids, roster, statusOf: () => 'g', ...over }) + +const out = build() +ok('every node carries an id, a level and a coordinate pair', + out.nodes.every((n) => n.id && n.lv && Array.isArray(n.c) && n.c.length === 2 && n.c.every(Number.isFinite))) +ok('every roster language reaches the tree', roster.size === out.nodes.filter((n) => n.iso && roster.has(n.iso)).length, + `roster ${roster.size}, on the tree ${out.nodes.filter((n) => n.iso && roster.has(n.iso)).length}`) +ok('a roster language absent from the snapshot WARNS rather than vanishing', + build({ roster: new Map([...roster, ['zzz', ['g9']]]) }).warnings.some((w) => /zzz/.test(w))) +ok('a languoid whose parent is outside the snapshot warns', + buildLanguageTree({ languoids: [...languoids, { id: 'orphan', name: 'Orphan', level: 'language', iso: 'ooo', parent: 'nope' }], roster }) + .warnings.some((w) => /orphan/.test(w))) + +// ── THE PACKING INVARIANT: family circles must not overlap. This is the one a reader sees instantly +// and nobody can check by eye across 300 families. +const fams = out.nodes.filter((n) => n.R !== undefined) +let worst = null +for (let i = 0; i < fams.length; i++) { + for (let j = i + 1; j < fams.length; j++) { + const [ax, ay] = fams[i].c, [bx, by] = fams[j].c + const gap = Math.hypot(ax - bx, ay - by) - (fams[i].R + fams[j].R) + if (worst === null || gap < worst.gap) worst = { gap, a: fams[i].id, b: fams[j].id } + } +} +ok('family circles do not overlap', !worst || worst.gap >= 0, worst ? `closest pair ${worst.a}/${worst.b} gap ${worst.gap.toFixed(4)}` : '') + +// ── THE ZOOM BUDGET. The drawing's share of the Mercator square is what decides how far a reader can +// zoom OUT below the opening view; it is a parameter precisely so a consumer can trade that off. +const lngSpan = (nodes) => { const xs = nodes.map((n) => n.c[0]); return Math.max(...xs) - Math.min(...xs) } +ok('the tapestry stays inside its extent budget', lngSpan(out.nodes) <= 0.17 * 360 + 1e-6, + `spans ${lngSpan(out.nodes).toFixed(2)}° of 360°`) +ok('a smaller extentFraction really does draw smaller', + lngSpan(build({ extentFraction: 0.08 }).nodes) < lngSpan(out.nodes)) + +// ── THE POSTER LAYOUT is a second, separate layout, and its whole reason is that a sheet cannot zoom. +const dialects = out.nodes.filter((n) => n.lv === 'd') +ok('dialects exist on screen', dialects.length > 0) +ok('...and get NO poster slot, so language names are not squeezed off the sheet', + dialects.every((n) => n.pc === undefined)) +ok('languages DO get a poster slot', out.nodes.filter((n) => n.lv === 'l').every((n) => Array.isArray(n.pc))) + +// ── STATUS IS OPAQUE. library must carry the letter without knowing what it means. +ok('an arbitrary status string is carried through, not normalised', + build({ statusOf: () => 'whatever-a-fork-calls-it' }).nodes.some((n) => n.st === 'whatever-a-fork-calls-it')) +ok('status is written on leaves only — a parent rollup is the CHART\'s runtime job, against the reader\'s scope', + build().nodes.filter((n) => n.lv === 'f' || n.lv === 'b').every((n) => n.st === undefined)) +ok('no statusOf at all is legal and simply omits st', + buildLanguageTree({ languoids, roster }).nodes.every((n) => n.st === undefined)) + +// ── DETERMINISM. A build-time artifact that differs run to run makes every diff unreadable. +ok('the same input builds the same tree twice', JSON.stringify(build().nodes) === JSON.stringify(build().nodes)) + +if (fails.length) { + console.error('LANGUAGE-TREE-LAYOUT: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`LANGUAGE-TREE-LAYOUT: PASS — ${out.nodes.length} nodes, families packed without overlap, every roster language reachable, the poster layout drops dialects, and the status letter stays opaque to library.`) diff --git a/library/scripts/build-language-tree.mjs b/library/scripts/build-language-tree.mjs new file mode 100644 index 000000000..073f66762 --- /dev/null +++ b/library/scripts/build-language-tree.mjs @@ -0,0 +1,199 @@ +/** + * build-language-tree.mjs — the LANGUAGE TREE layout, as a library function a fork can call on ITS rows. + * + * Every language family a roster belongs to, drawn as a radial tree (family -> branches -> languages -> + * dialects), each family's enclosing circle packed against the others into one non-overlapping tapestry. + * LAYOUT IS COMPUTED HERE, ONCE, AT BUILD TIME, so a phone does none of it, and the output is projected + * to lng/lat so a map engine can draw it undistorted on the GPU. + * + * ★ WHY THIS IS IN library/ AND WHAT DELIBERATELY IS NOT (Driver brief §B, "make it reusable"). This was + * extracted from gospel-internet's scripts/build-language-tree.mjs, which does TWO jobs in one file: this + * layout, and a classification of each language from eBible, FCBH verse scopes and a screened link table. + * Moving the file whole would have put those sources inside library/ and a fork building the tree from + * its OWN rows has no eBible index — the literal move is the one shape that would not deliver the reuse. + * So the CLASSIFICATION STAYS WITH ITS OWNER and arrives here as `statusOf`, a function returning an + * OPAQUE string. Nothing in this file knows what 'g' means, and a consumer colouring by speaker count, + * or by anything else, substitutes one function instead of forking a data pipeline. + * + * ★ THE STATUS IS WRITTEN ON LEAVES ONLY, ON PURPOSE. A family or branch gets NO status here. Rolling a + * parent's colour up from its descendants ("worst of the languages below it") is a RUNTIME decision the + * chart makes against the reader's current scope — which is not knowable at build time, because it + * changes as the reader filters. Baking it would freeze one scope's answer into the data. + * + * The numbers below are measured, not chosen. Where a constant has a reason it is stated; changing one + * without re-measuring is how this drawing gets quietly worse. + */ + +/** A tree whose single-child branch chains are collapsed for DRAWING; the skipped names are kept in `via`. */ +function collapse(n) { + n.kids = n.kids.map((k) => { + let c = k + const via = [] + while (c.level === 'family' && c.kids.length === 1 && c.kids[0].level === 'family') { via.push(c.name); c = c.kids[0] } + if (via.length) c.via = via + return c + }) + n.kids.sort((a, b) => a.name.localeCompare(b.name)) + n.kids.forEach(collapse) +} + +const leaves = (n) => (n._leaves ??= (n.kids.length ? n.kids.reduce((s, k) => s + leaves(k), 0) : 1)) +const depth = (n) => (n._depth ??= (n.kids.length ? 1 + Math.max(...n.kids.map(depth)) : 0)) +const clearMemo = (n) => { delete n._leaves; delete n._depth; n.kids.forEach(clearMemo) } +const hideDialects = (n) => { n._allKids = n.kids; n.kids = n.kids.filter((k) => k.level !== 'dialect'); n.kids.forEach(hideDialects) } +const showDialects = (n) => { if (n._allKids) { n.kids = n._allKids; delete n._allKids } n.kids.forEach(showDialects) } + +/** One family: leaves evenly around the circle, parents at the mean of their children, radius by depth. */ +function layoutFamily(root, { ARC, STEP_MIN }) { + const nLeaves = leaves(root), d = depth(root) + const step = d ? Math.max(STEP_MIN, (nLeaves * ARC) / (2 * Math.PI * d)) : 0 + let slot = 0 + const place = (n, lvl) => { + if (!n.kids.length) { n._a = ((slot + 0.5) / nLeaves) * 2 * Math.PI; slot++ } + else { n.kids.forEach((k) => place(k, lvl + 1)); n._a = (n.kids[0]._a + n.kids[n.kids.length - 1]._a) / 2 } + n._r = lvl * step + } + place(root, 0) + // The enclosing circle is the tree's REAL extent (its outermost node) plus room for one label, NOT the + // deepest possible ring: sized by the ring, the packing wasted about a third of the board. + let far = 0 + const walk = (n) => { far = Math.max(far, n._r); n.kids.forEach(walk) } + walk(root) + root._R = far + 1.6 + Math.min(3, Math.sqrt(nLeaves) * 0.25) + return root +} + +/** Largest first, at the nearest free spot on a spiral: circles never overlap and the whole fills a disc. */ +function pack(roots) { + const placed = [] + for (const root of [...roots].sort((a, b) => b._R - a._R || a.name.localeCompare(b.name))) { + const R = root._R + let best = null + if (!placed.length) best = [0, 0] + for (let t = 0; !best; t += 0.05) { + const rad = 0.35 * t * Math.max(1, R * 0.2) + const x = rad * Math.cos(t), y = rad * Math.sin(t) + if (placed.every((p) => Math.hypot(p.x - x, p.y - y) >= p.R + R + 0.8)) best = [x, y] + } + placed.push({ x: best[0], y: best[1], R }) + root._cx = best[0]; root._cy = best[1] + } + return placed +} + +const LV = { family: 'f', language: 'l', dialect: 'd' } + +/** + * @param {Object} input + * @param {Array} input.languoids [{ id, name, level, iso, parent }] — a Glottolog-shaped subset + * @param {string[]} [input.notInGlottolog] ISO codes the snapshot lacks; each becomes a leaf under one + * labelled root, so no roster language is left off the tree + * @param {Map} input.roster iso -> whatever the consumer wants on the node as `g` + * @param {Function} [input.statusOf] (iso) => opaque string, written as `st` on LEAVES only + * @param {Function} [input.nameOf] (iso) => display name for a notInGlottolog leaf + * @param {Object} [input.screen] { ARC, STEP_MIN } — the on-screen layout + * @param {Object} [input.poster] { ARC, STEP_MIN } — the print layout, stored as `pc` / `pR` + * @param {number} [input.extentFraction] share of the Mercator square the tapestry spans + * @returns {{ nodes: Array, coverage: Object, warnings: string[] }} + */ +export function buildLanguageTree({ + languoids, + notInGlottolog = [], + roster = new Map(), + statusOf = () => undefined, + nameOf = (iso) => iso, + screen = { ARC: 1.0, STEP_MIN: 3.2 }, + poster = { ARC: 1.0, STEP_MIN: 3.2 }, + extentFraction = 0.17, + missingRootId = 'not-in-glottolog', + missingRootName = 'Not in Glottolog', +} = {}) { + const warnings = [] + const L = new Map((languoids || []).map((l) => [l.id, { ...l, kids: [] }])) + const roots = [] + for (const n of L.values()) { + if (n.parent && L.has(n.parent)) L.get(n.parent).kids.push(n) + else if (!n.parent) roots.push(n) + else warnings.push(`${n.id} (${n.name}) has a parent ${n.parent} outside the snapshot`) + } + if (notInGlottolog.length) { + const miss = { id: missingRootId, name: missingRootName, level: 'family', iso: '', parent: '', kids: [] } + for (const iso of notInGlottolog) { + const leaf = { id: 'iso-' + iso, name: nameOf(iso) || iso, level: 'language', iso, parent: missingRootId, kids: [] } + L.set(leaf.id, leaf); miss.kids.push(leaf) + } + L.set(missingRootId, miss); roots.push(miss) + } + const isoToNode = new Map() + for (const n of L.values()) if (n.iso) isoToNode.set(n.iso, n) + for (const iso of roster.keys()) if (!isoToNode.has(iso)) warnings.push(`language ${iso} of the roster is not on the tree`) + + roots.forEach(collapse) + + // ★ THE POSTER LAYOUT IS COMPUTED FIRST, AND WITHOUT DIALECTS. Resources are catalogued per LANGUAGE, + // and on a sheet that cannot zoom, 2,653 dialect slots squeezed the 858 languages until ~40% of the + // language names could not be printed without overlap. The screen layout keeps them, because the map + // engine hides labels that do not fit and reveals them as the reader zooms — a printed sheet has one + // scale and must fit every name at it. Stored per node in units of its own extent, so it is scale-free. + roots.forEach(hideDialects); roots.forEach(clearMemo) + roots.forEach((r) => layoutFamily(r, poster)) + { + const pl = pack(roots) + let pext = 1 + for (const p of pl) pext = Math.max(pext, Math.abs(p.x) + p.R, Math.abs(p.y) + p.R) + const keep = (n, root) => { + n._pu = [(root._cx + n._r * Math.cos(n._a)) / pext, (root._cy + n._r * Math.sin(n._a)) / pext] + n.kids.forEach((k) => keep(k, root)) + } + for (const root of roots) { keep(root, root); root._pR = root._R / pext } + } + roots.forEach(showDialects); roots.forEach(clearMemo) + roots.forEach((r) => layoutFamily(r, screen)) + const placed = pack(roots) + + // ── to lng/lat through the INVERSE of Web Mercator, uniformly scaled, so the drawing is undistorted ── + let ext = 1 + for (const p of placed) ext = Math.max(ext, Math.abs(p.x) + p.R, Math.abs(p.y) + p.R) + // ★ HOW MUCH OF THE SQUARE THE TAPESTRY SPANS DECIDES HOW FAR A READER CAN ZOOM OUT, and that is the + // whole reason this number is a parameter with a measured default. The engines stop at zoom 0 (the + // whole Mercator square), so the SMALLER the drawing, the more room there is below its opening view. + // At 68% there was about one level of room (Driver, 2026-09-27: "I need to be able to zoom out a bit + // more"); at 0.17 there are two. + const K = extentFraction / ext + const toLngLat = (u, v) => { + const X = 0.5 + u * K, Y = 0.5 - v * K + const lng = (X - 0.5) * 360 + const lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * Y))) * 180 / Math.PI + return [Math.round(lng * 1e5) / 1e5, Math.round(lat * 1e5) / 1e5] + } + + // Edges are NOT emitted: every node carries its parent id and both coordinates, so the chart draws each + // parent->child line itself. It halves the file. + const nodes = [] + const emit = (n, root, parentId, isRoot) => { + const x = root._cx + n._r * Math.cos(n._a), y = root._cy + n._r * Math.sin(n._a) + const lv = isRoot && n.level === 'family' ? 'f' : n.level === 'family' ? 'b' : LV[n.level] || 'b' + const node = { id: n.id, n: n.name, lv, p: parentId || '', c: toLngLat(x, y) } + if (n.iso) node.iso = n.iso + if (n.via) node.via = n.via + if (lv === 'l' || (lv === 'd' && n.iso && roster.has(n.iso))) { + const st = statusOf(n.iso) + if (st !== undefined && st !== null && st !== '') node.st = st + } + if (n.iso && roster.has(n.iso)) node.g = roster.get(n.iso) + if (lv === 'f' || isRoot) { node.R = Math.round(root._R * K * 1e6) / 1e6; node.pR = Math.round(root._pR * 1e5) / 1e5 } + if (n._pu) node.pc = [Math.round(n._pu[0] * 1e5) / 1e5, Math.round(n._pu[1] * 1e5) / 1e5] + nodes.push(node) + for (const k of n.kids) emit(k, root, n.id, false) + } + for (const root of roots) emit(root, root, '', true) + + const levels = nodes.reduce((a, n) => ((a[n.lv] = (a[n.lv] || 0) + 1), a), {}) + return { + nodes, + coverage: { nodes: nodes.length, levels, families: roots.length, not_in_glottolog: notInGlottolog.length }, + warnings, + } +} + +export default buildLanguageTree From 273117f8c0625d61c011f31a9a454463c3d8a378 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 15:06:45 +0000 Subject: [PATCH 4/6] A composite embedded bare dropped tokenUrl, and passed because a ProfileLoader sat above it PM-tok, PRODUCTION-MATRIX.md:285. `tokenUrl` names a token SERVICE a host points at instead of pasting a key: the element asks that URL when it was handed no token. Only ProfileLoader ever read it. A pane composite bakes its children in rather than mounting them through ProfileLoader, so a composite embedded BARE -- one tag on a partner's page, which is the entire point of a composite -- dropped a tokenUrl-only config on the floor. It worked in every place anyone looked, because a ProfileLoader elsewhere on the page fetched and published the token: the composite was passing an exam somebody else sat. And the failure wears the wrong face. No token is "No Mapbox access token provided", 0 tiles, no canvas -- identical to a missing `tk`, which IS threaded -- so the natural reading is that the host mistyped their config rather than that a knob is unread. The rule lives in library/composables, not in each composite, because it has already been copied once (ProfileLoader.vue:132) and a third and fourth copy is how one of them later learns to ask ALWAYS -- which a token service billed per call notices before we do. The pure decision is split into host-token-url.js with NO framework import, because a fresh worktree has no node_modules and a module importing vue is unloadable by the gate that proves it right. Same shape as describeGroup keeping countryName injectable. check-composite-token-url takes its expectation from ProfileLoader -- the contract a composite REPLACES -- not from the composites it audits, so it cannot pass by construction; it exercises the decision rather than grepping for it; and it finds its subjects by a PROPERTY, a production bundle already forwarding `tk: hostToken`, so a third composite is covered the day it is written. stack-demo has the same shape and is excluded by its STAGE, not by name, so promoting it pulls it in automatically. Negative-tested 5 ways. The fifth found a bug in the gate itself: /tokenUrl/ also matches `tokenUrlX`, so the check meant to notice the contract being renamed could not fail. It is \btokenUrl\b now. A guard that cannot fail is the one nobody ever finds by reading. --- internal/check-composite-token-url.mjs | 101 ++++++++++++++++++ library/composables/host-token-url.js | 23 ++++ library/composables/useHostTokenUrl.js | 44 ++++++++ .../dashboard-plus-grid.vue | 12 +++ .../research-map-plus-grid.vue | 12 +++ 5 files changed, 192 insertions(+) create mode 100644 internal/check-composite-token-url.mjs create mode 100644 library/composables/host-token-url.js create mode 100644 library/composables/useHostTokenUrl.js diff --git a/internal/check-composite-token-url.mjs b/internal/check-composite-token-url.mjs new file mode 100644 index 000000000..3495c1187 --- /dev/null +++ b/internal/check-composite-token-url.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * check-composite-token-url — a pane composite must honour `tokenUrl`, not only `tk`. + * + * THE DEFECT (PM-tok, PRODUCTION-MATRIX.md:285). `tokenUrl` names a token SERVICE the element asks when + * it was handed no key. Only ProfileLoader read it. A composite bakes its children in instead of mounting + * them through ProfileLoader, so a composite embedded BARE — one tag on a partner's page, the entire + * point of a composite — dropped a tokenUrl-only config: no token, "No Mapbox access token provided", + * 0 tiles, no canvas. With an outer ProfileLoader on the page it worked, because THAT fetched and + * published the token, so the composite was passing an exam somebody else sat. + * + * ★ THE EXPECTATION COMES FROM ProfileLoader, NOT FROM THE COMPOSITES. A gate that reads the files it + * audits to decide what they should contain passes by construction (COMMON.md, twice). ProfileLoader is + * the contract a composite REPLACES, so this reads ProfileLoader for the rule — that a tokenUrl exists + * and is fetched only when no token is at hand — and then requires each composite to meet it. If + * ProfileLoader ever stops doing this, that is a finding here rather than a silent green. + * + * ★ WHICH COMPOSITES, DEFINED BY A PROPERTY rather than a list: a bundle that already forwards the host's + * `tk` to a baked-in child, at production stage. See the comment at the scan below. + * + * Source-level and headless BY DESIGN: the behavioural twin (check:composite-embed-knobs) needs a + * browser and only rega may run one. Exit 0 pass · 1 fail. + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const TAG = 'COMPOSITE-TOKEN-URL' +const fails = [] + +// ── the rule, read off the contract ──────────────────────────────────────────────────────────── +const loader = readFileSync(join(ROOT, 'library/ProfileLoader.vue'), 'utf8') +// \b on BOTH sides: a bare /tokenUrl/ also matches `tokenUrlX`, so this check could not see the +// contract being renamed out from under it — caught by its own negative test, which is the only reason +// anybody would ever notice a guard that cannot fail. +if (!/\btokenUrl\b/.test(loader)) { + fails.push('library/ProfileLoader.vue no longer mentions tokenUrl — the contract this gate derives from ' + + 'has moved. Re-read it before trusting anything below.') +} +if (!/fetchMapToken\s*\(/.test(loader)) { + fails.push('ProfileLoader no longer calls fetchMapToken — the rule changed shape; re-derive this gate.') +} + +// ── the decision itself, exercised rather than grepped ───────────────────────────────────────── +const { shouldFetchTokenUrl, readHostTokenUrl } = + await import(resolve(ROOT, 'library/composables/host-token-url.js')) +const dec = [ + ['a tokenUrl and nothing else -> ask', { tokenUrl: 'https://t.example/token' }, true], + ['the host pasted a key -> do NOT ask', { tokenUrl: 'https://t.example/token', token: 'pk.aaa' }, false], + ['the page already published one -> do NOT ask', { tokenUrl: 'https://t.example/token', sharedToken: 'pk.bbb' }, false], + ['no tokenUrl -> nothing to ask', {}, false], +] +for (const [name, input, want] of dec) { + if (shouldFetchTokenUrl(input) !== want) fails.push(`shouldFetchTokenUrl: ${name}`) +} +if (readHostTokenUrl({ tokenUrl: 'https://t.example/t' }) !== 'https://t.example/t') fails.push('readHostTokenUrl drops a real URL') +for (const bad of [{ tokenUrl: 5 }, { tokenUrl: '' }, {}, null, undefined]) { + if (readHostTokenUrl(bad) !== '') fails.push(`readHostTokenUrl should ignore ${JSON.stringify(bad)} — a non-string is not a URL`) +} + +// ── and every composite must be wired to it ──────────────────────────────────────────────────── +const manifest = JSON.parse(readFileSync(join(ROOT, 'public/dx-maps/manifest.json'), 'utf8')) +// ★ WHICH FILES, DEFINED BY A PROPERTY RATHER THAN A LIST. A composite in scope here is one that already +// forwards the host's KEY to a baked-in child — `tk: hostToken` — because the rule is simply "if you +// forward a token, forward the token SERVICE too". Deriving it that way means a third composite is +// covered the day it is written, and it cannot be skipped by forgetting to add a name here. +// +// ★ AND STAGE COMES FROM THE MANIFEST, NOT FROM AN EXEMPTION LIST. stack-demo has the same shape and is +// stage=experimental: no partner embeds it, so a tokenless map there costs a demo, not a customer. It is +// excluded by its STAGE, so promoting it to production pulls it into this gate automatically — which is +// the one moment anybody would otherwise forget. +const composites = [] +for (const b of manifest.bundles || []) { + if (b.stage !== 'production') continue + const p = join(ROOT, 'plugin-designs', b.bundle, `${b.bundle}.vue`) + if (!existsSync(p)) continue + const src = readFileSync(p, 'utf8') + if (/tk:\s*hostToken\.value/.test(src)) composites.push([`${b.bundle}.vue`, src]) +} +if (composites.length < 2) { + fails.push(`expected at least the two known production pane composites, found ${composites.length} — the ` + + 'detection stopped matching and this gate would pass while checking nothing.') +} +for (const [name, src] of composites) { + if (!/useHostTokenUrl\s*\(/.test(src)) { + fails.push(`${name} forwards the host's tk but never calls useHostTokenUrl(), so a bare embed with a ` + + 'tokenUrl-only config gets no token at all') + } + if (!/tokenUrl:\s*hostTokenUrl\.value/.test(src)) { + fails.push(`${name} does not forward tokenUrl into its children's profile-config, so a child that ` + + "outlives the composite's fetch cannot ask for itself") + } +} + +if (fails.length) { + console.error(`${TAG}: FAIL`) + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`${TAG}: PASS — ${composites.length} composites read tokenUrl and forward it, and the service is asked only when no token is already at hand.`) diff --git a/library/composables/host-token-url.js b/library/composables/host-token-url.js new file mode 100644 index 000000000..4d9b108ae --- /dev/null +++ b/library/composables/host-token-url.js @@ -0,0 +1,23 @@ +/** + * host-token-url.js — the `tokenUrl` rule, with NO framework import, so a gate can run it. + * + * Split out of useHostTokenUrl.js deliberately. The composable needs vue's computed/watch; a headless + * gate has no node_modules in a fresh worktree, so a module that imports vue is unloadable by the test + * that proves it right. Same shape as library/packets/describeGroup.js keeping countryName injectable: + * the DECISION lives where it can be executed, the WIRING lives where it needs a framework. + */ + +/** + * Ask the token service ONLY when nothing else supplied a token. + * Mirrors ProfileLoader.vue:133 — `url && !tk && !shared` — because a composite must behave the way the + * loader it replaces behaves, and because a service billed per call should be asked once, not always. + */ +export function shouldFetchTokenUrl({ tokenUrl = '', token = '', sharedToken = '' } = {}) { + return Boolean(tokenUrl) && !token && !sharedToken +} + +/** The `tokenUrl` a host actually wrote, or ''. A non-string is not a URL and is not passed on. */ +export function readHostTokenUrl(cfg) { + const u = cfg && cfg.tokenUrl + return typeof u === 'string' && u ? u : '' +} diff --git a/library/composables/useHostTokenUrl.js b/library/composables/useHostTokenUrl.js new file mode 100644 index 000000000..1b638aa72 --- /dev/null +++ b/library/composables/useHostTokenUrl.js @@ -0,0 +1,44 @@ +/** + * useHostTokenUrl.js — a composite honours `tokenUrl` the way ProfileLoader does. + * + * THE DEFECT (PM-tok, PRODUCTION-MATRIX.md:285). `tokenUrl` is a token SERVICE a host names in its + * profile-config instead of pasting a key: the element asks that URL when it was handed no token. Only + * ProfileLoader ever read it. A pane composite bakes its children in rather than mounting them through + * ProfileLoader, so a composite embedded BARE — the whole point of a composite, one tag on a partner's + * page — dropped a tokenUrl-only config on the floor. With an outer ProfileLoader on the page it worked, + * because THAT fetched and published the token; the composite was passing an exam somebody else sat. + * + * ★ THE FAILURE IS TOKENLESS MAPBOX, WHICH LOOKS LIKE A DIFFERENT BUG. No token means "No Mapbox access + * token provided", 0 tiles and no canvas — the same symptom as a missing `tk`, which IS threaded, so the + * obvious reading is that the host mis-typed their config rather than that a knob is unread. + * + * ★ AND IT LIVES HERE RATHER THAN IN EACH COMPOSITE because there are two composites today and the rule + * has already been copied once (ProfileLoader.vue:132). A third and fourth copy of "ask only when no + * token is at hand" is how one of them later learns to ask always, and a token service billed per call + * notices that before we do. + */ +import { computed, watch } from 'vue' +import { useSharedMapToken, fetchMapToken } from './useSharedMapToken.js' +import { shouldFetchTokenUrl, readHostTokenUrl } from './host-token-url.js' + +export { shouldFetchTokenUrl, readHostTokenUrl } + +/** + * Wire a composite's host config to the shared token service. + * @param {import('vue').Ref} hostConfig parsed profile-config of the COMPOSITE + * @param {import('vue').Ref} hostToken the host's own `tk`, which always wins + * @returns {import('vue').ComputedRef} the tokenUrl, to forward into children's profile-config + */ +export function useHostTokenUrl(hostConfig, hostToken) { + const sharedToken = useSharedMapToken() + const tokenUrl = computed(() => readHostTokenUrl(hostConfig?.value)) + // immediate, because a host that set tokenUrl before mount is the ordinary case, not the edge one. + watch(tokenUrl, (url) => { + if (shouldFetchTokenUrl({ tokenUrl: url, token: hostToken?.value || '', sharedToken: sharedToken.value })) { + fetchMapToken(url) + } + }, { immediate: true }) + return tokenUrl +} + +export default useHostTokenUrl diff --git a/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue b/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue index 978813493..7f552ab02 100644 --- a/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue +++ b/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue @@ -17,6 +17,7 @@ import { reactive, provide, inject, ref, computed, watchEffect, watch, onMounted import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, getRuntimeLocale, detectLocale } from '@map/i18n/index.js' import { resolveEmbedBase } from '@map/utils/embedBase.js' import { useSharedMapToken, publishMapToken } from '@map/composables/useSharedMapToken.js' +import { useHostTokenUrl } from '@map/composables/useHostTokenUrl.js' import StackLayout from '@map/layouts/StackLayout.vue' import ResearchDashboard from '../research-dashboard/research-dashboard.vue' import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue' @@ -323,6 +324,16 @@ watch(textDir, reflectDir) // default here. The shared page token keeps a late-arriving token working (the leaf FETCHES one), // which is what the composable exists for; the host's own tk still wins. const sharedToken = useSharedMapToken() +// ★ tokenUrl: A TOKEN SERVICE, NOT A TOKEN, AND ONLY ProfileLoader EVER READ IT (PM-tok). A host may +// name a URL to ASK for a key instead of pasting one. This composite bakes its children in rather than +// mounting them through ProfileLoader, so embedded BARE — one tag on a partner's page, which is the +// entire point of a composite — a tokenUrl-only config reached nobody: no token, "No Mapbox access +// token provided", 0 tiles, no canvas. With an outer ProfileLoader on the page it worked, because THAT +// fetched and published; the composite was passing an exam somebody else sat. The failure also wears +// the wrong face: it is identical to a missing `tk`, which IS threaded, so it reads as a host typo. +// Forwarded to the children too, so a child that outlives this fetch can still ask. +const hostTokenUrl = useHostTokenUrl(hostConfig, hostToken) + const mapboxToken = computed(() => hostToken.value || sharedToken.value || '') provide('mapboxToken', mapboxToken) @@ -340,6 +351,7 @@ provide('profileConfig', computed(() => ({ // Spread last so a host that supplies none leaves the key absent, and never overwrite the two // settings above — those are this composite's own layout and share decisions, not the host's. ...(hostToken.value ? { tk: hostToken.value } : {}), + ...(hostTokenUrl.value ? { tokenUrl: hostTokenUrl.value } : {}), ...(hostProvider.value ? { provider: hostProvider.value } : {}), ...(hostLang.value ? { lang: hostLang.value } : {}), ...(hostList.value ? { list: hostList.value } : {}), diff --git a/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue b/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue index 6a466d869..3d95bc239 100644 --- a/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue +++ b/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue @@ -60,6 +60,7 @@ import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, getRuntimeLocale, det import StackLayout from '@map/layouts/StackLayout.vue' import { resolveEmbedBase } from '@map/utils/embedBase.js' import { publishMapToken } from '@map/composables/useSharedMapToken.js' +import { useHostTokenUrl } from '@map/composables/useHostTokenUrl.js' // ★ THE HOST'S INLINE STYLE MUST NOT LAND ON OUR ROOT. // This SFC is mounted AS a custom element, so a style attribute the HOST page puts on the tag falls @@ -100,6 +101,16 @@ const hostToken = computed(() => { return typeof tk === 'string' && tk ? tk : '' }) if (hostToken.value) publishMapToken(hostToken.value) +// ★ tokenUrl: A TOKEN SERVICE, NOT A TOKEN, AND ONLY ProfileLoader EVER READ IT (PM-tok). A host may +// name a URL to ASK for a key instead of pasting one. This composite bakes its children in rather than +// mounting them through ProfileLoader, so embedded BARE — one tag on a partner's page, which is the +// entire point of a composite — a tokenUrl-only config reached nobody: no token, "No Mapbox access +// token provided", 0 tiles, no canvas. With an outer ProfileLoader on the page it worked, because THAT +// fetched and published; the composite was passing an exam somebody else sat. The failure also wears +// the wrong face: it is identical to a missing `tk`, which IS threaded, so it reads as a host typo. +// Forwarded to the children too, so a child that outlives this fetch can still ask. +const hostTokenUrl = useHostTokenUrl(hostConfig, hostToken) + // ★ THE PROVIDER IS A HOST KNOB TOO, AND FORGETTING IT BREAKS THE KEYLESS CASE ENTIRELY. // resolveMapProvider's ladder starts at profile-config.provider, and the child reads its OWN @@ -495,6 +506,7 @@ const sections = computed(() => [ // sharedToken`, and an empty string would be falsy anyway, but an absent key keeps the // child's own config identical to what its standalone leaf sets. ...(hostToken.value ? { tk: hostToken.value } : {}), + ...(hostTokenUrl.value ? { tokenUrl: hostTokenUrl.value } : {}), ...(hostProvider.value ? { provider: hostProvider.value } : {}), ...(hostLang.value ? { lang: hostLang.value } : {}), ...(hostList.value ? { list: hostList.value } : {}), From 09e09d8b65312f1f2f57de815b8d6cf3e840b937 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 15:32:52 +0000 Subject: [PATCH 5/6] The share button had to look smaller; its tap target did not have to shrink with it rega's 3c3bde71 fixed a real defect: ShareButton is the map toolbar's 40px round control plus a 2px ring, so it measured 44px inside a 38px bar and overflowed it. At 28px + ring it matches the search box, which is what the Driver asked for. The side effect is that the TAP TARGET went to 32px, and this repo states its own floor twice -- DataTable.vue:294, "44px is the accessible tap target; anything smaller is a miss on a phone", and LegendDesktop.vue:124 sizes rows to keep it. Nothing in the change said otherwise; the target simply followed the drawing, which is what tap targets do unless someone stops them. A transparent box centred on the button restores it without moving a pixel: absolutely positioned so it changes no layout, and part of the button so it takes the same clicks. The visual stays exactly as the Driver approved. 44 WIDE, 38 TALL, AND THE 6px IS DELIBERATE. The bar is 38px, so a 44px-tall target would hang 3px over the filters row and start taking taps meant for a filter -- one miss traded for another. 44x38 is the largest target that costs nothing else. Closing the last 6px needs a taller bar, and the Driver has just decided that axis in the other direction, so it is asked rather than assumed. --- .../components/dashboards/DashboardTopBar.vue | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardTopBar.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardTopBar.vue index 5eef991a4..e9892529a 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardTopBar.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardTopBar.vue @@ -214,6 +214,27 @@ useShadowStyles(` .rd-topbar .rd-ttl{ font-weight:800; font-size:var(--fs-lg,13px); letter-spacing:.2px; color:var(--rd-header-ink,#f3f3f1); } .rd-topbar .rd-ttl .dot{ color:var(--rd-accent,#e67e22); } .rd-topbar .rd-spacer{ flex:1; } + /* SHARE in this bar (Driver 2026-09-28: "too large"). ShareButton is the map toolbar's 40px round button + (36px under 768px) + a 2px rainbow ring, so it measured 44px in a 38px bar: taller than the bar itself. + Here it takes the bar's own control scale instead: 28px + the ring = 32px, the search box's height, with the + icon at the search icon's 15px. Scoped to .rd-topbar, so the map toolbars keep their 40px button. */ + .rd-topbar .share-wrap .mcb{ width:28px; height:28px; position:relative; } + .rd-topbar .share-wrap .mcb svg{ width:15px; height:15px; } + /* ★ THE BUTTON GOT SMALLER TO LOOK RIGHT; THE THING A FINGER HAS TO HIT DID NOT HAVE TO. Shrinking the + control to 28px (32 with the ring) also shrank the TAP TARGET to 32px, and this repo states its own + floor in two places — DataTable.vue:294 "44px is the accessible tap target; anything smaller is a miss + on a phone", and LegendDesktop.vue:124 sizing rows to keep it. A transparent box centred on the button + restores the target without moving a pixel of the drawing: it is absolutely positioned, so it changes + no layout, and it is part of the button, so it takes the same clicks. + ★ 44 WIDE, 38 TALL, AND THE SHORTFALL IS DELIBERATE RATHER THAN OVERLOOKED. The bar is 38px + (--rd-topbar-h), so 44 vertical cannot fit inside it: the extra 3px each side would hang over the + filters row below and start eating taps meant for a filter — trading one miss for another. 44x38 is + the largest target that costs nothing. Closing the last 6px needs a taller bar, which is a look + decision the Driver already made in the other direction, so it is asked rather than assumed. */ + .rd-topbar .share-wrap .mcb::after{ + content:''; position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); + width:44px; height:var(--rd-topbar-h,38px); + } /* primary FILTER search bar (shared SearchBar → bus.setSearch), lifted into the topbar row so it sits ABOVE the filters-dropdowns band (card BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG). Compact it for the 52px bar: shrink the primitive's padding/height so the white box fits. From 9614910e96e195977279475b173fca35a0890174 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 15:39:04 +0000 Subject: [PATCH 6/6] The factory stopped being a one-liner, so a whole bundle stopped being looked at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:i18n-keys-exist reported PASS over two planted poison keys in gospel-internet, three rounds running, and over 12 real keys its new panels name that exist in no locale. Reproduced before changing anything: with both poisons on disk the gate found 1 missing key; it now finds 15 on that same tree — the 2 poisons plus the 12. TWO CAUSES, EITHER ALONE ENOUGH TO HIDE EVERYTHING. factoriesIn() demanded the exact shape `export function useX() { return useOptionalT('ns') }`. useGiT is no longer that. It became a WRAPPER -- `const t = useOptionalT()` and a returned arrow adding an English-fallback argument -- which is ordinary refactoring and moved the bundle outside this gate: useGiT was not a factory, so `const T = useGiT()` was not an alias, so every T('…') was not a call site. Discovery now also accepts an exported function whose body BUILDS a translator, reading a namespace only when the call states one literally. And when a namespace could not be read, `if (ns === null) continue` dropped the binding in silence. That is the line that turned "I cannot see this" into "there is nothing to see", and it is why the bundle passed by having nothing looked at. Note what it is NOT: useOptionalT('') gives ns === '', an author DECLARING no namespace, which was already handled and counted. Empty is a decision; null was this gate's own ignorance. Such a binding is now registered for the FULL-KEY pass anyway -- a fully dotted key is checkable whatever the namespace is, which is what catches both poisons -- while the BARE calls it genuinely cannot qualify are counted, listed by binding, and FAIL. The gate states which translators it could not resolve instead of scoring them clean. Both poisons are pinned as permanent fixtures HERE rather than left in gospel's tree, so the proof does not depend on another agent keeping deliberately-broken code on main. The fixture asserts the four things that were independently broken: a wrapper factory is discovered, no namespace is guessed for one that states none, its binding still reaches the full-key pass, and a bare key behind it is recorded rather than skipped. Negative-tested: restoring the silent `continue`, or narrowing discovery back to one-liners, each fails the self-test; restored, it passes. NOT wired into gates:pre and left on demand, as it already was. It reports 13 real missing keys on today's main -- 12 of gospel's panel keys plus a pre-existing legend.noList -- and those are their owners' to add, not this gate's to hide. --- .../scripts/check-i18n-keys-exist.mjs | 126 +++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs b/plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs index 652904e98..e71924931 100644 --- a/plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs +++ b/plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs @@ -134,7 +134,31 @@ const files = listFiles((p) => /\.(vue|js|mjs)$/.test(p) && !p.includes('/script // back here. A factory whose namespace this gate cannot read is left alone rather than guessed at. const FACTORY_NS = new Map([['useOptionalT', null], ['useOptionalI18n', null]]) // null = read the call site's own first argument const factoriesIn = (src, into) => { + // 1. THE EXACT SHAPE, kept first and unchanged: `export function useX() { return useOptionalT('ns') }`. + // Its namespace is READ, not guessed, and that precision is worth keeping for every factory + // written that way. for (const m of src.matchAll(/export\s+function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{\s*return\s+useOptional(?:T|I18n)\s*\(\s*'([^']*)'/g)) into.set(m[1], m[2]) + // 2. ★ AND ANY OTHER EXPORTED FUNCTION THAT BUILDS A TRANSLATOR, because requiring the one-liner is + // how this gate lost a whole bundle. gospel's useGiT stopped being `return useOptionalT('gospelInternet')` + // and became a WRAPPER — `const t = useOptionalT()` then a returned arrow that adds a fallback + // argument. Perfectly reasonable code; it simply no longer matched, so useGiT was not a factory, + // `const T = useGiT()` was not an alias, and every T('…') in that bundle was not a call site. + // The gate reported PASS over two planted poison keys, three rounds running. + // + // The body is taken to the next top-level `export`, which is where a function ends in practice in + // this tree. A namespace is used only if the call states one literally; otherwise the factory is + // registered with null, which now means UNRESOLVED-AND-REPORTED rather than silently dropped. + // Registering it wrongly-but-loudly beats not registering it at all: the failure is visible. + for (const m of src.matchAll(/export\s+function\s+([A-Za-z_$][\w$]*)\s*\(/g)) { + const name = m[1] + if (into.has(name)) continue + const rest = src.slice(m.index) + const end = rest.indexOf('\nexport ', 1) + const body = end === -1 ? rest : rest.slice(0, end) + const call = /useOptional(?:T|I18n)\s*\(\s*(?:'([^']*)')?/.exec(body) + if (!call) continue + into.set(name, call[1] === undefined ? null : call[1]) + } return into } for (const f of files) { @@ -144,6 +168,10 @@ for (const f of files) { } let FACTORIES = [...FACTORY_NS.keys()].join('|') let fallbackOnly = 0 +// ★ A TRANSLATOR WHOSE NAMESPACE THIS GATE CANNOT READ. Distinct from useOptionalT(''), which is an +// author DECLARING no namespace; this is the gate failing to determine one, and the two must not be +// reported as the same thing. Round three of gospel-internet being invisible was exactly this, silent. +const unresolvedAliases = [] const seesFile = (src) => !!src && (src.includes('tStatic') || src.includes('$t(') || src.includes(' t(') || new RegExp(`(?:${FACTORIES})\\s*\\(`).test(src)) @@ -187,7 +215,30 @@ function aliasesIn(src) { const [, plain, destructured, factory, argsTail] = m const declared = FACTORY_NS.get(factory) const ns = declared === null || declared === undefined ? (/^\s*'([^']*)'/.exec(argsTail)?.[1] ?? null) : declared - if (ns === null) continue + // ★ `continue` HERE IS HOW THIS GATE WENT BLIND TO A WHOLE BUNDLE, THREE TIMES. ns === null means + // the namespace could not be READ — the factory was called with no string literal, as + // `useGiT()` became when its body changed to `const t = useOptionalT()`. Dropping the binding + // silently unregistered the alias, so every T('…') in those files stopped being a call site at + // all and the bundle passed by having nothing looked at. Two planted poisons sat there unreported + // while the gate printed PASS. + // + // Note what it is NOT: useOptionalT('') gives ns === '' — an author explicitly declaring no + // namespace — which is handled below and counted. Empty is a decision; null is this gate's own + // ignorance, and ignorance must be stated rather than skipped. + // + // So the binding is still registered for the FULL-KEY pass, because a fully dotted key + // (T('gospelInternet.foo')) is checkable no matter what the namespace is, and BARE calls it + // genuinely cannot resolve are counted and FAIL. The gate now says which it could not see. + if (ns === null) { + const nms = plain ? [plain] + : [...String(destructured).matchAll(/\bt\s*:\s*([A-Za-z_$][\w$]*)|(?:^|,)\s*(t)\s*(?=,|$)/g)].map((x) => x[1] || x[2]).filter(Boolean) + for (const n of nms) { + full.add(n) + const bare = (src.match(new RegExp(`(?:^|[^\\w$.])${n}\\(\\s*'[\\w-]+'`, 'g')) || []).length + unresolvedAliases.push({ name: n, factory, bare }) + } + continue + } const names = plain ? [plain] : [...String(destructured).matchAll(/\bt\s*:\s*([A-Za-z_$][\w$]*)|(?:^|,)\s*(t)\s*(?=,|$)/g)].map((x) => x[1] || x[2]).filter(Boolean) for (const n of names) { @@ -253,6 +304,48 @@ function aliasesIn(src) { } } +// ── ★ ROUND THREE: THE FACTORY STOPPED BEING A ONE-LINER, AND THE GATE STOPPED SEEING THE BUNDLE ── +// The fixture above pins `export function useGiT() { return useOptionalT('gospelInternet') }`. Real +// useGiT is no longer that: it became a WRAPPER — `const t = useOptionalT()` and a returned arrow that +// adds an English-fallback argument. Ordinary refactoring, and it moved the whole bundle outside this +// gate, because factoriesIn() demanded the one-line shape, so useGiT was not a factory, `const T = +// useGiT()` was not an alias, and every T('…') was not a call site. Two poison keys were planted in +// gospel-internet and reported nothing, three rounds running. +// +// Both poisons are pinned HERE rather than left in gospel's tree, so the proof does not depend on +// another agent keeping deliberately-broken code on main, and so this gate fails on its own evidence. +// MEASURED on the tree those poisons were planted in: before, 1 missing key; after, 15 — the 2 poisons +// plus the 12 real keys of gospel's new panels that had been missing in all eleven locales, unseen. +{ + const WRAPPER = "export function useGiT() {\n const t = useOptionalT()\n return (key, en, params) => t(key, params)\n}" + const SITE = "const T = useGiT()\nconst a = T('gospelInternet.zzz_poison_not_a_real_key')\nconst b = T('gospelInternet.zzz_board_poison')\nconst c = T('bare_key_it_cannot_qualify')" + const probe = factoriesIn(WRAPPER, FACTORY_NS) + const saved = FACTORIES + FACTORIES = [...probe.keys()].join('|') + const mark = unresolvedAliases.length + const fails = [] + // 1. a wrapper factory is DISCOVERED at all — this is the one that regressed. + if (!probe.has('useGiT')) fails.push('a factory that WRAPS useOptionalT() instead of returning it is not discovered') + // 2. and discovered as UNRESOLVED, not invented: the call states no namespace, so none is guessed. + if (probe.get('useGiT') !== null) fails.push('a namespace was guessed for a factory that states none') + const al = aliasesIn(SITE) + // 3. its binding is still registered for the FULL-KEY pass, which is what makes a fully dotted + // poison catchable even though the namespace is unknown. + if (!al.full.has('T')) fails.push('an unresolved translator binding is dropped, so its dotted keys are checked by nothing') + // 4. and the bare call it genuinely cannot qualify is RECORDED rather than skipped in silence. + const rec = unresolvedAliases.slice(mark) + if (!rec.some((u) => u.name === 'T' && u.bare > 0)) fails.push('a bare key behind an unresolved translator is not counted, so the gate would pass over it') + unresolvedAliases.length = mark // the fixture's findings are not this tree's + FACTORIES = saved + FACTORY_NS.delete('useGiT') + if (fails.length) { + console.error(`${TAG}: SELF-TEST FAILED — ${fails.join('; ')}.`) + console.error(' A translator this gate cannot resolve must be REPORTED, never skipped: skipping is what made a whole') + console.error(' bundle invisible three times while the gate printed PASS.') + process.exit(3) + } +} + const missing = new Map() // key -> { sites: [], via: Set } let literalCount = 0, prefixedCount = 0, dynamicCount = 0 const lineAt = (src, i) => src.slice(0, i).split('\n').length @@ -335,6 +428,31 @@ if (fallbackOnly) { } } +// ── ★ A TRANSLATOR THIS GATE COULD NOT RESOLVE IS A FINDING, NOT A SKIP ──────────────────────── +// Three rounds of gospel-internet being invisible all ended here. The namespace was unreadable, the +// binding was dropped, and the bundle passed because nothing was looked at — which reads identically +// to a bundle with no problems. Fully dotted keys behind such an alias ARE still checked above; what +// cannot be checked is a BARE key, because there is no namespace to qualify it with. That count is +// the failure: it is the number of strings this gate is silently not looking at. +let unresolvedFail = 0 +if (unresolvedAliases.length) { + const byName = new Map() + for (const u of unresolvedAliases) { + const k = `${u.name}() <- ${u.factory}()` + byName.set(k, (byName.get(k) || 0) + u.bare) + } + unresolvedFail = [...byName.values()].reduce((a, b) => a + b, 0) + console.log(` · ${byName.size} translator binding(s) whose NAMESPACE could not be read — the factory was called with no`) + console.log(` string literal, so a bare key behind them cannot be qualified. Fully dotted keys ARE checked; bare ones are not:`) + for (const [k, bare] of [...byName].sort()) { + console.log(` ${k} — ${bare} bare-key call site(s) unchecked`) + } + if (unresolvedFail) { + console.log(' Give the factory a namespace it states literally (useOptionalT(\'area\')), or pass fully dotted keys at every') + console.log(' call site. Until then those strings are outside every i18n gate in this repo.') + } +} + if (missing.size) { if (BY_BUNDLE) { const byBundle = new Map() @@ -362,4 +480,10 @@ if (missing.size) { console.error(' product decision, but changing it is.') process.exit(1) } +if (unresolvedFail) { + console.error(`\n${TAG}: FAIL — ${unresolvedFail} bare-key call site(s) sit behind a translator whose namespace this gate could`) + console.error(' not read, so they are checked by nothing. A pass that excludes them is not a pass: it is the same silence') + console.error(' that let two planted poison keys through three times. See the binding(s) listed above.') + process.exit(1) +} console.log(`${TAG}: PASS — every catalogue key named in code exists in English.`)