13 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 364 | Inject `logger` directly; drop the relay-only field from `PermissionSession` |
Inject logger directly; drop the relay-only field from PermissionSession
Problem Statement
PermissionSession accepts a SessionLogger in its constructor but never reads it internally — it only re-exposes it as a public readonly logger field for other collaborators to reach through.
SessionLifecycleHandler reaches this.session.logger (a stranger reached through the session) in three places, and the composition root reaches session.logger once more to build the GateDecisionReporter.
That is a relay-only dependency and a Law-of-Demeter reach-through: every consumer already has — or can be handed — the composition-root logger directly, so none of them needs to go through the session.
This is Phase 5 Step 3 (Track A) of the pi-permission-system improvement roadmap.
Goals
- Remove the
readonly loggerconstructor parameter fromPermissionSession, narrowing the constructor from 7 positional args to 6. - Inject
SessionLoggerdirectly intoSessionLifecycleHandler; replace the threethis.session.loggerreach-throughs withthis.logger. - Wire
GateDecisionReporterwith the composition-rootloggerinstead ofsession.logger. - Keep behavior identical — this is a structural refactor with no observable change.
This change is not breaking: it alters no public extension surface, config, output shape, or default. All edits are internal wiring and types.
Non-Goals
- Track B (
CacheKeyGate, #365), Track C (#366, #367), and Track D (#368) — independent Phase 5 steps, deferred. - Reshaping the
SessionLoggerinterface orPermissionSessionLoggerclass (settled in #362). - Touching the logger's notify sink (
(m) => session.notify(m)), which usessession.notify, notsession.logger, and is unaffected (settled in #363). - Removing the now-stale
loggerfield from theMockGateHandlerSessiontest type — see Open Questions.
Background
Relevant modules:
src/permission-session.ts—PermissionSessionclass. The constructor's second positional arg isreadonly logger: SessionLogger. Grepping the class body confirmsthis.loggeris never read internally; the field exists only for external reach-through.src/handlers/lifecycle.ts—SessionLifecycleHandler. Reachesthis.session.logger.warn(issue)once (policy issues) andthis.session.logger.debug("lifecycle.reload", …)twice (session-start reload, resources-discover reload).src/decision-reporter.ts—GateDecisionReporteralready accepts aSessionLoggeras its first constructor arg; no change to the class, only to howindex.tswires it.src/index.ts— the composition root. Constructslogger = new PermissionSessionLogger(…), then passes it into thePermissionSessionconstructor, and later reachessession.loggerto build the reporter.- Test fixtures:
test/helpers/session-fixtures.ts(makeRealSession) andtest/helpers/handler-fixtures.ts(makeHandler).
Prerequisites — both implemented (CLOSED):
- #362 — converted
createSessionLoggerinto thePermissionSessionLoggerclass. - #363 — added
PermissionSession.notify()and dissolved theindex.tsforward-reference cycle (removed thenull as unknown as ConfigStorecast).
The roadmap notes this step "shares edits to permission-session.ts and index.ts" with Step 2, so it lands after #363 to avoid conflicts.
Current main already contains the #363 result (let configStore: ConfigStore; with no cast, notify sink (m) => session.notify(m)), so the dependency is satisfied.
Constraint from AGENTS.md / package skill: when a roadmap step ships, mark it ✓ complete in docs/architecture/architecture.md as part of the shipping change.
That mark-complete is a ship-stage action, noted here for continuity.
Design Overview
Decision model
SessionLifecycleHandler already depends on three collaborators (session, resolver, serviceLifecycle).
It gains a fourth, logger: SessionLogger — a narrow interface (debug / review / warn) it fully uses (reads warn and debug).
This is a direct injection that replaces an indirect reach-through; it does not widen the dependency surface in any meaningful way (the logger was already reachable, just through the session).
GateDecisionReporter is unchanged: it already takes a SessionLogger first.
Only the composition-root argument changes from session.logger to the in-scope logger.
PermissionSession loses its logger field and the SessionLogger import.
The constructor narrows to 6 positional args:
constructor(
private readonly paths: ExtensionPaths,
private readonly forwarding: ForwardingController,
private readonly permissionManager: ScopedPermissionManager,
private readonly sessionRules: SessionRules,
private readonly configStore: SessionConfigStore,
private readonly gateway: PromptingGatewayLifecycle,
) {}
Handler call-site sketch (verifies the injection pattern)
After the change, SessionLifecycleHandler tells its own injected logger rather than reaching through the session:
// handlers/lifecycle.ts (after)
for (const issue of policyIssues) {
this.logger.warn(issue);
}
// ...
this.logger.debug("lifecycle.reload", { triggeredBy: "session_start", reason, cwd });
The composition root hands every consumer the same logger instance it already holds — no object reaches through another:
// index.ts (after)
const lifecycle = new SessionLifecycleHandler(session, resolver, serviceLifecycle, logger);
const reporter = new GateDecisionReporter(logger, pi.events);
This follows Tell-Don't-Ask (the handler tells the logger) and the Law of Demeter (no session.logger chain).
Edge cases
- No runtime behavior changes: the same
PermissionSessionLoggerinstance receives the samewarn/debug/reviewcalls in the same order. PermissionSession.notify()(the UI sink) is independent ofsession.loggerand stays as-is.
Module-Level Changes
Source:
src/permission-session.ts— remove thereadonly logger: SessionLoggerconstructor parameter; remove the now-unusedimport type { SessionLogger }; update the class-level JSDoc "Constructor deps" list to drop theSessionLoggerbullet.src/handlers/lifecycle.ts— addprivate readonly logger: SessionLoggeras the fourth constructor parameter; addimport type { SessionLogger } from "#src/session-logger"; replace the threethis.session.logger.*calls withthis.logger.*; update the constructor-deps JSDoc to documentlogger.src/index.ts— drop theloggerargument fromnew PermissionSession(…); passloggeras the fourth argument tonew SessionLifecycleHandler(session, resolver, serviceLifecycle, logger); changenew GateDecisionReporter(session.logger, pi.events)tonew GateDecisionReporter(logger, pi.events).
Tests:
test/helpers/session-fixtures.ts— drop theloggerargument from thenew PermissionSession(…)call inmakeRealSession. KeepmakeLogger()and continue returningloggerin the result bag (tests still wire it into the handler and reporter).test/helpers/handler-fixtures.ts— addloggertomakeHandler's returned bag (it is already destructured frommakeRealSessionand passed tonew GateDecisionReporter); no other change.test/handlers/lifecycle.test.ts—makeSetupconstructs the handler with an explicitloggerarg. Pass a logger that is distinct from the session's collaborators so the existinglogger.warn/logger.debugassertions genuinely verify direct injection (see Test Impact Analysis).test/handlers/external-directory-integration.test.ts— destructureloggerfrommakeHandler(…)and replace the threesession.logger.reviewreads withlogger.review.
Docs:
.pi/skills/package-pi-permission-system/SKILL.md— update the documentedmakeHandlerreturn bag to includelogger.docs/architecture/architecture.md— mark Phase 5 Step 3✓ complete(ship-stage action; the Phase 5 baseline/target metric table is a phase-level summary and is not edited per-step).
No docs/architecture/ layout/complexity listing references the logger field directly, so no diagram updates are required beyond the step-complete mark.
Test Impact Analysis
-
New coverage enabled. Today
lifecycle.test.tscannot distinguish "handler usessession.logger" from "handler uses an injected logger" becausemakeRealSessionreturns the same logger instance the session holds. After injection, the handler can be handed a logger that is independent of the session, so the existinglogger.warn/logger.debugassertions become a genuine test of direct injection. This is the meaningful red→green in Step 1: assert against a distinct injected logger first (fails while the handler readsthis.session.logger), then wire the injection (passes). -
Redundant coverage. None. No test asserts the existence of the
PermissionSession.loggerfield directly (permission-session.test.tshas zerologgerreferences), so nothing becomes dead. The threesession.logger.reviewreads inexternal-directory-integration.test.tsare re-pointed at the fixturelogger, asserting the same behavior. -
Coverage that must stay. The
external-directory-integration.test.tsreview-log assertions stay — they verify the reporter writes (or does not write) block entries through the logger. They move fromsession.logger.reviewto the fixturelogger.review(the same instance the reporter receives), so the assertion's meaning is preserved.
TDD Order
-
Inject
loggerintoSessionLifecycleHandler(keep thePermissionSession.loggerfield intact).- Surface:
test/handlers/lifecycle.test.ts. - Red: change
makeSetupto pass an explicit, session-independentlogger(e.g. a secondmakeLogger()) as a fourth constructor arg and keep assertinglogger.warn/logger.debug. This fails to compile against the 3-arg handler and, once compiling, fails because the handler still readsthis.session.logger. - Green: add the
loggerconstructor parameter toSessionLifecycleHandler, switch the threethis.session.logger.*calls tothis.logger.*, update its JSDoc and import, and passloggerfromindex.ts. - This commit leaves
PermissionSession.loggerin place (still read only by the reporter wiring), so the whole tree type-checks. - Commit:
refactor: inject logger into SessionLifecycleHandler (#364).
- Surface:
-
Drop the relay-only
loggerfield fromPermissionSessionand re-point the reporter wiring.- Surface:
src/permission-session.ts,src/index.ts,test/helpers/session-fixtures.ts,test/helpers/handler-fixtures.ts,test/handlers/external-directory-integration.test.ts. - This is one commit: removing the constructor field breaks every
new PermissionSession(…)call site and everysession.loggerread at the type level simultaneously, so the field removal, both construction-site updates, the reporter rewire, themakeHandlerreturn addition, and the external-directory test re-point must all land together. - Steps: remove the
readonly loggerparameter and its import/JSDoc fromPermissionSession; drop theloggerargument fromnew PermissionSession(…)inindex.tsand inmakeRealSession; changenew GateDecisionReporter(session.logger, …)tonew GateDecisionReporter(logger, …); addloggertomakeHandler's return; re-point the threesession.logger.reviewreads to the fixturelogger. - Green:
pnpm run checkandpnpm run testpass; the constructor is 6 args. - Commit:
refactor: drop relay-only logger field from PermissionSession (#364).
- Surface:
-
Align documentation.
- Surface:
.pi/skills/package-pi-permission-system/SKILL.md. - Update the documented
makeHandlerreturn bag to includelogger. - Commit:
docs(pi-permission-system): document logger in makeHandler return (#364). - The
docs/architecture/architecture.mdstep-complete mark is performed at ship time per the package convention.
- Surface:
Risks and Mitigations
- Risk: missing a
session.loggerconsumer. Mitigation: the full-tree grep found exactly four reach-throughs (3 inlifecycle.ts, 1 inindex.ts) plus three test reads inexternal-directory-integration.test.ts; the TypeScript compiler will reject any missed site once the field is removed in Step 2. - Risk: Step 2 is a multi-file atomic change; a partial edit leaves the tree red.
Mitigation: it is a single commit gated by
pnpm run check; the interlock is intentional and small (six files). - Risk: stale documentation. Mitigation: Step 3 updates the package skill; the architecture step-complete mark is part of ship.
Open Questions
- The
MockGateHandlerSessiontest type inhandler-fixtures.tsstill carries alogger: SessionLoggermember (commented "Logger shape expected by GateDecisionReporter"). After this change the real session no longer exposes a logger and nothing reads.loggeroff that mock type. Removing it is a tidy-up but would also require a SKILL.md edit (the type is documented there); defer unless it proves to be dead weight during implementation.