11 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 366 | Narrow `LocalPermissionsService` collaborators to interfaces |
Narrow LocalPermissionsService collaborators to interfaces
Problem Statement
LocalPermissionsService (src/permissions-service.ts) is constructed with three concrete classes — PermissionManager, SessionRules, and ToolInputFormatterRegistry — but it only calls checkPermission / getToolPermission, getRuleset, and register.
Concrete-class parameter types expose the classes' private members to TypeScript's structural checker, so a plain test double can never satisfy them.
permissions-service.test.ts is therefore forced into three as unknown as casts to build its mocks.
The awkward test object is the symptom; depending on concretions instead of abstractions is the cause.
Goals
- Type the three constructor parameters of
LocalPermissionsServiceas narrow interfaces, not concrete classes. - Reuse the existing
ScopedPermissionManagerinterface for the manager dependency. - Use
Pick<SessionRules, "getRuleset">for the ruleset read, matching the existing precedent inpermission-resolver.tsandpermission-event-rpc.ts. - Introduce a named
{ register }formatter interface that mirrors the existing read-sideToolInputFormatterLookup. - Remove the three
as unknown ascasts inpermissions-service.test.ts; mocks become plain objects.
This change is not breaking: it narrows internal parameter types only.
There is no change to observable behavior, output shape, public config, or any default.
The construction site in index.ts passes the same concrete instances, which structurally satisfy the narrower interfaces, so it needs no edit.
Non-Goals
- Track C Step 6 (#367, narrowing
PermissionForwarder'sExtensionContextdependency) — a sibling roadmap step, out of scope here. - Narrowing
ScopedPermissionManageritself to only the two methods the service uses — see Risks; this plan deliberately reuses the established shared interface. - Marking the roadmap step
✓ completeindocs/architecture/architecture.md— that is a shipping-time action performed during/ship-issue, not part of this refactor commit.
Background
Relevant existing modules:
src/permissions-service.ts— the class under change. Its three methods delegate straight to the collaborators:checkPermission→permissionManager.checkPermission(...)withsessionRules.getRuleset();getToolPermission→permissionManager.getToolPermission(...);registerToolInputFormatter→formatterRegistry.register(...).src/permission-manager.ts— already exportsScopedPermissionManager, a narrow interface implemented by the concretePermissionManager. It declaresconfigureForCwd,checkPermission,getToolPermission,getConfigIssues,getPolicyCacheStamp.PermissionSessionandPermissionResolveralready depend on this interface rather than the concrete class.src/session-rules.ts— exports the concreteSessionRulesclass withgetRuleset(): Ruleset.permission-resolver.tsandpermission-event-rpc.tsalready depend onPick<SessionRules, "getRuleset">.src/tool-input-formatter-registry.ts— exports the concreteToolInputFormatterRegistryplus the read-side interfaceToolInputFormatterLookup { get(...) }. There is no write-side interface yet; this plan adds one.src/index.ts(line ~126) — the sole production construction site:new LocalPermissionsService(permissionManager, sessionRules, formatterRegistry).
Constraint from the code-design skill (Structural Design → Dependency width): "When a shared interface references a collaborator, use a narrow interface type — not the concrete class.
Concrete class types expose private fields to TypeScript's structural checker, forcing test mocks to cast or replicate internals."
This issue is the direct remediation of that smell.
Constraint from the package skill: when a refactor targets testability, read the test files alongside the production code (done — see Test Impact Analysis).
Design Overview
The change replaces three concrete parameter types with abstractions. No runtime behavior changes; this is a pure type-narrowing refactor.
New write-side interface, added in tool-input-formatter-registry.ts directly above ToolInputFormatterLookup so the read/write pair sits together:
/**
* Registration side of the formatter registry (ISP — exposes only the
* write surface, mirroring the read-only {@link ToolInputFormatterLookup}).
*/
export interface ToolInputFormatterRegistrar {
register(toolName: string, formatter: ToolInputFormatter): () => void;
}
The concrete ToolInputFormatterRegistry gains ToolInputFormatterRegistrar in its implements clause (alongside the existing ToolInputFormatterLookup) so the contract is locked at the class declaration, not only inferred structurally.
Narrowed constructor in permissions-service.ts:
export class LocalPermissionsService implements PermissionsService {
constructor(
private readonly permissionManager: ScopedPermissionManager,
private readonly sessionRules: Pick<SessionRules, "getRuleset">,
private readonly formatterRegistry: ToolInputFormatterRegistrar,
) {}
// method bodies unchanged
}
Construction site (index.ts) — unchanged.
PermissionManager implements ScopedPermissionManager, SessionRules has getRuleset, and ToolInputFormatterRegistry has register, so all three concrete instances satisfy the narrower parameter types with no edit.
Test mocks become plain objects (the payoff):
function makePermissionManager() {
return {
checkPermission: vi.fn(...).mockReturnValue(makeCheckResult()),
getToolPermission: vi.fn(...).mockReturnValue("allow"),
} satisfies Pick<ScopedPermissionManager, "checkPermission" | "getToolPermission">;
}
The mock only needs the two methods the service calls — ScopedPermissionManager does not force the other three onto the literal because the parameter is structurally satisfied by a value typed as the Pick.
The factory return types use Pick<ScopedPermissionManager, "checkPermission" | "getToolPermission">, Pick<SessionRules, "getRuleset">, and Pick<ToolInputFormatterRegistrar, "register"> (or the bare interface) so no field beyond what the test exercises is required, and no as unknown as cast survives.
Edge cases
- None affecting runtime — the method bodies are untouched.
- The only failure mode is a compile error if a parameter type is narrowed incorrectly;
pnpm run checkcatches it at the commit boundary.
Module-Level Changes
src/tool-input-formatter-registry.ts— add the exportedToolInputFormatterRegistrarinterface; add it to theToolInputFormatterRegistryclassimplementsclause.src/permissions-service.ts— change the three constructor parameter types; update imports (PermissionManager→ScopedPermissionManagerfrom./permission-manager; keeptype SessionRulesfor thePick; replaceToolInputFormatterRegistrywithToolInputFormatterRegistrar, keepToolInputFormatter).test/permissions-service.test.ts— drop the threeas unknown ascasts; retype the three mock factories to the narrow interfaces; update imports to match.src/index.ts— no change (construction site already passes satisfying concrete instances).docs/architecture/architecture.md— no change in this plan; the✓ completemark on Track C Step 5 is applied at ship time.
Grep confirmation: LocalPermissionsService is constructed only in src/index.ts and test/permissions-service.test.ts.
No barrel re-export, skill doc, or other module references the concrete-class parameter types of this constructor.
Test Impact Analysis
- New tests enabled — none required.
The existing four/
describeblocks already cover all three methods (checkPermissioninput building + delegation + return passthrough,getToolPermissiondelegation + optionalagentName,registerToolInputFormatterdelegation + disposer passthrough). The narrowing makes those tests cleaner (plain-object mocks) without adding coverage. - Tests becoming redundant — none. The existing assertions still pertain; only the mock construction simplifies.
- Tests that must stay as-is — all of them.
They genuinely exercise
LocalPermissionsService's delegation contract, which is the layer being kept; the change only removes the casts they were forced into.
TDD Order
- Red → Green → Commit — narrow the collaborators.
- Red: in
test/permissions-service.test.ts, remove the threeas unknown as PermissionManager/SessionRules/ToolInputFormatterRegistrycasts and retype the mock factories to the narrow interfaces.pnpm run check(tsc) fails: a plain object typed as the narrow interface does not satisfy the still-concrete constructor parameters. - Green: add
ToolInputFormatterRegistrartosrc/tool-input-formatter-registry.ts(interface +implements); narrow the three constructor parameter types insrc/permissions-service.tsand fix its imports.pnpm run check,pnpm run lint, andpnpm run testpass;index.tsneeds no change. - This is a single atomic type change — the test simplification and the production narrowing must land in the same commit to keep the tree green.
- Commit:
refactor: narrow LocalPermissionsService collaborators to interfaces (#366).
- Red: in
Risks and Mitigations
- Risk: reusing
ScopedPermissionManager(5 methods) when the service calls only 2 is wider than strict ISP would prescribe. Mitigation: this is a deliberate, documented decision — both the issue and the Phase 5 Track C roadmap (docs/architecture/architecture.md) specify reusing the established shared interface thatPermissionSessionandPermissionResolveralready depend on, keeping the manager's contract consistent across consumers rather than fragmenting into per-consumerPicks. The testability goal (noas unknown ascast) is fully met regardless, because the test's mock factory return type is aPickof the two methods it exercises. - Risk: a hidden second construction site would break on the narrowed types.
Mitigation: grep confirms
index.tsand the test file are the only constructors; the concrete instancesindex.tspasses satisfy the narrower interfaces unchanged. - Risk: forgetting to add
implements ToolInputFormatterRegistrarwould leave the contract only structurally enforced. Mitigation: the step adds it to the class declaration so the compiler verifies the registry still satisfies the write side.
Open Questions
- None. The design is fully specified by the issue and the roadmap; deferred items (#367, roadmap completion mark) are captured under Non-Goals.