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:
parent
07eb74c33c
commit
fc369cbd9d
1 changed files with 123 additions and 1 deletions
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue