Files
my-pi/pi-permission-system/docs/architecture/history/phase-4-constructibility.md
T

183 lines
26 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 4: Constructibility and god-object decomposition
Goal: make the core collaborators independently constructable, then split the two god objects (`ExtensionRuntime`, `PermissionSession`) they hide behind.
The entry into this phase is the test tree, but the test tree is a symptom, not the disease.
`fallow` reports the production code is "clean" (avg cyclomatic 1.4, p90 2, zero complexity targets, zero dead code, zero production duplication) — but `fallow`'s syntactic metrics do not measure constructibility, closure density, injection seams, or a god object hiding behind narrow role interfaces.
Reading the tests as evidence of how hard the production code is to use reveals the real findings: collaborators that cannot be `new`-ed in isolation, a mutable runtime god object threaded through free functions, and a single 351-line class that implements six interfaces and is passed to one constructor three times.
The lens for this phase is constructibility: "why does this test need `vi.mock` of a module / a 17-field fixture / an `as unknown as` cast, and which production object is too hard to build because of it?".
The test-tree cleanup from the first draft (retiring the `permission-system.test.ts` catch-all, de-duplicating clone families, splitting oversized arrows) is folded in at the tail as a *measured consequence* of the production refactor, not the goal — most of the duplication and fixture weight dissolves once the collaborators are injectable.
Phase 4 is independent of any open feature issue — it is a pure structural round.
This phase deliberately revisits the Phase 3 approach: Phase 3 applied Interface Segregation to the *interfaces* (six narrow role interfaces) but not to the *object* (one class implements all six).
Phase 4 splits the object so each role maps to a distinct collaborator, then retires the fig-leaf interfaces that no longer earn their keep.
## Current health metrics
`fallow`'s structural metrics (left) say the production code is healthy; the constructibility metrics (right) — which `fallow` does not score — tell the real story.
| Metric | Value |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Health score | 76 B |
| LOC | 37,151 |
| Dead files / exports | 0% |
| Avg cyclomatic / p90 | 1.4 / 2 |
| Maintainability | 91.2 (good) |
| Complexity refactoring targets | 0 |
| Production duplication | 0% (no `src/` clone groups) |
| `index.ts` closures + `.bind` adapters | 10 (was 11; `canRequestPermissionConfirmation` removed by #339) |
| `runtime`-as-first-arg free functions | 0 (all eliminated by #335#337) |
| `PermissionSession` role interfaces implemented by one class | 0 handler fig-leaf roles (`GateHandlerSession` / `AgentPrepSession` / `SessionLifecycleSession` retired by #341; the class now `implements ToolCallGateInputs` only — a genuine pipeline-input contract, not a one-class fig leaf) |
| Test files using module-level `vi.mock` | 23 |
| `as unknown as` casts in `test/` | ~31 (3× `PermissionManager`, 1× `SessionRules`; prompting casts removed by #339) |
| Test duplication | 2,505 lines across 41 files — 3.4% (`dupes`) / 6.6% (health basis) |
| Very-high functions (>60 LOC) | 5% — all in `test/` |
Health-score deductions: hotspots -10.0 · unit size -10.0 · coupling -2.4 · duplication -1.6.
Measurement note: the dominant production hotspots — `permission-gate-handler.ts` (42.3, accelerating) and `index.ts` (37.3, accelerating) — are not benign churn.
`index.ts` is the closure-bag composition root this phase dismantles (Finding 4); its churn reflects the wiring friction directly.
The hotspot deduction is expected to fall once the closure bags collapse into object references.
## Findings
The headline findings are coupling and constructibility smells (Category C): a god object that constructs its own collaborators (DIP violation), a second god object built by a mutable factory, six interfaces over one class, and a closure-bag composition root that is a *consequence* of the first three.
Each is grounded in the specific test pain it forces.
| # | Finding | Category | Files | Impact | Risk | Priority |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------- | ------ | ---- | -------- |
| 1 | `PermissionSession` constructs its own `PermissionManager` (DIP violation): the constructor, `resetForNewSession()`, and `reload()` all call the free function `createPermissionManagerForCwd(...)` — the manager is never injected. Test cost: `permission-session.test.ts` must `vi.mock("../src/runtime")` to stub the factory and route a `{...} as unknown as PermissionManager` mock through it; the object cannot be `new`-ed with a test double. | C: anemic / DIP violation | `permission-session.ts`, `runtime.ts` | 5 | 3 | 15 |
| 2 | ~~`PermissionSession` god object behind six interfaces~~ ✓ addressed by #339#341: the prompting role moved to `PromptingGateway` (#339), the resolve role to `PermissionResolver` (#340), and the recorder role to `SessionRules`; the three fig-leaf handler interfaces (`GateHandlerSession` / `AgentPrepSession` / `SessionLifecycleSession`) were retired (#341). `PermissionSession` is now a state/lifecycle owner that `implements ToolCallGateInputs` only; `GateRunner(resolver, recorder, prompter, reporter)` receives three distinct collaborators; the 17-field `makeSession` intersection mock is gone — handler tests build a real `PermissionSession` + `PermissionResolver` from per-collaborator fakes (`test/helpers/session-fixtures.ts`). | C: god object / ISP applied to interface not object | `permission-session.ts`, `handler-fixtures.ts` | 5 | 4 | 10 |
| 3 | ~~`ExtensionRuntime` god object~~ ✓ addressed by #335#337: `ConfigStore` owns config (#335); logger is injectable (#336); `runtime.ts` deleted and `index.ts` constructs `ExtensionPaths` + `PermissionManager` + `SessionRules` + `ConfigStore` + logger directly (#337). The split-brain (gate and RPC reading different `PermissionManager`/`SessionRules` instances) is closed; `as unknown as ExtensionRuntime` casts are gone; `runtime`-arg free functions eliminated. | C: mutable closure state / forward reference / split-brain state | ~~`runtime.ts`~~, `index.ts` | 4 | 4 | 8 |
| 4 | `index.ts` is 20 closures + `.bind` adapters — a *consequence* of Findings 1-3: `() => runtime.config` (×4) exists because `config` is mutable shared state needing live reads; `runtime.writeReviewLog.bind(runtime)` (×3, duplicated in `forwardingDeps`) exists because the logging ops are free functions; `(ctx) => refreshExtensionConfig(runtime, ctx)` wraps each runtime free-function. These collapse to plain object references once the runtime ops become methods and config becomes a store with `current()`. | C: adapter closure density / E: wiring overhead | `index.ts` | 4 | 3 | 12 |
| 5 | Test-tree symptoms (folded in at the tail as measured consequence): the 2,785-line `permission-system.test.ts` catch-all (12 clone groups), 2,505 lines of test duplication, the residual `makeSession` clone in `external-directory-session-dedup.test.ts` ([#321] deferral), and the oversized `describe` arrows. Most of the fixture weight and `vi.mock` count is downstream of Findings 1-3 and shrinks as they land; what remains (the monolith carve) gets a dedicated trailing step. | D: test duplication / E: test organization | `test/permission-system.test.ts`, `test/` clone families | 3 | 1 | 15 |
## Steps
The nine steps are filed as [#334][#342].
Production first (Steps 1-8), then the test-cleanup tail (Step 9).
Each step is a behavior-preserving refactor that leaves the suite green; the success metric is the constructibility table above moving toward zero, observed as fewer `vi.mock` module stubs, smaller fixtures, and dropped casts.
1. **Inject a single `PermissionManager` into `PermissionSession`** ([#334]) ✓ complete
- Target: `permission-manager.ts` (add `configureForCwd(cwd)`); `permission-session.ts` constructor + `resetForNewSession` + `reload`; `index.ts`.
- `PermissionSession` holds one injected `PermissionManager` and calls `configureForCwd(ctx.cwd)` once at `session_start`, instead of constructing a new manager via the `createPermissionManagerForCwd` free function on every lifecycle event; tests pass a real or fake manager directly.
- The per-call reconstruction implied the project cwd can change across a session; it cannot (verified against Pi core — `AgentSession._cwd` and `ExtensionRunner.cwd` are each assigned once and never reassigned; `/reload` re-emits `session_start` with the same cwd).
The instance-swapping is dead generality; the extension just does not learn cwd until `session_start`.
- Smell category: C (DIP violation — addresses Finding 1).
- Outcome: `vi.mock("../src/runtime")` and `as unknown as PermissionManager` leave `permission-session.test.ts`; the manager is a single injected, substitutable collaborator — no `Factory` class.
2. **Extract a `ConfigStore` from the runtime free-functions** ([#335]) ✓ complete
- Target: new `src/config-store.ts` class owning `config` + `lastConfigWarning` with `current()` / `refresh(ctx?)` / `save(next, ctx)` / `logResolvedPaths()`; convert `refreshExtensionConfig` / `saveExtensionConfig` / `logResolvedConfigPaths` from `(runtime, …)` free functions into methods.
- Consumers hold the store and call `store.current()` instead of capturing `() => runtime.config`.
- Smell category: C (mutable shared state → owner — addresses Finding 3, part 1).
- Outcome: 4× `() => runtime.config` closures and 3× runtime-arg config free-functions are gone; config has one owner.
3. **Make the logger injectable; drop `createSessionLogger(runtime)`** ([#336]) ✓ complete
- Target: `src/session-logger.ts`, `src/logging.ts`, `index.ts`.
- Construct the logger from `ExtensionPaths` + the `ConfigStore` (debug toggle) + a narrow notify sink — not the whole runtime; remove the `runtime.writeDebugLog` / `runtime.runtimeContext?.ui.notify` reach-through.
- Smell category: C (Law-of-Demeter reach-through — addresses Finding 3, part 2).
- Outcome: no module takes the whole `ExtensionRuntime` for logging; the duplicated `.bind(runtime)` logging adapters disappear.
4. **Dissolve `ExtensionRuntime`; one source of truth for session state** ([#337]) ✓ complete
- Target: `runtime.ts`, `index.ts`, `permission-event-rpc.ts`, `config-modal.ts`.
- Remove the god runtime object; point the config-modal and RPC handlers at the *same* `PermissionManager` / `SessionRules` the gate handlers use (fixing the stale-manager / empty-session-rules split-brain), backed by the `ConfigStore` + `ExtensionPaths` + `PermissionSession`.
- Smell category: C (split-brain state — addresses Finding 3, part 3).
- Outcome: `as unknown as ExtensionRuntime` is gone; the deprecated RPC check and the gate path read the same session rules.
- Also injects `SessionRules` into `PermissionSession` (constructor now has 7 params) and retires `RuntimeContextRef` from `ConfigStore`.
5. **Collapse the `index.ts` closure bags into object references** ([#338]) ✓ complete
- Target: `index.ts`; the deps interfaces on `PermissionPrompter`, `PermissionSession`, the command, and the RPC handlers.
- With Steps 2-4 done, replace the remaining `() =>`/`.bind` adapters with direct collaborator references and shrink the deps bags; verify via `test/composition-root.test.ts`.
- Smell category: C/E (adapter closure density — addresses Finding 4).
- Outcome: `index.ts` closures 20 → 11.
Permanent floor: 6 `pi.on` handlers + 2 `toolRegistry` adapters + 2 logger forward-reference cycle closures (`getConfig`/`notify`; idiomatic; see pi-subagents pattern).
Transitional: 1 `canRequestPermissionConfirmation` closure removed by Step 6.
6. **Extract a context-owning `PromptingGateway`; collapse the prompt twins** ([#339]) ✓ complete
- Target: new `src/prompting-gateway.ts`; `permission-session.ts`; `handlers/gates/runner.ts`; `index.ts`.
- Move the stored context + `canConfirm()` / `prompt(details)` into one collaborator; `GateRunner` receives the gateway for the prompting role.
The `canPrompt(ctx)`/`canConfirm()` and `prompt(ctx, details)`/`promptPermission(details)` twins collapse to a single context-bound pair.
- Smell category: C (god object split — addresses Finding 2; depends on Step 1).
- Outcome: the prompting role is a distinct object; `makeSession` sheds its prompt-delegation closures and the `undefined as unknown as ExtensionContext` casts.
7. **Extract a `PermissionResolver` collaborator out of `PermissionSession`** ([#340]) ✓ complete
- Target: `src/permission-resolver.ts` (promote to a concrete class holding the `PermissionManager` + `SessionRules`); `permission-session.ts`; `index.ts`.
- The resolver owns `resolve` / `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp`; `PermissionSession` no longer plays the resolver role.
- Smell category: C (god object split — addresses Finding 2; depends on Step 1).
- Outcome: the resolution role is a distinct object directly unit-testable without a session fixture.
8. **Slim `PermissionSession` to a state/lifecycle owner; unwind the fig-leaf interfaces** ([#341]) ✓ complete
- Target: `permission-session.ts`; `gate-handler-session.ts`; `agent-prep-session.ts`; `session-lifecycle-session.ts`; the three handlers; `handler-fixtures.ts`.
- With prompting and resolution extracted (Steps 6-7), retire or merge the `GateHandlerSession` / `AgentPrepSession` / `SessionLifecycleSession` interfaces that were one-class fig leaves; handlers depend on the distinct collaborators. `GateRunner` now receives three *different* objects.
- Smell category: C (ISP applied to the object, not just the interface — addresses Finding 2; depends on Steps 6-7).
- Outcome: `GateRunner(session, session, session, …)` becomes `GateRunner(resolver, recorder, prompter, …)`; the 17-field `makeSession` fixture splits into small per-collaborator fixtures or disappears.
9. **Retire the `permission-system.test.ts` catch-all (test-cleanup tail)** ([#342]) ✓ complete
- Target: `test/permission-system.test.ts`; the co-located destination files.
- Redistribute the ~80 flat tests into the existing co-located files (`yolo-mode`, `system-prompt-sanitizer`, `permission-manager-unified`, `scope-merge`, the external-directory suite, `session-rules`, …) now that the collaborators are independently constructable; delete the emptied shell.
- Smell category: D/E (test organization — the part of Finding 5 the production refactor does not auto-resolve).
- Outcome: the 2,785-line monolith and its 12 clone groups are gone; the suite is fully co-located.
Expected phase outcome: the constructibility table moves toward zero — `index.ts` closures 20 → 11 (Steps 1-5) → 10 (Step 6), `runtime`-arg free functions 5 → 0, `PermissionSession` interfaces 6 → 1-2 on distinct objects, the `../src/runtime` / `../src/permission-manager` module mocks removed, the `PermissionManager` / `ExtensionRuntime` / `SessionRules` casts → 0; `permission-system.test.ts` deleted; test duplication falls as a consequence; health score 76 → target ≥ 80.
Deferred to Phase 5 (the "Full" scope exceeds 9 steps): further `PermissionSession` decomposition (an `ActiveAgentTracker` for agent-name state, a cache-key owner, an infra-path/preview-limits helper), and the remaining test-tree cleanup from the first draft that the production refactor does not dissolve — de-duplicating the residual clone families (`external-directory-integration`, `permission-forwarder`, the gate families) onto shared fixtures and splitting the oversized `describe` arrows (`bash-external-directory.test.ts` 880-line, `permission-session.test.ts` 575-line).
These are intentionally last: they are cheaper after Steps 1-8 shrink the fixtures they would otherwise migrate.
Phase 5 candidate — dissolve the logger `notify` cycle via the event bus: route the logger's IO-failure and `warn()` warnings through a `pi.events` channel (mirroring the existing `emitUiPromptEvent` pub-sub) instead of reaching `session.getRuntimeContext().ui.notify`.
The logger then depends only on the bus (available at construction), breaking the logger ↔ `PermissionSession` forward-reference cycle that [#338] leaves in place; the dedup `Set` stays on the emit side.
This is pub-sub, not the in-process Observer (`SubagentManagerObserver`) pattern pi-subagents uses — a directly-injected observer would reintroduce the cycle because the logger is constructed before any context-bearing collaborator.
The logger ↔ `ConfigStore` `getConfig` cycle is deliberately not a candidate: the logger must exist before the store yet needs live toggle reads, so the forward-reference closure is cheaper than any untangling (a push model would require the setter the composition root avoids).
## Step dependency diagram
Two production tracks run in parallel after Step 1, joined at the composition root and the test tail.
Track B (de-god the runtime) is the sequential chain `ConfigStore → logger → dissolve runtime → collapse index.ts closures`.
Track C (split the session) is `PromptingGateway` + `PermissionResolver` (both after Step 1, parallel) → slim the session and unwind the interfaces.
Step 5 and Step 8 both finalize `index.ts` wiring, so Step 8 is sequenced after Step 5 to avoid overlapping edits.
Step 9 (test tail) depends on the full production refactor — the collaborators must be constructable before the monolith's tests redistribute cleanly.
```mermaid
flowchart TD
S1["Step 1: Inject single PermissionManager (#334)"]
S2["Step 2: Extract ConfigStore (#335)"]
S3["Step 3: Make logger injectable (#336)"]
S4["Step 4: Dissolve ExtensionRuntime (#337)"]
S5["Step 5: Collapse index.ts closures (#338)"]
S6["Step 6: Extract PromptingGateway (#339)"]
S7["Step 7: Extract PermissionResolver (#340)"]
S8["Step 8: Slim PermissionSession, unwind interfaces (#341)"]
S9["Step 9: Retire permission-system.test.ts (#342)"]
S1 --> S6
S1 --> S7
S2 --> S3
S3 --> S4
S4 --> S5
S6 --> S8
S7 --> S8
S5 --> S8
S5 --> S9
S8 --> S9
```
## Tracks
| Track | Steps | Description |
| ----------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A: Injection foundation | 1 | Inject one `PermissionManager` (configured once at `session_start`) so `PermissionSession` is constructable with a test double (unblocks Tracks B and C) |
| B: De-god the runtime | 2 → 3 → 4 → 5 | `ConfigStore` → injectable logger → dissolve `ExtensionRuntime` → collapse the `index.ts` closure bags |
| C: Split the session | 6, 7 → 8 | Extract `PromptingGateway` + `PermissionResolver` (parallel after Step 1), then slim `PermissionSession` and unwind the fig-leaf interfaces |
| D: Test-cleanup tail | 9 | Retire the `permission-system.test.ts` catch-all once collaborators are constructable (measured consequence) |
[#321]: https://github.com/gotgenes/pi-packages/issues/321
[#334]: https://github.com/gotgenes/pi-packages/issues/334
[#335]: https://github.com/gotgenes/pi-packages/issues/335
[#336]: https://github.com/gotgenes/pi-packages/issues/336
[#337]: https://github.com/gotgenes/pi-packages/issues/337
[#338]: https://github.com/gotgenes/pi-packages/issues/338
[#339]: https://github.com/gotgenes/pi-packages/issues/339
[#340]: https://github.com/gotgenes/pi-packages/issues/340
[#341]: https://github.com/gotgenes/pi-packages/issues/341
[#342]: https://github.com/gotgenes/pi-packages/issues/342