17 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 473 | pi-permission-system: extract the tree-sitter parser and AST node-text resolver from bash-program.ts (Phase 6 Step 1) |
Extract the tree-sitter parser and AST node-text resolver from bash-program.ts
Release Recommendation
Release: mid-batch — defer (batch "bash-program-decomposition"); confirm at ship time
This is Step 1 of three in batch "bash-program-decomposition" (Steps 1 (#473), 2 #474, 3 #475; tail = Step 3).
The roadmap batches the decomposition so it releases once rather than as three internal-only patch releases, so this step lands on main but does not cut a release on its own.
A subtle consequence to flag for the Step 3 ship decision: all three steps are pure refactor: extractions, and refactor is hidden / non-version-bumping under release-please-config.json — so the batch produces no release unless Step 3 carries a feat:/fix: commit or the maintainer accepts no version bump for the internal decomposition.
Problem Statement
src/handlers/gates/bash-program.ts is 1,143 LOC and the package's #1 churn × complexity hotspot (fallow risk 97.0).
It mixes at least five concerns: tree-sitter parser bootstrap, AST traversal, the BashProgram value-object API, token collection, command enumeration, and the cd-fold projection.
The decomposition (Phase 6, Track A) starts with the two leaf utilities that have no dependency on the rest of the file: the lazy tree-sitter parser and the quote-aware node-text resolver.
Landing them in a new src/access-intent/bash/ directory seeds the package's first domain directory, so the extracted modules reach their final home the first time instead of being moved twice.
Goals
- Move the lazy tree-sitter parser (
getParser, theTSNode/TSParserinterfaces,initParser) frombash-program.tsintosrc/access-intent/bash/parser.ts. - Move the quote-aware node-text resolver (
resolveNodeText) andSKIP_SUBTREE_TYPESintosrc/access-intent/bash/node-text.ts. - Leave
bash-program.tsimporting both from their new homes — pure lift-and-shift, no behavior change. - Add the new unit tests the extraction enables:
resolveNodeTextquote-resolution cases and agetParserparse/memoization smoke test. - Keep the seeded directory's final shape (
src/access-intent/bash/) so Steps 2 and 3 extend it rather than relocate it.
This change is not breaking: the moved symbols are file-private (bash-program.ts never exported them), no package public API, config field, schema, or observable output changes.
Non-Goals
- Token collection (
PATTERN_FIRST_COMMANDS, the flag classifier, the token collectors) — that is Step 2 (#474). - Command enumeration, the
cd-fold cwd projection, and relocating the slimmedBashProgram— that is Step 3 (#475). - Any behavior change to parsing, AST traversal, quote resolution, or path projection.
- The
AccessPathvalue object and external-directory gate unification (#418; Track B, Steps 4–6). - Marking Step 2/3 complete or touching their roadmap entries.
Background
bash-program.ts(src/handlers/gates/bash-program.ts) currently defines, top to bottom: the parser block (interfacesTSNode/TSParser,initParser, the memoizedgetParser), theBashCommand/EffectiveBasetypes, theBashProgramclass, the AST-walker helpers (SKIP_SUBTREE_TYPES,resolveNodeText), the token-collection block, and the command-enumeration / cwd-projection block.getParserismemoizeAsyncWithRetry(initParser)(from#src/async-cache); it memoizes a successful parser and drops a rejected init so a transient WASM load failure is retried (#452).BashProgram.parsecallsawait getParser()once.resolveNodeTextis a pure recursive function over aTSNode: it returns the shell value of an argument node after quote removal (word→ text,raw_string→ strip single quotes,string/concatenation→ concatenate resolved children, expansions → literal text, default →.text).SKIP_SUBTREE_TYPES(aSetofheredoc_body,heredoc_end,comment) is not used byresolveNodeTextitself; it is consumed by the path-candidate collectors and the cwd-projection walkers that stay inbash-program.ts. Per the issue it moves intonode-text.tsandbash-program.tsimports it back.TSNodeis used pervasively across the file (extractCommandName, the token collectors, the command/path walkers,foldCd, …), so after the movebash-program.tsimports theTSNodetype fromparser.ts.TSParseris referenced only byinitParser's return type, which moves with it — so it stays module-private inparser.ts(not exported), avoiding a fallow dead-code flag for an export with no importer.createRequire(fromnode:module) andmemoizeAsyncWithRetry(from#src/async-cache) are used only by the parser block (verified by grep); both imports become dead inbash-program.tsafter Cycle 1 and must be removed, ortsc/eslint fails on the unused import.- AGENTS.md / package constraints that apply:
- Within-package imports use the
#src//#test/aliases, not relative paths — so the new modules and their cross-references use#src/access-intent/bash/.... @typescript-eslint/require-awaitis enabled forsrc/:initParserkeeps itsawait import("web-tree-sitter"), so it staysasync(no change).- The package skill (
.pi/skills/package-pi-permission-system/SKILL.md) namesgetParser = memoizeAsyncWithRetry(...)as living "inbash-program.ts"; this prose reference goes stale and must be updated toparser.ts. docs/architecture/architecture.mdcarries asrc/layout tree and anasync-cache.tsline that both referencebash-program.tsfor the parser; both need updating.
- Within-package imports use the
Design Overview
Two new leaf modules under the seeded domain directory, plus rewired imports in bash-program.ts.
This is a legitimate SRP decomposition, not metric-gaming procedure-splitting: the parser owns state (the memoized, lazily-initialized Parser singleton and its retry semantics), and resolveNodeText returns a value (a pure transformation of an AST node) — both pass the code-design "owns state / returns a value" test and both have zero dependency on the rest of bash-program.ts.
The change introduces no new collaborator, threads no new parameter, and touches no shared interface or layer wiring, so the design-review checklist triggers (shared-interface parameter, 5+-field dependency bag, cross-layer wiring) do not fire.
src/access-intent/bash/parser.ts
import { createRequire } from "node:module";
import { memoizeAsyncWithRetry } from "#src/async-cache";
/** Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker. */
export interface TSNode {
readonly type: string;
readonly text: string;
readonly childCount: number;
readonly isNamed: boolean;
child(index: number): TSNode | null;
}
interface TSParser {
parse(input: string): { rootNode: TSNode; delete(): void } | null;
delete(): void;
}
async function initParser(): Promise<TSParser> {
/* unchanged body — web-tree-sitter init + tree-sitter-bash WASM load */
}
/** Memoize on success but drop a rejected result so a transient init failure is retried (#452). */
export const getParser = memoizeAsyncWithRetry(initParser);
TSNode is exported (consumed by node-text.ts and bash-program.ts); TSParser and initParser stay private.
src/access-intent/bash/node-text.ts
import type { TSNode } from "#src/access-intent/bash/parser";
/** Node types whose subtrees are never descended for path extraction. */
export const SKIP_SUBTREE_TYPES = new Set(["heredoc_body", "heredoc_end", "comment"]);
/** Resolve the "shell value" of an argument node after quote removal. */
export function resolveNodeText(node: TSNode): string {
/* unchanged body */
}
bash-program.ts rewiring
import { getParser, type TSNode } from "#src/access-intent/bash/parser";
import { resolveNodeText, SKIP_SUBTREE_TYPES } from "#src/access-intent/bash/node-text";
Remove the parser block (TSNode/TSParser/initParser/getParser), SKIP_SUBTREE_TYPES, and resolveNodeText definitions, plus the now-dead createRequire and memoizeAsyncWithRetry imports.
Every internal call site (await getParser(), the resolveNodeText(child) calls, the SKIP_SUBTREE_TYPES.has(...) checks, every TSNode-typed signature) keeps its exact call shape — only the symbol's origin changes.
Extracted-module dependency check
node-text.ts depends only on parser.ts for the TSNode type (type-only import, no runtime edge).
parser.ts depends only on #src/async-cache and the dynamically-imported web-tree-sitter / tree-sitter-bash WASM.
Neither imports #src/canonicalize-path, so unit tests for them do not need the canonicalize mock that any test transitively importing bash-program.ts requires (retro 0345) — a concrete testability win.
No Tell-Don't-Ask violation, output-argument mutation, or reverse-search pattern is carried across: resolveNodeText is already a pure function and getParser is already a self-contained memoized factory.
Module-Level Changes
- NEW
src/access-intent/bash/parser.ts— exportsTSNode(interface) andgetParser; keepsTSParserandinitParserprivate. - NEW
src/access-intent/bash/node-text.ts— exportsresolveNodeTextandSKIP_SUBTREE_TYPES; type-importsTSNodefromparser.ts. - CHANGED
src/handlers/gates/bash-program.ts— remove the parser block (issue lines ~18–58),SKIP_SUBTREE_TYPES, andresolveNodeText(issue lines ~273–333); add the two new imports; remove the deadcreateRequire(node:module) andmemoizeAsyncWithRetry(#src/async-cache) imports. (Line numbers shift after Cycle 1 removes ~40 lines — re-grep forresolveNodeText/SKIP_SUBTREE_TYPESin Cycle 2 rather than trusting the issue's line ranges.) - NEW
test/access-intent/bash/parser.test.ts—getParserparse + memoization smoke test. - NEW
test/access-intent/bash/node-text.test.ts—resolveNodeTextquote-resolution unit tests. - CHANGED
docs/architecture/architecture.md— three edits:- Add an
access-intent/bash/subtree (parser.ts,node-text.ts) to thesrc/layout tree. - Update the
bash-program.tslayout line: it no longer owns the tree-sitter parser bootstrap or the node-text resolver (parser imported fromaccess-intent/bash/parser.ts, node-text fromaccess-intent/bash/node-text.ts). - Update the
async-cache.tsline —memoizeAsyncWithRetryis now "used byparser.ts" (wasbash-program.ts) for resilient tree-sitter parser init. - Append
✓ completeto the roadmap "Step 1 … (#473)" heading line (the step's code lands onmainwith this issue).
- Add an
- CHANGED
.pi/skills/package-pi-permission-system/SKILL.md— update the jiti-isolation note:getParser = memoizeAsyncWithRetry(...)now lives inparser.ts, notbash-program.ts.
No README.md, schema, config example, or config-loader change — this step touches no user-facing command, config field, or output.
Test Impact Analysis
- New tests the extraction enables.
resolveNodeTextwas a file-private helper reachable only through the full bash gate pipeline (parse → walk → collect); it now has a public seam testable in isolation against hand-builtTSNodefakes — coveringword,raw_string(single-quote strip),string(double-quote strip + child concatenation, skipping"delimiters),concatenation,string_content/simple_expansion/expansion(literal passthrough), and thedefaultfallback, plus a nestedconcatenation-of-stringcase.getParsergains a smoke test: parsingecho hiyields a non-null root node, and twogetParser()calls return the identical memoized instance. - Existing tests that become redundant.
None.
No current test targets
resolveNodeTextorgetParserdirectly — they are exercised only transitively through the bash gate integration suites — so the new lower-level tests duplicate nothing that can be removed. - Existing tests that must stay as-is.
All bash integration suites (
bash-arity.test.ts,bash-external-directory.test.ts,detect-permissive-bash-fallback.test.ts,external-directory-*.test.ts) stay unchanged — they genuinely exercise the end-to-endBashProgrambehavior the parser and node-text resolver feed, and they are the regression net proving the move is behavior-preserving.
Invariants at risk
This step touches bash-program.ts, a surface prior phase steps already refactored.
Because the change is a pure symbol move with identical call shapes, the existing suite pins every invariant — no new test is required, but the full suite must stay green to prove preservation:
cd-fold cwd projection across redirect-then-pipe (#454) and effective-working-directory projection (#307) — pinned bybash-external-directory.test.tsexternalPathsprojection cases.- Fail-closed on an unparseable bash command → synthetic
askwith the<unparseable-bash-command>sentinel (#452, #301) — pinned bydetect-permissive-bash-fallback.test.ts. - cd-aware
pathRuleCandidateskeeping the literal form after a non-literalcd(#393) — pinned by the bash path-gate suites. - Quote-resolution behavior (e.g.
$HOMEreturned as the literal text of asimple_expansion, retro 0350) — now also pinned directly by the newnode-text.test.ts.
TDD Order
- Cycle 1 — extract the parser.
Create
src/access-intent/bash/parser.ts(moveTSNode,TSParser,initParser,getParser; exportTSNode+getParser, keepTSParser/initParserprivate). Rewirebash-program.ts: addimport { getParser, type TSNode } from "#src/access-intent/bash/parser", delete the moved block, and remove the now-deadcreateRequireandmemoizeAsyncWithRetryimports. Addtest/access-intent/bash/parser.test.ts(parseecho hi; assert memoization identity). Verify:pnpm run check,pnpm -r run test(or package-filtered),pnpm fallow dead-codeall green. Commit:refactor(pi-permission-system): extract tree-sitter parser to access-intent/bash/parser.ts (#473). - Cycle 2 — extract the node-text resolver.
Re-grep
bash-program.tsforresolveNodeText/SKIP_SUBTREE_TYPES(line numbers shifted after Cycle 1). Createsrc/access-intent/bash/node-text.ts(moveresolveNodeText+SKIP_SUBTREE_TYPES; type-importTSNodefromparser.ts). Rewirebash-program.ts: addimport { resolveNodeText, SKIP_SUBTREE_TYPES } from "#src/access-intent/bash/node-text"and delete the moved definitions. Addtest/access-intent/bash/node-text.test.ts(the quote-resolution cases from Test Impact Analysis #1). Verify: same gates green. Commit:refactor(pi-permission-system): extract bash node-text resolver to access-intent/bash/node-text.ts (#473). - Cycle 3 — documentation.
Apply the four
docs/architecture/architecture.mdedits and theSKILL.mdupdate from Module-Level Changes. Verify:pnpm run lint(rumdl) green. Commit:docs(pi-permission-system): record parser/node-text extraction in architecture and skill (#473).
Each cycle leaves the repository compiling and the full suite green — a lift-and-shift has no failing-red phase, so each cycle's "test" step is the new characterization test plus the unchanged regression suite.
Risks and Mitigations
- Risk: a call site keeps a stale local reference and the move silently drops a symbol.
Mitigation:
tsc(pnpm run check) fails on any unresolvedTSNode/getParser/resolveNodeText/SKIP_SUBTREE_TYPES; the full bash suite proves runtime equivalence. - Risk: the dead
createRequire/memoizeAsyncWithRetryimports linger and fail lint. Mitigation: Cycle 1 removes them in the same commit;pnpm run check+ eslint catch a miss immediately. - Risk: exporting
TSParserwith no importer trips fallow dead-code. Mitigation: keepTSParserandinitParsermodule-private inparser.ts; onlyTSNodeandgetParserare exported (both have importers). - Risk: stale doc/skill prose referencing the parser's old home.
Mitigation: Cycle 3 updates the architecture layout tree, the
async-cache.tsline, and theSKILL.mdjiti note; the grep in Module-Level Changes enumerated every reference. - Risk: the batch produces no release because all three steps are
refactor:. Mitigation: flagged in Release Recommendation for the Step 3 ship decision — out of scope to resolve here.
Open Questions
- None blocking.
The directory seed name (
src/access-intent/bash/) and theSKIP_SUBTREE_TYPESplacement (node-text.ts) are both fixed by the roadmap and the issue body.