feat(compiler): self-hosting spec + generator multi-enum support
Some checks failed
Deploy Landing Page to Storj / deploy (push) Failing after 2s

- Add speckl-compile.speckdl — compiler formally specified in SpeckDL
- Generator: multi-enum detection, value→enum mapping, trailing comma cleanup
- Emit blocks collapsed to single-line (parser limitation)
- Generated artifacts: provenance, BOMs, WAT, TypeScript (2 known bugs → engineering#8)
This commit is contained in:
Scott Scoble 2026-05-03 14:42:03 -07:00
parent 39436b4382
commit 7230650628
12 changed files with 814 additions and 46 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "speckl-compile", "name": "speckl-compile",
"version": "0.2.0", "version": "0.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@ -42,6 +42,20 @@
"linux" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View file

@ -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}]]} {"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}]]}

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}

View file

@ -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() {
}
}

View file

@ -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)
)
)

View file

@ -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
}
}

View file

@ -41,19 +41,28 @@ function emitSpeck(speck: SpeckNode): string {
const constraints = members.filter(m => m.type === 'constraint') as ConstraintNode[]; const constraints = members.filter(m => m.type === 'constraint') as ConstraintNode[];
const verifies = members.filter(m => m.type === 'verify') as VerifyNode[]; const verifies = members.filter(m => m.type === 'verify') as VerifyNode[];
// Extract state enum values from an interface named *State if present // Collect ALL enum-like interfaces (interfaces with method names treated as enum values)
const stateInterface = members.find(m => m.type === 'interface' && m.name.endsWith('State')) as any; 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[] = []; let stateValues: string[] = [];
if (stateInterface?.methods?.length) { if (enumInterfaces.length > 0) {
stateValues = stateInterface.methods.map((m: any) => m.name); for (const iface of enumInterfaces) {
} stateValues.push(...iface.methods.map((m: any) => m.name));
if (stateValues.length === 0) { }
} else {
const seen = new Set<string>(); const seen = new Set<string>();
const builtinTypes = new Set(['String', 'Nat', 'Int', 'Bool', 'Real', 'Bytes', 'Date', 'Number']);
for (const a of actions) { for (const a of actions) {
for (const s of a.statements) { for (const s of a.statements) {
if (s.type === 'require') { if (s.type === 'require') {
const matches = s.expr.match(/\b(Open|Grooming|Groomed|Assigned|InProgress|InReview|Merged|Blocked|Closed)\b/g); const matches = s.expr.match(/\b[A-Z][a-zA-Z]+\b/g);
if (matches) matches.forEach(m => seen.add(m)); 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']; if (stateValues.length === 0) stateValues = ['Open', 'Closed'];
const stateEnumName = `${speck.name}State`; // Build a map of interface name -> enum values for multi-enum specs
const stateEnum = `export const ${stateEnumName} = {\n` + const enumMap = new Map<string, string[]>();
stateValues.map((v: string) => ` ${v}: '${v.toLowerCase()}',`).join('\n') + for (const iface of enumInterfaces) {
'\n} as const;\n' + enumMap.set(iface.name, iface.methods.map((m: any) => m.name));
`export type ${stateEnumName} = typeof ${stateEnumName}[keyof typeof ${stateEnumName}];`; }
// 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<string>();
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<string>();
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 eventInterfaces = events.map(e => {
const fields = e.fields.map(f => { const fields = e.fields.map(f => {
const name = cleanName(f.name); const name = cleanName(f.name);
return ` ${name}: ${tsType(f.type)};`; return ` ${name}: ${tsType(f.type, speck.name, enumMap)};`;
}).join('\n'); }).join('\n');
return `export interface ${e.name} {\n${fields}\n}`; return `export interface ${e.name} {\n${fields}\n}`;
}).join('\n\n'); }).join('\n\n');
@ -85,20 +176,26 @@ function emitSpeck(speck: SpeckNode): string {
const stateVarNames = new Set(stateVars.map(v => v.name)); const stateVarNames = new Set(stateVars.map(v => v.name));
const stateProperties = stateVars.map(v => { const stateProperties = stateVars.map(v => {
return ` ${v.name}: ${tsType(v.typeExpr)};`; return ` ${v.name}: ${tsType(v.typeExpr, speck.name, enumMap)};`;
}).join('\n'); }).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 const initAssignments = initNode
? initNode.assignments.map(a => { ? initNode.assignments.map(a => {
const name = cleanName(a.name); const name = cleanName(a.name);
const expr = cleanExpr(a.expr); const expr = cleanExpr(a.expr);
// Replace bare state literals with enum refs in init // Replace bare state literals with enum refs in init
const enumRef = replaceStateWithEnum(expr, stateEnumName); const enumRef = replaceStateWithEnum(expr, stateEnumName, allKnownStateValues);
return ` this.${name} = ${enumRef};`; return ` this.${name} = ${enumRef};`;
}).join('\n') }).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) => const constraintChecks = constraints.map((c, i) =>
` // C${i + 1}: ${c.expr}\n` + ` // C${i + 1}: ${c.expr}\n` +
@ -118,11 +215,13 @@ function emitSpeck(speck: SpeckNode): string {
` }` ` }`
).join('\n\n'); ).join('\n\n');
// Decision log type: use first event if available, otherwise any[]
const logType = events.length > 0 ? events[0].name + '[]' : 'any[]';
const classBody = ` const classBody = `
export class ${className} { export class ${className} {
${stateProperties} ${stateProperties}
decisionLog: StateTransition[] = []; decisionLog: ${logType} = [];
evidence: EvidenceItem[] = [];
constructor() { constructor() {
${initAssignments || ' // No init block defined'} ${initAssignments || ' // No init block defined'}
@ -146,18 +245,18 @@ ${verifyChecks}
return file; return file;
} }
function emitAction(action: ActionNode, stateVarNames: Set<string>, speckName: string, stateEnumName: string): string { function emitAction(action: ActionNode, stateVarNames: Set<string>, speckName: string, stateEnumName: string, knownStateValues: string[], enumMap: Map<string, string[]>): string {
// Collect local parameter names so we DON'T prefix them with this. // Collect local parameter names so we DON'T prefix them with this.
const localNames = new Set(action.params.map(p => p.name)); const localNames = new Set(action.params.map(p => p.name));
const params = action.params const params = action.params
.map(p => `${p.name}: ${tsType(p.type)}`) .map(p => `${p.name}: ${tsType(p.type, speckName, enumMap)}`)
.join(', '); .join(', ');
const requires = action.statements const requires = action.statements
.filter(s => s.type === 'require') .filter(s => s.type === 'require')
.map(s => { .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, "\\'")}');`; return ` if (!(${expr})) throw new Error('Guard failed: ${expr.replace(/'/g, "\\'")}');`;
}) })
.join('\n'); .join('\n');
@ -166,7 +265,7 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, speckName: s
.filter(s => s.type === 'assign') .filter(s => s.type === 'assign')
.map(s => { .map(s => {
const target = cleanName(s.target); 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};`; return ` this.${target} = ${expr};`;
}) })
.join('\n'); .join('\n');
@ -177,7 +276,7 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, speckName: s
const fields = s.fields const fields = s.fields
.map(f => { .map(f => {
const name = cleanName(f.name); 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}`; return ` ${name}: ${val}`;
}) })
.join(',\n'); .join(',\n');
@ -191,7 +290,7 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, speckName: s
// Rewrite SpeckDL expressions to valid TypeScript // Rewrite SpeckDL expressions to valid TypeScript
// Only prefix identifiers with `this.` if they are state variables AND not local params // 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): string { function rewriteExpr(expr: string, stateVarNames: Set<string>, localNames: Set<string>, stateEnumName: string, knownStateValues: string[]): string {
function shouldPrefix(id: string): boolean { function shouldPrefix(id: string): boolean {
return stateVarNames.has(id) && !localNames.has(id); return stateVarNames.has(id) && !localNames.has(id);
} }
@ -204,7 +303,13 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, localNames: Set<s
.replace(/\bforall\s+(\w+)\s+in\s+([^:]+):\s*(.+)/g, '$2.every(($1) => $3)'); .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 // 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<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 3: Replace identifiers that are state variables but not local params // Step 3: Replace identifiers that are state variables but not local params
const tokens = ts.split(/([\s\(\)\[\]{}+\-*/=<>!&|.,;:'"`]+)/); const tokens = ts.split(/([\s\(\)\[\]{}+\-*/=<>!&|.,;:'"`]+)/);
@ -254,15 +359,19 @@ function cleanName(s: string): string {
return s.replace(/,$/, '').trim(); return s.replace(/,$/, '').trim();
} }
function replaceStateWithEnum(expr: string, enumName: string): string { function replaceStateWithEnum(expr: string, enumName: string, knownStateValues: string[]): string {
return expr.replace(/\b(Open|Grooming|Groomed|Assigned|InProgress|InReview|Merged|Blocked|Closed)\b/g, `${enumName}.$1`); 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;
const stateValueRegex = new RegExp('\\b(' + Array.from(allStateValues).join('|') + ')\\b', 'g');
return expr.replace(stateValueRegex, `${enumName}.$1`);
} }
function cleanExpr(s: string): string { function cleanExpr(s: string): string {
return s.replace(/,$/, '').trim(); return s.replace(/,$/, '').trim();
} }
function tsType(t: any): string { function tsType(t: any, speckName: string = '', enumMap?: Map<string, string[]>): string {
if (!t) return 'unknown'; if (!t) return 'unknown';
if (t.type === 'primitive') { if (t.type === 'primitive') {
const map: Record<string, string> = { const map: Record<string, string> = {
@ -271,22 +380,28 @@ function tsType(t: any): string {
}; };
return map[t.name] || t.name.toLowerCase(); return map[t.name] || t.name.toLowerCase();
} }
if (t.type === 'list') return `${tsType(t.elementType)}[]`; if (t.type === 'list') return `${tsType(t.elementType, speckName, enumMap)}[]`;
if (t.type === 'set') return `Set<${tsType(t.elementType)}>`; if (t.type === 'set') return `Set<${tsType(t.elementType, speckName, enumMap)}>`;
if (t.type === 'map') return `Map<${tsType(t.keyType)}, ${tsType(t.valueType)}>`; if (t.type === 'map') return `Map<${tsType(t.keyType, speckName, enumMap)}, ${tsType(t.valueType, speckName, enumMap)}>`;
if (t.type === 'record') { if (t.type === 'record') {
const fields = t.fields.map((f: any) => { const fields = t.fields.map((f: any) => {
const name = cleanName(f.name); const name = cleanName(f.name);
return `${name}: ${tsType(f.type)}`; return `${name}: ${tsType(f.type, speckName, enumMap)}`;
}).join('; '); }).join('; ');
return `{ ${fields} }`; return `{ ${fields} }`;
} }
if (t.type === 'ident') { if (t.type === 'ident') {
const name = cleanName(t.name); const rawName = cleanName(t.name); // strip trailing comma
// Map known identifier types // Check if this ident maps to a known enum interface
if (name === 'IssueState') return 'IssueWorkflowState'; if (enumMap && enumMap.has(rawName)) {
if (name === 'Nat') return 'number'; return `${speckName}${rawName}`;
return name; }
// 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'; return 'unknown';
} }

View file

@ -12,7 +12,7 @@
"@type": "prov:Generation", "@type": "prov:Generation",
"prov:activity": { "prov:activity": {
"@type": "prov:Activity", "@type": "prov:Activity",
"prov:startTime": "2026-05-03T18:51:34.102Z" "prov:startTime": "2026-05-03T20:13:42.864Z"
} }
}, },
"rdfs:label": "RetryHandler", "rdfs:label": "RetryHandler",

View file

@ -38,7 +38,7 @@
"name": "Scott Scoble", "name": "Scott Scoble",
"email": "scott@scoble.me" "email": "scott@scoble.me"
}, },
"timestamp": "2026-05-03T18:51:34.104Z" "timestamp": "2026-05-03T20:13:42.867Z"
}, },
{ {
"subject": "speck:RetryHandler", "subject": "speck:RetryHandler",
@ -53,7 +53,7 @@
"name": "Speckl Compiler", "name": "Speckl Compiler",
"email": "compiler@speckl.scoble.me" "email": "compiler@speckl.scoble.me"
}, },
"timestamp": "2026-05-03T18:51:34.104Z" "timestamp": "2026-05-03T20:13:42.867Z"
}, },
{ {
"subject": "speck:RetryHandler", "subject": "speck:RetryHandler",
@ -68,7 +68,7 @@
"name": "Speckl Compiler", "name": "Speckl Compiler",
"email": "compiler@speckl.scoble.me" "email": "compiler@speckl.scoble.me"
}, },
"timestamp": "2026-05-03T18:51:34.104Z" "timestamp": "2026-05-03T20:13:42.867Z"
} }
] ]
} }

View file

@ -13,7 +13,7 @@
"spdxVersion": "3.0.1", "spdxVersion": "3.0.1",
"dataLicense": "CC0-1.0", "dataLicense": "CC0-1.0",
"creationInfo": { "creationInfo": {
"created": "2026-05-03T18:51:34.105Z", "created": "2026-05-03T20:13:42.868Z",
"createdBy": [ "createdBy": [
"Organization: Speckl", "Organization: Speckl",
"tool: speckl-compile-0.2.0" "tool: speckl-compile-0.2.0"
@ -27,7 +27,7 @@
"@type": "spdx:Tool", "@type": "spdx:Tool",
"name": "speckl-compile" "name": "speckl-compile"
}, },
"timestamp": "2026-05-03T18:51:34.105Z", "timestamp": "2026-05-03T20:13:42.868Z",
"comment": "Review type: manual" "comment": "Review type: manual"
} }
] ]