Project Test Cases and Runtime Failure Report v1
September 10, 2026 · View on GitHub
Status: implemented for the in-process project runner (semaprax run and
semaprax test over a semaprax.toml project). The gate is
tests/project.rs::developer_loop together with the unit tests in
src/project/execution/tests.rs. The completion matrix owns product status.
Audience: coding agents and people running project tests, tool authors
consuming the semaprax.project-execution.v1 envelope, and compiler
contributors.
This reference owns two additive behaviors of the reference interpreter's
project runner: named test cases inside the manifest-declared test module, and
the contract-failure detail that accompanies a language failure. It extends
the run/test contract in Project Manifest v1
without changing the envelope schema string, the normalized status object,
the entry closure, or the test module's main.
Named test cases
A test module is a module listed under tests in semaprax.toml. Its main
is the test closure exactly as before: semaprax test evaluates it and it
passes only by returning 0.
In addition, every function of that module that satisfies all of the following is a case and is executed on its own:
- its display name starts with
test_(TEST_CASE_PREFIX); - it takes no parameters and returns
i64; - it carries an explicit
@id.
A test_-prefixed function of any other shape, such as fn test_helper(value: i64) -> i64, is an ordinary function and is not a case. A function of another
module is never a case, whatever its name. Cases are ordered by stable
identity, which is the order of the linked program's function index.
Each case runs after main, on its own fixed 64 MiB stack, with the whole
--max-steps budget; the envelope's top-level fuel still describes main
alone. Every case is admitted under the same interpreter profile and closure
scan as main; a case whose closure is outside the profile fails the command
with the same selection diagnostics main would produce.
The command passes when main and every case return 0
(ProjectExecution::command_succeeded).
module calculator.tests;
@id("calculator.tests.main")
fn main() -> i64
{
0
}
@id("calculator.tests.test_add")
fn test_add() -> i64
{
if 19 + 23 == 42 { 0 } else { 1 }
}
Human report
semaprax test prints exactly one of:
project tests passedwhenmainreturns0and there are no cases;project tests passed (N named cases)whenmainand allNcases pass;- otherwise, on stderr with exit status
1, onefailed <stable-id>: <outcome>line formainif it failed and then for each failing case, a summaryproject tests failed: <failed> of <total> in <test-module>wheretotalcountsmainplus every case, and ahelp:line.
<outcome> is returned <value>, language status <status-json>, step budget exhausted, or call-depth bound exceeded. When the outcome is a
contract failure with retained detail, two indented lines follow the failed
line (see below). The help line reads a test passes by returning 0; a nonzero return is the failing check's code or count and, when the module has no
cases, continues , so give each check its own `fn test_<name>() -> i64` in the test module to have it reported by name.
semaprax run keeps its lines project execution failed with language status <status-json>, project execution exhausted its step budget, and project execution exceeded its call-depth bound; the contract-failure lines follow the
first of them when detail is retained.
Contract-failure detail
When evaluation ends in a violated requires or ensures clause, the
interpreter records at the failing frame:
- the stable identity of the function whose clause failed;
- the phase,
requiresorensures; - the clause text: the clause expression's span in the declaring file's retained source, trimmed; when that slice is unavailable, spans several lines, or exceeds 4096 bytes, the clause's revision-scoped expression identity is reported instead;
- the call's parameters in declaration order, each with its name, its
name-independent type key (
i64,bool,u8, ...), and its value. Scalars render as source literals with their width suffix (-7,3i32,255u8,true,'a'); owned or borrowed data renders as its kind and length (<string 3 bytes>), never its bytes.
The detail is data for reports. It never changes the normalized status, which
remains the semaprax.status.v1 object every backend agrees on, nor cleanup,
result publication, or exit status.
The human form is two lines indented by two spaces:
contract: requires right != 0 in calculator.divide
arguments: left = 1, right = 0
arguments: none names a zero-parameter function.
Envelope additions
The semaprax.project-execution.v1 envelope is unchanged for the entry role
except inside a language_failure outcome. Both additions are covered by the
payload digest and by project::verify_execution_envelope.
A language_failure outcome may carry a failure member after status:
{"kind":"language_failure","status":{...},"failure":{"function":"calculator.divide","phase":"requires","clause":"right != 0","arguments":[{"name":"left","type":"i64","value":"1"},{"name":"right","type":"i64","value":"0"}]}}
Verification requires failure to have exactly those keys, a phase equal to
the status's contract code, every text field nonempty and at most 4096 bytes,
and each argument to have exactly name, type, and value.
A test-role envelope always carries cases after outcome, an array that is
empty when the module declares no case:
"cases":[{"stable_id":"calculator.tests.test_add","name":"test_add","fuel":{"steps_used":13,"max_steps":1000000},"outcome":{"kind":"returned","type":"i64","value":"0"}}]
Each element has exactly stable_id, name, fuel, and outcome; name
starts with test_; fuel.max_steps equals the envelope's limits.max_steps
and steps_used does not exceed it; outcome uses the same closed vocabulary
as the top-level outcome, including failure. An entry-role envelope must not
carry cases; a test-role envelope must. The envelope's key order, nonclaims
list, and digest domain are unchanged, so a consumer that reads fields by name
keeps working, while one that pins whole test-envelope bytes observes the new
cases member.
Nonclaims
- No filesystem discovery. The envelope's
no_test_discoverynonclaim holds: cases are selected by name inside the linked program of the manifest-declared test module; no file, directory, or module outsidesourcesandtestsis consulted. - No isolation between cases beyond a fresh evaluator and step budget; cases share nothing at runtime because the language has no mutable globals.
- Arithmetic failures (division by zero, overflow) carry no frame detail; only contract clauses do.
- The native path (
semaprax run file.spx --native,build --target native) reports contract failures with the same repair facts as the interpreter: canonical clause text, persistent function identity, and parameters in declaration order with their observed values. Its normalized status and exit 70 contract are unchanged. - The prepared daemon interpreter and its Source Trace
(Prepared Project Interpreter v1)
retain the detail internally but render neither
failurenorcases; their wire bytes are unchanged. - A
test_function of a non-admitted shape is not a case. The human report ofsemaprax testprints one stderr line per such function,note: `<name>` is not a test case: <rule>; …, naming the first rule it misses (parameters, a non-i64result, or a missing explicit@id); the JSON envelope is unchanged and no compiler diagnostic is emitted.