From 6521c3e6ba1d2496d3db6528900a450e3032c8a7 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Sun, 27 Sep 2026 10:40:54 -0500 Subject: [PATCH] test(live): adr-migrate restores v0 records from snapshots beside the scenario After #581 and #583, main carries ADR-179 and ADR-186 as v1, and the release flavor's --depth 1 clone has no history to restore them from. The v0 forms, taken from main before #581, now ship with the scenario. --- .../docker/scenarios/adr-migrate/setup.sh | 16 +- ...the-guards-and-the-transition-fallbacks.md | 141 ++++++++++++++++++ ...ll-path-test-levels-and-the-tier-2-gate.md | 78 ++++++++++ 3 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/docker/scenarios/adr-migrate/v0/ADR-179-remove-the-pre-1-0-in-place-migrator-keep-the-guards-and-the-transition-fallbacks.md create mode 100644 tests/fixtures/docker/scenarios/adr-migrate/v0/ADR-186-live-integration-fixture-install-path-test-levels-and-the-tier-2-gate.md diff --git a/tests/fixtures/docker/scenarios/adr-migrate/setup.sh b/tests/fixtures/docker/scenarios/adr-migrate/setup.sh index 7a78ab3b..ff72f155 100644 --- a/tests/fixtures/docker/scenarios/adr-migrate/setup.sh +++ b/tests/fixtures/docker/scenarios/adr-migrate/setup.sh @@ -6,15 +6,13 @@ mkdir -p docs/scripts cp -r "$APP/docs/architecture" docs/ cp "$HOME/.claude/hooks/ways/documentation/adr/adr-tool" docs/scripts/adr chmod +x docs/scripts/adr -# The records under test start from their v0 form on main, so the rehearsal -# migrates them even when the branch under test already has. -base=$(git -C "$APP" merge-base HEAD origin/main 2>/dev/null || true) -if [[ -n "$base" ]]; then - for n in 179 186; do - f=$(ls docs/architecture/system/ADR-$n-*.md) - git -C "$APP" show "$base:$f" > "$f" 2>/dev/null || true - done -fi +# The records under test start from their v0 form, snapshotted beside this +# script from main before #581 migrated them. The release flavor clones with +# --depth 1, so the history to restore them from is not there. +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +for f in "$here"/v0/ADR-*.md; do + cp "$f" docs/architecture/system/ +done if grep -q '^contract:' docs/architecture/system/ADR-179-*.md docs/architecture/system/ADR-186-*.md; then echo "setup: the records under test are already v1; the rehearsal would test nothing" >&2 exit 1 diff --git a/tests/fixtures/docker/scenarios/adr-migrate/v0/ADR-179-remove-the-pre-1-0-in-place-migrator-keep-the-guards-and-the-transition-fallbacks.md b/tests/fixtures/docker/scenarios/adr-migrate/v0/ADR-179-remove-the-pre-1-0-in-place-migrator-keep-the-guards-and-the-transition-fallbacks.md new file mode 100644 index 00000000..dac54bd0 --- /dev/null +++ b/tests/fixtures/docker/scenarios/adr-migrate/v0/ADR-179-remove-the-pre-1-0-in-place-migrator-keep-the-guards-and-the-transition-fallbacks.md @@ -0,0 +1,141 @@ +--- +status: Accepted +date: 2026-08-17 +deciders: + - aaronsb + - claude +related: + - ADR-142 + - ADR-144 + - ADR-153 +--- + +# ADR-179: Remove the pre-1.0 in-place migrator; keep the guards and the transition fallbacks + +## Context + +ADR-144 §5 shipped `ways migrate` (plan / `--what-if` / `--execute`) to move a +pre-1.0 in-place `~/.claude` clone onto the 1.0 projection model, and gave it an +explicit deprecation lifecycle: dormant at 1.0.0, escalating SessionStart +pressure across 1.0.x, removed at 1.1.0. The escape hatch was named in the same +section — the migrator lives forever at the last tag that ships it, so removal +never means a user cannot migrate. + +The removal has been deferred twice. 1.1.0 shipped with the migrator still +present; 1.2.0 (`691b0e6`) corrected the docs and moved the window to 1.3.0. +That target passed too. The line is now at **1.8.3** and the migrator is still +compiled in — roughly 1,000 lines across `migrate.rs` and `migrate_exec.rs`, +plus the CLI variant, a reconcile parameter that exists only for it, and +migration instructions in the README, the install guide, `install.sh`, the +`deployment` way, and the `ways-update` skill. + +The deferrals bought adopter time. They have also left the escalation curve's +end state unreached: eight minor releases past the announced window, every +surface still presents `ways migrate` as a live command, and the deprecation +notices in those surfaces name removal targets (1.1, 1.3) that came and went. + +ADR-144 §5 also drew a second line that the removal must respect. Its "the cliff +is a ramp" clause says removing the migrator removes *assisted migration*, not +*function*: a post-removal binary on an un-migrated `~/.claude` still reads +correctly through the transition fallbacks — cache from the legacy +`claude-ways` dir, events from `~/.claude/stats`. Those fallbacks live in +`ways-core/src/paths.rs` and are cheap, non-destructive, and inert on a current +install. + +Two guards share the migrator's detector, `reconcile::is_legacy_in_place`. +`ways reconcile` refuses to run against an in-place clone because projecting +symlinks over a live repo would strand the user's checkout; `ways update` +refuses for the same reason. Both currently route the user to `ways migrate`. +ADR-144 §5 listed "migrator **and in-place check** removed" as one step. Removing +the checks would let `ways reconcile` clobber an in-place clone — an actively +destructive outcome for exactly the users the never-strand clause protects. + +## Decision + +Remove the migrator. Keep the in-place guards and the transition fallbacks. + +**Removed:** + +- `tools/ways-cli/src/cmd/migrate.rs` and `tools/ways-cli/src/cmd/migrate_exec.rs`, + their `mod` declarations, the `Commands::Migrate` clap variant, and its + dispatch arm. +- `ways_core::paths::cache_root_canonical`, which exists solely as the + migrator's rename destination. Runtime reads use the fallback-aware + `cache_root`. +- `reconcile::run`'s `allow_in_place` parameter and the bypass it gates. The + migrator is the only caller that ever passed `true`; with it gone the guard is + unconditional. +- The migration walkthrough in `docs/migration-1.0.md`, the `ways migrate` + invocations in `scripts/install.sh`, `skills/ways-update/SKILL.md`, the + `deployment` way, README, and `docs/install-guide.md`. + +**Kept:** + +- `reconcile::is_legacy_in_place` and both guards. Their messages retarget from + "run `ways migrate`" to the tag escape hatch. A user who reaches these guards + is told what their install is and where the migrator still lives. +- The `paths.rs` transition fallbacks — `LEGACY_CACHE` resolution in + `resolve_cache`, the `~/.claude/stats/events.jsonl` fallback in + `resolve_events`, and the `events_log_sources` union (ADR-153 §1). These are + the ramp ADR-144 §5 promised. The union also recovers orphaned `session_start` + lines on *migrated* installs whose shell hooks predated the path fix, so it + earns its keep independent of the in-place case. +- The legacy `~/.claude/ways.json` disabled layer, a lower-precedence config + read with no coupling to the migrator. + +**Escape hatch:** `ways-v1.8.3` is the last tag shipping the migrator. Migrating +after removal means `git clone --branch ways-v1.8.3 … && ways migrate --execute`, +then updating. `docs/migration-1.0.md` is rewritten around that route rather than +deleted, and the guards point at it. + +Removal lands in **1.9.0**. + +## Consequences + +### Positive + +- About 1,000 lines of transitional code leave the binary, along with a + destructive code path (whole-`~/.claude` relocation) that no current install + exercises. +- `reconcile::run` loses a boolean parameter whose only non-default caller is + being deleted, so the in-place guard becomes unconditional and the function's + contract simplifies. +- Every user-facing surface stops advertising a deprecation target that has + already passed. The migration story becomes one route (the tag) instead of a + live command shadowed by stale removal dates. + +### Negative + +- A pre-1.0 adopter who has not migrated by 1.9.0 now has a two-step path (clone + the tag, migrate, update) instead of one command. This is the cost ADR-144 §5 + accepted when it named the tag as the escape hatch. +- The migrator's crash-safe phase machinery and its tests go with it. Reviving + the capability would mean recovering it from the tag. + +### Neutral + +- ADR-144 §5's lifecycle is executed, not superseded — its escalation dates were + the cadence, not the contract. This ADR records the divergence on one point: + the in-place *check* stays. +- The `deployment` way keeps its legacy-in-place branch. The detection guidance + is still correct; only the remedy changes. + +## Alternatives Considered + +- **Remove the guards too, as ADR-144 §5 literally specified.** Rejected: with + the guard gone, `ways reconcile` on an in-place clone projects over a live git + repo and strands the checkout. Deleting assistance is within the lifecycle; + adding a destructive path is not. +- **Remove the `paths.rs` transition fallbacks in the same change.** Rejected for + now: they are inert on a current install, and dropping them turns "reads + correctly, unassisted" into "silently re-fetches the model and stops reading + its own stats." The `events_log_sources` union has a second justification that + outlives the in-place case. If these come out, it is as their own decision with + its own reasoning. +- **Defer again to 2.0.0.** Rejected: two deferrals have already passed with no + signal that a third would be used differently, and each one leaves the shipped + docs quoting a removal date that has expired. +- **Keep the migrator indefinitely as a dormant command.** Rejected: it carries a + destructive code path and a phase machine that no test run outside its own + fixtures exercises, and its presence is why the docs still carry a transition + narrative eight releases past 1.0. diff --git a/tests/fixtures/docker/scenarios/adr-migrate/v0/ADR-186-live-integration-fixture-install-path-test-levels-and-the-tier-2-gate.md b/tests/fixtures/docker/scenarios/adr-migrate/v0/ADR-186-live-integration-fixture-install-path-test-levels-and-the-tier-2-gate.md new file mode 100644 index 00000000..e44b62b1 --- /dev/null +++ b/tests/fixtures/docker/scenarios/adr-migrate/v0/ADR-186-live-integration-fixture-install-path-test-levels-and-the-tier-2-gate.md @@ -0,0 +1,78 @@ +--- +status: Accepted +date: 2026-09-17 +deciders: + - aaronsb + - claude +related: + - ADR-142 + - ADR-144 + - ADR-184 + - ADR-185 +--- + +# ADR-186: Live integration fixture: install-path test levels and the tier 2 gate + +## Context + +The install path is the installer script, `make setup` with its prebuilt downloads, `ways reconcile` into a config directory that already holds a user's own files, and the hook scripts that Claude Code runs from the merged `settings.json`. The reviews of PRs #501, #502, #504 and #508 each found a defect on that path by reading the code, and each said the same thing: none of it had been run end to end on a clean machine. + +The tests that exist stop short of it. The Rust unit tests and `session_sim` exercise the binary against fixture ways in a temporary directory. The reconcile tests put a fake source and a fake destination in a sandbox. The sandbox transcripts in the PR comments were run by hand and are not repeatable. Nothing runs the installer, downloads a release asset, seeds a home with the shape from #501 (a real `skills/` directory, three hand-written hooks, a second config directory), or feeds a hook script the payload Claude Code sends. + +ADR-184 increment 2 (#509) changes the installer's last act from projecting into `~/.claude` to handing off to the targets bootstrap. Building that against no fixture repeats the pattern the reviews named. + +## Decision + +**The install path is tested at two levels. Tier 1 installs and configures with no API key and runs on every pull request that touches the path. Tier 2 exercises a model with a key, runs on manual dispatch or a schedule, and never runs unattended on a pull request.** + +1. **Tier 1: install and configure, no key.** A Debian container with Claude Code installed at a pinned version. The home is seeded before the installer runs: a real `~/.claude/skills/` with a user's own skill, a `settings.json` with three user hooks and a `model` key, and a second config directory with its own settings and skills. The runner asserts: + - the installer runs unattended and refuses the real `skills/` directory with nothing deleted and `settings.json` byte-identical; + - the documented recovery (`ways reconcile --force`) moves the directory aside with the user's skill intact, links the projection roots, and keeps every user hook by identity in its event; + - the target is recorded in the user config, `ways config targets` reports one enabled target, and `ways status` reports the active state with the embedding engine up; + - `ways reconcile --dry-run` run twice prints identical output and reports no change; + - the second config directory is byte-identical to its seed; + - `way-embed` arrived as a release download. The image carries no C++ toolchain, so a source-build fallback is a failed assertion; + - `attend status` and `claude --version` exit zero, and the version is the pinned one; + - the hooks fire the way Claude Code fires them: the runner reads the hook commands out of the merged `settings.json`, pipes a synthetic `SessionStart`, `UserPromptSubmit`, and `PreToolUse` payload to each, and asserts on the additional-context output. Disclosure is proven with no model. + +2. **Two flavors of tier 1.** The `branch` flavor mounts the checkout and the binaries CI built for the same commit, so a pull request is tested against its own binary and its own hooks. It is the gate, and it runs on every pull request that touches the path and on every push to `main`. The `release` flavor runs the documented one-liner, which clones `main` and downloads the latest release assets. It runs on dispatch and on the nightly schedule, with Claude Code at its `latest` version, to catch drift on either side. + +3. **Tier 2: exercise with a key.** `claude -p` runs non-interactively under `CLAUDE_CONFIG_DIR` with prompts chosen to trigger named ways. The evidence is the transcript and the events log read through `ways introspect`, plus the answer scored against a rubric with a stated pass threshold. Two containers on one compose network carry the attend peer test: send from one, assert the other's inbox. Keepwarm is out of scope. + +4. **The gate.** Tier 2 reads the key from a repository secret. The workflow runs it on `workflow_dispatch` and on `schedule` only. A pull request never triggers it, and a fork pull request cannot see the secret. A change to tier 2 is verified by dispatching it against the branch. + +5. **Shape.** Everything lives under `tests/fixtures/docker/`: the compose file, one Dockerfile parameterized by Claude Code version and installer, the seed, the payloads, and one runner per tier. `make test-live TIER=1|2` is the entry point on a workstation and in CI. Tier 1 is a job in `portability.yml`. + +6. **Sequence.** Tier 1 lands before #509. Tier 2 follows tier 1 as its own increment. + +Reversibility: cheap. The fixture is additive. Removing the job removes the gate and nothing else. + +## Consequences + +### Positive + +- A change to the installer, the reconciler, the settings merge, or a hook script is run on a clean machine before review reads it. +- The #501 shape is a fixture, not a memory. The refusal, the recovery, and the kept hooks are asserted on every pull request. +- #509 is built against a fixture that fails when it wires the wrong directory. +- A pinned Claude Code on the gate and `latest` on the schedule separate our regressions from upstream drift. + +### Negative + +- Tier 1 needs network: the Claude Code installer, the way-embed and model downloads, and the release assets through `gh`. A network fault fails the job. The branch flavor keeps the four suite binaries off the network; way-embed, mmaid, and the model stay on it. +- Docker on the runner adds minutes to the portability workflow. +- Tier 2 on a schedule spends tokens on a fixed cadence. The threshold and the prompt set have to be maintained. + +### Neutral + +- The runner drives hooks from `settings.json` rather than by path, so a hook that the merge drops is a failed assertion rather than a silent skip. +- `portability.yml` gains a job that builds all four suite binaries before the fixture runs, since the branch flavor needs `ways-audit` and `attend-chat` beside `ways` and `attend`. The existing cross-platform matrix is unchanged. +- ADR-185's `--json` views are what the runner parses. +- The first run of the fixture found two defects on the download path, both fixed on the same branch. The download scripts listed 20 or 30 releases before grepping for a component prefix, and the newest tags of three components sat past that window. The way-embed download script's capability probe ran under `pipefail` and rejected every binary, since a supporting binary also exits nonzero when asked for `match --batch` without a corpus. #516 had diagnosed that as a release that predates the primitive. The release supports it; the probe was wrong. The image carries no C++ toolchain, so the fixture fails if either defect returns. + +## Alternatives Considered + +- **Run the installer on the GitHub runner's own home.** Rejected: the runner's home is not clean, and the seeded state would have to be undone between steps. A container starts empty every time. +- **Mock Claude Code.** Rejected: `claude --version`, the installer path, and the settings file are the things under test. The binary is a pinned download and costs one step. +- **Tier 2 on every pull request.** Rejected: a fork pull request has no secret, so the check would pass by absence, and every pull request would spend tokens. Dispatch and schedule keep the run deliberate. +- **One tier with the key optional.** Rejected: a runner that skips assertions when the key is missing reports green for two different things. Two runners with two names keep the report honest. +- **A virtual machine per run.** Rejected: slower to start, and the container already isolates the home, the XDG roots, and the PATH.