jev-agent-tools 0.1.4 → 0.2.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 (131) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/CONTRIBUTING.md +40 -0
  3. package/README.md +42 -9
  4. package/SECURITY.md +27 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +189 -0
  7. package/dist/adapters/ask-proof.js +144 -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 +181 -0
  11. package/dist/adapters/docs.js +172 -0
  12. package/dist/adapters/exec.js +207 -0
  13. package/dist/adapters/files.js +293 -0
  14. package/dist/adapters/find.js +122 -0
  15. package/dist/adapters/git-base.js +26 -0
  16. package/dist/adapters/git-inventory.js +71 -0
  17. package/dist/adapters/git.js +439 -0
  18. package/dist/adapters/locate-file.js +159 -0
  19. package/dist/adapters/output-lines.js +46 -0
  20. package/dist/adapters/private-storage.js +98 -0
  21. package/dist/adapters/risk-callers.js +426 -0
  22. package/dist/adapters/runner-version.js +78 -0
  23. package/dist/adapters/shell.js +76 -0
  24. package/dist/adapters/syntax.js +187 -0
  25. package/dist/adapters/test-inventory.js +131 -0
  26. package/dist/adapters/usage.js +20 -0
  27. package/dist/adapters/utf8.js +47 -0
  28. package/dist/configuration.js +257 -0
  29. package/dist/constants.js +119 -0
  30. package/dist/core/ask-closure.js +282 -0
  31. package/dist/core/ask-proof.js +1 -0
  32. package/dist/core/ask-references.js +194 -0
  33. package/dist/core/asks.js +436 -0
  34. package/dist/core/batches.js +65 -0
  35. package/dist/core/command-output.js +224 -0
  36. package/dist/core/diff.js +178 -0
  37. package/dist/core/docs.js +302 -0
  38. package/dist/core/find.js +108 -0
  39. package/dist/core/git.js +1 -0
  40. package/dist/core/imports.js +550 -0
  41. package/dist/core/integrity.js +45 -0
  42. package/dist/core/lexical.js +132 -0
  43. package/dist/core/locate.js +169 -0
  44. package/dist/core/output.js +120 -0
  45. package/dist/core/pointer.js +29 -0
  46. package/dist/core/risk-callers.js +851 -0
  47. package/dist/core/runner-version.js +45 -0
  48. package/dist/core/sections.js +230 -0
  49. package/dist/core/state.js +44 -0
  50. package/dist/core/syntax.js +1 -0
  51. package/dist/core/test-commands.js +334 -0
  52. package/dist/core/test-coverage.js +74 -0
  53. package/dist/core/test-discovery.js +1382 -0
  54. package/dist/core/test-evidence.js +527 -0
  55. package/dist/core/test-state.js +81 -0
  56. package/dist/core/truncate.js +12 -0
  57. package/dist/core/units.js +349 -0
  58. package/dist/describe.js +23 -0
  59. package/dist/guide.js +33 -0
  60. package/dist/host.js +24 -0
  61. package/dist/jev/client.js +434 -0
  62. package/dist/jev/pool.js +54 -0
  63. package/dist/jev/types.js +1 -0
  64. package/dist/mcp/main.js +124 -0
  65. package/dist/mcp/protocol.js +187 -0
  66. package/dist/mcp/tools.js +116 -0
  67. package/dist/presets/docs.js +62 -0
  68. package/dist/presets/risk.js +179 -0
  69. package/dist/presets/spec.js +81 -0
  70. package/dist/presets/witnesses.js +249 -0
  71. package/dist/render.js +42 -0
  72. package/dist/result.js +3 -0
  73. package/dist/runtime.js +1 -0
  74. package/dist/session.js +147 -0
  75. package/dist/texts/ask-files.js +1 -0
  76. package/dist/texts/ask.js +2 -0
  77. package/dist/texts/check-diff.js +17 -0
  78. package/dist/texts/configuration.js +1 -0
  79. package/dist/texts/find.js +14 -0
  80. package/dist/texts/guide.js +16 -0
  81. package/dist/texts/locate.js +10 -0
  82. package/dist/texts/select-tests.js +2 -0
  83. package/dist/tools/ask-files.js +217 -0
  84. package/dist/tools/ask-schema.js +70 -0
  85. package/dist/tools/ask.js +686 -0
  86. package/dist/tools/check-diff.js +402 -0
  87. package/dist/tools/docs-check.js +299 -0
  88. package/dist/tools/find.js +389 -0
  89. package/dist/tools/locate.js +303 -0
  90. package/dist/tools/select-tests.js +567 -0
  91. package/dist/tools/spec-check.js +166 -0
  92. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  93. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  94. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  95. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  96. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  97. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  98. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  99. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  100. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  101. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  102. package/docs/agent-instructions.md +91 -0
  103. package/docs/design.md +3 -3
  104. package/docs/mcp.md +231 -0
  105. package/package.json +19 -4
  106. package/server.json +57 -0
  107. package/src/adapters/canonical-path.ts +18 -0
  108. package/src/adapters/command.ts +7 -4
  109. package/src/adapters/exec.ts +226 -0
  110. package/src/adapters/private-storage.ts +143 -0
  111. package/src/adapters/risk-callers.ts +4 -2
  112. package/src/adapters/shell.ts +97 -0
  113. package/src/configuration.ts +39 -12
  114. package/src/constants.ts +11 -0
  115. package/src/core/command-output.ts +17 -1
  116. package/src/host.ts +11 -0
  117. package/src/jev/client.ts +12 -0
  118. package/src/jev/types.ts +6 -0
  119. package/src/mcp/main.ts +135 -0
  120. package/src/mcp/protocol.ts +282 -0
  121. package/src/mcp/tools.ts +166 -0
  122. package/src/session.ts +59 -0
  123. package/src/setup.ts +13 -5
  124. package/src/tools/ask-files.ts +5 -7
  125. package/src/tools/ask.ts +26 -22
  126. package/src/tools/check-diff.ts +8 -5
  127. package/src/tools/docs-check.ts +1 -0
  128. package/src/tools/find.ts +5 -2
  129. package/src/tools/locate.ts +5 -8
  130. package/src/tools/select-tests.ts +7 -4
  131. package/src/tools/spec-check.ts +1 -0
