check:frozen-labels in gates:post: a label resolved once never re-resolves on a locale switch

Written by the UPG-100 plugin owner. It parses templates and scripts and flags a translator
result stored at module or setup scope, or tStatic inside a computed. A production profile or
library/ fails; an experimental profile is reported. It found the search header's eight labels,
fixed in the commit before this, so it arrives green.
This commit is contained in:
Doxa Maps 2026-09-28 13:07:02 +00:00
parent ce82467dae
commit dbbcd5bc45
2 changed files with 238 additions and 1 deletions

View file

@ -0,0 +1,234 @@
#!/usr/bin/env node
/**
* check-frozen-labels — a label resolved ONCE that never re-resolves when the locale changes.
*
* THE DEFECT, and it is created BY FIXING ANOTHER ONE. `const TABS = [{ label: t('…') }]` at
* module/setup scope evaluates once. ProfileLoader drives appI18n.global.locale at RUNTIME, so a
* mid-session language switch leaves that label in the boot language while everything around it
* changes. A computed re-reads on access; a const does not. This is a DIFFERENT defect from
* hardcoded English with the SAME symptom: hardcoded English never consults the catalogue,
* a frozen label consults it once and caches. check:hardcoded-english finds the first and cannot
* see the second — and binding a literal to t() in the wrong scope converts one into the other.
*
* tStatic() freezes the same way for a different reason: it reads getRuntimeLocale() AT CALL TIME
* (library/i18n/index.js), so RE-CALLING it picks up a change; storing its result once does not.
*
* ★ PARSED, NOT REGEXED. A regex cannot tell `const X = t('a')` from
* `const X = computed(() => t('a'))` across a multi-line array. Templates go through
* @vue/compiler-sfc, scripts through @babel/parser; the test is on the AST.
*
* ★ SCOPED BY PROFILE STAGE, NOT BUNDLE STAGE. bundle.stage reads 'production' if ANY profile is,
* and FOUR bundles are mixed (cities-internet, gospel-resources, my-upg-100-list, regions-dashboard
* — measured 2026-09-28). A .vue file IS a profile, so the file's own profile.stage is the honest
* key. Scoping by bundle.stage would FAIL on experimental files in a production bundle.
* production profile or library/ -> FAIL (exit 1)
* experimental profile -> REPORTED, never fails
*
* ⚠ CANNOT TELL WHETHER A FROZEN LABEL IS RENDERED. A frozen const in dead code is not a
* user-visible defect; refs= is a reference count, a proxy, not proof it reaches a screen.
*
* Deliberately NOT flagged (all re-evaluate, or are pinned on purpose): t() inside a
* function/arrow/method body, inside computed()/watch()/watchEffect(), in a template expression,
* and tStatic(path, explicitLocale).
*
* NEGATIVE-TESTED: --self-test plants 4 bad shapes it must catch and 6 good ones it must not.
* Exit 0 clean · 1 a production/library finding · 2 parse error.
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, relative, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
import { parse as parseSfc } from '@vue/compiler-sfc'
import * as babel from '@babel/parser'
const TRANSLATORS = new Set(['t', 'tStatic', '$t'])
// A call is "reactive" if any ANCESTOR is one of these: the value is recomputed later.
const REACTIVE_WRAPPERS = new Set(['computed', 'watch', 'watchEffect', 'watchPostEffect', 'defineExpose'])
function parseScript(code) {
return babel.parse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'topLevelAwait', 'importMeta'],
errorRecovery: true,
})
}
/** Walk the AST, tracking whether we are inside a function body or a reactive wrapper. */
function findFrozen(ast, aliases) {
const out = []
const visit = (node, parents) => {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) { for (const n of node) visit(n, parents); return }
if (!node.type) return
if (node.type === 'CallExpression') {
const callee = node.callee
const name = callee?.type === 'Identifier' ? callee.name
: callee?.type === 'MemberExpression' && callee.property?.type === 'Identifier' ? callee.property.name
: null
if (name && (TRANSLATORS.has(name) || aliases.has(name))) {
// tStatic(path, EXPLICIT_LOCALE) — the caller pinned it deliberately.
const pinned = name === 'tStatic' && node.arguments.length > 1
// ★ tStatic IS NEVER LIVE, WHEREVER IT SITS (exp, 2026-09-28). It resolves the locale via
// getRuntimeLocale(), which reads a PLAIN window property — not a Vue ref — so a computed()
// wrapping it has NO reactive dependency and never invalidates. computed(() => tStatic(x))
// is strictly WORSE than the naked call: it looks like the fix, so nobody looks again.
// Only a reactive translator (vue-i18n t / useOptionalT) counts as live inside a wrapper.
// ⚠ BUT A PLAIN FUNCTION BODY STILL RE-RUNS. tStatic inside a function/arrow that a caller
// INVOKES re-reads the global each call (useOptionalT:74 returns exactly such an arrow), so
// only a REACTIVE WRAPPER is the lie: computed/watch cache the result and never invalidate.
const inReactiveWrapper = parents.some((p) =>
p.type === 'CallExpression' && p.callee?.type === 'Identifier' && REACTIVE_WRAPPERS.has(p.callee.name))
const neverLive = name === 'tStatic' && !pinned && inReactiveWrapper
// Inside any function body or reactive wrapper => re-evaluated later.
const shielded = parents.some((p) =>
p.type === 'FunctionDeclaration' || p.type === 'FunctionExpression' ||
p.type === 'ArrowFunctionExpression' || p.type === 'ObjectMethod' ||
p.type === 'ClassMethod' ||
(p.type === 'CallExpression' && p.callee?.type === 'Identifier' && REACTIVE_WRAPPERS.has(p.callee.name)))
if ((!shielded || neverLive) && !pinned) {
// Name the binding it freezes into, when there is one.
const decl = parents.find((p) => p.type === 'VariableDeclarator')
out.push({
line: node.loc?.start.line ?? 0,
binding: decl?.id?.type === 'Identifier' ? decl.id.name : '(expression)',
call: name,
})
}
}
}
const next = [...parents, node]
for (const k of Object.keys(node)) {
if (k === 'loc' || k === 'range' || k === 'leadingComments' || k === 'trailingComments') continue
visit(node[k], next)
}
}
visit(ast.program ?? ast, [])
return out
}
/** `const t2 = t` / `const { t } = useI18n()` — alias discovery, so a rename cannot hide a call. */
function findAliases(code) {
const set = new Set()
for (const m of code.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*(t|tStatic|useOptionalT\([^)]*\))\s*[;\n]/g)) set.add(m[1])
for (const m of code.matchAll(/const\s*\{\s*([^}]*)\}\s*=\s*useI18n\(\)/g)) {
for (const part of m[1].split(',')) {
const [orig, alias] = part.split(':').map((s) => s.trim())
if (orig === 't') set.add(alias || 't')
}
}
return set
}
export function scanSource(code) {
const aliases = findAliases(code)
let ast
try { ast = parseScript(code) } catch { return [] }
return findFrozen(ast, aliases)
}
export function scanVue(src) {
const { descriptor } = parseSfc(src)
const block = descriptor.scriptSetup || descriptor.script
if (!block) return []
const offset = src.slice(0, block.loc.start.offset).split('\n').length - 1
return scanSource(block.content).map((f) => ({ ...f, line: f.line + offset }))
}
// ── SELF-TEST ───────────────────────────────────────────────────────────────
const CASES = [
['BAD const array of t() labels', () => scanSource("const { t } = useI18n()\nconst TABS = [{ label: t('a.b') }]").length, 1],
['BAD t() straight into a const', () => scanSource("const { t } = useI18n()\nconst title = t('a.b')").length, 1],
['BAD tStatic() into a const', () => scanSource("const title = tStatic('a.b')").length, 1],
['BAD through an ALIAS of t', () => scanSource("const { t } = useI18n()\nconst t2 = t\nconst x = t2('a.b')").length, 1],
['GOOD computed(() => t())', () => scanSource("const { t } = useI18n()\nconst TABS = computed(() => [{ label: t('a.b') }])").length, 0],
['GOOD inside a function body', () => scanSource("const { t } = useI18n()\nfunction f() { return t('a.b') }").length, 0],
['GOOD inside an arrow', () => scanSource("const { t } = useI18n()\nconst f = () => t('a.b')").length, 0],
['GOOD tStatic with explicit locale', () => scanSource("const x = tStatic('a.b', 'en')").length, 0],
['GOOD watch(() => t())', () => scanSource("const { t } = useI18n()\nwatch(src, () => t('a.b'))").length, 0],
// ★ exp's false negative: a computed cannot make tStatic live.
['BAD computed(() => tStatic()) is STILL frozen', () => scanSource("const X = computed(() => tStatic('a.b'))").length, 1],
// A function BODY re-reads the global on every call (useOptionalT:74 returns such an arrow),
// so it is NOT frozen. Only a reactive wrapper caches it. I had this case backwards at first.
['GOOD tStatic in a function body re-runs', () => scanSource("function f(){ return tStatic('a.b') }").length, 0],
['GOOD computed(() => t()) IS live', () => scanSource("const { t } = useI18n()\nconst X = computed(() => t('a.b'))").length, 0],
['GOOD computed(() => tStatic(x, loc)) is pinned', () => scanSource("const X = computed(() => tStatic('a.b', 'en'))").length, 0],
['GOOD no translator at all', () => scanSource("const TABS = [{ label: 'Region' }]").length, 0],
]
if (process.argv.includes('--self-test')) {
let ok = 0
for (const [name, run, want] of CASES) {
let got
try { got = run() } catch (e) { got = `ERR ${e.message}` }
const pass = got === want
console.log(` ${pass ? '✓' : '✗'} ${name} -> ${got} (want ${want})`)
if (pass) ok++
}
console.log(ok === CASES.length
? `FREEZE-CENSUS SELF-TEST: PASS — ${ok}/${CASES.length}, every rule proven able to fail`
: `FREEZE-CENSUS SELF-TEST: FAIL — ${ok}/${CASES.length}`)
process.exit(ok === CASES.length ? 0 : 1)
}
// ── RUN ─────────────────────────────────────────────────────────────────────
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const TREE = join(ROOT, `public/${TREE_NAME}`)
// profile.stage per FILE. A .vue under plugin-designs/<bundle>/<profile>.vue IS that profile.
// Anything under library/ is shared by every bundle, so it is held to the production bar.
function stageIndex() {
const mf = join(TREE, 'manifest.json')
if (!existsSync(mf)) return null
const m = JSON.parse(readFileSync(mf, 'utf8'))
const byFile = new Map()
for (const b of m.bundles || []) for (const p of b.profiles || []) {
byFile.set(join('plugin-designs', b.bundle, p.profile + '.vue'), p.stage || b.stage || 'experimental')
}
return byFile
}
const walk = (dir, acc = []) => {
if (!existsSync(dir)) return acc
for (const e of readdirSync(dir)) {
if (e === 'node_modules' || e === '.git' || e === 'public') continue
const p = join(dir, e)
if (statSync(p).isDirectory()) walk(p, acc)
else if (/\.(vue|js|mjs)$/.test(e)) acc.push(p)
}
return acc
}
const stages = stageIndex()
if (!stages) {
console.log(`FROZEN-LABELS: SKIP — no built tree at public/${TREE_NAME}. Run a build first.`)
console.log(' Reported as a SKIP, not a pass: a gate that reports success when it never ran is worse than no gate.')
process.exit(0)
}
const files = ['library', 'plugin-designs'].flatMap((r) => walk(join(ROOT, r)))
const failures = [], reported = []
for (const f of files) {
const src = readFileSync(f, 'utf8')
let hits
try { hits = f.endsWith('.vue') ? scanVue(src) : scanSource(src) }
catch (e) { console.error(`FROZEN-LABELS: parse error in ${relative(ROOT, f)} — ${e.message}`); process.exit(2) }
if (!hits.length) continue
const rel = relative(ROOT, f)
// library/ is held to the production bar; a plugin file takes its own PROFILE stage.
const stage = rel.startsWith('library/') ? 'production' : (stages.get(rel) || 'experimental')
for (const h of hits) {
const refs = h.binding === '(expression)' ? -1 : (src.match(new RegExp(`\\b${h.binding}\\b`, 'g')) || []).length - 1
const row = `${rel}:${h.line} ${h.binding} (${h.call}) refs=${refs < 0 ? '?' : refs}`
;(stage === 'production' ? failures : reported).push(row)
}
}
for (const r of reported) console.log(` · ${r} [experimental — reported, not failing]`)
for (const r of failures) console.log(` ✗ ${r} [PRODUCTION]`)
if (failures.length) {
console.log(`FROZEN-LABELS: FAIL — ${failures.length} label(s) in a PRODUCTION profile or library/ resolve once and never re-resolve on a locale change. Wrap in computed().`)
process.exit(1)
}
console.log(`FROZEN-LABELS: PASS — 0 in production/library${reported.length ? `, ${reported.length} reported in experimental` : ''}.`)
process.exit(0)

