doxa-maps/internal/check-sfc-script-parses.mjs
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

146 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
/**
* check-sfc-script-parses.mjs — every .vue script block must actually PARSE.
*
* ⚠ IT IMPORTS `@vue/compiler-sfc`, so it needs an INSTALLED node_modules. Unlike its ~20 sibling
* check:* gates, which use only Node built-ins and therefore run in a bare worktree, this one
* dies with ERR_MODULE_NOT_FOUND if deps are missing — and it is the FIRST pre-gate to do so, so
* its failure is what a dependency-less worktree looks like from the build log. Measured
* 2026-09-24 in wt-packet: `node_modules/` was entirely absent, `bun run build` reported
* "6/7 pre gates passed — check:sfc-parse FAILED" on a tree with ZERO parse errors, and the real
* error (`Cannot find package '@vue/compiler-sfc'`) was hidden by the parallel runner.
* If you see this gate fail with ERR_MODULE_NOT_FOUND, run `bun install --frozen-lockfile` —
* do NOT chase a parse bug, and do NOT switch the shebang to bun. Switching runtimes only
* APPEARS to fix it: bun falls back to a global module cache and resolves the import on a tree
* that has no node_modules at all, so this gate goes green while all 23 bundle builds still fail
* on `Cannot find package 'vite'`. That is a masked environment fault, not a fix.
*
* THE GAP THIS CLOSES. `check:template-literal` catches a stray backtick inside a CSS *comment*
* within a template literal, but NOT one in LIVE CSS — and a live-CSS backtick breaks the build the
* same way. Proven both directions on 2026-09-21 (see
* BUG-TEMPLATE-LITERAL-GATE-MISSES-BACKTICKS-IN-LIVE-CSS): planting `content: \`x\`;` into
* doxa-simple-map's shadow CSS left check:template-literal at PASS/EXIT=0, while
* `BUNDLE=doxa-simple-map bun x vite build` died EXIT=1 with a Babel parseSubscripts stack trace
* pointing far from the edit — the exact misleading error that gate exists to replace.
*
* ★ WHY "NOT ACHIEVABLE" WAS ONE STEP TOO STRONG. That card concluded live-CSS detection was not
* achievable, on solid evidence: THREE lookahead heuristics were tried and reverted, scoring 98 and
* then 136 false positives on a HEALTHY tree (the real terminator of a template literal is also a
* backtick, so no window of surrounding characters can separate the two). The evidence was right
* and the inference over-generalised — "no heuristic works" is not "nothing works." The card itself
* named the answer in its last line: *needs a tokenizer*. A parser does not guess; it knows where
* the literal ends, because ending it is what parsing means.
*
* ★ MEASURED BEFORE BEING BUILT, against the same tree the heuristics failed on:
* lookahead heuristic (pre-fix) -> 98 false positives
* lookahead heuristic (post-fix) -> 136 false positives
* THIS (parse the script block) -> 0 false positives across 83 .vue files
*
* WHY @babel/parser SPECIFICALLY: it is the parser Vite's own pipeline reports errors from, so its
* verdict matches what actually breaks the build rather than approximating it. @vue/compiler-sfc
* splits the SFC first, so the check sees exactly the `<script>` / `<script setup>` text the build
* compiles — no regex extraction, no guessing where a block starts.
*
* WHAT IT DOES NOT DO: it is not a linter and takes no view on style. A file either parses as the
* build will parse it, or it does not. That narrowness is the point — it can never cry wolf, which
* is what disqualified every heuristic attempt.
*
* NEGATIVE-TESTED: planting a backtick in LIVE shadow CSS (the exact case check:template-literal
* misses) makes this FAIL naming the file and the line; removing it returns PASS. A run that finds
* fewer than MIN_FILES .vue files FAILS rather than passing vacuously.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse as sfcParse } from '@vue/compiler-sfc'
import { parse as babelParse } from '@babel/parser'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
// The two authored zones. internal/ and deployment/ hold no .vue sources.
const SCAN = ['plugin-designs', 'library']
function walk(dir, out = []) {
if (!existsSync(dir)) return out
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
const p = join(dir, e.name)
if (e.isDirectory()) walk(p, out)
else if (e.name.endsWith('.vue')) out.push(p)
}
return out
}
const files = SCAN.flatMap(d => walk(join(ROOT, d)))
// Vacuous guard: "0 files scanned" must never read as "0 problems".
const MIN_FILES = 40
if (files.length < MIN_FILES) {
console.error(`SFC-PARSE: FAIL — only ${files.length} .vue file(s) found (expected >= ${MIN_FILES}).`)
console.error(' The walk is broken or the tree is empty. Refusing to pass vacuously.')
process.exit(1)
}
const broken = []
let parsed = 0, blocks = 0
for (const f of files) {
const rel = f.replace(ROOT + '/', '')
const src = readFileSync(f, 'utf8')
let descriptor, errors
try { ({ descriptor, errors } = sfcParse(src, { filename: f })) }
catch (e) { broken.push({ rel, where: 'SFC', msg: String(e.message).split('\n')[0].slice(0, 90) }); continue }
if (errors && errors.length) {
broken.push({ rel, where: 'SFC', msg: String(errors[0].message).split('\n')[0].slice(0, 90) })
continue
}
// Both block forms compile in a real build, so both are checked.
const scripts = [descriptor.script, descriptor.scriptSetup].filter(Boolean)
let failed = false
for (const b of scripts) {
blocks++
try {
// jsx is enabled because a .vue script may legitimately contain it; enabling a plugin cannot
// make invalid code parse, only widen what counts as valid.
babelParse(b.content, { sourceType: 'module', plugins: ['jsx'] })
} catch (e) {
// Babel reports a line within the BLOCK; offset it to the file line so the message is
// actionable. b.loc.start.line is the line the block's content begins on.
const m = /\((\d+):(\d+)\)\s*$/.exec(e.message.split('\n')[0])
const fileLine = m ? (b.loc.start.line + Number(m[1]) - 1) : null
broken.push({
rel, where: b === descriptor.scriptSetup ? '<script setup>' : '<script>',
msg: e.message.split('\n')[0].replace(/\(\d+:\d+\)\s*$/, '').trim().slice(0, 80),
line: fileLine,
})
failed = true
break
}
}
if (!failed) parsed++
}
console.log(`SFC-PARSE: ${files.length} .vue file(s), ${blocks} script block(s) parsed.`)
if (broken.length) {
console.error(`\nSFC-PARSE: FAIL — ${broken.length} file(s) do not parse as the build will parse them:`)
for (const b of broken) {
console.error(` ✗ ${b.rel}${b.line ? ':' + b.line : ''} (${b.where})`)
console.error(` ${b.msg}`)
}
console.error(
'\n The build would fail on these too, but with a stack trace pointing FAR FROM the edit —\n' +
' that misleading error is what this gate exists to replace. The commonest cause in this repo\n' +
' is a stray backtick inside a shadow-CSS template literal: a backtick ENDS the literal, so\n' +
' everything after it is read as code. Write `display:block`, never a backticked form, and\n' +
' keep both synced CSS copies identical.'
)
process.exit(1)
}
console.log('SFC-PARSE: PASS — every .vue script block parses exactly as the build parses it.')
console.log(' This covers the LIVE-CSS backtick that check:template-literal cannot see (it reads')
console.log(' CSS comments only). Three lookahead heuristics scored 98 and 136 false positives on')
console.log(' this same tree; parsing scores 0, because it never guesses where a literal ends.')