jev-agent-tools 0.1.4 → 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.
Files changed (180) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/CONTRIBUTING.md +43 -0
  3. package/README.md +58 -17
  4. package/SECURITY.md +43 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +198 -0
  7. package/dist/adapters/ask-proof.js +200 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +234 -0
  11. package/dist/adapters/docs.js +192 -0
  12. package/dist/adapters/evidence-context.js +119 -0
  13. package/dist/adapters/exec.js +207 -0
  14. package/dist/adapters/files.js +418 -0
  15. package/dist/adapters/find.js +150 -0
  16. package/dist/adapters/git-base.js +32 -0
  17. package/dist/adapters/git-inventory.js +71 -0
  18. package/dist/adapters/git.js +483 -0
  19. package/dist/adapters/locate-file.js +197 -0
  20. package/dist/adapters/output-lines.js +46 -0
  21. package/dist/adapters/private-storage.js +106 -0
  22. package/dist/adapters/risk-callers.js +429 -0
  23. package/dist/adapters/runner-version.js +78 -0
  24. package/dist/adapters/shell.js +92 -0
  25. package/dist/adapters/syntax.js +187 -0
  26. package/dist/adapters/test-inventory.js +139 -0
  27. package/dist/adapters/usage.js +20 -0
  28. package/dist/adapters/utf8.js +47 -0
  29. package/dist/configuration.js +267 -0
  30. package/dist/constants.js +140 -0
  31. package/dist/core/ask-closure.js +282 -0
  32. package/dist/core/ask-proof.js +1 -0
  33. package/dist/core/ask-references.js +278 -0
  34. package/dist/core/asks.js +507 -0
  35. package/dist/core/batches.js +65 -0
  36. package/dist/core/command-output.js +224 -0
  37. package/dist/core/diff.js +178 -0
  38. package/dist/core/docs.js +302 -0
  39. package/dist/core/find.js +108 -0
  40. package/dist/core/git.js +1 -0
  41. package/dist/core/imports.js +550 -0
  42. package/dist/core/integrity.js +45 -0
  43. package/dist/core/lexical.js +132 -0
  44. package/dist/core/locate.js +169 -0
  45. package/dist/core/output.js +137 -0
  46. package/dist/core/pointer.js +29 -0
  47. package/dist/core/result-report.js +302 -0
  48. package/dist/core/risk-callers.js +851 -0
  49. package/dist/core/runner-version.js +45 -0
  50. package/dist/core/secret-path.js +34 -0
  51. package/dist/core/sections.js +230 -0
  52. package/dist/core/state.js +51 -0
  53. package/dist/core/syntax.js +1 -0
  54. package/dist/core/test-commands.js +334 -0
  55. package/dist/core/test-coverage.js +74 -0
  56. package/dist/core/test-discovery.js +1382 -0
  57. package/dist/core/test-evidence.js +527 -0
  58. package/dist/core/test-state.js +81 -0
  59. package/dist/core/truncate.js +12 -0
  60. package/dist/core/units.js +349 -0
  61. package/dist/describe.js +23 -0
  62. package/dist/guide.js +33 -0
  63. package/dist/host.js +24 -0
  64. package/dist/jev/client.js +456 -0
  65. package/dist/jev/pool.js +54 -0
  66. package/dist/jev/types.js +1 -0
  67. package/dist/mcp/main.js +124 -0
  68. package/dist/mcp/protocol.js +210 -0
  69. package/dist/mcp/tools.js +129 -0
  70. package/dist/presets/docs.js +62 -0
  71. package/dist/presets/risk.js +179 -0
  72. package/dist/presets/spec.js +81 -0
  73. package/dist/presets/witnesses.js +249 -0
  74. package/dist/render.js +114 -0
  75. package/dist/report-schema.js +1356 -0
  76. package/dist/result-types.js +1 -0
  77. package/dist/result.js +3 -0
  78. package/dist/runtime.js +1 -0
  79. package/dist/session.js +147 -0
  80. package/dist/texts/ask-files.js +3 -0
  81. package/dist/texts/ask.js +4 -0
  82. package/dist/texts/check-diff.js +20 -0
  83. package/dist/texts/configuration.js +1 -0
  84. package/dist/texts/find.js +19 -0
  85. package/dist/texts/guide.js +3 -0
  86. package/dist/texts/instructions.js +72 -0
  87. package/dist/texts/locate.js +15 -0
  88. package/dist/texts/select-tests.js +4 -0
  89. package/dist/tools/ask-files.js +450 -0
  90. package/dist/tools/ask-schema.js +70 -0
  91. package/dist/tools/ask.js +1147 -0
  92. package/dist/tools/check-diff.js +594 -0
  93. package/dist/tools/docs-check.js +408 -0
  94. package/dist/tools/find.js +682 -0
  95. package/dist/tools/locate.js +602 -0
  96. package/dist/tools/review-report.js +230 -0
  97. package/dist/tools/select-tests.js +821 -0
  98. package/dist/tools/spec-check.js +263 -0
  99. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  100. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  101. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  102. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  103. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  104. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  105. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  106. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  107. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  108. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  109. package/docs/agent-instructions.md +120 -0
  110. package/docs/design.md +16 -4
  111. package/docs/mcp.md +233 -0
  112. package/docs/tools/jev_ask.md +8 -5
  113. package/docs/tools/jev_ask_files.md +2 -1
  114. package/docs/tools/jev_check_diff.md +4 -1
  115. package/docs/tools/jev_find_files.md +2 -1
  116. package/docs/tools/jev_locate_in_file.md +5 -0
  117. package/docs/tools/jev_select_tests.md +4 -1
  118. package/package.json +19 -4
  119. package/rules/jev-ask.md +22 -1
  120. package/server.json +57 -0
  121. package/src/adapters/ask-files.ts +11 -3
  122. package/src/adapters/ask-proof.ts +69 -11
  123. package/src/adapters/canonical-path.ts +18 -0
  124. package/src/adapters/command.ts +102 -36
  125. package/src/adapters/docs.ts +33 -14
  126. package/src/adapters/evidence-context.ts +169 -0
  127. package/src/adapters/exec.ts +226 -0
  128. package/src/adapters/files.ts +146 -16
  129. package/src/adapters/find.ts +37 -7
  130. package/src/adapters/git-base.ts +7 -1
  131. package/src/adapters/git.ts +61 -8
  132. package/src/adapters/locate-file.ts +51 -9
  133. package/src/adapters/private-storage.ts +155 -0
  134. package/src/adapters/risk-callers.ts +7 -2
  135. package/src/adapters/shell.ts +113 -0
  136. package/src/adapters/test-inventory.ts +12 -4
  137. package/src/configuration.ts +55 -14
  138. package/src/constants.ts +37 -5
  139. package/src/core/ask-references.ts +262 -146
  140. package/src/core/asks.ts +79 -7
  141. package/src/core/command-output.ts +17 -1
  142. package/src/core/import-boundaries.ts +8 -3
  143. package/src/core/locate.ts +8 -5
  144. package/src/core/output.ts +34 -0
  145. package/src/core/result-report.ts +410 -0
  146. package/src/core/secret-path.ts +37 -0
  147. package/src/core/state.ts +8 -1
  148. package/src/core/units.ts +3 -2
  149. package/src/host.ts +11 -0
  150. package/src/index.ts +3 -0
  151. package/src/jev/client.ts +66 -16
  152. package/src/jev/types.ts +24 -3
  153. package/src/mcp/main.ts +135 -0
  154. package/src/mcp/protocol.ts +332 -0
  155. package/src/mcp/tools.ts +179 -0
  156. package/src/render.ts +109 -0
  157. package/src/report-schema.ts +1380 -0
  158. package/src/result-types.ts +234 -0
  159. package/src/result.ts +4 -1
  160. package/src/runtime.ts +6 -0
  161. package/src/session.ts +59 -0
  162. package/src/setup.ts +13 -5
  163. package/src/texts/ask-files.ts +4 -1
  164. package/src/texts/ask.ts +8 -1
  165. package/src/texts/check-diff.ts +7 -4
  166. package/src/texts/find.ts +8 -2
  167. package/src/texts/guide.ts +8 -16
  168. package/src/texts/instructions.ts +98 -0
  169. package/src/texts/locate.ts +8 -2
  170. package/src/texts/run-end.ts +2 -2
  171. package/src/texts/select-tests.ts +4 -1
  172. package/src/tools/ask-files.ts +311 -18
  173. package/src/tools/ask.ts +722 -95
  174. package/src/tools/check-diff.ts +337 -31
  175. package/src/tools/docs-check.ts +241 -38
  176. package/src/tools/find.ts +389 -29
  177. package/src/tools/locate.ts +387 -25
  178. package/src/tools/review-report.ts +308 -0
  179. package/src/tools/select-tests.ts +484 -23
  180. package/src/tools/spec-check.ts +194 -19
