Files
my-pi/pi-permission-system/docs/retro/0304-consolidate-bash-command-analysis.md
T

5.7 KiB

issue, issue_title
issue issue_title
304 Consolidate bash command analysis behind a single parsed representation and a candidate-combination helper

Retro: #304 — Consolidate bash command analysis

Stage: Planning (2026-06-01T20:26:00Z)

Session summary

Issue #304 was created during the planning session for #301 in response to the question "what architectural changes would make this easier?". While planning the #301 bash command-chain fix, the friction analysis surfaced two structural gaps in the bash permission path: no shared parsed-bash representation (three independent tree-sitter parses) and a duplicated most-restrictive candidate-selection loop across the two bash gates. The owner chose Beck-style "refactor first, then a trivial fix", so #304 captures the behavior-preserving enabler and #301 becomes a follow-up that builds on it.

Observations

  • Scope was deliberately trimmed from the issue's high-level framing. The issue text mentioned a dual-strategy combinator (first-non-default / most-restrictive); the plan narrows #2 to a result-level pickMostRestrictive only, because first-non-default lives at the rule level (evaluateFirst) one layer below and merging the two layers is out of scope. Adding an unused strategy parameter would be a speculative export (fallow would flag it).
  • The two bash gates share a most-restrictive core but wrap it in different filters — the path gate's #58 backward-compat ("token matching only the universal default is unrestricted") plus session-coverage, and the external-directory gate's "uncovered = state !== allow". So pickMostRestrictive is the right shared seam; the filters stay gate-specific. The external-directory gate is a clean drop-in; the path gate needs care to preserve #58 and loses its deny short-circuit (output-identical, slightly more in-memory checkPermission calls).
  • BashProgram (#1) is honestly the lower-leverage of the two enablers near-term: the two extractors already share the AST walker, so #1's win is cohesion and an extensible seam for #301, not fewer parses. Parse-once-and-inject across gates was deferred — it changes gate signatures and drifts into the deferred gate-consolidation enabler (#4).
  • Kept the existing extractor exports (extractTokensForPathRules, extractExternalPathsFromBashCommand) as thin facades over BashProgram specifically to avoid rewriting the 900-line test/bash-external-directory.test.ts (lift-and-shift / large-test-file rule).
  • Risk flagged: moving the parse/walk primitives into bash-program.ts to avoid a circular import is the largest single edit; it is mechanical and gated by the unchanged extractor suite + pnpm run check.
  • Labels available are coarse (no refactor/tech-debt); filed as enhancement + pkg:pi-permission-system.

Diagnostic details

  • Feedback-loop gap analysis — Two steps (path-gate refactor; cross-module primitive move) are explicitly paired with pnpm run check in the plan because they are behavior-preserving moves that the type checker, not the test suite alone, will catch first.

Stage: Implementation — TDD (2026-06-01T20:46:09Z)

Session summary

Executed all four planned steps as behavior-preserving refactors: extracted pickMostRestrictive (candidate-check.ts) and migrated both bash gates onto it, introduced the BashProgram value object (bash-program.ts) owning the tree-sitter primitives with the old extractors reduced to thin facades, and updated the architecture directory listing. Test count went from 1674 to 1686 (+12: six pickMostRestrictive cases, six BashProgram cases); the 900-line extractor suite and both bash-gate suites stayed green unchanged, confirming behavior preservation.

Observations

  • Pre-completion reviewer: PASS.
  • Reviewer warnings (all non-blocking, left as-is):
    • bash-path.ts recovers the worst token by reference identity (uncovered.find(({ check }) => check === worstCheck)) after pickMostRestrictive(uncovered.map(({ check }) => check)). Correct because .map() does not clone; kept the helper checks-only since that is the shared seam with the external-directory gate.
    • bash-external-directory.ts ends with pickMostRestrictive(...) ?? uncoveredEntries[0].check; the fallback is logically unreachable (the empty case returns earlier) but is required because pickMostRestrictive returns PermissionCheckResult | undefined and the type checker needs the narrowing.
    • bash-program.ts places the tree-sitter bootstrap (getParser etc.) above the exported BashProgram class; all declarations hoist so ordering is safe.
  • Baseline was not clean: a pre-existing MD053 lint failure in the 0301 plan (a self-referential [#301]: link definition left by the prior planning session) was fixed first as docs: remove self-referential issue link from #301 plan.
  • Fallow false positive: BashProgram's private constructor + static parse() factory defeats fallow's syntactic-only analysis (no compiler), so it reports pathTokens/externalPaths as unused class members. Suppressed with // fallow-ignore-next-line unused-class-member (note: the inline issue kind is singular unused-class-member, even though the .fallowrc.json rule key is plural unused-class-members; the suppression line must contain only the kind — trailing prose is parsed as bogus issue kinds). This suppression landed as its own refactor: commit rather than in the trailing docs: commit.
  • No deviations from the plan's Module-Level Changes; v3-architecture.md was reviewed and correctly left unchanged (historical pre-refactor narrative, does not enumerate current gate modules).
  • This unblocks #301, which can now add BashProgram.topLevelCommands() plus a bash command gate selecting with pickMostRestrictive.