doxa-maps/internal/check-mjs-parses.mjs
Doxa Maps 1c3c5830a1
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source f3d89f8c
2026-09-27 22:32:49 +00:00

109 lines
5.9 KiB
JavaScript

#!/usr/bin/env node
/**
* check-mjs-parses.mjs — every build script must PARSE before the build runs it.
*
* THE DEFECT THIS CATCHES, hit twice within ten minutes on 2026-09-22 by two different agents:
* a BACKTICK inside a `//` comment that sits INSIDE a template literal of generated browser JS.
* The backtick closes the literal early and the parser then reads prose as code, failing hundreds
* of lines away from the edit:
*
* // ... so a bare `TREE + '/x'` is only accidentally right
* SyntaxError: Unexpected identifier 'TREE' ← reported at :386, cause is the comment
*
* ★ WHY check:template-literal DOES NOT CATCH IT, and why that is not a bug in that gate. It scans
* for backticks inside `/* … *\/` CSS comments — the shadow-CSS shape. A `//` comment inside a
* generated-JS literal is a DIFFERENT GRAMMAR. Proven by controlled negative test: injecting
* `// a bare \`TREE\` reference` into internal/generate-tree.mjs — a file that gate explicitly
* lists — left it reporting PASS, while `node --check` failed immediately. The rule was right and
* the implementation covered a narrower case than the rule stated.
*
* ★ WHY A PARSER AND NOT A WIDER REGEX. The CSS-comment scanner works because CSS comments have one
* unambiguous form. JavaScript does not: a backtick can be legal inside a string, a regex, a nested
* template, or a comment that is itself inside a template. Any pattern precise enough to catch the
* bad case would false-positive on the legal ones — and a gate that cries wolf gets skipped, which
* is worse than this gap. The parser already knows all of that, so ask it.
*
* WHAT IT ASSERTS: every internal/*.mjs parses as an ES module. Nothing more. It is deliberately
* not a lint — it answers one question, the one that stops a build.
*
* WHY IT BELONGS EARLY. `bun run build` runs check:template-literal as step 1 precisely so a broken
* literal fails in a second rather than after minutes of compilation. This closes the other half of
* that promise for the 70 scripts the build itself is made of. It is fast (a parse, no execution)
* and hermetic: no network, no browser, no built tree.
*
* ★ IT PARSES, IT DOES NOT IMPORT. Importing would EXECUTE module top-level code — these scripts
* read files, spawn browsers and hit the network at import time. A syntax check must not have side
* effects.
*
* NEGATIVE-TESTED: injecting a backtick into a `//` comment inside a literal in generate-tree.mjs
* fails by name with the file and line, where check:template-literal reports PASS.
*/
import { readdirSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFileSync } from 'node:child_process'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DIR = join(ROOT, 'internal')
const files = readdirSync(DIR)
.filter((f) => f.endsWith('.mjs'))
.filter((f) => !f.startsWith('_tmp-')) // scratch probes are transient by construction
.sort()
if (!files.length) {
console.error('MJS-PARSE: FAIL — found 0 .mjs files under internal/.')
console.error(' A gate with nothing to assert is not passing; it is not running.')
process.exit(1)
}
// Under bun, process.execPath is bun, and `bun --check` RUNS the script instead of parsing it (a cloud
// build hung 18+ min inside check-link-rot-sample.mjs). Parse with a real node, or refuse to guess.
let NODE = process.execPath
if (process.versions.bun) {
NODE = 'node'
try { execFileSync(NODE, ['--version'], { stdio: 'pipe' }) } catch {
console.error('MJS-PARSE: FAIL — running under bun and no `node` on PATH; this gate needs node --check (bun --check executes).')
process.exit(1)
}
}
const broken = []
for (const f of files) {
const p = join(DIR, f)
try {
// `node --check` is the same parser the build uses, so a pass here means the build will at
// least get past parsing. It does not execute the module.
execFileSync(NODE, ['--check', p], { stdio: 'pipe' })
} catch (e) {
const out = String(e.stderr || e.stdout || e.message)
// node --check prints: <path>\n<offending source line>\n<caret>\n\nSyntaxError: <msg>
const msg = (out.match(/SyntaxError: .*/) || ['SyntaxError (message not captured)'])[0]
const line = (out.match(/:(\d+)\n/) || out.match(/\((\d+):\d+\)/) || [])[1]
broken.push({ f, msg: msg.slice(0, 120), line })
}
}
if (broken.length) {
console.error(`MJS-PARSE: FAIL — ${broken.length} of ${files.length} build script(s) do not parse.\n`)
for (const b of broken) {
console.error(` internal/${b.f}${b.line ? ':' + b.line : ''}`)
console.error(` ${b.msg}`)
}
console.error('\n ★ The usual cause is a BACKTICK inside a // comment that sits inside a template')
console.error(' literal of generated code. The backtick closes the literal and the parser reads')
console.error(' the prose as code, so the reported line is far from the real edit — look for a')
console.error(' code term wrapped in backticks in a comment, not at the line named above.')
console.error(' check:template-literal will NOT find it: that gate reads CSS comments, which is a')
console.error(' different grammar. Both gates are needed.')
console.error('\n Find every instance in one pass rather than iterating — node --check reports')
console.error(' only the FIRST, and each rebuild to surface the next costs minutes:')
console.error(" grep -n '^\\s*//.*`' internal/<file>.mjs")
process.exit(1)
}
console.log(`MJS-PARSE: PASS — ${files.length} build script(s) parse as ES modules.`)
console.log(' Parses only; never imports, because these scripts read files, spawn browsers and hit')
console.log(' the network at import time and a syntax check must have no side effects.')
console.log(' Complements check:template-literal rather than replacing it: that one reads CSS')
console.log(' comments in shadow-CSS literals, this one asks the parser about whole modules.')