diff --git a/internal/check-hardcoded-english.mjs b/internal/check-hardcoded-english.mjs index b64fdfe13..77ac51ccb 100644 --- a/internal/check-hardcoded-english.mjs +++ b/internal/check-hardcoded-english.mjs @@ -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