diff --git a/internal/plugin/cli_version.go b/internal/plugin/cli_version.go new file mode 100644 index 000000000..8d41ba104 --- /dev/null +++ b/internal/plugin/cli_version.go @@ -0,0 +1,162 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plugin + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/datarobot/cli/internal/version" +) + +// currentCLIVersion is a package-level seam for the running CLI version. +// It defaults to version.Version, but tests MUST override it (with a +// t.Cleanup/defer restore) rather than relying on the real value: in `go +// test`, version.Version is the literal "dev", which is an unparseable-CLI +// bypass under compatibleCLIVersion and would silently make every bound +// assertion pass regardless of the production logic. +var currentCLIVersion = version.Version + +// coreVersion strips any prerelease/build metadata from v, returning a +// version containing only major.minor.patch. This keeps comparisons +// symmetric: without it, a prerelease running CLI (e.g. 1.2.0-rc.1) would +// compare as less than its own release (1.2.0) under semver ordering, so it +// would fail a maxCLIVersion bound equal to its own release. +func coreVersion(v *semver.Version) *semver.Version { + return semver.New(v.Major(), v.Minor(), v.Patch(), "", "") +} + +// compatibleCLIVersion reports whether cliVersion satisfies the inclusive +// [minBound, maxBound] range. Either bound may be empty, meaning +// unconstrained on that side. Comparison uses core versions (major.minor. +// patch) only, so a prerelease CLI version is judged by its release version. +// +// A malformed minBound or maxBound ALWAYS causes a skip (returns false with +// a non-nil error) — this is checked before the CLI version is even parsed, +// so it takes precedence over the dev/unparseable-CLI-version bypass below. +// +// An unparseable cliVersion (including the default "dev" build) is treated +// as a bypass: once the bounds themselves are confirmed well-formed, the +// plugin loads unconditionally, since there is no reliable CLI version to +// compare against. +func compatibleCLIVersion(cliVersion, minBound, maxBound string) (bool, error) { + if minBound == "" && maxBound == "" { + return true, nil + } + + minVer, err := parseCLIVersionBound("minCLIVersion", minBound) + if err != nil { + return false, err + } + + maxVer, err := parseCLIVersionBound("maxCLIVersion", maxBound) + if err != nil { + return false, err + } + + cli, err := semver.NewVersion(cliVersion) + if err != nil { + // Unparseable/dev running CLI version: bypass now that the declared + // bounds are confirmed well-formed. + return true, nil + } + + return versionWithinBounds(cli, minVer, maxVer), nil +} + +// parseCLIVersionBound parses a declared minCLIVersion/maxCLIVersion value. +// An empty value is unconstrained (nil, nil). field names the manifest +// field in the returned error, for callers to surface an actionable message. +func parseCLIVersionBound(field, value string) (*semver.Version, error) { + if value == "" { + return nil, nil + } + + v, err := semver.NewVersion(value) + if err != nil { + return nil, fmt.Errorf("invalid %s %q: %w", field, value, err) + } + + return v, nil +} + +// versionWithinBounds reports whether cli's core version falls within the +// inclusive [minVer, maxVer] range. Either bound may be nil, meaning +// unconstrained on that side. +func versionWithinBounds(cli, minVer, maxVer *semver.Version) bool { + core := coreVersion(cli) + + if minVer != nil && core.LessThan(coreVersion(minVer)) { + return false + } + + if maxVer != nil && core.GreaterThan(coreVersion(maxVer)) { + return false + } + + return true +} + +// cliVersionSkip evaluates manifest's declared CLI version bounds against +// currentCLIVersion and returns a PluginConflict describing the skip when +// the plugin is not compatible, or nil when it may load. path identifies the +// executable (or managed plugin dir) being evaluated, for reporting. +func cliVersionSkip(manifest *PluginManifest, path string) *PluginConflict { + ok, err := compatibleCLIVersion(currentCLIVersion, manifest.MinCLIVersion, manifest.MaxCLIVersion) + if err == nil && ok { + return nil + } + + var detail string + + switch { + case err != nil: + detail = err.Error() + case manifest.MinCLIVersion != "" && manifest.MaxCLIVersion != "": + // Both bounds declared and the combined check failed: report the + // bound that actually breached, preferring the minimum since a + // version cannot violate both an inclusive min and an inclusive max + // unless the manifest's own range is inverted. + minOK, _ := compatibleCLIVersion(currentCLIVersion, manifest.MinCLIVersion, "") + if !minOK { + detail = fmt.Sprintf( + "requires dr >= %s (running %s); run 'dr self update'", + manifest.MinCLIVersion, currentCLIVersion, + ) + } else { + detail = fmt.Sprintf( + "supports dr <= %s (running %s); update the plugin", + manifest.MaxCLIVersion, currentCLIVersion, + ) + } + case manifest.MinCLIVersion != "": + detail = fmt.Sprintf( + "requires dr >= %s (running %s); run 'dr self update'", + manifest.MinCLIVersion, currentCLIVersion, + ) + default: + detail = fmt.Sprintf( + "supports dr <= %s (running %s); update the plugin", + manifest.MaxCLIVersion, currentCLIVersion, + ) + } + + return &PluginConflict{ + Name: manifest.Name, + Path: path, + Reason: SkipReasonVersionIncompatible, + Detail: detail, + } +} diff --git a/internal/plugin/cli_version_test.go b/internal/plugin/cli_version_test.go new file mode 100644 index 000000000..04bb6f2fd --- /dev/null +++ b/internal/plugin/cli_version_test.go @@ -0,0 +1,236 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plugin + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompatibleCLIVersion(t *testing.T) { + tests := []struct { + name string + cliVersion string + minBound string + maxBound string + wantOK bool + wantErr bool + }{ + { + name: "no bounds declared", + cliVersion: "1.0.0", + minBound: "", + maxBound: "", + wantOK: true, + }, + { + name: "min-only bound satisfied above minimum", + cliVersion: "1.5.0", + minBound: "1.0.0", + maxBound: "", + wantOK: true, + }, + { + name: "min-only bound satisfied at inclusive boundary", + cliVersion: "1.0.0", + minBound: "1.0.0", + maxBound: "", + wantOK: true, + }, + { + name: "min-only bound violated below minimum", + cliVersion: "1.9.0", + minBound: "2.0.0", + maxBound: "", + wantOK: false, + }, + { + name: "max-only bound satisfied at inclusive boundary", + cliVersion: "2.3.0", + minBound: "", + maxBound: "2.3.0", + wantOK: true, + }, + { + name: "max-only bound violated above maximum", + cliVersion: "2.3.1", + minBound: "", + maxBound: "2.3.0", + wantOK: false, + }, + { + name: "both bounds satisfied", + cliVersion: "1.5.0", + minBound: "1.0.0", + maxBound: "2.0.0", + wantOK: true, + }, + { + name: "prerelease CLI version compared by core version", + cliVersion: "1.2.0-rc.1", + minBound: "", + maxBound: "1.2.0", + wantOK: true, + }, + { + name: "dev CLI version bypasses well-formed bounds", + cliVersion: "dev", + minBound: "2.0.0", + maxBound: "", + wantOK: true, + }, + { + name: "unparseable CLI version bypasses well-formed bounds", + cliVersion: "not-a-semver", + minBound: "", + maxBound: "1.0.0", + wantOK: true, + }, + { + name: "malformed min bound is always a skip", + cliVersion: "1.5.0", + minBound: "1.x", + maxBound: "", + wantOK: false, + wantErr: true, + }, + { + name: "malformed max bound is always a skip", + cliVersion: "1.5.0", + minBound: "", + maxBound: "2.x", + wantOK: false, + wantErr: true, + }, + { + name: "malformed bound takes precedence over dev CLI bypass", + cliVersion: "dev", + minBound: "1.x", + maxBound: "", + wantOK: false, + wantErr: true, + }, + { + name: "malformed bound takes precedence over unparseable CLI bypass", + cliVersion: "garbage", + minBound: "", + maxBound: "2.x", + wantOK: false, + wantErr: true, + }, + { + name: "v-prefixed bounds are accepted", + cliVersion: "1.5.0", + minBound: "v1.0.0", + maxBound: "v2.0.0", + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, err := compatibleCLIVersion(tt.cliVersion, tt.minBound, tt.maxBound) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantOK, ok) + }) + } +} + +func TestCliVersionSkip(t *testing.T) { + t.Run("no bounds returns nil", func(t *testing.T) { + restore := setCurrentCLIVersionForTest(t, "1.5.0") + defer restore() + + manifest := &PluginManifest{BasicPluginManifest: BasicPluginManifest{Name: "widget"}} + + assert.Nil(t, cliVersionSkip(manifest, "/usr/local/bin/dr-widget")) + }) + + t.Run("below minimum returns a version-incompatible conflict naming the upgrade path", func(t *testing.T) { + restore := setCurrentCLIVersionForTest(t, "1.9.0") + defer restore() + + manifest := &PluginManifest{ + BasicPluginManifest: BasicPluginManifest{Name: "widget"}, + MinCLIVersion: "2.0.0", + } + + conflict := cliVersionSkip(manifest, "/usr/local/bin/dr-widget") + + require.NotNil(t, conflict) + assert.Equal(t, "widget", conflict.Name) + assert.Equal(t, "/usr/local/bin/dr-widget", conflict.Path) + assert.Equal(t, SkipReasonVersionIncompatible, conflict.Reason) + assert.Contains(t, conflict.Detail, "2.0.0") + assert.Contains(t, conflict.Detail, "1.9.0") + assert.Contains(t, conflict.Detail, "dr self update") + }) + + t.Run("above maximum returns a version-incompatible conflict naming the plugin update", func(t *testing.T) { + restore := setCurrentCLIVersionForTest(t, "1.6.0") + defer restore() + + manifest := &PluginManifest{ + BasicPluginManifest: BasicPluginManifest{Name: "widget"}, + MaxCLIVersion: "1.5.0", + } + + conflict := cliVersionSkip(manifest, "/usr/local/bin/dr-widget") + + require.NotNil(t, conflict) + assert.Equal(t, SkipReasonVersionIncompatible, conflict.Reason) + assert.Contains(t, conflict.Detail, "1.5.0") + assert.Contains(t, conflict.Detail, "1.6.0") + assert.Contains(t, conflict.Detail, "update the plugin") + }) + + t.Run("malformed bound returns a version-incompatible conflict naming the field", func(t *testing.T) { + restore := setCurrentCLIVersionForTest(t, "1.6.0") + defer restore() + + manifest := &PluginManifest{ + BasicPluginManifest: BasicPluginManifest{Name: "widget"}, + MaxCLIVersion: "1.x", + } + + conflict := cliVersionSkip(manifest, "/usr/local/bin/dr-widget") + + require.NotNil(t, conflict) + assert.Equal(t, SkipReasonVersionIncompatible, conflict.Reason) + assert.Contains(t, conflict.Detail, "maxCLIVersion") + assert.Contains(t, conflict.Detail, "1.x") + }) +} + +// setCurrentCLIVersionForTest overrides the currentCLIVersion seam for the +// duration of a test and returns a func that restores the prior value. +// version.Version is "dev" under `go test`, which would otherwise bypass +// every bound check and make these assertions vacuous. +func setCurrentCLIVersionForTest(t *testing.T, v string) func() { + t.Helper() + + prev := currentCLIVersion + currentCLIVersion = v + + return func() { currentCLIVersion = prev } +} diff --git a/internal/plugin/types.go b/internal/plugin/types.go index ed444f410..63406bcaf 100644 --- a/internal/plugin/types.go +++ b/internal/plugin/types.go @@ -41,7 +41,8 @@ type BasicPluginManifest struct { type PluginManifest struct { BasicPluginManifest Scripts *PluginScripts `json:"scripts,omitempty"` // Platform-specific script paths - MinCLIVersion string `json:"minCLIVersion,omitempty"` // Minimum CLI version required + MinCLIVersion string `json:"minCLIVersion,omitempty"` // Minimum CLI version required (inclusive) + MaxCLIVersion string `json:"maxCLIVersion,omitempty"` // Maximum CLI version supported (inclusive) } // RegistryVersion represents a specific version in the plugin registry. @@ -80,18 +81,42 @@ type DiscoveredPlugin struct { Executable string // Full path to executable } +// PluginSkipReason classifies why a plugin was excluded from discovery +// results and reported via the PluginConflict channel instead of loading. +type PluginSkipReason int + +const ( + // SkipReasonNameConflict is the zero value: a plugin with the same + // manifest name was already registered from a higher-priority location. + // This is the pre-existing (and only) skip reason before CLI version + // constraints were introduced, so every existing PluginConflict literal + // that omits Reason keeps its original meaning. + SkipReasonNameConflict PluginSkipReason = iota + + // SkipReasonVersionIncompatible means the plugin declared a + // minCLIVersion/maxCLIVersion bound that the running CLI version + // violates, or declared a malformed bound. + SkipReasonVersionIncompatible +) + // PluginConflict records a plugin executable that was skipped during -// discovery because a plugin with the same manifest name was already +// discovery, either because a plugin with the same manifest name was already // registered from a higher-priority location (managed dirs > local plugin -// dir > PATH; first match within a tier wins). +// dir > PATH; first match within a tier wins), or because it declared a CLI +// version bound (Reason == SkipReasonVersionIncompatible) that the running +// CLI does not satisfy. Detail carries reason-specific, human-readable +// context (e.g. the violated bound and the running CLI version); it is +// empty for name conflicts. // // Discovery returns conflicts as data instead of logging them directly, so // each caller can decide which ones are relevant: `dr plugin list` and // command registration warn about all of them, while `dr plugin version // ` only surfaces conflicts for the plugin actually being asked about. type PluginConflict struct { - Name string // manifest name that was already registered - Path string // executable (or managed plugin dir) that was skipped + Name string // manifest name that was already registered + Path string // executable (or managed plugin dir) that was skipped + Reason PluginSkipReason // why the plugin was skipped (zero value: name conflict) + Detail string // reason-specific detail (empty for name conflicts) } // DiscoveredPluginsRegistry holds discovered plugins with lazy initialization. diff --git a/internal/plugin/validation.go b/internal/plugin/validation.go index 56d2130b7..7020847fe 100644 --- a/internal/plugin/validation.go +++ b/internal/plugin/validation.go @@ -47,7 +47,8 @@ func validatePluginName(name string) error { } // ValidatePluginScript validates that a plugin script outputs a manifest matching the expected manifest. -// All fields must match exactly, including Scripts and MinCLIVersion for managed plugins. +// All fields must match exactly, except Scripts, MinCLIVersion, and MaxCLIVersion, which are +// optional managed-plugin fields (see validateManifests). func ValidatePluginScript(pluginDir string, expectedManifest PluginManifest) error { if err := ValidateLicense(pluginDir); err != nil { return err @@ -106,9 +107,9 @@ var createManifestValidatorOnce = sync.OnceValue(func() *validator.Validate { }) // validateManifests validates the script output manifest and checks that the -// core BasicPluginManifest fields match expected. Scripts and MinCLIVersion -// are intentionally ignored — they are optional managed-plugin fields that -// PATH plugins do not output. +// core BasicPluginManifest fields match expected. Scripts, MinCLIVersion, and +// MaxCLIVersion are intentionally ignored — they are optional managed-plugin +// fields that PATH plugins do not output. // // The field-by-field comparison below could be replaced with go-cmp, but was // written out explicitly to avoid adding that dependency. @@ -126,7 +127,7 @@ func validateManifests(expected, actual PluginManifest) error { var mismatches []string - // Fields `Scripts` and `MinCLIVersion` are ignored as they're optional managed plugin fields + // Fields `Scripts`, `MinCLIVersion`, and `MaxCLIVersion` are ignored as they're optional managed plugin fields if actual.Name != expected.Name { mismatches = append(mismatches, fmt.Sprintf("Name: expected %q, got %q", expected.Name, actual.Name)) } diff --git a/openspec/changes/cli-plugin-version-constraints/tasks.md b/openspec/changes/cli-plugin-version-constraints/tasks.md new file mode 100644 index 000000000..084adc660 --- /dev/null +++ b/openspec/changes/cli-plugin-version-constraints/tasks.md @@ -0,0 +1,66 @@ +# Tasks: Plugin manifest CLI version constraints (CFX-4730) + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | ~400-430 | +| 400-line budget risk | High | +| Chained PRs recommended | Yes | +| Suggested split | PR 1 (mechanism, ~180 lines) → PR 2 (integration, ~230 lines) | +| Delivery strategy | auto-chain | +| Chain strategy | stacked-to-main | + +Decision needed before apply: No +Chained PRs recommended: Yes +Chain strategy: stacked-to-main +400-line budget risk: High + +### Suggested Work Units + +| Unit | Goal | Likely PR | Focused test command | Runtime harness | Rollback boundary | +|------|------|-----------|----------------------|-----------------|-------------------| +| 1 | Add `MaxCLIVersion`, `PluginSkipReason`, `Reason`/`Detail`, and inert `compatibleCLIVersion`/`cliVersionSkip` predicate + seam; no call sites wired | PR 1 | `go test ./internal/plugin/... -run 'CLIVersion|TestValidateManifests' -race` | N/A — pure predicate, no discovery behavior change yet | Revert `cli_version.go`, `cli_version_test.go`, and the `types.go`/`validation.go` diffs; discovery untouched | +| 2 | Wire `cliVersionSkip` into both discover.go call sites before `seen[...]`, level-branch `LogConflicts`/add `ConflictsForReason`, update `cmd/plugin/discovery.go`, docs | PR 2 | `go test ./internal/plugin/... ./cmd/plugin/... -race` | `dr plugin list` / `dr plugin version ` against a manifest with `maxCLIVersion` below current | Revert discover.go/discovery.go diffs; PR 1's inert predicate stays, plugins keep loading unconditionally | + +## Phase 1: Foundation — Types (PR 1) + +- [x] 1.1 In `internal/plugin/types.go`: add `MaxCLIVersion string` (`json:"maxCLIVersion,omitempty"`) to `PluginManifest`; add `PluginSkipReason` type with `SkipReasonNameConflict = iota` (zero value); add `Reason PluginSkipReason` and `Detail string` to `PluginConflict`. Spec: Manifest Version Bound Fields. +- [x] 1.2 Update `PluginConflict` doc comment to describe the extended reason/detail contract without changing existing zero-value literals' meaning. + +## Phase 2: Foundation — Predicate (PR 1, TDD) + +- [x] 2.1 RED: create `internal/plugin/cli_version_test.go` with table-driven `TestCompatibleCLIVersion` covering: no bounds; min satisfied/at-boundary/violated; max at-boundary (loads)/violated; both bounds; `1.2.0-rc.1` vs `max 1.2.0` loads; `dev`/garbage CLI bypass; malformed min/max → `(false, err)` including on `dev` CLI; `v`-prefixed bounds. Spec scenarios: Min-only, Max-only inclusive, Both bounds, Below minimum, Above maximum, Malformed declared bound, Unparseable/dev CLI, Dev CLI with malformed bound, Prerelease CLI. +- [x] 2.2 GREEN: implement `internal/plugin/cli_version.go` with package var `currentCLIVersion = version.Version` (test seam), `coreVersion(v *semver.Version) *semver.Version`, and `compatibleCLIVersion(cliVersion, minBound, maxBound string) (bool, error)` per design's ordering: both empty → true; parse bounds (error → false,err); parse cliVersion (error → true,nil bypass); compare core versions. +- [x] 2.3 GREEN: add `cliVersionSkip(manifest *PluginManifest, path string) *PluginConflict` returning `nil` or `&PluginConflict{Reason: SkipReasonVersionIncompatible, Detail: ...}` with exact wording from design (min/max/malformed templates). +- [x] 2.4 Run `go test ./internal/plugin/... -run CLIVersion -race -v`; confirm all Phase 2.1 cases pass. + +## Phase 3: Validation Comments (PR 1) + +- [x] 3.1 In `internal/plugin/validation.go` (L50, L109-111, L129): update comments to state `MaxCLIVersion` is also excluded from cross-manifest comparison alongside `MinCLIVersion` (field compare already omits it by construction — comment-only change, no `validateManifests` logic edit). + +## Phase 4: Discovery Wiring (PR 2, TDD) + +- [ ] 4.1 RED: in `internal/plugin/discover_test.go`, extend `createManagedTestPlugin` to accept min/max bounds; add case asserting an out-of-range managed plugin is absent from results and present in conflicts with `Reason: SkipReasonVersionIncompatible`. Spec: Managed plugin below minimum. +- [ ] 4.2 RED: add `DiscoverWithContextSuite` case — two PATH dirs export the same manifest name, lexicographically-first violates `maxCLIVersion`; assert second registers under that name, conflicts hold exactly one `SkipReasonVersionIncompatible` record and zero `SkipReasonNameConflict` records. Spec: Skip Ordering Relative to Name Deduplication. +- [ ] 4.3 RED: override `currentCLIVersion` (with `t.Cleanup` restore) in `TestDuplicatePATHEntryDoesNotWarn` and the XDG-config-dirs discovery test to a real semver so the `dev` bypass does not mask assertions. +- [ ] 4.4 Confirm 4.1-4.3 fail against current code (`go test ./internal/plugin/... -race`). +- [ ] 4.5 GREEN: call `cliVersionSkip` in `loadManagedPlugin` (~L304) and `getManifestsParallel` (~L441 loop), both immediately before the `seen[...]` reservation; append its non-nil result to conflicts and `continue` without setting `seen[name]`. +- [ ] 4.6 Run `go test ./internal/plugin/... -race -v` until 4.1-4.3 are GREEN. + +## Phase 5: Reporting & CLI Wiring (PR 2, TDD) + +- [ ] 5.1 RED: extend `TestLogConflicts` asserting name-conflict wording is byte-identical to today, and a `SkipReasonVersionIncompatible` record emits Info-level output including `Detail`. Add `TestConflictsForReason`; extend `TestConflictsForName` fixtures with mixed reasons proving the filter stays reason-agnostic. Spec: Structured Single-Channel Skip Reporting. +- [ ] 5.2 GREEN: add `ConflictsForReason(conflicts []PluginConflict, reason PluginSkipReason) []PluginConflict` in `internal/plugin/discover.go`; change `LogConflicts` to switch on `Reason` — Warn for name conflicts (unchanged wording), Info for version incompatibility. +- [ ] 5.3 GREEN: in `cmd/plugin/discovery.go` (L56), change `RegisterPluginCommands` to call `LogConflicts(ConflictsForReason(conflicts, SkipReasonNameConflict))` and add a Debug line for the remainder, so routine discovery stays silent for version skips. Spec: Silent on routine command discovery. +- [ ] 5.4 Verify `dr plugin list` / `dr plugin version ` surface skipped/incompatible plugins with required-version detail (manual harness run, or existing list/version command tests if present). + +## Phase 6: Documentation (PR 2) + +- [ ] 6.1 `docs/development/plugins.md` (~L82-89): add `maxCLIVersion` and `minCLIVersion` to the Optional fields list with inclusive-bound and dev-bypass semantics. +- [ ] 6.2 `docs/development/remote-plugins.md` (~L61): add `maxCLIVersion` next to the existing `minCLIVersion` example. + +## Phase 7: Final Gates (PR 1 and PR 2, each before opening) + +- [x] 7.1 `task lint` clean on each slice. (PR 1 done; PR 2 pending) +- [x] 7.2 `task test` (race + coverage) green on each slice. (PR 1 done; PR 2 pending)