dsh-hashline-edit 0.2.1 → 0.2.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 (109) hide show
  1. package/README.md +60 -56
  2. package/cordis.patch.yml +3 -3
  3. package/lib/index.js +7224 -6454
  4. package/lib/types/dsh/contracts.d.ts +3 -12
  5. package/lib/types/dsh/fs.d.ts +0 -3
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/host-compat.d.ts +78 -7
  8. package/lib/types/dsh/index.d.ts +9 -21
  9. package/lib/types/dsh/metrics/events.d.ts +1 -1
  10. package/lib/types/dsh/metrics/observer.d.ts +1 -1
  11. package/lib/types/dsh/sandbox.d.ts +2 -2
  12. package/lib/types/dsh/shell-hook.d.ts +1 -1
  13. package/lib/types/dsh/tools/edit.d.ts +10 -0
  14. package/lib/types/dsh/tools/read.d.ts +3 -3
  15. package/lib/types/src/domain-errors.d.ts +2 -16
  16. package/lib/types/src/edit-tool.d.ts +41 -0
  17. package/lib/types/src/edit-undo.d.ts +2 -11
  18. package/lib/types/src/file-content/index.d.ts +1 -1
  19. package/lib/types/src/file-content/preview.d.ts +1 -1
  20. package/lib/types/src/hash-store.d.ts +2 -2
  21. package/lib/types/src/hashline/alphabet.d.ts +16 -1
  22. package/lib/types/src/hashline/hash-identity.d.ts +10 -4
  23. package/lib/types/src/mutation-engine/engine.d.ts +31 -0
  24. package/lib/types/src/mutation-engine/pipeline.d.ts +1 -8
  25. package/lib/types/src/mutation-engine/types.d.ts +50 -40
  26. package/lib/types/src/payload-contract.d.ts +135 -0
  27. package/lib/types/src/served-session/serve-render.d.ts +0 -6
  28. package/lib/types/src/undo-store.d.ts +1 -17
  29. package/package.json +21 -18
  30. package/prompts/edit-guidelines.md +10 -0
  31. package/prompts/edit-snippet.md +1 -0
  32. package/prompts/edit.md +1 -0
  33. package/prompts/read-guidelines.md +4 -4
  34. package/prompts/read.md +1 -1
  35. package/prompts/undo-last-edit-guidelines.md +2 -2
  36. package/prompts/undo-last-edit-snippet.md +1 -1
  37. package/prompts/undo-last-edit.md +1 -1
  38. package/src/bash-classifier.ts +1626 -0
  39. package/src/bash-conversion/decide.ts +812 -0
  40. package/src/bash-display-transform.ts +50 -0
  41. package/src/bash-multifile-config.ts +239 -0
  42. package/src/bash-view-config.ts +158 -0
  43. package/src/cut-repair.ts +11 -77
  44. package/src/domain-errors.ts +3 -35
  45. package/src/edit-normalize.ts +8 -0
  46. package/src/edit-render.ts +161 -0
  47. package/src/edit-tool.ts +118 -0
  48. package/src/edit-undo.ts +25 -379
  49. package/src/edit.ts +136 -0
  50. package/src/file-content/index.ts +1 -1
  51. package/src/file-content/preview.ts +1 -1
  52. package/src/file-content/served-header.ts +1 -1
  53. package/src/hash-store.ts +1 -18
  54. package/src/hashline/alphabet.ts +42 -1
  55. package/src/hashline/hash-identity.ts +12 -3
  56. package/src/integrations/pi-lens/command.ts +712 -0
  57. package/src/integrations/pi-lens/config.ts +148 -0
  58. package/src/integrations/pi-lens/io-bridge-adapter.ts +234 -0
  59. package/src/integrations/pi-lens/io-bridge.ts +112 -0
  60. package/src/integrations/pi-lens/settings-overlay.ts +272 -0
  61. package/src/lifecycle-hooks/index.ts +1992 -0
  62. package/src/lifecycle-hooks/types.ts +64 -0
  63. package/src/mutated-files.ts +75 -0
  64. package/src/mutation-engine/engine.ts +239 -0
  65. package/src/mutation-engine/index.ts +17 -0
  66. package/src/mutation-engine/pipeline.ts +27 -198
  67. package/src/mutation-engine/types.ts +57 -39
  68. package/src/mutation-queue.ts +1 -1
  69. package/src/payload-contract.ts +685 -0
  70. package/src/preview-controller.ts +84 -0
  71. package/src/prompts.ts +43 -10
  72. package/src/read.ts +865 -0
  73. package/src/search-hook.ts +41 -0
  74. package/src/served-session/serve-render.ts +2 -2
  75. package/src/store-config.ts +73 -0
  76. package/src/store-janitor-warning.ts +169 -0
  77. package/src/store-janitor.ts +539 -0
  78. package/src/store-kernel/settings.ts +233 -0
  79. package/src/store.ts +154 -0
  80. package/src/tui-presenter.ts +134 -0
  81. package/src/undo-store.ts +5 -74
  82. package/src/write-hook.ts +130 -0
  83. package/lib/types/dsh/hide.d.ts +0 -79
  84. package/lib/types/dsh/tools/apply-patch.d.ts +0 -38
  85. package/lib/types/src/apply-patch/commit.d.ts +0 -49
  86. package/lib/types/src/apply-patch/grammar.d.ts +0 -29
  87. package/lib/types/src/apply-patch/lifecycle.d.ts +0 -32
  88. package/lib/types/src/apply-patch/plan.d.ts +0 -50
  89. package/lib/types/src/apply-patch/render.d.ts +0 -51
  90. package/lib/types/src/apply-patch/resolve.d.ts +0 -129
  91. package/lib/types/src/apply-patch/tool.d.ts +0 -54
  92. package/lib/types/src/apply-patch/types.d.ts +0 -362
  93. package/lib/types/src/fs-lifecycle.d.ts +0 -44
  94. package/lib/types/src/host-utils.d.ts +0 -63
  95. package/lib/types/src/mutation-engine/edit-request.d.ts +0 -34
  96. package/prompts/apply-patch-guidelines.md +0 -10
  97. package/prompts/apply-patch.md +0 -1
  98. package/src/apply-patch/commit.ts +0 -190
  99. package/src/apply-patch/grammar.ts +0 -620
  100. package/src/apply-patch/index.ts +0 -17
  101. package/src/apply-patch/lifecycle.ts +0 -195
  102. package/src/apply-patch/plan.ts +0 -800
  103. package/src/apply-patch/render.ts +0 -137
  104. package/src/apply-patch/resolve.ts +0 -470
  105. package/src/apply-patch/tool.ts +0 -257
  106. package/src/apply-patch/types.ts +0 -387
  107. package/src/fs-lifecycle.ts +0 -201
  108. package/src/host-utils.ts +0 -224
  109. package/src/mutation-engine/edit-request.ts +0 -39
@@ -0,0 +1,1626 @@
1
+ /**
2
+ * AST-guarded classification of bash commands into slice-accurate file views.
3
+ *
4
+ * WHY a pure module: the chain rule, the slice algebra, and every adversarial
5
+ * guard (ADR-0033 D1/D2/D5) are pure AST predicates — unit-testable without
6
+ * fixtures, stores, or the pi runtime. The lifecycle layer applies the result
7
+ * to the disk re-read; this module never touches the filesystem.
8
+ *
9
+ * WHY unbash instead of regex: a regex cannot tell `cat f > g` (mutation)
10
+ * from `cat f` (view), nor `cat $F` (expansion) from `cat f` (literal). The
11
+ * typed AST makes every dangerous shape structural: redirects, heredocs,
12
+ * substitutions, and expansions are nodes, so fail-closed is the absence of
13
+ * a proof rather than the presence of a pattern.
14
+ */
15
+ import type { Command, Pipeline, Statement, Word } from "unbash";
16
+ import { BASH_SEARCH_MAX_FILES } from "./constants.js";
17
+ import { decide } from "./bash-conversion/decide.js";
18
+ import type { Decision } from "./bash-conversion/decide.js";
19
+ import { assertNever } from "./utils.js";
20
+
21
+ /** Maximum pipeline stages in one view segment (source + filters). */
22
+ export const BASH_VIEW_MAX_PIPELINE_STAGES = 4;
23
+ /** Maximum `&&` segments in one classified chain. */
24
+ export const BASH_VIEW_MAX_CHAIN_SEGMENTS = 8;
25
+
26
+ /**
27
+ * One exact line-window filter, stream-relative and 1-indexed. Every op is
28
+ * total over any input length: out-of-range selections yield no lines, never
29
+ * an error, so `applySliceOps` needs no failure channel.
30
+ */
31
+ export type SliceOp =
32
+ | { kind: "first"; n: number }
33
+ | { kind: "last"; n: number }
34
+ | { kind: "dropLast"; n: number }
35
+ | { kind: "from"; n: number }
36
+ | { kind: "range"; from: number; to: number };
37
+
38
+ /**
39
+ * WHY: (item 9) a content filter cannot fold over `LineInterval[]` — that algebra is
40
+ * WHY: count-based over a line count, so a keep/drop stage needs the line bytes. These ops
41
+ * WHY: carry the filter through the ordered stage array; the lifecycle layer folds them over
42
+ * WHY: `(true line, stream text)` pairs left to right and byte-gates the recomputed output
43
+ * WHY: against the observed stdout. Matching is literal substring, so a regex-looking
44
+ * WHY: pattern simply fails that gate and passes through raw.
45
+ *
46
+ * WHY: (item 11) `sortUnique`, `uniq` and `count` are the line-SET consumers. `sortUnique`
47
+ * WHY: (`sort -u`) dedupes by text keeping the FIRST occurrence's true line, then orders by
48
+ * WHY: raw bytes; `uniq` collapses ADJACENT equal runs keeping each run head; `count`
49
+ * WHY: (`wc -l`) is terminal — it consumes the whole stream and emits its length, so
50
+ * WHY: `parseViewSegment` refuses any stage after it. The first two reorder the stream, which
51
+ * WHY: is why the render states the order in its header while every lease keeps the true file
52
+ * WHY: coordinate it was folded from.
53
+ */
54
+ type ContentOp =
55
+ | { kind: "keep"; pattern: string }
56
+ | { kind: "drop"; pattern: string }
57
+ | { kind: "sortUnique" }
58
+ | { kind: "uniq" }
59
+ | { kind: "count" };
60
+
61
+ /**
62
+ * WHY: (item 9) one ordered pipeline stage. A single fold-left array keeps `grep|head` and
63
+ * WHY: `head|grep` distinguishable by position; `SliceOp` is a structural subset, so every
64
+ * WHY: Tier-1/2 `toEqual` literal still matches.
65
+ */
66
+ export type ViewOp = SliceOp | ContentOp;
67
+
68
+ /**
69
+ * WHY: (item 11) the union split is a total function, not a hand-rolled predicate: every arm is
70
+ * WHY: named here, so a new `ViewOp` kind fails `pnpm run typecheck` at this switch instead of
71
+ * WHY: being classified as a slice and folded through the interval algebra by accident.
72
+ */
73
+ export function isContentOp(viewOp: ViewOp): viewOp is ContentOp {
74
+ switch (viewOp.kind) {
75
+ case "keep":
76
+ case "drop":
77
+ case "sortUnique":
78
+ case "uniq":
79
+ case "count":
80
+ return true;
81
+ case "first":
82
+ case "last":
83
+ case "dropLast":
84
+ case "from":
85
+ case "range":
86
+ return false;
87
+ default:
88
+ return assertNever(viewOp);
89
+ }
90
+ }
91
+
92
+ /** The complement of `isContentOp`, derived from it so the split has one authority. */
93
+ export function isSliceOp(viewOp: ViewOp): viewOp is SliceOp {
94
+ return !isContentOp(viewOp);
95
+ }
96
+
97
+ /** Ordered disjoint 1-indexed inclusive line intervals over `[1..L]`. */
98
+ export interface LineInterval {
99
+ lo: number;
100
+ hi: number;
101
+ }
102
+
103
+ /**
104
+ * WHY: (ADR-0048 D2) the closed display-transform set. The transform is never served: the
105
+ * lifecycle re-runs it over the re-read raw slice to prove the observed stdout, then serves
106
+ * the raw rows and leases only those rows. `cat -vet` is the BSD/GNU-portable spelling of
107
+ * `cat -A`; both are admitted because the oracle compares the actual transform bytes.
108
+ */
109
+ type DisplayTransformKind = "cat-A" | "cat-vet" | "od-c" | "xxd" | "hexdump-C";
110
+ export interface DisplayTransform {
111
+ kind: DisplayTransformKind;
112
+ label: string;
113
+ }
114
+
115
+ export interface BashView {
116
+ /** File path exactly as written (relative or absolute). */
117
+ filePath: string;
118
+ /** Single pre-view literal `cd` target, if any (resolution base). Multi-`cd` and
119
+ * post-view-`cd` chains fail closed to pass-through (`multi-cd` / `post-view-cd`)
120
+ * because bash resolves each `cd` against the directory in effect at that point —
121
+ * applying only one of them out of order would lease a path never viewed. */
122
+ baseDir?: string;
123
+ /** Ordered stages in stream order; see `ViewOp` — folded left, so order matters. */
124
+ ops: ViewOp[];
125
+ /** Present-and-true only for `cat -n FILE` / `nl -ba FILE`; absent means unnumbered. Optional so Tier-1 `toEqual` literals keep passing. */
126
+ lineNumbered?: boolean;
127
+ /** (ADR-0048 D2) terminal display transform over this view; absent for a plain view. */
128
+ display?: DisplayTransform;
129
+ }
130
+
131
+ /**
132
+ * WHY: (ADR-0048 D3 — context searches) the window one search asked `grep`/`rg` to print around
133
+ * every match: `-B`/`--before-context` lines before it, `-A`/`--after-context` lines after it, and
134
+ * `-C`/`--context` both. The seam carries the window, not just "context was requested", because
135
+ * every served context row must fall inside SOME match's window — that membership check is the
136
+ * search's own prediction half (ADR-0048 D1) evaluated against the re-read file.
137
+ */
138
+ export interface BashSearchContext {
139
+ /** Context lines printed BEFORE each match (`-B N` / `--before-context`); 0 when unasked. */
140
+ before: number;
141
+ /** Context lines printed AFTER each match (`-A N` / `--after-context`); 0 when unasked. */
142
+ after: number;
143
+ }
144
+
145
+ /**
146
+ * One admitted single-file search: `grep`/`rg` over an allowlisted flag set (spec §3.2).
147
+ * The pattern and file are the operands exactly as written (unquoted by the parser) so the
148
+ * lifecycle layer resolves the same path bash did, and the `-n` injection is a pure splice.
149
+ */
150
+ export interface BashSearch {
151
+ program: SearchProgram;
152
+ /** Match pattern exactly as written; may begin with `-` only after a `--` separator. */
153
+ pattern: string;
154
+ /** The single file operand exactly as written (relative or absolute). */
155
+ filePath: string;
156
+ /** True when the model already asked for line numbers (`-n` / `--line-number`). */
157
+ lineNumbered: boolean;
158
+ /** Slice ops from a trailing `head`/`tail` pipeline (ADR-0039 D3); absent for a bare search. */
159
+ sliceOps?: SliceOp[];
160
+ /** Resolution base for a single pre-view literal `cd` — the view rule verbatim (ADR-0033 D1). */
161
+ baseDir?: string;
162
+ /**
163
+ * WHY: (ADR-0048 D3) present only when the search asked for a context window. Absent keeps every
164
+ * Tier-1 `toEqual` literal byte-identical and the admitted stdout geometry strictly `LINE:content`.
165
+ */
166
+ context?: BashSearchContext;
167
+ }
168
+
169
+ /** The only two intercepted search programs (spec §3.2). */
170
+ export type SearchProgram = "grep" | "rg";
171
+
172
+ /**
173
+ * WHY: (item 10) `grep`/`rg PATTERN FILE...` — two or more explicit files, no recursion flag.
174
+ * WHY: Every operand is a FILE, so the stdout geometry is the `FILE:LINE:content` triple that
175
+ * WHY: its own serve seam parses; the single-file `BashSearch` above is left byte-identical.
176
+ */
177
+ export interface BashMultiSearch {
178
+ program: SearchProgram;
179
+ /** Match pattern exactly as written; same `-`-leading/`-e` rules as `BashSearch`. */
180
+ pattern: string;
181
+ /** Every file operand exactly as written, in operand order; 2..`BASH_SEARCH_MAX_FILES`. */
182
+ filePaths: string[];
183
+ lineNumbered: boolean;
184
+ baseDir?: string;
185
+ }
186
+
187
+ /** WHY: (item 10) `grep -r PATTERN DIR` — exactly one explicit dir (or file) operand. */
188
+ export interface BashRecursiveSearch {
189
+ program: SearchProgram;
190
+ pattern: string;
191
+ /** The one operand exactly as written; a file operand recurses over just that file. */
192
+ dirPath: string;
193
+ lineNumbered: boolean;
194
+ baseDir?: string;
195
+ }
196
+
197
+ /** WHY: (item 10) `rg --files [DIR]` — an anchorless, existence-checked path list. */
198
+ export interface BashFilesList {
199
+ program: "rg";
200
+ /** Optional explicit list root exactly as written; absent means the effective cwd. */
201
+ dirPath?: string;
202
+ baseDir?: string;
203
+ }
204
+
205
+ /**
206
+ * WHY: (ADR-0049 M1) a standalone multi-file view: `cat FILE...`, `cat -n FILE...`,
207
+ * `nl -ba FILE...`, or `sed -n RANGE FILE...` over two or more newline-terminated text files.
208
+ * The operands stay exactly as written (one page each, in operand order) so the lifecycle layer
209
+ * resolves the same paths bash opened; `lineNumbered` names the display transform the observed
210
+ * stdout must reproduce, and `ops` is the numeric print selection folded over the STREAM (files laid
211
+ * end to end), which is how a `sed -n` range spanning files is expressed without a byte path.
212
+ *
213
+ * WHY this is its own tag rather than a widened `BashView`: one `filePath` cannot describe N pages,
214
+ * and the serve seam that proves line-count boundaries is a different seam from the single-file one.
215
+ * The single-file shape keeps its exact `toEqual` contract.
216
+ */
217
+ export interface BashMultiView {
218
+ /** The program exactly as written (`cat`, `nl`, or `sed`). */
219
+ program: "cat" | "nl" | "sed";
220
+ /** Every file operand in operand order, exactly as written; 2 or more.
221
+ * The file COUNT cap is a configured policy (ADR-0049 M6), enforced at the serve seam. */
222
+ filePaths: string[];
223
+ /** Numeric print selection, folded over the concatenated stream; empty means every line. */
224
+ ops: SliceOp[];
225
+ /** `cat -n` / `nl -ba`: the stdout is the continuous numbering transform (ADR-0049 M3). */
226
+ lineNumbered?: boolean;
227
+ /** Resolution base for a single pre-view literal `cd` — the view rule verbatim (ADR-0033 D1). */
228
+ baseDir?: string;
229
+ }
230
+
231
+ /**
232
+ * WHY: (item 10) the admitted search family, tagged by stdout geometry so each shape gets its
233
+ * WHY: own serve seam. `pureSearch` is the Tier-1/2 single-file shape and keeps its exact
234
+ * WHY: `toEqual` contract; the three new tags never reshape it.
235
+ */
236
+ export type SearchFamily =
237
+ | { kind: "pureSearch"; search: BashSearch }
238
+ | { kind: "pureMultiSearch"; search: BashMultiSearch }
239
+ | { kind: "pureRecursiveSearch"; search: BashRecursiveSearch }
240
+ | { kind: "pureFilesList"; search: BashFilesList };
241
+
242
+ /** A static glue segment: a literal print or silent env command with parse-time expected bytes. */
243
+ export interface GlueSegment {
244
+ /** Program exactly as written (`"echo"`, `"printf"`, `"export"`, `"set"`). */
245
+ program: "echo" | "printf" | "export" | "set";
246
+ /** Exact stdout contribution, computed at parse time and verified during peeling. */
247
+ expectedText: string;
248
+ /** UTF-8 byte length of `expectedText`. */
249
+ expectedBytes: number;
250
+ }
251
+ /** One admitted segment of a compound, in encounter order (`:`/`true` drop out, `cd` to base). */
252
+ export type CompoundSegment =
253
+ | ViewSeg
254
+ | SearchSeg
255
+ | { kind: "glue"; glue: GlueSegment }
256
+ | { kind: "raw"; command: string };
257
+ export type BashClass =
258
+ | { kind: "pureView"; view: BashView }
259
+ | { kind: "pureMultiView"; view: BashMultiView }
260
+ | { kind: "pureSearch"; search: BashSearch }
261
+ | { kind: "pureMultiSearch"; search: BashMultiSearch }
262
+ | { kind: "pureRecursiveSearch"; search: BashRecursiveSearch }
263
+ | { kind: "pureFilesList"; search: BashFilesList }
264
+ | {
265
+ kind: "pureCompound";
266
+ /** Ordered admitted segments; silent prefixes are absorbed (`cd` into `baseDir`). */
267
+ segments: CompoundSegment[];
268
+ /** Chain-level resolution base (single pre-block literal `cd`), if any. */
269
+ baseDir?: string;
270
+ }
271
+ | { kind: "passThrough"; reason: string };
272
+
273
+ /** `sed -n` numeric print only — locks out `w`/`e`/`r`/`s`/`;`/`{}`/`/` (R4). */
274
+ const SED_NUMERIC_PRINT = /^(\d+)(,(\d+))?p$/;
275
+ /** Bare `-N` shorthand (`head -20`), corpus-dominant per R2. */
276
+ const BARE_COUNT = /^-(\d+)$/;
277
+ /** Attached `-n` forms (`-n20`, `-n+55`, `-n-5`). */
278
+ const ATTACHED_COUNT = /^-n(\+?-?\d+)$/;
279
+ /** Unquoted glob metachars — unbash exposes globs as plain words (ADR-0033 D5). */
280
+ const UNQUOTED_GLOB = /[*?[]/;
281
+
282
+ /** The only two intercepted search programs (spec §3.2). */
283
+ const SEARCH_PROGRAMS: ReadonlySet<string> = new Set(["grep", "rg"]);
284
+
285
+ /** The flags a search may carry verbatim (spec §3.2). The value-taking match-limit forms
286
+ * (`-m N`, `--max-count=N`) are parsed in `parseSearchStage` instead. Everything else —
287
+ * output-altering (`-c -v -o -l -L -q -s -b -H -h`), rg's display shapes
288
+ * (`--column --heading --json --stats --files -r -0 --vimgrep -N`), and every other short
289
+ * or long form — fails closed to raw bash by absence from this set. */
290
+ const SEARCH_ALLOWED_FLAGS: ReadonlySet<string> = new Set([
291
+ "-n",
292
+ "--line-number",
293
+ "-i",
294
+ "--ignore-case",
295
+ "-F",
296
+ "--fixed-strings",
297
+ "-E",
298
+ "--extended-regexp",
299
+ "-w",
300
+ "--word-regexp",
301
+ "-x",
302
+ "--line-regexp",
303
+ // WHY: only the `never` value is admitted — `--color` alone, `--color=auto` and
304
+ // WHY: `--color=always` all change or fail to change the row geometry unpredictably.
305
+ "--color=never",
306
+ "--colour=never",
307
+ ]);
308
+
309
+ /** The letters a bundled single-dash cluster may contain (spec §3.2). Any digit or unknown
310
+ * letter anywhere in the cluster fails closed, so `-n5`, `-inQ` and `-x5` stay raw bash.
311
+ * `m` and `e` are special-cased: each may only close the cluster and consumes the next
312
+ * argument (`-nm 3`, `-ine pat`), while the attached `-m` forms (`-m3`, `-nm3`) fail closed.
313
+ * (`-ePAT` never reaches the cluster loop: any token starting `-e` is the attached
314
+ * explicit-pattern form and is handled before decomposition.) */
315
+ const SEARCH_SHORT_LETTERS: ReadonlySet<string> = new Set(["n", "i", "F", "E", "w", "x", "m", "e"]);
316
+
317
+ /** The context options' value kind: `-B` prints before a match, `-A` after it, `-C` both. */
318
+ type SearchContextKind = "before" | "after" | "both";
319
+
320
+ /** WHY: (ADR-0048 D3) the short context letters and what each selects. They are NOT in
321
+ * `SEARCH_SHORT_LETTERS`: each takes a value from the NEXT argument, exactly like `m`/`e`, and the
322
+ * cluster loop special-cases them below. ADR-0048 D3 names `grep -A/-B/-C` and `rg -C` only, so rg's
323
+ * (equally valid) `-A`/`-B` stay denied as unnamed. */
324
+ const SEARCH_CONTEXT_SHORT_LETTERS: ReadonlyMap<string, SearchContextKind> = new Map([
325
+ ["A", "after"],
326
+ ["B", "before"],
327
+ ["C", "both"],
328
+ ]);
329
+
330
+ /** WHY: (ADR-0048 D3) `--after-context`, `--before-context`, `--context` — the long context spellings,
331
+ * granted to `grep` alone because D3 states them inside the `grep` clause and grants `rg` only `-C`.
332
+ * Both value spellings (`--context 3` and `--context=3`) are the same option, so both are admitted. */
333
+ const SEARCH_CONTEXT_LONG_FLAGS: ReadonlyMap<string, SearchContextKind> = new Map([
334
+ ["--after-context", "after"],
335
+ ["--before-context", "before"],
336
+ ["--context", "both"],
337
+ ]);
338
+
339
+ /** The attached long spelling only: `--context=3`. An empty or non-numeric value fails closed. */
340
+ const SEARCH_CONTEXT_LONG_ATTACHED = /^--(after-context|before-context|context)=(.*)$/;
341
+
342
+ /**
343
+ * Fold one context option into the window already read, or `undefined` when the option is not
344
+ * admissible. `-A`/`-B` are the two sides, so they COMPOSE (`-B 2 -A 1` is one window), while `-C`
345
+ * sets both and therefore never composes: it cannot follow a side option and no side option can
346
+ * follow it. Repeating a side is refused too. A zero side always means "unasked" here, because the
347
+ * value itself must be strictly positive.
348
+ */
349
+ function mergeContext(
350
+ current: BashSearchContext | undefined,
351
+ kind: SearchContextKind | undefined,
352
+ value: string | undefined,
353
+ ): BashSearchContext | undefined {
354
+ if (kind === undefined || !isPositiveCount(value)) return undefined;
355
+ const n = Number(value);
356
+ if (current === undefined) {
357
+ if (kind === "before") return { before: n, after: 0 };
358
+ if (kind === "after") return { before: 0, after: n };
359
+ return { before: n, after: n };
360
+ }
361
+ if (kind === "both") return undefined;
362
+ if (kind === "before")
363
+ return current.before === 0 ? { before: n, after: current.after } : undefined;
364
+ return current.after === 0 ? { before: current.before, after: n } : undefined;
365
+ }
366
+
367
+ /** Long grep recursion markers (`-R`'s long form is `--dereference-recursive`). */
368
+ const SEARCH_GREP_RECURSIVE_FLAGS: ReadonlySet<string> = new Set([
369
+ "-r",
370
+ "-R",
371
+ "--recursive",
372
+ "--dereference-recursive",
373
+ ]);
374
+
375
+ /** The attached long match-limit form only: `--max-count=N`. A bare `--max-count` (value as
376
+ * a separate argument) fails closed — spec §3.2 admits the separate-argument spelling for
377
+ * the short `-m` alone. */
378
+ const SEARCH_MAX_COUNT_LONG = /^--max-count=(\d+)$/;
379
+
380
+ /** A run of ASCII digits: the only spelling an admitted `-m`/`--max-count` value may take. */
381
+ const SEARCH_COUNT_VALUE = /^\d+$/;
382
+ /** The two spellings that already request line numbers, so injection is skipped. */
383
+ const SEARCH_LINE_NUMBER_FLAGS: ReadonlySet<string> = new Set(["-n", "--line-number"]);
384
+
385
+ /** The one admitted match-limit value: `N` strictly positive, so `-m 0` and `-m 00` fail
386
+ * closed alongside `-m -1` and `-m abc`. A missing argument is `undefined` at runtime. */
387
+ function isPositiveCount(value: string | undefined): boolean {
388
+ if (value === undefined || !SEARCH_COUNT_VALUE.test(value)) return false;
389
+ const num = Number(value);
390
+ return Number.isSafeInteger(num) && num > 0;
391
+ }
392
+
393
+ /**
394
+ * The statically-known value of a word, or `undefined` when the word can mean
395
+ * anything other than its spelling at runtime: expansions, substitutions,
396
+ * globs, brace expansion, locale/`$''` quoting, leading `~`, or emptiness.
397
+ * Fully-quoted words keep their spelling (quoting disables every expansion).
398
+ */
399
+ function literalValue(word: Word | undefined): string | undefined {
400
+ if (!word) return undefined;
401
+ // WHY: `parts` is a lazy prototype getter (R5) — read it directly once,
402
+ // WHY: never spread or `Object.keys` the word.
403
+ const parts = word.parts;
404
+ if (parts === undefined) {
405
+ if (UNQUOTED_GLOB.test(word.value)) return undefined;
406
+ if (word.value.startsWith("~")) return undefined;
407
+ return word.value === "" ? undefined : word.value;
408
+ }
409
+ if (parts.length === 0) return undefined;
410
+ if (parts[0].type === "Literal" && word.value.startsWith("~")) return undefined;
411
+ let out = "";
412
+ for (const part of parts) {
413
+ if (part.type === "Literal") {
414
+ if (UNQUOTED_GLOB.test(part.value)) return undefined;
415
+ out += part.value;
416
+ } else if (part.type === "SingleQuoted") {
417
+ out += part.value;
418
+ } else if (part.type === "DoubleQuoted") {
419
+ for (const child of part.parts) {
420
+ if (child.type !== "Literal") return undefined;
421
+ out += child.value;
422
+ }
423
+ } else {
424
+ return undefined;
425
+ }
426
+ }
427
+ return out === "" ? undefined : out;
428
+ }
429
+
430
+ function isWord(node: unknown): node is Word {
431
+ return typeof node === "object" && node !== null && (node as { type?: unknown }).type === "Word";
432
+ }
433
+
434
+ interface StageShape {
435
+ name: string;
436
+ args: string[];
437
+ }
438
+
439
+ /** Structural shape gate: no prefix assignments, no redirects, literal name. */
440
+ function stageShape(cmd: Command): StageShape | undefined {
441
+ if (cmd.prefix.length > 0) return undefined;
442
+ if (cmd.redirects.length > 0) return undefined;
443
+ const name = literalValue(cmd.name);
444
+ if (name === undefined) return undefined;
445
+ const args: string[] = [];
446
+ for (const arg of cmd.args) {
447
+ if (!isWord(arg)) return undefined;
448
+ const value = literalValue(arg);
449
+ if (value === undefined) return undefined;
450
+ args.push(value);
451
+ }
452
+ return { name, args };
453
+ }
454
+
455
+ function positiveInt(text: string): number | undefined {
456
+ if (!/^\d+$/.test(text)) return undefined;
457
+ const n = Number(text);
458
+ if (!Number.isSafeInteger(n) || n < 1) return undefined;
459
+ return n;
460
+ }
461
+
462
+ interface CountSelection {
463
+ ops: SliceOp[];
464
+ file?: string;
465
+ }
466
+
467
+ /**
468
+ * `head`/`tail` count selector. Bare `-N` is the corpus-dominant shorthand
469
+ * (R2); `-n N`, `-n+N`, `-n-N`, and attached `-nN` are accepted; `-c`, `-f`,
470
+ * `-F`, `-q`, `-v`, `-z`, long flags, `--`, and lone `-` all fail closed.
471
+ * Flags must precede the file; at most one positional; zero counts rejected.
472
+ */
473
+ function parseCountSelector(name: "head" | "tail", args: string[]): CountSelection | undefined {
474
+ let selector: SliceOp | undefined;
475
+ let file: string | undefined;
476
+ let positionalSeen = false;
477
+ for (let i = 0; i < args.length; i++) {
478
+ const arg = args[i];
479
+ if (arg === "-n") {
480
+ const count = args[i + 1];
481
+ if (count === undefined || selector !== undefined) return undefined;
482
+ const parsed = parseSignedCount(name, count);
483
+ if (parsed === undefined) return undefined;
484
+ selector = parsed;
485
+ i++;
486
+ continue;
487
+ }
488
+ const attached = ATTACHED_COUNT.exec(arg);
489
+ if (attached) {
490
+ if (selector !== undefined) return undefined;
491
+ const parsed = parseSignedCount(name, attached[1]);
492
+ if (parsed === undefined) return undefined;
493
+ selector = parsed;
494
+ continue;
495
+ }
496
+ const bare = BARE_COUNT.exec(arg);
497
+ if (bare) {
498
+ if (selector !== undefined) return undefined;
499
+ const n = positiveInt(bare[1]);
500
+ if (n === undefined) return undefined;
501
+ selector = name === "head" ? { kind: "first", n } : { kind: "last", n };
502
+ continue;
503
+ }
504
+ if (arg.startsWith("-")) return undefined;
505
+ if (positionalSeen) return undefined;
506
+ positionalSeen = true;
507
+ file = arg;
508
+ }
509
+ if (positionalSeen && args[args.length - 1] !== file) return undefined;
510
+ const ops: SliceOp[] = selector ? [selector] : [defaultCount(name)];
511
+ return file === undefined ? { ops } : { ops, file };
512
+ }
513
+
514
+ /** Signed `-n` counts: plain selects, `+N` starts (tail), `-N` drops (head). */
515
+ function parseSignedCount(name: "head" | "tail", text: string): SliceOp | undefined {
516
+ if (text.startsWith("+")) {
517
+ if (name !== "tail") return undefined;
518
+ const n = positiveInt(text.slice(1));
519
+ return n === undefined ? undefined : { kind: "from", n };
520
+ }
521
+ if (text.startsWith("-")) {
522
+ if (name !== "head") return undefined;
523
+ const n = positiveInt(text.slice(1));
524
+ return n === undefined ? undefined : { kind: "dropLast", n };
525
+ }
526
+ const n = positiveInt(text);
527
+ if (n === undefined) return undefined;
528
+ return name === "head" ? { kind: "first", n } : { kind: "last", n };
529
+ }
530
+
531
+ function defaultCount(name: "head" | "tail"): SliceOp {
532
+ return name === "head" ? { kind: "first", n: 10 } : { kind: "last", n: 10 };
533
+ }
534
+
535
+ interface SourceStage {
536
+ filePath: string;
537
+ ops: SliceOp[];
538
+ lineNumbered?: boolean;
539
+ }
540
+
541
+ /** Stages that consume the source file: `cat FILE`, or a file-direct filter. */
542
+ function parseSourceStage(shape: StageShape): SourceStage | undefined {
543
+ // WHY: the `rtk` unwrap lives here (and only here) so every source position —
544
+ // WHY: bare commands, `&&` segments, pipeline stage zero — accepts it uniformly,
545
+ // WHY: while filter stages (`parseFilterStage`) and silent commands never do.
546
+ const stage = unwrapViewWrapper(shape);
547
+ if (!stage) return undefined;
548
+ if (stage.name === "cat") {
549
+ // WHY: (item 8) the numbered spelling is exactly `cat -n FILE`. A second flag,
550
+ // WHY: a long `--number`, an attached `-nFILE`, or a `-`-leading file all fail closed.
551
+ if (stage.args.length === 2 && stage.args[0] === "-n" && !stage.args[1].startsWith("-")) {
552
+ return { filePath: stage.args[1], ops: [], lineNumbered: true };
553
+ }
554
+ if (stage.args.length !== 1 || stage.args[0].startsWith("-")) return undefined;
555
+ return { filePath: stage.args[0], ops: [] };
556
+ }
557
+ // WHY: (item 8) the only admitted `nl` spelling is exactly `nl -ba FILE` (number all
558
+ // WHY: lines, the `cat -n` geometry). `-ba` attached differently (`-b a`,
559
+ // WHY: `--body-numbering=a`, `-bn`, `-bp`, `-bt`, `-v`, `-i`, `-s`, `-w`, `-n*`) and any
560
+ // WHY: extra operand all fail closed.
561
+ if (stage.name === "nl") {
562
+ if (stage.args.length !== 2 || stage.args[0] !== "-ba" || stage.args[1].startsWith("-")) {
563
+ return undefined;
564
+ }
565
+ return { filePath: stage.args[1], ops: [], lineNumbered: true };
566
+ }
567
+ if (stage.name === "head" || stage.name === "tail") {
568
+ const selection = parseCountSelector(stage.name, stage.args);
569
+ if (!selection || selection.file === undefined) return undefined;
570
+ return { filePath: selection.file, ops: selection.ops };
571
+ }
572
+ if (stage.name === "sed") {
573
+ const selection = parseSedArgs(stage.args);
574
+ if (!selection || selection.file === undefined) return undefined;
575
+ return { filePath: selection.file, ops: selection.ops };
576
+ }
577
+ return undefined;
578
+ }
579
+
580
+ /**
581
+ * WHY: (item 10) `rg --files [DIR]` — the anchorless path list. The shape is exactly the flag
582
+ * WHY: plus zero or one non-`-` root; any other token (a pattern, another flag) fails closed,
583
+ * WHY: because this shape has no match rows and therefore no `LINE:content` geometry at all.
584
+ */
585
+ function parseFilesListStage(stage: StageShape): BashFilesList | undefined {
586
+ if (stage.name !== "rg") return undefined;
587
+ if (stage.args.length === 0 || stage.args.length > 2) return undefined;
588
+ if (stage.args[0] !== "--files") return undefined;
589
+ const root = stage.args[1];
590
+ if (root !== undefined && root.startsWith("-")) return undefined;
591
+ return { program: "rg", ...(root !== undefined ? { dirPath: root } : {}) };
592
+ }
593
+
594
+ /**
595
+ * `grep`/`rg` single-file search selector (spec §3.2). Strict default-deny: only the
596
+ * allowlisted flags above survive and the shape must be exactly
597
+ * `[flags] PATTERN [flags] FILE [flags]`. Flags are admitted at any position before `--` —
598
+ * before the pattern, between pattern and file, and after the file — because GNU grep/rg
599
+ * accept post-operand options and the benchmark issues them (`grep pat -i f`). A bundled
600
+ * single-dash cluster (`-in`, `-wF`) is decomposed and EVERY letter must be admitted; the
601
+ * value-taking `m` may only close it (`-nm 3`). The explicit-pattern `-e` is admitted in
602
+ * three spellings — separate (`-e PAT`), attached (`-ePAT`, handled before cluster
603
+ * decomposition since `PAT` letters are not admitted), and cluster-close (`-ine PAT`,
604
+ * mirroring `m`: must close, consumes the next argument) — exactly once; its value becomes
605
+ * THE pattern so the operands must then be exactly `[FILE]`. `--` ends flag parsing, so
606
+ * `grep pat -- -n` names the file `-n`. Fail closed: attached short values (`-m3`, `-nm3`),
607
+ * any digit in a cluster (`-n5`), an unknown letter (`-inQ`), every `=`-long form but
608
+ * `--max-count=N` (`--line-number=x`, `--color=always`), a value that is not a strictly
609
+ * positive integer or not exactly `never`, `-f` (stays denied: file-input patterns break
610
+ * the single-`PATTERN FILE` geometry), `-e` with no value, `-e` twice, a pattern both via
611
+ * `-e` and positionally, a `-`-leading pattern with no `--` (unless given via `-e`), zero
612
+ * operands (stdin), and more than `BASH_SEARCH_MAX_FILES` file operands.
613
+ * Globs, tildes and expansions never reach here because `stageShape`/`literalValue` reject
614
+ * them first.
615
+ * WHY: (item 10) the operand arity now spans four shapes — `[PATTERN FILE]` (single-file),
616
+ * WHY: `[PATTERN FILE...]` (multi-file), `grep -r PATTERN DIR` (recursive walk) and
617
+ * WHY: `rg --files [DIR]` (anchorless list). Their stdout geometries differ (`LINE:content`,
618
+ * WHY: `FILE:LINE:content`, a path list), so the family is returned as a tagged union rather
619
+ * WHY: than one widened interface. Directory operands are still NOT detected here: this module
620
+ * WHY: is pure and zero-fs, so the lifecycle layer re-checks the resolved file kind — and
621
+ * WHY: upgrades an `rg` directory operand (rg recurses by default) while a `grep` directory
622
+ * WHY: operand stays raw (native grep errors without `-r`).
623
+ */
624
+ function parseSearchStage(stage: StageShape): SearchFamily | undefined {
625
+ const filesList = parseFilesListStage(stage);
626
+ if (filesList) return { kind: "pureFilesList", search: filesList };
627
+ if (!SEARCH_PROGRAMS.has(stage.name)) return undefined;
628
+ const program = stage.name as SearchProgram;
629
+ const isGrep = program === "grep";
630
+ const args = stage.args;
631
+ const operands: string[] = [];
632
+ let lineNumbered = false;
633
+ let separatorSeen = false;
634
+ let recursive = false;
635
+ let context: BashSearchContext | undefined;
636
+ let explicitPattern: string | undefined;
637
+ for (let index = 0; index < args.length; index++) {
638
+ const arg = args[index];
639
+ if (!separatorSeen && arg === "--") {
640
+ separatorSeen = true;
641
+ continue;
642
+ }
643
+ if (!separatorSeen && arg.startsWith("-")) {
644
+ if (SEARCH_ALLOWED_FLAGS.has(arg)) {
645
+ if (SEARCH_LINE_NUMBER_FLAGS.has(arg)) lineNumbered = true;
646
+ continue;
647
+ }
648
+ const attachedMaxCount = SEARCH_MAX_COUNT_LONG.exec(arg);
649
+ if (attachedMaxCount !== null) {
650
+ // WHY: `--max-count=N` is the only admitted `=`-long form and N must be a strictly
651
+ // WHY: positive integer literal — `--max-count=0` and `--max-count=` fail closed.
652
+ if (!isPositiveCount(attachedMaxCount[1])) return undefined;
653
+ continue;
654
+ }
655
+ // WHY: (item 10) grep's long recursion markers; rg's `--files` was handled whole above,
656
+ // WHY: so it can only reach here in a shape that fails the list gate and must stay denied.
657
+ if (isGrep && SEARCH_GREP_RECURSIVE_FLAGS.has(arg)) {
658
+ recursive = true;
659
+ continue;
660
+ }
661
+ // WHY: every other `--…` token is an unlisted long option (`--json`, `--color=always`).
662
+ // WHY: (ADR-0048 D3 — context searches) the grep long context spellings, with the value
663
+ // WHY: attached (`--context=3`) or separate (`--context 3`). D3 states them in the `grep`
664
+ // WHY: clause, so every other `--…` token — and every `--…` token for rg — stays raw.
665
+ if (arg.startsWith("--")) {
666
+ if (!isGrep) return undefined;
667
+ const attached = SEARCH_CONTEXT_LONG_ATTACHED.exec(arg);
668
+ if (attached !== null) {
669
+ const parsed = mergeContext(
670
+ context,
671
+ SEARCH_CONTEXT_LONG_FLAGS.get(`--${attached[1] ?? ""}`),
672
+ attached[2],
673
+ );
674
+ if (parsed === undefined) return undefined;
675
+ context = parsed;
676
+ continue;
677
+ }
678
+ const longKind = SEARCH_CONTEXT_LONG_FLAGS.get(arg);
679
+ if (longKind !== undefined) {
680
+ const parsed = mergeContext(context, longKind, args.at(index + 1));
681
+ if (parsed === undefined) return undefined;
682
+ context = parsed;
683
+ index += 1;
684
+ continue;
685
+ }
686
+ return undefined;
687
+ }
688
+ // WHY: explicit `-e` takes its pattern separately (`-e PAT`): exactly once, must have
689
+ // WHY: a next argument, which becomes THE pattern (so operands must then be `[FILE]`).
690
+ // WHY: a `-`-leading value is admitted here — that is the point of `-e`.
691
+ if (arg === "-e") {
692
+ if (explicitPattern !== undefined) return undefined;
693
+ const value = args.at(index + 1);
694
+ if (value === undefined) return undefined;
695
+ explicitPattern = value;
696
+ index += 1;
697
+ continue;
698
+ }
699
+ // WHY: attached `-ePAT` is handled BEFORE letter decomposition: `PAT...` letters are
700
+ // WHY: not admitted flags, so decomposing would misread the pattern as a cluster
701
+ // WHY: (`-efoo` is pattern `foo`, not flags `e`+`f`+`o`+`o`).
702
+ if (arg.startsWith("-e")) {
703
+ if (explicitPattern !== undefined) return undefined;
704
+ explicitPattern = arg.slice(2);
705
+ continue;
706
+ }
707
+ // WHY: a single-dash token is a short cluster: every letter must be admitted, and the
708
+ // WHY: value-taking `m`/`e` may only close it, each consuming the next argument
709
+ // WHY: (`-nm 3`, `-ine pat`).
710
+ const cluster = arg.slice(1).split("");
711
+ if (cluster.length === 0) return undefined;
712
+ for (let position = 0; position < cluster.length; position++) {
713
+ const letter = cluster[position];
714
+ // WHY: (item 10) `r`/`R` are grep's recursion markers and may sit anywhere in a cluster
715
+ // WHY: (`-rn`); for rg the same letters are `--replace` and stay denied.
716
+ if (letter === "r" || letter === "R") {
717
+ if (!isGrep) return undefined;
718
+ recursive = true;
719
+ continue;
720
+ }
721
+ // WHY: (ADR-0048 D3 — context searches) `-A`/`-B`/`-C` consume the NEXT argument, exactly
722
+ // WHY: like `m`, so each must close the cluster (`-nC 2` is admitted, `-C2` is not: an
723
+ // WHY: attached value would read digits inside the cluster, and that digit-fails-closed rule
724
+ // WHY: is what keeps `-n5` out). `-A`/`-B` compose into one window, while `-C` sets both and so
725
+ // WHY: never composes — mixing it with a side option fails closed rather than guessing which wins.
726
+ const contextKind = SEARCH_CONTEXT_SHORT_LETTERS.get(letter);
727
+ if (contextKind !== undefined) {
728
+ // WHY: (ADR-0048 D3) rg is granted `-C` only, so its `-A`/`-B` stay raw.
729
+ if (!isGrep && contextKind !== "both") return undefined;
730
+ if (position !== cluster.length - 1) return undefined;
731
+ const parsed = mergeContext(context, contextKind, args.at(index + 1));
732
+ if (parsed === undefined) return undefined;
733
+ context = parsed;
734
+ index += 1;
735
+ continue;
736
+ }
737
+
738
+ if (!SEARCH_SHORT_LETTERS.has(letter)) return undefined;
739
+ if (letter === "n") lineNumbered = true;
740
+ if (letter === "m") {
741
+ if (position !== cluster.length - 1) return undefined;
742
+ if (!isPositiveCount(args.at(index + 1))) return undefined;
743
+ index += 1;
744
+ }
745
+ if (letter === "e") {
746
+ if (position !== cluster.length - 1) return undefined;
747
+ if (explicitPattern !== undefined) return undefined;
748
+ const value = args.at(index + 1);
749
+ if (value === undefined) return undefined;
750
+ explicitPattern = value;
751
+ index += 1;
752
+ }
753
+ }
754
+ continue;
755
+ }
756
+ operands.push(arg);
757
+ }
758
+ // WHY: (item 10) operand arity. Without `-e` the first operand is the PATTERN and the rest
759
+ // WHY: are FILES; with `-e` the value IS the pattern, so EVERY operand is a file. Counts
760
+ // WHY: beyond `BASH_SEARCH_MAX_FILES` fail closed HERE (never silent-truncate, invariant 3);
761
+ // WHY: the total matched-line budget is enforced at serve time against `SEARCH_MAX_MATCHES`.
762
+ const fileOperands = explicitPattern === undefined ? operands.slice(1) : [...operands];
763
+ const pattern = explicitPattern ?? operands[0];
764
+ if (pattern === undefined) return undefined;
765
+ if (recursive) {
766
+ // WHY: grep's recursion marker takes exactly one operand — the dir (or file) to walk.
767
+ // WHY: Recursion and multi-file never mix (`grep -r pat a b` fails the whole stage closed).
768
+ if (fileOperands.length !== 1) return undefined;
769
+ // WHY: (ADR-0048 D3) recursive context is EXCLUDED by the decision: directory order is
770
+ // WHY: implementation-defined, so a window over a walk cannot be predicted from a re-read.
771
+ if (context !== undefined) return undefined;
772
+ const dirPath = fileOperands[0];
773
+ if (dirPath === undefined) return undefined;
774
+ return { kind: "pureRecursiveSearch", search: { program, pattern, dirPath, lineNumbered } };
775
+ }
776
+ // WHY: zero file operands is a stdin search (`grep pat`) — still denied, unchanged.
777
+ if (fileOperands.length === 0 || fileOperands.length > BASH_SEARCH_MAX_FILES) return undefined;
778
+ if (fileOperands.length === 1) {
779
+ const filePath = fileOperands[0];
780
+ if (filePath === undefined) return undefined;
781
+ return {
782
+ kind: "pureSearch",
783
+ search: {
784
+ program,
785
+ pattern,
786
+ filePath,
787
+ lineNumbered,
788
+ ...(context === undefined ? {} : { context }),
789
+ },
790
+ };
791
+ }
792
+ // WHY: (ADR-0048 D3) multi-file context is NOT admitted: D3's geometry is the single-file row
793
+ // WHY: (`LINE:content` / `LINE-content`). A multi-file run prints context as `FILE-LINE-content`,
794
+ // WHY: a shape — and a split-ambiguity surface — the decision does not specify, so it stays raw.
795
+ if (context !== undefined) return undefined;
796
+
797
+ return {
798
+ kind: "pureMultiSearch",
799
+ search: { program, pattern, filePaths: [...fileOperands], lineNumbered },
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Downstream pipe stages: selectors with no file operand (stdin only). The `rtk`
805
+ * unwrap applies here too (ADR-0033 D8 as amended): the field issues
806
+ * `rtk`-prefixed filters (`... | rtk tail -n 4`), and the D9 stdout gate keeps
807
+ * the transparency claim honest per call. Single unwrap, view commands only —
808
+ * `rtk` around anything else stays `unsafe` via the unchanged rules below.
809
+ */
810
+ function parseFilterStage(shape: StageShape): ViewOp[] | undefined {
811
+ const stage = unwrapViewWrapper(shape);
812
+ if (!stage) return undefined;
813
+ if (stage.name === "head" || stage.name === "tail") {
814
+ const selection = parseCountSelector(stage.name, stage.args);
815
+ if (!selection || selection.file !== undefined) return undefined;
816
+ return selection.ops;
817
+ }
818
+ if (stage.name === "sed") {
819
+ const selection = parseSedArgs(stage.args);
820
+ if (!selection || selection.file !== undefined) return undefined;
821
+ return selection.ops;
822
+ }
823
+ return parseContentFilter(stage);
824
+ }
825
+
826
+ /**
827
+ * WHY: (item 9) stdin content filters — `grep PATTERN` keeps, `grep -v PATTERN` drops,
828
+ * WHY: `rg -N PATTERN` keeps. Exact shapes only: no other flag, exactly one positional
829
+ * WHY: pattern, and a `-`-leading pattern fails closed (there is no `--` separator
830
+ * WHY: downstream). Patterns arrive through `stageShape`/`literalValue`, so quoting and
831
+ * WHY: expansion rules match search patterns. `-v`/`-N` are admitted HERE ONLY —
832
+ * WHY: `parseSearchStage` and `SEARCH_ALLOWED_FLAGS` are untouched, so `grep -v pat f` and
833
+ * WHY: `rg -N pat f` stay `unsupported-command:*` at source position.
834
+ */
835
+ function parseContentFilter(stage: StageShape): ViewOp[] | undefined {
836
+ if (stage.name === "grep") {
837
+ const [first, second] = stage.args;
838
+ if (stage.args.length === 1) {
839
+ if (first === undefined || first.startsWith("-")) return undefined;
840
+ return [{ kind: "keep", pattern: first }];
841
+ }
842
+ if (stage.args.length === 2 && first === "-v") {
843
+ if (second === undefined || second.startsWith("-")) return undefined;
844
+ return [{ kind: "drop", pattern: second }];
845
+ }
846
+ return undefined;
847
+ }
848
+ // WHY: `rg -N` is the one spelling that pins the no-number geometry; bare `rg PAT` keeps
849
+ // WHY: its default numbering, which the classifier refuses to guess at.
850
+ if (stage.name === "rg") {
851
+ const [first, second] = stage.args;
852
+ if (stage.args.length === 2 && first === "-N") {
853
+ if (second === undefined || second.startsWith("-")) return undefined;
854
+ return [{ kind: "keep", pattern: second }];
855
+ }
856
+ return undefined;
857
+ }
858
+ // WHY: (item 11) the three line-set consumers, each admitted in exactly ONE spelling: `sort
859
+ // WHY: -u` is the dedupe (bare `sort`, `-n`, `-r`, `-o FILE`, a file operand and every
860
+ // WHY: combined flag fail closed), `uniq` takes no flag or operand (adjacent-only, literal),
861
+ // WHY: and `wc -l` is the count (bare `wc`, `-c`, `-w` and any operand fail closed).
862
+ if (stage.name === "sort") {
863
+ return stage.args.length === 1 && stage.args[0] === "-u" ? [{ kind: "sortUnique" }] : undefined;
864
+ }
865
+ if (stage.name === "uniq") {
866
+ return stage.args.length === 0 ? [{ kind: "uniq" }] : undefined;
867
+ }
868
+ if (stage.name === "wc") {
869
+ return stage.args.length === 1 && stage.args[0] === "-l" ? [{ kind: "count" }] : undefined;
870
+ }
871
+ return undefined;
872
+ }
873
+
874
+ /**
875
+ * Sort + merge 1-indexed inclusive print ranges into sorted non-overlapping
876
+ * `SliceOp[]`. Overlap (`[1,5]+[3,7]`→`[1,7]`) and adjacency (`[1,3]+[4,5]`→`[1,5]`,
877
+ * no gap between them so nothing unprinted is added) collapse; disjoint ranges
878
+ * keep their order after sorting. Pure — no I/O, total over its input.
879
+ */
880
+ function mergeRangeOps(ranges: ReadonlyArray<{ from: number; to: number }>): SliceOp[] {
881
+ const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
882
+ const ops: SliceOp[] = [];
883
+ for (const range of sorted) {
884
+ const last = ops[ops.length - 1];
885
+ if (last !== undefined && last.kind === "range" && range.from <= last.to + 1) {
886
+ if (range.to > last.to) last.to = range.to;
887
+ } else {
888
+ ops.push({ kind: "range", from: range.from, to: range.to });
889
+ }
890
+ }
891
+ return ops;
892
+ }
893
+
894
+ /**
895
+ * The numeric-print spec of a `sed` stage, with EVERY trailing file operand (ADR-0049 M1/M5).
896
+ *
897
+ * WHY one grammar for both shapes: a multi-file `sed -n '2,4p' a b` and the settled single-file
898
+ * `sed -n '2,4p' a` must admit the same program text or the two would drift — so the range grammar
899
+ * lives here once and the single-file arity rule stays in `parseSedArgs` (its exact contract).
900
+ */
901
+ function parseSedPrintSpec(args: string[]): { ops: SliceOp[]; files: string[] } | undefined {
902
+ if (args.length < 2 || args[0] !== "-n") return undefined;
903
+ if (args[1] !== "-e") {
904
+ const printed = SED_NUMERIC_PRINT.exec(args[1]);
905
+ if (!printed) return undefined;
906
+ const from = Number(printed[1]);
907
+ const to = printed[3] === undefined ? from : Number(printed[3]);
908
+ if (to < from) return undefined; // WHY: reversed `10,1p` fails closed (strict-print spirit)
909
+ return { ops: [{ kind: "range", from, to }], files: args.slice(2) };
910
+ }
911
+ const ranges: Array<{ from: number; to: number }> = [];
912
+ let index = 1;
913
+ while (index < args.length) {
914
+ if (args[index] !== "-e") break;
915
+ const prog = args[index + 1];
916
+ if (prog === undefined) return undefined; // WHY: dangling `-e`
917
+ const printed = SED_NUMERIC_PRINT.exec(prog);
918
+ if (!printed) return undefined; // WHY: ANY unparseable spec fails the WHOLE stage
919
+ const from = Number(printed[1]);
920
+ const to = printed[3] === undefined ? from : Number(printed[3]);
921
+ if (to < from) return undefined;
922
+ ranges.push({ from, to });
923
+ index += 2;
924
+ }
925
+ if (ranges.length === 0) return undefined;
926
+ return { ops: mergeRangeOps(ranges), files: args.slice(index) };
927
+ }
928
+
929
+ /**
930
+ * WHY: (ADR-0049 M1) the admitted multi-file view shapes, and nothing else. `head`/`tail` are
931
+ * excluded because their `==> FILE <==` headers are not file bytes; stdin (`-`), redirects,
932
+ * prefixes, display transforms and pipelines never reach here (`stageShape`/`classifySegment`
933
+ * already refuse them), and a `-`-leading operand fails closed because option parsing is over.
934
+ *
935
+ * WHY the operand count is NOT capped here: the classifier is pure and zero-fs, so the file cap is
936
+ * operator policy resolved at the serve seam (ADR-0049 M6); this gate only requires the shape.
937
+ * WHY the `rtk` unwrap applies: ADR-0033 D8 already treats it as a transparent view wrapper and the
938
+ * D9 stdout gate verifies the claim per call, so `rtk cat a b` is the same view as `cat a b`.
939
+ */
940
+ function parseMultiViewStage(shape: StageShape): BashMultiView | undefined {
941
+ const stage = unwrapViewWrapper(shape);
942
+ if (!stage) return undefined;
943
+ const files = (args: readonly string[]): string[] | undefined => {
944
+ if (args.length < 2) return undefined;
945
+ if (args.some((arg) => arg.startsWith("-") || arg === "")) return undefined;
946
+ return [...args];
947
+ };
948
+ if (stage.name === "cat") {
949
+ if (stage.args[0] === "-n") {
950
+ const filePaths = files(stage.args.slice(1));
951
+ if (filePaths === undefined) return undefined;
952
+ return { program: "cat", filePaths, ops: [], lineNumbered: true };
953
+ }
954
+ const filePaths = files(stage.args);
955
+ return filePaths === undefined ? undefined : { program: "cat", filePaths, ops: [] };
956
+ }
957
+ if (stage.name === "nl") {
958
+ // WHY: (item 8) the only admitted `nl` spelling stays exactly `nl -ba FILE...`.
959
+ if (stage.args[0] !== "-ba") return undefined;
960
+ const filePaths = files(stage.args.slice(1));
961
+ if (filePaths === undefined) return undefined;
962
+ return { program: "nl", filePaths, ops: [], lineNumbered: true };
963
+ }
964
+ if (stage.name === "sed") {
965
+ const spec = parseSedPrintSpec(stage.args);
966
+ if (spec === undefined) return undefined;
967
+ const filePaths = files(spec.files);
968
+ return filePaths === undefined ? undefined : { program: "sed", filePaths, ops: spec.ops };
969
+ }
970
+ return undefined;
971
+ }
972
+
973
+ /** `-n PROG [FILE]` or `-n -e PROG [-e PROG …] [FILE]`; every PROG numeric print (R4 strict regex, reused). */
974
+ function parseSedArgs(args: string[]): { ops: SliceOp[]; file?: string } | undefined {
975
+ const spec = parseSedPrintSpec(args);
976
+ if (spec === undefined) return undefined;
977
+ // WHY: (ADR-0049 M1) the settled single-file arity contract, unchanged: the legacy spelling takes
978
+ // WHY: exactly `-n PROG [FILE]`, so a SECOND operand belongs to the multi-file shape instead.
979
+ if (args[1] !== "-e" && args.length !== 2 && args.length !== 3) return undefined;
980
+ const [file] = spec.files;
981
+ if (file === undefined) return spec.files.length === 0 ? { ops: spec.ops } : undefined;
982
+ return spec.files.length === 1 ? { ops: spec.ops, file } : undefined;
983
+ }
984
+
985
+ interface SilentSeg {
986
+ kind: "silent";
987
+ cdDir?: string;
988
+ }
989
+
990
+ export interface ViewSeg {
991
+ kind: "view";
992
+ filePath: string;
993
+ ops: ViewOp[];
994
+ lineNumbered?: boolean;
995
+ display?: DisplayTransform;
996
+ }
997
+
998
+ /** WHY: (ADR-0049 M1) a multi-file view segment. It is deliberately NOT a `ViewSeg`: the compound
999
+ * chain does not compose multi-file views (M1), and the serve seam proves N pages from line counts. */
1000
+ interface MultiViewSeg {
1001
+ kind: "multiView";
1002
+ view: BashMultiView;
1003
+ }
1004
+
1005
+ export interface SearchSeg {
1006
+ kind: "search";
1007
+ family: SearchFamily;
1008
+ }
1009
+
1010
+ export type Segment =
1011
+ | SilentSeg
1012
+ | ViewSeg
1013
+ | MultiViewSeg
1014
+ | SearchSeg
1015
+ | { kind: "unsafe"; reason: string };
1016
+
1017
+ /**
1018
+ * `cd <literal>` (never a `-`-leading operand), `true`, `:` — the corpus-closed
1019
+ * silent set (R3). A `-`-leading operand is an option, not a directory: bash
1020
+ * consumes `cd --`/`cd -P`/`cd -L` and, with no operand left, cds to `$HOME`, so
1021
+ * taking the flag as the target would resolve a path the model never viewed.
1022
+ *
1023
+ * WHY `prefixPosition`: every statement before the terminal view of a `;` chain
1024
+ * must be silent, and the field writes that prefix `pwd` as often as `cd`.
1025
+ * `pwd` prints the working directory, so it is admitted *only* there: the
1026
+ * position proves the sequencing is deterministic, and D9 still byte-checks
1027
+ * stdout, which carries the directory line and therefore never matches the
1028
+ * slice — the lease is refused at serve time. Outside a prefix position (an
1029
+ * `&&` chain) the set stays exactly the three commands above.
1030
+ */
1031
+ function parseSilent(stage: StageShape, prefixPosition = false): SilentSeg | undefined {
1032
+ if (prefixPosition && stage.name === "pwd" && stage.args.length === 0) {
1033
+ return { kind: "silent" };
1034
+ }
1035
+ if (stage.name === "cd") {
1036
+ if (stage.args.length !== 1 || stage.args[0].startsWith("-")) return undefined;
1037
+ return { kind: "silent", cdDir: stage.args[0] };
1038
+ }
1039
+ if ((stage.name === "true" || stage.name === ":") && stage.args.length === 0) {
1040
+ return { kind: "silent" };
1041
+ }
1042
+ return undefined;
1043
+ }
1044
+
1045
+ /**
1046
+ * `rtk` transparent wrapper (ADR-0033 D8): unwraps to the inner view command in
1047
+ * source and pipe-filter positions, view commands only, single unwrap only.
1048
+ * `rtk` provably proxies the view class byte-identically today, but reputation is
1049
+ * not the safety story — the stdout-verification gate (D9) re-checks every
1050
+ * replacement against the observed bytes, so a future filtering `rtk` subcommand
1051
+ * fails closed instead of mis-serving. `rtk` anywhere else (wrapping a non-view,
1052
+ * carrying its own flags, around silent commands) stays `unsafe`.
1053
+ */
1054
+ function unwrapViewWrapper(stage: StageShape): StageShape | undefined {
1055
+ if (stage.name !== "rtk") return stage;
1056
+ const [inner, ...rest] = stage.args;
1057
+ if (inner === undefined) return undefined;
1058
+ return { name: inner, args: rest };
1059
+ }
1060
+
1061
+ /** Programs admitted as static glue (exact standalone quoted literals; see the synonym gate). */
1062
+
1063
+ /**
1064
+ * WHY: (ADR-0039 D10) the inert tail of a relaxation-A compound: a byte-count-style summary
1065
+ * WHY: that cannot transform the block, redirect output, or reach the network. Only `wc` with
1066
+ * WHY: counting flags and at least one file operand qualifies — no stdin reads (a bare `wc`
1067
+ * WHY: counts the harness stdin, which misattributes), no `--` escape hatches, no file operands
1068
+ * WHY: starting with `-`. `stageShape` already rejects prefixes, redirects, and non-literal
1069
+ * WHY: words, so what remains is a fixed program with fixed counting semantics. Extending this
1070
+ * WHY: set is a follow-up with its own safety case — add a name here, not a new rule.
1071
+ */
1072
+ export function parseInertTail(
1073
+ node: Command | Pipeline,
1074
+ ): { program: "wc"; args: string[] } | undefined {
1075
+ if (node.type !== "Command") return undefined;
1076
+ const stage = stageShape(node);
1077
+ if (stage === undefined || stage.name !== "wc") return undefined;
1078
+ let files = 0;
1079
+ for (const arg of stage.args) {
1080
+ if (arg.startsWith("-")) {
1081
+ if (!/^-[lwcm]+$/.test(arg)) return undefined;
1082
+ continue;
1083
+ }
1084
+ files++;
1085
+ }
1086
+ if (files === 0) return undefined;
1087
+ return { program: "wc", args: stage.args };
1088
+ }
1089
+
1090
+ const LITERAL_PRINT_PROGRAMS = new Set(["echo", "printf"]);
1091
+ /**
1092
+ * WHY: (ADR-0039 D6) static glue for compounds: literal prints without dynamic expansions, plus
1093
+ * WHY: silent env commands. Expected content and byte length are computed at parse time and
1094
+ * WHY: verified during peeling; any form whose bytes cannot be predicted with certainty fails
1095
+ * WHY: closed here, because a wrong prediction must never produce a lease.
1096
+ */
1097
+ export function parseGlueSegment(node: Command | Pipeline): GlueSegment | undefined {
1098
+ if (node.type !== "Command") return undefined;
1099
+ if (node.prefix.length > 0 || node.redirects.length > 0) return undefined;
1100
+ if (!isWord(node.name)) return undefined;
1101
+ const name = literalValue(node.name);
1102
+ if (name === undefined) return undefined;
1103
+ if (name === "export") return parseExportGlue(node);
1104
+ const stage = stageShape(node);
1105
+ if (!stage) return undefined;
1106
+ if (LITERAL_PRINT_PROGRAMS.has(stage.name)) return parsePrintGlue(stage);
1107
+ if (stage.name === "set") return parseSetGlue(stage);
1108
+ return undefined;
1109
+ }
1110
+ function parsePrintGlue(stage: { name: string; args: string[] }): GlueSegment | undefined {
1111
+ if (stage.args.length !== 1) return undefined;
1112
+ const arg = stage.args[0];
1113
+ if (arg === undefined || arg.startsWith("-")) return undefined;
1114
+ if (stage.name === "echo") {
1115
+ // WHY: the print builtin's backslash handling varies by shell; only backslash-free literals
1116
+ // WHY: have certain bytes. Escaped content belongs to `printf` (below), which owns escapes.
1117
+ if (arg.includes("\\")) return undefined;
1118
+ return makeGlue("echo", `${arg}\n`);
1119
+ }
1120
+ if (stage.name !== "printf") return undefined;
1121
+ if (arg.includes("%")) return undefined;
1122
+ const text = parsePrintfEscapes(arg);
1123
+ if (text === undefined) return undefined;
1124
+ return makeGlue("printf", text);
1125
+ }
1126
+ function parsePrintfEscapes(format: string): string | undefined {
1127
+ let out = "";
1128
+ for (let i = 0; i < format.length; i++) {
1129
+ const ch = format[i];
1130
+ if (ch !== "\\") {
1131
+ out += ch;
1132
+ continue;
1133
+ }
1134
+ const esc = format[i + 1];
1135
+ if (esc === "n") {
1136
+ out += "\n";
1137
+ i++;
1138
+ } else if (esc === "t") {
1139
+ out += "\t";
1140
+ i++;
1141
+ } else if (esc === "\\") {
1142
+ out += "\\";
1143
+ i++;
1144
+ } else {
1145
+ return undefined;
1146
+ }
1147
+ }
1148
+ return out;
1149
+ }
1150
+ function parseExportGlue(node: Command): GlueSegment | undefined {
1151
+ if (node.suffix.length !== 1) return undefined;
1152
+ const only = node.suffix[0];
1153
+ if (only === undefined) return undefined;
1154
+ if (isWord(only)) {
1155
+ const name = literalValue(only);
1156
+ if (name === undefined || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return undefined;
1157
+ return makeGlue("export", "");
1158
+ }
1159
+ if (only.type !== "Assignment") return undefined;
1160
+ if (only.append === true || only.index !== undefined) return undefined;
1161
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(only.name)) return undefined;
1162
+ if (!isWord(only.value) || literalValue(only.value) === undefined) return undefined;
1163
+ return makeGlue("export", "");
1164
+ }
1165
+ function parseSetGlue(stage: { name: string; args: string[] }): GlueSegment | undefined {
1166
+ if (stage.args.length !== 1) return undefined;
1167
+ const flag = stage.args[0];
1168
+ if (flag !== "-e" && flag !== "+e") return undefined;
1169
+ return makeGlue("set", "");
1170
+ }
1171
+ function makeGlue(
1172
+ program: "echo" | "printf" | "export" | "set",
1173
+ expectedText: string,
1174
+ ): GlueSegment {
1175
+ return { program, expectedText, expectedBytes: Buffer.byteLength(expectedText, "utf-8") };
1176
+ }
1177
+ /**
1178
+ * WHY: (ADR-0048 D2) a terminal display-only stage. The stage has no operand and no redirect
1179
+ * (`stageShape` already rejected those); it is the LAST pipeline stage, so nothing can consume
1180
+ * its transformed bytes. `rtk cat -A` is deliberately not admitted: the wrapper is a different
1181
+ * command, and the bare transform is the only spelling whose bytes this oracle can reproduce.
1182
+ */
1183
+ function parseDisplayTransform(stage: StageShape): DisplayTransform | undefined {
1184
+ switch (stage.name) {
1185
+ case "cat":
1186
+ if (stage.args.length !== 1) return undefined;
1187
+ if (stage.args[0] === "-A") return { kind: "cat-A", label: "cat -A" };
1188
+ if (stage.args[0] === "-vet") return { kind: "cat-vet", label: "cat -vet" };
1189
+ return undefined;
1190
+ case "od":
1191
+ return stage.args.length === 1 && stage.args[0] === "-c"
1192
+ ? { kind: "od-c", label: "od -c" }
1193
+ : undefined;
1194
+ case "xxd":
1195
+ return stage.args.length === 0 ? { kind: "xxd", label: "xxd" } : undefined;
1196
+ case "hexdump":
1197
+ return stage.args.length === 1 && stage.args[0] === "-C"
1198
+ ? { kind: "hexdump-C", label: "hexdump -C" }
1199
+ : undefined;
1200
+ default:
1201
+ return undefined;
1202
+ }
1203
+ }
1204
+
1205
+ function parseViewSegment(node: Command | Pipeline): ViewSeg | SearchSeg | undefined {
1206
+ if (node.type === "Command") {
1207
+ const stage = stageShape(node);
1208
+ if (!stage) return undefined;
1209
+ const source = parseSourceStage(stage);
1210
+ if (!source) return undefined;
1211
+ return {
1212
+ kind: "view",
1213
+ filePath: source.filePath,
1214
+ ops: source.ops,
1215
+ ...(source.lineNumbered === true ? { lineNumbered: true } : {}),
1216
+ };
1217
+ }
1218
+ // WHY: the signature says Command | Pipeline, but `unbash` puts every statement kind in
1219
+ // WHY: AndOr.commands; a compound operand has neither `operators` nor `commands` and must
1220
+ // WHY: fail closed here instead of throwing out of the tool_result hook.
1221
+ const pipelineShape = node as { operators?: unknown; commands?: unknown };
1222
+ if (!Array.isArray(pipelineShape.operators) || !Array.isArray(pipelineShape.commands)) {
1223
+ return undefined;
1224
+ }
1225
+ if (node.operators.some((op) => op !== "|")) return undefined;
1226
+ if (node.commands.length < 2 || node.commands.length > BASH_VIEW_MAX_PIPELINE_STAGES) {
1227
+ return undefined;
1228
+ }
1229
+ const lastIndex = node.commands.length - 1;
1230
+ const last = node.commands[lastIndex];
1231
+ if (last === undefined || last.type !== "Command") return undefined;
1232
+ const lastStage = stageShape(last);
1233
+ if (!lastStage) return undefined;
1234
+ const display = parseDisplayTransform(lastStage);
1235
+ const headEnd = display === undefined ? node.commands.length : lastIndex;
1236
+ if (display !== undefined && headEnd < 1) return undefined;
1237
+
1238
+ const ops: ViewOp[] = [];
1239
+ let filePath: string | undefined;
1240
+ for (let index = 0; index < headEnd; index++) {
1241
+ const child = node.commands[index];
1242
+ // WHY: every stage must prove itself — a skipped failure here would serve
1243
+ // WHY: leases for lines the model never saw (e.g. a `grep` stage silently
1244
+ // WHY: dropped would misattribute filtered output to a head slice).
1245
+ if (child === undefined || child.type !== "Command") return undefined;
1246
+ const stage = stageShape(child);
1247
+ if (!stage) return undefined;
1248
+ if (index === 0) {
1249
+ const source = parseSourceStage(stage);
1250
+ if (source) {
1251
+ // WHY: (item 8) the numbered geometry is full-file display only; any downstream
1252
+ // WHY: filter would silently drop numbered rows while claiming full-file leases.
1253
+ // WHY: (item 9) Tier 3 does NOT lift this guard.
1254
+ if (source.lineNumbered === true) return undefined;
1255
+ filePath = source.filePath;
1256
+ ops.push(...source.ops);
1257
+ continue;
1258
+ }
1259
+ // WHY: (item 9) stage 0 may be a search (`grep pat f | head -5`). An UNNUMBERED
1260
+ // WHY: search proves exactly what a `keep` filter proves — the file's lines matching
1261
+ // WHY: the pattern — so it is translated into one and the content seam byte-gates the
1262
+ // WHY: fold against the observed stdout.
1263
+ // WHY: [ADR-0039 D3, superseding the numbered-search decline] A NUMBERED single-file search
1264
+ // WHY: piped into `head`/`tail` is admitted as `pureSearch` with the slice ops attached.
1265
+ // WHY: Admission is safe because the slice is a pure line-count truncation evaluated on
1266
+ // WHY: already-resolved rows: original file line numbers are preserved as identifiers, and
1267
+ // WHY: leases cover only the survivors shown. Any other downstream stage (sort, uniq,
1268
+ // WHY: count, keep, sed ranges) still fails closed here, like the item-8 numbered-view guard.
1269
+ const searchSource = parseSearchStage(stage);
1270
+ // WHY: (item 10) only the SINGLE-file, unnumbered shape has bare-content stdout that one
1271
+ // WHY: `keep` over ONE file can model; multi-file and recursive stdout carry `FILE:LINE:`
1272
+ // WHY: prefixes and `rg --files` carries paths, so those compose with nothing downstream.
1273
+ if (searchSource?.kind !== "pureSearch") {
1274
+ return undefined;
1275
+ }
1276
+ if (searchSource.search.lineNumbered) {
1277
+ if (display !== undefined) return undefined;
1278
+ // WHY: [ADR-0039 D3] collect the trailing head/tail slices; anything else fails closed.
1279
+ const sliceOps: SliceOp[] = [];
1280
+ for (let s = 1; s < node.commands.length; s++) {
1281
+ const downstream = node.commands[s];
1282
+ if (downstream === undefined || downstream.type !== "Command") return undefined;
1283
+ const downstreamStage = stageShape(downstream);
1284
+ if (!downstreamStage) return undefined;
1285
+ const filter = parseFilterStage(downstreamStage);
1286
+ if (!filter) return undefined;
1287
+ for (const op of filter) {
1288
+ if (op.kind !== "first" && op.kind !== "last") return undefined;
1289
+ sliceOps.push(op);
1290
+ }
1291
+ }
1292
+ if (sliceOps.length === 0) return undefined;
1293
+ return {
1294
+ kind: "search",
1295
+ family: {
1296
+ kind: "pureSearch",
1297
+ search: { ...searchSource.search, sliceOps },
1298
+ },
1299
+ };
1300
+ }
1301
+ filePath = searchSource.search.filePath;
1302
+ ops.push({ kind: "keep", pattern: searchSource.search.pattern });
1303
+ continue;
1304
+ }
1305
+ const filter = parseFilterStage(stage);
1306
+ if (!filter) return undefined;
1307
+ ops.push(...filter);
1308
+ }
1309
+ // WHY: (item 11) `wc -l` is TERMINAL: its stdout is the count, so nothing may consume the
1310
+ // WHY: rows after it (`... | wc -l | head -2` would head a number). The classifier refuses
1311
+ // WHY: the whole pipeline rather than admit a stage order the fold cannot model.
1312
+ const countAt = ops.findIndex((op) => op.kind === "count");
1313
+ if (countAt !== -1 && countAt !== ops.length - 1) return undefined;
1314
+ if (filePath === undefined) return undefined;
1315
+ if (display !== undefined) {
1316
+ if (ops.some(isContentOp)) return undefined;
1317
+ return { kind: "view", filePath, ops, display };
1318
+ }
1319
+ if (ops.length === 0) return undefined;
1320
+ return { kind: "view", filePath, ops };
1321
+ }
1322
+
1323
+ export function classifySegment(node: Command | Pipeline, prefixPosition = false): Segment {
1324
+ if (node.type === "Command") {
1325
+ const stage = stageShape(node);
1326
+ if (!stage) return { kind: "unsafe", reason: "non-literal-command" };
1327
+ const silent = parseSilent(stage, prefixPosition);
1328
+ if (silent) return silent;
1329
+ // WHY: (ADR-0049 M1) multi-file views are a STANDALONE shape, so they are admitted here —
1330
+ // WHY: the segment dispatch — and never in `parseViewSegment`, which is the pipeline path.
1331
+ const multiView = parseMultiViewStage(stage);
1332
+ if (multiView) return { kind: "multiView", view: multiView };
1333
+ const source = parseSourceStage(stage);
1334
+ if (source)
1335
+ return {
1336
+ kind: "view",
1337
+ filePath: source.filePath,
1338
+ ops: source.ops,
1339
+ ...(source.lineNumbered === true ? { lineNumbered: true } : {}),
1340
+ };
1341
+ const search = parseSearchStage(stage);
1342
+ if (search) return { kind: "search", family: search };
1343
+ return { kind: "unsafe", reason: `unsupported-command:${stage.name}` };
1344
+ }
1345
+ const view = parseViewSegment(node);
1346
+ if (view) return view;
1347
+ return { kind: "unsafe", reason: "unsupported-pipeline" };
1348
+ }
1349
+
1350
+ /**
1351
+ * [ADR-0033 D1, amended] A `;`-separated statement chain is admitted only when
1352
+ * every boundary between adjacent statements is an unconditional `;`.
1353
+ *
1354
+ * WHY the separator text decides: `unbash` reports a `;` and a newline as the
1355
+ * same statement boundary, so the parse alone cannot tell the sanctioned
1356
+ * `cd dir; cat file` shape from a newline-separated script. Only the `;` form is
1357
+ * the deterministic prefix shape this decision authorises; the newline form
1358
+ * stays pass-through rather than riding in on the same parse shape.
1359
+ */
1360
+ export function isSemicolonStatementChain(
1361
+ command: string,
1362
+ statements: readonly Statement[],
1363
+ ): boolean {
1364
+ if (statements.length < 2) return false;
1365
+ for (let index = 1; index < statements.length; index++) {
1366
+ const previous = statements[index - 1];
1367
+ const statement = statements[index];
1368
+ if (previous === undefined || statement === undefined) return false;
1369
+ if (!/^\s*;\s*$/.test(command.slice(previous.end, statement.pos))) return false;
1370
+ }
1371
+ return true;
1372
+ }
1373
+
1374
+ /**
1375
+ * Span-kind chain rule (ADR-0033 D1, amended): `&&`-only within a statement,
1376
+ * exactly one `view`, all others `silent`, undetermined is `unsafe`. Returns the
1377
+ * view with its resolution base (the single pre-view literal `cd`, if any;
1378
+ * multi-`cd` and post-view-`cd` chains are `passThrough`).
1379
+ *
1380
+ * A `;`-separated statement chain is admitted under the same rule with one
1381
+ * addition: every statement before the terminal one must itself be a silent
1382
+ * prefix, so the view is the last statement and the sequencing before it is
1383
+ * deterministic (see `isSemicolonStatementChain`).
1384
+ */
1385
+ /**
1386
+ * WHY: (ADR-0039 D7/D9) redundancy without a known cwd. Dot-forms (relative, no `..`,
1387
+ * WHY: normalizing to self) resolve to the base for EVERY base, so they need no tracked state.
1388
+ * WHY: An absolute target matches only a known-absolute tracked dir exactly (D9 lexical; the
1389
+ * WHY: `realpath` fallback belongs to the lifecycle, which has fs access). Everything else counts
1390
+ * WHY: normally — sound because an unproven `cd` is treated as a real change, never a no-op.
1391
+ */
1392
+ export function isRedundantCd(target: string, effCwd: string | undefined): boolean {
1393
+ if (!target.startsWith("/") && target.split("/").every((part) => part === "" || part === ".")) {
1394
+ return true;
1395
+ }
1396
+ if (effCwd !== undefined && effCwd.startsWith("/") && target === effCwd) {
1397
+ return true;
1398
+ }
1399
+ return false;
1400
+ }
1401
+ export function classifyBashCommand(command: string): Decision {
1402
+ return decide(command, {});
1403
+ }
1404
+
1405
+ /**
1406
+ * WHY the chain lives in `src/bash-conversion/decide.ts` (#151): the guards are data there
1407
+ * WHY: (RULES in array order = precedence). This keeps its name and out-param contract for the
1408
+ * WHY: line-number splice (`withSearchLineNumbers` below passes a live box); the table owns the walk.
1409
+ */
1410
+ function classifyBash(
1411
+ command: string,
1412
+ located: {
1413
+ searchNameEnds?: Array<{ end: number; program: SearchProgram; lineNumbered: boolean }>;
1414
+ },
1415
+ ): Decision {
1416
+ return decide(command, located);
1417
+ }
1418
+ function takeFirst(list: LineInterval[], n: number): LineInterval[] {
1419
+ const out: LineInterval[] = [];
1420
+ let remaining = n;
1421
+ for (const iv of list) {
1422
+ if (remaining <= 0) break;
1423
+ const take = Math.min(iv.hi - iv.lo + 1, remaining);
1424
+ out.push({ lo: iv.lo, hi: iv.lo + take - 1 });
1425
+ remaining -= take;
1426
+ }
1427
+ return out;
1428
+ }
1429
+
1430
+ function takeLast(list: LineInterval[], n: number): LineInterval[] {
1431
+ const lengths = list.map((iv) => iv.hi - iv.lo + 1);
1432
+ const total = lengths.reduce((a, b) => a + b, 0);
1433
+ let skip = Math.max(total - n, 0);
1434
+ const out: LineInterval[] = [];
1435
+ list.forEach((iv, index) => {
1436
+ if (skip >= lengths[index]) {
1437
+ skip -= lengths[index];
1438
+ return;
1439
+ }
1440
+ out.push({ lo: iv.lo + skip, hi: iv.hi });
1441
+ skip = 0;
1442
+ });
1443
+ return out;
1444
+ }
1445
+
1446
+ function selectRange(list: LineInterval[], from: number, to: number): LineInterval[] {
1447
+ if (to < 1 || from < 1 || to < from) return [];
1448
+ // WHY: stream positions are the concatenation of the intervals — walk once,
1449
+ // WHY: keeping positions inside [from, to]; clamping is automatic.
1450
+ const out: LineInterval[] = [];
1451
+ let pos = 0;
1452
+ for (const iv of list) {
1453
+ const len = iv.hi - iv.lo + 1;
1454
+ const start = pos + 1;
1455
+ const end = pos + len;
1456
+ const keepLo = Math.max(start, from);
1457
+ const keepHi = Math.min(end, to);
1458
+ if (keepLo <= keepHi) {
1459
+ out.push({ lo: iv.lo + (keepLo - start), hi: iv.lo + (keepHi - start) });
1460
+ }
1461
+ pos = end;
1462
+ if (pos >= to) break;
1463
+ }
1464
+ return out;
1465
+ }
1466
+
1467
+ /**
1468
+ * Interval algebra (ADR-0033 D2): folds stream-relative slice ops over the
1469
+ * 1-indexed line space `[1..lineCount]`. Total and exact — out-of-range
1470
+ * selections yield `[]` (the caller passes through), never an error.
1471
+ */
1472
+ export function applySliceOps(ops: readonly SliceOp[], lineCount: number): LineInterval[] {
1473
+ if (!Number.isInteger(lineCount) || lineCount <= 0) return [];
1474
+ let intervals: LineInterval[] = [{ lo: 1, hi: lineCount }];
1475
+ for (const op of ops) {
1476
+ switch (op.kind) {
1477
+ case "first":
1478
+ intervals = takeFirst(intervals, op.n);
1479
+ break;
1480
+ case "last":
1481
+ intervals = takeLast(intervals, op.n);
1482
+ break;
1483
+ case "dropLast": {
1484
+ const total = intervals.reduce((a, iv) => a + (iv.hi - iv.lo + 1), 0);
1485
+ intervals = takeFirst(intervals, total - op.n);
1486
+ break;
1487
+ }
1488
+ case "from":
1489
+ intervals = selectRange(intervals, op.n, Number.MAX_SAFE_INTEGER);
1490
+ break;
1491
+ case "range":
1492
+ intervals = selectRange(intervals, op.from, op.to);
1493
+ break;
1494
+ }
1495
+ if (intervals.length === 0) break;
1496
+ }
1497
+ return intervals;
1498
+ }
1499
+
1500
+ /** One stream line in a content fold: its TRUE 1-indexed file line and the text stages see. */
1501
+ export interface ContentLine {
1502
+ line: number;
1503
+ text: string;
1504
+ }
1505
+
1506
+ /**
1507
+ * WHY: (item 9) content-aware fold over `(line, text)` pairs, left in stage order. Slice
1508
+ * WHY: stages reuse the interval algebra over stream positions, mapped back to entries so a
1509
+ * WHY: multi-interval `sed -n -e` stage keeps its exact shape; `keep`/`drop` narrow by
1510
+ * WHY: literal substring. Total and pure — an empty result means the caller passes through
1511
+ * WHY: with zero leases, the same contract `applySliceOps` has.
1512
+ *
1513
+ * WHY: (item 11) the fold is a `switch` over the whole union with no fall-through slice arm, so
1514
+ * WHY: the three line-set consumers are handled where they are declared and an unknown kind is
1515
+ * WHY: refused (`assertNever`) instead of silently degrading to a slice.
1516
+ */
1517
+ export function applyViewOps(ops: readonly ViewOp[], lines: readonly ContentLine[]): ContentLine[] {
1518
+ let stream = [...lines];
1519
+ for (const op of ops) {
1520
+ switch (op.kind) {
1521
+ case "keep":
1522
+ stream = stream.filter((entry) => entry.text.includes(op.pattern));
1523
+ break;
1524
+ case "drop":
1525
+ stream = stream.filter((entry) => !entry.text.includes(op.pattern));
1526
+ break;
1527
+ case "sortUnique":
1528
+ stream = sortUniqueLines(stream);
1529
+ break;
1530
+ case "uniq":
1531
+ stream = collapseAdjacentRuns(stream);
1532
+ break;
1533
+ case "count":
1534
+ // WHY: (item 11) `wc -l` is terminal: the stream IS the counted set and its length is
1535
+ // WHY: the number, so this arm leaves the rows for the lifecycle layer to lease. No op
1536
+ // WHY: folds after it (`parseViewSegment` refuses one).
1537
+ break;
1538
+ case "first":
1539
+ case "last":
1540
+ case "dropLast":
1541
+ case "from":
1542
+ case "range": {
1543
+ const intervals = applySliceOps([op], stream.length);
1544
+ const next: ContentLine[] = [];
1545
+ for (const iv of intervals) next.push(...stream.slice(iv.lo - 1, iv.hi));
1546
+ stream = next;
1547
+ break;
1548
+ }
1549
+ default:
1550
+ // WHY: (item 11) an unknown op kind must never silently become a slice; the `never`
1551
+ // WHY: binding is what keeps `pnpm run typecheck` exhaustive over `ViewOp`.
1552
+ return assertNever(op);
1553
+ }
1554
+ if (stream.length === 0) return [];
1555
+ }
1556
+ return stream;
1557
+ }
1558
+
1559
+ /**
1560
+ * WHY: (item 11) `sort -u` dedupes by text keeping the FIRST occurrence's true line — the
1561
+ * WHY: anchor that travels with the row is a real file coordinate, never a renumbered one —
1562
+ * WHY: then orders by raw bytes — the C/POSIX collation `sort` uses under `LC_ALL=C`. A host
1563
+ * WHY: whose locale collation reorders the set (case-folding, accents) folds differently, fails
1564
+ * WHY: the whole-stdout byte gate and passes through raw, never a wrong order.
1565
+ */
1566
+ function sortUniqueLines(stream: ContentLine[]): ContentLine[] {
1567
+ const firstByText = new Map<string, ContentLine>();
1568
+ for (const entry of stream) {
1569
+ if (!firstByText.has(entry.text)) firstByText.set(entry.text, entry);
1570
+ }
1571
+ return [...firstByText.values()].sort((left, right) =>
1572
+ Buffer.compare(Buffer.from(left.text, "utf8"), Buffer.from(right.text, "utf8")),
1573
+ );
1574
+ }
1575
+
1576
+ /**
1577
+ * WHY: (item 11) `uniq` is ADJACENT-only and literal: each run of equal text collapses to its
1578
+ * WHY: head, so the same text may survive at two non-adjacent positions, each keeping its own
1579
+ * WHY: true line. Sorting is never part of it.
1580
+ */
1581
+ function collapseAdjacentRuns(stream: ContentLine[]): ContentLine[] {
1582
+ const out: ContentLine[] = [];
1583
+ for (const entry of stream) {
1584
+ const head = out[out.length - 1];
1585
+ if (head === undefined || head.text !== entry.text) out.push(entry);
1586
+ }
1587
+ return out;
1588
+ }
1589
+
1590
+ /**
1591
+ * [spec §3.3] The pure `tool_call` rewrite: inject the line-number flag immediately after the
1592
+ * search program name — before any `--` separator, so `grep -- -pat f` becomes
1593
+ * `grep -n -- -pat f` — and return the command byte-identically unchanged for everything the
1594
+ * allowlist does not admit (a denied flag, a non-search command, or an already numbered
1595
+ * search). WHY no pipeline rewrite (item 9): a search-led pipeline is served from the
1596
+ * model's own command's output — an unnumbered source is translated into a `keep` filter and
1597
+ * a numbered one composes with nothing — so injecting `-n` would change what a downstream
1598
+ * content filter sees. Zero filesystem access: the rewrite is decided from the AST alone, and
1599
+ * the `tool_result` side re-parses the mutated command instead of sharing state with this hook.
1600
+ */
1601
+ export function withSearchLineNumbers(command: string): string {
1602
+ const located: {
1603
+ searchNameEnds?: Array<{ end: number; program: SearchProgram; lineNumbered: boolean }>;
1604
+ } = {};
1605
+ const classification = classifyBash(command, located);
1606
+ // WHY: (item 10 + ADR-0039 D5) only search families (bare or compound segments) take the splice;
1607
+ // WHY: views, file lists, and declined commands return unchanged. Unnumbered offsets splice
1608
+ // WHY: right-to-left so earlier offsets stay valid; already-numbered need nothing.
1609
+ if (
1610
+ classification.kind !== "pureSearch" &&
1611
+ classification.kind !== "pureMultiSearch" &&
1612
+ classification.kind !== "pureRecursiveSearch" &&
1613
+ classification.kind !== "pureCompound"
1614
+ ) {
1615
+ return command;
1616
+ }
1617
+ let rewritten = command;
1618
+ const injections = [...(located.searchNameEnds ?? [])]
1619
+ .filter((injection) => !injection.lineNumbered)
1620
+ .sort((a, b) => b.end - a.end);
1621
+ for (const { end, program } of injections) {
1622
+ const flag = program === "rg" ? "--line-number" : "-n";
1623
+ rewritten = `${rewritten.slice(0, end)} ${flag}${rewritten.slice(end)}`;
1624
+ }
1625
+ return rewritten;
1626
+ }