jev-agent-tools 0.2.0 → 0.3.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 (117) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +22 -14
  4. package/SECURITY.md +17 -1
  5. package/dist/adapters/ask-files.js +11 -2
  6. package/dist/adapters/ask-proof.js +63 -7
  7. package/dist/adapters/command.js +82 -29
  8. package/dist/adapters/docs.js +30 -10
  9. package/dist/adapters/evidence-context.js +119 -0
  10. package/dist/adapters/files.js +141 -16
  11. package/dist/adapters/find.js +34 -6
  12. package/dist/adapters/git-base.js +7 -1
  13. package/dist/adapters/git.js +51 -7
  14. package/dist/adapters/locate-file.js +47 -9
  15. package/dist/adapters/private-storage.js +14 -6
  16. package/dist/adapters/risk-callers.js +3 -0
  17. package/dist/adapters/shell.js +23 -7
  18. package/dist/adapters/test-inventory.js +10 -2
  19. package/dist/configuration.js +17 -7
  20. package/dist/constants.js +26 -5
  21. package/dist/core/ask-references.js +193 -109
  22. package/dist/core/asks.js +78 -7
  23. package/dist/core/locate.js +8 -8
  24. package/dist/core/output.js +17 -0
  25. package/dist/core/result-report.js +302 -0
  26. package/dist/core/secret-path.js +34 -0
  27. package/dist/core/state.js +8 -1
  28. package/dist/core/units.js +1 -1
  29. package/dist/jev/client.js +34 -12
  30. package/dist/mcp/protocol.js +50 -27
  31. package/dist/mcp/tools.js +20 -7
  32. package/dist/render.js +72 -0
  33. package/dist/report-schema.js +1356 -0
  34. package/dist/result-types.js +1 -0
  35. package/dist/texts/ask-files.js +3 -1
  36. package/dist/texts/ask.js +3 -1
  37. package/dist/texts/check-diff.js +7 -4
  38. package/dist/texts/find.js +7 -2
  39. package/dist/texts/guide.js +3 -16
  40. package/dist/texts/instructions.js +72 -0
  41. package/dist/texts/locate.js +7 -2
  42. package/dist/texts/select-tests.js +3 -1
  43. package/dist/tools/ask-files.js +248 -15
  44. package/dist/tools/ask.js +523 -62
  45. package/dist/tools/check-diff.js +222 -30
  46. package/dist/tools/docs-check.js +122 -13
  47. package/dist/tools/find.js +320 -27
  48. package/dist/tools/locate.js +317 -18
  49. package/dist/tools/review-report.js +230 -0
  50. package/dist/tools/select-tests.js +273 -19
  51. package/dist/tools/spec-check.js +119 -22
  52. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +3 -3
  53. package/docs/agent-instructions.md +59 -30
  54. package/docs/design.md +13 -1
  55. package/docs/mcp.md +8 -6
  56. package/docs/tools/jev_ask.md +8 -5
  57. package/docs/tools/jev_ask_files.md +2 -1
  58. package/docs/tools/jev_check_diff.md +4 -1
  59. package/docs/tools/jev_find_files.md +2 -1
  60. package/docs/tools/jev_locate_in_file.md +5 -0
  61. package/docs/tools/jev_select_tests.md +4 -1
  62. package/package.json +1 -1
  63. package/rules/jev-ask.md +22 -1
  64. package/server.json +2 -2
  65. package/src/adapters/ask-files.ts +11 -3
  66. package/src/adapters/ask-proof.ts +69 -11
  67. package/src/adapters/command.ts +96 -33
  68. package/src/adapters/docs.ts +33 -14
  69. package/src/adapters/evidence-context.ts +169 -0
  70. package/src/adapters/files.ts +146 -16
  71. package/src/adapters/find.ts +37 -7
  72. package/src/adapters/git-base.ts +7 -1
  73. package/src/adapters/git.ts +61 -8
  74. package/src/adapters/locate-file.ts +51 -9
  75. package/src/adapters/private-storage.ts +17 -5
  76. package/src/adapters/risk-callers.ts +3 -0
  77. package/src/adapters/shell.ts +23 -7
  78. package/src/adapters/test-inventory.ts +12 -4
  79. package/src/configuration.ts +16 -2
  80. package/src/constants.ts +26 -5
  81. package/src/core/ask-references.ts +262 -146
  82. package/src/core/asks.ts +79 -7
  83. package/src/core/import-boundaries.ts +8 -3
  84. package/src/core/locate.ts +8 -5
  85. package/src/core/output.ts +34 -0
  86. package/src/core/result-report.ts +410 -0
  87. package/src/core/secret-path.ts +37 -0
  88. package/src/core/state.ts +8 -1
  89. package/src/core/units.ts +3 -2
  90. package/src/index.ts +3 -0
  91. package/src/jev/client.ts +54 -16
  92. package/src/jev/types.ts +18 -3
  93. package/src/mcp/protocol.ts +91 -41
  94. package/src/mcp/tools.ts +26 -13
  95. package/src/render.ts +109 -0
  96. package/src/report-schema.ts +1380 -0
  97. package/src/result-types.ts +234 -0
  98. package/src/result.ts +4 -1
  99. package/src/runtime.ts +6 -0
  100. package/src/texts/ask-files.ts +4 -1
  101. package/src/texts/ask.ts +8 -1
  102. package/src/texts/check-diff.ts +7 -4
  103. package/src/texts/find.ts +8 -2
  104. package/src/texts/guide.ts +8 -16
  105. package/src/texts/instructions.ts +98 -0
  106. package/src/texts/locate.ts +8 -2
  107. package/src/texts/run-end.ts +2 -2
  108. package/src/texts/select-tests.ts +4 -1
  109. package/src/tools/ask-files.ts +309 -14
  110. package/src/tools/ask.ts +700 -77
  111. package/src/tools/check-diff.ts +331 -28
  112. package/src/tools/docs-check.ts +241 -39
  113. package/src/tools/find.ts +386 -29
  114. package/src/tools/locate.ts +384 -19
  115. package/src/tools/review-report.ts +308 -0
  116. package/src/tools/select-tests.ts +479 -21
  117. package/src/tools/spec-check.ts +193 -19
