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,821 @@
1
+ import { isAbsolute, matchesGlob, relative, resolve } from "node:path";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { createAnalysisContext } from "../adapters/analysis-context.js";
4
+ import { resolveEvidenceContext, withEvidenceContext, } from "../adapters/evidence-context.js";
5
+ import { collectUnits } from "../adapters/git.js";
6
+ import { resolveBase } from "../adapters/git-base.js";
7
+ import { shareGitInventory } from "../adapters/git-inventory.js";
8
+ import { attachRunnerVersions } from "../adapters/runner-version.js";
9
+ import { collectTestInventory } from "../adapters/test-inventory.js";
10
+ import { hostUsage } from "../adapters/usage.js";
11
+ import { SELECT_MIN, STATE_MAX_CHARS, WITNESS_AUTO_MIN_CELLS, } from "../constants.js";
12
+ import { createImportGraphBuilder, importClosureLazy, } from "../core/imports.js";
13
+ import { buildEnvelope } from "../core/output.js";
14
+ import { known as reportKnown, } from "../core/result-report.js";
15
+ import { buildRunnerCommands } from "../core/test-commands.js";
16
+ import { prepareCoverageWitnesses } from "../core/test-coverage.js";
17
+ import { discoverTests, isTestConfiguration } from "../core/test-discovery.js";
18
+ import { prepareTestEvidence, } from "../core/test-evidence.js";
19
+ import { prepareTestStates } from "../core/test-state.js";
20
+ import { buildCoverageWitnessUnits, evaluateBatchWitnessHealth, } from "../presets/witnesses.js";
21
+ import { renderResultReport } from "../render.js";
22
+ import { SELECT_TESTS_DESCRIPTION } from "../texts/select-tests.js";
23
+ import { controlsFor, ReviewReport, reportMetrics } from "./review-report.js";
24
+ export const selectTestsParameters = Type.Object({
25
+ root: Type.Optional(Type.String()),
26
+ base: Type.Optional(Type.String({ minLength: 1 })),
27
+ paths: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
28
+ witnesses: Type.Optional(Type.Union([
29
+ Type.Literal("off"),
30
+ Type.Literal("auto"),
31
+ Type.Literal("on"),
32
+ ])),
33
+ max_calls: Type.Optional(Type.Integer({ minimum: 0 })),
34
+ }, { additionalProperties: false });
35
+ function makeCandidate(entry, files, evidence, changed, outside) {
36
+ const enriched = evidence.units.map((unit) => ({
37
+ id: unit.id,
38
+ kind: unit.kind,
39
+ file: unit.file,
40
+ name: unit.name,
41
+ exported: unit.exported,
42
+ before: unit.before,
43
+ after: unit.after,
44
+ usedBy: (evidence.usedBy.get(unit.id) ?? []).map(({ path, text }) => ({
45
+ path,
46
+ text,
47
+ })),
48
+ }));
49
+ return {
50
+ entry,
51
+ paths: evidence.paths,
52
+ units: evidence.units,
53
+ state: {
54
+ testFile: { path: entry.path, text: files.get(entry.path)?.text ?? "" },
55
+ changedUnits: enriched,
56
+ imports: evidence.imports.map(({ path, text }) => ({ path, text })),
57
+ },
58
+ touched: changed.has(entry.path),
59
+ uncertain: evidence.uncertain || outside,
60
+ };
61
+ }
62
+ export function createSelectTestsTool(dependencies) {
63
+ const { host, runtime, exec: execute } = dependencies;
64
+ return {
65
+ name: "jev_select_tests",
66
+ label: "Jev select tests",
67
+ description: SELECT_TESTS_DESCRIPTION,
68
+ parameters: selectTestsParameters,
69
+ ...(host.isOmp
70
+ ? { approval: "read", loadMode: "essential" }
71
+ : {
72
+ promptSnippet: "Pick the tests the diff can affect and the runner command for only those",
73
+ }),
74
+ async execute(_id, args, signal, _update, ctx) {
75
+ const client = dependencies.client;
76
+ const evidence = await resolveEvidenceContext(ctx.cwd, args.root, {
77
+ exec: execute,
78
+ signal,
79
+ origin: dependencies.evidenceOrigin,
80
+ });
81
+ const evidenceContext = evidence.context;
82
+ const cwd = evidence.ok ? evidence.cwd : evidenceContext.authority.path;
83
+ const started = performance.now();
84
+ const exec = shareGitInventory(execute);
85
+ const totals = {
86
+ calls: 0,
87
+ questions: 0,
88
+ cacheHits: 0,
89
+ cacheRequests: 0,
90
+ usage: { inputTokens: 0, costUsd: 0 },
91
+ };
92
+ const selectionEvidence = {
93
+ criteria: [],
94
+ wideningTriggers: [],
95
+ inventory: [],
96
+ };
97
+ const report = new ReviewReport();
98
+ let costKnown = false;
99
+ let unknownCost = false;
100
+ let sent = 0;
101
+ let budget;
102
+ const finish = (input, cause) => {
103
+ const limitKeys = new Set();
104
+ const uniqueLimits = input.limitations?.filter((limit) => {
105
+ const key = JSON.stringify([limit.fact, limit.next]);
106
+ if (limitKeys.has(key))
107
+ return false;
108
+ limitKeys.add(key);
109
+ return true;
110
+ });
111
+ const envelope = buildEnvelope({
112
+ ...input,
113
+ limitations: uniqueLimits,
114
+ yield: {
115
+ ...totals,
116
+ costUsd: costKnown && !unknownCost ? totals.usage.costUsd : undefined,
117
+ elapsedMs: performance.now() - started,
118
+ },
119
+ });
120
+ if (input.refusal) {
121
+ report.refusal = true;
122
+ report.diagnose(cause ?? "internal_error", input.refusal);
123
+ }
124
+ if (!report.items.size && !input.refusal)
125
+ report.diagnose("collection_empty", "No test decision candidates in the discovered inventory; this is not proof of no affected tests.", undefined, [], false);
126
+ const result = report.build("jev_select_tests", evidenceContext, reportMetrics(envelope));
127
+ runtime.session.record(envelope);
128
+ runtime.guide.deliver(ctx);
129
+ const details = {
130
+ ok: true,
131
+ answers: {},
132
+ ...totals,
133
+ evidenceContext,
134
+ selectionEvidence,
135
+ result,
136
+ limitations: uniqueLimits,
137
+ };
138
+ return {
139
+ content: [
140
+ {
141
+ type: "text",
142
+ text: renderResultReport(result, { details: envelope }),
143
+ },
144
+ ],
145
+ details,
146
+ ...hostUsage(host.isOmp, costKnown ? totals.usage : undefined),
147
+ };
148
+ };
149
+ if (!evidence.ok)
150
+ return finish({ refusal: evidence.error }, evidence.cause ?? "invalid_root");
151
+ evidenceContext.requestedBase = args.base ?? "HEAD";
152
+ for (const path of args.paths ?? [])
153
+ if (isAbsolute(path) ||
154
+ path.split(/[\\/]/).includes("..") ||
155
+ path.split(/[\\/]/).includes(".git"))
156
+ return finish({ refusal: `Path not permitted: ${path}` }, "forbidden_path");
157
+ const comparison = await resolveBase(exec, cwd, args.base, signal);
158
+ if (!comparison.ok)
159
+ return finish({ refusal: comparison.error }, comparison.cause ?? "invalid_base");
160
+ evidenceContext.resolvedBase = comparison.base;
161
+ const analysis = await createAnalysisContext();
162
+ const [inventory, diff] = await Promise.all([
163
+ collectTestInventory(exec, cwd, signal),
164
+ collectUnits(exec, { cwd: cwd, base: comparison.base, signal }, analysis.parser),
165
+ ]);
166
+ if (!inventory.ok)
167
+ return finish({ refusal: inventory.error }, inventory.cause ?? "file_unavailable");
168
+ if (!diff.ok)
169
+ return finish({ refusal: diff.error }, diff.cause ?? "git_failure");
170
+ if (evidenceContext.effectiveRoot)
171
+ evidenceContext.effectiveRoot.path = inventory.cwd;
172
+ selectionEvidence.inventory = [...inventory.paths];
173
+ const known = new Set(inventory.paths);
174
+ const sourceFiles = new Map();
175
+ const read = async (path) => {
176
+ const source = await inventory.read(path);
177
+ if (source)
178
+ sourceFiles.set(path, source);
179
+ return source;
180
+ };
181
+ const configurationPaths = inventory.paths.filter((path) => isTestConfiguration(path) ||
182
+ /(?:^|\/)tsconfig[^/]*\.json$/.test(path));
183
+ for (let offset = 0; offset < configurationPaths.length; offset += 16)
184
+ await Promise.all(configurationPaths.slice(offset, offset + 16).map(read));
185
+ const candidatePaths = new Set();
186
+ const configurationSet = new Set(configurationPaths);
187
+ for (;;) {
188
+ candidatePaths.clear();
189
+ const references = new Set();
190
+ discoverTests(inventory.paths.map((path) => sourceFiles.get(path) ?? { path, text: "" }), candidatePaths, analysis, references);
191
+ const pending = [...references].filter((path) => known.has(path) && !configurationSet.has(path));
192
+ if (!pending.length)
193
+ break;
194
+ for (const path of pending)
195
+ configurationSet.add(path);
196
+ configurationPaths.push(...pending);
197
+ for (let offset = 0; offset < pending.length; offset += 16)
198
+ await Promise.all(pending.slice(offset, offset + 16).map(read));
199
+ }
200
+ const planned = [...candidatePaths];
201
+ for (let offset = 0; offset < planned.length; offset += 16)
202
+ await Promise.all(planned.slice(offset, offset + 16).map(read));
203
+ const discovery = discoverTests([...sourceFiles.values()], undefined, analysis);
204
+ const versionedEntries = await attachRunnerVersions(inventory.cwd, discovery.entries, inventory.read, known);
205
+ const changed = new Set(diff.files.flatMap((file) => [file.path, file.oldPath]));
206
+ const build = createImportGraphBuilder([...sourceFiles.values()].filter((file) => configurationPaths.includes(file.path)), known, analysis);
207
+ const cache = new Map();
208
+ const roots = [
209
+ ...new Set([
210
+ ...discovery.entries.map((entry) => entry.path),
211
+ ...diff.units.map((unit) => unit.file),
212
+ ...inventory.paths.filter((path) => /(?:^|\/)conftest\.py$/.test(path)),
213
+ ]),
214
+ ];
215
+ for (const path of roots)
216
+ await importClosureLazy(read, path, { known, build, cache });
217
+ const graphs = await Promise.all(cache.values());
218
+ const graph = {
219
+ edges: new Map(graphs.flatMap((part) => [...part.edges])),
220
+ limits: graphs.flatMap((part) => part.limits),
221
+ };
222
+ const evidenceFor = prepareTestEvidence([...sourceFiles.values()], graph, diff.units, analysis.parser);
223
+ const evidenceLimits = [];
224
+ const outside = diff.files.some((file) => !graph.edges.has(file.path) ||
225
+ !/\.[cm]?[jt]sx?$|\.py$/.test(file.path));
226
+ selectionEvidence.wideningTriggers = diff.files
227
+ .filter((file) => !graph.edges.has(file.path) ||
228
+ !/\.[cm]?[jt]sx?$|\.py$/.test(file.path))
229
+ .map((file) => file.path);
230
+ selectionEvidence.criteria = (args.paths ?? []).map((criterion) => ({
231
+ criterion,
232
+ matches: versionedEntries
233
+ .filter((entry) => matchesGlob(entry.path, criterion) ||
234
+ entry.path.startsWith(`${criterion.replace(/\/$/, "")}/`))
235
+ .map((entry) => entry.path),
236
+ }));
237
+ const candidates = versionedEntries
238
+ .filter((entry) => !args.paths?.length ||
239
+ args.paths.some((path) => matchesGlob(entry.path, path) ||
240
+ entry.path.startsWith(`${path.replace(/\/$/, "")}/`)))
241
+ .map((entry) => {
242
+ const evidence = evidenceFor(entry);
243
+ evidenceLimits.push(...evidence.limits.map((limit) => ({
244
+ cause: limit.kind,
245
+ path: limit.path,
246
+ dependency: limit.specifier,
247
+ fact: `${limit.kind}: ${limit.path}${limit.specifier ? ` (${limit.specifier})` : ""}`,
248
+ next: "read the unresolved call chain or run this test",
249
+ })));
250
+ return makeCandidate(entry, sourceFiles, outside || evidence.uncertain
251
+ ? { ...evidence, units: diff.units }
252
+ : evidence, changed, outside);
253
+ });
254
+ report.inventories.push({
255
+ id: "test-candidates",
256
+ kind: "tests",
257
+ rules: [
258
+ "Tracked supported test declarations and literal project runner configuration",
259
+ "Import closure selection; conservative fallback preserves unjudged decisions",
260
+ ],
261
+ restrictions: args.paths ?? [],
262
+ discovered: reportKnown(versionedEntries.length),
263
+ considered: reportKnown(candidates.length),
264
+ scopeRestricted: Boolean(args.paths?.length),
265
+ criteria: selectionEvidence.criteria.map((criterion) => ({
266
+ criterion: criterion.criterion,
267
+ matches: reportKnown(criterion.matches.length),
268
+ outcome: criterion.matches.length ? "matched" : "no_match",
269
+ diagnosticIds: [],
270
+ })),
271
+ });
272
+ for (const candidate of candidates) {
273
+ const scenarios = candidate.entry.scenarios;
274
+ if (!scenarios.length)
275
+ report.expect(`test:${candidate.entry.path}:unknown`, candidate.entry.path, "scenario");
276
+ for (const scenario of scenarios)
277
+ report.expect(`test:${candidate.entry.path}:${scenario.id}`, `${candidate.entry.path}: ${scenario.name}`, "scenario", `test:${candidate.entry.path}`);
278
+ }
279
+ for (const criterion of selectionEvidence.criteria)
280
+ if (!criterion.matches.length) {
281
+ report.diagnose("criteria_no_match", `No discovered tests match criterion ${criterion.criterion}`, criterion.criterion);
282
+ const excluded = inventory.limits.filter((limit) => matchesGlob(limit.path, criterion.criterion) ||
283
+ limit.path.startsWith(`${criterion.criterion.replace(/\/$/, "")}/`));
284
+ if (excluded.length) {
285
+ report.diagnose("outside_inventory", "Requested criterion names entries excluded from the admitted inventory", criterion.criterion, [], false, excluded.map((limit) => limit.path));
286
+ const entry = report.inventories[0]?.criteria.find((entry) => entry.criterion === criterion.criterion);
287
+ if (entry)
288
+ entry.outcome = "outside_inventory";
289
+ }
290
+ }
291
+ if (selectionEvidence.wideningTriggers.length)
292
+ report.diagnose("conservative_widening", `Changed files outside supported dependency graph: ${selectionEvidence.wideningTriggers.join(", ")}`, undefined, [], false);
293
+ const selected = [];
294
+ const answers = [];
295
+ const unjudged = [];
296
+ const limitations = [
297
+ ...evidenceLimits,
298
+ ...discovery.limits.map((limit) => ({
299
+ cause: `runner ${limit.kind}${limit.framework ? ` (${limit.framework})` : ""}`,
300
+ path: limit.path,
301
+ fact: limit.kind === "types"
302
+ ? `type tests separate from runtime — ${limit.path}`
303
+ : limit.kind === "local_runner_unproven"
304
+ ? `local runner not proven: ${limit.framework} — ${limit.path}`
305
+ : limit.kind === "interactive_script_skipped"
306
+ ? `interactive runner script skipped — ${limit.path}`
307
+ : limit.kind === "unsupported"
308
+ ? `framework unsupported: ${limit.framework} — ${limit.path}`
309
+ : limit.kind === "unknown"
310
+ ? `framework unknown — ${limit.path}`
311
+ : `runner discovery incomplete — ${limit.path}`,
312
+ next: limit.kind === "types"
313
+ ? "run the identified project type-check command"
314
+ : limit.kind === "local_runner_unproven"
315
+ ? "verify the project runner executable"
316
+ : limit.kind === "interactive_script_skipped"
317
+ ? "use the non-interactive native runner command"
318
+ : limit.kind === "unsupported" || limit.kind === "unknown"
319
+ ? "run the listed files with the project runner"
320
+ : "read the config or collect with the project runner",
321
+ })),
322
+ ...inventory.limits.map((limit) => ({
323
+ cause: limit.kind,
324
+ path: limit.path,
325
+ fact: `${limit.kind}: ${limit.path}`,
326
+ next: "read the missing file or collect with the project runner",
327
+ })),
328
+ ];
329
+ for (const limit of graph.limits)
330
+ limitations.push({
331
+ cause: `import ${limit.kind}`,
332
+ path: limit.path,
333
+ dependency: limit.specifier,
334
+ fact: `${limit.kind === "dynamic" ? "dynamic import unresolved" : `import ${limit.kind}`}: ${limit.path} (${limit.specifier})`,
335
+ next: "read the missing dependency or collect with the project runner",
336
+ });
337
+ for (const limit of diff.limits)
338
+ limitations.push({
339
+ cause: limit.kind,
340
+ path: limit.file,
341
+ fact: `${limit.kind}: ${limit.file}`,
342
+ next: "read the complete changed source",
343
+ });
344
+ const coverage = new Map();
345
+ const incompleteUnits = new Set();
346
+ let fallback = false;
347
+ let skipped = 0;
348
+ const judge = async (state, questions, witnessIds) => {
349
+ state = withEvidenceContext(state, evidenceContext);
350
+ if (JSON.stringify(state).length > STATE_MAX_CHARS)
351
+ return {
352
+ ok: false,
353
+ cause: "evidence_too_large",
354
+ error: `required evidence exceeds STATE_MAX_CHARS=${STATE_MAX_CHARS}`,
355
+ };
356
+ if (args.max_calls !== undefined && sent >= args.max_calls) {
357
+ budget = {
358
+ kind: "max_calls",
359
+ message: `max_calls=${args.max_calls} reached`,
360
+ };
361
+ return { ok: false, error: budget.message };
362
+ }
363
+ if (!client)
364
+ return { ok: false, error: "Jev unavailable" };
365
+ const result = await client.judge(state, questions, {
366
+ signal,
367
+ witnesses: witnessIds,
368
+ ...runtime.session.requestGate(),
369
+ admissionCause: () => budget?.kind === "session" ? "session_budget" : "call_budget",
370
+ beforeRequest: (questionCount) => {
371
+ if (args.max_calls !== undefined && sent >= args.max_calls) {
372
+ budget = {
373
+ kind: "max_calls",
374
+ message: `max_calls=${args.max_calls} reached`,
375
+ };
376
+ return { ok: false, error: budget.message };
377
+ }
378
+ const admitted = runtime.session.admit(questionCount);
379
+ if (!admitted.ok) {
380
+ budget = { kind: "session", message: admitted.error };
381
+ return admitted;
382
+ }
383
+ sent++;
384
+ return admitted;
385
+ },
386
+ onUsage: (usage) => runtime.session.recordUsage(usage),
387
+ });
388
+ totals.calls += result.calls ?? 0;
389
+ totals.questions += result.questions ?? 0;
390
+ totals.cacheHits += result.cacheHits ?? 0;
391
+ totals.cacheRequests += result.cacheRequests ?? 0;
392
+ unknownCost ||= result.usage === undefined;
393
+ if (result.usage) {
394
+ costKnown = true;
395
+ totals.usage.inputTokens += result.usage.inputTokens;
396
+ totals.usage.costUsd += result.usage.costUsd;
397
+ }
398
+ return result;
399
+ };
400
+ const pointerResults = await Promise.all(candidates.map(async (candidate) => {
401
+ const { entry } = candidate;
402
+ const reportIds = [...report.items.values()]
403
+ .filter((item) => item.groupId === `test:${entry.path}` ||
404
+ item.id === `test:${entry.path}:unknown`)
405
+ .map((item) => item.id);
406
+ if (candidate.touched)
407
+ for (const id of reportIds)
408
+ report.static(id, "touched", true);
409
+ if (!candidate.units.length &&
410
+ !candidate.uncertain &&
411
+ !outside &&
412
+ !candidate.touched)
413
+ for (const id of reportIds)
414
+ report.static(id, "static import closure cannot reach changed units", false);
415
+ if (candidate.touched)
416
+ return {
417
+ candidate,
418
+ selected: null,
419
+ touched: true,
420
+ };
421
+ if (!candidate.units.length && !candidate.uncertain && !outside) {
422
+ skipped++;
423
+ return {
424
+ candidate,
425
+ selected: [],
426
+ touched: false,
427
+ };
428
+ }
429
+ const units = candidate.units;
430
+ if (units.some((unit) => unit.before === null && unit.after === null)) {
431
+ report.diagnose("binary_or_non_utf8", "Changed source unavailable", entry.path, reportIds);
432
+ for (const unit of units)
433
+ incompleteUnits.add(unit.id);
434
+ unjudged.push({
435
+ label: entry.path,
436
+ reason: "changed source not UTF-8 text or unavailable",
437
+ next: "run the whole file with the project runner",
438
+ });
439
+ return { candidate, selected: null, touched: false };
440
+ }
441
+ if (!entry.scenarios.length) {
442
+ report.totalUnknown = true;
443
+ report.diagnose("unsupported_syntax", "Scenario names or count unresolved", entry.path, reportIds);
444
+ unjudged.push({
445
+ label: entry.path,
446
+ reason: "scenario names or count unresolved",
447
+ next: "run the whole file with the project runner",
448
+ });
449
+ return { candidate, selected: null, touched: false };
450
+ }
451
+ const prepared = prepareTestStates(candidate.state, entry.scenarios);
452
+ const ids = prepared.unjudged.map((item) => item.scenario.id);
453
+ for (const item of prepared.unjudged) {
454
+ report.diagnose("evidence_too_large", item.reason, entry.path, [
455
+ `test:${entry.path}:${item.scenario.id}`,
456
+ ]);
457
+ for (const unit of units)
458
+ incompleteUnits.add(unit.id);
459
+ unjudged.push({
460
+ label: `${entry.path}: ${item.scenario.name}`,
461
+ reason: item.reason,
462
+ next: "run this test with the project runner",
463
+ });
464
+ limitations.push({
465
+ cause: "required test evidence omitted",
466
+ path: entry.path,
467
+ dependency: item.scenario.name,
468
+ fact: `required test evidence omitted: ${entry.path}: ${item.scenario.name}`,
469
+ next: "read the complete test body and required imports",
470
+ });
471
+ }
472
+ await Promise.all(prepared.batches.map(async (batch) => {
473
+ const questions = Object.fromEntries(batch.scenarios.map((scenario) => [
474
+ scenario.id,
475
+ {
476
+ type: "choice",
477
+ instructions: `When the test named ${scenario.name} runs, which changed unit does it execute or read, directly or through the functions it calls?`,
478
+ criteria: Object.fromEntries([
479
+ ...units.map((unit) => [
480
+ unit.id,
481
+ `${unit.name} (${unit.file})`,
482
+ ]),
483
+ ["none", "No changed unit executes or gets read"],
484
+ ]),
485
+ },
486
+ ]));
487
+ const result = await judge(batch.state, questions);
488
+ const batchIds = batch.scenarios.map((scenario) => `test:${entry.path}:${scenario.id}`);
489
+ if (!result.ok)
490
+ report.failure(result, batchIds, budget
491
+ ? budget.kind === "session"
492
+ ? "session_budget"
493
+ : "call_budget"
494
+ : !client
495
+ ? "not_configured"
496
+ : undefined);
497
+ else
498
+ for (const scenario of batch.scenarios) {
499
+ const answer = result.answers[scenario.id];
500
+ const id = `test:${entry.path}:${scenario.id}`;
501
+ report.answer(id, answer, {
502
+ band: prepared.unjudged.length ? "unsure" : "verdict",
503
+ });
504
+ const item = report.items.get(id);
505
+ if (!item)
506
+ throw new Error(`Unregistered selection result ${id}`);
507
+ item.selection = {
508
+ selected: answer?.type !== "choice" ||
509
+ 1 - (answer.probabilities.none ?? 0) >= SELECT_MIN,
510
+ reason: answer?.type === "choice"
511
+ ? "changed-unit pointer selection threshold"
512
+ : "conservative_fallback",
513
+ };
514
+ }
515
+ if (!result.ok) {
516
+ fallback ||= !budget;
517
+ for (const unit of units)
518
+ incompleteUnits.add(unit.id);
519
+ ids.push(...batch.scenarios.map((scenario) => scenario.id));
520
+ unjudged.push({
521
+ label: entry.path,
522
+ reason: result.error,
523
+ next: budget ? "raise max_calls" : "run all discovered tests",
524
+ });
525
+ return;
526
+ }
527
+ for (const scenario of batch.scenarios) {
528
+ const answer = result.answers[scenario.id];
529
+ if (answer?.type !== "choice" ||
530
+ typeof answer.probabilities.none !== "number" ||
531
+ !Number.isFinite(answer.probabilities.none)) {
532
+ ids.push(scenario.id);
533
+ for (const unit of units)
534
+ incompleteUnits.add(unit.id);
535
+ unjudged.push({
536
+ label: `${entry.path}: ${scenario.name}`,
537
+ reason: answer?.type === "unjudged"
538
+ ? answer.reason
539
+ : "missing valid pointer",
540
+ next: "run this test",
541
+ });
542
+ continue;
543
+ }
544
+ const p = 1 - answer.probabilities.none;
545
+ if (p >= SELECT_MIN) {
546
+ ids.push(scenario.id);
547
+ coverage.set(answer.choice, Math.max(coverage.get(answer.choice) ?? 0, p));
548
+ answers.push({
549
+ label: `${entry.path}: ${scenario.name}`,
550
+ value: {
551
+ head: `runs ${units.find((unit) => unit.id === answer.choice)?.name ?? answer.choice}`,
552
+ p,
553
+ },
554
+ band: prepared.unjudged.length ? "unsure" : "verdict",
555
+ });
556
+ }
557
+ }
558
+ }));
559
+ return { candidate, selected: ids, touched: false };
560
+ }));
561
+ for (const result of pointerResults) {
562
+ if (result.selected === null || result.selected.length)
563
+ selected.push({
564
+ entry: result.candidate.entry,
565
+ scenarioIds: result.selected,
566
+ });
567
+ if (result.touched)
568
+ answers.push({
569
+ label: result.candidate.entry.path,
570
+ value: { head: "touched", p: 1 },
571
+ band: "verdict",
572
+ });
573
+ }
574
+ const residual = diff.units.filter((unit) => unit.exported && (coverage.get(unit.id) ?? 0) < SELECT_MIN);
575
+ await Promise.all(candidates.map(async (candidate) => {
576
+ const units = residual.filter((unit) => candidate.units.some((reached) => reached.id === unit.id) ||
577
+ candidate.uncertain ||
578
+ outside);
579
+ if (!units.length)
580
+ return;
581
+ const witnessUnits = args.witnesses === "off" ||
582
+ (args.witnesses !== "on" &&
583
+ units.length * candidate.entry.scenarios.length <
584
+ WITNESS_AUTO_MIN_CELLS)
585
+ ? []
586
+ : buildCoverageWitnessUnits(units);
587
+ const preliminary = prepareCoverageWitnesses(candidate.state, {}, witnessUnits);
588
+ const prepared = prepareTestStates(preliminary.state, candidate.entry.scenarios);
589
+ for (const unit of units)
590
+ for (const scenario of candidate.entry.scenarios)
591
+ report.expect(`coverage:${candidate.entry.path}:${scenario.id}:${unit.id}`, `${candidate.entry.path}: ${scenario.name} executes ${unit.name}`, "scenario", `coverage:${candidate.entry.path}:${scenario.id}`);
592
+ for (const omitted of prepared.unjudged)
593
+ for (const unit of units)
594
+ report.diagnose("evidence_too_large", omitted.reason, candidate.entry.path, [
595
+ `coverage:${candidate.entry.path}:${omitted.scenario.id}:${unit.id}`,
596
+ ]);
597
+ if (prepared.unjudged.length || !candidate.entry.scenarios.length)
598
+ for (const unit of units)
599
+ incompleteUnits.add(unit.id);
600
+ await Promise.all(prepared.batches.map(async (batch) => {
601
+ const questions = Object.fromEntries(units.flatMap((unit) => batch.scenarios.map((scenario) => [
602
+ `${scenario.id}_${unit.id}`,
603
+ {
604
+ type: "bool",
605
+ instructions: `When test ${scenario.name} runs, does the after version of unit ${unit.id} (${unit.name}) execute or get read, directly or through functions it calls?`,
606
+ },
607
+ ])));
608
+ if (!Object.keys(questions).length)
609
+ return;
610
+ const witnessQuestions = prepareCoverageWitnesses(candidate.state, {}, witnessUnits);
611
+ const realIds = Object.keys(questions);
612
+ const result = await judge(batch.state, { ...questions, ...witnessQuestions.questions }, witnessQuestions.witnesses.map((witness) => witness.id));
613
+ const health = evaluateBatchWitnessHealth(realIds, witnessQuestions.witnesses, result);
614
+ limitations.push(...health.failures);
615
+ for (const failure of health.failures)
616
+ report.diagnose("control_failure", failure.fact, candidate.entry.path, units.flatMap((unit) => batch.scenarios
617
+ .filter((scenario) => health.unhealthyQuestionIds.has(`${scenario.id}_${unit.id}`))
618
+ .map((scenario) => `coverage:${candidate.entry.path}:${scenario.id}:${unit.id}`)), false);
619
+ report.countControls(result, witnessQuestions.witnesses.map((witness) => witness.id));
620
+ for (const unit of units)
621
+ for (const scenario of batch.scenarios) {
622
+ const id = `coverage:${candidate.entry.path}:${scenario.id}:${unit.id}`;
623
+ report.expect(id, `${candidate.entry.path}: ${scenario.name} executes ${unit.name}`, "scenario", `coverage:${candidate.entry.path}:${scenario.id}`);
624
+ if (!result.ok)
625
+ report.failure(result, [id], budget
626
+ ? budget.kind === "session"
627
+ ? "session_budget"
628
+ : "call_budget"
629
+ : !client
630
+ ? "not_configured"
631
+ : undefined);
632
+ else
633
+ report.answer(id, result.answers[`${scenario.id}_${unit.id}`], {
634
+ band: health.unhealthyQuestionIds.has(`${scenario.id}_${unit.id}`)
635
+ ? "unsure"
636
+ : "verdict",
637
+ reason: health.unhealthyQuestionIds.get(`${scenario.id}_${unit.id}`),
638
+ }, controlsFor(`${scenario.id}_${unit.id}`, result, witnessQuestions.witnesses.map((witness) => witness.id)), false);
639
+ }
640
+ for (const unit of units) {
641
+ if (!result.ok) {
642
+ fallback ||= !budget;
643
+ incompleteUnits.add(unit.id);
644
+ continue;
645
+ }
646
+ for (const scenario of batch.scenarios) {
647
+ const id = `${scenario.id}_${unit.id}`;
648
+ const answer = result.answers[id];
649
+ if (health.unhealthyQuestionIds.has(id)) {
650
+ incompleteUnits.add(unit.id);
651
+ continue;
652
+ }
653
+ if (answer?.type === "bool") {
654
+ coverage.set(unit.id, Math.max(coverage.get(unit.id) ?? 0, answer.p));
655
+ if (answer.p >= SELECT_MIN) {
656
+ const existing = selected.find((item) => item.entry === candidate.entry);
657
+ if (!existing)
658
+ selected.push({
659
+ entry: candidate.entry,
660
+ scenarioIds: [scenario.id],
661
+ });
662
+ else if (existing.scenarioIds !== null &&
663
+ !existing.scenarioIds.includes(scenario.id))
664
+ existing.scenarioIds = [
665
+ ...existing.scenarioIds,
666
+ scenario.id,
667
+ ];
668
+ answers.push({
669
+ label: `${candidate.entry.path}: ${scenario.name}`,
670
+ value: { head: `runs ${unit.name}`, p: answer.p },
671
+ band: "verdict",
672
+ });
673
+ }
674
+ }
675
+ else
676
+ incompleteUnits.add(unit.id);
677
+ }
678
+ }
679
+ }));
680
+ }));
681
+ const incomplete = discovery.limits.some((limit) => limit.kind === "types"
682
+ ? !discovery.entries.some((entry) => entry.path === limit.path &&
683
+ entry.invocation?.kind === "typecheck")
684
+ : limit.kind !== "local_runner_unproven" &&
685
+ limit.kind !== "interactive_script_skipped") ||
686
+ inventory.limits.length > 0 ||
687
+ diff.limits.length > 0 ||
688
+ graph.limits.length > 0 ||
689
+ evidenceLimits.length > 0 ||
690
+ Boolean(args.paths?.length);
691
+ for (const unit of residual)
692
+ if ((coverage.get(unit.id) ?? 0) < SELECT_MIN) {
693
+ const supportingIds = [...report.items.keys()].filter((id) => id.startsWith("coverage:") && id.endsWith(`:${unit.id}`));
694
+ report.diagnose(incomplete || incompleteUnits.has(unit.id)
695
+ ? "collection_omitted"
696
+ : "conservative_widening", `Derived from retained coverage decisions: no discovered test established execution of ${unit.name} (${unit.file}) within the considered inventory only.${incomplete || incompleteUnits.has(unit.id) ? " Absence remains unsure because evidence, scope, or controls are incomplete." : " This is not a global coverage claim."}`, unit.file, supportingIds, false);
697
+ if (!supportingIds.length) {
698
+ const diagnostic = report.diagnostics.at(-1);
699
+ if (diagnostic)
700
+ diagnostic.scope = {
701
+ kind: "inventory",
702
+ inventoryIds: ["test-candidates"],
703
+ };
704
+ const action = report.actions.at(-1);
705
+ if (action)
706
+ action.scope = {
707
+ kind: "inventory",
708
+ inventoryIds: ["test-candidates"],
709
+ };
710
+ }
711
+ }
712
+ if (fallback) {
713
+ selected.length = 0;
714
+ selected.push(...candidates.map((candidate) => ({
715
+ entry: candidate.entry,
716
+ scenarioIds: null,
717
+ })));
718
+ report.diagnose("conservative_widening", "Unavailable judgment conservatively selects all considered test candidates; static reachability exclusions do not narrow this fallback.", undefined, [], false);
719
+ limitations.push({
720
+ fact: "fallback: all",
721
+ next: "run all discovered tests with their project runner",
722
+ });
723
+ }
724
+ const commands = buildRunnerCommands(selected);
725
+ limitations.push(...commands.limits.flatMap((limit) => limit.files.map((path) => ({
726
+ cause: limit.reason,
727
+ path,
728
+ fact: `${limit.reason}: ${path}`,
729
+ next: limit.action,
730
+ }))));
731
+ for (const limit of commands.limits)
732
+ for (const path of limit.files)
733
+ report.diagnose("unresolved_runner", limit.reason, path, [], false);
734
+ for (const limit of discovery.limits)
735
+ report.diagnose("unresolved_runner", limit.kind, limit.path, [], limit.kind !== "local_runner_unproven" &&
736
+ limit.kind !== "interactive_script_skipped");
737
+ for (const limit of inventory.limits)
738
+ report.diagnose(limit.kind === "secret_pattern"
739
+ ? "secret_pattern"
740
+ : "collection_omitted", limit.kind, limit.path);
741
+ for (const limit of diff.limits)
742
+ report.diagnose(limit.kind === "secret_pattern"
743
+ ? "secret_pattern"
744
+ : "collection_omitted", limit.kind, limit.file);
745
+ for (const limit of graph.limits)
746
+ report.diagnose("dynamic_dependency", `${limit.kind}${limit.specifier ? ` (${limit.specifier})` : ""}`, limit.path, [], false);
747
+ for (const item of report.items.values()) {
748
+ const candidate = candidates.find((candidate) => item.id.startsWith(`test:${candidate.entry.path}:`) ||
749
+ item.id.startsWith(`coverage:${candidate.entry.path}:`));
750
+ if (!candidate)
751
+ continue;
752
+ const plan = selected.find((plan) => plan.entry === candidate.entry);
753
+ const scenario = candidate.entry.scenarios.find((scenario) => item.id === `test:${candidate.entry.path}:${scenario.id}` ||
754
+ item.id.startsWith(`coverage:${candidate.entry.path}:${scenario.id}:`));
755
+ const isSelected = Boolean(plan &&
756
+ (plan.scenarioIds === null ||
757
+ (scenario && plan.scenarioIds.includes(scenario.id))));
758
+ item.selection = {
759
+ selected: isSelected,
760
+ reason: fallback
761
+ ? "conservative_fallback"
762
+ : item.treatment === "static"
763
+ ? item.staticReason
764
+ : item.treatment === "not_judged"
765
+ ? "conservative_fallback"
766
+ : "unchanged selection policy and whole-file widening",
767
+ };
768
+ }
769
+ for (const criterion of report.inventories[0]?.criteria ?? [])
770
+ criterion.diagnosticIds = report.diagnostics
771
+ .filter((diagnostic) => diagnostic.cause === "criteria_no_match" &&
772
+ diagnostic.target.status === "known" &&
773
+ diagnostic.target.value === criterion.criterion)
774
+ .map((diagnostic) => diagnostic.id);
775
+ report.actions.push({
776
+ id: "execute-selection-plan",
777
+ code: "execute_plan",
778
+ target: reportKnown(inventory.cwd),
779
+ scope: { kind: "call" },
780
+ condition: "When verification is authorized and the listed runner/configuration is available.",
781
+ instruction: "Execute the retained runner commands separately; this tool has not executed any test.",
782
+ repeatUnchanged: false,
783
+ });
784
+ if (skipped)
785
+ limitations.push({
786
+ fact: `${skipped} tests skipped because their imports cannot reach the diff`,
787
+ next: "an alias or dynamic import would have kept a test in",
788
+ });
789
+ for (const criterion of selectionEvidence.criteria)
790
+ if (!criterion.matches.length)
791
+ limitations.push({
792
+ path: criterion.criterion,
793
+ cause: "selection criterion has no discovered test match",
794
+ fact: `selection criterion without match: ${criterion.criterion}`,
795
+ next: "Change the criterion or supply the missing test/configuration evidence; an empty scope does not prove absence of impact.",
796
+ });
797
+ if (selectionEvidence.wideningTriggers.length)
798
+ limitations.push({
799
+ fact: `selection widened conservatively: ${selectionEvidence.wideningTriggers.join(", ")}`,
800
+ next: "Run the widened static plans; dependency reachability is not established for these changed files.",
801
+ });
802
+ return finish({
803
+ answers,
804
+ unjudged,
805
+ limitations,
806
+ limitationPriorityPaths: [
807
+ ...new Set([
808
+ ...selected.map(({ entry }) => entry.path),
809
+ ...diff.units.map((unit) => unit.file),
810
+ ]),
811
+ ],
812
+ ...(budget ? { budget } : {}),
813
+ lines: commands.commands.map((command) => ({
814
+ type: "command",
815
+ ...command,
816
+ cwd: relative(cwd, resolve(inventory.cwd, command.cwd)) || ".",
817
+ })),
818
+ });
819
+ },
820
+ };
821
+ }