jev-agent-tools 0.1.4 → 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 (180) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/CONTRIBUTING.md +43 -0
  3. package/README.md +58 -17
  4. package/SECURITY.md +43 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +198 -0
  7. package/dist/adapters/ask-proof.js +200 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +234 -0
  11. package/dist/adapters/docs.js +192 -0
  12. package/dist/adapters/evidence-context.js +119 -0
  13. package/dist/adapters/exec.js +207 -0
  14. package/dist/adapters/files.js +418 -0
  15. package/dist/adapters/find.js +150 -0
  16. package/dist/adapters/git-base.js +32 -0
  17. package/dist/adapters/git-inventory.js +71 -0
  18. package/dist/adapters/git.js +483 -0
  19. package/dist/adapters/locate-file.js +197 -0
  20. package/dist/adapters/output-lines.js +46 -0
  21. package/dist/adapters/private-storage.js +106 -0
  22. package/dist/adapters/risk-callers.js +429 -0
  23. package/dist/adapters/runner-version.js +78 -0
  24. package/dist/adapters/shell.js +92 -0
  25. package/dist/adapters/syntax.js +187 -0
  26. package/dist/adapters/test-inventory.js +139 -0
  27. package/dist/adapters/usage.js +20 -0
  28. package/dist/adapters/utf8.js +47 -0
  29. package/dist/configuration.js +267 -0
  30. package/dist/constants.js +140 -0
  31. package/dist/core/ask-closure.js +282 -0
  32. package/dist/core/ask-proof.js +1 -0
  33. package/dist/core/ask-references.js +278 -0
  34. package/dist/core/asks.js +507 -0
  35. package/dist/core/batches.js +65 -0
  36. package/dist/core/command-output.js +224 -0
  37. package/dist/core/diff.js +178 -0
  38. package/dist/core/docs.js +302 -0
  39. package/dist/core/find.js +108 -0
  40. package/dist/core/git.js +1 -0
  41. package/dist/core/imports.js +550 -0
  42. package/dist/core/integrity.js +45 -0
  43. package/dist/core/lexical.js +132 -0
  44. package/dist/core/locate.js +169 -0
  45. package/dist/core/output.js +137 -0
  46. package/dist/core/pointer.js +29 -0
  47. package/dist/core/result-report.js +302 -0
  48. package/dist/core/risk-callers.js +851 -0
  49. package/dist/core/runner-version.js +45 -0
  50. package/dist/core/secret-path.js +34 -0
  51. package/dist/core/sections.js +230 -0
  52. package/dist/core/state.js +51 -0
  53. package/dist/core/syntax.js +1 -0
  54. package/dist/core/test-commands.js +334 -0
  55. package/dist/core/test-coverage.js +74 -0
  56. package/dist/core/test-discovery.js +1382 -0
  57. package/dist/core/test-evidence.js +527 -0
  58. package/dist/core/test-state.js +81 -0
  59. package/dist/core/truncate.js +12 -0
  60. package/dist/core/units.js +349 -0
  61. package/dist/describe.js +23 -0
  62. package/dist/guide.js +33 -0
  63. package/dist/host.js +24 -0
  64. package/dist/jev/client.js +456 -0
  65. package/dist/jev/pool.js +54 -0
  66. package/dist/jev/types.js +1 -0
  67. package/dist/mcp/main.js +124 -0
  68. package/dist/mcp/protocol.js +210 -0
  69. package/dist/mcp/tools.js +129 -0
  70. package/dist/presets/docs.js +62 -0
  71. package/dist/presets/risk.js +179 -0
  72. package/dist/presets/spec.js +81 -0
  73. package/dist/presets/witnesses.js +249 -0
  74. package/dist/render.js +114 -0
  75. package/dist/report-schema.js +1356 -0
  76. package/dist/result-types.js +1 -0
  77. package/dist/result.js +3 -0
  78. package/dist/runtime.js +1 -0
  79. package/dist/session.js +147 -0
  80. package/dist/texts/ask-files.js +3 -0
  81. package/dist/texts/ask.js +4 -0
  82. package/dist/texts/check-diff.js +20 -0
  83. package/dist/texts/configuration.js +1 -0
  84. package/dist/texts/find.js +19 -0
  85. package/dist/texts/guide.js +3 -0
  86. package/dist/texts/instructions.js +72 -0
  87. package/dist/texts/locate.js +15 -0
  88. package/dist/texts/select-tests.js +4 -0
  89. package/dist/tools/ask-files.js +450 -0
  90. package/dist/tools/ask-schema.js +70 -0
  91. package/dist/tools/ask.js +1147 -0
  92. package/dist/tools/check-diff.js +594 -0
  93. package/dist/tools/docs-check.js +408 -0
  94. package/dist/tools/find.js +682 -0
  95. package/dist/tools/locate.js +602 -0
  96. package/dist/tools/review-report.js +230 -0
  97. package/dist/tools/select-tests.js +821 -0
  98. package/dist/tools/spec-check.js +263 -0
  99. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  100. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  101. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  102. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  103. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  104. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  105. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  106. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  107. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  108. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  109. package/docs/agent-instructions.md +120 -0
  110. package/docs/design.md +16 -4
  111. package/docs/mcp.md +233 -0
  112. package/docs/tools/jev_ask.md +8 -5
  113. package/docs/tools/jev_ask_files.md +2 -1
  114. package/docs/tools/jev_check_diff.md +4 -1
  115. package/docs/tools/jev_find_files.md +2 -1
  116. package/docs/tools/jev_locate_in_file.md +5 -0
  117. package/docs/tools/jev_select_tests.md +4 -1
  118. package/package.json +19 -4
  119. package/rules/jev-ask.md +22 -1
  120. package/server.json +57 -0
  121. package/src/adapters/ask-files.ts +11 -3
  122. package/src/adapters/ask-proof.ts +69 -11
  123. package/src/adapters/canonical-path.ts +18 -0
  124. package/src/adapters/command.ts +102 -36
  125. package/src/adapters/docs.ts +33 -14
  126. package/src/adapters/evidence-context.ts +169 -0
  127. package/src/adapters/exec.ts +226 -0
  128. package/src/adapters/files.ts +146 -16
  129. package/src/adapters/find.ts +37 -7
  130. package/src/adapters/git-base.ts +7 -1
  131. package/src/adapters/git.ts +61 -8
  132. package/src/adapters/locate-file.ts +51 -9
  133. package/src/adapters/private-storage.ts +155 -0
  134. package/src/adapters/risk-callers.ts +7 -2
  135. package/src/adapters/shell.ts +113 -0
  136. package/src/adapters/test-inventory.ts +12 -4
  137. package/src/configuration.ts +55 -14
  138. package/src/constants.ts +37 -5
  139. package/src/core/ask-references.ts +262 -146
  140. package/src/core/asks.ts +79 -7
  141. package/src/core/command-output.ts +17 -1
  142. package/src/core/import-boundaries.ts +8 -3
  143. package/src/core/locate.ts +8 -5
  144. package/src/core/output.ts +34 -0
  145. package/src/core/result-report.ts +410 -0
  146. package/src/core/secret-path.ts +37 -0
  147. package/src/core/state.ts +8 -1
  148. package/src/core/units.ts +3 -2
  149. package/src/host.ts +11 -0
  150. package/src/index.ts +3 -0
  151. package/src/jev/client.ts +66 -16
  152. package/src/jev/types.ts +24 -3
  153. package/src/mcp/main.ts +135 -0
  154. package/src/mcp/protocol.ts +332 -0
  155. package/src/mcp/tools.ts +179 -0
  156. package/src/render.ts +109 -0
  157. package/src/report-schema.ts +1380 -0
  158. package/src/result-types.ts +234 -0
  159. package/src/result.ts +4 -1
  160. package/src/runtime.ts +6 -0
  161. package/src/session.ts +59 -0
  162. package/src/setup.ts +13 -5
  163. package/src/texts/ask-files.ts +4 -1
  164. package/src/texts/ask.ts +8 -1
  165. package/src/texts/check-diff.ts +7 -4
  166. package/src/texts/find.ts +8 -2
  167. package/src/texts/guide.ts +8 -16
  168. package/src/texts/instructions.ts +98 -0
  169. package/src/texts/locate.ts +8 -2
  170. package/src/texts/run-end.ts +2 -2
  171. package/src/texts/select-tests.ts +4 -1
  172. package/src/tools/ask-files.ts +311 -18
  173. package/src/tools/ask.ts +722 -95
  174. package/src/tools/check-diff.ts +337 -31
  175. package/src/tools/docs-check.ts +241 -38
  176. package/src/tools/find.ts +389 -29
  177. package/src/tools/locate.ts +387 -25
  178. package/src/tools/review-report.ts +308 -0
  179. package/src/tools/select-tests.ts +484 -23
  180. package/src/tools/spec-check.ts +194 -19
