Some checks failed
Deploy Landing Page to Storj / deploy (push) Failing after 2s
blog-01: Spec-Code Drift Is a Provenance Problem (~1,500 words) blog-02: Designing SpeckDL (~1,800 words) blog-03: How Speckl's Compiler Works (~1,900 words) blog-04: Embedded Provenance (~1,200 words) blog-05: Consensus Protocols in SpeckDL (~1,300 words) publishing-toolkit: Dev.to/Medium/Reddit/HN formatting + objection prep (~3,500 words) Total Phase 2 pipeline: ~11,200 words across 6 files. Previously existed only in workspace copy (posts 1/3/toolkit) or as untracked files (posts 2/4/5). All now committed to Forgejo.
154 lines
8.3 KiB
Markdown
154 lines
8.3 KiB
Markdown
# How Speckl's Compiler Works: From Spec to Five Auditable Artifacts
|
|
|
|
**Series:** Building Speckl — Part 3 of 5
|
|
**Published:** [date]
|
|
**Read time:** 8 minutes
|
|
**Tags:** #compilers #specification #formal-methods #typescript #webassembly #compliance
|
|
|
|
---
|
|
|
|
You've seen *why* spec-code drift matters (Part 1) and *what* SpeckDL looks like (Part 2). Now let's look under the hood: how does a single `.speck` file become five independently auditable artifacts?
|
|
|
|
The Speckl compiler is a single-pass, multi-target compiler in TypeScript. It's deterministic, zero-dependency (beyond Node.js + wabt), and produces no network calls. Here's the pipeline:
|
|
|
|
```
|
|
.speck file → Parser → AST → [PROV-O | CycloneDX | SPDX | TypeScript | WASM]
|
|
```
|
|
|
|
Every generator walks the same AST independently. No generator ever talks to another. That shared AST is the single source of truth — if the spec says it, every artifact reflects it.
|
|
|
|
## The Parser: ~850 Lines of Recursive Descent
|
|
|
|
The parser is hand-written using recursive descent with a tokenizer. No parser generators. No PEG grammar files. Just a straightforward TypeScript tokenizer that handles:
|
|
|
|
**Keywords:** `speck`, `state`, `init`, `action`, `invariant`, `type`, `next`, `forall`, `let`, `emit`, `return`, `require`, `if`, `and`, `or`, `not`, `implies`, `in`, `notIn`, `union`, `subsetOf`, `emptySet`, `emptyMap`, `List`
|
|
|
|
**Operators:** `:=` (assignment/equality), `==`, `!=`, `<=`, `>=`, `+`, `-`, `*`, `/`, `\` (set difference), `|` (action composition)
|
|
|
|
**Types:** `Bool`, `Nat`, `Int`, `String`, `Set<T>`, `Map<K,V>`, `List<T>`, `Record{...}`, `Optional<T>`
|
|
|
|
The parser builds a typed AST with these node types:
|
|
|
|
- **SpeckNode** — top-level spec with name and member list
|
|
- **StateNode** — typed state variables
|
|
- **InitNode** — initial state assertions
|
|
- **ActionNode** — named actions with preconditions, body, and return type
|
|
- **InvariantNode** — boolean invariants checked after every action
|
|
- **NextNode** — action composition (`A | B | C` means "A or B or C may fire next")
|
|
|
|
Type aliases are fully resolved during parsing — generators never see them. A `type AccountID = Nat` becomes `Nat` everywhere it's referenced. This means generators operate on structurally complete types, never chasing references.
|
|
|
|
## Five Generators, One AST
|
|
|
|
### 1. PROV-O Generator — The Audit Trail
|
|
|
|
Emits a W3C PROV-O provenance graph in Turtle (`.ttl`). This is the foundation of Speckl's auditability:
|
|
|
|
- **Entities:** The `.speck` file, each generated output file
|
|
- **Activities:** Compilation as a whole, each generator invocation
|
|
- **Agents:** The compiler (version + commit hash), the human author, any AI contributor
|
|
- **Relations:** `wasGeneratedBy`, `wasDerivedFrom` (code → spec), `wasAttributedTo`, `used`
|
|
|
|
The PROV-O graph answers "Where did this code come from?" with a machine-readable chain. Feed it to a PROV validator and you get cryptographic certainty about provenance.
|
|
|
|
### 2. CycloneDX Generator — The Industry-Standard SBOM
|
|
|
|
Outputs CycloneDX v1.4 JSON. Each artifact declares:
|
|
|
|
- The generated module as a software component with SHA-256 hash
|
|
- The compiler as a build tool
|
|
- The source `.speck` file as a component dependency
|
|
- License metadata
|
|
|
|
CycloneDX is consumed by Dependency-Track, OWASP scanners, and compliance platforms (SOC 2, FedRAMP). Your spec doesn't just produce code — it ships with an audit-ready SBOM out of the box.
|
|
|
|
### 3. SPDX Generator — The ISO Standard SBOM
|
|
|
|
Outputs SPDX v2.3 JSON (ISO/IEC 5962:2021). Provides package-level license declarations, relationship mapping to the source spec, and creator attribution. SPDX is required by many enterprise procurement processes — Speckl generates it automatically.
|
|
|
|
Two SBOM generators might seem redundant, but they serve different ecosystems: CycloneDX for OWASP/devsecops toolchains, SPDX for enterprise legal review. Both from the same spec with zero extra effort.
|
|
|
|
### 4. TypeScript Generator — Runnable State Machines
|
|
|
|
This is the powerhouse. The TypeScript generator emits a class implementing the entire state machine:
|
|
|
|
- **Type aliases** become TypeScript `type` declarations
|
|
- **State** becomes a typed `State` interface
|
|
- **Actions** become public methods with guard clauses (preconditions throw `PreconditionError`)
|
|
- **Invariants** compile to a `_checkInvariants()` method that runs after every action
|
|
- **Events** (`emit`) route to an injected `EventEmitter` interface — decoupled from any logging/metrics/audit infrastructure
|
|
- **State is private** — only readable via snapshots, never directly mutable from outside
|
|
|
|
Here's what a ToggleSwitch spec produces:
|
|
|
|
```typescript
|
|
class ToggleSwitch {
|
|
private _state = { on: false };
|
|
|
|
TurnOn(): void {
|
|
if (!this._state.on) throw new PreconditionError("TurnOn requires !on but on is true");
|
|
this._state.on = true;
|
|
this._checkInvariants();
|
|
}
|
|
|
|
TurnOff(): void {
|
|
if (this._state.on) throw new PreconditionError("TurnOff requires on but on is false");
|
|
this._state.on = false;
|
|
this._checkInvariants();
|
|
}
|
|
|
|
private _checkInvariants(): void {
|
|
// Runtime invariant checks
|
|
}
|
|
|
|
getState(): Readonly<State> { return { ...this._state }; }
|
|
}
|
|
```
|
|
|
|
It also handles `forall` loops over sets, `if` branches, `let` bindings, and compound types (`Set<T>` → `Set<T>`, `Map<K,V>` → `Map<K,V>`, `List<T>` → `T[]`).
|
|
|
|
### 5. WASM Generator — Specs in the Browser
|
|
|
|
Emits WebAssembly Text Format (`.wat`) that assembles to `.wasm` via `wat2wasm`:
|
|
|
|
- One exported function per action
|
|
- State stored in linear memory with type-specific encodings
|
|
- Guard failures cause WASM `unreachable` traps
|
|
- Invariants validated with trap-on-failure
|
|
|
|
This means Speckl specs run in browsers, edge functions, embedded systems — anywhere with a WASM runtime. And because the output is human-readable `.wat` before assembly, you can grep it, verify it, and feed it to formal verification tools.
|
|
|
|
## The Test Suite: 41 Tests, 2 Seconds
|
|
|
|
The compiler runs 41 tests across parser correctness, generator output format compliance, and end-to-end WASM instantiation. All tests complete in under 2 seconds. The suite is designed to make the compiler safe to refactor — break spec semantics, and tests catch it instantly.
|
|
|
|
Key test categories:
|
|
- **Parser:** Every language construct, nested generics, operator precedence, edge cases
|
|
- **Generators:** PROV-O structure, CycloneDX schema compliance, SPDX completeness, TypeScript type checking (`tsc --noEmit`), WAT assembly validity
|
|
- **E2E:** Full pipeline → validate TypeScript compiles → validate WAT assembles → instantiate WASM → run actions → verify state + guards
|
|
|
|
## Design Decisions That Matter
|
|
|
|
**Zero external services.** The compiler never makes network calls. Reads from disk, writes to disk. Deterministic, reproducible, air-gappable.
|
|
|
|
**Independent generators.** Adding a new target language (Rust, Python, C, SQL schema) means writing one new 200-400 line module. The parser and other generators don't change.
|
|
|
|
**Text-first WASM.** We emit `.wat` (text) not binary `.wasm`. Human-readable. Grep-able. Assembly is deferred to `wat2wasm`, keeping dependency surface minimal.
|
|
|
|
**DIST DESYNC guard.** A `check-dist.sh` + Makefile guard prevents stale compiled JavaScript from masking real bugs. The compiler refuses to run if `dist/` is out of date relative to `src/`. This eliminated an entire class of false-positive test passes.
|
|
|
|
**CLI-first.** The compiler is `speckl compile file.speck`. Internal API is stable but the primary interface is the command line — easy to drop into Makefiles, CI/CD pipelines, and build scripts.
|
|
|
|
## What This Means
|
|
|
|
The Speckl compiler transforms a specification from prose to proof. One `.speck` file becomes: a provenance graph, two industry-standard SBOMs, a type-safe TypeScript class, and a deployable WASM module. All independently auditable. All from a shared AST with zero external dependencies.
|
|
|
|
In the next post, we'll look at Speckl's embedded provenance system — how the compiler proves that the code you're running is the code you specified.
|
|
|
|
---
|
|
|
|
**Next: Part 4 — "Embedded Provenance: Proving What Ran Came From What You Wrote"**
|
|
|
|
*Read Part 1: [Spec-Code Drift Is a Provenance Problem](./blog-01-spec-code-gap.md)*
|
|
*Read Part 2: [Designing SpeckDL: What a Spec Language Needs in 2026](./blog-02-designing-speckdl.md)*
|
|
*Speckl repo: [os.scoble.me/forgejo/sscoble/speckl](https://os.scoble.me/forgejo/sscoble/speckl)*
|