@vitest-agent/sdk 1.0.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 (121) hide show
  1. package/DataReader-2kJ9I9Hh.d.ts +1809 -0
  2. package/LICENSE +21 -0
  3. package/README.md +52 -0
  4. package/dispatch.d.ts +48 -0
  5. package/dispatch.js +5 -0
  6. package/errors/AgentErrors.js +65 -0
  7. package/errors/DataStoreError.js +44 -0
  8. package/errors/DiscoveryError.js +21 -0
  9. package/errors/PathResolutionError.js +27 -0
  10. package/errors/ProjectIdentityError.js +29 -0
  11. package/errors/RunContextError.js +35 -0
  12. package/errors/TddErrors.js +66 -0
  13. package/exit-code-for-tag.js +18 -0
  14. package/formatters/ci-annotations.js +61 -0
  15. package/formatters/gfm.js +20 -0
  16. package/formatters/json.js +18 -0
  17. package/formatters/markdown.js +39 -0
  18. package/formatters/silent.js +9 -0
  19. package/formatters/terminal.js +56 -0
  20. package/index.d.ts +3334 -0
  21. package/index.js +120 -0
  22. package/internal-inject-env.js +42 -0
  23. package/layers/ConfigLive.js +47 -0
  24. package/layers/DataReaderLive.js +1666 -0
  25. package/layers/DataStoreLive.js +1185 -0
  26. package/layers/DetailResolverLive.js +9 -0
  27. package/layers/DiscoveryRegistryLive.js +70 -0
  28. package/layers/EnvironmentDetectorLive.js +21 -0
  29. package/layers/EnvironmentDetectorTest.js +13 -0
  30. package/layers/ExecutorResolverLive.js +9 -0
  31. package/layers/FormatSelectorLive.js +20 -0
  32. package/layers/HistoryTrackerLive.js +47 -0
  33. package/layers/HistoryTrackerTest.js +25 -0
  34. package/layers/LoggerLive.js +64 -0
  35. package/layers/OutputPipelineLive.js +13 -0
  36. package/layers/OutputRendererLive.js +28 -0
  37. package/layers/PathResolutionLive.js +36 -0
  38. package/layers/PerClientSessionMapLive.js +145 -0
  39. package/layers/ProjectDiscoveryLive.js +70 -0
  40. package/layers/ProjectDiscoveryTest.js +12 -0
  41. package/layers/ProjectIdentityLive.js +92 -0
  42. package/layers/RunContextLive.js +81 -0
  43. package/lib/format-triage.js +103 -0
  44. package/lib/format-wrapup.js +67 -0
  45. package/migrations/0001_initial.js +849 -0
  46. package/migrations/registry_0001_initial.js +39 -0
  47. package/migrations/session_map_0001_initial.js +61 -0
  48. package/package.json +64 -0
  49. package/schemas/Agent.js +42 -0
  50. package/schemas/AgentReport.js +71 -0
  51. package/schemas/Baselines.js +16 -0
  52. package/schemas/CacheManifest.js +27 -0
  53. package/schemas/ChannelEvent.js +138 -0
  54. package/schemas/Common.js +105 -0
  55. package/schemas/Config.js +28 -0
  56. package/schemas/Coverage.js +51 -0
  57. package/schemas/CoverageLevel.js +117 -0
  58. package/schemas/CoverageTargets.js +39 -0
  59. package/schemas/History.js +31 -0
  60. package/schemas/Identity.js +64 -0
  61. package/schemas/Options.js +57 -0
  62. package/schemas/RenderState.js +161 -0
  63. package/schemas/RunEvent.js +163 -0
  64. package/schemas/Tdd.js +44 -0
  65. package/schemas/Thresholds.js +30 -0
  66. package/schemas/Transport.js +16 -0
  67. package/schemas/Trends.js +23 -0
  68. package/schemas/turns/FileEditPayload.js +15 -0
  69. package/schemas/turns/HookFirePayload.js +13 -0
  70. package/schemas/turns/HypothesisPayload.js +13 -0
  71. package/schemas/turns/NotePayload.js +13 -0
  72. package/schemas/turns/ToolCallPayload.js +13 -0
  73. package/schemas/turns/ToolResultPayload.js +15 -0
  74. package/schemas/turns/UserPromptPayload.js +12 -0
  75. package/schemas/turns/index.js +15 -0
  76. package/services/Config.js +15 -0
  77. package/services/DataReader.js +8 -0
  78. package/services/DataStore.js +8 -0
  79. package/services/DetailResolver.js +8 -0
  80. package/services/DiscoveryRegistry.js +8 -0
  81. package/services/EnvironmentDetector.js +8 -0
  82. package/services/ExecutorResolver.js +8 -0
  83. package/services/FormatSelector.js +8 -0
  84. package/services/HistoryTracker.js +8 -0
  85. package/services/OutputRenderer.js +8 -0
  86. package/services/PerClientSessionMap.js +22 -0
  87. package/services/ProjectDiscovery.js +8 -0
  88. package/services/ProjectIdentity.js +63 -0
  89. package/services/RunContext.js +53 -0
  90. package/services/idempotency.js +57 -0
  91. package/sidecar-dispatch.js +84 -0
  92. package/sql/assemblers.js +59 -0
  93. package/testing/layers.js +21 -0
  94. package/testing.d.ts +25 -0
  95. package/testing.js +243 -0
  96. package/tsdoc-metadata.json +11 -0
  97. package/utils/ansi.js +49 -0
  98. package/utils/build-report.js +130 -0
  99. package/utils/canonicalize-git-url.js +66 -0
  100. package/utils/classify-test.js +22 -0
  101. package/utils/compress-lines.js +45 -0
  102. package/utils/compute-trend.js +88 -0
  103. package/utils/detect-pm.js +58 -0
  104. package/utils/detect-timeout.js +17 -0
  105. package/utils/ensure-migrated.js +52 -0
  106. package/utils/failure-signature.js +33 -0
  107. package/utils/format-console.js +219 -0
  108. package/utils/format-fatal-error.js +35 -0
  109. package/utils/format-gfm.js +161 -0
  110. package/utils/format-terminal.js +480 -0
  111. package/utils/function-boundary.js +75 -0
  112. package/utils/hyperlink.js +11 -0
  113. package/utils/match-vitest-command.js +88 -0
  114. package/utils/normalize-workspace-key.js +34 -0
  115. package/utils/probe-host-metadata.js +99 -0
  116. package/utils/resolve-data-path.js +66 -0
  117. package/utils/resolve-project-key-from-cwd.js +52 -0
  118. package/utils/resolve-workspace-key.js +33 -0
  119. package/utils/safe-filename.js +34 -0
  120. package/utils/validate-coverage-targets-shape.js +84 -0
  121. package/utils/validate-phase-transition.js +113 -0
