harnery 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +8 -0
- package/dist/commands/agents.d.ts +24 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +244 -13
- package/dist/commands/backup.d.ts +5 -4
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/backup.js +15 -14
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +27 -1
- package/dist/commands/claude-desktop.d.ts +19 -0
- package/dist/commands/claude-desktop.d.ts.map +1 -0
- package/dist/commands/claude-desktop.js +168 -0
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +149 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +37 -0
- package/dist/commands/eml.d.ts +32 -0
- package/dist/commands/eml.d.ts.map +1 -1
- package/dist/commands/eml.js +16 -3
- package/dist/commands/grep.d.ts +35 -2
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +427 -139
- package/dist/commands/harness.d.ts +11 -0
- package/dist/commands/harness.d.ts.map +1 -0
- package/dist/commands/harness.js +118 -0
- package/dist/commands/init.d.ts +15 -5
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +125 -14
- package/dist/commands/presence.d.ts +9 -4
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +88 -5
- package/dist/commands/relay.d.ts +9 -0
- package/dist/commands/relay.d.ts.map +1 -0
- package/dist/commands/relay.js +143 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +5 -0
- package/dist/commands/workflow.d.ts +4 -0
- package/dist/commands/workflow.d.ts.map +1 -0
- package/dist/commands/workflow.js +82 -0
- package/dist/core/agents/canonical-emit.d.ts +15 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +41 -3
- package/dist/core/agents/cli.js +14 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +44 -3
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +26 -1
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +26 -1
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +4 -37
- package/dist/core/agents/rules/stop-hook.d.ts +8 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +8 -0
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +16 -43
- package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +24 -4
- package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +24 -7
- package/dist/core/agents/state/names.d.ts +38 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +46 -7
- package/dist/core/agents/state/pidmap.d.ts +5 -1
- package/dist/core/agents/state/pidmap.d.ts.map +1 -1
- package/dist/core/agents/state/pidmap.js +58 -2
- package/dist/core/agents/state/stale-sweep.d.ts +3 -2
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +6 -6
- package/dist/core/config.d.ts +77 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +210 -25
- package/dist/core/context/index.d.ts +144 -0
- package/dist/core/context/index.d.ts.map +1 -0
- package/dist/core/context/index.js +380 -0
- package/dist/core/harnesses/bench.d.ts +29 -0
- package/dist/core/harnesses/bench.d.ts.map +1 -0
- package/dist/core/harnesses/bench.js +151 -0
- package/dist/core/harnesses/index.d.ts +9 -0
- package/dist/core/harnesses/index.d.ts.map +1 -0
- package/dist/core/harnesses/index.js +4 -0
- package/dist/core/harnesses/profiles.d.ts +62 -0
- package/dist/core/harnesses/profiles.d.ts.map +1 -0
- package/dist/core/harnesses/profiles.js +115 -0
- package/dist/core/harnesses/registry.d.ts +15 -0
- package/dist/core/harnesses/registry.d.ts.map +1 -0
- package/dist/core/harnesses/registry.js +113 -0
- package/dist/core/harnesses/types.d.ts +64 -0
- package/dist/core/harnesses/types.d.ts.map +1 -0
- package/dist/core/harnesses/types.js +20 -0
- package/dist/core/hooks/cli.js +309 -66
- package/dist/core/hooks/events/schema.d.ts +42 -1
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts +7 -0
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +4 -0
- package/dist/core/hooks/harness/parse.d.ts +1 -1
- package/dist/core/hooks/harness/parse.d.ts.map +1 -1
- package/dist/core/hooks/harness/parse.js +4 -0
- package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
- package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
- package/dist/core/hooks/resolve/coord-root.js +28 -6
- package/dist/core/presence/blob.d.ts +40 -0
- package/dist/core/presence/blob.d.ts.map +1 -0
- package/dist/core/presence/blob.js +91 -0
- package/dist/core/presence/git.d.ts +68 -0
- package/dist/core/presence/git.d.ts.map +1 -0
- package/dist/core/presence/git.js +173 -0
- package/dist/core/presence/index.d.ts +74 -0
- package/dist/core/presence/index.d.ts.map +1 -0
- package/dist/core/presence/index.js +224 -0
- package/dist/core/presence/relay-client.d.ts +39 -0
- package/dist/core/presence/relay-client.d.ts.map +1 -0
- package/dist/core/presence/relay-client.js +295 -0
- package/dist/core/presence/relay-protocol.d.ts +94 -0
- package/dist/core/presence/relay-protocol.d.ts.map +1 -0
- package/dist/core/presence/relay-protocol.js +169 -0
- package/dist/core/workflow/billing.d.ts +48 -0
- package/dist/core/workflow/billing.d.ts.map +1 -0
- package/dist/core/workflow/billing.js +108 -0
- package/dist/core/workflow/child-env.d.ts +30 -0
- package/dist/core/workflow/child-env.d.ts.map +1 -0
- package/dist/core/workflow/child-env.js +43 -0
- package/dist/core/workflow/engine.d.ts +21 -0
- package/dist/core/workflow/engine.d.ts.map +1 -0
- package/dist/core/workflow/engine.js +349 -0
- package/dist/core/workflow/harnesses.d.ts +17 -0
- package/dist/core/workflow/harnesses.d.ts.map +1 -0
- package/dist/core/workflow/harnesses.js +21 -0
- package/dist/core/workflow/spawn-claude.d.ts +25 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
- package/dist/core/workflow/spawn-claude.js +103 -0
- package/dist/core/workflow/spawn-codex.d.ts +22 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
- package/dist/core/workflow/spawn-codex.js +88 -0
- package/dist/core/workflow/spawn-cursor.d.ts +29 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
- package/dist/core/workflow/spawn-cursor.js +91 -0
- package/dist/core/workflow/types.d.ts +152 -0
- package/dist/core/workflow/types.d.ts.map +1 -0
- package/dist/core/workflow/types.js +9 -0
- package/dist/core/workflow/validate.d.ts +15 -0
- package/dist/core/workflow/validate.d.ts.map +1 -0
- package/dist/core/workflow/validate.js +70 -0
- package/dist/lib/browser/client.d.ts +16 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +22 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +1 -0
- package/dist/lib/browser/launch-args.d.ts +21 -0
- package/dist/lib/browser/launch-args.d.ts.map +1 -0
- package/dist/lib/browser/launch-args.js +32 -0
- package/dist/lib/claude-desktop.d.ts +105 -0
- package/dist/lib/claude-desktop.d.ts.map +1 -0
- package/dist/lib/claude-desktop.js +217 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +5 -2
- package/dist/lib/identities/assume.d.ts +51 -0
- package/dist/lib/identities/assume.d.ts.map +1 -0
- package/dist/lib/identities/assume.js +274 -0
- package/dist/lib/identities/index.d.ts +7 -7
- package/dist/lib/identities/index.d.ts.map +1 -1
- package/dist/lib/identities/index.js +24 -24
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +15 -2
- package/package.json +12 -1
- package/schemas/config.schema.json +88 -19
- package/src/commander.ts +8 -0
- package/src/commands/agents.ts +280 -14
- package/src/commands/backup.ts +17 -16
- package/src/commands/browse.ts +32 -0
- package/src/commands/claude-desktop.ts +215 -0
- package/src/commands/context.ts +174 -1
- package/src/commands/doctor.ts +44 -0
- package/src/commands/eml.ts +22 -4
- package/src/commands/grep.ts +535 -142
- package/src/commands/harness.ts +147 -0
- package/src/commands/init.ts +138 -17
- package/src/commands/presence.ts +111 -5
- package/src/commands/relay.ts +160 -0
- package/src/commands/sync.ts +4 -0
- package/src/commands/workflow.ts +123 -0
- package/src/core/agents/canonical-emit.ts +41 -3
- package/src/core/agents/cli.ts +15 -6
- package/src/core/agents/render/prompt-context.ts +48 -3
- package/src/core/agents/render/session-context.ts +27 -1
- package/src/core/agents/rules/claim-conflict.ts +26 -1
- package/src/core/agents/rules/commit-conflict.ts +4 -33
- package/src/core/agents/rules/stop-hook.ts +17 -0
- package/src/core/agents/session-events.ts +15 -39
- package/src/core/agents/state/heartbeat-projector.ts +23 -2
- package/src/core/agents/state/heartbeat-writer.ts +33 -7
- package/src/core/agents/state/names.ts +68 -9
- package/src/core/agents/state/pidmap.ts +59 -2
- package/src/core/agents/state/stale-sweep.ts +6 -11
- package/src/core/config.ts +271 -24
- package/src/core/context/index.ts +575 -0
- package/src/core/harnesses/bench.ts +214 -0
- package/src/core/harnesses/index.ts +31 -0
- package/src/core/harnesses/profiles.ts +141 -0
- package/src/core/harnesses/registry.ts +140 -0
- package/src/core/harnesses/types.ts +90 -0
- package/src/core/hooks/cli.ts +361 -65
- package/src/core/hooks/events/schema.ts +72 -0
- package/src/core/hooks/harness/events.ts +11 -0
- package/src/core/hooks/harness/parse.ts +7 -1
- package/src/core/hooks/resolve/coord-root.ts +28 -6
- package/src/core/presence/blob.ts +125 -0
- package/src/core/presence/git.ts +191 -0
- package/src/core/presence/index.ts +274 -0
- package/src/core/presence/relay-client.ts +322 -0
- package/src/core/presence/relay-protocol.ts +246 -0
- package/src/core/workflow/billing.ts +152 -0
- package/src/core/workflow/child-env.ts +47 -0
- package/src/core/workflow/engine.ts +403 -0
- package/src/core/workflow/harnesses.ts +33 -0
- package/src/core/workflow/spawn-claude.ts +118 -0
- package/src/core/workflow/spawn-codex.ts +93 -0
- package/src/core/workflow/spawn-cursor.ts +108 -0
- package/src/core/workflow/types.ts +160 -0
- package/src/core/workflow/validate.ts +75 -0
- package/src/lib/browser/client.ts +37 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/launch-args.ts +33 -0
- package/src/lib/claude-desktop.ts +293 -0
- package/src/lib/docs-lint.ts +5 -2
- package/src/lib/identities/assume.ts +363 -0
- package/src/lib/identities/index.ts +28 -24
- package/src/lib/instructions/templates.ts +16 -2
package/src/commands/grep.ts
CHANGED
|
@@ -1,29 +1,40 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
2
4
|
import type { Command } from "commander";
|
|
3
5
|
import type { EmitContext, HarneryProgramContext } from "../commander.ts";
|
|
4
6
|
import { resolveSearchEngine, type SearchEngine } from "../lib/tools/ripgrep.ts";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* `grep`: monorepo-aware code search. Prefers ripgrep (`rg`) when it is on
|
|
8
|
-
* PATH and falls back to GNU
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* PATH and falls back to GNU/BSD grep transparently — both engines are driven
|
|
11
|
+
* with equivalent flags and their output is parsed into the same envelope, so
|
|
12
|
+
* results are identical (pinned by tests/unit/grep-engine.test.ts).
|
|
11
13
|
* Smart default excludes (skip dist/.next/node_modules/.git/...), repo
|
|
12
|
-
* scoping (`--repo <name>` or `--all-repos`),
|
|
14
|
+
* scoping (`--repo <name>` or `--all-repos`), language presets, file-level
|
|
15
|
+
* boolean composition (`--and` / `--without`), and context (`-C`/`-A`/`-B`).
|
|
13
16
|
*
|
|
14
17
|
* Engine selection: `HARNERY_GREP_ENGINE=rg|grep` forces one; otherwise `rg`
|
|
15
|
-
* is resolved (managed install, PATH, or opt-in auto-provision; see
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* is resolved (managed install, PATH, or opt-in auto-provision; see
|
|
19
|
+
* resolveEngine) and used when available. Repos are searched in parallel, and
|
|
20
|
+
* in `--all-repos` mode the parent scan prunes submodule directories so each
|
|
21
|
+
* match is attributed to exactly one repo.
|
|
22
|
+
*
|
|
23
|
+
* Output framing: content searches request the NUL filename delimiter
|
|
24
|
+
* (`--null` on both engines — the long spelling, because BSD grep repurposes
|
|
25
|
+
* `-Z` for decompression) so filename boundaries are never inferred from
|
|
26
|
+
* punctuation. Context lines are NOT requested from the engines: matches are
|
|
27
|
+
* selected (and budgeted) first, then context windows are materialized from
|
|
28
|
+
* one file read per selected file, which keeps `--limit` semantics exact and
|
|
29
|
+
* both engines byte-identical.
|
|
20
30
|
*
|
|
21
31
|
* Default behavior matches extended-regex semantics (`grep -E` / ripgrep's
|
|
22
32
|
* default). Use `-F` / `--literal` to pin to literal-string mode. Output is
|
|
23
|
-
* line-oriented `file:line:content` in TTY mode
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
33
|
+
* line-oriented `file:line:content` in TTY mode (context rows render
|
|
34
|
+
* grep-style as `file-line-content`); `--json` emits the full GrepResult
|
|
35
|
+
* envelope `{ pattern, mode, engine, and_patterns, without_patterns, repos,
|
|
36
|
+
* total_matches, total_files, truncated, elapsed_ms }`. Matches are sorted
|
|
37
|
+
* (file, then line) for stable output across runs and engines.
|
|
27
38
|
*/
|
|
28
39
|
|
|
29
40
|
const DEFAULT_EXCLUDE_DIRS = [
|
|
@@ -70,7 +81,8 @@ export type GrepEngine = SearchEngine;
|
|
|
70
81
|
export interface GrepOpts {
|
|
71
82
|
repo?: string;
|
|
72
83
|
allRepos?: boolean;
|
|
73
|
-
lang
|
|
84
|
+
/** Repeatable and/or comma-separated (`--lang ts,tsx`). A bare string is accepted for direct callers. */
|
|
85
|
+
lang?: string | string[];
|
|
74
86
|
ignoreCase?: boolean;
|
|
75
87
|
wholeWord?: boolean;
|
|
76
88
|
literal?: boolean;
|
|
@@ -78,19 +90,26 @@ export interface GrepOpts {
|
|
|
78
90
|
files?: boolean;
|
|
79
91
|
count?: boolean;
|
|
80
92
|
context?: string;
|
|
93
|
+
afterContext?: string;
|
|
94
|
+
beforeContext?: string;
|
|
81
95
|
maxCount?: string;
|
|
82
96
|
limit?: string;
|
|
83
97
|
include?: string[];
|
|
84
98
|
exclude?: string[];
|
|
99
|
+
and?: string[];
|
|
100
|
+
without?: string[];
|
|
101
|
+
quiet?: boolean;
|
|
85
102
|
noDefaultExcludes?: boolean;
|
|
86
103
|
json?: boolean;
|
|
87
104
|
}
|
|
88
105
|
|
|
89
|
-
interface Match {
|
|
106
|
+
export interface Match {
|
|
90
107
|
repo: string;
|
|
91
108
|
file: string;
|
|
92
109
|
line: number;
|
|
93
110
|
text: string;
|
|
111
|
+
/** "match" = primary result row (counts toward totals + --limit); "context" = free -C/-A/-B row. */
|
|
112
|
+
kind: "match" | "context";
|
|
94
113
|
}
|
|
95
114
|
|
|
96
115
|
export function registerGrepCommand(
|
|
@@ -103,11 +122,17 @@ export function registerGrepCommand(
|
|
|
103
122
|
.description(
|
|
104
123
|
"Monorepo-aware code search (ripgrep when available, GNU grep fallback). " +
|
|
105
124
|
"Skips dist/.next/node_modules/.git/... by default. " +
|
|
106
|
-
"Use --repo, --all-repos, --lang for scoping
|
|
125
|
+
"Use --repo, --all-repos, --lang for scoping; --and/--without for file-level composition. " +
|
|
126
|
+
"Regex by default; -F for literal.",
|
|
107
127
|
)
|
|
108
128
|
.option("--repo <name>", "Scope to one submodule (`.` = parent repo root)")
|
|
109
129
|
.option("--all-repos", "Search parent + every submodule")
|
|
110
|
-
.option(
|
|
130
|
+
.option(
|
|
131
|
+
"--lang <lang>",
|
|
132
|
+
`File type preset, repeatable or comma-separated (${Object.keys(LANG_GLOBS).join(", ")})`,
|
|
133
|
+
collect,
|
|
134
|
+
[] as string[],
|
|
135
|
+
)
|
|
111
136
|
.option("-i, --ignore-case", "Case-insensitive match")
|
|
112
137
|
.option("-w, --whole-word", "Match whole words only")
|
|
113
138
|
.option("-F, --literal", "Treat <pattern> as a literal string (no regex)")
|
|
@@ -119,8 +144,26 @@ export function registerGrepCommand(
|
|
|
119
144
|
)
|
|
120
145
|
.option("-c, --count", "Print match count per file (suppresses content)")
|
|
121
146
|
.option("-C, --context <n>", "Print N lines of context around each match", "0")
|
|
147
|
+
.option("-A, --after-context <n>", "Print N lines after each match (overrides -C's after side)")
|
|
148
|
+
.option(
|
|
149
|
+
"-B, --before-context <n>",
|
|
150
|
+
"Print N lines before each match (overrides -C's before side)",
|
|
151
|
+
)
|
|
122
152
|
.option("--max-count <n>", "Stop after N matches per file")
|
|
123
153
|
.option("--limit <n>", "Truncate output to N matches total")
|
|
154
|
+
.option(
|
|
155
|
+
"--and <pattern>",
|
|
156
|
+
"Only show files that ALSO contain this pattern (file-level, repeatable)",
|
|
157
|
+
collect,
|
|
158
|
+
[] as string[],
|
|
159
|
+
)
|
|
160
|
+
.option(
|
|
161
|
+
"--without <pattern>",
|
|
162
|
+
"Drop files that contain this pattern (file-level, repeatable)",
|
|
163
|
+
collect,
|
|
164
|
+
[] as string[],
|
|
165
|
+
)
|
|
166
|
+
.option("-q, --quiet", "No output; exit 0 if any match exists, 1 if none")
|
|
124
167
|
.option("--include <glob>", "Extra --include glob (repeatable)", collect, [] as string[])
|
|
125
168
|
.option("--exclude <glob>", "Extra --exclude glob (repeatable)", collect, [] as string[])
|
|
126
169
|
.option("--no-default-excludes", "Disable the default skip list (node_modules, dist, etc.)")
|
|
@@ -128,6 +171,12 @@ export function registerGrepCommand(
|
|
|
128
171
|
.action(async (pattern: string, paths: string[], opts: GrepOpts) => {
|
|
129
172
|
try {
|
|
130
173
|
const result = await runGrep(pattern, paths, opts, context);
|
|
174
|
+
if (opts.quiet) {
|
|
175
|
+
// No output by contract; grep-conventional status. exitCode (not
|
|
176
|
+
// process.exit) so an embedding host isn't terminated mid-flush.
|
|
177
|
+
process.exitCode = result.total_matches > 0 ? 0 : 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
131
180
|
if (opts.json) {
|
|
132
181
|
emit.config({ format: "json" });
|
|
133
182
|
emit.data(result);
|
|
@@ -149,6 +198,8 @@ export interface GrepResult {
|
|
|
149
198
|
pattern: string;
|
|
150
199
|
mode: "regex" | "literal" | "files";
|
|
151
200
|
engine: GrepEngine;
|
|
201
|
+
and_patterns: string[];
|
|
202
|
+
without_patterns: string[];
|
|
152
203
|
repos: { name: string; cwd: string; matches: Match[]; truncated: boolean }[];
|
|
153
204
|
total_matches: number;
|
|
154
205
|
total_files: number;
|
|
@@ -156,45 +207,143 @@ export interface GrepResult {
|
|
|
156
207
|
elapsed_ms: number;
|
|
157
208
|
}
|
|
158
209
|
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
210
|
+
/** Validated, resolved options — built once before repo fan-out. */
|
|
211
|
+
interface NormOpts {
|
|
212
|
+
limit: number; // Infinity when unset
|
|
213
|
+
maxCount: number | undefined;
|
|
214
|
+
before: number;
|
|
215
|
+
after: number;
|
|
216
|
+
langGlobs: string[] | undefined;
|
|
217
|
+
andPatterns: string[];
|
|
218
|
+
withoutPatterns: string[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parse a strictly-decimal integer option. Rejects empty, negative, signed,
|
|
223
|
+
* fractional, and trailing-junk values before any engine is spawned.
|
|
224
|
+
*/
|
|
225
|
+
function parseIntOpt(flag: string, raw: string, min: number): number {
|
|
226
|
+
if (!/^\d+$/.test(raw.trim())) {
|
|
227
|
+
throw new Error(`${flag} expects a non-negative integer, got "${raw}"`);
|
|
228
|
+
}
|
|
229
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
230
|
+
if (n < min) throw new Error(`${flag} must be >= ${min}, got ${n}`);
|
|
231
|
+
return n;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeLangs(lang: string | string[] | undefined): string[] | undefined {
|
|
235
|
+
const rawValues = typeof lang === "string" ? [lang] : (lang ?? []);
|
|
236
|
+
const keys: string[] = [];
|
|
237
|
+
for (const raw of rawValues) {
|
|
238
|
+
for (const piece of raw.split(",")) {
|
|
239
|
+
const key = piece.trim();
|
|
240
|
+
if (key === "") continue;
|
|
241
|
+
if (!LANG_GLOBS[key]) {
|
|
242
|
+
throw new Error(`unknown --lang "${key}". Valid: ${Object.keys(LANG_GLOBS).join(", ")}`);
|
|
243
|
+
}
|
|
244
|
+
if (!keys.includes(key)) keys.push(key);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (keys.length === 0) return undefined;
|
|
248
|
+
const globs: string[] = [];
|
|
249
|
+
for (const key of keys) {
|
|
250
|
+
const langGlobs = LANG_GLOBS[key];
|
|
251
|
+
if (!langGlobs) continue;
|
|
252
|
+
for (const g of langGlobs) if (!globs.includes(g)) globs.push(g);
|
|
253
|
+
}
|
|
254
|
+
return globs;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Validate numerics, resolve context sides, expand langs, enforce the compatibility matrix. */
|
|
258
|
+
function normalizeOpts(opts: GrepOpts): NormOpts {
|
|
259
|
+
const limit = opts.limit ? parseIntOpt("--limit", opts.limit, 1) : Number.POSITIVE_INFINITY;
|
|
260
|
+
const maxCount = opts.maxCount ? parseIntOpt("--max-count", opts.maxCount, 1) : undefined;
|
|
261
|
+
const c = opts.context !== undefined ? parseIntOpt("-C/--context", opts.context, 0) : 0;
|
|
262
|
+
const before =
|
|
263
|
+
opts.beforeContext !== undefined
|
|
264
|
+
? parseIntOpt("-B/--before-context", opts.beforeContext, 0)
|
|
265
|
+
: c;
|
|
266
|
+
const after =
|
|
267
|
+
opts.afterContext !== undefined ? parseIntOpt("-A/--after-context", opts.afterContext, 0) : c;
|
|
268
|
+
const andPatterns = opts.and ?? [];
|
|
269
|
+
const withoutPatterns = opts.without ?? [];
|
|
270
|
+
const contextActive = before > 0 || after > 0;
|
|
271
|
+
|
|
167
272
|
if (opts.files) {
|
|
168
273
|
// Filename mode lists files by name glob; content-search flags make no
|
|
169
274
|
// sense here — reject loudly rather than silently ignoring them.
|
|
170
275
|
const incompatible: [unknown, string][] = [
|
|
171
|
-
[opts.lang, "--lang"],
|
|
276
|
+
[normalizeLangs(opts.lang)?.length, "--lang"],
|
|
172
277
|
[opts.count, "-c/--count"],
|
|
173
278
|
[opts.wholeWord, "-w/--whole-word"],
|
|
174
279
|
[opts.literal, "-F/--literal"],
|
|
175
280
|
[opts.maxCount, "--max-count"],
|
|
176
281
|
[opts.include?.length, "--include"],
|
|
177
|
-
[
|
|
282
|
+
[contextActive ? true : undefined, "-C/-A/-B context"],
|
|
283
|
+
[andPatterns.length ? true : undefined, "--and"],
|
|
284
|
+
[withoutPatterns.length ? true : undefined, "--without"],
|
|
178
285
|
];
|
|
179
286
|
for (const [set, flag] of incompatible) {
|
|
180
287
|
if (set) throw new Error(`${flag} does not apply to --files (filename glob) mode`);
|
|
181
288
|
}
|
|
182
289
|
}
|
|
290
|
+
if (contextActive) {
|
|
291
|
+
const rejected: [unknown, string][] = [
|
|
292
|
+
[opts.filesOnly, "-l/--files-only"],
|
|
293
|
+
[opts.count, "-c/--count"],
|
|
294
|
+
[opts.quiet, "-q/--quiet"],
|
|
295
|
+
];
|
|
296
|
+
for (const [set, flag] of rejected) {
|
|
297
|
+
if (set) throw new Error(`-C/-A/-B context does not combine with ${flag}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (opts.quiet) {
|
|
301
|
+
const rejected: [unknown, string][] = [
|
|
302
|
+
[opts.json, "--json"],
|
|
303
|
+
[opts.filesOnly, "-l/--files-only"],
|
|
304
|
+
[opts.count, "-c/--count"],
|
|
305
|
+
[opts.limit, "--limit"],
|
|
306
|
+
];
|
|
307
|
+
for (const [set, flag] of rejected) {
|
|
308
|
+
if (set) throw new Error(`-q/--quiet does not combine with ${flag}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
// -q only needs existence: one accepted primary row settles the exit code.
|
|
314
|
+
limit: opts.quiet ? 1 : limit,
|
|
315
|
+
maxCount,
|
|
316
|
+
before,
|
|
317
|
+
after,
|
|
318
|
+
langGlobs: normalizeLangs(opts.lang),
|
|
319
|
+
andPatterns,
|
|
320
|
+
withoutPatterns,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Exported for tests (not part of the package exports map). */
|
|
325
|
+
export async function runGrep(
|
|
326
|
+
pattern: string,
|
|
327
|
+
paths: string[],
|
|
328
|
+
opts: GrepOpts,
|
|
329
|
+
context: HarneryProgramContext | undefined,
|
|
330
|
+
): Promise<GrepResult> {
|
|
331
|
+
if (!pattern) throw new Error("pattern required");
|
|
332
|
+
const norm = normalizeOpts(opts);
|
|
183
333
|
const started = Date.now();
|
|
184
334
|
|
|
185
335
|
const { engine, rgBin } = await resolveSearchEngine("grep");
|
|
186
336
|
const repos = resolveRepos(opts, context);
|
|
187
|
-
const limit = opts.limit ? Number.parseInt(opts.limit, 10) : Number.POSITIVE_INFINITY;
|
|
188
|
-
const contextN = Number.parseInt(opts.context ?? "0", 10);
|
|
189
|
-
const sortable = !(Number.isFinite(contextN) && contextN > 0);
|
|
190
337
|
|
|
191
338
|
// Host-injected default excludes (generated mirrors, vendored trees, ...)
|
|
192
339
|
// ride the same --no-default-excludes gate as the built-in list.
|
|
193
340
|
const hostExcludeDirs = opts.noDefaultExcludes ? [] : (context?.grepExcludeDirs ?? []);
|
|
194
341
|
|
|
195
|
-
// All repos are searched concurrently; each
|
|
196
|
-
//
|
|
197
|
-
//
|
|
342
|
+
// All repos are searched concurrently; each collects at most limit+1
|
|
343
|
+
// accepted rows (one row of lookahead, so "exactly N results" is
|
|
344
|
+
// distinguishable from "more than N"), then the global budget is applied
|
|
345
|
+
// in repo order below so `--limit` semantics stay deterministic.
|
|
346
|
+
const acceptLimit = Number.isFinite(norm.limit) ? norm.limit + 1 : Number.POSITIVE_INFINITY;
|
|
198
347
|
const perRepo = await Promise.all(
|
|
199
348
|
repos.map((repo) => {
|
|
200
349
|
// In --all-repos mode the parent scan prunes submodule dirs — each
|
|
@@ -208,9 +357,10 @@ export async function runGrep(
|
|
|
208
357
|
pattern,
|
|
209
358
|
paths,
|
|
210
359
|
opts,
|
|
360
|
+
norm,
|
|
211
361
|
repo.cwd,
|
|
212
362
|
repo.name,
|
|
213
|
-
|
|
363
|
+
acceptLimit,
|
|
214
364
|
engine,
|
|
215
365
|
rgBin,
|
|
216
366
|
extraDirs,
|
|
@@ -222,25 +372,30 @@ export async function runGrep(
|
|
|
222
372
|
let totalMatches = 0;
|
|
223
373
|
const filesSeen = new Set<string>();
|
|
224
374
|
let truncated = false;
|
|
225
|
-
let budget = limit;
|
|
375
|
+
let budget = norm.limit;
|
|
226
376
|
|
|
227
377
|
for (let i = 0; i < repos.length; i++) {
|
|
228
378
|
const repo = repos[i];
|
|
229
379
|
if (!repo) continue;
|
|
230
380
|
const collected = perRepo[i] ?? [];
|
|
231
|
-
|
|
232
|
-
collected.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
233
|
-
}
|
|
234
|
-
const engineCapped = Number.isFinite(limit) && collected.length >= limit;
|
|
381
|
+
collected.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
235
382
|
const take = Number.isFinite(budget)
|
|
236
383
|
? Math.min(collected.length, Math.max(0, budget))
|
|
237
384
|
: collected.length;
|
|
238
|
-
|
|
239
|
-
|
|
385
|
+
let matches = collected.slice(0, take);
|
|
386
|
+
// truncated only when an accepted primary row was actually omitted —
|
|
387
|
+
// the lookahead row (or a later-repo surplus) is the proof.
|
|
388
|
+
const repoTruncated = collected.length > take;
|
|
240
389
|
if (repoTruncated) truncated = true;
|
|
241
390
|
if (Number.isFinite(budget)) budget -= take;
|
|
242
391
|
totalMatches += matches.length;
|
|
243
392
|
for (const m of matches) filesSeen.add(`${repo.name}/${m.file}`);
|
|
393
|
+
// Context is materialized only for the SELECTED rows, after budgeting,
|
|
394
|
+
// so context rows never consume the limit and trailing context is never
|
|
395
|
+
// cut off by an engine kill.
|
|
396
|
+
if (!opts.files && !opts.filesOnly && !opts.count && (norm.before > 0 || norm.after > 0)) {
|
|
397
|
+
matches = await materializeContext(matches, repo.cwd, norm.before, norm.after);
|
|
398
|
+
}
|
|
244
399
|
allRepoResults.push({ name: repo.name, cwd: repo.cwd, matches, truncated: repoTruncated });
|
|
245
400
|
}
|
|
246
401
|
|
|
@@ -248,6 +403,8 @@ export async function runGrep(
|
|
|
248
403
|
pattern,
|
|
249
404
|
mode: opts.files ? "files" : opts.literal ? "literal" : "regex",
|
|
250
405
|
engine,
|
|
406
|
+
and_patterns: norm.andPatterns,
|
|
407
|
+
without_patterns: norm.withoutPatterns,
|
|
251
408
|
repos: allRepoResults,
|
|
252
409
|
total_matches: totalMatches,
|
|
253
410
|
total_files: filesSeen.size,
|
|
@@ -295,57 +452,164 @@ async function runGrepInRepo(
|
|
|
295
452
|
pattern: string,
|
|
296
453
|
paths: string[],
|
|
297
454
|
opts: GrepOpts,
|
|
455
|
+
norm: NormOpts,
|
|
298
456
|
cwd: string,
|
|
299
457
|
repoName: string,
|
|
300
|
-
|
|
458
|
+
acceptLimit: number,
|
|
301
459
|
engine: GrepEngine,
|
|
302
460
|
rgBin: string,
|
|
303
461
|
extraExcludeDirs: readonly string[],
|
|
304
462
|
): Promise<Match[]> {
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
463
|
+
// File-level boolean composition: build the complete auxiliary file sets
|
|
464
|
+
// FIRST (no limit — a partial set can't prove absence), then feed the
|
|
465
|
+
// primary scan an in-stream predicate. Running the primary with its limit
|
|
466
|
+
// and filtering afterwards would let non-qualifying early hits consume the
|
|
467
|
+
// budget and hide later valid results.
|
|
468
|
+
let predicate: ((file: string) => boolean) | undefined;
|
|
469
|
+
if (norm.andPatterns.length > 0 || norm.withoutPatterns.length > 0) {
|
|
470
|
+
const requiredSets: Set<string>[] = [];
|
|
471
|
+
// Sequential, not parallel: repeated flags must not multiply peak child
|
|
472
|
+
// process concurrency by repos x patterns. Repo workers stay parallel.
|
|
473
|
+
for (const p of norm.andPatterns) {
|
|
474
|
+
const files = await runFileSetScan(
|
|
475
|
+
p,
|
|
476
|
+
paths,
|
|
477
|
+
opts,
|
|
478
|
+
norm,
|
|
479
|
+
cwd,
|
|
480
|
+
engine,
|
|
481
|
+
rgBin,
|
|
482
|
+
extraExcludeDirs,
|
|
483
|
+
);
|
|
484
|
+
if (files.size === 0) return []; // intersection is already empty
|
|
485
|
+
requiredSets.push(files);
|
|
486
|
+
}
|
|
487
|
+
const forbidden = new Set<string>();
|
|
488
|
+
for (const p of norm.withoutPatterns) {
|
|
489
|
+
const files = await runFileSetScan(
|
|
490
|
+
p,
|
|
491
|
+
paths,
|
|
492
|
+
opts,
|
|
493
|
+
norm,
|
|
494
|
+
cwd,
|
|
495
|
+
engine,
|
|
496
|
+
rgBin,
|
|
497
|
+
extraExcludeDirs,
|
|
498
|
+
);
|
|
499
|
+
for (const f of files) forbidden.add(f);
|
|
500
|
+
}
|
|
501
|
+
predicate = (file) => requiredSets.every((s) => s.has(file)) && !forbidden.has(file);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const [bin, args, decodeMode] = buildEngineInvocation(
|
|
505
|
+
pattern,
|
|
506
|
+
paths,
|
|
507
|
+
opts,
|
|
508
|
+
norm,
|
|
509
|
+
engine,
|
|
510
|
+
rgBin,
|
|
511
|
+
extraExcludeDirs,
|
|
512
|
+
false,
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
let matches = await execSearch(bin, args, cwd, acceptLimit, decodeMode, predicate, false);
|
|
316
516
|
// -c mode: GNU grep prints a `path:0` row for every searched file; ripgrep
|
|
317
517
|
// omits zero-count rows. Filter zeros on both engines so output matches.
|
|
318
518
|
if (opts.count) matches = matches.filter((m) => m.line > 0);
|
|
319
519
|
return matches.map((m) => ({ ...m, repo: repoName }));
|
|
320
520
|
}
|
|
321
521
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
522
|
+
/** Membership scan for one --and/--without pattern: complete files-only set, strict failure. */
|
|
523
|
+
async function runFileSetScan(
|
|
524
|
+
pattern: string,
|
|
525
|
+
paths: string[],
|
|
526
|
+
opts: GrepOpts,
|
|
527
|
+
norm: NormOpts,
|
|
528
|
+
cwd: string,
|
|
529
|
+
engine: GrepEngine,
|
|
530
|
+
rgBin: string,
|
|
531
|
+
extraExcludeDirs: readonly string[],
|
|
532
|
+
): Promise<Set<string>> {
|
|
533
|
+
const [bin, args, decodeMode] = buildEngineInvocation(
|
|
534
|
+
pattern,
|
|
535
|
+
paths,
|
|
536
|
+
opts,
|
|
537
|
+
norm,
|
|
538
|
+
engine,
|
|
539
|
+
rgBin,
|
|
540
|
+
extraExcludeDirs,
|
|
541
|
+
true,
|
|
542
|
+
);
|
|
543
|
+
const rows = await execSearch(
|
|
544
|
+
bin,
|
|
545
|
+
args,
|
|
546
|
+
cwd,
|
|
547
|
+
Number.POSITIVE_INFINITY,
|
|
548
|
+
decodeMode,
|
|
549
|
+
undefined,
|
|
550
|
+
true,
|
|
551
|
+
);
|
|
552
|
+
return new Set(rows.map((r) => r.file));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
type DecodeMode = "content" | "count" | "filesOnly" | "plainLines";
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Build one engine invocation. `membership: true` forces files-only mode for
|
|
559
|
+
* --and/--without set scans (match-shaping + scope flags shared with the
|
|
560
|
+
* primary; output flags not applied).
|
|
561
|
+
*/
|
|
562
|
+
function buildEngineInvocation(
|
|
563
|
+
pattern: string,
|
|
564
|
+
paths: string[],
|
|
565
|
+
opts: GrepOpts,
|
|
566
|
+
norm: NormOpts,
|
|
567
|
+
engine: GrepEngine,
|
|
568
|
+
rgBin: string,
|
|
569
|
+
extraExcludeDirs: readonly string[],
|
|
570
|
+
membership: boolean,
|
|
571
|
+
): [string, string[], DecodeMode] {
|
|
572
|
+
if (opts.files) {
|
|
573
|
+
// Filename mode keeps its existing rg/find framing (plain lines).
|
|
574
|
+
return engine === "rg"
|
|
575
|
+
? [rgBin, buildRgFilesArgs(pattern, paths, opts, extraExcludeDirs), "plainLines"]
|
|
576
|
+
: ["find", buildFindArgs(pattern, paths, opts, extraExcludeDirs), "plainLines"];
|
|
326
577
|
}
|
|
327
|
-
|
|
578
|
+
const filesOnly = membership || Boolean(opts.filesOnly);
|
|
579
|
+
const count = !membership && Boolean(opts.count);
|
|
580
|
+
const decodeMode: DecodeMode = filesOnly ? "filesOnly" : count ? "count" : "content";
|
|
581
|
+
const args =
|
|
582
|
+
engine === "rg"
|
|
583
|
+
? buildRgArgs(pattern, paths, opts, norm, extraExcludeDirs, { filesOnly, count })
|
|
584
|
+
: buildGrepArgs(pattern, paths, opts, norm, extraExcludeDirs, { filesOnly, count });
|
|
585
|
+
return [engine === "rg" ? rgBin : "grep", args, decodeMode];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
interface OutputShape {
|
|
589
|
+
filesOnly: boolean;
|
|
590
|
+
count: boolean;
|
|
328
591
|
}
|
|
329
592
|
|
|
330
593
|
function buildGrepArgs(
|
|
331
594
|
pattern: string,
|
|
332
595
|
paths: string[],
|
|
333
596
|
opts: GrepOpts,
|
|
597
|
+
norm: NormOpts,
|
|
334
598
|
extraExcludeDirs: readonly string[],
|
|
599
|
+
shape: OutputShape,
|
|
335
600
|
): string[] {
|
|
336
|
-
|
|
601
|
+
// --null (NOT -Z: BSD grep repurposes -Z for decompression): NUL after the
|
|
602
|
+
// filename, so paths containing colons/dashes can't confuse the decoder.
|
|
603
|
+
// -I: skip binary files. Context is never requested from the engine — see
|
|
604
|
+
// materializeContext.
|
|
605
|
+
const args: string[] = ["-rn", "-H", "--null", "--color=never", "-I"];
|
|
337
606
|
if (opts.ignoreCase) args.push("-i");
|
|
338
607
|
if (opts.wholeWord) args.push("-w");
|
|
339
608
|
if (opts.literal) args.push("-F");
|
|
340
609
|
else args.push("-E");
|
|
341
|
-
if (
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
if (Number.isFinite(contextN) && contextN > 0) args.push(`-C${contextN}`);
|
|
345
|
-
if (opts.maxCount) {
|
|
346
|
-
const n = Number.parseInt(opts.maxCount, 10);
|
|
347
|
-
if (Number.isFinite(n) && n > 0) args.push(`-m${n}`);
|
|
348
|
-
}
|
|
610
|
+
if (shape.filesOnly) args.push("-l");
|
|
611
|
+
if (shape.count) args.push("-c");
|
|
612
|
+
if (norm.maxCount !== undefined) args.push(`-m${norm.maxCount}`);
|
|
349
613
|
|
|
350
614
|
if (!opts.noDefaultExcludes) {
|
|
351
615
|
for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--exclude-dir=${d}`);
|
|
@@ -354,8 +618,7 @@ function buildGrepArgs(
|
|
|
354
618
|
for (const d of extraExcludeDirs) args.push(`--exclude-dir=${d}`);
|
|
355
619
|
for (const e of opts.exclude ?? []) args.push(`--exclude=${e}`);
|
|
356
620
|
|
|
357
|
-
|
|
358
|
-
if (langGlobs) for (const g of langGlobs) args.push(`--include=${g}`);
|
|
621
|
+
if (norm.langGlobs) for (const g of norm.langGlobs) args.push(`--include=${g}`);
|
|
359
622
|
for (const g of opts.include ?? []) args.push(`--include=${g}`);
|
|
360
623
|
|
|
361
624
|
args.push("--", pattern);
|
|
@@ -368,17 +631,21 @@ function buildRgArgs(
|
|
|
368
631
|
pattern: string,
|
|
369
632
|
paths: string[],
|
|
370
633
|
opts: GrepOpts,
|
|
634
|
+
norm: NormOpts,
|
|
371
635
|
extraExcludeDirs: readonly string[],
|
|
636
|
+
shape: OutputShape,
|
|
372
637
|
): string[] {
|
|
373
638
|
// --hidden --no-ignore: match GNU grep's semantics (search dotdirs, ignore
|
|
374
639
|
// .gitignore) so the only filters are the explicit exclude lists.
|
|
375
640
|
// --no-config: a user's ripgrep config file must not skew results.
|
|
376
641
|
// --with-filename: rg drops the file prefix for a single explicit file arg,
|
|
377
|
-
// which would break the shared
|
|
642
|
+
// which would break the shared decoder.
|
|
643
|
+
// --null: NUL after the filename (same spelling as GNU/BSD grep's long flag).
|
|
378
644
|
const args: string[] = [
|
|
379
645
|
"-n",
|
|
380
646
|
"--no-heading",
|
|
381
647
|
"--with-filename",
|
|
648
|
+
"--null",
|
|
382
649
|
"--color=never",
|
|
383
650
|
"--no-config",
|
|
384
651
|
"--hidden",
|
|
@@ -387,14 +654,9 @@ function buildRgArgs(
|
|
|
387
654
|
if (opts.ignoreCase) args.push("-i");
|
|
388
655
|
if (opts.wholeWord) args.push("-w");
|
|
389
656
|
if (opts.literal) args.push("-F");
|
|
390
|
-
if (
|
|
391
|
-
if (
|
|
392
|
-
|
|
393
|
-
if (Number.isFinite(contextN) && contextN > 0) args.push(`-C${contextN}`);
|
|
394
|
-
if (opts.maxCount) {
|
|
395
|
-
const n = Number.parseInt(opts.maxCount, 10);
|
|
396
|
-
if (Number.isFinite(n) && n > 0) args.push(`-m${n}`);
|
|
397
|
-
}
|
|
657
|
+
if (shape.filesOnly) args.push("-l");
|
|
658
|
+
if (shape.count) args.push("-c");
|
|
659
|
+
if (norm.maxCount !== undefined) args.push(`-m${norm.maxCount}`);
|
|
398
660
|
|
|
399
661
|
// Gitignore-style globs: a bare name matches (and prunes) at any depth,
|
|
400
662
|
// mirroring grep's --exclude-dir / --exclude basename semantics. ORDER
|
|
@@ -402,8 +664,7 @@ function buildRgArgs(
|
|
|
402
664
|
// first and negatives (excludes) last — otherwise `--include '*.md'` would
|
|
403
665
|
// re-include a .md file inside an excluded node_modules/. grep's
|
|
404
666
|
// --exclude-dir always beats --include, so this keeps the engines aligned.
|
|
405
|
-
|
|
406
|
-
if (langGlobs) for (const g of langGlobs) args.push(`--glob=${g}`);
|
|
667
|
+
if (norm.langGlobs) for (const g of norm.langGlobs) args.push(`--glob=${g}`);
|
|
407
668
|
for (const g of opts.include ?? []) args.push(`--glob=${g}`);
|
|
408
669
|
|
|
409
670
|
if (!opts.noDefaultExcludes) {
|
|
@@ -475,37 +736,119 @@ function buildFindArgs(
|
|
|
475
736
|
return args;
|
|
476
737
|
}
|
|
477
738
|
|
|
739
|
+
/**
|
|
740
|
+
* Streaming decoder for NUL-framed engine output. Exported for direct
|
|
741
|
+
* chunk-boundary tests. Record shapes (both engines, pinned by the parity
|
|
742
|
+
* suite):
|
|
743
|
+
* content: <path>NUL<line>:<text>LF
|
|
744
|
+
* count: <path>NUL<count>LF
|
|
745
|
+
* filesOnly: <path>NUL (NUL is the record terminator)
|
|
746
|
+
* plainLines: <path>LF (files mode: rg --files / find)
|
|
747
|
+
*
|
|
748
|
+
* Operates on Buffers so a chunk boundary can fall anywhere — including
|
|
749
|
+
* inside a multi-byte UTF-8 sequence — without corrupting a record: bytes
|
|
750
|
+
* are only decoded to strings once a full record is framed.
|
|
751
|
+
*/
|
|
752
|
+
export class NulDecoder {
|
|
753
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
754
|
+
constructor(private readonly mode: DecodeMode) {}
|
|
755
|
+
|
|
756
|
+
push(chunk: Buffer): Omit<Match, "repo" | "kind">[] {
|
|
757
|
+
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
758
|
+
const rows: Omit<Match, "repo" | "kind">[] = [];
|
|
759
|
+
if (this.mode === "filesOnly") {
|
|
760
|
+
let nul = this.buffer.indexOf(0);
|
|
761
|
+
while (nul >= 0) {
|
|
762
|
+
const path = this.buffer.subarray(0, nul).toString("utf8");
|
|
763
|
+
this.buffer = this.buffer.subarray(nul + 1);
|
|
764
|
+
// Engines may still newline-separate NUL-terminated records in edge
|
|
765
|
+
// configurations; a bare leading LF is framing residue, not a path.
|
|
766
|
+
const cleaned = path.startsWith("\n") ? path.slice(1) : path;
|
|
767
|
+
if (cleaned.length > 0) rows.push({ file: normalizeFile(cleaned), line: 0, text: "" });
|
|
768
|
+
nul = this.buffer.indexOf(0);
|
|
769
|
+
}
|
|
770
|
+
return rows;
|
|
771
|
+
}
|
|
772
|
+
let nl = this.buffer.indexOf(0x0a);
|
|
773
|
+
while (nl >= 0) {
|
|
774
|
+
const record = this.buffer.subarray(0, nl);
|
|
775
|
+
this.buffer = this.buffer.subarray(nl + 1);
|
|
776
|
+
const row = this.decodeRecord(record);
|
|
777
|
+
if (row) rows.push(row);
|
|
778
|
+
nl = this.buffer.indexOf(0x0a);
|
|
779
|
+
}
|
|
780
|
+
return rows;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Flush a trailing unterminated record (engine killed mid-write, or no final LF). */
|
|
784
|
+
flush(): Omit<Match, "repo" | "kind">[] {
|
|
785
|
+
if (this.buffer.length === 0) return [];
|
|
786
|
+
const record = this.buffer;
|
|
787
|
+
this.buffer = Buffer.alloc(0);
|
|
788
|
+
if (this.mode === "filesOnly") {
|
|
789
|
+
// A partial filesOnly record has no terminating NUL — an engine killed
|
|
790
|
+
// mid-path would yield a corrupt name; drop it.
|
|
791
|
+
return [];
|
|
792
|
+
}
|
|
793
|
+
const row = this.decodeRecord(record);
|
|
794
|
+
return row ? [row] : [];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private decodeRecord(record: Buffer): Omit<Match, "repo" | "kind"> | null {
|
|
798
|
+
if (this.mode === "plainLines") {
|
|
799
|
+
const path = record.toString("utf8");
|
|
800
|
+
if (path.length === 0) return null;
|
|
801
|
+
return { file: normalizeFile(path), line: 0, text: "" };
|
|
802
|
+
}
|
|
803
|
+
const nul = record.indexOf(0);
|
|
804
|
+
if (nul < 0) return null; // not a NUL-framed record (stray engine chatter)
|
|
805
|
+
const file = normalizeFile(record.subarray(0, nul).toString("utf8"));
|
|
806
|
+
const rest = record.subarray(nul + 1).toString("utf8");
|
|
807
|
+
if (this.mode === "count") {
|
|
808
|
+
const count = Number.parseInt(rest, 10);
|
|
809
|
+
if (!Number.isFinite(count)) return null;
|
|
810
|
+
return { file, line: count, text: "" };
|
|
811
|
+
}
|
|
812
|
+
// content: <line>:<text>. The engines never emit context/separator rows
|
|
813
|
+
// (context is materialized from file reads), so `:` is always present.
|
|
814
|
+
const colon = rest.indexOf(":");
|
|
815
|
+
if (colon < 0) return null;
|
|
816
|
+
const lineNum = Number.parseInt(rest.slice(0, colon), 10);
|
|
817
|
+
if (!Number.isFinite(lineNum)) return null;
|
|
818
|
+
return { file, line: lineNum, text: rest.slice(colon + 1) };
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
478
822
|
function execSearch(
|
|
479
823
|
bin: string,
|
|
480
824
|
args: string[],
|
|
481
825
|
cwd: string,
|
|
482
|
-
|
|
483
|
-
|
|
826
|
+
acceptLimit: number,
|
|
827
|
+
decodeMode: DecodeMode,
|
|
828
|
+
filePredicate: ((file: string) => boolean) | undefined,
|
|
829
|
+
strict: boolean,
|
|
830
|
+
): Promise<Match[]> {
|
|
484
831
|
return new Promise((resolveP, reject) => {
|
|
485
832
|
const proc = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
486
|
-
const
|
|
487
|
-
|
|
833
|
+
const decoder = new NulDecoder(decodeMode);
|
|
834
|
+
const matches: Match[] = [];
|
|
488
835
|
let stderr = "";
|
|
489
|
-
let
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
nl = buffer.indexOf("\n");
|
|
836
|
+
let killed = false;
|
|
837
|
+
|
|
838
|
+
const accept = (rows: Omit<Match, "repo" | "kind">[]): boolean => {
|
|
839
|
+
for (const row of rows) {
|
|
840
|
+
if (filePredicate && !filePredicate(row.file)) continue;
|
|
841
|
+
matches.push({ ...row, repo: "", kind: "match" });
|
|
842
|
+
if (Number.isFinite(acceptLimit) && matches.length >= acceptLimit) return true;
|
|
843
|
+
}
|
|
844
|
+
return false;
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
proc.stdout.on("data", (chunk: Buffer) => {
|
|
848
|
+
if (killed) return;
|
|
849
|
+
if (accept(decoder.push(chunk))) {
|
|
850
|
+
killed = true;
|
|
851
|
+
proc.kill("SIGTERM");
|
|
509
852
|
}
|
|
510
853
|
});
|
|
511
854
|
proc.stderr.setEncoding("utf8");
|
|
@@ -514,14 +857,14 @@ function execSearch(
|
|
|
514
857
|
});
|
|
515
858
|
proc.on("error", reject);
|
|
516
859
|
proc.on("close", (code) => {
|
|
517
|
-
if (
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
if (
|
|
860
|
+
if (!killed) accept(decoder.flush());
|
|
861
|
+
// Exit 1 is "no matches" on both engines: normal, not an error.
|
|
862
|
+
// Primary scans tolerate exit 2 with matches collected (an unreadable
|
|
863
|
+
// file mid-walk): surface the results rather than throwing them away.
|
|
864
|
+
// STRICT scans (--and/--without membership) must reject on ANY partial
|
|
865
|
+
// failure — an incomplete set cannot prove that a file lacks a pattern.
|
|
866
|
+
const failed = code !== null && code !== 0 && code !== 1 && !killed;
|
|
867
|
+
if (failed && (strict || matches.length === 0)) {
|
|
525
868
|
reject(new Error(`${bin} exited ${code}: ${stderr.trim() || "(no stderr)"}`));
|
|
526
869
|
return;
|
|
527
870
|
}
|
|
@@ -530,34 +873,76 @@ function execSearch(
|
|
|
530
873
|
});
|
|
531
874
|
}
|
|
532
875
|
|
|
533
|
-
/**
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
876
|
+
/**
|
|
877
|
+
* Materialize -C/-A/-B context for already-selected match rows: one file
|
|
878
|
+
* read per selected file, intervals clamped + merged, match rows keep the
|
|
879
|
+
* engine-captured text, other window lines become kind:"context" rows.
|
|
880
|
+
* An unreadable file (deleted since the scan — the accepted TOCTOU window)
|
|
881
|
+
* degrades to its match rows without context rather than failing the search.
|
|
882
|
+
*/
|
|
883
|
+
async function materializeContext(
|
|
884
|
+
selected: Match[],
|
|
885
|
+
cwd: string,
|
|
886
|
+
before: number,
|
|
887
|
+
after: number,
|
|
888
|
+
): Promise<Match[]> {
|
|
889
|
+
const byFile = new Map<string, Match[]>();
|
|
890
|
+
for (const m of selected) {
|
|
891
|
+
const rows = byFile.get(m.file);
|
|
892
|
+
if (rows) rows.push(m);
|
|
893
|
+
else byFile.set(m.file, [m]);
|
|
540
894
|
}
|
|
541
|
-
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
895
|
+
|
|
896
|
+
const out: Match[] = [];
|
|
897
|
+
for (const [file, rows] of byFile) {
|
|
898
|
+
const abs = isAbsolute(file) ? file : join(cwd, file);
|
|
899
|
+
let lines: string[] | null = null;
|
|
900
|
+
try {
|
|
901
|
+
const content = await readFile(abs, "utf8");
|
|
902
|
+
lines = content.split("\n");
|
|
903
|
+
if (lines.at(-1) === "") lines.pop();
|
|
904
|
+
} catch {
|
|
905
|
+
lines = null;
|
|
906
|
+
}
|
|
907
|
+
if (lines === null) {
|
|
908
|
+
out.push(...rows);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
// Merge overlapping/adjacent [line-before, line+after] windows.
|
|
912
|
+
const intervals: [number, number][] = rows
|
|
913
|
+
.map((m): [number, number] => [
|
|
914
|
+
Math.max(1, m.line - before),
|
|
915
|
+
Math.min(lines.length, m.line + after),
|
|
916
|
+
])
|
|
917
|
+
.sort((a, b) => a[0] - b[0]);
|
|
918
|
+
const merged: [number, number][] = [];
|
|
919
|
+
for (const iv of intervals) {
|
|
920
|
+
const last = merged.at(-1);
|
|
921
|
+
if (last && iv[0] <= last[1] + 1) last[1] = Math.max(last[1], iv[1]);
|
|
922
|
+
else merged.push([iv[0], iv[1]]);
|
|
923
|
+
}
|
|
924
|
+
const matchByLine = new Map(rows.map((m) => [m.line, m]));
|
|
925
|
+
const emitted = new Set<Match>();
|
|
926
|
+
for (const [start, end] of merged) {
|
|
927
|
+
if (start > end) continue;
|
|
928
|
+
for (let n = start; n <= end; n++) {
|
|
929
|
+
const m = matchByLine.get(n);
|
|
930
|
+
if (m) {
|
|
931
|
+
out.push(m);
|
|
932
|
+
emitted.add(m);
|
|
933
|
+
} else {
|
|
934
|
+
const repo = rows[0]?.repo ?? "";
|
|
935
|
+
out.push({ repo, file, line: n, text: lines[n - 1] ?? "", kind: "context" });
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
// A match whose line exceeds the file's current length (file shrank
|
|
940
|
+
// since the scan) sits outside every clamped window — still emit it.
|
|
941
|
+
for (const m of rows) {
|
|
942
|
+
if (!emitted.has(m)) out.push(m);
|
|
548
943
|
}
|
|
549
|
-
return { file: normalizeFile(line.slice(0, firstColon)), line: 0, text: after };
|
|
550
|
-
}
|
|
551
|
-
const lineNum = Number.parseInt(after.slice(0, secondColon), 10);
|
|
552
|
-
if (!Number.isFinite(lineNum)) {
|
|
553
|
-
// Probably a context separator like `--`. Skip.
|
|
554
|
-
return null;
|
|
555
944
|
}
|
|
556
|
-
return
|
|
557
|
-
file: normalizeFile(line.slice(0, firstColon)),
|
|
558
|
-
line: lineNum,
|
|
559
|
-
text: after.slice(secondColon + 1),
|
|
560
|
-
};
|
|
945
|
+
return out;
|
|
561
946
|
}
|
|
562
947
|
|
|
563
948
|
/** Both engines may emit a leading `./` for the default path; strip it so output is engine-identical. */
|
|
@@ -570,23 +955,31 @@ function renderResult(r: GrepResult, opts: GrepOpts): string {
|
|
|
570
955
|
const showRepoHeader = r.repos.length > 1;
|
|
571
956
|
for (const repo of r.repos) {
|
|
572
957
|
if (repo.matches.length === 0) continue;
|
|
958
|
+
const primary = repo.matches.filter((m) => m.kind === "match").length;
|
|
573
959
|
if (showRepoHeader) {
|
|
574
960
|
lines.push("");
|
|
575
|
-
lines.push(
|
|
576
|
-
`── ${repo.name} (${repo.matches.length} match${repo.matches.length === 1 ? "" : "es"}) ──`,
|
|
577
|
-
);
|
|
961
|
+
lines.push(`── ${repo.name} (${primary} match${primary === 1 ? "" : "es"}) ──`);
|
|
578
962
|
}
|
|
963
|
+
// Context mode: `--` between disjoint windows (file change or line gap).
|
|
964
|
+
const contextMode = repo.matches.some((x) => x.kind === "context");
|
|
965
|
+
let prev: Match | undefined;
|
|
579
966
|
for (const m of repo.matches) {
|
|
967
|
+
if (contextMode && prev && (prev.file !== m.file || m.line !== prev.line + 1)) {
|
|
968
|
+
lines.push("--");
|
|
969
|
+
}
|
|
580
970
|
if (opts.filesOnly || opts.files) {
|
|
581
971
|
lines.push(m.file);
|
|
582
972
|
} else if (opts.count) {
|
|
583
973
|
lines.push(`${m.file}:${m.line}`);
|
|
974
|
+
} else if (m.kind === "context") {
|
|
975
|
+
lines.push(`${m.file}-${m.line}-${m.text}`);
|
|
584
976
|
} else {
|
|
585
977
|
lines.push(`${m.file}:${m.line}:${m.text}`);
|
|
586
978
|
}
|
|
979
|
+
prev = m;
|
|
587
980
|
}
|
|
588
981
|
if (repo.truncated) {
|
|
589
|
-
lines.push(" (truncated;
|
|
982
|
+
lines.push(" (truncated; raise --limit)");
|
|
590
983
|
}
|
|
591
984
|
}
|
|
592
985
|
if (r.total_matches === 0) {
|
|
@@ -595,7 +988,7 @@ function renderResult(r: GrepResult, opts: GrepOpts): string {
|
|
|
595
988
|
} else if (r.repos.length > 1 || r.truncated) {
|
|
596
989
|
lines.push("");
|
|
597
990
|
const summary = `${r.total_matches} match${r.total_matches === 1 ? "" : "es"} across ${r.total_files} file${r.total_files === 1 ? "" : "s"}`;
|
|
598
|
-
const tail = r.truncated ? " (truncated;
|
|
991
|
+
const tail = r.truncated ? " (truncated; raise --limit)" : "";
|
|
599
992
|
lines.push(`${summary}${tail} (${r.elapsed_ms}ms, ${r.engine})`);
|
|
600
993
|
}
|
|
601
994
|
return lines.join("\n");
|