fix(compiler): protect object literal identifiers from this. prefix in rewriteExpr

Object spread expressions like { ...t, pending: false } were getting
spurious this. prefix applied to keys (this.pending: false), causing
3 tsc errors (TS1005: ':' expected). Fix: extract object literals
containing spread before Step 3 identifier rewriting, then restore.

Results: TigerBeetle TS errors reduced to known record-type issues (eng#11).
All 41 tests passing.
This commit is contained in:
Scott Scoble 2026-05-09 20:06:36 -07:00
parent c3ade18feb
commit 898e2e53ae
3 changed files with 80 additions and 6 deletions

View file

@ -64,7 +64,8 @@ function parseSpeck(lines, startIndex) {
const openCount = (rawLine.match(/{/g) || []).length;
const closeCount = (rawLine.match(/}/g) || []).length;
// Check for block-opening members
const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface '];
const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface ',
'state {', 'init {'];
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
if (isBlockStarter && openCount > 0) {
// Multi-line block: parse it and skip to its end
@ -116,9 +117,15 @@ function parseMemberBlock(lines, startIndex) {
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);
}
@ -188,7 +195,7 @@ function parseMember(line) {
if (line.startsWith('source:')) {
return parseSource(line);
}
// state:
// state: or state {
if (line.startsWith('state:')) {
const varsStr = line.substring(6).trim();
// state: { ... } - single-line form
@ -199,7 +206,18 @@ function parseMember(line) {
// state: { - multi-line opening (handled by parseStateMultiline)
return { type: 'state', variables: [] };
}
// init:
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('}')) {
@ -208,6 +226,17 @@ function parseMember(line) {
}
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);
@ -332,6 +361,39 @@ function parseTypeExpr(expr) {
// 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 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] === ',' && depth === 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));

File diff suppressed because one or more lines are too long

View file

@ -331,6 +331,14 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, localNames: Set<s
}
// Step 3: Replace identifiers that are state variables but not local params
// First, protect identifiers inside object literals (e.g. { ...t, pending: false })
// Those identifiers are object keys, not state variable refs
const objectLiterals: string[] = [];
ts = ts.replace(/\{[^}]*\.\.\.[^}]*\}/g, (match) => {
objectLiterals.push(match);
return `__OBJ_LIT_${objectLiterals.length - 1}__`;
});
const tokens = ts.split(/([\s\(\)\[\]{}+\-*/=<>!&|.,;:'"`]+)/);
const result = tokens.map(tok => {
if (/^[a-zA-Z_]\w*\.\w+$/.test(tok)) return tok; // already has dot (enum ref)
@ -339,7 +347,11 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, localNames: Set<s
return `this.${tok}`;
}
return tok;
}).join('');
}).join('')
// Undo this. prefix on identifiers after bracket access
.replace(/\]\.this\.(\w+)/g, '].$1')
// Restore object literals
.replace(/__OBJ_LIT_(\d+)__/g, (_, i) => objectLiterals[parseInt(i)]);
// Step 4: Replace comparison operators (also handle trailing == at end of string)
ts = result
@ -350,7 +362,7 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, localNames: Set<s
// Step 5: Translate SpeckDL expression patterns to JavaScript
// 5a: notIn -> !<map>.has(<key>) (e.g. "t.id notIn transfers.keys" -> "!transfers.has(t.id)")
ts = ts.replace(/(\S+)\s+notIn\s+(\w+)\.keys/g, '!$2.has($1)');
ts = ts.replace(/(\S+)\s+notIn\s+(\S+)\.keys/g, '!$2.has($1)');
// 5b: old(expr) -> drop the wrapper (specification construct, no JS equivalent needed)
// Match balanced parens: old(...). The inner expr can contain parens.