check:i18n-keys-exist was blind to a translator that arrives from a composable
go-int planted T('poison_key') behind "const T = useGiT()" and this gate reported the tree clean.
That is the one failure a check is not forgiven: a red gate gets fixed, a green gate that should be
red gets believed, and this is the gate that guards goal (d).
Three independent blindnesses were stacked, and any one alone hid the key:
1. the file never writes the word tStatic, so the precondition skipped it before anything ran;
2. useGiT is not tStatic, so the per-file alias discovery found no alias at all;
3. the alias is PREFIXED, because useOptionalT carries a namespace, while its call sites pass BARE
keys -- which the full-key pattern cannot match, because it requires a dot.
A translator bound from a composable is now resolved like any other, and the namespace is READ from
the factory rather than listed here -- useOptionalT and useOptionalI18n directly, plus any
"export function useXxxT() { return useOptionalT('ns') }", which is how useNeighborT and usePplrT
are written. That keeps the next author's factory covered the day they write it, which is the same
reason the wrapper NAMES were already read per file instead of hardcoded. Because qualify() prefixes
a bare key and leaves an already-qualified one alone, one binding registers as both shapes; the two
patterns are disjoint, so nothing is counted twice.
MEASURED before and after, with the same poison on disk: the previous version PASSES it, this one
fails it as "neighbor.zzz_not_a_real_key (via nt())". Coverage went 683 -> 743 full-key and 49 -> 56
prefixed call sites, so 67 call sites were being scored as clean without being read. The tree itself
is still clean: no real key was hiding behind those 67, so there is nothing to route to any owner.
A self-test asserts all three fixes SEPARATELY and exits 3 if any one goes blind, because testing
them together would not say which was fixed -- and because this gate had already been trusted once
while lying.
Empty-namespace bare keys stay outside the model and are COUNTED rather than assumed away. Measured
at zero today: all five such sites pass fully dotted keys, so every one is checked by the full-key
pass. The counter stays because the shape is writable.
Gate and description only. No product code, no rebuild.
This commit is contained in:
parent
7e04a94c34
commit
e514dd8544
2 changed files with 107 additions and 2 deletions
|
|
@ -338,7 +338,7 @@
|
|||
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
"check:hover-channel": "SPEC-DASHBOARD-KIT D3: the bus's third coordinated channel points at a record without committing to one. Hover looks like a weaker selection, and the cheap implementation is to put the key in selectionSet 'just while hovering' - every consequence of which is bad and none obvious at the call site: useSelectionCamera watches selectionVersion, so a hover would fly the camera at every mousemove (the resize-strobe class, on a photosensitive Driver); the CSV exporter reads selection > filter > all, so a hover would change what an export contains; the table's selection pill and the shell's live region would both narrate the pointer. Runs the real bus: hover holds its own ref, never enters the selection, never bumps the version, never touches the filters, a repeat emits once (hover fires on every mousemove across 2,107 rows), and the three channels clear independently. Does NOT check which panels light up - D3 says panels opt in, and the first opt-in belongs with a consumer that has a reason. No browser.",
|
||||
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
|
||||
"check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run.",
|
||||
"check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run. ROUND 2, after go-int planted T('poison_key') behind `const T = useGiT()` and this gate reported the tree CLEAN -- the one failure a check is not forgiven, because a red gate gets fixed and a green one that should be red gets believed. THREE independent blindnesses stacked, and any one alone hid the key: the file never writes the word tStatic so the precondition skipped it before anything ran; useGiT is not tStatic so no alias was found; and the alias is PREFIXED (useOptionalT carries a namespace) while its call sites pass BARE keys, which the full-key pattern cannot match because it requires a dot. Now a translator that arrives from a COMPOSABLE is resolved like any other, with the namespace READ from the factory's one-line body rather than listed here -- useOptionalT/useOptionalI18n directly, plus any `export function useXxxT() { return useOptionalT('ns') }`, which is how useNeighborT and usePplrT are written. Because qualify() prefixes a bare key and leaves an already-qualified one alone, one binding registers as BOTH shapes. MEASURED before and after with the same poison on disk: the old version PASSES it, this one fails it as 'neighbor.zzz_not_a_real_key (via nt())'; coverage went 683 -> 743 full-key and 49 -> 56 prefixed call sites, so 67 sites were being scored clean without being read. A self-test now asserts all three fixes separately and exits 3 if any goes blind, because this gate guards goal (d) and had already been trusted once while lying. Empty-namespace bare keys stay outside the model and are COUNTED rather than assumed away; measured at zero today, since all five such sites pass dotted keys.",
|
||||
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
|
||||
"check:i18n-stopped-scope": "createAppI18n() must succeed while a STOPPED effect scope is current: the Driver's P0 (Plugin Studio gallery, every tab after the first threw 'SyntaxError: Unexpected error' from createI18n, because Vue 3.5 makes a new scope inactive under a stopped one). A bare createI18n() in the same scope must throw first, or the harness proves nothing. Also asserts a bundle's second provide('appI18n') raises no warning.",
|
||||
"check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,34 @@ for (const p of catalogues) {
|
|||
// missing keys — a check that fails on its own documentation is a check nobody trusts.
|
||||
const files = listFiles((p) => /\.(vue|js|mjs)$/.test(p) && !p.includes('/scripts/') && !p.includes('/locales/'))
|
||||
|
||||
// ── ★ A TRANSLATOR CAN ARRIVE FROM A COMPOSABLE, NOT ONLY FROM A LOCAL WRAPPER ───────────────
|
||||
// go-int planted a poison key behind `const T = useGiT()` and this gate PASSED. Three failures of
|
||||
// the same shape stacked up: (1) the file never writes the word tStatic, so the precondition below
|
||||
// skipped it outright; (2) `useGiT` is not `tStatic`, so aliasesIn() found no alias at all; and
|
||||
// (3) the alias is PREFIXED — useOptionalT carries a namespace — while its call sites pass BARE
|
||||
// keys, which the full-key pattern cannot match because it requires a dot. Any one of the three
|
||||
// alone would have hidden the key. A census says this was never one bundle's problem: 26 alias
|
||||
// sites across three factories (useOptionalT, useNeighborT, usePplrT).
|
||||
//
|
||||
// THE NAMESPACE IS READ, NOT LISTED, for the same reason the wrapper names are. Every factory in
|
||||
// this tree is one line — `return useOptionalT('neighbor')` — so following it costs nothing, and
|
||||
// the next useXxxT() is covered the day it is written instead of the day someone remembers to come
|
||||
// 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) => {
|
||||
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])
|
||||
return into
|
||||
}
|
||||
for (const f of files) {
|
||||
let src = ''
|
||||
try { src = readFile(f) || '' } catch (_) { continue }
|
||||
if (src.includes('useOptional')) factoriesIn(src, FACTORY_NS)
|
||||
}
|
||||
let FACTORIES = [...FACTORY_NS.keys()].join('|')
|
||||
let fallbackOnly = 0
|
||||
const seesFile = (src) => !!src && (src.includes('tStatic') || src.includes('$t(') || src.includes(' t(')
|
||||
|| new RegExp(`(?:${FACTORIES})\\s*\\(`).test(src))
|
||||
|
||||
// ── ★ ALIASES ARE DISCOVERED PER FILE, NOT HARDCODED ─────────────────────────────────────────
|
||||
// The first version of this gate matched tStatic/$t/t and found four missing keys while sailing
|
||||
// past the ten it was written for, because the house pattern is a file-local wrapper. Adding `T` by
|
||||
|
|
@ -153,9 +181,78 @@ function aliasesIn(src) {
|
|||
else if (/=\s*tStatic\s*$/.test(body.trim())) full.add(name)
|
||||
}
|
||||
}
|
||||
// A NAME BOUND FROM A FACTORY: `const t = useOptionalT('connectivity')`, `const nt =
|
||||
// useNeighborT()`, `const { t: T, te } = useOptionalI18n('gospel')`.
|
||||
for (const m of src.matchAll(new RegExp(`(?:const|let|var)\\s+(?:([A-Za-z_$][\\w$]*)|\\{([^}]*)\\})\\s*=\\s*(${FACTORIES})\\s*\\(([^\\n]*)`, 'g'))) {
|
||||
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
|
||||
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) {
|
||||
if (ns) {
|
||||
// qualify() prefixes a BARE key and leaves an already-qualified one alone, so ONE name is
|
||||
// both shapes: prefixed for t('map_title'), full-key for t('connectivity.map_title'). The
|
||||
// two patterns below are disjoint — one demands a dot, the other forbids it — so
|
||||
// registering the name twice double-counts nothing.
|
||||
prefixed.set(n, [...new Set([...(prefixed.get(n) || []), ns])])
|
||||
full.add(n)
|
||||
} else {
|
||||
// ★ AN EMPTY NAMESPACE PLUS A BARE KEY WOULD BE OUTSIDE THIS GATE'S MODEL, so it is counted
|
||||
// rather than assumed away. useOptionalT('') leaves the key alone, so t('approx') would ask
|
||||
// for a TOP-LEVEL key, while the catalogue is indexed area-first ("aria.foo") the way
|
||||
// tStatic walks it — unfindable here either way. MEASURED at zero: all five empty-namespace
|
||||
// sites (poster-wall, upg-100-mfe, resources-map, ApproxPinNote, FilterDropdown) pass FULLY
|
||||
// DOTTED keys, so every one of them is checked by the full-key pass above and nothing is
|
||||
// being waved through. The counter stays because the shape is writable, and the day someone
|
||||
// writes it this prints a stated limit instead of a clean result.
|
||||
full.add(n)
|
||||
fallbackOnly += (src.match(new RegExp(`(?:^|[^\\w$.])${n}\\(\\s*'[\\w-]+'`, 'g')) || []).length
|
||||
}
|
||||
}
|
||||
}
|
||||
return { full, prefixed }
|
||||
}
|
||||
|
||||
// ── ★ SELF-TEST, BECAUSE THIS GATE ALREADY PASSED A PLANTED POISON KEY ────────────────────────
|
||||
// go-int planted `T('poison_key')` behind `const T = useGiT()` and this gate reported the tree
|
||||
// clean. That is the failure a check cannot be forgiven for: a red gate gets fixed, a green one that
|
||||
// should be red gets BELIEVED, and this one guards goal (d). Measured before and after, with the
|
||||
// same poison on disk: the version on main PASSES it, this version FAILS it as
|
||||
// `neighbor.zzz_not_a_real_key (via nt())`. Coverage went 683 -> 743 full-key and 49 -> 56 prefixed
|
||||
// call sites, so 67 sites that were being scored as clean were simply not being read.
|
||||
//
|
||||
// The three things asserted are the three that were independently broken; any one of them alone
|
||||
// hid the key, so testing them together would not have told me which was fixed.
|
||||
{
|
||||
const FIXTURE_FACTORY = "export function useGiT() {\n return useOptionalT('gospelInternet')\n}"
|
||||
const FIXTURE_SITE = "const T = useGiT()\nconst a = T('poison_key')\nconst b = T('gospelInternet.qualified_key')"
|
||||
// Registered in the REAL map, because aliasesIn() resolves a factory's namespace through it — a
|
||||
// copy would have the self-test exercise a lookup the scan does not use.
|
||||
const probe = factoriesIn(FIXTURE_FACTORY, FACTORY_NS)
|
||||
const saved = FACTORIES
|
||||
FACTORIES = [...probe.keys()].join('|')
|
||||
const fails = []
|
||||
// 1. the factory's namespace is READ from its one-line body, not guessed.
|
||||
if (probe.get('useGiT') !== 'gospelInternet') fails.push('a useXxxT() factory\'s namespace is not read from its body')
|
||||
// 2. a file whose ONLY translator comes from a composable is not skipped before anything runs.
|
||||
if (!seesFile(FIXTURE_SITE)) fails.push('a file with no tStatic but a composable translator is still skipped')
|
||||
// 3. the binding becomes BOTH shapes, because qualify() prefixes a bare key and leaves a
|
||||
// qualified one alone — bare-only would miss T('poison_key'), full-only would miss nothing
|
||||
// visible and is exactly how this passed before.
|
||||
const al = aliasesIn(FIXTURE_SITE)
|
||||
if (!(al.prefixed.get('T') || []).includes('gospelInternet')) fails.push('a composable-bound alias is not registered as PREFIXED')
|
||||
if (!al.full.has('T')) fails.push('a composable-bound alias is not also registered as FULL-key')
|
||||
FACTORIES = saved
|
||||
FACTORY_NS.delete('useGiT') // the fixture's factory is not part of this tree
|
||||
if (fails.length) {
|
||||
console.error(`${TAG}: SELF-TEST FAILED — ${fails.join('; ')}.`)
|
||||
console.error(' This gate cannot be trusted while blind to a composable-bound translator: that is how a planted key passed.')
|
||||
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
|
||||
|
|
@ -173,7 +270,10 @@ const record = (key, site, via) => {
|
|||
const callSites = new Map() // bundle -> count
|
||||
for (const f of files) {
|
||||
const src = readFile(f)
|
||||
if (!src || !src.includes('tStatic') && !src.includes('$t(') && !src.includes(' t(')) continue
|
||||
// seesFile()'s last clause is not decoration: a file whose only translator comes from a composable
|
||||
// never writes the word tStatic, and skipping it here is how the poison key got past everything
|
||||
// below. The self-test above drives this exact predicate for that reason.
|
||||
if (!seesFile(src)) continue
|
||||
const { full, prefixed } = aliasesIn(src)
|
||||
const names = [...full, ...prefixed.keys()].map((n) => n.replace('$', '\\$')).join('|')
|
||||
if (!names) continue
|
||||
|
|
@ -222,6 +322,11 @@ console.log(` · ${KEYS.size} English keys across ${catalogues.length} catalogu
|
|||
if (dynamicCount) {
|
||||
console.log(` · ${dynamicCount} DYNAMIC key expression(s) not checked — this gate will not invent the key set a runtime expression might produce.`)
|
||||
}
|
||||
if (fallbackOnly) {
|
||||
console.log(` · ${fallbackOnly} bare-key call site(s) behind useOptionalT('') are OUTSIDE this model and not checked: an empty namespace leaves the key`)
|
||||
console.log(` unqualified, while the catalogue is indexed area-first the way tStatic walks it. Those sites pass their own literal fallback MAP`)
|
||||
console.log(` rather than the catalogue, by the decision recorded in library/composables/useOptionalT.js — a limit, not a clean result.`)
|
||||
}
|
||||
{
|
||||
const bundles = listFiles((p) => /^plugin-designs\/[^/]+\//.test(p)).map((p) => p.split('/')[1])
|
||||
const silent = [...new Set(bundles)].filter((b) => !callSites.get(b)).sort()
|
||||
|
|
|
|||
Loading…
Reference in a new issue