A gate for the mistake I shipped: a name used but never bound

I broke both composites on main. Editing them 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 and never imported. Setup threw a
ReferenceError and both elements rendered nothing until a hotfix landed.

★ IT PASSED EVERY GATE, AND THAT IS THE INTERESTING PART. It compiled, because a
free identifier is valid JavaScript until it RUNS. It passed my grep of the
built bundle for the new code, because the code was genuinely there. It passed
template-literal, sfc-parse, composite-freshness and SRI, because not one of
them evaluates a setup function. The evidence I had could not distinguish a
working composite from a dead one, and I called it "verified headless".

So this asks the one question those gates cannot: is every name a component uses
actually bound — imported, declared, a parameter, or a known global? It reads
the same source the compiler reads, tracks real scopes (module, function, block,
catch, class), and runs in milliseconds with no browser. Fleet-wide, over every
.vue in plugin-designs/ and library/, because the mistake was a text-editing
habit rather than anything about one plugin.

WRITTEN BY RUNNING IT, which is the only way an allowlist gets honest. The first
run reported 4,259 findings — every one a false positive from a real bug in my
own walker: a helper that takes a node TYPE was being handed the NODE, so the
function branch never fired and every parameter read as unbound. The second run
found the globals this tree genuinely uses (d3, customElements, ShadowRoot, CSS,
Function, self, the typed arrays) and vite's build-time defines. The third found
a real gap in the gate: an SFC may have BOTH a plain <script> and a <script
setup>, and they share a scope — LegendTools exports a function from one and
uses it in the other, which I was reporting as free. Reading only one block
would have taught the fleet to ignore this gate on its first run.

NEGATIVE CONTROL, because a check that has never been seen to fail is not
evidence: restoring the broken import makes it FAIL naming all three names and
their lines; restored, it passes across 127 components.

It is the cheap half of a pair — it proves a name is bound, not that the thing
runs. The expensive half is a check that actually mounts a composite.
This commit is contained in:
Doxa Maps 2026-09-26 03:29:51 +00:00
parent 018264f6be
commit 7ff65526c2

View file

@ -0,0 +1,320 @@
#!/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', 'ResizeObserver', 'MutationObserver', 'PerformanceObserver', 'performance',
'HTMLElement', 'HTMLCanvasElement', 'HTMLImageElement', 'Element', 'Node', 'NodeList', 'DOMParser',
'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)
else if (e.endsWith('.vue')) 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
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.`)