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.
- package/CHANGELOG.md +37 -1
- package/CONTRIBUTING.md +3 -0
- package/README.md +22 -14
- package/SECURITY.md +17 -1
- package/dist/adapters/ask-files.js +11 -2
- package/dist/adapters/ask-proof.js +63 -7
- package/dist/adapters/command.js +82 -29
- package/dist/adapters/docs.js +30 -10
- package/dist/adapters/evidence-context.js +119 -0
- package/dist/adapters/files.js +141 -16
- package/dist/adapters/find.js +34 -6
- package/dist/adapters/git-base.js +7 -1
- package/dist/adapters/git.js +51 -7
- package/dist/adapters/locate-file.js +47 -9
- package/dist/adapters/private-storage.js +14 -6
- package/dist/adapters/risk-callers.js +3 -0
- package/dist/adapters/shell.js +23 -7
- package/dist/adapters/test-inventory.js +10 -2
- package/dist/configuration.js +17 -7
- package/dist/constants.js +26 -5
- package/dist/core/ask-references.js +193 -109
- package/dist/core/asks.js +78 -7
- package/dist/core/locate.js +8 -8
- package/dist/core/output.js +17 -0
- package/dist/core/result-report.js +302 -0
- package/dist/core/secret-path.js +34 -0
- package/dist/core/state.js +8 -1
- package/dist/core/units.js +1 -1
- package/dist/jev/client.js +34 -12
- package/dist/mcp/protocol.js +50 -27
- package/dist/mcp/tools.js +20 -7
- package/dist/render.js +72 -0
- package/dist/report-schema.js +1356 -0
- package/dist/result-types.js +1 -0
- package/dist/texts/ask-files.js +3 -1
- package/dist/texts/ask.js +3 -1
- package/dist/texts/check-diff.js +7 -4
- package/dist/texts/find.js +7 -2
- package/dist/texts/guide.js +3 -16
- package/dist/texts/instructions.js +72 -0
- package/dist/texts/locate.js +7 -2
- package/dist/texts/select-tests.js +3 -1
- package/dist/tools/ask-files.js +248 -15
- package/dist/tools/ask.js +523 -62
- package/dist/tools/check-diff.js +222 -30
- package/dist/tools/docs-check.js +122 -13
- package/dist/tools/find.js +320 -27
- package/dist/tools/locate.js +317 -18
- package/dist/tools/review-report.js +230 -0
- package/dist/tools/select-tests.js +273 -19
- package/dist/tools/spec-check.js +119 -22
- package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +3 -3
- package/docs/agent-instructions.md +59 -30
- package/docs/design.md +13 -1
- package/docs/mcp.md +8 -6
- package/docs/tools/jev_ask.md +8 -5
- package/docs/tools/jev_ask_files.md +2 -1
- package/docs/tools/jev_check_diff.md +4 -1
- package/docs/tools/jev_find_files.md +2 -1
- package/docs/tools/jev_locate_in_file.md +5 -0
- package/docs/tools/jev_select_tests.md +4 -1
- package/package.json +1 -1
- package/rules/jev-ask.md +22 -1
- package/server.json +2 -2
- package/src/adapters/ask-files.ts +11 -3
- package/src/adapters/ask-proof.ts +69 -11
- package/src/adapters/command.ts +96 -33
- package/src/adapters/docs.ts +33 -14
- package/src/adapters/evidence-context.ts +169 -0
- package/src/adapters/files.ts +146 -16
- package/src/adapters/find.ts +37 -7
- package/src/adapters/git-base.ts +7 -1
- package/src/adapters/git.ts +61 -8
- package/src/adapters/locate-file.ts +51 -9
- package/src/adapters/private-storage.ts +17 -5
- package/src/adapters/risk-callers.ts +3 -0
- package/src/adapters/shell.ts +23 -7
- package/src/adapters/test-inventory.ts +12 -4
- package/src/configuration.ts +16 -2
- package/src/constants.ts +26 -5
- package/src/core/ask-references.ts +262 -146
- package/src/core/asks.ts +79 -7
- package/src/core/import-boundaries.ts +8 -3
- package/src/core/locate.ts +8 -5
- package/src/core/output.ts +34 -0
- package/src/core/result-report.ts +410 -0
- package/src/core/secret-path.ts +37 -0
- package/src/core/state.ts +8 -1
- package/src/core/units.ts +3 -2
- package/src/index.ts +3 -0
- package/src/jev/client.ts +54 -16
- package/src/jev/types.ts +18 -3
- package/src/mcp/protocol.ts +91 -41
- package/src/mcp/tools.ts +26 -13
- package/src/render.ts +109 -0
- package/src/report-schema.ts +1380 -0
- package/src/result-types.ts +234 -0
- package/src/result.ts +4 -1
- package/src/runtime.ts +6 -0
- package/src/texts/ask-files.ts +4 -1
- package/src/texts/ask.ts +8 -1
- package/src/texts/check-diff.ts +7 -4
- package/src/texts/find.ts +8 -2
- package/src/texts/guide.ts +8 -16
- package/src/texts/instructions.ts +98 -0
- package/src/texts/locate.ts +8 -2
- package/src/texts/run-end.ts +2 -2
- package/src/texts/select-tests.ts +4 -1
- package/src/tools/ask-files.ts +309 -14
- package/src/tools/ask.ts +700 -77
- package/src/tools/check-diff.ts +331 -28
- package/src/tools/docs-check.ts +241 -39
- package/src/tools/find.ts +386 -29
- package/src/tools/locate.ts +384 -19
- package/src/tools/review-report.ts +308 -0
- package/src/tools/select-tests.ts +479 -21
- 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
|
+
}
|
package/dist/adapters/files.js
CHANGED
|
@@ -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 {
|
|
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
|
-
: {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
}
|
package/dist/adapters/find.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
"--
|
|
78
|
-
"--
|
|
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 {
|
|
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 {
|
|
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
|
}
|