@nathapp/nax 0.37.0 → 0.38.1
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/dist/nax.js +3258 -2894
- package/package.json +4 -1
- package/src/agents/claude-complete.ts +72 -0
- package/src/agents/claude-execution.ts +189 -0
- package/src/agents/claude-interactive.ts +77 -0
- package/src/agents/claude-plan.ts +23 -8
- package/src/agents/claude.ts +64 -349
- package/src/analyze/classifier.ts +2 -1
- package/src/cli/config-descriptions.ts +206 -0
- package/src/cli/config-diff.ts +103 -0
- package/src/cli/config-display.ts +285 -0
- package/src/cli/config-get.ts +55 -0
- package/src/cli/config.ts +7 -618
- package/src/cli/prompts-export.ts +58 -0
- package/src/cli/prompts-init.ts +200 -0
- package/src/cli/prompts-main.ts +237 -0
- package/src/cli/prompts-tdd.ts +78 -0
- package/src/cli/prompts.ts +10 -541
- package/src/commands/logs-formatter.ts +201 -0
- package/src/commands/logs-reader.ts +171 -0
- package/src/commands/logs.ts +11 -362
- package/src/config/loader.ts +4 -15
- package/src/config/runtime-types.ts +448 -0
- package/src/config/schema-types.ts +53 -0
- package/src/config/types.ts +49 -486
- package/src/context/auto-detect.ts +2 -1
- package/src/context/builder.ts +3 -2
- package/src/execution/crash-heartbeat.ts +77 -0
- package/src/execution/crash-recovery.ts +23 -365
- package/src/execution/crash-signals.ts +149 -0
- package/src/execution/crash-writer.ts +154 -0
- package/src/execution/parallel-coordinator.ts +278 -0
- package/src/execution/parallel-executor-rectification-pass.ts +117 -0
- package/src/execution/parallel-executor-rectify.ts +135 -0
- package/src/execution/parallel-executor.ts +19 -211
- package/src/execution/parallel-worker.ts +148 -0
- package/src/execution/parallel.ts +5 -404
- package/src/execution/pid-registry.ts +3 -8
- package/src/execution/runner-completion.ts +160 -0
- package/src/execution/runner-execution.ts +221 -0
- package/src/execution/runner-setup.ts +82 -0
- package/src/execution/runner.ts +53 -202
- package/src/execution/timeout-handler.ts +100 -0
- package/src/hooks/runner.ts +11 -21
- package/src/metrics/tracker.ts +7 -30
- package/src/pipeline/runner.ts +2 -1
- package/src/pipeline/stages/completion.ts +0 -1
- package/src/pipeline/stages/context.ts +2 -1
- package/src/plugins/extensions.ts +225 -0
- package/src/plugins/loader.ts +2 -1
- package/src/plugins/types.ts +16 -221
- package/src/prd/index.ts +2 -1
- package/src/prd/validate.ts +41 -0
- package/src/precheck/checks-blockers.ts +15 -419
- package/src/precheck/checks-cli.ts +68 -0
- package/src/precheck/checks-config.ts +102 -0
- package/src/precheck/checks-git.ts +87 -0
- package/src/precheck/checks-system.ts +163 -0
- package/src/review/orchestrator.ts +19 -6
- package/src/review/runner.ts +17 -5
- package/src/routing/chain.ts +2 -1
- package/src/routing/loader.ts +2 -5
- package/src/tdd/orchestrator.ts +2 -1
- package/src/tdd/verdict-reader.ts +266 -0
- package/src/tdd/verdict.ts +6 -271
- package/src/utils/errors.ts +12 -0
- package/src/utils/git.ts +12 -5
- package/src/utils/json-file.ts +72 -0
- package/src/verification/executor.ts +2 -1
- package/src/verification/smart-runner.ts +23 -3
- package/src/worktree/manager.ts +9 -3
- package/src/worktree/merge.ts +3 -2
package/src/tdd/verdict.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Verifier Verdict — types and
|
|
2
|
+
* Verifier Verdict — types and categorization
|
|
3
3
|
*
|
|
4
4
|
* The verifier (session 3) writes a structured verdict file to
|
|
5
5
|
* `.nax-verifier-verdict.json` in the workdir. This module reads,
|
|
6
|
-
* validates, and
|
|
6
|
+
* validates, interprets, and categorizes that verdict.
|
|
7
|
+
*
|
|
8
|
+
* Re-exports parser and coercer modules for backward compatibility.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import { unlink } from "node:fs/promises";
|
|
10
|
-
import path from "node:path";
|
|
11
|
-
import { getLogger } from "../logger";
|
|
12
11
|
import type { FailureCategory } from "./types";
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
export
|
|
13
|
+
// Re-export for backward compatibility
|
|
14
|
+
export { VERDICT_FILE, isValidVerdict, readVerdict, cleanupVerdict, coerceVerdict } from "./verdict-reader";
|
|
16
15
|
|
|
17
16
|
/** Structured verdict written by the verifier (session 3) */
|
|
18
17
|
export interface VerifierVerdict {
|
|
@@ -71,261 +70,6 @@ export interface VerifierVerdict {
|
|
|
71
70
|
reasoning: string;
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
/**
|
|
75
|
-
* Validate that a parsed object has the required fields for a VerifierVerdict.
|
|
76
|
-
* Returns true if the object appears to be a valid verdict.
|
|
77
|
-
*/
|
|
78
|
-
function isValidVerdict(obj: unknown): obj is VerifierVerdict {
|
|
79
|
-
if (!obj || typeof obj !== "object") return false;
|
|
80
|
-
const v = obj as Record<string, unknown>;
|
|
81
|
-
|
|
82
|
-
// Required top-level fields
|
|
83
|
-
if (v.version !== 1) return false;
|
|
84
|
-
if (typeof v.approved !== "boolean") return false;
|
|
85
|
-
|
|
86
|
-
// tests sub-object
|
|
87
|
-
if (!v.tests || typeof v.tests !== "object") return false;
|
|
88
|
-
const tests = v.tests as Record<string, unknown>;
|
|
89
|
-
if (typeof tests.allPassing !== "boolean") return false;
|
|
90
|
-
if (typeof tests.passCount !== "number") return false;
|
|
91
|
-
if (typeof tests.failCount !== "number") return false;
|
|
92
|
-
|
|
93
|
-
// testModifications sub-object
|
|
94
|
-
if (!v.testModifications || typeof v.testModifications !== "object") return false;
|
|
95
|
-
const mods = v.testModifications as Record<string, unknown>;
|
|
96
|
-
if (typeof mods.detected !== "boolean") return false;
|
|
97
|
-
if (!Array.isArray(mods.files)) return false;
|
|
98
|
-
if (typeof mods.legitimate !== "boolean") return false;
|
|
99
|
-
if (typeof mods.reasoning !== "string") return false;
|
|
100
|
-
|
|
101
|
-
// acceptanceCriteria sub-object
|
|
102
|
-
if (!v.acceptanceCriteria || typeof v.acceptanceCriteria !== "object") return false;
|
|
103
|
-
const ac = v.acceptanceCriteria as Record<string, unknown>;
|
|
104
|
-
if (typeof ac.allMet !== "boolean") return false;
|
|
105
|
-
if (!Array.isArray(ac.criteria)) return false;
|
|
106
|
-
|
|
107
|
-
// quality sub-object
|
|
108
|
-
if (!v.quality || typeof v.quality !== "object") return false;
|
|
109
|
-
const quality = v.quality as Record<string, unknown>;
|
|
110
|
-
if (!["good", "acceptable", "poor"].includes(quality.rating as string)) return false;
|
|
111
|
-
if (!Array.isArray(quality.issues)) return false;
|
|
112
|
-
|
|
113
|
-
// fixes and reasoning
|
|
114
|
-
if (!Array.isArray(v.fixes)) return false;
|
|
115
|
-
if (typeof v.reasoning !== "string") return false;
|
|
116
|
-
|
|
117
|
-
return true;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Coerce a free-form verdict object into the expected VerifierVerdict schema.
|
|
122
|
-
* Maps common agent-improvised patterns (verdict:"PASS", verification_summary, etc.)
|
|
123
|
-
* to the structured format. Returns null if too malformed to coerce.
|
|
124
|
-
*/
|
|
125
|
-
export function coerceVerdict(obj: Record<string, unknown>): VerifierVerdict | null {
|
|
126
|
-
try {
|
|
127
|
-
// Determine approval status
|
|
128
|
-
const verdictStr = String(obj.verdict ?? "").toUpperCase();
|
|
129
|
-
const approved =
|
|
130
|
-
verdictStr === "PASS" ||
|
|
131
|
-
verdictStr === "APPROVED" ||
|
|
132
|
-
verdictStr.startsWith("VERIFIED") ||
|
|
133
|
-
verdictStr.includes("ALL ACCEPTANCE CRITERIA MET") ||
|
|
134
|
-
obj.approved === true;
|
|
135
|
-
|
|
136
|
-
// Parse test results from verification_summary or top-level
|
|
137
|
-
let passCount = 0;
|
|
138
|
-
let failCount = 0;
|
|
139
|
-
let allPassing = approved;
|
|
140
|
-
const summary = obj.verification_summary as Record<string, unknown> | undefined;
|
|
141
|
-
if (summary?.test_results && typeof summary.test_results === "string") {
|
|
142
|
-
// Parse "45/45 PASS" or "42/45 PASS" patterns
|
|
143
|
-
const match = (summary.test_results as string).match(/(\d+)\/(\d+)/);
|
|
144
|
-
if (match) {
|
|
145
|
-
passCount = Number.parseInt(match[1], 10);
|
|
146
|
-
const total = Number.parseInt(match[2], 10);
|
|
147
|
-
failCount = total - passCount;
|
|
148
|
-
allPassing = failCount === 0;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
// Also check top-level tests object (partial schema compliance)
|
|
152
|
-
if (obj.tests && typeof obj.tests === "object") {
|
|
153
|
-
const t = obj.tests as Record<string, unknown>;
|
|
154
|
-
if (typeof t.passCount === "number") passCount = t.passCount;
|
|
155
|
-
if (typeof t.failCount === "number") failCount = t.failCount;
|
|
156
|
-
if (typeof t.allPassing === "boolean") allPassing = t.allPassing;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Parse acceptance criteria from acceptance_criteria_review or acceptanceCriteria
|
|
160
|
-
const criteria: Array<{ criterion: string; met: boolean; note?: string }> = [];
|
|
161
|
-
let allMet = approved;
|
|
162
|
-
const acReview = obj.acceptance_criteria_review as Record<string, unknown> | undefined;
|
|
163
|
-
if (acReview) {
|
|
164
|
-
for (const [key, val] of Object.entries(acReview)) {
|
|
165
|
-
if (key.startsWith("criterion") && val && typeof val === "object") {
|
|
166
|
-
const c = val as Record<string, unknown>;
|
|
167
|
-
const met = String(c.status ?? "").toUpperCase() === "SATISFIED" || c.met === true;
|
|
168
|
-
criteria.push({
|
|
169
|
-
criterion: String(c.name ?? c.criterion ?? key),
|
|
170
|
-
met,
|
|
171
|
-
note: c.evidence ? String(c.evidence).slice(0, 200) : undefined,
|
|
172
|
-
});
|
|
173
|
-
if (!met) allMet = false;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
// Also check top-level acceptanceCriteria
|
|
178
|
-
if (obj.acceptanceCriteria && typeof obj.acceptanceCriteria === "object") {
|
|
179
|
-
const ac = obj.acceptanceCriteria as Record<string, unknown>;
|
|
180
|
-
if (typeof ac.allMet === "boolean") allMet = ac.allMet;
|
|
181
|
-
if (Array.isArray(ac.criteria)) {
|
|
182
|
-
for (const c of ac.criteria) {
|
|
183
|
-
if (c && typeof c === "object") {
|
|
184
|
-
criteria.push(c as { criterion: string; met: boolean; note?: string });
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
// Parse summary AC count like "4/4 SATISFIED"
|
|
190
|
-
if (criteria.length === 0 && summary?.acceptance_criteria && typeof summary.acceptance_criteria === "string") {
|
|
191
|
-
const acMatch = (summary.acceptance_criteria as string).match(/(\d+)\/(\d+)/);
|
|
192
|
-
if (acMatch) {
|
|
193
|
-
const met = Number.parseInt(acMatch[1], 10);
|
|
194
|
-
const total = Number.parseInt(acMatch[2], 10);
|
|
195
|
-
allMet = met === total;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Parse quality
|
|
200
|
-
let rating: "good" | "acceptable" | "poor" = "acceptable";
|
|
201
|
-
const qualityStr = summary?.code_quality
|
|
202
|
-
? String(summary.code_quality).toLowerCase()
|
|
203
|
-
: obj.quality && typeof obj.quality === "object"
|
|
204
|
-
? String((obj.quality as Record<string, unknown>).rating ?? "acceptable").toLowerCase()
|
|
205
|
-
: "acceptable";
|
|
206
|
-
if (qualityStr === "high" || qualityStr === "good") rating = "good";
|
|
207
|
-
else if (qualityStr === "low" || qualityStr === "poor") rating = "poor";
|
|
208
|
-
|
|
209
|
-
// Build coerced verdict
|
|
210
|
-
return {
|
|
211
|
-
version: 1,
|
|
212
|
-
approved,
|
|
213
|
-
tests: { allPassing, passCount, failCount },
|
|
214
|
-
testModifications: {
|
|
215
|
-
detected: false,
|
|
216
|
-
files: [],
|
|
217
|
-
legitimate: true,
|
|
218
|
-
reasoning: "Not assessed in free-form verdict",
|
|
219
|
-
},
|
|
220
|
-
acceptanceCriteria: { allMet, criteria },
|
|
221
|
-
quality: { rating, issues: [] },
|
|
222
|
-
fixes: Array.isArray(obj.fixes) ? (obj.fixes as string[]) : [],
|
|
223
|
-
reasoning:
|
|
224
|
-
typeof obj.reasoning === "string"
|
|
225
|
-
? obj.reasoning
|
|
226
|
-
: typeof obj.overall_status === "string"
|
|
227
|
-
? (obj.overall_status as string)
|
|
228
|
-
: summary?.overall_status
|
|
229
|
-
? String(summary.overall_status)
|
|
230
|
-
: `Coerced from free-form verdict: ${verdictStr}`,
|
|
231
|
-
};
|
|
232
|
-
} catch {
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Read the verifier verdict file from the workdir.
|
|
239
|
-
*
|
|
240
|
-
* Returns the parsed VerifierVerdict when the file exists and is valid.
|
|
241
|
-
* Attempts tolerant coercion if the file doesn't match the strict schema.
|
|
242
|
-
* Returns null if:
|
|
243
|
-
* - File does not exist
|
|
244
|
-
* - File is not valid JSON
|
|
245
|
-
* - Required fields are missing and coercion fails
|
|
246
|
-
*
|
|
247
|
-
* Never throws.
|
|
248
|
-
*/
|
|
249
|
-
export async function readVerdict(workdir: string): Promise<VerifierVerdict | null> {
|
|
250
|
-
const logger = getLogger();
|
|
251
|
-
const verdictPath = path.join(workdir, VERDICT_FILE);
|
|
252
|
-
|
|
253
|
-
try {
|
|
254
|
-
const file = Bun.file(verdictPath);
|
|
255
|
-
const exists = await file.exists();
|
|
256
|
-
if (!exists) {
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Read as text first so we can log raw content on parse failure
|
|
261
|
-
let rawText: string;
|
|
262
|
-
try {
|
|
263
|
-
rawText = await file.text();
|
|
264
|
-
} catch (readErr) {
|
|
265
|
-
logger.warn("tdd", "Failed to read verifier verdict file", {
|
|
266
|
-
path: verdictPath,
|
|
267
|
-
error: String(readErr),
|
|
268
|
-
});
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
let parsed: unknown;
|
|
273
|
-
try {
|
|
274
|
-
parsed = JSON.parse(rawText);
|
|
275
|
-
} catch (parseErr) {
|
|
276
|
-
logger.warn("tdd", "Verifier verdict file is not valid JSON — ignoring", {
|
|
277
|
-
path: verdictPath,
|
|
278
|
-
error: String(parseErr),
|
|
279
|
-
rawContent: rawText.slice(0, 1000),
|
|
280
|
-
});
|
|
281
|
-
return null;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (isValidVerdict(parsed)) {
|
|
285
|
-
return parsed;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Strict validation failed — attempt tolerant coercion
|
|
289
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
290
|
-
const coerced = coerceVerdict(parsed as Record<string, unknown>);
|
|
291
|
-
if (coerced) {
|
|
292
|
-
logger.info("tdd", "Coerced free-form verdict to structured format", {
|
|
293
|
-
path: verdictPath,
|
|
294
|
-
approved: coerced.approved,
|
|
295
|
-
passCount: coerced.tests.passCount,
|
|
296
|
-
failCount: coerced.tests.failCount,
|
|
297
|
-
});
|
|
298
|
-
return coerced;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
logger.warn("tdd", "Verifier verdict file missing required fields and coercion failed — ignoring", {
|
|
303
|
-
path: verdictPath,
|
|
304
|
-
content: JSON.stringify(parsed).slice(0, 500),
|
|
305
|
-
});
|
|
306
|
-
return null;
|
|
307
|
-
} catch (err) {
|
|
308
|
-
logger.warn("tdd", "Failed to read verifier verdict file — ignoring", {
|
|
309
|
-
path: verdictPath,
|
|
310
|
-
error: String(err),
|
|
311
|
-
});
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Delete the verifier verdict file from the workdir.
|
|
318
|
-
* Ignores all errors (file may not exist, permissions, etc.).
|
|
319
|
-
*/
|
|
320
|
-
export async function cleanupVerdict(workdir: string): Promise<void> {
|
|
321
|
-
const verdictPath = path.join(workdir, VERDICT_FILE);
|
|
322
|
-
try {
|
|
323
|
-
await unlink(verdictPath);
|
|
324
|
-
} catch {
|
|
325
|
-
// Intentionally ignored — file may not exist or already be deleted
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
73
|
/** Result of categorizing a verifier verdict */
|
|
330
74
|
export interface VerdictCategorization {
|
|
331
75
|
success: boolean;
|
|
@@ -351,7 +95,6 @@ export interface VerdictCategorization {
|
|
|
351
95
|
* - null verdict, testsPass=false → tests-failing
|
|
352
96
|
*/
|
|
353
97
|
export function categorizeVerdict(verdict: VerifierVerdict | null, testsPass: boolean): VerdictCategorization {
|
|
354
|
-
// No verdict — fall back to test-only check
|
|
355
98
|
if (!verdict) {
|
|
356
99
|
if (testsPass) {
|
|
357
100
|
return { success: true };
|
|
@@ -363,14 +106,10 @@ export function categorizeVerdict(verdict: VerifierVerdict | null, testsPass: bo
|
|
|
363
106
|
};
|
|
364
107
|
}
|
|
365
108
|
|
|
366
|
-
// Approved
|
|
367
109
|
if (verdict.approved) {
|
|
368
110
|
return { success: true };
|
|
369
111
|
}
|
|
370
112
|
|
|
371
|
-
// Not approved — classify the reason
|
|
372
|
-
|
|
373
|
-
// 1. Illegitimate test modifications (implementer cheated)
|
|
374
113
|
if (verdict.testModifications.detected && !verdict.testModifications.legitimate) {
|
|
375
114
|
const files = verdict.testModifications.files.join(", ") || "unknown files";
|
|
376
115
|
return {
|
|
@@ -380,7 +119,6 @@ export function categorizeVerdict(verdict: VerifierVerdict | null, testsPass: bo
|
|
|
380
119
|
};
|
|
381
120
|
}
|
|
382
121
|
|
|
383
|
-
// 2. Tests failing
|
|
384
122
|
if (!verdict.tests.allPassing) {
|
|
385
123
|
return {
|
|
386
124
|
success: false,
|
|
@@ -389,7 +127,6 @@ export function categorizeVerdict(verdict: VerifierVerdict | null, testsPass: bo
|
|
|
389
127
|
};
|
|
390
128
|
}
|
|
391
129
|
|
|
392
|
-
// 3. Acceptance criteria not met
|
|
393
130
|
if (!verdict.acceptanceCriteria.allMet) {
|
|
394
131
|
const unmet = verdict.acceptanceCriteria.criteria.filter((c) => !c.met).map((c) => c.criterion);
|
|
395
132
|
return {
|
|
@@ -399,7 +136,6 @@ export function categorizeVerdict(verdict: VerifierVerdict | null, testsPass: bo
|
|
|
399
136
|
};
|
|
400
137
|
}
|
|
401
138
|
|
|
402
|
-
// 4. Poor quality
|
|
403
139
|
if (verdict.quality.rating === "poor") {
|
|
404
140
|
return {
|
|
405
141
|
success: false,
|
|
@@ -408,7 +144,6 @@ export function categorizeVerdict(verdict: VerifierVerdict | null, testsPass: bo
|
|
|
408
144
|
};
|
|
409
145
|
}
|
|
410
146
|
|
|
411
|
-
// Catch-all: verdict says not approved but no clear specific reason
|
|
412
147
|
return {
|
|
413
148
|
success: false,
|
|
414
149
|
failureCategory: "verifier-rejected",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract error message from unknown error type.
|
|
7
|
+
*
|
|
8
|
+
* Handles both Error instances and non-Error values that can be thrown in JavaScript.
|
|
9
|
+
*/
|
|
10
|
+
export function errorMessage(err: unknown): string {
|
|
11
|
+
return err instanceof Error ? err.message : String(err);
|
|
12
|
+
}
|
package/src/utils/git.ts
CHANGED
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
* Git utility functions
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { spawn } from "bun";
|
|
6
5
|
import { getSafeLogger } from "../logger";
|
|
7
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Injectable dependencies for git subprocess calls — allows tests to intercept
|
|
9
|
+
* Bun.spawn without mock.module().
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export const _gitDeps = { spawn: Bun.spawn };
|
|
14
|
+
|
|
8
15
|
/**
|
|
9
16
|
* Default timeout for git subprocess calls.
|
|
10
17
|
* Prevents git from hanging indefinitely on locked repos or network mounts.
|
|
@@ -20,7 +27,7 @@ const GIT_TIMEOUT_MS = 10_000;
|
|
|
20
27
|
* @internal
|
|
21
28
|
*/
|
|
22
29
|
export async function gitWithTimeout(args: string[], workdir: string): Promise<{ stdout: string; exitCode: number }> {
|
|
23
|
-
const proc =
|
|
30
|
+
const proc = _gitDeps.spawn(["git", ...args], {
|
|
24
31
|
cwd: workdir,
|
|
25
32
|
stdout: "pipe",
|
|
26
33
|
stderr: "pipe",
|
|
@@ -146,7 +153,7 @@ export function detectMergeConflict(output: string): boolean {
|
|
|
146
153
|
export async function autoCommitIfDirty(workdir: string, stage: string, role: string, storyId: string): Promise<void> {
|
|
147
154
|
const logger = getSafeLogger();
|
|
148
155
|
try {
|
|
149
|
-
const statusProc =
|
|
156
|
+
const statusProc = _gitDeps.spawn(["git", "status", "--porcelain"], {
|
|
150
157
|
cwd: workdir,
|
|
151
158
|
stdout: "pipe",
|
|
152
159
|
stderr: "pipe",
|
|
@@ -162,10 +169,10 @@ export async function autoCommitIfDirty(workdir: string, stage: string, role: st
|
|
|
162
169
|
dirtyFiles: statusOutput.trim().split("\n").length,
|
|
163
170
|
});
|
|
164
171
|
|
|
165
|
-
const addProc =
|
|
172
|
+
const addProc = _gitDeps.spawn(["git", "add", "-A"], { cwd: workdir, stdout: "pipe", stderr: "pipe" });
|
|
166
173
|
await addProc.exited;
|
|
167
174
|
|
|
168
|
-
const commitProc =
|
|
175
|
+
const commitProc = _gitDeps.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
|
|
169
176
|
cwd: workdir,
|
|
170
177
|
stdout: "pipe",
|
|
171
178
|
stderr: "pipe",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON File I/O Utility
|
|
3
|
+
*
|
|
4
|
+
* Provides type-safe, error-tolerant helpers for reading and writing JSON files.
|
|
5
|
+
* Encapsulates common patterns: existsSync check, try/catch, logging.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { getLogger } from "../logger";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Load a JSON file with type safety and error handling.
|
|
13
|
+
*
|
|
14
|
+
* Returns null if the file doesn't exist or cannot be parsed.
|
|
15
|
+
* Logs a warning if parsing fails.
|
|
16
|
+
*
|
|
17
|
+
* @param path - File path to load
|
|
18
|
+
* @param context - Logger context (e.g., "config", "hooks", "metrics")
|
|
19
|
+
* @returns Parsed JSON object, or null if file missing or invalid
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const config = await loadJsonFile<NaxConfig>("nax/config.json", "config");
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export async function loadJsonFile<T>(path: string, context = "json-file"): Promise<T | null> {
|
|
27
|
+
if (!existsSync(path)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const content = await Bun.file(path).json();
|
|
33
|
+
return content as T;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const logger = getLogger();
|
|
36
|
+
logger.warn(context, "Failed to parse JSON file", {
|
|
37
|
+
path,
|
|
38
|
+
error: String(err),
|
|
39
|
+
});
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Save an object as JSON to a file.
|
|
46
|
+
*
|
|
47
|
+
* Writes formatted JSON (2-space indent) for readability.
|
|
48
|
+
* Creates parent directories if they don't exist.
|
|
49
|
+
*
|
|
50
|
+
* @param path - File path to write to
|
|
51
|
+
* @param data - Object to serialize
|
|
52
|
+
* @param context - Logger context (for errors)
|
|
53
|
+
* @throws Error if write fails
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* await saveJsonFile("nax/config.json", config, "config");
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export async function saveJsonFile<T>(path: string, data: T, context = "json-file"): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
const json = JSON.stringify(data, null, 2);
|
|
63
|
+
await Bun.write(path, json);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const logger = getLogger();
|
|
66
|
+
logger.error(context, "Failed to write JSON file", {
|
|
67
|
+
path,
|
|
68
|
+
error: String(err),
|
|
69
|
+
});
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Subprocess } from "bun";
|
|
9
|
+
import { errorMessage } from "../utils/errors";
|
|
9
10
|
import type { TestExecutionResult } from "./types";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -41,7 +42,7 @@ async function drainWithDeadline(proc: Subprocess, deadlineMs: number): Promise<
|
|
|
41
42
|
if (!isExpectedStreamError) {
|
|
42
43
|
const { getSafeLogger } = await import("../logger");
|
|
43
44
|
getSafeLogger()?.debug("executor", "Unexpected error draining process output", {
|
|
44
|
-
error:
|
|
45
|
+
error: errorMessage(error),
|
|
45
46
|
});
|
|
46
47
|
}
|
|
47
48
|
}
|
|
@@ -6,6 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { gitWithTimeout } from "../utils/git";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Bun API wrappers — defined before functions to avoid circular type inference.
|
|
11
|
+
* Use closures so tests mocking Bun.Glob / Bun.file on the global namespace
|
|
12
|
+
* continue to work (closures evaluate Bun.* at call time).
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
const _bunDeps = {
|
|
17
|
+
glob: (p: string) => new Bun.Glob(p),
|
|
18
|
+
file: (path: string) => Bun.file(path),
|
|
19
|
+
};
|
|
20
|
+
|
|
9
21
|
/**
|
|
10
22
|
* Get TypeScript source files changed since the previous commit.
|
|
11
23
|
*
|
|
@@ -85,7 +97,7 @@ export async function importGrepFallback(
|
|
|
85
97
|
// Scan all test files matching the configured patterns
|
|
86
98
|
const testFilePaths: string[] = [];
|
|
87
99
|
for (const pattern of testFilePatterns) {
|
|
88
|
-
const glob =
|
|
100
|
+
const glob = _bunDeps.glob(pattern);
|
|
89
101
|
for await (const file of glob.scan(workdir)) {
|
|
90
102
|
testFilePaths.push(`${workdir}/${file}`);
|
|
91
103
|
}
|
|
@@ -96,7 +108,7 @@ export async function importGrepFallback(
|
|
|
96
108
|
for (const testFile of testFilePaths) {
|
|
97
109
|
let content: string;
|
|
98
110
|
try {
|
|
99
|
-
content = await
|
|
111
|
+
content = await _bunDeps.file(testFile).text();
|
|
100
112
|
} catch {
|
|
101
113
|
continue;
|
|
102
114
|
}
|
|
@@ -121,7 +133,7 @@ export async function mapSourceToTests(sourceFiles: string[], workdir: string):
|
|
|
121
133
|
const candidates = [`${workdir}/test/unit/${relative}`, `${workdir}/test/integration/${relative}`];
|
|
122
134
|
|
|
123
135
|
for (const candidate of candidates) {
|
|
124
|
-
if (await
|
|
136
|
+
if (await _bunDeps.file(candidate).exists()) {
|
|
125
137
|
result.push(candidate);
|
|
126
138
|
}
|
|
127
139
|
}
|
|
@@ -254,9 +266,17 @@ export function reverseMapTestToSource(testFiles: string[], workdir: string): st
|
|
|
254
266
|
* Allows tests to swap implementations without using mock.module(),
|
|
255
267
|
* which leaks across files in Bun 1.x due to shared module registry.
|
|
256
268
|
*
|
|
269
|
+
* Bun API wrappers use closures so that tests mocking Bun.Glob / Bun.file
|
|
270
|
+
* on the global namespace continue to work (closures evaluate Bun.* at
|
|
271
|
+
* call time, not at module initialisation time).
|
|
272
|
+
*
|
|
257
273
|
* @internal - test use only. Do not use in production code.
|
|
258
274
|
*/
|
|
259
275
|
export const _smartRunnerDeps = {
|
|
276
|
+
/** Wraps Bun.Glob construction — injectable for testing. */
|
|
277
|
+
glob: _bunDeps.glob,
|
|
278
|
+
/** Wraps Bun.file — injectable for testing. */
|
|
279
|
+
file: _bunDeps.file,
|
|
260
280
|
getChangedSourceFiles,
|
|
261
281
|
mapSourceToTests,
|
|
262
282
|
importGrepFallback,
|
package/src/worktree/manager.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, symlinkSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getSafeLogger } from "../logger";
|
|
4
|
+
import { validateStoryId } from "../prd/validate";
|
|
5
|
+
import { errorMessage } from "../utils/errors";
|
|
4
6
|
import type { WorktreeInfo } from "./types";
|
|
5
7
|
|
|
6
8
|
export class WorktreeManager {
|
|
@@ -9,6 +11,8 @@ export class WorktreeManager {
|
|
|
9
11
|
* and symlinks node_modules and .env from project root
|
|
10
12
|
*/
|
|
11
13
|
async create(projectRoot: string, storyId: string): Promise<void> {
|
|
14
|
+
validateStoryId(storyId);
|
|
15
|
+
|
|
12
16
|
const worktreePath = join(projectRoot, ".nax-wt", storyId);
|
|
13
17
|
const branchName = `nax/${storyId}`;
|
|
14
18
|
|
|
@@ -48,7 +52,7 @@ export class WorktreeManager {
|
|
|
48
52
|
} catch (error) {
|
|
49
53
|
// Clean up worktree if symlinking fails
|
|
50
54
|
await this.remove(projectRoot, storyId);
|
|
51
|
-
throw new Error(`Failed to symlink node_modules: ${
|
|
55
|
+
throw new Error(`Failed to symlink node_modules: ${errorMessage(error)}`);
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
|
|
@@ -61,7 +65,7 @@ export class WorktreeManager {
|
|
|
61
65
|
} catch (error) {
|
|
62
66
|
// Clean up worktree if symlinking fails
|
|
63
67
|
await this.remove(projectRoot, storyId);
|
|
64
|
-
throw new Error(`Failed to symlink .env: ${
|
|
68
|
+
throw new Error(`Failed to symlink .env: ${errorMessage(error)}`);
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
}
|
|
@@ -70,6 +74,8 @@ export class WorktreeManager {
|
|
|
70
74
|
* Removes worktree and deletes branch
|
|
71
75
|
*/
|
|
72
76
|
async remove(projectRoot: string, storyId: string): Promise<void> {
|
|
77
|
+
validateStoryId(storyId);
|
|
78
|
+
|
|
73
79
|
const worktreePath = join(projectRoot, ".nax-wt", storyId);
|
|
74
80
|
const branchName = `nax/${storyId}`;
|
|
75
81
|
|
|
@@ -122,7 +128,7 @@ export class WorktreeManager {
|
|
|
122
128
|
// Log warning but don't fail - worktree is already removed
|
|
123
129
|
const logger = getSafeLogger();
|
|
124
130
|
logger?.warn("worktree", `Failed to delete branch ${branchName}`, {
|
|
125
|
-
error:
|
|
131
|
+
error: errorMessage(error),
|
|
126
132
|
});
|
|
127
133
|
}
|
|
128
134
|
}
|
package/src/worktree/merge.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSafeLogger } from "../logger";
|
|
2
|
+
import { errorMessage } from "../utils/errors";
|
|
2
3
|
import type { WorktreeManager } from "./manager";
|
|
3
4
|
|
|
4
5
|
export interface MergeResult {
|
|
@@ -44,7 +45,7 @@ export class MergeEngine {
|
|
|
44
45
|
// Log warning but don't fail the merge
|
|
45
46
|
const logger = getSafeLogger();
|
|
46
47
|
logger?.warn("worktree", `Failed to cleanup worktree for ${storyId}`, {
|
|
47
|
-
error:
|
|
48
|
+
error: errorMessage(error),
|
|
48
49
|
});
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -294,7 +295,7 @@ export class MergeEngine {
|
|
|
294
295
|
// Log warning but don't throw - merge might already be aborted
|
|
295
296
|
const logger = getSafeLogger();
|
|
296
297
|
logger?.warn("worktree", "Failed to abort merge", {
|
|
297
|
-
error:
|
|
298
|
+
error: errorMessage(error),
|
|
298
299
|
});
|
|
299
300
|
}
|
|
300
301
|
}
|