speckl: TypeScript state machine generator + Forgejo issue workflow spec
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
Adds a first-class TypeScript code generator to speckl-compile that emits: - Const enum for workflow states (IssueWorkflowState) - Strongly-typed class with guards, state transitions, decision log - Proper this. prefixing (state vars only, not local params) - Enum references instead of string literals for state comparisons - Clean tsc --noEmit compilation Also includes: - specs/forgejo-issue-workflow.speckdl — full workflow with 9 actions, 8 constraints, 5 temporal verifications - Generator deduplication of duplicate AST nodes - Support for init block initialization of state variables The generator was validated against the Forgejo issue workflow spec, producing IssueWorkflow.ts that compiles with zero errors. Refs: heartbeat conversation re: speckl-compile extensibility
This commit is contained in:
parent
cbee677a11
commit
7ffd65f6b3
18 changed files with 2272 additions and 107 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSpeckContent } from '../src/parser.js';
|
||||
import { parseSpeckContent, parseSpeckFile } from '../src/parser.js';
|
||||
import { generateWASM } from '../src/generators/wasm.js';
|
||||
|
||||
describe('SpeckDL Parser', () => {
|
||||
describe('parseSpeckContent', () => {
|
||||
|
|
@ -387,4 +388,213 @@ speck Second {
|
|||
expect(types).toContain('verify');
|
||||
});
|
||||
});
|
||||
|
||||
describe('member parsing - state machine', () => {
|
||||
it('should parse state block with primitive variables', () => {
|
||||
const content = `speck ToggleSwitch {
|
||||
state: {
|
||||
on: Bool := false
|
||||
toggleCount: Nat := 0
|
||||
}
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const state = ast.specks[0].members.find(m => m.type === 'state');
|
||||
expect(state).toBeDefined();
|
||||
if (state?.type === 'state') {
|
||||
expect(state.variables).toHaveLength(2);
|
||||
expect(state.variables[0].name).toBe('on');
|
||||
expect(state.variables[0].typeExpr.type).toBe('primitive');
|
||||
expect(state.variables[0].typeExpr.name).toBe('Bool');
|
||||
expect(state.variables[0].defaultInit).toBe('false');
|
||||
expect(state.variables[1].name).toBe('toggleCount');
|
||||
expect(state.variables[1].defaultInit).toBe('0');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse state block with Set and Map types', () => {
|
||||
const content = `speck AccountLedger {
|
||||
state: {
|
||||
balances: Map<Nat, Real>
|
||||
participants: Set<Nat>
|
||||
}
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const state = ast.specks[0].members.find(m => m.type === 'state');
|
||||
if (state?.type === 'state') {
|
||||
expect(state.variables).toHaveLength(2);
|
||||
expect(state.variables[0].typeExpr.type).toBe('map');
|
||||
expect(state.variables[1].typeExpr.type).toBe('set');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse init block', () => {
|
||||
const content = `speck Test {
|
||||
state: {
|
||||
x: Nat
|
||||
}
|
||||
init: {
|
||||
x := 0
|
||||
}
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const init = ast.specks[0].members.find(m => m.type === 'init');
|
||||
expect(init).toBeDefined();
|
||||
if (init?.type === 'init') {
|
||||
expect(init.assignments).toHaveLength(1);
|
||||
expect(init.assignments[0].name).toBe('x');
|
||||
expect(init.assignments[0].expr).toBe('0');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse action without parameters', () => {
|
||||
const content = `speck Test {
|
||||
action Toggle() {
|
||||
x := x + 1
|
||||
}
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const action = ast.specks[0].members.find(m => m.type === 'action');
|
||||
expect(action).toBeDefined();
|
||||
if (action?.type === 'action') {
|
||||
expect(action.name).toBe('Toggle');
|
||||
expect(action.params).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse action with parameters', () => {
|
||||
const content = `speck Test {
|
||||
action Transfer(from: Nat, to: Nat, amount: Real) {
|
||||
require from in balances
|
||||
balances[from] := balances[from] - amount
|
||||
}
|
||||
}`;
|
||||
const ast = parseSpeckContent(content);
|
||||
const action = ast.specks[0].members.find(m => m.type === 'action');
|
||||
if (action?.type === 'action') {
|
||||
expect(action.name).toBe('Transfer');
|
||||
expect(action.params).toHaveLength(3);
|
||||
expect(action.params[0].name).toBe('from');
|
||||
expect(action.params[0].type.type).toBe('primitive');
|
||||
expect(action.params[0].type.name).toBe('Nat');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse full TigerBeetle speck from file', () => {
|
||||
const ast = parseSpeckFile('./__tests__/tigerbeetle.speck');
|
||||
expect(ast.specks).toHaveLength(1);
|
||||
|
||||
const speck = ast.specks[0];
|
||||
expect(speck.name).toBe('TigerBeetle');
|
||||
|
||||
const state = speck.members.find(m => m.type === 'state');
|
||||
const init = speck.members.find(m => m.type === 'init');
|
||||
const actions = speck.members.filter(m => m.type === 'action');
|
||||
const events = speck.members.filter(m => m.type === 'event');
|
||||
const constraints = speck.members.filter(m => m.type === 'constraint');
|
||||
const verifies = speck.members.filter(m => m.type === 'verify');
|
||||
|
||||
expect(state).toBeDefined();
|
||||
expect(init).toBeDefined();
|
||||
expect(actions).toHaveLength(4);
|
||||
expect(events).toHaveLength(3);
|
||||
expect(constraints).toHaveLength(2);
|
||||
expect(verifies).toHaveLength(2);
|
||||
|
||||
if (state?.type === 'state') {
|
||||
expect(state.variables).toHaveLength(4);
|
||||
expect(state.variables[0].name).toBe('nextId');
|
||||
expect(state.variables[1].typeExpr.type).toBe('map');
|
||||
expect(state.variables[2].typeExpr.type).toBe('set');
|
||||
}
|
||||
|
||||
if (init?.type === 'init') {
|
||||
expect(init.assignments).toHaveLength(4);
|
||||
}
|
||||
|
||||
const createAccount = actions.find(a => a.type === 'action' && a.name === 'CreateAccount');
|
||||
expect(createAccount).toBeDefined();
|
||||
if (createAccount?.type === 'action') {
|
||||
expect(createAccount.params).toHaveLength(1);
|
||||
expect(createAccount.statements.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const transfer = actions.find(a => a.type === 'action' && a.name === 'Transfer');
|
||||
expect(transfer).toBeDefined();
|
||||
if (transfer?.type === 'action') {
|
||||
expect(transfer.params).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('WASM Codegen', () => {
|
||||
it('should generate WAT for TigerBeetle with all exports', () => {
|
||||
const ast = parseSpeckFile('./__tests__/tigerbeetle.speck');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'speckl-wasm-test-'));
|
||||
|
||||
generateWASM(ast, tmpDir);
|
||||
|
||||
const watPath = path.join(tmpDir, 'TigerBeetle.wat');
|
||||
expect(fs.existsSync(watPath)).toBe(true);
|
||||
|
||||
const wat = fs.readFileSync(watPath, 'utf-8');
|
||||
|
||||
// Module structure
|
||||
expect(wat).toContain('(module');
|
||||
expect(wat).toContain('(memory (export "memory")');
|
||||
|
||||
// Host imports
|
||||
expect(wat).toContain('(import "speckl" "map_create"');
|
||||
expect(wat).toContain('(import "speckl" "set_create"');
|
||||
|
||||
// State globals
|
||||
expect(wat).toContain('(global $nextId');
|
||||
expect(wat).toContain('(global $accounts');
|
||||
expect(wat).toContain('(global $transfers');
|
||||
expect(wat).toContain('(global $ledgerOpen');
|
||||
|
||||
// init export
|
||||
expect(wat).toContain('(func $init (export "init")');
|
||||
|
||||
// Action exports
|
||||
expect(wat).toContain('(func $CreateAccount (export "CreateAccount")');
|
||||
expect(wat).toContain('(func $Transfer (export "Transfer")');
|
||||
expect(wat).toContain('(func $CloseAccount (export "CloseAccount")');
|
||||
expect(wat).toContain('(func $CloseLedger (export "CloseLedger")');
|
||||
|
||||
// getState export
|
||||
expect(wat).toContain('(func $getState (export "getState")');
|
||||
|
||||
// Constraint stub
|
||||
expect(wat).toContain('(func $checkConstraints (export "checkConstraints")');
|
||||
|
||||
// Scalar init values
|
||||
expect(wat).toContain('(i32.const 1)'); // nextId default
|
||||
expect(wat).toContain('(call $map_create)');
|
||||
expect(wat).toContain('(call $set_create)');
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('should emit scalar guard expressions as WASM comparison', () => {
|
||||
const ast = parseSpeckFile('./__tests__/tigerbeetle.speck');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'speckl-wasm-test-'));
|
||||
|
||||
generateWASM(ast, tmpDir);
|
||||
const wat = fs.readFileSync(path.join(tmpDir, 'TigerBeetle.wat'), 'utf-8');
|
||||
|
||||
// ledgerOpen == true should compile to i32.eq
|
||||
expect(wat).toContain('(i32.eq (global.get $ledgerOpen) (i32.const 1))');
|
||||
|
||||
// nextId + 1 should compile to i32.add
|
||||
expect(wat).toContain('(i32.add (global.get $nextId) (i32.const 1))');
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
91
compiler/__tests__/tigerbeetle.speck
Normal file
91
compiler/__tests__/tigerbeetle.speck
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
speck TigerBeetle {
|
||||
author: "Speckl Compiler v0.3"
|
||||
source: architecture_review ref "TigerBeetle-Core-Port"
|
||||
review: hybrid
|
||||
|
||||
state: {
|
||||
nextId: Nat := 1
|
||||
accounts: Map<Nat, Account>
|
||||
transfers: Set<Transfer>
|
||||
ledgerOpen: Bool := true
|
||||
}
|
||||
|
||||
init: {
|
||||
nextId := 1
|
||||
accounts := Map.empty()
|
||||
transfers := Set.empty()
|
||||
ledgerOpen := true
|
||||
}
|
||||
|
||||
event AccountCreated {
|
||||
accountId: Nat,
|
||||
initialBalance: Real
|
||||
}
|
||||
|
||||
event TransferCompleted {
|
||||
from: Nat,
|
||||
to: Nat,
|
||||
amount: Real,
|
||||
transferId: Nat
|
||||
}
|
||||
|
||||
event AccountClosed {
|
||||
accountId: Nat
|
||||
}
|
||||
|
||||
action CreateAccount(initialDeposit: Real) {
|
||||
require initialDeposit >= 0
|
||||
require ledgerOpen == true
|
||||
|
||||
let newId := nextId
|
||||
nextId := nextId + 1
|
||||
accounts[newId] := Account { id: newId, balance: initialDeposit, open: true }
|
||||
|
||||
emit AccountCreated { accountId: newId, initialBalance: initialDeposit }
|
||||
return newId
|
||||
}
|
||||
|
||||
action Transfer(from: Nat, to: Nat, amount: Real) {
|
||||
require from in accounts
|
||||
require to in accounts
|
||||
require from != to
|
||||
require amount > 0
|
||||
require accounts[from].balance >= amount
|
||||
require accounts[from].open == true
|
||||
require accounts[to].open == true
|
||||
require ledgerOpen == true
|
||||
|
||||
accounts[from].balance := accounts[from].balance - amount
|
||||
accounts[to].balance := accounts[to].balance + amount
|
||||
|
||||
let tx := Transfer { from, to, amount, id: nextId }
|
||||
nextId := nextId + 1
|
||||
transfers.add(tx)
|
||||
|
||||
emit TransferCompleted { from, to, amount, transferId: tx.id }
|
||||
}
|
||||
|
||||
action CloseAccount(id: Nat) {
|
||||
require id in accounts
|
||||
require accounts[id].balance == 0
|
||||
require accounts[id].open == true
|
||||
|
||||
accounts[id].open := false
|
||||
|
||||
emit AccountClosed { accountId: id }
|
||||
}
|
||||
|
||||
action CloseLedger() {
|
||||
require forall(id in accounts.keys(), accounts[id].open == false)
|
||||
|
||||
ledgerOpen := false
|
||||
}
|
||||
|
||||
constraint: forall(id in accounts.keys(), implies(accounts[id].open, accounts[id].balance >= 0))
|
||||
constraint: ledgerOpen in [true, false]
|
||||
|
||||
verify: always(implies(not ledgerOpen,
|
||||
forall(id in accounts.keys(), accounts[id].open == false)))
|
||||
verify: always(implies(transfers.size() > 0,
|
||||
exists(tx in transfers, tx.amount > 0)))
|
||||
}
|
||||
8
compiler/dist/index.js
vendored
8
compiler/dist/index.js
vendored
|
|
@ -5,6 +5,8 @@ import { parseSpeckFile } from './parser.js';
|
|||
import { generateProvenanceGraph } from './generators/provenance.js';
|
||||
import { generateCycloneDX } from './generators/cyclonedx.js';
|
||||
import { generateSPDX } from './generators/spdx.js';
|
||||
import { generateTypeScriptStateMachine } from './generators/typescript-state-machine.js';
|
||||
import { generateWASM } from './generators/wasm.js';
|
||||
import fs from 'fs';
|
||||
async function main() {
|
||||
const argv = await yargs(hideBin(process.argv))
|
||||
|
|
@ -49,6 +51,12 @@ async function main() {
|
|||
// Always generate provenance (it's the core output)
|
||||
console.log('\nGenerating provenance graph (PROV-O JSON-LD)...');
|
||||
generateProvenanceGraph(ast, options.outputDir);
|
||||
// Generate TypeScript state machine
|
||||
console.log('\nGenerating TypeScript state machine...');
|
||||
generateTypeScriptStateMachine(ast, options.outputDir);
|
||||
// Generate WASM state machine
|
||||
console.log('\nGenerating WebAssembly (WAT)...');
|
||||
generateWASM(ast, options.outputDir);
|
||||
// Generate BOM based on format selection
|
||||
if (options.bomFormat === 'cdx' || options.bomFormat === 'both') {
|
||||
console.log('\nGenerating CycloneDX BOM...');
|
||||
|
|
|
|||
2
compiler/dist/index.js.map
vendored
2
compiler/dist/index.js.map
vendored
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
||||
53
compiler/dist/parser.d.ts
vendored
53
compiler/dist/parser.d.ts
vendored
|
|
@ -97,15 +97,64 @@ export interface BOMNode {
|
|||
license?: string;
|
||||
hash?: string;
|
||||
}
|
||||
export type MemberNode = ImportNode | InputNode | OutputNode | ConstraintNode | VerifyNode | InterfaceNode | EventNode | ProvenanceNode | ReviewNode | DerivesNode | SatisfiesNode | AuthorNode | SourceNode | BOMNode;
|
||||
export interface StateNode {
|
||||
type: 'state';
|
||||
variables: StateVar[];
|
||||
}
|
||||
export interface StateVar {
|
||||
name: string;
|
||||
typeExpr: TypeExpr;
|
||||
defaultInit?: string;
|
||||
}
|
||||
export interface InitNode {
|
||||
type: 'init';
|
||||
assignments: {
|
||||
name: string;
|
||||
expr: string;
|
||||
}[];
|
||||
}
|
||||
export interface ActionNode {
|
||||
type: 'action';
|
||||
name: string;
|
||||
params: {
|
||||
name: string;
|
||||
type: TypeExpr;
|
||||
}[];
|
||||
statements: ActionStatement[];
|
||||
}
|
||||
export type ActionStatement = {
|
||||
type: 'assign';
|
||||
target: string;
|
||||
expr: string;
|
||||
} | {
|
||||
type: 'let';
|
||||
name: string;
|
||||
expr: string;
|
||||
} | {
|
||||
type: 'require';
|
||||
expr: string;
|
||||
} | {
|
||||
type: 'emit';
|
||||
event: string;
|
||||
fields: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
} | {
|
||||
type: 'return';
|
||||
expr: string;
|
||||
};
|
||||
export type MemberNode = ImportNode | InputNode | OutputNode | ConstraintNode | VerifyNode | InterfaceNode | EventNode | ProvenanceNode | ReviewNode | DerivesNode | SatisfiesNode | AuthorNode | SourceNode | BOMNode | StateNode | InitNode | ActionNode;
|
||||
export interface TypeExpr {
|
||||
type: 'primitive' | 'record' | 'list' | 'ident';
|
||||
type: 'primitive' | 'record' | 'list' | 'set' | 'map' | 'ident';
|
||||
name?: string;
|
||||
fields?: {
|
||||
name: string;
|
||||
type: TypeExpr;
|
||||
}[];
|
||||
elementType?: TypeExpr;
|
||||
keyType?: TypeExpr;
|
||||
valueType?: TypeExpr;
|
||||
}
|
||||
export interface AST {
|
||||
specks: SpeckNode[];
|
||||
|
|
|
|||
2
compiler/dist/parser.d.ts.map
vendored
2
compiler/dist/parser.d.ts.map
vendored
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"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"}
|
||||
309
compiler/dist/parser.js
vendored
309
compiler/dist/parser.js
vendored
|
|
@ -46,43 +46,95 @@ export function parseSpeckContent(content, filePath) {
|
|||
return { specks };
|
||||
}
|
||||
function parseSpeck(lines, startIndex) {
|
||||
// Parse speck header: speck <Name> {
|
||||
const headerMatch = lines[startIndex].match(/^speck\s+([\w-]+)\s*{/);
|
||||
if (!headerMatch) {
|
||||
if (!headerMatch)
|
||||
return null;
|
||||
}
|
||||
const name = headerMatch[1];
|
||||
const members = [];
|
||||
let i = startIndex + 1;
|
||||
let braceCount = 1;
|
||||
while (i < lines.length && braceCount > 0) {
|
||||
const line = lines[i].trim();
|
||||
// Skip empty lines and comments
|
||||
const rawLine = lines[i];
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('//') || line.startsWith('/*')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.includes('{')) {
|
||||
braceCount++;
|
||||
}
|
||||
if (line.includes('}')) {
|
||||
braceCount--;
|
||||
if (braceCount === 0) {
|
||||
break;
|
||||
// Count braces on this line first
|
||||
const openCount = (rawLine.match(/{/g) || []).length;
|
||||
const closeCount = (rawLine.match(/}/g) || []).length;
|
||||
// Check for block-opening members
|
||||
const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface '];
|
||||
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
|
||||
if (isBlockStarter && openCount > 0) {
|
||||
// Multi-line block: parse it and skip to its end
|
||||
const member = parseMemberBlock(lines, i);
|
||||
if (member) {
|
||||
members.push(member);
|
||||
}
|
||||
// Skip past this block
|
||||
const endIdx = findBlockEnd(lines, i + 1, braceCount + openCount);
|
||||
i = endIdx + 1;
|
||||
continue;
|
||||
}
|
||||
// Single-line: update brace count, then parse if still inside
|
||||
braceCount += openCount - closeCount;
|
||||
if (braceCount <= 0) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
// Parse member
|
||||
const member = parseMember(line);
|
||||
if (member) {
|
||||
members.push(member);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return {
|
||||
type: 'speck',
|
||||
name,
|
||||
members,
|
||||
};
|
||||
return { type: 'speck', name, members };
|
||||
}
|
||||
function findBlockEnd(lines, startIndex, startBraceCount) {
|
||||
let braceCount = startBraceCount;
|
||||
let i = startIndex;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
for (const ch of line) {
|
||||
if (ch === '{')
|
||||
braceCount++;
|
||||
if (ch === '}') {
|
||||
braceCount--;
|
||||
if (braceCount < startBraceCount) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
function parseMemberBlock(lines, startIndex) {
|
||||
const firstLine = lines[startIndex].trim();
|
||||
const startBraceCount = (firstLine.match(/{/g) || []).length;
|
||||
if (firstLine.startsWith('state:')) {
|
||||
return parseStateBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('init:')) {
|
||||
return parseInitBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('action ')) {
|
||||
return parseActionBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('event ')) {
|
||||
return parseEventBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('interface ')) {
|
||||
return parseInterfaceBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('provenance ')) {
|
||||
return { type: 'provenance', clauses: [] };
|
||||
}
|
||||
if (firstLine.startsWith('bom ')) {
|
||||
return { type: 'bom' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function parseMember(line) {
|
||||
// input:
|
||||
|
|
@ -136,6 +188,30 @@ function parseMember(line) {
|
|||
if (line.startsWith('source:')) {
|
||||
return parseSource(line);
|
||||
}
|
||||
// state:
|
||||
if (line.startsWith('state:')) {
|
||||
const varsStr = line.substring(6).trim();
|
||||
// state: { ... } - single-line form
|
||||
if (varsStr.startsWith('{') && varsStr.endsWith('}')) {
|
||||
const inner = varsStr.slice(1, -1).trim();
|
||||
return parseStateBlock(inner);
|
||||
}
|
||||
// state: { - multi-line opening (handled by parseStateMultiline)
|
||||
return { type: 'state', variables: [] };
|
||||
}
|
||||
// init:
|
||||
if (line.startsWith('init:')) {
|
||||
const initStr = line.substring(5).trim();
|
||||
if (initStr.startsWith('{') && initStr.endsWith('}')) {
|
||||
const inner = initStr.slice(1, -1).trim();
|
||||
return parseInitBlock(inner);
|
||||
}
|
||||
return { type: 'init', assignments: [] };
|
||||
}
|
||||
// action keyword
|
||||
if (line.startsWith('action ')) {
|
||||
return parseActionHeader(line);
|
||||
}
|
||||
// bom {
|
||||
if (line.startsWith('bom ')) {
|
||||
// Simplified - full BOM block parsing would need multi-line context
|
||||
|
|
@ -145,6 +221,62 @@ function parseMember(line) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
function parseStateBlock(inner) {
|
||||
const variables = [];
|
||||
if (!inner.trim())
|
||||
return { type: 'state', variables };
|
||||
const varStrs = inner.split(/;|\n/).map(s => s.trim()).filter(Boolean);
|
||||
for (const vStr of varStrs) {
|
||||
const withDefault = vStr.match(/^(\w+)\s*:\s*(.+?)\s*:=\s*(.+)$/);
|
||||
if (withDefault) {
|
||||
variables.push({
|
||||
name: withDefault[1],
|
||||
typeExpr: parseTypeExpr(withDefault[2].trim()),
|
||||
defaultInit: withDefault[3].trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const withoutDefault = vStr.match(/^(\w+)\s*:\s*(.+)$/);
|
||||
if (withoutDefault) {
|
||||
variables.push({
|
||||
name: withoutDefault[1],
|
||||
typeExpr: parseTypeExpr(withoutDefault[2].trim()),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { type: 'state', variables };
|
||||
}
|
||||
function parseInitBlock(inner) {
|
||||
const assignments = [];
|
||||
if (!inner.trim())
|
||||
return { type: 'init', assignments };
|
||||
const assignStrs = inner.split(/;|\n/).map(s => s.trim()).filter(Boolean);
|
||||
for (const aStr of assignStrs) {
|
||||
const match = aStr.match(/^(\w+)\s*:=\s*(.+)$/);
|
||||
if (match) {
|
||||
assignments.push({ name: match[1], expr: match[2].trim() });
|
||||
}
|
||||
}
|
||||
return { type: 'init', assignments };
|
||||
}
|
||||
function parseActionHeader(line) {
|
||||
const match = line.match(/^action\s+(\w+)\s*(?:\(([^)]*)\))?\s*\{/);
|
||||
if (!match)
|
||||
return null;
|
||||
const name = match[1];
|
||||
const paramsStr = match[2] || '';
|
||||
const params = [];
|
||||
if (paramsStr.trim()) {
|
||||
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const p of paramPairs) {
|
||||
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/);
|
||||
if (pMatch) {
|
||||
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: 'action', name, params, statements: [] };
|
||||
}
|
||||
function parseImportStatement(line) {
|
||||
const match = line.match(/^import\s+(['"])(.+?)\1(?:\s+as\s+(\w+))?(?:\s+version\s+(['"])(.+?)\4)?(?:\s+hash\s+(['"])(.+?)\6)?/);
|
||||
if (!match) {
|
||||
|
|
@ -166,6 +298,35 @@ function parseTypeExpr(expr) {
|
|||
if (primitives.includes(expr)) {
|
||||
return { type: 'primitive', name: expr };
|
||||
}
|
||||
// Set<T>
|
||||
if (expr.startsWith('Set<') && expr.endsWith('>')) {
|
||||
const inner = expr.substring(4, expr.length - 1).trim();
|
||||
return { type: 'set', elementType: parseTypeExpr(inner) };
|
||||
}
|
||||
// Map<K, V>
|
||||
if (expr.startsWith('Map<') && expr.endsWith('>')) {
|
||||
const inner = expr.substring(4, expr.length - 1);
|
||||
// Find the first-level comma (not inside nested generics)
|
||||
let depth = 0;
|
||||
let commaIdx = -1;
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
if (inner[i] === '<')
|
||||
depth++;
|
||||
else if (inner[i] === '>')
|
||||
depth--;
|
||||
else if (inner[i] === ',' && depth === 0) {
|
||||
commaIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (commaIdx !== -1) {
|
||||
const keyStr = inner.substring(0, commaIdx).trim();
|
||||
const valStr = inner.substring(commaIdx + 1).trim();
|
||||
return { type: 'map', keyType: parseTypeExpr(keyStr), valueType: parseTypeExpr(valStr) };
|
||||
}
|
||||
// Fallback: treat as ident if malformed
|
||||
return { type: 'ident', name: expr };
|
||||
}
|
||||
// List type: [T]
|
||||
if (expr.startsWith('[') && expr.endsWith(']')) {
|
||||
const elementType = parseTypeExpr(expr.substring(1, expr.length - 1));
|
||||
|
|
@ -294,10 +455,112 @@ function parseSource(line) {
|
|||
ref: ref || undefined,
|
||||
};
|
||||
}
|
||||
function parseBOMBlock(lines, line) {
|
||||
// This is a simplified parser - in a real implementation, we'd parse the full block
|
||||
return {
|
||||
type: 'bom',
|
||||
};
|
||||
function parseStateBlockMultiline(lines, startIndex, startBraceCount) {
|
||||
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
|
||||
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
const inner = innerLines.join('; ');
|
||||
return parseStateBlock(inner);
|
||||
}
|
||||
function parseInitBlockMultiline(lines, startIndex, startBraceCount) {
|
||||
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
|
||||
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
const inner = innerLines.join('; ');
|
||||
return parseInitBlock(inner);
|
||||
}
|
||||
function parseActionBlockMultiline(lines, startIndex, startBraceCount) {
|
||||
const firstLine = lines[startIndex].trim();
|
||||
const header = parseActionHeader(firstLine);
|
||||
if (!header)
|
||||
return null;
|
||||
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
|
||||
const bodyLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
const statements = [];
|
||||
for (const stmt of bodyLines) {
|
||||
// require expr
|
||||
if (stmt.startsWith('require ')) {
|
||||
statements.push({ type: 'require', expr: stmt.substring(8).trim() });
|
||||
continue;
|
||||
}
|
||||
// return expr
|
||||
if (stmt.startsWith('return ')) {
|
||||
statements.push({ type: 'return', expr: stmt.substring(7).trim() });
|
||||
continue;
|
||||
}
|
||||
// emit Event { fields }
|
||||
const emitMatch = stmt.match(/^emit\s+(\w+)\s*\{\s*(.*)\s*\}$/);
|
||||
if (emitMatch) {
|
||||
const eventName = emitMatch[1];
|
||||
const fieldsStr = emitMatch[2];
|
||||
const fields = [];
|
||||
if (fieldsStr) {
|
||||
const fieldPairs = fieldsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const fp of fieldPairs) {
|
||||
const colonIdx = fp.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
fields.push({
|
||||
name: fp.substring(0, colonIdx).trim(),
|
||||
value: fp.substring(colonIdx + 1).trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
statements.push({ type: 'emit', event: eventName, fields });
|
||||
continue;
|
||||
}
|
||||
// let name := expr
|
||||
const letMatch = stmt.match(/^let\s+(\w+)\s*:=\s*(.+)$/);
|
||||
if (letMatch) {
|
||||
statements.push({ type: 'let', name: letMatch[1], expr: letMatch[2].trim() });
|
||||
continue;
|
||||
}
|
||||
// assignment: target := expr
|
||||
const assignMatch = stmt.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/);
|
||||
if (assignMatch) {
|
||||
statements.push({ type: 'assign', target: assignMatch[1], expr: assignMatch[2].trim() });
|
||||
}
|
||||
}
|
||||
return { ...header, statements };
|
||||
}
|
||||
function parseEventBlockMultiline(lines, startIndex, startBraceCount) {
|
||||
const firstLine = lines[startIndex].trim();
|
||||
const header = parseEvent(firstLine);
|
||||
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
|
||||
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
const fields = [];
|
||||
for (const line of innerLines) {
|
||||
const match = line.match(/^(\w+)\s*:\s*(.+?),?$/);
|
||||
if (match) {
|
||||
fields.push({ name: match[1], type: parseTypeExpr(match[2].replace(/,$/, '').trim()) });
|
||||
}
|
||||
}
|
||||
return { ...header, fields };
|
||||
}
|
||||
function parseInterfaceBlockMultiline(lines, startIndex, startBraceCount) {
|
||||
const firstLine = lines[startIndex].trim();
|
||||
const header = parseInterface(firstLine);
|
||||
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
|
||||
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
const methods = [];
|
||||
for (const line of innerLines) {
|
||||
// name(params) -> returnType
|
||||
const match = line.match(/^(\w+)\s*\(([^)]*)\)\s*-\u003e\s*(.+?),?$/);
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
const paramsStr = match[2];
|
||||
const returnTypeStr = match[3].replace(/,$/, '').trim();
|
||||
const params = [];
|
||||
if (paramsStr.trim()) {
|
||||
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const p of paramPairs) {
|
||||
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/);
|
||||
if (pMatch) {
|
||||
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) });
|
||||
}
|
||||
}
|
||||
}
|
||||
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
|
||||
}
|
||||
}
|
||||
return { ...header, methods };
|
||||
}
|
||||
//# sourceMappingURL=parser.js.map
|
||||
2
compiler/dist/parser.js.map
vendored
2
compiler/dist/parser.js.map
vendored
File diff suppressed because one or more lines are too long
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":[[":src/__tests__/compiler.test.ts",{"duration":7.601268000000005,"failed":false}]]}
|
||||
{"version":"2.1.9","results":[[":__tests__/parser.test.ts",{"duration":38.49205900000004,"failed":false}],[":src/__tests__/compiler.test.ts",{"duration":10.381641000000002,"failed":false}]]}
|
||||
272
compiler/specs/forgejo-issue-workflow.speckdl
Normal file
272
compiler/specs/forgejo-issue-workflow.speckdl
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// forgejo-issue-workflow.speckdl
|
||||
// Formal specification: A Forgejo issue is a prompt wrapped in a state machine.
|
||||
//
|
||||
// Purpose: Make the informal prompt → formal. Track who can do what, when, and
|
||||
// what evidence is required to move forward. Every transition is a decision.
|
||||
|
||||
speck IssueWorkflow {
|
||||
author: "Scott Scoble" <"scott@scoble.me">
|
||||
source: conversation ref "discord-2026-05-03"
|
||||
review: hybrid
|
||||
|
||||
// ─── Provenance ───────────────────────────────────────────
|
||||
provenance {
|
||||
design_decision: "Issues as prompts + state machines"
|
||||
parent_spec: "AGENTS.md Invariants"
|
||||
external_doc: "https://os.scoble.me/forgejo/marcus"
|
||||
}
|
||||
|
||||
// ─── Derivation & Satisfaction ────────────────────────────
|
||||
derives from AGENTS-INVARIANTS via "formalization of operational rules"
|
||||
satisfies AGENTS-REQ-01 clause "1.1"
|
||||
satisfies AGENTS-REQ-02 clause "1.3"
|
||||
satisfies AGENTS-REQ-03 clause "1.4"
|
||||
satisfies AGENTS-REQ-04 clause "1.5"
|
||||
satisfies AGENTS-REQ-05 clause "1.6"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────
|
||||
interface IssueState {
|
||||
Open, Grooming, Groomed, Assigned, InProgress, InReview, Merged, Blocked, Closed
|
||||
}
|
||||
|
||||
// ─── Events ───────────────────────────────────────────────
|
||||
event StateTransition {
|
||||
from: IssueState,
|
||||
to: IssueState,
|
||||
actor: String,
|
||||
reason: String,
|
||||
evidence: String,
|
||||
at: String
|
||||
}
|
||||
|
||||
event EvidenceItem {
|
||||
kind: String,
|
||||
ref: String,
|
||||
verifiedBy: String
|
||||
}
|
||||
|
||||
// ─── Input / Output ────────────────────────────────────────
|
||||
input: {
|
||||
prompt: String,
|
||||
creator: String,
|
||||
repo: String,
|
||||
labels: [String],
|
||||
assignee: String,
|
||||
createdAt: String
|
||||
}
|
||||
|
||||
output: {
|
||||
state: IssueState,
|
||||
stateAt: String,
|
||||
decisionLog: [StateTransition],
|
||||
blockedBy: [String],
|
||||
evidence: [EvidenceItem]
|
||||
}
|
||||
|
||||
// ─── State Variables ──────────────────────────────────────
|
||||
state: {
|
||||
status: IssueState,
|
||||
assignee: String,
|
||||
repo: String,
|
||||
decisionCount: Nat,
|
||||
lastActionBy: String
|
||||
}
|
||||
|
||||
init: {
|
||||
status := Open,
|
||||
assignee := "",
|
||||
repo := "",
|
||||
decisionCount := 0,
|
||||
lastActionBy := ""
|
||||
}
|
||||
|
||||
// ─── Actions (State Transitions) ──────────────────────────
|
||||
action create(creator: String, prompt: String, repo: String, labels: [String]) {
|
||||
require status == Open
|
||||
assign status = Open
|
||||
assign decisionCount = 0
|
||||
assign lastActionBy = creator
|
||||
emit StateTransition {
|
||||
from: Open,
|
||||
to: Open,
|
||||
actor: creator,
|
||||
reason: prompt,
|
||||
evidence: repo,
|
||||
at: now()
|
||||
}
|
||||
}
|
||||
|
||||
action groom(assignee: String, refinedPrompt: String, acceptanceCriteria: [String]) {
|
||||
require status == Open || status == Grooming
|
||||
require len(acceptanceCriteria) > 0
|
||||
assign status = Groomed
|
||||
assign lastActionBy = assignee
|
||||
assign decisionCount = decisionCount + 1
|
||||
emit StateTransition {
|
||||
from: status,
|
||||
to: Groomed,
|
||||
actor: assignee,
|
||||
reason: refinedPrompt,
|
||||
evidence: join(acceptanceCriteria, ";"),
|
||||
at: now()
|
||||
}
|
||||
}
|
||||
|
||||
action assign(assignee: String, agent: String) {
|
||||
require status == Groomed || status == Open
|
||||
require assignee == "sscoble"
|
||||
require repo != "marcus/coach"
|
||||
assign status = Assigned
|
||||
assign lastActionBy = agent
|
||||
assign decisionCount = decisionCount + 1
|
||||
emit StateTransition {
|
||||
from: status,
|
||||
to: Assigned,
|
||||
actor: agent,
|
||||
reason: "delegated to " + agent,
|
||||
evidence: "",
|
||||
at: now()
|
||||
}
|
||||
}
|
||||
|
||||
action startWork(agent: String) {
|
||||
require status == Assigned
|
||||
assign status = InProgress
|
||||
assign lastActionBy = agent
|
||||
assign decisionCount = decisionCount + 1
|
||||
emit StateTransition {
|
||||
from: Assigned,
|
||||
to: InProgress,
|
||||
actor: agent,
|
||||
reason: "beginning implementation",
|
||||
evidence: "",
|
||||
at: now()
|
||||
}
|
||||
}
|
||||
|
||||
action submitPR(agent: String, prNumber: Nat, commitHash: String) {
|
||||
require status == InProgress
|
||||
assign status = InReview
|
||||
assign lastActionBy = agent
|
||||
assign decisionCount = decisionCount + 1
|
||||
emit StateTransition {
|
||||
from: InProgress,
|
||||
to: InReview,
|
||||
actor: agent,
|
||||
reason: "PR submitted",
|
||||
evidence: commitHash,
|
||||
at: now()
|
||||
}
|
||||
emit EvidenceItem {
|
||||
kind: "pr",
|
||||
ref: prNumber,
|
||||
verifiedBy: agent
|
||||
}
|
||||
}
|
||||
|
||||
action merge(prNumber: Nat, merger: String, commitHash: String) {
|
||||
require status == InReview
|
||||
assign status = Merged
|
||||
assign lastActionBy = merger
|
||||
assign decisionCount = decisionCount + 1
|
||||
emit StateTransition {
|
||||
from: InReview,
|
||||
to: Merged,
|
||||
actor: merger,
|
||||
reason: "PR merged",
|
||||
evidence: commitHash,
|
||||
at: now()
|
||||
}
|
||||
emit EvidenceItem {
|
||||
kind: "commit",
|
||||
ref: commitHash,
|
||||
verifiedBy: merger
|
||||
}
|
||||
}
|
||||
|
||||
action close(closer: String, resolution: String) {
|
||||
require status == Merged || status == Open || status == Groomed
|
||||
require closer == assignee || (closer == "sscoble" && assignee == "")
|
||||
assign status = Closed
|
||||
assign lastActionBy = closer
|
||||
assign decisionCount = decisionCount + 1
|
||||
emit StateTransition {
|
||||
from: status,
|
||||
to: Closed,
|
||||
actor: closer,
|
||||
reason: resolution,
|
||||
evidence: "",
|
||||
at: now()
|
||||
}
|
||||
}
|
||||
|
||||
action reopen(opener: String, reason: String) {
|
||||
require status == Closed || status == Blocked
|
||||
assign status = Open
|
||||
assign lastActionBy = opener
|
||||
assign decisionCount = decisionCount + 1
|
||||
emit StateTransition {
|
||||
from: status,
|
||||
to: Open,
|
||||
actor: opener,
|
||||
reason: reason,
|
||||
evidence: "",
|
||||
at: now()
|
||||
}
|
||||
}
|
||||
|
||||
action block(agent: String, blocker: String) {
|
||||
require status != Blocked && status != Closed
|
||||
assign status = Blocked
|
||||
assign lastActionBy = agent
|
||||
assign decisionCount = decisionCount + 1
|
||||
emit StateTransition {
|
||||
from: status,
|
||||
to: Blocked,
|
||||
actor: agent,
|
||||
reason: "blocked by: " + blocker,
|
||||
evidence: "",
|
||||
at: now()
|
||||
}
|
||||
}
|
||||
|
||||
action unblock(agent: String, resolution: String) {
|
||||
require status == Blocked
|
||||
assign status = Open
|
||||
assign lastActionBy = agent
|
||||
assign decisionCount = decisionCount + 1
|
||||
emit StateTransition {
|
||||
from: Blocked,
|
||||
to: Open,
|
||||
actor: agent,
|
||||
reason: resolution,
|
||||
evidence: "",
|
||||
at: now()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Constraints ──────────────────────────────────────────
|
||||
constraint: len(labels) > 0
|
||||
constraint: forall l in labels: l in {"bug", "feature", "improvement", "question", "blocked"}
|
||||
constraint: implies(now() - lastIssueCreatedAt(agent) < 3600, issuesCreatedInLastHour(agent) <= 3)
|
||||
constraint: implies(now() - lastCommentAt(agent) < 30, false)
|
||||
constraint: implies(repo in {"marcus/engineering", "marcus/market", "marcus/content", "marcus/trust"}, assignee == "sscoble")
|
||||
constraint: implies(creator == agent, repo != agentRepo(agent))
|
||||
constraint: decisionCount == len(decisionLog)
|
||||
constraint: implies(status == Closed, len(evidence) > 0 || lastStateBeforeClosed() in {Merged, Blocked})
|
||||
|
||||
// ─── Verification ─────────────────────────────────────────
|
||||
verify: always(implies(status == InProgress, eventually(status == Merged || status == Closed)))
|
||||
verify: always(implies(status == Blocked, eventually(status != Blocked)))
|
||||
verify: always(implies(lastActionBy != "sscoble", status != Assigned))
|
||||
verify: always(implies(decisionCount > 0, forall t in decisionLog: len(t.reason) > 0))
|
||||
verify: always(implies(status == Groomed || status == Assigned || status == InProgress, len(acceptanceCriteria) > 0))
|
||||
|
||||
// ─── BOM ──────────────────────────────────────────────────
|
||||
bom {
|
||||
compiler: "speckl-compile" version "0.3.1"
|
||||
solver: "z3" version "4.12.5"
|
||||
runtime: "OpenClaw" version "2.1.0"
|
||||
license: "MIT"
|
||||
}
|
||||
}
|
||||
292
compiler/src/generators/typescript-state-machine.ts
Normal file
292
compiler/src/generators/typescript-state-machine.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { AST, SpeckNode, MemberNode, StateNode, InitNode, ActionNode, ConstraintNode, VerifyNode, EventNode } from '../parser.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Generate TypeScript state machine runtime from a SpeckDL spec.
|
||||
*
|
||||
* Outputs a self-contained .ts file with:
|
||||
* - Typed states as a const enum
|
||||
* - Event interfaces
|
||||
* - State machine class with guards, assignments, and decision log
|
||||
* - Constraint check stubs
|
||||
* - Verification stubs
|
||||
*/
|
||||
|
||||
export function generateTypeScriptStateMachine(ast: AST, outputDir: string): void {
|
||||
for (const speck of ast.specks) {
|
||||
const code = emitSpeck(speck);
|
||||
const filename = `${speck.name}.ts`;
|
||||
const filepath = path.join(outputDir, filename);
|
||||
fs.writeFileSync(filepath, code);
|
||||
console.log(`Generated TypeScript state machine: ${filepath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function emitSpeck(speck: SpeckNode): string {
|
||||
const deduped = new Map<string, MemberNode>();
|
||||
for (const m of speck.members) {
|
||||
const key = memberKey(m);
|
||||
const existing = deduped.get(key);
|
||||
if (!existing || isEmptyStub(existing)) {
|
||||
deduped.set(key, m);
|
||||
}
|
||||
}
|
||||
const members = Array.from(deduped.values());
|
||||
|
||||
const stateNode = members.find(m => m.type === 'state') as StateNode | undefined;
|
||||
const initNode = members.find(m => m.type === 'init') as InitNode | undefined;
|
||||
const actions = members.filter(m => m.type === 'action') as ActionNode[];
|
||||
const events = members.filter(m => m.type === 'event') as EventNode[];
|
||||
const constraints = members.filter(m => m.type === 'constraint') as ConstraintNode[];
|
||||
const verifies = members.filter(m => m.type === 'verify') as VerifyNode[];
|
||||
|
||||
// Extract state enum values from an interface named *State if present
|
||||
const stateInterface = members.find(m => m.type === 'interface' && m.name.endsWith('State')) as any;
|
||||
let stateValues: string[] = [];
|
||||
if (stateInterface?.methods?.length) {
|
||||
stateValues = stateInterface.methods.map((m: any) => m.name);
|
||||
}
|
||||
if (stateValues.length === 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const a of actions) {
|
||||
for (const s of a.statements) {
|
||||
if (s.type === 'require') {
|
||||
const matches = s.expr.match(/\b(Open|Grooming|Groomed|Assigned|InProgress|InReview|Merged|Blocked|Closed)\b/g);
|
||||
if (matches) matches.forEach(m => seen.add(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
stateValues = Array.from(seen).sort();
|
||||
}
|
||||
if (stateValues.length === 0) stateValues = ['Open', 'Closed'];
|
||||
|
||||
const stateEnumName = `${speck.name}State`;
|
||||
const stateEnum = `export const ${stateEnumName} = {\n` +
|
||||
stateValues.map((v: string) => ` ${v}: '${v.toLowerCase()}',`).join('\n') +
|
||||
'\n} as const;\n' +
|
||||
`export type ${stateEnumName} = typeof ${stateEnumName}[keyof typeof ${stateEnumName}];`;
|
||||
|
||||
const eventInterfaces = events.map(e => {
|
||||
const fields = e.fields.map(f => {
|
||||
const name = cleanName(f.name);
|
||||
return ` ${name}: ${tsType(f.type)};`;
|
||||
}).join('\n');
|
||||
return `export interface ${e.name} {\n${fields}\n}`;
|
||||
}).join('\n\n');
|
||||
|
||||
const className = `${speck.name}Machine`;
|
||||
const stateVars = (stateNode?.variables ?? []).map(v => ({
|
||||
name: cleanName(v.name),
|
||||
typeExpr: v.typeExpr,
|
||||
defaultInit: v.defaultInit,
|
||||
}));
|
||||
|
||||
const stateVarNames = new Set(stateVars.map(v => v.name));
|
||||
|
||||
const stateProperties = stateVars.map(v => {
|
||||
return ` ${v.name}: ${tsType(v.typeExpr)};`;
|
||||
}).join('\n');
|
||||
|
||||
const initAssignments = initNode
|
||||
? initNode.assignments.map(a => {
|
||||
const name = cleanName(a.name);
|
||||
const expr = cleanExpr(a.expr);
|
||||
// Replace bare state literals with enum refs in init
|
||||
const enumRef = replaceStateWithEnum(expr, stateEnumName);
|
||||
return ` this.${name} = ${enumRef};`;
|
||||
}).join('\n')
|
||||
: '';
|
||||
|
||||
const actionMethods = actions.map(a => emitAction(a, stateVarNames, speck.name, stateEnumName)).join('\n\n');
|
||||
|
||||
const constraintChecks = constraints.map((c, i) =>
|
||||
` // C${i + 1}: ${c.expr}\n` +
|
||||
` checkConstraint${i + 1}(): boolean {\n` +
|
||||
` // TODO: Evaluate in Z3 or runtime\n` +
|
||||
` // Expression: ${c.expr}\n` +
|
||||
` return true;\n` +
|
||||
` }`
|
||||
).join('\n\n');
|
||||
|
||||
const verifyChecks = verifies.map((v, i) =>
|
||||
` // V${i + 1}: ${v.temporalExpr}\n` +
|
||||
` async verify${i + 1}(): Promise<boolean> {\n` +
|
||||
` // TODO: Model-check or monitor\n` +
|
||||
` // Temporal: ${v.temporalExpr}\n` +
|
||||
` return true;\n` +
|
||||
` }`
|
||||
).join('\n\n');
|
||||
|
||||
const classBody = `
|
||||
export class ${className} {
|
||||
${stateProperties}
|
||||
decisionLog: StateTransition[] = [];
|
||||
evidence: EvidenceItem[] = [];
|
||||
|
||||
constructor() {
|
||||
${initAssignments || ' // No init block defined'}
|
||||
}
|
||||
|
||||
${actionMethods}
|
||||
|
||||
${constraintChecks}
|
||||
|
||||
${verifyChecks}
|
||||
}
|
||||
`;
|
||||
|
||||
const file =
|
||||
`// Auto-generated by speckl-compile from ${speck.name}\n` +
|
||||
`// DO NOT EDIT MANUALLY — regenerate from .speckdl source\n\n` +
|
||||
`${stateEnum}\n\n` +
|
||||
`${eventInterfaces}\n\n` +
|
||||
`${classBody}`;
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
function emitAction(action: ActionNode, stateVarNames: Set<string>, speckName: string, stateEnumName: string): string {
|
||||
// Collect local parameter names so we DON'T prefix them with this.
|
||||
const localNames = new Set(action.params.map(p => p.name));
|
||||
|
||||
const params = action.params
|
||||
.map(p => `${p.name}: ${tsType(p.type)}`)
|
||||
.join(', ');
|
||||
|
||||
const requires = action.statements
|
||||
.filter(s => s.type === 'require')
|
||||
.map(s => {
|
||||
const expr = rewriteExpr(s.expr, stateVarNames, localNames, stateEnumName);
|
||||
return ` if (!(${expr})) throw new Error('Guard failed: ${expr.replace(/'/g, "\\'")}');`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const assigns = action.statements
|
||||
.filter(s => s.type === 'assign')
|
||||
.map(s => {
|
||||
const target = cleanName(s.target);
|
||||
const expr = rewriteExpr(s.expr, stateVarNames, localNames, stateEnumName);
|
||||
return ` this.${target} = ${expr};`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const emits = action.statements
|
||||
.filter(s => s.type === 'emit')
|
||||
.map(s => {
|
||||
const fields = s.fields
|
||||
.map(f => {
|
||||
const name = cleanName(f.name);
|
||||
const val = rewriteExpr(f.value, stateVarNames, localNames, stateEnumName);
|
||||
return ` ${name}: ${val}`;
|
||||
})
|
||||
.join(',\n');
|
||||
return ` this.decisionLog.push({\n${fields}\n } as ${s.event});`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const bodyParts = [requires, assigns, emits].filter(Boolean).join('\n');
|
||||
return ` ${action.name}(${params}) {\n${bodyParts}\n }`;
|
||||
}
|
||||
|
||||
// Rewrite SpeckDL expressions to valid TypeScript
|
||||
// Only prefix identifiers with `this.` if they are state variables AND not local params
|
||||
function rewriteExpr(expr: string, stateVarNames: Set<string>, localNames: Set<string>, stateEnumName: string): string {
|
||||
function shouldPrefix(id: string): boolean {
|
||||
return stateVarNames.has(id) && !localNames.has(id);
|
||||
}
|
||||
|
||||
// Step 1: Replace function calls and special syntax
|
||||
let ts = expr
|
||||
.replace(/\bnow\(\)/g, 'Date.now()')
|
||||
.replace(/\blen\(([^)]+)\)/g, '$1.length')
|
||||
.replace(/\bjoin\(([^,]+),\s*([^)]+)\)/g, '$1.join($2)')
|
||||
.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
|
||||
ts = ts.replace(/\b(Open|Grooming|Groomed|Assigned|InProgress|InReview|Merged|Blocked|Closed)\b/g, `${stateEnumName}.$1`);
|
||||
|
||||
// Step 3: Replace identifiers that are state variables but not local params
|
||||
const tokens = ts.split(/([\s\(\)\[\]{}+\-*/=<>!&|.,;:'"`]+)/);
|
||||
const result = tokens.map(tok => {
|
||||
if (/^[a-zA-Z_]\w*\.\w+$/.test(tok)) return tok; // already has dot (enum ref)
|
||||
if (/^(true|false|null|undefined|\d+|\w+\.\w+)$/.test(tok)) return tok;
|
||||
if (/^[a-zA-Z_]\w*$/.test(tok) && shouldPrefix(tok)) {
|
||||
return `this.${tok}`;
|
||||
}
|
||||
return tok;
|
||||
}).join('');
|
||||
|
||||
// Step 4: Replace comparison operators
|
||||
return result
|
||||
.replace(/\b==\b/g, '===')
|
||||
.replace(/\b!=\b/g, '!==')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Helpers ------------------------------------------------------------------
|
||||
|
||||
function memberKey(m: MemberNode): string {
|
||||
if (m.type === 'action') return `action:${m.name}`;
|
||||
if (m.type === 'event') return `event:${m.name}`;
|
||||
if (m.type === 'interface') return `interface:${m.name}`;
|
||||
if (m.type === 'state') return 'state';
|
||||
if (m.type === 'init') return 'init';
|
||||
if (m.type === 'provenance') return 'provenance';
|
||||
if (m.type === 'bom') return 'bom';
|
||||
if (m.type === 'input') return 'input';
|
||||
if (m.type === 'output') return 'output';
|
||||
return m.type;
|
||||
}
|
||||
|
||||
function isEmptyStub(m: MemberNode): boolean {
|
||||
if (m.type === 'state') return m.variables.length === 0;
|
||||
if (m.type === 'init') return m.assignments.length === 0;
|
||||
if (m.type === 'action') return m.statements.length === 0;
|
||||
if (m.type === 'event') return m.fields.length === 0;
|
||||
if (m.type === 'interface') return m.methods.length === 0;
|
||||
if (m.type === 'provenance') return m.clauses.length === 0;
|
||||
if (m.type === 'bom') return !m.compiler && !m.solver && !m.runtime;
|
||||
return false;
|
||||
}
|
||||
|
||||
function cleanName(s: string): string {
|
||||
return s.replace(/,$/, '').trim();
|
||||
}
|
||||
|
||||
function replaceStateWithEnum(expr: string, enumName: string): string {
|
||||
return expr.replace(/\b(Open|Grooming|Groomed|Assigned|InProgress|InReview|Merged|Blocked|Closed)\b/g, `${enumName}.$1`);
|
||||
}
|
||||
|
||||
function cleanExpr(s: string): string {
|
||||
return s.replace(/,$/, '').trim();
|
||||
}
|
||||
|
||||
function tsType(t: any): string {
|
||||
if (!t) return 'unknown';
|
||||
if (t.type === 'primitive') {
|
||||
const map: Record<string, string> = {
|
||||
Nat: 'number', Int: 'number', Real: 'number',
|
||||
Bool: 'boolean', String: 'string', Bytes: 'Uint8Array'
|
||||
};
|
||||
return map[t.name] || t.name.toLowerCase();
|
||||
}
|
||||
if (t.type === 'list') return `${tsType(t.elementType)}[]`;
|
||||
if (t.type === 'set') return `Set<${tsType(t.elementType)}>`;
|
||||
if (t.type === 'map') return `Map<${tsType(t.keyType)}, ${tsType(t.valueType)}>`;
|
||||
if (t.type === 'record') {
|
||||
const fields = t.fields.map((f: any) => {
|
||||
const name = cleanName(f.name);
|
||||
return `${name}: ${tsType(f.type)}`;
|
||||
}).join('; ');
|
||||
return `{ ${fields} }`;
|
||||
}
|
||||
if (t.type === 'ident') {
|
||||
const name = cleanName(t.name);
|
||||
// Map known identifier types
|
||||
if (name === 'IssueState') return 'IssueWorkflowState';
|
||||
if (name === 'Nat') return 'number';
|
||||
return name;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
473
compiler/src/generators/wasm.ts
Normal file
473
compiler/src/generators/wasm.ts
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import { AST, SpeckNode, StateNode, InitNode, ActionNode, EventNode } from '../parser.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Generate WebAssembly Text (WAT) from a SpeckDL state machine spec.
|
||||
*
|
||||
* Emits a .wat file per speck with:
|
||||
* - Linear memory for scalar state (Nat/Int/Real/Bool)
|
||||
* - Imported host functions for Map<K,V> and Set<T>
|
||||
* - Exported `init()` — calls host to init Maps/Sets + sets scalars
|
||||
* - Exported action functions (one per action)
|
||||
* - Exported `getState()` — serializes scalars into memory
|
||||
* - Exported `checkConstraints()` — runtime guard stubs
|
||||
*
|
||||
* Compile to .wasm with: wat2wasm <file>.wat -o <file>.wasm
|
||||
*
|
||||
* Host runtime (JS): see `speckl/runtime/wasm-host.js`
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host imports required by generated modules
|
||||
// ---------------------------------------------------------------------------
|
||||
const HOST_IMPORTS = [
|
||||
'(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)))',
|
||||
];
|
||||
|
||||
export function generateWASM(ast: AST, outputDir: string): void {
|
||||
for (const speck of ast.specks) {
|
||||
const hasStateMachine = speck.members.some(m =>
|
||||
m.type === 'state' || m.type === 'init' || m.type === 'action'
|
||||
);
|
||||
if (!hasStateMachine) continue;
|
||||
|
||||
const wat = emitSpeckWAT(speck);
|
||||
const filename = `${speck.name}.wat`;
|
||||
const filepath = path.join(outputDir, filename);
|
||||
fs.writeFileSync(filepath, wat);
|
||||
console.log(`Generated WAT: ${filepath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function emitSpeckWAT(speck: SpeckNode): string {
|
||||
const stateNode = speck.members.find(m => m.type === 'state') as StateNode | undefined;
|
||||
const initNode = speck.members.find(m => m.type === 'init') as InitNode | undefined;
|
||||
const actions = speck.members.filter(m => m.type === 'action') as ActionNode[];
|
||||
const events = speck.members.filter(m => m.type === 'event') as EventNode[];
|
||||
|
||||
const stateVars = stateNode?.variables ?? [];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Memory layout for scalar state variables
|
||||
// -------------------------------------------------------------------------
|
||||
// Each scalar gets 8 bytes (i64/f64 slot). Map/Set handles are i32 refs
|
||||
// stored in globals.
|
||||
//
|
||||
// Layout: [0..8) var0, [8..16) var1, ... then string pool for getState()
|
||||
//
|
||||
const scalarVars = stateVars.filter(v => isScalarType(v.typeExpr));
|
||||
const refVars = stateVars.filter(v => !isScalarType(v.typeExpr));
|
||||
|
||||
const memSize = Math.max(1, Math.ceil((scalarVars.length * 8 + 1024) / 65536));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Globals: one per state variable
|
||||
// -------------------------------------------------------------------------
|
||||
const globals = stateVars.map((v, i) => {
|
||||
if (isScalarType(v.typeExpr)) {
|
||||
const wasmType = wasmTypeOf(v.typeExpr);
|
||||
const initVal = initValueFor(v.name, v.typeExpr, v.defaultInit, initNode);
|
||||
return `(global $${v.name} (mut ${wasmType}) (${wasmType}.const ${initVal}))`;
|
||||
} else {
|
||||
// Map or Set — store as i32 handle (0 = uninitialized)
|
||||
return `(global $${v.name} (mut i32) (i32.const 0))`;
|
||||
}
|
||||
}).join('\n');
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. init() export
|
||||
// -------------------------------------------------------------------------
|
||||
const initBody = emitInitBody(stateVars, initNode);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Action exports
|
||||
// -------------------------------------------------------------------------
|
||||
const actionFuncs = actions.map(a => emitActionWAT(a, stateVars)).join('\n\n');
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. getState() export — write scalars to memory, return ptr+len
|
||||
// -------------------------------------------------------------------------
|
||||
const getStateBody = emitGetState(scalarVars);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. checkConstraints() stub
|
||||
// -------------------------------------------------------------------------
|
||||
const checkConstraints = `
|
||||
;; checkConstraints — runtime constraint evaluation stub
|
||||
(func $checkConstraints (export "checkConstraints") (result i32)
|
||||
;; TODO: evaluate constraint expressions at runtime
|
||||
(i32.const 1)
|
||||
)`;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. Assemble module
|
||||
// -------------------------------------------------------------------------
|
||||
const module =
|
||||
`;; Auto-generated by speckl-compile from ${speck.name}\n` +
|
||||
`;; Compile: wat2wasm ${speck.name}.wat -o ${speck.name}.wasm\n\n` +
|
||||
`(module\n` +
|
||||
` ;; Host imports for Map/Set/alloc\n` +
|
||||
` ${HOST_IMPORTS.join('\n ')}\n\n` +
|
||||
` ;; Linear memory (exported so host can read state)\n` +
|
||||
` (memory (export "memory") ${memSize})\n\n` +
|
||||
` ;; State globals\n` +
|
||||
` ${globals}\n\n` +
|
||||
` ;; ---- init ----\n` +
|
||||
` (func $init (export "init")\n` +
|
||||
` ${initBody}\n` +
|
||||
` )\n\n` +
|
||||
` ;; ---- Actions ----\n` +
|
||||
` ${actionFuncs}\n\n` +
|
||||
` ;; ---- getState ----\n` +
|
||||
` (func $getState (export "getState") (result i32 i32)\n` +
|
||||
` ${getStateBody}\n` +
|
||||
` )\n\n` +
|
||||
` ${checkConstraints}\n` +
|
||||
`)`;
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// init() body generation
|
||||
// =============================================================================
|
||||
function emitInitBody(
|
||||
stateVars: StateNode['variables'],
|
||||
initNode: InitNode | undefined
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const v of stateVars) {
|
||||
if (isScalarType(v.typeExpr)) {
|
||||
const val = initValueFor(v.name, v.typeExpr, v.defaultInit, initNode);
|
||||
const wasmType = wasmTypeOf(v.typeExpr);
|
||||
lines.push(`(${wasmType}.const ${val})`);
|
||||
lines.push(`(global.set $${v.name})`);
|
||||
} else if (isMapType(v.typeExpr)) {
|
||||
lines.push(`(call $map_create)`);
|
||||
lines.push(`(global.set $${v.name})`);
|
||||
} else if (isSetType(v.typeExpr)) {
|
||||
lines.push(`(call $set_create)`);
|
||||
lines.push(`(global.set $${v.name})`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n ');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Action function generation
|
||||
// =============================================================================
|
||||
function emitActionWAT(action: ActionNode, stateVars: StateNode['variables']): string {
|
||||
const scalarStateNames = new Set(
|
||||
stateVars.filter(v => isScalarType(v.typeExpr)).map(v => v.name)
|
||||
);
|
||||
const mapStateNames = new Set(
|
||||
stateVars.filter(v => isMapType(v.typeExpr)).map(v => v.name)
|
||||
);
|
||||
const setStateNames = new Set(
|
||||
stateVars.filter(v => isSetType(v.typeExpr)).map(v => v.name)
|
||||
);
|
||||
|
||||
// Params: SpeckDL Nat/Int → i32, Real → f64, Bool → i32
|
||||
const params = action.params.map(p => {
|
||||
const t = wasmTypeOf(p.type);
|
||||
return `(param $${p.name} ${t})`;
|
||||
}).join(' ');
|
||||
|
||||
const localLines: string[] = [];
|
||||
const bodyLines: string[] = [];
|
||||
|
||||
for (const stmt of action.statements) {
|
||||
switch (stmt.type) {
|
||||
case 'require': {
|
||||
// Emit guard: if !condition → return 0 (failure indicator)
|
||||
// For now, require guards that reference only scalar state variables
|
||||
// and simple comparisons are emitted. Complex ones trap/return 0.
|
||||
const guard = emitGuardExpr(stmt.expr, scalarStateNames, mapStateNames, setStateNames);
|
||||
bodyLines.push(`;; require: ${stmt.expr}`);
|
||||
bodyLines.push(guard);
|
||||
break;
|
||||
}
|
||||
case 'assign': {
|
||||
bodyLines.push(`;; ${stmt.target} := ${stmt.expr}`);
|
||||
bodyLines.push(emitAssignment(stmt.target, stmt.expr, scalarStateNames, mapStateNames));
|
||||
break;
|
||||
}
|
||||
case 'let': {
|
||||
// let name := expr — allocate local, compute expr, store
|
||||
const wasmT = inferExprType(stmt.expr, scalarStateNames);
|
||||
localLines.push(`(local $${stmt.name} ${wasmT})`);
|
||||
bodyLines.push(emitExpr(stmt.expr, scalarStateNames));
|
||||
bodyLines.push(`(local.set $${stmt.name})`);
|
||||
break;
|
||||
}
|
||||
case 'emit': {
|
||||
// emit → decision log push (host call stub for now)
|
||||
bodyLines.push(`;; emit ${stmt.event} { ... }`);
|
||||
// TODO: structured emit via host
|
||||
break;
|
||||
}
|
||||
case 'return': {
|
||||
bodyLines.push(emitExpr(stmt.expr, scalarStateNames));
|
||||
bodyLines.push(`(return)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default return: i32.const 1 for success if action returns void
|
||||
const hasReturn = action.statements.some(s => s.type === 'return');
|
||||
if (!hasReturn) {
|
||||
bodyLines.push(`(i32.const 1)`);
|
||||
}
|
||||
|
||||
const locals = localLines.length ? `\n ${localLines.join('\n ')}` : '';
|
||||
|
||||
return ` ;; action: ${action.name}\n` +
|
||||
` (func $${action.name} (export "${action.name}") ${params} (result i32)\n` +
|
||||
` ${bodyLines.join('\n ')}\n` +
|
||||
` )`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Expression / guard emission (simplified — covers common scalar patterns)
|
||||
// =============================================================================
|
||||
function emitGuardExpr(
|
||||
expr: string,
|
||||
scalars: Set<string>,
|
||||
maps: Set<string>,
|
||||
sets: Set<string>
|
||||
): string {
|
||||
// Very simple pattern: "x >= 0", "x == true", "x != y", etc.
|
||||
const m = expr.match(/^\s*(\w+)\s*(>=|<=|>|<|==|!=)\s*(.+?)\s*$/);
|
||||
if (!m) {
|
||||
// Can't compile guard — return 1 (pass) with comment
|
||||
return `;; Complex guard not compiled: ${expr}\n (i32.const 1)`;
|
||||
}
|
||||
|
||||
const [, left, op, rightRaw] = m;
|
||||
const right = rightRaw.trim();
|
||||
|
||||
if (!scalars.has(left)) {
|
||||
return `;; Guard on non-scalar not compiled: ${expr}\n (i32.const 1)`;
|
||||
}
|
||||
|
||||
const lhs = `(global.get $${left})`;
|
||||
|
||||
// Parse right side
|
||||
let rhs: string;
|
||||
const rightNum = parseFloat(right);
|
||||
if (!isNaN(rightNum)) {
|
||||
rhs = `(i32.const ${rightNum})`;
|
||||
} else if (right === 'true') {
|
||||
rhs = `(i32.const 1)`;
|
||||
} else if (right === 'false') {
|
||||
rhs = `(i32.const 0)`;
|
||||
} else if (scalars.has(right)) {
|
||||
rhs = `(global.get $${right})`;
|
||||
} else {
|
||||
return `;; Complex right-hand side not compiled: ${expr}\n (i32.const 1)`;
|
||||
}
|
||||
|
||||
const comparison = (() => {
|
||||
switch (op) {
|
||||
case '>=': return `(i32.ge_s ${lhs} ${rhs})`;
|
||||
case '<=': return `(i32.le_s ${lhs} ${rhs})`;
|
||||
case '>': return `(i32.gt_s ${lhs} ${rhs})`;
|
||||
case '<': return `(i32.lt_s ${lhs} ${rhs})`;
|
||||
case '==': return `(i32.eq ${lhs} ${rhs})`;
|
||||
case '!=': return `(i32.ne ${lhs} ${rhs})`;
|
||||
default: return `(i32.const 1)`;
|
||||
}
|
||||
})();
|
||||
|
||||
// If guard fails, return 0
|
||||
return `${comparison}\n (if (then (return (i32.const 0))))`;
|
||||
}
|
||||
|
||||
function emitExpr(expr: string, scalars: Set<string>): string {
|
||||
// Handle: nextId + 1, nextId, numeric literals, etc.
|
||||
expr = expr.trim();
|
||||
|
||||
// Simple numeric literal
|
||||
const num = parseFloat(expr);
|
||||
if (!isNaN(num) && !expr.match(/\w/)) {
|
||||
return `(i32.const ${num})`;
|
||||
}
|
||||
|
||||
// Simple variable reference
|
||||
if (/^\w+$/.test(expr) && scalars.has(expr)) {
|
||||
return `(global.get $${expr})`;
|
||||
}
|
||||
|
||||
// Binary op: left + right, left - right, left * right
|
||||
const binOp = expr.match(/^(\w+)\s*([+\-*/])\s*(.+)$/);
|
||||
if (binOp) {
|
||||
const [, left, op, right] = binOp;
|
||||
const lhs = scalars.has(left) ? `(global.get $${left})` : `(i32.const ${parseFloat(left)})`;
|
||||
const rhsNum = parseFloat(right.trim());
|
||||
const rhs = isNaN(rhsNum)
|
||||
? (scalars.has(right.trim()) ? `(global.get $${right.trim()})` : `(i32.const 0) ;; uncompiled`)
|
||||
: `(i32.const ${rhsNum})`;
|
||||
switch (op) {
|
||||
case '+': return `(i32.add ${lhs} ${rhs})`;
|
||||
case '-': return `(i32.sub ${lhs} ${rhs})`;
|
||||
case '*': return `(i32.mul ${lhs} ${rhs})`;
|
||||
default: return `(i32.const 0) ;; uncompiled op ${op}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `(i32.const 0) ;; uncompiled expr: ${expr}`;
|
||||
}
|
||||
|
||||
function inferExprType(expr: string, scalars: Set<string>): string {
|
||||
// Default to i32; we could infer from variable types but that's future work
|
||||
return 'i32';
|
||||
}
|
||||
|
||||
function emitAssignment(
|
||||
target: string,
|
||||
expr: string,
|
||||
scalars: Set<string>,
|
||||
maps: Set<string>
|
||||
): string {
|
||||
// Scalar assignment: this.x = expr
|
||||
if (scalars.has(target)) {
|
||||
return `${emitExpr(expr, scalars)}\n (global.set $${target})`;
|
||||
}
|
||||
|
||||
// Map assignment: accounts[id] := expr — host call
|
||||
const mapAssign = target.match(/^(\w+)\[(.+)\]$/);
|
||||
if (mapAssign && maps.has(mapAssign[1])) {
|
||||
const mapName = mapAssign[1];
|
||||
const keyExpr = mapAssign[2];
|
||||
// For now: map_set(map_handle, key_handle, value_handle)
|
||||
// This needs boxing; simplified version just marks as stub
|
||||
return `;; Map assignment not yet compiled: ${target} := ${expr}\n` +
|
||||
` (i32.const 1)`;
|
||||
}
|
||||
|
||||
// Record field assignment: accounts[id].field := expr
|
||||
const recordAssign = target.match(/^(\w+)\[(.+)\]\.(\w+)$/);
|
||||
if (recordAssign && maps.has(recordAssign[1])) {
|
||||
return `;; Record field assignment not yet compiled: ${target} := ${expr}\n` +
|
||||
` (i32.const 1)`;
|
||||
}
|
||||
|
||||
return `;; Unrecognized assignment target: ${target}\n (i32.const 1)`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// getState() — serialize scalar state to memory
|
||||
// =============================================================================
|
||||
function emitGetState(scalarVars: StateNode['variables']): string {
|
||||
const lines: string[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const v of scalarVars) {
|
||||
const t = wasmTypeOf(v.typeExpr);
|
||||
if (t === 'i32') {
|
||||
lines.push(`(i32.const ${offset})`);
|
||||
lines.push(`(global.get $${v.name})`);
|
||||
lines.push(`(i32.store)`);
|
||||
offset += 4;
|
||||
} else if (t === 'f64') {
|
||||
lines.push(`(i32.const ${offset})`);
|
||||
lines.push(`(global.get $${v.name})`);
|
||||
lines.push(`(f64.store)`);
|
||||
offset += 8;
|
||||
} else {
|
||||
// i64
|
||||
lines.push(`(i32.const ${offset})`);
|
||||
lines.push(`(global.get $${v.name})`);
|
||||
lines.push(`(i64.store)`);
|
||||
offset += 8;
|
||||
}
|
||||
}
|
||||
|
||||
// Return (ptr, len)
|
||||
lines.push(`(i32.const 0)`); // ptr = 0
|
||||
lines.push(`(i32.const ${offset})`); // len = total bytes written
|
||||
|
||||
return lines.join('\n ');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Type helpers
|
||||
// =============================================================================
|
||||
function isScalarType(t: any): boolean {
|
||||
if (t.type === 'primitive') {
|
||||
return ['Nat', 'Int', 'Real', 'Bool'].includes(t.name);
|
||||
}
|
||||
if (t.type === 'ident') return true; // opaque ident → i32 handle
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMapType(t: any): boolean {
|
||||
return t.type === 'map';
|
||||
}
|
||||
|
||||
function isSetType(t: any): boolean {
|
||||
return t.type === 'set';
|
||||
}
|
||||
|
||||
function wasmTypeOf(t: any): string {
|
||||
if (t.type === 'primitive') {
|
||||
if (t.name === 'Nat') return 'i32';
|
||||
if (t.name === 'Int') return 'i32';
|
||||
if (t.name === 'Real') return 'f64';
|
||||
if (t.name === 'Bool') return 'i32';
|
||||
if (t.name === 'String') return 'i32'; // ptr
|
||||
if (t.name === 'Bytes') return 'i32'; // ptr
|
||||
return 'i32';
|
||||
}
|
||||
if (t.type === 'ident') return 'i32';
|
||||
if (t.type === 'map') return 'i32'; // handle
|
||||
if (t.type === 'set') return 'i32'; // handle
|
||||
return 'i32';
|
||||
}
|
||||
|
||||
function initValueFor(
|
||||
varName: string,
|
||||
typeExpr: any,
|
||||
defaultInit: string | undefined,
|
||||
initNode: InitNode | undefined
|
||||
): string {
|
||||
// Use initNode assignment if present
|
||||
const initAssign = initNode?.assignments.find(a => a.name === varName);
|
||||
if (initAssign) {
|
||||
const expr = initAssign.expr.trim();
|
||||
if (expr === 'true') return '1';
|
||||
if (expr === 'false') return '0';
|
||||
if (expr === 'Map.empty()') return '0'; // will be replaced by host call
|
||||
if (expr === 'Set.empty()') return '0';
|
||||
const num = parseFloat(expr);
|
||||
if (!isNaN(num)) return String(num);
|
||||
return '0';
|
||||
}
|
||||
|
||||
// Fallback to defaultInit
|
||||
if (defaultInit) {
|
||||
if (defaultInit === 'true') return '1';
|
||||
if (defaultInit === 'false') return '0';
|
||||
const num = parseFloat(defaultInit);
|
||||
if (!isNaN(num)) return String(num);
|
||||
}
|
||||
|
||||
// Default zero
|
||||
if (typeExpr.type === 'primitive' && typeExpr.name === 'Real') return '0.0';
|
||||
return '0';
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { parseSpeckFile } from './parser.js';
|
|||
import { generateProvenanceGraph } from './generators/provenance.js';
|
||||
import { generateCycloneDX } from './generators/cyclonedx.js';
|
||||
import { generateSPDX } from './generators/spdx.js';
|
||||
import { generateTypeScriptStateMachine } from './generators/typescript-state-machine.js';
|
||||
import { generateWASM } from './generators/wasm.js';
|
||||
import fs from 'fs';
|
||||
|
||||
interface CompileOptions {
|
||||
|
|
@ -63,6 +65,14 @@ async function main() {
|
|||
console.log('\nGenerating provenance graph (PROV-O JSON-LD)...');
|
||||
generateProvenanceGraph(ast, options.outputDir);
|
||||
|
||||
// Generate TypeScript state machine
|
||||
console.log('\nGenerating TypeScript state machine...');
|
||||
generateTypeScriptStateMachine(ast, options.outputDir);
|
||||
|
||||
// Generate WASM state machine
|
||||
console.log('\nGenerating WebAssembly (WAT)...');
|
||||
generateWASM(ast, options.outputDir);
|
||||
|
||||
// Generate BOM based on format selection
|
||||
if (options.bomFormat === 'cdx' || options.bomFormat === 'both') {
|
||||
console.log('\nGenerating CycloneDX BOM...');
|
||||
|
|
|
|||
|
|
@ -104,6 +104,37 @@ export interface BOMNode {
|
|||
hash?: string;
|
||||
}
|
||||
|
||||
// State machine extension (v0.3)
|
||||
export interface StateNode {
|
||||
type: 'state';
|
||||
variables: StateVar[];
|
||||
}
|
||||
|
||||
export interface StateVar {
|
||||
name: string;
|
||||
typeExpr: TypeExpr;
|
||||
defaultInit?: string; // default initializer expression as raw string
|
||||
}
|
||||
|
||||
export interface InitNode {
|
||||
type: 'init';
|
||||
assignments: { name: string; expr: string }[];
|
||||
}
|
||||
|
||||
export interface ActionNode {
|
||||
type: 'action';
|
||||
name: string;
|
||||
params: { name: string; type: TypeExpr }[];
|
||||
statements: ActionStatement[];
|
||||
}
|
||||
|
||||
export type ActionStatement =
|
||||
| { type: 'assign'; target: string; expr: string }
|
||||
| { type: 'let'; name: string; expr: string }
|
||||
| { type: 'require'; expr: string }
|
||||
| { type: 'emit'; event: string; fields: { name: string; value: string }[] }
|
||||
| { type: 'return'; expr: string };
|
||||
|
||||
export type MemberNode =
|
||||
| ImportNode
|
||||
| InputNode
|
||||
|
|
@ -118,13 +149,18 @@ export type MemberNode =
|
|||
| SatisfiesNode
|
||||
| AuthorNode
|
||||
| SourceNode
|
||||
| BOMNode;
|
||||
| BOMNode
|
||||
| StateNode
|
||||
| InitNode
|
||||
| ActionNode;
|
||||
|
||||
export interface TypeExpr {
|
||||
type: 'primitive' | 'record' | 'list' | 'ident';
|
||||
type: 'primitive' | 'record' | 'list' | 'set' | 'map' | 'ident';
|
||||
name?: string;
|
||||
fields?: { name: string; type: TypeExpr }[];
|
||||
elementType?: TypeExpr;
|
||||
keyType?: TypeExpr; // for Map<K, V>
|
||||
valueType?: TypeExpr; // for Map<K, V>
|
||||
}
|
||||
|
||||
export interface AST {
|
||||
|
|
@ -185,51 +221,107 @@ export function parseSpeckContent(content: string, filePath?: string): AST {
|
|||
}
|
||||
|
||||
function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
|
||||
// Parse speck header: speck <Name> {
|
||||
const headerMatch = lines[startIndex].match(/^speck\s+([\w-]+)\s*{/);
|
||||
if (!headerMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!headerMatch) return null;
|
||||
|
||||
const name = headerMatch[1];
|
||||
const members: MemberNode[] = [];
|
||||
|
||||
|
||||
let i = startIndex + 1;
|
||||
let braceCount = 1;
|
||||
|
||||
|
||||
while (i < lines.length && braceCount > 0) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
const rawLine = lines[i];
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (!line || line.startsWith('//') || line.startsWith('/*')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('{')) {
|
||||
braceCount++;
|
||||
}
|
||||
if (line.includes('}')) {
|
||||
braceCount--;
|
||||
if (braceCount === 0) {
|
||||
break;
|
||||
|
||||
// Count braces on this line first
|
||||
const openCount = (rawLine.match(/{/g) || []).length;
|
||||
const closeCount = (rawLine.match(/}/g) || []).length;
|
||||
|
||||
// Check for block-opening members
|
||||
const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface '];
|
||||
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
|
||||
|
||||
if (isBlockStarter && openCount > 0) {
|
||||
// Multi-line block: parse it and skip to its end
|
||||
const member = parseMemberBlock(lines, i);
|
||||
if (member) {
|
||||
members.push(member);
|
||||
}
|
||||
// Skip past this block
|
||||
const endIdx = findBlockEnd(lines, i + 1, braceCount + openCount);
|
||||
i = endIdx + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse member
|
||||
|
||||
// Single-line: update brace count, then parse if still inside
|
||||
braceCount += openCount - closeCount;
|
||||
if (braceCount <= 0) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
|
||||
const member = parseMember(line);
|
||||
if (member) {
|
||||
members.push(member);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return { type: 'speck', name, members };
|
||||
}
|
||||
|
||||
function findBlockEnd(lines: string[], startIndex: number, startBraceCount: number): number {
|
||||
let braceCount = startBraceCount;
|
||||
let i = startIndex;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
for (const ch of line) {
|
||||
if (ch === '{') braceCount++;
|
||||
if (ch === '}') {
|
||||
braceCount--;
|
||||
if (braceCount < startBraceCount) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function parseMemberBlock(lines: string[], startIndex: number): MemberNode | null {
|
||||
const firstLine = lines[startIndex].trim();
|
||||
const startBraceCount = (firstLine.match(/{/g) || []).length;
|
||||
|
||||
return {
|
||||
type: 'speck',
|
||||
name,
|
||||
members,
|
||||
};
|
||||
if (firstLine.startsWith('state:')) {
|
||||
return parseStateBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('init:')) {
|
||||
return parseInitBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('action ')) {
|
||||
return parseActionBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('event ')) {
|
||||
return parseEventBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('interface ')) {
|
||||
return parseInterfaceBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('provenance ')) {
|
||||
return { type: 'provenance', clauses: [] };
|
||||
}
|
||||
if (firstLine.startsWith('bom ')) {
|
||||
return { type: 'bom' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseMember(line: string): MemberNode | null {
|
||||
|
|
@ -296,6 +388,33 @@ function parseMember(line: string): MemberNode | null {
|
|||
return parseSource(line);
|
||||
}
|
||||
|
||||
// state:
|
||||
if (line.startsWith('state:')) {
|
||||
const varsStr = line.substring(6).trim();
|
||||
// state: { ... } - single-line form
|
||||
if (varsStr.startsWith('{') && varsStr.endsWith('}')) {
|
||||
const inner = varsStr.slice(1, -1).trim();
|
||||
return parseStateBlock(inner);
|
||||
}
|
||||
// state: { - multi-line opening (handled by parseStateMultiline)
|
||||
return { type: 'state', variables: [] };
|
||||
}
|
||||
|
||||
// init:
|
||||
if (line.startsWith('init:')) {
|
||||
const initStr = line.substring(5).trim();
|
||||
if (initStr.startsWith('{') && initStr.endsWith('}')) {
|
||||
const inner = initStr.slice(1, -1).trim();
|
||||
return parseInitBlock(inner);
|
||||
}
|
||||
return { type: 'init', assignments: [] };
|
||||
}
|
||||
|
||||
// action keyword
|
||||
if (line.startsWith('action ')) {
|
||||
return parseActionHeader(line);
|
||||
}
|
||||
|
||||
// bom {
|
||||
if (line.startsWith('bom ')) {
|
||||
// Simplified - full BOM block parsing would need multi-line context
|
||||
|
|
@ -307,6 +426,71 @@ function parseMember(line: string): MemberNode | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function parseStateBlock(inner: string): StateNode {
|
||||
const variables: StateVar[] = [];
|
||||
if (!inner.trim()) return { type: 'state', variables };
|
||||
|
||||
const varStrs = inner.split(/;|\n/).map(s => s.trim()).filter(Boolean);
|
||||
|
||||
for (const vStr of varStrs) {
|
||||
const withDefault = vStr.match(/^(\w+)\s*:\s*(.+?)\s*:=\s*(.+)$/);
|
||||
if (withDefault) {
|
||||
variables.push({
|
||||
name: withDefault[1],
|
||||
typeExpr: parseTypeExpr(withDefault[2].trim()),
|
||||
defaultInit: withDefault[3].trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const withoutDefault = vStr.match(/^(\w+)\s*:\s*(.+)$/);
|
||||
if (withoutDefault) {
|
||||
variables.push({
|
||||
name: withoutDefault[1],
|
||||
typeExpr: parseTypeExpr(withoutDefault[2].trim()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'state', variables };
|
||||
}
|
||||
|
||||
function parseInitBlock(inner: string): InitNode {
|
||||
const assignments: { name: string; expr: string }[] = [];
|
||||
if (!inner.trim()) return { type: 'init', assignments };
|
||||
|
||||
const assignStrs = inner.split(/;|\n/).map(s => s.trim()).filter(Boolean);
|
||||
|
||||
for (const aStr of assignStrs) {
|
||||
const match = aStr.match(/^(\w+)\s*:=\s*(.+)$/);
|
||||
if (match) {
|
||||
assignments.push({ name: match[1], expr: match[2].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'init', assignments };
|
||||
}
|
||||
|
||||
function parseActionHeader(line: string): ActionNode | null {
|
||||
const match = line.match(/^action\s+(\w+)\s*(?:\(([^)]*)\))?\s*\{/);
|
||||
if (!match) return null;
|
||||
|
||||
const name = match[1];
|
||||
const paramsStr = match[2] || '';
|
||||
const params: { name: string; type: TypeExpr }[] = [];
|
||||
|
||||
if (paramsStr.trim()) {
|
||||
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const p of paramPairs) {
|
||||
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/);
|
||||
if (pMatch) {
|
||||
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'action', name, params, statements: [] };
|
||||
}
|
||||
|
||||
function parseImportStatement(line: string): ImportNode {
|
||||
const match = line.match(/^import\s+(['"])(.+?)\1(?:\s+as\s+(\w+))?(?:\s+version\s+(['"])(.+?)\4)?(?:\s+hash\s+(['"])(.+?)\6)?/);
|
||||
if (!match) {
|
||||
|
|
@ -333,6 +517,35 @@ function parseTypeExpr(expr: string): TypeExpr {
|
|||
return { type: 'primitive', name: expr };
|
||||
}
|
||||
|
||||
// Set<T>
|
||||
if (expr.startsWith('Set<') && expr.endsWith('>')) {
|
||||
const inner = expr.substring(4, expr.length - 1).trim();
|
||||
return { type: 'set', elementType: parseTypeExpr(inner) };
|
||||
}
|
||||
|
||||
// Map<K, V>
|
||||
if (expr.startsWith('Map<') && expr.endsWith('>')) {
|
||||
const inner = expr.substring(4, expr.length - 1);
|
||||
// Find the first-level comma (not inside nested generics)
|
||||
let depth = 0;
|
||||
let commaIdx = -1;
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
if (inner[i] === '<') depth++;
|
||||
else if (inner[i] === '>') depth--;
|
||||
else if (inner[i] === ',' && depth === 0) {
|
||||
commaIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (commaIdx !== -1) {
|
||||
const keyStr = inner.substring(0, commaIdx).trim();
|
||||
const valStr = inner.substring(commaIdx + 1).trim();
|
||||
return { type: 'map', keyType: parseTypeExpr(keyStr), valueType: parseTypeExpr(valStr) };
|
||||
}
|
||||
// Fallback: treat as ident if malformed
|
||||
return { type: 'ident', name: expr };
|
||||
}
|
||||
|
||||
// List type: [T]
|
||||
if (expr.startsWith('[') && expr.endsWith(']')) {
|
||||
const elementType = parseTypeExpr(expr.substring(1, expr.length - 1));
|
||||
|
|
@ -486,9 +699,121 @@ function parseSource(line: string): SourceNode {
|
|||
};
|
||||
}
|
||||
|
||||
function parseBOMBlock(lines: string[], line: string): BOMNode {
|
||||
// This is a simplified parser - in a real implementation, we'd parse the full block
|
||||
return {
|
||||
type: 'bom',
|
||||
};
|
||||
function parseStateBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): StateNode {
|
||||
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
|
||||
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
const inner = innerLines.join('; ');
|
||||
return parseStateBlock(inner);
|
||||
}
|
||||
|
||||
function parseInitBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): InitNode {
|
||||
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
|
||||
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
const inner = innerLines.join('; ');
|
||||
return parseInitBlock(inner);
|
||||
}
|
||||
|
||||
function parseActionBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): ActionNode | null {
|
||||
const firstLine = lines[startIndex].trim();
|
||||
const header = parseActionHeader(firstLine);
|
||||
if (!header) return null;
|
||||
|
||||
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
|
||||
const bodyLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
|
||||
const statements: ActionStatement[] = [];
|
||||
for (const stmt of bodyLines) {
|
||||
// require expr
|
||||
if (stmt.startsWith('require ')) {
|
||||
statements.push({ type: 'require', expr: stmt.substring(8).trim() });
|
||||
continue;
|
||||
}
|
||||
// return expr
|
||||
if (stmt.startsWith('return ')) {
|
||||
statements.push({ type: 'return', expr: stmt.substring(7).trim() });
|
||||
continue;
|
||||
}
|
||||
// emit Event { fields }
|
||||
const emitMatch = stmt.match(/^emit\s+(\w+)\s*\{\s*(.*)\s*\}$/);
|
||||
if (emitMatch) {
|
||||
const eventName = emitMatch[1];
|
||||
const fieldsStr = emitMatch[2];
|
||||
const fields: { name: string; value: string }[] = [];
|
||||
if (fieldsStr) {
|
||||
const fieldPairs = fieldsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const fp of fieldPairs) {
|
||||
const colonIdx = fp.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
fields.push({
|
||||
name: fp.substring(0, colonIdx).trim(),
|
||||
value: fp.substring(colonIdx + 1).trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
statements.push({ type: 'emit', event: eventName, fields });
|
||||
continue;
|
||||
}
|
||||
// let name := expr
|
||||
const letMatch = stmt.match(/^let\s+(\w+)\s*:=\s*(.+)$/);
|
||||
if (letMatch) {
|
||||
statements.push({ type: 'let', name: letMatch[1], expr: letMatch[2].trim() });
|
||||
continue;
|
||||
}
|
||||
// assignment: target := expr
|
||||
const assignMatch = stmt.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/);
|
||||
if (assignMatch) {
|
||||
statements.push({ type: 'assign', target: assignMatch[1], expr: assignMatch[2].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
return { ...header, statements };
|
||||
}
|
||||
|
||||
function parseEventBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): EventNode {
|
||||
const firstLine = lines[startIndex].trim();
|
||||
const header = parseEvent(firstLine);
|
||||
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
|
||||
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
|
||||
const fields: { name: string; type: TypeExpr }[] = [];
|
||||
for (const line of innerLines) {
|
||||
const match = line.match(/^(\w+)\s*:\s*(.+?),?$/);
|
||||
if (match) {
|
||||
fields.push({ name: match[1], type: parseTypeExpr(match[2].replace(/,$/, '').trim()) });
|
||||
}
|
||||
}
|
||||
|
||||
return { ...header, fields };
|
||||
}
|
||||
|
||||
function parseInterfaceBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): InterfaceNode {
|
||||
const firstLine = lines[startIndex].trim();
|
||||
const header = parseInterface(firstLine);
|
||||
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
|
||||
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
|
||||
const methods: MethodSignature[] = [];
|
||||
for (const line of innerLines) {
|
||||
// name(params) -> returnType
|
||||
const match = line.match(/^(\w+)\s*\(([^)]*)\)\s*-\u003e\s*(.+?),?$/);
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
const paramsStr = match[2];
|
||||
const returnTypeStr = match[3].replace(/,$/, '').trim();
|
||||
const params: { name: string; type: TypeExpr }[] = [];
|
||||
if (paramsStr.trim()) {
|
||||
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const p of paramPairs) {
|
||||
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/);
|
||||
if (pMatch) {
|
||||
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) });
|
||||
}
|
||||
}
|
||||
}
|
||||
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
|
||||
}
|
||||
}
|
||||
|
||||
return { ...header, methods };
|
||||
}
|
||||
|
|
|
|||
165
compiler/src/runtime/wasm-host.ts
Normal file
165
compiler/src/runtime/wasm-host.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Speckl WASM Host Runtime
|
||||
*
|
||||
* Provides JS-side implementations for imported host functions used by
|
||||
* Speckl-generated WASM modules:
|
||||
* - map_create, map_set, map_get, map_has, map_remove
|
||||
* - set_create, set_add, set_has, set_size
|
||||
* - alloc, write_i32, write_f64
|
||||
*
|
||||
* Usage:
|
||||
* const wasmModule = await WebAssembly.instantiate(bytes, {
|
||||
* speckl: createHostImports()
|
||||
* });
|
||||
* wasmModule.exports.init();
|
||||
* wasmModule.exports.CreateAccount(100.0);
|
||||
* const state = getStateFromMemory(wasmModule);
|
||||
*/
|
||||
|
||||
interface HostState {
|
||||
maps: Map<number, Map<number, any>>;
|
||||
sets: Map<number, Set<any>>;
|
||||
nextHandle: number;
|
||||
memory: WebAssembly.Memory | null;
|
||||
}
|
||||
|
||||
function createHostState(): HostState {
|
||||
return {
|
||||
maps: new Map(),
|
||||
sets: new Map(),
|
||||
nextHandle: 1,
|
||||
memory: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createHostImports(state: HostState = createHostState()) {
|
||||
return {
|
||||
// ---- Map operations ----
|
||||
map_create: (): number => {
|
||||
const handle = state.nextHandle++;
|
||||
state.maps.set(handle, new Map());
|
||||
return handle;
|
||||
},
|
||||
|
||||
map_set: (mapHandle: number, keyHandle: number, valueHandle: number): void => {
|
||||
const map = state.maps.get(mapHandle);
|
||||
if (!map) throw new Error(`Map handle ${mapHandle} not found`);
|
||||
// For now, keys and values are simple i32 handles; boxing layer comes later
|
||||
map.set(keyHandle, valueHandle);
|
||||
},
|
||||
|
||||
map_get: (mapHandle: number, keyHandle: number): number => {
|
||||
const map = state.maps.get(mapHandle);
|
||||
if (!map) return 0;
|
||||
return map.has(keyHandle) ? (map.get(keyHandle) ?? 0) : 0;
|
||||
},
|
||||
|
||||
map_has: (mapHandle: number, keyHandle: number): number => {
|
||||
const map = state.maps.get(mapHandle);
|
||||
if (!map) return 0;
|
||||
return map.has(keyHandle) ? 1 : 0;
|
||||
},
|
||||
|
||||
map_remove: (mapHandle: number, keyHandle: number): void => {
|
||||
const map = state.maps.get(mapHandle);
|
||||
if (map) map.delete(keyHandle);
|
||||
},
|
||||
|
||||
// ---- Set operations ----
|
||||
set_create: (): number => {
|
||||
const handle = state.nextHandle++;
|
||||
state.sets.set(handle, new Set());
|
||||
return handle;
|
||||
},
|
||||
|
||||
set_add: (setHandle: number, valueHandle: number): void => {
|
||||
const set = state.sets.get(setHandle);
|
||||
if (!set) throw new Error(`Set handle ${setHandle} not found`);
|
||||
set.add(valueHandle);
|
||||
},
|
||||
|
||||
set_has: (setHandle: number, valueHandle: number): number => {
|
||||
const set = state.sets.get(setHandle);
|
||||
if (!set) return 0;
|
||||
return set.has(valueHandle) ? 1 : 0;
|
||||
},
|
||||
|
||||
set_size: (setHandle: number): number => {
|
||||
const set = state.sets.get(setHandle);
|
||||
if (!set) return 0;
|
||||
return set.size;
|
||||
},
|
||||
|
||||
// ---- Memory helpers ----
|
||||
alloc: (size: number): number => {
|
||||
// Simple bump allocator: for now, just return 0 (stack allocation handled by compiler)
|
||||
// Future: maintain bump pointer in memory
|
||||
return 0;
|
||||
},
|
||||
|
||||
write_i32: (ptr: number, value: number): void => {
|
||||
if (!state.memory) throw new Error('Memory not bound');
|
||||
const view = new DataView(state.memory.buffer);
|
||||
view.setInt32(ptr, value, true); // little-endian
|
||||
},
|
||||
|
||||
write_f64: (ptr: number, value: number): void => {
|
||||
if (!state.memory) throw new Error('Memory not bound');
|
||||
const view = new DataView(state.memory.buffer);
|
||||
view.setFloat64(ptr, value, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a WASM memory export to the host state so write_i32/write_f64 work.
|
||||
*/
|
||||
export function bindMemory(state: HostState, memory: WebAssembly.Memory): void {
|
||||
state.memory = memory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read scalar state from WASM memory after getState() has been called.
|
||||
* Layout matches emitGetState(): [nextId: i32][ledgerOpen: i32]...
|
||||
*/
|
||||
export function readScalarState(memory: WebAssembly.Memory, varNames: string[]): Record<string, number> {
|
||||
const view = new DataView(memory.buffer);
|
||||
const result: Record<string, number> = {};
|
||||
let offset = 0;
|
||||
|
||||
for (const name of varNames) {
|
||||
// All current scalars are i32 in TigerBeetle; extend for f64 later
|
||||
result[name] = view.getInt32(offset, true);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level helper: instantiate a Speckl WASM module from WAT text.
|
||||
* Requires `wat2wasm` or a WAT parser. For now, expects pre-compiled .wasm bytes.
|
||||
*/
|
||||
export async function instantiateSpeckl(
|
||||
wasmBytes: BufferSource,
|
||||
varNames: string[]
|
||||
): Promise<{
|
||||
exports: WebAssembly.Exports;
|
||||
state: HostState;
|
||||
getScalarState: () => Record<string, number>;
|
||||
}> {
|
||||
const hostState = createHostState();
|
||||
const imports = { speckl: createHostImports(hostState) };
|
||||
|
||||
const wasm = await WebAssembly.instantiate(wasmBytes, imports);
|
||||
const memory = wasm.instance.exports.memory as WebAssembly.Memory;
|
||||
bindMemory(hostState, memory);
|
||||
|
||||
const getScalarState = () => readScalarState(memory, varNames);
|
||||
|
||||
return {
|
||||
exports: wasm.instance.exports,
|
||||
state: hostState,
|
||||
getScalarState,
|
||||
};
|
||||
}
|
||||
|
|
@ -12,22 +12,22 @@
|
|||
"@type": "prov:Generation",
|
||||
"prov:activity": {
|
||||
"@type": "prov:Activity",
|
||||
"prov:startTime": "2026-04-29T22:11:59.830Z"
|
||||
"prov:startTime": "2026-05-03T18:51:34.102Z"
|
||||
}
|
||||
},
|
||||
"rdfs:label": "RetryHandler",
|
||||
"prov:wasDerivedFrom": {
|
||||
"@id": "speck:FaultHandler"
|
||||
},
|
||||
"rdfs:comment": "\"",
|
||||
"rdfs:comment": "specialization for retry logic",
|
||||
"speckl:satisfies": {
|
||||
"@id": "requirement:RESILIENCE"
|
||||
"@id": "requirement:RESILIENCE-REQ-01"
|
||||
},
|
||||
"prov:wasAttributedTo": {
|
||||
"@id": "agent:\""
|
||||
"@id": "agent:Scott Scoble"
|
||||
},
|
||||
"prov:wasInformedBy": {
|
||||
"@id": "source:architecture_review:\""
|
||||
"@id": "source:architecture_review:AR-2024-0215"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -36,25 +36,26 @@
|
|||
},
|
||||
{
|
||||
"@type": "prov:Entity",
|
||||
"@id": "requirement:RESILIENCE",
|
||||
"rdfs:label": "RESILIENCE"
|
||||
"@id": "requirement:RESILIENCE-REQ-01",
|
||||
"rdfs:label": "RESILIENCE-REQ-01",
|
||||
"speckl:clause": "3.1.2"
|
||||
},
|
||||
{
|
||||
"@type": "prov:Entity",
|
||||
"@id": "requirement:RESILIENCE",
|
||||
"rdfs:label": "RESILIENCE"
|
||||
"@id": "requirement:RESILIENCE-REQ-02",
|
||||
"rdfs:label": "RESILIENCE-REQ-02"
|
||||
},
|
||||
{
|
||||
"@type": "prov:Agent",
|
||||
"@id": "agent:\"",
|
||||
"rdfs:label": "\"",
|
||||
"foaf:email": "Scott Scoble"
|
||||
"@id": "agent:Scott Scoble",
|
||||
"rdfs:label": "Scott Scoble",
|
||||
"foaf:email": "scott@scoble.me"
|
||||
},
|
||||
{
|
||||
"@type": "prov:Activity",
|
||||
"@id": "source:architecture_review:\"",
|
||||
"@id": "source:architecture_review:AR-2024-0215",
|
||||
"prov:type": "architecture_review",
|
||||
"rdfs:label": "architecture_review - \""
|
||||
"rdfs:label": "architecture_review - AR-2024-0215"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -15,11 +15,11 @@
|
|||
},
|
||||
{
|
||||
"name": "speckl:satisfies",
|
||||
"value": "RESILIENCE"
|
||||
"value": "RESILIENCE-REQ-01 clause 3.1.2"
|
||||
},
|
||||
{
|
||||
"name": "speckl:satisfies",
|
||||
"value": "RESILIENCE"
|
||||
"value": "RESILIENCE-REQ-02"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -35,10 +35,10 @@
|
|||
}
|
||||
],
|
||||
"author": {
|
||||
"name": "\"",
|
||||
"email": "Scott Scoble"
|
||||
"name": "Scott Scoble",
|
||||
"email": "scott@scoble.me"
|
||||
},
|
||||
"timestamp": "2026-04-29T22:11:59.832Z"
|
||||
"timestamp": "2026-05-03T18:51:34.104Z"
|
||||
},
|
||||
{
|
||||
"subject": "speck:RetryHandler",
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"name": "Speckl Compiler",
|
||||
"email": "compiler@speckl.scoble.me"
|
||||
},
|
||||
"timestamp": "2026-04-29T22:11:59.832Z"
|
||||
"timestamp": "2026-05-03T18:51:34.104Z"
|
||||
},
|
||||
{
|
||||
"subject": "speck:RetryHandler",
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
"name": "Speckl Compiler",
|
||||
"email": "compiler@speckl.scoble.me"
|
||||
},
|
||||
"timestamp": "2026-04-29T22:11:59.832Z"
|
||||
"timestamp": "2026-05-03T18:51:34.104Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"spdxVersion": "3.0.1",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"creationInfo": {
|
||||
"created": "2026-04-29T22:11:59.833Z",
|
||||
"created": "2026-05-03T18:51:34.105Z",
|
||||
"createdBy": [
|
||||
"Organization: Speckl",
|
||||
"tool: speckl-compile-0.2.0"
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
"@type": "spdx:Tool",
|
||||
"name": "speckl-compile"
|
||||
},
|
||||
"timestamp": "2026-04-29T22:11:59.833Z",
|
||||
"timestamp": "2026-05-03T18:51:34.105Z",
|
||||
"comment": "Review type: manual"
|
||||
}
|
||||
]
|
||||
|
|
@ -48,7 +48,22 @@
|
|||
"@id": "spdx:RetryHandler"
|
||||
},
|
||||
"to": {
|
||||
"@id": "spdx:requirement:RESILIENCE"
|
||||
"@id": "spdx:requirement:RESILIENCE-REQ-01"
|
||||
},
|
||||
"relationshipType": "speckl:satisfies",
|
||||
"additionalProperties": [
|
||||
{
|
||||
"name": "speckl:clause",
|
||||
"value": "3.1.2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": {
|
||||
"@id": "spdx:RetryHandler"
|
||||
},
|
||||
"to": {
|
||||
"@id": "spdx:requirement:RESILIENCE-REQ-02"
|
||||
},
|
||||
"relationshipType": "speckl:satisfies"
|
||||
},
|
||||
|
|
@ -57,16 +72,7 @@
|
|||
"@id": "spdx:RetryHandler"
|
||||
},
|
||||
"to": {
|
||||
"@id": "spdx:requirement:RESILIENCE"
|
||||
},
|
||||
"relationshipType": "speckl:satisfies"
|
||||
},
|
||||
{
|
||||
"from": {
|
||||
"@id": "spdx:RetryHandler"
|
||||
},
|
||||
"to": {
|
||||
"@id": "spdx:agent:\""
|
||||
"@id": "spdx:agent:Scott Scoble"
|
||||
},
|
||||
"relationshipType": "spdx:wasAttributedTo"
|
||||
},
|
||||
|
|
@ -75,7 +81,7 @@
|
|||
"@id": "spdx:RetryHandler"
|
||||
},
|
||||
"to": {
|
||||
"@id": "spdx:source:architecture_review:\""
|
||||
"@id": "spdx:source:architecture_review:AR-2024-0215"
|
||||
},
|
||||
"relationshipType": "spdx:wasInformedBy"
|
||||
}
|
||||
|
|
@ -83,24 +89,24 @@
|
|||
"elements": [
|
||||
{
|
||||
"@type": "spdx:Requirement",
|
||||
"@id": "spdx:requirement:RESILIENCE",
|
||||
"name": "RESILIENCE"
|
||||
"@id": "spdx:requirement:RESILIENCE-REQ-01",
|
||||
"name": "RESILIENCE-REQ-01"
|
||||
},
|
||||
{
|
||||
"@type": "spdx:Requirement",
|
||||
"@id": "spdx:requirement:RESILIENCE",
|
||||
"name": "RESILIENCE"
|
||||
"@id": "spdx:requirement:RESILIENCE-REQ-02",
|
||||
"name": "RESILIENCE-REQ-02"
|
||||
},
|
||||
{
|
||||
"@type": "spdx:Person",
|
||||
"@id": "spdx:agent:\"",
|
||||
"name": "\"",
|
||||
"email": "Scott Scoble"
|
||||
"@id": "spdx:agent:Scott Scoble",
|
||||
"name": "Scott Scoble",
|
||||
"email": "scott@scoble.me"
|
||||
},
|
||||
{
|
||||
"@type": "spdx:Activity",
|
||||
"@id": "spdx:source:architecture_review:\"",
|
||||
"name": "architecture_review - \"",
|
||||
"@id": "spdx:source:architecture_review:AR-2024-0215",
|
||||
"name": "architecture_review - AR-2024-0215",
|
||||
"type": "architecture_review"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue