19 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 321 | Continue shared test-fixture extraction for the largest clone families |
Continue shared test-fixture extraction for the largest clone families
Problem Statement
fallow dupes reports 7.6% duplication, entirely in the test tree — the single largest health deduction in pi-permission-system.
Phase 2 (#288) cut it from 9.1% to 7.1% by extracting test/helpers/ fixtures, but tests added since have pushed it back up.
This is Phase 3, Step 16 (Track E) of the improvement roadmap: migrate the four largest remaining clone families onto the existing shared fixtures, extending those helpers where a shape is not yet covered.
The four families are:
test/handlers/external-directory-integration.test.ts— 17 groups, 164 lines.test/handlers/gates/bash-path.test.ts— 6 groups, 61 lines.test/handlers/gates/runner.test.ts— 11 groups, 119 lines.test/handlers/tool-call.test.ts— 6 groups, 108 lines.
All four already import from test/helpers/, so the remaining clones are not "files that never adopted the fixtures."
They split into two kinds:
- Duplicate factory definitions — local
makeSession/makeHandler/makeToolRegistry/makeCheckPermissioninexternal-directory-integration.test.tsand localmakeDenialContextDescriptorinrunner.test.tsthat re-implement shapes the shared helpers already cover (or nearly cover). - Repeated override expressions — the 6-line
resolve: vi.fn().mockReturnValue(makeCheckResult({ state: "ask", matchedPattern: "*" }))block (~8 occurrences inrunner.test.ts), surface-dispatchingcheckPermissionmocks (external-directory-integration.test.ts,tool-call.test.ts), and redundantmakeTcc({ input: { command: "cat .env" } })calls that merely restate the factory default.
Goals
- Migrate the four named clone families onto the shared
test/helpers/fixtures. - Delete the local duplicate factory definitions, routing them through the shared helpers.
- Add convenience shortcuts to the shared fixtures for the recurring override expressions (per the user's confirmed "both" scope decision): a surface-dispatching check factory, a
makeGateRunnerresolve-result shortcut, a path-dispatching resolver, a denial-context descriptor factory, and atoolsshortcut on the handler factory. - Keep every assertion unchanged — this is a pure test refactor; the suite must stay green at the same test count throughout.
- Drive duplication 7.6% → under 6%, shrinking the test-duplication health deduction below -2.0.
Non-Goals
- No
src/changes. No production behavior changes. No new assertions. - Do not migrate
external-directory-session-dedup.test.ts(a fifth family that shares the local-makeSessionclone with the in-scope ext-dir file). It is outside the issue's named four-file scope; see Open Questions for the conditional follow-up. - Do not extract co-located tests for the new helpers — per the #288 decision, shared fixtures are covered transitively by the tests that consume them.
- Do not touch
permission-system.test.ts(its intra-file clones were addressed in #288) or the bash-command-regex dispatch logic that is genuine per-test intent. - Do not change the
/permission-systemcommand, config schema, or any policy surface.
Background
Shared fixtures live in test/helpers/:
handler-fixtures.ts—makeCtx,makeEvents,makeSession,makeToolRegistry,makeToolCallEvent,makeCheckResult(neutral default),makeHandler(returns{ handler, events, session, toolRegistry }),getDecisionEvents, and theMockGateHandlerSessiontype.gate-fixtures.ts—makeDescriptor,makeGateRunner({ runner, deps }),makeReporter,makeResolver,makeTcc(bash defaults:input: { command: "cat .env" }),makeGateCheckResult(path-surface defaults),makeGateInputs,makeSkillInputInputs,makeNotifier.manager-harness.ts—createManager.
Relevant facts confirmed by reading the files:
- The local
makeSession/makeHandlerinexternal-directory-integration.test.tsare byte-for-byte the sharedmakeSession/makeHandlerexcept for two defaults: the localmakeSessiondefaultsgetInfrastructureReadDirsto[](shared:["/test/agent", "/test/agent/git"]) andcheckPermissiontomakeCheckPermission("deny")(shared: neutral allow); the localmakeToolRegistryreturns the path-bearing tool set plusbash(shared:read+bashonly). makeTcc()already defaultsinputto{ command: "cat .env" }, so the manymakeTcc({ input: { command: "cat .env" } })calls inbash-path.test.tsare redundant and collapse tomakeTcc().runner.test.ts's localmakeDenialContextDescriptorismakeDescriptorwithsurface: "write", a caller-supplieddenialContext, and write-shapedpromptDetails/logContext/decision.- The production refactors this step is "best sequenced after" (#314, #317, #318, #319, #320) have all landed — the shared fixtures already import
PermissionResolver,GateRunner,ToolCallGatePipeline,SkillInputGatePipeline, andGateDecisionReporter. No soft dependency is outstanding; the step can proceed.
Constraints from AGENTS.md and the package skill that apply:
- Within the package, import siblings via
#src//#test/aliases, never relative paths. - Do not annotate mock-bag factories with the production interface — it erases
Mock<...>methods (mockReturnValue,mock.calls).makeNotifier,makeResolver, andmakeGateRunneralready follow this; new factories must too. - Adding an unused export to a helper file will be flagged by
fallow dead-code. Therefore each new helper is introduced in the same step that first consumes it — never as a standalone additive commit. - This is a migrate → full-suite-green → commit cycle (no red→green), so the next stage is
/build-plan, not/tdd-plan.
Design Overview
The migration is a behavior-preserving consolidation. No data shapes change; the only new surface is convenience factories that build existing mock shapes from fewer arguments.
New shared helpers
gate-fixtures.ts:
// Collapses the 6-line `resolve: vi.fn().mockReturnValue(...)` override.
// `makeGateRunner({ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }) })`
export function makeGateRunner(
overrides: {
resolveResult?: PermissionCheckResult; // wraps resolve in a vi.fn returning this
resolve?: PermissionResolver["resolve"]; // still accepted for mockImplementation cases
recordSessionApproval?: SessionApprovalRecorder["recordSessionApproval"];
canConfirm?: GatePrompter["canConfirm"];
promptPermission?: GatePrompter["promptPermission"];
reporter?: Partial<DecisionReporter>;
} = {},
): { runner: GateRunner; deps: { /* unchanged */ } };
// `makeDescriptor` variant with write-surface defaults + caller-supplied denialContext.
export function makeDenialDescriptor(
denialContext: DenialContext,
overrides?: Partial<GateDescriptor>,
): GateDescriptor;
// Resolver whose `resolve` dispatches on `input.path`, falling back to a default.
// `makePathDispatchResolver({ ".env": makeGateCheckResult({ state: "deny", matchedPattern: "*.env" }) }, makeGateCheckResult())`
export function makePathDispatchResolver(
byPath: Record<string, PermissionCheckResult>,
defaultResult: PermissionCheckResult,
): PermissionResolver;
handler-fixtures.ts:
// Surface-dispatching checkPermission mock. Replaces ext-dir's local
// `makeCheckPermission` and tool-call's inline path-gate dispatch.
// Per-surface source/origin default to production-realistic values; override per surface.
export function makeSurfaceCheck(
bySurface: Record<string, Partial<PermissionCheckResult> & { state: PermissionState }>,
defaultResult?: Partial<PermissionCheckResult> & { state: PermissionState },
): Mock<MockGateHandlerSession["checkPermission"]>;
// Bash-surface check whose state depends on a command regex. Replaces the
// three near-identical mockImplementation blocks in tool-call.test.ts.
export function makeBashCommandCheck(opts: {
deny: RegExp;
denyMatched: string;
allowMatched?: string;
}): Mock<MockGateHandlerSession["checkPermission"]>;
// `tools` shortcut: build the toolRegistry getAll mock from a name list.
export function makeHandler(overrides?: {
session?: Partial<MockGateHandlerSession>;
toolRegistry?: Partial<ToolRegistry>;
tools?: string[]; // sugar for toolRegistry.getAll → names.map(name => ({ name }))
}): { handler; events; session; toolRegistry };
Consumer call-site sketch (verifies the interaction pattern)
external-directory-integration.test.ts after migration — the local makeSession/makeHandler/makeToolRegistry/makeCheckPermission are gone:
import { makeHandler, makeSurfaceCheck, makeCtx, makeToolCallEvent } from "#test/helpers/handler-fixtures";
const PATH_BEARING = ["read", "write", "edit", "find", "grep", "ls"];
const denyExtDir = () =>
makeSurfaceCheck(
{ external_directory: { state: "deny" }, path: { state: "allow", source: "special" } },
{ state: "allow" },
);
const { handler } = makeHandler({ tools: [...PATH_BEARING, "bash"], session: { checkPermission: denyExtDir() } });
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
expect(await handler.handleToolCall(event, makeCtx())).toMatchObject({ block: true });
This keeps the interaction Tell-Don't-Ask: the test hands the handler a fully-built collaborator and asks for a decision; it does not reach through the session to assemble pipeline internals (the shared makeHandler owns that wiring).
runner.test.ts after migration:
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
promptPermission: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
});
const result = await runner.run(makeDenialDescriptor(ctx), null, "tc-1");
What stays inline (genuine per-test intent)
Per the code-design "structural reasons before extracting duplication" heuristic, these are kept as-is — extracting them would create a discriminator-laden leaky abstraction:
- The per-agent
agentAwareCheckinexternal-directory-integration.test.ts(dispatches onagentName, setsoriginper agent) — a one-off. - The
resolver.resolve.mockImplementationblocks inbash-path.test.tsthat dispatch on multiple path values with bespoke per-token logic — covered bymakePathDispatchResolveronly where the dispatch is a simple path→result map; the multi-condition ones stay inline. - Events that use the
toolNamealias field (skill-read gate tests) rather thanname— they deliberately exercise the alias-resolution path, so they keep their inline event literals instead ofmakeToolCallEvent(which emitsname). - The bash command-regex dispatch's regex and matched-pattern values remain per-test; only the surrounding boilerplate moves into
makeBashCommandCheck.
Edge cases
makeSurfaceCheckmust reproduce the source/origin defaults the ext-dir assertions read (external_directory→source: "tool",origin: "builtin";path→source: "special"). Decision-event assertions checkresult,resolution,origin,agentName— the factory's defaults must satisfy them without per-call overrides.- The shared
makeSession's non-emptygetInfrastructureReadDirsdefault (["/test/agent", …]) does not intersect any ext-dir test path (/test/project/*,/outside/project/*), so adopting the shared default cannot trigger an infra-read bypass. Verified by the full-suite green gate; if any test flips, passgetInfrastructureReadDirs: () => []at that call site.
Module-Level Changes
test/helpers/gate-fixtures.ts— addresolveResultoption tomakeGateRunner; addmakeDenialDescriptor; addmakePathDispatchResolver. ImportDenialContextfrom#src/denial-messagesandGateDescriptorfrom#src/handlers/gates/descriptor(the latter already imported).test/helpers/handler-fixtures.ts— addmakeSurfaceCheck,makeBashCommandCheck, and thetoolsoption onmakeHandler.test/handlers/gates/runner.test.ts— replace ~8resolve: vi.fn().mockReturnValue(...)overrides withresolveResult:; replace localmakeDenialContextDescriptorwithmakeDenialDescriptor; remove the now-unused local definition and any imports it alone required.test/handlers/gates/bash-path.test.ts— collapse redundantmakeTcc({ input: { command: "cat .env" } })tomakeTcc(); replace the simple-mapresolver.resolve.mockImplementationblocks withmakePathDispatchResolver; keep the localdescribeGateparse-once helper (single-file use).test/handlers/tool-call.test.ts— replace inlinename-form event literals withmakeToolCallEvent; replace the path-gate dispatch mocks withmakeSurfaceCheck; replace the three bash command-regex dispatch blocks withmakeBashCommandCheck; use thetoolsshortcut fortoolRegistry.test/handlers/external-directory-integration.test.ts— delete localmakeSession,makeHandler,makeToolRegistry,makeCheckPermission; import the sharedmakeHandler/makeSurfaceCheck; use thetoolsshortcut for the path-bearing set; keep the regression-guard import offormatExternalDirectoryAskPromptandEXTENSION_TAG; keep the inlineagentAwareCheck.docs/architecture/architecture.md— mark Phase 3 Step 16 (#321) complete with the realized duplication metric; update any health-deduction figure in the duplication track..pi/skills/package-pi-permission-system/SKILL.md— extend the Testing section'sgate-fixtures.ts/handler-fixtures.tsinventories withmakeSurfaceCheck,makeBashCommandCheck,makeDenialDescriptor,makePathDispatchResolver, themakeGateRunnerresolveResultoption, and themakeHandlertoolsshortcut.
Test Impact Analysis
- New unit tests enabled: none.
The new helpers are fixtures, not production units; per the #288 decision they are covered transitively by every test that consumes them.
No standalone helper tests are added (and adding unused exports would trip
fallow dead-code). - Existing tests made redundant: none. This is a pure setup refactor — every assertion is preserved verbatim, the test count is unchanged, and no test is deleted.
- Tests that must stay as-is: all of them.
The genuine-per-test-intent cases (agent-aware check,
toolName-alias events, multi-condition path dispatch, bash regex/pattern values) keep their inline setup so the abstraction does not leak a discriminator.
Build Order
Each step is a migrate → pnpm run check + full vitest run green → commit cycle.
Run the full suite (not just the touched file) after every step, because each step mutates a shared test/helpers/ module.
Add each new helper in the same commit as its first consumer to avoid an unused-export fallow dead-code flag.
runner.test.ts: addresolveResulttomakeGateRunnerandmakeDenialDescriptortogate-fixtures.ts; migraterunner.test.ts; delete localmakeDenialContextDescriptorand reconcile its now-unused imports. Commit:test: migrate runner gate tests onto shared fixtures (#321).bash-path.test.ts: addmakePathDispatchResolvertogate-fixtures.ts; collapse redundantmakeTcc(...)tomakeTcc(); migrate the simple path-dispatch resolvers. Commit:test: dedupe bash-path gate test setup (#321).tool-call.test.ts: addmakeSurfaceCheck,makeBashCommandCheck, and thetoolsoption onmakeHandlertohandler-fixtures.ts; migratetool-call.test.ts(events viamakeToolCallEvent, path/bash dispatch via the new factories). Commit:test: migrate tool-call handler tests onto shared fixtures (#321).external-directory-integration.test.ts: delete the localmakeSession/makeHandler/makeToolRegistry/makeCheckPermission; reuse the sharedmakeHandler+toolsshortcut +makeSurfaceCheck(added in step 3); keep the regression guard and inlineagentAwareCheck. Commit:test: migrate external-directory integration tests onto shared fixtures (#321).- Docs refresh: update
architecture.mdStep 16 status + duplication metric (runfallow dupesto capture the realized figure) and the packageSKILL.mdTesting inventory. Commit:docs: record test-fixture extraction phase 3 and new helpers (#321).
Reconcile imports after every deletion: grep each removed symbol (e.g. makeCheckPermission, makeDenialContextDescriptor) across the file before committing — a stale value import passes tsc and the lint exit code but is a biome warning the reviewer will flag (the recurring slip from the #288 retro).
Risks and Mitigations
- Divergent factory defaults (the primary correctness risk).
The local ext-dir
makeSessiondefaultedgetInfrastructureReadDirsto[]andcheckPermissionto deny; the shared one differs. Mitigation: ext-dir tests pass explicitcheckPermissioneverywhere (default moot), and the shared infra-dirs default does not intersect any test path (edge-case analysis above). The full-suite green gate after step 4 confirms it; if a test flips, add agetInfrastructureReadDirs: () => []override at that site. makeSurfaceChecksource/origin defaults drifting from what assertions read. Mitigation: seed the factory's per-surface defaults from the values the deletedmakeCheckPermissionproduced (external_directory/path/default branches), then run the full suite.- Over-extraction creating a leaky abstraction. Mitigation: the "What stays inline" list keeps genuine per-test logic out of the shared helpers; only mechanical boilerplate moves.
- Stale imports after deleting local factories (the #288 recurring friction).
Mitigation: grep each removed symbol before committing each step; run
pnpm run lintand read biome warnings, not just the exit code. - Target miss (lands at ~6% rather than under).
Mitigation: if
fallow dupesafter step 5 still shows ≥6%, the session-dedup follow-up (Open Questions) is the next lever — but it is out of the issue's named scope and should be a separate issue, not scope creep here.
Open Questions
- Should
external-directory-session-dedup.test.tsbe migrated too? It shares the local-makeSession/makeToolRegistryclone with the in-scope ext-dir file (the cross-file familyext-dir + session-dedup + handler-fixtures). Defer: it is a fifth family outside the issue's four-file scope. If the <6% target is not met after step 5, file a follow-up issue rather than expanding this one. - Final home of
bash-path.test.ts'sdescribeGateparse-once helper. Keep it local for now (single-file use); promote togate-fixtures.tsonly if a second consumer appears.