@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.
Files changed (66) hide show
  1. package/docs/ROADMAP.md +2 -0
  2. package/nax/config.json +2 -2
  3. package/nax/features/nax-compliance/prd.json +52 -0
  4. package/nax/features/nax-compliance/progress.txt +1 -0
  5. package/nax/features/v0.19.0-hardening/plan.md +7 -0
  6. package/nax/features/v0.19.0-hardening/prd.json +84 -0
  7. package/nax/features/v0.19.0-hardening/progress.txt +7 -0
  8. package/nax/features/v0.19.0-hardening/spec.md +18 -0
  9. package/nax/features/v0.19.0-hardening/tasks.md +8 -0
  10. package/nax/features/verify-v2/prd.json +79 -0
  11. package/nax/features/verify-v2/progress.txt +3 -0
  12. package/nax/status.json +27 -0
  13. package/package.json +2 -2
  14. package/src/acceptance/fix-generator.ts +6 -2
  15. package/src/acceptance/generator.ts +3 -1
  16. package/src/acceptance/types.ts +3 -1
  17. package/src/agents/claude-plan.ts +6 -5
  18. package/src/cli/analyze.ts +1 -0
  19. package/src/cli/init.ts +7 -6
  20. package/src/config/defaults.ts +3 -1
  21. package/src/config/schemas.ts +2 -0
  22. package/src/config/types.ts +6 -0
  23. package/src/context/injector.ts +18 -18
  24. package/src/execution/crash-recovery.ts +7 -10
  25. package/src/execution/lifecycle/acceptance-loop.ts +1 -0
  26. package/src/execution/lifecycle/index.ts +1 -1
  27. package/src/execution/lifecycle/precheck-runner.ts +1 -1
  28. package/src/execution/lifecycle/run-completion.ts +29 -0
  29. package/src/execution/lifecycle/run-regression.ts +301 -0
  30. package/src/execution/lifecycle/run-setup.ts +14 -14
  31. package/src/execution/parallel.ts +1 -1
  32. package/src/execution/pipeline-result-handler.ts +0 -1
  33. package/src/execution/post-verify.ts +31 -194
  34. package/src/execution/runner.ts +2 -19
  35. package/src/execution/sequential-executor.ts +1 -1
  36. package/src/hooks/runner.ts +2 -2
  37. package/src/interaction/plugins/auto.ts +2 -2
  38. package/src/logger/logger.ts +3 -5
  39. package/src/pipeline/stages/verify.ts +26 -22
  40. package/src/plugins/loader.ts +36 -9
  41. package/src/routing/batch-route.ts +32 -0
  42. package/src/routing/index.ts +1 -0
  43. package/src/routing/loader.ts +7 -0
  44. package/src/utils/path-security.ts +56 -0
  45. package/src/verification/executor.ts +6 -13
  46. package/src/verification/smart-runner.ts +52 -0
  47. package/test/integration/plugins/config-resolution.test.ts +3 -3
  48. package/test/integration/plugins/loader.test.ts +3 -1
  49. package/test/integration/precheck-integration.test.ts +18 -11
  50. package/test/integration/rectification-flow.test.ts +3 -3
  51. package/test/integration/review-config-commands.test.ts +1 -1
  52. package/test/integration/security-loader.test.ts +83 -0
  53. package/test/integration/verify-stage.test.ts +9 -0
  54. package/test/unit/config/defaults.test.ts +69 -0
  55. package/test/unit/config/regression-gate-schema.test.ts +159 -0
  56. package/test/unit/execution/lifecycle/run-completion.test.ts +239 -0
  57. package/test/unit/execution/lifecycle/run-regression.test.ts +418 -0
  58. package/test/unit/execution/post-verify-regression.test.ts +31 -84
  59. package/test/unit/execution/post-verify.test.ts +28 -48
  60. package/test/unit/formatters.test.ts +2 -3
  61. package/test/unit/hooks/shell-security.test.ts +40 -0
  62. package/test/unit/pipeline/stages/verify.test.ts +266 -0
  63. package/test/unit/pipeline/verify-smart-runner.test.ts +1 -0
  64. package/test/unit/utils/path-security.test.ts +47 -0
  65. package/src/execution/lifecycle/run-lifecycle.ts +0 -312
  66. package/test/unit/run-lifecycle.test.ts +0 -140
@@ -7,7 +7,7 @@
7
7
  * Ruby (Gemfile), Java/Kotlin (pom.xml / build.gradle).
8
8
  */
9
9
 
10
- import { existsSync, readFileSync } from "node:fs";
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 = readFileSync(goMod, "utf8");
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 = readFileSync(cargoPath, "utf8");
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 = readFileSync(pyproject, "utf8");
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 = readFileSync(requirements, "utf8")
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 = readFileSync(gemfile, "utf8");
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 = readFileSync(pom, "utf8");
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 = readFileSync(gradleFile, "utf8");
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 appendFileSync from node:fs to ensure file is created if it doesn't exist
67
- const { appendFileSync } = await import("node:fs");
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
- const { appendFileSync } = await import("node:fs");
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
- const { appendFileSync } = await import("node:fs");
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 appendFileSync from node:fs to ensure file is created if it doesn't exist
346
- const { appendFileSync } = await import("node:fs");
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`, "utf8");
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;