Skip to content

Commit af4b251

Browse files
SH2-0b: fix CST↔AST false divergences; harden toy corpus ≥1200.
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 24280f4 commit af4b251

4 files changed

Lines changed: 145 additions & 32 deletions

File tree

src/shape-schema.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export type LeafValueShape = {
3232
};
3333
export type CustomShape = { kind: 'custom'; fn: string; reason: string };
3434
export type KeepShape = { kind: 'keep' };
35+
/** Delegate Pratt atom NUD to an RD rule (e.g. Atom choice → Number|Identifier nodes). */
36+
export type RuleRefShape = { kind: 'rule'; name: string };
3537

3638
export type ChoiceArm = {
3739
name: string;
@@ -42,7 +44,7 @@ export type ChoiceShape = { kind: 'choice'; arms: ChoiceArm[] };
4244

4345
export type PrattShape = {
4446
kind: 'pratt';
45-
atom?: LeafValueShape | KeepShape | DropShape | CustomShape;
47+
atom?: LeafValueShape | KeepShape | DropShape | CustomShape | RuleRefShape;
4648
group?: InlineShape | NodeShape | CustomShape;
4749
nudSeq?: RuleShapeAtom;
4850
nudCapped?: RuleShapeAtom;

src/target-ts.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2494,8 +2494,12 @@ function emitShapeTypeDecls(ir: ParserIR, shapeIR: ShapeIR): string {
24942494
if (r.name === ir.entry) lines.push(`export type AstRoot = ${name};`);
24952495
} else if (r.shape.kind === 'pratt') {
24962496
const members: string[] = [];
2497-
const add = (s: RuleShape | undefined) => {
2497+
const add = (s: RuleShape | { kind: 'rule'; name: string } | undefined) => {
24982498
if (!s) return;
2499+
if (s.kind === 'rule') {
2500+
members.push(`${s.name}Shape`);
2501+
return;
2502+
}
24992503
if (s.kind === 'node') members.push(s.type);
25002504
else if (s.kind === 'leafValue') {
25012505
const ts = s.fn === 'number' ? 'number' : s.fn === 'bigint' ? 'bigint' : s.fn === 'boolean' ? 'boolean' : 'string';
@@ -3044,7 +3048,12 @@ function emitAstPrattRule(r: PrattRule, sir: ShapeIRRule, ids: LexIdPlan, shapeI
30443048
// ── atom ──────────────────────────────────────────────────────────────────
30453049
ctx.pratt.atom++;
30463050
let atomCode = '';
3047-
if (ps.atom?.kind === 'custom') {
3051+
if (ps.atom?.kind === 'rule') {
3052+
// Delegate to an RD rule that covers the same atom tokens (no runtime custom).
3053+
atomCode = ` if (${r.name}_ATOM.has(t.kid)) {
3054+
return parseAst${ps.atom.name}() as ${retType};
3055+
}`;
3056+
} else if (ps.atom?.kind === 'custom') {
30483057
atomCode = ` if (${r.name}_ATOM.has(t.kid)) {
30493058
const save = pos; const spOff = t.off; pos++;
30503059
const _lv = t.kid === ${kidOf(ids, 'Number')} ? _shapeLeafNumber(t)
@@ -3077,7 +3086,7 @@ function emitAstPrattRule(r: PrattRule, sir: ShapeIRRule, ids: LexIdPlan, shapeI
30773086
const close = b.steps[b.steps.length - 1]?.t === 'lit' ? lidOf(ids, (b.steps[b.steps.length - 1] as { value: string }).value) : 0;
30783087
groupCode = ` if (t.lid === ${open}) {
30793088
const save = pos;
3080-
if (!_shapeDropLit(${open})) return null;
3089+
if (!_shapeDropLit(${open})) { pos = save; return null; }
30813090
const inner = parseAst${r.name}();
30823091
if (inner === null || !_shapeDropLit(${close})) { pos = save; return null; }
30833092
return inner;

test/fixtures/shape-toy.ts

Lines changed: 101 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,8 @@ export const toyShape: ShapeSpec = {
126126
},
127127
Expr: {
128128
kind: 'pratt',
129-
// keep yields bare leafScalars; toy golden needs Number|Identifier products.
130-
atom: {
131-
kind: 'custom',
132-
fn: 'atom',
133-
reason: 'Pratt atom products are Number|Identifier nodes; keep+leafValue only yields scalars',
134-
},
129+
// Delegate to Atom choice so Number|Identifier nodes need no runtime custom.
130+
atom: { kind: 'rule', name: 'Atom' },
135131
group: { kind: 'inline' },
136132
prefix: {
137133
kind: 'node',
@@ -159,18 +155,42 @@ export const toyShape: ShapeSpec = {
159155
],
160156
},
161157
},
158+
// Three node arms (FIRST-overlap + true backtrack). Multi-alt single custom arm
159+
// remains proven via shape-parity's taggedCustomShape emit (altPath witness).
162160
Tagged: {
163161
kind: 'choice',
164162
arms: [
165163
{
166-
name: 'Tagged',
167-
altIndices: [0, 1, 2],
164+
name: 'ColonTag',
165+
altIndices: [0],
168166
shape: {
169-
kind: 'custom',
170-
fn: 'Tagged',
171-
reason:
172-
'Three alts share FIRST "tag" and yield ColonTag|EqualsTag|BareTag; ' +
173-
'one arm must try alts sequentially and hand altPath to custom',
167+
kind: 'node',
168+
type: 'ColonTag',
169+
fields: [
170+
{ name: 'name', bind: { at: 0 }, typeHint: 'string' },
171+
{ name: 'value', bind: { at: 1 }, typeHint: 'ExprShape' },
172+
],
173+
},
174+
},
175+
{
176+
name: 'EqualsTag',
177+
altIndices: [1],
178+
shape: {
179+
kind: 'node',
180+
type: 'EqualsTag',
181+
fields: [
182+
{ name: 'name', bind: { at: 0 }, typeHint: 'string' },
183+
{ name: 'value', bind: { at: 1 }, typeHint: 'ExprShape' },
184+
],
185+
},
186+
},
187+
{
188+
name: 'BareTag',
189+
altIndices: [2],
190+
shape: {
191+
kind: 'node',
192+
type: 'BareTag',
193+
fields: [{ name: 'name', bind: { at: 0 }, typeHint: 'string' }],
174194
},
175195
},
176196
],
@@ -230,7 +250,7 @@ export type ToyAstCustoms = Record<string, (ctx: ToyAstCustomCtx) => unknown>;
230250
const I = (name: string) => ({ type: 'Identifier', name });
231251
const N = (value: number) => ({ type: 'Number', value });
232252

233-
/** Default customs so golden rows work without per-call overrides. */
253+
/** Optional customs for override/witness tests (default toy shape needs none). */
234254
export const toyCustoms: ToyAstCustoms = {
235255
atom: (ctx) => {
236256
const t = ctx.kids[0];
@@ -247,6 +267,30 @@ export const toyCustoms: ToyAstCustoms = {
247267
},
248268
};
249269

270+
/** Multi-alt single custom arm — used by shape-parity altPath witness emit. */
271+
export const toyTaggedCustomShape: ShapeSpec = {
272+
...toyShape,
273+
rules: {
274+
...toyShape.rules,
275+
Tagged: {
276+
kind: 'choice',
277+
arms: [
278+
{
279+
name: 'Tagged',
280+
altIndices: [0, 1, 2],
281+
shape: {
282+
kind: 'custom',
283+
fn: 'Tagged',
284+
reason:
285+
'Three alts share FIRST "tag" and yield ColonTag|EqualsTag|BareTag; ' +
286+
'one arm must try alts sequentially and hand altPath to custom',
287+
},
288+
},
289+
],
290+
},
291+
},
292+
};
293+
250294
/** Proto2 golden (10), including custom-ctx row. */
251295
export const toyGolden: { src: string; expect: unknown; customs?: ToyAstCustoms }[] = [
252296
{ src: 'bang !x;', expect: { type: 'Program', body: [{ type: 'BangOne', arg: I('x') }] } },
@@ -282,7 +326,7 @@ export const toyGolden: { src: string; expect: unknown; customs?: ToyAstCustoms
282326
},
283327
];
284328

285-
/** Seeded corpus builder identical to proto2-harness (seed 0x5a2_2026 → 520). */
329+
/** Seeded corpus: SH2-0 base + SH2-0b multi-stmt / nested-group / adv-112 (seed → 800). */
286330
export function buildToyCorpus(seed = 0x5a2_2026): { src: string; source: string }[] {
287331
function rng32(s: number) {
288332
return () => {
@@ -305,11 +349,24 @@ export function buildToyCorpus(seed = 0x5a2_2026): { src: string; source: string
305349
if (r < .56) return `${expr(depth + 1)}(${expr(depth + 1)},${rng() < .25 ? '' : expr(depth + 1)})`;
306350
return `${expr(depth + 1)}${pick(['+', '-', '*', '/'])}${expr(depth + 1)}`;
307351
}
352+
/** Nested grouping for choice-arm Expr tails. */
353+
function groupedExpr(depth = 1): string {
354+
let e = atom();
355+
for (let i = 0; i < depth; i++) e = `(${e})`;
356+
return e;
357+
}
308358
function validItem(): string {
309359
const r = rng();
310360
if (r < .18) return `bang ${rng() < .5 ? '!' : '!!'}${atom()}`;
311361
if (r < .40) {
312-
const tail = pick([`:${expr()}`, `=${expr()}`, '']);
362+
const nest = 1 + Math.floor(rng() * 4);
363+
const tail = pick([
364+
`:${expr()}`,
365+
`=${expr()}`,
366+
`:${groupedExpr(nest)}`,
367+
`=${groupedExpr(nest)}`,
368+
'',
369+
]);
313370
return `tag ${pick(ids)}${tail}`;
314371
}
315372
if (r < .56) return `guard ${pick(ids)}${rng() < .5 ? `:${pick(ids)}` : ''}`;
@@ -320,6 +377,11 @@ export function buildToyCorpus(seed = 0x5a2_2026): { src: string; source: string
320377
}
321378
return expr();
322379
}
380+
/** Always 2–5 statements (SH2-0b multi-stmt coverage). */
381+
function multiProgram(): string {
382+
const n = 2 + Math.floor(rng() * 4);
383+
return Array.from({ length: n }, () => validItem() + ';').join(rng() < .3 ? '\n' : ' ');
384+
}
323385
function validProgram(): string {
324386
const n = Math.floor(rng() * 5);
325387
return Array.from({ length: n }, () => validItem() + ';').join(rng() < .3 ? '\n' : ' ');
@@ -331,14 +393,34 @@ export function buildToyCorpus(seed = 0x5a2_2026): { src: string; source: string
331393
'tag x', 'bang !!x', 'unknown unknown;', 'guard ;', 'args(1 2);',
332394
]);
333395
}
396+
// Planner adversarial 112 cases (depth groups + fragment cross-product).
397+
const advCases: string[] = [];
398+
for (let d = 1; d <= 40; d += 3) {
399+
advCases.push('tag x' + '='.repeat(1) + '('.repeat(d) + '1' + ')'.repeat(d) + ';');
400+
}
401+
const frag = [
402+
'tag x:1;', 'tag y=2;', 'tag z;', 'bang!x;', 'bang x;', 'f(1,2,)( );', 'a:(-b);',
403+
'tag x:1', 'tag x=;', 'tag :1;', 'bang !;', 'f(,);', 'f(1,,2);', 'tag x:1;tag y=2;tag z;',
404+
];
405+
for (const a of frag) for (const b of frag.slice(0, 7)) advCases.push(a + b);
406+
334407
const anchors = [
335408
'', 'bang !x;', 'bang !!x;', 'bang !!!x;', 'tag x:1;', 'tag x=1;', 'tag x;',
336409
'tag x:;', 'tag x=;', 'guard bad;', 'guard good;', 'guard good:a;',
337410
'args();', 'args(1,);', 'args(1,2);', 'f();', 'f(1,);', 'f(1)(2);',
338411
'1+2*3;', '-x(1);', 'tag t:f(1,2); bang !!7;',
412+
// SH2-0b: choice-arm nested groups + multi-stmt
413+
'tag x=(1);', 'tag x=((1));', 'tag y=(((2)));', 'tag z:(3);', 'tag z:((a));',
414+
'tag x:1;tag y=2;', 'bang!x;tag z;', 'tag x=(1);tag y=2;tag z;',
415+
'tag a=(1);tag b=((2));tag c:(((3)));bang!x;guard ok;',
339416
].map((src) => ({ src, source: 'boundary' }));
340-
const corpus = [...anchors];
341-
while (corpus.length < 360) corpus.push({ src: validProgram(), source: 'random-valid' });
342-
while (corpus.length < 520) corpus.push({ src: invalidProgram(), source: 'random-invalid' });
417+
418+
const corpus = [
419+
...anchors,
420+
...advCases.map((src) => ({ src, source: 'adv-112' as const })),
421+
];
422+
while (corpus.length < 560) corpus.push({ src: multiProgram(), source: 'multi-stmt' });
423+
while (corpus.length < 680) corpus.push({ src: validProgram(), source: 'random-valid' });
424+
while (corpus.length < 800) corpus.push({ src: invalidProgram(), source: 'random-invalid' });
343425
return corpus;
344426
}

test/shape-parity.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Gate: SH2-0 shape CST↔parseAst parity — calc+toy corpus ≥800, toy golden 10/10,
1+
// Gate: SH2-0b shape CST↔parseAst parity — calc+toy corpus ≥1200, toy golden 10/10,
22
// coverage table, typescript+SH0 fail-fast (no home-path permanent dependency).
33
import { mkdirSync, writeFileSync } from 'node:fs';
44
import { pathToFileURL } from 'node:url';
@@ -9,7 +9,7 @@ import { calcShape } from '../src/shape-calc.ts';
99
import type { ShapeSpec } from '../src/shape-schema.ts';
1010
import calcGrammar from './fixtures/calc.ts';
1111
import toyGrammar, {
12-
toyShape, toyCustoms, toyGolden, buildToyCorpus, type ToyAstCustoms,
12+
toyShape, toyCustoms, toyTaggedCustomShape, toyGolden, buildToyCorpus, type ToyAstCustoms,
1313
} from './fixtures/shape-toy.ts';
1414
import typescriptGrammar from '../typescript.ts';
1515

@@ -83,11 +83,15 @@ function buildCalcCorpus(): { src: string; source: string }[] {
8383
'let x = 1;', '1 + 2;', '1 + 2 * 3;', '-a;', '(1);',
8484
'let a = 1; let b = 2; a + b;', '1 - 2 - 3;', '-(a * b);',
8585
'foo; bar; baz;', '2 / 3;', '--x;',
86+
// SH2-0b: choice-arm group + multi-stmt (star Stmt)
87+
'let x = (1+2);', 'let y = ((3));', 'let a = (1); let b = (2+3); a + b;',
88+
'let x = 1; let y = 2; let z = 3; x + y + z;',
89+
'(1+2); (3);', 'let x = (1+2); 3 + 4;',
8690
'let ;', '1+', '(1', 'let x =', ';;;', 'x = 1;',
8791
].map((src) => ({ src, source: 'boundary' }));
8892
const out = [...anchors];
89-
while (out.length < 220) out.push({ src: prog(), source: 'random-valid' });
90-
while (out.length < 320) {
93+
while (out.length < 320) out.push({ src: prog(), source: 'random-valid' });
94+
while (out.length < 450) {
9195
out.push({
9296
src: pick(['let ;', '1+', '(1', 'let x =', 'x = 1;', '* 2;', 'let let = 1;']),
9397
source: 'random-invalid',
@@ -188,16 +192,18 @@ async function main(): Promise<void> {
188192

189193
let goldenOk = 0;
190194
for (const g of toyGolden) {
191-
const got = toyMod.parseAst(g.src, { customs: toyCustoms });
195+
const got = toyMod.parseAst(g.src);
192196
if (deepEq(got, g.expect)) goldenOk++;
193197
else {
194198
console.error(` golden fail ${JSON.stringify(g.src)}`);
195199
console.error(` got ${JSON.stringify(got)}`);
196200
console.error(` want ${JSON.stringify(g.expect)}`);
197201
}
198202
}
203+
// Multi-alt custom arm + altPath witness (separate emit; default toy is node arms).
204+
const taggedCustomMod = await emitLoad('toy-tagged-custom', toyGrammar, toyTaggedCustomShape);
199205
let customSeen: unknown = null;
200-
const customGot = toyMod.parseAst('tag ctx=9;', {
206+
const customGot = taggedCustomMod.parseAst('tag ctx=9;', {
201207
customs: {
202208
...toyCustoms,
203209
Tagged: (ctx) => {
@@ -224,10 +230,10 @@ async function main(): Promise<void> {
224230
check(goldenOk === 10, `toy golden ${goldenOk}/10`);
225231

226232
const toyCorpus = buildToyCorpus(0x5a2_2026);
227-
check(toyCorpus.length === 520, `toy corpus exact 520 (got ${toyCorpus.length})`);
233+
check(toyCorpus.length === 800, `toy corpus exact 800 (got ${toyCorpus.length})`);
228234
const calcCorpus = buildCalcCorpus();
229235
const totalN = toyCorpus.length + calcCorpus.length;
230-
check(totalN >= 800, `corpus total ≥800 (got ${totalN})`);
236+
check(totalN >= 1200, `corpus total ≥1200 (got ${totalN})`);
231237

232238
function parity(
233239
label: string,
@@ -259,7 +265,7 @@ async function main(): Promise<void> {
259265
return { diverge, cstAcc, astAcc };
260266
}
261267

262-
const toyP = parity('toy', toyMod, toyCorpus, toyCustoms);
268+
const toyP = parity('toy', toyMod, toyCorpus);
263269
const calcP = parity('calc', calcMod, calcCorpus);
264270

265271
const calcSpot = stripSpans(calcMod.parseAst('let x = 1; 2 + 3;'));
@@ -273,6 +279,20 @@ async function main(): Promise<void> {
273279
}),
274280
'calc spot golden',
275281
);
282+
const calcGroup = stripSpans(calcMod.parseAst('let x = (1+2); let y = ((3));'));
283+
check(
284+
deepEq(calcGroup, {
285+
type: 'Program',
286+
body: [
287+
{
288+
type: 'LetStatement', id: 'x',
289+
init: { type: 'BinaryExpression', left: 1, operator: '+', right: 2 },
290+
},
291+
{ type: 'LetStatement', id: 'y', init: 3 },
292+
],
293+
}),
294+
'calc group-in-let + multi-stmt',
295+
);
276296

277297
let tsErr = '';
278298
try {

0 commit comments

Comments
 (0)