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.
Files changed (234) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +8 -0
  4. package/dist/commands/agents.d.ts +24 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +244 -13
  7. package/dist/commands/backup.d.ts +5 -4
  8. package/dist/commands/backup.d.ts.map +1 -1
  9. package/dist/commands/backup.js +15 -14
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +27 -1
  12. package/dist/commands/claude-desktop.d.ts +19 -0
  13. package/dist/commands/claude-desktop.d.ts.map +1 -0
  14. package/dist/commands/claude-desktop.js +168 -0
  15. package/dist/commands/context.d.ts.map +1 -1
  16. package/dist/commands/context.js +149 -1
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +37 -0
  19. package/dist/commands/eml.d.ts +32 -0
  20. package/dist/commands/eml.d.ts.map +1 -1
  21. package/dist/commands/eml.js +16 -3
  22. package/dist/commands/grep.d.ts +35 -2
  23. package/dist/commands/grep.d.ts.map +1 -1
  24. package/dist/commands/grep.js +427 -139
  25. package/dist/commands/harness.d.ts +11 -0
  26. package/dist/commands/harness.d.ts.map +1 -0
  27. package/dist/commands/harness.js +118 -0
  28. package/dist/commands/init.d.ts +15 -5
  29. package/dist/commands/init.d.ts.map +1 -1
  30. package/dist/commands/init.js +125 -14
  31. package/dist/commands/presence.d.ts +9 -4
  32. package/dist/commands/presence.d.ts.map +1 -1
  33. package/dist/commands/presence.js +88 -5
  34. package/dist/commands/relay.d.ts +9 -0
  35. package/dist/commands/relay.d.ts.map +1 -0
  36. package/dist/commands/relay.js +143 -0
  37. package/dist/commands/sync.d.ts.map +1 -1
  38. package/dist/commands/sync.js +5 -0
  39. package/dist/commands/workflow.d.ts +4 -0
  40. package/dist/commands/workflow.d.ts.map +1 -0
  41. package/dist/commands/workflow.js +82 -0
  42. package/dist/core/agents/canonical-emit.d.ts +15 -0
  43. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  44. package/dist/core/agents/canonical-emit.js +41 -3
  45. package/dist/core/agents/cli.js +14 -5
  46. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  47. package/dist/core/agents/render/prompt-context.js +44 -3
  48. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/session-context.js +26 -1
  50. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  51. package/dist/core/agents/rules/claim-conflict.js +26 -1
  52. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  53. package/dist/core/agents/rules/commit-conflict.js +4 -37
  54. package/dist/core/agents/rules/stop-hook.d.ts +8 -0
  55. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  56. package/dist/core/agents/rules/stop-hook.js +8 -0
  57. package/dist/core/agents/session-events.d.ts.map +1 -1
  58. package/dist/core/agents/session-events.js +16 -43
  59. package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
  60. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  61. package/dist/core/agents/state/heartbeat-projector.js +24 -4
  62. package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
  63. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  64. package/dist/core/agents/state/heartbeat-writer.js +24 -7
  65. package/dist/core/agents/state/names.d.ts +38 -3
  66. package/dist/core/agents/state/names.d.ts.map +1 -1
  67. package/dist/core/agents/state/names.js +46 -7
  68. package/dist/core/agents/state/pidmap.d.ts +5 -1
  69. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  70. package/dist/core/agents/state/pidmap.js +58 -2
  71. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  72. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  73. package/dist/core/agents/state/stale-sweep.js +6 -6
  74. package/dist/core/config.d.ts +77 -11
  75. package/dist/core/config.d.ts.map +1 -1
  76. package/dist/core/config.js +210 -25
  77. package/dist/core/context/index.d.ts +144 -0
  78. package/dist/core/context/index.d.ts.map +1 -0
  79. package/dist/core/context/index.js +380 -0
  80. package/dist/core/harnesses/bench.d.ts +29 -0
  81. package/dist/core/harnesses/bench.d.ts.map +1 -0
  82. package/dist/core/harnesses/bench.js +151 -0
  83. package/dist/core/harnesses/index.d.ts +9 -0
  84. package/dist/core/harnesses/index.d.ts.map +1 -0
  85. package/dist/core/harnesses/index.js +4 -0
  86. package/dist/core/harnesses/profiles.d.ts +62 -0
  87. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  88. package/dist/core/harnesses/profiles.js +115 -0
  89. package/dist/core/harnesses/registry.d.ts +15 -0
  90. package/dist/core/harnesses/registry.d.ts.map +1 -0
  91. package/dist/core/harnesses/registry.js +113 -0
  92. package/dist/core/harnesses/types.d.ts +64 -0
  93. package/dist/core/harnesses/types.d.ts.map +1 -0
  94. package/dist/core/harnesses/types.js +20 -0
  95. package/dist/core/hooks/cli.js +309 -66
  96. package/dist/core/hooks/events/schema.d.ts +42 -1
  97. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  98. package/dist/core/hooks/harness/events.d.ts +7 -0
  99. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  100. package/dist/core/hooks/harness/events.js +4 -0
  101. package/dist/core/hooks/harness/parse.d.ts +1 -1
  102. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  103. package/dist/core/hooks/harness/parse.js +4 -0
  104. package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
  105. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  106. package/dist/core/hooks/resolve/coord-root.js +28 -6
  107. package/dist/core/presence/blob.d.ts +40 -0
  108. package/dist/core/presence/blob.d.ts.map +1 -0
  109. package/dist/core/presence/blob.js +91 -0
  110. package/dist/core/presence/git.d.ts +68 -0
  111. package/dist/core/presence/git.d.ts.map +1 -0
  112. package/dist/core/presence/git.js +173 -0
  113. package/dist/core/presence/index.d.ts +74 -0
  114. package/dist/core/presence/index.d.ts.map +1 -0
  115. package/dist/core/presence/index.js +224 -0
  116. package/dist/core/presence/relay-client.d.ts +39 -0
  117. package/dist/core/presence/relay-client.d.ts.map +1 -0
  118. package/dist/core/presence/relay-client.js +295 -0
  119. package/dist/core/presence/relay-protocol.d.ts +94 -0
  120. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  121. package/dist/core/presence/relay-protocol.js +169 -0
  122. package/dist/core/workflow/billing.d.ts +48 -0
  123. package/dist/core/workflow/billing.d.ts.map +1 -0
  124. package/dist/core/workflow/billing.js +108 -0
  125. package/dist/core/workflow/child-env.d.ts +30 -0
  126. package/dist/core/workflow/child-env.d.ts.map +1 -0
  127. package/dist/core/workflow/child-env.js +43 -0
  128. package/dist/core/workflow/engine.d.ts +21 -0
  129. package/dist/core/workflow/engine.d.ts.map +1 -0
  130. package/dist/core/workflow/engine.js +349 -0
  131. package/dist/core/workflow/harnesses.d.ts +17 -0
  132. package/dist/core/workflow/harnesses.d.ts.map +1 -0
  133. package/dist/core/workflow/harnesses.js +21 -0
  134. package/dist/core/workflow/spawn-claude.d.ts +25 -0
  135. package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
  136. package/dist/core/workflow/spawn-claude.js +103 -0
  137. package/dist/core/workflow/spawn-codex.d.ts +22 -0
  138. package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
  139. package/dist/core/workflow/spawn-codex.js +88 -0
  140. package/dist/core/workflow/spawn-cursor.d.ts +29 -0
  141. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
  142. package/dist/core/workflow/spawn-cursor.js +91 -0
  143. package/dist/core/workflow/types.d.ts +152 -0
  144. package/dist/core/workflow/types.d.ts.map +1 -0
  145. package/dist/core/workflow/types.js +9 -0
  146. package/dist/core/workflow/validate.d.ts +15 -0
  147. package/dist/core/workflow/validate.d.ts.map +1 -0
  148. package/dist/core/workflow/validate.js +70 -0
  149. package/dist/lib/browser/client.d.ts +16 -0
  150. package/dist/lib/browser/client.d.ts.map +1 -1
  151. package/dist/lib/browser/client.js +22 -0
  152. package/dist/lib/browser/index.d.ts +1 -0
  153. package/dist/lib/browser/index.d.ts.map +1 -1
  154. package/dist/lib/browser/index.js +1 -0
  155. package/dist/lib/browser/launch-args.d.ts +21 -0
  156. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  157. package/dist/lib/browser/launch-args.js +32 -0
  158. package/dist/lib/claude-desktop.d.ts +105 -0
  159. package/dist/lib/claude-desktop.d.ts.map +1 -0
  160. package/dist/lib/claude-desktop.js +217 -0
  161. package/dist/lib/docs-lint.d.ts.map +1 -1
  162. package/dist/lib/docs-lint.js +5 -2
  163. package/dist/lib/identities/assume.d.ts +51 -0
  164. package/dist/lib/identities/assume.d.ts.map +1 -0
  165. package/dist/lib/identities/assume.js +274 -0
  166. package/dist/lib/identities/index.d.ts +7 -7
  167. package/dist/lib/identities/index.d.ts.map +1 -1
  168. package/dist/lib/identities/index.js +24 -24
  169. package/dist/lib/instructions/templates.d.ts.map +1 -1
  170. package/dist/lib/instructions/templates.js +15 -2
  171. package/package.json +12 -1
  172. package/schemas/config.schema.json +88 -19
  173. package/src/commander.ts +8 -0
  174. package/src/commands/agents.ts +280 -14
  175. package/src/commands/backup.ts +17 -16
  176. package/src/commands/browse.ts +32 -0
  177. package/src/commands/claude-desktop.ts +215 -0
  178. package/src/commands/context.ts +174 -1
  179. package/src/commands/doctor.ts +44 -0
  180. package/src/commands/eml.ts +22 -4
  181. package/src/commands/grep.ts +535 -142
  182. package/src/commands/harness.ts +147 -0
  183. package/src/commands/init.ts +138 -17
  184. package/src/commands/presence.ts +111 -5
  185. package/src/commands/relay.ts +160 -0
  186. package/src/commands/sync.ts +4 -0
  187. package/src/commands/workflow.ts +123 -0
  188. package/src/core/agents/canonical-emit.ts +41 -3
  189. package/src/core/agents/cli.ts +15 -6
  190. package/src/core/agents/render/prompt-context.ts +48 -3
  191. package/src/core/agents/render/session-context.ts +27 -1
  192. package/src/core/agents/rules/claim-conflict.ts +26 -1
  193. package/src/core/agents/rules/commit-conflict.ts +4 -33
  194. package/src/core/agents/rules/stop-hook.ts +17 -0
  195. package/src/core/agents/session-events.ts +15 -39
  196. package/src/core/agents/state/heartbeat-projector.ts +23 -2
  197. package/src/core/agents/state/heartbeat-writer.ts +33 -7
  198. package/src/core/agents/state/names.ts +68 -9
  199. package/src/core/agents/state/pidmap.ts +59 -2
  200. package/src/core/agents/state/stale-sweep.ts +6 -11
  201. package/src/core/config.ts +271 -24
  202. package/src/core/context/index.ts +575 -0
  203. package/src/core/harnesses/bench.ts +214 -0
  204. package/src/core/harnesses/index.ts +31 -0
  205. package/src/core/harnesses/profiles.ts +141 -0
  206. package/src/core/harnesses/registry.ts +140 -0
  207. package/src/core/harnesses/types.ts +90 -0
  208. package/src/core/hooks/cli.ts +361 -65
  209. package/src/core/hooks/events/schema.ts +72 -0
  210. package/src/core/hooks/harness/events.ts +11 -0
  211. package/src/core/hooks/harness/parse.ts +7 -1
  212. package/src/core/hooks/resolve/coord-root.ts +28 -6
  213. package/src/core/presence/blob.ts +125 -0
  214. package/src/core/presence/git.ts +191 -0
  215. package/src/core/presence/index.ts +274 -0
  216. package/src/core/presence/relay-client.ts +322 -0
  217. package/src/core/presence/relay-protocol.ts +246 -0
  218. package/src/core/workflow/billing.ts +152 -0
  219. package/src/core/workflow/child-env.ts +47 -0
  220. package/src/core/workflow/engine.ts +403 -0
  221. package/src/core/workflow/harnesses.ts +33 -0
  222. package/src/core/workflow/spawn-claude.ts +118 -0
  223. package/src/core/workflow/spawn-codex.ts +93 -0
  224. package/src/core/workflow/spawn-cursor.ts +108 -0
  225. package/src/core/workflow/types.ts +160 -0
  226. package/src/core/workflow/validate.ts +75 -0
  227. package/src/lib/browser/client.ts +37 -0
  228. package/src/lib/browser/index.ts +1 -0
  229. package/src/lib/browser/launch-args.ts +33 -0
  230. package/src/lib/claude-desktop.ts +293 -0
  231. package/src/lib/docs-lint.ts +5 -2
  232. package/src/lib/identities/assume.ts +363 -0
  233. package/src/lib/identities/index.ts +28 -24
  234. package/src/lib/instructions/templates.ts +16 -2
