diff --git a/compiler/node_modules/.package-lock.json b/compiler/node_modules/.package-lock.json index cbe8eb9..af6e4bf 100644 --- a/compiler/node_modules/.package-lock.json +++ b/compiler/node_modules/.package-lock.json @@ -1,6 +1,6 @@ { "name": "speckl-compile", - "version": "0.2.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { @@ -42,6 +42,20 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1138,6 +1152,24 @@ } } }, + "node_modules/wabt": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/wabt/-/wabt-1.0.39.tgz", + "integrity": "sha512-ba+dRL/75VQQY7RkU/CgriGbkoWAfS8TDyUlJfJhJ8KhtXgMl5dhNvoPNUcQ9IWRhW8u41glMSuZeTvsYq2rRg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-decompile": "bin/wasm-decompile", + "wasm-interp": "bin/wasm-interp", + "wasm-objdump": "bin/wasm-objdump", + "wasm-stats": "bin/wasm-stats", + "wasm-strip": "bin/wasm-strip", + "wasm-validate": "bin/wasm-validate", + "wasm2c": "bin/wasm2c", + "wasm2wat": "bin/wasm2wat", + "wat2wasm": "bin/wat2wasm" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/compiler/node_modules/.vite/vitest/results.json b/compiler/node_modules/.vite/vitest/results.json index 243e61a..3d39bf4 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":38.49205900000004,"failed":false}],[":src/__tests__/compiler.test.ts",{"duration":10.381641000000002,"failed":false}]]} \ No newline at end of file +{"version":"2.1.9","results":[[":__tests__/parser.test.ts",{"duration":41.041000999999994,"failed":false}],[":__tests__/e2e-wasm.test.ts",{"duration":108.24758300000002,"failed":false}],[":src/__tests__/compiler.test.ts",{"duration":10.95270800000003,"failed":false}]]} \ No newline at end of file diff --git a/compiler/out/speckl-compile/SpecklCompiler.prov.jsonld b/compiler/out/speckl-compile/SpecklCompiler.prov.jsonld new file mode 100644 index 0000000..91014fc --- /dev/null +++ b/compiler/out/speckl-compile/SpecklCompiler.prov.jsonld @@ -0,0 +1,29 @@ +{ + "@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:SpecklCompiler", + "prov:qualifiedGeneration": { + "@type": "prov:Generation", + "prov:activity": { + "@type": "prov:Activity", + "prov:startTime": "2026-05-03T21:19:53.222Z" + } + }, + "rdfs:label": "SpecklCompiler", + "prov:wasAttributedTo": { + "@id": "agent:sscoble" + } + }, + { + "@type": "prov:Agent", + "@id": "agent:sscoble", + "rdfs:label": "sscoble" + } + ] +} \ No newline at end of file diff --git a/compiler/out/speckl-compile/SpecklCompiler.specbom.cdx.json b/compiler/out/speckl-compile/SpecklCompiler.specbom.cdx.json new file mode 100644 index 0000000..9ffc517 --- /dev/null +++ b/compiler/out/speckl-compile/SpecklCompiler.specbom.cdx.json @@ -0,0 +1,30 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [], + "metadata": { + "tools": [], + "component": { + "type": "application", + "name": "SpecklCompiler", + "version": "0.2.0", + "properties": [] + } + }, + "annotations": [ + { + "subject": "speck:SpecklCompiler", + "annotations": [ + { + "type": "tag", + "value": "provenanceType", + "text": "author" + } + ], + "author": { + "name": "sscoble" + }, + "timestamp": "2026-05-03T21:19:53.229Z" + } + ] +} \ No newline at end of file diff --git a/compiler/out/speckl-compile/SpecklCompiler.specbom.spdx.json b/compiler/out/speckl-compile/SpecklCompiler.specbom.spdx.json new file mode 100644 index 0000000..6d17d6c --- /dev/null +++ b/compiler/out/speckl-compile/SpecklCompiler.specbom.spdx.json @@ -0,0 +1,42 @@ +{ + "@context": [ + "https://spdx.github.io/spdx-v3/spdx-context.jsonld", + { + "speckl": "https://speckl.scoble.me/ns/v0.2#" + } + ], + "@type": "spdx:Document", + "spdx": { + "@id": "spdx:SpecklCompiler", + "@type": "spdx:Snippet", + "name": "SpecklCompiler", + "spdxVersion": "3.0.1", + "dataLicense": "CC0-1.0", + "creationInfo": { + "created": "2026-05-03T21:19:53.229Z", + "createdBy": [ + "Organization: Speckl", + "tool: speckl-compile-0.2.0" + ] + } + }, + "builtBy": [], + "relationships": [ + { + "from": { + "@id": "spdx:SpecklCompiler" + }, + "to": { + "@id": "spdx:agent:sscoble" + }, + "relationshipType": "spdx:wasAttributedTo" + } + ], + "elements": [ + { + "@type": "spdx:Person", + "@id": "spdx:agent:sscoble", + "name": "sscoble" + } + ] +} \ No newline at end of file diff --git a/compiler/out/speckl-compile/SpecklCompiler.ts b/compiler/out/speckl-compile/SpecklCompiler.ts new file mode 100644 index 0000000..75041fb --- /dev/null +++ b/compiler/out/speckl-compile/SpecklCompiler.ts @@ -0,0 +1,153 @@ +// Auto-generated by speckl-compile from SpecklCompiler +// DO NOT EDIT MANUALLY — regenerate from .speckdl source + +export const SpecklCompilerCompilerPhase = { + BOMsGenerated: 'bomsgenerated', + Idle: 'idle', + Parsed: 'parsed', + Parsing: 'parsing', + ProvenanceGenerated: 'provenancegenerated', + TypeScriptGenerated: 'typescriptgenerated', + WASMGenerated: 'wasmgenerated', +} as const; +export type SpecklCompilerCompilerPhase = typeof SpecklCompilerCompilerPhase[keyof typeof SpecklCompilerCompilerPhase]; + +export const SpecklCompilerArtifactKind = { + CycloneDX: 'cyclonedx', + Done: 'done', + Provenance: 'provenance', + SPDX: 'spdx', + TypeScript: 'typescript', + WebAssembly: 'webassembly', +} as const; +export type SpecklCompilerArtifactKind = typeof SpecklCompilerArtifactKind[keyof typeof SpecklCompilerArtifactKind]; + +export interface CompilationStarted { + source: string; + output: string; + at: string; +} + +export interface PhaseCompleted { + phase: SpecklCompilerCompilerPhase; + durationMs: number; + at: string; +} + +export interface ArtifactGenerated { + kind: SpecklCompilerArtifactKind; + path: string; + bytes: number; + at: string; +} + +export interface ErrorEmitted { + phase: SpecklCompilerCompilerPhase; + message: string; + line: number; + col: number; + at: string; +} + + +export class SpecklCompilerMachine { + phase: SpecklCompilerCompilerPhase; + sourcePath: String; + outputDir: String; + ast: any; + errors: String[]; + artifacts: any; + decisionLog: CompilationStarted[] = []; + + constructor() { + this.phase = SpecklCompilerCompilerPhase.Idle; + this.sourcePath = ""; + this.outputDir = "./out"; + this.ast = {}; + this.errors = []; + this.artifacts = {}; + } + + compile(sourcePath: string, outputDir: string) { + if (!(this.phase === SpecklCompilerCompilerPhase.Idle)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.Idle'); + if (!(sourcePath.length > 0)) throw new Error('Guard failed: sourcePath.length > 0'); + this.decisionLog.push({ + source: sourcePath, + output: outputDir, + at: Date.now() + } as CompilationStarted); + } + + parse() { + if (!(this.phase === SpecklCompilerCompilerPhase.Parsing)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.Parsing'); + if (!(this.sourcePath.length > 0)) throw new Error('Guard failed: this.sourcePath.length > 0'); + this.decisionLog.push({ + phase: SpecklCompilerCompilerPhase.Parsing, + durationMs: 0, + at: Date.now() + } as PhaseCompleted); + } + + generateProvenance() { + if (!(this.phase === SpecklCompilerCompilerPhase.Parsed || this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.WASMGenerated)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.Parsed || this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.WASMGenerated'); + this.decisionLog.push({ + kind: SpecklCompilerCompilerPhase.Provenance, + path: join(this.outputDir, + bytes: 0, + at: Date.now() + } as ArtifactGenerated); + } + + generateBOMs() { + if (!(this.phase === SpecklCompilerCompilerPhase.ProvenanceGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.ProvenanceGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed'); + this.decisionLog.push({ + kind: SpecklCompilerCompilerPhase.CycloneDX, + path: join(this.outputDir, + bytes: 0, + at: Date.now() + } as ArtifactGenerated); + this.decisionLog.push({ + kind: SpecklCompilerCompilerPhase.SPDX, + path: join(this.outputDir, + bytes: 0, + at: Date.now() + } as ArtifactGenerated); + } + + generateTypeScript() { + if (!(this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed'); + this.decisionLog.push({ + kind: SpecklCompilerCompilerPhase.TypeScript, + path: join(this.outputDir, + bytes: 0, + at: Date.now() + } as ArtifactGenerated); + } + + generateWASM() { + if (!(this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed'); + this.decisionLog.push({ + kind: SpecklCompilerCompilerPhase.WebAssembly, + path: join(this.outputDir, + bytes: 0, + at: Date.now() + } as ArtifactGenerated); + } + + report() { + if (!(this.phase === SpecklCompilerCompilerPhase.ProvenanceGenerated || this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.WASMGenerated)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.ProvenanceGenerated || this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.WASMGenerated'); + this.decisionLog.push({ + phase: SpecklCompilerCompilerPhase.Done, + durationMs: 0, + at: Date.now() + } as PhaseCompleted); + } + + reset() { + + } + + + + +} diff --git a/compiler/out/speckl-compile/SpecklCompiler.wat b/compiler/out/speckl-compile/SpecklCompiler.wat new file mode 100644 index 0000000..096073b --- /dev/null +++ b/compiler/out/speckl-compile/SpecklCompiler.wat @@ -0,0 +1,151 @@ +;; Auto-generated by speckl-compile from SpecklCompiler +;; Compile: wat2wasm SpecklCompiler.wat -o SpecklCompiler.wasm + +(module + ;; Host imports for Map/Set/alloc + (import "speckl" "map_create" (func $map_create (result i32))) + (import "speckl" "map_set" (func $map_set (param i32 i32 i32))) + (import "speckl" "map_get" (func $map_get (param i32 i32) (result i32))) + (import "speckl" "map_has" (func $map_has (param i32 i32) (result i32))) + (import "speckl" "map_remove" (func $map_remove (param i32 i32))) + (import "speckl" "set_create" (func $set_create (result i32))) + (import "speckl" "set_add" (func $set_add (param i32 i32))) + (import "speckl" "set_has" (func $set_has (param i32 i32) (result i32))) + (import "speckl" "set_size" (func $set_size (param i32) (result i32))) + (import "speckl" "alloc" (func $alloc (param i32) (result i32))) + (import "speckl" "write_i32" (func $write_i32 (param i32 i32))) + (import "speckl" "write_f64" (func $write_f64 (param i32 f64))) + + ;; Linear memory (exported so host can read state) + (memory (export "memory") 1) + + ;; State globals + (global $phase (mut i32) (i32.const 0)) +(global $sourcePath (mut i32) (i32.const 0)) +(global $outputDir (mut i32) (i32.const 0)) +(global $ast (mut i32) (i32.const 0)) +(global $errors (mut i32) (i32.const 0)) +(global $artifacts (mut i32) (i32.const 0)) + + ;; ---- init ---- + (func $init (export "init") + (i32.const 0) + (global.set $phase) + (i32.const 0) + (global.set $sourcePath) + (i32.const 0) + (global.set $outputDir) + (i32.const 0) + (global.set $ast) + (i32.const 0) + (global.set $errors) + (i32.const 0) + (global.set $artifacts) + ) + + ;; ---- Actions ---- + ;; action: compile + (func $compile (export "compile") (param $sourcePath i32) (param $outputDir i32) (result i32) + + ;; require: phase === Idle + ;; Complex right-hand side not compiled: phase === Idle + ;; require: len(sourcePath) > 0 + ;; Complex guard not compiled: len(sourcePath) > 0 + ;; emit CompilationStarted { ... } + (i32.const 1) + ) + + ;; action: parse + (func $parse (export "parse") (result i32) + + ;; require: phase === Parsing + ;; Complex right-hand side not compiled: phase === Parsing + ;; require: len(sourcePath) > 0 + ;; Complex guard not compiled: len(sourcePath) > 0 + ;; emit PhaseCompleted { ... } + (i32.const 1) + ) + + ;; action: generateProvenance + (func $generateProvenance (export "generateProvenance") (result i32) + + ;; require: phase === Parsed || phase === TypeScriptGenerated || phase === WASMGenerated + ;; Complex right-hand side not compiled: phase === Parsed || phase === TypeScriptGenerated || phase === WASMGenerated + ;; emit ArtifactGenerated { ... } + (i32.const 1) + ) + + ;; action: generateBOMs + (func $generateBOMs (export "generateBOMs") (result i32) + + ;; require: phase === ProvenanceGenerated || phase === Parsed + ;; Complex right-hand side not compiled: phase === ProvenanceGenerated || phase === Parsed + ;; emit ArtifactGenerated { ... } + ;; emit ArtifactGenerated { ... } + (i32.const 1) + ) + + ;; action: generateTypeScript + (func $generateTypeScript (export "generateTypeScript") (result i32) + + ;; require: phase === BOMsGenerated || phase === Parsed + ;; Complex right-hand side not compiled: phase === BOMsGenerated || phase === Parsed + ;; emit ArtifactGenerated { ... } + (i32.const 1) + ) + + ;; action: generateWASM + (func $generateWASM (export "generateWASM") (result i32) + + ;; require: phase === TypeScriptGenerated || phase === BOMsGenerated || phase === Parsed + ;; Complex right-hand side not compiled: phase === TypeScriptGenerated || phase === BOMsGenerated || phase === Parsed + ;; emit ArtifactGenerated { ... } + (i32.const 1) + ) + + ;; action: report + (func $report (export "report") (result i32) + + ;; require: phase === ProvenanceGenerated || phase === BOMsGenerated || phase === TypeScriptGenerated || phase === WASMGenerated + ;; Complex right-hand side not compiled: phase === ProvenanceGenerated || phase === BOMsGenerated || phase === TypeScriptGenerated || phase === WASMGenerated + ;; emit PhaseCompleted { ... } + (i32.const 1) + ) + + ;; action: reset + (func $reset (export "reset") (result i32) + + (i32.const 1) + ) + + ;; ---- getState ---- + (func $getState (export "getState") (result i32 i32) + (i32.const 0) + (global.get $phase) + (i32.store) + (i32.const 4) + (global.get $sourcePath) + (i32.store) + (i32.const 8) + (global.get $outputDir) + (i32.store) + (i32.const 12) + (global.get $ast) + (i32.store) + (i32.const 16) + (global.get $errors) + (i32.store) + (i32.const 20) + (global.get $artifacts) + (i32.store) + (i32.const 0) + (i32.const 24) + ) + + + ;; checkConstraints — runtime constraint evaluation stub + (func $checkConstraints (export "checkConstraints") (result i32) + ;; TODO: evaluate constraint expressions at runtime + (i32.const 1) + ) +) \ No newline at end of file diff --git a/compiler/specs/speckl-compile.speckdl b/compiler/specs/speckl-compile.speckdl new file mode 100644 index 0000000..0ecaeec --- /dev/null +++ b/compiler/specs/speckl-compile.speckdl @@ -0,0 +1,216 @@ +// speckl-compile.speckdl — The Speckl compiler, specified in SpeckDL +// This is the ultimate dogfood: the compiler compiles itself. + +speck SpecklCompiler { + version: "0.4.0" + author: "sscoble" + license: "MIT" + + // ─── Purpose ────────────────────────────────────────────── + // Transform a .speckdl specification into provenance, BOMs, + // and runnable artifacts (TypeScript, WebAssembly). + + // ─── State ──────────────────────────────────────────────── + state: { + phase: CompilerPhase, + sourcePath: String, + outputDir: String, + ast: AST, + errors: String[], + artifacts: ArtifactMap + } + + init: { + phase := Idle, + sourcePath := "", + outputDir := "./out", + ast := {}, + errors := [], + artifacts := {} + } + + // ─── Events ─────────────────────────────────────────────── + event CompilationStarted { + source: String, + output: String, + at: String + } + + event PhaseCompleted { + phase: CompilerPhase, + durationMs: Nat, + at: String + } + + event ArtifactGenerated { + kind: ArtifactKind, + path: String, + bytes: Nat, + at: String + } + + event ErrorEmitted { + phase: CompilerPhase, + message: String, + line: Nat, + col: Nat, + at: String + } + + // ─── Actions ────────────────────────────────────────────── + action compile(sourcePath: String, outputDir: String) { + require phase === Idle + require len(sourcePath) > 0 + + this.phase := Parsing + this.sourcePath := sourcePath + this.outputDir := outputDir + this.errors := [] + + emit CompilationStarted { source: sourcePath, output: outputDir, at: now() } + } + + action parse() { + require phase === Parsing + require len(sourcePath) > 0 + + // Lexical analysis: tokenize .speckdl source + // Syntactic analysis: build AST (SpeckNode, MemberNode hierarchy) + // Semantic validation: type check, name resolution + + this.phase := Parsed + this.ast := parseSpeckFile(sourcePath) + + emit PhaseCompleted { phase: Parsing, durationMs: 0, at: now() } + } + + action generateProvenance() { + require phase === Parsed || phase === TypeScriptGenerated || phase === WASMGenerated + + // Emit PROV-O JSON-LD provenance graph + // Tracks: compiler version, source file, generation time, generator used + + this.phase := ProvenanceGenerated + + emit ArtifactGenerated { kind: Provenance, path: join(outputDir, ".prov.jsonld"), bytes: 0, at: now() } + } + + action generateBOMs() { + require phase === ProvenanceGenerated || phase === Parsed + + // Emit CycloneDX SBOM (software bill of materials) + // Emit SPDX BOM (license and provenance) + + this.phase := BOMsGenerated + + emit ArtifactGenerated { kind: CycloneDX, path: join(outputDir, ".specbom.cdx.json"), bytes: 0, at: now() } + emit ArtifactGenerated { kind: SPDX, path: join(outputDir, ".specbom.spdx.json"), bytes: 0, at: now() } + } + + action generateTypeScript() { + require phase === BOMsGenerated || phase === Parsed + + // Transform AST → TypeScript state machine class + // Actions become methods, guards become if-throws, + // state vars become properties, init becomes constructor + + this.phase := TypeScriptGenerated + + emit ArtifactGenerated { kind: TypeScript, path: join(outputDir, ".ts"), bytes: 0, at: now() } + } + + action generateWASM() { + require phase === TypeScriptGenerated || phase === BOMsGenerated || phase === Parsed + + // Transform AST → WebAssembly Text (WAT) + // Scalar state → WASM globals, Map/Set → host imports + // Actions become exports with parameter mapping + + this.phase := WASMGenerated + + emit ArtifactGenerated { kind: WebAssembly, path: join(outputDir, ".wat"), bytes: 0, at: now() } + } + + action report() { + require phase === ProvenanceGenerated || phase === BOMsGenerated || phase === TypeScriptGenerated || phase === WASMGenerated + + // Print summary: phases completed, artifacts emitted, errors found + // Return exit code 0 if no errors, 1 otherwise + + this.phase := Done + + emit PhaseCompleted { phase: Done, durationMs: 0, at: now() } + } + + action reset() { + // Return to Idle for next compilation + this.phase := Idle + this.sourcePath := "" + this.outputDir := "./out" + this.ast := {} + this.errors := [] + this.artifacts := {} + } + + // ─── Constraints ────────────────────────────────────────── + constraint C1: len(errors) === 0 || phase === Done + // If there are errors, compilation must reach Done (with non-zero exit) + + constraint C2: forall a in artifacts: a.bytes > 0 + // Every generated artifact must have content + + constraint C3: phase === Idle || len(sourcePath) > 0 + // Non-idle phases require a source file + + constraint C4: artifacts[TypeScript] exists → phase >= TypeScriptGenerated + // TypeScript artifact implies the phase was reached + + constraint C5: artifacts[WebAssembly] exists → phase >= WASMGenerated + // WASM artifact implies the phase was reached + + // ─── Temporal Verifications ─────────────────────────────── + verify V1: always(implies(phase === Parsing, eventually(phase === Parsed || len(errors) > 0))) + // Parsing must complete or produce errors + + verify V2: always(implies(phase === Parsed, eventually(phase === Done))) + // Parsed must eventually reach Done + + verify V3: always(implies(phase === Done, forall a in artifacts: a.bytes > 0 || a.kind === ErrorLog)) + // Done implies all non-error artifacts have content + + // ─── Provenance ─────────────────────────────────────────── + provenance { + wasGeneratedBy(speckl-compile, SpecklCompiler, "2026-05-03T19:00:00Z") + used(SpecklCompiler, speckdl-v0.4, "grammar") + used(SpecklCompiler, typescript-5.4, "codegen") + used(SpecklCompiler, wasm-spec-2.0, "codegen") + } + + // ─── BOM ────────────────────────────────────────────────── + bom { + compiler: "speckl-compile@0.4.0" + solver: "z3@4.13.0" + runtime: "node@22.0.0" + } + + // ─── Supporting Types ─────────────────────────────────────── + // These would be defined in a standard library eventually + + interface CompilerPhase { + Idle, Parsing, Parsed, ProvenanceGenerated, BOMsGenerated, + TypeScriptGenerated, WASMGenerated, Done + } + + interface ArtifactKind { + Provenance, CycloneDX, SPDX, TypeScript, WebAssembly, ErrorLog + } + + interface AST { + // Abstract syntax tree — opaque in this spec + // Actual structure defined by parser implementation + } + + interface ArtifactMap { + // Map from ArtifactKind to generated file metadata + } +} diff --git a/compiler/src/generators/typescript-state-machine.ts b/compiler/src/generators/typescript-state-machine.ts index 2df9402..85f9912 100644 --- a/compiler/src/generators/typescript-state-machine.ts +++ b/compiler/src/generators/typescript-state-machine.ts @@ -41,19 +41,28 @@ function emitSpeck(speck: SpeckNode): string { const constraints = members.filter(m => m.type === 'constraint') as ConstraintNode[]; const verifies = members.filter(m => m.type === 'verify') as VerifyNode[]; - // Extract state enum values from an interface named *State if present - const stateInterface = members.find(m => m.type === 'interface' && m.name.endsWith('State')) as any; + // Collect ALL enum-like interfaces (interfaces with method names treated as enum values) + const allInterfaces = members.filter(m => m.type === 'interface') as any[]; + const enumInterfaces = allInterfaces.filter(iface => iface?.methods?.length > 0); + + // If no interfaces have methods, fall back to scanning action guards for capitalized words let stateValues: string[] = []; - if (stateInterface?.methods?.length) { - stateValues = stateInterface.methods.map((m: any) => m.name); - } - if (stateValues.length === 0) { + if (enumInterfaces.length > 0) { + for (const iface of enumInterfaces) { + stateValues.push(...iface.methods.map((m: any) => m.name)); + } + } else { const seen = new Set(); + const builtinTypes = new Set(['String', 'Nat', 'Int', 'Bool', 'Real', 'Bytes', 'Date', 'Number']); for (const a of actions) { for (const s of a.statements) { if (s.type === 'require') { - const matches = s.expr.match(/\b(Open|Grooming|Groomed|Assigned|InProgress|InReview|Merged|Blocked|Closed)\b/g); - if (matches) matches.forEach(m => seen.add(m)); + const matches = s.expr.match(/\b[A-Z][a-zA-Z]+\b/g); + if (matches) { + matches.forEach((m: string) => { + if (!builtinTypes.has(m)) seen.add(m); + }); + } } } } @@ -61,16 +70,98 @@ function emitSpeck(speck: SpeckNode): string { } if (stateValues.length === 0) stateValues = ['Open', 'Closed']; - const stateEnumName = `${speck.name}State`; - const stateEnum = `export const ${stateEnumName} = {\n` + - stateValues.map((v: string) => ` ${v}: '${v.toLowerCase()}',`).join('\n') + - '\n} as const;\n' + - `export type ${stateEnumName} = typeof ${stateEnumName}[keyof typeof ${stateEnumName}];`; + // Build a map of interface name -> enum values for multi-enum specs + const enumMap = new Map(); + for (const iface of enumInterfaces) { + enumMap.set(iface.name, iface.methods.map((m: any) => m.name)); + } + + // Determine which enum name to use for state comparisons + const stateVarType = stateNode?.variables?.[0]?.typeExpr; + let stateEnumName = `${speck.name}State`; + if (stateVarType?.type === 'ident' && stateVarType.name) { + const cleanTypeName = cleanName(stateVarType.name); + stateEnumName = `${speck.name}${cleanTypeName}`; + } else if (enumMap.size > 0) { + const firstEnum = enumMap.keys().next().value; + if (firstEnum) stateEnumName = `${speck.name}${firstEnum}`; + } + + // If we fell back to guard scanning, create the primary enum under the right name + if (enumInterfaces.length === 0) { + enumMap.set(stateEnumName.replace(speck.name, ''), stateValues); + } + + // Scan event field types for secondary enum types (e.g., ArtifactKind) + // If the type is an ident that matches an interface name, generate an enum for it + const eventFieldTypes = new Set(); + for (const ev of events) { + for (const f of ev.fields) { + if (f.type?.type === 'ident' && f.type.name) { + const typeName = cleanName(f.type.name); + if (typeName !== stateEnumName.replace(speck.name, '')) { + eventFieldTypes.add(typeName); + } + } + } + } + // Add any event field types that match interface names as enums + for (const iface of allInterfaces) { + const ifaceName = cleanName(iface.name); + if (eventFieldTypes.has(ifaceName)) { + // If the interface has methods, use them; otherwise scan guards for values + if (iface.methods?.length > 0) { + enumMap.set(ifaceName, iface.methods.map((m: any) => m.name)); + } else { + // Scan all action statements (both require guards and emit fields) for enum values + const seen = new Set(); + const builtinTypes = new Set(['String', 'Nat', 'Int', 'Bool', 'Real', 'Bytes', 'Date', 'Number']); + for (const a of actions) { + for (const s of a.statements) { + if (s.type === 'require') { + const matches = s.expr.match(/\b[A-Z][a-zA-Z]+\b/g); + if (matches) { + matches.forEach((m: string) => { + if (!builtinTypes.has(m)) seen.add(m); + }); + } + } + if (s.type === 'emit') { + for (const f of s.fields) { + const matches = f.value.match(/\b[A-Z][a-zA-Z]+\b/g); + if (matches) { + matches.forEach((m: string) => { + if (!builtinTypes.has(m)) seen.add(m); + }); + } + } + } + } + } + // Remove values already assigned to the primary enum + const primaryValues = new Set(enumMap.get(stateEnumName.replace(speck.name, '')) || []); + const secondaryValues = Array.from(seen).filter(v => !primaryValues.has(v)); + if (secondaryValues.length > 0) { + enumMap.set(ifaceName, secondaryValues.sort()); + } + } + } + } + const allEnums: string[] = []; + for (const [enumName, values] of enumMap) { + const fullEnumName = `${speck.name}${enumName}`; + const enumDef = `export const ${fullEnumName} = {\n` + + values.map((v: string) => ` ${v}: '${v.toLowerCase()}',`).join('\n') + + '\n} as const;\n' + + `export type ${fullEnumName} = typeof ${fullEnumName}[keyof typeof ${fullEnumName}];`; + allEnums.push(enumDef); + } + const stateEnum = allEnums.join('\n\n'); const eventInterfaces = events.map(e => { const fields = e.fields.map(f => { const name = cleanName(f.name); - return ` ${name}: ${tsType(f.type)};`; + return ` ${name}: ${tsType(f.type, speck.name, enumMap)};`; }).join('\n'); return `export interface ${e.name} {\n${fields}\n}`; }).join('\n\n'); @@ -85,20 +176,26 @@ function emitSpeck(speck: SpeckNode): string { const stateVarNames = new Set(stateVars.map(v => v.name)); const stateProperties = stateVars.map(v => { - return ` ${v.name}: ${tsType(v.typeExpr)};`; + return ` ${v.name}: ${tsType(v.typeExpr, speck.name, enumMap)};`; }).join('\n'); + // Collect all known state values from all enums for the rewriter + const allKnownStateValues: string[] = []; + for (const values of enumMap.values()) { + allKnownStateValues.push(...values); + } + 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); + const enumRef = replaceStateWithEnum(expr, stateEnumName, allKnownStateValues); return ` this.${name} = ${enumRef};`; }).join('\n') : ''; - const actionMethods = actions.map(a => emitAction(a, stateVarNames, speck.name, stateEnumName)).join('\n\n'); + const actionMethods = actions.map(a => emitAction(a, stateVarNames, speck.name, stateEnumName, allKnownStateValues, enumMap)).join('\n\n'); const constraintChecks = constraints.map((c, i) => ` // C${i + 1}: ${c.expr}\n` + @@ -118,11 +215,13 @@ function emitSpeck(speck: SpeckNode): string { ` }` ).join('\n\n'); + // Decision log type: use first event if available, otherwise any[] + const logType = events.length > 0 ? events[0].name + '[]' : 'any[]'; + const classBody = ` export class ${className} { ${stateProperties} - decisionLog: StateTransition[] = []; - evidence: EvidenceItem[] = []; + decisionLog: ${logType} = []; constructor() { ${initAssignments || ' // No init block defined'} @@ -146,18 +245,18 @@ ${verifyChecks} return file; } -function emitAction(action: ActionNode, stateVarNames: Set, speckName: string, stateEnumName: string): string { +function emitAction(action: ActionNode, stateVarNames: Set, speckName: string, stateEnumName: string, knownStateValues: string[], enumMap: Map): string { // Collect local parameter names so we DON'T prefix them with this. const localNames = new Set(action.params.map(p => p.name)); const params = action.params - .map(p => `${p.name}: ${tsType(p.type)}`) + .map(p => `${p.name}: ${tsType(p.type, speckName, enumMap)}`) .join(', '); const requires = action.statements .filter(s => s.type === 'require') .map(s => { - const expr = rewriteExpr(s.expr, stateVarNames, localNames, stateEnumName); + const expr = rewriteExpr(s.expr, stateVarNames, localNames, stateEnumName, knownStateValues); return ` if (!(${expr})) throw new Error('Guard failed: ${expr.replace(/'/g, "\\'")}');`; }) .join('\n'); @@ -166,7 +265,7 @@ function emitAction(action: ActionNode, stateVarNames: Set, speckName: s .filter(s => s.type === 'assign') .map(s => { const target = cleanName(s.target); - const expr = rewriteExpr(s.expr, stateVarNames, localNames, stateEnumName); + const expr = rewriteExpr(s.expr, stateVarNames, localNames, stateEnumName, knownStateValues); return ` this.${target} = ${expr};`; }) .join('\n'); @@ -177,7 +276,7 @@ function emitAction(action: ActionNode, stateVarNames: Set, speckName: s const fields = s.fields .map(f => { const name = cleanName(f.name); - const val = rewriteExpr(f.value, stateVarNames, localNames, stateEnumName); + const val = rewriteExpr(f.value, stateVarNames, localNames, stateEnumName, knownStateValues); return ` ${name}: ${val}`; }) .join(',\n'); @@ -191,7 +290,7 @@ function emitAction(action: ActionNode, stateVarNames: Set, 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, localNames: Set, stateEnumName: string): string { +function rewriteExpr(expr: string, stateVarNames: Set, localNames: Set, stateEnumName: string, knownStateValues: string[]): string { function shouldPrefix(id: string): boolean { return stateVarNames.has(id) && !localNames.has(id); } @@ -204,7 +303,13 @@ function rewriteExpr(expr: string, stateVarNames: Set, localNames: Set $3)'); // Step 2: Replace state enum literals with enum refs BEFORE identifier replacement - ts = ts.replace(/\b(Open|Grooming|Groomed|Assigned|InProgress|InReview|Merged|Blocked|Closed)\b/g, `${stateEnumName}.$1`); + const allStateValues = new Set(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 3: Replace identifiers that are state variables but not local params const tokens = ts.split(/([\s\(\)\[\]{}+\-*/=<>!&|.,;:'"`]+)/); @@ -254,15 +359,19 @@ function cleanName(s: string): string { return s.replace(/,$/, '').trim(); } -function replaceStateWithEnum(expr: string, enumName: string): string { - return expr.replace(/\b(Open|Grooming|Groomed|Assigned|InProgress|InReview|Merged|Blocked|Closed)\b/g, `${enumName}.$1`); +function replaceStateWithEnum(expr: string, enumName: string, knownStateValues: string[]): string { + const allStateValues = new Set(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; + const stateValueRegex = new RegExp('\\b(' + Array.from(allStateValues).join('|') + ')\\b', 'g'); + return expr.replace(stateValueRegex, `${enumName}.$1`); } function cleanExpr(s: string): string { return s.replace(/,$/, '').trim(); } -function tsType(t: any): string { +function tsType(t: any, speckName: string = '', enumMap?: Map): string { if (!t) return 'unknown'; if (t.type === 'primitive') { const map: Record = { @@ -271,22 +380,28 @@ function tsType(t: any): string { }; return map[t.name] || t.name.toLowerCase(); } - if (t.type === 'list') return `${tsType(t.elementType)}[]`; - if (t.type === 'set') return `Set<${tsType(t.elementType)}>`; - if (t.type === 'map') return `Map<${tsType(t.keyType)}, ${tsType(t.valueType)}>`; + if (t.type === 'list') return `${tsType(t.elementType, speckName, enumMap)}[]`; + if (t.type === 'set') return `Set<${tsType(t.elementType, speckName, enumMap)}>`; + if (t.type === 'map') return `Map<${tsType(t.keyType, speckName, enumMap)}, ${tsType(t.valueType, speckName, enumMap)}>`; if (t.type === 'record') { const fields = t.fields.map((f: any) => { const name = cleanName(f.name); - return `${name}: ${tsType(f.type)}`; + return `${name}: ${tsType(f.type, speckName, enumMap)}`; }).join('; '); return `{ ${fields} }`; } if (t.type === 'ident') { - const name = cleanName(t.name); - // Map known identifier types - if (name === 'IssueState') return 'IssueWorkflowState'; - if (name === 'Nat') return 'number'; - return name; + const rawName = cleanName(t.name); // strip trailing comma + // Check if this ident maps to a known enum interface + if (enumMap && enumMap.has(rawName)) { + return `${speckName}${rawName}`; + } + // Map known identifier types to concrete TS types + if (rawName === 'IssueState') return 'IssueWorkflowState'; + if (rawName === 'Nat') return 'number'; + // Opaque interfaces become 'any' for now + if (rawName === 'AST' || rawName === 'ArtifactMap') return 'any'; + return rawName; } return 'unknown'; } diff --git a/compiler/test-output/RetryHandler.prov.jsonld b/compiler/test-output/RetryHandler.prov.jsonld index 00ff164..6ff8713 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-05-03T18:51:34.102Z" + "prov:startTime": "2026-05-03T20:13:42.864Z" } }, "rdfs:label": "RetryHandler", diff --git a/compiler/test-output/RetryHandler.specbom.cdx.json b/compiler/test-output/RetryHandler.specbom.cdx.json index 7cf043d..bd8d9ed 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-05-03T18:51:34.104Z" + "timestamp": "2026-05-03T20:13:42.867Z" }, { "subject": "speck:RetryHandler", @@ -53,7 +53,7 @@ "name": "Speckl Compiler", "email": "compiler@speckl.scoble.me" }, - "timestamp": "2026-05-03T18:51:34.104Z" + "timestamp": "2026-05-03T20:13:42.867Z" }, { "subject": "speck:RetryHandler", @@ -68,7 +68,7 @@ "name": "Speckl Compiler", "email": "compiler@speckl.scoble.me" }, - "timestamp": "2026-05-03T18:51:34.104Z" + "timestamp": "2026-05-03T20:13:42.867Z" } ] } \ 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 5969981..aa2ce3b 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-05-03T18:51:34.105Z", + "created": "2026-05-03T20:13:42.868Z", "createdBy": [ "Organization: Speckl", "tool: speckl-compile-0.2.0" @@ -27,7 +27,7 @@ "@type": "spdx:Tool", "name": "speckl-compile" }, - "timestamp": "2026-05-03T18:51:34.105Z", + "timestamp": "2026-05-03T20:13:42.868Z", "comment": "Review type: manual" } ]