Compare commits

...
Sign in to create a new pull request.

25 commits

Author SHA1 Message Date
tef-engine
9e4ac1b120 ifblock-fix: real if/else codegen + parser require-continuation fix
- typescript-state-machine.ts: replace TODO stub at L557-559 with real
  if/else TypeScript codegen from SpeckDL ifblock AST nodes
- parser.ts: fix require-continuation loop at L1213 that was consuming
  'if (...)' lines as require args; added nextLine.startsWith('if ')
  to the stop conditions so ifblocks parse correctly
- examples/ConditionalToggle.speck: new test fixture exercising if/else
- compiler.test.ts: new describe block for ifblock code generation
  (asserts real 'if (' + brace block + 'else', no TODO stub)

Tests: 71/71 green (53 parser + 18 compiler incl. new ifblock test).
2026-07-19 01:31:27 -07:00
Scott Scoble
857fa6ceb4 fix: test path resolution — use __dirname instead of process.cwd() (cycle 74) 2026-06-29 19:09:47 -07:00
Scott Scoble
fa6f5bcf21 test: add 12 regression tests for Z3/TS/Rust expression translation
Tests verify:
- Z3: implies() preserves args, multi-level field access, len(), nil, distinct
- TS: implies() → (!p || q), constraint checks use every(), in → .has(), dedup fix
- Rust: constraint checks use iter().all(), implies() → (!p || q), dedup fix
- All 6 verify blocks + 8 constraints emitted in all backends

69/69 tests pass (57 existing + 12 new).
2026-06-29 17:46:17 -07:00
Scott Scoble
37cdc59180 fix(ts,rust): real constraint/verify expressions, dedup fix, implies translation
TS backend:
- Constraint checks now emit real forall→every() translation (was 'return true')
- Verify: implies(p,q) now (!p || q) (was (p || q) — wrong logic)
- Verify: 'in' operator now pre-tokenized to avoid || splitting the regex
- memberKey dedup fix: verify/constraint nodes now keyed by expression
  (was: all verify nodes deduped to 1, all constraints to 1)

Rust backend:
- Constraint checks: forall→iter().all(), implies()→(!p || q), nil→sentinel
- Verify checks: always()/eventually() with decision_log iteration
- Same memberKey dedup fix applied

All 8 constraints + 6 verify blocks now emit in both TS and Rust.
57/57 tests pass.
2026-06-29 17:39:06 -07:00
Scott Scoble
3ca5a8d21a fix(z3): preserve implies args, multi-level field access, len(), nil, distinct operator
Z3 expression translator fixes:
- implies(x, y) now preserves both arguments (was dropping both → (=> true true))
- Multi-level field access m.price.amount → m_price_amount (was m_price.(>= amount 0))
- len() now handled same as length() (was left as bare len())
- nil/null translated to speckl_nil sentinel constant
- != now properly converts to (distinct X Y) via convertToPrefix (was bare 'distinct')
- Added 'distinct' as binary operator in convertToPrefix
- speckl_nil declared as Int constant in output

All 8 product spec constraints now produce valid SMT-LIB2.
57/57 tests pass.
2026-06-29 17:31:31 -07:00
Scott Scoble
7ce0b7fecc fix(speckl): TS generator — emit interface record types (cycle 73)
Same defect as Rust backend: interface records (Money, Ticket, Order,
etc.) were not emitted as TS interfaces. Only events were emitted.

Now record-kind interfaces (with fields, not methods) emit as TS
interfaces, placed between the enum declarations and event interfaces.

Output for FederatedMeetupProduct: 426 lines (was 191), 47 interfaces
(was ~11). 57/57 tests pass.
2026-06-29 17:16:11 -07:00
Scott Scoble
84b8962d22 fix(speckl): Rust generator — Date type, record structs, event enum (cycle 73)
Three defects found by deep-inspecting the Rust output against the
product spec (FederatedMeetupProduct.speckdl):

1. Date type undefined in Rust — emitted as 'Date' which doesn't exist.
   Fixed: Date maps to u64 (Unix timestamp) in both the primitive map
   and the ident fallback path.

2. Interface records (Money, Ticket, Order, etc.) not emitted as Rust
   structs — only events were emitted. The TS backend has the same gap.
   Fixed: record-kind interfaces now emit as Rust structs.

3. Decision log typed as Vec<FirstEventName> instead of an enum union.
   Fixed: emit a SpeckName_Event enum with one variant per event type,
   type the decision log as Vec<SpeckName_Event>.

57/57 tests pass. Both specs generate clean Rust output.
2026-06-29 17:14:12 -07:00
Scott Scoble
e5ee3fe1c0 refactor(speckl): replace static-field hack with local-object pattern (cycle 73)
The proto_package / go_package / event_suffix directives were stored on
(parseSpeck as any)._protoPackage etc — static function fields that leaked
between parse calls (cycle 61 added a reset at the top, but the pattern
was still wrong).

Now collected in a local metadata object inside parseSpeck, spread into
the return value. No reset needed — the object is local to the invocation.

This is the durable fix referenced in the spec-compiler-dogfood skill's
anti-pattern section 'Storing per-speck parser metadata on a static
function field as a quick hack.'

57/57 tests pass, both specs protoc-clean.
2026-06-29 14:29:30 -07:00
Scott Scoble
121ac7de33 fix(speckl): error on unrecognized lines in service blocks (D3, cycle 73)
Previously, a typo like rc_purchase_ticket (missing p in rpc) was silently
swallowed — 13 RPCs in source, 12 in AST, no error. Now throws a parse
error with the unrecognized line and expected format.
2026-06-29 14:01:58 -07:00
Scott Scoble
5ae89e943c test(speckl): lock in List<T> in interface record fields (cycle 73) 2026-06-29 12:15:47 -07:00
Scott Scoble
04fa9844f4 fix(speckl): protobuf generator — List<T> in interfaces, enum prefix, transition type field, Date in interfaces (cycle 73)
Four defects found by dogfooding Speckl against the v0.3 product-layer
spec (FederatedMeetupProduct.speckdl, 74 members):

D1 — CRITICAL: List<T> fields in interface records emitted empty type
  Root cause: interface record fields called speckTypeToProto(f.type.name)
  which returns '' for List/Map/Set types (their .name is undefined).
  Event and state fields had inline List/Map dispatch, but interfaces did not.
  Fix: extracted typeExprToProto() shared function that handles all TypeExpr
  variants (primitive, ident, list, map, set). All four field-emitting code
  paths (interfaces, events, state, transitions) now use it.

D2 — MODERATE: Transition type discriminator field silently dropped
  Root cause: parser stores the type field in t.typeDiscriminator (string)
  rather than in t.fields array. Generator only emitted t.fields.
  Fix: emit typeDiscriminator as 'type' field at number 1, offset other
  fields by 1 to avoid collision.

D4 — LOW: Enum variant name collisions across enums in same proto scope
  Proto3 requires all enum values unique within enclosing scope, not just
  the enum. PENDING in Role and PENDING in OrderStatus collided.
  Fix: prefix all variants with upperSnake(enumName) — the proto3 convention.
  This also matches the handwritten proto's naming (TRANSITION_TYPE_*).

D5 — LOW: Date in interface fields did not trigger timestamp import
  The usesTimestamp check only scanned events and state, not interfaces.
  Fix: added interface member check to the timestamp import scan.

D3 (deferred): rc_ typo in spec source silently swallowed by parser.
  Parser bug, not generator bug. Filed for future cycle.

Verified: 56/56 tests pass, both FederatedMeetup.speckdl and
FederatedMeetupProduct.speckdl compile to protoc-clean output.
2026-06-29 12:12:40 -07:00
Scott Scoble
7beaf6f3cc fix(speckl): strip default-value syntax from event field types (cycle 65)
End-to-end compile of FederatedMeetup.speckdl (with the cycle-65
Transition envelope added) surfaced a real bug: the speckdl uses
default-value syntax on event fields:

  initial_mesh_peers: List<InitialMeshPeer> = []

The default is a Speckl semantic — a state-machine init value —
but the .proto wire format has no place for it. Without stripping,
the type expression 'List<InitialMeshPeer> = []' was emitted
verbatim as the field type, producing broken proto3 syntax that
protoc rejected.

Fix: parseEventBlockMultiline now strips '= <expr>' suffix from
the type expression before calling parseTypeExpr. The default value
is dropped (it's a Speckl semantic only; the wire format carries
no default).

Tests: 1 new parser test covers the federated-meetup
'initial_mesh_peers: List<InitialMeshPeer> = []' shape. All 56
tests pass (55 baseline + 1 new). tsc build clean.

End-to-end verified: with this fix and the cycle-65 federated-
meetup speckdl changes, the Speckl-generated .proto now compiles
cleanly with 'protoc' (previously rejected).
2026-06-28 22:36:36 -07:00
Scott Scoble
f37c0ccc57 feat(speckl): emit TransitionNode and OneofNode in proto generator (cycle 64)
The .proto generator now recognizes two new top-level speck members
and emits real proto3 syntax for them:

  oneof Name {                    oneof is NOT emitted at top level
    variant_field: VariantType    (it lives inside the transition's
  }                                payload block — see below)

  transition Name {               emitted as:
    type: <EnumName>                message Name {
    payload: <OneofName>              <regular fields>
    hlc: Bytes                        oneof payload {
  }                                    VariantType variant_field = 10;
                                       ...
                                     }
                                   }

The  and  fields are special-cased: type records the
discriminator enum reference (so future type-aware code can find it),
payload records the sibling oneof block to inline. Neither is emitted
as a regular proto field.

The oneof variants reference the suffixed message name when the
speck has 'event_suffix: "Payload"' (or any suffix). This makes the
generated variants match the consumer's Go types in the federated-
meetup case: 'CreateGroupPayload create_group = 10' is the type
binding the consumer's apply switch expects.

End-to-end verified: a speck with
  event_suffix: "Payload"
  oneof TransitionPayload {
    create_group: CreateGroup
    add_steward: AddSteward
  }
  transition Transition {
    type: TransitionType
    payload: TransitionPayload
    hlc: Bytes
  }
  event CreateGroup { name: String }
  event AddSteward { new_steward: String }

emits the right proto3:
  message CreateGroupPayload { ... }
  message AddStewardPayload { ... }
  message Transition {
    bytes hlc = 1;
    oneof payload {
      CreateGroupPayload create_group = 10;
      AddStewardPayload add_steward = 11;
    }
  }

All 55 tests pass. tsc build clean.

The federated-meetup speckdl (specs/FederatedMeetup.speckdl) does
not yet declare the Transition envelope — it uses 13 separate
event blocks. Adding the transition+oneof envelope to the real
spec is the next step (cycle 65).
2026-06-28 22:31:24 -07:00
Scott Scoble
4d95a2b975 fix(speckl): dispatch 'state as <Name>' to parseStateBlockMultiline (cycle 63)
End-to-end compile of FederatedMeetup.speckdl with the new
'state as StateSnapshot' header (cycle 63) surfaced that the
parser's main loop and parseMemberBlock dispatcher were both
missing 'state as ' as a recognized block-starter prefix. The
state-block parser (parseStateBlockMultiline) already extracts
the 'as' clause correctly (cycle 59), but the call site never
reached it: the block fell through the 'state:' / 'state {'
checks and was parsed as a regular member line, producing no
StateNode at all.

Fix: add 'state as ' to the blockStarters list in the main
parse loop, and add a parallel dispatch in parseMemberBlock
that routes 'state as ' headers to parseStateBlockMultiline.
The dispatcher change is symmetric with the existing 'state:'
and 'state {' forms.

Tests: 1 new parser test confirms the dispatch works for
'state as StateSnapshot { ... }'. All 55 tests pass (54
baseline + 1 new). tsc build clean.

End-to-end verified: running generateProtobuf against the
federated-meetup speck with 'state as StateSnapshot' now
emits 'message StateSnapshot { ... }' (correct), where
pre-fix the StateNode was lost entirely.
2026-06-28 22:29:28 -07:00
Scott Scoble
0e03aa4e04 fix(speckl): strip inline // comments from event/interface field types (cycle 62)
End-to-end compile of FederatedMeetup.speckdl surfaced a real
generator input bug: inline line comments on field declarations
were bleeding into the parsed type expression. A field like

  mesh_ip: Bytes   // 4 bytes (IPv4 overlay)

produced a TypeExpr whose name was literally
  'Bytes   // 4 bytes (IPv4 overlay)'
which the .proto generator emitted verbatim:

  bytes  // 4 bytes (IPv4 overlay) mesh_ip = 2;

— broken syntax (comment in the type position).

Fix: both parseEventBlockMultiline and parseInterfaceBlockMultiline
now strip '// ...' suffixes from each inner line BEFORE matching the
'name: type' regex or splitting on commas. The state-block parser
already had this; cycle 62 brings event and interface to parity.

Tests: 2 new parser tests covering event and interface blocks, both
with the FederatedMeetup-style inline comment. All 54 tests pass
(52 baseline + 2 new). tsc build clean.

End-to-end verified: running generateProtobuf against the real
FederatedMeetup.speckdl now emits clean 'bytes mesh_ip = 2;' for
the InitialMeshPeer interface, where cycle 62's pre-fix output
was the broken 'Bytes   // 4 bytes (IPv4 overlay) mesh_ip = 2;'.

This unblocks the next phase of the federated-meetup Speckl
migration: the consumer Go code can now bind to the generated
proto without a half-broken field in the way.
2026-06-28 22:26:06 -07:00
Scott Scoble
a6bdbce915 feat(speckl): event_suffix directive + static-field reset (cycle 61)
Adds 'event_suffix: "Payload"' as a top-of-file directive. When set,
the .proto generator appends the suffix to every 'event' block's
emitted message name. Default (no directive) preserves the existing
'event CreateGroup' → 'message CreateGroup' behavior.

Why: federated-meetup's handwritten proto names every transition
payload CreateGroupPayload, AddStewardPayload, etc. (the convention
that the wrapper message carries a 'Payload' suffix and the event
type lives in 'oneof payload { ... }'). Without this directive, the
Speckl-generated proto would emit 'message CreateGroup' and the
consumer Go code would fail to find the types it imports.

The test fixture uses three tests:
  - directive parsed and stored on SpeckNode
  - directive absent → eventSuffix undefined
  - both quoted ("Payload") and bare (Payload) values accepted

The reset of (parseSpeck as any)._protoPackage / _goPackage /
_eventSuffix at the start of parseSpeck fixes a pre-existing
static-field-hack leak where a previous test's metadata would
persist into the next test's parse. Documented as a known
anti-pattern in spec-compiler-dogfood SKILL.md; full cleanup
to a local-object pattern is tracked separately. The minimum
fix here is 'reset at start of parse' which restores test
isolation without refactoring the entire metadata-collection
path.

End-to-end verified: running generateProtobuf against a speck
with event_suffix: "Payload" emits 'message CreateGroupPayload'
and 'message AddStewardPayload' (correct).

All 52 tests pass. tsc build clean.

Reference: .hermes/skills/spec-compiler-dogfood/references/
  speckl-federated-meetup-protobuf-migration.md — the migration
unblocks when every generated message name matches what the
consumer's Go imports expect.
2026-06-28 22:02:12 -07:00
Scott Scoble
ec0f213448 test(speckl): lock in enum interface classification (cycle 60)
Adds a regression test that confirms a bare-identifier interface (no
methods with parens, no fields with colons) parses as kind: 'enum'
with the variants on its methods[] array. The .proto generator already
emits these correctly as 'enum X { X_UNSPECIFIED = 0; FOO = 1; ... }'
- confirmed end-to-end by running generateProtobuf against the
federated-meetup CustodyTier shape.

This was a verification cycle rather than a feature cycle: the compiler
already does the right thing for enums, but without a regression test
a future refactor of classifyInterfaces or parseInterface could break
this silently. The test pins the behavior so a PR that breaks enum
emission will fail CI.

All 49 tests pass (48 + 1 new). tsc build clean.

Reference: federated-meetup.speckdl CustodyTier shape
  interface CustodyTier {
    ColdHardware
    HotSigning
    ThresholdSplit
    BackupOnly
    Compromised
  }
2026-06-28 21:57:06 -07:00
Scott Scoble
0ab3174314 feat(speckl): state as <Name> message override (cycle 59)
Adds 'state as <MessageName> { ... }' syntax for the .proto generator.
When the 'as' clause is present, the generator emits
  message <MessageName> { ... }
instead of the default
  message <SpeckName>State { ... }.

Why: the federated-meetup consumer (internal/types/types.go and friends)
imports 'pb.StateSnapshot', not 'pb.FederatedMeetupState'. The previous
default of namespacing by speck name broke the migration: the generated
proto would have compiled cleanly in protoc but the consuming Go code
would not have built. With this override, the Speckl-generated proto
emits the exact message name the consumer expects.

The default behavior (no 'as' clause) is unchanged, so existing specs
that relied on the old convention (e.g. tests, examples) still work.

Tests: 2 new parser tests, both green. All 48 tests pass. tsc build clean.

Reference: .hermes/skills/spec-compiler-dogfood/references/
  speckl-federated-meetup-protobuf-migration.md
— the build break last session was caused by the missing override.
2026-06-28 21:55:54 -07:00
Scott Scoble
b65bf0e206 feat(speckl): transition envelope (cycle 58)
Adds 'transition Name { ... }' as a top-level member of a speck
block. The AST node is TransitionNode with fields[] plus optional
payloadOneof and typeDiscriminator references. Two field names
have special meaning:

  payload: <OneofName>  →  the generator will emit the referenced
                            oneof's variants inline as
                            'oneof payload { ... }' inside the
                            message body (i.e. payload becomes a
                            proto oneof, not a regular field)
  type:    <EnumName>   →  the discriminator enum for the variants

All other fields are emitted as ordinary proto message fields.

This is the shape federated-meetup needs for its Transition message:
  message Transition {
    TransitionType type = 1;
    StateRoot prior_state = 2;
    oneof payload {
      CreateGroupPayload create_group = 10;
      ...
    }
    Multisig steward_signatures = 31;
    bytes hlc = 32;
    fixed32 branch_id = 33;
    google.protobuf.Timestamp signed_at = 30;
  }

The next cycle (64) will wire the .proto generator to emit this shape
from a TransitionNode. Cycle 58 is parser-only — 3 new tests cover
regular fields, payload+type references, and trailing punctuation
tolerance. All 46 tests (39 baseline + 4 oneof + 3 transition) pass.
tsc build clean.
2026-06-28 21:53:38 -07:00
Scott Scoble
0e42961525 feat(speckl): oneof syntax for proto wire-format discriminated unions (cycle 57)
Adds 'oneof Name { field: Type, ... }' as a top-level member of a
speck block, alongside interface, event, service, etc. The AST node
is OneofNode with a variants array of { fieldName, typeName }. The
parser supports multi-line blocks, comments, and trailing commas /
semicolons. Generator integration is a follow-up cycle (proto/TS).

Motivation: federated-meetup's Transition message needs a oneof for
the 15+ payload variants (create_group, add_steward, ...). Without
oneof syntax, the consumer Go code has to hand-roll
'Transition_AddHostPeer' wrapper messages for each event type.
With oneof, the generated .proto emits a real proto3 oneof and the
generated Go bindings have a single 'isXXX()' discriminator API
that matches the consumer's expectations.

Tests: 4 new parser tests covering happy path, trailing punctuation,
comments, and coexistence with other member types. All 43 tests
(39 baseline + 4 new) pass; tsc build clean.

Reference: spec-compiler-dogfood skill, references/speckl-federated-
meetup-protobuf-migration.md — the prior build break was caused by
proto message-shape mismatches between the generated .proto and the
consumer Go code. oneof syntax is the first of 5 compiler extensions
needed to make Speckl-generated proto bindings a drop-in for the
handwritten proto/federated_meetup/v1/{state,rpc}.proto.
2026-06-28 21:49:26 -07:00
Scott Scoble
adc5b75bff feat(protobuf): honor speck.protoPackage and speck.goPackage overrides
The .proto backend previously emitted:
  package speckl.<name>.v1;
  option go_package = "github.com/sscoble/<name>/proto/.../v1;...v1";
hard-coded. Both fields are now overridable via the SpeckNode's
protoPackage / goPackage fields (populated by top-of-file metadata in
the .speckdl; see upcoming cycle 57 for the related oneof syntax which
will also be reading from the same SpeckNode).

For federated-meetup the overrides are:
  proto_package: "federated_meetup.v1"
  go_package:    "github.com/sscoble/federated-meetup/proto/...;federated_meetupv1"

The consumer Go code (internal/types/types.go, internal/group/) imports
"federated_meetup/v1" — without this override, the generated proto
would emit package "speckl.federated_meetup.v1" and the bindings
would not match the imports, breaking the consuming project's build.

Picks up a half-applied change left in the working tree from the
v0.6 review-v2 session. dist/ is gitignored; tsc build was clean
and all 39 baseline parser tests pass.
2026-06-28 21:49:19 -07:00
hermes
69f668db7c feat(speckl): protobuf backend + service block + record interface fields
- parser.ts: distinguish enum / record / service kinds in interface blocks
- parser.ts: add service { rpc X(R) returns (R); } syntax
- generators/protobuf.ts: emit proto3 (enums, messages, services) from .speckdl
- index.ts: add --target protobuf choice

Dogfood: FederatedMeetup.speckdl → 188-line .proto → buf generate →
working Go + ConnectRPC code with zero glue.

Closes speckl#61 (parser drops field declarations).
2026-06-28 01:25:54 -07:00
Scott Scoble
e6f95ddf0b fix(parser): parse multi-line input/output blocks as record types (speckl#57)
Multi-line `input: { ... }` and `output: { ... }` blocks fell through
to parseMember which returned `{ type: 'ident', name: '{' }` instead of
a record with fields. Broke RetryHandler.speck and similar real specks.

Fix: add `input:`/`output:` to blockStarters, dispatch to new
parseInputOutputBlock() that brace-counts across newlines, strips
line comments, and delegates to parseTypeExpr's record branch.

CoS-verified-live-close (cycle 126 pattern): subagent hit max_iterations
on the brace-count edge case. CoS applied focused fix + 2 new tests.
39/39 vitest pass.

Test evidence:
- RetryHandler.speck: input typeExpr.type='record' 4 fields
- 37 prior tests + 2 new = 39 passing
2026-06-09 13:53:02 -07:00
agent-engineering
d5eea7e7c7 fix(parser): multi-line verify: bodies — join depth-aware lines into temporalExpr (speckl#54)
- Add 'verify:' to the parseSpeck dispatch condition so multi-line verify:
  blocks reach the multi-line parser
- New parseVerifyColonBlock helper collects continuation lines using the
  same break-condition list as the existing skip-loop, with depth-balanced
  counter for (), [], {}
- Joins the parts with single spaces and returns a VerifyNode with a
  well-formed temporalExpr
- Closes sscoble/speckl#54 — unblocks B2 PR stack (#47 + #48 already closed)
- Pre-existing LTL inner-identifier scoping bug remains in #55 (out of scope)
2026-06-03 12:09:19 -07:00
agent-engineering
82c8bdc49b fix(speckl-48): implement verifyN() with LTL inner-expression translator (Z3 call-out, runtime-side) 2026-06-03 10:31:46 -07:00
39 changed files with 3351 additions and 519 deletions

View file

@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
import { fileURLToPath } from 'url';
import { parseSpeckContent, parseSpeckFile } from '../src/parser.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
describe('SpeckDL Parser', () => {
describe('parseSpeckContent', () => {
it('should parse a minimal speck definition with name', () => {
@ -100,6 +104,60 @@ speck Second {
}
});
it('should parse input with multi-line record type (speckl#57)', () => {
const content = `speck Test {
input: {
operation: Operation,
maxRetries: Nat,
initialDelay: Real,
maxDelay: Real
}
output: {
success: Bool,
finalResult: String,
attemptCount: Nat,
totalDelay: Real
}
}`;
const ast = parseSpeckContent(content);
const input = ast.specks[0].members[0];
const output = ast.specks[0].members[1];
expect(input.type).toBe('input');
expect(output.type).toBe('output');
if (input.type === 'input') {
expect(input.typeExpr.type).toBe('record');
expect(input.typeExpr.fields).toHaveLength(4);
expect(input.typeExpr.fields![0].name).toBe('operation');
expect(input.typeExpr.fields![3].name).toBe('maxDelay');
}
if (output.type === 'output') {
expect(output.typeExpr.type).toBe('record');
expect(output.typeExpr.fields).toHaveLength(4);
expect(output.typeExpr.fields![0].name).toBe('success');
expect(output.typeExpr.fields![3].name).toBe('totalDelay');
}
});
it('should parse input with multi-line record type, comments inside (speckl#57)', () => {
// Edge case: line comments inside the block
const content = `speck Test {
input: {
// the operation to retry
operation: Operation,
// max number of retry attempts
retries: Nat
}
}`;
const ast = parseSpeckContent(content);
const input = ast.specks[0].members[0];
if (input.type === 'input') {
expect(input.typeExpr.type).toBe('record');
expect(input.typeExpr.fields).toHaveLength(2);
expect(input.typeExpr.fields![0].name).toBe('operation');
expect(input.typeExpr.fields![1].name).toBe('retries');
}
});
it('should parse input with list type', () => {
const content = `speck Test {
input: [Nat]
@ -479,7 +537,7 @@ speck Second {
});
it('should parse full TigerBeetle speck from file', () => {
const ast = parseSpeckFile('./__tests__/tigerbeetle.speck');
const ast = parseSpeckFile(path.join(__dirname, 'tigerbeetle.speck'));
expect(ast.specks).toHaveLength(1);
const speck = ast.specks[0];
@ -524,4 +582,343 @@ speck Second {
}
});
});
// Cycle 57: oneof syntax for proto wire-format discriminated unions.
describe('oneof blocks (cycle 57)', () => {
it('should parse a minimal oneof with two variants', () => {
const content = `speck One {
oneof Payload {
create_group: CreateGroup
add_steward: AddSteward
}
}`;
const ast = parseSpeckContent(content);
const oneof = ast.specks[0].members.find(m => m.type === 'oneof');
expect(oneof).toBeDefined();
if (oneof?.type === 'oneof') {
expect(oneof.name).toBe('Payload');
expect(oneof.variants).toEqual([
{ fieldName: 'create_group', typeName: 'CreateGroup' },
{ fieldName: 'add_steward', typeName: 'AddSteward' },
]);
}
});
it('should tolerate trailing commas / semicolons (proto-style)', () => {
const content = `speck One {
oneof Payload {
create_group: CreateGroup,
add_steward: AddSteward;
}
}`;
const ast = parseSpeckContent(content);
const oneof = ast.specks[0].members.find(m => m.type === 'oneof');
if (oneof?.type === 'oneof') {
expect(oneof.variants).toHaveLength(2);
}
});
it('should skip comments inside oneof blocks', () => {
const content = `speck One {
oneof Payload {
// bootstrap variant
create_group: CreateGroup
/* member action */
add_member: AddMember
}
}`;
const ast = parseSpeckContent(content);
const oneof = ast.specks[0].members.find(m => m.type === 'oneof');
if (oneof?.type === 'oneof') {
expect(oneof.variants.map(v => v.fieldName)).toEqual(['create_group', 'add_member']);
}
});
it('should coexist with interface and event blocks', () => {
const content = `speck Mix {
event CreateGroup { name: String }
interface AddSteward { new_steward: String }
oneof Payload {
create_group: CreateGroup
add_steward: AddSteward
}
}`;
const ast = parseSpeckContent(content);
const members = ast.specks[0].members;
expect(members.filter(m => m.type === 'event')).toHaveLength(1);
expect(members.filter(m => m.type === 'interface')).toHaveLength(1);
expect(members.filter(m => m.type === 'oneof')).toHaveLength(1);
});
});
// Cycle 58: transition envelope — a proto message wrapping a oneof payload
// plus metadata fields. Federated-meetup's `Transition` message is the
// canonical example: prior_state, payload (oneof), steward_signatures,
// hlc, branch_id, signed_at.
describe('transition blocks (cycle 58)', () => {
it('should parse a minimal transition with regular fields', () => {
const content = `speck One {
transition Transition {
hlc: Bytes
branch_id: Nat
}
}`;
const ast = parseSpeckContent(content);
const t = ast.specks[0].members.find(m => m.type === 'transition');
expect(t).toBeDefined();
if (t?.type === 'transition') {
expect(t.name).toBe('Transition');
expect(t.fields.map(f => f.name)).toEqual(['hlc', 'branch_id']);
expect(t.payloadOneof).toBeUndefined();
expect(t.typeDiscriminator).toBeUndefined();
}
});
it('should recognize payload: <OneofName> and record the reference', () => {
const content = `speck One {
oneof TransitionPayload {
create_group: CreateGroupPayload
}
transition Transition {
type: TransitionType
payload: TransitionPayload
hlc: Bytes
}
}`;
const ast = parseSpeckContent(content);
const t = ast.specks[0].members.find(m => m.type === 'transition');
if (t?.type === 'transition') {
expect(t.payloadOneof).toBe('TransitionPayload');
// payload must NOT appear in fields
expect(t.fields.map(f => f.name)).toEqual(['hlc']);
// type is a discriminator reference, not a regular field
expect(t.typeDiscriminator).toBe('TransitionType');
expect(t.fields.find(f => f.name === 'type')).toBeUndefined();
}
});
it('should tolerate trailing commas and comments', () => {
const content = `speck One {
transition Transition {
// timestamp from the signer
signed_at: Timestamp,
hlc: Bytes;
}
}`;
const ast = parseSpeckContent(content);
const t = ast.specks[0].members.find(m => m.type === 'transition');
if (t?.type === 'transition') {
expect(t.fields.map(f => f.name)).toEqual(['signed_at', 'hlc']);
}
});
});
// Cycle 59: `state as <MessageName> { ... }` to override the
// .proto generator's default `<SpeckName>State` message name.
// This unblocks federated-meetup's proto migration: the consumer
// Go code imports `pb.StateSnapshot`, not `pb.FederatedMeetupState`.
describe('state as <Name> (cycle 59)', () => {
it('should record messageName override on StateNode', () => {
const content = `speck FederatedMeetup {
state as StateSnapshot {
branch_id: Nat,
stewards: StringList
}
}`;
const ast = parseSpeckContent(content);
const state = ast.specks[0].members.find(m => m.type === 'state');
if (state?.type === 'state') {
expect(state.messageName).toBe('StateSnapshot');
expect(state.variables.map(v => v.name)).toEqual(['branch_id', 'stewards']);
}
});
it('should leave messageName undefined when no `as` clause is present', () => {
const content = `speck FederatedMeetup {
state: {
branch_id: Nat
}
}`;
const ast = parseSpeckContent(content);
const state = ast.specks[0].members.find(m => m.type === 'state');
if (state?.type === 'state') {
expect(state.messageName).toBeUndefined();
}
});
});
// Cycle 60: enum interfaces (e.g. CustodyTier) parse as `kind: 'enum'`
// and the .proto generator emits a real `enum X { ... }` block.
describe('enum interfaces (cycle 60)', () => {
it('should classify bare-identifier interfaces as enums', () => {
const content = `speck FederatedMeetup {
interface CustodyTier {
ColdHardware
HotSigning
ThresholdSplit
BackupOnly
Compromised
}
}`;
const ast = parseSpeckContent(content);
const tier = ast.specks[0].members.find(m => m.name === 'CustodyTier');
if (tier?.type === 'interface') {
expect(tier.kind).toBe('enum');
expect(tier.methods.map(m => m.name)).toEqual([
'ColdHardware', 'HotSigning', 'ThresholdSplit', 'BackupOnly', 'Compromised'
]);
}
});
});
// Cycle 61: top-of-file `event_suffix:` directive. When set, the .proto
// generator appends the suffix to every event message name.
describe('event_suffix directive (cycle 61)', () => {
it('should record eventSuffix on the SpeckNode', () => {
const content = `speck FederatedMeetup {
event_suffix: "Payload"
event CreateGroup { name: String }
}`;
const ast = parseSpeckContent(content);
expect(ast.specks[0].eventSuffix).toBe('Payload');
});
it('should leave eventSuffix undefined when not set', () => {
const content = `speck FederatedMeetup {
event CreateGroup { name: String }
}`;
const ast = parseSpeckContent(content);
expect(ast.specks[0].eventSuffix).toBeUndefined();
});
it('should tolerate quoted and bare values', () => {
const a = parseSpeckContent('speck A {\n event_suffix: "Payload"\n}');
const b = parseSpeckContent('speck B {\n event_suffix: Payload\n}');
expect(a.specks[0].eventSuffix).toBe('Payload');
expect(b.specks[0].eventSuffix).toBe('Payload');
});
});
// Cycle 62: inline `//` comments on field declarations were bleeding
// into the type expression. Real federated-meetup had:
// mesh_ip: Bytes // 4 bytes (IPv4 overlay)
// emitted as `Bytes // 4 bytes (IPv4 overlay) mesh_ip = 2;` —
// broken syntax. The fix strips the comment before parsing the type.
describe('inline comment stripping (cycle 62)', () => {
it('should strip inline // comments from event field types', () => {
const content = `speck One {
event InitialMeshPeer {
host_wg_key: Bytes, // 32 bytes
mesh_ip: Bytes // 4 bytes (IPv4 overlay)
}
}`;
const ast = parseSpeckContent(content);
const ev = ast.specks[0].members.find(m => m.name === 'InitialMeshPeer');
if (ev?.type === 'event') {
const meshIp = ev.fields.find(f => f.name === 'mesh_ip');
expect(meshIp?.type.name).toBe('Bytes');
expect(meshIp?.type.name).not.toContain('IPv4');
}
});
it('should strip inline // comments from interface field types', () => {
const content = `speck One {
interface InitialMeshPeer {
host_wg_key: Bytes, // X25519
mesh_ip: Bytes // 4 bytes (IPv4 overlay)
}
}`;
const ast = parseSpeckContent(content);
const iface = ast.specks[0].members.find(m => m.name === 'InitialMeshPeer');
if (iface?.type === 'interface') {
const meshIp = iface.fields.find(f => f.name === 'mesh_ip');
expect(meshIp?.type.name).toBe('Bytes');
expect(meshIp?.type.name).not.toContain('IPv4');
}
});
});
// Cycle 63: 'state as <Name> { ... }' must dispatch through the
// same parseStateBlockMultiline path as 'state: { ... }'. Cycle 59
// added the parser-side extraction of the `as` clause; this test
// confirms the block-dispatcher also routes the new header form.
describe('state as <Name> dispatch (cycle 63)', () => {
it('should dispatch state as StateSnapshot to parseStateBlockMultiline', () => {
const content = `speck FederatedMeetup {
state as StateSnapshot {
branch_id: Nat
}
}`;
const ast = parseSpeckContent(content);
const state = ast.specks[0].members.find(m => m.type === 'state');
if (state?.type === 'state') {
expect(state.messageName).toBe('StateSnapshot');
expect(state.variables.map(v => v.name)).toEqual(['branch_id']);
}
});
});
// Cycle 65: default-value syntax `= <expr>` on event fields was
// bleeding into the type expression. Real federated-meetup has
// initial_mesh_peers: List<InitialMeshPeer> = []
// emitted as `List<InitialMeshPeer> = [] initial_mesh_peers = 1;`
// which protoc rejects. The fix strips `= <expr>` from the type
// before parsing (the default is a Speckl semantic; the .proto wire
// format has no place for it).
describe('default value stripping (cycle 65)', () => {
it('should strip `= []` from event field type expressions', () => {
const content = `speck One {
event CreateGroup {
initial_mesh_peers: List<InitialMeshPeer> = []
}
}`;
const ast = parseSpeckContent(content);
const ev = ast.specks[0].members.find(m => m.name === 'CreateGroup');
if (ev?.type === 'event') {
const f = ev.fields[0];
expect(f.name).toBe('initial_mesh_peers');
expect(f.type.type).toBe('list');
expect(f.type.elementType?.name).toBe('InitialMeshPeer');
// The full type string should not contain '= []' anywhere — verify
// via the elementType name and the parent's name (both undefined
// for List, so we check the parser did not preserve the default
// in any visible field).
const asAny = f.type as any;
expect(JSON.stringify(asAny)).not.toContain('= []');
}
});
});
// Cycle 73: List<T> fields in interface records must be preserved in the AST.
// Previously the generator emitted empty type for these fields because
// it called speckTypeToProto(f.type.name) which returns '' when .name is
// undefined (List/Map/Set nodes use .elementType/.keyType/.valueType,
// not .name). The generator was fixed, but this test locks in the parser
// behavior so a regression is caught early.
describe('List<T> in interface record fields (cycle 73)', () => {
it('should parse List<T> fields in interface records', () => {
const content = `speck One {
interface Container {
items: List<Ticket>,
names: List<String>
}
interface Ticket {
id: String
}
}`;
const ast = parseSpeckContent(content);
const iface = ast.specks[0].members.find(m => m.name === 'Container');
expect(iface).toBeDefined();
if (iface?.type === 'interface') {
expect(iface.fields).toBeDefined();
expect(iface.fields.length).toBe(2);
expect(iface.fields[0].name).toBe('items');
expect(iface.fields[0].type.type).toBe('list');
expect(iface.fields[0].type.elementType?.name).toBe('Ticket');
expect(iface.fields[1].name).toBe('names');
expect(iface.fields[1].type.type).toBe('list');
expect(iface.fields[1].type.elementType?.name).toBe('String');
}
});
});
});

View file

@ -1,10 +1,13 @@
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { parseSpeckFile } from '../parser.js';
import { generateProvenanceGraph } from '../generators/provenance.js';
import { generateCycloneDX } from '../generators/cyclonedx.js';
import { generateSPDX } from '../generators/spdx.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
describe('Compiler E2E', () => {
let ast;
const testOutputDir = './test-output';
@ -15,7 +18,7 @@ describe('Compiler E2E', () => {
}
});
it('should parse RetryHandler.speck', () => {
const specPath = path.join(process.cwd(), '..', 'examples', 'RetryHandler.speck');
const specPath = path.join(__dirname, '..', '..', '..', 'examples', 'RetryHandler.speck');
ast = parseSpeckFile(specPath);
expect(ast).toBeDefined();
expect(ast.specks).toBeDefined();
@ -54,4 +57,164 @@ describe('Compiler E2E', () => {
expect(spdx.spdx).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Regression tests for Z3 / TypeScript / Rust expression translation fixes.
//
// These tests compile the FederatedMeetupProduct spec against each backend
// (z3, typescript, rust) and assert that the generated output contains the
// expected translated patterns. They guard against regressions in:
// - implies() argument preservation (Z3 + TS + Rust)
// - multi-level field access flattening (Z3)
// - len()/length() normalization (Z3)
// - nil → speckl_nil translation (Z3)
// - != → (distinct X Y) wrapping (Z3)
// - constraint checks emitting real every() / iter().all() (TS + Rust)
// - 'in' operator → .has() in verify expressions (TS)
// - multiple verify blocks all emitted (memberKey dedup fix) (TS + Rust)
// ---------------------------------------------------------------------------
const SPEC_PATH = '/home/sscoble/projects/federated-meetup/specs/FederatedMeetupProduct.speckdl';
const OUT_DIR = './out'; // CLI writes to ./out relative to compiler cwd
const COMPILER_DIR = '/home/sscoble/speckl/compiler';
describe('Z3 / TS / Rust expression translation regressions', () => {
let z3Output;
let tsOutput;
let rustOutput;
beforeAll(() => {
// Clean out directory to avoid stale output
execSync(`rm -rf ${OUT_DIR}`, { cwd: COMPILER_DIR });
execSync(`mkdir -p ${OUT_DIR}`, { cwd: COMPILER_DIR });
// Compile to all three targets
execSync(`npx tsx src/index.ts ${SPEC_PATH} --target z3 --output ${OUT_DIR}`, { cwd: COMPILER_DIR, stdio: 'pipe' });
z3Output = fs.readFileSync(path.join(COMPILER_DIR, OUT_DIR, 'FederatedMeetupProduct.smt2'), 'utf-8');
execSync(`npx tsx src/index.ts ${SPEC_PATH} --target typescript --output ${OUT_DIR}`, { cwd: COMPILER_DIR, stdio: 'pipe' });
tsOutput = fs.readFileSync(path.join(COMPILER_DIR, OUT_DIR, 'FederatedMeetupProduct.ts'), 'utf-8');
execSync(`npx tsx src/index.ts ${SPEC_PATH} --target rust --output ${OUT_DIR}`, { cwd: COMPILER_DIR, stdio: 'pipe' });
rustOutput = fs.readFileSync(path.join(COMPILER_DIR, OUT_DIR, 'FederatedMeetupProduct', 'src', 'lib.rs'), 'utf-8');
}, 120_000);
// 1. Z3: implies() preserves both arguments → (=> P Q) not (=> true true)
it('Z3: implies() emits (=> P Q) preserving both arguments', () => {
expect(z3Output).toContain('(=>');
expect(z3Output).toContain('(=> (= o_status Refunded) (distinct o_refunded_at speckl_nil))');
// Ensure we did NOT regress to (=> true true)
expect(z3Output).not.toContain('(=> true true)');
expect(z3Output).not.toContain('(=> true');
});
// 2. Z3: multi-level field access m.price.amount → m_price_amount
it('Z3: multi-level field access m.price.amount flattens to m_price_amount', () => {
expect(z3Output).toContain('m_price_amount');
// Ensure we did NOT regress to single-level m_price or bare m.price.amount
expect(z3Output).not.toContain('m.price.amount');
// The constraint m_price_amount >= 0 should be present
expect(z3Output).toContain('(>= m_price_amount 0)');
});
// 3. Z3: len() handled same as length() → speckl_len_Int
it('Z3: len() is translated to speckl_len_Int (same as length())', () => {
// The spec uses len(m.price.currency) — should become speckl_len_Int
expect(z3Output).toContain('(speckl_len_Int m_price_currency)');
// Ensure we did NOT regress to bare len() or length()
expect(z3Output).not.toMatch(/\blen\s*\(/);
expect(z3Output).not.toMatch(/\blength\s*\(/);
});
// 4. Z3: nil translated to speckl_nil
it('Z3: nil is translated to speckl_nil sentinel', () => {
expect(z3Output).toContain('(declare-const speckl_nil Int)');
expect(z3Output).toContain('speckl_nil');
// Ensure we did NOT regress to bare nil / null
expect(z3Output).not.toMatch(/\bnil\b/);
});
// 5. Z3: != produces (distinct X Y) not bare 'distinct'
it('Z3: != produces wrapped (distinct X Y) not bare distinct', () => {
// The constraint g.hosting_tier != Free should emit (distinct g_hosting_tier Free)
expect(z3Output).toContain('(distinct g_hosting_tier Free)');
// Ensure we did NOT regress to bare 'distinct g_hosting_tier Free' without parens
// (the fix wraps the operator in parens)
// A bare distinct would look like "g_hosting_tier distinct Free" without surrounding parens
expect(z3Output).not.toMatch(/[^(]\w+\s+distinct\s+\w+/);
});
// 6. TS: implies(p,q) → (!p || q) not (p || q)
it('TS: implies(p, q) translates to (!p || q), not (p || q)', () => {
// constraint 6: implies(o.status == Refunded, o.refunded_at != nil)
// Should become (!(o.status === ...) || (o.refunded_at !== ...))
expect(tsOutput).toContain('(!(');
expect(tsOutput).toContain('||');
// The specific pattern: (!(o.status === ... || (o.refunded_at !== nil))
expect(tsOutput).toMatch(/!\(o\.status === .* \|\| \(o\.refunded_at !== /);
// Ensure the CODE (not comments) has negation on p.
// A regression would have `return this.orders.every((o: any) => (o.status ===`
// (without the `!(` negation before `o.status`).
// Strip comments to avoid matching the original spec text in comments.
const tsNoComments = tsOutput.replace(/^\s*\/\/.*$/gm, '');
// Positive: the every() body starts with (!(o.status — the (! is the negation
expect(tsNoComments).toMatch(/every\(\(o:\s*any\)\s*=>\s*\(!\(o\.status/);
// Negative: a regression would have every((o: any) => (o.status without the ! negation
expect(tsNoComments).not.toMatch(/every\(\(o:\s*any\)\s*=>\s*\(o\.status/);
});
// 7. TS: constraint checks emit real every() not return true
it('TS: constraint checks emit real .every() calls, not return true', () => {
expect(tsOutput).toContain('.every(');
// Multiple constraints use every() — at least 8 constraints
const everyCount = (tsOutput.match(/\.every\(/g) || []).length;
expect(everyCount).toBeGreaterThanOrEqual(8);
// Ensure we did NOT regress to `return true` stubs
// A stub would have checkConstraintN() { return true; }
expect(tsOutput).not.toMatch(/checkConstraint\d+\(\):\s*boolean\s*\{\s*return\s*true\s*;\s*\}/);
});
// 8. TS: 'in' operator in verify expressions → .has() not raw 'in'
it('TS: "in" operator in verify expressions translates to .has(), not raw in', () => {
// V1: always(implies(event == PurchaseTicket, ticket_id in tickets))
// Should become tickets.has(ticket_id)
expect(tsOutput).toContain('.has(');
expect(tsOutput).toContain('tickets.has(ticket_id)');
// Ensure we did NOT regress to raw 'in' operator (JS `in` checks property keys, not membership).
// A regression would look like `ticket_id in tickets` in CODE (not comments).
// Strip comments to avoid matching the original spec text in comments.
const tsNoComments = tsOutput.replace(/^\s*\/\/.*$/gm, '');
expect(tsNoComments).not.toMatch(/\bticket_id\s+in\s+tickets\b/);
});
// 9. TS/Rust: multiple verify blocks all emitted (memberKey dedup fix)
it('TS: all 6 verify blocks are emitted (memberKey dedup fix)', () => {
// The spec has 6 verify blocks; previously dedup killed all but the first
expect(tsOutput).toContain('verify1');
expect(tsOutput).toContain('verify2');
expect(tsOutput).toContain('verify3');
expect(tsOutput).toContain('verify4');
expect(tsOutput).toContain('verify5');
expect(tsOutput).toContain('verify6');
expect(tsOutput).not.toContain('verify7'); // only 6 verify blocks in spec
});
it('Rust: all 6 verify blocks are emitted (memberKey dedup fix)', () => {
expect(rustOutput).toContain('verify_1');
expect(rustOutput).toContain('verify_2');
expect(rustOutput).toContain('verify_3');
expect(rustOutput).toContain('verify_4');
expect(rustOutput).toContain('verify_5');
expect(rustOutput).toContain('verify_6');
expect(rustOutput).not.toContain('verify_7');
});
// 10. Rust: constraint checks emit iter().all() not return true
it('Rust: constraint checks emit iter().all(), not return true stubs', () => {
expect(rustOutput).toContain('.iter().all(');
// Multiple constraints use iter().all() — at least 8 constraints
const iterAllCount = (rustOutput.match(/\.iter\(\)\.all\(/g) || []).length;
expect(iterAllCount).toBeGreaterThanOrEqual(8);
// Ensure we did NOT regress to `return true` stubs
expect(rustOutput).not.toMatch(/check_invariant_\d+\(&self\)\s*->\s*bool\s*\{\s*true\s*\}/);
});
// 11. Rust: implies() → (!p || q) in Rust
it('Rust: implies(p, q) translates to (!p || q) in Rust', () => {
// constraint 6: implies(o.status == Refunded, o.refunded_at != nil)
// Should become (!(o.status == ... || (o.refunded_at != ...))
expect(rustOutput).toContain('(!(');
expect(rustOutput).toMatch(/!\(o\.status == .* \|\| \(o\.refunded_at != /);
// Ensure the CODE (not comments) has negation on p.
// A regression would have `self.orders.iter().all(|o| (o.status ==`
// (without the `!(` negation before `o.status`).
// Strip comments to avoid matching the original spec text in comments.
const rustNoComments = rustOutput.replace(/^\s*\/\/.*$/gm, '');
// Positive: the iter().all() body starts with (!(o.status — the (! is the negation
expect(rustNoComments).toMatch(/iter\(\)\.all\(\|o\|\s*\(!\(o\.status/);
// Negative: a regression would have iter().all(|o| (o.status without the ! negation
expect(rustNoComments).not.toMatch(/iter\(\)\.all\(\|o\|\s*\(o\.status/);
});
});
//# sourceMappingURL=compiler.test.js.map

File diff suppressed because one or more lines are too long

View file

@ -6,6 +6,7 @@ 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 { generateProtobuf } from './generators/protobuf.js';
import { generateZ3, parseInvariantsFromSource, parseNextFromSource } from './generators/z3.js';
import { generateRust } from './generators/rust.js';
import fs from 'fs';
@ -41,7 +42,7 @@ async function main() {
alias: 't',
describe: 'Compilation target',
type: 'string',
choices: ['typescript', 'z3', 'rust', 'all'],
choices: ['typescript', 'z3', 'rust', 'protobuf', 'all'],
default: 'typescript',
})
.option('verify-depth', {
@ -84,6 +85,14 @@ async function main() {
console.log('\nGenerating Rust state machine...');
generateRust(ast, options.outputDir);
}
// Generate protobuf schema if target is protobuf or all
// This is the load-bearing backend for the "no glue code" claim:
// the emitted .proto is the canonical wire format. Run `buf generate`
// on the output to produce Go/TS/Rust/C/Python/Java bindings.
if (options.target === 'protobuf' || options.target === 'all') {
console.log('\nGenerating protobuf schema (.proto)...');
generateProtobuf(ast, options.outputDir);
}
// Always generate provenance (it's the core output) if not z3-only or rust-only
if (options.target !== 'z3' && options.target !== 'rust') {
console.log('\nGenerating provenance graph (PROV-O JSON-LD)...');

View file

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

View file

@ -2,6 +2,12 @@ export interface SpeckNode {
type: 'speck';
name: string;
members: MemberNode[];
version?: string;
author?: string;
license?: string;
protoPackage?: string;
goPackage?: string;
eventSuffix?: string;
}
export interface ImportNode {
type: 'import';
@ -29,10 +35,28 @@ export interface VerifyNode {
temporalExpr: string;
depth?: number;
}
export interface InterfaceField {
name: string;
type: TypeExpr;
}
export interface RpcMethod {
name: string;
requestType: string;
responseType: string;
clientStreaming?: boolean;
serverStreaming?: boolean;
}
export interface ServiceNode {
type: 'service';
name: string;
rpcs: RpcMethod[];
}
export interface InterfaceNode {
type: 'interface';
name: string;
kind: 'enum' | 'record' | 'service' | 'mixed';
methods: MethodSignature[];
fields: InterfaceField[];
}
export interface MethodSignature {
name: string;
@ -50,6 +74,26 @@ export interface EventNode {
type: TypeExpr;
}[];
}
export interface OneofNode {
type: 'oneof';
name: string;
variants: OneofVariant[];
}
export interface OneofVariant {
fieldName: string;
typeName: string;
}
export interface TransitionNode {
type: 'transition';
name: string;
fields: TransitionField[];
payloadOneof?: string;
typeDiscriminator?: string;
}
export interface TransitionField {
name: string;
type: TypeExpr;
}
export interface ProvenanceNode {
type: 'provenance';
clauses: ProvenanceClause[];
@ -103,6 +147,7 @@ export interface BOMNode {
export interface StateNode {
type: 'state';
variables: StateVar[];
messageName?: string;
}
export interface StateVar {
name: string;
@ -156,7 +201,7 @@ export type ActionStatement = {
type: 'ifblock';
raw: string;
};
export type MemberNode = ImportNode | InputNode | OutputNode | ConstraintNode | VerifyNode | InterfaceNode | EventNode | ProvenanceNode | ReviewNode | DerivesNode | SatisfiesNode | AuthorNode | SourceNode | BOMNode | StateNode | InitNode | ActionNode;
export type MemberNode = ImportNode | InputNode | OutputNode | ConstraintNode | VerifyNode | InterfaceNode | EventNode | OneofNode | TransitionNode | ProvenanceNode | ReviewNode | DerivesNode | SatisfiesNode | AuthorNode | SourceNode | BOMNode | StateNode | InitNode | ActionNode | ServiceNode;
export interface TypeExpr {
type: 'primitive' | 'record' | 'list' | 'set' | 'map' | 'ident';
name?: string;

File diff suppressed because one or more lines are too long

View file

@ -51,6 +51,13 @@ function parseSpeck(lines, startIndex) {
return null;
const name = headerMatch[1];
const members = [];
// Cycle 73: refactored from static-field hack to local-object pattern.
// Previously, proto_package / go_package / event_suffix were stored
// on (parseSpeck as any)._protoPackage etc — static function fields
// that leaked between parse calls. Now collected in a local metadata
// object and spread into the return value. No reset needed because
// the object is local to this invocation.
const metadata = {};
let i = startIndex + 1;
let braceCount = 1;
while (i < lines.length && braceCount > 0) {
@ -60,12 +67,35 @@ function parseSpeck(lines, startIndex) {
i++;
continue;
}
// ── Top-of-file protobuf metadata directives ────────────────
// These attach to the SpeckNode and are NOT emitted as members.
// Format: `key: value` (string values may be quoted or bare).
// `author:`, `version:`, `license:` continue to be parsed as members
// (AuthorNode, etc.) for backward compatibility with the existing
// PROV-O / CycloneDX generators.
const metaMatch = line.match(/^(proto_package|go_package|event_suffix)\s*:\s*(.+)$/);
if (metaMatch) {
// Strip surrounding quotes if present
const value = metaMatch[2].trim().replace(/^['"]|['"]$/g, '');
if (metaMatch[1] === 'proto_package') {
metadata.protoPackage = value;
}
else if (metaMatch[1] === 'go_package') {
metadata.goPackage = value;
}
else if (metaMatch[1] === 'event_suffix') {
metadata.eventSuffix = value;
}
i++;
continue;
}
// Count braces on this line first
const openCount = (rawLine.match(/{/g) || []).length;
const closeCount = (rawLine.match(/}/g) || []).length;
// Check for block-opening members (those that open braces on the same line)
const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface ',
'state {', 'init {', 'verify ', 'constraint '];
'state {', 'init {', 'verify ', 'constraint ', 'input:', 'output:', 'service ', 'oneof ', 'transition ',
'state as '];
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
if (isBlockStarter && openCount > 0) {
// Multi-line block: parse it and skip to its end
@ -84,8 +114,8 @@ function parseSpeck(lines, startIndex) {
continue;
}
// Handle constraint/verify blocks that may span multiple lines without braces
// (e.g. constraint Name: forall r in [0..4]:)
if (line.startsWith('constraint ') || line.startsWith('verify ')) {
// (e.g. constraint Name: forall r in [0..4]:, verify: always(implies(\n ...\n)))
if (line.startsWith('constraint ') || line.startsWith('verify ') || line.startsWith('verify:')) {
const member = parseMultiLineConstraintVerify(lines, i);
if (member) {
members.push(member);
@ -98,7 +128,8 @@ function parseSpeck(lines, startIndex) {
nextLine.startsWith('state') || nextLine.startsWith('init') ||
nextLine.startsWith('action ') || nextLine.startsWith('event ') ||
nextLine.startsWith('interface ') || nextLine.startsWith('provenance ') ||
nextLine.startsWith('bom ') || nextLine.startsWith('}') ||
nextLine.startsWith('service ') || nextLine.startsWith('bom ') ||
nextLine.startsWith('}') ||
nextLine.startsWith('next:') || nextLine.startsWith('invariant ')) {
break;
}
@ -120,7 +151,10 @@ function parseSpeck(lines, startIndex) {
}
i++;
}
return { type: 'speck', name, members };
return { type: 'speck', name, members,
protoPackage: metadata.protoPackage,
goPackage: metadata.goPackage,
eventSuffix: metadata.eventSuffix };
}
function findBlockEnd(lines, startIndex, startBraceCount) {
let braceCount = startBraceCount;
@ -169,6 +203,12 @@ function parseMemberBlock(lines, startIndex) {
if (firstLine.startsWith('state {') || firstLine === 'state') {
return parseStateBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('state as ')) {
// Cycle 63: `state as <MessageName> { ... }` form must dispatch
// through the same state-block parser so the `as` override on the
// first line is captured.
return parseStateBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('init:')) {
return parseInitBlockMultiline(lines, startIndex, startBraceCount);
}
@ -184,6 +224,15 @@ function parseMemberBlock(lines, startIndex) {
if (firstLine.startsWith('interface ')) {
return parseInterfaceBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('oneof ')) {
return parseOneofBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('transition ')) {
return parseTransitionBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('service ')) {
return parseServiceBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('provenance ')) {
return { type: 'provenance', clauses: [] };
}
@ -196,8 +245,64 @@ function parseMemberBlock(lines, startIndex) {
if (firstLine.startsWith('constraint ')) {
return parseConstraintBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('input:') || firstLine.startsWith('output:')) {
return parseInputOutputBlock(lines, startIndex, startBraceCount);
}
return null;
}
/**
* Parse a multi-line `input: { ... }` or `output: { ... }` block.
*
* The first line opens with `input: {` or `output: {` and the matching `}`
* may be many lines later (with field declarations on intermediate lines).
* We collect all lines up to the matching `}` (brace-counted) and join the
* inner content into a single record string that `parseTypeExpr` already
* knows how to handle.
*/
function parseInputOutputBlock(lines, startIndex, startBraceCount) {
const header = lines[startIndex].trim();
const isInput = header.startsWith('input:');
const kind = isInput ? 'input' : 'output';
// Collect inner content lines, brace-counting across the block.
// startBraceCount is the brace count on the header line (1 for `input: {`).
// We need to find the line where braceCount returns to 0 (i.e. the closing `}`).
const innerLines = [];
let braceCount = 0;
let i = startIndex;
// First pass: extract inner content from header line (everything after the first `{`).
const headerLine = lines[startIndex].trim();
const headerNoComment = headerLine.replace(/\/\/.*$/, '').trim();
const openIdx = headerNoComment.indexOf('{');
if (openIdx >= 0) {
const after = headerNoComment.substring(openIdx + 1).trim();
if (after)
innerLines.push(after);
}
braceCount = 1; // we're inside one `{` from the header
i = startIndex + 1;
while (i < lines.length && braceCount > 0) {
const raw = lines[i];
const stripped = raw.trim();
// Strip line comments (//...) — they cannot be inside a type expression.
const noComment = stripped.replace(/\/\/.*$/, '').trim();
for (const ch of noComment) {
if (ch === '{')
braceCount++;
else if (ch === '}')
braceCount--;
}
if (braceCount > 0 && noComment) {
// Inside the block — keep content (skip the closing `}` line itself).
innerLines.push(noComment);
}
i++;
}
// Join inner content with newlines; `parseTypeExpr`'s record branch
// splits on commas and matches `name: type` pairs.
const recordExpr = '{ ' + innerLines.join(' ') + ' }';
const typeExpr = parseTypeExpr(recordExpr);
return { type: kind, typeExpr };
}
function parseMember(line) {
// input:
if (line.startsWith('input:')) {
@ -237,6 +342,14 @@ function parseMember(line) {
if (line.startsWith('event ')) {
return parseEvent(line);
}
// oneof:
if (line.startsWith('oneof ')) {
return parseOneof(line);
}
// transition:
if (line.startsWith('transition ')) {
return parseTransition(line);
}
// provenance {
if (line.startsWith('provenance ')) {
return { type: 'provenance', clauses: [] };
@ -513,11 +626,14 @@ function parseInterface(line) {
throw new Error(`Failed to parse interface: ${line}`);
}
const name = match[1];
// This is a simplified parser - in a real implementation, we'd need to handle multi-line interfaces
// Header-only; parseInterfaceBlockMultiline fills in methods/fields/kind
// by scanning the lines inside the { ... } block.
return {
type: 'interface',
name,
kind: 'mixed',
methods: [],
fields: [],
};
}
function parseEvent(line) {
@ -533,6 +649,32 @@ function parseEvent(line) {
fields: [],
};
}
// Cycle 57: parse `oneof Name {` (header only; parseOneofBlockMultiline fills variants).
function parseOneof(line) {
// oneof Name {
const match = line.match(/^oneof\s+(\w+)\s*{/);
if (!match) {
throw new Error(`Failed to parse oneof: ${line}`);
}
return {
type: 'oneof',
name: match[1],
variants: [],
};
}
// Cycle 58: parse `transition Name {` (header only; parseTransitionBlockMultiline fills fields + payload-oneof).
function parseTransition(line) {
// transition Name {
const match = line.match(/^transition\s+(\w+)\s*{/);
if (!match) {
throw new Error(`Failed to parse transition: ${line}`);
}
return {
type: 'transition',
name: match[1],
fields: [],
};
}
function parseProvenanceBlock(lines, line) {
// This is a simplified parser - in a real implementation, we'd parse the full block
return {
@ -610,6 +752,12 @@ function parseSource(line) {
};
}
function parseStateBlockMultiline(lines, startIndex, startBraceCount) {
// Cycle 59: support `state as <MessageName> { ... }` to override the
// generated .proto message name. Default (no `as`) keeps the existing
// `message <SpeckName>State { ... }` convention.
const headerLine = lines[startIndex].trim();
const asMatch = headerLine.match(/^state\s+as\s+(\w+)\s*{/);
const messageName = asMatch ? asMatch[1] : undefined;
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('/*'));
// State blocks separate fields with commas or newlines/semicolons.
@ -629,7 +777,10 @@ function parseStateBlockMultiline(lines, startIndex, startBraceCount) {
}
return acc + ' ' + line;
}, '');
return parseStateBlock(inner);
const result = parseStateBlock(inner);
if (messageName !== undefined)
result.messageName = messageName;
return result;
}
function parseInitBlockMultiline(lines, startIndex, startBraceCount) {
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
@ -813,7 +964,24 @@ function parseEventBlockMultiline(lines, startIndex, 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*(.+?),?$/);
// Cycle 62: strip inline `//` line-comments from the type expression
// before parsing. Without this, an event field like
// mesh_ip: Bytes // 4 bytes (IPv4 overlay)
// produces a TypeExpr whose name includes the comment text, which
// the .proto generator emits verbatim:
// bytes // 4 bytes (IPv4 overlay) mesh_ip = 2;
// (broken syntax — comment is in the type position).
const noComment = line.replace(/\/\/.*$/, '').trim();
// Cycle 65: strip default-value suffix `= <expr>` from the type
// expression. The federated-meetup speckdl has fields like
// initial_mesh_peers: List<InitialMeshPeer> = []
// where the default is meaningful in Speckl semantics (a state-
// machine init) but the .proto wire format doesn't carry a
// default — it's a generator concern. Without this, the type
// expression was emitted as `List<InitialMeshPeer> = []` and
// protoc rejects the resulting syntax.
const noDefault = noComment.replace(/\s*=\s*[^,]+$/, '').trim();
const match = noDefault.match(/^(\w+)\s*:\s*(.+?),?$/);
if (match) {
fields.push({ name: match[1], type: parseTypeExpr(match[2].replace(/,$/, '').trim()) });
}
@ -826,12 +994,20 @@ function parseInterfaceBlockMultiline(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 methods = [];
const fields = [];
for (const line of innerLines) {
// name-only: enum-style values (e.g., "Idle", "Idle, Parsing" comma-separated)
// Split on comma and take each trimmed token
const tokens = line.split(',').map(s => s.trim()).filter(Boolean);
// Cycle 62: strip inline `//` line-comments from each line before
// token-splitting. Without this, a field like
// mesh_ip: Bytes // 4 bytes (IPv4 overlay)
// has its comment text bleed into the parsed type expression,
// which the .proto generator emits verbatim — broken syntax.
const noComment = line.replace(/\/\/.*$/, '').trim();
if (!noComment)
continue;
// Split on comma so multi-variant lines like "Idle, Parsing, Done" all parse
const tokens = noComment.split(',').map(s => s.trim()).filter(Boolean);
for (const token of tokens) {
// Check if it’s a name(params) -> returnType method signature
// Check 1: method signature form: name(params) -> returnType
const methodMatch = token.match(/^(\w+)\s*\(([^)]*)\)\s*->\s*(.+?),?$/);
if (methodMatch) {
const name = methodMatch[1];
@ -848,14 +1024,135 @@ function parseInterfaceBlockMultiline(lines, startIndex, startBraceCount) {
}
}
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
continue;
}
else if (/^\w+$/.test(token)) {
// Name-only (enum-style) — treat as a method with no params and void return
// Check 2: field declaration form: field_name: TypeName
// Lowercase-starting identifier followed by ':' — record-style interface
const fieldMatch = token.match(/^(\w+)\s*:\s*(.+)$/);
if (fieldMatch && /^[a-z_]/.test(fieldMatch[1])) {
fields.push({ name: fieldMatch[1], type: parseTypeExpr(fieldMatch[2].trim()) });
continue;
}
// Check 3: bare identifier (enum variant) — name only, no params
if (/^\w+$/.test(token)) {
methods.push({ name: token, params: [], returnType: parseTypeExpr('Void') });
}
}
}
return { ...header, methods };
// Determine the kind based on what we collected
let kind = 'mixed';
if (fields.length > 0 && methods.length === 0) {
kind = 'record';
}
else if (methods.length > 0 && fields.length === 0) {
// Distinguish enum (all methods have empty params and Void return) from service
const allEnumLike = methods.every(m => m.params.length === 0 && m.returnType.name === 'Void');
kind = allEnumLike ? 'enum' : 'service';
}
return { ...header, kind, methods, fields };
}
// Cycle 57: parse a `oneof Name { ... }` block. Each non-empty, non-comment
// inner line is `field_name: TypeName`. The wire field_name follows
// lowerCamelCase convention (TypeScript/Connect) and is emitted to .proto
// as snake_case by the generator.
function parseOneofBlockMultiline(lines, startIndex, startBraceCount) {
const firstLine = lines[startIndex].trim();
const header = parseOneof(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 variants = [];
for (const line of innerLines) {
// Strip trailing comma or semicolon (proto-style "create_group: CreateGroup," is valid).
const trimmed = line.replace(/[,;]\s*$/, '');
const m = trimmed.match(/^(\w+)\s*:\s*(\w+)\s*$/);
if (m) {
variants.push({ fieldName: m[1], typeName: m[2] });
}
}
return { ...header, variants };
}
// Cycle 58: parse a `transition Name { ... }` block. Each non-empty,
// non-comment inner line is `field_name: TypeName`. Two field names
// have special meaning:
// - `payload: <OneofName>` — references a sibling `oneof` block;
// the proto generator will emit the variants inline as
// `oneof payload { ... }` inside the message body
// - `type: <EnumName>` — the discriminator enum for the variants
// Other fields are emitted as ordinary proto message fields.
function parseTransitionBlockMultiline(lines, startIndex, startBraceCount) {
const firstLine = lines[startIndex].trim();
const header = parseTransition(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 = [];
let payloadOneof;
let typeDiscriminator;
for (const line of innerLines) {
const trimmed = line.replace(/[,;]\s*$/, '');
const m = trimmed.match(/^(\w+)\s*:\s*(.+)$/);
if (!m)
continue;
const fieldName = m[1];
const typeStr = m[2].trim();
if (fieldName === 'payload') {
// The type is the name of a sibling oneof block.
payloadOneof = typeStr;
// Don't add a regular field for payload — it becomes the oneof.
continue;
}
if (fieldName === 'type') {
// The type is the name of a discriminator enum.
typeDiscriminator = typeStr;
// Don't add a regular field for type — it's a special discriminator.
continue;
}
fields.push({ name: fieldName, type: parseTypeExpr(typeStr) });
}
const result = { ...header, fields };
if (payloadOneof !== undefined)
result.payloadOneof = payloadOneof;
if (typeDiscriminator !== undefined)
result.typeDiscriminator = typeDiscriminator;
return result;
}
function parseServiceBlockMultiline(lines, startIndex, startBraceCount) {
// service Name {
const firstLine = lines[startIndex].trim();
const match = firstLine.match(/^service\s+(\w+)\s*{?/);
const name = match ? match[1] : 'UnnamedService';
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 rpcs = [];
for (const line of innerLines) {
// rpc MethodName(RequestType) returns (ResponseType);
// Also support streaming: rpc M(stream R) returns (S); or rpc M(R) returns (stream S);
const rpcMatch = line.match(/^rpc\s+(\w+)\s*\(\s*(stream\s+)?(\w+)\s*\)\s*returns\s*\(\s*(stream\s+)?(\w+)\s*\)\s*;?$/);
if (rpcMatch) {
const methodName = rpcMatch[1];
const clientStream = !!rpcMatch[2];
const requestType = rpcMatch[3];
const serverStream = !!rpcMatch[4];
const responseType = rpcMatch[5];
rpcs.push({
name: methodName,
requestType,
responseType,
clientStreaming: clientStream,
serverStreaming: serverStream,
});
}
else {
// Cycle 73: error on unrecognized lines inside service blocks.
// Previously, a typo like `rc_purchase_ticket(...)` (missing the `p`
// in `rpc`) was silently swallowed — the line didn't match the
// rpc regex, so it was skipped without error. Silent data loss in
// a spec compiler is unacceptable; the whole point is that the
// spec is the source of truth.
throw new Error(`Parse error in service block: unrecognized line "${line}". ` +
`Expected: rpc MethodName(RequestType) returns (ResponseType);`);
}
}
return { type: 'service', name, rpcs };
}
// --- Multi-line constraint/verify parsing (no braces needed) ---
/**
@ -873,11 +1170,128 @@ function parseMultiLineConstraintVerify(lines, startIndex) {
if (line.startsWith('verify ')) {
return parseVerifyAnyForm(lines, startIndex);
}
// Multi-line `verify:` form: verify: always(implies(\n ...\n))
// The body opens parens that span multiple lines; collect continuation
// lines until parens balance to zero, then return the joined string as
// the temporalExpr. speckl#54.
if (line.startsWith('verify:')) {
return parseVerifyColonBlock(lines, startIndex);
}
if (line.startsWith('constraint ')) {
return parseConstraintAnyForm(lines, startIndex);
}
return null;
}
/**
* Parse the `verify: <expr>` form (speckl#54) where the body can span
* multiple lines:
*
* verify: always(implies(
* cond1,
* cond2
* ))
*
* The whole expression (the part after `verify:`) is collected by
* appending subsequent non-empty, non-keyword-starting lines until
* parens/brackets/braces balance to zero. The joined string is stored
* in `temporalExpr` so the downstream LTL translator (typescript-state-machine
* verifyN()) sees a well-formed formula.
*
* The `name` is derived from the LTL operator's argument when it matches
* `always(Name)` / `eventually(Name)`. When the body is a richer expression
* (e.g. `always(implies(...))`) we fall back to `unnamed` — the generator
* still emits a working verifyN() that uses the full expression.
*/
function parseVerifyColonBlock(lines, startIndex) {
const firstLine = lines[startIndex].trim();
// Strip the `verify:` prefix.
const head = firstLine.substring(firstLine.indexOf(':') + 1).trim();
if (!head)
return null;
// If the head is already balanced on its own (single-line verify: expr),
// just return it directly — no continuation needed.
if (isExpressionComplete(head)) {
const nameMatch = head.match(/^(?:always|Always|eventually|Eventually)\s*\(\s*(\w+)\s*\)$/);
return {
type: 'verify',
name: nameMatch ? nameMatch[1] : 'unnamed',
temporalExpr: head,
};
}
// Multi-line: collect continuation lines until parens balance. The same
// break-condition list used in the parseSpeck skip-loop applies (don't
// swallow the next member's keyword). We additionally stop at any
// standalone `}` so a verify inside a brace-block still terminates.
const parts = [head];
let parens = 0, brackets = 0, braces = 0;
for (const ch of head) {
if (ch === '(')
parens++;
else if (ch === ')')
parens--;
else if (ch === '[')
brackets++;
else if (ch === ']')
brackets--;
else if (ch === '{')
braces++;
else if (ch === '}')
braces--;
}
let lastConsumed = startIndex;
for (let j = startIndex + 1; j < lines.length; j++) {
const raw = lines[j];
const nl = raw.trim();
if (!nl) {
// Empty line still updates counters (none) and advances lastConsumed
lastConsumed = j;
continue;
}
if (nl.startsWith('//') || nl.startsWith('/*')) {
// Skip standalone comments but treat them as part of the block
// (the LTL translator doesn't care about comments in temporalExpr).
lastConsumed = j;
continue;
}
// Stop at a new top-level member keyword — that's the next member.
if (nl.startsWith('constraint ') || nl.startsWith('verify ') ||
nl.startsWith('constraint:') || nl.startsWith('verify:') ||
nl.startsWith('state') || nl.startsWith('init') ||
nl.startsWith('action ') || nl.startsWith('event ') ||
nl.startsWith('interface ') || nl.startsWith('provenance ') ||
nl.startsWith('bom ') || nl === '}' ||
nl.startsWith('next:') || nl.startsWith('invariant ')) {
break;
}
// Update depth counters.
for (const ch of raw) {
if (ch === '(')
parens++;
else if (ch === ')')
parens--;
else if (ch === '[')
brackets++;
else if (ch === ']')
brackets--;
else if (ch === '{')
braces++;
else if (ch === '}')
braces--;
}
parts.push(nl);
lastConsumed = j;
if (parens === 0 && brackets === 0 && braces === 0) {
break;
}
}
const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
const nameMatch = joined.match(/^(?:always|Always|eventually|Eventually)\s*\(\s*(\w+)\s*\)$/);
return {
type: 'verify',
name: nameMatch ? nameMatch[1] : 'unnamed',
temporalExpr: joined,
};
}
function parseConstraintAnyForm(lines, startIndex) {
const line = lines[startIndex].trim();
const namedMatch = line.match(/^constraint\s+(\w+):(.*)$/);

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
{"version":"2.1.9","results":[[":__tests__/parser.test.ts",{"duration":31.455063999999993,"failed":false}],[":src/__tests__/compiler.test.ts",{"duration":12.627537000000018,"failed":false}]]}
{"version":"2.1.9","results":[[":__tests__/parser.test.ts",{"duration":58.32899300000008,"failed":false}],[":src/__tests__/compiler.test.ts",{"duration":7758.09763,"failed":false}]]}

View file

@ -1,30 +0,0 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"components": [],
"metadata": {
"tools": [],
"component": {
"type": "application",
"name": "SpecklCompiler",
"version": "0.2.0",
"properties": []
}
},
"annotations": [
{
"subject": "speck:SpecklCompiler",
"annotations": [
{
"type": "tag",
"value": "provenanceType",
"text": "author"
}
],
"author": {
"name": "sscoble"
},
"timestamp": "2026-05-03T21:19:53.229Z"
}
]
}

View file

@ -1,42 +0,0 @@
{
"@context": [
"https://spdx.github.io/spdx-v3/spdx-context.jsonld",
{
"speckl": "https://speckl.scoble.me/ns/v0.2#"
}
],
"@type": "spdx:Document",
"spdx": {
"@id": "spdx:SpecklCompiler",
"@type": "spdx:Snippet",
"name": "SpecklCompiler",
"spdxVersion": "3.0.1",
"dataLicense": "CC0-1.0",
"creationInfo": {
"created": "2026-05-03T21:19:53.229Z",
"createdBy": [
"Organization: Speckl",
"tool: speckl-compile-0.2.0"
]
}
},
"builtBy": [],
"relationships": [
{
"from": {
"@id": "spdx:SpecklCompiler"
},
"to": {
"@id": "spdx:agent:sscoble"
},
"relationshipType": "spdx:wasAttributedTo"
}
],
"elements": [
{
"@type": "spdx:Person",
"@id": "spdx:agent:sscoble",
"name": "sscoble"
}
]
}

View file

@ -1,153 +0,0 @@
// Auto-generated by speckl-compile from SpecklCompiler
// DO NOT EDIT MANUALLY — regenerate from .speckdl source
export const SpecklCompilerCompilerPhase = {
BOMsGenerated: 'bomsgenerated',
Idle: 'idle',
Parsed: 'parsed',
Parsing: 'parsing',
ProvenanceGenerated: 'provenancegenerated',
TypeScriptGenerated: 'typescriptgenerated',
WASMGenerated: 'wasmgenerated',
} as const;
export type SpecklCompilerCompilerPhase = typeof SpecklCompilerCompilerPhase[keyof typeof SpecklCompilerCompilerPhase];
export const SpecklCompilerArtifactKind = {
CycloneDX: 'cyclonedx',
Done: 'done',
Provenance: 'provenance',
SPDX: 'spdx',
TypeScript: 'typescript',
WebAssembly: 'webassembly',
} as const;
export type SpecklCompilerArtifactKind = typeof SpecklCompilerArtifactKind[keyof typeof SpecklCompilerArtifactKind];
export interface CompilationStarted {
source: string;
output: string;
at: string;
}
export interface PhaseCompleted {
phase: SpecklCompilerCompilerPhase;
durationMs: number;
at: string;
}
export interface ArtifactGenerated {
kind: SpecklCompilerArtifactKind;
path: string;
bytes: number;
at: string;
}
export interface ErrorEmitted {
phase: SpecklCompilerCompilerPhase;
message: string;
line: number;
col: number;
at: string;
}
export class SpecklCompilerMachine {
phase: SpecklCompilerCompilerPhase;
sourcePath: String;
outputDir: String;
ast: any;
errors: String[];
artifacts: any;
decisionLog: CompilationStarted[] = [];
constructor() {
this.phase = SpecklCompilerCompilerPhase.Idle;
this.sourcePath = "";
this.outputDir = "./out";
this.ast = {};
this.errors = [];
this.artifacts = {};
}
compile(sourcePath: string, outputDir: string) {
if (!(this.phase === SpecklCompilerCompilerPhase.Idle)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.Idle');
if (!(sourcePath.length > 0)) throw new Error('Guard failed: sourcePath.length > 0');
this.decisionLog.push({
source: sourcePath,
output: outputDir,
at: Date.now()
} as CompilationStarted);
}
parse() {
if (!(this.phase === SpecklCompilerCompilerPhase.Parsing)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.Parsing');
if (!(this.sourcePath.length > 0)) throw new Error('Guard failed: this.sourcePath.length > 0');
this.decisionLog.push({
phase: SpecklCompilerCompilerPhase.Parsing,
durationMs: 0,
at: Date.now()
} as PhaseCompleted);
}
generateProvenance() {
if (!(this.phase === SpecklCompilerCompilerPhase.Parsed || this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.WASMGenerated)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.Parsed || this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.WASMGenerated');
this.decisionLog.push({
kind: SpecklCompilerCompilerPhase.Provenance,
path: join(this.outputDir,
bytes: 0,
at: Date.now()
} as ArtifactGenerated);
}
generateBOMs() {
if (!(this.phase === SpecklCompilerCompilerPhase.ProvenanceGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.ProvenanceGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed');
this.decisionLog.push({
kind: SpecklCompilerCompilerPhase.CycloneDX,
path: join(this.outputDir,
bytes: 0,
at: Date.now()
} as ArtifactGenerated);
this.decisionLog.push({
kind: SpecklCompilerCompilerPhase.SPDX,
path: join(this.outputDir,
bytes: 0,
at: Date.now()
} as ArtifactGenerated);
}
generateTypeScript() {
if (!(this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed');
this.decisionLog.push({
kind: SpecklCompilerCompilerPhase.TypeScript,
path: join(this.outputDir,
bytes: 0,
at: Date.now()
} as ArtifactGenerated);
}
generateWASM() {
if (!(this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.Parsed');
this.decisionLog.push({
kind: SpecklCompilerCompilerPhase.WebAssembly,
path: join(this.outputDir,
bytes: 0,
at: Date.now()
} as ArtifactGenerated);
}
report() {
if (!(this.phase === SpecklCompilerCompilerPhase.ProvenanceGenerated || this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.WASMGenerated)) throw new Error('Guard failed: this.phase === SpecklCompilerCompilerPhase.ProvenanceGenerated || this.phase === SpecklCompilerCompilerPhase.BOMsGenerated || this.phase === SpecklCompilerCompilerPhase.TypeScriptGenerated || this.phase === SpecklCompilerCompilerPhase.WASMGenerated');
this.decisionLog.push({
phase: SpecklCompilerCompilerPhase.Done,
durationMs: 0,
at: Date.now()
} as PhaseCompleted);
}
reset() {
}
}

View file

@ -1,151 +0,0 @@
;; Auto-generated by speckl-compile from SpecklCompiler
;; Compile: wat2wasm SpecklCompiler.wat -o SpecklCompiler.wasm
(module
;; Host imports for Map/Set/alloc
(import "speckl" "map_create" (func $map_create (result i32)))
(import "speckl" "map_set" (func $map_set (param i32 i32 i32)))
(import "speckl" "map_get" (func $map_get (param i32 i32) (result i32)))
(import "speckl" "map_has" (func $map_has (param i32 i32) (result i32)))
(import "speckl" "map_remove" (func $map_remove (param i32 i32)))
(import "speckl" "set_create" (func $set_create (result i32)))
(import "speckl" "set_add" (func $set_add (param i32 i32)))
(import "speckl" "set_has" (func $set_has (param i32 i32) (result i32)))
(import "speckl" "set_size" (func $set_size (param i32) (result i32)))
(import "speckl" "alloc" (func $alloc (param i32) (result i32)))
(import "speckl" "write_i32" (func $write_i32 (param i32 i32)))
(import "speckl" "write_f64" (func $write_f64 (param i32 f64)))
;; Linear memory (exported so host can read state)
(memory (export "memory") 1)
;; State globals
(global $phase (mut i32) (i32.const 0))
(global $sourcePath (mut i32) (i32.const 0))
(global $outputDir (mut i32) (i32.const 0))
(global $ast (mut i32) (i32.const 0))
(global $errors (mut i32) (i32.const 0))
(global $artifacts (mut i32) (i32.const 0))
;; ---- init ----
(func $init (export "init")
(i32.const 0)
(global.set $phase)
(i32.const 0)
(global.set $sourcePath)
(i32.const 0)
(global.set $outputDir)
(i32.const 0)
(global.set $ast)
(i32.const 0)
(global.set $errors)
(i32.const 0)
(global.set $artifacts)
)
;; ---- Actions ----
;; action: compile
(func $compile (export "compile") (param $sourcePath i32) (param $outputDir i32) (result i32)
;; require: phase === Idle
;; Complex right-hand side not compiled: phase === Idle
;; require: len(sourcePath) > 0
;; Complex guard not compiled: len(sourcePath) > 0
;; emit CompilationStarted { ... }
(i32.const 1)
)
;; action: parse
(func $parse (export "parse") (result i32)
;; require: phase === Parsing
;; Complex right-hand side not compiled: phase === Parsing
;; require: len(sourcePath) > 0
;; Complex guard not compiled: len(sourcePath) > 0
;; emit PhaseCompleted { ... }
(i32.const 1)
)
;; action: generateProvenance
(func $generateProvenance (export "generateProvenance") (result i32)
;; require: phase === Parsed || phase === TypeScriptGenerated || phase === WASMGenerated
;; Complex right-hand side not compiled: phase === Parsed || phase === TypeScriptGenerated || phase === WASMGenerated
;; emit ArtifactGenerated { ... }
(i32.const 1)
)
;; action: generateBOMs
(func $generateBOMs (export "generateBOMs") (result i32)
;; require: phase === ProvenanceGenerated || phase === Parsed
;; Complex right-hand side not compiled: phase === ProvenanceGenerated || phase === Parsed
;; emit ArtifactGenerated { ... }
;; emit ArtifactGenerated { ... }
(i32.const 1)
)
;; action: generateTypeScript
(func $generateTypeScript (export "generateTypeScript") (result i32)
;; require: phase === BOMsGenerated || phase === Parsed
;; Complex right-hand side not compiled: phase === BOMsGenerated || phase === Parsed
;; emit ArtifactGenerated { ... }
(i32.const 1)
)
;; action: generateWASM
(func $generateWASM (export "generateWASM") (result i32)
;; require: phase === TypeScriptGenerated || phase === BOMsGenerated || phase === Parsed
;; Complex right-hand side not compiled: phase === TypeScriptGenerated || phase === BOMsGenerated || phase === Parsed
;; emit ArtifactGenerated { ... }
(i32.const 1)
)
;; action: report
(func $report (export "report") (result i32)
;; require: phase === ProvenanceGenerated || phase === BOMsGenerated || phase === TypeScriptGenerated || phase === WASMGenerated
;; Complex right-hand side not compiled: phase === ProvenanceGenerated || phase === BOMsGenerated || phase === TypeScriptGenerated || phase === WASMGenerated
;; emit PhaseCompleted { ... }
(i32.const 1)
)
;; action: reset
(func $reset (export "reset") (result i32)
(i32.const 1)
)
;; ---- getState ----
(func $getState (export "getState") (result i32 i32)
(i32.const 0)
(global.get $phase)
(i32.store)
(i32.const 4)
(global.get $sourcePath)
(i32.store)
(i32.const 8)
(global.get $outputDir)
(i32.store)
(i32.const 12)
(global.get $ast)
(i32.store)
(i32.const 16)
(global.get $errors)
(i32.store)
(i32.const 20)
(global.get $artifacts)
(i32.store)
(i32.const 0)
(i32.const 24)
)
;; checkConstraints — runtime constraint evaluation stub
(func $checkConstraints (export "checkConstraints") (result i32)
;; TODO: evaluate constraint expressions at runtime
(i32.const 1)
)
)

View file

@ -1,11 +1,15 @@
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { parseSpeckFile } from '../parser.js';
import { generateProvenanceGraph } from '../generators/provenance.js';
import { generateCycloneDX } from '../generators/cyclonedx.js';
import { generateSPDX } from '../generators/spdx.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
describe('Compiler E2E', () => {
let ast: any;
const testOutputDir = './test-output';
@ -18,7 +22,7 @@ describe('Compiler E2E', () => {
});
it('should parse RetryHandler.speck', () => {
const specPath = path.join(process.cwd(), '..', 'examples', 'RetryHandler.speck');
const specPath = path.join(__dirname, '..', '..', '..', 'examples', 'RetryHandler.speck');
ast = parseSpeckFile(specPath);
expect(ast).toBeDefined();
@ -70,4 +74,251 @@ describe('Compiler E2E', () => {
expect(spdx['@context']).toBeDefined();
expect(spdx.spdx).toBeDefined();
});
it('should generate protobuf schema', () => {
// Compile RetryHandler.speck with --target protobuf and assert the
// generated .proto contains the expected protobuf constructs.
const specPath = path.join(__dirname, '..', '..', '..', 'examples', 'RetryHandler.speck');
const protoOutDir = path.join(testOutputDir, 'proto-smoke');
if (!fs.existsSync(protoOutDir)) {
fs.mkdirSync(protoOutDir, { recursive: true });
}
execSync(
`npx tsx src/index.ts ${specPath} -t protobuf -o ${protoOutDir}`,
{ cwd: COMPILER_DIR, stdio: 'pipe' },
);
const protoFiles = fs.readdirSync(protoOutDir).filter(f => f.endsWith('.proto'));
expect(protoFiles.length).toBeGreaterThanOrEqual(1);
const protoContent = fs.readFileSync(path.join(protoOutDir, protoFiles[0]), 'utf-8');
expect(protoContent).toContain('syntax = "proto3";');
expect(protoContent).toMatch(/\bmessage\s+\w+/);
// RetryHandler has events with fields — assert at least one field line
expect(protoContent).toMatch(/\w+\s+\w+\s*=\s*\d+;/);
});
});
describe('ifblock code generation', () => {
it('should emit real TypeScript if/else from a SpeckDL ifblock', () => {
// Compile ConditionalToggle.speck (has an if/else action body) and assert
// the generated TS contains a real `if (` with a brace block, not the old
// TODO stub comment.
const specPath = path.join(__dirname, '..', '..', '..', 'examples', 'ConditionalToggle.speck');
const outDir = path.join(__dirname, 'ifblock-smoke');
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
execSync(
`npx tsx src/index.ts ${specPath} -t typescript -o ${outDir}`,
{ cwd: COMPILER_DIR, stdio: 'pipe' },
);
const tsFile = path.join(outDir, 'ConditionalToggle.ts');
expect(fs.existsSync(tsFile)).toBe(true);
const tsContent = fs.readFileSync(tsFile, 'utf-8');
// Should contain a real if statement
expect(tsContent).toMatch(/\bif\s*\(/);
// Should contain a brace block body
expect(tsContent).toContain('{ ');
expect(tsContent).toContain(' }');
// Should NOT contain the old TODO stub
expect(tsContent).not.toContain('TODO: SpeckDL conditional block');
// Should contain an else clause (our spec has if/else)
expect(tsContent).toMatch(/\belse\b/);
});
});
// ---------------------------------------------------------------------------
// Regression tests for Z3 / TypeScript / Rust expression translation fixes.
//
// These tests compile the FederatedMeetupProduct spec against each backend
// (z3, typescript, rust) and assert that the generated output contains the
// expected translated patterns. They guard against regressions in:
// - implies() argument preservation (Z3 + TS + Rust)
// - multi-level field access flattening (Z3)
// - len()/length() normalization (Z3)
// - nil → speckl_nil translation (Z3)
// - != → (distinct X Y) wrapping (Z3)
// - constraint checks emitting real every() / iter().all() (TS + Rust)
// - 'in' operator → .has() in verify expressions (TS)
// - multiple verify blocks all emitted (memberKey dedup fix) (TS + Rust)
// ---------------------------------------------------------------------------
const SPEC_PATH = '/home/sscoble/projects/federated-meetup/specs/FederatedMeetupProduct.speckdl';
const OUT_DIR = './out'; // CLI writes to ./out relative to compiler cwd
const COMPILER_DIR = '/home/sscoble/speckl/compiler';
describe('Z3 / TS / Rust expression translation regressions', () => {
let z3Output: string;
let tsOutput: string;
let rustOutput: string;
beforeAll(() => {
// Clean out directory to avoid stale output
execSync(`rm -rf ${OUT_DIR}`, { cwd: COMPILER_DIR });
execSync(`mkdir -p ${OUT_DIR}`, { cwd: COMPILER_DIR });
// Compile to all three targets
execSync(
`npx tsx src/index.ts ${SPEC_PATH} --target z3 --output ${OUT_DIR}`,
{ cwd: COMPILER_DIR, stdio: 'pipe' },
);
z3Output = fs.readFileSync(
path.join(COMPILER_DIR, OUT_DIR, 'FederatedMeetupProduct.smt2'),
'utf-8',
);
execSync(
`npx tsx src/index.ts ${SPEC_PATH} --target typescript --output ${OUT_DIR}`,
{ cwd: COMPILER_DIR, stdio: 'pipe' },
);
tsOutput = fs.readFileSync(
path.join(COMPILER_DIR, OUT_DIR, 'FederatedMeetupProduct.ts'),
'utf-8',
);
execSync(
`npx tsx src/index.ts ${SPEC_PATH} --target rust --output ${OUT_DIR}`,
{ cwd: COMPILER_DIR, stdio: 'pipe' },
);
rustOutput = fs.readFileSync(
path.join(COMPILER_DIR, OUT_DIR, 'FederatedMeetupProduct', 'src', 'lib.rs'),
'utf-8',
);
}, 120_000);
// 1. Z3: implies() preserves both arguments → (=> P Q) not (=> true true)
it('Z3: implies() emits (=> P Q) preserving both arguments', () => {
expect(z3Output).toContain('(=>');
expect(z3Output).toContain('(=> (= o_status Refunded) (distinct o_refunded_at speckl_nil))');
// Ensure we did NOT regress to (=> true true)
expect(z3Output).not.toContain('(=> true true)');
expect(z3Output).not.toContain('(=> true');
});
// 2. Z3: multi-level field access m.price.amount → m_price_amount
it('Z3: multi-level field access m.price.amount flattens to m_price_amount', () => {
expect(z3Output).toContain('m_price_amount');
// Ensure we did NOT regress to single-level m_price or bare m.price.amount
expect(z3Output).not.toContain('m.price.amount');
// The constraint m_price_amount >= 0 should be present
expect(z3Output).toContain('(>= m_price_amount 0)');
});
// 3. Z3: len() handled same as length() → speckl_len_Int
it('Z3: len() is translated to speckl_len_Int (same as length())', () => {
// The spec uses len(m.price.currency) — should become speckl_len_Int
expect(z3Output).toContain('(speckl_len_Int m_price_currency)');
// Ensure we did NOT regress to bare len() or length()
expect(z3Output).not.toMatch(/\blen\s*\(/);
expect(z3Output).not.toMatch(/\blength\s*\(/);
});
// 4. Z3: nil translated to speckl_nil
it('Z3: nil is translated to speckl_nil sentinel', () => {
expect(z3Output).toContain('(declare-const speckl_nil Int)');
expect(z3Output).toContain('speckl_nil');
// Ensure we did NOT regress to bare nil / null
expect(z3Output).not.toMatch(/\bnil\b/);
});
// 5. Z3: != produces (distinct X Y) not bare 'distinct'
it('Z3: != produces wrapped (distinct X Y) not bare distinct', () => {
// The constraint g.hosting_tier != Free should emit (distinct g_hosting_tier Free)
expect(z3Output).toContain('(distinct g_hosting_tier Free)');
// Ensure we did NOT regress to bare 'distinct g_hosting_tier Free' without parens
// (the fix wraps the operator in parens)
// A bare distinct would look like "g_hosting_tier distinct Free" without surrounding parens
expect(z3Output).not.toMatch(/[^(]\w+\s+distinct\s+\w+/);
});
// 6. TS: implies(p,q) → (!p || q) not (p || q)
it('TS: implies(p, q) translates to (!p || q), not (p || q)', () => {
// constraint 6: implies(o.status == Refunded, o.refunded_at != nil)
// Should become (!(o.status === ...) || (o.refunded_at !== ...))
expect(tsOutput).toContain('(!(');
expect(tsOutput).toContain('||');
// The specific pattern: (!(o.status === ... || (o.refunded_at !== nil))
expect(tsOutput).toMatch(/!\(o\.status === .* \|\| \(o\.refunded_at !== /);
// Ensure the CODE (not comments) has negation on p.
// A regression would have `return this.orders.every((o: any) => (o.status ===`
// (without the `!(` negation before `o.status`).
// Strip comments to avoid matching the original spec text in comments.
const tsNoComments = tsOutput.replace(/^\s*\/\/.*$/gm, '');
// Positive: the every() body starts with (!(o.status — the (! is the negation
expect(tsNoComments).toMatch(/every\(\(o:\s*any\)\s*=>\s*\(!\(o\.status/);
// Negative: a regression would have every((o: any) => (o.status without the ! negation
expect(tsNoComments).not.toMatch(/every\(\(o:\s*any\)\s*=>\s*\(o\.status/);
});
// 7. TS: constraint checks emit real every() not return true
it('TS: constraint checks emit real .every() calls, not return true', () => {
expect(tsOutput).toContain('.every(');
// Multiple constraints use every() — at least 8 constraints
const everyCount = (tsOutput.match(/\.every\(/g) || []).length;
expect(everyCount).toBeGreaterThanOrEqual(8);
// Ensure we did NOT regress to `return true` stubs
// A stub would have checkConstraintN() { return true; }
expect(tsOutput).not.toMatch(/checkConstraint\d+\(\):\s*boolean\s*\{\s*return\s*true\s*;\s*\}/);
});
// 8. TS: 'in' operator in verify expressions → .has() not raw 'in'
it('TS: "in" operator in verify expressions translates to .has(), not raw in', () => {
// V1: always(implies(event == PurchaseTicket, ticket_id in tickets))
// Should become tickets.has(ticket_id)
expect(tsOutput).toContain('.has(');
expect(tsOutput).toContain('tickets.has(ticket_id)');
// Ensure we did NOT regress to raw 'in' operator (JS `in` checks property keys, not membership).
// A regression would look like `ticket_id in tickets` in CODE (not comments).
// Strip comments to avoid matching the original spec text in comments.
const tsNoComments = tsOutput.replace(/^\s*\/\/.*$/gm, '');
expect(tsNoComments).not.toMatch(/\bticket_id\s+in\s+tickets\b/);
});
// 9. TS/Rust: multiple verify blocks all emitted (memberKey dedup fix)
it('TS: all 6 verify blocks are emitted (memberKey dedup fix)', () => {
// The spec has 6 verify blocks; previously dedup killed all but the first
expect(tsOutput).toContain('verify1');
expect(tsOutput).toContain('verify2');
expect(tsOutput).toContain('verify3');
expect(tsOutput).toContain('verify4');
expect(tsOutput).toContain('verify5');
expect(tsOutput).toContain('verify6');
expect(tsOutput).not.toContain('verify7'); // only 6 verify blocks in spec
});
it('Rust: all 6 verify blocks are emitted (memberKey dedup fix)', () => {
expect(rustOutput).toContain('verify_1');
expect(rustOutput).toContain('verify_2');
expect(rustOutput).toContain('verify_3');
expect(rustOutput).toContain('verify_4');
expect(rustOutput).toContain('verify_5');
expect(rustOutput).toContain('verify_6');
expect(rustOutput).not.toContain('verify_7');
});
// 10. Rust: constraint checks emit iter().all() not return true
it('Rust: constraint checks emit iter().all(), not return true stubs', () => {
expect(rustOutput).toContain('.iter().all(');
// Multiple constraints use iter().all() — at least 8 constraints
const iterAllCount = (rustOutput.match(/\.iter\(\)\.all\(/g) || []).length;
expect(iterAllCount).toBeGreaterThanOrEqual(8);
// Ensure we did NOT regress to `return true` stubs
expect(rustOutput).not.toMatch(/check_invariant_\d+\(&self\)\s*->\s*bool\s*\{\s*true\s*\}/);
});
// 11. Rust: implies() → (!p || q) in Rust
it('Rust: implies(p, q) translates to (!p || q) in Rust', () => {
// constraint 6: implies(o.status == Refunded, o.refunded_at != nil)
// Should become (!(o.status == ... || (o.refunded_at != ...))
expect(rustOutput).toContain('(!(');
expect(rustOutput).toMatch(/!\(o\.status == .* \|\| \(o\.refunded_at != /);
// Ensure the CODE (not comments) has negation on p.
// A regression would have `self.orders.iter().all(|o| (o.status ==`
// (without the `!(` negation before `o.status`).
// Strip comments to avoid matching the original spec text in comments.
const rustNoComments = rustOutput.replace(/^\s*\/\/.*$/gm, '');
// Positive: the iter().all() body starts with (!(o.status — the (! is the negation
expect(rustNoComments).toMatch(/iter\(\)\.all\(\|o\|\s*\(!\(o\.status/);
// Negative: a regression would have iter().all(|o| (o.status without the ! negation
expect(rustNoComments).not.toMatch(/iter\(\)\.all\(\|o\|\s*\(o\.status/);
});
});

View file

@ -7,23 +7,15 @@
"@graph": [
{
"@type": "prov:Entity",
"@id": "speck:SpecklCompiler",
"@id": "speck:ConditionalToggle",
"prov:qualifiedGeneration": {
"@type": "prov:Generation",
"prov:activity": {
"@type": "prov:Activity",
"prov:startTime": "2026-05-03T21:19:53.222Z"
"prov:startTime": "2026-07-19T08:31:19.208Z"
}
},
"rdfs:label": "SpecklCompiler",
"prov:wasAttributedTo": {
"@id": "agent:sscoble"
}
},
{
"@type": "prov:Agent",
"@id": "agent:sscoble",
"rdfs:label": "sscoble"
"rdfs:label": "ConditionalToggle"
}
]
}

View file

@ -0,0 +1,15 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"components": [],
"metadata": {
"tools": [],
"component": {
"type": "application",
"name": "ConditionalToggle",
"version": "0.2.0",
"properties": []
}
},
"annotations": []
}

View file

@ -0,0 +1,26 @@
{
"@context": [
"https://spdx.github.io/spdx-v3/spdx-context.jsonld",
{
"speckl": "https://speckl.scoble.me/ns/v0.2#"
}
],
"@type": "spdx:Document",
"spdx": {
"@id": "spdx:ConditionalToggle",
"@type": "spdx:Snippet",
"name": "ConditionalToggle",
"spdxVersion": "3.0.1",
"dataLicense": "CC0-1.0",
"creationInfo": {
"created": "2026-07-19T08:31:19.232Z",
"createdBy": [
"Organization: Speckl",
"tool: speckl-compile-0.2.0"
]
}
},
"builtBy": [],
"relationships": [],
"elements": []
}

View file

@ -0,0 +1,33 @@
// Auto-generated by speckl-compile from ConditionalToggle
// DO NOT EDIT MANUALLY — regenerate from .speckdl source
export const ConditionalToggleState = {
Open: 'open',
Closed: 'closed',
} as const;
export type ConditionalToggleState = typeof ConditionalToggleState[keyof typeof ConditionalToggleState];
export class ConditionalToggleMachine {
isOn!: boolean;
count!: number;
decisionLog: any[] = [];
constructor() {
// No init block defined
}
Toggle() {
const xxx: any = {} as any; // FIXME: parser dropped let/forall
if (!(true)) throw new Error('Guard failed: true');
if (this.isOn) { this.isOn = false } else { this.isOn = true }
this.count = this.count + 1;
return this.isOn;
}
}

View file

@ -0,0 +1,376 @@
/**
* Generate Protocol Buffers (proto3) from a SpeckDL spec.
*
* This is the load-bearing backend for the "no glue code" claim:
* the .proto file emitted here is the canonical wire format. Run
* `buf generate` on the output to produce Go / TypeScript / Rust / C
* / Python / Java / etc. bindings — every gRPC and ConnectRPC ecosystem
* gets free bindings with zero hand-written converters.
*
* Mapping:
* event X { name: String, age: Nat } -> message X { string name = 1; uint64 age = 2; }
* interface T { Foo, Bar } -> enum T { T_FOO = 0; T_BAR = 1; }
* interface X { field: Type } -> message X { Type field = 1; }
* state: { foo: Type, bar: Type } -> message StateSnapshot { Type foo = 1; Type bar = 2; }
* service S { rpc M(R) returns (Rsp);} -> service S { rpc M(R) returns (Rsp); }
* constraint: ... -> comment in generated .proto
* verify: ... -> comment in generated .proto
* bom { ... } -> file-level metadata
*
* Deterministic: field numbering is alphabetical by field name, so the same
* .speckdl always produces the same .proto byte-for-byte. Matches Speckl's
* existing determinism claim.
*/
import { AST, SpeckNode, MemberNode, StateNode, EventNode, InterfaceNode, TypeExpr, ServiceNode, TransitionNode, OneofNode } from '../parser.js';
import fs from 'fs';
import path from 'path';
const PROTO3 = 'proto3';
// SpeckDL type -> proto3 type
function speckTypeToProto(typeName: string): string {
const t = typeName.trim();
switch (t) {
case 'String': return 'string';
case 'StringList': return 'repeated string';
case 'Nat': return 'uint64';
case 'NatList': return 'repeated uint64';
case 'Int': return 'int64';
case 'IntList': return 'repeated int64';
case 'Bool': return 'bool';
case 'BoolList': return 'repeated bool';
case 'Bytes': return 'bytes';
case 'BytesList': return 'repeated bytes';
case 'Real': return 'double';
case 'Date': return 'google.protobuf.Timestamp';
default:
// User-defined types: passed through verbatim. The user is responsible
// for ensuring these resolve to messages/enums defined elsewhere in
// the same .proto (or imported).
return t;
}
}
// Resolve a TypeExpr (which may be primitive, ident, list, map, set) to a
// proto3 type string. Used by all field-emitting code paths so that
// interface records, events, state, and transitions all handle
// List<T>, Map<K,V>, and Set<T> consistently.
// Cycle 73: extracted from inline code in event/state/transition emitters.
// Previously, interface record fields only called speckTypeToProto(f.type.name)
// which returned '' for List/Map/Set types (their .name is undefined),
// producing malformed proto like ` = 1;` with no type.
function typeExprToProto(t: TypeExpr): string {
if (t.type === 'map' && t.keyType && t.valueType) {
const k = speckTypeToProto(t.keyType.name || 'string');
const v = speckTypeToProto(t.valueType.name || 'bytes');
return `map<${k}, ${v}>`;
}
if (t.type === 'list' && t.elementType) {
const inner = typeExprToProto(t.elementType);
return `repeated ${inner}`;
}
if (t.type === 'set' && t.elementType) {
const inner = typeExprToProto(t.elementType);
return `repeated ${inner}`;
}
// Primitive or ident — fall back to name-based lookup
return speckTypeToProto(t.name || '');
}
// Deterministic field numbering: alphabetical by field name, 1-indexed.
function assignFieldNumbers(fields: { name: string }[]): Map<string, number> {
const sorted = [...fields].sort((a, b) => a.name.localeCompare(b.name));
const map = new Map<string, number>();
sorted.forEach((f, i) => map.set(f.name, i + 1));
return map;
}
function snakeCase(s: string): string {
return s
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase();
}
function pascalCase(s: string): string {
return s
.replace(/[_\-]+/g, ' ')
.replace(/\s+(.)/g, (_, c: string) => c.toUpperCase())
.replace(/^(.)/, (_, c: string) => c.toUpperCase())
.replace(/\s/g, '');
}
function upperSnake(s: string): string {
return snakeCase(s).toUpperCase();
}
interface InterfaceField {
name: string;
type: TypeExpr; // matches parser's MethodSignature.params shape
}
interface InterfaceKind {
name: string;
isEnum: boolean;
isMessage: boolean;
variants?: string[];
fields?: InterfaceField[];
}
// Use the parser's `kind` discriminator to route each interface to the
// correct proto3 construct. The parser sets `kind` based on what was found
// in the interface block:
// 'enum' → proto3 enum (variants)
// 'record' → proto3 message (fields)
// 'service' → proto3 service (rpc methods) — handled separately
// 'mixed' → inspect methods/fields directly to disambiguate
function classifyInterfaces(members: MemberNode[]): Map<string, InterfaceKind> {
const result = new Map<string, InterfaceKind>();
for (const m of members) {
if (m.type !== 'interface') continue;
const iface = m as any; // parser uses kind/fields now
switch (iface.kind) {
case 'enum':
result.set(iface.name, {
name: iface.name,
isEnum: true,
isMessage: false,
variants: iface.methods.map((meth: any) => meth.name),
});
break;
case 'record':
result.set(iface.name, {
name: iface.name,
isEnum: false,
isMessage: true,
fields: iface.fields,
});
break;
case 'service':
// Services emitted separately; skip in this map
break;
case 'mixed':
default:
if (iface.fields && iface.fields.length > 0) {
result.set(iface.name, {
name: iface.name,
isEnum: false,
isMessage: true,
fields: iface.fields,
});
} else if (iface.methods && iface.methods.length > 0) {
result.set(iface.name, {
name: iface.name,
isEnum: true,
isMessage: false,
variants: iface.methods.map((meth: any) => meth.name),
});
}
break;
}
}
return result;
}
export function generateProtobuf(ast: AST, outputDir: string, packageName?: string): void {
for (const speck of ast.specks) {
const code = emitSpeck(speck, packageName);
const filename = `${snakeCase(speck.name)}.proto`;
const filepath = path.join(outputDir, filename);
fs.writeFileSync(filepath, code);
console.log(`Generated protobuf schema: ${filepath}`);
}
}
function emitSpeck(speck: SpeckNode, packageName?: string): string {
const members = speck.members;
const stateNode = members.find(m => m.type === 'state') as StateNode | undefined;
const events = members.filter(m => m.type === 'event') as EventNode[];
const interfaces = classifyInterfaces(members);
const pkg = (speck as any).protoPackage || packageName || `speckl.${snakeCase(speck.name)}.v1`;
const goPkg = (speck as any).goPackage
|| `github.com/sscoble/${snakeCase(speck.name)}/proto/${snakeCase(speck.name)}/v1;${snakeCase(speck.name)}v1`;
const lines: string[] = [];
// Header
lines.push('// Auto-generated by speckl-compile from ' + speck.name + '.speckdl');
lines.push('// DO NOT EDIT MANUALLY — regenerate from the .speckdl source');
lines.push('// SPDX-License-Identifier: MIT');
lines.push('');
lines.push('syntax = "' + PROTO3 + '";');
lines.push('');
lines.push('package ' + pkg + ';');
lines.push('');
lines.push('option go_package = "' + goPkg + '";');
lines.push('');
// Import google.protobuf.Timestamp if any field uses Date.
// Cycle 73: extended to also check interface record fields (not just
// events and state). Previously, a spec with Date only in interface
// records (e.g. Ticket.sale_starts_at) would miss the import.
const usesTimestamp = members.some(m => {
if (m.type === 'event') {
return (m as EventNode).fields.some(f => f.type.name === 'Date' ||
(f.type.type === 'list' && f.type.elementType?.name === 'Date'));
}
if (m.type === 'state') {
return (m as StateNode).variables.some(v =>
v.typeExpr.name === 'Date' ||
(v.typeExpr.type === 'list' && v.typeExpr.elementType?.name === 'Date')
);
}
if (m.type === 'interface') {
const iface = m as any;
if (iface.fields && iface.fields.length > 0) {
return iface.fields.some((f: any) =>
f.type.name === 'Date' ||
(f.type.type === 'list' && f.type.elementType?.name === 'Date')
);
}
}
return false;
});
if (usesTimestamp) {
lines.push('import "google/protobuf/timestamp.proto";');
lines.push('');
}
// ─── Enums (interface with variants) ────────────────────────
for (const [, iface] of Array.from(interfaces.entries())) {
if (iface.isEnum && iface.variants) {
lines.push(`enum ${iface.name} {`);
lines.push(` ${upperSnake(iface.name)}_UNSPECIFIED = 0;`);
iface.variants.forEach((v, i) => {
// Cycle 73: prefix variant with enum name to avoid collisions
// across enums in the same proto scope. Proto3 requires all enum
// values to be unique within the enclosing scope (not just the
// enum), so PENDING in Role and PENDING in OrderStatus collide.
// Prefixing with the enum name (ROLE_PENDING, ORDER_STATUS_PENDING)
// is the proto3 convention and eliminates the collision.
lines.push(` ${upperSnake(iface.name)}_${upperSnake(v)} = ${i + 1};`);
});
lines.push('}');
lines.push('');
}
}
// ─── Messages (interface with fields, plus events) ─────────
// First emit interface-messages (record types like BranchInfo, CustodyDeclaration)
for (const [, iface] of Array.from(interfaces.entries())) {
if (iface.isMessage && iface.fields && iface.fields.length > 0) {
lines.push(`message ${iface.name} {`);
const fieldNums = assignFieldNumbers(iface.fields);
for (const f of iface.fields) {
const num = fieldNums.get(f.name) || 1;
const protoType = typeExprToProto(f.type);
lines.push(` ${protoType} ${snakeCase(f.name)} = ${num};`);
}
lines.push('}');
lines.push('');
}
}
// Then emit events as messages
for (const ev of events) {
// Cycle 61: honor `event_suffix: "Payload"` directive so emitted
// message names match consumer Go bindings (e.g. CreateGroupPayload
// instead of CreateGroup).
const evName = speck.eventSuffix ? ev.name + speck.eventSuffix : ev.name;
lines.push(`message ${evName} {`);
if (ev.fields.length === 0) {
lines.push(' // empty event');
} else {
const fieldNums = assignFieldNumbers(ev.fields);
for (const f of ev.fields) {
const num = fieldNums.get(f.name) || 1;
const protoType = typeExprToProto(f.type);
lines.push(` ${protoType} ${snakeCase(f.name)} = ${num};`);
}
}
lines.push('}');
lines.push('');
}
// Then emit state snapshot as a message
if (stateNode && stateNode.variables.length > 0) {
// Cycle 59: honor `state as <Name>` override so the consumer Go code
// can import the generated message by the expected name (e.g.
// StateSnapshot instead of FederatedMeetupState).
const msgName = stateNode.messageName || `${pascalCase(speck.name)}State`;
lines.push(`message ${msgName} {`);
const fieldNums = assignFieldNumbers(stateNode.variables);
for (const v of stateNode.variables) {
const num = fieldNums.get(v.name) || 1;
const protoType = typeExprToProto(v.typeExpr);
lines.push(` ${protoType} ${snakeCase(v.name)} = ${num};`);
}
lines.push('}');
lines.push('');
}
// Cycle 64: emit TransitionNode messages. A `transition Name { ... }`
// block produces a proto message with the regular fields plus an inline
// `oneof payload { ... }` block, whose variants are pulled from a
// sibling `oneof <Name> { ... }` block referenced by `payload: <Name>`.
// Field numbers are assigned starting at 1; the oneof variants use
// 10, 11, 12, ... (proto convention: leave 1-15 for the most stable
// fields so the wire format is forward-compatible).
const transitionNodes = members.filter(m => m.type === 'transition') as TransitionNode[];
const oneofNodes = members.filter(m => m.type === 'oneof') as OneofNode[];
for (const t of transitionNodes) {
lines.push(`message ${t.name} {`);
// Cycle 73: emit the type discriminator field. The parser stores it
// in t.typeDiscriminator (a string naming the enum) rather than in
// t.fields, so it must be emitted explicitly. Field number 1 is
// reserved for the type discriminator (it's the most-accessed field).
if (t.typeDiscriminator) {
lines.push(` ${t.typeDiscriminator} type = 1;`);
}
const fieldNums = assignFieldNumbers(t.fields);
for (const f of t.fields) {
const num = fieldNums.get(f.name) || 1;
// If type discriminator is present, offset field numbers by 1
// to avoid collision with the type field at number 1.
const actualNum = t.typeDiscriminator ? num + 1 : num;
const protoType = typeExprToProto(f.type);
lines.push(` ${protoType} ${snakeCase(f.name)} = ${actualNum};`);
}
if (t.payloadOneof) {
const oneof = oneofNodes.find(o => o.name === t.payloadOneof);
if (oneof) {
lines.push(' oneof payload {');
oneof.variants.forEach((v, i) => {
// Reference the suffixed message name if event_suffix is set,
// so the oneof variants match what the consumer Go code expects.
const variantType = speck.eventSuffix ? v.typeName + speck.eventSuffix : v.typeName;
lines.push(` ${variantType} ${v.fieldName} = ${i + 10};`);
});
lines.push(' }');
}
}
lines.push('}');
lines.push('');
}
// ─── Service (RPC surface) ──────────────────────────────────
// ServiceNode members emit proto3 service blocks. These map directly to
// gRPC and ConnectRPC service definitions; buf generate + protoc-gen-go
// + protoc-gen-connect-go produce client stubs and Connect handlers.
const serviceNodes = members.filter((m: any) => m.type === 'service') as ServiceNode[];
if (serviceNodes.length > 0) {
for (const svc of serviceNodes) {
lines.push(`service ${svc.name} {`);
for (const rpc of svc.rpcs) {
const reqStream = rpc.clientStreaming ? 'stream ' : '';
const resStream = rpc.serverStreaming ? 'stream ' : '';
lines.push(` rpc ${pascalCase(rpc.name)}(${reqStream}${rpc.requestType}) returns (${resStream}${rpc.responseType});`);
}
lines.push('}');
lines.push('');
}
}
return lines.join('\n');
}

View file

@ -48,6 +48,30 @@ description = "Auto-generated Speck state machine: ${speck.name}"
}
}
/**
* Translate SpeckDL implies() to Rust boolean expression.
* implies(p, q) → (!p || q) in Rust.
* Also handles bare implies: implies A B → (!A || B)
*/
function rustExprImplications(expr: string): string {
let e = expr;
// implies(p, q) → (!(p) || (q))
// Use recursive replacement for nested implies
let changed = true;
while (changed) {
changed = false;
e = e.replace(/implies\s*\(([^,()]*(?:\([^()]*\)[^()]*)*),\s*([^()]+)\)/g, (_, p, q) => {
changed = true;
return `(!(${p.trim()}) || (${q.trim()}))`;
});
}
// nil → Rust's Option::None equivalent: use a sentinel. For now, use `false`
// since we can't properly represent nil without Option types.
e = e.replace(/\bnil\b/g, 'false /* nil sentinel */');
// == → == (already Rust), != → != (already Rust)
return e;
}
function emitSpeck(speck: SpeckNode): string {
const deduped = new Map<string, MemberNode>();
for (const m of speck.members) {
@ -198,6 +222,29 @@ function emitSpeck(speck: SpeckNode): string {
return `#[derive(Debug, Clone)]\npub struct ${e.name} {\n${fields}\n}`;
}).join('\n\n');
// Cycle 73: Event enum union for the decision log.
// Each variant wraps the corresponding event struct.
const eventEnum = events.length > 0
? `#[derive(Debug, Clone)]\npub enum ${speck.name}Event {\n` +
events.map(e => ` ${e.name}(${e.name}),`).join('\n') +
`\n}`
: '';
// Cycle 73: Interface record structs (Money, Ticket, Order, etc.)
// Previously only events were emitted as structs. Interface records
// with fields (kind === 'record') are now emitted too.
const recordInterfaces = allInterfaces.filter(iface =>
iface?.kind === 'record' || (iface?.fields && iface.fields.length > 0)
);
const recordStructs = recordInterfaces.map(iface => {
const fields = iface.fields.map((f: any) => {
const name = snakeCase(cleanName(f.name));
const ty = rustType(f.type, speck.name, enumMap);
return ` pub ${name}: ${ty},`;
}).join('\n');
return `#[derive(Debug, Clone)]\npub struct ${iface.name} {\n${fields}\n}`;
}).join('\n\n');
const structName = `${speck.name}Machine`;
// State variables with original name + snake_case name mapping
@ -256,28 +303,70 @@ function emitSpeck(speck: SpeckNode): string {
.map(a => emitAction(a, stateVarOrigNames, nameMap, mapVarOrigNames, speck.name, stateEnumName, allKnownStateValues, enumMap))
.join('\n\n');
// Invariant checks
const invariantChecks = constraints.map((c, i) =>
` /// C${i + 1}: ${c.expr}\n` +
` pub fn check_invariant_${i + 1}(&self) -> bool {\n` +
` // TODO: Evaluate in Z3 or runtime\n` +
` // Expression: ${c.expr}\n` +
` true\n` +
` }`
).join('\n\n');
// Invariant checks — translate constraint expressions to real Rust boolean checks.
// Constraint syntax: forall var in collection: expr
// → collection.iter().all(|var| translated_expr)
// Or: direct boolean expression → translated_expr
const invariantChecks = constraints.map((c, i) => {
const cExpr = c.expr.trim();
const forallMatch = cExpr.match(/^forall\s+(\w+)\s+in\s+(\w+):\s*(.+)$/s);
let rustBody: string;
if (forallMatch) {
const [, varName, collName, body] = forallMatch;
const rustColl = nameMap.get(collName) || collName;
const bodyRust = rewriteRustExpr(body, nameMap, mapVarOrigNames, new Set([varName]), stateEnumName, allKnownStateValues);
// Handle implies() in the body
const bodyFixed = rustExprImplications(bodyRust);
rustBody = ` self.${rustColl}.iter().all(|${varName}| ${bodyFixed})`;
} else {
const exprRust = rewriteRustExpr(cExpr, nameMap, mapVarOrigNames, new Set(), stateEnumName, allKnownStateValues);
const exprFixed = rustExprImplications(exprRust);
rustBody = ` ${exprFixed}`;
}
return ` /// C${i + 1}: ${c.expr}\n` +
` pub fn check_invariant_${i + 1}(&self) -> bool {\n` +
` // Expression: ${c.expr}\n` +
`${rustBody}\n` +
` }`;
}).join('\n\n');
// Verify stubs
const verifyChecks = verifies.map((v, i) =>
` /// V${i + 1}: ${v.temporalExpr}\n` +
` pub fn verify_${i + 1}(&self) -> bool {\n` +
` // TODO: Model-check or monitor\n` +
` // Temporal: ${v.temporalExpr}\n` +
` true\n` +
` }`
).join('\n\n');
// Verify checks — translate temporal expressions to real Rust boolean checks.
// LTL: always(p) → p must hold at current state + every decision-log entry.
// eventually(p) → p must hold at some step.
const verifyChecks = verifies.map((v, i) => {
const formula = v.temporalExpr.trim();
const ltlMatch = formula.match(/^(?:always|eventually)\s*\(([\s\S]*)\)$/);
const op = ltlMatch ? formula.match(/^(always|eventually)/)?.[1] : null;
const inner = ltlMatch ? ltlMatch[1].trim() : formula;
const innerRust = rustExprImplications(rewriteRustExpr(inner, nameMap, mapVarOrigNames, new Set(), stateEnumName, allKnownStateValues));
let body: string;
if (op === 'always') {
body = ` if !(${innerRust}) return false;\n` +
` for _evt in &self.decision_log {\n` +
` if !(${innerRust}) return false;\n` +
` }\n` +
` true`;
} else if (op === 'eventually') {
body = ` if ${innerRust} return true;\n` +
` for _evt in &self.decision_log {\n` +
` if ${innerRust} return true;\n` +
` }\n` +
` false`;
} else {
body = ` ${innerRust}`;
}
return ` /// V${i + 1}: ${v.temporalExpr}\n` +
` pub fn verify_${i + 1}(&self) -> bool {\n` +
` // Temporal: ${v.temporalExpr}\n` +
`${body}\n` +
` }`;
}).join('\n\n');
// Decision log type
const logEventType = events.length > 0 ? `Vec<${events[0].name}>` : 'Vec<String>';
// Decision log type — cycle 73: use an enum union of all event types,
// not just the first one. If there are no events, fall back to String.
const logEventType = events.length > 0
? `Vec<${speck.name}Event>`
: 'Vec<String>';
// Struct definition
const structDef = `/// Speck state machine: ${speck.name}\n` +
@ -318,11 +407,13 @@ function emitSpeck(speck: SpeckNode): string {
`// DO NOT EDIT MANUALLY — regenerate from .speckdl source\n\n` +
`use std::collections::{HashMap, HashSet};\n\n` +
`${stateEnum}\n\n` +
`${recordStructs ? recordStructs + '\n\n' : ''}` +
`${eventStructs}\n\n` +
`${eventEnum ? eventEnum + '\n\n' : ''}` +
`${structDef}\n\n` +
`${constructor}\n\n` +
`${actionImpl}\n` +
`${invariantImpl}\n` +
`${invariantImpl}` +
`${verifyImpl}\n`;
return file;
@ -555,6 +646,13 @@ function memberKey(m: MemberNode): string {
if (m.type === 'bom') return 'bom';
if (m.type === 'input') return 'input';
if (m.type === 'output') return 'output';
// For verify and constraint nodes, include the expression in the key
// so multiple verify/constraint blocks aren't deduped to one.
if (m.type === 'verify') return `verify:${(m as any).temporalExpr || ''}`;
if (m.type === 'constraint') return `constraint:${(m as any).expr || ''}`;
if (m.type === 'oneof') return `oneof:${(m as any).name || ''}`;
if (m.type === 'transition') return `transition:${(m as any).name || ''}`;
if (m.type === 'service') return `service:${(m as any).name || ''}`;
return m.type;
}
@ -619,6 +717,8 @@ function rustType(t: any, speckName: string = '', enumMap?: Map<string, string[]
Nat: 'u64', Int: 'i64', Real: 'f64',
Bool: 'bool', String: 'String', Bytes: 'Vec<u8>',
Number: 'f64',
// Cycle 73: Date → u64 Unix timestamp (avoids chrono dependency).
Date: 'u64',
};
return map[t.name] || t.name.toLowerCase();
}
@ -645,6 +745,7 @@ function rustType(t: any, speckName: string = '', enumMap?: Map<string, string[]
if (rawName === 'Int') return 'i64';
if (rawName === 'Bool') return 'bool';
if (rawName === 'String') return 'String';
if (rawName === 'Date') return 'u64';
// Handle Option<T> from SpeckDL's Option(Type) syntax
const optionMatch = rawName.match(/^Option[_(]\s*(\w+)\s*\)?$/);
if (optionMatch) {

View file

@ -187,6 +187,22 @@ function emitSpeck(speck: SpeckNode): string {
}
const stateEnum = allEnums.join('\n\n');
// Cycle 73: emit interface record types as TS interfaces.
// Previously only events were emitted as TS interfaces; interface records
// (Money, Ticket, Order, etc.) were missing from the output.
const recordInterfaces = allInterfaces.filter(iface =>
(iface?.kind === 'record' || (iface?.fields && iface.fields.length > 0)) &&
// Skip enum-like interfaces (they have methods, not fields)
!iface?.methods?.length
);
const recordInterfaceStr = recordInterfaces.map(iface => {
const fields = iface.fields.map((f: any) => {
const name = cleanName(f.name);
return ` ${name}: ${tsType(f.type, speck.name, enumMap)};`;
}).join('\n');
return `export interface ${iface.name} {\n${fields}\n}`;
}).join('\n\n');
const eventInterfaces = [
// Explicit event declarations
...events.map(e => {
@ -260,23 +276,135 @@ function emitSpeck(speck: SpeckNode): string {
const actionMethods = actions.map(a => emitAction(a, stateVarNames, mapVarNames, speck.name, stateEnumName, allKnownStateValues, enumMap)).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 constraintChecks = constraints.map((c, i) => {
// Translate constraint expression to a real TS boolean check.
// Constraint syntax: forall var in collection: expr
// → collection.every(var => translateExpr(expr))
// Or: direct boolean expression → translateExpr(expr)
const cExpr = c.expr.trim();
const forallMatch = cExpr.match(/^forall\s+(\w+)\s+in\s+(\w+):\s*(.+)$/s);
let tsBody: string;
if (forallMatch) {
const [, varName, collName, body] = forallMatch;
// Translate the body expression for TS
const bodyTs = rewriteExpr(
translateLtlInner(body),
stateVarNames,
mapVarNames,
new Set([varName]), // bound variable — don't prefix with this.
stateEnumName,
allKnownStateValues
);
tsBody = ` return this.${collName}.every((${varName}: any) => ${bodyTs});`;
} else {
// Direct boolean expression
const exprTs = rewriteExpr(
translateLtlInner(cExpr),
stateVarNames,
mapVarNames,
new Set(),
stateEnumName,
allKnownStateValues
);
tsBody = ` return ${exprTs};`;
}
return ` // C${i + 1}: ${c.expr}\n` +
` checkConstraint${i + 1}(): boolean {\n` +
` // Expression: ${c.expr}\n` +
`${tsBody}\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');
// Build verifyN() methods. Each verify block declares an LTL formula
// (e.g. `always(implies(P, Q))`). The Z3 generator has BMC unrolling
// (compiler/src/generators/z3.ts:emitBMC, verifyDepth default 10) for
// `Always(Name)` / `Eventually(Name)` patterns that reference declared
// invariants. For inline SpeckDL boolean expressions inside `always(...)`
// / `eventually(...)` — which is what the example specs use — Z3's BMC
// doesn't have direct support. The TS runtime implements the same LTL
// semantics (Bounded Model Checking over the decision-log trace) so the
// generated function returns a *real* boolean: false when the property
// is violated at the current state, true otherwise.
//
// Z3 call-out: a sibling spec can later be translated to SMT-LIB and
// checked with `z3 -smt2` (see compiler/src/generators/z3.ts). The
// runtime LTL monitor in TypeScript is the synchronous mirror of that
// Z3 BMC, evaluated against the current state snapshot.
const verifyChecks = verifies.map((v, i) => {
const formula = v.temporalExpr.trim();
const ltlMatch = formula.match(/^(always|Always|eventually|Eventually)\s*\(([\s\S]*)\)$/);
const op = ltlMatch ? ltlMatch[1].toLowerCase() : null;
// The inner expression is the rest of the formula. The trailing `)` is
// the LTL operator's closing paren — strip it (we already matched the
// outer parens above). For non-LTL formulas (no paren-wrapped head),
// treat the whole string as a single-state property.
const innerRaw = ltlMatch ? ltlMatch[2].trim() : formula;
// The LTL formula's body is a SpeckDL boolean expression. It can use
// SpeckDL keywords (`and`, `or`, `not`, `implies`, `in`) which TS
// doesn't recognise. We translate the *operator keywords* here and
// let `rewriteExpr` handle the *identifier* prefixing and the
// trailing-comparison / state-enum substitutions it already knows.
const innerTs = rewriteExpr(
translateLtlInner(innerRaw),
stateVarNames,
mapVarNames,
new Set<string>(), // no action-local names in a verify body
stateEnumName,
allKnownStateValues
);
// rewriteExpr turns bare SpeckDL identifiers that are state variables
// into `this.<name>`, so the LTL formula evaluates against the current
// class state. At the moment verifyN() is called, `this` IS the state
// we are checking.
//
// For `always(p)`: p must hold at every step in the trace. The trace
// is the current state plus the decision-log boundary markers. The
// LTL property is *violated* the first time p is false at any step.
// For `eventually(p)`: p must hold at some step in the trace.
// For unknown LTL operators, evaluate the raw formula at current state.
//
// The decision log contains emit() payloads from action executions.
// Each entry is an event object with the fields declared in the
// `event` blocks. The log gives us historical boundary markers even
// when the state itself has moved on — for the runtime LTL monitor
// we treat each log entry as a "step at which some state may have
// held". A faithful implementation would snapshot all state vars
// at each emit boundary; here we approximate by reading the live
// state at the end of the trace (the standard "safety at horizon"
// view that Z3's BMC adopts for one-step invariants).
const ltlBody = op === 'always'
? ` // LTL: always(p). Bounded check — p must hold at every step in the\n` +
` // bounded trace (decision log + current state). The first step\n` +
` // where p is false violates the always-quantified property.\n` +
` if (!(${innerTs})) return false;\n` +
` for (const _evt of this.decisionLog) {\n` +
` // Each emit() entry is a step boundary; p is re-evaluated\n` +
` // against the live state (BMC "safety-at-horizon" view).\n` +
` if (!(${innerTs})) return false;\n` +
` }\n` +
` return true;`
: op === 'eventually'
? ` // LTL: eventually(p). Bounded check — p must hold at some step.\n` +
` if (${innerTs}) return true;\n` +
` for (const _evt of this.decisionLog) {\n` +
` if (${innerTs}) return true;\n` +
` }\n` +
` return false;`
: ` // No LTL temporal operator detected — treat as a single-state check.\n` +
` return (${innerTs});`;
return (
` // V${i + 1}: ${formula}\n` +
` // Z3 call-out: Bounded Model Checking (BMC) with depth = decisionLog.length+1.\n` +
` // Mirrors the SMT-LIB BMC emitted by compiler/src/generators/z3.ts (emitBMC).\n` +
` // The inner expression is SpeckDL; we translate it to a TS boolean\n` +
` // check that reads the current class state via this.<stateVar>.\n` +
` async verify${i + 1}(): Promise<boolean> {\n` +
` // Temporal: ${formula}\n` +
`${ltlBody}\n` +
` }`
);
}).join('\n\n');
// Decision log type: use first event if available, otherwise any[]
const logType = events.length > 0 ? '(' + events.map(e => e.name).join(' | ') + ')[]' : 'any[]';
@ -337,6 +465,7 @@ ${verifyChecks}
`// Auto-generated by speckl-compile from ${speck.name}\n` +
`// DO NOT EDIT MANUALLY — regenerate from .speckdl source\n\n` +
`${stateEnum}\n\n` +
`${recordInterfaceStr ? recordInterfaceStr + '\n\n' : ''}` +
`${eventInterfaces}\n\n` +
`${hashUtility}` +
`${classBody}`;
@ -426,7 +555,78 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames:
break;
}
case 'ifblock': {
bodyParts.push(` // TODO: SpeckDL conditional block (if/else) - manual translation required`);
// The parser captures the raw text of if/else blocks as stmt.raw
// (see parser.ts:~1267). The raw text looks like:
// "if (cond) { thenBody } else { elseBody }"
// or "if (cond) { body }" when there is no else clause.
// We split on 'else', rewrite the condition and bodies via rewriteExpr,
// and emit a real TypeScript if/else statement.
const raw = stmt.raw as string;
// Extract the condition (text between 'if (' and the matching ')').
// The raw text is a single-line join, so the first ')' after 'if ('
// closes the condition — but the condition itself may contain nested
// parens. Use a balanced-paren scan from the first 'if'.
const ifIdx = raw.indexOf('if');
const condStart = raw.indexOf('(', ifIdx);
let condEnd = -1;
let depth = 0;
for (let k = condStart; k < raw.length; k++) {
if (raw[k] === '(') depth++;
else if (raw[k] === ')') { depth--; if (depth === 0) { condEnd = k; break; } }
}
const condRaw = condStart >= 0 && condEnd > condStart ? raw.slice(condStart + 1, condEnd) : 'true';
const cond = rewriteExpr(condRaw, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
// The remainder after the condition's closing paren holds the bodies.
const rest = condEnd > 0 ? raw.slice(condEnd + 1) : '';
// Split on 'else' (word-boundary) to separate then/else branches.
const elseIdx = rest.search(/\belse\b/);
let thenBody = '';
let elseBody = '';
if (elseIdx >= 0) {
thenBody = rest.slice(0, elseIdx).trim();
elseBody = rest.slice(elseIdx + 4).trim();
} else {
thenBody = rest.trim();
}
// Strip surrounding braces from bodies so we can rewrite the inner
// statements, then re-wrap in { ... }.
const stripBraces = (s: string) => {
const m = s.match(/^\s*\{([\s\S]*)\}\s*$/);
return m ? m[1].trim() : s;
};
// A body may contain multiple statements separated by ';' or newlines.
// Each statement may be a SpeckDL assignment (target := expr) or a bare
// expression. We rewrite each one:
// - assignment: this.target = rewriteExpr(expr) (or target = ... if local)
// - bare expr: rewriteExpr(expr)
const rewriteBody = (body: string) => {
if (!body) return '';
const inner = stripBraces(body);
// Split on ';' or newlines (the raw text is a single-line join, so
// statements may be separated by ';'). Keep empty parts out.
const parts = inner.split(/[;\n]/).map(s => s.trim()).filter(Boolean);
const rewritten = parts.map(part => {
const assignMatch = part.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/);
if (assignMatch) {
const target = cleanName(assignMatch[1]);
const rhs = rewriteExpr(assignMatch[2], stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
if (localNames.has(target)) {
return `${target} = ${rhs}`;
}
return `this.${target} = ${rhs}`;
}
return rewriteExpr(part, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
});
return rewritten.join('; ');
};
const thenRewritten = rewriteBody(thenBody);
const elseRewritten = rewriteBody(elseBody);
let stmtStr = ` if (${cond}) { ${thenRewritten} }`;
if (elseRewritten) {
stmtStr += ` else { ${elseRewritten} }`;
}
bodyParts.push(stmtStr);
break;
}
case 'return': {
@ -598,6 +798,9 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
// 5a: x in <map>.keys -> <map>.has(x) (e.g. "t.from in accounts.keys" -> "accounts.has(t.from)")
ts = ts.replace(/(\S+)\s+in\s+(\S+)\.keys/g, '$2.has($1)');
// 5a-pre: Handle __IN_LEFT__X__IN_RIGHT__Y__IN_END__ placeholders from translateLtlInner
// Use [^_] character class won't work for identifiers with underscores, so use a broader pattern
ts = ts.replace(/__IN_LEFT__([A-Za-z_][A-Za-z0-9_.]*)__IN_RIGHT__([A-Za-z_][A-Za-z0-9_.]*)__IN_END__/g, '$2.has($1)');
// 5a3: x in <set> -> <set>.has(x) (e.g. "id in pending" -> "pending.has(id)")
ts = ts.replace(/(\S+)\s+in\s+(\S+)/g, (match, key, collection) => {
// Only replace if collection looks like a state variable (not a JS keyword)
@ -786,6 +989,13 @@ function memberKey(m: MemberNode): string {
if (m.type === 'bom') return 'bom';
if (m.type === 'input') return 'input';
if (m.type === 'output') return 'output';
// For verify and constraint nodes, include the expression in the key
// so multiple verify/constraint blocks aren't deduped to one.
if (m.type === 'verify') return `verify:${(m as any).temporalExpr || ''}`;
if (m.type === 'constraint') return `constraint:${(m as any).expr || ''}`;
if (m.type === 'oneof') return `oneof:${(m as any).name || ''}`;
if (m.type === 'transition') return `transition:${(m as any).name || ''}`;
if (m.type === 'service') return `service:${(m as any).name || ''}`;
return m.type;
}
@ -816,6 +1026,73 @@ function cleanExpr(s: string): string {
return s.replace(/,$/, '').trim();
}
/**
* Translate SpeckDL `implies(P, Q)` inside an LTL formula body to its
* TypeScript equivalent `(!P) || Q`. After this pass, `rewriteExpr` can
* apply identifier prefixing, state-enum substitution, and the
* `and`/`or`/`not`/`in` rewrites it already knows.
*
* The walker handles balanced parens (e.g. `implies(not safe, a)`) and
* recurses on each arg so nested `implies` are also translated.
*/
function translateLtlInner(expr: string): string {
let out = '';
let i = 0;
while (i < expr.length) {
if (expr.substring(i, i + 8) === 'implies(' && (i === 0 || !/[A-Za-z0-9_]/.test(expr[i - 1]))) {
// Find the matching close paren
let depth = 1;
let j = i + 8;
const argsStart = j;
while (j < expr.length && depth > 0) {
if (expr[j] === '(') depth++;
else if (expr[j] === ')') depth--;
if (depth === 0) break;
j++;
}
const argsStr = expr.substring(argsStart, j);
// Split argsStr at the top-level comma
const splitIdx = findTopLevelComma(argsStr);
if (splitIdx === -1) {
// Malformed: keep as-is
out += expr.substring(i, j + 1);
} else {
const p = argsStr.substring(0, splitIdx).trim();
const q = argsStr.substring(splitIdx + 1).trim();
// Recursively translate the args (handles nested implies)
const pT = translateLtlInner(p);
const qT = translateLtlInner(q);
// Implication: p → q is equivalent to (!p || q)
out += `(!(${pT}) || (${qT}))`;
}
i = j + 1;
} else {
out += expr[i];
i++;
}
}
// Translate 'in' operator: X in Y → __in__(X, Y) placeholder
// rewriteExpr will convert this to Y.has(X) or Y.includes(X) later.
// Do this after implies expansion so 'in' isn't split by || operators.
out = out.replace(/(\b\w+(?:\.\w+)*)\s+in\s+(\b\w+(?:\.\w+)*)/g, '__IN_LEFT__$1__IN_RIGHT__$2__IN_END__');
return out;
}
/**
* Find the index of the top-level comma in `s` (i.e. a comma at paren
* depth 0). Returns -1 if none.
*/
function findTopLevelComma(s: string): number {
let depth = 0;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '(') depth++;
else if (c === ')') depth--;
else if (c === ',' && depth === 0) return i;
}
return -1;
}
function tsType(t: any, speckName: string = '', enumMap?: Map<string, string[]>): string {
if (!t) return 'unknown';
if (t.type === 'primitive') {

View file

@ -91,6 +91,8 @@ function emitZ3(speck: SpeckNode, options: Z3Options): string {
// Declare Speckl helper functions
// speckl_len: length of Array-simulated sequences
lines.push('(declare-fun speckl_len_Int ( (Array Int Int) ) Int)');
// speckl_nil: sentinel for nil/null comparisons (SpeckDL nil → Z3 constant)
lines.push('(declare-const speckl_nil Int)');
// Step 1: Declare sorts for record types
const recordTypes = collectRecordTypes(stateNode, actions);
@ -428,35 +430,38 @@ function translateExpr(expr: string, stateVars: StateVar[], suffix: string): str
// Handle prime notation for post-state: currentTerm' → currentTerm_post (invariant comparison)
e = e.replace(/\b(\w+)'/g, '$1_post');
// Handle implies(x, y) function call pattern
e = e.replace(/implies\s*\(([^,)]+),\s*([^)]+)\)/g, '(=> )');
// Handle bare implies keyword at top level
// Handle bare implies as SMT-LIB2 =>
// First, handle implies at top level of a line (no paren after second arg)
e = e.replace(/\bimplies\s+([^ ]+)\s+([^)]+)\)?/g, '(=> )');
// Clean up (=> ) empty cases
e = e.replace(/(=>\s*)/g, '(=> true true)');
// Handle implies(x, y) function call pattern — preserve both arguments
e = e.replace(/implies\s*\(([^,)]+),\s*([^)]+)\)/g, '(=> $1 $2)');
// Handle bare implies keyword: implies A B → (=> A B)
e = e.replace(/\bimplies\s+([^\s]+)\s+(.+)$/g, '(=> $1 $2)');
// Handle -> as implication (after field access cleanup to avoid false matches)// Handle -> as implication (after field access cleanup to avoid false matches)
e = e.replace(/->/g, '=>');
// Handle function calls: length(expr), min(a,b), etc.
// Handle function calls: length(expr), len(expr), min(a,b), etc.
e = e.replace(/\blength\(([^)]+)\)/g, 'len__$1__xx'); // marker, fix later
e = e.replace(/\blen\(([^)]+)\)/g, 'len__$1__xx'); // marker, fix later
e = e.replace(/\bmin\(([^,)]+),\s*([^)]+)\)/g, 'min__$1__$2__xx');
// Field access: a.balance → balance (drop qualifier for bound forall vars)
// For record fields accessed via dot, translate to Z3-safe names
e = e.replace(/(\w+)\.([\w_]+)/g, (_, obj, field) => {
// If the object is a state variable (map), .field is an accessor we can't resolve
// For bound forall variables, just use the field name
if (stateVarNames.has(obj)) {
// Accessing field on opaque sort — use a compound name
return `${obj}_${field}`;
// Field access: a.b.c → a_b_c (flatten all dot levels in one pass)
// For record fields accessed via dot, translate to Z3-safe compound names.
// Multi-level: m.price.amount → m_price_amount
e = e.replace(/(\w+)\.(\w+)(\.(\w+))*/g, (match, obj, field1, rest, field2) => {
if (rest) {
// Multi-level: m.price.amount → m_price_amount
return `${obj}_${field1}_${field2}`;
}
// For bound variables in forall, just emit the field
return `${obj}_${field}`;
return `${obj}_${field1}`;
});
// Handle nil → Z3 doesn't have nil/null. Translate to a sentinel:
// x != nil → (distinct x nil_sentinel) — but for Z3 we'll use a special value.
// For record types (opaque sorts), nil means "no value". We'll use a declared
// constant `nil` of a generic sort. For Int fields, nil = -1 sentinel.
// Simplest approach: translate nil to a Z3 declared constant `speckl_nil`.
e = e.replace(/\bnil\b/g, 'speckl_nil');
e = e.replace(/\bnull\b/g, 'speckl_nil');
// Handle notIn BEFORE general patterns
e = e.replace(/(\S+)\s+notIn\s+(\S+)\.keys/g, '(not (select $2 $1))');
@ -540,8 +545,9 @@ function translateExpr(expr: string, stateVars: StateVar[], suffix: string): str
e = e.replace(/__GE__/g, '>=');
e = e.replace(/__LE__/g, '<=');
// Wrap distinct
e = e.replace(/\bdistinct\s+(\S+)\s+(\S+)/g, '(distinct $1 $2)');
// Wrap distinct: X distinct Y → (distinct X Y)
// Handle both simple identifiers and parenthesized expressions
e = e.replace(/(\b\w+|\([^()]+\))\s+distinct\s+(\b\w+|\([^()]+\))/g, '(distinct $1 $2)');
// Fix remaining infix operators that escaped convertToPrefix
// word = word/val: convert to (= word val)
@ -568,6 +574,7 @@ function restoreStrings(expr: string, strings: string[]): string {
*/
function convertToPrefix(expr: string): string {
expr = convertOp(expr, '=>', '=>');
expr = convertOp(expr, 'distinct', 'distinct');
expr = convertOp(expr, 'and', 'and');
expr = convertOp(expr, 'or', 'or');
expr = convertOp(expr, '=', '=');

View file

@ -7,6 +7,7 @@ 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 { generateProtobuf } from './generators/protobuf.js';
import { generateZ3, Z3Options, parseInvariantsFromSource, parseNextFromSource } from './generators/z3.js';
import { generateRust } from './generators/rust.js';
import fs from 'fs';
@ -15,7 +16,7 @@ import path from 'path';
interface CompileOptions {
outputDir: string;
bomFormat: 'cdx' | 'spdx' | 'both';
target: 'typescript' | 'z3' | 'all';
target: 'typescript' | 'z3' | 'rust' | 'protobuf' | 'all';
verifyDepth: number;
}
@ -50,7 +51,7 @@ async function main() {
alias: 't',
describe: 'Compilation target',
type: 'string',
choices: ['typescript', 'z3', 'rust', 'all'],
choices: ['typescript', 'z3', 'rust', 'protobuf', 'all'],
default: 'typescript' as const,
})
.option('verify-depth', {
@ -74,7 +75,7 @@ async function main() {
const options = {
outputDir: argv.outputDir as string,
bomFormat: argv.bomFormat as 'cdx' | 'spdx' | 'both',
target: argv.target as 'typescript' | 'z3' | 'rust' | 'all',
target: argv.target as 'typescript' | 'z3' | 'rust' | 'protobuf' | 'all',
verifyDepth: argv.verifyDepth as number,
};
@ -101,6 +102,15 @@ async function main() {
generateRust(ast, options.outputDir);
}
// Generate protobuf schema if target is protobuf or all
// This is the load-bearing backend for the "no glue code" claim:
// the emitted .proto is the canonical wire format. Run `buf generate`
// on the output to produce Go/TS/Rust/C/Python/Java bindings.
if (options.target === 'protobuf' || options.target === 'all') {
console.log('\nGenerating protobuf schema (.proto)...');
generateProtobuf(ast, options.outputDir);
}
// Always generate provenance (it's the core output) if not z3-only or rust-only
if (options.target !== 'z3' && options.target !== 'rust') {
console.log('\nGenerating provenance graph (PROV-O JSON-LD)...');

View file

@ -6,6 +6,22 @@ export interface SpeckNode {
type: 'speck';
name: string;
members: MemberNode[];
// Optional metadata from top-of-file directives (version:, author:, license:, proto_package:, go_package:).
// Undefined when not specified in the source.
version?: string;
author?: string;
license?: string;
// Protobuf-specific overrides. If unset, generator uses sensible defaults.
// proto_package: e.g. "federated_meetup.v1"
// go_package: e.g. "github.com/sscoble/federated-meetup/proto/federated_meetup/v1;federatedmeetupv1"
protoPackage?: string;
goPackage?: string;
// Cycle 61: optional event-name suffix. When set, the .proto generator
// appends this string to every `event` block's message name (e.g. an
// event CreateGroup with event_suffix: "Payload" emits
// `message CreateGroupPayload { ... }`). Matches the federated-meetup
// convention where every transition payload is named XxxPayload.
eventSuffix?: string;
}
export interface ImportNode {
@ -39,10 +55,37 @@ export interface VerifyNode {
depth?: number;
}
export interface InterfaceField {
name: string;
type: TypeExpr;
}
export interface RpcMethod {
name: string;
requestType: string;
responseType: string;
// Streaming flags for gRPC/ConnectRPC
clientStreaming?: boolean;
serverStreaming?: boolean;
}
export interface ServiceNode {
type: 'service';
name: string;
rpcs: RpcMethod[];
}
export interface InterfaceNode {
type: 'interface';
name: string;
// Discriminator: which form does this interface take?
// 'enum' → only methods[] populated with variant names (returnType: Void)
// 'record' → only fields[] populated with name: Type entries
// 'service' → methods[] populated with method signatures (name(params) -> Type)
// 'mixed' → ambiguous; generators should inspect both
kind: 'enum' | 'record' | 'service' | 'mixed';
methods: MethodSignature[];
fields: InterfaceField[];
}
export interface MethodSignature {
@ -57,6 +100,42 @@ export interface EventNode {
fields: { name: string; type: TypeExpr }[];
}
// oneof: discriminated-union variant for proto wire-format compatibility.
// Cycle 57 — emits `oneof payload { TypeName field_name = N; ... }` when
// compiled to .proto. Variants reference top-level event/interface types;
// field_name is the snake_case wire name (lowerCamelCase convention from
// TypeScript).
export interface OneofNode {
type: 'oneof';
name: string;
variants: OneofVariant[];
}
export interface OneofVariant {
fieldName: string; // wire name, e.g. "create_group"
typeName: string; // referenced type, e.g. "CreateGroup"
}
// Cycle 58: Transition envelope — a proto message that wraps a oneof payload
// along with metadata fields (prior_state, signatures, hlc, branch_id, etc).
// Distinct from `event` because:
// - payload field references a OneofNode (not a regular message type)
// - generator emits a `oneof payload { ... }` block inside the message body
// - the discriminator field uses a proto enum (TransitionType)
// This is the shape federated-meetup needs for its `Transition` message.
export interface TransitionNode {
type: 'transition';
name: string;
fields: TransitionField[];
payloadOneof?: string; // name of the OneofNode whose variants go inside the oneof payload block
typeDiscriminator?: string; // name of the enum used to discriminate variants
}
export interface TransitionField {
name: string;
type: TypeExpr;
}
export interface ProvenanceNode {
type: 'provenance';
clauses: ProvenanceClause[];
@ -111,6 +190,15 @@ export interface BOMNode {
export interface StateNode {
type: 'state';
variables: StateVar[];
// Cycle 59: optional message-name override. When set, the .proto
// generator emits `message <messageName> { ... }` instead of
// `message <SpeckName>State { ... }`. This matches the consumer's
// expected binding name (e.g. StateSnapshot instead of
// FederatedMeetupState), so the Speckl-generated proto is a drop-in
// for the handwritten one. See:
// .hermes/skills/spec-compiler-dogfood/references/
// speckl-federated-meetup-protobuf-migration.md
messageName?: string;
}
export interface StateVar {
@ -141,24 +229,27 @@ export type ActionStatement =
| { type: 'return'; expr: string }
| { type: 'ifblock'; raw: string };
export type MemberNode =
| ImportNode
| InputNode
| OutputNode
| ConstraintNode
| VerifyNode
| InterfaceNode
| EventNode
| ProvenanceNode
| ReviewNode
| DerivesNode
| SatisfiesNode
| AuthorNode
| SourceNode
export type MemberNode =
| ImportNode
| InputNode
| OutputNode
| ConstraintNode
| VerifyNode
| InterfaceNode
| EventNode
| OneofNode
| TransitionNode
| ProvenanceNode
| ReviewNode
| DerivesNode
| SatisfiesNode
| AuthorNode
| SourceNode
| BOMNode
| StateNode
| InitNode
| ActionNode;
| ActionNode
| ServiceNode;
export interface TypeExpr {
type: 'primitive' | 'record' | 'list' | 'set' | 'map' | 'ident';
@ -233,6 +324,14 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
const name = headerMatch[1];
const members: MemberNode[] = [];
// Cycle 73: refactored from static-field hack to local-object pattern.
// Previously, proto_package / go_package / event_suffix were stored
// on (parseSpeck as any)._protoPackage etc — static function fields
// that leaked between parse calls. Now collected in a local metadata
// object and spread into the return value. No reset needed because
// the object is local to this invocation.
const metadata: { protoPackage?: string; goPackage?: string; eventSuffix?: string } = {};
let i = startIndex + 1;
let braceCount = 1;
@ -245,13 +344,35 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
continue;
}
// ── Top-of-file protobuf metadata directives ────────────────
// These attach to the SpeckNode and are NOT emitted as members.
// Format: `key: value` (string values may be quoted or bare).
// `author:`, `version:`, `license:` continue to be parsed as members
// (AuthorNode, etc.) for backward compatibility with the existing
// PROV-O / CycloneDX generators.
const metaMatch = line.match(/^(proto_package|go_package|event_suffix)\s*:\s*(.+)$/);
if (metaMatch) {
// Strip surrounding quotes if present
const value = metaMatch[2].trim().replace(/^['"]|['"]$/g, '');
if (metaMatch[1] === 'proto_package') {
metadata.protoPackage = value;
} else if (metaMatch[1] === 'go_package') {
metadata.goPackage = value;
} else if (metaMatch[1] === 'event_suffix') {
metadata.eventSuffix = value;
}
i++;
continue;
}
// Count braces on this line first
const openCount = (rawLine.match(/{/g) || []).length;
const closeCount = (rawLine.match(/}/g) || []).length;
// Check for block-opening members (those that open braces on the same line)
const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface ',
'state {', 'init {', 'verify ', 'constraint '];
'state {', 'init {', 'verify ', 'constraint ', 'input:', 'output:', 'service ', 'oneof ', 'transition ',
'state as '];
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
if (isBlockStarter && openCount > 0) {
@ -271,8 +392,8 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
}
// Handle constraint/verify blocks that may span multiple lines without braces
// (e.g. constraint Name: forall r in [0..4]:)
if (line.startsWith('constraint ') || line.startsWith('verify ')) {
// (e.g. constraint Name: forall r in [0..4]:, verify: always(implies(\n ...\n)))
if (line.startsWith('constraint ') || line.startsWith('verify ') || line.startsWith('verify:')) {
const member = parseMultiLineConstraintVerify(lines, i);
if (member) {
members.push(member);
@ -285,7 +406,8 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
nextLine.startsWith('state') || nextLine.startsWith('init') ||
nextLine.startsWith('action ') || nextLine.startsWith('event ') ||
nextLine.startsWith('interface ') || nextLine.startsWith('provenance ') ||
nextLine.startsWith('bom ') || nextLine.startsWith('}') ||
nextLine.startsWith('service ') || nextLine.startsWith('bom ') ||
nextLine.startsWith('}') ||
nextLine.startsWith('next:') || nextLine.startsWith('invariant ')) {
break;
}
@ -310,7 +432,10 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
i++;
}
return { type: 'speck', name, members };
return { type: 'speck', name, members,
protoPackage: metadata.protoPackage,
goPackage: metadata.goPackage,
eventSuffix: metadata.eventSuffix };
}
function findBlockEnd(lines: string[], startIndex: number, startBraceCount: number): number {
@ -356,6 +481,12 @@ function parseMemberBlock(lines: string[], startIndex: number): MemberNode | nul
if (firstLine.startsWith('state {') || firstLine === 'state') {
return parseStateBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('state as ')) {
// Cycle 63: `state as <MessageName> { ... }` form must dispatch
// through the same state-block parser so the `as` override on the
// first line is captured.
return parseStateBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('init:')) {
return parseInitBlockMultiline(lines, startIndex, startBraceCount);
}
@ -371,6 +502,15 @@ function parseMemberBlock(lines: string[], startIndex: number): MemberNode | nul
if (firstLine.startsWith('interface ')) {
return parseInterfaceBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('oneof ')) {
return parseOneofBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('transition ')) {
return parseTransitionBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('service ')) {
return parseServiceBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('provenance ')) {
return { type: 'provenance', clauses: [] };
}
@ -383,10 +523,70 @@ function parseMemberBlock(lines: string[], startIndex: number): MemberNode | nul
if (firstLine.startsWith('constraint ')) {
return parseConstraintBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('input:') || firstLine.startsWith('output:')) {
return parseInputOutputBlock(lines, startIndex, startBraceCount);
}
return null;
}
/**
* Parse a multi-line `input: { ... }` or `output: { ... }` block.
*
* The first line opens with `input: {` or `output: {` and the matching `}`
* may be many lines later (with field declarations on intermediate lines).
* We collect all lines up to the matching `}` (brace-counted) and join the
* inner content into a single record string that `parseTypeExpr` already
* knows how to handle.
*/
function parseInputOutputBlock(
lines: string[],
startIndex: number,
startBraceCount: number,
): MemberNode | null {
const header = lines[startIndex].trim();
const isInput = header.startsWith('input:');
const kind: 'input' | 'output' = isInput ? 'input' : 'output';
// Collect inner content lines, brace-counting across the block.
// startBraceCount is the brace count on the header line (1 for `input: {`).
// We need to find the line where braceCount returns to 0 (i.e. the closing `}`).
const innerLines: string[] = [];
let braceCount = 0;
let i = startIndex;
// First pass: extract inner content from header line (everything after the first `{`).
const headerLine = lines[startIndex].trim();
const headerNoComment = headerLine.replace(/\/\/.*$/, '').trim();
const openIdx = headerNoComment.indexOf('{');
if (openIdx >= 0) {
const after = headerNoComment.substring(openIdx + 1).trim();
if (after) innerLines.push(after);
}
braceCount = 1; // we're inside one `{` from the header
i = startIndex + 1;
while (i < lines.length && braceCount > 0) {
const raw = lines[i];
const stripped = raw.trim();
// Strip line comments (//...) — they cannot be inside a type expression.
const noComment = stripped.replace(/\/\/.*$/, '').trim();
for (const ch of noComment) {
if (ch === '{') braceCount++;
else if (ch === '}') braceCount--;
}
if (braceCount > 0 && noComment) {
// Inside the block — keep content (skip the closing `}` line itself).
innerLines.push(noComment);
}
i++;
}
// Join inner content with newlines; `parseTypeExpr`'s record branch
// splits on commas and matches `name: type` pairs.
const recordExpr = '{ ' + innerLines.join(' ') + ' }';
const typeExpr = parseTypeExpr(recordExpr);
return { type: kind, typeExpr };
}
function parseMember(line: string): MemberNode | null {
// input:
if (line.startsWith('input:')) {
@ -433,6 +633,16 @@ function parseMember(line: string): MemberNode | null {
if (line.startsWith('event ')) {
return parseEvent(line);
}
// oneof:
if (line.startsWith('oneof ')) {
return parseOneof(line);
}
// transition:
if (line.startsWith('transition ')) {
return parseTransition(line);
}
// provenance {
if (line.startsWith('provenance ')) {
@ -735,14 +945,17 @@ function parseInterface(line: string): InterfaceNode {
if (!match) {
throw new Error(`Failed to parse interface: ${line}`);
}
const name = match[1];
// This is a simplified parser - in a real implementation, we'd need to handle multi-line interfaces
// Header-only; parseInterfaceBlockMultiline fills in methods/fields/kind
// by scanning the lines inside the { ... } block.
return {
type: 'interface',
name,
kind: 'mixed',
methods: [],
fields: [],
};
}
@ -752,9 +965,9 @@ function parseEvent(line: string): EventNode {
if (!match) {
throw new Error(`Failed to parse event: ${line}`);
}
const name = match[1];
return {
type: 'event',
name,
@ -762,6 +975,34 @@ function parseEvent(line: string): EventNode {
};
}
// Cycle 57: parse `oneof Name {` (header only; parseOneofBlockMultiline fills variants).
function parseOneof(line: string): OneofNode {
// oneof Name {
const match = line.match(/^oneof\s+(\w+)\s*{/);
if (!match) {
throw new Error(`Failed to parse oneof: ${line}`);
}
return {
type: 'oneof',
name: match[1],
variants: [],
};
}
// Cycle 58: parse `transition Name {` (header only; parseTransitionBlockMultiline fills fields + payload-oneof).
function parseTransition(line: string): TransitionNode {
// transition Name {
const match = line.match(/^transition\s+(\w+)\s*{/);
if (!match) {
throw new Error(`Failed to parse transition: ${line}`);
}
return {
type: 'transition',
name: match[1],
fields: [],
};
}
function parseProvenanceBlock(lines: string[], line: string): ProvenanceNode {
// This is a simplified parser - in a real implementation, we'd parse the full block
return {
@ -853,6 +1094,13 @@ function parseSource(line: string): SourceNode {
}
function parseStateBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): StateNode {
// Cycle 59: support `state as <MessageName> { ... }` to override the
// generated .proto message name. Default (no `as`) keeps the existing
// `message <SpeckName>State { ... }` convention.
const headerLine = lines[startIndex].trim();
const asMatch = headerLine.match(/^state\s+as\s+(\w+)\s*{/);
const messageName = asMatch ? asMatch[1] : undefined;
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('/*'));
// State blocks separate fields with commas or newlines/semicolons.
@ -871,7 +1119,9 @@ function parseStateBlockMultiline(lines: string[], startIndex: number, startBrac
}
return acc + ' ' + line;
}, '');
return parseStateBlock(inner);
const result = parseStateBlock(inner);
if (messageName !== undefined) result.messageName = messageName;
return result;
}
function parseInitBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): InitNode {
@ -960,7 +1210,7 @@ function parseActionBlockMultiline(lines: string[], startIndex: number, startBra
if (nextLine.startsWith('precondition:') || nextLine.startsWith('postcondition:') ||
nextLine.startsWith('require ') || nextLine.startsWith('return ') ||
nextLine.startsWith('emit ') || nextLine.startsWith('let ') ||
nextLine.includes(':=')) {
nextLine.startsWith('if ') || nextLine.includes(':=')) {
break;
}
i++;
@ -1049,7 +1299,24 @@ function parseEventBlockMultiline(lines: string[], startIndex: number, startBrac
const fields: { name: string; type: TypeExpr }[] = [];
for (const line of innerLines) {
const match = line.match(/^(\w+)\s*:\s*(.+?),?$/);
// Cycle 62: strip inline `//` line-comments from the type expression
// before parsing. Without this, an event field like
// mesh_ip: Bytes // 4 bytes (IPv4 overlay)
// produces a TypeExpr whose name includes the comment text, which
// the .proto generator emits verbatim:
// bytes // 4 bytes (IPv4 overlay) mesh_ip = 2;
// (broken syntax — comment is in the type position).
const noComment = line.replace(/\/\/.*$/, '').trim();
// Cycle 65: strip default-value suffix `= <expr>` from the type
// expression. The federated-meetup speckdl has fields like
// initial_mesh_peers: List<InitialMeshPeer> = []
// where the default is meaningful in Speckl semantics (a state-
// machine init) but the .proto wire format doesn't carry a
// default — it's a generator concern. Without this, the type
// expression was emitted as `List<InitialMeshPeer> = []` and
// protoc rejects the resulting syntax.
const noDefault = noComment.replace(/\s*=\s*[^,]+$/, '').trim();
const match = noDefault.match(/^(\w+)\s*:\s*(.+?),?$/);
if (match) {
fields.push({ name: match[1], type: parseTypeExpr(match[2].replace(/,$/, '').trim()) });
}
@ -1065,12 +1332,20 @@ function parseInterfaceBlockMultiline(lines: string[], startIndex: number, start
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const methods: MethodSignature[] = [];
const fields: InterfaceField[] = [];
for (const line of innerLines) {
// name-only: enum-style values (e.g., "Idle", "Idle, Parsing" comma-separated)
// Split on comma and take each trimmed token
const tokens = line.split(',').map(s => s.trim()).filter(Boolean);
// Cycle 62: strip inline `//` line-comments from each line before
// token-splitting. Without this, a field like
// mesh_ip: Bytes // 4 bytes (IPv4 overlay)
// has its comment text bleed into the parsed type expression,
// which the .proto generator emits verbatim — broken syntax.
const noComment = line.replace(/\/\/.*$/, '').trim();
if (!noComment) continue;
// Split on comma so multi-variant lines like "Idle, Parsing, Done" all parse
const tokens = noComment.split(',').map(s => s.trim()).filter(Boolean);
for (const token of tokens) {
// Check if it’s a name(params) -> returnType method signature
// Check 1: method signature form: name(params) -> returnType
const methodMatch = token.match(/^(\w+)\s*\(([^)]*)\)\s*->\s*(.+?),?$/);
if (methodMatch) {
const name = methodMatch[1];
@ -1087,13 +1362,148 @@ function parseInterfaceBlockMultiline(lines: string[], startIndex: number, start
}
}
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
} else if (/^\w+$/.test(token)) {
// Name-only (enum-style) — treat as a method with no params and void return
continue;
}
// Check 2: field declaration form: field_name: TypeName
// Lowercase-starting identifier followed by ':' — record-style interface
const fieldMatch = token.match(/^(\w+)\s*:\s*(.+)$/);
if (fieldMatch && /^[a-z_]/.test(fieldMatch[1])) {
fields.push({ name: fieldMatch[1], type: parseTypeExpr(fieldMatch[2].trim()) });
continue;
}
// Check 3: bare identifier (enum variant) — name only, no params
if (/^\w+$/.test(token)) {
methods.push({ name: token, params: [], returnType: parseTypeExpr('Void') });
}
}
}
return { ...header, methods };
// Determine the kind based on what we collected
let kind: 'enum' | 'record' | 'service' | 'mixed' = 'mixed';
if (fields.length > 0 && methods.length === 0) {
kind = 'record';
} else if (methods.length > 0 && fields.length === 0) {
// Distinguish enum (all methods have empty params and Void return) from service
const allEnumLike = methods.every(m => m.params.length === 0 && m.returnType.name === 'Void');
kind = allEnumLike ? 'enum' : 'service';
}
return { ...header, kind, methods, fields };
}
// Cycle 57: parse a `oneof Name { ... }` block. Each non-empty, non-comment
// inner line is `field_name: TypeName`. The wire field_name follows
// lowerCamelCase convention (TypeScript/Connect) and is emitted to .proto
// as snake_case by the generator.
function parseOneofBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): OneofNode {
const firstLine = lines[startIndex].trim();
const header = parseOneof(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 variants: OneofVariant[] = [];
for (const line of innerLines) {
// Strip trailing comma or semicolon (proto-style "create_group: CreateGroup," is valid).
const trimmed = line.replace(/[,;]\s*$/, '');
const m = trimmed.match(/^(\w+)\s*:\s*(\w+)\s*$/);
if (m) {
variants.push({ fieldName: m[1], typeName: m[2] });
}
}
return { ...header, variants };
}
// Cycle 58: parse a `transition Name { ... }` block. Each non-empty,
// non-comment inner line is `field_name: TypeName`. Two field names
// have special meaning:
// - `payload: <OneofName>` — references a sibling `oneof` block;
// the proto generator will emit the variants inline as
// `oneof payload { ... }` inside the message body
// - `type: <EnumName>` — the discriminator enum for the variants
// Other fields are emitted as ordinary proto message fields.
function parseTransitionBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): TransitionNode {
const firstLine = lines[startIndex].trim();
const header = parseTransition(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: TransitionField[] = [];
let payloadOneof: string | undefined;
let typeDiscriminator: string | undefined;
for (const line of innerLines) {
const trimmed = line.replace(/[,;]\s*$/, '');
const m = trimmed.match(/^(\w+)\s*:\s*(.+)$/);
if (!m) continue;
const fieldName = m[1];
const typeStr = m[2].trim();
if (fieldName === 'payload') {
// The type is the name of a sibling oneof block.
payloadOneof = typeStr;
// Don't add a regular field for payload — it becomes the oneof.
continue;
}
if (fieldName === 'type') {
// The type is the name of a discriminator enum.
typeDiscriminator = typeStr;
// Don't add a regular field for type — it's a special discriminator.
continue;
}
fields.push({ name: fieldName, type: parseTypeExpr(typeStr) });
}
const result: TransitionNode = { ...header, fields };
if (payloadOneof !== undefined) result.payloadOneof = payloadOneof;
if (typeDiscriminator !== undefined) result.typeDiscriminator = typeDiscriminator;
return result;
}
function parseServiceBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): ServiceNode {
// service Name {
const firstLine = lines[startIndex].trim();
const match = firstLine.match(/^service\s+(\w+)\s*{?/);
const name = match ? match[1] : 'UnnamedService';
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 rpcs: RpcMethod[] = [];
for (const line of innerLines) {
// rpc MethodName(RequestType) returns (ResponseType);
// Also support streaming: rpc M(stream R) returns (S); or rpc M(R) returns (stream S);
const rpcMatch = line.match(/^rpc\s+(\w+)\s*\(\s*(stream\s+)?(\w+)\s*\)\s*returns\s*\(\s*(stream\s+)?(\w+)\s*\)\s*;?$/);
if (rpcMatch) {
const methodName = rpcMatch[1];
const clientStream = !!rpcMatch[2];
const requestType = rpcMatch[3];
const serverStream = !!rpcMatch[4];
const responseType = rpcMatch[5];
rpcs.push({
name: methodName,
requestType,
responseType,
clientStreaming: clientStream,
serverStreaming: serverStream,
});
} else {
// Cycle 73: error on unrecognized lines inside service blocks.
// Previously, a typo like `rc_purchase_ticket(...)` (missing the `p`
// in `rpc`) was silently swallowed — the line didn't match the
// rpc regex, so it was skipped without error. Silent data loss in
// a spec compiler is unacceptable; the whole point is that the
// spec is the source of truth.
throw new Error(
`Parse error in service block: unrecognized line "${line}". ` +
`Expected: rpc MethodName(RequestType) returns (ResponseType);`
);
}
}
return { type: 'service', name, rpcs };
}
// --- Multi-line constraint/verify parsing (no braces needed) ---
@ -1110,18 +1520,130 @@ function parseInterfaceBlockMultiline(lines: string[], startIndex: number, start
*/
function parseMultiLineConstraintVerify(lines: string[], startIndex: number): MemberNode | null {
const line = lines[startIndex].trim();
if (line.startsWith('verify ')) {
return parseVerifyAnyForm(lines, startIndex);
}
// Multi-line `verify:` form: verify: always(implies(\n ...\n))
// The body opens parens that span multiple lines; collect continuation
// lines until parens balance to zero, then return the joined string as
// the temporalExpr. speckl#54.
if (line.startsWith('verify:')) {
return parseVerifyColonBlock(lines, startIndex);
}
if (line.startsWith('constraint ')) {
return parseConstraintAnyForm(lines, startIndex);
}
return null;
}
/**
* Parse the `verify: <expr>` form (speckl#54) where the body can span
* multiple lines:
*
* verify: always(implies(
* cond1,
* cond2
* ))
*
* The whole expression (the part after `verify:`) is collected by
* appending subsequent non-empty, non-keyword-starting lines until
* parens/brackets/braces balance to zero. The joined string is stored
* in `temporalExpr` so the downstream LTL translator (typescript-state-machine
* verifyN()) sees a well-formed formula.
*
* The `name` is derived from the LTL operator's argument when it matches
* `always(Name)` / `eventually(Name)`. When the body is a richer expression
* (e.g. `always(implies(...))`) we fall back to `unnamed` — the generator
* still emits a working verifyN() that uses the full expression.
*/
function parseVerifyColonBlock(lines: string[], startIndex: number): VerifyNode | null {
const firstLine = lines[startIndex].trim();
// Strip the `verify:` prefix.
const head = firstLine.substring(firstLine.indexOf(':') + 1).trim();
if (!head) return null;
// If the head is already balanced on its own (single-line verify: expr),
// just return it directly — no continuation needed.
if (isExpressionComplete(head)) {
const nameMatch = head.match(/^(?:always|Always|eventually|Eventually)\s*\(\s*(\w+)\s*\)$/);
return {
type: 'verify',
name: nameMatch ? nameMatch[1] : 'unnamed',
temporalExpr: head,
};
}
// Multi-line: collect continuation lines until parens balance. The same
// break-condition list used in the parseSpeck skip-loop applies (don't
// swallow the next member's keyword). We additionally stop at any
// standalone `}` so a verify inside a brace-block still terminates.
const parts: string[] = [head];
let parens = 0, brackets = 0, braces = 0;
for (const ch of head) {
if (ch === '(') parens++;
else if (ch === ')') parens--;
else if (ch === '[') brackets++;
else if (ch === ']') brackets--;
else if (ch === '{') braces++;
else if (ch === '}') braces--;
}
let lastConsumed = startIndex;
for (let j = startIndex + 1; j < lines.length; j++) {
const raw = lines[j];
const nl = raw.trim();
if (!nl) {
// Empty line still updates counters (none) and advances lastConsumed
lastConsumed = j;
continue;
}
if (nl.startsWith('//') || nl.startsWith('/*')) {
// Skip standalone comments but treat them as part of the block
// (the LTL translator doesn't care about comments in temporalExpr).
lastConsumed = j;
continue;
}
// Stop at a new top-level member keyword — that's the next member.
if (
nl.startsWith('constraint ') || nl.startsWith('verify ') ||
nl.startsWith('constraint:') || nl.startsWith('verify:') ||
nl.startsWith('state') || nl.startsWith('init') ||
nl.startsWith('action ') || nl.startsWith('event ') ||
nl.startsWith('interface ') || nl.startsWith('provenance ') ||
nl.startsWith('bom ') || nl === '}' ||
nl.startsWith('next:') || nl.startsWith('invariant ')
) {
break;
}
// Update depth counters.
for (const ch of raw) {
if (ch === '(') parens++;
else if (ch === ')') parens--;
else if (ch === '[') brackets++;
else if (ch === ']') brackets--;
else if (ch === '{') braces++;
else if (ch === '}') braces--;
}
parts.push(nl);
lastConsumed = j;
if (parens === 0 && brackets === 0 && braces === 0) {
break;
}
}
const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
const nameMatch = joined.match(/^(?:always|Always|eventually|Eventually)\s*\(\s*(\w+)\s*\)$/);
return {
type: 'verify',
name: nameMatch ? nameMatch[1] : 'unnamed',
temporalExpr: joined,
};
}
function parseConstraintAnyForm(lines: string[], startIndex: number): ConstraintNode | null {
const line = lines[startIndex].trim();

16
compiler/test-ast.mjs Normal file
View file

@ -0,0 +1,16 @@
import { parseSpeckFile } from './dist/parser.js';
import fs from 'fs';
const ast = parseSpeckFile('/home/sscoble/speckl/examples/Raft.speck');
for (const speck of ast.specks) {
for (const m of speck.members) {
if (m.type === 'action') {
for (const s of (m.statements || [])) {
if (s.type === 'ifblock') {
console.log('--- ifblock in', m.name, '---');
console.log('raw:', s.raw.substring(0, 200));
}
}
}
}
}

View file

@ -12,7 +12,7 @@
"@type": "prov:Generation",
"prov:activity": {
"@type": "prov:Activity",
"prov:startTime": "2026-05-11T17:13:39.546Z"
"prov:startTime": "2026-07-19T08:31:16.455Z"
}
},
"rdfs:label": "RetryHandler",

View file

@ -38,7 +38,7 @@
"name": "Scott Scoble",
"email": "scott@scoble.me"
},
"timestamp": "2026-05-11T17:13:39.549Z"
"timestamp": "2026-07-19T08:31:16.458Z"
},
{
"subject": "speck:RetryHandler",
@ -53,7 +53,7 @@
"name": "Speckl Compiler",
"email": "compiler@speckl.scoble.me"
},
"timestamp": "2026-05-11T17:13:39.549Z"
"timestamp": "2026-07-19T08:31:16.458Z"
},
{
"subject": "speck:RetryHandler",
@ -68,7 +68,7 @@
"name": "Speckl Compiler",
"email": "compiler@speckl.scoble.me"
},
"timestamp": "2026-05-11T17:13:39.549Z"
"timestamp": "2026-07-19T08:31:16.458Z"
}
]
}

View file

@ -13,7 +13,7 @@
"spdxVersion": "3.0.1",
"dataLicense": "CC0-1.0",
"creationInfo": {
"created": "2026-05-11T17:13:39.551Z",
"created": "2026-07-19T08:31:16.459Z",
"createdBy": [
"Organization: Speckl",
"tool: speckl-compile-0.2.0"
@ -27,7 +27,7 @@
"@type": "spdx:Tool",
"name": "speckl-compile"
},
"timestamp": "2026-05-11T17:13:39.551Z",
"timestamp": "2026-07-19T08:31:16.459Z",
"comment": "Review type: manual"
}
]

22
compiler/test-parser.mjs Normal file
View file

@ -0,0 +1,22 @@
import { parseActionStatements } from './dist/expr/stmt-parser.js';
const tests = [
['serverState[voter].currentTerm := req.term'],
['let newTerm := 5', 'newTerm := 6'],
['require foo == bar'],
['return 5'],
['emit VoteGranted { voter: voter, candidate: 1, term: 2 }'],
['if foo { x := 1 } else { y := 2 }'],
['x := 1', 'y := 2', 'return 5'],
];
for (const lines of tests) {
console.log('---');
console.log('input:', lines);
try {
const out = parseActionStatements(lines);
console.log('output:', JSON.stringify(out, null, 2).substring(0, 700));
} catch (e) {
console.log('error:', e.message);
}
}

View file

@ -0,0 +1,29 @@
speck ConditionalToggle {
state {
isOn: Bool
count: Nat
}
init {
isOn == false
count == 0
}
invariant SwitchConsistent {
isOn in {true, false}
}
action Toggle {
require true
if (isOn) {
isOn := false
} else {
isOn := true
}
count := count + 1
return isOn
}
next: Toggle
}

View file

@ -0,0 +1 @@
{"version":"4.1.9","results":[[":compiler/dist/__tests__/compiler.test.js",{"duration":21.732912999999996,"failed":false}],[":compiler/__tests__/parser.test.ts",{"duration":56.655685000000005,"failed":false}],[":compiler/src/__tests__/compiler.test.ts",{"duration":3388.7825,"failed":false}]]}

View file

@ -0,0 +1,21 @@
{
"@context": {
"prov": "http://www.w3.org/ns/prov#",
"speckl": "https://speckl.scoble.me/ns/v0.2#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#"
},
"@graph": [
{
"@type": "prov:Entity",
"@id": "speck:OAuth2AuthorizationCode",
"prov:qualifiedGeneration": {
"@type": "prov:Generation",
"prov:activity": {
"@type": "prov:Activity",
"prov:startTime": "2026-06-08T14:27:09.658Z"
}
},
"rdfs:label": "OAuth2AuthorizationCode"
}
]
}

View file

@ -0,0 +1,15 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"components": [],
"metadata": {
"tools": [],
"component": {
"type": "application",
"name": "OAuth2AuthorizationCode",
"version": "0.2.0",
"properties": []
}
},
"annotations": []
}

View file

@ -0,0 +1,26 @@
{
"@context": [
"https://spdx.github.io/spdx-v3/spdx-context.jsonld",
{
"speckl": "https://speckl.scoble.me/ns/v0.2#"
}
],
"@type": "spdx:Document",
"spdx": {
"@id": "spdx:OAuth2AuthorizationCode",
"@type": "spdx:Snippet",
"name": "OAuth2AuthorizationCode",
"spdxVersion": "3.0.1",
"dataLicense": "CC0-1.0",
"creationInfo": {
"created": "2026-06-08T14:27:09.674Z",
"createdBy": [
"Organization: Speckl",
"tool: speckl-compile-0.2.0"
]
}
},
"builtBy": [],
"relationships": [],
"elements": []
}

View file

@ -0,0 +1,192 @@
// Auto-generated by speckl-compile from OAuth2AuthorizationCode
// DO NOT EDIT MANUALLY — regenerate from .speckdl source
export const OAuth2AuthorizationCodeOAuth2AuthorizationCodeState = {
Open: 'open',
Closed: 'closed',
} as const;
export type OAuth2AuthorizationCodeOAuth2AuthorizationCodeState = typeof OAuth2AuthorizationCodeOAuth2AuthorizationCodeState[keyof typeof OAuth2AuthorizationCodeOAuth2AuthorizationCodeState];
// Simple hash utility for SpeckDL hash() function
// In production, replace with a real crypto hash
function simpleHash(s: string, method?: string): string {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
}
return 'h_' + Math.abs(h).toString(16);
}
// Runtime type placeholders (opaque record types)
type AccessToken = any;
type AuthorizationCode = any;
type Bool }) = any;
type ClientId = any;
type ClientSecret = any;
type CodeChallenge = any;
type CodeVerifier = any;
type Map(AccessToken, { client_id: ClientId = any;
type Map(AuthorizationCode, { client_id: ClientId = any;
type Map(RefreshToken, { client_id: ClientId = any;
type Nat }) = any;
type RedirectUri = any;
type RefreshRequest = any;
type RefreshToken }) = any;
type Scope = any;
type SessionId = any;
type State = any;
type TokenRequest = any;
type UserId = any;
export class OAuth2AuthorizationCodeMachine {
clients!: Map<ClientId, { redirect_uris: Set<RedirectUri>; client_secret: ClientSecret }>;
authorizationCodes!: Map(AuthorizationCode, { client_id: ClientId;
redirect_uri!: RedirectUri;
state!: State;
code_challenge!: CodeChallenge;
code_challenge_method!: string;
scope!: Scope;
user_id!: UserId;
session_id!: SessionId;
created_at!: number;
used!: Bool });
accessTokens!: Map(AccessToken, { client_id: ClientId;
user_id!: UserId;
scope!: Scope;
expires_at!: number;
session_id!: SessionId;
refresh_token!: RefreshToken });
refreshTokens!: Map(RefreshToken, { client_id: ClientId;
user_id!: UserId;
scope!: Scope;
access_token!: AccessToken;
created_at!: Nat });
sessions!: Map<SessionId, { user_id: UserId }>;
stateNonces!: Map<State, { client_id: ClientId; redirect_uri: RedirectUri }>;
pkceChallenges!: Map<CodeChallenge, { code_verifier_hash: string; method: string }>;
currentTime!: number;
decisionLog: any[] = [];
constructor() {
// No init block defined
}
Authorize(client: ClientId, redirectUri: RedirectUri, scope: Scope, userId: UserId, sessionId: SessionId) {
const xxx: any = {} as any; // FIXME: parser dropped let/forall
const client_id: any = {} as any; // FIXME: parser dropped let/forall
const code_verifier_hash: any = {} as any; // FIXME: parser dropped let/forall
const __STR_LIT_0__: any = {} as any; // FIXME: parser dropped let/forall
const method: any = {} as any; // FIXME: parser dropped let/forall
const __STR_LIT_1__: any = {} as any; // FIXME: parser dropped let/forall
if (!(this.clients.has(client))) throw new Error('Guard failed: this.clients.has(client)');
if (!(this.clients.get(client)!.redirect_uris.has(redirectUri))) throw new Error('Guard failed: this.clients.get(client)!.redirect_uris.has(redirectUri)');
if (!(this.sessions.has(sessionId))) throw new Error('Guard failed: this.sessions.has(sessionId)');
if (!(this.sessions.get(sessionId)!.user_id === userId)) throw new Error('Guard failed: this.sessions.get(sessionId)!.user_id === userId');
const state = "state_" + simpleHash(client + redirectUri + this.currentTime);
const codeVerifier = "verifier_" + simpleHash(client + userId + this.currentTime);
const codeChallenge = simpleHash(codeVerifier, "S256");
this.stateNonces.set(state, { client_id: client, redirect_uri: redirectUri });
this.pkceChallenges.set(codeChallenge, { code_verifier_hash: simpleHash(codeVerifier, __STR_LIT_0__), method: __STR_LIT_1__ });
const authCode = "code_" + simpleHash(client + userId + this.currentTime);
this.authorizationCodes.set(authCode, { client_id: client, redirect_uri: redirectUri, state: state, code_challenge: codeChallenge, code_challenge_method: __STR_LIT_0__, scope: scope, user_id: userId, session_id: sessionId, created_at: this.currentTime, used: false });
}
ApproveAuthorization(code: AuthorizationCode, userId: UserId) {
const xxx: any = {} as any; // FIXME: parser dropped let/forall
if (!(this.authorizationCodes.has(code))) throw new Error('Guard failed: this.authorizationCodes.has(code)');
const c = this.authorizationCodes.get(code)!;
if (!(c.user_id === userId)) throw new Error('Guard failed: c.user_id === userId');
if (!(c.used === false)) throw new Error('Guard failed: c.used === false');
}
ExchangeCode(req: TokenRequest) {
const xxx: any = {} as any; // FIXME: parser dropped let/forall
const client_id: any = {} as any; // FIXME: parser dropped let/forall
if (!(this.clients.has(req.client_id))) throw new Error('Guard failed: this.clients.has(req.client_id)');
if (!(this.authorizationCodes.has(req.code))) throw new Error('Guard failed: this.authorizationCodes.has(req.code)');
const c = this.authorizationCodes.get(req.code)!;
if (!(c.used === false)) throw new Error('Guard failed: c.used === false');
if (!(c.client_id === req.client_id)) throw new Error('Guard failed: c.client_id === req.client_id');
if (!(c.redirect_uri === req.redirect_uri)) throw new Error('Guard failed: c.redirect_uri === req.redirect_uri');
const expectedChallenge = simpleHash(req.code_verifier, c.code_challenge_method);
if (!(expectedChallenge === c.code_challenge)) throw new Error('Guard failed: expectedChallenge === c.code_challenge');
this.pkceChallenges.delete(c.code_challenge);
this.stateNonces.delete(c.state);
const accessToken = "access_" + simpleHash(req.client_id + c.user_id + this.currentTime);
const refreshToken = "refresh_" + simpleHash(req.client_id + c.user_id + this.currentTime);
const expiresAt = this.currentTime + 3600;
this.accessTokens.set(accessToken, { client_id: req.client_id, user_id: c.user_id, scope: c.scope, expires_at: expiresAt, session_id: c.session_id, refresh_token: refreshToken });
this.refreshTokens.set(refreshToken, { client_id: req.client_id, user_id: c.user_id, scope: c.scope, access_token: accessToken, created_at: this.currentTime });
}
RefreshToken(req: RefreshRequest) {
const xxx: any = {} as any; // FIXME: parser dropped let/forall
const client_id: any = {} as any; // FIXME: parser dropped let/forall
if (!(this.clients.has(req.client_id))) throw new Error('Guard failed: this.clients.has(req.client_id)');
if (!(this.refreshTokens.has(req.refresh_token))) throw new Error('Guard failed: this.refreshTokens.has(req.refresh_token)');
const r = this.refreshTokens.get(req.refresh_token)!;
if (!(r.client_id === req.client_id)) throw new Error('Guard failed: r.client_id === req.client_id');
this.accessTokens.delete(r.access_token);
const newAccessToken = "access_" + simpleHash(req.client_id + r.user_id + this.currentTime);
const newRefreshToken = "refresh_" + simpleHash(req.client_id + r.user_id + this.currentTime);
const expiresAt = this.currentTime + 3600;
this.accessTokens.set(newAccessToken, { client_id: req.client_id, user_id: r.user_id, scope: req.scope.or(r.scope), expires_at: expiresAt, session_id: r.access_token.session_id, refresh_token: newRefreshToken });
this.refreshTokens.set(newRefreshToken, { client_id: req.client_id, user_id: r.user_id, scope: req.scope.or(r.scope), access_token: newAccessToken, created_at: this.currentTime });
this.refreshTokens.delete(req.refresh_token);
}
RevokeToken(client: ClientId, token: string, tokenType: string) {
const xxx: any = {} as any; // FIXME: parser dropped let/forall
const t: any = {} as any; // FIXME: parser dropped let/forall
const r: any = {} as any; // FIXME: parser dropped let/forall
if (!(this.clients.has(client))) throw new Error('Guard failed: this.clients.has(client)');
if (tokenType === "access") {
if (!(this.accessTokens.has(token))) throw new Error('Guard failed: this.accessTokens.has(token)');
this.t = this.accessTokens.get(token)!;
if (!(t.client_id === client)) throw new Error('Guard failed: t.client_id === client');
this.s[token] = null;
this.s[t.refresh_token] = null;
} else {
if (tokenType === "refresh") {
if (!(this.refreshTokens.has(token))) throw new Error('Guard failed: this.refreshTokens.has(token)');
this.r = this.refreshTokens.get(token)!;
if (!(r.client_id === client)) throw new Error('Guard failed: r.client_id === client');
this.s[token] = null;
this.s[r.access_token] = null;
}
}
}
VerifyPKCE(code: AuthorizationCode, verifier: CodeVerifier) {
const xxx: any = {} as any; // FIXME: parser dropped let/forall
if (!(this.authorizationCodes.has(code))) throw new Error('Guard failed: this.authorizationCodes.has(code)');
const c = this.authorizationCodes.get(code)!;
if (!(c.used === false)) throw new Error('Guard failed: c.used === false');
const expectedChallenge = simpleHash(verifier, c.code_challenge_method);
if (!(expectedChallenge === c.code_challenge)) throw new Error('Guard failed: expectedChallenge === c.code_challenge');
}
// V1: Always(NoTokenLeakage)
// Z3 call-out: Bounded Model Checking (BMC) with depth = decisionLog.length+1.
// Mirrors the SMT-LIB BMC emitted by compiler/src/generators/z3.ts (emitBMC).
// The inner expression is SpeckDL; we translate it to a TS boolean
// check that reads the current class state via this.<stateVar>.
async verify1(): Promise<boolean> {
// Temporal: Always(NoTokenLeakage)
// LTL: always(p). Bounded check — p must hold at every step in the
// bounded trace (decision log + current state). The first step
// where p is false violates the always-quantified property.
if (!(NoTokenLeakage)) return false;
for (const _evt of this.decisionLog) {
// Each emit() entry is a step boundary; p is re-evaluated
// against the live state (BMC "safety-at-horizon" view).
if (!(NoTokenLeakage)) return false;
}
return true;
}
}

View file

@ -0,0 +1,61 @@
{
"@context": {
"prov": "http://www.w3.org/ns/prov#",
"speckl": "https://speckl.scoble.me/ns/v0.2#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#"
},
"@graph": [
{
"@type": "prov:Entity",
"@id": "speck:RetryHandler",
"prov:qualifiedGeneration": {
"@type": "prov:Generation",
"prov:activity": {
"@type": "prov:Activity",
"prov:startTime": "2026-06-30T02:09:28.187Z"
}
},
"rdfs:label": "RetryHandler",
"prov:wasDerivedFrom": {
"@id": "speck:FaultHandler"
},
"rdfs:comment": "specialization for retry logic",
"speckl:satisfies": {
"@id": "requirement:RESILIENCE-REQ-01"
},
"prov:wasAttributedTo": {
"@id": "agent:Scott Scoble"
},
"prov:wasInformedBy": {
"@id": "source:architecture_review:AR-2024-0215"
}
},
{
"@type": "prov:Entity",
"@id": "speck:FaultHandler"
},
{
"@type": "prov:Entity",
"@id": "requirement:RESILIENCE-REQ-01",
"rdfs:label": "RESILIENCE-REQ-01",
"speckl:clause": "3.1.2"
},
{
"@type": "prov:Entity",
"@id": "requirement:RESILIENCE-REQ-02",
"rdfs:label": "RESILIENCE-REQ-02"
},
{
"@type": "prov:Agent",
"@id": "agent:Scott Scoble",
"rdfs:label": "Scott Scoble",
"foaf:email": "scott@scoble.me"
},
{
"@type": "prov:Activity",
"@id": "source:architecture_review:AR-2024-0215",
"prov:type": "architecture_review",
"rdfs:label": "architecture_review - AR-2024-0215"
}
]
}

View file

@ -0,0 +1,74 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"components": [],
"metadata": {
"tools": [],
"component": {
"type": "application",
"name": "RetryHandler",
"version": "0.2.0",
"properties": [
{
"name": "speckl:derivesFrom",
"value": "FaultHandler"
},
{
"name": "speckl:satisfies",
"value": "RESILIENCE-REQ-01 clause 3.1.2"
},
{
"name": "speckl:satisfies",
"value": "RESILIENCE-REQ-02"
}
]
}
},
"annotations": [
{
"subject": "speck:RetryHandler",
"annotations": [
{
"type": "tag",
"value": "provenanceType",
"text": "author"
}
],
"author": {
"name": "Scott Scoble",
"email": "scott@scoble.me"
},
"timestamp": "2026-06-30T02:09:28.192Z"
},
{
"subject": "speck:RetryHandler",
"annotations": [
{
"type": "tag",
"value": "provenanceType",
"text": "source"
}
],
"author": {
"name": "Speckl Compiler",
"email": "compiler@speckl.scoble.me"
},
"timestamp": "2026-06-30T02:09:28.192Z"
},
{
"subject": "speck:RetryHandler",
"annotations": [
{
"type": "tag",
"value": "reviewType",
"text": "manual"
}
],
"author": {
"name": "Speckl Compiler",
"email": "compiler@speckl.scoble.me"
},
"timestamp": "2026-06-30T02:09:28.192Z"
}
]
}

View file

@ -0,0 +1,113 @@
{
"@context": [
"https://spdx.github.io/spdx-v3/spdx-context.jsonld",
{
"speckl": "https://speckl.scoble.me/ns/v0.2#"
}
],
"@type": "spdx:Document",
"spdx": {
"@id": "spdx:RetryHandler",
"@type": "spdx:Snippet",
"name": "RetryHandler",
"spdxVersion": "3.0.1",
"dataLicense": "CC0-1.0",
"creationInfo": {
"created": "2026-06-30T02:09:28.194Z",
"createdBy": [
"Organization: Speckl",
"tool: speckl-compile-0.2.0"
]
},
"annotations": [
{
"@type": "spdx:Annotation",
"annotationType": "spdx:REVIEW",
"annotator": {
"@type": "spdx:Tool",
"name": "speckl-compile"
},
"timestamp": "2026-06-30T02:09:28.194Z",
"comment": "Review type: manual"
}
]
},
"builtBy": [],
"relationships": [
{
"from": {
"@id": "spdx:RetryHandler"
},
"to": {
"@id": "spdx:speck:FaultHandler"
},
"relationshipType": "spdx:derivesFrom"
},
{
"from": {
"@id": "spdx:RetryHandler"
},
"to": {
"@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"
},
{
"from": {
"@id": "spdx:RetryHandler"
},
"to": {
"@id": "spdx:agent:Scott Scoble"
},
"relationshipType": "spdx:wasAttributedTo"
},
{
"from": {
"@id": "spdx:RetryHandler"
},
"to": {
"@id": "spdx:source:architecture_review:AR-2024-0215"
},
"relationshipType": "spdx:wasInformedBy"
}
],
"elements": [
{
"@type": "spdx:Requirement",
"@id": "spdx:requirement:RESILIENCE-REQ-01",
"name": "RESILIENCE-REQ-01"
},
{
"@type": "spdx:Requirement",
"@id": "spdx:requirement:RESILIENCE-REQ-02",
"name": "RESILIENCE-REQ-02"
},
{
"@type": "spdx:Person",
"@id": "spdx:agent:Scott Scoble",
"name": "Scott Scoble",
"email": "scott@scoble.me"
},
{
"@type": "spdx:Activity",
"@id": "spdx:source:architecture_review:AR-2024-0215",
"name": "architecture_review - AR-2024-0215",
"type": "architecture_review"
}
]
}