@@ -0,0 +1,385 @@
1
+ import { loadSyntaxRootParser } from "./syntax.js";
2
+ function nodes(root, kinds) {
3
+ const result = [];
4
+ for (const kind of kinds) {
5
+ try {
6
+ result.push(...root.findAll({ rule: { kind } }));
7
+ }
8
+ catch {
9
+ /* Node kinds differ across the supported language grammars. */
10
+ }
11
+ }
12
+ return result
13
+ .map((node) => ({ node, index: node.range().start.index }))
14
+ .sort((a, b) => a.index - b.index)
15
+ .map((entry) => entry.node);
16
+ }
17
+ function names(root, includeTypes = true) {
18
+ const result = new Set();
19
+ const excluded = nodes(root, [
20
+ "import_statement",
21
+ "import_from_statement",
22
+ "export_clause",
23
+ ]).map((node) => node.range());
24
+ const typeScopes = [];
25
+ if (includeTypes) {
26
+ for (const binder of nodes(root, [
27
+ "type_parameter",
28
+ "infer_type",
29
+ "mapped_type_clause",
30
+ ])) {
31
+ const name = binder.field("name") ??
32
+ binder.children().find((node) => node.kind() === "type_identifier");
33
+ let scope = binder.parent();
34
+ if (binder.kind() === "type_parameter")
35
+ scope = scope?.parent() ?? null;
36
+ if (binder.kind() === "infer_type") {
37
+ while (scope && scope.kind() !== "conditional_type")
38
+ scope = scope.parent();
39
+ const own = name?.range();
40
+ if (name && own)
41
+ typeScopes.push({
42
+ name: name.text(),
43
+ start: own.start.index,
44
+ end: own.end.index,
45
+ });
46
+ scope = scope?.field("consequence") ?? null;
47
+ }
48
+ if (name && scope) {
49
+ const range = scope.range();
50
+ typeScopes.push({
51
+ name: name.text(),
52
+ start: range.start.index,
53
+ end: range.end.index,
54
+ });
55
+ }
56
+ }
57
+ }
58
+ for (const node of nodes(root, [
59
+ "identifier",
60
+ "type_identifier",
61
+ "nested_type_identifier",
62
+ "member_expression",
63
+ "attribute",
64
+ ])) {
65
+ const position = node.range().start.index;
66
+ if (node.kind() === "type_identifier") {
67
+ if (!includeTypes ||
68
+ node.parent()?.kind() === "nested_type_identifier" ||
69
+ typeScopes.some((scope) => scope.name === node.text() &&
70
+ position >= scope.start &&
71
+ position < scope.end))
72
+ continue;
73
+ }
74
+ if (!includeTypes && node.kind() === "nested_type_identifier")
75
+ continue;
76
+ if (excluded.some((range) => position >= range.start.index && position < range.end.index))
77
+ continue;
78
+ if (node.kind() === "identifier" || node.kind() === "type_identifier")
79
+ result.add(node.text());
80
+ else {
81
+ const owner = node.field("object") ?? node.field("module");
82
+ const property = node.field("property") ?? node.field("attribute") ?? node.field("name");
83
+ if (owner && property)
84
+ result.add(`${owner.text()}.${property.text()}`);
85
+ }
86
+ }
87
+ return [...result];
88
+ }
89
+ function literal(node) {
90
+ if (!node || !["string", "string_literal"].includes(String(node.kind())))
91
+ return undefined;
92
+ const fragments = nodes(node, ["string_fragment", "string_content"]);
93
+ if (nodes(node, ["interpolation", "escape_sequence"]).length)
94
+ return undefined;
95
+ return fragments.map((fragment) => fragment.text()).join("");
96
+ }
97
+ /** Collect AST metadata only; repository resolution remains in the shared I8 graph. */
98
+ export async function collectProofSyntax(files, before, sourceParser) {
99
+ const declarations = [];
100
+ const bindings = [];
101
+ const limits = [];
102
+ const uses = new Map();
103
+ const parser = sourceParser ?? (await loadSyntaxRootParser());
104
+ for (const file of files) {
105
+ if (!parser?.supports(file.path)) {
106
+ limits.push({
107
+ fact: `${file.path} : unsupported language${/\.[cm]?[jt]sx?$|\.py$/.test(file.path) ? ", optional parser unavailable" : ""}`,
108
+ next: "Install the language parser or provide the required declarations.",
109
+ });
110
+ continue;
111
+ }
112
+ const python = file.path.endsWith(".py");
113
+ const parsed = parser.parseRaw(file.path, file.text);
114
+ if (!parsed.ok) {
115
+ limits.push({
116
+ fact: `${file.path}: parse_failed`,
117
+ next: "read the complete source and check its syntax",
118
+ });
119
+ continue;
120
+ }
121
+ const root = parsed.root;
122
+ const cachedParser = Object.create(parser);
123
+ cachedParser.parseRaw = () => parsed;
124
+ const syntax = cachedParser.declarations(file.path, file.text, {
125
+ descendAbove: Infinity,
126
+ });
127
+ for (const limit of syntax.limits) {
128
+ if (limit.kind === "parse_partial" && limit.declaration)
129
+ continue;
130
+ limits.push({
131
+ fact: `${file.path} : ${limit.kind}`,
132
+ next: "check the complete supplied source",
133
+ });
134
+ }
135
+ if (syntax.limits.some((limit) => limit.kind === "parse_failed"))
136
+ continue;
137
+ const usedNames = names(root);
138
+ const valueNames = names(root, false);
139
+ uses.set(file.path, usedNames);
140
+ const collectDeclarations = (tree) => {
141
+ const result = [];
142
+ for (const outer of tree.children()) {
143
+ const envelope = outer;
144
+ const node = ["export_statement", "decorated_definition"].includes(String(outer.kind()))
145
+ ? (outer
146
+ .children()
147
+ .find((child) => [
148
+ "function_declaration",
149
+ "generator_function_declaration",
150
+ "class_declaration",
151
+ "lexical_declaration",
152
+ "variable_declaration",
153
+ "function_definition",
154
+ "class_definition",
155
+ "type_alias_declaration",
156
+ "interface_declaration",
157
+ "enum_declaration",
158
+ ].includes(String(child.kind()))) ?? outer)
159
+ : outer;
160
+ if ([
161
+ "function_declaration",
162
+ "generator_function_declaration",
163
+ "class_declaration",
164
+ "function_definition",
165
+ "class_definition",
166
+ "type_alias_declaration",
167
+ "interface_declaration",
168
+ "enum_declaration",
169
+ ].includes(String(node.kind()))) {
170
+ const name = node.field("name")?.text();
171
+ if (name)
172
+ result.push({ name, text: envelope.text(), node: envelope });
173
+ }
174
+ else if (["lexical_declaration", "variable_declaration"].includes(String(node.kind()))) {
175
+ for (const variable of node
176
+ .children()
177
+ .filter((child) => child.kind() === "variable_declarator")) {
178
+ const name = variable.field("name")?.text();
179
+ if (name)
180
+ result.push({ name, text: envelope.text(), node: envelope });
181
+ }
182
+ }
183
+ else if (python && node.kind() === "expression_statement") {
184
+ const assignment = node
185
+ .children()
186
+ .find((child) => child.kind() === "assignment");
187
+ const name = assignment?.field("left")?.text();
188
+ if (name)
189
+ result.push({ name, text: envelope.text(), node: envelope });
190
+ }
191
+ }
192
+ for (const declaration of result) {
193
+ if (!declaration.node.find({ rule: { kind: "ERROR" } }))
194
+ continue;
195
+ const fact = `${file.path} : parse_partial (${declaration.name})`;
196
+ if (!limits.some((limit) => limit.fact === fact))
197
+ limits.push({
198
+ fact,
199
+ next: "check the complete supplied source declaration",
200
+ });
201
+ }
202
+ return result;
203
+ };
204
+ const old = before && Object.hasOwn(before, file.path)
205
+ ? before[file.path]
206
+ : undefined;
207
+ const oldRoot = typeof old === "string" ? parser.parseRaw(file.path, old) : undefined;
208
+ const oldDeclarations = oldRoot?.ok
209
+ ? collectDeclarations(oldRoot.root)
210
+ : [];
211
+ const currentDeclarations = collectDeclarations(root);
212
+ for (const declaration of currentDeclarations) {
213
+ const tree = declaration.node;
214
+ const decorators = nodes(tree, ["decorator"]);
215
+ declarations.push({
216
+ path: file.path,
217
+ name: declaration.name,
218
+ text: declaration.text,
219
+ ...(old !== undefined
220
+ ? {
221
+ before: oldDeclarations.find((item) => item.name === declaration.name)
222
+ ?.text ?? null,
223
+ }
224
+ : {}),
225
+ usedNames: names(tree),
226
+ fixture: python &&
227
+ decorators.some((node) => nodes(node, ["identifier"]).some((item) => item.text() === "fixture")),
228
+ autouse: python &&
229
+ decorators.some((node) => nodes(node, ["keyword_argument"]).some((item) => item.field("name")?.text() === "autouse" &&
230
+ item.field("value")?.text() === "True")),
231
+ });
232
+ }
233
+ for (const declaration of oldDeclarations) {
234
+ if (!currentDeclarations.some((item) => item.name === declaration.name))
235
+ declarations.push({
236
+ path: file.path,
237
+ name: declaration.name,
238
+ text: "",
239
+ before: declaration.text,
240
+ usedNames: names(declaration.node),
241
+ deleted: true,
242
+ });
243
+ }
244
+ const add = (local, imported, specifier, reexport, data = false) => bindings.push({
245
+ path: file.path,
246
+ local,
247
+ imported,
248
+ specifier,
249
+ reexport,
250
+ used: data ||
251
+ usedNames.includes(local) ||
252
+ usedNames.some((name) => name.startsWith(`${local}.`)),
253
+ ...(data ? { data: true } : {}),
254
+ typeOnly: !data &&
255
+ !valueNames.some((name) => name === local || name.startsWith(`${local}.`)),
256
+ valueMembers: valueNames.filter((name) => name.startsWith(`${local}.`)),
257
+ });
258
+ for (const node of nodes(root, [
259
+ "import_statement",
260
+ "import_from_statement",
261
+ "export_statement",
262
+ ])) {
263
+ if (python) {
264
+ const children = node.children();
265
+ const module = node.kind() === "import_from_statement"
266
+ ? children.find((child) => ["relative_import", "dotted_name"].includes(String(child.kind())))
267
+ : undefined;
268
+ for (const child of children) {
269
+ if (child === module)
270
+ continue;
271
+ if (child.kind() === "aliased_import") {
272
+ const original = child.field("name")?.text() ??
273
+ child
274
+ .children()
275
+ .find((item) => item.kind() === "dotted_name")
276
+ ?.text();
277
+ const alias = child.field("alias")?.text() ??
278
+ child
279
+ .children()
280
+ .filter((item) => item.kind() === "identifier")
281
+ .at(-1)
282
+ ?.text();
283
+ if (original)
284
+ add(alias ?? original, module ? original : "*", module?.text() ?? original, file.path.endsWith("__init__.py"));
285
+ }
286
+ else if (child.kind() === "dotted_name")
287
+ add(child.text().split(".")[0] ?? child.text(), module ? child.text() : "*", module?.text() ?? child.text(), file.path.endsWith("__init__.py"));
288
+ else if (child.kind() === "wildcard_import" && module)
289
+ add("*", "*", module.text(), file.path.endsWith("__init__.py"));
290
+ }
291
+ }
292
+ else {
293
+ const specifier = literal(node.field("source") ??
294
+ node.children().find((child) => child.kind() === "string"));
295
+ const reexport = node.kind() === "export_statement";
296
+ const specs = nodes(node, ["import_specifier", "export_specifier"]);
297
+ if (specifier) {
298
+ for (const spec of specs) {
299
+ const ids = spec
300
+ .children()
301
+ .filter((child) => child.kind() === "identifier");
302
+ const imported = spec.field("name")?.text() ?? ids[0]?.text();
303
+ const alias = spec.field("alias")?.text() ?? ids.at(-1)?.text();
304
+ if (imported)
305
+ add(alias ?? imported, imported, specifier, reexport);
306
+ }
307
+ const clause = node
308
+ .children()
309
+ .find((child) => child.kind() === "import_clause");
310
+ const defaultName = clause
311
+ ?.children()
312
+ .find((child) => child.kind() === "identifier");
313
+ if (defaultName)
314
+ add(defaultName.text(), "default", specifier, false);
315
+ const namespace = nodes(node, [
316
+ "namespace_import",
317
+ "namespace_export",
318
+ ])[0];
319
+ if (namespace)
320
+ add(namespace
321
+ .children()
322
+ .find((child) => child.kind() === "identifier")
323
+ ?.text() ?? "*", "*", specifier, reexport);
324
+ else if (reexport &&
325
+ !specs.length &&
326
+ node.children().some((child) => child.text() === "*"))
327
+ add("*", "*", specifier, true);
328
+ }
329
+ else if (reexport) {
330
+ if (node.children().some((child) => child.kind() === "default")) {
331
+ const declaration = node
332
+ .children()
333
+ .find((child) => child.field("name") !== null);
334
+ const name = declaration?.field("name")?.text();
335
+ if (name)
336
+ add("default", name, "", true);
337
+ }
338
+ for (const spec of specs) {
339
+ const ids = spec
340
+ .children()
341
+ .filter((child) => child.kind() === "identifier");
342
+ if (ids[0])
343
+ add(ids.at(-1)?.text() ?? ids[0].text(), ids[0].text(), "", true);
344
+ }
345
+ }
346
+ }
347
+ }
348
+ const readAliases = bindings
349
+ .filter((binding) => binding.path === file.path && binding.imported === "readFileSync")
350
+ .map((binding) => binding.local);
351
+ for (const call of nodes(root, python ? ["call"] : ["call_expression"])) {
352
+ const fn = call.field("function");
353
+ const fnName = fn?.text() ?? "";
354
+ if (fnName === "readFileSync" ||
355
+ fnName.endsWith(".readFileSync") ||
356
+ readAliases.includes(fnName)) {
357
+ const argument = (call.field("arguments") ??
358
+ call.children().find((child) => child.kind() === "arguments"))
359
+ ?.children()
360
+ .find((child) => child.kind() === "string");
361
+ const specifier = literal(argument);
362
+ if (specifier !== undefined)
363
+ add(specifier, "*", specifier, false, true);
364
+ else
365
+ limits.push({
366
+ fact: `${file.path} : unresolved dynamic readFileSync call`,
367
+ next: "Provide the data file explicitly.",
368
+ });
369
+ }
370
+ if (["import", "__import__", "importlib.import_module"].includes(fnName))
371
+ limits.push({
372
+ fact: `${file.path} : unresolved dynamic import ${call.text()}`,
373
+ next: "Provide the dependency explicitly.",
374
+ });
375
+ }
376
+ }
377
+ return {
378
+ declarations,
379
+ bindings,
380
+ limits,
381
+ sources: files,
382
+ uses,
383
+ ...(before ? { before } : {}),
384
+ };
385
+ }
@@ -0,0 +1,17 @@
1
+ import { realpath } from "node:fs";
2
+ import { promisify } from "node:util";
3
+ const nativeRealpath = promisify(realpath.native);
4
+ /**
5
+ * Canonical form of an existing path: resolves symlinks and, on Windows,
6
+ * expands 8.3 short names (C:\Users\NAME~1) so the path relates correctly to
7
+ * `git rev-parse --show-toplevel`, which always reports the long form. Falls
8
+ * back to the input when the path cannot be resolved.
9
+ */
10
+ export async function canonicalPath(path) {
11
+ try {
12
+ return await nativeRealpath(path);
13
+ }
14
+ catch {
15
+ return path;
16
+ }
17
+ }
@@ -0,0 +1,234 @@
1
+ import { chmod, mkdtemp, rm, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ASK_TIMEOUT_S, OUTPUT_CHUNK_CHARS, OUTPUT_FAILURE_WINDOW_LINES, OUTPUT_FILE_MAX_BYTES, OUTPUT_FIND_MAX_CALLS, OUTPUT_LINE_MAX_CHARS, OUTPUT_REPEAT_MIN, OUTPUT_SHAPE_MAX_COUNT, } from "../constants.js";
5
+ import { cleanOutput, createRarityCompressor, failureTargets, lineShape, } from "../core/command-output.js";
6
+ import { outputLines } from "./output-lines.js";
7
+ import { resolveShell } from "./shell.js";
8
+ const TRUNCATION_MARK = `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`;
9
+ /** Shortest key prefix worth redacting when a line cut leaves only its start. */
10
+ const SECRET_PREFIX_MIN = 4;
11
+ /**
12
+ * Replace every occurrence of `secret` in `text`, counting replacements. A
13
+ * line cut at OUTPUT_LINE_MAX_CHARS can end inside the key; that trailing key
14
+ * prefix is redacted too.
15
+ */
16
+ export function redactSecret(text, secret) {
17
+ if (!secret)
18
+ return { text, count: 0 };
19
+ const parts = text.split(secret);
20
+ let result = parts.join("[redacted]");
21
+ let count = parts.length - 1;
22
+ if (result.endsWith(TRUNCATION_MARK)) {
23
+ const kept = result.slice(0, -TRUNCATION_MARK.length);
24
+ const min = Math.min(SECRET_PREFIX_MIN, secret.length);
25
+ for (let length = Math.min(secret.length - 1, kept.length); length >= min; length--) {
26
+ if (kept.endsWith(secret.slice(0, length))) {
27
+ result = `${kept.slice(0, -length)}[redacted]${TRUNCATION_MARK}`;
28
+ count++;
29
+ break;
30
+ }
31
+ }
32
+ }
33
+ return { text: result, count };
34
+ }
35
+ export async function captureCommand(exec, cwd, command, timeoutS = ASK_TIMEOUT_S, signal,
36
+ /** Configured Jev API key, replaced in the captured command and output. */
37
+ secret) {
38
+ const directory = await mkdtemp(join(tmpdir(), "jev-output-"));
39
+ let commandExecution = "not_started";
40
+ let completion;
41
+ try {
42
+ await chmod(directory, 0o700);
43
+ const stdoutPath = join(directory, "stdout");
44
+ const stderrPath = join(directory, "stderr");
45
+ signal?.throwIfAborted();
46
+ let executed;
47
+ try {
48
+ const shell = resolveShell();
49
+ // Fail closed: never spawn a bare name that PATH could resolve to WSL.
50
+ if (!shell.ok)
51
+ return {
52
+ ok: false,
53
+ cause: "file_unavailable",
54
+ commandExecution: "not_started",
55
+ error: shell.error,
56
+ };
57
+ commandExecution = "unknown";
58
+ executed = await exec(shell.executable, [
59
+ ...shell.prefix,
60
+ "-c",
61
+ `${shell.scriptPrefix}exec >"$1" 2>"$2"; set --; (\n${command}\n)\nexit $?`,
62
+ "jev",
63
+ stdoutPath,
64
+ stderrPath,
65
+ ], { cwd, timeout: timeoutS * 1000, signal });
66
+ commandExecution = "finished";
67
+ completion = {
68
+ commandExitCode: executed.killed ? null : executed.code,
69
+ commandTimedOut: executed.killed,
70
+ };
71
+ }
72
+ catch (error) {
73
+ signal?.throwIfAborted();
74
+ return {
75
+ ok: false,
76
+ cause: "file_unavailable",
77
+ commandExecution,
78
+ error: `Command executable unavailable: ${String(error)}`,
79
+ };
80
+ }
81
+ signal?.throwIfAborted();
82
+ const sizes = await Promise.all([stdoutPath, stderrPath].map(async (path) => {
83
+ try {
84
+ return (await stat(path)).size;
85
+ }
86
+ catch (error) {
87
+ if (error instanceof Error &&
88
+ "code" in error &&
89
+ error.code === "ENOENT")
90
+ return 0;
91
+ throw error;
92
+ }
93
+ }));
94
+ if (sizes.some((size) => size > OUTPUT_FILE_MAX_BYTES))
95
+ return {
96
+ ok: false,
97
+ cause: "evidence_too_large",
98
+ commandExecution: "finished",
99
+ ...completion,
100
+ error: `output exceeded ${OUTPUT_FILE_MAX_BYTES} bytes per stream; narrow command`,
101
+ };
102
+ const targets = [];
103
+ let signature;
104
+ let compressedChars = 0;
105
+ let linesTruncated = false;
106
+ let lineOmittedChars = 0;
107
+ let shapeLimitExceeded = false;
108
+ const texts = [];
109
+ let redactions = 0;
110
+ const redacted = (text) => {
111
+ const result = redactSecret(text, secret);
112
+ redactions += result.count;
113
+ return result.text;
114
+ };
115
+ for (const [index, path] of [stdoutPath, stderrPath].entries()) {
116
+ if (!sizes[index]) {
117
+ texts.push(redacted(index === 0 ? executed.stdout : executed.stderr));
118
+ continue;
119
+ }
120
+ const frequencies = new Map();
121
+ let shapeLimit = false;
122
+ for await (const raw of outputLines(path, signal)) {
123
+ signal?.throwIfAborted();
124
+ const line = redactSecret(cleanOutput(raw), secret).text;
125
+ linesTruncated ||= raw.endsWith(TRUNCATION_MARK);
126
+ const shape = lineShape(line);
127
+ if (!frequencies.has(shape) &&
128
+ frequencies.size >= OUTPUT_SHAPE_MAX_COUNT) {
129
+ shapeLimit = true;
130
+ shapeLimitExceeded = true;
131
+ frequencies.clear();
132
+ break;
133
+ }
134
+ frequencies.set(shape, Math.min(OUTPUT_REPEAT_MIN, (frequencies.get(shape) ?? 0) + 1));
135
+ }
136
+ let text = "";
137
+ const compressor = createRarityCompressor(frequencies, (line) => {
138
+ compressedChars += line.length + 1;
139
+ const remaining = OUTPUT_CHUNK_CHARS * OUTPUT_FIND_MAX_CALLS + 1 - text.length;
140
+ if (remaining > 0)
141
+ text += `${line}\n`.slice(0, remaining);
142
+ });
143
+ const failureWindow = [];
144
+ let failureSeen = false;
145
+ let afterFailure = 0;
146
+ // node:test prints its failing-test block ("✖ failing tests:" then
147
+ // "test at <file>:<line>:<col>") after one summary line per test, so
148
+ // the block can sit more than one window past the first anchor. Once
149
+ // the first window closes, keep scanning for that header and collect
150
+ // the next window after it. At most two windows (2 * (WINDOW + 1)
151
+ // lines) are ever buffered per stream.
152
+ let secondSeen = false;
153
+ let afterSecond = 0;
154
+ const isAnchor = (line) => failureTargets(line).signature !== undefined ||
155
+ /^\s*(?:✖|ERROR |FAIL |test at )/.test(line);
156
+ const isFailingTestsHeader = (line) => /^\s*(?:✖\s+)?failing tests:?\s*$/.test(line);
157
+ if (shapeLimit)
158
+ compressor.line(`…[shape limit exceeded at ${OUTPUT_SHAPE_MAX_COUNT}; rarity grouping disabled]`);
159
+ for await (const raw of outputLines(path, signal, (chars) => {
160
+ lineOmittedChars += chars;
161
+ })) {
162
+ signal?.throwIfAborted();
163
+ linesTruncated ||= raw.endsWith(TRUNCATION_MARK);
164
+ // Redact before compression, failure windows and state assembly.
165
+ const line = redacted(cleanOutput(raw));
166
+ compressor.line(line);
167
+ if (!failureSeen) {
168
+ failureWindow.push(line);
169
+ if (failureWindow.length > OUTPUT_FAILURE_WINDOW_LINES + 1)
170
+ failureWindow.shift();
171
+ if (isAnchor(raw))
172
+ failureSeen = true;
173
+ }
174
+ else if (afterFailure++ < OUTPUT_FAILURE_WINDOW_LINES) {
175
+ failureWindow.push(line);
176
+ if (!secondSeen && afterFailure > 1 && isFailingTestsHeader(raw)) {
177
+ secondSeen = true;
178
+ afterSecond = 0;
179
+ }
180
+ }
181
+ else if (!secondSeen) {
182
+ if (isFailingTestsHeader(raw)) {
183
+ secondSeen = true;
184
+ afterSecond = 0;
185
+ failureWindow.push(line);
186
+ }
187
+ }
188
+ else if (afterSecond++ < OUTPUT_FAILURE_WINDOW_LINES) {
189
+ failureWindow.push(line);
190
+ }
191
+ }
192
+ compressor.finish();
193
+ const failure = failureTargets(failureWindow.join("\n"));
194
+ signature ??= failure.signature;
195
+ targets.push(...failure.targets.slice(0, 20 - targets.length));
196
+ texts.push(text);
197
+ }
198
+ return {
199
+ ok: true,
200
+ commandExecution: "finished",
201
+ ...completion,
202
+ output: {
203
+ command: redacted(command),
204
+ exit_code: executed.killed ? null : executed.code,
205
+ timed_out: executed.killed,
206
+ stdout: texts[0] ?? "",
207
+ stderr: texts[1] ?? "",
208
+ compressed: true,
209
+ truncated: linesTruncated,
210
+ },
211
+ originalBytes: sizes.reduce((sum, size) => sum + size, 0),
212
+ compressedChars,
213
+ lineOmittedChars,
214
+ shapeLimitExceeded,
215
+ selectedPassages: false,
216
+ assertion: signature === "assertion",
217
+ targets,
218
+ redactions,
219
+ };
220
+ }
221
+ catch (error) {
222
+ signal?.throwIfAborted();
223
+ return {
224
+ ok: false,
225
+ cause: "file_unavailable",
226
+ commandExecution,
227
+ ...completion,
228
+ error: `Command capture unavailable: ${String(error)}`,
229
+ };
230
+ }
231
+ finally {
232
+ await rm(directory, { recursive: true, force: true });
233
+ }
234
+ }