@@ -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 `grep -rn` transparently — both engines are
6
- * driven with equivalent flags and their output is parsed into the same
7
- * envelope, so results are identical (pinned by tests/unit/grep-engine.test.ts).
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`), and language presets.
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 resolveEngine) and used when available. Repos are searched in
13
- * parallel, and in `--all-repos` mode the parent scan prunes submodule
14
- * directories so each match is attributed to exactly one repo (previously the
15
- * parent scan descended into submodules, double-scanning and double-reporting
16
- * every submodule match).
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, `{rows, total, truncated}`
21
- * in --json mode. Matches are sorted (file, then line) for stable output
22
- * across runs and engines; with `-C <n>` context, engine order is kept so
23
- * context groups stay adjacent.
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. Regex by default; -F for literal.")
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
- /** Exported for tests (not part of the package exports map). */
104
- export async function runGrep(pattern, paths, opts, context) {
105
- if (!pattern)
106
- throw new Error("pattern required");
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
- [Number.parseInt(opts.context ?? "0", 10) > 0 ? true : undefined, "-C/--context"],
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 is capped at the global limit
134
- // (a single repo can never contribute more), then the global budget is
135
- // applied in repo order below so `--limit` semantics stay deterministic.
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, limit, engine, rgBin, extraDirs);
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
- if (sortable) {
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
- const matches = collected.slice(0, take);
164
- const repoTruncated = engineCapped || collected.length > take;
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, limit, engine, rgBin, extraExcludeDirs) {
216
- // Filename mode: `rg --files` when available; POSIX `find` otherwise (GNU
217
- // grep has no list-by-name mode). Content mode: rg / grep as selected.
218
- const [bin, args] = opts.files
219
- ? engine === "rg"
220
- ? [rgBin, buildRgFilesArgs(pattern, paths, opts, extraExcludeDirs)]
221
- : ["find", buildFindArgs(pattern, paths, opts, extraExcludeDirs)]
222
- : engine === "rg"
223
- ? [rgBin, buildRgArgs(pattern, paths, opts, extraExcludeDirs)]
224
- : ["grep", buildGrepArgs(pattern, paths, opts, extraExcludeDirs)];
225
- let matches = await execSearch(bin, args, cwd, limit);
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
- function resolveLangGlobs(opts) {
233
- const langGlobs = opts.lang ? LANG_GLOBS[opts.lang] : undefined;
234
- if (opts.lang && !langGlobs) {
235
- throw new Error(`unknown --lang "${opts.lang}". Valid: ${Object.keys(LANG_GLOBS).join(", ")}`);
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
- return langGlobs;
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
- const args = ["-rn", "-H", "--color=never", "-I"]; // -I: skip binary files
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 (opts.filesOnly)
407
+ if (shape.filesOnly)
250
408
  args.push("-l");
251
- if (opts.count)
409
+ if (shape.count)
252
410
  args.push("-c");
253
- const contextN = Number.parseInt(opts.context ?? "0", 10);
254
- if (Number.isFinite(contextN) && contextN > 0)
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
- const langGlobs = resolveLangGlobs(opts);
272
- if (langGlobs)
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 parser.
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 (opts.filesOnly)
458
+ if (shape.filesOnly)
306
459
  args.push("-l");
307
- if (opts.count)
460
+ if (shape.count)
308
461
  args.push("-c");
309
- const contextN = Number.parseInt(opts.context ?? "0", 10);
310
- if (Number.isFinite(contextN) && contextN > 0)
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
- const langGlobs = resolveLangGlobs(opts);
324
- if (langGlobs)
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
- function execSearch(bin, args, cwd, limit) {
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 truncated = false;
405
- proc.stdout.setEncoding("utf8");
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 (truncated)
652
+ if (killed)
408
653
  return;
409
- buffer += chunk;
410
- let nl = buffer.indexOf("\n");
411
- while (nl >= 0) {
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 (buffer.length > 0 && !truncated) {
434
- const parsed = parseGrepLine(buffer);
435
- if (parsed)
436
- matches.push(parsed);
437
- }
438
- // Exit 1 is "no matches" on both engines: normal, not an error. Exit 2
439
- // with matches collected means a partial failure (an unreadable file
440
- // mid-walk); surface the results rather than throwing them away.
441
- if (code !== null && code !== 0 && code !== 1 && !truncated && matches.length === 0) {
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
- /** Parse `path:line:content` from grep/rg -n output. Returns null for ambiguous (no line number) lines. */
450
- function parseGrepLine(line) {
451
- // First colon ends path; second colon ends line number (if numeric).
452
- const firstColon = line.indexOf(":");
453
- if (firstColon < 0) {
454
- // -l mode: just a path. Encode as line=0, text="".
455
- return { file: normalizeFile(line), line: 0, text: "" };
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 after = line.slice(firstColon + 1);
458
- const secondColon = after.indexOf(":");
459
- if (secondColon < 0) {
460
- // -c mode: `path:count`.
461
- const count = Number.parseInt(after, 10);
462
- if (Number.isFinite(count)) {
463
- return { file: normalizeFile(line.slice(0, firstColon)), line: count, text: "" };
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} (${repo.matches.length} match${repo.matches.length === 1 ? "" : "es"}) ──`);
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; pass --limit to widen)");
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; use --limit)" : "";
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");