Decisions

ADR-132: Test-corpus-driven language migration

The fleet is about to begin a non-trivial cross-language migration: warp#1263 EPIC ports the devtools bash CLIs to Go binaries. Naive line-by-line porting of bash to Go loses behaviour at every awkward idiom (string…

#132

ADR-132: Test-corpus-driven language migration

Status: Proposed Date: 2026-05-05 Related: ADR-106 (generic migration playbook), ADR-122 (safe cutover pattern), ADR-117 (module guidelines), warp#1263 (devtools bash to Go migration EPIC, first applied case), warp#1267 (this ADR), warp#1262 (testing-quality structural gates EPIC, sister), warp#1264 (script usage telemetry — informs which scripts to migrate first).

Context

The fleet is about to begin a non-trivial cross-language migration: warp#1263 EPIC ports the devtools bash CLIs to Go binaries. Naive line-by-line porting of bash to Go loses behaviour at every awkward idiom (string splitting, exit-code propagation, here-docs, signal handling), produces awkward Go that nobody wants to maintain, and offers no parity guarantee — once the .sh is removed, the only evidence the new binary "behaves the same" is review judgement plus whatever ad-hoc spot-checks the porter remembered. The fleet has already paid the cost of the inverse failure mode in smaller refactors; doing it at the scale of every devtool would be demoralising and bug-prone.

The fleet does, however, have rich test coverage on the bash CLIs themselves. ~/.gyrum/devtools/test/*.test.sh exercises real journeys (a commit goes in, a review runs, a hook fires) against the bash binary as a black box. Those tests describe behaviour the operator depends on. The bash implementation is one of two possible artefacts that satisfies them. The Go binary, when it exists, will be the second.

The operator confirmed empirically (2026-05-04) that this approach works: a prior python-to-Go port succeeded by treating the TDD test corpus as the executable spec and implementing the new code from the tests, not from the old source. The structural opportunity is to codify the methodology so every fleet migration inherits the discipline rather than re-deriving it under deadline pressure.

ADR-106 and ADR-122 cover migration choreography — feature flags, dual-write, gradual cutover. They do not say how to verify the new implementation matches the old. This ADR fills that gap.

Decision

The test corpus IS the migration blueprint. To port a unit from language A to language B, factor the corpus to be implementation-agnostic, port behaviour to language B against the corpus, prove parity by running the same corpus against both implementations until 100% pass, then flip dispatch and delete the original.

The five-phase shape:

  1. Phase 0 — Corpus audit and extension. Before any port begins, audit the source's test corpus. Compute behaviour coverage, mutation score (per warp#1262 / warp#1261), and golden-master journey count. If mutation score is below 80% on the source, the corpus is too weak to anchor a port — STOP, file a "harvest test corpus" predecessor ticket, extend the source's tests until mutation score crosses 80%, only then begin the port. This phase is non-negotiable; skipping it ports against a weak spec and produces unverifiable parity.

  2. Phase 1 — Three-tier test taxonomy. Classify every test in the corpus. Golden master tests are captured-once recordings of the source's exact stdout, stderr, exit code, and observable side effects on canonical inputs; replayed against the port and asserted byte-equivalent (or canonical-equivalent post Phase 5). Behaviour cases are input/output pairs the original suite asserts; behaviour-locked, wording canonicalised. Property tests are invariants over input space; re-expressed in the target language's quickcheck/property framework. Golden masters are the irreducible spec for CLI ports where every byte of stdout matters because callers parse it.

  3. Phase 2 — Test corpus migration to target language. Translate the corpus to the target language's test framework BEFORE writing the implementation. The translated tests must compile, must all fail (no impl yet), and must cover at least one test per behaviour the source corpus covered. A test that cannot be translated cleanly is a signal: either it was implementation-locked and shouldn't ride along, or the new language cannot express it (rare, surface as an ADR amendment).

  4. Phase 3 — Implement the smallest impl that turns the corpus green. TDD red-to-green against the migrated corpus. Every line of new impl exists because a test demanded it. No speculative code, no "the bash also did this so I'll port it"; if there is no test for a behaviour, the behaviour does not get ported until a test for it does.

  5. Phase 4 — Equivalence harness verification. A small harness in the target repo runs the golden master tier against both the original and the new impl, and asserts identical stdout, stderr, exit code, and observable side effects (DB rows, files written, network calls). The harness runs in CI on every PR until the port is complete; once dispatch flips and the original is removed, the harness is deleted (its job is over).

  6. Phase 5 — Canonicalisation pass. Tests embed implementation details (exact error wording, specific log line shape, argument-parser library, internal function names). Strip them. The Eve persona (per warp#1262) reviews tests adversarially with one question: "is this test locking implementation or behaviour?" Implementation-locked tests are wrong-shaped; loosen them.

The cutover step (rename dispatch from .sh to the Go binary, remove the bash, delete bash-only tests that didn't survive Phase 5) follows ADR-122.

Consequences

What becomes easier

  • Parity is a mathematical property, not a hope. The equivalence harness either passes or fails. Reviewers stop arguing about "did you port that branch correctly" and start arguing about "is this test the right shape", which is a much shorter conversation.
  • The test corpus is the artefact that survives. When the port is done, the new corpus is the canonical spec; the original implementation is gone but the spec it produced lives.
  • The methodology generalises. bash to Go (warp#1263), python to Go (operator's prior work), TypeScript to Go, framework upgrades within the same language, even gyrum-go v1 to v2 — all use the same five phases. Sub-agents do not re-derive the discipline per migration.
  • Each phase has a structural defence against its own failure mode: weak corpus is caught at Phase 0 before any code is touched; implementation-locked tests are caught at Phase 5 by the Eve persona; behaviour drift between original and port is caught at Phase 4 by the harness. Failures are loud, not silent.
  • The migration EPIC has measurable progress at the corpus level. Each unit ports through five phases; status is visible without reading code.

What becomes harder

  • Phase 0 is sometimes longer than the port itself. A unit with a weak test corpus pays for the harvest before it ships any new behaviour. This is real cost. The benefit is that the resulting test suite is permanent infrastructure (warp#1262's testing-quality gates depend on the same coverage and mutation infrastructure), not throwaway scaffolding for the port.
  • The equivalence harness adds CI cost. Both implementations run on every PR for the duration of the port, roughly doubling test compute on the affected paths. Acceptable for the duration of the port, deletable after.
  • Sub-agents accustomed to "rewrite from scratch" must adapt. The countervailing structure is gyrum-create-port-blueprint <source-lang> <target-lang> <unit> (filed as a follow-up; not in this ADR's scope), which scaffolds the five-phase ticket structure automatically so the discipline is the path of least resistance.

What we sign up to operate

  • A small reusable equivalence-harness tool, written once and dropped into the target repo per port. The shape is implementation-agnostic; the harness reads the golden-master tier, executes both binaries on each canonical input, and asserts the four observables match. The first cut lands inside warp#1263 alongside the first ported unit; subsequent ports import it.
  • A Phase 0 mutation-score gate that sources from warp#1261's nightly cron output. The gate is procedural (a checklist item on the port ticket), not a CI block, since the source corpus lives in a different repo than the port branch.
  • A periodic check (during the migration window) that no new behaviour-only commits land on the source after Phase 2 freezes the corpus. New source-side commits during the port either replay through Phase 2 (corpus update first, then implementation matches) or wait until cutover.
  • Cleanup discipline at cutover: when the port is complete and dispatch flips, the harness is deleted. A live equivalence harness pointing at a deleted original is a worse failure mode than no harness; the cutover ticket explicitly removes both.

Alternatives considered

  • Line-by-line translation of the source. Rejected. Bugs in the source ride along into the port; bash idioms become awkward Go; the verification problem is unchanged because the new code has no independent test layer of its own. Produces ports that are semantically similar to the original including the bugs, with no parity guarantee.

  • Rewrite from scratch with intuition, no test corpus. Rejected. The default outcome is behaviour drift: the new implementation is "what I think the bash did" rather than "what the bash actually did". No structural defence against missed edge cases; verification reduces to review judgement and ad-hoc spot-checks. The operator named this failure mode explicitly when filing the ticket.

  • Translate from prose specification. Rejected. Prose specs age while code moves; spec rot makes parity unverifiable. A prose spec captures what the author remembered to write down, not the behaviour the implementation actually has. The corpus, by construction, captures behaviour the implementation actually has.

  • Mechanical AST translation (e.g. bash-to-Go transpiler). Rejected. Works for syntax, fails for semantics. Test coverage does not transfer, so the output is a syntactically-Go-shaped artefact with the original's untested behaviour intact and no path to canonicalisation. The Phase 5 stripping pass cannot run on AST output because the AST translation does not know which details are implementation-locked.

  • Test-corpus port at a lower mutation-score bar (50%). Rejected. 50% is below the sufficient-spec threshold; ports against a 50%-mutation corpus produce 50%-verified parity. The 80% threshold is the empirically validated bar from the operator's prior python-to-Go work and aligns with warp#1262's testing-quality gate. Lowering the bar to ship faster trades verification cost (paid once, at the corpus) for behaviour-drift cost (paid forever, at every regression).

  • Scope this ADR narrowly to bash-to-Go (warp#1263 only). Rejected. The methodology is general — the operator validated it on python-to-Go before warp#1263 existed. Naming it as a fleet primitive lets future migrations inherit the discipline without rederiving it; naming it narrowly would force every future port to re-litigate the shape.


Supersedes: none Superseded by: