jev-agent-tools 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/design.md +187 -0
- package/docs/tools/jev_ask.md +92 -0
- package/docs/tools/jev_ask_files.md +80 -0
- package/docs/tools/jev_check_diff.md +77 -0
- package/docs/tools/jev_find_files.md +59 -0
- package/docs/tools/jev_locate_in_file.md +51 -0
- package/docs/tools/jev_select_tests.md +59 -0
- package/package.json +85 -0
- package/rules/jev-ask.md +4 -0
- package/src/adapters/analysis-context.ts +100 -0
- package/src/adapters/ask-files.ts +236 -0
- package/src/adapters/ask-proof.ts +202 -0
- package/src/adapters/ask-syntax.ts +463 -0
- package/src/adapters/command.ts +240 -0
- package/src/adapters/docs.ts +222 -0
- package/src/adapters/files.ts +411 -0
- package/src/adapters/find.ts +151 -0
- package/src/adapters/git-base.ts +32 -0
- package/src/adapters/git-inventory.ts +94 -0
- package/src/adapters/git.ts +525 -0
- package/src/adapters/locate-file.ts +197 -0
- package/src/adapters/output-lines.ts +50 -0
- package/src/adapters/risk-callers.ts +525 -0
- package/src/adapters/runner-version.ts +102 -0
- package/src/adapters/syntax.ts +229 -0
- package/src/adapters/test-inventory.ts +168 -0
- package/src/adapters/usage.ts +21 -0
- package/src/adapters/utf8.ts +57 -0
- package/src/constants.ts +109 -0
- package/src/core/ask-closure.ts +419 -0
- package/src/core/ask-proof.ts +32 -0
- package/src/core/ask-references.ts +249 -0
- package/src/core/asks.ts +616 -0
- package/src/core/batches.ts +83 -0
- package/src/core/command-output.ts +249 -0
- package/src/core/diff.ts +226 -0
- package/src/core/docs.ts +399 -0
- package/src/core/find.ts +157 -0
- package/src/core/git.ts +5 -0
- package/src/core/import-boundaries.ts +102 -0
- package/src/core/imports.ts +691 -0
- package/src/core/integrity.ts +64 -0
- package/src/core/lexical.ts +130 -0
- package/src/core/locate.ts +213 -0
- package/src/core/output.ts +264 -0
- package/src/core/pointer.ts +51 -0
- package/src/core/risk-callers.ts +1270 -0
- package/src/core/runner-version.ts +66 -0
- package/src/core/sections.ts +269 -0
- package/src/core/state.ts +53 -0
- package/src/core/syntax.ts +8 -0
- package/src/core/test-commands.ts +430 -0
- package/src/core/test-coverage.ts +103 -0
- package/src/core/test-discovery.ts +1695 -0
- package/src/core/test-evidence.ts +649 -0
- package/src/core/test-state.ts +99 -0
- package/src/core/truncate.ts +14 -0
- package/src/core/units.ts +531 -0
- package/src/describe.ts +26 -0
- package/src/guide.ts +42 -0
- package/src/host.ts +22 -0
- package/src/index.ts +40 -0
- package/src/jev/client.ts +505 -0
- package/src/jev/pool.ts +60 -0
- package/src/jev/types.ts +60 -0
- package/src/presets/docs.ts +85 -0
- package/src/presets/risk.ts +263 -0
- package/src/presets/spec.ts +111 -0
- package/src/presets/witnesses.ts +313 -0
- package/src/render.ts +45 -0
- package/src/result.ts +4 -0
- package/src/run-end.ts +157 -0
- package/src/runtime.ts +16 -0
- package/src/session.ts +106 -0
- package/src/texts/ask-files.ts +2 -0
- package/src/texts/ask.ts +4 -0
- package/src/texts/check-diff.ts +24 -0
- package/src/texts/configuration.ts +2 -0
- package/src/texts/find.ts +14 -0
- package/src/texts/guide.ts +16 -0
- package/src/texts/locate.ts +10 -0
- package/src/texts/run-end.ts +13 -0
- package/src/texts/select-tests.ts +3 -0
- package/src/tools/ask-files.ts +263 -0
- package/src/tools/ask-schema.ts +116 -0
- package/src/tools/ask.ts +925 -0
- package/src/tools/check-diff.ts +510 -0
- package/src/tools/docs-check.ts +399 -0
- package/src/tools/find.ts +529 -0
- package/src/tools/locate.ts +369 -0
- package/src/tools/select-tests.ts +746 -0
- package/src/tools/spec-check.ts +210 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
type FileHandle,
|
|
5
|
+
lstat,
|
|
6
|
+
open,
|
|
7
|
+
readlink,
|
|
8
|
+
realpath,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
ASK_MAX_FILES,
|
|
13
|
+
FIND_EXCERPT_CHARS,
|
|
14
|
+
FIND_POINTER_EXCERPT_CHARS,
|
|
15
|
+
FIND_READ_CHUNK_BYTES,
|
|
16
|
+
STATE_MAX_CHARS,
|
|
17
|
+
TIMEOUT_MS,
|
|
18
|
+
} from "../constants.ts";
|
|
19
|
+
import { createExcerptCollector } from "../core/find.ts";
|
|
20
|
+
import type { GitExec } from "../core/git.ts";
|
|
21
|
+
import type { FileIdentity } from "../core/integrity.ts";
|
|
22
|
+
import type { Result } from "../result.ts";
|
|
23
|
+
import { createUtf8Decoder, decodeUtf8 } from "./utf8.ts";
|
|
24
|
+
|
|
25
|
+
export async function checkFileAdmission(
|
|
26
|
+
cwd: string,
|
|
27
|
+
path: string,
|
|
28
|
+
exec?: GitExec,
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
inventory?: ReadonlySet<string>,
|
|
31
|
+
): Promise<Result<object>> {
|
|
32
|
+
if (path.split(/[\\/]/).includes(".git"))
|
|
33
|
+
return { ok: false, error: `${path}: .git metadata: not sent to Jev` };
|
|
34
|
+
if (inventory)
|
|
35
|
+
return inventory.has(path)
|
|
36
|
+
? { ok: true }
|
|
37
|
+
: { ok: false, error: `${path}: gitignored: not sent to Jev` };
|
|
38
|
+
if (!exec) return { ok: true };
|
|
39
|
+
const listed = await exec(
|
|
40
|
+
"git",
|
|
41
|
+
[
|
|
42
|
+
"ls-files",
|
|
43
|
+
"--cached",
|
|
44
|
+
"--others",
|
|
45
|
+
"--exclude-standard",
|
|
46
|
+
"-z",
|
|
47
|
+
"--",
|
|
48
|
+
path,
|
|
49
|
+
],
|
|
50
|
+
{ cwd, timeout: TIMEOUT_MS, signal },
|
|
51
|
+
);
|
|
52
|
+
if (listed.killed)
|
|
53
|
+
return { ok: false, error: `${path}: file admission interrupted` };
|
|
54
|
+
if (listed.code === 0 && !listed.stdout.split("\0").includes(path))
|
|
55
|
+
return { ok: false, error: `${path}: gitignored: not sent to Jev` };
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function resolveInsideRepo(
|
|
60
|
+
root: string,
|
|
61
|
+
path: string,
|
|
62
|
+
options: { allowLeafSymlink?: boolean } = {},
|
|
63
|
+
): Promise<Result<{ abs: string; rel: string }>> {
|
|
64
|
+
if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: `Path must remain inside the repository: ${path}.`,
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
const base = await realpath(root);
|
|
71
|
+
const requested = resolve(base, path);
|
|
72
|
+
const rel = relative(base, requested);
|
|
73
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
74
|
+
return { ok: false, error: `Path escapes the repository: ${path}.` };
|
|
75
|
+
let current = base;
|
|
76
|
+
for (const part of rel.split(sep).filter(Boolean)) {
|
|
77
|
+
current = resolve(current, part);
|
|
78
|
+
if ((await lstat(current)).isSymbolicLink()) {
|
|
79
|
+
if (options.allowLeafSymlink && current === requested)
|
|
80
|
+
return { ok: true, abs: requested, rel: rel.replaceAll(sep, "/") };
|
|
81
|
+
return { ok: false, error: `Symbolic links are not read: ${path}.` };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const abs = await realpath(requested);
|
|
85
|
+
const actual = relative(base, abs);
|
|
86
|
+
if (actual === ".." || actual.startsWith(`..${sep}`) || isAbsolute(actual))
|
|
87
|
+
return { ok: false, error: `Path escapes the repository: ${path}.` };
|
|
88
|
+
return { ok: true, abs, rel: actual.replaceAll(sep, "/") };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { ok: false, error: `Cannot resolve ${path}: ${String(error)}` };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Leaf-link metadata is admitted without following the target. */
|
|
95
|
+
export async function admitRepoMetadata(
|
|
96
|
+
cwd: string,
|
|
97
|
+
path: string,
|
|
98
|
+
exec?: GitExec,
|
|
99
|
+
signal?: AbortSignal,
|
|
100
|
+
inventory?: ReadonlySet<string>,
|
|
101
|
+
): Promise<Result<{ abs: string; rel: string }>> {
|
|
102
|
+
const location = await resolveInsideRepo(cwd, path, {
|
|
103
|
+
allowLeafSymlink: true,
|
|
104
|
+
});
|
|
105
|
+
if (!location.ok) return location;
|
|
106
|
+
const admission = await checkFileAdmission(
|
|
107
|
+
cwd,
|
|
108
|
+
location.rel,
|
|
109
|
+
exec,
|
|
110
|
+
signal,
|
|
111
|
+
inventory,
|
|
112
|
+
);
|
|
113
|
+
return admission.ok ? location : admission;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** History is admitted by its tracked base tree, independent of worktree ignores or links. */
|
|
117
|
+
export async function admitGitPath(
|
|
118
|
+
cwd: string,
|
|
119
|
+
path: string,
|
|
120
|
+
base: string,
|
|
121
|
+
exec: GitExec,
|
|
122
|
+
signal?: AbortSignal,
|
|
123
|
+
inventory?: ReadonlyMap<string, string>,
|
|
124
|
+
): Promise<Result<object>> {
|
|
125
|
+
if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: `Path must remain inside the repository: ${path}.`,
|
|
129
|
+
};
|
|
130
|
+
const admission = await checkFileAdmission(cwd, path, undefined, signal);
|
|
131
|
+
if (!admission.ok) return admission;
|
|
132
|
+
// ls-tree cannot descend through a symlink or gitlink ancestor in the base tree.
|
|
133
|
+
let entry = inventory?.get(path);
|
|
134
|
+
if (!inventory) {
|
|
135
|
+
const tree = await exec(
|
|
136
|
+
"git",
|
|
137
|
+
["ls-tree", "-z", "--full-tree", "--end-of-options", base, "--", path],
|
|
138
|
+
{ cwd, timeout: TIMEOUT_MS, signal },
|
|
139
|
+
);
|
|
140
|
+
if (tree.code !== 0 || tree.killed)
|
|
141
|
+
return { ok: false, error: `${path}: not a file at comparison base` };
|
|
142
|
+
entry = tree.stdout
|
|
143
|
+
.split("\0")
|
|
144
|
+
.find((item) => item.slice(item.indexOf("\t") + 1) === path);
|
|
145
|
+
}
|
|
146
|
+
if (!entry || !/^(?:100644|100755|120000) blob /.test(entry))
|
|
147
|
+
return { ok: false, error: `${path}: not a file at comparison base` };
|
|
148
|
+
return { ok: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The only content opener for repository files. Callers own and close the handle. */
|
|
152
|
+
export async function openRepoFile(
|
|
153
|
+
cwd: string,
|
|
154
|
+
path: string,
|
|
155
|
+
options: {
|
|
156
|
+
exec?: GitExec;
|
|
157
|
+
signal?: AbortSignal;
|
|
158
|
+
inventory?: ReadonlySet<string>;
|
|
159
|
+
} = {},
|
|
160
|
+
): Promise<Result<{ handle: FileHandle; size: number }>> {
|
|
161
|
+
try {
|
|
162
|
+
options.signal?.throwIfAborted();
|
|
163
|
+
const location = await resolveInsideRepo(cwd, path);
|
|
164
|
+
if (!location.ok) return location;
|
|
165
|
+
const admission = await checkFileAdmission(
|
|
166
|
+
cwd,
|
|
167
|
+
location.rel,
|
|
168
|
+
options.exec,
|
|
169
|
+
options.signal,
|
|
170
|
+
options.inventory,
|
|
171
|
+
);
|
|
172
|
+
if (!admission.ok) return admission;
|
|
173
|
+
const handle = await open(
|
|
174
|
+
location.abs,
|
|
175
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
176
|
+
);
|
|
177
|
+
try {
|
|
178
|
+
const stat = await handle.stat();
|
|
179
|
+
if (!stat.isFile()) {
|
|
180
|
+
await handle.close();
|
|
181
|
+
return { ok: false, error: `Not a file: ${path}.` };
|
|
182
|
+
}
|
|
183
|
+
return { ok: true, handle, size: stat.size };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
await handle.close();
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return { ok: false, error: `Cannot read ${path}: ${String(error)}` };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function collectFiles(
|
|
194
|
+
cwd: string,
|
|
195
|
+
paths: string[],
|
|
196
|
+
signal?: AbortSignal,
|
|
197
|
+
options: {
|
|
198
|
+
exec?: GitExec;
|
|
199
|
+
allowEmpty?: boolean;
|
|
200
|
+
skipInvalidUtf8?: boolean;
|
|
201
|
+
inventory?: ReadonlySet<string>;
|
|
202
|
+
} = {},
|
|
203
|
+
): Promise<
|
|
204
|
+
Result<{
|
|
205
|
+
files: Record<string, string>;
|
|
206
|
+
identities: FileIdentity[];
|
|
207
|
+
skipped: string[];
|
|
208
|
+
}>
|
|
209
|
+
> {
|
|
210
|
+
if (paths.length > ASK_MAX_FILES)
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
error: `paths exceeds ${ASK_MAX_FILES} files; split paths into several calls.`,
|
|
214
|
+
};
|
|
215
|
+
const results = await Promise.all(
|
|
216
|
+
paths.map(async (path) => {
|
|
217
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
|
|
218
|
+
return {
|
|
219
|
+
ok: false as const,
|
|
220
|
+
error: `Internal URLs are not files; use read for ${path}.`,
|
|
221
|
+
};
|
|
222
|
+
let handle: FileHandle | undefined;
|
|
223
|
+
try {
|
|
224
|
+
signal?.throwIfAborted();
|
|
225
|
+
const opened = await openRepoFile(cwd, path, {
|
|
226
|
+
exec: options.exec,
|
|
227
|
+
signal,
|
|
228
|
+
inventory: options.inventory,
|
|
229
|
+
});
|
|
230
|
+
if (!opened.ok) return opened;
|
|
231
|
+
handle = opened.handle;
|
|
232
|
+
const bytes = Buffer.alloc(
|
|
233
|
+
Math.min(opened.size + 1, STATE_MAX_CHARS * 4 + 1),
|
|
234
|
+
);
|
|
235
|
+
let count = 0;
|
|
236
|
+
while (count < bytes.length) {
|
|
237
|
+
signal?.throwIfAborted();
|
|
238
|
+
const read = await handle.read(
|
|
239
|
+
bytes,
|
|
240
|
+
count,
|
|
241
|
+
bytes.length - count,
|
|
242
|
+
null,
|
|
243
|
+
);
|
|
244
|
+
if (!read.bytesRead) break;
|
|
245
|
+
count += read.bytesRead;
|
|
246
|
+
}
|
|
247
|
+
if (count === bytes.length)
|
|
248
|
+
return {
|
|
249
|
+
ok: false as const,
|
|
250
|
+
error: `File exceeds ${STATE_MAX_CHARS} chars: ${path}; files are never truncated. Split paths into several calls.`,
|
|
251
|
+
};
|
|
252
|
+
const decoded = decodeUtf8(bytes.subarray(0, count));
|
|
253
|
+
if (!decoded.ok)
|
|
254
|
+
return {
|
|
255
|
+
ok: false as const,
|
|
256
|
+
invalidUtf8: true,
|
|
257
|
+
error: `${path} is not UTF-8 text; provide a UTF-8 text file instead.`,
|
|
258
|
+
};
|
|
259
|
+
const text = decoded.text;
|
|
260
|
+
if (count === bytes.length || text.length > STATE_MAX_CHARS)
|
|
261
|
+
return {
|
|
262
|
+
ok: false as const,
|
|
263
|
+
error: `File exceeds ${STATE_MAX_CHARS} chars: ${path}; files are never truncated. Split paths into several calls.`,
|
|
264
|
+
};
|
|
265
|
+
if (!text.length && !options.allowEmpty)
|
|
266
|
+
return { ok: false as const, error: `${path} is empty.` };
|
|
267
|
+
return {
|
|
268
|
+
ok: true as const,
|
|
269
|
+
path,
|
|
270
|
+
text,
|
|
271
|
+
identity: {
|
|
272
|
+
requestedPath: path,
|
|
273
|
+
resolvedPath: resolve(cwd, path),
|
|
274
|
+
insertedPath: resolve(cwd, path),
|
|
275
|
+
content: text,
|
|
276
|
+
readSha256: createHash("sha256")
|
|
277
|
+
.update(bytes.subarray(0, count))
|
|
278
|
+
.digest("hex"),
|
|
279
|
+
insertedSha256: createHash("sha256").update(text).digest("hex"),
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false as const,
|
|
285
|
+
error: `Cannot read ${path}: ${String(error)}`,
|
|
286
|
+
};
|
|
287
|
+
} finally {
|
|
288
|
+
await handle?.close();
|
|
289
|
+
}
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
const failure = results.find(
|
|
293
|
+
(result) =>
|
|
294
|
+
!result.ok && !(options.skipInvalidUtf8 && "invalidUtf8" in result),
|
|
295
|
+
);
|
|
296
|
+
if (failure && !failure.ok) return failure;
|
|
297
|
+
return {
|
|
298
|
+
ok: true,
|
|
299
|
+
skipped: results.flatMap((result) =>
|
|
300
|
+
!result.ok && "invalidUtf8" in result ? [result.error] : [],
|
|
301
|
+
),
|
|
302
|
+
identities: results.flatMap((result) =>
|
|
303
|
+
result.ok ? [result.identity] : [],
|
|
304
|
+
),
|
|
305
|
+
files: Object.fromEntries(
|
|
306
|
+
results.flatMap((result) =>
|
|
307
|
+
result.ok ? [[result.path, result.text]] : [],
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function collectSearchExcerpts(
|
|
314
|
+
cwd: string,
|
|
315
|
+
paths: readonly string[],
|
|
316
|
+
keywords: readonly string[],
|
|
317
|
+
signal?: AbortSignal,
|
|
318
|
+
exec?: GitExec,
|
|
319
|
+
): Promise<
|
|
320
|
+
Result<{
|
|
321
|
+
files: Record<string, string>;
|
|
322
|
+
pointers: Record<string, string>;
|
|
323
|
+
ignored: string[];
|
|
324
|
+
limits: { path: string; reason: string }[];
|
|
325
|
+
}>
|
|
326
|
+
> {
|
|
327
|
+
const files: Record<string, string> = {},
|
|
328
|
+
pointers: Record<string, string> = {};
|
|
329
|
+
const ignored: string[] = [];
|
|
330
|
+
const limits: { path: string; reason: string }[] = [];
|
|
331
|
+
const results = await Promise.all(
|
|
332
|
+
paths.map(async (path) => {
|
|
333
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
|
|
334
|
+
return {
|
|
335
|
+
ok: false as const,
|
|
336
|
+
error: `Internal URLs are not files; use read for ${path}.`,
|
|
337
|
+
};
|
|
338
|
+
try {
|
|
339
|
+
const location = await admitRepoMetadata(cwd, path, exec, signal);
|
|
340
|
+
if (!location.ok) {
|
|
341
|
+
ignored.push(path);
|
|
342
|
+
return { ok: true as const };
|
|
343
|
+
}
|
|
344
|
+
if ((await lstat(location.abs)).isSymbolicLink()) {
|
|
345
|
+
const target = await readlink(location.abs);
|
|
346
|
+
files[path] = target;
|
|
347
|
+
pointers[path] = target;
|
|
348
|
+
return { ok: true as const };
|
|
349
|
+
}
|
|
350
|
+
const opened = await openRepoFile(cwd, path, { exec, signal });
|
|
351
|
+
if (!opened.ok) {
|
|
352
|
+
ignored.push(path);
|
|
353
|
+
return { ok: true as const };
|
|
354
|
+
}
|
|
355
|
+
const handle = opened.handle;
|
|
356
|
+
const content = createExcerptCollector(keywords, FIND_EXCERPT_CHARS);
|
|
357
|
+
const pointer = createExcerptCollector(
|
|
358
|
+
keywords,
|
|
359
|
+
FIND_POINTER_EXCERPT_CHARS,
|
|
360
|
+
);
|
|
361
|
+
const decode = createUtf8Decoder();
|
|
362
|
+
const stream = handle.createReadStream({
|
|
363
|
+
highWaterMark: FIND_READ_CHUNK_BYTES,
|
|
364
|
+
signal,
|
|
365
|
+
});
|
|
366
|
+
for await (const chunk of stream) {
|
|
367
|
+
const decoded = decode(chunk as Buffer);
|
|
368
|
+
if (!decoded.ok) {
|
|
369
|
+
limits.push({ path, reason: decoded.error });
|
|
370
|
+
ignored.push(path);
|
|
371
|
+
stream.destroy();
|
|
372
|
+
return { ok: true as const };
|
|
373
|
+
}
|
|
374
|
+
const text = decoded.text;
|
|
375
|
+
if (text.includes("\0")) {
|
|
376
|
+
ignored.push(path);
|
|
377
|
+
stream.destroy();
|
|
378
|
+
return { ok: true as const };
|
|
379
|
+
}
|
|
380
|
+
content.push(text);
|
|
381
|
+
pointer.push(text);
|
|
382
|
+
}
|
|
383
|
+
const end = decode();
|
|
384
|
+
if (!end.ok) {
|
|
385
|
+
limits.push({ path, reason: end.error });
|
|
386
|
+
ignored.push(path);
|
|
387
|
+
return { ok: true as const };
|
|
388
|
+
}
|
|
389
|
+
content.push(end.text);
|
|
390
|
+
pointer.push(end.text);
|
|
391
|
+
const excerpt = content.finish();
|
|
392
|
+
if (!excerpt.length) {
|
|
393
|
+
ignored.push(path);
|
|
394
|
+
return { ok: true as const };
|
|
395
|
+
}
|
|
396
|
+
files[path] = excerpt;
|
|
397
|
+
pointers[path] = pointer.finish();
|
|
398
|
+
return { ok: true as const };
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (signal?.aborted)
|
|
401
|
+
return { ok: false as const, error: String(error) };
|
|
402
|
+
ignored.push(path);
|
|
403
|
+
return { ok: true as const };
|
|
404
|
+
}
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
const failure = results.find((result) => !result.ok);
|
|
408
|
+
return failure && !failure.ok
|
|
409
|
+
? failure
|
|
410
|
+
: { ok: true, files, pointers, ignored, limits };
|
|
411
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { FileFinder, GrepCursor } from "@ff-labs/fff-node";
|
|
2
|
+
import {
|
|
3
|
+
FIND_GREP_PAGE_SIZE,
|
|
4
|
+
FIND_PATH_WEIGHT,
|
|
5
|
+
TIMEOUT_MS,
|
|
6
|
+
} from "../constants.ts";
|
|
7
|
+
import { pathAllowed } from "../core/find.ts";
|
|
8
|
+
import type { GitExec } from "../core/git.ts";
|
|
9
|
+
import type { Result } from "../result.ts";
|
|
10
|
+
|
|
11
|
+
export async function prefilter(
|
|
12
|
+
exec: GitExec,
|
|
13
|
+
cwd: string,
|
|
14
|
+
keywords: readonly string[],
|
|
15
|
+
limit: number,
|
|
16
|
+
scope?: string | readonly string[],
|
|
17
|
+
exclude?: readonly string[],
|
|
18
|
+
signal?: AbortSignal,
|
|
19
|
+
native = true,
|
|
20
|
+
): Promise<Result<{ paths: string[]; scopeFiles: number }>> {
|
|
21
|
+
try {
|
|
22
|
+
const listed = await exec(
|
|
23
|
+
"git",
|
|
24
|
+
["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
25
|
+
{ cwd, timeout: TIMEOUT_MS, signal },
|
|
26
|
+
);
|
|
27
|
+
if (listed.killed || listed.code !== 0)
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
error: `Cannot list repository files: ${listed.stderr || "git interrupted"}`,
|
|
31
|
+
};
|
|
32
|
+
const paths = [
|
|
33
|
+
...new Set(
|
|
34
|
+
listed.stdout
|
|
35
|
+
.split("\0")
|
|
36
|
+
.filter((path) => path && pathAllowed(path, scope, exclude)),
|
|
37
|
+
),
|
|
38
|
+
];
|
|
39
|
+
if (!paths.length) return { ok: true, paths: [], scopeFiles: 0 };
|
|
40
|
+
const allowed = new Set(paths);
|
|
41
|
+
let finder: FileFinder | undefined;
|
|
42
|
+
if (native) {
|
|
43
|
+
try {
|
|
44
|
+
// Static loading would break hosts where this optional native package is absent.
|
|
45
|
+
const { FileFinder } = await import("@ff-labs/fff-node");
|
|
46
|
+
const created = FileFinder.create({
|
|
47
|
+
basePath: cwd,
|
|
48
|
+
disableWatch: true,
|
|
49
|
+
});
|
|
50
|
+
if (created.ok) {
|
|
51
|
+
finder = created.value;
|
|
52
|
+
const ready = await finder.waitForScan(TIMEOUT_MS);
|
|
53
|
+
if (!ready.ok || !ready.value) {
|
|
54
|
+
finder.destroy();
|
|
55
|
+
finder = undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
/* Optional native package is unavailable in this host. */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!finder) {
|
|
63
|
+
const available = await exec("rg", ["--version"], {
|
|
64
|
+
cwd,
|
|
65
|
+
timeout: TIMEOUT_MS,
|
|
66
|
+
signal,
|
|
67
|
+
});
|
|
68
|
+
if (available.killed || available.code !== 0)
|
|
69
|
+
return { ok: false, error: "rg is required when fff is unavailable" };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const matches = await Promise.all(
|
|
73
|
+
keywords.map(async (word) => {
|
|
74
|
+
signal?.throwIfAborted();
|
|
75
|
+
if (finder) {
|
|
76
|
+
const found = new Set<string>();
|
|
77
|
+
let cursor: GrepCursor | null = null;
|
|
78
|
+
do {
|
|
79
|
+
const result = finder.multiGrep({
|
|
80
|
+
patterns: [word],
|
|
81
|
+
smartCase: true,
|
|
82
|
+
maxMatchesPerFile: 1,
|
|
83
|
+
pageSize: FIND_GREP_PAGE_SIZE,
|
|
84
|
+
cursor,
|
|
85
|
+
});
|
|
86
|
+
if (!result.ok) throw new Error(result.error);
|
|
87
|
+
for (const item of result.value.items)
|
|
88
|
+
if (allowed.has(item.relativePath))
|
|
89
|
+
found.add(item.relativePath);
|
|
90
|
+
cursor = result.value.nextCursor;
|
|
91
|
+
} while (cursor);
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
const result = await exec(
|
|
95
|
+
"rg",
|
|
96
|
+
[
|
|
97
|
+
"--files-with-matches",
|
|
98
|
+
"--hidden",
|
|
99
|
+
"--null",
|
|
100
|
+
"--fixed-strings",
|
|
101
|
+
"--ignore-case",
|
|
102
|
+
"--",
|
|
103
|
+
word,
|
|
104
|
+
".",
|
|
105
|
+
],
|
|
106
|
+
{ cwd, timeout: TIMEOUT_MS, signal },
|
|
107
|
+
);
|
|
108
|
+
if (
|
|
109
|
+
result.killed ||
|
|
110
|
+
(result.code !== 0 && result.code !== 1) ||
|
|
111
|
+
(result.code === 1 && result.stderr.trim())
|
|
112
|
+
)
|
|
113
|
+
throw new Error(result.stderr || "rg interrupted");
|
|
114
|
+
return new Set(
|
|
115
|
+
result.stdout
|
|
116
|
+
.split("\0")
|
|
117
|
+
.map((path) => path.replace(/^\.\//u, ""))
|
|
118
|
+
.filter((path) => allowed.has(path)),
|
|
119
|
+
);
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const scores = paths.map((path) => ({
|
|
123
|
+
path,
|
|
124
|
+
score: keywords.reduce((sum, word, index) => {
|
|
125
|
+
const matchesForWord = matches[index];
|
|
126
|
+
if (!matchesForWord) return sum;
|
|
127
|
+
const weight = Math.log(1 + paths.length / (1 + matchesForWord.size));
|
|
128
|
+
return (
|
|
129
|
+
sum +
|
|
130
|
+
weight *
|
|
131
|
+
(Number(matchesForWord.has(path)) +
|
|
132
|
+
FIND_PATH_WEIGHT * Number(path.toLowerCase().includes(word)))
|
|
133
|
+
);
|
|
134
|
+
}, 0),
|
|
135
|
+
}));
|
|
136
|
+
scores.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
paths: scores
|
|
140
|
+
.filter((item) => item.score > 0)
|
|
141
|
+
.slice(0, limit)
|
|
142
|
+
.map((item) => item.path),
|
|
143
|
+
scopeFiles: paths.length,
|
|
144
|
+
};
|
|
145
|
+
} finally {
|
|
146
|
+
finder?.destroy();
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return { ok: false, error: `Cannot prefilter files: ${String(error)}` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { TIMEOUT_MS } from "../constants.ts";
|
|
2
|
+
import type { GitExec } from "../core/git.ts";
|
|
3
|
+
import type { Result } from "../result.ts";
|
|
4
|
+
|
|
5
|
+
export async function resolveBase(
|
|
6
|
+
exec: GitExec,
|
|
7
|
+
cwd: string,
|
|
8
|
+
base?: string,
|
|
9
|
+
signal?: AbortSignal,
|
|
10
|
+
): Promise<Result<{ base: string }>> {
|
|
11
|
+
if (!base) return { ok: true, base: "HEAD" };
|
|
12
|
+
try {
|
|
13
|
+
const result = await exec("git", ["merge-base", "--", "HEAD", base], {
|
|
14
|
+
cwd,
|
|
15
|
+
timeout: TIMEOUT_MS,
|
|
16
|
+
signal,
|
|
17
|
+
});
|
|
18
|
+
if (result.killed)
|
|
19
|
+
return { ok: false, error: "Git interrupted (cancelled or timed out)." };
|
|
20
|
+
if (result.code === 0 && result.stdout.trim())
|
|
21
|
+
return { ok: true, base: result.stdout.trim() };
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
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
|
+
};
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: `Unable to resolve base=${base}: ${String(error)}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { GitExec } from "../core/git.ts";
|
|
2
|
+
|
|
3
|
+
/** Fresh per tool call: HEAD/index timestamps cannot detect untracked or ignore changes. */
|
|
4
|
+
export function shareGitInventory(exec: GitExec): GitExec {
|
|
5
|
+
const snapshots = new Map<string, ReturnType<GitExec>>();
|
|
6
|
+
return async (command, args, options) => {
|
|
7
|
+
const offset = args.indexOf("ls-files");
|
|
8
|
+
if (command !== "git" && command !== "rg") snapshots.clear();
|
|
9
|
+
if (command !== "git" || offset === -1) return exec(command, args, options);
|
|
10
|
+
const flags = args.slice(offset + 1);
|
|
11
|
+
if (
|
|
12
|
+
!flags.includes("-z") ||
|
|
13
|
+
flags.includes("--ignored") ||
|
|
14
|
+
flags.some((flag) => flag.startsWith("--with-tree"))
|
|
15
|
+
)
|
|
16
|
+
return exec(command, args, options);
|
|
17
|
+
const separator = flags.indexOf("--");
|
|
18
|
+
const filters = separator === -1 ? [] : flags.slice(separator + 1);
|
|
19
|
+
if (
|
|
20
|
+
filters.some(
|
|
21
|
+
(path) =>
|
|
22
|
+
!path.startsWith(":(top,literal)") &&
|
|
23
|
+
!path.startsWith(":(top,exclude,literal)") &&
|
|
24
|
+
/[*?[\]:]/.test(path),
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
return exec(command, args, options);
|
|
28
|
+
const allowed = new Set([
|
|
29
|
+
"-z",
|
|
30
|
+
"--cached",
|
|
31
|
+
"--others",
|
|
32
|
+
"--exclude-standard",
|
|
33
|
+
"--full-name",
|
|
34
|
+
]);
|
|
35
|
+
if (
|
|
36
|
+
(separator === -1 ? flags : flags.slice(0, separator)).some(
|
|
37
|
+
(flag) => !allowed.has(flag),
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
return exec(command, args, options);
|
|
41
|
+
const key = `${options.cwd}\0${flags.includes("--full-name")}`;
|
|
42
|
+
let snapshot = snapshots.get(key);
|
|
43
|
+
if (!snapshot) {
|
|
44
|
+
snapshot = exec(
|
|
45
|
+
"git",
|
|
46
|
+
[
|
|
47
|
+
"ls-files",
|
|
48
|
+
"--cached",
|
|
49
|
+
"--others",
|
|
50
|
+
"--exclude-standard",
|
|
51
|
+
...(flags.includes("--full-name") ? ["--full-name"] : []),
|
|
52
|
+
"-t",
|
|
53
|
+
"-z",
|
|
54
|
+
],
|
|
55
|
+
options,
|
|
56
|
+
);
|
|
57
|
+
snapshots.set(key, snapshot);
|
|
58
|
+
}
|
|
59
|
+
const result = await snapshot;
|
|
60
|
+
if (result.code !== 0 || result.killed) return result;
|
|
61
|
+
const entries = result.stdout.split("\0").filter(Boolean);
|
|
62
|
+
const othersOnly =
|
|
63
|
+
flags.includes("--others") && !flags.includes("--cached");
|
|
64
|
+
const trackedOnly = !flags.includes("--others");
|
|
65
|
+
const include = filters.filter(
|
|
66
|
+
(path) => !path.startsWith(":(top,exclude,literal)"),
|
|
67
|
+
);
|
|
68
|
+
const exclude = filters
|
|
69
|
+
.filter((path) => path.startsWith(":(top,exclude,literal)"))
|
|
70
|
+
.map((path) => path.slice(":(top,exclude,literal)".length));
|
|
71
|
+
const matches = (path: string, filter: string) => {
|
|
72
|
+
const literal = filter.replace(/^:\(top,literal\)/, "");
|
|
73
|
+
return path === literal || path.startsWith(`${literal}/`);
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
...result,
|
|
77
|
+
stdout: entries
|
|
78
|
+
.filter(
|
|
79
|
+
(entry) =>
|
|
80
|
+
!(othersOnly && entry[0] !== "?") &&
|
|
81
|
+
!(trackedOnly && entry[0] === "?"),
|
|
82
|
+
)
|
|
83
|
+
.map((entry) => entry.slice(2))
|
|
84
|
+
.filter(
|
|
85
|
+
(path) =>
|
|
86
|
+
(!include.length ||
|
|
87
|
+
include.some((filter) => matches(path, filter))) &&
|
|
88
|
+
!exclude.some((filter) => matches(path, filter)),
|
|
89
|
+
)
|
|
90
|
+
.map((path) => `${path}\0`)
|
|
91
|
+
.join(""),
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
}
|