doxa-maps/plugin-designs/research-map-plus-grid/scripts/check-composite-locale-leaks.mjs
Doxa Maps fbc517c94a A browser gate refuses to launch outside the browser slot; check:browser-gates-guarded keeps it that way
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
2026-09-28 08:20:21 +00:00

217 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* check-composite-locale-leaks.mjs — a composite rendered in a non-English locale must not show a
* string the catalogue already translates.
*
* WHY THIS REPLACES THE PREVIOUS SWEEP, AND WHY THE PREVIOUS SWEEP PASSED A BROKEN TREE.
* The old method split every rendered string into "third-party", "DATA" and "CHROME" and counted
* only the third bucket. That split is a GUESS, and it guessed wrong in exactly the place the
* defect lived: single words like 'Region', 'Prayer', 'Country', 'Language' read as data — they are
* plausible values as well as plausible labels — so 28 distinct leaks on Big Picture and 41 on
* Simple Research scored as zero. An independent verifier found them on the same tree the sweep had
* just called clean.
*
* ★ THE CRITERION IS NOT A CATEGORY, IT IS A LOOKUP. A rendered string is a LEAK when:
*
* the English catalogue holds that exact string as a value, AND
* this locale's catalogue holds a DIFFERENT value for that same key, AND
* the page still renders the English one.
*
* No judgement about what kind of word it is. If the translation exists and the reader is not
* getting it, that is the defect, whatever the word looks like. A data value that merely coincides
* with a catalogue entry is the only false positive this can produce, and it is one we WANT to see:
* a coincidence worth suppressing is worth suppressing by name, in this file, with a reason.
*
* WHERE THE DEFECT ACTUALLY IS. Not in the library components — they translate. The English is
* hardcoded at the CALL SITE, passed down as a prop that no lookup ever touches:
*
* <FilterDropdown :label="'Prayer'"> research-dashboard/.../DashboardResearchFilters.vue
*
* A component that is perfectly localized renders English because it was handed English.
*
* Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from '../../../internal/_tree-name.mjs'
const TAG = 'COMPOSITE-LOCALE-LEAKS'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
const LOCALES_DIR = join(ROOT, 'library', 'i18n', 'locales')
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
if (!existsSync(LOCALES_DIR)) skip('no locale catalogues to compare against.')
let chromium
try { ({ chromium } = await import('../../../internal/lib/playwright-guarded.mjs')) } catch { skip('playwright is not installed.') }
// The composites in this lane. Both, always: the two share the grid and half the chrome, and a fix
// proved on one has repeatedly been stale on the other.
const COMPOSITES = ['research-map-plus-grid', 'dashboard-plus-grid']
const LOCALES = (process.env.LEAK_LOCALES || 'ar').split(',').map((s) => s.trim()).filter(Boolean)
// ── the catalogues, flattened to key -> value ────────────────────────────────────────────────────
const flatten = (obj, prefix, out) => {
for (const [k, v] of Object.entries(obj || {})) {
const key = prefix ? `${prefix}.${k}` : k
if (v && typeof v === 'object') flatten(v, key, out)
else if (typeof v === 'string') out[key] = v
}
return out
}
const catalogue = (locale) => {
const dir = join(LOCALES_DIR, locale)
if (!existsSync(dir)) return null
const out = {}
for (const f of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
flatten(JSON.parse(readFileSync(join(dir, f), 'utf8')), f.replace(/\.json$/, ''), out)
}
return out
}
const EN = catalogue('en')
if (!EN) skip('no English catalogue to compare against.')
// English value -> the keys that carry it. A word like 'Region' is usually several keys; ONE key
// with a real translation is enough to make rendering the English a leak.
const byEnglish = new Map()
for (const [key, val] of Object.entries(EN)) {
const v = String(val).trim()
if (!v) continue
if (!byEnglish.has(v)) byEnglish.set(v, [])
byEnglish.get(v).push(key)
}
// ── the two things a catalogue lookup CANNOT tell apart, suppressed BY NAME with a reason ───────
// The criterion asks "does the catalogue translate this string", and for two kinds of text that
// question is the wrong one. Each entry here is a surface, not a string: suppressing a WORD would
// hide the next real leak that happens to use it, while suppressing a surface says exactly which
// element is not ours to translate and why.
const SUPPRESS = [
{ where: /^(canvas|button|a|div)\.(mapboxgl|maplibregl)-/, why: "vendor chrome — mapbox-gl/maplibre-gl render these themselves ('Map', 'Clear', 'Toggle attribution'); we pass the geocoder its placeholder and have no hook for the rest" },
{ where: /^div\.rd-td$/, why: 'a table DATA cell — the row value as the API returned it, not chrome. "Unengaged" here is a datum that happens to match controls.unengaged' },
{ where: /^span\.fr-lbl$/, why: 'a facet-rail VALUE (region names, affinity blocs, religion families as the data spells them) — the rail\'s own headings are chrome and are not in this class' },
]
// ── serve the built tree ─────────────────────────────────────────────────────────────────────────
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const leafOf = (bundle) => {
const b = (manifest.bundles || []).find((x) => x.bundle === bundle)
const p = b && (b.profiles || []).find((p) => p.iframeUrl)
return p ? String(p.iframeUrl).replace(/^\.\//, '') : null
}
const freePort = () => new Promise((ok) => {
const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
})
const PORT = await freePort()
const TREE = `http://127.0.0.1:${PORT}/${TREE_NAME}`
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
await new Promise((r) => setTimeout(r, 900))
if (!await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)) {
cleanup(); skip('could not serve the built tree locally.')
}
const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
await requireThisTree(TREE, ROOT, TAG)
// ── collect every rendered string, shadow roots included ─────────────────────────────────────────
const COLLECT = `(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
await sleep(12000) // let both halves fetch and render
const found = new Map()
const note = (text, el) => {
const t = String(text || '').replace(/\\s+/g, ' ').trim()
if (!t || t.length > 120) return
const where = el.tagName.toLowerCase() + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/)[0] : '')
if (!found.has(t)) found.set(t, { count: 0, where })
found.get(t).count++
}
const seen = new Set()
const walk = (root) => {
if (!root || seen.has(root)) return
seen.add(root)
for (const el of root.querySelectorAll('*')) {
if (el.shadowRoot) walk(el.shadowRoot)
// Only a node's OWN text: taking textContent would report every ancestor as carrying the
// string too, and one leak would score as a dozen.
for (const n of el.childNodes) {
if (n.nodeType === 3) note(n.nodeValue, el)
}
for (const a of ['placeholder', 'title', 'aria-label', 'alt']) {
const v = el.getAttribute && el.getAttribute(a)
if (v) note(v, el)
}
}
}
walk(document)
return [...found.entries()].map(([text, m]) => ({ text, count: m.count, where: m.where }))
})()`
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const report = []
let totalDistinct = 0
try {
for (const locale of LOCALES) {
const LOC = catalogue(locale)
if (!LOC) { report.push({ locale, skip: 'no catalogue' }); continue }
for (const bundle of COMPOSITES) {
const leaf = leafOf(bundle)
if (!leaf) { report.push({ locale, bundle, skip: 'no leaf in the manifest' }); continue }
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
await page.goto(`${TREE}/${leaf}?lang=${locale}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
const strings = await page.evaluate(COLLECT)
await page.close()
const leaks = []
const suppressed = []
for (const s of strings) {
const keys = byEnglish.get(s.text)
if (!keys) continue
// Translated somewhere for this locale? One key is enough — and the translation must differ
// from the English, or there is nothing for the reader to be missing.
const translated = keys.filter((k) => LOC[k] && String(LOC[k]).trim() && String(LOC[k]).trim() !== s.text)
if (!translated.length) continue
const muted = SUPPRESS.find((r) => r.where.test(s.where))
if (muted) { suppressed.push({ ...s, why: muted.why }); continue }
leaks.push({ ...s, keys: translated.slice(0, 2) })
}
if (process.env.LEAK_DUMP) {
console.log(`--- ${bundle} [${locale}] all ${strings.length} rendered strings ---`)
for (const s of strings) console.log(` ${s.where} | ${s.text}`)
}
leaks.sort((a, b) => b.count - a.count)
totalDistinct += leaks.length
report.push({ locale, bundle, leaks, suppressed, scanned: strings.length })
}
}
} catch (err) {
cleanup(); await browser.close().catch(() => {})
console.log(`${TAG}: FAIL — the measurement itself failed: ${err && err.message}`)
process.exit(1)
} finally {
await browser.close().catch(() => {})
cleanup()
}
for (const r of report) {
if (r.skip) { console.log(` · ${r.locale} ${r.bundle || ''} — SKIPPED (${r.skip})`); continue }
const n = r.leaks.length
const instances = r.leaks.reduce((a, l) => a + l.count, 0)
console.log(` ${n ? '✗' : '✓'} ${r.bundle} [${r.locale}] — ${n} distinct leak(s), ${instances} instance(s), of ${r.scanned} rendered string(s)`)
for (const l of r.leaks.slice(0, 12)) {
console.log(` '${l.text}' ×${l.count} ${l.where} → ${l.keys.join(', ')}`)
}
if (n > 12) console.log(` … and ${n - 12} more`)
for (const m of r.suppressed || []) console.log(` (not counted) '${m.text}' ×${m.count} ${m.where} — ${m.why}`)
}
if (totalDistinct) {
console.log(`\n${TAG}: FAIL — ${totalDistinct} string(s) render in English on a page asked for another locale, and the catalogue has a translation for every one of them.`)
console.log(' The library components translate; the English is hardcoded at the CALL SITE and')
console.log(' handed down as a prop (e.g. FilterDropdown :label="\'Prayer\'").')
process.exit(1)
}
console.log(`${TAG}: PASS — nothing the catalogue translates is rendering in English.`)