diff --git a/internal/check-hardcoded-english.mjs b/internal/check-hardcoded-english.mjs index a111e7ec3..35b5f5528 100644 --- a/internal/check-hardcoded-english.mjs +++ b/internal/check-hardcoded-english.mjs @@ -189,6 +189,26 @@ function isDomQuerySite(parent) { } // A literal that is an argument to one of these never reaches a reader, whatever it says. +// ★ CONCATENATION HIDES A DEVELOPER SITE FROM ITS OWN GUARD. `throw new Error(\`head\` + rest)` +// puts a BinaryExpression between the literal and the NewExpression, so the parent check below sees +// the `+` and not the `new Error`. Measured on legend-tree.js:392, a thrown message reported as a +// leak. It is NOT a .js-path bug — a .vue file concatenating inside a throw hits it identically; the +// .js scan just surfaced a shape that is rarer in templates. +// +// Only `+` chains are walked: a string built with + is still the same argument to the same call, +// whereas any other expression type between the two is a different construct and should be judged +// on its own site. +function developerSiteAncestor(parent, stack) { + if (isDeveloperSite(parent)) return true + if (!parent || parent.type !== 'BinaryExpression' || parent.operator !== '+') return false + for (let i = stack.length - 1; i >= 0; i--) { + const n = stack[i] + if (n.type === 'BinaryExpression' && n.operator === '+') continue + return isDeveloperSite(n) + } + return false +} + function isDeveloperSite(parent) { if (!parent) return false if (parent.type === 'CallExpression' && parent.callee?.object?.name === 'console') return true @@ -513,7 +533,7 @@ function scanScript(code, file, out, lineBase = 0) { // ★ DEVELOPER STRINGS ARE EXEMPT BY CALL SITE, NOT BY CONTENT (upg100). `throw new Error('…')` // and console.* never reach a reader, and judging them by their text means inventing a rule for // every sentence a developer might write. The SITE is the fact; the wording is not. - if (isDeveloperSite(parent) || isDomQuerySite(parent)) return + if (developerSiteAncestor(parent, stack) || isDomQuerySite(parent)) return if (parent && /Import|Export/.test(parent.type)) return if (parent && parent.type === 'TaggedTemplateExpression') return if (guardedFallback(stack, names)) return @@ -944,6 +964,20 @@ const a = greeting + 'Welcome to the prayer map' 'an HTML attribute seam on dir/lang is allowed') ok(!allowedBy('Direction: left to right'), 'prose ABOUT direction is NOT — the shape needs an attribute name followed by =') + // ★ A DEVELOPER SITE REACHED THROUGH CONCATENATION. `throw new Error(\`head\` + rest)` puts a + // BinaryExpression between the literal and the NewExpression, so the parent-only check missed it + // and reported a thrown message as a leak (legend-tree.js:392). Not a .js-path bug — a .vue file + // concatenating inside a throw hits it identically. + const DEV = ` +` + const dev = scanSfc(DEV, 'dev.vue').map((f) => f.text) + ok(!dev.some((t) => /leaf nodes violate/.test(t)), + 'QUIET on a thrown developer message built by CONCATENATION') + ok(dev.some((t) => /A sentence a reader actually sees/.test(t)), + 'COUNTS a concatenated sentence that is NOT at a developer site') // the catalogue-side rule, tested directly: the two facts that make a fallback safe ok(fallbackFullyResolved('dashboard.map_loading'), 'a key present and really translated in all 11 locales counts as resolved')