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/dist/commands/grep.js
CHANGED
|
@@ -1,26 +1,37 @@
|
|
|
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 { resolveSearchEngine } from "../lib/tools/ripgrep.js";
|
|
3
5
|
/**
|
|
4
6
|
* `grep`: monorepo-aware code search. Prefers ripgrep (`rg`) when it is on
|
|
5
|
-
* PATH and falls back to GNU
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* PATH and falls back to GNU/BSD grep transparently — both engines are driven
|
|
8
|
+
* with equivalent flags and their output is parsed into the same envelope, so
|
|
9
|
+
* results are identical (pinned by tests/unit/grep-engine.test.ts).
|
|
8
10
|
* Smart default excludes (skip dist/.next/node_modules/.git/...), repo
|
|
9
|
-
* scoping (`--repo <name>` or `--all-repos`),
|
|
11
|
+
* scoping (`--repo <name>` or `--all-repos`), language presets, file-level
|
|
12
|
+
* boolean composition (`--and` / `--without`), and context (`-C`/`-A`/`-B`).
|
|
10
13
|
*
|
|
11
14
|
* Engine selection: `HARNERY_GREP_ENGINE=rg|grep` forces one; otherwise `rg`
|
|
12
|
-
* is resolved (managed install, PATH, or opt-in auto-provision; see
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* is resolved (managed install, PATH, or opt-in auto-provision; see
|
|
16
|
+
* resolveEngine) and used when available. Repos are searched in parallel, and
|
|
17
|
+
* in `--all-repos` mode the parent scan prunes submodule directories so each
|
|
18
|
+
* match is attributed to exactly one repo.
|
|
19
|
+
*
|
|
20
|
+
* Output framing: content searches request the NUL filename delimiter
|
|
21
|
+
* (`--null` on both engines — the long spelling, because BSD grep repurposes
|
|
22
|
+
* `-Z` for decompression) so filename boundaries are never inferred from
|
|
23
|
+
* punctuation. Context lines are NOT requested from the engines: matches are
|
|
24
|
+
* selected (and budgeted) first, then context windows are materialized from
|
|
25
|
+
* one file read per selected file, which keeps `--limit` semantics exact and
|
|
26
|
+
* both engines byte-identical.
|
|
17
27
|
*
|
|
18
28
|
* Default behavior matches extended-regex semantics (`grep -E` / ripgrep's
|
|
19
29
|
* default). Use `-F` / `--literal` to pin to literal-string mode. Output is
|
|
20
|
-
* line-oriented `file:line:content` in TTY mode
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
30
|
+
* line-oriented `file:line:content` in TTY mode (context rows render
|
|
31
|
+
* grep-style as `file-line-content`); `--json` emits the full GrepResult
|
|
32
|
+
* envelope `{ pattern, mode, engine, and_patterns, without_patterns, repos,
|
|
33
|
+
* total_matches, total_files, truncated, elapsed_ms }`. Matches are sorted
|
|
34
|
+
* (file, then line) for stable output across runs and engines.
|
|
24
35
|
*/
|
|
25
36
|
const DEFAULT_EXCLUDE_DIRS = [
|
|
26
37
|
".git",
|
|
@@ -63,10 +74,11 @@ export function registerGrepCommand(program, emit, context) {
|
|
|
63
74
|
.command("grep <pattern> [paths...]")
|
|
64
75
|
.description("Monorepo-aware code search (ripgrep when available, GNU grep fallback). " +
|
|
65
76
|
"Skips dist/.next/node_modules/.git/... by default. " +
|
|
66
|
-
"Use --repo, --all-repos, --lang for scoping
|
|
77
|
+
"Use --repo, --all-repos, --lang for scoping; --and/--without for file-level composition. " +
|
|
78
|
+
"Regex by default; -F for literal.")
|
|
67
79
|
.option("--repo <name>", "Scope to one submodule (`.` = parent repo root)")
|
|
68
80
|
.option("--all-repos", "Search parent + every submodule")
|
|
69
|
-
.option("--lang <lang>", `File type preset (${Object.keys(LANG_GLOBS).join(", ")})
|
|
81
|
+
.option("--lang <lang>", `File type preset, repeatable or comma-separated (${Object.keys(LANG_GLOBS).join(", ")})`, collect, [])
|
|
70
82
|
.option("-i, --ignore-case", "Case-insensitive match")
|
|
71
83
|
.option("-w, --whole-word", "Match whole words only")
|
|
72
84
|
.option("-F, --literal", "Treat <pattern> as a literal string (no regex)")
|
|
@@ -75,8 +87,13 @@ export function registerGrepCommand(program, emit, context) {
|
|
|
75
87
|
"(rg --files when available, POSIX find fallback)")
|
|
76
88
|
.option("-c, --count", "Print match count per file (suppresses content)")
|
|
77
89
|
.option("-C, --context <n>", "Print N lines of context around each match", "0")
|
|
90
|
+
.option("-A, --after-context <n>", "Print N lines after each match (overrides -C's after side)")
|
|
91
|
+
.option("-B, --before-context <n>", "Print N lines before each match (overrides -C's before side)")
|
|
78
92
|
.option("--max-count <n>", "Stop after N matches per file")
|
|
79
93
|
.option("--limit <n>", "Truncate output to N matches total")
|
|
94
|
+
.option("--and <pattern>", "Only show files that ALSO contain this pattern (file-level, repeatable)", collect, [])
|
|
95
|
+
.option("--without <pattern>", "Drop files that contain this pattern (file-level, repeatable)", collect, [])
|
|
96
|
+
.option("-q, --quiet", "No output; exit 0 if any match exists, 1 if none")
|
|
80
97
|
.option("--include <glob>", "Extra --include glob (repeatable)", collect, [])
|
|
81
98
|
.option("--exclude <glob>", "Extra --exclude glob (repeatable)", collect, [])
|
|
82
99
|
.option("--no-default-excludes", "Disable the default skip list (node_modules, dist, etc.)")
|
|
@@ -84,6 +101,12 @@ export function registerGrepCommand(program, emit, context) {
|
|
|
84
101
|
.action(async (pattern, paths, opts) => {
|
|
85
102
|
try {
|
|
86
103
|
const result = await runGrep(pattern, paths, opts, context);
|
|
104
|
+
if (opts.quiet) {
|
|
105
|
+
// No output by contract; grep-conventional status. exitCode (not
|
|
106
|
+
// process.exit) so an embedding host isn't terminated mid-flush.
|
|
107
|
+
process.exitCode = result.total_matches > 0 ? 0 : 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
87
110
|
if (opts.json) {
|
|
88
111
|
emit.config({ format: "json" });
|
|
89
112
|
emit.data(result);
|
|
@@ -100,39 +123,128 @@ export function registerGrepCommand(program, emit, context) {
|
|
|
100
123
|
function collect(value, prev) {
|
|
101
124
|
return [...prev, value];
|
|
102
125
|
}
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Parse a strictly-decimal integer option. Rejects empty, negative, signed,
|
|
128
|
+
* fractional, and trailing-junk values before any engine is spawned.
|
|
129
|
+
*/
|
|
130
|
+
function parseIntOpt(flag, raw, min) {
|
|
131
|
+
if (!/^\d+$/.test(raw.trim())) {
|
|
132
|
+
throw new Error(`${flag} expects a non-negative integer, got "${raw}"`);
|
|
133
|
+
}
|
|
134
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
135
|
+
if (n < min)
|
|
136
|
+
throw new Error(`${flag} must be >= ${min}, got ${n}`);
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
function normalizeLangs(lang) {
|
|
140
|
+
const rawValues = typeof lang === "string" ? [lang] : (lang ?? []);
|
|
141
|
+
const keys = [];
|
|
142
|
+
for (const raw of rawValues) {
|
|
143
|
+
for (const piece of raw.split(",")) {
|
|
144
|
+
const key = piece.trim();
|
|
145
|
+
if (key === "")
|
|
146
|
+
continue;
|
|
147
|
+
if (!LANG_GLOBS[key]) {
|
|
148
|
+
throw new Error(`unknown --lang "${key}". Valid: ${Object.keys(LANG_GLOBS).join(", ")}`);
|
|
149
|
+
}
|
|
150
|
+
if (!keys.includes(key))
|
|
151
|
+
keys.push(key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (keys.length === 0)
|
|
155
|
+
return undefined;
|
|
156
|
+
const globs = [];
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
const langGlobs = LANG_GLOBS[key];
|
|
159
|
+
if (!langGlobs)
|
|
160
|
+
continue;
|
|
161
|
+
for (const g of langGlobs)
|
|
162
|
+
if (!globs.includes(g))
|
|
163
|
+
globs.push(g);
|
|
164
|
+
}
|
|
165
|
+
return globs;
|
|
166
|
+
}
|
|
167
|
+
/** Validate numerics, resolve context sides, expand langs, enforce the compatibility matrix. */
|
|
168
|
+
function normalizeOpts(opts) {
|
|
169
|
+
const limit = opts.limit ? parseIntOpt("--limit", opts.limit, 1) : Number.POSITIVE_INFINITY;
|
|
170
|
+
const maxCount = opts.maxCount ? parseIntOpt("--max-count", opts.maxCount, 1) : undefined;
|
|
171
|
+
const c = opts.context !== undefined ? parseIntOpt("-C/--context", opts.context, 0) : 0;
|
|
172
|
+
const before = opts.beforeContext !== undefined
|
|
173
|
+
? parseIntOpt("-B/--before-context", opts.beforeContext, 0)
|
|
174
|
+
: c;
|
|
175
|
+
const after = opts.afterContext !== undefined ? parseIntOpt("-A/--after-context", opts.afterContext, 0) : c;
|
|
176
|
+
const andPatterns = opts.and ?? [];
|
|
177
|
+
const withoutPatterns = opts.without ?? [];
|
|
178
|
+
const contextActive = before > 0 || after > 0;
|
|
107
179
|
if (opts.files) {
|
|
108
180
|
// Filename mode lists files by name glob; content-search flags make no
|
|
109
181
|
// sense here — reject loudly rather than silently ignoring them.
|
|
110
182
|
const incompatible = [
|
|
111
|
-
[opts.lang, "--lang"],
|
|
183
|
+
[normalizeLangs(opts.lang)?.length, "--lang"],
|
|
112
184
|
[opts.count, "-c/--count"],
|
|
113
185
|
[opts.wholeWord, "-w/--whole-word"],
|
|
114
186
|
[opts.literal, "-F/--literal"],
|
|
115
187
|
[opts.maxCount, "--max-count"],
|
|
116
188
|
[opts.include?.length, "--include"],
|
|
117
|
-
[
|
|
189
|
+
[contextActive ? true : undefined, "-C/-A/-B context"],
|
|
190
|
+
[andPatterns.length ? true : undefined, "--and"],
|
|
191
|
+
[withoutPatterns.length ? true : undefined, "--without"],
|
|
118
192
|
];
|
|
119
193
|
for (const [set, flag] of incompatible) {
|
|
120
194
|
if (set)
|
|
121
195
|
throw new Error(`${flag} does not apply to --files (filename glob) mode`);
|
|
122
196
|
}
|
|
123
197
|
}
|
|
198
|
+
if (contextActive) {
|
|
199
|
+
const rejected = [
|
|
200
|
+
[opts.filesOnly, "-l/--files-only"],
|
|
201
|
+
[opts.count, "-c/--count"],
|
|
202
|
+
[opts.quiet, "-q/--quiet"],
|
|
203
|
+
];
|
|
204
|
+
for (const [set, flag] of rejected) {
|
|
205
|
+
if (set)
|
|
206
|
+
throw new Error(`-C/-A/-B context does not combine with ${flag}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (opts.quiet) {
|
|
210
|
+
const rejected = [
|
|
211
|
+
[opts.json, "--json"],
|
|
212
|
+
[opts.filesOnly, "-l/--files-only"],
|
|
213
|
+
[opts.count, "-c/--count"],
|
|
214
|
+
[opts.limit, "--limit"],
|
|
215
|
+
];
|
|
216
|
+
for (const [set, flag] of rejected) {
|
|
217
|
+
if (set)
|
|
218
|
+
throw new Error(`-q/--quiet does not combine with ${flag}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
// -q only needs existence: one accepted primary row settles the exit code.
|
|
223
|
+
limit: opts.quiet ? 1 : limit,
|
|
224
|
+
maxCount,
|
|
225
|
+
before,
|
|
226
|
+
after,
|
|
227
|
+
langGlobs: normalizeLangs(opts.lang),
|
|
228
|
+
andPatterns,
|
|
229
|
+
withoutPatterns,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** Exported for tests (not part of the package exports map). */
|
|
233
|
+
export async function runGrep(pattern, paths, opts, context) {
|
|
234
|
+
if (!pattern)
|
|
235
|
+
throw new Error("pattern required");
|
|
236
|
+
const norm = normalizeOpts(opts);
|
|
124
237
|
const started = Date.now();
|
|
125
238
|
const { engine, rgBin } = await resolveSearchEngine("grep");
|
|
126
239
|
const repos = resolveRepos(opts, context);
|
|
127
|
-
const limit = opts.limit ? Number.parseInt(opts.limit, 10) : Number.POSITIVE_INFINITY;
|
|
128
|
-
const contextN = Number.parseInt(opts.context ?? "0", 10);
|
|
129
|
-
const sortable = !(Number.isFinite(contextN) && contextN > 0);
|
|
130
240
|
// Host-injected default excludes (generated mirrors, vendored trees, ...)
|
|
131
241
|
// ride the same --no-default-excludes gate as the built-in list.
|
|
132
242
|
const hostExcludeDirs = opts.noDefaultExcludes ? [] : (context?.grepExcludeDirs ?? []);
|
|
133
|
-
// All repos are searched concurrently; each
|
|
134
|
-
//
|
|
135
|
-
//
|
|
243
|
+
// All repos are searched concurrently; each collects at most limit+1
|
|
244
|
+
// accepted rows (one row of lookahead, so "exactly N results" is
|
|
245
|
+
// distinguishable from "more than N"), then the global budget is applied
|
|
246
|
+
// in repo order below so `--limit` semantics stay deterministic.
|
|
247
|
+
const acceptLimit = Number.isFinite(norm.limit) ? norm.limit + 1 : Number.POSITIVE_INFINITY;
|
|
136
248
|
const perRepo = await Promise.all(repos.map((repo) => {
|
|
137
249
|
// In --all-repos mode the parent scan prunes submodule dirs — each
|
|
138
250
|
// submodule gets its own scoped scan, so descending from the parent
|
|
@@ -141,27 +253,26 @@ export async function runGrep(pattern, paths, opts, context) {
|
|
|
141
253
|
// even when --no-default-excludes is set.
|
|
142
254
|
const dedupeDirs = opts.allRepos && repo.name === "parent" ? (context?.submodules ?? []) : [];
|
|
143
255
|
const extraDirs = [...hostExcludeDirs, ...dedupeDirs];
|
|
144
|
-
return runGrepInRepo(pattern, paths, opts, repo.cwd, repo.name,
|
|
256
|
+
return runGrepInRepo(pattern, paths, opts, norm, repo.cwd, repo.name, acceptLimit, engine, rgBin, extraDirs);
|
|
145
257
|
}));
|
|
146
258
|
const allRepoResults = [];
|
|
147
259
|
let totalMatches = 0;
|
|
148
260
|
const filesSeen = new Set();
|
|
149
261
|
let truncated = false;
|
|
150
|
-
let budget = limit;
|
|
262
|
+
let budget = norm.limit;
|
|
151
263
|
for (let i = 0; i < repos.length; i++) {
|
|
152
264
|
const repo = repos[i];
|
|
153
265
|
if (!repo)
|
|
154
266
|
continue;
|
|
155
267
|
const collected = perRepo[i] ?? [];
|
|
156
|
-
|
|
157
|
-
collected.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
158
|
-
}
|
|
159
|
-
const engineCapped = Number.isFinite(limit) && collected.length >= limit;
|
|
268
|
+
collected.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
160
269
|
const take = Number.isFinite(budget)
|
|
161
270
|
? Math.min(collected.length, Math.max(0, budget))
|
|
162
271
|
: collected.length;
|
|
163
|
-
|
|
164
|
-
|
|
272
|
+
let matches = collected.slice(0, take);
|
|
273
|
+
// truncated only when an accepted primary row was actually omitted —
|
|
274
|
+
// the lookahead row (or a later-repo surplus) is the proof.
|
|
275
|
+
const repoTruncated = collected.length > take;
|
|
165
276
|
if (repoTruncated)
|
|
166
277
|
truncated = true;
|
|
167
278
|
if (Number.isFinite(budget))
|
|
@@ -169,12 +280,20 @@ export async function runGrep(pattern, paths, opts, context) {
|
|
|
169
280
|
totalMatches += matches.length;
|
|
170
281
|
for (const m of matches)
|
|
171
282
|
filesSeen.add(`${repo.name}/${m.file}`);
|
|
283
|
+
// Context is materialized only for the SELECTED rows, after budgeting,
|
|
284
|
+
// so context rows never consume the limit and trailing context is never
|
|
285
|
+
// cut off by an engine kill.
|
|
286
|
+
if (!opts.files && !opts.filesOnly && !opts.count && (norm.before > 0 || norm.after > 0)) {
|
|
287
|
+
matches = await materializeContext(matches, repo.cwd, norm.before, norm.after);
|
|
288
|
+
}
|
|
172
289
|
allRepoResults.push({ name: repo.name, cwd: repo.cwd, matches, truncated: repoTruncated });
|
|
173
290
|
}
|
|
174
291
|
return {
|
|
175
292
|
pattern,
|
|
176
293
|
mode: opts.files ? "files" : opts.literal ? "literal" : "regex",
|
|
177
294
|
engine,
|
|
295
|
+
and_patterns: norm.andPatterns,
|
|
296
|
+
without_patterns: norm.withoutPatterns,
|
|
178
297
|
repos: allRepoResults,
|
|
179
298
|
total_matches: totalMatches,
|
|
180
299
|
total_files: filesSeen.size,
|
|
@@ -212,32 +331,71 @@ function resolveRepos(opts, context) {
|
|
|
212
331
|
}
|
|
213
332
|
return [{ name: "cwd", cwd: process.cwd() }];
|
|
214
333
|
}
|
|
215
|
-
async function runGrepInRepo(pattern, paths, opts, cwd, repoName,
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
334
|
+
async function runGrepInRepo(pattern, paths, opts, norm, cwd, repoName, acceptLimit, engine, rgBin, extraExcludeDirs) {
|
|
335
|
+
// File-level boolean composition: build the complete auxiliary file sets
|
|
336
|
+
// FIRST (no limit — a partial set can't prove absence), then feed the
|
|
337
|
+
// primary scan an in-stream predicate. Running the primary with its limit
|
|
338
|
+
// and filtering afterwards would let non-qualifying early hits consume the
|
|
339
|
+
// budget and hide later valid results.
|
|
340
|
+
let predicate;
|
|
341
|
+
if (norm.andPatterns.length > 0 || norm.withoutPatterns.length > 0) {
|
|
342
|
+
const requiredSets = [];
|
|
343
|
+
// Sequential, not parallel: repeated flags must not multiply peak child
|
|
344
|
+
// process concurrency by repos x patterns. Repo workers stay parallel.
|
|
345
|
+
for (const p of norm.andPatterns) {
|
|
346
|
+
const files = await runFileSetScan(p, paths, opts, norm, cwd, engine, rgBin, extraExcludeDirs);
|
|
347
|
+
if (files.size === 0)
|
|
348
|
+
return []; // intersection is already empty
|
|
349
|
+
requiredSets.push(files);
|
|
350
|
+
}
|
|
351
|
+
const forbidden = new Set();
|
|
352
|
+
for (const p of norm.withoutPatterns) {
|
|
353
|
+
const files = await runFileSetScan(p, paths, opts, norm, cwd, engine, rgBin, extraExcludeDirs);
|
|
354
|
+
for (const f of files)
|
|
355
|
+
forbidden.add(f);
|
|
356
|
+
}
|
|
357
|
+
predicate = (file) => requiredSets.every((s) => s.has(file)) && !forbidden.has(file);
|
|
358
|
+
}
|
|
359
|
+
const [bin, args, decodeMode] = buildEngineInvocation(pattern, paths, opts, norm, engine, rgBin, extraExcludeDirs, false);
|
|
360
|
+
let matches = await execSearch(bin, args, cwd, acceptLimit, decodeMode, predicate, false);
|
|
226
361
|
// -c mode: GNU grep prints a `path:0` row for every searched file; ripgrep
|
|
227
362
|
// omits zero-count rows. Filter zeros on both engines so output matches.
|
|
228
363
|
if (opts.count)
|
|
229
364
|
matches = matches.filter((m) => m.line > 0);
|
|
230
365
|
return matches.map((m) => ({ ...m, repo: repoName }));
|
|
231
366
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
367
|
+
/** Membership scan for one --and/--without pattern: complete files-only set, strict failure. */
|
|
368
|
+
async function runFileSetScan(pattern, paths, opts, norm, cwd, engine, rgBin, extraExcludeDirs) {
|
|
369
|
+
const [bin, args, decodeMode] = buildEngineInvocation(pattern, paths, opts, norm, engine, rgBin, extraExcludeDirs, true);
|
|
370
|
+
const rows = await execSearch(bin, args, cwd, Number.POSITIVE_INFINITY, decodeMode, undefined, true);
|
|
371
|
+
return new Set(rows.map((r) => r.file));
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Build one engine invocation. `membership: true` forces files-only mode for
|
|
375
|
+
* --and/--without set scans (match-shaping + scope flags shared with the
|
|
376
|
+
* primary; output flags not applied).
|
|
377
|
+
*/
|
|
378
|
+
function buildEngineInvocation(pattern, paths, opts, norm, engine, rgBin, extraExcludeDirs, membership) {
|
|
379
|
+
if (opts.files) {
|
|
380
|
+
// Filename mode keeps its existing rg/find framing (plain lines).
|
|
381
|
+
return engine === "rg"
|
|
382
|
+
? [rgBin, buildRgFilesArgs(pattern, paths, opts, extraExcludeDirs), "plainLines"]
|
|
383
|
+
: ["find", buildFindArgs(pattern, paths, opts, extraExcludeDirs), "plainLines"];
|
|
236
384
|
}
|
|
237
|
-
|
|
385
|
+
const filesOnly = membership || Boolean(opts.filesOnly);
|
|
386
|
+
const count = !membership && Boolean(opts.count);
|
|
387
|
+
const decodeMode = filesOnly ? "filesOnly" : count ? "count" : "content";
|
|
388
|
+
const args = engine === "rg"
|
|
389
|
+
? buildRgArgs(pattern, paths, opts, norm, extraExcludeDirs, { filesOnly, count })
|
|
390
|
+
: buildGrepArgs(pattern, paths, opts, norm, extraExcludeDirs, { filesOnly, count });
|
|
391
|
+
return [engine === "rg" ? rgBin : "grep", args, decodeMode];
|
|
238
392
|
}
|
|
239
|
-
function buildGrepArgs(pattern, paths, opts, extraExcludeDirs) {
|
|
240
|
-
|
|
393
|
+
function buildGrepArgs(pattern, paths, opts, norm, extraExcludeDirs, shape) {
|
|
394
|
+
// --null (NOT -Z: BSD grep repurposes -Z for decompression): NUL after the
|
|
395
|
+
// filename, so paths containing colons/dashes can't confuse the decoder.
|
|
396
|
+
// -I: skip binary files. Context is never requested from the engine — see
|
|
397
|
+
// materializeContext.
|
|
398
|
+
const args = ["-rn", "-H", "--null", "--color=never", "-I"];
|
|
241
399
|
if (opts.ignoreCase)
|
|
242
400
|
args.push("-i");
|
|
243
401
|
if (opts.wholeWord)
|
|
@@ -246,18 +404,12 @@ function buildGrepArgs(pattern, paths, opts, extraExcludeDirs) {
|
|
|
246
404
|
args.push("-F");
|
|
247
405
|
else
|
|
248
406
|
args.push("-E");
|
|
249
|
-
if (
|
|
407
|
+
if (shape.filesOnly)
|
|
250
408
|
args.push("-l");
|
|
251
|
-
if (
|
|
409
|
+
if (shape.count)
|
|
252
410
|
args.push("-c");
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
args.push(`-C${contextN}`);
|
|
256
|
-
if (opts.maxCount) {
|
|
257
|
-
const n = Number.parseInt(opts.maxCount, 10);
|
|
258
|
-
if (Number.isFinite(n) && n > 0)
|
|
259
|
-
args.push(`-m${n}`);
|
|
260
|
-
}
|
|
411
|
+
if (norm.maxCount !== undefined)
|
|
412
|
+
args.push(`-m${norm.maxCount}`);
|
|
261
413
|
if (!opts.noDefaultExcludes) {
|
|
262
414
|
for (const d of DEFAULT_EXCLUDE_DIRS)
|
|
263
415
|
args.push(`--exclude-dir=${d}`);
|
|
@@ -268,9 +420,8 @@ function buildGrepArgs(pattern, paths, opts, extraExcludeDirs) {
|
|
|
268
420
|
args.push(`--exclude-dir=${d}`);
|
|
269
421
|
for (const e of opts.exclude ?? [])
|
|
270
422
|
args.push(`--exclude=${e}`);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
for (const g of langGlobs)
|
|
423
|
+
if (norm.langGlobs)
|
|
424
|
+
for (const g of norm.langGlobs)
|
|
274
425
|
args.push(`--include=${g}`);
|
|
275
426
|
for (const g of opts.include ?? [])
|
|
276
427
|
args.push(`--include=${g}`);
|
|
@@ -281,16 +432,18 @@ function buildGrepArgs(pattern, paths, opts, extraExcludeDirs) {
|
|
|
281
432
|
args.push(".");
|
|
282
433
|
return args;
|
|
283
434
|
}
|
|
284
|
-
function buildRgArgs(pattern, paths, opts, extraExcludeDirs) {
|
|
435
|
+
function buildRgArgs(pattern, paths, opts, norm, extraExcludeDirs, shape) {
|
|
285
436
|
// --hidden --no-ignore: match GNU grep's semantics (search dotdirs, ignore
|
|
286
437
|
// .gitignore) so the only filters are the explicit exclude lists.
|
|
287
438
|
// --no-config: a user's ripgrep config file must not skew results.
|
|
288
439
|
// --with-filename: rg drops the file prefix for a single explicit file arg,
|
|
289
|
-
// which would break the shared
|
|
440
|
+
// which would break the shared decoder.
|
|
441
|
+
// --null: NUL after the filename (same spelling as GNU/BSD grep's long flag).
|
|
290
442
|
const args = [
|
|
291
443
|
"-n",
|
|
292
444
|
"--no-heading",
|
|
293
445
|
"--with-filename",
|
|
446
|
+
"--null",
|
|
294
447
|
"--color=never",
|
|
295
448
|
"--no-config",
|
|
296
449
|
"--hidden",
|
|
@@ -302,27 +455,20 @@ function buildRgArgs(pattern, paths, opts, extraExcludeDirs) {
|
|
|
302
455
|
args.push("-w");
|
|
303
456
|
if (opts.literal)
|
|
304
457
|
args.push("-F");
|
|
305
|
-
if (
|
|
458
|
+
if (shape.filesOnly)
|
|
306
459
|
args.push("-l");
|
|
307
|
-
if (
|
|
460
|
+
if (shape.count)
|
|
308
461
|
args.push("-c");
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
args.push(`-C${contextN}`);
|
|
312
|
-
if (opts.maxCount) {
|
|
313
|
-
const n = Number.parseInt(opts.maxCount, 10);
|
|
314
|
-
if (Number.isFinite(n) && n > 0)
|
|
315
|
-
args.push(`-m${n}`);
|
|
316
|
-
}
|
|
462
|
+
if (norm.maxCount !== undefined)
|
|
463
|
+
args.push(`-m${norm.maxCount}`);
|
|
317
464
|
// Gitignore-style globs: a bare name matches (and prunes) at any depth,
|
|
318
465
|
// mirroring grep's --exclude-dir / --exclude basename semantics. ORDER
|
|
319
466
|
// MATTERS: rg globs are last-match-wins, so positives (includes/lang) go
|
|
320
467
|
// first and negatives (excludes) last — otherwise `--include '*.md'` would
|
|
321
468
|
// re-include a .md file inside an excluded node_modules/. grep's
|
|
322
469
|
// --exclude-dir always beats --include, so this keeps the engines aligned.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
for (const g of langGlobs)
|
|
470
|
+
if (norm.langGlobs)
|
|
471
|
+
for (const g of norm.langGlobs)
|
|
326
472
|
args.push(`--glob=${g}`);
|
|
327
473
|
for (const g of opts.include ?? [])
|
|
328
474
|
args.push(`--glob=${g}`);
|
|
@@ -395,33 +541,119 @@ function buildFindArgs(pattern, paths, opts, extraExcludeDirs) {
|
|
|
395
541
|
args.push("-print");
|
|
396
542
|
return args;
|
|
397
543
|
}
|
|
398
|
-
|
|
544
|
+
/**
|
|
545
|
+
* Streaming decoder for NUL-framed engine output. Exported for direct
|
|
546
|
+
* chunk-boundary tests. Record shapes (both engines, pinned by the parity
|
|
547
|
+
* suite):
|
|
548
|
+
* content: <path>NUL<line>:<text>LF
|
|
549
|
+
* count: <path>NUL<count>LF
|
|
550
|
+
* filesOnly: <path>NUL (NUL is the record terminator)
|
|
551
|
+
* plainLines: <path>LF (files mode: rg --files / find)
|
|
552
|
+
*
|
|
553
|
+
* Operates on Buffers so a chunk boundary can fall anywhere — including
|
|
554
|
+
* inside a multi-byte UTF-8 sequence — without corrupting a record: bytes
|
|
555
|
+
* are only decoded to strings once a full record is framed.
|
|
556
|
+
*/
|
|
557
|
+
export class NulDecoder {
|
|
558
|
+
mode;
|
|
559
|
+
buffer = Buffer.alloc(0);
|
|
560
|
+
constructor(mode) {
|
|
561
|
+
this.mode = mode;
|
|
562
|
+
}
|
|
563
|
+
push(chunk) {
|
|
564
|
+
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
565
|
+
const rows = [];
|
|
566
|
+
if (this.mode === "filesOnly") {
|
|
567
|
+
let nul = this.buffer.indexOf(0);
|
|
568
|
+
while (nul >= 0) {
|
|
569
|
+
const path = this.buffer.subarray(0, nul).toString("utf8");
|
|
570
|
+
this.buffer = this.buffer.subarray(nul + 1);
|
|
571
|
+
// Engines may still newline-separate NUL-terminated records in edge
|
|
572
|
+
// configurations; a bare leading LF is framing residue, not a path.
|
|
573
|
+
const cleaned = path.startsWith("\n") ? path.slice(1) : path;
|
|
574
|
+
if (cleaned.length > 0)
|
|
575
|
+
rows.push({ file: normalizeFile(cleaned), line: 0, text: "" });
|
|
576
|
+
nul = this.buffer.indexOf(0);
|
|
577
|
+
}
|
|
578
|
+
return rows;
|
|
579
|
+
}
|
|
580
|
+
let nl = this.buffer.indexOf(0x0a);
|
|
581
|
+
while (nl >= 0) {
|
|
582
|
+
const record = this.buffer.subarray(0, nl);
|
|
583
|
+
this.buffer = this.buffer.subarray(nl + 1);
|
|
584
|
+
const row = this.decodeRecord(record);
|
|
585
|
+
if (row)
|
|
586
|
+
rows.push(row);
|
|
587
|
+
nl = this.buffer.indexOf(0x0a);
|
|
588
|
+
}
|
|
589
|
+
return rows;
|
|
590
|
+
}
|
|
591
|
+
/** Flush a trailing unterminated record (engine killed mid-write, or no final LF). */
|
|
592
|
+
flush() {
|
|
593
|
+
if (this.buffer.length === 0)
|
|
594
|
+
return [];
|
|
595
|
+
const record = this.buffer;
|
|
596
|
+
this.buffer = Buffer.alloc(0);
|
|
597
|
+
if (this.mode === "filesOnly") {
|
|
598
|
+
// A partial filesOnly record has no terminating NUL — an engine killed
|
|
599
|
+
// mid-path would yield a corrupt name; drop it.
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
const row = this.decodeRecord(record);
|
|
603
|
+
return row ? [row] : [];
|
|
604
|
+
}
|
|
605
|
+
decodeRecord(record) {
|
|
606
|
+
if (this.mode === "plainLines") {
|
|
607
|
+
const path = record.toString("utf8");
|
|
608
|
+
if (path.length === 0)
|
|
609
|
+
return null;
|
|
610
|
+
return { file: normalizeFile(path), line: 0, text: "" };
|
|
611
|
+
}
|
|
612
|
+
const nul = record.indexOf(0);
|
|
613
|
+
if (nul < 0)
|
|
614
|
+
return null; // not a NUL-framed record (stray engine chatter)
|
|
615
|
+
const file = normalizeFile(record.subarray(0, nul).toString("utf8"));
|
|
616
|
+
const rest = record.subarray(nul + 1).toString("utf8");
|
|
617
|
+
if (this.mode === "count") {
|
|
618
|
+
const count = Number.parseInt(rest, 10);
|
|
619
|
+
if (!Number.isFinite(count))
|
|
620
|
+
return null;
|
|
621
|
+
return { file, line: count, text: "" };
|
|
622
|
+
}
|
|
623
|
+
// content: <line>:<text>. The engines never emit context/separator rows
|
|
624
|
+
// (context is materialized from file reads), so `:` is always present.
|
|
625
|
+
const colon = rest.indexOf(":");
|
|
626
|
+
if (colon < 0)
|
|
627
|
+
return null;
|
|
628
|
+
const lineNum = Number.parseInt(rest.slice(0, colon), 10);
|
|
629
|
+
if (!Number.isFinite(lineNum))
|
|
630
|
+
return null;
|
|
631
|
+
return { file, line: lineNum, text: rest.slice(colon + 1) };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function execSearch(bin, args, cwd, acceptLimit, decodeMode, filePredicate, strict) {
|
|
399
635
|
return new Promise((resolveP, reject) => {
|
|
400
636
|
const proc = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
637
|
+
const decoder = new NulDecoder(decodeMode);
|
|
401
638
|
const matches = [];
|
|
402
|
-
let buffer = "";
|
|
403
639
|
let stderr = "";
|
|
404
|
-
let
|
|
405
|
-
|
|
640
|
+
let killed = false;
|
|
641
|
+
const accept = (rows) => {
|
|
642
|
+
for (const row of rows) {
|
|
643
|
+
if (filePredicate && !filePredicate(row.file))
|
|
644
|
+
continue;
|
|
645
|
+
matches.push({ ...row, repo: "", kind: "match" });
|
|
646
|
+
if (Number.isFinite(acceptLimit) && matches.length >= acceptLimit)
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
return false;
|
|
650
|
+
};
|
|
406
651
|
proc.stdout.on("data", (chunk) => {
|
|
407
|
-
if (
|
|
652
|
+
if (killed)
|
|
408
653
|
return;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const line = buffer.slice(0, nl);
|
|
413
|
-
buffer = buffer.slice(nl + 1);
|
|
414
|
-
if (line.length !== 0) {
|
|
415
|
-
const parsed = parseGrepLine(line);
|
|
416
|
-
if (parsed)
|
|
417
|
-
matches.push(parsed);
|
|
418
|
-
if (Number.isFinite(limit) && matches.length >= limit) {
|
|
419
|
-
truncated = true;
|
|
420
|
-
proc.kill("SIGTERM");
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
nl = buffer.indexOf("\n");
|
|
654
|
+
if (accept(decoder.push(chunk))) {
|
|
655
|
+
killed = true;
|
|
656
|
+
proc.kill("SIGTERM");
|
|
425
657
|
}
|
|
426
658
|
});
|
|
427
659
|
proc.stderr.setEncoding("utf8");
|
|
@@ -430,15 +662,15 @@ function execSearch(bin, args, cwd, limit) {
|
|
|
430
662
|
});
|
|
431
663
|
proc.on("error", reject);
|
|
432
664
|
proc.on("close", (code) => {
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
if (
|
|
665
|
+
if (!killed)
|
|
666
|
+
accept(decoder.flush());
|
|
667
|
+
// Exit 1 is "no matches" on both engines: normal, not an error.
|
|
668
|
+
// Primary scans tolerate exit 2 with matches collected (an unreadable
|
|
669
|
+
// file mid-walk): surface the results rather than throwing them away.
|
|
670
|
+
// STRICT scans (--and/--without membership) must reject on ANY partial
|
|
671
|
+
// failure — an incomplete set cannot prove that a file lacks a pattern.
|
|
672
|
+
const failed = code !== null && code !== 0 && code !== 1 && !killed;
|
|
673
|
+
if (failed && (strict || matches.length === 0)) {
|
|
442
674
|
reject(new Error(`${bin} exited ${code}: ${stderr.trim() || "(no stderr)"}`));
|
|
443
675
|
return;
|
|
444
676
|
}
|
|
@@ -446,34 +678,79 @@ function execSearch(bin, args, cwd, limit) {
|
|
|
446
678
|
});
|
|
447
679
|
});
|
|
448
680
|
}
|
|
449
|
-
/**
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
681
|
+
/**
|
|
682
|
+
* Materialize -C/-A/-B context for already-selected match rows: one file
|
|
683
|
+
* read per selected file, intervals clamped + merged, match rows keep the
|
|
684
|
+
* engine-captured text, other window lines become kind:"context" rows.
|
|
685
|
+
* An unreadable file (deleted since the scan — the accepted TOCTOU window)
|
|
686
|
+
* degrades to its match rows without context rather than failing the search.
|
|
687
|
+
*/
|
|
688
|
+
async function materializeContext(selected, cwd, before, after) {
|
|
689
|
+
const byFile = new Map();
|
|
690
|
+
for (const m of selected) {
|
|
691
|
+
const rows = byFile.get(m.file);
|
|
692
|
+
if (rows)
|
|
693
|
+
rows.push(m);
|
|
694
|
+
else
|
|
695
|
+
byFile.set(m.file, [m]);
|
|
456
696
|
}
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
697
|
+
const out = [];
|
|
698
|
+
for (const [file, rows] of byFile) {
|
|
699
|
+
const abs = isAbsolute(file) ? file : join(cwd, file);
|
|
700
|
+
let lines = null;
|
|
701
|
+
try {
|
|
702
|
+
const content = await readFile(abs, "utf8");
|
|
703
|
+
lines = content.split("\n");
|
|
704
|
+
if (lines.at(-1) === "")
|
|
705
|
+
lines.pop();
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
lines = null;
|
|
709
|
+
}
|
|
710
|
+
if (lines === null) {
|
|
711
|
+
out.push(...rows);
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
// Merge overlapping/adjacent [line-before, line+after] windows.
|
|
715
|
+
const intervals = rows
|
|
716
|
+
.map((m) => [
|
|
717
|
+
Math.max(1, m.line - before),
|
|
718
|
+
Math.min(lines.length, m.line + after),
|
|
719
|
+
])
|
|
720
|
+
.sort((a, b) => a[0] - b[0]);
|
|
721
|
+
const merged = [];
|
|
722
|
+
for (const iv of intervals) {
|
|
723
|
+
const last = merged.at(-1);
|
|
724
|
+
if (last && iv[0] <= last[1] + 1)
|
|
725
|
+
last[1] = Math.max(last[1], iv[1]);
|
|
726
|
+
else
|
|
727
|
+
merged.push([iv[0], iv[1]]);
|
|
728
|
+
}
|
|
729
|
+
const matchByLine = new Map(rows.map((m) => [m.line, m]));
|
|
730
|
+
const emitted = new Set();
|
|
731
|
+
for (const [start, end] of merged) {
|
|
732
|
+
if (start > end)
|
|
733
|
+
continue;
|
|
734
|
+
for (let n = start; n <= end; n++) {
|
|
735
|
+
const m = matchByLine.get(n);
|
|
736
|
+
if (m) {
|
|
737
|
+
out.push(m);
|
|
738
|
+
emitted.add(m);
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
const repo = rows[0]?.repo ?? "";
|
|
742
|
+
out.push({ repo, file, line: n, text: lines[n - 1] ?? "", kind: "context" });
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// A match whose line exceeds the file's current length (file shrank
|
|
747
|
+
// since the scan) sits outside every clamped window — still emit it.
|
|
748
|
+
for (const m of rows) {
|
|
749
|
+
if (!emitted.has(m))
|
|
750
|
+
out.push(m);
|
|
464
751
|
}
|
|
465
|
-
return { file: normalizeFile(line.slice(0, firstColon)), line: 0, text: after };
|
|
466
|
-
}
|
|
467
|
-
const lineNum = Number.parseInt(after.slice(0, secondColon), 10);
|
|
468
|
-
if (!Number.isFinite(lineNum)) {
|
|
469
|
-
// Probably a context separator like `--`. Skip.
|
|
470
|
-
return null;
|
|
471
752
|
}
|
|
472
|
-
return
|
|
473
|
-
file: normalizeFile(line.slice(0, firstColon)),
|
|
474
|
-
line: lineNum,
|
|
475
|
-
text: after.slice(secondColon + 1),
|
|
476
|
-
};
|
|
753
|
+
return out;
|
|
477
754
|
}
|
|
478
755
|
/** Both engines may emit a leading `./` for the default path; strip it so output is engine-identical. */
|
|
479
756
|
function normalizeFile(file) {
|
|
@@ -485,23 +762,34 @@ function renderResult(r, opts) {
|
|
|
485
762
|
for (const repo of r.repos) {
|
|
486
763
|
if (repo.matches.length === 0)
|
|
487
764
|
continue;
|
|
765
|
+
const primary = repo.matches.filter((m) => m.kind === "match").length;
|
|
488
766
|
if (showRepoHeader) {
|
|
489
767
|
lines.push("");
|
|
490
|
-
lines.push(`── ${repo.name} (${
|
|
768
|
+
lines.push(`── ${repo.name} (${primary} match${primary === 1 ? "" : "es"}) ──`);
|
|
491
769
|
}
|
|
770
|
+
// Context mode: `--` between disjoint windows (file change or line gap).
|
|
771
|
+
const contextMode = repo.matches.some((x) => x.kind === "context");
|
|
772
|
+
let prev;
|
|
492
773
|
for (const m of repo.matches) {
|
|
774
|
+
if (contextMode && prev && (prev.file !== m.file || m.line !== prev.line + 1)) {
|
|
775
|
+
lines.push("--");
|
|
776
|
+
}
|
|
493
777
|
if (opts.filesOnly || opts.files) {
|
|
494
778
|
lines.push(m.file);
|
|
495
779
|
}
|
|
496
780
|
else if (opts.count) {
|
|
497
781
|
lines.push(`${m.file}:${m.line}`);
|
|
498
782
|
}
|
|
783
|
+
else if (m.kind === "context") {
|
|
784
|
+
lines.push(`${m.file}-${m.line}-${m.text}`);
|
|
785
|
+
}
|
|
499
786
|
else {
|
|
500
787
|
lines.push(`${m.file}:${m.line}:${m.text}`);
|
|
501
788
|
}
|
|
789
|
+
prev = m;
|
|
502
790
|
}
|
|
503
791
|
if (repo.truncated) {
|
|
504
|
-
lines.push(" (truncated;
|
|
792
|
+
lines.push(" (truncated; raise --limit)");
|
|
505
793
|
}
|
|
506
794
|
}
|
|
507
795
|
if (r.total_matches === 0) {
|
|
@@ -511,7 +799,7 @@ function renderResult(r, opts) {
|
|
|
511
799
|
else if (r.repos.length > 1 || r.truncated) {
|
|
512
800
|
lines.push("");
|
|
513
801
|
const summary = `${r.total_matches} match${r.total_matches === 1 ? "" : "es"} across ${r.total_files} file${r.total_files === 1 ? "" : "s"}`;
|
|
514
|
-
const tail = r.truncated ? " (truncated;
|
|
802
|
+
const tail = r.truncated ? " (truncated; raise --limit)" : "";
|
|
515
803
|
lines.push(`${summary}${tail} (${r.elapsed_ms}ms, ${r.engine})`);
|
|
516
804
|
}
|
|
517
805
|
return lines.join("\n");
|