sh-ast 0.1.0 → 0.2.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.
Files changed (39) hide show
  1. package/dist/analyze/enumerate-commands.d.ts +7 -1
  2. package/dist/analyze/enumerate-commands.d.ts.map +1 -1
  3. package/dist/analyze/enumerate-commands.js +7 -1
  4. package/dist/analyze/enumerate-commands.js.map +1 -1
  5. package/dist/analyze/index.d.ts +12 -4
  6. package/dist/analyze/index.d.ts.map +1 -1
  7. package/dist/analyze/index.js +18 -15
  8. package/dist/analyze/index.js.map +1 -1
  9. package/dist/analyze/resolve-argv0.d.ts +335 -0
  10. package/dist/analyze/resolve-argv0.d.ts.map +1 -0
  11. package/dist/analyze/resolve-argv0.js +510 -0
  12. package/dist/analyze/resolve-argv0.js.map +1 -0
  13. package/dist/analyze/resolve-word.d.ts +5 -0
  14. package/dist/analyze/resolve-word.d.ts.map +1 -1
  15. package/dist/analyze/resolve-word.js +5 -0
  16. package/dist/analyze/resolve-word.js.map +1 -1
  17. package/dist/analyze.d.ts +592 -211
  18. package/dist/errors.d.ts +81 -16
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js +90 -17
  21. package/dist/errors.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/parse-depth-guard.d.ts +11 -0
  27. package/dist/parse-depth-guard.d.ts.map +1 -0
  28. package/dist/parse-depth-guard.js +633 -0
  29. package/dist/parse-depth-guard.js.map +1 -0
  30. package/dist/parse.d.ts +7 -2
  31. package/dist/parse.d.ts.map +1 -1
  32. package/dist/parse.js +15 -8
  33. package/dist/parse.js.map +1 -1
  34. package/dist/sh-ast.d.ts +121 -45
  35. package/dist/types.d.ts +5 -1
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/wasm-instance.js +3 -3
  38. package/dist/wasm-instance.js.map +1 -1
  39. package/package.json +1 -1
