@vitest-agent/sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/DataReader-2kJ9I9Hh.d.ts +1809 -0
  2. package/LICENSE +21 -0
  3. package/README.md +52 -0
  4. package/dispatch.d.ts +48 -0
  5. package/dispatch.js +5 -0
  6. package/errors/AgentErrors.js +65 -0
  7. package/errors/DataStoreError.js +44 -0
  8. package/errors/DiscoveryError.js +21 -0
  9. package/errors/PathResolutionError.js +27 -0
  10. package/errors/ProjectIdentityError.js +29 -0
  11. package/errors/RunContextError.js +35 -0
  12. package/errors/TddErrors.js +66 -0
  13. package/exit-code-for-tag.js +18 -0
  14. package/formatters/ci-annotations.js +61 -0
  15. package/formatters/gfm.js +20 -0
  16. package/formatters/json.js +18 -0
  17. package/formatters/markdown.js +39 -0
  18. package/formatters/silent.js +9 -0
  19. package/formatters/terminal.js +56 -0
  20. package/index.d.ts +3334 -0
  21. package/index.js +120 -0
  22. package/internal-inject-env.js +42 -0
  23. package/layers/ConfigLive.js +47 -0
  24. package/layers/DataReaderLive.js +1666 -0
  25. package/layers/DataStoreLive.js +1185 -0
  26. package/layers/DetailResolverLive.js +9 -0
  27. package/layers/DiscoveryRegistryLive.js +70 -0
  28. package/layers/EnvironmentDetectorLive.js +21 -0
  29. package/layers/EnvironmentDetectorTest.js +13 -0
  30. package/layers/ExecutorResolverLive.js +9 -0
  31. package/layers/FormatSelectorLive.js +20 -0
  32. package/layers/HistoryTrackerLive.js +47 -0
  33. package/layers/HistoryTrackerTest.js +25 -0
  34. package/layers/LoggerLive.js +64 -0
  35. package/layers/OutputPipelineLive.js +13 -0
  36. package/layers/OutputRendererLive.js +28 -0
  37. package/layers/PathResolutionLive.js +36 -0
  38. package/layers/PerClientSessionMapLive.js +145 -0
  39. package/layers/ProjectDiscoveryLive.js +70 -0
  40. package/layers/ProjectDiscoveryTest.js +12 -0
  41. package/layers/ProjectIdentityLive.js +92 -0
  42. package/layers/RunContextLive.js +81 -0
  43. package/lib/format-triage.js +103 -0
  44. package/lib/format-wrapup.js +67 -0
  45. package/migrations/0001_initial.js +849 -0
  46. package/migrations/registry_0001_initial.js +39 -0
  47. package/migrations/session_map_0001_initial.js +61 -0
  48. package/package.json +64 -0
  49. package/schemas/Agent.js +42 -0
  50. package/schemas/AgentReport.js +71 -0
  51. package/schemas/Baselines.js +16 -0
  52. package/schemas/CacheManifest.js +27 -0
  53. package/schemas/ChannelEvent.js +138 -0
  54. package/schemas/Common.js +105 -0
  55. package/schemas/Config.js +28 -0
  56. package/schemas/Coverage.js +51 -0
  57. package/schemas/CoverageLevel.js +117 -0
  58. package/schemas/CoverageTargets.js +39 -0
  59. package/schemas/History.js +31 -0
  60. package/schemas/Identity.js +64 -0
  61. package/schemas/Options.js +57 -0
  62. package/schemas/RenderState.js +161 -0
  63. package/schemas/RunEvent.js +163 -0
  64. package/schemas/Tdd.js +44 -0
  65. package/schemas/Thresholds.js +30 -0
  66. package/schemas/Transport.js +16 -0
  67. package/schemas/Trends.js +23 -0
  68. package/schemas/turns/FileEditPayload.js +15 -0
  69. package/schemas/turns/HookFirePayload.js +13 -0
  70. package/schemas/turns/HypothesisPayload.js +13 -0
  71. package/schemas/turns/NotePayload.js +13 -0
  72. package/schemas/turns/ToolCallPayload.js +13 -0
  73. package/schemas/turns/ToolResultPayload.js +15 -0
  74. package/schemas/turns/UserPromptPayload.js +12 -0
  75. package/schemas/turns/index.js +15 -0
  76. package/services/Config.js +15 -0
  77. package/services/DataReader.js +8 -0
  78. package/services/DataStore.js +8 -0
  79. package/services/DetailResolver.js +8 -0
  80. package/services/DiscoveryRegistry.js +8 -0
  81. package/services/EnvironmentDetector.js +8 -0
  82. package/services/ExecutorResolver.js +8 -0
  83. package/services/FormatSelector.js +8 -0
  84. package/services/HistoryTracker.js +8 -0
  85. package/services/OutputRenderer.js +8 -0
  86. package/services/PerClientSessionMap.js +22 -0
  87. package/services/ProjectDiscovery.js +8 -0
  88. package/services/ProjectIdentity.js +63 -0
  89. package/services/RunContext.js +53 -0
  90. package/services/idempotency.js +57 -0
  91. package/sidecar-dispatch.js +84 -0
  92. package/sql/assemblers.js +59 -0
  93. package/testing/layers.js +21 -0
  94. package/testing.d.ts +25 -0
  95. package/testing.js +243 -0
  96. package/tsdoc-metadata.json +11 -0
  97. package/utils/ansi.js +49 -0
  98. package/utils/build-report.js +130 -0
  99. package/utils/canonicalize-git-url.js +66 -0
  100. package/utils/classify-test.js +22 -0
  101. package/utils/compress-lines.js +45 -0
  102. package/utils/compute-trend.js +88 -0
  103. package/utils/detect-pm.js +58 -0
  104. package/utils/detect-timeout.js +17 -0
  105. package/utils/ensure-migrated.js +52 -0
  106. package/utils/failure-signature.js +33 -0
  107. package/utils/format-console.js +219 -0
  108. package/utils/format-fatal-error.js +35 -0
  109. package/utils/format-gfm.js +161 -0
  110. package/utils/format-terminal.js +480 -0
  111. package/utils/function-boundary.js +75 -0
  112. package/utils/hyperlink.js +11 -0
  113. package/utils/match-vitest-command.js +88 -0
  114. package/utils/normalize-workspace-key.js +34 -0
  115. package/utils/probe-host-metadata.js +99 -0
  116. package/utils/resolve-data-path.js +66 -0
  117. package/utils/resolve-project-key-from-cwd.js +52 -0
  118. package/utils/resolve-workspace-key.js +33 -0
  119. package/utils/safe-filename.js +34 -0
  120. package/utils/validate-coverage-targets-shape.js +84 -0
  121. package/utils/validate-phase-transition.js +113 -0
@@ -0,0 +1,99 @@
1
+ //#region src/utils/probe-host-metadata.ts
2
+ const isUseful = (value) => value !== void 0 && value.length > 0;
3
+ /**
4
+ * Walk the priority chain of env-var probes. Returns the first match
5
+ * along with any decorating metadata (`term_program`, `ci_provider`,
6
+ * etc.) for that source. When no probe matches, `source`/`value`/
7
+ * `metadata` are all `null`.
8
+ *
9
+ * The probe order matches the plan's table exactly:
10
+ *
11
+ * 1. `TMUX_PANE` (tmux reattach-stable)
12
+ * 2. `WT_SESSION` (Windows Terminal pane)
13
+ * 3. `WEZTERM_PANE` (WezTerm pane)
14
+ * 4. `KITTY_WINDOW_ID` (kitty window)
15
+ * 5. `TERM_SESSION_ID` (iTerm2, Terminal.app)
16
+ * 6. `VSCODE_INJECTION` + `process.ppid` (handled by the Live layer
17
+ * since it needs `process.ppid`, not env)
18
+ * 7. `GITHUB_RUN_ID` + `GITHUB_RUN_ATTEMPT`
19
+ * 8. `BUILDKITE_JOB_ID` / `CIRCLE_BUILD_NUM` / `GITLAB_CI_JOB_ID`
20
+ * 9. `process.ppid` walk (handled by the Live layer)
21
+ * @public
22
+ */
23
+ const probeHostMetadataFromEnv = (env) => {
24
+ const decoration = (extra) => {
25
+ const baseDecoration = {};
26
+ if (isUseful(env.TERM_PROGRAM)) baseDecoration.term_program = env.TERM_PROGRAM;
27
+ if (isUseful(env.TERM_PROGRAM_VERSION)) baseDecoration.term_program_version = env.TERM_PROGRAM_VERSION;
28
+ return {
29
+ ...baseDecoration,
30
+ ...extra
31
+ };
32
+ };
33
+ if (isUseful(env.TMUX_PANE)) return {
34
+ source: "TMUX_PANE",
35
+ value: env.TMUX_PANE,
36
+ metadata: decoration(isUseful(env.TMUX) ? { tmux_socket: env.TMUX } : {})
37
+ };
38
+ if (isUseful(env.WT_SESSION)) return {
39
+ source: "WT_SESSION",
40
+ value: env.WT_SESSION,
41
+ metadata: decoration({})
42
+ };
43
+ if (isUseful(env.WEZTERM_PANE)) return {
44
+ source: "WEZTERM_PANE",
45
+ value: env.WEZTERM_PANE,
46
+ metadata: decoration({})
47
+ };
48
+ if (isUseful(env.KITTY_WINDOW_ID)) return {
49
+ source: "KITTY_WINDOW_ID",
50
+ value: env.KITTY_WINDOW_ID,
51
+ metadata: decoration({})
52
+ };
53
+ if (isUseful(env.TERM_SESSION_ID)) return {
54
+ source: "TERM_SESSION_ID",
55
+ value: env.TERM_SESSION_ID,
56
+ metadata: decoration({})
57
+ };
58
+ if (isUseful(env.GITHUB_RUN_ID)) return {
59
+ source: "GITHUB_RUN_ID",
60
+ value: env.GITHUB_RUN_ID,
61
+ metadata: decoration({
62
+ ci: true,
63
+ ci_provider: "github",
64
+ ...isUseful(env.GITHUB_RUN_ATTEMPT) ? { github_run_attempt: env.GITHUB_RUN_ATTEMPT } : {}
65
+ })
66
+ };
67
+ if (isUseful(env.BUILDKITE_JOB_ID)) return {
68
+ source: "BUILDKITE_JOB_ID",
69
+ value: env.BUILDKITE_JOB_ID,
70
+ metadata: decoration({
71
+ ci: true,
72
+ ci_provider: "buildkite"
73
+ })
74
+ };
75
+ if (isUseful(env.CIRCLE_BUILD_NUM)) return {
76
+ source: "CIRCLE_BUILD_NUM",
77
+ value: env.CIRCLE_BUILD_NUM,
78
+ metadata: decoration({
79
+ ci: true,
80
+ ci_provider: "circle"
81
+ })
82
+ };
83
+ if (isUseful(env.GITLAB_CI_JOB_ID)) return {
84
+ source: "GITLAB_CI_JOB_ID",
85
+ value: env.GITLAB_CI_JOB_ID,
86
+ metadata: decoration({
87
+ ci: true,
88
+ ci_provider: "gitlab"
89
+ })
90
+ };
91
+ return {
92
+ source: null,
93
+ value: null,
94
+ metadata: null
95
+ };
96
+ };
97
+
98
+ //#endregion
99
+ export { probeHostMetadataFromEnv };
@@ -0,0 +1,66 @@
1
+ import { VitestAgentConfig } from "../schemas/Config.js";
2
+ import { VitestAgentConfigFile } from "../services/Config.js";
3
+ import { normalizeWorkspaceKey } from "./normalize-workspace-key.js";
4
+ import { resolveProjectKeyFromCwd } from "./resolve-project-key-from-cwd.js";
5
+ import { Effect } from "effect";
6
+ import { join } from "node:path";
7
+ import { mkdirSync } from "node:fs";
8
+ import { AppDirs } from "xdg-effect";
9
+
10
+ //#region src/utils/resolve-data-path.ts
11
+ /**
12
+ * Filename of the SQLite database that stores all reporter data.
13
+ * @public
14
+ */
15
+ const DATABASE_FILENAME = "data.db";
16
+ /**
17
+ * Resolve the absolute path to the SQLite database for `projectDir`.
18
+ *
19
+ * The directory containing the database is ensured to exist before the
20
+ * function returns, so callers can open the database immediately without
21
+ * needing to create parent directories. better-sqlite3 creates the file but
22
+ * not its parent.
23
+ *
24
+ * Precedence (highest first):
25
+ *
26
+ * 1. `options.cacheDir` — programmatic override.
27
+ * 2. `cacheDir` from `vitest-agent.config.toml`.
28
+ * 3. `<XDG data>/<normalized projectKey from config>/data.db`.
29
+ * 4. `<XDG data>/<projectKey resolved from cwd's package.json>/data.db`.
30
+ * Source 4 prefers `repository.url` (canonicalized to `host__path`),
31
+ * falling back to the normalized `name`. This matches the sidecar
32
+ * CLI's `_internal register-agent` resolver so the reporter and
33
+ * sidecar always write to the same `data.db`.
34
+ *
35
+ * The XDG data directory is namespaced via `AppDirs` (typically
36
+ * `$XDG_DATA_HOME/vitest-agent`, defaulting to
37
+ * `~/.local/share/vitest-agent`).
38
+ *
39
+ * The path is a function of identity, not filesystem layout. When no
40
+ * `package.json` is reachable from `projectDir`, the cwd basename is used
41
+ * as the final fallback so the function never throws on identity lookup.
42
+ *
43
+ * @param projectDir - Absolute path inside the user's workspace.
44
+ * @param options - Optional programmatic overrides.
45
+ * @public
46
+ */
47
+ const resolveDataPath = (projectDir, options = {}) => Effect.gen(function* () {
48
+ if (options.cacheDir) {
49
+ ensureDirSync(options.cacheDir);
50
+ return join(options.cacheDir, DATABASE_FILENAME);
51
+ }
52
+ const loaded = yield* (yield* VitestAgentConfigFile).loadOrDefault(new VitestAgentConfig({}));
53
+ if (loaded.cacheDir) {
54
+ ensureDirSync(loaded.cacheDir);
55
+ return join(loaded.cacheDir, DATABASE_FILENAME);
56
+ }
57
+ const dir = join(yield* (yield* AppDirs).ensureData, loaded.projectKey ? normalizeWorkspaceKey(loaded.projectKey) : resolveProjectKeyFromCwd(projectDir));
58
+ ensureDirSync(dir);
59
+ return join(dir, DATABASE_FILENAME);
60
+ });
61
+ const ensureDirSync = (path) => {
62
+ mkdirSync(path, { recursive: true });
63
+ };
64
+
65
+ //#endregion
66
+ export { DATABASE_FILENAME, resolveDataPath };
@@ -0,0 +1,52 @@
1
+ import { gitUrlToProjectKey } from "./canonicalize-git-url.js";
2
+ import { normalizeWorkspaceKey } from "./normalize-workspace-key.js";
3
+ import { dirname, join } from "node:path";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+
6
+ //#region src/utils/resolve-project-key-from-cwd.ts
7
+ const PACKAGE_JSON = "package.json";
8
+ const findNearestPackageJson = (startDir) => {
9
+ let dir = startDir;
10
+ while (true) {
11
+ const candidate = join(dir, PACKAGE_JSON);
12
+ if (existsSync(candidate)) return candidate;
13
+ const parent = dirname(dir);
14
+ if (parent === dir) return null;
15
+ dir = parent;
16
+ }
17
+ };
18
+ const readRepositoryUrl = (parsed) => {
19
+ const repo = parsed.repository;
20
+ if (typeof repo === "string" && repo.trim().length > 0) return repo.trim();
21
+ if (repo !== null && typeof repo === "object" && "url" in repo && typeof repo.url === "string") {
22
+ const url = repo.url.trim();
23
+ return url.length > 0 ? url : null;
24
+ }
25
+ return null;
26
+ };
27
+ /**
28
+ * Compute the project key for a workspace by reading `package.json`
29
+ * fields directly. Returns the canonical `host__path` form when a git
30
+ * remote URL is present, otherwise the normalized package name.
31
+ *
32
+ * Always returns a non-empty string — falls back to the cwd basename
33
+ * (or `"anonymous-project"`) so callers don't have to handle the
34
+ * empty case.
35
+ * @public
36
+ */
37
+ const resolveProjectKeyFromCwd = (cwd) => {
38
+ const pkgPath = findNearestPackageJson(cwd);
39
+ if (pkgPath !== null) try {
40
+ const parsed = JSON.parse(readFileSync(pkgPath, "utf-8"));
41
+ const repoUrl = readRepositoryUrl(parsed);
42
+ if (repoUrl !== null) {
43
+ const key = gitUrlToProjectKey(repoUrl);
44
+ if (key !== null) return key;
45
+ }
46
+ if (typeof parsed.name === "string" && parsed.name.length > 0) return normalizeWorkspaceKey(parsed.name);
47
+ } catch {}
48
+ return normalizeWorkspaceKey(cwd.split("/").filter((segment) => segment.length > 0).pop() ?? "anonymous-project");
49
+ };
50
+
51
+ //#endregion
52
+ export { resolveProjectKeyFromCwd };
@@ -0,0 +1,33 @@
1
+ import { normalizeWorkspaceKey } from "./normalize-workspace-key.js";
2
+ import { Effect } from "effect";
3
+ import { WorkspaceDiscovery, WorkspaceRootNotFoundError } from "workspaces-effect";
4
+
5
+ //#region src/utils/resolve-workspace-key.ts
6
+ /**
7
+ * Resolve the normalized workspace key for the workspace containing
8
+ * `projectDir`.
9
+ *
10
+ * The key is the root `package.json`'s `name`, normalized via
11
+ * `normalizeWorkspaceKey`. This becomes the directory segment under
12
+ * `$XDG_DATA_HOME/vitest-agent/` where the SQLite database lives.
13
+ *
14
+ * Fails with `WorkspaceRootNotFoundError` when no root workspace is
15
+ * discoverable from `projectDir`. `WorkspacePackage.name` is enforced
16
+ * non-empty by `workspaces-effect`'s schema, so a successful root lookup
17
+ * always yields a usable name.
18
+ *
19
+ * @param projectDir - Absolute path inside the workspace. Typically the
20
+ * reporter's resolved `projectDir` (CLAUDE_PROJECT_DIR or process.cwd()).
21
+ * @public
22
+ */
23
+ const resolveWorkspaceKey = (projectDir) => Effect.gen(function* () {
24
+ const root = (yield* (yield* WorkspaceDiscovery).listPackages(projectDir)).find((pkg) => pkg.isRootWorkspace);
25
+ if (!root) return yield* Effect.fail(new WorkspaceRootNotFoundError({
26
+ searchPath: projectDir,
27
+ reason: "No root workspace package found in the discovered package list."
28
+ }));
29
+ return normalizeWorkspaceKey(root.name);
30
+ });
31
+
32
+ //#endregion
33
+ export { resolveWorkspaceKey };
@@ -0,0 +1,34 @@
1
+ //#region src/utils/safe-filename.ts
2
+ /**
3
+ * Convert a project name to a filesystem-safe filename.
4
+ *
5
+ * Replaces `/` and `:` characters with `__` (double underscore).
6
+ * Returns `"default"` for empty strings, which is used as the
7
+ * fallback project name for single-repo configurations.
8
+ *
9
+ * @param name - Project name to sanitize
10
+ * @returns Filesystem-safe filename string
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * import { safeFilename } from "@vitest-agent/sdk/utils";
15
+ *
16
+ * safeFilename("\@savvy-web/my-lib:unit");
17
+ * // Returns: "\@savvy-web__my-lib__unit"
18
+ *
19
+ * safeFilename("core");
20
+ * // Returns: "core"
21
+ *
22
+ * safeFilename("");
23
+ * // Returns: "default"
24
+ * ```
25
+ *
26
+ * @public
27
+ */
28
+ function safeFilename(name) {
29
+ if (!name) return "default";
30
+ return name.replace(/[/:]/g, "__");
31
+ }
32
+
33
+ //#endregion
34
+ export { safeFilename };
@@ -0,0 +1,84 @@
1
+ //#region src/utils/validate-coverage-targets-shape.ts
2
+ /** Metric keys that may appear at the top level or inside a glob-pattern entry. */
3
+ const METRIC_KEYS = /* @__PURE__ */ new Set([
4
+ "lines",
5
+ "functions",
6
+ "branches",
7
+ "statements",
8
+ "100"
9
+ ]);
10
+ /** Pushes an INVALID_TARGET_VALUE error when the numeric value is zero or negative. */
11
+ function checkNumericValue(value, path, errors) {
12
+ if (value <= 0) errors.push({
13
+ code: "INVALID_TARGET_VALUE",
14
+ path,
15
+ message: `Coverage target at "${path}" must be a positive number greater than zero, got ${value}.`
16
+ });
17
+ }
18
+ /** Emits an INVALID_TARGET_VALUE error when `true` appears at a key other than `"100"`. */
19
+ function checkBooleanShortcut(key, path, errors) {
20
+ if (key === "100") return;
21
+ errors.push({
22
+ code: "INVALID_TARGET_VALUE",
23
+ path,
24
+ message: `Coverage target at "${path}" cannot be true. The "true" shorthand is only valid at key "100" (meaning 100% across all metrics).`
25
+ });
26
+ }
27
+ /** Checks all values inside a nested per-metric object (glob-pattern entry). */
28
+ function checkNestedMetrics(nested, prefix, errors) {
29
+ for (const [metricKey, metricValue] of Object.entries(nested)) {
30
+ const path = `${prefix}.${metricKey}`;
31
+ if (typeof metricValue === "number") checkNumericValue(metricValue, path, errors);
32
+ else if (metricValue === true) checkBooleanShortcut(metricKey, path, errors);
33
+ }
34
+ }
35
+ /**
36
+ * Validates the shape of a raw coverageTargets value, producing structured
37
+ * diagnostic entries. This is a pure function with no Effect or I/O.
38
+ *
39
+ * Codes emitted:
40
+ * - `INVALID_TARGET_VALUE` — a numeric metric value is zero or negative,
41
+ * OR a literal `true` appears at any key other than `"100"`. The
42
+ * offending `path` is included (e.g. `"lines"`, `"src/**.ts.lines"`,
43
+ * or `"statements"` when misused as a boolean shortcut).
44
+ * - `PERFILE_ON_TARGETS` — the `perFile` key appears inside coverageTargets;
45
+ * it should be set on `coverage.thresholds.perFile` instead.
46
+ *
47
+ * @param input - The raw value passed as coverageTargets.
48
+ * @returns A result object with errors, warnings, and info arrays.
49
+ * @public
50
+ */
51
+ function validateCoverageTargetsShape(input) {
52
+ const errors = [];
53
+ const warnings = [];
54
+ const info = [];
55
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return {
56
+ errors,
57
+ warnings,
58
+ info
59
+ };
60
+ for (const [key, value] of Object.entries(input)) {
61
+ if (key === "perFile") {
62
+ warnings.push({
63
+ code: "PERFILE_ON_TARGETS",
64
+ path: key,
65
+ message: "The \"perFile\" key is not valid inside coverageTargets. Set coverage.thresholds.perFile instead."
66
+ });
67
+ continue;
68
+ }
69
+ if (METRIC_KEYS.has(key)) {
70
+ if (typeof value === "number") checkNumericValue(value, key, errors);
71
+ else if (value === true) checkBooleanShortcut(key, key, errors);
72
+ } else if (typeof value === "number") checkNumericValue(value, key, errors);
73
+ else if (value === true) checkBooleanShortcut(key, key, errors);
74
+ else if (value !== null && typeof value === "object" && !Array.isArray(value)) checkNestedMetrics(value, key, errors);
75
+ }
76
+ return {
77
+ errors,
78
+ warnings,
79
+ info
80
+ };
81
+ }
82
+
83
+ //#endregion
84
+ export { validateCoverageTargetsShape };
@@ -0,0 +1,113 @@
1
+ //#region src/utils/validate-phase-transition.ts
2
+ /**
3
+ * Return the artifact kind a phase transition requires, plus a
4
+ * human-readable hint for surfacing in remediations.
5
+ *
6
+ * Exported so the MCP `tdd_phase_transition_request` tool can pre-compute
7
+ * the expected kind when the agent omits `citedArtifactId` (auto-resolve
8
+ * to the most recent matching artifact). The transitions without a
9
+ * required artifact (`spike→red`, `*→red`, etc.) return `null`.
10
+ * @public
11
+ */
12
+ const requiredArtifactForTransition = (from, to) => {
13
+ if (from === "red" && to === "green") return {
14
+ kind: "test_failed_run",
15
+ humanHint: "Run the failing test via run_tests, then record the test_failed_run artifact before requesting red→green."
16
+ };
17
+ if (from === "green" && to === "refactor") return {
18
+ kind: "test_passed_run",
19
+ humanHint: "Run the test via run_tests and confirm it passes; record test_passed_run before requesting green→refactor."
20
+ };
21
+ if (from === "refactor" && to === "red") return {
22
+ kind: "test_passed_run",
23
+ humanHint: "Refactor must end with all tests still passing; record test_passed_run before starting the next behavior."
24
+ };
25
+ return null;
26
+ };
27
+ /** @public */
28
+ const validatePhaseTransition = (ctx) => {
29
+ if (ctx.requested_phase === "green" && ctx.current_phase !== "red" && ctx.current_phase !== "red.triangulate" && ctx.current_phase !== "green.fake-it") return {
30
+ accepted: false,
31
+ phase: ctx.current_phase,
32
+ denialReason: "wrong_source_phase",
33
+ remediation: {
34
+ suggestedTool: "tdd_phase_transition_request",
35
+ suggestedArgs: { requestedPhase: "red" },
36
+ humanHint: `Cannot transition from '${ctx.current_phase}' directly to 'green'. The red phase must be entered explicitly first (${ctx.current_phase}→red), then a failing test written and run, then red→green requested with a test_failed_run artifact.`
37
+ }
38
+ };
39
+ const expected = requiredArtifactForTransition(ctx.current_phase, ctx.requested_phase);
40
+ if (expected === null) return {
41
+ accepted: true,
42
+ phase: ctx.requested_phase
43
+ };
44
+ if (ctx.cited_artifact.artifact_kind !== expected.kind) return {
45
+ accepted: false,
46
+ phase: ctx.current_phase,
47
+ denialReason: "wrong_artifact_kind",
48
+ remediation: {
49
+ suggestedTool: "run_tests",
50
+ suggestedArgs: {},
51
+ humanHint: expected.humanHint
52
+ }
53
+ };
54
+ if (ctx.cited_artifact.test_case_id === null) return {
55
+ accepted: false,
56
+ phase: ctx.current_phase,
57
+ denialReason: "missing_artifact_evidence",
58
+ remediation: {
59
+ suggestedTool: "run_tests",
60
+ suggestedArgs: {},
61
+ humanHint: "The cited artifact has no specific test (test_case_id is null), so it cannot be bound to this phase. Run a specific failing test via run_tests so the resulting artifact carries a test_case_id, then cite that artifact."
62
+ }
63
+ };
64
+ if (expected.kind === "test_failed_run") {
65
+ if (ctx.cited_artifact.test_case_created_turn_at !== null && ctx.cited_artifact.test_case_created_turn_at < ctx.phase_started_at) return {
66
+ accepted: false,
67
+ phase: ctx.current_phase,
68
+ denialReason: "evidence_not_in_phase_window",
69
+ remediation: {
70
+ suggestedTool: "run_tests",
71
+ suggestedArgs: {},
72
+ humanHint: "The cited test was authored before this phase started. Write a new failing test inside the current phase."
73
+ }
74
+ };
75
+ if (!ctx.cited_artifact.test_case_authored_in_session) return {
76
+ accepted: false,
77
+ phase: ctx.current_phase,
78
+ denialReason: "evidence_not_in_phase_window",
79
+ remediation: {
80
+ suggestedTool: "run_tests",
81
+ suggestedArgs: {},
82
+ humanHint: "The cited test was not authored in this TDD session. Write the test yourself in the current phase."
83
+ }
84
+ };
85
+ }
86
+ if (ctx.requested_behavior_id !== null && ctx.cited_artifact.behavior_id !== ctx.requested_behavior_id) return {
87
+ accepted: false,
88
+ phase: ctx.current_phase,
89
+ denialReason: "evidence_not_for_behavior",
90
+ remediation: {
91
+ suggestedTool: "run_tests",
92
+ suggestedArgs: {},
93
+ humanHint: "The cited artifact references a different behavior than the one being transitioned. Run the test for the requested behavior."
94
+ }
95
+ };
96
+ if (expected.kind === "test_failed_run" && ctx.cited_artifact.test_run_id !== null && ctx.cited_artifact.test_first_failure_run_id !== null && ctx.cited_artifact.test_first_failure_run_id !== ctx.cited_artifact.test_run_id) return {
97
+ accepted: false,
98
+ phase: ctx.current_phase,
99
+ denialReason: "evidence_test_was_already_failing",
100
+ remediation: {
101
+ suggestedTool: "run_tests",
102
+ suggestedArgs: {},
103
+ humanHint: "The cited test was already failing before this TDD session. Write a new test for the goal."
104
+ }
105
+ };
106
+ return {
107
+ accepted: true,
108
+ phase: ctx.requested_phase
109
+ };
110
+ };
111
+
112
+ //#endregion
113
+ export { requiredArtifactForTransition, validatePhaseTransition };