Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@ Commands:
- [minimize](#minimize)
- [diff](#diff)
- [export](#export)
- [score](#score)
- [run](#run)

Exit code conventions:

- `0` success (or a replayed failure reproduced intentionally).
- `1` the run behaved differently, or the operation could not complete safely.
- `2` invalid, unreadable, or unresolvable input.
- `2` invalid, unreadable, or unresolvable input, or (for `run`) a check that
could not execute.
- `3` `validate --verify-integrity` on a legacy bundle without integrity
metadata.

Expand Down Expand Up @@ -520,6 +523,127 @@ Common errors:
- `no expectation to score` -- the test asserts the recorded run without an
`--expect-output`, so there is no claim for a mutant to separate from it.

## run

Run an exported regression test and write the receipt yourself.

```text
Usage: stepfork run [OPTIONS] TEST
```

`stepfork export` writes a pytest file; this command runs it and classifies the
outcome. The classification is the point: a check that **could not execute** --
the bundle it names is not there, a fixture cannot be built, nothing was
collected -- is reported as `could_not_execute`, which is neither a pass nor a
caught regression.

pytest's exit status does not carry that distinction. Measured on pytest 9.1.1
against the file `stepfork export` writes:

| Case | pytest exit | JUnit kind |
|---|---|---|
| the agent's behaviour changed | 1 | `failure` |
| the trace bundle is missing | 1 | `failure` |
| a dependency is absent | 1 | `failure` |
| a fixture cannot be built | 1 | `error` |
| collection failed | 2 | `error` |
| no tests were collected | 5 | (none) |

The first two rows are identical, so a pipeline that keys on either one reports
a missing artifact as a caught regression. `run` resolves the bundle **before**
launching pytest, so that row becomes `could_not_execute` and no failure is
manufactured for it.

The receipt is written by the runner after the child process exits -- never by
the test -- and it names the three inputs the verdict rests on: the raw exit
status, a digest of the bundle, and a hash of the test file.

Arguments:

| Argument | Description |
|---|---|
| `TEST` | The pytest file `stepfork export` wrote. Required. |

Options:

| Option | Description |
|---|---|
| `--bundle <path>` | Name the `.sftrace` bundle instead of reading `TRACE_PATH` from the test. |
| `--receipt <path>` | Write a JSON receipt of what the runner observed. |
| `--json` | Print the receipt to stdout. |
| `--pytest-arg <str>` | Extra argument for the pytest subprocess. Repeat per argument. |
| `--timeout-seconds <float>` | Stop the check after this long and report `could_not_execute`. |
| `--help` | Show command help and exit. |

A passing run:

```bash
stepfork run test_notify_regression.py --receipt receipts/notify.json
```

```text
Test test_notify_regression.py
Test sha256 129694b27992161b
Bundle notify.sftrace
Bundle digest 7f3eaeed6a9820ce
pytest exit 0
Collected 1 test(s), 0 failure(s), 0 error(s), 0 skipped

Verdict: PASSED
Receipt: receipts/notify.json
```

The same test with its bundle removed says what happened instead of reporting a
regression:

```text
Verdict: COULD NOT EXECUTE
Reason: bundle_missing
the bundle this test names is not there: notify.sftrace; the check cannot run,
so this is not a regression
The check did not execute, so this run says nothing about the agent's behaviour.
```

The receipt:

```json
{
"receipt_version": 1,
"verdict": "could_not_execute",
"reason": "bundle_missing",
"exit_status": null,
"bundle": "notify.sftrace",
"bundle_digest": null,
"test": "test_notify_regression.py",
"test_sha256": "129694b2...",
"counts": {"tests": 0, "failures": 0, "errors": 0, "skipped": 0},
"command": [],
"duration_ms": 0,
"stepfork_version": "0.2.0",
"created_at": "2026-10-18T16:53:50Z"
}
```

Exit codes:

| Code | Meaning |
|---|---|
| `0` | `passed`: the check ran and agreed. |
| `1` | `failed`: the check ran and disagreed -- a caught regression. |
| `2` | `could_not_execute`: the check never ran, or could not be built. |

The `reason` field names which: `bundle_missing`, `bundle_unreadable`,
`check_error` (a fixture or collection error),
`no_tests_collected`, `pytest_unavailable`, `pytest_error`, `report_missing`.

**What `run` does not catch.** The runner pre-checks the artifacts it can name
from the test's literals -- the bundle. It does not pre-check what the test body
*imports*: an entrypoint the local tree cannot resolve, or a dependency
imported inside the test, raises inside the test body, and pytest reports an
exception raised there as `failure`, exactly as it reports a changed return
value. Those still read as `failed`. Resolving `ENTRYPOINT` before the launch,
the way the bundle is resolved, is the follow-up that would move them too.

---

## Error handling
Expand Down
2 changes: 2 additions & 0 deletions src/stepfork/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from stepfork.cli.inspect import inspect_command
from stepfork.cli.minimize import minimize_command
from stepfork.cli.replay import replay_command
from stepfork.cli.run import run_command
from stepfork.cli.score import score_command
from stepfork.cli.validate import validate_command

Expand All @@ -29,6 +30,7 @@
app.command("inspect")(inspect_command)
app.command("minimize")(minimize_command)
app.command("replay")(replay_command)
app.command("run")(run_command)
app.command("score")(score_command)
app.command("validate")(validate_command)

Expand Down
141 changes: 141 additions & 0 deletions src/stepfork/cli/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""`stepfork run` command."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Annotated

import typer
from rich.console import Console
from rich.table import Table

from stepfork.export.receipt import (
EXIT_BY_VERDICT,
VERDICT_COULD_NOT_EXECUTE,
VERDICT_FAILED,
VERDICT_PASSED,
Receipt,
run_exported_test,
write_receipt,
)

console = Console()

STYLE = {
VERDICT_PASSED: "[green]PASSED[/green]",
VERDICT_FAILED: "[red]FAILED[/red]",
VERDICT_COULD_NOT_EXECUTE: "[yellow]COULD NOT EXECUTE[/yellow]",
}


def run_command(
test: Annotated[
Path,
typer.Argument(
help="Path to the pytest file `stepfork export` wrote.",
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
),
],
bundle: Annotated[
Path | None,
typer.Option(
"--bundle",
help="Name the .sftrace bundle instead of reading it from the test.",
),
] = None,
receipt: Annotated[
Path | None,
typer.Option(
"--receipt",
help="Write a JSON receipt of what the runner observed.",
),
] = None,
json_output: Annotated[
bool,
typer.Option("--json", help="Print the receipt to stdout as JSON."),
] = False,
pytest_arg: Annotated[
list[str] | None,
typer.Option(
"--pytest-arg",
help="Extra argument for the pytest subprocess; repeat per argument.",
),
] = None,
timeout_seconds: Annotated[
float | None,
typer.Option(
"--timeout-seconds",
help="Stop the check after this long and report could_not_execute.",
),
] = None,
) -> None:
"""Run an exported regression test and write the receipt yourself.

`stepfork export` writes a test; this command runs it. The difference from
running pytest directly is the verdict: a check that could not execute --
the bundle is missing, a fixture cannot be built, nothing was collected --
is reported as `could_not_execute`, which is neither a pass nor a caught
regression. pytest's exit status does not carry that distinction: measured
on pytest 9.1.1 a missing bundle and a changed return value both exit 1 and
both land in JUnit as `failure`.

The receipt is written by this process after the child exits, never by the
test, and it names the bundle digest and the test hash the verdict rests
on.
"""
found: Receipt = run_exported_test(
test,
bundle=bundle,
pytest_args=pytest_arg,
timeout_seconds=timeout_seconds,
)

if json_output:
console.print_json(json.dumps(found.to_json(), sort_keys=True))
else:
_report(found)

if receipt is not None:
write_receipt(found, receipt)
if not json_output:
console.print(f"Receipt: {receipt}")

raise typer.Exit(EXIT_BY_VERDICT[found.verdict])


def _report(found: Receipt) -> None:
table = Table.grid(padding=(0, 4))
table.add_column(style="bold")
table.add_column()
table.add_row("Test", found.test)
table.add_row("Test sha256", found.test_sha256[:16] or "(unreadable)")
table.add_row("Bundle", found.bundle or "(none)")
table.add_row(
"Bundle digest",
(found.bundle_digest or "(none)")[:16],
)
if found.exit_status is not None:
table.add_row("pytest exit", str(found.exit_status))
counts = found.counts
table.add_row(
"Collected",
f"{counts.tests} test(s), {counts.failures} failure(s), "
f"{counts.errors} error(s), {counts.skipped} skipped",
)
console.print(table)
console.print()
console.print(f"Verdict: {STYLE[found.verdict]}")
if found.reason is not None:
console.print(f"Reason: {found.reason}")
if found.detail:
console.print(f"{found.detail}")
if found.verdict == VERDICT_COULD_NOT_EXECUTE:
console.print(
"The check did not execute, so this run says nothing about the "
"agent's behaviour."
)
console.print()
Loading
Loading