12 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 532 | pi-permission-system: split value-guards.ts by cohesion |
Split value-guards.ts by cohesion
Release Recommendation
Release: ship independently
Phase 8 Step 8 is tagged Release: independent in the architecture roadmap (Track D — health) and has no dependencies.
It is a refactor: commit — a hidden: true changelog type — so it does not cut a release on its own; it lands on main and auto-batches into the next feat:/fix:/unhidden-docs: release.
Problem Statement
value-guards.ts mixes two unrelated concerns: generic value-parsing guards that many modules use, and domain-specific permission-state guards that belong with the types they narrow.
fallow reports value-guards.ts as the package's only refactoring target — it inherited the high fan-in of the former common.ts grab-bag (#479) without shedding the mixed cohesion.
Splitting it by cohesion co-locates the domain guards with their types and clears the last refactoring target for Phase 8.
Goals
- Keep the generic parsing guards (
toRecord,getNonEmptyString) insrc/value-guards.ts. - Move the two domain guards (
isPermissionState,isDenyWithReason) tosrc/types.ts, next to thePermissionState/DenyWithReasontypes they narrow. - Repoint every consumer of the two domain guards from
./value-guardsto./types. - Non-breaking: no observable behavior, output shape, or config default changes — the guards move verbatim.
Non-Goals
- No behavior change to any guard — the function bodies move byte-for-byte.
- No change to
toRecord/getNonEmptyStringor their ~20 consumers beyond what the domain-guard move requires (their imports stay on./value-guards). - Not reducing the re-narrowing in
normalize.ts/config-loader.tsnow that zod hands them validated input — that simplification is deferred (see #547 Open Questions and ADRdocs/decisions/0004-zod-config-schema-single-source.md). - Not moving
value-guards.tsortypes.tsintosrc/authority/— the Phase 8 directory sketch only reseats the modules it rewrites for the spine, not these.
Background
src/value-guards.ts(38 LOC) currently exports four functions:toRecord,getNonEmptyString,isPermissionState,isDenyWithReason. It carriesimport type { DenyWithReason, PermissionState } from "./types"solely for the two domain guards.- The issue's "Proposed change" lists
normalizeOptionalStringArrayandnormalizeOptionalPositiveIntamong the generic guards to keep, but #547 already removed both when zod took over config validation (commit146844aa). The roadmap step note (#532,architecture.md:942) records this; the surviving generic set is justtoRecord+getNonEmptyString. src/types.tsis currently types-only (interfaces, type aliases, andexport typere-exports of the config-shape types fromconfig-schema.ts). It already importsDenyWithReasonandPermissionStatefromconfig-schema.tsasimport type, so the moved guards can narrow to them without a new import.- Domain-guard consumers (from grep):
src/permission-manager.ts—isPermissionState(line 202). Already imports types from./types. An ESLintno-restricted-importsrule on this file blocks onlyaccess-intent/access-path; importing a guard from./typesis allowed.src/normalize.ts—isDenyWithReason,isPermissionState(lines 22, 28, 36). Already importsFlatPermissionConfigfrom./types(asimport type).src/config-loader.ts—isDenyWithReason,isPermissionState(lines 127, 137, 140). Already imports types from./types.test/value-guards.test.ts— imports all four from#src/value-guards.
- Generic-guard consumers (
toRecord/getNonEmptyString) across ~18src/files and 2test/files keep importing from./value-guards/#src/value-guardsunchanged.
Constraint from the package skill: architecture.md names the value-guards.ts / types.ts function inventory in prose (a module-move check misses it); the doc-update commit must edit those lines.
Design Overview
This is a cohesion split — relocating two whole functions to the module that owns the types they guard — not a decomposition of a procedure.
It introduces no new collaborator and changes no behavior; the design value is that value-guards.ts becomes purely generic parsing guards and the domain guards live beside PermissionState / DenyWithReason.
types.ts transitions from types-only to types-plus-their-guards.
This is the direction the issue and roadmap prescribe and is a natural home: a type guard is the runtime companion of the type it narrows.
No circular import results — types.ts already imports DenyWithReason / PermissionState from config-schema.ts, and the guards depend only on those.
value-guards.ts drops its import type { DenyWithReason, PermissionState } from "./types" line once the guards leave (it no longer references either type).
Consumer call sites are unchanged in body; only the import source moves.
For example, normalize.ts:
// before
import type { FlatPermissionConfig } from "./types";
import { isDenyWithReason, isPermissionState } from "./value-guards";
// after
import { isDenyWithReason, isPermissionState } from "./types";
import type { FlatPermissionConfig } from "./types";
The autoformatter will merge/reorder the two ./types imports; author them as it prefers and let pnpm run lint settle the final form.
Guard bodies (moved verbatim)
export function isPermissionState(value: unknown): value is PermissionState {
return value === "allow" || value === "deny" || value === "ask";
}
/**
* Narrow type guard: a raw value representing a DenyWithReason object.
* Accepts `{ action: "deny" }` and `{ action: "deny", reason: "…" }`.
* Rejects a non-string `reason` to keep malformed config out of the rule set.
*/
export function isDenyWithReason(value: unknown): value is DenyWithReason {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
return (
record.action === "deny" &&
(record.reason === undefined || typeof record.reason === "string")
);
}
Module-Level Changes
src/value-guards.ts— removeisPermissionStateandisDenyWithReason; remove the now-unusedimport type { DenyWithReason, PermissionState } from "./types". KeeptoRecordandgetNonEmptyString.src/types.ts— addisPermissionStateandisDenyWithReason(verbatim), narrowing to the already-importedPermissionState/DenyWithReason. Place them below the type declarations they guard, per the stepdown rule.src/permission-manager.ts— importisPermissionStatefrom./typesinstead of./value-guards.src/normalize.ts— importisDenyWithReason,isPermissionStatefrom./typesinstead of./value-guards.src/config-loader.ts— importisDenyWithReason,isPermissionStatefrom./typesinstead of./value-guards.test/value-guards.test.ts— remove thedescribe("isPermissionState", …)anddescribe("isDenyWithReason", …)blocks; keep thetoRecordandgetNonEmptyStringblocks; trim the import to{ getNonEmptyString, toRecord }.test/types.test.ts— new; the two moveddescribeblocks, importing{ isDenyWithReason, isPermissionState }from#src/types.docs/architecture/architecture.md— doc updates in the implementation commit:- Module-tree listing (line 836): change the
value-guards.tsdescription toRuntime type guards (toRecord,getNonEmptyString); add the two domain guards to thetypes.tsdescription (line 838). - Mark Step 8 complete (line 940 heading ✅ and the
S8Mermaid node, line 958). - Health-metric row "fallow refactoring targets" (line 874): update the achieved value from 1 to 0.
Re-run
pnpm --filter @gotgenes/pi-permission-system exec fallowat implementation time to confirm the target clears before editing the row.
- Module-tree listing (line 836): change the
No user-facing docs (README.md, docs/configuration.md), config schema, or example config reference these internal guard symbols — grep confirms hits only in docs/plans/, docs/retro/, docs/decisions/, and docs/architecture/history/ (all historical records that must not be edited) plus the live architecture.md lines above.
Test Impact Analysis
- New unit tests enabled — none genuinely new; the two guards were already unit-tested.
The move relocates their existing
describeblocks intotest/types.test.tsverbatim, keeping identical assertions. - Tests that become redundant — none; no assertion is dropped.
test/value-guards.test.tsshrinks to the two generic-guard blocks;test/types.test.tsgains the two domain-guard blocks. - Tests that must stay as-is — the
toRecord/getNonEmptyStringblocks stay intest/value-guards.test.ts(they exercise the guards that remain there); the domain-guard assertions move unchanged (they pin the byte-for-byte-identical behavior across the move).
Invariants at risk
- The behavior of all four guards must be identical post-move — pinned by the relocated
test/types.test.tsblocks and the retainedtest/value-guards.test.tsblocks (the same assertions that pass today). permission-manager.ts'sno-restricted-importsguard must stay green — the new./typesimport is not anaccess-pathimport, so it is allowed;pnpm run lintconfirms.
TDD Order
Single atomic step — removing the two exports from value-guards.ts breaks every importer and its tests at the type level in the same commit, so the extraction, all consumer import updates, and all test moves must land together (per the "removing an export breaks importers in that commit" rule; mirrors #479's single-step split).
-
Move the domain guards to
types.tsand repoint consumers.- Add
isPermissionState/isDenyWithReasontosrc/types.ts; remove them (and the now-unused type import) fromsrc/value-guards.ts. - Repoint
src/permission-manager.ts,src/normalize.ts,src/config-loader.tsto import the guards from./types. - Create
test/types.test.tswith the two moveddescribeblocks (import from#src/types); trimtest/value-guards.test.tsto the two generic-guard blocks and its import. - Verify:
pnpm run check,pnpm run lint,pnpm --filter @gotgenes/pi-permission-system exec vitest run, andpnpm fallow dead-codeall green; the relocated guard tests pass unchanged. - Commit:
refactor(pi-permission-system): split value-guards.ts by cohesion (#532).
- Add
-
Update
docs/architecture/architecture.md.- Edit the module-tree descriptions for
value-guards.tsandtypes.ts; mark Step 8 ✅ (heading +S8Mermaid node); update the "fallow refactoring targets" health row to 0 after re-runningfallowto confirm. - Verify:
pnpm run lint(markdown) green; the MermaidS8node renders with the ✅ marker. - Commit:
docs(pi-permission-system): mark Phase 8 Step 8 complete (#532).
- Edit the module-tree descriptions for
Note: per the package skill, the roadmap-completion marker (✅ on the step heading and Mermaid node) lands in the implementation doc-update commit (step 2 here), not a deferred ship commit.
Risks and Mitigations
- Autoformatter reflows the merged
./typesimports — buildoldTextfrom a freshly-read region when editing consumer imports, sincepi-autoformatmay reorder the two import lines after the first edit. fallowstill flagsvalue-guards.ts— the fan-in that made it a target is ontoRecord/getNonEmptyString, which stay. If the target does not clear to 0, do not force the health-row edit; record the actualfallowcount in the row and note the residual in the retro, since the mixed-cohesion smell (not fan-in) is what this step targets.- A missed domain-guard consumer — grep confirms exactly three
src/importers plus the one test file;pnpm run checkfails loudly on any import left pointing at the removedvalue-guardsexports.
Open Questions
- None. The direction is unambiguous, non-breaking, matches the roadmap, and the operator authored the issue.