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

14 KiB
Raw Blame History

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)

  1. 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)

  1. 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.
  2. 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)

  1. 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

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