diff --git a/internal/check-frozen-labels.mjs b/internal/check-frozen-labels.mjs index 33c4e38ec..a7e3b7893 100644 --- a/internal/check-frozen-labels.mjs +++ b/internal/check-frozen-labels.mjs @@ -206,9 +206,26 @@ function findStoredTreeFreeze(code) { // 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 + // ★ THE MECHANISM IS "BUILT CORRECTLY, THEN PARKED", not "built once at setup" (gospel, on + // upg100's legendOpts). That function re-reads t() correctly on EVERY call — its own comment + // says the function shape is deliberate — and the freeze is one level out: the RESULT is + // assigned into a ref and nothing re-fills it. A rule that only looked for a translator outside + // a function would miss it entirely, and it is the shape that will recur, because it looks + // correct at the call site. + // + // So: a translator ANYWHERE in the function, plus a returned structure, plus a stored result. + // The returned-structure test stays because it is what separates a label builder from a + // fetcher that happens to call t() once for an error string — five production false positives + // came from dropping it. + const callsTranslator = /\b(?:t|tStatic|tf)\s*\(\s*['"`]/.test(body) + const returnsStructure = /return\s*[{[]/.test(body) + if (!(callsTranslator && returnsStructure)) continue + // ⚠ A GETTER DEFERS THE READ TO WHOEVER TOUCHES THE PROPERTY, so storing the object is safe — + // the label resolves on access, not on construction. useDashboardShell builds its segment list + // exactly this way and says why in its own comment ("a resolved string would freeze the first + // locale seen"), and the looser rule above flagged that CORRECT code. Same escape hatch the + // provide() rule uses, for the same reason: what matters is when the read happens. + if (/\bget\s+[A-Za-z_$][\w$]*\s*\(\s*\)\s*\{/.test(body)) 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) @@ -348,6 +365,15 @@ const CASES = [ // ⚠ gospel's model.js:46 matched only because a DOC COMMENT quotes a translator call. ['GOOD a doc comment that quotes t()', () => scanSource("// returns { title: t('a.b') } for each zone\nfunction opts(){ return { id: 1 } }\nnodes.value = build(opts())").filter((f) => f.call === 'stored-tree').length, 0], + // ── gospel (GOS-VERIFY): the mechanism is "built correctly, then parked", not "built at setup" ── + // upg100's legendOpts re-reads t() on every call; the freeze is that its RESULT is assigned to a + // ref and nothing re-fills it. A rule looking only for a translator OUTSIDE a function misses it. + ['BAD strings built above the return, result stored', + () => scanSource("function opts(){ const s = { a: t('x.y') }; return { title: (z) => s.a } }\nnodes.value = build(opts())").filter((f) => f.call === 'stored-tree').length, 1], + // ⚠ a GETTER defers the read to the consumer, so storing the object is safe. useDashboardShell + // builds its segment list this way on purpose; the looser rule above flagged that correct code. + ['GOOD returned object exposes labels through getters', + () => scanSource("function seg(){ return [{ k: 'map', get l() { return tStatic('dashboard.map') } }] }\nconst SEG = seg()").filter((f) => f.call === 'stored-tree').length, 0], // ── gospel's PATTERN 1, real in GiCombos.vue and still live on development ──────────────────── // Every call site is keyed and every key resolves in all eleven locales; what is wrong is WHEN // they are read. Their fix defers the read to the consumer through a getter.