View file

@ -87,6 +87,8 @@
"check:fits-screen:self-test:d": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=d",
"check:fits-screen:self-test:e": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=e",
"check:fixed-anchoring": "node internal/check-fixed-anchoring.mjs",
"check:frozen-labels": "node internal/check-frozen-labels.mjs",
"check:frozen-labels:self-test": "node internal/check-frozen-labels.mjs --self-test",
"check:gallery-embed": "node internal/check-research-gallery-embed.mjs",
"check:gallery-embed:iframe": "node internal/check-research-gallery-embed.mjs --mode iframe",
"check:gallery-embed:js": "node internal/check-research-gallery-embed.mjs --mode js",
@ -260,7 +262,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:frozen-labels",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
@ -361,6 +363,7 @@
"check:fits-screen:self-test:c": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
"check:fits-screen:self-test:d": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
"check:fits-screen:self-test:e": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
"check:frozen-labels": "a label resolved ONCE that never re-resolves on a mid-session locale switch: t() or tStatic() stored at module/setup scope, or tStatic() inside a computed (it reads a plain global the computed cannot track). Parsed, not regexed. Scoped by PROFILE stage: a production profile or library/ FAILS, an experimental profile is REPORTED only. In gates:post. The :self-test plants 4 bad shapes and 6 good ones (14 assertions).",
"check:gallery-embed": "the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes, bare <script>+<doxa-maps-spa> and <iframe>, with the layout inside the host box and every curated tab painting a map. The summary line prints the tab count PER MODE, and a mode that checked zero tabs is a FAIL. One mode only: check:gallery-embed:js or check:gallery-embed:iframe. Starts its own servers on OS-assigned ports; needs only a built tree. Exit 2 = SKIP (no built tree / no playwright).",
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on the live test host): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.",
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",