@@ -0,0 +1,45 @@
1
+ export function runnerVersion(value) {
2
+ return typeof value === "string" &&
3
+ /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(value)
4
+ ? value
5
+ : undefined;
6
+ }
7
+ /** Parse only resolved lock versions, never dependency ranges or executable configuration. */
8
+ export function lockedRunnerVersion(runner, path, text) {
9
+ if (path.endsWith("package-lock.json")) {
10
+ try {
11
+ const lock = JSON.parse(text);
12
+ return runnerVersion(lock.packages?.[`node_modules/${runner}`]?.version ??
13
+ lock.dependencies?.[runner]?.version);
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ if (path.endsWith("bun.lock")) {
20
+ const match = new RegExp(`"${runner}"\\s*:\\s*\\[\\s*"${runner}@(\\d+\\.\\d+\\.\\d+(?:-[\\w.-]+)?)"`).exec(text);
21
+ return runnerVersion(match?.[1]);
22
+ }
23
+ if (path.endsWith("pnpm-lock.yaml")) {
24
+ const rootImporter = /(?:^|\n) {2}\.:\s*\n([\s\S]*?)(?=\n {2}[^ ]|\n[^ ]|$)/.exec(text)?.[1] ??
25
+ "";
26
+ const block = new RegExp(`(?:^|\\n)\\s{4,}${runner}:\\s*\\n\\s+specifier:[^\\n]*\\n\\s+version:\\s*['"]?([^\\s'"(]+)`).exec(rootImporter);
27
+ return runnerVersion(block?.[1]);
28
+ }
29
+ if (path.endsWith("yarn.lock")) {
30
+ const expression = new RegExp(`(?:^|\\n)["']?${runner}@[^\\n]*:\\s*\\n(?:[^\\n]*\\n)*?\\s+version[ :]+["']?([^\\s"']+)`, "g");
31
+ const versions = new Set([...text.matchAll(expression)].map((match) => runnerVersion(match[1])));
32
+ return versions.size === 1 ? [...versions][0] : undefined;
33
+ }
34
+ return undefined;
35
+ }
36
+ // Only identity-smoked release lines can receive native name filters.
37
+ export const VERIFIED_NAME_FILTERS = {
38
+ vitest: [
39
+ { version: /^3\.2\.\d+$/, separator: " " },
40
+ { version: /^4\.1\.\d+$/, separator: " " },
41
+ { version: /^5\.0\.\d+$/, separator: " > " },
42
+ ],
43
+ jest: [{ version: /^30\.\d+\.\d+$/, separator: " " }],
44
+ mocha: [{ version: /^11\.8\.0$/, separator: " " }],
45
+ };
@@ -0,0 +1,230 @@
1
+ import { LOCATE_SECTION_MAX_LINES, LOCATE_SECTION_MIN_LINES, LOCATE_WINDOW_LINES, } from "../constants.js";
2
+ /** Exact Markdown heading sections; callers may apply their own display grouping. */
3
+ export function markdownSections(text) {
4
+ const lines = text.split("\n");
5
+ if (lines.at(-1) === "")
6
+ lines.pop();
7
+ const headings = [];
8
+ let fence;
9
+ let frontMatter = lines[0]?.trim() === "---";
10
+ let html;
11
+ let previousText = false;
12
+ for (let index = 0; index < lines.length; index++) {
13
+ const line = lines[index] ?? "";
14
+ if (frontMatter) {
15
+ if (index > 0 && /^(?:---|\.\.\.)\s*$/.test(line))
16
+ frontMatter = false;
17
+ previousText = false;
18
+ continue;
19
+ }
20
+ if (html) {
21
+ if (html === "blank" ? !line.trim() : line.toLowerCase().includes(html))
22
+ html = undefined;
23
+ previousText = false;
24
+ continue;
25
+ }
26
+ const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
27
+ if (fence) {
28
+ if (marker &&
29
+ marker[1]?.[0] === fence.character &&
30
+ marker[1].length >= fence.length &&
31
+ !marker[2]?.trim())
32
+ fence = undefined;
33
+ previousText = false;
34
+ continue;
35
+ }
36
+ if (marker && !(marker[1]?.[0] === "`" && marker[2]?.includes("`"))) {
37
+ fence = {
38
+ character: marker[1]?.[0] ?? "`",
39
+ length: marker[1]?.length ?? 3,
40
+ };
41
+ previousText = false;
42
+ continue;
43
+ }
44
+ const block = /^ {0,3}<(script|pre|style|textarea)(?:\s|>|$)/i.exec(line);
45
+ const closing = line.trimStart().startsWith("<!--")
46
+ ? "-->"
47
+ : block
48
+ ? `</${block[1]?.toLowerCase()}>`
49
+ : /^ {0,3}<\/?(?:address|article|aside|blockquote|div|dl|fieldset|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|section|table|tbody|td|th|tr|ul)(?:\s|\/?>|$)/i.test(line)
50
+ ? "blank"
51
+ : undefined;
52
+ if (closing) {
53
+ if (closing === "blank" || !line.toLowerCase().includes(closing))
54
+ html = closing;
55
+ previousText = false;
56
+ continue;
57
+ }
58
+ const heading = /^ {0,3}#{1,6}(?:[\t ]+|$)(.*?)\s*$/.exec(line);
59
+ if (heading) {
60
+ headings.push({
61
+ start: index + 1,
62
+ level: line.trimStart().match(/^#+/)?.[0].length ?? 1,
63
+ label: (heading[1] ?? "").replace(/[\t ]+#+[\t ]*$/, "").trim(),
64
+ });
65
+ previousText = false;
66
+ }
67
+ else if (previousText && /^ {0,3}(?:=+|-+)\s*$/.test(line)) {
68
+ headings.push({
69
+ start: index,
70
+ level: line.trimStart().startsWith("=") ? 1 : 2,
71
+ label: lines[index - 1]?.trim() ?? "",
72
+ });
73
+ previousText = false;
74
+ }
75
+ else
76
+ previousText = Boolean(line.trim()) && !/^ {4}|^\t/.test(line);
77
+ }
78
+ return headings.map((heading, index) => {
79
+ const end = (headings[index + 1]?.start ?? lines.length + 1) - 1;
80
+ return {
81
+ id: `S${index + 1}`,
82
+ ...heading,
83
+ end,
84
+ text: lines.slice(heading.start - 1, end).join("\n"),
85
+ };
86
+ });
87
+ }
88
+ /** Produces disjoint, exhaustive ranges; labels never replace the underlying evidence. */
89
+ export function sectionFile(path, text, declarations) {
90
+ const lines = text.split("\n");
91
+ if (lines.at(-1) === "")
92
+ lines.pop();
93
+ if (!lines.length)
94
+ return { sections: [], lines: 0, method: "windows" };
95
+ const boundaries = [];
96
+ let method = "windows";
97
+ if (declarations?.length) {
98
+ const sorted = [...declarations].sort((a, b) => a.start - b.start || b.end - a.end);
99
+ const ancestors = [];
100
+ for (let index = 0; index < sorted.length; index++) {
101
+ const declaration = sorted[index];
102
+ if (!declaration)
103
+ continue;
104
+ while ((ancestors.at(-1)?.end ?? Infinity) < declaration.end)
105
+ ancestors.pop();
106
+ const enclosing = ancestors.at(-1);
107
+ const suppressed = enclosing &&
108
+ enclosing.end - enclosing.start + 1 <= LOCATE_SECTION_MAX_LINES;
109
+ if (!suppressed && boundaries.at(-1)?.start !== declaration.start)
110
+ boundaries.push({ start: declaration.start, label: declaration.name });
111
+ ancestors.push(declaration);
112
+ if (!suppressed && declaration.end < lines.length)
113
+ boundaries.push({
114
+ start: declaration.end + 1,
115
+ label: `${enclosing?.name ?? declaration.name} continuation`,
116
+ });
117
+ }
118
+ boundaries.sort((a, b) => a.start - b.start ||
119
+ Number(a.label.endsWith(" continuation")) -
120
+ Number(b.label.endsWith(" continuation")));
121
+ for (let index = boundaries.length - 1; index > 0; index--)
122
+ if (boundaries[index]?.start === boundaries[index - 1]?.start)
123
+ boundaries.splice(index, 1);
124
+ method = "syntax";
125
+ }
126
+ else if (/\.(?:md|mdx|markdown)$/i.test(path)) {
127
+ for (const section of markdownSections(text))
128
+ boundaries.push({ start: section.start, label: section.label });
129
+ if (boundaries.length)
130
+ method = "markdown";
131
+ }
132
+ if (!boundaries.length) {
133
+ let pythonClass;
134
+ lines.forEach((line, index) => {
135
+ const indentation = line.length - line.trimStart().length;
136
+ if (path.endsWith(".py") &&
137
+ line.trim() &&
138
+ !line.trimStart().startsWith("#")) {
139
+ if (pythonClass && indentation <= pythonClass.indent)
140
+ pythonClass = undefined;
141
+ const className = /^\s*class\s+(\w+)/.exec(line)?.[1];
142
+ if (className) {
143
+ pythonClass = {
144
+ name: className,
145
+ indent: indentation,
146
+ start: index + 1,
147
+ seenMethod: false,
148
+ };
149
+ boundaries.push({ start: index + 1, label: className });
150
+ return;
151
+ }
152
+ }
153
+ const declaration = /^\s*(?:(?:export|default|async|public|private|protected|static|abstract)\s+)*(?:(?:function\s*\*?|class|interface|enum|type|def|fn|func|struct|impl)\s+([\w.$]+)|(?:const|let|var)\s+([\w$]+)\s*=|([\w$]+)\s*\([^;]*\)\s*(?::[^=]+)?\s*\{)/.exec(line);
154
+ if (!declaration)
155
+ return;
156
+ const indent = line.length - line.trimStart().length;
157
+ if (indent > 4 ||
158
+ /^(?:if|for|while|switch|catch)$/.test(declaration[3] ?? ""))
159
+ return;
160
+ let start = index + 1;
161
+ if (path.endsWith(".py") &&
162
+ pythonClass &&
163
+ /^\s*(?:async\s+)?def\s/.test(line) &&
164
+ !pythonClass.seenMethod) {
165
+ boundaries.pop();
166
+ pythonClass.seenMethod = true;
167
+ start = pythonClass.start;
168
+ }
169
+ boundaries.push({
170
+ start,
171
+ label: pythonClass
172
+ ? `${pythonClass.name}.${declaration[1] ?? "method"}`
173
+ : (declaration[1] ?? declaration[2] ?? declaration[3] ?? "section"),
174
+ });
175
+ });
176
+ if (boundaries.length)
177
+ method = "heuristic";
178
+ }
179
+ if (!boundaries.length) {
180
+ for (let start = 1; start <= lines.length; start += LOCATE_WINDOW_LINES)
181
+ boundaries.push({
182
+ start,
183
+ label: `lines ${start}-${Math.min(lines.length, start + LOCATE_WINDOW_LINES - 1)}`,
184
+ });
185
+ }
186
+ else {
187
+ const first = boundaries[0];
188
+ if (first)
189
+ first.start = 1;
190
+ }
191
+ const ranges = boundaries.map((boundary, index) => ({
192
+ ...boundary,
193
+ end: (boundaries[index + 1]?.start ?? lines.length + 1) - 1,
194
+ }));
195
+ // Split oversized leaf nodes without losing a line or inventing syntax.
196
+ const chunks = ranges.flatMap((range) => {
197
+ const result = [];
198
+ const size = method === "windows" ? LOCATE_WINDOW_LINES : LOCATE_SECTION_MAX_LINES;
199
+ for (let start = range.start; start <= range.end; start += size)
200
+ result.push({
201
+ start,
202
+ end: Math.min(range.end, start + size - 1),
203
+ label: range.label,
204
+ });
205
+ return result;
206
+ });
207
+ const merged = [];
208
+ for (const chunk of chunks) {
209
+ const previous = merged.at(-1);
210
+ if (method !== "windows" &&
211
+ previous &&
212
+ (chunk.end - chunk.start + 1 < LOCATE_SECTION_MIN_LINES ||
213
+ previous.end - previous.start + 1 < LOCATE_SECTION_MIN_LINES) &&
214
+ chunk.end - previous.start + 1 <= LOCATE_SECTION_MAX_LINES) {
215
+ previous.end = chunk.end;
216
+ previous.label += ` / ${chunk.label}`;
217
+ }
218
+ else
219
+ merged.push({ ...chunk });
220
+ }
221
+ return {
222
+ lines: lines.length,
223
+ method,
224
+ sections: merged.map((range, index) => ({
225
+ id: `S${index + 1}`,
226
+ ...range,
227
+ text: lines.slice(range.start - 1, range.end).join("\n"),
228
+ })),
229
+ };
230
+ }
@@ -0,0 +1,44 @@
1
+ import { ASK_NOTE_MAX_CHARS, STATE_MAX_CHARS } from "../constants.js";
2
+ import { isRecord } from "../result.js";
3
+ const reservedKeys = ["files", "files_before", "base_sha", "closure"];
4
+ export function assembleState(note, files) {
5
+ if (note !== undefined && note.length > ASK_NOTE_MAX_CHARS)
6
+ return {
7
+ ok: false,
8
+ error: `state exceeds ${ASK_NOTE_MAX_CHARS} chars (${note.length}); shorten the note.`,
9
+ };
10
+ let state = {};
11
+ if (note !== undefined) {
12
+ let parsed;
13
+ try {
14
+ parsed = JSON.parse(note);
15
+ }
16
+ catch {
17
+ parsed = note;
18
+ }
19
+ const reserved = isRecord(parsed)
20
+ ? reservedKeys.find((key) => Object.hasOwn(parsed, key))
21
+ : undefined;
22
+ if (reserved)
23
+ return {
24
+ ok: false,
25
+ error: `state contains reserved key ${reserved}; rename it in your note and use paths for files read by code.`,
26
+ };
27
+ if (isRecord(parsed))
28
+ state = parsed;
29
+ else
30
+ state.state = parsed;
31
+ }
32
+ const noteSize = JSON.stringify(state).length;
33
+ if (Object.keys(files).length)
34
+ state.files = files;
35
+ const size = JSON.stringify(state).length;
36
+ if (size > STATE_MAX_CHARS)
37
+ return {
38
+ ok: false,
39
+ error: `serialized state exceeds ${STATE_MAX_CHARS} chars (${size}); serialized parts: note ${noteSize}, ${Object.entries(files)
40
+ .map(([path, text]) => `${path} ${JSON.stringify({ [path]: text }).length}`)
41
+ .join(", ")}. Split the situation into smaller states or provide smaller source files; files are never truncated.`,
42
+ };
43
+ return { ok: true, state };
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,334 @@
1
+ import { posix } from "node:path";
2
+ import { SELECT_FILE_SHARE } from "../constants.js";
3
+ import { VERIFIED_NAME_FILTERS } from "./runner-version.js";
4
+ const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ const exactPattern = (names) => [...new Set(names)].map((name) => `^${escapeRegex(name)}$`).join("|");
6
+ const localPath = (entry, path = entry.path) => posix.relative(entry.cwd, path) || ".";
7
+ function resolveSelection(selection) {
8
+ const { entry, scenarioIds } = selection;
9
+ if (scenarioIds === null)
10
+ return { entry, scenarios: null };
11
+ if (!entry.countKnown)
12
+ return { entry, scenarios: null, reason: "unknown scenario count" };
13
+ const ids = new Set(scenarioIds);
14
+ const scenarios = entry.scenarios.filter((scenario) => ids.has(scenario.id));
15
+ if (scenarios.length !== ids.size)
16
+ return {
17
+ entry,
18
+ scenarios: null,
19
+ reason: "unresolved scenario identifier",
20
+ };
21
+ if (scenarios.length >= entry.scenarios.length * SELECT_FILE_SHARE)
22
+ return { entry, scenarios: null };
23
+ if (entry.scenarios.some((scenario) => !scenario.reliable))
24
+ return { entry, scenarios: null, reason: "dynamic names or parameters" };
25
+ const names = new Set();
26
+ for (const scenario of entry.scenarios) {
27
+ if (names.has(scenario.name))
28
+ return { entry, scenarios: null, reason: "ambiguous scenario names" };
29
+ names.add(scenario.name);
30
+ }
31
+ return { entry, scenarios };
32
+ }
33
+ /** Builds argument vectors only; neither collects nor executes any runner. */
34
+ export function buildRunnerCommands(selected) {
35
+ const commands = [];
36
+ const limits = [];
37
+ const groups = new Map();
38
+ for (const selection of selected) {
39
+ if (selection.scenarioIds !== null && selection.scenarioIds.length === 0)
40
+ continue;
41
+ const { entry } = selection;
42
+ const key = JSON.stringify([
43
+ entry.cwd,
44
+ entry.framework,
45
+ entry.runnerVersion,
46
+ entry.config,
47
+ entry.project,
48
+ entry.options,
49
+ entry.kind,
50
+ entry.invocation,
51
+ entry.commandRefusal,
52
+ entry.nameFilterBlocked,
53
+ entry.nameFilterReason,
54
+ ]);
55
+ const group = groups.get(key);
56
+ if (group)
57
+ group.push(selection);
58
+ else
59
+ groups.set(key, [selection]);
60
+ }
61
+ for (const group of groups.values()) {
62
+ const entry = group[0].entry;
63
+ const files = [...new Set(group.map((item) => item.entry.path))];
64
+ const limit = (reason, action) => {
65
+ limits.push({ files, reason, action });
66
+ };
67
+ if (entry.kind === "types") {
68
+ if (entry.invocation?.kind === "typecheck") {
69
+ commands.push({
70
+ cwd: entry.cwd,
71
+ framework: entry.framework,
72
+ ...(entry.config ? { config: entry.config } : {}),
73
+ ...(entry.project ? { project: entry.project } : {}),
74
+ executable: entry.invocation.executable,
75
+ args: [...entry.invocation.args],
76
+ files,
77
+ });
78
+ limit("typecheck project scope: selected files do not restrict the command", "run the identified project type-check command without file or name filters");
79
+ }
80
+ else {
81
+ limit("type tests: command not identified", "run the project's type-check command");
82
+ }
83
+ continue;
84
+ }
85
+ if (entry.framework === "unknown") {
86
+ limit("framework unknown", "run the listed files with the project runner");
87
+ continue;
88
+ }
89
+ if (entry.commandRefusal) {
90
+ limit(entry.commandRefusal, "run the listed files with the project runner");
91
+ continue;
92
+ }
93
+ if (["ava", "mocha"].includes(entry.framework) &&
94
+ files.some((file) => /[*?[\]{}()!]/.test(localPath(entry, file)))) {
95
+ limit("targeted path has unverified glob metacharacters", "run the listed files with the project runner");
96
+ continue;
97
+ }
98
+ if (entry.invocation?.kind !== "runner")
99
+ limit("local_runner_unproven: native executable availability is not established", "verify the local executable or use the project's runner script");
100
+ const resolved = group.map(resolveSelection);
101
+ for (const item of resolved)
102
+ if (item.entry.nameFilterReason)
103
+ limits.push({
104
+ files: [item.entry.path],
105
+ reason: `fallback: all — ${item.entry.nameFilterReason}`,
106
+ action: "run the whole file with the project runner",
107
+ });
108
+ for (const item of resolved) {
109
+ if (item.reason)
110
+ limits.push({
111
+ files: [item.entry.path],
112
+ reason: `fallback: all — ${item.reason}`,
113
+ action: "run the whole file with the project runner",
114
+ });
115
+ }
116
+ let wholeGroup = false;
117
+ const nominal = ["vitest", "jest", "node", "bun", "go", "mocha"].includes(entry.framework);
118
+ if (nominal &&
119
+ resolved.some((item) => item.scenarios === null) &&
120
+ resolved.some((item) => item.scenarios !== null)) {
121
+ wholeGroup = true;
122
+ limit("fallback: all — whole files and name filters in the same group", "run all listed files without a name filter");
123
+ }
124
+ const paths = files.map((file) => localPath(entry, file));
125
+ const scenarios = resolved.flatMap((item) => item.scenarios ?? []);
126
+ let pattern = !wholeGroup && resolved.every((item) => item.scenarios !== null)
127
+ ? exactPattern(scenarios.map((scenario) => scenario.name))
128
+ : undefined;
129
+ if (pattern &&
130
+ (entry.framework === "vitest" ||
131
+ entry.framework === "jest" ||
132
+ entry.framework === "mocha")) {
133
+ const contract = VERIFIED_NAME_FILTERS[entry.framework].find((candidate) => candidate.version.test(entry.runnerVersion ?? ""));
134
+ if (!contract ||
135
+ (contract.separator !== " " &&
136
+ scenarios.some((scenario) => !scenario.nameParts))) {
137
+ pattern = undefined;
138
+ limit(`name filter skipped: ${entry.framework} ${entry.runnerVersion ?? "unknown"} not verified`, "run the whole file with the project runner");
139
+ }
140
+ else {
141
+ pattern = exactPattern(scenarios.map((scenario) => scenario.nameParts?.join(contract.separator) ?? scenario.name));
142
+ }
143
+ }
144
+ if (entry.nameFilterBlocked)
145
+ pattern = undefined;
146
+ let executable;
147
+ let args;
148
+ const config = entry.config ? localPath(entry, entry.config) : undefined;
149
+ switch (entry.framework) {
150
+ case "ava":
151
+ executable = "ava";
152
+ args = [...entry.options];
153
+ if (config && !config.endsWith("package.json"))
154
+ args.push("--config", config);
155
+ args.push(...paths);
156
+ break;
157
+ case "mocha":
158
+ executable = "mocha";
159
+ args = [...entry.options];
160
+ if (config && !config.endsWith("package.json"))
161
+ args.push("--config", config);
162
+ if (pattern)
163
+ args.push("--grep", pattern);
164
+ args.push(...paths);
165
+ break;
166
+ case "deno":
167
+ executable = "deno";
168
+ args = ["test", ...entry.options];
169
+ if (config)
170
+ args.push("--config", config);
171
+ args.push(...paths);
172
+ break;
173
+ case "vitest":
174
+ executable = "vitest";
175
+ args = ["run", ...entry.options];
176
+ if (config)
177
+ args.push("--config", config);
178
+ if (entry.project)
179
+ args.push("--project", entry.project);
180
+ if (pattern)
181
+ args.push("-t", pattern);
182
+ args.push(...paths);
183
+ break;
184
+ case "jest":
185
+ executable = "jest";
186
+ args = [...entry.options];
187
+ if (config)
188
+ args.push("--config", config);
189
+ if (entry.project)
190
+ args.push("--selectProjects", entry.project);
191
+ if (pattern)
192
+ args.push("-t", pattern);
193
+ args.push("--runTestsByPath", ...paths);
194
+ break;
195
+ case "node":
196
+ executable = "node";
197
+ if (pattern) {
198
+ if (scenarios.some((scenario) => scenario.ancestors === undefined)) {
199
+ pattern = undefined;
200
+ limit("fallback: all — unresolved node:test hierarchy", "run all listed files without a name filter");
201
+ }
202
+ else {
203
+ pattern = exactPattern(scenarios.flatMap((scenario) => [
204
+ ...(scenario.ancestors ?? []),
205
+ scenario.name,
206
+ ]));
207
+ if (scenarios.some((scenario) => (scenario.ancestors?.length ?? 0) > 0))
208
+ limit("node:test: admitting an ancestor may also run its other subtests", "verify the expanded subtree with the project runner");
209
+ }
210
+ }
211
+ args = [...entry.options, "--test"];
212
+ if (pattern)
213
+ args.push("--test-name-pattern", pattern);
214
+ args.push(...paths);
215
+ break;
216
+ case "bun":
217
+ executable = "bun";
218
+ args = config ? ["--config", config, "test"] : ["test"];
219
+ args.push(...entry.options);
220
+ if (pattern)
221
+ args.push("--test-name-pattern", pattern);
222
+ args.push(...paths.map((path) => (posix.isAbsolute(path) ? path : `./${path}`)));
223
+ break;
224
+ case "playwright":
225
+ executable = "playwright";
226
+ args = ["test", ...entry.options];
227
+ if (config)
228
+ args.push("--config", config);
229
+ if (entry.project)
230
+ args.push("--project", entry.project);
231
+ for (const item of resolved) {
232
+ const path = escapeRegex(localPath(item.entry));
233
+ if (item.scenarios === null ||
234
+ item.scenarios.some((scenario) => scenario.line < 1)) {
235
+ args.push(path);
236
+ if (item.scenarios !== null)
237
+ limit("fallback: all — unknown declaration line", "run the whole file with the project runner");
238
+ }
239
+ else {
240
+ args.push(...new Set(item.scenarios.map((scenario) => `${path}:${scenario.line}`)));
241
+ }
242
+ }
243
+ break;
244
+ case "go": {
245
+ executable = "go";
246
+ const packages = [
247
+ ...new Set(paths.map((path) => {
248
+ const directory = posix.dirname(path);
249
+ return directory === "."
250
+ ? "."
251
+ : directory.startsWith(".")
252
+ ? directory
253
+ : `./${directory}`;
254
+ })),
255
+ ];
256
+ if (pattern) {
257
+ const names = scenarios.map((scenario) => {
258
+ const separator = scenario.name.indexOf("/");
259
+ return separator < 0
260
+ ? scenario.name
261
+ : scenario.name.slice(0, separator);
262
+ });
263
+ if (names.some((name) => !/^(Test|Example)[A-Za-z0-9_]*$/.test(name))) {
264
+ pattern = undefined;
265
+ limit("fallback: all — benchmarks or fuzz targets cannot be expressed safely", "run the affected packages, benchmarks and fuzz targets with the project runner");
266
+ }
267
+ else {
268
+ pattern = exactPattern(names);
269
+ if (scenarios.some((scenario) => scenario.name.includes("/")))
270
+ limit("fallback: all — Go subtest: whole parent retained", "run the selected top-level parents with the project runner");
271
+ }
272
+ }
273
+ args = ["test", ...entry.options, ...packages];
274
+ if (pattern)
275
+ args.push("-run", pattern);
276
+ limit("Go runs packages, not files; examples and build constraints depend on the project", "verify build constraints and executable examples with the project runner");
277
+ break;
278
+ }
279
+ case "pytest":
280
+ executable = "python";
281
+ args = ["-m", "pytest", ...entry.options];
282
+ if (config)
283
+ args.push("-c", config);
284
+ for (const item of resolved) {
285
+ const path = localPath(item.entry);
286
+ if (item.scenarios === null)
287
+ args.push(path);
288
+ else
289
+ for (const scenario of item.scenarios) {
290
+ const separator = scenario.name.indexOf("::");
291
+ const nodePath = scenario.name.slice(0, separator);
292
+ const isFullId = separator >= 0 &&
293
+ (nodePath === item.entry.path || nodePath === path);
294
+ args.push(isFullId
295
+ ? path + scenario.name.slice(separator)
296
+ : `${path}::${scenario.name}`);
297
+ }
298
+ }
299
+ break;
300
+ default:
301
+ continue;
302
+ }
303
+ if (entry.invocation?.kind === "runner") {
304
+ // Identified options are already part of the invocation prefix.
305
+ const bunConfig = entry.framework === "bun" && config ? 2 : 0;
306
+ const prefixLength = entry.options.length +
307
+ bunConfig +
308
+ (entry.framework === "pytest"
309
+ ? 2
310
+ : ["jest", "ava", "mocha"].includes(entry.framework)
311
+ ? 0
312
+ : 1);
313
+ executable = entry.invocation.executable;
314
+ args = [
315
+ ...(bunConfig ? ["--config", config] : []),
316
+ ...(entry.framework === "vitest" && entry.invocation.args[0] !== "run"
317
+ ? ["run"]
318
+ : []),
319
+ ...entry.invocation.args,
320
+ ...args.slice(prefixLength),
321
+ ];
322
+ }
323
+ commands.push({
324
+ cwd: entry.cwd,
325
+ framework: entry.framework,
326
+ ...(entry.config ? { config: entry.config } : {}),
327
+ ...(entry.project ? { project: entry.project } : {}),
328
+ executable,
329
+ args,
330
+ files,
331
+ });
332
+ }
333
+ return { commands, limits };
334
+ }