@nathapp/nax 0.18.6 → 0.20.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/docs/ROADMAP.md +2 -0
- package/nax/config.json +2 -2
- package/nax/features/nax-compliance/prd.json +52 -0
- package/nax/features/nax-compliance/progress.txt +1 -0
- package/nax/features/v0.19.0-hardening/plan.md +7 -0
- package/nax/features/v0.19.0-hardening/prd.json +84 -0
- package/nax/features/v0.19.0-hardening/progress.txt +7 -0
- package/nax/features/v0.19.0-hardening/spec.md +18 -0
- package/nax/features/v0.19.0-hardening/tasks.md +8 -0
- package/nax/features/verify-v2/prd.json +79 -0
- package/nax/features/verify-v2/progress.txt +3 -0
- package/nax/status.json +27 -0
- package/package.json +2 -2
- package/src/acceptance/fix-generator.ts +6 -2
- package/src/acceptance/generator.ts +3 -1
- package/src/acceptance/types.ts +3 -1
- package/src/agents/claude-plan.ts +6 -5
- package/src/cli/analyze.ts +1 -0
- package/src/cli/init.ts +7 -6
- package/src/config/defaults.ts +3 -1
- package/src/config/schemas.ts +2 -0
- package/src/config/types.ts +6 -0
- package/src/context/injector.ts +18 -18
- package/src/execution/crash-recovery.ts +7 -10
- package/src/execution/lifecycle/acceptance-loop.ts +1 -0
- package/src/execution/lifecycle/index.ts +1 -1
- package/src/execution/lifecycle/precheck-runner.ts +1 -1
- package/src/execution/lifecycle/run-completion.ts +29 -0
- package/src/execution/lifecycle/run-regression.ts +301 -0
- package/src/execution/lifecycle/run-setup.ts +14 -14
- package/src/execution/parallel.ts +1 -1
- package/src/execution/pipeline-result-handler.ts +0 -1
- package/src/execution/post-verify.ts +31 -194
- package/src/execution/runner.ts +2 -19
- package/src/execution/sequential-executor.ts +1 -1
- package/src/hooks/runner.ts +2 -2
- package/src/interaction/plugins/auto.ts +2 -2
- package/src/logger/logger.ts +3 -5
- package/src/pipeline/stages/verify.ts +26 -22
- package/src/plugins/loader.ts +36 -9
- package/src/routing/batch-route.ts +32 -0
- package/src/routing/index.ts +1 -0
- package/src/routing/loader.ts +7 -0
- package/src/utils/path-security.ts +56 -0
- package/src/verification/executor.ts +6 -13
- package/src/verification/smart-runner.ts +52 -0
- package/test/integration/plugins/config-resolution.test.ts +3 -3
- package/test/integration/plugins/loader.test.ts +3 -1
- package/test/integration/precheck-integration.test.ts +18 -11
- package/test/integration/rectification-flow.test.ts +3 -3
- package/test/integration/review-config-commands.test.ts +1 -1
- package/test/integration/security-loader.test.ts +83 -0
- package/test/integration/verify-stage.test.ts +9 -0
- package/test/unit/config/defaults.test.ts +69 -0
- package/test/unit/config/regression-gate-schema.test.ts +159 -0
- package/test/unit/execution/lifecycle/run-completion.test.ts +239 -0
- package/test/unit/execution/lifecycle/run-regression.test.ts +418 -0
- package/test/unit/execution/post-verify-regression.test.ts +31 -84
- package/test/unit/execution/post-verify.test.ts +28 -48
- package/test/unit/formatters.test.ts +2 -3
- package/test/unit/hooks/shell-security.test.ts +40 -0
- package/test/unit/pipeline/stages/verify.test.ts +266 -0
- package/test/unit/pipeline/verify-smart-runner.test.ts +1 -0
- package/test/unit/utils/path-security.test.ts +47 -0
- package/src/execution/lifecycle/run-lifecycle.ts +0 -312
- package/test/unit/run-lifecycle.test.ts +0 -140
package/src/context/injector.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Ruby (Gemfile), Java/Kotlin (pom.xml / build.gradle).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { existsSync
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import type { NaxConfig } from "../config";
|
|
13
13
|
import type { ProjectMetadata } from "./types";
|
|
@@ -68,12 +68,12 @@ async function detectNode(workdir: string): Promise<{ name?: string; lang: strin
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/** Go: read go.mod for module name + direct dependencies */
|
|
71
|
-
function detectGo(workdir: string): { name?: string; lang: string; dependencies: string[] } | null {
|
|
71
|
+
async function detectGo(workdir: string): Promise<{ name?: string; lang: string; dependencies: string[] } | null> {
|
|
72
72
|
const goMod = join(workdir, "go.mod");
|
|
73
73
|
if (!existsSync(goMod)) return null;
|
|
74
74
|
|
|
75
75
|
try {
|
|
76
|
-
const content =
|
|
76
|
+
const content = await Bun.file(goMod).text();
|
|
77
77
|
const moduleMatch = content.match(/^module\s+(\S+)/m);
|
|
78
78
|
const name = moduleMatch?.[1];
|
|
79
79
|
|
|
@@ -95,12 +95,12 @@ function detectGo(workdir: string): { name?: string; lang: string; dependencies:
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/** Rust: read Cargo.toml for package name + dependencies */
|
|
98
|
-
function detectRust(workdir: string): { name?: string; lang: string; dependencies: string[] } | null {
|
|
98
|
+
async function detectRust(workdir: string): Promise<{ name?: string; lang: string; dependencies: string[] } | null> {
|
|
99
99
|
const cargoPath = join(workdir, "Cargo.toml");
|
|
100
100
|
if (!existsSync(cargoPath)) return null;
|
|
101
101
|
|
|
102
102
|
try {
|
|
103
|
-
const content =
|
|
103
|
+
const content = await Bun.file(cargoPath).text();
|
|
104
104
|
const nameMatch = content.match(/^\[package\][^[]*name\s*=\s*"([^"]+)"/ms);
|
|
105
105
|
const name = nameMatch?.[1];
|
|
106
106
|
|
|
@@ -119,7 +119,7 @@ function detectRust(workdir: string): { name?: string; lang: string; dependencie
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/** Python: read pyproject.toml or requirements.txt */
|
|
122
|
-
function detectPython(workdir: string): { name?: string; lang: string; dependencies: string[] } | null {
|
|
122
|
+
async function detectPython(workdir: string): Promise<{ name?: string; lang: string; dependencies: string[] } | null> {
|
|
123
123
|
const pyproject = join(workdir, "pyproject.toml");
|
|
124
124
|
const requirements = join(workdir, "requirements.txt");
|
|
125
125
|
|
|
@@ -127,7 +127,7 @@ function detectPython(workdir: string): { name?: string; lang: string; dependenc
|
|
|
127
127
|
|
|
128
128
|
try {
|
|
129
129
|
if (existsSync(pyproject)) {
|
|
130
|
-
const content =
|
|
130
|
+
const content = await Bun.file(pyproject).text();
|
|
131
131
|
const nameMatch = content.match(/^\s*name\s*=\s*"([^"]+)"/m);
|
|
132
132
|
const depsSection = content.match(/^\[project\][^[]*dependencies\s*=\s*\[([^\]]*)\]/ms)?.[1] ?? "";
|
|
133
133
|
const deps = depsSection
|
|
@@ -139,7 +139,7 @@ function detectPython(workdir: string): { name?: string; lang: string; dependenc
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// Fallback: requirements.txt
|
|
142
|
-
const lines =
|
|
142
|
+
const lines = (await Bun.file(requirements).text())
|
|
143
143
|
.split("\n")
|
|
144
144
|
.map((l) => l.split(/[>=<!]/)[0].trim())
|
|
145
145
|
.filter((l) => l && !l.startsWith("#"))
|
|
@@ -169,12 +169,12 @@ async function detectPhp(workdir: string): Promise<{ name?: string; lang: string
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/** Ruby: read Gemfile */
|
|
172
|
-
function detectRuby(workdir: string): { name?: string; lang: string; dependencies: string[] } | null {
|
|
172
|
+
async function detectRuby(workdir: string): Promise<{ name?: string; lang: string; dependencies: string[] } | null> {
|
|
173
173
|
const gemfile = join(workdir, "Gemfile");
|
|
174
174
|
if (!existsSync(gemfile)) return null;
|
|
175
175
|
|
|
176
176
|
try {
|
|
177
|
-
const content =
|
|
177
|
+
const content = await Bun.file(gemfile).text();
|
|
178
178
|
const gems = [...content.matchAll(/^\s*gem\s+['"]([^'"]+)['"]/gm)].map((m) => m[1]).slice(0, 10);
|
|
179
179
|
return { lang: "Ruby", dependencies: gems };
|
|
180
180
|
} catch {
|
|
@@ -183,7 +183,7 @@ function detectRuby(workdir: string): { name?: string; lang: string; dependencie
|
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
/** Java/Kotlin: detect from pom.xml or build.gradle */
|
|
186
|
-
function detectJvm(workdir: string): { name?: string; lang: string; dependencies: string[] } | null {
|
|
186
|
+
async function detectJvm(workdir: string): Promise<{ name?: string; lang: string; dependencies: string[] } | null> {
|
|
187
187
|
const pom = join(workdir, "pom.xml");
|
|
188
188
|
const gradle = join(workdir, "build.gradle");
|
|
189
189
|
const gradleKts = join(workdir, "build.gradle.kts");
|
|
@@ -192,7 +192,7 @@ function detectJvm(workdir: string): { name?: string; lang: string; dependencies
|
|
|
192
192
|
|
|
193
193
|
try {
|
|
194
194
|
if (existsSync(pom)) {
|
|
195
|
-
const content =
|
|
195
|
+
const content = await Bun.file(pom).text();
|
|
196
196
|
const nameMatch = content.match(/<artifactId>([^<]+)<\/artifactId>/);
|
|
197
197
|
const deps = [...content.matchAll(/<artifactId>([^<]+)<\/artifactId>/g)]
|
|
198
198
|
.map((m) => m[1])
|
|
@@ -203,7 +203,7 @@ function detectJvm(workdir: string): { name?: string; lang: string; dependencies
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
const gradleFile = existsSync(gradleKts) ? gradleKts : gradle;
|
|
206
|
-
const content =
|
|
206
|
+
const content = await Bun.file(gradleFile).text();
|
|
207
207
|
const lang = gradleFile.endsWith(".kts") ? "Kotlin" : "Java";
|
|
208
208
|
const deps = [...content.matchAll(/implementation[^'"]*['"]([^:'"]+:[^:'"]+)[^'"]*['"]/g)]
|
|
209
209
|
.map((m) => m[1].split(":").pop() ?? m[1])
|
|
@@ -223,12 +223,12 @@ function detectJvm(workdir: string): { name?: string; lang: string; dependencies
|
|
|
223
223
|
export async function buildProjectMetadata(workdir: string, config: NaxConfig): Promise<ProjectMetadata> {
|
|
224
224
|
// Priority: Go > Rust > Python > PHP > Ruby > JVM > Node
|
|
225
225
|
const detected =
|
|
226
|
-
detectGo(workdir) ??
|
|
227
|
-
detectRust(workdir) ??
|
|
228
|
-
detectPython(workdir) ??
|
|
226
|
+
(await detectGo(workdir)) ??
|
|
227
|
+
(await detectRust(workdir)) ??
|
|
228
|
+
(await detectPython(workdir)) ??
|
|
229
229
|
(await detectPhp(workdir)) ??
|
|
230
|
-
detectRuby(workdir) ??
|
|
231
|
-
detectJvm(workdir) ??
|
|
230
|
+
(await detectRuby(workdir)) ??
|
|
231
|
+
(await detectJvm(workdir)) ??
|
|
232
232
|
(await detectNode(workdir));
|
|
233
233
|
|
|
234
234
|
return {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
1
2
|
/**
|
|
2
3
|
* Crash Recovery — Signal handlers, heartbeat, and exit summary
|
|
3
4
|
*
|
|
@@ -63,9 +64,8 @@ async function writeFatalLog(jsonlFilePath: string | undefined, signal: string,
|
|
|
63
64
|
};
|
|
64
65
|
|
|
65
66
|
const line = `${JSON.stringify(fatalEntry)}\n`;
|
|
66
|
-
// Use
|
|
67
|
-
|
|
68
|
-
appendFileSync(jsonlFilePath, line, "utf8");
|
|
67
|
+
// Use Bun.write with append: true
|
|
68
|
+
appendFileSync(jsonlFilePath, line);
|
|
69
69
|
} catch (err) {
|
|
70
70
|
console.error("[crash-recovery] Failed to write fatal log:", err);
|
|
71
71
|
}
|
|
@@ -107,8 +107,7 @@ async function writeRunComplete(ctx: CrashRecoveryContext, exitReason: string):
|
|
|
107
107
|
};
|
|
108
108
|
|
|
109
109
|
const line = `${JSON.stringify(runCompleteEntry)}\n`;
|
|
110
|
-
|
|
111
|
-
appendFileSync(ctx.jsonlFilePath, line, "utf8");
|
|
110
|
+
appendFileSync(ctx.jsonlFilePath, line);
|
|
112
111
|
logger?.debug("crash-recovery", "run.complete event written", { exitReason });
|
|
113
112
|
} catch (err) {
|
|
114
113
|
console.error("[crash-recovery] Failed to write run.complete event:", err);
|
|
@@ -279,8 +278,7 @@ export function startHeartbeat(
|
|
|
279
278
|
},
|
|
280
279
|
};
|
|
281
280
|
const line = `${JSON.stringify(heartbeatEntry)}\n`;
|
|
282
|
-
|
|
283
|
-
appendFileSync(jsonlFilePath, line, "utf8");
|
|
281
|
+
appendFileSync(jsonlFilePath, line);
|
|
284
282
|
} catch (err) {
|
|
285
283
|
logger?.warn("crash-recovery", "Failed to write heartbeat", { error: (err as Error).message });
|
|
286
284
|
}
|
|
@@ -342,9 +340,8 @@ export async function writeExitSummary(
|
|
|
342
340
|
};
|
|
343
341
|
|
|
344
342
|
const line = `${JSON.stringify(summaryEntry)}\n`;
|
|
345
|
-
// Use
|
|
346
|
-
|
|
347
|
-
appendFileSync(jsonlFilePath, line, "utf8");
|
|
343
|
+
// Use Bun.write with append: true
|
|
344
|
+
appendFileSync(jsonlFilePath, line);
|
|
348
345
|
logger?.debug("crash-recovery", "Exit summary written");
|
|
349
346
|
} catch (err) {
|
|
350
347
|
logger?.warn("crash-recovery", "Failed to write exit summary", { error: (err as Error).message });
|
|
@@ -93,6 +93,7 @@ async function generateAndAddFixStories(
|
|
|
93
93
|
specContent: await loadSpecContent(ctx.featureDir),
|
|
94
94
|
workdir: ctx.workdir,
|
|
95
95
|
modelDef,
|
|
96
|
+
config: ctx.config,
|
|
96
97
|
});
|
|
97
98
|
if (fixStories.length === 0) {
|
|
98
99
|
logger?.error("acceptance", "Failed to generate fix stories");
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* Lifecycle module exports
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export { RunLifecycle, type SetupResult, type TeardownOptions } from "./run-lifecycle";
|
|
6
5
|
export { runAcceptanceLoop, type AcceptanceLoopContext, type AcceptanceLoopResult } from "./acceptance-loop";
|
|
7
6
|
export { emitStoryComplete, type StoryCompleteEvent } from "./story-hooks";
|
|
8
7
|
export { outputRunHeader, outputRunFooter, type RunHeaderOptions, type RunFooterOptions } from "./headless-formatter";
|
|
@@ -10,3 +9,4 @@ export { handleParallelCompletion, type ParallelCompletionOptions } from "./para
|
|
|
10
9
|
export { handleRunCompletion, type RunCompletionOptions, type RunCompletionResult } from "./run-completion";
|
|
11
10
|
export { cleanupRun, type RunCleanupOptions } from "./run-cleanup";
|
|
12
11
|
export { setupRun, type RunSetupOptions, type RunSetupResult } from "./run-setup";
|
|
12
|
+
export { runDeferredRegression, type DeferredRegressionOptions, type DeferredRegressionResult } from "./run-regression";
|
|
@@ -62,7 +62,7 @@ export async function runPrecheckValidation(ctx: PrecheckContext): Promise<void>
|
|
|
62
62
|
warnings: precheckResult.output.warnings.map((w) => ({ name: w.name, message: w.message })),
|
|
63
63
|
summary: precheckResult.output.summary,
|
|
64
64
|
};
|
|
65
|
-
appendFileSync(ctx.logFilePath, `${JSON.stringify(precheckLog)}\n
|
|
65
|
+
require("node:fs").appendFileSync(ctx.logFilePath, `${JSON.stringify(precheckLog)}\n`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// Handle blockers (Tier 1 failures)
|
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
* Run Completion — Final Metrics and Status Updates
|
|
3
3
|
*
|
|
4
4
|
* Handles the final steps after sequential execution completes:
|
|
5
|
+
* - Run deferred regression gate (if configured)
|
|
5
6
|
* - Save run metrics
|
|
6
7
|
* - Log completion summary with per-story metrics
|
|
7
8
|
* - Update final status
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import type { NaxConfig } from "../../config";
|
|
10
12
|
import { getSafeLogger } from "../../logger";
|
|
11
13
|
import type { StoryMetrics } from "../../metrics";
|
|
12
14
|
import { saveRunMetrics } from "../../metrics";
|
|
13
15
|
import { countStories, isComplete, isStalled } from "../../prd";
|
|
14
16
|
import type { PRD } from "../../prd";
|
|
15
17
|
import type { StatusWriter } from "../status-writer";
|
|
18
|
+
import { runDeferredRegression } from "./run-regression";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Injectable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
|
|
22
|
+
* @internal - test use only.
|
|
23
|
+
*/
|
|
24
|
+
export const _runCompletionDeps = {
|
|
25
|
+
runDeferredRegression,
|
|
26
|
+
};
|
|
16
27
|
|
|
17
28
|
export interface RunCompletionOptions {
|
|
18
29
|
runId: string;
|
|
@@ -26,6 +37,7 @@ export interface RunCompletionOptions {
|
|
|
26
37
|
startTime: number;
|
|
27
38
|
workdir: string;
|
|
28
39
|
statusWriter: StatusWriter;
|
|
40
|
+
config: NaxConfig;
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
export interface RunCompletionResult {
|
|
@@ -57,8 +69,25 @@ export async function handleRunCompletion(options: RunCompletionOptions): Promis
|
|
|
57
69
|
startTime,
|
|
58
70
|
workdir,
|
|
59
71
|
statusWriter,
|
|
72
|
+
config,
|
|
60
73
|
} = options;
|
|
61
74
|
|
|
75
|
+
// Run deferred regression gate before final metrics
|
|
76
|
+
const regressionMode = config.execution.regressionGate?.mode;
|
|
77
|
+
if (regressionMode === "deferred" && config.quality.commands.test) {
|
|
78
|
+
const regressionResult = await _runCompletionDeps.runDeferredRegression({
|
|
79
|
+
config,
|
|
80
|
+
prd,
|
|
81
|
+
workdir,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
logger?.info("regression", "Deferred regression gate completed", {
|
|
85
|
+
success: regressionResult.success,
|
|
86
|
+
failedTests: regressionResult.failedTests,
|
|
87
|
+
affectedStories: regressionResult.affectedStories,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
62
91
|
const durationMs = Date.now() - startTime;
|
|
63
92
|
const runCompletedAt = new Date().toISOString();
|
|
64
93
|
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deferred Regression Gate
|
|
3
|
+
*
|
|
4
|
+
* Runs full test suite once after all stories complete, then attempts
|
|
5
|
+
* targeted rectification per responsible story. Handles edge cases:
|
|
6
|
+
* - Partial completion: only check stories marked passed
|
|
7
|
+
* - Overlapping file changes: try last modified story first
|
|
8
|
+
* - Unmapped tests: warn and mark all passed stories for re-verification
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { NaxConfig } from "../../config";
|
|
12
|
+
import { getSafeLogger } from "../../logger";
|
|
13
|
+
import type { PRD, UserStory } from "../../prd";
|
|
14
|
+
import { countStories } from "../../prd";
|
|
15
|
+
import { hasCommitsForStory } from "../../utils/git";
|
|
16
|
+
import { parseBunTestOutput } from "../../verification";
|
|
17
|
+
import { reverseMapTestToSource } from "../../verification/smart-runner";
|
|
18
|
+
import { runRectificationLoop } from "../post-verify-rectification";
|
|
19
|
+
import { runVerification } from "../verification";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Injectable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
|
|
23
|
+
* @internal - test use only.
|
|
24
|
+
*/
|
|
25
|
+
export const _regressionDeps = {
|
|
26
|
+
runVerification,
|
|
27
|
+
runRectificationLoop,
|
|
28
|
+
parseBunTestOutput,
|
|
29
|
+
reverseMapTestToSource,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface DeferredRegressionOptions {
|
|
33
|
+
config: NaxConfig;
|
|
34
|
+
prd: PRD;
|
|
35
|
+
workdir: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DeferredRegressionResult {
|
|
39
|
+
success: boolean;
|
|
40
|
+
failedTests: number;
|
|
41
|
+
passedTests: number;
|
|
42
|
+
rectificationAttempts: number;
|
|
43
|
+
affectedStories: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map a test file to the story responsible for it via git log.
|
|
48
|
+
*
|
|
49
|
+
* Searches recent commits for story IDs in the format US-NNN.
|
|
50
|
+
* Returns the first matching story ID, or undefined if not found.
|
|
51
|
+
*/
|
|
52
|
+
async function findResponsibleStory(
|
|
53
|
+
testFile: string,
|
|
54
|
+
workdir: string,
|
|
55
|
+
passedStories: UserStory[],
|
|
56
|
+
): Promise<UserStory | undefined> {
|
|
57
|
+
const logger = getSafeLogger();
|
|
58
|
+
|
|
59
|
+
// Try each passed story in reverse order (most recent first)
|
|
60
|
+
for (let i = passedStories.length - 1; i >= 0; i--) {
|
|
61
|
+
const story = passedStories[i];
|
|
62
|
+
const hasCommits = await hasCommitsForStory(workdir, story.id, 50);
|
|
63
|
+
if (hasCommits) {
|
|
64
|
+
logger?.info("regression", `Mapped test to story ${story.id}`, { testFile });
|
|
65
|
+
return story;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run deferred regression gate after all stories complete.
|
|
74
|
+
*
|
|
75
|
+
* Steps:
|
|
76
|
+
* 1. Run full test suite
|
|
77
|
+
* 2. If failures, reverse-map test files to source files to stories
|
|
78
|
+
* 3. For each affected story, attempt targeted rectification
|
|
79
|
+
* 4. Re-run full suite to confirm fixes
|
|
80
|
+
* 5. Return results with affected story list
|
|
81
|
+
*/
|
|
82
|
+
export async function runDeferredRegression(options: DeferredRegressionOptions): Promise<DeferredRegressionResult> {
|
|
83
|
+
const logger = getSafeLogger();
|
|
84
|
+
const { config, prd, workdir } = options;
|
|
85
|
+
|
|
86
|
+
// Check if regression gate is deferred
|
|
87
|
+
const regressionMode = config.execution.regressionGate?.mode ?? "deferred";
|
|
88
|
+
if (regressionMode === "disabled") {
|
|
89
|
+
logger?.info("regression", "Deferred regression gate disabled");
|
|
90
|
+
return {
|
|
91
|
+
success: true,
|
|
92
|
+
failedTests: 0,
|
|
93
|
+
passedTests: 0,
|
|
94
|
+
rectificationAttempts: 0,
|
|
95
|
+
affectedStories: [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (regressionMode !== "deferred") {
|
|
100
|
+
logger?.info("regression", "Regression gate mode is not deferred, skipping");
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
failedTests: 0,
|
|
104
|
+
passedTests: 0,
|
|
105
|
+
rectificationAttempts: 0,
|
|
106
|
+
affectedStories: [],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const testCommand = config.quality.commands.test ?? "bun test";
|
|
111
|
+
const timeoutSeconds = config.execution.regressionGate?.timeoutSeconds ?? 120;
|
|
112
|
+
const maxRectificationAttempts = config.execution.regressionGate?.maxRectificationAttempts ?? 2;
|
|
113
|
+
|
|
114
|
+
// Only check stories that have been marked as passed
|
|
115
|
+
const counts = countStories(prd);
|
|
116
|
+
const passedStories = prd.userStories.filter((s) => s.status === "passed");
|
|
117
|
+
|
|
118
|
+
if (passedStories.length === 0) {
|
|
119
|
+
logger?.info("regression", "No passed stories to verify (partial completion)");
|
|
120
|
+
return {
|
|
121
|
+
success: true,
|
|
122
|
+
failedTests: 0,
|
|
123
|
+
passedTests: 0,
|
|
124
|
+
rectificationAttempts: 0,
|
|
125
|
+
affectedStories: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
logger?.info("regression", "Running deferred full-suite regression gate", {
|
|
130
|
+
totalStories: counts.total,
|
|
131
|
+
passedStories: passedStories.length,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Step 1: Run full test suite
|
|
135
|
+
const fullSuiteResult = await _regressionDeps.runVerification({
|
|
136
|
+
workingDirectory: workdir,
|
|
137
|
+
command: testCommand,
|
|
138
|
+
timeoutSeconds,
|
|
139
|
+
forceExit: config.quality.forceExit,
|
|
140
|
+
detectOpenHandles: config.quality.detectOpenHandles,
|
|
141
|
+
detectOpenHandlesRetries: config.quality.detectOpenHandlesRetries,
|
|
142
|
+
timeoutRetryCount: 0,
|
|
143
|
+
gracePeriodMs: config.quality.gracePeriodMs,
|
|
144
|
+
drainTimeoutMs: config.quality.drainTimeoutMs,
|
|
145
|
+
shell: config.quality.shell,
|
|
146
|
+
stripEnvVars: config.quality.stripEnvVars,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (fullSuiteResult.success) {
|
|
150
|
+
logger?.info("regression", "Full suite passed");
|
|
151
|
+
return {
|
|
152
|
+
success: true,
|
|
153
|
+
failedTests: 0,
|
|
154
|
+
passedTests: fullSuiteResult.passCount ?? 0,
|
|
155
|
+
rectificationAttempts: 0,
|
|
156
|
+
affectedStories: [],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Handle timeout
|
|
161
|
+
const acceptOnTimeout = config.execution.regressionGate?.acceptOnTimeout ?? true;
|
|
162
|
+
if (fullSuiteResult.status === "TIMEOUT" && acceptOnTimeout) {
|
|
163
|
+
logger?.warn("regression", "Full-suite regression gate timed out (accepted as pass)");
|
|
164
|
+
return {
|
|
165
|
+
success: true,
|
|
166
|
+
failedTests: 0,
|
|
167
|
+
passedTests: 0,
|
|
168
|
+
rectificationAttempts: 0,
|
|
169
|
+
affectedStories: [],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!fullSuiteResult.output) {
|
|
174
|
+
logger?.error("regression", "Full suite failed with no output");
|
|
175
|
+
return {
|
|
176
|
+
success: false,
|
|
177
|
+
failedTests: fullSuiteResult.failCount ?? 0,
|
|
178
|
+
passedTests: fullSuiteResult.passCount ?? 0,
|
|
179
|
+
rectificationAttempts: 0,
|
|
180
|
+
affectedStories: [],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Step 2: Parse failures and map to source files to stories
|
|
185
|
+
const testSummary = _regressionDeps.parseBunTestOutput(fullSuiteResult.output);
|
|
186
|
+
const affectedStories = new Set<string>();
|
|
187
|
+
const affectedStoriesObjs = new Map<string, UserStory>();
|
|
188
|
+
|
|
189
|
+
logger?.warn("regression", "Regression detected", {
|
|
190
|
+
failedTests: testSummary.failed,
|
|
191
|
+
passedTests: testSummary.passed,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Extract test file paths from failures
|
|
195
|
+
const testFilesInFailures = new Set<string>();
|
|
196
|
+
for (const failure of testSummary.failures) {
|
|
197
|
+
if (failure.file) {
|
|
198
|
+
testFilesInFailures.add(failure.file);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (testFilesInFailures.size === 0) {
|
|
203
|
+
logger?.warn("regression", "No test files found in failures (unmapped)");
|
|
204
|
+
// Mark all passed stories for re-verification
|
|
205
|
+
for (const story of passedStories) {
|
|
206
|
+
affectedStories.add(story.id);
|
|
207
|
+
affectedStoriesObjs.set(story.id, story);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
// Map test files to source files to stories
|
|
211
|
+
const testFilesArray = Array.from(testFilesInFailures);
|
|
212
|
+
const sourceFilesArray = _regressionDeps.reverseMapTestToSource(testFilesArray, workdir);
|
|
213
|
+
|
|
214
|
+
logger?.info("regression", "Mapped test files to source files", {
|
|
215
|
+
testFiles: testFilesArray.length,
|
|
216
|
+
sourceFiles: sourceFilesArray.length,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
for (const testFile of testFilesArray) {
|
|
220
|
+
const responsibleStory = await findResponsibleStory(testFile, workdir, passedStories);
|
|
221
|
+
if (responsibleStory) {
|
|
222
|
+
affectedStories.add(responsibleStory.id);
|
|
223
|
+
affectedStoriesObjs.set(responsibleStory.id, responsibleStory);
|
|
224
|
+
} else {
|
|
225
|
+
logger?.warn("regression", "Could not map test file to story", { testFile });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (affectedStories.size === 0) {
|
|
231
|
+
logger?.warn("regression", "No stories could be mapped to failures");
|
|
232
|
+
return {
|
|
233
|
+
success: false,
|
|
234
|
+
failedTests: testSummary.failed,
|
|
235
|
+
passedTests: testSummary.passed,
|
|
236
|
+
rectificationAttempts: 0,
|
|
237
|
+
affectedStories: Array.from(affectedStories),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Step 3: Attempt rectification per story
|
|
242
|
+
let rectificationAttempts = 0;
|
|
243
|
+
const affectedStoriesList = Array.from(affectedStoriesObjs.values());
|
|
244
|
+
|
|
245
|
+
for (const story of affectedStoriesList) {
|
|
246
|
+
for (let attempt = 0; attempt < maxRectificationAttempts; attempt++) {
|
|
247
|
+
rectificationAttempts++;
|
|
248
|
+
|
|
249
|
+
logger?.info("regression", `Rectifying story ${story.id} (attempt ${attempt + 1}/${maxRectificationAttempts})`);
|
|
250
|
+
|
|
251
|
+
const fixed = await _regressionDeps.runRectificationLoop({
|
|
252
|
+
config,
|
|
253
|
+
workdir,
|
|
254
|
+
story,
|
|
255
|
+
testCommand,
|
|
256
|
+
timeoutSeconds,
|
|
257
|
+
testOutput: fullSuiteResult.output,
|
|
258
|
+
promptPrefix: `# DEFERRED REGRESSION: Full-Suite Failures\n\nYour story ${story.id} broke tests in the full suite. Fix these regressions.`,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (fixed) {
|
|
262
|
+
logger?.info("regression", `Story ${story.id} rectified successfully`);
|
|
263
|
+
break; // Move to next story
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Step 4: Re-run full suite to confirm
|
|
269
|
+
logger?.info("regression", "Re-running full suite after rectification");
|
|
270
|
+
const retryResult = await _regressionDeps.runVerification({
|
|
271
|
+
workingDirectory: workdir,
|
|
272
|
+
command: testCommand,
|
|
273
|
+
timeoutSeconds,
|
|
274
|
+
forceExit: config.quality.forceExit,
|
|
275
|
+
detectOpenHandles: config.quality.detectOpenHandles,
|
|
276
|
+
detectOpenHandlesRetries: config.quality.detectOpenHandlesRetries,
|
|
277
|
+
timeoutRetryCount: 0,
|
|
278
|
+
gracePeriodMs: config.quality.gracePeriodMs,
|
|
279
|
+
drainTimeoutMs: config.quality.drainTimeoutMs,
|
|
280
|
+
shell: config.quality.shell,
|
|
281
|
+
stripEnvVars: config.quality.stripEnvVars,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const success = retryResult.success || (retryResult.status === "TIMEOUT" && acceptOnTimeout);
|
|
285
|
+
|
|
286
|
+
if (success) {
|
|
287
|
+
logger?.info("regression", "Deferred regression gate passed after rectification");
|
|
288
|
+
} else {
|
|
289
|
+
logger?.warn("regression", "Deferred regression gate still failing after rectification", {
|
|
290
|
+
remainingFailures: retryResult.failCount,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
success,
|
|
296
|
+
failedTests: retryResult.failCount ?? 0,
|
|
297
|
+
passedTests: retryResult.passCount ?? 0,
|
|
298
|
+
rectificationAttempts,
|
|
299
|
+
affectedStories: Array.from(affectedStories),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -122,20 +122,6 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
|
|
|
122
122
|
getStoriesCompleted: options.getStoriesCompleted,
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
// Acquire lock to prevent concurrent execution
|
|
126
|
-
const lockAcquired = await acquireLock(workdir);
|
|
127
|
-
if (!lockAcquired) {
|
|
128
|
-
logger?.error("execution", "Another nax process is already running in this directory");
|
|
129
|
-
logger?.error("execution", "If you believe this is an error, remove nax.lock manually");
|
|
130
|
-
throw new LockAcquisitionError(workdir);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Load plugins (before try block so it's accessible in finally)
|
|
134
|
-
const globalPluginsDir = path.join(os.homedir(), ".nax", "plugins");
|
|
135
|
-
const projectPluginsDir = path.join(workdir, "nax", "plugins");
|
|
136
|
-
const configPlugins = config.plugins || [];
|
|
137
|
-
const pluginRegistry = await loadPlugins(globalPluginsDir, projectPluginsDir, configPlugins, workdir);
|
|
138
|
-
|
|
139
125
|
// Load PRD (before try block so it's accessible in finally for onRunEnd)
|
|
140
126
|
let prd = await loadPRD(prdPath);
|
|
141
127
|
|
|
@@ -163,6 +149,20 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
|
|
|
163
149
|
logger?.warn("precheck", "Precheck validations skipped (--skip-precheck)");
|
|
164
150
|
}
|
|
165
151
|
|
|
152
|
+
// Acquire lock to prevent concurrent execution
|
|
153
|
+
const lockAcquired = await acquireLock(workdir);
|
|
154
|
+
if (!lockAcquired) {
|
|
155
|
+
logger?.error("execution", "Another nax process is already running in this directory");
|
|
156
|
+
logger?.error("execution", "If you believe this is an error, remove nax.lock manually");
|
|
157
|
+
throw new LockAcquisitionError(workdir);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Load plugins (before try block so it's accessible in finally)
|
|
161
|
+
const globalPluginsDir = path.join(os.homedir(), ".nax", "plugins");
|
|
162
|
+
const projectPluginsDir = path.join(workdir, "nax", "plugins");
|
|
163
|
+
const configPlugins = config.plugins || [];
|
|
164
|
+
const pluginRegistry = await loadPlugins(globalPluginsDir, projectPluginsDir, configPlugins, workdir);
|
|
165
|
+
|
|
166
166
|
// Log plugins loaded
|
|
167
167
|
logger?.info("plugins", `Loaded ${pluginRegistry.plugins.length} plugins`, {
|
|
168
168
|
plugins: pluginRegistry.plugins.map((p) => ({ name: p.name, version: p.version, provides: p.provides })),
|
|
@@ -18,7 +18,7 @@ import type { PipelineContext, RoutingResult } from "../pipeline/types";
|
|
|
18
18
|
import type { PluginRegistry } from "../plugins/registry";
|
|
19
19
|
import type { PRD, UserStory } from "../prd";
|
|
20
20
|
import { markStoryFailed, markStoryPassed, savePRD } from "../prd";
|
|
21
|
-
import { routeTask } from "../routing";
|
|
21
|
+
import { routeTask, tryLlmBatchRoute } from "../routing";
|
|
22
22
|
import { WorktreeManager } from "../worktree/manager";
|
|
23
23
|
import { MergeEngine, type StoryDependencies } from "../worktree/merge";
|
|
24
24
|
|
|
@@ -83,7 +83,6 @@ export async function handlePipelineSuccess(
|
|
|
83
83
|
storiesToExecute: ctx.storiesToExecute,
|
|
84
84
|
allStoryMetrics: ctx.allStoryMetrics,
|
|
85
85
|
timeoutRetryCountMap: ctx.timeoutRetryCountMap,
|
|
86
|
-
storyGitRef: ctx.storyGitRef ?? undefined,
|
|
87
86
|
});
|
|
88
87
|
const verificationPassed = verifyResult.passed;
|
|
89
88
|
prd = verifyResult.prd;
|