@nathapp/nax 0.37.0 → 0.38.1
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/dist/nax.js +3258 -2894
- package/package.json +4 -1
- package/src/agents/claude-complete.ts +72 -0
- package/src/agents/claude-execution.ts +189 -0
- package/src/agents/claude-interactive.ts +77 -0
- package/src/agents/claude-plan.ts +23 -8
- package/src/agents/claude.ts +64 -349
- package/src/analyze/classifier.ts +2 -1
- package/src/cli/config-descriptions.ts +206 -0
- package/src/cli/config-diff.ts +103 -0
- package/src/cli/config-display.ts +285 -0
- package/src/cli/config-get.ts +55 -0
- package/src/cli/config.ts +7 -618
- package/src/cli/prompts-export.ts +58 -0
- package/src/cli/prompts-init.ts +200 -0
- package/src/cli/prompts-main.ts +237 -0
- package/src/cli/prompts-tdd.ts +78 -0
- package/src/cli/prompts.ts +10 -541
- package/src/commands/logs-formatter.ts +201 -0
- package/src/commands/logs-reader.ts +171 -0
- package/src/commands/logs.ts +11 -362
- package/src/config/loader.ts +4 -15
- package/src/config/runtime-types.ts +448 -0
- package/src/config/schema-types.ts +53 -0
- package/src/config/types.ts +49 -486
- package/src/context/auto-detect.ts +2 -1
- package/src/context/builder.ts +3 -2
- package/src/execution/crash-heartbeat.ts +77 -0
- package/src/execution/crash-recovery.ts +23 -365
- package/src/execution/crash-signals.ts +149 -0
- package/src/execution/crash-writer.ts +154 -0
- package/src/execution/parallel-coordinator.ts +278 -0
- package/src/execution/parallel-executor-rectification-pass.ts +117 -0
- package/src/execution/parallel-executor-rectify.ts +135 -0
- package/src/execution/parallel-executor.ts +19 -211
- package/src/execution/parallel-worker.ts +148 -0
- package/src/execution/parallel.ts +5 -404
- package/src/execution/pid-registry.ts +3 -8
- package/src/execution/runner-completion.ts +160 -0
- package/src/execution/runner-execution.ts +221 -0
- package/src/execution/runner-setup.ts +82 -0
- package/src/execution/runner.ts +53 -202
- package/src/execution/timeout-handler.ts +100 -0
- package/src/hooks/runner.ts +11 -21
- package/src/metrics/tracker.ts +7 -30
- package/src/pipeline/runner.ts +2 -1
- package/src/pipeline/stages/completion.ts +0 -1
- package/src/pipeline/stages/context.ts +2 -1
- package/src/plugins/extensions.ts +225 -0
- package/src/plugins/loader.ts +2 -1
- package/src/plugins/types.ts +16 -221
- package/src/prd/index.ts +2 -1
- package/src/prd/validate.ts +41 -0
- package/src/precheck/checks-blockers.ts +15 -419
- package/src/precheck/checks-cli.ts +68 -0
- package/src/precheck/checks-config.ts +102 -0
- package/src/precheck/checks-git.ts +87 -0
- package/src/precheck/checks-system.ts +163 -0
- package/src/review/orchestrator.ts +19 -6
- package/src/review/runner.ts +17 -5
- package/src/routing/chain.ts +2 -1
- package/src/routing/loader.ts +2 -5
- package/src/tdd/orchestrator.ts +2 -1
- package/src/tdd/verdict-reader.ts +266 -0
- package/src/tdd/verdict.ts +6 -271
- package/src/utils/errors.ts +12 -0
- package/src/utils/git.ts +12 -5
- package/src/utils/json-file.ts +72 -0
- package/src/verification/executor.ts +2 -1
- package/src/verification/smart-runner.ts +23 -3
- package/src/worktree/manager.ts +9 -3
- package/src/worktree/merge.ts +3 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-level precheck implementations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, statSync } from "node:fs";
|
|
6
|
+
import type { NaxConfig } from "../config";
|
|
7
|
+
import type { Check } from "./types";
|
|
8
|
+
|
|
9
|
+
/** Check if dependencies are installed (language-aware). Detects: node_modules, target, venv, vendor */
|
|
10
|
+
export async function checkDependenciesInstalled(workdir: string): Promise<Check> {
|
|
11
|
+
const depPaths = [
|
|
12
|
+
{ path: "node_modules" },
|
|
13
|
+
{ path: "target" },
|
|
14
|
+
{ path: "venv" },
|
|
15
|
+
{ path: ".venv" },
|
|
16
|
+
{ path: "vendor" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const found: string[] = [];
|
|
20
|
+
for (const { path } of depPaths) {
|
|
21
|
+
const fullPath = `${workdir}/${path}`;
|
|
22
|
+
if (existsSync(fullPath)) {
|
|
23
|
+
const stats = statSync(fullPath);
|
|
24
|
+
if (stats.isDirectory()) {
|
|
25
|
+
found.push(path);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const passed = found.length > 0;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
name: "dependencies-installed",
|
|
34
|
+
tier: "blocker",
|
|
35
|
+
passed,
|
|
36
|
+
message: passed ? `Dependencies found: ${found.join(", ")}` : "No dependency directories detected",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Check if test command works. Skips silently if command is null/false. */
|
|
41
|
+
export async function checkTestCommand(config: NaxConfig): Promise<Check> {
|
|
42
|
+
const testCommand = config.execution.testCommand || (config.quality?.commands?.test as string | undefined);
|
|
43
|
+
|
|
44
|
+
if (!testCommand || testCommand === null) {
|
|
45
|
+
return {
|
|
46
|
+
name: "test-command-works",
|
|
47
|
+
tier: "blocker",
|
|
48
|
+
passed: true,
|
|
49
|
+
message: "Test command not configured (skipped)",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parts = testCommand.split(" ");
|
|
54
|
+
const [cmd, ...args] = parts;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const proc = Bun.spawn([cmd, ...args, "--help"], {
|
|
58
|
+
stdout: "pipe",
|
|
59
|
+
stderr: "pipe",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const exitCode = await proc.exited;
|
|
63
|
+
const passed = exitCode === 0;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
name: "test-command-works",
|
|
67
|
+
tier: "blocker",
|
|
68
|
+
passed,
|
|
69
|
+
message: passed ? "Test command is available" : `Test command failed: ${testCommand}`,
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return {
|
|
73
|
+
name: "test-command-works",
|
|
74
|
+
tier: "blocker",
|
|
75
|
+
passed: false,
|
|
76
|
+
message: `Test command failed: ${testCommand}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Check if lint command works. Skips silently if command is null/false. */
|
|
82
|
+
export async function checkLintCommand(config: NaxConfig): Promise<Check> {
|
|
83
|
+
const lintCommand = config.execution.lintCommand;
|
|
84
|
+
|
|
85
|
+
if (!lintCommand || lintCommand === null) {
|
|
86
|
+
return {
|
|
87
|
+
name: "lint-command-works",
|
|
88
|
+
tier: "blocker",
|
|
89
|
+
passed: true,
|
|
90
|
+
message: "Lint command not configured (skipped)",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const parts = lintCommand.split(" ");
|
|
95
|
+
const [cmd, ...args] = parts;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const proc = Bun.spawn([cmd, ...args, "--help"], {
|
|
99
|
+
stdout: "pipe",
|
|
100
|
+
stderr: "pipe",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const exitCode = await proc.exited;
|
|
104
|
+
const passed = exitCode === 0;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
name: "lint-command-works",
|
|
108
|
+
tier: "blocker",
|
|
109
|
+
passed,
|
|
110
|
+
message: passed ? "Lint command is available" : `Lint command failed: ${lintCommand}`,
|
|
111
|
+
};
|
|
112
|
+
} catch {
|
|
113
|
+
return {
|
|
114
|
+
name: "lint-command-works",
|
|
115
|
+
tier: "blocker",
|
|
116
|
+
passed: false,
|
|
117
|
+
message: `Lint command failed: ${lintCommand}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Check if typecheck command works. Skips silently if command is null/false. */
|
|
123
|
+
export async function checkTypecheckCommand(config: NaxConfig): Promise<Check> {
|
|
124
|
+
const typecheckCommand = config.execution.typecheckCommand;
|
|
125
|
+
|
|
126
|
+
if (!typecheckCommand || typecheckCommand === null) {
|
|
127
|
+
return {
|
|
128
|
+
name: "typecheck-command-works",
|
|
129
|
+
tier: "blocker",
|
|
130
|
+
passed: true,
|
|
131
|
+
message: "Typecheck command not configured (skipped)",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const parts = typecheckCommand.split(" ");
|
|
136
|
+
const [cmd, ...args] = parts;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const proc = Bun.spawn([cmd, ...args, "--help"], {
|
|
140
|
+
stdout: "pipe",
|
|
141
|
+
stderr: "pipe",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const exitCode = await proc.exited;
|
|
145
|
+
const passed = exitCode === 0;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
name: "typecheck-command-works",
|
|
149
|
+
tier: "blocker",
|
|
150
|
+
passed,
|
|
151
|
+
message: passed
|
|
152
|
+
? `Typecheck command is available: ${typecheckCommand}`
|
|
153
|
+
: `Typecheck command failed: ${typecheckCommand}`,
|
|
154
|
+
};
|
|
155
|
+
} catch {
|
|
156
|
+
return {
|
|
157
|
+
name: "typecheck-command-works",
|
|
158
|
+
tier: "blocker",
|
|
159
|
+
passed: false,
|
|
160
|
+
message: `Typecheck command failed: ${typecheckCommand}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -12,17 +12,31 @@ import { spawn } from "bun";
|
|
|
12
12
|
import type { NaxConfig } from "../config";
|
|
13
13
|
import { getSafeLogger } from "../logger";
|
|
14
14
|
import type { PluginRegistry } from "../plugins";
|
|
15
|
+
import { errorMessage } from "../utils/errors";
|
|
15
16
|
import { runReview } from "./runner";
|
|
16
17
|
import type { ReviewConfig, ReviewResult } from "./types";
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Injectable dependencies for getChangedFiles() — allows tests to intercept
|
|
21
|
+
* spawn calls without requiring the git binary.
|
|
22
|
+
*
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
export const _orchestratorDeps = { spawn };
|
|
26
|
+
|
|
18
27
|
async function getChangedFiles(workdir: string, baseRef?: string): Promise<string[]> {
|
|
19
28
|
try {
|
|
20
29
|
const diffArgs = ["diff", "--name-only"];
|
|
21
30
|
const [stagedProc, unstagedProc, baseProc] = [
|
|
22
|
-
spawn({ cmd: ["git", ...diffArgs, "--cached"], cwd: workdir, stdout: "pipe", stderr: "pipe" }),
|
|
23
|
-
spawn({ cmd: ["git", ...diffArgs], cwd: workdir, stdout: "pipe", stderr: "pipe" }),
|
|
31
|
+
_orchestratorDeps.spawn({ cmd: ["git", ...diffArgs, "--cached"], cwd: workdir, stdout: "pipe", stderr: "pipe" }),
|
|
32
|
+
_orchestratorDeps.spawn({ cmd: ["git", ...diffArgs], cwd: workdir, stdout: "pipe", stderr: "pipe" }),
|
|
24
33
|
baseRef
|
|
25
|
-
? spawn({
|
|
34
|
+
? _orchestratorDeps.spawn({
|
|
35
|
+
cmd: ["git", ...diffArgs, `${baseRef}...HEAD`],
|
|
36
|
+
cwd: workdir,
|
|
37
|
+
stdout: "pipe",
|
|
38
|
+
stderr: "pipe",
|
|
39
|
+
})
|
|
26
40
|
: null,
|
|
27
41
|
];
|
|
28
42
|
|
|
@@ -73,8 +87,7 @@ export class ReviewOrchestrator {
|
|
|
73
87
|
const reviewers = plugins.getReviewers();
|
|
74
88
|
if (reviewers.length > 0) {
|
|
75
89
|
// Use the story's start ref if available to capture auto-committed changes
|
|
76
|
-
|
|
77
|
-
const baseRef = (executionConfig as any)?.storyGitRef;
|
|
90
|
+
const baseRef = executionConfig?.storyGitRef;
|
|
78
91
|
const changedFiles = await getChangedFiles(workdir, baseRef);
|
|
79
92
|
const pluginResults: ReviewResult["pluginReviewers"] = [];
|
|
80
93
|
|
|
@@ -108,7 +121,7 @@ export class ReviewOrchestrator {
|
|
|
108
121
|
};
|
|
109
122
|
}
|
|
110
123
|
} catch (error) {
|
|
111
|
-
const errorMsg =
|
|
124
|
+
const errorMsg = errorMessage(error);
|
|
112
125
|
logger?.warn("review", `Plugin reviewer threw error: ${reviewer.name}`, { error: errorMsg });
|
|
113
126
|
pluginResults.push({ name: reviewer.name, passed: false, output: "", error: errorMsg });
|
|
114
127
|
builtIn.pluginReviewers = pluginResults;
|
package/src/review/runner.ts
CHANGED
|
@@ -7,8 +7,17 @@
|
|
|
7
7
|
import { spawn } from "bun";
|
|
8
8
|
import type { ExecutionConfig } from "../config/schema";
|
|
9
9
|
import { getSafeLogger } from "../logger";
|
|
10
|
+
import { errorMessage } from "../utils/errors";
|
|
10
11
|
import type { ReviewCheckName, ReviewCheckResult, ReviewConfig, ReviewResult } from "./types";
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Injectable dependencies for runner internals — allows tests to intercept
|
|
15
|
+
* Bun.file and spawn calls without mock.module().
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export const _reviewRunnerDeps = { spawn, file: Bun.file };
|
|
20
|
+
|
|
12
21
|
/** Default commands for each check type */
|
|
13
22
|
const DEFAULT_COMMANDS: Record<ReviewCheckName, string> = {
|
|
14
23
|
typecheck: "bun run typecheck",
|
|
@@ -21,7 +30,7 @@ const DEFAULT_COMMANDS: Record<ReviewCheckName, string> = {
|
|
|
21
30
|
*/
|
|
22
31
|
async function loadPackageJson(workdir: string): Promise<Record<string, unknown> | null> {
|
|
23
32
|
try {
|
|
24
|
-
const file =
|
|
33
|
+
const file = _reviewRunnerDeps.file(`${workdir}/package.json`);
|
|
25
34
|
const content = await file.text();
|
|
26
35
|
return JSON.parse(content);
|
|
27
36
|
} catch {
|
|
@@ -80,6 +89,9 @@ async function resolveCommand(
|
|
|
80
89
|
/** Default timeout for review checks (lint, typecheck). BUG-039. */
|
|
81
90
|
const REVIEW_CHECK_TIMEOUT_MS = 120_000;
|
|
82
91
|
|
|
92
|
+
/** Grace period between SIGTERM and SIGKILL for review check cleanup. */
|
|
93
|
+
const SIGKILL_GRACE_PERIOD_MS = 5_000;
|
|
94
|
+
|
|
83
95
|
/**
|
|
84
96
|
* Run a single review check with a hard timeout.
|
|
85
97
|
*
|
|
@@ -95,7 +107,7 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
|
|
|
95
107
|
const args = parts.slice(1);
|
|
96
108
|
|
|
97
109
|
// Spawn the process
|
|
98
|
-
const proc = spawn({
|
|
110
|
+
const proc = _reviewRunnerDeps.spawn({
|
|
99
111
|
cmd: [executable, ...args],
|
|
100
112
|
cwd: workdir,
|
|
101
113
|
stdout: "pipe",
|
|
@@ -117,7 +129,7 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
|
|
|
117
129
|
} catch {
|
|
118
130
|
/* already exited */
|
|
119
131
|
}
|
|
120
|
-
},
|
|
132
|
+
}, SIGKILL_GRACE_PERIOD_MS);
|
|
121
133
|
}, REVIEW_CHECK_TIMEOUT_MS);
|
|
122
134
|
|
|
123
135
|
// Wait for completion
|
|
@@ -154,7 +166,7 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
|
|
|
154
166
|
command,
|
|
155
167
|
success: false,
|
|
156
168
|
exitCode: -1,
|
|
157
|
-
output:
|
|
169
|
+
output: errorMessage(error),
|
|
158
170
|
durationMs: Date.now() - startTime,
|
|
159
171
|
};
|
|
160
172
|
}
|
|
@@ -166,7 +178,7 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
|
|
|
166
178
|
*/
|
|
167
179
|
async function getUncommittedFilesImpl(workdir: string): Promise<string[]> {
|
|
168
180
|
try {
|
|
169
|
-
const proc = spawn({
|
|
181
|
+
const proc = _reviewRunnerDeps.spawn({
|
|
170
182
|
cmd: ["git", "diff", "--name-only", "HEAD"],
|
|
171
183
|
cwd: workdir,
|
|
172
184
|
stdout: "pipe",
|
package/src/routing/chain.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { getSafeLogger } from "../logger";
|
|
9
9
|
import type { UserStory } from "../prd/types";
|
|
10
|
+
import { errorMessage } from "../utils/errors";
|
|
10
11
|
import type { RoutingContext, RoutingDecision, RoutingStrategy } from "./strategy";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -51,7 +52,7 @@ export class StrategyChain {
|
|
|
51
52
|
logger?.error("routing", `Plugin router "${strategy.name}" failed`, {
|
|
52
53
|
strategyName: strategy.name,
|
|
53
54
|
storyId: story.id,
|
|
54
|
-
error:
|
|
55
|
+
error: errorMessage(error),
|
|
55
56
|
});
|
|
56
57
|
// Continue to next strategy
|
|
57
58
|
}
|
package/src/routing/loader.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
|
+
import { errorMessage } from "../utils/errors";
|
|
8
9
|
import { validateModulePath } from "../utils/path-security";
|
|
9
10
|
import type { RoutingStrategy } from "./strategy";
|
|
10
11
|
|
|
@@ -56,10 +57,6 @@ export async function loadCustomStrategy(strategyPath: string, workdir: string):
|
|
|
56
57
|
|
|
57
58
|
return strategy as RoutingStrategy;
|
|
58
59
|
} catch (error) {
|
|
59
|
-
throw new Error(
|
|
60
|
-
`Failed to load custom routing strategy from ${absolutePath}: ${
|
|
61
|
-
error instanceof Error ? error.message : String(error)
|
|
62
|
-
}`,
|
|
63
|
-
);
|
|
60
|
+
throw new Error(`Failed to load custom routing strategy from ${absolutePath}: ${errorMessage(error)}`);
|
|
64
61
|
}
|
|
65
62
|
}
|
package/src/tdd/orchestrator.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { resolveModel } from "../config";
|
|
|
13
13
|
import { isGreenfieldStory } from "../context/greenfield";
|
|
14
14
|
import { getLogger } from "../logger";
|
|
15
15
|
import type { UserStory } from "../prd";
|
|
16
|
+
import { errorMessage } from "../utils/errors";
|
|
16
17
|
import { captureGitRef } from "../utils/git";
|
|
17
18
|
import { executeWithTimeout } from "../verification";
|
|
18
19
|
import { runFullSuiteGate } from "./rectification-gate";
|
|
@@ -379,7 +380,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
|
|
|
379
380
|
} catch (error) {
|
|
380
381
|
logger.error("tdd", "Failed to rollback git changes after TDD failure", {
|
|
381
382
|
storyId: story.id,
|
|
382
|
-
error:
|
|
383
|
+
error: errorMessage(error),
|
|
383
384
|
});
|
|
384
385
|
}
|
|
385
386
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verdict file reading, validation, and coercion
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { unlink } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { getLogger } from "../logger";
|
|
8
|
+
import type { VerifierVerdict } from "./verdict";
|
|
9
|
+
|
|
10
|
+
/** File name written by the verifier agent */
|
|
11
|
+
export const VERDICT_FILE = ".nax-verifier-verdict.json";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validate that a parsed object has the required fields for a VerifierVerdict.
|
|
15
|
+
* Returns true if the object appears to be a valid verdict.
|
|
16
|
+
*/
|
|
17
|
+
export function isValidVerdict(obj: unknown): obj is VerifierVerdict {
|
|
18
|
+
if (!obj || typeof obj !== "object") return false;
|
|
19
|
+
const v = obj as Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
// Required top-level fields
|
|
22
|
+
if (v.version !== 1) return false;
|
|
23
|
+
if (typeof v.approved !== "boolean") return false;
|
|
24
|
+
|
|
25
|
+
// tests sub-object
|
|
26
|
+
if (!v.tests || typeof v.tests !== "object") return false;
|
|
27
|
+
const tests = v.tests as Record<string, unknown>;
|
|
28
|
+
if (typeof tests.allPassing !== "boolean") return false;
|
|
29
|
+
if (typeof tests.passCount !== "number") return false;
|
|
30
|
+
if (typeof tests.failCount !== "number") return false;
|
|
31
|
+
|
|
32
|
+
// testModifications sub-object
|
|
33
|
+
if (!v.testModifications || typeof v.testModifications !== "object") return false;
|
|
34
|
+
const mods = v.testModifications as Record<string, unknown>;
|
|
35
|
+
if (typeof mods.detected !== "boolean") return false;
|
|
36
|
+
if (!Array.isArray(mods.files)) return false;
|
|
37
|
+
if (typeof mods.legitimate !== "boolean") return false;
|
|
38
|
+
if (typeof mods.reasoning !== "string") return false;
|
|
39
|
+
|
|
40
|
+
// acceptanceCriteria sub-object
|
|
41
|
+
if (!v.acceptanceCriteria || typeof v.acceptanceCriteria !== "object") return false;
|
|
42
|
+
const ac = v.acceptanceCriteria as Record<string, unknown>;
|
|
43
|
+
if (typeof ac.allMet !== "boolean") return false;
|
|
44
|
+
if (!Array.isArray(ac.criteria)) return false;
|
|
45
|
+
|
|
46
|
+
// quality sub-object
|
|
47
|
+
if (!v.quality || typeof v.quality !== "object") return false;
|
|
48
|
+
const quality = v.quality as Record<string, unknown>;
|
|
49
|
+
if (!["good", "acceptable", "poor"].includes(quality.rating as string)) return false;
|
|
50
|
+
if (!Array.isArray(quality.issues)) return false;
|
|
51
|
+
|
|
52
|
+
// fixes and reasoning
|
|
53
|
+
if (!Array.isArray(v.fixes)) return false;
|
|
54
|
+
if (typeof v.reasoning !== "string") return false;
|
|
55
|
+
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Coerce a free-form verdict object into the expected VerifierVerdict schema.
|
|
61
|
+
* Maps common agent-improvised patterns (verdict:"PASS", verification_summary, etc.)
|
|
62
|
+
* to the structured format. Returns null if too malformed to coerce.
|
|
63
|
+
*/
|
|
64
|
+
export function coerceVerdict(obj: Record<string, unknown>): VerifierVerdict | null {
|
|
65
|
+
try {
|
|
66
|
+
// Determine approval status
|
|
67
|
+
const verdictStr = String(obj.verdict ?? "").toUpperCase();
|
|
68
|
+
const approved =
|
|
69
|
+
verdictStr === "PASS" ||
|
|
70
|
+
verdictStr === "APPROVED" ||
|
|
71
|
+
verdictStr.startsWith("VERIFIED") ||
|
|
72
|
+
verdictStr.includes("ALL ACCEPTANCE CRITERIA MET") ||
|
|
73
|
+
obj.approved === true;
|
|
74
|
+
|
|
75
|
+
// Parse test results from verification_summary or top-level
|
|
76
|
+
let passCount = 0;
|
|
77
|
+
let failCount = 0;
|
|
78
|
+
let allPassing = approved;
|
|
79
|
+
const summary = obj.verification_summary as Record<string, unknown> | undefined;
|
|
80
|
+
if (summary?.test_results && typeof summary.test_results === "string") {
|
|
81
|
+
// Parse "45/45 PASS" or "42/45 PASS" patterns
|
|
82
|
+
const match = (summary.test_results as string).match(/(\d+)\/(\d+)/);
|
|
83
|
+
if (match) {
|
|
84
|
+
passCount = Number.parseInt(match[1], 10);
|
|
85
|
+
const total = Number.parseInt(match[2], 10);
|
|
86
|
+
failCount = total - passCount;
|
|
87
|
+
allPassing = failCount === 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Also check top-level tests object (partial schema compliance)
|
|
91
|
+
if (obj.tests && typeof obj.tests === "object") {
|
|
92
|
+
const t = obj.tests as Record<string, unknown>;
|
|
93
|
+
if (typeof t.passCount === "number") passCount = t.passCount;
|
|
94
|
+
if (typeof t.failCount === "number") failCount = t.failCount;
|
|
95
|
+
if (typeof t.allPassing === "boolean") allPassing = t.allPassing;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Parse acceptance criteria from acceptance_criteria_review or acceptanceCriteria
|
|
99
|
+
const criteria: Array<{ criterion: string; met: boolean; note?: string }> = [];
|
|
100
|
+
let allMet = approved;
|
|
101
|
+
const acReview = obj.acceptance_criteria_review as Record<string, unknown> | undefined;
|
|
102
|
+
if (acReview) {
|
|
103
|
+
for (const [key, val] of Object.entries(acReview)) {
|
|
104
|
+
if (key.startsWith("criterion") && val && typeof val === "object") {
|
|
105
|
+
const c = val as Record<string, unknown>;
|
|
106
|
+
const met = String(c.status ?? "").toUpperCase() === "SATISFIED" || c.met === true;
|
|
107
|
+
criteria.push({
|
|
108
|
+
criterion: String(c.name ?? c.criterion ?? key),
|
|
109
|
+
met,
|
|
110
|
+
note: c.evidence ? String(c.evidence).slice(0, 200) : undefined,
|
|
111
|
+
});
|
|
112
|
+
if (!met) allMet = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Also check top-level acceptanceCriteria
|
|
117
|
+
if (obj.acceptanceCriteria && typeof obj.acceptanceCriteria === "object") {
|
|
118
|
+
const ac = obj.acceptanceCriteria as Record<string, unknown>;
|
|
119
|
+
if (typeof ac.allMet === "boolean") allMet = ac.allMet;
|
|
120
|
+
if (Array.isArray(ac.criteria)) {
|
|
121
|
+
for (const c of ac.criteria) {
|
|
122
|
+
if (c && typeof c === "object") {
|
|
123
|
+
criteria.push(c as { criterion: string; met: boolean; note?: string });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Parse summary AC count like "4/4 SATISFIED"
|
|
129
|
+
if (criteria.length === 0 && summary?.acceptance_criteria && typeof summary.acceptance_criteria === "string") {
|
|
130
|
+
const acMatch = (summary.acceptance_criteria as string).match(/(\d+)\/(\d+)/);
|
|
131
|
+
if (acMatch) {
|
|
132
|
+
const met = Number.parseInt(acMatch[1], 10);
|
|
133
|
+
const total = Number.parseInt(acMatch[2], 10);
|
|
134
|
+
allMet = met === total;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Parse quality
|
|
139
|
+
let rating: "good" | "acceptable" | "poor" = "acceptable";
|
|
140
|
+
const qualityStr = summary?.code_quality
|
|
141
|
+
? String(summary.code_quality).toLowerCase()
|
|
142
|
+
: obj.quality && typeof obj.quality === "object"
|
|
143
|
+
? String((obj.quality as Record<string, unknown>).rating ?? "acceptable").toLowerCase()
|
|
144
|
+
: "acceptable";
|
|
145
|
+
if (qualityStr === "high" || qualityStr === "good") rating = "good";
|
|
146
|
+
else if (qualityStr === "low" || qualityStr === "poor") rating = "poor";
|
|
147
|
+
|
|
148
|
+
// Build coerced verdict
|
|
149
|
+
return {
|
|
150
|
+
version: 1,
|
|
151
|
+
approved,
|
|
152
|
+
tests: { allPassing, passCount, failCount },
|
|
153
|
+
testModifications: {
|
|
154
|
+
detected: false,
|
|
155
|
+
files: [],
|
|
156
|
+
legitimate: true,
|
|
157
|
+
reasoning: "Not assessed in free-form verdict",
|
|
158
|
+
},
|
|
159
|
+
acceptanceCriteria: { allMet, criteria },
|
|
160
|
+
quality: { rating, issues: [] },
|
|
161
|
+
fixes: Array.isArray(obj.fixes) ? (obj.fixes as string[]) : [],
|
|
162
|
+
reasoning:
|
|
163
|
+
typeof obj.reasoning === "string"
|
|
164
|
+
? obj.reasoning
|
|
165
|
+
: typeof obj.overall_status === "string"
|
|
166
|
+
? (obj.overall_status as string)
|
|
167
|
+
: summary?.overall_status
|
|
168
|
+
? String(summary.overall_status)
|
|
169
|
+
: `Coerced from free-form verdict: ${verdictStr}`,
|
|
170
|
+
};
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read the verifier verdict file from the workdir.
|
|
178
|
+
*
|
|
179
|
+
* Returns the parsed VerifierVerdict when the file exists and is valid.
|
|
180
|
+
* Attempts tolerant coercion if the file doesn't match the strict schema.
|
|
181
|
+
* Returns null if:
|
|
182
|
+
* - File does not exist
|
|
183
|
+
* - File is not valid JSON
|
|
184
|
+
* - Required fields are missing and coercion fails
|
|
185
|
+
*
|
|
186
|
+
* Never throws.
|
|
187
|
+
*/
|
|
188
|
+
export async function readVerdict(workdir: string): Promise<VerifierVerdict | null> {
|
|
189
|
+
const logger = getLogger();
|
|
190
|
+
const verdictPath = path.join(workdir, VERDICT_FILE);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const file = Bun.file(verdictPath);
|
|
194
|
+
const exists = await file.exists();
|
|
195
|
+
if (!exists) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Read as text first so we can log raw content on parse failure
|
|
200
|
+
let rawText: string;
|
|
201
|
+
try {
|
|
202
|
+
rawText = await file.text();
|
|
203
|
+
} catch (readErr) {
|
|
204
|
+
logger.warn("tdd", "Failed to read verifier verdict file", {
|
|
205
|
+
path: verdictPath,
|
|
206
|
+
error: String(readErr),
|
|
207
|
+
});
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let parsed: unknown;
|
|
212
|
+
try {
|
|
213
|
+
parsed = JSON.parse(rawText);
|
|
214
|
+
} catch (parseErr) {
|
|
215
|
+
logger.warn("tdd", "Verifier verdict file is not valid JSON — ignoring", {
|
|
216
|
+
path: verdictPath,
|
|
217
|
+
error: String(parseErr),
|
|
218
|
+
rawContent: rawText.slice(0, 1000),
|
|
219
|
+
});
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (isValidVerdict(parsed)) {
|
|
224
|
+
return parsed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Strict validation failed — attempt tolerant coercion
|
|
228
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
229
|
+
const coerced = coerceVerdict(parsed as Record<string, unknown>);
|
|
230
|
+
if (coerced) {
|
|
231
|
+
logger.info("tdd", "Coerced free-form verdict to structured format", {
|
|
232
|
+
path: verdictPath,
|
|
233
|
+
approved: coerced.approved,
|
|
234
|
+
passCount: coerced.tests.passCount,
|
|
235
|
+
failCount: coerced.tests.failCount,
|
|
236
|
+
});
|
|
237
|
+
return coerced;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
logger.warn("tdd", "Verifier verdict file missing required fields and coercion failed — ignoring", {
|
|
242
|
+
path: verdictPath,
|
|
243
|
+
content: JSON.stringify(parsed).slice(0, 500),
|
|
244
|
+
});
|
|
245
|
+
return null;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
logger.warn("tdd", "Failed to read verifier verdict file — ignoring", {
|
|
248
|
+
path: verdictPath,
|
|
249
|
+
error: String(err),
|
|
250
|
+
});
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Delete the verifier verdict file from the workdir.
|
|
257
|
+
* Ignores all errors (file may not exist, permissions, etc.).
|
|
258
|
+
*/
|
|
259
|
+
export async function cleanupVerdict(workdir: string): Promise<void> {
|
|
260
|
+
const verdictPath = path.join(workdir, VERDICT_FILE);
|
|
261
|
+
try {
|
|
262
|
+
await unlink(verdictPath);
|
|
263
|
+
} catch {
|
|
264
|
+
// Intentionally ignored — file may not exist or already be deleted
|
|
265
|
+
}
|
|
266
|
+
}
|