@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,88 @@
1
+ //#region src/utils/compute-trend.ts
2
+ const STABLE_THRESHOLD = .1;
3
+ const MAX_ENTRIES = 50;
4
+ /**
5
+ * Hash resolved targets to detect configuration changes.
6
+ *
7
+ * Note: JSON.stringify is order-sensitive, but resolveThresholds()
8
+ * always produces keys in METRIC_KEYS iteration order, so the hash
9
+ * is stable for the same logical configuration.
10
+ * @public
11
+ */
12
+ function hashTargets(targets) {
13
+ if (!targets) return void 0;
14
+ return JSON.stringify(targets);
15
+ }
16
+ /**
17
+ * Compute direction from aggregate delta.
18
+ *
19
+ * Threshold is compared against the sum of all four metric deltas, not
20
+ * per-metric. This means the sensitivity is higher when multiple metrics
21
+ * move together vs. a single metric. This is intentional -- aggregate
22
+ * movement is a better signal of overall trajectory.
23
+ */
24
+ function computeDirection(delta) {
25
+ const sum = delta.lines + delta.functions + delta.branches + delta.statements;
26
+ if (sum > STABLE_THRESHOLD) return "improving";
27
+ if (sum < -.1) return "regressing";
28
+ return "stable";
29
+ }
30
+ /**
31
+ * Compute a new trend entry and return the updated trend record.
32
+ *
33
+ * If targets have changed (different hash), the trend history is
34
+ * cleared and the new entry becomes the first data point.
35
+ * @public
36
+ */
37
+ function computeTrend(current, existing, targets) {
38
+ const currentHash = hashTargets(targets);
39
+ const entries = existing?.entries ?? [];
40
+ const baseEntries = entries[entries.length - 1]?.targetsHash !== currentHash ? [] : entries;
41
+ const previous = baseEntries[baseEntries.length - 1];
42
+ const delta = previous ? {
43
+ lines: current.lines - previous.coverage.lines,
44
+ functions: current.functions - previous.coverage.functions,
45
+ branches: current.branches - previous.coverage.branches,
46
+ statements: current.statements - previous.coverage.statements
47
+ } : {
48
+ lines: 0,
49
+ functions: 0,
50
+ branches: 0,
51
+ statements: 0
52
+ };
53
+ const entry = {
54
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
55
+ coverage: { ...current },
56
+ delta,
57
+ direction: computeDirection(delta),
58
+ ...currentHash ? { targetsHash: currentHash } : {}
59
+ };
60
+ const newEntries = [...baseEntries, entry];
61
+ if (newEntries.length > MAX_ENTRIES) newEntries.splice(0, newEntries.length - MAX_ENTRIES);
62
+ return { entries: newEntries };
63
+ }
64
+ /**
65
+ * Get the overall direction from recent entries.
66
+ * @public
67
+ */
68
+ function getRecentDirection(record, count = 5) {
69
+ const entries = record.entries;
70
+ if (entries.length === 0) return {
71
+ direction: "stable",
72
+ runCount: 0
73
+ };
74
+ const recent = entries.slice(-count);
75
+ let improving = 0;
76
+ let regressing = 0;
77
+ for (const e of recent) {
78
+ if (e.direction === "improving") improving++;
79
+ if (e.direction === "regressing") regressing++;
80
+ }
81
+ return {
82
+ direction: improving > regressing ? "improving" : regressing > improving ? "regressing" : "stable",
83
+ runCount: recent.length
84
+ };
85
+ }
86
+
87
+ //#endregion
88
+ export { computeTrend, getRecentDirection, hashTargets };
@@ -0,0 +1,58 @@
1
+ //#region src/utils/detect-pm.ts
2
+ const LOCKFILE_MAP = [
3
+ ["pnpm-lock.yaml", "pnpm"],
4
+ ["package-lock.json", "npm"],
5
+ ["yarn.lock", "yarn"],
6
+ ["bun.lock", "bun"]
7
+ ];
8
+ const RUN_COMMANDS = {
9
+ pnpm: "pnpm vitest run",
10
+ npm: "npx vitest run",
11
+ yarn: "yarn vitest run",
12
+ bun: "bun vitest run"
13
+ };
14
+ /**
15
+ * Detect the package manager used in a project by inspecting the root directory.
16
+ *
17
+ * @remarks
18
+ * Detection order:
19
+ * 1. The `packageManager` field in `package.json` (e.g., `"pnpm\@10.32.1"`)
20
+ * 2. Lockfile presence: `pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lock`
21
+ *
22
+ * Returns `null` when no package manager can be determined.
23
+ *
24
+ * @param root - Absolute path to the project root directory
25
+ * @param fs - Filesystem adapter for reading files and checking existence
26
+ * @returns The detected package manager, or `null`
27
+ *
28
+ * @internal
29
+ */
30
+ async function detectPackageManager(root, fs) {
31
+ try {
32
+ const raw = await fs.readFile(`${root}/package.json`);
33
+ const pkg = JSON.parse(raw);
34
+ if (pkg.packageManager) {
35
+ const name = pkg.packageManager.split("@")[0];
36
+ if (name in RUN_COMMANDS) return name;
37
+ }
38
+ } catch {}
39
+ for (const [lockfile, pm] of LOCKFILE_MAP) if (await fs.exists(`${root}/${lockfile}`)) return pm;
40
+ return null;
41
+ }
42
+ /**
43
+ * Return the vitest run command for the given package manager.
44
+ *
45
+ * Falls back to `"npx vitest run"` when `pm` is `null`.
46
+ *
47
+ * @param pm - Detected package manager, or `null` for unknown
48
+ * @returns Shell command string for running vitest
49
+ *
50
+ * @internal
51
+ */
52
+ function getRunCommand(pm) {
53
+ if (!pm) return "npx vitest run";
54
+ return RUN_COMMANDS[pm];
55
+ }
56
+
57
+ //#endregion
58
+ export { detectPackageManager, getRunCommand };
@@ -0,0 +1,17 @@
1
+ //#region src/utils/detect-timeout.ts
2
+ const TIMEOUT_MESSAGE = /\btimed out in \d+\s*ms/i;
3
+ const TIMEOUT_NAME = /timeout/i;
4
+ /**
5
+ * True when the error looks like a Vitest test or hook timeout. Either
6
+ * the message matches "timed out in Nms" or the error name contains
7
+ * "timeout".
8
+ * @public
9
+ */
10
+ const isTimeoutError = (error) => {
11
+ if (error.message !== void 0 && TIMEOUT_MESSAGE.test(error.message)) return true;
12
+ if (error.name !== void 0 && TIMEOUT_NAME.test(error.name)) return true;
13
+ return false;
14
+ };
15
+
16
+ //#endregion
17
+ export { isTimeoutError };
@@ -0,0 +1,52 @@
1
+ import { LoggerLive } from "../layers/LoggerLive.js";
2
+ import migration from "../migrations/0001_initial.js";
3
+ import { Effect, Layer } from "effect";
4
+ import { SqlClient } from "@effect/sql/SqlClient";
5
+ import * as NodeContext from "@effect/platform-node/NodeContext";
6
+ import { layer } from "@effect/sql-sqlite-node/SqliteClient";
7
+ import * as SqliteMigrator from "@effect/sql-sqlite-node/SqliteMigrator";
8
+
9
+ //#region src/utils/ensure-migrated.ts
10
+ const GLOBAL_KEY = Symbol.for("vitest-agent/migration-promises");
11
+ const getCache = () => {
12
+ const g = globalThis;
13
+ let cache = g[GLOBAL_KEY];
14
+ if (!cache) {
15
+ cache = /* @__PURE__ */ new Map();
16
+ g[GLOBAL_KEY] = cache;
17
+ }
18
+ return cache;
19
+ };
20
+ /**
21
+ * Ensure the SQLite database at `dbPath` is migrated. Runs migrations at
22
+ * most once per dbPath in the current process; concurrent calls share the
23
+ * same in-flight promise. Subsequent calls (after the first resolves) are
24
+ * no-ops.
25
+ * @public
26
+ */
27
+ function ensureMigrated(dbPath, logLevel, logFile) {
28
+ const cache = getCache();
29
+ const cached = cache.get(dbPath);
30
+ if (cached) return cached;
31
+ const SqliteLayer = layer({ filename: dbPath });
32
+ const PlatformLayer = NodeContext.layer;
33
+ const MigratorLayer = SqliteMigrator.layer({ loader: SqliteMigrator.fromRecord({ "0001_initial": migration }) }).pipe(Layer.provide(Layer.merge(SqliteLayer, PlatformLayer)));
34
+ const program = Effect.gen(function* () {
35
+ yield* SqlClient;
36
+ }).pipe(Effect.provide(MigratorLayer), Effect.provide(Layer.merge(SqliteLayer, PlatformLayer)), Effect.provide(LoggerLive(logLevel, logFile)));
37
+ const promise = Effect.runPromise(program);
38
+ cache.set(dbPath, promise);
39
+ promise.catch(() => {});
40
+ return promise;
41
+ }
42
+ /**
43
+ * Reset the migration cache. Test-only.
44
+ *
45
+ * @internal
46
+ */
47
+ function _resetMigrationCacheForTesting() {
48
+ getCache().clear();
49
+ }
50
+
51
+ //#endregion
52
+ export { _resetMigrationCacheForTesting, ensureMigrated };
@@ -0,0 +1,33 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ //#region src/utils/failure-signature.ts
4
+ const ASSERTION_REGEX = /(toBe|toEqual|toContain|toMatch|toThrow|toBeNull|toBeUndefined|toBeTruthy|toBeFalsy|toBeGreaterThan|toBeLessThan|toBeInstanceOf|toBeCloseTo|toHaveBeenCalled|toHaveBeenCalledWith|toHaveProperty|toHaveLength)\s*\(([^)]*)\)/;
5
+ const typeTagOf = (literal) => {
6
+ const trimmed = literal.trim();
7
+ if (trimmed === "" || trimmed === ")") return "";
8
+ if (/^\d+(\.\d+)?$/.test(trimmed)) return "<number>";
9
+ if (/^["'`]/.test(trimmed)) return "<string>";
10
+ if (/^(true|false)$/.test(trimmed)) return "<boolean>";
11
+ if (/^null$/.test(trimmed)) return "<null>";
12
+ if (/^undefined$/.test(trimmed)) return "<undefined>";
13
+ if (/^[{[]/.test(trimmed)) return "<object>";
14
+ return "<expr>";
15
+ };
16
+ /** @public */
17
+ const normalizeAssertionShape = (message) => {
18
+ const match = message.match(ASSERTION_REGEX);
19
+ if (match === null) return null;
20
+ const matcher = match[1];
21
+ const arg = typeTagOf(match[2]);
22
+ return arg === "" ? matcher : `${matcher}(${arg})`;
23
+ };
24
+ /** @public */
25
+ const computeFailureSignature = (input) => {
26
+ const shape = normalizeAssertionShape(input.assertion_message) ?? "<unknown>";
27
+ const lineCoord = input.top_frame_function_boundary_line !== null ? `fb:${input.top_frame_function_boundary_line}` : input.top_frame_raw_line !== void 0 ? `raw:${Math.floor(input.top_frame_raw_line / 10) * 10}` : "raw:?";
28
+ const key = `${input.error_name}|${shape}|${input.top_frame_function_name}|${lineCoord}`;
29
+ return createHash("sha256").update(key).digest("hex").substring(0, 16);
30
+ };
31
+
32
+ //#endregion
33
+ export { computeFailureSignature, normalizeAssertionShape };
@@ -0,0 +1,219 @@
1
+ import { ansi } from "./ansi.js";
2
+
3
+ //#region src/utils/format-console.ts
4
+ /**
5
+ * Return the metric with the lowest percentage from a `FileCoverageReport`.
6
+ *
7
+ * @remarks
8
+ * Used to surface the single worst coverage metric per file in console output,
9
+ * keeping the display compact. Labels are abbreviated: `"Stmts"`, `"Branch"`,
10
+ * `"Funcs"`, `"Lines"`.
11
+ *
12
+ * @param f - File coverage report to analyze
13
+ * @returns Object with `label` and `pct` of the worst metric
14
+ *
15
+ * @internal
16
+ */
17
+ function getWorstMetric(f) {
18
+ return [
19
+ {
20
+ label: "Stmts",
21
+ pct: f.summary.statements
22
+ },
23
+ {
24
+ label: "Branch",
25
+ pct: f.summary.branches
26
+ },
27
+ {
28
+ label: "Funcs",
29
+ pct: f.summary.functions
30
+ },
31
+ {
32
+ label: "Lines",
33
+ pct: f.summary.lines
34
+ }
35
+ ].reduce((worst, m) => m.pct < worst.pct ? m : worst);
36
+ }
37
+ /**
38
+ * Strip the working directory prefix from a file path to produce a
39
+ * project-relative path for display.
40
+ *
41
+ * @param filePath - Absolute or relative file path
42
+ * @param cwd - Working directory to strip; defaults to `process.cwd()`
43
+ * @returns Project-relative path string
44
+ *
45
+ * @internal
46
+ */
47
+ function relativePath(filePath, cwd) {
48
+ const root = cwd ?? process.cwd();
49
+ if (filePath.startsWith(root)) {
50
+ const rel = filePath.slice(root.length);
51
+ return rel.startsWith("/") ? rel.slice(1) : rel;
52
+ }
53
+ return filePath;
54
+ }
55
+ /**
56
+ * Determine the output tier based on test results and coverage state.
57
+ *
58
+ * - `"red"` -- failures, unhandled errors, or files below threshold
59
+ * - `"yellow"` -- all tests pass but coverage targets exist (room to improve)
60
+ * - `"green"` -- all tests pass, all coverage targets met
61
+ *
62
+ * @internal
63
+ */
64
+ function determineTier(report) {
65
+ if (report.summary.failed > 0 || report.unhandledErrors.length > 0) return "red";
66
+ if (report.coverage) {
67
+ if (report.coverage.lowCoverage.length > 0) return "red";
68
+ if (report.coverage.targets && report.coverage.belowTarget && report.coverage.belowTarget.length > 0) return "yellow";
69
+ }
70
+ return "green";
71
+ }
72
+ /**
73
+ * Format an {@link (AgentReport:variable)} as compact, actionable console markdown.
74
+ *
75
+ * @remarks
76
+ * The output is structured for LLM agent consumption with four sections:
77
+ *
78
+ * 1. **Compact header** with pass/fail counts and duration
79
+ * 2. **Failed test details** with error messages and diffs (the core value)
80
+ * 3. **Coverage gaps** showing only files below threshold, worst metric first
81
+ * 4. **Next steps** with re-run commands and cache file pointer
82
+ *
83
+ * When all tests pass, the output collapses to a single success line
84
+ * with an optional cache file reference.
85
+ *
86
+ * Returns an empty string when `consoleOutput` is `"silent"`.
87
+ *
88
+ * @param report - The report to format
89
+ * @param options - Formatting options controlling verbosity and color
90
+ * @returns Formatted markdown string, or empty string for silent mode
91
+ *
92
+ * @internal
93
+ */
94
+ function formatConsoleMarkdown(report, options) {
95
+ const { consoleOutput, coverageConsoleLimit, noColor } = options;
96
+ if (consoleOutput === "silent") return "";
97
+ const ao = { noColor };
98
+ const lines = [];
99
+ const hasFailures = report.summary.failed > 0;
100
+ const duration = Math.round(report.summary.duration);
101
+ const projectLabel = report.project ? ` (${report.project})` : "";
102
+ if (hasFailures) lines.push(`## ${ansi("✗", "red", ao)} Vitest${projectLabel} \u2014 ${report.summary.failed} failed, ${report.summary.passed} passed (${duration}ms)`);
103
+ else lines.push(`## ${ansi("✓", "green", ao)} Vitest${projectLabel} \u2014 ${report.summary.passed} passed (${duration}ms)`);
104
+ lines.push("");
105
+ if (options.trendSummary && report.coverage) {
106
+ const { direction, runCount, firstMetric } = options.trendSummary;
107
+ const tier = determineTier(report);
108
+ if (tier === "green") {
109
+ const trendNote = `Coverage: all targets met | trending ${direction} over ${runCount} runs`;
110
+ lines[0] = `${lines[0]} — ${trendNote}`;
111
+ } else if (tier === "yellow") {
112
+ const belowTargetCount = report.coverage.belowTarget?.length ?? 0;
113
+ let summary = `Coverage: ${belowTargetCount} file${belowTargetCount !== 1 ? "s" : ""} below target`;
114
+ summary += ` | trending ${direction} over ${runCount} runs`;
115
+ if (firstMetric) {
116
+ summary += ` (${firstMetric.name}: ${firstMetric.from}% -> ${firstMetric.to}%`;
117
+ if (firstMetric.target) summary += `, target: ${firstMetric.target}%`;
118
+ summary += ")";
119
+ }
120
+ lines.push(summary);
121
+ lines.push("");
122
+ } else {
123
+ let summary = "Coverage:";
124
+ if (report.coverage.lowCoverage.length > 0) summary += ` ${report.coverage.lowCoverage.length} file${report.coverage.lowCoverage.length !== 1 ? "s" : ""} below threshold`;
125
+ summary += ` | trending ${direction} over ${runCount} runs`;
126
+ if (firstMetric) summary += ` (${firstMetric.name}: ${firstMetric.from}% -> ${firstMetric.to}%)`;
127
+ lines.push(summary);
128
+ lines.push("");
129
+ }
130
+ }
131
+ if (hasFailures) for (const mod of report.failed) {
132
+ const file = relativePath(mod.file);
133
+ lines.push(`### ${ansi("✗", "red", ao)} \`${file}\``);
134
+ lines.push("");
135
+ if (mod.errors && mod.errors.length > 0) for (const err of mod.errors) {
136
+ lines.push(ansi(err.message, "red", ao));
137
+ if (err.diff) {
138
+ lines.push("");
139
+ lines.push("```diff");
140
+ lines.push(err.diff);
141
+ lines.push("```");
142
+ }
143
+ lines.push("");
144
+ }
145
+ const failedTests = mod.tests.filter((t) => t.state === "failed");
146
+ for (const test of failedTests) {
147
+ const label = test.classification ? ` [${test.classification}]` : "";
148
+ lines.push(`- ${ansi("✗", "red", ao)} **${test.fullName}**${label}`);
149
+ if (test.errors && test.errors.length > 0) for (const err of test.errors) {
150
+ lines.push(` ${ansi(err.message, "dim", ao)}`);
151
+ if (err.diff) {
152
+ lines.push("");
153
+ lines.push(" ```diff");
154
+ for (const diffLine of err.diff.split("\n")) lines.push(` ${diffLine}`);
155
+ lines.push(" ```");
156
+ }
157
+ }
158
+ lines.push("");
159
+ }
160
+ }
161
+ if (report.coverage) {
162
+ const cov = report.coverage;
163
+ const globalThresholds = cov.thresholds.global;
164
+ const filesToShow = cov.lowCoverage.slice(0, coverageConsoleLimit);
165
+ if (filesToShow.length > 0) {
166
+ lines.push(`### Coverage gaps`);
167
+ lines.push("");
168
+ for (const f of filesToShow) {
169
+ const file = relativePath(f.file);
170
+ const worst = getWorstMetric(f);
171
+ const thresholdValue = globalThresholds[worst.label === "Stmts" ? "statements" : worst.label === "Branch" ? "branches" : worst.label === "Funcs" ? "functions" : "lines"];
172
+ const thresholdSuffix = thresholdValue != null ? ` (threshold: ${thresholdValue}%)` : "";
173
+ lines.push(`- \`${file}\` \u2014 ${worst.label}: ${worst.pct}%${thresholdSuffix} \u2014 uncovered: ${f.uncoveredLines}`);
174
+ }
175
+ lines.push("");
176
+ }
177
+ if (cov.targets && cov.belowTarget && cov.belowTarget.length > 0) {
178
+ lines.push(`### Coverage improvements needed`);
179
+ lines.push("");
180
+ for (const f of cov.belowTarget.slice(0, coverageConsoleLimit)) {
181
+ const file = relativePath(f.file);
182
+ const worst = getWorstMetric(f);
183
+ const metricKey = worst.label === "Stmts" ? "statements" : worst.label === "Branch" ? "branches" : worst.label === "Funcs" ? "functions" : "lines";
184
+ const targetValue = cov.targets.global[metricKey];
185
+ const targetSuffix = targetValue != null ? ` (target: ${targetValue}%)` : "";
186
+ lines.push(`- \`${file}\` \u2014 ${worst.label}: ${worst.pct}%${targetSuffix} \u2014 uncovered: ${f.uncoveredLines}`);
187
+ }
188
+ lines.push("");
189
+ }
190
+ }
191
+ if (hasFailures || report.coverage && report.coverage.lowCoverage.length > 0) {
192
+ lines.push("### Next steps");
193
+ lines.push("");
194
+ if (hasFailures) {
195
+ const allFailedTests = report.failed.flatMap((m) => m.tests.filter((t) => t.state === "failed"));
196
+ const newFailures = allFailedTests.filter((t) => t.classification === "new-failure");
197
+ const persistent = allFailedTests.filter((t) => t.classification === "persistent");
198
+ const flaky = allFailedTests.filter((t) => t.classification === "flaky");
199
+ if (newFailures.length > 0) {
200
+ const files = [...new Set(report.failed.filter((m) => m.tests.some((t) => t.classification === "new-failure")).map((m) => relativePath(m.file)))];
201
+ lines.push(`- Fix ${newFailures.length} new failure${newFailures.length > 1 ? "s" : ""} in ${files.map((f) => `\`${f}\``).join(", ")} (likely caused by recent changes)`);
202
+ }
203
+ if (persistent.length > 0) lines.push(`- ${persistent.length} persistent failure${persistent.length > 1 ? "s" : ""} (pre-existing, may not be yours)`);
204
+ if (flaky.length > 0) lines.push(`- ${flaky.length} flaky test${flaky.length > 1 ? "s" : ""} (may pass on retry)`);
205
+ for (const file of report.failedFiles) lines.push(`- Re-run: \`vitest run ${relativePath(file)}\``);
206
+ if (options.mcp) {
207
+ lines.push("- Use `test_history` for failure trends");
208
+ lines.push("- Use `test_errors` to search errors by type");
209
+ lines.push("- Use `test_coverage` for coverage gap analysis");
210
+ lines.push("- Use `note({ action: \"create\" })` to record debugging findings");
211
+ }
212
+ }
213
+ lines.push("");
214
+ }
215
+ return lines.join("\n");
216
+ }
217
+
218
+ //#endregion
219
+ export { determineTier, formatConsoleMarkdown, getWorstMetric, relativePath };
@@ -0,0 +1,35 @@
1
+ import { Cause } from "effect";
2
+
3
+ //#region src/utils/format-fatal-error.ts
4
+ const ISSUE_URL = "https://github.com/spencerbeggs/vitest-agent/issues";
5
+ const FIBER_FAILURE_CAUSE_KEY = Symbol.for("effect/Runtime/FiberFailure/Cause");
6
+ /**
7
+ * Format an unknown error into a human-readable string with issue URL.
8
+ *
9
+ * Extraction order:
10
+ * 1. Effect Cause — use `Cause.pretty()` for full formatting
11
+ * 2. Effect FiberFailure — extract Cause and use `Cause.pretty()`
12
+ * 3. Plain Error — extract `.message` and `.stack`
13
+ * 4. Unknown — `String(err)`
14
+ *
15
+ * @public
16
+ */
17
+ function formatFatalError(err) {
18
+ let detail;
19
+ if (Cause.isCause(err)) detail = Cause.pretty(err);
20
+ else if (err != null && typeof err === "object" && FIBER_FAILURE_CAUSE_KEY in err) {
21
+ const cause = err[FIBER_FAILURE_CAUSE_KEY];
22
+ if (Cause.isCause(cause)) detail = Cause.pretty(cause);
23
+ else detail = String(err);
24
+ } else if (err instanceof Error) detail = err.stack ?? err.message;
25
+ else if (err !== null && typeof err === "object") try {
26
+ detail = JSON.stringify(err, null, 2);
27
+ } catch {
28
+ detail = String(err);
29
+ }
30
+ else detail = String(err);
31
+ return `${detail}\nPlease report at ${ISSUE_URL}`;
32
+ }
33
+
34
+ //#endregion
35
+ export { formatFatalError };
@@ -0,0 +1,161 @@
1
+ //#region src/utils/format-gfm.ts
2
+ /**
3
+ * Format a number as a percentage string.
4
+ *
5
+ * @internal
6
+ */
7
+ function pct(n) {
8
+ return `${n}%`;
9
+ }
10
+ /**
11
+ * Find the worst (lowest) coverage metric for a file.
12
+ *
13
+ * @internal
14
+ */
15
+ function worstCoverage(f) {
16
+ return [
17
+ {
18
+ label: "Stmts",
19
+ pct: f.summary.statements
20
+ },
21
+ {
22
+ label: "Branch",
23
+ pct: f.summary.branches
24
+ },
25
+ {
26
+ label: "Funcs",
27
+ pct: f.summary.functions
28
+ },
29
+ {
30
+ label: "Lines",
31
+ pct: f.summary.lines
32
+ }
33
+ ].reduce((worst, m) => m.pct < worst.pct ? m : worst);
34
+ }
35
+ /**
36
+ * Format the body of a single project's GFM output.
37
+ *
38
+ * @internal
39
+ */
40
+ function formatProjectBody(report) {
41
+ const lines = [];
42
+ if (report.failed.length > 0) {
43
+ lines.push("## Failed Tests");
44
+ lines.push("");
45
+ for (const mod of report.failed) {
46
+ lines.push(`### \`${mod.file}\``);
47
+ lines.push("");
48
+ if (mod.errors && mod.errors.length > 0) for (const err of mod.errors) {
49
+ lines.push(`> ${err.message}`);
50
+ if (err.diff) {
51
+ lines.push("");
52
+ lines.push("```diff");
53
+ lines.push(err.diff);
54
+ lines.push("```");
55
+ }
56
+ lines.push("");
57
+ }
58
+ const failedTests = mod.tests.filter((t) => t.state === "failed");
59
+ for (const test of failedTests) {
60
+ lines.push(`**${test.fullName}**`);
61
+ lines.push("");
62
+ if (test.errors && test.errors.length > 0) for (const err of test.errors) {
63
+ lines.push(`> ${err.message}`);
64
+ if (err.diff) {
65
+ lines.push("");
66
+ lines.push("```diff");
67
+ lines.push(err.diff);
68
+ lines.push("```");
69
+ }
70
+ }
71
+ lines.push("");
72
+ }
73
+ }
74
+ }
75
+ if (report.coverage) {
76
+ const cov = report.coverage;
77
+ lines.push("## Coverage");
78
+ lines.push("");
79
+ lines.push("| Metric | Coverage |");
80
+ lines.push("| --- | --- |");
81
+ lines.push(`| Statements | ${pct(cov.totals.statements)} |`);
82
+ lines.push(`| Branches | ${pct(cov.totals.branches)} |`);
83
+ lines.push(`| Functions | ${pct(cov.totals.functions)} |`);
84
+ lines.push(`| Lines | ${pct(cov.totals.lines)} |`);
85
+ lines.push("");
86
+ if (cov.lowCoverage.length > 0) {
87
+ const thresholdParts = [];
88
+ const g = cov.thresholds.global;
89
+ if (g.lines !== void 0) thresholdParts.push(`lines: ${g.lines}%`);
90
+ if (g.functions !== void 0) thresholdParts.push(`functions: ${g.functions}%`);
91
+ if (g.branches !== void 0) thresholdParts.push(`branches: ${g.branches}%`);
92
+ if (g.statements !== void 0) thresholdParts.push(`statements: ${g.statements}%`);
93
+ const thresholdDisplay = thresholdParts.length > 0 ? thresholdParts.join(", ") : "configured thresholds";
94
+ lines.push("> [!WARNING]");
95
+ lines.push(`> ${cov.lowCoverage.length} file(s) below coverage threshold (${thresholdDisplay}):`);
96
+ lines.push(">");
97
+ for (const f of cov.lowCoverage) {
98
+ const worst = worstCoverage(f);
99
+ lines.push(`> - \`${f.file}\` — ${worst.label} ${pct(worst.pct)}`);
100
+ }
101
+ lines.push("");
102
+ }
103
+ }
104
+ return lines;
105
+ }
106
+ /**
107
+ * Format one or more {@link (AgentReport:variable) | AgentReports} as a GitHub-Flavored
108
+ * Markdown string for writing to `GITHUB_STEP_SUMMARY`.
109
+ *
110
+ * @remarks
111
+ * GFM-specific features used in the output:
112
+ *
113
+ * - **Summary table** with pass/fail counts across all projects
114
+ * - **Collapsible `<details>` blocks** for per-project results in monorepo setups
115
+ * - **Tables** for coverage metrics
116
+ * - **Diff-fenced code blocks** for expected/received comparison output
117
+ * - **GitHub Alert callouts** (`[!WARNING]`) for coverage threshold violations
118
+ *
119
+ * For a single report, the project body is rendered directly without
120
+ * a `<details>` wrapper. For multiple reports (monorepo), each project
121
+ * gets its own collapsible section with a status icon in the summary line.
122
+ *
123
+ * @param reports - One or more agent reports to format
124
+ * @returns GFM-formatted string ready for appending to `GITHUB_STEP_SUMMARY`
125
+ *
126
+ * @public
127
+ */
128
+ function formatGfm(reports) {
129
+ const lines = [];
130
+ const headerIcon = reports.some((r) => r.summary.failed > 0) ? "❌" : "✅";
131
+ lines.push(`# ${headerIcon} Vitest Results`);
132
+ lines.push("");
133
+ const totalPassed = reports.reduce((acc, r) => acc + r.summary.passed, 0);
134
+ const totalFailed = reports.reduce((acc, r) => acc + r.summary.failed, 0);
135
+ const totalTests = reports.reduce((acc, r) => acc + r.summary.total, 0);
136
+ lines.push("| | Count |");
137
+ lines.push("| --- | --- |");
138
+ lines.push(`| Passed | **${totalPassed}** |`);
139
+ lines.push(`| Failed | **${totalFailed}** |`);
140
+ lines.push(`| Total | **${totalTests}** |`);
141
+ lines.push("");
142
+ if (reports.length > 1) for (const report of reports) {
143
+ const projectLabel = report.project ?? "default";
144
+ const statusIcon = report.summary.failed > 0 ? "❌" : "✅";
145
+ lines.push(`<details>`);
146
+ lines.push(`<summary>${statusIcon} ${projectLabel}</summary>`);
147
+ lines.push("");
148
+ const bodyLines = formatProjectBody(report);
149
+ lines.push(...bodyLines);
150
+ lines.push("</details>");
151
+ lines.push("");
152
+ }
153
+ else if (reports.length === 1) {
154
+ const bodyLines = formatProjectBody(reports[0]);
155
+ lines.push(...bodyLines);
156
+ }
157
+ return lines.join("\n");
158
+ }
159
+
160
+ //#endregion
161
+ export { formatGfm };