@komarspn/pi-permission-system 16.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,1143 @@
1
+ import { createRequire } from "node:module";
2
+ import { basename, isAbsolute, join, resolve } from "node:path";
3
+ import { memoizeAsyncWithRetry } from "#src/async-cache";
4
+ import { canonicalizePath } from "#src/canonicalize-path";
5
+ import {
6
+ classifyTokenAsPathCandidate,
7
+ classifyTokenAsRuleCandidate,
8
+ } from "#src/handlers/gates/bash-token-classification";
9
+ import {
10
+ getPathPolicyValues,
11
+ isPathWithinDirectory,
12
+ isSafeSystemPath,
13
+ normalizePathForComparison,
14
+ normalizePathPolicyLiteral,
15
+ } from "#src/path-utils";
16
+ import type { BashCommandContext } from "#src/types";
17
+
18
+ // ── tree-sitter-bash lazy parser ───────────────────────────────────────────
19
+
20
+ /**
21
+ * Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
22
+ * Defined locally so callers do not need to import web-tree-sitter types.
23
+ */
24
+ interface TSNode {
25
+ readonly type: string;
26
+ readonly text: string;
27
+ readonly childCount: number;
28
+ /** False for anonymous tokens (operators, delimiters); true for named nodes. */
29
+ readonly isNamed: boolean;
30
+ child(index: number): TSNode | null;
31
+ }
32
+
33
+ /**
34
+ * Minimal subset of web-tree-sitter's Parser used by this module.
35
+ */
36
+ interface TSParser {
37
+ parse(input: string): { rootNode: TSNode; delete(): void } | null;
38
+ delete(): void;
39
+ }
40
+
41
+ async function initParser(): Promise<TSParser> {
42
+ // Use named imports — web-tree-sitter exports Parser as a named class.
43
+ const { Parser, Language } = await import("web-tree-sitter");
44
+ const req = createRequire(import.meta.url);
45
+ const treeSitterWasm = req.resolve("web-tree-sitter/web-tree-sitter.wasm");
46
+ await Parser.init({ locateFile: () => treeSitterWasm });
47
+
48
+ const parser = new Parser();
49
+ const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
50
+ const bash = await Language.load(bashWasm);
51
+ parser.setLanguage(bash);
52
+ return parser;
53
+ }
54
+
55
+ // Memoize on success but drop a rejected result so a transient init failure
56
+ // (e.g. a slow WASM load) is retried on the next tool call instead of poisoning
57
+ // the parser for the process lifetime.
58
+ const getParser = memoizeAsyncWithRetry(initParser);
59
+
60
+ // ── Parsed bash command representation ───────────────────────────────────────
61
+
62
+ /**
63
+ * One command-pattern unit of a parsed bash program.
64
+ *
65
+ * Minimal by design — `text` is the simple-command (or whole compound
66
+ * statement) string matched against the bash rules. The type is the stable
67
+ * extension point: #306 adds an execution `context`, #307 adds per-command
68
+ * path candidates and an effective working directory.
69
+ */
70
+ export interface BashCommand {
71
+ readonly text: string;
72
+ /**
73
+ * Execution context for a nested command (substitution or subshell); absent
74
+ * for a current-shell (top-level) command.
75
+ */
76
+ readonly context?: BashCommandContext;
77
+ }
78
+
79
+ /**
80
+ * The working directory in force where a path candidate appears.
81
+ *
82
+ * A `known` base carries an `offset` to be joined with `cwd` at resolution time
83
+ * (the parse-time walk never sees `cwd`): a relative-or-absolute path string
84
+ * built by folding the literal targets of current-shell `cd` commands (`""` =
85
+ * `cwd`); an absolute offset (from `cd /abs`) ignores `cwd` at resolution time.
86
+ * An `unknown` base marks a non-literal `cd` target (`cd "$DIR"`, `cd $(…)`,
87
+ * `cd -`, bare `cd`, `cd ~…`) that made the effective directory unresolvable.
88
+ */
89
+ type EffectiveBase =
90
+ | { readonly kind: "known"; readonly offset: string }
91
+ | { readonly kind: "unknown" };
92
+
93
+ /**
94
+ * A path-candidate token paired with the effective working directory projected
95
+ * onto the point in the command stream where it appears.
96
+ */
97
+ interface PathCandidate {
98
+ readonly token: string;
99
+ readonly base: EffectiveBase;
100
+ }
101
+
102
+ export interface BashPathRuleCandidate {
103
+ /** Raw path-like token shown in prompts, logs, and session approvals. */
104
+ readonly token: string;
105
+ /** Equivalent values used for permission policy matching. */
106
+ readonly policyValues: readonly string[];
107
+ }
108
+
109
+ /**
110
+ * A bash command parsed once into a reusable representation.
111
+ *
112
+ * Parsing is the expensive step (tree-sitter WASM); `BashProgram` performs it
113
+ * a single time and exposes typed slices derived from the same AST walk so the
114
+ * bash permission gates do not each re-parse and re-walk the command, and so
115
+ * the slices are guaranteed to agree.
116
+ *
117
+ * Construct via the async `parse()` factory; the constructor is private.
118
+ */
119
+ export class BashProgram {
120
+ private constructor(
121
+ private readonly rawCandidates: readonly PathCandidate[],
122
+ private readonly commandUnits: readonly BashCommand[],
123
+ ) {}
124
+
125
+ /**
126
+ * Parse a bash command into a `BashProgram`.
127
+ *
128
+ * Uses tree-sitter-bash to build the full AST and walks command-argument and
129
+ * redirect-destination nodes once into raw candidate tokens, each tagged with
130
+ * the effective working directory projected onto its position by folding
131
+ * current-shell `cd` commands. Heredoc bodies, comments, and other
132
+ * non-argument content are skipped. An unparseable command yields an empty
133
+ * program.
134
+ */
135
+ static async parse(command: string): Promise<BashProgram> {
136
+ const parser = await getParser();
137
+ const tree = parser.parse(command);
138
+ if (!tree) return new BashProgram([], []);
139
+
140
+ try {
141
+ const rawCandidates = collectPathCandidates(tree.rootNode);
142
+ const commandUnits = collectCommands(tree.rootNode);
143
+ return new BashProgram(rawCandidates, commandUnits);
144
+ } finally {
145
+ tree.delete();
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Path-rule candidates paired with their policy lookup values.
151
+ *
152
+ * When `cwd` is available, each relative token is resolved against the
153
+ * effective working directory in force at the token's position (folding
154
+ * literal current-shell `cd` commands), while raw and project-relative
155
+ * aliases are retained for backward-compatible relative rules. A token after
156
+ * a non-literal `cd` keeps only its literal value so no spurious absolute
157
+ * rule can match.
158
+ */
159
+ pathRuleCandidates(cwd?: string): BashPathRuleCandidate[] {
160
+ const seen = new Set<string>();
161
+ const result: BashPathRuleCandidate[] = [];
162
+
163
+ for (const { token, base } of this.rawCandidates) {
164
+ const candidate = classifyTokenAsRuleCandidate(token);
165
+ if (!candidate) continue;
166
+
167
+ const policyValues = getPolicyValuesForRuleCandidate(
168
+ candidate,
169
+ base,
170
+ cwd,
171
+ );
172
+ if (policyValues.length === 0) continue;
173
+
174
+ const key = policyValues.join("\0");
175
+ if (seen.has(key)) continue;
176
+ seen.add(key);
177
+ result.push({ token: candidate, policyValues });
178
+ }
179
+
180
+ return result;
181
+ }
182
+
183
+ /**
184
+ * The top-level command-pattern units of the chain, in source order.
185
+ *
186
+ * Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
187
+ * quotes, command substitution, and subshells are respected by the parser and
188
+ * are NOT split — a subshell or other compound statement is emitted whole. May
189
+ * be empty (e.g. an empty command or a comment-only line); callers fall back
190
+ * to the whole command so the surface is never evaluated weaker than before.
191
+ */
192
+ // Used by resolveBashCommandCheck (bash-command.ts) and tests. Fallow's
193
+ // syntactic analysis cannot resolve the static-factory return type (private
194
+ // ctor), so it reports a false positive here.
195
+ // fallow-ignore-next-line unused-class-member
196
+ commands(): BashCommand[] {
197
+ return [...this.commandUnits];
198
+ }
199
+
200
+ /**
201
+ * Deduplicated paths that resolve outside `cwd`, in their lexical (as-typed,
202
+ * normalized but not symlink-resolved) form.
203
+ *
204
+ * Each candidate is resolved against the effective working directory in force
205
+ * where it appears, projected by folding a sequence of current-shell `cd`
206
+ * commands (joined by `&&`, `||`, `;`, or a newline). A `cd` inside a
207
+ * pipeline or a backgrounded command runs in a subshell and does not update
208
+ * the running directory; a leading current-shell `cd` before a
209
+ * redirect-then-pipe (`cd a && pnpm x 2>&1 | tail`) folds because bash `|`
210
+ * binds tighter than `&&`/`||`/`;`, even though tree-sitter-bash groups the
211
+ * whole redirected list as the pipeline's first stage (#454).
212
+ *
213
+ * The outside-`cwd` decision and the dedup identity use the canonical
214
+ * (symlink-resolved) form, but the returned value is the lexical form so
215
+ * `external_directory` config patterns match the path as the user typed it
216
+ * (#418); the gate re-derives the canonical alias for matching.
217
+ */
218
+ externalPaths(cwd: string): string[] {
219
+ const normalizedCwd = canonicalizePath(
220
+ normalizePathForComparison(cwd, cwd),
221
+ );
222
+
223
+ const seen = new Set<string>();
224
+ const externalPaths: string[] = [];
225
+
226
+ for (const { token, base } of this.rawCandidates) {
227
+ const candidate = classifyTokenAsPathCandidate(token);
228
+ if (!candidate) continue;
229
+
230
+ // Unknown effective directory: a relative candidate could resolve
231
+ // anywhere, so flag it conservatively (resolving against `cwd` only for a
232
+ // display path). Absolute / `~` candidates are base-independent and
233
+ // resolve normally below.
234
+ if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
235
+ const lexical = normalizePathForComparison(candidate, cwd);
236
+ const canonical = canonicalizePath(lexical);
237
+ if (
238
+ canonical &&
239
+ normalizedCwd !== "" &&
240
+ !isSafeSystemPath(canonical) &&
241
+ !seen.has(canonical)
242
+ ) {
243
+ seen.add(canonical);
244
+ externalPaths.push(lexical);
245
+ }
246
+ continue;
247
+ }
248
+
249
+ const resolveBase =
250
+ base.kind === "known" ? resolve(cwd, base.offset) : cwd;
251
+ const lexical = normalizePathForComparison(candidate, resolveBase);
252
+ if (!lexical) continue;
253
+ // The boundary decision and dedup identity use the canonical
254
+ // (symlink-resolved) form, but the returned value is the lexical form so
255
+ // config patterns match the path as the user typed it (#418).
256
+ const canonical = canonicalizePath(lexical);
257
+
258
+ if (
259
+ normalizedCwd !== "" &&
260
+ !isSafeSystemPath(canonical) &&
261
+ !isPathWithinDirectory(canonical, normalizedCwd) &&
262
+ !seen.has(canonical)
263
+ ) {
264
+ seen.add(canonical);
265
+ externalPaths.push(lexical);
266
+ }
267
+ }
268
+
269
+ return externalPaths;
270
+ }
271
+ }
272
+
273
+ // ── AST walker ─────────────────────────────────────────────────────────────
274
+
275
+ /**
276
+ * Node types whose subtrees must never be descended into for
277
+ * path extraction — their text content is not a command argument.
278
+ */
279
+ const SKIP_SUBTREE_TYPES = new Set(["heredoc_body", "heredoc_end", "comment"]);
280
+
281
+ /**
282
+ * Resolve the "shell value" of an argument node — the string the shell
283
+ * would pass to the command after quote removal.
284
+ *
285
+ * - `word` → `.text` (already unquoted)
286
+ * - `raw_string` → strip surrounding single quotes
287
+ * - `string` → strip surrounding double quotes, concatenate children text
288
+ * - `concatenation` → concatenate resolved children
289
+ * - other → `.text` as fallback
290
+ */
291
+ function resolveNodeText(node: TSNode): string {
292
+ switch (node.type) {
293
+ case "word":
294
+ return node.text;
295
+ case "raw_string": {
296
+ // Strip surrounding single quotes: 'content' → content
297
+ const t = node.text;
298
+ if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) {
299
+ return t.slice(1, -1);
300
+ }
301
+ return t;
302
+ }
303
+ case "string": {
304
+ // Double-quoted string: concatenate the resolved text of inner children,
305
+ // skipping the quote-delimiter nodes (literal `"`).
306
+ let result = "";
307
+ for (let i = 0; i < node.childCount; i++) {
308
+ const child = node.child(i);
309
+ if (!child) continue;
310
+ // Skip the literal `"` delimiters
311
+ if (child.type === '"') continue;
312
+ result += resolveNodeText(child);
313
+ }
314
+ return result;
315
+ }
316
+ case "string_content":
317
+ case "simple_expansion":
318
+ case "expansion":
319
+ return node.text;
320
+ case "concatenation": {
321
+ let result = "";
322
+ for (let i = 0; i < node.childCount; i++) {
323
+ const child = node.child(i);
324
+ if (!child) continue;
325
+ result += resolveNodeText(child);
326
+ }
327
+ return result;
328
+ }
329
+ default:
330
+ return node.text;
331
+ }
332
+ }
333
+
334
+ // ── Pattern-first command config ───────────────────────────────────────────
335
+
336
+ interface PatternCommandConfig {
337
+ /** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
338
+ readonly argConsumingFlags: ReadonlySet<string>;
339
+ /** Flags that consume the next argument as a file path */
340
+ readonly fileConsumingFlags: ReadonlySet<string>;
341
+ /**
342
+ * Number of leading positional arguments that are patterns/scripts, not paths.
343
+ * Default: 1 (covers sed, awk, grep, rg).
344
+ * sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
345
+ */
346
+ readonly patternPositionals?: number;
347
+ }
348
+
349
+ /**
350
+ * Commands whose first N positional arguments are inline patterns/scripts,
351
+ * not filesystem paths. The map stores per-command flag configuration so
352
+ * the walker can correctly identify which arguments are consumed by flags
353
+ * vs. which are positional.
354
+ */
355
+ const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
356
+ new Map([
357
+ [
358
+ "sed",
359
+ {
360
+ argConsumingFlags: new Set(["-e", "-i"]),
361
+ fileConsumingFlags: new Set(["-f"]),
362
+ },
363
+ ],
364
+ [
365
+ "awk",
366
+ {
367
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
368
+ fileConsumingFlags: new Set(["-f"]),
369
+ },
370
+ ],
371
+ [
372
+ "gawk",
373
+ {
374
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
375
+ fileConsumingFlags: new Set(["-f"]),
376
+ },
377
+ ],
378
+ [
379
+ "nawk",
380
+ {
381
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
382
+ fileConsumingFlags: new Set(["-f"]),
383
+ },
384
+ ],
385
+ [
386
+ "grep",
387
+ {
388
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
389
+ fileConsumingFlags: new Set(["-f"]),
390
+ },
391
+ ],
392
+ [
393
+ "egrep",
394
+ {
395
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
396
+ fileConsumingFlags: new Set(["-f"]),
397
+ },
398
+ ],
399
+ [
400
+ "fgrep",
401
+ {
402
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
403
+ fileConsumingFlags: new Set(["-f"]),
404
+ },
405
+ ],
406
+ [
407
+ "rg",
408
+ {
409
+ argConsumingFlags: new Set([
410
+ "-e",
411
+ "-A",
412
+ "-B",
413
+ "-C",
414
+ "-m",
415
+ "-g",
416
+ "-t",
417
+ "-T",
418
+ "-j",
419
+ "-M",
420
+ "-r",
421
+ "-E",
422
+ ]),
423
+ fileConsumingFlags: new Set(["-f"]),
424
+ },
425
+ ],
426
+ [
427
+ "sd",
428
+ {
429
+ argConsumingFlags: new Set(["-n", "-f"]),
430
+ fileConsumingFlags: new Set([]),
431
+ patternPositionals: 2,
432
+ },
433
+ ],
434
+ ]);
435
+
436
+ /** Node types that represent argument values in the AST. */
437
+ const ARG_NODE_TYPES = new Set([
438
+ "word",
439
+ "concatenation",
440
+ "string",
441
+ "raw_string",
442
+ ]);
443
+
444
+ /**
445
+ * Extract the command name from a `command` node.
446
+ * Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
447
+ * if the command name cannot be determined (e.g. variable expansion).
448
+ */
449
+ function extractCommandName(node: TSNode): string | undefined {
450
+ for (let i = 0; i < node.childCount; i++) {
451
+ const child = node.child(i);
452
+ if (!child) continue;
453
+ if (child.type === "command_name") {
454
+ const text = resolveNodeText(child);
455
+ return text ? basename(text) : undefined;
456
+ }
457
+ }
458
+ return undefined;
459
+ }
460
+
461
+ /**
462
+ * Describes what the walker should do when it encounters a flag word inside
463
+ * a pattern-first command. Using a discriminated union lets the `switch` in
464
+ * `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
465
+ * assertion (which would trigger the Biome/ESLint assertion conflict).
466
+ */
467
+ type PatternCommandFlagDirective =
468
+ | { kind: "end-of-flags" }
469
+ | { kind: "regular-flag" }
470
+ | {
471
+ kind: "consume-arg";
472
+ nextArgAction: "skip" | "extract";
473
+ setsExplicitScript: boolean;
474
+ };
475
+
476
+ /**
477
+ * Classify a flag word from a pattern-first command into a directive that
478
+ * tells the walker how to handle the flag and its following argument.
479
+ */
480
+ function classifyPatternCommandFlag(
481
+ text: string,
482
+ config: PatternCommandConfig,
483
+ ): PatternCommandFlagDirective {
484
+ if (text === "--") return { kind: "end-of-flags" };
485
+ if (config.argConsumingFlags.has(text)) {
486
+ return {
487
+ kind: "consume-arg",
488
+ nextArgAction: "skip",
489
+ setsExplicitScript: text === "-e" || text === "-f",
490
+ };
491
+ }
492
+ if (config.fileConsumingFlags.has(text)) {
493
+ return {
494
+ kind: "consume-arg",
495
+ nextArgAction: "extract",
496
+ setsExplicitScript: true,
497
+ };
498
+ }
499
+ return { kind: "regular-flag" };
500
+ }
501
+
502
+ /**
503
+ * Collect path-candidate tokens from a command known to have
504
+ * pattern/script arguments in leading positional slots.
505
+ *
506
+ * Uses position-based skipping: the first N positional arguments
507
+ * (where N = patternPositionals, default 1) are assumed to be
508
+ * inline patterns/scripts and are skipped. Remaining positional
509
+ * arguments are collected as path candidates.
510
+ *
511
+ * Flags listed in `argConsumingFlags` consume the next argument
512
+ * (skipped). Flags in `fileConsumingFlags` consume the next
513
+ * argument as a file path (collected). The flags `-e` and `-f`
514
+ * additionally signal that an explicit script was provided via
515
+ * flag, so no inline positional script is expected.
516
+ */
517
+ function collectPatternCommandTokens(
518
+ node: TSNode,
519
+ config: PatternCommandConfig,
520
+ ): string[] {
521
+ const patternPositionals = config.patternPositionals ?? 1;
522
+ let hasExplicitScript = false;
523
+ let positionalsSeen = 0;
524
+ let nextArgAction: "skip" | "extract" | null = null;
525
+ let pastEndOfFlags = false;
526
+ const tokens: string[] = [];
527
+
528
+ for (let i = 0; i < node.childCount; i++) {
529
+ const child = node.child(i);
530
+ if (!child) continue;
531
+
532
+ // Skip command_name and variable_assignment nodes.
533
+ if (child.type === "command_name" || child.type === "variable_assignment")
534
+ continue;
535
+
536
+ // Only process argument-like nodes; recurse into others
537
+ // (e.g. command_substitution) for nested commands.
538
+ if (!ARG_NODE_TYPES.has(child.type)) {
539
+ tokens.push(...collectPathCandidateTokens(child));
540
+ continue;
541
+ }
542
+
543
+ const text = resolveNodeText(child);
544
+
545
+ // Handle consumed argument from previous flag.
546
+ if (nextArgAction === "skip") {
547
+ nextArgAction = null;
548
+ continue;
549
+ }
550
+ if (nextArgAction === "extract") {
551
+ tokens.push(text);
552
+ nextArgAction = null;
553
+ continue;
554
+ }
555
+
556
+ // Flag detection (only before "--" end-of-flags marker).
557
+ if (
558
+ !pastEndOfFlags &&
559
+ child.type === "word" &&
560
+ text.startsWith("-") &&
561
+ text.length > 1
562
+ ) {
563
+ const directive = classifyPatternCommandFlag(text, config);
564
+ switch (directive.kind) {
565
+ case "end-of-flags":
566
+ pastEndOfFlags = true;
567
+ break;
568
+ case "consume-arg":
569
+ nextArgAction = directive.nextArgAction;
570
+ if (directive.setsExplicitScript) hasExplicitScript = true;
571
+ break;
572
+ case "regular-flag":
573
+ break;
574
+ }
575
+ continue;
576
+ }
577
+
578
+ // Positional argument.
579
+ if (!hasExplicitScript && positionalsSeen < patternPositionals) {
580
+ positionalsSeen++;
581
+ continue; // Skip: this is an inline pattern/script.
582
+ }
583
+
584
+ // File argument — collect as path candidate.
585
+ tokens.push(text);
586
+ }
587
+
588
+ return tokens;
589
+ }
590
+
591
+ /**
592
+ * Collect all argument tokens from a generic (non-pattern-first) command node,
593
+ * skipping the command name and variable assignments.
594
+ */
595
+ function collectGenericCommandTokens(node: TSNode): string[] {
596
+ const tokens: string[] = [];
597
+ let seenCommandName = false;
598
+
599
+ for (let i = 0; i < node.childCount; i++) {
600
+ const child = node.child(i);
601
+ if (!child) continue;
602
+
603
+ if (child.type === "command_name") {
604
+ seenCommandName = true;
605
+ continue;
606
+ }
607
+ // Skip variable_assignment nodes (FOO=/bar)
608
+ if (child.type === "variable_assignment") continue;
609
+
610
+ // If there was no explicit command_name node, the first word-like
611
+ // child is the command name itself — skip it.
612
+ if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
613
+ seenCommandName = true;
614
+ continue;
615
+ }
616
+
617
+ // Argument nodes: resolve their text and collect.
618
+ if (ARG_NODE_TYPES.has(child.type)) {
619
+ tokens.push(resolveNodeText(child));
620
+ continue;
621
+ }
622
+
623
+ // Recurse into other children (e.g. command_substitution nested in args)
624
+ tokens.push(...collectPathCandidateTokens(child));
625
+ }
626
+
627
+ return tokens;
628
+ }
629
+
630
+ /**
631
+ * Collect redirect-destination tokens from a `file_redirect` node.
632
+ */
633
+ function collectRedirectTokens(node: TSNode): string[] {
634
+ const tokens: string[] = [];
635
+ for (let i = 0; i < node.childCount; i++) {
636
+ const child = node.child(i);
637
+ if (!child) continue;
638
+ if (ARG_NODE_TYPES.has(child.type)) {
639
+ tokens.push(resolveNodeText(child));
640
+ }
641
+ }
642
+ return tokens;
643
+ }
644
+
645
+ /**
646
+ * Select the collection strategy for a `command` node: pattern-first
647
+ * commands use `collectPatternCommandTokens`; all others use
648
+ * `collectGenericCommandTokens`.
649
+ */
650
+ function collectCommandTokens(node: TSNode): string[] {
651
+ const commandName = extractCommandName(node);
652
+ const config = commandName
653
+ ? PATTERN_FIRST_COMMANDS.get(commandName)
654
+ : undefined;
655
+ return config
656
+ ? collectPatternCommandTokens(node, config)
657
+ : collectGenericCommandTokens(node);
658
+ }
659
+
660
+ /**
661
+ * Recursively visit the AST and collect resolved text of nodes that
662
+ * represent command arguments or redirect destinations.
663
+ *
664
+ * Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
665
+ *
666
+ * For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
667
+ * argument skipping to avoid collecting inline patterns/scripts
668
+ * as path candidates. For all other commands, collects all
669
+ * arguments generically.
670
+ */
671
+ function collectPathCandidateTokens(node: TSNode): string[] {
672
+ if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
673
+ if (node.type === "command") return collectCommandTokens(node);
674
+ if (node.type === "file_redirect") return collectRedirectTokens(node);
675
+
676
+ const tokens: string[] = [];
677
+ for (let i = 0; i < node.childCount; i++) {
678
+ const child = node.child(i);
679
+ if (child) tokens.push(...collectPathCandidateTokens(child));
680
+ }
681
+ return tokens;
682
+ }
683
+
684
+ // Token classification is delegated to bash-token-classification.ts,
685
+ // which exports classifyTokenAsPathCandidate and classifyTokenAsRuleCandidate
686
+ // with a shared rejectNonPathToken predicate eliminating the prior clone.
687
+
688
+ // ── Command enumeration ──────────────────────────────────────────────────────
689
+
690
+ /**
691
+ * Container node types descended into when enumerating command units.
692
+ */
693
+ const COMMAND_ENUM_DESCEND = new Set([
694
+ "program",
695
+ "list",
696
+ "pipeline",
697
+ "redirected_statement",
698
+ ]);
699
+
700
+ /**
701
+ * Named node types skipped during command enumeration: redirect targets,
702
+ * comments, and heredoc bodies — none is a command to evaluate. Anonymous
703
+ * tokens (chain operators `&&`/`;`/`|`, substitution and subshell delimiters
704
+ * `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not listed here.
705
+ */
706
+ const COMMAND_ENUM_SKIP = new Set([
707
+ "file_redirect",
708
+ "heredoc_redirect",
709
+ "herestring_redirect",
710
+ "comment",
711
+ "heredoc_body",
712
+ "heredoc_end",
713
+ ]);
714
+
715
+ /**
716
+ * Nested execution contexts whose interior commands really execute and must be
717
+ * evaluated too: command substitution (`$(…)`, backticks) and process
718
+ * substitution (`<(…)`/`>(…)`). Subshells (`( … )`) are handled separately
719
+ * because they are also emitted whole.
720
+ */
721
+ const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
722
+ ["command_substitution", "command_substitution"],
723
+ ["process_substitution", "process_substitution"],
724
+ ]);
725
+
726
+ /**
727
+ * Enumerate the command units of a bash program, in source order.
728
+ *
729
+ * Descends container nodes (`program`, `list`, `pipeline`, `redirected_statement`)
730
+ * and emits each `command` node whole. Additionally descends into the three
731
+ * nested execution contexts — command substitution (`$(…)`, backticks), process
732
+ * substitution (`<(…)`/`>(…)`), and subshells (`( … )`) — emitting each inner
733
+ * command as its own unit *in addition to* the enclosing command, since those
734
+ * inner commands really execute (#306). Control-flow bodies and `{ … }` brace
735
+ * groups are emitted whole without descending (deferred).
736
+ *
737
+ * The enclosing command/subshell is always still emitted whole, so adding the
738
+ * nested units can only ever produce a more-restrictive decision, never weaker.
739
+ */
740
+ function collectCommands(node: TSNode): BashCommand[] {
741
+ const out: BashCommand[] = [];
742
+ collectCommandsInto(node, undefined, out);
743
+ return out;
744
+ }
745
+
746
+ function collectCommandsInto(
747
+ node: TSNode,
748
+ context: BashCommandContext | undefined,
749
+ out: BashCommand[],
750
+ ): void {
751
+ // Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
752
+ // carry no command.
753
+ if (!node.isNamed) return;
754
+ if (COMMAND_ENUM_SKIP.has(node.type)) return;
755
+
756
+ if (node.type === "command") {
757
+ out.push(makeUnit(node.text, context));
758
+ // A command's text already contains any substitution; descend its subtree
759
+ // to ALSO emit the inner commands of command/process substitutions.
760
+ collectSubstitutionCommands(node, out);
761
+ return;
762
+ }
763
+
764
+ if (node.type === "subshell") {
765
+ out.push(makeUnit(node.text, context)); // never-weaker whole emit
766
+ descendCommandChildren(node, "subshell", out);
767
+ return;
768
+ }
769
+
770
+ if (COMMAND_ENUM_DESCEND.has(node.type)) {
771
+ descendCommandChildren(node, context, out);
772
+ return;
773
+ }
774
+
775
+ // Any other named statement (compound_statement `{ … }`, if/while/for/case,
776
+ // function_definition): emit whole, do not descend — deferred (#306).
777
+ out.push(makeUnit(node.text, context));
778
+ }
779
+
780
+ function makeUnit(
781
+ text: string,
782
+ context: BashCommandContext | undefined,
783
+ ): BashCommand {
784
+ return context ? { text, context } : { text };
785
+ }
786
+
787
+ function descendCommandChildren(
788
+ node: TSNode,
789
+ context: BashCommandContext | undefined,
790
+ out: BashCommand[],
791
+ ): void {
792
+ for (let i = 0; i < node.childCount; i++) {
793
+ const child = node.child(i);
794
+ if (child) collectCommandsInto(child, context, out);
795
+ }
796
+ }
797
+
798
+ /**
799
+ * Search a command's subtree for command/process substitutions and enumerate
800
+ * the commands inside them, tagged with the substitution's execution context.
801
+ * A substitution can nest under `command_name` (when the whole command is
802
+ * `$(…)`) or under an argument, so the entire subtree is searched.
803
+ */
804
+ function collectSubstitutionCommands(node: TSNode, out: BashCommand[]): void {
805
+ for (let i = 0; i < node.childCount; i++) {
806
+ const child = node.child(i);
807
+ if (!child) continue;
808
+ const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
809
+ if (nestedContext) {
810
+ descendCommandChildren(child, nestedContext, out);
811
+ } else {
812
+ collectSubstitutionCommands(child, out);
813
+ }
814
+ }
815
+ }
816
+
817
+ // ── Effective working directory projection ─────────────────────────────────
818
+
819
+ /** The working directory in force at the start of a program (`cwd`). */
820
+ const CWD_BASE: EffectiveBase = { kind: "known", offset: "" };
821
+
822
+ /** The effective directory after a non-literal or unresolvable `cd`. */
823
+ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
824
+
825
+ /**
826
+ * Walk the AST once, collecting every path-candidate token tagged with the
827
+ * effective working directory projected onto its position.
828
+ *
829
+ * The effective directory is stateful: it starts at `cwd` and each current-shell
830
+ * `cd <literal>` (joined by `&&`, `||`, `;`, or a newline) folds into it for
831
+ * subsequent commands. A `cd` inside a pipeline or a backgrounded command runs
832
+ * in a subshell and does not update the running directory; subshell and
833
+ * brace-group interiors inherit the enclosing base without folding their own
834
+ * `cd`s (a conservative first tier).
835
+ */
836
+ function collectPathCandidates(rootNode: TSNode): PathCandidate[] {
837
+ const out: PathCandidate[] = [];
838
+ walkForCandidates(rootNode, CWD_BASE, out);
839
+ return out;
840
+ }
841
+
842
+ /**
843
+ * Collect a single node's candidates tagged with `base`, returning the
844
+ * effective base in force *after* the node (the input base unless the node is a
845
+ * current-shell `cd <literal>` that folds the running directory).
846
+ */
847
+ function walkForCandidates(
848
+ node: TSNode,
849
+ base: EffectiveBase,
850
+ out: PathCandidate[],
851
+ ): EffectiveBase {
852
+ switch (node.type) {
853
+ case "program":
854
+ case "list":
855
+ case "redirected_statement":
856
+ return walkCurrentShellSequence(node, base, out);
857
+ case "command":
858
+ tagTokens(collectCommandTokens(node), base, out);
859
+ return foldCd(node, base);
860
+ case "pipeline":
861
+ // tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the
862
+ // first stage of a pipeline (`cd a && pnpm x 2>&1 | tail` parses as
863
+ // `(cd a && pnpm x 2>&1) | tail`), burying a current-shell `cd` inside a
864
+ // node the `default` case treats as non-folding. Recover bash operator
865
+ // precedence (`|` binds tighter than `&&`/`||`/`;`): fold the first
866
+ // stage's leading current-shell commands while keeping its terminal
867
+ // command and every downstream stage as non-folding subshells (#454).
868
+ return walkPipeline(node, base, out);
869
+ case "subshell":
870
+ // A subshell runs in a child shell: its interior `cd`s fold within the
871
+ // subshell but reset on exit, so the folded base is discarded.
872
+ walkCurrentShellSequence(node, base, out);
873
+ return base;
874
+ case "compound_statement":
875
+ // A `{ … }` brace group runs in the current shell, so its `cd`s persist
876
+ // to following commands — thread and return the folded base.
877
+ return walkCurrentShellSequence(node, base, out);
878
+ default:
879
+ // Pipelines, control-flow bodies, redirect targets, and command/process
880
+ // substitution interiors: collect every candidate in the subtree tagged
881
+ // with the enclosing base and do not fold their internal `cd`s. (Folding
882
+ // inside substitutions is deferred — conservative, never under-flags.)
883
+ tagTokens(collectPathCandidateTokens(node), base, out);
884
+ return base;
885
+ }
886
+ }
887
+
888
+ /**
889
+ * Fold a current-shell sequence (`program` / `list` / `redirected_statement`):
890
+ * thread the effective base left-to-right through the children so a `cd` updates
891
+ * the base for following siblings. A statement immediately followed by the
892
+ * background operator (`&`) runs in a subshell, so its folded base is discarded.
893
+ */
894
+ function walkCurrentShellSequence(
895
+ seqNode: TSNode,
896
+ base: EffectiveBase,
897
+ out: PathCandidate[],
898
+ ): EffectiveBase {
899
+ let current = base;
900
+ for (let i = 0; i < seqNode.childCount; i++) {
901
+ const child = seqNode.child(i);
902
+ if (!child?.isNamed) continue;
903
+ if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
904
+ const after = walkForCandidates(child, current, out);
905
+ current = isBackgrounded(seqNode, i) ? current : after;
906
+ }
907
+ return current;
908
+ }
909
+
910
+ /**
911
+ * Walk a `pipeline` node, returning the effective base in force after it.
912
+ *
913
+ * Each stage of a true pipeline (`A | B | C`) runs in a subshell, so a `cd`
914
+ * inside any stage must not leak — the base normally passes through unchanged.
915
+ * The exception is the first stage: tree-sitter-bash wraps a redirect-bearing
916
+ * current-shell `&&`/`;` list (`cd a && pnpm x 2>&1 | tail`) as that stage, and
917
+ * bash precedence makes the list's leading commands current-shell, so they fold
918
+ * and the folded base persists past the pipeline to following siblings.
919
+ *
920
+ * The terminal command of the first stage is the real pipe stage (a subshell)
921
+ * and must not fold; every stage after a `|` is a downstream subshell stage and
922
+ * collects tokens against the folded base without folding (#454).
923
+ */
924
+ function walkPipeline(
925
+ node: TSNode,
926
+ base: EffectiveBase,
927
+ out: PathCandidate[],
928
+ ): EffectiveBase {
929
+ let current = base;
930
+ let first = true;
931
+ for (let i = 0; i < node.childCount; i++) {
932
+ const child = node.child(i);
933
+ if (!child?.isNamed) continue;
934
+ if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
935
+ if (first) {
936
+ current = foldPipelineFirstStage(child, current, out);
937
+ first = false;
938
+ continue;
939
+ }
940
+ // Downstream stage (after a `|`): subshell — collect against the folded
941
+ // base, do not fold.
942
+ tagTokens(collectPathCandidateTokens(child), current, out);
943
+ }
944
+ return current;
945
+ }
946
+
947
+ /**
948
+ * Collect the first pipe stage's candidates, folding its leading current-shell
949
+ * `cd` commands when tree-sitter wrapped a `list` or `redirected_statement`
950
+ * around them. The terminal command of that container is the real pipe stage (a
951
+ * subshell) and is collected without folding. A bare `command` first stage (a
952
+ * true pipeline first stage such as `cd nested | cat ../b`) is a subshell: it
953
+ * collects against the input base and does not fold.
954
+ */
955
+ function foldPipelineFirstStage(
956
+ node: TSNode,
957
+ base: EffectiveBase,
958
+ out: PathCandidate[],
959
+ ): EffectiveBase {
960
+ if (node.type === "list") return foldListExceptTerminal(node, base, out);
961
+ if (node.type === "redirected_statement") {
962
+ let current = base;
963
+ for (let i = 0; i < node.childCount; i++) {
964
+ const child = node.child(i);
965
+ if (!child?.isNamed) continue;
966
+ if (child.type === "file_redirect") {
967
+ // Redirect destinations are part of the piped stage; collect them
968
+ // against the folded base without folding.
969
+ tagTokens(collectRedirectTokens(child), current, out);
970
+ continue;
971
+ }
972
+ // The inner statement is the `list`/`command` being redirected; fold its
973
+ // leading current-shell commands via the terminal-excluding walk.
974
+ current = foldPipelineFirstStage(child, current, out);
975
+ }
976
+ return current;
977
+ }
978
+ // Bare `command` or any other shape: a true subshell first stage.
979
+ tagTokens(collectPathCandidateTokens(node), base, out);
980
+ return base;
981
+ }
982
+
983
+ /**
984
+ * Fold every named, non-skip child of a `list` except the last, threading the
985
+ * effective base left-to-right through the leading current-shell commands; the
986
+ * terminal child is the real pipe stage and is collected without folding.
987
+ */
988
+ function foldListExceptTerminal(
989
+ node: TSNode,
990
+ base: EffectiveBase,
991
+ out: PathCandidate[],
992
+ ): EffectiveBase {
993
+ const namedChildren: TSNode[] = [];
994
+ for (let i = 0; i < node.childCount; i++) {
995
+ const child = node.child(i);
996
+ if (child?.isNamed && !SKIP_SUBTREE_TYPES.has(child.type)) {
997
+ namedChildren.push(child);
998
+ }
999
+ }
1000
+ let current = base;
1001
+ for (let i = 0; i < namedChildren.length; i++) {
1002
+ const child = namedChildren[i];
1003
+ if (i < namedChildren.length - 1) {
1004
+ current = walkForCandidates(child, current, out);
1005
+ } else {
1006
+ // Terminal child = the real pipe stage; collect without folding.
1007
+ tagTokens(collectPathCandidateTokens(child), current, out);
1008
+ }
1009
+ }
1010
+ return current;
1011
+ }
1012
+
1013
+ /**
1014
+ * True when the statement at `index` is immediately followed by the background
1015
+ * operator (`&`) — distinct from the `&&` / `||` / `;` current-shell separators.
1016
+ */
1017
+ function isBackgrounded(seqNode: TSNode, index: number): boolean {
1018
+ const next = seqNode.child(index + 1);
1019
+ if (!next || next.isNamed) return false;
1020
+ return next.type === "&";
1021
+ }
1022
+
1023
+ function tagTokens(
1024
+ tokens: readonly string[],
1025
+ base: EffectiveBase,
1026
+ out: PathCandidate[],
1027
+ ): void {
1028
+ for (const token of tokens) out.push({ token, base });
1029
+ }
1030
+
1031
+ /**
1032
+ * True when a path candidate is relative (resolved against the effective
1033
+ * directory) rather than absolute (`/…`) or home-relative (`~…`), which are
1034
+ * base-independent. Used to decide which candidates an unknown base affects.
1035
+ */
1036
+ function isRelativeCandidate(candidate: string): boolean {
1037
+ return !candidate.startsWith("/") && !candidate.startsWith("~");
1038
+ }
1039
+
1040
+ function getPolicyValuesForRuleCandidate(
1041
+ candidate: string,
1042
+ base: EffectiveBase,
1043
+ cwd: string | undefined,
1044
+ ): string[] {
1045
+ if (!cwd) {
1046
+ const literal = normalizePathPolicyLiteral(candidate);
1047
+ return literal ? [literal] : [];
1048
+ }
1049
+
1050
+ if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
1051
+ const literal = normalizePathPolicyLiteral(candidate);
1052
+ return literal ? [literal] : [];
1053
+ }
1054
+
1055
+ const resolveBase = base.kind === "known" ? resolve(cwd, base.offset) : cwd;
1056
+ return getPathPolicyValues(candidate, { cwd, resolveBase });
1057
+ }
1058
+
1059
+ /**
1060
+ * Compute the effective base after a command runs. Returns `base` unchanged
1061
+ * unless the command is `cd`:
1062
+ *
1063
+ * - `cd /abs` (absolute literal) → a fresh known base, recovering from an
1064
+ * earlier unknown base.
1065
+ * - `cd rel` (relative literal) → fold into a known base, or stay unknown if the
1066
+ * base was already unknown.
1067
+ * - `cd "$DIR"` / `cd $(…)` / `cd -` / bare `cd` / `cd ~…` (non-literal) →
1068
+ * unknown.
1069
+ */
1070
+ function foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
1071
+ if (extractCommandName(commandNode) !== "cd") return base;
1072
+ const target = cdLiteralTarget(commandNode);
1073
+ if (target === null) return UNKNOWN_BASE;
1074
+ if (isAbsolute(target)) return { kind: "known", offset: target };
1075
+ if (base.kind === "unknown") return UNKNOWN_BASE;
1076
+ return { kind: "known", offset: join(base.offset, target) };
1077
+ }
1078
+
1079
+ /**
1080
+ * Resolve the literal target of a `cd` command, or `null` when the first
1081
+ * argument is not a static literal (contains an expansion or command
1082
+ * substitution) or cannot be resolved against the working directory (`cd -`,
1083
+ * `cd ~…`, bare `cd`).
1084
+ */
1085
+ function cdLiteralTarget(commandNode: TSNode): string | null {
1086
+ for (let i = 0; i < commandNode.childCount; i++) {
1087
+ const child = commandNode.child(i);
1088
+ if (!child) continue;
1089
+ if (child.type === "command_name" || child.type === "variable_assignment")
1090
+ continue;
1091
+ if (!child.isNamed) continue;
1092
+ // Skip the `--` end-of-flags marker; the next argument is the target.
1093
+ if (child.type === "word" && child.text === "--") continue;
1094
+ if (!ARG_NODE_TYPES.has(child.type)) return null;
1095
+ return literalTextOf(child);
1096
+ }
1097
+ return null;
1098
+ }
1099
+
1100
+ /**
1101
+ * The literal string value of an argument node, or `null` when it contains a
1102
+ * variable expansion / command substitution or is a non-resolvable `cd`
1103
+ * destination (`-`, `~…`).
1104
+ */
1105
+ function literalTextOf(node: TSNode): string | null {
1106
+ switch (node.type) {
1107
+ case "word": {
1108
+ const text = node.text;
1109
+ if (text === "-" || text.startsWith("~")) return null;
1110
+ return text;
1111
+ }
1112
+ case "raw_string": {
1113
+ const text = node.text;
1114
+ return text.length >= 2 && text.startsWith("'") && text.endsWith("'")
1115
+ ? text.slice(1, -1)
1116
+ : text;
1117
+ }
1118
+ case "concatenation": {
1119
+ let result = "";
1120
+ for (let i = 0; i < node.childCount; i++) {
1121
+ const child = node.child(i);
1122
+ if (!child) continue;
1123
+ const part = literalTextOf(child);
1124
+ if (part === null) return null;
1125
+ result += part;
1126
+ }
1127
+ return result;
1128
+ }
1129
+ case "string": {
1130
+ let result = "";
1131
+ for (let i = 0; i < node.childCount; i++) {
1132
+ const child = node.child(i);
1133
+ if (!child) continue;
1134
+ if (child.type === '"') continue;
1135
+ if (child.type !== "string_content") return null;
1136
+ result += child.text;
1137
+ }
1138
+ return result;
1139
+ }
1140
+ default:
1141
+ return null;
1142
+ }
1143
+ }