@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.
- package/DataReader-2kJ9I9Hh.d.ts +1809 -0
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dispatch.d.ts +48 -0
- package/dispatch.js +5 -0
- package/errors/AgentErrors.js +65 -0
- package/errors/DataStoreError.js +44 -0
- package/errors/DiscoveryError.js +21 -0
- package/errors/PathResolutionError.js +27 -0
- package/errors/ProjectIdentityError.js +29 -0
- package/errors/RunContextError.js +35 -0
- package/errors/TddErrors.js +66 -0
- package/exit-code-for-tag.js +18 -0
- package/formatters/ci-annotations.js +61 -0
- package/formatters/gfm.js +20 -0
- package/formatters/json.js +18 -0
- package/formatters/markdown.js +39 -0
- package/formatters/silent.js +9 -0
- package/formatters/terminal.js +56 -0
- package/index.d.ts +3334 -0
- package/index.js +120 -0
- package/internal-inject-env.js +42 -0
- package/layers/ConfigLive.js +47 -0
- package/layers/DataReaderLive.js +1666 -0
- package/layers/DataStoreLive.js +1185 -0
- package/layers/DetailResolverLive.js +9 -0
- package/layers/DiscoveryRegistryLive.js +70 -0
- package/layers/EnvironmentDetectorLive.js +21 -0
- package/layers/EnvironmentDetectorTest.js +13 -0
- package/layers/ExecutorResolverLive.js +9 -0
- package/layers/FormatSelectorLive.js +20 -0
- package/layers/HistoryTrackerLive.js +47 -0
- package/layers/HistoryTrackerTest.js +25 -0
- package/layers/LoggerLive.js +64 -0
- package/layers/OutputPipelineLive.js +13 -0
- package/layers/OutputRendererLive.js +28 -0
- package/layers/PathResolutionLive.js +36 -0
- package/layers/PerClientSessionMapLive.js +145 -0
- package/layers/ProjectDiscoveryLive.js +70 -0
- package/layers/ProjectDiscoveryTest.js +12 -0
- package/layers/ProjectIdentityLive.js +92 -0
- package/layers/RunContextLive.js +81 -0
- package/lib/format-triage.js +103 -0
- package/lib/format-wrapup.js +67 -0
- package/migrations/0001_initial.js +849 -0
- package/migrations/registry_0001_initial.js +39 -0
- package/migrations/session_map_0001_initial.js +61 -0
- package/package.json +64 -0
- package/schemas/Agent.js +42 -0
- package/schemas/AgentReport.js +71 -0
- package/schemas/Baselines.js +16 -0
- package/schemas/CacheManifest.js +27 -0
- package/schemas/ChannelEvent.js +138 -0
- package/schemas/Common.js +105 -0
- package/schemas/Config.js +28 -0
- package/schemas/Coverage.js +51 -0
- package/schemas/CoverageLevel.js +117 -0
- package/schemas/CoverageTargets.js +39 -0
- package/schemas/History.js +31 -0
- package/schemas/Identity.js +64 -0
- package/schemas/Options.js +57 -0
- package/schemas/RenderState.js +161 -0
- package/schemas/RunEvent.js +163 -0
- package/schemas/Tdd.js +44 -0
- package/schemas/Thresholds.js +30 -0
- package/schemas/Transport.js +16 -0
- package/schemas/Trends.js +23 -0
- package/schemas/turns/FileEditPayload.js +15 -0
- package/schemas/turns/HookFirePayload.js +13 -0
- package/schemas/turns/HypothesisPayload.js +13 -0
- package/schemas/turns/NotePayload.js +13 -0
- package/schemas/turns/ToolCallPayload.js +13 -0
- package/schemas/turns/ToolResultPayload.js +15 -0
- package/schemas/turns/UserPromptPayload.js +12 -0
- package/schemas/turns/index.js +15 -0
- package/services/Config.js +15 -0
- package/services/DataReader.js +8 -0
- package/services/DataStore.js +8 -0
- package/services/DetailResolver.js +8 -0
- package/services/DiscoveryRegistry.js +8 -0
- package/services/EnvironmentDetector.js +8 -0
- package/services/ExecutorResolver.js +8 -0
- package/services/FormatSelector.js +8 -0
- package/services/HistoryTracker.js +8 -0
- package/services/OutputRenderer.js +8 -0
- package/services/PerClientSessionMap.js +22 -0
- package/services/ProjectDiscovery.js +8 -0
- package/services/ProjectIdentity.js +63 -0
- package/services/RunContext.js +53 -0
- package/services/idempotency.js +57 -0
- package/sidecar-dispatch.js +84 -0
- package/sql/assemblers.js +59 -0
- package/testing/layers.js +21 -0
- package/testing.d.ts +25 -0
- package/testing.js +243 -0
- package/tsdoc-metadata.json +11 -0
- package/utils/ansi.js +49 -0
- package/utils/build-report.js +130 -0
- package/utils/canonicalize-git-url.js +66 -0
- package/utils/classify-test.js +22 -0
- package/utils/compress-lines.js +45 -0
- package/utils/compute-trend.js +88 -0
- package/utils/detect-pm.js +58 -0
- package/utils/detect-timeout.js +17 -0
- package/utils/ensure-migrated.js +52 -0
- package/utils/failure-signature.js +33 -0
- package/utils/format-console.js +219 -0
- package/utils/format-fatal-error.js +35 -0
- package/utils/format-gfm.js +161 -0
- package/utils/format-terminal.js +480 -0
- package/utils/function-boundary.js +75 -0
- package/utils/hyperlink.js +11 -0
- package/utils/match-vitest-command.js +88 -0
- package/utils/normalize-workspace-key.js +34 -0
- package/utils/probe-host-metadata.js +99 -0
- package/utils/resolve-data-path.js +66 -0
- package/utils/resolve-project-key-from-cwd.js +52 -0
- package/utils/resolve-workspace-key.js +33 -0
- package/utils/safe-filename.js +34 -0
- package/utils/validate-coverage-targets-shape.js +84 -0
- 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 };
|