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
|
@@ -18,6 +18,7 @@ Code splits the file into declarations or sections and constructs a choice over
|
|
|
18
18
|
|
|
19
19
|
| Field | Type / default | Meaning |
|
|
20
20
|
|---|---|---|
|
|
21
|
+
| `root` | Optional nonempty string | Exact initial Git root or registered worktree of the same repository; see [evidence-root admission](../../README.md#evidence-root). |
|
|
21
22
|
| `path` | Required nonempty string | One repository-relative file, at least 19,000 bytes. |
|
|
22
23
|
| `goal` | Required nonempty string | Sentence describing the behavior you need to find. |
|
|
23
24
|
|
|
@@ -38,10 +39,14 @@ Illustrative call with a fictional repository path, not a recorded execution:
|
|
|
38
39
|
|
|
39
40
|
A result names `path:start-end`, a label, probability and the next read. Above 0.7 an unmarked range is a verdict: read it. From 0.4 through 0.7, unsure lists the two best ranges by probability: read both, not an arbitrary next section. Below 0.4 the tool narrows to three leading candidates plus none and judges again, retaining the original unsure band. Option-order sensitivity can also leave the result unsure. `none` means no supplied section fits: search elsewhere or clarify the goal. See [shared result reading](../../README.md#read-the-results).
|
|
40
41
|
|
|
42
|
+
The versioned report preserves actual primary/control values and their fresh/cache source. Missing required control answers produce unjudged work rather than a synthesized range or probability. Typed diagnostics retain parsing/window omissions and native next actions, including searching elsewhere for a judged `none`.
|
|
43
|
+
|
|
41
44
|
## Limits and failure behavior
|
|
42
45
|
|
|
43
46
|
Small files are refused with direct-read guidance. Whole-file admission is bounded by 320,000 characters and 1,280,000 bytes; larger files take the streamed-window path, not silent whole-file truncation. Window serialization, fallback section sizes, parsing support and choice cardinality are separately bounded. Unreadable or invalid evidence, missing configuration and exhausted session budgets are reported rather than producing a range verdict. Missing optional Python grammar is named with installation guidance. A selected window does not prove every relevant declaration was inspected.
|
|
44
47
|
|
|
48
|
+
The serialized judgment budget includes per-call evidence provenance. Block planning, excerpt allocation and full-text refinement reserve that metadata capacity before selecting evidence; provenance never silently pushes an otherwise planned request past admission. Selected blocks still require at least two sections and obey the choice limit.
|
|
49
|
+
|
|
45
50
|
## Host differences
|
|
46
51
|
|
|
47
52
|
omp `read` already outlines code files: use this tool when that outline does not tell you which range serves your goal. Both hosts use the same judgment protocol; omp marks the tool read-only.
|
|
@@ -20,6 +20,7 @@ Residual exported-unit checks concern changed units not exercised by any discove
|
|
|
20
20
|
|
|
21
21
|
| Field | Type / default | Meaning |
|
|
22
22
|
|---|---|---|
|
|
23
|
+
| `root` | Optional nonempty string | Exact initial Git root or registered worktree of the same repository; see [evidence-root admission](../../README.md#evidence-root). |
|
|
23
24
|
| `base` | Optional nonempty Git ref, default `HEAD` | Compare the current tree with this revision. |
|
|
24
25
|
| `paths` | Optional array of nonempty strings, default discovered inventory | Candidate test files or globs, not changed source paths. |
|
|
25
26
|
| `witnesses` | Optional `"off"`, `"auto"` (default), `"on"` | Controls residual coverage checks; test pointers have no witnesses. |
|
|
@@ -27,6 +28,8 @@ Residual exported-unit checks concern changed units not exercised by any discove
|
|
|
27
28
|
|
|
28
29
|
Unknown fields and unresolved refs are rejected with guidance. Repository-relative evidence paths are confined to the repository; internal URLs are not file inputs. Narrowing candidate paths also narrows the inventory to which coverage observations apply.
|
|
29
30
|
|
|
31
|
+
Each requested criterion reports its matches and any excluded or unmatched inventory. Partial matches keep the matched selection and identify the unmatched criteria; zero matches are not proof of no affected tests. Changed files outside the supported dependency graph retain conservative widening, with the triggering paths named. Runner commands remain static plans and use the admitted root.
|
|
32
|
+
|
|
30
33
|
## Example
|
|
31
34
|
|
|
32
35
|
Illustrative call with fictional repository paths, not a recorded execution:
|
|
@@ -44,7 +47,7 @@ Illustrative call with fictional repository paths, not a recorded execution:
|
|
|
44
47
|
|
|
45
48
|
Inspect selected scenarios and the returned runner commands, then execute them yourself. A scenario is selected when `1 - p(none) >= 0.5`; missing answers are selected too. If at least 80% of a file's scenarios are selected, names are uncertain or counts unknown, the command runs the whole file instead of a fragile name filter. Commands remain separate when runner context differs.
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
Static selection identifies its deterministic reason separately from fresh/cache Jev judgments. Conservative fallback preserves tests when judgments are unavailable; it is not confirmation that every scenario is affected. Residual absence is a derived reservation linked to retained coverage items or the considered inventory, not an extra requested judgment or fabricated probability. It does not establish repository-wide uncovered behavior. Read material limits and follow the named native action. See [shared result reading](../../README.md#read-the-results).
|
|
48
51
|
|
|
49
52
|
## Limits and failure behavior
|
|
50
53
|
|
package/package.json
CHANGED
package/rules/jev-ask.md
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
alwaysApply: true
|
|
3
3
|
---
|
|
4
|
-
|
|
4
|
+
<!-- Generated policy 2026-10-03.1 from src/texts/instructions.ts; run node scripts/generate-instructions.ts after editing the source. -->
|
|
5
|
+
Use Jev for a bounded semantic judgment when its answer could change an open decision or focus the next inspection, and the relevant evidence is available. Use decisive reading, search or authorized execution directly when it settles the question. Exact lookup, counting and runtime causality belong to native tools. A Jev call is not a prerequisite for a conclusion, a review or task completion; no explanation is needed for choosing native tools.
|
|
6
|
+
|
|
7
|
+
Before a chosen call, identify the open decision and supply only the evidence needed for a bounded question; a complete prior analysis is not required. Ask about one positive, self-contained observable fact per claim, with its sources, and show both sides of a comparison.
|
|
8
|
+
- Failure: relevant output, the failing test and the implementation it exercises; output alone is a lead, not a bug-versus-wrong-test diagnosis. Include configuration or runtime evidence when the hypothesis depends on it. A static judgment does not establish runtime causality.
|
|
9
|
+
- Change: current evidence and the earlier reference via base. Use the checkout/root corresponding to the work and admitted by the tool; another clone is not the same context.
|
|
10
|
+
- Plan/documentation: precise observable commitments and the passages that constrain them. Local confirmation does not establish that omitted obligations were searched.
|
|
11
|
+
- Selection/review: compatible inventory, references and configuration. Suggested commands are not executed commands; existing tests need not cover a new scenario. Read the diff natively for its contents, not as proof of safety or global coverage.
|
|
12
|
+
|
|
13
|
+
After an unavailable, refused or out-of-scope result, continue natively within the existing permissions; do not widen sharing or confinement to obtain a judgment. For uncertainty or missing evidence, inspect or obtain the decisive piece, or leave the conclusion open. Revisit Jev only when new evidence, a material context change or a new useful question makes the judgment useful; rewording unchanged evidence is not a reason to retry. There is no retry quota for genuinely changed evidence, and no certification is needed once decisive evidence settles the question. A reached cap or an unjudged result is not evidence of safety or zero affected tests.
|
|
14
|
+
|
|
15
|
+
jev_check_diff: Review a stable diff for semantic risks, stale documentation or specification drift when that review can inform an open decision. Read the diff natively when you need its contents. Choose risk, docs or spec for the question at hand; neither risk followed by docs nor a Jev review before done is required. Findings are leads within the inspected scope, not proof of global safety or completeness.
|
|
16
|
+
|
|
17
|
+
Report current conclusions first, then decisive evidence, origin and scope, then material reservations. Established means supported by relevant decisive evidence; a reported check remains explicitly reported, not observed execution. Distinguish native reading/execution, Jev's static judgment and testimony. A call count or global status is not proof.
|
|
18
|
+
If native evidence settles the same scope and context after a Jev uncertainty, attribute the current conclusion to that evidence; old unsure or abstain results need not be recited as current reservations. A later Jev confirmation is attributed to Jev and retains its static limits. Independent reservations survive either resolution.
|
|
19
|
+
Where an uncertainty actually returned by Jev remains material, explicitly say “Jev did not confirm X”, identify the missing evidence or guarantee, its known or indeterminate impact and the evidence to obtain. Preserve that reservation in the main text, summary and recommendation. Name native reservations and unjudged work as such, not as Jev uncertainty. Unresolved contradiction, stale evidence or a different checkout/base/scenario remains a reservation; the latest favorable answer does not win by default.
|
|
20
|
+
Keep material limits in the main text, not only behind a link. Do not generalize a checked scenario into a universal guarantee. Separate relevant unestablished hypotheses from observations; omit irrelevant hypotheses. Explain unavailable historical causes only when material to action or requested, without inventing causality.
|
|
21
|
+
Use existing history references when useful or requested; no exhaustive historical relay, persistent register or History block is required. A requested audit details available steps separately from the current state and does not reopen settled conclusions. Preserve existing traces; identify unavailable traces when they limit evidence or audit, without reconstructing evidence, executions or call counts. pi/OMP may reference an existing trace; MCP references must be client-accessible or explicitly unavailable.
|
|
22
|
+
|
|
23
|
+
pi and OMP retain an automatic, opt-out documentation check at task completion (JEV_TOOLS_AUTO_DOCS=0 disables it). It is a host feature, not an agent requirement to call Jev. Inspect any flagged sentence against the change and update it or explain with evidence why it remains correct. The check is limited and does not certify documentation completeness or demonstrated usefulness. A flagged sentence does not require a second call or recursive reviews.
|
|
24
|
+
|
|
25
|
+
Evidence passed to jev_* tools leaves the machine for the configured endpoint. Review its data handling; share only authorized evidence, never secrets or credentials. Host/client approvals still apply. jev_ask commands run with normal shell permissions and no sandbox; prefer read-only commands. Tool choice does not relax quality, proof, approval or confidentiality obligations.
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.NomenAK/jev-agent-tools",
|
|
4
4
|
"title": "Jev agent tools",
|
|
5
5
|
"description": "Evidence-oriented code questions, diff review and test selection via a Jev endpoint.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.3.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/NomenAK/jev-tools",
|
|
9
9
|
"source": "github"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
16
16
|
"identifier": "jev-agent-tools",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.3.0",
|
|
18
18
|
"runtimeHint": "npx",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
@@ -23,7 +23,7 @@ export async function collectAskFiles(
|
|
|
23
23
|
paths: readonly string[],
|
|
24
24
|
signal?: AbortSignal,
|
|
25
25
|
exec?: GitExec,
|
|
26
|
-
): Promise<Result<{ files: AskFile[]; skipped: string[] }>> {
|
|
26
|
+
): Promise<Result<{ files: AskFile[]; skipped: string[]; secret: string[] }>> {
|
|
27
27
|
if (!paths.length)
|
|
28
28
|
return {
|
|
29
29
|
ok: false,
|
|
@@ -42,6 +42,7 @@ export async function collectAskFiles(
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
const skipped = new Set<string>();
|
|
45
|
+
const secret = new Set<string>();
|
|
45
46
|
const candidates = new Set<string>();
|
|
46
47
|
try {
|
|
47
48
|
let inventory: Set<string> | undefined;
|
|
@@ -63,7 +64,9 @@ export async function collectAskFiles(
|
|
|
63
64
|
inventory,
|
|
64
65
|
);
|
|
65
66
|
if (!admission.ok) {
|
|
66
|
-
|
|
67
|
+
const entry = `${path} (${admission.error})`;
|
|
68
|
+
skipped.add(entry);
|
|
69
|
+
if (admission.cause === "secret_pattern") secret.add(entry);
|
|
67
70
|
return;
|
|
68
71
|
}
|
|
69
72
|
if (path.split("/").some((part) => excludedDirectories.has(part)))
|
|
@@ -229,7 +232,12 @@ export async function collectAskFiles(
|
|
|
229
232
|
};
|
|
230
233
|
}
|
|
231
234
|
}
|
|
232
|
-
return {
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
files,
|
|
238
|
+
skipped: [...skipped].sort(),
|
|
239
|
+
secret: [...secret].sort(),
|
|
240
|
+
};
|
|
233
241
|
} catch (error) {
|
|
234
242
|
return { ok: false, error: `Cannot expand paths: ${String(error)}` };
|
|
235
243
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { relative, resolve } from "node:path";
|
|
1
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
2
|
import { STATE_MAX_CHARS, TIMEOUT_MS } from "../constants.ts";
|
|
3
3
|
import type { GitExec } from "../core/git.ts";
|
|
4
4
|
import type { ImportSource } from "../core/imports.ts";
|
|
5
5
|
import type { Result } from "../result.ts";
|
|
6
|
-
import { collectFiles } from "./files.ts";
|
|
6
|
+
import { checkFileAdmission, collectFiles } from "./files.ts";
|
|
7
7
|
import { verifyGitUtf8 } from "./utf8.ts";
|
|
8
8
|
|
|
9
9
|
export interface AskRepository {
|
|
10
10
|
root: string;
|
|
11
11
|
known: ReadonlySet<string>;
|
|
12
|
+
beforeKnown: ReadonlySet<string>;
|
|
12
13
|
baseSha?: string;
|
|
13
14
|
read(path: string): Promise<Result<ImportSource>>;
|
|
14
15
|
readBefore(path: string): Promise<Result<{ text: string | null }>>;
|
|
@@ -69,18 +70,20 @@ export async function collectAskRepository(
|
|
|
69
70
|
for (const path of ignored.stdout.split("\0")) known.delete(path);
|
|
70
71
|
const baseSha = revision?.stdout.trim();
|
|
71
72
|
const basePaths = baseSha
|
|
72
|
-
? await exec(
|
|
73
|
-
"git",
|
|
74
|
-
["ls-tree", "-r", "--name-only", "-z", baseSha],
|
|
75
|
-
gitOptions,
|
|
76
|
-
)
|
|
73
|
+
? await exec("git", ["ls-tree", "-r", "-z", baseSha], gitOptions)
|
|
77
74
|
: undefined;
|
|
78
75
|
if (basePaths && (basePaths.code !== 0 || basePaths.killed))
|
|
79
76
|
return {
|
|
80
77
|
ok: false,
|
|
81
78
|
error: basePaths.stderr || "Cannot list base paths.",
|
|
82
79
|
};
|
|
83
|
-
const
|
|
80
|
+
const beforeEntries = new Map(
|
|
81
|
+
(basePaths?.stdout.split("\0").filter(Boolean) ?? []).map((entry) => [
|
|
82
|
+
entry.slice(entry.indexOf("\t") + 1),
|
|
83
|
+
entry,
|
|
84
|
+
]),
|
|
85
|
+
);
|
|
86
|
+
const beforeKnown = new Set(beforeEntries.keys());
|
|
84
87
|
const reads = new Map<string, Promise<Result<ImportSource>>>();
|
|
85
88
|
const beforeReads = new Map<
|
|
86
89
|
string,
|
|
@@ -90,6 +93,7 @@ export async function collectAskRepository(
|
|
|
90
93
|
ok: true,
|
|
91
94
|
root,
|
|
92
95
|
known,
|
|
96
|
+
beforeKnown,
|
|
93
97
|
baseSha,
|
|
94
98
|
read(path) {
|
|
95
99
|
let pending = reads.get(path);
|
|
@@ -117,9 +121,53 @@ export async function collectAskRepository(
|
|
|
117
121
|
let pending = beforeReads.get(path);
|
|
118
122
|
if (!pending) {
|
|
119
123
|
pending = (async () => {
|
|
120
|
-
if (!baseSha || !beforeKnown.has(path))
|
|
121
|
-
return { ok: true as const, text: null };
|
|
122
124
|
try {
|
|
125
|
+
if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
|
|
126
|
+
return {
|
|
127
|
+
ok: false as const,
|
|
128
|
+
cause: "forbidden_path" as const,
|
|
129
|
+
error: `Path must remain inside the repository: ${path}.`,
|
|
130
|
+
};
|
|
131
|
+
const admission = await checkFileAdmission(root, path);
|
|
132
|
+
if (!admission.ok) return admission;
|
|
133
|
+
const ignoredPath = await exec(
|
|
134
|
+
"git",
|
|
135
|
+
["check-ignore", "--no-index", "--", path],
|
|
136
|
+
gitOptions,
|
|
137
|
+
);
|
|
138
|
+
if (ignoredPath.killed || ![0, 1].includes(ignoredPath.code))
|
|
139
|
+
return {
|
|
140
|
+
ok: false as const,
|
|
141
|
+
cause: "git_failure" as const,
|
|
142
|
+
error: `${path}: historical admission unavailable`,
|
|
143
|
+
};
|
|
144
|
+
if (ignoredPath.code === 0)
|
|
145
|
+
return {
|
|
146
|
+
ok: false as const,
|
|
147
|
+
cause: "ignored_path" as const,
|
|
148
|
+
error: `${path}: gitignored: not sent to Jev`,
|
|
149
|
+
};
|
|
150
|
+
if (!baseSha)
|
|
151
|
+
return {
|
|
152
|
+
ok: false as const,
|
|
153
|
+
error: `${path}: comparison base unavailable`,
|
|
154
|
+
};
|
|
155
|
+
if (!beforeKnown.has(path))
|
|
156
|
+
return known.has(path)
|
|
157
|
+
? { ok: true as const, text: null }
|
|
158
|
+
: {
|
|
159
|
+
ok: false as const,
|
|
160
|
+
error: `${path}: not in current or comparison base inventory`,
|
|
161
|
+
};
|
|
162
|
+
const entry = beforeEntries.get(path) ?? "";
|
|
163
|
+
if (!/^(?:100644|100755) blob /.test(entry))
|
|
164
|
+
return {
|
|
165
|
+
ok: false as const,
|
|
166
|
+
cause: entry.startsWith("120000 ")
|
|
167
|
+
? ("symlink" as const)
|
|
168
|
+
: ("file_unavailable" as const),
|
|
169
|
+
error: `${path}: not a regular file at comparison base: not sent to Jev`,
|
|
170
|
+
};
|
|
123
171
|
const object = await exec(
|
|
124
172
|
"git",
|
|
125
173
|
[
|
|
@@ -172,10 +220,20 @@ export async function collectAskRepository(
|
|
|
172
220
|
ok: false as const,
|
|
173
221
|
error: `before evidence unavailable for ${path}`,
|
|
174
222
|
};
|
|
223
|
+
if (loaded.stdout.includes("\0"))
|
|
224
|
+
return {
|
|
225
|
+
ok: false as const,
|
|
226
|
+
cause: "binary_or_non_utf8" as const,
|
|
227
|
+
error: `${path}: binary content: not sent to Jev`,
|
|
228
|
+
};
|
|
175
229
|
const decoded = verifyGitUtf8(loaded.stdout, id, size);
|
|
176
230
|
return decoded.ok
|
|
177
231
|
? decoded
|
|
178
|
-
: {
|
|
232
|
+
: {
|
|
233
|
+
ok: false as const,
|
|
234
|
+
cause: "binary_or_non_utf8" as const,
|
|
235
|
+
error: `${path}: ${decoded.error}`,
|
|
236
|
+
};
|
|
179
237
|
} catch (error) {
|
|
180
238
|
return { ok: false as const, error: String(error) };
|
|
181
239
|
}
|
package/src/adapters/command.ts
CHANGED
|
@@ -32,12 +32,47 @@ export interface CommandOutput {
|
|
|
32
32
|
compressed: boolean;
|
|
33
33
|
truncated: boolean;
|
|
34
34
|
}
|
|
35
|
+
const TRUNCATION_MARK = `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`;
|
|
36
|
+
/** Shortest key prefix worth redacting when a line cut leaves only its start. */
|
|
37
|
+
const SECRET_PREFIX_MIN = 4;
|
|
38
|
+
/**
|
|
39
|
+
* Replace every occurrence of `secret` in `text`, counting replacements. A
|
|
40
|
+
* line cut at OUTPUT_LINE_MAX_CHARS can end inside the key; that trailing key
|
|
41
|
+
* prefix is redacted too.
|
|
42
|
+
*/
|
|
43
|
+
export function redactSecret(
|
|
44
|
+
text: string,
|
|
45
|
+
secret: string | undefined,
|
|
46
|
+
): { text: string; count: number } {
|
|
47
|
+
if (!secret) return { text, count: 0 };
|
|
48
|
+
const parts = text.split(secret);
|
|
49
|
+
let result = parts.join("[redacted]");
|
|
50
|
+
let count = parts.length - 1;
|
|
51
|
+
if (result.endsWith(TRUNCATION_MARK)) {
|
|
52
|
+
const kept = result.slice(0, -TRUNCATION_MARK.length);
|
|
53
|
+
const min = Math.min(SECRET_PREFIX_MIN, secret.length);
|
|
54
|
+
for (
|
|
55
|
+
let length = Math.min(secret.length - 1, kept.length);
|
|
56
|
+
length >= min;
|
|
57
|
+
length--
|
|
58
|
+
) {
|
|
59
|
+
if (kept.endsWith(secret.slice(0, length))) {
|
|
60
|
+
result = `${kept.slice(0, -length)}[redacted]${TRUNCATION_MARK}`;
|
|
61
|
+
count++;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { text: result, count };
|
|
67
|
+
}
|
|
35
68
|
export async function captureCommand(
|
|
36
69
|
exec: GitExec,
|
|
37
70
|
cwd: string,
|
|
38
71
|
command: string,
|
|
39
72
|
timeoutS = ASK_TIMEOUT_S,
|
|
40
73
|
signal?: AbortSignal,
|
|
74
|
+
/** Configured Jev API key, replaced in the captured command and output. */
|
|
75
|
+
secret?: string,
|
|
41
76
|
): Promise<
|
|
42
77
|
Result<{
|
|
43
78
|
output: CommandOutput;
|
|
@@ -48,9 +83,19 @@ export async function captureCommand(
|
|
|
48
83
|
selectedPassages: boolean;
|
|
49
84
|
assertion: boolean;
|
|
50
85
|
targets: FailureTarget[];
|
|
51
|
-
|
|
86
|
+
/** Occurrences of `secret` replaced with `[redacted]`. */
|
|
87
|
+
redactions: number;
|
|
88
|
+
}> & {
|
|
89
|
+
commandExecution: "not_started" | "unknown" | "finished";
|
|
90
|
+
commandExitCode?: number | null;
|
|
91
|
+
commandTimedOut?: boolean;
|
|
92
|
+
}
|
|
52
93
|
> {
|
|
53
94
|
const directory = await mkdtemp(join(tmpdir(), "jev-output-"));
|
|
95
|
+
let commandExecution: "not_started" | "unknown" | "finished" = "not_started";
|
|
96
|
+
let completion:
|
|
97
|
+
| { commandExitCode: number | null; commandTimedOut: boolean }
|
|
98
|
+
| undefined;
|
|
54
99
|
try {
|
|
55
100
|
await chmod(directory, 0o700);
|
|
56
101
|
const stdoutPath = join(directory, "stdout");
|
|
@@ -65,7 +110,14 @@ export async function captureCommand(
|
|
|
65
110
|
try {
|
|
66
111
|
const shell = resolveShell();
|
|
67
112
|
// Fail closed: never spawn a bare name that PATH could resolve to WSL.
|
|
68
|
-
if (!shell.ok)
|
|
113
|
+
if (!shell.ok)
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
cause: "file_unavailable",
|
|
117
|
+
commandExecution: "not_started",
|
|
118
|
+
error: shell.error,
|
|
119
|
+
};
|
|
120
|
+
commandExecution = "unknown";
|
|
69
121
|
executed = await exec(
|
|
70
122
|
shell.executable,
|
|
71
123
|
[
|
|
@@ -78,26 +130,18 @@ export async function captureCommand(
|
|
|
78
130
|
],
|
|
79
131
|
{ cwd, timeout: timeoutS * 1000, signal },
|
|
80
132
|
);
|
|
133
|
+
commandExecution = "finished";
|
|
134
|
+
completion = {
|
|
135
|
+
commandExitCode: executed.killed ? null : executed.code,
|
|
136
|
+
commandTimedOut: executed.killed,
|
|
137
|
+
};
|
|
81
138
|
} catch (error) {
|
|
82
139
|
signal?.throwIfAborted();
|
|
83
140
|
return {
|
|
84
|
-
ok:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
timed_out: false,
|
|
89
|
-
stdout: "",
|
|
90
|
-
stderr: `Command executable unavailable: ${String(error)}`,
|
|
91
|
-
compressed: true,
|
|
92
|
-
truncated: false,
|
|
93
|
-
},
|
|
94
|
-
originalBytes: 0,
|
|
95
|
-
compressedChars: 0,
|
|
96
|
-
lineOmittedChars: 0,
|
|
97
|
-
shapeLimitExceeded: false,
|
|
98
|
-
selectedPassages: false,
|
|
99
|
-
assertion: false,
|
|
100
|
-
targets: [],
|
|
141
|
+
ok: false,
|
|
142
|
+
cause: "file_unavailable",
|
|
143
|
+
commandExecution,
|
|
144
|
+
error: `Command executable unavailable: ${String(error)}`,
|
|
101
145
|
};
|
|
102
146
|
}
|
|
103
147
|
signal?.throwIfAborted();
|
|
@@ -119,6 +163,9 @@ export async function captureCommand(
|
|
|
119
163
|
if (sizes.some((size) => size > OUTPUT_FILE_MAX_BYTES))
|
|
120
164
|
return {
|
|
121
165
|
ok: false,
|
|
166
|
+
cause: "evidence_too_large",
|
|
167
|
+
commandExecution: "finished",
|
|
168
|
+
...completion,
|
|
122
169
|
error: `output exceeded ${OUTPUT_FILE_MAX_BYTES} bytes per stream; narrow command`,
|
|
123
170
|
};
|
|
124
171
|
const targets: FailureTarget[] = [];
|
|
@@ -128,19 +175,23 @@ export async function captureCommand(
|
|
|
128
175
|
let lineOmittedChars = 0;
|
|
129
176
|
let shapeLimitExceeded = false;
|
|
130
177
|
const texts: string[] = [];
|
|
178
|
+
let redactions = 0;
|
|
179
|
+
const redacted = (text: string): string => {
|
|
180
|
+
const result = redactSecret(text, secret);
|
|
181
|
+
redactions += result.count;
|
|
182
|
+
return result.text;
|
|
183
|
+
};
|
|
131
184
|
for (const [index, path] of [stdoutPath, stderrPath].entries()) {
|
|
132
185
|
if (!sizes[index]) {
|
|
133
|
-
texts.push(index === 0 ? executed.stdout : executed.stderr);
|
|
186
|
+
texts.push(redacted(index === 0 ? executed.stdout : executed.stderr));
|
|
134
187
|
continue;
|
|
135
188
|
}
|
|
136
189
|
const frequencies = new Map<string, number>();
|
|
137
190
|
let shapeLimit = false;
|
|
138
191
|
for await (const raw of outputLines(path, signal)) {
|
|
139
192
|
signal?.throwIfAborted();
|
|
140
|
-
const line = cleanOutput(raw);
|
|
141
|
-
linesTruncated ||= raw.endsWith(
|
|
142
|
-
`…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`,
|
|
143
|
-
);
|
|
193
|
+
const line = redactSecret(cleanOutput(raw), secret).text;
|
|
194
|
+
linesTruncated ||= raw.endsWith(TRUNCATION_MARK);
|
|
144
195
|
const shape = lineShape(line);
|
|
145
196
|
if (
|
|
146
197
|
!frequencies.has(shape) &&
|
|
@@ -187,17 +238,17 @@ export async function captureCommand(
|
|
|
187
238
|
lineOmittedChars += chars;
|
|
188
239
|
})) {
|
|
189
240
|
signal?.throwIfAborted();
|
|
190
|
-
linesTruncated ||= raw.endsWith(
|
|
191
|
-
|
|
192
|
-
);
|
|
193
|
-
compressor.line(
|
|
241
|
+
linesTruncated ||= raw.endsWith(TRUNCATION_MARK);
|
|
242
|
+
// Redact before compression, failure windows and state assembly.
|
|
243
|
+
const line = redacted(cleanOutput(raw));
|
|
244
|
+
compressor.line(line);
|
|
194
245
|
if (!failureSeen) {
|
|
195
|
-
failureWindow.push(
|
|
246
|
+
failureWindow.push(line);
|
|
196
247
|
if (failureWindow.length > OUTPUT_FAILURE_WINDOW_LINES + 1)
|
|
197
248
|
failureWindow.shift();
|
|
198
249
|
if (isAnchor(raw)) failureSeen = true;
|
|
199
250
|
} else if (afterFailure++ < OUTPUT_FAILURE_WINDOW_LINES) {
|
|
200
|
-
failureWindow.push(
|
|
251
|
+
failureWindow.push(line);
|
|
201
252
|
if (!secondSeen && afterFailure > 1 && isFailingTestsHeader(raw)) {
|
|
202
253
|
secondSeen = true;
|
|
203
254
|
afterSecond = 0;
|
|
@@ -206,10 +257,10 @@ export async function captureCommand(
|
|
|
206
257
|
if (isFailingTestsHeader(raw)) {
|
|
207
258
|
secondSeen = true;
|
|
208
259
|
afterSecond = 0;
|
|
209
|
-
failureWindow.push(
|
|
260
|
+
failureWindow.push(line);
|
|
210
261
|
}
|
|
211
262
|
} else if (afterSecond++ < OUTPUT_FAILURE_WINDOW_LINES) {
|
|
212
|
-
failureWindow.push(
|
|
263
|
+
failureWindow.push(line);
|
|
213
264
|
}
|
|
214
265
|
}
|
|
215
266
|
compressor.finish();
|
|
@@ -220,8 +271,10 @@ export async function captureCommand(
|
|
|
220
271
|
}
|
|
221
272
|
return {
|
|
222
273
|
ok: true,
|
|
274
|
+
commandExecution: "finished",
|
|
275
|
+
...completion,
|
|
223
276
|
output: {
|
|
224
|
-
command,
|
|
277
|
+
command: redacted(command),
|
|
225
278
|
exit_code: executed.killed ? null : executed.code,
|
|
226
279
|
timed_out: executed.killed,
|
|
227
280
|
stdout: texts[0] ?? "",
|
|
@@ -236,6 +289,16 @@ export async function captureCommand(
|
|
|
236
289
|
selectedPassages: false,
|
|
237
290
|
assertion: signature === "assertion",
|
|
238
291
|
targets,
|
|
292
|
+
redactions,
|
|
293
|
+
};
|
|
294
|
+
} catch (error) {
|
|
295
|
+
signal?.throwIfAborted();
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
cause: "file_unavailable",
|
|
299
|
+
commandExecution,
|
|
300
|
+
...completion,
|
|
301
|
+
error: `Command capture unavailable: ${String(error)}`,
|
|
239
302
|
};
|
|
240
303
|
} finally {
|
|
241
304
|
await rm(directory, { recursive: true, force: true });
|
package/src/adapters/docs.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "../core/docs.ts";
|
|
6
6
|
import type { GitExec } from "../core/git.ts";
|
|
7
7
|
import type { Result } from "../result.ts";
|
|
8
|
+
import type { Cause } from "../result-types.ts";
|
|
8
9
|
import {
|
|
9
10
|
checkFileAdmission,
|
|
10
11
|
openRepoFile,
|
|
@@ -20,7 +21,7 @@ export interface DocsInventory {
|
|
|
20
21
|
cwd: string;
|
|
21
22
|
files: DocsSource[];
|
|
22
23
|
tracked: ReadonlySet<string>;
|
|
23
|
-
limits: { path: string; reason: string }[];
|
|
24
|
+
limits: { path: string; reason: string; cause?: Cause }[];
|
|
24
25
|
read: (path: string) => Promise<DocsSource | undefined>;
|
|
25
26
|
}
|
|
26
27
|
/** Inventory paths eagerly, but load only Markdown/configuration and subsequently reached sources. */
|
|
@@ -37,6 +38,7 @@ export async function collectDocsInventory(
|
|
|
37
38
|
if (root.code || root.killed)
|
|
38
39
|
return {
|
|
39
40
|
ok: false,
|
|
41
|
+
cause: signal?.aborted ? "cancelled" : "git_failure",
|
|
40
42
|
error: root.stderr.trim() || "Repository root not found.",
|
|
41
43
|
};
|
|
42
44
|
cwd = root.stdout.trim();
|
|
@@ -48,6 +50,7 @@ export async function collectDocsInventory(
|
|
|
48
50
|
if (list.code || list.killed)
|
|
49
51
|
return {
|
|
50
52
|
ok: false,
|
|
53
|
+
cause: signal?.aborted ? "cancelled" : "git_failure",
|
|
51
54
|
error: list.stderr.trim() || "Unable to inventory tracked files.",
|
|
52
55
|
};
|
|
53
56
|
const tracked = new Set(list.stdout.split("\0").filter(Boolean));
|
|
@@ -70,7 +73,11 @@ export async function collectDocsInventory(
|
|
|
70
73
|
inventory: tracked,
|
|
71
74
|
});
|
|
72
75
|
if (!opened.ok) {
|
|
73
|
-
limits.push({
|
|
76
|
+
limits.push({
|
|
77
|
+
path,
|
|
78
|
+
reason: opened.error,
|
|
79
|
+
...(opened.cause ? { cause: opened.cause } : {}),
|
|
80
|
+
});
|
|
74
81
|
return undefined;
|
|
75
82
|
}
|
|
76
83
|
const handle = opened.handle;
|
|
@@ -168,18 +175,22 @@ export function docsDeclarationSearch(
|
|
|
168
175
|
const path = candidates[index++];
|
|
169
176
|
if (path === undefined) return;
|
|
170
177
|
const location = await resolveInsideRepo(inventory.cwd, path);
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
(
|
|
174
|
-
await checkFileAdmission(
|
|
178
|
+
const admission = location.ok
|
|
179
|
+
? await checkFileAdmission(
|
|
175
180
|
inventory.cwd,
|
|
176
181
|
location.rel,
|
|
177
182
|
exec,
|
|
178
183
|
signal,
|
|
179
184
|
inventory.tracked,
|
|
180
185
|
)
|
|
181
|
-
|
|
182
|
-
if (
|
|
186
|
+
: location;
|
|
187
|
+
if (admission.ok) paths.push(path);
|
|
188
|
+
else if (admission.cause === "secret_pattern")
|
|
189
|
+
inventory.limits.push({
|
|
190
|
+
path,
|
|
191
|
+
reason: admission.error,
|
|
192
|
+
cause: admission.cause,
|
|
193
|
+
});
|
|
183
194
|
}
|
|
184
195
|
}),
|
|
185
196
|
);
|
|
@@ -188,12 +199,20 @@ export function docsDeclarationSearch(
|
|
|
188
199
|
const paths = await admittedPaths;
|
|
189
200
|
if (!paths.length) return new Map();
|
|
190
201
|
const patterns = ["-e", docsDeclarationPattern(names)];
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
let result:
|
|
203
|
+
| { stdout: string; stderr: string; code: number; killed: boolean }
|
|
204
|
+
| undefined;
|
|
205
|
+
try {
|
|
206
|
+
result = await exec("rg", ["--json", ...patterns, "--", ...paths], {
|
|
207
|
+
cwd: inventory.cwd,
|
|
208
|
+
timeout: TIMEOUT_MS,
|
|
209
|
+
signal,
|
|
210
|
+
});
|
|
211
|
+
} catch {
|
|
212
|
+
// A missing or unstartable rg is an unavailable search, not a failure.
|
|
213
|
+
signal?.throwIfAborted();
|
|
214
|
+
}
|
|
215
|
+
if (!result || result.killed || (result.code !== 0 && result.code !== 1)) {
|
|
197
216
|
inventory.limits.push({
|
|
198
217
|
path: "documentation",
|
|
199
218
|
reason: "declaration search unavailable",
|