@nathapp/nax 0.48.3 → 0.49.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.
@@ -5,12 +5,11 @@
5
5
  * This is a lightweight verification before the full review stage.
6
6
  *
7
7
  * @returns
8
- * - `continue`: Tests passed
9
- * - `escalate`: Tests failed (retry with escalation)
8
+ * - `continue`: Tests passed, OR TEST_FAILURE (ctx.verifyResult.success===false → rectifyStage handles it)
9
+ * - `escalate`: TIMEOUT or RUNTIME_CRASH (structural rectify can't fix these)
10
10
  */
11
11
 
12
- import { join } from "node:path";
13
- import { loadConfigForWorkdir } from "../../config/loader";
12
+ import { basename, join } from "node:path";
14
13
  import type { SmartTestRunnerConfig } from "../../config/types";
15
14
  import { getLogger } from "../../logger";
16
15
  import { logTestOutput } from "../../utils/log-test-output";
@@ -18,6 +17,7 @@ import { detectRuntimeCrash } from "../../verification/crash-detector";
18
17
  import type { VerifyStatus } from "../../verification/orchestrator-types";
19
18
  import { regression } from "../../verification/runners";
20
19
  import { _smartRunnerDeps } from "../../verification/smart-runner";
20
+ import { isMonorepoOrchestratorCommand } from "../../verification/strategies/scoped";
21
21
  import type { PipelineContext, PipelineStage, StageResult } from "../types";
22
22
 
23
23
  const DEFAULT_SMART_RUNNER_CONFIG: SmartTestRunnerConfig = {
@@ -47,6 +47,41 @@ function buildScopedCommand(testFiles: string[], baseCommand: string, testScoped
47
47
  return _smartRunnerDeps.buildSmartTestCommand(testFiles, baseCommand);
48
48
  }
49
49
 
50
+ /**
51
+ * Read the npm package name from <dir>/package.json.
52
+ * Returns null if not found or file has no name field.
53
+ */
54
+ async function readPackageName(dir: string): Promise<string | null> {
55
+ try {
56
+ const content = await Bun.file(join(dir, "package.json")).json();
57
+ return typeof content.name === "string" ? content.name : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Substitute {{package}} placeholder in a testScoped template.
65
+ *
66
+ * Reads the npm package name from <packageDir>/package.json.
67
+ * Returns null when package.json is absent or has no name field — callers
68
+ * should skip the template entirely in that case (non-JS/non-Node projects
69
+ * have no package identity to inject, so don't fall back to a dir name guess).
70
+ *
71
+ * @param template - Template string (e.g. "bunx turbo test --filter={{package}}")
72
+ * @param packageDir - Absolute path to the package directory
73
+ * @returns Resolved template, or null if {{package}} cannot be resolved
74
+ */
75
+ async function resolvePackageTemplate(template: string, packageDir: string): Promise<string | null> {
76
+ if (!template.includes("{{package}}")) return template;
77
+ const name = await _verifyDeps.readPackageName(packageDir);
78
+ if (name === null) {
79
+ // No package.json or no name field — skip template, can't resolve {{package}}
80
+ return null;
81
+ }
82
+ return template.replaceAll("{{package}}", name);
83
+ }
84
+
50
85
  export const verifyStage: PipelineStage = {
51
86
  name: "verify",
52
87
  enabled: (ctx: PipelineContext) => !ctx.fullSuiteGatePassed,
@@ -55,10 +90,9 @@ export const verifyStage: PipelineStage = {
55
90
  async execute(ctx: PipelineContext): Promise<StageResult> {
56
91
  const logger = getLogger();
57
92
 
58
- // MW-009: resolve effective config for per-package test commands
59
- const effectiveConfig = ctx.story.workdir
60
- ? await _verifyDeps.loadConfigForWorkdir(join(ctx.workdir, "nax", "config.json"), ctx.story.workdir)
61
- : ctx.config;
93
+ // PKG-003: use centrally resolved effective config (set once per story in iteration-runner)
94
+ // Falls back to ctx.config for contexts that predate PKG-003 (e.g., acceptance-loop)
95
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
62
96
 
63
97
  // Skip verification if tests are not required
64
98
  if (!effectiveConfig.quality.requireTests) {
@@ -82,10 +116,38 @@ export const verifyStage: PipelineStage = {
82
116
  // Determine effective test command (smart runner or full suite)
83
117
  let effectiveCommand = testCommand;
84
118
  let isFullSuite = true;
85
- const smartRunnerConfig = coerceSmartTestRunner(ctx.config.execution.smartTestRunner);
86
- const regressionMode = ctx.config.execution.regressionGate?.mode ?? "deferred";
119
+ const smartRunnerConfig = coerceSmartTestRunner(effectiveConfig.execution.smartTestRunner);
120
+ const regressionMode = effectiveConfig.execution.regressionGate?.mode ?? "deferred";
87
121
 
88
- if (smartRunnerConfig.enabled) {
122
+ // Resolve {{package}} in testScoped template for monorepo stories.
123
+ // Returns null if package.json is absent (non-JS project) — falls through to smart-runner.
124
+ let resolvedTestScopedTemplate: string | undefined = testScopedTemplate;
125
+ if (testScopedTemplate && ctx.story.workdir) {
126
+ const resolved = await resolvePackageTemplate(testScopedTemplate, effectiveWorkdir);
127
+ resolvedTestScopedTemplate = resolved ?? undefined; // null → skip template
128
+ }
129
+
130
+ // Monorepo orchestrators (turbo, nx) handle change-aware scoping natively via their own
131
+ // filter syntax. Skip nax's smart runner — appending file paths would produce invalid syntax.
132
+ // Instead, use the testScoped template (with {{package}} resolved) to scope per-story.
133
+ const isMonorepoOrchestrator = isMonorepoOrchestratorCommand(testCommand);
134
+
135
+ if (isMonorepoOrchestrator) {
136
+ if (resolvedTestScopedTemplate && ctx.story.workdir) {
137
+ // Use the resolved scoped template (e.g. "bunx turbo test --filter=@koda/cli")
138
+ effectiveCommand = resolvedTestScopedTemplate;
139
+ isFullSuite = false;
140
+ logger.info("verify", "Monorepo orchestrator — using testScoped template", {
141
+ storyId: ctx.story.id,
142
+ command: effectiveCommand,
143
+ });
144
+ } else {
145
+ logger.info("verify", "Monorepo orchestrator — running full suite (no package context)", {
146
+ storyId: ctx.story.id,
147
+ command: effectiveCommand,
148
+ });
149
+ }
150
+ } else if (smartRunnerConfig.enabled) {
89
151
  // MW-006: pass packagePrefix so git diff is scoped to the package in monorepos
90
152
  const sourceFiles = await _smartRunnerDeps.getChangedSourceFiles(
91
153
  effectiveWorkdir,
@@ -99,7 +161,7 @@ export const verifyStage: PipelineStage = {
99
161
  logger.info("verify", `[smart-runner] Pass 1: path convention matched ${pass1Files.length} test files`, {
100
162
  storyId: ctx.story.id,
101
163
  });
102
- effectiveCommand = buildScopedCommand(pass1Files, testCommand, testScopedTemplate);
164
+ effectiveCommand = buildScopedCommand(pass1Files, testCommand, resolvedTestScopedTemplate);
103
165
  isFullSuite = false;
104
166
  } else if (smartRunnerConfig.fallback === "import-grep") {
105
167
  // Pass 2: import-grep fallback
@@ -112,7 +174,7 @@ export const verifyStage: PipelineStage = {
112
174
  logger.info("verify", `[smart-runner] Pass 2: import-grep matched ${pass2Files.length} test files`, {
113
175
  storyId: ctx.story.id,
114
176
  });
115
- effectiveCommand = buildScopedCommand(pass2Files, testCommand, testScopedTemplate);
177
+ effectiveCommand = buildScopedCommand(pass2Files, testCommand, resolvedTestScopedTemplate);
116
178
  isFullSuite = false;
117
179
  }
118
180
  }
@@ -143,8 +205,8 @@ export const verifyStage: PipelineStage = {
143
205
  const result = await _verifyDeps.regression({
144
206
  workdir: effectiveWorkdir,
145
207
  command: effectiveCommand,
146
- timeoutSeconds: ctx.config.execution.verificationTimeoutSeconds,
147
- acceptOnTimeout: ctx.config.execution.regressionGate?.acceptOnTimeout ?? true,
208
+ timeoutSeconds: effectiveConfig.execution.verificationTimeoutSeconds,
209
+ acceptOnTimeout: effectiveConfig.execution.regressionGate?.acceptOnTimeout ?? true,
148
210
  });
149
211
 
150
212
  // Store result on context for rectify stage
@@ -172,7 +234,7 @@ export const verifyStage: PipelineStage = {
172
234
  if (!result.success) {
173
235
  // BUG-019: Distinguish timeout from actual test failures
174
236
  if (result.status === "TIMEOUT") {
175
- const timeout = ctx.config.execution.verificationTimeoutSeconds;
237
+ const timeout = effectiveConfig.execution.verificationTimeoutSeconds;
176
238
  logger.error(
177
239
  "verify",
178
240
  `Test suite exceeded timeout (${timeout}s). This is NOT a test failure — consider increasing execution.verificationTimeoutSeconds or scoping tests.`,
@@ -195,13 +257,19 @@ export const verifyStage: PipelineStage = {
195
257
  logTestOutput(logger, "verify", result.output, { storyId: ctx.story.id });
196
258
  }
197
259
 
198
- return {
199
- action: "escalate",
200
- reason:
201
- result.status === "TIMEOUT"
202
- ? `Test suite TIMEOUT after ${ctx.config.execution.verificationTimeoutSeconds}s (not a code failure)`
203
- : `Tests failed (exit code ${result.status ?? "non-zero"})`,
204
- };
260
+ // RUNTIME_CRASH and TIMEOUT are structural — escalate immediately (rectify can't fix them)
261
+ if (result.status === "TIMEOUT" || detectRuntimeCrash(result.output)) {
262
+ return {
263
+ action: "escalate",
264
+ reason:
265
+ result.status === "TIMEOUT"
266
+ ? `Test suite TIMEOUT after ${effectiveConfig.execution.verificationTimeoutSeconds}s (not a code failure)`
267
+ : `Tests failed with runtime crash (exit code ${result.status ?? "non-zero"})`,
268
+ };
269
+ }
270
+
271
+ // TEST_FAILURE: ctx.verifyResult is set with success:false — rectifyStage handles it next
272
+ return { action: "continue" };
205
273
  }
206
274
 
207
275
  logger.info("verify", "Tests passed", { storyId: ctx.story.id });
@@ -214,5 +282,5 @@ export const verifyStage: PipelineStage = {
214
282
  */
215
283
  export const _verifyDeps = {
216
284
  regression,
217
- loadConfigForWorkdir,
285
+ readPackageName,
218
286
  };
@@ -58,6 +58,13 @@ export type AgentGetFn = (name: string) => import("../agents/types").AgentAdapte
58
58
  export interface PipelineContext {
59
59
  /** Ngent configuration */
60
60
  config: NaxConfig;
61
+ /**
62
+ * Resolved config for this story's package.
63
+ * When story.workdir is set, this is root config merged with package config.
64
+ * When no workdir, this equals ctx.config (root).
65
+ * Set once per story in the iteration runner before pipeline execution.
66
+ */
67
+ effectiveConfig: NaxConfig;
61
68
  /** Full PRD document */
62
69
  prd: PRD;
63
70
  /** Current story (or batch leader) */
@@ -221,11 +221,26 @@ export async function runReview(
221
221
 
222
222
  // RQ-001: Check for uncommitted tracked files before running checks
223
223
  const allUncommittedFiles = await _deps.getUncommittedFiles(workdir);
224
- // Exclude nax runtime files — written by nax itself during the run, not by the agent
225
- const NAX_RUNTIME_FILES = new Set(["nax/status.json", ".nax-verifier-verdict.json"]);
226
- const uncommittedFiles = allUncommittedFiles.filter(
227
- (f) => !NAX_RUNTIME_FILES.has(f) && !f.match(/^nax\/features\/.+\/prd\.json$/),
228
- );
224
+ // Exclude nax runtime files — written by nax itself during the run, not by the agent.
225
+ // Patterns use a suffix match (no leading ^) so they work in both single-package repos
226
+ // (nax/features/…) and monorepos where paths are prefixed (apps/cli/nax/features/…).
227
+ const NAX_RUNTIME_PATTERNS = [
228
+ /nax\.lock$/,
229
+ /nax\/metrics\.json$/,
230
+ /nax\/status\.json$/,
231
+ /nax\/features\/[^/]+\/status\.json$/,
232
+ /nax\/features\/[^/]+\/prd\.json$/,
233
+ /nax\/features\/[^/]+\/runs\//,
234
+ /nax\/features\/[^/]+\/plan\//,
235
+ /nax\/features\/[^/]+\/acp-sessions\.json$/,
236
+ /nax\/features\/[^/]+\/interactions\//,
237
+ /nax\/features\/[^/]+\/progress\.txt$/,
238
+ /nax\/features\/[^/]+\/acceptance-refined\.json$/,
239
+ /\.nax-verifier-verdict\.json$/,
240
+ /\.nax-pids$/,
241
+ /\.nax-wt\//,
242
+ ];
243
+ const uncommittedFiles = allUncommittedFiles.filter((f) => !NAX_RUNTIME_PATTERNS.some((pattern) => pattern.test(f)));
229
244
  if (uncommittedFiles.length > 0) {
230
245
  const fileList = uncommittedFiles.join(", ");
231
246
  logger?.warn("review", `Uncommitted changes detected before review: ${fileList}`);