From a0110a72162689325850376fa75d40fbb6a2453e Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 09:05:41 +0000 Subject: [PATCH] Catch a tree that is built correctly and then parked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gospel verified the upg100 finding independently and refined the mechanism, which changes what the rule has to look for. legendOpts re-reads t() on every call — its own comment says the function shape is deliberate — so a reader skimming for "a translator outside a function" would clear it. The freeze is one level out: the result is assigned into a ref at two call sites and no locale watcher ever re-fills them. The labels are computed freshly and then frozen by where they are put. So the rule now asks for a translator anywhere in the function plus a returned structure plus a stored result, rather than requiring the call to sit inside the returned literal. The old form happened to catch upg100's case only because one property calls t() inline; a version building its strings above the return would have slipped through, and that is the shape that will recur because it looks correct at the call site. Loosening it immediately flagged correct code in this owner's own plugin: useDashboardShell builds its segment list with `get l() { return tStatic(…) }` and says why in a comment — a resolved string would freeze the first locale seen. A getter defers the read to whoever touches the property, so storing the object is safe. Same escape hatch the provide() rule uses, for the same reason: what matters is when the read happens. Both new cases are in the table, and both sides of the real finding are now proven against real code rather than fixtures: development's upg-100-list.vue FAILS and upg100's fixed version at d533db379 PASSES, which matches the result they got running this gate against their own slot head. Self-test 26/26. --- internal/check-frozen-labels.mjs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) 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.