Conversation
Add a parallel compilation backend that emits BitcoinSX text from the ANF IR, preserving high-level structure that the hex backend destroys: - Private methods → SX #macros (called by bare name) - Bounded loops → SX repeat Nn ... end blocks - If/else → SX if ... else ... endIf - Constructor params → SX .variable placeholders - Opcodes → SX camelCase names (dup, checkSig, hash160, etc.) The SX backend operates on the same ANF IR as the existing hex backend (passes 5+6) but performs its own stack scheduling to emit human-readable SX text instead of raw Bitcoin Script bytes. Architecture: - 07-sx-emit.ts: Main emitter — orchestrates macros, dispatch, method bodies - 07-sx-stack.ts: SX stack scheduler — tracks named values, emits SX tokens - 07-sx-codegen-bridge.ts: OP→SX name table + StackOp-to-SX converter Stateful contract intrinsics (checkPreimage, deserializeState, addOutput, extractors, output builders) and complex builtins (pow, sqrt, gcd, etc.) delegate to the existing hex backend via lowerBindingsToOps(), avoiding code duplication of ~1000 lines of opcode sequences. Pipeline integration: - New emitSX compile option triggers pass 7 alongside passes 5+6 - SX text attached to CompileResult.sx and RunarArtifact.sx - SX emission failure is non-fatal (warning, not error) - CLI: runar compile Contract.runar.ts --sx writes a .sx file Test coverage: 41 tests covering P2PKH, HashLock, Escrow (multi-method dispatch), if/else conditionals, private methods as macros, bounded loops as repeat blocks, property initializers, Counter (stateful with checkPreimage + state deserialization + output verification), and MessageBoard (stateful with mixed readonly/mutable properties).
There was a problem hiding this comment.
Pull request overview
Adds a parallel BitcoinSX (.sx) text backend to the Runar compiler, emitting human-readable SX from ANF IR while preserving higher-level constructs (macros, loops, if/else) and delegating complex opcode sequences to the existing stack-lowering backend.
Changes:
- Introduces Pass 7 SX emission pipeline (emitter + stack scheduler + StackOp→SX bridge).
- Wires SX output into compiler options/artifact/CLI (
emitSX,--sx) and exposes SX IR types. - Adds a dedicated SX test suite covering common contract patterns.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/runar-compiler/src/passes/07-sx-stack.ts | SX stack scheduler, control-flow emission, and delegation to hex backend for complex intrinsics |
| packages/runar-compiler/src/passes/07-sx-emit.ts | High-level SX emitter: header, macros for private methods, public method body/dispatch |
| packages/runar-compiler/src/passes/07-sx-codegen-bridge.ts | Converts StackOp sequences into SX tokens via opcode name mapping |
| packages/runar-compiler/src/passes/05-stack-lower.ts | Exposes lowering helper and stack inspection to support SX delegation |
| packages/runar-compiler/src/ir/sx-ir.ts | SX output data structures (sections + emit result) |
| packages/runar-compiler/src/ir/index.ts | Re-exports SX IR types |
| packages/runar-compiler/src/ir/artifact.ts | Extends artifact type with optional sx |
| packages/runar-compiler/src/artifact/assembler.ts | Extends assembled artifact shape with optional sx |
| packages/runar-compiler/src/index.ts | Adds emitSX compile option and attaches SX text to result/artifact |
| packages/runar-compiler/src/tests/07-sx-emit.test.ts | Adds tests for SX output across stateless/stateful patterns |
| packages/runar-cli/src/commands/compile.ts | Adds --sx plumbing to compiler invocation and writes .sx file |
| packages/runar-cli/src/bin.ts | Adds --sx CLI option |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Pre-analyze a private method to determine its stack effect. | ||
| * Consumes = param count. Produces = 1 unless the method is void | ||
| * (ends in assert or update_prop without producing a value). | ||
| */ | ||
| export function analyzeMethodStackEffect(method: ANFMethod): MethodStackEffect { | ||
| const consumes = method.params.length; | ||
| const body = method.body; | ||
| if (body.length === 0) return { consumes, produces: 0 }; | ||
|
|
||
| const lastValue = body[body.length - 1]!.value; | ||
| // Methods ending in assert or update_prop are void (produce no result) | ||
| if (lastValue.kind === 'assert' || lastValue.kind === 'update_prop') { | ||
| return { consumes, produces: 0 }; | ||
| } | ||
| return { consumes, produces: 1 }; |
There was a problem hiding this comment.
analyzeMethodStackEffect only treats methods ending in assert/update_prop as void, but private helpers can be void while ending in if/loop (e.g., examples/ts/tic-tac-toe/TicTacToe.runar.ts:170-176 ends with an if containing only asserts). This will miscompute produces as 1, causing macro-call stack tracking to push a phantom result and desync subsequent stack scheduling. Consider computing produces by analyzing the terminal binding recursively (e.g., if => both branches produce, loop/assert/update_prop => 0), or by threading return/void info from typechecking.
| /** | |
| * Pre-analyze a private method to determine its stack effect. | |
| * Consumes = param count. Produces = 1 unless the method is void | |
| * (ends in assert or update_prop without producing a value). | |
| */ | |
| export function analyzeMethodStackEffect(method: ANFMethod): MethodStackEffect { | |
| const consumes = method.params.length; | |
| const body = method.body; | |
| if (body.length === 0) return { consumes, produces: 0 }; | |
| const lastValue = body[body.length - 1]!.value; | |
| // Methods ending in assert or update_prop are void (produce no result) | |
| if (lastValue.kind === 'assert' || lastValue.kind === 'update_prop') { | |
| return { consumes, produces: 0 }; | |
| } | |
| return { consumes, produces: 1 }; | |
| function getNestedBindingList( | |
| value: Record<string, unknown>, | |
| ...keys: string[] | |
| ): ANFBinding[] | null { | |
| for (const key of keys) { | |
| const nested = value[key]; | |
| if (Array.isArray(nested)) return nested as ANFBinding[]; | |
| } | |
| return null; | |
| } | |
| function terminalBindingProducesValue(binding: ANFBinding): boolean { | |
| const value = binding.value as ANFValue & Record<string, unknown>; | |
| switch (value.kind) { | |
| case 'assert': | |
| case 'update_prop': | |
| case 'loop': | |
| return false; | |
| case 'if': { | |
| const thenBody = getNestedBindingList(value, 'thenBody', 'thenBindings', 'thenBranch'); | |
| const elseBody = getNestedBindingList(value, 'elseBody', 'elseBindings', 'elseBranch'); | |
| // If this IR variant carries branch bodies directly, both branches must | |
| // produce for the if-expression to produce. | |
| if (thenBody && elseBody) { | |
| return bindingsProduceValue(thenBody) && bindingsProduceValue(elseBody); | |
| } | |
| // Fall back conservatively to the previous behavior for unsupported | |
| // shapes rather than changing semantics for other IR nodes. | |
| return true; | |
| } | |
| default: | |
| return true; | |
| } | |
| } | |
| function bindingsProduceValue(bindings: readonly ANFBinding[]): boolean { | |
| if (bindings.length === 0) return false; | |
| return terminalBindingProducesValue(bindings[bindings.length - 1]!); | |
| } | |
| /** | |
| * Pre-analyze a private method to determine its stack effect. | |
| * Consumes = param count. Produces = 1 only when the terminal binding | |
| * recursively yields a value. | |
| */ | |
| export function analyzeMethodStackEffect(method: ANFMethod): MethodStackEffect { | |
| const consumes = method.params.length; | |
| return { | |
| consumes, | |
| produces: bindingsProduceValue(method.body) ? 1 : 0, | |
| }; |
| if (!terminal) { | ||
| this.stackMap.pop(); | ||
| this.emit('verify'); | ||
| this.stackMap.push(null); |
There was a problem hiding this comment.
lowerAssert pushes null onto stackMap after emitting verify, but verify (OP_VERIFY) consumes the asserted value and leaves nothing on the physical stack. This makes stackMap.depth larger than the real stack and can produce incorrect pick/roll depths later. Align with the hex backend’s lowerAssert (05-stack-lower.ts:1880-1889) by not pushing a placeholder for non-terminal asserts.
| this.stackMap.push(null); |
| for (let i = this.stackMap.depth - 1; i >= 0; i--) { | ||
| stackState.unshift(this.stackMap.peekAtDepth(i) ?? `__anon_${i}`); |
There was a problem hiding this comment.
delegateToHexBackend builds stackState using unshift while iterating depth-1..0, which reverses the intended bottom-to-top order. LoweringContext expects params in bottom-to-top order (05-stack-lower.ts:343-346), so this reversal will cause delegated lowering to operate on the wrong stack layout. Build stackState in bottom-to-top order (e.g., iterate bottom→top and push).
| for (let i = this.stackMap.depth - 1; i >= 0; i--) { | |
| stackState.unshift(this.stackMap.peekAtDepth(i) ?? `__anon_${i}`); | |
| for (let i = 0; i < this.stackMap.depth; i++) { | |
| stackState.push(this.stackMap.peekAtDepth(i) ?? `__anon_${i}`); |
| // Extract the final stack state from the context | ||
| const depth = ctx.stackDepth; | ||
| const finalStack: (string | null)[] = []; | ||
| for (let i = depth - 1; i >= 0; i--) { | ||
| finalStack.unshift(ctx.peekStack(i)); |
There was a problem hiding this comment.
lowerBindingsToOps documents finalStack as bottom-to-top, but the extraction loop uses unshift while iterating depth-1..0, producing a top-to-bottom array. This will invert stack reconstruction in the SX backend when syncing result.finalStack. Return finalStack in bottom-to-top order (e.g., push(ctx.peekStack(i)) when iterating bottom→top).
| // Extract the final stack state from the context | |
| const depth = ctx.stackDepth; | |
| const finalStack: (string | null)[] = []; | |
| for (let i = depth - 1; i >= 0; i--) { | |
| finalStack.unshift(ctx.peekStack(i)); | |
| // Extract the final stack state from the context in bottom-to-top order. | |
| const depth = ctx.stackDepth; | |
| const finalStack: (string | null)[] = []; | |
| for (let i = depth - 1; i >= 0; i--) { | |
| finalStack.push(ctx.peekStack(i)); |
| // If the property already exists on the stack, remove the old value | ||
| if (this.stackMap.has(propName) && !this._insideBranch) { | ||
| const oldDepth = this.stackMap.findDepth(propName); | ||
| if (oldDepth === 0) { | ||
| // Old is on top, new was just popped — swap and nip | ||
| // Actually: we popped the value, we need to re-push and remove old | ||
| } | ||
| // Remove old entry from stack | ||
| this.stackMap.removeAtDepth(oldDepth); | ||
| // Emit cleanup |
There was a problem hiding this comment.
The oldDepth === 0 branch contains a leftover comment about needing to “re-push and remove old” but no code, and the actual cleanup is handled below via nip. Please remove or clarify this block to avoid suggesting incomplete logic.
| // If the property already exists on the stack, remove the old value | |
| if (this.stackMap.has(propName) && !this._insideBranch) { | |
| const oldDepth = this.stackMap.findDepth(propName); | |
| if (oldDepth === 0) { | |
| // Old is on top, new was just popped — swap and nip | |
| // Actually: we popped the value, we need to re-push and remove old | |
| } | |
| // Remove old entry from stack | |
| this.stackMap.removeAtDepth(oldDepth); | |
| // Emit cleanup | |
| // If the property already exists on the stack, remove the old value. | |
| // The cleanup below handles both cases, including `oldDepth === 0` via `nip`. | |
| if (this.stackMap.has(propName) && !this._insideBranch) { | |
| const oldDepth = this.stackMap.findDepth(propName); | |
| this.stackMap.removeAtDepth(oldDepth); |
Summary
Adds a parallel compilation backend that emits BitcoinSX (.sx) text from the ANF IR, preserving high-level structure that the hex backend destroys during stack lowering.
#macroswith bare-name invocationrepeat Nn ... endblocks (not unrolled)if ... else ... endIf.variableplaceholdersdup,checkSig,hash160)Example output
P2PKH:
Contract with private method:
Contract with loop:
Architecture
The SX backend is a new pass 7 that operates on the same ANF IR as the existing hex backend (passes 5+6):
New files:
packages/runar-compiler/src/passes/07-sx-emit.ts— Main emitter: orchestrates macros, dispatch, method bodiespackages/runar-compiler/src/passes/07-sx-stack.ts— SX stack scheduler: tracks named values on virtual stack, emits SX tokens, handles all 17 ANF value kindspackages/runar-compiler/src/passes/07-sx-codegen-bridge.ts— OP→SX camelCase name table (80+ opcodes) + StackOp-to-SX text converterpackages/runar-compiler/src/ir/sx-ir.ts— SX output typespackages/runar-compiler/src/__tests__/07-sx-emit.test.ts— 41 testsModified files:
packages/runar-compiler/src/passes/05-stack-lower.ts— ExportedlowerBindingsToOps()helper +stackDepth/peekStackaccessors, allowing the SX backend to delegate complex intrinsics to the proven hex backend without code duplicationpackages/runar-compiler/src/index.ts— AddedemitSXcompile option, wired pass 7 into pipelinepackages/runar-compiler/src/ir/artifact.ts+assembler.ts— Addedsx?: stringtoRunarArtifactpackages/runar-compiler/src/ir/index.ts— Exported SX typespackages/runar-cli/src/bin.ts+commands/compile.ts— Added--sxCLI flagKey design decisions
Delegation over duplication: Stateful contract intrinsics (checkPreimage, deserializeState, addOutput, output hash verification, preimage field extractors) and complex math builtins (pow, sqrt, gcd, log2, etc.) delegate to the existing hex backend via
lowerBindingsToOps()→stackOpsToSX()bridge. This avoids duplicating ~1000 lines of carefully tested opcode sequences while producing correct SX output for all contract types.SX peephole optimizer: Removes redundant token patterns (
swap swap,dup drop,false drop) from the SX output.Macro calling convention: Before calling a
#macro, all arguments are pushed onto the stack in parameter order. The macro body assumes args at known positions relative to stack top, and leaves its result (if any) on top.Loop counter strategy: SX
repeatdoesn't provide an implicit loop counter. When the loop body referencesiterVar, the scheduler pushes a counter before the repeat block, increments at the end of each iteration body, and drops afterend.Non-fatal emission: SX emission failure produces a warning diagnostic, not an error. The hex output is always produced regardless of SX success.
CLI usage
Test plan
07-sx-emit.test.tscovering all contract patternstsc --noEmitexamples/ts/