diff --git a/internal/check-frozen-labels.mjs b/internal/check-frozen-labels.mjs index 12a1ca031..406cc4522 100644 --- a/internal/check-frozen-labels.mjs +++ b/internal/check-frozen-labels.mjs @@ -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],