Catch a tree that is built correctly and then parked

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.
This commit is contained in:
Doxa Maps 2026-09-29 09:05:41 +00:00
parent c885b12612
commit a0110a7216

View file

@ -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.