diff --git a/.github/workflows/ts-differential.yml b/.github/workflows/ts-differential.yml new file mode 100644 index 0000000000..902b72c336 --- /dev/null +++ b/.github/workflows/ts-differential.yml @@ -0,0 +1,103 @@ +name: TS grammar differential + +# tsc is the ground truth for TypeScript syntax; this checks the vendored +# tree-sitter-typescript/tsx wasm against it over pinned corpora. +# See docs/grammars/ts-differential.md. + +on: + pull_request: + paths: + - 'src/extraction/wasm/tree-sitter-typescript.wasm' + - 'src/extraction/wasm/tree-sitter-tsx.wasm' + - 'src/extraction/wasm-runtime-flags.ts' + - 'codegraph-kernel/grammars/typescript/**' + - 'docs/grammars/tree-sitter-typescript.*' + - 'scripts/ts-differential.mjs' + - 'scripts/ts-differential.corpora.json' + - 'scripts/ts-differential.baseline.json' + - '__tests__/fixtures/ts-syntax/**' + - 'package-lock.json' + - '.github/workflows/ts-differential.yml' + push: + branches: [main] + paths: + - 'src/extraction/wasm/tree-sitter-typescript.wasm' + - 'src/extraction/wasm/tree-sitter-tsx.wasm' + - 'src/extraction/wasm-runtime-flags.ts' + - 'codegraph-kernel/grammars/typescript/**' + - 'docs/grammars/tree-sitter-typescript.*' + - 'scripts/ts-differential.mjs' + - 'scripts/ts-differential.corpora.json' + - 'scripts/ts-differential.baseline.json' + - '__tests__/fixtures/ts-syntax/**' + - 'package-lock.json' + - '.github/workflows/ts-differential.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ts-differential-${{ github.ref }} + cancel-in-progress: true + +jobs: + differential: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + # The corpus cache lives in $RUNNER_TEMP, outside the workspace. actions/cache + # rejects any path containing ".." ("Relative pathing '.' and '..' is not allowed"), + # so the old ${{ github.workspace }}/../ts-diff-cache never saved or restored. The + # runner context is not available in job-level `env`, so it is exported once here + # through $GITHUB_ENV: every later step (the cache restore/save `path:` inputs, the + # script and the shell check) then sees the same absolute path via env.TS_DIFF_CACHE. + - name: Set corpus cache dir + run: echo "TS_DIFF_CACHE=$RUNNER_TEMP/ts-diff-cache" >> "$GITHUB_ENV" + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + # The script loads the wasm straight from src/ and needs no build. + - run: npm ci --ignore-scripts + + # Restore here, save in its own step below with if: always(): a plain + # actions/cache saves only when the job succeeds, so a failing run would + # re-download every corpus on the next push. + - uses: actions/cache/restore@v4 + id: corpus-cache + with: + path: ${{ env.TS_DIFF_CACHE }} + key: ts-diff-corpora-v3-${{ hashFiles('scripts/ts-differential.corpora.json') }} + + - name: Differential parse (tsc vs tree-sitter) + run: node scripts/ts-differential.mjs --json "$RUNNER_TEMP/ts-differential.json" + + - name: Upload full per-file results + if: always() + uses: actions/upload-artifact@v4 + with: + name: ts-differential-json + path: ${{ runner.temp }}/ts-differential.json + if-no-files-found: ignore + retention-days: 14 + + # Save only once at least one corpus finished fetching (the cache dir lives + # outside the workspace, so hashFiles() cannot test it; use the shell). + - name: Check for fetched corpora + id: corpus-fetched + if: always() && steps.corpus-cache.outputs.cache-hit != 'true' + run: | + rm -rf "$TS_DIFF_CACHE"/*.partial-* + if find "$TS_DIFF_CACHE" -maxdepth 2 -name .ts-differential-complete 2>/dev/null | grep -q .; then + echo "present=true" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/cache/save@v4 + if: always() && steps.corpus-cache.outputs.cache-hit != 'true' && steps.corpus-fetched.outputs.present == 'true' + with: + path: ${{ env.TS_DIFF_CACHE }} + key: ts-diff-corpora-v3-${{ hashFiles('scripts/ts-differential.corpora.json') }} diff --git a/__tests__/fixtures/ts-syntax/abstract-construct.ts b/__tests__/fixtures/ts-syntax/abstract-construct.ts new file mode 100644 index 0000000000..d77693bffa --- /dev/null +++ b/__tests__/fixtures/ts-syntax/abstract-construct.ts @@ -0,0 +1,21 @@ +// Abstract construct signatures (TS 4.2) and abstract members +export type AbstractCtor = abstract new (...args: any[]) => T; + +export function Mixin object>(Base: TBase) { + abstract class Mixed extends Base { + abstract name(): string; + } + return Mixed; +} + +export abstract class Shape { + abstract readonly sides: number; + protected abstract area(): number; + abstract get label(): string; +} + +export interface Factory { + new (x: number): object; +} + +export type Ctor = abstract new () => object; diff --git a/__tests__/fixtures/ts-syntax/accessor.ts b/__tests__/fixtures/ts-syntax/accessor.ts new file mode 100644 index 0000000000..823ed7cc48 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/accessor.ts @@ -0,0 +1,8 @@ +// Auto-accessors (TS 4.9) +export class Counter { + accessor count = 0; + private accessor secret: string = "s"; + declare readonly tag: string; + accessor #hidden = 1; + public accessor flag?: boolean; +} diff --git a/__tests__/fixtures/ts-syntax/asserts.ts b/__tests__/fixtures/ts-syntax/asserts.ts new file mode 100644 index 0000000000..eeff330d07 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/asserts.ts @@ -0,0 +1,21 @@ +// Assertion functions and type predicates +export function assertString(x: unknown): asserts x is string { + if (typeof x !== "string") throw new Error("no"); +} + +export function assertTruthy(x: unknown, msg?: string): asserts x { + if (!x) throw new Error(msg); +} + +export function isNum(x: unknown): x is number { + return typeof x === "number"; +} + +export class Guard { + isThis(): this is Guard { + return true; + } + assertThis(): asserts this is Guard {} +} + +export const arrow = (x: unknown): x is string => typeof x === "string"; diff --git a/__tests__/fixtures/ts-syntax/const-type-params.ts b/__tests__/fixtures/ts-syntax/const-type-params.ts new file mode 100644 index 0000000000..fea320b45e --- /dev/null +++ b/__tests__/fixtures/ts-syntax/const-type-params.ts @@ -0,0 +1,17 @@ +// const type parameters (TS 5.0) +export function tuple(...xs: T): T { + return xs; +} + +export class Box { + constructor(public value: T) {} + map(f: (t: T) => U): Box { + return new Box(f(this.value)); + } +} + +export const id = (x: T): T => x; + +export interface Maker { + (s: T): T; +} diff --git a/__tests__/fixtures/ts-syntax/cts-module.cts b/__tests__/fixtures/ts-syntax/cts-module.cts new file mode 100644 index 0000000000..6d103311ac --- /dev/null +++ b/__tests__/fixtures/ts-syntax/cts-module.cts @@ -0,0 +1,4 @@ +// CommonJS-flavoured TypeScript (.cts) +import fs = require("node:fs"); +const x: typeof fs = fs; +export = { x, read: (p: string): string => fs.readFileSync(p, "utf8") }; diff --git a/__tests__/fixtures/ts-syntax/declarations.d.ts b/__tests__/fixtures/ts-syntax/declarations.d.ts new file mode 100644 index 0000000000..8de985ade0 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/declarations.d.ts @@ -0,0 +1,22 @@ +// Ambient declarations (.d.ts) +declare function overloaded(x: string): string; +declare function overloaded(x: number): number; + +declare const VERSION: string; +declare let mutable: number; + +declare class Legacy { + constructor(x: T); + static create(): Legacy; + readonly prop: T; + method?(): void; +} + +declare namespace NS { + interface Opts { a?: number } + function run(o?: Opts): void; +} + +export as namespace MyLib; +export { NS, VERSION }; +export default Legacy; diff --git a/__tests__/fixtures/ts-syntax/decorators.ts b/__tests__/fixtures/ts-syntax/decorators.ts new file mode 100644 index 0000000000..4c93834e6d --- /dev/null +++ b/__tests__/fixtures/ts-syntax/decorators.ts @@ -0,0 +1,28 @@ +// Decorators: class, member and parameter forms +declare function dec(...args: any[]): any; +declare function factory(name: string): (...args: any[]) => any; + +@dec +@factory("svc") +export class Service { + @dec static s = 1; + @dec field = 2; + @factory("x") accessor acc = 3; + + constructor(@dec private readonly dep: string, @factory("opt") public opt?: number) {} + + @dec + method(@dec a: string, @factory("b") b: number): void { + void a, b; + } + + @dec get value(): number { + return 1; + } + + @dec set value(v: number) { + void v; + } +} + +export const Expr = @dec class {}; diff --git a/__tests__/fixtures/ts-syntax/enums-namespaces.ts b/__tests__/fixtures/ts-syntax/enums-namespaces.ts new file mode 100644 index 0000000000..fedb891823 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/enums-namespaces.ts @@ -0,0 +1,25 @@ +// Enums, namespaces, modules, global augmentation +export enum Dir { Up = 1, Down, Left = "L".length, Right = Left << 1 } +export const enum CE { A = "a", B = "b" } +export declare enum DE { X, Y } + +export namespace Outer.Inner { + export const v = 1; + export namespace Deep { + export type T = string; + } +} + +declare module "some-mod" { + export function f(): void; +} + +declare global { + interface Window { + custom: string; + } +} + +export import Alias = Outer.Inner; +import fs = require("fs"); +export = fs; diff --git a/__tests__/fixtures/ts-syntax/gap-await-using-in-for.ts b/__tests__/fixtures/ts-syntax/gap-await-using-in-for.ts new file mode 100644 index 0000000000..321c26fb24 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/gap-await-using-in-for.ts @@ -0,0 +1,3 @@ +// Known gap: `await using` declaration in a for-await-of head (TS 5.2). +declare const asyncHandles: AsyncIterable; +export async function drain() { for await (await using h of asyncHandles) { void h; } } diff --git a/__tests__/fixtures/ts-syntax/gap-export-named-from-attributes.ts b/__tests__/fixtures/ts-syntax/gap-export-named-from-attributes.ts new file mode 100644 index 0000000000..369c65bfed --- /dev/null +++ b/__tests__/fixtures/ts-syntax/gap-export-named-from-attributes.ts @@ -0,0 +1,2 @@ +// Known gap: import attributes on a named re-export. +export { default as data } from "./data.json" with { type: "json" }; diff --git a/__tests__/fixtures/ts-syntax/gap-export-star-from-attributes.ts b/__tests__/fixtures/ts-syntax/gap-export-star-from-attributes.ts new file mode 100644 index 0000000000..ac532d2e7b --- /dev/null +++ b/__tests__/fixtures/ts-syntax/gap-export-star-from-attributes.ts @@ -0,0 +1,2 @@ +// Known gap: import attributes on `export * from`. +export * from "./more.json" with { type: "json" }; diff --git a/__tests__/fixtures/ts-syntax/gap-static-accessor.ts b/__tests__/fixtures/ts-syntax/gap-static-accessor.ts new file mode 100644 index 0000000000..041b989c99 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/gap-static-accessor.ts @@ -0,0 +1,2 @@ +// Known gap: `static accessor` (TS 4.9 auto-accessors). Plain `accessor` parses: see accessor.ts. +export class Config { static accessor shared = 1; } diff --git a/__tests__/fixtures/ts-syntax/gap-using-in-for.ts b/__tests__/fixtures/ts-syntax/gap-using-in-for.ts new file mode 100644 index 0000000000..3ee5f6ecbf --- /dev/null +++ b/__tests__/fixtures/ts-syntax/gap-using-in-for.ts @@ -0,0 +1,3 @@ +// Known gap: `using` declaration in a for-of head (TS 5.2). +declare const handles: Disposable[]; +for (using h of handles) { void h; } diff --git a/__tests__/fixtures/ts-syntax/generic-arrow.tsx b/__tests__/fixtures/ts-syntax/generic-arrow.tsx new file mode 100644 index 0000000000..a1fa4ebcfd --- /dev/null +++ b/__tests__/fixtures/ts-syntax/generic-arrow.tsx @@ -0,0 +1,13 @@ +// TSX: generic arrows need `` or `extends` +import * as React from "react"; + +export const identity = (x: T): T => x; +export const withExtends = (x: T) => x; +export const pair = (a: A, b: B): [A, B] => [a, b]; + +export function List(props: { items: T[]; render: (t: T) => React.ReactNode }) { + return
    {props.items.map((i) =>
  • {props.render(i)}
  • )}
