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,169 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { parse, resolve, sep } from "node:path";
3
+ import { TIMEOUT_MS } from "../constants.ts";
4
+ import type { GitExec } from "../core/git.ts";
5
+ import type { State } from "../jev/types.ts";
6
+ import type { Cause } from "../result-types.ts";
7
+
8
+ export type EvidenceContext = {
9
+ authority: { path: string; origin: "host" | "server" };
10
+ authorityReason?: string;
11
+ requestedRoot?: string;
12
+ effectiveRoot?: {
13
+ path: string;
14
+ origin: "host" | "server" | "override";
15
+ commonDir?: string;
16
+ };
17
+ requestedBase?: string;
18
+ resolvedBase?: string;
19
+ };
20
+ export interface ContextDependencies {
21
+ exec: GitExec;
22
+ signal?: AbortSignal;
23
+ origin?: "host" | "server";
24
+ lstat?: typeof lstat;
25
+ realpath?: typeof realpath;
26
+ }
27
+ export type EvidenceContextResult =
28
+ | { ok: true; cwd: string; context: EvidenceContext }
29
+ | { ok: false; error: string; cause: Cause; context: EvidenceContext };
30
+
31
+ /** Bind each judgment (including controls) to its admitted per-call provenance. */
32
+ export function withEvidenceContext(
33
+ state: State,
34
+ context: EvidenceContext,
35
+ ): State {
36
+ const provenance = {
37
+ ...context,
38
+ authority: { ...context.authority },
39
+ ...(context.effectiveRoot
40
+ ? { effectiveRoot: { ...context.effectiveRoot } }
41
+ : {}),
42
+ };
43
+ const evidence = state.evidence;
44
+ // A producer's non-object evidence is content, not a metadata container.
45
+ if (
46
+ evidence !== undefined &&
47
+ (evidence === null ||
48
+ typeof evidence !== "object" ||
49
+ Array.isArray(evidence))
50
+ )
51
+ return { ...state, evidenceContext: provenance };
52
+ return { ...state, evidence: { ...evidence, context: provenance } };
53
+ }
54
+
55
+ /** Metadata-only admission. No evidence, commands, caches or judgments precede this. */
56
+ export async function resolveEvidenceContext(
57
+ initialCwd: string,
58
+ root: string | undefined,
59
+ deps: ContextDependencies,
60
+ ): Promise<EvidenceContextResult> {
61
+ const canonical = deps.realpath ?? realpath;
62
+ const inspect = deps.lstat ?? lstat;
63
+ let initial: string;
64
+ try {
65
+ initial = await canonical(initialCwd);
66
+ } catch {
67
+ return {
68
+ ok: false,
69
+ cause: "file_unavailable",
70
+ error: "Initial evidence authority cannot be canonicalized",
71
+ context: {
72
+ authority: { path: "", origin: deps.origin ?? "host" },
73
+ authorityReason: "Initial evidence authority cannot be canonicalized",
74
+ ...(root !== undefined ? { requestedRoot: root } : {}),
75
+ },
76
+ };
77
+ }
78
+ const origin = deps.origin ?? "host";
79
+ const context: EvidenceContext = {
80
+ authority: { path: initial, origin },
81
+ ...(root !== undefined ? { requestedRoot: root } : {}),
82
+ };
83
+ if (root === undefined) {
84
+ context.effectiveRoot = { path: initial, origin };
85
+ return { ok: true, cwd: initial, context };
86
+ }
87
+ const refuse = (
88
+ reason: string,
89
+ cause: Cause = "invalid_root",
90
+ ): EvidenceContextResult => ({
91
+ ok: false,
92
+ cause,
93
+ error: `Invalid root: ${reason}`,
94
+ context,
95
+ });
96
+ if (!root.trim()) return refuse("provide a non-empty directory");
97
+ if (root.split(/[\\/]/).includes(".."))
98
+ return refuse("parent traversal is forbidden");
99
+ const candidate = resolve(initial, root);
100
+ try {
101
+ let component = parse(candidate).root;
102
+ for (const part of candidate
103
+ .slice(component.length)
104
+ .split(sep)
105
+ .filter(Boolean)) {
106
+ component = resolve(component, part);
107
+ const metadata = await inspect(component);
108
+ if (metadata.isSymbolicLink())
109
+ return refuse("symbolic links are forbidden");
110
+ if (!metadata.isDirectory())
111
+ return refuse("root must be an existing directory");
112
+ }
113
+ const path = await canonical(candidate);
114
+ const git = async (cwd: string, args: string[]) => {
115
+ const result = await deps.exec("git", args, {
116
+ cwd,
117
+ timeout: TIMEOUT_MS,
118
+ signal: deps.signal,
119
+ });
120
+ if (result.code || result.killed)
121
+ throw new Error("Git metadata unavailable");
122
+ return args.includes("-z")
123
+ ? result.stdout
124
+ : result.stdout.replace(/\r?\n$/, "");
125
+ };
126
+ const initialTop = await canonical(
127
+ await git(initial, ["rev-parse", "--show-toplevel"]),
128
+ );
129
+ const candidateTop = await canonical(
130
+ await git(path, ["rev-parse", "--show-toplevel"]),
131
+ );
132
+ if (candidateTop !== path)
133
+ return refuse("root must equal a Git top-level directory");
134
+ const common = await canonical(
135
+ resolve(initial, await git(initial, ["rev-parse", "--git-common-dir"])),
136
+ );
137
+ const candidateCommon = await canonical(
138
+ resolve(path, await git(path, ["rev-parse", "--git-common-dir"])),
139
+ );
140
+ if (candidateCommon !== common)
141
+ return refuse("root belongs to a different repository");
142
+ const listed = await git(initial, [
143
+ "worktree",
144
+ "list",
145
+ "--porcelain",
146
+ "-z",
147
+ ]);
148
+ const registered = listed.split("\0\0").some((block) => {
149
+ const fields = block.split("\0");
150
+ const worktree = fields
151
+ .find((field) => field.startsWith("worktree "))
152
+ ?.slice(9);
153
+ return (
154
+ worktree !== undefined &&
155
+ resolve(worktree) === path &&
156
+ !fields.some((field) => /^prunable(?: |$)/.test(field))
157
+ );
158
+ });
159
+ if (path !== initialTop && !registered)
160
+ return refuse("root is not a registered live worktree");
161
+ context.effectiveRoot = { path, origin: "override", commonDir: common };
162
+ return { ok: true, cwd: path, context };
163
+ } catch (error) {
164
+ return refuse(
165
+ `cannot admit directory (${error instanceof Error ? error.message : String(error)})`,
166
+ deps.signal?.aborted ? "cancelled" : "invalid_root",
167
+ );
168
+ }
169
+ }
@@ -19,7 +19,9 @@ import {
19
19
  import { createExcerptCollector } from "../core/find.ts";
20
20
  import type { GitExec } from "../core/git.ts";
21
21
  import type { FileIdentity } from "../core/integrity.ts";
22
+ import { isSecretPath, secretRefusal } from "../core/secret-path.ts";
22
23
  import type { Result } from "../result.ts";
24
+ import type { Cause } from "../result-types.ts";
23
25
  import { createUtf8Decoder, decodeUtf8 } from "./utf8.ts";
24
26
 
25
27
  export async function checkFileAdmission(
@@ -30,11 +32,22 @@ export async function checkFileAdmission(
30
32
  inventory?: ReadonlySet<string>,
31
33
  ): Promise<Result<object>> {
32
34
  if (path.split(/[\\/]/).includes(".git"))
33
- return { ok: false, error: `${path}: .git metadata: not sent to Jev` };
35
+ return {
36
+ ok: false,
37
+ cause: "forbidden_path",
38
+ error: `${path}: .git metadata: not sent to Jev`,
39
+ };
40
+ // Before any inventory lookup or read: secret names are never admitted.
41
+ if (isSecretPath(path))
42
+ return { ok: false, cause: "secret_pattern", error: secretRefusal(path) };
34
43
  if (inventory)
35
44
  return inventory.has(path)
36
45
  ? { ok: true }
37
- : { ok: false, error: `${path}: gitignored: not sent to Jev` };
46
+ : {
47
+ ok: false,
48
+ cause: "ignored_path",
49
+ error: `${path}: outside admitted inventory: not sent to Jev`,
50
+ };
38
51
  if (!exec) return { ok: true };
39
52
  const listed = await exec(
40
53
  "git",
@@ -50,9 +63,17 @@ export async function checkFileAdmission(
50
63
  { cwd, timeout: TIMEOUT_MS, signal },
51
64
  );
52
65
  if (listed.killed)
53
- return { ok: false, error: `${path}: file admission interrupted` };
66
+ return {
67
+ ok: false,
68
+ cause: signal?.aborted ? "cancelled" : "git_failure",
69
+ error: `${path}: file admission interrupted`,
70
+ };
54
71
  if (listed.code === 0 && !listed.stdout.split("\0").includes(path))
55
- return { ok: false, error: `${path}: gitignored: not sent to Jev` };
72
+ return {
73
+ ok: false,
74
+ cause: "ignored_path",
75
+ error: `${path}: gitignored: not sent to Jev`,
76
+ };
56
77
  return { ok: true };
57
78
  }
58
79
 
@@ -64,6 +85,7 @@ export async function resolveInsideRepo(
64
85
  if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
65
86
  return {
66
87
  ok: false,
88
+ cause: "forbidden_path",
67
89
  error: `Path must remain inside the repository: ${path}.`,
68
90
  };
69
91
  try {
@@ -71,23 +93,39 @@ export async function resolveInsideRepo(
71
93
  const requested = resolve(base, path);
72
94
  const rel = relative(base, requested);
73
95
  if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
74
- return { ok: false, error: `Path escapes the repository: ${path}.` };
96
+ return {
97
+ ok: false,
98
+ cause: "forbidden_path",
99
+ error: `Path escapes the repository: ${path}.`,
100
+ };
75
101
  let current = base;
76
102
  for (const part of rel.split(sep).filter(Boolean)) {
77
103
  current = resolve(current, part);
78
104
  if ((await lstat(current)).isSymbolicLink()) {
79
105
  if (options.allowLeafSymlink && current === requested)
80
106
  return { ok: true, abs: requested, rel: rel.replaceAll(sep, "/") };
81
- return { ok: false, error: `Symbolic links are not read: ${path}.` };
107
+ return {
108
+ ok: false,
109
+ cause: "symlink",
110
+ error: `Symbolic links are not read: ${path}.`,
111
+ };
82
112
  }
83
113
  }
84
114
  const abs = await realpath(requested);
85
115
  const actual = relative(base, abs);
86
116
  if (actual === ".." || actual.startsWith(`..${sep}`) || isAbsolute(actual))
87
- return { ok: false, error: `Path escapes the repository: ${path}.` };
117
+ return {
118
+ ok: false,
119
+ cause: "forbidden_path",
120
+ error: `Path escapes the repository: ${path}.`,
121
+ };
88
122
  return { ok: true, abs, rel: actual.replaceAll(sep, "/") };
89
123
  } catch (error) {
90
- return { ok: false, error: `Cannot resolve ${path}: ${String(error)}` };
124
+ return {
125
+ ok: false,
126
+ cause: "file_unavailable",
127
+ error: `Cannot resolve ${path}: ${String(error)}`,
128
+ };
91
129
  }
92
130
  }
93
131
 
@@ -125,6 +163,7 @@ export async function admitGitPath(
125
163
  if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
126
164
  return {
127
165
  ok: false,
166
+ cause: "forbidden_path",
128
167
  error: `Path must remain inside the repository: ${path}.`,
129
168
  };
130
169
  const admission = await checkFileAdmission(cwd, path, undefined, signal);
@@ -138,13 +177,21 @@ export async function admitGitPath(
138
177
  { cwd, timeout: TIMEOUT_MS, signal },
139
178
  );
140
179
  if (tree.code !== 0 || tree.killed)
141
- return { ok: false, error: `${path}: not a file at comparison base` };
180
+ return {
181
+ ok: false,
182
+ cause: signal?.aborted ? "cancelled" : "git_failure",
183
+ error: `${path}: comparison base tree unavailable`,
184
+ };
142
185
  entry = tree.stdout
143
186
  .split("\0")
144
187
  .find((item) => item.slice(item.indexOf("\t") + 1) === path);
145
188
  }
146
189
  if (!entry || !/^(?:100644|100755|120000) blob /.test(entry))
147
- return { ok: false, error: `${path}: not a file at comparison base` };
190
+ return {
191
+ ok: false,
192
+ cause: "file_unavailable",
193
+ error: `${path}: not a file at comparison base`,
194
+ };
148
195
  return { ok: true };
149
196
  }
150
197
 
@@ -178,7 +225,11 @@ export async function openRepoFile(
178
225
  const stat = await handle.stat();
179
226
  if (!stat.isFile()) {
180
227
  await handle.close();
181
- return { ok: false, error: `Not a file: ${path}.` };
228
+ return {
229
+ ok: false,
230
+ cause: "file_unavailable",
231
+ error: `Not a file: ${path}.`,
232
+ };
182
233
  }
183
234
  return { ok: true, handle, size: stat.size };
184
235
  } catch (error) {
@@ -186,7 +237,15 @@ export async function openRepoFile(
186
237
  throw error;
187
238
  }
188
239
  } catch (error) {
189
- return { ok: false, error: `Cannot read ${path}: ${String(error)}` };
240
+ return {
241
+ ok: false,
242
+ cause: options.signal?.aborted
243
+ ? "cancelled"
244
+ : (error as NodeJS.ErrnoException).code === "ELOOP"
245
+ ? "symlink"
246
+ : "file_unavailable",
247
+ error: `Cannot read ${path}: ${String(error)}`,
248
+ };
190
249
  }
191
250
  }
192
251
 
@@ -207,9 +266,27 @@ export async function collectFiles(
207
266
  skipped: string[];
208
267
  }>
209
268
  > {
269
+ for (const path of paths) {
270
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
271
+ return {
272
+ ok: false,
273
+ cause: "forbidden_path",
274
+ error: `Internal URLs are not files; use read for ${path}.`,
275
+ };
276
+ const admission = await resolveInsideRepo(cwd, path);
277
+ if (!admission.ok) return admission;
278
+ }
279
+ paths = [
280
+ ...new Set(
281
+ paths.map((path) =>
282
+ relative(cwd, resolve(cwd, path)).split("\\").join("/"),
283
+ ),
284
+ ),
285
+ ].sort();
210
286
  if (paths.length > ASK_MAX_FILES)
211
287
  return {
212
288
  ok: false,
289
+ cause: "evidence_too_large",
213
290
  error: `paths exceeds ${ASK_MAX_FILES} files; split paths into several calls.`,
214
291
  };
215
292
  const results = await Promise.all(
@@ -217,6 +294,7 @@ export async function collectFiles(
217
294
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
218
295
  return {
219
296
  ok: false as const,
297
+ cause: "forbidden_path" as const,
220
298
  error: `Internal URLs are not files; use read for ${path}.`,
221
299
  };
222
300
  let handle: FileHandle | undefined;
@@ -247,23 +325,30 @@ export async function collectFiles(
247
325
  if (count === bytes.length)
248
326
  return {
249
327
  ok: false as const,
328
+ cause: "evidence_too_large" as const,
250
329
  error: `File exceeds ${STATE_MAX_CHARS} chars: ${path}; files are never truncated. Split paths into several calls.`,
251
330
  };
252
331
  const decoded = decodeUtf8(bytes.subarray(0, count));
253
- if (!decoded.ok)
332
+ if (!decoded.ok || (decoded.ok && decoded.text.includes("\0")))
254
333
  return {
255
334
  ok: false as const,
256
335
  invalidUtf8: true,
336
+ cause: "binary_or_non_utf8" as const,
257
337
  error: `${path} is not UTF-8 text; provide a UTF-8 text file instead.`,
258
338
  };
259
339
  const text = decoded.text;
260
340
  if (count === bytes.length || text.length > STATE_MAX_CHARS)
261
341
  return {
262
342
  ok: false as const,
343
+ cause: "evidence_too_large" as const,
263
344
  error: `File exceeds ${STATE_MAX_CHARS} chars: ${path}; files are never truncated. Split paths into several calls.`,
264
345
  };
265
346
  if (!text.length && !options.allowEmpty)
266
- return { ok: false as const, error: `${path} is empty.` };
347
+ return {
348
+ ok: false as const,
349
+ cause: "empty_required" as const,
350
+ error: `${path} is empty.`,
351
+ };
267
352
  return {
268
353
  ok: true as const,
269
354
  path,
@@ -282,6 +367,9 @@ export async function collectFiles(
282
367
  } catch (error) {
283
368
  return {
284
369
  ok: false as const,
370
+ cause: signal?.aborted
371
+ ? ("cancelled" as const)
372
+ : ("file_unavailable" as const),
285
373
  error: `Cannot read ${path}: ${String(error)}`,
286
374
  };
287
375
  } finally {
@@ -322,23 +410,31 @@ export async function collectSearchExcerpts(
322
410
  pointers: Record<string, string>;
323
411
  ignored: string[];
324
412
  limits: { path: string; reason: string }[];
413
+ omissions: { path: string; cause: Cause; reason: string }[];
325
414
  }>
326
415
  > {
327
416
  const files: Record<string, string> = {},
328
417
  pointers: Record<string, string> = {};
329
418
  const ignored: string[] = [];
330
419
  const limits: { path: string; reason: string }[] = [];
420
+ const omissions: { path: string; cause: Cause; reason: string }[] = [];
331
421
  const results = await Promise.all(
332
422
  paths.map(async (path) => {
333
423
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
334
424
  return {
335
425
  ok: false as const,
426
+ cause: "forbidden_path" as const,
336
427
  error: `Internal URLs are not files; use read for ${path}.`,
337
428
  };
338
429
  try {
339
430
  const location = await admitRepoMetadata(cwd, path, exec, signal);
340
431
  if (!location.ok) {
341
432
  ignored.push(path);
433
+ omissions.push({
434
+ path,
435
+ cause: location.cause ?? "file_unavailable",
436
+ reason: location.error,
437
+ });
342
438
  return { ok: true as const };
343
439
  }
344
440
  if ((await lstat(location.abs)).isSymbolicLink()) {
@@ -350,6 +446,11 @@ export async function collectSearchExcerpts(
350
446
  const opened = await openRepoFile(cwd, path, { exec, signal });
351
447
  if (!opened.ok) {
352
448
  ignored.push(path);
449
+ omissions.push({
450
+ path,
451
+ cause: opened.cause ?? "file_unavailable",
452
+ reason: opened.error,
453
+ });
353
454
  return { ok: true as const };
354
455
  }
355
456
  const handle = opened.handle;
@@ -367,6 +468,11 @@ export async function collectSearchExcerpts(
367
468
  const decoded = decode(chunk as Buffer);
368
469
  if (!decoded.ok) {
369
470
  limits.push({ path, reason: decoded.error });
471
+ omissions.push({
472
+ path,
473
+ cause: "binary_or_non_utf8",
474
+ reason: decoded.error,
475
+ });
370
476
  ignored.push(path);
371
477
  stream.destroy();
372
478
  return { ok: true as const };
@@ -374,6 +480,11 @@ export async function collectSearchExcerpts(
374
480
  const text = decoded.text;
375
481
  if (text.includes("\0")) {
376
482
  ignored.push(path);
483
+ omissions.push({
484
+ path,
485
+ cause: "binary_or_non_utf8",
486
+ reason: "Binary NUL content is not sent to Jev",
487
+ });
377
488
  stream.destroy();
378
489
  return { ok: true as const };
379
490
  }
@@ -383,6 +494,11 @@ export async function collectSearchExcerpts(
383
494
  const end = decode();
384
495
  if (!end.ok) {
385
496
  limits.push({ path, reason: end.error });
497
+ omissions.push({
498
+ path,
499
+ cause: "binary_or_non_utf8",
500
+ reason: end.error,
501
+ });
386
502
  ignored.push(path);
387
503
  return { ok: true as const };
388
504
  }
@@ -391,6 +507,11 @@ export async function collectSearchExcerpts(
391
507
  const excerpt = content.finish();
392
508
  if (!excerpt.length) {
393
509
  ignored.push(path);
510
+ omissions.push({
511
+ path,
512
+ cause: "collection_empty",
513
+ reason: "No non-empty search excerpt was collected",
514
+ });
394
515
  return { ok: true as const };
395
516
  }
396
517
  files[path] = excerpt;
@@ -398,8 +519,17 @@ export async function collectSearchExcerpts(
398
519
  return { ok: true as const };
399
520
  } catch (error) {
400
521
  if (signal?.aborted)
401
- return { ok: false as const, error: String(error) };
522
+ return {
523
+ ok: false as const,
524
+ cause: "cancelled" as const,
525
+ error: String(error),
526
+ };
402
527
  ignored.push(path);
528
+ omissions.push({
529
+ path,
530
+ cause: "file_unavailable",
531
+ reason: String(error),
532
+ });
403
533
  return { ok: true as const };
404
534
  }
405
535
  }),
@@ -407,5 +537,5 @@ export async function collectSearchExcerpts(
407
537
  const failure = results.find((result) => !result.ok);
408
538
  return failure && !failure.ok
409
539
  ? failure
410
- : { ok: true, files, pointers, ignored, limits };
540
+ : { ok: true, files, pointers, ignored, limits, omissions };
411
541
  }
@@ -1,3 +1,4 @@
1
+ import { isAbsolute } from "node:path";
1
2
  import type { FileFinder, GrepCursor } from "@ff-labs/fff-node";
2
3
  import {
3
4
  FIND_GREP_PAGE_SIZE,
@@ -6,6 +7,7 @@ import {
6
7
  } from "../constants.ts";
7
8
  import { pathAllowed } from "../core/find.ts";
8
9
  import type { GitExec } from "../core/git.ts";
10
+ import { isSecretPath, SECRET_NAME_GLOBS } from "../core/secret-path.ts";
9
11
  import type { Result } from "../result.ts";
10
12
 
11
13
  export async function prefilter(
@@ -17,7 +19,24 @@ export async function prefilter(
17
19
  exclude?: readonly string[],
18
20
  signal?: AbortSignal,
19
21
  native = true,
20
- ): Promise<Result<{ paths: string[]; scopeFiles: number }>> {
22
+ ): Promise<Result<{ paths: string[]; scopeFiles: number; secret: string[] }>> {
23
+ const restrictions = [
24
+ ...(typeof scope === "string" ? [scope] : (scope ?? [])),
25
+ ...(exclude ?? []),
26
+ ];
27
+ for (const restriction of restrictions) {
28
+ if (
29
+ isAbsolute(restriction) ||
30
+ restriction
31
+ .split(/[\\/]/)
32
+ .some((segment) => segment === ".." || segment === ".git")
33
+ )
34
+ return {
35
+ ok: false,
36
+ cause: "forbidden_path",
37
+ error: `Discovery restriction must remain inside admitted files: ${restriction}`,
38
+ };
39
+ }
21
40
  try {
22
41
  const listed = await exec(
23
42
  "git",
@@ -27,19 +46,25 @@ export async function prefilter(
27
46
  if (listed.killed || listed.code !== 0)
28
47
  return {
29
48
  ok: false,
49
+ cause: signal?.aborted ? "cancelled" : "git_failure",
30
50
  error: `Cannot list repository files: ${listed.stderr || "git interrupted"}`,
31
51
  };
32
- const paths = [
52
+ const inScope = [
33
53
  ...new Set(
34
54
  listed.stdout
35
55
  .split("\0")
36
56
  .filter((path) => path && pathAllowed(path, scope, exclude)),
37
57
  ),
38
58
  ];
39
- if (!paths.length) return { ok: true, paths: [], scopeFiles: 0 };
59
+ // Secret-named files are neither ranked, read nor named to Jev.
60
+ const secret = inScope.filter(isSecretPath);
61
+ const paths = inScope.filter((path) => !isSecretPath(path));
62
+ if (!paths.length) return { ok: true, paths: [], scopeFiles: 0, secret };
40
63
  const allowed = new Set(paths);
41
64
  let finder: FileFinder | undefined;
42
- if (native) {
65
+ // The native index cannot skip files, so it never scans a scope that
66
+ // holds secret-named files; rg excludes them by name instead.
67
+ if (native && !secret.length) {
43
68
  try {
44
69
  // Static loading would break hosts where this optional native package is absent.
45
70
  const { FileFinder } = await import("@ff-labs/fff-node");
@@ -97,8 +122,8 @@ export async function prefilter(
97
122
  "--files-with-matches",
98
123
  "--hidden",
99
124
  "--null",
100
- "--fixed-strings",
101
- "--ignore-case",
125
+ "--glob-case-insensitive",
126
+ ...SECRET_NAME_GLOBS.flatMap((glob) => ["--glob", `!${glob}`]),
102
127
  "--",
103
128
  word,
104
129
  ".",
@@ -141,11 +166,16 @@ export async function prefilter(
141
166
  .slice(0, limit)
142
167
  .map((item) => item.path),
143
168
  scopeFiles: paths.length,
169
+ secret,
144
170
  };
145
171
  } finally {
146
172
  finder?.destroy();
147
173
  }
148
174
  } catch (error) {
149
- return { ok: false, error: `Cannot prefilter files: ${String(error)}` };
175
+ return {
176
+ ok: false,
177
+ cause: signal?.aborted ? "cancelled" : "git_failure",
178
+ error: `Cannot prefilter files: ${String(error)}`,
179
+ };
150
180
  }
151
181
  }
@@ -16,16 +16,22 @@ export async function resolveBase(
16
16
  signal,
17
17
  });
18
18
  if (result.killed)
19
- return { ok: false, error: "Git interrupted (cancelled or timed out)." };
19
+ return {
20
+ ok: false,
21
+ cause: signal?.aborted ? "cancelled" : "git_failure",
22
+ error: "Git interrupted (cancelled or timed out).",
23
+ };
20
24
  if (result.code === 0 && result.stdout.trim())
21
25
  return { ok: true, base: result.stdout.trim() };
22
26
  return {
23
27
  ok: false,
28
+ cause: "invalid_base",
24
29
  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()}` : ""}`,
25
30
  };
26
31
  } catch (error) {
27
32
  return {
28
33
  ok: false,
34
+ cause: signal?.aborted ? "cancelled" : "git_failure",
29
35
  error: `Unable to resolve base=${base}: ${String(error)}`,
30
36
  };
31
37
  }