#!/usr/bin/env node /** * check-setup-order.mjs — code that RUNS during setup must not be written below what it reads. * * Two arms, one root cause: a line's POSITION in a setup block is not the same as the MOMENT it * runs, and both defects below shipped because the author read the file top-to-bottom and assumed * the runtime would too. Both were found in the agent's own console, in a gallery tab, on code * that built clean, parsed clean and passed every other gate in this tree. * * ── ARM A: IMMEDIATE-TDZ ────────────────────────────────────────────────────────────────────── * research-map.vue:2427 — `ReferenceError: Cannot access '_lastFitKey' before initialization`, * and the whole map blanked, because setup threw. * * `watch(src, cb, { immediate: true })` runs cb DURING setup, at the line the watch() sits on. * That watcher called applyFitSlugs(), a `function` declaration 350 lines further down — which is * hoisted, so the CALL resolved fine. Its constants were not: `let _lastFitKey` and seven * `const FIT_*` above it are lexical, still in their temporal dead zone, and the first read threw. * * THE HOISTING ASYMMETRY IS THE WHOLE TRAP. A hoisted function is a promise that the NAME * resolves early, not that its BODY can run early. So the dangerous edge is not "does the watcher * read something below it" — that is easy to see — it is "does the watcher CALL something that * reads something below it", one level of indirection away, and invisible at both ends: the * watcher looks like it only touches the store, and the function looks like it only touches * constants that are obviously right there beside it. * * ── ARM B: HOOK-AFTER-AWAIT ─────────────────────────────────────────────────────────────────── * DashboardMapPanel.vue:1233 — `onBeforeUnmount is called when there is no active component * instance` (a warning, which is why it survived), and the cleanup silently never ran. * * Every lifecycle registrar closes over Vue's CURRENT INSTANCE, which exists only while setup is * on the stack. `onMounted(async () => { await initializeMap(); ...; onBeforeUnmount(teardown) })` * resumes after a microtask, with no current instance — so the registration is dropped and a * ResizeObserver on document.body plus a window 'resize' listener outlive the component. The * failure mode is the worst kind: nothing throws, the feature works, and the leak only shows up * as a studio that gets slower the more tabs the reader has visited. * * FIX SHAPE, not just "move the line": the teardown cannot live next to the code that creates the * thing, because that code is past the await. Declare the handle at setup scope and tear it down * in one hook registered during setup — which is the pattern the same file already used for * containerRO and attribRO, three lines away from the one that got it wrong. * * Both arms STRIP COMMENTS FIRST. This tree has been bitten five times by a check satisfied by a * comment quoting the code it was looking for, and this file's own header names both symbols. * * USAGE: node internal/check-setup-order.mjs [--poison] * EXIT: 0 = clean, 1 = a defect, 3 = the self-test failed (the gate cannot be trusted). */ import { readFileSync, readdirSync, statSync } from 'node:fs' import { resolve, dirname, relative, join } from 'node:path' import { fileURLToPath } from 'node:url' const HERE = dirname(fileURLToPath(import.meta.url)) const REPO = resolve(HERE, '..') const POISON = process.argv.includes('--poison') const HOOKS = [ 'onMounted', 'onBeforeMount', 'onBeforeUnmount', 'onUnmounted', 'onUpdated', 'onBeforeUpdate', 'onActivated', 'onDeactivated', 'onErrorCaptured', 'onRenderTracked', 'onRenderTriggered', 'onServerPrefetch', ] const HOOK_RE = new RegExp('\\b(' + HOOKS.join('|') + ')\\s*\\(', 'g') /* Blank out comments and string bodies in place, so every index stays where it was. */ const blank = (m) => m.replace(/[^\n]/g, ' ') const stripComments = (src) => src .replace(//g, blank) .replace(/\/\*[\s\S]*?\*\//g, blank) .replace(/(^|[^:\\'"`/])\/\/[^\n]*/g, (m, p) => p + ' '.repeat(m.length - p.length)) /* Blank the TEXT of every literal while keeping `${...}` interpolations, which are code. * One state machine rather than three regexes, because the three quote styles nest: version 2 of * this gate flagged four data-tables.vue files for "ensure() reads `table`" when the only `table` * in ensure() is the word in `new Error(`table not found (${r.status})`)`. Prose in a template * literal is exactly as inert as prose in a comment, and a regex cannot tell them apart. */ const stripStrings = (src) => { const out = src.split('') const stack = [] let i = 0 const blankAt = (n) => { if (src[n] !== '\n') out[n] = ' ' } while (i < src.length) { const c = src[i] const top = stack[stack.length - 1] if (top && (top.kind === 'sq' || top.kind === 'dq')) { if (c === '\\') { blankAt(i); if (i + 1 < src.length) blankAt(i + 1); i += 2; continue } if ((c === "'" && top.kind === 'sq') || (c === '"' && top.kind === 'dq') || c === '\n') { stack.pop(); i++; continue } blankAt(i); i++; continue } if (top && top.kind === 'tpl') { if (c === '\\') { blankAt(i); if (i + 1 < src.length) blankAt(i + 1); i += 2; continue } if (c === '`') { stack.pop(); i++; continue } if (c === '$' && src[i + 1] === '{') { stack.push({ kind: 'expr', depth: 0 }); i += 2; continue } blankAt(i); i++; continue } if (c === "'") { stack.push({ kind: 'sq' }); i++; continue } if (c === '"') { stack.push({ kind: 'dq' }); i++; continue } if (c === '`') { stack.push({ kind: 'tpl' }); i++; continue } if (top && top.kind === 'expr') { if (c === '{') top.depth++ else if (c === '}') { if (top.depth === 0) { stack.pop(); i++; continue } top.depth-- } } i++ } return out.join('') } /* Only the ` const PLANTED_B = `` /* The same two files after the fix, plus the shapes most likely to be mistaken for the defects: * a NON-immediate watcher above its constants (legal — it cannot run until they exist), a hook * registered BEFORE an await, and a comment that names the symbol the arms search for. */ const CLEAN = `` const PLANTED_C = `` const blindA = run(PLANTED_A, 'x.vue').filter((h) => h.arm === 'IMMEDIATE-TDZ').length === 0 const blindB = run(PLANTED_B, 'x.vue').filter((h) => h.arm === 'HOOK-AFTER-AWAIT').length === 0 const blindC = run(PLANTED_C, 'x.vue').filter((h) => h.arm === 'INSTANCE-OUTSIDE-SETUP').length === 0 const noisy = run(CLEAN, 'x.vue') if (blindA || blindB || blindC || noisy.length) { console.error('SETUP-ORDER: SELF-TEST FAILED — ' + (blindA ? 'arm A is blind to the shipped TDZ. ' : '') + (blindB ? 'arm B is blind to the shipped late hook. ' : '') + (blindC ? 'arm C is blind to the shipped getCurrentInstance-in-a-watcher. ' : '') + (noisy.length ? 'false positives on the fixed code: ' + noisy.map((h) => `${h.arm} ${h.why}`).join('; ') : '')) process.exit(3) } if (POISON) { console.log('SETUP-ORDER: self-test passed (all three defects caught, every fix and legal shape quiet).'); process.exit(0) } // ── scan ───────────────────────────────────────────────────────────────────────────────────────── // ★ ONE TRIGGER PER ARM, DERIVED — NOT A HAND-KEPT LIST, because a stale precondition voids every // careful thing below it and does so SILENTLY. This is the second time in one day: check:i18n-keys-exist // skipped any file that never wrote the word "tStatic", which is how a planted poison key passed it, // and I diagnosed that and then wrote the same hole here within the hour — arm C went in and the // early-out still only knew about arms A and B, so BOTH files carrying the live RTL bug were skipped // before a single arm ran. The gate reported the tree clean and the self-test stayed green, because a // fixture is text and never meets the file filter. // The rule this encodes: a cheap early-out is part of a gate's COVERAGE, not an optimisation, so it // has to be written where adding an arm forces you to extend it. const ARM_TRIGGERS = [ /\bimmediate\s*:\s*true\b/, // A: an immediate watcher runs during setup /\basync\b/, // B: only an async body can put a hook after an await /\bgetCurrentInstance\s*\(/, // C: the instance read that answers only during setup or a hook ] const SCANNABLE = new RegExp(ARM_TRIGGERS.map((r) => r.source).join('|')) const ROOTS = ['plugin-designs', 'library'] const SKIP = /(^|\/)(node_modules|dist|public|\.git|fixtures)(\/|$)/ function files(dir, acc = []) { let entries try { entries = readdirSync(dir) } catch (_) { return acc } for (const e of entries) { const p = join(dir, e) if (SKIP.test(relative(REPO, p))) continue let st try { st = statSync(p) } catch (_) { continue } if (st.isDirectory()) files(p, acc) else if (/\.(vue|js|mjs)$/.test(e)) acc.push(p) } return acc } const found = [] for (const root of ROOTS) { for (const f of files(resolve(REPO, root))) { let src try { src = readFileSync(f, 'utf8') } catch (_) { continue } if (!SCANNABLE.test(src)) continue for (const h of run(src, f)) found.push({ file: relative(REPO, f), ...h }) } } if (!found.length) { console.log('SETUP-ORDER: OK — no immediate watcher reads below itself, no lifecycle hook is registered after an await, and no getCurrentInstance() is read from a watcher or a listener.') process.exit(0) } console.error(`SETUP-ORDER: ${found.length} defect(s) —`) for (const h of found) console.error(` ${h.arm} ${h.file}:${h.line} ${h.why}`) console.error('\nARM A: move the declaration above the watcher (a hoisted function resolves its NAME early, not its BODY).') console.error('ARM B: declare the handle at setup scope and tear it down in a hook registered BEFORE the first await.') console.error('ARM C: capture the instance in setup (const _selfInstance = getCurrentInstance()); a call inside a hook is fine, inside a watcher it is null.') process.exit(1)