diff --git a/src/token-dfa.ts b/src/token-dfa.ts deleted file mode 100644 index 9584a3b..0000000 --- a/src/token-dfa.ts +++ /dev/null @@ -1,347 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// token-dfa.ts — derive a char-code DFA matcher from a token's structured pattern IR -// (src/token-pattern.ts): a scanner that dispatches on char codes instead of executing a -// regex per token (issue #5). KEPT as the measurement behind that issue — `compileTokenDfa` -// is exercised only by test/token-dfa-verify.ts, which found a GENERIC DFA interpreter to be -// net-negative vs V8's JIT-compiled sticky regex on all 12 TS tokens (Ident 0.30×). The -// emitter that would have turned the DFA into specialized straight-line JS was never wired in -// (zero callers) and is removed; revisit from this measurement if char-code scanning is -// pursued again. -// -// The lexer matches one token at a time, anchored at `pos`, taking that token's -// greedy/longest match (sticky `re.lastIndex = pos; re.exec(s)`). This compiles the -// REGULAR subset of the IR — literal · charClass · anyChar · seq · alt · greedy -// repeat · never, plus a single TRAILING lookahead over a char class (the `(?!…)` -// guard the numeric tokens end with) — to an NFA (Thompson), then a DFA (subset -// construction), and runs it over `charCodeAt` code units. `match(s, pos)` returns -// the same match length the token's sticky regex would, or -1. -// -// Anything outside that subset (mid-pattern look-around, lookbehind, anchors, a -// non-greedy quantifier) → `compileTokenDfa` returns null and the caller keeps using -// the regex. So the scanner is byte-identical by construction: a DFA where the IR is -// regular, the proven regex elsewhere. Char classes are matched over UTF-16 code -// units (0..0xFFFF) exactly like the non-`/u` regexes the lexer emits today. -// ───────────────────────────────────────────────────────────────────────────── - -import type { TokenPattern, TokenCharClassItem } from './types.ts'; - -// UTF-16 code-unit alphabet. Negated classes complement within [0, MAX_CODE]. -const MAX_CODE = 0xffff; - -// A half-open is avoided: ranges are inclusive [lo, hi] of code units. -export interface Range { lo: number; hi: number } - -// ── Char-class → sorted, merged, inclusive ranges ── -function classRanges(items: TokenCharClassItem[], negate: boolean): Range[] { - const raw: Range[] = []; - for (const item of items) { - if (item.type === 'char') { - const c = item.value.charCodeAt(0); - raw.push({ lo: c, hi: c }); - } else { - const a = item.from.charCodeAt(0), b = item.to.charCodeAt(0); - raw.push({ lo: Math.min(a, b), hi: Math.max(a, b) }); - } - } - const merged = mergeRanges(raw); - return negate ? complementRanges(merged) : merged; -} - -function mergeRanges(ranges: Range[]): Range[] { - if (ranges.length === 0) return []; - const sorted = [...ranges].sort((a, b) => a.lo - b.lo || a.hi - b.hi); - const out: Range[] = [{ ...sorted[0] }]; - for (let i = 1; i < sorted.length; i++) { - const last = out[out.length - 1], r = sorted[i]; - if (r.lo <= last.hi + 1) last.hi = Math.max(last.hi, r.hi); - else out.push({ ...r }); - } - return out; -} - -function complementRanges(ranges: Range[]): Range[] { - // ranges are sorted+merged; complement within [0, MAX_CODE]. - const out: Range[] = []; - let next = 0; - for (const r of ranges) { - if (r.lo > next) out.push({ lo: next, hi: r.lo - 1 }); - next = r.hi + 1; - } - if (next <= MAX_CODE) out.push({ lo: next, hi: MAX_CODE }); - return out; -} - -// ── NFA (Thompson) ── -// A transition is either an epsilon move or a move on any code unit inside `ranges`. -interface NfaState { eps: number[]; trans: { ranges: Range[]; to: number }[] } - -class UnsupportedPattern extends Error {} - -class Nfa { - states: NfaState[] = []; - newState(): number { this.states.push({ eps: [], trans: [] }); return this.states.length - 1; } - eps(a: number, b: number): void { this.states[a].eps.push(b); } - move(a: number, ranges: Range[], b: number): void { this.states[a].trans.push({ ranges, to: b }); } -} - -// Build an NFA fragment for `pattern`; returns [start, accept]. Throws UnsupportedPattern -// for any non-regular construct so the caller can fall back to the regex. -function build(nfa: Nfa, pattern: TokenPattern): [number, number] { - if (typeof pattern === 'string') return buildLiteral(nfa, pattern); - switch (pattern.type) { - case 'anyChar': { - const s = nfa.newState(), a = nfa.newState(); - nfa.move(s, [{ lo: 0, hi: MAX_CODE }], a); - return [s, a]; - } - case 'charClass': { - const ranges = classRanges(pattern.items, pattern.negate); - const s = nfa.newState(), a = nfa.newState(); - if (ranges.length) nfa.move(s, ranges, a); // empty class → no edge → never matches - return [s, a]; - } - case 'seq': { - if (pattern.items.length === 0) { const s = nfa.newState(); return [s, s]; } - let [start, acc] = build(nfa, pattern.items[0]); - for (let i = 1; i < pattern.items.length; i++) { - const [s2, a2] = build(nfa, pattern.items[i]); - nfa.eps(acc, s2); - acc = a2; - } - return [start, acc]; - } - case 'alt': { - const s = nfa.newState(), a = nfa.newState(); - for (const item of pattern.items) { - const [s2, a2] = build(nfa, item); - nfa.eps(s, s2); - nfa.eps(a2, a); - } - return [s, a]; - } - case 'repeat': { - if (!pattern.greedy) throw new UnsupportedPattern('non-greedy repeat'); - // min mandatory copies, then either an unbounded star or (max-min) optional copies. - const s = nfa.newState(); - let acc = s; - for (let i = 0; i < pattern.min; i++) { - const [s2, a2] = build(nfa, pattern.body); - nfa.eps(acc, s2); - acc = a2; - } - if (pattern.max === undefined) { - // star: acc --eps--> bodyStart, bodyAccept --eps--> acc (loop) and onward. - const [s2, a2] = build(nfa, pattern.body); - const a = nfa.newState(); - nfa.eps(acc, s2); - nfa.eps(a2, s2); // loop - nfa.eps(acc, a); // skip (zero more) - nfa.eps(a2, a); // exit after >=1 - return [s, a]; - } else { - const a = nfa.newState(); - let cur = acc; - for (let i = pattern.min; i < pattern.max; i++) { - const [s2, a2] = build(nfa, pattern.body); - nfa.eps(cur, s2); - nfa.eps(cur, a); // optional: skip the rest - cur = a2; - } - nfa.eps(cur, a); - return [s, a]; - } - } - case 'never': { - const s = nfa.newState(), a = nfa.newState(); // no edge s→a → never accepts - return [s, a]; - } - // Non-regular: the caller must fall back to the regex. - case 'lookahead': - case 'lookbehind': - case 'anchor': - throw new UnsupportedPattern(pattern.type); - } -} - -function buildLiteral(nfa: Nfa, literal: string): [number, number] { - const start = nfa.newState(); - let cur = start; - for (let i = 0; i < literal.length; i++) { - const c = literal.charCodeAt(i); - const next = nfa.newState(); - nfa.move(cur, [{ lo: c, hi: c }], next); - cur = next; - } - return [start, cur]; -} - -// ── Subset construction → DFA ── -interface DfaState { accept: boolean; edges: { ranges: Range[]; to: number }[] } - -function epsilonClosure(nfa: Nfa, set: Set): Set { - const stack = [...set], out = new Set(set); - while (stack.length) { - const s = stack.pop()!; - for (const t of nfa.states[s].eps) if (!out.has(t)) { out.add(t); stack.push(t); } - } - return out; -} - -function setKey(set: Set): string { - return [...set].sort((a, b) => a - b).join(','); -} - -// Partition boundaries: every code unit where some transition's membership flips. We -// build a sorted list of "cut points" so the alphabet splits into intervals on which -// every NFA transition is constant — the classic DFA alphabet partition. -function buildDfa(nfa: Nfa, start: number, accept: number): DfaState[] { - const startSet = epsilonClosure(nfa, new Set([start])); - const dfa: DfaState[] = []; - const index = new Map(); - const queue: Set[] = []; - - const intern = (set: Set): number => { - const key = setKey(set); - let id = index.get(key); - if (id === undefined) { - id = dfa.length; - index.set(key, id); - dfa.push({ accept: set.has(accept), edges: [] }); - queue.push(set); - } - return id; - }; - - intern(startSet); - while (queue.length) { - const set = queue.shift()!; - const id = index.get(setKey(set))!; - // Collect this state's outgoing transitions, then split into disjoint intervals. - const trans: { ranges: Range[]; to: number }[] = []; - for (const ns of set) for (const tr of nfa.states[ns].trans) trans.push(tr); - if (trans.length === 0) continue; - // Cut points: for every range [lo,hi] add boundaries at lo and hi+1. - const cuts = new Set(); - for (const tr of trans) for (const r of tr.ranges) { cuts.add(r.lo); cuts.add(r.hi + 1); } - const points = [...cuts].sort((a, b) => a - b); - // For each elementary interval [points[i], points[i+1]-1], gather NFA targets. - const edges: { ranges: Range[]; to: number }[] = []; - for (let i = 0; i < points.length - 1; i++) { - const lo = points[i], hi = points[i + 1] - 1; - if (hi < lo) continue; - const targets = new Set(); - for (const tr of trans) { - for (const r of tr.ranges) if (r.lo <= lo && hi <= r.hi) { targets.add(tr.to); break; } - } - if (targets.size === 0) continue; - const toId = intern(epsilonClosure(nfa, targets)); - edges.push({ ranges: [{ lo, hi }], to: toId }); - } - // Merge adjacent intervals that go to the same DFA state (compacts the table). - edges.sort((a, b) => a.ranges[0].lo - b.ranges[0].lo); - const merged: { ranges: Range[]; to: number }[] = []; - for (const e of edges) { - const last = merged[merged.length - 1]; - if (last && last.to === e.to && last.ranges[last.ranges.length - 1].hi + 1 === e.ranges[0].lo) { - last.ranges[last.ranges.length - 1].hi = e.ranges[0].hi; - } else merged.push({ ranges: [{ ...e.ranges[0] }], to: e.to }); - } - dfa[id].edges = merged; - } - return dfa; -} - -function dfaNext(state: DfaState, code: number): number { - for (const e of state.edges) { - for (const r of e.ranges) { - if (code < r.lo) break; // ranges are sorted ascending - if (code <= r.hi) return e.to; - } - } - return -1; -} - -// Run the DFA from `pos`, recording every accepting length. Returns the lengths in -// DESCENDING order (longest first) — what a greedy regex would prefer, and what the -// trailing-lookahead retry needs. -function runAcceptLengths(dfa: DfaState[], s: string, pos: number): number[] { - const accepts: number[] = []; - let state = 0, i = pos; - if (dfa[0].accept) accepts.push(0); - while (state >= 0 && i < s.length) { - const next = dfaNext(dfa[state], s.charCodeAt(i)); - if (next < 0) break; - state = next; - i++; - if (dfa[state].accept) accepts.push(i - pos); - } - return accepts.reverse(); -} - -// ── Public compile ── -export interface TokenDfa { - /** Match length at `pos`, or -1 — byte-identical to the token's sticky regex exec. */ - match(s: string, pos: number): number; -} - -// `DfaState` / `buildDfa` are consumed by `compileTokenDfa` below (the measured interpreter). - -// A trailing `(?!class)` / `(?=class)` over a single char class is the only look-around -// the numeric tokens use; supported by retrying shorter body matches until the assertion -// at the body's end holds. Detected structurally on the IR. -function trailingLookahead(pattern: TokenPattern): { body: TokenPattern; ranges: Range[]; negate: boolean } | null { - if (typeof pattern === 'string' || pattern.type !== 'seq') return null; - const last = pattern.items[pattern.items.length - 1]; - if (typeof last === 'string' || last.type !== 'lookahead') return null; - const inner = last.body; - if (typeof inner === 'string' || inner.type !== 'charClass') return null; // only a char-class assertion - const body: TokenPattern = pattern.items.length === 2 - ? pattern.items[0] - : { type: 'seq', items: pattern.items.slice(0, -1) }; - return { body, ranges: classRanges(inner.items, inner.negate), negate: last.negate }; -} - -function inRanges(ranges: Range[], code: number): boolean { - for (const r of ranges) if (code >= r.lo && code <= r.hi) return true; - return false; -} - -/** - * Compile a token's pattern to a char-code DFA matcher, or return null if the pattern - * uses a construct outside the supported regular subset (caller falls back to regex). - */ -export function compileTokenDfa(pattern: TokenPattern): TokenDfa | null { - try { - const look = trailingLookahead(pattern); - if (look) { - const nfa = new Nfa(); - const [start, accept] = build(nfa, look.body); - const dfa = buildDfa(nfa, start, accept); - const { ranges, negate } = look; - return { - match(s, pos) { - const lens = runAcceptLengths(dfa, s, pos); // longest first - for (const len of lens) { - const at = pos + len; - const has = at < s.length && inRanges(ranges, s.charCodeAt(at)); - // negative lookahead succeeds when the char is absent (incl. EOF); positive needs it present. - if (negate ? !has : has) return len; - } - return -1; - }, - }; - } - const nfa = new Nfa(); - const [start, accept] = build(nfa, pattern); - const dfa = buildDfa(nfa, start, accept); - return { - match(s, pos) { - const lens = runAcceptLengths(dfa, s, pos); - return lens.length ? lens[0] : -1; - }, - }; - } catch (e) { - if (e instanceof UnsupportedPattern) return null; - throw e; - } -} diff --git a/src/trace-markers.ts b/src/trace-markers.ts deleted file mode 100644 index 5870bcb..0000000 --- a/src/trace-markers.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Region markers for the execution tracer (test/exec-trace.ts). -// -// In production these are near-free no-ops: they flip a global flag nothing reads, so -// leaving `startTrace()`/`endTrace()` around a region you're optimizing costs nothing. -// The tracer runs an *instrumented* build of the source whose recorded ops are gated on -// that same flag — so the markers bound exactly the executed region that gets printed. -export function startTrace(): void { (globalThis as { __REC?: boolean }).__REC = true; } -export function endTrace(): void { (globalThis as { __REC?: boolean }).__REC = false; } diff --git a/test/ab-emitted.mjs b/test/ab-emitted.mjs deleted file mode 100644 index bfa89d1..0000000 --- a/test/ab-emitted.mjs +++ /dev/null @@ -1,29 +0,0 @@ -// Interleaved A/B of two EMITTED parser builds on the PR#4 bench files — the standard -// keep/revert gate for emit-layer perf work (best-of-7 × N=20, min taken, engines -// alternated within one process so machine drift cancels). -// node test/ab-emitted.mjs /tmp/emitted-A.mjs /tmp/emitted-B.mjs -// Emit a build of the current working tree with: -// node -e "const{emitParser}=await import('./src/emit-parser.ts');const g=(await import('./typescript.ts')).default;require('fs').writeFileSync(process.argv[1],emitParser(g))" --input-type=module /tmp/emitted-X.mjs -// (or see test/emit-parser-bench.ts). For a committed baseline: git stash → emit → stash pop. -import { readFileSync } from 'node:fs'; -const [pa, pb] = [process.argv[2], process.argv[3]]; -if (!pa || !pb) { console.error('usage: node test/ab-emitted.mjs '); process.exit(1); } -const A = await import(pa); -const B = await import(pb); -const paths = [ - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/RealWorld/parserharness.ts', - '/tmp/ts-repo/tests/cases/conformance/fixSignatureCaching.ts', - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/parserRealSource7.ts', - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/RealWorld/parserindenter.ts', -]; -const files = paths.map(p => ({ name: p.split('/').pop(), code: readFileSync(p, 'utf8') })); -const time = (fn, code, n) => { const s = process.hrtime.bigint(); for (let i = 0; i < n; i++) { try { fn(code); } catch {} } return Number(process.hrtime.bigint() - s) / 1e6 / n; }; -for (const { code } of files) for (let i = 0; i < 10; i++) { try { A.parse(code); B.parse(code); } catch {} } -let ta = 0, tb = 0; -for (const { name, code } of files) { - let a = Infinity, b = Infinity; - for (let r = 0; r < 7; r++) { a = Math.min(a, time(A.parse, code, 20)); b = Math.min(b, time(B.parse, code, 20)); } - ta += a; tb += b; - console.log(`${name.padEnd(28)} ${a.toFixed(2).padStart(6)} → ${b.toFixed(2).padEnd(6)} ${((a / b - 1) * 100).toFixed(1).padStart(6)}%`); -} -console.log(`${'AGGREGATE'.padEnd(28)} ${ta.toFixed(2).padStart(6)} → ${tb.toFixed(2).padEnd(6)} ${((ta / tb - 1) * 100).toFixed(1).padStart(6)}%`); diff --git a/test/check.ts b/test/check.ts index a862de7..b484ce5 100644 --- a/test/check.ts +++ b/test/check.ts @@ -16,8 +16,10 @@ import { cpus } from 'node:os'; interface Gate { group: string; name: string; args: string[] } const GATES: Gate[] = [ - { group: 'core', name: 'sanity', args: ['test/sanity-check.ts'] }, { group: 'core', name: 'agnostic', args: ['test/agnostic.ts'] }, + { group: 'core', name: 'left-recursion', args: ['test/left-recursion.ts'] }, + { group: 'core', name: 'newline-mode', args: ['test/newline-mode.ts'] }, + { group: 'core', name: 'interpolation-metadata', args: ['test/interpolation-metadata.ts'] }, { group: 'core', name: 'refactor-guard', args: ['test/refactor-guard.ts'] }, { group: 'core', name: 'cst-text-invariant', args: ['test/cst-text-invariant.ts'] }, { group: 'conformance', name: 'ts-ast-structure', args: ['test/ts-ast-verify.ts'] }, @@ -45,7 +47,6 @@ const GATES: Gate[] = [ { group: 'highlighter', name: 'angle-depth', args: ['test/angle-depth-probe.ts'] }, { group: 'highlighter', name: 'html-monarch', args: ['test/html-monarch.ts'] }, { group: 'highlighter', name: 'html-embed-js', args: ['test/html-embed-js.ts'] }, - { group: 'highlighter', name: 'html-lexer-spike', args: ['test/html-lexer-spike.ts'] }, { group: 'highlighter', name: 'self-close-sites', args: ['test/self-close-sites.ts'] }, { group: 'highlighter', name: 'raw-text-case-sites', args: ['test/raw-text-case-sites.ts'] }, { group: 'core', name: 'indent-extensions', args: ['test/indent-extensions.ts'] }, diff --git a/test/cpu-profile.ts b/test/cpu-profile.ts deleted file mode 100644 index 04dc41a..0000000 --- a/test/cpu-profile.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Generate a V8 CPU profile of the parser. -// npx tsx test/cpu-profile.ts # profile the bench corpus (mixed real files) -// npx tsx test/cpu-profile.ts # profile a single file -// npx tsx test/cpu-profile.ts 8 # ...for ~8 seconds of samples -// Writes parser.cpuprofile (open in Chrome DevTools ▸ Performance ▸ Load profile, or VS Code). -import { Session } from 'node:inspector'; -import { writeFileSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { createParser } from '../src/gen-parser.ts'; - -const grammar = (await import('../typescript.ts')).default; -const { parse } = createParser(grammar); - -const arg = process.argv[2]; -const seconds = Number(process.argv[3]) || 4; - -const corpus = [ - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/RealWorld/parserharness.ts', - '/tmp/ts-repo/tests/cases/conformance/fixSignatureCaching.ts', - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/parserRealSource7.ts', - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/RealWorld/parserindenter.ts', -]; -const paths = arg ? [arg] : corpus; -const files = paths - .map((p) => { try { return readFileSync(p, 'utf-8'); } catch { return null; } }) - .filter((c): c is string => c !== null); - -if (files.length === 0) { console.error('no readable input files:', paths); process.exit(1); } -const totalKB = (files.reduce((n, c) => n + c.length, 0) / 1024).toFixed(0); - -// Warm up so the profile reflects JIT-optimized steady state, not the cold tier. -for (let r = 0; r < 5; r++) for (const c of files) { try { parse(c); } catch {} } - -const session = new Session(); -session.connect(); -const post = (method: string, params?: any) => - new Promise((res, rej) => session.post(method, params, (e: any, r: any) => (e ? rej(e) : res(r)))); - -await post('Profiler.enable'); -await post('Profiler.setSamplingInterval', { interval: 200 }); // microseconds (5x finer than default) -await post('Profiler.start'); - -const start = process.hrtime.bigint(); -let parses = 0; -while (Number(process.hrtime.bigint() - start) / 1e9 < seconds) { - for (const c of files) { try { parse(c); } catch {} parses++; } -} -const secs = Number(process.hrtime.bigint() - start) / 1e9; - -const { profile } = await post('Profiler.stop'); -const outPath = resolve('parser.cpuprofile'); -writeFileSync(outPath, JSON.stringify(profile)); - -// Text summary: aggregate self-time (hitCount = samples landing on top of stack) by function. -const byFn = new Map(); -let totalSamples = 0; -for (const node of profile.nodes) { - const hc = node.hitCount || 0; - if (!hc) continue; - totalSamples += hc; - const cf = node.callFrame; - const name = cf.functionName || '(anonymous)'; - const file = (cf.url || '').split('/').pop() || cf.url || ''; - const where = file ? `${file}:${cf.lineNumber + 1}` : '(native)'; - const key = `${name.padEnd(22)} ${where}`; - byFn.set(key, (byFn.get(key) || 0) + hc); -} -const top = [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25); - -console.log(`\nprofiled ${parses} parses of ${files.length} file(s) (${totalKB} KB/round) in ${secs.toFixed(1)}s`); -console.log(`${totalSamples} samples @200us\n`); -console.log(' self% samples function @ file:line'); -console.log(' ' + '-'.repeat(60)); -let cum = 0; -for (const [k, v] of top) { - cum += v; - console.log(`${(100 * v / totalSamples).toFixed(1).padStart(5)}% ${String(v).padStart(7)} ${k}`); -} -console.log(` (top 25 = ${(100 * cum / totalSamples).toFixed(0)}% of self-time)`); -console.log(`\nwrote ${outPath}`); -console.log('open in Chrome DevTools (Performance ▸ Load profile) or just open the file in VS Code.'); diff --git a/test/emit-parser-bench.ts b/test/emit-parser-bench.ts deleted file mode 100644 index 5af58a2..0000000 --- a/test/emit-parser-bench.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Benchmark: RUNTIME INTERPRETER (createParser, gen-parser.ts) vs the EMITTED -// specialized parser (emit-parser.ts) on the files where they AGREE — the 4 -// test/bench.ts files (verified byte-identical) plus, optionally, more. -// -// Method mirrors test/bench.ts: warm up, then N timed runs, ms/parse per file + -// the multiplier (interpreter_ms / emitted_ms) per file and aggregate. The lexer -// is shared (same tokenize), so the delta isolates the PARSER-layer speedup. -// -// node test/emit-parser-bench.ts # the 4 bench files, N=20 -// node test/emit-parser-bench.ts # custom timed-run count -import { createParser } from '../src/gen-parser.ts'; -import { emitParser, jsTarget } from '../src/emit.ts'; -import { readFileSync, writeFileSync } from 'fs'; - -const grammar = (await import('../typescript.ts')).default; -const oracle = createParser(grammar); - -const EMITTED = '/tmp/emitted-parser.mts'; -writeFileSync(EMITTED, emitParser(grammar, jsTarget)); -const emitted = await import(EMITTED + '?v=' + Date.now()); - -const N = Number(process.argv[2]) || 20; - -const files = [ - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/RealWorld/parserharness.ts', - '/tmp/ts-repo/tests/cases/conformance/fixSignatureCaching.ts', - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/parserRealSource7.ts', - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/RealWorld/parserindenter.ts', -]; - -function time(parse: (s: string) => unknown, code: string, n: number): number { - const start = process.hrtime.bigint(); - for (let i = 0; i < n; i++) { try { parse(code); } catch {} } - return Number(process.hrtime.bigint() - start) / 1e6 / n; -} - -// Warm up both engines (JIT to steady state) before timing. -function warm(parse: (s: string) => unknown, code: string) { - for (let i = 0; i < 10; i++) { try { parse(code); } catch {} } -} - -console.log(`N=${N} timed runs/engine/file (lexer is shared → delta = parser layer)\n`); -console.log('file KB interp ms emit ms speedup'); -console.log('-'.repeat(74)); - -let totInterp = 0, totEmit = 0; -for (const f of files) { - const code = readFileSync(f, 'utf-8'); - warm(oracle.parse, code); - warm(emitted.parse as (s: string) => unknown, code); - // Interleave a few rounds and take the best (min) to reduce GC/scheduler noise. - let interp = Infinity, emit = Infinity; - for (let r = 0; r < 5; r++) { - interp = Math.min(interp, time(oracle.parse, code, N)); - emit = Math.min(emit, time(emitted.parse as (s: string) => unknown, code, N)); - } - totInterp += interp; totEmit += emit; - const name = (f.split('/').pop() ?? '').padEnd(28); - const kb = (code.length / 1024).toFixed(0).padStart(4); - console.log(`${name}${kb} ${interp.toFixed(2).padStart(8)} ${emit.toFixed(2).padStart(7)} ${(interp / emit).toFixed(2)}x`); -} -console.log('-'.repeat(74)); -console.log(`${'AGGREGATE'.padEnd(28)} ${totInterp.toFixed(2).padStart(8)} ${totEmit.toFixed(2).padStart(7)} ${(totInterp / totEmit).toFixed(2)}x`); diff --git a/test/exec-trace.ts b/test/exec-trace.ts deleted file mode 100644 index 5942b90..0000000 --- a/test/exec-trace.ts +++ /dev/null @@ -1,232 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// exec-trace.ts — flattened execution-path tracer (standalone; debug-only). -// -// Prints the ACTUAL source executed between startTrace()/endTrace(), flattened across -// call boundaries, with the runtime value of each step and a cost tag — the basis for -// op-by-op review of whether each branch/op is minimal. -// -// It does NOT touch the production parser: it AST-instruments a copy (TS compiler API) -// so every statement-expression / initializer / return / condition records (line, text, -// value, cost) into a buffer gated on a global flag the markers flip. Markers in the real -// source (src/trace-markers.ts) are near-free no-ops; here they bound what gets printed. -// -// node test/exec-trace.ts # gen-parser.ts on "a + b" (entry Expr) -// node test/exec-trace.ts "" # custom input / entry rule -// node test/exec-trace.ts "" -// ───────────────────────────────────────────────────────────────────────────── -import ts from 'typescript'; -import { readFileSync, writeFileSync } from 'fs'; -import { dirname, resolve } from 'path'; -import { pathToFileURL } from 'url'; - -const REPO = resolve(import.meta.dirname, '..'); -const args = process.argv.slice(2); -const pos = args.filter(a => !a.startsWith('--')); -const input = pos[0] ?? 'a + b'; -const entry = pos[1] ?? 'Expr'; -const target = resolve(REPO, pos[2] ?? 'src/gen-parser.ts'); -// Optional spatial scope: only record ops on lines A..B (focus on a region without -// editing the source; the time-region startTrace/endTrace markers are the other knob). -const linesArg = args.find(a => a.startsWith('--lines=')); -const [LO, HI] = linesArg ? linesArg.slice(8).split('-').map(Number) : [0, Infinity]; - -// ── 1) cost classification (transform-time) ── -function costOf(node: ts.Node): string { - let cost = ''; - const scan = (n: ts.Node): void => { - if (cost) return; - if (ts.isFunctionExpression(n) || ts.isArrowFunction(n)) return; // don't peer into nested fns - if (ts.isNewExpression(n) || ts.isObjectLiteralExpression(n) || ts.isArrayLiteralExpression(n)) { cost = 'alloc'; return; } - if (ts.isCallExpression(n)) { - const callee = n.expression; - if (ts.isPropertyAccessExpression(callee) && /^(get|set|has|add|delete)$/.test(callee.name.text)) cost = `map.${callee.name.text}`; - else cost = 'call'; - return; - } - ts.forEachChild(n, scan); - }; - ts.forEachChild(node, scan); - if (!cost && (ts.isNewExpression(node) || ts.isObjectLiteralExpression(node) || ts.isArrayLiteralExpression(node))) cost = 'alloc'; - return cost; -} - -// ── 2) instrument ── -const source = readFileSync(target, 'utf-8'); -const sf = ts.createSourceFile(target, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); -const lineOf = (n: ts.Node) => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1; -const textOf = (n: ts.Node) => { - // For a declaration show `name = init` (drop the `: Type` annotation — pure noise). - let raw = ts.isVariableDeclaration(n) ? `${n.name.getText(sf)} = ${n.initializer ? n.initializer.getText(sf) : ''}` : n.getText(sf); - raw = raw.replace(/\s+/g, ' '); - return raw.length > 64 ? raw.slice(0, 63) + '…' : raw; -}; - -const transformer: ts.TransformerFactory = (context) => { - const f = context.factory; - const G = (m: string) => f.createPropertyAccessExpression(f.createIdentifier('globalThis'), m); - const num = (n: number) => f.createNumericLiteral(n); - const str = (s: string) => f.createStringLiteral(s); - // __recv(line, text, cost, expr) → records the value, returns expr unchanged (single eval). - const recv = (l: number, t: string, c: string, e: ts.Expression) => - f.createCallExpression(G('__recv'), undefined, [num(l), str(t), str(c), e]); - const recc = (l: number, t: string, e: ts.Expression) => - f.createCallExpression(G('__recc'), undefined, [num(l), str(t), e]); - const enterStmt = (l: number, name: string) => - f.createExpressionStatement(f.createCallExpression(G('__enter'), undefined, [num(l), str(name)])); - const exitStmt = (l: number) => - f.createExpressionStatement(f.createCallExpression(G('__exit'), undefined, [num(l)])); - - const isFnExpr = (e: ts.Expression) => ts.isArrowFunction(e) || ts.isFunctionExpression(e); - - const visit = (node: ts.Node): ts.Node => { - // capture original line/text BEFORE children are replaced - if (ts.isExpressionStatement(node)) { - const l = lineOf(node.expression), t = textOf(node.expression), c = costOf(node.expression); - const v = ts.visitEachChild(node, visit, context); - return f.updateExpressionStatement(v, recv(l, t, c, v.expression)); - } - if (ts.isVariableDeclaration(node) && node.initializer && !isFnExpr(node.initializer)) { - const l = lineOf(node), t = textOf(node), c = costOf(node.initializer); - const v = ts.visitEachChild(node, visit, context) as ts.VariableDeclaration; - return f.updateVariableDeclaration(v, v.name, v.exclamationToken, v.type, recv(l, t, c, v.initializer!)); - } - if (ts.isReturnStatement(node) && node.expression) { - const l = lineOf(node), t = textOf(node), c = costOf(node.expression); - const v = ts.visitEachChild(node, visit, context) as ts.ReturnStatement; - return f.updateReturnStatement(v, recv(l, t, c, v.expression!)); - } - if (ts.isIfStatement(node)) { - const l = lineOf(node.expression), t = textOf(node.expression); - const v = ts.visitEachChild(node, visit, context) as ts.IfStatement; - return f.updateIfStatement(v, recc(l, t, v.expression), v.thenStatement, v.elseStatement); - } - if (ts.isWhileStatement(node)) { - const l = lineOf(node.expression), t = textOf(node.expression); - const v = ts.visitEachChild(node, visit, context) as ts.WhileStatement; - return f.updateWhileStatement(v, recc(l, t, v.expression), v.statement); - } - // Frame boundaries (named function declarations — the parser's functions): enter marker - // + try/finally exit so every call is exactly one balanced ▸…◂ frame (early returns, - // throws, fall-through all hit the finally). Gated on the FN's line so a --lines scope - // drops a frame's enter+exit together (stays balanced). - if (ts.isFunctionDeclaration(node) && node.body && node.name) { - const l = lineOf(node), name = node.name.text; - const v = ts.visitEachChild(node, visit, context) as ts.FunctionDeclaration; - const tryFin = f.createTryStatement(f.createBlock(v.body!.statements, true), undefined, f.createBlock([exitStmt(l)], true)); - const body = f.createBlock([enterStmt(l, name), tryFin], true); - return f.updateFunctionDeclaration(v, v.modifiers, v.asteriskToken, v.name, v.typeParameters, v.parameters, v.type, body); - } - return ts.visitEachChild(node, visit, context); - }; - return (root) => ts.visitNode(root, visit) as ts.SourceFile; -}; - -const result = ts.transform(sf, [transformer]); -let printed = ts.createPrinter().printFile(result.transformed[0]); -// absolutize relative imports so the /tmp copy resolves the real sibling modules -printed = printed.replace(/from\s+(['"])(\.\.?\/[^'"]+)\1/g, (_m, q, p) => `from ${q}${resolve(dirname(target), p)}${q}`); - -const outPath = '/tmp/exec-trace-instrumented.ts'; -writeFileSync(outPath, printed); - -// ── 3) record runtime ── -type Ev = - | { kind: 'op'; l: number; t: string; c: string; v?: string } - | { kind: 'enter'; l: number; fn: string } - | { kind: 'exit'; l: number }; -const TRACE: Ev[] = []; -const g = globalThis as Record; -g.__REC = false; -g.__TRACE = TRACE; -function fmt(v: unknown): string { - if (v === null) return '∅'; - if (v === undefined) return '⊥'; - if (typeof v === 'boolean' || typeof v === 'number') return String(v); - if (typeof v === 'string') return v.length > 24 ? JSON.stringify(v.slice(0, 24)) + '…' : JSON.stringify(v); - if (Array.isArray(v)) return `[${v.length}]`; - if (v instanceof Map) return `Map(${v.size})`; - if (v instanceof Set) return `Set(${v.size})`; - if (typeof v === 'object') { - const o = v as Record; - if (o.tokenType !== undefined) return `${o.tokenType}@${o.offset}..${o.end}`; - if (o.rule !== undefined) return `node(${o.rule})`; - if ('type' in o && 'text' in o) return `${o.type || 'punct'}'${o.text}'`; - return `{${Object.keys(o).slice(0, 4).join(',')}}`; - } - if (typeof v === 'function') return 'fn'; - return String(v); -} -const on = (l: number) => g.__REC && l >= LO && l <= HI; -g.__recv = (l: number, t: string, c: string, v: unknown) => { if (on(l)) TRACE.push({ kind: 'op', l, t, c, v: fmt(v) }); return v; }; -g.__recc = (l: number, t: string, v: unknown) => { if (on(l)) TRACE.push({ kind: 'op', l, t, c: 'cond', v: fmt(v) }); return v; }; -g.__enter = (l: number, fn: string) => { if (on(l)) TRACE.push({ kind: 'enter', l, fn }); }; -g.__exit = (l: number) => { if (on(l)) TRACE.push({ kind: 'exit', l }); }; - -const mod = await import(pathToFileURL(outPath).href); -const grammar = (await import(pathToFileURL(resolve(REPO, 'typescript.ts')).href)).default; -const parser = mod.createParser(grammar); - -g.__REC = true; -let err: string | null = null; -try { parser.parse(input, entry); } catch (e) { err = (e as Error).message; } -g.__REC = false; - -// ── 4) build frame tree → group by DISTINCT execution path → render ── -const fileName = target.replace(REPO + '/', ''); -const ops = TRACE.filter((e): e is Extract => e.kind === 'op'); -const allocs = ops.filter(e => e.c === 'alloc').length; -const mapset = ops.filter(e => e.c.startsWith('map.')).length; -const conds = ops.filter(e => e.c === 'cond').length; -const calls = TRACE.filter(e => e.kind === 'enter').length; - -type OpN = { op: true; l: number; t: string; c: string; vs: (string | undefined)[]; n: number }; -type FrameN = { op: false; fn: string; l: number; items: Node[]; n: number }; -type Node = OpN | FrameN; - -// events → tree (enter pushes a frame, exit pops; try/finally keeps them balanced) -const root: FrameN = { op: false, fn: '(root)', l: 0, items: [], n: 1 }; -const stack: FrameN[] = [root]; -for (const e of TRACE) { - const top = stack[stack.length - 1]; - if (e.kind === 'enter') { const fr: FrameN = { op: false, fn: e.fn, l: e.l, items: [], n: 1 }; top.items.push(fr); stack.push(fr); } - else if (e.kind === 'exit') { if (stack.length > 1) stack.pop(); } - else top.items.push({ op: true, l: e.l, t: e.t, c: e.c, vs: [e.v], n: 1 }); -} - -// path signature: same path ⇔ same op/frame structure (values excluded — they aggregate) -const sig = (n: Node): string => n.op ? `o${n.l}:${n.t}` : `f${n.fn}:${n.l}(${n.items.map(sig).join(',')})`; -// merge same-signature nodes: concat op value-lists, recurse frames position-wise (same sig ⇒ same shape) -function merge(ns: Node[]): Node { - const total = ns.reduce((s, x) => s + x.n, 0); - if (ns[0].op) { const f = ns[0]; return { op: true, l: f.l, t: f.t, c: f.c, vs: (ns as OpN[]).flatMap(x => x.vs), n: total }; } - const f0 = ns[0] as FrameN; - return { op: false, fn: f0.fn, l: f0.l, items: f0.items.map((_, i) => merge((ns as FrameN[]).map(x => x.items[i]))), n: total }; -} -// group a sibling list by signature (first-occurrence order), then recurse into frames -function group(items: Node[]): Node[] { - const order: string[] = []; const by = new Map(); - for (const it of items) { const s = sig(it); if (!by.has(s)) { by.set(s, []); order.push(s); } by.get(s)!.push(it); } - return order.map(s => { const m = merge(by.get(s)!); if (!m.op) m.items = group(m.items); return m; }); -} - -const valDist = (vs: (string | undefined)[]) => { - if (vs[0] === undefined) return ''; - const c = new Map(); - for (const v of vs) c.set(v!, (c.get(v!) ?? 0) + 1); - return c.size === 1 ? ` → ${[...c.keys()][0]}` : ` → ${[...c].map(([v, k]) => `${v}×${k}`).join(' ')}`; -}; -const out: string[] = []; -const render = (ns: Node[], d: number) => { - const ind = ' '.repeat(d); - for (const n of ns) { - const rep = n.n > 1 ? ` ×${n.n}` : ''; - if (n.op) out.push(`${ind}${n.l} ${n.t}${valDist(n.vs)}${n.c && n.c !== 'cond' ? ` {${n.c}}` : ''}${rep}`); - else { out.push(`${ind}▸${n.fn}${rep}`); render(n.items, d + 1); } - } -}; -render(group(root.items), 0); - -console.log(`═ ${fileName} · ${JSON.stringify(input)} (entry ${entry}) ═`); -console.log(`summary: ${TRACE.length} ops · ${calls} calls · ${allocs} alloc · ${mapset} map/set · ${conds} cond${err ? ` · ERROR ${err}` : ''}`); -console.log(`fmt: [indent=call depth] line source → value(×N if varies) {cost} · ▸fn ×N = N calls, one DISTINCT path (bare line=${fileName})`); -console.log(out.join('\n')); diff --git a/test/html-embed-js.ts b/test/html-embed-js.ts index 3a73088..794c80a 100644 --- a/test/html-embed-js.ts +++ b/test/html-embed-js.ts @@ -131,6 +131,10 @@ if (existsSync(OFFICIAL)) { console.log(`\nBoundary vs official (both embedding Monogram's JS), ${block.repeat(n)} -`; -const htmlDocs = [50, 200, 800].map((kb) => { - const n = Math.round((kb * 1024) / block.length); - const code = doc(n); - return { name: `synthetic-${(code.length / 1024).toFixed(0)}kb.html`, code }; -}); - -console.log('\n── HTML: emitted html.ts vs parse5 (WHATWG reference) ──'); -console.log('doc KB mono ms parse5 ms mono/parse5'); -console.log('-'.repeat(72)); -// Sanity: both sides must actually parse the synthetic corpus. -for (const { name, code } of htmlDocs) { monoHtml.parse(code); parse5.parseFragment(code); } -bench(htmlDocs, (c) => monoHtml.parse(c), (c) => parse5.parseFragment(c)); diff --git a/test/profile-vs-tsc.mjs b/test/profile-vs-tsc.mjs deleted file mode 100644 index 61d7382..0000000 --- a/test/profile-vs-tsc.mjs +++ /dev/null @@ -1,115 +0,0 @@ -// CPU-profile the EMITTED Monogram parser vs the official tsc parser on the PR#4 bench -// files: per-file timing ratio, then a V8 profile of each engine with top self-time -// tables + layer shares (lexer / parser / GC). -// node test/profile-vs-tsc.mjs # timing table + both profiles -// node --no-turbo-inlining test/profile-vs-tsc.mjs # honest per-fn attribution -// Writes /tmp/mono.cpuprofile + /tmp/tsc.cpuprofile (inspect via test/profile-lines.mjs). -import { Session } from 'node:inspector'; -import { readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const ts = (await import(REPO + '/node_modules/typescript/lib/typescript.js')).default; -const { emitParser, jsTarget } = await import(REPO + '/src/emit.ts'); -const grammar = (await import(REPO + '/typescript.ts')).default; - -writeFileSync('/tmp/emitted-current.mts', emitParser(grammar, jsTarget)); -const emitted = await import('/tmp/emitted-current.mts?v=' + Date.now()); - -const paths = [ - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/RealWorld/parserharness.ts', - '/tmp/ts-repo/tests/cases/conformance/fixSignatureCaching.ts', - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/parserRealSource7.ts', - '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/RealWorld/parserindenter.ts', -]; -const files = paths.map((p) => ({ name: p.split('/').pop(), code: readFileSync(p, 'utf-8') })); - -const mono = (code) => emitted.parse(code); -const tscF = (code) => ts.createSourceFile('f.ts', code, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); -const tscT = (code) => ts.createSourceFile('f.ts', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - -function time(fn, code, n) { - const s = process.hrtime.bigint(); - for (let i = 0; i < n; i++) { try { fn(code); } catch {} } - return Number(process.hrtime.bigint() - s) / 1e6 / n; -} - -for (const { code } of files) for (let i = 0; i < 10; i++) { try { mono(code); tscF(code); tscT(code); } catch {} } -console.log('file KB mono ms tsc(f) ms tsc(t) ms mono/tsc(f) mono/tsc(t)'); -console.log('-'.repeat(100)); -let tm = 0, tf = 0, tt = 0; -for (const { name, code } of files) { - let m = Infinity, f = Infinity, t = Infinity; - for (let r = 0; r < 5; r++) { - m = Math.min(m, time(mono, code, 20)); - f = Math.min(f, time(tscF, code, 20)); - t = Math.min(t, time(tscT, code, 20)); - } - tm += m; tf += f; tt += t; - console.log(`${name.padEnd(28)}${(code.length / 1024).toFixed(0).padStart(4)} ${m.toFixed(2).padStart(7)} ${f.toFixed(2).padStart(8)} ${t.toFixed(2).padStart(8)} ${(m / f).toFixed(2).padStart(6)}x ${(m / t).toFixed(2).padStart(5)}x`); -} -console.log('-'.repeat(100)); -console.log(`${'AGGREGATE'.padEnd(28)} ${tm.toFixed(2).padStart(7)} ${tf.toFixed(2).padStart(8)} ${tt.toFixed(2).padStart(8)} ${(tm / tf).toFixed(2).padStart(6)}x ${(tm / tt).toFixed(2).padStart(5)}x`); - -const session = new Session(); -session.connect(); -const post = (method, params) => new Promise((res, rej) => session.post(method, params, (e, r) => (e ? rej(e) : res(r)))); -await post('Profiler.enable'); -await post('Profiler.setSamplingInterval', { interval: 100 }); - -async function profileEngine(fn, seconds) { - for (const { code } of files) for (let i = 0; i < 5; i++) { try { fn(code); } catch {} } - await post('Profiler.start'); - const s = process.hrtime.bigint(); - let rounds = 0; - while (Number(process.hrtime.bigint() - s) / 1e9 < seconds) { - for (const { code } of files) { try { fn(code); } catch {} } - rounds++; - } - const { profile } = await post('Profiler.stop'); - return { profile, rounds }; -} - -function analyze(tag, profile, lexerRoots) { - const byId = new Map(profile.nodes.map((n) => [n.id, n])); - const childOf = new Map(); - for (const n of profile.nodes) for (const c of n.children ?? []) childOf.set(c, n.id); - const isLexerRoot = (n) => lexerRoots.test(n.callFrame.functionName || ''); - const lexerFlag = new Map(); - const flagOf = (id) => { - if (lexerFlag.has(id)) return lexerFlag.get(id); - const n = byId.get(id); - const v = isLexerRoot(n) || (childOf.has(id) ? flagOf(childOf.get(id)) : false); - lexerFlag.set(id, v); - return v; - }; - let total = 0, gc = 0, lexer = 0, idle = 0; - const byFn = new Map(); - for (const n of profile.nodes) { - const hc = n.hitCount || 0; - if (!hc) continue; - const fn = n.callFrame.functionName || '(anonymous)'; - if (fn === '(idle)' || fn === '(program)') { idle += hc; continue; } - total += hc; - if (fn === '(garbage collector)') { gc += hc; continue; } - if (flagOf(n.id)) lexer += hc; - const file = (n.callFrame.url || '').split('/').pop() || '(native)'; - byFn.set(`${fn} @${file}:${n.callFrame.lineNumber + 1}`, (byFn.get(`${fn} @${file}:${n.callFrame.lineNumber + 1}`) || 0) + hc); - } - const parser = total - gc - lexer; - console.log(`\n══ ${tag} ══ ${total} samples (idle/program excluded: ${idle})`); - console.log(` layers: lexer ${(100 * lexer / total).toFixed(1)}% parser ${(100 * parser / total).toFixed(1)}% GC ${(100 * gc / total).toFixed(1)}%`); - console.log(' self% samples function'); - for (const [k, v] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25)) - console.log(` ${(100 * v / total).toFixed(1).padStart(5)} ${String(v).padStart(7)} ${k}`); -} - -const pm = await profileEngine(mono, 4); -const pf = await profileEngine(tscF, 4); -writeFileSync('/tmp/mono.cpuprofile', JSON.stringify(pm.profile)); -writeFileSync('/tmp/tsc.cpuprofile', JSON.stringify(pf.profile)); - -analyze(`Monogram emitted (${pm.rounds} rounds)`, pm.profile, /^tokenize$/); -analyze(`tsc setParentNodes=false (${pf.rounds} rounds)`, pf.profile, /^(scan|reScan\w+)$/); -console.log('\nwrote /tmp/mono.cpuprofile /tmp/tsc.cpuprofile'); diff --git a/test/sanity-check.ts b/test/sanity-check.ts deleted file mode 100644 index c3aee4f..0000000 --- a/test/sanity-check.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createParser } from '../src/gen-parser.ts'; - -const grammar = (await import('../typescript.ts')).default; -const { parse } = createParser(grammar); - -// Sanity checks for common syntax -const tests = [ - 'var x = 1;', - 'function foo() {}', - 'class C { x: number = 1; m() { return this.x; } }', - 'type Foo = T extends string ? T : never;', - 'interface I { x: number; m(): void; }', - 'async function* gen() { yield 1; yield* other(); }', - 'const c = { x: 1, m() {}, get a() { return 1; } };', - '() => 1', - 'a?.b?.[c]?.()', - '`hello ${name}`', - `type T = \`\${infer U}\`;`, - '@dec class C { @prop x: number; m(@d arg: T) {} }', - 'import { a, b as c } from "m";', - 'export default function() {}', - 'export type { T } from "m";', -]; - -let passed = 0; -for (const code of tests) { - try { - parse(code); - passed++; - } catch (e: any) { - console.log(`FAIL: ${code}`); - console.log(` ${e.message.slice(0, 100)}`); - } -} -console.log(`${passed}/${tests.length} basic tests pass`); -process.exit(passed === tests.length ? 0 : 1); diff --git a/test/snapshot.ts b/test/snapshot.ts deleted file mode 100644 index 5c2754f..0000000 --- a/test/snapshot.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Snapshot/diff harness: tracks exactly which files pass, so grammar changes -// can be evaluated for regressions (pass->fail) as well as wins (fail->pass). -import { createParser } from '../src/gen-parser.ts'; -import { readdir, writeFile, readFile } from 'fs/promises'; -import { readFileSync, existsSync } from 'fs'; -import { join } from 'path'; - -const grammar = (await import('../typescript.ts')).default; -const { parse } = createParser(grammar); -const baseDir = '/tmp/ts-repo/tests/cases/conformance'; -const SNAP = '/tmp/pass-snapshot.json'; - -async function getAllTsFiles(dir: string): Promise { - const files: string[] = []; - for (const entry of await readdir(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) files.push(...await getAllTsFiles(full)); - else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) files.push(full); - } - return files; -} - -const files = (await getAllTsFiles(baseDir)).sort(); -const passing = new Set(); -for (const file of files) { - const code = readFileSync(file, 'utf-8'); - try { parse(code); passing.add(file.replace(baseDir + '/', '')); } catch {} -} - -console.log(`${passing.size}/${files.length} passed (${(passing.size / files.length * 100).toFixed(1)}%)`); - -const mode = process.argv[2]; -if (mode === 'save') { - await writeFile(SNAP, JSON.stringify([...passing].sort(), null, 0)); - console.log(`Saved baseline snapshot (${passing.size} passing) to ${SNAP}`); -} else { - if (!existsSync(SNAP)) { console.log('No baseline snapshot. Run with "save" first.'); process.exit(0); } - const base = new Set(JSON.parse(await readFile(SNAP, 'utf-8'))); - const regressions = [...base].filter(f => !passing.has(f)).sort(); - const wins = [...passing].filter(f => !base.has(f)).sort(); - console.log(`\nBaseline: ${base.size} passing`); - console.log(`Net delta: ${passing.size - base.size >= 0 ? '+' : ''}${passing.size - base.size}`); - console.log(`\nREGRESSIONS (pass->fail): ${regressions.length}`); - for (const f of regressions) console.log(` - ${f}`); - console.log(`\nWINS (fail->pass): ${wins.length}`); - for (const f of wins) console.log(` + ${f}`); -} diff --git a/test/token-dfa-verify.ts b/test/token-dfa-verify.ts deleted file mode 100644 index a86f6c8..0000000 --- a/test/token-dfa-verify.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Correctness + speed gate for token-dfa.ts: for every TS token whose pattern compiles -// to a DFA, the DFA's match length must equal the token's sticky-regex match length at -// EVERY position of the corpus (byte-identical), and we measure the per-token speedup. -// -// node test/token-dfa-verify.ts -import { compileTokenDfa } from '../src/token-dfa.ts'; -import { tokenPatternSource } from '../src/token-pattern.ts'; -import { readFileSync, readdirSync } from 'fs'; -import { join } from 'path'; - -const grammar = (await import('../typescript.ts')).default; - -const base = '/tmp/ts-repo/tests/cases/conformance'; -function walk(d: string): string[] { - const o: string[] = []; - for (const e of readdirSync(d, { withFileTypes: true })) { - const f = join(d, e.name); - if (e.isDirectory()) o.push(...walk(f)); - else if (e.name.endsWith('.ts') && !e.name.endsWith('.d.ts')) o.push(f); - } - return o; -} -const files = walk(base).sort().filter((_, i) => i % 11 === 0); // ~stride sample -const sources = files.map(f => { try { return readFileSync(f, 'utf-8'); } catch { return ''; } }).filter(Boolean); -const totalChars = sources.reduce((a, s) => a + s.length, 0); - -// Tokens the per-position lexer loop actually runs through a regex (skip template). -const tokens = grammar.tokens.filter(t => !t.template); - -console.log(`tokens: ${tokens.length} · corpus sample: ${sources.length} files, ${(totalChars / 1024).toFixed(0)} KB\n`); -console.log('token DFA? positions mism regex ms dfa ms speedup'); -console.log('-'.repeat(78)); - -let totalMism = 0, compiled = 0, fellBack = 0; -for (const t of tokens) { - let src: string; - try { src = tokenPatternSource(t); } catch { src = ''; } - const dfa = compileTokenDfa(t.pattern); - if (!dfa) { - fellBack++; - console.log(`${t.name.padEnd(16)} regex ${'—'.padStart(10)} ${'—'.padStart(4)} (unsupported → falls back to regex)`); - continue; - } - compiled++; - const re = new RegExp(`(?:${src})`, 'y'); - - // Correctness: at every position, DFA length === regex length. - let mism = 0, positions = 0; - for (const s of sources) { - for (let pos = 0; pos < s.length; pos++) { - re.lastIndex = pos; - const m = re.exec(s); - const reLen = m ? m[0].length : -1; - const dfaLen = dfa.match(s, pos); - positions++; - if (reLen !== dfaLen) { - if (mism < 3) console.log(` MISMATCH @${pos} re=${reLen} dfa=${dfaLen} ctx=${JSON.stringify(s.slice(pos, pos + 24))}`); - mism++; - } - } - } - totalMism += mism; - - // Speed: scan each source once via regex vs DFA (best-of-5). - const timeRe = () => { let acc = 0; for (const s of sources) for (let p = 0; p < s.length; p++) { re.lastIndex = p; const m = re.exec(s); acc += m ? m[0].length : 0; } return acc; }; - const timeDfa = () => { let acc = 0; for (const s of sources) for (let p = 0; p < s.length; p++) { const l = dfa.match(s, p); acc += l > 0 ? l : 0; } return acc; }; - const best = (fn: () => number) => { for (let w = 0; w < 2; w++) fn(); let b = Infinity; for (let r = 0; r < 5; r++) { const t0 = process.hrtime.bigint(); fn(); const dt = Number(process.hrtime.bigint() - t0) / 1e6; if (dt < b) b = dt; } return b; }; - const reMs = best(timeRe), dfaMs = best(timeDfa); - console.log(`${t.name.padEnd(16)} dfa ${String(positions).padStart(10)} ${String(mism).padStart(4)} ${reMs.toFixed(1).padStart(8)} ${dfaMs.toFixed(1).padStart(6)} ${(reMs / dfaMs).toFixed(2)}×`); -} - -console.log('-'.repeat(78)); -console.log(`compiled to DFA: ${compiled} · fell back to regex: ${fellBack} · TOTAL mismatches: ${totalMism}`); -process.exit(totalMism === 0 ? 0 : 1); diff --git a/test/treesitter-smoke.ts b/test/treesitter-smoke.ts deleted file mode 100644 index bba77ff..0000000 --- a/test/treesitter-smoke.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Smoke test for gen-treesitter: generate a tree-sitter package from the -// TypeScript grammar and structurally sanity-check the three artifacts -// (grammar.js, queries/highlights.scm, src/scanner.c). -// -// Run with: node test/treesitter-smoke.ts -// -// If the `tree-sitter` CLI is installed, this ALSO tries to compile the generated -// grammar.js and reports the result — but never fails the suite on a missing -// toolchain (the task says: validate structurally, don't block on installs). -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import vm from 'node:vm'; -import { generateTreeSitter } from '../src/gen-treesitter.ts'; - -const grammar = (await import('../typescript.ts')).default; -const yamlGrammar = (await import('../yaml.ts')).default; - -let ok = 0, fail = 0; -const check = (label: string, cond: boolean) => { - if (cond) ok++; - else { fail++; console.log(' ✗', label); } -}; - -const { grammarJs, highlightsScm, scannerC, externalTokens } = generateTreeSitter(grammar, 'typescript'); - -// ── grammar.js ────────────────────────────────────────────────────────────── -check('grammar.js is non-empty', grammarJs.length > 500); -check('grammar.js calls grammar({...})', /module\.exports\s*=\s*grammar\(\{/.test(grammarJs)); -check('grammar.js has a name field', /name:\s*"typescript"/.test(grammarJs)); -check('grammar.js has a rules: block', /rules:\s*\{/.test(grammarJs)); -check('grammar.js declares extras (whitespace + comments)', /extras:\s*\$ =>/.test(grammarJs)); -check('grammar.js declares word (identifier token)', /word:\s*\$ =>\s*\$\.\w+/.test(grammarJs)); -check('grammar.js references the entry rule (program)', /\bprogram:\s*\$ =>/.test(grammarJs)); -check('grammar.js uses seq()', grammarJs.includes('seq(')); -check('grammar.js uses choice()', grammarJs.includes('choice(')); -check('grammar.js uses optional()', grammarJs.includes('optional(')); -check('grammar.js uses repeat()', grammarJs.includes('repeat(')); -check('grammar.js maps prec.left for left-assoc operators', grammarJs.includes('prec.left(')); -check('grammar.js maps prec.right for right-assoc operators', grammarJs.includes('prec.right(')); -check('grammar.js declares conflicts (generics/arrow ambiguities)', /conflicts:\s*\$ =>/.test(grammarJs)); -check('grammar.js declares externals (scanner regex token)', /externals:\s*\$ =>/.test(grammarJs)); -check('grammar.js maps operator into a field', grammarJs.includes("field('operator'")); -check('grammar.js wraps declaration names in a name field', grammarJs.includes("field('name'")); - -// Balanced parens/braces — a crude validity check on the generated JS. -function balanced(src: string, open: string, close: string): boolean { - let depth = 0, inStr: string | null = null, inRegex = false, prev = ''; - for (let i = 0; i < src.length; i++) { - const c = src[i]; - if (inStr) { if (c === inStr && prev !== '\\') inStr = null; prev = c; continue; } - if (inRegex) { if (c === '/' && prev !== '\\') inRegex = false; prev = c; continue; } - if (c === '"' || c === "'") { inStr = c; prev = c; continue; } - // crude regex-literal skip: `/` preceded by `(` or `,` or whitespace - if (c === '/' && /[(,\s]/.test(prev)) { inRegex = true; prev = c; continue; } - if (c === open) depth++; - else if (c === close) { depth--; if (depth < 0) return false; } - prev = c; - } - return depth === 0; -} -check('grammar.js has balanced parentheses', balanced(grammarJs, '(', ')')); -check('grammar.js has balanced braces', balanced(grammarJs, '{', '}')); - -// Strongest validity check: the generated grammar.js must PARSE and EXECUTE as -// real JS through stubbed tree-sitter DSL globals. This catches malformed regex -// literals, stray tokens, and unresolved references that brace-counting misses. -function grammarExecutes(src: string): { ok: boolean; ruleCount: number; err?: string } { - const mk = (name: string) => (...args: unknown[]) => ({ type: name, args }); - const prec = Object.assign((...a: unknown[]) => mk('prec')(...a), { - left: (...a: unknown[]) => mk('prec.left')(...a), - right: (...a: unknown[]) => mk('prec.right')(...a), - }); - const sandbox: Record = { - module: { exports: {} as { rules?: Record } }, - grammar: (def: any) => { - const $ = new Proxy({}, { get: (_t, k) => ({ type: 'ref', name: String(k) }) }); - for (const fn of Object.values(def.rules)) (fn as (x: unknown) => unknown)($); - for (const k of ['extras', 'word', 'externals', 'conflicts']) if (def[k]) def[k]($); - return def; - }, - seq: mk('seq'), choice: mk('choice'), optional: mk('optional'), - repeat: mk('repeat'), repeat1: mk('repeat1'), token: mk('token'), - field: mk('field'), blank: mk('blank'), prec, - }; - vm.createContext(sandbox); - try { - new vm.Script(src, { filename: 'grammar.js' }); - vm.runInContext(src, sandbox, { filename: 'grammar.js' }); - const exp = (sandbox.module as { exports: { rules?: Record } }).exports; - return { ok: true, ruleCount: Object.keys(exp.rules ?? {}).length }; - } catch (e: any) { - return { ok: false, ruleCount: 0, err: e.message }; - } -} -const exec = grammarExecutes(grammarJs); -check(`grammar.js parses & executes as JS${exec.err ? ' (' + exec.err + ')' : ''}`, exec.ok); -check(`grammar.js exposes all rules after execution (${exec.ruleCount})`, exec.ruleCount >= grammar.rules.length); - -const yamlGrammarJs = generateTreeSitter(yamlGrammar, 'yaml').grammarJs; -const yamlExec = grammarExecutes(yamlGrammarJs); -const emptyRegexTokenCall = 'token(/' + '/)'; -check('YAML grammar.js never emits empty regex token call', !yamlGrammarJs.includes(emptyRegexTokenCall)); -check(`YAML grammar.js parses & executes as JS${yamlExec.err ? ' (' + yamlExec.err + ')' : ''}`, yamlExec.ok); - -// Every rule and non-scanner token should appear as a rule entry. -const ruleNamesSnake = grammar.rules.map(r => r.name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()); -const missingRules = ruleNamesSnake.filter(n => !new RegExp(`\\b${n}:\\s*\\$ =>`).test(grammarJs)); -check(`all ${grammar.rules.length} rules emitted (missing: ${missingRules.join(',') || 'none'})`, missingRules.length === 0); - -// ── highlights.scm ──────────────────────────────────────────────────────────── -check('highlights.scm is non-empty', highlightsScm.length > 200); -check('highlights.scm has @keyword captures', highlightsScm.includes('@keyword')); -check('highlights.scm has @string captures', highlightsScm.includes('@string')); -check('highlights.scm has @number captures', highlightsScm.includes('@number')); -check('highlights.scm has @operator captures', highlightsScm.includes('@operator')); -check('highlights.scm has @type captures', highlightsScm.includes('@type')); -check('highlights.scm has @function captures', highlightsScm.includes('@function')); -check('highlights.scm has @comment captures', highlightsScm.includes('@comment')); -check('highlights.scm has @variable fallback', highlightsScm.includes('@variable')); -check('highlights.scm has @constant.builtin (true/false/null)', highlightsScm.includes('@constant.builtin')); -check('highlights.scm has @type.builtin (primitives)', highlightsScm.includes('@type.builtin')); -check('highlights.scm captures specific keywords (class)', highlightsScm.includes('"class"')); -check('highlights.scm uses list form [ … ] for grouped literals', /\[\s*\n/.test(highlightsScm)); -check('highlights.scm queries declaration names via the name: field', /name:\s*\(\w+\)\s*@/.test(highlightsScm)); -check('highlights.scm uses only standard predicates (#any-of?/#eq?/#match?)', - (highlightsScm.match(/#[\w-]+\?/g) ?? []).every(p => ['#any-of?', '#eq?', '#match?', '#not-eq?', '#set!'].includes(p))); -// Balanced brackets in the query file. -check('highlights.scm has balanced brackets', balanced(highlightsScm, '[', ']')); -check('highlights.scm has balanced parens', balanced(highlightsScm, '(', ')')); - -// Count captures — should be plentiful. -const captureCount = (highlightsScm.match(/@[\w.]+/g) ?? []).length; -check(`highlights.scm has many captures (${captureCount})`, captureCount > 30); - -// ── scanner.c ───────────────────────────────────────────────────────────────── -check('scanner.c is non-empty', scannerC.length > 300); -check('scanner.c includes parser.h', scannerC.includes('#include "tree_sitter/parser.h"')); -check('scanner.c defines the 5 required entry points', - scannerC.includes('_external_scanner_create') && - scannerC.includes('_external_scanner_destroy') && - scannerC.includes('_external_scanner_serialize') && - scannerC.includes('_external_scanner_deserialize') && - scannerC.includes('_external_scanner_scan')); -check('scanner.c declares a TokenType enum', scannerC.includes('enum TokenType')); -check('scanner.c implements regex-literal scan', scannerC.includes('scan_regex')); -check('scanner.c derives regex flag chars from the token', /flags = "[a-z]+"/i.test(scannerC)); -check('scanner.c references the external token name', externalTokens.length > 0 && scannerC.includes(externalTokens[0].toUpperCase())); -check('scanner.c documents the template stub with derived delimiters', scannerC.includes('interpOpen = "${"')); -check('externalTokens reported', externalTokens.length >= 1); - -// ── Optional: try the real tree-sitter CLI if present ────────────────────────── -function hasTreeSitter(): boolean { - try { execFileSync('tree-sitter', ['--version'], { stdio: 'ignore' }); return true; } - catch { return false; } -} - -if (hasTreeSitter()) { - console.log('\ntree-sitter CLI found — attempting to generate the parser…'); - try { - const dir = mkdtempSync(join(tmpdir(), 'monogram-ts-')); - mkdirSync(join(dir, 'src'), { recursive: true }); - mkdirSync(join(dir, 'queries'), { recursive: true }); - writeFileSync(join(dir, 'grammar.js'), grammarJs); - writeFileSync(join(dir, 'queries', 'highlights.scm'), highlightsScm); - writeFileSync(join(dir, 'src', 'scanner.c'), scannerC); - writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'tree-sitter-typescript-monogram', version: '0.0.0' }, null, 2)); - try { - const result = execFileSync('tree-sitter', ['generate'], { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); - console.log(' tree-sitter generate SUCCEEDED'); - if (result.trim()) console.log(' ' + result.trim().split('\n').join('\n ')); - } catch (e: any) { - console.log(' tree-sitter generate reported issues (expected for a derived grammar):'); - const msg = (e.stderr || e.stdout || e.message || '').toString(); - console.log(' ' + msg.trim().split('\n').slice(0, 12).join('\n ')); - } - console.log(` (artifacts written to ${dir})`); - } catch (e: any) { - console.log(' could not run CLI test:', e.message); - } -} else { - console.log('\ntree-sitter CLI not found — structural validation only (not a failure).'); -} - -console.log(`\n${ok}/${ok + fail} structural checks pass`); -process.exit(fail === 0 ? 0 : 1); diff --git a/test/treesitter-yaml-bench.ts b/test/treesitter-yaml-bench.ts deleted file mode 100644 index db6190d..0000000 --- a/test/treesitter-yaml-bench.ts +++ /dev/null @@ -1,45 +0,0 @@ -// YAML tree-sitter accuracy bench (issue #3): how many VALID yaml-test-suite inputs the DERIVED -// YAML tree-sitter parses with no ERROR/MISSING node. "Valid" = the `yaml` package accepts the input -// (so a failure is the tree-sitter grammar's, not a malformed sample). The corpus is extracted from -// the yaml-test-suite src meta-files exactly like test/src-coverage-yaml.ts. -// -// git clone --depth 1 https://github.com/yaml/yaml-test-suite /tmp/yaml-test-suite -// cd tree-sitter/yaml && npx tree-sitter generate && npx tree-sitter build --wasm . -// node test/treesitter-yaml-bench.ts -import { readdirSync, readFileSync, existsSync } from 'node:fs'; -import { parse as yamlParse, parseAllDocuments } from 'yaml'; - -const WASM = 'tree-sitter/yaml/tree-sitter-yaml.wasm'; -const SUITE = '/tmp/yaml-test-suite/src'; -if (!existsSync(WASM)) { console.error(`missing ${WASM} — run: (cd tree-sitter/yaml && npx tree-sitter build --wasm .)`); process.exit(1); } -if (!existsSync(SUITE)) { console.error(`missing ${SUITE} — git clone --depth 1 https://github.com/yaml/yaml-test-suite /tmp/yaml-test-suite`); process.exit(1); } - -const { Parser, Language } = await import('web-tree-sitter'); -await Parser.init(); -const lang = await Language.load(WASM); -const parser = new Parser(); -parser.setLanguage(lang); - -// Decode the suite's visible-whitespace markers to real bytes (same as src-coverage-yaml). -const decode = (s: string) => s.replace(/␣/g, ' ').replace(/—*»/g, '\t').replace(/[↵∎]/g, ''); -const corpus: string[] = []; -for (const f of readdirSync(SUITE).filter((n) => n.endsWith('.yaml'))) { - try { - const meta = yamlParse(readFileSync(`${SUITE}/${f}`, 'utf8')); - for (const t of (Array.isArray(meta) ? meta : [meta])) if (t && typeof t.yaml === 'string') corpus.push(decode(t.yaml)); - } catch { /* skip meta-files that don't round-trip */ } -} -const valid = corpus.filter((c) => { try { return parseAllDocuments(c).every((d: any) => d.errors.length === 0); } catch { return false; } }); - -function hasError(node: any): boolean { - if (node.type === 'ERROR' || node.isError === true || node.isMissing === true) return true; - for (let i = 0; i < node.childCount; i++) { const c = node.child(i); if (c && hasError(c)) return true; } - return false; -} - -let ok = 0; -for (const c of valid) { const tree = parser.parse(c); if (tree && !hasError(tree.rootNode)) ok++; } -const pct = ((100 * ok) / valid.length).toFixed(1); -console.log(`YAML corpus: ${corpus.length} inputs (${valid.length} valid per the yaml package).`); -console.log(`YAML tree-sitter accuracy: ${ok}/${valid.length} valid inputs parse ERROR-free (${pct}%).`); -console.log(`##TSYAML## ${JSON.stringify({ name: 'YAML', engine: 'tree-sitter (derived)', valid: valid.length, errorFree: ok, pct: Number(pct) })}`); diff --git a/test/tsgo-parsebench/main.go b/test/tsgo-parsebench/main.go deleted file mode 100644 index 792e942..0000000 --- a/test/tsgo-parsebench/main.go +++ /dev/null @@ -1,43 +0,0 @@ -// Parse-only self-bench for test/profile-portable-peers.mjs (stdin → N iterations → ms/iter). -// Built with a go.mod that `replace`s github.com/microsoft/typescript-go → $TSGO_REPO. -package main - -import ( - "fmt" - "io" - "os" - "strconv" - "time" - - "github.com/microsoft/typescript-go/internal/ast" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/parser" -) - -func parse(src string) { - _ = parser.ParseSourceFile(ast.SourceFileParseOptions{ - FileName: "/bench.js", - Path: "/bench.js", - }, src, core.ScriptKindJS) -} - -func main() { - data, _ := io.ReadAll(os.Stdin) - src := string(data) - if len(os.Args) > 1 { - iters, err := strconv.Atoi(os.Args[1]) - if err != nil || iters <= 0 { - os.Exit(2) - } - for i := 0; i < 3; i++ { - parse(src) - } - t0 := time.Now() - for i := 0; i < iters; i++ { - parse(src) - } - fmt.Printf("%.4f\n", float64(time.Since(t0).Nanoseconds())/1e6/float64(iters)) - return - } - parse(src) -} diff --git a/test/verify-rejects.ts b/test/verify-rejects.ts deleted file mode 100644 index bc97765..0000000 --- a/test/verify-rejects.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Rigor check for the "error-test" bucket: failing a file that TS also rejects -// only counts as *correct* if we reject for the RIGHT reason — i.e. we reach at -// least as far as TS's first syntax error before bailing. If our parser chokes -// EARLIER than TS's first diagnostic, the code before that point is valid TS we -// silently can't parse — a hidden gap hiding behind the "intentional error" label. -import { createParser } from '../src/gen-parser.ts'; -import { readdir } from 'fs/promises'; -import { readFileSync } from 'fs'; -import { join } from 'path'; -import ts from 'typescript'; - -const grammar = (await import('../typescript.ts')).default; -const { parse } = createParser(grammar); -const baseDir = '/tmp/ts-repo/tests/cases/conformance'; -const SLACK = 8; // chars of tolerance (token boundary / trivia differences) - -async function allTsFiles(dir: string): Promise { - const out: string[] = []; - for (const e of await readdir(dir, { withFileTypes: true })) { - const full = join(dir, e.name); - if (e.isDirectory()) out.push(...await allTsFiles(full)); - else if (e.name.endsWith('.ts') && !e.name.endsWith('.d.ts')) out.push(full); - } - return out; -} - -const isMulti = (t: string) => /^\s*\/\/\s*@filename:/im.test(t); -// How far our parser actually reached: prefer the `farthest` backtracking mark, -// else the primary error offset. -function ourReach(msg: string): number | null { - const far = msg.match(/farthest: offset (\d+)/); - if (far) return +far[1]; - const at = msg.match(/offset (\d+)/); - return at ? +at[1] : null; -} - -const files = (await allTsFiles(baseDir)).sort(); -let agree = 0, early = 0, unknown = 0, oracleCrash = 0; -const earlies: { file: string; ourReach: number; tsFirst: number; ctx: string }[] = []; - -for (const file of files) { - const code = readFileSync(file, 'utf-8'); - if (isMulti(code)) continue; // single-file only (clean comparison) - let msg = ''; - try { parse(code); continue; } catch (e: any) { msg = e.message; } // only files we FAIL - - // the oracle itself can die on malformed input (e.g. a Debug.assert inside - // tsc's `await using` paths) — a crashed oracle has no verdict, count + skip - let sf; - try { - sf = ts.createSourceFile('t.ts', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - } catch { - oracleCrash++; - continue; - } - const diags = (sf as any).parseDiagnostics ?? []; - if (diags.length === 0) continue; // that's a REAL gap, handled elsewhere - - const tsFirst = Math.min(...diags.map((d: any) => d.start ?? Infinity)); - const reach = ourReach(msg); - if (reach == null) { unknown++; continue; } - - if (reach >= tsFirst - SLACK) { - agree++; // we got to (or past) TS's first error → right reason - } else { - early++; // we bailed on valid code BEFORE the error - earlies.push({ file: file.replace(baseDir + '/', ''), ourReach: reach, tsFirst, ctx: JSON.stringify(code.slice(Math.max(0, reach - 40), reach + 15)) }); - } -} - -console.log(`Single-file error-tests we fail: ${agree + early + unknown}`); -console.log(` AGREE (reach >= TS first error - ${SLACK}) : ${agree} ← rejected for the right reason`); -console.log(` EARLY (bail before TS's error) : ${early} ← hidden gap: valid code we can't parse`); -console.log(` UNKNOWN (no offset in our error) : ${unknown}`); -if (oracleCrash > 0) console.log(` ORACLE-CRASH (tsc threw; no verdict) : ${oracleCrash}`); -if (earlies.length) { - console.log(`\n===== EARLY (hidden gaps) =====`); - earlies.sort((a, b) => (a.tsFirst - a.ourReach) - (b.tsFirst - b.ourReach)); - for (const e of earlies) console.log(` ${e.file}\n ours@${e.ourReach} vs TS@${e.tsFirst} (gap ${e.tsFirst - e.ourReach}) near ${e.ctx}`); -}