mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
133 lines
14 KiB
Markdown
133 lines
14 KiB
Markdown
# 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
|