diff --git a/internal/check-hardcoded-english.mjs b/internal/check-hardcoded-english.mjs index 4e358cbb8..c413be76c 100644 --- a/internal/check-hardcoded-english.mjs +++ b/internal/check-hardcoded-english.mjs @@ -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 = ` ` 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. 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