diff --git a/internal/check-frozen-labels.mjs b/internal/check-frozen-labels.mjs index 406cc4522..74e008886 100644 --- a/internal/check-frozen-labels.mjs +++ b/internal/check-frozen-labels.mjs @@ -150,6 +150,42 @@ const DEAD_LOCALE_GETTER = /\b(getRuntimeLocale|detectLocale|localeFromUrl)\s*\( * 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. */ +/** + * Is offset `at` inside the parentheses of a computed()/watch()/watchEffect() that tracks the locale? + * + * ⚠ BRACE DEPTH, NOT A LOOKBACK WINDOW (gospel, GOS-FROZEN-SR). The first version of this asked + * whether a wrapper appeared in the preceding 260 characters. gospel hit exactly this in GiDrawer.vue + * — the enclosing `computed(` opens at line 35 and the call sites are at 95-100 — and reported SIX + * false positives on working code, nearly "fixing" it. Measured on the same shape here: 2,600 + * characters between the `watch(` and the call, ten times the window. A window cannot answer a + * containment question; only matching brackets can. + * + * Walks backwards counting bracket depth, so it finds the wrapper that actually ENCLOSES this + * offset however far above it sits, and cannot be fooled by a closed wrapper that merely precedes it. + */ +function enclosingReactiveWrapper(code, at) { + let depth = 0 + for (let i = at; i >= 0; i--) { + const c = code[i] + if (c === ')' || c === '}' || c === ']') depth++ + else if (c === '(' || c === '{' || c === '[') { + if (depth > 0) { depth--; continue } + // an unclosed opener: is it the paren of a reactive wrapper call? + if (c === '(') { + const head = code.slice(Math.max(0, i - 40), i) + const m = /\b(computed|watch|watchEffect)\s*$/.exec(head) + if (m) { + // watchEffect always tracks what it reads; watch/computed must name a locale source. + if (m[1] === 'watchEffect') return true + const scope = code.slice(i, Math.min(code.length, at)) + if (REACTIVE_LOCALE.test(scope)) return true + } + } + } + } + return false +} + function findStoredTreeFreeze(code) { const out = [] // functions whose body calls a translator @@ -180,10 +216,9 @@ function findStoredTreeFreeze(code) { // 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 (enclosingReactiveWrapper(code, i)) live = true } if (stored && !live) { const line = code.slice(0, m.index).split('\n').length @@ -265,6 +300,14 @@ const CASES = [ // 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], + // ── gospel (GOS-FROZEN-SR): the wrapper can open FAR above the call site ────────────────────── + // Their GiDrawer.vue has computed( at line 35 and the call sites at 95-100. A lookback window + // reported SIX false positives on working code; this fixture is ~2,600 chars between the two. + ['GOOD watcher opens 40 lines above the call site', + () => scanSource("function opts(){ return { title: t('poster.zone_deaf') } }\nwatch(() => packetLang(), () => {\n // padding 0\n // padding 1\n // padding 2\n // padding 3\n // padding 4\n // padding 5\n // padding 6\n // padding 7\n // padding 8\n // padding 9\n // padding 10\n // padding 11\n // padding 12\n // padding 13\n // padding 14\n // padding 15\n // padding 16\n // padding 17\n // padding 18\n // padding 19\n // padding 20\n // padding 21\n // padding 22\n // padding 23\n // padding 24\n // padding 25\n // padding 26\n // padding 27\n // padding 28\n // padding 29\n // padding 30\n // padding 31\n // padding 32\n // padding 33\n // padding 34\n // padding 35\n // padding 36\n // padding 37\n // padding 38\n // padding 39\n nodes.value = build(opts())\n})").filter((f) => f.call === 'stored-tree').length, 0], + // ⚠ 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], ['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',