From 9e4ac1b1202b33fb39555009e0b25e9dd8808798 Mon Sep 17 00:00:00 2001 From: tef-engine Date: Sun, 19 Jul 2026 01:28:00 -0700 Subject: [PATCH] ifblock-fix: real if/else codegen + parser require-continuation fix - typescript-state-machine.ts: replace TODO stub at L557-559 with real if/else TypeScript codegen from SpeckDL ifblock AST nodes - parser.ts: fix require-continuation loop at L1213 that was consuming 'if (...)' lines as require args; added nextLine.startsWith('if ') to the stop conditions so ifblocks parse correctly - examples/ConditionalToggle.speck: new test fixture exercising if/else - compiler.test.ts: new describe block for ifblock code generation (asserts real 'if (' + brace block + 'else', no TODO stub) Tests: 71/71 green (53 parser + 18 compiler incl. new ifblock test). --- .../node_modules/.vite/vitest/results.json | 2 +- compiler/src/__tests__/compiler.test.ts | 50 +++++++++++++ .../ConditionalToggle.prov.jsonld | 21 ++++++ .../ConditionalToggle.specbom.cdx.json | 15 ++++ .../ConditionalToggle.specbom.spdx.json | 26 +++++++ .../ifblock-smoke/ConditionalToggle.ts | 33 +++++++++ .../generators/typescript-state-machine.ts | 73 ++++++++++++++++++- compiler/src/parser.ts | 2 +- compiler/test-output/RetryHandler.prov.jsonld | 2 +- .../test-output/RetryHandler.specbom.cdx.json | 6 +- .../RetryHandler.specbom.spdx.json | 4 +- examples/ConditionalToggle.speck | 29 ++++++++ 12 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 compiler/src/__tests__/ifblock-smoke/ConditionalToggle.prov.jsonld create mode 100644 compiler/src/__tests__/ifblock-smoke/ConditionalToggle.specbom.cdx.json create mode 100644 compiler/src/__tests__/ifblock-smoke/ConditionalToggle.specbom.spdx.json create mode 100644 compiler/src/__tests__/ifblock-smoke/ConditionalToggle.ts create mode 100644 examples/ConditionalToggle.speck diff --git a/compiler/node_modules/.vite/vitest/results.json b/compiler/node_modules/.vite/vitest/results.json index 4a090eb..d51f525 100644 --- a/compiler/node_modules/.vite/vitest/results.json +++ b/compiler/node_modules/.vite/vitest/results.json @@ -1 +1 @@ -{"version":"2.1.9","results":[[":__tests__/parser.test.ts",{"duration":55.17420900000002,"failed":false}],[":src/__tests__/compiler.test.ts",{"duration":3350.1431570000004,"failed":false}]]} \ No newline at end of file +{"version":"2.1.9","results":[[":__tests__/parser.test.ts",{"duration":58.32899300000008,"failed":false}],[":src/__tests__/compiler.test.ts",{"duration":7758.09763,"failed":false}]]} \ No newline at end of file diff --git a/compiler/src/__tests__/compiler.test.ts b/compiler/src/__tests__/compiler.test.ts index 9381ac0..57a9ed1 100644 --- a/compiler/src/__tests__/compiler.test.ts +++ b/compiler/src/__tests__/compiler.test.ts @@ -74,6 +74,56 @@ describe('Compiler E2E', () => { expect(spdx['@context']).toBeDefined(); expect(spdx.spdx).toBeDefined(); }); + + it('should generate protobuf schema', () => { + // Compile RetryHandler.speck with --target protobuf and assert the + // generated .proto contains the expected protobuf constructs. + const specPath = path.join(__dirname, '..', '..', '..', 'examples', 'RetryHandler.speck'); + const protoOutDir = path.join(testOutputDir, 'proto-smoke'); + if (!fs.existsSync(protoOutDir)) { + fs.mkdirSync(protoOutDir, { recursive: true }); + } + execSync( + `npx tsx src/index.ts ${specPath} -t protobuf -o ${protoOutDir}`, + { cwd: COMPILER_DIR, stdio: 'pipe' }, + ); + const protoFiles = fs.readdirSync(protoOutDir).filter(f => f.endsWith('.proto')); + expect(protoFiles.length).toBeGreaterThanOrEqual(1); + const protoContent = fs.readFileSync(path.join(protoOutDir, protoFiles[0]), 'utf-8'); + expect(protoContent).toContain('syntax = "proto3";'); + expect(protoContent).toMatch(/\bmessage\s+\w+/); + // RetryHandler has events with fields — assert at least one field line + expect(protoContent).toMatch(/\w+\s+\w+\s*=\s*\d+;/); + }); +}); + +describe('ifblock code generation', () => { + it('should emit real TypeScript if/else from a SpeckDL ifblock', () => { + // Compile ConditionalToggle.speck (has an if/else action body) and assert + // the generated TS contains a real `if (` with a brace block, not the old + // TODO stub comment. + const specPath = path.join(__dirname, '..', '..', '..', 'examples', 'ConditionalToggle.speck'); + const outDir = path.join(__dirname, 'ifblock-smoke'); + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + execSync( + `npx tsx src/index.ts ${specPath} -t typescript -o ${outDir}`, + { cwd: COMPILER_DIR, stdio: 'pipe' }, + ); + const tsFile = path.join(outDir, 'ConditionalToggle.ts'); + expect(fs.existsSync(tsFile)).toBe(true); + const tsContent = fs.readFileSync(tsFile, 'utf-8'); + // Should contain a real if statement + expect(tsContent).toMatch(/\bif\s*\(/); + // Should contain a brace block body + expect(tsContent).toContain('{ '); + expect(tsContent).toContain(' }'); + // Should NOT contain the old TODO stub + expect(tsContent).not.toContain('TODO: SpeckDL conditional block'); + // Should contain an else clause (our spec has if/else) + expect(tsContent).toMatch(/\belse\b/); + }); }); // --------------------------------------------------------------------------- diff --git a/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.prov.jsonld b/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.prov.jsonld new file mode 100644 index 0000000..bad4340 --- /dev/null +++ b/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.prov.jsonld @@ -0,0 +1,21 @@ +{ + "@context": { + "prov": "http://www.w3.org/ns/prov#", + "speckl": "https://speckl.scoble.me/ns/v0.2#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#" + }, + "@graph": [ + { + "@type": "prov:Entity", + "@id": "speck:ConditionalToggle", + "prov:qualifiedGeneration": { + "@type": "prov:Generation", + "prov:activity": { + "@type": "prov:Activity", + "prov:startTime": "2026-07-19T08:31:19.208Z" + } + }, + "rdfs:label": "ConditionalToggle" + } + ] +} \ No newline at end of file diff --git a/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.specbom.cdx.json b/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.specbom.cdx.json new file mode 100644 index 0000000..62caf7f --- /dev/null +++ b/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.specbom.cdx.json @@ -0,0 +1,15 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [], + "metadata": { + "tools": [], + "component": { + "type": "application", + "name": "ConditionalToggle", + "version": "0.2.0", + "properties": [] + } + }, + "annotations": [] +} \ No newline at end of file diff --git a/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.specbom.spdx.json b/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.specbom.spdx.json new file mode 100644 index 0000000..24c2715 --- /dev/null +++ b/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.specbom.spdx.json @@ -0,0 +1,26 @@ +{ + "@context": [ + "https://spdx.github.io/spdx-v3/spdx-context.jsonld", + { + "speckl": "https://speckl.scoble.me/ns/v0.2#" + } + ], + "@type": "spdx:Document", + "spdx": { + "@id": "spdx:ConditionalToggle", + "@type": "spdx:Snippet", + "name": "ConditionalToggle", + "spdxVersion": "3.0.1", + "dataLicense": "CC0-1.0", + "creationInfo": { + "created": "2026-07-19T08:31:19.232Z", + "createdBy": [ + "Organization: Speckl", + "tool: speckl-compile-0.2.0" + ] + } + }, + "builtBy": [], + "relationships": [], + "elements": [] +} \ No newline at end of file diff --git a/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.ts b/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.ts new file mode 100644 index 0000000..7454369 --- /dev/null +++ b/compiler/src/__tests__/ifblock-smoke/ConditionalToggle.ts @@ -0,0 +1,33 @@ +// Auto-generated by speckl-compile from ConditionalToggle +// DO NOT EDIT MANUALLY — regenerate from .speckdl source + +export const ConditionalToggleState = { + Open: 'open', + Closed: 'closed', +} as const; +export type ConditionalToggleState = typeof ConditionalToggleState[keyof typeof ConditionalToggleState]; + + + + +export class ConditionalToggleMachine { + isOn!: boolean; + count!: number; + decisionLog: any[] = []; + + constructor() { + // No init block defined + } + + Toggle() { + const xxx: any = {} as any; // FIXME: parser dropped let/forall + if (!(true)) throw new Error('Guard failed: true'); + if (this.isOn) { this.isOn = false } else { this.isOn = true } + this.count = this.count + 1; + return this.isOn; + } + + + + +} diff --git a/compiler/src/generators/typescript-state-machine.ts b/compiler/src/generators/typescript-state-machine.ts index 4dd7a23..630d899 100644 --- a/compiler/src/generators/typescript-state-machine.ts +++ b/compiler/src/generators/typescript-state-machine.ts @@ -555,7 +555,78 @@ function emitAction(action: ActionNode, stateVarNames: Set, mapVarNames: break; } case 'ifblock': { - bodyParts.push(` // TODO: SpeckDL conditional block (if/else) - manual translation required`); + // The parser captures the raw text of if/else blocks as stmt.raw + // (see parser.ts:~1267). The raw text looks like: + // "if (cond) { thenBody } else { elseBody }" + // or "if (cond) { body }" when there is no else clause. + // We split on 'else', rewrite the condition and bodies via rewriteExpr, + // and emit a real TypeScript if/else statement. + const raw = stmt.raw as string; + // Extract the condition (text between 'if (' and the matching ')'). + // The raw text is a single-line join, so the first ')' after 'if (' + // closes the condition — but the condition itself may contain nested + // parens. Use a balanced-paren scan from the first 'if'. + const ifIdx = raw.indexOf('if'); + const condStart = raw.indexOf('(', ifIdx); + let condEnd = -1; + let depth = 0; + for (let k = condStart; k < raw.length; k++) { + if (raw[k] === '(') depth++; + else if (raw[k] === ')') { depth--; if (depth === 0) { condEnd = k; break; } } + } + const condRaw = condStart >= 0 && condEnd > condStart ? raw.slice(condStart + 1, condEnd) : 'true'; + const cond = rewriteExpr(condRaw, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues); + + // The remainder after the condition's closing paren holds the bodies. + const rest = condEnd > 0 ? raw.slice(condEnd + 1) : ''; + // Split on 'else' (word-boundary) to separate then/else branches. + const elseIdx = rest.search(/\belse\b/); + let thenBody = ''; + let elseBody = ''; + if (elseIdx >= 0) { + thenBody = rest.slice(0, elseIdx).trim(); + elseBody = rest.slice(elseIdx + 4).trim(); + } else { + thenBody = rest.trim(); + } + // Strip surrounding braces from bodies so we can rewrite the inner + // statements, then re-wrap in { ... }. + const stripBraces = (s: string) => { + const m = s.match(/^\s*\{([\s\S]*)\}\s*$/); + return m ? m[1].trim() : s; + }; + // A body may contain multiple statements separated by ';' or newlines. + // Each statement may be a SpeckDL assignment (target := expr) or a bare + // expression. We rewrite each one: + // - assignment: this.target = rewriteExpr(expr) (or target = ... if local) + // - bare expr: rewriteExpr(expr) + const rewriteBody = (body: string) => { + if (!body) return ''; + const inner = stripBraces(body); + // Split on ';' or newlines (the raw text is a single-line join, so + // statements may be separated by ';'). Keep empty parts out. + const parts = inner.split(/[;\n]/).map(s => s.trim()).filter(Boolean); + const rewritten = parts.map(part => { + const assignMatch = part.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/); + if (assignMatch) { + const target = cleanName(assignMatch[1]); + const rhs = rewriteExpr(assignMatch[2], stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues); + if (localNames.has(target)) { + return `${target} = ${rhs}`; + } + return `this.${target} = ${rhs}`; + } + return rewriteExpr(part, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues); + }); + return rewritten.join('; '); + }; + const thenRewritten = rewriteBody(thenBody); + const elseRewritten = rewriteBody(elseBody); + let stmtStr = ` if (${cond}) { ${thenRewritten} }`; + if (elseRewritten) { + stmtStr += ` else { ${elseRewritten} }`; + } + bodyParts.push(stmtStr); break; } case 'return': { diff --git a/compiler/src/parser.ts b/compiler/src/parser.ts index 3ccb289..6021c59 100644 --- a/compiler/src/parser.ts +++ b/compiler/src/parser.ts @@ -1210,7 +1210,7 @@ function parseActionBlockMultiline(lines: string[], startIndex: number, startBra if (nextLine.startsWith('precondition:') || nextLine.startsWith('postcondition:') || nextLine.startsWith('require ') || nextLine.startsWith('return ') || nextLine.startsWith('emit ') || nextLine.startsWith('let ') || - nextLine.includes(':=')) { + nextLine.startsWith('if ') || nextLine.includes(':=')) { break; } i++; diff --git a/compiler/test-output/RetryHandler.prov.jsonld b/compiler/test-output/RetryHandler.prov.jsonld index 267459e..e3cfcb2 100644 --- a/compiler/test-output/RetryHandler.prov.jsonld +++ b/compiler/test-output/RetryHandler.prov.jsonld @@ -12,7 +12,7 @@ "@type": "prov:Generation", "prov:activity": { "@type": "prov:Activity", - "prov:startTime": "2026-06-30T01:54:05.948Z" + "prov:startTime": "2026-07-19T08:31:16.455Z" } }, "rdfs:label": "RetryHandler", diff --git a/compiler/test-output/RetryHandler.specbom.cdx.json b/compiler/test-output/RetryHandler.specbom.cdx.json index 166b33a..540d17e 100644 --- a/compiler/test-output/RetryHandler.specbom.cdx.json +++ b/compiler/test-output/RetryHandler.specbom.cdx.json @@ -38,7 +38,7 @@ "name": "Scott Scoble", "email": "scott@scoble.me" }, - "timestamp": "2026-06-30T01:54:05.951Z" + "timestamp": "2026-07-19T08:31:16.458Z" }, { "subject": "speck:RetryHandler", @@ -53,7 +53,7 @@ "name": "Speckl Compiler", "email": "compiler@speckl.scoble.me" }, - "timestamp": "2026-06-30T01:54:05.951Z" + "timestamp": "2026-07-19T08:31:16.458Z" }, { "subject": "speck:RetryHandler", @@ -68,7 +68,7 @@ "name": "Speckl Compiler", "email": "compiler@speckl.scoble.me" }, - "timestamp": "2026-06-30T01:54:05.951Z" + "timestamp": "2026-07-19T08:31:16.458Z" } ] } \ No newline at end of file diff --git a/compiler/test-output/RetryHandler.specbom.spdx.json b/compiler/test-output/RetryHandler.specbom.spdx.json index 0c09ab7..39260eb 100644 --- a/compiler/test-output/RetryHandler.specbom.spdx.json +++ b/compiler/test-output/RetryHandler.specbom.spdx.json @@ -13,7 +13,7 @@ "spdxVersion": "3.0.1", "dataLicense": "CC0-1.0", "creationInfo": { - "created": "2026-06-30T01:54:05.952Z", + "created": "2026-07-19T08:31:16.459Z", "createdBy": [ "Organization: Speckl", "tool: speckl-compile-0.2.0" @@ -27,7 +27,7 @@ "@type": "spdx:Tool", "name": "speckl-compile" }, - "timestamp": "2026-06-30T01:54:05.952Z", + "timestamp": "2026-07-19T08:31:16.459Z", "comment": "Review type: manual" } ] diff --git a/examples/ConditionalToggle.speck b/examples/ConditionalToggle.speck new file mode 100644 index 0000000..4d91623 --- /dev/null +++ b/examples/ConditionalToggle.speck @@ -0,0 +1,29 @@ +speck ConditionalToggle { + + state { + isOn: Bool + count: Nat + } + + init { + isOn == false + count == 0 + } + + invariant SwitchConsistent { + isOn in {true, false} + } + + action Toggle { + require true + if (isOn) { + isOn := false + } else { + isOn := true + } + count := count + 1 + return isOn + } + + next: Toggle +} \ No newline at end of file