; +} + +export const el = items={[1, 2]} render={(n) => {n}} />; +export const frag = <>text {identity("x")}; diff --git a/__tests__/fixtures/ts-syntax/import-attributes.ts b/__tests__/fixtures/ts-syntax/import-attributes.ts new file mode 100644 index 0000000000..4d1865c02c --- /dev/null +++ b/__tests__/fixtures/ts-syntax/import-attributes.ts @@ -0,0 +1,10 @@ +// Import attributes (TS 5.3) +import data from "./data.json" with { type: "json" }; +import type cfg from "./cfg.json" with { "resolution-mode": "import" }; + +export async function load() { + const m = await import("./lazy.json", { with: { type: "json" } }); + return [data, m]; +} + +export type Cfg = typeof cfg; diff --git a/__tests__/fixtures/ts-syntax/infer-extends.ts b/__tests__/fixtures/ts-syntax/infer-extends.ts new file mode 100644 index 0000000000..2fb7583a09 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/infer-extends.ts @@ -0,0 +1,8 @@ +// `infer X extends Y` (TS 4.7) +export type FirstString = T extends [infer S extends string, ...unknown[]] ? S : never; + +export type ToNum = S extends `${infer N extends number}` ? N : never; + +export type Unwrap = T extends Promise ? U : T; + +export type Both = T extends [infer A extends string, infer B extends number] ? [A, B] : never; diff --git a/__tests__/fixtures/ts-syntax/misc-modern.mts b/__tests__/fixtures/ts-syntax/misc-modern.mts new file mode 100644 index 0000000000..8b5bc5f9f3 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/misc-modern.mts @@ -0,0 +1,28 @@ +// Assorted modern syntax in an .mts module +export type { A, B } from "./types.mjs"; +import { type C, D } from "./c.mjs"; +import type * as NS from "./ns.mjs"; + +export class P { + #priv = 1; + static readonly s: unique symbol = Symbol(); + declare x: number; + override toString(): string { + return `${this.#priv}`; + } + has(o: object): boolean { + return #priv in o; + } +} + +export const a = obj?.b?.[0]?.(1) ?? 2; +let n: number | undefined; +n ||= 1; +n &&= 2; +n ??= 3; +export const big = 10n ** 2n; +export const sep = 1_000_000; +export type Opt = Partial<{ a: 1 }> & { readonly [k: string]: unknown }; +declare const obj: { b?: [(x: number) => number] }; +void C, D; +export type { NS }; diff --git a/__tests__/fixtures/ts-syntax/satisfies.ts b/__tests__/fixtures/ts-syntax/satisfies.ts new file mode 100644 index 0000000000..9449fd2f24 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/satisfies.ts @@ -0,0 +1,13 @@ +// `satisfies` operator (TS 4.9) +type Colors = Record; + +export const palette = { + red: [255, 0, 0], + green: "#00ff00", +} satisfies Colors; + +export const nested = ({ a: 1 } satisfies Record) as const; + +export function f(x: T) { + return (x satisfies object) as T; +} diff --git a/__tests__/fixtures/ts-syntax/static-blocks.ts b/__tests__/fixtures/ts-syntax/static-blocks.ts new file mode 100644 index 0000000000..5a0c6ebec2 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/static-blocks.ts @@ -0,0 +1,18 @@ +// Class static initialization blocks +export class Registry { + static items = new Map(); + static #count = 0; + + static { + Registry.items.set("a", 1); + Registry.#count++; + } + + static { + try { + Registry.items.set("b", 2); + } catch { + // ignore + } + } +} diff --git a/__tests__/fixtures/ts-syntax/template-literal-types.ts b/__tests__/fixtures/ts-syntax/template-literal-types.ts new file mode 100644 index 0000000000..b5a6a150f1 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/template-literal-types.ts @@ -0,0 +1,8 @@ +// Template literal types and key remapping +export type Greeting = `hello ${string}`; +export type EventName = `${T}Changed`; +export type Getters = { [K in keyof T as `get${Capitalize}`]: () => T[K] }; +export type Path = `${number}.${number}` | `#${Uppercase<"a" | "b">}`; +export type Split = S extends `${infer H}.${infer R}` ? [H, ...Split] : [S]; +export type NoNever = { [K in keyof T as T[K] extends never ? never : K]-?: T[K] }; +export type Mutable = { -readonly [K in keyof T]: T[K] }; diff --git a/__tests__/fixtures/ts-syntax/tuples.ts b/__tests__/fixtures/ts-syntax/tuples.ts new file mode 100644 index 0000000000..0c7c009f3f --- /dev/null +++ b/__tests__/fixtures/ts-syntax/tuples.ts @@ -0,0 +1,10 @@ +// Labeled, optional and rest tuple elements (TS 4.0) +export type Range = [start: number, end?: number]; +export type Args = [first: string, ...rest: number[]]; +export type Mid = [a: string, ...mid: boolean[], z: number]; +export type Variadic = [...T, last: string]; +export type RO = readonly [x: number, y?: string]; +export type Opt = [string?, number?]; +export function f(...args: [a: number, b?: string]): void { + void args; +} diff --git a/__tests__/fixtures/ts-syntax/using.ts b/__tests__/fixtures/ts-syntax/using.ts new file mode 100644 index 0000000000..dc7127abe3 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/using.ts @@ -0,0 +1,21 @@ +// Explicit resource management (TS 5.2) +interface Res { + [Symbol.dispose](): void; +} +interface AsyncRes { + [Symbol.asyncDispose](): Promise; +} + +declare function open(): Res; +declare function openAsync(): Promise; + +export function sync(): void { + using a = open(); + using b = open(), c = open(); + void a, b, c; +} + +export async function asyncUse(): Promise { + await using r = await openAsync(); + void r; +} diff --git a/__tests__/fixtures/ts-syntax/variance-out-name.ts b/__tests__/fixtures/ts-syntax/variance-out-name.ts new file mode 100644 index 0000000000..180bb9b346 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/variance-out-name.ts @@ -0,0 +1,5 @@ +// Must keep parsing: `out` / `in`-adjacent names as plain type parameters. +// Both upstream variance PRs (#361, #364) regress these. +export type A = out[]; +export type B = out; +export type C = [out, T]; diff --git a/__tests__/fixtures/ts-syntax/variance-out-name.tsx b/__tests__/fixtures/ts-syntax/variance-out-name.tsx new file mode 100644 index 0000000000..144f49e9d5 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/variance-out-name.tsx @@ -0,0 +1,2 @@ +// Must keep parsing in tsx: a generic arrow whose parameter is named `out`. +export const id = (x: out): out => x; diff --git a/__tests__/fixtures/ts-syntax/variance.ts b/__tests__/fixtures/ts-syntax/variance.ts new file mode 100644 index 0000000000..0978099968 --- /dev/null +++ b/__tests__/fixtures/ts-syntax/variance.ts @@ -0,0 +1,7 @@ +// Known gap: variance annotations on type parameters (TS 4.7), tree-sitter-typescript#370. +export interface Producer { get(): T; } +export interface Consumer { set(v: T): void; } +export interface State { get(): T; set(v: T): void; } +export type Fn = (a: A) => R; +export class Cell { constructor(public value: T) {} } +export abstract class Source { abstract read(): T; } diff --git a/docs/grammars/ts-differential.md b/docs/grammars/ts-differential.md new file mode 100644 index 0000000000..cc3fb866d8 --- /dev/null +++ b/docs/grammars/ts-differential.md @@ -0,0 +1,129 @@ +# TS grammar differential + +The vendored `tree-sitter-typescript.wasm` / `tree-sitter-tsx.wasm` come from an +upstream grammar that is no longer maintained, and we carry patches on top of it. +This job makes `tsc` the ground truth on every grammar change. + +## Scope + +This checks the vendored **wasm** grammars only (`src/extraction/wasm/tree-sitter-typescript.wasm` +and `tree-sitter-tsx.wasm`). The native kernel's TypeScript grammar is not tested here; +wasm/kernel parity is `scripts/kernel-parity.mjs`'s job. + +## What it checks + +`scripts/ts-differential.mjs` parses every `.ts`/`.tsx`/`.mts`/`.cts` file (`.d.ts` +included, `node_modules` excluded) in a set of pinned corpora with both parsers: + +- **oracle**: `ts.createSourceFile(...).parseDiagnostics` (syntax only, no type check); +- **grammar**: `tree.rootNode.hasError` (`.tsx` uses the tsx wasm, everything else the typescript wasm). + +Each file lands in one class: `agree-ok`, `agree-err` (both report an error), +`DIVERGENT` (tsc accepts it, tree-sitter reports an error) or `tsc-only-err` (counted only). + +The baseline (`scripts/ts-differential.baseline.json`) is `divergent: {corpus: {path: firstErrorRow}}` +(an old array-of-paths baseline is still read, with unknown rows, and rewritten by `--update`). + +The run **fails with exit 1** when: + +- a `DIVERGENT` file is not in the baseline (printed as `corpus:path:row`); +- (pinned corpora only) a baselined `DIVERGENT` file's first error row moved **earlier** than the + baselined row: a new error inside an already-divergent file (printed as `corpus:path:row (was R)`). + The pinned code never changes, so the grammar changed error recovery or the pin moved: re-baseline + with `--update` if that is intended. A later row is an improvement (reported, suggests `--update`, + does not fail); an unknown baselined row accepts any row. The `local` corpus is exempt from this + row gate (its rows shift whenever a fixture is edited, and it is strict per file already, below); +- (`local` fixtures only) tsc reports any syntax error in a fixture ("fixture is not valid TypeScript"), + or a fixture that is not a gap fixture (`gap-*`, `variance.*`) is `DIVERGENT`, even if baselined. + +It **fails with exit 2** (setup) on bad args, a fetch failure, a corpus that scans 0 files, +a version mismatch, a blind oracle or grammar (see Canaries), a baselined path that is +absent from the run (`vanished`: renamed, deleted, wrong corpus root), on a full run a baseline +corpus that is no longer in `corpora.json`, a malformed baseline (a row must be a non-negative +integer, or the corpus an old-format array; anything else names the corpus and path), or +`WASM_RUNTIME_FLAGS` drifting from the source (below). Vanished paths are never counted as +`now-agree`; confirm the change and re-baseline. Every exit-2 path also appends +`## TS grammar differential` + `**Setup error:** ` to `$GITHUB_STEP_SUMMARY` +(best-effort), so the job summary is never empty. + +Reported, never failing: `now-agree` counts only baselined paths that are now `agree-ok`; a +baselined path that tsc now rejects too is counted as `now-err`; `improved` is above. +The in-repo fixtures (`__tests__/fixtures/ts-syntax/`, one small file per modern construct) are +baselined the same way. `gap-*.ts` and `variance.ts` record constructs tsc accepts that the +grammar does not parse yet, **one construct per file** so fixing one is visible. A grammar that +fixes one reports it as `now-agree`, which is how a candidate grammar or fork is compared with ours. + +Canaries (fail closed, exit 2): `sf.parseDiagnostics` must be an array, `let x: = ;` must +yield diagnostics and `let x: number = 1;` none under `ScriptKind.TS`, and under `ScriptKind.TSX` +`const a =
;` must yield diagnostics and `const a =
;` none (else the tsc oracle is blind); in both wasm grammars `let x: = ;` must +`hasError` and `let x: number = 1;` must not. + +Corpora (`scripts/ts-differential.corpora.json`, pinned by SHA): Effect, Zod, +Excalidraw, VS Code `src/`, plus the fixtures. + +## Run it + +```bash +npm ci --ignore-scripts # typescript + web-tree-sitter only; no build needed +node scripts/ts-differential.mjs # all corpora +node scripts/ts-differential.mjs --only local # just the fixtures (fast) +node scripts/ts-differential.mjs --json out.json # full per-file results (first error row each) +``` + +The script re-execs itself once under `node --liftoff-only` (`WASM_RUNTIME_FLAGS`, +`src/extraction/wasm-runtime-flags.ts`: V8 Zone OOM compiling big grammar wasm on Node 22+), +so a plain `node scripts/ts-differential.mjs` is fine. `TS_DIFF_RELAUNCHED` guards the loop; if it is +set but `--liftoff-only` is missing from `process.execArgv`, the run exits 2 rather than running unflagged. +The script keeps its own copy of the flag list (the `.ts` source is not importable from `.mjs`); +at startup it extracts the `WASM_RUNTIME_FLAGS` array literal from `src/extraction/wasm-runtime-flags.ts` +with a regex and exits 2 naming both lists on a mismatch or if the literal cannot be parsed. + +Corpora are cloned (depth 1; VS Code blobless + sparse `src/`) into +`$TS_DIFF_CACHE`, default `~/.cache/codegraph-ts-differential/-` (CI: `$RUNNER_TEMP/ts-diff-cache`) (plus `-` +for a sparse corpus), and re-used on later runs only if the completion marker (JSON `{sha, subdir}`) +matches. The first run downloads a few hundred MB. + +Exit codes: `0` ok, `1` gate failure (above), `2` setup failure (above) or the installed +`typescript` / `web-tree-sitter` version differs from the baseline's. + +## Re-baseline + +```bash +node scripts/ts-differential.mjs --update +``` + +Rewrites `scripts/ts-differential.baseline.json` (versions, wasm sha256, sorted +divergent paths per corpus, each with its first error row). It refuses (exit 1) if a local +fixture is not valid TypeScript or a non-gap fixture diverges. Review the diff and commit it +with the change that caused it. +Re-baseline when: + +- a grammar patch lands and the report says files `now-agree` / `improved` (the baseline should shrink); +- the report lists `vanished` paths (a fixture was renamed or removed); +- `typescript` or `web-tree-sitter` is bumped (exit 2 asks for this): check that new + divergences are real grammar gaps and not an oracle change; +- a corpus pin changes. + +Never re-baseline to silence a new divergence on a file that tsc accepts: fix the +grammar, or record the gap deliberately in the commit message. + +## CI + +`.github/workflows/ts-differential.yml` runs on PRs and pushes to `main` that touch the +two wasm files, `src/extraction/wasm-runtime-flags.ts`, `codegraph-kernel/grammars/typescript/**`, `docs/grammars/tree-sitter-typescript.*` +(the last two arrive with the #3226 mirror), the script, corpora, baseline or fixtures, or +`package-lock.json`. The job summary lists per-corpus counts and the first 20 new divergences +as `corpus:path:line`. The `--json` results are uploaded as the `ts-differential-json` artifact +(14 days, even when the job fails). The corpus cache is restored up front and saved with +`if: always()`, so a failing run still keeps the downloads. It lives in `$RUNNER_TEMP/ts-diff-cache` +(exported through `$GITHUB_ENV` by the first step: `actions/cache` rejects a `path` containing `..`, +and the `runner` context is not available in job-level `env`); the key is +`ts-diff-corpora-v3-`. + +## Adding a fixture + +Add one small file to `__tests__/fixtures/ts-syntax/` for **one** construct tsc accepts, and +run `node scripts/ts-differential.mjs --only local`. If the current grammar parses it, +you're done. If it doesn't, name the file `gap-.ts` and re-baseline +(`--update`), so the gap is recorded and its fix shows up as `now-agree`. A file that is not +valid TypeScript, or a non-gap name that diverges, fails the run and blocks `--update`. diff --git a/scripts/ts-differential.baseline.json b/scripts/ts-differential.baseline.json new file mode 100644 index 0000000000..d6cb9a2d8b --- /dev/null +++ b/scripts/ts-differential.baseline.json @@ -0,0 +1,286 @@ +{ + "tsc": "5.9.3", + "webTreeSitter": "0.25.10", + "generatedAt": "2026-11-06T00:03:17.738Z", + "wasmSha256": { + "typescript": "3a44d634c9840dccec36f33b99592bd086a2f940e9cd80c64347c45b7dce662f", + "tsx": "8f647a1b2cafe9ab00fb2056d79021d2a144ba17a72f45511072311c1b05d08e" + }, + "divergent": { + "effect": { + "ai-docs/src/01_effect/03_services/20_layer-composition.ts": 45, + "packages/ai/anthropic/src/AnthropicConfig.ts": 79, + "packages/ai/anthropic/src/AnthropicLanguageModel.ts": 865, + "packages/ai/anthropic/src/Generated.ts": 1, + "packages/ai/openai-compat/src/OpenAiConfig.ts": 82, + "packages/ai/openai-compat/src/OpenAiEmbeddingModel.ts": 192, + "packages/ai/openai-compat/src/OpenAiLanguageModel.ts": 745, + "packages/ai/openai/src/Generated.ts": 1, + "packages/ai/openai/src/OpenAiConfig.ts": 88, + "packages/ai/openai/src/OpenAiEmbeddingModel.ts": 206, + "packages/ai/openai/src/OpenAiLanguageModel.ts": 823, + "packages/ai/openrouter/src/OpenRouterConfig.ts": 64, + "packages/ai/openrouter/src/OpenRouterLanguageModel.ts": 679, + "packages/ai/typesafe/src/TypeSafeConfig.ts": 63, + "packages/atom/react/src/Hooks.ts": 188, + "packages/atom/solid/src/Hooks.ts": 65, + "packages/effect/src/Arbitrary.ts": 53, + "packages/effect/src/Array.ts": 216, + "packages/effect/src/Boolean.ts": 97, + "packages/effect/src/Brand.ts": 37, + "packages/effect/src/Cache.ts": 113, + "packages/effect/src/Cause.ts": 79, + "packages/effect/src/Channel.ts": 1, + "packages/effect/src/ChannelSchema.ts": 200, + "packages/effect/src/Chunk.ts": 54, + "packages/effect/src/Config.ts": 113, + "packages/effect/src/Context.ts": 68, + "packages/effect/src/Data.ts": 415, + "packages/effect/src/DateTime.ts": 1, + "packages/effect/src/Deferred.ts": 60, + "packages/effect/src/Differ.ts": 29, + "packages/effect/src/Duration.ts": 1148, + "packages/effect/src/Effect.ts": 122, + "packages/effect/src/Equivalence.ts": 62, + "packages/effect/src/Exit.ts": 89, + "packages/effect/src/Fiber.ts": 73, + "packages/effect/src/FiberHandle.ts": 59, + "packages/effect/src/FiberMap.ts": 62, + "packages/effect/src/FiberSet.ts": 58, + "packages/effect/src/Filter.ts": 46, + "packages/effect/src/Formatter.ts": 43, + "packages/effect/src/Function.ts": 1, + "packages/effect/src/Graph.ts": 85, + "packages/effect/src/HashMap.ts": 52, + "packages/effect/src/HashRing.ts": 138, + "packages/effect/src/HashSet.ts": 53, + "packages/effect/src/Iterable.ts": 129, + "packages/effect/src/Latch.ts": 372, + "packages/effect/src/Layer.ts": 56, + "packages/effect/src/LayerMap.ts": 80, + "packages/effect/src/LayerRef.ts": 45, + "packages/effect/src/Logger.ts": 66, + "packages/effect/src/ManagedRuntime.ts": 120, + "packages/effect/src/Match.ts": 154, + "packages/effect/src/Metric.ts": 113, + "packages/effect/src/MutableHashMap.ts": 65, + "packages/effect/src/MutableHashSet.ts": 67, + "packages/effect/src/MutableList.ts": 37, + "packages/effect/src/MutableRef.ts": 72, + "packages/effect/src/Newtype.ts": 62, + "packages/effect/src/NonEmptyIterable.ts": 53, + "packages/effect/src/Optic.ts": 67, + "packages/effect/src/Option.ts": 78, + "packages/effect/src/Order.ts": 55, + "packages/effect/src/Ordering.ts": 134, + "packages/effect/src/PartitionedSemaphore.ts": 69, + "packages/effect/src/Pool.ts": 60, + "packages/effect/src/Predicate.ts": 48, + "packages/effect/src/PubSub.ts": 68, + "packages/effect/src/Pull.ts": 42, + "packages/effect/src/Queue.ts": 175, + "packages/effect/src/Random.ts": 317, + "packages/effect/src/RcMap.ts": 77, + "packages/effect/src/RcRef.ts": 69, + "packages/effect/src/Record.ts": 49, + "packages/effect/src/Redacted.ts": 58, + "packages/effect/src/Ref.ts": 61, + "packages/effect/src/Request.ts": 53, + "packages/effect/src/RequestResolver.ts": 1, + "packages/effect/src/Resource.ts": 43, + "packages/effect/src/Result.ts": 99, + "packages/effect/src/Runtime.ts": 198, + "packages/effect/src/Schedule.ts": 55, + "packages/effect/src/Schema.ts": 1, + "packages/effect/src/SchemaAST.ts": 4093, + "packages/effect/src/SchemaGetter.ts": 50, + "packages/effect/src/SchemaIssue.ts": 1125, + "packages/effect/src/SchemaTransformation.ts": 78, + "packages/effect/src/Scope.ts": 323, + "packages/effect/src/ScopedCache.ts": 61, + "packages/effect/src/ScopedRef.ts": 40, + "packages/effect/src/Semaphore.ts": 476, + "packages/effect/src/Sink.ts": 66, + "packages/effect/src/Stream.ts": 1, + "packages/effect/src/String.ts": 162, + "packages/effect/src/Struct.ts": 149, + "packages/effect/src/SubscriptionRef.ts": 39, + "packages/effect/src/SynchronizedRef.ts": 40, + "packages/effect/src/Trie.ts": 62, + "packages/effect/src/Tuple.ts": 79, + "packages/effect/src/TxChunk.ts": 74, + "packages/effect/src/TxDeferred.ts": 65, + "packages/effect/src/TxHashMap.ts": 87, + "packages/effect/src/TxHashSet.ts": 91, + "packages/effect/src/TxPriorityQueue.ts": 61, + "packages/effect/src/TxPubSub.ts": 55, + "packages/effect/src/TxQueue.ts": 82, + "packages/effect/src/TxReentrantLock.ts": 487, + "packages/effect/src/TxRef.ts": 64, + "packages/effect/src/TxSemaphore.ts": 575, + "packages/effect/src/TxSubscriptionRef.ts": 69, + "packages/effect/src/UndefinedOr.ts": 34, + "packages/effect/src/Unify.ts": 291, + "packages/effect/src/Utils.ts": 122, + "packages/effect/src/ai/Chat.ts": 234, + "packages/effect/src/ai/LanguageModel.ts": 1, + "packages/effect/src/ai/McpClient.ts": 206, + "packages/effect/src/ai/McpProtocol.ts": 158, + "packages/effect/src/ai/McpServer.ts": 522, + "packages/effect/src/ai/Model.ts": 36, + "packages/effect/src/ai/Tool.ts": 212, + "packages/effect/src/ai/Toolkit.ts": 69, + "packages/effect/src/ai/internal/mcpCore.ts": 51, + "packages/effect/src/ai/internal/mcpProtocol.ts": 521, + "packages/effect/src/cli/Argument.ts": 351, + "packages/effect/src/cli/Command.ts": 124, + "packages/effect/src/cli/Flag.ts": 482, + "packages/effect/src/cli/Param.ts": 44, + "packages/effect/src/cli/Primitive.ts": 74, + "packages/effect/src/cli/Prompt.ts": 939, + "packages/effect/src/cluster/Entity.ts": 75, + "packages/effect/src/cluster/EntityResource.ts": 55, + "packages/effect/src/cluster/Envelope.ts": 114, + "packages/effect/src/crypto/Aead.ts": 192, + "packages/effect/src/crypto/Cipher.ts": 119, + "packages/effect/src/crypto/KeyAgreement.ts": 143, + "packages/effect/src/crypto/KeyWrap.ts": 176, + "packages/effect/src/crypto/Mac.ts": 101, + "packages/effect/src/crypto/SecureRandom.ts": 38, + "packages/effect/src/crypto/Signature.ts": 284, + "packages/effect/src/encoding/Csv.ts": 1, + "packages/effect/src/encoding/Ndjson.ts": 517, + "packages/effect/src/encoding/SchemaBinary.ts": 858, + "packages/effect/src/eventlog/Event.ts": 59, + "packages/effect/src/eventlog/EventGroup.ts": 60, + "packages/effect/src/eventlog/EventRow.ts": 300, + "packages/effect/src/eventlog/SqlEventLogServerEncrypted.ts": 273, + "packages/effect/src/eventlog/SqlEventLogServerUnencrypted.ts": 164, + "packages/effect/src/graphql/GraphQL.ts": 127, + "packages/effect/src/graphql/GraphQLClient.ts": 95, + "packages/effect/src/graphql/GraphQLGroup.ts": 23, + "packages/effect/src/graphql/GraphQLResolver.ts": 133, + "packages/effect/src/http-api/HttpApi.ts": 58, + "packages/effect/src/http-api/HttpApiEndpoint.ts": 275, + "packages/effect/src/http-api/HttpApiGroup.ts": 56, + "packages/effect/src/http-api/HttpApiSchema.ts": 563, + "packages/effect/src/http-api/HttpApiSecurity.ts": 43, + "packages/effect/src/http-api/OpenApi.ts": 1241, + "packages/effect/src/http/HttpClient.ts": 311, + "packages/effect/src/http/HttpClientResponse.ts": 241, + "packages/effect/src/http/HttpEffect.ts": 297, + "packages/effect/src/http/HttpRouter.ts": 834, + "packages/effect/src/http/HttpServer.ts": 48, + "packages/effect/src/internal/arbitrary/model.ts": 31, + "packages/effect/src/internal/core.ts": 309, + "packages/effect/src/internal/dateTime.ts": 1, + "packages/effect/src/internal/doNotation.ts": 8, + "packages/effect/src/internal/effect.ts": 1, + "packages/effect/src/internal/executionPlan.ts": 98, + "packages/effect/src/internal/graph.ts": 18, + "packages/effect/src/internal/hashMap.ts": 26, + "packages/effect/src/internal/hashSet.ts": 22, + "packages/effect/src/internal/layer.ts": 40, + "packages/effect/src/internal/matcher.ts": 242, + "packages/effect/src/internal/request.ts": 20, + "packages/effect/src/internal/result.ts": 110, + "packages/effect/src/internal/schedule.ts": 19, + "packages/effect/src/internal/trie.ts": 22, + "packages/effect/src/net/IpInterface.ts": 26, + "packages/effect/src/net/IpNetwork.ts": 28, + "packages/effect/src/net/NetAddress.ts": 619, + "packages/effect/src/persistence/CircuitBreaker.ts": 389, + "packages/effect/src/persistence/KeyValueStore.ts": 830, + "packages/effect/src/persistence/PersistedCache.ts": 31, + "packages/effect/src/persistence/PersistedQueue.ts": 76, + "packages/effect/src/persistence/Persistence.ts": 611, + "packages/effect/src/reactivity/AsyncResult.ts": 1, + "packages/effect/src/reactivity/Atom.ts": 1, + "packages/effect/src/reactivity/AtomRegistry.ts": 219, + "packages/effect/src/reactivity/Reactivity.ts": 324, + "packages/effect/src/rpc/Rpc.ts": 78, + "packages/effect/src/rpc/RpcClient.ts": 928, + "packages/effect/src/rpc/RpcGroup.ts": 37, + "packages/effect/src/schema/VariantSchema.ts": 41, + "packages/effect/src/socket/Socket.ts": 681, + "packages/effect/src/sql/Migrator.ts": 201, + "packages/effect/src/sql/SqlResolver.ts": 80, + "packages/effect/src/workers/Transferable.ts": 142, + "packages/effect/src/workflow/Activity.ts": 230, + "packages/effect/src/workflow/DurableDeferred.ts": 1, + "packages/effect/src/workflow/Workflow.ts": 196, + "packages/effect/test/ai/utils.ts": 22, + "packages/effect/test/encoding/Json5.test.ts": 102, + "packages/effect/typetest/schema/Schema.tst.ts": 1, + "packages/opentelemetry/src/NodeSdk.ts": 114, + "packages/opentelemetry/src/OtelTracer.ts": 382, + "packages/opentelemetry/src/WebSdk.ts": 108, + "packages/platform/browser/src/BrowserRuntime.ts": 44, + "packages/platform/browser/src/IndexedDbDatabase.ts": 154, + "packages/platform/browser/src/IndexedDbTable.ts": 32, + "packages/platform/browser/src/IndexedDbVersion.ts": 36, + "packages/platform/bun/src/BunRuntime.ts": 46, + "packages/platform/deno/src/DenoRuntime.ts": 25, + "packages/platform/node-shared/src/NodeRuntime.ts": 30, + "packages/platform/node-shared/src/NodeStream.ts": 192, + "packages/platform/node/src/NodeRuntime.ts": 47, + "packages/platform/node/test/cluster/SqlMessageStorage.integration.test.ts": 190, + "packages/sql/clickhouse/src/ClickhouseClient.ts": 143, + "packages/sql/d1/test/Client.test.ts": 78, + "packages/sql/libsql/test/Client.integration.test.ts": 303, + "packages/sql/mssql/src/Parameter.ts": 44, + "packages/sql/mssql/test/Binary.test.ts": 98, + "packages/sql/mssql/test/Cancellation.integration.test.ts": 26, + "packages/sql/mssql/test/Client.integration.test.ts": 21, + "packages/sql/mssql/test/Persistence.integration.test.ts": 53, + "packages/sql/mysql2/test/Cancellation.integration.test.ts": 18, + "packages/sql/mysql2/test/Client.integration.test.ts": 46, + "packages/sql/mysql2/test/Persistence.integration.test.ts": 46, + "packages/sql/pg/src/PgProtocol.ts": 1387, + "packages/sql/pg/test/Client.integration.test.ts": 288, + "packages/sql/pg/test/Persistence.integration.test.ts": 111, + "packages/sql/pg/test/PgMigrator.integration.test.ts": 17, + "packages/sql/pg/test/SqlEventJournal.integration.test.ts": 29, + "packages/sql/pglite/test/Client.test.ts": 25, + "packages/sql/pglite/test/Json.test.ts": 13, + "packages/sql/pglite/test/Migrator.test.ts": 36, + "packages/sql/pglite/test/PersistedQueue.test.ts": 22, + "packages/sql/sqlite-node/test/Persistence.test.ts": 210, + "packages/sql/sqlite-node/test/SqlEventJournal.test.ts": 353 + }, + "zod": { + "packages/zod/src/v4/classic/schemas.ts": 35, + "packages/zod/src/v4/core/checks.ts": 28, + "packages/zod/src/v4/core/schemas.ts": 181, + "packages/zod/src/v4/mini/schemas.ts": 10 + }, + "excalidraw": { + "packages/element/tests/dragElements.test.ts": 16, + "packages/excalidraw/tests/clipboard.test.tsx": 39, + "packages/excalidraw/tests/onDuplicate.test.tsx": 46, + "packages/excalidraw/tests/wheel.test.tsx": 37, + "setupTests.ts": 36 + }, + "vscode": { + "src/vs/code/electron-browser/workbench/workbench.ts": 16, + "src/vs/platform/agentHost/node/agentHostDatabase.ts": 2419, + "src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts": 159, + "src/vs/platform/agentHost/test/node/codex/codexChatDiscovery.test.ts": 873, + "src/vs/platform/browserView/electron-browser/preload-browserView.ts": 321, + "src/vs/platform/tunnel/test/node/tunnelProxy.test.ts": 464, + "src/vs/sessions/electron-browser/sessions.ts": 14, + "src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts": 558, + "src/vs/workbench/contrib/issue/browser/issueFormService.ts": 258, + "src/vs/workbench/contrib/terminal/test/browser/terminalProfileService.integrationTest.ts": 93, + "src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoice.ts": 38 + }, + "local": { + "__tests__/fixtures/ts-syntax/gap-await-using-in-for.ts": 3, + "__tests__/fixtures/ts-syntax/gap-export-named-from-attributes.ts": 2, + "__tests__/fixtures/ts-syntax/gap-export-star-from-attributes.ts": 2, + "__tests__/fixtures/ts-syntax/gap-static-accessor.ts": 2, + "__tests__/fixtures/ts-syntax/gap-using-in-for.ts": 3, + "__tests__/fixtures/ts-syntax/variance.ts": 2 + } + } +} diff --git a/scripts/ts-differential.corpora.json b/scripts/ts-differential.corpora.json new file mode 100644 index 0000000000..3897977a18 --- /dev/null +++ b/scripts/ts-differential.corpora.json @@ -0,0 +1,7 @@ +[ + { "name": "effect", "repo": "Effect-TS/effect", "sha": "7a87ff9b88b1a570995b1b5570559c1b4dd5367b", "subdir": "" }, + { "name": "zod", "repo": "colinhacks/zod", "sha": "2554b70c3d4a80273381e61d527d820720626440", "subdir": "" }, + { "name": "excalidraw", "repo": "excalidraw/excalidraw", "sha": "dd2f8fc32b92cbf56f4abee6a0cf3b14681b4b55", "subdir": "" }, + { "name": "vscode", "repo": "microsoft/vscode", "sha": "ca43fc94a6cc0a20cb4deefc90e63693d15063e8", "subdir": "src" }, + { "name": "local", "local": "__tests__/fixtures/ts-syntax" } +] diff --git a/scripts/ts-differential.mjs b/scripts/ts-differential.mjs new file mode 100644 index 0000000000..87f1774430 --- /dev/null +++ b/scripts/ts-differential.mjs @@ -0,0 +1,635 @@ +#!/usr/bin/env node +/** + * TypeScript grammar differential: tsc is the ground truth for "is this file + * syntactically valid TypeScript"; the vendored tree-sitter-typescript/tsx + * wasm grammars are checked against it over pinned real-world corpora. + * Scope: the wasm grammars only. The native kernel grammar is not tested here; + * wasm/kernel parity is scripts/kernel-parity.mjs's job. + * + * Per file: + * oracle ts.createSourceFile(...).parseDiagnostics (syntax only, no type check) + * grammar tree.rootNode.hasError (.tsx -> tsx wasm, else typescript wasm) + * class agree-ok | agree-err | DIVERGENT (tsc ok, tree-sitter err) | tsc-only-err + * + * Baseline (scripts/ts-differential.baseline.json): divergent: {corpus: {path: firstErrorRow}}. + * An old-format array of paths is still read (row unknown) and rewritten by --update. + * + * Gate (exit 1): + * - a DIVERGENT file that is not in the baseline ("new"); + * - PINNED corpora only: a baselined DIVERGENT file whose first error row moved + * EARLIER than the baselined row: a new error inside an already-divergent file + * ("corpus:path:row (was R)"). A later row is an improvement (reported, not a failure); + * an unknown baselined row accepts any row. The pinned code never changes, so a moved + * row means the grammar changed error recovery or the pin moved: re-baseline with --update. + * The `local` corpus is exempt (its rows shift whenever a fixture is edited; it is + * already strict per file, see below); + * - local corpus only: any tsc error in a fixture ("fixture is not valid TypeScript"), + * and a DIVERGENT fixture that is not a gap fixture (gap-*, variance.*), even if baselined. + * Setup failure (exit 2; each also appends "**Setup error:** " to + * GITHUB_STEP_SUMMARY): bad args, fetch failure, a corpus that scans 0 files, a + * baselined path that is absent from this run ("vanished": renamed/deleted/wrong + * root; never counted as now-agree), on a full run a baseline corpus that is not in + * corpora.json (vanished corpus), a malformed baseline (a row must be a non-negative + * integer, or the corpus an old-format array), version mismatch, a blind oracle + * (ScriptKind.TS or TSX) or grammar, WASM_RUNTIME_FLAGS drifting from + * src/extraction/wasm-runtime-flags.ts, or the relaunch guard set without --liftoff-only. + * now-agree counts only baselined paths that are now agree-ok; a baselined path that + * is now agree-err / tsc-only-err is counted separately (now-err). All are reported + * so the baseline can be re-generated; none but "vanished" fails the run. + * + * Usage: + * node scripts/ts-differential.mjs [--update] [--only ] [--json ] + * + * --update rewrite the baseline from this run (all corpora; not with --only). + * Refuses (exit 1) if a local fixture is not valid TypeScript or a + * non-gap fixture diverges. + * --only run one corpus (compared against its baseline entry only) + * --json write full per-file results + * + * The script re-execs itself once under `node --liftoff-only` (WASM_RUNTIME_FLAGS in + * src/extraction/wasm-runtime-flags.ts: V8 Zone OOM on big grammar wasm); the env var + * TS_DIFF_RELAUNCHED guards against loops. If it is set but the flag is missing from + * process.execArgv the run exits 2 instead of running unflagged. This script's copy of + * the flag list is checked against the .ts source at startup (regex on the array literal); + * a mismatch or an unparseable source is a setup error. + * + * Env: TS_DIFF_CACHE corpus clone cache (default ~/.cache/codegraph-ts-differential; + * CI: $RUNNER_TEMP/ts-diff-cache) + * GITHUB_STEP_SUMMARY when set, a markdown summary is appended to it + * + * Exit: 0 = ok, 1 = gate failure (above), 2 = setup failure (above). + * Needs no build: wasm is loaded straight from src/extraction/wasm/. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { createRequire } from 'node:module'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const SELF = fileURLToPath(import.meta.url); +const ROOT = path.resolve(path.dirname(SELF), '..'); +const require = createRequire(import.meta.url); +const CORPORA_PATH = path.join(ROOT, 'scripts', 'ts-differential.corpora.json'); +const BASELINE_PATH = path.join(ROOT, 'scripts', 'ts-differential.baseline.json'); +const WASM = { + typescript: path.join(ROOT, 'src', 'extraction', 'wasm', 'tree-sitter-typescript.wasm'), + tsx: path.join(ROOT, 'src', 'extraction', 'wasm', 'tree-sitter-tsx.wasm'), +}; +const EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']); +const MARKER = '.ts-differential-complete'; +// Mirrors WASM_RUNTIME_FLAGS in src/extraction/wasm-runtime-flags.ts (a .ts file; not +// importable here). checkFlagsMirror() fails the run if the two drift apart. +const WASM_RUNTIME_FLAGS = ['--liftoff-only']; +const FLAGS_SOURCE = path.join(ROOT, 'src', 'extraction', 'wasm-runtime-flags.ts'); +const RELAUNCH_GUARD_ENV = 'TS_DIFF_RELAUNCHED'; +const CANARY_BAD = 'let x: = ;'; +const CANARY_GOOD = 'let x: number = 1;'; +// JSX is only valid under ScriptKind.TSX (verified with tsc: bad -> diagnostics, good -> none). +const CANARY_TSX_BAD = 'const a =
;'; +const CANARY_TSX_GOOD = 'const a =
;'; + +class SetupError extends Error {} + +/** A fixture that is allowed to diverge: a recorded grammar gap. */ +const isGapFixture = (p) => /^(gap-|variance\.)/.test(path.basename(p)); + +/** + * V8 flags are read at engine init, so --liftoff-only must be on node's command + * line. Re-spawn once with it when this process lacks it. Returns an exit code, or + * null when no relaunch was needed (or the guard says we already tried). + */ +function relaunchWithWasmFlags() { + if (WASM_RUNTIME_FLAGS.every((f) => process.execArgv.includes(f))) return null; + if (process.env[RELAUNCH_GUARD_ENV]) { + // Fail closed: running unflagged risks the V8 Zone OOM (or, worse, a silent half-run). + throw new SetupError(`${RELAUNCH_GUARD_ENV} is set but process.execArgv (${JSON.stringify(process.execArgv)}) lacks ${WASM_RUNTIME_FLAGS.join(' ')}: refusing to run unflagged (a stale ${RELAUNCH_GUARD_ENV} in the environment? unset it)`); + } + const r = spawnSync(process.execPath, [...WASM_RUNTIME_FLAGS, ...process.execArgv, SELF, ...process.argv.slice(2)], { + stdio: 'inherit', + env: { ...process.env, [RELAUNCH_GUARD_ENV]: '1' }, + }); + if (r.error) { + throw new SetupError(`cannot relaunch under ${WASM_RUNTIME_FLAGS.join(' ')}: ${r.error.message}`); + } + if (r.status === null) { + throw new SetupError(`relaunched run was killed by ${r.signal}`); + } + return r.status; +} + +/** Fail if this script's WASM_RUNTIME_FLAGS copy differs from the one in src/extraction/wasm-runtime-flags.ts. */ +function checkFlagsMirror() { + let src; + try { + src = fs.readFileSync(FLAGS_SOURCE, 'utf8'); + } catch (e) { + throw new SetupError(`cannot read ${path.relative(ROOT, FLAGS_SOURCE)} to check WASM_RUNTIME_FLAGS: ${e.message}`); + } + const m = /export\s+const\s+WASM_RUNTIME_FLAGS\b[^=]*=\s*\[([^\]]*)\]/.exec(src); + if (!m) { + throw new SetupError(`cannot parse the WASM_RUNTIME_FLAGS array literal in ${path.relative(ROOT, FLAGS_SOURCE)}; this script's copy is ${JSON.stringify(WASM_RUNTIME_FLAGS)} (reformatted? update the regex in checkFlagsMirror)`); + } + const theirs = [...m[1].matchAll(/(['"])(.*?)\1/g)].map((x) => x[2]); + if (theirs.length !== WASM_RUNTIME_FLAGS.length || theirs.some((f, i) => f !== WASM_RUNTIME_FLAGS[i])) { + throw new SetupError(`WASM_RUNTIME_FLAGS drifted: ${path.relative(ROOT, FLAGS_SOURCE)} has ${JSON.stringify(theirs)}, this script has ${JSON.stringify(WASM_RUNTIME_FLAGS)}; update the copy in scripts/ts-differential.mjs`); + } +} + +function parseArgs(argv) { + const o = { update: false, only: null, json: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--update') o.update = true; + else if (a === '--only') o.only = argv[++i]; + else if (a === '--json') o.json = argv[++i]; + else throw new SetupError(`unknown argument: ${a}`); + } + if ((argv.includes('--only') && !o.only) || (argv.includes('--json') && !o.json)) { + throw new SetupError('--only / --json need a value'); + } + if (o.update && o.only) throw new SetupError('--update rewrites the whole baseline; do not combine with --only'); + return o; +} + +function sha256(file) { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +function git(cwd, args) { + return execFileSync('git', args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + maxBuffer: 64 * 1024 * 1024, + }).toString(); +} + +function appendSummary(lines) { + if (!process.env.GITHUB_STEP_SUMMARY) return; + try { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join('\n') + '\n'); + } catch (e) { + process.stderr.write(`warning: could not write step summary: ${e.message}\n`); + } +} + +/** True when `dir` holds a finished checkout of exactly this corpus (sha + subdir). */ +function markerMatches(dir, c) { + try { + const m = JSON.parse(fs.readFileSync(path.join(dir, MARKER), 'utf8')); + return m.sha === c.sha && (m.subdir ?? '') === (c.subdir ?? ''); + } catch { + return false; + } +} + +/** Fetch repo@sha into the cache (re-using an existing complete clone). Returns the checkout dir. */ +function ensureCorpus(c, cacheRoot) { + // The dir is keyed on name + sha + subdir: a sparse checkout of one subdir must never + // be reused for another at the same sha. + const subKey = c.subdir ? `-${crypto.createHash('sha1').update(c.subdir).digest('hex').slice(0, 8)}` : ''; + const dir = path.join(cacheRoot, `${c.name}-${c.sha.slice(0, 12)}${subKey}`); + if (markerMatches(dir, c)) return dir; + fs.mkdirSync(cacheRoot, { recursive: true }); + const tmp = `${dir}.partial-${process.pid}`; + fs.rmSync(tmp, { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(tmp); + const url = `https://github.com/${c.repo}.git`; + process.stderr.write(`[fetch] ${c.name}: ${c.repo}@${c.sha.slice(0, 12)}${c.subdir ? ` (sparse ${c.subdir}/)` : ''}\n`); + try { + git(tmp, ['init', '-q']); + git(tmp, ['remote', 'add', 'origin', url]); + if (c.subdir) { + // blobless + sparse: pull only the subtree's blobs at checkout time + git(tmp, ['sparse-checkout', 'set', '--cone', c.subdir]); + git(tmp, ['fetch', '-q', '--depth', '1', '--filter=blob:none', 'origin', c.sha]); + } else { + git(tmp, ['fetch', '-q', '--depth', '1', 'origin', c.sha]); + } + git(tmp, ['-c', 'advice.detachedHead=false', 'checkout', '-q', 'FETCH_HEAD']); + const head = git(tmp, ['rev-parse', 'HEAD']).trim(); + if (head !== c.sha) throw new Error(`fetched ${head}, expected ${c.sha}`); + } catch (e) { + fs.rmSync(tmp, { recursive: true, force: true }); + const msg = e.stderr ? e.stderr.toString().trim() : e.message; + throw new SetupError(`fetch of ${c.name} failed: ${msg}`); + } + fs.writeFileSync(path.join(tmp, MARKER), JSON.stringify({ sha: c.sha, subdir: c.subdir ?? '' }) + '\n'); + try { + fs.renameSync(tmp, dir); + } catch (e) { + // A concurrent run may have finished the same checkout first. + fs.rmSync(tmp, { recursive: true, force: true }); + if (markerMatches(dir, c)) return dir; + throw new SetupError(`cannot move the fetched ${c.name} checkout into ${dir} (${e.code ?? e.message}) and it holds no complete matching checkout; remove that directory and retry`); + } + return dir; +} + +/** All .ts/.tsx/.mts/.cts files (incl. .d.ts) under dir/rel, skipping node_modules, .git and symlinks. */ +function listFiles(base, rel) { + const out = []; + const stack = [rel]; + while (stack.length) { + const r = stack.pop(); + let entries; + try { + entries = fs.readdirSync(path.join(base, r), { withFileTypes: true }); + } catch (e) { + throw new SetupError(`cannot read ${path.join(base, r)}: ${e.message}`); + } + for (const d of entries) { + if (d.isSymbolicLink()) continue; + const p = r ? `${r}/${d.name}` : d.name; + if (d.isDirectory()) { + if (d.name === 'node_modules' || d.name === '.git') continue; + stack.push(p); + } else if (d.isFile() && EXTS.has(path.extname(d.name))) { + out.push(p); + } + } + } + return out.sort(); +} + +/** First ERROR or MISSING node in document order -> 0-based row, or null. */ +function firstErrorRow(node) { + if (node.type === 'ERROR' || node.isMissing) return node.startPosition.row; + for (let i = 0; i < node.childCount; i++) { + const ch = node.child(i); + if (ch && ch.hasError) { + const r = firstErrorRow(ch); + if (r !== null) return r; + } + } + return null; +} + +/** + * corpus -> Map(path -> first error row | null). A row must be a non-negative integer; the + * old array-of-paths format is accepted (row unknown -> null). Anything else is a SetupError: + * a malformed row must never silently become "unknown", which accepts any row. + */ +function baselineEntries(baseline, corpus) { + const raw = baseline?.divergent?.[corpus]; + if (raw === undefined || raw === null) return new Map(); + if (Array.isArray(raw)) return new Map(raw.map((p) => [p, null])); + if (typeof raw !== 'object') { + throw new SetupError(`baseline divergent.${corpus} must be an object {path: row} or an old-format array of paths, got ${JSON.stringify(raw)}`); + } + return new Map(Object.entries(raw).map(([p, row]) => { + if (!Number.isInteger(row) || row < 0) { + throw new SetupError(`baseline divergent.${corpus}["${p}"] = ${JSON.stringify(row)}: a row must be a non-negative integer (corpus "${corpus}", ${path.relative(ROOT, BASELINE_PATH)}); re-baseline with --update`); + } + return [p, row]; + })); +} + +function readJson(file, what) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (e) { + throw new SetupError(`cannot read ${what} ${path.relative(ROOT, file)}: ${e.message}`); + } +} + +function tscDiagnostics(ts, name, text, kind) { + const sf = ts.createSourceFile(name, text, ts.ScriptTarget.Latest, false, kind); + if (!Array.isArray(sf.parseDiagnostics)) { + throw new SetupError(`tsc oracle blind: sf.parseDiagnostics is ${typeof sf.parseDiagnostics}, not an array (typescript ${ts.version} changed its internals?)`); + } + return sf.parseDiagnostics; +} + +async function main() { + const t0 = Date.now(); + const opts = parseArgs(process.argv.slice(2)); + checkFlagsMirror(); + + let ts, tsVersion, wtsVersion, Parser, Language; + try { + ts = require('typescript'); + tsVersion = ts.version; + wtsVersion = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules', 'web-tree-sitter', 'package.json'), 'utf8')).version; + ({ Parser, Language } = await import('web-tree-sitter')); + } catch (e) { + throw new SetupError(`cannot load typescript / web-tree-sitter (run npm ci): ${e.message}`); + } + for (const w of Object.values(WASM)) { + if (!fs.existsSync(w)) throw new SetupError(`missing grammar wasm: ${w}`); + } + const wasmSha256 = { typescript: sha256(WASM.typescript), tsx: sha256(WASM.tsx) }; + + // Fail closed: an oracle that stopped reporting errors would read every file as tsc-ok. + if (!tscDiagnostics(ts, 'canary.ts', CANARY_BAD, ts.ScriptKind.TS).length) { + throw new SetupError(`tsc oracle blind: "${CANARY_BAD}" produced no parse diagnostics`); + } + if (tscDiagnostics(ts, 'canary.ts', CANARY_GOOD, ts.ScriptKind.TS).length) { + throw new SetupError(`tsc oracle blind: "${CANARY_GOOD}" produced parse diagnostics`); + } + if (!tscDiagnostics(ts, 'canary.tsx', CANARY_TSX_BAD, ts.ScriptKind.TSX).length) { + throw new SetupError(`tsc oracle blind (TSX): "${CANARY_TSX_BAD}" produced no parse diagnostics`); + } + if (tscDiagnostics(ts, 'canary.tsx', CANARY_TSX_GOOD, ts.ScriptKind.TSX).length) { + throw new SetupError(`tsc oracle blind (TSX): "${CANARY_TSX_GOOD}" produced parse diagnostics`); + } + + const corpora = readJson(CORPORA_PATH, 'corpora file'); + const selected = opts.only ? corpora.filter((c) => c.name === opts.only) : corpora; + if (!selected.length) throw new SetupError(`no corpus named "${opts.only}" (have: ${corpora.map((c) => c.name).join(', ')})`); + + let baseline = null; + if (fs.existsSync(BASELINE_PATH)) { + // --update rewrites the baseline, so a malformed one must not block it. + try { + baseline = readJson(BASELINE_PATH, 'baseline'); + } catch (e) { + if (!opts.update) throw e; + process.stderr.write(`warning: ignoring the unreadable baseline (--update rewrites it): ${e.message}\n`); + } + } + if (!opts.update) { + if (!baseline) throw new SetupError('no baseline; run with --update to create one'); + const bad = []; + if (baseline.tsc !== tsVersion) bad.push(`tsc ${tsVersion} vs baseline ${baseline.tsc}`); + if (baseline.webTreeSitter !== wtsVersion) bad.push(`web-tree-sitter ${wtsVersion} vs baseline ${baseline.webTreeSitter}`); + if (bad.length) { + process.stderr.write(`Version mismatch (${bad.join('; ')}).\nThe oracle or loader changed: re-baseline with "node scripts/ts-differential.mjs --update" and commit the result.\n`); + appendSummary([ + '## TS grammar differential', '', + `**Version mismatch** (${bad.join('; ')}).`, '', + 'The oracle or loader changed, so results are not comparable with the baseline. Re-baseline with `node scripts/ts-differential.mjs --update` and commit the result.', + ]); + return { exit: 2 }; + } + // A full run covers every corpus, so a baselined corpus that is no longer in corpora.json + // (renamed/removed) would otherwise be silently dropped. (--only compares one entry.) + if (!opts.only) { + const names = new Set(corpora.map((c) => c.name)); + const gone = Object.keys(baseline.divergent ?? {}).filter((k) => !names.has(k)); + if (gone.length) { + throw new SetupError(`baseline corpus/corpora VANISHED from ${path.relative(ROOT, CORPORA_PATH)}: ${gone.join(', ')}. If the removal or rename is intended, re-baseline with "node scripts/ts-differential.mjs --update" and commit the result`); + } + } + } + + await Parser.init(); + const langs = { + typescript: await Language.load(WASM.typescript), + tsx: await Language.load(WASM.tsx), + }; + const parsers = { typescript: new Parser(), tsx: new Parser() }; + parsers.typescript.setLanguage(langs.typescript); + parsers.tsx.setLanguage(langs.tsx); + + // Grammar canary: a known-bad snippet must error and a known-good one must not. + for (const k of ['typescript', 'tsx']) { + for (const [text, want] of [[CANARY_BAD, true], [CANARY_GOOD, false]]) { + const t = parsers[k].parse(text); + if (!t) throw new SetupError(`${k} grammar canary: no tree for "${text}"`); + const got = t.rootNode.hasError; + t.delete(); + if (got !== want) throw new SetupError(`${k} grammar canary: "${text}" gave hasError=${got}, expected ${want} (wrong or broken wasm?)`); + } + } + + const cacheRoot = process.env.TS_DIFF_CACHE || path.join(os.homedir(), '.cache', 'codegraph-ts-differential'); + const dirs = new Map(); + for (const c of selected) { + dirs.set(c.name, c.local ? ROOT : ensureCorpus(c, cacheRoot)); + } + + const results = {}; // corpus -> [{path, cls, tscErr, tsErr, row}] + for (const c of selected) { + const base = dirs.get(c.name); + const rel = c.local ?? c.subdir ?? ''; + const files = listFiles(base, rel); + process.stderr.write(`[scan] ${c.name}: ${files.length} files\n`); + if (!files.length) throw new SetupError(`corpus "${c.name}" scanned 0 files under ${path.join(base, rel)}: wrong path or empty checkout`); + const rows = []; + for (const f of files) { + const ext = path.extname(f); + let text; + try { + text = fs.readFileSync(path.join(base, f), 'utf8'); + } catch (e) { + throw new SetupError(`cannot read ${f}: ${e.message}`); + } + const isTsx = ext === '.tsx'; + const tscErr = tscDiagnostics(ts, f, text, isTsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS).length > 0; + const tree = parsers[isTsx ? 'tsx' : 'typescript'].parse(text); + if (!tree) throw new SetupError(`tree-sitter returned no tree for ${f}`); + const tsErr = tree.rootNode.hasError; + const row = tsErr ? firstErrorRow(tree.rootNode) : null; + tree.delete(); + const cls = tscErr ? (tsErr ? 'agree-err' : 'tsc-only-err') : tsErr ? 'DIVERGENT' : 'agree-ok'; + rows.push({ path: f, cls, tscErr, tsErr, row: row === null ? null : row + 1 }); + } + results[c.name] = rows; + } + + // Compare against the baseline. + const report = []; + const allNew = []; + const allWorse = []; + const allImproved = []; + const allVanished = []; + const invalidFixtures = []; // local corpus: tsc rejects the fixture + const nonGapDivergent = []; // local corpus: a non-gap fixture diverges + for (const c of selected) { + const rows = results[c.name]; + const byPath = new Map(rows.map((r) => [r.path, r])); + const count = (k) => rows.filter((r) => r.cls === k).length; + const divergent = rows.filter((r) => r.cls === 'DIVERGENT'); + let known; + try { + known = baselineEntries(baseline, c.name); + } catch (e) { + if (!(e instanceof SetupError) || !opts.update) throw e; + process.stderr.write(`warning: ignoring the malformed baseline entry (--update rewrites it): ${e.message}\n`); + known = new Map(); + } + const fresh = divergent.filter((r) => !known.has(r.path)); + let worse = 0; + let improved = 0; + let nowAgree = 0; + let nowErr = 0; + const vanished = []; + for (const [p, was] of known) { + const r = byPath.get(p); + if (!r) vanished.push(p); + else if (r.cls === 'agree-ok') nowAgree++; + else if (r.cls === 'agree-err' || r.cls === 'tsc-only-err') nowErr++; + // Row gate: pinned corpora only. `local` is strict per file already, and its rows + // shift whenever a fixture is edited. + else if (!c.local && was !== null && r.row !== null) { + if (r.row < was) { + worse++; + allWorse.push({ corpus: c.name, path: p, row: r.row, was }); + } else if (r.row > was) { + improved++; + allImproved.push({ corpus: c.name, path: p, row: r.row, was }); + } + } + } + for (const r of fresh) allNew.push({ corpus: c.name, ...r }); + for (const p of vanished) allVanished.push({ corpus: c.name, path: p }); + if (c.local) { + for (const r of rows) { + if (r.tscErr) invalidFixtures.push({ corpus: c.name, path: r.path, cls: r.cls }); + if (r.cls === 'DIVERGENT' && !isGapFixture(r.path)) nonGapDivergent.push({ corpus: c.name, path: r.path, row: r.row }); + } + } + report.push({ + corpus: c.name, + files: rows.length, + tsErr: count('agree-err') + count('DIVERGENT'), + agreeOk: count('agree-ok'), + agreeErr: count('agree-err'), + divergent: divergent.length, + newDiv: fresh.length, + worse, + improved, + nowAgree, + nowErr, + vanished: vanished.length, + tscOnlyErr: count('tsc-only-err'), + }); + } + + // Table + const cols = [ + ['corpus', 'corpus'], ['files', 'files'], ['agree-ok', 'agreeOk'], ['agree-err', 'agreeErr'], + ['DIVERGENT', 'divergent'], ['new', 'newDiv'], ['worse', 'worse'], ['improved', 'improved'], + ['now-agree', 'nowAgree'], ['now-err', 'nowErr'], ['vanished', 'vanished'], + ['tsc-only-err', 'tscOnlyErr'], ['ts-err (tot)', 'tsErr'], + ]; + const widths = cols.map(([h, k]) => Math.max(h.length, ...report.map((r) => String(r[k]).length))); + const line = (cells) => cells.map((c, i) => (i === 0 ? String(c).padEnd(widths[i]) : String(c).padStart(widths[i]))).join(' '); + console.log(line(cols.map(([h]) => h))); + console.log(widths.map((w) => '-'.repeat(w)).join(' ')); + for (const r of report) console.log(line(cols.map(([, k]) => r[k]))); + console.log(`\ntsc ${tsVersion} web-tree-sitter ${wtsVersion}`); + if (baseline?.wasmSha256 && !opts.update) { + for (const k of ['typescript', 'tsx']) { + if (baseline.wasmSha256[k] !== wasmSha256[k]) { + console.log(`note: ${k} wasm differs from the baseline's (${wasmSha256[k].slice(0, 12)} vs ${String(baseline.wasmSha256[k]).slice(0, 12)})`); + } + } + } + const sum = (k) => report.reduce((n, r) => n + r[k], 0); + const totalNow = sum('nowAgree'); + const totalNowErr = sum('nowErr'); + if (totalNow) console.log(`${totalNow} baseline file(s) now agree with tsc: update the baseline (--update).`); + if (totalNowErr) console.log(`${totalNowErr} baseline file(s) are now rejected by tsc too (now-err): update the baseline (--update).`); + const listRows = (title, items, fmt) => { + console.log(`\n${items.length} ${title}`); + for (const r of items.slice(0, 50)) console.log(` ${fmt(r)}`); + if (items.length > 50) console.log(` ... ${items.length - 50} more (use --json)`); + }; + if (allImproved.length) { + listRows('baselined divergent file(s) whose first error moved later (improved; --update to record):', allImproved, (r) => `${r.corpus}:${r.path}:${r.row} (was ${r.was})`); + } + if (!opts.update) { + if (allNew.length) listRows('NEW divergence(s) (tsc ok, tree-sitter error):', allNew, (r) => `${r.corpus}:${r.path}:${r.row ?? '?'}`); + if (allWorse.length) { + listRows('NEW error(s) inside already-divergent files (first error moved earlier):', allWorse, (r) => `${r.corpus}:${r.path}:${r.row} (was ${r.was})`); + console.log('If the grammar changed error recovery or the corpus pin moved, re-baseline with --update.'); + } + if (allVanished.length) { + listRows('baselined path(s) VANISHED from this run (renamed, deleted or wrong corpus root); they are not "now agree":', allVanished, (r) => `${r.corpus}:${r.path}`); + console.log('Confirm the change is intended, then re-baseline: node scripts/ts-differential.mjs --update'); + } + } + if (invalidFixtures.length) { + listRows('local fixture(s): fixture is not valid TypeScript (tsc reports a syntax error):', invalidFixtures, (r) => `${r.corpus}:${r.path} (${r.cls})`); + } + if (nonGapDivergent.length) { + listRows('local non-gap fixture(s) DIVERGENT (only gap-* / variance.* may diverge; this is a grammar regression or a misnamed gap):', nonGapDivergent, (r) => `${r.corpus}:${r.path}:${r.row ?? '?'}`); + } + + if (opts.json) { + fs.mkdirSync(path.dirname(path.resolve(opts.json)), { recursive: true }); + fs.writeFileSync(opts.json, JSON.stringify({ + tsc: tsVersion, webTreeSitter: wtsVersion, execArgv: process.execArgv, wasmSha256, report, + worse: allWorse, improved: allImproved, vanished: allVanished, invalidFixtures, nonGapDivergent, results, + }, null, 1) + '\n'); + } + + const md = []; + md.push('## TS grammar differential', ''); + md.push(`tsc ${tsVersion}, web-tree-sitter ${wtsVersion}`, ''); + md.push('| corpus | files | divergent | new | worse | improved | now agree | now err | vanished | ts err | tsc-only err |', '|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|'); + for (const r of report) md.push(`| ${r.corpus} | ${r.files} | ${r.divergent} | ${r.newDiv} | ${r.worse} | ${r.improved} | ${r.nowAgree} | ${r.nowErr} | ${r.vanished} | ${r.tsErr} | ${r.tscOnlyErr} |`); + md.push(''); + if (totalNow || totalNowErr || allImproved.length) md.push('Baseline files improved or now agree. Update the baseline: `node scripts/ts-differential.mjs --update`.', ''); + if (allNew.length) { + md.push(`### ${allNew.length} new divergence(s)`, '', 'tsc accepts these files; tree-sitter reports an error (first 20):', ''); + for (const r of allNew.slice(0, 20)) md.push(`- \`${r.corpus}:${r.path}:${r.row ?? '?'}\``); + md.push(''); + } + if (allWorse.length) { + md.push(`### ${allWorse.length} new error(s) in already-divergent files`, '', 'The first error moved earlier than the baselined row (first 20). If the grammar changed error recovery or the corpus pin moved, re-baseline with `node scripts/ts-differential.mjs --update`:', ''); + for (const r of allWorse.slice(0, 20)) md.push(`- \`${r.corpus}:${r.path}:${r.row}\` (was ${r.was})`); + md.push(''); + } + if (allVanished.length) { + md.push(`### ${allVanished.length} baselined path(s) vanished`, '', 'These are in the baseline but absent from this run (renamed, deleted or wrong corpus root). Confirm, then re-baseline with `node scripts/ts-differential.mjs --update` (first 20):', ''); + for (const r of allVanished.slice(0, 20)) md.push(`- \`${r.corpus}:${r.path}\``); + md.push(''); + } + if (invalidFixtures.length) md.push(`### Fixture is not valid TypeScript`, '', ...invalidFixtures.map((r) => `- \`${r.path}\``), ''); + if (nonGapDivergent.length) md.push(`### Non-gap fixture diverges`, '', ...nonGapDivergent.map((r) => `- \`${r.path}:${r.row ?? '?'}\``), ''); + if (!allNew.length && !allWorse.length && !allVanished.length && !invalidFixtures.length && !nonGapDivergent.length) md.push('No new divergences.', ''); + appendSummary(md); + + const localBad = invalidFixtures.length > 0 || nonGapDivergent.length > 0; + + if (opts.update) { + if (localBad) { + process.stderr.write('refusing to write the baseline: fix the local fixtures above (invalid TypeScript, or a non-gap fixture that diverges) first.\n'); + return { exit: 1 }; + } + const divergent = {}; + for (const c of selected) { + const entries = results[c.name].filter((r) => r.cls === 'DIVERGENT').sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + divergent[c.name] = Object.fromEntries(entries.map((r) => [r.path, r.row])); + } + const out = { tsc: tsVersion, webTreeSitter: wtsVersion, generatedAt: new Date().toISOString(), wasmSha256, divergent }; + fs.writeFileSync(BASELINE_PATH, JSON.stringify(out, null, 1) + '\n'); + console.log(`\nbaseline written: ${path.relative(ROOT, BASELINE_PATH)}`); + console.log(`wall time: ${((Date.now() - t0) / 1000).toFixed(1)}s`); + return { exit: 0 }; + } + + console.log(`\nwall time: ${((Date.now() - t0) / 1000).toFixed(1)}s`); + if (allVanished.length) return { exit: 2 }; + return { exit: allNew.length || allWorse.length || localBad ? 1 : 0 }; +} + +/** Report a failure and exit 2. A SetupError also goes to the step summary (best-effort). */ +function fatal(e) { + if (e instanceof SetupError) { + process.stderr.write(`setup error: ${e.message}\n`); + appendSummary(['## TS grammar differential', '', `**Setup error:** ${e.message}`]); + } else { + process.stderr.write(`${e.stack ?? e}\n`); + } + process.exit(2); +} + +let relaunched = null; +try { + relaunched = relaunchWithWasmFlags(); +} catch (e) { + fatal(e); +} +if (relaunched !== null) { + process.exit(relaunched); +} else { + main().then(({ exit }) => process.exit(exit), fatal); +}