@vitest-agent/plugin 5.4.9 → 5.6.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,6 +1,6 @@
1
1
  import "@effected/env";
2
2
  import { EnvironmentDetector, PlatformLiveError, PlatformOptions, PlatformServices, SettingsInput, StackFrameInput } from "@vitest-agent/engine";
3
- import { AgentPluginOptions, AgentReporterOptions, ConsoleMode, CoverageBaselines, CoverageInput, CoverageLevel, CoverageLevelName, CoverageLevelName as CoverageLevelName$1, CoverageReport, ResolvedReporterConfig, ResolvedThresholds, RunEvent, Transport, VitestAgentReporterFactory, resolveCoverageInput, validateCoverageConfig } from "@vitest-agent/sdk";
3
+ import { AgentPluginOptions, AgentReporterOptions, ConsoleMode, CoverageBaselines, CoverageInput, CoverageLevel, CoverageLevelName, CoverageLevelName as CoverageLevelName$1, CoverageReport, ResolvedReporterConfig, ResolvedThresholds, RunEvent, RunRetentionPolicy, SuggestedActionRecord, Transport, VitestAgentReporterFactory, resolveCoverageInput, validateCoverageConfig } from "@vitest-agent/sdk";
4
4
  import { Context, Effect, Layer, Option } from "effect";
5
5
  import { TestProjectInlineConfiguration, TestTagDefinition } from "vitest/config";
6
6
  import { ResolvedConfig, VitestPluginContext } from "vitest/node";
@@ -263,7 +263,51 @@ interface DiscoverProjectsOptions {
263
263
  * Forwarded to `@effected/workspaces`' `getWorkspacePackagesSync`.
264
264
  */
265
265
  readonly maxDepth?: number;
266
+ /**
267
+ * Per-project `test` overrides keyed by project name (the workspace
268
+ * package name, or an `additionalEntries` name). Each value is shallow-merged
269
+ * over the generated project's `test` block after the strategy has built it;
270
+ * `name` is never replaced. A key naming no discovered project throws, so a
271
+ * typo cannot silently leave a project on its defaults. Supplying this
272
+ * bypasses the process-level result cache.
273
+ */
274
+ readonly configure?: ProjectConfigureMap;
266
275
  }
276
+ /**
277
+ * The Vitest `test` fields a `configure` entry may set on one generated
278
+ * project — everything Vitest accepts on a project's inline `test` block
279
+ * except `name`, which stays the discovered project's own.
280
+ *
281
+ * @remarks
282
+ * The typical use is pool scheduling per project: `{ isolate: false }` for a
283
+ * package whose suites share no module-level state, while a package that
284
+ * spawns processes or calls `process.chdir()` keeps the root's forks plus
285
+ * isolation (issue #626). The merge is shallow: an `include` or `exclude` here
286
+ * replaces the strategy's list rather than appending to it.
287
+ * @public
288
+ */
289
+ type ProjectTestOverrides = Omit<NonNullable<TestProjectInlineConfiguration["test"]>, "name">;
290
+ /**
291
+ * Project name to {@link ProjectTestOverrides}, as passed to `configure`.
292
+ * @public
293
+ */
294
+ type ProjectConfigureMap = Readonly<Record<string, ProjectTestOverrides>>;
295
+ /**
296
+ * Shallow-merges each `overrides` entry into the generated project whose
297
+ * `test.name` equals the entry's key. Pure: returns new project objects for the
298
+ * configured names and the original objects for the rest; the discovered
299
+ * `name` always wins over one carried by the override. Only a string
300
+ * `test.name` can be keyed; a project named through Vitest's object form is
301
+ * left untouched (and a key for it is reported as unknown).
302
+ *
303
+ * Throws when a key names no project in `configs`, listing the discovered
304
+ * names so the typo is visible at config-load time.
305
+ * @param configs - The projects a strategy produced
306
+ * @param overrides - Per-project `test` overrides keyed by project name
307
+ * @returns The projects with overrides applied
308
+ * @public
309
+ */
310
+ export declare function applyProjectOverrides(configs: ReadonlyArray<TestProjectInlineConfiguration>, overrides: ProjectConfigureMap): TestProjectInlineConfiguration[];
267
311
  /**
268
312
  * Scan all workspace packages and additional entries through the active strategy and return projects + tags.
269
313
  * @param options - Optional strategy, working directory, and extra project entries
@@ -464,16 +508,28 @@ export declare namespace AgentPlugin {
464
508
  * };
465
509
  * ```
466
510
  *
511
+ * Per-project `test` overrides ride the options object's `configure` map,
512
+ * keyed by project name and shallow-merged over the generated project's
513
+ * `test` block (`name` is never replaced); an unknown key throws. The
514
+ * typical use is per-package pool scheduling (issue #626):
515
+ *
516
+ * ```ts
517
+ * const { projects, tags } = await AgentPlugin.discover({
518
+ * configure: { "@acme/pure-lib": { isolate: false } },
519
+ * });
520
+ * ```
521
+ *
467
522
  * @param strategy - Optional strategy or options object
468
- * `{ strategy?, cwd?, maxDepth? }`.
523
+ * `{ strategy?, cwd?, maxDepth?, configure? }`.
469
524
  * Pass a {@link DiscoverStrategy} directly, or an options object to also
470
- * specify a custom workspace root via `cwd` and deeper package traversal via
471
- * `maxDepth`.
525
+ * specify a custom workspace root via `cwd`, deeper package traversal via
526
+ * `maxDepth`, and per-project `test` overrides via `configure`.
472
527
  */
473
528
  function discover(strategy?: DiscoverStrategy | {
474
529
  strategy?: DiscoverStrategy;
475
530
  cwd?: string;
476
531
  maxDepth?: number;
532
+ configure?: ProjectConfigureMap;
477
533
  }): DiscoverBuilder;
478
534
  /**
479
535
  * Run a shell command, suppressing all output unless the command fails.
@@ -545,6 +601,18 @@ interface AgentReporterConstructorOptions extends AgentReporterOptions {
545
601
  * @internal
546
602
  */
547
603
  coverageMode?: "full" | "ui-only";
604
+ /**
605
+ * Whether the run is recorded in SQLite (issue #625). The plugin
606
+ * resolves it from `AgentPlugin({ persist })` and the
607
+ * `VITEST_AGENT_PERSIST` operator override; defaults to `true` when
608
+ * `AgentReporter` is constructed directly. `false` routes
609
+ * `onTestRunEnd` through the same DB-free path UI-only mode uses —
610
+ * every render and `RunEvent` still happens, coverage is still
611
+ * analyzed, and `data.db` is never resolved, created or opened.
612
+ *
613
+ * @internal
614
+ */
615
+ persist?: boolean;
548
616
  consoleMode?: ConsoleMode;
549
617
  mcp?: boolean;
550
618
  githubActions?: boolean;
