12 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 109 | refactor: deduplicate mergeFlatPermissions and path normalization helpers |
Deduplicate shared helpers
Problem Statement
Two sets of helper functions are copy-pasted across the codebase:
mergeFlatPermissions()— identical implementations insrc/config-loader.tsandsrc/permission-manager.ts.normalizePathForComparison()andisPathWithinDirectory()— identical implementations insrc/external-directory.tsandsrc/skill-prompt-sanitizer.ts.
If merge or path logic changes, one copy might not get updated, creating a silent divergence bug.
Goals
- Extract
mergeFlatPermissions()intosrc/permission-merge.ts; both consumers import from there. - Extract
normalizePathForComparison()andisPathWithinDirectory()intosrc/path-utils.ts; all consumers import from there. - No behavioral changes — pure extraction refactoring.
- Existing tests pass without logic changes.
Non-Goals
- Splitting
external-directory.tsinto focused modules — that is #110's scope. - Adding new tests for the helpers beyond what already exists (the existing
external-directory.test.tscoverage fornormalizePathForComparisonandisPathWithinDirectorywill be moved to a newpath-utils.test.ts). - Changing merge semantics or path normalization logic.
Background
mergeFlatPermissions()
Both copies implement deep-shallow merge: when both base and override values for a key are objects, shallow-merge the objects; otherwise the override replaces the base. Used by:
config-loader.ts— merging project config over global config (mergeConfigs()).permission-manager.ts— merging per-agent frontmatter over baseline config (resolvePermissions()).
Path helpers
normalizePathForComparison() resolves a path string (handling ~, @ prefix, quotes, relative paths) to an absolute normalized form.
isPathWithinDirectory() checks whether a normalized path is equal to or under a directory.
Used by:
external-directory.ts—isSafeSystemPath(),isPathOutsideWorkingDirectory(), tree-sitter bash path extraction.skill-prompt-sanitizer.ts— filtering skill prompt entries to those within a skill's base directory.handlers/gates/external-directory.ts— normalizing external paths for gate checks.handlers/gates/skill-read.ts— normalizing skill read paths.
The path helpers touch the external_directory and skills permission surfaces, but this refactoring changes no permission logic.
Coordination with #110
Issue #110 plans to split external-directory.ts into focused modules and notes that if #109 lands first, path-utils.ts becomes the canonical home.
The #110 plan already defers path-helper deduplication to #109.
Landing #109 first is the recommended order.
Design Overview
Pure mechanical extraction — no new types, no logic changes, no new APIs.
src/permission-merge.ts
import type { FlatPermissionConfig, PermissionState } from "./types";
export function mergeFlatPermissions(
base: FlatPermissionConfig,
override: FlatPermissionConfig,
): FlatPermissionConfig { /* existing logic */ }
src/path-utils.ts
export function normalizePathForComparison(
pathValue: string,
cwd: string,
): string { /* existing logic */ }
export function isPathWithinDirectory(
pathValue: string,
directory: string,
): boolean { /* existing logic */ }
Both new modules export only pure functions with no module-scope state.
Module-Level Changes
| File | Change |
|---|---|
src/permission-merge.ts |
New. Contains mergeFlatPermissions(). |
src/path-utils.ts |
New. Contains normalizePathForComparison() and isPathWithinDirectory(). |
src/config-loader.ts |
Remove local mergeFlatPermissions(), add import { mergeFlatPermissions } from "./permission-merge". |
src/permission-manager.ts |
Remove local mergeFlatPermissions(), add import { mergeFlatPermissions } from "./permission-merge". |
src/external-directory.ts |
Remove local normalizePathForComparison() and isPathWithinDirectory(), add import { normalizePathForComparison, isPathWithinDirectory } from "./path-utils". Keep re-exporting both so downstream barrel imports continue to work. |
src/skill-prompt-sanitizer.ts |
Remove local normalizePathForComparison() and isPathWithinDirectory(), add import { normalizePathForComparison, isPathWithinDirectory } from "./path-utils". |
src/handlers/gates/external-directory.ts |
Update import of normalizePathForComparison — can remain importing from ../../external-directory (barrel) or switch to ../../path-utils; prefer the direct module. |
src/handlers/gates/skill-read.ts |
Update import of normalizePathForComparison — same as above. |
tests/path-utils.test.ts |
New. Move the normalizePathForComparison and isPathWithinDirectory describe blocks from tests/external-directory.test.ts here, importing from ../src/path-utils. |
tests/external-directory.test.ts |
Remove the moved describe blocks. Remaining tests continue importing from ../src/external-directory (barrel re-export ensures no breakage). |
tests/permission-merge.test.ts |
New. Unit tests for mergeFlatPermissions() covering: string-replaces-string, both-objects shallow-merge, object-replaces-string, string-replaces-object, empty override. |
docs/architecture/target-architecture.md |
Add permission-merge.ts and path-utils.ts to the module list if present. |
Test Impact Analysis
- New unit tests enabled:
mergeFlatPermissions()currently has no direct unit tests — it is only exercised indirectly through config-loader and permission-manager integration tests. The extraction enables focused unit tests intests/permission-merge.test.ts. - Tests that move: The
normalizePathForComparisonandisPathWithinDirectorydescribe blocks intests/external-directory.test.tsmove totests/path-utils.test.tswith only the import path changing. - Tests that stay as-is: All other tests in
tests/external-directory.test.ts,tests/skill-prompt-sanitizer.test.ts,tests/config-loader.test.ts, andtests/permission-manager.test.tsstay unchanged — they exercise higher-level behavior that happens to use these helpers internally.
TDD Order
-
test: add unit tests for mergeFlatPermissions Create
tests/permission-merge.test.tsimporting from../src/permission-merge. Tests will initially fail (module does not exist). Cover: string-replaces-string, both-objects-merge, object-replaces-string, string-replaces-object, empty inputs. Commit:test: add unit tests for mergeFlatPermissions -
feat: extract mergeFlatPermissions to permission-merge.ts Create
src/permission-merge.tswith the function. Updatesrc/config-loader.tsandsrc/permission-manager.tsto import from it, remove local copies. All tests pass (new + existing). Commit:refactor: extract mergeFlatPermissions to permission-merge.ts -
test: move path helper tests to path-utils.test.ts Create
tests/path-utils.test.tswith thenormalizePathForComparisonandisPathWithinDirectoryblocks, importing from../src/path-utils. Remove those blocks fromtests/external-directory.test.ts. New tests initially fail (module does not exist); existing tests still pass. Commit:test: move path helper tests to path-utils.test.ts -
feat: extract path helpers to path-utils.ts Create
src/path-utils.tswith both functions. Updatesrc/external-directory.tsto import + re-export from./path-utils, remove local copies. Updatesrc/skill-prompt-sanitizer.tsto import from./path-utils, remove local copies. Updatesrc/handlers/gates/external-directory.tsandsrc/handlers/gates/skill-read.tsto import from../../path-utils. All tests pass. Commit:refactor: extract path helpers to path-utils.ts -
docs: update architecture docs Add the two new modules to
docs/architecture/target-architecture.md. Commit:docs: update target architecture for extracted helpers
Risks and Mitigations
| Risk | Mitigation |
|---|---|
Barrel re-export in external-directory.ts is missed, breaking downstream imports. |
Step 4 explicitly keeps the re-export; the existing external-directory.test.ts tests serve as a regression gate. |
| Could this silently weaken a permission? | No — pure extraction with zero logic changes. The same functions, same call sites, same behavior. |
#110 plan references path helpers in external-directory.ts. |
#110's plan already anticipates #109 landing first and notes path-utils.ts as the canonical home. No conflict. |
mergeFlatPermissions copies have silently diverged. |
Verified: the two copies are textually identical except for the PermissionState import style (inline import("./types").PermissionState vs. top-level import). Functionality is identical. |
Open Questions
None — the issue is unambiguous and the extraction is mechanical.