Skip to content

Commit 94941b2

Browse files
committed
Close the last 2 valid-code conformance gaps → 100% valid-code coverage
Both remaining REAL gaps (valid TS that tsc parses cleanly but we failed) are fixed. Valid single-file coverage is now 3376/3376 (zero gaps); bidirectional agreement with tsc is 96.7% (the rest is over-acceptance). 1. Postfix closes the access tail (parserComputedPropertyName29). `[e] = id++` ⏎ `[e2]` was mis-parsed as `(id++)[e2]`, but member access can't attach to a postfix result (an UpdateExpression isn't a MemberExpression). After a true postfix binds, no access-tail LED (`.`/`[`/`(`/`?.`/`<T>`/tagged-template) may follow — binary/ternary ops still do. Tails are detected structurally (last item isn't a self-operand; connector isn't a word-keyword), so the engine stays language-agnostic. TS non-null `!` moves from prec-postfix to an access-tail LED `[$, '!']`, since it IS part of the LHS chain (`x!.y`, `x!()` are valid). Bonus: `a--.x`, `a++ ++`, `foo ++ ++` are now correctly rejected (they were over-accepts). 2. Mixfix operand re-bind for left-recursive rules (inferTypesWithExtends1). `(T extends infer U extends number ? 1 : 0)` failed: inside parens, an `infer …extends T` over-consumed the conditional's `?`. The re-bind that already fixed this for expressions lived only in parsePratt, but Type is left-recursive (parseLeftRec). Factor the mixfix detection into mixfixOf() and reuse matchMixfixLed from parseLeftRec via a contMixfix map. Add test/conformance-matrix.ts (the bidirectional confusion matrix vs tsc's parseDiagnostics — the real metric) and test/verify-rejects.ts (checks we reject at tsc's first-error offset). Regenerate artifacts; reframe the README around valid-code coverage + bidirectional agreement.
1 parent 9c30cfd commit 94941b2

9 files changed

Lines changed: 199 additions & 20 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ A TextMate grammar is a pile of regexes guessing at a language's structure. It's
1010

1111
Monogram inverts the dependency:
1212

13-
1. **Write the grammar, then prove it.** The grammar is executable. Monogram runs it as a recursive-descent + Pratt parser over the TypeScript conformance suite. Today it parses **95.4%** (3602 / 3776 files); the goal is **100% coverage of the official parser** — at which point the grammar is a *verified, complete model* of the language's syntax, not an approximation.
13+
1. **Write the grammar, then prove it.** The grammar is executable. Monogram runs it as a recursive-descent + Pratt parser over the TypeScript conformance suite and measures conformance *bidirectionally* — it must accept what `tsc` accepts **and** reject what `tsc` rejects. Today it parses **100% of valid single-file cases** (3376 / 3376 — zero valid-code gaps) and agrees with `tsc` **bidirectionally on 96.7%** (3544 / 3664); the remaining gap is *over-acceptance* (the grammar is still too permissive on some invalid inputs), and the goal is 100% both ways — a *verified, complete model* of the language's syntax, not an approximation.
1414

1515
2. **Derive the highlighter from the proven grammar.** The TextMate grammar is generated from that same parser-validated grammar — never hand-written. Its correctness is underwritten by the parser conformance run, not by regex tuning.
1616

@@ -37,7 +37,8 @@ And — from the same grammar — first-pass generators for the rest of the edit
3737
Validated on TypeScript (grammar: [`examples/typescript.ts`](examples/typescript.ts), 537 lines):
3838

3939
```
40-
Parser conformance 95.4% 3602 / 3776 files from the TypeScript conformance suite (goal: 100%)
40+
Valid-code coverage 100% 3376 / 3376 valid single-file conformance cases parse (zero gaps)
41+
Bidirectional 96.7% 3544 / 3664 — also rejects what tsc rejects (goal: 100%, gap = over-acceptance)
4142
Highlighter 99.3% 589 / 593 tokens match VS Code's official grammar
4243
Generated grammar 42 KB vs the official hand-written 226 KB
4344
Engine language-agnostic — no TypeScript-specific code
@@ -184,4 +185,4 @@ Every highlighter target (TextMate, tree-sitter queries, Lezer styleTags, Monarc
184185
| ANTLR | yes |||
185186
| Langium | yes | Monarch (separate) ||
186187
| ungrammar | AST types |||
187-
| **Monogram** | **CST (95.4% → 100%)** | **auto-derived (99.3%)** | **yes** |
188+
| **Monogram** | **CST (100% valid / 96.7% bidir)** | **auto-derived (99.3%)** | **yes** |

examples/lezer/highlight.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ export const highlighting = styleTags({
4545
"Semi": t.punctuation,
4646
"Op_25/Op_2a/Op_2a2a/Op_2b/Op_2d/Op_2f": t.arithmeticOperator,
4747
"QuestionDot": t.derefOperator,
48-
"Op_253d/Op_26263d/Op_263d/Op_2a2a3d/Op_2a3d/Op_2b3d/Op_2d3d/Op_2f3d/Op_3c3c3d/Op_3d/Op_3e3e3d/Op_3e3e3e3d/Op_3f3f3d/Op_5e3d/Op_7c3d/Op_7c7c3d": t.definitionOperator,
4948
"Op_21/Op_2626/Op_3f3f/Op_7c7c/Op_7e": t.logicOperator,
49+
"Op_253d/Op_26263d/Op_263d/Op_2a2a3d/Op_2a3d/Op_2b3d/Op_2d3d/Op_2f3d/Op_3c3c3d/Op_3d/Op_3e3e3d/Op_3e3e3e3d/Op_3f3f3d/Op_5e3d/Op_7c3d/Op_7c7c3d": t.definitionOperator,
5050
"Op_213d/Op_213d3d/Op_3d3d/Op_3d3d3d": t.compareOperator,
5151
"Op_2b2b/Op_2d2d": t.operator,
5252
});

examples/lezer/typescript.grammar

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Expr {
5353
Expr Dot (Ident | PrivateField) |
5454
Expr QuestionDot (Ident | PrivateField | ParenL (Expr (Comma Expr)*)? ParenR | BracketL Expr BracketR | Template) |
5555
Expr BracketL Expr BracketR |
56+
Expr Op_21 |
5657
Expr Question Expr Colon Expr |
5758
Expr kw_as Type |
5859
Expr kw_instanceof Expr |
@@ -94,7 +95,7 @@ Expr {
9495
Expr !l13 Op_2a2a Expr |
9596
!l14 (Op_21 | Op_7e | Op_2b | Op_2d | kw_typeof | kw_void | kw_delete | kw_await | kw_yield) Expr |
9697
!l15 (Op_2b2b | Op_2d2d) Expr |
97-
Expr !l16 (Op_2b2b | Op_2d2d | Op_21)
98+
Expr !l16 (Op_2b2b | Op_2d2d)
9899
}
99100
Prop {
100101
(Spread Expr | (kw_public | kw_private | kw_protected)? (kw_get | kw_set) MemberName ParenL ((Param (Comma Param)*)?)? ParenR (Colon Type)? Block | kw_async? Op_2a? MemberName TypeParams? ParenL (Param (Comma Param)*)? ParenR (Colon Type)? Block | Template (Colon Expr | ParenL (Param (Comma Param)*)? ParenR (Colon Type)? Block) | MemberName Colon Expr | BracketL Expr (Comma Expr)* BracketR Colon Expr | Ident (Op_3d Expr | Question (Colon Expr)? | ))

examples/tree-sitter/grammar.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ module.exports = grammar({
5959
prec.right(14, seq($.expr, field('operator', "**"), $.expr)),
6060
prec.right(15, seq(field('operator', choice("!", "~", "+", "-", "typeof", "void", "delete", "await", "yield")), $.expr)),
6161
prec.right(16, seq(field('operator', choice("++", "--")), $.expr)),
62-
prec.left(17, seq($.expr, field('operator', choice("++", "--", "!")))),
62+
prec.left(17, seq($.expr, field('operator', choice("++", "--")))),
6363
$.ident,
6464
$.number,
6565
$.string,
@@ -77,6 +77,7 @@ module.exports = grammar({
7777
prec.left(18, seq($.expr, ".", choice($.ident, $.private_field))),
7878
prec.left(18, seq($.expr, "?.", choice($.ident, $.private_field, seq("(", optional(seq($.expr, repeat(seq(",", $.expr)), optional(","))), ")"), seq("[", $.expr, "]"), $.template))),
7979
prec.left(18, seq($.expr, "[", $.expr, "]")),
80+
prec.left(18, seq($.expr, "!")),
8081
prec.left(18, seq($.expr, "?", $.expr, ":", $.expr)),
8182
prec.left(18, seq($.expr, "as", $.type)),
8283
prec.left(18, seq($.expr, "instanceof", $.expr)),

examples/typescript.monarch.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,7 +1011,7 @@
10111011
}
10121012
],
10131013
[
1014-
">>>=|\\*\\*=|<<=|>>=|\\?\\?=|\\|\\|=|&&=|===|!==|>>>|=>|\\+=|-=|\\*=|%=|&=|\\|=|\\^=|\\?\\?|\\|\\||&&|==|!=|<=|>=|<<|>>|\\*\\*|\\+\\+|--|\\||&|-|\\+|\\*|=|!|\\^|%|~",
1014+
">>>=|\\*\\*=|<<=|>>=|\\?\\?=|\\|\\|=|&&=|===|!==|>>>|=>|\\+=|-=|\\*=|%=|&=|\\|=|\\^=|\\?\\?|\\|\\||&&|==|!=|<=|>=|<<|>>|\\*\\*|\\+\\+|--|\\||&|-|\\+|!|\\*|=|\\^|%|~",
10151015
{
10161016
"token": "operator",
10171017
"switchTo": "@root"
@@ -1230,7 +1230,7 @@
12301230
"delimiter.square"
12311231
],
12321232
[
1233-
">>>=|\\*\\*=|<<=|>>=|\\?\\?=|\\|\\|=|&&=|===|!==|>>>|=>|\\+=|-=|\\*=|%=|&=|\\|=|\\^=|\\?\\?|\\|\\||&&|==|!=|<=|>=|<<|>>|\\*\\*|\\+\\+|--|\\||&|-|\\+|\\*|=|!|\\^|%|~",
1233+
">>>=|\\*\\*=|<<=|>>=|\\?\\?=|\\|\\|=|&&=|===|!==|>>>|=>|\\+=|-=|\\*=|%=|&=|\\|=|\\^=|\\?\\?|\\|\\||&&|==|!=|<=|>=|<<|>>|\\*\\*|\\+\\+|--|\\||&|-|\\+|!|\\*|=|\\^|%|~",
12341234
"operator"
12351235
],
12361236
[

examples/typescript.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ const Expr = rule($ => [
178178
// optional chaining: ?.x | ?.#x | ?.(args) | ?.[i] | ?.`…`
179179
[$, '?.', alt(Ident, PrivateField, ['(', sep($, ','), ')'], ['[', $, ']'], Template)],
180180
[$, '[', $, ']'],
181+
[$, '!'], // TS non-null assertion — a LHS-chain tail (access can follow: `x!.y`, `x!()`), unlike update `++`/`--`
181182
[$, '?', $, ':', $],
182183
[$, 'as', Type],
183184
[$, 'instanceof', $],
@@ -484,7 +485,7 @@ export default defineGrammar({
484485
right('**'),
485486
right(prefix('!', '~', '+', '-', 'typeof', 'void', 'delete', 'await', 'yield')),
486487
right(prefix('++', '--')),
487-
left(postfix('++', '--', '!')),
488+
left(postfix('++', '--')),
488489
],
489490

490491
rules: {

src/gen-parser.ts

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -200,16 +200,55 @@ export function createParser(grammar: CstGrammar) {
200200
return !!e && e.type === 'ref' && e.name === ruleName;
201201
}
202202
type MixfixInfo = { openLit: string; sepLit: string };
203+
// A continuation `<lit L1> $self <lit L2> …` whose inner `$self` operand can
204+
// over-consume the `L2` the operator needs (e.g. ternary `? $ : $`, conditional
205+
// type `extends $ ? $ : $`). The re-bind retries that operand capped.
206+
function mixfixOf(items: RuleExpr[], ruleName: string): MixfixInfo | null {
207+
if (items.length >= 4
208+
&& items[0]?.type === 'literal'
209+
&& selfRefName(items[1], ruleName)
210+
&& items[2]?.type === 'literal') {
211+
return { openLit: items[0].value, sepLit: items[2].value };
212+
}
213+
return null;
214+
}
203215
const ledMixfix = new Map<object, MixfixInfo>();
216+
for (const [ruleName, { leds }] of prattClassified.entries()) {
217+
for (const led of leds) {
218+
const info = mixfixOf(led.items, ruleName);
219+
if (info) ledMixfix.set(led, info);
220+
}
221+
}
222+
// Same re-bind for left-recursive (non-Pratt) rules like `Type`, whose
223+
// continuations carry the implicit leading `$`, so they are already stripped.
224+
const contMixfix = new Map<object, MixfixInfo>();
225+
for (const [ruleName, { continuations }] of leftRecClassified.entries()) {
226+
for (const cont of continuations) {
227+
const info = mixfixOf(cont, ruleName);
228+
if (info) contMixfix.set(cont, info);
229+
}
230+
}
231+
232+
// ── Access-tail LEDs (closed under a postfix operator) ──
233+
// A postfix operator (`a++`) turns its operand into an "update expression" that
234+
// member-access tails can no longer attach to: `a++[b]`, `a++.c`, `a++()`,
235+
// `a++<T>()`, `` a++`x` `` are all ill-formed (member access needs a primary, not
236+
// a postfix result). So once a postfix binds, such tails must NOT continue — the
237+
// bracketed term belongs to whatever follows (e.g. the next class field after an
238+
// ASI). Detected structurally, language-agnostically: an access tail is a non-op
239+
// LED that is "closed" (its last item is not a fresh same-rule operand, unlike a
240+
// binary/ternary `… in $` / `? $ : $`) AND whose connector is a punctuator, not a
241+
// word-operator — so `as`/`satisfies`/`in`/`instanceof`/`?:` still bind after `a++`.
242+
const accessTailLeds = new Set<object>();
204243
for (const [ruleName, { leds }] of prattClassified.entries()) {
205244
for (const led of leds) {
206245
const it = led.items;
207-
if (it.length >= 4
208-
&& it[0]?.type === 'literal'
209-
&& selfRefName(it[1], ruleName)
210-
&& it[2]?.type === 'literal') {
211-
ledMixfix.set(led, { openLit: it[0].value, sepLit: it[2].value });
212-
}
246+
if (it.length === 0) continue;
247+
if (it[0].type === 'op' || it[0].type === 'postfix') continue; // operator LEDs, not tails
248+
const last = it[it.length - 1];
249+
const lastIsOperand = selfRefName(last, ruleName); // open binary/ternary operand
250+
const wordConnector = it[0].type === 'literal' && /^[A-Za-z]/.test(it[0].value);
251+
if (!lastIsOperand && !wordConnector) accessTailLeds.add(led);
213252
}
214253
}
215254

@@ -523,7 +562,16 @@ export function createParser(grammar: CstGrammar) {
523562
const contSaved = pos;
524563
for (const cont of continuations) {
525564
pos = contSaved;
526-
const children = matchSeq(cont);
565+
let children = matchSeq(cont);
566+
// Mixfix operand re-bind (same fix parsePratt uses): a continuation of the
567+
// shape `<lit> $self <lit> …` (e.g. the conditional type `extends $ ? $ : $`)
568+
// can have its inner operand over-consume the separator the operator needs
569+
// (an `infer U extends T ? …` swallowing the conditional's `?`). Retry it
570+
// capped so the operand stops before that separator.
571+
if (children === null) {
572+
const mix = contMixfix.get(cont);
573+
if (mix) { pos = contSaved; children = matchMixfixLed({ items: cont }, rule.name, mix); }
574+
}
527575
if (children !== null) {
528576
node = {
529577
kind: 'node',
@@ -587,6 +635,10 @@ export function createParser(grammar: CstGrammar) {
587635

588636
if (!lhs) { pos = saved; return null; }
589637

638+
// Once a postfix operator binds (`a++`), the operand is an update expression
639+
// that access tails (`[…]`, `.x`, `(…)`, `<T>`, tagged template) can't extend.
640+
let tailClosed = false;
641+
590642
// LED loop
591643
while (true) {
592644
const tok = peek();
@@ -599,6 +651,7 @@ export function createParser(grammar: CstGrammar) {
599651
for (const led of leds) {
600652
if (led.items[0]?.type === 'op' || led.items[0]?.type === 'postfix') continue;
601653
if (maxBp <= minBp) continue;
654+
if (tailClosed && accessTailLeds.has(led)) continue; // no access tail after a postfix
602655
// Skip a LED whose connector is excluded in this context (e.g. `in` under
603656
// a no-`in` for-head) — it rebinds to the enclosing rule instead.
604657
if (suppressCur && led.items[0]?.type === 'literal' && suppressCur.has(led.items[0].value)) continue;
@@ -632,10 +685,13 @@ export function createParser(grammar: CstGrammar) {
632685
const info = opTable.get(tokKey);
633686
if (info && info.lbp > minBp) {
634687
if (info.position === 'postfix') {
635-
pos++;
636-
const opLeaf: CstLeaf = { kind: 'leaf', tokenType: '$operator', text: tok.text, offset: tok.offset, end: tok.offset + tok.text.length };
637-
lhs = { kind: 'node', rule: rule.name, children: [lhs, opLeaf], offset: lhs.offset, end: opLeaf.end };
638-
matched = true;
688+
if (!tailClosed) { // can't postfix an update expr (`a++ --`)
689+
pos++;
690+
const opLeaf: CstLeaf = { kind: 'leaf', tokenType: '$operator', text: tok.text, offset: tok.offset, end: tok.offset + tok.text.length };
691+
lhs = { kind: 'node', rule: rule.name, children: [lhs, opLeaf], offset: lhs.offset, end: opLeaf.end };
692+
tailClosed = true;
693+
matched = true;
694+
}
639695
} else {
640696
pos++;
641697
const opLeaf: CstLeaf = { kind: 'leaf', tokenType: '$operator', text: tok.text, offset: tok.offset, end: tok.offset + tok.text.length };

test/conformance-matrix.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Bidirectional conformance: a real parser must ACCEPT what TS accepts AND REJECT
2+
// what TS rejects. The snapshot's "pass = didn't throw" only measures acceptance —
3+
// the easy half — so it counts an over-accept (invalid code we wave through) as a
4+
// success. This computes the full confusion matrix against TS's own parseDiagnostics
5+
// over single-file conformance cases (multi-`@filename` excluded — not one program).
6+
import { createParser } from '../src/gen-parser.ts';
7+
import { readdirSync, readFileSync } from 'fs';
8+
import { join } from 'path';
9+
import ts from 'typescript';
10+
11+
const grammar = (await import('../examples/typescript.ts')).default;
12+
const { parse } = createParser(grammar);
13+
const base = '/tmp/ts-repo/tests/cases/conformance';
14+
15+
function walk(d: string): string[] {
16+
let o: string[] = [];
17+
for (const e of readdirSync(d, { withFileTypes: true })) {
18+
const f = join(d, e.name);
19+
if (e.isDirectory()) o = o.concat(walk(f));
20+
else if (e.name.endsWith('.ts') && !e.name.endsWith('.d.ts')) o.push(f);
21+
}
22+
return o;
23+
}
24+
const isMulti = (t: string) => /^\s*\/\/\s*@filename:/im.test(t);
25+
26+
let TP = 0, FN = 0, FP = 0, TN = 0;
27+
const fns: string[] = [], fps: string[] = [];
28+
for (const f of walk(base)) {
29+
const code = readFileSync(f, 'utf8');
30+
if (isMulti(code)) continue;
31+
const sf = ts.createSourceFile('t.ts', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
32+
const tsAccept = ((sf as any).parseDiagnostics?.length ?? 0) === 0;
33+
let weAccept = true;
34+
try { parse(code); } catch { weAccept = false; }
35+
if (tsAccept && weAccept) TP++;
36+
else if (tsAccept && !weAccept) { FN++; fns.push(f.replace(base + '/', '')); }
37+
else if (!tsAccept && weAccept) { FP++; fps.push(f.replace(base + '/', '')); }
38+
else TN++;
39+
}
40+
const total = TP + FN + FP + TN;
41+
console.log(`Single-file conformance cases: ${total}\n`);
42+
console.log(' WE accept WE reject');
43+
console.log(` TS accept (valid) ${String(TP).padStart(6)} (correct) ${String(FN).padStart(4)} (MISS — valid-code gap)`);
44+
console.log(` TS reject (error) ${String(FP).padStart(6)} (over-accept)${String(TN).padStart(4)} (correct reject)`);
45+
console.log('');
46+
console.log(` Valid-code coverage : ${(TP / (TP + FN) * 100).toFixed(2)}% (${TP}/${TP + FN}) ← parses every valid file when FN=0`);
47+
console.log(` Bidirectional agree : ${((TP + TN) / total * 100).toFixed(2)}% (${TP + TN}/${total})`);
48+
if (fns.length) console.log(`\n MISSED valid (real gaps):\n${fns.map(x => ' - ' + x).join('\n')}`);

test/verify-rejects.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Rigor check for the "error-test" bucket: failing a file that TS also rejects
2+
// only counts as *correct* if we reject for the RIGHT reason — i.e. we reach at
3+
// least as far as TS's first syntax error before bailing. If our parser chokes
4+
// EARLIER than TS's first diagnostic, the code before that point is valid TS we
5+
// silently can't parse — a hidden gap hiding behind the "intentional error" label.
6+
import { createParser } from '../src/gen-parser.ts';
7+
import { readdir } from 'fs/promises';
8+
import { readFileSync } from 'fs';
9+
import { join } from 'path';
10+
import ts from 'typescript';
11+
12+
const grammar = (await import('../examples/typescript.ts')).default;
13+
const { parse } = createParser(grammar);
14+
const baseDir = '/tmp/ts-repo/tests/cases/conformance';
15+
const SLACK = 8; // chars of tolerance (token boundary / trivia differences)
16+
17+
async function allTsFiles(dir: string): Promise<string[]> {
18+
const out: string[] = [];
19+
for (const e of await readdir(dir, { withFileTypes: true })) {
20+
const full = join(dir, e.name);
21+
if (e.isDirectory()) out.push(...await allTsFiles(full));
22+
else if (e.name.endsWith('.ts') && !e.name.endsWith('.d.ts')) out.push(full);
23+
}
24+
return out;
25+
}
26+
27+
const isMulti = (t: string) => /^\s*\/\/\s*@filename:/im.test(t);
28+
// How far our parser actually reached: prefer the `farthest` backtracking mark,
29+
// else the primary error offset.
30+
function ourReach(msg: string): number | null {
31+
const far = msg.match(/farthest: offset (\d+)/);
32+
if (far) return +far[1];
33+
const at = msg.match(/offset (\d+)/);
34+
return at ? +at[1] : null;
35+
}
36+
37+
const files = (await allTsFiles(baseDir)).sort();
38+
let agree = 0, early = 0, unknown = 0;
39+
const earlies: { file: string; ourReach: number; tsFirst: number; ctx: string }[] = [];
40+
41+
for (const file of files) {
42+
const code = readFileSync(file, 'utf-8');
43+
if (isMulti(code)) continue; // single-file only (clean comparison)
44+
let msg = '';
45+
try { parse(code); continue; } catch (e: any) { msg = e.message; } // only files we FAIL
46+
47+
const sf = ts.createSourceFile('t.ts', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
48+
const diags = (sf as any).parseDiagnostics ?? [];
49+
if (diags.length === 0) continue; // that's a REAL gap, handled elsewhere
50+
51+
const tsFirst = Math.min(...diags.map((d: any) => d.start ?? Infinity));
52+
const reach = ourReach(msg);
53+
if (reach == null) { unknown++; continue; }
54+
55+
if (reach >= tsFirst - SLACK) {
56+
agree++; // we got to (or past) TS's first error → right reason
57+
} else {
58+
early++; // we bailed on valid code BEFORE the error
59+
earlies.push({ file: file.replace(baseDir + '/', ''), ourReach: reach, tsFirst, ctx: JSON.stringify(code.slice(Math.max(0, reach - 40), reach + 15)) });
60+
}
61+
}
62+
63+
console.log(`Single-file error-tests we fail: ${agree + early + unknown}`);
64+
console.log(` AGREE (reach >= TS first error - ${SLACK}) : ${agree} ← rejected for the right reason`);
65+
console.log(` EARLY (bail before TS's error) : ${early} ← hidden gap: valid code we can't parse`);
66+
console.log(` UNKNOWN (no offset in our error) : ${unknown}`);
67+
if (earlies.length) {
68+
console.log(`\n===== EARLY (hidden gaps) =====`);
69+
earlies.sort((a, b) => (a.tsFirst - a.ourReach) - (b.tsFirst - b.ourReach));
70+
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}`);
71+
}

0 commit comments

Comments
 (0)