fix: correct enum value rewriting and parser quoted-string handling #16

Closed
agent-engineering wants to merge 1 commit from fix/compiler-enum-rewrite-parser-quotes into main
4 changed files with 110 additions and 18 deletions

View file

@ -498,7 +498,35 @@ function parseActionBlockMultiline(lines, startIndex, startBraceCount) {
const fieldsStr = emitMatch[2];
const fields = [];
if (fieldsStr) {
const fieldPairs = fieldsStr.split(',').map(s => s.trim()).filter(Boolean);
// Split on commas but respect quoted strings (don't split inside "...")
const fieldPairs = [];
let depth = 0;
let inQuote = false;
let current = '';
for (let i = 0; i < fieldsStr.length; i++) {
const ch = fieldsStr[i];
if (ch === '"' && (i === 0 || fieldsStr[i - 1] !== '\\')) {
inQuote = !inQuote;
current += ch;
}
else if (!inQuote && ch === '(') {
depth++;
current += ch;
}
else if (!inQuote && ch === ')') {
depth--;
current += ch;
}
else if (!inQuote && depth === 0 && ch === ',') {
fieldPairs.push(current.trim());
current = '';
}
else {
current += ch;
}
}
if (current.trim())
fieldPairs.push(current.trim());
for (const fp of fieldPairs) {
const colonIdx = fp.indexOf(':');
if (colonIdx > 0) {

File diff suppressed because one or more lines are too long

View file

@ -185,12 +185,21 @@ function emitSpeck(speck: SpeckNode): string {
allKnownStateValues.push(...values);
}
// Build value -> enumName map for correct enum prefixing in init
const initValueToEnum = new Map<string, string>();
for (const [enumName, values] of enumMap) {
const fullEnumName = `${speck.name}${enumName}`;
for (const v of values) {
initValueToEnum.set(v, fullEnumName);
}
}
const initAssignments = initNode
? initNode.assignments.map(a => {
const name = cleanName(a.name);
const expr = cleanExpr(a.expr);
// Replace bare state literals with enum refs in init
const enumRef = replaceStateWithEnum(expr, stateEnumName, allKnownStateValues);
const enumRef = replaceStateWithEnum(expr, stateEnumName, allKnownStateValues, initValueToEnum);
return ` this.${name} = ${enumRef};`;
}).join('\n')
: '';
@ -238,6 +247,7 @@ ${verifyChecks}
const file =
`// Auto-generated by speckl-compile from ${speck.name}\n` +
`// DO NOT EDIT MANUALLY — regenerate from .speckdl source\n\n` +
`import path from 'path';\n\n` +
`${stateEnum}\n\n` +
`${eventInterfaces}\n\n` +
`${classBody}`;
@ -256,7 +266,15 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, speckName: s
const requires = action.statements
.filter(s => s.type === 'require')
.map(s => {
const expr = rewriteExpr(s.expr, stateVarNames, localNames, stateEnumName, knownStateValues);
// Build value -> enumName map for correct enum prefixing
const valueToEnum = new Map<string, string>();
for (const [enumName, values] of enumMap) {
const fullEnumName = `${speckName}${enumName}`;
for (const v of values) {
valueToEnum.set(v, fullEnumName);
}
}
const expr = rewriteExpr(s.expr, stateVarNames, localNames, stateEnumName, knownStateValues, valueToEnum);
return ` if (!(${expr})) throw new Error('Guard failed: ${expr.replace(/'/g, "\\'")}');`;
})
.join('\n');
@ -265,7 +283,8 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, speckName: s
.filter(s => s.type === 'assign')
.map(s => {
const target = cleanName(s.target);
const expr = rewriteExpr(s.expr, stateVarNames, localNames, stateEnumName, knownStateValues);
// For assignments, no enum value rewriting needed
const expr = rewriteExpr(s.expr, stateVarNames, localNames, stateEnumName, knownStateValues, new Map());
return ` this.${target} = ${expr};`;
})
.join('\n');
@ -276,8 +295,16 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, speckName: s
const fields = s.fields
.map(f => {
const name = cleanName(f.name);
const val = rewriteExpr(f.value, stateVarNames, localNames, stateEnumName, knownStateValues);
return ` ${name}: ${val}`;
// Emit fields also need correct enum prefixing for non-state enum values
const emitValueToEnum = new Map();
for (const [enumName, values] of enumMap) {
const fullEnumName = `${speckName}${enumName}`;
for (const v of values) {
emitValueToEnum.set(v, fullEnumName);
}
}
const expr = rewriteExpr(f.value, stateVarNames, localNames, stateEnumName, knownStateValues, emitValueToEnum);
return ` ${name}: ${expr}`;
})
.join(',\n');
return ` this.decisionLog.push({\n${fields}\n } as ${s.event});`;
@ -290,7 +317,7 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, speckName: s
// Rewrite SpeckDL expressions to valid TypeScript
// Only prefix identifiers with `this.` if they are state variables AND not local params
function rewriteExpr(expr: string, stateVarNames: Set<string>, localNames: Set<string>, stateEnumName: string, knownStateValues: string[]): string {
function rewriteExpr(expr: string, stateVarNames: Set<string>, localNames: Set<string>, stateEnumName: string, knownStateValues: string[], valueToEnum?: Map<string, string>): string {
function shouldPrefix(id: string): boolean {
return stateVarNames.has(id) && !localNames.has(id);
}
@ -299,16 +326,18 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, localNames: Set<s
let ts = expr
.replace(/\bnow\(\)/g, 'Date.now()')
.replace(/\blen\(([^)]+)\)/g, '$1.length')
.replace(/\bjoin\(([^,]+),\s*([^)]+)\)/g, '$1.join($2)')
.replace(/\bjoin\(([^,]+),\s*([^)]+)\)/g, 'path.join($1, $2)')
.replace(/\bforall\s+(\w+)\s+in\s+([^:]+):\s*(.+)/g, '$2.every(($1) => $3)');
// Step 2: Replace state enum literals with enum refs BEFORE identifier replacement
const allStateValues = new Set<string>(knownStateValues);
// Add common fallback states
['Open','Grooming','Groomed','Assigned','InProgress','InReview','Merged','Blocked','Closed','Idle','Parsing','Parsed','ProvenanceGenerated','BOMsGenerated','TypeScriptGenerated','WASMGenerated','Done'].forEach(s => allStateValues.add(s));
if (allStateValues.size > 0) {
const stateValueRegex = new RegExp('\\b(' + Array.from(allStateValues).join('|') + ')\\b', 'g');
ts = ts.replace(stateValueRegex, `${stateEnumName}.$1`);
// Step 2: Replace enum values with their correct enum refs BEFORE identifier replacement
if (valueToEnum && valueToEnum.size > 0) {
// Sort by length (longest first) to avoid partial matches
const sortedValues = Array.from(valueToEnum.keys()).sort((a, b) => b.length - a.length);
for (const value of sortedValues) {
const enumRef = valueToEnum.get(value)!;
const regex = new RegExp(`\\b${value}\\b`, 'g');
ts = ts.replace(regex, `${enumRef}.${value}`);
}
}
// Step 3: Replace identifiers that are state variables but not local params
@ -359,7 +388,18 @@ function cleanName(s: string): string {
return s.replace(/,$/, '').trim();
}
function replaceStateWithEnum(expr: string, enumName: string, knownStateValues: string[]): string {
function replaceStateWithEnum(expr: string, enumName: string, knownStateValues: string[], valueToEnum?: Map<string, string>): string {
if (valueToEnum && valueToEnum.size > 0) {
// Sort by length (longest first) to avoid partial matches
const sortedValues = Array.from(valueToEnum.keys()).sort((a, b) => b.length - a.length);
for (const value of sortedValues) {
const enumRef = valueToEnum.get(value)!;
const regex = new RegExp(`\\b${value}\\b`, 'g');
expr = expr.replace(regex, `${enumRef}.${value}`);
}
return expr;
}
const allStateValues = new Set<string>(knownStateValues);
['Open','Grooming','Groomed','Assigned','InProgress','InReview','Merged','Blocked','Closed','Idle','Parsing','Parsed','ProvenanceGenerated','BOMsGenerated','TypeScriptGenerated','WASMGenerated','Done'].forEach(s => allStateValues.add(s));
if (allStateValues.size === 0) return expr;

View file

@ -746,7 +746,31 @@ function parseActionBlockMultiline(lines: string[], startIndex: number, startBra
const fieldsStr = emitMatch[2];
const fields: { name: string; value: string }[] = [];
if (fieldsStr) {
const fieldPairs = fieldsStr.split(',').map(s => s.trim()).filter(Boolean);
// Split on commas but respect quoted strings (don't split inside "...")
const fieldPairs: string[] = [];
let depth = 0;
let inQuote = false;
let current = '';
for (let i = 0; i < fieldsStr.length; i++) {
const ch = fieldsStr[i];
if (ch === '"' && (i === 0 || fieldsStr[i - 1] !== '\\')) {
inQuote = !inQuote;
current += ch;
} else if (!inQuote && ch === '(') {
depth++;
current += ch;
} else if (!inQuote && ch === ')') {
depth--;
current += ch;
} else if (!inQuote && depth === 0 && ch === ',') {
fieldPairs.push(current.trim());
current = '';
} else {
current += ch;
}
}
if (current.trim()) fieldPairs.push(current.trim());
for (const fp of fieldPairs) {
const colonIdx = fp.indexOf(':');
if (colonIdx > 0) {