feat(compiler): self-hosting spec + generator multi-enum support
Some checks failed
Deploy Landing Page to Storj / deploy (push) Failing after 2s
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:
parent
39436b4382
commit
7230650628
12 changed files with 814 additions and 46 deletions
34
compiler/node_modules/.package-lock.json
generated
vendored
34
compiler/node_modules/.package-lock.json
generated
vendored
|
|
@ -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",
|
||||||
|
|
|
||||||
2
compiler/node_modules/.vite/vitest/results.json
generated
vendored
2
compiler/node_modules/.vite/vitest/results.json
generated
vendored
|
|
@ -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}]]}
|
||||||
29
compiler/out/speckl-compile/SpecklCompiler.prov.jsonld
Normal file
29
compiler/out/speckl-compile/SpecklCompiler.prov.jsonld
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
30
compiler/out/speckl-compile/SpecklCompiler.specbom.cdx.json
Normal file
30
compiler/out/speckl-compile/SpecklCompiler.specbom.cdx.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
42
compiler/out/speckl-compile/SpecklCompiler.specbom.spdx.json
Normal file
42
compiler/out/speckl-compile/SpecklCompiler.specbom.spdx.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
153
compiler/out/speckl-compile/SpecklCompiler.ts
Normal file
153
compiler/out/speckl-compile/SpecklCompiler.ts
Normal 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() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
151
compiler/out/speckl-compile/SpecklCompiler.wat
Normal file
151
compiler/out/speckl-compile/SpecklCompiler.wat
Normal 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)
|
||||||
|
)
|
||||||
|
)
|
||||||
216
compiler/specs/speckl-compile.speckdl
Normal file
216
compiler/specs/speckl-compile.speckdl
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue