@vitest-agent/plugin 2.5.7 → 3.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.
package/index.d.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { TestTagDefinition } from "@vitest/runner";
2
1
  import { AgentPluginOptions, AgentReporterOptions, ConsoleMode, CoverageBaselines, CoverageInput, CoverageLevel, CoverageLevelName, CoverageLevelName as CoverageLevelName$1, CoverageReport, EnvironmentDetector, OutputFormat, ResolvedReporterConfig, ResolvedThresholds, RunEvent, SettingsInput, StackFrameInput, Transport, VitestAgentReporterFactory, resolveCoverageInput, validateCoverageConfig } from "@vitest-agent/sdk";
3
2
  import { Context, Effect, Layer, LogLevel, Option } from "effect";
4
- import { TestProjectInlineConfiguration } from "vitest/config";
3
+ import { TestProjectInlineConfiguration, TestTagDefinition } from "vitest/config";
5
4
  import { ResolvedConfig, VitestPluginContext } from "vitest/node";
6
5
  import { SourceMap } from "magic-string";
7
6
  import { WorkspacesSyncOptions } from "@effected/workspaces/node-sync";
@@ -323,8 +322,10 @@ export declare function AgentPlugin(options?: AgentPluginConstructorOptions, _la
323
322
  * passed to Vitest's native `coverage.thresholds`; the `coverageTargets`
324
323
  * half is passed to `AgentPlugin({ coverageTargets })`.
325
324
  *
326
- * `thresholds` carries the optional `perFile` flag; `coverageTargets`
327
- * does not it inherits `perFile` from `coverage.thresholds.perFile`.
325
+ * `thresholds` carries the optional top-level `perFile` flag. Under
326
+ * Vitest 5 a glob-pattern entry may carry its own `perFile`, and a file a
327
+ * pattern matches does NOT inherit the top-level one — the top-level
328
+ * setting applies only to files no pattern matches.
328
329
  * @public
329
330
  */
330
331
  interface CoverageLevelPreset {
@@ -384,9 +385,14 @@ export declare namespace AgentPlugin {
384
385
  /**
385
386
  * Tolerance functions for Vitest's `coverage.thresholds.autoUpdate` field.
386
387
  *
387
- * Vitest's contract: `autoUpdate?: boolean | ((newThreshold: number) => number)`.
388
- * Pass one of these functions directly. `standard` floors; `strict` ceils;
389
- * `lenient` floors and subtracts 2 (clamped to 0) to leave a slack buffer.
388
+ * Vitest's contract is
389
+ * `autoUpdate?: boolean | ((newThreshold: number, previousThreshold: number) => number)`.
390
+ * Pass one of these functions directly. `standard` floors the new value;
391
+ * `strict` ceils it; `lenient` floors and subtracts 2 (clamped to 0) to
392
+ * leave a slack buffer, and never returns a value below
393
+ * `previousThreshold` — so a temporary coverage dip cannot ratchet the
394
+ * configured floor downward. `standard` and `strict` ignore
395
+ * `previousThreshold`.
390
396
  *
391
397
  * ```ts
392
398
  * defineConfig({
@@ -398,9 +404,9 @@ export declare namespace AgentPlugin {
398
404
  * ```
399
405
  */
400
406
  const COVERAGE_AUTOUPDATE: Readonly<{
401
- standard: (n: number) => number;
402
- strict: (n: number) => number;
403
- lenient: (n: number) => number;
407
+ standard: (next: number, previous: number) => number;
408
+ strict: (next: number, previous: number) => number;
409
+ lenient: (next: number, previous: number) => number;
404
410
  }>;
405
411
  /**
406
412
  * Discover Vitest project configs and tag definitions from the workspace layout.
@@ -511,6 +517,13 @@ interface AgentReporterConstructorOptions extends AgentReporterOptions {
511
517
  format?: OutputFormat;
512
518
  mcp?: boolean;
513
519
  githubActions?: boolean;
520
+ /**
521
+ * The `.vitest/<scope>` directory name for `report`-targeted rendered
522
+ * output. Undefined disables report files.
523
+ *
524
+ * @internal
525
+ */
526
+ reportScope?: string;
514
527
  transport?: Transport;
515
528
  /**
516
529
  * Optional `test.passWithNoTests` value the plugin captured from the
@@ -549,6 +562,10 @@ interface AgentReporterConstructorOptions extends AgentReporterOptions {
549
562
  * results via Vitest's native `TestProject` API. In single-project mode,
550
563
  * results are written with project name "default".
551
564
  *
565
+ * Not to be confused with Vitest 5's own `AgentReporter` export from
566
+ * `vitest/node` (an alias of `MinimalReporter`) — this is
567
+ * `@vitest-agent/plugin`'s `AgentReporter`, an unrelated class.
568
+ *
552
569
  * @privateRemarks
553
570
  * The `onCoverage` hook fires **before** `onTestRunEnd` in Vitest's lifecycle.
554
571
  * Coverage data must be stashed as instance state and merged during
@@ -600,6 +617,11 @@ export declare class AgentReporter {
600
617
  * @internal
601
618
  */
602
619
  _vitest: unknown;
620
+ /**
621
+ * Lazily-created writer for `report`-targeted output. Null when
622
+ * `reportScope` is unset (report files disabled) or before `onInit`.
623
+ */
624
+ private reportWriter;
603
625
  private coverage;
604
626
  private logLevel;
605
627
  private logFile;
@@ -969,6 +991,18 @@ export declare class AgentReporter {
969
991
  };
970
992
  }, annotation: {
971
993
  message: string;
994
+ type?: string;
995
+ location?: {
996
+ file: string;
997
+ line: number;
998
+ column: number;
999
+ };
1000
+ attachment?: {
1001
+ contentType?: string;
1002
+ path?: string;
1003
+ body?: string | Uint8Array;
1004
+ bodyEncoding?: "base64" | "utf-8";
1005
+ };
972
1006
  }): void;
973
1007
  /**
974
1008
  * Vitest streaming hook: a test case recorded an artifact.
@@ -985,6 +1019,17 @@ export declare class AgentReporter {
985
1019
  };
986
1020
  }, artifact: {
987
1021
  type?: string;
1022
+ location?: {
1023
+ file: string;
1024
+ line: number;
1025
+ column: number;
1026
+ };
1027
+ attachments?: ReadonlyArray<{
1028
+ contentType?: string;
1029
+ path?: string;
1030
+ body?: string | Uint8Array;
1031
+ bodyEncoding?: "base64" | "utf-8";
1032
+ }>;
988
1033
  }): void;
989
1034
  /**
990
1035
  * Vitest streaming hook: watch mode has finished its initial run and
@@ -1036,6 +1081,23 @@ export declare class AgentReporter {
1036
1081
  onTestRunEnd(testModules: ReadonlyArray<unknown>, unhandledErrors: ReadonlyArray<unknown>, reason: "passed" | "failed" | "interrupted"): Promise<void>;
1037
1082
  }
1038
1083
  //#endregion
1084
+ //#region src/utils/configuration-error.d.ts
1085
+ /**
1086
+ * A mistake in the user's own `AgentPlugin` configuration — a bad report
1087
+ * scope, an unsafe report filename — as opposed to an internal failure of
1088
+ * the plugin.
1089
+ *
1090
+ * `configureVitest` reports these as a single `vitest-agent: <message>`
1091
+ * line on stderr, with no stack trace and no "please report an issue"
1092
+ * banner: there is nothing to report, the user simply needs to fix the
1093
+ * config. Every other throw keeps the `formatFatalError` treatment.
1094
+ *
1095
+ * @public
1096
+ */
1097
+ export declare class ConfigurationError extends Error {
1098
+ readonly name = "ConfigurationError";
1099
+ }
1100
+ //#endregion
1039
1101
  //#region src/utils/discover-projects.d.ts
1040
1102
  /**
1041
1103
  * The resolved output of `discoverProjects` — projects and tag definitions ready for `defineConfig`.
@@ -1353,9 +1415,12 @@ export declare function resolveThresholds(input: VitestThresholdsInput | undefin
1353
1415
  * These are the reporters suppressed when an agent takes over console output.
1354
1416
  *
1355
1417
  * @privateRemarks
1356
- * `"agent"` is the built-in Vitest reporter added in v4.1 that reduces
1357
- * console noise for AI agents. We strip it because our reporter replaces
1358
- * its functionality with structured markdown output.
1418
+ * `"agent"` and `"minimal"` both resolve to Vitest's `MinimalReporter`
1419
+ * class. `"agent"` is the v4.1 spelling; `"minimal"` is the v5 spelling
1420
+ * and is what `configDefaults.reporters` selects whenever `std-env`'s
1421
+ * `isAgent` is true — which is the vitest-agent primary use case. Both
1422
+ * are stripped because our reporter replaces their functionality with
1423
+ * structured markdown output.
1359
1424
  *
1360
1425
  * @see {@link https://vitest.dev/api/advanced/reporters.html | Vitest Reporter docs}
1361
1426
  * @internal
@@ -1382,9 +1447,9 @@ export declare const COVERAGE_LEVELS: Readonly<Record<import("@vitest-agent/sdk"
1382
1447
  export declare const COVERAGE_LEVELS_PER_FILE: Readonly<Record<import("@vitest-agent/sdk").CoverageLevelName, CoverageLevelPreset>>;
1383
1448
  /** Auto-update tolerance functions for `coverage.thresholds.autoUpdate`. Mirrors `AgentPlugin.COVERAGE_AUTOUPDATE`. @public */
1384
1449
  export declare const COVERAGE_AUTOUPDATE: Readonly<{
1385
- standard: (n: number) => number;
1386
- strict: (n: number) => number;
1387
- lenient: (n: number) => number;
1450
+ standard: (next: number, previous: number) => number;
1451
+ strict: (next: number, previous: number) => number;
1452
+ lenient: (next: number, previous: number) => number;
1388
1453
  }>;
1389
1454
  //#endregion
1390
1455
  export { type AddProjectInput, type AgentPluginConstructorOptions, type AgentReporterConstructorOptions, type ClassifyContext, type ClassifyFn, type CoverageInput, CoverageLevel, type CoverageLevelName, type CoverageLevelPreset, type CoverageOptions, type DiscoverBuilder, type DiscoverInput, type PackageJson as DiscoverPackageJson, type DiscoverProjectsOptions, type DiscoverProjectsResult, type DiscoverResult, type DiscoverStrategyCreateOptions, type DiscoverStrategyExtendOptions, type InjectTagsResult, type ModuleInfo, type TagOptions, type ValidationError, type ValidationInfo, type ValidationInput, type ValidationResult, type ValidationWarning, type VitestErrorLike, type VitestStackFrameLike, type VitestThresholdsInput, type WalkerEntry, type WalkerEntryStat, type WalkerFileSystem, resolveCoverageInput, validateCoverageConfig };
package/index.js CHANGED
@@ -7,6 +7,7 @@ import { ReporterLive } from "./layers/ReporterLive.js";
7
7
  import { captureEnvVars } from "./utils/capture-env.js";
8
8
  import { captureSettings, hashSettings } from "./utils/capture-settings.js";
9
9
  import { processFailure } from "./utils/process-failure.js";
10
+ import { ConfigurationError } from "./utils/configuration-error.js";
10
11
  import { AgentReporter } from "./reporter.js";
11
12
  import { nodeWalkerFs } from "./utils/walker-fs.js";
12
13
  import { findTestFiles } from "./utils/find-test-files.js";
@@ -29,4 +30,4 @@ const COVERAGE_LEVELS_PER_FILE = AgentPlugin.COVERAGE_LEVELS_PER_FILE;
29
30
  const COVERAGE_AUTOUPDATE = AgentPlugin.COVERAGE_AUTOUPDATE;
30
31
 
31
32
  //#endregion
32
- export { AgentPlugin, AgentReporter, CONSOLE_REPORTERS, COVERAGE_AUTOUPDATE, COVERAGE_LEVELS, COVERAGE_LEVELS_PER_FILE, CURRENT_PLUGIN_VERSION, ConfigValidation, ConfigValidationLive, ConfigValidationTest, CoverageAnalyzer, CoverageAnalyzerLive, CoverageAnalyzerTest, CoverageLevel, DefaultDiscoverStrategy, DiscoverStrategy, ReporterLive, Tag, captureEnvVars, captureSettings, classifyByDirectory, classifyByFilename, combineClassifiers, discoverProjects, findTestFiles, hashSettings, nodeWalkerFs, processFailure, resolveCoverageInput, resolveThresholds, stripConsoleReporters, validateCoverageConfig };
33
+ export { AgentPlugin, AgentReporter, CONSOLE_REPORTERS, COVERAGE_AUTOUPDATE, COVERAGE_LEVELS, COVERAGE_LEVELS_PER_FILE, CURRENT_PLUGIN_VERSION, ConfigValidation, ConfigValidationLive, ConfigValidationTest, ConfigurationError, CoverageAnalyzer, CoverageAnalyzerLive, CoverageAnalyzerTest, CoverageLevel, DefaultDiscoverStrategy, DiscoverStrategy, ReporterLive, Tag, captureEnvVars, captureSettings, classifyByDirectory, classifyByFilename, combineClassifiers, discoverProjects, findTestFiles, hashSettings, nodeWalkerFs, processFailure, resolveCoverageInput, resolveThresholds, stripConsoleReporters, validateCoverageConfig };
@@ -8,7 +8,7 @@ import { Effect, Layer } from "effect";
8
8
  /**
9
9
  * Live implementation of the ConfigValidation service.
10
10
  *
11
- * Implements all seven validation rules from the 2.0 starter rule set.
11
+ * Implements the eight built-in validation rules.
12
12
  * Rules run inside `validate(...)` — no I/O at construction time.
13
13
  *
14
14
  * @packageDocumentation
@@ -90,7 +90,7 @@ function runInvalidTargetValueRule(input, errors, warnings) {
90
90
  });
91
91
  for (const warn of result.warnings) if (warn.code === "PERFILE_ON_TARGETS") warnings.push({
92
92
  code: "PERFILE_ON_TARGETS",
93
- message: `The "perFile" key should not be set inside coverageTargets. Set coverage.thresholds.perFile instead.`
93
+ message: `A top-level "perFile" key should not be set inside coverageTargets. Set coverage.thresholds.perFile instead, or move it inside a glob-pattern entry — under Vitest 5 a glob-pattern entry carries its own perFile and no longer inherits the top-level one.`
94
94
  });
95
95
  }
96
96
  /**
@@ -119,6 +119,32 @@ function runMissingProviderPackageRule(input, errors) {
119
119
  });
120
120
  }
121
121
  /**
122
+ * Run the GITHUB_JOB_SUMMARY_COLLISION rule.
123
+ *
124
+ * Vitest 5's `github-actions` reporter writes a markdown job summary by
125
+ * default, and Vitest seeds the reporter from `configDefaults` under
126
+ * `GITHUB_ACTIONS=true`. The plugin normalizes every such entry to
127
+ * `jobSummary.enabled = false` in `configureVitest`, so a bare string or a
128
+ * tuple with no `jobSummary` key is NOT a collision. Only an explicit
129
+ * `jobSummary: { enabled: true }` survives normalization, and that entry
130
+ * collides with the plugin's own step summary under `ci-github`. Runs in
131
+ * both operating modes — it is a reporter concern, not a coverage concern.
132
+ */
133
+ function runGithubJobSummaryCollisionRule(input, warnings) {
134
+ const reporters = input.vitestConfig.reporters;
135
+ if (!Array.isArray(reporters)) return;
136
+ if (!reporters.some((entry) => {
137
+ if (!Array.isArray(entry) || entry[0] !== "github-actions") return false;
138
+ return entry[1]?.jobSummary?.enabled === true;
139
+ })) return;
140
+ warnings.push({
141
+ code: "GITHUB_JOB_SUMMARY_COLLISION",
142
+ path: "reporters",
143
+ message: "You enabled the Vitest job summary explicitly on the \"github-actions\" reporter, and vitest-agent also writes a GitHub Actions step summary. Both summaries will appear in the same job.",
144
+ remediation: "Drop the explicit jobSummary option so the plugin can set { jobSummary: { enabled: false } } for you, or set console.ci to \"silent\" to suppress the plugin's own summary instead."
145
+ });
146
+ }
147
+ /**
122
148
  * Core validation logic. Runs all rules and accumulates results.
123
149
  */
124
150
  function runAllRules(input) {
@@ -128,6 +154,7 @@ function runAllRules(input) {
128
154
  const mode = resolveMode(input.vitestConfig);
129
155
  runTargetThresholdRules(input, errors, warnings);
130
156
  runInvalidTargetValueRule(input, errors, warnings);
157
+ runGithubJobSummaryCollisionRule(input, warnings);
131
158
  if (mode === "full") {
132
159
  runUnsupportedProviderRule(input, errors);
133
160
  runMissingProviderPackageRule(input, errors);
@@ -39,6 +39,33 @@ function resolveEffectiveThresholds(filePath, resolved) {
39
39
  return resolved.global;
40
40
  }
41
41
  /**
42
+ * Resolve the per-file threshold override for a file path.
43
+ *
44
+ * Vitest 5 widened `perFile` to `boolean | MetricThresholds` and stopped
45
+ * letting a glob-pattern entry inherit the top-level setting. A matched
46
+ * pattern's own `perFile` is the ONLY per-file setting for that file — a
47
+ * pattern that declares none does not fall back to the top-level one. The
48
+ * top-level `perFile` applies only to files no pattern matches, mirroring
49
+ * Vitest 5.
50
+ *
51
+ * Only an OBJECT-valued setting changes anything here — it replaces the
52
+ * metric numbers used for the per-file check. `true` / `false` / absent
53
+ * all return `null`, which leaves the existing per-file reporting behavior
54
+ * (check against the effective metric thresholds) exactly as it was.
55
+ */
56
+ function resolveEffectivePerFileThresholds(filePath, resolved) {
57
+ let setting;
58
+ let matched = false;
59
+ for (const [pattern, metrics] of resolved.patterns ?? []) if (matchGlob(filePath, pattern)) {
60
+ matched = true;
61
+ setting = metrics.perFile;
62
+ break;
63
+ }
64
+ if (!matched) setting = resolved.perFile;
65
+ if (setting === void 0 || typeof setting === "boolean") return null;
66
+ return setting;
67
+ }
68
+ /**
42
69
  * Internal coverage processing logic. Shared by both `process` and `processScoped`.
43
70
  *
44
71
  * @param coverageMap - The value received by `onCoverage`; duck-typed at runtime
@@ -73,7 +100,7 @@ function processCoverageInternal(coverageMap, options, testedFiles) {
73
100
  const isBareZero = fileStats.statements === 0 && fileStats.branches === 0 && fileStats.functions === 0 && fileStats.lines === 0;
74
101
  if (isBareZero && !includeBareZero) continue;
75
102
  if (scoped && !testedFileSet?.has(filePath)) continue;
76
- const isBelowThreshold = isBelowMetricThresholds(fileStats, resolveEffectiveThresholds(filePath, options.thresholds));
103
+ const isBelowThreshold = isBelowMetricThresholds(fileStats, resolveEffectivePerFileThresholds(filePath, options.thresholds) ?? resolveEffectiveThresholds(filePath, options.thresholds));
77
104
  if (isBareZero || isBelowThreshold) {
78
105
  const uncoveredLines = compressLines(fileCoverage.getUncoveredLines());
79
106
  lowCoverage.push({
@@ -1,5 +1,5 @@
1
1
  import { CoverageAnalyzerLive } from "./CoverageAnalyzerLive.js";
2
- import { DataReaderLive, DataStoreLive, HistoryTrackerLive, LoggerLive, OutputPipelineLive, migration0001 } from "@vitest-agent/sdk";
2
+ import { DataReaderLive, DataStoreLive, HistoryTrackerLive, LoggerLive, OutputPipelineLive, PROJECT_MIGRATIONS } from "@vitest-agent/sdk";
3
3
  import { Layer } from "effect";
4
4
  import * as NodeServices from "@effect/platform-node/NodeServices";
5
5
  import { layer } from "@effect/sql-sqlite-node/SqliteClient";
@@ -13,7 +13,7 @@ import * as SqliteMigrator from "@effect/sql-sqlite-node/SqliteMigrator";
13
13
  const ReporterLive = (dbPath, logLevel, logFile) => {
14
14
  const SqliteLayer = layer({ filename: dbPath });
15
15
  const PlatformLayer = NodeServices.layer;
16
- const MigratorLayer = SqliteMigrator.layer({ loader: SqliteMigrator.fromRecord({ "0001_initial": migration0001 }) }).pipe(Layer.provide(Layer.merge(SqliteLayer, PlatformLayer)));
16
+ const MigratorLayer = SqliteMigrator.layer({ loader: SqliteMigrator.fromRecord(PROJECT_MIGRATIONS) }).pipe(Layer.provide(Layer.merge(SqliteLayer, PlatformLayer)));
17
17
  return Layer.mergeAll(DataStoreLive, CoverageAnalyzerLive, HistoryTrackerLive, OutputPipelineLive).pipe(Layer.provideMerge(DataReaderLive), Layer.provideMerge(MigratorLayer), Layer.provideMerge(SqliteLayer), Layer.provideMerge(PlatformLayer), Layer.provideMerge(LoggerLive(logLevel, logFile)));
18
18
  };
19
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/plugin",
3
- "version": "2.5.7",
3
+ "version": "3.0.0",
4
4
  "private": false,
5
5
  "description": "Vitest plugin for the vitest-agent ecosystem: owns persistence, classification, baselines, trends, and dispatches rendering to a configurable reporter.",
6
6
  "keywords": [
@@ -41,18 +41,18 @@
41
41
  "dependencies": {
42
42
  "@effect/platform-node": "4.0.0-rc.112",
43
43
  "@effect/sql-sqlite-node": "4.0.0-rc.112",
44
- "@effected/workspaces": "^0.20.0",
45
- "@vitest-agent/cli": "2.2.15",
46
- "@vitest-agent/mcp": "2.4.15",
47
- "@vitest-agent/reporter": "2.2.4",
48
- "@vitest-agent/sdk": "2.5.1",
44
+ "@effected/workspaces": "^0.20.1",
45
+ "@vitest-agent/cli": "2.2.16",
46
+ "@vitest-agent/mcp": "3.0.0",
47
+ "@vitest-agent/reporter": "3.0.0",
48
+ "@vitest-agent/sdk": "3.0.0",
49
49
  "effect": "4.0.0-rc.112",
50
50
  "magic-string": "^1.2.3"
51
51
  },
52
52
  "peerDependencies": {
53
- "@vitest/coverage-istanbul": "^4.1.0",
54
- "@vitest/coverage-v8": "^4.1.0",
55
- "vitest": "^4.1.0"
53
+ "@vitest/coverage-istanbul": "^5.0.0",
54
+ "@vitest/coverage-v8": "^5.0.0",
55
+ "vitest": "^5.0.0"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=24.11.0"
package/plugin.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { ConfigValidation } from "./services/ConfigValidation.js";
2
2
  import { resolveThresholds } from "./utils/resolve-thresholds.js";
3
3
  import { ConfigValidationLive } from "./layers/ConfigValidationLive.js";
4
+ import { ConfigurationError } from "./utils/configuration-error.js";
5
+ import { assertFlatScope } from "./utils/report-writer.js";
4
6
  import { AgentReporter } from "./reporter.js";
5
7
  import { buildModuleInfo } from "./utils/build-module-info.js";
6
8
  import { DefaultDiscoverStrategy } from "./utils/discover-strategy.js";
@@ -11,6 +13,7 @@ import { isBenignViteSourceMapWarning } from "./utils/is-benign-vite-source-map-
11
13
  import { resolveCoverageDirIsolation } from "./utils/resolve-coverage-dir-isolation.js";
12
14
  import { DEFAULT_BUILT_RECENTLY_MS, DEFAULT_LOCK_STALE_MS, DEFAULT_LOCK_WAIT_TIMEOUT_MS, acquireRunScriptLock, markRunScriptDone, parseLockTimingOverride, releaseRunScriptLock } from "./utils/run-script-lock.js";
13
15
  import { stripConsoleReporters } from "./utils/strip-console-reporters.js";
16
+ import { makeTagCacheKeyGenerator } from "./utils/tag-cache-key.js";
14
17
  import { execSync } from "node:child_process";
15
18
  import { mkdtempSync, rmSync } from "node:fs";
16
19
  import { tmpdir } from "node:os";
@@ -98,6 +101,14 @@ function resolveFormat(mode) {
98
101
  */
99
102
  const aggregatedReporterByVitest = /* @__PURE__ */ new WeakSet();
100
103
  /**
104
+ * Per-Vitest-instance guard for the `fsModuleCache` cache-key generator.
105
+ * `configureVitest` fires once per project, but the generator is global to
106
+ * the Vitest instance — register it exactly once per run.
107
+ *
108
+ * @internal
109
+ */
110
+ const cacheKeyGeneratorByVitest = /* @__PURE__ */ new WeakSet();
111
+ /**
101
112
  * Guards the coverage.reportsDirectory isolation decision (issue #194) to
102
113
  * run at most once per Vitest run — `configureVitest` fires once per
103
114
  * project, but `coverage.reportsDirectory` is root-level config shared by
@@ -114,7 +125,7 @@ const coverageDirDecidedByVitest = /* @__PURE__ */ new WeakSet();
114
125
  *
115
126
  * @public
116
127
  */
117
- const CURRENT_PLUGIN_VERSION = "2.5.7";
128
+ const CURRENT_PLUGIN_VERSION = "3.0.0";
118
129
  const TEST_FILE_DIR_RE = new RegExp(`/(?:${SRC_DIR}|${TEST_DIR})/`);
119
130
  const isTestFile = (id) => isTestFileName(id) && TEST_FILE_DIR_RE.test(id);
120
131
  /**
@@ -169,6 +180,13 @@ function AgentPlugin(options = {}, _layer) {
169
180
  const logLevel = resolveLogLevel();
170
181
  const log = logLevel !== void 0 && logLevel !== "None" ? (...args) => process.stderr.write(`[vitest-agent:plugin] ${args.map(String).join(" ")}\n`) : (..._args) => {};
171
182
  const discoverStrategyResolved = options.discoverStrategy === false ? null : options.discoverStrategy ?? new DefaultDiscoverStrategy();
183
+ const classifyForCache = (id) => {
184
+ if (!discoverStrategyResolved) return void 0;
185
+ const cleanId = id.split("?")[0] ?? id;
186
+ if (!isTestFile(cleanId)) return void 0;
187
+ const tags = discoverStrategyResolved.classify({ module: buildModuleInfo(cleanId) });
188
+ return tags.length === 0 ? void 0 : tags;
189
+ };
172
190
  const pluginObj = {
173
191
  name: "vitest-agent",
174
192
  configResolved(resolvedConfig) {
@@ -178,8 +196,16 @@ function AgentPlugin(options = {}, _layer) {
178
196
  try {
179
197
  const { vitest, project } = ctx;
180
198
  log("configureVitest called | project:", project?.name ?? "(root)");
199
+ if (discoverStrategyResolved && !cacheKeyGeneratorByVitest.has(vitest)) {
200
+ cacheKeyGeneratorByVitest.add(vitest);
201
+ ctx.defineCacheKeyGenerator(makeTagCacheKeyGenerator(classifyForCache));
202
+ log("registered fsModuleCache tag cache-key generator");
203
+ }
181
204
  const env = await Effect.runPromise(Effect.provide(Effect.flatMap(EnvironmentDetector, (d) => d.detect()), layer));
182
205
  const executor = envToExecutor(env);
206
+ const reportOption = options.report;
207
+ const reportScope = reportOption === false ? void 0 : executor === "human" && reportOption === void 0 ? void 0 : reportOption?.scope ?? "vitest-agent";
208
+ if (reportScope !== void 0) assertFlatScope(reportScope);
183
209
  const consoleMode = resolveConsoleMode(options, executor, env);
184
210
  const format = resolveFormat(consoleMode);
185
211
  const mcp = executor === "agent";
@@ -195,7 +221,7 @@ function AgentPlugin(options = {}, _layer) {
195
221
  }
196
222
  }
197
223
  if (env === "ci-github" && consoleMode !== "silent") {
198
- log("ensuring github-actions reporter is present");
224
+ log("normalizing the github-actions reporter entry");
199
225
  const withGithubActions = ensureGithubActionsReporter(vitest.config.reporters);
200
226
  vitest.config.reporters = withGithubActions;
201
227
  }
@@ -258,6 +284,7 @@ function AgentPlugin(options = {}, _layer) {
258
284
  consoleMode,
259
285
  mcp,
260
286
  githubActions,
287
+ ...reportScope !== void 0 && { reportScope },
261
288
  transport,
262
289
  ...passWithNoTests !== void 0 ? { passWithNoTests } : {},
263
290
  ...options.reporter !== void 0 && { reporter: options.reporter },
@@ -267,20 +294,19 @@ function AgentPlugin(options = {}, _layer) {
267
294
  aggregatedReporterByVitest.add(vitest);
268
295
  log("reporters after push:", vitest.config.reporters.length);
269
296
  } catch (err) {
297
+ if (err instanceof ConfigurationError) {
298
+ process.stderr.write(`${err.message}\n`);
299
+ throw err;
300
+ }
270
301
  process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
271
302
  throw err;
272
303
  }
273
304
  }
274
305
  };
275
306
  if (discoverStrategyResolved) pluginObj.transform = (code, id) => {
276
- const cleanId = id.split("?")[0] ?? id;
277
- if (!isTestFile(cleanId)) return null;
278
- const module = buildModuleInfo(cleanId);
279
- const tags = discoverStrategyResolved.classify({ module });
280
- if (tags.length === 0) return null;
281
- const rewritten = injectTags(code, [...tags]);
282
- if (rewritten === null) return null;
283
- return rewritten;
307
+ const tags = classifyForCache(id);
308
+ if (tags === void 0) return null;
309
+ return injectTags(code, [...tags]);
284
310
  };
285
311
  return pluginObj;
286
312
  }
@@ -340,9 +366,12 @@ function makeDiscoverBuilder(options) {
340
366
  full: buildPreset(CoverageLevel.full, CoverageLevel.full, true)
341
367
  });
342
368
  _AgentPlugin.COVERAGE_AUTOUPDATE = Object.freeze({
343
- standard: (n) => Math.floor(n),
344
- strict: (n) => Math.ceil(n),
345
- lenient: (n) => Math.max(0, Math.floor(n - 2))
369
+ standard: (next, _previous) => Math.floor(next),
370
+ strict: (next, _previous) => Math.ceil(next),
371
+ lenient: (next, previous) => {
372
+ const slack = Math.max(0, Math.floor(next - 2));
373
+ return typeof previous === "number" ? Math.max(previous, slack) : slack;
374
+ }
346
375
  });
347
376
  function discover(strategy) {
348
377
  if (strategy === void 0) return makeDiscoverBuilder({});
package/reporter.js CHANGED
@@ -6,9 +6,10 @@ import { captureEnvVars } from "./utils/capture-env.js";
6
6
  import { captureSettings, hashSettings } from "./utils/capture-settings.js";
7
7
  import { isPartialRun } from "./utils/is-partial-run.js";
8
8
  import { processFailure } from "./utils/process-failure.js";
9
+ import { assertReportCapable, createReportWriter } from "./utils/report-writer.js";
9
10
  import { routeRenderedOutput } from "./utils/route-rendered-output.js";
10
11
  import { stringifyFailureValue } from "./utils/stringify-failure-value.js";
11
- import { mkdirSync } from "node:fs";
12
+ import { mkdirSync, statSync } from "node:fs";
12
13
  import { dirname } from "node:path";
13
14
  import { DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, FormatSelector, HistoryTracker, OutputPipelineLive, PathResolutionLive, buildAgentReport, coerceErrorField, computeTrend, ensureMigrated, formatFatalError, historyKey, isTimeoutError, probeHostMetadataFromEnv, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
14
15
  import { Effect, Option, PubSub } from "effect";
@@ -89,6 +90,153 @@ function computeUpdatedBaselines(existing, actual, targets) {
89
90
  };
90
91
  }
91
92
  /**
93
+ * Size of a path-only attachment. Vitest has already rewritten `path`
94
+ * to its `.vitest/attachments/` location (or left an external URL), so
95
+ * a miss here is a dangling or remote descriptor, not an error.
96
+ */
97
+ const attachmentPathByteSize = (path) => {
98
+ try {
99
+ return statSync(path).size;
100
+ } catch {
101
+ return 0;
102
+ }
103
+ };
104
+ /**
105
+ * Read one property off a user-authored artifact or attachment object.
106
+ * Both carry arbitrary user data and may expose live getters that throw,
107
+ * so every read is guarded -- the same discipline `coerceErrorField`
108
+ * applies to error objects.
109
+ */
110
+ const readField = (raw, key) => {
111
+ try {
112
+ return raw[key];
113
+ } catch {
114
+ return;
115
+ }
116
+ };
117
+ /**
118
+ * Normalize one Vitest attachment onto a `TestAttachmentInput`.
119
+ *
120
+ * A `Uint8Array` body is base64-encoded and declared as such, matching
121
+ * what Vitest does for its own consumers. A string body is passed
122
+ * through with whatever encoding the producer declared. `byteSize` is
123
+ * always the size of the decoded payload: the raw array length, the
124
+ * base64-decoded length, the UTF-8 length of a text body, or the
125
+ * on-disk size of a path-only attachment.
126
+ */
127
+ const toAttachmentInput = (att) => {
128
+ const raw = att;
129
+ const contentType = readField(raw, "contentType");
130
+ const path = readField(raw, "path");
131
+ const descriptor = {
132
+ ...typeof contentType === "string" && { contentType },
133
+ ...typeof path === "string" && { path }
134
+ };
135
+ const body = readField(raw, "body");
136
+ if (body instanceof Uint8Array) return {
137
+ ...descriptor,
138
+ body: Buffer.from(body).toString("base64"),
139
+ bodyEncoding: "base64",
140
+ byteSize: body.byteLength
141
+ };
142
+ if (typeof body === "string") {
143
+ const bodyEncoding = readField(raw, "bodyEncoding") === "base64" ? "base64" : "utf-8";
144
+ return {
145
+ ...descriptor,
146
+ body,
147
+ bodyEncoding,
148
+ byteSize: Buffer.byteLength(body, bodyEncoding === "base64" ? "base64" : "utf8")
149
+ };
150
+ }
151
+ return {
152
+ ...descriptor,
153
+ byteSize: typeof path === "string" ? attachmentPathByteSize(path) : 0
154
+ };
155
+ };
156
+ const toAttachmentInputs = (attachments) => attachments.map(toAttachmentInput);
157
+ /**
158
+ * Strip the inline body off attachment inputs, leaving only the
159
+ * descriptor (`contentType`, `path`, `byteSize`).
160
+ *
161
+ * The run-event stream is a live channel a subscriber may buffer, log or
162
+ * forward; an inline body can be up to the 64 KiB cap per attachment, so
163
+ * events carry the descriptor and leave the bytes to the database. The
164
+ * persistence path keeps using `toAttachmentInputs`.
165
+ */
166
+ const toAttachmentDescriptors = (attachments) => toAttachmentInputs(attachments).map(({ body: _body, bodyEncoding: _bodyEncoding, ...descriptor }) => descriptor);
167
+ /**
168
+ * Map Vitest annotations onto `DataStore.writeAnnotations` inputs.
169
+ * @internal
170
+ */
171
+ const toAnnotationInputs = (testCaseId, annotations) => annotations.map((anno) => ({
172
+ testCaseId,
173
+ type: anno.type ?? "notice",
174
+ message: anno.message,
175
+ ...anno.location !== void 0 && {
176
+ locationFile: anno.location.file,
177
+ locationLine: anno.location.line,
178
+ locationColumn: anno.location.column
179
+ },
180
+ attachments: toAttachmentInputs(anno.attachment !== void 0 ? [anno.attachment] : [])
181
+ }));
182
+ /**
183
+ * JSON-encode an artifact's custom fields, minus the ones modelled as
184
+ * their own columns. Returns `undefined` when there is nothing to store
185
+ * or when the object resists encoding -- a throwing getter surfaced by
186
+ * `Object.entries`, or a circular structure `JSON.stringify` rejects.
187
+ * Losing the `data` blob is acceptable; aborting the whole run's
188
+ * persistence over one malformed user artifact is not.
189
+ */
190
+ const collectArtifactData = (raw, skipMessage) => {
191
+ try {
192
+ const custom = {};
193
+ for (const [key, value] of Object.entries(raw)) {
194
+ if (key === "type" || key === "location" || key === "attachments") continue;
195
+ if (key === "message" && skipMessage) continue;
196
+ custom[key] = value;
197
+ }
198
+ if (Object.keys(custom).length === 0) return void 0;
199
+ return JSON.stringify(custom);
200
+ } catch {
201
+ return;
202
+ }
203
+ };
204
+ /**
205
+ * Map Vitest test artifacts onto `DataStore.writeArtifacts` inputs.
206
+ *
207
+ * `internal:` is a Vitest-reserved type prefix; `internal:annotation`
208
+ * never reaches `artifacts()` anyway, but the guard keeps any future
209
+ * internal type out of `test_artifacts`.
210
+ * @internal
211
+ */
212
+ const toArtifactInputs = (testCaseId, artifacts) => {
213
+ const out = [];
214
+ for (const raw of artifacts) {
215
+ const rawType = readField(raw, "type");
216
+ const type = typeof rawType === "string" ? rawType : "";
217
+ if (type === "" || type.startsWith("internal:")) continue;
218
+ const location = readField(raw, "location");
219
+ const rawAttachments = readField(raw, "attachments");
220
+ const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
221
+ const message = readField(raw, "message");
222
+ const messageIsString = typeof message === "string";
223
+ const data = collectArtifactData(raw, messageIsString);
224
+ out.push({
225
+ testCaseId,
226
+ type,
227
+ ...messageIsString && { message },
228
+ ...data !== void 0 && { data },
229
+ ...location !== void 0 && {
230
+ locationFile: location.file,
231
+ locationLine: location.line,
232
+ locationColumn: location.column
233
+ },
234
+ attachments: toAttachmentInputs(attachments)
235
+ });
236
+ }
237
+ return out;
238
+ };
239
+ /**
92
240
  * Vitest Reporter that produces structured output for LLM coding agents.
93
241
  *
94
242
  * @remarks
@@ -106,6 +254,10 @@ function computeUpdatedBaselines(existing, actual, targets) {
106
254
  * results via Vitest's native `TestProject` API. In single-project mode,
107
255
  * results are written with project name "default".
108
256
  *
257
+ * Not to be confused with Vitest 5's own `AgentReporter` export from
258
+ * `vitest/node` (an alias of `MinimalReporter`) — this is
259
+ * `@vitest-agent/plugin`'s `AgentReporter`, an unrelated class.
260
+ *
109
261
  * @privateRemarks
110
262
  * The `onCoverage` hook fires **before** `onTestRunEnd` in Vitest's lifecycle.
111
263
  * Coverage data must be stashed as instance state and merged during
@@ -157,6 +309,11 @@ var AgentReporter = class {
157
309
  * @internal
158
310
  */
159
311
  _vitest = null;
312
+ /**
313
+ * Lazily-created writer for `report`-targeted output. Null when
314
+ * `reportScope` is unset (report files disabled) or before `onInit`.
315
+ */
316
+ reportWriter = null;
160
317
  coverage = null;
161
318
  logLevel;
162
319
  logFile;
@@ -254,6 +411,7 @@ var AgentReporter = class {
254
411
  githubActions,
255
412
  githubSummary: githubActions,
256
413
  githubSummaryFile: void 0,
414
+ ...options.reportScope !== void 0 ? { reportScope: options.reportScope } : {},
257
415
  ...derivedFormat !== void 0 ? { format: derivedFormat } : {},
258
416
  consoleMode,
259
417
  ...options.mcp !== void 0 ? { mcp: options.mcp } : {},
@@ -308,6 +466,10 @@ var AgentReporter = class {
308
466
  */
309
467
  async onInit(vitest) {
310
468
  this._vitest = vitest;
469
+ if (this.options.reportScope !== void 0) {
470
+ assertReportCapable(vitest);
471
+ this.reportWriter = createReportWriter(vitest, this.options.reportScope);
472
+ }
311
473
  try {
312
474
  await this.ensureDbPath();
313
475
  } catch {}
@@ -741,7 +903,10 @@ var AgentReporter = class {
741
903
  modulePath,
742
904
  testName: testCase.name,
743
905
  suitePath: this.collectSuitePath(testCase),
744
- annotation: annotation.message
906
+ annotation: annotation.message,
907
+ annotationType: annotation.type ?? "notice",
908
+ ...annotation.location !== void 0 && { location: annotation.location },
909
+ attachments: toAttachmentDescriptors(annotation.attachment !== void 0 ? [annotation.attachment] : [])
745
910
  });
746
911
  }
747
912
  /**
@@ -751,12 +916,16 @@ var AgentReporter = class {
751
916
  if (!this.wantsRunEvents()) return;
752
917
  const modulePath = testCase.module?.relativeModuleId ?? "";
753
918
  if (modulePath === "") return;
919
+ const type = artifact.type ?? "";
920
+ if (type === "" || type.startsWith("internal:")) return;
754
921
  this.emit({
755
922
  _tag: "TestArtifactRecorded",
756
923
  modulePath,
757
924
  testName: testCase.name,
758
925
  suitePath: this.collectSuitePath(testCase),
759
- artifact: artifact.type ?? "artifact"
926
+ artifact: type,
927
+ ...artifact.location !== void 0 && { location: artifact.location },
928
+ attachments: toAttachmentDescriptors(artifact.attachments ?? [])
760
929
  });
761
930
  }
762
931
  /**
@@ -869,6 +1038,7 @@ var AgentReporter = class {
869
1038
  const opts = this.options;
870
1039
  const stashedCoverage = this.coverage;
871
1040
  const stashedVitest = this._vitest;
1041
+ const reportWriter = this.reportWriter;
872
1042
  const logLevel = this.logLevel;
873
1043
  const logFile = this.logFile;
874
1044
  const runEvents = this.runEvents;
@@ -964,11 +1134,15 @@ var AgentReporter = class {
964
1134
  classifications: /* @__PURE__ */ new Map()
965
1135
  };
966
1136
  const allOutputs = reporters.flatMap((r) => r.render(renderInput, kit));
967
- for (const output of allOutputs) routeRenderedOutput(output, { ...githubSummaryFile !== void 0 && { githubSummaryFile } });
1137
+ for (const output of allOutputs) routeRenderedOutput(output, {
1138
+ ...githubSummaryFile !== void 0 && { githubSummaryFile },
1139
+ ...reportWriter !== null && { writeReport: reportWriter.write }
1140
+ });
968
1141
  });
969
1142
  await Effect.runPromise(uiProgram.pipe(Effect.provide(OutputPipelineLive), Effect.provide(NodeServices.layer))).catch((err) => {
970
1143
  process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
971
1144
  });
1145
+ await reportWriter?.flush();
972
1146
  return;
973
1147
  }
974
1148
  if (dbPath !== void 0 && persistDisabled === void 0) try {
@@ -1167,8 +1341,8 @@ var AgentReporter = class {
1167
1341
  let testIdx = 0;
1168
1342
  for (const testCase of mod.children.allTests()) {
1169
1343
  const result = testCase.result();
1344
+ const testCaseId = testCaseIds[testIdx];
1170
1345
  if (result?.errors && result.errors.length > 0) {
1171
- const testCaseId = testCaseIds[testIdx];
1172
1346
  const inputs = [];
1173
1347
  for (let ordinal = 0; ordinal < result.errors.length; ordinal++) {
1174
1348
  const e = result.errors[ordinal];
@@ -1202,6 +1376,10 @@ var AgentReporter = class {
1202
1376
  }
1203
1377
  yield* store.writeErrors(runId, inputs);
1204
1378
  }
1379
+ const annotationInputs = toAnnotationInputs(testCaseId, testCase.annotations?.() ?? []);
1380
+ if (annotationInputs.length > 0) yield* store.writeAnnotations(runId, annotationInputs);
1381
+ const artifactInputs = toArtifactInputs(testCaseId, testCase.artifacts?.() ?? []);
1382
+ if (artifactInputs.length > 0) yield* store.writeArtifacts(runId, artifactInputs);
1205
1383
  testIdx++;
1206
1384
  }
1207
1385
  const modErrors = mod.errors();
@@ -1506,15 +1684,19 @@ var AgentReporter = class {
1506
1684
  reporterCount: reporters.length,
1507
1685
  outputs: allOutputs.length
1508
1686
  }));
1509
- for (const output of allOutputs) routeRenderedOutput(output, { ...githubSummaryFile !== void 0 && { githubSummaryFile } });
1687
+ for (const output of allOutputs) routeRenderedOutput(output, {
1688
+ ...githubSummaryFile !== void 0 && { githubSummaryFile },
1689
+ ...reportWriter !== null && { writeReport: reportWriter.write }
1690
+ });
1510
1691
  });
1511
1692
  await Effect.runPromise(renderProgram.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(OutputPipelineLive), Effect.provide(NodeServices.layer))).catch((err) => {
1512
1693
  process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
1513
1694
  });
1695
+ await reportWriter?.flush();
1514
1696
  const persistError = persistDisabled ?? persistFailure;
1515
1697
  if (persistError !== void 0) process.stderr.write(`vitest-agent: persistence failed — results above were rendered but NOT recorded: ${persistError}\n`);
1516
1698
  }
1517
1699
  };
1518
1700
 
1519
1701
  //#endregion
1520
- export { AgentReporter };
1702
+ export { AgentReporter, toAnnotationInputs, toArtifactInputs };
@@ -0,0 +1,19 @@
1
+ //#region src/utils/configuration-error.ts
2
+ /**
3
+ * A mistake in the user's own `AgentPlugin` configuration — a bad report
4
+ * scope, an unsafe report filename — as opposed to an internal failure of
5
+ * the plugin.
6
+ *
7
+ * `configureVitest` reports these as a single `vitest-agent: <message>`
8
+ * line on stderr, with no stack trace and no "please report an issue"
9
+ * banner: there is nothing to report, the user simply needs to fix the
10
+ * config. Every other throw keeps the `formatFatalError` treatment.
11
+ *
12
+ * @public
13
+ */
14
+ var ConfigurationError = class extends Error {
15
+ name = "ConfigurationError";
16
+ };
17
+
18
+ //#endregion
19
+ export { ConfigurationError };
@@ -1,23 +1,50 @@
1
1
  //#region src/utils/ensure-github-reporter.ts
2
2
  /**
3
- * Ensure a Vitest `reporters` array contains a `github-actions` entry.
3
+ * Ensure a Vitest `reporters` array contains a `github-actions` entry with
4
+ * its markdown job summary disabled.
4
5
  *
5
6
  * @privateRemarks
6
- * Vitest only auto-appends its built-in `"github-actions"` reporter when
7
- * the resolved `reporters` array is EMPTY. Once any other reporter is
8
- * configured (which the plugin always does), that implicit behavior no
9
- * longer applies, so `AgentPlugin` calls this explicitly under
10
- * `env === "ci-github"` to guarantee the entry is present.
7
+ * Under Vitest 5 `github-actions` is seeded into `configDefaults.reporters`
8
+ * whenever `GITHUB_ACTIONS=true`, and `resolveConfig` normalizes every bare
9
+ * reporter name to a `[name, {}]` tuple. So by the time `configureVitest`
10
+ * runs, the common CI case already holds `["github-actions", {}]` an entry
11
+ * that still writes a markdown job summary, because the reporter's own
12
+ * default is `jobSummary.enabled = true`. The plugin writes its own step
13
+ * summary to `$GITHUB_STEP_SUMMARY` under `env === "ci-github"`, so leaving
14
+ * that entry alone produces two reports in the same job.
15
+ *
16
+ * This function therefore NORMALIZES an existing entry rather than only
17
+ * appending a missing one: a bare string or a tuple whose options leave
18
+ * `jobSummary.enabled` unset becomes
19
+ * `["github-actions", { jobSummary: { enabled: false } }]`, with every other
20
+ * option preserved. The plugin owns the summary, the reporter owns the
21
+ * `::error::` annotations. An explicit `jobSummary.enabled === true` is a
22
+ * deliberate opt-in and is left untouched; the `GITHUB_JOB_SUMMARY_COLLISION`
23
+ * ConfigValidation rule warns about that case instead.
11
24
  *
12
25
  * @internal
13
26
  */
