jev-agent-tools 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/docs/design.md +187 -0
  5. package/docs/tools/jev_ask.md +92 -0
  6. package/docs/tools/jev_ask_files.md +80 -0
  7. package/docs/tools/jev_check_diff.md +77 -0
  8. package/docs/tools/jev_find_files.md +59 -0
  9. package/docs/tools/jev_locate_in_file.md +51 -0
  10. package/docs/tools/jev_select_tests.md +59 -0
  11. package/package.json +85 -0
  12. package/rules/jev-ask.md +4 -0
  13. package/src/adapters/analysis-context.ts +100 -0
  14. package/src/adapters/ask-files.ts +236 -0
  15. package/src/adapters/ask-proof.ts +202 -0
  16. package/src/adapters/ask-syntax.ts +463 -0
  17. package/src/adapters/command.ts +240 -0
  18. package/src/adapters/docs.ts +222 -0
  19. package/src/adapters/files.ts +411 -0
  20. package/src/adapters/find.ts +151 -0
  21. package/src/adapters/git-base.ts +32 -0
  22. package/src/adapters/git-inventory.ts +94 -0
  23. package/src/adapters/git.ts +525 -0
  24. package/src/adapters/locate-file.ts +197 -0
  25. package/src/adapters/output-lines.ts +50 -0
  26. package/src/adapters/risk-callers.ts +525 -0
  27. package/src/adapters/runner-version.ts +102 -0
  28. package/src/adapters/syntax.ts +229 -0
  29. package/src/adapters/test-inventory.ts +168 -0
  30. package/src/adapters/usage.ts +21 -0
  31. package/src/adapters/utf8.ts +57 -0
  32. package/src/constants.ts +109 -0
  33. package/src/core/ask-closure.ts +419 -0
  34. package/src/core/ask-proof.ts +32 -0
  35. package/src/core/ask-references.ts +249 -0
  36. package/src/core/asks.ts +616 -0
  37. package/src/core/batches.ts +83 -0
  38. package/src/core/command-output.ts +249 -0
  39. package/src/core/diff.ts +226 -0
  40. package/src/core/docs.ts +399 -0
  41. package/src/core/find.ts +157 -0
  42. package/src/core/git.ts +5 -0
  43. package/src/core/import-boundaries.ts +102 -0
  44. package/src/core/imports.ts +691 -0
  45. package/src/core/integrity.ts +64 -0
  46. package/src/core/lexical.ts +130 -0
  47. package/src/core/locate.ts +213 -0
  48. package/src/core/output.ts +264 -0
  49. package/src/core/pointer.ts +51 -0
  50. package/src/core/risk-callers.ts +1270 -0
  51. package/src/core/runner-version.ts +66 -0
  52. package/src/core/sections.ts +269 -0
  53. package/src/core/state.ts +53 -0
  54. package/src/core/syntax.ts +8 -0
  55. package/src/core/test-commands.ts +430 -0
  56. package/src/core/test-coverage.ts +103 -0
  57. package/src/core/test-discovery.ts +1695 -0
  58. package/src/core/test-evidence.ts +649 -0
  59. package/src/core/test-state.ts +99 -0
  60. package/src/core/truncate.ts +14 -0
  61. package/src/core/units.ts +531 -0
  62. package/src/describe.ts +26 -0
  63. package/src/guide.ts +42 -0
  64. package/src/host.ts +22 -0
  65. package/src/index.ts +40 -0
  66. package/src/jev/client.ts +505 -0
  67. package/src/jev/pool.ts +60 -0
  68. package/src/jev/types.ts +60 -0
  69. package/src/presets/docs.ts +85 -0
  70. package/src/presets/risk.ts +263 -0
  71. package/src/presets/spec.ts +111 -0
  72. package/src/presets/witnesses.ts +313 -0
  73. package/src/render.ts +45 -0
  74. package/src/result.ts +4 -0
  75. package/src/run-end.ts +157 -0
  76. package/src/runtime.ts +16 -0
  77. package/src/session.ts +106 -0
  78. package/src/texts/ask-files.ts +2 -0
  79. package/src/texts/ask.ts +4 -0
  80. package/src/texts/check-diff.ts +24 -0
  81. package/src/texts/configuration.ts +2 -0
  82. package/src/texts/find.ts +14 -0
  83. package/src/texts/guide.ts +16 -0
  84. package/src/texts/locate.ts +10 -0
  85. package/src/texts/run-end.ts +13 -0
  86. package/src/texts/select-tests.ts +3 -0
  87. package/src/tools/ask-files.ts +263 -0
  88. package/src/tools/ask-schema.ts +116 -0
  89. package/src/tools/ask.ts +925 -0
  90. package/src/tools/check-diff.ts +510 -0
  91. package/src/tools/docs-check.ts +399 -0
  92. package/src/tools/find.ts +529 -0
  93. package/src/tools/locate.ts +369 -0
  94. package/src/tools/select-tests.ts +746 -0
  95. package/src/tools/spec-check.ts +210 -0
