doxa-maps/plugin-designs/research-map-plus-grid/scripts/check-no-free-identifiers.mjs
Doxa Maps 9b4db40443 Three post-landing gates green again: the library index, the capability table, Path2D
gates:post had gone red on development in three places that no landing runs, because landings run
gates:pre. The library index and the capability-exemplars table are derived documents: regenerated,
they now list the country-map bundle, BackToTopButton and useHostedHelp. check:no-free-identifiers
flagged the Wall's new Path2D (a canvas built-in every browser has) as never bound; it knows the
canvas built-ins now. No code changes.
2026-09-29 02:35:35 +00:00

333 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* check-no-free-identifiers.mjs — an SFC must not reference a name it never bound.
*
* ★ THE DEFECT THIS EXISTS FOR, AND I SHIPPED IT. Editing two composites with a pattern-match
* script, I replaced an import line by matching text that had already changed, so the replacement
* silently matched nothing and three names — RTL_LOCALES, getRuntimeLocale, detectLocale — were
* used but never imported. Both composites threw a ReferenceError in setup and rendered NOTHING on
* main until a hotfix landed.
*
* It passed every gate. It compiled, because a free identifier is perfectly valid JavaScript until
* it RUNS. It passed a grep of the built bundle for the new code, because the code was there. It
* passed template-literal, sfc-parse, freshness and SRI, because not one of them evaluates a setup
* function. The evidence I had could not tell a working composite from a dead one, and I called it
* "verified headless" anyway.
*
* So this reads what the compiler reads and asks the one question those gates cannot: is every name
* this file USES actually BOUND somewhere — imported, declared, a parameter, or a known global?
* No browser, no mount, milliseconds per file. It is the cheap half of the pair; exp-shepherd's
* check:composites-mount is the expensive half that proves the thing actually runs.
*
* SCOPE: every .vue under plugin-designs/ and library/ — fleet-wide, because the mistake was a
* text-edit habit rather than anything about one plugin.
*
* HOW IT AVOIDS CRYING WOLF. It tracks real scopes (module, function, block, catch, class), so a
* name bound in a nested function is not reported at the top. It ignores non-computed property
* names, labels, and the declaration side of imports and destructuring. Vue's compiler macros and
* the browser/node globals this tree actually uses are listed below, by name, where a reader can
* see and argue with them — an allowlist that is a regex is an allowlist nobody can audit.
*
* Exit: 0 clean · 1 at least one free identifier · 2 could not parse (reported, not silent).
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { resolve, dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse as parseSFC } from '@vue/compiler-sfc'
import * as acorn from 'acorn'
const TAG = 'NO-FREE-IDENTIFIERS'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const ROOTS = ['plugin-designs', 'library']
// Vue's <script setup> compiler macros: real bindings the compiler provides, not free names.
const VUE_MACROS = [
'defineProps', 'defineEmits', 'defineExpose', 'defineOptions', 'defineModel',
'defineSlots', 'withDefaults', 'useSlots', 'useAttrs', 'useTemplateRef', 'useId',
]
// Globals this tree actually uses. Listed by name so the list can be read and argued with.
const GLOBALS = [
'globalThis', 'window', 'document', 'navigator', 'location', 'history', 'screen',
'console', 'Math', 'JSON', 'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol',
'Promise', 'Set', 'Map', 'WeakMap', 'WeakSet', 'Date', 'RegExp', 'Proxy', 'Reflect', 'BigInt',
'Error', 'TypeError', 'RangeError', 'SyntaxError', 'Infinity', 'NaN', 'undefined',
'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURIComponent', 'decodeURIComponent',
'encodeURI', 'decodeURI', 'structuredClone', 'queueMicrotask', 'crypto',
'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval',
'requestAnimationFrame', 'cancelAnimationFrame', 'requestIdleCallback',
'fetch', 'Headers', 'Request', 'Response', 'AbortController', 'AbortSignal',
'URL', 'URLSearchParams', 'Blob', 'File', 'FileReader', 'FormData', 'TextEncoder', 'TextDecoder',
'Event', 'CustomEvent', 'EventTarget', 'MessageChannel', 'BroadcastChannel',
'IntersectionObserver', 'DOMException', 'createImageBitmap', 'ResizeObserver', 'MutationObserver', 'PerformanceObserver', 'performance',
'HTMLElement', 'HTMLCanvasElement', 'HTMLImageElement', 'Element', 'Node', 'NodeList', 'DOMParser',
'Path2D', 'OffscreenCanvas', 'ImageData', // canvas built-ins (the Wall draws its icons with Path2D)
'Image', 'Audio', 'Intl', 'getComputedStyle', 'matchMedia', 'alert', 'confirm', 'prompt',
'localStorage', 'sessionStorage', 'devicePixelRatio', 'innerWidth', 'innerHeight',
'process', 'Buffer', '__dirname', '__filename', 'require', 'module', 'exports',
// Map engines and their plugins arrive as globals from a <script> tag, by design.
'mapboxgl', 'maplibregl', 'MapboxGeocoder', 'MaplibreGeocoder', 'turf', 'html2canvas', 'jspdf', 'd3',
// More of the standard library and DOM than the first draft listed — every one of these was found
// by RUNNING this gate over the tree and reading what it flagged, which is the only way an
// allowlist gets honest.
'Function', 'customElements', 'ShadowRoot', 'ShadowRoot', 'CSS', 'self', 'Uint8Array', 'Uint16Array',
'Uint32Array', 'Int8Array', 'Int16Array', 'Int32Array', 'Float32Array', 'Float64Array', 'ArrayBuffer',
'DataView', 'WeakRef', 'FinalizationRegistry', 'ClipboardItem', 'CSSStyleSheet', 'XMLSerializer',
// Build-time defines: vite REPLACES these before the browser ever sees them (vite.config.js
// `define`), so they are bound by the bundler rather than by the file.
'__DOXA_TREE_NAME__', '__DOXA_QA__',
]
const PRELUDE = new Set([...VUE_MACROS, ...GLOBALS])
// ── scope tracking ───────────────────────────────────────────────────────────────────────────────
class Scope {
constructor(parent, kind) { this.parent = parent; this.kind = kind; this.names = new Set() }
declare(name) { if (name) this.names.add(name) }
has(name) { return this.names.has(name) || (this.parent ? this.parent.has(name) : false) }
}
const isFn = (t) => t === 'FunctionDeclaration' || t === 'FunctionExpression' || t === 'ArrowFunctionExpression'
/** Every name a binding PATTERN introduces (destructuring, defaults, rest). */
function patternNames(node, out = []) {
if (!node) return out
switch (node.type) {
case 'Identifier': out.push(node.name); break
case 'ObjectPattern': for (const p of node.properties) patternNames(p.type === 'RestElement' ? p.argument : p.value, out); break
case 'ArrayPattern': for (const e of node.elements) patternNames(e, out); break
case 'AssignmentPattern': patternNames(node.left, out); break
case 'RestElement': patternNames(node.argument, out); break
}
return out
}
/** Hoist the declarations a scope owns, before walking its body — `foo()` above `function foo` is legal. */
function hoist(body, scope, { fnScope }) {
for (const n of body || []) {
if (!n) continue
if (n.type === 'FunctionDeclaration' && n.id) scope.declare(n.id.name)
else if (n.type === 'ClassDeclaration' && n.id) scope.declare(n.id.name)
else if (n.type === 'VariableDeclaration') {
const target = n.kind === 'var' ? fnScope : scope
for (const d of n.declarations) for (const name of patternNames(d.id)) target.declare(name)
} else if (n.type === 'ImportDeclaration') {
for (const s of n.specifiers) scope.declare(s.local.name)
} else if (n.type === 'ExportNamedDeclaration' && n.declaration) {
hoist([n.declaration], scope, { fnScope })
} else if (n.type === 'ExportDefaultDeclaration' && n.declaration && n.declaration.id) {
scope.declare(n.declaration.id.name)
} else if (n.type === 'LabeledStatement') {
hoist([n.body], scope, { fnScope })
} else if (n.type === 'ForStatement' || n.type === 'ForOfStatement' || n.type === 'ForInStatement') {
// `var` in a for-init belongs to the function scope even though the loop makes a block.
const init = n.init || n.left
if (init && init.type === 'VariableDeclaration' && init.kind === 'var') {
for (const d of init.declarations) for (const name of patternNames(d.id)) fnScope.declare(name)
}
if (n.body && n.body.type === 'BlockStatement') hoist(n.body.body, scope, { fnScope })
else if (n.body) hoist([n.body], scope, { fnScope })
} else if (n.type === 'IfStatement') {
for (const b of [n.consequent, n.alternate]) {
if (!b) continue
if (b.type === 'BlockStatement') hoist(b.body, scope, { fnScope })
else hoist([b], scope, { fnScope })
}
} else if (n.type === 'TryStatement') {
if (n.block) hoist(n.block.body, scope, { fnScope })
if (n.handler && n.handler.body) hoist(n.handler.body.body, scope, { fnScope })
if (n.finalizer) hoist(n.finalizer.body, scope, { fnScope })
} else if (n.type === 'BlockStatement') {
hoist(n.body, scope, { fnScope })
} else if (n.type === 'WhileStatement' || n.type === 'DoWhileStatement') {
if (n.body && n.body.type === 'BlockStatement') hoist(n.body.body, scope, { fnScope })
} else if (n.type === 'SwitchStatement') {
for (const c of n.cases) hoist(c.consequent, scope, { fnScope })
}
}
}
function analyse(ast, seeded = []) {
const free = [] // { name, line }
const module = new Scope(null, 'module')
for (const n of seeded) module.declare(n)
hoist(ast.body, module, { fnScope: module })
const walk = (node, scope, fnScope, parent, key) => {
if (!node || typeof node.type !== 'string') return
// Names that are NOT references.
if (node.type === 'Identifier') {
const isMemberProp = parent && parent.type === 'MemberExpression' && key === 'property' && !parent.computed
const isPropKey = parent && parent.type === 'Property' && key === 'key' && !parent.computed
const isMethodKey = parent && (parent.type === 'MethodDefinition' || parent.type === 'PropertyDefinition') && key === 'key' && !parent.computed
const isLabel = parent && (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement' || parent.type === 'ContinueStatement') && key === 'label'
const isMetaProp = parent && parent.type === 'MetaProperty'
const isImportSpec = parent && /^(Import(Default|Namespace)?Specifier|ExportSpecifier)$/.test(parent.type)
if (isMemberProp || isPropKey || isMethodKey || isLabel || isMetaProp || isImportSpec) return
if (!scope.has(node.name) && !PRELUDE.has(node.name)) {
free.push({ name: node.name, line: node.loc ? node.loc.start.line : 0 })
}
return
}
// Scopes.
if (isFn(node.type)) {
const s = new Scope(scope, 'function')
if (node.type === 'FunctionExpression' && node.id) s.declare(node.id.name)
for (const p of node.params) for (const n of patternNames(p)) s.declare(n)
s.declare('arguments')
// Defaults in params are evaluated in the function scope.
for (const p of node.params) walk(p, s, s, node, 'params')
if (node.body && node.body.type === 'BlockStatement') {
hoist(node.body.body, s, { fnScope: s })
for (const st of node.body.body) walk(st, s, s, node, 'body')
} else {
walk(node.body, s, s, node, 'body')
}
return
}
if (node.type === 'BlockStatement' && parent && !isFn(parent.type)) {
const s = new Scope(scope, 'block')
hoist(node.body, s, { fnScope })
for (const st of node.body) walk(st, s, fnScope, node, 'body')
return
}
if (node.type === 'CatchClause') {
const s = new Scope(scope, 'catch')
for (const n of patternNames(node.param)) s.declare(n)
hoist(node.body.body, s, { fnScope })
for (const st of node.body.body) walk(st, s, fnScope, node, 'body')
return
}
if (node.type === 'ForStatement' || node.type === 'ForOfStatement' || node.type === 'ForInStatement') {
const s = new Scope(scope, 'block')
const init = node.init || node.left
if (init && init.type === 'VariableDeclaration') {
for (const d of init.declarations) for (const n of patternNames(d.id)) s.declare(n)
}
for (const k of ['init', 'left', 'test', 'update', 'right', 'body']) {
if (node[k]) {
if (k === 'body' && node[k].type === 'BlockStatement') {
hoist(node[k].body, s, { fnScope })
for (const st of node[k].body) walk(st, s, fnScope, node[k], 'body')
} else walk(node[k], s, fnScope, node, k)
}
}
return
}
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
const s = new Scope(scope, 'class')
if (node.id) s.declare(node.id.name)
if (node.superClass) walk(node.superClass, s, fnScope, node, 'superClass')
if (node.body) for (const el of node.body.body) walk(el, s, fnScope, node, 'body')
return
}
// A variable declarator's id is a binding, not a reference; its init is a reference site.
if (node.type === 'VariableDeclarator') {
// Defaults inside the pattern ARE references.
const scanPattern = (p) => {
if (!p) return
if (p.type === 'AssignmentPattern') { walk(p.right, scope, fnScope, p, 'right'); scanPattern(p.left) }
else if (p.type === 'ObjectPattern') for (const pr of p.properties) scanPattern(pr.type === 'RestElement' ? pr.argument : pr.value)
else if (p.type === 'ArrayPattern') for (const e of p.elements) scanPattern(e)
}
scanPattern(node.id)
if (node.init) walk(node.init, scope, fnScope, node, 'init')
return
}
if (node.type === 'ImportDeclaration' || node.type === 'ExportAllDeclaration') return
for (const k of Object.keys(node)) {
if (k === 'loc' || k === 'start' || k === 'end' || k === 'range' || k === 'type') continue
const v = node[k]
if (Array.isArray(v)) for (const c of v) walk(c, scope, fnScope, node, k)
else if (v && typeof v === 'object' && typeof v.type === 'string') walk(v, scope, fnScope, node, k)
}
}
for (const st of ast.body) walk(st, module, module, ast, 'body')
return free
}
// ── sweep ────────────────────────────────────────────────────────────────────────────────────────
function vueFiles(dir, out = []) {
let entries
try { entries = readdirSync(dir) } catch { return out }
for (const e of entries) {
if (e === 'node_modules' || e.startsWith('.')) continue
const p = join(dir, e)
const st = statSync(p)
if (st.isDirectory()) vueFiles(p, out)
// ★ .js MODULES TOO, since the NEUTRAL P1 (2026-09-27). This gate read only .vue script blocks,
// and the bug that emptied Big Picture's Religion and Region charts was a plain .js module:
// useDashboardAggregations.js read NEUTRAL five times and never imported it. A bundle compiles
// .js and .vue alike; the gate was seeing half of what ships.
else if (e.endsWith('.vue') || e.endsWith('.js')) out.push(p)
}
return out
}
const files = ROOTS.flatMap((r) => vueFiles(join(ROOT, r)))
const offenders = []
const unparsed = []
let scanned = 0
for (const file of files) {
const src = readFileSync(file, 'utf8')
let descriptor
if (file.endsWith('.js')) {
// A .js module is its own script block. Running it through the SFC parser is what broke the first
// attempt at this: parseSFC found no <script>, the content came back shifted, and acorn reported
// "Unexpected token (2:1)" — the second line of the file's own doc comment. offset 0 keeps the
// reported line numbers the file's real ones.
descriptor = { scriptSetup: null, script: { content: src, loc: { start: { offset: 0 } } } }
} else {
try { ({ descriptor } = parseSFC(src, { filename: file })) } catch (e) { unparsed.push([file, e.message]); continue }
}
// ★ BOTH SCRIPT BLOCKS, because an SFC may have both and they SHARE a scope. LegendTools.vue
// exports a function from its plain <script> and uses it in <script setup>; reading only one
// block reported that as a free identifier, which would have taught everyone to ignore this gate
// on its first run. The plain block's top-level names are seeded into the module scope.
const setup = descriptor.scriptSetup
const plain = descriptor.script
const block = setup || plain
if (!block || !block.content.trim()) continue
scanned++
const parseJs = (code) => acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module', locations: true, allowAwaitOutsideFunction: true })
let ast, seeded = []
try {
ast = parseJs(block.content)
if (setup && plain && plain.content.trim()) {
const other = parseJs(plain.content)
const s2 = new Scope(null, 'module')
hoist(other.body, s2, { fnScope: s2 })
seeded = [...s2.names]
}
} catch (e) { unparsed.push([file, e.message]); continue }
const free = analyse(ast, seeded)
if (free.length) {
// The script block starts partway down the file; report the file's own line numbers.
const offset = src.slice(0, block.loc.start.offset).split('\n').length - 1
const seen = new Set()
for (const f of free) {
const k = `${f.name}:${f.line}`
if (seen.has(k)) continue
seen.add(k)
offenders.push({ file: relative(ROOT, file), name: f.name, line: f.line + offset })
}
}
}
console.log(`${TAG}: scanned ${scanned} single-file component(s) with a script block across ${ROOTS.join(', ')}.`)
for (const [f, m] of unparsed) console.log(` ⚠ could not parse ${relative(ROOT, f)}: ${String(m).slice(0, 120)}`)
if (offenders.length) {
console.log(`\n${TAG}: FAIL — ${offenders.length} reference(s) to a name that is never bound:`)
for (const o of offenders.slice(0, 40)) console.log(` ✗ ${o.file}:${o.line} ${o.name}`)
if (offenders.length > 40) console.log(` … and ${offenders.length - 40} more`)
console.log('\n A free identifier is valid JavaScript until it RUNS, so it compiles, bundles and')
console.log(' passes every gate that does not evaluate a setup function — then throws a')
console.log(' ReferenceError and renders nothing. Usually a missing import.')
process.exit(1)
}
if (unparsed.length) { console.log(`\n${TAG}: FAIL — ${unparsed.length} file(s) could not be parsed; a file this gate cannot read is a file it cannot vouch for.`); process.exit(2) }
console.log(`${TAG}: PASS — every name used is imported, declared, a parameter, or a known global.`)