@nathapp/nax 0.41.0 → 0.42.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/README.md +1 -0
- package/bin/nax.ts +130 -11
- package/dist/nax.js +478 -186
- package/package.json +7 -6
- package/src/agents/acp/adapter.ts +3 -5
- package/src/agents/claude.ts +12 -2
- package/src/analyze/scanner.ts +16 -20
- package/src/cli/plan.ts +211 -145
- package/src/commands/precheck.ts +1 -1
- package/src/interaction/plugins/webhook.ts +10 -1
- package/src/prd/schema.ts +249 -0
- package/src/tdd/session-runner.ts +11 -2
- package/src/utils/git.ts +30 -0
- package/src/verification/runners.ts +10 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD JSON Validation and Schema Enforcement
|
|
3
|
+
*
|
|
4
|
+
* Validates and normalizes LLM-generated PRD JSON output before writing to disk.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Complexity, TestStrategy } from "../config";
|
|
8
|
+
import type { PRD, UserStory } from "./types";
|
|
9
|
+
import { validateStoryId } from "./validate";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Constants
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const VALID_COMPLEXITY: Complexity[] = ["simple", "medium", "complex", "expert"];
|
|
16
|
+
const VALID_TEST_STRATEGIES: TestStrategy[] = [
|
|
17
|
+
"test-after",
|
|
18
|
+
"tdd-simple",
|
|
19
|
+
"three-session-tdd",
|
|
20
|
+
"three-session-tdd-lite",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Pattern matching ST001 → ST-001 style IDs (prefix letters + digits, no separator) */
|
|
24
|
+
const STORY_ID_NO_SEPARATOR = /^([A-Za-z]+)(\d+)$/;
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Public API
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract JSON from a markdown code block.
|
|
32
|
+
*
|
|
33
|
+
* Handles:
|
|
34
|
+
* ```json ... ```
|
|
35
|
+
* ``` ... ```
|
|
36
|
+
*
|
|
37
|
+
* Returns the input unchanged if no code block is detected.
|
|
38
|
+
*/
|
|
39
|
+
export function extractJsonFromMarkdown(text: string): string {
|
|
40
|
+
const match = text.match(/```(?:json)?\s*\n([\s\S]*?)\n?\s*```/);
|
|
41
|
+
if (match) {
|
|
42
|
+
return match[1] ?? text;
|
|
43
|
+
}
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Strip trailing commas before closing braces/brackets to handle a common LLM quirk.
|
|
49
|
+
* e.g. `{"a":1,}` → `{"a":1}`
|
|
50
|
+
*/
|
|
51
|
+
function stripTrailingCommas(text: string): string {
|
|
52
|
+
return text.replace(/,\s*([}\]])/g, "$1");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalize a story ID: convert e.g. ST001 → ST-001.
|
|
57
|
+
* Leaves IDs that already have separators unchanged.
|
|
58
|
+
*/
|
|
59
|
+
function normalizeStoryId(id: string): string {
|
|
60
|
+
const match = id.match(STORY_ID_NO_SEPARATOR);
|
|
61
|
+
if (match) {
|
|
62
|
+
return `${match[1]}-${match[2]}`;
|
|
63
|
+
}
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Normalize complexity string (case-insensitive) to a valid Complexity value.
|
|
69
|
+
* Returns null if no match found.
|
|
70
|
+
*/
|
|
71
|
+
function normalizeComplexity(raw: string): Complexity | null {
|
|
72
|
+
const lower = raw.toLowerCase() as Complexity;
|
|
73
|
+
if ((VALID_COMPLEXITY as string[]).includes(lower)) {
|
|
74
|
+
return lower;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate a single story from raw LLM output.
|
|
81
|
+
* Returns a normalized UserStory or throws with field-level error.
|
|
82
|
+
*/
|
|
83
|
+
function validateStory(raw: unknown, index: number, allIds: Set<string>): UserStory {
|
|
84
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
85
|
+
throw new Error(`[schema] story[${index}] must be an object`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const s = raw as Record<string, unknown>;
|
|
89
|
+
|
|
90
|
+
// id
|
|
91
|
+
const rawId = s.id;
|
|
92
|
+
if (rawId === undefined || rawId === null || rawId === "") {
|
|
93
|
+
throw new Error(`[schema] story[${index}].id is required and must be non-empty`);
|
|
94
|
+
}
|
|
95
|
+
if (typeof rawId !== "string") {
|
|
96
|
+
throw new Error(`[schema] story[${index}].id must be a string`);
|
|
97
|
+
}
|
|
98
|
+
const id = normalizeStoryId(rawId);
|
|
99
|
+
validateStoryId(id);
|
|
100
|
+
|
|
101
|
+
// title
|
|
102
|
+
const title = s.title;
|
|
103
|
+
if (!title || typeof title !== "string" || title.trim() === "") {
|
|
104
|
+
throw new Error(`[schema] story[${index}].title is required and must be non-empty`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// description
|
|
108
|
+
const description = s.description;
|
|
109
|
+
if (!description || typeof description !== "string" || description.trim() === "") {
|
|
110
|
+
throw new Error(`[schema] story[${index}].description is required and must be non-empty`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// acceptanceCriteria
|
|
114
|
+
const ac = s.acceptanceCriteria;
|
|
115
|
+
if (!Array.isArray(ac) || ac.length === 0) {
|
|
116
|
+
throw new Error(`[schema] story[${index}].acceptanceCriteria is required and must be a non-empty array`);
|
|
117
|
+
}
|
|
118
|
+
for (let i = 0; i < ac.length; i++) {
|
|
119
|
+
if (typeof ac[i] !== "string") {
|
|
120
|
+
throw new Error(`[schema] story[${index}].acceptanceCriteria[${i}] must be a string`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// complexity — accept from routing.complexity (PRD format) or top-level complexity (legacy)
|
|
125
|
+
const routing = typeof s.routing === "object" && s.routing !== null ? (s.routing as Record<string, unknown>) : {};
|
|
126
|
+
const rawComplexity = routing.complexity ?? s.complexity;
|
|
127
|
+
if (rawComplexity === undefined || rawComplexity === null) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`[schema] story[${index}] missing complexity. Set routing.complexity to one of: ${VALID_COMPLEXITY.join(", ")}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (typeof rawComplexity !== "string") {
|
|
133
|
+
throw new Error(`[schema] story[${index}].routing.complexity must be a string`);
|
|
134
|
+
}
|
|
135
|
+
const complexity = normalizeComplexity(rawComplexity);
|
|
136
|
+
if (complexity === null) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`[schema] story[${index}].routing.complexity "${rawComplexity}" is invalid. Valid values: ${VALID_COMPLEXITY.join(", ")}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// testStrategy — accept from routing.testStrategy or top-level testStrategy
|
|
143
|
+
const rawTestStrategy = routing.testStrategy ?? s.testStrategy;
|
|
144
|
+
const testStrategy: TestStrategy =
|
|
145
|
+
rawTestStrategy !== undefined && (VALID_TEST_STRATEGIES as unknown[]).includes(rawTestStrategy)
|
|
146
|
+
? (rawTestStrategy as TestStrategy)
|
|
147
|
+
: "tdd-simple";
|
|
148
|
+
|
|
149
|
+
// dependencies
|
|
150
|
+
const rawDeps = s.dependencies;
|
|
151
|
+
const dependencies: string[] = Array.isArray(rawDeps) ? (rawDeps as string[]) : [];
|
|
152
|
+
|
|
153
|
+
// Validate dependency references (against already-known IDs)
|
|
154
|
+
for (const dep of dependencies) {
|
|
155
|
+
if (!allIds.has(dep)) {
|
|
156
|
+
throw new Error(`[schema] story[${index}].dependencies references unknown story ID "${dep}"`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// tags
|
|
161
|
+
const rawTags = s.tags;
|
|
162
|
+
const tags: string[] = Array.isArray(rawTags) ? (rawTags as string[]) : [];
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
title: title.trim(),
|
|
167
|
+
description: description.trim(),
|
|
168
|
+
acceptanceCriteria: ac as string[],
|
|
169
|
+
tags,
|
|
170
|
+
dependencies,
|
|
171
|
+
// Force runtime state — never trust LLM output
|
|
172
|
+
status: "pending",
|
|
173
|
+
passes: false,
|
|
174
|
+
attempts: 0,
|
|
175
|
+
escalations: [],
|
|
176
|
+
routing: {
|
|
177
|
+
complexity,
|
|
178
|
+
testStrategy,
|
|
179
|
+
reasoning: "validated from LLM output",
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse raw string input, handling markdown wrapping and trailing commas.
|
|
186
|
+
* Throws with parse error context on failure.
|
|
187
|
+
*/
|
|
188
|
+
function parseRawString(text: string): unknown {
|
|
189
|
+
const extracted = extractJsonFromMarkdown(text);
|
|
190
|
+
const cleaned = stripTrailingCommas(extracted);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(cleaned);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const parseErr = err as SyntaxError;
|
|
196
|
+
throw new Error(`[schema] Failed to parse JSON: ${parseErr.message}`, { cause: parseErr });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Validate and normalize the JSON output from the planning LLM.
|
|
202
|
+
*
|
|
203
|
+
* @param raw - Raw LLM output (string or already-parsed object)
|
|
204
|
+
* @param feature - Feature name for auto-fill
|
|
205
|
+
* @param branch - Branch name for auto-fill
|
|
206
|
+
* @returns Validated PRD object
|
|
207
|
+
*/
|
|
208
|
+
export function validatePlanOutput(raw: unknown, feature: string, branch: string): PRD {
|
|
209
|
+
// Parse string input
|
|
210
|
+
const parsed: unknown = typeof raw === "string" ? parseRawString(raw) : raw;
|
|
211
|
+
|
|
212
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
213
|
+
throw new Error("[schema] PRD output must be a JSON object");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const obj = parsed as Record<string, unknown>;
|
|
217
|
+
|
|
218
|
+
// Validate top-level userStories
|
|
219
|
+
const rawStories = obj.userStories;
|
|
220
|
+
if (!Array.isArray(rawStories) || rawStories.length === 0) {
|
|
221
|
+
throw new Error("[schema] userStories is required and must be a non-empty array");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// First pass: collect all story IDs (after normalization) for dependency validation
|
|
225
|
+
const allIds = new Set<string>();
|
|
226
|
+
for (const story of rawStories) {
|
|
227
|
+
if (typeof story === "object" && story !== null && !Array.isArray(story)) {
|
|
228
|
+
const s = story as Record<string, unknown>;
|
|
229
|
+
const rawId = s.id;
|
|
230
|
+
if (typeof rawId === "string" && rawId !== "") {
|
|
231
|
+
allIds.add(normalizeStoryId(rawId));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Second pass: full validation
|
|
237
|
+
const userStories: UserStory[] = rawStories.map((story, index) => validateStory(story, index, allIds));
|
|
238
|
+
|
|
239
|
+
const now = new Date().toISOString();
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
project: typeof obj.project === "string" && obj.project !== "" ? obj.project : feature,
|
|
243
|
+
feature,
|
|
244
|
+
branchName: branch,
|
|
245
|
+
createdAt: typeof obj.createdAt === "string" ? obj.createdAt : now,
|
|
246
|
+
updatedAt: now,
|
|
247
|
+
userStories,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -10,8 +10,17 @@ import { resolveModel } from "../config";
|
|
|
10
10
|
import { getLogger } from "../logger";
|
|
11
11
|
import type { UserStory } from "../prd";
|
|
12
12
|
import { PromptBuilder } from "../prompts";
|
|
13
|
-
import { autoCommitIfDirty } from "../utils/git";
|
|
13
|
+
import { autoCommitIfDirty as _autoCommitIfDirtyFn } from "../utils/git";
|
|
14
14
|
import { cleanupProcessTree } from "./cleanup";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Injectable dependencies for session-runner — allows tests to mock
|
|
18
|
+
* autoCommitIfDirty without going through internal git deps.
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export const _sessionRunnerDeps = {
|
|
22
|
+
autoCommitIfDirty: _autoCommitIfDirtyFn,
|
|
23
|
+
};
|
|
15
24
|
import { getChangedFiles, verifyImplementerIsolation, verifyTestWriterIsolation } from "./isolation";
|
|
16
25
|
import type { IsolationCheck } from "./types";
|
|
17
26
|
import type { TddSessionResult, TddSessionRole } from "./types";
|
|
@@ -158,7 +167,7 @@ export async function runTddSession(
|
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
// BUG-058: Auto-commit if agent left uncommitted changes
|
|
161
|
-
await autoCommitIfDirty(workdir, "tdd", role, story.id);
|
|
170
|
+
await _sessionRunnerDeps.autoCommitIfDirty(workdir, "tdd", role, story.id);
|
|
162
171
|
|
|
163
172
|
// Check isolation based on role and skipIsolation flag.
|
|
164
173
|
let isolation: IsolationCheck | undefined;
|
package/src/utils/git.ts
CHANGED
|
@@ -153,6 +153,36 @@ export function detectMergeConflict(output: string): boolean {
|
|
|
153
153
|
export async function autoCommitIfDirty(workdir: string, stage: string, role: string, storyId: string): Promise<void> {
|
|
154
154
|
const logger = getSafeLogger();
|
|
155
155
|
try {
|
|
156
|
+
// Guard: only auto-commit if workdir IS the git repository root.
|
|
157
|
+
// Without this, a workdir nested inside another git repo (e.g. a temp dir
|
|
158
|
+
// created inside the nax repo during tests) would cause git to walk up and
|
|
159
|
+
// commit files from the parent repo instead.
|
|
160
|
+
const topLevelProc = _gitDeps.spawn(["git", "rev-parse", "--show-toplevel"], {
|
|
161
|
+
cwd: workdir,
|
|
162
|
+
stdout: "pipe",
|
|
163
|
+
stderr: "pipe",
|
|
164
|
+
});
|
|
165
|
+
const gitRoot = (await new Response(topLevelProc.stdout).text()).trim();
|
|
166
|
+
await topLevelProc.exited;
|
|
167
|
+
|
|
168
|
+
// Normalize paths to handle symlinks (e.g. /tmp → /private/tmp on macOS)
|
|
169
|
+
const { realpathSync } = await import("node:fs");
|
|
170
|
+
const realWorkdir = (() => {
|
|
171
|
+
try {
|
|
172
|
+
return realpathSync(workdir);
|
|
173
|
+
} catch {
|
|
174
|
+
return workdir;
|
|
175
|
+
}
|
|
176
|
+
})();
|
|
177
|
+
const realGitRoot = (() => {
|
|
178
|
+
try {
|
|
179
|
+
return realpathSync(gitRoot);
|
|
180
|
+
} catch {
|
|
181
|
+
return gitRoot;
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
184
|
+
if (realWorkdir !== realGitRoot) return;
|
|
185
|
+
|
|
156
186
|
const statusProc = _gitDeps.spawn(["git", "status", "--porcelain"], {
|
|
157
187
|
cwd: workdir,
|
|
158
188
|
stdout: "pipe",
|
|
@@ -113,8 +113,17 @@ export async function scoped(options: VerificationGateOptions): Promise<Verifica
|
|
|
113
113
|
return runVerificationCore({ ...options, command: scopedCommand });
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Injectable dependencies for regression() — allows tests to replace
|
|
118
|
+
* the 2s agent-cleanup sleep with a no-op without touching production behaviour.
|
|
119
|
+
* @internal
|
|
120
|
+
*/
|
|
121
|
+
export const _regressionRunnerDeps = {
|
|
122
|
+
sleep: (ms: number): Promise<void> => Bun.sleep(ms),
|
|
123
|
+
};
|
|
124
|
+
|
|
116
125
|
/** Quick smoke test — no asset verification, 2s delay to let agent processes terminate. */
|
|
117
126
|
export async function regression(options: VerificationGateOptions): Promise<VerificationResult> {
|
|
118
|
-
await
|
|
127
|
+
await _regressionRunnerDeps.sleep(2000);
|
|
119
128
|
return runVerificationCore({ ...options, expectedFiles: undefined });
|
|
120
129
|
}
|