@@ -0,0 +1,119 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { parse, resolve, sep } from "node:path";
3
+ import { TIMEOUT_MS } from "../constants.js";
4
+ /** Bind each judgment (including controls) to its admitted per-call provenance. */
5
+ export function withEvidenceContext(state, context) {
6
+ const provenance = {
7
+ ...context,
8
+ authority: { ...context.authority },
9
+ ...(context.effectiveRoot
10
+ ? { effectiveRoot: { ...context.effectiveRoot } }
11
+ : {}),
12
+ };
13
+ const evidence = state.evidence;
14
+ // A producer's non-object evidence is content, not a metadata container.
15
+ if (evidence !== undefined &&
16
+ (evidence === null ||
17
+ typeof evidence !== "object" ||
18
+ Array.isArray(evidence)))
19
+ return { ...state, evidenceContext: provenance };
20
+ return { ...state, evidence: { ...evidence, context: provenance } };
21
+ }
22
+ /** Metadata-only admission. No evidence, commands, caches or judgments precede this. */
23
+ export async function resolveEvidenceContext(initialCwd, root, deps) {
24
+ const canonical = deps.realpath ?? realpath;
25
+ const inspect = deps.lstat ?? lstat;
26
+ let initial;
27
+ try {
28
+ initial = await canonical(initialCwd);
29
+ }
30
+ catch {
31
+ return {
32
+ ok: false,
33
+ cause: "file_unavailable",
34
+ error: "Initial evidence authority cannot be canonicalized",
35
+ context: {
36
+ authority: { path: "", origin: deps.origin ?? "host" },
37
+ authorityReason: "Initial evidence authority cannot be canonicalized",
38
+ ...(root !== undefined ? { requestedRoot: root } : {}),
39
+ },
40
+ };
41
+ }
42
+ const origin = deps.origin ?? "host";
43
+ const context = {
44
+ authority: { path: initial, origin },
45
+ ...(root !== undefined ? { requestedRoot: root } : {}),
46
+ };
47
+ if (root === undefined) {
48
+ context.effectiveRoot = { path: initial, origin };
49
+ return { ok: true, cwd: initial, context };
50
+ }
51
+ const refuse = (reason, cause = "invalid_root") => ({
52
+ ok: false,
53
+ cause,
54
+ error: `Invalid root: ${reason}`,
55
+ context,
56
+ });
57
+ if (!root.trim())
58
+ return refuse("provide a non-empty directory");
59
+ if (root.split(/[\\/]/).includes(".."))
60
+ return refuse("parent traversal is forbidden");
61
+ const candidate = resolve(initial, root);
62
+ try {
63
+ let component = parse(candidate).root;
64
+ for (const part of candidate
65
+ .slice(component.length)
66
+ .split(sep)
67
+ .filter(Boolean)) {
68
+ component = resolve(component, part);
69
+ const metadata = await inspect(component);
70
+ if (metadata.isSymbolicLink())
71
+ return refuse("symbolic links are forbidden");
72
+ if (!metadata.isDirectory())
73
+ return refuse("root must be an existing directory");
74
+ }
75
+ const path = await canonical(candidate);
76
+ const git = async (cwd, args) => {
77
+ const result = await deps.exec("git", args, {
78
+ cwd,
79
+ timeout: TIMEOUT_MS,
80
+ signal: deps.signal,
81
+ });
82
+ if (result.code || result.killed)
83
+ throw new Error("Git metadata unavailable");
84
+ return args.includes("-z")
85
+ ? result.stdout
86
+ : result.stdout.replace(/\r?\n$/, "");
87
+ };
88
+ const initialTop = await canonical(await git(initial, ["rev-parse", "--show-toplevel"]));
89
+ const candidateTop = await canonical(await git(path, ["rev-parse", "--show-toplevel"]));
90
+ if (candidateTop !== path)
91
+ return refuse("root must equal a Git top-level directory");
92
+ const common = await canonical(resolve(initial, await git(initial, ["rev-parse", "--git-common-dir"])));
93
+ const candidateCommon = await canonical(resolve(path, await git(path, ["rev-parse", "--git-common-dir"])));
94
+ if (candidateCommon !== common)
95
+ return refuse("root belongs to a different repository");
96
+ const listed = await git(initial, [
97
+ "worktree",
98
+ "list",
99
+ "--porcelain",
100
+ "-z",
101
+ ]);
102
+ const registered = listed.split("\0\0").some((block) => {
103
+ const fields = block.split("\0");
104
+ const worktree = fields
105
+ .find((field) => field.startsWith("worktree "))
106
+ ?.slice(9);
107
+ return (worktree !== undefined &&
108
+ resolve(worktree) === path &&
109
+ !fields.some((field) => /^prunable(?: |$)/.test(field)));
110
+ });
111
+ if (path !== initialTop && !registered)
112
+ return refuse("root is not a registered live worktree");
113
+ context.effectiveRoot = { path, origin: "override", commonDir: common };
114
+ return { ok: true, cwd: path, context };
115
+ }
116
+ catch (error) {
117
+ return refuse(`cannot admit directory (${error instanceof Error ? error.message : String(error)})`, deps.signal?.aborted ? "cancelled" : "invalid_root");
118
+ }
119
+ }
@@ -4,14 +4,26 @@ import { lstat, open, readlink, realpath, } from "node:fs/promises";
4
4
  import { isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import { ASK_MAX_FILES, FIND_EXCERPT_CHARS, FIND_POINTER_EXCERPT_CHARS, FIND_READ_CHUNK_BYTES, STATE_MAX_CHARS, TIMEOUT_MS, } from "../constants.js";
6
6
  import { createExcerptCollector } from "../core/find.js";
7
+ import { isSecretPath, secretRefusal } from "../core/secret-path.js";
7
8
  import { createUtf8Decoder, decodeUtf8 } from "./utf8.js";
8
9
  export async function checkFileAdmission(cwd, path, exec, signal, inventory) {
9
10
  if (path.split(/[\\/]/).includes(".git"))
10
- return { ok: false, error: `${path}: .git metadata: not sent to Jev` };
11
+ return {
12
+ ok: false,
13
+ cause: "forbidden_path",
14
+ error: `${path}: .git metadata: not sent to Jev`,
15
+ };
16
+ // Before any inventory lookup or read: secret names are never admitted.
17
+ if (isSecretPath(path))
18
+ return { ok: false, cause: "secret_pattern", error: secretRefusal(path) };
11
19
  if (inventory)
12
20
  return inventory.has(path)
13
21
  ? { ok: true }
14
- : { ok: false, error: `${path}: gitignored: not sent to Jev` };
22
+ : {
23
+ ok: false,
24
+ cause: "ignored_path",
25
+ error: `${path}: outside admitted inventory: not sent to Jev`,
26
+ };
15
27
  if (!exec)
16
28
  return { ok: true };
17
29
  const listed = await exec("git", [
@@ -24,15 +36,24 @@ export async function checkFileAdmission(cwd, path, exec, signal, inventory) {
24
36
  path,
25
37
  ], { cwd, timeout: TIMEOUT_MS, signal });
26
38
  if (listed.killed)
27
- return { ok: false, error: `${path}: file admission interrupted` };
39
+ return {
40
+ ok: false,
41
+ cause: signal?.aborted ? "cancelled" : "git_failure",
42
+ error: `${path}: file admission interrupted`,
43
+ };
28
44
  if (listed.code === 0 && !listed.stdout.split("\0").includes(path))
29
- return { ok: false, error: `${path}: gitignored: not sent to Jev` };
45
+ return {
46
+ ok: false,
47
+ cause: "ignored_path",
48
+ error: `${path}: gitignored: not sent to Jev`,
49
+ };
30
50
  return { ok: true };
31
51
  }
32
52
  export async function resolveInsideRepo(root, path, options = {}) {
33
53
  if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
34
54
  return {
35
55
  ok: false,
56
+ cause: "forbidden_path",
36
57
  error: `Path must remain inside the repository: ${path}.`,
37
58
  };
38
59
  try {
@@ -40,24 +61,40 @@ export async function resolveInsideRepo(root, path, options = {}) {
40
61
  const requested = resolve(base, path);
41
62
  const rel = relative(base, requested);
42
63
  if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
43
- return { ok: false, error: `Path escapes the repository: ${path}.` };
64
+ return {
65
+ ok: false,
66
+ cause: "forbidden_path",
67
+ error: `Path escapes the repository: ${path}.`,
68
+ };
44
69
  let current = base;
45
70
  for (const part of rel.split(sep).filter(Boolean)) {
46
71
  current = resolve(current, part);
47
72
  if ((await lstat(current)).isSymbolicLink()) {
48
73
  if (options.allowLeafSymlink && current === requested)
49
74
  return { ok: true, abs: requested, rel: rel.replaceAll(sep, "/") };
50
- return { ok: false, error: `Symbolic links are not read: ${path}.` };
75
+ return {
76
+ ok: false,
77
+ cause: "symlink",
78
+ error: `Symbolic links are not read: ${path}.`,
79
+ };
51
80
  }
52
81
  }
53
82
  const abs = await realpath(requested);
54
83
  const actual = relative(base, abs);
55
84
  if (actual === ".." || actual.startsWith(`..${sep}`) || isAbsolute(actual))
56
- return { ok: false, error: `Path escapes the repository: ${path}.` };
85
+ return {
86
+ ok: false,
87
+ cause: "forbidden_path",
88
+ error: `Path escapes the repository: ${path}.`,
89
+ };
57
90
  return { ok: true, abs, rel: actual.replaceAll(sep, "/") };
58
91
  }
59
92
  catch (error) {
60
- return { ok: false, error: `Cannot resolve ${path}: ${String(error)}` };
93
+ return {
94
+ ok: false,
95
+ cause: "file_unavailable",
96
+ error: `Cannot resolve ${path}: ${String(error)}`,
97
+ };
61
98
  }
62
99
  }
63
100
  /** Leaf-link metadata is admitted without following the target. */
@@ -75,6 +112,7 @@ export async function admitGitPath(cwd, path, base, exec, signal, inventory) {
75
112
  if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
76
113
  return {
77
114
  ok: false,
115
+ cause: "forbidden_path",
78
116
  error: `Path must remain inside the repository: ${path}.`,
79
117
  };
80
118
  const admission = await checkFileAdmission(cwd, path, undefined, signal);
@@ -85,13 +123,21 @@ export async function admitGitPath(cwd, path, base, exec, signal, inventory) {
85
123
  if (!inventory) {
86
124
  const tree = await exec("git", ["ls-tree", "-z", "--full-tree", "--end-of-options", base, "--", path], { cwd, timeout: TIMEOUT_MS, signal });
87
125
  if (tree.code !== 0 || tree.killed)
88
- return { ok: false, error: `${path}: not a file at comparison base` };
126
+ return {
127
+ ok: false,
128
+ cause: signal?.aborted ? "cancelled" : "git_failure",
129
+ error: `${path}: comparison base tree unavailable`,
130
+ };
89
131
  entry = tree.stdout
90
132
  .split("\0")
91
133
  .find((item) => item.slice(item.indexOf("\t") + 1) === path);
92
134
  }
93
135
  if (!entry || !/^(?:100644|100755|120000) blob /.test(entry))
94
- return { ok: false, error: `${path}: not a file at comparison base` };
136
+ return {
137
+ ok: false,
138
+ cause: "file_unavailable",
139
+ error: `${path}: not a file at comparison base`,
140
+ };
95
141
  return { ok: true };
96
142
  }
97
143
  /** The only content opener for repository files. Callers own and close the handle. */
@@ -109,7 +155,11 @@ export async function openRepoFile(cwd, path, options = {}) {
109
155
  const stat = await handle.stat();
110
156
  if (!stat.isFile()) {
111
157
  await handle.close();
112
- return { ok: false, error: `Not a file: ${path}.` };
158
+ return {
159
+ ok: false,
160
+ cause: "file_unavailable",
161
+ error: `Not a file: ${path}.`,
162
+ };
113
163
  }
114
164
  return { ok: true, handle, size: stat.size };
115
165
  }
@@ -119,19 +169,43 @@ export async function openRepoFile(cwd, path, options = {}) {
119
169
  }
120
170
  }
121
171
  catch (error) {
122
- return { ok: false, error: `Cannot read ${path}: ${String(error)}` };
172
+ return {
173
+ ok: false,
174
+ cause: options.signal?.aborted
175
+ ? "cancelled"
176
+ : error.code === "ELOOP"
177
+ ? "symlink"
178
+ : "file_unavailable",
179
+ error: `Cannot read ${path}: ${String(error)}`,
180
+ };
123
181
  }
124
182
  }
125
183
  export async function collectFiles(cwd, paths, signal, options = {}) {
184
+ for (const path of paths) {
185
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
186
+ return {
187
+ ok: false,
188
+ cause: "forbidden_path",
189
+ error: `Internal URLs are not files; use read for ${path}.`,
190
+ };
191
+ const admission = await resolveInsideRepo(cwd, path);
192
+ if (!admission.ok)
193
+ return admission;
194
+ }
195
+ paths = [
196
+ ...new Set(paths.map((path) => relative(cwd, resolve(cwd, path)).split("\\").join("/"))),
197
+ ].sort();
126
198
  if (paths.length > ASK_MAX_FILES)
127
199
  return {
128
200
  ok: false,
201
+ cause: "evidence_too_large",
129
202
  error: `paths exceeds ${ASK_MAX_FILES} files; split paths into several calls.`,
130
203
  };
131
204
  const results = await Promise.all(paths.map(async (path) => {
132
205
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
133
206
  return {
134
207
  ok: false,
208
+ cause: "forbidden_path",
135
209
  error: `Internal URLs are not files; use read for ${path}.`,
136
210
  };
137
211
  let handle;
@@ -157,23 +231,30 @@ export async function collectFiles(cwd, paths, signal, options = {}) {
157
231
  if (count === bytes.length)
158
232
  return {
159
233
  ok: false,
234
+ cause: "evidence_too_large",
160
235
  error: `File exceeds ${STATE_MAX_CHARS} chars: ${path}; files are never truncated. Split paths into several calls.`,
161
236
  };
162
237
  const decoded = decodeUtf8(bytes.subarray(0, count));
163
- if (!decoded.ok)
238
+ if (!decoded.ok || (decoded.ok && decoded.text.includes("\0")))
164
239
  return {
165
240
  ok: false,
166
241
  invalidUtf8: true,
242
+ cause: "binary_or_non_utf8",
167
243
  error: `${path} is not UTF-8 text; provide a UTF-8 text file instead.`,
168
244
  };
169
245
  const text = decoded.text;
170
246
  if (count === bytes.length || text.length > STATE_MAX_CHARS)
171
247
  return {
172
248
  ok: false,
249
+ cause: "evidence_too_large",
173
250
  error: `File exceeds ${STATE_MAX_CHARS} chars: ${path}; files are never truncated. Split paths into several calls.`,
174
251
  };
175
252
  if (!text.length && !options.allowEmpty)
176
- return { ok: false, error: `${path} is empty.` };
253
+ return {
254
+ ok: false,
255
+ cause: "empty_required",
256
+ error: `${path} is empty.`,
257
+ };
177
258
  return {
178
259
  ok: true,
179
260
  path,
@@ -193,6 +274,9 @@ export async function collectFiles(cwd, paths, signal, options = {}) {
193
274
  catch (error) {
194
275
  return {
195
276
  ok: false,
277
+ cause: signal?.aborted
278
+ ? "cancelled"
279
+ : "file_unavailable",
196
280
  error: `Cannot read ${path}: ${String(error)}`,
197
281
  };
198
282
  }
@@ -214,16 +298,23 @@ export async function collectSearchExcerpts(cwd, paths, keywords, signal, exec)
214
298
  const files = {}, pointers = {};
215
299
  const ignored = [];
216
300
  const limits = [];
301
+ const omissions = [];
217
302
  const results = await Promise.all(paths.map(async (path) => {
218
303
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
219
304
  return {
220
305
  ok: false,
306
+ cause: "forbidden_path",
221
307
  error: `Internal URLs are not files; use read for ${path}.`,
222
308
  };
223
309
  try {
224
310
  const location = await admitRepoMetadata(cwd, path, exec, signal);
225
311
  if (!location.ok) {
226
312
  ignored.push(path);
313
+ omissions.push({
314
+ path,
315
+ cause: location.cause ?? "file_unavailable",
316
+ reason: location.error,
317
+ });
227
318
  return { ok: true };
228
319
  }
229
320
  if ((await lstat(location.abs)).isSymbolicLink()) {
@@ -235,6 +326,11 @@ export async function collectSearchExcerpts(cwd, paths, keywords, signal, exec)
235
326
  const opened = await openRepoFile(cwd, path, { exec, signal });
236
327
  if (!opened.ok) {
237
328
  ignored.push(path);
329
+ omissions.push({
330
+ path,
331
+ cause: opened.cause ?? "file_unavailable",
332
+ reason: opened.error,
333
+ });
238
334
  return { ok: true };
239
335
  }
240
336
  const handle = opened.handle;
@@ -249,6 +345,11 @@ export async function collectSearchExcerpts(cwd, paths, keywords, signal, exec)
249
345
  const decoded = decode(chunk);
250
346
  if (!decoded.ok) {
251
347
  limits.push({ path, reason: decoded.error });
348
+ omissions.push({
349
+ path,
350
+ cause: "binary_or_non_utf8",
351
+ reason: decoded.error,
352
+ });
252
353
  ignored.push(path);
253
354
  stream.destroy();
254
355
  return { ok: true };
@@ -256,6 +357,11 @@ export async function collectSearchExcerpts(cwd, paths, keywords, signal, exec)
256
357
  const text = decoded.text;
257
358
  if (text.includes("\0")) {
258
359
  ignored.push(path);
360
+ omissions.push({
361
+ path,
362
+ cause: "binary_or_non_utf8",
363
+ reason: "Binary NUL content is not sent to Jev",
364
+ });
259
365
  stream.destroy();
260
366
  return { ok: true };
261
367
  }
@@ -265,6 +371,11 @@ export async function collectSearchExcerpts(cwd, paths, keywords, signal, exec)
265
371
  const end = decode();
266
372
  if (!end.ok) {
267
373
  limits.push({ path, reason: end.error });
374
+ omissions.push({
375
+ path,
376
+ cause: "binary_or_non_utf8",
377
+ reason: end.error,
378
+ });
268
379
  ignored.push(path);
269
380
  return { ok: true };
270
381
  }
@@ -273,6 +384,11 @@ export async function collectSearchExcerpts(cwd, paths, keywords, signal, exec)
273
384
  const excerpt = content.finish();
274
385
  if (!excerpt.length) {
275
386
  ignored.push(path);
387
+ omissions.push({
388
+ path,
389
+ cause: "collection_empty",
390
+ reason: "No non-empty search excerpt was collected",
391
+ });
276
392
  return { ok: true };
277
393
  }
278
394
  files[path] = excerpt;
@@ -281,13 +397,22 @@ export async function collectSearchExcerpts(cwd, paths, keywords, signal, exec)
281
397
  }
282
398
  catch (error) {
283
399
  if (signal?.aborted)
284
- return { ok: false, error: String(error) };
400
+ return {
401
+ ok: false,
402
+ cause: "cancelled",
403
+ error: String(error),
404
+ };
285
405
  ignored.push(path);
406
+ omissions.push({
407
+ path,
408
+ cause: "file_unavailable",
409
+ reason: String(error),
410
+ });
286
411
  return { ok: true };
287
412
  }
288
413
  }));
289
414
  const failure = results.find((result) => !result.ok);
290
415
  return failure && !failure.ok
291
416
  ? failure
292
- : { ok: true, files, pointers, ignored, limits };
417
+ : { ok: true, files, pointers, ignored, limits, omissions };
293
418
  }
@@ -1,23 +1,46 @@
1
+ import { isAbsolute } from "node:path";
1
2
  import { FIND_GREP_PAGE_SIZE, FIND_PATH_WEIGHT, TIMEOUT_MS, } from "../constants.js";
2
3
  import { pathAllowed } from "../core/find.js";
4
+ import { isSecretPath, SECRET_NAME_GLOBS } from "../core/secret-path.js";
3
5
  export async function prefilter(exec, cwd, keywords, limit, scope, exclude, signal, native = true) {
6
+ const restrictions = [
7
+ ...(typeof scope === "string" ? [scope] : (scope ?? [])),
8
+ ...(exclude ?? []),
9
+ ];
10
+ for (const restriction of restrictions) {
11
+ if (isAbsolute(restriction) ||
12
+ restriction
13
+ .split(/[\\/]/)
14
+ .some((segment) => segment === ".." || segment === ".git"))
15
+ return {
16
+ ok: false,
17
+ cause: "forbidden_path",
18
+ error: `Discovery restriction must remain inside admitted files: ${restriction}`,
19
+ };
20
+ }
4
21
  try {
5
22
  const listed = await exec("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd, timeout: TIMEOUT_MS, signal });
6
23
  if (listed.killed || listed.code !== 0)
7
24
  return {
8
25
  ok: false,
26
+ cause: signal?.aborted ? "cancelled" : "git_failure",
9
27
  error: `Cannot list repository files: ${listed.stderr || "git interrupted"}`,
10
28
  };
11
- const paths = [
29
+ const inScope = [
12
30
  ...new Set(listed.stdout
13
31
  .split("\0")
14
32
  .filter((path) => path && pathAllowed(path, scope, exclude))),
15
33
  ];
34
+ // Secret-named files are neither ranked, read nor named to Jev.
35
+ const secret = inScope.filter(isSecretPath);
36
+ const paths = inScope.filter((path) => !isSecretPath(path));
16
37
  if (!paths.length)
17
- return { ok: true, paths: [], scopeFiles: 0 };
38
+ return { ok: true, paths: [], scopeFiles: 0, secret };
18
39
  const allowed = new Set(paths);
19
40
  let finder;
20
- if (native) {
41
+ // The native index cannot skip files, so it never scans a scope that
42
+ // holds secret-named files; rg excludes them by name instead.
43
+ if (native && !secret.length) {
21
44
  try {
22
45
  // Static loading would break hosts where this optional native package is absent.
23
46
  const { FileFinder } = await import("@ff-labs/fff-node");
@@ -74,8 +97,8 @@ export async function prefilter(exec, cwd, keywords, limit, scope, exclude, sign
74
97
  "--files-with-matches",
75
98
  "--hidden",
76
99
  "--null",
77
- "--fixed-strings",
78
- "--ignore-case",
100
+ "--glob-case-insensitive",
101
+ ...SECRET_NAME_GLOBS.flatMap((glob) => ["--glob", `!${glob}`]),
79
102
  "--",
80
103
  word,
81
104
  ".",
@@ -110,6 +133,7 @@ export async function prefilter(exec, cwd, keywords, limit, scope, exclude, sign
110
133
  .slice(0, limit)
111
134
  .map((item) => item.path),
112
135
  scopeFiles: paths.length,
136
+ secret,
113
137
  };
114
138
  }
115
139
  finally {
@@ -117,6 +141,10 @@ export async function prefilter(exec, cwd, keywords, limit, scope, exclude, sign
117
141
  }
118
142
  }
119
143
  catch (error) {
120
- return { ok: false, error: `Cannot prefilter files: ${String(error)}` };
144
+ return {
145
+ ok: false,
146
+ cause: signal?.aborted ? "cancelled" : "git_failure",
147
+ error: `Cannot prefilter files: ${String(error)}`,
148
+ };
121
149
  }
122
150
  }
@@ -9,17 +9,23 @@ export async function resolveBase(exec, cwd, base, signal) {
9
9
  signal,
10
10
  });
11
11
  if (result.killed)
12
- return { ok: false, error: "Git interrupted (cancelled or timed out)." };
12
+ return {
13
+ ok: false,
14
+ cause: signal?.aborted ? "cancelled" : "git_failure",
15
+ error: "Git interrupted (cancelled or timed out).",
16
+ };
13
17
  if (result.code === 0 && result.stdout.trim())
14
18
  return { ok: true, base: result.stdout.trim() };
15
19
  return {
16
20
  ok: false,
21
+ cause: "invalid_base",
17
22
  error: `Cannot resolve base=${base}: history may be truncated or no shared ancestor is available. Run git fetch --unshallow (or git fetch --deepen=<count>), or choose a base that is an ancestor of HEAD.${result.stderr.trim() ? ` Git: ${result.stderr.trim()}` : ""}`,
18
23
  };
19
24
  }
20
25
  catch (error) {
21
26
  return {
22
27
  ok: false,
28
+ cause: signal?.aborted ? "cancelled" : "git_failure",
23
29
  error: `Unable to resolve base=${base}: ${String(error)}`,
24
30
  };
25
31
  }