@@ -0,0 +1,483 @@
1
+ import { lstat, readlink } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { GIT_BLOB_BATCH_SIZE, STATE_MAX_CHARS, TIMEOUT_MS, } from "../constants.js";
4
+ import { parseDiff } from "../core/diff.js";
5
+ import { isSecretPath } from "../core/secret-path.js";
6
+ import { buildUnits, } from "../core/units.js";
7
+ import { admitGitPath, admitRepoMetadata, openRepoFile } from "./files.js";
8
+ import { shareGitInventory } from "./git-inventory.js";
9
+ import { loadSyntaxParser } from "./syntax.js";
10
+ import { decodeUtf8, verifyGitUtf8 } from "./utf8.js";
11
+ const config = [
12
+ "-c",
13
+ "color.ui=false",
14
+ "-c",
15
+ "core.quotePath=true",
16
+ "-c",
17
+ "diff.suppressBlankEmpty=false",
18
+ "-c",
19
+ "diff.relative=false",
20
+ "-c",
21
+ "diff.noprefix=false",
22
+ "-c",
23
+ "diff.mnemonicPrefix=false",
24
+ "-c",
25
+ "diff.algorithm=myers",
26
+ "-c",
27
+ "diff.indentHeuristic=false",
28
+ ];
29
+ async function git(exec, options, args) {
30
+ try {
31
+ options.signal?.throwIfAborted();
32
+ const result = await exec("git", [...config, ...args], {
33
+ cwd: options.cwd,
34
+ timeout: TIMEOUT_MS,
35
+ signal: options.signal,
36
+ });
37
+ if (result.killed)
38
+ return {
39
+ ok: false,
40
+ cause: options.signal?.aborted ? "cancelled" : "git_failure",
41
+ error: "Git interrupted (cancelled or timed out).",
42
+ };
43
+ if (result.code !== 0)
44
+ return {
45
+ ok: false,
46
+ cause: "git_failure",
47
+ error: `Git failed: ${result.stderr}`,
48
+ };
49
+ return { ok: true, text: result.stdout };
50
+ }
51
+ catch (error) {
52
+ return {
53
+ ok: false,
54
+ cause: options.signal?.aborted ? "cancelled" : "git_failure",
55
+ error: `Unable to execute git: ${String(error)}`,
56
+ };
57
+ }
58
+ }
59
+ async function readWorktree(options, exec, path, inventory) {
60
+ let handle;
61
+ try {
62
+ options.signal?.throwIfAborted();
63
+ const location = await admitRepoMetadata(options.cwd, path, exec, options.signal, inventory);
64
+ if (!location.ok)
65
+ return { text: "", binary: false, limitation: "unreadable" };
66
+ const stat = await lstat(location.abs);
67
+ if (stat.isSymbolicLink())
68
+ return { text: await readlink(location.abs), binary: false };
69
+ if (stat.size > STATE_MAX_CHARS)
70
+ return { text: "", binary: false, limitation: "too_large" };
71
+ const opened = await openRepoFile(options.cwd, path, {
72
+ exec,
73
+ signal: options.signal,
74
+ inventory,
75
+ });
76
+ if (!opened.ok)
77
+ return { text: "", binary: false, limitation: "unreadable" };
78
+ handle = opened.handle;
79
+ const buffer = Buffer.alloc(STATE_MAX_CHARS + 1);
80
+ let offset = 0;
81
+ while (offset < buffer.length) {
82
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null);
83
+ if (!bytesRead)
84
+ break;
85
+ offset += bytesRead;
86
+ }
87
+ if (offset > STATE_MAX_CHARS)
88
+ return { text: "", binary: false, limitation: "too_large" };
89
+ const bytes = buffer.subarray(0, offset);
90
+ const decoded = decodeUtf8(bytes);
91
+ return decoded.ok
92
+ ? { text: decoded.text, binary: bytes.includes(0) }
93
+ : { text: "", binary: false, limitation: "not UTF-8 text" };
94
+ }
95
+ catch {
96
+ return { text: "", binary: false, limitation: "unreadable" };
97
+ }
98
+ finally {
99
+ await handle?.close();
100
+ }
101
+ }
102
+ async function readGitBlobs(exec, options, objects) {
103
+ const blobs = new Map();
104
+ const invalid = new Set();
105
+ for (let offset = 0; offset < objects.length; offset += GIT_BLOB_BATCH_SIZE) {
106
+ const batch = objects.slice(offset, offset + GIT_BLOB_BATCH_SIZE);
107
+ const result = await git(exec, options, [
108
+ "show",
109
+ "--no-ext-diff",
110
+ "--no-textconv",
111
+ "--end-of-options",
112
+ ...batch.map((object) => object.id),
113
+ ]);
114
+ if (!result.ok)
115
+ return result;
116
+ const bytes = Buffer.from(result.text, "utf8");
117
+ if (bytes.length !== batch.reduce((sum, object) => sum + object.size, 0)) {
118
+ // Host decoding can replace invalid UTF-8: never split subsequent blobs at shifted offsets.
119
+ for (const object of batch) {
120
+ const single = await git(exec, options, [
121
+ "show",
122
+ "--no-ext-diff",
123
+ "--no-textconv",
124
+ "--end-of-options",
125
+ object.id,
126
+ ]);
127
+ if (!single.ok)
128
+ return single;
129
+ const decoded = verifyGitUtf8(single.text, object.id, object.size);
130
+ if (decoded.ok)
131
+ blobs.set(object.id, decoded.text);
132
+ else
133
+ invalid.add(object.id);
134
+ }
135
+ continue;
136
+ }
137
+ let position = 0;
138
+ for (const object of batch) {
139
+ const decoded = verifyGitUtf8(bytes.subarray(position, position + object.size).toString("utf8"), object.id, object.size);
140
+ if (decoded.ok)
141
+ blobs.set(object.id, decoded.text);
142
+ else
143
+ invalid.add(object.id);
144
+ position += object.size;
145
+ }
146
+ }
147
+ return { ok: true, blobs, invalid };
148
+ }
149
+ export async function collectDiff(exec, options) {
150
+ exec = shareGitInventory(exec);
151
+ const root = await git(exec, options, ["rev-parse", "--show-toplevel"]);
152
+ if (!root.ok)
153
+ return root;
154
+ const cwd = root.text.replace(/\n$/, "");
155
+ const scoped = { ...options, cwd };
156
+ const location = await git(exec, options, ["rev-parse", "--show-prefix"]);
157
+ if (!location.ok)
158
+ return location;
159
+ const repositoryPrefix = location.text.replace(/\n$/, "");
160
+ const paths = [
161
+ "--",
162
+ ...(options.paths ?? []).map((path) => `:(top,literal)${repositoryPrefix}${path}`),
163
+ ];
164
+ const rawResult = await git(exec, scoped, [
165
+ "diff",
166
+ "--raw",
167
+ "-z",
168
+ "--no-ext-diff",
169
+ "--no-textconv",
170
+ "--ignore-submodules=none",
171
+ "--end-of-options",
172
+ options.base,
173
+ ...paths,
174
+ ]);
175
+ if (!rawResult.ok)
176
+ return rawResult;
177
+ const untrackedResult = await git(exec, scoped, [
178
+ "ls-files",
179
+ "--full-name",
180
+ "--others",
181
+ "--exclude-standard",
182
+ "-z",
183
+ ...paths,
184
+ ]);
185
+ if (!untrackedResult.ok)
186
+ return untrackedResult;
187
+ let candidates;
188
+ try {
189
+ candidates = parseDiff({
190
+ raw: rawResult.text,
191
+ numstat: "",
192
+ patch: "",
193
+ untracked: untrackedResult.text.split("\0").filter(Boolean),
194
+ });
195
+ }
196
+ catch {
197
+ return {
198
+ ok: false,
199
+ cause: "git_failure",
200
+ error: "Inconsistent Git snapshots; collect again.",
201
+ kind: "inconsistent_diff",
202
+ };
203
+ }
204
+ const unsupported = [];
205
+ // Secret-named paths (either side of a rename) stay named exclusions;
206
+ // excluding them from every later pathspec keeps Git from emitting their
207
+ // patch text or pairing them into a rename.
208
+ const secret = candidates
209
+ .filter((file) => isSecretPath(file.path) || isSecretPath(file.oldPath))
210
+ .map((file) => file.path);
211
+ await Promise.all(candidates.map(async (file) => {
212
+ if (secret.includes(file.path))
213
+ return;
214
+ try {
215
+ const stat = await lstat(resolve(cwd, file.path));
216
+ if (!stat.isFile() &&
217
+ !stat.isSymbolicLink() &&
218
+ file.oldMode !== "160000" &&
219
+ file.newMode !== "160000")
220
+ unsupported.push(file.path);
221
+ }
222
+ catch {
223
+ /* Missing tracked paths remain deletions. */
224
+ }
225
+ }));
226
+ for (const path of [...unsupported, ...secret])
227
+ paths.push(`:(top,exclude,literal)${path}`);
228
+ for (const file of candidates)
229
+ if (secret.includes(file.path) && file.oldPath !== file.path)
230
+ paths.push(`:(top,exclude,literal)${file.oldPath}`);
231
+ const prefix = [
232
+ "diff",
233
+ "--no-color",
234
+ "--no-ext-diff",
235
+ "--no-textconv",
236
+ "--find-renames",
237
+ "--src-prefix=a/",
238
+ "--dst-prefix=b/",
239
+ "--no-relative",
240
+ "--line-prefix=",
241
+ "--submodule=short",
242
+ "--ignore-submodules=none",
243
+ ];
244
+ const results = await Promise.all([
245
+ git(exec, scoped, [
246
+ ...prefix,
247
+ "--raw",
248
+ "-z",
249
+ "--end-of-options",
250
+ options.base,
251
+ ...paths,
252
+ ]),
253
+ git(exec, scoped, [
254
+ ...prefix,
255
+ "--numstat",
256
+ "-z",
257
+ "--end-of-options",
258
+ options.base,
259
+ ...paths,
260
+ ]),
261
+ git(exec, scoped, [
262
+ ...prefix,
263
+ "--patch",
264
+ "--unified=8",
265
+ "--end-of-options",
266
+ options.base,
267
+ ...paths,
268
+ ]),
269
+ git(exec, scoped, [
270
+ "ls-files",
271
+ "--full-name",
272
+ "--others",
273
+ "--exclude-standard",
274
+ "-z",
275
+ ...paths,
276
+ ]),
277
+ git(exec, scoped, [
278
+ "ls-files",
279
+ "--full-name",
280
+ "--cached",
281
+ "--others",
282
+ "--exclude-standard",
283
+ "-z",
284
+ ]),
285
+ git(exec, scoped, [
286
+ "ls-tree",
287
+ "-r",
288
+ "--long",
289
+ "-z",
290
+ "--full-tree",
291
+ "--end-of-options",
292
+ options.base,
293
+ ]),
294
+ ]);
295
+ for (const result of results)
296
+ if (!result.ok)
297
+ return result;
298
+ const [raw, numstat, patch, untracked, current, base] = results;
299
+ if (!raw.ok ||
300
+ !numstat.ok ||
301
+ !patch.ok ||
302
+ !untracked.ok ||
303
+ !current.ok ||
304
+ !base.ok)
305
+ return {
306
+ ok: false,
307
+ cause: "git_failure",
308
+ error: "Incomplete Git collection.",
309
+ };
310
+ const currentInventory = new Set(current.text.split("\0").filter(Boolean));
311
+ const baseInventory = new Map();
312
+ for (const entry of base.text.split("\0")) {
313
+ const separator = entry.indexOf("\t");
314
+ if (separator !== -1)
315
+ baseInventory.set(entry.slice(separator + 1), entry);
316
+ }
317
+ let files;
318
+ try {
319
+ files = parseDiff({
320
+ raw: raw.text,
321
+ numstat: numstat.text,
322
+ patch: patch.text,
323
+ verifyPaths: true,
324
+ untracked: untracked.text.split("\0").filter(Boolean),
325
+ });
326
+ }
327
+ catch {
328
+ return {
329
+ ok: false,
330
+ cause: "git_failure",
331
+ error: "Inconsistent Git snapshots; collect again.",
332
+ kind: "inconsistent_diff",
333
+ };
334
+ }
335
+ files.push(...candidates.filter((file) => unsupported.includes(file.path) || secret.includes(file.path)));
336
+ const objects = new Map();
337
+ for (const file of files) {
338
+ if (/^[A?]/.test(file.status) ||
339
+ file.binary ||
340
+ unsupported.includes(file.path) ||
341
+ secret.includes(file.path))
342
+ continue;
343
+ const admission = await admitGitPath(cwd, file.oldPath, options.base, exec, options.signal, baseInventory);
344
+ if (!admission.ok)
345
+ continue;
346
+ const entry = baseInventory.get(file.oldPath);
347
+ const match = entry &&
348
+ /^(?:100644|100755|120000) blob ([0-9a-f]+)\s+(\d+)\t/.exec(entry);
349
+ if (match)
350
+ objects.set(file.oldPath, { id: match[1] ?? "", size: Number(match[2]) });
351
+ }
352
+ const uniqueObjects = new Map([...objects.values()]
353
+ .filter((object) => object.size <= STATE_MAX_CHARS)
354
+ .map((object) => [object.id, object]));
355
+ const loaded = await readGitBlobs(exec, scoped, [...uniqueObjects.values()]);
356
+ if (!loaded.ok)
357
+ return loaded;
358
+ const collectFile = async (file) => {
359
+ if (secret.includes(file.path))
360
+ return {
361
+ ok: true,
362
+ file: {
363
+ ...file,
364
+ hunks: [],
365
+ binary: false,
366
+ before: null,
367
+ after: null,
368
+ limitation: "secret_pattern",
369
+ },
370
+ };
371
+ const historical = !/^[A?]/.test(file.status);
372
+ const admission = historical
373
+ ? await admitGitPath(cwd, file.oldPath, options.base, exec, options.signal, baseInventory)
374
+ : await admitRepoMetadata(cwd, file.path, exec, options.signal, currentInventory);
375
+ if (!admission.ok)
376
+ return {
377
+ ok: true,
378
+ file: {
379
+ ...file,
380
+ hunks: [],
381
+ before: null,
382
+ after: null,
383
+ limitation: "unreadable",
384
+ },
385
+ };
386
+ if (unsupported.includes(file.path)) {
387
+ return {
388
+ ok: true,
389
+ file: { ...file, before: null, after: null, limitation: "unreadable" },
390
+ };
391
+ }
392
+ if (file.binary) {
393
+ return { ok: true, file: { ...file, before: null, after: null } };
394
+ }
395
+ if (file.oldMode === "160000" || file.newMode === "160000") {
396
+ return {
397
+ ok: true,
398
+ file: { ...file, before: null, after: null, limitation: "unreadable" },
399
+ };
400
+ }
401
+ if (file.status === "D") {
402
+ try {
403
+ const replacement = await lstat(resolve(cwd, file.path));
404
+ if (!replacement.isFile() && !replacement.isSymbolicLink()) {
405
+ return {
406
+ ok: true,
407
+ file: {
408
+ ...file,
409
+ before: null,
410
+ after: null,
411
+ limitation: "unreadable",
412
+ },
413
+ };
414
+ }
415
+ }
416
+ catch {
417
+ /* A genuinely deleted path has no worktree version. */
418
+ }
419
+ }
420
+ let before = "";
421
+ let limitation;
422
+ if (!/^[A?]/.test(file.status)) {
423
+ const object = objects.get(file.oldPath);
424
+ if (!object)
425
+ return {
426
+ ok: false,
427
+ cause: "git_failure",
428
+ error: "Incomplete Git base tree; collect again.",
429
+ };
430
+ if (object.size > STATE_MAX_CHARS)
431
+ limitation = "too_large";
432
+ else if (loaded.invalid.has(object.id))
433
+ limitation = "not UTF-8 text";
434
+ else
435
+ before = loaded.blobs.get(object.id) ?? "";
436
+ }
437
+ const after = file.status === "D"
438
+ ? { text: "", binary: false }
439
+ : await readWorktree(scoped, exec, file.path, currentInventory);
440
+ limitation = after.limitation ?? limitation;
441
+ const unverifiableHunk = limitation === "too_large" &&
442
+ file.hunks.some((hunk) => (hunk.beforeText ?? "").includes("\uFFFD") ||
443
+ (hunk.afterText ?? "").includes("\uFFFD"));
444
+ if (unverifiableHunk)
445
+ limitation = "not UTF-8 text";
446
+ return {
447
+ ok: true,
448
+ file: {
449
+ ...file,
450
+ hunks: after.limitation === "unreadable" || limitation === "not UTF-8 text"
451
+ ? file.hunks.map(({ beforeText: _before, afterText: _after, ...hunk }) => hunk)
452
+ : file.hunks,
453
+ binary: after.binary,
454
+ limitation,
455
+ before: /^[A?]/.test(file.status) ||
456
+ limitation === "too_large" ||
457
+ limitation === "not UTF-8 text"
458
+ ? null
459
+ : before,
460
+ after: file.status === "D" || limitation ? null : after.text,
461
+ },
462
+ };
463
+ };
464
+ const sources = [];
465
+ for (let offset = 0; offset < files.length; offset += 4) {
466
+ const batch = await Promise.all(files.slice(offset, offset + 4).map(collectFile));
467
+ for (const result of batch) {
468
+ if (!result.ok)
469
+ return result;
470
+ sources.push(result.file);
471
+ }
472
+ }
473
+ return { ok: true, files: sources };
474
+ }
475
+ export async function collectUnits(exec, options, sourceParser) {
476
+ const [diff, parser] = await Promise.all([
477
+ collectDiff(exec, options),
478
+ sourceParser ?? loadSyntaxParser(),
479
+ ]);
480
+ if (!diff.ok)
481
+ return diff;
482
+ return { ok: true, files: diff.files, ...buildUnits(diff.files, parser) };
483
+ }
@@ -0,0 +1,197 @@
1
+ import { LOCATE_LABEL_MAX_CHARS, LOCATE_READ_BUFFER_BYTES, LOCATE_WHOLE_MAX_BYTES, LOCATE_WHOLE_MAX_CHARS, LOCATE_WINDOW_LINES, LOCATE_WINDOW_MAX_SERIALIZED_CHARS, STATE_MAX_CHARS, } from "../constants.js";
2
+ import { truncate } from "../core/truncate.js";
3
+ import { openRepoFile } from "./files.js";
4
+ import { createUtf8Decoder, decodeUtf8 } from "./utf8.js";
5
+ /** The initial read is bounded; large-file outlines retain labels rather than source. */
6
+ export async function readLocateFile(cwd, path, signal, exec) {
7
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
8
+ return {
9
+ ok: false,
10
+ cause: "forbidden_path",
11
+ error: `Internal URLs are not files; use read for ${path}.`,
12
+ };
13
+ const opened = await openRepoFile(cwd, path, { exec, signal });
14
+ if (!opened.ok)
15
+ return opened;
16
+ const handle = opened.handle;
17
+ try {
18
+ const stat = await handle.stat();
19
+ if (!stat.isFile())
20
+ return {
21
+ ok: false,
22
+ cause: "file_unavailable",
23
+ error: `Not a file: ${path}.`,
24
+ };
25
+ const buffer = Buffer.alloc(Math.min(stat.size + 1, LOCATE_WHOLE_MAX_BYTES + 1));
26
+ let count = 0;
27
+ while (count < buffer.length) {
28
+ signal?.throwIfAborted();
29
+ const read = await handle.read(buffer, count, buffer.length - count, null);
30
+ if (!read.bytesRead)
31
+ break;
32
+ count += read.bytesRead;
33
+ }
34
+ if (buffer.subarray(0, count).includes(0))
35
+ return {
36
+ ok: false,
37
+ cause: "binary_or_non_utf8",
38
+ error: `Not a text file: ${path}.`,
39
+ };
40
+ if (count < buffer.length) {
41
+ const decoded = decodeUtf8(buffer.subarray(0, count));
42
+ if (!decoded.ok)
43
+ return {
44
+ ok: false,
45
+ cause: "binary_or_non_utf8",
46
+ error: `${path}: ${decoded.error}`,
47
+ };
48
+ const text = decoded.text;
49
+ if (text.length <= LOCATE_WHOLE_MAX_CHARS)
50
+ return {
51
+ ok: true,
52
+ kind: "whole",
53
+ bytes: count,
54
+ lines: text
55
+ ? text.split("\n").length - Number(text.endsWith("\n"))
56
+ : 0,
57
+ text,
58
+ };
59
+ }
60
+ const scan = await scanRange(cwd, path, undefined, signal, exec);
61
+ return scan.ok
62
+ ? {
63
+ ok: true,
64
+ kind: "outline",
65
+ bytes: stat.size,
66
+ lines: scan.lines,
67
+ windows: scan.windows,
68
+ }
69
+ : scan;
70
+ }
71
+ catch (error) {
72
+ return {
73
+ ok: false,
74
+ cause: signal?.aborted ? "cancelled" : "file_unavailable",
75
+ error: `Cannot read ${path}: ${String(error)}`,
76
+ };
77
+ }
78
+ finally {
79
+ await handle.close();
80
+ }
81
+ }
82
+ export async function scanRange(cwd, path, range, signal, exec) {
83
+ const opened = await openRepoFile(cwd, path, { exec, signal });
84
+ if (!opened.ok)
85
+ return opened;
86
+ const handle = opened.handle;
87
+ const decode = createUtf8Decoder();
88
+ const buffer = Buffer.alloc(LOCATE_READ_BUFFER_BYTES);
89
+ let reachedEof = false;
90
+ let line = 1, label = "", pending = false, text = "", bytes = 0;
91
+ const windows = [];
92
+ let windowStart = 1, head = "", tail = "", serializedTextChars = 2;
93
+ const finishWindow = (end) => {
94
+ windows.push({
95
+ start: windowStart,
96
+ end,
97
+ label: label || `lines ${windowStart}-${end}`,
98
+ text: `${head}\n…\n${tail}`,
99
+ serializedTextChars,
100
+ });
101
+ windowStart = end + 1;
102
+ label = "";
103
+ head = "";
104
+ tail = "";
105
+ serializedTextChars = 2;
106
+ };
107
+ const consume = (chunk) => {
108
+ for (const piece of chunk.split(/(?<=\n)/)) {
109
+ if (!piece)
110
+ continue;
111
+ pending = true;
112
+ const excerptLimit = LOCATE_WINDOW_MAX_SERIALIZED_CHARS;
113
+ serializedTextChars += JSON.stringify(piece).length - 2;
114
+ head = truncate(head + piece, excerptLimit);
115
+ const combined = tail + piece;
116
+ const tailStart = Math.max(0, combined.length - excerptLimit);
117
+ tail = combined.slice(tailStart +
118
+ (tailStart > 0 && /[\uDC00-\uDFFF]/.test(combined[tailStart] ?? "")
119
+ ? 1
120
+ : 0));
121
+ if ((line - 1) % LOCATE_WINDOW_LINES === 0 &&
122
+ label.length < LOCATE_LABEL_MAX_CHARS)
123
+ label = truncate(label + piece.trim(), LOCATE_LABEL_MAX_CHARS);
124
+ if (range && line >= range.start && line <= range.end)
125
+ text += piece;
126
+ if (text.length > STATE_MAX_CHARS)
127
+ return false;
128
+ if (piece.endsWith("\n")) {
129
+ if (line - windowStart + 1 >= LOCATE_WINDOW_LINES ||
130
+ serializedTextChars >= LOCATE_WINDOW_MAX_SERIALIZED_CHARS)
131
+ finishWindow(line);
132
+ line++;
133
+ pending = false;
134
+ }
135
+ }
136
+ return true;
137
+ };
138
+ try {
139
+ while (true) {
140
+ signal?.throwIfAborted();
141
+ const read = await handle.read(buffer, 0, buffer.length, null);
142
+ if (!read.bytesRead) {
143
+ reachedEof = true;
144
+ break;
145
+ }
146
+ if (buffer.subarray(0, read.bytesRead).includes(0))
147
+ return {
148
+ ok: false,
149
+ cause: "binary_or_non_utf8",
150
+ error: `Not a text file: ${path}.`,
151
+ };
152
+ bytes += read.bytesRead;
153
+ const decoded = decode(buffer.subarray(0, read.bytesRead));
154
+ if (!decoded.ok)
155
+ return {
156
+ ok: false,
157
+ cause: "binary_or_non_utf8",
158
+ error: `${path}: ${decoded.error}`,
159
+ };
160
+ if (!consume(decoded.text))
161
+ return {
162
+ ok: false,
163
+ cause: "evidence_too_large",
164
+ error: `Selected range exceeds ${STATE_MAX_CHARS} chars; read a narrower range directly.`,
165
+ };
166
+ if (range && line > range.end)
167
+ break;
168
+ }
169
+ const end = reachedEof ? decode() : { ok: true, text: "" };
170
+ if (!end.ok)
171
+ return {
172
+ ok: false,
173
+ cause: "binary_or_non_utf8",
174
+ error: `${path}: ${end.error}`,
175
+ };
176
+ if (!consume(end.text))
177
+ return {
178
+ ok: false,
179
+ cause: "evidence_too_large",
180
+ error: "Selected range too large.",
181
+ };
182
+ const lines = line - Number(!pending);
183
+ if (lines > 0 && (windows.at(-1)?.end ?? 0) < lines)
184
+ finishWindow(lines);
185
+ return { ok: true, bytes, lines, windows, text };
186
+ }
187
+ catch (error) {
188
+ return {
189
+ ok: false,
190
+ cause: signal?.aborted ? "cancelled" : "file_unavailable",
191
+ error: `Cannot read ${path}: ${String(error)}`,
192
+ };
193
+ }
194
+ finally {
195
+ await handle.close();
196
+ }
197
+ }