@@ -0,0 +1,240 @@
1
+ import { chmod, mkdtemp, rm, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ ASK_TIMEOUT_S,
6
+ OUTPUT_CHUNK_CHARS,
7
+ OUTPUT_FAILURE_WINDOW_LINES,
8
+ OUTPUT_FILE_MAX_BYTES,
9
+ OUTPUT_FIND_MAX_CALLS,
10
+ OUTPUT_LINE_MAX_CHARS,
11
+ OUTPUT_REPEAT_MIN,
12
+ OUTPUT_SHAPE_MAX_COUNT,
13
+ } from "../constants.ts";
14
+ import {
15
+ cleanOutput,
16
+ createRarityCompressor,
17
+ type FailureTarget,
18
+ failureTargets,
19
+ lineShape,
20
+ } from "../core/command-output.ts";
21
+ import type { GitExec } from "../core/git.ts";
22
+ import type { Result } from "../result.ts";
23
+ import { outputLines } from "./output-lines.ts";
24
+
25
+ export interface CommandOutput {
26
+ command: string;
27
+ exit_code: number | null;
28
+ timed_out: boolean;
29
+ stdout: string;
30
+ stderr: string;
31
+ compressed: boolean;
32
+ truncated: boolean;
33
+ }
34
+ export async function captureCommand(
35
+ exec: GitExec,
36
+ cwd: string,
37
+ command: string,
38
+ timeoutS = ASK_TIMEOUT_S,
39
+ signal?: AbortSignal,
40
+ ): Promise<
41
+ Result<{
42
+ output: CommandOutput;
43
+ originalBytes: number;
44
+ compressedChars: number;
45
+ lineOmittedChars: number;
46
+ shapeLimitExceeded: boolean;
47
+ selectedPassages: boolean;
48
+ assertion: boolean;
49
+ targets: FailureTarget[];
50
+ }>
51
+ > {
52
+ const directory = await mkdtemp(join(tmpdir(), "jev-output-"));
53
+ try {
54
+ await chmod(directory, 0o700);
55
+ const stdoutPath = join(directory, "stdout");
56
+ const stderrPath = join(directory, "stderr");
57
+ signal?.throwIfAborted();
58
+ let executed: {
59
+ stdout: string;
60
+ stderr: string;
61
+ code: number;
62
+ killed: boolean;
63
+ };
64
+ try {
65
+ executed = await exec(
66
+ "env",
67
+ [
68
+ "CI=1",
69
+ "bash",
70
+ "-c",
71
+ `exec >"$1" 2>"$2"; set --; (\n${command}\n)\nexit $?`,
72
+ "jev",
73
+ stdoutPath,
74
+ stderrPath,
75
+ ],
76
+ { cwd, timeout: timeoutS * 1000, signal },
77
+ );
78
+ } catch (error) {
79
+ signal?.throwIfAborted();
80
+ return {
81
+ ok: true,
82
+ output: {
83
+ command,
84
+ exit_code: null,
85
+ timed_out: false,
86
+ stdout: "",
87
+ stderr: `Command executable unavailable: ${String(error)}`,
88
+ compressed: true,
89
+ truncated: false,
90
+ },
91
+ originalBytes: 0,
92
+ compressedChars: 0,
93
+ lineOmittedChars: 0,
94
+ shapeLimitExceeded: false,
95
+ selectedPassages: false,
96
+ assertion: false,
97
+ targets: [],
98
+ };
99
+ }
100
+ signal?.throwIfAborted();
101
+ const sizes = await Promise.all(
102
+ [stdoutPath, stderrPath].map(async (path) => {
103
+ try {
104
+ return (await stat(path)).size;
105
+ } catch (error) {
106
+ if (
107
+ error instanceof Error &&
108
+ "code" in error &&
109
+ error.code === "ENOENT"
110
+ )
111
+ return 0;
112
+ throw error;
113
+ }
114
+ }),
115
+ );
116
+ if (sizes.some((size) => size > OUTPUT_FILE_MAX_BYTES))
117
+ return {
118
+ ok: false,
119
+ error: `output exceeded ${OUTPUT_FILE_MAX_BYTES} bytes per stream; narrow command`,
120
+ };
121
+ const targets: FailureTarget[] = [];
122
+ let signature: "assertion" | "environment" | undefined;
123
+ let compressedChars = 0;
124
+ let linesTruncated = false;
125
+ let lineOmittedChars = 0;
126
+ let shapeLimitExceeded = false;
127
+ const texts: string[] = [];
128
+ for (const [index, path] of [stdoutPath, stderrPath].entries()) {
129
+ if (!sizes[index]) {
130
+ texts.push(index === 0 ? executed.stdout : executed.stderr);
131
+ continue;
132
+ }
133
+ const frequencies = new Map<string, number>();
134
+ let shapeLimit = false;
135
+ for await (const raw of outputLines(path, signal)) {
136
+ signal?.throwIfAborted();
137
+ const line = cleanOutput(raw);
138
+ linesTruncated ||= raw.endsWith(
139
+ `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`,
140
+ );
141
+ const shape = lineShape(line);
142
+ if (
143
+ !frequencies.has(shape) &&
144
+ frequencies.size >= OUTPUT_SHAPE_MAX_COUNT
145
+ ) {
146
+ shapeLimit = true;
147
+ shapeLimitExceeded = true;
148
+ frequencies.clear();
149
+ break;
150
+ }
151
+ frequencies.set(
152
+ shape,
153
+ Math.min(OUTPUT_REPEAT_MIN, (frequencies.get(shape) ?? 0) + 1),
154
+ );
155
+ }
156
+ let text = "";
157
+ const compressor = createRarityCompressor(frequencies, (line) => {
158
+ compressedChars += line.length + 1;
159
+ const remaining =
160
+ OUTPUT_CHUNK_CHARS * OUTPUT_FIND_MAX_CALLS + 1 - text.length;
161
+ if (remaining > 0) text += `${line}\n`.slice(0, remaining);
162
+ });
163
+ const failureWindow: string[] = [];
164
+ let failureSeen = false;
165
+ let afterFailure = 0;
166
+ // node:test prints its failing-test block ("✖ failing tests:" then
167
+ // "test at <file>:<line>:<col>") after one summary line per test, so
168
+ // the block can sit more than one window past the first anchor. Once
169
+ // the first window closes, keep scanning for that header and collect
170
+ // the next window after it. At most two windows (2 * (WINDOW + 1)
171
+ // lines) are ever buffered per stream.
172
+ let secondSeen = false;
173
+ let afterSecond = 0;
174
+ const isAnchor = (line: string): boolean =>
175
+ failureTargets(line).signature !== undefined ||
176
+ /^\s*(?:✖|ERROR |FAIL |test at )/.test(line);
177
+ const isFailingTestsHeader = (line: string): boolean =>
178
+ /^\s*(?:✖\s+)?failing tests:?\s*$/.test(line);
179
+ if (shapeLimit)
180
+ compressor.line(
181
+ `…[shape limit exceeded at ${OUTPUT_SHAPE_MAX_COUNT}; rarity grouping disabled]`,
182
+ );
183
+ for await (const raw of outputLines(path, signal, (chars) => {
184
+ lineOmittedChars += chars;
185
+ })) {
186
+ signal?.throwIfAborted();
187
+ linesTruncated ||= raw.endsWith(
188
+ `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`,
189
+ );
190
+ compressor.line(cleanOutput(raw));
191
+ if (!failureSeen) {
192
+ failureWindow.push(cleanOutput(raw));
193
+ if (failureWindow.length > OUTPUT_FAILURE_WINDOW_LINES + 1)
194
+ failureWindow.shift();
195
+ if (isAnchor(raw)) failureSeen = true;
196
+ } else if (afterFailure++ < OUTPUT_FAILURE_WINDOW_LINES) {
197
+ failureWindow.push(cleanOutput(raw));
198
+ if (!secondSeen && afterFailure > 1 && isFailingTestsHeader(raw)) {
199
+ secondSeen = true;
200
+ afterSecond = 0;
201
+ }
202
+ } else if (!secondSeen) {
203
+ if (isFailingTestsHeader(raw)) {
204
+ secondSeen = true;
205
+ afterSecond = 0;
206
+ failureWindow.push(cleanOutput(raw));
207
+ }
208
+ } else if (afterSecond++ < OUTPUT_FAILURE_WINDOW_LINES) {
209
+ failureWindow.push(cleanOutput(raw));
210
+ }
211
+ }
212
+ compressor.finish();
213
+ const failure = failureTargets(failureWindow.join("\n"));
214
+ signature ??= failure.signature;
215
+ targets.push(...failure.targets.slice(0, 20 - targets.length));
216
+ texts.push(text);
217
+ }
218
+ return {
219
+ ok: true,
220
+ output: {
221
+ command,
222
+ exit_code: executed.killed ? null : executed.code,
223
+ timed_out: executed.killed,
224
+ stdout: texts[0] ?? "",
225
+ stderr: texts[1] ?? "",
226
+ compressed: true,
227
+ truncated: linesTruncated,
228
+ },
229
+ originalBytes: sizes.reduce((sum, size) => sum + size, 0),
230
+ compressedChars,
231
+ lineOmittedChars,
232
+ shapeLimitExceeded,
233
+ selectedPassages: false,
234
+ assertion: signature === "assertion",
235
+ targets,
236
+ };
237
+ } finally {
238
+ await rm(directory, { recursive: true, force: true });
239
+ }
240
+ }
@@ -0,0 +1,222 @@
1
+ import { STATE_MAX_CHARS, TIMEOUT_MS } from "../constants.ts";
2
+ import {
3
+ attributeDocsDeclarations,
4
+ docsDeclarationPattern,
5
+ } from "../core/docs.ts";
6
+ import type { GitExec } from "../core/git.ts";
7
+ import type { Result } from "../result.ts";
8
+ import {
9
+ checkFileAdmission,
10
+ openRepoFile,
11
+ resolveInsideRepo,
12
+ } from "./files.ts";
13
+ import { decodeUtf8 } from "./utf8.ts";
14
+
15
+ export interface DocsSource {
16
+ path: string;
17
+ text: string;
18
+ }
19
+ export interface DocsInventory {
20
+ cwd: string;
21
+ files: DocsSource[];
22
+ tracked: ReadonlySet<string>;
23
+ limits: { path: string; reason: string }[];
24
+ read: (path: string) => Promise<DocsSource | undefined>;
25
+ }
26
+ /** Inventory paths eagerly, but load only Markdown/configuration and subsequently reached sources. */
27
+ export async function collectDocsInventory(
28
+ exec: GitExec,
29
+ cwd: string,
30
+ signal?: AbortSignal,
31
+ ): Promise<Result<DocsInventory>> {
32
+ const root = await exec("git", ["rev-parse", "--show-toplevel"], {
33
+ cwd,
34
+ timeout: TIMEOUT_MS,
35
+ signal,
36
+ });
37
+ if (root.code || root.killed)
38
+ return {
39
+ ok: false,
40
+ error: root.stderr.trim() || "Repository root not found.",
41
+ };
42
+ cwd = root.stdout.trim();
43
+ const list = await exec("git", ["ls-files", "-z"], {
44
+ cwd,
45
+ timeout: TIMEOUT_MS,
46
+ signal,
47
+ });
48
+ if (list.code || list.killed)
49
+ return {
50
+ ok: false,
51
+ error: list.stderr.trim() || "Unable to inventory tracked files.",
52
+ };
53
+ const tracked = new Set(list.stdout.split("\0").filter(Boolean));
54
+ const limits: DocsInventory["limits"] = [];
55
+ const cache = new Map<string, Promise<DocsSource | undefined>>();
56
+ const read = (path: string): Promise<DocsSource | undefined> => {
57
+ const previous = cache.get(path);
58
+ if (previous) return previous;
59
+ const pending = (async () => {
60
+ if (!tracked.has(path) || path.split("/").includes(".git"))
61
+ return undefined;
62
+ if (signal?.aborted) {
63
+ limits.push({ path, reason: "collection interrupted" });
64
+ return undefined;
65
+ }
66
+ try {
67
+ const opened = await openRepoFile(cwd, path, {
68
+ exec,
69
+ signal,
70
+ inventory: tracked,
71
+ });
72
+ if (!opened.ok) {
73
+ limits.push({ path, reason: opened.error });
74
+ return undefined;
75
+ }
76
+ const handle = opened.handle;
77
+ try {
78
+ const stat = await handle.stat();
79
+ if (!stat.isFile() || stat.size > STATE_MAX_CHARS * 4) {
80
+ limits.push({
81
+ path,
82
+ reason: "not a regular file or file too large",
83
+ });
84
+ return undefined;
85
+ }
86
+ const buffer = Buffer.alloc(stat.size + 1);
87
+ let bytesRead = 0;
88
+ while (bytesRead < buffer.length) {
89
+ signal?.throwIfAborted();
90
+ const chunk = await handle.read(
91
+ buffer,
92
+ bytesRead,
93
+ buffer.length - bytesRead,
94
+ null,
95
+ );
96
+ if (!chunk.bytesRead) break;
97
+ bytesRead += chunk.bytesRead;
98
+ }
99
+ if (bytesRead > stat.size) {
100
+ limits.push({ path, reason: "file changed during reading" });
101
+ return undefined;
102
+ }
103
+ const decoded = decodeUtf8(buffer.subarray(0, bytesRead));
104
+ if (!decoded.ok) {
105
+ limits.push({ path, reason: decoded.error });
106
+ return undefined;
107
+ }
108
+ const text = decoded.text;
109
+ if (text.includes("\0")) {
110
+ limits.push({ path, reason: "binary file" });
111
+ return undefined;
112
+ }
113
+ return { path, text };
114
+ } finally {
115
+ await handle.close();
116
+ }
117
+ } catch (error) {
118
+ limits.push({ path, reason: `unable to read: ${String(error)}` });
119
+ return undefined;
120
+ }
121
+ })();
122
+ cache.set(path, pending);
123
+ return pending;
124
+ };
125
+ const paths = [...tracked].filter(
126
+ (path) =>
127
+ /\.(?:md|mdx|markdown)$/i.test(path) ||
128
+ /(?:^|\/)(?:package|tsconfig[^/]*)\.json$/.test(path),
129
+ );
130
+ const files: DocsSource[] = [];
131
+ let index = 0;
132
+ await Promise.all(
133
+ Array.from({ length: Math.min(8, paths.length) }, async () => {
134
+ for (;;) {
135
+ const path = paths[index++];
136
+ if (path === undefined) return;
137
+ const source = await read(path);
138
+ if (source) files.push(source);
139
+ }
140
+ }),
141
+ );
142
+ files.sort((a, b) => a.path.localeCompare(b.path));
143
+ return { ok: true, cwd, files, tracked, limits, read };
144
+ }
145
+
146
+ /** Admitted paths only; one batch search transports declaration lines to the pure core. */
147
+ export function docsDeclarationSearch(
148
+ exec: GitExec,
149
+ inventory: DocsInventory,
150
+ signal?: AbortSignal,
151
+ ): (
152
+ names: readonly string[],
153
+ ) => Promise<ReadonlyMap<string, readonly string[]>> {
154
+ let admittedPaths: Promise<string[]> | undefined;
155
+ return async (names) => {
156
+ if (!names.length) return new Map();
157
+ admittedPaths ??= (async () => {
158
+ const candidates = [...inventory.tracked].filter(
159
+ (path) =>
160
+ /\.[cm]?[jt]sx?$|\.py$|\.go$/i.test(path) &&
161
+ !path.split("/").some((part) => part === ".." || part === ".git"),
162
+ );
163
+ const paths: string[] = [];
164
+ let index = 0;
165
+ await Promise.all(
166
+ Array.from({ length: Math.min(16, candidates.length) }, async () => {
167
+ for (;;) {
168
+ const path = candidates[index++];
169
+ if (path === undefined) return;
170
+ const location = await resolveInsideRepo(inventory.cwd, path);
171
+ const admitted =
172
+ location.ok &&
173
+ (
174
+ await checkFileAdmission(
175
+ inventory.cwd,
176
+ location.rel,
177
+ exec,
178
+ signal,
179
+ inventory.tracked,
180
+ )
181
+ ).ok;
182
+ if (admitted) paths.push(path);
183
+ }
184
+ }),
185
+ );
186
+ return paths;
187
+ })();
188
+ const paths = await admittedPaths;
189
+ if (!paths.length) return new Map();
190
+ const patterns = ["-e", docsDeclarationPattern(names)];
191
+ const result = await exec("rg", ["--json", ...patterns, "--", ...paths], {
192
+ cwd: inventory.cwd,
193
+ timeout: TIMEOUT_MS,
194
+ signal,
195
+ });
196
+ if (result.killed || (result.code !== 0 && result.code !== 1)) {
197
+ inventory.limits.push({
198
+ path: "documentation",
199
+ reason: "declaration search unavailable",
200
+ });
201
+ return new Map(
202
+ names.map((name) => [
203
+ name,
204
+ paths.length > 1 ? paths : [...paths, "<unknown declaration owner>"],
205
+ ]),
206
+ );
207
+ }
208
+ const admitted = new Set(paths);
209
+ const rows: { path: string; text: string }[] = [];
210
+ for (const line of result.stdout.split("\n")) {
211
+ if (!line) continue;
212
+ const item = JSON.parse(line) as {
213
+ type: string;
214
+ data?: { path?: { text?: string }; lines?: { text?: string } };
215
+ };
216
+ const path = item.data?.path?.text;
217
+ if (item.type === "match" && path && admitted.has(path))
218
+ rows.push({ path, text: item.data?.lines?.text ?? "" });
219
+ }
220
+ return attributeDocsDeclarations(names, rows);
221
+ };
222
+ }