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,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
|
+
}
|
package/src/adapters/files.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
: {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
}
|
package/src/adapters/find.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"--
|
|
101
|
-
"--
|
|
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 {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
cause: signal?.aborted ? "cancelled" : "git_failure",
|
|
178
|
+
error: `Cannot prefilter files: ${String(error)}`,
|
|
179
|
+
};
|
|
150
180
|
}
|
|
151
181
|
}
|
package/src/adapters/git-base.ts
CHANGED
|
@@ -16,16 +16,22 @@ export async function resolveBase(
|
|
|
16
16
|
signal,
|
|
17
17
|
});
|
|
18
18
|
if (result.killed)
|
|
19
|
-
return {
|
|
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
|
}
|