Files
my-pi/pi-permission-system/docs/plans/archive/0021-split-index-into-focused-modules.md

369 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 21
issue_title: "Split src/index.ts (1,983 lines) into focused modules"
---
# Split src/index.ts into focused modules
## Problem Statement
`src/index.ts` is ~1,973 lines and houses at least seven distinct concerns beyond the extension factory that is its actual responsibility.
AGENTS.md requires "one concern per file in `src/`" — `index.ts` is the last major outlier.
## Goals
- Mechanically extract cohesive groups of functions into new focused modules.
- Reduce `src/index.ts` line count by moving all extractable module-scope functions out.
- Preserve all existing behavior — no observable change from the test suite.
- Add unit tests for the newly extracted modules and the pre-existing focused modules that lack them, using dependency injection and vitest mocks to test each module in isolation.
## Non-Goals
- Behavior changes of any kind.
- Restructuring the `piPermissionSystemExtension` factory or its closure-scoped helpers (they are inherently coupled to extension lifecycle state).
## Background
### Dependency status
| Issue | Title | Status | Relevance |
| ----- | --------------------------------------- | ------------------ | ------------------------------------------------------------------------------------- |
| #10 | Consolidate config layout | Closed/implemented | Was a prerequisite — no longer blocks. |
| #20 | Delete permission-request event channel | Closed/implemented | Removed `emitPermissionRequestEvent` and related types, reducing `index.ts` slightly. |
### What was in src/index.ts (~1,973 lines)
The file contained these function/constant groups beyond the extension factory:
1. **Active-agent detection** (~50 lines): `ACTIVE_AGENT_TAG_REGEX`, `normalizeAgentName`, `getActiveAgentName`, `getActiveAgentNameFromSystemPrompt`.
2. **External-directory / path utilities** (~70 lines): `PATH_BEARING_TOOLS`, `normalizePathForComparison`, `isPathWithinDirectory`, `getPathBearingToolPath`, `isPathOutsideWorkingDirectory`.
3. **Permission prompt formatting** (~250 lines): `formatMissingToolNameReason`, `formatUnknownToolReason`, `formatPermissionHardStopHint`, `formatDenyReason`, `formatUserDeniedReason`, `formatAskPrompt`, `formatSkillAskPrompt`, `formatSkillPathAskPrompt`, `formatSkillPathDenyReason`, `formatExternalDirectoryHardStopHint`, `formatExternalDirectoryAskPrompt`, `formatExternalDirectoryDenyReason`, `formatExternalDirectoryUserDeniedReason`.
4. **Tool-input preview / text utilities** (~120 lines): `TOOL_INPUT_PREVIEW_MAX_LENGTH`, `TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH`, `TOOL_TEXT_SUMMARY_MAX_LENGTH`, `truncateInlineText`, `sanitizeInlineText`, `countTextLines`, `formatCount`, `getPromptPath`, `formatEditInputForPrompt`, `formatWriteInputForPrompt`, `formatReadInputForPrompt`, `formatSearchInputForPrompt`, `serializeToolInputPreview`, `formatJsonInputForPrompt`, `formatToolInputForPrompt`, `formatGenericToolInputForLog`, `getToolInputPreviewForLog`, `getPermissionLogContext`.
5. **Subagent context** (~25 lines): `normalizeFilesystemPath`, `isSubagentExecutionContext`.
6. **Forwarded-permission file IO** (~180 lines): `sleep`, `formatUnknownErrorMessage`, `isErrnoCode`, `logPermissionForwardingWarning`, `logPermissionForwardingError`, `ensureDirectoryExists`, `getPermissionForwardingLocationForSession`, `ensurePermissionForwardingLocation`, `getExistingPermissionForwardingLocation`, `tryRemoveDirectoryIfEmpty`, `cleanupPermissionForwardingLocationIfEmpty`, `safeDeleteFile`, `writeJsonFileAtomic`, `readForwardedPermissionRequest`, `readForwardedPermissionResponse`.
7. **Forwarded-permission polling + confirmation** (~180 lines): `formatForwardedPermissionPrompt`, `waitForForwardedPermissionApproval`, `processForwardedPermissionRequests`, `confirmPermission`.
8. **Misc helpers** (~30 lines): `extractSkillNameFromInput`, `getEventToolName`, `getEventInput`, `getContextSystemPrompt`, `getSessionId`, `canRequestPermissionConfirmation`, `derivePiProjectPaths`, `createPermissionManagerForCwd`.
### Permission surfaces affected
None — pure refactor.
### Test coverage gap
Before this issue, the entire `src/index.ts` — and every pre-existing focused module that was *already* extracted (`bash-filter.ts`, `wildcard-matcher.ts`, `system-prompt-sanitizer.ts`, `skill-prompt-sanitizer.ts`, `permission-manager.ts`, etc.) — was tested exclusively through 2 integration test files:
| File | Tests | Lines |
| --------------------------------- | ----- | ----- |
| `tests/permission-system.test.ts` | 68 | 2,490 |
| `tests/session-start.test.ts` | 2 | 114 |
These are flat lists of `test()` calls (no `describe()` grouping) that exercise the full `piPermissionSystemExtension` factory end-to-end via a mock `ExtensionAPI`.
The modules listed below have **no dedicated unit test file** at all:
- `src/active-agent.ts` (58 lines) — newly extracted
- `src/bash-filter.ts` (51 lines) — pre-existing
- `src/before-agent-start-cache.ts` (44 lines) — pre-existing
- `src/common.ts` (88 lines) — pre-existing
- `src/external-directory.ts` (113 lines) — newly extracted
- `src/logging.ts` (118 lines) — pre-existing
- `src/permission-dialog.ts` (89 lines) — pre-existing
- `src/permission-forwarding.ts` (126 lines) — pre-existing
- `src/permission-manager.ts` (941 lines) — pre-existing
- `src/permission-prompts.ts` (131 lines) — newly extracted
- `src/skill-prompt-sanitizer.ts` (344 lines) — pre-existing
- `src/status.ts` (35 lines) — pre-existing
- `src/subagent-context.ts` (52 lines) — newly extracted
- `src/system-prompt-sanitizer.ts` (210 lines) — pre-existing
- `src/tool-input-preview.ts` (206 lines) — newly extracted
- `src/tool-registry.ts` (139 lines) — pre-existing
- `src/wildcard-matcher.ts` (84 lines) — pre-existing
- `src/yolo-mode.ts` (29 lines) — pre-existing
- `src/forwarded-permissions/io.ts` (328 lines) — newly extracted
- `src/forwarded-permissions/polling.ts` (334 lines) — newly extracted
### Design challenges still in index.ts
After extraction, `src/index.ts` is ~970 lines.
The factory function itself is ~740 lines because it owns:
1. **Module-scope mutable state**`extensionConfig`, `extensionLogger`, `loggingWarningReporter`, and `reportedLoggingWarnings` live outside the factory.
The factory writes to them via `setExtensionConfig` / `setLoggingWarningReporter` and every extracted module that needs logging receives it via setter injection (`setForwardedPermissionLogger`).
This works but creates hidden temporal coupling: callers must call the setter before any logging function is invoked.
2. **Module-scope constants derived from `getAgentDir()`**`PI_AGENT_DIR`, `SESSIONS_DIR`, `SUBAGENT_SESSIONS_DIR`, `PERMISSION_FORWARDING_DIR`, and `GLOBAL_LOGS_DIR` are all computed at import time, which violates the AGENTS.md rule *"Do not cache `getAgentDir()` at module scope."*
They happen to work because `getAgentDir()` returns a stable value in production, but they make the module difficult to test in isolation (tests set `PI_CODING_AGENT_DIR` after import).
3. **Closure-scoped helpers that could be pure**`refreshExtensionConfig`, `saveExtensionConfig`, `resolveAgentName`, `shouldExposeTool`, `logResolvedConfigPaths`, `reviewPermissionDecision`, `promptPermission`, `startForwardedPermissionPolling`, and `stopForwardedPermissionPolling` are all closures over `permissionManager`, `runtimeContext`, `extensionConfig`, and the forwarding timer.
Some of these (e.g., `resolveAgentName`, `shouldExposeTool`) could be pure functions if given their dependencies as parameters; others (e.g., `startForwardedPermissionPolling`) genuinely need mutable timer state.
4. **Six event handlers inline**`session_start`, `resources_discover`, `session_shutdown`, `before_agent_start`, `input`, and `tool_call` are defined inline as lambdas inside the factory.
The `tool_call` handler alone is ~250 lines.
These could be separate named functions that receive a context object with the shared state.
Addressing these is out of scope for this issue (pure refactor) but would be the next step toward a testable, sub-300-line `index.ts`.
## Design Overview
### New module layout (implemented)
| New file | Concern | Actual lines |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| `src/active-agent.ts` | Agent name extraction from session metadata and system prompt | 58 |
| `src/external-directory.ts` | Path normalization, outside-cwd detection, `PATH_BEARING_TOOLS`, external-directory format helpers | 113 |
| `src/permission-prompts.ts` | All `format*` helpers for ask/deny/user-denied prompts | 131 |
| `src/tool-input-preview.ts` | Text utilities and tool-input formatting for prompts and logs | 206 |
| `src/subagent-context.ts` | `isSubagentExecutionContext`, `normalizeFilesystemPath` | 52 |
| `src/forwarded-permissions/io.ts` | Atomic JSON write, request/response read, directory ensure/cleanup, error helpers, logger setter | 328 |
| `src/forwarded-permissions/polling.ts` | `waitForForwardedPermissionApproval`, `processForwardedPermissionRequests`, `confirmPermission`, `getSessionId`, `getContextSystemPrompt`, `formatForwardedPermissionPrompt`, `PermissionForwardingDeps` | 334 |
`src/index.ts` retains (~970 lines):
- Imports from the new modules and existing ones.
- Module-scope logging state and helpers (`extensionConfig`, `extensionLogger`, `writeDebugLog`, `writeReviewLog`, etc.).
- Module-scope constants (`PI_AGENT_DIR`, `SESSIONS_DIR`, etc.).
- Small helpers tightly coupled to module state (`extractSkillNameFromInput`, `getEventToolName`, `getEventInput`, `canRequestPermissionConfirmation`, `derivePiProjectPaths`, `createPermissionManagerForCwd`).
- `piPermissionSystemExtension` factory (the default export) with closure-scoped helpers and six event handlers.
### Module dependency direction
```text
index.ts
├── active-agent.ts
├── external-directory.ts
├── permission-prompts.ts
│ └── tool-input-preview.ts
├── subagent-context.ts
└── forwarded-permissions/
├── io.ts
└── polling.ts (imports io.ts, active-agent.ts, subagent-context.ts)
```
No new module imports from `index.ts` — dependency flows one way (index → modules).
### Dependency injection patterns used
- `isSubagentExecutionContext(ctx, subagentSessionsDir)` — takes the directory as a parameter instead of reading a module-scope constant.
- `getToolInputPreviewForLog(result, input, pathBearingTools)` / `getPermissionLogContext(result, input, pathBearingTools)` — receive the `PATH_BEARING_TOOLS` set as a parameter.
- `ensurePermissionForwardingLocation(forwardingDir, sessionId)` / `getExistingPermissionForwardingLocation(forwardingDir, sessionId)` — receive the forwarding directory as a parameter.
- `setForwardedPermissionLogger({ writeReviewLog, writeDebugLog })` — setter injection for the IO module's logger.
- `PermissionForwardingDeps` — context object passed to `confirmPermission`, `processForwardedPermissionRequests`, and `waitForForwardedPermissionApproval` carrying `forwardingDir`, `subagentSessionsDir`, `writeReviewLog`, `requestPermissionDecisionFromUi`, and `shouldAutoApprove`.
### Testing strategy: dependency injection and vitest mocks
Every module has collaborators — other modules it imports and calls.
Unit tests should verify each module in isolation by mocking its collaborators with `vi.mock()` and, where needed, `vi.fn()` / `vi.spyOn()`.
This ensures tests exercise the module's own logic and boundary conditions without coupling to the real behavior of dependencies.
**Guiding principles:**
1. **Mock collaborators, not the module under test.**
If `permission-prompts.ts` imports `formatToolInputForPrompt` from `tool-input-preview.ts`, the permission-prompts tests mock `tool-input-preview.ts` and verify that the prompts module calls it with the right arguments and uses its return value correctly.
2. **Use `vi.mock()` for module-level imports.**
Vitest hoists `vi.mock()` calls so the module under test receives mocked versions of its dependencies at import time.
3. **Use `vi.fn()` for injected function dependencies.**
When a function takes a callback or deps object (e.g., `PermissionForwardingDeps`), pass `vi.fn()` stubs and assert they were called correctly.
4. **Mock `ExtensionContext` as a plain object.**
The Pi `ExtensionContext` is an interface — tests construct minimal objects satisfying only the properties the module actually reads (e.g., `{ sessionManager: { getEntries: vi.fn() } }`).
5. **Mock filesystem operations.**
Modules that use `node:fs` (`forwarded-permissions/io.ts`) should have `node:fs` mocked via `vi.mock("node:fs")` so tests never touch the real filesystem.
6. **Restore mocks between tests.**
Use `afterEach(() => { vi.restoreAllMocks(); })` to prevent test pollution.
**Example pattern for a module with collaborators:**
```typescript
import { describe, expect, test, vi, afterEach } from "vitest";
// Mock the collaborator module before importing the module under test.
vi.mock("../tool-input-preview.js", () => ({
formatToolInputForPrompt: vi.fn(() => "mocked preview"),
}));
import { formatAskPrompt } from "../src/permission-prompts.js";
import { formatToolInputForPrompt } from "../src/tool-input-preview.js";
afterEach(() => {
vi.restoreAllMocks();
});
describe("formatAskPrompt", () => {
test("includes tool input preview for non-bash tools", () => {
const result = formatAskPrompt(
{ toolName: "read", state: "ask", source: "tool" },
"my-agent",
{ path: "/foo" },
);
expect(formatToolInputForPrompt).toHaveBeenCalledWith("read", { path: "/foo" });
expect(result).toContain("mocked preview");
});
});
```
### Export strategy
Each new module exports only the functions and constants that `index.ts` (or sibling modules) actually reference.
`PermissionReviewSource` stays in `index.ts` as it is only used there.
### Module-scope constant rule
Per AGENTS.md, `getAgentDir()` must not be cached at module scope.
The extracted modules receive directory values as parameters; `index.ts` calls `getAgentDir()` at invocation time inside closures (no change from current behavior).
Constants like `ACTIVE_AGENT_TAG_REGEX`, `PATH_BEARING_TOOLS`, and length limits are safe at module scope since they do not depend on the environment.
## Module-Level Changes
### Added
| File | Content |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/active-agent.ts` | `ACTIVE_AGENT_TAG_REGEX`, `normalizeAgentName`, `getActiveAgentName`, `getActiveAgentNameFromSystemPrompt` |
| `src/external-directory.ts` | `PATH_BEARING_TOOLS`, `normalizePathForComparison`, `isPathWithinDirectory`, `getPathBearingToolPath`, `isPathOutsideWorkingDirectory`, `formatExternalDirectoryHardStopHint`, `formatExternalDirectoryAskPrompt`, `formatExternalDirectoryDenyReason`, `formatExternalDirectoryUserDeniedReason` |
| `src/permission-prompts.ts` | `formatMissingToolNameReason`, `formatUnknownToolReason`, `formatPermissionHardStopHint`, `formatDenyReason`, `formatUserDeniedReason`, `formatAskPrompt`, `formatSkillAskPrompt`, `formatSkillPathAskPrompt`, `formatSkillPathDenyReason` |
| `src/tool-input-preview.ts` | `truncateInlineText`, `sanitizeInlineText`, `countTextLines`, `formatCount`, `getPromptPath`, `formatEditInputForPrompt`, `formatWriteInputForPrompt`, `formatReadInputForPrompt`, `formatSearchInputForPrompt`, `serializeToolInputPreview`, `formatJsonInputForPrompt`, `formatToolInputForPrompt`, `formatGenericToolInputForLog`, `getToolInputPreviewForLog`, `getPermissionLogContext`, length constants |
| `src/subagent-context.ts` | `normalizeFilesystemPath`, `isSubagentExecutionContext` |
| `src/forwarded-permissions/io.ts` | `sleep`, `formatUnknownErrorMessage`, `isErrnoCode`, `logPermissionForwardingWarning`, `logPermissionForwardingError`, `ensureDirectoryExists`, `getPermissionForwardingLocationForSession`, `ensurePermissionForwardingLocation`, `getExistingPermissionForwardingLocation`, `tryRemoveDirectoryIfEmpty`, `cleanupPermissionForwardingLocationIfEmpty`, `safeDeleteFile`, `writeJsonFileAtomic`, `readForwardedPermissionRequest`, `readForwardedPermissionResponse`, `listRequestFiles`, `setForwardedPermissionLogger`, `ForwardedPermissionLogger` |
| `src/forwarded-permissions/polling.ts` | `getSessionId`, `getContextSystemPrompt`, `formatForwardedPermissionPrompt`, `waitForForwardedPermissionApproval`, `processForwardedPermissionRequests`, `confirmPermission`, `PermissionForwardingDeps` |
### Changed
| File | Change |
| -------------- | -------------------------------------------------------------------------------------------------------- |
| `src/index.ts` | Removed extracted functions/constants; added imports from new modules; reduced from ~1,973 to ~970 lines |
### Unchanged
All pre-existing modules (`src/permission-manager.ts`, `src/bash-filter.ts`, `src/wildcard-matcher.ts`, `src/system-prompt-sanitizer.ts`, `src/skill-prompt-sanitizer.ts`, `src/extension-config.ts`, etc.) and test files are not modified.
## TDD Order
### Phase 1: Extract modules (steps 18, completed)
Since this was a pure mechanical refactor with no behavior change, the cycle was extract → verify → commit.
Existing tests passed after each step without modification.
1.**Extract `src/active-agent.ts`** — move agent-name detection functions.
Commit: `refactor: extract active-agent detection into src/active-agent.ts (#21)`
2.**Extract `src/subagent-context.ts`** — move subagent detection helpers.
Commit: `refactor: extract subagent context into src/subagent-context.ts (#21)`
3.**Extract `src/tool-input-preview.ts`** — move text utilities and tool-input formatters.
Commit: `refactor: extract tool-input preview into src/tool-input-preview.ts (#21)`
4.**Extract `src/external-directory.ts`** — move path utilities and external-directory format helpers.
Commit: `refactor: extract external-directory logic into src/external-directory.ts (#21)`
5.**Extract `src/permission-prompts.ts`** — move ask/deny/user-denied prompt formatters (imports `tool-input-preview.ts`).
Commit: `refactor: extract permission prompts into src/permission-prompts.ts (#21)`
6.**Extract `src/forwarded-permissions/io.ts`** — move file IO, directory management, and error helpers.
Commit: `refactor: extract forwarded-permission IO into src/forwarded-permissions/io.ts (#21)`
7.**Extract `src/forwarded-permissions/polling.ts`** — move polling loop and `confirmPermission`.
Commit: `refactor: extract forwarded-permission polling into src/forwarded-permissions/polling.ts (#21)`
8.**Final cleanup** — remove dead imports, fix lint warnings.
Commit: `refactor: finalize index.ts split (#21)`
### Phase 2: Add unit tests (steps 920)
Each step adds a dedicated test file for a module that currently has no unit tests.
The goal is direct coverage of each module's exported functions in isolation — using `vi.mock()` to replace collaborator modules and `vi.fn()` for injected dependencies.
Tests exercise edge cases and boundary conditions that are hard to reach through the end-to-end factory tests.
Every test file should use `describe()` blocks to group tests by exported function, and `afterEach(() => { vi.restoreAllMocks(); })` to prevent cross-test pollution.
1. **`tests/wildcard-matcher.test.ts`** — test `compileWildcardPatternEntries`, `findCompiledWildcardMatch`, `findCompiledWildcardMatchForNames`.
No collaborators to mock (pure algorithm).
Cover: empty patterns, exact match, glob `*` matching, last-match-wins precedence, multi-name lookup, no-match returns null.
Commit: `test: add unit tests for wildcard-matcher (#21)`
2. **`tests/common.test.ts`** — test `toRecord`, `getNonEmptyString`, `isPermissionState`, `extractFrontmatter`, `parseSimpleYamlMap`.
No collaborators to mock (pure functions).
Cover: non-object inputs to `toRecord`, whitespace-only strings, all three permission states, malformed frontmatter delimiters, empty YAML map, multi-line values.
Commit: `test: add unit tests for common (#21)`
3. **`tests/bash-filter.test.ts`** — test `BashFilter.check`.
Mock: `vi.mock("./wildcard-matcher.js")` to verify `BashFilter` delegates pattern matching to the wildcard-matcher and applies the default fallback correctly.
Cover: exact match, glob patterns, last-match-wins, default fallback for unmatched commands, empty command, whitespace normalization.
Commit: `test: add unit tests for bash-filter (#21)`
4. **`tests/yolo-mode.test.ts`** — test `shouldAutoApprovePermissionState`, `canResolveAskPermissionRequest`.
No collaborators to mock (pure functions taking config/flags).
Cover: yolo on/off × ask/allow/deny, subagent with no UI and yolo off, subagent with no UI and yolo on.
Commit: `test: add unit tests for yolo-mode (#21)`
5. **`tests/tool-input-preview.test.ts`** — test all exported formatters and `getPermissionLogContext`.
Mock: `vi.mock("./logging.js")` so `safeJsonStringify` returns controlled output — verifies the module delegates serialization to its collaborator and handles the result.
Cover: truncation at exact boundary, multi-line content, empty input, edit with multiple replacements, path-bearing vs non-path-bearing tools in `getPermissionLogContext`.
Commit: `test: add unit tests for tool-input-preview (#21)`
6. **`tests/external-directory.test.ts`** — test `normalizePathForComparison`, `isPathWithinDirectory`, `isPathOutsideWorkingDirectory`, `getPathBearingToolPath`, and format helpers.
Mock: `vi.mock("node:os", () => ({ homedir: vi.fn(() => "/mock/home") }))` so tilde-expansion tests are deterministic and platform-independent.
Cover: tilde expansion, relative paths, path-bearing vs non-path-bearing tools, empty strings, quoted paths, `@`-prefixed paths, format helpers with/without agent name.
Commit: `test: add unit tests for external-directory (#21)`
7. **`tests/permission-prompts.test.ts`** — test all `format*` exported functions.
Mock: `vi.mock("./tool-input-preview.js")` so `formatToolInputForPrompt` returns controlled strings — verifies `formatAskPrompt` calls the collaborator with the right tool name and input and incorporates the preview into the prompt string.
Cover: with/without agent name, MCP target, bash command with/without matched pattern, denial reason, skill path deny/ask.
Commit: `test: add unit tests for permission-prompts (#21)`
8. **`tests/active-agent.test.ts`** — test `normalizeAgentName`, `getActiveAgentName`, `getActiveAgentNameFromSystemPrompt`.
Mock `ExtensionContext` as a plain object: `{ sessionManager: { getEntries: vi.fn(() => [...]) } }`.
Cover: whitespace-only name, null, tag variations in system prompt, missing tag, session entries with `active_agent` custom type, last-entry-wins when multiple entries exist, entry with `name: null` resets.
Commit: `test: add unit tests for active-agent (#21)`
9. **`tests/subagent-context.test.ts`** — test `isSubagentExecutionContext`, `normalizeFilesystemPath`.
Mock `ExtensionContext` as a plain object: `{ sessionManager: { getSessionDir: vi.fn() } }`.
Use `vi.stubEnv()` / `vi.unstubAllEnvs()` to control `SUBAGENT_ENV_HINT_KEYS` without leaking across tests.
Cover: env variable detection (each of the 3 hint keys), session dir within/outside subagent root, missing session dir, empty env values.
Commit: `test: add unit tests for subagent-context (#21)`
10. **`tests/tool-registry.test.ts`** — test `checkRequestedToolRegistration`, `getToolNameFromValue`.
Mock: `vi.mock("./common.js")` to control `getNonEmptyString` / `toRecord` return values — verifies the registry delegates input parsing to its collaborators.
Cover: registered tool, unregistered tool, missing tool name, event with `input` vs `arguments`, empty tool list.
Commit: `test: add unit tests for tool-registry (#21)`
11. **`tests/system-prompt-sanitizer.test.ts`** — test `sanitizeAvailableToolsSection`.
No collaborators to mock (pure string transformation).
Cover: removing denied tools, preserving allowed tools, multi-section prompts, missing Available tools section, tool guidance blocks for inactive tools, empty allowed-tools list.
Commit: `test: add unit tests for system-prompt-sanitizer (#21)`
12. **`tests/skill-prompt-sanitizer.test.ts`** — test `resolveSkillPromptEntries`, `findSkillPathMatch`.
Mock `PermissionManager` as a plain object: `{ checkPermission: vi.fn() }` — verifies the sanitizer delegates permission checks to the manager and uses the returned state to decide whether to strip, keep, or mark skill blocks.
Cover: skill allow/deny/ask, path matching within/outside skill directories, multi-skill prompts, no skill blocks in prompt.
Commit: `test: add unit tests for skill-prompt-sanitizer (#21)`
## Risks and Mitigations
| Risk | Mitigation |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Could this silently weaken a permission? | No — pure refactor moves functions without changing logic. Every step verifies `npm test` passes, and the test suite covers tool/bash/mcp/skill/special/external-directory permission decisions. |
| Circular dependency between new modules | Dependency flows one way (index → modules → shared types). No module imports from `index.ts`. `tool-input-preview.ts` is imported by `permission-prompts.ts` only. |
| Module-scope caching of `getAgentDir()` | Extracted modules receive directory paths as parameters. `getAgentDir()` is called only in `index.ts` closures at invocation time, matching the existing pattern and the AGENTS.md rule. Note: `index.ts` itself still caches `PI_AGENT_DIR` at module scope — this is a pre-existing violation, not introduced by this change. |
| Import path breaks in tests | Only two test files import from `index.ts` (`tests/permission-system.test.ts`, `tests/session-start.test.ts`), both importing only the default export `piPermissionSystemExtension`, which remains in `index.ts`. |
| Forwarded-permission closures depend on logger state | `forwarded-permissions/io.ts` uses a setter-injected logger (`setForwardedPermissionLogger`). The setter must be called before any IO function that logs. This is wired up in the factory before `refreshExtensionConfig()`. |
| Mocked unit tests could drift from real module contracts | Unit tests verify each module's contract with its collaborators (correct arguments passed, return values used). Integration tests in `permission-system.test.ts` continue to verify end-to-end wiring with real collaborators. Both must pass — mocks catch contract violations early, integration tests catch wiring mistakes. |
| Over-mocking hides real bugs | Mock only direct collaborators (one level deep). Never mock the module under test. If a test needs to mock more than 23 collaborators, that is a signal the module has too many responsibilities and should be split further. |
## Open Questions
- **Should `PermissionReviewSource` move to `src/types.ts`?**
Currently only used in `index.ts`.
Defer until a second module needs it.
- **Should `extractSkillNameFromInput` move to `src/skill-prompt-sanitizer.ts`?**
It's closely related but currently only called in the `input` event handler.
Defer to keep this change mechanical.
- **Should `permission-manager.ts` (941 lines) get its own unit test file?**
It has complex logic (MCP target resolution, policy merge, caching) that would benefit from direct tests with `vi.mock("node:fs")` to control config file reads.
Deferred — it would be a large effort and the integration tests cover the main paths.
Consider as a separate issue.
- **Should the module-scope `PI_AGENT_DIR` constants be moved inside the factory?**
This would fix the AGENTS.md rule violation but requires threading the values through more call sites.
Consider as part of a future factory restructuring issue.
- **Should `forwarded-permissions/io.ts` replace setter injection with parameter injection?**
The current `setForwardedPermissionLogger` pattern creates temporal coupling.
An alternative is to pass the logger as a parameter to each function that logs (matching the `PermissionForwardingDeps` pattern in `polling.ts`).
This would make the module fully stateless and easier to test without calling a setter first.
Consider for a future cleanup.