14
27
  function ensureGithubActionsReporter(reporters) {
15
- if (reporters.some((entry) => {
16
- if (typeof entry === "string") return entry === "github-actions";
17
- if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0] === "github-actions";
18
- return false;
19
- })) return reporters;
20
- return [...reporters, ["github-actions", {}]];
28
+ let found = false;
29
+ const normalized = reporters.map((entry) => {
30
+ if (entry === "github-actions") {
31
+ found = true;
32
+ return ["github-actions", { jobSummary: { enabled: false } }];
33
+ }
34
+ if (!Array.isArray(entry) || entry[0] !== "github-actions") return entry;
35
+ found = true;
36
+ const options = entry[1] ?? {};
37
+ if (options.jobSummary?.enabled === true) return entry;
38
+ return ["github-actions", {
39
+ ...options,
40
+ jobSummary: {
41
+ ...options.jobSummary,
42
+ enabled: false
43
+ }
44
+ }];
45
+ });
46
+ if (found) return normalized;
47
+ return [...reporters, ["github-actions", { jobSummary: { enabled: false } }]];
21
48
  }
22
49
 
23
50
  //#endregion
@@ -0,0 +1,83 @@
1
+ import { ConfigurationError } from "./configuration-error.js";
2
+
3
+ //#region src/utils/report-writer.ts
4
+ /**
5
+ * Lazily-created writer over Vitest 5's `createReport` scope directory.
6
+ *
7
+ * `vitest.createReport(scope)` mkdirs `<config.root>/.vitest/<scope>`
8
+ * eagerly and synchronously at call time, so the handle is created on
9
+ * the first write rather than at reporter construction — a run that
10
+ * emits no report-targeted output leaves no directory behind.
11
+ *
12
+ * `clean()` is never called: it would wipe a prior shard's output, and
13
+ * it is a no-op under `--merge-reports` anyway.
14
+ *
15
+ * @internal
16
+ */
17
+ const VITEST_5_REQUIRED = "vitest-agent: writing report files requires Vitest 5's `createReport` API. Upgrade vitest to ^5.0.0, or set `AgentPlugin({ report: false })`.";
18
+ /**
19
+ * Narrow a Vitest instance to one carrying `createReport`, throwing the
20
+ * upgrade message when it does not.
21
+ *
22
+ * Called eagerly from `onInit` so an unsupported Vitest fails before any
23
+ * rendering happens, rather than mid-way through a routing loop. It only
24
+ * reads the property — `createReport` itself stays uncalled, preserving
25
+ * the lazy directory creation.
26
+ *
27
+ * @internal
28
+ */
29
+ const assertReportCapable = (vitest) => {
30
+ if (typeof vitest?.createReport !== "function") throw new Error(VITEST_5_REQUIRED);
31
+ };
32
+ /**
33
+ * Vitest's `Report.writeFile` resolves `filename` against the scope
34
+ * directory with no `mkdir` and no containment check, so a nested path
35
+ * rejects at write time and a `..` segment escapes the directory
36
+ * entirely. Reject both here, where the message can name the culprit.
37
+ */
38
+ const assertFlatFilename = (filename) => {
39
+ if (filename.includes("/") || filename.includes("\\") || filename === ".." || filename === ".") throw new ConfigurationError(`vitest-agent: report filename ${filename} must be a flat name — Vitest writes it directly into the report scope directory and creates no intermediate directories.`);
40
+ };
41
+ /**
42
+ * The report scope names a single directory under `<config.root>/.vitest`.
43
+ * Vitest joins it without a containment check, so a `..` segment or a
44
+ * path separator escapes `.vitest` entirely, and an empty scope resolves
45
+ * to `.vitest` itself. Reject all of those here, where the message can
46
+ * name the offending scope.
47
+ *
48
+ * A dot inside the name (`vitest.agent`) is fine — only the bare `.` and
49
+ * `..` traversal names are rejected.
50
+ *
51
+ * @internal
52
+ */
53
+ const assertFlatScope = (scope) => {
54
+ if (scope === "" || scope.includes("/") || scope.includes("\\") || scope === "." || scope === "..") throw new ConfigurationError(`vitest-agent: report scope ${JSON.stringify(scope)} must be a single flat directory name — it is created directly under <root>/.vitest and must not traverse or nest.`);
55
+ };
56
+ /** @internal */
57
+ const createReportWriter = (vitest, scope) => {
58
+ let handle = null;
59
+ const pending = [];
60
+ const resolveHandle = () => {
61
+ if (handle !== null) return handle;
62
+ const create = vitest?.createReport;
63
+ if (typeof create !== "function") throw new Error(VITEST_5_REQUIRED);
64
+ handle = create.call(vitest, scope);
65
+ return handle;
66
+ };
67
+ return {
68
+ write: (filename, content) => {
69
+ assertFlatFilename(filename);
70
+ const report = resolveHandle();
71
+ pending.push(report.writeFile(filename, content).catch((err) => {
72
+ process.stderr.write(`vitest-agent: report file ${filename} not written: ${String(err)}\n`);
73
+ }));
74
+ },
75
+ flush: async () => {
76
+ await Promise.all(pending);
77
+ pending.length = 0;
78
+ }
79
+ };
80
+ };
81
+
82
+ //#endregion
83
+ export { assertFlatScope, assertReportCapable, createReportWriter };
@@ -12,6 +12,25 @@ const RESERVED_KEYS = /* @__PURE__ */ new Set([
12
12
  "autoUpdate"
13
13
  ]);
14
14
  /**
15
+ * Extract the four metric numbers (plus the `100: true` shorthand) from a
16
+ * raw threshold-shaped record. Shared by the global, glob-pattern, and
17
+ * object-valued `perFile` paths.
18
+ */
19
+ function extractMetrics(obj) {
20
+ const metrics = {};
21
+ if (obj["100"] === true) {
22
+ metrics.lines = 100;
23
+ metrics.functions = 100;
24
+ metrics.branches = 100;
25
+ metrics.statements = 100;
26
+ }
27
+ for (const mk of METRIC_KEYS) {
28
+ const mv = obj[mk];
29
+ if (typeof mv === "number") metrics[mk] = mv;
30
+ }
31
+ return metrics;
32
+ }
33
+ /**
15
34
  * Parse Vitest `coverage.thresholds` format into a normalized `ResolvedThresholds`.
16
35
  * @param input - The raw `coverage.thresholds` object from Vitest config
17
36
  * @returns Normalized thresholds with global, perFile, and pattern entries
@@ -37,21 +56,14 @@ function resolveThresholds(input) {
37
56
  if (typeof value === "number") global[key] = value;
38
57
  }
39
58
  if (input.perFile === true) perFile = true;
59
+ else if (typeof input.perFile === "object" && input.perFile !== null && !Array.isArray(input.perFile)) perFile = extractMetrics(input.perFile);
40
60
  for (const [key, value] of Object.entries(input)) {
41
61
  if (RESERVED_KEYS.has(key)) continue;
42
62
  if (typeof value !== "object" || value === null) continue;
43
- const patternMetrics = {};
44
63
  const obj = value;
45
- if (obj["100"] === true) {
46
- patternMetrics.lines = 100;
47
- patternMetrics.functions = 100;
48
- patternMetrics.branches = 100;
49
- patternMetrics.statements = 100;
50
- }
51
- for (const mk of METRIC_KEYS) {
52
- const mv = obj[mk];
53
- if (typeof mv === "number") patternMetrics[mk] = mv;
54
- }
64
+ const patternMetrics = extractMetrics(obj);
65
+ if (obj.perFile === true || obj.perFile === false) patternMetrics.perFile = obj.perFile;
66
+ else if (typeof obj.perFile === "object" && obj.perFile !== null && !Array.isArray(obj.perFile)) patternMetrics.perFile = extractMetrics(obj.perFile);
55
67
  if (Object.keys(patternMetrics).length > 0) patterns.push([key, patternMetrics]);
56
68
  }
57
69
  return {
@@ -16,6 +16,11 @@ import { dirname } from "node:path";
16
16
  * GitHub Actions and the user-supplied reporter shouldn't have produced
17
17
  * one anyway."
18
18
  *
19
+ * `report` outputs carry their own `filename` and go to the plugin's
20
+ * `writeReport` sink — Vitest 5's `createReport` scope directory. When
21
+ * report files are disabled the sink is absent and the output is
22
+ * dropped, mirroring the `github-summary` contract.
23
+ *
19
24
  * `file` outputs require an explicit path embedded in the output (a
20
25
  * convention reporters should adopt; this helper currently treats `file`
21
26
  * as a no-op until we settle on a path field). The default reporter
@@ -37,6 +42,13 @@ const routeRenderedOutput = (output, options) => {
37
42
  } catch {}
38
43
  return;
39
44
  }
45
+ case "report":
46
+ try {
47
+ options.writeReport?.(output.filename, output.content);
48
+ } catch (err) {
49
+ process.stderr.write(`vitest-agent: report file ${output.filename} not written: ${String(err)}\n`);
50
+ }
51
+ return;
40
52
  case "file": return;
41
53
  }
42
54
  };
@@ -4,9 +4,12 @@
4
4
  * These are the reporters suppressed when an agent takes over console output.
5
5
  *
6
6
  * @privateRemarks
7
- * `"agent"` is the built-in Vitest reporter added in v4.1 that reduces
8
- * console noise for AI agents. We strip it because our reporter replaces
9
- * its functionality with structured markdown output.
7
+ * `"agent"` and `"minimal"` both resolve to Vitest's `MinimalReporter`
8
+ * class. `"agent"` is the v4.1 spelling; `"minimal"` is the v5 spelling
9
+ * and is what `configDefaults.reporters` selects whenever `std-env`'s
10
+ * `isAgent` is true — which is the vitest-agent primary use case. Both
11
+ * are stripped because our reporter replaces their functionality with
12
+ * structured markdown output.
10
13
  *
11
14
  * @see {@link https://vitest.dev/api/advanced/reporters.html | Vitest Reporter docs}
12
15
  * @internal
@@ -19,7 +22,8 @@ const CONSOLE_REPORTERS = /* @__PURE__ */ new Set([
19
22
  "tap",
20
23
  "tap-flat",
21
24
  "hanging-process",
22
- "agent"
25
+ "agent",
26
+ "minimal"
23
27
  ]);
