A DOM query argument is a selector, not prose

gospel's diff of a hand triage against the checker found this: a selector
listing two custom elements was reported as user-visible English, because it
reads as English only where the tag names happen to be English words. The
single-tag rule matched neither, since it is a comma-separated list.

Exempt by call site rather than by content, as with developer strings. No
content rule can reliably tell a tag list from a sentence, and the site is a
fact about what the string is for. Covers querySelector, querySelectorAll,
closest, matches and the getElementBy* family.

The self-test plants gospel's own case and a second list of custom elements
from another plugin, since the pattern is not theirs alone.
This commit is contained in:
Doxa Maps 2026-09-28 12:14:13 +00:00
parent 66765053d2
commit bee8d7182c

View file

@ -41,6 +41,7 @@
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { execSync } from 'node:child_process'
import { parse } from '@vue/compiler-sfc'
import * as babel from '@babel/parser'
@ -91,6 +92,22 @@ function localizedNames(code) {
for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(t|tf)\b\s*(?![(.])/g)) names.add(m[1])
return names
}
// ★ A DOM QUERY ARGUMENT IS A SELECTOR, NOT PROSE — and a selector made of custom-element tags
// reads as English only because the tag names are English words. gospel found
// `querySelector("gospel-resources-dashboard, gospel-resources-map")` reported as user-visible text:
// the single-tag rule matched neither, because it is a comma-separated LIST. Exempting by CALL SITE
// is the right shape (as with developer strings) — the site is the fact, and no content rule can
// reliably tell a tag list from a sentence.
const DOM_QUERY = new Set([
'querySelector', 'querySelectorAll', 'closest', 'matches', 'getElementById',
'getElementsByClassName', 'getElementsByTagName', 'getElementsByName',
])
function isDomQuerySite(parent) {
return !!parent && parent.type === 'CallExpression'
&& parent.callee?.type === 'MemberExpression'
&& DOM_QUERY.has(parent.callee.property?.name || '')
}
// A literal that is an argument to one of these never reaches a reader, whatever it says.
function isDeveloperSite(parent) {
if (!parent) return false
@ -249,7 +266,7 @@ function scanScript(code, file, out) {
// ★ DEVELOPER STRINGS ARE EXEMPT BY CALL SITE, NOT BY CONTENT (upg100). `throw new Error('…')`
// and console.* never reach a reader, and judging them by their text means inventing a rule for
// every sentence a developer might write. The SITE is the fact; the wording is not.
if (isDeveloperSite(parent)) return
if (isDeveloperSite(parent) || isDomQuerySite(parent)) return
if (parent && /Import|Export/.test(parent.type)) return
if (parent && parent.type === 'TaggedTemplateExpression') return
if (guardedFallback(stack, names)) return
@ -376,6 +393,8 @@ const PLANTED_BAD = `<template>
<span>{{ ok ? 'Everything loaded' : 'Nothing loaded' }}</span>
<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,
{{ tally.both }} have both a film and a text Scripture.</bdi></p>
</div>
</template>
<script setup>
@ -408,6 +427,8 @@ const proxyMiss = t.search || 'Search names, country, religion…'
const keyMiss = msg !== 'messages.engineMissing' ? msg : 'The map engine did not load.'
const devThrow = () => { throw new Error('table not found for this request') }
const devWarn = () => console.warn('boundaries failed to load for this map')
const host = document.querySelector('gospel-resources-dashboard, gospel-resources-map')
const one = el.closest('regions-dashboard, doxa-simple-map')
</script>`
const XAREA_BAD = `const t = useOptionalT('regions')
@ -446,6 +467,13 @@ function selfTest() {
const ltr = bad.filter((f) => f.deliberate)
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
// text node, so a check that only marked a whole-text island reported the pieces as separate
// leaks — and they nearly got translated. <bdi dir="ltr"> is the element they actually use.
const splitIsland = bad.filter((f) => /sign languages on this map|both a film/.test(f.text))
ok(splitIsland.length > 0, 'sees the pieces of an interpolation-split island at all')
ok(splitIsland.every((f) => f.deliberate),
`every piece of an interpolation-split dir="ltr" island is DELIBERATE (got ${splitIsland.filter((f) => !f.deliberate).length} not marked)`)
ok(bad.filter((f) => !f.deliberate).some((f) => f.text === 'Prayer Coverage'), 'while a leak OUTSIDE the island is still counted')
ok(!good.some((f) => f.text === 'All Regions'), 'QUIET on a call through an ALIAS of t()')
ok(!good.some((f) => f.text === 'Regions List'), 'QUIET on a te()-guarded fallback')
@ -455,6 +483,8 @@ function selfTest() {
ok(!good.some((f) => /map engine did not load/.test(f.text)), 'QUIET on the "did t() return the key?" miss idiom (msg !== \'a.dotted.key\')')
ok(!good.some((f) => /table not found/.test(f.text)), 'QUIET on a throw new Error(…) argument — exempt by CALL SITE')
ok(!good.some((f) => /boundaries failed/.test(f.text)), 'QUIET on a console.warn(…) argument')
ok(!good.some((f) => /gospel-resources-dashboard|regions-dashboard, doxa/.test(f.text)),
'QUIET on a DOM query selector, including a comma-separated list of custom-element tags')
ok(!good.some((f) => /rd-card|example\.test|nu-latn|HTTP/.test(f.text)), 'QUIET on css, urls, locale extensions and developer error text')
ok(good.length === 0, `QUIET overall on a correct file (got ${good.length}${good.length ? ': ' + JSON.stringify(good[0]) : ''})`)
// The blind spot is PINNED, not merely mentioned: if single-word detection is ever added, this
@ -473,6 +503,15 @@ function selfTest() {
return fails
}
// Short HEAD sha plus a dirty marker, or 'unknown' outside a checkout.
function treeStamp() {
try {
const sha = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
const dirty = execSync('git status --porcelain', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
return dirty ? `${sha}+dirty(${dirty.split('\n').length} file(s))` : sha
} catch { return 'unknown (not a git checkout)' }
}
// ── main ────────────────────────────────────────────────────────────────────────────────────────
// RUN ONLY WHEN INVOKED DIRECTLY. Without this guard, `import`ing the module to reuse scanSfc() runs
// the whole production scan as a side effect — which is how I first "verified" it against sr's real
@ -497,7 +536,12 @@ try {
const deliberate = findings.filter((f) => f.deliberate)
const leaks = findings.filter((f) => !f.deliberate)
console.log(`\n ${dirs.length} production plugin(s), ${files} SFC(s) scanned`)
// ★ 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
// the tool says it: any number quoted from this output carries the tree it was measured on, and a
// dirty tree is marked because then the sha alone does not describe what ran.
console.log(`\n ${dirs.length} production plugin(s), ${files} SFC(s) scanned · tree ${treeStamp()}`)
for (const f of leaks) console.log(` ${f.kind.padEnd(16)} ${f.file.replace(/^.*plugin-designs\//, '')} ${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