fix(parser): parse multi-line input/output blocks as record types (speckl#57)

Multi-line `input: { ... }` and `output: { ... }` blocks fell through
to parseMember which returned `{ type: 'ident', name: '{' }` instead of
a record with fields. Broke RetryHandler.speck and similar real specks.

Fix: add `input:`/`output:` to blockStarters, dispatch to new
parseInputOutputBlock() that brace-counts across newlines, strips
line comments, and delegates to parseTypeExpr's record branch.

CoS-verified-live-close (cycle 126 pattern): subagent hit max_iterations
on the brace-count edge case. CoS applied focused fix + 2 new tests.
39/39 vitest pass.

Test evidence:
- RetryHandler.speck: input typeExpr.type='record' 4 fields
- 37 prior tests + 2 new = 39 passing
This commit is contained in:
Scott Scoble 2026-06-09 13:53:02 -07:00
parent d5eea7e7c7
commit e6f95ddf0b
2 changed files with 116 additions and 2 deletions

View file

@ -100,6 +100,60 @@ speck Second {
}
});
it('should parse input with multi-line record type (speckl#57)', () => {
const content = `speck Test {
input: {
operation: Operation,
maxRetries: Nat,
initialDelay: Real,
maxDelay: Real
}
output: {
success: Bool,
finalResult: String,
attemptCount: Nat,
totalDelay: Real
}
}`;
const ast = parseSpeckContent(content);
const input = ast.specks[0].members[0];
const output = ast.specks[0].members[1];
expect(input.type).toBe('input');
expect(output.type).toBe('output');
if (input.type === 'input') {
expect(input.typeExpr.type).toBe('record');
expect(input.typeExpr.fields).toHaveLength(4);
expect(input.typeExpr.fields![0].name).toBe('operation');
expect(input.typeExpr.fields![3].name).toBe('maxDelay');
}
if (output.type === 'output') {
expect(output.typeExpr.type).toBe('record');
expect(output.typeExpr.fields).toHaveLength(4);
expect(output.typeExpr.fields![0].name).toBe('success');
expect(output.typeExpr.fields![3].name).toBe('totalDelay');
}
});
it('should parse input with multi-line record type, comments inside (speckl#57)', () => {
// Edge case: line comments inside the block
const content = `speck Test {
input: {
// the operation to retry
operation: Operation,
// max number of retry attempts
retries: Nat
}
}`;
const ast = parseSpeckContent(content);
const input = ast.specks[0].members[0];
if (input.type === 'input') {
expect(input.typeExpr.type).toBe('record');
expect(input.typeExpr.fields).toHaveLength(2);
expect(input.typeExpr.fields![0].name).toBe('operation');
expect(input.typeExpr.fields![1].name).toBe('retries');
}
});
it('should parse input with list type', () => {
const content = `speck Test {
input: [Nat]

View file

@ -251,7 +251,7 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
// Check for block-opening members (those that open braces on the same line)
const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface ',
'state {', 'init {', 'verify ', 'constraint '];
'state {', 'init {', 'verify ', 'constraint ', 'input:', 'output:'];
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
if (isBlockStarter && openCount > 0) {
@ -383,10 +383,70 @@ function parseMemberBlock(lines: string[], startIndex: number): MemberNode | nul
if (firstLine.startsWith('constraint ')) {
return parseConstraintBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('input:') || firstLine.startsWith('output:')) {
return parseInputOutputBlock(lines, startIndex, startBraceCount);
}
return null;
}
/**
* Parse a multi-line `input: { ... }` or `output: { ... }` block.
*
* The first line opens with `input: {` or `output: {` and the matching `}`
* may be many lines later (with field declarations on intermediate lines).
* We collect all lines up to the matching `}` (brace-counted) and join the
* inner content into a single record string that `parseTypeExpr` already
* knows how to handle.
*/
function parseInputOutputBlock(
lines: string[],
startIndex: number,
startBraceCount: number,
): MemberNode | null {
const header = lines[startIndex].trim();
const isInput = header.startsWith('input:');
const kind: 'input' | 'output' = isInput ? 'input' : 'output';
// Collect inner content lines, brace-counting across the block.
// startBraceCount is the brace count on the header line (1 for `input: {`).
// We need to find the line where braceCount returns to 0 (i.e. the closing `}`).
const innerLines: string[] = [];
let braceCount = 0;
let i = startIndex;
// First pass: extract inner content from header line (everything after the first `{`).
const headerLine = lines[startIndex].trim();
const headerNoComment = headerLine.replace(/\/\/.*$/, '').trim();
const openIdx = headerNoComment.indexOf('{');
if (openIdx >= 0) {
const after = headerNoComment.substring(openIdx + 1).trim();
if (after) innerLines.push(after);
}
braceCount = 1; // we're inside one `{` from the header
i = startIndex + 1;
while (i < lines.length && braceCount > 0) {
const raw = lines[i];
const stripped = raw.trim();
// Strip line comments (//...) — they cannot be inside a type expression.
const noComment = stripped.replace(/\/\/.*$/, '').trim();
for (const ch of noComment) {
if (ch === '{') braceCount++;
else if (ch === '}') braceCount--;
}
if (braceCount > 0 && noComment) {
// Inside the block — keep content (skip the closing `}` line itself).
innerLines.push(noComment);
}
i++;
}
// Join inner content with newlines; `parseTypeExpr`'s record branch
// splits on commas and matches `name: type` pairs.
const recordExpr = '{ ' + innerLines.join(' ') + ' }';
const typeExpr = parseTypeExpr(recordExpr);
return { type: kind, typeExpr };
}
function parseMember(line: string): MemberNode | null {
// input:
if (line.startsWith('input:')) {