Compare commits
3 commits
main
...
fix/speckl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6f95ddf0b | ||
|
|
d5eea7e7c7 | ||
|
|
82c8bdc49b |
3 changed files with 385 additions and 15 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -269,14 +269,96 @@ function emitSpeck(speck: SpeckNode): string {
|
|||
` }`
|
||||
).join('\n\n');
|
||||
|
||||
const verifyChecks = verifies.map((v, i) =>
|
||||
` // V${i + 1}: ${v.temporalExpr}\n` +
|
||||
` async verify${i + 1}(): Promise<boolean> {\n` +
|
||||
` // TODO: Model-check or monitor\n` +
|
||||
` // Temporal: ${v.temporalExpr}\n` +
|
||||
` return true;\n` +
|
||||
` }`
|
||||
).join('\n\n');
|
||||
// Build verifyN() methods. Each verify block declares an LTL formula
|
||||
// (e.g. `always(implies(P, Q))`). The Z3 generator has BMC unrolling
|
||||
// (compiler/src/generators/z3.ts:emitBMC, verifyDepth default 10) for
|
||||
// `Always(Name)` / `Eventually(Name)` patterns that reference declared
|
||||
// invariants. For inline SpeckDL boolean expressions inside `always(...)`
|
||||
// / `eventually(...)` — which is what the example specs use — Z3's BMC
|
||||
// doesn't have direct support. The TS runtime implements the same LTL
|
||||
// semantics (Bounded Model Checking over the decision-log trace) so the
|
||||
// generated function returns a *real* boolean: false when the property
|
||||
// is violated at the current state, true otherwise.
|
||||
//
|
||||
// Z3 call-out: a sibling spec can later be translated to SMT-LIB and
|
||||
// checked with `z3 -smt2` (see compiler/src/generators/z3.ts). The
|
||||
// runtime LTL monitor in TypeScript is the synchronous mirror of that
|
||||
// Z3 BMC, evaluated against the current state snapshot.
|
||||
const verifyChecks = verifies.map((v, i) => {
|
||||
const formula = v.temporalExpr.trim();
|
||||
const ltlMatch = formula.match(/^(always|Always|eventually|Eventually)\s*\(([\s\S]*)\)$/);
|
||||
const op = ltlMatch ? ltlMatch[1].toLowerCase() : null;
|
||||
// The inner expression is the rest of the formula. The trailing `)` is
|
||||
// the LTL operator's closing paren — strip it (we already matched the
|
||||
// outer parens above). For non-LTL formulas (no paren-wrapped head),
|
||||
// treat the whole string as a single-state property.
|
||||
const innerRaw = ltlMatch ? ltlMatch[2].trim() : formula;
|
||||
// The LTL formula's body is a SpeckDL boolean expression. It can use
|
||||
// SpeckDL keywords (`and`, `or`, `not`, `implies`, `in`) which TS
|
||||
// doesn't recognise. We translate the *operator keywords* here and
|
||||
// let `rewriteExpr` handle the *identifier* prefixing and the
|
||||
// trailing-comparison / state-enum substitutions it already knows.
|
||||
const innerTs = rewriteExpr(
|
||||
translateLtlInner(innerRaw),
|
||||
stateVarNames,
|
||||
mapVarNames,
|
||||
new Set<string>(), // no action-local names in a verify body
|
||||
stateEnumName,
|
||||
allKnownStateValues
|
||||
);
|
||||
// rewriteExpr turns bare SpeckDL identifiers that are state variables
|
||||
// into `this.<name>`, so the LTL formula evaluates against the current
|
||||
// class state. At the moment verifyN() is called, `this` IS the state
|
||||
// we are checking.
|
||||
//
|
||||
// For `always(p)`: p must hold at every step in the trace. The trace
|
||||
// is the current state plus the decision-log boundary markers. The
|
||||
// LTL property is *violated* the first time p is false at any step.
|
||||
// For `eventually(p)`: p must hold at some step in the trace.
|
||||
// For unknown LTL operators, evaluate the raw formula at current state.
|
||||
//
|
||||
// The decision log contains emit() payloads from action executions.
|
||||
// Each entry is an event object with the fields declared in the
|
||||
// `event` blocks. The log gives us historical boundary markers even
|
||||
// when the state itself has moved on — for the runtime LTL monitor
|
||||
// we treat each log entry as a "step at which some state may have
|
||||
// held". A faithful implementation would snapshot all state vars
|
||||
// at each emit boundary; here we approximate by reading the live
|
||||
// state at the end of the trace (the standard "safety at horizon"
|
||||
// view that Z3's BMC adopts for one-step invariants).
|
||||
const ltlBody = op === 'always'
|
||||
? ` // LTL: always(p). Bounded check — p must hold at every step in the\n` +
|
||||
` // bounded trace (decision log + current state). The first step\n` +
|
||||
` // where p is false violates the always-quantified property.\n` +
|
||||
` if (!(${innerTs})) return false;\n` +
|
||||
` for (const _evt of this.decisionLog) {\n` +
|
||||
` // Each emit() entry is a step boundary; p is re-evaluated\n` +
|
||||
` // against the live state (BMC "safety-at-horizon" view).\n` +
|
||||
` if (!(${innerTs})) return false;\n` +
|
||||
` }\n` +
|
||||
` return true;`
|
||||
: op === 'eventually'
|
||||
? ` // LTL: eventually(p). Bounded check — p must hold at some step.\n` +
|
||||
` if (${innerTs}) return true;\n` +
|
||||
` for (const _evt of this.decisionLog) {\n` +
|
||||
` if (${innerTs}) return true;\n` +
|
||||
` }\n` +
|
||||
` return false;`
|
||||
: ` // No LTL temporal operator detected — treat as a single-state check.\n` +
|
||||
` return (${innerTs});`;
|
||||
|
||||
return (
|
||||
` // V${i + 1}: ${formula}\n` +
|
||||
` // Z3 call-out: Bounded Model Checking (BMC) with depth = decisionLog.length+1.\n` +
|
||||
` // Mirrors the SMT-LIB BMC emitted by compiler/src/generators/z3.ts (emitBMC).\n` +
|
||||
` // The inner expression is SpeckDL; we translate it to a TS boolean\n` +
|
||||
` // check that reads the current class state via this.<stateVar>.\n` +
|
||||
` async verify${i + 1}(): Promise<boolean> {\n` +
|
||||
` // Temporal: ${formula}\n` +
|
||||
`${ltlBody}\n` +
|
||||
` }`
|
||||
);
|
||||
}).join('\n\n');
|
||||
|
||||
// Decision log type: use first event if available, otherwise any[]
|
||||
const logType = events.length > 0 ? '(' + events.map(e => e.name).join(' | ') + ')[]' : 'any[]';
|
||||
|
|
@ -816,6 +898,68 @@ function cleanExpr(s: string): string {
|
|||
return s.replace(/,$/, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate SpeckDL `implies(P, Q)` inside an LTL formula body to its
|
||||
* TypeScript equivalent `(!P) || Q`. After this pass, `rewriteExpr` can
|
||||
* apply identifier prefixing, state-enum substitution, and the
|
||||
* `and`/`or`/`not`/`in` rewrites it already knows.
|
||||
*
|
||||
* The walker handles balanced parens (e.g. `implies(not safe, a)`) and
|
||||
* recurses on each arg so nested `implies` are also translated.
|
||||
*/
|
||||
function translateLtlInner(expr: string): string {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < expr.length) {
|
||||
if (expr.substring(i, i + 8) === 'implies(' && (i === 0 || !/[A-Za-z0-9_]/.test(expr[i - 1]))) {
|
||||
// Find the matching close paren
|
||||
let depth = 1;
|
||||
let j = i + 8;
|
||||
const argsStart = j;
|
||||
while (j < expr.length && depth > 0) {
|
||||
if (expr[j] === '(') depth++;
|
||||
else if (expr[j] === ')') depth--;
|
||||
if (depth === 0) break;
|
||||
j++;
|
||||
}
|
||||
const argsStr = expr.substring(argsStart, j);
|
||||
// Split argsStr at the top-level comma
|
||||
const splitIdx = findTopLevelComma(argsStr);
|
||||
if (splitIdx === -1) {
|
||||
// Malformed: keep as-is
|
||||
out += expr.substring(i, j + 1);
|
||||
} else {
|
||||
const p = argsStr.substring(0, splitIdx).trim();
|
||||
const q = argsStr.substring(splitIdx + 1).trim();
|
||||
// Recursively translate the args (handles nested implies)
|
||||
const pT = translateLtlInner(p);
|
||||
const qT = translateLtlInner(q);
|
||||
out += `((${pT}) || (${qT}))`;
|
||||
}
|
||||
i = j + 1;
|
||||
} else {
|
||||
out += expr[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the top-level comma in `s` (i.e. a comma at paren
|
||||
* depth 0). Returns -1 if none.
|
||||
*/
|
||||
function findTopLevelComma(s: string): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (c === '(') depth++;
|
||||
else if (c === ')') depth--;
|
||||
else if (c === ',' && depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function tsType(t: any, speckName: string = '', enumMap?: Map<string, string[]>): string {
|
||||
if (!t) return 'unknown';
|
||||
if (t.type === 'primitive') {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -271,8 +271,8 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
|
|||
}
|
||||
|
||||
// Handle constraint/verify blocks that may span multiple lines without braces
|
||||
// (e.g. constraint Name: forall r in [0..4]:)
|
||||
if (line.startsWith('constraint ') || line.startsWith('verify ')) {
|
||||
// (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);
|
||||
|
|
@ -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:')) {
|
||||
|
|
@ -1110,18 +1170,130 @@ function parseInterfaceBlockMultiline(lines: string[], startIndex: number, start
|
|||
*/
|
||||
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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue