@nathapp/nax 0.46.3 → 0.47.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.
@@ -9,6 +9,8 @@
9
9
  * - `escalate`: Tests failed (retry with escalation)
10
10
  */
11
11
 
12
+ import { join } from "node:path";
13
+ import { loadConfigForWorkdir } from "../../config/loader";
12
14
  import type { SmartTestRunnerConfig } from "../../config/types";
13
15
  import { getLogger } from "../../logger";
14
16
  import { logTestOutput } from "../../utils/log-test-output";
@@ -53,15 +55,20 @@ export const verifyStage: PipelineStage = {
53
55
  async execute(ctx: PipelineContext): Promise<StageResult> {
54
56
  const logger = getLogger();
55
57
 
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;
62
+
56
63
  // Skip verification if tests are not required
57
- if (!ctx.config.quality.requireTests) {
64
+ if (!effectiveConfig.quality.requireTests) {
58
65
  logger.debug("verify", "Skipping verification (quality.requireTests = false)", { storyId: ctx.story.id });
59
66
  return { action: "continue" };
60
67
  }
61
68
 
62
69
  // Skip verification if no test command is configured
63
- const testCommand = ctx.config.review?.commands?.test ?? ctx.config.quality.commands.test;
64
- const testScopedTemplate = ctx.config.quality.commands.testScoped;
70
+ const testCommand = effectiveConfig.review?.commands?.test ?? effectiveConfig.quality.commands.test;
71
+ const testScopedTemplate = effectiveConfig.quality.commands.testScoped;
65
72
  if (!testCommand) {
66
73
  logger.debug("verify", "Skipping verification (no test command configured)", { storyId: ctx.story.id });
67
74
  return { action: "continue" };
@@ -69,6 +76,9 @@ export const verifyStage: PipelineStage = {
69
76
 
70
77
  logger.info("verify", "Running verification", { storyId: ctx.story.id });
71
78
 
79
+ // MW-006: resolve effective workdir for test execution
80
+ const effectiveWorkdir = ctx.story.workdir ? join(ctx.workdir, ctx.story.workdir) : ctx.workdir;
81
+
72
82
  // Determine effective test command (smart runner or full suite)
73
83
  let effectiveCommand = testCommand;
74
84
  let isFullSuite = true;
@@ -76,10 +86,15 @@ export const verifyStage: PipelineStage = {
76
86
  const regressionMode = ctx.config.execution.regressionGate?.mode ?? "deferred";
77
87
 
78
88
  if (smartRunnerConfig.enabled) {
79
- const sourceFiles = await _smartRunnerDeps.getChangedSourceFiles(ctx.workdir, ctx.storyGitRef);
89
+ // MW-006: pass packagePrefix so git diff is scoped to the package in monorepos
90
+ const sourceFiles = await _smartRunnerDeps.getChangedSourceFiles(
91
+ effectiveWorkdir,
92
+ ctx.storyGitRef,
93
+ ctx.story.workdir,
94
+ );
80
95
 
81
96
  // Pass 1: path convention mapping
82
- const pass1Files = await _smartRunnerDeps.mapSourceToTests(sourceFiles, ctx.workdir);
97
+ const pass1Files = await _smartRunnerDeps.mapSourceToTests(sourceFiles, effectiveWorkdir);
83
98
  if (pass1Files.length > 0) {
84
99
  logger.info("verify", `[smart-runner] Pass 1: path convention matched ${pass1Files.length} test files`, {
85
100
  storyId: ctx.story.id,
@@ -90,7 +105,7 @@ export const verifyStage: PipelineStage = {
90
105
  // Pass 2: import-grep fallback
91
106
  const pass2Files = await _smartRunnerDeps.importGrepFallback(
92
107
  sourceFiles,
93
- ctx.workdir,
108
+ effectiveWorkdir,
94
109
  smartRunnerConfig.testFilePatterns,
95
110
  );
96
111
  if (pass2Files.length > 0) {
@@ -126,7 +141,7 @@ export const verifyStage: PipelineStage = {
126
141
 
127
142
  // Use unified regression gate (includes 2s wait for agent process cleanup)
128
143
  const result = await _verifyDeps.regression({
129
- workdir: ctx.workdir,
144
+ workdir: effectiveWorkdir,
130
145
  command: effectiveCommand,
131
146
  timeoutSeconds: ctx.config.execution.verificationTimeoutSeconds,
132
147
  acceptOnTimeout: ctx.config.execution.regressionGate?.acceptOnTimeout ?? true,
@@ -199,4 +214,5 @@ export const verifyStage: PipelineStage = {
199
214
  */
200
215
  export const _verifyDeps = {
201
216
  regression,
217
+ loadConfigForWorkdir,
202
218
  };
package/src/prd/schema.ts CHANGED
@@ -155,6 +155,22 @@ function validateStory(raw: unknown, index: number, allIds: Set<string>): UserSt
155
155
  const rawTags = s.tags;
156
156
  const tags: string[] = Array.isArray(rawTags) ? (rawTags as string[]) : [];
157
157
 
158
+ // workdir — optional, relative path only, no traversal
159
+ const rawWorkdir = s.workdir;
160
+ let workdir: string | undefined;
161
+ if (rawWorkdir !== undefined && rawWorkdir !== null) {
162
+ if (typeof rawWorkdir !== "string") {
163
+ throw new Error(`[schema] story[${index}].workdir must be a string`);
164
+ }
165
+ if (rawWorkdir.startsWith("/")) {
166
+ throw new Error(`[schema] story[${index}].workdir must be relative (no leading /): "${rawWorkdir}"`);
167
+ }
168
+ if (rawWorkdir.includes("..")) {
169
+ throw new Error(`[schema] story[${index}].workdir must not contain '..': "${rawWorkdir}"`);
170
+ }
171
+ workdir = rawWorkdir;
172
+ }
173
+
158
174
  return {
159
175
  id,
160
176
  title: title.trim(),
@@ -172,6 +188,7 @@ function validateStory(raw: unknown, index: number, allIds: Set<string>): UserSt
172
188
  testStrategy,
173
189
  reasoning: "validated from LLM output",
174
190
  },
191
+ ...(workdir !== undefined ? { workdir } : {}),
175
192
  };
176
193
  }
177
194
 
package/src/prd/types.ts CHANGED
@@ -127,6 +127,12 @@ export interface UserStory {
127
127
  failureCategory?: FailureCategory;
128
128
  /** Worktree path for parallel execution (set when --parallel is used) */
129
129
  worktreePath?: string;
130
+ /**
131
+ * Working directory for this story, relative to repo root.
132
+ * Overrides the global workdir for pipeline execution.
133
+ * @example "packages/api"
134
+ */
135
+ workdir?: string;
130
136
  }
131
137
 
132
138
  // ============================================================================
@@ -37,127 +37,65 @@ export async function checkDependenciesInstalled(workdir: string): Promise<Check
37
37
  };
38
38
  }
39
39
 
40
- /** Check if test command works. Skips silently if command is null/false. */
40
+ /** Check if test command is configured. Downgraded to warning since the verify stage will catch actual failures. */
41
41
  export async function checkTestCommand(config: NaxConfig): Promise<Check> {
42
42
  const testCommand = config.execution.testCommand || (config.quality?.commands?.test as string | undefined);
43
43
 
44
44
  if (!testCommand || testCommand === null) {
45
45
  return {
46
46
  name: "test-command-works",
47
- tier: "blocker",
47
+ tier: "warning",
48
48
  passed: true,
49
- message: "Test command not configured (skipped)",
49
+ message: "Test command not configured (will use default: bun test)",
50
50
  };
51
51
  }
52
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
- }
53
+ return {
54
+ name: "test-command-works",
55
+ tier: "warning",
56
+ passed: true,
57
+ message: `Test command configured: ${testCommand}`,
58
+ };
79
59
  }
80
60
 
81
- /** Check if lint command works. Skips silently if command is null/false. */
61
+ /** Check if lint command is configured. Downgraded to warning since the verify stage will catch actual failures. */
82
62
  export async function checkLintCommand(config: NaxConfig): Promise<Check> {
83
63
  const lintCommand = config.execution.lintCommand;
84
64
 
85
65
  if (!lintCommand || lintCommand === null) {
86
66
  return {
87
67
  name: "lint-command-works",
88
- tier: "blocker",
68
+ tier: "warning",
89
69
  passed: true,
90
70
  message: "Lint command not configured (skipped)",
91
71
  };
92
72
  }
93
73
 
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
- }
74
+ return {
75
+ name: "lint-command-works",
76
+ tier: "warning",
77
+ passed: true,
78
+ message: `Lint command configured: ${lintCommand}`,
79
+ };
120
80
  }
121
81
 
122
- /** Check if typecheck command works. Skips silently if command is null/false. */
82
+ /** Check if typecheck command is configured. Downgraded to warning since the verify stage will catch actual failures. */
123
83
  export async function checkTypecheckCommand(config: NaxConfig): Promise<Check> {
124
84
  const typecheckCommand = config.execution.typecheckCommand;
125
85
 
126
86
  if (!typecheckCommand || typecheckCommand === null) {
127
87
  return {
128
88
  name: "typecheck-command-works",
129
- tier: "blocker",
89
+ tier: "warning",
130
90
  passed: true,
131
91
  message: "Typecheck command not configured (skipped)",
132
92
  };
133
93
  }
134
94
 
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
- }
95
+ return {
96
+ name: "typecheck-command-works",
97
+ tier: "warning",
98
+ passed: true,
99
+ message: `Typecheck command configured: ${typecheckCommand}`,
100
+ };
163
101
  }
@@ -75,6 +75,7 @@ export class ReviewOrchestrator {
75
75
  executionConfig: NaxConfig["execution"],
76
76
  plugins?: PluginRegistry,
77
77
  storyGitRef?: string,
78
+ scopePrefix?: string,
78
79
  ): Promise<OrchestratorReviewResult> {
79
80
  const logger = getSafeLogger();
80
81
 
@@ -95,14 +96,17 @@ export class ReviewOrchestrator {
95
96
  // Use the story's start ref if available to capture auto-committed changes
96
97
  const baseRef = storyGitRef ?? executionConfig?.storyGitRef;
97
98
  const changedFiles = await getChangedFiles(workdir, baseRef);
99
+ const scopedFiles = scopePrefix
100
+ ? changedFiles.filter((f) => f === scopePrefix || f.startsWith(`${scopePrefix}/`))
101
+ : changedFiles;
98
102
  const pluginResults: ReviewResult["pluginReviewers"] = [];
99
103
 
100
104
  for (const reviewer of reviewers) {
101
105
  logger?.info("review", `Running plugin reviewer: ${reviewer.name}`, {
102
- changedFiles: changedFiles.length,
106
+ changedFiles: scopedFiles.length,
103
107
  });
104
108
  try {
105
- const result = await reviewer.check(workdir, changedFiles);
109
+ const result = await reviewer.check(workdir, scopedFiles);
106
110
  // Always log the result so skips/passes are visible in the log
107
111
  logger?.info("review", `Plugin reviewer result: ${reviewer.name}`, {
108
112
  passed: result.passed,
@@ -194,7 +194,27 @@ export function buildSmartTestCommand(testFiles: string[], baseCommand: string):
194
194
  return newParts.join(" ");
195
195
  }
196
196
 
197
- export async function getChangedSourceFiles(workdir: string, baseRef?: string): Promise<string[]> {
197
+ /**
198
+ * Get TypeScript source files changed since the previous commit.
199
+ *
200
+ * Runs `git diff --name-only <ref>` in the given workdir and filters
201
+ * results to only `.ts` files under the relevant source prefix.
202
+ *
203
+ * In a monorepo, git returns paths relative to the git root (e.g.
204
+ * `packages/api/src/foo.ts`). When `packagePrefix` is set to the
205
+ * story's workdir (e.g. `"packages/api"`), the filter is scoped to
206
+ * `<packagePrefix>/src/` instead of just `src/`.
207
+ *
208
+ * @param workdir - Working directory to run git command in
209
+ * @param baseRef - Git ref for diff base (default: HEAD~1)
210
+ * @param packagePrefix - Story workdir relative to repo root (e.g. "packages/api")
211
+ * @returns Array of changed .ts file paths relative to the git root
212
+ */
213
+ export async function getChangedSourceFiles(
214
+ workdir: string,
215
+ baseRef?: string,
216
+ packagePrefix?: string,
217
+ ): Promise<string[]> {
198
218
  try {
199
219
  // FEAT-010: Use per-attempt baseRef for precise diff; fall back to HEAD~1 if not provided
200
220
  const ref = baseRef ?? "HEAD~1";
@@ -204,7 +224,9 @@ export async function getChangedSourceFiles(workdir: string, baseRef?: string):
204
224
 
205
225
  const lines = stdout.trim().split("\n").filter(Boolean);
206
226
 
207
- return lines.filter((f) => f.startsWith("src/") && f.endsWith(".ts"));
227
+ // MW-006: scope filter to package prefix in monorepo
228
+ const srcPrefix = packagePrefix ? `${packagePrefix}/src/` : "src/";
229
+ return lines.filter((f) => f.startsWith(srcPrefix) && f.endsWith(".ts"));
208
230
  } catch {
209
231
  return [];
210
232
  }