16 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 286 | Decompose resolvePermissions in permission-manager.ts |
Decompose resolvePermissions into a linear pipeline
Problem Statement
PermissionManager.resolvePermissions in src/permission-manager.ts does three things in one body.
It loads four config scopes, runs a scope-merge loop that simultaneously builds the merged permission object and a parallel origin map tracking which scope contributed each (surface, pattern) entry, then synthesizes defaults and composes the final ruleset.
The origin-tracking branch — shallow-merge attribution vs. full-replacement attribution — is the densest part of the function and is interleaved with the merge itself.
fallow health --targets ranks this function second in the package: cognitive complexity 33 in a 302-LOC file, CRAP risk 97.
This is Phase 2 step 2 of the improvement roadmap in docs/architecture/architecture.md.
Goals
- Extract
mergeScopesWithOrigins(scopes)returning{ mergedPermission, origins }, isolating the origin-map bookkeeping from the rest of the resolve pipeline. - Leave the remaining
resolvePermissionsbody reading as a linear pipeline: load scopes → merge with origins → extract universal fallback → build config rules → compose. - Behavior-preserving:
permission-manager-unified.test.tsstays green without modification. - Drive
resolvePermissionscognitive complexity from 33 toward the< 15target and lower the CRAP-97 hotspot.
Non-Goals
- No change to merge precedence, origin semantics, universal-fallback extraction, baseline synthesis, or composed-ruleset ordering — the decision model is frozen.
- No change to
mergeFlatPermissionsinpermission-merge.ts(its shallow-merge vs. replacement semantics are mirrored, not modified). - No change to
synthesize.ts,normalize.ts,rule.ts, ortypes.tspublic surfaces. - No change to the other Phase 2 targets:
runGateCheck(#287),bash-path-extractor.ts(#289),stripJsonComments(#290), test-fixture dedup (#288). - No change to the
v3-architecture.mddata-flow diagram —resolvePermissions(agentName)remains a node; only its internals move.
Background
Relevant existing modules:
src/permission-manager.ts—PermissionManager; the privateresolvePermissions(agentName?)method is the target. It is the sole producer of the cachedResolvedPermissionsconsumed bygetComposedConfigRules,getToolPermission, andcheckPermission.src/permission-merge.ts—mergeFlatPermissions(base, override): deep-shallow merge of twoFlatPermissionConfigobjects (both objects → shallow-merge pattern maps; otherwise override replaces base). The extracted function calls this internally and mirrors its branch shape for attribution.src/rule.ts—RuleOriginunion ("global" | "project" | "agent" | "project-agent" | "builtin" | "baseline" | "session"). The four config-scope labels are a subset.src/types.ts—FlatPermissionConfig(Record<string, PermissionState | Record<string, PermissionState>>) andScopeConfig({ permission?: FlatPermissionConfig }).
The origin-tracking loop today (the part being extracted):
type OriginMap = Map<string, Map<string, RuleOrigin>>;
const origins: OriginMap = new Map();
let mergedPermission: FlatPermissionConfig = {};
for (const [scopeName, scope] of [
["global", globalConfig],
["project", projectConfig],
["agent", agentConfig],
["project-agent", projectAgentConfig],
] as const) {
if (!scope.permission) continue;
for (const [surface, value] of Object.entries(scope.permission)) {
const baseVal = mergedPermission[surface];
const bothObjects = /* both are non-null objects */;
if (bothObjects) {
// shallow-merge: incoming patterns attributed to this scope;
// existing patterns keep their earlier origin
} else {
// full replacement: this scope takes over the whole surface entry
}
}
mergedPermission = mergeFlatPermissions(mergedPermission, scope.permission);
}
Constraints from AGENTS.md / the package skill that apply:
- Enforce permissions deterministically — the same policy + input must always produce the same decision. The refactor must not perturb merge order or attribution.
- Keep modules focused (one concern per file).
- Within the package, import sibling modules via
#src//#test/aliases, not relative paths. - When a rename or extraction adds exports, verify at least one consumer imports each symbol — fallow flags speculative re-exports as dead code.
- ES2024 target —
Object.entries,Object.fromEntries,Mapare available.
Design Overview
New module: src/scope-merge.ts
The user chose a dedicated module (over folding into permission-merge.ts or keeping an exported helper in permission-manager.ts).
This matches the package's dominant one-concern-per-file convention (normalize.ts, synthesize.ts, permission-merge.ts each have a sibling test) and keeps permission-merge.ts purely about config-shape merging.
The function is pure: it receives the already-loaded scopes (loading stays in resolvePermissions, preserving the "load scopes" pipeline step) and returns the merged config plus the origin map.
import { mergeFlatPermissions } from "#src/permission-merge";
import type { RuleOrigin } from "#src/rule";
import type { FlatPermissionConfig, ScopeConfig } from "#src/types";
/** Surface → (pattern → originating scope). */
type OriginMap = Map<string, Map<string, RuleOrigin>>;
export interface MergedScopes {
mergedPermission: FlatPermissionConfig;
origins: OriginMap;
}
/**
* Merge permission objects across scopes (lowest → highest precedence) while
* tracking which scope contributed each (surface, pattern) entry.
*
* Mirrors mergeFlatPermissions() semantics:
* - both values are objects → shallow-merge; each incoming pattern is
* attributed to this scope, existing patterns keep their earlier origin.
* - otherwise → full replacement; the whole surface entry is re-attributed
* to this scope.
*/
export function mergeScopesWithOrigins(
scopes: readonly (readonly [RuleOrigin, ScopeConfig])[],
): MergedScopes {
const origins: OriginMap = new Map();
let mergedPermission: FlatPermissionConfig = {};
for (const [scopeName, scope] of scopes) {
if (!scope.permission) continue;
for (const [surface, value] of Object.entries(scope.permission)) {
// ... attribution branch moved verbatim, including the
// eslint-disable comments for the defensive null/type checks
}
mergedPermission = mergeFlatPermissions(mergedPermission, scope.permission);
}
return { mergedPermission, origins };
}
The attribution branch (shallow-merge vs. full-replacement, plus the string-vs-object handling and the eslint-disable @typescript-eslint/no-unnecessary-condition comments) moves into the inner loop unchanged.
The OriginMap type alias moves out of resolvePermissions and into this module; it stays unexported because the consumer reads origins via the inferred MergedScopes return type and never names it.
Consumer call site (resolvePermissions)
The four loader calls stay; the loop collapses to one call:
const { mergedPermission, origins } = mergeScopesWithOrigins([
["global", this.loader.loadGlobalConfig()],
["project", this.loader.loadProjectConfig()],
["agent", this.loader.loadAgentConfig(agentName)],
["project-agent", this.loader.loadProjectAgentConfig(agentName)],
]);
This follows Tell-Don't-Ask (the manager hands the loaded scopes to the merge function and takes back a value object) and carries no output-argument mutation — origins is constructed inside the function and returned, not written into a received bag.
The downstream pipeline (universalFallback, universalFallbackOrigin, permissionWithoutUniversal, configRules, composeRuleset) is untouched and continues to read mergedPermission and origins.
ISP check: mergeScopesWithOrigins reads only scope.permission from each ScopeConfig, which is the type's only field — no unused fields are carried.
Edge cases (all unchanged)
- A scope with no
permissionkey is skipped (continue), contributing nothing to either map. - A string surface value attributes
"*"to the scope; an object value attributes each pattern key. - Full replacement (string overriding an object, or an object replacing a string) re-attributes the entire surface entry to the replacing scope, discarding lower-scope attribution.
- Shallow-merge keeps lower-scope origins for patterns the higher scope does not redefine.
- An empty
scopesarray returns{ mergedPermission: {}, origins: new Map() }. - The universal
"*"surface is attributed like any other and read downstream viaorigins.get("*")?.get("*").
Module-Level Changes
src/scope-merge.ts (new):
- Add exported
mergeScopesWithOriginsand the exportedMergedScopesinterface. - Add the unexported
OriginMaptype alias. - Import
mergeFlatPermissionsfrom#src/permission-merge,RuleOriginfrom#src/rule,FlatPermissionConfig+ScopeConfigfrom#src/types.
src/permission-manager.ts:
- Remove the inline
type OriginMapdeclaration, theorigins/mergedPermissioninitialization, and the scope-mergeforloop fromresolvePermissions. - Replace them with the single
mergeScopesWithOrigins([...])call shown above. - Remove the now-unused
import { mergeFlatPermissions } from "./permission-merge";— after extraction this module no longer calls it directly (it was the sole call site in this file). - Add
import { mergeScopesWithOrigins } from "#src/scope-merge";(existing imports already use relative./form; match the file's existing convention or#src/— eslint will normalize). - Keep the
RuleOriginandFlatPermissionConfigimports — both are still used downstream (universalFallbackOrigin: RuleOrigin,permissionWithoutUniversal: FlatPermissionConfig).
test/scope-merge.test.ts (new): direct unit tests for mergeScopesWithOrigins (see Test Impact).
docs/architecture/architecture.md:
- Add a
scope-merge.tsentry to the module-tree listing (nearpermission-manager.ts, ~line 483) — e.g.Cross-scope permission merge + origin-map bookkeeping. - Update the source-tree line for
permission-manager.tsif its one-line description should shed the "Policy merge" framing now that merge lives inscope-merge.ts(optional wording tweak). - Mark Phase 2 step 2 (#286) as ✅ completed in the Steps section with the outcome.
- Refresh the "Refactoring targets" and "Worst CRAP risk" health-metric rows and the finding-#2 row after re-running
fallow health --targetsto capture the newresolvePermissionscomplexity / CRAP numbers.
.pi/skills/package-pi-permission-system/SKILL.md: no symbol it documents is removed or renamed — no change.
No file in Module-Level Changes is also claimed unchanged in Non-Goals.
Test Impact Analysis
- New unit tests enabled by the extraction.
mergeScopesWithOriginsbecomes a directly testable pure function, isolating the origin-map bookkeeping that previously could only be exercised end-to-end throughresolvePermissions→getComposedConfigRules→ rule-origin assertions. Newtest/scope-merge.test.tscovers: empty scopes; a single scope with a string surface value (origins["surface"]["*"] === scope); a single scope with an object value (each pattern attributed); shallow-merge across two scopes (existing patterns retain the lower-scope origin, new patterns get the higher scope); full replacement (string-over-object and object-over-string both re-attribute the whole surface); and precedence order across all four scopes. - Tests that become redundant.
None are removed.
permission-manager-unified.test.tsstill verifies the end-to-end origin annotations through the composed ruleset; it is the behavior-preservation safety net and stays unmodified. - Tests that must stay as-is.
permission-manager-unified.test.ts(origin/source assertions across all surfaces) andpermission-merge.test.ts(unchangedmergeFlatPermissionssemantics) genuinely exercise the layers around the extraction and must remain green without modification.
TDD Order
test:Addtest/scope-merge.test.tscoveringmergeScopesWithOrigins: empty scopes, string-value attribution, object-value attribution, shallow-merge origin retention, full-replacement re-attribution, and four-scope precedence. Red:#src/scope-mergedoes not exist yet (compile error in the new test, mirroring the #285 step-1 pattern). Suggested commit:test: cover mergeScopesWithOrigins extraction.refactor:Createsrc/scope-merge.tswithmergeScopesWithOrigins+MergedScopes, moving the attribution loop verbatim; rewireresolvePermissionsto the single call, delete the inline loop andOriginMapalias, and drop the now-unusedmergeFlatPermissionsimport. The new module and its sole production call site land in one commit (the type checker requires the export to exist forpermission-manager.tsto compile against it). Green: newscope-merge.test.tspasses andpermission-manager-unified.test.tspasses unmodified. Runpnpm --filter @gotgenes/pi-permission-system run testandpnpm --filter @gotgenes/pi-permission-system run checkbefore committing. Suggested commit:refactor: extract mergeScopesWithOrigins from resolvePermissions.docs:Updatearchitecture.md— add thescope-merge.tsmodule-tree entry, mark Phase 2 step 2 complete, and refresh the health-metric / finding rows after re-runningfallow health --targetsto record the newresolvePermissionsnumbers. Suggested commit:docs: mark Phase 2 step 2 complete in permission-system roadmap.
All three steps are small and individually reviewable. No step rewrites a large test file; the extraction is pure and the existing integration suite proves behavior preservation.
Risks and Mitigations
- Risk: attribution drift — a subtle change in shallow-merge vs. full-replacement origin assignment.
Mitigation: the inner branch (including the
eslint-disablecomments and string/object handling) moves verbatim;scope-merge.test.tsasserts each attribution case directly andpermission-manager-unified.test.tsverifies the end-to-end origins unchanged. - Risk: merge order or precedence drift.
Mitigation: the four scopes are passed in the same lowest→highest order; the precedence test in
scope-merge.test.tsand the integration suite both depend on ordering. - Risk: the step-1 commit leaves
pnpm checkred until step 2 (the test imports a not-yet-created module). Mitigation: this mirrors the accepted #285 pattern; steps 1 and 2 ship together in the same session, and step 2 restores green. - Risk: a leftover
mergeFlatPermissionsreference after removing its import. Mitigation: grep confirmsresolvePermissionsis the file's onlymergeFlatPermissionscall site;pnpm checkin step 2 catches any stray reference. - Risk: fallow flags
MergedScopesas a dead export. Mitigation:scope-merge.test.tsimportsMergedScopesto type its expected results, giving the export a consumer;mergeScopesWithOriginsis consumed by bothpermission-manager.tsand the test.
Open Questions
- Whether to further extract the universal-fallback / config-rule-building tail of
resolvePermissionsinto its own helper — deferred. The issue scopes this change to the origin-map extraction; revisit only iffallow healthstill flagsresolvePermissionsabove target after step 2.