@@ -563,6 +631,17 @@ interface AgentReporterConstructorOptions extends AgentReporterOptions {
563
631
  * `run_tests` tool.
564
632
  */
565
633
  passWithNoTests?: boolean;
634
+ /**
635
+ * Run-level suggested actions the plugin resolved at config time — the
636
+ * `ConfigValidation` warnings and info entries, mapped onto
637
+ * `SuggestedActionRecord`s (issue #544). Forwarded verbatim onto
638
+ * `ReporterRenderInput.suggestedActions` for every render, so the
639
+ * `Actions:` trailer repeats them in watch mode. Absent when the config
640
+ * validated clean.
641
+ *
642
+ * @internal
643
+ */
644
+ suggestedActions?: ReadonlyArray<SuggestedActionRecord>;
566
645
  coverageThresholds?: ResolvedThresholds | Record<string, unknown>;
567
646
  coverageTargets?: ResolvedThresholds | Record<string, unknown>;
568
647
  /**
@@ -574,6 +653,17 @@ interface AgentReporterConstructorOptions extends AgentReporterOptions {
574
653
  * @internal
575
654
  */
576
655
  cacheDir?: string;
656
+ /**
657
+ * Run-retention override (issue 623). A `RunRetentionPolicy` is applied
658
+ * as given; `false` disables run pruning for this reporter; absent means
659
+ * the policy the `[retention]` table of `vitest-agent.config.toml`
660
+ * resolves to (the default of `RunRetentionPolicy.DEFAULT_KEEP_RUNS`
661
+ * newest runs per project when the table is absent). Takes precedence
662
+ * over the TOML file, like `cacheDir` does for the database path.
663
+ *
664
+ * @internal
665
+ */
666
+ retention?: RunRetentionPolicy | false;
577
667
  }
578
668
  /**
579
669
  * Vitest Reporter that produces structured output for LLM coding agents.
@@ -628,14 +718,17 @@ export declare class AgentReporter {
628
718
  private options;
629
719
  private dbPath;
630
720
  /**
631
- * Set to `true` after the first `onTestRunEnd` invocation completes.
721
+ * Set to `true` once `onTestRunEnd` has done this run's work.
632
722
  *
633
- * Vitest can fire `onTestRunEnd` more than once per `vitest run` in
634
- * some multi-project configurations (e.g., once per project group, with
635
- * the same `testModules` array each time). The plugin pushes only ONE
636
- * AgentReporter per run, so a fresh instance always starts with this
637
- * flag `false`; the first call does the work and the flag prevents
638
- * subsequent invocations from re-rendering and re-writing to the DB.
723
+ * Vitest can fire `onTestRunEnd` more than once per run in some
724
+ * multi-project configurations (e.g., once per project group, with the
725
+ * same `testModules` array each time). The first call does the work and
726
+ * the flag prevents subsequent invocations from re-rendering and
727
+ * re-writing to the DB. The flag is PER RUN, not per instance: Vitest
728
+ * keeps one reporter instance alive across watch-mode reruns, so
729
+ * `onTestRunStart` re-arms it — otherwise every rerun after the first
730
+ * would silently skip persistence, rendering and `RunFinished`
731
+ * (issue #576).
639
732
  */
640
733
  private rendered;
641
734
  /**
@@ -1270,6 +1363,21 @@ interface CoverageOptions {
1270
1363
  * production; forward or back slashes both work).
1271
1364
  */
1272
1365
  readonly root?: string;
1366
+ /**
1367
+ * Vitest's resolved `coverage.include` globs. When set, a coverage-map
1368
+ * key matching none of them is dropped before any per-file or glob
1369
+ * evaluation (issue #622). Matched like threshold patterns: against
1370
+ * `relative(root, key)` when `root` is set, verbatim otherwise. Absent,
1371
+ * every key is kept. A path under a `node_modules` segment is dropped
1372
+ * regardless of this option.
1373
+ */
1374
+ readonly include?: ReadonlyArray<string>;
1375
+ /**
1376
+ * Vitest's resolved `coverage.exclude` globs. A coverage-map key matching
1377
+ * any of them is dropped before evaluation (issue #622), with the same
1378
+ * matching rule as `include`.
1379
+ */
1380
+ readonly exclude?: ReadonlyArray<string>;
1273
1381
  }
1274
1382
  declare const CoverageAnalyzer_base: Context.ServiceClass<CoverageAnalyzer, "vitest-agent/CoverageAnalyzer", {
1275
1383
  readonly process: (coverage: unknown, options: CoverageOptions) => Effect.Effect<Option.Option<CoverageReport>>;
@@ -1490,5 +1598,5 @@ export declare const COVERAGE_AUTOUPDATE: Readonly<{
1490
1598
  strict: (next: number, previous: number) => number;
1491
1599
  lenient: (next: number, previous: number) => number;
1492
1600
  }>;
1493
- 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 };
1601
+ 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 ProjectConfigureMap, type ProjectTestOverrides, 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 };
1494
1602
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -13,7 +13,7 @@ import { nodeWalkerFs } from "./utils/walker-fs.js";
13
13
  import { findTestFiles } from "./utils/find-test-files.js";
14
14
  import { Tag } from "./utils/tag.js";
15
15
  import { DefaultDiscoverStrategy, DiscoverStrategy } from "./utils/discover-strategy.js";
16
- import { discoverProjects } from "./utils/discover-projects.js";
16
+ import { applyProjectOverrides, discoverProjects } from "./utils/discover-projects.js";
17
17
  import { CONSOLE_REPORTERS, stripConsoleReporters } from "./utils/strip-console-reporters.js";
18
18
  import { CURRENT_PLUGIN_VERSION } from "./version.js";
19
19
  import { AgentPlugin } from "./plugin.js";
@@ -30,4 +30,4 @@ const COVERAGE_LEVELS_PER_FILE = AgentPlugin.COVERAGE_LEVELS_PER_FILE;
30
30
  /** Auto-update tolerance functions for `coverage.thresholds.autoUpdate`. Mirrors `AgentPlugin.COVERAGE_AUTOUPDATE`. @public */
31
31
  const COVERAGE_AUTOUPDATE = AgentPlugin.COVERAGE_AUTOUPDATE;
32
32
 
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 };
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, applyProjectOverrides, captureEnvVars, captureSettings, classifyByDirectory, classifyByFilename, combineClassifiers, discoverProjects, findTestFiles, hashSettings, nodeWalkerFs, processFailure, resolveCoverageInput, resolveThresholds, stripConsoleReporters, validateCoverageConfig };
@@ -104,6 +104,28 @@ function matchGlob(filePath, pattern) {
104
104
  return compiled?.matches(filePath) ?? false;
105
105
  }
106
106
  /**
107
+ * True when a path has a `node_modules` directory segment anywhere in it,
108
+ * under either separator. A dependency is never one of the project's own
109
+ * sources, whatever Vitest's untested-files glob swept into the map
110
+ * (issue #622: `rolldown-pnpm-config` under `node_modules` reached
111
+ * `file_coverage` on spencerbeggs/effected).
112
+ */
113
+ function isNodeModulesPath(filePath) {
114
+ return /(?:^|\/)node_modules(?:\/|$)/.test(toPosixPath(filePath));
115
+ }
116
+ /**
117
+ * Decide whether a coverage-map key is one of the project's sources: not
118
+ * under `node_modules`, inside `include` when given, and outside `exclude`
119
+ * when given. `matchPath` is the glob-matching form of the key (see
120
+ * `CoverageOptions.root`).
121
+ */
122
+ function isProjectSource(filePath, matchPath, options) {
123
+ if (isNodeModulesPath(filePath)) return false;
124
+ const { include, exclude } = options;
125
+ if (include !== void 0 && include.length > 0 && !include.some((pattern) => matchGlob(matchPath, pattern))) return false;
126
+ return !exclude?.some((pattern) => matchGlob(matchPath, pattern));
127
+ }
128
+ /**
107
129
  * Resolve the effective thresholds for a file path by checking pattern
108
130
  * overrides first, falling back to global thresholds.
109
131
  */
