20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 480 | pi-permission-system: extract shared fixtures for the external-directory tests (Phase 6 Step 8) |
Extract shared fixtures for the external-directory tests
Release Recommendation
Release: ship independently
Per the Phase 6 roadmap, Step 8 (#480) carries Release: independent — it has no batch siblings (Steps 6, 7, 8 are independently releasable).
The realistic nuance: this is a test-only change, so every commit is a test: type, which is a hidden: true changelog entry.
A test:-only landing does not cut a release on its own — it lands on main and auto-batches into the next feat:/fix:/unhidden-docs: release.
"Ship independently" therefore means "no batch to wait for," not "this PR cuts a release."
Problem Statement
The external-directory test suite duplicates setup heavily. fallow reports the two handler-pipeline test files as the worst clone family in the package after the Phase 6 Step 5 gate unification:
test/handlers/external-directory-integration.test.ts— 21 clone groups, 214 lines.test/handlers/external-directory-session-dedup.test.ts— 3 clone groups, 74 lines (including a 43-line whole-wiring duplicate betweenmakeDeduplicatingHandlerand the inline shutdown test).
Each file hand-rolls the same checkPermission/check(intent) routing mocks, prompter literals, handler wiring, tool-call event shapes, and decision-event/review-log query patterns.
Now that Step 5 (#477) collapsed the two external-directory gates into a single policy check, a shared fixture can target that one collapsed gate rather than two parallel gates.
Goals
- Add
test/helpers/external-directory-fixtures.tscarrying the shared setup and query helpers for the collapsed external-directory gate. - Migrate
test/handlers/external-directory-integration.test.tsandtest/handlers/external-directory-session-dedup.test.tsonto it. - Eliminate the 43-line whole-wiring duplicate inside the session-dedup file (the inline shutdown test re-implements
makeDeduplicatingHandler). - Drive package duplication from 7.1% down to ≤ 6.5% (the roadmap's Step 8 outcome target).
This change is not breaking — it touches test/ only, no runtime behavior, output shape, or default changes.
Non-Goals
test/bash-external-directory.test.tsis out of scope (operator decision, this session). It tests a different surface — the pureextractExternalPathsFromBashCommandfunction, which runs before the gate, not the collapsed gate itself. Its bulk is the repeated system-under-test call (await extractExternalPathsFromBashCommand(cmd, cwd)+expect), which thetestingskill says not to wrap to chase a clone metric. fallow does not list it among the duplication families; its "880-line arrow" is a unit-size smell, not a clone family, and is not addressable by fixture extraction. No follow-up issue is filed for it.- The other external-directory test files (
external-directory-symlink-acceptance.test.ts,test/handlers/gates/*) are not migrated — they are not part of the issue's named clone family. - No production (
src/) changes — this is a test-only refactor.
Background
Relevant existing fixtures (test/helpers/):
handler-fixtures.ts—makeHandler(builds a realPermissionSession+PermissionResolverwired into the handler and pipelines exactly asindex.ts),makeSurfaceCheck(surface-dispatchingcheckmock),makeToolCallEvent,makeCtx,makeEvents,makeToolRegistry,getDecisionEvents. File 1 already builds onmakeHandler+makeSurfaceCheck(via its localmakeExtDirCheck).session-fixtures.ts—makeRealSession,makeRealResolver, and per-collaborator fakes. File 2 builds on these directly with manualGateRunner/GateDecisionReporter/pipeline wiring.
Both files dispatch the unified permissionManager.check(intent) (the single resolution entry point since #478).
An inline handler that mocks check must dispatch on intent.kind (path-values carries values, tool carries input) and intent.surface, or external-directory checks false-green to allow — the new fixture must preserve this dispatch faithfully.
Constraints from AGENTS.md / skills that apply:
testingskill — do not wrap the system-under-test call in a helper to eliminate a clone (the act is the test subject); group shared arrangement in fixtures, keep the act explicit.testingskill — BiomenoUnusedImportsis warning-level (exit 0); after migrating, re-check each file's imports for orphans, since lint stays green on a stray import.code-designskill — no speculative exports; a fixture export with no consumer is dead code fallow flags. Therefore each migration step lands the fixture pieces it consumes in the same commit (no fixture-only commit).code-designskill (preparatory refactoring) — land the fixture + migration as small, separately-reviewabletest:commits that each leave the suite green.
Design Overview
One new fixture module, test/helpers/external-directory-fixtures.ts, hosts everything the two handler-pipeline files share for the collapsed gate.
It composes the existing handler-fixtures.ts / session-fixtures.ts rather than duplicating them.
Exports
Shared constants (currently re-declared per file):
export const EXT_DIR_CWD = "/test/project";
export const EXTERNAL_PATH = "/outside/project/file.ts";
export const ALL_PATH_BEARING_TOOLS = ["read", "write", "edit", "find", "grep", "ls"];
export const OPTIONAL_PATH_TOOLS = ["find", "grep", "ls"];
export const ALL_TOOLS = [...ALL_PATH_BEARING_TOOLS, "bash"];
Setup builders:
makeExtDirCheck(externalDirectoryState, toolState?)— moved verbatim from File 1. WrapsmakeSurfaceCheckto routeexternal_directoryto the given state,pathto a transparentallow/source: "special", and every other surface totoolState(defaultallow).makeApprovingPrompter()/makeDenyingPrompter(denialReason?)/makeUnavailablePrompter()/makeSessionApprovingPrompter()— the repeatedGatePrompterliterals from File 1 and File 2. Each returns{ canConfirm, prompt }with the rightmockResolvedValue(approved/denied/approved_for_session).makeExtDirDedupCheck(permissionManager, sessionRules)— the session-dedupcheck(intent)mock implementation, applied viavi.mocked(permissionManager.check).mockImplementation(...). This is the 43-line block currently copy-pasted betweenmakeDeduplicatingHandlerand the inline shutdown test; it returnsaskforexternal_directoryunless a recorded session rule (wildcardMatch) covers the path, elseallow.makeDeduplicatingHandler(prompter?)— moved from File 2. Builds the fully-wiredPermissionGateHandlerovermakeRealSession+makeRealResolver, installsmakeExtDirDedupCheck, and returns{ handler, prompter }. The inline shutdown test re-usesmakeExtDirDedupCheck(and, where it needs the rawsessionforsession.shutdown(), a sibling buildermakeDedupWiring(prompter?)returning{ handler, prompter, session }).
Event builders (preserve each file's existing event shape during lift-and-shift):
- File 1 keeps
makeToolCallEvent(toolName, { input: { path } })fromhandler-fixtures.ts(usesname:). - File 2 currently builds inline
{ type: "tool_call", toolCallId, toolName, input }literals (usestoolName:); addmakeExtDirToolEvent(toolName, path, toolCallId?)andmakeExtDirBashEvent(command, toolCallId?)matching that exact shape so behavior is unchanged. (getToolNameFromValueaccepts bothnameandtoolName; both suites are green today, so do not normalize the shape in this refactor.)
Query helpers (the repeated decision-event and review-log scans):
findExtDirDecision(events)→getDecisionEvents(events).find((d) => d.surface === "external_directory")— File 1 repeats this scan ~12 times.blockReviewEntries(logger)→ filters thelogger.reviewmock calls topermission_request.blockedentries — File 1 repeats this ~4 times.
Consumer call-site sketch (Tell-Don't-Ask / LoD check)
File 1, after migration — the act stays explicit, only arrangement and queries collapse:
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
await handler.handleToolCall(
makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } }),
makeCtx(),
);
expect(findExtDirDecision(events)).toMatchObject({ result: "deny", resolution: "policy_deny" });
findExtDirDecision collapses the getDecisionEvents(...).find(...) reach-through into one named query — the missing abstraction the design-review checklist (LoD §2) flags when multiple callers repeat the same chain.
File 2 shutdown test, after migration — the 43-line wiring duplicate dissolves:
const { handler, prompter, session } = makeDedupWiring();
// ... fire two calls, assert one prompt ...
session.shutdown();
// ... third call re-prompts ...
Design-review checklist outcome
| Check | Finding |
|---|---|
| Dependency width | Builders take 0–2 focused params; no wide bag introduced. |
| Law of Demeter | findExtDirDecision / blockReviewEntries remove repeated reach-throughs — a net LoD improvement. |
| Output arguments | None. |
| Scattered resets | None. |
| Parameter relay | None. |
| Test mock depth | No new as unknown as casts; reuses typed makeSurfaceCheck / makeRealSession. |
| Missing abstractions | The collapsed gate's setup + query concepts now have one home. |
No production API gap to fix upstream — the extracted helpers consume existing fixtures and the existing check(intent) surface unchanged.
Module-Level Changes
| File | Change |
|---|---|
test/helpers/external-directory-fixtures.ts |
New. Constants, setup builders (makeExtDirCheck, prompter builders, makeExtDirDedupCheck, makeDeduplicatingHandler, makeDedupWiring), event builders (makeExtDirToolEvent, makeExtDirBashEvent), query helpers (findExtDirDecision, blockReviewEntries). |
test/handlers/external-directory-integration.test.ts |
Remove the local makeExtDirCheck, constants, prompter literals, and decision/review scans; import them from the new fixture. Keep every describe/it and the helper-presence regression guard intact. |
test/handlers/external-directory-session-dedup.test.ts |
Remove the local makeDeduplicatingHandler and the inline shutdown-test wiring duplicate; import makeDeduplicatingHandler / makeDedupWiring / makeExtDirDedupCheck / event builders from the new fixture. Keep every describe/it. |
packages/pi-permission-system/docs/architecture/architecture.md |
At ship time (not in this plan's commits): mark roadmap Step 8 complete (✅ on the #### 8. heading and the S8 Mermaid node), per the package skill's roadmap-completion convention. The Phase 6 metrics table's duplication target is already stated; update the realized figure if the table tracks actuals. |
No src/ symbols are removed or renamed, so no src//README/skill/architecture prose grep for removed symbols is needed.
The architecture doc references these test files only in the Step 8 narrative (already present); no stale symbol references are introduced.
Test Impact Analysis
This is itself a test refactor, so the standard extraction lenses map as:
- New tests enabled — none required; the extraction does not expose a new unit.
The shared
makeExtDirDedupCheckmakes the dedup mock a single reviewed implementation, removing the risk that the two copies drift (the inline shutdown copy could silently diverge frommakeDeduplicatingHandler). - Tests made redundant — none removed.
Every
describe/itin both files is preserved; only arrangement and query scaffolding is centralized. The act of each test stays explicit at its call site (per thetestingskill). - Tests that must stay as-is — all of them. These are integration tests over the real handler pipeline through the collapsed gate; they exercise the layer the fixture sets up, so the assertions are unchanged.
Invariants at risk
This change touches the surface Step 5 (#477, collapse the two external-directory gates) and Step 6 (#478, narrow resolver to resolve(intent)) already refactored.
The invariants those steps established and the tests that pin them:
- The collapsed gate produces one external-directory decision per access — pinned by the
external_directory decision event fields/ policy-statedescribes inexternal-directory-integration.test.ts. The migration preserves these tests verbatim;findExtDirDecisionreads the same decision stream. - The unified
check(intent)dispatch is kind- and surface-aware (#478 / #418) — pinned by the dedupcheck(intent)mock honoringintent.kind === "path-values".makeExtDirDedupCheckpreserves the exact dispatch; a regression here would false-green toallow, which the dedup re-prompt assertions catch. - Session approvals clear on shutdown — pinned by the
session shutdown clears external-directory approvalstest, kept intact viamakeDedupWiring(which exposes the realsession).
No invariant lives only in prose; each is pinned by a surviving test, so a later step cannot regress an earlier outcome with a green suite.
TDD Order
This is a behavior-preserving refactor (the suite is already green), so each step is a lift-and-shift commit that keeps the full suite green — there is no red phase. Run the full package suite after each step (shared-helper changes), not just the migrated file.
-
Migrate File 1 onto the new fixture. Create
test/helpers/external-directory-fixtures.tswith exactly the pieces File 1 consumes (constants,makeExtDirCheck, prompter builders,findExtDirDecision,blockReviewEntries) and rewriteexternal-directory-integration.test.tsto import them — in one commit, so no export is unused. Re-check File 1's imports for orphans (BiomenoUnusedImportsis warning-level). Verify:pnpm --filter @gotgenes/pi-permission-system exec vitest run test/handlers/external-directory-integration.test.tsgreen, then full suite green. Commit:test(permission-system): extract external-directory integration fixtures (#480). -
Migrate File 2 onto the new fixture, dissolving the wiring duplicate. Add the File-2 pieces to the fixture (
makeExtDirDedupCheck,makeDeduplicatingHandler,makeDedupWiring,makeExtDirToolEvent,makeExtDirBashEvent) and rewriteexternal-directory-session-dedup.test.ts— including the inline shutdown test — to consume them, in one commit. The inline shutdown wiring (lines ~312–363) collapses ontomakeDedupWiring+makeExtDirDedupCheck, removing the 43-line duplicate. Re-check File 2's imports for orphans. Verify:pnpm --filter @gotgenes/pi-permission-system exec vitest run test/handlers/external-directory-session-dedup.test.tsgreen, then full suite green. Commit:test(permission-system): extract external-directory session-dedup fixtures (#480). -
Verify duplication target and tidy. Run
pnpm fallow dupesand confirm the two external-directory clone families (21 groups/214 lines and 3 groups/74 lines) are gone and package duplication is ≤ 6.5%. Runpnpm run checkandpnpm run lint. If a clone family remains above target, fold the remaining shared arrangement into the fixture in this step. Commit (only if a tidy edit is needed):test(permission-system): tidy external-directory fixture imports (#480).
(The roadmap Step 8 completion markers in architecture.md land at ship time, per the package skill, not in these steps.)
Risks and Mitigations
| Risk | Mitigation |
|---|---|
The dedup check(intent) mock drifts from production dispatch during extraction, silently false-greening external-directory to allow. |
makeExtDirDedupCheck is moved verbatim and preserves intent.kind === "path-values" dispatch; the re-prompt assertions (different file / different dir / after shutdown) fail loudly if it regresses. |
| A fixture export ends up unused (fallow dead-export flag). | Each migration step lands fixture pieces and their sole consumer in the same commit; no fixture-only commit. |
| Orphaned imports left in the migrated files (Biome warning-level, lint stays green). | Step 1 and Step 2 each re-check imports; Step 3 runs pnpm run lint and a final import scan. |
| Duplication does not reach ≤ 6.5% after both migrations. | Step 3 verifies with pnpm fallow dupes and folds any residual shared arrangement into the fixture before finishing. |
Event-shape normalization (name vs toolName) accidentally changes behavior. |
Lift-and-shift preserves each file's existing event shape; the new event builders for File 2 match its current toolName literals exactly. |
Open Questions
None.
The File 3 scope question (whether to migrate test/bash-external-directory.test.ts) was resolved this session: leave it out of scope, no follow-up.