@gotgenes/pi-permission-system 36.2.0 → 37.0.0

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.
@@ -17,24 +17,98 @@
17
17
  * See `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
18
18
  */
19
19
  import { homedir } from "node:os";
20
+ import { hasHomePrefix } from "#src/path/expand-home";
20
21
 
21
22
  import type { TSNode } from "./parser";
22
23
 
23
24
  /**
24
- * The value of a plain `$NAME` / `${NAME}` reference, or `null` when the node
25
- * is not a plain reference or names a variable outside the resolvable set.
25
+ * The resolvable variables as one program sees them: which of them the program
26
+ * rebinds, so that a reference to a rebound one no longer reads as its startup
27
+ * value.
26
28
  *
27
- * Plainness is decided structurally, not by matching the node's text: a plain
28
- * reference carries exactly one `variable_name` child and nothing else but
29
- * delimiters. An operator form (`${HOME:-/tmp}`, `${#HOME}`, `${HOME%/*}`)
30
- * carries additional children and is therefore rejected without this module
31
- * needing to enumerate bash's expansion operators.
29
+ * A closed set of rebound names, never their values — tracking what a program
30
+ * assigns is the dataflow ADR 0009 declines.
32
31
  */
33
- export function resolvePlainVariableExpansion(node: TSNode): string | null {
34
- const name = plainVariableName(node);
35
- return name === null ? null : (RESOLVABLE_VARIABLES.get(name)?.() ?? null);
32
+ export class ShellVariables {
33
+ /** A program that rebinds none of the resolvable variables. */
34
+ static readonly UNREBOUND = new ShellVariables(new Set());
35
+
36
+ /**
37
+ * The resolvable variables `roots` rebind: the primary parse and every
38
+ * salvaged region, since an assignment in one governs a reference in another.
39
+ *
40
+ * A resolvable name rebinds when a `variable_name` carries it anywhere but as
41
+ * a plain reference's name: an assignment (a prefix one too), a declaration, a
42
+ * `for` variable, `unset`, an arithmetic assignment, `${HOME:=x}`. It also
43
+ * rebinds when a name-binding builtin is handed it as an argument (`read`,
44
+ * `printf -v`, `let`, a quoted `export "HOME=…"`), and both rebind under a
45
+ * command running code the walk never parses (`eval`, `source`, `.`, `trap`). Position is
46
+ * ignored, because a loop or a function body can run a later assignment
47
+ * first; a prefix assignment and an operator read (`${HOME:-x}`) count
48
+ * although neither rebinds the current shell, which costs only the
49
+ * projection of a program already spelling the name oddly.
50
+ */
51
+ static scan(roots: readonly TSNode[]): ShellVariables {
52
+ const rebound = new Set<string>();
53
+ for (const root of roots) collectRebound(root, rebound, false);
54
+ return rebound.size === 0
55
+ ? ShellVariables.UNREBOUND
56
+ : new ShellVariables(rebound);
57
+ }
58
+
59
+ private constructor(private readonly rebound: ReadonlySet<string>) {}
60
+
61
+ /**
62
+ * The value of a plain `$NAME` / `${NAME}` reference, or `null` when the
63
+ * node is not a plain reference or names a variable outside the resolvable
64
+ * set.
65
+ *
66
+ * Plainness is decided structurally, not by matching the node's text: a
67
+ * plain reference carries exactly one `variable_name` child and nothing else
68
+ * but delimiters. An operator form (`${HOME:-/tmp}`, `${#HOME}`,
69
+ * `${HOME%/*}`) carries additional children and is therefore rejected without
70
+ * this module needing to enumerate bash's expansion operators.
71
+ */
72
+ resolveReference(node: TSNode): string | null {
73
+ const name = plainVariableName(node);
74
+ if (name === null || this.rebound.has(name)) return null;
75
+ return RESOLVABLE_VARIABLES.get(name)?.() ?? null;
76
+ }
77
+
78
+ /**
79
+ * Whether a collected path token is spelled from a `HOME` this program
80
+ * rebinds (`$HOME/x`, `${HOME}`, `~/x`).
81
+ *
82
+ * Such a token names no path the projection can know, yet path normalization
83
+ * expands its prefix to the startup home exactly as it does for a config
84
+ * pattern, so it must leave the path surfaces before it reaches them.
85
+ */
86
+ spellsReboundHome(token: string): boolean {
87
+ return this.rebound.has("HOME") && hasHomePrefix(token);
88
+ }
89
+
90
+ /**
91
+ * How a word's leading unquoted text expands when it opens with a tilde
92
+ * prefix that reads `HOME` (`~` or `~/…`; `~user` reads the password
93
+ * database instead), or `undefined` when it does not.
94
+ *
95
+ * Unknown once the program rebinds `HOME`: bash 3.2, which Pi runs as
96
+ * `/bin/bash` on macOS, expands it from the reassigned value. Otherwise it is
97
+ * the inherited `HOME`, which `homedir()` returns verbatim, so the word leads
98
+ * with whatever that does.
99
+ */
100
+ readTilde(leadingText: string): TildeReading | undefined {
101
+ if (leadingText !== "~" && !leadingText.startsWith("~/")) return undefined;
102
+ if (this.rebound.has("HOME")) return { known: false };
103
+ return { known: true, leadsWithDash: homedir().startsWith("-") };
104
+ }
36
105
  }
37
106
 
107
+ /** What a leading tilde prefix expands to, as far as the program shows. */
108
+ export type TildeReading =
109
+ | { readonly known: false }
110
+ | { readonly known: true; readonly leadsWithDash: boolean };
111
+
38
112
  /**
39
113
  * How each resolvable variable is spelled as a path.
40
114
  *
@@ -50,6 +124,129 @@ const RESOLVABLE_VARIABLES: ReadonlyMap<string, () => string> = new Map([
50
124
  ["PWD", () => "."],
51
125
  ]);
52
126
 
127
+ /**
128
+ * Record each resolvable name `node` binds.
129
+ *
130
+ * `bindsNames` holds beneath a builtin that binds the names it is handed
131
+ * (`read HOME`, `export "HOME=/etc"`, `declare -n r=HOME`), and only there does
132
+ * an argument spelling a name count: `grep HOME ~/.bashrc` binds nothing, and
133
+ * reading it as a rebinding would drop the `~/.bashrc` it does read.
134
+ */
135
+ function collectRebound(
136
+ node: TSNode,
137
+ rebound: Set<string>,
138
+ inheritedBindsNames: boolean,
139
+ ): void {
140
+ if (REFERENCE_TYPES.has(node.type) && plainVariableName(node) !== null) {
141
+ return;
142
+ }
143
+ const bindsNames = bindsNamesBeneath(node, inheritedBindsNames);
144
+ if (node.type === "variable_name" && RESOLVABLE_VARIABLES.has(node.text)) {
145
+ rebound.add(node.text);
146
+ }
147
+ if (bindsNames && NAME_ARGUMENT_TYPES.has(node.type)) {
148
+ const name = NAMED_ARGUMENT.exec(node.text.replace(QUOTING, ""))?.[1];
149
+ if (name) rebound.add(name);
150
+ }
151
+ if (node.type === "command" && UNSEEN_CODE_COMMANDS.has(commandName(node))) {
152
+ for (const name of RESOLVABLE_VARIABLES.keys()) rebound.add(name);
153
+ }
154
+ for (let i = 0; i < node.childCount; i++) {
155
+ const child = node.child(i);
156
+ if (child) collectRebound(child, rebound, bindsNames);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Whether the arguments beneath `node` are names its command binds. A command
162
+ * decides for itself; a `declare`-family or `unset` statement always binds;
163
+ * anything else inherits.
164
+ */
165
+ function bindsNamesBeneath(node: TSNode, inherited: boolean): boolean {
166
+ if (node.type === "declaration_command" || node.type === "unset_command") {
167
+ return true;
168
+ }
169
+ if (node.type !== "command") return inherited;
170
+ const name = commandName(node);
171
+ if (name === "printf") return hasArgument(node, "-v");
172
+ return NAME_BINDING_COMMANDS.has(name);
173
+ }
174
+
175
+ /**
176
+ * Builtins that bind a name passed as an argument. `declare` and its family
177
+ * parse as a `declaration_command` when spelled plainly and as a `command`
178
+ * when quoted, so they are listed here too; `printf` binds only under `-v`.
179
+ */
180
+ const NAME_BINDING_COMMANDS: ReadonlySet<string> = new Set([
181
+ "read",
182
+ "mapfile",
183
+ "readarray",
184
+ "getopts",
185
+ "let",
186
+ "unset",
187
+ "declare",
188
+ "typeset",
189
+ "local",
190
+ "export",
191
+ "readonly",
192
+ ]);
193
+
194
+ /**
195
+ * Argument nodes that can spell a name a builtin binds, whole or as the head
196
+ * of an assignment (`HOME=…`, `HOME+=…`, `HOME[0]=…`, `let HOME++`).
197
+ */
198
+ const NAME_ARGUMENT_TYPES: ReadonlySet<string> = new Set([
199
+ "word",
200
+ "string",
201
+ "raw_string",
202
+ "concatenation",
203
+ ]);
204
+
205
+ /** A resolvable name leading an argument, not followed by more of an identifier. */
206
+ const NAMED_ARGUMENT = new RegExp(
207
+ `^(${[...RESOLVABLE_VARIABLES.keys()].join("|")})(?![A-Za-z0-9_])`,
208
+ );
209
+
210
+ /** Quote and escape characters, which the shell removes before a builtin sees the name. */
211
+ const QUOTING = /["'\\]/g;
212
+
213
+ /** A command's name as the shell reads it, quotes and escapes removed. */
214
+ function commandName(command: TSNode): string {
215
+ for (let i = 0; i < command.childCount; i++) {
216
+ const child = command.child(i);
217
+ if (child?.type === "command_name") return child.text.replace(QUOTING, "");
218
+ }
219
+ return "";
220
+ }
221
+
222
+ /** Whether a command carries `flag` as one of its own arguments. */
223
+ function hasArgument(command: TSNode, flag: string): boolean {
224
+ for (let i = 0; i < command.childCount; i++) {
225
+ const child = command.child(i);
226
+ if (child && child.type !== "command_name" && child.text === flag) {
227
+ return true;
228
+ }
229
+ }
230
+ return false;
231
+ }
232
+
233
+ /**
234
+ * Commands that run code the walk never parses, which may rebind anything:
235
+ * `eval`'s joined arguments, a file `source`/`.` reads, and a `trap` action.
236
+ */
237
+ const UNSEEN_CODE_COMMANDS: ReadonlySet<string> = new Set([
238
+ "eval",
239
+ "source",
240
+ ".",
241
+ "trap",
242
+ ]);
243
+
244
+ /** The node types a variable reference parses as. */
245
+ const REFERENCE_TYPES: ReadonlySet<string> = new Set([
246
+ "simple_expansion",
247
+ "expansion",
248
+ ]);
249
+
53
250
  /** Node types that delimit an expansion without altering what it evaluates to. */
54
251
  const EXPANSION_DELIMITERS: ReadonlySet<string> = new Set(["$", "${", "}"]);
55
252
 
@@ -3,7 +3,9 @@ import {
3
3
  collectCommands,
4
4
  collectSalvagedCommands,
5
5
  } from "./command-enumeration";
6
+ import { WordReader } from "./node-text";
6
7
  import { getWarmBashParser } from "./parser";
8
+ import { ShellVariables } from "./shell-variable-expansion";
7
9
  import { withSalvagedRoots } from "./unresolved-salvage";
8
10
 
9
11
  /**
@@ -26,10 +28,15 @@ export function parseBashCommandsSync(command: string): BashCommand[] | null {
26
28
  const tree = parser.parse(command);
27
29
  if (!tree) return [];
28
30
  try {
29
- return withSalvagedRoots(tree.rootNode, parser, (salvaged) => [
30
- ...collectCommands(tree.rootNode),
31
- ...salvaged.flatMap(collectSalvagedCommands),
32
- ]);
31
+ return withSalvagedRoots(tree.rootNode, parser, (salvaged) => {
32
+ const words = new WordReader(
33
+ ShellVariables.scan([tree.rootNode, ...salvaged]),
34
+ );
35
+ return [
36
+ ...collectCommands(tree.rootNode, words),
37
+ ...salvaged.flatMap((root) => collectSalvagedCommands(root, words)),
38
+ ];
39
+ });
33
40
  } finally {
34
41
  tree.delete();
35
42
  }
@@ -5,10 +5,8 @@ import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
5
5
  import {
6
6
  ARG_NODE_TYPES,
7
7
  type ArgWord,
8
- hasComputedPart,
9
- readArgWord,
10
- resolveNodeText,
11
8
  SKIP_SUBTREE_TYPES,
9
+ type WordReader,
12
10
  } from "./node-text";
13
11
  import type { TSNode } from "./parser";
14
12
  import {
@@ -63,24 +61,27 @@ export interface PathToken {
63
61
  * `script`-role flag's argument for an interpreter. For all other commands,
64
62
  * collects all arguments generically.
65
63
  */
66
- export function collectPathCandidateTokens(node: TSNode): PathToken[] {
67
- if (node.type === "command") return collectCommandTokens(node);
68
- if (node.type === "file_redirect") return collectRedirectTokens(node);
64
+ export function collectPathCandidateTokens(
65
+ node: TSNode,
66
+ words: WordReader,
67
+ ): PathToken[] {
68
+ if (node.type === "command") return collectCommandTokens(node, words);
69
+ if (node.type === "file_redirect") return collectRedirectTokens(node, words);
69
70
  if (node.type === "for_statement") {
70
- return collectStatementOperandTokens(node, "after-in");
71
+ return collectStatementOperandTokens(node, "after-in", words);
71
72
  }
72
73
  if (node.type === "case_statement") {
73
- return collectStatementOperandTokens(node, "before-in");
74
+ return collectStatementOperandTokens(node, "before-in", words);
74
75
  }
75
76
  if (EXECUTION_HOST_TYPES.has(node.type)) {
76
- return collectHostedExecutionTokens(node);
77
+ return collectHostedExecutionTokens(node, words);
77
78
  }
78
79
  if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
79
80
 
80
81
  const tokens: PathToken[] = [];
81
82
  for (let i = 0; i < node.childCount; i++) {
82
83
  const child = node.child(i);
83
- if (child) tokens.push(...collectPathCandidateTokens(child));
84
+ if (child) tokens.push(...collectPathCandidateTokens(child, words));
84
85
  }
85
86
  return tokens;
86
87
  }
@@ -95,19 +96,22 @@ export function collectPathCandidateTokens(node: TSNode): PathToken[] {
95
96
  * spelling proves nothing. A nested execution collected along the way keeps
96
97
  * its own command's attribution instead.
97
98
  */
98
- export function collectCommandTokens(node: TSNode): PathToken[] {
99
+ export function collectCommandTokens(
100
+ node: TSNode,
101
+ words: WordReader,
102
+ ): PathToken[] {
99
103
  const effect = proveCommandEffect(
100
- extractCommandWord(node) ?? "",
101
- commandArgumentWords(node),
104
+ extractCommandWord(node, words) ?? "",
105
+ commandArgumentWords(node, words),
102
106
  );
103
- const commandName = extractCommandName(node);
107
+ const commandName = extractCommandName(node, words);
104
108
  const config = commandName
105
109
  ? PATTERN_FIRST_COMMANDS.get(commandName)
106
110
  : undefined;
107
- if (config) return collectPatternCommandTokens(node, config, effect);
111
+ if (config) return collectPatternCommandTokens(node, config, effect, words);
108
112
  return [
109
- ...collectGenericCommandTokens(node, effect),
110
- ...collectEmbeddedOptionValues(node, effect),
113
+ ...collectGenericCommandTokens(node, effect, words),
114
+ ...collectEmbeddedOptionValues(node, effect, words),
111
115
  ];
112
116
  }
113
117
 
@@ -139,7 +143,10 @@ export function collectCommandTokens(node: TSNode): PathToken[] {
139
143
  * Reading the redirect node itself belongs to `redirect-analysis.ts`, which
140
144
  * the command enumerator consults for the same fact (#803).
141
145
  */
142
- export function collectRedirectTokens(node: TSNode): PathToken[] {
146
+ export function collectRedirectTokens(
147
+ node: TSNode,
148
+ words: WordReader,
149
+ ): PathToken[] {
143
150
  const target = redirectTargetIndex(node);
144
151
  const tokens: PathToken[] = [];
145
152
  for (let i = 0; i < node.childCount; i++) {
@@ -148,15 +155,15 @@ export function collectRedirectTokens(node: TSNode): PathToken[] {
148
155
  if (ARG_NODE_TYPES.has(child.type)) {
149
156
  const effect = redirectEffectForDestination(node, child);
150
157
  if (effect) {
151
- const token = resolveNodeText(child);
158
+ const token = words.text(child);
152
159
  const role: TokenRole =
153
- i === target && provesTarget(effect, child, token)
160
+ i === target && provesTarget(effect, child, token, words)
154
161
  ? "redirect-destination"
155
162
  : "operand";
156
163
  tokens.push({ token, effect, role });
157
164
  }
158
165
  }
159
- tokens.push(...collectHostedExecutionTokens(child));
166
+ tokens.push(...collectHostedExecutionTokens(child, words));
160
167
  }
161
168
  return tokens;
162
169
  }
@@ -174,9 +181,10 @@ function provesTarget(
174
181
  effect: TokenEffect,
175
182
  destination: TSNode,
176
183
  token: string,
184
+ words: WordReader,
177
185
  ): boolean {
178
186
  return (
179
- effect.source === "syntax" && !hasComputedPart(destination) && token !== ""
187
+ effect.source === "syntax" && !words.isComputed(destination) && token !== ""
180
188
  );
181
189
  }
182
190
 
@@ -193,10 +201,13 @@ function provesTarget(
193
201
  * (`> ${DIR}/$(cmd)`), so the traversal is the root-inclusive
194
202
  * `forEachExecutionIn`.
195
203
  */
196
- function collectHostedExecutionTokens(node: TSNode): PathToken[] {
204
+ function collectHostedExecutionTokens(
205
+ node: TSNode,
206
+ words: WordReader,
207
+ ): PathToken[] {
197
208
  const tokens: PathToken[] = [];
198
209
  forEachExecutionIn(node, (contextNode) => {
199
- tokens.push(...collectPathCandidateTokens(contextNode));
210
+ tokens.push(...collectPathCandidateTokens(contextNode, words));
200
211
  });
201
212
  return tokens;
202
213
  }
@@ -244,6 +255,7 @@ type OperandSide = "before-in" | "after-in";
244
255
  function collectStatementOperandTokens(
245
256
  node: TSNode,
246
257
  operandSide: OperandSide,
258
+ words: WordReader,
247
259
  ): PathToken[] {
248
260
  const tokens: PathToken[] = [];
249
261
  let seenIn = false;
@@ -256,15 +268,15 @@ function collectStatementOperandTokens(
256
268
  }
257
269
  const side: OperandSide = seenIn ? "after-in" : "before-in";
258
270
  if (side !== operandSide || !ARG_NODE_TYPES.has(child.type)) {
259
- tokens.push(...collectPathCandidateTokens(child));
271
+ tokens.push(...collectPathCandidateTokens(child, words));
260
272
  continue;
261
273
  }
262
274
  tokens.push({
263
- token: resolveNodeText(child),
275
+ token: words.text(child),
264
276
  effect: UNPROVEN_EFFECT,
265
277
  role: "operand",
266
278
  });
267
- tokens.push(...collectHostedExecutionTokens(child));
279
+ tokens.push(...collectHostedExecutionTokens(child, words));
268
280
  }
269
281
  return tokens;
270
282
  }
@@ -279,8 +291,11 @@ function collectStatementOperandTokens(
279
291
  * capability claim, where the directory prefix is the whole point — use
280
292
  * {@link extractCommandWord} there.
281
293
  */
282
- export function extractCommandName(node: TSNode): string | undefined {
283
- const word = extractCommandWord(node);
294
+ export function extractCommandName(
295
+ node: TSNode,
296
+ words: WordReader,
297
+ ): string | undefined {
298
+ const word = extractCommandWord(node, words);
284
299
  return word === undefined ? undefined : basename(word);
285
300
  }
286
301
 
@@ -294,12 +309,15 @@ export function extractCommandName(node: TSNode): string | undefined {
294
309
  * Documented against {@link extractCommandName}, which answers the other
295
310
  * question.
296
311
  */
297
- export function extractCommandWord(node: TSNode): string | undefined {
312
+ export function extractCommandWord(
313
+ node: TSNode,
314
+ words: WordReader,
315
+ ): string | undefined {
298
316
  for (let i = 0; i < node.childCount; i++) {
299
317
  const child = node.child(i);
300
318
  if (!child) continue;
301
319
  if (child.type === "command_name") {
302
- const text = resolveNodeText(child);
320
+ const text = words.text(child);
303
321
  return text === "" ? undefined : text;
304
322
  }
305
323
  }
@@ -319,16 +337,16 @@ export function extractCommandWord(node: TSNode): string | undefined {
319
337
  * reaches the program as an argument too, and a proof that never saw it could
320
338
  * not know it might spell `-i`.
321
339
  */
322
- function commandArgumentWords(node: TSNode): ArgWord[] {
323
- const words: ArgWord[] = [];
340
+ function commandArgumentWords(node: TSNode, words: WordReader): ArgWord[] {
341
+ const argWords: ArgWord[] = [];
324
342
  for (let i = 0; i < node.childCount; i++) {
325
343
  const child = node.child(i);
326
344
  if (!child?.isNamed) continue;
327
345
  if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
328
346
  if (REDIRECT_NODE_TYPES.has(child.type)) continue;
329
- words.push(readArgWord(child));
347
+ argWords.push(words.argWord(child));
330
348
  }
331
- return words;
349
+ return argWords;
332
350
  }
333
351
 
334
352
  /**
@@ -376,6 +394,7 @@ const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
376
394
  function collectEmbeddedOptionValues(
377
395
  node: TSNode,
378
396
  effect: TokenEffect,
397
+ words: WordReader,
379
398
  ): PathToken[] {
380
399
  const values: PathToken[] = [];
381
400
  for (let i = 0; i < node.childCount; i++) {
@@ -384,7 +403,7 @@ function collectEmbeddedOptionValues(
384
403
  if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
385
404
  if (!ARG_NODE_TYPES.has(child.type)) continue;
386
405
 
387
- const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
406
+ const value = OPTION_VALUE_PATTERN.exec(words.text(child))?.[1];
388
407
  if (value !== undefined) {
389
408
  values.push({ token: value, effect, role: "operand" });
390
409
  }
@@ -768,6 +787,7 @@ function collectPatternCommandTokens(
768
787
  node: TSNode,
769
788
  config: PatternCommandConfig,
770
789
  effect: TokenEffect,
790
+ words: WordReader,
771
791
  ): PathToken[] {
772
792
  const patternPositionals = config.patternPositionals ?? 1;
773
793
  let hasExplicitScript = false;
@@ -782,12 +802,12 @@ function collectPatternCommandTokens(
782
802
 
783
803
  if (COMMAND_PREFIX_TYPES.has(child.type)) {
784
804
  // Supplies no operand of its own, but may host one that really runs.
785
- tokens.push(...collectHostedExecutionTokens(child));
805
+ tokens.push(...collectHostedExecutionTokens(child, words));
786
806
  continue;
787
807
  }
788
808
 
789
809
  const isArgNode = ARG_NODE_TYPES.has(child.type);
790
- const text = resolveNodeText(child);
810
+ const text = words.text(child);
791
811
 
792
812
  // An argument is read for its text below, and its text alone — but a
793
813
  // quoted substitution sitting there really runs, and its own operands are
@@ -796,7 +816,7 @@ function collectPatternCommandTokens(
796
816
  // recursions below; the quoted one parses as a `string` and would
797
817
  // otherwise stop here, whichever role the argument turns out to have
798
818
  // (#945).
799
- if (isArgNode) tokens.push(...collectHostedExecutionTokens(child));
819
+ if (isArgNode) tokens.push(...collectHostedExecutionTokens(child, words));
800
820
 
801
821
  // Handle the argument a previous flag consumed. The consumption discharges
802
822
  // on whatever node type follows, not only on an ARG_NODE_TYPES one: a bare
@@ -810,7 +830,7 @@ function collectPatternCommandTokens(
810
830
  if (!isArgNode) {
811
831
  // Contributes no operand text of its own, but may host a nested
812
832
  // execution whose operands are candidates (#741).
813
- tokens.push(...collectPathCandidateTokens(child));
833
+ tokens.push(...collectPathCandidateTokens(child, words));
814
834
  continue;
815
835
  }
816
836
  const discharge = dischargePendingConsumption(consumption, text, effect);
@@ -838,7 +858,7 @@ function collectPatternCommandTokens(
838
858
  positionalsSeen++;
839
859
  }
840
860
  // Recurse for nested commands (e.g. command_substitution).
841
- tokens.push(...collectPathCandidateTokens(child));
861
+ tokens.push(...collectPathCandidateTokens(child, words));
842
862
  continue;
843
863
  }
844
864
 
@@ -963,6 +983,7 @@ function dischargePendingConsumption(
963
983
  function collectGenericCommandTokens(
964
984
  node: TSNode,
965
985
  effect: TokenEffect,
986
+ words: WordReader,
966
987
  ): PathToken[] {
967
988
  const tokens: PathToken[] = [];
968
989
  let seenCommandName = false;
@@ -974,7 +995,7 @@ function collectGenericCommandTokens(
974
995
  if (COMMAND_PREFIX_TYPES.has(child.type)) {
975
996
  // Supplies no operand of its own, but may host one that really runs.
976
997
  if (child.type === "command_name") seenCommandName = true;
977
- tokens.push(...collectHostedExecutionTokens(child));
998
+ tokens.push(...collectHostedExecutionTokens(child, words));
978
999
  continue;
979
1000
  }
980
1001
 
@@ -983,7 +1004,7 @@ function collectGenericCommandTokens(
983
1004
  // sits (ADR 0009's positional invariance, #945). The unquoted spelling
984
1005
  // reaches the nested command through the trailing recursion.
985
1006
  if (ARG_NODE_TYPES.has(child.type))
986
- tokens.push(...collectHostedExecutionTokens(child));
1007
+ tokens.push(...collectHostedExecutionTokens(child, words));
987
1008
 
988
1009
  // If there was no explicit command_name node, the first word-like
989
1010
  // child is the command name itself — skip it.
@@ -994,12 +1015,12 @@ function collectGenericCommandTokens(
994
1015
 
995
1016
  // Argument nodes: resolve their text and collect.
996
1017
  if (ARG_NODE_TYPES.has(child.type)) {
997
- tokens.push({ token: resolveNodeText(child), effect, role: "operand" });
1018
+ tokens.push({ token: words.text(child), effect, role: "operand" });
998
1019
  continue;
999
1020
  }
1000
1021
 
1001
1022
  // Recurse into other children (e.g. command_substitution nested in args)
1002
- tokens.push(...collectPathCandidateTokens(child));
1023
+ tokens.push(...collectPathCandidateTokens(child, words));
1003
1024
  }
1004
1025
 
1005
1026
  return tokens;