Skip to content

Commit b814d96

Browse files
committed
Two more structure bugs from the widened oracle; valid-only contract
Bug #2 - statement-position function merged with a following call: longest- match let the expression arm win whenever a member/call tail made it LONGER ("function f(){}" + newline + "(g)()" became ONE IIFE-style expression statement; tsc keeps a declaration + a separate statement). Fix: the ES2023 14.5 ExpressionStatement lookahead restriction - the expression arm may not begin with function / async function. "class" is deliberately NOT guarded yet: the class-DECLARATION arm is narrower than tsc's (extends-expression heritage, bare ';' class elements, decorator placements), so 31 tsc-valid corpus files still rely on the class-EXPRESSION fallback - widening the declaration arm is the named prerequisite. The guard flips exactly 3 corpus files to reject, all tsc-INVALID (template-typed params, "function* gen" without parens): FN=0 held. Rest parameters also gained their checker-not-parser tail ("...b = init", "...b?: T") so the declaration arm carries what the expression fallback used to. Bug #3 (pre-existing, all builds) - bare for-in heads: ForHead's no-declaration arm parsed its target Expr WITHOUT the no-"in" exclusion, so "for (key in obj)" swallowed the "in" inside an in-LED, the arm failed, and the statement fell back to a CALL parse "for(...)" with "for" as an identifier. Same exclude as binding initializers on the target. The interpreter's maxPos moves to advance-based tracking (mirroring the emitted engine's relocation) - the new reject inputs exposed that the two engines' farthest-position semantics had silently diverged; reject messages are engine-identical again (468 files). ts-ast-verify gains the valid-only contract: files where tsc itself reports parse errors are SKIPPED (error-recovery shapes are each parser's own policy, not the grammar contract) - parserharness/parserRealSource7 fall under it; the lowering still gained their missing shapes (NewTarget index form, old-style type assertion). Gate corpus widened to fixSignatureCaching (2,952 nodes) + parserindenter (3,168), both node-identical to tsc. 28/28 gates; 18,805-file interp=emit; conformance 5383/5659 (baseline minus the 3 correct rejects).
1 parent 710f967 commit b814d96

5 files changed

Lines changed: 71 additions & 23 deletions

File tree

javascript.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ const Param = rule($ => {
354354
[Ident, opt('=', Expr)],
355355
[BindingPattern, opt('=', Expr)],
356356
// a rest element can never validly be a reserved word (`...while`), so guarding it is FN-safe.
357-
['...', alt([notReserved, Ident], BindingPattern)], // rest
357+
['...', alt([notReserved, Ident], BindingPattern), opt('=', Expr)], // rest (an initializer is a CHECKER error in tsc, not a parse error)
358358
);
359359
return [
360360
[opt(DecoratorExpr), body],
@@ -371,7 +371,11 @@ const ForHead = rule($ => {
371371
[alt('in', 'of'), Expr],
372372
)],
373373
[opt(Expr, many(',', Expr)), ...cTail], // C-style, no declaration: `for (i=0; …; …)` / `for (;;)`
374-
[Expr, alt('in', 'of'), Expr], // for-in/of, no declaration: `for (x of xs)`
374+
// for-in/of, no declaration: `for (x of xs)`. The target Expr parses in a no-`in`
375+
// context (same exclude as binding initializers): the `in` belongs to the for-head,
376+
// not to an in-LED inside the target — without it `for (key in obj)` swallowed the
377+
// `in`, the arm failed, and the statement fell back to a CALL parse `for(...)`.
378+
[exclude('in', Expr), alt('in', 'of'), Expr],
375379
];
376380
});
377381

@@ -400,7 +404,17 @@ const Stmt = rule($ => [
400404
['with', '(', Expr, ')', $],
401405
[opt('await'), 'using', sep(Binding, ','), opt(';')],
402406
Decl,
403-
[Expr, many(',', Expr), opt(';')],
407+
// ExpressionStatement lookahead restriction (ES2023 §14.5): a statement may not
408+
// begin with `function` / `async function` — those are declarations at statement
409+
// level. Without this guard, longest-match lets the expression arm win whenever a
410+
// call/member tail makes it LONGER (`function f(){}\n(g)()` merged into one
411+
// IIFE-style expression statement; tsc keeps them separate). `{` needs no guard
412+
// (the Block alternative ties in length and wins as the first-listed alternative).
413+
// `class` is NOT guarded yet: the class-DECLARATION arm is narrower than tsc's
414+
// (extends-expression heritage, bare `;` class elements, decorator placements), so
415+
// 31 tsc-valid corpus files still rely on the class-EXPRESSION fallback — widen the
416+
// declaration arm first, then guard.
417+
[not(alt('function', ['async', 'function'])), Expr, many(',', Expr), opt(';')],
404418
]);
405419

