Files
my-pi/pi-permission-system/docs/architecture/history/phase-5-tell-dont-ask-sweep.md
T

133 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 5: Tell-Don't-Ask and decoupling sweep
Goal: clear the residual state-encapsulation and decoupling smells that Phase 4 left behind — factory closures over mutable state, a composition-root forward-reference cycle, anemic getter/setter pairs a handler orchestrates by hand, Law-of-Demeter reach-throughs, and concrete-class dependencies that force test casts.
Phase 4 converted essentially every mutable-state-and-closures bag into a state-owning class, so Phase 5 is deliberately narrow.
A targeted sweep for Tell-Don't-Ask violations turned up seven findings, and they were the only genuine state-encapsulation and decoupling work left.
The phase does not touch `bash-program.ts` (pure AST parsing — splitting it produces free-function modules, not state-owning behavior) or reframe `Ruleset` (that would be a value object, and it would fight the intentional pure-evaluation design principle).
`fallow` reports a clean syntactic surface (health 76, 0% dead files, 0% reported dead exports, avg cyclomatic 1.4, no refactoring targets), which is exactly why these findings matter: they are structural smells `fallow` cannot see — a mutable Set hidden in a closure, a `null`-init cast papering over a construction cycle, an anemic accessor quartet a handler drives via ask-then-tell, a relay-only field reached through, and concrete-class constructor types that force `as unknown as` casts in tests.
## Findings summary
| Metric | Phase 5 baseline | Phase 5 target |
| ------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| Health score | 76 (B) | ≥ 76 (structural, not score-driven) |
| Production `as unknown as` casts | 3 (`index.ts` ×1, `config-store.ts` ×2 serialization) | 2 (serialization only) |
| Factory closures over mutable state | 1 (`createSessionLogger`) | 0 |
| Forward-reference `null`-init holders in `index.ts` | 2 (`configStore`, `sessionNotify`) | 0 |
| Anemic cache accessors on `PermissionSession` | 4 methods over 2 fields | 0 (2 owned `CacheKeyGate` sub-objects) |
| Ask-then-tell pairs in `AgentPrepHandler` | 2 | 0 |
| Test-only-alive exports | 1 (`shouldApplyCachedAgentStartState`) | 0 |
| `PermissionSession` constructor arity | 7 positional args | 6 (relay-only `logger` dropped) |
| `session.logger` / `session.getRuntimeContext()?.ui` reach-throughs | 5 (1 notify sink, 3 lifecycle logger, 1 reporter wiring) | 0 |
| `config-modal` controller reach-throughs | 1 (`permissionManager` + `session.lastKnownActiveAgentName`) | 0 |
| `LocalPermissionsService` concrete-class deps | 3 | 0 (narrow interfaces) |
| Test `as unknown as` casts removed | — | 8 (3 service + 5 forwarder ctx) → 8 more (8 `ExtensionContext` ctx; #367) = 16 total; 4 remain |
Unchanged guardrails: 0% dead code, avg cyclomatic 1.4, maintainability 91.1, no new public surface.
## Steps
The seven steps are filed as [#362][#368].
Each is a behavior-preserving refactor that leaves the suite green; the success metric is the table above moving toward zero, observed as fewer production casts, dropped forward-reference holders, and fewer forced test casts.
### Track A — logger state + PermissionSession/composition-root coupling (serial)
The composition-root forward-reference cycle existed *because* the logger needed late-bound config-reading and UI-notify capability, and the `logger` field on `PermissionSession` was relayed straight back out — so these three landed in order: make the logger a state-owning class, dissolve the cycle, then drop the relay-only field.
1. **Convert `createSessionLogger` into a `SessionLogger` class** ([#362]) ✓ complete
- Target: `src/session-logger.ts` — the `createSessionLogger` factory that returned an object literal closing over a mutable `reported: Set<string>` (IO-failure-warning dedup) and the writer.
- Smell: Category C (mutable closure state) — a bag of state + closures masquerading as a factory.
- Outcome: a `SessionLogger` class that privately owns `reported` and the writer and exposes `debug` / `review` / `warn`; constructed as `new SessionLogger(deps)`; no factory-closure mutable state remains.
2. **Add `PermissionSession.notify()` and dissolve the `index.ts` forward-reference cycle** ([#363]) ✓ complete
- Target: `src/permission-session.ts` (new `notify(message)` Tell-Don't-Ask method over the owned context); `src/index.ts` (removed `let configStore = null as unknown as ConfigStore` and the `let sessionNotify` holder, wiring the logger's notify sink as `(m) => session.notify(m)`).
- Smell: Category C (forward references + the only production `as unknown as` cast + the `getRuntimeContext()?.ui.notify` Law-of-Demeter reach-through).
- Outcome: production `as unknown as` casts 3 → 2; `index.ts` has no `null`-init holders; the UI-notify reach-through became a single tell to the context-owning session.
- Depended on Step 1 (the logger reshape that lets construction order resolve without the cast).
3. **Inject `logger` directly into the lifecycle handler and reporter; drop the relay-only field** ([#364]) ✓ complete
- Target: `src/permission-session.ts` (removed the `readonly logger` constructor parameter — never read internally, only relayed — taking the constructor from 7 args to 6); `src/handlers/lifecycle.ts` (accept a `SessionLogger` and call `this.logger.warn/debug` instead of `this.session.logger`); `src/index.ts` (pass the composition-root `logger` to `new GateDecisionReporter(logger, …)` and `new SessionLifecycleHandler(session, resolver, serviceLifecycle, logger)`).
- Smell: Category C (relay-only dependency / Law-of-Demeter reach-through — the handler talked to `session.logger`, a stranger reached through the session).
- Outcome: `PermissionSession` no longer exposes `logger`; the three lifecycle reach-throughs and the one reporter-wiring reach-through are gone; the constructor narrowed to 6 args.
- Depended on Step 2 (shares edits to `permission-session.ts` and `index.ts`; serialized to avoid conflicts).
### Track B — anemic cache-key state (independent)
4. **Encapsulate agent-start cache keys in a `CacheKeyGate` class** ([#365]) ✓ complete
- Target: `src/permission-session.ts` (replaced the four anemic methods — `shouldUpdateActiveTools` / `commitActiveToolsCacheKey` / `shouldUpdatePromptState` / `commitPromptStateCacheKey` — and their two `string | null` fields with two `CacheKeyGate` instances); `src/handlers/before-agent-start.ts` (collapsed the two ask-then-tell pairs into `gate.runIfChanged(key, effect)`); `src/before-agent-start-cache.ts` (removed the dead-in-production `shouldApplyCachedAgentStartState` and folded its comparison into `CacheKeyGate`).
- Smell: Category C (anemic domain / ask-then-tell — the handler asked "should I update?"
then told "commit") plus Category A (a redundant export kept alive only by its own test, which is why `fallow`'s 0%-dead-exports missed it).
- Outcome: a `CacheKeyGate` class owning a previous key and exposing `runIfChanged(nextKey, effect)`; `PermissionSession`'s four cache methods became two owned sub-objects; the handler's ask-then-tell pairs became single tells; one source of truth for the key comparison; the test-only-alive free function is gone.
### Track C — narrow-interface decoupling for testability (independent)
5. **Narrow `LocalPermissionsService` collaborators to interfaces** ([#366]) ✓ complete
- Target: `src/permissions-service.ts` — the constructor typed the concrete `PermissionManager`, `SessionRules`, and `ToolInputFormatterRegistry` but only called `checkPermission` / `getToolPermission`, `getRuleset`, and `register`.
- Smell: Category C (DIP — depending on concrete classes) / Category D (testability — concrete-class types expose private members, so `permissions-service.test.ts` was forced into `as unknown as` casts).
- Outcome: depends on the existing `ScopedPermissionManager`, `Pick<SessionRules, "getRuleset">`, and a `{ register }` formatter interface; the three `as unknown as` casts in `permissions-service.test.ts` disappeared and mocks became plain objects.
6. **Narrow `PermissionForwarder`'s context dependency to a local interface** ([#367]) ✓ complete
- Target: `src/forwarded-permissions/permission-forwarder.ts` — methods took the full SDK `ExtensionContext` rather than a narrow local interface of the fields actually read.
- Smell: Category C (platform-type threading) / Category D (testability).
- Outcome: the five `as unknown as ExtensionContext` casts in `permission-forwarder.test.ts` (the single biggest cluster of the 12 such casts across 7 test files) disappeared; a bounded down-payment on the systemic ctx-threading pattern.
### Track D — slash-command reach-through (independent)
7. **Remove the `config-modal` controller reach-through** ([#368]) ✓ complete
- Target: `src/config-modal.ts` — the `show` handler chained `controller.permissionManager.getComposedConfigRules(controller.session.lastKnownActiveAgentName ?? undefined)`, reaching through the controller bag to two strangers.
- Smell: Category C (Law-of-Demeter reach-through).
- Outcome: collapsed the controller's `permissionManager` + `session` fields into a single `getActiveAgentConfigRules()` accessor wired in the composition root, so the command tells one collaborator; the `PermissionSession.lastKnownActiveAgentName` getter is no longer consumed via object-literal wiring (retiring the `fallow` false-positive suppression).
## Step dependency diagram
```mermaid
flowchart TD
S1["Step 1: SessionLogger class (#362)"]
S2["Step 2: PermissionSession.notify + dissolve index.ts cycle (#363)"]
S3["Step 3: inject logger; drop relay-only field (#364)"]
S4["Step 4: CacheKeyGate for agent-start cache keys (#365)"]
S5["Step 5: narrow LocalPermissionsService collaborators (#366)"]
S6["Step 6: narrow PermissionForwarder context (#367)"]
S7["Step 7: remove config-modal reach-through (#368)"]
S1 --> S2 --> S3
subgraph TrackA["Track A — logger state + composition-root coupling (serial)"]
S1
S2
S3
end
subgraph TrackB["Track B — anemic cache-key state"]
S4
end
subgraph TrackC["Track C — narrow-interface decoupling"]
S5
S6
end
subgraph TrackD["Track D — slash-command reach-through"]
S7
end
```
## Tracks
| Track | Steps | Description |
| ---------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| A: logger state + composition-root coupling (serial) | 1 → 2 → 3 | Make the logger a state-owning class, dissolve the `index.ts` forward-reference cycle, then drop the relay-only `logger` field from `PermissionSession` |
| B: anemic cache-key state | 4 | Replace the four anemic cache accessors and two fields with two owned `CacheKeyGate` sub-objects, collapsing the handler's ask-then-tell pairs into single tells |
| C: narrow-interface decoupling | 5, 6 | Narrow `LocalPermissionsService` and `PermissionForwarder` to local interfaces so forced `as unknown as` test casts disappear (independent of each other and the others) |
| D: slash-command reach-through | 7 | Collapse the `config-modal` controller's two reached-through fields into a single `getActiveAgentConfigRules()` accessor |
[#362]: https://github.com/gotgenes/pi-packages/issues/362
[#363]: https://github.com/gotgenes/pi-packages/issues/363
[#364]: https://github.com/gotgenes/pi-packages/issues/364
[#365]: https://github.com/gotgenes/pi-packages/issues/365
[#366]: https://github.com/gotgenes/pi-packages/issues/366
[#367]: https://github.com/gotgenes/pi-packages/issues/367
[#368]: https://github.com/gotgenes/pi-packages/issues/368