Report English handed to a component as a prop
A literal string passed to a child that renders it is invisible to every scan we have: in the parent it is an attribute value, in the child an interpolation of a variable. The English and the render are in different files, so no single-file rule can join them. One plugin hands a component a unit and a noun that it prints as visible text, and a shared component derives the plural by appending an s -- an English rule in library code, with no English string at the site to find. Reported as suspect, never counted. Whether the child renders the prop is not knowable from here; it may be a key, an id, or an event name. Calling a suspect a defect would make the count untrustworthy in the other direction, so this is a question for a human rather than a verdict. Only component tags, and only prop names that suggest rendered text, so an event name is left alone. Plain elements keep going through the existing visible-attribute rule. Canary mode now triages an unplantable file too. "No text surface" and "hands literal English to a child" are opposite risks, and reporting them together buried the second inside the first -- which is exactly how this class stayed hidden inside a bland count.
This commit is contained in:
parent
f3f57f6a04
commit
982375e085
1 changed files with 60 additions and 4 deletions
|
|
@ -215,6 +215,32 @@ export function crossAreaKeys(code, file, root = '.') {
|
|||
return out
|
||||
}
|
||||
|
||||
// ── LITERAL STRINGS PASSED AS PROPS ─────────────────────────────────────────────────────────────
|
||||
// ★ THE THIRD FALSE-NEGATIVE CLASS, and the gate is structurally unable to confirm it (gospel).
|
||||
// poster-wall.vue passes `unit="UPG" item-noun="list"` to <PosterWall>, and PosterWall.vue renders
|
||||
// `{{ itemNoun }}` and `{{ unit }}` as visible text. Neither file shows a leak to a template scan:
|
||||
// in the plugin it is an attribute VALUE, in the library it is an interpolation of a VARIABLE. The
|
||||
// English and the render live in different files, so no single-file rule can join them.
|
||||
// Reported as SUSPECT, never counted as a leak, because whether the child renders the prop is not
|
||||
// knowable from here — the child may use it as a key, an id, or an event name. A suspect is a
|
||||
// question for a human, and calling it a defect would make the count untrustworthy in the other
|
||||
// direction.
|
||||
const PROP_HINT = /noun|label|title|unit|text|heading|intro|placeholder|caption/i
|
||||
// Component tags only: PascalCase, or hyphenated custom elements. A plain <div> takes attributes,
|
||||
// not props, and its visible ones are already covered by VISIBLE_ATTRS.
|
||||
const isComponentTag = (tag) => /^[A-Z]/.test(tag) || /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(tag)
|
||||
|
||||
function literalProps(node, file, out) {
|
||||
if (node.type !== 1 || !isComponentTag(node.tag || '')) return
|
||||
for (const p of node.props || []) {
|
||||
if (p.type !== 6 || !p.value?.content) continue // static attribute only
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
// ── the walk ────────────────────────────────────────────────────────────────────────────────────
|
||||
function walkAst(node, visit, parent = null, stack = []) {
|
||||
if (!node || typeof node.type !== 'string') return
|
||||
|
|
@ -329,6 +355,7 @@ function walkTemplate(node, file, out, ltr = false) {
|
|||
const raw = String(node.content || '').trim()
|
||||
if (raw.length && isProse(raw) && !allowedBy(raw)) out.push({ file, kind: 'template-text', text: raw, deliberate: inLtr })
|
||||
}
|
||||
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
|
||||
|
|
@ -397,6 +424,7 @@ const PLANTED_BAD = `<template>
|
|||
<th :title="\`Sort this table by \${col}\`">h</th>
|
||||
<button :title="'Reset the view'">r</button>
|
||||
<span>{{ ok ? 'Everything loaded' : 'Nothing loaded' }}</span>
|
||||
<PosterWall unit="UPG" item-noun="list" event-name="doxa:show-on-map" />
|
||||
<p dir="ltr">Counted BY LANGUAGE, so groups collapse.</p>
|
||||
<div dir="ltr"><em>Source: eBible, checked 2026-09</em></div>
|
||||
<p><bdi dir="ltr">Of {{ tally.langs }} sign languages on this map, covering {{ tally.people }} people,
|
||||
|
|
@ -470,7 +498,14 @@ function selfTest() {
|
|||
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')
|
||||
const ltr = bad.filter((f) => f.deliberate)
|
||||
// ★ literal props handed to a component that may render them (gospel's poster-wall case)
|
||||
const sus = bad.filter((f) => f.suspect)
|
||||
ok(sus.some((f) => f.kind === 'suspect-prop:unit' && /UPG/.test(f.text)), 'SEES a literal prop whose name suggests it is rendered (unit=)')
|
||||
ok(sus.some((f) => f.kind === 'suspect-prop:item-noun'), 'and one named item-noun')
|
||||
ok(!sus.some((f) => /event-name/.test(f.kind)), 'QUIET on a prop whose name does not suggest rendering (event-name=)')
|
||||
ok(!bad.filter((f) => !f.deliberate && !f.suspect).some((f) => /UPG|item-noun/.test(f.text)),
|
||||
'a SUSPECT is reported separately and is NOT counted as a leak — the child may never render it')
|
||||
const ltr = bad.filter((f) => f.deliberate && !f.suspect)
|
||||
ok(ltr.some((f) => /Counted BY LANGUAGE/.test(f.text)), 'marks text inside dir="ltr" as DELIBERATE, not a leak')
|
||||
ok(ltr.some((f) => /Source: eBible/.test(f.text)), 'and INHERITS the marker down the subtree')
|
||||
// ★ gospel's real case: an island SPLIT BY INTERPOLATIONS. Each run between two {{ }} is its own
|
||||
|
|
@ -573,7 +608,20 @@ function canaryMode() {
|
|||
const src = withCanary(readFileSync(f, 'utf8'), f)
|
||||
// ★ A SKIP IS NOT A PASS, and hiding it would repeat the mistake this whole mode exists to
|
||||
// prevent: a file nobody could plant a canary in is a file whose zero is unproven. Reported.
|
||||
if (src === null) { skipped.push(`${plugin}/${f.split('/').pop()}`); continue }
|
||||
if (src === null) {
|
||||
// ★ TRIAGE THE SKIP. "Unplantable" is two different states and they carry opposite risk: a
|
||||
// component with no text surface has nothing to leak, while one that hands literal English
|
||||
// to a child has a leak this gate cannot confirm. Reporting them together would bury the
|
||||
// second in the first — which is how gospel's poster-wall case sat inside a bland count.
|
||||
let why = 'no text surface'
|
||||
try {
|
||||
const probe = scanSfc(readFileSync(f, 'utf8'), f)
|
||||
const sp = probe.filter((x) => x.suspect)
|
||||
if (sp.length) why = `PASSES ${sp.length} LITERAL PROP(S) — ${sp.map((x) => x.text).join(', ')}`
|
||||
} catch { why = 'unparseable' }
|
||||
skipped.push(`${plugin}/${f.split('/').pop()} — ${why}`)
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
let found
|
||||
try { found = scanSfc(src, f) } catch (e) { bad.push(`${f.split('/').pop()} (parse: ${e.message})`); continue }
|
||||
|
|
@ -617,8 +665,9 @@ try {
|
|||
for (const d of dirs) for (const f of sfcsIn(d)) { files++; findings.push(...scanSfc(readFileSync(f, 'utf8'), f)) }
|
||||
} catch (e) { console.error('HARDCODED-ENGLISH: parse error —', e.message); process.exit(2) }
|
||||
|
||||
const deliberate = findings.filter((f) => f.deliberate)
|
||||
const leaks = findings.filter((f) => !f.deliberate)
|
||||
const deliberate = findings.filter((f) => f.deliberate && !f.suspect)
|
||||
const suspects = findings.filter((f) => f.suspect)
|
||||
const leaks = findings.filter((f) => !f.deliberate && !f.suspect)
|
||||
// ★ THE RUN NAMES ITS OWN SHA. Three reports today were true of an older artifact than the one in
|
||||
// hand, and each cost someone a wasted investigation — the last one nearly had a working island
|
||||
// check "fixed". gospel's corollary is that the reader cannot check what the writer did not say, so
|
||||
|
|
@ -643,6 +692,13 @@ if (deliberate.length) {
|
|||
for (const f of deliberate.slice(0, 8)) console.log(` · ${f.file.replace(/^.*plugin-designs\//, '')} ${JSON.stringify(f.text).slice(0, 80)}`)
|
||||
if (deliberate.length > 8) console.log(` · …and ${deliberate.length - 8} more`)
|
||||
}
|
||||
if (suspects.length) {
|
||||
// NOT counted. Whether the child renders the prop is not knowable from here, so this is a question
|
||||
// for a human rather than a defect. It is the only visibility we have into a class the gate cannot
|
||||
// confirm: the English is in one file and the render is in another.
|
||||
console.log(`\n ${suspects.length} SUSPECT literal prop(s) — English handed to a component that may render it:`)
|
||||
for (const f of suspects) console.log(` ? ${f.file.replace(/^.*plugin-designs\//, '')} ${f.text}`)
|
||||
}
|
||||
// ★ THE STAMP GOES ON THE LINE PEOPLE QUOTE, not only on the header. gospel's qualification is that
|
||||
// they investigated our mismatch because the ARITHMETIC WOULD NOT CLOSE — an eight-hit gap was too
|
||||
// large to rationalise — and that a gap of one or two would have been shrugged off as drift. So the
|
||||
|
|
|
|||
Loading…
Reference in a new issue