24
28
  /**
25
29
  * Filter out built-in console reporters from a Vitest reporters array.
@@ -0,0 +1,30 @@
1
+ //#region src/utils/tag-cache-key.ts
2
+ /**
3
+ * Cache-key material for Vitest 5's `fsModuleCache`.
4
+ *
5
+ * The tag-injection transform's output depends on the classification tag
6
+ * set, which Vitest cannot see. Returning this string from a
7
+ * `defineCacheKeyGenerator` callback folds the tag set into the module
8
+ * cache key, so a changed tag set invalidates the cached prelude.
9
+ *
10
+ * @internal
11
+ */
12
+ function tagCacheKey(tags) {
13
+ return `vitest-agent:tags:${[...tags].sort().join(",")}`;
14
+ }
15
+ /**
16
+ * Builds the `defineCacheKeyGenerator` callback from the same per-id
17
+ * classification the `transform` hook uses. Ids the transform would not
18
+ * rewrite contribute nothing to the cache key.
19
+ *
20
+ * @internal
21
+ */
22
+ function makeTagCacheKeyGenerator(classify) {
23
+ return (context) => {
24
+ const tags = classify(context.id);
25
+ return tags === void 0 ? void 0 : tagCacheKey(tags);
26
+ };
27
+ }
28
+
29
+ //#endregion
30
+ export { makeTagCacheKeyGenerator, tagCacheKey };