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

26 KiB
Raw Blame History

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.

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)