406420
// ── Declarations ──

src/gen-parser.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,7 @@ export function createParser(grammar: CstGrammar) {
760760
function parse(source: string, entryRule?: string): CstNode {
761761
const tokens = tokenize(source);
762762
let pos = 0;
763-
let maxPos = 0; // farthest token index the parser ever attempted to read (diagnostic)
763+
let maxPos = 0; // farthest token index ever ADVANCED past (diagnostic; updated at the pos++ sites, mirroring the emitted engine so reject messages stay engine-identical)
764764
// Packrat memo for pratt/left-recursive rules (Expr, Type, …): cache the
765765
// parse result + end position by start position, so backtracking doesn't
766766
// re-parse the same rule at the same spot. Sound because those rules reset
@@ -777,7 +777,6 @@ export function createParser(grammar: CstGrammar) {
777777
let parseLimit = -1;
778778

779779
function peek(): Token | null {
780-
if (pos > maxPos) maxPos = pos;
781780
if (parseLimit >= 0 && pos >= parseLimit) return null;
782781
return tokens[pos] ?? null;
783782
}
@@ -794,14 +793,14 @@ export function createParser(grammar: CstGrammar) {
794793
if (isKeywordLiteral(value)) {
795794
// Keyword literal: match against Ident token with same text
796795
if (tok.type !== '' && tokenNames.has(tok.type) && tok.text === value) {
797-
pos++;
796+
if (++pos > maxPos) maxPos = pos;
798797
return { tokenType: '$keyword', offset: tok.offset, end: tok.offset + tok.text.length };
799798
}
800799
return null;
801800
}
802801
// Punctuation literal
803802
if (tok.type === '' && tok.text === value) {
804-
pos++;
803+
if (++pos > maxPos) maxPos = pos;
805804
return { tokenType: '$punct', offset: tok.offset, end: tok.offset + tok.text.length };
806805
}
807806
// Split multi-`>` tokens: `>>`, `>>>`, `>>=`, `>>>=` can yield a single `>`
@@ -812,7 +811,7 @@ export function createParser(grammar: CstGrammar) {
812811
{ type: '', text: rest, offset: tok.offset + 1, k: 0, t: 0, newlineBefore: false, commentBefore: false, multilineFlowBefore: false },
813812
);
814813
memo.clear(); // splice shifts later token indices → memo entries are stale
815-
pos++;
814+
if (++pos > maxPos) maxPos = pos;
816815
return { tokenType: '$punct', offset: tok.offset, end: tok.offset + 1 };
817816
}
818817
return null;
@@ -823,7 +822,7 @@ export function createParser(grammar: CstGrammar) {
823822
const tok = peek();
824823
if (!tok) return null;
825824
if (tok.type === name) {
826-
pos++;
825+
if (++pos > maxPos) maxPos = pos;
827826
return { tokenType: name, offset: tok.offset, end: tok.offset + tok.text.length };
828827
}
829828
return null;
@@ -842,12 +841,12 @@ export function createParser(grammar: CstGrammar) {
842841
const tok = peek();
843842
if (!tok) return null;
844843
if (tok.type === templateTokenName) {
845-
pos++;
844+
if (++pos > maxPos) maxPos = pos;
846845
return { tokenType: templateTokenName, offset: tok.offset, end: tok.offset + tok.text.length };
847846
}
848847
if (tok.type === '$templateHead') {
849848
const children: CstChild[] = [];
850-
pos++;
849+
if (++pos > maxPos) maxPos = pos;
851850
children.push({ tokenType: '$templateHead', offset: tok.offset, end: tok.offset + tok.text.length });
852851
const interpRule = currentPrattContext ?? findExprRule();
853852
while (true) {
@@ -856,12 +855,12 @@ export function createParser(grammar: CstGrammar) {
856855
const next = peek();
857856
if (!next) break;
858857
if (next.type === '$templateMiddle') {
859-
pos++;
858+
if (++pos > maxPos) maxPos = pos;
860859
children.push({ tokenType: '$templateMiddle', offset: next.offset, end: next.offset + next.text.length });
861860
continue;
862861
}
863862
if (next.type === '$templateTail') {
864-
pos++;
863+
if (++pos > maxPos) maxPos = pos;
865864
children.push({ tokenType: '$templateTail', offset: next.offset, end: next.offset + next.text.length });
866865
break;
867866
}
@@ -1041,7 +1040,7 @@ export function createParser(grammar: CstGrammar) {
10411040
const key = tok.text;
10421041
const info = prefixOps.get(key);
10431042
if (info) {
1044-
pos++;
1043+
if (++pos > maxPos) maxPos = pos;
10451044
const opLeaf: CstLeaf = { tokenType: '$operator', offset: tok.offset, end: tok.offset + tok.text.length };
10461045
const rhs = parsePratt(rule, info.rbp);
10471046
if (rhs && pos > bestNudPos) {
@@ -1136,7 +1135,7 @@ export function createParser(grammar: CstGrammar) {
11361135
if (info && info.lbp > minBp) {
11371136
if (info.position === 'postfix') {
11381137
if (!tailClosed) { // can't postfix an update expr (`a++ --`)
1139-
pos++;
1138+
if (++pos > maxPos) maxPos = pos;
11401139
const opLeaf: CstLeaf = { tokenType: '$operator', offset: tok.offset, end: tok.offset + tok.text.length };
11411140
lhs = { rule: rule.name, children: [lhs, opLeaf], offset: lhs.offset, end: opLeaf.end };
11421141
tailClosed = true;
@@ -1157,7 +1156,7 @@ export function createParser(grammar: CstGrammar) {
11571156
return null;
11581157
}
11591158
}
1160-
pos++;
1159+
if (++pos > maxPos) maxPos = pos;
11611160
const opLeaf: CstLeaf = { tokenType: '$operator', offset: tok.offset, end: tok.offset + tok.text.length };
11621161
const rhs = parsePratt(rule, info.rbp);
11631162
if (rhs) {

test/ts-ast-lowering.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ function lowerExpr(n: CstNode): Ast {
164164
if (t0 === '...' && c.length === 2 && ruleIs(c[1], 'Expr')) {
165165
return ast('SpreadElement', n.offset, n.end, [lowerExpr(c[1])]);
166166
}
167+
// Old-style type assertion: ['<', Type, '>', Expr]
168+
if (t0 === '<' && c.length === 4 && ruleIs(c[1], 'Type') && ruleIs(c[3], 'Expr')) {
169+
return ast('TypeAssertionExpression', n.offset, n.end, [lowerType(c[1]), lowerExpr(c[3])]);
170+
}
167171
// Parenthesized vs arrow: both arms start '('.
168172
// PAIN(5): two different grammar ALTERNATIVES with the same first token reach the
169173
// consumer as child-shape puzzles — '(' Expr ')' vs '(' Param* ')' '=>' …; the
@@ -287,6 +291,10 @@ function lowerNewTarget(n: CstNode): Ast {
287291
if (ruleIs(c[0], 'NewTarget') && c.length === 3 && isLeaf(c[2])) {
288292
return ast('PropertyAccessExpression', n.offset, n.end, [lowerNewTarget(c[0]), ast('Identifier', c[2].offset, c[2].end)]);
289293
}
294+
// index form: NewTarget '[' Expr ']' (`new benchmarks[i]()`)
295+
if (ruleIs(c[0], 'NewTarget') && c.length === 4 && leafIs(c[1], '[') && ruleIs(c[2], 'Expr')) {
296+
return ast('ElementAccessExpression', n.offset, n.end, [lowerNewTarget(c[0]), lowerExpr(c[2])]);
297+
}
290298
if (c.length === 1 && ruleIs(c[0], 'Expr')) return lowerExpr(c[0]);
291299
throw new Unlowered('NewTarget shape', n.offset);
292300
}

test/ts-ast-verify.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,16 @@ function compare(mine: Ast, theirs: ts.Node, sf: ts.SourceFile, st: Stats): void
5454
for (let i = 0; i < tc.length; i++) compare(mine.children[i], tc[i], sf, st);
5555
}
5656

57-
function run(name: string, code: string): { ok: boolean; line: string; samples: string[] } {
57+
function run(name: string, code: string): { ok: boolean; skipped?: boolean; line: string; samples: string[] } {
58+
// Structural contract: VALID files only. When tsc itself reports parse errors, its
59+
// tree is an error-RECOVERY shape (each parser's recovery strategy is its own) —
60+
// comparing structures there compares recovery policies, not the grammar.
61+
{
62+
const probe = ts.createSourceFile('f.ts', code, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS) as ts.SourceFile & { parseDiagnostics: unknown[] };
63+
if (probe.parseDiagnostics.length > 0) {
64+
return { ok: true, skipped: true, line: `${name}: SKIPPED (tsc reports ${probe.parseDiagnostics.length} parse error(s) — recovery shapes are out of contract)`, samples: [] };
65+
}
66+
}
5867
let cst;
5968
try { cst = parser.parse(code); }
6069
catch (e) { return { ok: false, line: `${name}: MONOGRAM REJECT ${(e as Error).message.slice(0, 60)}`, samples: [] }; }
@@ -113,7 +122,10 @@ const SNIPPETS: [string, string][] = [
113122
['comma-empty', ';;'],
114123
];
115124

116-
const CORPUS_FILE = '/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/RealWorld/parserindenter.ts';
125+
const CORPUS_FILES = [
126+
'/tmp/ts-repo/tests/cases/conformance/parser/ecmascript5/RealWorld/parserindenter.ts',
127+
'/tmp/ts-repo/tests/cases/conformance/fixSignatureCaching.ts',
128+
];
117129
const files = process.argv.slice(2);
118130
let pass = 0, fail = 0;
119131
if (files.length > 0) {
@@ -131,8 +143,9 @@ if (files.length > 0) {
131143
for (const s of r.samples) console.log(' ', s);
132144
r.ok ? pass++ : fail++;
133145
}
134-
if (existsSync(CORPUS_FILE)) {
135-
const r = run('parserindenter.ts', readFileSync(CORPUS_FILE, 'utf-8'));
146+
for (const cf of CORPUS_FILES) {
147+
if (!existsSync(cf)) continue;
148+
const r = run(cf.split('/').pop()!, readFileSync(cf, 'utf-8'));
136149
console.log((r.ok ? '✓ ' : '✗ ') + r.line);
137150
for (const s of r.samples) console.log(' ', s);
138151
r.ok ? pass++ : fail++;

typescript.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ const Param = rule($ => {
270270
[BindingPattern, ...tail],
271271
// a rest element, by contrast, can never validly be a reserved word (`...while`),
272272
// and `...this` is invalid too, so guarding the rest name is FN-safe.
273-
['...', alt([notReserved, Ident], BindingPattern), opt('?'), opt(':', Type)], // rest
273+
['...', alt([notReserved, Ident], BindingPattern), opt('?'), opt(':', Type), opt('=', Expr)], // rest (`?`/initializer are CHECKER errors in tsc, not parse errors)
274274
);
275275
return [
276276
['this', ':', Type],
@@ -292,7 +292,11 @@ const ForHead = rule($ => {
292292
[alt('in', 'of'), Expr],
293293
)],
294294
[opt(Expr, many(',', Expr)), ...cTail], // C-style, no declaration: `for (i=0; …; …)` / `for (;;)`
295-
[Expr, alt('in', 'of'), Expr], // for-in/of, no declaration: `for (x of xs)`
295+
// for-in/of, no declaration: `for (x of xs)`. The target Expr parses in a no-`in`
296+
// context (same exclude as binding initializers): the `in` belongs to the for-head,
297+
// not to an in-LED inside the target — without it `for (key in obj)` swallowed the
298+
// `in`, the arm failed, and the statement fell back to a CALL parse `for(...)`.
299+
[exclude('in', Expr), alt('in', 'of'), Expr],
296300
];
297301
});
298302

@@ -321,7 +325,17 @@ const Stmt = rule($ => [
321325
['with', '(', Expr, ')', $],
322326
[opt('await'), 'using', sep(Binding, ','), opt(';')],
323327
Decl,
324-
[Expr, many(',', Expr), opt(';')],
328+
// ExpressionStatement lookahead restriction (ES2023 §14.5): a statement may not
329+
// begin with `function` / `async function` — those are declarations at statement
330+
// level. Without this guard, longest-match lets the expression arm win whenever a
331+
// call/member tail makes it LONGER (`function f(){}\n(g)()` merged into one
332+
// IIFE-style expression statement; tsc keeps them separate). `{` needs no guard
333+
// (the Block alternative ties in length and wins as the first-listed alternative).
334+
// `class` is NOT guarded yet: the class-DECLARATION arm is narrower than tsc's
335+
// (extends-expression heritage, bare `;` class elements, decorator placements), so
336+
// 31 tsc-valid corpus files still rely on the class-EXPRESSION fallback — widen the
337+
// declaration arm first, then guard.
338+
[not(alt('function', ['async', 'function'])), Expr, many(',', Expr), opt(';')],
325339
]);
326340

327341
// ── Type Parameters ──

0 commit comments

Comments
 (0)