Skip to content

Commit 50748e7

Browse files
jingle2008claude
andcommitted
Merge branch 'refactor/category-drift-guards'
Category drift-guards + load-path consolidation (review findings #3, #5): collapse handleDataMsg to its live roles and add drift-guard tests across the tui/domain/keys dispatch planes so a future category that misses a plane fails a test instead of drifting silently. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 parents 5b12049 + 367e9df commit 50748e7

11 files changed

Lines changed: 1057 additions & 77 deletions

‎docs/superpowers/plans/2026-06-22-category-drift-guards.md‎

Lines changed: 581 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Category Drift-Guards + Load-Path Consolidation — Design
2+
3+
**Date:** 2026-06-22
4+
**Status:** Approved (pending implementation)
5+
**Findings:** Code-quality review #3 (category behavior duplicated across dispatch planes) and #5 (overlapping old/new load-message paths).
6+
7+
## Problem
8+
9+
Per-category behavior is encoded across ~19 dispatch planes in ~14 files (domain
10+
methods, the columns registry, a dozen TUI maps/switches, CLI `get`, MCP tools).
11+
Several planes default-fall-through **silently** when a category is missing —
12+
which is how the original GPUWorkload lazy-load bug shipped. Separately, the TUI
13+
has a `dataMsg` type-switch whose per-category cases duplicate the typed
14+
`*LoadedMsg` handlers, and that switch silently omits GPUWorkload.
15+
16+
The chosen scope (explicitly **not** a structural rewrite) is to:
17+
1. Collapse the duplicated load-path so there is one per-category path (#5).
18+
2. Add drift-guards across the partial planes so a future category that misses a
19+
plane **fails a test** instead of drifting silently (#3, root-cause mitigation).
20+
21+
## Non-goals
22+
23+
- No single "category descriptor" struct driving all planes (the larger #3
24+
rewrite). Deferred.
25+
- No shared CLI/MCP/TUI loader resolver (the medium #3 option). Deferred.
26+
- No behavior change to any user-facing command. The only production code change
27+
is removing dead branches in `handleDataMsg` (#5).
28+
29+
## Section 1 — Collapse the dual load-path (#5)
30+
31+
### Current state
32+
33+
- `dataMsg{Data any, Gen int}` has two **live** production roles:
34+
- `dataMsg{Data: *models.Dataset}` — the foundational Init load, produced via
35+
`datasetLoadedMsg` → `handleDataMsg` (`model_update.go`).
36+
- `dataMsg{}` (nil Data) — a refresh-display signal from `refreshDataCmd`
37+
(`reducer_category.go:100`).
38+
- `handleDataMsg` (`model_reducer.go`) type-switches on `msg.Data` with
39+
per-category cases (`[]models.BaseModel`, `map[string][]models.ImportedModel`,
40+
`[]models.GPUPool` + `updateGPUPoolState()`, `map[string][]models.GPUNode`,
41+
`map[string][]models.DedicatedAICluster`, `models.TenancyOverrideGroup`).
42+
These cases are **dead in production**: every per-category load now flows
43+
through typed `*LoadedMsg` → `routeListLoadedMsg` → the typed handlers
44+
(`handleBaseModelsLoaded`, …). Only tests still drive the `dataMsg`
45+
per-category cases. The switch omits GPUWorkload — a latent asymmetry, not a
46+
live bug.
47+
48+
### Change
49+
50+
Reduce `handleDataMsg` to its live roles:
51+
52+
```go
53+
func (m *Model) handleDataMsg(msg dataMsg) tea.Cmd {
54+
// gen-guard unchanged
55+
if msg.Gen != 0 && msg.Gen != m.gens.msg {
56+
m.endTask(true)
57+
return nil
58+
}
59+
if ds, ok := msg.Data.(*models.Dataset); ok {
60+
m.dataset = ds
61+
m.endTask(true)
62+
m.logger.Infow("data loaded", "category", m.category, "pendingTasks", m.pendingTasks)
63+
}
64+
m.refreshDisplay()
65+
return nil
66+
}
67+
```
68+
69+
- Removes the dead per-category cases. `updateGPUPoolState()` is unaffected — it
70+
is already invoked by the typed `handleGPUPoolsLoaded` path.
71+
- `dataMsg` keeps both live roles; the per-category responsibility now lives
72+
solely in the typed family. The overlap (#5) and GPUWorkload omission are gone.
73+
74+
### Tests touched
75+
76+
- `TestProcessDataAndErrorMsg` (`model_test.go:261-269`) currently drives
77+
`handleDataMsg` with per-category `dataMsg` payloads. Repoint these at the
78+
typed handlers (`m.handleBaseModelsLoaded(...)`, `m.handleGPUPoolsLoaded(...)`,
79+
etc.) so real-path coverage is preserved rather than testing removed branches.
80+
- `messages_test.go` (`dataMsg{Data: data}`) — adjust if it asserts a removed
81+
case; keep the `*models.Dataset` and empty-`dataMsg` assertions.
82+
- Empty `dataMsg{}` usages (refresh signal) across other tests are unchanged.
83+
84+
### Guard for #5
85+
86+
A test asserting `handleDataMsg` only acts on `*models.Dataset`/refresh — i.e.
87+
feeding it a per-category payload (e.g. `[]models.GPUPool`) does **not** mutate
88+
the dataset (that path belongs to the typed handler). This locks in the
89+
single-path invariant.
90+
91+
## Section 2 — Drift-guard catalog
92+
93+
New `*_drift_test.go` files, one per package, each iterating `domain.Categories`.
94+
Mirrors the established pattern (`TestRowSources_CoversAllCategories`,
95+
`internal/columns/registry_test.go`). **No production changes** beyond Section 1:
96+
the command-constructor switches (`startK8sWatchCmd`, `reloadCategoryCmd`) build
97+
their `tea.Cmd` lazily, so a test can call them per-category and assert
98+
handled-vs-nil without triggering any watch or load.
99+
100+
Three guard styles:
101+
- **Predicate invariant** — plane membership is derived from a domain truth; the
102+
guard self-maintains (no list to update).
103+
- **Behavior-probe** — call the plane's function for each category and assert the
104+
expected handled/excluded outcome (for switches that can't be introspected).
105+
- **Account-for-all** — for genuine per-category UI choices with no domain
106+
predicate: a table requires every category to be present or in an explicit
107+
exclusion set, so a new category fails until someone makes the decision.
108+
109+
### `internal/domain` — `category_drift_test.go`
110+
111+
- `NeedsKubeConfig`: account-for-all — `want := map[Category]bool{…}` with an
112+
entry for every `domain.Categories`; assert `len(want) == len(Categories)` and
113+
`want[c] == c.NeedsKubeConfig()` for each. New category → missing entry → fail.
114+
- `ScopedCategories`/`Parents`: structural invariant — for every category `c` and
115+
every `child` in `c.ScopedCategories()`, assert `c` appears in
116+
`child.Parents()` (round-trip consistency, no list). Plus an account-for-all
117+
scope table asserting each category's `ScopedCategories()` matches an expected
118+
map covering all categories.
119+
- `Aliases`: invariant — every category has ≥1 alias; aliases are unique across
120+
categories (no collisions); `ParseCategory(alias) == c` round-trips for every
121+
alias of every category.
122+
123+
### `internal/ui/tui` — `category_drift_test.go`
124+
125+
- `lazyLoadedCategories`: predicate — every `c.NeedsKubeConfig()` category is in
126+
the set. (Supersedes/absorbs the existing
127+
`TestLazyLoadedCategories_CoversKubeBacked`; remove the old one to avoid
128+
duplication.)
129+
- `handlers` map (`reducer_category.go`): predicate — every kube-backed category
130+
has an entry.
131+
- `startK8sWatchCmd`: behavior-probe — returns non-nil `tea.Cmd` for every
132+
kube-backed category and nil for non-kube categories.
133+
- `reloadCategoryCmd`: behavior-probe — non-nil for the same kube-backed set.
134+
- `statsColumns`: account-for-all — every category present or in an explicit
135+
`noStats` exclusion set; assert the union covers all categories.
136+
- `itemKeyFrom`: behavior-probe — returns a non-nil `ItemKey` for every category,
137+
probing with a representative `table.Row`. If a synthetic row proves
138+
impractical for some categories, the implementer may instead assert that
139+
`itemKeyFrom`'s flat-vs-grouped classification accounts for every category via
140+
an account-for-all table; either mechanism must fail when a category is added
141+
without handling.
142+
- `parentScope`: assert consistency with the domain scope graph — every category
143+
with `Parents()` is handled by `parentScope` (probe, or account-for-all over
144+
the scope-category set if a synthetic row is impractical).
145+
- `deleteItem` ↔ `confirmDelete` consistency: the set of categories for which
146+
`deleteItem` returns non-nil equals the set `confirmDelete` assigns a tier to;
147+
assert they agree (guards the finding-#2 coupling from the confirmation work).
148+
149+
### `internal/ui/tui/keys` — `category_drift_test.go`
150+
151+
- `catContext`: account-for-all — every category present or in an explicit
152+
`noContextKeys` exclusion set; assert the union covers all categories.
153+
154+
### Already guarded (keep, do not duplicate)
155+
156+
- `columns/registry` (`registry_test.go`), `rowSources`
157+
(`TestRowSources_CoversAllCategories`).
158+
159+
## Architecture / isolation
160+
161+
- Each guard test is self-contained in its package's `*_drift_test.go`, reading
162+
unexported registries directly (same-package tests) — no new production
163+
exports.
164+
- Explicit exclusion sets (`noStats`, `noContextKeys`, expected-value tables) live
165+
beside their guard test, documented with one line on why each category is
166+
excluded.
167+
168+
## Error handling
169+
170+
Guards are tests; failures surface at `make ci` / `go test`. No runtime paths
171+
change. The #5 change removes branches only; the foundational-load and
172+
refresh-signal paths are preserved exactly.
173+
174+
## Testing
175+
176+
- Section 1: the repointed `TestProcessDataAndErrorMsg`, the new single-path
177+
guard, and the existing TUI suite must stay green.
178+
- Section 2: each new guard must **fail if a category is removed from its plane**
179+
— verify by temporarily dropping a known category from one plane and confirming
180+
the guard catches it (then revert). This proves the guard actually guards.
181+
- Full `make ci` green (tests, lint, coverage threshold) on the branch.
182+
183+
## Out of scope / future
184+
185+
- The full category-descriptor refactor (#3 "Full") and the shared loader
186+
resolver (#3 "Moderate") remain available as later, larger efforts. These
187+
guards make that refactor safer by pinning current cross-plane invariants
188+
first.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package domain
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// Every category's kube-backed status must be a deliberate, listed choice.
11+
func TestNeedsKubeConfig_EveryCategoryAccountedFor(t *testing.T) {
12+
t.Parallel()
13+
want := map[Category]bool{
14+
Tenant: false,
15+
LimitDefinition: false,
16+
ConsolePropertyDefinition: false,
17+
PropertyDefinition: false,
18+
LimitTenancyOverride: false,
19+
ConsolePropertyTenancyOverride: false,
20+
PropertyTenancyOverride: false,
21+
LimitRegionalOverride: false,
22+
ConsolePropertyRegionalOverride: false,
23+
PropertyRegionalOverride: false,
24+
BaseModel: true,
25+
ImportedModel: true,
26+
ModelArtifact: false,
27+
Environment: false,
28+
ServiceTenancy: false,
29+
GPUPool: false,
30+
GPUNode: true,
31+
GPUWorkload: true,
32+
DedicatedAICluster: true,
33+
Alias: false,
34+
}
35+
require.Len(t, want, len(Categories), "every category must have an expected NeedsKubeConfig value")
36+
for _, c := range Categories {
37+
exp, ok := want[c]
38+
require.Truef(t, ok, "no expected NeedsKubeConfig value for %s", c)
39+
assert.Equalf(t, exp, c.NeedsKubeConfig(), "NeedsKubeConfig mismatch for %s", c)
40+
}
41+
}
42+
43+
// Scope graph must be internally consistent: every child lists its parent back.
44+
func TestScopeGraph_RoundTrips(t *testing.T) {
45+
t.Parallel()
46+
for _, parent := range Categories {
47+
for _, child := range parent.ScopedCategories() {
48+
assert.Containsf(t, child.Parents(), parent,
49+
"%s is scoped by %s but %s is not in %s.Parents()", child, parent, parent, child)
50+
}
51+
}
52+
}
53+
54+
// Aliases must be unique across categories and round-trip through ParseCategory.
55+
func TestAliases_UniqueAndRoundTrip(t *testing.T) {
56+
t.Parallel()
57+
seen := map[string]Category{}
58+
for _, c := range Categories {
59+
aliases := c.Aliases()
60+
assert.NotEmptyf(t, aliases, "%s must have at least one alias", c)
61+
for _, a := range aliases {
62+
if other, dup := seen[a]; dup {
63+
t.Errorf("alias %q is shared by %s and %s", a, other, c)
64+
}
65+
seen[a] = c
66+
got, err := ParseCategory(a)
67+
require.NoErrorf(t, err, "alias %q does not parse", a)
68+
assert.Equalf(t, c, got, "alias %q parses to %s, want %s", a, got, c)
69+
}
70+
}
71+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package tui
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/charmbracelet/bubbles/table"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/jingle2008/toolkit/internal/domain"
12+
"github.com/jingle2008/toolkit/pkg/models"
13+
)
14+
15+
// kubeBackedCategories returns every category that loads from a live cluster.
16+
func kubeBackedCategories() []domain.Category {
17+
var out []domain.Category
18+
for _, c := range domain.Categories {
19+
if c.NeedsKubeConfig() {
20+
out = append(out, c)
21+
}
22+
}
23+
return out
24+
}
25+
26+
// Every kube-backed category must be lazy-loaded, have a load handler, and be
27+
// reloadable/watchable — the cluster of planes the GPUWorkload bug missed.
28+
func TestKubeBackedCategories_FullyWired(t *testing.T) {
29+
t.Parallel()
30+
m := newTestModel(t)
31+
for _, c := range kubeBackedCategories() {
32+
_, lazy := lazyLoadedCategories[c]
33+
assert.Truef(t, lazy, "%s must be in lazyLoadedCategories", c)
34+
35+
_, handled := categoryHandlers[c]
36+
assert.Truef(t, handled, "%s must have a categoryHandlers entry", c)
37+
38+
assert.NotNilf(t, m.reloadCategoryCmd(c, 1), "%s must be reloadable", c)
39+
}
40+
}
41+
42+
// reloadCategoryCmd returns a command only for kube-backed categories.
43+
func TestReloadCategoryCmd_OnlyKubeBacked(t *testing.T) {
44+
t.Parallel()
45+
m := newTestModel(t)
46+
for _, c := range domain.Categories {
47+
got := m.reloadCategoryCmd(c, 1)
48+
if c.NeedsKubeConfig() {
49+
assert.NotNilf(t, got, "%s is kube-backed and must reload", c)
50+
} else {
51+
assert.Nilf(t, got, "%s is not kube-backed and must not reload", c)
52+
}
53+
}
54+
}
55+
56+
// startK8sWatchCmd must start a watch for every kube-backed category.
57+
func TestStartK8sWatchCmd_CoversKubeBacked(t *testing.T) {
58+
t.Parallel()
59+
ld := &watchableLoader{}
60+
for _, c := range kubeBackedCategories() {
61+
cmd := startK8sWatchCmd(context.Background(), ld, c, "kc", models.Environment{}, 1)
62+
require.NotNilf(t, cmd, "%s watch cmd must be built", c)
63+
_, started := cmd().(k8sWatchStartedMsg)
64+
assert.Truef(t, started, "%s must produce k8sWatchStartedMsg, got unavailable", c)
65+
}
66+
}
67+
68+
// noStatsCategories are intentionally without aggregate stat columns. A new
69+
// category must be added here OR to statsColumns — never silently neither.
70+
// Keep in sync with statsColumns (table_utils.go).
71+
var noStatsCategories = map[domain.Category]struct{}{
72+
domain.Tenant: {},
73+
domain.LimitDefinition: {},
74+
domain.ConsolePropertyDefinition: {},
75+
domain.PropertyDefinition: {},
76+
domain.LimitTenancyOverride: {},
77+
domain.ConsolePropertyTenancyOverride: {},
78+
domain.PropertyTenancyOverride: {},
79+
domain.LimitRegionalOverride: {},
80+
domain.ConsolePropertyRegionalOverride: {},
81+
domain.PropertyRegionalOverride: {},
82+
domain.BaseModel: {},
83+
domain.ImportedModel: {},
84+
domain.ModelArtifact: {},
85+
domain.Environment: {},
86+
domain.ServiceTenancy: {},
87+
domain.Alias: {},
88+
}
89+
90+
// Every category either has stat columns or is explicitly listed as having
91+
// none — a new category fails until someone decides.
92+
func TestStatsColumns_EveryCategoryAccountedFor(t *testing.T) {
93+
t.Parallel()
94+
for _, c := range domain.Categories {
95+
_, hasStats := statsColumns[c]
96+
_, excluded := noStatsCategories[c]
97+
assert.Truef(t, hasStats != excluded,
98+
"%s must be in exactly one of statsColumns / noStatsCategories (hasStats=%v excluded=%v)",
99+
c, hasStats, excluded)
100+
}
101+
}
102+
103+
// itemKeyFrom must produce a non-nil key for every category so selection works.
104+
func TestItemKeyFrom_NonNilForEveryCategory(t *testing.T) {
105+
t.Parallel()
106+
row := table.Row{"a", "b", "c", "d"}
107+
for _, c := range domain.Categories {
108+
assert.NotNilf(t, itemKeyFrom(c, row), "itemKeyFrom returned nil for %s", c)
109+
}
110+
}
111+
112+
func TestParentScope_ResolvesForScopedCategories(t *testing.T) {
113+
t.Parallel()
114+
row := table.Row{"a", "b", "c", "d"}
115+
for _, c := range domain.Categories {
116+
// parentScope resolves a parent only for single-parent categories:
117+
// multi-parent categories (the tenancy overrides) can't disambiguate,
118+
// and categories with no parent have no scope. Every single-parent
119+
// category MUST resolve — a new one whose parent is not handled in
120+
// parentScope's switch fails here, surfacing the drift.
121+
if len(c.Parents()) != 1 {
122+
continue
123+
}
124+
_, ok := parentScope(c, row)
125+
assert.Truef(t, ok, "parentScope must resolve single-parent category %s (parent %s)", c, c.Parents()[0])
126+
}
127+
}

0 commit comments

Comments
 (0)