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 = `{{ x }}
+`
+ 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')