diff --git a/scripts/check-driver-memory-census.mjs b/scripts/check-driver-memory-census.mjs index a41504223e..09a395f61d 100644 --- a/scripts/check-driver-memory-census.mjs +++ b/scripts/check-driver-memory-census.mjs @@ -108,6 +108,7 @@ import { join, dirname, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const LEDGER_PATH = join(ROOT, 'scripts', 'driver-memory-census.ledger.json'); @@ -229,7 +230,7 @@ function classify(node) { /** Every occurrence of the specifier in one source text, classified. */ export function scanSource(fileName, text) { - const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true); + const sf = parseSourceFile(fileName, text); const found = []; const visit = (node) => { if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && namesPackage(node.text)) { diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index dff5f29eb4..0a25eccb88 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -240,6 +240,7 @@ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const BASELINE_PATH = join(ROOT, 'scripts', 'durability-degradation.baseline.json'); @@ -2441,7 +2442,7 @@ function runReadSeamRule({ list = false } = {}) { for (const file of collectSourceFiles(join(ROOT, root))) { const text = readFileSync(file, 'utf8'); if (!text.includes('catch')) continue; - const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sf = parseSourceFile(file, text, ts.ScriptKind.TS); analyzeReadSeams(sf, relative(ROOT, file).split(sep).join('/'), findings, seams, { usedDiscriminators, }); @@ -2716,7 +2717,7 @@ function run({ list = false } = {}) { for (const file of files) { const text = readFileSync(file, 'utf8'); if (!text.includes('catch')) continue; - const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sf = parseSourceFile(file, text, ts.ScriptKind.TS); analyzeSourceFile(sf, relative(ROOT, file).split(sep).join('/'), findings, seams, { usedPropagationSites, summaryBranches, @@ -3815,7 +3816,7 @@ function selfTest() { let failures = 0; for (const c of cases) { - const sf = ts.createSourceFile('t.ts', c.code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sf = parseSourceFile('t.ts', c.code, ts.ScriptKind.TS); const findings = []; const seams = []; const summaryBranches = []; @@ -4481,7 +4482,7 @@ function selfTestReadSeams() { let failures = 0; for (const c of cases) { - const sf = ts.createSourceFile('t.ts', c.code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sf = parseSourceFile('t.ts', c.code, ts.ScriptKind.TS); const findings = []; const seams = []; const usedDiscriminators = new Set(); diff --git a/scripts/check-engine-double-contract.mjs b/scripts/check-engine-double-contract.mjs index 160c2dee33..a969e6b487 100644 --- a/scripts/check-engine-double-contract.mjs +++ b/scripts/check-engine-double-contract.mjs @@ -275,6 +275,7 @@ import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs'; import { join, dirname, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const BASELINE_PATH = join(ROOT, 'scripts', 'engine-double-contract.baseline.json'); @@ -816,7 +817,7 @@ function localFunctions(sourceFile) { * #5393 hit and #5480 removed the excuse for. */ function scanSource(fileName, text, slice = SLICES[0], opts = {}) { - const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const sf = parseSourceFile(fileName, text); const pinnedNames = pinnedImportsOf(sf, slice); const locals = localFunctions(sf); const doubles = []; @@ -960,7 +961,7 @@ function declaredBindings(sf) { * construct left the population without any verdict being recorded. */ function censusSource(fileName, text, slice) { - const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const sf = parseSourceFile(fileName, text); const declared = declaredBindings(sf); const unrecognised = []; const scopedOut = []; @@ -1202,7 +1203,7 @@ function censusRecognizer() { const rel = relative(ROOT, abs).split(sep).join('/'); const text = readFileSync(abs, 'utf8'); if (!/\b(delete|update)\s*[(:,}]/.test(text)) continue; - const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const sf = parseSourceFile(abs, text); const consider = (props) => { for (const m of props) { @@ -1802,7 +1803,7 @@ function envelopeImportsOf(sourceFile) { * caller-supplied id, in a function that answers a receipt. */ function scanSeams(fileName, text) { - const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sf = parseSourceFile(fileName, text, ts.ScriptKind.TS); const envelopeNames = envelopeImportsOf(sf); const localFns = localFunctions(sf); const methodFns = classMethods(sf); @@ -1949,7 +1950,7 @@ function declaredFunctionNames(sourceFile) { function fileDeclaresFunction(file, fn) { const abs = join(ROOT, file); if (!existsSync(abs)) return false; - const sf = ts.createSourceFile(file, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sf = parseSourceFile(file, readFileSync(abs, 'utf8'), ts.ScriptKind.TS); return declaredFunctionNames(sf).has(fn); } @@ -3517,7 +3518,7 @@ ${body} // (`scalarWhereIdOf` is where "scalar" means something) and this pins it // where a mutation can reach it. const whereIdOf = (src) => { - const f = ts.createSourceFile('t.ts', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const f = parseSourceFile('t.ts', src, ts.ScriptKind.TS); let lit = null; const v = (n) => { if (!lit && ts.isObjectLiteralExpression(n) && propertyNamed(n, 'where')) lit = n; ts.forEachChild(n, v); }; v(f); @@ -3929,7 +3930,7 @@ class Svc { // a walker blind to one of them would classify that seam's loss as the // quieter story — and the classifier would still look healthy. const namesOf = (src) => declaredFunctionNames( - ts.createSourceFile('d.ts', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)); + parseSourceFile('d.ts', src, ts.ScriptKind.TS)); expect('declaredFunctionNames reads a top-level function declaration (`callData`s shape)', namesOf('export async function callData(deps) { return 1; }').has('callData')); expect('…an OBJECT LITERAL method (`protocol.updateData` / the MCP bridge’s shape)', @@ -4053,7 +4054,7 @@ const engine: any = { registry: {}, insert: async (o: string, d: any) => d, find // another would print a confident table and hide the same blind spot the // constants did. const kindsOf = (src) => { - const sf = ts.createSourceFile('k.test.ts', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const sf = parseSourceFile('k.test.ts', src, ts.ScriptKind.TSX); const out = []; const visit = (n) => { if (ts.isObjectLiteralExpression(n) || ts.isClassDeclaration(n)) { @@ -4076,7 +4077,7 @@ const engine: any = { registry: {}, insert: async (o: string, d: any) => d, find expect('#9943 — a method body is its own kind too', kindsOf('const e = { async update(o, d) {} };') === 'method body'); const assignSites = (src) => objectAssignSites( - ts.createSourceFile('a.test.ts', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX), SCANNED_VERBS, + parseSourceFile('a.test.ts', src, ts.ScriptKind.TSX), SCANNED_VERBS, ); let sites = assignSites('const ql = Object.assign(makeQl(), { async find() { return []; }, async insert() { return null; } });'); expect('#8553 — an Object.assign override varying OTHER engine members reads as BASE-accounted ' diff --git a/scripts/check-filter-alias-parity.mjs b/scripts/check-filter-alias-parity.mjs index 7fb0645bd9..d0747301c9 100644 --- a/scripts/check-filter-alias-parity.mjs +++ b/scripts/check-filter-alias-parity.mjs @@ -87,6 +87,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -108,7 +109,7 @@ const FILTER_SLOT = 'where'; class UnreadableShape extends Error {} function parse(path, text) { - return ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true); + return parseSourceFile(path, text); } /** Every node in a subtree, depth-first. */ diff --git a/scripts/check-init-service-contract.mjs b/scripts/check-init-service-contract.mjs index 207a67c05a..1c0f153219 100644 --- a/scripts/check-init-service-contract.mjs +++ b/scripts/check-init-service-contract.mjs @@ -82,6 +82,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -393,7 +394,7 @@ function scan(files = discoverFiles()) { // would be filtered out here before the AST ever saw it, which is the same // silent-hole failure this file's #4772 note is about. if (!PREFILTER_TOKENS.some((token) => text.includes(token))) continue; - const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const src = parseSourceFile(file, text); for (const unit of collectPluginUnits(file, src)) { units.push({ ...unit, initCalls: initServiceCalls(unit, src) }); } @@ -501,7 +502,7 @@ function selfTest() { const assert = (cond, msg) => { if (!cond) { console.error('✗ self-test: ' + msg); process.exit(1); } }; const auditSource = (code) => { - const src = ts.createSourceFile('fixture.ts', code, ts.ScriptTarget.Latest, true); + const src = parseSourceFile('fixture.ts', code); const units = collectPluginUnits('fixture.ts', src).map((u) => ({ ...u, initCalls: initServiceCalls(u, src) })); return auditUnits(units); }; diff --git a/scripts/check-kernel-hook-pairs.mjs b/scripts/check-kernel-hook-pairs.mjs index 5e4973f67b..05db38ed2d 100644 --- a/scripts/check-kernel-hook-pairs.mjs +++ b/scripts/check-kernel-hook-pairs.mjs @@ -71,6 +71,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -104,7 +105,7 @@ const HOOK_NAME = /^kernel:[A-Za-z][A-Za-z0-9_:-]*$/; // ── Scanning ───────────────────────────────────────────────────────────────── function parse(fileName, source) { - return ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + return parseSourceFile(fileName, source, ts.ScriptKind.TS); } /** The identifier a call expression ends in: `a.b.c(x)` → `c`, `c(x)` → `c`. */ diff --git a/scripts/check-meta-type-normalized.mjs b/scripts/check-meta-type-normalized.mjs index 4999c811d3..581d5abf5a 100644 --- a/scripts/check-meta-type-normalized.mjs +++ b/scripts/check-meta-type-normalized.mjs @@ -82,6 +82,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -145,7 +146,7 @@ function walkFiles(dir, out) { /** Every raw-param decision site in one file. */ function findViolations(file) { const text = readFileSync(file, 'utf8'); - const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const source = parseSourceFile(file, text); const found = []; const record = (node, kind) => { @@ -198,7 +199,7 @@ function selfTest() { const ok2 = RestServer.metaTypeSingular(req.params.type) === 'book'; const ok3 = metaType === 'doc'; `; - const source = ts.createSourceFile('fixture.ts', fixture, ts.ScriptTarget.Latest, true); + const source = parseSourceFile('fixture.ts', fixture); const hits = []; const visit = (node) => { if (ts.isBinaryExpression(node) && COMPARISON_OPS.has(node.operatorToken.kind) diff --git a/scripts/check-org-identifier.mjs b/scripts/check-org-identifier.mjs index 2244c8d3f3..438702306b 100644 --- a/scripts/check-org-identifier.mjs +++ b/scripts/check-org-identifier.mjs @@ -269,6 +269,7 @@ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; import { maskComments } from './js-comment-mask.mjs'; const ROOTS = ['examples', 'apps', 'packages']; @@ -499,7 +500,7 @@ function parse(text, file) { // `scriptKind` left to the parser so `.tsx` / `.jsx` / `.mjs` are inferred // from the name. The parser is error-tolerant: a file it cannot fully parse // still yields the nodes around the failure rather than throwing. - return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, /* setParentNodes */ true); + return parseSourceFile(file, text); } // ── the gate ────────────────────────────────────────────────────────────── diff --git a/scripts/check-parse-guard.mjs b/scripts/check-parse-guard.mjs new file mode 100644 index 0000000000..8162583ce9 --- /dev/null +++ b/scripts/check-parse-guard.mjs @@ -0,0 +1,245 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-parse-guard -- every `scripts/**` TypeScript parse goes through ONE module. + * + * node scripts/check-parse-guard.mjs # scan the tree + * node scripts/check-parse-guard.mjs --self-test # verify the checker itself + * + * ## What this gate is for + * + * `ts.createSourceFile` never throws. Hand it a syntax error and it returns a + * `SourceFile` built by error recovery, with the errors parked on + * `parseDiagnostics` -- a property that NOTHING in `scripts/` read. Fifteen + * gates walked TypeScript that way, so any one of them could report a confident + * zero about a file it never managed to read: **a file the gate could not read, + * scored as a file with nothing to report.** + * + * `scripts/ts-parse.mjs` is the fix -- it reads the diagnostics and REFUSES. + * This gate is the half that makes the fix hold, and without it the helper + * would be strictly worse than fifteen hand-written checks: a sixteenth gate + * typing the raw call would inherit the whole defect, and its symptom is a + * GREEN LINE, so nobody would notice. Converting the callers is a one-time + * sweep; this file is what covers the caller that has not been written yet. + * + * That is not a theory about the tree, it is the tree's own measured result + * twice over: `check-entry-guard.mjs` exists because a one-time sweep of 33 + * files did not stop a twelfth spelling of "was I run?", and `js-comment-mask.mjs` + * exists because two private `stripComments` families drifted apart in two + * different directions. + * + * ## Why a spelling gate rather than a behavioural sweep + * + * The same answer `check-entry-guard.mjs` gives, and for the same reason: many + * `scripts/**` entry points have real side effects, so a gate that RUNS them + * all is a gate nobody can run locally, and "did it refuse?" is not a decidable + * property of an arbitrary tool. The behavioural evidence lives once, at the + * module: `ts-parse.mjs --self-test` spawns real children and pins that every + * measured wreck refuses, names its file, and cannot be swallowed by a caller's + * `try/catch`. Pinning the refusal once and enforcing that everyone routes + * through it covers the same ground for fifteen callers, and keeps covering it + * for the sixteenth. + * + * ## What it reads + * + * Comments AND string/template/regex literals are masked before the scan + * (`js-comment-mask.mjs`), for the same reason `check-entry-guard.mjs` masks + * them: `ts-parse.mjs`'s own header discusses `ts.createSourceFile` at length, + * gates quote it in their prose, and a spawned child's source can carry it in a + * string payload. None of those is a call site, and an allowlist to excuse them + * would be a hole the next such file falls through silently. + * + * ## What it deliberately does NOT cover, so the green line does not over-claim + * + * • **`ts.createProgram`** (`check-published-readme-exports.mjs`). A Program + * reports syntax through `getSyntacticDiagnostics()`, a different API with + * different failure modes; folding it in here would mean one helper making + * two promises. It has the same class of hole and is filed rather than + * silently swept in. + * • **`ts.transpileModule`** (`check-where-matcher-conformance.mjs`). It + * reports nothing at all unless `reportDiagnostics: true` is passed -- same + * class, third API. + * • **Outside `scripts/**`.** `packages/lint/src/*.ts`, + * `packages/cli/src/utils/detect-free-identifiers.ts`, + * `packages/spec/scripts/*.ts` and `packages/lint/scripts/*.mjs` parse too. + * They are not covered here for the reason `invoked-as.mjs` gives for its own + * `packages/cli` sibling: `scripts/` runs as plain `.mjs` against a possibly + * unbuilt tree, and making a published package depend on repo tooling to + * answer "did this parse?" trades this bug for a worse one. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; +import { blank, scanSource } from './js-comment-mask.mjs'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const REPO_ROOT = resolve(HERE, '..'); +const SCRIPTS = HERE; + +/** The one module allowed to call `ts.createSourceFile`. */ +const PARSER_HOME = join(SCRIPTS, 'ts-parse.mjs'); + +/** The canonical call, and the only accepted spelling. */ +export const CANONICAL = 'parseSourceFile(fileName, text /*, scriptKind */)'; + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name.startsWith('.')) continue; + const p = join(dir, name); + const st = statSync(p); + if (st.isDirectory()) walk(p, out); + else if (name.endsWith('.mjs') || name.endsWith('.js') || name.endsWith('.cjs')) out.push(p); + } + return out; +} + +/** Code only: comments, strings, templates and regex literals all blanked. */ +export function codeOnly(source) { + const { comment, literal } = scanSource(source); + const both = new Uint8Array(comment.length); + for (let i = 0; i < both.length; i++) both[i] = comment[i] || literal[i]; + return blank(source, both); +} + +function lineOf(source, index) { + return source.slice(0, index).split('\n').length; +} + +/** + * Findings for one file's source. Exported so the self-test drives the real + * scanner over fixture sources rather than over this tree, which would only + * prove what today's tree happens to contain. + */ +export function scanFile(rel, source, { isParserHome = false } = {}) { + if (isParserHome) return []; + const findings = []; + const code = codeOnly(source); + + // Any `createSourceFile` -- `ts.createSourceFile(...)`, a destructured + // `createSourceFile(...)`, or an aliased `tsc.createSourceFile(...)`. The + // receiver is deliberately not part of the pattern: a gate that renamed its + // typescript import would otherwise walk straight through. + const re = /\bcreateSourceFile\b/g; + let m; + while ((m = re.exec(code))) { + findings.push({ + rel, + line: lineOf(source, m.index), + what: 'ts.createSourceFile', + why: 'a raw parse whose parseDiagnostics nobody reads', + }); + } + return findings; +} + +function main() { + const files = walk(SCRIPTS).sort(); + const findings = []; + let scanned = 0; + for (const abs of files) { + if (abs === resolve(fileURLToPath(import.meta.url))) continue; // this file names the call it bans + scanned += 1; + const rel = relative(REPO_ROOT, abs); + findings.push(...scanFile(rel, readFileSync(abs, 'utf8'), { isParserHome: abs === PARSER_HOME })); + } + + if (findings.length) { + console.error(`x check:parse-guard — ${findings.length} raw TypeScript parse(s) in scripts/:\n`); + for (const f of findings) console.error(` ${f.rel}:${f.line} ${f.what} — ${f.why}`); + console.error( + `\n ts.createSourceFile NEVER THROWS. A syntax error, or the wrong` + + `\n ScriptKind, returns a recovered partial tree with the errors parked` + + `\n on parseDiagnostics — so a scan of that tree finds none of what it` + + `\n is looking for and scores the file CLEAN. The gate prints its green` + + `\n line over a file it could not read.` + + `\n` + + `\n Route the parse through the one module that reads them:` + + `\n` + + `\n import { parseSourceFile } from './ts-parse.mjs'; // '../ts-parse.mjs' from a subdir` + + `\n const sf = ${CANONICAL};` + + `\n` + + `\n Omit scriptKind unless the source has no real file name: TypeScript` + + `\n infers it from the extension, and forcing one is its own blind spot` + + `\n (a gate here really was reading 32 of its 2504 files as wreckage).` + + `\n` + + `\n scripts/ts-parse.mjs carries the rationale and the refusal fixture.`, + ); + return 1; + } + console.log( + `✓ check:parse-guard: ${scanned} scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.`, + ); + return 0; +} + +// --------------------------------------------------------------------------- +// Self-test -- fixture sources, not this tree +// --------------------------------------------------------------------------- + +export function selfTest() { + const cases = []; + const t = (name, ok, detail) => cases.push({ name, ok: Boolean(ok), detail }); + const hits = (src, opts) => scanFile('fixture.mjs', src, opts); + + // -- the call this gate exists to catch, in each spelling ------------------ + t('a plain ts.createSourceFile is a finding', + hits('const sf = ts.createSourceFile(f, text, ts.ScriptTarget.Latest, true);').length === 1); + t('an aliased receiver is a finding too — the receiver is not part of the pattern', + hits('const sf = tsc.createSourceFile(f, text);').length === 1); + t('a destructured createSourceFile is a finding', + hits('import { createSourceFile } from "typescript";\nconst sf = createSourceFile(f, text);').length === 2); + t('two call sites in one file are two findings', + hits('ts.createSourceFile(a, b);\nts.createSourceFile(c, d);').length === 2); + + // -- the finding is openable ---------------------------------------------- + const located = hits('const x = 1;\nconst y = 2;\nconst sf = ts.createSourceFile(f, t);'); + t('the finding carries the line number of the call', + located.length === 1 && located[0].line === 3, JSON.stringify(located)); + + // -- prose and payloads are NOT call sites. Getting this wrong makes the + // gate fabricate findings out of its own documentation. ---------------- + t('a line comment naming the call is not a finding', + hits('// ts.createSourceFile never throws\nconst a = 1;').length === 0); + t('a block comment naming the call is not a finding', + hits('/**\n * ts.createSourceFile never throws.\n */\nconst a = 1;').length === 0); + t('a string payload naming the call is not a finding', + hits('const probe = "ts.createSourceFile(f, t)";').length === 0); + t('a template payload naming the call is not a finding', + hits('const probe = `const sf = ts.createSourceFile(${f}, ${t});`;').length === 0); + + // -- the sanctioned call is silent ---------------------------------------- + t('the canonical parseSourceFile call is not a finding', + hits('import { parseSourceFile } from "./ts-parse.mjs";\nconst sf = parseSourceFile(file, text);').length === 0); + + // -- the parser home is exempt, and ONLY the parser home ------------------ + t('the parser home may call it', + hits('const sf = ts.createSourceFile(f, t);', { isParserHome: true }).length === 0); + t('…and any other file may not', + hits('const sf = ts.createSourceFile(f, t);', { isParserHome: false }).length === 1); + + // -- the masker is load-bearing: prove it, rather than trusting it -------- + t('codeOnly blanks a comment but keeps the line count', + codeOnly('// gone\nconst a = 1;\n').split('\n').length === 3 + && !codeOnly('// gone\nconst a = 1;\n').includes('gone')); + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` x ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + if (failed.length) { + console.error(`x check:parse-guard self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log( + `✓ check:parse-guard self-test: ${cases.length} cases pass (every spelling of the raw call is caught, ` + + `prose and payloads are not, and only ts-parse.mjs is exempt).`, + ); + return 0; +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) process.exit(selfTest()); + process.exit(main()); +} diff --git a/scripts/check-resume-authority-declared.mjs b/scripts/check-resume-authority-declared.mjs index f841d54ab3..138b4192b0 100644 --- a/scripts/check-resume-authority-declared.mjs +++ b/scripts/check-resume-authority-declared.mjs @@ -106,6 +106,7 @@ import { readFileSync, readdirSync } from 'node:fs'; import { join, dirname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const DEFAULT_SCAN_ROOTS = ['packages', 'examples']; @@ -183,7 +184,7 @@ function readDescriptor(objectLiteral) { /** Every `defineActionDescriptor({ ... })` literal in one source text. */ function scanSource(fileName, text) { - const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true); + const sf = parseSourceFile(fileName, text); const found = []; const visit = (node) => { diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index b9bde430a2..78a7e3504a 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -143,6 +143,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -841,7 +842,7 @@ const EXPRESS_RESPONSE_MODULES = { * @returns {{bodies: number, reads: number, unenveloped: number, errorWithoutMessage: number, errorCodeNotString: number, strayKeys: number, stringError: number, siblingCode: number, sites: Record, readSites: string[]}} */ export function scanHonoRouteSource(source, fileName = 'plugin.ts', receivers = HONO_CONTEXT_RECEIVERS) { - const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); + const sf = parseSourceFile(fileName, source); const found = { bodies: 0, // Zero-argument `.json()` calls on the SAME receivers: reads, never writes. @@ -1253,7 +1254,7 @@ function discoverExpressRoutes() { * @returns {{responses: number, ok: number, err: number, privateOk: number, stringError: number, siblingCode: number, sites: string[], stringErrorSites: string[], siblingCodeSites: string[]}} */ export function scanSource(source, fileName = 'module.ts') { - const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); + const sf = parseSourceFile(fileName, source); const found = { responses: 0, ok: 0, err: 0, privateOk: 0, stringError: 0, siblingCode: 0, sites: [], stringErrorSites: [], siblingCodeSites: [], @@ -1363,7 +1364,7 @@ export function scanSource(source, fileName = 'module.ts') { * @returns {{handBuilt: number, sites: string[]}} */ export function scanDomainSource(source, fileName = 'domain.ts') { - const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); + const sf = parseSourceFile(fileName, source); const found = { handBuilt: 0, sites: [] }; const isDispatcherResult = (obj) => diff --git a/scripts/check-startup-registry-verdict.mjs b/scripts/check-startup-registry-verdict.mjs index eaf57b8afa..90920ccab5 100644 --- a/scripts/check-startup-registry-verdict.mjs +++ b/scripts/check-startup-registry-verdict.mjs @@ -149,6 +149,7 @@ import { tmpdir } from 'node:os'; import { join, relative, sep, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const BASELINE_PATH = join(ROOT, 'scripts', 'startup-registry-verdict.baseline.json'); @@ -943,7 +944,7 @@ function run({ list = false, packagesDir } = {}) { for (const file of files) { const text = readFileSync(file, 'utf8'); if (!text.includes('providesServices')) continue; - const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sf = parseSourceFile(file, text, ts.ScriptKind.TS); for (const [service, owners] of buildProviderIndex(collectPluginUnits(sf))) { if (!providers.has(service)) providers.set(service, new Set()); for (const o of owners) providers.get(service).add(o); @@ -957,7 +958,7 @@ function run({ list = false, packagesDir } = {}) { for (const probe of SERVICE_REGISTRY_PROBES.keys()) if (text.includes(probe)) interesting = true; for (const registry of OPEN_CAPABILITY_REGISTRIES.keys()) if (text.includes(registry)) interesting = true; if (!interesting) continue; - const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sf = parseSourceFile(file, text, ts.ScriptKind.TS); analyzeSourceFile(sf, relative(relBase, file).split(sep).join('/'), findings, seams, providers); } @@ -1346,7 +1347,7 @@ function selfTest() { let failures = 0; for (const c of cases) { - const sf = ts.createSourceFile('t.ts', `${PROVIDERS}\n${c.code}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sf = parseSourceFile('t.ts', `${PROVIDERS}\n${c.code}`, ts.ScriptKind.TS); const findings = []; const seams = []; analyzeSourceFile(sf, 't.ts', findings, seams); diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs index 03aafa1b32..9ca7c69bd9 100644 --- a/scripts/check-tenant-chokepoint.mjs +++ b/scripts/check-tenant-chokepoint.mjs @@ -111,6 +111,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -212,7 +213,7 @@ function isScoped(body, name) { * green run over a clean tree cannot exercise at all. */ export function analyzeSource(fileName, text) { - const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true); + const sf = parseSourceFile(fileName, text); const builders = []; const unclassifiable = []; diff --git a/scripts/check-verify-stand-in-erasure.mjs b/scripts/check-verify-stand-in-erasure.mjs index 294442c4ca..5209c6db80 100644 --- a/scripts/check-verify-stand-in-erasure.mjs +++ b/scripts/check-verify-stand-in-erasure.mjs @@ -73,6 +73,7 @@ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; import { join, dirname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; import { VERIFY_STAND_IN_CHECKS } from '../eslint.config.mjs'; @@ -87,13 +88,7 @@ const SOURCE_RE = /\.(ts|tsx|mts|cts)$/; // TSX silently loses the angle-bracket assertion spelling — one of the shapes // this gate has to see. const parse = (file, text) => - ts.createSourceFile( - file, - text, - ts.ScriptTarget.Latest, - true, - file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, - ); + parseSourceFile(file, text); function walkFiles(dir, out = []) { let entries; diff --git a/scripts/check-where-matcher-conformance.mjs b/scripts/check-where-matcher-conformance.mjs index c7993b8d7f..a5a37b42a5 100644 --- a/scripts/check-where-matcher-conformance.mjs +++ b/scripts/check-where-matcher-conformance.mjs @@ -223,6 +223,7 @@ import { join, relative, resolve, dirname } from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -387,7 +388,7 @@ function enclosingParameters(node) { export function discoverInSource(text, label) { const out = []; if (!/Object\.(entries|keys)|\$or|\$and/.test(text)) return out; - const sf = ts.createSourceFile(label, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sf = parseSourceFile(label, text, ts.ScriptKind.TS); const visit = (node) => { if (isFn(node) && node.body) { const params = node.parameters.map((p) => (ts.isIdentifier(p.name) ? p.name.text : null)); diff --git a/scripts/check-wildcard-fallthrough.mjs b/scripts/check-wildcard-fallthrough.mjs index 95428ea746..bec95343ff 100644 --- a/scripts/check-wildcard-fallthrough.mjs +++ b/scripts/check-wildcard-fallthrough.mjs @@ -78,6 +78,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -295,7 +296,7 @@ function resolveHandler(arg, src) { /** Every wildcard mount in one file. */ function scanFile(file) { const text = readFileSync(join(ROOT, file), 'utf8'); - const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const src = parseSourceFile(file, text); const sites = []; const visit = (node) => { @@ -448,7 +449,7 @@ function audit() { function selfTest() { const assert = (cond, msg) => { if (!cond) { console.error('✗ self-test: ' + msg); process.exit(1); } }; - const parse = (code) => ts.createSourceFile('t.ts', code, ts.ScriptTarget.Latest, true); + const parse = (code) => parseSourceFile('t.ts', code); // `isWildcard` — namespace claims vs single paths. assert(isWildcard('/api/v1/auth/*'), 'plain wildcard'); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 7dfad72e23..8b5c98d971 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -841,6 +841,36 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/objectql/src/meta-object-primary-designation-roundtrip.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/objectql/src/meta-object-primary-designation-roundtrip.test.ts", + "verb": "update", + "pinned": 1 + }, + { + "file": "packages/objectql/src/meta-object-search-companion-roundtrip.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/objectql/src/meta-object-search-companion-roundtrip.test.ts", + "verb": "update", + "pinned": 1 + }, + { + "file": "packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/objectql/src/package-disable-enforcement.test.ts", "verb": "delete", diff --git a/scripts/ts-parse.mjs b/scripts/ts-parse.mjs new file mode 100644 index 0000000000..3d997084f5 --- /dev/null +++ b/scripts/ts-parse.mjs @@ -0,0 +1,388 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ts-parse -- the ONE answer to "did this source actually parse?" + * + * node scripts/ts-parse.mjs --self-test + * + * ## The defect this closes + * + * **`ts.createSourceFile` never throws.** Hand it merge-conflict markers, a + * truncated body, or a source read under the wrong `ScriptKind` and it returns + * a `SourceFile` that looks like any other: the errors are parked on + * `parseDiagnostics`, a property NOTHING in `scripts/` read. A gate then walks + * that wreckage, finds none of the shapes it is looking for, and scores the + * file CLEAN -- **a file the gate could not read is reported as a file with + * nothing to report.** The gate prints its green line, its count is lower than + * it should be, and nothing anywhere says which file went unread. + * + * Measured here on 2026-08-21 against TypeScript 6.0.3 -- every one of these + * returns a tree and exits normally: + * + * source ScriptKind.TS ScriptKind.TSX + * ------------------------------- -------------- -------------- + * merge-conflict markers 3 diagnostics 3 diagnostics + * truncated function body 1 diagnostic 1 diagnostic + * a JSX element 4 diagnostics 0 + * `const id = (x: T): T => x;` 0 3 diagnostics + * + * ## This is not hypothetical here -- it was LIVE on `main` when this landed + * + * The last two rows are the same defect wearing the `ScriptKind` hat, and one + * gate in this tree was standing on them. `check-engine-double-contract.mjs` + * walked 2504 `*.{test,spec}.{ts,tsx,mts}` files under `packages/` and + * `examples/` while forcing `ts.ScriptKind.TSX` on every one of them. In TSX a + * `<` opens a JSX element, so an ordinary `new Map()` or a generic + * arrow made the rest of the file wreckage. **32 of its 2504 files parsed with + * parse errors** (up to 633 diagnostics in one file), and the gate reported: + * + * check-engine-double-contract: OK -- 342 pinned, 133 in the DEBT ledger, 2 exempt. + * + * Reading the same 2504 files under the ScriptKind their own file names imply + * turns that line into `6 problem(s)`: three test files were pinning six engine + * doubles that the ledger had never recorded, because the scan had never been + * able to see them. The census moved 236 -> 239 delete doubles and 272 -> 275 + * update doubles at the same time. Nothing about the tree changed; only whether + * the gate could read it. That is the whole failure mode in one measurement, + * and it is why the refusal below is not a defensive nicety. + * + * ## Why ONE module rather than 15 copies of a three-line check + * + * A shared helper is a second source of truth WHILE THE FIRST ONE IS STILL + * REACHABLE. That is the real objection to a helper, and it is answered by + * removing the first source rather than by arguing: `check-parse-guard.mjs` + * next door fails on a raw `ts.createSourceFile` anywhere in `scripts/**` + * outside this file, so there is no second spelling left to drift from. + * + * The tree has already run this experiment twice, and both results are in + * `scripts/`: + * + * • `invoked-as.mjs` -- "was I run, or imported?" -- replaced ELEVEN + * hand-typed spellings across 33 files, NINE of them wrong, and + * `check-entry-guard.mjs` is the half that stops a twelfth being typed. + * • `js-comment-mask.mjs` -- "is this span code, or prose?" -- replaced two + * families of private `stripComments`, each silently wrong in a different + * direction. + * + * A per-gate copy of "and check the diagnostics" would drift the same way: one + * reads `.length` on a field it forgot can be undefined, one warns instead of + * failing, one is simply never typed into the sixteenth gate -- and a missing + * copy is invisible, because its symptom is a green line. + * + * ## Why it EXITS rather than throws + * + * A throw is swallowable, and the swallow is already written down in this repo: + * `packages/lint/src/validate-react-page-props.ts` and + * `lint-startup-registry-verdict.ts` both wrap `createSourceFile` in + * `try { ... } catch { continue / return [] }` -- dead code today, guarding + * against a throw that cannot happen, and a SILENT SKIP the moment a parse + * started throwing. Exiting cannot be caught, so a refusal cannot be downgraded + * into a quieter answer by a caller that meant well. + * + * Exit code 3, deliberately not 1: "this gate found violations" and "this gate + * could not read the tree" are different verdicts and a reader should not have + * to guess which one they got. Both are non-zero, so CI fails either way. + * + * ## The knobs that are NOT knobs + * + * `ScriptTarget.Latest` and `setParentNodes: true` are fixed here because all + * 34 `createSourceFile` lines in `scripts/` passed exactly those -- measured, + * not assumed -- and a call site that needs a different pair should say so once, + * here, rather than re-open a five-argument call for everyone. + * + * `scriptKind` stays a parameter because it is genuinely per-call-site, and + * **omitting it is the safe default**: TypeScript then infers it from the file + * name's extension, which is what a scan over a real tree wants and is exactly + * what the engine-double-contract measurement above is about. Pass it only for + * a source that has no real file name -- a fixture string in a self-test. + * + * ## Counting, for a census that has a numerator + * + * With `OS_TOOLING_PARSE_CENSUS` set, this module prints how many parses ran, + * over how many distinct file names, and how many refused, when the process + * exits. It only ADDS observation. There is deliberately no env var that turns + * the refusal off: a guard with a documented bypass is a guard that will be + * bypassed. + */ + +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import ts from 'typescript'; + +import { isEntrypoint } from './invoked-as.mjs'; + +/** + * The exit status of a refusal. Distinct from 1 ("this gate found violations") + * so a reader can tell "there is nothing to report" from "I could not read it". + */ +export const EXIT_UNPARSEABLE = 3; + +/** Parses attempted, the distinct file names, and the refusals. */ +const census = { parses: 0, files: new Set(), refusals: 0 }; + +/** A snapshot of what this module has been asked to parse in this process. */ +export function parseCensus() { + return { parses: census.parses, files: census.files.size, refusals: census.refusals }; +} + +/** + * The parse errors TypeScript recorded for `sourceFile`, as plain rows. + * + * `parseDiagnostics` is not on the public `SourceFile` type -- it lives on the + * internal shape -- which is most of why it goes unread. It has been populated + * by the parser since the compiler had one, and reading it is the only way to + * learn that a tree is wreckage. The cast is contained HERE, in one function, + * rather than repeated at every call site: that containment is a second reason + * this module exists. + * + * Answers `[]` for anything that is not a source file rather than throwing, so + * a caller cannot turn a bad argument into a crash it then catches. + * + * @param {ts.SourceFile} sourceFile + * @returns {{ line: number, column: number, message: string }[]} + */ +export function describeDiagnostics(sourceFile) { + const raw = /** @type {any} */ (sourceFile)?.parseDiagnostics; + if (!Array.isArray(raw)) return []; + return raw.map((d) => { + const at = typeof d.start === 'number' + ? ts.getLineAndCharacterOfPosition(sourceFile, d.start) + : { line: 0, character: 0 }; + return { + line: at.line + 1, + column: at.character + 1, + message: ts.flattenDiagnosticMessageText(d.messageText, ' '), + }; + }); +} + +/** `ScriptKind.TSX` -> `'TSX'`, and a phrase for the inferred case. */ +function describeScriptKind(scriptKind) { + if (scriptKind === undefined) return 'inferred from the file name'; + for (const [name, value] of Object.entries(ts.ScriptKind)) { + if (value === scriptKind && Number.isNaN(Number(name))) return name; + } + return String(scriptKind); +} + +/** + * The refusal text. Separate from the exit so the self-test can read it, and so + * the wording is pinned by a case rather than by whoever reads it next. + */ +export function refusalReport(fileName, scriptKind, diagnostics) { + const shown = diagnostics.slice(0, 5); + const rest = diagnostics.length - shown.length; + return [ + `x ts-parse — REFUSING to scan a source that does not parse.`, + ``, + ` file ${fileName}`, + ` parsed as ${describeScriptKind(scriptKind)}`, + ` errors ${diagnostics.length} parse diagnostic(s) from TypeScript ${ts.version}`, + ``, + ...shown.map((d) => ` ${fileName}:${d.line}:${d.column} ${d.message}`), + ...(rest > 0 ? [` … and ${rest} more`] : []), + ``, + ` ts.createSourceFile never throws: it returns a tree with the errors`, + ` parked on parseDiagnostics. A scan of that tree finds none of what it`, + ` is looking for and would score this file CLEAN — a file the gate could`, + ` not read, reported as a file with nothing to report. The run is aborted`, + ` instead: a number nobody measured is worse than no number.`, + ``, + ` If this file compiles for tsc, suspect the ScriptKind this call site`, + ` passes. (x) => x is a generic arrow in TS and an unterminated JSX`, + ` tag in TSX; a JSX element is the reverse. Omitting scriptKind lets the`, + ` file name decide, which is what a scan over a real tree wants.`, + ``, + ].join('\n'); +} + +/** + * Parse `text` as TypeScript, or refuse. + * + * The ONLY sanctioned way to build a `ts.SourceFile` under `scripts/**` -- see + * `check-parse-guard.mjs`, which fails on a raw `ts.createSourceFile` anywhere + * else in that tree. + * + * @param {string} fileName What the tree is called. Its extension picks the + * ScriptKind when `scriptKind` is omitted, so pass the real path when you + * have one. + * @param {string} text The source. + * @param {ts.ScriptKind} [scriptKind] Omit to let the file name decide. + * @returns {ts.SourceFile} A tree with NO parse errors. There is no other + * return: an unparseable source ends the process with {@link EXIT_UNPARSEABLE}. + */ +export function parseSourceFile(fileName, text, scriptKind) { + census.parses += 1; + census.files.add(fileName); + + const sourceFile = ts.createSourceFile( + fileName, + text, + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + scriptKind, + ); + + const diagnostics = describeDiagnostics(sourceFile); + if (diagnostics.length > 0) { + census.refusals += 1; + process.stderr.write(refusalReport(fileName, scriptKind, diagnostics)); + process.exit(EXIT_UNPARSEABLE); + } + return sourceFile; +} + +if (process.env.OS_TOOLING_PARSE_CENSUS) { + process.on('exit', () => { + const c = parseCensus(); + process.stderr.write( + `[ts-parse census] ${c.parses} parse(s) over ${c.files} distinct file name(s); ${c.refusals} refusal(s)\n`, + ); + }); +} + +// --------------------------------------------------------------------------- +// Self-test -- real child processes, because the refusal IS a process exit +// --------------------------------------------------------------------------- + +/** + * The refusal cannot be observed in-process: it exits. So the cases that matter + * spawn a real child and read what it printed and what status it left, exactly + * as `invoked-as.mjs` drives a real symlink rather than a model of one. + */ +export function selfTest() { + const cases = []; + const t = (name, ok, detail) => cases.push({ name, ok: Boolean(ok), detail }); + + const SELF = fileURLToPath(import.meta.url); + // The conflict markers are BUILT rather than typed: a literal one in this + // file would be a merge-conflict marker in this file. + const MARKER = '<'.repeat(7); + const MIDDLE = '='.repeat(7); + const CLOSER = '>'.repeat(7); + + const CONFLICTED = `const a = 1;\n${MARKER} HEAD\nconst b = 2;\n${MIDDLE}\nconst b = 3;\n${CLOSER} other\n`; + const TRUNCATED = 'export function f() {\n const x = {\n'; + const JSX = 'const el =
hi
;\n'; + const GENERIC_ARROW = 'const id = (x: T): T => x;\n'; + const CLEAN = 'export const a: number = 1;\n'; + + const dir = mkdtempSync(join(tmpdir(), 'ts-parse-')); + try { + // The probe lives in a temp dir, where a bare `typescript` specifier does + // not resolve -- so the URL is resolved HERE, from this module, and pasted + // in. `body` is spliced into a module that imports THIS one, so the child + // exercises the real export through the real module graph rather than a + // re-implementation of it. + const TS_URL = import.meta.resolve('typescript'); + const run = (body) => { + const probe = join(dir, `probe-${cases.length}-${Math.random().toString(36).slice(2)}.mjs`); + writeFileSync( + probe, + `import ts from ${JSON.stringify(TS_URL)};\n` + + `import { parseSourceFile, parseCensus } from ${JSON.stringify(pathToFileURL(SELF).href)};\n` + + `void ts;\n${body}\n`, + ); + const r = spawnSync(process.execPath, [probe], { encoding: 'utf8' }); + rmSync(probe, { force: true }); + return { status: r.status, out: (r.stdout || '').trim(), err: r.stderr || '' }; + }; + + const parse = (text, fileName = 't.ts', kindExpr = 'undefined') => + run( + `const sf = parseSourceFile(${JSON.stringify(fileName)}, ${JSON.stringify(text)}, ${kindExpr});\n` + + `console.log('PARSED ' + sf.statements.length);\n`, + ); + + // -- a clean source still parses, and the tree is usable ------------------ + const clean = parse(CLEAN); + t('a clean source parses and returns a usable tree', + clean.status === 0 && clean.out === 'PARSED 1', JSON.stringify(clean)); + + // -- THE case: each measured wreck refuses instead of scoring clean ------- + for (const [name, text] of [ + ['merge-conflict markers', CONFLICTED], + ['a truncated body', TRUNCATED], + ['JSX under the TS ScriptKind', JSX], + ]) { + const r = parse(text, 'packages/foo/src/bar.ts'); + t(`${name} REFUSES rather than returning a tree`, + r.status === EXIT_UNPARSEABLE && r.out === '', + JSON.stringify({ status: r.status, out: r.out })); + t(`…and the refusal for ${name} NAMES THE FILE`, + r.err.includes('packages/foo/src/bar.ts'), r.err.slice(0, 200)); + } + + // -- the refusal carries a location a reader can open -------------------- + const located = parse(CONFLICTED, 'packages/foo/src/bar.ts'); + t('the refusal reports line:column and TypeScript’s own message', + /packages\/foo\/src\/bar\.ts:2:1\s+Merge conflict marker encountered\./.test(located.err), + located.err.slice(0, 400)); + + // -- ScriptKind, both directions. This is the shape that hides in a green + // gate rather than in a broken file, and it was LIVE on main. ---------- + t('JSX in a .tsx file parses when the extension decides', + parse(JSX, 'page.tsx').status === 0); + t('…and the SAME source refuses when the call site forces ScriptKind.TS', + parse(JSX, 'page.tsx', 'ts.ScriptKind.TS').status === EXIT_UNPARSEABLE); + t('a generic arrow parses in a .ts file', + parse(GENERIC_ARROW, 'util.ts').status === 0); + t('…and refuses when the call site forces ScriptKind.TSX (the shape a TSX-everything gate went blind on)', + parse(GENERIC_ARROW, 'util.ts', 'ts.ScriptKind.TSX').status === EXIT_UNPARSEABLE); + + // -- the refusal is NOT swallowable, which is why it exits rather than + // throws: `try { parse } catch { continue }` is written in this repo + // today, against a throw that never comes ---------------------------- + const swallowed = run( + `let caught = false;\n` + + `try { parseSourceFile('t.ts', ${JSON.stringify(TRUNCATED)}); } catch { caught = true; }\n` + + `console.log(caught ? 'SWALLOWED' : 'NOT REACHED');\n`, + ); + t('a caller’s try/catch cannot downgrade the refusal into a skip', + swallowed.status === EXIT_UNPARSEABLE && !swallowed.out.includes('SWALLOWED'), + JSON.stringify(swallowed)); + + // -- the census has a numerator ------------------------------------------ + const counted = run( + `parseSourceFile('a.ts', 'const a = 1;');\n` + + `parseSourceFile('a.ts', 'const b = 2;');\n` + + `parseSourceFile('b.ts', 'const c = 3;');\n` + + `console.log(JSON.stringify(parseCensus()));\n`, + ); + t('the census counts parses and distinct file names', + counted.status === 0 && counted.out === '{"parses":3,"files":2,"refusals":0}', + JSON.stringify(counted)); + + // -- the diagnostics reader itself, in-process --------------------------- + t('describeDiagnostics answers [] for a tree that parsed', + describeDiagnostics(parseSourceFile('ok.ts', CLEAN)).length === 0); + t('describeDiagnostics answers [] for a non-SourceFile rather than throwing', + describeDiagnostics(/** @type {any} */ ({})).length === 0); + t('describeDiagnostics answers [] for undefined rather than throwing', + describeDiagnostics(/** @type {any} */ (undefined)).length === 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` x ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + if (failed.length) { + console.error(`x ts-parse self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log( + `✓ ts-parse self-test: ${cases.length} cases pass (every measured wreck refuses and names its file, ` + + `both ScriptKind directions, and a caller’s try/catch cannot swallow it).`, + ); + return 0; +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) process.exit(selfTest()); + console.log('usage: node scripts/ts-parse.mjs --self-test'); +}