speckl/compiler/src/parser.ts
Scott Scoble e6f95ddf0b 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
2026-06-09 13:53:02 -07:00

1521 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'fs';
import path from 'path';
// AST node types for SpeckDL
export interface SpeckNode {
type: 'speck';
name: string;
members: MemberNode[];
}
export interface ImportNode {
type: 'import';
path: string;
alias?: string;
version?: string;
hash?: string;
}
export interface InputNode {
type: 'input';
typeExpr: TypeExpr;
}
export interface OutputNode {
type: 'output';
typeExpr: TypeExpr;
}
export interface ConstraintNode {
type: 'constraint';
name?: string;
expr: string;
}
export interface VerifyNode {
type: 'verify';
name: string;
temporalExpr: string;
depth?: number;
}
export interface InterfaceNode {
type: 'interface';
name: string;
methods: MethodSignature[];
}
export interface MethodSignature {
name: string;
params: { name: string; type: TypeExpr }[];
returnType: TypeExpr;
}
export interface EventNode {
type: 'event';
name: string;
fields: { name: string; type: TypeExpr }[];
}
export interface ProvenanceNode {
type: 'provenance';
clauses: ProvenanceClause[];
}
export interface ProvenanceClause {
type: 'regulation' | 'design_decision' | 'parent_spec' | 'external_doc';
value: string;
location?: string; // for external_doc
}
export interface ReviewNode {
type: 'review';
kind: 'manual' | 'auto' | 'hybrid';
}
export interface DerivesNode {
type: 'derives';
from: string;
via?: string;
}
export interface SatisfiesNode {
type: 'satisfies';
requirement: string;
clause?: string;
}
export interface AuthorNode {
type: 'author';
name: string;
email?: string;
}
export interface SourceNode {
type: 'source';
kind: 'conversation' | 'meeting' | 'document' | 'regulation' |
'architecture_review' | 'threat_model' | 'compliance_audit';
ref?: string;
}
export interface BOMNode {
type: 'bom';
compiler?: { name: string; version?: string };
solver?: { name: string; version?: string };
runtime?: { name: string; version?: string };
license?: string;
hash?: string;
}
// State machine extension (v0.3)
export interface StateNode {
type: 'state';
variables: StateVar[];
}
export interface StateVar {
name: string;
typeExpr: TypeExpr;
defaultInit?: string; // default initializer expression as raw string
}
export interface InitNode {
type: 'init';
assignments: { name: string; expr: string }[];
}
export interface ActionNode {
type: 'action';
name: string;
params: { name: string; type: TypeExpr }[];
statements: ActionStatement[];
}
export type ActionStatement =
| { type: 'assign'; target: string; expr: string }
| { type: 'let'; name: string; expr: string }
| { type: 'require'; expr: string }
| { type: 'precondition'; expr: string }
| { type: 'postcondition'; expr: string }
| { type: 'emit'; event: string; fields: { name: string; value: string }[] }
| { type: 'return'; expr: string }
| { type: 'ifblock'; raw: string };
export type MemberNode =
| ImportNode
| InputNode
| OutputNode
| ConstraintNode
| VerifyNode
| InterfaceNode
| EventNode
| ProvenanceNode
| ReviewNode
| DerivesNode
| SatisfiesNode
| AuthorNode
| SourceNode
| BOMNode
| StateNode
| InitNode
| ActionNode;
export interface TypeExpr {
type: 'primitive' | 'record' | 'list' | 'set' | 'map' | 'ident';
name?: string;
fields?: { name: string; type: TypeExpr }[];
elementType?: TypeExpr;
keyType?: TypeExpr; // for Map<K, V>
valueType?: TypeExpr; // for Map<K, V>
}
export interface AST {
specks: SpeckNode[];
}
/**
* Parse a .speck file and return the AST
*/
export function parseSpeckFile(filePath: string): AST {
const content = fs.readFileSync(filePath, 'utf-8');
return parseSpeckContent(content, filePath);
}
/**
* Parse SpeckDL content and return the AST
*/
export function parseSpeckContent(content: string, filePath?: string): AST {
const specks: SpeckNode[] = [];
const lines = content.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i].trim();
// Skip empty lines and comments
if (!line || line.startsWith('//') || line.startsWith('/*')) {
i++;
continue;
}
// Handle import statements
if (line.startsWith('import ')) {
const importNode = parseImportStatement(line);
// Import is a special case - it's not part of a speck
// We'll handle it separately in a future update
i++;
continue;
}
// Handle speck definitions
if (line.startsWith('speck ')) {
const speck = parseSpeck(lines, i);
if (speck) {
specks.push(speck);
// Move index past the speck we just parsed
i++; // Increment by 1, the loop will continue from next line
} else {
i++;
}
continue;
}
i++;
}
return { specks };
}
function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
const headerMatch = lines[startIndex].match(/^speck\s+([\w-]+)\s*{/);
if (!headerMatch) return null;
const name = headerMatch[1];
const members: MemberNode[] = [];
let i = startIndex + 1;
let braceCount = 1;
while (i < lines.length && braceCount > 0) {
const rawLine = lines[i];
const line = rawLine.trim();
if (!line || line.startsWith('//') || line.startsWith('/*')) {
i++;
continue;
}
// Count braces on this line first
const openCount = (rawLine.match(/{/g) || []).length;
const closeCount = (rawLine.match(/}/g) || []).length;
// 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 ', 'input:', 'output:'];
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
if (isBlockStarter && openCount > 0) {
// Multi-line block: parse it and skip to its end
const member = parseMemberBlock(lines, i);
if (member) {
members.push(member);
}
// Skip past this block if it spans multiple lines
if (closeCount < openCount) {
const endIdx = findBlockEnd(lines, i + 1, braceCount + openCount);
i = endIdx + 1;
} else {
i++;
}
continue;
}
// Handle constraint/verify blocks that may span multiple lines without braces
// (e.g. constraint Name: forall r in [0..4]:, verify: always(implies(\n ...\n)))
if (line.startsWith('constraint ') || line.startsWith('verify ') || line.startsWith('verify:')) {
const member = parseMultiLineConstraintVerify(lines, i);
if (member) {
members.push(member);
// Skip past the consumed continuation lines
while (i + 1 < lines.length) {
const nextLine = lines[i + 1].trim();
if (!nextLine || nextLine.startsWith('//') || nextLine.startsWith('/*') ||
nextLine.startsWith('constraint ') || nextLine.startsWith('verify ') ||
nextLine.startsWith('constraint:') || nextLine.startsWith('verify:') ||
nextLine.startsWith('state') || nextLine.startsWith('init') ||
nextLine.startsWith('action ') || nextLine.startsWith('event ') ||
nextLine.startsWith('interface ') || nextLine.startsWith('provenance ') ||
nextLine.startsWith('bom ') || nextLine.startsWith('}') ||
nextLine.startsWith('next:') || nextLine.startsWith('invariant ')) {
break;
}
i++;
}
}
i++;
continue;
}
// Single-line: update brace count, then parse if still inside
braceCount += openCount - closeCount;
if (braceCount <= 0) {
i++;
break;
}
const member = parseMember(line);
if (member) {
members.push(member);
}
i++;
}
return { type: 'speck', name, members };
}
function findBlockEnd(lines: string[], startIndex: number, startBraceCount: number): number {
let braceCount = startBraceCount;
let i = startIndex;
while (i < lines.length) {
const line = lines[i];
for (const ch of line) {
if (ch === '{') braceCount++;
if (ch === '}') {
braceCount--;
if (braceCount < startBraceCount) {
return i;
}
}
}
i++;
}
return i;
}
// Check if an expression has balanced braces, parens, and brackets
function isExpressionComplete(expr: string): boolean {
let braces = 0, parens = 0, brackets = 0;
for (const char of expr) {
if (char === '{') braces++;
else if (char === '}') braces--;
else if (char === '(') parens++;
else if (char === ')') parens--;
else if (char === '[') brackets++;
else if (char === ']') brackets--;
}
return braces === 0 && parens === 0 && brackets === 0;
}
function parseMemberBlock(lines: string[], startIndex: number): MemberNode | null {
const firstLine = lines[startIndex].trim();
const startBraceCount = (firstLine.match(/{/g) || []).length;
if (firstLine.startsWith('state:')) {
return parseStateBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('state {') || firstLine === 'state') {
return parseStateBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('init:')) {
return parseInitBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('init {') || firstLine === 'init') {
return parseInitBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('action ')) {
return parseActionBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('event ')) {
return parseEventBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('interface ')) {
return parseInterfaceBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('provenance ')) {
return { type: 'provenance', clauses: [] };
}
if (firstLine.startsWith('bom ')) {
return { type: 'bom' };
}
if (firstLine.startsWith('verify ')) {
return parseVerifyBlockMultiline(lines, startIndex, startBraceCount);
}
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:')) {
const typeExpr = parseTypeExpr(line.substring(6).trim());
return { type: 'input', typeExpr };
}
// output:
if (line.startsWith('output:')) {
const typeExpr = parseTypeExpr(line.substring(7).trim());
return { type: 'output', typeExpr };
}
// constraint Name: expr (single-line named constraint)
if (line.startsWith('constraint ')) {
const match = line.match(/^constraint\s+(\w+):\s*(.+)$/);
if (match) {
return { type: 'constraint', name: match[1], expr: match[2].trim() };
}
return null; // multi-line constraint block (handled by parseMemberBlock)
}
// constraint: expr (unnamed, single-line)
if (line.startsWith('constraint:')) {
return { type: 'constraint', expr: line.substring(11).trim() };
}
// verify Always(Name) { depth N } or verify Always(Name) (single-line forms)
if (line.startsWith('verify ')) {
return parseVerifySingleLine(line);
}
// verify: expr (old unnamed single-line)
if (line.startsWith('verify:')) {
return { type: 'verify', name: 'unnamed', temporalExpr: line.substring(7).trim() };
}
// interface:
if (line.startsWith('interface ')) {
return parseInterface(line);
}
// event:
if (line.startsWith('event ')) {
return parseEvent(line);
}
// provenance {
if (line.startsWith('provenance ')) {
return { type: 'provenance', clauses: [] };
}
// review:
if (line.startsWith('review:')) {
const kind = line.substring(7).trim() as 'manual' | 'auto' | 'hybrid';
return { type: 'review', kind };
}
// derives from:
if (line.startsWith('derives from ')) {
return parseDerives(line);
}
// satisfies:
if (line.startsWith('satisfies ')) {
return parseSatisfies(line);
}
// author:
if (line.startsWith('author:')) {
return parseAuthor(line);
}
// source:
if (line.startsWith('source:')) {
return parseSource(line);
}
// state: or state {
if (line.startsWith('state:')) {
const varsStr = line.substring(6).trim();
// state: { ... } - single-line form
if (varsStr.startsWith('{') && varsStr.endsWith('}')) {
const inner = varsStr.slice(1, -1).trim();
return parseStateBlock(inner);
}
// state: { - multi-line opening (handled by parseStateMultiline)
return { type: 'state', variables: [] };
}
if (line.startsWith('state {') || line === 'state') {
// state { ... } inline or state { on its own line (handled by parseMemberBlock)
if (line.startsWith('state {')) {
const inner = line.substring(6).trim();
const content = inner.startsWith('{') ? inner.slice(1).trim() : inner;
if (content.endsWith('}')) {
return parseStateBlock(content.slice(0, -1).trim());
}
}
return { type: 'state', variables: [] };
}
// init: or init {
if (line.startsWith('init:')) {
const initStr = line.substring(5).trim();
if (initStr.startsWith('{') && initStr.endsWith('}')) {
const inner = initStr.slice(1, -1).trim();
return parseInitBlock(inner);
}
return { type: 'init', assignments: [] };
}
if (line.startsWith('init {') || line === 'init') {
// init { ... } inline
if (line.startsWith('init {')) {
const inner = line.substring(5).trim();
const content = inner.startsWith('{') ? inner.slice(1).trim() : inner;
if (content.endsWith('}')) {
return parseInitBlock(content.slice(0, -1).trim());
}
}
return { type: 'init', assignments: [] };
}
// action keyword
if (line.startsWith('action ')) {
return parseActionHeader(line);
}
// bom {
if (line.startsWith('bom ')) {
// Simplified - full BOM block parsing would need multi-line context
return {
type: 'bom',
};
}
return null;
}
function parseStateBlock(inner: string): StateNode {
const variables: StateVar[] = [];
if (!inner.trim()) return { type: 'state', variables };
const varStrs = inner.split(/;|\n/).map(s => s.trim()).filter(Boolean);
for (const vStr of varStrs) {
const withDefault = vStr.match(/^(\w+)\s*:\s*(.+?)\s*:=\s*(.+)$/);
if (withDefault) {
// Strip trailing comments from type expression
let typeStr = withDefault[2].trim();
const commentIdx = typeStr.indexOf('//');
if (commentIdx >= 0) typeStr = typeStr.substring(0, commentIdx).trim();
variables.push({
name: withDefault[1],
typeExpr: parseTypeExpr(typeStr),
defaultInit: withDefault[3].trim(),
});
continue;
}
const withoutDefault = vStr.match(/^(\w+)\s*:\s*(.+)$/);
if (withoutDefault) {
// Strip trailing comments from type expression
let typeStr = withoutDefault[2].trim();
const commentIdx = typeStr.indexOf('//');
if (commentIdx >= 0) typeStr = typeStr.substring(0, commentIdx).trim();
variables.push({
name: withoutDefault[1],
typeExpr: parseTypeExpr(typeStr),
});
}
}
return { type: 'state', variables };
}
function parseInitBlock(inner: string): InitNode {
const assignments: { name: string; expr: string }[] = [];
if (!inner.trim()) return { type: 'init', assignments };
const assignStrs = inner.split(/;|\n/).map(s => s.trim()).filter(Boolean);
for (const aStr of assignStrs) {
const match = aStr.match(/^(\w+)\s*:=\s*(.+)$/);
if (match) {
assignments.push({ name: match[1], expr: match[2].trim() });
}
}
return { type: 'init', assignments };
}
function parseActionHeader(line: string): ActionNode | null {
const match = line.match(/^action\s+(\w+)\s*(?:\(([^)]*)\))?\s*\{/);
if (!match) return null;
const name = match[1];
const paramsStr = match[2] || '';
const params: { name: string; type: TypeExpr }[] = [];
if (paramsStr.trim()) {
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
for (const p of paramPairs) {
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/);
if (pMatch) {
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) });
}
}
}
return { type: 'action', name, params, statements: [] };
}
function parseImportStatement(line: string): ImportNode {
const match = line.match(/^import\s+(['"])(.+?)\1(?:\s+as\s+(\w+))?(?:\s+version\s+(['"])(.+?)\4)?(?:\s+hash\s+(['"])(.+?)\6)?/);
if (!match) {
throw new Error(`Failed to parse import statement: ${line}`);
}
const [, , path, , alias, , version, , hash] = match;
return {
type: 'import',
path,
alias,
version: version || undefined,
hash: hash || undefined,
};
}
function parseTypeExpr(expr: string): TypeExpr {
expr = expr.trim();
// Primitive types
const primitives = ['Nat', 'Int', 'Real', 'Bool', 'String', 'Bytes'];
if (primitives.includes(expr)) {
return { type: 'primitive', name: expr };
}
// List<T>
if (expr.startsWith('List<') && expr.endsWith('>')) {
const inner = expr.substring(5, expr.length - 1).trim();
return { type: 'list', elementType: parseTypeExpr(inner) };
}
// Set<T>
if (expr.startsWith('Set<') && expr.endsWith('>')) {
const inner = expr.substring(4, expr.length - 1).trim();
return { type: 'set', elementType: parseTypeExpr(inner) };
}
// Map<K, V>
if (expr.startsWith('Map<') && expr.endsWith('>')) {
const inner = expr.substring(4, expr.length - 1);
// Find the first-level comma (not inside nested generics or record literals)
let depth = 0;
let braceDepth = 0;
let commaIdx = -1;
for (let i = 0; i < inner.length; i++) {
if (inner[i] === '<' || inner[i] === '(') depth++;
else if (inner[i] === '>' || inner[i] === ')') depth--;
else if (inner[i] === '{') braceDepth++;
else if (inner[i] === '}') braceDepth--;
else if (inner[i] === ',' && depth === 0 && braceDepth === 0) {
commaIdx = i;
break;
}
}
if (commaIdx !== -1) {
const keyStr = inner.substring(0, commaIdx).trim();
const valStr = inner.substring(commaIdx + 1).trim();
return { type: 'map', keyType: parseTypeExpr(keyStr), valueType: parseTypeExpr(valStr) };
}
// Fallback: treat as ident if malformed
return { type: 'ident', name: expr };
}
// List(T) — parenthetical syntax (SpeckDL canonical form)
if (expr.startsWith('List(') && expr.endsWith(')')) {
const inner = expr.substring(5, expr.length - 1).trim();
return { type: 'list', elementType: parseTypeExpr(inner) };
}
// Set(T) — parenthetical syntax (SpeckDL canonical form)
if (expr.startsWith('Set(') && expr.endsWith(')')) {
const inner = expr.substring(4, expr.length - 1).trim();
return { type: 'set', elementType: parseTypeExpr(inner) };
}
// Map(K, V) — parenthetical syntax (SpeckDL canonical form)
if (expr.startsWith('Map(') && expr.endsWith(')')) {
const inner = expr.substring(4, expr.length - 1);
let depth = 0;
let braceDepth = 0;
let commaIdx = -1;
for (let i = 0; i < inner.length; i++) {
if (inner[i] === '(' || inner[i] === '<') depth++;
else if (inner[i] === ')' || inner[i] === '>') depth--;
else if (inner[i] === '{') braceDepth++;
else if (inner[i] === '}') braceDepth--;
else if (inner[i] === ',' && depth === 0 && braceDepth === 0) {
commaIdx = i;
break;
}
}
if (commaIdx !== -1) {
const keyStr = inner.substring(0, commaIdx).trim();
const valStr = inner.substring(commaIdx + 1).trim();
return { type: 'map', keyType: parseTypeExpr(keyStr), valueType: parseTypeExpr(valStr) };
}
// Fallback: treat as ident if malformed
return { type: 'ident', name: expr };
}
// List type: [T]
if (expr.startsWith('[') && expr.endsWith(']')) {
const elementType = parseTypeExpr(expr.substring(1, expr.length - 1));
return { type: 'list', elementType };
}
// Record type: { ... }
if (expr.startsWith('{') && expr.endsWith('}')) {
const content = expr.substring(1, expr.length - 1).trim();
const fields: { name: string; type: TypeExpr }[] = [];
if (content) {
// Split by comma and parse each field
const fieldStrs = content.split(',').map(s => s.trim());
for (const fieldStr of fieldStrs) {
const fieldMatch = fieldStr.match(/^(\w+)\s*:\s*(.+)$/);
if (fieldMatch) {
const [, name, typeStr] = fieldMatch;
fields.push({ name, type: parseTypeExpr(typeStr.trim()) });
}
}
}
return { type: 'record', fields };
}
// Identifier (for interface references, etc.)
return { type: 'ident', name: expr };
}
function parseInterface(line: string): InterfaceNode {
// interface Name {
const match = line.match(/^interface\s+(\w+)\s*{/);
if (!match) {
throw new Error(`Failed to parse interface: ${line}`);
}
const name = match[1];
// This is a simplified parser - in a real implementation, we'd need to handle multi-line interfaces
return {
type: 'interface',
name,
methods: [],
};
}
function parseEvent(line: string): EventNode {
// event Name {
const match = line.match(/^event\s+(\w+)\s*{/);
if (!match) {
throw new Error(`Failed to parse event: ${line}`);
}
const name = match[1];
return {
type: 'event',
name,
fields: [],
};
}
function parseProvenanceBlock(lines: string[], line: string): ProvenanceNode {
// This is a simplified parser - in a real implementation, we'd parse the full block
return {
type: 'provenance',
clauses: [],
};
}
function parseDerives(line: string): DerivesNode {
// derives from Name via "rationale"
const match = line.match(/^derives\s+from\s+(\w+(?:-\w+)*)(?:\s+via\s+(['"])(.+?)\2)?/);
if (!match) {
console.warn(`Failed to parse derives statement: ${line}`);
return { type: 'derives', from: 'unknown' };
}
const [, from, , via] = match;
return {
type: 'derives',
from,
via: via || undefined,
};
}
function parseSatisfies(line: string): SatisfiesNode {
// satisfies Name [clause "X.Y.Z"]
// e.g., satisfies RESILIENCE-REQ-01 clause "3.1.2"
// e.g., satisfies RESILIENCE-REQ-02
const match = line.match(/^satisfies\s+([\w-]+(?:-[\w]+)*)(?:\s+clause\s+(['"])(.+?)\2)?/);
if (!match) {
console.warn(`Failed to parse satisfies statement: ${line}`);
return { type: 'satisfies', requirement: 'unknown' };
}
const [, requirement, , clause] = match;
return {
type: 'satisfies',
requirement,
clause: clause || undefined,
};
}
function parseAuthor(line: string): AuthorNode {
// author: "Name" <"email">
// e.g., author: "Scott Scoble" <"scott@scoble.me">
const match = line.match(/^author:\s+(['"])(.+?)\1(?:\s+<(['"])(.+?)\3>)?/);
if (!match) {
// Try without angle brackets or with different format
console.warn(`Failed to parse author statement: ${line}`);
return { type: 'author', name: 'unknown' };
}
const [, , name, , email] = match;
return {
type: 'author',
name,
email: email || undefined,
};
}
function parseSource(line: string): SourceNode {
// source: kind ref "identifier"
// e.g., source: architecture_review ref "AR-2024-0215"
const match = line.match(/^source:\s+(\w+)(?:\s+ref\s+(['"])(.+?)\2)?/);
if (!match) {
console.warn(`Failed to parse source statement: ${line}`);
return { type: 'source', kind: 'document' };
}
const [, kind, , ref] = match;
const validKinds = [
'conversation', 'meeting', 'document', 'regulation',
'architecture_review', 'threat_model', 'compliance_audit'
];
if (!validKinds.includes(kind)) {
console.warn(`Invalid source kind: ${kind}, defaulting to document`);
return { type: 'source', kind: 'document', ref: ref || undefined };
}
return {
type: 'source',
kind: kind as any,
ref: ref || undefined,
};
}
function parseStateBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): StateNode {
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
// State blocks separate fields with commas or newlines/semicolons.
// Strip trailing commas (they are field separators, not continuations)
// then join with semicolons between complete variable declarations.
const cleanedLines = innerLines.map(l => l.replace(/,+\s*$/, ''));
const inner = cleanedLines.reduce((acc, line, i) => {
if (i === 0) return line;
const prevLine = cleanedLines[i - 1];
// Check if this line starts a new variable declaration (name: type)
const isNewVar = /^\w+\s*:/.test(line);
// Check if prev line ends with a continuation character (brace or paren, NOT comma)
const prevIsContinuation = /[{(]\s*$/.test(prevLine);
if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line;
}
return acc + ' ' + line;
}, '');
return parseStateBlock(inner);
}
function parseInitBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): InitNode {
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
// Init blocks separate fields with commas or newlines/semicolons.
// Strip trailing commas and join with semicolons.
const cleanedLines = innerLines.map(l => l.replace(/,+\s*$/, ''));
const inner = cleanedLines.reduce((acc, line, i) => {
if (i === 0) return line;
const prevLine = cleanedLines[i - 1];
const isNewVar = /^\w+\s*:/.test(line);
const prevIsContinuation = /[{(]\s*$/.test(prevLine);
if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line;
}
return acc + ' ' + line;
}, '');
return parseInitBlock(inner);
}
function parseActionBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): ActionNode | null {
const firstLine = lines[startIndex].trim();
const header = parseActionHeader(firstLine);
if (!header) return null;
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
// Keep blank lines as null to separate statements, filter only comments
const bodyLinesRaw = lines.slice(startIndex + 1, endIndex).map(l => {
const trimmed = l.trim();
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) return null;
return trimmed;
});
// Collapse consecutive nulls and strip trailing nulls
const bodyLines: (string | null)[] = [];
for (const line of bodyLinesRaw) {
if (line !== null || (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] !== null)) {
bodyLines.push(line);
}
}
// Remove trailing nulls
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] === null) {
bodyLines.pop();
}
const statements: ActionStatement[] = [];
for (let i = 0; i < bodyLines.length; i++) {
const stmtOrNull = bodyLines[i];
// Skip blank line separators
if (stmtOrNull === null) continue;
const stmt = stmtOrNull;
// precondition: expr
if (stmt.startsWith('precondition:')) {
let expr = stmt.substring(13).trim();
// support 'or' continuation lines
while (i + 1 < bodyLines.length) {
const nextLine = bodyLines[i + 1];
if (nextLine === null || !nextLine.startsWith('or ')) break;
i++;
expr += ' || ' + nextLine.substring(3).trim();
}
statements.push({ type: 'precondition', expr });
continue;
}
// postcondition: expr
if (stmt.startsWith('postcondition:')) {
let expr = stmt.substring(14).trim();
// support 'or' continuation lines
while (i + 1 < bodyLines.length) {
const nextLine = bodyLines[i + 1];
if (nextLine === null || !nextLine.startsWith('or ')) break;
i++;
expr += ' || ' + nextLine.substring(3).trim();
}
statements.push({ type: 'postcondition', expr });
continue;
}
// require expr
if (stmt.startsWith('require ')) {
let expr = stmt.substring(8).trim();
// support multi-line continuation (forall, or, etc.)
while (i + 1 < bodyLines.length) {
const nextLine = bodyLines[i + 1];
if (nextLine === null) break;
// Stop if next line starts a new statement keyword
if (nextLine.startsWith('precondition:') || nextLine.startsWith('postcondition:') ||
nextLine.startsWith('require ') || nextLine.startsWith('return ') ||
nextLine.startsWith('emit ') || nextLine.startsWith('let ') ||
nextLine.includes(':=')) {
break;
}
i++;
expr += ' ' + nextLine;
}
statements.push({ type: 'require', expr });
continue;
}
// return expr
if (stmt.startsWith('return ')) {
statements.push({ type: 'return', expr: stmt.substring(7).trim() });
continue;
}
// emit Event { fields }
const emitMatch = stmt.match(/^emit\s+(\w+)\s*\{\s*(.*)\s*\}$/);
if (emitMatch) {
const eventName = emitMatch[1];
const fieldsStr = emitMatch[2];
const fields: { name: string; value: string }[] = [];
if (fieldsStr) {
const fieldPairs = splitFields(fieldsStr);
for (const fp of fieldPairs) {
const colonIdx = fp.indexOf(':');
if (colonIdx > 0) {
fields.push({
name: fp.substring(0, colonIdx).trim(),
value: fp.substring(colonIdx + 1).trim(),
});
}
}
}
statements.push({ type: 'emit', event: eventName, fields });
continue;
}
// if condition { ... } else if condition { ... } else { ... }
// SpeckDL conditional blocks - parse as a special statement type
if (stmt.startsWith('if ')) {
// Find matching } for this if block (may span multiple bodyLines entries)
let braceDepth = 0;
let j = i;
const blockLines: string[] = [];
while (j < bodyLines.length) {
const line = bodyLines[j];
if (line === null) { j++; continue; }
blockLines.push(line);
for (const ch of line) {
if (ch === '{') braceDepth++;
else if (ch === '}') braceDepth--;
}
j++;
if (braceDepth === 0) break;
}
// For now, treat as a raw 'ifblock' statement that the generator can handle
statements.push({ type: 'ifblock', raw: blockLines.join(' ') });
i = j - 1; // skip to end of if block
continue;
}
// let name := expr
const letMatch = stmt.match(/^let\s+(\w+)\s*:=\s*(.+)$/);
if (letMatch) {
statements.push({ type: 'let', name: letMatch[1], expr: letMatch[2].trim() });
continue;
}
// assignment: target := expr (may span multiple lines)
const assignMatch = stmt.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/);
if (assignMatch) {
let target = assignMatch[1];
let expr = assignMatch[2].trim();
// Handle multiline expressions: join subsequent lines until braces/parens are balanced
while (i + 1 < bodyLines.length && !isExpressionComplete(expr)) {
i++;
expr += ' ' + bodyLines[i];
}
statements.push({ type: 'assign', target, expr });
}
}
return { ...header, statements };
}
function parseEventBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): EventNode {
const firstLine = lines[startIndex].trim();
const header = parseEvent(firstLine);
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const fields: { name: string; type: TypeExpr }[] = [];
for (const line of innerLines) {
const match = line.match(/^(\w+)\s*:\s*(.+?),?$/);
if (match) {
fields.push({ name: match[1], type: parseTypeExpr(match[2].replace(/,$/, '').trim()) });
}
}
return { ...header, fields };
}
function parseInterfaceBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): InterfaceNode {
const firstLine = lines[startIndex].trim();
const header = parseInterface(firstLine);
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const methods: MethodSignature[] = [];
for (const line of innerLines) {
// name-only: enum-style values (e.g., "Idle", "Idle, Parsing" comma-separated)
// Split on comma and take each trimmed token
const tokens = line.split(',').map(s => s.trim()).filter(Boolean);
for (const token of tokens) {
// Check if it’s a name(params) -> returnType method signature
const methodMatch = token.match(/^(\w+)\s*\(([^)]*)\)\s*->\s*(.+?),?$/);
if (methodMatch) {
const name = methodMatch[1];
const paramsStr = methodMatch[2];
const returnTypeStr = methodMatch[3].replace(/,$/, '').trim();
const params: { name: string; type: TypeExpr }[] = [];
if (paramsStr.trim()) {
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
for (const p of paramPairs) {
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/);
if (pMatch) {
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) });
}
}
}
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
} else if (/^\w+$/.test(token)) {
// Name-only (enum-style) — treat as a method with no params and void return
methods.push({ name: token, params: [], returnType: parseTypeExpr('Void') });
}
}
}
return { ...header, methods };
}
// --- Multi-line constraint/verify parsing (no braces needed) ---
/**
* Parse a constraint or verify that may span multiple lines without braces.
* Used for forall-style constraints like:
* constraint Name: forall r in [0..4]:
* (expr)
*
* and verify blocks like:
* verify Always(Name)
* verify Always(Name) { depth N }
*/
function parseMultiLineConstraintVerify(lines: string[], startIndex: number): MemberNode | null {
const line = lines[startIndex].trim();
if (line.startsWith('verify ')) {
return parseVerifyAnyForm(lines, startIndex);
}
// Multi-line `verify:` form: verify: always(implies(\n ...\n))
// The body opens parens that span multiple lines; collect continuation
// lines until parens balance to zero, then return the joined string as
// the temporalExpr. speckl#54.
if (line.startsWith('verify:')) {
return parseVerifyColonBlock(lines, startIndex);
}
if (line.startsWith('constraint ')) {
return parseConstraintAnyForm(lines, startIndex);
}
return null;
}
/**
* Parse the `verify: <expr>` form (speckl#54) where the body can span
* multiple lines:
*
* verify: always(implies(
* cond1,
* cond2
* ))
*
* The whole expression (the part after `verify:`) is collected by
* appending subsequent non-empty, non-keyword-starting lines until
* parens/brackets/braces balance to zero. The joined string is stored
* in `temporalExpr` so the downstream LTL translator (typescript-state-machine
* verifyN()) sees a well-formed formula.
*
* The `name` is derived from the LTL operator's argument when it matches
* `always(Name)` / `eventually(Name)`. When the body is a richer expression
* (e.g. `always(implies(...))`) we fall back to `unnamed` — the generator
* still emits a working verifyN() that uses the full expression.
*/
function parseVerifyColonBlock(lines: string[], startIndex: number): VerifyNode | null {
const firstLine = lines[startIndex].trim();
// Strip the `verify:` prefix.
const head = firstLine.substring(firstLine.indexOf(':') + 1).trim();
if (!head) return null;
// If the head is already balanced on its own (single-line verify: expr),
// just return it directly — no continuation needed.
if (isExpressionComplete(head)) {
const nameMatch = head.match(/^(?:always|Always|eventually|Eventually)\s*\(\s*(\w+)\s*\)$/);
return {
type: 'verify',
name: nameMatch ? nameMatch[1] : 'unnamed',
temporalExpr: head,
};
}
// Multi-line: collect continuation lines until parens balance. The same
// break-condition list used in the parseSpeck skip-loop applies (don't
// swallow the next member's keyword). We additionally stop at any
// standalone `}` so a verify inside a brace-block still terminates.
const parts: string[] = [head];
let parens = 0, brackets = 0, braces = 0;
for (const ch of head) {
if (ch === '(') parens++;
else if (ch === ')') parens--;
else if (ch === '[') brackets++;
else if (ch === ']') brackets--;
else if (ch === '{') braces++;
else if (ch === '}') braces--;
}
let lastConsumed = startIndex;
for (let j = startIndex + 1; j < lines.length; j++) {
const raw = lines[j];
const nl = raw.trim();
if (!nl) {
// Empty line still updates counters (none) and advances lastConsumed
lastConsumed = j;
continue;
}
if (nl.startsWith('//') || nl.startsWith('/*')) {
// Skip standalone comments but treat them as part of the block
// (the LTL translator doesn't care about comments in temporalExpr).
lastConsumed = j;
continue;
}
// Stop at a new top-level member keyword — that's the next member.
if (
nl.startsWith('constraint ') || nl.startsWith('verify ') ||
nl.startsWith('constraint:') || nl.startsWith('verify:') ||
nl.startsWith('state') || nl.startsWith('init') ||
nl.startsWith('action ') || nl.startsWith('event ') ||
nl.startsWith('interface ') || nl.startsWith('provenance ') ||
nl.startsWith('bom ') || nl === '}' ||
nl.startsWith('next:') || nl.startsWith('invariant ')
) {
break;
}
// Update depth counters.
for (const ch of raw) {
if (ch === '(') parens++;
else if (ch === ')') parens--;
else if (ch === '[') brackets++;
else if (ch === ']') brackets--;
else if (ch === '{') braces++;
else if (ch === '}') braces--;
}
parts.push(nl);
lastConsumed = j;
if (parens === 0 && brackets === 0 && braces === 0) {
break;
}
}
const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
const nameMatch = joined.match(/^(?:always|Always|eventually|Eventually)\s*\(\s*(\w+)\s*\)$/);
return {
type: 'verify',
name: nameMatch ? nameMatch[1] : 'unnamed',
temporalExpr: joined,
};
}
function parseConstraintAnyForm(lines: string[], startIndex: number): ConstraintNode | null {
const line = lines[startIndex].trim();
const namedMatch = line.match(/^constraint\s+(\w+):(.*)$/);
if (!namedMatch) return null;
const name = namedMatch[1];
let expr = namedMatch[2].trim();
// If opens a brace, treat as block
if (expr.startsWith('{')) {
const afterBrace = expr.substring(1).trim();
const openCount = (line.match(/{/g) || []).length;
const endIdx = findBlockEnd(lines, startIndex + 1, openCount);
const innerLines = lines.slice(startIndex + 1, endIdx).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const inner = innerLines.join(' ');
expr = (afterBrace + ' ' + inner).trim();
} else {
// No braces — but might span multiple lines (forall continuation)
const continuationLines: string[] = [];
for (let j = startIndex + 1; j < lines.length; j++) {
const nl = lines[j].trim();
if (!nl || nl.startsWith('//') || nl.startsWith('/*') ||
nl.startsWith('constraint ') || nl.startsWith('verify ') ||
nl.startsWith('constraint:') || nl.startsWith('verify:') ||
nl.startsWith('state') || nl.startsWith('init') ||
nl.startsWith('action ') || nl.startsWith('event ') ||
nl.startsWith('interface ') || nl.startsWith('provenance ') ||
nl.startsWith('bom ') || nl.startsWith('}') ||
nl.startsWith('next:') || nl.startsWith('invariant ')) {
break;
}
continuationLines.push(nl);
}
if (continuationLines.length > 0) {
expr = expr + ' ' + continuationLines.join(' ');
}
}
return { type: 'constraint', name, expr: expr.trim() };
}
function parseVerifyAnyForm(lines: string[], startIndex: number): VerifyNode | null {
const line = lines[startIndex].trim();
// Single-line: verify Always(Name) { depth N }
const fullMatch = line.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*\{\s*depth\s+(\d+)\s*\}/);
if (fullMatch) {
return {
type: 'verify',
name: fullMatch[2],
temporalExpr: fullMatch[1].trim(),
depth: parseInt(fullMatch[3], 10),
};
}
// Multi-line: verify Always(Name) { ... }
if (line.includes('{')) {
const headerMatch = line.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*\{/);
if (headerMatch) {
const temporalExpr = headerMatch[1].trim();
const name = headerMatch[2];
const openCount = (line.match(/{/g) || []).length;
const endIdx = findBlockEnd(lines, startIndex + 1, openCount);
const innerLines = lines.slice(startIndex + 1, endIdx).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const inner = innerLines.join(' ');
const afterBrace = line.substring(line.indexOf('{') + 1).trim();
const combined = (afterBrace + ' ' + inner).trim();
const depthMatch = combined.match(/\bdepth\s+(\d+)\b/);
const depth = depthMatch ? parseInt(depthMatch[1], 10) : undefined;
return { type: 'verify', name, temporalExpr, depth };
}
}
// Simple: verify Always(Name)
const simpleMatch = line.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*$/);
if (simpleMatch) {
return {
type: 'verify',
name: simpleMatch[2],
temporalExpr: simpleMatch[1].trim(),
};
}
return null;
}
// --- Verify/Constraint block parsing ---
/**
* Parse a verify block opened with `verify Always(Name) { ... }` or similar.
* The first line already contains the header; we need to parse the body inside { }.
*/
function parseVerifyBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): VerifyNode | null {
const firstLine = lines[startIndex].trim();
// Match: verify Always(Name) { depth N } or verify Always(Name) {
const headerMatch = firstLine.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*\{/);
if (!headerMatch) return null;
const temporalExpr = headerMatch[1].trim();
const name = headerMatch[2];
// Check if the brace closes on the same line (single-line form)
const openBraceCount = (firstLine.match(/{/g) || []).length;
const closeBraceCount = (firstLine.match(/}/g) || []).length;
if (closeBraceCount >= openBraceCount) {
// Single-line: parse depth from same line
const afterBrace = firstLine.substring(firstLine.indexOf('{') + 1);
const beforeClose = afterBrace.substring(0, afterBrace.lastIndexOf('}')).trim();
const depthMatch = beforeClose.match(/\bdepth\s+(\d+)\b/);
const depth = depthMatch ? parseInt(depthMatch[1], 10) : undefined;
return { type: 'verify', name, temporalExpr, depth };
}
// Multi-line block
let depth: number | undefined;
const endIdx = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIdx).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const inner = innerLines.join(' ');
const afterBrace = firstLine.substring(firstLine.indexOf('{') + 1).trim();
const depthMatch = (afterBrace + ' ' + inner).match(/\bdepth\s+(\d+)\b/);
if (depthMatch) {
depth = parseInt(depthMatch[1], 10);
}
return { type: 'verify', name, temporalExpr, depth };
}
/**
* Parse a single-line verify statement: verify Always(Name) { depth N }
*/
function parseVerifySingleLine(line: string): VerifyNode | null {
// verify Always(Name) { depth N }
const fullMatch = line.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*\{\s*depth\s+(\d+)\s*\}/);
if (fullMatch) {
return {
type: 'verify',
name: fullMatch[2],
temporalExpr: fullMatch[1].trim(),
depth: parseInt(fullMatch[3], 10),
};
}
// verify Always(Name) (no depth, no braces — just a statement)
const simpleMatch = line.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*$/);
if (simpleMatch) {
return {
type: 'verify',
name: simpleMatch[2],
temporalExpr: simpleMatch[1].trim(),
};
}
return null;
}
/**
* Parse a constraint block opened with `constraint Name: ...` which may
* be multi-line (e.g. forall spanning multiple lines).
*/
function parseConstraintBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): ConstraintNode | null {
const firstLine = lines[startIndex].trim();
// Match: constraint Name: expr (possibly opening a {} block, or just a forall on next line)
// For the SQLiteWAL style: constraint Name: expr (single line, no braces needed)
const namedMatch = firstLine.match(/^constraint\s+(\w+):(.*)$/);
if (namedMatch) {
const name = namedMatch[1];
let expr = namedMatch[2].trim();
// If the expression opens a brace, collect the block
if (expr.startsWith('{')) {
const endIdx = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIdx).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const inner = innerLines.join(' ');
// Combine: content after { on first line + content from inner lines
const afterBrace = expr.substring(1).trim();
expr = (afterBrace + ' ' + inner).trim();
} else {
// No braces, but might still be multiline (forall spanning multiple lines)
// Check if next line(s) continue the expression
const endIdx = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIdx).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
if (innerLines.length > 0) {
expr = expr + ' ' + innerLines.join(' ');
}
}
return { type: 'constraint', name, expr: expr.trim() };
}
return null;
}
/** Split on commas while respecting parenthesized groups and quoted strings */
function splitFields(s: string): string[] {
const result: string[] = [];
let depth = 0;
let inDouble = false;
let inSingle = false;
let start = 0;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
if (inDouble || inSingle) continue;
if (ch === '(' || ch === '{') { depth++; continue; }
if (ch === ')' || ch === '}') { depth--; continue; }
if (ch === ',' && depth === 0) {
result.push(s.substring(start, i).trim());
start = i + 1;
}
}
const last = s.substring(start).trim();
if (last) result.push(last);
return result;
}