Repository navigation
proef v0.16.0
Supersedes 0.15.0, which was cut (
release: v0.15.0, 2026-08-25) but never
tagged or published — its changes are all here, and crates.io goes 0.14.0 →
0.16.0 with nothing skipped.
Fixed
-
The record-size ceiling reached two of its four readers. 0.13.0 bounded
the run-record read at 256 MiB because records travel —diffreads a
downloaded baseline,flakyreads every retained run — and the read, the
line split and the parsedVec<Event>are resident at once, so a corrupt or
hostile file was an OOM rather than an error. The bound lives in
record::read_events, andexplainandreporteach opened
events.jsonlwith a bareread_to_stringinstead, so neither had it.
reporteven used the guarded reader for the base record two dozen lines
below the raw read of the primary one.Both now go through
read_events, which returns the parsed events — exactly
the read-once/parse-once its own comment asked for. A source-scanning test
makes the next reader go through the same door, the shape this project
already uses for the raw-print and malformed-plural rules: a guard added in
one place and left for the next call site to rediscover is how it went
missing the first time.
Added
-
explain,diffanddoctorspeak--format json. They were the three
commands with no machine output, and the three a consumer reaches for
after a run. A run directory isartifacts/ + events.jsonl + run.logand
carries no structured summary, so anything analysing a run it did not launch
— a CI job reading another job's artifact, a script, an agent — had to fold
events.jsonlitself. That is the fold proef's own two internal copies
disagreed on three ways beforereport::suite_totalsunified them; handing
the canonical answer over is cheaper than inviting everyone to re-derive the
one proef got wrong.Each object mirrors its prose field for field rather than modelling a richer
view — the prose is the contract a reader already knows, and a machine
surface that says something different is a second answer to one question.
diff'sflaky/slowerstay the rendered sentences for the same reason.
The flag is the existing single-variantjsonenum the listing commands
already use, renamed fromListFormattoJsonFormatnow that it serves
non-listing commands too. Machine mode owns stdout: notes whose content the
object already carries are suppressed rather than repeated on stderr.doctorneeded a real change to get there — it printed each check as it ran,
so the verdict was the only thing a caller could see. Checks are collected
before rendering now, which makes the JSON a second rendering rather than a
second walk: the failure mode where one surface gains a check the other never
learns about. -
--console failed— thefullBDD tree, but only for scenarios that
failed or warned. A clean run prints the run line and the summary; a dirty
one prints exactly whatfullwould. The gap it fills is the CI one:
fullis a wall of green on a large suite,dotteddrops the detail you
need when something breaks, andquietdrops everything.Warned scenarios are shown, which the name does not say and the code
explains: a warned scenario is one whoseoptional:step failed,
RunSummary::passedcounts it with the passes, and the summary line has no
warned column — so a mode that showed onlyFailedwould let a run in which
something did fail print exactly what a spotless one prints. A fourth
value on the existing flag rather than a new one.
Fixed
-
cargo denyfailed on a yanked transitive crate.rand 0.10.2resolved
chacha20 0.10.1, which was yanked from crates.io; the lock now takes
0.10.2. Not the secret store's copy —chacha20poly1305pins0.9.1,
which is unaffected — so nothing about encryption changed. Found by the
gate, which is what it is for. -
proef report -owrote the author's home directory into the file built to
be shared. With the report inside the run dir the artifact links are a
bareartifacts/…; with-opointing anywhere else they were made
absolute, which resolves only on the machine that produced them — and-o
exists to put the report somewhere it will be published, which is exactly
where that path is dead. 0.13.0 scrubbed machine identity out of the run
record (R12-1); this put it back, twelve times over, in the HTML uploaded
beside it. The href is now relative to the report, which resolves everywhere
the absolute one did plus wherever report and artifacts travel together,
and in the CI shape (-o public/report.html) names nothing outside the
workspace. The href is built from path components joined with/, not
fromPath::display— Windows renders\, which is not a separator in a
URL, so a Windows-generated report's links would have been dead either way
(the absolute path it replaces had the same flaw). A report written somewhere
sharing no ancestor with the run dir
still names the directories between them — that is what a correct relative
path from there is, and it is no worse than what it replaces. -
The report's
--skipcolour failed WCAG AA, and every status pill failed
it in dark mode.--skipwas the one palette token the dark block did not
redefine: a grey chosen against#0d1117(5.48:1 there) left carrying white
text on white at 3.45:1, against a 4.5:1 threshold — on the status a
reader scans for after an interrupted run. It is now#59636e(6.11:1).Writing the guard rather than the fix found a second defect nobody had
measured:.pillpaintedcolor:#fffon the status colour, and the dark
palette's colours are tuned as text on a dark ground, so all four dark
pills sat between 2.52:1 and 3.45:1. The pill foreground is now a palette
token — white on light, the page ground on dark — putting all four between
5.48:1 and 7.5:1. A test asserts the ratio rather than the hex, so a future
palette change is free to move a colour and not free to move it below AA,
and a second test pins that both palettes define the same token set (the
absence that caused this). -
The HTML report had one heading and no outline. The timeline carried an
<h2>; the tag table and the scenario list — the body of the page — had
none, so there was nothing to navigate by and no anchor to link a section
with. Both gained one, sharing the class the timeline already used (renamed
from.timeline-hto.section-h, since it now serves three). Pinned
structurally, so a section added without a heading fails the test. -
A step's
name:label reached the artifact and nothing else. A macro
with more than one step turns one feature sentence into several engine
steps, and they share aStepRefexactly — same file, same line, same
text. The emitter has always written the authoredname:into the
artifact's entry comment, which is why the.hurlcould tell them apart;
StepRefnever carried it, so the console, the HTML report,JUnit, TAP,
the job summary andexplainall printed the same sentence once per step,
with nothing but the status glyph to distinguish a warning from the failure
beside it. The reference corpus demonstrated it: threestep_finished
events for the cookie session is exercised, byte-identical in the pinned
snapshot, are nowobtain the session cookie,optional probe (forces a split)andcookie survives the split.StepOutcomeandstep_finishednow carrylabel, exactly as they carry
fragment— the two answer neighbouring questions (which file did this
request come from / which step of the sentence is this) and travel the
same channels. Oneproef_core::report::step_labelrenders it for every
sink, so the six cannot drift. Additive on the wire: absent when a step has
noname:, so every pre-existing record still parses and re-renders
unchanged, and the event schema stays1.This retires two claims that were not true when written:
AUTHORING.md's "they anchor artifacts, events, and failure output" and
LoweredStep::label's own "(events/console)". Same class as
reproduce_hintin the R18 wave — computed all along, printed all along,
dropped by the record. -
A fragment's text ran on into the comments introducing the entry below it.
hurl attaches the blank and comment lines above a request to that request,
which is exactly what makes the# @proefbinding reliable — but it also
means an entry has two different starts: where its lines begin and where its
request begins. The scanner used one value for both, ending each fragment at
the next entry's request line, so every comment a corpus author wrote to
introduce the next request was copied into the previous fragment and from
there into the emitted.hurl. An artifact could carry
# Destructive. Operators only.while containing no destructive request at
all, andtrim_endcould not help — a comment is not whitespace. The same
applied at the end of a file, where a trailing note became part of the last
fragment. A fragment now runs from its annotation to the end of its own
request and response; the gap between two entries documents the one below it
and belongs to neither. Nothing executed differently, because hurl permits
only comments and blanks between entries — which is why it survived: the only
damage was to what the durable record says a request is.The property covering this asserted one request line per fragment, which is
blind to comments; it now also asserts that no fragment holds any of the
generator's inter-entry filler. -
explainand the HTML report disagreed about a truncated run's totals.
A record with no tailrun_finished— a run killed mid-flight — is
reconstructed by counting, and each surface carried its own version of that
fallback. On the same bytes they differed three ways: the report dropped
Warnedscenarios from every column, counted[run] setup/teardown
scenarios into a headline its own page labels "excluded from totals above",
and read a pre-0.6.0 record's per-phase totals as the suite verdict where
explaincorrectly declined to. Oneproef_core::report::suite_totalsnow
holds the rule — prefer the tail event unless it cannot be trusted, else
count suite scenarios withWarnedriding along withPassed, exactly as
the live path reports — and both surfaces call it.Also un-splices three doc comments in
html.rsthat an earlier change had
merged into one, leavingrender_tag_tableandrender_timelineundocumented
andrender_provenance_and_summarycarrying all three. -
A parse error pointing at a non-ASCII character produced a span that split
the codepoint. gherkin reports a char-counted column, so the span's start
was correct; its end added one byte to that, landing inside a multi-byte
character whenever the error pointed at one — a span that is not a valid
slice of its own source. Nothing crashed, which is how it survived: miette
tolerated it and drew the caret slightly to the left, and the LSP's converter
snaps to a boundary defensively, so every consumer defended itself instead of
the producer being right. Found by the newfuzz_feature_parsetarget within
a minute of first running. -
A long
--tagsexpression aborted the process instead of failing.
and/orchains parse iteratively, and the module said so as though that
settled it — but an iterative parse still builds a left-leaning tree as deep
as the chain is long, and bothevaland the derivedDropwalk that tree
recursively. A--tagsexpression of roughly twenty thousandand-joined
atoms therefore overflowed the stack and died on SIGABRT: a signal, not one
of the four exit codes ADR-0009 promises, and well within what a command line
accepts. Expressions are now capped at 512 tokens, which bounds the tree and
so bounds both walks, and past the cap you get a message naming the limit.
(The test that was meant to cover this built 5 000 atoms and asserted
success — one order of magnitude below the cliff.)
Changed
-
Secret redaction no longer runs inside the reporter mutex. The sink
masked each event while holding the lock that fans it out to the reporters,
so every scenario thread queued behind work none of them share — and masking
is the expensive half, a scan per text field per needle with roughly nine
needles derived per secret. It reads the event and the needle set and writes
neither, so it never needed the lock; the critical section now covers only
the fan-out it exists for.Order is unaffected and the tests say why: a scenario is one thread, so its
own events still reach the lock in the order it emitted them, and order
across scenarios was never guaranteed. A new test emits from eight threads
at once and asserts nothing is lost or doubled, everything arrives redacted,
and each emitter's own events keep their order. No timing assertion — the
flake rule forbids one, and the change is justified structurally rather than
by a stopwatch. -
Pack validation is linear in the macro count, not quadratic. Every
span locator scanned the whole pack file to find its macro's block, so
validating N macros scanned the file N times. A single indexing pass
(locate::MacroIndex) records each macro's name span and block region, and
the locators became lookups into it. Measured on a release build over
generated packs: 3200 macros went from 1.96 s to 0.03 s (~65×), and the
curve changed shape — 4× per doubling before, ~2× after — so 6400 macros now
cost 0.06 s where the old scaling predicts ~8 s.It also fixes an inconsistency the split readers hid:
macro_spanaccepted a
quoted"macro name":header while the region scan behind every other
locator accepted only the bare form, so a quoted macro got a caret on its
name and silently no span for itsmatch:,use:,ref:or payload lines.
One reader now gives one answer.
Added
-
proef flaky --by <key>splits flakiness by run context.--by env, or
any[meta]/--metakey (--by runner), folds the history per context
instead of pooling it. A scenario that flaps in one environment and is solid
in another is not flaky but context-dependent — the fix is in the
environment, not the test — and a merged history cannot reach that
conclusion, because pooled failures and passes look exactly like one flapping
test. The command names the scenarios whose verdict changes with where they
ran, which is the finding the flag exists for. A run that never set the key
becomes its own(unset)bucket rather than being folded in with runs that
did; the context also rides in--format json. Reads theenv/metadata
provenance the record has carried since ADR-0020 — no new recorded field. -
proef schema configpublishes theproef.tomlJSON Schema. TOML
language servers (Taplo, tombi) validate against JSON Schema, so one file
buys completion, hover documentation and typo detection in the config —
before a run rather than after one. Generated from the same Rust model that
parses the file, so it describes keys as they are written (runs-dir, not
runs_dir) and inheritsdeny_unknown_fields, making an editor refuse
exactly what proef refuses.proef schemakeeps printing the pack schema, so
one command answers "what may I write in this file?" for both authored
formats rather than two verbs answering it once each. -
An assertion that fails on values looking identical now says why. When
the actual and expected values differ solely in whitespace, the failure
carries a note repeating both with every whitespace character drawn —·for
a space,\t/\r/\nfor the usual escapes,\u{a0}for the exotic ones.
hurl's own message was already correct; the defect was simply invisible, so a
trailing space, a CRLF fixture leaking\r, or a non-breaking space pasted
out of a browser read as "the tool is wrong". Taken from hurl's structured
actual/expectedrather than parsed back out of its prose, and emitted per
error so it sits beside the values it explains; silent whenever the
difference is already visible. -
proef flakyaudits quarantine, which nothing else could. A
@quarantinescenario's failures gate nothing by design, so no exit code, no
summary and no CI job reports them — which makes the tag's own failure mode
invisible: a quarantined scenario failing every run has been switched off
and left in the suite. It now readsDISABLEDrather than sharing the
brokenverdict with untagged always-failures, which wrongly implies someone
is watching. The opposite case gets its own verdict too: green throughout the
window isrecovered, a tag that outlived its problem and is now suppressing
the next real regression. Both print what to do, and--format jsoncarries
quarantinedplus the verdict key so a scheduled job can gate on either.This needed the record reader to stop dropping data it was already given:
scenario_finishedhas carriedtagssince 0.15.0, butScenarioRunnever
parsed them, leaving every record consumer tag-blind. -
Document symbols and hover. A feature outlines to its scenarios (with
their tags), a pack to its macros (with the pattern each matches) — the
vocabulary chosen by what discovery found in the file, never by its
extension. Hover answers the question go-to-definition charges a round trip
for: what a step binds, what ause:targets, what aref:resolves to and
which of its variables still need abind:. Every fact is read from the same
analysis the diagnostics come from, so a hover cannot contradict the squiggle
on its own line.SuiteAnalysisgains ascenariosindex, taken from the
parse rather than from binding — an outline that hid exactly the scenarios you
are debugging would be worse than no outline. -
A panic no longer ends the editor session silently. Only the recompute was
guarded, so a panic inside completion, definition or references escaped the
message loop and killed the server — leaving an editor that shows nothing,
which reads as "proef has no opinion here" rather than as a failure. Both
entry points (a request, the debounced recompute) now wrap everything they do,
the request is answered withInternalErrorrather than dropped, and the
user is told once per suite state throughwindow/showMessage— the channel
an editor surfaces, unlike the stderr line that was the only report before.
The next edit clears the report, because whether the new state also fails is
news. -
The editor can apply a "did you mean", not just print it. Every
misspelled-name diagnostic that already suggested a nearest spelling now
carries the structured half of that suggestion — a span and a replacement —
andproef lspserves it as aquickfixcode action:use:andref:
targets,with:andbind:keys, step kinds, Examples placeholders, and
data-table columns. The suggestion is computed once and rendered twice
(prose for a reader, an edit for an editor), so the message and the fix can
never disagree.A fix is attached only when the edit is certain: the suggested name is near
enough, and the misspelling occurs exactly once, as a whole token, in the
diagnostic's own file. Each of those failing means no fix rather than an
approximate one — notably, a lowering error anchors on the feature step that
invoked a macro while the typo lives in the pack, so it finds nothing to
replace and offers nothing rather than editing the healthy file. The action
is reachable from either the diagnostic or the token, because the two are
regularly lines apart: ause:error carets the macro's name key.
Fixed
- EDITORS.md no longer under-promises on built-in macros. It said the
expect*family has "no jump target and no hover"; the first half is true and
structural (their pack is compiled into the binary, so there is no file to
open), the second is not — a built-in is in the analysis like any other macro,
so hover answers with its pattern and params and names the pack asbuiltin:…,
which is exactly why the jump is unavailable. Pinned by a test, since the
page now claims it.
Changed
-
The editor stops re-analysing the suite on every keystroke. Completion,
go-to-definition and find-references each ran the whole pipeline from
scratch — read every pack and feature off the provider, parse, bind, lower —
and threw the result away; between two keystrokes none of those inputs have
changed, so the second run could only reproduce the first one's answer. The
server now holds the analysis and drops it exactly where an edit lands (the
same notification path that already marks the suite dirty), so one recompute
serves the debounced diagnostics publish and every request until the next
edit. Measured on the two-file test suite: 10 provider reads per request
before, none between edits after — pinned by a read-counting provider rather
than by timing, per the flake rule. -
The LSP's type layer moved to the maintained generator:
lsp-types
0.97 (unmaintained since; the crate that shipped its ownfluent-uri
Urinewtype) is replaced bygen-lsp-types0.11 under the same
lsp_types::name — rust-analyzer's own aliasing pattern, so every
usepath is unchanged. Itsurlfeature aliasesUritourl::Url,
which the embedded hurl engine already pulls in, so the swap adds no
new crate and drops three (lsp-types,fluent-uri,serde_repr).
Url::from_file_path/to_file_pathare the native-path bridge
documents.rshad to hand-roll under 0.97 — drive letters, segment
joining, percent-encoding, ~90 lines — so the bridge is now a wrapper
that only pins the source-name identity rule. Behaviour visible to an
editor is unchanged; the one difference is what counts as a malformed
URI (urlpercent-encodes a raw space wherefluent-urirejected it),
and request dispatch now compares a method enum rather than strings,
so an unknown method lands inCustominstead of matching nothing.Breaking (library):
proef_core::report::percent_encodeis private.
It was public solely soproef-lspcould encode URI path segments
against the identical unreserved set; that hand-rolled encoder is gone,
and redaction needles — its only remaining caller — live in the same
module.
Added
-
README answers the comparison a prospect actually runs: a "When
something else fits better" section maps rawhurl(the exit stays open
in both directions), Karate (choose it for embedded JS and whole-body
fuzzy matching — the two mechanisms proef deliberately refuses; choose
proef for one binary, deterministic reproduction, and files that run
with no framework at all), and Postman/Bruno-class clients. The
quick-start also points atproef initas the start that demonstrates
theref:body form —tests/features/is deliberately fragment-free
(the reference corpus is config-independent by design, and
[run] fragmentsis a config key; the runnableref:demo lives in
the scaffold, pinned green against the fixture). -
The docs site can get a visitor to a binary, and CI to a green
workflow. New Installing page — install lived only in
the repo README, outside the published site's source, so the site's
first step sent visitors back to GitHub — and a new CI page
with the paste-ready workflow the docs never had (zeroruns-onblocks
existed anywhere): install, secrets viaPROEF_SECRET_*,--junit auto, a--shardmatrix,--metaprovenance, thediff --fail-on-regressionbaseline gate,--reruncontinuation, and
flakyover retained records. Nav reordered visitor-first (Installing →
Getting started → Writing scenarios). -
AUTHORING gains the three recipes every real suite needs:
login-then-use-the-token (the docs' most-asked absent question — zero
"login" hits existed), waiting for an eventually-consistent result
(finiteretry:as the polling primitive, and why it must be finite),
and test-data seeding/cleanup across its three scopes (Background:,
[run] setup/teardown,saveAs: global).
Fixed
-
The tutorial's
ref:invitation no longer self-destructs. §3.6
showed a second[run]table that, pasted beside §3.5's, was a TOML
duplicate-table error naming a directory the tutorial's layout doesn't
have; the fragments key now lives (commented) in §3.5's one config
block. "A suite is two things" undercounted its own mandatory
proef.toml— it says three files now, and the tree shows all three.
TROUBLESHOOTING stops listing hurl's[Options] repeat:as if it were
a proef step key. -
The
proef initscaffold goes green against the dev fixture. The
advertised fastest path (init→ fixture →test) ended 1 pass /
2 fail: the scaffold calls/searchand/version, and the fixture
served neither — a red first run that read as a broken tool. Both routes
exist now, the whole path is pinned by an integration test, and the
scaffold'sref:fragment thereby executes against a live endpoint —
the body form's first runnable demonstration.
Added
-
Every release archive ships a
.sha256sidecar (basename inside, so
sha256sum -cworks from a download directory). Attestation covers the
provenance story forghusers; the sidecar covers everyone who
installs withcurl— the half that was missing against the
ripgrep/uv/starship baseline. -
A broken
proef.tomlis a located diagnostic, not a bare sentence.
The file is edited as often as any pack, and it was the one authored
input whose errors carried no code, no source excerpt and no caret —
whilepack::yamlhad all three for the structurally identical failure.
New codesproef::config::toml(with toml's own error span under the
caret) andproef::config::unreadable, in the catalogue (73 → 75) and
pinned by an integration test;proef lsp's boot warning anddoctor's
config row carry the same message. -
Five help-less refusals gained their missing action.
feature::parse(the shape of a feature file, and the most common way
one stops parsing),bind::ambiguous_step(make one pattern more
specific or retire the duplicate),bind::table_conflict(one source
per param),pack::use_cycle(pull shared steps into a third macro),
and the rawretry: -1message now says why infinite retries are
refused (hurl cannot be interrupted mid-call) and what to write instead
— it used to cite "ADR-0007", an internal document id with no in-band
route to it. -
A miss below the did-you-mean threshold names the valid set instead of
going silent. All eleven suggestion sites ended
closest(…).unwrap_or_default()— when nothing was near, the tail
vanished, andunknown_step_kindsaid "not claimed by any registered
engine" about a registry with exactly one member it never named. One
matcher::suggest_or_enumeratenow serves every site: the nearest
spelling when one is near, else the set verbatim (small), else a count
with the command that lists it ((9 known —proef macroslists them)).unknown_fake,unknown_variableandmissing_config_var
carry the same rendered tail through their typed errors. -
unknown_placeholderfires once per authored defect, not once per
Examples row — a 500-row outline with one typo'd<column>pushed 500
byte-identical diagnostics at one span (the console collapsed them;
SARIF, one-result-per-site by design, did not). It also now names the
header's columns. -
Every rendered error links the diagnostics catalogue. The stable
codes were greppable and led nowhere — the catalogue was linked from
every doc and reachable from no error.Renderedimplements
Diagnostic::url()and the LSP setscode_description, so editors show
a clickable link on the code; on a terminal miette renders an OSC-8
hyperlink, and into a pipe or snapshot the URL prints as plain text
beside the code (links ride the same TTY/NO_COLORgate as color — an
escape sequence a non-terminal sink must never see). -
Pack diagnostics point at the defect, not the macro's name. Every
pattern-family anddefaults:error anchored on the macro-name span —
thirteen of the nineteen seeded pack snapshots underlinedlogin:while
the broken{rol}sat on a line outside the excerpt (one excerpted the
previous macro). Thematch:-line span was computed since the pass was
written and never reached a diagnostic; it does now, with the name span
as fallback.locate::macro_spanalso stopped matching pack-root
bind:entries (a macro sharing a name with a bind key anchored every
diagnostic on the config line). -
Parser errors speak hurl's and gherkin's prose, not Rust's. A pack
author was shownResponseSectionName { name: "Wrong" }and
Method { name: "" }—{:?}of internal enums from crates they never
heard of. All three engine sites now render through hurl's own
DisplaySourceError("the section is not valid. Valid values are
Captures or Asserts"), and gherkin's expectation-set tail is
sort-normalized: it renders from aHashSet, so the same broken file
printed two different messages across processes (observed live) —
breaking snapshot determinism and the duplicate-collapse alike. Pinned. -
A
resolve::*error names the pack it lives in. The span is the
feature step (the invocation), but${nope}is written in a pack YAML
the message never named — the reader was sent to a healthy.feature
line while the sick file stayed anonymous. Every resolve error now
carries(pack <file>).
Added
-
The HTML report is triageable, linkable, and filterable. Every
scenario block carries anid="s-<slug>"anchor (the samestem--name
slug as its artifact, so the two cannot disagree) — a failure is now a
URL a colleague can be handed. A "failed:" jump rail under the summary
links straight to each failing block (blocks keep completion order — the
rail is how a reader skips the green between failures), and a
status-filter bar (all/failed/skipped/warned) toggles block visibility
through a ~15-line inline script: progressive enhancement over classes
the blocks already carry, no framework, still one self-contained file.
Snapshot reviewed deliberately. -
--watchreads like an inner loop. A visual rule with a rerun
counter separates iterations (twenty edits used to stack twenty trees
with nothing marking where the current one begins), and the post-run
line says the verdict in words ("failures — details above") instead of
an exit number to decode. -
Shell completions and a man page, generated by the binary itself:
hiddenproef completions <shell>(bash/zsh/fish/powershell/elvish) and
proef mansubcommands, and every release archive now carries
completions/plusproef.1— generated during packaging by the exact
artifact they ship beside, so they can never drift from it. -
--envis global, like--config:proef --env staging testand
proef test --env stagingboth work — five commands read the profile,
and the position-sensitive spelling was a lesson nobody needed. -
doctorexamines the project, not just the engine: suite resolution
(feature-file count, or the failure),hurlon PATH (a warning when
absent — the engine is embedded, but ADR-0018's stock-replay promise and
the emitted# replay:hints need the binary), and runs-dir writability
(probed with cleanup — the first-runcreate_dir_allfailure was
invisible to the one command whose job is diagnosis). -
A typo'd
--tags/--scenarionames the nearest real spelling. The
refusal held every scenario name and tag at the moment it printed "check
--tags/--scenario" and used none of them; it now suggests the closest
name and tag (glob atoms excepted — a glob selecting nothing is a fact,
not a typo) and points atproef flows, the treatment
[run] exclusive-tagsalways had. -
proef fragmentssays why a listing is empty when no
[run] fragmentsroot is configured — previously indistinguishable from
a configured-but-empty corpus, though the reader's next move differs. -
The console speaks in color, and every run ends on its identity. The
status vocabulary (✓/✗/∅/⚠, the dotted glyphs, the summary's
verdict half) is ANSI-colored on a terminal —NO_COLOR, a dumb TERM, or
a non-terminal stream turns it off, and therun.logmirror strips the
paint either way (content verbatim, paint never). Color is paint on
identical bytes: the record, the exit code and every text assertion see
the same output. Each run's final stderr line is now
run <id> · <seconds>s— the run id is the reproduction key--shard,
--shuffleand${fake:…}all hang off, and it previously printed only
at the top of the scrollback; a red run's trailer adds the
proef explainpointer. Wall-clock stays console-only, never entering
the record.
Fixed
-
A failure no longer prints its detail twice. An engine fault quotes
the failing step's own detail, and the located step line just below
printed the same ~200 characters again; when the fault message contains a
failing step's detail, the fault line now keeps the scenario identity and
the step line carries the detail once. -
JUnit failure and skip detail reaches every platform. The detail —
assert diff, fragment provenance,@skip:reason, the quarantine notice —
lived only in themessageattribute; GitLab parses only the element
text, and Azure maps the text to its stack-trace field, so half the
platforms showed a bare failure (or a reasonless skip). Every non-success
now carries both, and a failure's text node additionally carries each
failing step's redacted reproduce hint — the content channel has the room
the one-line attribute does not. Pinned alongside two library guarantees
that were verified rather than assumed: quick-junit strips ANSI escapes
and XML-1.0-illegal control characters on every setter (one binary
response byte used to be the classic whole-report killer on
Jenkins/GitLab), andtimeis plain three-decimal seconds; both now have
tests so a dependency bump cannot shed them silently. A third pin:
composed reports (suite + rerun-carried + teardown) yield each
classname+nameidentity exactly once — GitLab silently drops
duplicates. -
The GitHub job summary can no longer vanish at the 1 MiB cap. The
documented failure mode at GitHub's limit is silent disappearance (and
oversized writes have aborted jobs in shipped first-party actions); a
failing rerun-overlay suite with per-tag tables crosses it more easily
than it looks. The summary now truncates deterministically at a line
boundary under a 900 KB budget, saying how many lines were cut and where
the full detail lives. -
::errorannotations budget for GitHub's real limit. GitHub keeps
ten error annotations per step and silently drops the rest — an uncapped
emission made a forty-failure run look like exactly ten. The budget is
now one annotation per failing scenario (its first failing step with
detail, else its fault) capped at ten, with a closing::noticenaming
what the ten are out of;title=is clipped under GitHub's 255-character
cap before encoding.
Breaking
--outputsplit by meaning:--formatchooses a format,-o/--output
names a path.testtakes--format json|tap; the listing commands
(flows,macros,fragments,flaky) take--format json— each
through its own enum, so clap's help can no longer advertisetapon
four commands whose runtime rejected it (the old shared enum lied about
a quarter of the surface, and-ochanged category between siblings:
format on five commands, directory onartifacts, file onreport).
--output json/--output tapno longer parse on those five commands —
clean break, no alias;artifacts/reportkeep-o/--outputfor their
paths, unchanged. The runtimejson_onlycheck is deleted: the type
system does its job now.- Library:
World::set_globalreturnsbool(#[must_use]) —false
is a refused promotion — andWorldgainsguard_secrets;
Redactionsgains thetaintsprobe. The hurl engine's private
equality-only gate is deleted in favor of the World's. - Library:
ConsoleReporter::newtakes a fourthcolor: bool— the
TTY/NO_COLORprobe stays at the CLI edge; the sans-IO core takes the
answer as a plain value.
Fixed
-
saveAs: globalrefuses a secret it can find, not just a secret it
can equal. The gate lived in the hurl engine and matched whole-value
equality against raw secret values — a capture merely containing one
(Bearer <token>) or carrying an encoded reflection (base64/hex/percent/
JSON-escape) promoted to.proef-state.jsonin plaintext. The refusal
now lives on the store's owner (World::set_global), armed once per
scenario by the runner with the same derived-needle set redaction uses
(ADR-0005) — one needle list for both invariants, and every engine a
scenario dispatches to is covered. The invariant is now genuinely
property-tested (any composite carrying a guarded secret never enters
the store), as CLAUDE.md had claimed of the single example test. -
The SLA gate honors
@quarantine.sla::checkmeasured every
scenario while the exit code excluded quarantined ones — so a
quarantined, timing-marginal scenario (exactly what gets quarantined)
could not fail the run on its assertions but still turned it red on
latency. The latency population now applies the same non-gating list as
the exit code. -
A record that travels can no longer lie, crash, or steer. Reading a
record predatingscenario_finished.file(or any foreign baseline whose
closes key under the serde default""), the step buffer never attached:
every scenario read as step-less,flakycould never see a retry or a
duration, anddiff --fail-on-regressioncertified green over empty step
maps — the close now adopts its steps' file when exactly one pending
scenario matches by name (pinned by test). The head fold's "first head
wins" guard tested emptiness rather than position, so a second
run_startedin a concatenated or legacy record overwrote the run's
env/metadata/rerun_ofwholesale (pinned by test).rerun_of— a
string read out of the record — was joined onto the runs root
unvalidated, so a crafted"../../elsewhere"spliced a foreign file's
events into the rendered report; it must now be a single path component,
and--run-idgets the same rule at the CLI edge (a typed clap error on
separators or.., on all four commands that accept one). Record reads
gained a generous 256 MiB ceiling — the one input loaded with no bound —
and every duration sum over record-suppliedu64s (HTML report, tag
table,flaky) is now saturating instead of a debug-build panic on a
corrupt file. -
A
[tag-links]template can no longer be subverted by a tag's
spelling. The GitHub-summary sink substituted the tag into the URL raw,
so@JIRA-1)[x](yclosed the markdown link early and injected content
into the job summary; the tag is now percent-encoded in the URL slot.
Both sinks (HTML report and summary) also render non-http(s)templates
as plain text rather than mintingjavascript:-class links. -
An inverted
Spandegrades instead of exploding:Span::lenand the
SARIFbyteLengthare saturating — B1's shipped class, closed in the
type rather than at one construction site. -
Eleven sites that swallowed an error and reported success now speak.
The class the v0.6.0–v0.8.0 series was named for, still present at the
edges: a poisoned store lock silently skipped persisting the World (every
saveAs: globalpromotion of the run lost — now recovered, matching the
runner's own policy, which also stops failing an innocent scenario for
another thread's panic); an unreadable subdirectory silently shrank the
suite to a confident "0 failed" (now warned, per entry too);doctor
reported a clean "no packs" over a tree it could not read (now a Fail
row) andfmtformatted nothing while reporting success (now warned); a
non-UTF-8 environment value read as "not set" — the wrong cause — for
${env:…}(now named up front); a.map.jsonserialization failure was
the one silent write in the run record (now warned);proef lspbooted
with defaults over aproef.tomlthat exists but does not parse,
silently diverging from the runner (now says so on stderr); one
unreadable run aborted all ofproef flaky(now skipped and counted,
with the two-run floor re-applied over what was readable);xtask docs-checkprinted "aligned" when it could not read the directories it
checks (now a failure); and a mis-severitied diagnostic pushed into the
lowering error sink vanished entirely (any error-sink entry now fails the
scenario). -
fmtnormalizes every literal-block spelling. The scan required the
key line to end with|, sohurl: |-,|+, an indent indicator, or
a trailing comment — all loadable — were silently skipped and--check
certified them canonical. Folded scalars (>) stay out deliberately:
YAML folding rewrites the line structure there is nothing line-preserved
to normalize. -
A Ctrl-C landing in
--watch's debounce window no longer launches one
more full suite run. The ≥300 ms drain between "change detected" and the
rerun never checked the interrupt, and the rerun then minted a fresh
cancellation token — so the handler cancelled the finished run's token,
printed "leaving watch", and a whole suite executed anyway. The interrupt
is now checked inside the drain and again after the new token is stored,
so a Ctrl-C from any point forward cancels the token the run actually
carries. -
--watchcan no longer go silently deaf. A delivered watcher error
and notify's rescan signal (the kernel-queue-overflow event agit checkoutburst produces) were both discarded by the event filter — the
watch kept printing "watching … for changes" while missing every change.
Both now retrigger a run, saying why. Two adjacent silent paths gained
voices too: a runs dir whose path has no final component now warns that
its writes cannot be excluded from the watch (the self-feeding-loop
shape), and a failed Ctrl-C handler registration now says the two-stage
interrupt is unavailable instead of silently dropping the contract. -
A comment on a section header no longer blinds the scans that gate on
it. hurl's ownsection_nameparser leaves the rest of the header line
to the ordinary comment terminator, so[Options] # tuningis a real
section — but proef's scans required whole-line equality. Behind a
commented header, validation pass 6 was off entirely:retry: -1
dry-ran clean (the abandoned-thread hole ADR-0007 exists to refuse), the
delay cap and the double-declaration check with it, in inline blocks and
fragments alike. The same equality bug made[Captures] # idsdrop every
capture under it from.map.json, and[Asserts] # noteopen a second
section under anexpect:merge. Oneis_section_headerrecogniser now
serves every section scan. -
delay: 5his refused likedelay: 90malways was. The duration
table knewms/s/mbut not hurl'sh, so an hour-spelled delay five
times over the 1-hour cap fell through the suffix parse and validated
clean. The table now mirrorshurl_core'sDurationUnitin full. -
A pack-scope
bind:value resolves in the pack's scope, not in whichever
macro reached it first. The table resolved through the first ref-using
macro's argument scope and was then cached for the scenario — a bare
${param}silently took that macro's value everywhere (or vanished, blaming
an innocent macro). The pack table now resolves arg-free and default-free:
namespaced references (${url:…},${vars:…},${secret:…},${fake:…},
${env:…}) are its vocabulary, and a bare${name}is a deterministic
error attributed to the pack's ownbind:in every macro order. -
A star-heavy tag atom can no longer hang selection or abort the
process. The glob matcher was naive recursion: backtracking was
exponential in the*count (a 19-character atom took seconds per tag per
scenario) and recursion depth grew with pattern length (a long enough
atom in--tags,[run] exclusive-tagsor[tag-links]overflowed the
stack — SIGABRT, outside the exit contract). Rewritten as the standard
two-pointer match: linear-ish, iterative, oracle-property-tested against
the old semantics. -
A bound value carrying a lone
\ris refused at lower time.
lower::multiline_bindtested\nalone, so a carriage return (a value
read off a CRLF file) sailed into the emitted[Options] variable:line
and died one stage later asemit::invalid_artifact— blaming generated
text the author never wrote. The guard now refuses any control character
except tab. -
An
HTTP2-Settings:request header no longer mis-slots anexpect:
merge. The last-entry scan recognised a response line by the bare
prefixHTTP, which the emitter's own recogniser was already hardened
against; both now share oneis_response_line(HTTP/HTTP/).