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:
Doxa Maps 2026-09-28 12:56:28 +00:00
parent f3f57f6a04
commit 982375e085

View file

@ -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