diff --git a/internal/check-frozen-labels.mjs b/internal/check-frozen-labels.mjs new file mode 100644 index 00000000..dd204f34 --- /dev/null +++ b/internal/check-frozen-labels.mjs @@ -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//.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) diff --git a/internal/check-hardcoded-english.mjs b/internal/check-hardcoded-english.mjs index 86c16eae..c305c97f 100644 --- a/internal/check-hardcoded-english.mjs +++ b/internal/check-hardcoded-english.mjs @@ -215,6 +215,32 @@ export function crossAreaKeys(code, file, root = '.') { return out } +// ── LITERAL STRINGS PASSED AS PROPS ───────────────────────────────────────────────────────────── +// ★ THE THIRD FALSE-NEGATIVE CLASS, and the gate is structurally unable to confirm it (gospel). +// poster-wall.vue passes `unit="UPG" item-noun="list"` to , and PosterWall.vue renders +// `{{ itemNoun }}` and `{{ unit }}` as visible text. Neither file shows a leak to a template scan: +// in the plugin it is an attribute VALUE, in the library it is an interpolation of a VARIABLE. The +// English and the render live in different files, so no single-file rule can join them. +// Reported as SUSPECT, never counted as a leak, because whether the child renders the prop is not +// knowable from here — the child may use it as a key, an id, or an event name. A suspect is a +// question for a human, and calling it a defect would make the count untrustworthy in the other +// direction. +const PROP_HINT = /noun|label|title|unit|text|heading|intro|placeholder|caption/i +// Component tags only: PascalCase, or hyphenated custom elements. A plain
takes attributes, +// not props, and its visible ones are already covered by VISIBLE_ATTRS. +const isComponentTag = (tag) => /^[A-Z]/.test(tag) || /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(tag) + +function literalProps(node, file, out) { + if (node.type !== 1 || !isComponentTag(node.tag || '')) return + for (const p of node.props || []) { + if (p.type !== 6 || !p.value?.content) continue // static attribute only + if (!PROP_HINT.test(p.name)) continue + const text = String(p.value.content).trim() + if (!text.length || !isProse(text) || allowedBy(text)) continue + out.push({ file, kind: `suspect-prop:${p.name}`, text: `<${node.tag} ${p.name}="${text}">`, suspect: true }) + } +} + // ── the walk ──────────────────────────────────────────────────────────────────────────────────── function walkAst(node, visit, parent = null, stack = []) { if (!node || typeof node.type !== 'string') return @@ -312,21 +338,37 @@ function scanBoundExpr(expr, file, attrName, out) { // leak would ask someone to "fix" a deliberate decision. Such findings are marked deliberate, listed // SEPARATELY, and excluded from the count a gate would fail on — visible, but not a defect. // Inherited down the subtree: a dir="ltr" wrapper covers everything inside it. +// ★ ONLY A STATIC dir="ltr" MARKS AN ISLAND, and getting this wrong blinded a whole plugin. +// The first version also accepted a BOUND :dir whose expression merely CONTAINED 'ltr' — which +// matches `:dir="isRTL ? 'rtl' : 'ltr'"`, the correct locale-aware direction switch that every +// RTL-ready component puts on its ROOT. The effect was that such a component's entire template was +// treated as deliberate English and every leak in it silenced. regions-dashboard read 0 for exactly +// that reason: not clean, BLIND. A dynamic direction switch is the OPPOSITE of "this stays English" +// — it is the component promising to follow the reader's locale. const hasLtr = (node) => (node.props || []).some((p) => - (p.type === 6 && p.name === 'dir' && p.value?.content === 'ltr') - || (p.type === 7 && p.name === 'bind' && p.arg?.content === 'dir' && /['"]ltr['"]/.test(p.exp?.content || ''))) + p.type === 6 && p.name === 'dir' && p.value?.content === 'ltr') function walkTemplate(node, file, out, ltr = false) { if (!node) return - const inLtr = ltr || (node.type === 1 && hasLtr(node)) + // ★ NAME THE ISLAND THAT SUPPRESSED A STRING, not just the fact of suppression. The deliberate + // bucket is the one place where over-claiming costs nothing and shows nothing: a 74-line island + // hiding 27 strings and a one-line read identically in a flat count. gospel found exactly + // that on their own card — an island they believed was a caveat turned out to cover the honesty + // prose as well. An island is an object with a size, so the report gives it one. + const own = node.type === 1 && hasLtr(node) + const inLtr = ltr || own + const island = own + ? { file, line: node.loc?.start?.line ?? 0, endLine: node.loc?.end?.line ?? 0, tag: node.tag || 'element' } + : (ltr && typeof ltr === 'object' ? ltr : null) if (node.type === 2) { const raw = String(node.content || '').trim() - if (raw.length && isProse(raw) && !allowedBy(raw)) out.push({ file, kind: 'template-text', text: raw, deliberate: inLtr }) + if (raw.length && isProse(raw) && !allowedBy(raw)) out.push({ file, kind: 'template-text', text: raw, deliberate: !!inLtr, island }) } + if (node.type === 1) literalProps(node, file, out) if (node.type === 1) for (const p of node.props || []) { if (p.type === 6 && VISIBLE_ATTRS.has(p.name)) { const v = p.value && p.value.content - if (v && isProse(v) && !allowedBy(v)) out.push({ file, kind: `attr:${p.name}`, text: v, deliberate: inLtr }) + if (v && isProse(v) && !allowedBy(v)) out.push({ file, kind: `attr:${p.name}`, text: v, deliberate: !!inLtr, island }) } // (interpolations are handled below, on the node itself) // type 7 = DIRECTIVE. v-bind carries the attribute name in `arg`. @@ -339,7 +381,7 @@ function walkTemplate(node, file, out, ltr = false) { if (attrName !== 'style' && attrName !== 'class') { const before = out.length scanBoundExpr(p.exp.content, file, attrName, out) - for (let i = before; i < out.length; i++) out[i].deliberate = inLtr + for (let i = before; i < out.length; i++) { out[i].deliberate = !!inLtr; out[i].island = island } } } } @@ -349,9 +391,9 @@ function walkTemplate(node, file, out, ltr = false) { if (node.type === 5 && node.content?.content) { const before = out.length scanBoundExpr(node.content.content, file, 'interpolation', out) - for (let i = before; i < out.length; i++) out[i].deliberate = inLtr + for (let i = before; i < out.length; i++) { out[i].deliberate = !!inLtr; out[i].island = island } } - for (const c of node.children || []) walkTemplate(c, file, out, inLtr) + for (const c of node.children || []) walkTemplate(c, file, out, island || inLtr) } export function scanSfc(src, file) { @@ -385,12 +427,13 @@ const sfcsIn = (dir) => { // ── self-test: the gate must be shown able to SEE, and able to stay quiet ──────────────────────── const PLANTED_BAD = `