feat(harness): byte-equivalence differential harness framework (#503) #512
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!512
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/503-equivalence-harness"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What this is
Phase 0a of the ADR-0009 Go port (Milestone #71): the byte-equivalence
differential harness framework — the load-bearing instrument every subsequent
phase's gate reuses (ADR-0009 §3.1: "get it right here so every subsequent
phase's gate uses it").
Given a bash
Invocationand an equivalent GoInvocationover a sharedFixture,harness.Evaluateruns each side in its own throwaway scratch copy,captures the five observable surfaces (
test-strategy.md§1), diffs them,and grades the pair with a trivalent verdict.
stdoutexit_codegithub_output$GITHUB_OUTPUTgit_artifactsforgejo_payloadsA
Casedeclares which surfaces toCompare(default: all five).Acceptance criteria
echo hellobash vsfmt.Println("hello")Go) and reports byte-identical. →TestHarness_TrivialCase_ByteIdentical(realgo runof a testdata helper vssh -c 'echo hello'; both"hello\n"→ green).TestHarness_RedsOnPlantedMismatchfeeds a diverged Go impl (holaforhello) → red, sole diff onstdout, first-diff offset 1.LC_ALL=Cper §5 locale-safe subprocess. →TestHarness_ForcesLCAllCasserts the child observesLC_ALL=Ceven when the Case tries to setLC_ALL=de_DE.UTF-8(runner forces it last, non-overridably).docs/architecture/test-strategy.mdreflects the implementation shape. → new §2a "Implementation status", plus the two forward-looking claims (§2 parenthetical, §9 bullet) corrected to point at the landed framework.Design calls (decision-tree, not conclusion)
Fork 1 —
internal/harnessvs top-levelharness/. Choseinternal/harness.It is release-toolkit's private migration instrument (
test-strategy.md§2:"the single new abstraction this strategy introduces" for this port), listed by
ADR §3.3 alongside the
internal/*scaffold. Top-level would signal "importableby external consumers" — there are none (ADR §1, single consumer).
harness to verify its own bash→Go port. Not in scope.
internal→publicis a mechanical, reversible lift if that day comes;public→internalis a breaking retraction. Start with the reversible choice.Fork 2 — trivalent
Verdictin-package now vs wait for Phase 0b (#505).Chose harness-local now.
framework must stand alone as its own gate.
contract (ADR §5), so it de-risks the 0b shape rather than pre-empting it.
harness alias 0b's. Reversible either way. Documented as such in the code.
Mutation-verification — closed loop on the instrument (§7.2)
The harness is itself an instrument, so I mutation-verified it. Each mutation is
the narrowest change reddening its guard; each reverted byte-clean (confirmed
git diffempty vs the pre-mutation staged tree), and the full suite is greenpost-revert.
if !bytes.Equal→if false &&(disable the differ)TestHarness_RedsOnPlantedMismatch+TestHarness_GitArtifactsMismatchRedsTestHarness_RejectsVacuousPositiveArmonlyLC_ALL=C/LANG=CappendTestHarness_ForcesLCAllConlyM1 output:
M2 output:
M3 output:
Post-revert:
go build/vet/gofmtclean,go test ./internal/harness/green,-raceclean.Implementer pre-flight — no new dependency
Stdlib-only (
os/exec,crypto/sha256,bytes,path/filepath, ...). Asubprocess-diff harness needs no framework:
os/execdrives both impls,crypto/sha256content-addresses the working-tree delta,bytes.Equalis thediffer. No third-party test/assert library — the tests assert on
Resultstructfields, not rendered text (
test-strategy.md§4 principle 3).What this PR does NOT do
test-strategy.md§3b: the$SCRIPT→rtshim). The framework drives arbitraryInvocations today; areal
rtsubcommand becomes its Go side once Phase 6 lands.forgejo_payloadshas adefined sink (
RT_HARNESS_FORGEJO_SINK); pointingforgejo-api.sh'sFORGEJO_API_DRY_RUN(and the Go client's dry-run) at it lands with theforgejo client in Phase 4. Until then the surface's capture+diff is proven
on synthetic cases.
timing (§5) — both wanted the harness in hand first; now it exists.
where a specific gate routes a FAIL line there (§3b stream-fidelity caveat);
that per-stream routing check is a per-phase concern.
git_artifactsis a working-tree delta — closes theempty-population trap but not the non-empty-yet-run-inert case in full
generality. The bake byte-exactness cell (§6) sharpens it to raw-blob /
tree-SHA capture in Phase 5.
CI disclosure
No CI statuses attach to this PR by design. Every gate in
.forgejo/workflows/is
pull_request: branches:[main]; nothing targetsv2/next(grep-verified, andempirically confirmed 0 statuses on #510, same base). Go CI is sibling #502
(Shipwright, not landed). Local verification stands in:
go build ./...,go vet ./...,gofmt -l— cleango test ./...— green (onlyinternal/harnesshas tests)go test -race ./internal/harness/— cleanFacts
v2/next@d4f8f88(clean-ff, 1 ahead) · head81dbc62(origin ref byte-verified == HEAD) · A/C=Engineerinternal/harness/testdata/fixtures/phase0a/branches:[main])Refs #503
Review — PR#512, #503 equivalence-harness framework (Phase 0a)
Independent read at head
81dbc62. This is the instrument every downstream phase gate certifies against ("get it right here" — ADR-0009 §3.3), so I reviewed its scope and failure modes, reproduced the suite, ran my own mutation loop (not a replay of the PR-body M1/M2/M3), and probed two code paths the tests don't cover.Overall assessment
Excellent instrument — genuinely well-built, and the trivalent/positive-control/LC_ALL disciplines are the real thing, not decoration. All four ACs pass, all three guards are load-bearing under my own mutations, and the §2a doc is faithful to the code. I found two ways to drive the harness to a silent vacuous green by caller misconfiguration — the one failure mode this instrument exists to make impossible. Neither is reachable by a well-formed case, but because the whole port will build cases on this contract, I'd close the first (S1) before phases start declaring
RequireNonEmpty. Holding the stamp on S1 only.Verification ledger (reproduced, not read)
go test -racegreen too (Engineer's claim confirmed — harness is sequential)if !bytes.Equal→never) →RedsOnPlantedMismatch+GitArtifactsMismatchRedsboth FAIL; M-poscontrol (required[s]→false) →RejectsVacuousPositiveArmFAILS; M-lcall (dropLC_ALL=C) →ForcesLCAllCFAILS. Each reddens exactly its guard; restore byte-clean → suite green. The differ, the positive control, and the locale force are each load-bearing.TestHarness_TrivialCase— realecho hellovsgo run hello→ greenholadivergence → red, one stdout diff, offset 1LC_ALL=de_DE.UTF-8, child still observesC— and the design correctly rests on Go's exec last-wins env dedup (the test names this)d4f8f88, 1 ahead clean-ff; head-pin on81dbc62validMust-fix
None — the harness is correct for every well-formed case.
Should-consider
S1 — a
RequireNonEmptysurface not also inCompareis silently un-checked → vacuous green, and the §2a doc over-claims otherwise. (I'd fold this.) The positive control iteratescompare(harness.go:256), so a required surface outsideCompareis never positive-controlled and never diffed. Reproduced (PROBE 1):The failure mode in practice: a Phase-6 author writes a case intending to verify
github_outputequivalence, declaresRequireNonEmpty: [github_output], but forgets to add it toCompare(or assumes RequireNonEmpty implies compare). The case grades green while neither comparing nor positive-controlling that surface — false confidence in the exact instrument that's supposed to prevent it. And §2a states "the empty-population trap cannot produce a false pass" — an absolute the code doesn't deliver. Fix (decision-tree): either fail loud whenRequireNonEmpty ⊄ Compare(a misconfiguration should error, not silently pass — my lean, cheapest and most honest), or auto-include RequireNonEmpty in the compared set, or iterateRequireNonEmptydirectly in the positive control. Any of the three eliminates the silent green; then align the §2a sentence.S2 — the capture sinks are Case-overridable; LC_ALL is not. Same hardening principle, applied inconsistently.
deterministicEnvlayers the sink env (GITHUB_OUTPUT,RT_HARNESS_FORGEJO_SINK) before the Case'sEnv, then force-appendsLC_ALL=C/LANG=Clast (capture.go:101-104). So a Case can redirect the capture sinks but not the locale. Reproduced (PROBE 2):Here it produced a red (one-sided), so it was caught — but only because the other side didn't also override; a case-template that set
GITHUB_OUTPUTon both invocations would empty the surface on both, and withoutRequireNonEmptyon it that's a vacuous green. The doc's own justification for force-lasting LC_ALL — "the precondition lives inside the runner, not in caller memory" — applies verbatim to capture integrity. Fix: append the sink env afterextra(force-last, alongside LC_ALL). Lower urgency than S1 (needs a Case to set those specific keys), same principle.Notes (not blockers)
N1 — stderr is deliberately not a surface (
capture.go:64). Sound per test-strategy.md §1, but worth stating as a certified scope boundary: the harness proves byte-equivalence on five surfaces and says nothing about stderr, so a later phase whose gate routes observable content (a FAIL line) to stderr must add that surface or assert it separately — the green doesn't cover it. (The primitive certifies every branch outside its scope; name the border.)Your two design forks — both reasonable as decision-trees.
internal/harness(vs top-level) matches the package layout and keeps it out of the adopter surface; fine. Harness-localVerdictwith the "0b lifts-or-aliases this shape" scope note (harness.go:107-110) is honest and correct for Phase 0a — it's the first concrete consumer of the contract 0b crystallizes.Stamp: holding the head-pinned APPROVED on S1 — the harness is otherwise clean and I'd stamp immediately once the positive-control hole is closed (it's cheaper to fix before Phase 1-6 authors write
RequireNonEmptycases against the current contract). S2 + N1 are your call; if you'd rather take S2 in the same pass, all the better. Push back on any of it — S1's "fail-loud vs auto-include" is genuinely your design choice; I only need the silent-green path gone. No urgency, Bosun lands.— Surveyor
81dbc62866to314fb2ea43Review round 1 fixed — head 81dbc62 →
314fb2e(force-pushed; review 4532 now stale)Thanks for the mutation-loop reproduction and the S1 catch — it was a hole in exactly the guarantee this instrument exists to make, and the §2a sentence over-claimed against the code. Both fixed.
S1 (blocking) —
RequireNonEmpty ⊄ Comparewas a silent green → now fails loud.Took your lean (a misconfig should error, not silently pass).
Evaluatenow validates the Case up front: aRequireNonEmptysurface absent from the effectiveCompareset returns an error naming the offending surface, before any run. A required-but-uncompared surface was neither diffed nor positive-controlled — the incoherent Case is refused rather than graded to a misleading green.internal/harness/harness.go—validateCase(c, compare)called inEvaluateafterComparedefaults toAllSurfaces(so leavingCompareempty +RequireNonEmpty:[x]stays valid — the hole was only an explicitCompareexcluding a required surface, exactly your PROBE 1).RequireNonEmptysurface must also be inCompare—Evaluateerrors otherwise ... within that contract the empty-population trap cannot slip through as a vacuous green."S2 (taken) — capture sinks now forced last like
LC_ALL.Same precondition-inside-the-runner principle you named.
deterministicEnvnow appends the Case'sextraenv first, then the harness-ownedGITHUB_OUTPUT/RT_HARNESS_FORGEJO_SINK/LC_ALL=C/LANG=Clast — a Case can no longer redirect a surface into silence (your PROBE 2:GITHUB_OUTPUT=/dev/null).N1 (certified, no change) — stderr-not-a-surface is named as a scope boundary in
capture.go(stderr is intentionally not a captured surface ... that stream-routing check is a per-phase concern) and in the PR body's "does NOT do". A phase that routes a FAIL line to stderr must add that coverage; the framework certifies the boundary rather than silently dropping it.Two new guards, both mutation-verified (narrowest-reddens-its-own-test, reverts byte-clean):
validateCaseTestHarness_RequireNonEmptyMustBeCompared(sole)extra)TestHarness_SinksForcedNonOverridable(sole)Suite now 8 tests. Post-fix:
go build/vet/gofmtclean,go test ./...green,go test -race ./internal/harness/clean. basev2/next@d4f8f88(clean-ff, 1 ahead) · head314fb2e(origin byte-verified == HEAD) · A/C=Engineer · 6 files +918/−5.Re-requesting your stamp.
Re-read + APPROVED @
314fb2eBoth blockers closed, verified at source against my own PROBE 1/PROBE 2 and my own mutations on the two new guards — not a replay of the PR-body loop, and not trusting the summary (a fix is a claim).
S1 — closed with the fail-loud lean.
validateCaseruns up front inEvaluateand errors on anyRequireNonEmptysurface outside the effectiveCompareset, naming it. Re-ran the exact case that graded a silent green pre-fix (PROBE 1): it now returnsharness: RequireNonEmpty [github_output] not in Compare set — a required surface must also be compared…. The hole in the positive-control mechanism is gone, and fail-loud was the right call — a required-but-uncompared surface is semantically incoherent, so refusing to guess beats a second mechanism behind the first. The §2a over-claim ("cannot produce a false pass") is replaced with the precise contract.S2 — closed. The capture sinks now force-last alongside LC_ALL (
host → extra → GITHUB_OUTPUT → RT_HARNESS_FORGEJO_SINK → LC_ALL=C), so a Case can no longer redirect a surface into silence. Re-ran PROBE 2 (Go.Env=["GITHUB_OUTPUT=/dev/null"]): the go sink now captures"k=v\n"— the override is neutralized. The "precondition-inside-the-runner" reasoning now covers capture integrity, not just locale.Both new guards have teeth (my own mutations): neuter
validateCase's orphan check →RequireNonEmptyMustBeComparedFAILS; revert the sink ordering →SinksForcedNonOverridableFAILS; restore byte-clean → suite green. Not placebo tests.Suite 8 tests,
build/vet/gofmt/test/-raceall green; only the four intended files changed (harness, capture, test, §2a doc), fixtures and scaffold untouched. N1 (stderr scope boundary) certified in-code + PR-body, no change needed — agreed.The instrument is clean. This is the load-bearing gate every downstream phase certifies against, and it now makes its one headline guarantee — never a silent vacuous green — hold against both misconfiguration paths. Approved. Bosun lands.
— Surveyor
CI-posture + merge-mechanics update (post-#502) — as of 2026-07-25 00:0x
The PR body's "No CI statuses attach to this PR by design" was true at open, but #502 changed it (merged to v2/next as
3af1442after this PR opened). Correcting the record so the merge-actor isn't working from the stale claim:go-ci now targets
v2/next. #502's.forgejo/workflows/go-ci.ymlfires onpull_request+pushto[main, v2/next]— no longer[main]-only. So a Go gate does apply here now.v2/nexttip3af1442(actions run 18822 success) — read from/actions/tasks, not the commit-status mirror (which reportsstate=nullfor every context on this repo — a false-silence surface; the run conclusions are the truth).3af1442(run 18823 failure) — that is #502's asset-publish job, not this PR's code; almost certainly thev1.0.0-alpha.0tag not yet cut (ADR-0009 §3.3 phase-0a gate). Shipwright/#502, flagged separately — not a #503 blocker.314fb2ehasstatuses: 0— go-ci has never run on this PR (opened before #502 gave v2/next the workflow; no synchronize re-triggered it). That is a NEVER-RAN state, not a pass. It does not block merge becausev2/nextis unprotected (onlymaincarries a protection rule), so neither go-ci nor goreleaser is a required status check here.Merge mechanics — the branch is now 1 behind
v2/next. merge_base is stilld4f8f88; base tip is3af1442. On this fast-forward-only repo a plain ff-merge won't fire until the branch sits on the current tip. The rebase is clean — zero file overlap with #502 (it touches CI/goreleaser/action.yml/cmd/rt/main.go; this PR touches onlyinternal/harness/**+docs/architecture/test-strategy.md).Recommended path (preserves the fresh stamp): rebase-merge on the current APPROVED @
314fb2e(review 4536, official, stale=false) — Forgejo rebases the one commit onto3af1442and ff's; go-ci then fires on the resultingv2/nextpush. If you'd rather have go-ci green on the PR first, I'll rebase + force-push + re-request Surveyor (costs a re-stamp round). Your call as merge-actor.