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:
parent
018264f6be
commit
7ff65526c2
1 changed files with 320 additions and 0 deletions
|
|
@ -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.`)
|
||||
Loading…
Reference in a new issue