Files
my-pi/pi-permission-system/docs/plans/0562-path-flavor-platform-language.md

309 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 562
issue_title: "Leaf path modules re-derive the win32 path flavor from a raw platform parameter"
---
# PathFlavor: pass the platform's path language, not the raw discriminator
## Release Recommendation
**Release:** ship independently
This is Step 3 of the pi-permission-system Phase 10 roadmap, tagged `Release: independent` (a member of no batch).
It is a behavior-preserving refactor, so every implementation commit is `refactor:` (a hidden changelog type) — the work lands on `main` and auto-batches into the next `feat:`/`fix:` release rather than cutting one of its own (Refs [#479]).
The closing documentation commit touches only release-excluded paths (`docs/architecture`, `.pi/`), so it does not cut a release either.
## Problem Statement
The win32-vs-POSIX path flavor is re-derived from a raw `platform: NodeJS.Platform` parameter at 13 sites across the package.
Six leaf functions open with the identical `const impl = platform === "win32" ? winPath : posixPath` ternary; the win32 case fold (`toLowerCase()`), the separator pick, and the `{ caseInsensitive: true, windowsSeparators: true }` match-options literal are each independently re-derived at further sites.
The variant set is closed (win32 vs. POSIX), so the risk is not variant growth — it is **connascence of algorithm**: every site must re-derive the same mapping identically, and in a permission system a leaf that misses the case fold or separator fold is a silent bypass (the [#382]/[#508] bug class).
The [#505]/[#510] seam made the leaves pure platform-parameterized functions behind the `PathNormalizer` facade — it fixed *where the platform is read* but threaded the raw discriminator rather than the resolved product, so each leaf still re-interprets it.
## Goals
- Introduce `PathFlavor`, a value object that is the resolved product of the single `platform === "win32"` decision, and thread it into the leaves in place of the raw `platform` string.
- Make the [#382]/[#508] must-agree bug class **structurally impossible**: the case fold, the containment geometry, and the match-options each have exactly one implementation, owned by the flavor.
- Remove `NodeJS.Platform` from every domain signature: after this change, no `src/` module outside `index.ts` and the `PathFlavor` factory names `platform`.
- Preserve behavior exactly — identical decisions on every input, on both platforms.
This is not a breaking change.
- Advance the roadmap's structural metrics: `platform === "win32"` sites 13 → 1; `caseInsensitive` derivations → 1; flat `src/` root 62 → 59 (three leaves relocate into `src/path/`).
## Non-Goals
- Unifying `subagent-context`'s bespoke prefix-containment onto `PathFlavor.isWithin`.
That check (`isPathWithinDirectoryForSubagent`) uses a different algorithm than `path.relative`-based containment and diverges on `..` segments and cross-root paths, so collapsing it is **behavior-affecting**, not a pure refactor.
Filed as a follow-up: [#571].
- Splitting a separate `BashDialect` collaborator out of the win32 flavor.
Pi core guarantees bash-on-win32 is always Git Bash (no cmd/PowerShell branch in core's `shell.ts`), so the win32⇔MSYS pairing is fixed; a second object would be polymorphism over an axis that cannot vary independently.
Track-and-watch: extract it only if core ever un-fixes the pairing (WSL, PowerShell).
- Any change to config, schema, the permission model, or user-facing docs — this is an internal structural refactor with no observable surface change.
## Background
The relevant modules and their current platform coupling:
- `src/path-normalizer.ts` — the facade constructed at the session edge with `(platform, cwd)`.
Selects `this.impl` once, but also carries two `this.platform !== "win32"` guards in `forBashToken` / `interpretBashCdTarget` for Git Bash/MSYS semantics, and a `usesWindowsSeparators()` accessor.
- `src/access-intent/path-normalization.ts``normalizePathForComparison` (resolve + normalize + fold), `canonicalNormalizePathForComparison` (+ realpath + fold), `getPathPolicyValues` / `getAbsolutePathPolicyValues` / `getCwdRelativePathPolicyValues`.
Called by `AccessPath.forPath`.
- `src/path-containment.ts``isPathWithinDirectory` (the `path.relative` containment geometry) and `isPathOutsideWorkingDirectory` (geometry + `isSafeSystemPath` exclusion).
- `src/canonicalize-path.ts``canonicalizePath` (best-effort `realpathSync` walk using `impl.parse`/`impl.sep`/`impl.join`).
- `src/pi-infrastructure-read.ts``isPiInfrastructureRead` (re-derives the win32 match-options literal, calls `isPathWithinDirectory`).
- `src/authority/subagent-context.ts``normalizeFilesystemPath` (normalize + fold) and `isSubagentExecutionContext`, plus a private prefix-containment helper.
- `src/rule.ts``pathMatchOptions` (re-derives the match-options literal, gated by `PATH_SURFACES`) and the `evaluate`/`evaluateFirst`/`evaluateAnyValue`/`evaluateMostRestrictive`/`ruleMatches` family, all threaded `platform`.
- `src/permission-manager.ts` — holds `this.platform`, relays it to the `rule.ts` family.
Consumes `ResolvedAccessIntent` and **must not import `AccessPath`** (ADR-0002, enforced by a `no-restricted-imports` rule scoped to this file).
`PathFlavor` is a plain value object, not `AccessPath`, so it is safe to consume here.
- `src/access-intent/bash/token-classification.ts``classifyTokenAsRuleCandidate` takes a `windowsSeparators` boolean derived from `PathNormalizer.usesWindowsSeparators()` by `bash-path-resolver.ts` ([#520]).
- `src/index.ts` — the sole `process.platform` reader (the ESLint `no-restricted-syntax` guard exempts only this file); threads `hostPlatform` into `PermissionManager`, `PermissionSession` (→ `PathNormalizer`), and `SubagentDetection`.
Constraints from AGENTS.md and the package skill that apply:
- Platform handling has a single home (`PathNormalizer`); no `src/` module reads `process.platform` (ESLint-guarded).
`PathFlavor` becomes a second platform-semantics home, still fed exclusively from `index.ts`'s one read.
- To test Windows behavior on POSIX CI, inject a win32 flavor — never `vi.mock("node:path")`.
The documented `new PathNormalizer("win32", cwd)` idiom becomes `new PathNormalizer(win32PathFlavor, cwd)`.
- `permission-manager.ts` stays string-based (ADR-0002); a `PathFlavor` import is allowed (the guard bans only `access-intent/access-path`).
## Design Overview
### The `PathFlavor` value object
`PathFlavor` is the platform's **path language** expressed as one object: syntax recognition, token semantics, and an equivalence relation — the three things the leaves currently re-derive.
It is pure (no filesystem access) and immutable; two cached singletons are selected by a factory that holds the package's one remaining `=== "win32"` comparison.
```typescript
// src/path/path-flavor.ts
import type { PlatformPath } from "node:path";
import type { BashTokenShape } from "#src/access-intent/bash/msys-bash-tokens";
import type { WildcardMatchOptions } from "#src/wildcard-matcher";
export interface PathFlavor {
/** Node's own platform strategy (path.win32 | path.posix). Path-domain primitives use it directly. */
readonly impl: PlatformPath;
/** Win32 { caseInsensitive, windowsSeparators } | undefined — the match-options product for the wildcard engine. */
readonly matchOptions: WildcardMatchOptions | undefined;
/** Comparison case fold: win32 => value.toLowerCase(), posix => value. */
fold(value: string): string;
/** resolve + normalize + fold against a base — the #382 invariant's single home. */
comparable(pathValue: string, base: string): string;
/** path.relative-based containment geometry. */
isWithin(pathValue: string, directory: string): boolean;
/** True when the token contains a path separator under this platform (posix: "/"; win32: "/" or "\"). */
hasPathSeparator(token: string): boolean;
/** MSYS/Git-Bash token shape on win32; always { kind: "plain" } on posix. */
bashTokenShape(token: string): BashTokenShape;
}
export const posixPathFlavor: PathFlavor;
export const win32PathFlavor: PathFlavor;
/** The one `platform === "win32"` decision in the package. */
export function pathFlavorForPlatform(platform: NodeJS.Platform): PathFlavor;
```
Ownership rule (prevents god-object drift): the flavor owns **platform semantics** (fold, geometry, token shape, match options, separator syntax).
**Domain policy** stays in the functions that consume it — the lexical cleanup in `normalizePathForComparison` (trim / strip quotes / strip `@` / `expandHome`), the alias generation in `getPathPolicyValues`, the `isSafeSystemPath` exclusion in `isPathOutsideWorkingDirectory`, the infra-read rules, and `rule.ts`'s `PATH_SURFACES`-gated dispatch.
`impl` is exposed rather than wrapped: post-migration its consumers are exclusively path-domain primitives, and `PlatformPath` is itself a strategy object Node maintains — wrapping it would add ~7 forwarding methods with no semantics.
The doc comment states the ownership rule; sealing it later (make it private, mirror the used methods) is a two-line change if it ever itches.
### How the flavor dissolves each site
`fold` / `comparable` / `matchOptions` collapse the equivalence family — the #382/#508 must-agree class.
A leaf can no longer re-derive-and-diverge because it never derives at all:
```typescript
// path-normalization.ts — after
export function normalizePathForComparison(pathValue: string, base: string, flavor: PathFlavor): string {
const cleaned = lexicalCleanup(pathValue); // domain policy stays here
return cleaned ? flavor.comparable(cleaned, base) : "";
}
```
`bashTokenShape` dissolves `PathNormalizer`'s two `!== "win32"` guards into uniform dispatch — the posix flavor returns `{ kind: "plain" }` (semantically correct: every posix token is an ordinary path), so the switch runs unchanged on both platforms:
```typescript
// path-normalizer.ts — after; no platform conditional remains
forBashToken(token: string, options?: { resolveBase?: string }): AccessPath {
const shape = this.flavor.bashTokenShape(token);
switch (shape.kind) {
case "device": return AccessPath.forDevice(token);
case "drive-mount": return this.forPath(shape.windowsPath, options);
case "posix-absolute": { /* literal-only + backslash alias, unchanged */ }
case "plain": return this.forPath(token, options);
}
}
```
`hasPathSeparator` dissolves the ask-leak in the bash rule-candidate classifier — the resolver stops asking `usesWindowsSeparators()` and relaying a boolean through an options bag:
```typescript
// token-classification.ts — after; the two includes() lines become one
if (token.startsWith(".")) return token;
if (flavor.hasPathSeparator(token)) return token; // was: includes("/") + (windowsSeparators && includes("\\"))
if (token.includes("..")) return token;
if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token;
```
`PathNormalizer` exposes its flavor (`readonly flavor: PathFlavor`) so `bash-path-resolver.ts` passes `this.normalizer.flavor` to the classifier; `PathNormalizer.usesWindowsSeparators()` and `RuleCandidateOptions.windowsSeparators` are deleted.
### Construction and threading
`index.ts` performs the one `process.platform` read, resolves it into the flavor once, and injects that collaborator into the three holders — "instantiate the right collaborator as soon as we know the platform":
```typescript
// index.ts
const flavor = pathFlavorForPlatform(process.platform);
const permissionManager = new PermissionManager({ agentDir, flavor, isYoloEnabled });
const subagentDetection = new SubagentDetection({ subagentSessionsDir, flavor, registry });
// session ctor: new PermissionSession(..., flavor) -> new PathNormalizer(flavor, cwd)
```
`PathNormalizer` and `PermissionManager` hold `this.flavor` and drop their `platform` fields; `PermissionManager`'s constructor option becomes `flavor?: PathFlavor` (defaulting to `posixPathFlavor`, mirroring the old `platform ?? "linux"`); `SubagentDetection`'s dep becomes `flavor: PathFlavor`.
### Lift-and-shift bridge
Migrating a leaf's signature from `platform` to `PathFlavor` breaks its callers at the type level.
To keep every commit compiling and green, the migration is bottom-up: each leaf switches to `PathFlavor` first, and its not-yet-migrated callers bridge with an inline `pathFlavorForPlatform(platform)` at the call site.
Because `pathFlavorForPlatform` returns cached singletons, the bridge is cheap and cannot diverge — the transitional state is still bypass-safe.
The final threading step removes every inline bridge once the holders carry the flavor directly.
## Module-Level Changes
New:
- `src/path/path-flavor.ts` — the `PathFlavor` interface, `posixPathFlavor` / `win32PathFlavor` singletons, `pathFlavorForPlatform` factory.
- `test/path/path-flavor.test.ts` — unit tests for every capability, on both flavors.
Relocated into `src/path/` (tidy-first — these leaves reach their final home; three fewer files at the flat `src/` root, 62 → 59):
- `src/path-containment.ts``src/path/path-containment.ts`.
Delete the standalone `isPathWithinDirectory` export (geometry moves onto `flavor.isWithin`); keep `isPathOutsideWorkingDirectory(canonicalPath, canonicalCwd, flavor)`.
- `src/canonicalize-path.ts``src/path/canonicalize-path.ts`; `platform``flavor` (`flavor.impl`).
- `src/pi-infrastructure-read.ts``src/path/pi-infrastructure-read.ts`; `platform``flavor` (`flavor.matchOptions`, `flavor.isWithin`); delete the re-derived match-options literal.
- Move each module's test to `test/path/` and update the `#src/...` import.
Edited (signature/body, no relocation):
- `src/access-intent/path-normalization.ts` — all five exports `platform``flavor`; `normalizePathForComparison` = lexical cleanup + `flavor.comparable`; `canonicalNormalizePathForComparison` = `flavor.comparable` + `canonicalizePath` + `flavor.fold`; `getCwdRelativePathPolicyValues` uses `flavor.impl.relative` + `flavor.isWithin`.
- `src/access-intent/access-path.ts``AccessPath.forPath` option `platform``flavor` (its only caller is `PathNormalizer.forPath`).
- `src/path-normalizer.ts` — constructor `platform``flavor`; hold `this.flavor`; expose `readonly flavor`; drop both `!== "win32"` guards via `flavor.bashTokenShape`; delete `usesWindowsSeparators()`; relay `this.flavor` to every leaf; import the relocated leaves from `#src/path/...`.
- `src/authority/subagent-context.ts``normalizeFilesystemPath` and `isSubagentExecutionContext` `platform``flavor` (`flavor.impl`, `flavor.fold`, `flavor.impl.sep`).
- `src/authority/subagent-detection.ts``SubagentDetectionDeps.platform``flavor: PathFlavor`.
- `src/rule.ts``pathMatchOptions(surface, flavor)` returns `PATH_SURFACES.has(surface) ? flavor.matchOptions : undefined`; `ruleMatches` / `evaluate` / `evaluateFirst` / `evaluateAnyValue` / `evaluateMostRestrictive` `platform``flavor`.
- `src/permission-manager.ts` — option `platform?``flavor?: PathFlavor` (default `posixPathFlavor`); hold `this.flavor`; relay to the `rule.ts` family.
Import `#src/path/path-flavor` (allowed by the `no-restricted-imports` guard, which bans only `access-intent/access-path`).
- `src/permission-session.ts` — constructor `platform``flavor`; build `new PathNormalizer(flavor, "")` and rebuild on `activate`.
- `src/access-intent/bash/token-classification.ts``classifyTokenAsRuleCandidate(token, flavor)` via `flavor.hasPathSeparator`; delete `RuleCandidateOptions.windowsSeparators` (and the interface if it empties).
- `src/access-intent/bash/bash-path-resolver.ts` — pass `this.normalizer.flavor` to the classifier; drop the `usesWindowsSeparators()` read.
- `src/index.ts` — construct the flavor once; inject into `PermissionManager`, `PermissionSession`, `SubagentDetection`. `hostPlatform` stays only as the argument to `pathFlavorForPlatform`.
Verify `BashTokenShape` is exported from `src/access-intent/bash/msys-bash-tokens.ts` for `path-flavor.ts` to import (it holds the discriminated union `classifyWin32BashToken` returns); export it if it is currently local.
Test fixtures/harnesses:
- `test/helpers/session-fixtures.ts``makeRealSession`'s `platform?` override → `flavor?: PathFlavor` (or accept `platform` and map to `pathFlavorForPlatform` at the boundary); it builds a real `PermissionManager` + `PermissionSession`.
- `test/helpers/gate-fixtures.ts` — the `new PathNormalizer(process.platform, ...)` construction → `pathFlavorForPlatform(process.platform)`.
- Every `new PathNormalizer("win32"|"linux"|process.platform, cwd)` site (~30 across `test/`) → the corresponding singleton (`win32PathFlavor` / `posixPathFlavor`) or `pathFlavorForPlatform(process.platform)`.
- `test/rule.test.ts` (49 platform references) and the manager tests → flavor singletons.
Documentation (final commit; all release-excluded, so no release impact):
- `.pi/skills/package-pi-permission-system/SKILL.md` — rework the prose naming the removed/changed mechanisms: the leaf list ("every `path-containment` / `path-normalization` / `pi-infrastructure-read` / `canonicalize-path` / `rule.ts` / `subagent-context.ts` leaf takes an injected `platform` parameter"), the `usesWindowsSeparators()` reference in the bash `external_directory` note, and the "pass `platform: 'win32'`" test idiom → "pass `win32PathFlavor`".
These are reworded-prose updates carrying no removed symbol, so grep the skill for each mechanism name.
- `packages/pi-permission-system/docs/architecture/architecture.md` — mark Step 3 complete (`✅` on the step heading and the `S3` Mermaid node); update the health-metric rows (`platform === "win32"` 13 → 1, flat `src/` root 62 → 59, `caseInsensitive` derivations → 1); refresh any module-layout listing or narrative that names the relocated files or the `platform` threading.
`architecture.md` inline-copies the `rule.ts` `Rule`/`RuleOrigin`/`Ruleset` types — those are unchanged (only function signatures change), so that listing needs no edit; confirm during the pass.
- `packages/pi-permission-system/docs/decisions/0002-path-values-string-boundary.md` — confirm it stays accurate; the manager now consumes a `PathFlavor` but still not `AccessPath`, so the boundary holds.
Add a clarifying sentence only if the ADR's wording implies the manager holds a raw `platform`.
## Test Impact Analysis
1. New unit tests the extraction enables (previously impractical): `path-flavor.test.ts` directly exercises `fold`, `comparable`, `isWithin`, `hasPathSeparator`, `bashTokenShape`, and `matchOptions` on both singletons — the win32 case/separator semantics that were previously only reachable transitively through `AccessPath` / gate tests now have a focused home.
2. Tests that become redundant: the win32-specific assertions scattered in `path-containment.test.ts` (containment geometry) and portions of `path-normalization.test.ts` (fold behavior) overlap with the new flavor tests.
Keep them for now — they exercise the leaf functions' domain-policy wrapping (lexical cleanup, alias generation), not the flavor's raw geometry — but simplify any assertion that only re-checks the fold once `path-flavor.test.ts` owns it.
3. Tests that must stay as-is: the `PathNormalizer` win32 tests (`path-normalizer.test.ts`), the bash MSYS token tests (`msys-bash-tokens.test.ts`, `bash-external-directory.test.ts`), and the gate acceptance tests genuinely exercise the composed behavior (token shape → AccessPath → decision) and pin the [#533]/[#520] semantics end-to-end; they only swap their construction idiom to the flavor singleton.
## Invariants at risk
This change touches surfaces earlier phases refactored; each documented outcome must stay green:
- [#382]/[#508] — win32 case/separator fold on path-surface matching.
Pinned by the win32 path-matching tests in `rule.test.ts` and `permission-manager-unified.test.ts` (`new PathNormalizer("win32", ...)`).
The fold moving onto `flavor.fold`/`flavor.matchOptions` must not change any decision — these tests are the guard.
- [#533] — Git Bash/MSYS bash-token semantics (safe devices preserved, `/c/` mounts translated, other POSIX absolutes literal-only).
Pinned by `bash-external-directory.test.ts` and `msys-bash-tokens.test.ts`; `bashTokenShape` dispatch must reproduce them exactly.
- [#520] — win32 backslash-relative token recognized as path-shaped.
Pinned by the backslash-token cases in the bash path tests; `hasPathSeparator` replacing the `windowsSeparators` flag must keep the same classification.
- [#510]/[#505] — `PathNormalizer` is the single platform home fed from one `process.platform` read.
Pinned by the ESLint `no-restricted-syntax` guard (still exempting only `index.ts`) and the `test/composition-root.test.ts` wiring tests.
No new test is needed — each invariant already lives in a test, not only prose.
## TDD Order
Every step below is behavior-preserving; each keeps the suite green and compiling.
Commit type is `refactor:` throughout (the closing docs commit is `docs:`), so nothing cuts a release on its own.
`pnpm fallow dead-code` is a final-state gate — the flavor's methods are consumed incrementally across steps 29 and are all live by step 8; do not expect a clean fallow run mid-sequence.
1. **Add `PathFlavor` (pure addition).**
Red: `test/path/path-flavor.test.ts` asserts `fold` / `comparable` / `isWithin` / `hasPathSeparator` / `bashTokenShape` / `matchOptions` / `impl` on `win32PathFlavor` and `posixPathFlavor`, plus `pathFlavorForPlatform` selection.
Green: implement `src/path/path-flavor.ts`; export `BashTokenShape` from `msys-bash-tokens.ts` if needed.
`refactor(pi-permission-system): add PathFlavor value object`.
2. **Relocate + migrate `canonicalize-path`.**
Move to `src/path/`, signature `platform``flavor`; bridge its one caller (`path-normalization`) inline; move its test; update importers.
`refactor(pi-permission-system): thread PathFlavor through canonicalize-path`.
3. **Relocate + migrate `path-containment`; move geometry onto `flavor.isWithin`.**
Delete standalone `isPathWithinDirectory`; migrate all callers (`path-normalization`, `pi-infrastructure-read`, `path-normalizer`) to `flavor.isWithin` (inline bridge where the holder still has `platform`); keep `isPathOutsideWorkingDirectory(..., flavor)`; move test.
`refactor(pi-permission-system): move containment geometry onto PathFlavor`.
4. **Migrate `path-normalization` + `access-path.forPath`.**
All five `path-normalization` exports → `flavor` (`comparable`/`fold`/`impl`); `AccessPath.forPath` option `platform``flavor`; bridge the caller (`PathNormalizer.forPath`); update `path-normalization` tests.
`refactor(pi-permission-system): thread PathFlavor through path normalization`.
5. **Relocate + migrate `pi-infrastructure-read`.**
Move to `src/path/`, `platform``flavor` (`matchOptions`, `isWithin`); delete the re-derived match-options literal; bridge the caller; move test.
`refactor(pi-permission-system): thread PathFlavor through infrastructure-read`.
6. **Migrate `subagent-context` + `subagent-detection`.**
Leaves → `flavor` (`impl`, `fold`, `impl.sep`); `SubagentDetectionDeps.platform``flavor`; bridge `index.ts`'s construction inline; update subagent tests.
`refactor(pi-permission-system): thread PathFlavor through subagent detection`.
7. **Migrate `rule.ts` + `permission-manager`.**
`pathMatchOptions` and the `evaluate` family `platform``flavor`; manager option `platform?``flavor?` (default `posixPathFlavor`); migrate `rule.test.ts` (49 sites) and the manager tests to the singletons via a lift-and-shift within the step.
`refactor(pi-permission-system): thread PathFlavor through rule evaluation`.
8. **Thread the flavor from `index.ts`; dissolve `PathNormalizer`'s platform conditionals.**
`index.ts` constructs the flavor once and injects it into `PermissionManager` / `PermissionSession` / `SubagentDetection`; `PermissionSession` + `PathNormalizer` constructors `platform``flavor`; `PathNormalizer` exposes `readonly flavor`, drops both `!== "win32"` guards via `bashTokenShape`; remove every inline `pathFlavorForPlatform(platform)` bridge from steps 27; update all ~30 `PathNormalizer` test constructors, `session-fixtures`, `gate-fixtures`, and `composition-root.test.ts`.
`refactor(pi-permission-system): inject PathFlavor from the composition root`.
9. **Migrate the bash rule-candidate classifier; delete `usesWindowsSeparators`.**
`classifyTokenAsRuleCandidate(token, flavor)` via `flavor.hasPathSeparator`; `bash-path-resolver` passes `this.normalizer.flavor`; delete `usesWindowsSeparators()` and `RuleCandidateOptions.windowsSeparators`; update classifier + resolver tests.
`refactor(pi-permission-system): answer path-separator syntax on PathFlavor`.
10. **Documentation.**
Update the package skill's reworded prose and the architecture roadmap (Step 3 `✅` + Mermaid node + health metrics); confirm ADR-0002 accuracy.
`docs(pi-permission-system): record PathFlavor and complete roadmap Step 3`.
## Risks and Mitigations
- **Silent decision drift (the exact bug class this fixes).**
Mitigation: behavior-preserving throughout, guarded by the [#382]/[#508]/[#533]/[#520] invariant tests listed above; each step keeps the suite green before committing.
- **Large mechanical test migration (`rule.test.ts`, ~30 `PathNormalizer` constructors).**
Mitigation: lift-and-shift with the cached-singleton bridge so no step rewrites a whole test file at once; the singleton swap is a construction-idiom change, not a behavior change.
- **Transitional dead code failing a mid-sequence `fallow` run.**
Mitigation: treat fallow as a final-state gate (documented above); the pre-completion reviewer runs it once at the end when every flavor method is live.
- **Intermediary inline `pathFlavorForPlatform(platform)` bridges left behind.**
Mitigation: step 8 explicitly removes every bridge; a grep for `pathFlavorForPlatform(` outside `index.ts`, the factory, and tests must return nothing at the end.
- **`permission-manager.ts` accidentally importing `AccessPath` while adding the `PathFlavor` import.**
Mitigation: the `no-restricted-imports` rule already bans it; `PathFlavor` lives in `src/path/`, a different module, so the guard is untouched.
## Open Questions
- None blocking.
The `subagent-context` containment unification is deferred by design to [#571]; the `BashDialect` split is track-and-watch (Non-Goals).
[#382]: https://github.com/gotgenes/pi-packages/issues/382
[#479]: https://github.com/gotgenes/pi-packages/issues/479
[#505]: https://github.com/gotgenes/pi-packages/issues/505
[#508]: https://github.com/gotgenes/pi-packages/issues/508
[#510]: https://github.com/gotgenes/pi-packages/issues/510
[#520]: https://github.com/gotgenes/pi-packages/issues/520
[#533]: https://github.com/gotgenes/pi-packages/issues/533
[#571]: https://github.com/gotgenes/pi-packages/issues/571