diff --git a/internal/check-universal-props.mjs b/internal/check-universal-props.mjs new file mode 100644 index 000000000..cf3c76b12 --- /dev/null +++ b/internal/check-universal-props.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * check-universal-props.mjs — every plugin honours the props the Plugin Studio header sets for all + * of them (documentation/reference-universal-props.md): map ENGINE, LOCALE (+dir), Mapbox TOKEN and + * token-service URL. HEADLESS: no browser, no server. It reads what each bundle's source and SHIPPED + * bundle consume, and fails on a bypass: a literal or a private global where the library resolver + * belongs. + * + * WHY STATIC. A bypass is a line of code, and it is the same line whether or not anyone is looking + * at the map: a `setStyle('mapbox://…')` breaks the MapLibre engine switch, a `window.DOXA_TOKEN` + * read ignores the page's token, a `toLocaleString('en-US')` ignores the locale. Each was found in + * this tree by reading the source (the census behind the contract page). Rendering proves the + * happy path; this proves nobody went around it. + * + * RULES (id · prop · what fails): + * ENG-STYLE engine a `mapbox://` style literal in map.setStyle() or a raw `new gl.Map({style})` + * outside a resolveNamedStyle/registry fallback: MapLibre cannot load it + * (a style handed to useMapInstance is translated there, so it passes) + * ENG-GLOBAL engine `new mapboxgl.X` / `new window.mapboxgl.X` (build it with engineFor(map)) + * ENG-PIN engine a literal provider:'mapbox'|'maplibre' handed to useMapInstance (ALLOW-listed + * where it is the plugin's design) + * TOK-GLOBAL token reading window.DOXA_TOKEN (nothing sets it on a built page; use the injected + * mapboxToken, fed by profile-config.tk and window.MAP_TOKEN) + * TOK-LITERAL token a `pk.ey…` token literal in code (comments excluded) + * LOC-ENUS locale toLocaleString('en-US') / Intl.NumberFormat('en-US') (use the active locale). + * WARN only: whether numerals follow the locale is a Driver decision + * Source = plugin-designs//**.{vue,js} minus index.html dev pages and scripts/. Shipped = + * public///.js, checked for the TOK-GLOBAL and TOK-LITERAL strings the + * minifier cannot rename. + * + * ALLOW: a documented exception per bundle+rule, with its reason. Nothing else passes. + * Self-test: every rule must fire on a synthetic violation, or the gate exits 3. + * Usage: node internal/check-universal-props.mjs [--only a,b] [--report] + * Exit: 0 conforms · 1 a bypass · 3 self-test could not see a planted bypass. + */ +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' +import { resolve, dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { TREE_NAME } from './_tree-name.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const argv = process.argv.slice(2) +const ONLY = argv.includes('--only') ? argv[argv.indexOf('--only') + 1].split(',') : null +const REPORT = argv.includes('--report') + +// The 7 production maps' bundles (PRODUCTION-MATRIX.md) and the Experimental shelf. +const BUNDLES = [ + 'dashboard-plus-grid', 'research-dashboard', 'research-map-plus-grid', 'doxa-research-map', 'people-groups-grid', + 'regions-dashboard', 'my-upg-100-list', 'doxa-simple-map', 'cities-internet', 'gospel-resources', + 'all-people-groups', 'country-posters', 'doxa-vision-card', 'pplr-data-maps', 'prayer-walk-planner', + 'upg-neighbor', 'wall-of-people-groups', 'key-cities', +] +const ALLOW = { + 'prayer-walk-planner:ENG-PIN': 'keyless by design: always MapLibre, needs no token (index.js header)', + 'key-cities:ENG-STYLE': 'gated, Mapbox-only by design (mapbox:// styles, ensureEngineScript(\'mapbox\'))', +} + +const stripComments = (src) => src + .replace(//g, (m) => m.replace(/[^\n]/g, ' ')) + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) + .replace(/(^|[^:\\'"`])\/\/[^\n]*/g, (m, p) => p + ' '.repeat(m.length - p.length)) + +const RULES = [ + { id: 'ENG-STYLE', prop: 'engine', test(src) { + // useMapInstance swaps any non-http style for an OpenFreeMap one under MapLibre + // (maplibreStyle() in useMapInstance.js), so a mapbox:// default handed to IT is fine. What is + // not translated: a direct map.setStyle('mapbox://…') and a raw `new gl.Map({ style: 'mapbox://…' })`. + const out = [] + const res = [/setStyle\s*\(([^;]{0,220}?)mapbox:\/\/styles\//g, /new\s+[\w.]+\.Map\s*\(\s*\{[^}]{0,400}?style\s*:([^,}]{0,160}?)mapbox:\/\/styles\//g] + for (const re of res) { let m; while ((m = re.exec(src))) { + const win = src.slice(Math.max(0, m.index - 160), m.index + m[0].length) + if (/resolveNamedStyle|registry|\.style\s*\|\|/.test(win)) continue // a fallback behind the registry + out.push(m.index) + } } + return out } }, + { id: 'ENG-GLOBAL', prop: 'engine', re: /new\s+(?:window\.)?mapboxgl\.(?:Marker|Popup|Map|NavigationControl|FullscreenControl|GeolocateControl|ScaleControl|AttributionControl)\b/g }, + { id: 'ENG-PIN', prop: 'engine', re: /useMapInstance\s*\(\s*\{[^}]{0,400}?provider\s*:\s*['"](?:mapbox|maplibre)['"]/g }, + { id: 'TOK-GLOBAL', prop: 'token', re: /window\.DOXA_TOKEN\b/g, shipped: 'DOXA_TOKEN' }, + { id: 'TOK-LITERAL', prop: 'token', re: /['"`]pk\.ey[A-Za-z0-9_-]{20,}/g, shipped: /pk\.eyJ[A-Za-z0-9_-]{20,}/ }, + { id: 'LOC-ENUS', prop: 'locale', warn: true, re: /(?:toLocaleString|NumberFormat|DateTimeFormat)\s*\(\s*['"]en(?:-US)?['"]/g }, +] +const run = (rule, src) => rule.test ? rule.test(src) : [...src.matchAll(rule.re)].map((m) => m.index) +const lineOf = (src, idx) => src.slice(0, idx).split('\n').length + +// ── self-test: every rule must see a planted violation ───────────────────────────────────────── +const PLANTED = ` + map.setStyle(isDark ? 'mapbox://styles/mapbox/dark-v11' : 'x') + const m2 = new gl.Map({ container: el, style: 'mapbox://styles/mapbox/light-v11' }) + const p = new mapboxgl.Popup() + useMapInstance({ container: el, provider: 'mapbox' }) + const tk = window.DOXA_TOKEN + const t = 'pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJjbGFudGVkIn0' + n.toLocaleString('en-US')` +const missed = RULES.filter((r) => !run(r, stripComments(PLANTED)).length).map((r) => r.id) +const CLEAN = `map.setStyle(resolveNamedStyle('ink', p).style || 'mapbox://styles/mapbox/dark-v11') + useMapInstance({ container: el, style: cfg.style ?? 'mapbox://styles/mapbox/light-v11' }) + // new mapboxgl.Popup() in a comment is not code + n.toLocaleString(locale.value)` +const falsePos = RULES.filter((r) => run(r, stripComments(CLEAN)).length).map((r) => r.id) +if (missed.length || falsePos.length) { + console.error(`UNIVERSAL-PROPS: SELF-TEST FAILED — ${missed.length ? 'rules blind to a planted bypass: ' + missed.join(', ') : ''}${falsePos.length ? ' false positives on clean code: ' + falsePos.join(', ') : ''}`) + process.exit(3) +} + +// ── scan ─────────────────────────────────────────────────────────────────────────────────────── +function files(dir) { + const out = [] + for (const n of readdirSync(dir)) { + const p = join(dir, n) + if (n === 'node_modules' || n === 'scripts' || n === 'data' || n === 'assets') continue + const st = statSync(p) + if (st.isDirectory()) out.push(...files(p)) + else if (/\.(vue|js|mjs)$/.test(n)) out.push(p) + } + return out +} +const findings = [] +const matrix = {} +for (const b of BUNDLES.filter((x) => !ONLY || ONLY.includes(x))) { + const dir = resolve(ROOT, 'plugin-designs', b) + if (!existsSync(dir)) continue + matrix[b] = { engine: 'ok', token: 'ok', locale: 'ok' } + for (const f of files(dir)) { + const src = stripComments(readFileSync(f, 'utf8')) + for (const r of RULES) for (const idx of run(r, src)) { + const allowed = ALLOW[`${b}:${r.id}`] + if (allowed) { if (matrix[b][r.prop] === 'ok') matrix[b][r.prop] = 'allowed'; continue } + findings.push({ b, rule: r.id, prop: r.prop, warn: !!r.warn, where: `${relative(ROOT, f)}:${lineOf(src, idx)}`, text: src.slice(idx, idx + 70).replace(/\s+/g, ' ').trim() }) + matrix[b][r.prop] = r.warn ? (matrix[b][r.prop] === 'BYPASS' ? 'BYPASS' : 'warn') : 'BYPASS' + } + } + const shipped = resolve(ROOT, 'public', TREE_NAME, b, `${b}.js`) + if (existsSync(shipped)) { + const js = readFileSync(shipped, 'utf8') + for (const r of RULES.filter((x) => x.shipped)) { + const hit = typeof r.shipped === 'string' ? js.includes(r.shipped) : r.shipped.test(js) + if (hit && !ALLOW[`${b}:${r.id}`]) { findings.push({ b, rule: r.id, prop: r.prop, where: relative(ROOT, shipped), text: '(in the shipped bundle)' }); matrix[b][r.prop] = 'BYPASS' } + } + } +} + +if (REPORT) { + console.log('bundle'.padEnd(24) + 'engine'.padEnd(10) + 'token'.padEnd(10) + 'locale') + for (const [b, m] of Object.entries(matrix)) console.log(b.padEnd(24) + m.engine.padEnd(10) + m.token.padEnd(10) + m.locale) +} +for (const f of findings) console.log(`${f.warn ? 'WARN ' : 'BYPASS '} ${f.b.padEnd(22)} ${f.rule.padEnd(11)} ${f.where} ${f.text}`) +const hard = findings.filter((f) => !f.warn) +if (findings.length - hard.length) console.log(`UNIVERSAL-PROPS: ${findings.length - hard.length} WARN(s): LOC-ENUS fixes numerals to en-US whatever the locale. Warn-only until the Driver decides the numerals policy.`) +if (hard.length) { + console.log(`UNIVERSAL-PROPS: FAIL — ${hard.length} bypass(es) in ${new Set(hard.map((f) => f.b)).size} bundle(s). Read the prop through the library (documentation/reference-universal-props.md), or ALLOW it here with the reason it is by design.`) + process.exit(1) +} +console.log(`UNIVERSAL-PROPS: PASS — ${Object.keys(matrix).length} bundles read engine, token and locale through the library (self-test: ${RULES.length} rules each caught a planted bypass).`) diff --git a/package.json b/package.json index d69b6334a..abbadd569 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "check:rtl-shaping": "node internal/check-gospel-rtl-shaping.mjs", "check:rtl-shaping:poison": "node internal/check-gospel-rtl-shaping.mjs --poison", "check:rtl-raw": "node internal/check-raw-map-rtl-shaping.mjs", + "check:universal-props": "node internal/check-universal-props.mjs --report", "check:rtl-raw:poison": "node internal/check-raw-map-rtl-shaping.mjs --poison", "check:scrim-viewport": "node internal/check-scrim-covers-viewport.mjs", "check:scrim-viewport:self-test": "node internal/check-scrim-covers-viewport.mjs --self-test",