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 +13/−0
- LICENSE +27/−0
- README.md +82/−0
- bench/campaign/Main.hs +56/−0
- docs/adr/1-framework-neutral-core.md +21/−0
- docs/adr/10-source-archive-release-boundary.md +33/−0
- docs/adr/2-evidence-owned-classification.md +25/−0
- docs/adr/3-immutable-snapshots-and-staged-edits.md +23/−0
- docs/adr/4-posix-process-ownership.md +34/−0
- docs/adr/5-campaign-ledger-and-report-trust.md +27/−0
- docs/adr/6-explicit-portable-wire-boundaries.md +24/−0
- docs/adr/7-attempt-bound-test-evidence.md +27/−0
- docs/adr/8-scoped-resources-and-conservative-reuse.md +37/−0
- docs/adr/9-migration-proof-and-domain-ownership.md +34/−0
- docs/cli.md +75/−0
- docs/design/adapters.md +56/−0
- docs/design/api-contract.md +60/−0
- docs/design/classification.md +51/−0
- docs/design/compatibility.md +112/−0
- docs/design/process-supervision.md +50/−0
- docs/design/reports.md +68/−0
- docs/design/scheduling.md +116/−0
- docs/design/workspaces.md +58/−0
- docs/initial-spec.md +357/−0
- docs/migration-guide.md +90/−0
- docs/quickstart.md +54/−0
- docs/releasing.md +71/−0
- docs/research/ecosystem-mutation-testing-design.md +228/−0
- docs/research/index.md +12/−0
- docs/research/log.md +8/−0
- docs/research/mutation-testing-survey.md +302/−0
- docs/troubleshooting.md +77/−0
- docs/validation/migration-measurements.json +270/−0
- docs/validation/migration-results.md +111/−0
- docs/validation/migration-source-identities.json +56/−0
- examples/adapters/README.md +8/−0
- examples/adapters/hspec.json +38/−0
- examples/adapters/input +1/−0
- examples/adapters/property.json +40/−0
- examples/adapters/tasty.json +38/−0
- examples/library/Definitions.hs +33/−0
- examples/library/Main.hs +12/−0
- examples/migrations/README.md +11/−0
- examples/migrations/diagnostic-checker/LICENSE +27/−0
- examples/migrations/diagnostic-checker/heni.json +38/−0
- examples/migrations/diagnostic-checker/policy.txt +3/−0
- examples/migrations/diagnostic-checker/tools/Build.hs +10/−0
- examples/migrations/diagnostic-checker/tools/Check.hs +16/−0
- examples/migrations/generated-code/LICENSE +27/−0
- examples/migrations/generated-code/cabal.project +1/−0
- examples/migrations/generated-code/generated-fixture.cabal +16/−0
- examples/migrations/generated-code/heni.json +45/−0
- examples/migrations/generated-code/model.txt +2/−0
- examples/migrations/generated-code/test/Main.hs +7/−0
- examples/migrations/generated-code/tools/Build.hs +18/−0
- examples/migrations/patch-catalogue/LICENSE +27/−0
- examples/migrations/patch-catalogue/cabal.project +1/−0
- examples/migrations/patch-catalogue/heni-minimal-fixture.cabal +17/−0
- examples/migrations/patch-catalogue/heni.json +49/−0
- examples/migrations/patch-catalogue/patches/guard-bypass.patch +12/−0
- examples/migrations/patch-catalogue/patches/survivor.patch +12/−0
- examples/migrations/patch-catalogue/src/Capacity.hs +7/−0
- examples/migrations/patch-catalogue/test/Main.hs +15/−0
- examples/migrations/replacement/LICENSE +27/−0
- examples/migrations/replacement/cabal.project +1/−0
- examples/migrations/replacement/heni-minimal-fixture.cabal +17/−0
- examples/migrations/replacement/heni.json +51/−0
- examples/migrations/replacement/src/Capacity.hs +7/−0
- examples/migrations/replacement/test/Main.hs +15/−0
- examples/minimal/cabal.project +1/−0
- examples/minimal/heni-minimal-fixture.cabal +17/−0
- examples/minimal/heni.json +97/−0
- examples/minimal/src/Capacity.hs +7/−0
- examples/minimal/test/Main.hs +15/−0
- examples/standalone/Main.hs +22/−0
- examples/standalone/consumer.cabal +18/−0
- examples/standalone/target/Checker.hs +11/−0
- examples/standalone/target/heni.json +79/−0
- examples/standalone/target/input +1/−0
- heni.cabal +327/−0
- schemas/manifest-v1.schema.json +680/−0
- schemas/report-v1.schema.json +735/−0
- schemas/test-evidence-v1.schema.json +691/−0
- scripts/check-schemas.py +59/−0
- scripts/check-sdist.py +236/−0
- scripts/check-sdist.sh +3/−0
- scripts/measure-migration.py +124/−0
- scripts/test-migrations.sh +8/−0
- src/Test/Heni.hs +23/−0
- src/Test/Heni/Adapter.hs +360/−0
- src/Test/Heni/BuildReuse.hs +100/−0
- src/Test/Heni/Case.hs +55/−0
- src/Test/Heni/Command.hs +36/−0
- src/Test/Heni/Crash.hs +71/−0
- src/Test/Heni/Edit.hs +156/−0
- src/Test/Heni/Expectation.hs +12/−0
- src/Test/Heni/Internal/Baseline.hs +34/−0
- src/Test/Heni/Internal/Codec.hs +380/−0
- src/Test/Heni/Internal/Files.hs +130/−0
- src/Test/Heni/Internal/Model.hs +278/−0
- src/Test/Heni/Internal/Prelude.hs +6/−0
- src/Test/Heni/Internal/Process/Posix.hs +78/−0
- src/Test/Heni/Manifest.hs +237/−0
- src/Test/Heni/Process.hs +224/−0
- src/Test/Heni/Replay.hs +19/−0
- src/Test/Heni/Report.hs +187/−0
- src/Test/Heni/Resource.hs +172/−0
- src/Test/Heni/Result.hs +143/−0
- src/Test/Heni/Runner.hs +615/−0
- src/Test/Heni/Scheduler.hs +64/−0
- src/Test/Heni/Workspace.hs +142/−0
- test/adapter/Main.hs +91/−0
- test/api/Consumer.hs +63/−0
- test/api/Labels.hs +15/−0
- test/evidence-fixture/Main.hs +83/−0
- test/fixture/Main.hs +97/−0
- test/migration/Legacy.hs +88/−0
- test/migration/Main.hs +175/−0
- test/process/Main.hs +130/−0
- test/runner/Main.hs +152/−0
- test/scheduler/Main.hs +267/−0
- test/unit/Main.hs +135/−0
- test/workspace/Main.hs +142/−0
+ 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"