package/dist/analyze.d.ts CHANGED
@@ -1,3 +1,100 @@
1
+ /**
2
+ * The result of statically resolving one position in an
3
+ * {@link Argv0Resolution.chain}: either an ordinary {@link WordResolution}
4
+ * (from `resolveWord`) or an {@link Argv0UnresolvedWord} (a `resolveArgv0`-
5
+ * level "we don't know", distinct from any reason `resolveWord` itself
6
+ * would report).
7
+ *
8
+ * @public
9
+ */
10
+ export declare type Argv0ChainWord = WordResolution | Argv0UnresolvedWord;
11
+
12
+ /**
13
+ * The result of following a {@link CommandSite}'s argv0 through zero or
14
+ * more transparent wrappers to the effective command actually invoked.
15
+ * Facts only — no safety verdict; see {@link resolveArgv0}'s doc comment.
16
+ *
17
+ * @public
18
+ */
19
+ export declare interface Argv0Resolution {
20
+ /**
21
+ * Every word resolved along the way, outermost wrapper first, ending
22
+ * with {@link Argv0Resolution.effective} — e.g. for
23
+ * `nohup env A=1 command rm x` this is the four resolutions for
24
+ * `nohup`, `env`, `command`, `rm`, in that order. Always has at least
25
+ * one element.
26
+ */
27
+ readonly chain: readonly Argv0ChainWord[];
28
+ /**
29
+ * The last element of {@link Argv0Resolution.chain} — the effective
30
+ * command, or, if resolution hit a statically-unknowable word anywhere
31
+ * along the chain (an expansion, a glob, an unrecognized flag, an
32
+ * embedded-command operand, …), that unknowable result itself.
33
+ * **A `static: false` word is never guessed through**: the moment one is
34
+ * reached (whether at argv0, several wrappers deep, or synthesized by
35
+ * `resolveArgv0` itself for an unrecognized flag), it becomes
36
+ * `effective` and the chain stops — this is the security-relevant
37
+ * guarantee that makes `sudo -u x "$prog"` report an unknowable
38
+ * effective command rather than silently treating `sudo` itself as the
39
+ * answer, and makes `sudo -D /tmp rm x` (an unrecognized `sudo` flag)
40
+ * report `'unknown-flag'` rather than misreporting `rm` — or, worse,
41
+ * `/tmp` — as the effective command.
42
+ */
43
+ readonly effective: Argv0ChainWord;
44
+ /**
45
+ * The number of `VAR=val` shell-assignment prefixes on the `CallExpr`
46
+ * itself (`CommandSite.node.assigns`, e.g. the `FOO=bar` in
47
+ * `FOO=bar rm x`) that were skipped to reach argv0. This is a distinct
48
+ * mechanism from a wrapper's own `VAR=val` *operands* (e.g. `env`'s or
49
+ * `sudo`'s, see {@link WrapperSpec.skipAssignmentOperands}) — always
50
+ * exactly `CommandSite.node.assigns`'s length, independent of the
51
+ * wrapper chain found afterward.
52
+ */
53
+ readonly assignmentsSkipped: number;
54
+ }
55
+
56
+ /**
57
+ * Why {@link resolveArgv0} couldn't identify a single, definite effective
58
+ * command — distinct from {@link WordResolutionReason}, which is about why
59
+ * one *word*'s text is unknowable at the shell-syntax level (an expansion,
60
+ * a glob, …). `Argv0UnresolvedReason` instead reflects `resolveArgv0`'s own
61
+ * wrapper-table-driven analysis of an otherwise statically-known word:
62
+ *
63
+ * - `'unknown-flag'` — a statically known word shaped like a flag (starts
64
+ * with `-`, isn't exactly `--`) for the wrapper currently being
65
+ * followed, but that doesn't match any flag/operand shape
66
+ * {@link WrapperSpec} recognizes for it. Never guessed through as the
67
+ * wrapped command — see {@link resolveArgv0}'s doc comment.
68
+ * - `'embedded-command'` — a wrapper flag whose value structurally embeds
69
+ * the real command rather than naming it as a separate word (e.g.
70
+ * `env -S 'rm -rf /'` — see {@link WrapperSpec.unresolvableFlags}'s doc
71
+ * comment).
72
+ *
73
+ * This union may grow in a **minor** release (mirroring
74
+ * `WordResolutionReason`'s and `CommandContext`'s semver policy in
75
+ * `resolve-word.ts`/`enumerate-commands.ts`) — a reason this version
76
+ * doesn't yet know about only ever accompanies `static: false`; an
77
+ * exhaustive `switch` should still include a `default` case.
78
+ *
79
+ * @public
80
+ */
81
+ export declare type Argv0UnresolvedReason = 'unknown-flag' | 'embedded-command';
82
+
83
+ /**
84
+ * A word position {@link resolveArgv0} could not resolve to either a known
85
+ * static text or one of {@link WordResolution}'s own `static: false`
86
+ * reasons — see {@link Argv0UnresolvedReason}'s doc comment. Shares
87
+ * `WordResolution`'s `static: false` discriminant shape by design, so a
88
+ * consumer that only branches on `.static` treats it identically to any
89
+ * other unresolvable word.
90
+ *
91
+ * @public
92
+ */
93
+ export declare interface Argv0UnresolvedWord {
94
+ readonly static: false;
95
+ readonly reason: Argv0UnresolvedReason;
96
+ }
97
+
1
98
  /**
2
99
  * A single frame of the path from the root of the tree down to a
3
100
  * {@link CommandSite}, describing *how* the command is reached — never
@@ -115,6 +212,52 @@ export declare interface CommandSite {
115
212
  readonly context: readonly CommandContext[];
116
213
  }
117
214
 
215
+ /**
216
+ * `sh-ast/analyze`'s default {@link WrapperSpec} table — every entry's
217
+ * flag/operand handling is hand-derived from that wrapper's own manual
218
+ * page (or, for shell builtins, the Bash Reference Manual/POSIX Shell &
219
+ * Utilities volume), cited per entry below, and cross-checked empirically
220
+ * against the real utility where one was locally available (GNU coreutils
221
+ * `env`/`nice` directly; `sudo` via `sudo -h`'s usage/option summary only
222
+ * — never executed, per this package's "facts only" posture and basic
223
+ * safety hygiene). This table is *data*, not policy: it names no
224
+ * "dangerous" commands, makes no safety judgment, and a caller can freely
225
+ * replace or extend it via {@link ResolveArgv0Options.transparentWrappers}
226
+ * (see {@link resolveArgv0}'s criterion-4-style configurability guarantee).
227
+ *
228
+ * Frozen (including each entry object and its own array/regexp fields) so
229
+ * immutability of this shared, module-scoped table is a contract, not just
230
+ * a convention any importer happens to honor — mirrors `visitorKeys`'s and
231
+ * `CHILD_TYPE_SCHEMA`'s freezing in `visitor-keys.ts`/`normalize.ts`.
232
+ *
233
+ * `xargs` is deliberately **excluded**: unlike every entry here, `xargs`
234
+ * doesn't simply re-exec a single wrapped command word it can point to
235
+ * statically — it invokes `command [initial-arguments]` once per *batch*
236
+ * of additional arguments assembled from its own stdin at run time
237
+ * (batched per `-n`/`-L`/line-splitting rules, GNU xargs(1)). Treating it
238
+ * as transparent would misrepresent what's actually invoked: the "wrapped
239
+ * command" is only ever a syntactic *prefix* of the real, stdin-dependent
240
+ * argv, and that prefix can itself be entirely absent (bare `xargs` reruns
241
+ * each stdin-derived line as its own command). No `WrapperSpec` shape here
242
+ * can honestly express that, so `xargs` is left for a caller to model
243
+ * explicitly if their use case calls for it, rather than shipped as a
244
+ * silently-wrong default.
245
+ *
246
+ * @see https://www.gnu.org/software/bash/manual/bash.html#Bourne-Shell-Builtins — `command`, `exec` (Bash Reference Manual §4.1)
247
+ * @see https://pubs.opengroup.org/onlinepubs/9699919799/utilities/command.html — POSIX `command`
248
+ * @see https://www.gnu.org/software/coreutils/manual/html_node/env-invocation.html — GNU coreutils `env`
249
+ * @see https://pubs.opengroup.org/onlinepubs/9699919799/utilities/env.html — POSIX `env`
250
+ * @see https://pubs.opengroup.org/onlinepubs/9699919799/utilities/nohup.html — POSIX `nohup`
251
+ * @see https://www.gnu.org/software/coreutils/manual/html_node/nice-invocation.html — GNU coreutils `nice`
252
+ * @see https://pubs.opengroup.org/onlinepubs/9699919799/utilities/nice.html — POSIX `nice`
253
+ * @see https://pubs.opengroup.org/onlinepubs/9699919799/utilities/time.html — POSIX `time` utility (distinct from the bash `time` *reserved word*, which mvdan/sh models as a transparent `TimeClause` — see {@link WrapperSpec}'s `time` entry below)
254
+ * @see https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html — GNU coreutils `timeout`
255
+ * @see https://www.sudo.ws/docs/man/sudo.man/ — `sudo(8)`
256
+ * @see https://www.gnu.org/software/findutils/manual/html_node/find_html/xargs-options.html — GNU `xargs` (why it's excluded, above)
257
+ * @public
258
+ */
259
+ export declare const DEFAULT_TRANSPARENT_WRAPPERS: readonly WrapperSpec[];
260
+
118
261
  /**
119
262
  * Enumerates every command invocation (`CallExpr`) reachable from `root`,
120
263
  * with its resolved words ({@link resolveWord}) and the path used to reach
@@ -145,7 +288,13 @@ export declare interface CommandSite {
145
288
  * nesting-depth guard below, regardless of how many stages/links it has.
146
289
  *
147
290
  * @param root - Any `ShNode` — typically a `File` from `parseSync`, but any
148
- * subtree (a `Stmt`, a `Command`, or even a bare `Word`) is handled.
291
+ * subtree (a `Stmt`, a `Command`, or even a bare `Word`) is handled. Note
292
+ * `parseSync` itself already refuses (via its own `ShParseMaxDepthError`) any
293
+ * input whose estimated nesting exceeds *its* limit before ever producing a
294
+ * tree — see `parse-depth-guard.ts` — so a `root` sourced from `parseSync`
295
+ * has already passed that earlier, lower-level line of defense; this
296
+ * module's own guard below remains a second, independent backstop for
297
+ * trees built or mutated some other way.
149
298
  * @throws {@link ShAnalyzeMaxDepthError} if `root`'s genuinely nested
150
299
  * structure (subshells within subshells, chained command/process
151
300
  * substitutions, deeply nested `if`/`case`/loop/function/`time`/`{ }`
@@ -163,228 +312,460 @@ export declare interface CommandSite {
163
312
  *
164
313
  * @public
165
314
  */
