packages feed

heni (empty) → 0.1.0.0

raw patch · 123 files changed

+11485/−0 lines, 123 filesdep +aesondep +basedep +bytestring

Dependencies added: aeson, base, bytestring, containers, crypton, directory, filepath, heni, process, stm, text, unix

Files

+ CHANGELOG.md view
@@ -0,0 +1,13 @@+# 0.1.0.0 — 2026-10-24++## New Features++Initial framework-neutral mutation campaigns with checked edits, immutable+source snapshots, bounded POSIX processes, precise classification, strict JSON+manifests and portable reports. Optional Tasty/Hspec reporters provide named+assertion evidence; required QuickCheck replay and fresh native probes retain+separate original and reproduction observations.++Includes scoped resources, bounded workers, conservative baseline sharing and+verified Haskell build reuse, four representative migration families, strict+report recovery, standalone archive validation and BSD-3-Clause licensing.
+ LICENSE view
@@ -0,0 +1,27 @@+BSD 3-Clause License++Copyright (c) 2026, Nadeem Bitar+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+   list of conditions and the following disclaimer.+2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.+3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software+   without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,82 @@+# Heni++Heni runs deliberate mutations and verifies that the intended tests catch them.+Describe a checked edit, build/test commands and a precise failure witness. Heni+first proves the unchanged baseline, then runs the mutation in a disposable copy+and retains a portable report.++Version 0.1.0.0 is unreleased. Local release validation and its limits are recorded+in [release readiness](https://github.com/shinzui/heni/blob/master/docs/validation/release-readiness.md). Ordinary Cabal and+released dependencies are sufficient; repository documentation tools and Nix are+optional. Supported compiler/platform combinations are listed in+[compatibility](docs/design/compatibility.md).++From this checkout:++```sh+cabal build all -j1+cabal run heni-cli:exe:heni -- check examples/minimal/heni.json+cabal run heni-cli:exe:heni -- run examples/minimal/heni.json --case guard-bypass --output .heni-runs/first-caught+cabal run heni-cli:exe:heni -- report .heni-runs/first-caught/report.json+```++Expect `caught` and exit 0. Selecting `--case survivor` with a fresh output path+produces `survived` and exit 1. Configuration or infrastructure failures exit 2.+The [quickstart](docs/quickstart.md) begins with the unchanged passing fixture and+shows both cases. Commands use literal argv; no shell mutation runner is needed.++| Package | Purpose |+| --- | --- |+| `heni` | Framework-neutral `Test.Heni` library, strict manifests, runner and reports. |+| `heni-cli` | The `heni` executable using the same library classifier. |+| `heni-tasty` | Optional complete named-test evidence for Tasty. |+| `heni-hspec` | Optional Hspec evidence and native QuickCheck replay. |++A Haskell consumer constructs `CaseDefinition`, calls `mkCampaign`, then+`runCampaign (defaultRunOptions source output) campaign`. The library returns+`RunResult`; the caller owns application rendering/exits. See the+[short library example](examples/library/Definitions.hs) and the+[independent consumer](examples/standalone/Main.hs). Use generic exact diagnostics+when test counts are unknown, or install a reporter for selected/executed IDs and+named assertions. Tasty/Hspec reporters are optional. Released Aeson brings a transitive+QuickCheck dependency; the core exposes no framework test types.++Heni supports checked UTF-8 replacement, create/delete, optional Git textual+patches and trusted Haskell transformations. Baselines, controls, intentional+compiler rejection, replay and fresh crash probes have separate evidence+contracts. Timeouts, unrelated errors, stale edits and missing test evidence+cannot count as successful semantic detection. Source snapshots include selected+dirty and untracked regular files; the original checkout remains unchanged.++Scoped resources and bounded workers support consumer-owned fixtures. Baseline+sharing is opt-in and requires complete compatible identities. Incremental build+reuse requires a trusted content-observation verifier; there is no generic+portable switch that makes every build cache safe.++The POSIX supervisor bounds commands, output capture and group cleanup. Detached+descendants and runner SIGKILL need stronger consumer containment. Windows, AST+mutation generation, distributed execution, persistent verdict caches and+automatic database rollback are outside this release. Consumer hooks are trusted+code, not a security sandbox.++Read the [CLI reference](docs/cli.md), [troubleshooting](docs/troubleshooting.md),+[adapter contracts](docs/design/adapters.md), [resource/reuse guide](docs/design/scheduling.md),+and [migration guide](docs/migration-guide.md). Versioned [schemas](schemas/) cover+manifests, reports and test evidence. The [migration evidence](docs/validation/migration-results.md)+separates measured fixture results from historical research and future consumer work.++For contributors:++```sh+cabal test all -j1 --test-show-details=direct+sh scripts/test-migrations.sh+sh scripts/check-sdist.sh+```++The archive check additionally needs development-only Python/jsonschema and Git+for the explicit optional patch gate. Core tests run with a tool path excluding+Git, Nix, Mori, OKF and Python. [Release preparation](docs/releasing.md) explains+local archives and the separate later Hackage candidate/publication review.++Heni derives its name from Japanese 変異 (*hen’i*), mutation or variation.+Licensed BSD-3-Clause; copyright 2026 Nadeem Bitar.
+ bench/campaign/Main.hs view
@@ -0,0 +1,56 @@+module Main (main) where++import Control.Exception+import Data.Aeson+import Data.ByteString.Lazy.Char8 qualified as LBS+import Data.IORef+import Data.Version (showVersion)+import Definitions (exampleCampaign)+import GHC.Clock (getMonotonicTimeNSec)+import Paths_heni (getDataFileName)+import System.Directory qualified as Dir+import System.Environment (getArgs)+import System.FilePath (takeDirectory, (</>))+import System.Info qualified as Platform+import System.Posix.Temp (mkdtemp)+import Test.Heni+import Text.Read (readMaybe)++modify_ :: IORef a -> (a -> a) -> IO ()+modify_ ref f = atomicModifyIORef' ref (\x -> (f x, ()))++main :: IO ()+main = do+  args <- getArgs+  let parse [] = (1, "fresh")+      parse ["--workers", n, "--mode", mode] | Just count <- readMaybe n, count > 0, count <= 256, mode `elem` ["fresh", "reuse"] = (count, mode)+      parse _ = error "use --workers N --mode fresh|reuse"+      (jobs, mode) = parse args+  source <- getDataFileName "examples/minimal/heni-minimal-fixture.cabal" >>= pure . takeDirectory+  bracket (Dir.getTemporaryDirectory >>= \p -> mkdtemp (p </> "heni-cabal-bench-")) Dir.removePathForcibly $ \root -> do+    totals <- newIORef (0 :: Int, 0 :: Double, 0 :: Int, 0 :: Int)+    let observe (CaseStarted _) = modify_ totals (\(baselines, buildTime, active, peak) -> (baselines, buildTime, active + 1, max peak (active + 1)))+        observe (CaseFinished _) = modify_ totals (\(b, t, a, p) -> (b, t, a - 1, p))+        observe (StageFinished _ role StageEvidence {stage = Build, durationSeconds}) = modify_ totals (\(b, t, a, p) -> (b + if role == "baseline" then 1 else 0, t + durationSeconds, a, p))+        observe _ = pure ()+        options = (defaultRunOptions source (root </> "run")) {workers = jobs, progress = observe, reusePolicy = if mode == "reuse" then ReuseBaselines "current GHC/Cabal and released fixture dependencies; no external state" Nothing else Fresh}+    start <- getMonotonicTimeNSec+    result <- runCampaign options exampleCampaign+    end <- getMonotonicTimeNSec+    (baselines, buildTime, _, peak) <- readIORef totals+    LBS.putStrLn+      ( encode+          ( object+              [ "workload" .= String "three real Cabal cases: caught, survivor, compiler error",+                "mode" .= mode,+                "workers" .= jobs,+                "perBuildJobs" .= (1 :: Int),+                "peakCaseConcurrency" .= peak,+                "baselineBuildInvocations" .= baselines,+                "elapsedSeconds" .= (fromIntegral (end - start) / 1e9 :: Double),+                "sumBuildStageSeconds" .= buildTime,+                "platform" .= object ["os" .= Platform.os, "arch" .= Platform.arch, "compiler" .= Platform.compilerName, "compilerVersion" .= showVersion Platform.compilerVersion],+                "result" .= runResultJSON result+              ]+          )+      )
+ docs/adr/1-framework-neutral-core.md view
@@ -0,0 +1,21 @@+# Framework-neutral public library++Status: accepted. Date: 2026-10-24.++Heni owns definitions, checked execution, evidence classification and reports.+The CLI wraps those library APIs. Optional test frameworks supply evidence in+separate packages and never export their types through Test.Heni. Consumers own+service fixtures and effects, discharged into plain IO hooks. Portable case+data and executable hooks remain separate; arbitrary functions are not JSON.++Use strict concrete records, GHC2024 and a small internal prelude. Constructor+matching and explicit combinators keep generic-lens's transitive label orphan+out of the public import closure. This is a scoped adaptation of+mori://shinzui/haskell-jitsurei/docs/core-record-patterns. A downstream compile+fixture defines its own IsLabel instance while importing Test.Heni.++This boundary keeps ordinary Cabal consumers independent of application effect+stacks, Mori, OKF, Nix and test-framework dependencies. The cost is separate+optional packages and explicit codecs. It follows the boundary lessons of+mori://shinzui/mori-app/okf/adrs/concepts/ADR-1 and+mori://shinzui/keiro/okf/adrs/concepts/ADR-28 without adopting their frameworks.
+ docs/adr/10-source-archive-release-boundary.md view
@@ -0,0 +1,33 @@+# Source archives define the release validation boundary++Status: accepted. Date: 2026-10-24.++Validate the four Heni source distributions outside the checkout, with released+non-Heni dependencies. A core-only project must work independently; inspect the+reachable component graph rather than every node in Cabal's elaborated plan.+Aeson brings transitive QuickCheck, while Tasty/Hspec and Heni reporters remain+optional. Keep framework test APIs outside the core.++Run core tests with a restricted tool path, then separately require positive Git+patch integration and optional framework reporters. Archive tests also prove+complete Haddock, schema agreement, the standalone library/CLI exit contract and+representative migrations. Preserve failed extractions and bounded validation+logs. Do not let absence of a capability silently satisfy its positive gate.++Advertise only successfully tested OS/compiler combinations and dependency sets.+GHC 9.12.4 is both ends of the initial admitted compiler range; widening base+bounds is a compatibility task. Keep -Werror in owned CI policy. Version public+Haskell APIs under PVP independently from wire schemas.++The maintainer selected BSD-3-Clause and supplied metadata by reference to their+other packages. Name lookups are neither reservations nor ownership checks.+Source/docs hashes and readiness evidence are repository-only records excluded+from source distributions to avoid self-reference. Candidate upload, rendered+Hackage review, account authorization and publication remain separate release+actions after local readiness; local artifacts never imply an upload occurred.++Haddock instance-origin links can target hidden definition modules. Release docs+selectively generate the two implementation definition pages with `--show`,+marking them `not-home` and explicitly outside the supported import API. Public+facades remain documentation homes and installed Cabal module exports do not+change. Check generated module and source links as well as coverage percentages.
+ docs/adr/2-evidence-owned-classification.md view
@@ -0,0 +1,25 @@+# Evidence owns the verdict++Status: accepted. Date: 2026-10-24.++Every execution path calls the same pure classifyCase and summarizeRun.+Expectations cannot override process outcomes, baseline success, changed-byte+requirements, setup, timeout or cleanup validity. Evidence is distinct from a+verdict and retains secondary failures. Generic diagnostics disclose their+weaker attribution instead of inventing framework test counts.++Named detection requires the exact declared assertion failure set and actual+execution. Required replay carries the same token, generator and failure+identity. Explicit crash probes and intentional build rejections are distinct+from semantic kills. Timeouts remain unresolved. Unchanged controls are the+only permitted no-op cases and invalidate run confidence when they fail.++A validated selection ledger owns exactly-once accounting; missing results are+NotRun, and duplicate/foreign evidence is rejected. Infrastructure takes+precedence over survivors in run status. The semantic fraction excludes+controls, crashes, build rejection and unresolved outcomes, and is marked+incomplete when any run obligation remains. This conservatism prevents ordinary+command errors from turning a broken mutation campaign green.++The executable truth table and ledger invariants live in test/unit/Main.hs;+the detailed precedence contract is in docs/design/classification.md.
+ docs/adr/3-immutable-snapshots-and-staged-edits.md view
@@ -0,0 +1,23 @@+# Immutable snapshots and staged edits++Status: accepted. Date: 2026-10-24.++The selected working tree is the source of truth. Capture names, bytes,+executable bits and policy into an immutable content-identified value, then+materialize exclusive disposable workspaces. Never back up and restore over+the active checkout. Snapshot identity covers policy as well as bytes, and+double capture rejects observed concurrent changes without claiming an atomic+filesystem snapshot.++Apply an entire edit sequence before publishing a candidate to the runner.+Recompute the actual final inventory; hook declarations are not proof of byte+changes. Reject no-op mutations, changed controls, symlinks, special files and+edits to runner-owned metadata. Keep stale mutation errors distinct from IO or+cleanup infrastructure errors. Optional patch subprocesses use the shared+supervisor; Git is not a core runtime requirement for other edits.++The first implementation stores immutable bytes in memory and rejects source+symlinks. These deliberate limits simplify ownership and make active-checkout+preservation independently verifiable. A capability API constrains ordinary+hooks but is not a security sandbox for arbitrary IO. Reset failure invalidates+worker reuse, and fresh workspaces remain the default.
+ docs/adr/4-posix-process-ownership.md view
@@ -0,0 +1,34 @@+# POSIX process groups own command lifetimes++Status: accepted. Date: 2026-10-24.++All external commands, including Git patch checks, setup, teardown and replay,+use the same supervisor. It owns a POSIX group, drains both pipes, enforces a+monotonic budget, terminates remaining group members even after the leader+exits, and bounds escalation, direct-child reaping and reader joins. Structured+exit/signal/timeout evidence takes precedence over terminal text. Cancellation+retains process evidence while preserving the original exception for the caller.++Bounded raw logs are presentation artifacts. Exact diagnostic witnesses are+scanned through all bytes and retained separately; invalid UTF-8 display+decoding must never create detection evidence. Observation failures and cleanup+failures remain infrastructure information, not successful detections.++Linux and macOS are the first target platforms; Windows is deliberately+unsupported. Ordinary POSIX groups do not contain detached descendants or+recover from runner SIGKILL. Document and test this boundary instead of+promising an OS sandbox. Consumers requiring stronger containment supply one.++Host executable availability is checked before a campaign. Explicit `./...`+programs are workspace-local build products: validate their contained relative+syntax at preflight and resolve them in the stage's checked cwd after build.+Reject runtime symlink escapes. Applicability checking can defer their existence+without running a build; this must not be presented as proof they already exist.++Group signalling errors are provisional until bounded cleanup ends. macOS can+return EPERM while an exiting group's members are being reaped, then ESRCH.+Only a subsequent definite ESRCH clears those errors; persistent permission or+observation failure remains infrastructure failure. Stop signalling after+definite absence, and reap the direct child independently. Repeated short+timeouts exercise this lifecycle race. Apple's [group signalling source](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/kern_sig.c)+filters zombie group members before computing whether any target was found.
+ docs/adr/5-campaign-ledger-and-report-trust.md view
@@ -0,0 +1,27 @@+# One campaign ledger and portable evidence++Status: accepted. Date: 2026-10-24.++The library owns the complete baseline/edit/build/test/classify lifecycle and+the selected-ID ledger. CLI and optional framework paths consume it. Baseline+and candidate configuration must match; failed baselines never produce mutant+kills. Only a successful build permits semantic tests. Checked hooks may enrich+evidence but cannot override actual outcome, timeout, diagnostic, source-change+or cleanup validity. Every external stage, including Git, uses the supervisor.++Write a sequenced ledger snapshot before work and after meaningful observations;+finalize summaries by atomic rename. Include baseline/patch evidence separately+from candidate classification inputs, retain secondary cleanup/report errors,+and fill all queued IDs on interruption. Recovery is read-only and explicitly+untrustworthy; a journal is evidence, not permission to resume commands.++Use explicit versioned codecs and relative artifact paths. Reject duplicate JSON+keys before map conversion, and reject malformed ledgers and escaping/missing+artifacts when reading. Preserve exact witnesses independently of bounded logs.+Reports redact environment values and explicitly sensitive argv indexes without+claiming complete secret-bearing reproducibility. Consumers own what their+commands emit. Atomic rename does not promise durability when storage refuses IO.++The cost of full ledger snapshots is larger journals. The initial serial runner+chooses inspectable recovery and fresh workspace isolation before optimizing+scheduling or reuse. Later scheduling must preserve these accounting invariants.
+ docs/adr/6-explicit-portable-wire-boundaries.md view
@@ -0,0 +1,24 @@+# Explicit portable input and thin CLI++Status: accepted. Date: 2026-10-24.++Versioned JSON manifests lower into the same validated Campaign as Haskell+definitions. The CLI owns arguments, rendering and exit mapping, while the+library owns applicability, execution, classification and report reading.+Reject unsupported versions, unknown/duplicate keys, invalid references and+unsupported hooks before execution. Defaults apply only to missing nonnullable+fields, not explicit nulls. JSON Schema describes shape; the decoder additionally+checks semantic relationships that schema cannot express.++Anchor source paths to the manifest file. Keep explicit caller-relative output+paths deliberate and document their different role. Repeat filters by union+within each kind and intersection between case/tag kinds, retaining author+order. Empty selection is an error. Check/list/report never imply a baseline+or execute mutation tests. Recovery is read-only. New adapter/resource syntax+must wait for real execution support rather than silently doing nothing.++Machine output remains clean JSON; observation and errors use stderr. Human+color follows terminal detection by default. Package version identity comes+from Cabal archives without Git. These boundaries follow the small applicable+parts of mori://shinzui/haskell-jitsurei/docs/cli-overview and preserve the+library-owned behavior established in docs/adr/1-framework-neutral-core.md.
+ docs/adr/7-attempt-bound-test-evidence.md view
@@ -0,0 +1,27 @@+# Attempt-bound test evidence and replay++Status: accepted. Date: 2026-10-24.++Framework adapters describe observations through a dedicated versioned JSON+Lines artifact. Only the core classifier chooses verdicts. Bind every record+to a fresh run/case/stage/role/attempt identity and cross-check completion,+counts, test transitions and failure membership. Reject stale, malformed and+partial evidence without falling back to diagnostic matching. A framework+exception after completion appends explicit invalidation while retaining facts.++Stable escaped path IDs distinguish nested or parameterized display names.+Unknown unfiltered discovery remains unknown. Report selected/executed IDs+exactly and require intended assertions to execute in both baseline and+candidate. Optional framework packages own version-specific public APIs;+framework types never enter the core public import closure.++Required replay is a supervised second attempt in the same edited workspace.+Preserve both observations and exact framework token/configuration. A replay+timeout is a failed reproduction, with the underlying Timeout retained, not+a semantic detection. Support one explicit QuickCheck replay property per+selected attempt initially; use typed builders for other native protocols.++A declared crash needs a fresh attempt-bound reachability probe and actual+native signal, with no timeout/cancellation. Probes do not prove arbitrary+root cause. These artifacts assume cooperating test programs and do not+provide cryptographic attestation against them.
+ docs/adr/8-scoped-resources-and-conservative-reuse.md view
@@ -0,0 +1,37 @@+# Scoped resources and conservative campaign reuse++Status: accepted. Date: 2026-10-24.++Use callback-scoped providers for consumer-owned values. Campaign, worker and+case scopes make lifetime explicit; reset external state before baseline and+candidate, acquire declared providers in order and release in reverse order.+Namespace environment additions and serialize only nonsecret identity/version/+epoch metadata. Default resource capacity to one. Acquire multi-resource claims+atomically within a finite campaign, preserving deterministic result order.+Separate Heni processes require external coordination.++Case-local evidence belongs to one worker; shared result updates are atomic and+journal writes/progress callbacks are serialized. Cancel and join every worker+before finalizing. Preserve original cancellation and all cleanup errors.+Fresh private workspaces remain the correctness reference.++Reuse only passing in-campaign baselines whose complete input contracts match.+Compare sensitive command/environment values in memory, without portable weak+hashes. Require nonsecret toolchain identity, exact structured producer identity+and known resource epochs; disable reuse for unidentified custom hooks. Record+baseline-owner provenance, never invent a fresh attempt artifact for a reused+baseline. No cross-run verdict cache is provided.++Incremental builds require an explicit Haskell provider that verifies content-key+observation before tests. Restore and inventory source independently, keep all+writable artifacts worker-private, and retire retained state after suspicious+source, reset, build observation or cleanup. Generic commands cannot prove a+universal invalidation rule, so no portable incremental-build flag is offered.+Same-size/coarse-mtime adversarial fixtures and fresh-mode parity are required+for a provider's claimed compatibility. Timings remain observations, while+isolation and invocation counts are correctness gates.++Emit the final case progress event only after case-scoped resources have been+disposed. Teardown can revise a semantic detection to infrastructure failure;+observers must receive that same final case verdict. Worker/campaign teardown+remains run-level confidence evidence because those scopes span multiple cases.
+ docs/adr/9-migration-proof-and-domain-ownership.md view
@@ -0,0 +1,34 @@+# Migration proof and consumer-owned fault meaning++Status: accepted. Date: 2026-10-24.++Heni owns disposable source lifecycles, supervision, baseline validity,+attribution and reports. Consumers retain fault meaning, selected assertions,+generator/checker code and external resource reset semantics. Representative+fixtures are independently authored and redistributable; do not copy or silently+migrate the surveyed repositories as part of validating this boundary.++For generated code, mutate generator input before generation when possible.+An output mutation requires a consumer workflow that preserves and verifies the+artifact through compilation. Do not hide a regenerating build behind a claim+that the original edit reached the test. Workspace-built ./program executables+resolve after build inside the checked stage cwd.++A portable manifest is sufficient for common edits and exact diagnostics.+Haskell-only transformations and verifiers remain explicit trusted callbacks;+no second general-purpose runner belongs in migration examples. Strong named+protocols retain their exact identities instead of falling back to terminal+scraping. Database-state fault injection and live-service acceptance remain+consumer work beyond filesystem isolation.++The cross-package migration trial is an explicit integration gate with a+separately built CLI executable. Keep it out of default core test dependencies+to preserve independent core archives and avoid Cabal build-tool cycles. Release+CI must run the integration gate and optional reporter tests.++Measure fixture lifecycle scaffolding separately from mutation definitions,+domain/build helpers, automated interface actions and compiler time. Agent-run+observations do not establish human usability or production-wide savings.+Historical survey evidence, refreshed source identities and newly executed+trials are separate claims. No migration or publication is implied by local+parity evidence.
+ docs/cli.md view
@@ -0,0 +1,75 @@+# CLI quickstart++Build with ordinary Cabal and run from this checkout:++```sh+cabal build heni-cli:exe:heni+cabal run heni-cli:exe:heni -- check examples/minimal/heni.json+cabal run heni-cli:exe:heni -- list examples/minimal/heni.json+cabal run heni-cli:exe:heni -- run examples/minimal/heni.json --case guard-bypass --output .heni-runs/cli-example+cabal run heni-cli:exe:heni -- report .heni-runs/cli-example/report.json+```++Use a new output directory each time. The selected guard mutation is caught;+the example's survivor and unrelated compile-error cases deliberately fail the+contract. `check` checks structure, command availability and edit applicability+in disposable copies. It does not run builds, tests, discovery or baselines.+`list` reads definitions without launching commands. `report` only reads stored+evidence. `report DIRECTORY --recover` reconstructs an explicitly incomplete+journal snapshot without executing or resuming anything.++Manifest source roots are relative to the manifest's directory, defaulting to+`.`; mutation targets and command working directories are relative to the+snapshot root. An explicit CLI `--output` path is relative to the caller's+directory. Without it, output is SOURCE/mutation-results, a default excluded+name. A custom nested output location must be excluded from source capture.++Repeat `--case` and `--tag` freely. Values within each kind form a union; the+case and tag sets are intersected. Selection retains author order. Unknown+explicit IDs and empty intersections fail before execution. The current+default is serial fresh-copy execution. `--jobs N` selects 1–256 workers and+overrides manifest `execution.workers`; resource capacities still apply.++`run` exits 0 only when the library reports a trustworthy met contract, 1 for+contract failure, and 2 for configuration or infrastructure failure. SIGINT+exits 130 after partial evidence finalization. `check` exits 2 when applicability+or configuration fails. `list` and successful `report` rendering exit 0 even+when the rendered run failed; use `run` for the CI gate.++All commands support `--json`, with JSON on stdout and progress/errors on stderr.+Run JSON contains schemaVersion, reportPath, selectionRule and the library result.+Report JSON is the full stored report envelope. Human output includes verdict+counts, unresolved cases, witnesses and separate run failures. `--color auto`+uses ANSI only on a terminal; `always` and `never` override human output, while+machine JSON remains uncolored. `--version` comes from the Cabal package and+works without Git. Shell wrappers can use the installed `heni` executable;+Python callers should invoke argv and inspect its exit/JSON instead of rebuilding+mutation orchestration.++Version-one manifests reject unknown keys at every level, unsupported versions,+duplicate object keys, unsafe targets, bad budgets, duplicate IDs and unknown+references. Missing timeoutSeconds defaults to 30; outputLimitBytes defaults to+1048576 per stream; arguments, sensitiveArguments and tags default to empty;+replacement occurrences defaults to exactly one. Explicit null is accepted only+for nullable fields such as selector, test and workingDirectory. Effective+microsecond budgets and output limits appear in reports. Command environment+values are redacted; sensitiveArguments lists zero-based argv indexes to redact.++The [manifest schema](../schemas/manifest-v1.schema.json) defines wire shape;+the decoder also checks semantic references and edit/expectation constraints.+Generic diagnostics, intentional build rejection, unchanged controls, named-test+protocol adapters, required replay and attempt-bound crash probes are supported.+Custom Haskell functions and build-tool observation verifiers cannot be serialized.+Scoped resource commands and baseline reuse use the shared runner. Unsupported features never silently+fall back to ordinary diagnostic matching.++Named-test adapters, required replay and explicit crash probes are described in+[adapter contracts](design/adapters.md), with runnable manifests in examples/adapters.++See [scheduling and resource contracts](design/scheduling.md) for portable resource+claims, baseline reuse identities, external reset obligations and build-job budgets.++Host tools are checked before execution. An explicit `./path/to/program` is a+workspace-local build product; `check` validates its path but defers existence+until the build/test lifecycle. It never builds the program during applicability+checking. Traversal and runtime symlink escapes are rejected.
+ docs/design/adapters.md view
@@ -0,0 +1,56 @@+# Named assertions, replay and native probes++Install `heni-tasty` or `heni-hspec` in the consumer test executable. Replace+Tasty's `defaultMain` with `Test.Heni.Tasty.heniMain`, or Hspec's `hspec` with+`Test.Heni.Hspec.heniHspec`. Ordinary direct execution keeps the native runner.+Inside an attempt the reporter writes a dedicated versioned JSON Lines file;+stdout, colors and display formatting are not the protocol.++Set `adapter: "heni-v1"` in a manifest case and use+`expect: {"kind":"named-tests","failures":["suite/intended"],"replayRequired":false}`.+Stable IDs join nested groups/example names with `/`, escaping literal `~`+and `/` as `~0` and `~1`. Duplicate IDs are rejected. Tasty selectors use native+pattern syntax; Hspec selectors match substrings of the stable ID. Both+intersect with native command filters. Tasty and Hspec report exact selected+and executed IDs; unfiltered discovery is currently unknown, not invented.+Hspec history reruns and dry-run are unsupported inside an attempt. Fail-fast partial+execution is infrastructure evidence, never a completed successful detection.++The core requires an unchanged baseline with the intended tests executed and+passed. A candidate must fail precisely the allowed named assertions. Skips,+setup errors, other failures, count mismatches and incomplete records cannot+count as caught. Every record binds run, case, stage, role and fresh attempt.+Producer version must agree with baseline. The protocol and reporters are an+evidence contract for cooperating programs, not an attestation against a+malicious executable.++For QuickCheck with Hspec, wrap one explicitly selected property example:++```haskell+it "property" $ heniProperty "suite/property"+  (QC.stdArgs {QC.maxSuccess = 1, QC.maxSize = 30}) property+```++Use `replayRequired: true`. The helper records QuickCheck's native seed/size,+version, generator arguments and counterexample. Heni supervises a separate+Replay stage on the same edited workspace. Missing/invalid tokens, changed+configuration, another failure or replay timeout reject reproduction; original+and replay facts remain available. One replay property per selected attempt+is supported. Other frameworks can supply `CaseHooks.replayBuilder` and emit+the same protocol. Counterexamples may contain sensitive application data;+the consumer must redact them before recording when needed.++For a declared native crash use+`expect: {"kind":"crash","signal":6,"probe":"operation-reached"}`+and call `Test.Heni.Crash.recordCrashProbe "operation-reached"` immediately+before the operation. Signal numbers are platform-specific; Haskell users can+use `System.Posix.Signals`. This channel requires no test adapter. Fresh+identity, the declared signal and absence of timeout/cancellation are all+required. A probe proves reachability, not the cause of an arbitrary crash.+Numeric exits and ordinary stdout markers do not prove a native signal.++`adapters/heni-tasty/test/fixture/Main.hs` and+`adapters/heni-hspec/test/fixture/Main.hs` are complete runnable examples.+The core standalone producer tests keep both frameworks outside its dependency+closure. `schemas/test-evidence-v1.schema.json` documents record shape; the+reader additionally verifies sequence, membership, transitions and totals.
+ docs/design/api-contract.md view
@@ -0,0 +1,60 @@+# Public API and ownership++The root `heni` library exposes `Test.Heni` and focused Case, Command,+Expectation and Result modules. It has no test-framework dependency, mandatory+terminal output, process exit, or repository-tool requirement. IDs, relative+targets, budgets and selected ledgers have hidden constructors. Call+`validateCase` and `validateCommand` before execution; their `ConfigError`+names the rejected field. Case data and evidence use concrete strict records.++The minimal construction sequence is visible in `test/api/Consumer.hs`: create+IDs and a relative target, construct a `CaseDefinition`, validate it, then+classify evidence. That fixture uses synthetic evidence only. It covers a+replacement, textual patch catalogue, generated-code hook, and external+diagnostic. The model also expresses named tests, replay, build rejection,+unchanged controls and signal probes. The real campaign path is demonstrated in+`examples/library/` and the optional adapter packages.++`CaseDefinition` contains serializable data. `CaseHooks` holds trusted IO+functions separately. `WorkspaceOps` supplies checked reads and writes; the+workspace owner supplies checked creation/deletion. Arbitrary IO can escape+these operations: this is an API capability, not an OS sandbox. A custom+evidence callback returns evidence or a rejection; it cannot return a verdict.+The execution owner must preserve actual process outcome and all validity+gates when interpreting custom evidence.++The execution API is:++```haskell+runCampaign :: RunOptions -> Campaign -> IO RunResult+```++These argument types now live in `Test.Heni.Runner`. `mkCampaign` validates+commands, references and custom edit registrations. RunOptions includes+source/output locations, selection, an IO progress callback, and a cleanup hook+for each baseline/candidate workspace. Command.sensitiveArguments marks+zero-based argv indexes for report redaction; environment values are always+redacted from portable configuration. The workspace owner supplies scoped immutable+snapshots and independently checked changes. The process owner supplies actual+outcomes, bounded logs and durations as StageEvidence. The campaign owner+validates references, baselines and final accounting, then calls the pure+classifier and summary. The adapter owner supplies versioned selection and+replay evidence. The report owner writes explicit versioned codecs rather than+deriving a wire contract from Haskell constructor spelling. The CLI renders the+same RunResult and never classifies failures itself.++Later owners may extend these records before publication; update the model+tests and this contract together. The public import closure intentionally has+no generic-lens label orphan; `test/api/Labels.hs` compiles a consumer's own+overloaded label instance. The internal prelude exports only foundational+types, following the scoped adaptation in [the boundary ADR](../adr/1-framework-neutral-core.md).++`CaseDefinition.evidenceMode` declares generic commands or the strong v1+protocol. `CaseHooks.replayBuilder` can build a native framework replay command;+strong protocol facts remain authoritative even with a custom observer. See+[adapter contracts](adapters.md) for named identities, replay and fresh probes.++`RunOptions` also accepts bounded workers, scoped resources/claims and conservative+reuse policies. Construct from `defaultRunOptions` and update named fields; portable+callers use `manifestRunOptions`. [Scheduling contracts](scheduling.md) explain the+Haskell-only build observation provider and resource ownership boundaries.
+ docs/design/classification.md view
@@ -0,0 +1,51 @@+# Evidence classification++`classifyCase` is the only verdict function. It retains the complete+CaseEvidence, including secondary cleanup errors. It evaluates these gates in+order: invalid definition; infrastructure, cleanup or malformed protocol;+duplicate stage; stopped execution; missing/failed baseline; rejected or+unchanged mutation (or changed control); runner timeout; cancellation; required+stage and expectation. Stage order does not affect a verdict. Duplicate stages+are invalid evidence and cannot inflate counts.++A failed baseline yields `not-run` for its candidate and a run-level+BaselineFailure with affected IDs. A failing control yields its observed+case verdict and a ControlFailure. Neither contributes to semantic scoring.+Infrastructure takes precedence over matching diagnostic output. Setup and+teardown nonzero exits are infrastructure failures. Evidence marked truncated is+untrustworthy. The supervisor may truncate raw log artifacts while retaining+exact byte-matched witnesses separately; this does not mark evidence unavailable. Timeout+and runner cancellation never prove a crash or assertion detection.++Semantic expectations require a successful Build. A build failure is+`unexpected-build-failure`. Named tests additionally require known positive+selected/executed counts and execution of every intended ID without skipping.+Absent execution is `uncovered`; missing stage evidence is infrastructure.+Exit 1 with exactly the declared failed-ID set proves detection. Extra failed+assertions are `wrong-failure`; exit 0 with no failed IDs survives. Malformed+counts, failed IDs outside execution, and simultaneous executed/skipped IDs are+protocol infrastructure failures. Unknown counts never become zero counts.++A generic diagnostic requires the declared nonzero exit code and literal+nonempty output witness. Its counts remain unknown; its evidence strength is+explicitly GenericDiagnostic. Build-stage diagnostics and BuildRejection count+as ExpectedBuildRejection, separately from semantic detections. A CrashProbe+requires the declared signal and nonempty case-specific probe. It counts as+ExplicitCrash. A normal exit cannot substitute for a signal.++RequireReplay demands an identical token, generator configuration and exact+named failure set in the original and Replay stages, plus actual replay test+execution and exit 1. Missing or mismatched replay is `wrong-failure`.++`summarizeRun` accepts a validated selected ledger. It rejects duplicate or+foreign results, preserves selection order, and fills every missing result with+`not-run`. Baseline and control failures are deduplicated and retained at run+level. RunInfrastructure or an infrastructure case gives RunUntrustworthy;+interruption next gives RunInterrupted; any unresolved case or run failure gives+RunFailed. Only all-caught, baseline/control-valid runs pass. CLI exit mapping+belongs to its presentation owner.++The score numerator is caught semantic cases and its denominator adds survived+semantic cases. Other detection kinds never enter it. Zero denominator yields+Nothing. An unresolved case or run failure marks the ratio incomplete, even if+the visible fraction is one. Scores cannot make the CI contract pass.
+ docs/design/compatibility.md view
@@ -0,0 +1,112 @@+# Compatibility evidence++Initial local validation uses GHC 9.12.4 and cabal-install 3.16.1.0 on+aarch64 macOS. The [official GHC release](https://www.haskell.org/ghc/blog/20260327-ghc-9.12.4-released.html)+confirms this is a released compiler. The initial CI matrix requests Linux and+macOS with this compiler; writing that job is not evidence it ran remotely.+GHC2024 and the GHC 9.12 minimum follow the project standard at+mori://shinzui/haskell-jitsurei/docs/core-standards.++Dependency lookup on 2026-10-24 used `mori registry list`, `registry search`,+`registry show --full` and `registry docs`. Source exports were inspected in+mori://haskell/text and mori://haskell/containers (Data.Text and Data.Set;+file-level artifact URIs pending). Their registry records have no curated+guides. Authoritative Hackage preferred-version JSON reports text 2.1.4 and+containers 0.8.1 as the latest normal releases. Upstream Git tags confirm+text `2.1.4` at `423fd981e576bd17a8b5fa48d0ad6b9a0c370e77` and containers+`v0.8.1` dereferenced at `953428ad079d5708df696442f9bdeaccd1acfc6c`.++The initial locally tested dependency set is base 4.21.2.0, text 2.1.4 and+containers 0.7. Bounds admit base 4.21.2 only within its major release,+text 2.1.4 within 2.1, and containers 0.7–0.8. Broader containers bounds use+stable shared operations; they are not a claim that all admitted versions have+been tested. EP-9 owns final bound testing and the advertised release matrix.+No local checkout pin or unreleased dependency is required by Cabal.++Workspace dependencies were checked on 2026-10-24. Hackage reports directory+1.3.11.0 (upstream `v1.3.11.0`, `bf2afc2ddf9d3bcf360ba2eec08d3deb70874c3a`),+process 1.6.30.0 (`v1.6.30.0`, `11fd247ad33208da7a914acf15d4a09d64a6a4c4`),+crypton 2.2.5 (`crypton-v2.2.5`, `74738d1c8da69649f3e629f49a964fe942a9e79a`),+bytestring 0.12.2.0 (`0.12.2.0`, `d984ad00644c0157bad04900434b9d36f23633c5`),+unix 2.8.8.0 (`v2.8.8.0`) and filepath 1.5.5.0. The locally tested workspace+set uses directory 1.3.10.1, bytestring 0.12.2.0, unix 2.8.8.0, filepath+1.5.5.0, process 1.6.26.1 (test helper) and released crypton 2.2.5. Latest+versions and tested versions are deliberately recorded separately.++Mori located source and hashing guidance at mori://kazu-yamamoto/crypton,+mori://haskell/bytestring and mori://haskell/process. The registered+mori://haskell/directory source was absent (FRIC-1 in docs/friction-log), so+System.Directory was inspected from the official v1.3.10.1 tag. Unix and+filepath had no owning registry entries; official tagged source was used for+POSIX file status, temporary directories, signal groups and path operations.++Report support uses released aeson 2.3.2.0, verified against Hackage preferred+versions and upstream tag `v2.3.2.0` at+`682162c66d26a770fbfb6271c797e646bc5c4f2e`. Mori source inspection at+mori://haskell/aeson covered explicit object codecs and the exposed JSON token+stream; token validation rejects duplicate keys before map conversion. The+development-only schema checker uses jsonschema 4.26.0, verified against PyPI+and upstream `v4.26.0` at `c87a92202e986be7e3ae0db992cdc2608bbfd815`.+The Haskell runtime does not depend on that Python tooling.++Optional reporters were checked against released Tasty 1.5.5, tasty-hunit+0.10.2, Hspec/hspec-core/hspec-api 2.11.18 and QuickCheck 2.19.0.0. Mori's+mori://UnkindPartition/tasty source supplied public runner/fold/ingredient APIs.+Its newest core tag is `core-1.5.4` (`0394c73e647834829e7f500c9dab0be3ff9e4f11`),+so the actual Hackage 1.5.5 archive was also inspected rather than assuming tag+parity. `hunit-0.10.2` is `298f69b99b7f1561c93e0eff87dde57be5bbb079`.+Hspec and QuickCheck had no owning Mori corpus entries; authoritative source+at Hspec `2.11.18` (`96242bf996b3eccc10cfe3406c16ffcb519b422d`) and+QuickCheck `2.19` (`3d8fe8686024760006ca1ca656cbdd82cd71f11e`) supplied+the stable V2 formatter and native replay APIs. Hackage preferred versions+were checked separately. STM 2.5.3.1 was inspected at mori://haskell/stm and+verified against `v2.5.3.1` (`48bfb8812633e9b9d4fd1cdeb4c795cefb8f8014`).++The core scheduler now uses the same verified released STM 2.5.3.1 API for+atomic queue/claim transitions; no async framework or application effect stack+is required. Worker processes and resource commands retain the shared POSIX+supervisor. Timing observations and explicit per-build job limits are recorded+in [scheduling](scheduling.md), separately from compatibility claims.++Cabal's current normal Hackage release was independently checked as 3.18.2.0+(upstream `cabal-install-v3.18.2.0`, dereferenced+`45f982c12ef78ea8ec5beb43cb8c71c8e8f237c1`). Local measurements still use+3.16.1.0 (`cabal-install-v3.16.1.0`, dereferenced+`8d1f5a33662be0db0654061fb53fb00e3f4417e0`). The qualified build-tool test+cycle was observed on 3.16.1.0, not asserted to have been reproduced on 3.18.2.0.+Mori supplied solver source at mori://haskell/cabal/packages/cabal-install-solver;+the optional integration boundary also preserves standalone core tests regardless+of the solver version.++The first release admits GHC 9.12.4/base 4.21.2.0 only. This is both the minimum+and newest compiler in the supported range; earlier 9.12 patch releases have+older base versions. The official GHC site also lists 9.14.1, whose base 4.22+is outside the admitted range. GHC 9.14 and Windows are not advertised or+validated by these bounds. Widening compiler support requires a fresh test matrix.++Release validation uses explicit direct dependency sets: the lower set listed+above and a newest-admitted set changing containers to 0.8.1, directory to+1.3.11.0 and process to 1.6.30.0. Other direct lower bounds already equal the+registry's latest admitted releases as checked again on 2026-10-24. Solver+plans retain exact transitive versions and Hackage Cabal/source hashes. Aeson+itself depends on QuickCheck; core has no reachable Tasty, Hspec, CLI or Heni+reporter dependency. Public core modules expose no framework test API.++The archive gate validates actual extracted packages and an independent consumer;+core tests exclude Git/Nix/Mori/OKF/Python from executable search. Optional Git+patches and framework reporters run in separate explicit gates. CI configures+Linux/macOS and lower/newest sets with GHC 9.12.4 and owned-package `-Werror`;+that option is not imposed in published Cabal metadata. Remote CI execution is+not claimed by writing the workflow. Exact locally passed combinations and+archive identities are in the repository's release-readiness record.++| Locally executed platform | Compiler | Cabal | Evidence |+| --- | --- | --- | --- |+| aarch64 macOS / Apple M1 Max | 9.12.4 | 3.16.1.0 | All core, CLI, Tasty and Hspec suites; representative migration trials. |+| aarch64 Linux / Debian 12 container, kernel 6.8.0 | 9.12.4 | 3.14.1.1 | All core, CLI, Tasty and Hspec suites from extracted initial archives. |++The Linux runtime used the official `haskell:9.12.4-slim-bookworm` arm64 image+at digest `sha256:a017c85d1557f6098cfb6356e13a4b9ef16dfd89010cd1708ac4e01251c95402`,+with container init reaping descendants. This is native Linux in a task-owned VM,+not remote GitHub Actions evidence. Final immutable archive/bounds results are+tracked separately in the readiness record; the table above records suite runs.
+ docs/design/process-supervision.md view
@@ -0,0 +1,50 @@+# Process ownership and evidence++`runCommand` launches executable plus literal argv in the workspace (or a+checked relative directory) with explicit environment overrides and removals.+It inherits other environment entries for execution but never records them.+`validateExecutable` provides preflight diagnostics; launch races still become+LaunchFailed evidence. Heni uses a POSIX process group, masked acquisition,+monotonic wall-clock budgets, and two concurrently draining output threads.+Embedding executables must enable the GHC threaded runtime.++The supervisor distinguishes a normal exit, negative process-package signal+status, timeout and cancellation. Shell exit 128+signal is only an exit code.+On every completion path it sends SIGTERM to remaining owned group members,+waits up to the grace budget, then sends SIGKILL and polls direct-child reaping+within another grace budget. This handles a leader exiting while a descendant+retains the pipes, and descendants ignoring SIGTERM. Group shutdown is briefly+uninterruptible around bounded nonblocking polling, so repeated asynchronous+cancellation cannot skip escalation. Output-reader joins and log flushes defer+further cancellation until their bounded cleanup completes.++Each stream retains at most its configured bytes, with total bytes seen and+truncation recorded in LogArtifact. Diagnostics are scanned as UTF-8 bytes+through all chunks with sufficient overlap, independently per stream. Requested+witnesses total at most 64KiB; exact matched witnesses remain in the report even+when the raw log stopped retaining data. Only these byte-proved witnesses enter+StageEvidence.transcript. Display decoding cannot fabricate a match from+invalid UTF-8. A raw log artifact's truncation is distinct from stage evidence+being unavailable. ProcessReport retains primary evidence and secondary reader,+shutdown and log errors. Observer failure terminates the group and produces+infrastructure information.++Asynchronous cancellation raises ProcessAborted with final process evidence and+the original exception. A campaign must finalize its selected ledger before+rethrowing that cause. The library never exits the host program or prints+mandatory output. Arbitrary observers/hooks are trusted IO and must cooperate+with cancellation; hostile uninterruptible code cannot be contained by this API.++Local `heni-process` execution on macOS/GHC 9.12.4 proves literal metacharacter+arguments, exit 1 versus SIGABRT, witness-before-timeout, simultaneous 6.5MiB+stdout/stderr floods capped to 1024 bytes each, cross-chunk witness retention,+malformed UTF-8, leader/descendant pipe inheritance, ignored SIGTERM, observer+failure and cancellation. Its outer watchdog is 20 seconds; shutdown assertions+allow two seconds of CI scheduling tolerance. Linux runs are configured in CI+and remain part of final release evidence.++Descendants deliberately creating a new session/group can escape supervision.+The detached fixture proves this boundary and explicitly kills its owned PID.+Abrupt runner SIGKILL, power loss and uninterruptible kernel operations cannot+execute normal cleanup. Stronger guarantees require an external sandbox.+No process-name search or broad PID sweep is an authorized recovery method.
+ docs/design/reports.md view
@@ -0,0 +1,68 @@+# Campaign evidence and recovery++`mkCampaign` checks cases, commands, references and Haskell edit registrations.+`runCampaign` validates the requested nonempty selected ledger, executable+availability and output location before execution. ConfigurationFailure is+separate from a runtime result. The output directory is exclusively created;+existing evidence is never merged or overwritten by a new run. Runtime IO,+observation and cleanup failures are retained as infrastructure information.++The serial path uses a fresh workspace for every baseline and candidate. A+baseline has the same commands, environment and selector; HENI_SELECTOR carries+the declared selector to a command or adapter. Generic commands must implement+their own selection and do not invent framework counts. Baseline failure stops+its candidate. Build rejection runs the unchanged build successfully first.+Tests run only after a successful candidate build when the expectation requires+them. Every process, including optional Git patch check/apply, uses the same+bounded supervisor. Custom evidence hooks can supply selection/replay facts but+cannot replace actual process outcome, unavailable-evidence flag, diagnostic+witness, duration or log references.++RunOptions provides an observation callback and an explicit workspace cleanup+hook invoked for both baseline and candidate before built-in disposal. Cleanup+errors retain observed candidate stages and invalidate confidence. Hooks are+trusted cooperative IO; arbitrary uninterruptible code is outside the process+supervisor's containment contract. Later resources compose their own lifetimes+with this lifecycle rather than choosing another verdict path.++The selected ledger is written before cases start. Each journal record contains+a sequenced complete ledger snapshot plus effective context, after source+capture, stage observations and case completion. Pending work is explicitly+NotRun. Cancellation finalizes interrupted and queued IDs before rethrowing its+original exception. The final JSON is written to a unique temporary file and+renamed atomically within the owned report directory. This prevents partial+JSON reads; it does not guarantee persistence through power loss or disk refusal.+Final-write failure returns an untrustworthy in-memory result with best-effort+existing journal evidence.++Version-one codecs use explicit keys and tags. Reports include tool/schema+versions, source identity and policy defaults, platform/compiler, definitions,+effective command budgets/output limits, case changes, baseline/candidate+process observations, durations, diagnostic witnesses, optional test counts and+replay, secondary errors, log byte/truncation metadata and relative references.+Source changes record before/after hashes, executable bits and UTF-8 text when+available. Baseline and patch observations live under metadata.caseDetails;+the case result's evidence contains candidate classification inputs.++Environment override values are always redacted in portable configuration;+inherited values are never recorded. Command.sensitiveArguments identifies+zero-based argument indexes whose values are replaced with `<redacted>`.+Redaction is explicit and means the report is not a complete secret-bearing+reproduction recipe. Consumer command output remains retained evidence: callers+must avoid emitting credentials in diagnostics or logs.++`readReport` checks versions, unknown fields, summary accounting, every baseline,+patch and candidate artifact's containment and existence, and symlink components.+Original JSON token streams reject duplicate keys (including escaped equal+spellings) before map conversion; nesting is bounded. `recoverReport` never+executes commands or writes over a report. It reads sequenced journal snapshots,+rejects interior corruption or semantic/schema/sequence errors, and tolerates+only an unfinished final non-newline record whose parser reaches end-of-input.+Recovered reports are explicitly untrustworthy even when their last snapshot+contained completed cases. Missing work never becomes caught. Relative artifact+references remain usable when the whole report directory moves.++The JSON Schema describes wire shape. The decoder additionally checks semantic+relationships, ledger uniqueness and recomputed summary status/score. Schema+validation is development-only Python tooling; neither the Haskell library nor+CLI requires Python. CI validates a real emitted fixture against Draft 2020-12.
+ docs/design/scheduling.md view
@@ -0,0 +1,116 @@+# Resources, workers and conservative reuse++Fresh execution is the reference mode. `RunOptions.workers` defaults to one and+accepts 1–256; CLI `--jobs N` overrides `execution.workers`. Each case gets fresh+baseline/candidate copies unless a Haskell build provider explicitly opts in.+Selection and final report order remain author order, even when independent+cases complete in another order. Journal writes and progress callbacks have one+owner; case evidence is private to its scheduled worker.++Resource names use uppercase ASCII with digits/underscores. Providers choose+campaign, worker or case scope. `managedResource` brackets a consumer value,+returns nonsecret identity/version/epoch plus namespaced environment additions,+and resets before both baseline and candidate. Acquisitions compose in declared+order and release in reverse order, including partial acquisition failure.+Primary and teardown errors are both preserved. A failed resource operation is+infrastructure evidence. Trusted Haskell callbacks must make their own foreign+calls interruptible; Heni cannot promise a deadline for uninterruptible code.++Portable resources use named setup/reset/teardown commands, each with its own+budget and output limit, in a private resource workspace. Example root fields:++```json+{+  "execution": {"workers": 2},+  "resources": [{+    "name": "DB", "scope": "case", "capacity": 1,+    "identity": "local-fixture", "version": "v1", "epoch": "clean",+    "environment": {"HENI_RESOURCE_DB_PORT": "allocated-by-consumer"},+    "setup": "prepare-db", "reset": "reset-db", "teardown": "release-db"+  }]+}+```++Only resources claimed by selected cases are acquired. Each case declares+`resources: ["DB"]`; commands reference ordinary manifest+command definitions. Environment names must start `HENI_RESOURCE_DB_` for this+resource and values are redacted. The consumer supplies actual allocated ports+or paths, reset/teardown semantics and meaningful epochs. An absent epoch is+honest unknown state. A failed acquire must clean up its own partial effects;+Heni releases earlier successfully acquired providers. Workspace deletion does+not roll back a service.++Multiple claims are acquired atomically against campaign-wide capacities, so+opposite declaration orders cannot deadlock. Capacity defaults to one. Worker-scoped capacities also bound the worker pool,+so acquiring idle worker fixtures cannot exceed the declared budget. Higher+capacity is an explicit consumer assertion that simultaneous use/reset is safe.+Finite queued work can pass blocked items to run independent cases; blocked+items become eligible when owners release capacity. Cancellation stops the+queue, terminates owned commands, joins workers, releases providers and+finalizes selected IDs once. These limits coordinate one Heni process only;+independent processes need external locks or separate fixture namespaces.++`execution.baselineReuse: {"toolchain":"ghc/dependency/build-contract"}` opts+into campaign-local baseline sharing. Haskell uses `ReuseBaselines` with the+same nonsecret contract. The key compares snapshot bytes/policy, build/test+commands and arguments, environment overrides and inherited values, selector,+expectation/evidence mode, resource identity/version/epoch and toolchain+contract. Secrets are compared in memory, never exported or weakly hashed.+Structured suites additionally declare the exact producer string as `adapter`;+it must agree with the observed producer. Unknown resource epochs or custom+hooks without identities disable sharing. A consumer opting in asserts that+external inputs are represented and tests do not depend semantically on fresh+run/case/attempt IDs. Reports record owner-case provenance and reasons.++Only passing baselines are shared. One producer serves matching waiters;+cancellation wakes them without publishing success. Failure/cleanup errors+invalidate the cache. A new campaign has no trusted prior verdicts. Resource+identity/epoch/environment must agree between a case's baseline and candidate.+This is deliberately more conservative than caching only source filenames.++Incremental build artifacts require the Haskell-only `BuildReuse` provider.+It declares a build-tool contract, retained `dist`, `dist-newstyle` or+`.heni-build` directories, optional input preparation and a verifier that the+build observed `HENI_BUILD_INPUT_ID`. Each worker owns its own retained tree.+Heni restores exact staged source bytes, verifies source independently before+and after commands, and checks the build observation before tests or accepting an expected compiler+rejection. Command or+environment changes discard retained artifacts. Failed reset, unexpected+generated inputs or an invalid observation retires the tree; its next use starts+with all artifacts removed. Generic commands have no automatic timestamp or+invalidation guarantee, so this provider cannot be serialized as a universal+manifest option. The verifier is trusted consumer integration, not attestation.++`test/scheduler/Main.hs` proves same-size alternating edits with all input mtimes+set to the same epoch. Fresh and retained builds agree; a deliberately stale+build fails observation validation, and the following case starts fresh. It+also proves baseline invocation counts, selector/command/epoch/environment+invalidation, isolated writes, opposite-order claims and cancellation cleanup.++The real Cabal workload is `heni-campaign-bench`, using three fixture cases.+Run fresh and reuse modes repeatedly and compare baseline invocations, wall+time, the sum of build-stage durations, and peak case concurrency separately.+Build-stage time includes Cabal/compiler orchestration; it is not compiler CPU+time. Timing is observational, never a correctness threshold. Each example+uses Cabal `-j1`: budget total build jobs as mutation workers times per-build+jobs, plus declared fixture capacity. Increasing workers does not imply a+universal speedup. Measurements for this checkout follow.+++On 2026-10-24, Apple M1 Max/aarch64 macOS, GHC 9.12.4, Cabal 3.16.1.0:++| Mode | Repeat | Workers | Baseline builds | Wall seconds | Sum build-stage seconds | Peak cases |+| --- | --- | --- | --- | --- | --- | --- |+| fresh | 1 | 1 | 3 | 81.118 | 52.079 | 1 |+| fresh | 2 | 1 | 3 | 81.132 | 51.864 | 1 |+| reuse | 1 | 2 | 1 | 37.707 | 35.152 | 2 |+| reuse | 2 | 2 | 1 | 37.896 | 35.196 | 2 |++Fresh wall-time range was 81.118–81.132s; reuse/two-worker range was+37.707–37.896s. Each run used a new source snapshot/private workspace with the+same warm installed dependency cache. These figures combine baseline sharing+and parallelism, so they do not isolate either effect. Both modes preserved+Caught/Survived/UnexpectedBuildFailure outcomes. Correctness is established by+fresh parity and 3→1 baseline counts, not by these timing differences. The sum+of stage times is work across processes and can exceed wall time when tasks+overlap. This small fixture does not predict consumer-project speedups.
+ docs/design/workspaces.md view
@@ -0,0 +1,58 @@+# Source capture and checked edits++`withSnapshot` reads selected working-tree files twice and rejects an observed+inventory change. This includes dirty edits and untracked regular files; it+does not read Git HEAD. Default exclusions at every depth are `.git`, `dist`,+`dist-newstyle`, `cabal-dev`, `.direnv`, `result`, `result-*`,+`mutation-results`, `.heni`, `.heni-*` and `.DS_Store`. Explicit relative+inclusion subtrees and exclusion subtrees are recorded in SnapshotPolicy.+Included names must use normalized relative paths. Symlinks and special files+are rejected, including a symlink source root. Select a smaller source root or+explicitly exclude unsupported inputs. Binary files are preserved unchanged.++Snapshot bytes are immutable in-memory ByteStrings. SHA-256 covers sorted,+length-framed relative names, executable bits, contents and policy metadata.+No later operation can mutate a snapshot through a filesystem path. Capturing+twice detects ordinary changes; it is not an atomic OS snapshot against+adversarial concurrent writers. Large source trees currently cost memory+proportional to their selected bytes. Streaming/disk-backed snapshots are a+future optimization, not a different identity contract.++`withWorkspace` creates a unique private POSIX temporary directory, materializes+fresh writable files, and removes only that owned directory on exit. Copies+are never hard links. Build directories are independent. An output directory+must be outside the source root or excluded by the effective policy; ancestors+and the source root itself are never valid output locations. Canonical paths+are checked before accepting this relationship.++`withEditedWorkspace` applies the complete ordered edit sequence to one private+candidate. Its callback is invoked only after all edits and an independent+full inventory succeed. A replacement decodes strict UTF-8, checks the exact+non-overlapping anchor count, and preserves all other bytes. Create requires+absence; Delete requires exact old UTF-8 contents. A mutation must change final+bytes or executable bits. A sequence that undoes itself is invalid. An explicit+Control must remain unchanged. FileChange records actual before/after contents+and executable bits, including additions and removals.++Custom edit hooks receive checked read/write/create/delete operations. Every+existing path component is checked without following symlinks before an+operation; the final inventory rejects links introduced by hooks. Mutations of+runner-owned build/evidence paths are rejected. Trusted arbitrary Haskell IO+or consumer subprocesses can escape these capabilities. This API is not an OS+sandbox and does not defend against hostile concurrent link replacement.++Optional Git patches use a supplied bounded PatchRunner: the process owner+provides the production implementation. EP-2 tests use a timeout-bounded helper.+Git is unnecessary for replacement, create/delete or custom edits. The accepted+subset is unquoted `diff --git a/path b/path` textual patches, with matching+names, normal headers and ordinary file creation/deletion. Binary, rename,+copy, symlink and mode-change patches are rejected. All diff and old/new header+paths are checked, including `/dev/null` only as the absent file header. Git+checks before applying; failure discards the entire candidate even if a tool+partially wrote it. Patch content lives in a separate owned temporary directory.++EditError distinguishes InvalidEdit from EditInfrastructure. Stale anchors and+unsafe patch definitions are invalid; ordinary permission and missing-tool IO+failures are infrastructure. Cleanup exceptions remain observable. Reset only+operates on an opaque owned Workspace and independently verifies pristine+bytes; any failure invalidates reuse. Initial campaigns use fresh copies.
+ docs/initial-spec.md view
@@ -0,0 +1,357 @@+# Heni initial specification++Status: proposed design, 2026-10-24. There is no implementation yet. Command+examples, module boundaries, and manifest fields below describe the intended+interface and must be exercised in a prototype before becoming a released API.++## 1. Purpose and audience++Heni is a Haskell library and CLI for running deliberate mutations and verifying+that tests catch the intended faults. It helps authors answer: “Does this test+actually protect the behavior I care about?”++The intended users are Haskell package maintainers, application teams, compiler+and DSL authors, and maintainers of validators or evidence checkers. A user+should be able to add one useful mutation without first adopting a new test+framework or infrastructure stack.++Success means less runner code, more precise failure attribution, and mutation+evidence that another contributor can reproduce. A mutation score alone cannot+establish that a program is correct.++## 2. Distribution and portability++Publish a library package, `heni`, and a CLI package, `heni-cli`, whose executable+is named `heni`. Keep command-line parsing and terminal presentation dependencies+in the CLI package. Keep the core's dependency set small and its public types+independent of any one test framework. Verify package-name availability before+the first upload.++Both packages must build from their Hackage source distributions with Cabal and+released dependencies. Git, Nix, OKF, Mori, remote builders, and services such as+Kafka or PostgreSQL are optional integrations or consumer-supplied test resources.+They must not be required to use the generic library or run its core test suite.+The patch adapter may require Git and must declare that requirement explicitly.++The initial supported execution platforms are Linux and macOS. Path handling and+process supervision must be designed so Windows support can be added; document+platform limits explicitly until that support is tested. Select supported GHC+versions and dependency bounds from successful CI builds and current upstream+releases before publication. This specification does not invent those bounds.++## 3. First-release scope++The first release must support:++1. Curated source or fixture mutations with stable IDs and a human explanation.+2. Patch application, checked UTF-8 text replacement, and a Haskell API for+   custom transformations in an isolated workspace.+3. An unchanged baseline, explicit build/check and test stages, and nonempty+   test selection where the expectation names tests.+4. Precise expected failures, bounded subprocesses, and complete result records.+5. A library and CLI with identical execution and classification semantics.+6. Versioned JSON manifests and reports, readable terminal output, and CI exits.+7. Optional property-test replay hooks and explicitly declared crash probes.++Automatic AST mutation generation, a GHC plugin, distributed scheduling,+coverage-derived test selection, and framework-specific in-process test runners+are later extensions. Define seams for them without making them prerequisites+for the initial execution core. Most transformations and diagnostic expectations+remain in the consumer's repository.++Small invalid-input unit tests can continue to use their existing test framework.+Heni earns its place when a case needs mutation application, isolation, staged+execution, attribution, or durable evidence.++## 4. Core model and public library++Use the `Test.Heni` namespace. The top-level module should expose a small,+documented path from defining a case to obtaining a structured result. Proposed+internal responsibilities are:++| Responsibility | Contract |+| --- | --- |+| Case definition | Stable ID, description, tags, edits, stages, selection, expectation, and budgets. |+| Workspace | An immutable source snapshot and a disposable mutable workspace. |+| Edit | Checked transformations with explicit preconditions and touched files. |+| Process | Structured executable/arguments, working directory, environment overrides, budgets, and termination. |+| Expectation | A predicate over stage, exit/signal, selection evidence, and diagnostics. |+| Runner | Baselines, controls, scheduling, reset, cancellation, and classification. |+| Report | Versioned serializable results and evidence references. |++Prefer concrete data types and functions for common cases. Custom edit and+expectation hooks must return structured errors and evidence. Keep process,+workspace, and test-framework details behind explicit interfaces; avoid a large+typeclass hierarchy before multiple real consumers establish the need.++The CLI must call this library. It must not maintain a second outcome classifier.+The library must return results without calling `exitWith` or printing mandatory+terminal output. Supply a progress callback or event stream for callers that+want live feedback. Asynchronous cancellation must still terminate owned+children and close evidence files.++Haskell-only hooks may exceed the portable manifest vocabulary. The API must+describe that boundary honestly; every built-in manifest feature must have a+library equivalent, but arbitrary Haskell functions need not serialize to JSON.++## 5. Execution contract++For each selected case, the runner must:++1. Validate the manifest, referenced commands, paths, IDs, selectors, and budgets.+2. Snapshot the declared source inputs and record their identity. Define whether+   the snapshot includes committed files, dirty edits, and untracked files;+   never silently test HEAD when the user selected working-tree contents.+3. Prepare a disposable workspace with its own writable build directory and any+   declared resource setup. Record the effective build configuration.+4. Build and run the unchanged baseline for the same selection and configuration+   the mutant will use. Verify that the selected tests exist and pass.+5. Restore the pristine snapshot, apply exactly this case's edit sequence, and+   verify that the mutation changed the intended files.+6. Build or check the mutated candidate. Run the selected test command only when+   the declared expectation requires it and the preceding stage succeeded.+7. Evaluate the expected outcome using structured stage and failure evidence.+   Run any declared replay step, preserving the original and replay outcomes.+8. Write the case result and logs, then reset or discard the workspace. A failed+   reset invalidates reuse of that worker; continue only in a fresh workspace.++An unchanged baseline may be reused for cases with the same snapshot,+configuration, environment contract, and selector. An incremental build directory+may be reused within an isolated worker if every case starts from identical+source bytes and the build tool observes restored inputs correctly. Separate+workers must not share a writable candidate tree or build directory.++Support explicit no-op control cases. A no-op must leave bytes unchanged and its+tests must pass; controls are the sole exception to the changed-bytes rule. A+failed or timed-out control invalidates the run's confidence in its detections.+Baseline success and controls reduce false attribution but do not prove absence+of flakiness.++Source snapshots must exclude run outputs and build artifacts by default. The+report records inclusion/exclusion rules and the manifest, tests, configuration,+and runner version used. Cleanup must preserve logs needed to explain failures.+Heni must never restore a backup over a user's active checkout.++## 6. Edits and workspace boundaries++Text replacements declare a target file, old text, new text, and required match+count. The default match count is exactly one. Missing or ambiguous anchors and+an empty change are invalid mutations, not survivors or successful detections.+Apply multi-edit cases in a declared order; later edits observe earlier edits.++Patch application must be checked and atomic with respect to the case workspace.+A partially applied patch must not contaminate the next case. File creation and+deletion must have preconditions and appear in the recorded diff. Invalid UTF-8+must produce an explicit edit error; binary edits require a separate API.++All file mutations and working directories must stay inside the disposable+workspace. Reject absolute mutation targets, parent traversal, and symlink+escapes. Execute commands as an executable plus argument list, without implicit+shell interpolation. Shell scripts remain available through an explicit shell+command. A workspace isolates files; it is not an operating-system sandbox for+consumer-supplied commands or a way to roll back external service effects.++## 7. Expected failures and verdicts++The default semantic expectation is: the candidate builds, a nonempty selection+executes, and the named assertion fails. A plain nonzero subprocess exit is+insufficient. Standard test adapters should provide test IDs, selected/executed+counts, and named failures. Generic command expectations must declare the+accepted exit code and a specific diagnostic witness, and document their weaker+ability to distinguish assertion failure from setup failure.++| Result | Meaning |+| --- | --- |+| `caught` | The declared fault was detected through the required stage and witness. |+| `survived` | Valid mutation, passing baseline, and executed selection, but the required detection did not occur. |+| `invalid-mutation` | Edit preconditions failed, the patch is stale, or a required change was absent. |+| `uncovered` | A required selector matched no tests, or execution evidence shows the intended selection did not run. |+| `unexpected-build-failure` | A semantic mutant did not reach its test stage. |+| `wrong-failure` | The command failed through a different test, diagnostic, exit, or signal. |+| `timed-out` | A stage exceeded its declared budget. |+| `infrastructure-error` | Preparation, resource setup, process launch, reporting, or cleanup failed. |+| `not-run` | A selected case was skipped because execution stopped or its baseline failed. |++Record baseline and control failures as run-level failures with affected cases.+Every selected case must appear exactly once in the final report, including+cases that never started. An invalid configuration is a configuration error,+with a diagnostic naming the field and case where possible.++An intentional build-rejection case must declare the build/check stage and+expected diagnostic. Report it as `caught` with detection kind+`expected-build-rejection`, separately from semantic test kills. An unrelated+compiler error does not satisfy that expectation.++A crash expectation must declare the accepted signal and a case-specific probe+showing that the intended native operation was reached. Generic crashes and+runner-enforced termination never count as successful semantic detections.+Timeouts remain distinct outcomes. Future hang-detection features require a+separate explicit contract.++For property cases, record the generator configuration and replay token. When+replay is required, the counterexample must reproduce the same named failure;+otherwise classify the result as `wrong-failure` with replay evidence.++## 8. Commands, adapters, and resources++The generic command interface is the baseline integration. Supply Cabal-oriented+examples and optional adapters for Tasty and Hspec. The generic core must not+depend on those test libraries or require migrating consumer test suites.++Each stage has a wall-clock budget, output policy, working directory, and+environment overrides. Distinguish a normal exit, signal termination, and+runner-enforced timeout. On timeout or interruption, terminate and reap the+owned process group where supported, using a bounded grace period. Specify and+test descendant-process behavior on each supported platform.++Setup/teardown hooks and resource concurrency limits must be explicit. Consumers+own broker, database, and other external fixtures. Parallel execution is opt-in+for cases using shared services unless the user declares independent resource+names or an appropriate concurrency limit. Avoid multiplying Cabal build jobs+by mutation-worker jobs without a documented resource budget.++## 9. CLI and portable manifests++The proposed command surface is:++```text+heni check heni.json+heni list heni.json+heni run heni.json --case guard-bypass --output mutation-results+heni run heni.json --tag regression --jobs 2 --output mutation-results+heni report mutation-results/report.json+```++`check` validates the definition and edit applicability without running mutation+tests. It must clearly distinguish static checks from test-discovery or baseline+execution, which may need a build. `list` explains IDs, tags, selectors, and+expectations. `run` records the selected cases and supports cancellation.+`report` renders stored evidence without rerunning commands. Selecting nothing+must be explicit in the output and fail `run` by default.++Use JSON as the initial interchange format, with a published JSON Schema and a+top-level `schemaVersion`. Reject unsupported versions and unknown keys to catch+typos. Commands are reusable named definitions; cases reference them by ID.+Describe defaults in the schema and include the effective values in reports.++This illustrative case uses a generic diagnostic expectation. It does not+provide the richer test-count and named-assertion evidence of a test adapter:++```json+{+  "schemaVersion": 1,+  "commands": {+    "build": {"executable": "cabal", "arguments": ["build", "example-tests"], "timeoutSeconds": 120},+    "test": {"executable": "cabal", "arguments": ["test", "example-tests", "--test-show-details=direct"], "timeoutSeconds": 30}+  },+  "cases": [{+    "id": "guard-bypass",+    "description": "Removing the capacity guard must be detected.",+    "tags": ["regression"],+    "edits": [{"kind": "replace", "path": "src/Capacity.hs", "old": "used < limit", "new": "True", "occurrences": 1}],+    "build": "build",+    "test": "test",+    "expect": {"kind": "diagnostic", "stage": "test", "exitCode": 1, "outputContains": "rejects full capacity: FAIL"}+  }]+}+```++Run the unchanged command with the same selector before using this expectation.+The example is a design sketch; release examples must be runnable and checked+against the actual manifest decoder and JSON Schema.++Proposed exit contract: `0` means every selected case met its expectation and+baselines/controls passed; `1` means a completed run failed that contract; `2`+means configuration or infrastructure prevented a trustworthy complete run.+On interruption, use the platform's documented interruption convention and+retain a partial report. Infrastructure errors take precedence over detected+survivors when selecting the overall exit status.++## 10. Reports, selection, and efficiency++The report must include schema and tool versions, source snapshot identity and+dirty-state policy, effective configuration, platform/tool versions, selected+IDs, case descriptions, applied diffs, stage durations, commands, outcomes,+diagnostic witnesses, selected/executed test counts when available, replay data,+and relative log paths. Record external resource versions when supplied by the+consumer. Do not dump the full inherited environment or credentials into reports.++Write progress and case results incrementally, then finalize the summary+atomically. Failed and cancelled runs must retain useful evidence. Allow output+retention and log-volume limits, but never silently truncate the witness used to+justify a detection. Reports should be portable with their referenced log files.++Start with explicit case and tag selection. Later changed-file selection must+account for tests, fixtures, the runner configuration, and source changes. When+impact cannot be established, rerun conservatively; a source-only filter can+miss a weakened test. Coverage-driven selection requires versioned coverage+provenance and must not silently drop missing mutation IDs.++Always show result counts and incomplete cases. If a semantic mutation score is+displayed, define it as caught semantic cases divided by caught plus survived+semantic cases, alongside the separate counts for invalid, uncovered, timed-out,+wrong-failure, and unrun cases. A score with unresolved cases must be labeled+incomplete and cannot make the CI gate pass. Report expected build rejections,+controls, and explicit reviewed exclusions separately. Do not automatically+declare an undetected mutation equivalent.++## 11. Validation and release criteria++The runner's own tests must cover false-positive classifications, baseline and+control failure, empty selection, ambiguous/no-op edits, partial patch failure,+restoration, timeout and cancellation cleanup, wrong named failures, replay+mismatch, partial reports, and worker independence. Use small self-contained+fixture packages or subprocesses; heavyweight services belong in optional+integration suites.++Before the first public release:++1. Demonstrate a useful case from a fresh standalone Cabal project using only+   released dependencies, through both the library and the CLI.+2. Exercise representative patch, replacement, named-test, diagnostic,+   intentional build-rejection, and replay cases. Run baseline/control checks+   and prove that wrong failures cannot produce a successful result.+3. Provide a quickstart, runnable examples, complete Haddock for exposed+   modules, manifest/report schemas, command help, and a troubleshooting guide+   for every outcome. The first successful mutation should take only a small+   manifest or a short Haskell definition.+4. Run `cabal check` for each package; build and test from extracted `cabal sdist`+   archives; build Haddock; verify examples and all required fixtures are in the+   archives. Validate against the advertised Linux/macOS and GHC matrix.+5. Include accurate package metadata, maintainer/contact and source links,+   changelogs, and a maintainer-selected open-source license and license file.+   Follow the Haskell Package Versioning Policy for public API changes. Choose+   tested dependency bounds and document compatibility.+6. Verify intended Hackage names, prepare candidate uploads, and review their+   rendered documentation and installability before publication. Publication+   itself is a later release action.++These are Heni's release quality criteria. Cabal's official+[packaging guide](https://cabal.readthedocs.io/en/stable/how-to-package-haskell-code.html)+and [command reference](https://cabal.readthedocs.io/en/stable/cabal-commands.html)+describe package construction, checks, source distributions, documentation, and+uploads; the [PVP](https://pvp.haskell.org/) defines versioning policy.++## 12. Delivery order and evidence of value++First implement the result model and classification fixtures, then isolated+workspaces and checked edits, followed by generic process execution. Add the+portable manifest and CLI through the same library entry point. Introduce test+adapters and replay only after the core can demonstrate the full baseline to+report cycle.++Validate extraction against the patterns in [RES-1](research/mutation-testing-survey.md):+one compact replacement runner, a patch catalogue, a generated-code mutation,+and an external-command diagnostic case. Preserve the specific expectation and+compare outcomes before and after migration. Keep these consumer migrations as+separate work from bootstrapping this repository.++Measure new-case authoring effort, shared versus consumer-specific runner code,+failure diagnosis effort, and elapsed build/test time independently. The initial+claim is reduced orchestration and more trustworthy verdicts; runtime speedups+must be measured. Keep unsupported cases and integration costs in the results.++The maintainer selected BSD-3-Clause on 2026-10-24. Open design decisions include the final GHC matrix, exact+adapter output protocols, patch implementation, resource-hook API, and which+report fields become stable in version 1. Resolve them with prototypes and real+consumer evidence before committing to a broad public abstraction.
+ docs/migration-guide.md view
@@ -0,0 +1,90 @@+# Moving a mutation script to Heni++Keep the fault and its assertion in the consumer project. Move snapshotting,+checked edits, baselines, subprocess ownership, attribution and reporting into+Heni. Start with one meaningful case and a precise witness; migrate a service+fixture only after its owner supplies reset and teardown semantics.++The local examples are independently authored BSD-3-Clause fixtures. No code+or license grant was copied from the surveyed consumers, and their repositories+were not modified or executed. The source inventory was refreshed through Mori+on 2026-10-24; exact HEAD/file hashes are in+[the identity record](validation/migration-source-identities.json). The rdkafka+runner changed after RES-1: its refreshed file has 453 lines rather than the+historical 349. Neither count estimates removable orchestration. File-level+Mori artifact URIs are pending; each relative source path below is paired with+its canonical owner.++| Local family | Surveyed pattern | Fault and witness |+| --- | --- | --- |+| [replacement](../examples/migrations/replacement/heni.json) | [rdkafka-hs](mori://shinzui/rdkafka-hs/packages/rdkafka-hs), `scripts/test-mutations.hs`; [Shibuya](mori://shinzui/shibuya), `scripts/audit/run-websocket-mutation-controls.py` | Replace one capacity guard; require the full-capacity diagnostic. |+| [patch catalogue](../examples/migrations/patch-catalogue/heni.json) | [Nagare](mori://shinzui/nagare), `cli/nagare-harness/src/Nagare/Harness/Mutation.hs` and `cli/nagarectl/test/mutations/records.json` | Apply a named checked textual patch; stale patches are invalid mutations. |+| [generated code](../examples/migrations/generated-code/heni.json) | [Keiro DSL](mori://shinzui/keiro/packages/keiro-dsl), `keiro-dsl/test/history-dependent-holes-mutation-test.sh` | Mutate generator input, generate, compile, then require the generated guard assertion to fail. |+| [diagnostic checker](../examples/migrations/diagnostic-checker/heni.json) | The external-rule checker pattern in [RES-1](research/mutation-testing-survey.md) | Change capacity policy; require exit 1 and the exact capacity-rule diagnostic. |++Each checked-in default manifest selects an intended-caught case. The trial+adds a deliberately surviving label change through both independent Haskell+case definitions and the portable manifest. Replacement also distinguishes an+unrelated compile error from an intentional compiler-rejection expectation.+Use a new output directory for every invocation:++```bash+cabal run heni-cli:exe:heni -- run examples/migrations/replacement/heni.json --output .heni-runs/migration-replacement+cabal run heni-cli:exe:heni -- run examples/migrations/patch-catalogue/heni.json --output .heni-runs/migration-patches+cabal run heni-cli:exe:heni -- run examples/migrations/generated-code/heni.json --output .heni-runs/migration-generated+cabal run heni-cli:exe:heni -- run examples/migrations/diagnostic-checker/heni.json --output .heni-runs/migration-diagnostic+```++These examples need released GHC/Cabal, and only the patch family needs Git.+They require no database, broker, Mori, OKF or Nix at runtime. The checker builds+`./dist-newstyle/checker`; availability of explicit workspace executables is+checked after build rather than against the caller's active directory.++For generated code, identify what the edit changes. This fixture edits+`model.txt` before `tools/Build.hs` creates `generated/Generated.hs`. The build+log records the generated definition and the test executes that compiled+module. The test command never regenerates it. Editing generated output before+a build that overwrites it can produce a survivor without testing the intended+fault. Use generator input where possible; otherwise supply a consumer-owned+build/edit workflow that preserves and verifies the generated artifact.++A Haskell consumer can replace a declarative edit with `CustomEdit` and a+`CaseHooks.customEdits` function using checked `WorkspaceOps`. The generated+trial demonstrates that boundary while its CLI equivalent uses `Replace`.+The hook owns the transformation only. It does not copy/restore a checkout,+spawn cleanup loops, or decide the mutation verdict. Arbitrary Haskell hooks+and build observation verifiers cannot be serialized into a manifest.++| Capability | First-release contract |+| --- | --- |+| Checked UTF-8 replacement, create/delete | Direct Haskell and manifest support, ordered exact preconditions. |+| Textual patch catalogues | Optional Git; no rename/copy/mode-only/binary patch claim. |+| Generated artifacts | Input mutation is direct; output ordering/preservation needs a consumer-owned workflow or hook. |+| Generic external checker | Exact stage, exit and byte witness; test counts remain unknown. |+| Named assertions | Optional Tasty/Hspec reporters, complete attempt-bound IDs/counts, no colored-output scraping. |+| Property replay | Native QuickCheck helper with Hspec, one selected replay property per attempt; other native protocols need typed builders. |+| Native crashes | POSIX signal plus a fresh case/stage probe; reachability does not establish arbitrary root cause. |+| Controls and compiler rejection | Explicit separate expectations; an unrelated compiler failure cannot satisfy a semantic assertion. |+| Multi-stage commands | Build/test/replay are supervised; consumer generation/setup commands remain explicit. |+| Shared services | Scoped resources, namespaced bindings, reset/teardown, capacities and meaningful epochs; live service proof belongs to the consumer. |+| Workers and reuse | Private trees, bounded claims; opt-in compatible baselines. Incremental builds need a verified Haskell provider; no universal Cabal timestamp guarantee. |+| Binary files | Snapshot/copy support; no first-release binary mutation vocabulary. |+| Database-state fault injection | External consumer fixture work; filesystem isolation does not roll back a database. |+| Detached processes/cross-process locks | Consumer ownership; the in-process scheduler cannot coordinate separate campaigns. |+| AST operators, distributed execution, persistent verdict cache, Windows | Unsupported in this release. |++Run the combined trial with `sh scripts/test-migrations.sh`. It separately builds+the CLI, then enables the core archive's `migration-tests` flag with+`HENI_CLI_EXE`. Default core tests have no CLI/framework dependency. Repository+release CI runs this explicit integration gate as well as the optional reporter+packages. The Haskell comparison harness in `test/migration/Legacy.hs` is an+independently authored finite measurement fixture with deliberately exit-only+verdicts, not a recommended runner or a copy of production scripts.++The [value record](validation/migration-results.md) separates executable parity,+consumer definitions/domain helpers, lifecycle scaffolding, automated authoring+and diagnosis actions, and elapsed build time. Automated agent observations are+not a human usability study. Full consumer migrations remain later work: start+with one replacement in its owning project, then a small patch batch, generated+workflows with explicit artifact ordering, and finally live resource cases with+consumer-owned acceptance. No fleet migration has occurred here.
+ docs/quickstart.md view
@@ -0,0 +1,54 @@+# First caught mutation and survivor++Use GHC 9.12.4 and Cabal with released dependencies. From this checkout, build+the packages normally; Nix and repository indexing tools are optional developer+tooling. Heni is currently unreleased, so Hackage installation is a later step.++First observe the unchanged target passing:++```bash+cd examples/minimal+cabal test fixture-test --offline -j1 --test-show-details=direct+cd ../..+```++Then check the manifest's structure and edit applicability. This command does+not run tests or discovery:++```bash+cabal run heni-cli:exe:heni -- check examples/minimal/heni.json+```++Run one capacity-guard mutation. Heni first builds/tests the unchanged snapshot,+then edits and tests a separate disposable copy:++```bash+cabal run heni-cli:exe:heni -- run examples/minimal/heni.json --case guard-bypass --output .heni-runs/quickstart-caught+```++Expect `caught` and exit 0, with the precise full-capacity assertion diagnostic.+The report and logs are under the chosen output directory. A harmless marker+change demonstrates a survivor:++```bash+cabal run heni-cli:exe:heni -- run examples/minimal/heni.json --case survivor --output .heni-runs/quickstart-survivor+```++Expect `survived` and exit 1. A survivor means the valid change was not detected;+it does not establish equivalence. Use fresh output paths when repeating these+commands. `heni report PATH/report.json` renders evidence without rerunning it.++For Haskell, [Definitions.hs](../examples/library/Definitions.hs) shows short+case definitions and [Main.hs](../examples/library/Main.hs) calls the same+`runCampaign` API. Construct options with `defaultRunOptions` and update named+fields. The library returns `RunResult` and leaves application exits/rendering+to the caller:++```bash+cabal run heni:exe:heni-library-example -- --output .heni-runs/quickstart-library+```++That example returns caught, survivor and unrelated compiler-failure evidence.+See [CLI usage](cli.md), [migration patterns](migration-guide.md),+[named adapters and replay](design/adapters.md), and+[resource ownership](design/scheduling.md) for stronger integrations.
+ docs/releasing.md view
@@ -0,0 +1,71 @@+# Preparing a release++The bootstrap produces local source/docs archives and a readiness record. It does+not upload candidates, publish packages, push commits or create tags. Recheck the+[readiness record](https://github.com/shinzui/heni/blob/master/docs/validation/release-readiness.md) and current package-name state+before a later release action. An absent package name is not a reservation or an+ownership grant; verify the intended Hackage account's upload permissions.++The archive gate requires released cabal-install 3.16.1.0 or newer for its+selective Haddock options. Core build/runtime tests also passed with 3.14.1.1,+but that version hides the requested implementation pages. Use the tested+GHC/Cabal combinations in [compatibility](design/compatibility.md).+The four packages share version 0.1.0.0; CLI/reporters require that exact core+version. Public API versioning follows the [PVP](https://pvp.haskell.org/): breaking+API/type/instance changes increase A.B, additive APIs increase C, and fixes may+use the fourth component. JSON schema versions evolve independently. Do not use+`--allow-newer` to replace validation of admitted dependencies.++Run package checks in each package directory and then validate fresh source+archives. Development-only schema validation requires jsonschema 4.26.0.++```sh+cabal build all -j1+cabal test all -j1 --test-show-details=direct+sh scripts/test-migrations.sh+HENI_SCHEMA_PYTHON=/path/to/schema-venv/bin/python sh scripts/check-sdist.sh --bounds lower --output dist-release/lower+HENI_SCHEMA_PYTHON=/path/to/schema-venv/bin/python sh scripts/check-sdist.sh --bounds newest --output dist-release/newest+```++The script records the fresh extraction path, source archive hashes, compiler,+Cabal, each check's exit/duration and solver plans. It retains failed extractions+for diagnosis. Only extracted Heni packages are project sources; other dependencies+come from released repositories. Core-only tests have no CLI/framework closure+or Git/Nix/Mori/OKF/Python executable in their PATH. A separate positive patch gate+requires Git. CLI/reporters, Haddock, schemas, standalone 0/1/2 exit checks and the+four-family migration trial run from archives. Use `--archives DIRECTORY` to+validate an existing immutable archive set on another machine.++The script selectively generates two non-exposed implementation pages so+Haddock's instance-origin links resolve. Their `not-home` attributes keep public+facades as documentation homes; this does not expose package modules or private+constructors. Use the script's Haddock flags when rebuilding release docs.++Inspect generated module pages and source links, command help, examples,+troubleshooting, package contents and dependency plans. Copy accepted Haddock+archives from the fresh extraction's dist-newstyle tree beside the source+archives; record their hashes. Never modify an accepted archive after hashing:+rebuild and rerun relevant gates after source changes. The readiness record and its hashes are repository-only evidence, excluded+from source archives to avoid self-referential archive hashes. Keep those+results under version control; retain large build logs and local tarballs outside+Git. Confirm no credential or secret environment value appears in evidence.++For the separately authorized Hackage step, consult the current official+[Cabal packaging guide](https://cabal.readthedocs.io/en/stable/how-to-package-haskell-code.html)+and [command reference](https://cabal.readthedocs.io/en/stable/cabal-commands.html#cabal-upload).+As checked on 2026-10-24, `cabal upload SOURCE.tar.gz` uploads a candidate by+default; `--publish` immediately publishes. `cabal upload --documentation DOCS.tar.gz`+uploads candidate documentation. Keep authentication in the user's normal secure+Cabal configuration; no credential is part of this validation script or record.++Upload candidates only after that action is authorized. Review each candidate's+rendered documentation, source contents, license, dependency bounds and installability+with all matching Heni candidate archives in a fresh project. A candidate cannot+make another unpublished core version a normal released dependency. Coordinate+publication in dependency order (core, then CLI/reporters) after candidate review;+verify normal Hackage installation and rendered docs afterward. Candidate review,+account authorization, publication and release tags remain explicit handoff items.++Actual consumer migrations are a separate roadmap in [the migration guide](migration-guide.md).+Each owning project must preserve its fault, witness and live resource/reset+contract; local representative fixtures do not establish those migrations.
+ docs/research/ecosystem-mutation-testing-design.md view
@@ -0,0 +1,228 @@+---+type: Research Document+title: Mutation-testing ecosystem lessons for Heni's API and execution model+description: Compare mutation tools across ecosystems and derive bounded, testable design choices for Heni.+generated:+  by: process:codex+  at: "2026-10-24T14:36:00Z"+researchId: RES-2+status: complete+reviews:+  - kind: model+    reviewer: process:codex+    reviewed_at: "2026-10-24T14:53:00Z"+    document_timestamp: "2026-10-24T14:36:00Z"+    scope: content-and-metadata+    outcome: commented+    provider: openai+    model: gpt-6-astra+    effort: unspecified+    context: >-+      Author self-review of primary-source comparisons, canonical references,+      adoption inferences, and prototype/evidence limits. No tool benchmarks,+      implementation verification, or independent approval is claimed.+scope: >-+  Documentation review of cargo-mutants, StrykerJS, Stryker.NET, PIT, and+  mutmut on 2026-10-24, combined with Heni RES-1 and selected local Haskell+  standards and architectural decisions. No comparative benchmarks or tool+  executions were performed. This record informs prototypes; it does not+  certify APIs, package compatibility, or productivity improvements.+sources:+  - id: cargo-baseline+    resource: https://mutants.rs/baseline.html+    title: cargo-mutants baseline tests+  - id: cargo-results+    resource: https://mutants.rs/using-results.html+    title: cargo-mutants result interpretation+  - id: cargo-timeouts+    resource: https://mutants.rs/timeouts.html+    title: cargo-mutants hangs and timeouts+  - id: stryker-states+    resource: https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/+    title: Stryker mutant states and metrics+  - id: stryker-incremental+    resource: https://stryker-mutator.io/docs/stryker-js/incremental/+    title: StrykerJS incremental execution and limitations+  - id: stryker-workers+    resource: https://stryker-mutator.io/docs/stryker-js/parallel-workers/+    title: StrykerJS worker resource conflicts+  - id: stryker-net+    resource: https://stryker-mutator.io/docs/stryker-net/configuration/+    title: Stryker.NET coverage and baseline configuration+  - id: pit-basics+    resource: https://pitest.org/quickstart/basic_concepts/+    title: PIT operators, coverage, and results+  - id: pit-incremental+    resource: https://pitest.org/quickstart/incremental_analysis/+    title: PIT incremental analysis assumptions+  - id: mutmut+    resource: https://mutmut.readthedocs.io/en/latest/+    title: mutmut usage, selection, and process isolation+  - id: haskell-standards+    resource: mori://shinzui/haskell-jitsurei/docs/core-standards+    title: Haskell core standards+  - id: records+    resource: mori://shinzui/haskell-jitsurei/docs/core-record-patterns+    title: Record conventions and transitive orphan-instance exposure+  - id: prelude+    resource: mori://shinzui/haskell-jitsurei/docs/core-custom-prelude+    title: Small project preludes and import boundaries+  - id: library-boundary+    resource: mori://shinzui/mori-app/okf/adrs/concepts/ADR-1+    title: A base library owns transport and delivery, not the domain effect stack+  - id: cli-boundary+    resource: mori://shinzui/keiro/okf/adrs/concepts/ADR-28+    title: Operator commands wrap supported library APIs and respect schema ownership+---++# Mutation-testing ecosystem lessons for Heni's API and execution model++## Question and evidence boundary++Which ideas from established mutation tools help Heni replace bespoke orchestration+without losing the precise fault expectations identified in+[RES-1](mutation-testing-survey.md)? The research index (`docs/research/index.md`) is the entry+point for both records. RES-1 supplies the local use-case evidence; this record+adds cross-ecosystem design comparisons before the public API is implemented.++The sources below are primary project documentation, consulted on 2026-10-24.+Their URLs describe evolving products rather than pinned releases. No tool was+installed or benchmarked for this comparison. Claims about speed, completeness,+or superiority are consequently not established. Dependency versions and bounds+must be checked against Hackage and upstream tags when selected, not inferred+from either these pages or the local Mori corpus.++## Observations and implications++| Tool | Documented behavior | Proposed Heni response |+| --- | --- | --- |+| cargo-mutants, Rust | Runs an unchanged baseline in a temporary copy, then distinguishes caught, missed, unviable, and timeout outcomes. Build and test timeout controls are separate. | Adopt the baseline/candidate stage boundary and explicit budgets. Require the declared witness as well as a failed command. |+| StrykerJS and shared reporting | Provides separate mutant states, partial incremental results, and reuse informed by changes to source and tests. Its score counts timeout as detection. | Adopt durable per-case evidence and distinct states; retain Heni's stricter timeout and CI semantics. |+| Stryker.NET, C# | Offers per-test coverage selection and isolated test runs; static initialization may need all tests because precise coverage is unavailable. | Model selection evidence and identity explicitly; postpone coverage selection until its provenance and fallback behavior can be proved. |+| PIT, Java | Selects tests using line coverage and timing; incremental analysis acknowledges incomplete dependency tracking. | Leave room for selection and scheduling strategies, but start with explicit selection and conservative reuse. |+| mutmut, Python | Supports focused runs and persistent progress; documents extra fixture copying, relevance filtering, and inherited-state hazards in forked workers. | Make snapshot contents explicit and start stages in supervised external processes. Provide an easy focused-case workflow. |++The cargo-mutants baseline verifies that tests still pass after copying the tree;+this matters when fixtures or location assumptions are missing. Its result model+separates inability to compile from a successful test detection. Heni should+also distinguish intentional build rejection from an accidental compiler error.+These are execution-stage lessons, not a reason to copy Rust's mutation operators.+Sources: [baseline](https://mutants.rs/baseline.html),+[results](https://mutants.rs/using-results.html), and+[timeouts](https://mutants.rs/timeouts.html).++Stryker's shared metrics explicitly include timeouts among detections. Heni's+curated cases ask whether a particular fault reached a particular assertion;+elapsed time alone cannot supply that evidence. Keep `timed-out` separate and+make unresolved cases fail the run contract. If a reporting adapter is added+later, it must disclose this semantic difference instead of mapping a timeout+to a successful Heni detection. Source:+[states and metrics](https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/).++StrykerJS incremental execution retains partial results and considers source+and test changes, but documents gaps for configuration, dependencies, environment,+and incomplete test-location reporting. PIT likewise notes that changes in+dependencies can invalidate otherwise unchanged class/test results. Heni should+first reuse baselines only inside a run with an explicit identity comprising+source bytes, commands, selector, adapter, build configuration, and environment+and resource contracts. Persistent cross-run verdict reuse is outside the first+release. Sources: [StrykerJS incremental](https://stryker-mutator.io/docs/stryker-js/incremental/)+and [PIT incremental](https://pitest.org/quickstart/incremental_analysis/).++StrykerJS documents false kills caused by workers competing for the same port+or resource and offers worker identities for partitioning. Heni should give each+worker independent writable source/build directories, plus explicit resource+claims and limits. A worker identity alone does not reserve a port, isolate a+database, or undo external effects. Source:+[parallel workers](https://stryker-mutator.io/docs/stryker-js/parallel-workers/).++PIT's coverage/timing selection and Stryker.NET's conservative treatment of+static initialization show why selection belongs in an explicit adapter+contract. Named-test evidence must distinguish discovered, selected, executed,+failed, skipped, and setup-failed tests. Heni must not treat an unknown count as+zero or assume a filter actually executed its intended test. Sources:+[PIT basics](https://pitest.org/quickstart/basic_concepts/) and+[Stryker.NET configuration](https://stryker-mutator.io/docs/stryker-net/configuration/).++mutmut documents extra files needed in its working directory, relevance limits+that change which tests count, and process-state inheritance that can cause hangs+or misleading outcomes. Heni should make inclusion rules visible, preserve+selection policy in evidence, and avoid an in-process framework runner in the+initial design. This is an inference about Heni's needs, not a claim that the+same Python process mechanisms apply to Haskell. Source:+[mutmut documentation](https://mutmut.readthedocs.io/en/latest/).++## Haskell design constraints++Apply the relevant [core standards](mori://shinzui/haskell-jitsurei/docs/core-standards):+GHC2024, the GHC 9.12-or-newer policy, common extension stanzas, postpositive+qualified imports, explicit deriving strategies, and strict record fields.+The actual tested compiler matrix and dependency bounds remain release evidence,+not facts established by the policy. The existing repository scaffold names+GHC 9.12.4; this is a local development choice, not a verified compatibility range.++Use concrete values and smart constructors for IDs, relative paths, positive+budgets, and nonempty case collections. Keep wire codecs explicit rather than+allowing constructor renaming to change JSON. A small internal prelude may+reduce repetition, but do not make its convenience exports the public API.+[Record patterns](mori://shinzui/haskell-jitsurei/docs/core-record-patterns) warn+that label instances propagate transitively. Therefore the public library's+import closure must not introduce generic-lens's orphan label instance; plain+constructor matching and explicit combinators are a justified application of+the pattern's isolation concern. Optional CLI internals may use lenses without+exposing them to library consumers. Document this scoped adaptation rather than+silently importing a large application prelude. See also the+[prelude guidance](mori://shinzui/haskell-jitsurei/docs/core-custom-prelude).++Two relevant cross-repository ADRs were inspected through Mori. The+[base-library boundary decision](mori://shinzui/mori-app/okf/adrs/concepts/ADR-1)+shows why consumer hooks should discharge their own domain effects into plain+`IO`. The [CLI ownership decision](mori://shinzui/keiro/okf/adrs/concepts/ADR-28)+supports one library-owned execution/classification path with alternative+presentations. These are architectural analogies; their event stores, service+stacks, and operational authorization policies are not Heni requirements.+No local Heni ADR existed at inspection time.++## API direction to test++A caller should construct a case, validate it, run a selected campaign, and+inspect a structured report without changing test framework. Keep a short+`Test.Heni` entry path, explicit option records with safe defaults, and optional+progress callbacks. Avoid a custom effect language or a hierarchy of classes+until two consumers require the same abstraction.++Separate a portable case description from Haskell-only transformations and+expectation hooks. A custom hook returns structured evidence; it cannot bypass+baseline success, required byte changes, timeout handling, or cleanup errors.+Built-in CLI cases must lower into the same library model. Framework reporters+produce versioned evidence; the core owns the verdict. Resource providers should+have scoped lifetimes so acquisition, use, and release cannot accidentally be+separated by normal control flow. Their cancellation behavior needs executable+tests, not just a convenient type signature.++Defer automatic source/AST operators, mutation switching, compiler plugins,+distributed workers, automatic equivalence claims, and coverage-driven+selection. Keep compatible extension points, but do not pay their complexity+before curated script replacement works.++## Prototype and adoption tests++The API must express all four RES-1 extraction patterns: checked replacement,+patch catalogue, generated-code change, and external diagnostic validation.+Add named assertions, property replay, explicit crash probes, and serialized+shared-resource use to exercise the difficult extensions. Each trial needs an+unchanged baseline and an intentionally broken detector or infrastructure path.+Otherwise a successful run may merely preserve an existing false positive.++Measure consumer-owned orchestration removed separately from fault definitions,+new-case authoring time, diagnosis effort, and elapsed build/test time. Record+hardware, compiler, commands, warm/cold conditions, fixture identity, and every+unsupported pattern. A best-in-class aspiration becomes credible through these+acceptance results and portable packaging; this research alone does not establish it.++The coordinated implementation is+[MasterPlan 1](../masterplans/1-bootstrap-heni-for-trustworthy-mutation-testing-and-hackage-release.md).+The first child proves the model with executable fixtures; the first real+baseline-to-report run is required before adapters, concurrency, or migration+variants expand. Actual cross-repository migrations remain separate work;+Heni contains reproducible, independently authored conformance fixtures.
+ docs/research/index.md view
@@ -0,0 +1,12 @@+---+okf_version: "0.2"+---++# Files++- [profile.dhall](profile.dhall)++# Research Document++- [Mutation-testing ecosystem lessons for Heni's API and execution model](ecosystem-mutation-testing-design.md) - Compare mutation tools across ecosystems and derive bounded, testable design choices for Heni.+- [Mutation testing across Haskell projects and the case for Heni](mutation-testing-survey.md) - Evaluate existing mutation runners, identify reusable contracts, and bound a public Haskell library and CLI.
+ docs/research/log.md view
@@ -0,0 +1,8 @@+# Bundle Update Log++## 2026-10-24++* **Add**: Add RES-2 comparing mutation tools across Rust, JavaScript, C#, Java, and Python, with explicit Heni adoption decisions and prototype gates.+* **Adoption**: Adopt the [published research-document profile](mori://shinzui/okf-profiles/profiles/research-documents), export `documentation.researchDocuments`, pinned at `v0.25.0` with a Dhall integrity hash.+* **Add**: Capture [RES-1: Mutation testing across Haskell projects and the case for Heni](mutation-testing-survey.md), including source identities, measured runner counts, reusable contracts, and evidence limits.+* **Update**: Link the investigation to the [initial public library and CLI specification](../initial-spec.md) and record author self-review without claiming independent verification.
+ docs/research/mutation-testing-survey.md view
@@ -0,0 +1,302 @@+---+type: Research Document+title: Mutation testing across Haskell projects and the case for Heni+description: Evaluate existing mutation runners, identify reusable contracts, and bound a public Haskell library and CLI.+generated:+  by: process:codex+  at: "2026-10-24T14:01:14Z"+researchId: RES-1+status: complete+scope: >-+  Source inspection of mutation infrastructure in Nagare, Keiro, rdkafka-hs,+  and Shibuya, with a discovery scan of 148 Mori-registered project paths in+  the shinzui and tan namespaces and selected adjacent checker tests. Counts+  and primary-source identities were refreshed on 2026-10-24. Existing reports+  were read; mutation suites were not rerun. This is an extraction assessment,+  not an exhaustive ecosystem survey or a measured productivity benchmark.+reviews:+  - kind: model+    reviewer: process:codex+    reviewed_at: "2026-10-24T14:08:32Z"+    document_timestamp: "2026-10-24T14:01:14Z"+    scope: content-and-metadata+    outcome: commented+    provider: openai+    model: gpt-6+    effort: unspecified+    context: >-+      Author self-review of source counts, primary file fingerprints, stage+      classification examples, canonical references, and stated evidence limits.+      This is not independent review or an approval attestation. Source suites+      were not rerun and future API feasibility remains unverified.+sources:+  - id: nagare+    resource: mori://shinzui/nagare+    title: Mutation manifest, Haskell harness, and incremental sweep workers+  - id: keiro+    resource: mori://shinzui/keiro/packages/keiro-dsl+    title: Generated-code and DSL conformance mutation scripts+  - id: rdkafka+    resource: mori://shinzui/rdkafka-hs/packages/rdkafka-hs+    title: Consolidated semantic mutation runner and parity evidence+  - id: shibuya+    resource: mori://shinzui/shibuya+    title: WebSocket and queue mutation controls+  - id: profiles+    resource: mori://shinzui/okf-profiles+    title: Product-discovery fixture mutations and precise diagnostic checks+  - id: kenshou+    resource: mori://shinzui/keiro-runtime-kenshou+    title: Checker self-tests using doctored ledgers+  - id: kikan+    resource: mori://shinzui/kikan+    title: Inline-document contract fixture mutation tests+  - id: keiki+    resource: mori://shinzui/keiki+    title: Reusable shape-hash sensitivity tests+  - id: kotei+    resource: mori://shinzui/kotei+    title: Release-feature validator self-tests+  - id: kiroku+    resource: mori://shinzui/kiroku+    title: Benchmark acceptance validator fault checks+  - id: mls+    resource: mori://tan/mls-reconciler+    title: Database-state and schema fault cases for chapter synchronization+  - id: cabal+    resource: https://cabal.readthedocs.io/en/stable/cabal-commands.html+    title: Cabal package checks, source distributions, documentation, and uploads+  - id: pvp+    resource: https://pvp.haskell.org/+    title: Haskell Package Versioning Policy+---++# Mutation testing across Haskell projects and the case for Heni++## Question and conclusion++Would a shared Haskell library save effort across the existing Bash, Python, and+Haskell mutation runners, and could it be useful to the wider Haskell ecosystem?++**The source evidence justifies a small shared execution library with a CLI.**+The repeated work is preparing a baseline, isolating and applying a deliberate+fault, invoking the appropriate build/tests, attributing the failure, restoring+the candidate, and preserving evidence. The faults and expected diagnostics+remain project-specific.++The public product should make one useful mutation easy to define, work with+existing test suites, and run from ordinary Cabal packages. Hackage publication+is an explicit project goal. Internal repository conventions are evidence for+the design, not required infrastructure for downstream users.++This completed record captures a bounded investigation. Its review entry records+an author self-review, not independent approval; no verification attestation is claimed.+Implementation feasibility, developer-time savings, and runtime improvements+remain to be established through prototypes and migration trials.++## Method and evidence boundary++Mori located the owning repositories and documentation. A filename and tracked+code-content scan covered 148 registered project paths in the `shinzui` and+`tan` namespaces under the local project tree. The scan found candidates;+selected source files were then read to distinguish test sensitivity from+ordinary application/database operations also called mutations.++This scan can miss unregistered repositories, ignored or untracked scripts,+different terminology, and ephemeral investigation scripts. It is sufficient+to establish repeated implementations, not a complete inventory of all uses.+No mutation suite, broker, database, remote builder, or timing benchmark was run+for this investigation. Existing success reports are historical evidence, not+fresh verification.++Primary source snapshots refreshed while capturing this record:++| Project | Inspected HEAD | Working-tree qualification |+| --- | --- | --- |+| [Nagare](mori://shinzui/nagare) | `4c99ffe3df84af1f16a2161944ac790197dd63fa` | Clean. |+| [Keiro](mori://shinzui/keiro) | `e6ef560b0a8db0dc091d0f8d7da1bb08c29155be` | Clean. |+| [rdkafka-hs](mori://shinzui/rdkafka-hs) | `5a80c66d7dcccc74062e14f146d18d32b33587ff` | Checkout contained unrelated manifest/plan edits; the inspected runner and parity record were unmodified. |+| [Shibuya](mori://shinzui/shibuya) | `0967f450b659b48d24cb49f199718451a65d2d48` | Clean. |++The file lists below are paired with canonical project/package URIs. File-level+artifact URI coverage is pending; retain the owning URI with each relative path.+The package URI for the Nagare harness was not resolvable in the local registry+at inspection time, so its owning project URI is used.++## Primary implementations++### Nagare: a patch catalogue and staged proof runner++In [Nagare](mori://shinzui/nagare), the relevant files are+`cli/nagare-harness/src/Nagare/Harness/Mutation.hs`,+`cli/nagarectl/test/mutations/records.json`, and `nix/test-runs.nix`.[^nagare]++The manifest contains **189 records: 188 `test-fails` and one `build-fails`**.+Each patch has a record name, suite, test pattern, and expected failure stage.+The fast gate checks catalogue consistency, duplicate names, patch applicability,+and whether patterns select tests. The Haskell result model distinguishes killed,+survived, wrong-stage, stale, and not-run records.++The remote sweep prepares one workspace and baseline build per worker, then+patches, builds incrementally, tests a selected pattern, and reverses the patch.+Source comments describe the full-rebuild cost and explain the incremental+strategy; those timings were not independently measured here.++Two gaps are visible in the source. `classifyProof` accepts any failed test-stage+exit as a kill; the sweep uses `timeout 1800`, so a timeout exit can be classified+as detection. Also, `selectRecords` follows changes to patch files, their source+targets, and the manifest, without generally tracking which changed test files+could weaken a mutation's detector. A shared runner should separate timeouts and+include test changes in any conservative impact selection.++### Keiro: repeated shell orchestration with improving attribution++In [Keiro's DSL package](mori://shinzui/keiro/packages/keiro-dsl),+`keiro-dsl/test/*mutation-test.sh` contains **21 top-level scripts totaling 1,558+lines**, excluding four additional mutation helper scripts in a fixture+subdirectory. The total includes consumer-specific faults and assertions as+well as orchestration; it is not an estimate of removable code.[^keiro]++Repeated code includes temporary backups, restoration traps, baseline runs,+embedded Python/sed/perl edits, command exit capture, diagnostic matching, and+post-restoration checks. The cases cover generated transducers, behavior holes,+replay, structural/nominal mappings, and DSL validation.++The older `keiro-dsl/test/mutation-test.sh` accepts any failed `cabal test`,+despite describing a specific named assertion in its comment. An unrelated build+failure can satisfy its success branch. Newer scripts, including+`keiro-dsl/test/history-dependent-holes-mutation-test.sh` and+`keiro-dsl/test/mapped-register-construction-mutation-test.sh`, check precise+named failures or diagnostics; some also hash or compare restored bytes.++Many scripts modify the active checkout and later copy backups over it. That+restoration strategy can interfere with concurrent editing or another mutation+run. Disposable workspaces would remove this recurring source of coordination+risk while preserving the project-owned mutation definitions.++### rdkafka-hs: an existing compact Haskell extraction candidate++In [rdkafka-hs](mori://shinzui/rdkafka-hs/packages/rdkafka-hs),+`scripts/test-mutations.hs` is a **349-line Haskell runner** with five families:+producer, seek, property, topic, and callback. It defines **37 mutations and+seven unchanged controls**.[^rdkafka]++The runner makes an isolated source distribution, resets edited sources between+cases, checks replacement anchors, builds each candidate, and requires a named+regression failure. Property cases record and rerun generated counterexamples.+Certain native lifetime faults explicitly permit a signal failure after a+case-specific safety marker. Evidence includes logs, revision, source fingerprint,+archive hash, and native library version.++`docs/testing/mutation-runner-parity.json` reports parity for 32 original+mutations and five controls, plus five new callback mutations and two controls.+That is evidence of a previous consolidation from separate runners. Its results+were inspected, not rerun or recertified against the refreshed HEAD.++This is a useful starting point for extracting common orchestration, but it is+still specialized: source archive layout, Cabal component names, output parsing,+fingerprint inputs, native crash probes, and replay conventions belong to this+consumer. Generalization should expose those choices explicitly.++### Shibuya: the same pattern in Python++In [Shibuya](mori://shinzui/shibuya),+`scripts/audit/run-websocket-mutation-controls.py` applies three behavioral+controls to queue and WebSocket implementation code. It requires a disposable+checkout, edits known anchors, runs focused tests with a timeout, restores in a+`finally` block, and records source hashes and results.[^shibuya]++Its failure check combines a nonzero exit with evidence that examples ran.+That is stronger than accepting an arbitrary build failure, but weaker than+requiring the exact named assertion. It independently repeats the same core+responsibilities as the Haskell and shell runners.++## Adjacent patterns and their limits++| Source | Observed pattern | Implication for Heni |+| --- | --- | --- |+| [OKF Profiles](mori://shinzui/okf-profiles), `scripts/test-product-discovery.py` | Copies fixture trees, applies exact-count edits, requires exit 1 and the expected rule/source/field diagnostic, rejects unrelated rule errors, and checks the restored baseline. | A strong model for diagnostic expectations over external commands.[^profiles] |+| [Kenshou](mori://shinzui/keiro-runtime-kenshou), `kenshou-check/src/Kenshou/Check/Selftest.hs` | Nine deliberately doctored ledgers must produce checker violations. | Useful sensitivity controls; they can remain lightweight in-process tests.[^kenshou] |+| [Kikan](mori://shinzui/kikan), `docs/architecture/evolution/conformance/inline-document-conversations/test_verify.py` | Copies contract fixtures, changes them, refreshes the manifest, and checks semantic rejection codes. | Fixture mutation needs the same attribution discipline as source mutation.[^kikan] |+| [Keiki](mori://shinzui/keiki), `keiki-codec-json-test/src/Keiki/Codec/JSON/Test.hs` | A reusable helper checks that schema-shape variations change the hash. | Evidence that small domain-specific sensitivity helpers are already useful; no subprocess runner is needed for every case.[^keiki] |+| [Kotei](mori://shinzui/kotei), `scripts/check-release-features.py` | Four altered inventory variants must be rejected. | Keep simple validator self-tests small.[^kotei] |+| [Kiroku](mori://shinzui/kiroku), `bench/mp13-release/test-validation.py` | Twelve altered evidence fixtures must raise validator errors. | Evidence corruption is adjacent to mutation testing, with distinct domain assertions.[^kiroku] |+| [MLS Reconciler](mori://tan/mls-reconciler), `scripts/test_chapter_sync.py` | Alters database state/schema and checks refusal without changing target snapshots. | External fault injection has fixture and side-effect obligations that workspace isolation alone cannot solve.[^mls] |++Do not count every occurrence of “mutation” as program mutation testing. Database+DML, application commands, schema-evolution fixtures, and adversarial inputs may+test product behavior directly. They inform extension points but do not all+justify migration to a shared runner.++## Reusable contract and extraction recommendation++The common abstraction is a case with a stable identity, deliberate change,+passing baseline, selected execution, expected detection, and evidence. Shared+infrastructure can own isolation, checked edits, stages, process budgets,+classification, restoration, controls, and reports. Consumers should own fault+meaning, exact diagnostic/test expectations, fixtures, and service setup.++A CLI makes that infrastructure available to Python and shell callers; a Haskell+API supports typed custom cases without requiring subprocess manifest generation.+Both should call one runner and one classifier. Separate library and CLI packages+keep command-line dependencies out of library consumers.++The existing code is enough to justify an extraction prototype. It does not yet+establish the best generic API or a numerical return on investment. Start with a+checked replacement case, a patch catalogue case, a generated-code mutation, and+an external checker diagnostic case. Compare before/after behavior, including+deliberately broken infrastructure, empty selection, wrong diagnostics, and+timeouts. Measure authoring effort and runtime separately.++The likely savings are in repeated orchestration, maintenance, and failure+diagnosis. Compilation, test runtime, fixture costs, and choosing meaningful+faults remain. Automatic generation and remote scheduling can be added later+when real consumers establish their contracts.++## Public-package implications++The initial public product should need only Cabal and its declared runtime+tools, provide runnable examples for existing Haskell test suites, and explain+the distinction between a survivor and an unverifiable run. A documented+Haskell API, versioned interchange formats, complete reports on failure, and+predictable CI exits are central to its usefulness.++Release readiness includes successful builds from source archives, package+checks, Haddock, verified examples, tested dependency bounds, a supported GHC/OS+matrix, package metadata and licensing, and candidate review. These are design+criteria informed by Cabal's package tooling and the PVP.[^cabal][^pvp]++The [initial specification](../initial-spec.md) makes these proposed contracts+explicit. It is a design artifact, not a claim that the package exists on+Hackage or that its proposed commands already run.++## Evidence fingerprints++SHA-256 of the primary inspected files, allowing the source observations to be+compared with later revisions:++| Owning project | Relative path | SHA-256 |+| --- | --- | --- |+| [Nagare](mori://shinzui/nagare) | `cli/nagare-harness/src/Nagare/Harness/Mutation.hs` | `bb16987bb45e18b609bf1c7e5bbd2bb0b07ae9b67aef01507d97a8cec8485f6b` |+| [Nagare](mori://shinzui/nagare) | `nix/test-runs.nix` | `650a318671a0ff31622455252e552722095e6dbe8042ee07a487cf29e2e3c658` |+| [Nagare](mori://shinzui/nagare) | `cli/nagarectl/test/mutations/records.json` | `f412a0c4191d90e60c47716d222f26bd56473c22278ca993e40b56aeacbcdd63` |+| [Keiro](mori://shinzui/keiro) | `keiro-dsl/test/mutation-test.sh` | `1aa082f1df2730db8087f005ea8c9b6077e5bc13ad3402e5f813ca90914aaa47` |+| [Keiro](mori://shinzui/keiro) | `keiro-dsl/test/history-dependent-holes-mutation-test.sh` | `a7b25458f41c9c2953877b3d4c8d4b0f8d98e5ac4f4ab4522a51b9777b7c2a08` |+| [Keiro](mori://shinzui/keiro) | `keiro-dsl/test/mapped-register-construction-mutation-test.sh` | `be7ff63c33580429f1e81b790055d17cf87b9d44a45fd73f043ecf2a5752a4f2` |+| [rdkafka-hs](mori://shinzui/rdkafka-hs) | `scripts/test-mutations.hs` | `c4959158e199fad5d2148a90f6201ae679a4f153a2979ca05c58ff074dd790b6` |+| [rdkafka-hs](mori://shinzui/rdkafka-hs) | `docs/testing/mutation-runner-parity.json` | `7a5f7a136c7d7ec761678cc511e2e9ecbbb671273cf677275ac2133de3b11349` |+| [Shibuya](mori://shinzui/shibuya) | `scripts/audit/run-websocket-mutation-controls.py` | `f6201fe05cfbff1ca73cc9fff3e24ba2196a4631cc9825c8eca9e87f22634b55` |++[^nagare]: Source files listed under the canonical Nagare project above; no sweep was executed.+[^keiro]: Top-level script count and line count refreshed directly from the DSL test directory.+[^rdkafka]: Runner source and the existing parity JSON; historical results were not rerun.+[^shibuya]: Python runner source, inspected without executing its mutations.+[^profiles]: Product-discovery mutation table, `check_rules`, and `check_mutations` inspected directly.+[^kenshou]: `nonVacuityVerdict` and its targeted checker/fact pairs inspected directly.+[^kikan]: Contract verifier test documentation and fixture-copy/mutate helpers inspected directly.+[^keiki]: `regFileShapeSensitivitySpec` and its documented shape variations inspected directly.+[^kotei]: `--self-test` mutation list and validator rejection loop inspected directly.+[^kiroku]: `test_rejects_faults` fixture mutations inspected directly.+[^mls]: SQL/schema fault loop and unchanged-target snapshot assertions inspected directly.+[^cabal]: [Cabal command reference](https://cabal.readthedocs.io/en/stable/cabal-commands.html), consulted for the role of package checks, distributions, documentation, and uploads.+[^pvp]: [Haskell Package Versioning Policy](https://pvp.haskell.org/), consulted for public API versioning.
+ docs/troubleshooting.md view
@@ -0,0 +1,77 @@+# Understanding results++`caught` requires the declared exit/signal and witness after the validity gates.+Generic diagnostic evidence has weaker attribution than a framework assertion.+Inspect the case definition, exact witness and relative logs. Build rejection,+explicit crash and controls are tallied separately from semantic detections.++`survived` means the valid candidate passed without the expected detection.+Strengthen the relevant assertion or review whether the deliberate fault tests+the intended behavior. Heni does not automatically declare equivalence.++`invalid-mutation` means an anchor/count, patch, creation/deletion precondition,+or final byte-change requirement failed. Refresh the edit against the selected+working tree. Multi-edit sequences see prior edits and must not undo themselves.+Only an explicit unchanged control is allowed to change no bytes.++`uncovered` means the intended named selection did not execute. Check exact test+IDs, filter configuration and skipped tests. Unknown counts are not zero counts;+generic commands cannot manufacture named-test evidence from stdout.++`unexpected-build-failure` means a semantic candidate failed to build and never+reached its assertion. Inspect compiler logs. If rejection itself is intentional,+declare a build-rejection expectation with a precise nonempty diagnostic.++`wrong-failure` means a different assertion, diagnostic, exit, signal or replay+was observed. Fix attribution rather than accepting arbitrary command failure.+Additional undeclared named assertion failures also invalidate a named detection.++`timed-out` means a stage exceeded its wall-clock budget. Diagnose the process+and resource state before increasing the budget. Output printed before a hang+does not turn timeout into detection.++`infrastructure-error` means launch, setup, protocol, output, observation,+reporting or cleanup could not be trusted. Inspect primary and secondary errors;+a named witness cannot override them. Command/environment metadata redacts+secrets, but consumer commands should not emit credentials into retained logs.++`not-run` accounts for selected work that could not execute, including failed+baselines, cancellation and queued work. Baseline/control failures also appear+at run level with affected IDs. Every selected ID must appear exactly once.++For interrupted runs, inspect report.json or use `heni report DIRECTORY+--recover`. Recovery reads evidence and never reruns commands. A truncated last+journal record can be ignored; interior corruption, wrong sequence/schema and+missing or escaping artifacts fail clearly. Move the whole directory with its+logs to keep reports portable. Recovery is explicitly untrustworthy, so run a+new campaign before relying on a passing CI result.++Existing output directories are refused. Choose a new location. If output is+inside the source tree, exclude it explicitly or use the default+mutation-results name. Source symlinks and special files are unsupported in the+first capture policy; select/exclude inputs explicitly instead of following+them silently. Disposable workspaces protect the active checkout, while external+service state and detached processes remain consumer responsibilities.++For generated code, inspect the final build artifact and its generator input.+The migration example changes input before generation; changing output before+a regenerating build can erase the intended fault. A surviving marker change+in the trial is deliberate and verifies the distinction.++A required replay mismatch remains `wrong-failure`, including a replay timeout;+the underlying Replay outcome and original failure are retained. Invalid+QuickCheck tokens become skipped/pending reproductions and cannot count as+caught. Multiple replay properties need a consumer-specific protocol rather+than the initial one-property helper.++For resource failures, inspect acquire/reset/release records and their command+logs. Both primary and teardown errors matter. Check that baseline and candidate+have equivalent identity/version/epoch and namespaced bindings. Worker capacities+also limit fixture acquisition. Separate Heni processes require external locking.+Unknown epochs disable baseline reuse; an unverified build observation or+unexpected generated source retires retained artifacts and invalidates the case.++`check` cannot prove an explicit `./...` build product already exists. It checks+contained syntax and defers availability until after build. A missing or+symlinked workspace program is a launch/infrastructure failure. Host tools are+still checked before a campaign starts.
+ docs/validation/migration-measurements.json view
@@ -0,0 +1,270 @@+{+  "operator": "automated Python observation of agent-authored fixtures; no human effort inference",+  "method": "nonblank/noncomment physical lines inside named Legacy.hs regions; local wrapper line included; definitions subtracted",+  "comparisonSharedOrchestrationLOC": 17,+  "platform": {+    "arch": "aarch64",+    "compilerVersion": "9.12",+    "os": "darwin"+  },+  "families": [+    {+      "family": "replacement",+      "comparisonLocalOrchestrationLOC": 4,+      "comparisonDefinitionLOC": 2,+      "manifestLifecycleScaffoldingLOC": 0,+      "retainedDomainBuildHelperLOC": 0,+      "legacyIntendedCaught": true,+      "legacyUnrelatedCompileAccepted": true,+      "typedFreshSeconds": 100.067377,+      "cliParallelReuseSeconds": 47.105884,+      "wholeCampaignRepeatsPerCondition": 1,+      "outcomes": [+        "Caught",+        "Survived",+        "UnexpectedBuildFailure",+        "Caught"+      ],+      "typedFresh": {+        "baselineBuildInvocations": 4,+        "baselineBuildTiming": {+          "repeats": 4,+          "minSeconds": 8.836197,+          "maxSeconds": 9.027514,+          "medianSeconds": 8.910768000000001+        },+        "sumBuildStageSeconds": 70.697988,+        "sumAllStageSeconds": 100.009445+      },+      "cliParallelReuse": {+        "baselineBuildInvocations": 2,+        "baselineBuildTiming": {+          "repeats": 2,+          "minSeconds": 9.096244,+          "maxSeconds": 9.131108,+          "medianSeconds": 9.113676+        },+        "sumBuildStageSeconds": 52.13598,+        "sumAllStageSeconds": 70.068589+      },+      "typedElapsedMinusStagesSeconds": 0.05793199999999388+    },+    {+      "family": "patch-catalogue",+      "comparisonLocalOrchestrationLOC": 4,+      "comparisonDefinitionLOC": 2,+      "manifestLifecycleScaffoldingLOC": 0,+      "retainedDomainBuildHelperLOC": 0,+      "legacyIntendedCaught": true,+      "legacyUnrelatedCompileAccepted": null,+      "typedFreshSeconds": 59.412925,+      "cliParallelReuseSeconds": 30.661492,+      "wholeCampaignRepeatsPerCondition": 1,+      "outcomes": [+        "Caught",+        "Survived"+      ],+      "typedFresh": {+        "baselineBuildInvocations": 2,+        "baselineBuildTiming": {+          "repeats": 2,+          "minSeconds": 8.709212,+          "maxSeconds": 9.054494,+          "medianSeconds": 8.881853+        },+        "sumBuildStageSeconds": 35.634768,+        "sumAllStageSeconds": 59.373957+      },+      "cliParallelReuse": {+        "baselineBuildInvocations": 1,+        "baselineBuildTiming": {+          "repeats": 1,+          "minSeconds": 8.835608,+          "maxSeconds": 8.835608,+          "medianSeconds": 8.835608+        },+        "sumBuildStageSeconds": 27.25745,+        "sumAllStageSeconds": 46.281157+      },+      "typedElapsedMinusStagesSeconds": 0.03896800000000411+    },+    {+      "family": "generated-code",+      "comparisonLocalOrchestrationLOC": 5,+      "comparisonDefinitionLOC": 2,+      "manifestLifecycleScaffoldingLOC": 0,+      "retainedDomainBuildHelperLOC": 16,+      "legacyIntendedCaught": true,+      "legacyUnrelatedCompileAccepted": null,+      "typedFreshSeconds": 62.960805,+      "cliParallelReuseSeconds": 31.536973,+      "wholeCampaignRepeatsPerCondition": 1,+      "outcomes": [+        "Caught",+        "Survived"+      ],+      "typedFresh": {+        "baselineBuildInvocations": 2,+        "baselineBuildTiming": {+          "repeats": 2,+          "minSeconds": 9.055222,+          "maxSeconds": 9.373377,+          "medianSeconds": 9.2142995+        },+        "sumBuildStageSeconds": 37.77156,+        "sumAllStageSeconds": 62.877587+      },+      "cliParallelReuse": {+        "baselineBuildInvocations": 1,+        "baselineBuildTiming": {+          "repeats": 1,+          "minSeconds": 9.24417,+          "maxSeconds": 9.24417,+          "medianSeconds": 9.24417+        },+        "sumBuildStageSeconds": 28.935714,+        "sumAllStageSeconds": 47.479472+      },+      "typedElapsedMinusStagesSeconds": 0.08321800000000223+    },+    {+      "family": "diagnostic-checker",+      "comparisonLocalOrchestrationLOC": 4,+      "comparisonDefinitionLOC": 2,+      "manifestLifecycleScaffoldingLOC": 0,+      "retainedDomainBuildHelperLOC": 22,+      "legacyIntendedCaught": true,+      "legacyUnrelatedCompileAccepted": null,+      "typedFreshSeconds": 4.973488,+      "cliParallelReuseSeconds": 2.729233,+      "wholeCampaignRepeatsPerCondition": 1,+      "outcomes": [+        "Caught",+        "Survived"+      ],+      "typedFresh": {+        "baselineBuildInvocations": 2,+        "baselineBuildTiming": {+          "repeats": 2,+          "minSeconds": 0.864715,+          "maxSeconds": 0.918225,+          "medianSeconds": 0.89147+        },+        "sumBuildStageSeconds": 3.558678,+        "sumAllStageSeconds": 4.954149+      },+      "cliParallelReuse": {+        "baselineBuildInvocations": 1,+        "baselineBuildTiming": {+          "repeats": 1,+          "minSeconds": 0.877249,+          "maxSeconds": 0.877249,+          "medianSeconds": 0.877249+        },+        "sumBuildStageSeconds": 2.775368,+        "sumAllStageSeconds": 3.93278+      },+      "typedElapsedMinusStagesSeconds": 0.01933899999999955+    }+  ],+  "authoring": [+    {+      "family": "replacement",+      "templateFieldsChanged": [+        "id",+        "description",+        "edits"+      ],+      "validationCommands": 1,+      "testsExecutedDuringCheck": false,+      "automatedSerializationAndCheck": {+        "repeats": 3,+        "minSeconds": 0.025291917379945517,+        "maxSeconds": 0.05823345901444554,+        "medianSeconds": 0.02542133303359151+      }+    },+    {+      "family": "patch-catalogue",+      "templateFieldsChanged": [+        "id",+        "description",+        "edits"+      ],+      "validationCommands": 1,+      "testsExecutedDuringCheck": false,+      "automatedSerializationAndCheck": {+        "repeats": 3,+        "minSeconds": 0.37392104091122746,+        "maxSeconds": 0.40745075000450015,+        "medianSeconds": 0.38921954203397036+      }+    },+    {+      "family": "generated-code",+      "templateFieldsChanged": [+        "id",+        "description",+        "edits"+      ],+      "validationCommands": 1,+      "testsExecutedDuringCheck": false,+      "automatedSerializationAndCheck": {+        "repeats": 3,+        "minSeconds": 0.024125791154801846,+        "maxSeconds": 0.024874708615243435,+        "medianSeconds": 0.024490791838616133+      }+    },+    {+      "family": "diagnostic-checker",+      "templateFieldsChanged": [+        "id",+        "description",+        "edits"+      ],+      "validationCommands": 1,+      "testsExecutedDuringCheck": false,+      "automatedSerializationAndCheck": {+        "repeats": 3,+        "minSeconds": 0.023509624879807234,+        "maxSeconds": 0.024647582788020372,+        "medianSeconds": 0.024002416990697384+      }+    }+  ],+  "diagnosticRuleMismatch": {+    "verdict": "wrong-failure",+    "expected": "policy.capacity: maximum must be positive",+    "observed": "policy.timeout: duration must be positive",+    "exit": 1+  },+  "diagnosis": {+    "artifactReads": 2,+    "expectedFailures": [+      "suite/intended"+    ],+    "actualFailures": [+      "suite/other"+    ],+    "automatedReadTime": {+      "repeats": 5,+      "minSeconds": 0.0006172088906168938,+      "maxSeconds": 0.0029806247912347317,+      "medianSeconds": 0.0006547500379383564+    }+  },+  "reproduction": {+    "commands": 1,+    "case": "diagnostic-checker/invalid-capacity",+    "exit": 0,+    "verdict": "caught",+    "seconds": 2.5890352916903794+  },+  "limits": [+    "Whole-campaign variance is not estimated from one run per condition.",+    "Fresh library and parallel/reuse CLI conditions differ.",+    "Elapsed-minus-stages includes copies, edits and report IO; it is not isolated CPU overhead.",+    "Authoring/diagnosis timings exclude human reasoning."+  ]+}
+ docs/validation/migration-results.md view
@@ -0,0 +1,111 @@+# Representative migration evidence++Accepted local trials on 2026-10-24 used Apple M1 Max/aarch64 macOS,+GHC 9.12.4 and Cabal 3.16.1.0, with a warm installed dependency cache and fresh+case workspaces. All four independently authored families ran through public+Haskell definitions and CLI manifests. The generated Haskell case used a custom+checked transformation; its manifest equivalent used replacement. No surveyed+consumer repository or service was modified or executed.++Reproduce with `sh scripts/test-migrations.sh`. The script explicitly enables+cross-package tests and supplies a separately built CLI. It retains reports and+logs under `.heni-runs/heni-migration-*`, including failed attempts. Condensed+observations are in [migration-measurements.json](migration-measurements.json);+[source identities](migration-source-identities.json) distinguish the refreshed+files from the historical RES-1 snapshots. The current rdkafka runner is 453+lines; the historical research described a different 349-line revision.++Every family produced Caught and Survived with matching typed/CLI meaning and+identical source snapshot identities. Replacement additionally produced+UnexpectedBuildFailure for an unrelated compiler error, and Caught only when+that rejection was explicitly declared with its compiler diagnostic. The+exit-only comparison actually accepted the unrelated compiler failure, proving+the attribution improvement. The external checker also rejected a timeout-rule+error as WrongFailure when the case required a capacity-rule diagnostic.++The combined trial matched wrong named assertion, known zero selection, stale+edit, test hang, failed setup, broken teardown, replay, fresh native crash and+unchanged control outcomes. Reports with a stale schema were rejected. The+real native QuickCheck counterexample integration remains in the optional+Hspec package's accepted test, while the combined core trial exercises its+framework-neutral replay channel. All selected IDs were accounted for. The+process and scheduler suites separately prove cancellation, worker isolation,+resource capacities, cleanup and post-teardown final progress events.++An initial combined run conservatively reported infrastructure failure during a+serial timeout. Failure retention was corrected, and subsequent regressions+identified a macOS group-exit race: killpg briefly returned EPERM before ESRCH.+An independent 100-iteration probe reproduced the transition. The supervisor+now polls within its existing shutdown bounds and clears provisional errors+only after definite disappearance; persistent errors still invalidate evidence.+The focused process suite includes twelve short-timeout repetitions, and the+adapter suite verifies retained replay-timeout evidence. The accepted full+migration run and its retained artifacts remain valid; no failed timeout was+counted as detection.++The following counts describe only the independently authored comparison+harness, not removable code in production projects. A further 17 shared+nonblank/noncomment lines implement copying, scoped disposal and command+capture. Per-family orchestration counts exclude the two definition lines and+include the local wrapper. Manifest consumers add zero lifecycle-scaffolding+lines; command configuration, edits and witnesses remain necessary data.+Generator/checker build helpers remain consumer-owned in both paths.++| Family | Comparison orchestration LOC | Definition LOC | Retained domain/build helper LOC | Typed fresh seconds | CLI parallel/reuse seconds | Baseline builds, fresh / reuse |+| --- | --- | --- | --- | --- | --- | --- |+| replacement | 4 | 2 | 0 | 100.067 | 47.106 | 4 / 2 |+| patch-catalogue | 4 | 2 | 0 | 59.413 | 30.661 | 2 / 1 |+| generated-code | 5 | 2 | 16 | 62.961 | 31.537 | 2 / 1 |+| diagnostic-checker | 4 | 2 | 22 | 4.973 | 2.729 | 2 / 1 |++The library conditions use one worker and fresh baselines; CLI conditions use+two workers and compatible baseline sharing. They preserve meaning but combine+multiple performance changes. There is one accepted whole-campaign timing per+condition, so whole-campaign variance is not estimated. Unchanged baseline+builds repeat within the fresh campaigns; their ranges and the sum of build+stage times are recorded in the JSON. The controlled Cabal workload in+[the scheduling evidence](../design/scheduling.md) has two repeats per condition+and reports its variation independently. These data do not predict universal+speedups.++Fresh library elapsed time minus the sum of supervised stage durations was+0.019–0.083s across these tiny fixtures. This difference includes source copies,+checks, custom edits, reporting IO and timing granularity; it is not isolated+runner CPU time. Build/test work dominates these examples, while stronger+failure attribution is independently observable.++An automated template-addition trial changed three case fields (ID, description+and edits), serialized one manifest and ran one `check` command. Each family+was repeated three times; `check` confirmed it executed no tests. The figures+below measure automation plus process/static-check latency, excluding human+fault selection or reasoning.++| Family | Median automated addition/check ms | Range ms |+| --- | --- | --- |+| replacement | 25.42 | 25.29–58.23 |+| patch-catalogue | 389.22 | 373.92–407.45 |+| generated-code | 24.49 | 24.13–24.87 |+| diagnostic-checker | 24.00 | 23.51–24.65 |++Diagnosis required two artifact reads: report expectation/actual IDs and the+dedicated test protocol. It identified expected `suite/intended` versus actual+`suite/other`. Five automated reads took a median of+0.655ms; no human diagnosis+time is inferred. One CLI command reproduced the checker Caught result with+exit 0 in 2.589s.++To repeat the observations after a complete trial:++```bash+python3 scripts/measure-migration.py --results .heni-runs/migration-results.json --cli /path/from/cabal-list-bin/heni --output docs/validation/migration-measurements.json+```++The API review found concrete case data, checked custom transformations,+framework-neutral evidence, explicit resource scopes and redacted reports+sufficient for these four families. It exposed and fixed workspace-built+executable preflight and post-teardown progress ordering. It did not establish+support for binary mutation, arbitrary generated-output ordering, all property+frameworks, database-state rollback or unverified incremental build tools.+Those limits and later consumer migration batches are explicit in+[the guide](../migration-guide.md). No human usability study, fleet migration,+Hackage candidate review or publication is claimed.
+ docs/validation/migration-source-identities.json view
@@ -0,0 +1,56 @@+[+  {+    "owner": "mori://shinzui/rdkafka-hs/packages/rdkafka-hs",+    "head": "2917f3af396dd1536944951a65946ada87ceb874",+    "files": [+      {+        "path": "scripts/test-mutations.hs",+        "sha256": "bc1ff5f598a122928e187f30b4c929cfa805f79133c086fb7ad288d5c1888b5b",+        "lines": 453+      }+    ]+  },+  {+    "owner": "mori://shinzui/nagare",+    "head": "4c99ffe3df84af1f16a2161944ac790197dd63fa",+    "files": [+      {+        "path": "cli/nagare-harness/src/Nagare/Harness/Mutation.hs",+        "sha256": "bb16987bb45e18b609bf1c7e5bbd2bb0b07ae9b67aef01507d97a8cec8485f6b",+        "lines": 315+      },+      {+        "path": "cli/nagarectl/test/mutations/records.json",+        "sha256": "f412a0c4191d90e60c47716d222f26bd56473c22278ca993e40b56aeacbcdd63",+        "lines": 1136+      }+    ]+  },+  {+    "owner": "mori://shinzui/keiro/packages/keiro-dsl",+    "head": "e6ef560b0a8db0dc091d0f8d7da1bb08c29155be",+    "files": [+      {+        "path": "keiro-dsl/test/mutation-test.sh",+        "sha256": "1aa082f1df2730db8087f005ea8c9b6077e5bc13ad3402e5f813ca90914aaa47",+        "lines": 37+      },+      {+        "path": "keiro-dsl/test/history-dependent-holes-mutation-test.sh",+        "sha256": "a7b25458f41c9c2953877b3d4c8d4b0f8d98e5ac4f4ab4522a51b9777b7c2a08",+        "lines": 85+      }+    ]+  },+  {+    "owner": "mori://shinzui/shibuya",+    "head": "0967f450b659b48d24cb49f199718451a65d2d48",+    "files": [+      {+        "path": "scripts/audit/run-websocket-mutation-controls.py",+        "sha256": "f6201fe05cfbff1ca73cc9fff3e24ba2196a4631cc9825c8eca9e87f22634b55",+        "lines": 75+      }+    ]+  }+]
+ examples/adapters/README.md view
@@ -0,0 +1,8 @@+# Framework examples++The fixture executables are built in the optional packages. Add their executable+directories to PATH (obtain paths using `cabal list-bin heni-tasty-fixture` and+`cabal list-bin heni-hspec-fixture`), then run `heni run examples/adapters/tasty.json`,+`heni run examples/adapters/hspec.json`, or `heni run examples/adapters/property.json`.+The fixtures use the same reporter installation a consumer test main uses.+See [adapter contracts](../../docs/design/adapters.md) for identity and selector rules.
+ examples/adapters/hspec.json view
@@ -0,0 +1,38 @@+{+  "schemaVersion": 1,+  "sourceRoot": ".",+  "commands": {+    "build": {+      "executable": "heni-hspec-fixture",+      "arguments": [+        "--heni-build"+      ]+    },+    "test": {+      "executable": "heni-hspec-fixture"+    }+  },+  "cases": [+    {+      "id": "named",+      "description": "Detect the intended assertion",+      "adapter": "heni-v1",+      "edits": [+        {+          "kind": "replace",+          "path": "input",+          "old": "anchor",+          "new": "intended"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "named-tests",+        "failures": [+          "suite/intended"+        ]+      }+    }+  ]+}
+ examples/adapters/input view
@@ -0,0 +1,1 @@+anchor
+ examples/adapters/property.json view
@@ -0,0 +1,40 @@+{+  "schemaVersion": 1,+  "sourceRoot": ".",+  "commands": {+    "build": {+      "executable": "heni-hspec-fixture",+      "arguments": [+        "--heni-build"+      ]+    },+    "test": {+      "executable": "heni-hspec-fixture"+    }+  },+  "cases": [+    {+      "id": "property",+      "description": "Detect the intended assertion",+      "adapter": "heni-v1",+      "edits": [+        {+          "kind": "replace",+          "path": "input",+          "old": "anchor",+          "new": "property"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "named-tests",+        "failures": [+          "suite/property"+        ],+        "replayRequired": true+      },+      "selector": "property"+    }+  ]+}
+ examples/adapters/tasty.json view
@@ -0,0 +1,38 @@+{+  "schemaVersion": 1,+  "sourceRoot": ".",+  "commands": {+    "build": {+      "executable": "heni-tasty-fixture",+      "arguments": [+        "--heni-build"+      ]+    },+    "test": {+      "executable": "heni-tasty-fixture"+    }+  },+  "cases": [+    {+      "id": "named",+      "description": "Detect the intended assertion",+      "adapter": "heni-v1",+      "edits": [+        {+          "kind": "replace",+          "path": "input",+          "old": "anchor",+          "new": "intended"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "named-tests",+        "failures": [+          "suite/intended"+        ]+      }+    }+  ]+}
+ examples/library/Definitions.hs view
@@ -0,0 +1,33 @@+module Definitions (exampleCampaign) where++import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Test.Heni++checked :: (Show e) => Either e a -> a+checked = either (error . show) id++exampleCampaign :: Campaign+exampleCampaign = checked (mkCampaign commands [(c, noHooks) | c <- cases])+  where+    commandId = checked . mkCommandId+    budget = checked (mkStageBudget 60000000)+    command args = Command "cabal" args Set.empty Nothing [] budget (OutputPolicy 1048576)+    commands =+      Map.fromList+        [ (commandId "build", command ["build", "fixture-test", "--offline", "-j1"]),+          (commandId "test", command ["test", "fixture-test", "--offline", "-j1", "--test-show-details=direct"])+        ]+    target = checked (mkRelativeTarget "src/Capacity.hs")+    define ident old new =+      CaseDefinition+        (checked (mkCaseId ident))+        "The capacity boundary assertion must detect a removed guard."+        (Set.singleton "regression")+        [Replace target old new 1]+        (commandId "build")+        (Just (commandId "test"))+        Nothing+        (Diagnostic Test 1 "rejects full capacity: FAIL")+        GenericCommands+    cases = [define "guard-bypass" "used < limit" "True", define "survivor" "baseline" "mutant", define "compile-error" "used < limit" "missingCapacityGuard"]
+ examples/library/Main.hs view
@@ -0,0 +1,12 @@+module Main (main) where++import Definitions (exampleCampaign)+import System.Environment (getArgs)+import Test.Heni++main :: IO ()+main = do+  args <- getArgs+  let output = case args of ["--output", path] -> path; _ -> ".heni-runs/library-smoke"+  result <- runCampaign (defaultRunOptions "examples/minimal" output) exampleCampaign+  print result
+ examples/migrations/README.md view
@@ -0,0 +1,11 @@+# Representative migrations++Each family has a one-case default heni.json, a passing unchanged target and a+precise intended witness. Replacement and patch-catalogue use a standalone Cabal+capacity assertion. Generated-code changes generator input before compilation.+Diagnostic-checker compiles and executes its own workspace-local validator.++All files are independently authored BSD-3-Clause examples. Read+[the migration guide](../../docs/migration-guide.md) for commands, surviving and+adversarial variants, capability boundaries and the measured comparison method.+The integration trial is `sh scripts/test-migrations.sh` from the repository root.
+ examples/migrations/diagnostic-checker/LICENSE view
@@ -0,0 +1,27 @@+BSD 3-Clause License++Copyright (c) 2026, Nadeem Bitar+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+   list of conditions and the following disclaimer.+2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.+3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software+   without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ examples/migrations/diagnostic-checker/heni.json view
@@ -0,0 +1,38 @@+{+  "schemaVersion": 1,+  "commands": {+    "build": {+      "executable": "runghc",+      "arguments": [+        "tools/Build.hs"+      ],+      "timeoutSeconds": 30+    },+    "test": {+      "executable": "./dist-newstyle/checker",+      "timeoutSeconds": 5+    }+  },+  "cases": [+    {+      "id": "invalid-capacity",+      "description": "The external checker must identify the exact capacity rule.",+      "edits": [+        {+          "kind": "replace",+          "path": "policy.txt",+          "old": "capacity=10",+          "new": "capacity=-1"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "diagnostic",+        "stage": "test",+        "exitCode": 1,+        "outputContains": "policy.capacity: maximum must be positive"+      }+    }+  ]+}
+ examples/migrations/diagnostic-checker/policy.txt view
@@ -0,0 +1,3 @@+capacity=10+timeout=30+label=baseline
+ examples/migrations/diagnostic-checker/tools/Build.hs view
@@ -0,0 +1,10 @@+module Main (main) where++import System.Directory (createDirectoryIfMissing)+import System.Exit (exitWith)+import System.Process (rawSystem)++main :: IO ()+main = do+  createDirectoryIfMissing True "dist-newstyle"+  rawSystem "ghc" ["-O0", "-outputdir", "dist-newstyle", "-o", "dist-newstyle/checker", "tools/Check.hs"] >>= exitWith
+ examples/migrations/diagnostic-checker/tools/Check.hs view
@@ -0,0 +1,16 @@+module Main (main) where++import Data.List (isInfixOf)+import System.Exit (ExitCode (..), exitWith)++main :: IO ()+main = do+  input <- readFile "policy.txt"+  if "capacity=-1" `isInfixOf` input+    then reject "policy.capacity: maximum must be positive"+    else+      if "timeout=-1" `isInfixOf` input+        then reject "policy.timeout: duration must be positive"+        else putStrLn "policy: PASS"+  where+    reject why = putStrLn why >> exitWith (ExitFailure 1)
+ examples/migrations/generated-code/LICENSE view
@@ -0,0 +1,27 @@+BSD 3-Clause License++Copyright (c) 2026, Nadeem Bitar+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+   list of conditions and the following disclaimer.+2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.+3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software+   without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ examples/migrations/generated-code/cabal.project view
@@ -0,0 +1,1 @@+packages: .
+ examples/migrations/generated-code/generated-fixture.cabal view
@@ -0,0 +1,16 @@+cabal-version: 3.4+name: heni-generated-fixture+version: 0.1.0.0+build-type: Simple+license: BSD-3-Clause++test-suite fixture-test+  type: exitcode-stdio-1.0+  default-language: GHC2024+  hs-source-dirs:+    test+    generated++  main-is: Main.hs+  other-modules: Generated+  build-depends: base >=4.21.2 && <4.22
+ examples/migrations/generated-code/heni.json view
@@ -0,0 +1,45 @@+{+  "schemaVersion": 1,+  "commands": {+    "build": {+      "executable": "runghc",+      "arguments": [+        "tools/Build.hs"+      ],+      "timeoutSeconds": 60+    },+    "test": {+      "executable": "cabal",+      "arguments": [+        "test",+        "fixture-test",+        "--offline",+        "-j1",+        "--test-show-details=direct"+      ],+      "timeoutSeconds": 30+    }+  },+  "cases": [+    {+      "id": "generated-guard",+      "description": "Mutate generator input before generation so the compiled guard is removed.",+      "edits": [+        {+          "kind": "replace",+          "path": "model.txt",+          "old": "mode=strict",+          "new": "mode=open"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "diagnostic",+        "stage": "test",+        "exitCode": 1,+        "outputContains": "generated capacity: FAIL"+      }+    }+  ]+}
+ examples/migrations/generated-code/model.txt view
@@ -0,0 +1,2 @@+mode=strict+label=baseline
+ examples/migrations/generated-code/test/Main.hs view
@@ -0,0 +1,7 @@+module Main (main) where++import Generated+import System.Exit (exitFailure)++main :: IO ()+main = if not (admits 10 10) then putStrLn "generated capacity: PASS" else putStrLn "generated capacity: FAIL" >> exitFailure
+ examples/migrations/generated-code/tools/Build.hs view
@@ -0,0 +1,18 @@+module Main (main) where++import Data.List (isInfixOf)+import System.Directory (createDirectoryIfMissing)+import System.Exit (exitWith)+import System.IO (hFlush, stdout)+import System.Process (rawSystem)++main :: IO ()+main = do+  model <- readFile "model.txt"+  let guard = if "mode=strict" `isInfixOf` model then "used < limit" else "True"+      generated = "module Generated (admits) where\nadmits :: Int -> Int -> Bool\nadmits used limit = " ++ guard ++ "\n"+  createDirectoryIfMissing True "generated"+  writeFile "generated/Generated.hs" generated+  putStrLn ("compiled generator input: " ++ show model ++ "; artifact: admits used limit = " ++ guard)+  hFlush stdout+  rawSystem "cabal" ["build", "fixture-test", "--offline", "-j1"] >>= exitWith
+ examples/migrations/patch-catalogue/LICENSE view
@@ -0,0 +1,27 @@+BSD 3-Clause License++Copyright (c) 2026, Nadeem Bitar+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+   list of conditions and the following disclaimer.+2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.+3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software+   without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ examples/migrations/patch-catalogue/cabal.project view
@@ -0,0 +1,1 @@+packages: .
+ examples/migrations/patch-catalogue/heni-minimal-fixture.cabal view
@@ -0,0 +1,17 @@+cabal-version: 3.4+name: heni-minimal-fixture+version: 0.1.0.0+build-type: Simple+license: BSD-3-Clause+synopsis: Standalone guard fixture for Heni++test-suite fixture-test+  type: exitcode-stdio-1.0+  default-language: GHC2024+  hs-source-dirs:+    src+    test++  main-is: Main.hs+  other-modules: Capacity+  build-depends: base >=4.21 && <5
+ examples/migrations/patch-catalogue/heni.json view
@@ -0,0 +1,49 @@+{+  "schemaVersion": 1,+  "commands": {+    "build": {+      "executable": "cabal",+      "arguments": [+        "build",+        "fixture-test",+        "--offline",+        "-j1"+      ],+      "timeoutSeconds": 60+    },+    "test": {+      "executable": "cabal",+      "arguments": [+        "test",+        "fixture-test",+        "--offline",+        "--test-show-details=direct",+        "-j1"+      ],+      "timeoutSeconds": 60+    }+  },+  "cases": [+    {+      "id": "guard-bypass",+      "description": "Removing the capacity guard must fail the full-capacity assertion.",+      "tags": [+        "regression"+      ],+      "edits": [+        {+          "kind": "patch",+          "content": "diff --git a/src/Capacity.hs b/src/Capacity.hs\n--- a/src/Capacity.hs\n+++ b/src/Capacity.hs\n@@ -1,7 +1,7 @@\n module Capacity (admits, marker) where\n \n admits :: Int -> Int -> Bool\n-admits used limit = used < limit\n+admits used limit = True\n \n marker :: String\n marker = \"baseline\"\n"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "diagnostic",+        "stage": "test",+        "exitCode": 1,+        "outputContains": "rejects full capacity: FAIL"+      }+    }+  ]+}
+ examples/migrations/patch-catalogue/patches/guard-bypass.patch view
@@ -0,0 +1,12 @@+diff --git a/src/Capacity.hs b/src/Capacity.hs+--- a/src/Capacity.hs++++ b/src/Capacity.hs+@@ -1,7 +1,7 @@+ module Capacity (admits, marker) where+ + admits :: Int -> Int -> Bool+-admits used limit = used < limit++admits used limit = True+ + marker :: String+ marker = "baseline"
+ examples/migrations/patch-catalogue/patches/survivor.patch view
@@ -0,0 +1,12 @@+diff --git a/src/Capacity.hs b/src/Capacity.hs+--- a/src/Capacity.hs++++ b/src/Capacity.hs+@@ -1,7 +1,7 @@+ module Capacity (admits, marker) where+ + admits :: Int -> Int -> Bool+ admits used limit = used < limit+ + marker :: String+-marker = "baseline"++marker = "variant"
+ examples/migrations/patch-catalogue/src/Capacity.hs view
@@ -0,0 +1,7 @@+module Capacity (admits, marker) where++admits :: Int -> Int -> Bool+admits used limit = used < limit++marker :: String+marker = "baseline"
+ examples/migrations/patch-catalogue/test/Main.hs view
@@ -0,0 +1,15 @@+module Main (main) where++import Capacity+import Control.Monad (unless)+import System.Exit (exitFailure)++main :: IO ()+main = do+  unless (not (admits 1 1)) $ do+    putStrLn "rejects full capacity: FAIL"+    exitFailure+  unless (admits 0 1) $ do+    putStrLn "admits spare capacity: FAIL"+    exitFailure+  putStrLn ("capacity assertions: PASS (" ++ marker ++ ")")
+ examples/migrations/replacement/LICENSE view
@@ -0,0 +1,27 @@+BSD 3-Clause License++Copyright (c) 2026, Nadeem Bitar+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+   list of conditions and the following disclaimer.+2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.+3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software+   without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ examples/migrations/replacement/cabal.project view
@@ -0,0 +1,1 @@+packages: .
+ examples/migrations/replacement/heni-minimal-fixture.cabal view
@@ -0,0 +1,17 @@+cabal-version: 3.4+name: heni-minimal-fixture+version: 0.1.0.0+build-type: Simple+license: BSD-3-Clause+synopsis: Standalone guard fixture for Heni++test-suite fixture-test+  type: exitcode-stdio-1.0+  default-language: GHC2024+  hs-source-dirs:+    src+    test++  main-is: Main.hs+  other-modules: Capacity+  build-depends: base >=4.21 && <5
+ examples/migrations/replacement/heni.json view
@@ -0,0 +1,51 @@+{+  "schemaVersion": 1,+  "commands": {+    "build": {+      "executable": "cabal",+      "arguments": [+        "build",+        "fixture-test",+        "--offline",+        "-j1"+      ],+      "timeoutSeconds": 60+    },+    "test": {+      "executable": "cabal",+      "arguments": [+        "test",+        "fixture-test",+        "--offline",+        "--test-show-details=direct",+        "-j1"+      ],+      "timeoutSeconds": 60+    }+  },+  "cases": [+    {+      "id": "guard-bypass",+      "description": "Removing the capacity guard must fail the full-capacity assertion.",+      "tags": [+        "regression"+      ],+      "edits": [+        {+          "kind": "replace",+          "path": "src/Capacity.hs",+          "old": "used < limit",+          "new": "True"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "diagnostic",+        "stage": "test",+        "exitCode": 1,+        "outputContains": "rejects full capacity: FAIL"+      }+    }+  ]+}
+ examples/migrations/replacement/src/Capacity.hs view
@@ -0,0 +1,7 @@+module Capacity (admits, marker) where++admits :: Int -> Int -> Bool+admits used limit = used < limit++marker :: String+marker = "baseline"
+ examples/migrations/replacement/test/Main.hs view
@@ -0,0 +1,15 @@+module Main (main) where++import Capacity+import Control.Monad (unless)+import System.Exit (exitFailure)++main :: IO ()+main = do+  unless (not (admits 1 1)) $ do+    putStrLn "rejects full capacity: FAIL"+    exitFailure+  unless (admits 0 1) $ do+    putStrLn "admits spare capacity: FAIL"+    exitFailure+  putStrLn ("capacity assertions: PASS (" ++ marker ++ ")")
+ examples/minimal/cabal.project view
@@ -0,0 +1,1 @@+packages: .
+ examples/minimal/heni-minimal-fixture.cabal view
@@ -0,0 +1,17 @@+cabal-version: 3.4+name: heni-minimal-fixture+version: 0.1.0.0+build-type: Simple+license: BSD-3-Clause+synopsis: Standalone guard fixture for Heni++test-suite fixture-test+  type: exitcode-stdio-1.0+  default-language: GHC2024+  hs-source-dirs:+    src+    test++  main-is: Main.hs+  other-modules: Capacity+  build-depends: base >=4.21 && <5
+ examples/minimal/heni.json view
@@ -0,0 +1,97 @@+{+  "schemaVersion": 1,+  "commands": {+    "build": {+      "executable": "cabal",+      "arguments": [+        "build",+        "fixture-test",+        "--offline",+        "-j1"+      ],+      "timeoutSeconds": 60+    },+    "test": {+      "executable": "cabal",+      "arguments": [+        "test",+        "fixture-test",+        "--offline",+        "--test-show-details=direct",+        "-j1"+      ],+      "timeoutSeconds": 60+    }+  },+  "cases": [+    {+      "id": "guard-bypass",+      "description": "The capacity assertion must detect removal of the guard.",+      "tags": [+        "regression"+      ],+      "edits": [+        {+          "kind": "replace",+          "path": "src/Capacity.hs",+          "old": "used < limit",+          "new": "True"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "diagnostic",+        "stage": "test",+        "exitCode": 1,+        "outputContains": "rejects full capacity: FAIL"+      }+    },+    {+      "id": "survivor",+      "description": "A harmless marker edit demonstrates a surviving mutation.",+      "tags": [+        "example"+      ],+      "edits": [+        {+          "kind": "replace",+          "path": "src/Capacity.hs",+          "old": "baseline",+          "new": "mutant"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "diagnostic",+        "stage": "test",+        "exitCode": 1,+        "outputContains": "rejects full capacity: FAIL"+      }+    },+    {+      "id": "compile-error",+      "description": "An unrelated compile failure cannot prove semantic detection.",+      "tags": [+        "example"+      ],+      "edits": [+        {+          "kind": "replace",+          "path": "src/Capacity.hs",+          "old": "used < limit",+          "new": "missingCapacityGuard"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "diagnostic",+        "stage": "test",+        "exitCode": 1,+        "outputContains": "rejects full capacity: FAIL"+      }+    }+  ]+}
+ examples/minimal/src/Capacity.hs view
@@ -0,0 +1,7 @@+module Capacity (admits, marker) where++admits :: Int -> Int -> Bool+admits used limit = used < limit++marker :: String+marker = "baseline"
+ examples/minimal/test/Main.hs view
@@ -0,0 +1,15 @@+module Main (main) where++import Capacity+import Control.Monad (unless)+import System.Exit (exitFailure)++main :: IO ()+main = do+  unless (not (admits 1 1)) $ do+    putStrLn "rejects full capacity: FAIL"+    exitFailure+  unless (admits 0 1) $ do+    putStrLn "admits spare capacity: FAIL"+    exitFailure+  putStrLn ("capacity assertions: PASS (" ++ marker ++ ")")
+ examples/standalone/Main.hs view
@@ -0,0 +1,22 @@+module Main (main) where++import Control.Monad (unless)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Test.Heni++checked :: (Show e) => Either e a -> a+checked = either (error . show) id++main :: IO ()+main = do+  let build = checked (mkCommandId "build")+      test = checked (mkCommandId "test")+      command executable args = Command executable args Set.empty Nothing [] (checked (mkStageBudget 30000000)) (OutputPolicy 4096)+      commands = Map.fromList [(build, command "ghc" ["-v0", "Checker.hs", "-o", "checker"]), (test, command "./checker" [])]+      define cid new = CaseDefinition (checked (mkCaseId cid)) "Remove the intended capacity guard" Set.empty [Replace (checked (mkRelativeTarget "input")) "anchor" new 1] build (Just test) Nothing (Diagnostic Test 1 "capacity rule violated") GenericCommands+      campaign = checked (mkCampaign commands [(define cid value, noHooks) | (cid, value) <- [("caught", "mutant"), ("survivor", "harmless"), ("wrong", "unrelated")]])+  result <- runCampaign (defaultRunOptions "target" "library-results") campaign+  let RunResult {results, status} = result+  unless ([verdict | CaseResult {verdict} <- results] == [Caught, Survived, WrongFailure] && status == RunFailed) (error (show result))+  putStrLn "Standalone library: caught, survived, wrong-failure"
+ examples/standalone/consumer.cabal view
@@ -0,0 +1,18 @@+cabal-version: 3.4+name: heni-standalone-consumer+version: 0.1.0.0+build-type: Simple+license: BSD-3-Clause++executable consumer+  main-is: Main.hs+  default-language: GHC2024+  default-extensions:+    DuplicateRecordFields+    OverloadedStrings++  ghc-options: -threaded+  build-depends:+    base >=4.21.2 && <4.22,+    containers >=0.7 && <0.9,+    heni ==0.1.0.0,
+ examples/standalone/target/Checker.hs view
@@ -0,0 +1,11 @@+module Main (main) where++import System.Exit (exitFailure)++main :: IO ()+main = do+  input <- readFile "input"+  case input of+    "mutant\n" -> putStrLn "capacity rule violated" >> exitFailure+    "unrelated\n" -> putStrLn "unrelated checker failure" >> exitFailure+    _ -> pure ()
+ examples/standalone/target/heni.json view
@@ -0,0 +1,79 @@+{+  "schemaVersion": 1,+  "commands": {+    "build": {+      "executable": "ghc",+      "arguments": [+        "-v0",+        "Checker.hs",+        "-o",+        "checker"+      ]+    },+    "test": {+      "executable": "./checker"+    }+  },+  "cases": [+    {+      "id": "caught",+      "description": "Remove the intended capacity guard",+      "edits": [+        {+          "kind": "replace",+          "path": "input",+          "old": "anchor",+          "new": "mutant"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "diagnostic",+        "stage": "test",+        "exitCode": 1,+        "outputContains": "capacity rule violated"+      }+    },+    {+      "id": "survivor",+      "description": "Remove the intended capacity guard",+      "edits": [+        {+          "kind": "replace",+          "path": "input",+          "old": "anchor",+          "new": "harmless"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "diagnostic",+        "stage": "test",+        "exitCode": 1,+        "outputContains": "capacity rule violated"+      }+    },+    {+      "id": "wrong",+      "description": "Remove the intended capacity guard",+      "edits": [+        {+          "kind": "replace",+          "path": "input",+          "old": "anchor",+          "new": "unrelated"+        }+      ],+      "build": "build",+      "test": "test",+      "expect": {+        "kind": "diagnostic",+        "stage": "test",+        "exitCode": 1,+        "outputContains": "capacity rule violated"+      }+    }+  ]+}
+ examples/standalone/target/input view
@@ -0,0 +1,1 @@+anchor
+ heni.cabal view
@@ -0,0 +1,327 @@+cabal-version: 3.4+name: heni+version: 0.1.0.0+synopsis: Deliberate mutation testing with attributable evidence+description:+  Run checked mutations in disposable workspaces and require precise+  failure evidence from existing tests and commands.++homepage: https://github.com/shinzui/heni+bug-reports: https://github.com/shinzui/heni/issues+license: BSD-3-Clause+license-file: LICENSE+author: Nadeem Bitar+maintainer: nadeem@gmail.com+copyright: 2026 Nadeem Bitar+category: Testing+build-type: Simple+tested-with: ghc ==9.12.4+extra-doc-files:+  CHANGELOG.md+  README.md+  docs/*.md+  docs/adr/*.md+  docs/design/*.md+  docs/research/*.md+  docs/validation/migration-measurements.json+  docs/validation/migration-results.md+  docs/validation/migration-source-identities.json++extra-source-files:+  schemas/*.json+  scripts/check-schemas.py+  scripts/check-sdist.py+  scripts/check-sdist.sh+  scripts/measure-migration.py+  scripts/test-migrations.sh++data-files:+  examples/adapters/*.json+  examples/adapters/README.md+  examples/adapters/input+  examples/migrations/README.md+  examples/migrations/diagnostic-checker/LICENSE+  examples/migrations/diagnostic-checker/heni.json+  examples/migrations/diagnostic-checker/policy.txt+  examples/migrations/diagnostic-checker/tools/Build.hs+  examples/migrations/diagnostic-checker/tools/Check.hs+  examples/migrations/generated-code/LICENSE+  examples/migrations/generated-code/cabal.project+  examples/migrations/generated-code/generated-fixture.cabal+  examples/migrations/generated-code/heni.json+  examples/migrations/generated-code/model.txt+  examples/migrations/generated-code/test/Main.hs+  examples/migrations/generated-code/tools/Build.hs+  examples/migrations/patch-catalogue/LICENSE+  examples/migrations/patch-catalogue/cabal.project+  examples/migrations/patch-catalogue/heni-minimal-fixture.cabal+  examples/migrations/patch-catalogue/heni.json+  examples/migrations/patch-catalogue/patches/guard-bypass.patch+  examples/migrations/patch-catalogue/patches/survivor.patch+  examples/migrations/patch-catalogue/src/Capacity.hs+  examples/migrations/patch-catalogue/test/Main.hs+  examples/migrations/replacement/LICENSE+  examples/migrations/replacement/cabal.project+  examples/migrations/replacement/heni-minimal-fixture.cabal+  examples/migrations/replacement/heni.json+  examples/migrations/replacement/src/Capacity.hs+  examples/migrations/replacement/test/Main.hs+  examples/minimal/*.cabal+  examples/minimal/cabal.project+  examples/minimal/heni.json+  examples/minimal/src/*.hs+  examples/minimal/test/*.hs+  examples/standalone/*.cabal+  examples/standalone/*.hs+  examples/standalone/target/*.hs+  examples/standalone/target/*.json+  examples/standalone/target/input++source-repository head+  type: git+  location: https://github.com/shinzui/heni.git++flag migration-tests+  description: Run cross-package migration trials with an explicitly supplied CLI executable.+  default: False+  manual: True++common common+  default-language: GHC2024+  default-extensions:+    DeriveAnyClass+    DuplicateRecordFields+    OverloadedLabels+    OverloadedStrings++  ghc-options:+    -Wall+    -Wcompat+    -Wincomplete-uni-patterns+    -Wincomplete-record-updates++  build-depends:+    base >=4.21.2 && <4.22,+    containers >=0.7 && <0.9,+    text >=2.1.4 && <2.2,++library+  import: common+  hs-source-dirs: src+  build-depends:+    aeson >=2.3.2 && <2.4,+    bytestring >=0.12.2 && <0.13,+    crypton >=2.2.5 && <2.3,+    directory >=1.3.10.1 && <1.4,+    filepath >=1.5.5 && <1.6,+    process >=1.6.26.1 && <1.7,+    stm >=2.5.3.1 && <2.6,+    unix >=2.8.8 && <2.9,++  exposed-modules:+    Test.Heni+    Test.Heni.Adapter+    Test.Heni.BuildReuse+    Test.Heni.Case+    Test.Heni.Command+    Test.Heni.Crash+    Test.Heni.Edit+    Test.Heni.Expectation+    Test.Heni.Manifest+    Test.Heni.Process+    Test.Heni.Replay+    Test.Heni.Report+    Test.Heni.Resource+    Test.Heni.Result+    Test.Heni.Runner+    Test.Heni.Scheduler+    Test.Heni.Workspace++  other-modules:+    Test.Heni.Internal.Baseline+    Test.Heni.Internal.Codec+    Test.Heni.Internal.Files+    Test.Heni.Internal.Model+    Test.Heni.Internal.Prelude+    Test.Heni.Internal.Process.Posix++executable heni-fixture+  import: common+  hs-source-dirs: test/fixture+  main-is: Main.hs+  build-depends:+    bytestring >=0.12.2 && <0.13,+    directory >=1.3.10.1 && <1.4,+    filepath >=1.5.5 && <1.6,+    process >=1.6.26.1 && <1.7,+    unix >=2.8.8 && <2.9,++  ghc-options: -threaded++executable heni-evidence-fixture+  import: common+  hs-source-dirs: test/evidence-fixture+  main-is: Main.hs+  build-depends:+    bytestring >=0.12.2 && <0.13,+    heni ==0.1.0.0,+    unix >=2.8.8 && <2.9,++  ghc-options: -threaded++executable heni-library-example+  import: common+  hs-source-dirs: examples/library+  main-is: Main.hs+  other-modules: Definitions+  build-depends: heni+  ghc-options: -threaded++test-suite heni-runner+  import: common+  type: exitcode-stdio-1.0+  hs-source-dirs:+    test/runner+    examples/library++  main-is: Main.hs+  other-modules:+    Definitions+    Paths_heni++  autogen-modules: Paths_heni+  build-depends:+    aeson >=2.3.2 && <2.4,+    bytestring >=0.12.2 && <0.13,+    directory >=1.3.10.1 && <1.4,+    filepath >=1.5.5 && <1.6,+    heni ==0.1.0.0,+    unix >=2.8.8 && <2.9,++  build-tool-depends: heni:heni-fixture+  ghc-options: -threaded++test-suite heni-process+  import: common+  type: exitcode-stdio-1.0+  hs-source-dirs: test/process+  main-is: Main.hs+  build-depends:+    bytestring >=0.12.2 && <0.13,+    directory >=1.3.10.1 && <1.4,+    filepath >=1.5.5 && <1.6,+    heni ==0.1.0.0,+    unix >=2.8.8 && <2.9,++  build-tool-depends: heni:heni-fixture+  ghc-options: -threaded++test-suite heni-adapter+  import: common+  type: exitcode-stdio-1.0+  hs-source-dirs: test/adapter+  main-is: Main.hs+  build-depends:+    aeson >=2.3.2 && <2.4,+    bytestring >=0.12.2 && <0.13,+    directory >=1.3.10.1 && <1.4,+    filepath >=1.5.5 && <1.6,+    heni ==0.1.0.0,+    unix >=2.8.8 && <2.9,++  build-tool-depends: heni:heni-evidence-fixture+  ghc-options: -threaded++test-suite heni-unit+  import: common+  type: exitcode-stdio-1.0+  hs-source-dirs:+    test/unit+    test/api++  main-is: Main.hs+  other-modules:+    Consumer+    Labels++  build-depends: heni+  ghc-options: -threaded++test-suite heni-workspace+  import: common+  type: exitcode-stdio-1.0+  hs-source-dirs: test/workspace+  main-is: Main.hs+  build-depends:+    bytestring >=0.12.2 && <0.13,+    directory >=1.3.10.1 && <1.4,+    filepath >=1.5.5 && <1.6,+    heni ==0.1.0.0,+    process >=1.6.26.1 && <1.7,+    unix >=2.8.8 && <2.9,++  ghc-options: -threaded++test-suite heni-scheduler+  import: common+  type: exitcode-stdio-1.0+  hs-source-dirs: test/scheduler+  main-is: Main.hs+  build-depends:+    bytestring >=0.12.2 && <0.13,+    directory >=1.3.10.1 && <1.4,+    filepath >=1.5.5 && <1.6,+    heni ==0.1.0.0,+    unix >=2.8.8 && <2.9,++  build-tool-depends: heni:heni-fixture+  ghc-options: -threaded++executable heni-campaign-bench+  import: common+  hs-source-dirs:+    bench/campaign+    examples/library++  main-is: Main.hs+  other-modules:+    Definitions+    Paths_heni++  autogen-modules: Paths_heni+  build-depends:+    aeson >=2.3.2 && <2.4,+    bytestring >=0.12.2 && <0.13,+    directory >=1.3.10.1 && <1.4,+    filepath >=1.5.5 && <1.6,+    heni ==0.1.0.0,+    unix >=2.8.8 && <2.9,++  ghc-options: -threaded++test-suite heni-migration+  import: common+  type: exitcode-stdio-1.0+  hs-source-dirs: test/migration+  main-is: Main.hs+  other-modules:+    Legacy+    Paths_heni++  autogen-modules: Paths_heni+  build-depends:+    aeson >=2.3.2 && <2.4,+    bytestring >=0.12.2 && <0.13,+    directory >=1.3.10.1 && <1.4,+    filepath >=1.5.5 && <1.6,+    heni ==0.1.0.0,+    process >=1.6.26.1 && <1.7,+    unix >=2.8.8 && <2.9,++  build-tool-depends:+    heni:heni-evidence-fixture,+    heni:heni-fixture,++  if !flag(migration-tests)+    buildable: False+  ghc-options: -threaded
+ schemas/manifest-v1.schema.json view
@@ -0,0 +1,680 @@+{+  "$schema": "https://json-schema.org/draft/2020-12/schema",+  "$id": "https://raw.githubusercontent.com/shinzui/heni/master/schemas/manifest-v1.schema.json",+  "title": "Heni manifest v1",+  "type": "object",+  "properties": {+    "schemaVersion": {+      "const": 1+    },+    "sourceRoot": {+      "type": "string",+      "minLength": 1,+      "pattern": "^(?!/)[^\\u0000]+$",+      "default": "."+    },+    "sourcePolicy": {+      "type": "object",+      "properties": {+        "includes": {+          "type": "array",+          "items": {+            "type": "string",+            "minLength": 1,+            "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"+          },+          "default": []+        },+        "excludes": {+          "type": "array",+          "items": {+            "type": "string",+            "minLength": 1,+            "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"+          },+          "default": []+        }+      },+      "required": [],+      "additionalProperties": false+    },+    "commands": {+      "type": "object",+      "propertyNames": {+        "type": "string",+        "pattern": "^[A-Za-z0-9_.-]+$"+      },+      "additionalProperties": {+        "$ref": "#/$defs/command"+      }+    },+    "cases": {+      "type": "array",+      "items": {+        "$ref": "#/$defs/case"+      },+      "minItems": 1+    },+    "resources": {+      "type": "array",+      "items": {+        "$ref": "#/$defs/resource"+      },+      "default": []+    },+    "execution": {+      "type": "object",+      "properties": {+        "workers": {+          "type": "integer",+          "minimum": 1,+          "maximum": 256,+          "default": 1+        },+        "baselineReuse": {+          "anyOf": [+            {+              "type": "null"+            },+            {+              "type": "object",+              "properties": {+                "toolchain": {+                  "type": "string",+                  "pattern": "\\S"+                },+                "adapter": {+                  "anyOf": [+                    {+                      "type": "string",+                      "pattern": "\\S"+                    },+                    {+                      "type": "null"+                    }+                  ]+                }+              },+              "required": [+                "toolchain"+              ],+              "additionalProperties": false+            }+          ]+        }+      },+      "additionalProperties": false+    }+  },+  "required": [+    "schemaVersion",+    "commands",+    "cases"+  ],+  "additionalProperties": false,+  "$defs": {+    "command": {+      "type": "object",+      "properties": {+        "executable": {+          "type": "string",+          "minLength": 1+        },+        "arguments": {+          "type": "array",+          "items": {+            "type": "string"+          },+          "default": []+        },+        "sensitiveArguments": {+          "type": "array",+          "items": {+            "type": "integer",+            "minimum": 0+          },+          "uniqueItems": true,+          "default": []+        },+        "workingDirectory": {+          "anyOf": [+            {+              "type": "string",+              "minLength": 1,+              "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"+            },+            {+              "type": "null"+            }+          ]+        },+        "environment": {+          "type": "object",+          "propertyNames": {+            "pattern": "^[^=\\u0000]+$"+          },+          "additionalProperties": {+            "type": "string"+          },+          "default": {}+        },+        "timeoutSeconds": {+          "type": "number",+          "exclusiveMinimum": 0,+          "maximum": 86400,+          "default": 30+        },+        "outputLimitBytes": {+          "type": "integer",+          "minimum": 1,+          "default": 1048576+        }+      },+      "required": [+        "executable"+      ],+      "additionalProperties": false+    },+    "edit": {+      "oneOf": [+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "replace"+            },+            "path": {+              "type": "string",+              "minLength": 1,+              "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"+            },+            "old": {+              "type": "string",+              "minLength": 1+            },+            "new": {+              "type": "string"+            },+            "occurrences": {+              "type": "integer",+              "minimum": 1,+              "default": 1+            }+          },+          "required": [+            "kind",+            "path",+            "old",+            "new"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "create"+            },+            "path": {+              "type": "string",+              "minLength": 1,+              "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"+            },+            "content": {+              "type": "string"+            }+          },+          "required": [+            "kind",+            "path",+            "content"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "delete"+            },+            "path": {+              "type": "string",+              "minLength": 1,+              "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*/$)(?!.*[\\\\:\\u0000]).+$"+            },+            "expected": {+              "type": "string"+            }+          },+          "required": [+            "kind",+            "path",+            "expected"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "patch"+            },+            "content": {+              "type": "string",+              "minLength": 1+            }+          },+          "required": [+            "kind",+            "content"+          ],+          "additionalProperties": false+        }+      ]+    },+    "expectation": {+      "oneOf": [+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "diagnostic"+            },+            "stage": {+              "enum": [+                "build",+                "test"+              ]+            },+            "exitCode": {+              "type": "integer",+              "minimum": 1+            },+            "outputContains": {+              "type": "string",+              "minLength": 1+            }+          },+          "required": [+            "kind",+            "stage",+            "exitCode",+            "outputContains"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "build-rejection"+            },+            "exitCode": {+              "type": "integer",+              "minimum": 1+            },+            "outputContains": {+              "type": "string",+              "minLength": 1+            }+          },+          "required": [+            "kind",+            "exitCode",+            "outputContains"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "control"+            }+          },+          "required": [+            "kind"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "named-tests"+            },+            "failures": {+              "type": "array",+              "items": {+                "type": "string",+                "minLength": 1+              },+              "uniqueItems": true,+              "minItems": 1+            },+            "replayRequired": {+              "type": "boolean",+              "default": false+            }+          },+          "required": [+            "kind",+            "failures"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "crash"+            },+            "signal": {+              "type": "integer",+              "minimum": 1+            },+            "probe": {+              "type": "string",+              "minLength": 1+            }+          },+          "required": [+            "kind",+            "signal",+            "probe"+          ],+          "additionalProperties": false+        }+      ]+    },+    "case": {+      "type": "object",+      "properties": {+        "id": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "description": {+          "type": "string",+          "pattern": "\\S"+        },+        "tags": {+          "type": "array",+          "items": {+            "type": "string"+          },+          "uniqueItems": true,+          "default": []+        },+        "edits": {+          "type": "array",+          "items": {+            "$ref": "#/$defs/edit"+          },+          "default": []+        },+        "build": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "test": {+          "anyOf": [+            {+              "type": "string",+              "pattern": "^[A-Za-z0-9_.-]+$"+            },+            {+              "type": "null"+            }+          ]+        },+        "selector": {+          "anyOf": [+            {+              "type": "string",+              "pattern": "\\S"+            },+            {+              "type": "null"+            }+          ]+        },+        "expect": {+          "$ref": "#/$defs/expectation"+        },+        "adapter": {+          "enum": [+            null,+            "heni-v1"+          ]+        },+        "resources": {+          "type": "array",+          "items": {+            "type": "string",+            "pattern": "^[A-Z][A-Z0-9_]*$"+          },+          "uniqueItems": true,+          "default": []+        }+      },+      "required": [+        "id",+        "description",+        "build",+        "expect"+      ],+      "additionalProperties": false,+      "allOf": [+        {+          "if": {+            "properties": {+              "expect": {+                "properties": {+                  "kind": {+                    "const": "control"+                  }+                }+              }+            }+          },+          "then": {+            "properties": {+              "edits": {+                "maxItems": 0+              },+              "test": {+                "type": "string",+                "pattern": "^[A-Za-z0-9_.-]+$"+              }+            },+            "required": [+              "test"+            ]+          },+          "else": {+            "required": [+              "edits"+            ],+            "properties": {+              "edits": {+                "minItems": 1+              }+            }+          }+        },+        {+          "if": {+            "properties": {+              "expect": {+                "properties": {+                  "kind": {+                    "const": "diagnostic"+                  },+                  "stage": {+                    "const": "test"+                  }+                },+                "required": [+                  "stage"+                ]+              }+            }+          },+          "then": {+            "required": [+              "test"+            ],+            "properties": {+              "test": {+                "type": "string",+                "pattern": "^[A-Za-z0-9_.-]+$"+              }+            }+          }+        },+        {+          "if": {+            "properties": {+              "expect": {+                "properties": {+                  "kind": {+                    "enum": [+                      "named-tests",+                      "crash"+                    ]+                  }+                }+              }+            }+          },+          "then": {+            "required": [+              "test"+            ],+            "properties": {+              "test": {+                "type": "string"+              }+            }+          }+        },+        {+          "if": {+            "properties": {+              "expect": {+                "properties": {+                  "kind": {+                    "const": "named-tests"+                  }+                }+              }+            }+          },+          "then": {+            "required": [+              "adapter"+            ],+            "properties": {+              "adapter": {+                "const": "heni-v1"+              }+            }+          }+        },+        {+          "if": {+            "properties": {+              "expect": {+                "properties": {+                  "kind": {+                    "const": "crash"+                  }+                }+              }+            }+          },+          "then": {+            "properties": {+              "adapter": {+                "type": "null"+              }+            }+          }+        }+      ]+    },+    "resource": {+      "type": "object",+      "properties": {+        "name": {+          "type": "string",+          "pattern": "^[A-Z][A-Z0-9_]*$"+        },+        "scope": {+          "enum": [+            "campaign",+            "worker",+            "case"+          ],+          "default": "case"+        },+        "capacity": {+          "type": "integer",+          "minimum": 1,+          "default": 1+        },+        "identity": {+          "type": "string",+          "minLength": 1+        },+        "version": {+          "type": "string",+          "minLength": 1+        },+        "epoch": {+          "anyOf": [+            {+              "type": "string",+              "minLength": 1+            },+            {+              "type": "null"+            }+          ]+        },+        "environment": {+          "type": "object",+          "propertyNames": {+            "pattern": "^HENI_RESOURCE_[A-Z][A-Z0-9_]*_[^=\\u0000]*$"+          },+          "additionalProperties": {+            "type": "string"+          },+          "default": {}+        },+        "setup": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "reset": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "teardown": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        }+      },+      "required": [+        "name",+        "identity",+        "version",+        "setup",+        "reset",+        "teardown"+      ],+      "additionalProperties": false+    }+  }+}
+ schemas/report-v1.schema.json view
@@ -0,0 +1,735 @@+{+  "$schema": "https://json-schema.org/draft/2020-12/schema",+  "$id": "https://raw.githubusercontent.com/shinzui/heni/master/schemas/report-v1.schema.json",+  "title": "Heni report v1",+  "type": "object",+  "properties": {+    "schemaVersion": {+      "const": 1+    },+    "toolVersion": {+      "type": "string"+    },+    "metadata": {+      "$ref": "#/$defs/metadata"+    },+    "result": {+      "$ref": "#/$defs/result"+    }+  },+  "required": [+    "schemaVersion",+    "toolVersion",+    "metadata",+    "result"+  ],+  "additionalProperties": false,+  "$defs": {+    "stageName": {+      "type": "string",+      "enum": [+        "setup",+        "build",+        "test",+        "replay",+        "teardown"+      ]+    },+    "processOutcome": {+      "oneOf": [+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "exit"+            },+            "code": {+              "type": "integer",+              "minimum": 0+            }+          },+          "required": [+            "kind",+            "code"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "signal"+            },+            "signal": {+              "type": "integer",+              "minimum": 1+            }+          },+          "required": [+            "kind",+            "signal"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "timeout"+            }+          },+          "required": [+            "kind"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "cancelled"+            }+          },+          "required": [+            "kind"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "launch-failed"+            },+            "message": {+              "type": "string"+            }+          },+          "required": [+            "kind",+            "message"+          ],+          "additionalProperties": false+        }+      ]+    },+    "selection": {+      "type": "object",+      "properties": {+        "discovered": {+          "anyOf": [+            {+              "type": "integer"+            },+            {+              "type": "null"+            }+          ]+        },+        "selected": {+          "anyOf": [+            {+              "type": "integer"+            },+            {+              "type": "null"+            }+          ]+        },+        "executed": {+          "anyOf": [+            {+              "type": "integer"+            },+            {+              "type": "null"+            }+          ]+        },+        "executedIds": {+          "type": "array",+          "items": {+            "type": "string"+          },+          "uniqueItems": true+        },+        "failedIds": {+          "type": "array",+          "items": {+            "type": "string"+          },+          "uniqueItems": true+        },+        "skippedIds": {+          "type": "array",+          "items": {+            "type": "string"+          },+          "uniqueItems": true+        },+        "setupErrors": {+          "type": "array",+          "items": {+            "type": "string"+          }+        }+      },+      "required": [+        "discovered",+        "selected",+        "executed",+        "executedIds",+        "failedIds",+        "skippedIds",+        "setupErrors"+      ],+      "additionalProperties": false+    },+    "replay": {+      "type": "object",+      "properties": {+        "token": {+          "type": "string"+        },+        "generator": {+          "type": "string"+        },+        "failures": {+          "type": "array",+          "items": {+            "type": "string"+          },+          "uniqueItems": true+        }+      },+      "required": [+        "token",+        "generator",+        "failures"+      ],+      "additionalProperties": false+    },+    "stage": {+      "type": "object",+      "properties": {+        "stage": {+          "$ref": "#/$defs/stageName"+        },+        "outcome": {+          "$ref": "#/$defs/processOutcome"+        },+        "witnesses": {+          "type": "string"+        },+        "evidenceUnavailable": {+          "type": "boolean"+        },+        "selection": {+          "$ref": "#/$defs/selection"+        },+        "replay": {+          "anyOf": [+            {+              "$ref": "#/$defs/replay"+            },+            {+              "type": "null"+            }+          ]+        },+        "durationSeconds": {+          "type": "number",+          "minimum": 0+        },+        "logPaths": {+          "type": "array",+          "items": {+            "type": "string",+            "minLength": 1,+            "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*[\\\\:\\u0000]).+$"+          }+        }+      },+      "required": [+        "stage",+        "outcome",+        "witnesses",+        "evidenceUnavailable",+        "selection",+        "replay",+        "durationSeconds",+        "logPaths"+      ],+      "additionalProperties": false+    },+    "baseline": {+      "oneOf": [+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "passed"+            }+          },+          "required": [+            "kind"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "unavailable"+            }+          },+          "required": [+            "kind"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "failed"+            },+            "message": {+              "type": "string"+            }+          },+          "required": [+            "kind",+            "message"+          ],+          "additionalProperties": false+        }+      ]+    },+    "mutation": {+      "oneOf": [+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "changed"+            }+          },+          "required": [+            "kind"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "unchanged"+            }+          },+          "required": [+            "kind"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "rejected"+            },+            "message": {+              "type": "string"+            }+          },+          "required": [+            "kind",+            "message"+          ],+          "additionalProperties": false+        }+      ]+    },+    "caseEvidence": {+      "type": "object",+      "properties": {+        "baseline": {+          "$ref": "#/$defs/baseline"+        },+        "mutation": {+          "$ref": "#/$defs/mutation"+        },+        "stages": {+          "type": "array",+          "items": {+            "$ref": "#/$defs/stage"+          }+        },+        "infrastructureErrors": {+          "type": "array",+          "items": {+            "type": "string"+          }+        },+        "cleanupErrors": {+          "type": "array",+          "items": {+            "type": "string"+          }+        },+        "stopped": {+          "anyOf": [+            {+              "type": "string"+            },+            {+              "type": "null"+            }+          ]+        }+      },+      "required": [+        "baseline",+        "mutation",+        "stages",+        "infrastructureErrors",+        "cleanupErrors",+        "stopped"+      ],+      "additionalProperties": false+    },+    "caseResult": {+      "type": "object",+      "properties": {+        "id": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "verdict": {+          "type": "string",+          "enum": [+            "caught",+            "survived",+            "invalid-mutation",+            "uncovered",+            "unexpected-build-failure",+            "wrong-failure",+            "timed-out",+            "infrastructure-error",+            "not-run"+          ]+        },+        "detection": {+          "anyOf": [+            {+              "type": "string",+              "enum": [+                "semantic-named",+                "generic-diagnostic",+                "expected-build-rejection",+                "explicit-crash",+                "control-passed"+              ]+            },+            {+              "type": "null"+            }+          ]+        },+        "semantic": {+          "type": "boolean"+        },+        "control": {+          "type": "boolean"+        },+        "reason": {+          "type": "string"+        },+        "evidence": {+          "$ref": "#/$defs/caseEvidence"+        }+      },+      "required": [+        "id",+        "verdict",+        "detection",+        "semantic",+        "control",+        "reason",+        "evidence"+      ],+      "additionalProperties": false+    },+    "failure": {+      "oneOf": [+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "baseline"+            },+            "affected": {+              "type": "array",+              "items": {+                "type": "string",+                "pattern": "^[A-Za-z0-9_.-]+$"+              }+            },+            "message": {+              "type": "string"+            }+          },+          "required": [+            "kind",+            "affected",+            "message"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "control"+            },+            "id": {+              "type": "string",+              "pattern": "^[A-Za-z0-9_.-]+$"+            },+            "message": {+              "type": "string"+            }+          },+          "required": [+            "kind",+            "id",+            "message"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "infrastructure"+            },+            "message": {+              "type": "string"+            }+          },+          "required": [+            "kind",+            "message"+          ],+          "additionalProperties": false+        },+        {+          "type": "object",+          "properties": {+            "kind": {+              "const": "interrupted"+            },+            "message": {+              "type": "string"+            }+          },+          "required": [+            "kind",+            "message"+          ],+          "additionalProperties": false+        }+      ]+    },+    "score": {+      "type": "object",+      "properties": {+        "caught": {+          "type": "integer",+          "minimum": 0+        },+        "total": {+          "type": "integer",+          "minimum": 1+        },+        "complete": {+          "type": "boolean"+        }+      },+      "required": [+        "caught",+        "total",+        "complete"+      ],+      "additionalProperties": false+    },+    "result": {+      "type": "object",+      "properties": {+        "selectedIds": {+          "type": "array",+          "items": {+            "type": "string",+            "pattern": "^[A-Za-z0-9_.-]+$"+          },+          "minItems": 1,+          "uniqueItems": true+        },+        "cases": {+          "type": "array",+          "items": {+            "$ref": "#/$defs/caseResult"+          },+          "minItems": 1+        },+        "failures": {+          "type": "array",+          "items": {+            "$ref": "#/$defs/failure"+          }+        },+        "status": {+          "type": "string",+          "enum": [+            "passed",+            "failed",+            "untrustworthy",+            "interrupted"+          ]+        },+        "semanticScore": {+          "anyOf": [+            {+              "$ref": "#/$defs/score"+            },+            {+              "type": "null"+            }+          ]+        }+      },+      "required": [+        "selectedIds",+        "cases",+        "failures",+        "status",+        "semanticScore"+      ],+      "additionalProperties": false+    },+    "metadata": {+      "type": "object",+      "properties": {+        "platform": {+          "type": "object",+          "properties": {+            "os": {+              "type": "string"+            },+            "arch": {+              "type": "string"+            },+            "compiler": {+              "type": "string"+            },+            "compilerVersion": {+              "type": "string"+            }+          },+          "required": [+            "os",+            "arch",+            "compiler",+            "compilerVersion"+          ],+          "additionalProperties": false+        },+        "sourcePolicy": {+          "type": "string"+        },+        "commands": {+          "type": "object"+        },+        "definitions": {+          "type": "array"+        },+        "caseDetails": {+          "type": "object"+        },+        "resources": {+          "type": "array"+        },+        "snapshot": {+          "type": "object",+          "properties": {+            "identity": {+              "type": "string",+              "pattern": "^[0-9a-f]{64}$"+            },+            "policy": {+              "type": "object",+              "properties": {+                "includes": {+                  "type": "array",+                  "items": {+                    "type": "string",+                    "minLength": 1,+                    "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*[\\\\:\\u0000]).+$"+                  }+                },+                "excludes": {+                  "type": "array",+                  "items": {+                    "type": "string",+                    "minLength": 1,+                    "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*[\\\\:\\u0000]).+$"+                  }+                },+                "defaultExcludedNames": {+                  "type": "array",+                  "items": {+                    "type": "string"+                  }+                },+                "defaultExcludedPrefixes": {+                  "type": "array",+                  "items": {+                    "type": "string"+                  }+                }+              },+              "required": [+                "includes",+                "excludes",+                "defaultExcludedNames",+                "defaultExcludedPrefixes"+              ],+              "additionalProperties": false+            }+          },+          "required": [+            "identity",+            "policy"+          ],+          "additionalProperties": false+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "execution": {+          "type": "object"+        }+      },+      "required": [+        "platform",+        "sourcePolicy",+        "commands",+        "definitions",+        "caseDetails",+        "resources"+      ],+      "additionalProperties": false+    }+  }+}
+ schemas/test-evidence-v1.schema.json view
@@ -0,0 +1,691 @@+{+  "$schema": "https://json-schema.org/draft/2020-12/schema",+  "$id": "https://raw.githubusercontent.com/shinzui/heni/master/schemas/test-evidence-v1.schema.json",+  "title": "Heni test-evidence v1 JSON Lines record",+  "description": "Each line is one record; the core additionally validates attempt identity, sequence, lifecycle, membership and completion counts.",+  "oneOf": [+    {+      "type": "object",+      "properties": {+        "protocolVersion": {+          "const": 1+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "caseId": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "stage": {+          "enum": [+            "setup",+            "build",+            "test",+            "replay",+            "teardown"+          ]+        },+        "role": {+          "enum": [+            "baseline",+            "candidate"+          ]+        },+        "attempt": {+          "type": "string",+          "minLength": 1+        },+        "sequence": {+          "type": "integer",+          "minimum": 0+        },+        "kind": {+          "const": "begin"+        },+        "producerVersion": {+          "type": "string",+          "minLength": 1+        },+        "discoveredIds": {+          "anyOf": [+            {+              "type": "array",+              "items": {+                "type": "string",+                "minLength": 1+              },+              "uniqueItems": true+            },+            {+              "type": "null"+            }+          ]+        },+        "selectedIds": {+          "type": "array",+          "items": {+            "type": "string",+            "minLength": 1+          },+          "uniqueItems": true+        }+      },+      "required": [+        "protocolVersion",+        "runId",+        "caseId",+        "stage",+        "role",+        "attempt",+        "sequence",+        "kind",+        "producerVersion",+        "discoveredIds",+        "selectedIds"+      ],+      "additionalProperties": false+    },+    {+      "type": "object",+      "properties": {+        "protocolVersion": {+          "const": 1+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "caseId": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "stage": {+          "enum": [+            "setup",+            "build",+            "test",+            "replay",+            "teardown"+          ]+        },+        "role": {+          "enum": [+            "baseline",+            "candidate"+          ]+        },+        "attempt": {+          "type": "string",+          "minLength": 1+        },+        "sequence": {+          "type": "integer",+          "minimum": 0+        },+        "kind": {+          "const": "started"+        },+        "testId": {+          "type": "string",+          "minLength": 1+        }+      },+      "required": [+        "protocolVersion",+        "runId",+        "caseId",+        "stage",+        "role",+        "attempt",+        "sequence",+        "kind",+        "testId"+      ],+      "additionalProperties": false+    },+    {+      "type": "object",+      "properties": {+        "protocolVersion": {+          "const": 1+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "caseId": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "stage": {+          "enum": [+            "setup",+            "build",+            "test",+            "replay",+            "teardown"+          ]+        },+        "role": {+          "enum": [+            "baseline",+            "candidate"+          ]+        },+        "attempt": {+          "type": "string",+          "minLength": 1+        },+        "sequence": {+          "type": "integer",+          "minimum": 0+        },+        "kind": {+          "const": "passed"+        },+        "testId": {+          "type": "string",+          "minLength": 1+        }+      },+      "required": [+        "protocolVersion",+        "runId",+        "caseId",+        "stage",+        "role",+        "attempt",+        "sequence",+        "kind",+        "testId"+      ],+      "additionalProperties": false+    },+    {+      "type": "object",+      "properties": {+        "protocolVersion": {+          "const": 1+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "caseId": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "stage": {+          "enum": [+            "setup",+            "build",+            "test",+            "replay",+            "teardown"+          ]+        },+        "role": {+          "enum": [+            "baseline",+            "candidate"+          ]+        },+        "attempt": {+          "type": "string",+          "minLength": 1+        },+        "sequence": {+          "type": "integer",+          "minimum": 0+        },+        "kind": {+          "const": "failed"+        },+        "testId": {+          "type": "string",+          "minLength": 1+        }+      },+      "required": [+        "protocolVersion",+        "runId",+        "caseId",+        "stage",+        "role",+        "attempt",+        "sequence",+        "kind",+        "testId"+      ],+      "additionalProperties": false+    },+    {+      "type": "object",+      "properties": {+        "protocolVersion": {+          "const": 1+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "caseId": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "stage": {+          "enum": [+            "setup",+            "build",+            "test",+            "replay",+            "teardown"+          ]+        },+        "role": {+          "enum": [+            "baseline",+            "candidate"+          ]+        },+        "attempt": {+          "type": "string",+          "minLength": 1+        },+        "sequence": {+          "type": "integer",+          "minimum": 0+        },+        "kind": {+          "const": "skipped"+        },+        "testId": {+          "type": "string",+          "minLength": 1+        }+      },+      "required": [+        "protocolVersion",+        "runId",+        "caseId",+        "stage",+        "role",+        "attempt",+        "sequence",+        "kind",+        "testId"+      ],+      "additionalProperties": false+    },+    {+      "type": "object",+      "properties": {+        "protocolVersion": {+          "const": 1+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "caseId": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "stage": {+          "enum": [+            "setup",+            "build",+            "test",+            "replay",+            "teardown"+          ]+        },+        "role": {+          "enum": [+            "baseline",+            "candidate"+          ]+        },+        "attempt": {+          "type": "string",+          "minLength": 1+        },+        "sequence": {+          "type": "integer",+          "minimum": 0+        },+        "kind": {+          "const": "setup-failed"+        },+        "testId": {+          "type": "string",+          "minLength": 1+        },+        "message": {+          "type": "string"+        }+      },+      "required": [+        "protocolVersion",+        "runId",+        "caseId",+        "stage",+        "role",+        "attempt",+        "sequence",+        "kind",+        "testId",+        "message"+      ],+      "additionalProperties": false+    },+    {+      "type": "object",+      "properties": {+        "protocolVersion": {+          "const": 1+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "caseId": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "stage": {+          "enum": [+            "setup",+            "build",+            "test",+            "replay",+            "teardown"+          ]+        },+        "role": {+          "enum": [+            "baseline",+            "candidate"+          ]+        },+        "attempt": {+          "type": "string",+          "minLength": 1+        },+        "sequence": {+          "type": "integer",+          "minimum": 0+        },+        "kind": {+          "const": "setup-error"+        },+        "message": {+          "type": "string"+        }+      },+      "required": [+        "protocolVersion",+        "runId",+        "caseId",+        "stage",+        "role",+        "attempt",+        "sequence",+        "kind",+        "message"+      ],+      "additionalProperties": false+    },+    {+      "type": "object",+      "properties": {+        "protocolVersion": {+          "const": 1+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "caseId": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "stage": {+          "enum": [+            "setup",+            "build",+            "test",+            "replay",+            "teardown"+          ]+        },+        "role": {+          "enum": [+            "baseline",+            "candidate"+          ]+        },+        "attempt": {+          "type": "string",+          "minLength": 1+        },+        "sequence": {+          "type": "integer",+          "minimum": 0+        },+        "kind": {+          "const": "replay"+        },+        "replay": {+          "type": "object",+          "properties": {+            "token": {+              "type": "string",+              "minLength": 1+            },+            "generator": {+              "type": "string",+              "minLength": 1+            },+            "failures": {+              "type": "array",+              "items": {+                "type": "string",+                "minLength": 1+              },+              "uniqueItems": true,+              "minItems": 1+            }+          },+          "required": [+            "token",+            "generator",+            "failures"+          ],+          "additionalProperties": false+        },+        "counterexample": {+          "type": "array",+          "items": {+            "type": "string"+          }+        }+      },+      "required": [+        "protocolVersion",+        "runId",+        "caseId",+        "stage",+        "role",+        "attempt",+        "sequence",+        "kind",+        "replay",+        "counterexample"+      ],+      "additionalProperties": false+    },+    {+      "type": "object",+      "properties": {+        "protocolVersion": {+          "const": 1+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "caseId": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "stage": {+          "enum": [+            "setup",+            "build",+            "test",+            "replay",+            "teardown"+          ]+        },+        "role": {+          "enum": [+            "baseline",+            "candidate"+          ]+        },+        "attempt": {+          "type": "string",+          "minLength": 1+        },+        "sequence": {+          "type": "integer",+          "minimum": 0+        },+        "kind": {+          "const": "complete"+        },+        "counts": {+          "type": "object",+          "properties": {+            "discovered": {+              "anyOf": [+                {+                  "type": "integer",+                  "minimum": 0+                },+                {+                  "type": "null"+                }+              ]+            },+            "selected": {+              "type": "integer",+              "minimum": 0+            },+            "executed": {+              "type": "integer",+              "minimum": 0+            },+            "passed": {+              "type": "integer",+              "minimum": 0+            },+            "failed": {+              "type": "integer",+              "minimum": 0+            },+            "skipped": {+              "type": "integer",+              "minimum": 0+            },+            "setupErrors": {+              "type": "integer",+              "minimum": 0+            }+          },+          "required": [+            "discovered",+            "selected",+            "executed",+            "passed",+            "failed",+            "skipped",+            "setupErrors"+          ],+          "additionalProperties": false+        }+      },+      "required": [+        "protocolVersion",+        "runId",+        "caseId",+        "stage",+        "role",+        "attempt",+        "sequence",+        "kind",+        "counts"+      ],+      "additionalProperties": false+    },+    {+      "type": "object",+      "properties": {+        "protocolVersion": {+          "const": 1+        },+        "runId": {+          "type": "string",+          "minLength": 1+        },+        "caseId": {+          "type": "string",+          "pattern": "^[A-Za-z0-9_.-]+$"+        },+        "stage": {+          "enum": [+            "setup",+            "build",+            "test",+            "replay",+            "teardown"+          ]+        },+        "role": {+          "enum": [+            "baseline",+            "candidate"+          ]+        },+        "attempt": {+          "type": "string",+          "minLength": 1+        },+        "sequence": {+          "const": -1+        },+        "kind": {+          "const": "invalidated"+        },+        "message": {+          "type": "string"+        }+      },+      "required": [+        "protocolVersion",+        "runId",+        "caseId",+        "stage",+        "role",+        "attempt",+        "sequence",+        "kind",+        "message"+      ],+      "additionalProperties": false+    }+  ]+}
+ scripts/check-schemas.py view
@@ -0,0 +1,59 @@+"""Validate published JSON Schemas and real emitted fixtures (development only)."""+import argparse+import json+import sys+import os+from pathlib import Path+from jsonschema import Draft202012Validator++root = Path(__file__).resolve().parent.parent+parser = argparse.ArgumentParser(description=__doc__)+parser.add_argument("--corpus", type=Path, default=root / "heni-cli/test/corpus")+parser.add_argument("reports", nargs="*")+args = parser.parse_args()+assert args.corpus.is_dir(), "supply --corpus from the extracted heni-cli archive"+assert list(args.corpus.glob("*.json")), "manifest corpus is empty"++def unique_object(pairs):+    result = {}+    for key, value in pairs:+        if key in result:+            raise ValueError(f"duplicate JSON object key: {key}")+        result[key] = value+    return result++manifest_schema = json.loads((root / "schemas/manifest-v1.schema.json").read_text())+Draft202012Validator.check_schema(manifest_schema)+manifest_validator = Draft202012Validator(manifest_schema)+for path in args.corpus.glob("*.json"):+    try:+        value = json.loads(path.read_text(), object_pairs_hook=unique_object)+        valid = manifest_validator.is_valid(value)+    except ValueError:+        valid = False+    assert valid == path.name.startswith("valid-"), path.name+manifest_validator.validate(json.loads((root / "examples/minimal/heni.json").read_text()))+manifest_validator.validate(json.loads((root / "examples/standalone/target/heni.json").read_text()))+for path in (root / "examples/adapters").glob("*.json"):+    manifest_validator.validate(json.loads(path.read_text()))+for path in (root / "examples/migrations").glob("*/heni.json"):+    manifest_validator.validate(json.loads(path.read_text()))+schema = json.loads((root / "schemas/report-v1.schema.json").read_text())+Draft202012Validator.check_schema(schema)+validator = Draft202012Validator(schema)+for argument in args.reports:+    value = json.loads(Path(argument).read_text())+    validator.validate(value)+    invalid = dict(value, schemaVersion=999)+    assert not validator.is_valid(invalid), "unknown report version accepted"+    invalid = dict(value, unexpected=True)+    assert not validator.is_valid(invalid), "unknown report field accepted"+print("Manifest corpus, report schema and emitted fixtures passed")++evidence_schema = json.loads((root / "schemas/test-evidence-v1.schema.json").read_text())+Draft202012Validator.check_schema(evidence_schema)+evidence_validator = Draft202012Validator(evidence_schema)+if os.environ.get("HENI_SCHEMA_EVIDENCE"):+    for line in Path(os.environ["HENI_SCHEMA_EVIDENCE"]).read_text().splitlines():+        evidence_validator.validate(json.loads(line, object_pairs_hook=unique_object))+    print("Emitted test evidence schema passed")
+ scripts/check-sdist.py view
@@ -0,0 +1,236 @@+"""Local release validation. Never uploads; retains failed extractions and logs."""+import argparse+import atexit+import hashlib+from html.parser import HTMLParser+import json+import os+import re+from pathlib import Path+import shutil+import subprocess+import tarfile+import tempfile+import time+from urllib.parse import unquote, urlsplit++parser = argparse.ArgumentParser(description=__doc__)+parser.add_argument("--archives", type=Path, help="validate existing archives instead of creating them")+parser.add_argument("--bounds", choices=["lower", "newest"], default="newest")+parser.add_argument("--output", type=Path, default=Path("dist-release"))+args = parser.parse_args()+cabal_version = subprocess.check_output(["cabal", "--numeric-version"], text=True).strip()+version_parts = tuple(int(part) for part in cabal_version.split("."))+if (version_parts + (0, 0, 0, 0))[:4] < (3, 16, 1, 0):+    parser.error("archive documentation requires cabal-install >=3.16.1.0; older Cabal does not preserve the selective Haddock show flags")+checkout = Path(__file__).resolve().parent.parent+output = args.output.resolve()+output.mkdir(parents=True, exist_ok=True)+fresh = Path(tempfile.mkdtemp(prefix="heni-sdist-"))+print(f"Fresh extraction: {fresh}", flush=True)+checks = []+++def retain_evidence():+    evidence = output / "evidence"+    evidence.mkdir(exist_ok=True)+    for pattern in ["*.log", "*-plan.json", "core-closure.json", "core-report.json", "resource-report.json", "test-evidence.jsonl", "migration-results.json"]:+        for path in fresh.glob(pattern):+            shutil.copyfile(path, evidence / path.name)+    campaigns = evidence / "campaigns"+    retained = list(fresh.glob("heni-migration-*")) + list(fresh.glob("adapter-example-*"))+    retained += [fresh / "consumer" / name for name in ["library-results", "cli-caught", "cli-survivor", "cli-wrong"]]+    for directory in retained:+        if directory.is_dir():+            campaigns.mkdir(exist_ok=True)+            shutil.copytree(directory, campaigns / directory.name, dirs_exist_ok=True)+    (evidence / "state.json").write_text(json.dumps({"extraction": str(fresh), "bounds": args.bounds, "checks": checks}, indent=2) + "\n")+++atexit.register(retain_evidence)+++def run(label, argv, cwd, env=None, expected=0):+    print(label, flush=True)+    start = time.monotonic()+    with (fresh / (label + ".log")).open("w") as log:+        result = subprocess.run(argv, cwd=cwd, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=1800)+    checks.append({"check": label, "exit": result.returncode, "expected": expected, "seconds": time.monotonic() - start})+    assert result.returncode == expected, f"{label}: see {fresh / (label + '.log')}"+++def project(path, packages):+    versions = {"base": "4.21.2.0", "text": "2.1.4", "containers": "0.7", "bytestring": "0.12.2.0", "directory": "1.3.10.1", "filepath": "1.5.5.0", "unix": "2.8.8.0", "process": "1.6.26.1", "crypton": "2.2.5", "aeson": "2.3.2.0", "stm": "2.5.3.1", "tasty": "1.5.5", "tasty-hunit": "0.10.2", "hspec": "2.11.18", "hspec-core": "2.11.18", "hspec-api": "2.11.18", "QuickCheck": "2.19.0.0"}+    if args.bounds == "newest":+        versions.update(containers="0.8.1", directory="1.3.11.0", process="1.6.30.0")+    constraints = ", ".join(name + " ==" + version for name, version in versions.items())+    policy = ""+    if os.environ.get("HENI_CI_WERROR") == "1":+        policy = "".join("package " + name + "\n  ghc-options: -Werror\n" for name in ["heni", "heni-cli", "heni-tasty", "heni-hspec"])+    path.write_text("packages: " + " ".join(packages) + "\nwrite-ghc-environment-files: never\nconstraints: " + constraints + "\n" + policy)+++archives = args.archives.resolve() if args.archives else output+if not args.archives:+    run("sdist", ["cabal", "sdist", "all", "--output-directory", str(archives)], checkout)+names = ["heni", "heni-cli", "heni-tasty", "heni-hspec"]+hashes = {}+for name in names:+    archive = archives / (name + "-0.1.0.0.tar.gz")+    hashes[archive.name] = hashlib.sha256(archive.read_bytes()).hexdigest()+    with tarfile.open(archive) as bundle:+        for member in bundle.getmembers():+            path = Path(member.name)+            assert not path.is_absolute() and ".." not in path.parts+            assert member.isfile() or member.isdir(), f"nonregular archive member: {member.name}"+        bundle.extractall(fresh)  # members were restricted to contained regular paths above+packages = {name: fresh / (name + "-0.1.0.0") for name in names}+for name, package in packages.items():+    for required in ["LICENSE", "CHANGELOG.md", name + ".cabal"]:+        assert (package / required).is_file(), (name, required)+    for path in package.rglob("*"):+        assert not path.is_symlink(), path+        if path.is_file():+            # Match an absolute reference, not a relative path whose suffix+            # happens to share the checkout basename (e.g. test/runner).+            reference = rb"(?<![A-Za-z0-9_.-])" + re.escape(str(checkout).encode()) + rb"(?:/|[\s\"'])"+            assert re.search(reference, path.read_bytes()) is None, f"checkout reference: {path}"+    run("check-" + name, ["cabal", "check"], package)+core = packages["heni"]+for required in ["schemas/manifest-v1.schema.json", "examples/minimal/src/Capacity.hs", "examples/standalone/target/heni.json", "test/process/Main.hs", "test/migration/Main.hs"]:+    assert (core / required).is_file(), required++# Construct a tool path explicitly, without enumerating the compiler store or+# inheriting Git, Nix, Mori, OKF, Python, or a consumer mutation runner.+tools = fresh / "core-tools"+tools.mkdir()+build_tools = """+cabal ghc ghc-pkg runghc runhaskell hsc2hs cc gcc clang as ld ld.gold ld.bfd ld.lld+ar ranlib strip libtool objcopy objdump readelf cpp c++ g++ strings make pkg-config+xcrun xcodebuild dsymutil otool nm lipo codesign sw_vers xattr+sh curl install tar gzip uname rm rmdir mkdir cp mv cat sed awk perl tr touch chmod+sort uniq cut head tail env readlink realpath dirname basename pwd find ln getconf+ls printf sleep wc test diff cmp dd file tee od iconv ldd true false du df ps+hostname which id whoami mktemp timeout setarch getent tput stty expr echo grep date+""".split()+for command in build_tools:+    found = shutil.which(command)+    if found:+        (tools / command).symlink_to(found)+minimal = os.environ.copy()+minimal["PATH"] = str(tools)+minimal.pop("HENI_REQUIRE_GIT", None)+minimal["HENI_SCHEMA_REPORT"] = str(fresh / "core-report.json")+minimal["HENI_SCHEMA_RESOURCE"] = str(fresh / "resource-report.json")+minimal["HENI_SCHEMA_EVIDENCE"] = str(fresh / "test-evidence.jsonl")+for command in ["git", "nix", "mori", "okf", "python3"]:+    assert shutil.which(command, path=minimal["PATH"]) is None++project(core / "cabal.project", ["."])+run("core-independent", ["cabal", "test", "all", "-j1", "--test-show-details=direct"], core, minimal)+plan = json.loads((core / "dist-newstyle/cache/plan.json").read_text())+nodes = {item["id"]: item for item in plan["install-plan"]}+reachable = set()+++def visit(key):+    if key in reachable:+        return+    reachable.add(key)+    node = nodes[key]+    for dependency in node.get("depends", []) + node.get("exe-depends", []):+        visit(dependency)+++for node in nodes.values():+    if node["pkg-name"] == "heni":+        visit(node["id"])+closure = {nodes[key]["pkg-name"] for key in reachable}+assert not closure.intersection({"heni-cli", "heni-tasty", "heni-hspec", "tasty", "hspec", "hspec-core"}), closure+# Released Aeson itself depends on QuickCheck; this is not a reporter dependency.+(fresh / "core-closure.json").write_text(json.dumps(sorted(closure), indent=2) + "\n")+shutil.copyfile(core / "dist-newstyle/cache/plan.json", fresh / "core-plan.json")+patch_env = os.environ.copy()+patch_env["HENI_REQUIRE_GIT"] = "1"+run("optional-git-patches", ["cabal", "test", "heni-workspace", "heni-runner", "-j1", "--test-show-details=direct"], core, patch_env)++cli = packages["heni-cli"]+project(cli / "cabal.project", [".", "../heni-0.1.0.0"])+cli_env = os.environ.copy()+cli_env["HENI_SCHEMA_RESOURCE"] = str(fresh / "resource-report.json")+run("cli-with-core-archive", ["cabal", "test", "heni-cli:heni-cli-test", "-j1", "--test-show-details=direct"], cli, cli_env)+project(fresh / "cabal.project", [p.name for p in packages.values()])+run("optional-reporters", ["cabal", "test", "heni-tasty:heni-tasty-test", "heni-hspec:heni-hspec-test", "-j1", "--test-show-details=direct"], fresh)+run("haddock", ["cabal", "haddock", "all", "--haddock-for-hackage", "--haddock-executables", "--haddock-option=--show=Test.Heni.Internal.Model", "--haddock-option=--show=Test.Heni.Internal.Files", "-j1"], fresh)+text = (fresh / "haddock.log").read_text()+for line in text.splitlines():+    if "% (" in line and "in 'Test.Heni" in line:+        assert "100%" in line, line+for module in ["Test-Heni.html", "Test-Heni-Resource.html", "Test-Heni-Tasty.html", "Test-Heni-Hspec.html"]:+    assert list((fresh / "dist-newstyle").rglob(module)), module+++class Links(HTMLParser):+    def __init__(self):+        super().__init__()+        self.links = []++    def handle_starttag(self, tag, attrs):+        if tag == "a":+            self.links.extend(value for key, value in attrs if key == "href")+++for page in (fresh / "dist-newstyle").rglob("Test-Heni*.html"):+    links = Links()+    links.feed(page.read_text())+    for href in links.links:+        parsed = urlsplit(href)+        path = unquote(parsed.path)+        if not parsed.scheme and (path.startswith("Test-Heni") or path.startswith("src/")):+            assert (page.parent / path).is_file(), (page, href)+schema_env = os.environ.copy()+schema_env["HENI_SCHEMA_EVIDENCE"] = str(fresh / "test-evidence.jsonl")+run("schema-agreement", [os.environ.get("HENI_SCHEMA_PYTHON", "python3"), str(core / "scripts/check-schemas.py"), "--corpus", str(cli / "test/corpus"), str(fresh / "core-report.json"), str(fresh / "resource-report.json")], fresh, schema_env)++binary = subprocess.check_output(["cabal", "list-bin", "heni-cli:exe:heni"], cwd=fresh, text=True).strip()+run("version-without-git", [binary, "--version"], fresh, minimal)+run("help", [binary, "--help"], fresh)+framework_env = os.environ.copy()+fixture_dirs = []+for package in ["heni-tasty", "heni-hspec"]:+    fixture = subprocess.check_output(["cabal", "list-bin", package + ":exe:" + package + "-fixture"], cwd=fresh, text=True).strip()+    fixture_dirs.append(str(Path(fixture).parent))+framework_env["PATH"] = os.pathsep.join(fixture_dirs + [framework_env["PATH"]])+for example in ["tasty", "hspec", "property"]:+    run("adapter-example-" + example, [binary, "run", str(core / "examples/adapters" / (example + ".json")), "--output", str(fresh / ("adapter-example-" + example)), "--json"], fresh, framework_env)++consumer = fresh / "consumer"+shutil.copytree(core / "examples/standalone", consumer)+project(consumer / "cabal.project", [".", "../heni-0.1.0.0"])+run("standalone-library", ["cabal", "run", "consumer", "-j1"], consumer, minimal)+for case, exit_code, verdict in [("caught", 0, "caught"), ("survivor", 1, "survived"), ("wrong", 1, "wrong-failure")]:+    destination = consumer / ("cli-" + case)+    run("standalone-cli-" + case, [binary, "run", "target/heni.json", "--case", case, "--output", str(destination), "--json"], consumer, minimal, exit_code)+    report = json.loads((destination / "report.json").read_text())+    assert report["result"]["cases"][0]["verdict"] == verdict+run("standalone-config-exit", [binary, "run", "target/heni.json", "--case", "absent"], consumer, minimal, 2)+migration_env = os.environ.copy()+migration_env["HENI_CLI_EXE"] = binary+migration_env["HENI_MIGRATION_RESULTS"] = str(fresh / "migration-results.json")+run("migration-archives", ["cabal", "test", "heni:heni-migration", "-f", "migration-tests", "-j1", "--test-show-details=direct"], fresh, migration_env)+shutil.copyfile(fresh / "dist-newstyle/cache/plan.json", fresh / "combined-plan.json")+for filename in ["core-plan.json", "combined-plan.json"]:+    for item in json.loads((fresh / filename).read_text())["install-plan"]:+        source = item.get("pkg-src", {})+        if source.get("type") == "local":+            assert Path(source["path"]).resolve().is_relative_to(fresh.resolve()), source+        else:+            assert not source or source.get("type") == "repo-tar", source+for archive in (fresh / "dist-newstyle").rglob("*-docs.tar.gz"):+    shutil.copyfile(archive, output / archive.name)+    hashes[archive.name] = hashlib.sha256(archive.read_bytes()).hexdigest()+for name in names:+    assert (output / (name + "-0.1.0.0-docs.tar.gz")).is_file(), name+summary = {"bounds": args.bounds, "extraction": str(fresh), "archives": hashes, "compiler": subprocess.check_output(["ghc", "--numeric-version"], text=True).strip(), "cabal": subprocess.check_output(["cabal", "--numeric-version"], text=True).strip(), "checks": checks}+(output / "archive-validation.json").write_text(json.dumps(summary, indent=2) + "\n")+print(f"Archive validation passed; evidence: {output / 'archive-validation.json'}", flush=True)
+ scripts/check-sdist.sh view
@@ -0,0 +1,3 @@+#!/bin/sh+set -eu+exec python3 "$(dirname "$0")/check-sdist.py" "$@"
+ scripts/measure-migration.py view
@@ -0,0 +1,124 @@+"""Development-only, automated interface observations; never human effort timing."""+import argparse+import copy+import json+from pathlib import Path+import shutil+import statistics+import subprocess+import tempfile+import time++parser = argparse.ArgumentParser()+parser.add_argument("--results", type=Path, required=True)+parser.add_argument("--cli", type=Path, required=True)+parser.add_argument("--output", type=Path, required=True)+args = parser.parse_args()+root = Path(__file__).resolve().parent.parent+cli = args.cli.resolve()+(root / ".heni-runs").mkdir(exist_ok=True)+trial = json.loads(args.results.read_text())+assert len(trial["families"]) == 4, "a complete successful trial is required"+legacy = (root / "test/migration/Legacy.hs").read_text().splitlines()++def region(name):+    first = next(i for i, line in enumerate(legacy) if line.strip() == "-- BEGIN " + name)+    last = next(i for i, line in enumerate(legacy) if line.strip() == "-- END " + name)+    return {i for i in range(first + 1, last) if legacy[i].strip() and not legacy[i].lstrip().startswith("--")}++def timing(values):+    return {"repeats": len(values), "minSeconds": min(values), "maxSeconds": max(values), "medianSeconds": statistics.median(values)}++def stages(report):+    return [(name, observation["stage"]) for details in report["metadata"]["caseDetails"].values()+            for name, observation in details.items() if isinstance(observation, dict) and isinstance(observation.get("stage"), dict)]++families = []+authoring = []+diagnostic_negative = None+for family in trial["families"]:+    name = family["family"]+    before = region(name + "-orchestration") - region(name + "-definition")+    domain = region(name + "-definition")+    fixture = root / "examples/migrations" / name+    definition = json.loads((fixture / "heni.json").read_text())+    helper_loc = sum(sum(bool(line.strip()) and not line.lstrip().startswith("--") for line in p.read_text().splitlines()) for p in (fixture / "tools").glob("*.hs"))+    row = {"family": name, "comparisonLocalOrchestrationLOC": len(before) + 1,+           "comparisonDefinitionLOC": len(domain), "manifestLifecycleScaffoldingLOC": 0,+           "retainedDomainBuildHelperLOC": helper_loc, "legacyIntendedCaught": family["legacyIntendedCaught"],+           "legacyUnrelatedCompileAccepted": family["legacyUnrelatedCompileAccepted"],+           "typedFreshSeconds": family["typedFreshSeconds"], "cliParallelReuseSeconds": family["cliParallelReuseSeconds"],+           "wholeCampaignRepeatsPerCondition": 1, "outcomes": family["outcomes"]}+    for label, report in [("typedFresh", family["typedReport"]), ("cliParallelReuse", family["cliReport"])]:+        observations = stages(report)+        baseline = [s["durationSeconds"] for role, s in observations if role == "baseline-build"]+        row[label] = {"baselineBuildInvocations": len(baseline), "baselineBuildTiming": timing(baseline),+                      "sumBuildStageSeconds": sum(s["durationSeconds"] for _, s in observations if s["stage"] == "build"),+                      "sumAllStageSeconds": sum(s["durationSeconds"] for _, s in observations)}+    row["typedElapsedMinusStagesSeconds"] = row["typedFreshSeconds"] - row["typedFresh"]["sumAllStageSeconds"]+    families.append(row)+    samples = []+    with tempfile.TemporaryDirectory(prefix="migration-authoring-", dir=root / ".heni-runs") as scratch:+        scratch = Path(scratch)+        shutil.copytree(fixture, scratch / "source")+        for _ in range(3):+            started = time.perf_counter()+            value = copy.deepcopy(definition)+            new = copy.deepcopy(value["cases"][0])+            new.update(id="authoring-variant", description="A new harmless label change demonstrates a surviving case.")+            if name == "patch-catalogue":+                new["edits"] = [{"kind": "patch", "content": (fixture / "patches/survivor.patch").read_text()}]+            else:+                new["edits"] = [{"kind": "replace", "path": {"generated-code": "model.txt", "diagnostic-checker": "policy.txt"}.get(name, "src/Capacity.hs"), "old": "baseline", "new": "authoring-variant"}]+            value["cases"].append(new)+            value["sourceRoot"] = "source"+            manifest = scratch / "heni.json"+            manifest.write_text(json.dumps(value, indent=2) + "\n")+            checked = subprocess.run([str(cli), "check", str(manifest), "--json"], capture_output=True, text=True, check=True)+            assert json.loads(checked.stdout)["testsExecuted"] is False+            samples.append(time.perf_counter() - started)+        if name == "diagnostic-checker":+            wrong = copy.deepcopy(definition)+            wrong["sourceRoot"] = "source"+            wrong["cases"][0].update(id="wrong-diagnostic", edits=[{"kind": "replace", "path": "policy.txt", "old": "timeout=30", "new": "timeout=-1"}])+            manifest.write_text(json.dumps(wrong, indent=2) + "\n")+            checked = subprocess.run([str(cli), "run", str(manifest), "--output", str(scratch / "wrong-run"), "--json"], capture_output=True, text=True)+            assert checked.returncode == 1+            case = json.loads(checked.stdout)["result"]["cases"][0]+            observed = (scratch / "wrong-run/wrong-diagnostic-candidate-test.stdout.log").read_text().strip()+            assert case["verdict"] == "wrong-failure" and observed == "policy.timeout: duration must be positive"+            diagnostic_negative = {"verdict": case["verdict"], "expected": "policy.capacity: maximum must be positive", "observed": observed, "exit": 1}+    authoring.append({"family": name, "templateFieldsChanged": ["id", "description", "edits"], "validationCommands": 1,+                      "testsExecutedDuringCheck": False, "automatedSerializationAndCheck": timing(samples)})++report_root = Path(trial["evidenceDirectory"]) / "rich-cli"+diagnosis = []+for _ in range(5):+    started = time.perf_counter()+    stored = json.loads((report_root / "report.json").read_text())+    case = next(c for c in stored["result"]["cases"] if c["id"] == "wrong-named")+    expected = next(c for c in stored["metadata"]["definitions"] if c["id"] == "wrong-named")["expect"]["failures"]+    stage = next(s for s in case["evidence"]["stages"] if s["stage"] == "test")+    protocol = next(p for p in stage["logPaths"] if p.endswith("evidence.jsonl"))+    events = [json.loads(line) for line in (report_root / protocol).read_text().splitlines()]+    actual = [e["testId"] for e in events if e["kind"] == "failed"]+    assert case["verdict"] == "wrong-failure" and expected == ["suite/intended"] and actual == ["suite/other"]+    diagnosis.append(time.perf_counter() - started)++with tempfile.TemporaryDirectory(prefix="migration-reproduce-", dir=root / ".heni-runs") as scratch:+    started = time.perf_counter()+    result = subprocess.run([str(cli), "run", str(root / "examples/migrations/diagnostic-checker/heni.json"), "--output", str(Path(scratch) / "run"), "--json"], capture_output=True, text=True, check=True)+    reproduction_seconds = time.perf_counter() - started+    reproduced = json.loads(result.stdout)["result"]["cases"]+    assert [c["verdict"] for c in reproduced] == ["caught"]++measurement = {"operator": "automated Python observation of agent-authored fixtures; no human effort inference",+               "method": "nonblank/noncomment physical lines inside named Legacy.hs regions; local wrapper line included; definitions subtracted",+               "comparisonSharedOrchestrationLOC": len(region("shared-orchestration")), "platform": trial["platform"],+               "families": families, "authoring": authoring, "diagnosticRuleMismatch": diagnostic_negative,+               "diagnosis": {"artifactReads": 2, "expectedFailures": expected, "actualFailures": actual, "automatedReadTime": timing(diagnosis)},+               "reproduction": {"commands": 1, "case": "diagnostic-checker/invalid-capacity", "exit": 0, "verdict": "caught", "seconds": reproduction_seconds},+               "limits": ["Whole-campaign variance is not estimated from one run per condition.", "Fresh library and parallel/reuse CLI conditions differ.", "Elapsed-minus-stages includes copies, edits and report IO; it is not isolated CPU overhead.", "Authoring/diagnosis timings exclude human reasoning."]}+args.output.parent.mkdir(parents=True, exist_ok=True)+args.output.write_text(json.dumps(measurement, indent=2) + "\n")+print("Recorded automated scaffolding, authoring/check, diagnosis, reproduction and runtime observations")
+ scripts/test-migrations.sh view
@@ -0,0 +1,8 @@+#!/bin/sh+set -eu+# Explicit cross-package validation; the core package does not depend on CLI.+cabal build heni-cli:exe:heni -j1+HENI_CLI_EXE=$(cabal list-bin heni-cli:exe:heni)+HENI_MIGRATION_RESULTS=${HENI_MIGRATION_RESULTS:-"$(pwd)/.heni-runs/migration-results.json"}+export HENI_CLI_EXE HENI_MIGRATION_RESULTS+cabal test heni:heni-migration -f migration-tests -j1 --test-show-details=direct "$@"
+ src/Test/Heni.hs view
@@ -0,0 +1,23 @@+-- | Deliberate mutation definitions and trustworthy evidence classification.+--+-- Construct and validate a 'CaseDefinition', collect evidence, then call+-- 'classifyCase'. The execution runner is added by the campaign implementation;+-- this module never prints output or terminates the embedding application.+module Test.Heni+  ( module Test.Heni.Case,+    module Test.Heni.Command,+    module Test.Heni.Expectation,+    module Test.Heni.Result,+    module Test.Heni.Runner,+    module Test.Heni.Report,+    module Test.Heni.Manifest,+  )+where++import Test.Heni.Case+import Test.Heni.Command+import Test.Heni.Expectation+import Test.Heni.Manifest+import Test.Heni.Report+import Test.Heni.Result+import Test.Heni.Runner
+ src/Test/Heni/Adapter.hs view
@@ -0,0 +1,360 @@+-- | Attempt-bound framework-neutral JSON Lines evidence. Reporters describe+-- observations; only the core classifier chooses a verdict.+module Test.Heni.Adapter+  ( EvidenceAttempt (..),+    EvidenceError (..),+    TestObservation (..),+    SuiteEvidence (..),+    newRunIdentity,+    newEvidenceAttempt,+    evidencePath,+    evidenceRelativePath,+    attemptEnvironment,+    currentEvidenceAttempt,+    emitSuite,+    emitCurrentSuite,+    readEvidence,+    stableTestId,+    recordPropertyReplay,+    readPropertyReplay,+    attemptIdentityJSON,+    guardEvidenceAttempt,+  )+where++import Control.Exception+import Control.Monad (foldM, forM_, unless, when)+import Data.Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.Aeson.Types (Pair, Parser, parseEither)+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as LBS+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import System.Directory qualified as Dir+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, takeFileName, (</>))+import System.IO+import System.Posix.Temp (mkdtemp)+import Test.Heni.Case+import Test.Heni.Command+import Test.Heni.Expectation+import Test.Heni.Internal.Codec qualified as Codec+import Test.Heni.Internal.Files (WorkspaceError, checkedPath)+import Prelude hiding (sequence)++-- | Fresh identity allocated by the runner, never inferred from terminal text.+data EvidenceAttempt = EvidenceAttempt+  { outputRoot :: !FilePath,+    directoryName :: !FilePath,+    runId :: !Text,+    caseId :: !CaseId,+    stage :: !Stage,+    role :: !Text+  }+  deriving stock (Eq, Show)++-- | Protocol failures remain distinct from assertion failures.+newtype EvidenceError = EvidenceError Text+  deriving stock (Eq, Show)+  deriving anyclass (Exception)++-- | Complete per-test observations with stable full IDs.+data TestObservation = TestPassed !Text | TestFailed !Text | TestSkipped !Text | TestSetupFailed !Text !Text+  deriving stock (Eq, Show)++-- | Unknown unfiltered discovery is honest; selected/executed identities are exact.+data SuiteEvidence = SuiteEvidence+  { producerVersion :: !Text,+    discoveredIds :: !(Maybe [Text]),+    selectedIds :: ![Text],+    observations :: ![TestObservation],+    setupErrors :: ![Text],+    replayEvidence :: !(Maybe ReplayEvidence),+    counterexample :: ![Text]+  }+  deriving stock (Eq, Show)++-- | Campaign identity backed by an exclusively created private marker directory.+newRunIdentity :: FilePath -> IO Text+newRunIdentity output = do+  root <- Dir.makeAbsolute output+  Text.pack . takeFileName <$> mkdtemp (root </> ".identity-")++-- | Allocate a fresh destination for every baseline, candidate and replay attempt.+newEvidenceAttempt :: FilePath -> Text -> CaseId -> Stage -> Text -> IO EvidenceAttempt+newEvidenceAttempt output runId caseId stage role = do+  outputRoot <- Dir.makeAbsolute output+  directoryName <- takeFileName <$> mkdtemp (outputRoot </> ".attempt-")+  pure EvidenceAttempt {outputRoot, directoryName, runId, caseId, stage, role}++-- | Absolute destination supplied only to the child process.+evidencePath :: EvidenceAttempt -> FilePath+evidencePath a@EvidenceAttempt {outputRoot} = outputRoot </> evidenceRelativePath a++-- | Portable relative artifact reference.+evidenceRelativePath :: EvidenceAttempt -> FilePath+evidenceRelativePath EvidenceAttempt {directoryName} = directoryName </> "evidence.jsonl"++-- | Reserved environment entries, replaced freshly for each attempt.+attemptEnvironment :: EvidenceAttempt -> [(String, String)]+attemptEnvironment a@EvidenceAttempt {runId, caseId, stage, role, directoryName} =+  [ ("HENI_EVIDENCE_FILE", evidencePath a),+    ("HENI_RUN_ID", Text.unpack runId),+    ("HENI_CASE_ID", Text.unpack (caseIdText caseId)),+    ("HENI_STAGE", Text.unpack (stageText stage)),+    ("HENI_ROLE", Text.unpack role),+    ("HENI_ATTEMPT", directoryName)+  ]++-- | Normal direct framework execution has no destination. Partial Heni identity+-- configuration fails instead of emitting ambiguously attributed evidence.+currentEvidenceAttempt :: IO (Maybe EvidenceAttempt)+currentEvidenceAttempt =+  lookupEnv "HENI_EVIDENCE_FILE" >>= \case+    Nothing -> pure Nothing+    Just file -> do+      let required key = lookupEnv key >>= maybe (throwIO (EvidenceError ("missing " <> Text.pack key))) pure+      runId <- Text.pack <$> required "HENI_RUN_ID"+      caseId <- required "HENI_CASE_ID" >>= either (throwIO . EvidenceError . Text.pack . show) pure . mkCaseId . Text.pack+      stage <- required "HENI_STAGE" >>= either (throwIO . EvidenceError . Text.pack) pure . parseEither (Codec.enumParser Codec.stageTable) . String . Text.pack+      role <- Text.pack <$> required "HENI_ROLE"+      nonce <- required "HENI_ATTEMPT"+      unless (takeFileName file == "evidence.jsonl" && takeFileName (takeDirectory file) == nonce) (throwIO (EvidenceError "evidence destination/attempt mismatch"))+      pure (Just (EvidenceAttempt (takeDirectory (takeDirectory file)) nonce runId caseId stage role))++-- | Escape path separators and tildes so nested or parameterized names retain+-- unambiguous identity. Duplicate resulting IDs are rejected by the protocol.+stableTestId :: [Text] -> Text+stableTestId = Text.intercalate "/" . map (Text.replace "/" "~1" . Text.replace "~" "~0")++validateAttempt :: EvidenceAttempt -> IO ()+validateAttempt EvidenceAttempt {runId, role, directoryName} = do+  when (Text.null runId || role `notElem` ["baseline", "candidate"]) (throwIO (EvidenceError "invalid run identity or attempt role"))+  unless (takeFileName directoryName == directoryName) (throwIO (EvidenceError "invalid attempt directory name"))+  either (throwIO . EvidenceError . Text.pack . show) (const (pure ())) (mkRelativeTarget directoryName)++identityPairs :: EvidenceAttempt -> [Pair]+identityPairs EvidenceAttempt {runId, caseId, stage, role, directoryName} =+  ["protocolVersion" .= (1 :: Int), "runId" .= runId, "caseId" .= caseIdText caseId, "stage" .= String (stageText stage), "role" .= role, "attempt" .= directoryName]++stageText :: Stage -> Text+stageText stage = case Codec.enumValue Codec.stageTable stage of String t -> t; _ -> error "stage table"++-- | Shared identity object for other attempt-bound evidence channels.+attemptIdentityJSON :: EvidenceAttempt -> Value+attemptIdentityJSON = object . identityPairs++-- | Emit complete observed facts after framework completion. No completion is+-- written when execution is partial. Files must be fresh, and are capped at 8MiB.+emitSuite :: EvidenceAttempt -> SuiteEvidence -> IO ()+emitSuite attempt suite@SuiteEvidence {producerVersion, discoveredIds, selectedIds, observations, setupErrors, replayEvidence, counterexample} = do+  validateAttempt attempt+  either (throwIO . EvidenceError) pure (validateSuite suite)+  exists <- Dir.doesPathExist (evidencePath attempt)+  when exists (throwIO (EvidenceError "evidence destination already exists"))+  let begin = ("begin", ["producerVersion" .= producerVersion, "discoveredIds" .= discoveredIds, "selectedIds" .= selectedIds])+      testRecords = concatMap observationRecords observations+      setup = [("setup-error", ["message" .= why]) | why <- setupErrors]+      replayRecords = maybe [] (\r -> [("replay", ["replay" .= Codec.replayJSON r, "counterexample" .= counterexample])]) replayEvidence+      complete = ("complete", ["counts" .= countsJSON suite])+      records = [object (identityPairs attempt ++ ["sequence" .= n, "kind" .= String kind] ++ fields) | (n, (kind, fields)) <- zip [0 :: Int ..] (begin : testRecords ++ setup ++ replayRecords ++ [complete])]+      bytes = LBS.concat [encode record <> "\n" | record <- records]+  when (LBS.length bytes > 8388608) (throwIO (EvidenceError "test evidence exceeds 8MiB"))+  withBinaryFile (evidencePath attempt) WriteMode $ \file -> LBS.hPut file bytes >> hFlush file++-- | Reporter convenience: ordinary execution remains usable without Heni.+emitCurrentSuite :: SuiteEvidence -> IO ()+emitCurrentSuite suite = currentEvidenceAttempt >>= mapM_ (`emitSuite` suite)++observationId :: TestObservation -> Text+observationId = \case TestPassed t -> t; TestFailed t -> t; TestSkipped t -> t; TestSetupFailed t _ -> t++observationRecords :: TestObservation -> [(Text, [Pair])]+observationRecords observation = case observation of+  TestPassed t -> [("started", ["testId" .= t]), ("passed", ["testId" .= t])]+  TestFailed t -> [("started", ["testId" .= t]), ("failed", ["testId" .= t])]+  TestSkipped t -> [("skipped", ["testId" .= t])]+  TestSetupFailed t why -> [("setup-failed", ["testId" .= t, "message" .= why])]++validateSuite :: SuiteEvidence -> Either Text ()+validateSuite SuiteEvidence {producerVersion, discoveredIds, selectedIds, observations, replayEvidence} = do+  when (Text.null producerVersion) (Left "missing reporter version identity")+  unless (unique selectedIds && all (not . Text.null) selectedIds) (Left "empty or duplicate selected test identity")+  forM_ discoveredIds $ \ids -> unless (unique ids && all (not . Text.null) ids && Set.fromList selectedIds `Set.isSubsetOf` Set.fromList ids) (Left "selected identities do not agree with discovery")+  let observed = map observationId observations+      failed = Set.fromList [t | TestFailed t <- observations]+  unless (unique observed && Set.fromList selectedIds == Set.fromList observed) (Left "incomplete or conflicting selected test observations")+  forM_ replayEvidence $ \ReplayEvidence {token, generator, failures} ->+    unless (not (Text.null token || Text.null generator) && Text.length token <= 65536 && Text.length generator <= 65536 && failures `Set.isSubsetOf` failed && not (Set.null failures)) (Left "replay does not identify the observed failures")+  where+    unique xs = Set.size (Set.fromList xs) == length xs++countsJSON :: SuiteEvidence -> Value+countsJSON SuiteEvidence {discoveredIds, selectedIds, observations, setupErrors} =+  object+    [ "discovered" .= fmap length discoveredIds,+      "selected" .= length selectedIds,+      "executed" .= length [() | o <- observations, case o of TestPassed {} -> True; TestFailed {} -> True; _ -> False],+      "passed" .= length [() | TestPassed {} <- observations],+      "failed" .= length [() | TestFailed {} <- observations],+      "skipped" .= length [() | TestSkipped {} <- observations],+      "setupErrors" .= (length setupErrors + length [() | TestSetupFailed {} <- observations])+    ]++data Record+  = Begin !Text !(Maybe [Text]) ![Text]+  | Started !Text+  | Finished !TestObservation+  | SetupError !Text+  | ReplayRecord !ReplayEvidence ![Text]+  | Complete !Value+  deriving stock (Show)++parseRecord :: EvidenceAttempt -> Int -> Value -> Parser Record+parseRecord expected index value =+  withObject+    "test-evidence record"+    ( \o -> do+        kind <- o .: "kind" :: Parser Text+        let fields = case kind of "begin" -> ["producerVersion", "discoveredIds", "selectedIds"]; "setup-error" -> ["message"]; "invalidated" -> ["message"]; "replay" -> ["replay", "counterexample"]; "complete" -> ["counts"]; "setup-failed" -> ["testId", "message"]; _ -> ["testId"]+        Codec.strictObject+          "test-evidence record"+          (["protocolVersion", "runId", "caseId", "stage", "role", "attempt", "sequence", "kind"] ++ fields)+          ( \record -> do+              version <- record .: "protocolVersion"+              unless (version == (1 :: Int)) (fail "unsupported evidence protocol version")+              forM_ (identityPairs expected) $ \(key, wanted) -> unless (KM.lookup key record == Just wanted) (fail "stale or wrong run/case/stage/attempt identity")+              when (kind == "invalidated") $ do+                why <- record .: "message"+                fail ("reporter invalidated attempt: " ++ why)+              sequence <- record .: "sequence"+              unless (sequence == index) (fail "evidence event sequence gap or duplicate")+              case kind of+                "begin" -> Begin <$> record .: "producerVersion" <*> record .: "discoveredIds" <*> record .: "selectedIds"+                "started" -> Started <$> record .: "testId"+                "passed" -> Finished . TestPassed <$> record .: "testId"+                "failed" -> Finished . TestFailed <$> record .: "testId"+                "skipped" -> Finished . TestSkipped <$> record .: "testId"+                "setup-failed" -> Finished <$> (TestSetupFailed <$> record .: "testId" <*> record .: "message")+                "setup-error" -> SetupError <$> record .: "message"+                "replay" -> ReplayRecord <$> (record .: "replay" >>= Codec.parseReplay) <*> record .: "counterexample"+                "complete" -> Complete <$> record .: "counts"+                _ -> fail "unknown evidence event kind"+          )+          value+    )+    value++-- | Read only the fresh attempt's bounded file and verify every identity,+-- sequence, execution transition, completion count and failure membership.+readEvidence :: EvidenceAttempt -> IO (Either Text (SelectionEvidence, Maybe ReplayEvidence, Text))+readEvidence attempt = evidenceIO $ do+  validateAttempt attempt+  bytes <- readAttemptFile attempt "evidence.jsonl"+  unless (not (BS.null bytes) && BS.last bytes == 10) (throwIO (EvidenceError "incomplete evidence: missing final newline/completion"))+  let lines' = init (BS.split 10 bytes)+  values <- either (throwIO . EvidenceError . Text.pack) pure (mapM Codec.decodeJSON lines')+  records <- either (throwIO . EvidenceError . Text.pack) pure (mapM (\(index, value) -> parseEither (parseRecord attempt index) value) (zip [0 ..] values))+  suite <- either (throwIO . EvidenceError) pure (normalize records)+  let SuiteEvidence {producerVersion, discoveredIds, selectedIds, observations, setupErrors, replayEvidence} = suite+      executedIds = Set.fromList [t | o <- observations, t <- case o of TestPassed t -> [t]; TestFailed t -> [t]; _ -> []]+      failedIds = Set.fromList [t | TestFailed t <- observations]+      skippedIds = Set.fromList [t | TestSkipped t <- observations]+      errors = setupErrors ++ [t <> ": " <> why | TestSetupFailed t why <- observations]+  pure (SelectionEvidence (fmap length discoveredIds) (Just (length selectedIds)) (Just (Set.size executedIds)) executedIds failedIds skippedIds errors, replayEvidence, producerVersion)++normalize :: [Record] -> Either Text SuiteEvidence+normalize records = case records of+  Begin producer discovered selected : rest -> case reverse rest of+    Complete counts : reversed -> do+      (_, finished, errors, replay, counterexample) <- foldM (step (Set.fromList selected)) (Set.empty, Map.empty, [], Nothing, []) (reverse reversed)+      let suite = SuiteEvidence producer discovered selected (Map.elems finished) errors replay counterexample+      validateSuite suite+      unless (counts == countsJSON suite) (Left "completion counts contradict observed identities")+      pure suite+    _ -> Left "missing final completion record"+  _ -> Left "missing initial evidence identity/selection record"+  where+    step selected (started, finished, errors, replay, counterexample) record = case record of+      Started t -> do+        unless (t `Set.member` selected && t `Set.notMember` started && Map.notMember t finished) (Left "unexpected or repeated test start")+        pure (Set.insert t started, finished, errors, replay, counterexample)+      Finished observation -> do+        let t = observationId observation+            needsStart = case observation of TestPassed {} -> True; TestFailed {} -> True; _ -> False+        unless (t `Set.member` selected && Map.notMember t finished && (t `Set.member` started) == needsStart) (Left "conflicting test completion/start identity")+        pure (started, Map.insert t observation finished, errors, replay, counterexample)+      SetupError why -> pure (started, finished, errors ++ [why], replay, counterexample)+      ReplayRecord value examples -> case replay of+        Just _ -> Left "duplicate replay evidence"+        Nothing -> pure (started, finished, errors, Just value, examples)+      _ -> Left "duplicate or misplaced begin/completion record"++readAttemptFile :: EvidenceAttempt -> FilePath -> IO BS.ByteString+readAttemptFile EvidenceAttempt {outputRoot, directoryName} name = do+  target <- either (throwIO . EvidenceError . Text.pack . show) pure (mkRelativeTarget (directoryName </> name))+  path <- checkedPath outputRoot target+  bytes <- withBinaryFile path ReadMode (\file -> BS.hGet file 8388609)+  when (BS.length bytes > 8388608) (throwIO (EvidenceError "test evidence exceeds 8MiB"))+  pure bytes++-- | Optional framework property helpers record the exact seed/size token and+-- generator contract in a separate fresh sidecar, later included in protocol.+recordPropertyReplay :: ReplayEvidence -> [Text] -> IO ()+recordPropertyReplay replay counterexample =+  currentEvidenceAttempt+    >>= mapM_+      ( \attempt@EvidenceAttempt {outputRoot, directoryName} -> do+          let path = outputRoot </> directoryName </> "property.json"+          exists <- Dir.doesPathExist path+          when exists (throwIO (EvidenceError "one QuickCheck replay property per attempt is supported"))+          LBS.writeFile path (encode (object (identityPairs attempt ++ ["replay" .= Codec.replayJSON replay, "counterexample" .= counterexample])))+      )++-- | Validate a helper sidecar before a reporter incorporates it into completion.+readPropertyReplay :: EvidenceAttempt -> IO (Maybe (ReplayEvidence, [Text]))+readPropertyReplay attempt@EvidenceAttempt {outputRoot, directoryName} = do+  exists <- Dir.doesPathExist (outputRoot </> directoryName </> "property.json")+  if not exists+    then pure Nothing+    else do+      bytes <- readAttemptFile attempt "property.json"+      value <- either (throwIO . EvidenceError . Text.pack) pure (Codec.decodeJSON bytes)+      either+        (throwIO . EvidenceError . Text.pack)+        (pure . Just)+        ( parseEither+            ( Codec.strictObject "property replay" ["protocolVersion", "runId", "caseId", "stage", "role", "attempt", "replay", "counterexample"] $ \o -> do+                forM_ (identityPairs attempt) $ \(key, wanted) -> unless (KM.lookup key o == Just wanted) (fail "stale property replay identity")+                (,) <$> (o .: "replay" >>= Codec.parseReplay) <*> o .: "counterexample"+            )+            value+        )++evidenceIO :: IO a -> IO (Either Text a)+evidenceIO action =+  catches+    (Right <$> action)+    [ Handler (\(EvidenceError why) -> pure (Left why)),+      Handler (\(e :: IOException) -> pure (Left (Text.pack (show e)))),+      Handler (\(e :: WorkspaceError) -> pure (Left (Text.pack (show e))))+    ]++-- | An exception after a framework wrote completion must invalidate that proof,+-- while retaining the prior artifact. Ordinary framework success/failure exits+-- are preserved. The runner still owns timeout and cancellation.+guardEvidenceAttempt :: EvidenceAttempt -> IO a -> IO a+guardEvidenceAttempt attempt action =+  action `catch` \(e :: SomeException) -> do+    case fromException e :: Maybe ExitCode of+      Just ExitSuccess -> pure ()+      Just (ExitFailure 1) -> pure ()+      _ -> do+        let line = encode (object (identityPairs attempt ++ ["sequence" .= (-1 :: Int), "kind" .= String "invalidated", "message" .= displayException e])) <> "\n"+        _ <- try (LBS.appendFile (evidencePath attempt) line) :: IO (Either IOException ())+        pure ()+    throwIO e
+ src/Test/Heni/BuildReuse.hs view
@@ -0,0 +1,100 @@+-- | Explicit build-tool contracts for retaining private worker artifacts.+-- Generic commands have no automatic invalidation guarantee and default fresh.+module Test.Heni.BuildReuse+  ( BuildReuse (..),+    BuildWorker,+    BuildReuseError (..),+    validateBuildReuse,+    withBuildWorker,+    withBuildCandidate,+    confirmBuild,+    retireBuildWorker,+  )+where++import Control.Exception+import Control.Monad (forM_, unless, when)+import Data.IORef+import Data.Text (Text)+import Data.Text qualified as Text+import System.Directory qualified as Dir+import System.FilePath (splitDirectories, (</>))+import Test.Heni.Case+import Test.Heni.Internal.Files+import Test.Heni.Workspace++-- | A consumer promises a content-key-aware build tool. Before tests, the+-- verifier must prove that the completed build observed HENI_BUILD_INPUT_ID.+-- The preparation callback can enforce conservative timestamp behavior. Neither+-- callback is an attestation against malicious commands. This hook is Haskell+-- only because arbitrary build tools cannot share one reliable portable check.+data BuildReuse = BuildReuse+  { contractIdentity :: !Text,+    retainedDirectories :: ![RelativeTarget],+    prepareInputs :: !(Workspace -> IO ()),+    verifyBuildObservation :: !(Workspace -> Text -> IO (Either Text ()))+  }++-- | Invalidation/reset failures invalidate the current case, never a kill.+newtype BuildReuseError = BuildReuseError Text+  deriving stock (Eq, Show)+  deriving anyclass (Exception)++-- | One private workspace and an in-memory command/environment contract.+data BuildWorker key = BuildWorker !SourceSnapshot !BuildReuse !Workspace !(IORef (Maybe (key, Inventory))) !(IORef Bool)++-- | Retain only known top-level build directories, never selected source paths.+validateBuildReuse :: BuildReuse -> Either ConfigError ()+validateBuildReuse BuildReuse {contractIdentity, retainedDirectories} = do+  unless (not (Text.null (Text.strip contractIdentity)) && not (null retainedDirectories)) (Left (ConfigError "buildReuse" "nonempty contract and build directories required"))+  unless (all (\p -> targetPath p `elem` ["dist", "dist-newstyle", ".heni-build"]) retainedDirectories) (Left (ConfigError "buildReuse.directories" "only dist, dist-newstyle or .heni-build can be retained"))++-- | The workspace is never shared with another worker and is discarded at exit.+withBuildWorker :: SourceSnapshot -> BuildReuse -> (BuildWorker key -> IO a) -> IO a+withBuildWorker snapshot provider action = withWorkspace snapshot $ \workspace -> do+  previous <- newIORef Nothing+  dirty <- newIORef True+  action (BuildWorker snapshot provider workspace previous dirty)++-- | Retire retained artifacts after any suspicious lifecycle result.+retireBuildWorker :: BuildWorker key -> IO ()+retireBuildWorker (BuildWorker _ _ _ _ dirty) = writeIORef dirty True++-- | Independently verify the previous source tree, restore exact staged bytes,+-- preserve private build directories, and verify source again after use. A+-- retired worker starts by deleting all retained artifacts from its owned tree.+withBuildCandidate :: (Eq key) => BuildWorker key -> key -> Workspace -> (Workspace -> Text -> IO a) -> IO a+withBuildCandidate worker@(BuildWorker snapshot BuildReuse {retainedDirectories, prepareInputs} workspace previous dirty) key staged action =+  operation `onException` retireBuildWorker worker+  where+    names = map targetPath retainedDirectories+    excluded path = case splitDirectories path of first : _ -> first `elem` names; [] -> False+    operation = do+      old <- readIORef previous+      retired <- readIORef dirty+      when (retired || maybe True ((/= key) . fst) old) (resetWorkspace snapshot workspace >> writeIORef previous Nothing)+      saved <- readIORef previous+      forM_ saved $ \(_, expected) ->+        inventory excluded (workspaceRoot workspace) >>= \actual ->+          unless (actual == expected) (throwIO (BuildReuseError "unexpected source/generated state in retained worker"))+      expected <- inventory (const False) (workspaceRoot staged)+      entries <- Dir.listDirectory (workspaceRoot workspace)+      forM_ (filter (`notElem` names) entries) (Dir.removePathForcibly . (workspaceRoot workspace </>))+      materialize (workspaceRoot workspace) expected+      actual <- inventory excluded (workspaceRoot workspace)+      unless (actual == expected) (throwIO (BuildReuseError "restored build inputs do not match staged mutation"))+      let inputId = inventoryDigest "heni-build-input-v1" expected+      prepareInputs workspace+      verified <- inventory excluded (workspaceRoot workspace)+      unless (verified == expected) (throwIO (BuildReuseError "build preparation changed selected input bytes"))+      value <- action workspace inputId+      final <- inventory excluded (workspaceRoot workspace)+      unless (final == expected) (throwIO (BuildReuseError "build/test changed source or created undeclared generated inputs"))+      writeIORef previous (Just (key, expected))+      writeIORef dirty False+      pure value++-- | Check the build-tool observation before running tests against its artifacts.+confirmBuild :: BuildWorker key -> Text -> IO ()+confirmBuild (BuildWorker _ BuildReuse {verifyBuildObservation} workspace _ _) inputId =+  verifyBuildObservation workspace inputId >>= either (throwIO . BuildReuseError) pure
+ src/Test/Heni/Case.hs view
@@ -0,0 +1,55 @@+-- | Case definitions and validated portable identities.+module Test.Heni.Case+  ( CaseId,+    mkCaseId,+    caseIdText,+    RelativeTarget,+    mkRelativeTarget,+    targetPath,+    SelectedCases,+    mkSelectedCases,+    selectedCaseIds,+    ConfigError (..),+    CaseDefinition (..),+    EvidenceMode (..),+    Edit (..),+    CaseHooks (..),+    WorkspaceOps (..),+    noHooks,+    validateCase,+  )+where++import Data.Set qualified as Set+import Data.Text qualified as Text+import Test.Heni.Internal.Model++-- | Reject invalid expectations and edits before invoking any command.+validateCase :: CaseDefinition -> Either ConfigError ()+validateCase CaseDefinition {description, edits, test, expectation, selector, evidenceMode}+  | Text.null (Text.strip description) = bad "description" "expected an explanation of the fault"+  | maybe False (Text.null . Text.strip) selector = bad "selector" "empty selector"+  | expectation /= Control && null edits = bad "edits" "mutation requires edits"+  | expectation == Control && not (null edits) = bad "edits" "controls must explicitly leave source unchanged"+  | requiresTest expectation && test == Nothing = bad "test" "expectation requires a test command"+  | CrashProbe {} <- expectation, evidenceMode == StructuredTestsV1 = bad "adapter" "crash probes use their dedicated attempt-bound channel"+  | otherwise = mapM_ validateEdit edits >> validateExpectation expectation+  where+    bad f m = Left (ConfigError f m)+    requiresTest BuildRejection {} = False+    requiresTest (Diagnostic Build _ _) = False+    requiresTest _ = True+    validateEdit (Replace _ old new n)+      | Text.null old || old == new || n <= 0 = bad "edits.replace" "expected nonempty distinct text and a positive exact count"+    validateEdit (Patch p) | Text.null (Text.strip p) = bad "edits.patch" "empty patch"+    validateEdit (CustomEdit k) | Text.null k = bad "edits.custom" "empty hook key"+    validateEdit _ = Right ()+    validateExpectation (NamedTests ids _)+      | Set.null ids || any Text.null ids = bad "expect.namedTests" "expected nonempty named failure set"+    validateExpectation (Diagnostic s code witness)+      | s `notElem` [Build, Test] || code <= 0 || Text.null witness = bad "expect.diagnostic" "expected build/test stage, nonzero exit and witness"+    validateExpectation (BuildRejection code witness)+      | code <= 0 || Text.null witness = bad "expect.buildRejection" "expected nonzero exit and witness"+    validateExpectation (CrashProbe sig probe)+      | sig <= 0 || Text.null probe = bad "expect.crash" "expected positive signal and a case-specific probe"+    validateExpectation _ = Right ()
+ src/Test/Heni/Command.hs view
@@ -0,0 +1,36 @@+-- | Portable commands and validated budgets. Commands run without shell expansion.+module Test.Heni.Command+  ( CommandId,+    mkCommandId,+    commandIdText,+    StageBudget,+    mkStageBudget,+    budgetMicroseconds,+    Stage (..),+    ProcessOutcome (..),+    OutputPolicy (..),+    Command (..),+    validateCommand,+  )+where++import Data.List (isPrefixOf)+import Data.Set qualified as Set+import Test.Heni.Internal.Model++-- | Validate launch-independent constraints. Environment names must be unique.+validateCommand :: Command -> Either ConfigError ()+validateCommand Command {executable, arguments, sensitiveArguments, environment, output = OutputPolicy {limitBytes}}+  | null executable || '\0' `elem` executable = bad "executable" "expected an executable without NUL"+  | "./" `isPrefixOf` executable && invalidWorkspaceExecutable = bad "executable" "workspace executable must be a contained relative path"+  | any (elem '\0') arguments = bad "arguments" "NUL in argument"+  | any (\i -> i < 0 || i >= length arguments) (Set.toList sensitiveArguments) = bad "sensitiveArguments" "argument index is out of range"+  | limitBytes <= 0 = bad "output.limitBytes" "expected a positive output limit"+  | any (\(k, v) -> null k || any (`elem` k) ['=', '\0'] || '\0' `elem` v) environment = bad "environment" "invalid environment entry"+  | length (map fst environment) /= length (unique (map fst environment)) = bad "environment" "duplicate environment key"+  | otherwise = Right ()+  where+    invalidWorkspaceExecutable = case mkRelativeTarget (drop 2 executable) of Left _ -> True; Right _ -> False+    bad f m = Left (ConfigError f m)+    unique [] = []+    unique (x : xs) = x : unique (filter (/= x) xs)
+ src/Test/Heni/Crash.hs view
@@ -0,0 +1,71 @@+-- | Fresh attempt-bound reachability probes for explicitly expected native signals.+module Test.Heni.Crash (recordCrashProbe, writeCrashProbe, readCrashProbe, probeRelativePath) where++import Control.Exception+import Control.Monad (forM_, unless, when)+import Data.Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.Aeson.Types (parseEither)+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as LBS+import Data.Text (Text)+import Data.Text qualified as Text+import System.Directory qualified as Dir+import System.FilePath ((</>))+import System.IO+import Test.Heni.Adapter+import Test.Heni.Case (mkRelativeTarget)+import Test.Heni.Internal.Codec (decodeJSON, strictObject)+import Test.Heni.Internal.Files (WorkspaceError, checkedPath)++-- | Relative path kept with the report's other artifacts.+probeRelativePath :: EvidenceAttempt -> FilePath+probeRelativePath EvidenceAttempt {directoryName} = directoryName </> "probe.json"++-- | Called immediately before the intended native operation. Outside Heni it is+-- a no-op; under Heni it refuses to replace existing probe evidence.+recordCrashProbe :: Text -> IO ()+recordCrashProbe probe = currentEvidenceAttempt >>= mapM_ (`writeCrashProbe` probe)++-- | Explicit form for non-environment integrations and conformance fixtures.+writeCrashProbe :: EvidenceAttempt -> Text -> IO ()+writeCrashProbe attempt@EvidenceAttempt {outputRoot} probe = do+  when (Text.null probe || Text.length probe > 65536) (throwIO (EvidenceError "probe must be nonempty and bounded"))+  let path = outputRoot </> probeRelativePath attempt+  exists <- Dir.doesPathExist path+  when exists (throwIO (EvidenceError "probe destination already exists"))+  case attemptIdentityJSON attempt of+    Object identity -> withBinaryFile path WriteMode $ \file -> LBS.hPut file (encode (Object (KM.insert "probe" (String probe) identity))) >> hFlush file+    _ -> error "attempt identity object"++-- | Missing, stale or mismatched probes never prove reachability. The caller+-- retains the rejection reason alongside the actual exit/signal evidence.+readCrashProbe :: EvidenceAttempt -> Text -> IO (Either Text ())+readCrashProbe attempt@EvidenceAttempt {outputRoot} expected =+  catches+    (Right <$> action)+    [ Handler (\(EvidenceError why) -> pure (Left why)),+      Handler (\(e :: IOException) -> pure (Left (Text.pack (show e)))),+      Handler (\(e :: WorkspaceError) -> pure (Left (Text.pack (show e))))+    ]+  where+    action = do+      target <- either (throwIO . EvidenceError . Text.pack . show) pure (mkRelativeTarget (probeRelativePath attempt))+      path <- checkedPath outputRoot target+      bytes <- withBinaryFile path ReadMode (\file -> BS.hGet file 262145)+      when (BS.length bytes > 262144) (throwIO (EvidenceError "probe exceeds size limit"))+      value <- either (throwIO . EvidenceError . Text.pack) pure (decodeJSON bytes)+      identity <- case attemptIdentityJSON attempt of+        Object fields -> pure fields+        _ -> throwIO (EvidenceError "internal attempt identity is not an object")+      either+        (throwIO . EvidenceError . Text.pack)+        pure+        ( parseEither+            ( strictObject "crash probe" ("probe" : KM.keys identity) $ \o -> do+                forM_ (KM.toList identity) $ \(key, wanted) -> unless (KM.lookup key o == Just wanted) (fail "stale or wrong crash probe attempt identity")+                probe <- o .: "probe"+                unless (probe == expected) (fail "wrong crash reachability probe")+            )+            value+        )
+ src/Test/Heni/Edit.hs view
@@ -0,0 +1,156 @@+-- | Transactional checked edits: the callback sees a candidate only after all+-- preconditions and the independent final inventory have passed.+module Test.Heni.Edit+  ( EditError (..),+    FileChange (..),+    EditEvidence (..),+    PatchRunner,+    withEditedWorkspace,+    workspaceOperations,+  )+where++import Control.Exception+import Control.Monad (forM_, unless, when)+import Data.ByteString qualified as BS+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import System.Directory qualified as Dir+import System.FilePath (splitDirectories, takeDirectory)+import Test.Heni.Case+import Test.Heni.Expectation+import Test.Heni.Internal.Files+import Test.Heni.Workspace++-- | Stale/invalid mutations differ from ordinary filesystem infrastructure errors.+data EditError = InvalidEdit !Text | EditInfrastructure !Text+  deriving stock (Eq, Show)+  deriving anyclass (Exception)++-- | Actual before/after bytes, including creation, deletion and executable modes.+data FileChange = FileChange {path :: !RelativeTarget, before :: !(Maybe FileEntry), after :: !(Maybe FileEntry)}+  deriving stock (Eq, Show)++-- | Independently recomputed changes, not a hook's claimed touched-file list.+newtype EditEvidence = EditEvidence {changes :: [FileChange]}+  deriving stock (Eq, Show)++-- | Inject the common supervisor for optional Git. It must bound and reap children.+-- Arguments are passed literally; the runner supplies its workspace as cwd.+type PatchRunner = Workspace -> [String] -> IO (Either Text ())++-- | Checked trusted-IO capabilities. Writes require existing files; creation and+-- deletion have explicit byte preconditions and reject runner-owned paths.+workspaceOperations :: Workspace -> WorkspaceOps+workspaceOperations workspace = WorkspaceOps readText writeText createText deleteText+  where+    checked target = do+      when (reserved target) (throwIO (InvalidEdit "edit targets runner-owned build/evidence metadata"))+      checkedWorkspacePath workspace target+    readText target = do+      p <- checked target+      b <- BS.readFile p+      either (const (throwIO (InvalidEdit "target is not valid UTF-8"))) pure (Text.decodeUtf8' b)+    writeText target content = do+      _ <- readText target+      p <- checked target+      BS.writeFile p (Text.encodeUtf8 content)+    createText target content = do+      p <- checked target+      exists <- Dir.doesPathExist p+      when exists (throwIO (InvalidEdit "creation target already exists"))+      Dir.createDirectoryIfMissing True (takeDirectory p)+      BS.writeFile p (Text.encodeUtf8 content)+    deleteText target expected = do+      actual <- readText target+      unless (actual == expected) (throwIO (InvalidEdit "deletion precondition failed"))+      p <- checked target+      Dir.removeFile p++reserved :: RelativeTarget -> Bool+reserved target = any (\p -> p `elem` [".git", "dist", "dist-newstyle", ".heni", "mutation-results"] || ".heni-" `Text.isPrefixOf` Text.pack p) (splitDirectories (targetPath target))++-- | Allocate a private candidate, apply ordered edits, inspect all final bytes,+-- then invoke the consumer. Failure or cancellation discards the entire tree.+withEditedWorkspace :: forall a. PatchRunner -> CaseHooks -> SourceSnapshot -> CaseDefinition -> (Workspace -> EditEvidence -> IO a) -> IO (Either EditError a)+withEditedWorkspace runPatch hooks snapshot definition action =+  catches+    (Right <$> withWorkspace snapshot candidate)+    [ Handler (pure . Left :: EditError -> IO (Either EditError a)),+      Handler (\e -> pure (Left (InvalidEdit (Text.pack (show (e :: WorkspaceError)))))),+      Handler (\e -> pure (Left (EditInfrastructure (Text.pack (show (e :: IOException))))))+    ]+  where+    candidate workspace = do+      let CaseDefinition {edits, expectation} = definition+      forM_ edits (apply workspace)+      actual <- inventory (const False) (workspaceRoot workspace)+      let original = snapshotFiles snapshot+          changed =+            [ FileChange p (Map.lookup p original) (Map.lookup p actual)+            | p <- Set.toAscList (Map.keysSet original <> Map.keysSet actual),+              Map.lookup p original /= Map.lookup p actual+            ]+      when (any (\FileChange {path} -> reserved path) changed) (throwIO (InvalidEdit "edit changed runner-owned metadata"))+      when (expectation /= Control && null changed) (throwIO (InvalidEdit "mutation left all bytes unchanged"))+      when (expectation == Control && not (null changed)) (throwIO (InvalidEdit "control changed source bytes"))+      action workspace (EditEvidence changed)+    apply workspace edit = do+      let ops@WorkspaceOps {readTarget, writeTarget, createTarget, deleteTarget} = workspaceOperations workspace+      case edit of+        Replace target old new count -> do+          when (Text.null old || old == new || count <= 0) (throwIO (InvalidEdit "invalid replacement definition"))+          actual <- readTarget target+          unless (Text.count old actual == count) (throwIO (InvalidEdit "replacement anchor count did not match"))+          writeTarget target (Text.replace old new actual)+        Create target content -> createTarget target content+        Delete target expected -> deleteTarget target expected+        CustomEdit key -> case hooks of+          CaseHooks {customEdits} -> case lookup key customEdits of+            Nothing -> throwIO (InvalidEdit ("missing custom edit hook: " <> key))+            Just hook -> hook ops+        Patch content -> do+          targets <- either (throwIO . InvalidEdit) pure (patchTargets content)+          mapM_ (checkedWorkspacePath workspace) targets+          withOwnedDirectory ".heni-patch-" $ \scratch -> do+            let patchFile = scratch ++ "/candidate.patch"+            BS.writeFile patchFile (Text.encodeUtf8 content)+            forM_ [["apply", "--check", "--", patchFile], ["apply", "--", patchFile]] $ \args -> do+              result <- runPatch workspace args+              either (throwIO . InvalidEdit) pure result++patchTargets :: Text -> Either Text [RelativeTarget]+patchTargets content+  | any unsupported lines' = Left "only textual Git patches without rename/copy/mode/binary operations are supported"+  | null headers = Left "patch requires diff --git headers"+  | otherwise = do+      diffs <- concat <$> mapM parse headers+      paths <- mapM parseFileHeader (filter (\line -> "--- " `Text.isPrefixOf` line || "+++ " `Text.isPrefixOf` line) lines')+      let allPaths = diffs ++ [p | Just p <- paths]+      if any reserved allPaths then Left "patch targets runner-owned metadata" else Right allPaths+  where+    lines' = Text.lines content+    headers = filter (Text.isPrefixOf "diff --git ") lines'+    unsupported line =+      any+        (`Text.isPrefixOf` line)+        ["GIT binary patch", "Binary files ", "rename ", "copy ", "old mode ", "new mode "]+        || ("new file mode " `Text.isPrefixOf` line && line `notElem` ["new file mode 100644", "new file mode 100755"])+        || ("deleted file mode " `Text.isPrefixOf` line && line `notElem` ["deleted file mode 100644", "deleted file mode 100755"])+        || ("index " `Text.isPrefixOf` line && "120000" `Text.isSuffixOf` line)+    parse line = case Text.words line of+      ["diff", "--git", a, b] -> do+        x <- pathAfter "a/" a+        y <- pathAfter "b/" b+        if x /= y then Left "patch renaming is unsupported" else Right [x, y]+      _ -> Left "quoted or spaced patch paths are not supported"+    pathAfter prefix t = case Text.stripPrefix prefix t of+      Nothing -> Left "patch requires a/ and b/ paths"+      Just p -> either (Left . Text.pack . show) Right (mkRelativeTarget (Text.unpack p))+    parseFileHeader line+      | Text.drop 4 line == "/dev/null" = Right Nothing+      | "--- " `Text.isPrefixOf` line = Just <$> pathAfter "a/" (Text.drop 4 line)+      | otherwise = Just <$> pathAfter "b/" (Text.drop 4 line)
+ src/Test/Heni/Expectation.hs view
@@ -0,0 +1,12 @@+-- | Explicit expectation and selection evidence. Framework types never enter the core.+module Test.Heni.Expectation+  ( Expectation (..),+    ReplayPolicy (..),+    ReplayEvidence (..),+    SelectionEvidence (..),+    unknownSelection,+    StageEvidence (..),+  )+where++import Test.Heni.Internal.Model
+ src/Test/Heni/Internal/Baseline.hs view
@@ -0,0 +1,34 @@+-- | Campaign-local single-flight baseline sharing. Keys may contain secrets and+-- are never serialized or hashed for portable reports.+module Test.Heni.Internal.Baseline (BaselineCache, newBaselineCache, clearBaselineCache, shareBaseline) where++import Control.Concurrent.MVar+import Control.Exception++newtype BaselineCache key value = BaselineCache (MVar [(key, MVar (Maybe value))])++newBaselineCache :: IO (BaselineCache key value)+newBaselineCache = BaselineCache <$> newMVar []++clearBaselineCache :: BaselineCache key value -> IO ()+clearBaselineCache (BaselineCache cache) = modifyMVar_ cache (const (pure []))++-- | The producer returns Nothing for an unshareable observation. Waiters then+-- execute independently. Cancellation publishes Nothing before rethrowing so+-- another worker can never remain blocked on abandoned ownership.+shareBaseline :: (Eq key) => BaselineCache key value -> key -> IO (Maybe value) -> IO (Bool, Maybe value)+shareBaseline (BaselineCache cache) key compute = mask $ \restore -> do+  (owner, slot) <- modifyMVar cache $ \entries -> case lookup key entries of+    Just existing -> pure (entries, (False, existing))+    Nothing -> do fresh <- newEmptyMVar; pure ((key, fresh) : entries, (True, fresh))+  if owner+    then do+      result <- try (restore compute)+      case result of+        Left (e :: SomeException) -> putMVar slot Nothing >> throwIO e+        Right value -> putMVar slot value >> pure (False, value)+    else do+      cached <- restore (readMVar slot)+      case cached of+        Just value -> pure (True, Just value)+        Nothing -> (False,) <$> restore compute
+ src/Test/Heni/Internal/Codec.hs view
@@ -0,0 +1,380 @@+-- | Explicit version-one wire vocabulary. No Generic-derived compatibility.+module Test.Heni.Internal.Codec where++import Control.Monad (unless)+import Data.Aeson+import Data.Aeson.Decoding.ByteString (bsToTokens)+import Data.Aeson.Decoding.Tokens+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.Aeson.Types (Parser)+import Data.ByteString qualified as BS+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Test.Heni.Case (validateCase)+import Test.Heni.Command (validateCommand)+import Test.Heni.Internal.Model+import Test.Heni.Result (summarizeRun)++-- | Validate the original token stream before Aeson's map conversion can erase+-- duplicate keys. Escaped spellings are compared as decoded keys at every depth.+decodeJSON :: BS.ByteString -> Either String Value+decodeJSON bytes = do+  leftover <- checkTokens 0 (bsToTokens bytes)+  unless (BS.all (`elem` [9, 10, 13, 32]) leftover) (Left "trailing non-whitespace after JSON value")+  eitherDecodeStrict bytes++checkTokens :: Int -> Tokens k String -> Either String k+checkTokens depth _ | depth > 128 = Left "JSON nesting exceeds 128 levels"+checkTokens _ (TkLit _ k) = Right k+checkTokens _ (TkText _ k) = Right k+checkTokens _ (TkNumber _ k) = Right k+checkTokens depth (TkArrayOpen array) = checkArray (depth + 1) array+checkTokens depth (TkRecordOpen record) = checkRecord (depth + 1) Set.empty record+checkTokens _ (TkErr message) = Left message++checkArray :: Int -> TkArray k String -> Either String k+checkArray depth (TkItem value) = checkTokens depth value >>= checkArray depth+checkArray _ (TkArrayEnd k) = Right k+checkArray _ (TkArrayErr message) = Left message++checkRecord :: Int -> Set.Set Key -> TkRecord k String -> Either String k+checkRecord depth seen (TkPair key value)+  | key `Set.member` seen = Left ("duplicate JSON object key: " ++ Key.toString key)+  | otherwise = checkTokens depth value >>= checkRecord depth (Set.insert key seen)+checkRecord _ _ (TkRecordEnd k) = Right k+checkRecord _ _ (TkRecordErr message) = Left message++strictObject :: String -> [Key] -> (Object -> Parser a) -> Value -> Parser a+strictObject label allowed f = withObject label $ \o -> do+  unless (all (`elem` allowed) (KM.keys o)) (fail (label ++ ": unknown keys " ++ show (filter (`notElem` allowed) (KM.keys o))))+  f o++checked :: (Show e) => Either e a -> Parser a+checked = either (fail . show) pure++enumValue :: (Eq a) => [(Text, a)] -> a -> Value+enumValue table a = case [name | (name, b) <- table, a == b] of+  name : _ -> String name+  [] -> error "internal wire enum table is incomplete"++enumParser :: [(Text, a)] -> Value -> Parser a+enumParser table = withText "enum" (\name -> maybe (fail ("unknown value: " ++ Text.unpack name)) pure (lookup name table))++stageTable :: [(Text, Stage)]+stageTable = [("setup", Setup), ("build", Build), ("test", Test), ("replay", Replay), ("teardown", Teardown)]++verdicts :: [(Text, Verdict)]+verdicts = [("caught", Caught), ("survived", Survived), ("invalid-mutation", InvalidMutation), ("uncovered", Uncovered), ("unexpected-build-failure", UnexpectedBuildFailure), ("wrong-failure", WrongFailure), ("timed-out", TimedOut), ("infrastructure-error", InfrastructureError), ("not-run", NotRun)]++detections :: [(Text, DetectionKind)]+detections = [("semantic-named", SemanticNamed), ("generic-diagnostic", GenericDiagnostic), ("expected-build-rejection", ExpectedBuildRejection), ("explicit-crash", ExplicitCrash), ("control-passed", ControlPassed)]++statuses :: [(Text, RunStatus)]+statuses = [("passed", RunPassed), ("failed", RunFailed), ("untrustworthy", RunUntrustworthy), ("interrupted", RunInterrupted)]++nullable :: (a -> Value) -> Maybe a -> Value+nullable = maybe Null++parseNullable :: (Value -> Parser a) -> Value -> Parser (Maybe a)+parseNullable _ Null = pure Nothing+parseNullable f v = Just <$> f v++commandJSON :: Command -> Value+commandJSON Command {executable, arguments, sensitiveArguments, workingDirectory, environment, budget, output = OutputPolicy {limitBytes}} =+  object+    [ "executable" .= executable,+      "arguments" .= arguments,+      "sensitiveArguments" .= Set.toAscList sensitiveArguments,+      "workingDirectory" .= fmap targetPath workingDirectory,+      "environment" .= object [Key.fromString k .= v | (k, v) <- environment],+      "timeoutMicroseconds" .= budgetMicroseconds budget,+      "outputLimitBytes" .= limitBytes+    ]++parseCommand :: Value -> Parser Command+parseCommand = strictObject "command" ["executable", "arguments", "sensitiveArguments", "workingDirectory", "environment", "timeoutMicroseconds", "outputLimitBytes"] $ \o -> do+  executable <- o .: "executable"+  arguments <- o .:? "arguments" .!= []+  sensitiveArguments <- o .:? "sensitiveArguments" .!= [] >>= uniqueSet+  relative <- o .:? "workingDirectory"+  workingDirectory <- traverse (checked . mkRelativeTarget) relative+  env <- o .:? "environment" .!= KM.empty+  environment <- mapM (\(k, v) -> (,) (Key.toString k) <$> parseJSON v) (KM.toList env)+  budget <- o .: "timeoutMicroseconds" >>= checked . mkStageBudget+  output <- OutputPolicy <$> o .:? "outputLimitBytes" .!= 1048576+  let command = Command {executable, arguments, sensitiveArguments, workingDirectory, environment, budget, output}+  checked (validateCommand command)+  pure command++editJSON :: Edit -> Value+editJSON (Replace p old new n) = object ["kind" .= String "replace", "path" .= targetPath p, "old" .= old, "new" .= new, "occurrences" .= n]+editJSON (Create p content) = object ["kind" .= String "create", "path" .= targetPath p, "content" .= content]+editJSON (Delete p content) = object ["kind" .= String "delete", "path" .= targetPath p, "expected" .= content]+editJSON (Patch content) = object ["kind" .= String "patch", "content" .= content]+editJSON (CustomEdit key) = object ["kind" .= String "custom", "key" .= key]++parseEdit :: Value -> Parser Edit+parseEdit value =+  withObject+    "edit"+    ( \o ->+        o .: "kind" >>= \(kind :: Text) -> case kind of+          "replace" -> strictObject "replace" ["kind", "path", "old", "new", "occurrences"] (\x -> Replace <$> (x .: "path" >>= checked . mkRelativeTarget) <*> x .: "old" <*> x .: "new" <*> x .:? "occurrences" .!= 1) value+          "create" -> strictObject "create" ["kind", "path", "content"] (\x -> Create <$> (x .: "path" >>= checked . mkRelativeTarget) <*> x .: "content") value+          "delete" -> strictObject "delete" ["kind", "path", "expected"] (\x -> Delete <$> (x .: "path" >>= checked . mkRelativeTarget) <*> x .: "expected") value+          "patch" -> strictObject "patch" ["kind", "content"] (\x -> Patch <$> x .: "content") value+          "custom" -> strictObject "custom" ["kind", "key"] (\x -> CustomEdit <$> x .: "key") value+          _ -> fail "unknown edit kind"+    )+    value++expectationJSON :: Expectation -> Value+expectationJSON (NamedTests ids policy) = object ["kind" .= String "named-tests", "failures" .= Set.toAscList ids, "replayRequired" .= (policy == RequireReplay)]+expectationJSON (Diagnostic stage code witness) = object ["kind" .= String "diagnostic", "stage" .= enumValue stageTable stage, "exitCode" .= code, "outputContains" .= witness]+expectationJSON (BuildRejection code witness) = object ["kind" .= String "build-rejection", "exitCode" .= code, "outputContains" .= witness]+expectationJSON (CrashProbe signal probe) = object ["kind" .= String "crash", "signal" .= signal, "probe" .= probe]+expectationJSON Control = object ["kind" .= String "control"]++parseExpectation :: Value -> Parser Expectation+parseExpectation value =+  withObject+    "expectation"+    ( \o ->+        o .: "kind" >>= \(kind :: Text) -> case kind of+          "named-tests" -> strictObject "named-tests" ["kind", "failures", "replayRequired"] (\x -> NamedTests <$> (x .: "failures" >>= uniqueSet) <*> ((\b -> if b then RequireReplay else NoReplay) <$> (x .:? "replayRequired" .!= False))) value+          "diagnostic" -> strictObject "diagnostic" ["kind", "stage", "exitCode", "outputContains"] (\x -> Diagnostic <$> (x .: "stage" >>= enumParser stageTable) <*> x .: "exitCode" <*> x .: "outputContains") value+          "build-rejection" -> strictObject "build-rejection" ["kind", "exitCode", "outputContains"] (\x -> BuildRejection <$> x .: "exitCode" <*> x .: "outputContains") value+          "crash" -> strictObject "crash" ["kind", "signal", "probe"] (\x -> CrashProbe <$> x .: "signal" <*> x .: "probe") value+          "control" -> strictObject "control" ["kind"] (const (pure Control)) value+          _ -> fail "unknown expectation kind"+    )+    value++uniqueSet :: (Ord a) => [a] -> Parser (Set.Set a)+uniqueSet xs = let s = Set.fromList xs in if Set.size s == length xs then pure s else fail "duplicate set member"++caseJSON :: CaseDefinition -> Value+caseJSON CaseDefinition {caseId, description, tags, edits, build, test, selector, expectation, evidenceMode} =+  object+    [ "id" .= caseIdText caseId,+      "description" .= description,+      "tags" .= Set.toAscList tags,+      "edits" .= map editJSON edits,+      "build" .= commandIdText build,+      "test" .= fmap commandIdText test,+      "selector" .= selector,+      "expect" .= expectationJSON expectation,+      "adapter" .= (case evidenceMode of GenericCommands -> Null; StructuredTestsV1 -> String "heni-v1")+    ]++parseCase :: Value -> Parser CaseDefinition+parseCase = strictObject "case" ["id", "description", "tags", "edits", "build", "test", "selector", "expect", "adapter"] $ \o -> do+  caseId <- o .: "id" >>= checked . mkCaseId+  description <- o .: "description"+  tags <- o .:? "tags" .!= [] >>= uniqueSet+  edits <- o .:? "edits" .!= [] >>= mapM parseEdit+  build <- o .: "build" >>= checked . mkCommandId+  test <- o .:? "test" >>= traverse (checked . mkCommandId)+  selector <- o .:? "selector"+  expectation <- o .: "expect" >>= parseExpectation+  evidenceMode <-+    o .:? "adapter" >>= \case+      Nothing -> pure GenericCommands+      Just ("heni-v1" :: Text) -> pure StructuredTestsV1+      Just _ -> fail "unsupported adapter; expected heni-v1"+  let definition = CaseDefinition {caseId, description, tags, edits, build, test, selector, expectation, evidenceMode}+  checked (validateCase definition)+  pure definition++outcomeJSON :: ProcessOutcome -> Value+outcomeJSON (Exited code) = object ["kind" .= String "exit", "code" .= code]+outcomeJSON (Signaled signal) = object ["kind" .= String "signal", "signal" .= signal]+outcomeJSON Timeout = object ["kind" .= String "timeout"]+outcomeJSON Cancelled = object ["kind" .= String "cancelled"]+outcomeJSON (LaunchFailed message) = object ["kind" .= String "launch-failed", "message" .= message]++parseOutcome :: Value -> Parser ProcessOutcome+parseOutcome value =+  withObject+    "outcome"+    ( \o ->+        o .: "kind" >>= \(kind :: Text) -> case kind of+          "exit" -> strictObject "exit" ["kind", "code"] (\x -> do n <- x .: "code"; if n < 0 then fail "negative normal exit" else pure (Exited n)) value+          "signal" -> strictObject "signal" ["kind", "signal"] (\x -> do n <- x .: "signal"; if n <= 0 then fail "nonpositive signal" else pure (Signaled n)) value+          "timeout" -> strictObject "timeout" ["kind"] (const (pure Timeout)) value+          "cancelled" -> strictObject "cancelled" ["kind"] (const (pure Cancelled)) value+          "launch-failed" -> strictObject "launch-failed" ["kind", "message"] (\x -> LaunchFailed <$> x .: "message") value+          _ -> fail "unknown process outcome"+    )+    value++selectionJSON :: SelectionEvidence -> Value+selectionJSON SelectionEvidence {discovered, selected, executed, executedIds, failedIds, skippedIds, setupErrors} =+  object+    [ "discovered" .= discovered,+      "selected" .= selected,+      "executed" .= executed,+      "executedIds" .= Set.toAscList executedIds,+      "failedIds" .= Set.toAscList failedIds,+      "skippedIds" .= Set.toAscList skippedIds,+      "setupErrors" .= setupErrors+    ]++parseSelection :: Value -> Parser SelectionEvidence+parseSelection = strictObject "selection" ["discovered", "selected", "executed", "executedIds", "failedIds", "skippedIds", "setupErrors"] $ \o ->+  SelectionEvidence+    <$> o .: "discovered"+    <*> o .: "selected"+    <*> o .: "executed"+    <*> (o .: "executedIds" >>= uniqueSet)+    <*> (o .: "failedIds" >>= uniqueSet)+    <*> (o .: "skippedIds" >>= uniqueSet)+    <*> o .: "setupErrors"++replayJSON :: ReplayEvidence -> Value+replayJSON ReplayEvidence {token, generator, failures} = object ["token" .= token, "generator" .= generator, "failures" .= Set.toAscList failures]++parseReplay :: Value -> Parser ReplayEvidence+parseReplay = strictObject "replay" ["token", "generator", "failures"] $ \o -> ReplayEvidence <$> o .: "token" <*> o .: "generator" <*> (o .: "failures" >>= uniqueSet)++stageJSON :: StageEvidence -> Value+stageJSON StageEvidence {stage, outcome, transcript, truncated, selection, replay, durationSeconds, logPaths} =+  object+    [ "stage" .= enumValue stageTable stage,+      "outcome" .= outcomeJSON outcome,+      "witnesses" .= transcript,+      "evidenceUnavailable" .= truncated,+      "selection" .= selectionJSON selection,+      "replay" .= nullable replayJSON replay,+      "durationSeconds" .= durationSeconds,+      "logPaths" .= logPaths+    ]++parseStage :: Value -> Parser StageEvidence+parseStage = strictObject "stage" ["stage", "outcome", "witnesses", "evidenceUnavailable", "selection", "replay", "durationSeconds", "logPaths"] $ \o -> do+  stage <- o .: "stage" >>= enumParser stageTable+  outcome <- o .: "outcome" >>= parseOutcome+  transcript <- o .: "witnesses"+  truncated <- o .: "evidenceUnavailable"+  selection <- o .: "selection" >>= parseSelection+  replay <- o .: "replay" >>= parseNullable parseReplay+  durationSeconds <- o .: "durationSeconds"+  unless (durationSeconds >= 0 && not (isInfinite durationSeconds || isNaN durationSeconds)) (fail "invalid duration")+  logPaths <- o .: "logPaths"+  mapM_ (checked . mkRelativeTarget) logPaths+  pure StageEvidence {stage, outcome, transcript, truncated, selection, replay, durationSeconds, logPaths}++baselineJSON :: BaselineEvidence -> Value+baselineJSON BaselinePassed = object ["kind" .= String "passed"]+baselineJSON BaselineUnavailable = object ["kind" .= String "unavailable"]+baselineJSON (BaselineFailed message) = object ["kind" .= String "failed", "message" .= message]++parseBaseline :: Value -> Parser BaselineEvidence+parseBaseline = strictObject "baseline" ["kind", "message"] $ \o ->+  o .: "kind" >>= \(kind :: Text) -> case kind of+    "passed" -> pure BaselinePassed+    "unavailable" -> pure BaselineUnavailable+    "failed" -> BaselineFailed <$> o .: "message"+    _ -> fail "unknown baseline status"++mutationJSON :: MutationEvidence -> Value+mutationJSON Changed = object ["kind" .= String "changed"]+mutationJSON Unchanged = object ["kind" .= String "unchanged"]+mutationJSON (EditRejected message) = object ["kind" .= String "rejected", "message" .= message]++parseMutation :: Value -> Parser MutationEvidence+parseMutation = strictObject "mutation" ["kind", "message"] $ \o ->+  o .: "kind" >>= \(kind :: Text) -> case kind of+    "changed" -> pure Changed+    "unchanged" -> pure Unchanged+    "rejected" -> EditRejected <$> o .: "message"+    _ -> fail "unknown mutation status"++evidenceJSON :: CaseEvidence -> Value+evidenceJSON CaseEvidence {baseline, mutation, stages, infrastructureErrors, cleanupErrors, stopped} =+  object+    [ "baseline" .= baselineJSON baseline,+      "mutation" .= mutationJSON mutation,+      "stages" .= map stageJSON stages,+      "infrastructureErrors" .= infrastructureErrors,+      "cleanupErrors" .= cleanupErrors,+      "stopped" .= stopped+    ]++parseEvidence :: Value -> Parser CaseEvidence+parseEvidence = strictObject "case evidence" ["baseline", "mutation", "stages", "infrastructureErrors", "cleanupErrors", "stopped"] $ \o ->+  CaseEvidence+    <$> (o .: "baseline" >>= parseBaseline)+    <*> (o .: "mutation" >>= parseMutation)+    <*> (o .: "stages" >>= mapM parseStage)+    <*> o .: "infrastructureErrors"+    <*> o .: "cleanupErrors"+    <*> o .: "stopped"++caseResultJSON :: CaseResult -> Value+caseResultJSON CaseResult {caseId, verdict, detection, semantic, control, reason, evidence} =+  object+    [ "id" .= caseIdText caseId,+      "verdict" .= enumValue verdicts verdict,+      "detection" .= nullable (enumValue detections) detection,+      "semantic" .= semantic,+      "control" .= control,+      "reason" .= reason,+      "evidence" .= evidenceJSON evidence+    ]++parseCaseResult :: Value -> Parser CaseResult+parseCaseResult = strictObject "case result" ["id", "verdict", "detection", "semantic", "control", "reason", "evidence"] $ \o ->+  CaseResult+    <$> (o .: "id" >>= checked . mkCaseId)+    <*> (o .: "verdict" >>= enumParser verdicts)+    <*> (o .: "detection" >>= parseNullable (enumParser detections))+    <*> o .: "semantic"+    <*> o .: "control"+    <*> o .: "reason"+    <*> (o .: "evidence" >>= parseEvidence)++failureJSON :: RunFailure -> Value+failureJSON (BaselineFailure ids why) = object ["kind" .= String "baseline", "affected" .= map caseIdText ids, "message" .= why]+failureJSON (ControlFailure i why) = object ["kind" .= String "control", "id" .= caseIdText i, "message" .= why]+failureJSON (RunInfrastructure why) = object ["kind" .= String "infrastructure", "message" .= why]+failureJSON (Interrupted why) = object ["kind" .= String "interrupted", "message" .= why]++parseFailure :: Value -> Parser RunFailure+parseFailure = strictObject "run failure" ["kind", "affected", "id", "message"] $ \o ->+  o .: "kind" >>= \(kind :: Text) -> case kind of+    "baseline" -> BaselineFailure <$> (o .: "affected" >>= mapM (checked . mkCaseId)) <*> o .: "message"+    "control" -> ControlFailure <$> (o .: "id" >>= checked . mkCaseId) <*> o .: "message"+    "infrastructure" -> RunInfrastructure <$> o .: "message"+    "interrupted" -> Interrupted <$> o .: "message"+    _ -> fail "unknown run failure"++scoreJSON :: SemanticScore -> Value+scoreJSON (SemanticScore caught total complete) = object ["caught" .= caught, "total" .= total, "complete" .= complete]++parseScore :: Value -> Parser SemanticScore+parseScore = strictObject "score" ["caught", "total", "complete"] $ \o -> SemanticScore <$> o .: "caught" <*> o .: "total" <*> o .: "complete"++resultJSON :: RunResult -> Value+resultJSON RunResult {selectedIds, results, failures, status, score} =+  object+    [ "selectedIds" .= map caseIdText selectedIds,+      "cases" .= map caseResultJSON results,+      "failures" .= map failureJSON failures,+      "status" .= enumValue statuses status,+      "semanticScore" .= nullable scoreJSON score+    ]++parseResult :: Value -> Parser RunResult+parseResult = strictObject "run result" ["selectedIds", "cases", "failures", "status", "semanticScore"] $ \o -> do+  selected <- o .: "selectedIds" >>= mapM (checked . mkCaseId) >>= checked . mkSelectedCases+  cases <- o .: "cases" >>= mapM parseCaseResult+  failures <- o .: "failures" >>= mapM parseFailure+  status <- o .: "status" >>= enumParser statuses+  score <- o .: "semanticScore" >>= parseNullable parseScore+  result <- checked (summarizeRun selected cases failures)+  let RunResult {status = actualStatus, score = actualScore} = result+  unless (actualStatus == status && actualScore == score) (fail "stored summary contradicts selected ledger")+  pure result
+ src/Test/Heni/Internal/Files.hs view
@@ -0,0 +1,130 @@+{-# OPTIONS_HADDOCK not-home #-}++-- | Implementation detail, not an exposed module or supported import API.+-- Owns temporary paths and checked byte inventories for the POSIX implementation.+module Test.Heni.Internal.Files where++import Control.Exception+import Control.Monad (forM, forM_, unless, when)+import Crypto.Hash (Digest, SHA256, hash)+import Data.Bits ((.&.))+import Data.ByteString qualified as BS+import Data.ByteString.Char8 qualified as B8+import Data.List (sort)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import System.Directory qualified as Dir+import System.FilePath (takeDirectory, (</>))+import System.IO.Error (isDoesNotExistError)+import System.Posix.Files qualified as Posix+import System.Posix.Temp (mkdtemp)+import Test.Heni.Case (RelativeTarget, mkRelativeTarget, targetPath)++-- | Structured filesystem failure; ordinary IO errors remain IOException.+data WorkspaceError = UnsafeSource !Text | SourceChanged | WorkspaceMismatch !Text+  deriving stock (Eq, Show)+  deriving anyclass (Exception)++-- | Immutable bytes and executable bits, excluding ownership and timestamps.+data FileEntry = FileEntry {bytes :: !BS.ByteString, executableBits :: !Int}+  deriving stock (Eq, Show)++-- | Canonical relative targets mapped to immutable file contents.+type Inventory = Map.Map RelativeTarget FileEntry++-- | Preserve both exceptions when disposal fails after an action already failed.+data WorkspaceFinalizationError = WorkspaceFinalizationError !(Maybe SomeException) !SomeException+  deriving stock (Show)+  deriving anyclass (Exception)++-- | Mask disposal, retaining both the primary and secondary exception.+withFinalizer :: forall a. IO a -> IO () -> IO a+withFinalizer action release = mask $ \restore -> do+  primary <- try (restore action) :: IO (Either SomeException a)+  secondary <- try release :: IO (Either SomeException ())+  case (primary, secondary) of+    (Right value, Right ()) -> pure value+    (Left e, Right ()) -> throwIO e+    (Right _, Left e) -> throwIO (WorkspaceFinalizationError Nothing e)+    (Left first, Left second) -> throwIO (WorkspaceFinalizationError (Just first) second)++-- | Lowercase hexadecimal SHA-256 content digest.+digest :: BS.ByteString -> Text+digest = Text.pack . show . (hash :: BS.ByteString -> Digest SHA256)++-- | Length-prefix bytes to prevent concatenation ambiguity.+framed :: BS.ByteString -> BS.ByteString+framed b = B8.pack (show (BS.length b) ++ ":") <> b++-- | Hash policy, names, executable bits and bytes with explicit framing.+inventoryDigest :: Text -> Inventory -> Text+inventoryDigest policy files =+  digest $+    framed (Text.encodeUtf8 policy)+      <> BS.concat [framed (Text.encodeUtf8 (Text.pack (targetPath p))) <> framed (B8.pack (show mode)) <> framed b | (p, FileEntry b mode) <- Map.toAscList files]++-- | Allocate an opaque temporary directory and dispose it after the callback.+withOwnedDirectory :: String -> (FilePath -> IO a) -> IO a+withOwnedDirectory prefix action = mask $ \restore -> do+  root <- allocate+  withFinalizer (restore (action root)) (Dir.removePathForcibly root)+  where+    allocate = do+      temp <- Dir.getTemporaryDirectory >>= Dir.canonicalizePath+      mkdtemp (temp </> prefix)++-- | No symlink component is accepted, including a dangling link. Call before IO.+checkedPath :: FilePath -> RelativeTarget -> IO FilePath+checkedPath root target = do+  rootStatus <- Posix.getSymbolicLinkStatus root+  unless (Posix.isDirectory rootStatus && not (Posix.isSymbolicLink rootStatus)) (throwIO (UnsafeSource "workspace root is not a directory"))+  go root (Text.splitOn "/" (Text.pack (targetPath target)))+  pure (root </> targetPath target)+  where+    go _ [] = pure ()+    go current (part : rest) = do+      let p = current </> Text.unpack part+      status <- tryIO (Posix.getSymbolicLinkStatus p)+      case status of+        Left e | isDoesNotExistError e -> pure ()+        Left e -> throwIO e+        Right st -> do+          when (Posix.isSymbolicLink st) (throwIO (UnsafeSource ("symlink target: " <> Text.pack p)))+          unless+            (if null rest then Posix.isRegularFile st || Posix.isDirectory st else Posix.isDirectory st)+            (throwIO (UnsafeSource ("non-regular path: " <> Text.pack p)))+          go p rest++-- | Catch only ordinary IO failures, preserving asynchronous exceptions.+tryIO :: IO a -> IO (Either IOException a)+tryIO = try++-- | Read the exact selected regular-file tree; names are canonical relative paths.+inventory :: (FilePath -> Bool) -> FilePath -> IO Inventory+inventory excluded root = Map.fromList <$> walk ""+  where+    walk relative = do+      let full = if null relative then root else root </> relative+      st <- Posix.getSymbolicLinkStatus full+      when (Posix.isSymbolicLink st) (throwIO (UnsafeSource ("source symlink is unsupported: " <> Text.pack relative)))+      if Posix.isDirectory st+        then do+          names <- sort <$> Dir.listDirectory full+          concat <$> forM names (\n -> let p = if null relative then n else relative </> n in if excluded p then pure [] else walk p)+        else+          if Posix.isRegularFile st+            then do+              target <- either (throwIO . UnsafeSource . Text.pack . show) pure (mkRelativeTarget relative)+              b <- BS.readFile full+              pure [(target, FileEntry b (fromIntegral (Posix.fileMode st .&. 0o111)))]+            else throwIO (UnsafeSource ("special source file is unsupported: " <> Text.pack relative))++-- | Write independent regular files with their captured executable bits.+materialize :: FilePath -> Inventory -> IO ()+materialize root files = forM_ (Map.toAscList files) $ \(target, FileEntry b mode) -> do+  p <- checkedPath root target+  Dir.createDirectoryIfMissing True (takeDirectory p)+  BS.writeFile p b+  Posix.setFileMode p (0o600 + fromIntegral mode)
+ src/Test/Heni/Internal/Model.hs view
@@ -0,0 +1,278 @@+{-# OPTIONS_HADDOCK not-home #-}++-- | Implementation detail, not an exposed module or supported import API.+-- Public facades hide constrained constructors and own their documentation.+module Test.Heni.Internal.Model where++import Data.Char (isAlphaNum, isAscii)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Test.Heni.Internal.Prelude++-- | A field-specific configuration problem, detected before execution.+data ConfigError = ConfigError {field :: !Text, message :: !Text}+  deriving stock (Eq, Show, Generic)++-- | Stable portable case identifier.+newtype CaseId = CaseId Text deriving stock (Eq, Ord, Show, Generic)++-- | Stable portable command identifier.+newtype CommandId = CommandId Text deriving stock (Eq, Ord, Show, Generic)++-- | Slash-separated path relative to a workspace, never a parent or root.+newtype RelativeTarget = RelativeTarget FilePath deriving stock (Eq, Ord, Show, Generic)++-- | Positive wall-clock microseconds, bounded to avoid timer overflow.+newtype StageBudget = StageBudget Int deriving stock (Eq, Ord, Show, Generic)++-- | A nonempty set of selected IDs, preserving the requested order.+newtype SelectedCases = SelectedCases [CaseId] deriving stock (Eq, Show, Generic)++-- | Construct an identifier using ASCII letters, digits, dots, dashes and underscores.+mkCaseId :: Text -> Either ConfigError CaseId+mkCaseId t = CaseId <$> identifier "case.id" t++-- | Construct a reusable command identifier.+mkCommandId :: Text -> Either ConfigError CommandId+mkCommandId t = CommandId <$> identifier "command.id" t++-- | Shared ASCII identity check used by the public smart constructors.+identifier :: Text -> Text -> Either ConfigError Text+identifier f t+  | Text.null t || not (Text.all (\c -> isAscii c && (isAlphaNum c || c `elem` ("-_." :: String))) t) =+      Left (ConfigError f "expected a nonempty portable identifier")+  | otherwise = Right t++-- | Reject traversal, drive syntax, backslashes, empty segments and NULs.+-- Filesystem checks must additionally reject symlinks when opening the target.+mkRelativeTarget :: FilePath -> Either ConfigError RelativeTarget+mkRelativeTarget p+  | null p || any (`elem` p) ['\\', ':', '\0'] || any (`elem` ["", ".", ".."]) parts =+      Left (ConfigError "path" "expected a normalized relative path")+  | otherwise = Right (RelativeTarget p)+  where+    parts = Text.splitOn "/" (Text.pack p)++-- | Budget in microseconds; at most one day for a single stage.+mkStageBudget :: Int -> Either ConfigError StageBudget+mkStageBudget n+  | n > 0 && toInteger n <= 86400000000 = Right (StageBudget n)+  | otherwise = Left (ConfigError "budget" "expected positive microseconds, at most one day")++-- | Reject empty or duplicate selections rather than silently changing their meaning.+mkSelectedCases :: [CaseId] -> Either ConfigError SelectedCases+mkSelectedCases ids+  | null ids = Left (ConfigError "selection" "no cases selected")+  | Set.size (Set.fromList ids) /= length ids = Left (ConfigError "selection" "duplicate case IDs")+  | otherwise = Right (SelectedCases ids)++-- | Underlying portable ID.+caseIdText :: CaseId -> Text+caseIdText (CaseId t) = t++-- | Underlying command ID.+commandIdText :: CommandId -> Text+commandIdText (CommandId t) = t++-- | Path suitable for joining to a verified workspace root.+targetPath :: RelativeTarget -> FilePath+targetPath (RelativeTarget p) = p++-- | Timer argument in microseconds.+budgetMicroseconds :: StageBudget -> Int+budgetMicroseconds (StageBudget n) = n++-- | Requested case order.+selectedCaseIds :: SelectedCases -> [CaseId]+selectedCaseIds (SelectedCases ids) = ids++-- | Independently supervised execution phases.+data Stage = Setup | Build | Test | Replay | Teardown+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++-- | Exit, signal and runner intervention are deliberately distinct.+data ProcessOutcome = Exited !Int | Signaled !Int | Timeout | Cancelled | LaunchFailed !Text+  deriving stock (Eq, Show, Generic)++-- | Output retention policy. Exceeding the limit cannot prove a diagnostic.+data OutputPolicy = OutputPolicy {limitBytes :: !Int}+  deriving stock (Eq, Show, Generic)++-- | An executable and arguments, with no implicit shell expansion.+data Command = Command+  { executable :: !FilePath,+    arguments :: ![String],+    sensitiveArguments :: !(Set Int),+    workingDirectory :: !(Maybe RelativeTarget),+    environment :: ![(String, String)],+    budget :: !StageBudget,+    output :: !OutputPolicy+  }+  deriving stock (Eq, Show, Generic)++-- | Checked text, patch, or registered Haskell edit. Patches are UTF-8 content.+data Edit+  = Replace !RelativeTarget !Text !Text !Int+  | Create !RelativeTarget !Text+  | Delete !RelativeTarget !Text+  | Patch !Text+  | CustomEdit !Text+  deriving stock (Eq, Show, Generic)++-- | A replay token and its exact generator configuration.+data ReplayEvidence = ReplayEvidence+  {token :: !Text, generator :: !Text, failures :: !(Set Text)}+  deriving stock (Eq, Show, Generic)++-- | Whether matching counterexample replay is mandatory.+data ReplayPolicy = NoReplay | RequireReplay+  deriving stock (Eq, Show, Generic)++-- | Expectations declare their evidence strength rather than guessing from output.+data Expectation+  = NamedTests !(Set Text) !ReplayPolicy+  | Diagnostic !Stage !Int !Text+  | BuildRejection !Int !Text+  | CrashProbe !Int !Text+  | Control+  deriving stock (Eq, Show, Generic)++-- | Declared framework-neutral evidence transport.+data EvidenceMode = GenericCommands | StructuredTestsV1+  deriving stock (Eq, Show, Generic)++-- | Portable definition, including its declared evidence transport.+data CaseDefinition = CaseDefinition+  { caseId :: !CaseId,+    description :: !Text,+    tags :: !(Set Text),+    edits :: ![Edit],+    build :: !CommandId,+    test :: !(Maybe CommandId),+    selector :: !(Maybe Text),+    expectation :: !Expectation,+    evidenceMode :: !EvidenceMode+  }+  deriving stock (Eq, Show, Generic)++-- | Operations supplied by the workspace owner. Hooks are trusted IO, not a sandbox.+data WorkspaceOps = WorkspaceOps+  { readTarget :: !(RelativeTarget -> IO Text),+    writeTarget :: !(RelativeTarget -> Text -> IO ()),+    createTarget :: !(RelativeTarget -> Text -> IO ()),+    deleteTarget :: !(RelativeTarget -> Text -> IO ())+  }++-- | Haskell-only extension points. Returned evidence still passes all validity gates.+data CaseHooks = CaseHooks+  { customEdits :: ![(Text, WorkspaceOps -> IO ())],+    customEvidence :: !(Maybe (StageEvidence -> IO (Either Text StageEvidence))),+    replayBuilder :: !(Maybe (ReplayEvidence -> Command -> IO (Either Text Command)))+  }++-- | No custom behavior.+noHooks :: CaseHooks+noHooks = CaseHooks [] Nothing Nothing++-- | Counts are optional: unknown is different from zero. IDs identify attribution.+data SelectionEvidence = SelectionEvidence+  { discovered :: !(Maybe Int),+    selected :: !(Maybe Int),+    executed :: !(Maybe Int),+    executedIds :: !(Set Text),+    failedIds :: !(Set Text),+    skippedIds :: !(Set Text),+    setupErrors :: ![Text]+  }+  deriving stock (Eq, Show, Generic)++-- | Generic commands do not invent test counts.+unknownSelection :: SelectionEvidence+unknownSelection = SelectionEvidence Nothing Nothing Nothing Set.empty Set.empty Set.empty []++-- | Observations from a single stage. A truncated output cannot be a witness.+data StageEvidence = StageEvidence+  { stage :: !Stage,+    outcome :: !ProcessOutcome,+    transcript :: !Text,+    truncated :: !Bool,+    selection :: !SelectionEvidence,+    replay :: !(Maybe ReplayEvidence),+    durationSeconds :: !Double,+    logPaths :: ![FilePath]+  }+  deriving stock (Eq, Show, Generic)++-- | Whether edit preconditions and the final byte-change requirement passed.+data MutationEvidence = Changed | Unchanged | EditRejected !Text+  deriving stock (Eq, Show, Generic)++-- | A baseline failure stops the candidate; it is never a mutant kill.+data BaselineEvidence = BaselinePassed | BaselineFailed !Text | BaselineUnavailable+  deriving stock (Eq, Show, Generic)++-- | All validity gates and primary/secondary observations used by the classifier.+data CaseEvidence = CaseEvidence+  { baseline :: !BaselineEvidence,+    mutation :: !MutationEvidence,+    stages :: ![StageEvidence],+    infrastructureErrors :: ![Text],+    cleanupErrors :: ![Text],+    stopped :: !(Maybe Text)+  }+  deriving stock (Eq, Show, Generic)++-- | All nine public case outcomes.+data Verdict+  = Caught+  | Survived+  | InvalidMutation+  | Uncovered+  | UnexpectedBuildFailure+  | WrongFailure+  | TimedOut+  | InfrastructureError+  | NotRun+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++-- | Successful detections and passing controls are tallied separately.+data DetectionKind = SemanticNamed | GenericDiagnostic | ExpectedBuildRejection | ExplicitCrash | ControlPassed+  deriving stock (Eq, Ord, Show, Generic)++-- | Classifier output, retaining every observation and secondary error.+data CaseResult = CaseResult+  { caseId :: !CaseId,+    verdict :: !Verdict,+    detection :: !(Maybe DetectionKind),+    semantic :: !Bool,+    control :: !Bool,+    reason :: !Text,+    evidence :: !CaseEvidence+  }+  deriving stock (Eq, Show, Generic)++-- | Campaign failures keep affected IDs even when no candidate could start.+data RunFailure+  = BaselineFailure ![CaseId] !Text+  | ControlFailure !CaseId !Text+  | RunInfrastructure !Text+  | Interrupted !Text+  deriving stock (Eq, Show, Generic)++-- | CLI-neutral run contract. Infrastructure takes precedence over a failed contract.+data RunStatus = RunPassed | RunFailed | RunUntrustworthy | RunInterrupted+  deriving stock (Eq, Show, Generic)++-- | Only caught and survived semantic mutations contribute to the ratio.+data SemanticScore = SemanticScore {caught :: !Int, total :: !Int, complete :: !Bool}+  deriving stock (Eq, Show, Generic)++-- | Exactly one result per selected ID, in selection order.+data RunResult = RunResult+  { selectedIds :: ![CaseId],+    results :: ![CaseResult],+    failures :: ![RunFailure],+    status :: !RunStatus,+    score :: !(Maybe SemanticScore)+  }+  deriving stock (Eq, Show, Generic)
+ src/Test/Heni/Internal/Prelude.hs view
@@ -0,0 +1,6 @@+-- | Small internal convenience imports. Never adds label instances.+module Test.Heni.Internal.Prelude (Text, Set, Generic) where++import Data.Set (Set)+import Data.Text (Text)+import GHC.Generics (Generic)
+ src/Test/Heni/Internal/Process/Posix.hs view
@@ -0,0 +1,78 @@+-- | Narrow POSIX group termination. Detached descendants require a sandbox.+module Test.Heni.Internal.Process.Posix (stopGroup, exitOutcome) where++import Control.Concurrent (threadDelay)+import Control.Exception+import Data.Text (Text)+import Data.Text qualified as Text+import GHC.Clock (getMonotonicTimeNSec)+import System.Exit (ExitCode (..))+import System.IO.Error (isDoesNotExistError)+import System.Posix.Signals+import System.Process (Pid, ProcessHandle, getProcessExitCode)+import Test.Heni.Command (ProcessOutcome (..))++exitOutcome :: ExitCode -> ProcessOutcome+exitOutcome ExitSuccess = Exited 0+exitOutcome (ExitFailure n) | n < 0 = Signaled (negate n)+exitOutcome (ExitFailure n) = Exited n++-- | Signal only while the owned group remains observable. Once absence has+-- been observed, never send another signal to that transient numeric identity.+-- Reap the direct child independently; all waits have explicit bounds.+stopGroup :: Int -> Maybe Pid -> ProcessHandle -> IO [Text]+stopGroup grace pid process = do+  initial <- groupState+  termErrors <- case initial of Right False -> pure []; _ -> send sigTERM+  deadline <- (+ fromIntegral grace * 1000) <$> getMonotonicTimeNSec+  remaining <- case initial of+    Right False -> pure (Right False)+    _ -> waitGroup deadline+  killErrors <- case remaining of Right False -> pure []; _ -> send sigKILL+  reapDeadline <- (+ fromIntegral grace * 1000) <$> getMonotonicTimeNSec+  final <- case remaining of Right False -> pure (Right False); _ -> waitGroup reapDeadline+  reaped <- reap reapDeadline+  -- Darwin can temporarily return EPERM for a group whose members are exiting.+  -- Only proven disappearance clears these provisional observation/send errors.+  let shutdownErrors = case final of+        Right False -> []+        _ -> either pure (const []) initial ++ termErrors ++ either pure (const []) final ++ killErrors+  pure (shutdownErrors ++ ["direct child could not be reaped within shutdown budget" | not reaped])+  where+    send signal = case pid of+      Nothing -> pure ["missing owned process group identity"]+      Just group -> do+        result <- try (signalProcessGroup signal group)+        case result of+          Right () -> pure []+          Left e | isDoesNotExistError e -> pure []+          Left (e :: IOException) -> do+            -- A disappearing group can make a redundant signal fail. Accept+            -- only definite absence, never a permission/observation failure.+            after <- groupState+            pure $ case after of+              Right False -> []+              _ -> ["sending signal " <> Text.pack (show signal) <> " to owned group: " <> Text.pack (displayException e)]+    groupState = case pid of+      Nothing -> pure (Left "missing owned process group identity")+      Just group -> do+        result <- try (signalProcessGroup nullSignal group) :: IO (Either IOException ())+        pure $ case result of+          Right () -> Right True+          Left e | isDoesNotExistError e -> Right False+          Left e -> Left ("probing owned process group: " <> Text.pack (displayException e))+    waitGroup deadline = do+      _ <- getProcessExitCode process+      state <- groupState+      now <- getMonotonicTimeNSec+      case state of+        Right False -> pure state+        _ | now >= deadline -> pure state+        _ -> threadDelay 5000 >> waitGroup deadline+    reap deadline = do+      result <- getProcessExitCode process+      now <- getMonotonicTimeNSec+      case result of+        Just _ -> pure True+        Nothing | now >= deadline -> pure False+        Nothing -> threadDelay 5000 >> reap deadline
+ src/Test/Heni/Manifest.hs view
@@ -0,0 +1,237 @@+-- | Strict version-one portable manifests and isolated applicability checks.+module Test.Heni.Manifest+  ( Manifest,+    decodeManifest,+    readManifest,+    manifestSourceRoot,+    manifestPolicy,+    manifestCampaign,+    manifestRunOptions,+    caseDefinitionJSON,+    Applicability (..),+    checkManifest,+  )+where++import Control.Exception+import Control.Monad (forM, forM_, unless, when)+import Data.Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.Aeson.Types (JSONPathElement (..), Parser, parseEither)+import Data.ByteString qualified as BS+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import System.Directory qualified as Dir+import System.FilePath (isAbsolute, takeDirectory, (</>))+import Test.Heni.Case+import Test.Heni.Command+import Test.Heni.Edit+import Test.Heni.Expectation+import Test.Heni.Internal.Codec+import Test.Heni.Internal.Files (withOwnedDirectory)+import Test.Heni.Process+import Test.Heni.Resource qualified as Resource+import Test.Heni.Runner+import Test.Heni.Workspace++-- | Source roots are relative to the manifest file until readManifest resolves them.+data Manifest = Manifest !FilePath !SnapshotPolicy !Campaign !Execution++data Execution = Execution ![Resource.ResourceProvider] !(Map.Map CaseId [Text]) !Int !ReusePolicy++-- | Declared source root, resolved by readManifest.+manifestSourceRoot :: Manifest -> FilePath+manifestSourceRoot (Manifest root _ _ _) = root++-- | Explicit source inclusion and exclusion choices.+manifestPolicy :: Manifest -> SnapshotPolicy+manifestPolicy (Manifest _ policy _ _) = policy++-- | The same validated campaign used by Haskell callers.+manifestCampaign :: Manifest -> Campaign+manifestCampaign (Manifest _ _ campaign _) = campaign++-- | Lower portable execution/resource options into the shared library runner.+manifestRunOptions :: FilePath -> Manifest -> RunOptions+manifestRunOptions output (Manifest root policy _ (Execution providers claims jobs reuse)) =+  (defaultRunOptions root output) {sourcePolicy = policy, workers = jobs, resources = providers, resourceClaims = claims, reusePolicy = reuse}++-- | Explicit portable case representation, shared with report metadata.+caseDefinitionJSON :: CaseDefinition -> Value+caseDefinitionJSON = caseJSON++-- | Decode at most 4MiB, reject duplicates/unknown keys and validate all references.+decodeManifest :: BS.ByteString -> Either ConfigError Manifest+decodeManifest bytes+  | BS.length bytes > 4194304 = Left (ConfigError "manifest" "manifest exceeds 4MiB")+  | otherwise = either (Left . ConfigError "manifest" . Text.pack) Right $ decodeJSON bytes >>= parseEither parseManifest++parseManifest :: Value -> Parser Manifest+parseManifest = strictObject "manifest" ["schemaVersion", "sourceRoot", "sourcePolicy", "commands", "cases", "resources", "execution"] $ \o -> do+  version <- o .: "schemaVersion"+  unless (version == (1 :: Int)) (fail "unsupported schemaVersion")+  root <- defaultField o "sourceRoot" "."+  when (null root || isAbsolute root || '\0' `elem` root) (fail "sourceRoot must be a nonempty relative path")+  policy <- case KM.lookup "sourcePolicy" o of Nothing -> pure defaultSnapshotPolicy; Just value -> parsePolicy value+  commandValues <- o .: "commands" :: Parser Object+  commands <- forM (KM.toList commandValues) $ \(key, value) -> do+    ident <- checked (mkCommandId (Key.toText key))+    command <- parsePortableCommand value <?> Key key+    pure (ident, command)+  caseValues <- o .: "cases"+  definitions <- forM (zip [0 ..] caseValues) $ \(index, value) -> parsePortableCase value <?> Index index+  campaign <- checked (mkCampaign (Map.fromList commands) [(definition, noHooks) | definition <- definitions])+  providers <- defaultField o "resources" [] >>= mapM (parseResource (Map.fromList commands))+  checked (Resource.validateResources providers)+  claims <- forM (zip definitions caseValues) $ \(CaseDefinition {caseId}, value) ->+    withObject+      "case resource claims"+      ( \c -> do+          names <- defaultField c "resources" []+          unless (length names == Set.size (Set.fromList names) && all (`elem` map Resource.resourceName providers) names) (fail "unknown or duplicate resource claim")+          pure (caseId, names)+      )+      value+  (jobs, reuse) <- case KM.lookup "execution" o of Nothing -> pure (1, Fresh); Just value -> parseExecution value+  pure (Manifest root policy campaign (Execution providers (Map.fromList claims) jobs reuse))++parsePolicy :: Value -> Parser SnapshotPolicy+parsePolicy = strictObject "sourcePolicy" ["includes", "excludes"] $ \o ->+  SnapshotPolicy <$> (defaultField o "includes" [] >>= mapM (checked . mkRelativeTarget)) <*> (defaultField o "excludes" [] >>= mapM (checked . mkRelativeTarget))++defaultField :: (FromJSON a) => Object -> Key -> a -> Parser a+defaultField o key fallback = maybe (pure fallback) parseJSON (KM.lookup key o)++parsePortableCommand :: Value -> Parser Command+parsePortableCommand = strictObject "command" ["executable", "arguments", "sensitiveArguments", "workingDirectory", "environment", "timeoutSeconds", "outputLimitBytes"] $ \o -> do+  executable <- o .: "executable"+  arguments <- defaultField o "arguments" []+  sensitive <- defaultField o "sensitiveArguments" [] >>= uniqueSet+  working <- o .:? "workingDirectory" >>= traverse (checked . mkRelativeTarget)+  environmentValues <- defaultField o "environment" KM.empty+  environment <- forM (KM.toList environmentValues) $ \(key, value) -> (,) (Key.toString key) <$> parseJSON value+  seconds <- defaultField o "timeoutSeconds" (30 :: Double)+  unless (seconds > 0 && seconds <= 86400 && not (isNaN seconds || isInfinite seconds)) (fail "timeoutSeconds must be positive and finite, at most 86400")+  budget <- checked (mkStageBudget (ceiling (seconds * 1000000)))+  output <- OutputPolicy <$> defaultField o "outputLimitBytes" 1048576+  let command = Command executable arguments sensitive working environment budget output+  checked (validateCommand command)+  pure command++parsePortableCase :: Value -> Parser CaseDefinition+parsePortableCase value = do+  -- Defaults never turn an explicit null into a missing non-nullable field.+  withObject+    "case"+    ( \o -> do+        forM_ ["tags", "edits"] $ \key -> when (KM.lookup key o == Just Null) (fail (Key.toString key ++ " cannot be null"))+        edits <- o .:? "edits" .!= []+        forM_ edits $ withObject "edit" $ \edit -> when (KM.lookup "occurrences" edit == Just Null) (fail "occurrences cannot be null")+    )+    value+  withObject+    "case"+    ( \o -> case KM.lookup "expect" o of+        Just (Object expected) | KM.lookup "replayRequired" expected == Just Null -> fail "replayRequired cannot be null"+        _ -> pure ()+    )+    value+  stripped <- withObject "case" (pure . Object . KM.delete "resources") value+  definition@CaseDefinition {edits, expectation, evidenceMode} <- parseCase stripped+  forM_ edits $ \case CustomEdit {} -> fail "custom Haskell hooks cannot be serialized in manifests"; _ -> pure ()+  case expectation of+    NamedTests {} | evidenceMode /= StructuredTestsV1 -> fail "named-tests requires adapter: heni-v1"+    NamedTests {} -> pure definition+    CrashProbe {} -> pure definition+    _ -> pure definition++-- | Read with source paths anchored to the manifest location, not the caller's cwd.+readManifest :: FilePath -> IO (Either ConfigError Manifest)+readManifest path = do+  result <- try $ do+    absolute <- Dir.makeAbsolute path+    bytes <- BS.readFile absolute+    case decodeManifest bytes of+      Left e -> pure (Left e)+      Right (Manifest root policy campaign execution) -> do+        source <- Dir.canonicalizePath (takeDirectory absolute </> root)+        pure (Right (Manifest source policy campaign execution))+  pure $ either (Left . ConfigError "manifest" . Text.pack . show) id (result :: Either IOException (Either ConfigError Manifest))++-- | Static validation plus checked edit applicability; no test discovery or baseline.+data Applicability = Applicability {caseId :: !CaseId, applicable :: !Bool, diagnostics :: ![Text]}+  deriving stock (Eq, Show)++-- | Check available commands and edits in fresh disposable candidates. Git is+-- optional unless a selected definition contains a patch, and uses runCommand.+checkManifest :: Manifest -> IO (Either ConfigError [Applicability])+checkManifest (Manifest root policy campaign _) =+  catches+    (Right <$> action)+    [ Handler (\(e :: IOException) -> pure (Left (ConfigError "check" (Text.pack (show e))))),+      Handler (\(e :: WorkspaceError) -> pure (Left (ConfigError "source" (Text.pack (show e)))))+    ]+  where+    action = do+      availability <- mapM validateExecutable (Map.elems (campaignCommands campaign))+      let commandErrors = [Text.pack (show e) | Left e <- availability]+      withOwnedDirectory ".heni-check-" $ \logs -> withSnapshot root policy $ \snapshot ->+        forM (campaignCases campaign) $ \definition@CaseDefinition {caseId} -> do+          result <- withEditedWorkspace (gitRunner logs caseId) noHooks snapshot definition (\_ _ -> pure ())+          let editErrors = either (pure . Text.pack . show) (const []) result+              diagnostics = commandErrors ++ editErrors+          pure (Applicability caseId (null diagnostics) diagnostics)+    gitRunner logs caseId workspace args = do+      let prefix = Text.unpack (caseIdText caseId) ++ if "--check" `elem` args then "-check" else "-apply"+          budget = either (error . show) id (mkStageBudget 5000000)+          command = Command "git" args Set.empty Nothing [] budget (OutputPolicy 65536)+      ProcessReport {evidence = StageEvidence {outcome}, errors} <- runCommand (defaultProcessOptions logs prefix) workspace Setup command `catch` \ProcessAborted {cause} -> throwIO cause+      unless (null errors) (throwIO (EditInfrastructure (Text.intercalate "; " errors)))+      case outcome of+        Exited 0 -> pure (Right ())+        Exited _ -> pure (Left "Git rejected patch applicability")+        _ -> throwIO (EditInfrastructure (Text.pack (show outcome)))++parseExecution :: Value -> Parser (Int, ReusePolicy)+parseExecution = strictObject "execution" ["workers", "baselineReuse"] $ \o -> do+  jobs <- defaultField o "workers" 1+  unless (jobs > 0 && jobs <= 256) (fail "workers must be between 1 and 256")+  reuse <- case KM.lookup "baselineReuse" o of+    Nothing -> pure Fresh+    Just Null -> pure Fresh+    Just value ->+      strictObject+        "baselineReuse"+        ["toolchain", "adapter"]+        ( \r -> do+            toolchain <- r .: "toolchain"+            adapter <- r .:? "adapter"+            unless (not (Text.null (Text.strip toolchain)) && maybe True (not . Text.null . Text.strip) adapter) (fail "empty reuse identity")+            pure (ReuseBaselines toolchain adapter)+        )+        value+  pure (jobs, reuse)++parseResource :: Map.Map CommandId Command -> Value -> Parser Resource.ResourceProvider+parseResource commands = strictObject "resource" ["name", "scope", "capacity", "identity", "version", "epoch", "environment", "setup", "reset", "teardown"] $ \o -> do+  name <- o .: "name"+  scope <-+    defaultField o "scope" ("case" :: Text) >>= \case+      "campaign" -> pure Resource.CampaignScope+      "worker" -> pure Resource.WorkerScope+      "case" -> pure Resource.CaseScope+      _ -> fail "unknown resource scope"+  capacity <- defaultField o "capacity" 1+  identity <- o .: "identity"+  version <- o .: "version"+  epoch <- o .:? "epoch"+  env <- defaultField o "environment" KM.empty >>= mapM (\(key, value) -> (Key.toString key,) <$> parseJSON value) . KM.toList+  let binding = Resource.ResourceBinding identity version epoch env+  _ <- checked (Resource.bindingEnvironment [(name, binding)])+  let referenced key = do+        ident <- o .: key >>= checked . mkCommandId+        maybe (fail ("unknown resource command: " ++ Key.toString key)) pure (Map.lookup ident commands)+  Resource.commandResource name scope capacity binding <$> referenced "setup" <*> referenced "reset" <*> referenced "teardown"
+ src/Test/Heni/Process.hs view
@@ -0,0 +1,224 @@+-- | Bounded commands, owned POSIX groups and independent stdout/stderr evidence.+module Test.Heni.Process+  ( Stream (..),+    LogArtifact (..),+    ProcessOptions (..),+    defaultProcessOptions,+    ProcessReport (..),+    ProcessAborted (..),+    runCommand,+    validateExecutable,+  )+where++import Control.Concurrent+import Control.Exception hiding (handle)+import Control.Monad (unless, when)+import Data.ByteString qualified as BS+import Data.IORef+import Data.List (isPrefixOf, nub)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import GHC.Clock (getMonotonicTimeNSec)+import System.Directory qualified as Dir+import System.Environment (getEnvironment)+import System.FilePath (isPathSeparator, (</>))+import System.IO+import System.Process qualified as P+import System.Timeout (timeout)+import Test.Heni.Case (ConfigError (..), mkRelativeTarget, targetPath)+import Test.Heni.Command+import Test.Heni.Expectation+import Test.Heni.Internal.Process.Posix+import Test.Heni.Workspace++-- | Streams remain separate; a witness cannot span stdout and stderr.+data Stream = Stdout | Stderr deriving stock (Eq, Show)++-- | Bounded retained bytes and total byte count; paths are portable basenames.+data LogArtifact = LogArtifact+  {stream :: !Stream, path :: !FilePath, bytesSeen :: !Integer, bytesRetained :: !Int, truncated :: !Bool}+  deriving stock (Eq, Show)++-- | Observation callbacks may fail but cannot alter process outcomes.+data ProcessOptions = ProcessOptions+  { logDirectory :: !FilePath,+    logPrefix :: !String,+    graceBudget :: !StageBudget,+    witnesses :: ![Text],+    environmentRemovals :: ![String],+    observer :: !(Stream -> BS.ByteString -> IO ())+  }++-- | A 200ms grace period and no diagnostic matching; specify an owned log directory.+defaultProcessOptions :: FilePath -> String -> ProcessOptions+defaultProcessOptions directory prefix = ProcessOptions directory prefix grace [] [] (\_ _ -> pure ())+  where+    grace = either (error . show) id (mkStageBudget 200000)++-- | Primary stage facts plus bounded log metadata and secondary shutdown errors.+data ProcessReport = ProcessReport+  {evidence :: !StageEvidence, logs :: ![LogArtifact], preservedWitnesses :: ![Text], errors :: ![Text]}+  deriving stock (Eq, Show)++-- | Cancellation retains final process evidence. Campaigns finalize their ledger,+-- then rethrow cause to honor the original asynchronous exception.+data ProcessAborted = ProcessAborted {report :: !ProcessReport, cause :: !SomeException}+  deriving stock (Show)+  deriving anyclass (Exception)++-- | Resolve host tools before execution. Explicit ./workspace programs are+-- validated structurally here and resolved after the build, inside its workspace.+-- Launch races are still handled in IO.+validateExecutable :: Command -> IO (Either ConfigError ())+validateExecutable command@Command {executable} = case validateCommand command of+  Left e -> pure (Left e)+  Right () | "./" `isPrefixOf` executable -> pure (Right ())+  Right () -> do+    found <- Dir.findExecutable executable+    pure $ maybe (Left (ConfigError "executable" "executable is unavailable")) (const (Right ())) found++data Capture = Capture !Integer !BS.ByteString !BS.ByteString ![Text]++-- | Run literal executable/argv in an exclusive workspace. Logs are bounded per+-- stream. Exact requested witnesses are scanned through all bytes and retained+-- separately, so log truncation alone cannot erase a proved witness.+runCommand :: ProcessOptions -> Workspace -> Stage -> Command -> IO ProcessReport+runCommand ProcessOptions {logDirectory, logPrefix, graceBudget, witnesses, environmentRemovals, observer} workspace stage command@Command {executable, arguments, workingDirectory, environment, budget, output = OutputPolicy {limitBytes}} = do+  started <- getMonotonicTimeNSec+  let failed why = pure (ProcessReport (StageEvidence stage (LaunchFailed why) "" False unknownSelection Nothing 0 []) [] [] [why])+  case validateCommand command of+    Left e -> failed (Text.pack (show e))+    Right () | null logPrefix || any isPathSeparator logPrefix || logPrefix `elem` [".", ".."] -> failed "invalid log prefix"+    Right () | sum (map (BS.length . Text.encodeUtf8) witnesses) > 65536 || any Text.null witnesses -> failed "diagnostic witnesses must be nonempty and total at most 64KiB"+    Right () -> do+      launch <- try $ do+        cwd <- maybe (pure (workspaceRoot workspace)) (checkedWorkspacePath workspace) workingDirectory `catch` workspaceLaunchError+        isDirectory <- Dir.doesDirectoryExist cwd+        unless isDirectory (ioError (userError "working directory does not exist"))+        when ("./" `isPrefixOf` executable) $ do+          let relative = maybe "" ((++ "/") . targetPath) workingDirectory ++ drop 2 executable+          target <- either (ioError . userError . show) pure (mkRelativeTarget relative)+          _ <- checkedWorkspacePath workspace target `catch` workspaceLaunchError+          pure ()+        inherited <- getEnvironment+        let overrides = Map.fromList environment+            effective = Map.toList (overrides `Map.union` foldr Map.delete (Map.fromList inherited) environmentRemovals)+        Dir.createDirectoryIfMissing True logDirectory+        withBinaryFile (logDirectory </> logPrefix ++ ".stdout.log") WriteMode $ \outLog ->+          withBinaryFile (logDirectory </> logPrefix ++ ".stderr.log") WriteMode $ \errLog ->+            supervise started cwd effective outLog errLog+      case launch of+        Left (e :: IOException) -> failed (Text.pack (show e))+        Right result -> pure result+  where+    workspaceLaunchError (e :: WorkspaceError) = ioError (userError (show e))+    supervise started cwd effective outLog errLog = mask $ \restore -> do+      -- Dependency-owned record update is isolated to the process adapter; no+      -- label instances propagate through the public library.+      let spec =+            (P.proc executable arguments)+              { P.cwd = Just cwd,+                P.env = Just effective,+                P.std_in = P.NoStream,+                P.std_out = P.CreatePipe,+                P.std_err = P.CreatePipe,+                P.create_group = True,+                P.close_fds = True+              }+      (_, maybeOut, maybeErr, process) <- P.createProcess spec+      pid <- P.getPid process+      case (maybeOut, maybeErr) of+        (Just stdoutHandle, Just stderrHandle) -> do+          outRef <- newIORef (Capture 0 BS.empty BS.empty [])+          errRef <- newIORef (Capture 0 BS.empty BS.empty [])+          outDone <- newEmptyMVar+          errDone <- newEmptyMVar+          outThread <- forkFinally (restore (drain Stdout stdoutHandle outLog outRef)) (putMVar outDone)+          errThread <- forkFinally (restore (drain Stderr stderrHandle errLog errRef)) (putMVar errDone)+          let poll = do+                readerFailure <- firstFailure [outDone, errDone]+                case readerFailure of+                  Just e -> throwIO e+                  Nothing -> do+                    exit <- P.getProcessExitCode process+                    now <- getMonotonicTimeNSec+                    case exit of+                      Just code -> pure (exitOutcome code)+                      Nothing | now - started >= fromIntegral (budgetMicroseconds budget) * 1000 -> pure Timeout+                      Nothing -> threadDelay 5000 >> poll+          primary <- try (restore poll) :: IO (Either SomeException ProcessOutcome)+          deferred <- newIORef Nothing+          let defer :: forall b. IO b -> IO b+              defer action =+                action `catch` \(e :: SomeAsyncException) -> do+                  writeIORef deferred (Just (toException e))+                  defer action+          -- Cleanup runs masked and has no blocking operation without a bound.+          shutdown <- uninterruptibleMask_ (stopGroup (budgetMicroseconds graceBudget) pid process)+          let finish tid handle done = do+                joined <- timeout (budgetMicroseconds graceBudget) (readMVar done)+                case joined of+                  Nothing -> do+                    _ <- timeout (budgetMicroseconds graceBudget) (killThread tid)+                    _ <- timeout (budgetMicroseconds graceBudget) (readMVar done)+                    closeQuietly handle+                    pure ["output reader did not finish within shutdown budget"]+                  Just (Left e) -> closeQuietly handle >> pure [Text.pack (displayException e)]+                  Just (Right ()) -> closeQuietly handle >> pure []+          outErrors <- defer (finish outThread stdoutHandle outDone)+          errErrors <- defer (finish errThread stderrHandle errDone)+          flushResult <- defer (try (hFlush outLog >> hFlush errLog)) :: IO (Either IOException ())+          secondaryCancellation <- readIORef deferred+          outCapture <- readIORef outRef+          errCapture <- readIORef errRef+          ended <- getMonotonicTimeNSec+          let found = nub (captureWitnesses outCapture ++ captureWitnesses errCapture)+              -- Only exact byte-matched witnesses enter classifier text. Lenient+              -- display decoding of malformed UTF-8 must never invent a match.+              outputText = Text.intercalate "\n" found+              secondary = shutdown ++ outErrors ++ errErrors ++ either (\e -> [Text.pack (show e)]) (const []) flushResult+              primaryErrors = either (\e -> case fromException e :: Maybe SomeAsyncException of Just _ -> []; Nothing -> [Text.pack (displayException e)]) (const []) primary+              outcome = either (const Cancelled) id primary+              unavailable = not (null (primaryErrors ++ secondary))+              stageEvidence = StageEvidence stage outcome outputText unavailable unknownSelection Nothing (fromIntegral (ended - started) / 1e9) [logPrefix ++ ".stdout.log", logPrefix ++ ".stderr.log"]+              result = ProcessReport stageEvidence [artifact Stdout outCapture, artifact Stderr errCapture] found (primaryErrors ++ secondary)+          case primary of+            Left e | Just (_ :: SomeAsyncException) <- fromException e -> throwIO (ProcessAborted result e)+            _ -> case secondaryCancellation of+              Just e -> throwIO (ProcessAborted result e)+              Nothing -> pure result+        _ -> do+          _ <- stopGroup (budgetMicroseconds graceBudget) pid process+          ioError (userError "process library did not supply requested output pipes")+    captureWitnesses (Capture _ _ _ found) = found+    captureTruncated (Capture count b _ _) = count > fromIntegral (BS.length b)+    artifact stream capture@(Capture count b _ _) = LogArtifact stream (logPrefix ++ suffix stream) count (BS.length b) (captureTruncated capture)+    suffix Stdout = ".stdout.log"+    suffix Stderr = ".stderr.log"+    drain stream input logHandle ref = do+      chunk <- BS.hGetSome input 32768+      unless (BS.null chunk) $ do+        Capture count kept tailBytes found <- readIORef ref+        let retain = BS.take (max 0 (limitBytes - BS.length kept)) chunk+            haystack = tailBytes <> chunk+            newFound = nub (found ++ [w | w <- witnesses, Text.encodeUtf8 w `BS.isInfixOf` haystack])+            overlap = maximum (0 : map ((subtract 1) . BS.length . Text.encodeUtf8) witnesses)+            nextTail = BS.drop (max 0 (BS.length haystack - overlap)) haystack+        BS.hPut logHandle retain+        writeIORef ref (Capture (count + fromIntegral (BS.length chunk)) (kept <> retain) nextTail newFound)+        observer stream chunk+        drain stream input logHandle ref++firstFailure :: [MVar (Either SomeException ())] -> IO (Maybe SomeException)+firstFailure [] = pure Nothing+firstFailure (done : rest) = do+  result <- tryReadMVar done+  case result of Just (Left e) -> pure (Just e); _ -> firstFailure rest++closeQuietly :: Handle -> IO ()+closeQuietly handle = do+  _ <- try (hClose handle) :: IO (Either IOException ())+  pure ()
+ src/Test/Heni/Replay.hs view
@@ -0,0 +1,19 @@+-- | Opaque replay tokens belong to the producing framework, not a universal DSL.+module Test.Heni.Replay (replayCommand) where++import Data.Text qualified as Text+import Test.Heni.Command+import Test.Heni.Expectation++-- | Reuse the candidate test command with the exact token and generator contract.+-- A typed CaseHooks replayBuilder may replace this for another framework.+replayCommand :: ReplayEvidence -> Command -> Command+replayCommand ReplayEvidence {token, generator} (Command exe args sensitive working env budget output) =+  Command+    exe+    args+    sensitive+    working+    (("HENI_REPLAY_TOKEN", Text.unpack token) : ("HENI_REPLAY_GENERATOR", Text.unpack generator) : filter (\(key, _) -> key `notElem` ["HENI_REPLAY_TOKEN", "HENI_REPLAY_GENERATOR"]) env)+    budget+    output
+ src/Test/Heni/Report.hs view
@@ -0,0 +1,187 @@+-- | Versioned portable reports and read-only journal recovery.+module Test.Heni.Report+  ( StoredReport (..),+    ReportError (..),+    reportJSON,+    readReport,+    recoverReport,+    writeReport,+    appendJournal,+    runResultJSON,+  )+where++import Control.Exception+import Control.Monad (forM_, unless)+import Data.Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.Aeson.Types (Parser, parseEither)+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as LBS+import Data.Foldable (toList)+import Data.Text (Text)+import Data.Text qualified as Text+import System.Directory qualified as Dir+import System.FilePath (takeDirectory, (</>))+import System.IO+import Test.Heni.Case (mkRelativeTarget, mkSelectedCases)+import Test.Heni.Command (ProcessOutcome (..))+import Test.Heni.Expectation (StageEvidence (..))+import Test.Heni.Internal.Codec+import Test.Heni.Internal.Files (WorkspaceError, checkedPath)+import Test.Heni.Result+import Prelude hiding (sequence)++-- | Version-one run-result payload used by nonterminal renderers.+runResultJSON :: RunResult -> Value+runResultJSON = resultJSON++-- | Effective, redacted configuration and the authoritative classified result.+data StoredReport = StoredReport {metadata :: !Value, result :: !RunResult}+  deriving stock (Eq, Show)++-- | IO, schema, ledger, journal and evidence-containment failures.+newtype ReportError = ReportError Text+  deriving stock (Eq, Show)+  deriving anyclass (Exception)++-- | Stable v1 envelope, with codecs independent of Haskell constructor names.+reportJSON :: StoredReport -> Value+reportJSON StoredReport {metadata, result} =+  object+    ["schemaVersion" .= (1 :: Int), "toolVersion" .= String "0.1.0.0", "metadata" .= metadata, "result" .= resultJSON result]++parseReport :: Value -> Parser StoredReport+parseReport = strictObject "report" ["schemaVersion", "toolVersion", "metadata", "result"] $ \o -> do+  version <- o .: "schemaVersion"+  unless (version == (1 :: Int)) (fail "unsupported report schemaVersion")+  (_ :: Text) <- o .: "toolVersion"+  StoredReport <$> o .: "metadata" <*> (o .: "result" >>= parseResult)++-- | Atomic replacement within an exclusively allocated run directory. This+-- protects readers from half-written JSON, not from arbitrary storage failure.+writeReport :: FilePath -> StoredReport -> IO ()+writeReport directory stored = mask $ \restore -> do+  (temporary, file) <- openBinaryTempFile directory ".report-"+  let remove = do+        _ <- try (hClose file) :: IO (Either IOException ())+        exists <- Dir.doesFileExist temporary+        if exists then Dir.removeFile temporary else pure ()+  (restore (LBS.hPut file (encode (reportJSON stored))) >> hClose file >> Dir.renameFile temporary (directory </> "report.json")) `onException` remove++-- | Append a complete ledger snapshot after each observation. Sequence numbers+-- make missing, duplicate and reordered records detectable during recovery.+appendJournal :: FilePath -> Int -> Text -> StoredReport -> IO ()+appendJournal directory sequence event stored =+  withBinaryFile (directory </> "journal.jsonl") AppendMode $ \file -> do+    LBS.hPut file (encode (object ["sequence" .= sequence, "event" .= event, "report" .= reportJSON stored]))+    BS.hPut file "\n"+    hFlush file++-- | Read and validate summary accounting and every referenced log's containment+-- and existence. Nothing executes while reading evidence.+readReport :: FilePath -> IO (Either ReportError StoredReport)+readReport path = handleErrors $ do+  bytes <- BS.readFile path+  value <- either (throwIO . ReportError . Text.pack) pure (decodeJSON bytes)+  stored <- either (throwIO . ReportError . Text.pack) pure (parseEither parseReport value)+  validateArtifacts (takeDirectory path) stored+  pure stored++validateArtifacts :: FilePath -> StoredReport -> IO ()+validateArtifacts root StoredReport {metadata, result = RunResult {results}} = do+  -- Reuse points at the original producer's actual baseline, never a synthetic+  -- fresh attempt. Secrets used in equality checks deliberately are not stored.+  case metadata of+    Object o -> case KM.lookup "caseDetails" o of+      Just (Object cases) -> forM_ (KM.toList cases) $ \(current, details) -> case details of+        Object fields -> case KM.lookup "baseline-reuse" fields of+          Just (Object reuse) | KM.lookup "reused" reuse == Just (Bool True) -> do+            owner <- case KM.lookup "ownerCase" reuse of Just (String name) -> pure (Key.fromText name); _ -> throwIO (ReportError "reused baseline has no owner")+            unless (owner /= current) (throwIO (ReportError "reused baseline points to itself"))+            observed <- case KM.lookup owner cases of+              Just (Object ownerDetails) -> case KM.lookup "baseline-build" ownerDetails of+                Just (Object observation) -> case KM.lookup "stage" observation of Just stage -> pure stage; _ -> throwIO (ReportError "baseline owner has no build evidence")+                _ -> throwIO (ReportError "baseline owner has no original baseline")+              _ -> throwIO (ReportError "unknown baseline owner")+            StageEvidence {outcome} <- either (throwIO . ReportError . Text.pack) pure (parseEither parseStage observed)+            unless (outcome == Exited 0) (throwIO (ReportError "reused baseline owner did not build successfully"))+          _ -> pure ()+        _ -> pure ()+      _ -> pure ()+    _ -> pure ()+  let caseStages = concat [stages | CaseResult {evidence = CaseEvidence {stages}} <- results]+      metadataStages = case metadata of+        Object o -> case KM.lookup "caseDetails" o of+          Just (Object cases) -> [value | Object details <- KM.elems cases, Object observation <- KM.elems details, Just value <- [KM.lookup "stage" observation]]+          _ -> []+        _ -> []+      resourceStages = case metadata of+        Object o -> case KM.lookup "resources" o of+          Just (Array events) -> [stage | Object event <- toList events, Just stage <- [KM.lookup "stage" event]]+          _ -> []+        _ -> []+  additionalStages <- mapM (either (throwIO . ReportError . Text.pack) pure . parseEither parseStage) (metadataStages ++ resourceStages)+  forM_ (caseStages ++ additionalStages) $ \StageEvidence {logPaths} -> forM_ logPaths $ \relative -> do+    target <- either (throwIO . ReportError . Text.pack . show) pure (mkRelativeTarget relative)+    path <- checkedPath root target+    exists <- Dir.doesFileExist path+    unless exists (throwIO (ReportError ("missing evidence artifact: " <> Text.pack relative)))++-- | Recover the last complete journal snapshot, ignoring only a truncated final+-- non-newline record. Interior corruption, sequence gaps and changed ledgers+-- are errors. The recovered run is explicitly untrustworthy and never resumed.+recoverReport :: FilePath -> IO (Either ReportError StoredReport)+recoverReport directory = handleErrors $ do+  bytes <- BS.readFile (directory </> "journal.jsonl")+  let lines' = BS.split 10 bytes+      complete = if BS.null bytes || BS.last bytes == 10 then init lines' else lines'+  records <- mapM decodeRecord (zip [0 :: Int ..] complete)+  usable <- case reverse records of+    [] -> throwIO (ReportError "empty journal")+    Right stored : _ -> pure stored+    Left why : rest | not (BS.null bytes) && BS.last bytes /= 10 && "end-of-input" `Text.isInfixOf` why -> case rest of+      Right stored : _ -> pure stored+      _ -> throwIO (ReportError "no complete journal record")+    Left why : _ -> throwIO (ReportError why)+  let interior = if not (BS.null bytes) && BS.last bytes /= 10 then init records else records+  forM_ interior (either (throwIO . ReportError) (const (pure ())))+  let ledgers = [ids | Right StoredReport {result = RunResult {selectedIds = ids}} <- records]+  case ledgers of+    first : rest -> unless (all (== first) rest) (throwIO (ReportError "journal selected ledger changed"))+    [] -> throwIO (ReportError "journal has no ledger")+  let StoredReport metadata RunResult {selectedIds, results, failures} = usable+  selected <- either (throwIO . ReportError . Text.pack . show) pure (mkSelectedCases selectedIds)+  recovered <- either (throwIO . ReportError . Text.pack . show) pure (summarizeRun selected results (failures ++ [RunInfrastructure "recovered journal; execution was not resumed"]))+  let stored = StoredReport metadata recovered+  validateArtifacts directory stored+  pure stored+  where+    decodeRecord (expected, line) = case decodeJSON line of+      Left why -> pure (Left (Text.pack why))+      Right value -> do+        record <-+          either+            (throwIO . ReportError . Text.pack)+            pure+            ( parseEither+                ( strictObject "journal record" ["sequence", "event", "report"] $ \o -> do+                    sequence <- o .: "sequence"+                    unless (sequence == expected) (fail "journal sequence gap or duplicate")+                    event <- o .: "event"+                    unless ((event :: Text) `elem` ["selected", "snapshot", "stage", "case", "finalized"]) (fail "unknown journal event")+                    o .: "report" >>= parseReport+                )+                value+            )+        pure (Right record)++handleErrors :: forall a. IO a -> IO (Either ReportError a)+handleErrors action =+  catches+    (Right <$> action)+    [ Handler (pure . Left :: ReportError -> IO (Either ReportError a)),+      Handler (\(e :: WorkspaceError) -> pure (Left (ReportError (Text.pack (show e))))),+      Handler (\(e :: IOException) -> pure (Left (ReportError (Text.pack (show e)))))+    ]
+ src/Test/Heni/Resource.hs view
@@ -0,0 +1,172 @@+{-# LANGUAGE RankNTypes #-}++-- | Consumer-owned resource lifetimes. Trusted callbacks must implement their+-- own external reset semantics; workspace disposal cannot roll back a service.+module Test.Heni.Resource+  ( ResourceScope (..),+    ResourceBinding (..),+    ResourceContext (..),+    ResourceEvent (..),+    ResourceProvider,+    resourceName,+    resourceScope,+    resourceCapacity,+    resourceCommands,+    ResourceLease,+    resourceBinding,+    prepareResource,+    managedResource,+    commandResource,+    withResources,+    validateResources,+    ResourcePhase (..),+    ResourceError (..),+    bindingEnvironment,+  )+where++import Control.Exception+import Control.Monad (unless)+import Data.Char (isAsciiUpper)+import Data.IORef+import Data.List (nub)+import Data.Text (Text)+import Data.Text qualified as Text+import Test.Heni.Case (ConfigError (..))+import Test.Heni.Command+import Test.Heni.Expectation+import Test.Heni.Internal.Files (withFinalizer)+import Test.Heni.Process+import Test.Heni.Workspace++-- | One acquisition for the campaign, each worker, or each claimed case.+data ResourceScope = CampaignScope | WorkerScope | CaseScope deriving stock (Eq, Show)++-- | Nonsecret identities/versions are reportable. Unknown epoch disables reuse;+-- environment values remain in memory and are redacted from portable reports.+data ResourceBinding = ResourceBinding+  {identity :: !Text, version :: !Text, epoch :: !(Maybe Text), environment :: ![(String, String)]}+  deriving stock (Eq, Show)++-- | Owned context for resource hooks and their supervised command artifacts.+data ResourceContext = ResourceContext+  { instanceId :: !Text,+    snapshot :: !SourceSnapshot,+    artifactDirectory :: !FilePath,+    observeResource :: !(ResourceEvent -> IO ())+  }++-- | Facts about completed ownership transitions, with no environment values.+data ResourceEvent = ResourceEvent !Text !Text !ResourcePhase !ResourceBinding | ResourceCommandFinished !Text !Text !ResourcePhase !ProcessReport+  deriving stock (Eq, Show)++-- | Lifecycle failures differ from test assertions.+data ResourcePhase = AcquireResource | ResetResource | ReleaseResource deriving stock (Eq, Show)++-- | Resource name, failed lifecycle phase and original exception.+data ResourceError = ResourceError !Text !ResourcePhase !SomeException+  deriving stock (Show)+  deriving anyclass (Exception)++-- | Opaque lease; only its owner can update the prepared state.+data ResourceLease = ResourceLease !(IO ResourceBinding) !(IO ResourceBinding)++-- | A scoped CPS provider keeps arbitrary consumer values out of JSON/core types.+data ResourceProvider = ResourceProvider+  { -- | Uppercase identity used by claims and environment namespaces.+    resourceName :: !Text,+    -- | Ownership lifetime; each instance is acquired and released once.+    resourceScope :: !ResourceScope,+    -- | Maximum concurrent claims, also bounding worker-scoped acquisition.+    resourceCapacity :: !Int,+    -- | Portable lifecycle commands for preflight; arbitrary providers use [].+    resourceCommands :: ![Command],+    provide :: forall a. ResourceContext -> (ResourceLease -> IO a) -> IO a+  }++-- | Current identity without changing the external fixture.+resourceBinding :: ResourceLease -> IO ResourceBinding+resourceBinding (ResourceLease current _) = current++-- | Reset before both baseline and candidate; a failure invalidates the case.+prepareResource :: ResourceLease -> IO ResourceBinding+prepareResource (ResourceLease _ reset) = reset++-- | Acquire in declaration order and release in reverse order, including partial+-- acquisition, callback failure and cancellation. Cleanup failures retain the+-- primary exception via 'WorkspaceFinalizationError'. No timeout guarantee is+-- possible for arbitrary uninterruptible consumer foreign calls.+managedResource :: Text -> ResourceScope -> Int -> (ResourceContext -> IO (value, ResourceBinding)) -> (value -> IO ResourceBinding) -> (value -> IO ()) -> ResourceProvider+managedResource name scope capacity acquire reset release = ResourceProvider name scope capacity [] $ \context@ResourceContext {instanceId, observeResource} action -> mask $ \restore -> do+  (value, initial) <- phase AcquireResource (restore (acquire context))+  current <- newIORef initial+  let announce p b = observeResource (ResourceEvent name instanceId p b)+      check b = either (throwIO . userError . show) pure (validateBinding name b)+      resetLease = mask $ \unmask -> do+        next <- phase ResetResource (unmask (reset value))+        writeIORef current next+        check next+        announce ResetResource next+        pure next+      dispose = do+        latest <- readIORef current+        phase ReleaseResource (release value)+        announce ReleaseResource latest+  (check initial >> announce AcquireResource initial >> restore (action (ResourceLease (readIORef current) resetLease))) `withFinalizer` dispose+  where+    phase :: ResourcePhase -> IO b -> IO b+    phase step work =+      work `catch` \(e :: SomeException) -> case fromException e :: Maybe SomeAsyncException of+        Just _ -> throwIO e+        Nothing -> throwIO (ResourceError name step e)++-- | Portable supervised setup/reset/teardown. Each provider instance owns a+-- private workspace. Commands have independent declared budgets and logs.+commandResource :: Text -> ResourceScope -> Int -> ResourceBinding -> Command -> Command -> Command -> ResourceProvider+commandResource name scope capacity binding setup reset teardown = ResourceProvider name scope capacity [setup, reset, teardown] $ \context@ResourceContext {snapshot, artifactDirectory, instanceId, observeResource} action ->+  withWorkspace snapshot $ \workspace -> do+    counter <- newIORef (0 :: Int)+    let run phase command = do+          index <- atomicModifyIORef' counter (\n -> (n + 1, n))+          let prefix = "resource-" ++ Text.unpack name ++ "-" ++ Text.unpack instanceId ++ "-" ++ show index+          report@ProcessReport {evidence = StageEvidence {outcome}, errors} <-+            runCommand (defaultProcessOptions artifactDirectory prefix) workspace (if phase == ReleaseResource then Teardown else Setup) (addEnvironment command)+              `catch` \ProcessAborted {report, cause} -> throwIO cause `withFinalizer` observeResource (ResourceCommandFinished name instanceId phase report)+          observeResource (ResourceCommandFinished name instanceId phase report)+          unless (outcome == Exited 0 && null errors) (ioError (userError ("resource command " ++ show phase ++ ": " ++ show outcome ++ " " ++ show errors)))+        addEnvironment (Command e a s w env b o) = Command e a s w (environment ++ filter ((`notElem` map fst environment) . fst) env) b o+        ResourceBinding {environment} = binding+        provider = managedResource name scope capacity (\_ -> run AcquireResource setup >> pure ((), binding)) (\() -> run ResetResource reset >> pure binding) (\() -> run ReleaseResource teardown)+    provide provider context action++-- | Compose ownership; the callback receives named opaque leases.+withResources :: [ResourceProvider] -> ResourceContext -> ([(Text, ResourceLease)] -> IO a) -> IO a+withResources [] _ action = action []+withResources (provider : rest) context action = provide provider context $ \lease ->+  withResources rest context (action . ((resourceName provider, lease) :))++-- | Validate names, capacities and namespaces before any acquisition.+validateResources :: [ResourceProvider] -> Either ConfigError ()+validateResources providers = do+  let names = map resourceName providers+  unless (length names == length (nub names)) (Left (ConfigError "resources" "duplicate resource name"))+  mapM_+    ( \ResourceProvider {resourceName, resourceCapacity} -> do+        unless (validName resourceName) (Left (ConfigError "resources.name" "use uppercase ASCII names with digits/underscores"))+        unless (resourceCapacity > 0) (Left (ConfigError "resources.capacity" "capacity must be positive"))+    )+    providers++validName :: Text -> Bool+validName name = case Text.uncons name of Nothing -> False; Just (first, rest) -> isAsciiUpper first && Text.all (\c -> isAsciiUpper c || (c >= '0' && c <= '9') || c == '_') rest++validateBinding :: Text -> ResourceBinding -> Either ConfigError ()+validateBinding name ResourceBinding {identity, version, epoch, environment} = do+  unless (not (Text.null identity || Text.null version) && maybe True (not . Text.null) epoch) (Left (ConfigError "resource.binding" "identity/version/known epoch must be nonempty"))+  let keys = map fst environment+      prefix = "HENI_RESOURCE_" <> name <> "_"+  unless (length keys == length (nub keys) && all (\key -> prefix `Text.isPrefixOf` Text.pack key && not (any (`elem` ['=', '\0']) key)) keys && all (notElem '\0' . snd) environment) (Left (ConfigError "resource.environment" "duplicate or unnamespaced environment binding"))++-- | Combine bindings only after validation; resource namespaces prevent clashes.+bindingEnvironment :: [(Text, ResourceBinding)] -> Either ConfigError [(String, String)]+bindingEnvironment bindings = mapM_ (uncurry validateBinding) bindings >> pure (concatMap (\(_, ResourceBinding {environment}) -> environment) bindings)
+ src/Test/Heni/Result.hs view
@@ -0,0 +1,143 @@+-- | Pure evidence classification and exactly-once campaign accounting.+module Test.Heni.Result+  ( MutationEvidence (..),+    BaselineEvidence (..),+    CaseEvidence (..),+    Verdict (..),+    DetectionKind (..),+    CaseResult (..),+    RunFailure (..),+    RunStatus (..),+    SemanticScore (..),+    RunResult (..),+    classifyCase,+    summarizeRun,+    validateStageEvidence,+  )+where++import Data.List (find, nub)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as Text+import Test.Heni.Case (validateCase)+import Test.Heni.Internal.Model++-- | Classify observations without running IO. Validity gates precede all witnesses.+classifyCase :: CaseDefinition -> CaseEvidence -> CaseResult+classifyCase definition@CaseDefinition {caseId, expectation, evidenceMode} evidence@CaseEvidence {baseline, mutation, stages, infrastructureErrors, cleanupErrors, stopped}+  | Left err <- validateCase definition = result InfrastructureError Nothing (Text.pack (show err))+  | not (null infrastructureErrors && null cleanupErrors) = result InfrastructureError Nothing "infrastructure or cleanup failed"+  | any stageInfrastructure stages = result InfrastructureError Nothing "launch, setup, output, or evidence protocol failed"+  | length stages /= Set.size (Set.fromList [s | StageEvidence {stage = s} <- stages]) = result InfrastructureError Nothing "duplicate stage evidence"+  | Just why <- stopped = result NotRun Nothing why+  | baseline /= BaselinePassed = result NotRun Nothing "unchanged baseline did not pass"+  | EditRejected why <- mutation = result InvalidMutation Nothing why+  | expectation /= Control && mutation /= Changed = result InvalidMutation Nothing "mutation did not change bytes"+  | expectation == Control && mutation /= Unchanged = result InvalidMutation Nothing "control changed bytes"+  | any (\StageEvidence {stage, outcome} -> outcome == Timeout && (stage /= Replay || not requiresReplay)) stages = result TimedOut Nothing "stage exceeded wall-clock budget"+  | any (\StageEvidence {outcome} -> outcome == Cancelled) stages = result NotRun Nothing "stage cancelled"+  | otherwise = classifyExpectation expectation+  where+    requiresReplay = case expectation of NamedTests _ RequireReplay -> True; _ -> False+    semanticCase = case expectation of+      NamedTests {} -> True+      Diagnostic Test _ _ -> True+      _ -> False+    result v d r = CaseResult caseId v d semanticCase (expectation == Control) r evidence+    at s = find (\StageEvidence {stage} -> stage == s) stages+    passed s = case at s of Just StageEvidence {outcome = Exited 0} -> True; _ -> False+    buildGate next+      | at Build == Nothing = result InfrastructureError Nothing "missing build evidence"+      | not (passed Build) = result UnexpectedBuildFailure Nothing "semantic candidate did not build"+      | otherwise = next+    missing = result InfrastructureError Nothing "missing required stage evidence"+    selectionGate next = case at Test of+      Just StageEvidence {selection = SelectionEvidence {selected, executed}} | evidenceMode == StructuredTestsV1 && (maybe True (<= 0) selected || maybe True (<= 0) executed) -> result Uncovered Nothing "structured test selection did not execute"+      _ -> next+    wrong = result WrongFailure Nothing "observed failure did not match the declared expectation"+    survived = result Survived Nothing "candidate passed without the required detection"+    caught kind = result Caught (Just kind) "declared failure and witness matched"+    diagnostic s code witness kind = case at s of+      Nothing -> missing+      Just StageEvidence {outcome, transcript}+        | outcome == Exited code && witness `Text.isInfixOf` transcript -> caught kind+        | outcome == Exited 0 -> survived+        | otherwise -> wrong+    classifyExpectation (Diagnostic Build code witness) = diagnostic Build code witness ExpectedBuildRejection+    classifyExpectation (Diagnostic s code witness) = buildGate (selectionGate (diagnostic s code witness GenericDiagnostic))+    classifyExpectation (BuildRejection code witness) = diagnostic Build code witness ExpectedBuildRejection+    classifyExpectation (CrashProbe sig probe) = buildGate $ case at Test of+      Nothing -> missing+      Just StageEvidence {outcome, transcript}+        | outcome == Signaled sig && probe `Text.isInfixOf` transcript -> caught ExplicitCrash+        | outcome == Exited 0 -> survived+        | otherwise -> wrong+    classifyExpectation Control = buildGate $ selectionGate $ case at Test of+      Nothing -> missing+      Just StageEvidence {outcome = Exited 0} -> result Caught (Just ControlPassed) "unchanged control passed"+      _ -> wrong+    classifyExpectation (NamedTests expected policy) = buildGate $ case at Test of+      Nothing -> missing+      Just original@StageEvidence {outcome, selection = SelectionEvidence {selected, executed, executedIds, failedIds, skippedIds}}+        | maybe True (<= 0) selected || maybe True (<= 0) executed || not (expected `Set.isSubsetOf` executedIds) || not (Set.null (Set.intersection expected skippedIds)) -> result Uncovered Nothing "intended tests did not execute"+        | outcome == Exited 0 && Set.null failedIds -> survived+        | outcome == Exited 0 || outcome /= Exited 1 || failedIds /= expected -> wrong+        | policy == RequireReplay && not (reproduced expected original) -> result WrongFailure Nothing "required replay did not reproduce identical failure and generator identity"+        | otherwise -> caught SemanticNamed+    reproduced expected StageEvidence {replay = Just ReplayEvidence {token = t, generator = g, failures = fs}} =+      not (Text.null t || Text.null g) && fs == expected && case at Replay of+        Just StageEvidence {outcome = Exited 1, selection = SelectionEvidence {executed, executedIds, failedIds, skippedIds}, replay = Just ReplayEvidence {token = rt, generator = rg, failures = rfs}} ->+          maybe False (> 0) executed && expected `Set.isSubsetOf` executedIds && Set.null (Set.intersection expected skippedIds) && failedIds == expected && rfs == expected && rt == t && rg == g+        _ -> False+    reproduced _ _ = False++stageInfrastructure :: StageEvidence -> Bool+stageInfrastructure StageEvidence {stage, outcome, truncated, selection = SelectionEvidence {discovered, selected, executed, executedIds, failedIds, skippedIds, setupErrors}} =+  launchFailure || hookFailure || truncated || not (null setupErrors) || invalidCounts || invalidIds+  where+    launchFailure = case outcome of LaunchFailed {} -> True; _ -> False+    hookFailure = stage `elem` [Setup, Teardown] && outcome `notElem` [Exited 0, Timeout, Cancelled]+    invalidCounts =+      any (maybe False (< 0)) [discovered, selected, executed]+        || exceeds selected discovered+        || exceeds executed selected+        || maybe False (< Set.size executedIds) executed+    exceeds (Just a) (Just b) = a > b+    exceeds _ _ = False+    invalidIds = not (failedIds `Set.isSubsetOf` executedIds) || not (Set.null (Set.intersection executedIds skippedIds))++-- | Shared protocol/validity check for unchanged baselines and candidates.+validateStageEvidence :: StageEvidence -> Either Text.Text ()+validateStageEvidence evidence+  | stageInfrastructure evidence = Left "launch, setup, output, or selection evidence is invalid"+  | otherwise = Right ()++-- | Reject duplicate/foreign results, fill missing results with 'NotRun', and+-- recompute totals from the selected ledger. Input ordering cannot inflate scores.+summarizeRun :: SelectedCases -> [CaseResult] -> [RunFailure] -> Either ConfigError RunResult+summarizeRun (SelectedCases ids) supplied suppliedFailures+  | length supplied /= Map.size byId = bad "duplicate case result"+  | any (`notElem` ids) (Map.keys byId) = bad "result for unselected case"+  | any (`notElem` ids) affected = bad "run failure refers to unselected case"+  | otherwise = Right (RunResult ids ordered allFailures runStatus semanticScore)+  where+    bad = Left . ConfigError "results"+    byId = Map.fromList [(i, r) | r@CaseResult {caseId = i} <- supplied]+    missing i = CaseResult i NotRun Nothing False False "selected case did not complete" (CaseEvidence BaselineUnavailable Unchanged [] [] [] (Just "not executed"))+    ordered = [Map.findWithDefault (missing i) i byId | i <- ids]+    controlFailures = [ControlFailure i why | CaseResult {caseId = i, verdict, reason = why, control = True} <- ordered, verdict /= Caught]+    baselineFailures = [BaselineFailure [i] why | CaseResult {caseId = i, evidence = CaseEvidence {baseline = BaselineFailed why}} <- ordered]+    allFailures = nub (suppliedFailures ++ controlFailures ++ baselineFailures)+    affected = concatMap (\case BaselineFailure xs _ -> xs; ControlFailure i _ -> [i]; _ -> []) suppliedFailures+    hasInfra = any (\CaseResult {verdict} -> verdict == InfrastructureError) ordered || any (\case RunInfrastructure {} -> True; _ -> False) allFailures+    interrupted = any (\case Interrupted {} -> True; _ -> False) allFailures+    runStatus+      | hasInfra = RunUntrustworthy+      | interrupted = RunInterrupted+      | not (null allFailures) || any (\CaseResult {verdict} -> verdict /= Caught) ordered = RunFailed+      | otherwise = RunPassed+    numerator = length [() | CaseResult {semantic = True, verdict = Caught} <- ordered]+    denominator = numerator + length [() | CaseResult {semantic = True, verdict = Survived} <- ordered]+    isComplete = null allFailures && all (\CaseResult {verdict} -> verdict `elem` [Caught, Survived]) ordered+    semanticScore = if denominator == 0 then Nothing else Just (SemanticScore numerator denominator isComplete)
+ src/Test/Heni/Runner.hs view
@@ -0,0 +1,615 @@+-- | One baseline/candidate lifecycle shared by library users and the CLI.+module Test.Heni.Runner+  ( Campaign,+    mkCampaign,+    campaignCases,+    campaignCommands,+    RunOptions (..),+    ReusePolicy (..),+    defaultRunOptions,+    RunEvent (..),+    ConfigurationFailure (..),+    runCampaign,+  )+where++import Control.Concurrent.MVar+import Control.Exception hiding (handle)+import Control.Monad (forM_, unless, when)+import Data.Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString qualified as BS+import Data.Foldable (toList)+import Data.IORef+import Data.List (sort)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Version (showVersion)+import System.Directory qualified as Dir+import System.Environment (getEnvironment)+import System.FilePath (takeDirectory)+import System.Info qualified as Platform+import Test.Heni.Adapter qualified as Adapter+import Test.Heni.BuildReuse qualified as Build+import Test.Heni.Case+import Test.Heni.Command+import Test.Heni.Crash qualified as Crash+import Test.Heni.Edit+import Test.Heni.Expectation+import Test.Heni.Internal.Baseline qualified as Baseline+import Test.Heni.Internal.Codec+import Test.Heni.Internal.Files (digest, withFinalizer)+import Test.Heni.Process+import Test.Heni.Replay qualified as Replay+import Test.Heni.Report+import Test.Heni.Resource qualified as Resource+import Test.Heni.Result+import Test.Heni.Scheduler qualified as Scheduler+import Test.Heni.Workspace+import Prelude hiding (sequence)++-- | Validated references and portable definitions, with optional trusted IO hooks.+data Campaign = Campaign !(Map.Map CommandId Command) ![(CaseDefinition, CaseHooks)]++-- | Construction rejects all case/command definition errors before execution.+mkCampaign :: Map.Map CommandId Command -> [(CaseDefinition, CaseHooks)] -> Either ConfigError Campaign+mkCampaign commands definitions = do+  _ <- mkSelectedCases [i | (CaseDefinition {caseId = i}, _) <- definitions]+  mapM_ validateCommand (Map.elems commands)+  forM_ definitions $ \(definition@CaseDefinition {caseId, build, test, edits}, CaseHooks {customEdits}) -> do+    validateCase definition+    unless+      (all (`Map.member` commands) (build : maybe [] pure test))+      (Left (ConfigError (caseIdText caseId <> ".commands") "unknown command reference"))+    unless+      (length customEdits == Set.size (Set.fromList (map fst customEdits)))+      (Left (ConfigError (caseIdText caseId <> ".hooks") "duplicate custom edit hook"))+    forM_ edits $ \case+      CustomEdit key | key `notElem` map fst customEdits -> Left (ConfigError (caseIdText caseId <> ".edits") "missing custom edit hook")+      _ -> Right ()+  pure (Campaign commands definitions)++-- | Portable definitions in author order.+campaignCases :: Campaign -> [CaseDefinition]+campaignCases (Campaign _ definitions) = map fst definitions++-- | Named reusable commands.+campaignCommands :: Campaign -> Map.Map CommandId Command+campaignCommands (Campaign commands _) = commands++-- | Observer notifications do not own verdicts.+data RunEvent = CaseStarted !CaseId | StageFinished !CaseId !Text !StageEvidence | CaseFinished !CaseResult+  deriving stock (Eq, Show)++-- | Fresh is the reference mode. Opt-in sharing requires a nonsecret toolchain+-- contract; structured suites also require their exact producer identity. The+-- consumer asserts that external inputs and test behavior do not depend on+-- per-attempt identity fields. Unknown external state disables sharing.+data ReusePolicy = Fresh | ReuseBaselines !Text !(Maybe Text)+  deriving stock (Eq, Show)++data BaselineKey = BaselineKey !Text !Command !(Maybe Command) !(Maybe Text) !Expectation !EvidenceMode ![(String, String)] ![(Text, Resource.ResourceBinding)] !ReusePolicy+  deriving stock (Eq)++data BaselineEntry = BaselineEntry !CaseId !(Maybe Text)++-- | Explicit source/output roots and optional selected ledger. No mandatory output.+data RunOptions = RunOptions+  { sourceRoot :: !FilePath,+    outputDirectory :: !FilePath,+    sourcePolicy :: !SnapshotPolicy,+    selectedCases :: !(Maybe SelectedCases),+    progress :: !(RunEvent -> IO ()),+    workspaceCleanup :: !(Workspace -> IO ()),+    workers :: !Int,+    resources :: ![Resource.ResourceProvider],+    resourceClaims :: !(Map.Map CaseId [Text]),+    reusePolicy :: !ReusePolicy,+    buildReuse :: !(Maybe Build.BuildReuse)+  }++-- | Fresh serial workspaces, whole working-tree inputs, all cases, silent observer.+defaultRunOptions :: FilePath -> FilePath -> RunOptions+defaultRunOptions source output = RunOptions source output defaultSnapshotPolicy Nothing (const (pure ())) (const (pure ())) 1 [] Map.empty Fresh Nothing++-- | Invalid configurations are separate from executed mutation results.+newtype ConfigurationFailure = ConfigurationFailure ConfigError+  deriving stock (Show)+  deriving anyclass (Exception)++-- | Execute selected cases. Runtime failures return structured results. On+-- asynchronous cancellation, finalize the partial report and rethrow the cause.+runCampaign :: RunOptions -> Campaign -> IO RunResult+runCampaign RunOptions {sourceRoot, outputDirectory, sourcePolicy, selectedCases, progress, workspaceCleanup, workers, resources, resourceClaims, reusePolicy, buildReuse} campaign@(Campaign commands definitions) = mask $ \restore -> do+  either (throwIO . ConfigurationFailure) pure (Resource.validateResources resources)+  unless (workers > 0 && workers <= 256) (throwIO (ConfigurationFailure (ConfigError "workers" "must be between 1 and 256")))+  forM_ (Map.toList resourceClaims) $ \(cid, claims) -> do+    unless+      (cid `elem` [caseId | (CaseDefinition {caseId}, _) <- definitions] && all (`elem` map Resource.resourceName resources) claims && length claims == Set.size (Set.fromList claims))+      (throwIO (ConfigurationFailure (ConfigError "resources.claims" "unknown case/resource or duplicate claim")))+  forM_ buildReuse (either (throwIO . ConfigurationFailure) pure . Build.validateBuildReuse)+  case reusePolicy of+    ReuseBaselines contract adapter ->+      unless+        (not (Text.null (Text.strip contract)) && maybe True (not . Text.null . Text.strip) adapter)+        (throwIO (ConfigurationFailure (ConfigError "reuse" "toolchain/known adapter contracts must be nonempty")))+    Fresh -> pure ()+  selected <- either (throwIO . ConfigurationFailure) pure $ maybe (mkSelectedCases [i | (CaseDefinition {caseId = i}, _) <- definitions]) Right selectedCases+  let ids = selectedCaseIds selected+  unless+    (all (`elem` [i | (CaseDefinition {caseId = i}, _) <- definitions]) ids)+    (throwIO (ConfigurationFailure (ConfigError "selection" "unknown case ID")))+  validateOutputLocation sourceRoot sourcePolicy outputDirectory `catch` \(e :: WorkspaceError) ->+    throwIO (ConfigurationFailure (ConfigError "output" (Text.pack (show e))))+  existing <- Dir.doesPathExist outputDirectory+  when existing (throwIO (ConfigurationFailure (ConfigError "output" "run directory already exists")))+  -- Executable availability is validated for selected commands only.+  let chosen = [(d, h) | i <- ids, (d@CaseDefinition {caseId = j}, h) <- definitions, i == j]+      commandIds = Set.fromList [c | (CaseDefinition {build, test}, _) <- chosen, c <- build : maybe [] pure test]+  let activeNames = concat [Map.findWithDefault [] cid resourceClaims | cid <- ids]+      activeResources = filter ((`elem` activeNames) . Resource.resourceName) resources+      effectiveWorkers = minimum (workers : [Resource.resourceCapacity p | p <- activeResources, Resource.resourceScope p == Resource.WorkerScope])+  forM_ (Set.toList commandIds) $ \key -> validateExecutable (commands Map.! key) >>= either (throwIO . ConfigurationFailure) pure+  forM_ (concatMap Resource.resourceCommands activeResources) $ \command -> validateExecutable command >>= either (throwIO . ConfigurationFailure) pure+  resultsRef <- newIORef Map.empty+  failuresRef <- newIORef []+  contextRef <-+    newIORef+      ( case initialMetadata campaign chosen of+          Object o ->+            Object+              ( KM.insert+                  "execution"+                  ( object+                      [ "workers" .= effectiveWorkers,+                        "requestedWorkers" .= workers,+                        "baselineReuse" .= reusePolicyJSON reusePolicy,+                        "buildReuseContract" .= fmap (\Build.BuildReuse {contractIdentity} -> contractIdentity) buildReuse,+                        "resourceDefinitions" .= [object ["name" .= Resource.resourceName p, "scope" .= resourceScopeText (Resource.resourceScope p), "capacity" .= Resource.resourceCapacity p, "commands" .= map redactedCommand (Resource.resourceCommands p)] | p <- resources],+                        "resourceClaims" .= object [Key.fromText (caseIdText cid) .= claims | (cid, claims) <- Map.toAscList resourceClaims]+                      ]+                  )+                  o+              )+          other -> other+      )+  sequenceRef <- newIORef 0+  runIdentity <- newIORef ""+  journalLock <- newMVar ()+  observerLock <- newMVar ()+  workerBindings <- newMVar Map.empty+  baselineCache <- Baseline.newBaselineCache+  let summary = do+        results <- Map.elems <$> readIORef resultsRef+        failures <- readIORef failuresRef+        either (throwIO . ConfigurationFailure) pure (summarizeRun selected results failures)+      notifyProgress event = withMVar observerLock (\_ -> progress event)+      stored = StoredReport <$> readIORef contextRef <*> summary+      journal event = withMVar journalLock $ \_ -> do+        value <- stored+        sequence <- readIORef sequenceRef+        appendJournal outputDirectory sequence event value+        writeIORef sequenceRef (sequence + 1)+      addCaseDetails caseId key value = atomicModify_ contextRef $ \context -> case context of+        Object metadata -> Object (mapToKeyMap metadata caseId key value)+        _ -> context+      runOne snapshot leases retainedWorker (definition@CaseDefinition {caseId, build, test, expectation, evidenceMode}, hooks) = do+        baselineRef <- newIORef BaselineUnavailable+        mutationRef <- newIORef Unchanged+        stagesRef <- newIORef []+        errorsRef <- newIORef []+        cleanupRef <- newIORef []+        producerIdentity <- newIORef Nothing+        environmentRef <- newIORef []+        actualBaselineRef <- newIORef Nothing+        let currentEvidence stopped = CaseEvidence <$> readIORef baselineRef <*> readIORef mutationRef <*> readIORef stagesRef <*> readIORef errorsRef <*> readIORef cleanupRef <*> pure stopped+            cleanup workspace =+              workspaceCleanup workspace `catch` \(e :: SomeException) -> do+                modifyIORef' cleanupRef (++ [Text.pack (displayException e)])+                throwIO e+            updatePartial = do+              observations <- currentEvidence (Just "case execution is incomplete")+              atomicModify_ resultsRef (Map.insert caseId (classifyCase definition observations))+            runStage role workspace stage command = do+              identity <- readIORef runIdentity+              attempt <-+                if stage `elem` [Test, Replay] && (evidenceMode == StructuredTestsV1 || isCrash expectation)+                  then Just <$> Adapter.newEvidenceAttempt outputDirectory identity caseId stage role+                  else pure Nothing+              additions <- readIORef environmentRef+              let prefix = Text.unpack (caseIdText caseId <> "-" <> role <> "-" <> stageName stage)+                  wanted = witnessesFor expectation stage+                  effectiveCommand = withEnvironment (additions ++ maybe [] Adapter.attemptEnvironment attempt) (withSelector definition command)+                  options = case defaultProcessOptions outputDirectory prefix of+                    ProcessOptions d p g _ _ observer -> ProcessOptions d p g wanted reservedEnvironment observer+                  record processReport@ProcessReport {evidence, errors, logs, preservedWitnesses} = do+                    normalized <- normalizeAttempt producerIdentity definition attempt evidence+                    enhanced <- if evidenceMode == StructuredTestsV1 then observeStructured hooks normalized else enhance hooks normalized+                    when (role == "candidate") $ do+                      modifyIORef' stagesRef (++ [enhanced])+                    let invalidEvidence = either pure (const []) (validateStageEvidence enhanced)+                    modifyIORef' errorsRef (++ (errors ++ invalidEvidence))+                    addCaseDetails+                      caseId+                      (role <> "-" <> stageName stage)+                      ( object+                          [ "stage" .= stageJSON enhanced,+                            "logs" .= map logJSON logs,+                            "preservedWitnesses" .= preservedWitnesses,+                            "errors" .= errors,+                            "command" .= redactedCommand effectiveCommand,+                            "attempt" .= maybe Null Adapter.attemptIdentityJSON attempt+                          ]+                      )+                    updatePartial+                    journal "stage"+                    notifyProgress (StageFinished caseId role enhanced)+                    pure (processReport, enhanced)+              process <- try (runCommand options workspace stage effectiveCommand)+              case process of+                Left ProcessAborted {report, cause} -> record report >> throwIO cause+                Right report -> record report+        updatePartial+        attempted <- try $ restore $ do+          notifyProgress (CaseStarted caseId)+          prepared <- mapM (\(name, lease) -> (name,) <$> Resource.prepareResource lease) leases+          additions <- either (throwIO . ConfigurationFailure) pure (Resource.bindingEnvironment prepared)+          writeIORef environmentRef additions+          addCaseDetails caseId "baseline-resources" (toJSON [resourceBindingJSON name binding | (name, binding) <- prepared])+          let executeBaseline =+                withWorkspace snapshot $ \workspace ->+                  ( do+                      (buildReport, buildEvidence) <- runStage "baseline" workspace Build (commands Map.! build)+                      if not (stagePassed buildReport buildEvidence)+                        then pure False+                        else case test of+                          Nothing -> pure True+                          Just testId | needsTest expectation -> do+                            (testReport, testEvidence) <- runStage "baseline" workspace Test (commands Map.! testId)+                            pure (stagePassed testReport testEvidence && baselineSelection definition testEvidence)+                          _ -> pure True+                  )+                    `withFinalizer` cleanup workspace+          inherited <- sort <$> getEnvironment+          let CaseDefinition {selector} = definition+              baselineKey = BaselineKey (snapshotIdentity snapshot) (commands Map.! build) (if needsTest expectation then (commands Map.!) <$> test else Nothing) selector expectation evidenceMode inherited prepared reusePolicy+              hooksAllow = case hooks of CaseHooks {customEdits, customEvidence} -> null customEdits && case customEvidence of Nothing -> True; Just _ -> False+              epochsKnown = all (\(_, Resource.ResourceBinding {epoch}) -> epoch /= Nothing) prepared+              adapterKnown = case (evidenceMode, reusePolicy) of (StructuredTestsV1, ReuseBaselines _ (Just _)) -> True; (StructuredTestsV1, _) -> False; _ -> True+              eligible = reusePolicy /= Fresh && hooksAllow && epochsKnown && adapterKnown+              compute = do+                passed <- executeBaseline+                writeIORef actualBaselineRef (Just passed)+                producer <- readIORef producerIdentity+                let producerAgrees = case (evidenceMode, reusePolicy) of (StructuredTestsV1, ReuseBaselines _ expected) -> expected == producer; _ -> True+                pure (if passed && producerAgrees then Just (BaselineEntry caseId producer) else Nothing)+          (reused, entry) <- if eligible then Baseline.shareBaseline baselineCache baselineKey compute else (False,) <$> compute+          actual <- readIORef actualBaselineRef+          let baselinePassed = case entry of Just _ -> True; Nothing -> actual == Just True+              reason+                | reusePolicy == Fresh = "fresh reference mode"+                | not hooksAllow = "custom hooks have no portable baseline identity"+                | not epochsKnown = "resource epoch is unknown"+                | not adapterKnown = "structured producer identity is undeclared"+                | reused = "matching campaign-local baseline contract"+                | otherwise = "executed fresh baseline; only passing matching producer facts are shareable"+          forM_ entry $ \(BaselineEntry _ producer) -> writeIORef producerIdentity producer+          addCaseDetails caseId "baseline-reuse" (object ["reused" .= reused, "ownerCase" .= fmap (\(BaselineEntry owner _) -> caseIdText owner) entry, "reason" .= String reason, "policy" .= reusePolicyJSON reusePolicy, "environmentComparedInMemory" .= True])+          writeIORef baselineRef (if baselinePassed then BaselinePassed else BaselineFailed "unchanged build/test or required selection failed")+          when baselinePassed $ do+            let withCandidate staged action = case retainedWorker of+                  Nothing -> action staged Nothing+                  Just worker -> do+                    inheritedNow <- sort <$> getEnvironment+                    Build.withBuildCandidate worker (commands Map.! build, additions ++ inheritedNow) staged (\workspace inputId -> action workspace (Just inputId))+            applied <- withEditedWorkspace (patchRunner caseId) hooks snapshot definition $ \staged editEvidence ->+              withCandidate staged $ \workspace inputId ->+                ( do+                    candidateBindings <- mapM (\(name, lease) -> (name,) <$> Resource.prepareResource lease) leases+                    unless (candidateBindings == prepared) (ioError (userError "resource identity/epoch/environment changed between baseline and candidate"))+                    candidateEnvironment <- either (throwIO . ConfigurationFailure) pure (Resource.bindingEnvironment candidateBindings)+                    let buildEnvironment = maybe [] (\key -> [("HENI_BUILD_INPUT_ID", Text.unpack key)]) inputId+                    writeIORef environmentRef (buildEnvironment ++ candidateEnvironment)+                    addCaseDetails caseId "build-reuse" (object ["enabled" .= (inputId /= Nothing), "inputIdentity" .= inputId, "contract" .= fmap (\Build.BuildReuse {contractIdentity} -> contractIdentity) buildReuse])+                    addCaseDetails caseId "candidate-resources" (toJSON [resourceBindingJSON name binding | (name, binding) <- candidateBindings])+                    writeIORef mutationRef (if expectation == Control then Unchanged else Changed)+                    addCaseDetails caseId "changes" (changesJSON editEvidence)+                    (buildReport, buildEvidence) <- runStage "candidate" workspace Build (commands Map.! build)+                    when (case buildEvidence of StageEvidence {outcome = Exited _} -> True; _ -> False) $ forM_ ((,) <$> retainedWorker <*> inputId) (\(worker, key) -> Build.confirmBuild worker key)+                    when (stagePassed buildReport buildEvidence && needsTest expectation) $ forM_ test $ \testId -> do+                      (_, testEvidence) <- runStage "candidate" workspace Test (commands Map.! testId)+                      case (expectation, testEvidence, hooks) of+                        (NamedTests expected RequireReplay, StageEvidence {outcome = Exited 1, replay = Just token, selection = SelectionEvidence {failedIds}}, CaseHooks {replayBuilder})+                          | expected == failedIds && validateStageEvidence testEvidence == Right () -> do+                              built <- maybe (pure (Right (Replay.replayCommand token (commands Map.! testId)))) (\builder -> builder token (commands Map.! testId)) replayBuilder+                              case built of+                                Left why -> addCaseDetails caseId "replayRejected" (String why)+                                Right replayCommand -> do+                                  _ <- runStage "candidate" workspace Replay replayCommand+                                  pure ()+                        _ -> pure ()+                )+                  `withFinalizer` cleanup workspace+            case applied of+              Left (InvalidEdit why) -> writeIORef mutationRef (EditRejected why)+              Left (EditInfrastructure why) -> modifyIORef' errorsRef (++ [why])+              Right () -> pure ()+        case attempted of+          Left (e :: SomeException) -> do+            modifyIORef' cleanupRef (++ finalizationErrors e)+            let original = originalException e+            case fromException original :: Maybe SomeAsyncException of+              Just _ -> do+                observations <- currentEvidence (Just "execution interrupted")+                atomicModify_ resultsRef (Map.insert caseId (classifyCase definition observations))+                throwIO original+              Nothing -> modifyIORef' errorsRef (++ [Text.pack (displayException e)])+          Right () -> pure ()+        observations <- currentEvidence Nothing+        let result@CaseResult {verdict} = classifyCase definition observations+        when (verdict == InfrastructureError) $ do+          Baseline.clearBaselineCache baselineCache+          forM_ retainedWorker Build.retireBuildWorker+        atomicModify_ resultsRef (Map.insert caseId result)+      patchRunner caseId workspace args = do+        -- Patch check/apply share the exact supervisor used by build/test.+        let prefix = Text.unpack (caseIdText caseId <> if "--check" `elem` args then "-patch-check" else "-patch-apply")+            budget = either (error . show) id (mkStageBudget 5000000)+            command = Command "git" args Set.empty Nothing [] budget (OutputPolicy 65536)+        ProcessReport {evidence = patchEvidence@StageEvidence {outcome}, errors, logs} <- runCommand (defaultProcessOptions outputDirectory prefix) workspace Setup command `catch` \ProcessAborted {cause} -> throwIO cause+        addCaseDetails+          caseId+          (if "--check" `elem` args then "patch-check" else "patch-apply")+          (object ["stage" .= stageJSON patchEvidence, "logs" .= map logJSON logs, "errors" .= errors])+        journal "stage"+        unless (null errors) (throwIO (EditInfrastructure (Text.intercalate "; " errors)))+        case outcome of+          Exited 0 -> pure (Right ())+          Exited _ -> pure (Left "Git rejected textual patch; see patch logs")+          _ -> throwIO (EditInfrastructure ("Git patch supervisor failed: " <> Text.pack (show outcome)))+  attempted <- try $ restore $ do+    Dir.createDirectoryIfMissing True (takeDirectory outputDirectory)+    Dir.createDirectory outputDirectory+    identity <- Adapter.newRunIdentity outputDirectory+    writeIORef runIdentity identity+    atomicModify_ contextRef (\case Object o -> Object (KM.insert "runId" (String identity) o); other -> other)+    journal "selected"+    withSnapshot sourceRoot sourcePolicy $ \snapshot -> do+      atomicModify_ contextRef (addSnapshot snapshot)+      journal "snapshot"+      let observeResource event = do+            atomicModify_ contextRef (appendResourceEvent event)+            journal "resource"+          context ident = Resource.ResourceContext ident snapshot outputDirectory observeResource+          scope target = filter ((== target) . Resource.resourceScope) activeResources+          withWorker number action = Resource.withResources (scope Resource.WorkerScope) (context ("worker-" <> Text.pack (show number))) $ \leases ->+            let run retained =+                  (modifyMVar_ workerBindings (pure . Map.insert number (leases, retained)) >> action)+                    `withFinalizer` modifyMVar_ workerBindings (pure . Map.delete number)+             in maybe (run Nothing) (\provider -> Build.withBuildWorker snapshot provider (run . Just)) buildReuse+      Resource.withResources (scope Resource.CampaignScope) (context "campaign") $ \campaignLeases ->+        Scheduler.runScheduled+          effectiveWorkers+          (Map.fromList [(Resource.resourceName p, Resource.resourceCapacity p) | p <- activeResources])+          [(item, Map.findWithDefault [] cid resourceClaims) | item@(CaseDefinition {caseId = cid}, _) <- chosen]+          withWorker+          $ \number item@(definition@CaseDefinition {caseId}, _) -> do+            (workerLeases, retainedWorker) <- Map.findWithDefault ([], Nothing) number <$> readMVar workerBindings+            let claims = Map.findWithDefault [] caseId resourceClaims+                persistent = filter ((`elem` claims) . fst) (campaignLeases ++ workerLeases)+                localProviders = filter ((`elem` claims) . Resource.resourceName) (scope Resource.CaseScope)+            attempt <- try (Resource.withResources localProviders (context ("case-" <> caseIdText caseId)) (\leases -> runOne snapshot (persistent ++ leases) retainedWorker item))+            case attempt of+              Right () -> pure ()+              Left (e :: SomeException) -> case fromException (originalException e) :: Maybe SomeAsyncException of+                Just _ -> throwIO (originalException e)+                Nothing -> do+                  Baseline.clearBaselineCache baselineCache+                  forM_ retainedWorker Build.retireBuildWorker+                  prior <- Map.lookup caseId <$> readIORef resultsRef+                  let old = case prior of Just CaseResult {evidence} -> evidence; Nothing -> CaseEvidence BaselineUnavailable Unchanged [] [] [] Nothing+                      CaseEvidence b m stages errors clean stop = old+                      failed = classifyCase definition (CaseEvidence b m stages errors (clean ++ [Text.pack (displayException e)]) stop)+                  atomicModify_ resultsRef (Map.insert caseId failed)+                  journal "resource-failure"+            completed <- Map.lookup caseId <$> readIORef resultsRef+            journal "case"+            forM_ completed (notifyProgress . CaseFinished)+  case attempted of+    Left (e :: SomeException) -> do+      atomicModify_ failuresRef (++ map RunInfrastructure (finalizationErrors e))+      case fromException (originalException e) :: Maybe SomeAsyncException of+        Just _ -> atomicModify_ failuresRef (++ [Interrupted (Text.pack (displayException (originalException e)))])+        Nothing -> atomicModify_ failuresRef (++ [RunInfrastructure (Text.pack (displayException e))])+    Right () -> pure ()+  finalWrite <- try (journal "finalized" >> stored >>= writeReport outputDirectory) :: IO (Either SomeException ())+  case finalWrite of+    Left e -> atomicModify_ failuresRef (++ [RunInfrastructure ("report finalization failed: " <> Text.pack (displayException e))])+    Right () -> pure ()+  final <- summary+  case attempted of+    Left e | Just (_ :: SomeAsyncException) <- fromException (originalException e) -> throwIO (originalException e)+    _ -> case finalWrite of+      Left e | Just (_ :: SomeAsyncException) <- fromException e -> throwIO e+      _ -> pure final++stageName :: Stage -> Text+stageName s = case enumValue stageTable s of String t -> t; _ -> error "stage table"++needsTest :: Expectation -> Bool+needsTest BuildRejection {} = False+needsTest (Diagnostic Build _ _) = False+needsTest _ = True++witnessesFor :: Expectation -> Stage -> [Text]+witnessesFor (Diagnostic s _ witness) stage | s == stage = [witness]+witnessesFor (BuildRejection _ witness) Build = [witness]+witnessesFor _ _ = []++stagePassed :: ProcessReport -> StageEvidence -> Bool+stagePassed ProcessReport {errors} evidence@StageEvidence {outcome} = outcome == Exited 0 && null errors && validateStageEvidence evidence == Right ()++baselineSelection :: CaseDefinition -> StageEvidence -> Bool+baselineSelection CaseDefinition {expectation = NamedTests expected _} StageEvidence {selection = SelectionEvidence {selected, executed, executedIds, failedIds, skippedIds}} =+  maybe False (> 0) selected && maybe False (> 0) executed && expected `Set.isSubsetOf` executedIds && Set.null failedIds && Set.null (expected `Set.intersection` skippedIds)+baselineSelection CaseDefinition {evidenceMode = StructuredTestsV1} StageEvidence {selection = SelectionEvidence {selected, executed, failedIds}} = maybe False (> 0) selected && maybe False (> 0) executed && Set.null failedIds+baselineSelection _ _ = True++enhance :: CaseHooks -> StageEvidence -> IO StageEvidence+enhance CaseHooks {customEvidence = Nothing} evidence = pure evidence+enhance _ evidence@StageEvidence {outcome} | outcome `elem` [Timeout, Cancelled] = pure evidence+enhance CaseHooks {customEvidence = Just hook} evidence@(StageEvidence s o text unavailable _ _ duration logs) = do+  supplied <- hook evidence+  case supplied of+    Left why -> pure (StageEvidence s o text True (SelectionEvidence Nothing Nothing Nothing Set.empty Set.empty Set.empty [why]) Nothing duration logs)+    Right StageEvidence {selection, replay} -> pure (StageEvidence s o text unavailable selection replay duration logs)++isCrash :: Expectation -> Bool+isCrash CrashProbe {} = True+isCrash _ = False++reservedEnvironment :: [String]+reservedEnvironment = ["HENI_EVIDENCE_FILE", "HENI_RUN_ID", "HENI_CASE_ID", "HENI_STAGE", "HENI_ROLE", "HENI_ATTEMPT", "HENI_SELECTOR", "HENI_REPLAY_TOKEN", "HENI_REPLAY_GENERATOR", "HENI_BUILD_INPUT_ID"]++withEnvironment :: [(String, String)] -> Command -> Command+withEnvironment additions (Command exe args sensitive working env budget output) =+  Command exe args sensitive working (additions ++ filter (\(key, _) -> key `notElem` map fst additions) env) budget output++-- Protocol evidence is authoritative. A custom observer can reject it, but cannot+-- replace a framework's actual execution or replay membership.+observeStructured :: CaseHooks -> StageEvidence -> IO StageEvidence+observeStructured CaseHooks {customEvidence = Nothing} evidence = pure evidence+observeStructured _ evidence@StageEvidence {outcome} | outcome `elem` [Timeout, Cancelled] = pure evidence+observeStructured CaseHooks {customEvidence = Just hook} evidence@(StageEvidence s o text _ selection replay duration logs) =+  hook evidence >>= \case+    Right _ -> pure evidence+    Left why -> pure (StageEvidence s o text True (addSelectionError why selection) replay duration logs)++addSelectionError :: Text -> SelectionEvidence -> SelectionEvidence+addSelectionError why (SelectionEvidence d s e ids failed skipped errors) = SelectionEvidence d s e ids failed skipped (errors ++ [why])++normalizeAttempt :: IORef (Maybe Text) -> CaseDefinition -> Maybe Adapter.EvidenceAttempt -> StageEvidence -> IO StageEvidence+normalizeAttempt _ _ Nothing evidence = pure evidence+normalizeAttempt producerIdentity CaseDefinition {expectation, evidenceMode} (Just attempt@Adapter.EvidenceAttempt {outputRoot, role}) evidence@(StageEvidence s o text unavailable selection replay duration logs) = do+  let addArtifact relative = do+        exists <- Dir.doesFileExist (outputRoot ++ "/" ++ relative)+        pure [relative | exists]+  protocolArtifacts <- if evidenceMode == StructuredTestsV1 then addArtifact (Adapter.evidenceRelativePath attempt) else pure []+  probeArtifacts <- if isCrash expectation then addArtifact (Crash.probeRelativePath attempt) else pure []+  let artifacts = logs ++ protocolArtifacts ++ probeArtifacts+  if o `elem` [Timeout, Cancelled]+    then pure (StageEvidence s o text unavailable selection replay duration artifacts)+    else case expectation of+      CrashProbe _ probe -> do+        valid <- Crash.readCrashProbe attempt probe+        pure (StageEvidence s o (either (const "") (const probe) valid) unavailable selection replay duration artifacts)+      _+        | evidenceMode == StructuredTestsV1 ->+            Adapter.readEvidence attempt >>= \case+              Left why -> pure (StageEvidence s o text True (addSelectionError why unknownSelection) Nothing duration artifacts)+              Right (selected, replayed, producer) -> do+                expected <- readIORef producerIdentity+                if role == "baseline" then writeIORef producerIdentity (Just producer) else pure ()+                let normalized = if role /= "baseline" && expected /= Just producer then addSelectionError "reporter version changed after baseline" selected else selected+                pure (StageEvidence s o text unavailable normalized replayed duration artifacts)+      _ -> pure evidence++initialMetadata :: Campaign -> [(CaseDefinition, CaseHooks)] -> Value+initialMetadata (Campaign commands _) selected =+  object+    [ "platform" .= object ["os" .= Platform.os, "arch" .= Platform.arch, "compiler" .= Platform.compilerName, "compilerVersion" .= showVersion Platform.compilerVersion],+      "sourcePolicy" .= String "selected working-tree bytes including dirty/untracked regular files",+      "commands" .= object [Key.fromText (commandIdText key) .= redactedCommand command | (key, command) <- Map.toAscList commands],+      "definitions" .= map (caseJSON . fst) selected,+      "caseDetails" .= object [],+      "resources" .= ([] :: [Value])+    ]++redactedCommand :: Command -> Value+redactedCommand Command {executable, arguments, sensitiveArguments, workingDirectory, environment, budget, output} =+  object+    [ "executable" .= executable,+      "arguments" .= [if Set.member i sensitiveArguments then "<redacted>" else arg | (i, arg) <- zip [0 ..] arguments],+      "sensitiveArguments" .= Set.toAscList sensitiveArguments,+      "workingDirectory" .= fmap targetPath workingDirectory,+      "environmentOverrides" .= object [Key.fromString key .= String "<redacted>" | (key, _) <- environment],+      "environmentRedacted" .= True,+      "timeoutMicroseconds" .= budgetMicroseconds budget,+      "outputLimitBytes" .= case output of OutputPolicy {limitBytes} -> limitBytes+    ]++addSnapshot :: SourceSnapshot -> Value -> Value+addSnapshot snapshot (Object o) = Object (KM.insert "snapshot" (object ["identity" .= snapshotIdentity snapshot, "policy" .= policyJSON (snapshotPolicy snapshot)]) o)+addSnapshot _ other = other++policyJSON :: SnapshotPolicy -> Value+policyJSON SnapshotPolicy {includes, excludes} = object ["includes" .= map targetPath includes, "excludes" .= map targetPath excludes, "defaultExcludedNames" .= defaultExcludedNames, "defaultExcludedPrefixes" .= defaultExcludedPrefixes]++mapToKeyMap :: Object -> CaseId -> Text -> Value -> Object+mapToKeyMap metadata caseId key value = KM.insert "caseDetails" (Object updated) metadata+  where+    existing = case KM.lookup "caseDetails" metadata of Just (Object o) -> o; _ -> KM.empty+    caseKey = Key.fromText (caseIdText caseId)+    details = case KM.lookup caseKey existing of Just (Object o) -> o; _ -> KM.empty+    updated = KM.insert caseKey (Object (KM.insert (Key.fromText key) value details)) existing++logJSON :: LogArtifact -> Value+logJSON LogArtifact {stream, path, bytesSeen, bytesRetained, truncated} =+  object+    ["stream" .= (if stream == Stdout then String "stdout" else String "stderr"), "path" .= path, "bytesSeen" .= bytesSeen, "bytesRetained" .= bytesRetained, "truncated" .= truncated]++changesJSON :: EditEvidence -> Value+changesJSON (EditEvidence changes) = toJSON [object ["path" .= targetPath path, "before" .= nullable fileJSON before, "after" .= nullable fileJSON after] | FileChange {path, before, after} <- changes]+  where+    fileJSON FileEntry {bytes, executableBits} = object ["sha256" .= digest bytes, "executableBits" .= executableBits, "text" .= either (const Nothing) Just (Text.decodeUtf8' bytes), "byteLength" .= BS.length bytes]++withSelector :: CaseDefinition -> Command -> Command+withSelector CaseDefinition {selector} (Command executable arguments sensitive working environment budget output) =+  Command executable arguments sensitive working (maybe environment (\s -> ("HENI_SELECTOR", Text.unpack s) : filter ((/= "HENI_SELECTOR") . fst) environment) selector) budget output++originalException :: SomeException -> SomeException+originalException e = case fromException e of+  Just (WorkspaceFinalizationError (Just primary) _) -> originalException primary+  Just (WorkspaceFinalizationError Nothing secondary) -> originalException secondary+  _ -> case fromException e of+    Just (Resource.ResourceError _ _ cause) -> originalException cause+    _ -> e++finalizationErrors :: SomeException -> [Text]+finalizationErrors e = case fromException e of+  Just (WorkspaceFinalizationError primary secondary) -> Text.pack (displayException secondary) : maybe [] finalizationErrors primary+  _ -> case fromException e of+    Just (Resource.ResourceError _ _ cause) -> finalizationErrors cause+    _ -> []++-- Atomic shared state; case-local evidence is private to one scheduled owner.+atomicModify_ :: IORef a -> (a -> a) -> IO ()+atomicModify_ ref update = atomicModifyIORef' ref (\value -> (update value, ()))++resourceBindingJSON :: Text -> Resource.ResourceBinding -> Value+resourceBindingJSON name Resource.ResourceBinding {identity, version, epoch, environment} =+  object+    ["name" .= name, "identity" .= identity, "version" .= version, "epoch" .= epoch, "environmentKeys" .= map fst environment, "environmentRedacted" .= True]++appendResourceEvent :: Resource.ResourceEvent -> Value -> Value+appendResourceEvent event (Object o) =+  let old = case KM.lookup "resources" o of Just (Array a) -> toList a; _ -> []+      value = case event of+        Resource.ResourceEvent name instanceId phase binding -> object ["instance" .= instanceId, "phase" .= resourcePhaseText phase, "binding" .= resourceBindingJSON name binding]+        Resource.ResourceCommandFinished name instanceId phase ProcessReport {evidence, logs, errors} -> object ["resource" .= name, "instance" .= instanceId, "phase" .= resourcePhaseText phase, "stage" .= stageJSON evidence, "logs" .= map logJSON logs, "errors" .= errors]+   in Object (KM.insert "resources" (toJSON (old ++ [value])) o)+appendResourceEvent _ other = other++reusePolicyJSON :: ReusePolicy -> Value+reusePolicyJSON Fresh = object ["mode" .= String "fresh"]+reusePolicyJSON (ReuseBaselines toolchain adapter) = object ["mode" .= String "baselines", "toolchain" .= toolchain, "adapter" .= adapter]++resourceScopeText :: Resource.ResourceScope -> Text+resourceScopeText = \case Resource.CampaignScope -> "campaign"; Resource.WorkerScope -> "worker"; Resource.CaseScope -> "case"++resourcePhaseText :: Resource.ResourcePhase -> Text+resourcePhaseText = \case Resource.AcquireResource -> "acquire"; Resource.ResetResource -> "reset"; Resource.ReleaseResource -> "release"
+ src/Test/Heni/Scheduler.hs view
@@ -0,0 +1,64 @@+-- | Bounded workers with atomic multi-resource claims and owned cancellation.+module Test.Heni.Scheduler (runScheduled) where++import Control.Concurrent+import Control.Concurrent.STM+import Control.Exception+import Control.Monad (forM, forM_, replicateM_, unless)+import Data.List (findIndex)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text (Text)+import Test.Heni.Internal.Files (WorkspaceFinalizationError (..), withFinalizer)++-- | Run a finite author-ordered queue. Claims are acquired atomically; a blocked+-- item can be passed by independent work, and becomes eligible when its owners+-- release capacity. Failures cancel/join every worker before returning. This+-- coordinates one process only; external processes require external locks.+runScheduled :: Int -> Map.Map Text Int -> [(a, [Text])] -> (Int -> IO () -> IO ()) -> (Int -> a -> IO ()) -> IO ()+runScheduled count capacities work withWorker action = do+  unless (count > 0 && count <= 256 && all (> 0) (Map.elems capacities)) (ioError (userError "invalid worker count or resource capacity"))+  forM_ work $ \(_, claims) -> unless (all (`Map.member` capacities) claims && Set.size (Set.fromList claims) == length claims) (ioError (userError "unknown or duplicate resource claim"))+  mask $ \restore -> do+    queue <- newTVarIO work+    active <- newTVarIO Map.empty+    completed <- newChan+    let takeWork = do+          remaining <- readTVar queue+          case remaining of+            [] -> pure Nothing+            _ -> do+              usage <- readTVar active+              let available (_, claims) = all (\name -> Map.findWithDefault 0 name usage < capacities Map.! name) claims+              case findIndex available remaining of+                Nothing -> retry+                Just index -> case splitAt index remaining of+                  (before, (item, claims) : after) -> do+                    writeTVar queue (before ++ after)+                    writeTVar active (foldr (\name -> Map.insertWith (+) name 1) usage claims)+                    pure (Just (item, claims))+                  _ -> retry+        release claims = atomically (modifyTVar' active (\usage -> foldr (Map.adjust (subtract 1)) usage claims))+        worker number = mask $ \restoreWorker ->+          atomically takeWork >>= \case+            Nothing -> pure ()+            Just (item, claims) -> (restoreWorker (action number item) `withFinalizer` release claims) >> restoreWorker (worker number)+    handles <- forM [1 .. min count (max 1 (length work))] $ \number -> do+      done <- newEmptyMVar+      tid <- forkIOWithUnmask $ \unmask -> do+        result <- try (unmask (withWorker number (worker number)))+        putMVar done (result :: Either SomeException ())+        writeChan completed result+      pure (tid, done)+    let join = mapM_ (readMVar . snd) handles+        cancel = do+          forM_ handles (\(tid, _) -> throwTo tid ThreadKilled)+          states <- mapM (readMVar . snd) handles+          pure [e | Left e <- states, Nothing <- [fromException e :: Maybe SomeAsyncException]]+        await = replicateM_ (length handles) $ readChan completed >>= either throwIO pure+    outcome <- try (restore await)+    case outcome of+      Left (e :: SomeException) -> do+        secondary <- cancel+        throwIO (foldl (\primary extra -> toException (WorkspaceFinalizationError (Just primary) extra)) e secondary)+      Right () -> join
+ src/Test/Heni/Workspace.hs view
@@ -0,0 +1,142 @@+-- | Immutable source bytes and exclusive disposable working copies.+module Test.Heni.Workspace+  ( SourceSnapshot,+    SnapshotPolicy (..),+    defaultSnapshotPolicy,+    defaultExcludedNames,+    defaultExcludedPrefixes,+    Workspace,+    WorkspaceError (..),+    WorkspaceFinalizationError (..),+    FileEntry (..),+    withSnapshot,+    withWorkspace,+    workspaceRoot,+    snapshotIdentity,+    snapshotPolicy,+    snapshotFiles,+    verifyWorkspace,+    resetWorkspace,+    checkedWorkspacePath,+    validateOutputLocation,+  )+where++import Control.Exception (throwIO)+import Control.Monad (forM_, unless, when)+import Data.List (isPrefixOf)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import System.Directory qualified as Dir+import System.FilePath (makeRelative, splitDirectories, (</>))+import Test.Heni.Case (RelativeTarget, targetPath)+import Test.Heni.Internal.Files++-- | Includes selected working-tree bytes (dirty and untracked), never implicit HEAD.+data SnapshotPolicy = SnapshotPolicy+  { includes :: ![RelativeTarget],+    excludes :: ![RelativeTarget]+  }+  deriving stock (Eq, Show)++-- | Empty includes means the root. Default excluded names apply at every depth.+defaultSnapshotPolicy :: SnapshotPolicy+defaultSnapshotPolicy = SnapshotPolicy [] []++-- | Stored in memory: callers cannot mutate snapshot bytes through a filesystem path.+data SourceSnapshot = SourceSnapshot !Text !SnapshotPolicy !Inventory++-- | Constructor hidden: only Heni allocates and owns a cleanup target.+newtype Workspace = Workspace FilePath++-- | SHA-256 over framed sorted names, bytes, executable bits and policy.+snapshotIdentity :: SourceSnapshot -> Text+snapshotIdentity (SourceSnapshot identity _ _) = identity++-- | Effective inclusion and exclusion choices.+snapshotPolicy :: SourceSnapshot -> SnapshotPolicy+snapshotPolicy (SourceSnapshot _ policy _) = policy++-- | Immutable selected bytes, including binary fixtures.+snapshotFiles :: SourceSnapshot -> Map.Map RelativeTarget FileEntry+snapshotFiles (SourceSnapshot _ _ files) = files++-- | Exclusive directory for consumer commands. This is not an OS sandbox.+workspaceRoot :: Workspace -> FilePath+workspaceRoot (Workspace root) = root++-- | Effective default directory/file names, recorded in snapshot identities.+defaultExcludedNames :: [FilePath]+defaultExcludedNames = [".git", "dist", "dist-newstyle", "cabal-dev", ".direnv", "result", "mutation-results", ".heni", ".DS_Store"]++-- | Name prefixes excluded at every depth.+defaultExcludedPrefixes :: [FilePath]+defaultExcludedPrefixes = ["result-", ".heni-"]++excludedBy :: SnapshotPolicy -> FilePath -> Bool+excludedBy (SnapshotPolicy ins outs) p = defaultExcluded || explicitExcluded || outsideSelection+  where+    parts = splitDirectories p+    defaultExcluded = any (\part -> part `elem` defaultExcludedNames || any (`isPrefixOf` part) defaultExcludedPrefixes) parts+    prefix a b = a == b || (a ++ "/") `isPrefixOf` b+    explicitExcluded = any (\t -> prefix (targetPath t) p) outs+    outsideSelection = not (null ins) && not (any (\t -> prefix p (targetPath t) || prefix (targetPath t) p) ins)++-- | Capture twice and require identical inventory; this detects ordinary concurrent+-- changes, but is not an atomic OS snapshot under adversarial writers.+withSnapshot :: FilePath -> SnapshotPolicy -> (SourceSnapshot -> IO a) -> IO a+withSnapshot root policy action = do+  canonical <- Dir.canonicalizePath root+  when (canonical == "/" || canonical == "/nix/store" || "/nix/store/" `isPrefixOf` canonical) (throwIO (UnsafeSource "source root is not a project directory"))+  symlink <- Dir.pathIsSymbolicLink root+  when symlink (throwIO (UnsafeSource "source root is a symlink"))+  let SnapshotPolicy {includes} = policy+  forM_ includes $ \target -> do+    path <- checkedPath canonical target+    exists <- Dir.doesPathExist path+    unless exists (throwIO (UnsafeSource ("included input does not exist: " <> Text.pack (targetPath target))))+  before <- inventory (excludedBy policy) canonical+  after <- inventory (excludedBy policy) canonical+  unless (before == after) (throwIO SourceChanged)+  action (SourceSnapshot (inventoryDigest (Text.pack (show policy ++ show defaultExcludedNames ++ show defaultExcludedPrefixes)) before) policy before)++-- | Bracket allocation and removal. Cleanup exceptions remain observable.+withWorkspace :: SourceSnapshot -> (Workspace -> IO a) -> IO a+withWorkspace (SourceSnapshot _ _ files) action = withOwnedDirectory ".heni-workspace-" $ \root -> do+  materialize root files+  action (Workspace root)++-- | Verify exact source inventory. Build outputs are deliberately excluded.+verifyWorkspace :: SourceSnapshot -> Workspace -> IO ()+verifyWorkspace (SourceSnapshot _ policy files) (Workspace root) = do+  actual <- inventory (excludedBy policy) root+  unless (actual == files) (throwIO (WorkspaceMismatch "workspace differs from pristine snapshot"))++-- | Reset only an opaque owned workspace, then independently verify it.+-- A thrown exception invalidates reuse; the caller must allocate a fresh copy.+resetWorkspace :: SourceSnapshot -> Workspace -> IO ()+resetWorkspace snap@(SourceSnapshot _ _ files) (Workspace root) = do+  -- Validate the root before listing or deleting anything.+  _ <- inventory (const True) root+  names <- Dir.listDirectory root+  mapM_ (Dir.removePathForcibly . (root </>)) names+  materialize root files+  verifyWorkspace snap (Workspace root)++-- | Validate every existing component before an operation; rejects symlink escapes.+checkedWorkspacePath :: Workspace -> RelativeTarget -> IO FilePath+checkedWorkspacePath (Workspace root) = checkedPath root++-- | Output under the source root is permitted only when the effective policy+-- excludes it. Ancestors and source-equal locations are always rejected.+validateOutputLocation :: FilePath -> SnapshotPolicy -> FilePath -> IO ()+validateOutputLocation source policy output = do+  s <- Dir.canonicalizePath source+  o <- Dir.canonicalizePath output+  let relative = makeRelative s o+      outputAncestor = o == s || (o ++ "/") `isPrefixOf` s+      outputInside = not (null relative) && not (".." `elem` splitDirectories relative) && not ("/" `isPrefixOf` relative)+  when+    (outputAncestor || (outputInside && not (excludedBy policy relative)))+    (throwIO (UnsafeSource "output must be outside the source or explicitly excluded"))
+ test/adapter/Main.hs view
@@ -0,0 +1,91 @@+module Main (main) where++import Control.Exception+import Control.Monad (forM_, unless)+import Data.Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString qualified as BS+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import System.Directory qualified as Dir+import System.Environment (lookupEnv)+import System.FilePath (takeDirectory, (</>))+import System.Posix.Signals (sigABRT)+import System.Posix.Temp (mkdtemp)+import System.Timeout (timeout)+import Test.Heni+import Test.Heni.Adapter+import Test.Heni.Replay qualified as Replay++assert' :: String -> Bool -> IO ()+assert' name good = unless good (error name)++checked :: (Show e) => Either e a -> a+checked = either (error . show) id++main :: IO ()+main =+  timeout 120000000 checks >>= \case+    Nothing -> error "adapter integration watchdog"+    Just () -> pure ()++checks :: IO ()+checks = bracket (Dir.getTemporaryDirectory >>= \tmp -> mkdtemp (tmp </> "heni-adapter-test-")) Dir.removePathForcibly $ \root -> do+  let source = root </> "source"+      budget = checked (mkStageBudget 2000000)+      buildId = checked (mkCommandId "build")+      testId = checked (mkCommandId "test")+      caseId = checked (mkCaseId "named")+      command args = Command "heni-evidence-fixture" args Set.empty Nothing [] budget (OutputPolicy 1024)+      definition expectation mode = CaseDefinition caseId "Detect the intended assertion" Set.empty [Replace (checked (mkRelativeTarget "input")) "anchor" "mutant" 1] buildId (Just testId) Nothing expectation mode+      named policy = definition (NamedTests (Set.singleton "suite/intended") policy) StructuredTestsV1+      run name args chosenDefinition hooks =+        runCampaign+          (defaultRunOptions source (root </> name))+          (checked (mkCampaign (Map.fromList [(buildId, command ["exit", "0"]), (testId, command args)]) [(chosenDefinition, hooks)]))+      single verdict RunResult {results} = case results of [CaseResult {verdict = v}] -> v == verdict; _ -> False+  Dir.createDirectory source+  BS.writeFile (source </> "input") "anchor\n"+  forM_ [("intended", Caught), ("wrong", WrongFailure), ("skipped", Uncovered), ("setup", InfrastructureError), ("stale", InfrastructureError), ("partial", InfrastructureError), ("counts", InfrastructureError), ("version", InfrastructureError)] $ \(mode, expected) -> do+    result <- run mode ["protocol", "input", mode] (named NoReplay) noHooks+    unless (single expected result) $ do+      stored <- readReport (root </> mode </> "report.json")+      print (case stored of Right StoredReport {metadata = Object o} -> KM.lookup "caseDetails" o; _ -> Nothing)+    assert' ("protocol fixture " ++ mode ++ ": " ++ show result) (single expected result)+  replay <- run "replay" ["protocol", "input", "replay"] (named RequireReplay) noHooks+  assert' "required replay actual execution" (single Caught replay && case replay of RunResult {results = [CaseResult {evidence = CaseEvidence {stages}}]} -> length stages == 3; _ -> False)+  let badBuilder = CaseHooks [] Nothing (Just (\token originalCommand -> pure (Right (changeToken (Replay.replayCommand token originalCommand)))))+      changeToken (Command exe args sensitive working env changedBudget output) = Command exe args sensitive working (("HENI_REPLAY_TOKEN", "different-token") : filter ((/= "HENI_REPLAY_TOKEN") . fst) env) changedBudget output+  mismatch <- run "replay-mismatch" ["protocol", "input", "replay"] (named RequireReplay) badBuilder+  assert' "opaque token mismatch cannot be caught" (single WrongFailure mismatch)+  replayTimeout <- run "replay-timeout" ["protocol", "input", "replay-timeout"] (named RequireReplay) noHooks+  assert' ("replay timeout is a rejected reproduction with retained stage timeout: " ++ show replayTimeout) (single WrongFailure replayTimeout && case replayTimeout of RunResult {results = [CaseResult {evidence = CaseEvidence {stages}}]} -> any (\StageEvidence {stage, outcome} -> stage == Replay && outcome == Timeout) stages; _ -> False)+  let empty = case named NoReplay of CaseDefinition i d ts es b t _ e mode -> CaseDefinition i d ts es b t (Just "absent") e mode+  uncovered <- run "empty" ["protocol", "input", "intended"] empty noHooks+  assert' "known empty baseline cannot run candidate" (single NotRun uncovered)+  forM_ [("good", Caught), ("missing", WrongFailure), ("stale", WrongFailure), ("wrong-signal", WrongFailure), ("timeout", TimedOut), ("exit", WrongFailure)] $ \(mode, expected) -> do+    result <- run ("crash-" ++ mode) ["crash", "input", mode] (definition (CrashProbe (fromIntegral sigABRT) "operation-reached") GenericCommands) noHooks+    assert' ("fresh crash probe " ++ mode ++ ": " ++ show result) (single expected result)+  assert' "stable IDs escape separators unambiguously" (stableTestId ["suite/a", "x~y"] == "suite~1a/x~0y")+  -- Direct codec conformance uses an actual writer, not invented stdout.+  Dir.createDirectory (root </> "protocol")+  identity <- newRunIdentity (root </> "protocol")+  attempt <- newEvidenceAttempt (root </> "protocol") identity caseId Test "candidate"+  emitSuite attempt (SuiteEvidence "fixture/v1" (Just ["suite/intended"]) ["suite/intended"] [TestPassed "suite/intended"] [] Nothing [])+  parsed <- readEvidence attempt+  assert' "protocol round trip" (case parsed of Right (SelectionEvidence {executed = Just 1}, Nothing, "fixture/v1") -> True; _ -> False)+  schemaOutput <- lookupEnv "HENI_SCHEMA_EVIDENCE"+  forM_ schemaOutput $ \path -> Dir.createDirectoryIfMissing True (takeDirectory path) >> Dir.copyFile (evidencePath attempt) path+  duplicated <- try (emitSuite attempt (SuiteEvidence "fixture/v1" Nothing [] [] [] Nothing [])) :: IO (Either EvidenceError ())+  assert' "writer refuses stale destination" (case duplicated of Left _ -> True; _ -> False)+  invalidated <- newEvidenceAttempt (root </> "protocol") identity caseId Test "candidate"+  _ <-+    try+      ( guardEvidenceAttempt invalidated $ do+          emitSuite invalidated (SuiteEvidence "fixture/v1" Nothing ["suite/intended"] [TestFailed "suite/intended"] [] Nothing [])+          ioError (userError "cleanup after completion")+      ) ::+      IO (Either IOException ())+  bad <- readEvidence invalidated+  assert' "post-completion reporter exception invalidates proof" (case bad of Left _ -> True; _ -> False)+  putStrLn "Attempt-bound protocol, named attribution, replay and native probe checks passed"
+ test/api/Consumer.hs view
@@ -0,0 +1,63 @@+-- A separate consumer: imports only the exposed umbrella, never internal modules.+module Consumer (definitions, namedCase, diagnosticCase, buildCase, replayCase, controlCase, crashCase) where++import Data.Set qualified as Set+import Test.Heni++checked :: (Show e) => Either e a -> a+checked = either (error . show) id++baseCase :: CaseDefinition+baseCase =+  CaseDefinition+    (checked (mkCaseId "guard-bypass"))+    "Removing the capacity guard must be detected."+    (Set.singleton "regression")+    [Replace (checked (mkRelativeTarget "src/Capacity.hs")) "used < limit" "True" 1]+    (checked (mkCommandId "build"))+    (Just (checked (mkCommandId "test")))+    (Just "rejects full capacity")+    (NamedTests (Set.singleton "rejects full capacity") NoReplay)+    GenericCommands++namedCase, diagnosticCase, buildCase, replayCase, controlCase, crashCase :: CaseDefinition+namedCase = baseCase+diagnosticCase = changeExpectation (Diagnostic Test 1 "capacity rule violated") baseCase+buildCase = changeExpectation (BuildRejection 1 "unbound variable") baseCase+replayCase = changeExpectation (NamedTests (Set.singleton "rejects full capacity") RequireReplay) baseCase+controlCase = case baseCase of+  CaseDefinition i d ts _ b t s _ mode -> CaseDefinition i d ts [] b t s Control mode+crashCase = changeExpectation (CrashProbe 6 "capacity-probe: native operation reached") baseCase++changeExpectation :: Expectation -> CaseDefinition -> CaseDefinition+changeExpectation e (CaseDefinition i d ts es b t s _ mode) = CaseDefinition i d ts es b t s e mode++-- Four independently authored construction trials, corresponding to RES-1 patterns.+-- These are synthetic classifier inputs, not executed migrations.+definitions :: [CaseDefinition]+definitions = [namedCase, patchCatalogue, generatedCode, diagnosticCase]+  where+    patchCatalogue = case baseCase of+      CaseDefinition _ d ts _ b t s e mode ->+        CaseDefinition+          (checked (mkCaseId "patch-guard"))+          d+          ts+          [Patch "diff --git a/src/Capacity.hs b/src/Capacity.hs\n--- a/src/Capacity.hs\n+++ b/src/Capacity.hs\n@@ -1 +1 @@\n-used < limit\n+True\n"]+          b+          t+          s+          e+          mode+    generatedCode = case baseCase of+      CaseDefinition _ d ts _ b t s e mode ->+        CaseDefinition+          (checked (mkCaseId "generated-guard"))+          d+          ts+          [CustomEdit "regenerate-without-guard"]+          b+          t+          s+          e+          mode
+ test/api/Labels.hs view
@@ -0,0 +1,15 @@+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}++-- Compile regression for consumer-defined overloaded labels.+module Labels (labelWorks) where++import GHC.OverloadedLabels (IsLabel (..))+import Test.Heni ()++data ConsumerLabel = ConsumerLabel deriving stock (Eq)++instance IsLabel "consumer" ConsumerLabel where fromLabel = ConsumerLabel++labelWorks :: Bool+labelWorks = #consumer == ConsumerLabel
+ test/evidence-fixture/Main.hs view
@@ -0,0 +1,83 @@+module Main (main) where++import Control.Concurrent (threadDelay)+import Data.ByteString qualified as BS+import Data.List (isInfixOf)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import System.Environment (getArgs, lookupEnv)+import System.Exit (ExitCode (..), exitWith)+import System.IO+import System.Posix.Process (getProcessID)+import System.Posix.Signals+import Test.Heni.Adapter+import Test.Heni.Crash+import Test.Heni.Expectation (ReplayEvidence (..))++main :: IO ()+main = do+  hSetBuffering stdout NoBuffering+  hSetBuffering stderr NoBuffering+  getArgs >>= \case+    ["exit", "0"] -> pure ()+    ["protocol", path, mode] -> protocolFixture path mode+    ["crash", path, mode] -> crashFixture path mode+    _ -> error "unknown evidence fixture mode"++protocolFixture :: FilePath -> String -> IO ()+protocolFixture path mode = do+  mutated <- isInfixOf "mutant" <$> readFile path+  selector <- fmap Text.pack <$> lookupEnv "HENI_SELECTOR"+  currentStage <- lookupEnv "HENI_STAGE"+  let allIds = ["suite/intended", "suite/other"]+      selected = maybe allIds (\s -> filter (Text.isInfixOf s) allIds) selector+      observation ident+        | not mutated = TestPassed ident+        | ident == "suite/intended" && mode == "skipped" = TestSkipped ident+        | ident == "suite/intended" && mode == "setup" = TestSetupFailed ident "fixture setup failed"+        | ident == "suite/other" && mode == "wrong" = TestFailed ident+        | ident == "suite/intended" && mode /= "wrong" = TestFailed ident+        | otherwise = TestPassed ident+      observations = map observation selected+      failed = Set.fromList [ident | TestFailed ident <- observations]+  token <- maybe "fixture-token" Text.pack <$> lookupEnv "HENI_REPLAY_TOKEN"+  generator <- maybe "fixture-generator" Text.pack <$> lookupEnv "HENI_REPLAY_GENERATOR"+  let replay = if mutated && mode `elem` ["replay", "replay-timeout"] then Just (ReplayEvidence token generator failed) else Nothing+      producer = if mutated && mode == "version" then "fixture/v2" else "fixture/v1"+      suite = SuiteEvidence producer (Just allIds) selected observations [] replay ["counterexample"]+  currentEvidenceAttempt+    >>= mapM_+      ( \attempt -> do+          let destination = if mutated && mode == "stale" then case attempt of EvidenceAttempt root dir _ ident stage role -> EvidenceAttempt root dir "stale-run" ident stage role else attempt+          emitSuite destination suite+          if mutated+            then case mode of+              "partial" -> do+                bytes <- BS.readFile (evidencePath attempt)+                BS.writeFile (evidencePath attempt) (BS.intercalate "\n" (init (init (BS.split 10 bytes))))+              "counts" -> do+                bytes <- BS.readFile (evidencePath attempt)+                let text = Text.replace "\"executed\":2" "\"executed\":9" (Text.decodeUtf8 bytes)+                BS.writeFile (evidencePath attempt) (Text.encodeUtf8 text)+              _ -> pure ()+            else pure ()+      )+  if mutated && mode == "replay-timeout" && currentStage == Just "replay" then threadDelay 30000000 else pure ()+  if any (\case TestFailed {} -> True; TestSetupFailed {} -> True; _ -> False) observations then exitWith (ExitFailure 1) else pure ()++crashFixture :: FilePath -> String -> IO ()+crashFixture path mode = do+  mutated <- isInfixOf "mutant" <$> readFile path+  if not mutated+    then recordCrashProbe "operation-reached"+    else do+      case mode of+        "missing" -> putStrLn "operation-reached"+        "stale" -> currentEvidenceAttempt >>= mapM_ (\case EvidenceAttempt root dir _ ident stage role -> writeCrashProbe (EvidenceAttempt root dir "stale-run" ident stage role) "operation-reached")+        _ -> recordCrashProbe "operation-reached"+      case mode of+        "timeout" -> threadDelay 30000000+        "exit" -> exitWith (ExitFailure (128 + fromIntegral sigABRT))+        "wrong-signal" -> getProcessID >>= signalProcess sigTERM+        _ -> getProcessID >>= signalProcess sigABRT
+ test/fixture/Main.hs view
@@ -0,0 +1,97 @@+module Main (main) where++import Control.Concurrent (threadDelay)+import Control.Monad (forever, replicateM_)+import Data.ByteString qualified as BS+import Data.List (isInfixOf)+import System.Directory qualified as Dir+import System.Environment (getArgs, getExecutablePath, lookupEnv)+import System.Exit (ExitCode (..), exitWith)+import System.IO+import System.IO.Error (catchIOError)+import System.Posix.Process (createSession, getProcessID)+import System.Posix.Signals+import System.Process qualified as P++main :: IO ()+main = do+  hSetBuffering stdout NoBuffering+  hSetBuffering stderr NoBuffering+  getArgs >>= \case+    ["exit", code] -> exitWith (if code == "0" then ExitSuccess else ExitFailure (read code))+    ["signal"] -> getProcessID >>= signalProcess sigABRT+    ["sleep"] -> putStrLn "exact-witness" >> threadDelay 30000000+    ["ignore", marker] -> do+      _ <- installHandler sigTERM Ignore Nothing+      pid <- getProcessID+      writeFile marker (show pid)+      forever (threadDelay 1000000)+    ["descendant", marker] -> do+      exe <- getExecutablePath+      _ <- P.createProcess (P.proc exe ["ignore", marker])+      waitForMarker marker+      -- Exit while the descendant retains stdout/stderr and ignores SIGTERM.+      pure ()+    ["detached", marker] -> do+      _ <- createSession+      pid <- getProcessID+      writeFile marker (show pid)+      forever (threadDelay 1000000)+    ["detach", marker] -> do+      exe <- getExecutablePath+      _ <- P.createProcess (P.proc exe ["detached", marker])+      waitForMarker marker+    ["flood"] -> do+      replicateM_ 200 (BS.hPut stdout (BS.replicate 32768 65) >> BS.hPut stderr (BS.replicate 32768 66))+      BS.hPut stdout "exact-"+      threadDelay 10000+      BS.hPut stdout "witness"+      exitWith (ExitFailure 1)+    ["invalid-utf8"] -> BS.hPut stdout (BS.pack [255]) >> exitWith (ExitFailure 1)+    ["incremental-build", mode] -> do+      content <- readFile "input"+      key <- maybe "fresh" id <$> lookupEnv "HENI_BUILD_INPUT_ID"+      Dir.createDirectoryIfMissing True "dist-newstyle"+      exists <- Dir.doesFileExist "dist-newstyle/observed-key"+      old <- if exists then readFile "dist-newstyle/observed-key" else pure "absent"+      length old `seq` pure ()+      if (mode == "unsafe" && exists) || old == key+        then pure ()+        else do+          writeFile "dist-newstyle/compiled" content+          writeFile "dist-newstyle/observed-key" key+      if mode == "generated" then writeFile "unexpected-generated" "unexpected" else pure ()+      if mode == "reject" && content == "mutant" then putStrLn "intended-build-rejection" >> exitWith (ExitFailure 1) else pure ()+    ["incremental-test"] -> do+      compiled <- readFile "dist-newstyle/compiled"+      if compiled == "mutant" then putStrLn "capacity rule violated" >> exitWith (ExitFailure 1) else pure ()+    ["counter", path] -> do+      previous <- readFile path `catchIOError` const (pure "0")+      let n = (read previous :: Int) + 1+      length previous `seq` writeFile path (show n)+      if n > 1 then putStrLn "unchanged control failed" >> exitWith (ExitFailure 1) else pure ()+    ["check", path, mode] -> do+      content <- readFile path+      if "mutant" `isInfixOf` content+        then case mode of+          "diagnostic" -> putStrLn "capacity rule violated" >> exitWith (ExitFailure 1)+          "wrong" -> putStrLn "unrelated failure" >> exitWith (ExitFailure 1)+          "timeout" -> putStrLn "capacity rule violated" >> threadDelay 30000000+          _ -> pure ()+        else pure ()+    ["check", path, "timeout", marker] -> do+      content <- readFile path+      if "mutant" `isInfixOf` content+        then do+          pid <- getProcessID+          writeFile marker (show pid)+          putStrLn "capacity rule violated"+          threadDelay 30000000+        else pure ()+    "echo" : args -> mapM_ print args+    _ -> error "unknown fixture mode"++waitForMarker :: FilePath -> IO ()+waitForMarker marker = do+  ready <- (not . null) <$> (readFile marker `catchIOError` const (pure ""))+  if ready then pure () else threadDelay 5000 >> waitForMarker marker
+ test/migration/Legacy.hs view
@@ -0,0 +1,88 @@+-- | Independently authored finite comparison harnesses, not production runners+-- and not copies of the surveyed repositories. Deliberately exit-only verdicts+-- demonstrate why attributable evidence matters. Never used for hang fixtures.+module Legacy (runLegacy, copyFixture) where++import Control.Exception (bracket)+import Control.Monad (forM_, unless)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text+import System.Directory qualified as Dir+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.Posix.Temp (mkdtemp)+import System.Process qualified as P++-- BEGIN shared-orchestration+copyFixture :: FilePath -> FilePath -> IO ()+copyFixture source destination = do+  Dir.createDirectoryIfMissing True destination+  names <- Dir.listDirectory source+  forM_ names $ \name -> do+    directory <- Dir.doesDirectoryExist (source </> name)+    if directory+      then copyFixture (source </> name) (destination </> name)+      else Dir.copyFile (source </> name) (destination </> name)++withCopy :: FilePath -> (FilePath -> IO a) -> IO a+withCopy source action = bracket (Dir.getTemporaryDirectory >>= \p -> mkdtemp (p </> "legacy-comparison-")) Dir.removePathForcibly $ \root -> copyFixture source root >> action root++command :: FilePath -> String -> [String] -> IO ExitCode+command root exe args = do+  (code, _, _) <- P.readCreateProcessWithExitCode ((P.proc exe args) {P.cwd = Just root}) ""+  pure code++baseline :: ExitCode -> IO ()+baseline code = unless (code == ExitSuccess) (error "comparison fixture baseline failed")++-- END shared-orchestration++runLegacy :: String -> FilePath -> IO Bool+runLegacy "replacement" source = withCopy source $ \root -> do+  -- BEGIN replacement-orchestration+  command root "cabal" ["test", "fixture-test", "--offline", "-j1"] >>= baseline+  -- BEGIN replacement-definition+  text <- Text.readFile (root </> "src/Capacity.hs")+  Text.writeFile (root </> "src/Capacity.hs") (Text.replace "used < limit" "True" text)+  -- END replacement-definition+  code <- command root "cabal" ["test", "fixture-test", "--offline", "-j1"]+  pure (code /= ExitSuccess)+-- END replacement-orchestration+runLegacy "patch-catalogue" source = withCopy source $ \root -> do+  -- BEGIN patch-catalogue-orchestration+  command root "cabal" ["test", "fixture-test", "--offline", "-j1"] >>= baseline+  -- BEGIN patch-catalogue-definition+  command root "git" ["apply", "--check", "patches/guard-bypass.patch"] >>= baseline+  command root "git" ["apply", "patches/guard-bypass.patch"] >>= baseline+  -- END patch-catalogue-definition+  code <- command root "cabal" ["test", "fixture-test", "--offline", "-j1"]+  pure (code /= ExitSuccess)+-- END patch-catalogue-orchestration+runLegacy "generated-code" source = withCopy source $ \root -> do+  -- BEGIN generated-code-orchestration+  command root "runghc" ["tools/Build.hs"] >>= baseline+  command root "cabal" ["test", "fixture-test", "--offline", "-j1"] >>= baseline+  -- BEGIN generated-code-definition+  text <- Text.readFile (root </> "model.txt")+  Text.writeFile (root </> "model.txt") (Text.replace "mode=strict" "mode=open" text)+  -- END generated-code-definition+  built <- command root "runghc" ["tools/Build.hs"]+  if built /= ExitSuccess then pure True else (/= ExitSuccess) <$> command root "cabal" ["test", "fixture-test", "--offline", "-j1"]+-- END generated-code-orchestration+runLegacy "diagnostic-checker" source = withCopy source $ \root -> do+  -- BEGIN diagnostic-checker-orchestration+  command root "runghc" ["tools/Build.hs"] >>= baseline+  command root "./dist-newstyle/checker" [] >>= baseline+  -- BEGIN diagnostic-checker-definition+  text <- Text.readFile (root </> "policy.txt")+  Text.writeFile (root </> "policy.txt") (Text.replace "capacity=10" "capacity=-1" text)+  -- END diagnostic-checker-definition+  (/= ExitSuccess) <$> command root "./dist-newstyle/checker" []+-- END diagnostic-checker-orchestration+-- A finite negative comparison: this is the historical exit-only mistake.+runLegacy "unrelated-compile-failure" source = withCopy source $ \root -> do+  command root "cabal" ["test", "fixture-test", "--offline", "-j1"] >>= baseline+  text <- Text.readFile (root </> "src/Capacity.hs")+  Text.writeFile (root </> "src/Capacity.hs") (Text.replace "used < limit" "missingCapacityGuard" text)+  (/= ExitSuccess) <$> command root "cabal" ["test", "fixture-test", "--offline", "-j1"]+runLegacy _ _ = error "unknown comparison family"
+ test/migration/Main.hs view
@@ -0,0 +1,175 @@+module Main (main) where++import Control.Exception+import Control.Monad (forM, forM_, unless)+import Data.Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.Aeson.Types (Pair)+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as LBS+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text+import Data.Version (showVersion)+import GHC.Clock (getMonotonicTimeNSec)+import Legacy+import Paths_heni (getDataFileName)+import System.Directory qualified as Dir+import System.Environment (getArgs, lookupEnv)+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, takeFileName, (</>))+import System.IO (BufferMode (..), hSetBuffering, stdout)+import System.Info qualified as Platform+import System.Posix.Signals (sigABRT)+import System.Posix.Temp (mkdtemp)+import System.Process (readProcessWithExitCode)+import System.Timeout (timeout)+import Test.Heni+import Test.Heni.Workspace++checked :: (Show e) => Either e a -> a+checked = either (error . show) id++assert' :: String -> Bool -> IO ()+assert' why good = unless good (error why)++jsonField :: Key -> Value -> Value+jsonField key (Object o) = maybe Null id (KM.lookup key o)+jsonField _ _ = Null++set :: Key -> Value -> Value -> Value+set key value (Object o) = Object (KM.insert key value o)+set _ _ other = other++signature :: RunResult -> [(Text, Verdict, Maybe DetectionKind, [(Stage, ProcessOutcome)])]+signature RunResult {results} = [(caseIdText caseId, verdict, detection, [(stage, outcome) | StageEvidence {stage, outcome} <- stages]) | CaseResult {caseId, verdict, detection, evidence = CaseEvidence {stages}} <- results]++main :: IO ()+main = do+  hSetBuffering stdout LineBuffering+  timeout 1200000000 suite >>= \case Nothing -> error "migration watchdog (20 minutes)"; Just () -> pure ()++suite :: IO ()+suite = bracket (Dir.getTemporaryDirectory >>= \p -> mkdtemp (p </> "heni-migration-")) preserveAndRemove $ \root -> do+  cliExecutable <- lookupEnv "HENI_CLI_EXE" >>= maybe (error "set HENI_CLI_EXE to the separately built heni-cli executable") pure+  args <- getArgs+  let families = if "--rich-only" `elem` args then [] else ["replacement", "patch-catalogue", "generated-code", "diagnostic-checker"]+  records <- forM families $ \family -> do+    putStrLn ("migration family: " ++ family)+    sourceManifest <- getDataFileName ("examples/migrations/" ++ family ++ "/heni.json")+    original <- BS.readFile sourceManifest >>= either (error . show) pure . eitherDecodeStrict+    legacyStart <- getMonotonicTimeNSec+    legacyCaught <- runLegacy family (takeDirectory sourceManifest)+    legacyEnd <- getMonotonicTimeNSec+    assert' (family ++ " legacy intended detection") legacyCaught+    legacyWrong <- if family == "replacement" then Just <$> runLegacy "unrelated-compile-failure" (takeDirectory sourceManifest) else pure Nothing+    assert' "exit-only comparison demonstrates unrelated compiler false positive" (family /= "replacement" || legacyWrong == Just True)+    withSnapshot (takeDirectory sourceManifest) defaultSnapshotPolicy $ \snapshot -> withWorkspace snapshot $ \outer -> do+      let source = workspaceRoot outer+          edits path before after = [Replace (checked (mkRelativeTarget path)) before after 1]+          (ident, target, witness) = case family of+            "generated-code" -> ("generated-guard", "model.txt", "generated capacity: FAIL")+            "diagnostic-checker" -> ("invalid-capacity", "policy.txt", "policy.capacity: maximum must be positive")+            _ -> ("guard-bypass", "src/Capacity.hs", "rejects full capacity: FAIL")+          (old, new) = case family of "generated-code" -> ("mode=strict", "mode=open"); "diagnostic-checker" -> ("capacity=10", "capacity=-1"); _ -> ("used < limit", "True")+          base cid change expect = CaseDefinition (checked (mkCaseId cid)) "Fixture-specific migration" Set.empty change (checked (mkCommandId "build")) (Just (checked (mkCommandId "test"))) Nothing expect GenericCommands+          expected = Diagnostic Test 1 witness+      patch <- if family == "patch-catalogue" then Just <$> Text.readFile (source </> "patches/guard-bypass.patch") else pure Nothing+      survivorPatch <- if family == "patch-catalogue" then Just <$> Text.readFile (source </> "patches/survivor.patch") else pure Nothing+      let caught = base ident (maybe (edits target old new) (pure . Patch) patch) expected+          survivor = base "surviving-label" (maybe (edits target "baseline" "variant") (pure . Patch) survivorPatch) expected+          compileError = base "unrelated-compile-error" (edits target "used < limit" "missingCapacityGuard") expected+          rejection = base "expected-build-rejection" (edits target "used < limit" "missingCapacityGuard") (BuildRejection 1 "not in scope")+          portableCases = [caught, survivor] ++ (if family == "replacement" then [compileError, rejection] else [])+          value = set "execution" (object ["workers" .= (2 :: Int), "baselineReuse" .= object ["toolchain" .= String "local GHC/Cabal fixture contract; no external state"]]) (set "cases" (toJSON (map caseDefinitionJSON portableCases)) original)+          manifestPath = source </> "heni.json"+      LBS.writeFile manifestPath (encode value)+      manifest <- readManifest manifestPath >>= either (error . show) pure+      let customGenerated definition = case definition of+            CaseDefinition cid desc tags _ build test selector expect mode ->+              ( CaseDefinition cid desc tags [CustomEdit "generator-input"] build test selector expect mode,+                CaseHooks+                  [ ( "generator-input",+                      \WorkspaceOps {readTarget, writeTarget} -> do+                        let path = checked (mkRelativeTarget "model.txt")+                        content <- readTarget path+                        unless (Text.count "mode=strict" content == 1) (ioError (userError "generator input anchor mismatch"))+                        writeTarget path (Text.replace "mode=strict" "mode=open" content)+                    )+                  ]+                  Nothing+                  Nothing+              )+          typed = [if family == "generated-code" && index == 0 then customGenerated d else (d, noHooks) | (index, d) <- zip [0 :: Int ..] portableCases]+          campaign = checked (mkCampaign (campaignCommands (manifestCampaign manifest)) typed)+      start <- getMonotonicTimeNSec+      library <- runCampaign (defaultRunOptions source (root </> family </> "library")) campaign+      middle <- getMonotonicTimeNSec+      (exit, _, err) <- readProcessWithExitCode cliExecutable ["run", manifestPath, "--jobs", "2", "--output", root </> family </> "cli", "--json"] ""+      end <- getMonotonicTimeNSec+      assert' (family ++ " expected nonpassing survivor exit: " ++ err) (exit == ExitFailure 1)+      cliReport@StoredReport {result = cli} <- readReport (root </> family </> "cli/report.json") >>= either (error . show) pure+      libReport <- readReport (root </> family </> "library/report.json") >>= either (error . show) pure+      assert' (family ++ " typed/manifest semantic parity: " ++ show (signature library, signature cli)) (signature library == signature cli)+      assert' (family ++ " intended/surviving variants") (map (\(_, v, _, _) -> v) (signature library) == [Caught, Survived] ++ (if family == "replacement" then [UnexpectedBuildFailure, Caught] else []))+      let StoredReport {metadata = lm} = libReport; StoredReport {metadata = cm} = cliReport+      assert' "both paths capture identical selected source bytes" (jsonField "identity" (jsonField "snapshot" lm) == jsonField "identity" (jsonField "snapshot" cm))+      whenGenerated family $ do+        output <- BS.readFile (root </> family </> "library/generated-guard-candidate-build.stdout.log")+        assert' "mutated generator input reaches compiled artifact" ("artifact: admits used limit = True" `BS.isInfixOf` output)+      -- Reading a stale schema is a diagnostic action, never a rerun.+      LBS.writeFile (root </> family </> "stale-report.json") (encode (set "schemaVersion" (Number 999) (reportJSON cliReport)))+      stale <- readReport (root </> family </> "stale-report.json")+      assert' "stale report schema is rejected" (case stale of Left _ -> True; _ -> False)+      pure (object ["family" .= family, "legacyIntendedCaught" .= legacyCaught, "legacyUnrelatedCompileAccepted" .= legacyWrong, "legacySeconds" .= seconds legacyStart legacyEnd, "typedFreshSeconds" .= seconds start middle, "cliParallelReuseSeconds" .= seconds middle end, "outcomes" .= [show verdict | CaseResult {verdict} <- case library of RunResult {results} -> results], "typedReport" .= reportJSON libReport, "cliReport" .= reportJSON cliReport])+  rich <- richTrials cliExecutable root+  let output = object ["trialSelection" .= String (if null families then "rich-only" else "complete"), "operator" .= String "automated agent-authored trial; no human usability timing claimed", "platform" .= object ["os" .= Platform.os, "arch" .= Platform.arch, "compilerVersion" .= showVersion Platform.compilerVersion], "families" .= records, "richer" .= rich]+  destination <- lookupEnv "HENI_MIGRATION_RESULTS"+  forM_ destination $ \path -> do+    Dir.createDirectoryIfMissing True (takeDirectory path)+    let evidenceDirectory = takeDirectory path </> takeFileName root+    LBS.writeFile path (encode (set "evidenceDirectory" (String (Text.pack evidenceDirectory)) output))+  putStrLn (if null families then "Richer evidence trial passed" else "Four migration families, typed/CLI parity, precise negatives and richer evidence passed")+  where+    seconds start end = fromIntegral (end - start) / 1e9 :: Double+    whenGenerated family action = if family == "generated-code" then action else pure ()++richTrials :: FilePath -> FilePath -> IO Value+richTrials cliExecutable root = do+  let source = root </> "rich-source"+      build = "build" :: Text+      command args budget = object ["executable" .= String "heni-evidence-fixture", "arguments" .= (args :: [String]), "timeoutSeconds" .= (budget :: Double)]+      namedCommand mode = command ["protocol", "input", mode] 5+      replace old new = object ["kind" .= String "replace", "path" .= String "input", "old" .= (old :: Text), "new" .= (new :: Text)]+      named replay = object ["kind" .= String "named-tests", "failures" .= (["suite/intended"] :: [Text]), "replayRequired" .= replay]+      define :: Text -> Text -> Value -> Value -> [Value] -> [Pair] -> Value+      define cid test expect adapter edits extras = object (["id" .= (cid :: Text), "description" .= String "Combined evidence migration", "build" .= build, "test" .= (test :: Text), "expect" .= expect, "adapter" .= adapter, "edits" .= edits] ++ extras)+      diagnostic = object ["kind" .= String "diagnostic", "stage" .= String "test", "exitCode" .= (1 :: Int), "outputContains" .= String "capacity rule violated"]+      normal cid test = define cid test (named False) (String "heni-v1") [replace "anchor" "mutant"]+      resource :: Text -> Text -> Text -> Value+      resource name setup teardown = object ["name" .= name, "identity" .= String "fixture", "version" .= String "v1", "epoch" .= String "clean", "setup" .= setup, "reset" .= build, "teardown" .= teardown]+      cases = [normal "intended" "intended" [], normal "wrong-named" "wrong" [], normal "empty-selection" "intended" ["selector" .= String "absent"], define "stale-edit" "generic" diagnostic Null [replace "missing" "mutant"] [], define "hang" "hang" diagnostic Null [replace "anchor" "mutant"] [], normal "setup-failed" "intended" ["resources" .= (["SETUP"] :: [Text])], normal "teardown-failed" "intended" ["resources" .= (["TEARDOWN"] :: [Text])], define "replay" "replay" (named True) (String "heni-v1") [replace "anchor" "mutant"] [], define "crash" "crash" (object ["kind" .= String "crash", "signal" .= (fromIntegral sigABRT :: Int), "probe" .= String "operation-reached"]) Null [replace "anchor" "mutant"] [], define "control" "intended" (object ["kind" .= String "control"]) (String "heni-v1") [] []]+      value = object ["schemaVersion" .= (1 :: Int), "execution" .= object ["workers" .= (2 :: Int), "baselineReuse" .= object ["toolchain" .= String "standalone protocol fixture/v1", "adapter" .= String "fixture/v1"]], "commands" .= object ["build" .= command ["exit", "0"] 5, "fail" .= command ["exit", "1"] 5, "intended" .= namedCommand "intended", "wrong" .= namedCommand "wrong", "replay" .= namedCommand "replay", "crash" .= command ["crash", "input", "good"] 5, "generic" .= object ["executable" .= String "heni-fixture", "arguments" .= (["check", "input", "diagnostic"] :: [String])], "hang" .= object ["executable" .= String "heni-fixture", "arguments" .= (["check", "input", "timeout"] :: [String]), "timeoutSeconds" .= (0.5 :: Double)]], "resources" .= [resource ("SETUP" :: Text) ("fail" :: Text) build, resource ("TEARDOWN" :: Text) build ("fail" :: Text)], "cases" .= cases]+  Dir.createDirectory source+  BS.writeFile (source </> "input") "anchor"+  LBS.writeFile (source </> "heni.json") (encode value)+  manifest <- readManifest (source </> "heni.json") >>= either (error . show) pure+  lib <- runCampaign ((manifestRunOptions (root </> "rich-library") manifest) {workers = 1, reusePolicy = Fresh}) (manifestCampaign manifest)+  (exit, _, err) <- readProcessWithExitCode cliExecutable ["run", source </> "heni.json", "--output", root </> "rich-cli", "--json"] ""+  assert' ("rich infrastructure exit: " ++ err) (exit == ExitFailure 2)+  report@StoredReport {result = cli} <- readReport (root </> "rich-cli/report.json") >>= either (error . show) pure+  let issues RunResult {results} = [(caseIdText caseId, infrastructureErrors, cleanupErrors) | CaseResult {caseId, evidence = CaseEvidence {infrastructureErrors, cleanupErrors}} <- results]+  assert' ("richer typed/CLI parity: " ++ show (signature lib, signature cli) ++ "; issues: " ++ show (issues lib, issues cli)) (signature lib == signature cli)+  assert' "richer outcomes preserve named, replay, crash, resource and control meaning" (map (\(_, v, _, _) -> v) (signature lib) == [Caught, WrongFailure, NotRun, InvalidMutation, TimedOut, InfrastructureError, InfrastructureError, Caught, Caught, Caught])+  pure (reportJSON report)++preserveAndRemove :: FilePath -> IO ()+preserveAndRemove root = do+  destination <- lookupEnv "HENI_MIGRATION_RESULTS"+  forM_ destination $ \path -> do+    Dir.createDirectoryIfMissing True (takeDirectory path)+    let evidenceDirectory = takeDirectory path </> takeFileName root+    copyFixture root evidenceDirectory+    putStrLn ("migration evidence retained: " ++ evidenceDirectory)+  Dir.removePathForcibly root
+ test/process/Main.hs view
@@ -0,0 +1,130 @@+module Main (main) where++import Control.Concurrent+import Control.Exception+import Control.Monad (forM_, unless)+import Data.ByteString qualified as BS+import Data.Set qualified as Set+import Data.Text qualified as Text+import System.Directory qualified as Dir+import System.FilePath ((</>))+import System.Posix.Files (createSymbolicLink, fileExist, setFileMode)+import System.Posix.Signals+import System.Posix.Temp (mkdtemp)+import System.Posix.Types qualified+import System.Timeout (timeout)+import Test.Heni+import Test.Heni.Process+import Test.Heni.Workspace++assert' :: String -> Bool -> IO ()+assert' name good = unless good (error name)++checked :: (Show e) => Either e a -> a+checked = either (error . show) id++command :: [String] -> Int -> Command+command args micros = Command "heni-fixture" args Set.empty Nothing [] (checked (mkStageBudget micros)) (OutputPolicy 1024)++main :: IO ()+main = do+  result <- timeout 20000000 suite+  assert' "outer process-suite watchdog" (result == Just ())++suite :: IO ()+suite = bracket (Dir.getTemporaryDirectory >>= \tmp -> mkdtemp (tmp </> "heni-process-test-")) Dir.removePathForcibly $ \root -> do+  Dir.createDirectory (root </> "source")+  withSnapshot (root </> "source") defaultSnapshotPolicy $ \snapshot -> withWorkspace snapshot $ \workspace -> do+    let opts prefix = case defaultProcessOptions (root </> "logs") prefix of+          ProcessOptions d p g _ e observer -> ProcessOptions d p g ["exact-witness"] e observer+        run prefix args micros = runCommand (opts prefix) workspace Test (command args micros)+        outcomeOf ProcessReport {evidence = StageEvidence {outcome}} = outcome+    missing <- runCommand (opts "missing") workspace Test (Command "heni-definitely-absent" [] Set.empty Nothing [] (checked (mkStageBudget 100000)) (OutputPolicy 100))+    assert' "launch failure" (case outcomeOf missing of LaunchFailed {} -> True; _ -> False)+    exited <- run "exit" ["exit", "1"] 1000000+    assert' ("exit differs from signal: " ++ show (outcomeOf exited)) (outcomeOf exited == Exited 1)+    let localCommand = Command "./local-fixture" ["exit", "1"] Set.empty Nothing [] (checked (mkStageBudget 2000000)) (OutputPolicy 1024)+    deferred <- validateExecutable localCommand+    assert' "workspace executable availability is deferred until build" (deferred == Right ())+    installed <- Dir.findExecutable "heni-fixture" >>= maybe (error "missing fixture") pure+    Dir.copyFile installed (workspaceRoot workspace </> "local-fixture")+    setFileMode (workspaceRoot workspace </> "local-fixture") 0o700+    local <- runCommand (opts "workspace-program") workspace Test localCommand+    assert' "built workspace executable runs from owned tree" (outcomeOf local == Exited 1)+    assert' "workspace executable traversal is invalid" (case validateCommand (Command "./../escape" [] Set.empty Nothing [] (checked (mkStageBudget 100000)) (OutputPolicy 100)) of Left _ -> True; _ -> False)+    Dir.removeFile (workspaceRoot workspace </> "local-fixture")+    createSymbolicLink installed (workspaceRoot workspace </> "local-fixture")+    escaped <- runCommand (opts "workspace-symlink") workspace Test localCommand+    assert' "workspace executable symlinks are rejected before launch" (case outcomeOf escaped of LaunchFailed {} -> True; _ -> False)+    Dir.removeFile (workspaceRoot workspace </> "local-fixture")+    signaled <- run "signal" ["signal"] 1000000+    assert' "actual signal" (outcomeOf signaled == Signaled (fromIntegral sigABRT))+    timed <- run "timeout" ["sleep"] 50000+    assert' "witness before timeout" (outcomeOf timed == Timeout)+    assert' "bounded timeout and cleanup" (case timed of ProcessReport {evidence = StageEvidence {durationSeconds}} -> durationSeconds < 2)+    forM_ [1 .. 12 :: Int] $ \n -> do+      repeated <- run ("timeout-race-" ++ show n) ["sleep"] 20000+      assert' ("repeated group disappearance retains clean timeout: " ++ show repeated) (outcomeOf repeated == Timeout && case repeated of ProcessReport {errors} -> null errors)+    literal <- run "literal" ["echo", "space word", "'quotes'", "$HOME", "; touch nope"] 1000000+    assert' "literal argument command succeeds" (outcomeOf literal == Exited 0)+    bytes <- BS.readFile (root </> "logs/literal.stdout.log")+    assert' "arguments preserved literally" (all (`BS.isInfixOf` bytes) ["space word", "'quotes'", "$HOME", "; touch nope"])+    noFile <- fileExist (workspaceRoot workspace </> "nope")+    assert' "no shell interpolation" (not noFile)+    flood <- run "flood" ["flood"] 5000000+    assert' "concurrent drains complete" (outcomeOf flood == Exited 1)+    assert' "witness retained beyond log cap and chunk boundary" (case flood of ProcessReport {preservedWitnesses, logs, evidence = StageEvidence {truncated}} -> preservedWitnesses == ["exact-witness"] && not truncated && all (\LogArtifact {bytesRetained} -> bytesRetained <= 1024) logs && any (\LogArtifact {truncated = t} -> t) logs)+    malformed <- runCommand (case opts "invalid" of ProcessOptions d p g _ e o -> ProcessOptions d p g ["\xfffd"] e o) workspace Test (command ["invalid-utf8"] 1000000)+    assert' "invalid UTF8 cannot fabricate witness" (case malformed of ProcessReport {preservedWitnesses, evidence = StageEvidence {transcript}} -> null preservedWitnesses && Text.null transcript)+    let marker = root </> "descendant.pid"+    descendants <- run "descendant" ["descendant", marker] 2000000+    pid <- read <$> readFile marker+    assert' "leader normal exit preserved" (outcomeOf descendants == Exited 0)+    assertNotRunning pid+    assert' "pipe inheritance bounded" (case descendants of ProcessReport {evidence = StageEvidence {durationSeconds}} -> durationSeconds < 2)+    observerFailed <- runCommand (case opts "observer" of ProcessOptions d p g ws e _ -> ProcessOptions d p g ws e (\_ _ -> ioError (userError "observer failed"))) workspace Test (command ["sleep"] 5000000)+    assert' "observer failure captured" (case observerFailed of ProcessReport {errors} -> not (null errors))+    forM_ [1 .. 3 :: Int] $ \n -> do+      done <- newEmptyMVar+      let cancelMarker = root </> ("cancel-" ++ show n ++ ".pid")+      tid <- forkFinally (run ("cancel-" ++ show n) ["ignore", cancelMarker] 5000000) (putMVar done)+      waitMarker cancelMarker+      cancelPid <- read <$> readFile cancelMarker+      throwTo tid ThreadKilled+      _ <- forkIO (throwTo tid ThreadKilled)+      completed <- timeout 2000000 (takeMVar done)+      let cancelledWithEvidence = case completed of+            Just (Left e) -> case fromException e of+              Just ProcessAborted {} -> True+              Nothing -> False+            _ -> False+      assert' "asynchronous cancellation carries evidence" cancelledWithEvidence+      assertNotRunning cancelPid+    -- Detached processes are outside group supervision. The test owns and+    -- explicitly cleans up this PID; this documents the boundary.+    let detachedMarker = root </> "detached.pid"+    detached <- run "detach" ["detach", detachedMarker] 1000000+    detachedPid <- read <$> readFile detachedMarker+    signalProcess sigKILL detachedPid+    assert' "detached pipe writer reported" (case detached of ProcessReport {errors} -> not (null errors))+  putStrLn "POSIX process outcome, timeout, descendant, cancellation and bounded log checks passed"++waitMarker :: FilePath -> IO ()+waitMarker p = do+  exists <- Dir.doesFileExist p+  if exists+    then do contents <- readFile p; if null contents then threadDelay 5000 >> waitMarker p else pure ()+    else threadDelay 5000 >> waitMarker p++assertNotRunning :: System.Posix.Types.ProcessID -> IO ()+assertNotRunning pid = do+  -- signal 0 may still observe an orphan zombie until the system reaps it.+  -- A second fixture-independent query uses the OS process state via getProcessStatus+  -- only for owned direct children; marker descendants are checked through a+  -- bounded disappearance poll here.+  gone <- timeout 1000000 waitGone+  assert' "owned descendant no longer running" (gone == Just ())+  where+    waitGone = do+      alive <- try (signalProcess nullSignal pid) :: IO (Either IOException ())+      case alive of Left _ -> pure (); Right () -> threadDelay 5000 >> waitGone
+ test/runner/Main.hs view
@@ -0,0 +1,152 @@+module Main (main) where++import Control.Concurrent+import Control.Exception+import Control.Monad (forM_, unless, when)+import Data.ByteString qualified as BS+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Definitions (exampleCampaign)+import Paths_heni (getDataFileName)+import System.Directory qualified as Dir+import System.Environment (lookupEnv)+import System.FilePath (takeDirectory, (</>))+import System.Posix.Temp (mkdtemp)+import System.Timeout (timeout)+import Test.Heni++assert' :: String -> Bool -> IO ()+assert' name good = unless good (error name)++checked :: (Show e) => Either e a -> a+checked = either (error . show) id++main :: IO ()+main = do+  result <- timeout 180000000 suite+  assert' "outer campaign watchdog" (result == Just ())++suite :: IO ()+suite = bracket (Dir.getTemporaryDirectory >>= \tmp -> mkdtemp (tmp </> "heni-runner-test-")) Dir.removePathForcibly $ \root -> do+  fixture <- getDataFileName "examples/minimal"+  original <- BS.readFile (fixture </> "src/Capacity.hs")+  actual <- runCampaign (defaultRunOptions fixture (root </> "real")) exampleCampaign+  assert' "actual Cabal baseline/candidate classifications" (case actual of RunResult {results} -> [v | CaseResult {verdict = v} <- results] == [Caught, Survived, UnexpectedBuildFailure])+  BS.readFile (fixture </> "src/Capacity.hs") >>= assert' "actual source unchanged" . (== original)+  stored <- readReport (root </> "real/report.json")+  assert' "portable report round trip" (case stored of Right StoredReport {result} -> result == actual; _ -> False)+  schemaOutput <- lookupEnv "HENI_SCHEMA_REPORT"+  forM_ schemaOutput $ \path -> do+    Dir.createDirectoryIfMissing True (takeDirectory path)+    Dir.copyFile (root </> "real/report.json") path+  recovered <- recoverReport (root </> "real")+  assert' "journal recovery is explicitly incomplete" (case recovered of Right StoredReport {result = RunResult {status = RunUntrustworthy, results}} -> length results == 3; _ -> False)+  BS.appendFile (root </> "real/journal.jsonl") "{\"sequence\":"+  truncated <- recoverReport (root </> "real")+  assert' "truncated last record tolerated" (case truncated of Right _ -> True; _ -> False)+  Dir.renameDirectory (root </> "real") (root </> "moved")+  moved <- readReport (root </> "moved/report.json")+  assert' "moved report retains artifacts" (case moved of Right _ -> True; _ -> False)+  journal <- BS.readFile (root </> "moved/journal.jsonl")+  BS.writeFile (root </> "moved/journal.jsonl") ("garbage\n" <> journal)+  corrupt <- recoverReport (root </> "moved")+  assert' "interior corruption rejected" (case corrupt of Left _ -> True; _ -> False)+  let selectedId = checked (mkCaseId "case")+      buildId = checked (mkCommandId "build")+      testId = checked (mkCommandId "test")+      budget = checked (mkStageBudget 1000000)+      cmd args = Command "heni-fixture" args Set.empty Nothing [] budget (OutputPolicy 1024)+      source = root </> "source"+      definition = CaseDefinition selectedId "Detect the intended fixture fault" Set.empty [Replace (checked (mkRelativeTarget "input")) "anchor" "mutant" 1] buildId (Just testId) Nothing (Diagnostic Test 1 "capacity rule violated") GenericCommands+      campaign mode c hooks = checked (mkCampaign (Map.fromList [(buildId, cmd ["exit", "0"]), (testId, cmd ["check", "input", mode])]) [(c, hooks)])+  Dir.createDirectory source+  BS.writeFile (source </> "input") "anchor\n"+  forM_ [("caught", "diagnostic", Caught), ("wrong", "wrong", WrongFailure), ("timeout", "timeout", TimedOut), ("survivor", "pass", Survived)] $ \(name, mode, expected) -> do+    observed <- runCampaign (defaultRunOptions source (root </> name)) (campaign mode definition noHooks)+    assert' (name ++ " classification") (case observed of RunResult {results = [CaseResult {verdict}]} -> verdict == expected; _ -> False)+  let invalid = case definition of CaseDefinition i d ts _ b t s e mode -> CaseDefinition i d ts [Replace (checked (mkRelativeTarget "input")) "missing" "bad" 1] b t s e mode+  stale <- runCampaign (defaultRunOptions source (root </> "stale")) (campaign "diagnostic" invalid noHooks)+  assert' "invalid edit distinct" (case stale of RunResult {results = [CaseResult {verdict = InvalidMutation}]} -> True; _ -> False)+  let patch = "diff --git a/input b/input\n--- a/input\n+++ b/input\n@@ -1 +1 @@\n-anchor\n+mutant\n"+      patchedDefinition = case definition of CaseDefinition i d ts _ b t s e mode -> CaseDefinition i d ts [Patch patch] b t s e mode+  available <- Dir.findExecutable "git"+  required <- lookupEnv "HENI_REQUIRE_GIT"+  when (required == Just "1" && available == Nothing) (error "patch validation requires Git")+  case available of+    Nothing -> putStrLn "Git unavailable: optional production patch check skipped"+    Just _ -> do+      patched <- runCampaign (defaultRunOptions source (root </> "patch")) (campaign "diagnostic" patchedDefinition noHooks)+      assert' "production Git path uses supervised patch runner" (case patched of RunResult {results = [CaseResult {verdict = Caught}]} -> True; _ -> False)+  let control = case definition of CaseDefinition i d ts _ b t s _ mode -> CaseDefinition i d ts [] b t s Control mode+      failedControl = checked (mkCampaign (Map.fromList [(buildId, cmd ["exit", "0"]), (testId, cmd ["counter", root </> "control-counter"])]) [(control, noHooks)])+  controlResult <- runCampaign (defaultRunOptions source (root </> "control")) failedControl+  assert' "failed control cannot pass run" (case controlResult of RunResult {status = RunFailed, results = [CaseResult {verdict = WrongFailure}], failures = [ControlFailure {}]} -> True; _ -> False)+  BS.writeFile (source </> "input") "mutant"+  baseline <- runCampaign (defaultRunOptions source (root </> "baseline")) (campaign "diagnostic" definition noHooks)+  assert' "failed baseline prevents candidate" (case baseline of RunResult {results = [CaseResult {verdict = NotRun}], failures = [BaselineFailure {}]} -> True; _ -> False)+  BS.writeFile (source </> "input") "anchor\n"+  let failedObserver = (defaultRunOptions source (root </> "observer")) {progress = \_ -> ioError (userError "observer failed")}+  observer <- runCampaign failedObserver (campaign "diagnostic" definition noHooks)+  assert' "observer failure untrustworthy" (case observer of RunResult {status = RunUntrustworthy} -> True; _ -> False)+  let named = case definition of CaseDefinition i d ts es b t s _ mode -> CaseDefinition i d ts es b t s (NamedTests (Set.singleton "intended") NoReplay) mode+  uncoveredBaseline <- runCampaign (defaultRunOptions source (root </> "empty-selection")) (campaign "diagnostic" named noHooks)+  assert' "absent named selection blocks candidate" (case uncoveredBaseline of RunResult {results = [CaseResult {verdict = NotRun}], failures = [BaselineFailure {}]} -> True; _ -> False)+  let existing = root </> "caught"+  duplicate <- try (runCampaign (defaultRunOptions source existing) (campaign "diagnostic" definition noHooks)) :: IO (Either ConfigurationFailure RunResult)+  assert' "existing output refused" (case duplicate of Left _ -> True; _ -> False)+  -- Interrupt while a real candidate command is running. Finalization preserves+  -- the active and queued case exactly once before rethrowing cancellation.+  let readyPath = root </> "candidate-ready"+  done <- newEmptyMVar+  let second = case definition of CaseDefinition _ d ts es b t s e mode -> CaseDefinition (checked (mkCaseId "queued")) d ts es b t s e mode+      commands = Map.fromList [(buildId, cmd ["exit", "0"]), (testId, cmd ["check", "input", "timeout", readyPath])]+      cancellationCampaign = checked (mkCampaign commands [(definition, noHooks), (second, noHooks)])+      cancelOptions = defaultRunOptions source (root </> "cancelled")+  tid <- forkFinally (runCampaign cancelOptions cancellationCampaign) (putMVar done)+  let waitReady = do exists <- Dir.doesFileExist readyPath; unless exists (threadDelay 5000 >> waitReady)+  waitReady+  throwTo tid ThreadKilled+  finished <- timeout 3000000 (takeMVar done)+  assert' "cancellation rethrown after finalization" (case finished of Just (Left _) -> True; _ -> False)+  partial <- readReport (root </> "cancelled/report.json")+  assert' "interrupted ledger exactly once" (case partial of Right StoredReport {result = RunResult {results, status = RunInterrupted}} -> length results == 2 && all (\CaseResult {verdict} -> verdict == NotRun) results; _ -> False)+  -- Environment values and explicitly sensitive argv are redacted in reports.+  let secretCommand = Command "heni-fixture" ["echo", "private-token"] (Set.singleton 1) Nothing [("TOKEN", "private-token")] budget (OutputPolicy 1024)+      secretCampaign = checked (mkCampaign (Map.fromList [(buildId, secretCommand), (testId, cmd ["check", "input", "diagnostic"])]) [(definition, noHooks)])+  _ <- runCampaign (defaultRunOptions source (root </> "redacted")) secretCampaign+  reportBytes <- BS.readFile (root </> "redacted/report.json")+  assert' "explicit secrets redacted in portable report" (not ("private-token" `BS.isInfixOf` reportBytes))+  cleanupCount <- newIORef (0 :: Int)+  let badCleanup =+        (defaultRunOptions source (root </> "cleanup-failure"))+          { workspaceCleanup = \_ -> do+              n <- atomicModifyIORef' cleanupCount (\n -> (n + 1, n + 1))+              if n == 2 then ioError (userError "consumer cleanup failed") else pure ()+          }+  cleanupFailed <- runCampaign badCleanup (campaign "diagnostic" definition noHooks)+  assert'+    "cleanup failure retains primary candidate evidence"+    ( case cleanupFailed of+        RunResult {status = RunUntrustworthy, results = [CaseResult {verdict = InfrastructureError, evidence = CaseEvidence {stages, cleanupErrors}}]} -> length stages == 2 && not (null cleanupErrors)+        _ -> False+    )+  -- A syntactically complete bad record is corruption, not a truncated suffix.+  originalJournal <- BS.readFile (root </> "caught/journal.jsonl")+  BS.appendFile (root </> "caught/journal.jsonl") "null"+  badLast <- recoverReport (root </> "caught")+  assert' "complete invalid final journal record rejected" (case badLast of Left _ -> True; _ -> False)+  BS.writeFile (root </> "caught/journal.jsonl") originalJournal+  Dir.removeFile (root </> "caught/case-baseline-test.stdout.log")+  missingArtifact <- readReport (root </> "caught/report.json")+  assert' "missing baseline artifact is rejected" (case missingArtifact of Left _ -> True; _ -> False)+  -- Failed final writes still return infrastructure information in memory.+  let sabotage =+        (defaultRunOptions source (root </> "unwritable"))+          { progress = \case+              CaseFinished {} -> Dir.removePathForcibly (root </> "unwritable") >> BS.writeFile (root </> "unwritable") "blocks report directory"+              _ -> pure ()+          }+  diskFailure <- runCampaign sabotage (campaign "diagnostic" definition noHooks)+  assert' "report IO failure outranks caught case" (case diskFailure of RunResult {status = RunUntrustworthy} -> True; _ -> False)+  putStrLn "Real Cabal mutation cycle, lifecycle, portable reports and recovery checks passed"
+ test/scheduler/Main.hs view
@@ -0,0 +1,267 @@+module Main (main) where++import Control.Concurrent+import Control.Exception+import Control.Monad (unless)+import Data.ByteString qualified as BS+import Data.IORef+import Data.List (sort)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Encoding+import System.Directory qualified as Dir+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.FilePath ((</>))+import System.Posix.Files (setFileTimes)+import System.Posix.Temp (mkdtemp)+import System.Timeout (timeout)+import Test.Heni+import Test.Heni.BuildReuse qualified as Build+import Test.Heni.Resource qualified as R+import Test.Heni.Scheduler (runScheduled)+import Test.Heni.Workspace++assert' :: String -> Bool -> IO ()+assert' why good = unless good (error why)++checked :: (Show e) => Either e a -> a+checked = either (error . show) id++modify_ :: IORef a -> (a -> a) -> IO ()+modify_ ref f = atomicModifyIORef' ref (\a -> (f a, ()))++main :: IO ()+main = timeout 120000000 checks >>= \case Nothing -> error "scheduler watchdog"; Just () -> pure ()++checks :: IO ()+checks = bracket (Dir.getTemporaryDirectory >>= \p -> mkdtemp (p </> "heni-scheduler-")) Dir.removePathForcibly $ \root -> do+  let source = root </> "source"+  Dir.createDirectory source+  writeFile (source </> "input") "anchor"+  lifecycle <- newIORef []+  let binding = R.ResourceBinding "fixture" "v1" (Just "clean-1") [("HENI_RESOURCE_DB_TOKEN", "secret-not-reportable")]+      makeProvider name scope failAcquire failRelease =+        R.managedResource+          name+          scope+          (if name == "WORKER" then 2 else 1)+          (\_ -> do modify_ lifecycle (++ [name <> "+"]); if failAcquire then ioError (userError "setup") else pure ((), if name == "DB" then binding else R.ResourceBinding "other" "v1" (Just "clean-1") []))+          (\() -> modify_ lifecycle (++ [name <> "="]) >> pure (if name == "DB" then binding else R.ResourceBinding "other" "v1" (Just "clean-1") []))+          (\() -> do modify_ lifecycle (++ [name <> "-"]); if failRelease then ioError (userError "cleanup") else pure ())+      db = makeProvider "DB" R.CaseScope False False+  withSnapshot source defaultSnapshotPolicy $ \snapshot -> do+    let context = R.ResourceContext "test" snapshot root (const (pure ()))+    R.withResources [db, makeProvider "SECOND" R.CaseScope False False] context $ \leases -> mapM_ (R.prepareResource . snd) leases+    events <- readIORef lifecycle+    assert' "declaration acquisition/reverse release" (events == ["DB+", "SECOND+", "DB=", "SECOND=", "SECOND-", "DB-"])+    writeIORef lifecycle []+    partial <- try (R.withResources [db, makeProvider "SECOND" R.CaseScope True False] context (const (pure ()))) :: IO (Either R.ResourceError ())+    assert' "partial acquisition reports setup" (case partial of Left _ -> True; _ -> False)+    events' <- readIORef lifecycle+    assert' "partial acquisition releases earlier ownership" (events' == ["DB+", "SECOND+", "DB-"])+    both <- try (R.withResources [makeProvider "DB" R.CaseScope False True] context (const (ioError (userError "primary")))) :: IO (Either WorkspaceFinalizationError ())+    assert' "primary plus cleanup errors preserved" (case both of Left (WorkspaceFinalizationError (Just _) _) -> True; _ -> False)+  withSnapshot source defaultSnapshotPolicy $ \snapshot -> do+    writeIORef lifecycle []+    timed <- timeout 10000 (R.withResources [db] (R.ResourceContext "timed" snapshot root (const (pure ()))) (const (threadDelay 1000000)))+    releasedAfterTimeout <- readIORef lifecycle+    assert' "timeout releases acquired callback resources" (timed == Nothing && releasedAfterTimeout == ["DB+", "DB-"])+  usage <- newIORef (0 :: Int, 0 :: Int)+  completed <- newIORef []+  let doWork _ item = bracket_ (modify_ usage (\(n, peak) -> (n + 1, max peak (n + 1)))) (modify_ usage (\(n, peak) -> (n - 1, peak))) (threadDelay 30000 >> modify_ completed (++ [item]))+  runScheduled 3 (Map.fromList [("A", 1), ("B", 1)]) [(1 :: Int, ["A", "B"]), (2, ["B", "A"]), (3, ["A"])] (\_ action -> action) doWork+  (_, peak) <- readIORef usage+  assert' "atomic opposite-order claims never overlap" (peak == 1)+  done <- readIORef completed+  assert' "finite queue completes exactly once" (sort done == [1, 2, 3])+  entered <- newMVar (0 :: Int)+  barrier <- newEmptyMVar+  runScheduled+    2+    Map.empty+    [(1 :: Int, []), (2, [])]+    (\_ action -> action)+    ( \_ _ -> do+        n <- modifyMVar entered (\v -> pure (v + 1, v + 1))+        if n == 2 then putMVar barrier () else pure ()+        readMVar barrier+    )+  let bid = checked (mkCommandId "build")+      tid = checked (mkCommandId "test")+      ids = map (checked . mkCaseId) ["first", "second", "queued"]+      command args = Command "heni-fixture" args Set.empty Nothing [] (checked (mkStageBudget 5000000)) (OutputPolicy 1024)+      definition cid = CaseDefinition cid "resource scheduling" Set.empty [Replace (checked (mkRelativeTarget "input")) "anchor" "mutant" 1] bid (Just tid) Nothing (Diagnostic Test 1 "capacity rule violated") GenericCommands+      campaign = checked (mkCampaign (Map.fromList [(bid, command ["exit", "0"]), (tid, command ["check", "input", "diagnostic"])]) [(definition cid, noHooks) | cid <- ids])+      claims = Map.fromList [(cid, ["DB"]) | cid <- ids]+  paths <- newIORef []+  let options name workers =+        (defaultRunOptions source (root </> name))+          { workers = workers,+            resources = [db],+            resourceClaims = claims,+            workspaceCleanup = \workspace -> modify_ paths (++ [workspaceRoot workspace])+          }+  serial <- runCampaign (options "serial" 1) campaign+  parallel <- runCampaign (options "parallel" 3) campaign+  let outcomes RunResult {results} = map (\CaseResult {caseId, verdict} -> (caseId, verdict)) results+  assert' "serial/parallel verdict and ledger parity" (outcomes serial == outcomes parallel && all ((== Caught) . snd) (outcomes parallel))+  roots <- readIORef paths+  assert' "all baseline/candidate trees are exclusive" (length roots == 12 && Set.size (Set.fromList roots) == 12)+  portable <- readReport (root </> "parallel/report.json")+  assert' "parallel journal/report artifacts remain readable" (case portable of Right _ -> True; _ -> False)+  writeIORef lifecycle []+  let scopedProviders = [makeProvider "DB" R.CampaignScope False False, makeProvider "WORKER" R.WorkerScope False False, makeProvider "CASE" R.CaseScope False False]+  _ <- runCampaign ((options "all-scopes" 2) {resources = scopedProviders, resourceClaims = Map.fromList [(cid, ["DB", "WORKER", "CASE"]) | cid <- ids]}) campaign+  scopeEvents <- readIORef lifecycle+  let occurrences event = length (filter (== event) scopeEvents)+  assert' ("campaign/worker/case acquire and release lifetimes: " ++ show scopeEvents) (occurrences "DB+" == 1 && occurrences "DB-" == 1 && occurrences "WORKER+" == 2 && occurrences "WORKER-" == 2 && occurrences "CASE+" == 3 && occurrences "CASE-" == 3)+  writeIORef lifecycle []+  _ <- runCampaign ((options "worker-acquisition-capacity" 3) {resources = [makeProvider "DB" R.WorkerScope False False]}) campaign+  workerEvents <- readIORef lifecycle+  assert' "worker acquisition itself respects declared capacity" (length (filter (== "DB+") workerEvents) == 1 && length (filter (== "DB-") workerEvents) == 1)+  baselineCount <- newIORef (0 :: Int)+  let observeBaseline (StageFinished _ "baseline" StageEvidence {stage = Test}) = modify_ baselineCount (+ 1)+      observeBaseline _ = pure ()+      reuseOptions name provider count = (options name count) {resources = [provider], reusePolicy = ReuseBaselines "fixture-toolchain/v1" Nothing, progress = observeBaseline}+      runCount name provider count chosen = do+        writeIORef baselineCount 0+        result <- runCampaign (reuseOptions name provider count) chosen+        invocations <- readIORef baselineCount+        pure (result, invocations)+  (reused, count) <- runCount "reuse" db 3 campaign+  assert' "compatible baselines execute exactly once across workers" (count == 1 && outcomes reused == outcomes parallel)+  originalReuseReport <- BS.readFile (root </> "reuse/report.json")+  BS.writeFile (root </> "reuse/report.json") (Encoding.encodeUtf8 (Text.replace "\"ownerCase\":\"first\"" "\"ownerCase\":\"absent\"" (Encoding.decodeUtf8 originalReuseReport)))+  badOwner <- readReport (root </> "reuse/report.json")+  assert' "reuse provenance cannot reference absent producer evidence" (case badOwner of Left _ -> True; _ -> False)+  BS.writeFile (root </> "reuse/report.json") originalReuseReport+  let bid2 = checked (mkCommandId "different-build")+      alter cid select buildId = case definition cid of CaseDefinition i d ts es _ t _ e mode -> CaseDefinition i d ts es buildId t select e mode+      varied =+        checked+          ( mkCampaign+              (Map.fromList [(bid, command ["exit", "0"]), (bid2, command ["echo", "different build option"]), (tid, command ["check", "input", "diagnostic"])])+              [(alter (ids !! 0) Nothing bid, noHooks), (alter (ids !! 1) (Just "different selection") bid, noHooks), (alter (ids !! 2) Nothing bid2, noHooks)]+          )+  (_, differentCount) <- runCount "different-contracts" db 1 varied+  assert' "selector and command/build changes invalidate sharing" (differentCount == 3)+  let unknown = R.managedResource "DB" R.CaseScope 1 (\_ -> pure ((), R.ResourceBinding "fixture" "v1" Nothing [])) (\() -> pure (R.ResourceBinding "fixture" "v1" Nothing [])) (\() -> pure ())+  (_, unknownCount) <- runCount "unknown-epoch" unknown 2 campaign+  assert' "unknown external state disables reuse" (unknownCount == 3)+  epochCounter <- newIORef (0 :: Int)+  let changing =+        R.managedResource+          "DB"+          R.CaseScope+          1+          ( \_ -> do+              n <- atomicModifyIORef' epochCounter (\v -> (v + 1, v + 1))+              let b = R.ResourceBinding "fixture" "v1" (Just (Text.pack (show n))) []+              pure (b, b)+          )+          pure+          (const (pure ()))+  (_, epochCount) <- runCount "changed-epoch" changing 1 campaign+  assert' "changed resource epoch invalidates sharing" (epochCount == 3)+  cleanupCount <- newIORef (0 :: Int)+  writeIORef baselineCount 0+  resetFailed <-+    runCampaign+      ( (reuseOptions "cleanup-invalidates" db 1)+          { workspaceCleanup = \_ -> do+              n <- atomicModifyIORef' cleanupCount (\v -> (v + 1, v + 1)); if n == 2 then ioError (userError "failed reset") else pure ()+          }+      )+      campaign+  afterFailure <- readIORef baselineCount+  assert' "failed cleanup retires cached evidence; next case is fresh" (afterFailure == 2 && case resetFailed of RunResult {results = CaseResult {verdict = InfrastructureError} : CaseResult {verdict = Caught} : _} -> True; _ -> False)+  secretReport <- BS.readFile (root </> "reuse/report.json")+  assert' "resource values and secret comparison never enter reports" (not ("secret-not-reportable" `BS.isInfixOf` secretReport))+  finishedEvents <- newIORef []+  let brokenTeardown = makeProvider "DB" R.CaseScope False True+      observeFinished (CaseFinished CaseResult {caseId, verdict}) = modify_ finishedEvents (++ [(caseId, verdict)])+      observeFinished _ = pure ()+  releasedRun <- runCampaign ((options "final-event-after-release" 2) {resources = [brokenTeardown], progress = observeFinished}) campaign+  finalEvents <- readIORef finishedEvents+  assert' "one final progress event after failing case teardown" (sort finalEvents == sort (outcomes releasedRun) && length finalEvents == 3 && all ((== InfrastructureError) . snd) finalEvents)+  priorEnv <- lookupEnv "HENI_ENV_CONFORMANCE"+  let restoreEnv = maybe (unsetEnv "HENI_ENV_CONFORMANCE") (setEnv "HENI_ENV_CONFORMANCE") priorEnv+  ( do+      setEnv "HENI_ENV_CONFORMANCE" "first-state"+      writeIORef baselineCount 0+      let envObserver event = do+            observeBaseline event+            case event of CaseFinished {} -> setEnv "HENI_ENV_CONFORMANCE" "second-state"; _ -> pure ()+      _ <- runCampaign ((reuseOptions "environment-change" db 1) {progress = envObserver}) campaign+      envCount <- readIORef baselineCount+      assert' "inherited environment contract is compared in memory" (envCount == 2)+    )+    `finally` restoreEnv+  writeFile (source </> "changed-test-or-fixture") "changed input"+  (_, newRunCount) <- runCount "new-snapshot" db 1 campaign+  assert' "a new campaign with changed inputs starts without a verdict cache" (newRunCount == 1)+  Dir.removeFile (source </> "changed-test-or-fixture")+  buildRoots <- newIORef []+  let buildProvider =+        Build.BuildReuse+          "content-key-fixture/v1"+          [checked (mkRelativeTarget "dist-newstyle")]+          (\workspace -> setFileTimes (workspaceRoot workspace </> "input") 0 0)+          ( \workspace expected -> do+              observed <- Text.pack <$> readFile (workspaceRoot workspace </> "dist-newstyle/observed-key")+              pure (if observed == expected then Right () else Left "build did not observe restored/mutated input key")+          )+      alternating mode =+        checked+          ( mkCampaign+              (Map.fromList [(bid, command ["incremental-build", mode]), (tid, command ["incremental-test"])])+              [(case definition cid of CaseDefinition i d ts _ b t sel e m -> CaseDefinition i d ts [Replace (checked (mkRelativeTarget "input")) "anchor" changed 1] b t sel e m, noHooks) | (cid, changed) <- zip ids ["mutant", "safe__", "mutant"]]+          )+      buildOptions name provider = (defaultRunOptions source (root </> name)) {buildReuse = Just provider, workspaceCleanup = \workspace -> modify_ buildRoots (++ [workspaceRoot workspace])}+  incremental <- runCampaign (buildOptions "incremental" buildProvider) (alternating "safe")+  freshAlternating <- runCampaign (defaultRunOptions source (root </> "fresh-alternating")) (alternating "safe")+  assert' "same-size alternating edits with identical coarse mtimes preserve fresh outcomes" (outcomes incremental == outcomes freshAlternating && map snd (outcomes incremental) == [Caught, Survived, Caught])+  seenRoots <- readIORef buildRoots+  assert' "one private incremental candidate tree, separate fresh baselines" (Set.size (Set.fromList seenRoots) == 4)+  unsafe <- runCampaign (buildOptions "unsafe-invalidation" buildProvider) (alternating "unsafe")+  assert' "stale build observations are infrastructure, never detection" (case unsafe of RunResult {results = [CaseResult {verdict = Caught}, CaseResult {verdict = InfrastructureError}, CaseResult {verdict = Caught}]} -> True; _ -> False)+  let rejected =+        checked+          ( mkCampaign+              (campaignCommands (alternating "reject"))+              [(case d of CaseDefinition i desc tags edits b t sel _ mode -> CaseDefinition i desc tags edits b t sel (BuildRejection 1 "intended-build-rejection") mode, noHooks) | d <- campaignCases (alternating "reject")]+          )+      badObservation = case buildProvider of Build.BuildReuse contract dirs prep _ -> Build.BuildReuse contract dirs prep (\_ _ -> pure (Left "unproved compiler input"))+  verifiedRejection <- runCampaign (buildOptions "verified-build-rejection" buildProvider) rejected+  assert' "verified retained compiler rejection keeps expected-build meaning" (map snd (outcomes verifiedRejection) == [Caught, Survived, Caught])+  rejectedBuild <- runCampaign (buildOptions "incremental-build-rejection" badObservation) rejected+  assert' "expected compiler rejection also requires observed input identity" (all ((== InfrastructureError) . snd) (outcomes rejectedBuild))+  let unexpected = case buildProvider of Build.BuildReuse contract dirs prep verify -> Build.BuildReuse contract dirs (\workspace -> prep workspace >> writeFile (workspaceRoot workspace </> "unexpected-generated") "bad") verify+  generated <- runCampaign (buildOptions "generated-input" unexpected) (alternating "safe")+  assert' "undeclared generated state invalidates retained worker" (all ((== InfrastructureError) . snd) (outcomes generated))+  acquired <- newIORef (0 :: Int)+  released <- newIORef (0 :: Int)+  ready <- newEmptyMVar+  enteredCount <- newIORef (0 :: Int)+  let held = R.managedResource "HELD" R.CaseScope 2 (\_ -> modify_ acquired (+ 1) >> pure ((), R.ResourceBinding "held" "v1" Nothing [])) (\() -> pure (R.ResourceBinding "held" "v1" Nothing [])) (\() -> modify_ released (+ 1))+      waitCampaign = checked (mkCampaign (Map.fromList [(bid, command ["sleep"]), (tid, command ["exit", "0"])]) [(definition cid, noHooks) | cid <- ids])+      observe (CaseStarted _) = do n <- atomicModifyIORef' enteredCount (\v -> (v + 1, v + 1)); if n == 2 then putMVar ready () else pure ()+      observe _ = pure ()+  finished <- newEmptyMVar+  thread <- forkFinally (runCampaign ((defaultRunOptions source (root </> "cancel")) {workers = 2, resources = [held], resourceClaims = Map.fromList [(cid, ["HELD"]) | cid <- ids], progress = observe}) waitCampaign) (putMVar finished)+  takeMVar ready+  throwTo thread ThreadKilled+  cancelled <- takeMVar finished+  let wasCancelled = case cancelled of+        Left e -> case fromException e :: Maybe AsyncException of+          Just ThreadKilled -> True+          _ -> False+        Right _ -> False+  assert' "cancellation rethrows" wasCancelled+  a <- readIORef acquired+  r <- readIORef released+  assert' "all acquired resources released on cancellation" (a == 2 && r == a)+  partialReport <- readReport (root </> "cancel/report.json")+  assert' "queued and running IDs finalized exactly once" (case partialReport of Right StoredReport {result = RunResult {results, status}} -> length results == 3 && status == RunInterrupted; _ -> False)+  putStrLn "Scoped ownership, atomic capacities, isolated workers, parity and cancellation passed"
+ test/unit/Main.hs view
@@ -0,0 +1,135 @@+module Main (main) where++import Consumer+import Control.Monad (forM_, unless)+import Data.Set qualified as Set+import Data.Text qualified+import Labels (labelWorks)+import Test.Heni++assert :: String -> Bool -> IO ()+assert name condition = unless condition (error name)++buildOk :: StageEvidence+buildOk = stageEvidence Build (Exited 0) ""++stageEvidence :: Stage -> ProcessOutcome -> String -> StageEvidence+stageEvidence s o text = StageEvidence s o (fromString text) False unknownSelection Nothing 0 []+  where+    fromString = Data.Text.pack++namedFailure :: StageEvidence+namedFailure =+  StageEvidence+    Test+    (Exited 1)+    ""+    False+    (SelectionEvidence (Just 1) (Just 1) (Just 1) intended intended Set.empty [])+    Nothing+    0+    []++intended :: Set.Set Data.Text.Text+intended = Set.singleton "rejects full capacity"++observations :: [StageEvidence] -> CaseEvidence+observations ss = CaseEvidence BaselinePassed Changed ss [] [] Nothing++verdictOf :: CaseDefinition -> CaseEvidence -> Verdict+verdictOf c e = case classifyCase c e of CaseResult {verdict} -> verdict++withSelection :: SelectionEvidence -> StageEvidence -> StageEvidence+withSelection sel (StageEvidence s o txt tr _ r d logs) = StageEvidence s o txt tr sel r d logs++withReplay :: ReplayEvidence -> StageEvidence -> StageEvidence+withReplay r (StageEvidence s o txt tr sel _ d logs) = StageEvidence s o txt tr sel (Just r) d logs++main :: IO ()+main = do+  assert "own label instance" labelWorks+  forM_ definitions (\c -> assert "public construction" (validateCase c == Right ()))+  let matching = observations [buildOk, namedFailure]+      wrongNamed =+        withSelection+          ( SelectionEvidence+              (Just 2)+              (Just 2)+              (Just 2)+              (Set.insert "unrelated" intended)+              (Set.singleton "unrelated")+              Set.empty+              []+          )+          namedFailure+      extraNamed =+        withSelection+          ( SelectionEvidence+              (Just 2)+              (Just 2)+              (Just 2)+              (Set.insert "unrelated" intended)+              (Set.insert "unrelated" intended)+              Set.empty+              []+          )+          namedFailure+      zero = withSelection (SelectionEvidence (Just 1) (Just 0) (Just 0) Set.empty Set.empty intended []) namedFailure+      setup = withSelection (SelectionEvidence (Just 1) (Just 1) (Just 1) intended intended Set.empty ["fixture setup failed"]) namedFailure+      testDiag = stageEvidence Test (Exited 1) "capacity rule violated"+      baselineFailed = CaseEvidence (BaselineFailed "unchanged test failed") Changed [] [] [] Nothing+      replayInfo = ReplayEvidence "seed:42" "size=20" intended+      replayStage = withReplay replayInfo (case namedFailure of StageEvidence _ o txt tr sel r d logs -> StageEvidence Replay o txt tr sel r d logs)+      replayMismatch = withReplay (ReplayEvidence "seed:43" "size=20" intended) replayStage+      scenarios =+        [ ("correct named failure", namedCase, matching, Caught),+          ("wrong named failure", namedCase, observations [buildOk, wrongNamed], WrongFailure),+          ("extra named failure", namedCase, observations [buildOk, extraNamed], WrongFailure),+          ("wrong exit", diagnosticCase, observations [buildOk, stageEvidence Test (Exited 2) "capacity rule violated"], WrongFailure),+          ("diagnostic before timeout", diagnosticCase, observations [buildOk, stageEvidence Test Timeout "capacity rule violated"], TimedOut),+          ("zero tests", namedCase, observations [buildOk, zero], Uncovered),+          ("unknown counts", namedCase, observations [buildOk, testDiag], Uncovered),+          ("skipped intended test", namedCase, observations [buildOk, zero], Uncovered),+          ("semantic compile failure", namedCase, observations [stageEvidence Build (Exited 1) "compile error"], UnexpectedBuildFailure),+          ("intentional compile rejection", buildCase, observations [stageEvidence Build (Exited 1) "unbound variable"], Caught),+          ("unrelated compile rejection", buildCase, observations [stageEvidence Build (Exited 1) "missing package"], WrongFailure),+          ("stale edit", namedCase, CaseEvidence BaselinePassed (EditRejected "anchor missing") [] [] [] Nothing, InvalidMutation),+          ("no-op edit", namedCase, CaseEvidence BaselinePassed Unchanged [] [] [] Nothing, InvalidMutation),+          ("baseline failure", namedCase, baselineFailed, NotRun),+          ("setup failure with named witness", namedCase, observations [buildOk, setup], InfrastructureError),+          ("cleanup failure with named witness", namedCase, CaseEvidence BaselinePassed Changed [buildOk, namedFailure] [] ["reset failed"] Nothing, InfrastructureError),+          ("crash without probe", crashCase, observations [buildOk, stageEvidence Test (Signaled 6) ""], WrongFailure),+          ("crash with probe", crashCase, observations [buildOk, stageEvidence Test (Signaled 6) "capacity-probe: native operation reached"], Caught),+          ("runner kill cannot be crash", crashCase, observations [buildOk, stageEvidence Test Timeout "capacity-probe: native operation reached"], TimedOut),+          ("missing replay", replayCase, matching, WrongFailure),+          ("matching replay", replayCase, observations [buildOk, withReplay replayInfo namedFailure, replayStage], Caught),+          ("replay identity mismatch", replayCase, observations [buildOk, withReplay replayInfo namedFailure, replayMismatch], WrongFailure),+          ("duplicate stage", namedCase, observations [buildOk, namedFailure, namedFailure], InfrastructureError),+          ("diagnostic detection", diagnosticCase, observations [buildOk, testDiag], Caught),+          ("survivor", diagnosticCase, observations [buildOk, stageEvidence Test (Exited 0) ""], Survived),+          ("passing control", controlCase, CaseEvidence BaselinePassed Unchanged [buildOk, stageEvidence Test (Exited 0) ""] [] [] Nothing, Caught),+          ("failing control", controlCase, CaseEvidence BaselinePassed Unchanged [buildOk, testDiag] [] [] Nothing, WrongFailure),+          ("truncated witness", diagnosticCase, observations [buildOk, StageEvidence Test (Exited 1) "capacity rule violated" True unknownSelection Nothing 0 []], InfrastructureError)+        ]+  forM_ scenarios $ \(name, c, e, expected) -> assert (name ++ ": " ++ show (classifyCase c e)) (verdictOf c e == expected)+  let checked = either (error . show) id+      i = case namedCase of CaseDefinition {caseId} -> caseId+      selected = checked (mkSelectedCases [i])+      result = classifyCase namedCase matching+      summary = checked (summarizeRun selected [result] [])+  assert "duplicate results rejected" (case summarizeRun selected [result, result] [] of Left _ -> True; _ -> False)+  assert "missing result accounted" (case checked (summarizeRun selected [] []) of RunResult {results = [CaseResult {verdict = NotRun}]} -> True; _ -> False)+  assert "single semantic score" (case summary of RunResult {score = Just (SemanticScore 1 1 True), status = RunPassed} -> True; _ -> False)+  assert "baseline becomes run failure" (case checked (summarizeRun selected [classifyCase namedCase baselineFailed] []) of RunResult {failures = [BaselineFailure {}], status = RunFailed} -> True; _ -> False)+  assert "control becomes run failure" (case checked (summarizeRun selected [classifyCase controlCase (CaseEvidence BaselinePassed Unchanged [buildOk, testDiag] [] [] Nothing)] []) of RunResult {failures = [ControlFailure {}]} -> True; _ -> False)+  assert "zero denominator" (case checked (summarizeRun selected [classifyCase buildCase (observations [stageEvidence Build (Exited 1) "unbound variable"])] []) of RunResult {score = Nothing} -> True; _ -> False)+  assert "infrastructure precedence" (case checked (summarizeRun selected [classifyCase diagnosticCase (observations [buildOk, stageEvidence Test (Exited 0) ""])] [RunInfrastructure "report failed"]) of RunResult {status = RunUntrustworthy} -> True; _ -> False)+  forM_ ["", "/absolute", "../escape", "a/../b", "a//b", "C:drive", "a\\b", "a/./b"] $ \p ->+    assert ("reject path " ++ p) (case mkRelativeTarget p of Left _ -> True; _ -> False)+  assert "budget zero rejected" (case mkStageBudget 0 of Left _ -> True; _ -> False)+  assert "empty selection rejected" (case mkSelectedCases [] of Left _ -> True; _ -> False)+  assert "duplicate selection rejected" (case mkSelectedCases [i, i] of Left _ -> True; _ -> False)+  -- Every permutation of unique stage evidence classifies identically; duplication+  -- is a protocol error rather than an inflated count.+  assert "stage reordering" (verdictOf namedCase (observations [namedFailure, buildOk]) == Caught)+  putStrLn (show (length scenarios) ++ " classifier scenarios and ledger/API invariants passed")
+ test/workspace/Main.hs view
@@ -0,0 +1,142 @@+module Main (main) where++import Control.Concurrent (threadDelay)+import Control.Exception+import Control.Monad (forM_, unless, when)+import Data.ByteString qualified as BS+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as Text+import System.Directory qualified as Dir+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.Posix.Files qualified as Posix+import System.Posix.Temp (mkdtemp)+import System.Process qualified as Process+import System.Timeout (timeout)+import Test.Heni+import Test.Heni.Edit+import Test.Heni.Workspace++assert' :: String -> Bool -> IO ()+assert' message condition = unless condition (error message)++checked :: (Show e) => Either e a -> a+checked = either (error . show) id++target :: FilePath -> RelativeTarget+target = checked . mkRelativeTarget++caseWith :: [Edit] -> CaseDefinition+caseWith edits =+  CaseDefinition+    (checked (mkCaseId "workspace-test"))+    "Test checked mutations"+    Set.empty+    edits+    (checked (mkCommandId "build"))+    (Just (checked (mkCommandId "test")))+    Nothing+    (Diagnostic Test 1 "witness")+    GenericCommands++withTree :: (FilePath -> IO a) -> IO a+withTree action = bracket (Dir.getTemporaryDirectory >>= \tmp -> mkdtemp (tmp </> "heni-test-")) Dir.removePathForcibly action++-- Test-only bounded helper until EP-3 supplies the production supervisor. Git+-- doesn't spawn descendants for apply; timeout cancels its process bracket.+gitRunner :: PatchRunner+gitRunner workspace args = do+  let root = workspaceRoot workspace+  result <- timeout 5000000 (Process.readCreateProcessWithExitCode (Process.proc "git" ("-C" : root : args)) "")+  pure $ case result of+    Just (ExitSuccess, _, _) -> Right ()+    Just (_, out, err) -> Left (Text.pack (out ++ err))+    Nothing -> Left "git apply timeout"++main :: IO ()+main = withTree $ \source -> do+  BS.writeFile (source </> "dirty.txt") "anchor\n"+  BS.writeFile (source </> "untracked.bin") (BS.pack [0, 255, 1])+  Dir.createDirectory (source </> "dist-newstyle")+  BS.writeFile (source </> "dist-newstyle/ignored") "build"+  Dir.createDirectory (source </> "mutation-results")+  BS.writeFile (source </> "mutation-results/report.json") "ignored"+  withSnapshot source defaultSnapshotPolicy $ \snapshot -> do+    let files = snapshotFiles snapshot+        edit = Replace (target "dirty.txt") "anchor" "changed" 1+        run edits = withEditedWorkspace gitRunner noHooks snapshot (caseWith edits) (\w e -> (,) <$> BS.readFile (workspaceRoot w </> "dirty.txt") <*> pure e)+    assert' "selected dirty/untracked bytes" (Map.size files == 2 && Map.member (target "untracked.bin") files)+    assert' "SHA256 identity" (Text.length (snapshotIdentity snapshot) == 64)+    withSnapshot source defaultSnapshotPolicy $ \again -> assert' "deterministic identity" (snapshotIdentity again == snapshotIdentity snapshot)+    result <- run [edit]+    assert' "checked replacement and touched inventory" (case result of Right ("changed\n", EditEvidence [FileChange {}]) -> True; _ -> False)+    original <- BS.readFile (source </> "dirty.txt")+    assert' "source untouched" (original == "anchor\n")+    withWorkspace snapshot $ \other -> do+      originalOther <- BS.readFile (workspaceRoot other </> "dirty.txt")+      assert' "other workspace pristine" (originalOther == "anchor\n")+    forM_ [[Replace (target "dirty.txt") "missing" "bad" 1], [Replace (target "dirty.txt") "anchor" "x" 2], [edit, Replace (target "dirty.txt") "missing" "x" 1], [edit, Replace (target "dirty.txt") "changed" "anchor" 1]] $ \edits -> do+      failed <- run edits+      assert' "stale/ambiguous/partial/no-op rejected" (case failed of Left InvalidEdit {} -> True; _ -> False)+    malformed <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [Replace (target "untracked.bin") "x" "y" 1]) (\_ _ -> pure ())+    assert' "UTF8 error is invalid edit" (case malformed of Left InvalidEdit {} -> True; _ -> False)+    -- No callback is published on partial failure.+    published <- newIORef False+    _ <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [edit, Replace (target "dirty.txt") "missing" "bad" 1]) (\_ _ -> writeIORef published True)+    readIORef published >>= assert' "partial candidate unpublished" . not+    created <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [Create (target "new.txt") "created", Delete (target "dirty.txt") "anchor\n"]) $ \_ (EditEvidence changes) -> pure (length changes)+    assert' "creation/deletion recorded" (created == Right 2)+    duplicateCreate <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [Create (target "dirty.txt") "created"]) (\_ _ -> pure ())+    assert' "creation precondition" (case duplicateCreate of Left InvalidEdit {} -> True; _ -> False)+    let control = case caseWith [] of CaseDefinition i d ts es b t s _ mode -> CaseDefinition i d ts es b t s Control mode+    controlResult <- withEditedWorkspace gitRunner noHooks snapshot control (\_ (EditEvidence changes) -> pure (null changes))+    assert' "unchanged control accepted" (controlResult == Right True)+    withWorkspace snapshot $ \w -> do+      let WorkspaceOps {writeTarget} = workspaceOperations w+      writeTarget (target "dirty.txt") "changed"+      differs <- try (verifyWorkspace snapshot w) :: IO (Either WorkspaceError ())+      assert' "verification detects changes" (case differs of Left WorkspaceMismatch {} -> True; _ -> False)+      resetWorkspace snapshot w+      verifyWorkspace snapshot w+      withTree $ \outside -> do+        BS.writeFile (outside </> "victim") "safe"+        Posix.createSymbolicLink (outside </> "victim") (workspaceRoot w </> "escape")+        rejected <- try (writeTarget (target "escape") "bad") :: IO (Either WorkspaceError ())+        assert' "symlink write rejected" (case rejected of Left UnsafeSource {} -> True; _ -> False)+        BS.readFile (outside </> "victim") >>= assert' "outside bytes untouched" . (== "safe")+      -- A deleted owned root produces an observable reset failure.+      Dir.removePathForcibly (workspaceRoot w)+      reset <- try (resetWorkspace snapshot w) :: IO (Either IOException ())+      assert' "reset failure invalidates workspace" (case reset of Left _ -> True; _ -> False)+    let patch = "diff --git a/dirty.txt b/dirty.txt\n--- a/dirty.txt\n+++ b/dirty.txt\n@@ -1 +1 @@\n-anchor\n+patched\n"+    available <- Dir.findExecutable "git"+    required <- lookupEnv "HENI_REQUIRE_GIT"+    when (required == Just "1" && available == Nothing) (error "patch validation requires Git")+    case available of+      Nothing -> putStrLn "Git unavailable: optional positive patch checks skipped"+      Just _ -> do+        patched <- run [Patch patch]+        assert' "textual patch" (case patched of Right ("patched\n", _) -> True; _ -> False)+        stalePatch <- run [Patch (patch <> "diff --git a/missing b/missing\n--- a/missing\n+++ b/missing\n@@ -1 +1 @@\n-old\n+new\n")]+        assert' "partial patch discarded" (case stalePatch of Left InvalidEdit {} -> True; _ -> False)+        again <- run [edit]+        assert' "patch failure cannot contaminate next case" (case again of Right ("changed\n", _) -> True; _ -> False)+    unsafePatch <- run [Patch (Text.replace "--- a/dirty.txt" "--- a/../escape" patch)]+    assert' "all patch header paths checked" (case unsafePatch of Left InvalidEdit {} -> True; _ -> False)+    missingGit <- withEditedWorkspace (\_ _ -> throwIO (userError "git is unavailable")) noHooks snapshot (caseWith [Patch patch]) (\_ _ -> pure ())+    assert' "missing Git infrastructure capability" (case missingGit of Left EditInfrastructure {} -> True; _ -> False)+    let hooks = CaseHooks [("cancel", \_ -> threadDelay 10000000)] Nothing Nothing+    cancelled <- timeout 20000 (withEditedWorkspace gitRunner hooks snapshot (caseWith [CustomEdit "cancel"]) (\_ _ -> pure ()))+    assert' "cancellation bounded" (case cancelled of Nothing -> True; _ -> False)+    withWorkspace snapshot (verifyWorkspace snapshot)+    validateOutputLocation source defaultSnapshotPolicy (source </> "mutation-results")+    invalidOutput <- try (validateOutputLocation source defaultSnapshotPolicy (source </> "new-results")) :: IO (Either WorkspaceError ())+    assert' "nested output exclusion required" (case invalidOutput of Left _ -> True; _ -> False)+  withTree $ \outside -> do+    Posix.createSymbolicLink outside (source </> "source-link")+    rejected <- try (withSnapshot source defaultSnapshotPolicy (\_ -> pure ())) :: IO (Either WorkspaceError ())+    assert' "source symlink rejected" (case rejected of Left UnsafeSource {} -> True; _ -> False)+  putStrLn "Workspace isolation, transactional edits, patch and recovery checks passed"