@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.
@@ -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 Bun.sleep(2000);
127
+ await _regressionRunnerDeps.sleep(2000);
119
128
  return runVerificationCore({ ...options, expectedFiles: undefined });
120
129
  }