Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').
- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
(71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
The default list returns the same array, unfetched. A list whose membership cannot load shows no
groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
thread `list` to their children like `provider`.
Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.
Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
172 lines
12 KiB
JavaScript
172 lines
12 KiB
JavaScript
#!/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, and the people-group LIST (library/lists/). 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
|
|
* LIST-FETCH list a file that fetches the people-group list (/api/people-groups/list, a
|
|
* source's endpoints.bulk, or the doxa-people-groups.json roster) without
|
|
* applyPeopleGroupList: that map would show DOXA's list whatever list was asked
|
|
* LIST-DSM list `new DataSourceManager(...)` without `profileConfig`: the manager applies the
|
|
* list itself, but without the embed's config only ?list= / window.MAP_LIST reach it
|
|
* LIST-KEYS list a composite's INHERITED_PAGE_KEYS without 'list': its panes never hear it
|
|
* Source = plugin-designs/<bundle>/**.{vue,js} minus index.html dev pages and scripts/. Shipped =
|
|
* public/<tree>/<bundle>/<bundle>.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\'))',
|
|
'all-people-groups:LIST-DSM': 'draws the IMB dataset (all 12,379 groups), not the DOXA list: a list there is Tier B (SPEC-PEOPLE-GROUP-LIST-PROP.md)',
|
|
}
|
|
|
|
const stripComments = (src) => src
|
|
.replace(/<!--[\s\S]*?-->/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 },
|
|
// A whole-file rule: the fetch and the helper need not be on the same line, only in the same file.
|
|
{ id: 'LIST-FETCH', prop: 'list', test(src) {
|
|
const m = /\/api\/people-groups\/list|endpoints\?\.bulk|doxa-people-groups\.json/.exec(src)
|
|
return m && !/applyPeopleGroupList\s*\(/.test(src) ? [m.index] : [] } },
|
|
{ id: 'LIST-DSM', prop: 'list', re: /new\s+DataSourceManager\s*\((?![^)]*\bprofileConfig\b)[^)]*\)/g },
|
|
{ id: 'LIST-KEYS', prop: 'list', re: /INHERITED_PAGE_KEYS\s*=\s*\[(?![^\]]*['"]list['"])[^\]]*\]/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 r = await fetch(base + '/api/people-groups/list?fields=slug')
|
|
const dsm = new DataSourceManager({ locale: 'en' })
|
|
const INHERITED_PAGE_KEYS = ['locale', 'provider']`
|
|
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 r2 = await applyPeopleGroupList((await fetch(base + '/api/people-groups/list')).posts, cfg)
|
|
const dsm2 = new DataSourceManager({ locale: 'en', profileConfig })
|
|
const INHERITED_PAGE_KEYS = ['locale', 'provider', 'list']`
|
|
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', list: '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'.padEnd(10) + 'list')
|
|
for (const [b, m] of Object.entries(matrix)) console.log(b.padEnd(24) + m.engine.padEnd(10) + m.token.padEnd(10) + m.locale.padEnd(10) + m.list)
|
|
}
|
|
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, locale and list through the library (self-test: ${RULES.length} rules each caught a planted bypass).`)
|