diff --git a/.c8rc.json b/.c8rc.json
index bf99c36..a665999 100644
--- a/.c8rc.json
+++ b/.c8rc.json
@@ -6,7 +6,9 @@
"exclude": [
"src/**/*.d.ts",
"src/debugAdapter/types.ts",
- "src/sourcemap/SourceMapper.ts"
+ "src/sourcemap/SourceMapper.ts",
+ "src/trace/main.ts",
+ "src/server/main.ts"
],
"extension": [".ts"],
"reporter": ["text", "html", "lcov"],
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a51be5c..15d9a18 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,5 +1,12 @@
# Contributing
+> **Audience:** `contributor` · `maintainer`
+>
+> **TL;DR:** How to hack on the extension — set up a dev environment
+> (devcontainer or by hand), the everyday build/lint/test commands, the
+> test-first convention and how to regenerate fixtures, and a tour of how the
+> trace-replay adapter works internally (architecture map included).
+
Thanks for your interest in improving the Soroban Debugger! This document covers
how to get a development environment running and the conventions we follow.
@@ -72,6 +79,28 @@ Because the whole recording is in memory, stepping *backward* is just as cheap
as stepping forward. The adapter runs in-process in the extension host
(`DebugAdapterInlineImplementation`).
+```mermaid
+flowchart TB
+ subgraph build["Build (LiveBackend)"]
+ CRATE["contract crate"] -->|"CARGO_PROFILE_RELEASE_DEBUG=true
STRIP=none, OPT_LEVEL=0"| WASM["wasm + DWARF
(pristine linker output)"]
+ end
+ WASM --> KOMET["komet-node
executes the whole transaction"]
+ KOMET --> TRACE["entire trace
one record per wasm instruction"]
+
+ subgraph model["In-memory model — vscode-free"]
+ TRACE --> VAL["validate positions
vs static disassembly"]
+ WASM -. "DWARF" .-> MAP["map code offset → Rust file:line"]
+ VAL --> CUR["cursor machine
(forward == backward cost)"]
+ MAP --> CUR
+ end
+
+ CUR -->|"each DAP request
just moves the cursor"| DAP["SorobanDebugSession
StoppedEvents / frames / disassembly"]
+```
+
+A `rawTrace` replay skips the *Build* and *komet-node* stages entirely — the
+JSONL trace is loaded straight into the model (and a paired `wasmPath` still
+feeds the DWARF/disassembly seams).
+
- **The build injects debug info without touching your `Cargo.toml`.** It sets
`CARGO_PROFILE_RELEASE_DEBUG=true` / `CARGO_PROFILE_RELEASE_STRIP=none` for
`stellar contract build`, so the wasm carries DWARF. The **pristine linker
diff --git a/README.md b/README.md
index d210e7f..8c1c000 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,15 @@ step through — forward or backward.
Two settings let you point at executables that aren't on your `PATH`:
`soroban.stellar.path` and `soroban.kometNode.path`.
+### Beyond the editor
+
+The debugger is also available outside VS Code:
+
+- [**`soroban-trace`**](docs/trace-cli.md) — a one-shot CLI that prints a
+ Rust-level execution trace as JSONL, for scripts, CI, and AI agents.
+- [**`soroban-dap`**](docs/dap-cli.md) — the debug adapter served over TCP, so other
+ editors (nvim-dap, IntelliJ, Emacs) can drive it.
+
## Contributing
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for how to
diff --git a/docs/dap-cli-internal.md b/docs/dap-cli-internal.md
new file mode 100644
index 0000000..c7115d7
--- /dev/null
+++ b/docs/dap-cli-internal.md
@@ -0,0 +1,77 @@
+# `soroban-dap` internals
+
+> **Audience:** `contributor` · `maintainer` · `integrator` (internals)
+>
+> **TL;DR:** How the standalone TCP DAP server wraps `SorobanDebugSession` — one
+> session per connection over a socket — plus the backend-selector constructor
+> and the per-connection teardown that keeps a dropped connection from leaking a
+> `komet-node` process. User-facing usage is in [`dap-cli.md`](./dap-cli.md). The
+> `vscode`-free shared core (`backendFor` / `buildStopModel` / `pcAtIndex`) is
+> documented in
+> [`trace-cli-internal.md`](./trace-cli-internal.md#shared-headless-core).
+
+The server reuses the exact DAP handlers and stepping engine (S1–S20, see
+[`stepping.md`](./stepping.md)) the VS Code extension runs — it only changes the
+transport from an in-process inline adapter to a TCP socket.
+
+```mermaid
+flowchart TB
+ C["DAP client
VS Code · nvim-dap · IntelliJ · Emacs"]
+ C -->|"TCP socket"| SRV["net.createServer"]
+ SRV -->|"one per connection"| SDS["SorobanDebugSession(backendFor)
DAP handlers + stepping engine"]
+ SDS --> RESOLVE["backendFor(args) → resolve → ResolvedTrace"]
+ SDS --> CORE["buildStopModel + pcAtIndex
(shared core)"]
+ CLOSE["socket close / error"] -->|"session.teardown()"| DISPOSE["backend.dispose()"]
+```
+
+## `startDapServer(opts): Promise<{port, close}>` — `src/server/dapServer.ts`
+
+`startDapServer({ host?, port, backendFor? })` opens a `net.createServer`. For each
+connection it:
+
+1. creates `new SorobanDebugSession(select)` where `select` defaults to the shared
+ `backendFor` selector (injectable via `opts.backendFor` so tests can observe
+ teardown with a spy backend);
+2. calls `session.setRunAsServer(true)` (see below);
+3. pipes the DAP wire protocol over the socket with `session.start(socket, socket)`;
+4. registers `socket.on('close', …)` and `socket.on('error', …)` → `session.teardown()`.
+
+It resolves the **actual** listening port (so `port: 0` yields an OS-assigned port) and
+returns `{ port, close }`, where `close()` shuts the server down.
+
+### Per-connection teardown (the two subtleties)
+
+- **`setRunAsServer(true)` is mandatory.** The base `@vscode/debugadapter`
+ `DebugSession` calls `process.exit(0)` roughly 100 ms after *any* socket closes
+ unless it is in server mode. Without this flag a single client disconnect would kill
+ the whole server process (and every other connection with it).
+
+- **`teardown()`, not `shutdown()`.** `DebugSession.shutdown()` is a no-op in server
+ mode, so it can't dispose the backend. `SorobanDebugSession.teardown()` is the single,
+ idempotent (`disposed`-guarded) place `backend.dispose()` runs. It is reached both by a
+ clean `disconnect` request *and* by the socket `close`/`error` handlers — so an
+ **abrupt** disconnect (editor crash, network drop, SIGKILL) that sends no `disconnect`
+ over the wire still tears down the `LiveBackend`'s `komet-node` subprocess. The
+ `disposed` guard makes the second call after a clean disconnect a harmless no-op.
+
+## Session constructor change — `src/debugAdapter/SorobanDebugSession.ts`
+
+`SorobanDebugSession` accepts **either** a concrete `SessionBackend` (unchanged; used by
+the extension and the stdio test harness) **or** a selector
+`(args: SorobanLaunchArgs) => SessionBackend`, resolved on the first line of
+`launchRequest` (the backend is unused before then). The TCP server passes the selector
+so the backend can depend on the per-connection launch config, which only arrives with
+the client's `launch` request.
+
+## Why TCP, not HTTP
+
+Plain request/response HTTP is intentionally not offered: DAP is a bidirectional,
+event-driven protocol (async `stopped`/`output`/`terminated` events) that does not fit
+HTTP's request/response shape. TCP is DAP's canonical "server mode", supported by every
+mainstream DAP client. A WebSocket transport could be added later for browser-fronted
+clients.
+
+`src/server/main.ts` is the thin, coverage-excluded entry: it delegates argv parsing to
+the pure `parseServerArgs` (`src/server/cliArgs.ts`, unit-tested), starts the server, and
+logs the listening address. Help goes to stdout (exit 0); a usage error goes to stderr
+(exit 2).
diff --git a/docs/dap-cli.md b/docs/dap-cli.md
new file mode 100644
index 0000000..be91d2d
--- /dev/null
+++ b/docs/dap-cli.md
@@ -0,0 +1,82 @@
+# `soroban-dap` — standalone DAP server (TCP)
+
+> **Audience:** `other-editor user` (nvim-dap / IntelliJ / Emacs) · `tooling integrator`
+>
+> **TL;DR:** `soroban-dap` serves the Soroban debug adapter over a TCP socket
+> (DAP's canonical "server mode"), so debuggers *other* than VS Code can drive
+> it. For the one-shot JSONL trace CLI, see [`trace-cli.md`](./trace-cli.md); for
+> internals, see [`dap-cli-internal.md`](./dap-cli-internal.md).
+
+`soroban-dap` runs the debug adapter as a TCP server. Each client connection
+gets its own independent session; the launch configuration is sent by the client
+over the wire, exactly as in the editor.
+
+## Building
+
+```sh
+npm install
+npm run build
+```
+
+This produces `dist/dap-server.js`. Run it directly with
+`node dist/dap-server.js …`, or expose it as the `soroban-dap` command by
+installing the package (`npm install -g .`, or `npm link` for local
+development). (`npm run build` also builds the trace CLI — see
+[`trace-cli.md`](./trace-cli.md).)
+
+Debugging a real contract needs the same tools as the editor — the [Stellar
+CLI](https://developers.stellar.org/docs/tools/cli) and
+[komet-node](https://github.com/runtimeverification/komet-node) on your `PATH`
+(see the [main README](../README.md#requirements)) — unless the client launches
+with a recorded `rawTrace` (offline replay).
+
+## Usage
+
+`soroban-dap --help`:
+
+```text
+soroban-dap — serve the Soroban debug adapter over a TCP socket
+
+Usage:
+ soroban-dap [--port ] [--host ]
+
+Options:
+ --port TCP port to listen on (default 4711).
+ --host Interface to bind (default 127.0.0.1, loopback only).
+ -h, --help Show this help.
+
+Connect any DAP client to the port (e.g. VS Code "debugServer": ).
+```
+
+```sh
+node dist/dap-server.js --port 4711
+# → Soroban DAP server listening on 127.0.0.1:4711
+```
+
+The server binds **loopback** by default — it does not expose the debugger to
+the network. Only override `--host` on a trusted, isolated network.
+
+## Connecting a client
+
+Any DAP client that can attach to a running server works. From VS Code, point a
+launch configuration at the port with `debugServer`:
+
+```jsonc
+{
+ "type": "soroban",
+ "request": "launch",
+ "name": "Attach to soroban-dap",
+ "debugServer": 4711,
+ "rawTrace": "test/fixtures/adder-debug.trace.jsonl",
+ "wasmPath": "test/fixtures/adder-debug.wasm"
+}
+```
+
+Other editors use their own attach mechanism (e.g. nvim-dap's `server`/`port`
+adapter config) — the wire protocol is standard DAP, so any conformant client
+can drive it. The full set of launch attributes is the same as the editor's
+[configuration reference](../README.md#configuration-reference).
+
+> Plain request/response HTTP is intentionally not offered: DAP is a
+> bidirectional, event-driven protocol that doesn't fit HTTP's request/response
+> shape. A WebSocket transport could be added if a browser-based client needs one.
diff --git a/docs/stepping.md b/docs/stepping.md
index 01f9873..8ccc13d 100644
--- a/docs/stepping.md
+++ b/docs/stepping.md
@@ -1,5 +1,13 @@
# Stepping semantics
+> **Audience:** `contributor` · `maintainer` (replay/stepping engine)
+>
+> **TL;DR:** The precise contract for how the cursor moves through a recorded
+> trace — statement vs. instruction granularity, forward and backward. Defines
+> the per-record model (visible/mapped/depth/run) and the numbered rules
+> (S1–S20) that the test suite pins, plus the opt-level build prerequisite and
+> the fixtures behind each rule.
+
The contract between the debugger's replay engine and the user's expectations
when stepping through a recorded trace — at **statement** (Rust source) and
**instruction** (Disassembly View) granularity, forward and backward. The
@@ -54,6 +62,20 @@ invisible record, and (at statement granularity) never on an unmapped or
filtered-out one, except when filtering would leave the trace (or a function
frame) with no stop point at all.
+The two granularities are successive filters over the same trace — each stop set
+is a subset of the one above it:
+
+```mermaid
+flowchart TB
+ ALL["all trace records"] -->|"drop invisible records
(pos fails validation)"| VIS["visible records
= instruction stop points"]
+ VIS -->|"group into line runs;
keep each run's first index"| RS["run starts
one per line execution (incl. per loop iteration)"]
+ RS -->|"S17 drop declaration lines
S18 drop non-epilogue braces"| SS["statement stops
= where source stepping rests"]
+```
+
+Instruction stepping visits the middle box; statement stepping visits the
+bottom box. Filtering never empties a frame (see S17/S18): if it would remove
+every stop, the unfiltered run starts stand.
+
## Rules
### Session start and run boundaries
diff --git a/docs/trace-cli-internal.md b/docs/trace-cli-internal.md
new file mode 100644
index 0000000..9f2af2b
--- /dev/null
+++ b/docs/trace-cli-internal.md
@@ -0,0 +1,158 @@
+# `soroban-trace` internals
+
+> **Audience:** `contributor` · `maintainer` · `integrator` (internals)
+>
+> **TL;DR:** How `soroban-trace` turns a resolved trace into Rust-source-level
+> JSONL, and the `vscode`-free shared core it sits on (also used by the DAP
+> server — see [`dap-cli-internal.md`](./dap-cli-internal.md)). Documents the
+> `SourceStop`/`TraceVar` schema and the ground-truth fixtures. User-facing
+> usage is in [`trace-cli.md`](./trace-cli.md).
+
+The CLI is a pure pipeline over the same replay engine the VS Code extension
+uses; it never touches the DAP stepping engine (S1–S20, see
+[`stepping.md`](./stepping.md)). It needs no `SorobanDebugSession` at all:
+
+```mermaid
+flowchart TB
+ BF["backendFor(args)"]
+ BF -->|"rawTrace set"| RTB["RawTraceBackend
offline JSONL replay"]
+ BF -->|"otherwise"| LB["LiveBackend
build → komet-node → trace"]
+ RTB --> RT["ResolvedTrace"]
+ LB --> RT
+
+ subgraph core["shared headless core — vscode-free"]
+ BSM["buildStopModel → StopModel"]
+ PCA["pcAtIndex"]
+ PSS["projectSourceStop
serializable, eager var expansion"]
+ end
+
+ RT --> BSM
+ PCA --> PSS
+ BSM -->|"runStarts"| RCT["runCliTrace"]
+ PSS --> RCT
+ RCT --> OUT["kind-tagged JSONL
meta · stop · result"]
+```
+
+## Shared headless core
+
+All modules below are pure (no `vscode`, no DAP wire I/O) and unit-testable.
+`backendFor`, `buildStopModel`, and `pcAtIndex` are also used by the DAP server
+(see [`dap-cli-internal.md`](./dap-cli-internal.md)).
+
+### `backendFor(args): SessionBackend` — `src/debugAdapter/backendFor.ts`
+
+Selects the trace-acquisition backend from launch args: `args.rawTrace` present →
+`RawTraceBackend` (offline replay of a JSONL trace, symbol-rich when `wasmPath` is
+also given), else `LiveBackend` (the full build→komet-node→trace pipeline). Extracted
+verbatim from the inline selector previously in `extension.ts`; reused by the extension,
+the TCP server, and the CLI. Reads only `args.rawTrace`, so it needs no `vscode`.
+
+### `buildStopModel(resolved): StopModel` — `src/debugAdapter/stopModel.ts`
+
+The single source of truth for a trace's stop points, so the IDE and the CLI can never
+disagree about where a "stop" is. Given a `ResolvedTrace` it derives, exactly as
+`SorobanDebugSession.launchRequest` did inline:
+
+```ts
+interface StopModel {
+ /** Validated code offset → trace indices (never raw pos; global-init excluded). */
+ validatedPosToIndices: Map;
+ /** Visible (validated-position) record indices, ascending. */
+ visibleIndices: number[];
+ /** Call depth per record (parallel to records), via computeDepths. */
+ depths: number[];
+ /** Raw line-run starts, pre-S17/S18 (for breakpoint narrowing). */
+ rawRunStarts: number[];
+ /** Statement-granularity stop points, post-S17/S18 (the source stops). */
+ runStarts: number[];
+ /** runStarts[0] ?? visibleIndices[0] ?? 0. */
+ firstStopPoint: number;
+ /** runStarts[last] ?? visibleIndices[last] ?? max(0, records.length-1). */
+ lastStopPoint: number;
+}
+```
+
+Composition (unchanged): `computeDepths(records, positions, disassembly.functionRanges)`
+→ `computeRunStarts(positions, depths, i => source.lineKeyForIndex(i))` →
+`statementStops(rawRunStarts, depths, i => classifyLineRole(source.sourceTextForIndex(i)))`
+(all from `stops.ts`).
+
+### `pcAtIndex(positions, index): number | null` — `src/debugAdapter/stopModel.ts`
+
+The current-PC rule the session uses: the validated code offset at `index`, or the
+nearest *earlier* record that has one, else `null`. Keeps variable scope aligned
+between IDE and CLI. Extracted from `SorobanDebugSession.currentPc`.
+
+### `projectSourceStop(resolved, stopModel, index, opts): SourceStop` — `src/trace/projectStop.ts`
+
+A **serializable** projection of one stop — NOT shared with the DAP handlers, whose
+lazy `Handles`/child-thunk machinery is deliberately different. Reuses only the
+low-level resolver calls:
+
+- `source.locationForIndex(index)` → `{path, line, column?}` (or unmapped)
+- `pcAtIndex(resolved.positions, index)` → the PC
+- `variables.functionNameAt(pc)` → function name (**may be `null`** even with DWARF)
+- `makeRuntimeState(record, memoryImage, index)` + `variables.variablesInScope(pc)` +
+ `variables.decodeVariable(v, state, pc)` → decoded variables
+
+Children (`DecodedValue.children`) are expanded **eagerly** into plain arrays, bounded
+by a per-stop budget: `maxDepth` (default 3), `maxChildren` (default 64), and a global
+per-stop node cap (~1500) that appends a `{name:"…", truncated:true}` marker when hit.
+Pointer-cycle safety is already handled inside `ValueDecoder`; the budget only bounds
+breadth×depth blow-up. A variable with no DWARF name renders as `` (matching the
+DAP handler). `column` may be absent.
+
+```ts
+interface SourceStop {
+ step: number; // 0-based ordinal among source stops
+ traceIndex: number; // index into model.records
+ depth: number; // stopModel.depths[traceIndex]
+ pc: string | null; // hex, e.g. "0x2d", or null
+ function: string | null; // functionNameAt(pc) or null
+ instr: string; // renderInstr(record.instr)
+ source: { path: string; line: number; column?: number } | null;
+ variables: TraceVar[];
+}
+interface TraceVar {
+ name: string; // "" when DWARF gives none
+ type?: string;
+ value: string;
+ children?: TraceVar[]; // present only when expandable and within budget
+ truncated?: boolean; // marker node when the budget was hit
+}
+```
+
+## `runCliTrace(resolved, opts): string[]` — `src/trace/runTrace.ts`
+
+`runCliTrace` (pure) walks `stopModel.runStarts` in order — provably the same sequence a
+user sees stepping in (statement-granularity `stepIn` visits `runStarts[0..n]` then
+terminates per S20) — and emits kind-tagged JSONL:
+
+```jsonl
+{"kind":"meta","function":"add","wasm":"…","records":41,"stops":1,"hasDwarf":true}
+{"kind":"stop","step":0,"traceIndex":29,"depth":0,"pc":"0x2d","function":"invoke_raw_extern","instr":"i32.add","source":{"path":"…/examples/adder/src/lib.rs","line":16,"column":9},"variables":[{"name":"arg_0","type":"Val","value":"17179869188"},{"name":"arg_1","type":"Val","value":"12884901892"}]}
+{"kind":"result","returnValue":"…","terminated":true}
+```
+
+If `stopModel.runStarts` is empty (no DWARF / no source), it throws rather than
+silently emitting `visibleIndices` as if they were source statements (the CLI's
+`--allow-no-source` opt-in relaxes this).
+
+`src/trace/main.ts` is a thin, coverage-excluded entry: it delegates argv parsing to the
+pure `parseTraceArgs` (`src/trace/cliArgs.ts`, unit-tested), then
+`backendFor(args).resolve(...)`, `runCliTrace`, writes to stdout or `--out`, and
+`backend.dispose()`. Help goes to stdout (exit 0); a usage error goes to stderr (exit 2);
+a runtime failure exits 1.
+
+## Ground-truth fixtures (verified)
+
+Used by the golden tests; `runStarts` is the CLI stop sequence.
+
+| fixture | records | runStarts (source stops) | notes |
+|---|---|---|---|
+| `adder-debug` | 41 | `[29]` | 1 stop; entry line 16 col 9, fn `invoke_raw_extern`, `arg_0:Val=17179869188`, `arg_1:Val=12884901892` |
+| `stepper-debug` | 85 | `[21,27,29,39,44,46,56,61,63,73]` | 10 stops, 2 functions; idx 29 → fn `triple`, line 15, **no column**, `x:u32=0` |
+| `increment-debug` | 2717 | `[646,999,1454,1904,1956,2495]` | 6 stops; idx 999 → line 21, `current:u32=15`, `env:Env` (expandable); some vars **unnamed** and `functionNameAt`→`null` |
+
+Fixtures live in `test/fixtures/.trace.jsonl` + `.wasm`. The adder's DWARF
+resolves to `examples/adder/src/lib.rs`.
diff --git a/docs/trace-cli.md b/docs/trace-cli.md
new file mode 100644
index 0000000..4350354
--- /dev/null
+++ b/docs/trace-cli.md
@@ -0,0 +1,123 @@
+# `soroban-trace` — Rust-level execution trace (CLI)
+
+> **Audience:** `soroban developer` (outside VS Code) · `CI / scripting user` ·
+> `AI agent integrator`
+>
+> **TL;DR:** `soroban-trace` builds and runs a contract once and prints a
+> Rust-source-level execution trace as JSONL — one record per source statement,
+> with the in-scope variables at that point. Built for scripts, CI, and AI
+> agents that want to *read* an execution rather than step through it
+> interactively. It can also replay a previously recorded run fully offline. For
+> the standalone DAP server, see [`dap-cli.md`](./dap-cli.md); for internals, see
+> [`trace-cli-internal.md`](./trace-cli-internal.md).
+
+`soroban-trace` is a thin front-end over the same replay engine the VS Code
+extension uses. It emits one JSON object per line: a leading `meta` record, one
+`stop` per source-level statement (in execution order), and a trailing
+`result`.
+
+## Building
+
+```sh
+npm install
+npm run build
+```
+
+This produces `dist/trace.js`. Run it directly with `node dist/trace.js …`, or
+expose it as the `soroban-trace` command by installing the package
+(`npm install -g .`, or `npm link` for local development). (`npm run build` also
+builds the DAP server — see [`dap-cli.md`](./dap-cli.md).)
+
+Live mode (the primary use below) builds and runs a contract, so it needs the
+same tools as the editor — the [Stellar
+CLI](https://developers.stellar.org/docs/tools/cli) and
+[komet-node](https://github.com/runtimeverification/komet-node) on your `PATH`
+(see the [main README](../README.md#requirements)). The offline replay at the end
+needs no toolchain.
+
+## Usage
+
+`soroban-trace --help`:
+
+```text
+soroban-trace — emit a Rust source-level execution trace as JSONL
+
+Usage:
+ soroban-trace --raw-trace [--wasm ] [options] (offline replay)
+ soroban-trace --contract --function [options] (build & run live)
+
+Options:
+ --raw-trace Recorded JSONL trace to replay (offline mode).
+ --wasm Contract .wasm supplying DWARF debug info (source + variables).
+ --contract Crate directory to build and run (live mode).
+ --function Contract function to invoke (required in live mode).
+ --args-json Function arguments, e.g. '[{"value":1,"type":"u32"}]'.
+ --out Write JSONL to a file instead of stdout.
+ --depth Max variable-expansion depth (default 3).
+ --max-children Max children materialized per aggregate (default 64).
+ --allow-no-source Don't error when the trace has no source-level stops.
+ -h, --help Show this help.
+
+Examples:
+ soroban-trace --raw-trace run.jsonl --wasm contract.wasm
+ soroban-trace --contract . --function add --args-json '[{"value":1,"type":"u32"}]'
+```
+
+## Quick start (build → deploy → run → trace)
+
+Trace the bundled [`examples/adder`](../examples/adder) contract — a debug build
+of `add(a, b) -> u32` — invoking `add(4, 3)`. Run from the repository root:
+
+```sh
+node dist/trace.js \
+ --contract examples/adder \
+ --function add \
+ --args-json '[{"value":4,"type":"u32"},{"value":3,"type":"u32"}]'
+```
+
+This builds the crate with DWARF debug info at opt-level 0, spawns komet-node,
+deploys, invokes `add(4, 3)`, and streams the resulting source-level trace as
+JSONL to stdout:
+
+```jsonl
+{"kind":"meta","function":"add","args":[{"value":4,"type":"u32"},{"value":3,"type":"u32"}],"records":41,"stops":1,"hasDwarf":true}
+{"kind":"stop","step":0,"traceIndex":29,"depth":0,"pc":"0x2d","function":"invoke_raw_extern","instr":"i32.add","source":{"path":".../examples/adder/src/lib.rs","line":16,"column":9},"variables":[{"name":"arg_0","type":"Val","value":"17179869188"},{"name":"arg_1","type":"Val","value":"12884901892"}]}
+{"kind":"result","terminated":true}
+```
+
+Each `stop` carries the source location, the enclosing function, the call
+`depth`, the wasm `pc`/`instr`, and the in-scope variables decoded from DWARF
+(aggregates expand into a nested `children` array, bounded by `--depth` /
+`--max-children`). The full `SourceStop` / `TraceVar` field reference is in
+[`trace-cli-internal.md`](./trace-cli-internal.md).
+
+Add `--out trace.jsonl` to write to a file instead of stdout. Other bundled
+crates to try: `examples/increment --function increment`,
+`examples/stepper --function sum_triples`, `examples/greeter --function store`
+(see [`examples/README.md`](../examples/README.md) for each contract's shape).
+
+> **komet-node in this devcontainer:** the `komet-node` on `$PATH` here is a
+> stale build that hangs on value-returning calls (`add`, `increment`). Prefix
+> the rebuilt node — `PATH=/home/node/.komet-node/bin:$PATH node dist/trace.js …`
+> — until the fix lands on `$PATH`. See
+> [`examples/README.md`](../examples/README.md#komet-node-version-note) for the
+> full note.
+
+## Offline replay (a recorded run, no toolchain)
+
+Already have a recorded `komet-node` trace? Replay it with no build and no
+komet-node — pass the matching debug `--wasm` to get source-level stops:
+
+```sh
+node dist/trace.js \
+ --raw-trace test/fixtures/adder-debug.trace.jsonl \
+ --wasm test/fixtures/adder-debug.wasm
+```
+
+This produces the same trace as the live command above (it *is* a recorded
+`add(4, 3)` run of `examples/adder`), which makes it a handy zero-dependency way
+to see the output format.
+
+If the trace has no Rust source-level stops (no matching `--wasm`, or a
+non-debug build) the command exits non-zero with a message, rather than emitting
+a misleading trace — pass `--allow-no-source` to override.
diff --git a/esbuild.js b/esbuild.js
index ced109c..3e4c09a 100644
--- a/esbuild.js
+++ b/esbuild.js
@@ -3,28 +3,51 @@ const esbuild = require('esbuild');
const watch = process.argv.includes('--watch');
const minify = process.argv.includes('--minify');
-/** @type {import('esbuild').BuildOptions} */
-const options = {
- entryPoints: ['src/extension.ts'],
+/** Settings shared by every bundle. */
+const common = {
bundle: true,
- outfile: 'dist/extension.js',
platform: 'node',
target: 'node22',
format: 'cjs',
- // `vscode` is provided by the extension host at runtime and must not be bundled.
- external: ['vscode'],
sourcemap: !minify,
minify,
logLevel: 'info',
};
+/** @type {import('esbuild').BuildOptions} */
+const extension = {
+ ...common,
+ entryPoints: ['src/extension.ts'],
+ outfile: 'dist/extension.js',
+ // `vscode` is provided by the extension host at runtime and must not be bundled.
+ external: ['vscode'],
+};
+
+/** @type {import('esbuild').BuildOptions} */
+const dapServer = {
+ ...common,
+ entryPoints: ['src/server/main.ts'],
+ outfile: 'dist/dap-server.js',
+ banner: { js: '#!/usr/bin/env node' },
+};
+
+/** @type {import('esbuild').BuildOptions} */
+const trace = {
+ ...common,
+ entryPoints: ['src/trace/main.ts'],
+ outfile: 'dist/trace.js',
+ banner: { js: '#!/usr/bin/env node' },
+};
+
+const allOptions = [extension, dapServer, trace];
+
async function main() {
if (watch) {
- const ctx = await esbuild.context(options);
- await ctx.watch();
+ const contexts = await Promise.all(allOptions.map((o) => esbuild.context(o)));
+ await Promise.all(contexts.map((c) => c.watch()));
console.log('esbuild: watching...');
} else {
- await esbuild.build(options);
+ await Promise.all(allOptions.map((o) => esbuild.build(o)));
}
}
diff --git a/examples/README.md b/examples/README.md
index 519a7ed..033c1f5 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,5 +1,12 @@
# Soroban Debugger — example workspace
+> **Audience:** `new user` · `soroban developer` (getting started)
+>
+> **TL;DR:** A guided tour of the ready-to-run example contracts and launch
+> configs. Start with the zero-dependency **Replay … with symbols** configs to
+> see source-level, forward-and-backward debugging in seconds — no toolchain
+> needed — then graduate to the full build-and-run pipeline.
+
This folder is a **self-contained example workspace** for the Soroban Debugger
(komet) extension. Pressing **F5** ("Run Extension") in the extension repo opens
this folder in an Extension Development Host with the extension already loaded,
@@ -81,6 +88,20 @@ Once the nix cache ships the fix, delete that setting and the extension will use
`komet-node` from `$PATH`. (The `soroban.stellar.path` setting works the same way
for the Stellar CLI.)
+The standalone `trace.js` CLI does **not** read the VS Code settings — it always
+spawns `komet-node` from `$PATH`. In this devcontainer that is still the stale
+build, so a value-returning live call (`add`, `increment`) hangs until the RPC
+times out. To use the rebuilt node, put it ahead of the stale one on `$PATH`:
+
+```sh
+PATH=/home/node/.komet-node/bin:$PATH \
+node ../../dist/trace.js --contract . --function add \
+ --args-json '[{"value":1,"type":"u32"},{"value":2,"type":"u32"}]' \
+ --out trace.jsonl
+```
+
+Once the fix is on `$PATH`, the `PATH` prefix can be dropped.
+
## Adding your own contract
Drop a new crate directory here (with a `Cargo.toml` exposing a `#[contract]`),
diff --git a/package.json b/package.json
index 7f0efb3..f340477 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,10 @@
"Debuggers"
],
"main": "./dist/extension.js",
+ "bin": {
+ "soroban-dap": "dist/dap-server.js",
+ "soroban-trace": "dist/trace.js"
+ },
"activationEvents": [
"onDebug"
],
diff --git a/src/debugAdapter/SorobanDebugSession.ts b/src/debugAdapter/SorobanDebugSession.ts
index c5a6189..46f81c3 100644
--- a/src/debugAdapter/SorobanDebugSession.ts
+++ b/src/debugAdapter/SorobanDebugSession.ts
@@ -23,13 +23,8 @@ import {
import { DebugProtocol } from '@vscode/debugprotocol';
import * as path from 'path';
import { TraceModel } from './TraceModel';
-import {
- classifyLineRole,
- computeDepths,
- computeRunStarts,
- firstNonWhitespaceColumn,
- statementStops,
-} from './stops';
+import { firstNonWhitespaceColumn } from './stops';
+import { buildStopModel, pcAtIndex } from './stopModel';
import { SourceMapper } from '../sourcemap/SourceMapper';
import { VariableResolver, NullVariableResolver } from '../sourcemap/VariableResolver';
import { Disassembly } from '../wasm/Disassembly';
@@ -52,7 +47,13 @@ enum ScopeRef {
}
export class SorobanDebugSession extends DebugSession {
- private readonly backend: SessionBackend;
+ /**
+ * Either a concrete backend or a selector resolved on the first line of
+ * launchRequest (the TCP server passes the selector so the backend can depend
+ * on the per-connection launch config). Once launched, this holds the
+ * concrete backend.
+ */
+ private backend: SessionBackend | ((args: SorobanLaunchArgs) => SessionBackend);
private model?: TraceModel;
private source?: SourceMapper;
private disassembly?: Disassembly;
@@ -108,7 +109,15 @@ export class SorobanDebugSession extends DebugSession {
*/
private readonly sourceVarChildren = new Handles<() => ChildVar[]>(1000);
- constructor(backend: SessionBackend) {
+ /**
+ * Set once the per-connection backend has been disposed, so teardown is
+ * idempotent: a clean `disconnect` disposes, and the subsequent socket
+ * 'close'/'error' from the TCP server must NOT dispose the (already torn
+ * down) komet-node pipeline a second time.
+ */
+ private disposed = false;
+
+ constructor(backend: SessionBackend | ((args: SorobanLaunchArgs) => SessionBackend)) {
super();
this.backend = backend;
this.setDebuggerLinesStartAt1(true);
@@ -149,6 +158,12 @@ export class SorobanDebugSession extends DebugSession {
response: DebugProtocol.LaunchResponse,
args: SorobanLaunchArgs,
): Promise {
+ // Resolve the backend selector (TCP server) to a concrete backend now that
+ // the per-connection launch config is known; a concrete backend passes
+ // through unchanged.
+ if (typeof this.backend === 'function') {
+ this.backend = this.backend(args);
+ }
try {
const resolved: ResolvedTrace = await this.backend.resolve(args, (msg) => this.log(msg));
this.model = resolved.model;
@@ -157,25 +172,12 @@ export class SorobanDebugSession extends DebugSession {
this.memoryImage = new MemoryImage(this.model.records);
this.disassembly = resolved.disassembly;
this.positions = resolved.positions;
- this.validatedPosToIndices = new Map();
- this.visibleIndices = [];
- this.positions.forEach((pos, i) => {
- if (pos !== null) {
- this.visibleIndices.push(i);
- const list = this.validatedPosToIndices.get(pos);
- if (list) {
- list.push(i);
- } else {
- this.validatedPosToIndices.set(pos, [i]);
- }
- }
- });
- const source = this.source;
- this.depths = computeDepths(this.model.records, this.positions, this.disassembly.functionRanges);
- this.rawRunStarts = computeRunStarts(this.positions, this.depths, (i) => source.lineKeyForIndex(i));
- this.runStarts = statementStops(this.rawRunStarts, this.depths, (i) =>
- classifyLineRole(source.sourceTextForIndex(i)),
- );
+ const stopModel = buildStopModel(resolved);
+ this.validatedPosToIndices = stopModel.validatedPosToIndices;
+ this.visibleIndices = stopModel.visibleIndices;
+ this.depths = stopModel.depths;
+ this.rawRunStarts = stopModel.rawRunStarts;
+ this.runStarts = stopModel.runStarts;
if (this.model.isEmpty) {
this.sendErrorResponse(response, 2001, 'The trace is empty; nothing to debug.');
@@ -378,13 +380,7 @@ export class SorobanDebugSession extends DebugSession {
if (!this.model) {
return null;
}
- for (let i = this.model.cursor; i >= 0; i--) {
- const pos = this.positions[i];
- if (pos !== null && pos !== undefined) {
- return pos;
- }
- }
- return null;
+ return pcAtIndex(this.positions, this.model.cursor);
}
protected disassembleRequest(
@@ -583,12 +579,35 @@ export class SorobanDebugSession extends DebugSession {
response: DebugProtocol.DisconnectResponse,
_args: DebugProtocol.DisconnectArguments,
): Promise {
+ await this.teardown();
+ this.sendResponse(response);
+ }
+
+ /**
+ * Dispose the per-connection backend exactly once. This is the single place
+ * `backend.dispose()` runs, reached both by the client's `disconnect` request
+ * and by the TCP server's socket 'close'/'error' handlers (an abrupt
+ * disconnect — editor crash, network drop, SIGKILL — sends no `disconnect`
+ * over the wire, so without this the LiveBackend's komet-node subprocess would
+ * leak). Public and idempotent: the `disposed` guard makes a second call after
+ * a clean disconnect a no-op, so the komet-node pipeline is never
+ * double-disposed. NOTE: `DebugSession.shutdown()` is a no-op in server mode,
+ * so it can NOT be relied on for backend teardown — this must be called directly.
+ */
+ async teardown(): Promise {
+ if (this.disposed) {
+ return;
+ }
+ this.disposed = true;
try {
- await this.backend.dispose();
+ // A launch may never have happened, in which case the backend is still a
+ // selector function with nothing to dispose.
+ if (typeof this.backend !== 'function') {
+ await this.backend.dispose();
+ }
} catch {
// best-effort teardown
}
- this.sendResponse(response);
}
protected async terminateRequest(
diff --git a/src/debugAdapter/backendFor.ts b/src/debugAdapter/backendFor.ts
new file mode 100644
index 0000000..417e08f
--- /dev/null
+++ b/src/debugAdapter/backendFor.ts
@@ -0,0 +1,20 @@
+/**
+ * Trace-acquisition backend selector (docs/trace-cli-internal.md, "backendFor").
+ *
+ * Picks the vscode-free backend from the launch args: `args.rawTrace` present →
+ * RawTraceBackend (offline JSONL replay), else LiveBackend (the full
+ * build → komet-node → trace pipeline). Reads only `args.rawTrace`, so it needs
+ * no `vscode`; reused by the extension, the TCP server, and the CLI.
+ */
+
+import { SessionBackend, SorobanLaunchArgs } from './types';
+import { RawTraceBackend } from './backends/RawTraceBackend';
+import { LiveBackend } from './backends/LiveBackend';
+
+/** Selects a backend per launch configuration. */
+export function backendFor(config: SorobanLaunchArgs): SessionBackend {
+ if (config.rawTrace) {
+ return new RawTraceBackend();
+ }
+ return new LiveBackend();
+}
diff --git a/src/debugAdapter/stopModel.ts b/src/debugAdapter/stopModel.ts
new file mode 100644
index 0000000..b36eeab
--- /dev/null
+++ b/src/debugAdapter/stopModel.ts
@@ -0,0 +1,98 @@
+/**
+ * The shared headless stop model (docs/trace-cli-internal.md, "Shared headless core").
+ *
+ * `buildStopModel` is the single source of truth for a trace's stop points, so
+ * the IDE (SorobanDebugSession) and the CLI can never disagree about where a
+ * "stop" is. It derives, exactly as SorobanDebugSession.launchRequest did
+ * inline, the validated-position → indices map, the visible record indices, the
+ * per-record call depths, the raw line-run starts, the statement-granularity
+ * run starts (post S17/S18), and the first/last stop points.
+ *
+ * `pcAtIndex` is the current-PC rule the session uses: the validated code
+ * offset at `index`, or the nearest EARLIER record that has one, else null.
+ *
+ * Pure module (no `vscode`, no DAP imports).
+ */
+
+import {
+ classifyLineRole,
+ computeDepths,
+ computeRunStarts,
+ statementStops,
+} from './stops';
+import { ResolvedTrace } from './types';
+
+export interface StopModel {
+ /** Validated code offset → trace indices (never raw pos; global-init excluded). */
+ validatedPosToIndices: Map;
+ /** Visible (validated-position) record indices, ascending. */
+ visibleIndices: number[];
+ /** Call depth per record (parallel to records), via computeDepths. */
+ depths: number[];
+ /** Raw line-run starts, pre-S17/S18 (for breakpoint narrowing). */
+ rawRunStarts: number[];
+ /** Statement-granularity stop points, post-S17/S18 (the source stops). */
+ runStarts: number[];
+ /** runStarts[0] ?? visibleIndices[0] ?? 0. */
+ firstStopPoint: number;
+ /** runStarts[last] ?? visibleIndices[last] ?? max(0, records.length-1). */
+ lastStopPoint: number;
+}
+
+/**
+ * Derive the trace's stop model from a resolved trace, reproducing exactly the
+ * inline computation SorobanDebugSession.launchRequest performed.
+ */
+export function buildStopModel(resolved: ResolvedTrace): StopModel {
+ const { model, source, disassembly, positions } = resolved;
+
+ const validatedPosToIndices = new Map();
+ const visibleIndices: number[] = [];
+ positions.forEach((pos, i) => {
+ if (pos !== null) {
+ visibleIndices.push(i);
+ const list = validatedPosToIndices.get(pos);
+ if (list) {
+ list.push(i);
+ } else {
+ validatedPosToIndices.set(pos, [i]);
+ }
+ }
+ });
+
+ const depths = computeDepths(model.records, positions, disassembly.functionRanges);
+ const rawRunStarts = computeRunStarts(positions, depths, (i) => source.lineKeyForIndex(i));
+ const runStarts = statementStops(rawRunStarts, depths, (i) =>
+ classifyLineRole(source.sourceTextForIndex(i)),
+ );
+
+ const firstStopPoint = runStarts[0] ?? visibleIndices[0] ?? 0;
+ const lastStopPoint =
+ runStarts[runStarts.length - 1] ??
+ visibleIndices[visibleIndices.length - 1] ??
+ Math.max(0, model.records.length - 1);
+
+ return {
+ validatedPosToIndices,
+ visibleIndices,
+ depths,
+ rawRunStarts,
+ runStarts,
+ firstStopPoint,
+ lastStopPoint,
+ };
+}
+
+/**
+ * The current-PC rule: the validated code offset at `index`, or the nearest
+ * EARLIER record that has one, else null.
+ */
+export function pcAtIndex(positions: readonly (number | null)[], index: number): number | null {
+ for (let i = index; i >= 0; i--) {
+ const pos = positions[i];
+ if (pos !== null && pos !== undefined) {
+ return pos;
+ }
+ }
+ return null;
+}
diff --git a/src/extension.ts b/src/extension.ts
index 07ed24b..9c4a08e 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -11,9 +11,8 @@
import * as vscode from 'vscode';
import { SorobanDebugSession } from './debugAdapter/SorobanDebugSession';
-import { RawTraceBackend } from './debugAdapter/backends/RawTraceBackend';
-import { LiveBackend } from './debugAdapter/backends/LiveBackend';
-import { SessionBackend, SorobanLaunchArgs } from './debugAdapter/types';
+import { backendFor } from './debugAdapter/backendFor';
+import { SorobanLaunchArgs } from './debugAdapter/types';
export function activate(context: vscode.ExtensionContext): void {
const provider = new SorobanConfigurationProvider();
@@ -28,14 +27,6 @@ export function deactivate(): void {
// No global resources to release; sessions clean up on disconnect.
}
-/** Selects a backend per launch configuration. */
-function backendFor(config: SorobanLaunchArgs): SessionBackend {
- if (config.rawTrace) {
- return new RawTraceBackend();
- }
- return new LiveBackend();
-}
-
class SorobanAdapterFactory implements vscode.DebugAdapterDescriptorFactory {
createDebugAdapterDescriptor(
session: vscode.DebugSession,
diff --git a/src/pipeline/TurnkeyPipeline.ts b/src/pipeline/TurnkeyPipeline.ts
index 33c2add..8c5c25c 100644
--- a/src/pipeline/TurnkeyPipeline.ts
+++ b/src/pipeline/TurnkeyPipeline.ts
@@ -89,11 +89,7 @@ export class TurnkeyPipeline {
const result = await client.getTransaction(sent.hash);
if (result.status === 'FAILED') {
throw new Error(
- `Invocation of ${args.function}(...) failed on komet-node (status FAILED, tx ${sent.hash}). ` +
- `Note: the current komet-node only completes contract calls that return no value — ` +
- `its callTx handling asserts a Void result, so any function returning a value gets ` +
- `stuck and the transaction is reported FAILED. Try a function that returns () / no value, ` +
- `or update komet-node.`,
+ `Invocation of ${args.function}(...) failed on komet-node (status FAILED, tx ${sent.hash}).`,
);
}
diff --git a/src/server/cliArgs.ts b/src/server/cliArgs.ts
new file mode 100644
index 0000000..df8a603
--- /dev/null
+++ b/src/server/cliArgs.ts
@@ -0,0 +1,87 @@
+/**
+ * Pure argv parsing for the standalone TCP DAP server CLI (`soroban-dap`).
+ *
+ * `parseServerArgs` resolves `--help`, validates `--host`/`--port`, and maps a
+ * raw argv slice onto a discriminated union the (coverage-excluded) shell
+ * dispatches on. PURE: never reads process.argv, never prints, never exits.
+ */
+
+/** The `soroban-dap` help text. */
+export const SERVER_USAGE = `soroban-dap — serve the Soroban debug adapter over a TCP socket
+
+Usage:
+ soroban-dap [--port ] [--host ]
+
+Options:
+ --port TCP port to listen on (default 4711).
+ --host Interface to bind (default 127.0.0.1, loopback only).
+ -h, --help Show this help.
+
+Connect any DAP client to the port (e.g. VS Code "debugServer": ).
+`;
+
+/** Outcome of parsing `soroban-dap` argv. */
+export type ServerParse =
+ | { kind: 'help'; text: string }
+ | { kind: 'error'; message: string }
+ | { kind: 'run'; host?: string; port: number };
+
+const SERVER_HINT = "Run 'soroban-dap --help' for usage.";
+
+/** Value-taking flags for `soroban-dap`. */
+const SERVER_VALUE_FLAGS = new Set(['--host', '--port']);
+
+/** Whether a token is being used as an option (leading dash). */
+function looksLikeFlag(token: string): boolean {
+ return token.startsWith('-');
+}
+
+/**
+ * Devex front door for `soroban-dap`: resolve `--help`, validate tokens and
+ * `--port`, and map argv onto a `ServerParse`. Pure.
+ */
+export function parseServerArgs(argv: string[]): ServerParse {
+ // --help / -h wins anywhere.
+ if (argv.includes('-h') || argv.includes('--help')) {
+ return { kind: 'help', text: SERVER_USAGE };
+ }
+
+ const err = (message: string): ServerParse => ({
+ kind: 'error',
+ message: `${message} ${SERVER_HINT}`,
+ });
+
+ const values: Record = {};
+
+ for (let i = 0; i < argv.length; i++) {
+ const token = argv[i];
+ if (SERVER_VALUE_FLAGS.has(token)) {
+ const next = argv[i + 1];
+ if (next === undefined || looksLikeFlag(next)) {
+ return err(`Missing value for ${token}`);
+ }
+ values[token] = next;
+ i++;
+ } else if (looksLikeFlag(token)) {
+ return err(`Unknown option: ${token}`);
+ } else {
+ return err(`Unexpected argument: ${token}`);
+ }
+ }
+
+ const host = values['--host'];
+ const portRaw = values['--port'];
+
+ let port = 4711;
+ if (portRaw !== undefined) {
+ if (!/^\d+$/.test(portRaw)) {
+ return err('--port must be an integer between 0 and 65535.');
+ }
+ port = Number(portRaw);
+ if (port > 65535) {
+ return err('--port must be an integer between 0 and 65535.');
+ }
+ }
+
+ return { kind: 'run', host, port };
+}
diff --git a/src/server/dapServer.ts b/src/server/dapServer.ts
new file mode 100644
index 0000000..f0822cf
--- /dev/null
+++ b/src/server/dapServer.ts
@@ -0,0 +1,62 @@
+/**
+ * Standalone TCP DAP server (`soroban-dap`) — docs/dap-cli-internal.md, "Interface 2".
+ *
+ * Opens a `net.createServer`; for each connection it creates a fresh
+ * `SorobanDebugSession(backendFor)` (the selector overload, so the backend can
+ * depend on the per-connection launch config) and pipes `session.start(socket,
+ * socket)`. On socket close/error it calls `session.teardown()` so
+ * `backend.dispose()` runs and a `LiveBackend` komet-node process is not leaked
+ * even on an ABRUPT disconnect that sends no `disconnect` request over the wire
+ * (editor crash, network drop, SIGKILL). `DebugSession.shutdown()` can NOT be
+ * used for this: it is a no-op in server mode, so it would never dispose the
+ * backend — hence the explicit idempotent `teardown()`, which also runs on a
+ * clean `disconnect` and guards against double-dispose. This is DAP's canonical
+ * "server mode": any DAP client can connect to the port. No `vscode` import.
+ */
+
+import * as net from 'net';
+import { SorobanDebugSession } from '../debugAdapter/SorobanDebugSession';
+import { backendFor } from '../debugAdapter/backendFor';
+import { SessionBackend, SorobanLaunchArgs } from '../debugAdapter/types';
+
+export interface DapServerOptions {
+ host?: string;
+ port: number;
+ /**
+ * Backend selector; defaults to the real `backendFor`. Injectable so tests
+ * can observe per-connection teardown with a spy backend.
+ */
+ backendFor?: (args: SorobanLaunchArgs) => SessionBackend;
+}
+
+export async function startDapServer(
+ opts: DapServerOptions,
+): Promise<{ port: number; close: () => Promise }> {
+ const select = opts.backendFor ?? backendFor;
+ const server = net.createServer((socket) => {
+ const session = new SorobanDebugSession(select);
+ // Server mode: a dropped connection must tear down only this session, never
+ // the shared server process. Without this, DebugSession.shutdown() calls
+ // process.exit(0) 100ms after any socket closes (docs/dap-cli-internal.md,
+ // "DAP's canonical server mode").
+ session.setRunAsServer(true);
+ session.start(socket, socket);
+ // Dispose the per-connection backend on teardown. shutdown() is a no-op in
+ // server mode, so it can't do this; teardown() is idempotent, so a clean
+ // `disconnect` (which already disposed) makes this a harmless no-op.
+ const teardown = () => {
+ void session.teardown();
+ };
+ socket.on('close', teardown);
+ socket.on('error', teardown);
+ });
+
+ await new Promise((resolve) => {
+ server.listen(opts.port, opts.host ?? '127.0.0.1', resolve);
+ });
+
+ const port = (server.address() as net.AddressInfo).port;
+ const close = () => new Promise((resolve) => server.close(() => resolve()));
+
+ return { port, close };
+}
diff --git a/src/server/main.ts b/src/server/main.ts
new file mode 100644
index 0000000..c3c3bcf
--- /dev/null
+++ b/src/server/main.ts
@@ -0,0 +1,35 @@
+/**
+ * Thin CLI entry for the standalone TCP DAP server (`soroban-dap`).
+ *
+ * Parses argv (via `parseServerArgs`), then dispatches: show help (stdout, exit
+ * 0), report a usage error (stderr, exit 2), or start the server and log the
+ * listening address to stderr. Coverage-excluded: the real logic lives in
+ * `cliArgs.ts` / `dapServer.ts`, exercised directly by tests.
+ */
+
+import { parseServerArgs } from './cliArgs';
+import { startDapServer } from './dapServer';
+
+async function main(): Promise {
+ const p = parseServerArgs(process.argv.slice(2));
+
+ if (p.kind === 'help') {
+ process.stdout.write(p.text + '\n');
+ return;
+ }
+ if (p.kind === 'error') {
+ process.stderr.write(p.message + '\n');
+ process.exitCode = 2;
+ return;
+ }
+
+ const srv = await startDapServer({ host: p.host, port: p.port });
+ process.stderr.write(
+ `Soroban DAP server listening on ${p.host ?? '127.0.0.1'}:${srv.port}\n`,
+ );
+}
+
+main().catch((err) => {
+ process.stderr.write(String(err instanceof Error ? err.stack ?? err.message : err) + '\n');
+ process.exit(1);
+});
diff --git a/src/trace/cliArgs.ts b/src/trace/cliArgs.ts
new file mode 100644
index 0000000..c2de16c
--- /dev/null
+++ b/src/trace/cliArgs.ts
@@ -0,0 +1,166 @@
+/**
+ * Pure argv parsing for the one-shot trace CLI (`soroban-trace`).
+ *
+ * Extracted from the coverage-excluded `main.ts` so the flag semantics can be
+ * unit-tested directly. `parseTraceArgs` is the devex front door: it resolves
+ * `--help`, validates tokens and mode selection, and maps argv onto a
+ * discriminated union the (coverage-excluded) shell dispatches on. PURE: never
+ * reads process.argv, never prints, never exits.
+ */
+
+import { SorobanLaunchArgs } from '../debugAdapter/types';
+import { ScValArg } from '../soroban/scval';
+
+/** The `soroban-trace` help text. */
+export const TRACE_USAGE = `soroban-trace — emit a Rust source-level execution trace as JSONL
+
+Usage:
+ soroban-trace --raw-trace [--wasm ] [options] (offline replay)
+ soroban-trace --contract --function [options] (build & run live)
+
+Options:
+ --raw-trace Recorded JSONL trace to replay (offline mode).
+ --wasm Contract .wasm supplying DWARF debug info (source + variables).
+ --contract Crate directory to build and run (live mode).
+ --function Contract function to invoke (required in live mode).
+ --args-json Function arguments, e.g. '[{"value":1,"type":"u32"}]'.
+ --out Write JSONL to a file instead of stdout.
+ --depth Max variable-expansion depth (default 3).
+ --max-children Max children materialized per aggregate (default 64).
+ --allow-no-source Don't error when the trace has no source-level stops.
+ -h, --help Show this help.
+
+Examples:
+ soroban-trace --raw-trace run.jsonl --wasm contract.wasm
+ soroban-trace --contract . --function add --args-json '[{"value":1,"type":"u32"}]'
+`;
+
+/** Outcome of parsing `soroban-trace` argv. */
+export type TraceParse =
+ | { kind: 'help'; text: string }
+ | { kind: 'error'; message: string }
+ | {
+ kind: 'run';
+ launch: SorobanLaunchArgs;
+ out?: string;
+ opts: { maxDepth?: number; maxChildren?: number; allowNoSource?: boolean };
+ };
+
+const TRACE_HINT = "Run 'soroban-trace --help' for usage.";
+
+/** Value-taking flags for `soroban-trace`. */
+const TRACE_VALUE_FLAGS = new Set([
+ '--raw-trace',
+ '--wasm',
+ '--contract',
+ '--function',
+ '--args-json',
+ '--out',
+ '--depth',
+ '--max-children',
+]);
+
+/** Whether a token is being used as an option (leading dash). */
+function looksLikeFlag(token: string): boolean {
+ return token.startsWith('-');
+}
+
+/** Whether a string is a non-negative integer literal. */
+function isNonNegInt(s: string): boolean {
+ return /^\d+$/.test(s);
+}
+
+/**
+ * Devex front door for `soroban-trace`: resolve `--help`, validate tokens and
+ * mode selection, and map argv onto a `TraceParse`. Pure.
+ */
+export function parseTraceArgs(argv: string[]): TraceParse {
+ // --help / -h wins anywhere.
+ if (argv.includes('-h') || argv.includes('--help')) {
+ return { kind: 'help', text: TRACE_USAGE };
+ }
+
+ const err = (message: string): TraceParse => ({
+ kind: 'error',
+ message: `${message} ${TRACE_HINT}`,
+ });
+
+ const values: Record = {};
+ let allowNoSource = false;
+
+ for (let i = 0; i < argv.length; i++) {
+ const token = argv[i];
+ if (TRACE_VALUE_FLAGS.has(token)) {
+ const next = argv[i + 1];
+ if (next === undefined || looksLikeFlag(next)) {
+ return err(`Missing value for ${token}`);
+ }
+ values[token] = next;
+ i++;
+ } else if (token === '--allow-no-source') {
+ allowNoSource = true;
+ } else if (looksLikeFlag(token)) {
+ return err(`Unknown option: ${token}`);
+ } else {
+ return err(`Unexpected argument: ${token}`);
+ }
+ }
+
+ const rawTrace = values['--raw-trace'];
+ const wasmPath = values['--wasm'];
+ const contract = values['--contract'];
+ const fn = values['--function'];
+ const argsJson = values['--args-json'];
+ const out = values['--out'];
+ const depth = values['--depth'];
+ const maxChildren = values['--max-children'];
+
+ // Mode selection.
+ if (rawTrace === undefined && contract === undefined && wasmPath === undefined) {
+ return err(
+ 'Specify --raw-trace for offline replay, or --contract/--wasm with --function for live mode.',
+ );
+ }
+ // Live mode (no --raw-trace) requires --function.
+ if (rawTrace === undefined && fn === undefined) {
+ return err('--function is required in live mode.');
+ }
+
+ // --args-json must JSON.parse to an array.
+ let args: ScValArg[] | undefined;
+ if (argsJson !== undefined) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(argsJson);
+ } catch (e) {
+ return err(`Invalid --args-json: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ if (!Array.isArray(parsed)) {
+ return err('Invalid --args-json: expected a JSON array.');
+ }
+ args = parsed as ScValArg[];
+ }
+
+ // Numeric options must be non-negative integers.
+ if (depth !== undefined && !isNonNegInt(depth)) {
+ return err('--depth must be a non-negative integer.');
+ }
+ if (maxChildren !== undefined && !isNonNegInt(maxChildren)) {
+ return err('--max-children must be a non-negative integer.');
+ }
+
+ const launch: SorobanLaunchArgs = {
+ function: fn ?? '',
+ rawTrace,
+ wasmPath,
+ contract,
+ ...(args !== undefined ? { args } : {}),
+ };
+
+ const opts: { maxDepth?: number; maxChildren?: number; allowNoSource?: boolean } = {};
+ if (depth !== undefined) opts.maxDepth = Number(depth);
+ if (maxChildren !== undefined) opts.maxChildren = Number(maxChildren);
+ if (allowNoSource) opts.allowNoSource = true;
+
+ return { kind: 'run', launch, out, opts };
+}
diff --git a/src/trace/main.ts b/src/trace/main.ts
new file mode 100644
index 0000000..95d4e4f
--- /dev/null
+++ b/src/trace/main.ts
@@ -0,0 +1,52 @@
+/**
+ * Thin CLI entry for the one-shot trace projection (`soroban-trace`).
+ *
+ * Parses argv (via `parseTraceArgs`), then dispatches: show help (stdout, exit
+ * 0), report a usage error (stderr, exit 2), or resolve a trace through the
+ * selected backend, project it to JSONL via `runCliTrace`, and write the result
+ * to `--out` (or stdout). Coverage-excluded: the real logic lives in
+ * `cliArgs.ts` / `runTrace.ts` / `projectStop.ts`, exercised directly by tests.
+ */
+
+import * as fs from 'fs';
+import { backendFor } from '../debugAdapter/backendFor';
+import { parseTraceArgs } from './cliArgs';
+import { runCliTrace } from './runTrace';
+
+async function main(): Promise {
+ const p = parseTraceArgs(process.argv.slice(2));
+
+ if (p.kind === 'help') {
+ process.stdout.write(p.text + '\n');
+ return;
+ }
+ if (p.kind === 'error') {
+ process.stderr.write(p.message + '\n');
+ process.exitCode = 2;
+ return;
+ }
+
+ const { launch, out, opts } = p;
+ const backend = backendFor(launch);
+ try {
+ const resolved = await backend.resolve(launch, (msg) => process.stderr.write(msg + '\n'));
+ const lines = runCliTrace(resolved, {
+ function: launch.function,
+ wasm: launch.wasmPath,
+ ...opts,
+ });
+ const output = lines.join('\n') + '\n';
+ if (out) {
+ fs.writeFileSync(out, output);
+ } else {
+ process.stdout.write(output);
+ }
+ } finally {
+ await backend.dispose();
+ }
+}
+
+main().catch((err) => {
+ process.stderr.write(String(err instanceof Error ? err.stack ?? err.message : err) + '\n');
+ process.exit(1);
+});
diff --git a/src/trace/projectStop.ts b/src/trace/projectStop.ts
new file mode 100644
index 0000000..fa2bace
--- /dev/null
+++ b/src/trace/projectStop.ts
@@ -0,0 +1,172 @@
+/**
+ * A serializable projection of one trace stop (docs/trace-cli-internal.md,
+ * "projectSourceStop"). Unlike the DAP handlers — whose lazy `Handles` /
+ * child-thunk machinery is deliberately different — this reuses only the
+ * low-level resolver calls and expands `DecodedValue.children` EAGERLY into
+ * plain arrays, bounded by a per-stop depth/child/node budget.
+ *
+ * Pure module (no `vscode`, no DAP imports).
+ */
+
+import { ResolvedTrace } from '../debugAdapter/types';
+import { StopModel, pcAtIndex } from '../debugAdapter/stopModel';
+import { makeRuntimeState } from '../debugAdapter/runtimeState';
+import { MemoryImage } from '../debugAdapter/MemoryImage';
+import { renderInstr } from '../komet/mnemonics';
+import { DecodedValue } from '../dwarf/ValueDecoder';
+
+/** A serializable single-stop projection. */
+export interface SourceStop {
+ /** 0-based ordinal among source stops. */
+ step: number;
+ /** Index into model.records. */
+ traceIndex: number;
+ /** stopModel.depths[traceIndex]. */
+ depth: number;
+ /** Hex, e.g. "0x2d", or null. */
+ pc: string | null;
+ /** functionNameAt(pc), or null. */
+ function: string | null;
+ /** renderInstr(record.instr). */
+ instr: string;
+ /** Mapped source location, or null when unmapped. */
+ source: { path: string; line: number; column?: number } | null;
+ variables: TraceVar[];
+}
+
+/** One decoded variable, eagerly expanded within budget. */
+export interface TraceVar {
+ /** "" when DWARF gives none. */
+ name: string;
+ /** The type's display name; "" when the decoder supplies none. */
+ type: string;
+ value: string;
+ /** Present only when expandable and within budget. */
+ children?: TraceVar[];
+ /** Marker set when the budget cut expansion. */
+ truncated?: boolean;
+}
+
+/** Per-stop expansion budget plus an optional pre-built memory image. */
+export interface ProjectOpts {
+ maxDepth?: number;
+ maxChildren?: number;
+ maxNodes?: number;
+ memory?: MemoryImage;
+}
+
+const DEFAULT_MAX_DEPTH = 3;
+const DEFAULT_MAX_CHILDREN = 64;
+const DEFAULT_MAX_NODES = 1500;
+
+/** Mutable per-call node counter so a per-stop budget stays isolated. */
+interface NodeCounter {
+ count: number;
+}
+
+/**
+ * Expand a decoded value into a serializable TraceVar, materializing children
+ * eagerly up to the depth/child/node budget.
+ */
+function expandDecoded(
+ name: string,
+ decoded: DecodedValue,
+ depth: number,
+ maxDepth: number,
+ maxChildren: number,
+ maxNodes: number,
+ counter: NodeCounter,
+): TraceVar {
+ const node: TraceVar = { name, type: decoded.typeName ?? '', value: decoded.display };
+
+ if (typeof decoded.children === 'function') {
+ if (depth >= maxDepth || counter.count >= maxNodes) {
+ node.truncated = true;
+ return node;
+ }
+ const rawChildren = decoded.children();
+ if (rawChildren.length === 0) {
+ // Genuinely empty — omit the `children` key entirely.
+ return node;
+ }
+ const children: TraceVar[] = [];
+ const limit = Math.min(rawChildren.length, maxChildren);
+ for (let i = 0; i < limit; i++) {
+ counter.count++;
+ const child = rawChildren[i];
+ children.push(
+ expandDecoded(
+ child.name,
+ child.value,
+ depth + 1,
+ maxDepth,
+ maxChildren,
+ maxNodes,
+ counter,
+ ),
+ );
+ }
+ if (rawChildren.length > maxChildren) {
+ children.push({ name: '…', type: '', value: '…', truncated: true });
+ }
+ node.children = children;
+ }
+
+ return node;
+}
+
+/**
+ * Project the stop at `index` into a serializable `SourceStop`, reusing the
+ * low-level resolver calls and eagerly expanding decoded variable trees.
+ */
+export function projectSourceStop(
+ resolved: ResolvedTrace,
+ stopModel: StopModel,
+ index: number,
+ opts?: ProjectOpts,
+): SourceStop {
+ const maxDepth = opts?.maxDepth ?? DEFAULT_MAX_DEPTH;
+ const maxChildren = opts?.maxChildren ?? DEFAULT_MAX_CHILDREN;
+ const maxNodes = opts?.maxNodes ?? DEFAULT_MAX_NODES;
+
+ const record = resolved.model.records[index];
+
+ const mapped = resolved.source.locationForIndex(index);
+ let source: SourceStop['source'] = null;
+ if (mapped) {
+ source = { path: mapped.path, line: mapped.line };
+ if (mapped.column !== undefined && mapped.column !== null) {
+ source.column = mapped.column;
+ }
+ }
+
+ const pc = pcAtIndex(resolved.positions, index);
+ const pcHex = pc === null ? null : '0x' + pc.toString(16);
+ const functionName = pc === null ? null : (resolved.variables.functionNameAt(pc) ?? null);
+
+ const variables: TraceVar[] = [];
+ if (pc !== null && resolved.variables.hasVariables()) {
+ const memory = opts?.memory ?? new MemoryImage(resolved.model.records);
+ const state = makeRuntimeState(record, memory, index);
+ const counter: NodeCounter = { count: 0 };
+ for (const v of resolved.variables.variablesInScope(pc)) {
+ const decoded = resolved.variables.decodeVariable(v, state, pc);
+ variables.push(
+ expandDecoded(v.name ?? '', decoded, 0, maxDepth, maxChildren, maxNodes, counter),
+ );
+ }
+ }
+
+ const step = stopModel.runStarts.indexOf(index);
+
+ return {
+ step,
+ traceIndex: index,
+ depth: stopModel.depths[index],
+ pc: pcHex,
+ function: functionName,
+ instr: renderInstr(record.instr),
+ source,
+ variables,
+ };
+}
diff --git a/src/trace/runTrace.ts b/src/trace/runTrace.ts
new file mode 100644
index 0000000..fbde0ff
--- /dev/null
+++ b/src/trace/runTrace.ts
@@ -0,0 +1,76 @@
+/**
+ * The one-shot CLI trace projection (docs/trace-cli-internal.md, "Interface 1").
+ *
+ * `runCliTrace` walks `stopModel.runStarts` in order — provably the same
+ * sequence a user sees stepping in (statement-granularity stepIn visits
+ * runStarts[0..n] then terminates per S20) — and emits kind-tagged JSONL: a
+ * leading `meta` record, one `stop` per runStart, then a trailing `result`.
+ *
+ * When `runStarts` is empty (no DWARF / no source) it ERRORS rather than
+ * silently emitting `visibleIndices` as if they were source statements, unless
+ * `allowNoSource` opts in.
+ *
+ * Pure module (no `vscode`, no DAP imports).
+ */
+
+import { ResolvedTrace } from '../debugAdapter/types';
+import { buildStopModel } from '../debugAdapter/stopModel';
+import { MemoryImage } from '../debugAdapter/MemoryImage';
+import { ProjectOpts, projectSourceStop } from './projectStop';
+
+/** Options for the one-shot CLI trace projection. */
+export interface CliTraceOpts extends ProjectOpts {
+ function?: string;
+ wasm?: string;
+ allowNoSource?: boolean;
+}
+
+/**
+ * Project a resolved trace into kind-tagged JSONL lines. Throws when there are
+ * no source-level stops unless `opts.allowNoSource` is set.
+ */
+export function runCliTrace(resolved: ResolvedTrace, opts?: CliTraceOpts): string[] {
+ const stopModel = buildStopModel(resolved);
+
+ if (stopModel.runStarts.length === 0 && !opts?.allowNoSource) {
+ throw new Error(
+ 'No Rust source-level stops in this trace (no DWARF / no source). ' +
+ 'Pass a matching --wasm, or --allow-no-source.',
+ );
+ }
+
+ const memory = new MemoryImage(resolved.model.records);
+ const projectOpts: ProjectOpts = {
+ maxDepth: opts?.maxDepth,
+ maxChildren: opts?.maxChildren,
+ maxNodes: opts?.maxNodes,
+ memory,
+ };
+
+ const lines: string[] = [
+ JSON.stringify({
+ kind: 'meta',
+ function: opts?.function,
+ wasm: opts?.wasm,
+ records: resolved.model.records.length,
+ stops: stopModel.runStarts.length,
+ hasDwarf: resolved.variables.hasVariables(),
+ }),
+ ];
+
+ for (const index of stopModel.runStarts) {
+ lines.push(
+ JSON.stringify({ kind: 'stop', ...projectSourceStop(resolved, stopModel, index, projectOpts) }),
+ );
+ }
+
+ lines.push(
+ JSON.stringify({
+ kind: 'result',
+ ...(resolved.returnValue !== undefined ? { returnValue: resolved.returnValue } : {}),
+ terminated: true,
+ }),
+ );
+
+ return lines;
+}
diff --git a/test/backendFor.test.ts b/test/backendFor.test.ts
new file mode 100644
index 0000000..5c7d2c1
--- /dev/null
+++ b/test/backendFor.test.ts
@@ -0,0 +1,42 @@
+/**
+ * Unit suite for the trace-acquisition backend selector (docs/trace-cli-internal.md,
+ * "backendFor"). backendFor(args) picks the vscode-free backend from the launch
+ * args, reused by the extension, the TCP server, and the CLI:
+ *
+ * args.rawTrace present → RawTraceBackend (offline JSONL replay)
+ * otherwise → LiveBackend (build → komet-node → trace pipeline)
+ *
+ * It reads only args.rawTrace, so it needs no vscode. The module does not exist
+ * yet, so this is the red anchor for it.
+ */
+
+import * as assert from 'assert';
+import { backendFor } from '../src/debugAdapter/backendFor';
+import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend';
+import { LiveBackend } from '../src/debugAdapter/backends/LiveBackend';
+
+describe('backendFor (docs/trace-cli-internal.md, backend selection)', () => {
+ it('selects RawTraceBackend when rawTrace is present', () => {
+ const backend = backendFor({ rawTrace: 'x.jsonl' } as any);
+ assert.ok(
+ backend instanceof RawTraceBackend,
+ 'expected a RawTraceBackend for a rawTrace launch',
+ );
+ assert.ok(
+ !(backend instanceof LiveBackend),
+ 'a rawTrace launch must not select the LiveBackend',
+ );
+ });
+
+ it('selects LiveBackend when there is no rawTrace', () => {
+ const backend = backendFor({ function: 'add' } as any);
+ assert.ok(
+ backend instanceof LiveBackend,
+ 'expected a LiveBackend for a live (no rawTrace) launch',
+ );
+ assert.ok(
+ !(backend instanceof RawTraceBackend),
+ 'a live launch must not select the RawTraceBackend',
+ );
+ });
+});
diff --git a/test/dapServer.test.ts b/test/dapServer.test.ts
new file mode 100644
index 0000000..3c33016
--- /dev/null
+++ b/test/dapServer.test.ts
@@ -0,0 +1,152 @@
+import * as assert from 'assert';
+import * as path from 'path';
+import { DebugClient } from '@vscode/debugadapter-testsupport';
+import { DebugProtocol } from '@vscode/debugprotocol';
+// src/server/dapServer.ts does not exist yet — this import is what makes the
+// suite fail to compile/run until M2's TCP server lands (docs/dap-cli-internal.md,
+// "Interface 2 — standalone TCP DAP server").
+import { startDapServer } from '../src/server/dapServer';
+import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend';
+import {
+ ProgressReporter,
+ ResolvedTrace,
+ SessionBackend,
+ SorobanLaunchArgs,
+} from '../src/debugAdapter/types';
+
+const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures');
+const ADDER_TRACE = path.join(FIXTURES, 'adder-debug.trace.jsonl');
+const ADDER_WASM = path.join(FIXTURES, 'adder-debug.wasm');
+
+/** The real contract source the fixture's DWARF resolves to. */
+const LIB_RS_SUFFIX = 'examples/adder/src/lib.rs';
+
+const WITH_WASM = { rawTrace: ADDER_TRACE, wasmPath: ADDER_WASM };
+const THREAD = { threadId: 1 };
+
+describe('startDapServer (standalone TCP DAP server)', () => {
+ let srv: { port: number; close: () => Promise };
+ let clients: DebugClient[];
+
+ beforeEach(async () => {
+ clients = [];
+ srv = await startDapServer({ port: 0 });
+ assert.strictEqual(typeof srv.port, 'number', 'expected a numeric ephemeral port');
+ assert.ok(srv.port > 0, `expected an assigned ephemeral port, got ${srv.port}`);
+ });
+
+ afterEach(async function () {
+ this.timeout(30000);
+ for (const dc of clients) {
+ try {
+ await dc.stop();
+ } catch {
+ // best-effort teardown; a client may already be disconnected
+ }
+ }
+ await srv.close();
+ });
+
+ /** Connect a fresh DebugClient to the server's TCP port. */
+ async function connect(): Promise {
+ const dc = new DebugClient('node', 'unused', 'soroban');
+ clients.push(dc);
+ await dc.start(srv.port);
+ return dc;
+ }
+
+ it('drives a real DAP session over the socket, stopping at the adder entry', async function () {
+ this.timeout(30000);
+ const dc = await connect();
+
+ const [, , stopped] = await Promise.all([
+ dc.configurationSequence(),
+ dc.launch(WITH_WASM as any),
+ dc.waitForEvent('stopped'),
+ ]);
+ assert.strictEqual((stopped as DebugProtocol.StoppedEvent).body.reason, 'entry');
+
+ const res = await dc.stackTraceRequest(THREAD);
+ assert.ok(res.body.stackFrames.length >= 1, 'expected at least one stack frame');
+ const top = res.body.stackFrames[0];
+ assert.strictEqual(top.line, 16);
+ assert.ok(
+ top.source?.path?.endsWith(LIB_RS_SUFFIX),
+ `unexpected source: ${top.source?.path}`,
+ );
+ });
+
+ it('serves a second independent connection (one session per connection)', async function () {
+ this.timeout(30000);
+ // First connection drives a full session to a stop...
+ const dc1 = await connect();
+ await Promise.all([
+ dc1.configurationSequence(),
+ dc1.launch(WITH_WASM as any),
+ dc1.waitForEvent('stopped'),
+ ]);
+
+ // ...while a second, independent connection to the same server initializes
+ // and disconnects cleanly, proving the server is not single-session.
+ const dc2 = await connect();
+ const init = await dc2.initializeRequest();
+ assert.ok(init.body, 'expected initialize capabilities on the second connection');
+ await dc2.disconnectRequest({});
+ });
+
+ it('disposes the per-connection backend when the socket drops without a disconnect', async function () {
+ this.timeout(30000);
+
+ // A spy backend delegating to the real replay path, counting disposals.
+ // This is exactly the LiveBackend leak scenario: a backend resolved by a
+ // launch (its komet-node pipeline live) must be disposed even when the
+ // client vanishes without sending a `disconnect` request.
+ class SpyBackend implements SessionBackend {
+ disposeCount = 0;
+ private readonly inner = new RawTraceBackend();
+ resolve(args: SorobanLaunchArgs, report: ProgressReporter): Promise {
+ return this.inner.resolve(args, report);
+ }
+ async dispose(): Promise {
+ this.disposeCount++;
+ }
+ }
+ const spy = new SpyBackend();
+
+ // A dedicated server whose sole connection uses the spy backend.
+ const leakSrv = await startDapServer({ port: 0, backendFor: () => spy });
+ try {
+ const dc = new DebugClient('node', 'unused', 'soroban');
+ await dc.start(leakSrv.port);
+
+ // Drive a full launch so the backend selector resolves to the concrete
+ // spy (mirrors a live komet-node pipeline being spun up).
+ await Promise.all([
+ dc.configurationSequence(),
+ dc.launch(WITH_WASM as any),
+ dc.waitForEvent('stopped'),
+ ]);
+ assert.strictEqual(spy.disposeCount, 0, 'backend should not be disposed while connected');
+
+ // Abrupt drop: destroy the underlying socket WITHOUT a disconnect request
+ // (editor crash / network drop). The old code wired socket close to
+ // session.shutdown(), a no-op in server mode, so dispose never ran.
+ const socket = (dc as unknown as { _socket?: import('net').Socket })._socket;
+ assert.ok(socket, 'expected an underlying socket on the DebugClient');
+ socket.destroy();
+
+ // Poll until the teardown handler has disposed the backend.
+ const deadline = Date.now() + 5000;
+ while (spy.disposeCount === 0 && Date.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 20));
+ }
+ assert.strictEqual(
+ spy.disposeCount,
+ 1,
+ 'abrupt socket drop must dispose the per-connection backend exactly once',
+ );
+ } finally {
+ await leakSrv.close();
+ }
+ });
+});
diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts
index f63748b..fd6b920 100644
--- a/test/pipeline.test.ts
+++ b/test/pipeline.test.ts
@@ -91,7 +91,20 @@ describe('TurnkeyPipeline (against mock komet-node)', () => {
await mock.stop();
mock = new MockKometNode({ trace, traceStatus: 'FAILED' });
port = await mock.start();
- await assert.rejects(() => run(), /FAILED/);
+ await assert.rejects(
+ () => run(),
+ (err: Error) => {
+ // Still flags the FAILED status and identifies the invocation...
+ assert.match(err.message, /FAILED/);
+ assert.match(err.message, /add/);
+ // ...and pins the invocation to its transaction hash (mock's hashFor
+ // yields a 64-char hex hash, so this stays non-brittle).
+ assert.match(err.message, /tx [0-9a-f]{64}/);
+ // ...but no longer parrots the (now-fixed) value-return limitation.
+ assert.doesNotMatch(err.message, /no value|Void|update komet-node|stuck/i);
+ return true;
+ },
+ );
});
});
diff --git a/test/projectStop.test.ts b/test/projectStop.test.ts
new file mode 100644
index 0000000..28f04d0
--- /dev/null
+++ b/test/projectStop.test.ts
@@ -0,0 +1,183 @@
+/**
+ * Unit suite for the serializable single-stop projection
+ * (docs/trace-cli-internal.md, "projectSourceStop"). The module under test does not
+ * exist yet — this is the red anchor for it.
+ *
+ * projectSourceStop(resolved, stopModel, index, opts): SourceStop
+ * from src/trace/projectStop.ts
+ *
+ * It reuses only the low-level resolver calls (locationForIndex, pcAtIndex,
+ * functionNameAt, makeRuntimeState + variablesInScope + decodeVariable) and,
+ * unlike the DAP handlers, expands DecodedValue.children EAGERLY into plain
+ * arrays bounded by a per-stop depth/child budget. Values are pinned to the
+ * verified ground-truth fixtures (docs/trace-cli-internal.md ground-truth table).
+ */
+
+import * as assert from 'assert';
+import * as path from 'path';
+import { buildStopModel } from '../src/debugAdapter/stopModel';
+import { projectSourceStop } from '../src/trace/projectStop';
+import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend';
+import { ResolvedTrace } from '../src/debugAdapter/types';
+
+const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures');
+
+/** Resolve a symbol-rich ResolvedTrace (rawTrace + wasmPath) for a fixture. */
+async function resolveFixture(name: string): Promise {
+ const args = {
+ rawTrace: path.join(FIXTURES, `${name}.trace.jsonl`),
+ wasmPath: path.join(FIXTURES, `${name}.wasm`),
+ };
+ return new RawTraceBackend().resolve(args as any, () => {});
+}
+
+describe('projectSourceStop (docs/trace-cli-internal.md, serializable stop projection)', () => {
+ describe('adder-debug idx 29 (the sole statement stop)', () => {
+ let resolved: ResolvedTrace;
+
+ before(async () => {
+ resolved = await resolveFixture('adder-debug');
+ });
+
+ it('projects the pinned SourceStop shape and values', () => {
+ const sm = buildStopModel(resolved);
+ const stop = projectSourceStop(resolved, sm, 29);
+
+ // Ordinal among source stops: runStarts = [29] → step 0.
+ assert.strictEqual(stop.step, 0);
+ assert.strictEqual(stop.traceIndex, 29);
+ assert.strictEqual(stop.depth, 0);
+ // pc hex = "0x" + pos.toString(16); pos 45 → "0x2d".
+ assert.strictEqual(stop.pc, '0x2d');
+ assert.strictEqual(stop.function, 'invoke_raw_extern');
+ assert.ok(
+ stop.instr.startsWith('i32.add'),
+ `expected instr to start with i32.add, got: ${stop.instr}`,
+ );
+
+ assert.ok(stop.source, 'expected a mapped source location');
+ assert.strictEqual(stop.source!.line, 16);
+ assert.strictEqual(stop.source!.column, 9);
+ assert.ok(
+ stop.source!.path.endsWith('examples/adder/src/lib.rs'),
+ `unexpected source path: ${stop.source!.path}`,
+ );
+ });
+
+ it('projects the two leaf argument variables with no children', () => {
+ const sm = buildStopModel(resolved);
+ const stop = projectSourceStop(resolved, sm, 29);
+
+ assert.deepStrictEqual(stop.variables, [
+ { name: 'arg_0', type: 'Val', value: '17179869188' },
+ { name: 'arg_1', type: 'Val', value: '12884901892' },
+ ]);
+ // Leaves: neither `children` nor `truncated` keys are emitted.
+ for (const v of stop.variables) {
+ assert.strictEqual(v.children, undefined);
+ assert.strictEqual(v.truncated, undefined);
+ }
+ });
+ });
+
+ describe('stepper-debug idx 29 (function `triple`)', () => {
+ let resolved: ResolvedTrace;
+
+ before(async () => {
+ resolved = await resolveFixture('stepper-debug');
+ });
+
+ it('omits an absent column and pins the `triple`/x=0 values', () => {
+ const sm = buildStopModel(resolved);
+ const stop = projectSourceStop(resolved, sm, 29);
+
+ // runStarts = [21,27,29,...] → idx 29 is the third source stop (step 2).
+ assert.strictEqual(stop.step, 2);
+ assert.strictEqual(stop.traceIndex, 29);
+ assert.strictEqual(stop.function, 'triple');
+
+ assert.ok(stop.source, 'expected a mapped source location');
+ assert.strictEqual(stop.source!.line, 15);
+ // The null column MUST be omitted, never emitted as `column: null/undefined`.
+ assert.ok(
+ !('column' in stop.source!),
+ `expected column to be omitted, got: ${JSON.stringify(stop.source)}`,
+ );
+
+ const x = stop.variables.find((v) => v.name === 'x');
+ assert.ok(x, 'expected a variable named x');
+ assert.strictEqual(x!.type, 'u32');
+ assert.strictEqual(x!.value, '0');
+ });
+ });
+
+ describe('increment-debug idx 999 (expandable `env`, unnamed first var)', () => {
+ let resolved: ResolvedTrace;
+
+ before(async () => {
+ resolved = await resolveFixture('increment-debug');
+ });
+
+ it('renders an unnamed variable as and tolerates a null function', () => {
+ const sm = buildStopModel(resolved);
+ const stop = projectSourceStop(resolved, sm, 999);
+
+ // runStarts = [646,999,...] → idx 999 is the second source stop (step 1).
+ assert.strictEqual(stop.step, 1);
+ assert.strictEqual(stop.traceIndex, 999);
+ // functionNameAt may return null even with DWARF; the field must reflect it.
+ assert.ok(
+ stop.function === null || typeof stop.function === 'string',
+ `function must be a string or null, got: ${String(stop.function)}`,
+ );
+
+ assert.ok(stop.source, 'expected a mapped source location');
+ assert.strictEqual(stop.source!.line, 21);
+
+ // The first variable has no DWARF name → it renders as "".
+ assert.strictEqual(stop.variables[0].name, '');
+ });
+
+ it('pins `current:u32=15` and eagerly expands `env` exactly one level', () => {
+ const sm = buildStopModel(resolved);
+ const stop = projectSourceStop(resolved, sm, 999);
+
+ const current = stop.variables.find((v) => v.name === 'current');
+ assert.ok(current, 'expected a variable named current');
+ assert.strictEqual(current!.type, 'u32');
+ assert.strictEqual(current!.value, '15');
+
+ const env = stop.variables.find((v) => v.name === 'env');
+ assert.ok(env, 'expected a variable named env');
+ assert.strictEqual(env!.type, 'Env');
+ assert.strictEqual(env!.value, 'Env');
+
+ // env IS expandable: exactly one child, env_impl:Guest.
+ assert.ok(Array.isArray(env!.children), 'expected env to be expanded eagerly');
+ assert.strictEqual(env!.children!.length, 1);
+ const child = env!.children![0];
+ assert.strictEqual(child.name, 'env_impl');
+ assert.strictEqual(child.type, 'Guest');
+ assert.strictEqual(child.value, 'Guest');
+
+ // Recursive expansion reaches env_impl by name.
+ assert.strictEqual(env!.children![0].name, 'env_impl');
+
+ // env_impl has an EMPTY child set → the `children` key MUST be omitted,
+ // never emitted as `children: []`. Not truncated either — genuinely empty.
+ assert.strictEqual(child.children, undefined);
+ assert.strictEqual(child.truncated, undefined);
+ });
+
+ it('honors a depth budget: {maxDepth: 0} cuts env children and marks it truncated', () => {
+ const sm = buildStopModel(resolved);
+ const stop = projectSourceStop(resolved, sm, 999, { maxDepth: 0 });
+
+ const env = stop.variables.find((v) => v.name === 'env');
+ assert.ok(env, 'expected a variable named env');
+ // Expandable but cut by the depth budget: no children, truncated marker set.
+ assert.strictEqual(env!.children, undefined);
+ assert.strictEqual(env!.truncated, true);
+ });
+ });
+});
diff --git a/test/runTrace.test.ts b/test/runTrace.test.ts
new file mode 100644
index 0000000..a76f883
--- /dev/null
+++ b/test/runTrace.test.ts
@@ -0,0 +1,133 @@
+/**
+ * Unit suite for the one-shot CLI trace projection
+ * (docs/trace-cli-internal.md, "Interface 1 — one-shot CLI"). The module under test
+ * does not exist yet — this is the red anchor for it.
+ *
+ * runCliTrace(resolved, opts): string[] from src/trace/runTrace.ts
+ *
+ * It walks stopModel.runStarts in order — provably the same sequence a user
+ * sees stepping in — and emits kind-tagged JSONL lines: a leading `meta`
+ * record, one `stop` per runStart, then a trailing `result`. If runStarts is
+ * empty (no DWARF / no source) it ERRORS rather than silently emitting
+ * visibleIndices, unless opts.allowNoSource is set. Values are pinned to the
+ * verified ground-truth fixtures.
+ */
+
+import * as assert from 'assert';
+import * as path from 'path';
+import { buildStopModel } from '../src/debugAdapter/stopModel';
+import { runCliTrace } from '../src/trace/runTrace';
+import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend';
+import { ResolvedTrace } from '../src/debugAdapter/types';
+
+const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures');
+
+/** Resolve a symbol-rich ResolvedTrace (rawTrace + wasmPath) for a fixture. */
+async function resolveFixture(name: string): Promise {
+ const args = {
+ rawTrace: path.join(FIXTURES, `${name}.trace.jsonl`),
+ wasmPath: path.join(FIXTURES, `${name}.wasm`),
+ };
+ return new RawTraceBackend().resolve(args as any, () => {});
+}
+
+describe('runCliTrace (docs/trace-cli-internal.md, one-shot CLI JSONL)', () => {
+ describe('adder-debug (records=41, runStarts=[29])', () => {
+ let resolved: ResolvedTrace;
+
+ before(async () => {
+ resolved = await resolveFixture('adder-debug');
+ });
+
+ it('emits meta, exactly one stop, and a terminated result', () => {
+ const sm = buildStopModel(resolved);
+ const lines = runCliTrace(resolved, {});
+ assert.ok(Array.isArray(lines), 'expected string[] JSONL lines');
+
+ // Line 0: meta.
+ const meta = JSON.parse(lines[0]);
+ assert.strictEqual(meta.kind, 'meta');
+ assert.strictEqual(meta.records, 41);
+ assert.strictEqual(meta.stops, 1);
+ assert.strictEqual(meta.hasDwarf, true);
+
+ // Exactly runStarts.length stop lines.
+ const stops = lines.map((l) => JSON.parse(l)).filter((r) => r.kind === 'stop');
+ assert.strictEqual(stops.length, sm.runStarts.length);
+ assert.strictEqual(stops.length, 1);
+
+ // The last line is the terminated result, with NO returnValue
+ // (RawTraceBackend sets none).
+ const result = JSON.parse(lines[lines.length - 1]);
+ assert.strictEqual(result.kind, 'result');
+ assert.strictEqual(result.terminated, true);
+ assert.ok(
+ !('returnValue' in result) || result.returnValue === undefined,
+ `expected no returnValue, got: ${JSON.stringify(result)}`,
+ );
+ });
+
+ it('pins the step-0 stop values', () => {
+ const lines = runCliTrace(resolved, {});
+ const stop = lines.map((l) => JSON.parse(l)).find((r) => r.kind === 'stop');
+ assert.ok(stop, 'expected a stop line');
+
+ assert.strictEqual(stop.step, 0);
+ assert.strictEqual(stop.traceIndex, 29);
+ assert.strictEqual(stop.depth, 0);
+ assert.strictEqual(stop.pc, '0x2d');
+ assert.strictEqual(stop.function, 'invoke_raw_extern');
+ assert.ok(
+ String(stop.instr).startsWith('i32.add'),
+ `expected instr to start with i32.add, got: ${stop.instr}`,
+ );
+ assert.strictEqual(stop.source.line, 16);
+ assert.strictEqual(stop.source.column, 9);
+ assert.ok(
+ String(stop.source.path).endsWith('examples/adder/src/lib.rs'),
+ `unexpected source path: ${stop.source.path}`,
+ );
+ assert.deepStrictEqual(stop.variables, [
+ { name: 'arg_0', type: 'Val', value: '17179869188' },
+ { name: 'arg_1', type: 'Val', value: '12884901892' },
+ ]);
+ });
+ });
+
+ describe('stepper-debug (records=85, 10 source stops)', () => {
+ it('emits one ascending stop per runStart with matching traceIndex', async () => {
+ const resolved = await resolveFixture('stepper-debug');
+ const sm = buildStopModel(resolved);
+ const lines = runCliTrace(resolved, {});
+
+ const stops = lines.map((l) => JSON.parse(l)).filter((r) => r.kind === 'stop');
+ assert.strictEqual(stops.length, sm.runStarts.length);
+ assert.strictEqual(stops.length, 10);
+
+ stops.forEach((stop, step) => {
+ assert.strictEqual(stop.step, step, `stop ${step} has step ${stop.step}`);
+ assert.strictEqual(
+ stop.traceIndex,
+ sm.runStarts[step],
+ `stop ${step} traceIndex should equal runStarts[${step}]`,
+ );
+ });
+ });
+ });
+
+ describe('no DWARF (rawTrace only, empty runStarts)', () => {
+ it('throws rather than silently emitting visibleIndices', async () => {
+ const resolved = await new RawTraceBackend().resolve(
+ { rawTrace: path.join(FIXTURES, 'adder-debug.trace.jsonl') } as any,
+ () => {},
+ );
+ const sm = buildStopModel(resolved);
+ assert.strictEqual(sm.runStarts.length, 0, 'precondition: no source stops without wasm');
+ assert.throws(
+ () => runCliTrace(resolved, {}),
+ /source|dwarf|stop/i,
+ 'expected runCliTrace to throw when there are no source stops',
+ );
+ });
+ });
+});
diff --git a/test/serverCli.test.ts b/test/serverCli.test.ts
new file mode 100644
index 0000000..d6a7bb8
--- /dev/null
+++ b/test/serverCli.test.ts
@@ -0,0 +1,130 @@
+/**
+ * Unit suite for the pure argv parser behind the DAP TCP server CLI
+ * (`soroban-dap`), milestone M3 (CLI devex: --help + argument validation).
+ *
+ * parseServerArgs(argv): ServerParse from src/server/cliArgs.ts
+ * SERVER_USAGE: string the help text
+ *
+ * The parser is PURE — it never touches process.argv, never prints, never
+ * exits. It maps a raw argv slice onto a discriminated union describing what
+ * the (coverage-excluded) main.ts shell should do: show help, report a usage
+ * error, or run with a host/port.
+ *
+ * This module does not exist yet, so this is the red anchor for it.
+ */
+
+import * as assert from 'assert';
+import { parseServerArgs, SERVER_USAGE, ServerParse } from '../src/server/cliArgs';
+
+/** Narrow a ServerParse to the 'run' variant (fails the test otherwise). */
+function asRun(p: ServerParse): Extract {
+ assert.strictEqual(p.kind, 'run', `expected run, got ${p.kind}: ${JSON.stringify(p)}`);
+ return p as Extract;
+}
+
+/** Narrow a ServerParse to the 'error' variant and return its message. */
+function asError(p: ServerParse): string {
+ assert.strictEqual(p.kind, 'error', `expected error, got ${p.kind}: ${JSON.stringify(p)}`);
+ return (p as Extract).message;
+}
+
+/** Narrow a ServerParse to the 'help' variant and return its text. */
+function asHelp(p: ServerParse): string {
+ assert.strictEqual(p.kind, 'help', `expected help, got ${p.kind}: ${JSON.stringify(p)}`);
+ return (p as Extract).text;
+}
+
+describe('parseServerArgs (M3 CLI devex)', () => {
+ describe('help', () => {
+ it('--help → kind "help" with the usage text', () => {
+ const text = asHelp(parseServerArgs(['--help']));
+ assert.strictEqual(text, SERVER_USAGE);
+ });
+
+ it('-h → kind "help" with the usage text', () => {
+ const text = asHelp(parseServerArgs(['-h']));
+ assert.strictEqual(text, SERVER_USAGE);
+ });
+
+ it('--help wins even alongside otherwise-valid flags', () => {
+ const text = asHelp(parseServerArgs(['--port', '5000', '--help']));
+ assert.strictEqual(text, SERVER_USAGE);
+ });
+
+ it('SERVER_USAGE carries the documented stable substrings', () => {
+ for (const needle of ['soroban-dap', 'Usage', '--port', '--host', '-h, --help']) {
+ assert.ok(SERVER_USAGE.includes(needle), `SERVER_USAGE should contain ${needle}`);
+ }
+ });
+ });
+
+ describe('defaults', () => {
+ it('[] → run with port 4711 and host undefined', () => {
+ const p = asRun(parseServerArgs([]));
+ assert.strictEqual(p.port, 4711);
+ assert.strictEqual(p.host, undefined);
+ });
+ });
+
+ describe('--port', () => {
+ it('--port 5000 → run with port 5000', () => {
+ const p = asRun(parseServerArgs(['--port', '5000']));
+ assert.strictEqual(p.port, 5000);
+ });
+
+ it('--port 0 → run with port 0 (valid boundary)', () => {
+ const p = asRun(parseServerArgs(['--port', '0']));
+ assert.strictEqual(p.port, 0);
+ });
+
+ it('--port 65535 → run with port 65535 (valid upper boundary)', () => {
+ const p = asRun(parseServerArgs(['--port', '65535']));
+ assert.strictEqual(p.port, 65535);
+ });
+
+ it('--port abc (non-integer) → error mentioning --port', () => {
+ const msg = asError(parseServerArgs(['--port', 'abc']));
+ assert.ok(msg.includes('--port'), msg);
+ });
+
+ it('--port 65536 (off-by-one, out of range) → error "between 0 and 65535"', () => {
+ const msg = asError(parseServerArgs(['--port', '65536']));
+ assert.ok(msg.includes('between 0 and 65535'), msg);
+ });
+
+ it('--port 70000 (out of range) → error "between 0 and 65535"', () => {
+ const msg = asError(parseServerArgs(['--port', '70000']));
+ assert.ok(msg.includes('between 0 and 65535'), msg);
+ });
+
+ it('--port with no value → "Missing value for --port"', () => {
+ const msg = asError(parseServerArgs(['--port']));
+ assert.ok(msg.includes('Missing value for --port'), msg);
+ });
+
+ it('--host --port (value is a flag) → "Missing value for --host"', () => {
+ const msg = asError(parseServerArgs(['--host', '--port']));
+ assert.ok(msg.includes('Missing value for --host'), msg);
+ });
+ });
+
+ describe('--host', () => {
+ it('--host 0.0.0.0 --port 9229 → run with both set', () => {
+ const p = asRun(parseServerArgs(['--host', '0.0.0.0', '--port', '9229']));
+ assert.strictEqual(p.host, '0.0.0.0');
+ assert.strictEqual(p.port, 9229);
+ });
+ });
+
+ describe('token validation', () => {
+ it('unknown flag → "Unknown option: --nope"', () => {
+ const msg = asError(parseServerArgs(['--nope']));
+ assert.ok(msg.includes('Unknown option: --nope'), msg);
+ });
+
+ it('stray positional → "Unexpected argument: stray"', () => {
+ const msg = asError(parseServerArgs(['stray']));
+ assert.ok(msg.includes('Unexpected argument: stray'), msg);
+ });
+ });
+});
diff --git a/test/stopModel.test.ts b/test/stopModel.test.ts
new file mode 100644
index 0000000..5f232c3
--- /dev/null
+++ b/test/stopModel.test.ts
@@ -0,0 +1,111 @@
+/**
+ * Unit suite for the shared headless stop model (docs/trace-cli-internal.md, "Shared
+ * headless core"). Two pure, vscode-free functions in
+ * src/debugAdapter/stopModel.ts are the single source of truth the CLI and the
+ * DAP session both build on, so they can never disagree about where a "stop" is:
+ *
+ * buildStopModel(resolved): StopModel — derives, exactly as
+ * SorobanDebugSession.launchRequest did inline, the validatedPosToIndices
+ * map, visibleIndices, per-record depths, the raw line-run starts, the
+ * statement-granularity runStarts (post S17/S18), and the first/last stop
+ * points.
+ * pcAtIndex(positions, index) — the current-PC rule: the validated code
+ * offset at `index`, or the nearest EARLIER record that has one, else null.
+ *
+ * Values are pinned to the verified ground-truth fixtures. The module does not
+ * exist yet, so this is the red anchor for it.
+ */
+
+import * as assert from 'assert';
+import * as path from 'path';
+import { buildStopModel, pcAtIndex } from '../src/debugAdapter/stopModel';
+import { RawTraceBackend } from '../src/debugAdapter/backends/RawTraceBackend';
+import { ResolvedTrace } from '../src/debugAdapter/types';
+
+const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures');
+
+/** Resolve a symbol-rich ResolvedTrace (rawTrace + wasmPath) for a fixture. */
+async function resolveFixture(name: string): Promise {
+ const args = {
+ rawTrace: path.join(FIXTURES, `${name}.trace.jsonl`),
+ wasmPath: path.join(FIXTURES, `${name}.wasm`),
+ };
+ return new RawTraceBackend().resolve(args as any, () => {});
+}
+
+describe('buildStopModel (docs/trace-cli-internal.md, shared headless core)', () => {
+ describe('adder-debug fixture', () => {
+ let resolved: ResolvedTrace;
+
+ before(async () => {
+ resolved = await resolveFixture('adder-debug');
+ });
+
+ it('derives the pinned ground-truth stop model', () => {
+ const model = buildStopModel(resolved);
+ assert.deepStrictEqual(model.rawRunStarts, [6, 29, 40]);
+ assert.deepStrictEqual(model.runStarts, [29]);
+ assert.strictEqual(model.visibleIndices.length, 35);
+ assert.strictEqual(model.firstStopPoint, 29);
+ assert.strictEqual(model.lastStopPoint, 29);
+ assert.strictEqual(model.depths.length, 41);
+ });
+
+ it('maps the validated code offset 45 to trace index 29', () => {
+ const model = buildStopModel(resolved);
+ assert.ok(model.validatedPosToIndices instanceof Map, 'validatedPosToIndices should be a Map');
+ assert.ok(
+ model.validatedPosToIndices.get(45)?.includes(29),
+ 'validatedPosToIndices.get(45) should include index 29',
+ );
+ });
+ });
+
+ describe('stepper-debug fixture', () => {
+ let resolved: ResolvedTrace;
+
+ before(async () => {
+ resolved = await resolveFixture('stepper-debug');
+ });
+
+ it('derives the pinned ground-truth stop model', () => {
+ const model = buildStopModel(resolved);
+ assert.deepStrictEqual(
+ model.rawRunStarts,
+ [5, 21, 27, 29, 39, 44, 46, 56, 61, 63, 73, 84],
+ );
+ assert.deepStrictEqual(model.runStarts, [21, 27, 29, 39, 44, 46, 56, 61, 63, 73]);
+ assert.strictEqual(model.visibleIndices.length, 80);
+ assert.strictEqual(model.firstStopPoint, 21);
+ assert.strictEqual(model.lastStopPoint, 73);
+ assert.strictEqual(model.depths.length, 85);
+ });
+ });
+});
+
+describe('pcAtIndex (docs/trace-cli-internal.md, current-PC rule)', () => {
+ it('returns the validated code offset at the index (adder idx 29 → 45)', async () => {
+ const resolved = await resolveFixture('adder-debug');
+ assert.strictEqual(pcAtIndex(resolved.positions, 29), 45);
+ });
+
+ it('returns null before the first validated record (adder head)', async () => {
+ const resolved = await resolveFixture('adder-debug');
+ // The adder's first validated position is at index 6; indices 0..5 are
+ // unvalidated global-initializer records with no earlier PC.
+ assert.strictEqual(pcAtIndex(resolved.positions, 0), null);
+ assert.strictEqual(pcAtIndex(resolved.positions, 5), null);
+ });
+
+ it('returns the nearest EARLIER validated offset for an unmapped index', () => {
+ // Interior unmapped (null) records inherit the last validated PC seen.
+ const positions = [null, 10, null, null, 20, null];
+ assert.strictEqual(pcAtIndex(positions, 1), 10);
+ assert.strictEqual(pcAtIndex(positions, 2), 10);
+ assert.strictEqual(pcAtIndex(positions, 3), 10);
+ assert.strictEqual(pcAtIndex(positions, 4), 20);
+ assert.strictEqual(pcAtIndex(positions, 5), 20);
+ // No validated record at or before index 0 → null.
+ assert.strictEqual(pcAtIndex(positions, 0), null);
+ });
+});
diff --git a/test/traceCli.test.ts b/test/traceCli.test.ts
new file mode 100644
index 0000000..441ddb1
--- /dev/null
+++ b/test/traceCli.test.ts
@@ -0,0 +1,206 @@
+/**
+ * Unit suite for the pure argv parser behind the one-shot trace CLI
+ * (`soroban-trace`), milestone M3 (CLI devex: --help + argument validation).
+ *
+ * parseTraceArgs(argv): TraceParse from src/trace/cliArgs.ts
+ * TRACE_USAGE: string the help text
+ *
+ * The parser is PURE — it never touches process.argv, never prints, never
+ * exits. It maps a raw argv slice onto a discriminated union describing what
+ * the (coverage-excluded) main.ts shell should do: show help, report a usage
+ * error, or run with a resolved launch + projection options.
+ *
+ * These modules do not export this API yet, so this is the red anchor for it.
+ */
+
+import * as assert from 'assert';
+import { parseTraceArgs, TRACE_USAGE, TraceParse } from '../src/trace/cliArgs';
+
+/** Narrow a TraceParse to the 'run' variant (fails the test otherwise). */
+function asRun(p: TraceParse): Extract {
+ assert.strictEqual(p.kind, 'run', `expected run, got ${p.kind}: ${JSON.stringify(p)}`);
+ return p as Extract;
+}
+
+/** Narrow a TraceParse to the 'error' variant and return its message. */
+function asError(p: TraceParse): string {
+ assert.strictEqual(p.kind, 'error', `expected error, got ${p.kind}: ${JSON.stringify(p)}`);
+ return (p as Extract).message;
+}
+
+/** Narrow a TraceParse to the 'help' variant and return its text. */
+function asHelp(p: TraceParse): string {
+ assert.strictEqual(p.kind, 'help', `expected help, got ${p.kind}: ${JSON.stringify(p)}`);
+ return (p as Extract).text;
+}
+
+describe('parseTraceArgs (M3 CLI devex)', () => {
+ describe('help', () => {
+ it('--help → kind "help" with the usage text', () => {
+ const text = asHelp(parseTraceArgs(['--help']));
+ assert.strictEqual(text, TRACE_USAGE);
+ });
+
+ it('-h → kind "help" with the usage text', () => {
+ const text = asHelp(parseTraceArgs(['-h']));
+ assert.strictEqual(text, TRACE_USAGE);
+ });
+
+ it('--help wins even alongside otherwise-valid flags', () => {
+ const text = asHelp(parseTraceArgs(['--raw-trace', 'run.jsonl', '--help']));
+ assert.strictEqual(text, TRACE_USAGE);
+ });
+
+ it('TRACE_USAGE carries the documented stable substrings', () => {
+ for (const needle of [
+ 'soroban-trace',
+ 'Usage',
+ '--raw-trace',
+ '--wasm',
+ '--contract',
+ '--function',
+ '--allow-no-source',
+ '-h, --help',
+ 'Examples',
+ ]) {
+ assert.ok(TRACE_USAGE.includes(needle), `TRACE_USAGE should contain ${needle}`);
+ }
+ });
+ });
+
+ describe('mode selection', () => {
+ it('[] → error, must specify a mode', () => {
+ const msg = asError(parseTraceArgs([]));
+ assert.ok(msg.includes('Specify --raw-trace'), msg);
+ });
+
+ it('error messages carry the --help hint', () => {
+ const msg = asError(parseTraceArgs([]));
+ assert.ok(msg.includes('--help'), `error should hint at --help: ${msg}`);
+ });
+
+ it('--contract without --function → error, function required in live mode', () => {
+ const msg = asError(parseTraceArgs(['--contract', '.']));
+ assert.ok(msg.includes('--function is required'), msg);
+ });
+ });
+
+ describe('offline mode (--raw-trace)', () => {
+ it('--raw-trace with no value → "Missing value for --raw-trace"', () => {
+ const msg = asError(parseTraceArgs(['--raw-trace']));
+ assert.ok(msg.includes('Missing value for --raw-trace'), msg);
+ });
+
+ it('--raw-trace → run with launch.rawTrace set', () => {
+ const p = asRun(parseTraceArgs(['--raw-trace', 'run.jsonl']));
+ assert.strictEqual(p.launch.rawTrace, 'run.jsonl');
+ });
+
+ it('--raw-trace --wasm → run with launch.wasmPath set', () => {
+ const p = asRun(parseTraceArgs(['--raw-trace', 'r', '--wasm', 'w']));
+ assert.strictEqual(p.launch.rawTrace, 'r');
+ assert.strictEqual(p.launch.wasmPath, 'w');
+ });
+ });
+
+ describe('live mode (--contract/--wasm + --function)', () => {
+ it('--contract . --function add → run with launch.contract and launch.function', () => {
+ const p = asRun(parseTraceArgs(['--contract', '.', '--function', 'add']));
+ assert.strictEqual(p.launch.contract, '.');
+ assert.strictEqual(p.launch.function, 'add');
+ });
+ });
+
+ describe('--args-json', () => {
+ it('malformed JSON → error containing "Invalid --args-json"', () => {
+ const msg = asError(
+ parseTraceArgs(['--contract', '.', '--function', 'a', '--args-json', '[bad']),
+ );
+ assert.ok(msg.includes('Invalid --args-json'), msg);
+ });
+
+ it('valid JSON array → run with launch.args of the right length', () => {
+ const p = asRun(
+ parseTraceArgs([
+ '--contract',
+ '.',
+ '--function',
+ 'a',
+ '--args-json',
+ '[{"value":1,"type":"u32"}]',
+ ]),
+ );
+ assert.ok(p.launch.args, 'launch.args should be present');
+ assert.strictEqual(p.launch.args!.length, 1);
+ });
+
+ it('valid JSON that is not an array (number) → "expected a JSON array"', () => {
+ const msg = asError(
+ parseTraceArgs(['--contract', '.', '--function', 'a', '--args-json', '5']),
+ );
+ assert.ok(msg.includes('expected a JSON array'), msg);
+ });
+
+ it('valid JSON that is not an array (object) → "expected a JSON array"', () => {
+ const msg = asError(
+ parseTraceArgs(['--contract', '.', '--function', 'a', '--args-json', '{}']),
+ );
+ assert.ok(msg.includes('expected a JSON array'), msg);
+ });
+ });
+
+ describe('numeric options', () => {
+ it('--depth x (non-integer) → error mentioning --depth', () => {
+ const msg = asError(parseTraceArgs(['--raw-trace', 'r', '--depth', 'x']));
+ assert.ok(msg.includes('--depth'), msg);
+ });
+
+ it('--max-children -1 (leading dash) → missing-value branch, "Missing value for --max-children"', () => {
+ // `-1` looks like a flag, so it trips the missing-value guard before the
+ // numeric check ever runs.
+ const msg = asError(parseTraceArgs(['--raw-trace', 'r', '--max-children', '-1']));
+ assert.ok(msg.includes('Missing value for --max-children'), msg);
+ });
+
+ it('--max-children 2.5 (non-integer value) → "must be a non-negative integer"', () => {
+ // A non-dash, non-numeric value reaches isNonNegInt and fails there.
+ const msg = asError(parseTraceArgs(['--raw-trace', 'r', '--max-children', '2.5']));
+ assert.ok(msg.includes('--max-children'), msg);
+ assert.ok(msg.includes('non-negative integer'), msg);
+ });
+
+ it('--depth 2 → run with opts.maxDepth === 2', () => {
+ const p = asRun(parseTraceArgs(['--raw-trace', 'r', '--depth', '2']));
+ assert.strictEqual(p.opts.maxDepth, 2);
+ });
+
+ it('--depth 0 (boundary) → valid run with opts.maxDepth === 0', () => {
+ const p = asRun(parseTraceArgs(['--raw-trace', 'r', '--depth', '0']));
+ assert.strictEqual(p.opts.maxDepth, 0);
+ });
+ });
+
+ describe('token validation', () => {
+ it('unknown flag → "Unknown option: --frobnicate"', () => {
+ const msg = asError(parseTraceArgs(['--frobnicate']));
+ assert.ok(msg.includes('Unknown option: --frobnicate'), msg);
+ });
+
+ it('stray positional → "Unexpected argument: stray"', () => {
+ const msg = asError(parseTraceArgs(['--raw-trace', 'r', 'stray']));
+ assert.ok(msg.includes('Unexpected argument: stray'), msg);
+ });
+ });
+
+ describe('boolean & passthrough options', () => {
+ it('--allow-no-source → run with opts.allowNoSource === true', () => {
+ const p = asRun(parseTraceArgs(['--raw-trace', 'r', '--allow-no-source']));
+ assert.strictEqual(p.opts.allowNoSource, true);
+ });
+
+ it('--out o.jsonl → run with out === "o.jsonl"', () => {
+ const p = asRun(parseTraceArgs(['--raw-trace', 'r', '--out', 'o.jsonl']));
+ assert.strictEqual(p.out, 'o.jsonl');
+ });
+ });
+});