@@ -213,6 +235,7 @@ function processCoverageInternal(coverageMap, options, testedFiles) {
213
235
  const mapSummaries = [];
214
236
  for (const filePath of coverageMap.files()) {
215
237
  const matchPath = matchKey(filePath);
238
+ if (!isProjectSource(filePath, matchPath, options)) continue;
216
239
  const fileCoverage = coverageMap.fileCoverageFor(filePath);
217
240
  const fileSummary = fileCoverage.toSummary();
218
241
  mapSummaries.push({
@@ -293,4 +316,4 @@ const CoverageAnalyzerLive = Layer.succeed(CoverageAnalyzer, {
293
316
  })
294
317
  });
295
318
 
296
- export { CoverageAnalyzerLive };
319
+ export { CoverageAnalyzerLive, isNodeModulesPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/plugin",
3
- "version": "5.4.9",
3
+ "version": "5.6.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": [
@@ -49,11 +49,11 @@
49
49
  "@effected/glob": "^0.14.0",
50
50
  "@effected/walker": "^0.19.0",
51
51
  "@effected/workspaces": "^0.38.0",
52
- "@vitest-agent/cli": "4.0.13",
53
- "@vitest-agent/engine": "1.1.2",
54
- "@vitest-agent/mcp": "5.2.9",
55
- "@vitest-agent/reporter": "5.0.8",
56
- "@vitest-agent/sdk": "7.0.5",
52
+ "@vitest-agent/cli": "4.1.1",
53
+ "@vitest-agent/engine": "2.0.0",
54
+ "@vitest-agent/mcp": "5.3.0",
55
+ "@vitest-agent/reporter": "6.0.0",
56
+ "@vitest-agent/sdk": "8.0.0",
57
57
  "effect": "^4.0.6",
58
58
  "magic-string": "^1.4.3"
59
59
  },
package/plugin.js CHANGED
@@ -6,12 +6,14 @@ import { assertFlatScope } from "./utils/report-writer.js";
6
6
  import { installStrayOutputCapture } from "./utils/stray-output-capture.js";
7
7
  import { AgentReporter } from "./reporter.js";
8
8
  import { buildModuleInfo } from "./utils/build-module-info.js";
9
+ import { publishConfigSuggestedActions, suggestedActionsFromValidation } from "./utils/config-suggested-actions.js";
9
10
  import { DefaultDiscoverStrategy } from "./utils/discover-strategy.js";
10
11
  import { discoverProjects } from "./utils/discover-projects.js";
11
12
  import { ensureGithubActionsReporter } from "./utils/ensure-github-reporter.js";
12
13
  import { injectTags } from "./utils/inject-tags.js";
13
14
  import { isBenignViteSourceMapWarning } from "./utils/is-benign-vite-source-map-warning.js";
14
15
  import { resolveCoverageDirIsolation } from "./utils/resolve-coverage-dir-isolation.js";
16
+ import { PERSIST_ENV_VAR, resolvePersistence } from "./utils/resolve-persistence.js";
15
17
  import { DEFAULT_BUILT_RECENTLY_MS, DEFAULT_LOCK_STALE_MS, DEFAULT_LOCK_WAIT_TIMEOUT_MS, acquireRunScriptLock, markRunScriptDone, parseLockTimingOverride, releaseRunScriptLock } from "./utils/run-script-lock.js";
16
18
  import { stripConsoleReporters } from "./utils/strip-console-reporters.js";
17
19
  import { makeTagCacheKeyGenerator } from "./utils/tag-cache-key.js";
@@ -215,11 +217,15 @@ function AgentPlugin(options = {}, _layer) {
215
217
  ctx.defineCacheKeyGenerator(makeTagCacheKeyGenerator(classifyForCache));
216
218
  log("registered fsModuleCache tag cache-key generator");
217
219
  }
218
- const { env, executor } = await Effect.runPromise(Effect.gen(function* () {
219
- const env = yield* (yield* EnvironmentDetector).detect();
220
+ const { env, executor, inGithubActions } = await Effect.runPromise(Effect.gen(function* () {
221
+ const detector = yield* EnvironmentDetector;
222
+ const env = yield* detector.detect();
223
+ const executor = yield* (yield* ExecutorResolver).resolve(env);
224
+ const ci = yield* detector.ci;
220
225
  return {
221
226
  env,
222
- executor: yield* (yield* ExecutorResolver).resolve(env)
227
+ executor,
228
+ inGithubActions: Option.contains(ci, "github-actions")
223
229
  };
224
230
  }).pipe(Effect.provide(Layer.merge(layer, ExecutorResolverLive))));
225
231
  const reportOption = options.report;
@@ -253,17 +259,19 @@ function AgentPlugin(options = {}, _layer) {
253
259
  stderr: process.stderr
254
260
  });
255
261
  }
256
- if (env === "ci-github" && consoleMode !== "silent") {
262
+ const githubActions = inGithubActions && consoleMode !== "silent";
263
+ if (githubActions) {
257
264
  log("normalizing the github-actions reporter entry");
258
265
  const withGithubActions = ensureGithubActionsReporter(vitest.config.reporters);
259
266
  vitest.config.reporters = withGithubActions;
260
267
  }
261
- const githubActions = env === "ci-github" && consoleMode !== "silent";
262
268
  log("githubActions (auto):", githubActions);
263
269
  const validation = await Effect.runPromise(Effect.provide(Effect.flatMap(ConfigValidation, (cv) => cv.validate({
264
270
  vitestConfig: vitest.config,
265
271
  pluginOptions: options
266
272
  })), ConfigValidationLive));
273
+ const configSuggestedActions = suggestedActionsFromValidation(validation);
274
+ publishConfigSuggestedActions(vitest, configSuggestedActions);
267
275
  for (const w of validation.warnings) reportOnce(`[vitest-agent:plugin] warning ${w.code}: ${w.message}` + (w.remediation ? `\n ${w.remediation}` : "") + "\n");
268
276
  for (const i of validation.info) reportOnce(`[vitest-agent:plugin] info ${i.code}: ${i.message}\n`);
269
277
  if (validation.errors.length > 0) {
@@ -275,6 +283,13 @@ function AgentPlugin(options = {}, _layer) {
275
283
  const rawTargets = options.coverageTargets;
276
284
  const coverageTargets = rawTargets ? resolveThresholds(rawTargets) : void 0;
277
285
  const coverageMode = coverageConfig?.enabled === false ? "ui-only" : "full";
286
+ const persistence = resolvePersistence({
287
+ option: options.persist,
288
+ env: process.env
289
+ });
290
+ if (persistence.ignoredEnvValue !== void 0) reportOnce(`[vitest-agent:plugin] ignoring ${PERSIST_ENV_VAR}=${persistence.ignoredEnvValue}: expected 0|false|off or 1|true|on\n`);
291
+ const persist = persistence.persist;
292
+ log("coverageMode:", coverageMode, "| persist:", persist, `(${persistence.source})`);
278
293
  if (coverageConfig && !coverageDirDecidedByVitest.has(vitest)) {
279
294
  coverageDirDecidedByVitest.add(vitest);
280
295
  const dirDecision = resolveCoverageDirIsolation({
@@ -313,12 +328,14 @@ function AgentPlugin(options = {}, _layer) {
313
328
  ...coverageThresholds !== void 0 ? { coverageThresholds } : {},
314
329
  ...coverageTargets !== void 0 ? { coverageTargets } : {},
315
330
  coverageMode,
331
+ persist,
316
332
  consoleMode,
317
333
  mcp,
318
334
  githubActions,
319
335
  ...reportScope !== void 0 && { reportScope },
320
336
  transport,
321
337
  ...passWithNoTests !== void 0 ? { passWithNoTests } : {},
338
+ ...configSuggestedActions.length > 0 ? { suggestedActions: configSuggestedActions } : {},
322
339
  ...options.reporter !== void 0 && { reporter: options.reporter },
323
340
  ...options.onRunEvent !== void 0 ? { onRunEvent: options.onRunEvent } : {}
324
341
  });
@@ -417,7 +434,8 @@ function makeDiscoverBuilder(options) {
417
434
  return makeDiscoverBuilder({
418
435
  ...opts.strategy !== void 0 ? { strategy: opts.strategy } : {},
419
436
  ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {},
420
- ...opts.maxDepth !== void 0 ? { maxDepth: opts.maxDepth } : {}
437
+ ...opts.maxDepth !== void 0 ? { maxDepth: opts.maxDepth } : {},
438
+ ...opts.configure !== void 0 ? { configure: opts.configure } : {}
421
439
  });
422
440
  }
423
441
  _AgentPlugin.discover = discover;
package/reporter.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolveThresholds } from "./utils/resolve-thresholds.js";
2
2
  import { CoverageAnalyzer } from "./services/CoverageAnalyzer.js";
3
+ import { CoverageAnalyzerLive } from "./layers/CoverageAnalyzerLive.js";
3
4
  import { ReporterLive } from "./layers/ReporterLive.js";
4
5
  import { buildReporterKit, normalizeReporters } from "./utils/build-reporter-kit.js";
5
6
  import { captureEnvVars } from "./utils/capture-env.js";
@@ -13,8 +14,8 @@ import { strayOutputCaptureOf } from "./utils/stray-output-capture.js";
13
14
  import { stringifyFailureValue } from "./utils/stringify-failure-value.js";
14
15
  import { mkdirSync, statSync } from "node:fs";
15
16
  import { dirname } from "node:path";
16
- import { DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, HistoryTracker, OutputPipelineLive, PathResolutionLive, ensureMigrated, historyKey, resolveDataPath, resolveLogFile, resolveLogLevel, withPersistTransaction } from "@vitest-agent/engine";
17
- import { buildAgentReport, coerceErrorField, computeTrend, formatFatalError, isTimeoutError, probeHostMetadataFromEnv } from "@vitest-agent/sdk";
17
+ import { ConfigLive, DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, HistoryTracker, OutputPipelineLive, PathResolutionLive, VitestAgentConfigFile, ensureMigrated, historyKey, resolveDataPath, resolveLogFile, resolveLogLevel, withPersistTransaction } from "@vitest-agent/engine";
18
+ import { RunRetentionPolicy, VitestAgentConfig, buildAgentReport, coerceErrorField, computeTrend, formatFatalError, isTimeoutError, probeHostMetadataFromEnv } from "@vitest-agent/sdk";
18
19
  import { Effect, Option, PubSub } from "effect";
19
20
  import { randomUUID } from "node:crypto";
20
21
  import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -73,6 +74,36 @@ function resolveDataPathCached(projectDir) {
73
74
  return promise;
74
75
  }
75
76
  /**
77
+ * Cache of the run-retention policy resolved from `vitest-agent.config.toml`,
78
+ * keyed by `projectDir` for the same reason as `dbPathCache`: one load per
79
+ * process, however many reporter instances a multi-project config builds.
80
+ *
81
+ * @internal
82
+ */
83
+ const retentionPolicyCache = /* @__PURE__ */ new Map();
84
+ /**
85
+ * The run-retention policy the `[retention]` table of
86
+ * `vitest-agent.config.toml` asks for (issue 623): the default policy when
87
+ * the file or table is absent, `Option.none()` when `retention.enabled` is
88
+ * `false`. Loads the config through `ConfigLive(projectDir)` alone — the
89
+ * light half of `PathResolutionLive`, without the workspace scan — so a
90
+ * programmatic `cacheDir` still gets the configured policy.
91
+ *
92
+ * A config that cannot be read resolves to `Option.none()`: the prune is
93
+ * skipped for this run rather than applied under a policy nobody chose, and
94
+ * the same unreadable file already surfaces through `resolveDataPath`.
95
+ */
96
+ function resolveRetentionPolicyCached(projectDir) {
97
+ const cached = retentionPolicyCache.get(projectDir);
98
+ if (cached !== void 0) return cached;
99
+ const promise = Effect.runPromise(Effect.gen(function* () {
100
+ const config = yield* (yield* VitestAgentConfigFile).loadOrDefault(new VitestAgentConfig({}));
101
+ return RunRetentionPolicy.fromConfig(config);
102
+ }).pipe(Effect.provide(ConfigLive(projectDir)), Effect.provide(NodeServices.layer), Effect.catch(() => Effect.succeed(Option.none()))));
103
+ retentionPolicyCache.set(projectDir, promise);
104
+ return promise;
105
+ }
106
+ /**
76
107
  * Compute updated baselines using ratchet logic: take the max of actual vs previous,
77
108
  * capped by targets if set.
78
109
  *
@@ -300,14 +331,17 @@ var AgentReporter = class {
300
331
  options;
301
332
  dbPath = null;
302
333
  /**
303
- * Set to `true` after the first `onTestRunEnd` invocation completes.
334
+ * Set to `true` once `onTestRunEnd` has done this run's work.
304
335
  *
305
- * Vitest can fire `onTestRunEnd` more than once per `vitest run` in
306
- * some multi-project configurations (e.g., once per project group, with
307
- * the same `testModules` array each time). The plugin pushes only ONE
308
- * AgentReporter per run, so a fresh instance always starts with this
309
- * flag `false`; the first call does the work and the flag prevents
310
- * subsequent invocations from re-rendering and re-writing to the DB.
336
+ * Vitest can fire `onTestRunEnd` more than once per run in some
337
+ * multi-project configurations (e.g., once per project group, with the
338
+ * same `testModules` array each time). The first call does the work and
339
+ * the flag prevents subsequent invocations from re-rendering and
340
+ * re-writing to the DB. The flag is PER RUN, not per instance: Vitest
341
+ * keeps one reporter instance alive across watch-mode reruns, so
342
+ * `onTestRunStart` re-arms it — otherwise every rerun after the first
343
+ * would silently skip persistence, rendering and `RunFinished`
344
+ * (issue #576).
311
345
  */
312
346
  rendered = false;
313
347
  /**
@@ -443,6 +477,7 @@ var AgentReporter = class {
443
477
  const githubActions = options.githubActions ?? false;
444
478
  const base = {
445
479
  ...options.cacheDir !== void 0 ? { cacheDir: options.cacheDir } : {},
480
+ ...options.retention !== void 0 ? { retention: options.retention } : {},
446
481
  consoleOutput,
447
482
  omitPassingTests: true,
448
483
  coverageThresholds: resolvedThresholds,
@@ -458,8 +493,10 @@ var AgentReporter = class {
458
493
  ...options.projectFilter !== void 0 ? { projectFilter: options.projectFilter } : {},
459
494
  reporter: options.reporter ?? DefaultVitestAgentReporter,
460
495
  coverageMode: options.coverageMode ?? "full",
496
+ persist: options.persist ?? true,
461
497
  transport: options.transport ?? { kind: "local" },
462
- ...options.passWithNoTests !== void 0 ? { passWithNoTests: options.passWithNoTests } : {}
498
+ ...options.passWithNoTests !== void 0 ? { passWithNoTests: options.passWithNoTests } : {},
499
+ ...options.suggestedActions !== void 0 && options.suggestedActions.length > 0 ? { suggestedActions: options.suggestedActions } : {}
463
500
  };
464
501
  this.options = resolvedTargets ? {
465
502
  ...base,
@@ -488,6 +525,7 @@ var AgentReporter = class {
488
525
  detail: opts.detail ?? "standard",
489
526
  noColor: false,
490
527
  coverageMode: opts.coverageMode,
528
+ persist: opts.persist,
491
529
  transport: opts.transport,
492
530
  ...opts.coverageThresholds !== void 0 && { coverageThresholds: opts.coverageThresholds },
493
531
  ...opts.coverageTargets !== void 0 && { coverageTargets: opts.coverageTargets },
@@ -510,7 +548,7 @@ var AgentReporter = class {
510
548
  assertReportCapable(vitest);
511
549
  this.reportWriter = createReportWriter(vitest, this.options.reportScope);
512
550
  }
513
- try {
551
+ if (this.options.persist) try {
514
552
  await this.ensureDbPath();
515
553
  } catch {}
516
554
  await this.initReporters();
@@ -618,7 +656,8 @@ var AgentReporter = class {
618
656
  ...opts.coverageThresholds !== void 0 && { coverageThresholds: opts.coverageThresholds },
619
657
  ...opts.coverageTargets !== void 0 && { coverageTargets: opts.coverageTargets },
620
658
  ...opts.passWithNoTests !== void 0 && { passWithNoTests: opts.passWithNoTests },
621
- coverageMode: opts.coverageMode
659
+ coverageMode: opts.coverageMode,
660
+ persist: opts.persist
622
661
  });
623
662
  this.reporters = normalizeReporters(opts.reporter(kit));
624
663
  } catch (err) {
@@ -678,6 +717,8 @@ var AgentReporter = class {
678
717
  * `RunStarted` event for live subscribers.
679
718
  */
680
719
  onTestRunStart(_specifications) {
720
+ this.rendered = false;
721
+ this.coverage = null;
681
722
  this.startedSpecCount = _specifications.length;
682
723
  this.strayCapture?.reset();
683
724
  if (this.neutralizedThresholdSnapshot.size > 0) {
@@ -1173,14 +1214,84 @@ var AgentReporter = class {
1173
1214
  const wantsRunEvents = this.wantsRunEvents();
1174
1215
  let dbPath;
1175
1216
  let persistDisabled;
1176
- try {
1217
+ if (opts.persist) try {
1177
1218
  dbPath = await this.ensureDbPath();
1178
1219
  } catch (err) {
1179
1220
  persistDisabled = formatFatalError(err);
1180
1221
  }
1222
+ const retention = dbPath === void 0 || persistDisabled !== void 0 ? Option.none() : opts.retention === false ? Option.none() : opts.retention !== void 0 ? Option.some(opts.retention) : await resolveRetentionPolicyCached(process.cwd());
1181
1223
  const filteredModules = opts.projectFilter ? modules.filter((m) => (m.project.name || "default") === opts.projectFilter) : modules;
1182
1224
  if (filteredModules.length === 0 && opts.projectFilter) return;
1183
- if (opts.coverageMode === "ui-only") {
1225
+ const coverageRoot = (() => {
1226
+ const root = stashedVitest?.config?.root;
1227
+ return typeof root === "string" ? root : void 0;
1228
+ })();
1229
+ const coverageGlobs = (() => {
1230
+ const coverage = stashedVitest?.config?.coverage;
1231
+ const asGlobs = (value) => Array.isArray(value) && value.every((v) => typeof v === "string") ? value : void 0;
1232
+ const record = typeof coverage === "object" && coverage !== null ? coverage : {};
1233
+ const include = asGlobs(record.include);
1234
+ const exclude = asGlobs(record.exclude);
1235
+ return {
1236
+ ...include !== void 0 ? { include } : {},
1237
+ ...exclude !== void 0 ? { exclude } : {}
1238
+ };
1239
+ })();
1240
+ const analyzeCoverage = (baselines) => Effect.gen(function* () {
1241
+ const analyzer = yield* CoverageAnalyzer;
1242
+ const projectModuleCounts = /* @__PURE__ */ new Map();
1243
+ for (const m of modules) {
1244
+ const key = m.project.name || "default";
1245
+ projectModuleCounts.set(key, (projectModuleCounts.get(key) ?? 0) + 1);
1246
+ }
1247
+ const primaryProject = Array.from(projectModuleCounts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0];
1248
+ const isFirstProject = !opts.projectFilter || opts.projectFilter === primaryProject;
1249
+ const coverageOpts = {
1250
+ thresholds: opts.coverageThresholds,
1251
+ includeBareZero: opts.includeBareZero,
1252
+ ...opts.coverageTargets ? { targets: opts.coverageTargets } : {},
1253
+ ...baselines ? { baselines } : {},
1254
+ ...isPartial ? { totalFiles: totalSpecCount } : {},
1255
+ ...coverageRoot !== void 0 ? { root: coverageRoot } : {},
1256
+ ...coverageGlobs
1257
+ };
1258
+ const coverageResult = stashedCoverage && isFirstProject ? isPartial ? yield* analyzer.processScoped(stashedCoverage, coverageOpts, testedFiles ?? []) : yield* analyzer.process(stashedCoverage, coverageOpts) : Option.none();
1259
+ const coverageReport = Option.getOrUndefined(coverageResult);
1260
+ if (wantsRunEvents && coverageReport !== void 0) {
1261
+ const globalThresholds = coverageReport.thresholds.global;
1262
+ emitEvent({
1263
+ _tag: "CoverageReady",
1264
+ metrics: coverageReport.totals,
1265
+ thresholds: globalThresholds,
1266
+ gaps: coverageReport.lowCoverage.map((fc) => ({
1267
+ file: fc.file,
1268
+ missing: fc.summary,
1269
+ uncoveredLines: fc.uncoveredLines
1270
+ })),
1271
+ ...coverageReport.scoped ? { scoped: coverageReport.scoped } : {},
1272
+ ...coverageReport.scopedFiles !== void 0 ? { scopedFiles: coverageReport.scopedFiles.length } : {},
1273
+ ...coverageReport.totalFiles !== void 0 ? { totalFiles: coverageReport.totalFiles } : {},
1274
+ ...coverageReport.globShortfalls !== void 0 ? { globShortfalls: coverageReport.globShortfalls } : {}
1275
+ });
1276
+ if (!coverageReport.scoped) for (const metric of [
1277
+ "lines",
1278
+ "branches",
1279
+ "functions",
1280
+ "statements"
1281
+ ]) {
1282
+ const expected = globalThresholds[metric];
1283
+ const actual = coverageReport.totals[metric];
1284
+ if (expected !== void 0 && actual < expected) emitEvent({
1285
+ _tag: "ThresholdViolation",
1286
+ metric,
1287
+ expected,
1288
+ actual
1289
+ });
1290
+ }
1291
+ }
1292
+ return coverageReport;
1293
+ });
1294
+ if (opts.coverageMode === "ui-only" || !opts.persist) {
1184
1295
  const uiProjectGroups = /* @__PURE__ */ new Map();
1185
1296
  for (const mod of filteredModules) {
1186
1297
  const key = mod.project.name || "default";
@@ -1198,11 +1309,16 @@ var AgentReporter = class {
1198
1309
  const detector = yield* EnvironmentDetector;
1199
1310
  const executorResolver = yield* ExecutorResolver;
1200
1311
  const detailResolver = yield* DetailResolver;
1312
+ const coverageReport = yield* analyzeCoverage(void 0);
1313
+ const reports = coverageReport ? uiReports.map((r) => ({
1314
+ ...r,
1315
+ coverage: coverageReport
1316
+ })) : uiReports;
1201
1317
  const env = yield* detector.detect();
1202
1318
  const executor = yield* executorResolver.resolve(env);
1203
1319
  const health = {
1204
- hasFailures: uiReports.some((r) => r.failedFiles.length > 0 || r.unhandledErrors.length > 0),
1205
- belowTargets: false,
1320
+ hasFailures: reports.some((r) => r.failedFiles.length > 0 || r.unhandledErrors.length > 0),
1321
+ belowTargets: (coverageReport?.belowTarget?.length ?? 0) > 0,
1206
1322
  hasTargets: !!opts.coverageTargets
1207
1323
  };
1208
1324
  const detail = yield* detailResolver.resolve(executor, health, opts.detail);
@@ -1222,12 +1338,14 @@ var AgentReporter = class {
1222
1338
  ...opts.coverageThresholds !== void 0 && { coverageThresholds: opts.coverageThresholds },
1223
1339
  ...opts.coverageTargets !== void 0 && { coverageTargets: opts.coverageTargets },
1224
1340
  ...opts.passWithNoTests !== void 0 && { passWithNoTests: opts.passWithNoTests },
1225
- coverageMode: opts.coverageMode
1341
+ coverageMode: opts.coverageMode,
1342
+ persist: opts.persist
1226
1343
  });
1227
1344
  const reporters = preBuiltReporters ?? normalizeReporters(opts.reporter(kit));
1228
1345
  const renderInput = {
1229
- reports: withStrayOutput(uiReports, strayOutput),
1230
- classifications: /* @__PURE__ */ new Map()
1346
+ reports: withStrayOutput(reports, strayOutput),
1347
+ classifications: /* @__PURE__ */ new Map(),
1348
+ ...opts.suggestedActions !== void 0 && { suggestedActions: opts.suggestedActions }
1231
1349
  };
1232
1350
  const allOutputs = reporters.flatMap((r) => r.render(renderInput, kit));
1233
1351
  for (const output of allOutputs) routeRenderedOutput(output, {
@@ -1235,7 +1353,7 @@ var AgentReporter = class {
1235
1353
  ...reportWriter !== null && { writeReport: reportWriter.write }
1236
1354
  });
1237
1355
  });
1238
- await Effect.runPromise(uiProgram.pipe(Effect.provide(OutputPipelineLive(process.env)), Effect.provide(NodeServices.layer))).catch((err) => {
1356
+ await Effect.runPromise(uiProgram.pipe(Effect.provide(CoverageAnalyzerLive), Effect.provide(OutputPipelineLive(process.env)), Effect.provide(NodeServices.layer))).catch((err) => {
1239
1357
  process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
1240
1358
  });
1241
1359
  await reportWriter?.flush();
@@ -1270,7 +1388,6 @@ var AgentReporter = class {
1270
1388
  const persistProgram = Effect.gen(function* () {
1271
1389
  const store = yield* DataStore;
1272
1390
  const reader = yield* DataReader;
1273
- const analyzer = yield* CoverageAnalyzer;
1274
1391
  const tracker = yield* HistoryTracker;
1275
1392
  const invocationId = randomUUID();
1276
1393
  const vitest = stashedVitest;
@@ -1288,60 +1405,12 @@ var AgentReporter = class {
1288
1405
  }
1289
1406
  const baselinesOpt = yield* reader.getBaselines("__global__").pipe(Effect.catch(() => Effect.succeed(Option.none())));
1290
1407
  const baselines = Option.getOrUndefined(baselinesOpt);
1291
- const projectModuleCounts = /* @__PURE__ */ new Map();
1292
- for (const m of modules) {
1293
- const key = m.project.name || "default";
1294
- projectModuleCounts.set(key, (projectModuleCounts.get(key) ?? 0) + 1);
1295
- }
1296
- const primaryProject = Array.from(projectModuleCounts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0];
1297
- const isFirstProject = !opts.projectFilter || opts.projectFilter === primaryProject;
1298
- const coverageRoot = typeof vitestConfig.root === "string" ? vitestConfig.root : void 0;
1299
- const coverageOpts = {
1300
- thresholds: opts.coverageThresholds,
1301
- includeBareZero: opts.includeBareZero,
1302
- ...opts.coverageTargets ? { targets: opts.coverageTargets } : {},
1303
- ...baselines ? { baselines } : {},
1304
- ...isPartial ? { totalFiles: totalSpecCount } : {},
1305
- ...coverageRoot !== void 0 ? { root: coverageRoot } : {}
1306
- };
1307
- const coverageResult = stashedCoverage && isFirstProject ? isPartial ? yield* analyzer.processScoped(stashedCoverage, coverageOpts, testedFiles ?? []) : yield* analyzer.process(stashedCoverage, coverageOpts) : Option.none();
1308
- const coverageReport = Option.getOrUndefined(coverageResult);
1309
- if (wantsRunEvents && coverageReport !== void 0) {
1310
- const globalThresholds = coverageReport.thresholds.global;
1311
- emitEvent({
1312
- _tag: "CoverageReady",
1313
- metrics: coverageReport.totals,
1314
- thresholds: globalThresholds,
1315
- gaps: coverageReport.lowCoverage.map((fc) => ({
1316
- file: fc.file,
1317
- missing: fc.summary,
1318
- uncoveredLines: fc.uncoveredLines
1319
- })),
1320
- ...coverageReport.scoped ? { scoped: coverageReport.scoped } : {},
1321
- ...coverageReport.scopedFiles !== void 0 ? { scopedFiles: coverageReport.scopedFiles.length } : {},
1322
- ...coverageReport.totalFiles !== void 0 ? { totalFiles: coverageReport.totalFiles } : {},
1323
- ...coverageReport.globShortfalls !== void 0 ? { globShortfalls: coverageReport.globShortfalls } : {}
1324
- });
1325
- if (!coverageReport.scoped) for (const metric of [
1326
- "lines",
1327
- "branches",
1328
- "functions",
1329
- "statements"
1330
- ]) {
1331
- const expected = globalThresholds[metric];
1332
- const actual = coverageReport.totals[metric];
1333
- if (expected !== void 0 && actual < expected) emitEvent({
1334
- _tag: "ThresholdViolation",
1335
- metric,
1336
- expected,
1337
- actual
1338
- });
1339
- }
1340
- }
1408
+ const coverageReport = yield* analyzeCoverage(baselines);
1341
1409
  const reports = [];
1342
1410
  const isMultiProject = projectGroups.size > 1 || !!opts.projectFilter;
1343
1411
  yield* withPersistTransaction(Effect.gen(function* () {
1344
1412
  yield* store.writeSettings(settingsHash, settings, envVars);
1413
+ let fileCoverageWritten = false;
1345
1414
  for (const [projectName, projectModules] of projectGroups) {
1346
1415
  const project = projectName === "default" ? "default" : projectName;
1347
1416
  const baseReport = buildAgentReport(projectModules, errors, reason, { omitPassingTests: opts.omitPassingTests }, isMultiProject ? projectName : void 0);
@@ -1381,7 +1450,8 @@ var AgentReporter = class {
1381
1450
  gitCommitSha: process.env.GITHUB_SHA ?? null,
1382
1451
  hostSource: hostProbe.source,
1383
1452
  hostValue: hostProbe.value,
1384
- hostMetadata: hostProbe.metadata
1453
+ hostMetadata: hostProbe.metadata,
1454
+ strayOutput: strayOutput ?? null
1385
1455
  });
1386
1456
  for (const mod of projectModules) {
1387
1457
  const fileId = yield* store.ensureFile(mod.relativeModuleId);
@@ -1618,7 +1688,8 @@ var AgentReporter = class {
1618
1688
  coverage: coverageReport
1619
1689
  } : reportWithTags;
1620
1690
  reports.push(report);
1621
- if (coverageReport) {
1691
+ if (coverageReport && !fileCoverageWritten) {
1692
+ fileCoverageWritten = true;
1622
1693
  const tierByFile = /* @__PURE__ */ new Map();
1623
1694
  const sourceByFile = /* @__PURE__ */ new Map();
1624
1695
  for (const fc of coverageReport.belowTarget ?? []) {
@@ -1663,6 +1734,10 @@ var AgentReporter = class {
1663
1734
  if (opts.coverageThresholds !== void 0) yield* store.writeThresholds(opts.coverageThresholds);
1664
1735
  if (opts.coverageTargets !== void 0) yield* store.writeTargets(opts.coverageTargets);
1665
1736
  }
1737
+ if (Option.isSome(retention)) yield* store.pruneRuns(retention.value).pipe(Effect.tap((result) => result.prunedRuns > 0 ? Effect.logDebug("pruned runs", {
1738
+ prunedRuns: result.prunedRuns,
1739
+ prunedFiles: result.prunedFiles
1740
+ }) : Effect.void), Effect.catch((error) => Effect.logWarning("run retention prune failed", { error: error.message })));
1666
1741
  }));
1667
1742
  let trendSummary;
1668
1743
  if (coverageReport && !coverageReport.scoped) {
@@ -1773,12 +1848,14 @@ var AgentReporter = class {
1773
1848
  ...opts.coverageThresholds !== void 0 && { coverageThresholds: opts.coverageThresholds },
1774
1849
  ...opts.coverageTargets !== void 0 && { coverageTargets: opts.coverageTargets },
1775
1850
  ...opts.passWithNoTests !== void 0 && { passWithNoTests: opts.passWithNoTests },
1776
- coverageMode: opts.coverageMode
1851
+ coverageMode: opts.coverageMode,
1852
+ persist: opts.persist
1777
1853
  });
1778
1854
  const reporters = preBuiltReporters ?? normalizeReporters(opts.reporter(kit));
1779
1855
  const renderInput = {
1780
1856
  reports: withStrayOutput(reports, strayOutput),
1781
1857
  classifications,
1858
+ ...opts.suggestedActions !== void 0 && { suggestedActions: opts.suggestedActions },
1782
1859
  ...trendSummary !== void 0 && { trendSummary }
1783
1860
  };
1784
1861
  const allOutputs = reporters.flatMap((r) => r.render(renderInput, kit));
@@ -1,37 +1,33 @@
1
- import { osc8 } from "@vitest-agent/sdk";
2
-
3
1
  // src/utils/build-reporter-kit.ts
4
2
  const buildReporterKit = (input) => {
5
3
  const consoleOutput = input.consoleMode === "silent" ? "silent" : "failures";
6
4
  const githubSummary = input.githubActions;
7
5
  const githubSummaryFile = process.env.GITHUB_STEP_SUMMARY;
8
- const config = {
9
- executor: input.executor,
10
- consoleMode: input.consoleMode,
11
- mcp: input.mcp,
12
- consoleOutput,
13
- omitPassingTests: true,
14
- coverageConsoleLimit: 10,
15
- includeBareZero: false,
16
- githubActions: input.githubActions,
17
- githubSummary,
18
- detail: input.detail,
19
- noColor: input.noColor,
20
- coverageMode: input.coverageMode,
21
- transport: input.transport,
22
- ...input.dbPath !== void 0 && { dbPath: input.dbPath },
23
- ...input.projectFilter !== void 0 && { projectFilter: input.projectFilter },
24
- ...githubSummaryFile !== void 0 && { githubSummaryFile },
25
- ...input.runCommand !== void 0 && { runCommand: input.runCommand },
26
- ...input.coverageThresholds !== void 0 && { coverageThresholds: input.coverageThresholds },
27
- ...input.coverageTargets !== void 0 && { coverageTargets: input.coverageTargets },
28
- ...input.passWithNoTests !== void 0 && { passWithNoTests: input.passWithNoTests }
29
- };
30
- const osc8Enabled = !input.noColor && (input.env === "terminal" || input.env === "agent-shell");
31
6
  return {
32
- config,
7
+ config: {
8
+ executor: input.executor,
9
+ consoleMode: input.consoleMode,
10
+ mcp: input.mcp,
11
+ consoleOutput,
12
+ omitPassingTests: true,
13
+ coverageConsoleLimit: 10,
14
+ includeBareZero: false,
15
+ githubActions: input.githubActions,
16
+ githubSummary,
17
+ detail: input.detail,
18
+ noColor: input.noColor,
19
+ coverageMode: input.coverageMode,
20
+ persist: input.persist,
21
+ transport: input.transport,
22
+ ...input.dbPath !== void 0 && { dbPath: input.dbPath },
23
+ ...input.projectFilter !== void 0 && { projectFilter: input.projectFilter },
24
+ ...githubSummaryFile !== void 0 && { githubSummaryFile },
25
+ ...input.runCommand !== void 0 && { runCommand: input.runCommand },
26
+ ...input.coverageThresholds !== void 0 && { coverageThresholds: input.coverageThresholds },
27
+ ...input.coverageTargets !== void 0 && { coverageTargets: input.coverageTargets },
28
+ ...input.passWithNoTests !== void 0 && { passWithNoTests: input.passWithNoTests }
29
+ },
33
30
  stdEnv: input.env,
34
- stdOsc8: (url, label) => osc8(url, label, { enabled: osc8Enabled }),
35
31
  ...input.runEvents !== void 0 && { runEvents: input.runEvents }
36
32
  };
37
33
  };
@@ -0,0 +1,42 @@
1
+ // src/utils/config-suggested-actions.ts
2
+ /**
3
+ * The `Symbol.for` key under which `configureVitest` publishes the run's
4
+ * config-derived suggested actions on the Vitest instance. `Symbol.for`
5
+ * resolves to the identical symbol across module copies in one process, so
6
+ * the MCP `run_tests` tool reads the same slot off the Vitest instance it
7
+ * created (mirrors the discovery last-scan slot).
8
+ *
9
+ * @internal
10
+ */
11
+ const CONFIG_SUGGESTED_ACTIONS_SYMBOL = Symbol.for("vitest-agent:config-suggested-actions");
12
+ /**
13
+ * Map a `ValidationResult`'s warnings and info entries onto suggested
14
+ * actions, in the order the validator produced them. A warning becomes a
15
+ * `warn` action and an info entry an `info` action; the title names the
16
+ * rule code, the detail carries the message followed by the remediation
17
+ * when the rule has one. No `targetTool` — there is no MCP tool that edits
18
+ * the Vitest config.
19
+ *
20
+ * @internal
21
+ */
22
+ const suggestedActionsFromValidation = (result) => [...result.warnings.map((w) => ({
23
+ severity: "warn",
24
+ title: `Coverage config: ${w.code}`,
25
+ detail: w.remediation !== void 0 ? `${w.message} ${w.remediation}` : w.message
26
+ })), ...result.info.map((i) => ({
27
+ severity: "info",
28
+ title: `Coverage config: ${i.code}`,
29
+ detail: i.message
30
+ }))];
31
+ /**
32
+ * Publish the run's config-derived suggested actions on the Vitest instance
33
+ * for readers in the same process (the MCP `run_tests` tool). An empty list
34
+ * clears the slot so a watch-mode rerun after a config fix reports nothing.
35
+ *
36
+ * @internal
37
+ */
38
+ const publishConfigSuggestedActions = (vitest, actions) => {
39
+ vitest[CONFIG_SUGGESTED_ACTIONS_SYMBOL] = actions.length > 0 ? actions : void 0;
40
+ };
41
+
42
+ export { CONFIG_SUGGESTED_ACTIONS_SYMBOL, publishConfigSuggestedActions, suggestedActionsFromValidation };
@@ -8,6 +8,42 @@ import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/works
8
8
  import { nodeSyncOps } from "@effected/workspaces/node-sync";
9
9
 
10
10
  // src/utils/discover-projects.ts
11
+ /**
12
+ * Shallow-merges each `overrides` entry into the generated project whose
13
+ * `test.name` equals the entry's key. Pure: returns new project objects for the
14
+ * configured names and the original objects for the rest; the discovered
15
+ * `name` always wins over one carried by the override. Only a string
16
+ * `test.name` can be keyed; a project named through Vitest's object form is
17
+ * left untouched (and a key for it is reported as unknown).
18
+ *
19
+ * Throws when a key names no project in `configs`, listing the discovered
20
+ * names so the typo is visible at config-load time.
21
+ * @param configs - The projects a strategy produced
22
+ * @param overrides - Per-project `test` overrides keyed by project name
23
+ * @returns The projects with overrides applied
24
+ * @public
25
+ */
26
+ function applyProjectOverrides(configs, overrides) {
27
+ const names = new Set(configs.map((c) => c.test?.name).filter((n) => typeof n === "string"));
28
+ const unknown = Object.keys(overrides).filter((key) => !names.has(key));
29
+ if (unknown.length > 0) {
30
+ const quoted = unknown.map((k) => `"${k}"`).join(", ");
31
+ const discovered = [...names].map((n) => `"${n}"`).join(", ") || "(none)";
32
+ throw new Error(`[vitest-agent] configure: ${quoted} ${unknown.length === 1 ? "matches" : "match"} no discovered project. Discovered projects: ${discovered}.`);
33
+ }
34
+ return configs.map((config) => {
35
+ const name = config.test?.name;
36
+ if (typeof name !== "string" || !Object.hasOwn(overrides, name)) return config;
37
+ return {
38
+ ...config,
39
+ test: {
40
+ ...config.test,
41
+ ...overrides[name],
42
+ name
43
+ }
44
+ };
45
+ });
46
+ }
11
47
  const DISCOVERY_LAST_SCAN_SYMBOL = Symbol.for("vitest-agent:discovery:last-scan-at");
12
48
  const warnedDeclinedPackagePaths = /* @__PURE__ */ new Set();
13
49
  async function warnIfDeclinedPackageIsTestShaped(pkg, fs) {
@@ -101,6 +137,7 @@ async function discoverProjects(options) {
101
137
  const strategy = options?.strategy;
102
138
  const cwd = options?.cwd;
103
139
  const additionalEntries = options?.additionalEntries ?? [];
140
+ const configure = options?.configure;
104
141
  const fs = options?.fs ?? nodeWalkerFs;
105
142
  const syncOps = options?.syncOps ?? nodeSyncOps;
106
143
  const packageEnumerationOptions = {
@@ -109,7 +146,7 @@ async function discoverProjects(options) {
109
146
  };
110
147
  const root = findWorkspaceRootSync(cwd ?? process.cwd(), syncOps);
111
148
  if (!root) throw new Error(`[vitest-agent] Could not find workspace root from ${cwd ?? process.cwd()}. Ensure a pnpm-workspace.yaml or package.json with "workspaces" exists.`);
112
- const useCache = strategy === void 0 && additionalEntries.length === 0;
149
+ const useCache = strategy === void 0 && additionalEntries.length === 0 && configure === void 0;
113
150
  const resolvedStrategy = strategy ?? new DefaultDiscoverStrategy();
114
151
  const packages = getWorkspacePackagesSync(root, packageEnumerationOptions);
115
152
  let signature;
@@ -154,8 +191,9 @@ async function discoverProjects(options) {
154
191
  configs.push(config);
155
192
  }
156
193
  const tags = [...resolvedStrategy.tagDefinitions];
194
+ const configured = configure === void 0 ? configs : applyProjectOverrides(configs, configure);
157
195
  const result = {
158
- projects: configs.length > 0 ? configs : void 0,
196
+ projects: configured.length > 0 ? configured : void 0,
159
197
  tags
160
198
  };
161
199
  if (useCache && signature !== void 0) _cache.set(root, {
@@ -166,4 +204,4 @@ async function discoverProjects(options) {
166
204
  return result;
167
205
  }
168
206
 
169
- export { discoverProjects };
207
+ export { applyProjectOverrides, discoverProjects };
@@ -0,0 +1,61 @@
1
+ // src/utils/resolve-persistence.ts
2
+ /**
3
+ * The environment variable an operator sets to override `AgentPlugin`'s
4
+ * `persist` option when the config cannot be edited (issue #625).
5
+ */
6
+ const PERSIST_ENV_VAR = "VITEST_AGENT_PERSIST";
7
+ /** Values of `VITEST_AGENT_PERSIST` that turn persistence off (compared lower-cased and trimmed). */
8
+ const OFF_VALUES = /* @__PURE__ */ new Set([
9
+ "0",
10
+ "false",
11
+ "off"
12
+ ]);
13
+ /** Values of `VITEST_AGENT_PERSIST` that turn persistence on (compared lower-cased and trimmed). */
14
+ const ON_VALUES = /* @__PURE__ */ new Set([
15
+ "1",
16
+ "true",
17
+ "on"
18
+ ]);
19
+ /**
20
+ * Pure decision: should this run be recorded in the SQLite `data.db`?
21
+ *
22
+ * @remarks
23
+ * The env var is an operator override and wins over the option in either
24
+ * direction — `VITEST_AGENT_PERSIST=0` turns persistence off under
25
+ * `persist: true`, and `=1` turns it back on under `persist: false` — so a
26
+ * runner whose `vitest.config.ts` is not editable can still decide. An
27
+ * empty value is unset. Nothing here reads `CI`: the opt-out is explicit
28
+ * by design, because some CI setups cache `data.db` between jobs and
29
+ * query it, and an automatic opt-out would silently empty their history.
30
+ *
31
+ * @public
32
+ */
33
+ function resolvePersistence(input) {
34
+ const raw = input.env[PERSIST_ENV_VAR];
35
+ let ignoredEnvValue;
36
+ if (raw !== void 0 && raw.trim() !== "") {
37
+ const normalized = raw.trim().toLowerCase();
38
+ if (OFF_VALUES.has(normalized)) return {
39
+ persist: false,
40
+ source: "env"
41
+ };
42
+ if (ON_VALUES.has(normalized)) return {
43
+ persist: true,
44
+ source: "env"
45
+ };
46
+ ignoredEnvValue = raw;
47
+ }
48
+ const fromOption = input.option === void 0 ? {
49
+ persist: true,
50
+ source: "default"
51
+ } : {
52
+ persist: input.option,
53
+ source: "option"
54
+ };
55
+ return ignoredEnvValue === void 0 ? fromOption : {
56
+ ...fromOption,
57
+ ignoredEnvValue
58
+ };
59
+ }
60
+
61
+ export { PERSIST_ENV_VAR, resolvePersistence };
package/version.js CHANGED
@@ -8,6 +8,6 @@
8
8
  *
9
9
  * @public
10
10
  */
11
- const CURRENT_PLUGIN_VERSION = "5.4.9";
11
+ const CURRENT_PLUGIN_VERSION = "5.6.0";
12
12
 
13
13
  export { CURRENT_PLUGIN_VERSION };