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.
- package/README.md +60 -56
- package/cordis.patch.yml +3 -3
- package/lib/index.js +7224 -6454
- package/lib/types/dsh/contracts.d.ts +3 -12
- package/lib/types/dsh/fs.d.ts +0 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/host-compat.d.ts +78 -7
- package/lib/types/dsh/index.d.ts +9 -21
- package/lib/types/dsh/metrics/events.d.ts +1 -1
- package/lib/types/dsh/metrics/observer.d.ts +1 -1
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +1 -1
- package/lib/types/dsh/tools/edit.d.ts +10 -0
- package/lib/types/dsh/tools/read.d.ts +3 -3
- package/lib/types/src/domain-errors.d.ts +2 -16
- package/lib/types/src/edit-tool.d.ts +41 -0
- package/lib/types/src/edit-undo.d.ts +2 -11
- package/lib/types/src/file-content/index.d.ts +1 -1
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/hash-store.d.ts +2 -2
- package/lib/types/src/hashline/alphabet.d.ts +16 -1
- package/lib/types/src/hashline/hash-identity.d.ts +10 -4
- package/lib/types/src/mutation-engine/engine.d.ts +31 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +1 -8
- package/lib/types/src/mutation-engine/types.d.ts +50 -40
- package/lib/types/src/payload-contract.d.ts +135 -0
- package/lib/types/src/served-session/serve-render.d.ts +0 -6
- package/lib/types/src/undo-store.d.ts +1 -17
- package/package.json +21 -18
- package/prompts/edit-guidelines.md +10 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +1 -0
- package/prompts/read-guidelines.md +4 -4
- package/prompts/read.md +1 -1
- package/prompts/undo-last-edit-guidelines.md +2 -2
- package/prompts/undo-last-edit-snippet.md +1 -1
- package/prompts/undo-last-edit.md +1 -1
- package/src/bash-classifier.ts +1626 -0
- package/src/bash-conversion/decide.ts +812 -0
- package/src/bash-display-transform.ts +50 -0
- package/src/bash-multifile-config.ts +239 -0
- package/src/bash-view-config.ts +158 -0
- package/src/cut-repair.ts +11 -77
- package/src/domain-errors.ts +3 -35
- package/src/edit-normalize.ts +8 -0
- package/src/edit-render.ts +161 -0
- package/src/edit-tool.ts +118 -0
- package/src/edit-undo.ts +25 -379
- package/src/edit.ts +136 -0
- package/src/file-content/index.ts +1 -1
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/hash-store.ts +1 -18
- package/src/hashline/alphabet.ts +42 -1
- package/src/hashline/hash-identity.ts +12 -3
- package/src/integrations/pi-lens/command.ts +712 -0
- package/src/integrations/pi-lens/config.ts +148 -0
- package/src/integrations/pi-lens/io-bridge-adapter.ts +234 -0
- package/src/integrations/pi-lens/io-bridge.ts +112 -0
- package/src/integrations/pi-lens/settings-overlay.ts +272 -0
- package/src/lifecycle-hooks/index.ts +1992 -0
- package/src/lifecycle-hooks/types.ts +64 -0
- package/src/mutated-files.ts +75 -0
- package/src/mutation-engine/engine.ts +239 -0
- package/src/mutation-engine/index.ts +17 -0
- package/src/mutation-engine/pipeline.ts +27 -198
- package/src/mutation-engine/types.ts +57 -39
- package/src/mutation-queue.ts +1 -1
- package/src/payload-contract.ts +685 -0
- package/src/preview-controller.ts +84 -0
- package/src/prompts.ts +43 -10
- package/src/read.ts +865 -0
- package/src/search-hook.ts +41 -0
- package/src/served-session/serve-render.ts +2 -2
- package/src/store-config.ts +73 -0
- package/src/store-janitor-warning.ts +169 -0
- package/src/store-janitor.ts +539 -0
- package/src/store-kernel/settings.ts +233 -0
- package/src/store.ts +154 -0
- package/src/tui-presenter.ts +134 -0
- package/src/undo-store.ts +5 -74
- package/src/write-hook.ts +130 -0
- package/lib/types/dsh/hide.d.ts +0 -79
- package/lib/types/dsh/tools/apply-patch.d.ts +0 -38
- package/lib/types/src/apply-patch/commit.d.ts +0 -49
- package/lib/types/src/apply-patch/grammar.d.ts +0 -29
- package/lib/types/src/apply-patch/lifecycle.d.ts +0 -32
- package/lib/types/src/apply-patch/plan.d.ts +0 -50
- package/lib/types/src/apply-patch/render.d.ts +0 -51
- package/lib/types/src/apply-patch/resolve.d.ts +0 -129
- package/lib/types/src/apply-patch/tool.d.ts +0 -54
- package/lib/types/src/apply-patch/types.d.ts +0 -362
- package/lib/types/src/fs-lifecycle.d.ts +0 -44
- package/lib/types/src/host-utils.d.ts +0 -63
- package/lib/types/src/mutation-engine/edit-request.d.ts +0 -34
- package/prompts/apply-patch-guidelines.md +0 -10
- package/prompts/apply-patch.md +0 -1
- package/src/apply-patch/commit.ts +0 -190
- package/src/apply-patch/grammar.ts +0 -620
- package/src/apply-patch/index.ts +0 -17
- package/src/apply-patch/lifecycle.ts +0 -195
- package/src/apply-patch/plan.ts +0 -800
- package/src/apply-patch/render.ts +0 -137
- package/src/apply-patch/resolve.ts +0 -470
- package/src/apply-patch/tool.ts +0 -257
- package/src/apply-patch/types.ts +0 -387
- package/src/fs-lifecycle.ts +0 -201
- package/src/host-utils.ts +0 -224
- 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
|
+
}
|