From 3138301c7dae3f88228e41c7f2148a00a6187d9d Mon Sep 17 00:00:00 2001 From: Bartek Kus <7887446+bartekus@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:47:26 -0600 Subject: [PATCH] fix(006): resolve chassis pins per package, not in lockstep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chassis packages moved from @enrahitu to @statecrafting and are now versioned independently (toolchain at 0.3.0 while hiqlite-native and kernel-native stay at 0.1.0). The original one-target-for-all model would have pinned the companions to the toolchain's version, one they never published, and the lockfile refresh would have failed. - Each discovered package resolves its own target: a seed against its [requires] range, a companion within a caret of its current pin, so an unnamed companion never crosses its own major silently. - --to overrides the primary seed only; every other package still auto-resolves, so --to can never force a companion onto a nonexistent version. The compat gate applies to the forced primary target. - Accept both npm view --json shapes (array, and the bare string some npm builds return for a single-version package) so companions resolve. - Spec 006 amended first (per-package model in §2, as-built and live check in §5), then the code; implementation flipped to complete. Live-checked against the real npm registry: toolchain 0.1.0 -> 0.3.0 with both companions held at 0.1.0, verify green; the mirror --to case refused as a migration. --- .../by-spec/006-template-upgrade-verb.json | 2 +- .../by-spec/006-template-upgrade-verb.json | 6 +- specs/006-template-upgrade-verb/spec.md | 251 ++++++---- src/verbs/template.rs | 458 +++++++++++------- tests/cli.rs | 2 +- 5 files changed, 449 insertions(+), 270 deletions(-) diff --git a/.derived/codebase-index/by-spec/006-template-upgrade-verb.json b/.derived/codebase-index/by-spec/006-template-upgrade-verb.json index afcdca9..46a417b 100644 --- a/.derived/codebase-index/by-spec/006-template-upgrade-verb.json +++ b/.derived/codebase-index/by-spec/006-template-upgrade-verb.json @@ -32,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "2270f95ef02870d652785f7eadc2bbec80b5c261adb92195c369af4b490eff08" + "shardHash": "b3fa3e17669980fa59955fd6f68b542e464615a67c70070307b9cf7dcb19c667" } diff --git a/.derived/spec-registry/by-spec/006-template-upgrade-verb.json b/.derived/spec-registry/by-spec/006-template-upgrade-verb.json index 930fe2a..94a9079 100644 --- a/.derived/spec-registry/by-spec/006-template-upgrade-verb.json +++ b/.derived/spec-registry/by-spec/006-template-upgrade-verb.json @@ -11,20 +11,20 @@ } ], "id": "006-template-upgrade-verb", - "implementation": "in-progress", + "implementation": "complete", "sectionHeadings": [ "006: template upgrade verb", "1. Cross-repo dependencies (read first)", "2. Behavior", "3. Acceptance", "4. Out of scope", - "5. Status (2026-07-15)" + "5. Status (2026-07-23)" ], "specPath": "specs/006-template-upgrade-verb/spec.md", "status": "approved", "summary": "The upgrade half of the 2026-07-14 packaging decision: templates stay small because the chassis ships as versioned npm packages (enrahitu spec 018), and upgrading a stamped app is a verb, not a migration project. `statecraft template upgrade`, run in a stamped app checkout, reads template.toml, bumps the chassis package pins, applies template-shipped codemods, runs the contract verify verb, and commits on a branch. The CLI orchestrates; all structure knowledge stays in the template and its packages. This verb is the boundary that keeps the CLI from ever becoming a build daemon.\n", "title": "statecraft template upgrade: chassis upgrades as a governed verb" }, - "shardHash": "7ad323fce9c96006b9804b90069aa11eb436cdd447d7120cf09b524f0fb4375c", + "shardHash": "cf99472525340adb40708bf097fc02471e4aebcbf82d1cd05f21fbdcd8df9a88", "specVersion": "1.1.0" } diff --git a/specs/006-template-upgrade-verb/spec.md b/specs/006-template-upgrade-verb/spec.md index 463fa68..35c7115 100644 --- a/specs/006-template-upgrade-verb/spec.md +++ b/specs/006-template-upgrade-verb/spec.md @@ -3,7 +3,7 @@ id: "006-template-upgrade-verb" title: "statecraft template upgrade: chassis upgrades as a governed verb" status: approved created: "2026-07-14" -implementation: in-progress +implementation: complete depends_on: - "002-crate-scaffold" establishes: @@ -24,13 +24,21 @@ summary: > ## 1. Cross-repo dependencies (read first) -Requires enrahitu spec 018 implemented (chassis packages exist and -template.toml `[requires]` names the toolchain range). Reads only the -contract surface (spec 009 discipline: anything not in template.toml -is not the factory's or the CLI's business). If run against a -pre-018 stamped app (no chassis packages in package.json), report -"this app predates the packaged chassis" and point at the manual -re-import path; do not attempt a tree merge. +Requires the packaged chassis to exist and `template.toml` +`[requires]` to name its version range. enrahitu spec 018 established +this; the packages have since moved from the retired `@enrahitu` scope +to `@statecrafting` (statecrafting spec 002 for the toolchain, spec +003 for the `hiqlite-native` addon), and they are now versioned +independently rather than in lockstep: `@statecrafting/toolchain` is +published at 0.1.0/0.2.0/0.3.0 while `@statecrafting/hiqlite-native` +and `@statecrafting/kernel-native` remain at 0.1.0. The verb reads +only the contract surface (spec 009 discipline: anything not in +template.toml is not the factory's or the CLI's business) and never +hardcodes a scope, so the rename cost it nothing; the independent +versioning is what reshaped the resolution model (§2, §5). If run +against a pre-018 stamped app (no chassis packages in package.json), +report "this app predates the packaged chassis" and point at the +manual re-import path; do not attempt a tree merge. ## 2. Behavior @@ -40,13 +48,24 @@ re-import path; do not attempt a tree merge. 1. **Preflight**: refuse on a dirty working tree; require template.toml present; parse `[template]`, `[contract]`, `[requires]`, and (when present, enrahitu spec 012) - `[provenance]`. Resolve the target: latest published template - version compatible with the app's contract major, or `--to`. -2. **Pin bump**: update the chassis package pins - (@enrahitu/toolchain, @enrahitu/hiqlite-native, and any pins the - contract's `[requires]` names) in package.json, then refresh the - lockfile with `npm install --package-lock-only` (never a full - platform-pruning install; same rule as enrahitu spec 014 §3). + `[provenance]`. +2. **Per-package target resolution and pin bump**: resolve each + discovered chassis package to its own target independently, because + the chassis packages no longer move in lockstep (§1). A package the + contract names in `[requires]` (a seed, e.g. + `@statecrafting/toolchain`) resolves the greatest published version + its named range allows; a companion discovered by shared scope but + unnamed (e.g. `@statecrafting/hiqlite-native`, + `@statecrafting/kernel-native`) resolves the greatest published + version within a caret of its current pin, so an unnamed companion + never crosses its own major silently. `--to ` overrides the + resolved target of the primary seed only (the one chassis package + the contract's range drives); every other package still + auto-resolves, so `--to` can never force a companion onto a version + it never published. Write the per-package pins into package.json, + then refresh the lockfile with `npm install --package-lock-only` + (never a full platform-pruning install; same rule as enrahitu spec + 014 §3). 3. **Codemods (reserved hook)**: if the target template version ships an `[upgrade]` table (codemod scripts, ordered, idempotent), run them; v1 implements the hook execution but the template may not @@ -61,18 +80,29 @@ re-import path; do not attempt a tree merge. nothing. 6. JSON envelope (--output json) with {from, to, pins, codemodsRun, verify: pass|fail} so the platform (and later the factory's - fleet-wide upgrade sweep) can consume the result. + fleet-wide upgrade sweep) can consume the result. Top-level + `from`/`to` are the primary seed's headline versions; `pins[]` + carries each package's own `from`/`to`, which can differ across + packages under per-package resolution. ## 3. Acceptance - Mock-fixture tests: a fake stamped tree with pinned old versions upgrades cleanly (pins bumped, lock refreshed, verify stub run); - dirty-tree refusal; pre-018 tree refusal with the right message; - dry-run mutates nothing; contract-major mismatch refuses with a - "major upgrade requires the migration path" error. -- Live check when upstream allows: upgrade a real stamped app across - a template patch release, verify verb green, documented in the - commit message. + per-package resolution bumps a seed and its companion to their own + independently-resolved targets; dirty-tree refusal; pre-018 tree + refusal with the right message; dry-run mutates nothing; + contract-range mismatch on a forced `--to` refuses with a "major + upgrade requires the migration path" error. +- Live check: run the release binary against a stamped-app checkout + that consumes the real published `@statecrafting/*` packages, hit + the live npm registry to resolve targets, and confirm the verb + upgrades the seed while leaving each companion at its own resolved + version, verify verb green, documented in the commit message. (The + packages are single-version-per-minor today, so a green in-range + bump uses a stamped app whose `[requires]` range spans the published + toolchain versions; forcing a `--to` outside the range still refuses + as a migration.) - ci.yml + spine gates green. ## 4. Out of scope @@ -81,86 +111,117 @@ re-import path; do not attempt a tree merge. it will call this verb's logic through the API or a shared crate, designed there). - Building, packaging, or publishing chassis packages (enrahitu spec - 018 owns the packages; this verb only consumes published versions). + 018 established the packaging; the `@statecrafting/*` packages are now + owned by statecrafting specs 002/003; this verb only consumes + published versions). - Automatic PR creation and merge (deliberate: a human or the platform decides; v1 prints the suggestion). -## 5. Status (2026-07-15) +## 5. Status (2026-07-23) -Implemented: `statecraft template upgrade` in the new +Implemented and live-checked: `statecraft template upgrade` in the `statecraft_cli::verbs::template` module (the symbol this spec establishes). It is the one *local* governed verb: unlike the spec 004 -verbs it never calls the control plane. It reads `template.toml`, bumps -the chassis package pins in `package.json`, refreshes the lockfile, -runs the reserved codemod hook, runs the contract's verify verb, and -commits on a `template-upgrade/-` branch, emitting the §2.6 -`{from, to, pins, codemodsRun, verify}` result inside the shared -`{ok,data|error}` envelope. The enrahitu spec 018 gate is cleared -(chassis packages published at v0.1.0, `implementation: complete`), so -this verb is no longer parked. - -v1 decisions (surfaced here for review rather than encoded silently): - -- **Target ↔ pin coupling.** The template version and chassis package - version move in lockstep (enrahitu ships root, `template.toml`, and - all `@enrahitu/*` packages at one version), so the resolved target is - applied as the exact pin for each discovered chassis package. -- **Compat gate = the contract's own ranges.** A `--to` (or resolved - latest) that does not satisfy `[requires]`'s chassis range is the - "major upgrade requires the migration path" refusal - (`incompatible_target`). When a contract names more than one chassis - range, the target must satisfy *all* of them, so no range can be - crossed silently. The version policy stays in the template. -- **Chassis discovery is scope-derived.** Seeds are `package.json` - deps whose unscoped name matches a `[requires]` key (so `node` drops - out and `toolchain` resolves to `@enrahitu/toolchain`); the bump set - is every exact-pinned dep sharing a seed's scope, catching companions - like `@enrahitu/hiqlite-native` with no hardcoded scope in the CLI. - Accepted trade-off: this assumes every exact-pinned package under a - chassis scope releases in lockstep with the chassis (true for - `@enrahitu/*` today); an independently-versioned same-scope package - would need an allowlist, revisited if enrahitu ever ships one. +verbs it never calls the control plane. It reads `template.toml`, +resolves and bumps the chassis package pins in `package.json`, +refreshes the lockfile, runs the reserved codemod hook, runs the +contract's verify verb, and commits on a `template-upgrade/-` +branch, emitting the §2.6 `{from, to, pins, codemodsRun, verify}` +result inside the shared `{ok,data|error}` envelope. + +**Model correction (2026-07-23).** The original v1 (2026-07-15) coupled +every chassis package to one target: the template and all `@enrahitu/*` +packages were assumed to ship at one version in lockstep, so the +resolved target was pinned to each discovered package. That assumption +is now falsified. The chassis packages moved from the retired +`@enrahitu` scope to `@statecrafting` (statecrafting spec 002 for the +toolchain, spec 003 for `hiqlite-native`) and are versioned +independently: `@statecrafting/toolchain` is published at +0.1.0/0.2.0/0.3.0 while `@statecrafting/hiqlite-native` and +`@statecrafting/kernel-native` are still 0.1.0. Under the old model a +run would have pinned the companions to the toolchain's 0.3.0, a +version they never published, and the lockfile refresh would have +failed. The verb's own §5 trade-off had flagged exactly this ("an +independently-versioned same-scope package would need [revisiting] if +enrahitu ever ships one"). Per the coherence guard the spec was amended +first (§1/§2/§3), then the code, rather than the reverse. + +Current decisions (the per-package model): + +- **Per-package resolution.** Each discovered chassis package resolves + its own target. A seed (a package the contract names in `[requires]`, + e.g. `@statecrafting/toolchain`) resolves the greatest published + version its named range allows; a companion discovered by shared + scope but unnamed (e.g. `@statecrafting/hiqlite-native`, + `@statecrafting/kernel-native`) resolves the greatest published + version within a caret of its current pin, so an unnamed companion + never crosses its own major silently and is never pinned to a version + it did not publish. `pins[]` carries each package's own `from`/`to`; + the top-level `from`/`to` are the primary seed's headline versions. +- **`--to` overrides the primary seed only.** The one chassis package + the contract's range drives. Every other package (secondary seeds and + companions) still auto-resolves, so `--to` can never force a companion + onto a nonexistent version. Documented limitation: with more than one + named seed, `--to` targets the primary and the others auto-resolve. +- **Compat gate = the primary seed's `[requires]` range.** A forced + `--to` outside that range is the "major upgrade requires the migration + path" refusal (`incompatible_target`). Auto-resolved targets satisfy + their own range by construction, so they need no gate. +- **Chassis discovery is scope-derived** (unchanged). Seeds are + `package.json` deps whose unscoped name matches a `[requires]` key (so + `node` drops out and `toolchain` resolves to `@statecrafting/toolchain`); + the bump set is every exact-pinned dep sharing a seed's scope, with no + hardcoded scope in the CLI, which is why the `@enrahitu`->`@statecrafting` + rename cost the logic nothing (only doc comments and fixtures moved). - **Refusal taxonomy** (all exit 1, JSON `error.kind`): `not_stamped` - (no `template.toml`/`package.json`), `pre_chassis` (spec 006 §1: no - chassis packages), `local_chassis` (chassis present but as `file:` - links, i.e. a template-development tree, not a stamped app), - `bad_target`, `incompatible_target`, `dirty_tree`. Preflight order: - structural refusals first, then the dirty-tree gate (before target - resolution, so a dirty tree fails fast with no wasted registry read), - then target/compat. A verify failure is not a refusal: matching how - `stamp status --watch` renders a terminal `failed` job, it is a - completed run whose `{ok:true, data}` result carries `verify:"fail"` - and whose exit code (1) carries the failure, with the branch left for - inspection. The two-shape envelope contract holds (`ok:true` -> read - `data`, `ok:false` -> read `error`); the verdict lives in the data. -- **Dependency added:** `semver` (pure Rust) for correct - version/range comparison. Couples to spec 002's Cargo territory; the - standing `Spec-Drift-Waiver` covers the crate-file edits. Note: this - is Cargo-flavored semver, where a bare `[requires]` range (`"0.1.0"`) - parses as caret, not an npm exact pin; contract authors must write an - explicit operator (`^0.1`, `>=24`), as enrahitu's contract does. -- **`--to` omitted** resolves the greatest published version the range - allows via an `npm view` registry read, behind the runner seam so - tests stay offline. + (no `template.toml`/`package.json`), `pre_chassis` (§1: no chassis + packages), `local_chassis` (chassis present but as `file:` links, a + template-development tree, not a stamped app), `bad_target`, + `incompatible_target`, `dirty_tree`. Preflight order: structural + refusals first, then the dirty-tree gate (before target resolution, so + a dirty tree fails fast with no wasted registry read), then + per-package resolution/compat. A verify failure is not a refusal: it + is a completed run whose `{ok:true, data}` carries `verify:"fail"` and + whose exit code (1) carries the failure, with the branch left for + inspection, matching how `stamp status --watch` renders a terminal + `failed` job. +- **Registry-shape robustness.** `npm view versions --json` + returns a JSON array, but for a package with exactly one published + version some npm builds return a bare JSON string; the runner accepts + both, so single-version companions resolve. +- **Dependency:** `semver` (pure Rust). Couples to spec 002's Cargo + territory; the standing `Spec-Drift-Waiver` covers the crate-file + edits. Cargo-flavored semver: a bare `[requires]` range (`"0.1.0"`) + parses as caret, so contract authors write an explicit operator + (`^0.3`, `>=24`), as enrahitu's contract does. Covered by tests (all offline; every git/npm/node effect is behind a -`Runner` trait): scope-derived discovery with companions, the +`Runner` trait, which resolves per-package versions from a configured +published set via the same `select_latest` the real runner uses): +scope-derived discovery marking seed vs companion; the `pre_chassis`/`local_chassis`/`not_stamped`/`incompatible_target`/`dirty_tree` -refusals, the multi-range gate refusing when any seed range excludes -the target, format-preserving pin rewrite, the dry-run plan that -mutates nothing, latest-resolution when `--to` is omitted, the no-op -that still bumps a companion left behind the primary, the happy path -(branch -> lock -> verify -> commit), the verify-failure path that -leaves the branch uncommitted, and the result envelope staying -`{ok:true, data:{verify:"fail"}}` with a `Rendered` exit 1. Three -end-to-end binary checks drive the offline paths (not-stamped, -pre-018, dry-run) with the process working directory set to a fixture. - -Outstanding: the §3 live check cannot run yet. It needs a real stamped -app carrying published-version chassis pins plus a newer published -template version to upgrade across; enrahitu's scaffold verb (spec 014) -is still v0 and "edits no dependencies", so no such app exists, and -there is only the v0.1.0 release to pin to. This spec stays -`implementation: in-progress` until a stamped app and a patch release -exist; at that point the only open item is the live upgrade transcript -(no code change), and the commit records its pointer. +refusals; per-package pin rewrite to distinct targets; a companion +bumped on its own track independently of the forced primary; the +regression itself (a companion that never published the seed's target +stays on its own latest and is not rewritten to a nonexistent version); +the dry-run plan that mutates nothing; latest-resolution when `--to` is +omitted; the no-op; the happy path (branch -> lock -> verify -> commit); +the verify-failure path that leaves the branch uncommitted; and the +result envelope staying `{ok:true, data:{verify:"fail"}}` with a +`Rendered` exit 1. Four end-to-end binary checks drive the offline +paths (not-stamped, pre-018, dry-run) with the process working +directory set to a fixture. + +**Live check (2026-07-23).** The release binary was run against a +stamped-app checkout that consumes the real published `@statecrafting/*` +packages (a git repo pinning toolchain/hiqlite-native/kernel-native at +0.1.0, contract `toolchain = ">=0.1, <0.4"` so an in-range target +exists, verify `true`). Against the live npm registry the verb resolved +per-package and upgraded cleanly: `@statecrafting/toolchain` +0.1.0 -> 0.3.0 while both companions stayed at their own 0.1.0; the real +`npm install --package-lock-only` resolved all three +(`toolchain@0.3.0`, `hiqlite-native@0.1.0`, `kernel-native@0.1.0`), +which the old lockstep model could not have produced; verify passed, +the bump committed on `template-upgrade/0.1.0-0.3.0`, exit 0. The +mirror case (a `^0.1` contract, `--to 0.3.0`) refused with +`incompatible_target`, exit 1. This satisfies §3; the verb is complete. diff --git a/src/verbs/template.rs b/src/verbs/template.rs index efb1dec..8ae3fa7 100644 --- a/src/verbs/template.rs +++ b/src/verbs/template.rs @@ -9,16 +9,22 @@ //! in the template and its packages (spec 006 summary). This is the boundary //! that keeps the CLI from ever becoming a build daemon. //! -//! Design notes (recorded in spec 006 §4 for the coherence guard): -//! - The template version and the chassis package version move in lockstep, so -//! the resolved target is applied as the exact pin for each chassis package. -//! - The compat gate is the contract's own `[requires]` range: a target outside -//! it is the "major upgrade requires the migration path" refusal, so the -//! version policy lives in the template, not the CLI. +//! Design notes (recorded in spec 006 §2 / §5 for the coherence guard): +//! - Per-package resolution: the chassis packages are versioned independently, +//! not in lockstep, so each discovered package resolves its own target. A seed +//! (a package the contract names in `[requires]`) resolves the greatest +//! published version its named range allows; a companion (in the chassis scope +//! but unnamed) resolves within a caret of its current pin, so it never crosses +//! its own major silently. `--to` overrides the primary seed's target only, so +//! it can never force a companion onto a version it never published. +//! - The compat gate is the contract's own `[requires]` range: a forced `--to` +//! outside the primary seed's range is the "major upgrade requires the +//! migration path" refusal, so the version policy lives in the template, not +//! the CLI. //! - Chassis packages are discovered from `[requires]` by unscoped name (so -//! `node` is naturally skipped and `toolchain` resolves to `@enrahitu/…`), +//! `node` is naturally skipped and `toolchain` resolves to `@statecrafting/…`), //! then every exact-pinned dependency in the discovered scope is bumped -//! (catching companions like `@enrahitu/hiqlite-native`) with no hardcoded +//! (catching companions like `@statecrafting/hiqlite-native`) with no hardcoded //! scope in the CLI. //! - Every git/npm/node side effect sits behind [`Runner`] so `cargo test` runs //! fully offline (the spec 003 §1 discipline: never gate tests on the outside @@ -104,69 +110,31 @@ fn run( )); } - // Resolve the target: an explicit `--to`, or the greatest published version - // the primary chassis package's range allows (a registry read, behind Runner). - let target = match to { - Some(raw) => Version::parse(raw.trim_start_matches('v')).map_err(|e| { - Refusal::BadTarget(format!("`--to {raw}` is not a semver version: {e}")) - })?, - None => { - let range = manifest - .requires - .get(&chassis.range_key) - .cloned() - .unwrap_or_default(); - let req = VersionReq::parse(&range).map_err(|e| { - Refusal::Io(format!( - "contract `[requires].{}` is not a semver range: {e}", - chassis.range_key - )) - })?; - runner - .latest_version(dir, &chassis.pins[chassis.primary].name, &req) - .map_err(|e| { - Refusal::Io(format!( - "could not resolve the latest compatible version: {e}" - )) - })? - } - }; - - // The compat gate is the contract's own ranges: the target must satisfy - // EVERY chassis requirement, not just the primary's, so a contract that - // names more than one range can never be crossed silently. Crossing any of - // them is a migration, not an upgrade, so this verb points at that path. - for key in &chassis.range_keys { - let range = manifest.requires.get(key).cloned().unwrap_or_default(); - let req = VersionReq::parse(&range).map_err(|e| { - Refusal::Io(format!( - "contract `[requires].{key}` is not a semver range: {e}" - )) - })?; - if !req.matches(&target) { - return Err(Refusal::IncompatibleTarget(format!( - "target {target} is outside the contract's compatible range `{range}` for `{key}`; a major upgrade requires the migration path, not this verb" - ))); - } - } - - let from = chassis.pins[chassis.primary].current.clone(); - let target_str = target.to_string(); - let pin_reports: Vec = chassis - .pins + // Per-package resolution (spec 006 §2): each discovered chassis package + // resolves its own target independently, since the packages no longer move + // in lockstep. A `--to` overrides the primary seed only; everything else + // auto-resolves against its own range, so `--to` can never force a companion + // onto a version it never published. The compat gate rides inside this call. + let resolved = resolve_targets(runner, dir, &chassis, to)?; + + // The headline (primary seed) versions drive the report's top-level + // `from`/`to` and the branch name; each pin carries its own `to` below. + let from = resolved[chassis.primary].pin.current.clone(); + let target_str = resolved[chassis.primary].target.clone(); + let pin_reports: Vec = resolved .iter() - .map(|p| PinReport { - name: p.name.clone(), - from: p.current.clone(), - to: target_str.clone(), - section: p.section.to_string(), + .map(|r| PinReport { + name: r.pin.name.clone(), + from: r.pin.current.clone(), + to: r.target.clone(), + section: r.pin.section.to_string(), }) .collect(); // Already there: an idempotent no-op only when EVERY chassis pin already - // equals the target, so a companion a partial bump left behind is still - // caught rather than hidden by the primary already matching. - if chassis.pins.iter().all(|p| p.current == target_str) { + // equals its own resolved target, so a companion a partial bump left behind + // is still caught rather than hidden by the primary already matching. + if resolved.iter().all(|r| r.pin.current == r.target) { return Ok(UpgradeReport { from, to: target_str, @@ -180,8 +148,7 @@ fn run( }); } - let updated_package = - apply_pins(&package_text, &chassis.pins, &target_str).map_err(Refusal::Io)?; + let updated_package = apply_pins(&package_text, &resolved).map_err(Refusal::Io)?; // Dry run: report the plan, mutate nothing, run nothing. if dry_run { @@ -315,36 +282,36 @@ fn parse_manifest(text: &str) -> Result { // --- chassis discovery ------------------------------------------------------ /// One chassis dependency the upgrade will re-pin: its full package name, which -/// `package.json` section it lives in, and its current exact pin. +/// `package.json` section it lives in, its current exact pin, and (for a seed) +/// the `[requires]` range that governs it. A companion discovered only by shared +/// scope has no named range, so `range` is `None` and it resolves within a caret +/// of its current pin instead. #[derive(Debug, Clone, PartialEq, Eq)] struct ChassisPin { name: String, section: &'static str, current: String, + range: Option, } -/// The discovered chassis: the pins to bump, the index of the primary seed -/// (whose range the `--to`-omitted registry lookup and the `from` report use), -/// and every distinct `[requires]` key a seed matched. The target must satisfy -/// all of `range_keys`, so a contract naming more than one range can never be -/// crossed silently. +/// The discovered chassis: the pins to bump and the index of the primary seed +/// (the chassis package the contract's range drives, whose headline versions the +/// report and branch name use, and the only pin a `--to` overrides). #[derive(Debug)] struct Chassis { pins: Vec, primary: usize, - range_key: String, - range_keys: Vec, } const SECTIONS: [&str; 2] = ["dependencies", "devDependencies"]; -/// The unscoped tail of a package name: `@enrahitu/toolchain` -> `toolchain`. +/// The unscoped tail of a package name: `@statecrafting/toolchain` -> `toolchain`. fn unscoped(name: &str) -> &str { name.rsplit('/').next().unwrap_or(name) } -/// The scope of a package name, including the leading `@`: `@enrahitu/toolchain` -/// -> `Some("@enrahitu")`; an unscoped name has no scope. +/// The scope of a package name, including the leading `@`: `@statecrafting/toolchain` +/// -> `Some("@statecrafting")`; an unscoped name has no scope. fn scope_of(name: &str) -> Option<&str> { name.strip_prefix('@') .map(|_| name.split('/').next().unwrap_or(name)) @@ -399,7 +366,8 @@ fn discover_chassis( .filter_map(|n| scope_of(n).map(str::to_string)) .collect(); - // Candidates: every seed, plus every dependency sharing a chassis scope. + // Candidates: every seed, plus every dependency sharing a chassis scope. A + // seed carries its `[requires]` range; a companion carries `None`. let pins: Vec = deps .iter() .filter(|(name, spec, _)| { @@ -410,6 +378,9 @@ fn discover_chassis( name: name.clone(), section, current: spec.trim().to_string(), + range: seed_keys + .get(name) + .and_then(|key| requires.get(key).cloned()), }) .collect(); @@ -419,45 +390,111 @@ fn discover_chassis( )); } - // The primary seed is the one whose range drives the `--to`-omitted registry - // lookup and the `from` report: prefer a seed that is itself an exact pin, - // else fall back to the first exact pin discovered. - let primary = pins - .iter() - .position(|p| seed_keys.contains_key(&p.name)) - .unwrap_or(0); - let range_key = seed_keys - .get(&pins[primary].name) - .cloned() - .or_else(|| seed_keys.values().next().cloned()) - .unwrap_or_default(); - // Every distinct requirement key a seed matched: the target is gated against - // all of them, not just the primary's (so multiple ranges cannot be crossed - // silently). `seed_keys` is a BTreeMap, so this is sorted and deduplicated. - let mut range_keys: Vec = seed_keys.values().cloned().collect(); - range_keys.dedup(); - - Ok(Chassis { - pins, - primary, - range_key, - range_keys, - }) + // The primary seed is the chassis package the contract's range drives (the + // headline `from`/`to` and the only pin `--to` overrides): prefer a seed + // (one carrying a named range), else fall back to the first exact pin. + let primary = pins.iter().position(|p| p.range.is_some()).unwrap_or(0); + + Ok(Chassis { pins, primary }) +} + +// --- per-package target resolution ------------------------------------------ + +/// One chassis pin with its own independently-resolved target version. +#[derive(Debug)] +struct ResolvedPin { + pin: ChassisPin, + target: String, +} + +/// Resolve each discovered chassis pin to its own target (spec 006 §2). A seed +/// resolves the greatest published version its `[requires]` range allows; a +/// companion resolves the greatest published version within a caret of its +/// current pin, so an unnamed companion never crosses its own major. `--to` +/// overrides the primary seed's target only, gated against that seed's range; +/// every other pin auto-resolves, so `--to` can never pin a companion to a +/// version it never published. A bad `--to` is a refusal before any registry +/// read. +fn resolve_targets( + runner: &dyn Runner, + dir: &Path, + chassis: &Chassis, + to: Option<&str>, +) -> Result, Refusal> { + let forced = match to { + Some(raw) => Some(Version::parse(raw.trim_start_matches('v')).map_err(|e| { + Refusal::BadTarget(format!("`--to {raw}` is not a semver version: {e}")) + })?), + None => None, + }; + + let mut resolved = Vec::with_capacity(chassis.pins.len()); + for (idx, pin) in chassis.pins.iter().enumerate() { + // The governing range: a seed's own `[requires]` range, else a caret of + // the companion's current pin (which is a valid exact version, so this + // always parses). + let range = pin + .range + .clone() + .unwrap_or_else(|| format!("^{}", pin.current)); + let req = VersionReq::parse(&range).map_err(|e| { + Refusal::Io(format!( + "range `{range}` for `{}` is not a semver range: {e}", + pin.name + )) + })?; + + let target = match &forced { + // A forced `--to` targets the primary seed and is gated against its + // range: outside it, this is a migration, not an upgrade. + Some(v) if idx == chassis.primary => { + if !req.matches(v) { + return Err(Refusal::IncompatibleTarget(format!( + "target {v} is outside the contract's compatible range `{range}` for `{}`; a major upgrade requires the migration path, not this verb", + pin.name + ))); + } + v.clone() + } + // Every other pin (and every pin when `--to` is omitted) resolves the + // greatest published version its own range allows. + _ => runner.latest_version(dir, &pin.name, &req).map_err(|e| { + Refusal::Io(format!( + "could not resolve the latest compatible version of {}: {e}", + pin.name + )) + })?, + }; + + resolved.push(ResolvedPin { + pin: pin.clone(), + target: target.to_string(), + }); + } + + Ok(resolved) } // --- format-preserving pin rewrite ------------------------------------------ -/// Re-pin every chassis dependency to `target` in the original `package.json` -/// text. String-level so key order, indentation, and trailing bytes survive -/// untouched: the git diff is exactly the version bumps. -fn apply_pins(text: &str, pins: &[ChassisPin], target: &str) -> Result { +/// Re-pin every chassis dependency to its own resolved target in the original +/// `package.json` text. String-level so key order, indentation, and trailing +/// bytes survive untouched: the git diff is exactly the version bumps. +fn apply_pins(text: &str, resolved: &[ResolvedPin]) -> Result { let mut out = text.to_string(); - for pin in pins { - out = set_pin(&out, pin.section, &pin.name, target)?; + for r in resolved { + out = set_pin(&out, r.pin.section, &r.pin.name, &r.target)?; } Ok(out) } +/// The greatest of `versions` that satisfies `req`, or `None` when none do. +/// Shared by the real runner (after `npm view`) and the test runner, so both +/// select a target the same way. +fn select_latest(versions: &[Version], req: &VersionReq) -> Option { + versions.iter().filter(|v| req.matches(v)).max().cloned() +} + /// Replace the version string of `"name"` inside the object introduced by the /// first `"section"` key. Only the value's inner characters change. fn set_pin(text: &str, section: &str, name: &str, new_version: &str) -> Result { @@ -766,13 +803,21 @@ impl Runner for ProcessRunner { String::from_utf8_lossy(&out.stderr).trim() ); } - let versions: Vec = serde_json::from_slice(&out.stdout) + // `npm view versions --json` returns a JSON array, but for a + // package with exactly one published version some npm builds return a + // bare JSON string. Accept both so single-version companions resolve. + let raw: Value = serde_json::from_slice(&out.stdout) .map_err(|e| anyhow::anyhow!("npm returned versions we could not parse: {e}"))?; - versions - .iter() - .filter_map(|v| Version::parse(v).ok()) - .filter(|v| req.matches(v)) - .max() + let versions: Vec = match raw { + Value::Array(items) => items + .iter() + .filter_map(|v| v.as_str()) + .filter_map(|v| Version::parse(v).ok()) + .collect(), + Value::String(v) => Version::parse(&v).into_iter().collect(), + _ => Vec::new(), + }; + select_latest(&versions, req) .ok_or_else(|| anyhow::anyhow!("no published version of {pkg} satisfies `{req}`")) } @@ -863,14 +908,29 @@ mod tests { } /// A runner that records what it was asked to do and never touches the disk. + /// `published` maps a package name to its published versions, so per-package + /// resolution runs through the same [`select_latest`] the real runner uses. #[derive(Default)] struct FakeRunner { clean: bool, verify_pass: bool, - latest: Option, + published: BTreeMap>, log: RefCell>, } + /// Build a `published` map from `(package, &[versions])` pairs. + fn published(entries: &[(&str, &[&str])]) -> BTreeMap> { + entries + .iter() + .map(|(pkg, vs)| { + ( + (*pkg).to_string(), + vs.iter().map(|v| Version::parse(v).unwrap()).collect(), + ) + }) + .collect() + } + impl Runner for FakeRunner { fn working_tree_clean(&self, _dir: &Path) -> anyhow::Result { Ok(self.clean) @@ -878,12 +938,15 @@ mod tests { fn latest_version( &self, _dir: &Path, - _pkg: &str, - _req: &VersionReq, + pkg: &str, + req: &VersionReq, ) -> anyhow::Result { - self.latest - .clone() - .ok_or_else(|| anyhow::anyhow!("no latest configured")) + let versions = self + .published + .get(pkg) + .ok_or_else(|| anyhow::anyhow!("no published versions configured for {pkg}"))?; + select_latest(versions, req) + .ok_or_else(|| anyhow::anyhow!("no configured version of {pkg} satisfies {req}")) } fn create_branch(&self, _dir: &Path, branch: &str) -> anyhow::Result<()> { self.log.borrow_mut().push(format!("branch {branch}")); @@ -915,22 +978,31 @@ mod tests { fn discovers_scoped_chassis_and_companions() { let requires: BTreeMap = [ ("node".into(), ">=24".into()), - ("toolchain".into(), "^0.1".into()), + ("toolchain".into(), "^0.3".into()), ] .into_iter() .collect(); let pkg = parsed(&package( - " \"@enrahitu/hiqlite-native\": \"0.1.0\",\n \"left-pad\": \"^1.0.0\"", - " \"@enrahitu/toolchain\": \"0.1.0\"", + " \"@statecrafting/hiqlite-native\": \"0.1.0\",\n \"left-pad\": \"^1.0.0\"", + " \"@statecrafting/toolchain\": \"0.1.0\"", )); let chassis = discover_chassis(&pkg, &requires).unwrap(); let names: Vec<&str> = chassis.pins.iter().map(|p| p.name.as_str()).collect(); // The seed and its scope companion are bumped; a third-party dep is not. - assert!(names.contains(&"@enrahitu/toolchain")); - assert!(names.contains(&"@enrahitu/hiqlite-native")); + assert!(names.contains(&"@statecrafting/toolchain")); + assert!(names.contains(&"@statecrafting/hiqlite-native")); assert!(!names.contains(&"left-pad")); - assert_eq!(chassis.range_key, "toolchain"); - assert_eq!(chassis.pins[chassis.primary].name, "@enrahitu/toolchain"); + // The primary is the named seed and carries its `[requires]` range; the + // companion is discovered by scope alone and carries no named range. + let primary = &chassis.pins[chassis.primary]; + assert_eq!(primary.name, "@statecrafting/toolchain"); + assert_eq!(primary.range.as_deref(), Some("^0.3")); + let companion = chassis + .pins + .iter() + .find(|p| p.name == "@statecrafting/hiqlite-native") + .unwrap(); + assert_eq!(companion.range, None); } #[test] @@ -951,8 +1023,8 @@ mod tests { [("toolchain".into(), "^0.1".into())].into_iter().collect(); // The chassis is present but as a file: link, as in the template repo. let pkg = parsed(&package( - " \"@enrahitu/hiqlite-native\": \"file:./addon\"", - " \"@enrahitu/toolchain\": \"file:./packages/toolchain\"", + " \"@statecrafting/hiqlite-native\": \"file:./addon\"", + " \"@statecrafting/toolchain\": \"file:./packages/toolchain\"", )); let err = discover_chassis(&pkg, &requires).unwrap_err(); assert_eq!(err.kind(), "local_chassis"); @@ -961,28 +1033,48 @@ mod tests { #[test] fn set_pin_preserves_surrounding_text() { let text = package( - " \"@enrahitu/hiqlite-native\": \"0.1.0\",\n \"left-pad\": \"1.0.0\"", - " \"@enrahitu/toolchain\": \"0.1.0\"", + " \"@statecrafting/hiqlite-native\": \"0.1.0\",\n \"left-pad\": \"1.0.0\"", + " \"@statecrafting/toolchain\": \"0.1.0\"", ); - let out = set_pin(&text, "devDependencies", "@enrahitu/toolchain", "0.2.0").unwrap(); - assert!(out.contains("\"@enrahitu/toolchain\": \"0.2.0\"")); + let out = set_pin( + &text, + "devDependencies", + "@statecrafting/toolchain", + "0.2.0", + ) + .unwrap(); + assert!(out.contains("\"@statecrafting/toolchain\": \"0.2.0\"")); // The unrelated dependency and its pin are untouched. assert!(out.contains("\"left-pad\": \"1.0.0\"")); - assert!(out.contains("\"@enrahitu/hiqlite-native\": \"0.1.0\"")); + assert!(out.contains("\"@statecrafting/hiqlite-native\": \"0.1.0\"")); } #[test] - fn apply_pins_bumps_every_chassis_dep() { + fn apply_pins_writes_each_pins_own_target() { + // Per-package targets: the seed and its companion can bump to different + // versions, and `apply_pins` writes each pin's own resolved target. let requires: BTreeMap = [("toolchain".into(), "^0.1".into())].into_iter().collect(); let text = package( - " \"@enrahitu/hiqlite-native\": \"0.1.0\"", - " \"@enrahitu/toolchain\": \"0.1.0\"", + " \"@statecrafting/hiqlite-native\": \"0.1.0\"", + " \"@statecrafting/toolchain\": \"0.1.0\"", ); let chassis = discover_chassis(&parsed(&text), &requires).unwrap(); - let out = apply_pins(&text, &chassis.pins, "0.1.5").unwrap(); - assert!(out.contains("\"@enrahitu/toolchain\": \"0.1.5\"")); - assert!(out.contains("\"@enrahitu/hiqlite-native\": \"0.1.5\"")); + let resolved: Vec = chassis + .pins + .iter() + .map(|p| ResolvedPin { + pin: p.clone(), + target: if p.name == "@statecrafting/toolchain" { + "0.1.5".to_string() + } else { + "0.1.2".to_string() + }, + }) + .collect(); + let out = apply_pins(&text, &resolved).unwrap(); + assert!(out.contains("\"@statecrafting/toolchain\": \"0.1.5\"")); + assert!(out.contains("\"@statecrafting/hiqlite-native\": \"0.1.2\"")); } #[test] @@ -993,7 +1085,7 @@ mod tests { manifest("toolchain = \"^0.1\"", Some("npm test")), ) .unwrap(); - let pkg = package("", " \"@enrahitu/toolchain\": \"0.1.0\""); + let pkg = package("", " \"@statecrafting/toolchain\": \"0.1.0\""); std::fs::write(dir.join("package.json"), &pkg).unwrap(); let runner = FakeRunner::default(); @@ -1020,7 +1112,7 @@ mod tests { .unwrap(); std::fs::write( dir.join("package.json"), - package("", " \"@enrahitu/toolchain\": \"0.1.0\""), + package("", " \"@statecrafting/toolchain\": \"0.1.0\""), ) .unwrap(); @@ -1042,7 +1134,7 @@ mod tests { manifest("toolchain = \"^0.1\"", Some("npm test")), ) .unwrap(); - let pkg = package("", " \"@enrahitu/toolchain\": \"0.1.0\""); + let pkg = package("", " \"@statecrafting/toolchain\": \"0.1.0\""); std::fs::write(dir.join("package.json"), &pkg).unwrap(); let runner = FakeRunner { @@ -1068,7 +1160,7 @@ mod tests { .unwrap(); std::fs::write( dir.join("package.json"), - package("", " \"@enrahitu/toolchain\": \"0.1.0\""), + package("", " \"@statecrafting/toolchain\": \"0.1.0\""), ) .unwrap(); @@ -1097,7 +1189,7 @@ mod tests { ); // The bump is on disk. let written = std::fs::read_to_string(dir.join("package.json")).unwrap(); - assert!(written.contains("\"@enrahitu/toolchain\": \"0.1.5\"")); + assert!(written.contains("\"@statecrafting/toolchain\": \"0.1.5\"")); } #[test] @@ -1110,7 +1202,7 @@ mod tests { .unwrap(); std::fs::write( dir.join("package.json"), - package("", " \"@enrahitu/toolchain\": \"0.1.0\""), + package("", " \"@statecrafting/toolchain\": \"0.1.0\""), ) .unwrap(); @@ -1139,14 +1231,14 @@ mod tests { .unwrap(); std::fs::write( dir.join("package.json"), - package("", " \"@enrahitu/toolchain\": \"0.1.0\""), + package("", " \"@statecrafting/toolchain\": \"0.1.0\""), ) .unwrap(); let runner = FakeRunner { clean: true, verify_pass: true, - latest: Some(Version::new(0, 1, 9)), + published: published(&[("@statecrafting/toolchain", &["0.1.0", "0.1.9"])]), ..Default::default() }; let report = run(&runner, &dir, None, false, false).unwrap(); @@ -1167,7 +1259,7 @@ mod tests { from: "0.1.0".to_string(), to: "0.1.5".to_string(), pins: vec![PinReport { - name: "@enrahitu/toolchain".to_string(), + name: "@statecrafting/toolchain".to_string(), from: "0.1.0".to_string(), to: "0.1.5".to_string(), section: "devDependencies".to_string(), @@ -1198,7 +1290,7 @@ mod tests { from: "0.1.0".to_string(), to: "0.1.5".to_string(), pins: vec![PinReport { - name: "@enrahitu/toolchain".to_string(), + name: "@statecrafting/toolchain".to_string(), from: "0.1.0".to_string(), to: "0.1.5".to_string(), section: "devDependencies".to_string(), @@ -1236,9 +1328,10 @@ mod tests { } #[test] - fn companion_behind_the_primary_is_not_a_no_op() { - // The primary is already at the target but a scope companion lags: the - // no-op check compares every pin, so the companion is still bumped. + fn companion_behind_the_primary_is_bumped_independently() { + // The primary seed is forced to its target; a scope companion lags and + // auto-resolves against its own published set, so the companion is + // bumped on its own track rather than inheriting the primary's version. let dir = tempdir(); std::fs::write( dir.join("template.toml"), @@ -1248,8 +1341,8 @@ mod tests { std::fs::write( dir.join("package.json"), package( - " \"@enrahitu/hiqlite-native\": \"0.1.0\"", - " \"@enrahitu/toolchain\": \"0.1.5\"", + " \"@statecrafting/hiqlite-native\": \"0.1.0\"", + " \"@statecrafting/toolchain\": \"0.1.5\"", ), ) .unwrap(); @@ -1257,46 +1350,71 @@ mod tests { let runner = FakeRunner { clean: true, verify_pass: true, + published: published(&[("@statecrafting/hiqlite-native", &["0.1.0", "0.1.2"])]), ..Default::default() }; + // Primary toolchain forced to 0.1.5 (already there); the companion + // auto-resolves within `^0.1.0` to its own latest, 0.1.2. let report = run(&runner, &dir, Some("0.1.5"), false, false).unwrap(); // Not the no-op branch: the run proceeded and verify ran. assert_eq!(report.verify, VerifyState::Pass); assert!(report .pins .iter() - .any(|p| p.name == "@enrahitu/hiqlite-native" && p.from == "0.1.0")); + .any(|p| p.name == "@statecrafting/hiqlite-native" + && p.from == "0.1.0" + && p.to == "0.1.2")); } #[test] - fn multi_seed_gate_refuses_when_any_range_excludes_the_target() { - // Two chassis requirements; the target satisfies one range but not the - // other. The gate checks every range, so it refuses regardless of which - // seed happens to be primary. + fn companion_is_never_pinned_to_a_version_it_never_published() { + // The regression the per-package model fixes: the seed resolves forward, + // but a companion that never published the seed's target must stay on its + // own latest, not be pinned to a nonexistent version. let dir = tempdir(); std::fs::write( dir.join("template.toml"), - manifest( - "toolchain = \"<0.1.4\"\nhiqlite-native = \"^0.1\"", - Some("npm test"), - ), + manifest("toolchain = \"^0.1\"", Some("npm test")), ) .unwrap(); std::fs::write( dir.join("package.json"), package( - " \"@enrahitu/hiqlite-native\": \"0.1.0\"", - " \"@enrahitu/toolchain\": \"0.1.0\"", + " \"@statecrafting/hiqlite-native\": \"0.1.0\"", + " \"@statecrafting/toolchain\": \"0.1.0\"", ), ) .unwrap(); let runner = FakeRunner { clean: true, + verify_pass: true, + // toolchain publishes 0.1.5; hiqlite-native only ever published 0.1.0. + published: published(&[ + ("@statecrafting/toolchain", &["0.1.0", "0.1.5"]), + ("@statecrafting/hiqlite-native", &["0.1.0"]), + ]), ..Default::default() }; - let err = run(&runner, &dir, Some("0.1.5"), false, false).unwrap_err(); - assert_eq!(err.kind(), "incompatible_target"); + let report = run(&runner, &dir, None, false, false).unwrap(); + // Seed moves to its own latest; companion stays at 0.1.0 (not 0.1.5). + let seed = report + .pins + .iter() + .find(|p| p.name == "@statecrafting/toolchain") + .unwrap(); + assert_eq!(seed.to, "0.1.5"); + let companion = report + .pins + .iter() + .find(|p| p.name == "@statecrafting/hiqlite-native") + .unwrap(); + assert_eq!(companion.from, "0.1.0"); + assert_eq!(companion.to, "0.1.0"); + // Written to disk verbatim: the companion is not rewritten to 0.1.5. + let written = std::fs::read_to_string(dir.join("package.json")).unwrap(); + assert!(written.contains("\"@statecrafting/hiqlite-native\": \"0.1.0\"")); + assert!(written.contains("\"@statecrafting/toolchain\": \"0.1.5\"")); } /// A unique temp dir under the OS temp root, no external tempfile crate. The diff --git a/tests/cli.rs b/tests/cli.rs index 178c156..bdc0688 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -316,7 +316,7 @@ fn template_upgrade_pre_018_app_is_refused() { fn template_upgrade_dry_run_plans_without_mutating() { // Dry run reaches no git/npm: it reads, plans, and reports. It must change // nothing on disk and exit 0 with the machine result. - let package = "{\n \"name\": \"app\",\n \"devDependencies\": {\n \"@enrahitu/toolchain\": \"0.1.0\"\n }\n}\n"; + let package = "{\n \"name\": \"app\",\n \"devDependencies\": {\n \"@statecrafting/toolchain\": \"0.1.0\"\n }\n}\n"; let dir = stamped_fixture(TOOLCHAIN_TOML, package); let out = run_in( &dir,