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:
parent
c0ff2d3714
commit
c7677edd57
1 changed files with 55 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue