Teach frozen-labels the two shapes a function body hides

The existing rule treats "inside a function" as safe, because a function
re-runs. That is true of the function and false of the label when nothing
calls the function again after the locale changes. upg100 hit both shapes
for real in their UPG-100 i18n batch.

A tree built once from t() and stored: opts() returns labels, onMounted
assigns the built tree to a ref, and no call site sits inside a watcher. The
translator call is shielded by the function, so the walk reports it clean
while the labels stay in the boot language.

A computed whose only locale source is getRuntimeLocale() or detectLocale().
Those are plain getters that notify nobody, so the computed has no reactive
dependency on the language and resolves once. It is the existing tStatic
rule generalised: the getter can be reached through any helper, not only
through a translator.

The watcher does not have to be watch(locale, …). upg100's own fix watches
() => packetLang(), which reads packetLocale.value — a real reactive source
under a local name. A signal looking for the literal `locale` would have
flagged the fix as the defect, which is the worst thing a gate can do to
someone who just did the work.

The first draft flagged five PRODUCTION files, none of them a label tree:
fetchPhotos, relabelSheet, popupHtml and two more — large functions that
call a translator once for an error string and return something unrelated.
"The body mentions t()" is not the shape. The rule now requires the
translator call to sit inside a RETURNED object or array literal, which is
what "builds a structure of labels" actually looks like.

Six cases added to the gate's own table, each bad shape paired with the good
one it must stay quiet on, plus that false positive. Self-test 20/20.
This commit is contained in:
Doxa Maps 2026-09-29 08:48:05 +00:00
parent 07eb74c33c
commit fc369cbd9d

View file

