Skip to content

proef v0.16.0

Choose a tag to compare

@github-actions github-actions released this 31 Aug 12:48
· 41 commits to main since this release
3feb209

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 — diff reads a
    downloaded baseline, flaky reads every retained run — and the read, the
    line split and the parsed Vec<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, and explain and report each opened
    events.jsonl with a bare read_to_string instead, so neither had it.
    report even 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, diff and doctor speak --format json. They were the three
    commands with no machine output, and the three a consumer reaches for
    after a run. A run directory is artifacts/ + events.jsonl + run.log and
    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.jsonl itself. That is the fold proef's own two internal copies
    disagreed on three ways before report::suite_totals unified 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's flaky/slower stay the rendered sentences for the same reason.
    The flag is the existing single-variant json enum the listing commands
    already use, renamed from ListFormat to JsonFormat now 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.

    doctor needed 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 — the full BDD tree, but only for scenarios that
    failed or warned. A clean run prints the run line and the summary; a dirty
    one prints exactly what full would. The gap it fills is the CI one:
    full is a wall of green on a large suite, dotted drops the detail you
    need when something breaks, and quiet drops everything.

    Warned scenarios are shown, which the name does not say and the code
    explains: a warned scenario is one whose optional: step failed,
    RunSummary::passed counts it with the passes, and the summary line has no
    warned column — so a mode that showed only Failed would 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 deny failed on a yanked transitive crate. rand 0.10.2 resolved
    chacha20 0.10.1, which was yanked from crates.io; the lock now takes
    0.10.2. Not the secret store's copy — chacha20poly1305 pins 0.9.1,
    which is unaffected — so nothing about encryption changed. Found by the
    gate, which is what it is for.

  • proef report -o wrote the author's home directory into the file built to
    be shared.
    With the report inside the run dir the artifact links are a
    bare artifacts/…; with -o pointing 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
    from Path::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 --skip colour failed WCAG AA, and every status pill failed
    it in dark mode.
    --skip was 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: .pill painted color:#fff on 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-h to .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 a StepRef exactly — same file, same line, same
    text. The emitter has always written the authored name: into the
    artifact's entry comment, which is why the .hurl could tell them apart;
    StepRef never carried it, so the console, the HTML report, JUnit, TAP,
    the job summary and explain all 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: three step_finished
    events for the cookie session is exercised, byte-identical in the pinned
    snapshot, are now obtain the session cookie, optional probe (forces a split) and cookie survives the split.

    StepOutcome and step_finished now carry label, 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. One proef_core::report::step_label renders it for every
    sink, so the six cannot drift. Additive on the wire: absent when a step has
    no name:, so every pre-existing record still parses and re-renders
    unchanged, and the event schema stays 1.

    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_hint in 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 # @proef binding 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, and trim_end could 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.

  • explain and the HTML report disagreed about a truncated run's totals.
    A record with no tail run_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
    Warned scenarios 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
    explain correctly declined to. One proef_core::report::suite_totals now
    holds the rule — prefer the tail event unless it cannot be trusted, else
    count suite scenarios with Warned riding along with Passed, exactly as
    the live path reports — and both surfaces call it.

    Also un-splices three doc comments in html.rs that an earlier change had
    merged into one, leaving render_tag_table and render_timeline undocumented
    and render_provenance_and_summary carrying 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 new fuzz_feature_parse target within
    a minute of first running.

  • A long --tags expression aborted the process instead of failing.
    and/or chains 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 both eval and the derived Drop walk that tree
    recursively. A --tags expression of roughly twenty thousand and-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_span accepted 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 its match:, 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]/--meta key (--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 the env/metadata
    provenance the record has carried since ADR-0020 — no new recorded field.

  • proef schema config publishes the proef.toml JSON 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 inherits deny_unknown_fields, making an editor refuse
    exactly what proef refuses. proef schema keeps 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/\n for 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/expected rather 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 flaky audits quarantine, which nothing else could. A
    @quarantine scenario'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 reads DISABLED rather than sharing the
    broken verdict with untagged always-failures, which wrongly implies someone
    is watching. The opposite case gets its own verdict too: green throughout the
    window is recovered, a tag that outlived its problem and is now suppressing
    the next real regression. Both print what to do, and --format json carries
    quarantined plus 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_finished has carried tags since 0.15.0, but ScenarioRun never
    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 a use: targets, what a ref: resolves to and
    which of its variables still need a bind:. Every fact is read from the same
    analysis the diagnostics come from, so a hover cannot contradict the squiggle
    on its own line. SuiteAnalysis gains a scenarios index, 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 with InternalError rather than dropped, and the
    user is told once per suite state through window/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 —
    and proef lsp serves it as a quickfix code action: use: and ref:
    targets, with: and bind: 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: a use: 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 as builtin:…,
    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 own fluent-uri
    Uri newtype) is replaced by gen-lsp-types 0.11 under the same
    lsp_types:: name — rust-analyzer's own aliasing pattern, so every
    use path is unchanged. Its url feature aliases Uri to url::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_path are the native-path bridge
    documents.rs had 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 (url percent-encodes a raw space where fluent-uri rejected it),
    and request dispatch now compares a method enum rather than strings,
    so an unknown method lands in Custom instead of matching nothing.

    Breaking (library): proef_core::report::percent_encode is private.
    It was public solely so proef-lsp could 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 raw hurl (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 at proef init as the start that demonstrates
    the ref: body form — tests/features/ is deliberately fragment-free
    (the reference corpus is config-independent by design, and
    [run] fragments is a config key; the runnable ref: 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 (zero runs-on blocks
    existed anywhere): install, secrets via PROEF_SECRET_*, --junit auto, a --shard matrix, --meta provenance, the diff --fail-on-regression baseline gate, --rerun continuation, and
    flaky over 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
    (finite retry: 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 init scaffold goes green against the dev fixture. The
    advertised fastest path (init → fixture → test) ended 1 pass /
    2 fail: the scaffold calls /search and /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's ref: fragment thereby executes against a live endpoint —
    the body form's first runnable demonstration.

Added

  • Every release archive ships a .sha256 sidecar (basename inside, so
    sha256sum -c works from a download directory). Attestation covers the
    provenance story for gh users; the sidecar covers everyone who
    installs with curl — the half that was missing against the
    ripgrep/uv/starship baseline.

  • A broken proef.toml is 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 —
    while pack::yaml had all three for the structurally identical failure.
    New codes proef::config::toml (with toml's own error span under the
    caret) and proef::config::unreadable, in the catalogue (73 → 75) and
    pinned by an integration test; proef lsp's boot warning and doctor'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 raw retry: -1 message 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, and unknown_step_kind said "not claimed by any registered
    engine" about a registry with exactly one member it never named. One
    matcher::suggest_or_enumerate now 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 macros lists them)). unknown_fake, unknown_variable and missing_config_var
    carry the same rendered tail through their typed errors.

  • unknown_placeholder fires 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. Rendered implements
    Diagnostic::url() and the LSP sets code_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_COLOR gate 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 and defaults: error anchored on the macro-name span —
    thirteen of the nineteen seeded pack snapshots underlined login: while
    the broken {rol} sat on a line outside the excerpt (one excerpted the
    previous macro). The match:-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_span also 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 shown ResponseSectionName { 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 a HashSet, 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 an id="s-<slug>" anchor (the same stem--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.

  • --watch reads 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:
    hidden proef completions <shell> (bash/zsh/fish/powershell/elvish) and
    proef man subcommands, and every release archive now carries
    completions/ plus proef.1 — generated during packaging by the exact
    artifact they ship beside, so they can never drift from it.

  • --env is global, like --config: proef --env staging test and
    proef test --env staging both work — five commands read the profile,
    and the position-sensitive spelling was a lesson nobody needed.

  • doctor examines the project, not just the engine: suite resolution
    (feature-file count, or the failure), hurl on 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-run create_dir_all failure was
    invisible to the one command whose job is diagnosis).

  • A typo'd --tags/--scenario names 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 at proef flows, the treatment
    [run] exclusive-tags always had.

  • proef fragments says why a listing is empty when no
    [run] fragments root 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 the run.log mirror 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,
    --shuffle and ${fake:…} all hang off, and it previously printed only
    at the top of the scrollback; a red run's trailer adds the
    proef explain pointer. 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 the message attribute; 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), and time is 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+name identity 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.

  • ::error annotations 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 ::notice naming
    what the ten are out of; title= is clipped under GitHub's 255-character
    cap before encoding.

Breaking

  • --output split by meaning: --format chooses a format, -o/--output
    names a path.
    test takes --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 advertise tap on
    four commands whose runtime rejected it (the old shared enum lied about
    a quarter of the surface, and -o changed category between siblings:
    format on five commands, directory on artifacts, file on report).
    --output json/--output tap no longer parse on those five commands —
    clean break, no alias; artifacts/report keep -o/--output for their
    paths, unchanged. The runtime json_only check is deleted: the type
    system does its job now.
  • Library: World::set_global returns bool (#[must_use]) — false
    is a refused promotion — and World gains guard_secrets;
    Redactions gains the taints probe. The hurl engine's private
    equality-only gate is deleted in favor of the World's.
  • Library: ConsoleReporter::new takes a fourth color: bool — the
    TTY/NO_COLOR probe stays at the CLI edge; the sans-IO core takes the
    answer as a plain value.

Fixed

  • saveAs: global refuses 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.json in 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::check measured 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 predating scenario_finished.file (or any foreign baseline whose
    closes key under the serde default ""), the step buffer never attached:
    every scenario read as step-less, flaky could never see a retry or a
    duration, and diff --fail-on-regression certified 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_started in a concatenated or legacy record overwrote the run's
    env/metadata/rerun_of wholesale (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-id gets 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-supplied u64s (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](y closed 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 minting javascript:-class links.

  • An inverted Span degrades instead of exploding: Span::len and the
    SARIF byteLength are 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: global promotion 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) and fmt formatted 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.json serialization failure was
    the one silent write in the run record (now warned); proef lsp booted
    with defaults over a proef.toml that exists but does not parse,
    silently diverging from the runner (now says so on stderr); one
    unreadable run aborted all of proef flaky (now skipped and counted,
    with the two-run floor re-applied over what was readable); xtask docs-check printed "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).

  • fmt normalizes every literal-block spelling. The scan required the
    key line to end with |, so hurl: |-, |+, 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.

  • --watch can no longer go silently deaf. A delivered watcher error
    and notify's rescan signal (the kernel-queue-overflow event a git checkout burst 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 own section_name parser leaves the rest of the header line
    to the ordinary comment terminator, so [Options] # tuning is 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] # ids drop every
    capture under it from .map.json, and [Asserts] # note open a second
    section under an expect: merge. One is_section_header recogniser now
    serves every section scan.

  • delay: 5h is refused like delay: 90m always was. The duration
    table knew ms/s/m but not hurl's h, so an hour-spelled delay five
    times over the 1-hour cap fell through the suffix parse and validated
    clean. The table now mirrors hurl_core's DurationUnit in 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 own bind: 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-tags or [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 \r is refused at lower time.
    lower::multiline_bind tested \n alone, so a carriage return (a value
    read off a CRLF file) sailed into the emitted [Options] variable: line
    and died one stage later as emit::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 an expect:
    merge.
    The last-entry scan recognised a response line by the bare
    prefix HTTP, which the emitter's own recogniser was already hardened
    against; both now share one is_response_line (HTTP / HTTP/).