A gate for the negative case the three engine gates never asserted
ade12e4c removed one silent engine substitution. This asserts that nobody
re-adds it, and it found the two I had no business fixing myself:
cities-internet/connectivity-map.vue:365 falls back to mapboxgl
gospel-resources/resources-map.vue:1424 falls back to mapboxgl
and ZERO hits in my own plugin, which is the fail-then-pass proof — on real code
rather than a synthetic mutation.
★ WHY THIS IS NOT A FOURTH DUPLICATE, which I checked before writing it because I
nearly built a redundant check:sri earlier today. Three engine gates exist:
check:leaf-engine-per-provider — a leaf loads the engine its provider asks for
check:provider-switch — the page switch reaches a raw-built map
check:auto-engine — 'auto' = Mapbox with a token, MapLibre without
All three assert the HAPPY PATH: what happens when engines load. This defect only
appears when one does NOT load, so all three were green for as long as it existed.
The property nobody asserted is the negative one — "when the requested engine is
absent, no other engine is substituted".
IT IS A SOURCE CHECK ON PURPOSE. Reproducing this live means serving a leaf with
MapLibre deliberately blocked: a browser slot spent deciding what a grep decides.
The fallback either appears in the file or it does not.
⚠ AND IT REFUSES TO JUDGE RATHER THAN PASS VACUOUSLY: exit 75 when no file calls
engineGlobal at all, because "I found no files" is not "I found nothing wrong" —
a green run on an empty scan is the exact shape of a gate that has quietly stopped
measuring anything.
--self-test proves the pattern still fires, since a source gate that has never
matched is indistinguishable from a broken regex. It matches all three real forms
(window-prefixed, bare, whitespace-padded) and ignores a comment quoting the bad
pattern, a `|| null` fallback and an unrelated `||` token chain. Comment lines are
skipped deliberately: a gate that fails on its own explanation teaches people to
delete the explanation.
check:no-silent-engine + :self-test wired in alphabetical position, with the
reasoning in //scripts.
This commit is contained in:
parent
ade12e4c0b
commit
d70f5d4538
2 changed files with 169 additions and 0 deletions
166
internal/check-no-silent-engine-substitution.mjs
Normal file
166
internal/check-no-silent-engine-substitution.mjs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-no-silent-engine-substitution — a profile must never fall back to a DIFFERENT map engine than
|
||||
* the one its provider asked for.
|
||||
*
|
||||
* THE DEFECT, found by regions' C3 read and routed by dx-shepherd (END-SHEP-C3C, 2026-09-26):
|
||||
*
|
||||
* const gl = engineGlobal(provider) || window.mapboxgl
|
||||
*
|
||||
* That `||` looks like ordinary defensive coding and is not. engineGlobal() ALREADY resolves mapbox's own
|
||||
* global for the mapbox case — useMapInstance.js:115 is
|
||||
* `ENGINES[provider]?.global || ENGINES.mapbox.global`, which falls back to mapbox's NAME only for an
|
||||
* UNKNOWN provider name, never for a missing engine. So the expression can fire in exactly ONE situation:
|
||||
* the page asked for 'maplibre' and MapLibre failed to load. It then hands back MAPBOX — the token-gated
|
||||
* engine — to a page that explicitly asked for the keyless one, breaking the keyless contract
|
||||
* research-map.instances.json documents.
|
||||
*
|
||||
* ★ AND THE SUBSTITUTE IS BROKEN BY CONSTRUCTION, which is what makes this worth a gate rather than a
|
||||
* one-line fix. These profiles gate `gl.accessToken = token` on `if (!isMaplibre)`, so a maplibre request
|
||||
* that fell through to Mapbox got Mapbox WITHOUT a token: 401s on every tile instead of a legible failure.
|
||||
* A fallback that cannot produce a working map even in principle is strictly worse than no fallback,
|
||||
* because it converts a diagnosable error into a mysterious one.
|
||||
*
|
||||
* ⚠ WHY THE THREE EXISTING ENGINE GATES DID NOT CATCH IT, and why this is not a duplicate of them:
|
||||
* check-leaf-engine-per-provider — a leaf loads the engine its provider asks for
|
||||
* check-provider-switch — the page-level switch reaches a raw-built map
|
||||
* check-auto-engine — 'auto' resolves to Mapbox with a token, MapLibre without
|
||||
* All three assert the HAPPY PATH: what happens when engines load. This bug only appears when one does
|
||||
* NOT load, so all three were green for as long as it existed. The property here is the negative one —
|
||||
* "when the requested engine is absent, no other engine is substituted" — and nothing asserted it.
|
||||
*
|
||||
* ⚠ IT IS A SOURCE CHECK, ON PURPOSE. Reproducing it at the rendered layer means serving a leaf with
|
||||
* MapLibre deliberately blocked, which is a browser run for a defect whose whole signature is one
|
||||
* expression. The source is the authoritative place: the fallback either appears in the file or it does
|
||||
* not. A browser gate here would cost a slot to measure something a grep decides.
|
||||
*
|
||||
* Usage: node internal/check-no-silent-engine-substitution.mjs
|
||||
* node internal/check-no-silent-engine-substitution.mjs --self-test
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = process.env.DOXA_REPO || resolve(HERE, '..')
|
||||
const ROOT = join(REPO, 'plugin-designs')
|
||||
|
||||
// ⚠ FLAGS FILTERED FROM THE POSITIONAL READ. A bare `--self-test` was otherwise taken as a path, which
|
||||
// has bitten this repo three times (--self-test read as a leaf, --emit read as a baseUrl, --poison read
|
||||
// as a packet).
|
||||
const SELF_TEST = process.argv.includes('--self-test')
|
||||
|
||||
// The substitution to refuse: engineGlobal(...) OR-ed with a specific engine's global. Any of these
|
||||
// named globals on the right of a `||` after engineGlobal is a cross-engine fallback by definition —
|
||||
// engineGlobal already returns the RIGHT global for the requested provider, so the only thing an `||`
|
||||
// can add is a DIFFERENT engine.
|
||||
const ENGINE_GLOBALS = ['mapboxgl', 'maplibregl']
|
||||
const PATTERN = new RegExp(
|
||||
String.raw`engineGlobal\s*\([^)]*\)\s*\|\|\s*(?:window\.)?(${ENGINE_GLOBALS.join('|')})\b`,
|
||||
'g',
|
||||
)
|
||||
|
||||
function scan(text) {
|
||||
const hits = []
|
||||
const lines = text.split('\n')
|
||||
lines.forEach((line, i) => {
|
||||
// Skip comment lines: this file's own documentation quotes the bad pattern, and so does every fix
|
||||
// that explains what it removed. A gate that fails on its own explanation teaches people to delete
|
||||
// the explanation.
|
||||
const t = line.trim()
|
||||
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return
|
||||
PATTERN.lastIndex = 0
|
||||
let m
|
||||
while ((m = PATTERN.exec(line)) !== null) hits.push({ line: i + 1, engine: m[1], text: t.slice(0, 110) })
|
||||
})
|
||||
return hits
|
||||
}
|
||||
|
||||
if (SELF_TEST) {
|
||||
// ★ PROVE THE PATTERN FIRES BEFORE TRUSTING A GREEN RUN. A source gate that has never matched anything
|
||||
// is indistinguishable from a broken regex, and this one's whole value is that it matches.
|
||||
const should = [
|
||||
'const gl = engineGlobal(upgProvider) || window.mapboxgl',
|
||||
' const gl = engineGlobal(p)||maplibregl',
|
||||
'let gl = engineGlobal( provider ) || window.maplibregl;',
|
||||
]
|
||||
const shouldNot = [
|
||||
'const gl = engineGlobal(upgProvider)',
|
||||
'// const gl = engineGlobal(p) || window.mapboxgl <- the removed fallback',
|
||||
'const gl = engineGlobal(p) || null',
|
||||
'const token = mapboxToken?.value || profileConfig?.value?.tk || \'\'',
|
||||
]
|
||||
let bad = 0
|
||||
for (const s of should) {
|
||||
const n = scan(s).length
|
||||
console.log(` ${n ? 'MATCHED ' : 'MISSED '} ${s.trim().slice(0, 62)}`)
|
||||
if (!n) bad++
|
||||
}
|
||||
for (const s of shouldNot) {
|
||||
const n = scan(s).length
|
||||
console.log(` ${n ? 'FALSE+ ' : 'ignored '} ${s.trim().slice(0, 62)}`)
|
||||
if (n) bad++
|
||||
}
|
||||
if (bad) {
|
||||
console.error(`\nNO-SILENT-ENGINE-SUBSTITUTION --self-test: FAIL — ${bad} case(s) wrong.`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nNO-SILENT-ENGINE-SUBSTITUTION --self-test: PASS — the pattern matches every real form '
|
||||
+ 'and ignores comments, a null fallback and an unrelated token chain.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!existsSync(ROOT)) {
|
||||
console.log(`NO-SILENT-ENGINE-SUBSTITUTION: NOT MEASURED — no plugin-designs tree at ${ROOT}`)
|
||||
process.exit(75)
|
||||
}
|
||||
|
||||
const findings = []
|
||||
let scanned = 0
|
||||
const walk = (dir) => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue
|
||||
walk(p)
|
||||
} else if (/\.(vue|js|mjs)$/.test(e.name)) {
|
||||
let text
|
||||
try { text = readFileSync(p, 'utf8') } catch { continue }
|
||||
if (!text.includes('engineGlobal')) continue
|
||||
scanned++
|
||||
for (const h of scan(text)) findings.push({ file: p.slice(REPO.length + 1), ...h })
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(ROOT)
|
||||
|
||||
console.log('NO-SILENT-ENGINE-SUBSTITUTION')
|
||||
console.log(` ${scanned} file(s) call engineGlobal()`)
|
||||
|
||||
// ⚠ REFUSE TO JUDGE rather than pass vacuously. If nothing in the tree calls engineGlobal at all, the
|
||||
// pattern had nothing to match and a green result would mean "I found no files", not "the code is clean".
|
||||
if (!scanned) {
|
||||
console.log('NO-SILENT-ENGINE-SUBSTITUTION: NOT MEASURED — no file calls engineGlobal(), so there was '
|
||||
+ 'nothing to check. Exit 75 rather than 0: "found nothing" is not "found nothing wrong".')
|
||||
process.exit(75)
|
||||
}
|
||||
|
||||
if (findings.length) {
|
||||
console.error(`\nNO-SILENT-ENGINE-SUBSTITUTION: FAIL — ${findings.length} silent engine substitution(s).`)
|
||||
for (const f of findings) {
|
||||
console.error(` ✗ ${f.file}:${f.line} falls back to ${f.engine}`)
|
||||
console.error(` ${f.text}`)
|
||||
}
|
||||
console.error('\n engineGlobal() ALREADY returns the right global for the requested provider, so an `||`')
|
||||
console.error(' after it can only supply a DIFFERENT engine — and it fires only when the page asked for')
|
||||
console.error(' maplibre and maplibre failed to load. A page that asked for the KEYLESS engine then')
|
||||
console.error(' receives the TOKEN-GATED one, and because accessToken is gated on !isMaplibre it')
|
||||
console.error(' receives it WITHOUT a token: 401s instead of a legible error.')
|
||||
console.error('\n FIX: delete the fallback. Keep the requested engine, and let the profile\'s existing')
|
||||
console.error(' !gl branch fail visibly — console.warn plus the map\'s error/empty state, never a blank,')
|
||||
console.error(' and never another engine. Name the engine in the message so a partner can tell an')
|
||||
console.error(' honoured request from a silent downgrade.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('NO-SILENT-ENGINE-SUBSTITUTION: PASS — every engineGlobal() call keeps the engine its provider '
|
||||
+ 'asked for.')
|
||||
|
|
@ -138,6 +138,8 @@
|
|||
"check:no-free-identifiers": "node plugin-designs/research-map-plus-grid/scripts/check-no-free-identifiers.mjs",
|
||||
"check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs",
|
||||
"check:no-public-binds": "node internal/check-no-public-binds.mjs",
|
||||
"check:no-silent-engine": "node internal/check-no-silent-engine-substitution.mjs",
|
||||
"check:no-silent-engine:self-test": "node internal/check-no-silent-engine-substitution.mjs --self-test",
|
||||
"check:one-palette": "node internal/check-one-palette.mjs",
|
||||
"check:one-palette:self-test": "node internal/check-one-palette.mjs --self-test",
|
||||
"check:packet-browser": "node internal/check-packet-browser.mjs",
|
||||
|
|
@ -337,6 +339,7 @@
|
|||
"check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/\u2026). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
|
||||
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
|
||||
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
|
||||
"check:no-silent-engine": "A profile must NEVER fall back to a different map engine than its provider asked for. `engineGlobal(provider) || window.mapboxgl` looks defensive and is not: engineGlobal already returns the right global for the requested provider (useMapInstance.js:115 falls back to mapbox's NAME only for an UNKNOWN provider, never a missing engine), so the || fires in exactly one case — the page asked for maplibre and maplibre failed to load — and hands back the TOKEN-GATED engine to a page that asked for the KEYLESS one. Worse, accessToken is gated on !isMaplibre, so the substitute arrives WITHOUT a token: 401s on every tile instead of a legible failure. Found by regions' C3 read 2026-09-26; 2 offenders remained (cities-internet, gospel-resources) after my-upg-100-list was fixed. NOT a duplicate of check:leaf-engine-per-provider / check:provider-switch / check:auto-engine — all three assert the HAPPY path (what loads) and this bug only appears when something does NOT load, so all three were green while it existed. A SOURCE check on purpose: reproducing it live means serving a leaf with MapLibre deliberately blocked, spending a browser slot on what a grep decides. Exits 75 when no file calls engineGlobal — 'found nothing' is not 'found nothing wrong'. Run :self-test to prove the pattern still matches.",
|
||||
"check:packet-browser": "the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
|
||||
"check:packet-fixture": "the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).",
|
||||
"check:packet-render:expect-funnel": "check:packet-render with the page-2 FUNNEL declared REQUIRED. Exists because of a real near-miss: a merge resolved with `git checkout --ours` deleted the printPacket call site that supplies the funnel data (60 lines) and EVERY GATE STAYED GREEN — it had to, because the funnel page is OPTIONAL by design (buildPacket omits it when the arg is null), so a packet without page 2 is not malformed, just smaller. Always requiring it would be the wrong fix: the 11 pre-funnel laos packets and any host with no funnel data would go red, and a false red costs as much as a false green. So the CALLER, which knows whether this packet was built with funnel data, declares it — an absence is then either declared (no flag) or a failure (flag passed, page missing), never a silent nothing. Pass it for any packet from a profile that wires packetFunnel(). Browser gate.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue