@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.
- package/bin/nax.ts +20 -0
- package/dist/nax.js +1200 -744
- package/package.json +1 -1
- package/src/cli/generate.ts +73 -11
- package/src/cli/init-context.ts +57 -0
- package/src/cli/init.ts +14 -1
- package/src/cli/plan.ts +30 -7
- package/src/config/loader.ts +34 -1
- package/src/config/merge.ts +37 -0
- package/src/context/generator.ts +85 -0
- package/src/execution/story-context.ts +33 -2
- package/src/pipeline/stages/context.ts +5 -1
- package/src/pipeline/stages/execution.ts +26 -3
- package/src/pipeline/stages/review.ts +6 -1
- package/src/pipeline/stages/verify.ts +23 -7
- package/src/prd/schema.ts +17 -0
- package/src/prd/types.ts +6 -0
- package/src/precheck/checks-system.ts +25 -87
- package/src/review/orchestrator.ts +6 -2
- package/src/verification/smart-runner.ts +24 -2
|
@@ -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 (!
|
|
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 =
|
|
64
|
-
const testScopedTemplate =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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: "
|
|
47
|
+
tier: "warning",
|
|
48
48
|
passed: true,
|
|
49
|
-
message: "Test command not configured (
|
|
49
|
+
message: "Test command not configured (will use default: bun test)",
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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: "
|
|
68
|
+
tier: "warning",
|
|
89
69
|
passed: true,
|
|
90
70
|
message: "Lint command not configured (skipped)",
|
|
91
71
|
};
|
|
92
72
|
}
|
|
93
73
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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: "
|
|
89
|
+
tier: "warning",
|
|
130
90
|
passed: true,
|
|
131
91
|
message: "Typecheck command not configured (skipped)",
|
|
132
92
|
};
|
|
133
93
|
}
|
|
134
94
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
106
|
+
changedFiles: scopedFiles.length,
|
|
103
107
|
});
|
|
104
108
|
try {
|
|
105
|
-
const result = await reviewer.check(workdir,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|