26 KiB
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 interfacesPromptingGateway (#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 objectConfigStore 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.tsindex.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.
-
Inject a single
PermissionManagerintoPermissionSession(#334) ✓ complete- Target:
permission-manager.ts(addconfigureForCwd(cwd));permission-session.tsconstructor +resetForNewSession+reload;index.ts. PermissionSessionholds one injectedPermissionManagerand callsconfigureForCwd(ctx.cwd)once atsession_start, instead of constructing a new manager via thecreatePermissionManagerForCwdfree 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._cwdandExtensionRunner.cwdare each assigned once and never reassigned;/reloadre-emitssession_startwith the same cwd). The instance-swapping is dead generality; the extension just does not learn cwd untilsession_start. - Smell category: C (DIP violation — addresses Finding 1).
- Outcome:
vi.mock("../src/runtime")andas unknown as PermissionManagerleavepermission-session.test.ts; the manager is a single injected, substitutable collaborator — noFactoryclass.
- Target:
-
Extract a
ConfigStorefrom the runtime free-functions (#335) ✓ complete- Target: new
src/config-store.tsclass owningconfig+lastConfigWarningwithcurrent()/refresh(ctx?)/save(next, ctx)/logResolvedPaths(); convertrefreshExtensionConfig/saveExtensionConfig/logResolvedConfigPathsfrom(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.configclosures and 3× runtime-arg config free-functions are gone; config has one owner.
- Target: new
-
Make the logger injectable; drop
createSessionLogger(runtime)(#336) ✓ complete- Target:
src/session-logger.ts,src/logging.ts,index.ts. - Construct the logger from
ExtensionPaths+ theConfigStore(debug toggle) + a narrow notify sink — not the whole runtime; remove theruntime.writeDebugLog/runtime.runtimeContext?.ui.notifyreach-through. - Smell category: C (Law-of-Demeter reach-through — addresses Finding 3, part 2).
- Outcome: no module takes the whole
ExtensionRuntimefor logging; the duplicated.bind(runtime)logging adapters disappear.
- Target:
-
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/SessionRulesthe gate handlers use (fixing the stale-manager / empty-session-rules split-brain), backed by theConfigStore+ExtensionPaths+PermissionSession. - Smell category: C (split-brain state — addresses Finding 3, part 3).
- Outcome:
as unknown as ExtensionRuntimeis gone; the deprecated RPC check and the gate path read the same session rules. - Also injects
SessionRulesintoPermissionSession(constructor now has 7 params) and retiresRuntimeContextReffromConfigStore.
- Target:
-
Collapse the
index.tsclosure bags into object references (#338) ✓ complete- Target:
index.ts; the deps interfaces onPermissionPrompter,PermissionSession, the command, and the RPC handlers. - With Steps 2-4 done, replace the remaining
() =>/.bindadapters with direct collaborator references and shrink the deps bags; verify viatest/composition-root.test.ts. - Smell category: C/E (adapter closure density — addresses Finding 4).
- Outcome:
index.tsclosures 20 → 11. Permanent floor: 6pi.onhandlers + 2toolRegistryadapters + 2 logger forward-reference cycle closures (getConfig/notify; idiomatic; see pi-subagents pattern). Transitional: 1canRequestPermissionConfirmationclosure removed by Step 6.
- Target:
-
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;GateRunnerreceives the gateway for the prompting role. ThecanPrompt(ctx)/canConfirm()andprompt(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;
makeSessionsheds its prompt-delegation closures and theundefined as unknown as ExtensionContextcasts.
- Target: new
-
Extract a
PermissionResolvercollaborator out ofPermissionSession(#340) ✓ complete- Target:
src/permission-resolver.ts(promote to a concrete class holding thePermissionManager+SessionRules);permission-session.ts;index.ts. - The resolver owns
resolve/checkPermission/getToolPermission/getConfigIssues/getPolicyCacheStamp;PermissionSessionno 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.
- Target:
-
Slim
PermissionSessionto 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/SessionLifecycleSessioninterfaces that were one-class fig leaves; handlers depend on the distinct collaborators.GateRunnernow 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, …)becomesGateRunner(resolver, recorder, prompter, …); the 17-fieldmakeSessionfixture splits into small per-collaborator fixtures or disappears.
- Target:
-
Retire the
permission-system.test.tscatch-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.
- Target:
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) |