diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3872738447..f85158e509 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -172,6 +172,32 @@ jobs: - name: scripts/ entry guards go through one predicate run: pnpm check:entry-guard + # Every `scripts/**` TypeScript parse goes through ONE module (#10133 / + # #10573), and this is the half that keeps it that way (#10574). + # NONE of the three parser entry points throws on a source it cannot + # read: `ts.createSourceFile` returns a tree built by error recovery with + # the errors parked on `parseDiagnostics`; `ts.createProgram` parks them + # behind a second call, `getSyntacticDiagnostics()`; `ts.transpileModule` + # reports nothing at all without `reportDiagnostics: true` and still + # hands back an `outputText`. A gate then walks the wreckage, finds none + # of the shapes it is looking for, and scores the file CLEAN — so the + # SYMPTOM OF A MISSING REFUSAL IS A GREEN LINE, and an unguarded gate is + # indistinguishable from a guarded one by reading CI. That is not a + # theory: one gate here forced `ScriptKind.TSX` on 2504 test files, read + # 32 of them as wreckage, and printed `OK` while six pinned engine + # doubles went uncounted. + # The #10573 sweep converted 32 call sites across 15 gates; it could not + # stop the sixteenth being typed, and within the hour a new gate landed + # with two raw calls in it — caught by this step, which is the whole + # argument for having it. Same shape as `check:entry-guard` above. + # Also prints the parses OUTSIDE `scripts/**` (#10575) that it does not + # govern, so its green line is read as a claim about `scripts/` and not + # about the repository. + # Scans ~121 scripts/ files plus a read-only census of the rest, no + # spawns; ~0.6s. + - name: scripts/ TypeScript parses go through one module + run: pnpm check:parse-guard + # Stack-collection enumerations vs the schema (#6242). `stack.zod.ts` # decides which collections a stack may declare; eight other enumerations # of that same set are hand-maintained (the map-format list, the diff --git a/package.json b/package.json index 912fa9075a..f1cc06d0b7 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "check:app-nav-i18n": "pnpm --filter @objectstack/cli run check:app-nav-i18n", "check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs", "check:entry-guard": "node scripts/check-entry-guard.mjs --self-test && node scripts/check-entry-guard.mjs", + "check:parse-guard": "node scripts/check-parse-guard.mjs --self-test && node scripts/check-parse-guard.mjs", "check:stack-collection-maps": "node scripts/check-stack-collection-maps.mjs --self-test && node scripts/check-stack-collection-maps.mjs", "check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs", "check:doc-anchors": "node scripts/check-doc-anchors.mjs --self-test && node scripts/check-doc-anchors.mjs", diff --git a/scripts/check-optional-error-sink-contract.mjs b/scripts/check-optional-error-sink-contract.mjs index 727439656a..46b2870283 100644 --- a/scripts/check-optional-error-sink-contract.mjs +++ b/scripts/check-optional-error-sink-contract.mjs @@ -164,6 +164,8 @@ import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { parseSourceFile } from './ts-parse.mjs'; + const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, '..'); const BASELINE_PATH = join(HERE, 'optional-error-sink-contract.baseline.json'); @@ -489,7 +491,14 @@ function run({ list = false } = {}) { // this regex is exactly how the first draft of this population read a // clean tree while missing both audit sinks. if (!/\berror\s*\??\s*[:(]/.test(text)) continue; - const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + // `parseSourceFile` rather than the raw call: ts.createSourceFile never + // throws, so a `.ts` file with a syntax error would be walked as a + // recovered partial tree, contribute nothing to the census, and be + // scored as a file with no sinks to report. scriptKind is OMITTED — + // `collectSourceFiles` yields `.ts` only, so the file name infers + // exactly what the forced `ScriptKind.TS` used to say, and forcing + // one is its own blind spot (see scripts/ts-parse.mjs). + const sf = parseSourceFile(file, text); analyzeSourceFile(sf, relative(ROOT, file).split(sep).join('/'), census); } } @@ -656,7 +665,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); const census = emptyCensus(); analyzeSourceFile(sf, 't.ts', census); const verdicts = census.sinks.map((s) => s.verdict); diff --git a/scripts/check-parse-guard.mjs b/scripts/check-parse-guard.mjs index 8162583ce9..f182157bea 100644 --- a/scripts/check-parse-guard.mjs +++ b/scripts/check-parse-guard.mjs @@ -2,7 +2,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * check-parse-guard -- every `scripts/**` TypeScript parse goes through ONE module. + * check-parse-guard -- every `scripts/**` TypeScript parse goes through ONE module, + * and every parse this gate CANNOT reach is counted rather than left unsaid. * * node scripts/check-parse-guard.mjs # scan the tree * node scripts/check-parse-guard.mjs --self-test # verify the checker itself @@ -50,23 +51,41 @@ * 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. + * ## All THREE parser entry points, not just the loudest one + * + * `createSourceFile` is one of three ways into the TypeScript parser, and the + * other two are quieter. `ts.createProgram` parks syntax errors behind a second + * call, `getSyntacticDiagnostics()`; `ts.transpileModule` reports nothing at + * all unless `reportDiagnostics: true` is passed, and still hands back an + * `outputText` that is not JavaScript. All three are banned here and all three + * have a checked counterpart in `ts-parse.mjs`. + * + * They were covered separately at first, on the reasoning that folding them in + * would make one helper carry two promises. What that actually bought was a + * green line narrower than it read: this gate said "every TypeScript parse goes + * through ts-parse.mjs" while two live gates in the same directory parsed + * through neither, and its own header was the only place that said so. A scope + * caveat that lives in a header is not enforced by anything, so the caveat is + * now a bound this file computes. + * + * ## What it still does NOT cover -- and now COUNTS instead of leaving unsaid + * + * Parses outside `scripts/**` are not banned 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. Whether the package-side validators want the same REFUSAL at all + * is a real question and not this gate's to answer: a `scripts/**` gate audits + * a tree its author controls, while a publish-time lint validator is handed + * metadata by someone else and may legitimately want to REPORT an unparseable + * source rather than end the process. + * + * What is this gate's to answer is whether its own verdict tells the truth + * about its reach. So the out-of-tree population is walked, counted and NAMED + * on every run. "120 files covered" reads as a statement about the repository; + * "120 files covered, N parses outside my scope, here they are" is the same + * measurement without the borrowed authority -- and the number moves when + * somebody adds one, which a sentence in a header never does. */ import { readdirSync, readFileSync, statSync } from 'node:fs'; @@ -80,11 +99,51 @@ const HERE = resolve(fileURLToPath(import.meta.url), '..'); const REPO_ROOT = resolve(HERE, '..'); const SCRIPTS = HERE; -/** The one module allowed to call `ts.createSourceFile`. */ +/** The one module allowed to reach the TypeScript parser directly. */ const PARSER_HOME = join(SCRIPTS, 'ts-parse.mjs'); -/** The canonical call, and the only accepted spelling. */ -export const CANONICAL = 'parseSourceFile(fileName, text /*, scriptKind */)'; +/** + * The three parser entry points, each with the checked call that replaces it. + * + * The receiver is deliberately NOT part of any pattern: a gate that renamed its + * `typescript` import, or destructured the function, would otherwise walk + * straight through. Matching the bare name costs a masked mention in prose, + * which is why prose is masked before the scan. + */ +export const ENTRY_POINTS = [ + { + spelling: 'createSourceFile', + what: 'ts.createSourceFile', + why: 'a raw parse whose parseDiagnostics nobody reads', + canonical: 'parseSourceFile(fileName, text /*, scriptKind */)', + }, + { + spelling: 'createProgram', + what: 'ts.createProgram', + why: 'a Program whose getSyntacticDiagnostics() nobody calls', + canonical: 'createProgramChecked(rootNames, options /*, host */)', + }, + { + spelling: 'transpileModule', + what: 'ts.transpileModule', + why: 'a transpile that reports NOTHING without reportDiagnostics: true', + canonical: 'transpileChecked(fileName, text /*, transpileOptions */)', + }, +]; + +/** The canonical `createSourceFile` replacement, kept as its own export. */ +export const CANONICAL = ENTRY_POINTS[0].canonical; + +/** One alternation over every banned spelling. Rebuilt from the table above. */ +const SPELLINGS = new RegExp(`\\b(${ENTRY_POINTS.map((e) => e.spelling).join('|')})\\b`, 'g'); + +/** Build outputs and vendored trees are nobody's source. */ +const SKIP_DIRS = new Set([ + 'node_modules', 'dist', 'build', 'coverage', '.turbo', '.next', '.cache', '.git', 'out', +]); + +/** Anything the repo authors and TypeScript can parse. `.d.ts` is generated. */ +const OUTSIDE_EXT = /\.(?:[cm]?[jt]sx?)$/; function walk(dir, out = []) { for (const name of readdirSync(dir)) { @@ -97,6 +156,27 @@ function walk(dir, out = []) { return out; } +/** + * Everything the repo authors OUTSIDE `scripts/**` -- the population this gate + * reports on but does not govern. + * + * Walked from the repository root rather than from a list of directories on + * purpose: a list would have to be edited when a new top-level tree appears, + * and the failure of an un-edited list is a census that quietly stops counting + * a whole directory. That is the same shape of silence this file exists for. + */ +function walkOutside(dir, out = []) { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name) || name.startsWith('.')) continue; + const p = join(dir, name); + if (p === SCRIPTS) continue; // governed above; not "outside" + const st = statSync(p); + if (st.isDirectory()) walkOutside(p, out); + else if (OUTSIDE_EXT.test(name) && !name.endsWith('.d.ts')) out.push(p); + } + return out; +} + /** Code only: comments, strings, templates and regex literals all blanked. */ export function codeOnly(source) { const { comment, literal } = scanSource(source); @@ -116,26 +196,56 @@ function lineOf(source, index) { */ export function scanFile(rel, source, { isParserHome = false } = {}) { if (isParserHome) return []; + // Cheap prefilter. Masking is not free and the out-of-tree census reads every + // authored file in the repository; masking cannot INTRODUCE a spelling, so a + // source with none is already answered. + if (!SPELLINGS.test(source)) { + SPELLINGS.lastIndex = 0; + return []; + } + SPELLINGS.lastIndex = 0; + 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))) { + while ((m = SPELLINGS.exec(code))) { + const entry = ENTRY_POINTS.find((e) => e.spelling === m[1]); findings.push({ rel, line: lineOf(source, m.index), - what: 'ts.createSourceFile', - why: 'a raw parse whose parseDiagnostics nobody reads', + what: entry.what, + why: entry.why, + canonical: entry.canonical, }); } + SPELLINGS.lastIndex = 0; return findings; } +/** + * The out-of-tree census: the same scanner, a different verdict. + * + * Deliberately the SAME `scanFile` the governed half runs. A second scanner for + * the reported half would drift from the governed one in the direction that + * makes the census read lower than the truth, which is the exact failure this + * file is about. + * + * @param {(abs: string) => string} read Injected so the self-test can drive + * this over fixtures rather than over whatever today's tree happens to hold. + */ +export function censusOutside(files, read, rootFor = (abs) => relative(REPO_ROOT, abs)) { + const rows = []; + for (const abs of files) { + const rel = rootFor(abs); + for (const f of scanFile(rel, read(abs))) { + rows.push({ ...f, isTest: /\.(?:test|spec|pin\.test)\.[cm]?[jt]sx?$/.test(rel) }); + } + } + rows.sort((a, b) => a.rel.localeCompare(b.rel) || a.line - b.line); + return rows; +} + function main() { const files = walk(SCRIPTS).sort(); const findings = []; @@ -150,32 +260,67 @@ function main() { 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}`); + const used = ENTRY_POINTS.filter((e) => findings.some((f) => f.what === e.what)); 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 NONE of the three TypeScript parser entry points THROWS on a source` + + `\n it cannot read. createSourceFile returns a recovered partial tree` + + `\n with the errors parked on parseDiagnostics; createProgram parks` + + `\n them behind a second call, getSyntacticDiagnostics(); transpileModule` + + `\n reports nothing at all without reportDiagnostics: true and still` + + `\n returns an outputText. In every case a scan of the result finds none` + + `\n of what it is looking for and scores the source CLEAN — the gate` + + `\n prints its green line over something it could not read.` + `\n` - + `\n Route the parse through the one module that reads them:` + + `\n Route it 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 import { ${used.map((e) => e.canonical.replace(/\(.*$/, '')).join(', ')} } from './ts-parse.mjs';` + + `\n // '../ts-parse.mjs' from a subdirectory` + + used.map((e) => `\n ${e.canonical}`).join('') + `\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.`, + + `\n scripts/ts-parse.mjs carries the rationale and the refusal fixtures.`, ); return 1; } + console.log( `✓ check:parse-guard: ${scanned} scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.`, ); + reportOutside(censusOutside(walkOutside(REPO_ROOT), (abs) => readFileSync(abs, 'utf8'))); return 0; } +/** + * Print the population this gate reports on but does not govern. + * + * Not a failure, and deliberately not a ratchet: what the package side should + * DO about these is an open shape question (see the header), and a ratchet + * would force an answer by making the next unrelated PR red. What it must not + * be is absent — the green line above is a claim about `scripts/**`, and + * without this block it reads as a claim about the repository. + */ +export function reportOutside(rows) { + const prod = rows.filter((r) => !r.isTest); + const tests = rows.filter((r) => r.isTest); + const files = new Set(rows.map((r) => r.rel)); + console.log( + ` … and ${rows.length} parse(s) OUTSIDE scripts/ in ${files.size} file(s) that this gate does` + + ` NOT govern — ${prod.length} in shipped/gate code, ${tests.length} in tests:`, + ); + for (const r of [...prod, ...tests]) { + console.log(` ${r.rel}:${r.line} ${r.what}${r.isTest ? ' [test]' : ''}`); + } + console.log( + ` They cannot import scripts/ts-parse.mjs — a published package answering` + + `\n "did this parse?" through repo tooling trades this bug for a worse one.` + + `\n Counted and named so the line above is read as what it is: a statement` + + `\n about scripts/, not about the repository. Shape decision: see the header.`, + ); +} + // --------------------------------------------------------------------------- // Self-test -- fixture sources, not this tree // --------------------------------------------------------------------------- @@ -195,6 +340,41 @@ export function selfTest() { t('two call sites in one file are two findings', hits('ts.createSourceFile(a, b);\nts.createSourceFile(c, d);').length === 2); + // -- the OTHER two parser entry points, same gate ------------------------- + t('a ts.createProgram is a finding', + hits('const p = ts.createProgram([entry], OPTIONS);').length === 1); + t('a ts.transpileModule is a finding', + hits('const js = ts.transpileModule(code, opts).outputText;').length === 1); + t('an aliased or destructured createProgram is a finding too', + hits('import { createProgram } from "typescript";\nconst p = createProgram(files, o);').length === 2); + const allThree = hits('ts.createSourceFile(a, b);\nts.createProgram(c, d);\nts.transpileModule(e, f);'); + t('one file reaching all three entry points is three findings, each naming its own API', + allThree.length === 3 + && allThree.map((f) => f.what).join(',') === 'ts.createSourceFile,ts.createProgram,ts.transpileModule', + JSON.stringify(allThree.map((f) => f.what))); + t('each finding carries the checked call that replaces THAT api', + allThree[1].canonical.startsWith('createProgramChecked') + && allThree[2].canonical.startsWith('transpileChecked'), + JSON.stringify(allThree.map((f) => f.canonical))); + + // -- the CHECKED calls must not match the banned ones. The names share a + // prefix, so this is one word boundary away from banning the fix itself + // and turning every converted call site red. --------------------------- + t('createProgramChecked is not a createProgram finding', + hits('import { createProgramChecked } from "./ts-parse.mjs";\n' + + 'const p = createProgramChecked(files, OPTIONS);').length === 0); + t('transpileChecked is not a transpileModule finding', + hits('import { transpileChecked } from "./ts-parse.mjs";\n' + + 'const js = transpileChecked(name, code, opts).outputText;').length === 0); + + // -- the shared /g regex is reused for a prefilter AND a scan; a stale + // lastIndex would make the SECOND file with a call site read clean ------ + const twice = 'const sf = ts.createSourceFile(f, t);'; + t('scanning the same source twice gives the same answer (no lastIndex carry-over)', + hits(twice).length === 1 && hits(twice).length === 1); + t('a file with no spelling at all does not disturb the next file that has one', + hits('const a = 1;').length === 0 && hits(twice).length === 1); + // -- 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', @@ -208,6 +388,9 @@ export function selfTest() { 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('prose naming the other two entry points is not a finding either', + hits('// ts.createProgram parks syntax behind getSyntacticDiagnostics()\n' + + '/* ts.transpileModule reports nothing by default */\nconst a = 1;').length === 0); t('a template payload naming the call is not a finding', hits('const probe = `const sf = ts.createSourceFile(${f}, ${t});`;').length === 0); @@ -220,6 +403,29 @@ export function selfTest() { 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); + t('the parser home is exempt for all three entry points, not just the first', + hits('ts.createSourceFile(a, b);\nts.createProgram(c, d);\nts.transpileModule(e, f);', + { isParserHome: true }).length === 0); + + // -- the out-of-tree census: the SAME scanner, a different verdict --------- + const OUTSIDE = new Map([ + ['packages/lint/src/validate-react-page-props.ts', 'sf = tsc.createSourceFile("page.tsx", src);'], + ['packages/spec/scripts/build-api-surface.ts', 'const program = ts.createProgram(entries, o);'], + ['packages/spec/src/ui/app.test.ts', 'const program = ts.createProgram([entry], {});'], + ['packages/lint/src/clean.ts', '// nothing to see here\nexport const a = 1;'], + ]); + const census = censusOutside([...OUTSIDE.keys()], (k) => OUTSIDE.get(k), (k) => k); + t('the census counts every out-of-tree parse, whatever the api', + census.length === 3, JSON.stringify(census.map((r) => `${r.rel}:${r.what}`))); + t('the census marks a test-file site as a test and a shipped one as not', + census.filter((r) => r.isTest).length === 1 + && census.find((r) => r.isTest).rel === 'packages/spec/src/ui/app.test.ts', + JSON.stringify(census.map((r) => [r.rel, r.isTest]))); + t('a clean out-of-tree file contributes nothing to the census', + !census.some((r) => r.rel.endsWith('clean.ts'))); + t('the census is sorted, so its printed block is diffable run to run', + census.map((r) => r.rel).join('|') + === [...census].sort((a, b) => a.rel.localeCompare(b.rel)).map((r) => r.rel).join('|')); // -- the masker is load-bearing: prove it, rather than trusting it -------- t('codeOnly blanks a comment but keeps the line count', @@ -233,8 +439,9 @@ export function selfTest() { 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).`, + `✓ check:parse-guard self-test: ${cases.length} cases pass (every spelling of all three parser entry ` + + `points is caught, their checked replacements are not, prose and payloads are not, only ts-parse.mjs ` + + `is exempt, and the out-of-tree census counts what this gate does not govern).`, ); return 0; } diff --git a/scripts/check-published-readme-exports.mjs b/scripts/check-published-readme-exports.mjs index 6169591173..5d6edae3d2 100644 --- a/scripts/check-published-readme-exports.mjs +++ b/scripts/check-published-readme-exports.mjs @@ -279,6 +279,7 @@ import { join, posix, resolve } from 'node:path'; import process from 'node:process'; import ts from 'typescript'; import { isEntrypoint } from './invoked-as.mjs'; +import { createProgramChecked } from './ts-parse.mjs'; // Anchored to the script, not to cwd: the verdict must not depend on where the // guard was invoked from. @@ -827,7 +828,13 @@ const TS_OPTIONS = { * each); one shared program parses them once. */ function typeSurface(absEntries) { - const program = ts.createProgram([...absEntries], TS_OPTIONS); + // `createProgramChecked` rather than `ts.createProgram`: a Program parks its + // syntax errors behind a SECOND call, `getSyntacticDiagnostics()`, which this + // gate never made. Every checker answer below — "does this type have that + // member?", "what does this module export?" — would then be an answer about a + // tree the compiler could not read, shaped exactly like an answer about a + // tree it could, and this gate's whole job is to trust those answers. + const program = createProgramChecked([...absEntries], TS_OPTIONS); const checker = program.getTypeChecker(); const cache = new Map(); // Shared by both member questions below, so the "not knowable is never a diff --git a/scripts/check-where-matcher-conformance.mjs b/scripts/check-where-matcher-conformance.mjs index a5a37b42a5..b5ea88b06b 100644 --- a/scripts/check-where-matcher-conformance.mjs +++ b/scripts/check-where-matcher-conformance.mjs @@ -223,7 +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 { parseSourceFile, transpileChecked } from './ts-parse.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -479,7 +479,18 @@ function buildCallable(candidate, dropped = new Set()) { `};`; } const code = `${[...included.values()].join('\n')}\n${definition}\nreturn ${self};`; - const js = ts.transpileModule(code, { + // The lifted source is SYNTHESISED, so it gets a synthetic name that still + // says where it came from — a refusal has to be openable, and pointing at the + // real file's line numbers would point at lines this text does not have. + // + // `transpileChecked` rather than `ts.transpileModule` because the raw call + // reports NOTHING without `reportDiagnostics: true` and still returns an + // `outputText`: a dropped operand comes back as `return row.a === ;`, which + // `new Function` then throws on, and the `catch` in `judge()` below files + // that as UNJUDGED — "could not judge this candidate" standing in for "could + // not read it". Those are different verdicts and the baseline counts them + // differently. + const js = transpileChecked(`${candidate.file}#L${candidate.line}.lifted.ts`, code, { compilerOptions: { target: ts.ScriptTarget.ES2022, isolatedModules: true }, }).outputText; return { fn: new Function(js)(), included }; diff --git a/scripts/ts-parse.mjs b/scripts/ts-parse.mjs index 3d997084f5..3f57f85c1b 100644 --- a/scripts/ts-parse.mjs +++ b/scripts/ts-parse.mjs @@ -70,6 +70,35 @@ * failing, one is simply never typed into the sixteenth gate -- and a missing * copy is invisible, because its symptom is a green line. * + * ## Three parser entry points, ONE question + * + * `createSourceFile` is not the only way into the TypeScript parser, and the + * other two are quieter still. Measured here against TypeScript 6.0.3: + * + * • **`ts.createProgram`** parks syntax errors behind a SECOND call, + * `getSyntacticDiagnostics()`. A Program nobody asks answers every type + * question it is given about a tree it could not read, and the answers look + * exactly like answers about a tree it could. + * • **`ts.transpileModule`** reports NOTHING AT ALL unless + * `reportDiagnostics: true` is passed -- the quietest of the three. Hand it + * a snippet with a dropped operand and it hands back + * `return row.a === ;` as `outputText`: output that is not JavaScript, with + * an empty diagnostic list, because the list was never requested. + * + * Both were live in this tree when this section landed, and both had the SAME + * shape as the `createSourceFile` defect rather than a milder one: + * `check-published-readme-exports.mjs` built a Program and never called + * `getSyntacticDiagnostics`, and `check-where-matcher-conformance.mjs` + * transpiled without `reportDiagnostics`, where the un-runnable `outputText` + * became a `new Function` throw that the caller's own `catch` filed as + * `UNJUDGED` -- a source the gate could not READ, recorded as a source it could + * not JUDGE, which is a different and much quieter claim. + * + * `createProgramChecked` and `transpileChecked` below route those two through + * the same refusal, so "did this parse?" has one answer under `scripts/**` + * whichever entry point a gate reaches for -- and `check-parse-guard.mjs` fails + * on all three raw spellings, so there is no fourth answer to drift into. + * * ## Why it EXITS rather than throws * * A throw is swallowable, and the swallow is already written down in this repo: @@ -123,11 +152,23 @@ import { isEntrypoint } from './invoked-as.mjs'; export const EXIT_UNPARSEABLE = 3; /** Parses attempted, the distinct file names, and the refusals. */ -const census = { parses: 0, files: new Set(), refusals: 0 }; +const census = { parses: 0, programs: 0, transpiles: 0, files: new Set(), refusals: 0 }; -/** A snapshot of what this module has been asked to parse in this process. */ +/** + * A snapshot of what this module has been asked to parse in this process. + * + * `parses` counts every source that reached the parser through ANY of the three + * entry points, so it stays the numerator of "how much of this run was read"; + * `programs` and `transpiles` say which door they came through. + */ export function parseCensus() { - return { parses: census.parses, files: census.files.size, refusals: census.refusals }; + return { + parses: census.parses, + programs: census.programs, + transpiles: census.transpiles, + files: census.files.size, + refusals: census.refusals, + }; } /** @@ -170,6 +211,57 @@ function describeScriptKind(scriptKind) { return String(scriptKind); } +/** + * `ts.Diagnostic[]` -- the shape `getSyntacticDiagnostics()` and + * `transpileModule`'s `diagnostics` hand back -- as the same plain rows + * {@link describeDiagnostics} produces, so all three refusals read identically + * in a log. `file` is carried because a Program's diagnostics span many files + * while one parse's do not. + * + * @param {readonly ts.Diagnostic[]} diagnostics + * @returns {{ line: number, column: number, message: string, file?: string }[]} + */ +export function describeTsDiagnostics(diagnostics) { + if (!Array.isArray(diagnostics)) return []; + return diagnostics.map((d) => { + const at = d.file && typeof d.start === 'number' + ? ts.getLineAndCharacterOfPosition(d.file, d.start) + : { line: 0, character: 0 }; + return { + line: at.line + 1, + column: at.character + 1, + message: ts.flattenDiagnosticMessageText(d.messageText, ' '), + file: d.file?.fileName, + }; + }); +} + +/** + * The first five locations of a refusal, plus an `and N more` line. Shared by + * all three refusals so a reader who has learned to open one has learned to + * open all of them. + */ +function locationLines(fileName, rows) { + const shown = rows.slice(0, 5); + const rest = rows.length - shown.length; + return [ + ...shown.map((d) => ` ${d.file ?? fileName}:${d.line}:${d.column} ${d.message}`), + ...(rest > 0 ? [` … and ${rest} more`] : []), + ]; +} + +/** + * The closing half every refusal shares: why the run ends here rather than + * carrying on over a source nobody could read. + */ +const REFUSAL_WHY = [ + ` A scan of a tree the parser could not read finds none of what it is`, + ` looking for and scores the source CLEAN — a source the gate could not`, + ` read, reported as a source with nothing to report. The run is aborted`, + ` instead: a number nobody measured is worse than no number.`, + ``, +]; + /** * 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. @@ -237,11 +329,142 @@ export function parseSourceFile(fileName, text, scriptKind) { return sourceFile; } +/** + * The refusal text for a Program whose sources do not parse. + * + * Pinned here rather than inlined for the same reason {@link refusalReport} is: + * a self-test case reads it, so the wording answers to a case instead of to + * whoever edits it next. + */ +export function programRefusalReport(rootNames, rows) { + return [ + `x ts-parse — REFUSING to query a Program whose sources do not parse.`, + ``, + ` roots ${rootNames.length} entry point(s), starting at ${rootNames[0] ?? '(none)'}`, + ` errors ${rows.length} syntactic diagnostic(s) from TypeScript ${ts.version}`, + ``, + ...locationLines(rootNames[0] ?? '(unknown)', rows), + ``, + ` ts.createProgram does not throw and does not volunteer this: syntax`, + ` errors sit behind a SECOND call, getSyntacticDiagnostics(), which most`, + ` callers never make. Every checker answer below an unread Program —`, + ` "does this type have that member?", "what does this module export?" —`, + ` is then an answer about a tree the compiler could not read, and it is`, + ` shaped exactly like an answer about a tree it could.`, + ``, + ...REFUSAL_WHY, + ].join('\n'); +} + +/** + * The refusal text for a source that could not be transpiled. + */ +export function transpileRefusalReport(fileName, rows) { + return [ + `x ts-parse — REFUSING to run output transpiled from a source that does not parse.`, + ``, + ` file ${fileName}`, + ` errors ${rows.length} diagnostic(s) from TypeScript ${ts.version}`, + ``, + ...locationLines(fileName, rows), + ``, + ` ts.transpileModule reports NOTHING unless reportDiagnostics: true is`, + ` passed, and it still returns an outputText — text that is not`, + ` JavaScript, handed back as if it were. A caller that runs it gets a`, + ` throw from somewhere else entirely, one \`catch\` away from being filed`, + ` as "could not judge this candidate" rather than "could not read it".`, + ``, + ...REFUSAL_WHY, + ].join('\n'); +} + +/** + * Build a `ts.Program`, or refuse. + * + * The ONLY sanctioned way to build one under `scripts/**` -- see + * `check-parse-guard.mjs`, which fails on a raw `ts.createProgram` anywhere + * else in that tree. + * + * The check is `program.getSyntacticDiagnostics()` over EVERY file the Program + * pulled in, not just the roots. A Program's answers are transitive -- an entry + * point's exported type is read out of whatever file declares it -- so a root + * that parsed while its declaration source did not is exactly the state that + * produces confident answers about an unread tree. Syntactic only: a SEMANTIC + * diagnostic is a fact about the code under test and belongs to the caller's + * own verdict, while a syntactic one means there is no code under test. + * + * @param {readonly string[]} rootNames + * @param {ts.CompilerOptions} options + * @param {ts.CompilerHost} [host] + * @returns {ts.Program} A Program with NO syntax errors. There is no other + * return: unparseable sources end the process with {@link EXIT_UNPARSEABLE}. + */ +export function createProgramChecked(rootNames, options, host) { + const roots = [...rootNames]; + census.programs += 1; + census.parses += roots.length; + for (const r of roots) census.files.add(r); + + const program = host === undefined + ? ts.createProgram(roots, options) + : ts.createProgram(roots, options, host); + + const rows = describeTsDiagnostics(program.getSyntacticDiagnostics()); + if (rows.length > 0) { + census.refusals += 1; + process.stderr.write(programRefusalReport(roots, rows)); + process.exit(EXIT_UNPARSEABLE); + } + return program; +} + +/** + * Transpile `text` to JavaScript, or refuse. + * + * The ONLY sanctioned way to call `ts.transpileModule` under `scripts/**` -- + * see `check-parse-guard.mjs`, which fails on the raw call anywhere else in + * that tree. + * + * `reportDiagnostics` is forced ON and is deliberately not a parameter: the + * default is what makes this API the quietest of the three, and a knob that can + * restore the silence is a knob that will. Every diagnostic this API can + * produce -- syntax, and the `isolatedModules` grammar rules -- means the + * emitted text is not a faithful translation of the input, so all of them + * refuse. + * + * @param {string} fileName What the source is called. Decides the ScriptKind + * exactly as it does for {@link parseSourceFile}; pass the real path when you + * have one, and a `.ts`/`.tsx` name for a synthesised snippet. + * @param {string} text The source. + * @param {ts.TranspileOptions} [transpileOptions] + * @returns {ts.TranspileOutput} Output emitted from a source that parsed. + */ +export function transpileChecked(fileName, text, transpileOptions = {}) { + census.transpiles += 1; + census.parses += 1; + census.files.add(fileName); + + const result = ts.transpileModule(text, { + ...transpileOptions, + fileName, + reportDiagnostics: true, + }); + + const rows = describeTsDiagnostics(result.diagnostics ?? []); + if (rows.length > 0) { + census.refusals += 1; + process.stderr.write(transpileRefusalReport(fileName, rows)); + process.exit(EXIT_UNPARSEABLE); + } + return result; +} + 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`, + `[ts-parse census] ${c.parses} parse(s) over ${c.files} distinct file name(s) ` + + `(${c.programs} program(s), ${c.transpiles} transpile(s)); ${c.refusals} refusal(s)\n`, ); }); } @@ -271,6 +494,10 @@ export function selfTest() { const JSX = 'const el =