@nathapp/nax 0.46.3 → 0.48.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 +1344 -747
- package/package.json +1 -1
- package/src/cli/generate.ts +86 -13
- package/src/cli/init-context.ts +57 -0
- package/src/cli/init.ts +14 -1
- package/src/cli/plan.ts +139 -8
- package/src/config/loader.ts +34 -1
- package/src/config/merge.ts +37 -0
- package/src/config/runtime-types.ts +12 -0
- package/src/context/generator.ts +181 -1
- 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
package/src/context/generator.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Replaces the old constitution generator.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import type { NaxConfig } from "../config";
|
|
11
11
|
import { validateFilePath } from "../config/path-security";
|
|
@@ -129,5 +129,185 @@ async function generateAll(options: GenerateOptions, config: NaxConfig): Promise
|
|
|
129
129
|
return results;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/** Result from generateForPackage */
|
|
133
|
+
export interface PackageGenerationResult {
|
|
134
|
+
packageDir: string;
|
|
135
|
+
outputFile: string;
|
|
136
|
+
content: string;
|
|
137
|
+
written: boolean;
|
|
138
|
+
error?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Discover packages that have nax/context.md files.
|
|
143
|
+
*
|
|
144
|
+
* Scans up to 2 levels deep (one-level and two-level patterns).
|
|
145
|
+
*
|
|
146
|
+
* @param repoRoot - Absolute repo root to scan
|
|
147
|
+
* @returns Array of package directory paths (absolute)
|
|
148
|
+
*/
|
|
149
|
+
export async function discoverPackages(repoRoot: string): Promise<string[]> {
|
|
150
|
+
const packages: string[] = [];
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
|
|
153
|
+
for (const pattern of ["*/nax/context.md", "*/*/nax/context.md"]) {
|
|
154
|
+
const glob = new Bun.Glob(pattern);
|
|
155
|
+
for await (const match of glob.scan(repoRoot)) {
|
|
156
|
+
// match is e.g. "packages/api/nax/context.md" — strip trailing /nax/context.md
|
|
157
|
+
const pkgRelative = match.replace(/\/nax\/context\.md$/, "");
|
|
158
|
+
const pkgAbsolute = join(repoRoot, pkgRelative);
|
|
159
|
+
if (!seen.has(pkgAbsolute)) {
|
|
160
|
+
seen.add(pkgAbsolute);
|
|
161
|
+
packages.push(pkgAbsolute);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return packages;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Discover packages from workspace manifests (turbo.json, package.json workspaces,
|
|
171
|
+
* pnpm-workspace.yaml). Used as fallback when no nax/context.md files exist yet.
|
|
172
|
+
*
|
|
173
|
+
* Returns relative paths (e.g. "packages/api") sorted alphabetically.
|
|
174
|
+
*/
|
|
175
|
+
export async function discoverWorkspacePackages(repoRoot: string): Promise<string[]> {
|
|
176
|
+
// 1. Prefer packages that already have nax/context.md
|
|
177
|
+
const existing = await discoverPackages(repoRoot);
|
|
178
|
+
if (existing.length > 0) {
|
|
179
|
+
return existing.map((p) => p.replace(`${repoRoot}/`, ""));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const seen = new Set<string>();
|
|
183
|
+
const results: string[] = [];
|
|
184
|
+
|
|
185
|
+
async function resolveGlobs(patterns: string[]): Promise<void> {
|
|
186
|
+
for (const pattern of patterns) {
|
|
187
|
+
if (pattern.startsWith("!")) continue; // skip negations
|
|
188
|
+
// Convert workspace pattern to package.json glob so Bun can scan files
|
|
189
|
+
// "packages/*" → "packages/*/package.json"
|
|
190
|
+
// "packages/**" → "packages/**/package.json"
|
|
191
|
+
const base = pattern.replace(/\/+$/, ""); // strip trailing slashes
|
|
192
|
+
const pkgPattern = base.endsWith("*") ? `${base}/package.json` : `${base}/*/package.json`;
|
|
193
|
+
|
|
194
|
+
const g = new Bun.Glob(pkgPattern);
|
|
195
|
+
for await (const match of g.scan(repoRoot)) {
|
|
196
|
+
const rel = match.replace(/\/package\.json$/, "");
|
|
197
|
+
if (!seen.has(rel)) {
|
|
198
|
+
seen.add(rel);
|
|
199
|
+
results.push(rel);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 2. turbo v2+: turbo.json top-level "packages" array
|
|
206
|
+
const turboPath = join(repoRoot, "turbo.json");
|
|
207
|
+
if (existsSync(turboPath)) {
|
|
208
|
+
try {
|
|
209
|
+
const turbo = JSON.parse(readFileSync(turboPath, "utf-8")) as Record<string, unknown>;
|
|
210
|
+
if (Array.isArray(turbo.packages)) {
|
|
211
|
+
await resolveGlobs(turbo.packages as string[]);
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// malformed turbo.json — skip
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 3. root package.json "workspaces" (npm/yarn/bun/turbo v1)
|
|
219
|
+
const pkgPath = join(repoRoot, "package.json");
|
|
220
|
+
if (existsSync(pkgPath)) {
|
|
221
|
+
try {
|
|
222
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as Record<string, unknown>;
|
|
223
|
+
const ws = pkg.workspaces;
|
|
224
|
+
const patterns: string[] = Array.isArray(ws)
|
|
225
|
+
? (ws as string[])
|
|
226
|
+
: Array.isArray((ws as Record<string, unknown>)?.packages)
|
|
227
|
+
? ((ws as Record<string, unknown>).packages as string[])
|
|
228
|
+
: [];
|
|
229
|
+
if (patterns.length > 0) await resolveGlobs(patterns);
|
|
230
|
+
} catch {
|
|
231
|
+
// malformed package.json — skip
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 4. pnpm-workspace.yaml
|
|
236
|
+
const pnpmPath = join(repoRoot, "pnpm-workspace.yaml");
|
|
237
|
+
if (existsSync(pnpmPath)) {
|
|
238
|
+
try {
|
|
239
|
+
const raw = readFileSync(pnpmPath, "utf-8");
|
|
240
|
+
// Simple YAML parse for "packages:\n - 'packages/*'" without full YAML dep
|
|
241
|
+
const lines = raw.split("\n");
|
|
242
|
+
let inPackages = false;
|
|
243
|
+
const patterns: string[] = [];
|
|
244
|
+
for (const line of lines) {
|
|
245
|
+
if (/^packages\s*:/.test(line)) {
|
|
246
|
+
inPackages = true;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (inPackages && /^\s+-\s+/.test(line)) {
|
|
250
|
+
patterns.push(line.replace(/^\s+-\s+['"]?/, "").replace(/['"]?\s*$/, ""));
|
|
251
|
+
} else if (inPackages && !/^\s/.test(line)) {
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (patterns.length > 0) await resolveGlobs(patterns);
|
|
256
|
+
} catch {
|
|
257
|
+
// malformed yaml — skip
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return results.sort();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Generate the claude CLAUDE.md for a specific package.
|
|
266
|
+
*
|
|
267
|
+
* Reads `<packageDir>/nax/context.md` and writes `<packageDir>/CLAUDE.md`.
|
|
268
|
+
* Per-package CLAUDE.md contains only package-specific content — Claude Code's
|
|
269
|
+
* native directory hierarchy merges root CLAUDE.md + package CLAUDE.md at runtime.
|
|
270
|
+
*/
|
|
271
|
+
export async function generateForPackage(
|
|
272
|
+
packageDir: string,
|
|
273
|
+
config: NaxConfig,
|
|
274
|
+
dryRun = false,
|
|
275
|
+
): Promise<PackageGenerationResult> {
|
|
276
|
+
const contextPath = join(packageDir, "nax", "context.md");
|
|
277
|
+
|
|
278
|
+
if (!existsSync(contextPath)) {
|
|
279
|
+
return {
|
|
280
|
+
packageDir,
|
|
281
|
+
outputFile: "CLAUDE.md",
|
|
282
|
+
content: "",
|
|
283
|
+
written: false,
|
|
284
|
+
error: `context.md not found: ${contextPath}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const options: GenerateOptions = {
|
|
290
|
+
contextPath,
|
|
291
|
+
outputDir: packageDir,
|
|
292
|
+
workdir: packageDir,
|
|
293
|
+
dryRun,
|
|
294
|
+
autoInject: true,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const result = await generateFor("claude", options, config);
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
packageDir,
|
|
301
|
+
outputFile: result.outputFile,
|
|
302
|
+
content: result.content,
|
|
303
|
+
written: result.written,
|
|
304
|
+
error: result.error,
|
|
305
|
+
};
|
|
306
|
+
} catch (err) {
|
|
307
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
308
|
+
return { packageDir, outputFile: "CLAUDE.md", content: "", written: false, error };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
132
312
|
export { generateFor, generateAll };
|
|
133
313
|
export type { AgentType };
|
|
@@ -127,14 +127,33 @@ export async function buildStoryContext(prd: PRD, story: UserStory, _config: Nax
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Load package-level context.md content if it exists.
|
|
132
|
+
*
|
|
133
|
+
* Reads <packageWorkdir>/nax/context.md and returns its content, or null
|
|
134
|
+
* if the file does not exist.
|
|
135
|
+
*
|
|
136
|
+
* @internal
|
|
137
|
+
*/
|
|
138
|
+
async function loadPackageContextMd(packageWorkdir: string): Promise<string | null> {
|
|
139
|
+
const contextPath = `${packageWorkdir}/nax/context.md`;
|
|
140
|
+
const file = Bun.file(contextPath);
|
|
141
|
+
if (!(await file.exists())) return null;
|
|
142
|
+
return file.text();
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
/**
|
|
131
146
|
* Build story context returning both markdown and element-level data.
|
|
132
147
|
* Used by `nax prompts` CLI for accurate frontmatter token counts.
|
|
148
|
+
*
|
|
149
|
+
* When `packageWorkdir` is provided (absolute path of story.workdir),
|
|
150
|
+
* appends the package-level nax/context.md after the root context.
|
|
133
151
|
*/
|
|
134
152
|
export async function buildStoryContextFull(
|
|
135
153
|
prd: PRD,
|
|
136
154
|
story: UserStory,
|
|
137
155
|
config: NaxConfig,
|
|
156
|
+
packageWorkdir?: string,
|
|
138
157
|
): Promise<{ markdown: string; builtContext: BuiltContext } | undefined> {
|
|
139
158
|
try {
|
|
140
159
|
const storyContext: StoryContext = {
|
|
@@ -152,11 +171,23 @@ export async function buildStoryContextFull(
|
|
|
152
171
|
|
|
153
172
|
const built = await buildContext(storyContext, budget);
|
|
154
173
|
|
|
155
|
-
|
|
174
|
+
// MW-003: append package-level context.md if workdir is set
|
|
175
|
+
let packageSection = "";
|
|
176
|
+
if (packageWorkdir) {
|
|
177
|
+
const pkgContent = await loadPackageContextMd(packageWorkdir);
|
|
178
|
+
if (pkgContent) {
|
|
179
|
+
packageSection = `\n---\n\n${pkgContent.trim()}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (built.elements.length === 0 && !packageSection) {
|
|
156
184
|
return undefined;
|
|
157
185
|
}
|
|
158
186
|
|
|
159
|
-
|
|
187
|
+
const baseMarkdown = built.elements.length > 0 ? formatContextAsMarkdown(built) : "";
|
|
188
|
+
const markdown = packageSection ? `${baseMarkdown}${packageSection}` : baseMarkdown;
|
|
189
|
+
|
|
190
|
+
return { markdown, builtContext: built };
|
|
160
191
|
} catch (error) {
|
|
161
192
|
const logger = getSafeLogger();
|
|
162
193
|
logger?.warn("context", "Context builder failed", {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* ```
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { join } from "node:path";
|
|
23
24
|
import type { ContextElement } from "../../context/types";
|
|
24
25
|
import { buildStoryContextFull } from "../../execution/helpers";
|
|
25
26
|
import { getLogger } from "../../logger";
|
|
@@ -33,8 +34,11 @@ export const contextStage: PipelineStage = {
|
|
|
33
34
|
async execute(ctx: PipelineContext): Promise<StageResult> {
|
|
34
35
|
const logger = getLogger();
|
|
35
36
|
|
|
37
|
+
// MW-003: resolve package workdir for per-package context.md loading
|
|
38
|
+
const packageWorkdir = ctx.story.workdir ? join(ctx.workdir, ctx.story.workdir) : undefined;
|
|
39
|
+
|
|
36
40
|
// Build context from PRD with element-level tracking
|
|
37
|
-
const result = await buildStoryContextFull(ctx.prd, ctx.story, ctx.config);
|
|
41
|
+
const result = await buildStoryContextFull(ctx.prd, ctx.story, ctx.config, packageWorkdir);
|
|
38
42
|
|
|
39
43
|
// SOFT FAILURE: Empty context is acceptable — agent can work without PRD context
|
|
40
44
|
// This happens when no relevant stories/context is found, which is normal
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
* ```
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import { existsSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
33
35
|
import { getAgent, validateAgentForTier } from "../../agents";
|
|
34
36
|
import { resolveModel } from "../../config";
|
|
35
37
|
import { resolvePermissions } from "../../config/permissions";
|
|
@@ -40,6 +42,22 @@ import { runThreeSessionTdd } from "../../tdd";
|
|
|
40
42
|
import { autoCommitIfDirty, detectMergeConflict } from "../../utils/git";
|
|
41
43
|
import type { PipelineContext, PipelineStage, StageResult } from "../types";
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the effective working directory for a story.
|
|
47
|
+
* When story.workdir is set, returns join(repoRoot, story.workdir).
|
|
48
|
+
* Otherwise returns the repo root unchanged.
|
|
49
|
+
*
|
|
50
|
+
* MW-001 runtime check: throws if the resolved workdir does not exist on disk.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveStoryWorkdir(repoRoot: string, storyWorkdir?: string): string {
|
|
53
|
+
if (!storyWorkdir) return repoRoot;
|
|
54
|
+
const resolved = join(repoRoot, storyWorkdir);
|
|
55
|
+
if (!existsSync(resolved)) {
|
|
56
|
+
throw new Error(`[execution] story.workdir "${storyWorkdir}" does not exist at "${resolved}"`);
|
|
57
|
+
}
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
/**
|
|
44
62
|
* Detect if agent output contains ambiguity signals
|
|
45
63
|
* Checks for keywords that indicate the agent is unsure about the implementation
|
|
@@ -128,11 +146,13 @@ export const executionStage: PipelineStage = {
|
|
|
128
146
|
lite: isLiteMode,
|
|
129
147
|
});
|
|
130
148
|
|
|
149
|
+
const effectiveWorkdir = _executionDeps.resolveStoryWorkdir(ctx.workdir, ctx.story.workdir);
|
|
150
|
+
|
|
131
151
|
const tddResult = await runThreeSessionTdd({
|
|
132
152
|
agent,
|
|
133
153
|
story: ctx.story,
|
|
134
154
|
config: ctx.config,
|
|
135
|
-
workdir:
|
|
155
|
+
workdir: effectiveWorkdir,
|
|
136
156
|
modelTier: ctx.routing.modelTier,
|
|
137
157
|
featureName: ctx.prd.feature,
|
|
138
158
|
contextMarkdown: ctx.contextMarkdown,
|
|
@@ -212,9 +232,11 @@ export const executionStage: PipelineStage = {
|
|
|
212
232
|
});
|
|
213
233
|
}
|
|
214
234
|
|
|
235
|
+
const storyWorkdir = _executionDeps.resolveStoryWorkdir(ctx.workdir, ctx.story.workdir);
|
|
236
|
+
|
|
215
237
|
const result = await agent.run({
|
|
216
238
|
prompt: ctx.prompt,
|
|
217
|
-
workdir:
|
|
239
|
+
workdir: storyWorkdir,
|
|
218
240
|
modelTier: ctx.routing.modelTier,
|
|
219
241
|
modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
|
|
220
242
|
timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
|
|
@@ -258,7 +280,7 @@ export const executionStage: PipelineStage = {
|
|
|
258
280
|
ctx.agentResult = result;
|
|
259
281
|
|
|
260
282
|
// BUG-058: Auto-commit if agent left uncommitted changes (single-session/test-after)
|
|
261
|
-
await autoCommitIfDirty(
|
|
283
|
+
await autoCommitIfDirty(storyWorkdir, "execution", "single-session", ctx.story.id);
|
|
262
284
|
|
|
263
285
|
// merge-conflict trigger: detect CONFLICT markers in agent output
|
|
264
286
|
const combinedOutput = (result.output ?? "") + (result.stderr ?? "");
|
|
@@ -327,4 +349,5 @@ export const _executionDeps = {
|
|
|
327
349
|
checkMergeConflict,
|
|
328
350
|
isAmbiguousOutput,
|
|
329
351
|
checkStoryAmbiguity,
|
|
352
|
+
resolveStoryWorkdir,
|
|
330
353
|
};
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
// RE-ARCH: rewrite
|
|
14
|
+
import { join } from "node:path";
|
|
14
15
|
import { checkSecurityReview, isTriggerEnabled } from "../../interaction/triggers";
|
|
15
16
|
import { getLogger } from "../../logger";
|
|
16
17
|
import { reviewOrchestrator } from "../../review/orchestrator";
|
|
@@ -25,12 +26,16 @@ export const reviewStage: PipelineStage = {
|
|
|
25
26
|
|
|
26
27
|
logger.info("review", "Running review phase", { storyId: ctx.story.id });
|
|
27
28
|
|
|
29
|
+
// MW-010: scope review to package directory when story.workdir is set
|
|
30
|
+
const effectiveWorkdir = ctx.story.workdir ? join(ctx.workdir, ctx.story.workdir) : ctx.workdir;
|
|
31
|
+
|
|
28
32
|
const result = await reviewOrchestrator.review(
|
|
29
33
|
ctx.config.review,
|
|
30
|
-
|
|
34
|
+
effectiveWorkdir,
|
|
31
35
|
ctx.config.execution,
|
|
32
36
|
ctx.plugins,
|
|
33
37
|
ctx.storyGitRef,
|
|
38
|
+
ctx.story.workdir, // MW-010: scope changed-file checks to package
|
|
34
39
|
);
|
|
35
40
|
|
|
36
41
|
ctx.reviewResult = result.builtIn;
|
|
@@ -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
|
}
|