#!/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)