166
- declare interface Position {
315
+ export declare interface Position {
167
316
  readonly line: number;
168
317
  readonly column: number;
169
318
  }
170
319
 
171
320
  /**
172
- * Determines whether a `Word` node is statically a known string, and if
173
- * so, what that string is the atom of shell-AST static analysis (e.g.
174
- * "is this word literally `rm`"). A word is static iff every part is a
175
- * literal after shell unquoting: a bare `Lit` (backslash escapes
176
- * processed), a `SglQuoted` (including `$'...'` ANSI-C escapes decoded),
177
- * or a `DblQuoted` containing only literal parts (and not itself a
178
- * `$"..."` locale translation) — including concatenations of these (`'r'm`
179
- * resolves to `"rm"`). A word containing any expansion (`ParamExp`,
180
- * `CmdSubst`, `ArithmExp`, `ProcSubst`), a triggering unquoted `~` (see
181
- * `options.context`), an unquoted glob metacharacter/bracket
182
- * expression/`ExtGlob`, a `$"..."` locale translation, or an unrepresentable
183
- * `$'\U...'` escape is `static: false` with a reason — never an error, and
184
- * never a safety judgment (see {@link WordResolution}).
321
+ * Follows `site`'s argv0 through zero or more *transparent wrappers*
322
+ * commands whose own argv0 isn't the command actually run, because they
323
+ * just locate and re-exec another command (`env FOO=1 rm -rf /`,
324
+ * `command rm`, `nohup rm`, `sudo -u x "$prog"`) to the **effective**
325
+ * command: the one a permission/policy check must actually judge, since
326
+ * argv0 alone is trivially spoofable through any of these.
185
327
  *
186
- * @param word - A `Word` node, e.g. `parseSync(...).stmts[0].cmd.args[0]`.
187
- * @param options - See {@link ResolveWordOptions}.
188
- * @throws TypeError if `word.type` is not `"Word"` a programmer-error
189
- * misuse of the API, not a "malformed shell source" case (well-formed
190
- * shell source, however dynamic, never throws; see {@link WordResolution}).
328
+ * The wrapper table ({@link ResolveArgv0Options.transparentWrappers},
329
+ * defaulting to {@link DEFAULT_TRANSPARENT_WRAPPERS}) is plain data: each
330
+ * {@link WrapperSpec} declares how to locate the word it wraps, and
331
+ * neither this function nor the table encodes any safety judgment about
332
+ * *which* commands are dangerous matching {@link CommandSite}'s and
333
+ * {@link resolveWord}'s "facts only" posture. A caller can drop a default
334
+ * entry (that name then resolves as an ordinary, non-transparent
335
+ * command) or add their own (e.g. a project's `with-retry` helper) and
336
+ * both directions are followed identically to any built-in entry.
191
337
  *
192
- * @public
193
- */
194
- export declare function resolveWord(word: ShNode, options?: ResolveWordOptions): WordResolution;
195
-
196
- /**
197
- * Options controlling {@link resolveWord}'s tilde-expansion detection.
198
- *
199
- * @public
200
- */
201
- export declare interface ResolveWordOptions {
202
- /**
203
- * Which Bash grammar position `word` occupies — the two positions Bash
204
- * §3.5.2 ("Tilde Expansion") documents distinct tilde-expansion triggers
205
- * for:
206
- *
207
- * - `'command-argument'` — an ordinary simple-command argument word (e.g.
208
- * `echo <word>`). Only a *word-initial* unquoted `~` triggers tilde
209
- * expansion; an unquoted `~` elsewhere in the word (including right
210
- * after a `:`) is an ordinary character (`echo a:~b` prints `a:~b`
211
- * literally in real bash).
212
- * - `'assignment-value'` — the value side of a shell assignment (e.g.
213
- * `PATH=<word>`, an `Assign` node's `value`). Bash additionally
214
- * tilde-expands an unquoted `~` immediately following an unquoted `:`
215
- * anywhere in the value (`PATH=/foo:~/bar` expands the `~/bar`
216
- * segment) — this is documented for `PATH`-like colon-separated
217
- * assignment values specifically.
218
- *
219
- * **Default: `'assignment-value'` semantics apply whenever this option is
220
- * omitted.** `resolveWord` takes a bare `Word` node with no grammar
221
- * context attached, so it cannot see whether its caller extracted that
222
- * word from a command argument or an assignment value — omitting this
223
- * option is deliberately treated as the *more conservative* of the two
224
- * (the one that reports `'tilde'` in more cases), so a caller that
225
- * doesn't know or care about the distinction never under-reports a real
226
- * tilde-expansion site. Pass `'command-argument'` explicitly to opt into
227
- * the narrower, word-initial-only check that matches real bash's
228
- * behavior for ordinary arguments.
229
- *
230
- * @see https://www.gnu.org/software/bash/manual/bash.html#Tilde-Expansion
231
- */
232
- readonly context?: 'command-argument' | 'assignment-value';
233
- }
234
-
235
- /**
236
- * Thrown by `sh-ast/analyze`'s `enumerateCommands` when a tree's genuinely
237
- * nested structure — subshells within subshells, chained command/process
238
- * substitutions, deeply nested `if`/`case`/loop/function/`time`/`{ }`
239
- * bodies, deeply chained `elif` — exceeds its defensive recursion-depth
240
- * guard. **Not** thrown for a long *linear* chain (`|`/`|&`/`&&`/`||` of
241
- * any realistic length): `enumerateCommands` traverses those iteratively,
242
- * so chain length alone never grows this guard's depth counter — only
243
- * genuine tree nesting does.
338
+ * **A statically-unknowable word is never guessed through** — three
339
+ * distinct cases all stop the chain immediately at that word rather than
340
+ * falling back to any other guess:
244
341
  *
245
- * `enumerateCommands` deliberately fails closed here rather than returning
246
- * a truncated, partial result: a partial `CommandSite[]` silently omits
247
- * real command sites, which is a false negative for a permission-hook-style
248
- * consumer that treats "command not found in the enumeration" as "nothing
249
- * to worry about" an explicit, documented throw is safer than a silent
250
- * under-report. Gate-style consumers should treat this error as `deny`,
251
- * not fall back to "no commands found, so allow".
342
+ * 1. `argv0` or any wrapper's located word itself resolves `static: false`
343
+ * (an expansion, tilde, glob, …) `sudo -u x "$prog"` reports
344
+ * `effective: { static: false, reason: 'expansion' }` with
345
+ * `chain: [<sudo>, <"$prog">]`, not `rm`/`sudo`/anything static.
346
+ * 2. A *statically known* word shaped like a flag (`-`-prefixed, not
347
+ * `--`) doesn't match any flag/operand shape the current
348
+ * {@link WrapperSpec} recognizes `sudo -D /tmp rm x` (`-D` isn't
349
+ * modeled for `sudo`) reports an `effective` of `static: false` with
350
+ * `reason: 'unknown-flag'`, **not** `rm` (the pre-fix behavior: any
351
+ * unrecognized word, flag-shaped or not, was silently treated as the
352
+ * wrapped command — a false report that hid the real effective
353
+ * command behind a flag this table simply hadn't modeled yet).
354
+ * 3. A recognized flag's value structurally embeds the real command
355
+ * rather than naming it as a separate word — `env -S 'rm -rf /'`
356
+ * reports `effective: { static: false, reason: 'embedded-command' }`
357
+ * (see {@link WrapperSpec.unresolvableFlags}'s doc comment).
252
358
  *
253
- * @public
254
- */
255
- export declare class ShAnalyzeMaxDepthError extends ShBridgeError {
256
- readonly code = "ESLINT_SH_ANALYZE_MAX_DEPTH";
257
- /** The maximum nesting depth `enumerateCommands` supports; the same value every time (not caller-configurable). */
258
- readonly maxDepth: number;
259
- constructor(maxDepth: number);
260
- }
261
-
262
- /**
263
- * Common base class for every error this package throws — originally just
264
- * {@link parseSync}'s errors, now also the `sh-ast/analyze` layer's (see
265
- * {@link ShAnalyzeMaxDepthError}). Provides a stable, documented `code`
266
- * discriminator (e.g. `"ESLINT_SH_PARSE_ERROR"`) alongside the usual
267
- * `instanceof` narrowing, so consumers can branch on failure kind
268
- * programmatically without parsing `.message` strings. Never thrown
269
- * directly — only via its concrete subclasses ({@link ShParseError},
270
- * {@link ShInvalidDialectError}, {@link ShBridgeInternalError},
271
- * {@link ShAnalyzeMaxDepthError}).
359
+ * `VAR=val` shell-assignment prefixes on the `CallExpr` itself
360
+ * (`CommandSite.node.assigns`, e.g. `FOO=bar rm x`) are always skipped and
361
+ * counted in {@link Argv0Resolution.assignmentsSkipped} a mechanism
362
+ * distinct from a wrapper's own `VAR=val` *operands* (`env A=1 rm x`,
363
+ * `sudo A=1 rm x`), which {@link WrapperSpec.skipAssignmentOperands}
364
+ * handles per-wrapper.
272
365
  *
273
- * @public
274
- */
275
- declare abstract class ShBridgeError extends Error {
276
- /**
277
- * Stable, machine-readable discriminator for this error kind. Distinct
278
- * per subclass and will not change once published.
366
+ * @param site - A {@link CommandSite}, typically from {@link enumerateCommands}.
367
+ * @param options - See {@link ResolveArgv0Options}.
368
+ * @throws TypeError if `site.argv` is empty — a programmer-error misuse of
369
+ * the API (every `CommandSite` `enumerateCommands` ever produces has at
370
+ * least one argv word; see its doc comment), not a "malformed shell
371
+ * source" case.
372
+ * @throws {@link ShAnalyzeInvalidWrapperSpecError} if
373
+ * `options.transparentWrappers` contains a malformed entry (see
374
+ * {@link ResolveArgv0Options.transparentWrappers}'s doc comment).
375
+ * @public
279
376
  */
280
- abstract readonly code: string;
281
- }
377
+ export declare function resolveArgv0(site: CommandSite, options?: ResolveArgv0Options): Argv0Resolution;
282
378
 
283
- /**
284
- * A normalized AST node produced by {@link parseSync}.
285
- *
286
- * `range` and `loc` are expressed in UTF-16 code units, so
287
- * `code.slice(node.range[0], node.range[1])` reproduces the node's exact
288
- * source text even though mvdan/sh itself reports byte offsets and
289
- * byte-counting columns internally (see design/ARCHITECTURE.md's
290
- * "Serialization contract"). Every other field copied over from mvdan/sh's
291
- * typedjson tree keeps its original value but with the field name
292
- * lowercased (`Stmts` becomes `stmts`, `CondLast` becomes `condlast`, etc.).
293
- *
294
- * @public
295
- */
296
- declare interface ShNode {
297
- readonly type: string;
298
- readonly range: readonly [number, number];
299
- readonly loc: {
300
- readonly start: Position;
301
- readonly end: Position;
302
- };
303
- [field: string]: unknown;
304
- }
379
+ /**
380
+ * Options accepted by {@link resolveArgv0}.
381
+ *
382
+ * @public
383
+ */
384
+ export declare interface ResolveArgv0Options {
385
+ /**
386
+ * The transparent-wrapper table to follow. Defaults to
387
+ * {@link DEFAULT_TRANSPARENT_WRAPPERS}. Pass a replacement array to drop
388
+ * a default entry entirely (that wrapper is then treated as an ordinary,
389
+ * non-transparent effective command), or add project-specific entries
390
+ * (e.g. a `with-retry` wrapper) — the table is plain data, not baked-in
391
+ * policy.
392
+ *
393
+ * Validated at the {@link resolveArgv0} boundary: a malformed entry (a
394
+ * non-array `names`, an empty `names`, or a wrong-typed flag field)
395
+ * throws {@link ShAnalyzeInvalidWrapperSpecError} immediately, rather
396
+ * than failing later with a confusing native `TypeError` deep inside
397
+ * flag matching.
398
+ */
399
+ readonly transparentWrappers?: readonly WrapperSpec[];
400
+ }
305
401
 
306
- /**
307
- * The result of statically resolving a `Word` node's text. Reports facts
308
- * only never a safety verdict, never a hardcoded command/wrapper list.
309
- * `static: false` ("unknowable") is a first-class, valid result, not an
310
- * error condition.
311
- *
312
- * @public
313
- */
314
- export declare type WordResolution = {
315
- /** The word is a compile-time-known string. */
316
- readonly static: true;
317
- /** The word's exact resolved text (quotes and escapes removed). */
318
- readonly text: string;
319
- } | {
320
- /** The word's value depends on program state, the environment, or the filesystem. */
321
- readonly static: false;
322
- /** Why this word cannot be statically resolved. */
323
- readonly reason: WordResolutionReason;
324
- };
402
+ /**
403
+ * Determines whether a `Word` node is statically a known string, and if
404
+ * so, what that string is the atom of shell-AST static analysis (e.g.
405
+ * "is this word literally `rm`"). A word is static iff every part is a
406
+ * literal after shell unquoting: a bare `Lit` (backslash escapes
407
+ * processed), a `SglQuoted` (including `$'...'` ANSI-C escapes decoded),
408
+ * or a `DblQuoted` containing only literal parts (and not itself a
409
+ * `$"..."` locale translation) — including concatenations of these (`'r'm`
410
+ * resolves to `"rm"`). A word containing any expansion (`ParamExp`,
411
+ * `CmdSubst`, `ArithmExp`, `ProcSubst`), a triggering unquoted `~` (see
412
+ * `options.context`), an unquoted glob metacharacter/bracket
413
+ * expression/`ExtGlob`, a `$"..."` locale translation, or an unrepresentable
414
+ * `$'\U...'` escape is `static: false` with a reason — never an error, and
415
+ * never a safety judgment (see {@link WordResolution}).
416
+ *
417
+ * @param word - A `Word` node, e.g. `parseSync(...).stmts[0].cmd.args[0]`.
418
+ * A `word` sourced from `parseSync` has already passed that function's own
419
+ * pathological-nesting guard (`ShParseMaxDepthError` — see
420
+ * `parse-depth-guard.ts`), which rejects deeply-nested input before it is
421
+ * ever turned into a tree at all, so `resolveWord` itself never needs (and
422
+ * does not implement) a depth guard of its own.
423
+ * @param options - See {@link ResolveWordOptions}.
424
+ * @throws TypeError if `word.type` is not `"Word"` — a programmer-error
425
+ * misuse of the API, not a "malformed shell source" case (well-formed
426
+ * shell source, however dynamic, never throws; see {@link WordResolution}).
427
+ *
428
+ * @public
429
+ */
430
+ export declare function resolveWord(word: ShNode, options?: ResolveWordOptions): WordResolution;
325
431
 
326
- /**
327
- * Why a {@link resolveWord} result is `static: false`: the word's exact
328
- * text cannot be determined from source alone.
329
- *
330
- * - `'expansion'` — the word contains a `ParamExp` (`$x`, `${x}`), a
331
- * `CmdSubst` (command substitution, either `$(...)` or backtick form), an
332
- * `ArithmExp` (`$((expr))`), or a `ProcSubst` (`<(cmd)`/`>(cmd)`) part:
333
- * its value depends on program state (variables, command output, process
334
- * substitution) that only exists at run time.
335
- * - `'tilde'` — an unquoted, unescaped `~` triggers Bash tilde expansion
336
- * (Bash Reference Manual §3.5.2, "Tilde Expansion") at the start of the
337
- * word, orwhen `resolveWord`'s `context` option is `'assignment-value'`
338
- * or omitted immediately after an unquoted `:` anywhere in the word too
339
- * (§3.5.2 also documents this for assignment-statement values, e.g.
340
- * `PATH=/foo:~/bar`). Either way: statically shaped, but its value
341
- * depends on the filesystem/environment (`$HOME`, NSS user database
342
- * lookups) at run time. See `resolveWord`'s `context` parameter doc for
343
- * the full command-argument-vs-assignment-value distinction.
344
- * - `'glob'` the word contains an unquoted, unescaped glob metacharacter
345
- * (`*`/`?`), an unquoted bracket expression (`[...]` an unquoted `[`
346
- * with a later unquoted `]` in the same word, POSIX 2.13.1 / Bash
347
- * Reference Manual §3.5.8.1, "Pattern Matching"), or an `ExtGlob` part
348
- * (Bash Reference Manual §3.5.8, "Filename Expansion"): its value depends
349
- * on which filesystem entries exist at run time.
350
- * - `'brace'` the word contains a `BraceExp` part (`{a,b}`/`{1..10}`,
351
- * Bash Reference Manual §3.5.1, "Brace Expansion"). mvdan/sh v3.13.1 only
352
- * ever produces this node type when its caller applies `syntax.SplitBraces`
353
- * to the parsed tree; this bridge's shim does not call it, so today a
354
- * brace expression like `{a,b}` reaches {@link resolveWord} as an
355
- * ordinary `Lit` (indistinguishable, in the AST this bridge produces,
356
- * from literal `{`/`,`/`}` characters) rather than as a `BraceExp` part.
357
- * This reason is handled here for forward-compatibility with that node
358
- * type and is not reachable through {@link resolveWord} today.
359
- * - `'locale'` — the word contains a `$"..."` locale-translated string
360
- * (Bash Reference Manual §3.1.2.5, "Locale-Specific Translation"): its
361
- * value is looked up in the current locale's translation catalog at run
362
- * time via `gettext`, regardless of whether its contents also contain an
363
- * expansion. `'locale'` takes precedence over any `'expansion'` a
364
- * `$"..."` word's *contents* would otherwise report — the `DblQuoted`
365
- * node's own `dollar` flag is checked before its children are visited at
366
- * all, so e.g. `$"hi $x"` reports `'locale'`, never `'expansion'`.
367
- * - `'unsupported'` the word contains a `$'...'` (ANSI-C quoted) `\u`/`\U`
368
- * escape whose value has no corresponding Unicode character (greater than
369
- * `U+10FFFF`, e.g. `$'\UFFFFFFFF'`): real bash's output for this case is
370
- * an undocumented, non-UTF-8 byte sequence with no exact representation
371
- * as a JavaScript string (see `ansi-c-escapes.ts`'s
372
- * `MAX_UNICODE_CODE_POINT` doc comment for the empirical detail) this
373
- * package declines to claim a `static: true` text bash never actually
374
- * produces.
375
- *
376
- * This union may grow in a **minor** release — new mvdan/sh word-part node
377
- * types, or new statically-shaped-but-dynamic categories, can add new reason
378
- * strings without that being a breaking change. It is deliberately not
379
- * sealed against extension elsewhere in the codebase. A reason string this
380
- * version of the package doesn't yet know about only ever accompanies
381
- * `static: false`, never changes the meaning of `static: true`, so runtime
382
- * consumers that only branch on `.static` are safe by construction across
383
- * such additions; an exhaustive compile-time `switch` over `.reason` should
384
- * still include a `default` case to stay forward-compatible.
385
- *
386
- * @public
387
- */
388
- export declare type WordResolutionReason = 'expansion' | 'tilde' | 'glob' | 'brace' | 'locale' | 'unsupported';
432
+ /**
433
+ * Options controlling {@link resolveWord}'s tilde-expansion detection.
434
+ *
435
+ * @public
436
+ */
437
+ export declare interface ResolveWordOptions {
438
+ /**
439
+ * Which Bash grammar position `word` occupies the two positions Bash
440
+ * §3.5.2 ("Tilde Expansion") documents distinct tilde-expansion triggers
441
+ * for:
442
+ *
443
+ * - `'command-argument'` an ordinary simple-command argument word (e.g.
444
+ * `echo <word>`). Only a *word-initial* unquoted `~` triggers tilde
445
+ * expansion; an unquoted `~` elsewhere in the word (including right
446
+ * after a `:`) is an ordinary character (`echo a:~b` prints `a:~b`
447
+ * literally in real bash).
448
+ * - `'assignment-value'` the value side of a shell assignment (e.g.
449
+ * `PATH=<word>`, an `Assign` node's `value`). Bash additionally
450
+ * tilde-expands an unquoted `~` immediately following an unquoted `:`
451
+ * anywhere in the value (`PATH=/foo:~/bar` expands the `~/bar`
452
+ * segment) this is documented for `PATH`-like colon-separated
453
+ * assignment values specifically.
454
+ *
455
+ * **Default: `'assignment-value'` semantics apply whenever this option is
456
+ * omitted.** `resolveWord` takes a bare `Word` node with no grammar
457
+ * context attached, so it cannot see whether its caller extracted that
458
+ * word from a command argument or an assignment value — omitting this
459
+ * option is deliberately treated as the *more conservative* of the two
460
+ * (the one that reports `'tilde'` in more cases), so a caller that
461
+ * doesn't know or care about the distinction never under-reports a real
462
+ * tilde-expansion site. Pass `'command-argument'` explicitly to opt into
463
+ * the narrower, word-initial-only check that matches real bash's
464
+ * behavior for ordinary arguments.
465
+ *
466
+ * @see https://www.gnu.org/software/bash/manual/bash.html#Tilde-Expansion
467
+ */
468
+ readonly context?: 'command-argument' | 'assignment-value';
469
+ }
470
+
471
+ /**
472
+ * Thrown by `sh-ast/analyze`'s `resolveArgv0` when a caller-supplied
473
+ * `options.transparentWrappers` array contains a malformed `WrapperSpec`
474
+ * entry e.g. a non-array or empty `names`, or a flag field that isn't an
475
+ * array of strings. Fails closed with a clear, specific message at the
476
+ * point the malformed table is supplied, rather than letting the bad shape
477
+ * reach flag-matching logic and surface as a confusing native `TypeError`
478
+ * (or, worse, a silently wrong match) far from its actual cause.
479
+ *
480
+ * @public
481
+ */
482
+ export declare class ShAnalyzeInvalidWrapperSpecError extends ShAstError {
483
+ readonly code = "SH_AST_ANALYZE_INVALID_WRAPPER_SPEC";
484
+ constructor(message: string);
485
+ }
486
+
487
+ /**
488
+ * Thrown by `sh-ast/analyze`'s `enumerateCommands` when a tree's genuinely
489
+ * nested structure subshells within subshells, chained command/process
490
+ * substitutions, deeply nested `if`/`case`/loop/function/`time`/`{ }`
491
+ * bodies, deeply chained `elif` — exceeds its defensive recursion-depth
492
+ * guard. **Not** thrown for a long *linear* chain (`|`/`|&`/`&&`/`||` of
493
+ * any realistic length): `enumerateCommands` traverses those iteratively,
494
+ * so chain length alone never grows this guard's depth counter only
495
+ * genuine tree nesting does.
496
+ *
497
+ * `enumerateCommands` deliberately fails closed here rather than returning
498
+ * a truncated, partial result: a partial `CommandSite[]` silently omits
499
+ * real command sites, which is a false negative for a permission-hook-style
500
+ * consumer that treats "command not found in the enumeration" as "nothing
501
+ * to worry about" — an explicit, documented throw is safer than a silent
502
+ * under-report. Gate-style consumers should treat this error as `deny`,
503
+ * not fall back to "no commands found, so allow".
504
+ *
505
+ * @public
506
+ */
507
+ export declare class ShAnalyzeMaxDepthError extends ShAstError {
508
+ readonly code = "SH_AST_ANALYZE_MAX_DEPTH";
509
+ /** The maximum nesting depth `enumerateCommands` supports; the same value every time (not caller-configurable). */
510
+ readonly maxDepth: number;
511
+ constructor(maxDepth: number);
512
+ }
513
+
514
+ /**
515
+ * Common base class for every error this package throws — originally just
516
+ * {@link sh-ast#parseSync | parseSync}'s errors, now also the
517
+ * `sh-ast/analyze` layer's (see {@link ShAnalyzeMaxDepthError},
518
+ * {@link ShAnalyzeInvalidWrapperSpecError}). Provides a stable, documented
519
+ * `code` discriminator (e.g. `"SH_AST_PARSE_ERROR"`) alongside the usual
520
+ * `instanceof` narrowing, so consumers can branch on failure kind
521
+ * programmatically without parsing `.message` strings. Never thrown directly
522
+ * — only via its concrete subclasses
523
+ * ({@link sh-ast#ShParseError | ShParseError},
524
+ * {@link sh-ast#ShInvalidDialectError | ShInvalidDialectError},
525
+ * {@link sh-ast#ShInternalError | ShInternalError},
526
+ * {@link ShAnalyzeMaxDepthError},
527
+ * {@link sh-ast#ShParseMaxDepthError | ShParseMaxDepthError},
528
+ * {@link ShAnalyzeInvalidWrapperSpecError}).
529
+ *
530
+ * Re-exported from `sh-ast/analyze` as well as the root `sh-ast` entry
531
+ * point (see #23) — every subclass thrown by `sh-ast/analyze`'s own
532
+ * functions extends this, so a consumer of that subpath alone can
533
+ * `catch`/reference the base without also importing from root.
534
+ *
535
+ * @public
536
+ */
537
+ export declare abstract class ShAstError extends Error {
538
+ /**
539
+ * Stable, machine-readable discriminator for this error kind. Distinct
540
+ * per subclass and will not change once published.
541
+ */
542
+ abstract readonly code: string;
543
+ }
544
+
545
+ /**
546
+ * A normalized AST node produced by {@link sh-ast#parseSync | parseSync}.
547
+ *
548
+ * `range` and `loc` are expressed in UTF-16 code units, so
549
+ * `code.slice(node.range[0], node.range[1])` reproduces the node's exact
550
+ * source text — even though mvdan/sh itself reports byte offsets and
551
+ * byte-counting columns internally (see design/ARCHITECTURE.md's
552
+ * "Serialization contract"). Every other field copied over from mvdan/sh's
553
+ * typedjson tree keeps its original value but with the field name
554
+ * lowercased (`Stmts` becomes `stmts`, `CondLast` becomes `condlast`, etc.).
555
+ *
556
+ * Also re-exported from `sh-ast/analyze` (see #23) — the subpath's public
557
+ * surface consumes and exposes `ShNode`, so a consumer can reference the type
558
+ * without also importing from the root `sh-ast` entry point.
559
+ *
560
+ * @public
561
+ */
562
+ export declare interface ShNode {
563
+ readonly type: string;
564
+ readonly range: readonly [number, number];
565
+ readonly loc: {
566
+ readonly start: Position;
567
+ readonly end: Position;
568
+ };
569
+ [field: string]: unknown;
570
+ }
571
+
572
+ /**
573
+ * The result of statically resolving a `Word` node's text. Reports facts
574
+ * only — never a safety verdict, never a hardcoded command/wrapper list.
575
+ * `static: false` ("unknowable") is a first-class, valid result, not an
576
+ * error condition.
577
+ *
578
+ * @public
579
+ */
580
+ export declare type WordResolution = {
581
+ /** The word is a compile-time-known string. */
582
+ readonly static: true;
583
+ /** The word's exact resolved text (quotes and escapes removed). */
584
+ readonly text: string;
585
+ } | {
586
+ /** The word's value depends on program state, the environment, or the filesystem. */
587
+ readonly static: false;
588
+ /** Why this word cannot be statically resolved. */
589
+ readonly reason: WordResolutionReason;
590
+ };
591
+
592
+ /**
593
+ * Why a {@link resolveWord} result is `static: false`: the word's exact
594
+ * text cannot be determined from source alone.
595
+ *
596
+ * - `'expansion'` — the word contains a `ParamExp` (`$x`, `${x}`), a
597
+ * `CmdSubst` (command substitution, either `$(...)` or backtick form), an
598
+ * `ArithmExp` (`$((expr))`), or a `ProcSubst` (`<(cmd)`/`>(cmd)`) part:
599
+ * its value depends on program state (variables, command output, process
600
+ * substitution) that only exists at run time.
601
+ * - `'tilde'` — an unquoted, unescaped `~` triggers Bash tilde expansion
602
+ * (Bash Reference Manual §3.5.2, "Tilde Expansion") at the start of the
603
+ * word, or — when `resolveWord`'s `context` option is `'assignment-value'`
604
+ * or omitted — immediately after an unquoted `:` anywhere in the word too
605
+ * (§3.5.2 also documents this for assignment-statement values, e.g.
606
+ * `PATH=/foo:~/bar`). Either way: statically shaped, but its value
607
+ * depends on the filesystem/environment (`$HOME`, NSS user database
608
+ * lookups) at run time. See `resolveWord`'s `context` parameter doc for
609
+ * the full command-argument-vs-assignment-value distinction.
610
+ * - `'glob'` — the word contains an unquoted, unescaped glob metacharacter
611
+ * (`*`/`?`), an unquoted bracket expression (`[...]` — an unquoted `[`
612
+ * with a later unquoted `]` in the same word, POSIX 2.13.1 / Bash
613
+ * Reference Manual §3.5.8.1, "Pattern Matching"), or an `ExtGlob` part
614
+ * (Bash Reference Manual §3.5.8, "Filename Expansion"): its value depends
615
+ * on which filesystem entries exist at run time.
616
+ * - `'brace'` — the word contains a `BraceExp` part (`{a,b}`/`{1..10}`,
617
+ * Bash Reference Manual §3.5.1, "Brace Expansion"). mvdan/sh v3.13.1 only
618
+ * ever produces this node type when its caller applies `syntax.SplitBraces`
619
+ * to the parsed tree; this bridge's shim does not call it, so today a
620
+ * brace expression like `{a,b}` reaches {@link resolveWord} as an
621
+ * ordinary `Lit` (indistinguishable, in the AST this bridge produces,
622
+ * from literal `{`/`,`/`}` characters) rather than as a `BraceExp` part.
623
+ * This reason is handled here for forward-compatibility with that node
624
+ * type and is not reachable through {@link resolveWord} today.
625
+ * - `'locale'` — the word contains a `$"..."` locale-translated string
626
+ * (Bash Reference Manual §3.1.2.5, "Locale-Specific Translation"): its
627
+ * value is looked up in the current locale's translation catalog at run
628
+ * time via `gettext`, regardless of whether its contents also contain an
629
+ * expansion. `'locale'` takes precedence over any `'expansion'` a
630
+ * `$"..."` word's *contents* would otherwise report — the `DblQuoted`
631
+ * node's own `dollar` flag is checked before its children are visited at
632
+ * all, so e.g. `$"hi $x"` reports `'locale'`, never `'expansion'`.
633
+ * - `'unsupported'` — the word contains a `$'...'` (ANSI-C quoted) `\u`/`\U`
634
+ * escape whose value has no corresponding Unicode character (greater than
635
+ * `U+10FFFF`, e.g. `$'\UFFFFFFFF'`): real bash's output for this case is
636
+ * an undocumented, non-UTF-8 byte sequence with no exact representation
637
+ * as a JavaScript string (see `ansi-c-escapes.ts`'s
638
+ * `MAX_UNICODE_CODE_POINT` doc comment for the empirical detail) — this
639
+ * package declines to claim a `static: true` text bash never actually
640
+ * produces.
641
+ *
642
+ * This union may grow in a **minor** release — new mvdan/sh word-part node
643
+ * types, or new statically-shaped-but-dynamic categories, can add new reason
644
+ * strings without that being a breaking change. It is deliberately not
645
+ * sealed against extension elsewhere in the codebase. A reason string this
646
+ * version of the package doesn't yet know about only ever accompanies
647
+ * `static: false`, never changes the meaning of `static: true`, so runtime
648
+ * consumers that only branch on `.static` are safe by construction across
649
+ * such additions; an exhaustive compile-time `switch` over `.reason` should
650
+ * still include a `default` case to stay forward-compatible.
651
+ *
652
+ * @public
653
+ */
654
+ export declare type WordResolutionReason = 'expansion' | 'tilde' | 'glob' | 'brace' | 'locale' | 'unsupported';
655
+
656
+ /**
657
+ * A data-only description of one "transparent wrapper" — a command whose
658
+ * own argv0 is not the *effective* command actually run, because it just
659
+ * locates and re-execs another command (`env`, `sudo`, `nohup`, …).
660
+ * {@link resolveArgv0} interprets every field here structurally (skip N
661
+ * words, recognize this literal flag, …); adding a new `WrapperSpec` never
662
+ * requires a code change to {@link resolveArgv0} itself — that's what makes
663
+ * {@link DEFAULT_TRANSPARENT_WRAPPERS} data rather than a set of hardcoded
664
+ * `if (name === 'env')` branches, and what lets a caller extend or replace
665
+ * the table with their own project-specific wrappers (e.g. a `with-retry`
666
+ * helper).
667
+ *
668
+ * Every flag/operand-recognizing field is checked against a word's
669
+ * *statically resolved* text only (its `.text` on a {@link WordResolution}
670
+ * `static: true` result) — a dynamic word can never be identified as one of
671
+ * a wrapper's own flags or operands (see {@link resolveArgv0}'s doc comment
672
+ * for why an unrecognizable word always ends the chain rather than being
673
+ * guessed through).
674
+ *
675
+ * This shape may grow new optional fields in a **minor** release as this
676
+ * bridge models more wrapper flag conventions; existing fields' meaning
677
+ * never changes.
678
+ *
679
+ * @public
680
+ */
681
+ export declare interface WrapperSpec {
682
+ /**
683
+ * The literal, exact argv0 text (after quote/escape removal) that
684
+ * identifies this wrapper — e.g. `['env']`. Matched only against a
685
+ * `static: true` word; see {@link WrapperSpec}'s doc comment.
686
+ *
687
+ * Matching is **exact-name-only** — never basename matching. `sudo` does
688
+ * not match `/usr/bin/sudo` or `./sudo`; only a word whose *entire*
689
+ * resolved text equals one of `names` is recognized. This is a
690
+ * deliberate, narrower-than-real-world policy choice (a real shell would
691
+ * happily run `/usr/bin/sudo`), not an oversight: silently treating any
692
+ * path *ending in* a known wrapper name as that wrapper would be a
693
+ * different, broader matching policy this package isn't making on a
694
+ * caller's behalf. A caller who wants path-aware matching can express it
695
+ * explicitly via a custom {@link ResolveArgv0Options.transparentWrappers}
696
+ * table (e.g. `names: ['sudo', '/usr/bin/sudo']`).
697
+ */
698
+ readonly names: readonly string[];
699
+ /**
700
+ * When `true`, a `NAME=value`-shaped operand (an unquoted identifier
701
+ * followed by `=`, e.g. `A=1`) appearing after the wrapper name is
702
+ * skipped — this is `env`'s and `sudo`'s own `VAR=val` *operand*
703
+ * mechanism (setting variables in the wrapped command's environment),
704
+ * which is a distinct, per-wrapper concept from the shell-level
705
+ * `CallExpr.assigns` prefixes {@link Argv0Resolution.assignmentsSkipped}
706
+ * counts.
707
+ */
708
+ readonly skipAssignmentOperands?: boolean;
709
+ /** Exact flag tokens that take no operand of their own (e.g. `-i`, `-c`). */
710
+ readonly noArgFlags?: readonly string[];
711
+ /**
712
+ * A regular expression matched against a whole flag-position word for
713
+ * flag *shapes* that aren't fixed strings — e.g. `nice`'s legacy attached
714
+ * adjustment form (`nice -10 cmd`). Consumes no separate operand word,
715
+ * like {@link WrapperSpec.noArgFlags}.
716
+ */
717
+ readonly noArgFlagPattern?: RegExp;
718
+ /**
719
+ * Exact flag tokens that take an operand (e.g. `-u user`, `-n 10`).
720
+ * Recognized in every standard getopt-style form:
721
+ *
722
+ * - Separate word: `-u user` (both words skipped).
723
+ * - Attached short form: `-uuser` (one word — everything after the flag
724
+ * character is the operand text, exactly like real getopt).
725
+ * - Clustered with preceding no-operand short flags:
726
+ * `-Eu user`/`-Euuser` (`-E` from {@link WrapperSpec.noArgFlags},
727
+ * `-u` from here) — see {@link resolveArgv0}'s doc comment for the
728
+ * clustering algorithm.
729
+ * - Attached long form (only for an entry starting with `--`):
730
+ * `--long-flag=value` (one word).
731
+ *
732
+ * Any of these forms is recognized without a separate `WrapperSpec`
733
+ * field — the shapes are derived structurally from `argFlags` and
734
+ * `noArgFlags` together, matching how real short-option parsing works,
735
+ * rather than needing to be spelled out per wrapper.
736
+ */
737
+ readonly argFlags?: readonly string[];
738
+ /**
739
+ * Exact flag tokens (and, for a `--`-prefixed entry, its attached
740
+ * `--flag=value` form; for a short `-X` entry, its attached `-Xvalue`
741
+ * form) that make the **whole** {@link resolveArgv0} resolution
742
+ * unresolvable the moment they're seen, rather than being skipped like
743
+ * {@link WrapperSpec.argFlags} or ending the chain normally like an
744
+ * unrecognized word. For a flag whose *value itself* structurally embeds
745
+ * the real command in a way no `WrapperSpec` field can name a fixed
746
+ * "the wrapped command is word N" position for — e.g. `env -S string`
747
+ * splices `string`'s own words into argv, so the real command is
748
+ * *inside* that operand, not identifiable as a separate word at all (see
749
+ * `env`'s table entry). Reported via
750
+ * {@link Argv0Resolution.effective}'s `'embedded-command'` reason.
751
+ */
752
+ readonly unresolvableFlags?: readonly string[];
753
+ /**
754
+ * Exact flag tokens whose mere presence means this wrapper's own
755
+ * invocation *is* the effective command — nothing after it is ever the
756
+ * wrapped command, regardless of what other words follow. E.g.
757
+ * `command -v rm` doesn't execute `rm` at all; it prints whether `rm`
758
+ * is a recognized command name (Bash Reference Manual §4.1). Checked
759
+ * independently of word position (not "the Nth flag"), and takes
760
+ * precedence the moment it's seen — see `command`'s table entry.
761
+ */
762
+ readonly stopsChainFlags?: readonly string[];
763
+ /**
764
+ * The number of plain (non-flag, non-assignment-operand) words this
765
+ * wrapper requires *before* the wrapped command word — e.g. `timeout`'s
766
+ * mandatory `DURATION` operand (`timeout 10 rm -rf x`).
767
+ */
768
+ readonly positionalOperandsBeforeCommand?: number;
769
+ }
389
770
 
390
- export { }
771
+ export { }