@@ -0,0 +1,480 @@
1
+ import { ansi } from "./ansi.js";
2
+ import { relativePath } from "./format-console.js";
3
+ import { compressLines } from "./compress-lines.js";
4
+
5
+ //#region src/utils/format-terminal.ts
6
+ /**
7
+ * Compress a comma-separated line list into ranges where consecutive
8
+ * numbers run together. Defensive: callers may pass already-compressed
9
+ * strings (e.g. "29-32, 34-38") or uncompressed (e.g. "29,30,31,32") —
10
+ * both are handled. If parsing fails, the original string is returned
11
+ * unchanged.
12
+ *
13
+ * @internal
14
+ */
15
+ const compressUncov = (uncoveredLines) => {
16
+ if (uncoveredLines.length === 0) return "";
17
+ if (uncoveredLines.includes("-")) return uncoveredLines;
18
+ const numbers = [];
19
+ for (const piece of uncoveredLines.split(",")) {
20
+ const n = Number.parseInt(piece.trim(), 10);
21
+ if (Number.isNaN(n)) return uncoveredLines;
22
+ numbers.push(n);
23
+ }
24
+ return compressLines(numbers);
25
+ };
26
+ const METRICS = [
27
+ {
28
+ key: "lines",
29
+ label: "lines"
30
+ },
31
+ {
32
+ key: "branches",
33
+ label: "branches"
34
+ },
35
+ {
36
+ key: "functions",
37
+ label: "funcs"
38
+ },
39
+ {
40
+ key: "statements",
41
+ label: "stmts"
42
+ }
43
+ ];
44
+ /**
45
+ * Format a duration in milliseconds as a short human string.
46
+ *
47
+ * Sub-second durations stay in ms (`"328ms"`); 1s and longer switch to
48
+ * seconds with one decimal (`"5.6s"`). Avoids the noise of "5577ms" in
49
+ * summary rows where second-level precision is plenty.
50
+ *
51
+ * @internal
52
+ */
53
+ const formatDuration = (ms) => {
54
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
55
+ return `${(ms / 1e3).toFixed(1)}s`;
56
+ };
57
+ /**
58
+ * Decide whether the project label should appear in the row.
59
+ *
60
+ * Single-project setups with no explicit `project.name` (Vitest's
61
+ * default) have `project` set to `"default"`. In that case there's
62
+ * nothing to disambiguate; suppress the label.
63
+ *
64
+ * @internal
65
+ */
66
+ const showProjectLabel = (reports) => {
67
+ if (reports.length > 1) return true;
68
+ const single = reports[0];
69
+ if (!single?.project) return false;
70
+ if (single.project === "default") return false;
71
+ return true;
72
+ };
73
+ /**
74
+ * Right-pad a column value to a fixed width with spaces. Plain ASCII
75
+ * width — does not account for ANSI escapes (caller is responsible).
76
+ *
77
+ * @internal
78
+ */
79
+ const pad = (s, width) => s.length >= width ? s : s + " ".repeat(width - s.length);
80
+ /**
81
+ * Build an inline tag-count summary suffix for a project row.
82
+ * Only shown when two or more distinct tag kinds are present.
83
+ *
84
+ * @internal
85
+ */
86
+ const buildTagSummary = (tagCounts) => {
87
+ if (!tagCounts) return "";
88
+ const keys = Object.keys(tagCounts).sort();
89
+ if (keys.length < 2) return "";
90
+ return " " + keys.map((k) => {
91
+ const c = tagCounts[k];
92
+ return `${k}:${(c?.passed ?? 0) + (c?.failed ?? 0) + (c?.skipped ?? 0)}`;
93
+ }).join(" ");
94
+ };
95
+ /**
96
+ * Render indented per-tag pass/fail breakdown rows for a project that has failures.
97
+ * The `s` (skipped) column only appears when skipped \> 0 for that tag.
98
+ *
99
+ * @internal
100
+ */
101
+ const renderTagFailureBreakdown = (tagCounts) => {
102
+ const lines = [];
103
+ const keys = Object.keys(tagCounts).sort();
104
+ if (keys.length === 0) return lines;
105
+ const tagWidth = Math.max(...keys.map((k) => k.length));
106
+ for (const k of keys) {
107
+ const c = tagCounts[k];
108
+ const p = c?.passed ?? 0;
109
+ const f = c?.failed ?? 0;
110
+ const s = c?.skipped ?? 0;
111
+ const skipPart = s > 0 ? ` ${s}s` : "";
112
+ lines.push(` ${k.padEnd(tagWidth)} ${p}p ${f}f${skipPart}`);
113
+ }
114
+ return lines;
115
+ };
116
+ /**
117
+ * Project row: `✓ name 103 passed (2.1s)` or
118
+ * `✗ name 102 passed, 1 failed (2.1s)`.
119
+ *
120
+ * @internal
121
+ */
122
+ const renderProjectRow = (report, ao, nameWidth) => {
123
+ const failed = report.summary.failed;
124
+ const passed = report.summary.passed;
125
+ const skipped = report.summary.skipped;
126
+ const duration = formatDuration(report.summary.duration);
127
+ const name = report.project ?? "default";
128
+ const tick = failed === 0 ? ansi("✓", "green", ao) : ansi("✗", "red", ao);
129
+ const counts = [];
130
+ if (failed > 0) counts.push(`${failed} failed`);
131
+ counts.push(`${passed} passed`);
132
+ if (skipped > 0) counts.push(`${skipped} skipped`);
133
+ return `${` ${tick} ${pad(name, nameWidth)} ${counts.join(", ")} (${duration})`}${buildTagSummary(report.tagCounts)}`;
134
+ };
135
+ /**
136
+ * Format the global target spec compactly.
137
+ *
138
+ * If every metric has the same target (the common case), collapse to
139
+ * `≥ 80% lines, branches, funcs, stmts`. If targets differ, list per
140
+ * metric: `lines ≥ 80%, branches ≥ 75%, funcs ≥ 70%, stmts ≥ 80%`.
141
+ *
142
+ * @internal
143
+ */
144
+ const formatTargetSpec = (targets) => {
145
+ const set = /* @__PURE__ */ new Set();
146
+ const present = [];
147
+ for (const m of METRICS) {
148
+ const v = targets[m.key];
149
+ if (v == null) continue;
150
+ set.add(v);
151
+ present.push({
152
+ label: m.label,
153
+ v
154
+ });
155
+ }
156
+ if (present.length === 0) return "";
157
+ if (set.size === 1) {
158
+ const v = [...set][0];
159
+ return `${present.map((p) => p.label).join(", ")} ≥ ${v}%`;
160
+ }
161
+ return present.map((p) => `${p.label} ≥ ${p.v}%`).join(", ");
162
+ };
163
+ /**
164
+ * Header labels for the v8-style coverage table. The order is fixed
165
+ * and matches the column order rendered by {@link renderBelowTargetTable}.
166
+ *
167
+ * @internal
168
+ */
169
+ const TABLE_HEADERS = {
170
+ file: "File",
171
+ stmts: "% Stmts",
172
+ branches: "% Branch",
173
+ funcs: "% Funcs",
174
+ lines: "% Lines",
175
+ uncov: "Uncovered Line #s"
176
+ };
177
+ /**
178
+ * Pad a cell with one space on each side of its content. Numeric
179
+ * cells are right-aligned, string cells are left-aligned. The width
180
+ * argument is the inner content width (without the padding spaces).
181
+ *
182
+ * @internal
183
+ */
184
+ const cell = (s, width, align) => {
185
+ return ` ${align === "right" ? s.padStart(width) : s.padEnd(width)} `;
186
+ };
187
+ /**
188
+ * Render a separator row matching the column geometry of the header.
189
+ * Width per column = inner width + 2 (the padding spaces) so the row
190
+ * lines up with the headers and data rows when joined by `|`.
191
+ *
192
+ * @internal
193
+ */
194
+ const sepRow = (widths) => widths.map((w) => "-".repeat(w + 2)).join("|");
195
+ /**
196
+ * v8-style coverage table for files below aspirational targets.
197
+ *
198
+ * Columns: File, % Stmts, % Branch, % Funcs, % Lines, Uncovered
199
+ * Line #s. Numeric columns are right-aligned and show the file's
200
+ * actual coverage value (no `%` suffix — the header carries it).
201
+ * Showing every metric per row, not just the below-target ones,
202
+ * matches v8's reporter and gives an LLM agent a complete picture
203
+ * of where the file stands relative to all four metrics.
204
+ *
205
+ * Returns header rows + data rows + a trailing separator. The caller
206
+ * is responsible for the surrounding caption and any truncation
207
+ * footer.
208
+ *
209
+ * @internal
210
+ */
211
+ const renderBelowTargetTable = (files) => {
212
+ if (files.length === 0) return [];
213
+ const rows = files.map((f) => ({
214
+ file: relativePath(f.file),
215
+ stmts: String(Math.round(f.summary.statements)),
216
+ branches: String(Math.round(f.summary.branches)),
217
+ funcs: String(Math.round(f.summary.functions)),
218
+ lines: String(Math.round(f.summary.lines)),
219
+ uncov: compressUncov(f.uncoveredLines)
220
+ }));
221
+ const widths = [
222
+ Math.max(TABLE_HEADERS.file.length, ...rows.map((r) => r.file.length)),
223
+ Math.max(TABLE_HEADERS.stmts.length, ...rows.map((r) => r.stmts.length)),
224
+ Math.max(TABLE_HEADERS.branches.length, ...rows.map((r) => r.branches.length)),
225
+ Math.max(TABLE_HEADERS.funcs.length, ...rows.map((r) => r.funcs.length)),
226
+ Math.max(TABLE_HEADERS.lines.length, ...rows.map((r) => r.lines.length)),
227
+ Math.max(TABLE_HEADERS.uncov.length, ...rows.map((r) => r.uncov.length))
228
+ ];
229
+ const out = [];
230
+ out.push(sepRow(widths));
231
+ out.push([
232
+ cell(TABLE_HEADERS.file, widths[0], "left"),
233
+ cell(TABLE_HEADERS.stmts, widths[1], "right"),
234
+ cell(TABLE_HEADERS.branches, widths[2], "right"),
235
+ cell(TABLE_HEADERS.funcs, widths[3], "right"),
236
+ cell(TABLE_HEADERS.lines, widths[4], "right"),
237
+ cell(TABLE_HEADERS.uncov, widths[5], "left")
238
+ ].join("|"));
239
+ out.push(sepRow(widths));
240
+ for (const r of rows) out.push([
241
+ cell(r.file, widths[0], "left"),
242
+ cell(r.stmts, widths[1], "right"),
243
+ cell(r.branches, widths[2], "right"),
244
+ cell(r.funcs, widths[3], "right"),
245
+ cell(r.lines, widths[4], "right"),
246
+ cell(r.uncov, widths[5], "left")
247
+ ].join("|"));
248
+ out.push(sepRow(widths));
249
+ return out;
250
+ };
251
+ /**
252
+ * Aggregated coverage state across reports.
253
+ *
254
+ * @internal
255
+ */
256
+ const aggregateCoverage = (reports) => {
257
+ let hasCoverage = false;
258
+ let thresholdsGlobal;
259
+ let targetsGlobal;
260
+ const belowTargetByFile = /* @__PURE__ */ new Map();
261
+ const belowThresholdByFile = /* @__PURE__ */ new Map();
262
+ for (const r of reports) {
263
+ const cov = r.coverage;
264
+ if (!cov) continue;
265
+ hasCoverage = true;
266
+ thresholdsGlobal ??= cov.thresholds.global;
267
+ targetsGlobal ??= cov.targets?.global;
268
+ if (cov.lowCoverage) {
269
+ for (const f of cov.lowCoverage) if (!belowThresholdByFile.has(f.file)) belowThresholdByFile.set(f.file, f);
270
+ }
271
+ if (cov.belowTarget) {
272
+ for (const f of cov.belowTarget) if (!belowTargetByFile.has(f.file)) belowTargetByFile.set(f.file, f);
273
+ }
274
+ }
275
+ const belowTargetFiles = [...belowTargetByFile.values()];
276
+ const belowThresholdCount = belowThresholdByFile.size;
277
+ return {
278
+ hasCoverage,
279
+ thresholdsMet: belowThresholdCount === 0,
280
+ belowThresholdCount,
281
+ belowTargetFiles,
282
+ ...thresholdsGlobal !== void 0 ? { thresholdsGlobal } : {},
283
+ ...targetsGlobal !== void 0 ? { targetsGlobal } : {}
284
+ };
285
+ };
286
+ /**
287
+ * Coverage section: one or two summary lines plus per-file rows.
288
+ *
289
+ * @internal
290
+ */
291
+ const renderCoverageSection = (reports, options, ao) => {
292
+ const agg = aggregateCoverage(reports);
293
+ if (!agg.hasCoverage) return [];
294
+ const lines = [];
295
+ const thresholdSpec = agg.thresholdsGlobal ? formatTargetSpec(agg.thresholdsGlobal) : "";
296
+ const targetSpec = agg.targetsGlobal ? formatTargetSpec(agg.targetsGlobal) : "";
297
+ if (!agg.thresholdsMet) {
298
+ const cross = ansi("✗", "red", ao);
299
+ const fileWord = agg.belowThresholdCount === 1 ? "file" : "files";
300
+ lines.push(`Coverage: ${cross} ${agg.belowThresholdCount} ${fileWord} below minimum thresholds${thresholdSpec ? ` (${thresholdSpec})` : ""}`);
301
+ } else if (agg.belowTargetFiles.length > 0) {
302
+ const tick = ansi("✓", "green", ao);
303
+ const warn = ansi("⚠", "yellow", ao);
304
+ const fileWord = agg.belowTargetFiles.length === 1 ? "file" : "files";
305
+ lines.push(`Coverage: ${tick} minimum thresholds met${thresholdSpec ? ` (${thresholdSpec})` : ""}`);
306
+ lines.push(`Coverage: ${warn} ${agg.belowTargetFiles.length} ${fileWord} below aspirational targets${targetSpec ? ` (${targetSpec})` : ""}`);
307
+ } else {
308
+ const tick = ansi("✓", "green", ao);
309
+ if (agg.targetsGlobal) lines.push(`Coverage: ${tick} minimum thresholds + aspirational targets met${targetSpec ? ` (${targetSpec})` : ""}`);
310
+ else lines.push(`Coverage: ${tick} minimum thresholds met${thresholdSpec ? ` (${thresholdSpec})` : ""}`);
311
+ }
312
+ if (options.trendSummary) {
313
+ const { direction, runCount } = options.trendSummary;
314
+ lines.push(`Trend: ${direction} (${runCount} runs)`);
315
+ }
316
+ if (agg.belowTargetFiles.length > 0 && agg.targetsGlobal) {
317
+ lines.push("");
318
+ lines.push("Files below aspirational target:");
319
+ const limit = options.coverageConsoleLimit;
320
+ const shown = agg.belowTargetFiles.slice(0, limit);
321
+ lines.push(...renderBelowTargetTable(shown));
322
+ if (agg.belowTargetFiles.length > limit) lines.push(`… ${agg.belowTargetFiles.length - limit} more (use the test_coverage MCP tool for the full list)`);
323
+ }
324
+ return lines;
325
+ };
326
+ /**
327
+ * Failures block: grouped by project, one entry per failed test with
328
+ * inline error message and stack hint.
329
+ *
330
+ * @internal
331
+ */
332
+ const renderFailuresSection = (reports, ao) => {
333
+ const lines = [];
334
+ const reportsWithFailures = reports.filter((r) => r.summary.failed > 0 || r.unhandledErrors.length > 0);
335
+ if (reportsWithFailures.length === 0) return lines;
336
+ lines.push("Failures:");
337
+ for (const report of reportsWithFailures) {
338
+ const projectName = report.project ?? "default";
339
+ lines.push(` ${projectName}`);
340
+ for (const mod of report.failed) {
341
+ const file = relativePath(mod.file);
342
+ const failedTests = mod.tests.filter((t) => t.state === "failed");
343
+ for (const test of failedTests) lines.push(...renderFailedTest(file, test, ao));
344
+ if (mod.errors && mod.errors.length > 0) for (const err of mod.errors) {
345
+ lines.push(` ${ansi("✗", "red", ao)} ${file} (module-level error)`);
346
+ lines.push(` ${ansi(err.message, "red", ao)}`);
347
+ }
348
+ }
349
+ for (const err of report.unhandledErrors) {
350
+ lines.push(` ${ansi("✗", "red", ao)} unhandled error`);
351
+ lines.push(` ${ansi(err.message, "red", ao)}`);
352
+ }
353
+ }
354
+ return lines;
355
+ };
356
+ const renderFailedTest = (file, test, ao) => {
357
+ const cross = ansi("✗", "red", ao);
358
+ const cls = test.classification ? ansi(` [${test.classification}]`, "dim", ao) : "";
359
+ const lines = [` ${cross} ${file} > ${test.fullName}${cls}`];
360
+ if (test.errors && test.errors.length > 0) for (const err of test.errors) lines.push(` ${ansi(err.message, "dim", ao)}`);
361
+ return lines;
362
+ };
363
+ /**
364
+ * Total line: aggregated pass/fail/skip counts + max-project duration.
365
+ *
366
+ * @internal
367
+ */
368
+ const renderTotal = (reports, ao) => {
369
+ let passed = 0;
370
+ let failed = 0;
371
+ let skipped = 0;
372
+ let maxDuration = 0;
373
+ for (const r of reports) {
374
+ passed += r.summary.passed;
375
+ failed += r.summary.failed;
376
+ skipped += r.summary.skipped;
377
+ if (r.summary.duration > maxDuration) maxDuration = r.summary.duration;
378
+ }
379
+ const parts = [];
380
+ if (failed > 0) parts.push(ansi(`${failed} failed`, "red", ao));
381
+ parts.push(`${passed} passed`);
382
+ if (skipped > 0) parts.push(`${skipped} skipped`);
383
+ return `Total: ${parts.join(", ")} (${formatDuration(maxDuration)})`;
384
+ };
385
+ /**
386
+ * Re-run hints when failures exist.
387
+ *
388
+ * @internal
389
+ */
390
+ const renderNextSteps = (reports, options) => {
391
+ const lines = [];
392
+ const allFailedFiles = /* @__PURE__ */ new Set();
393
+ for (const r of reports) for (const f of r.failedFiles) allFailedFiles.add(relativePath(f));
394
+ if (allFailedFiles.size === 0) return lines;
395
+ lines.push("Next steps:");
396
+ for (const file of allFailedFiles) lines.push(` Re-run: pnpm vitest run ${file}`);
397
+ if (options.mcp) {
398
+ lines.push(" Use the test_history MCP tool to check failure trends");
399
+ lines.push(" Use the test_errors MCP tool to search errors by type");
400
+ }
401
+ return lines;
402
+ };
403
+ /**
404
+ * Header line for multi-project runs.
405
+ *
406
+ * @internal
407
+ */
408
+ const renderHeader = (reports) => {
409
+ const lines = [];
410
+ if (showProjectLabel(reports) && reports.length > 1) lines.push(`Projects (${reports.length}):`);
411
+ else if (showProjectLabel(reports) && reports.length === 1) lines.push(`Project: ${reports[0]?.project ?? "default"}`);
412
+ return lines;
413
+ };
414
+ /**
415
+ * Public entry point: render a collection of `AgentReport` rows as
416
+ * a single plain-text block for stdout.
417
+ *
418
+ * @public
419
+ */
420
+ const formatTerminal = (reports, options) => {
421
+ if (reports.length === 0) return "";
422
+ const ao = { noColor: options.noColor };
423
+ const out = [];
424
+ const header = renderHeader(reports);
425
+ if (header.length > 0) out.push(...header);
426
+ if (reports.length > 1) {
427
+ const widths = reports.map((r) => (r.project ?? "default").length);
428
+ const nameWidth = Math.max(0, ...widths);
429
+ for (const r of reports) {
430
+ out.push(renderProjectRow(r, ao, nameWidth));
431
+ if (r.summary.failed > 0 && r.tagCounts) out.push(...renderTagFailureBreakdown(r.tagCounts));
432
+ }
433
+ out.push("");
434
+ } else if (showProjectLabel(reports)) {
435
+ const r = reports[0];
436
+ if (r) {
437
+ const tick = r.summary.failed === 0 ? ansi("✓", "green", ao) : ansi("✗", "red", ao);
438
+ const counts = [];
439
+ if (r.summary.failed > 0) counts.push(`${r.summary.failed} failed`);
440
+ counts.push(`${r.summary.passed} passed`);
441
+ if (r.summary.skipped > 0) counts.push(`${r.summary.skipped} skipped`);
442
+ const baseRow = `${tick} ${counts.join(", ")} (${formatDuration(r.summary.duration)})`;
443
+ const tagSummary = buildTagSummary(r.tagCounts);
444
+ out.push(`${baseRow}${tagSummary}`);
445
+ if (r.summary.failed > 0 && r.tagCounts) out.push(...renderTagFailureBreakdown(r.tagCounts));
446
+ out.push("");
447
+ }
448
+ } else if (reports.length === 1) {
449
+ const r = reports[0];
450
+ if (r) {
451
+ const tick = r.summary.failed === 0 ? ansi("✓", "green", ao) : ansi("✗", "red", ao);
452
+ const counts = [];
453
+ if (r.summary.failed > 0) counts.push(`${r.summary.failed} failed`);
454
+ counts.push(`${r.summary.passed} passed`);
455
+ if (r.summary.skipped > 0) counts.push(`${r.summary.skipped} skipped`);
456
+ out.push(`${tick} ${counts.join(", ")} (${formatDuration(r.summary.duration)})`);
457
+ out.push("");
458
+ }
459
+ }
460
+ const failures = renderFailuresSection(reports, ao);
461
+ if (failures.length > 0) {
462
+ out.push(...failures);
463
+ out.push("");
464
+ }
465
+ const coverage = renderCoverageSection(reports, options, ao);
466
+ if (coverage.length > 0) {
467
+ out.push(...coverage);
468
+ out.push("");
469
+ }
470
+ out.push(renderTotal(reports, ao));
471
+ const nextSteps = renderNextSteps(reports, options);
472
+ if (nextSteps.length > 0) {
473
+ out.push("");
474
+ out.push(...nextSteps);
475
+ }
476
+ return out.join("\n");
477
+ };
478
+
479
+ //#endregion
480
+ export { formatTerminal };
@@ -0,0 +1,75 @@
1
+ import * as acorn from "acorn";
2
+ import { tsPlugin } from "acorn-typescript";
3
+
4
+ //#region src/utils/function-boundary.ts
5
+ const Parser = acorn.Parser.extend(tsPlugin());
6
+ const isFunctionLike = (node) => node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression";
7
+ const containsLine = (node, line) => node.loc !== void 0 && node.loc.start.line <= line && node.loc.end.line >= line;
8
+ const nodeName = (node, parent) => {
9
+ if (node.id !== null && node.id !== void 0) return node.id.name;
10
+ if (parent !== null && parent.type === "VariableDeclarator") {
11
+ const decl = parent;
12
+ if (decl.id !== void 0 && decl.id.name !== void 0) return decl.id.name;
13
+ }
14
+ if (parent !== null && parent.type === "MethodDefinition") {
15
+ const method = parent;
16
+ if (method.key !== void 0 && method.key.name !== void 0) return method.key.name;
17
+ }
18
+ return "<anonymous>";
19
+ };
20
+ /**
21
+ * Parse `source` (JavaScript or TypeScript, via `acorn` + `acorn-typescript`)
22
+ * and return the smallest enclosing function's start line and name for `line`.
23
+ *
24
+ * Returns `null` only when the parser rejects the source outright (rare —
25
+ * usually a syntax error). Type annotations, generics, decorators, and `as`
26
+ * casts are all accepted.
27
+ * @public
28
+ */
29
+ const findFunctionBoundary = (source, line) => {
30
+ let ast;
31
+ try {
32
+ ast = Parser.parse(source, {
33
+ ecmaVersion: "latest",
34
+ sourceType: "module",
35
+ locations: true
36
+ });
37
+ } catch {
38
+ return null;
39
+ }
40
+ let smallest = null;
41
+ const walk = (node, parent) => {
42
+ if (node === null || node === void 0) return;
43
+ if (Array.isArray(node)) {
44
+ for (const child of node) walk(child, parent);
45
+ return;
46
+ }
47
+ if (typeof node !== "object") return;
48
+ if (isFunctionLike(node) && containsLine(node, line)) if (smallest === null) smallest = {
49
+ node,
50
+ parent
51
+ };
52
+ else {
53
+ const currSpan = smallest.node.loc.end.line - smallest.node.loc.start.line;
54
+ if (node.loc.end.line - node.loc.start.line < currSpan) smallest = {
55
+ node,
56
+ parent
57
+ };
58
+ }
59
+ for (const key of Object.keys(node)) {
60
+ if (key === "loc" || key === "type") continue;
61
+ const value = node[key];
62
+ if (value !== null && typeof value === "object") walk(value, node);
63
+ }
64
+ };
65
+ walk(ast, null);
66
+ if (smallest === null) return null;
67
+ const s = smallest;
68
+ return {
69
+ line: s.node.loc.start.line,
70
+ name: nodeName(s.node, s.parent)
71
+ };
72
+ };
73
+
74
+ //#endregion
75
+ export { findFunctionBoundary };
@@ -0,0 +1,11 @@
1
+ //#region src/utils/hyperlink.ts
2
+ const ESC = "\x1B";
3
+ const BEL = "\x07";
4
+ /** @public */
5
+ const osc8 = (url, label, options) => {
6
+ if (!options.enabled) return label;
7
+ return `${ESC}]8;;${url}${BEL}${label}${ESC}]8;;${BEL}`;
8
+ };
9
+
10
+ //#endregion
11
+ export { osc8 };
@@ -0,0 +1,88 @@
1
+ //#region src/utils/match-vitest-command.ts
2
+ const RE_DIRECT = /^\s*vitest(\s|$)/;
3
+ const RE_RUNNER = /^\s*(npx|pnpx|bunx)\s+vitest(\s|$)/;
4
+ const RE_PM_EXEC = /^\s*(npm|pnpm|yarn|bun)\s+(exec|dlx|x)\s+vitest(\s|$)/;
5
+ const RE_NODE_BIN = /^\s*node\s+(\.\/)?node_modules\/\.bin\/vitest(\s|$)/;
6
+ const matchPmScript = (command, scripts) => {
7
+ if (scripts.size === 0) return false;
8
+ const trimmed = command.trimStart();
9
+ for (const scriptName of scripts) {
10
+ const escaped = scriptName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11
+ if (new RegExp(`^(npm|pnpm|yarn|bun)\\s+(run\\s+)?${escaped}(\\s|$)`).test(trimmed)) return true;
12
+ }
13
+ return false;
14
+ };
15
+ /**
16
+ * Return `true` if the command invokes Vitest in any of the five
17
+ * supported shapes. The `vitestScripts` set carries the names of
18
+ * package.json scripts that were detected as containing `vitest`
19
+ * (computed once per project and cached by the sidecar).
20
+ * @public
21
+ */
22
+ const isVitestInvocation = (command, vitestScripts) => {
23
+ if (RE_DIRECT.test(command)) return true;
24
+ if (RE_RUNNER.test(command)) return true;
25
+ if (RE_PM_EXEC.test(command)) return true;
26
+ if (RE_NODE_BIN.test(command)) return true;
27
+ if (matchPmScript(command, vitestScripts)) return true;
28
+ return false;
29
+ };
30
+ /**
31
+ * Build the env-var prefix string to prepend to a matched command.
32
+ *
33
+ * Returns the assignments in the documented order:
34
+ * `VITEST_AGENT_CONVERSATION_ID=<uuid> VITEST_AGENT_AGENT_ID=<uuid> [VITEST_AGENT_PARENT_AGENT_ID=<uuid>]`
35
+ *
36
+ * POSIX env-prefix syntax scopes the assignments to the
37
+ * immediately-following process — no sticky-env across subsequent
38
+ * Bash calls, no risk of cross-contamination.
39
+ * @public
40
+ */
41
+ const buildEnvPrefix = (input) => {
42
+ const parts = [];
43
+ parts.push(`VITEST_AGENT_CONVERSATION_ID=${input.conversationId}`);
44
+ parts.push(`VITEST_AGENT_AGENT_ID=${input.agentId}`);
45
+ if (input.parentAgentId !== void 0) parts.push(`VITEST_AGENT_PARENT_AGENT_ID=${input.parentAgentId}`);
46
+ return parts.join(" ");
47
+ };
48
+ /**
49
+ * Compute the rewritten command for a Bash tool invocation. When the
50
+ * command does not match a Vitest shape, returns the original
51
+ * unchanged. When it matches, prepends the env prefix.
52
+ * @public
53
+ */
54
+ const rewriteBashCommand = (input) => {
55
+ if (!isVitestInvocation(input.command, input.vitestScripts)) return input.command;
56
+ return `${buildEnvPrefix({
57
+ conversationId: input.conversationId,
58
+ agentId: input.agentId,
59
+ ...input.parentAgentId !== void 0 && { parentAgentId: input.parentAgentId }
60
+ })} ${input.command}`;
61
+ };
62
+ /**
63
+ * Detect which package.json scripts invoke Vitest by inspecting the
64
+ * `scripts` field. Resolves one hop of script reference (e.g.,
65
+ * `"test": "pnpm test:unit && pnpm test:int"` finds vitest if either
66
+ * `test:unit` or `test:int` mentions vitest). Multi-hop indirection
67
+ * is a documented limitation.
68
+ * @public
69
+ */
70
+ const detectVitestScripts = (scripts) => {
71
+ const directHits = /* @__PURE__ */ new Set();
72
+ for (const [name, body] of Object.entries(scripts)) if (/\bvitest\b/.test(body)) directHits.add(name);
73
+ const includesScriptRef = (body, scriptName) => {
74
+ const escaped = scriptName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
75
+ return new RegExp(`(npm|pnpm|yarn|bun)\\s+(run\\s+)?${escaped}(\\s|$|&)`).test(body);
76
+ };
77
+ for (const [name, body] of Object.entries(scripts)) {
78
+ if (directHits.has(name)) continue;
79
+ for (const hit of directHits) if (includesScriptRef(body, hit)) {
80
+ directHits.add(name);
81
+ break;
82
+ }
83
+ }
84
+ return directHits;
85
+ };
86
+
87
+ //#endregion
88
+ export { buildEnvPrefix, detectVitestScripts, isVitestInvocation, rewriteBashCommand };
@@ -0,0 +1,34 @@
1
+ //#region src/utils/normalize-workspace-key.ts
2
+ /**
3
+ * Normalize a workspace name into a filesystem-safe directory segment.
4
+ *
5
+ * The workspace key drives the per-project subdirectory under the XDG data
6
+ * directory (e.g. `$XDG_DATA_HOME/vitest-agent/<key>/data.db`). The
7
+ * key must be deterministic for a given input and safe to use as a single
8
+ * path segment on macOS, Linux, and Windows.
9
+ *
10
+ * Rules (applied in order):
11
+ *
12
+ * 1. Replace `/` with `__` so scoped names like `@org/pkg` collapse to
13
+ * `@org__pkg` rather than introducing a subdirectory boundary. Scoped
14
+ * npm names contain at most one `/` (between scope and name), and
15
+ * leading-`@` requires the scope/name form, so `@org__pkg` is not a
16
+ * valid unscoped npm name -- the `__` token is unambiguous in
17
+ * practice.
18
+ * 2. Replace any character outside `[A-Za-z0-9._@-]` with `_`. This catches
19
+ * Windows-reserved characters (`\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`),
20
+ * control characters, whitespace, and anything else that varies in
21
+ * interpretation across filesystems.
22
+ * 3. Collapse runs of underscores produced by the substitution above into a
23
+ * single `_` so the output stays compact.
24
+ *
25
+ * @param name - The raw `name` field from a workspace's `package.json`.
26
+ * @returns A filesystem-safe single path segment.
27
+ * @public
28
+ */
29
+ function normalizeWorkspaceKey(name) {
30
+ return name.replaceAll("/", "__").replace(/[^A-Za-z0-9._@-]/g, "_").replace(/_{3,}/g, "__");
31
+ }
32
+
33
+ //#endregion
34
+ export { normalizeWorkspaceKey };