@ -124,11 +124,116 @@ function findAliases(code) {
return set
}
// ── TWO SHAPES THE SHIELDING RULE ABOVE CANNOT SEE (upg100, both real in their i18n batch) ──────
//
// The rule above treats "inside a function body" as safe, because a function RE-RUNS. That is true
// of the function and false of the label, when nothing calls the function again after the locale
// changes. Both shapes below are frozen labels that the AST walk reports as fine.
/** Names that read the locale REACTIVELY — a watcher on any of these re-runs on a language change. */
const REACTIVE_LOCALE = /\blocale\b|\bpacketLang\b|\buiLocale\b|\beffectiveLocale\b|\bactiveLocale\b/
/** Plain getters: they return today's locale and notify nobody. */
const DEAD_LOCALE_GETTER = /\b(getRuntimeLocale|detectLocale|localeFromUrl)\s*\(/
/**
* SHAPE 1 — a tree or list built ONCE from t() and stored.
*
* function opts() { return { title: () => t('poster.zone_deaf') } }
* onMounted(() => { nodes.value = build(opts()) }) // and nothing re-runs it
*
* The t() sits in a function, so the walk above shields it; the RESULT is assigned to a ref and
* never rebuilt. Flagged only when NO call site of that function sits inside a reactive wrapper or a
* locale watcher.
*
* ⚠ THE WATCHER DOES NOT HAVE TO BE `watch(locale, …)`. upg100's own fix is
* `watch(() => packetLang(), …)`, where packetLang() reads packetLocale.value — a real reactive
* source under a local name. A signal that looked for the literal `locale` would have flagged the
* FIX as the defect, which is the worst thing a gate can do to someone who just did the work.
*/
function findStoredTreeFreeze(code) {
const out = []
// functions whose body calls a translator
const fns = [...code.matchAll(/(?:^|\n)\s*(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g)]
for (const m of fns) {
const name = m[1]
const open = code.indexOf('{', m.index + m[0].length - 1)
let d = 0, end = open
for (let i = open; i < code.length; i++) {
if (code[i] === '{') d++
else if (code[i] === '}') { d--; if (d === 0) { end = i; break } }
}
const body = code.slice(open, end + 1)
// ⚠ "THE BODY MENTIONS t()" IS NOT THE SHAPE. Tested against development: that alone flagged
// fetchPhotos (an image fetcher), relabelSheet and popupHtml — large functions that call a
// translator once for an error string or a caption and return something unrelated. Five
// production files, none of them a label tree. The shape upg100 described is narrower: the
// function RETURNS A STRUCTURE whose values come from the translator — an options object or a
// node list — which a caller then stores. So require the translator call to sit inside a
// returned object/array literal, not merely somewhere in the body.
const returnsTranslatedStructure = /return\s*\{[\s\S]{0,400}?\b(?:t|tStatic|tf)\s*\(\s*['"`]/.test(body)
|| /return\s*\[[\s\S]{0,400}?\b(?:t|tStatic|tf)\s*\(\s*['"`]/.test(body)
if (!returnsTranslatedStructure) continue
// every call site of this function, outside its own declaration
const callRe = new RegExp(`\\b${name}\\s*\\(`, 'g')
const sites = [...code.matchAll(callRe)].map((x) => x.index).filter((i) => i < m.index || i > end)
if (!sites.length) continue
// is ANY call site assigned to a ref/let, and is ANY inside a reactive wrapper or locale watcher?
let stored = false, live = false
for (const i of sites) {
const before = code.slice(Math.max(0, i - 260), i)
const line = code.slice(code.lastIndexOf('\n', i) + 1, code.indexOf('\n', i))
if (/\.value\s*=|^\s*(?:let|const)\s+\w+\s*=/.test(line)) stored = true
if (/\b(computed|watch|watchEffect)\s*\(/.test(before) && (REACTIVE_LOCALE.test(before) || /watchEffect/.test(before))) live = true
}
if (stored && !live) {
const line = code.slice(0, m.index).split('\n').length
out.push({ line, binding: name, call: 'stored-tree', why: `${name}() builds labels from t() and its result is stored; no call site is inside a locale watcher or reactive wrapper` })
}
}
return out
}
/**
* SHAPE 2 — a computed whose ONLY locale source is a non-reactive getter.
*
* computed(() => zones.map(z => title(z, getRuntimeLocale())))
*
* getRuntimeLocale() reads a plain window property, so the computed has no reactive dependency on
* the language and resolves once. This is the `neverLive` rule above generalised past tStatic: the
* getter can be reached through any helper, not only through a translator call.
*/
function findDeadGetterComputed(code) {
const out = []
for (const m of code.matchAll(/\b(computed|watchEffect)\s*\(\s*\(\s*\)\s*=>/g)) {
const open = code.indexOf('{', m.index)
const paren = code.indexOf('(', m.index + m[0].length - 3)
// take the arrow body by bracket depth from the wrapper's own opening paren
const start = code.indexOf('(', m.index + m[1].length)
let d = 0, end = start
for (let i = start; i < code.length; i++) {
if (code[i] === '(') d++
else if (code[i] === ')') { d--; if (d === 0) { end = i; break } }
}
const body = code.slice(start, end + 1)
if (!DEAD_LOCALE_GETTER.test(body)) continue
// a reactive read anywhere in the body rescues it: locale.value, t(), or any reactive locale name
const rescued = /\.value\b/.test(body) || /\b(?:t|tf)\s*\(\s*['"`]/.test(body) || REACTIVE_LOCALE.test(body)
if (rescued) continue
out.push({
line: code.slice(0, m.index).split('\n').length,
binding: '(computed)',
call: 'dead-locale-getter',
why: `${m[1]}() reads the locale only through a plain getter, so it never re-runs on a language change`,
})
}
return out
}
export function scanSource(code) {
const aliases = findAliases(code)
let ast
try { ast = parseScript(code) } catch { return [] }
return findFrozen(ast, aliases)
return [...findFrozen(ast, aliases), ...findStoredTreeFreeze(code), ...findDeadGetterComputed(code)]
}
export function scanVue(src) {
@ -147,6 +252,23 @@ const CASES = [
['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],
// ── upg100's two shapes: a function-shielded freeze, and a computed with no reactive locale ──
['BAD stored tree, nothing re-runs it',
() => scanSource("function opts(){ return { title: t('poster.zone_deaf') } }\nonMounted(() => { nodes.value = build(opts()) })").filter((f) => f.call === 'stored-tree').length, 1],
['GOOD stored tree + watch(locale)',
() => scanSource("function opts(){ return { title: t('poster.zone_deaf') } }\nwatch(locale, () => { nodes.value = build(opts()) })").filter((f) => f.call === 'stored-tree').length, 0],
// ⚠ upg100's OWN FIX watches a locale getter under a local name, not `locale`. A signal that
// looked for the literal would flag the fix as the defect.
['GOOD stored tree + watch(() => packetLang())',
() => scanSource("function opts(){ return { title: t('poster.zone_deaf') } }\nwatch(() => packetLang(), () => { nodes.value = build(opts()) })").filter((f) => f.call === 'stored-tree').length, 0],
// ⚠ the false-positive case that five PRODUCTION files hit on the first draft: a big function that
// calls a translator once for an error string and returns something unrelated.
['GOOD fetcher that merely mentions t()',
() => scanSource("function fetchPhotos(g){ const e = t('no_photo'); return new Map() }\nconst p = fetchPhotos(x)").filter((f) => f.call === 'stored-tree').length, 0],
['BAD computed reads only a dead locale getter',
() => scanSource("const x = computed(() => zones.map(z => title(z, getRuntimeLocale())))").filter((f) => f.call === 'dead-locale-getter').length, 1],
['GOOD computed reads locale.value first',
() => scanSource("const x = computed(() => { const l = locale.value; return zones.map(z => title(z, l || getRuntimeLocale())) })").filter((f) => f.call === 'dead-locale-getter').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],