Report the line of each hardcoded string, not just the file

Triage meant grepping the reported string to find its site, and the first
textual match is very often a comment that discusses the string rather than
the code that renders it. Two findings were sited to prose that way, and a
count built on those sites was wrong.

Each finding now carries the line. <script> and <script setup> are scanned
separately rather than joined, because concatenating them made every line in
the second block wrong by the length of the first — a confident wrong site
sends the reader further off than no site at all. A bound expression is
re-parsed on its own, so its findings take the attribute's line instead of
the fragment's own numbering.

The self-test reads the reported line back out of the fixture and requires
it to contain the string, so an offset error fails rather than passing with
a plausible number.

Detection is unchanged: 249 before and after.
This commit is contained in:
Doxa Maps 2026-09-29 04:05:48 +00:00
parent c0ff2d3714
commit c7677edd57

View file

@ -251,7 +251,7 @@ function literalProps(node, file, out) {
if (!PROP_HINT.test(p.name)) continue
const text = String(p.value.content).trim()
if (!text.length || !isProse(text) || allowedBy(text)) continue
out.push({ file, kind: `suspect-prop:${p.name}`, text: `<${node.tag} ${p.name}="${text}">`, suspect: true })
out.push({ file, kind: `suspect-prop:${p.name}`, text: `<${node.tag} ${p.name}="${text}">`, suspect: true, line: p.loc?.start?.line ?? node.loc?.start?.line ?? 0 })
}
}
@ -295,7 +295,22 @@ function guardedFallback(stack, names) {
})
}
function scanScript(code, file, out) {
// A node's own line, shifted into file coordinates. Babel counts from 1, so a block that itself
// starts at line N puts its first line at N, not N+1 — hence the -1. Missing loc (errorRecovery can
// produce it) degrades to the block start rather than to a confident 0.
function lineOf(node, lineBase) {
const l = node?.loc?.start?.line
return typeof l === 'number' ? l + lineBase - (lineBase ? 1 : 0) : (lineBase || 0)
}
// ★ A FINDING WITHOUT A LINE COSTS THE READER THE SEARCH. Reporting only file + text means every
// owner re-derives the site by grepping the string, and the FIRST textual match is very often a
// comment that discusses the string rather than the code that renders it — sr triaged two findings
// to the wrong site that way before adding this. `lineBase` maps a parsed block's own coordinates
// back onto the file: 0 for a whole script block that starts at line 1, the block's start line
// otherwise, and the attribute's line for a bound expression (which is re-parsed standalone, so its
// internal numbering describes the fragment and not the file).
function scanScript(code, file, out, lineBase = 0) {
const names = localizedNames(code)
const ast = babel.parse(code, { sourceType: 'module', plugins: ['jsx'], errorRecovery: true })
walkAst(ast, (node, parent, stack) => {
@ -316,7 +331,7 @@ function scanScript(code, file, out) {
if (!text.length || !isProse(text)) return // gospel: empty matches were being reported
if (!/\s/.test(text)) return // ⚠ the single-word blind spot, documented above
if (allowedBy(text)) return
out.push({ file, kind: 'script-string', text })
out.push({ file, kind: 'script-string', text, line: lineOf(node, lineBase) })
return
}
// ★ ONE TEMPLATE LITERAL IS ONE FINDING (upg100). Reporting each quasi separately turned a single
@ -330,7 +345,7 @@ function scanScript(code, file, out) {
})
if (!leaks) return
const whole = quasis.map((q) => q.trim()).filter(Boolean).join(' {…} ').trim()
out.push({ file, kind: 'template-literal', text: whole })
out.push({ file, kind: 'template-literal', text: whole, line: lineOf(node, lineBase) })
})
}
@ -339,12 +354,14 @@ function scanScript(code, file, out) {
// nothing — a blind spot every one of the four private scanners shared (sr, 2026-09-28; real case
// DashboardDataTable.vue:884). The expression is parsed with the same rules as a script block, so a
// literal inside t()/tf(), or behind a te() guard, still passes.
function scanBoundExpr(expr, file, attrName, out) {
function scanBoundExpr(expr, file, attrName, out, line = 0) {
const code = String(expr || '').trim()
if (!code) return
const found = []
try { scanScript(`(${code})`, file, found) } catch { return } // not parseable as an expression: skip
for (const f of found) out.push({ file, kind: `bind:${attrName}`, text: f.text })
// The fragment was parsed alone, so `f.line` numbers the fragment; the ATTRIBUTE's line is the
// fact the reader needs. A multi-line bound expression therefore reports its opening line.
for (const f of found) out.push({ file, kind: `bind:${attrName}`, text: f.text, line })
}
// ★ dir="ltr" MARKS TEXT THAT IS MEANT TO STAY ENGLISH. gospel's plugins use it as the island
@ -376,13 +393,13 @@ function walkTemplate(node, file, out, ltr = false) {
: (ltr && typeof ltr === 'object' ? ltr : null)
if (node.type === 2) {
const raw = String(node.content || '').trim()
if (raw.length && isProse(raw) && !allowedBy(raw)) out.push({ file, kind: 'template-text', text: raw, deliberate: !!inLtr, island })
if (raw.length && isProse(raw) && !allowedBy(raw)) out.push({ file, kind: 'template-text', text: raw, deliberate: !!inLtr, island, line: node.loc?.start?.line ?? 0 })
}
if (node.type === 1) literalProps(node, file, out)
if (node.type === 1) for (const p of node.props || []) {
if (p.type === 6 && VISIBLE_ATTRS.has(p.name)) {
const v = p.value && p.value.content
if (v && isProse(v) && !allowedBy(v)) out.push({ file, kind: `attr:${p.name}`, text: v, deliberate: !!inLtr, island })
if (v && isProse(v) && !allowedBy(v)) out.push({ file, kind: `attr:${p.name}`, text: v, deliberate: !!inLtr, island, line: p.loc?.start?.line ?? node.loc?.start?.line ?? 0 })
}
// (interpolations are handled below, on the node itself)
// type 7 = DIRECTIVE. v-bind carries the attribute name in `arg`.
@ -394,7 +411,7 @@ function walkTemplate(node, file, out, ltr = false) {
// sentences that happen to contain a colon.
if (attrName !== 'style' && attrName !== 'class') {
const before = out.length
scanBoundExpr(p.exp.content, file, attrName, out)
scanBoundExpr(p.exp.content, file, attrName, out, p.loc?.start?.line ?? node.loc?.start?.line ?? 0)
for (let i = before; i < out.length; i++) { out[i].deliberate = !!inLtr; out[i].island = island }
}
}
@ -404,7 +421,7 @@ function walkTemplate(node, file, out, ltr = false) {
// `{{ t('Foo') }}` stays quiet because the expression is parsed with the same translator rules.
if (node.type === 5 && node.content?.content) {
const before = out.length
scanBoundExpr(node.content.content, file, 'interpolation', out)
scanBoundExpr(node.content.content, file, 'interpolation', out, node.loc?.start?.line ?? 0)
for (let i = before; i < out.length; i++) { out[i].deliberate = !!inLtr; out[i].island = island }
}
for (const c of node.children || []) walkTemplate(c, file, out, island || inLtr)
@ -415,8 +432,16 @@ export function scanSfc(src, file) {
const { descriptor, errors } = parse(src, { filename: file })
if (errors && errors.length) throw new Error(`${file}: ${errors[0].message}`)
if (descriptor.template && descriptor.template.ast) walkTemplate(descriptor.template.ast, file, out)
// ★ SCANNED SEPARATELY, NOT JOINED. Concatenating <script> and <script setup> made every line
// number in the second block wrong by the length of the first, which is worse than no line at
// all — a confident wrong site sends the reader to unrelated code. Each block carries its own
// start line, so each is parsed in its own coordinates. crossAreaKeys still reads the joined
// text, because it matches key STRINGS and never reports a position.
for (const blk of [descriptor.script, descriptor.scriptSetup]) {
if (blk?.content) scanScript(blk.content, file, out, blk.loc?.start?.line ?? 0)
}
const code = [descriptor.script?.content, descriptor.scriptSetup?.content].filter(Boolean).join('\n')
if (code) { scanScript(code, file, out); out.push(...crossAreaKeys(code, file)) }
if (code) out.push(...crossAreaKeys(code, file))
return out
}
@ -544,6 +569,24 @@ function selfTest() {
ok(bad.some((f) => f.kind === 'bind:title' && /Sort this table by/.test(f.text)), 'SEES a TEMPLATE LITERAL inside a bound attribute (sr: every scanner missed this)')
ok(bad.some((f) => f.kind === 'bind:title' && f.text === 'Reset the view'), "SEES a quoted '…' literal inside a bound attribute (both quote styles)")
ok(bad.some((f) => f.kind === 'bind:interpolation' && /Everything loaded|Nothing loaded/.test(f.text)), 'SEES a literal inside an INTERPOLATION expression')
// ★ EVERY FINDING CARRIES A LINE, AND THE LINE MUST HOLD THE STRING. Asserting only that a line
// is PRESENT would pass on a confidently wrong number, which is the failure this exists to stop:
// sr sent itself to two comment lines that merely discussed a string while the rendering code sat
// elsewhere. So the fixture's own source is the oracle — read the reported line back and require
// the text (or, for a split literal, its first fragment) to be on it. An off-by-one from a block
// offset, or a fragment's local numbering leaking out of a bound expression, fails here.
const badLines = PLANTED_BAD.split('\n')
ok(bad.every((f) => typeof f.line === 'number' && f.line > 0), 'every finding carries a LINE')
const misplaced = bad.filter((f) => {
const src = badLines[f.line - 1] || ''
// A suspect's text is SYNTHESISED (`<Tag prop="value">`) and so is not on any line verbatim;
// its prop VALUE is the part the source actually holds.
const m = /^<\S+ \S+="(.*)">$/.exec(String(f.text))
const needle = (m ? m[1] : String(f.text).split(' {…} ')[0]).trim()
return needle && !src.includes(needle)
})
ok(misplaced.length === 0,
`every reported line actually CONTAINS its string (${misplaced.length} misplaced${misplaced.length ? ': ' + misplaced.map((f) => `${f.kind}@${f.line} ${JSON.stringify(f.text.slice(0, 28))}`).join('; ') : ''})`)
const splits = bad.filter((f) => /polys|zones/.test(f.text))
ok(splits.length === 1, `a split template literal is ONE finding, not one per fragment (got ${splits.length})`)
ok(splits.length === 1 && /polys.*\{…\}.*zones/.test(splits[0].text), 'and it is reported as the WHOLE string with its holes marked')
@ -752,7 +795,7 @@ if (composed.length) {
// reader will have, and the answer is a fact about the composition graph rather than a judgement.
console.log(` including ${composed.length} reached only by composition: ${composed.map((n) => `${n} (${stageReason(MANIFEST, n)})`).join('; ')}`)
}
for (const f of leaks) console.log(` ${f.kind.padEnd(16)} ${f.file.replace(/^.*plugin-designs\//, '')} ${JSON.stringify(f.text).slice(0, 90)}`)
for (const f of leaks) console.log(` ${f.kind.padEnd(16)} ${f.file.replace(/^.*plugin-designs\//, '')}:${f.line ?? 0} ${JSON.stringify(f.text).slice(0, 90)}`)
if (deliberate.length) {
// Listed so nobody wonders where they went, but NOT counted: dir="ltr" is the plugin's own marker
// for content the Driver ruled stays English. Asking someone to "fix" these would be asking them