A developer site reached through concatenation is still a developer site

`throw new Error(`head ${n} tail` + detail)` puts a BinaryExpression between
the literal and the NewExpression, so the parent-only check saw the `+` and
not the throw, and reported a thrown message as a reader-visible leak.

Found while measuring the .js scan on legend-tree.js:392, and my first
report of it was wrong in a way worth recording: I told dx-shepherd the
developer-site exemption "is not firing on the .js path". It fires fine.
The gap is concatenation, and a .vue file concatenating inside a throw hits
it identically — the .js scan only surfaced a shape that is rarer in
templates. Three .vue findings cleared when this landed, which is the proof
it was never .js-specific: 92 -> 89.

Only `+` chains are walked. A string built with + is still the same argument
to the same call; any other expression type between the two is a different
construct and should be judged on its own site.

Two-sided: a thrown message built by concatenation goes quiet, and a
concatenated sentence that is NOT at a developer site still counts.

The .js scan itself is NOT in this commit. It needs the data-map exemption
first, or it hands two owners ~60 findings that are lookup keys they have
already classified.
This commit is contained in:
Doxa Maps 2026-09-29 09:38:30 +00:00
parent 051a876768
commit f96381481b

View file

@ -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 = `<template><p>{{ x }}</p></template>
<script setup>
throw new Error(\`legend-tree L15: \${n} leaf nodes violate the shape\` + detail)
const shown = 'A sentence a reader actually sees' + suffix
</script>`
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')