@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.
Files changed (72) hide show
  1. package/dist/nax.js +3258 -2894
  2. package/package.json +4 -1
  3. package/src/agents/claude-complete.ts +72 -0
  4. package/src/agents/claude-execution.ts +189 -0
  5. package/src/agents/claude-interactive.ts +77 -0
  6. package/src/agents/claude-plan.ts +23 -8
  7. package/src/agents/claude.ts +64 -349
  8. package/src/analyze/classifier.ts +2 -1
  9. package/src/cli/config-descriptions.ts +206 -0
  10. package/src/cli/config-diff.ts +103 -0
  11. package/src/cli/config-display.ts +285 -0
  12. package/src/cli/config-get.ts +55 -0
  13. package/src/cli/config.ts +7 -618
  14. package/src/cli/prompts-export.ts +58 -0
  15. package/src/cli/prompts-init.ts +200 -0
  16. package/src/cli/prompts-main.ts +237 -0
  17. package/src/cli/prompts-tdd.ts +78 -0
  18. package/src/cli/prompts.ts +10 -541
  19. package/src/commands/logs-formatter.ts +201 -0
  20. package/src/commands/logs-reader.ts +171 -0
  21. package/src/commands/logs.ts +11 -362
  22. package/src/config/loader.ts +4 -15
  23. package/src/config/runtime-types.ts +448 -0
  24. package/src/config/schema-types.ts +53 -0
  25. package/src/config/types.ts +49 -486
  26. package/src/context/auto-detect.ts +2 -1
  27. package/src/context/builder.ts +3 -2
  28. package/src/execution/crash-heartbeat.ts +77 -0
  29. package/src/execution/crash-recovery.ts +23 -365
  30. package/src/execution/crash-signals.ts +149 -0
  31. package/src/execution/crash-writer.ts +154 -0
  32. package/src/execution/parallel-coordinator.ts +278 -0
  33. package/src/execution/parallel-executor-rectification-pass.ts +117 -0
  34. package/src/execution/parallel-executor-rectify.ts +135 -0
  35. package/src/execution/parallel-executor.ts +19 -211
  36. package/src/execution/parallel-worker.ts +148 -0
  37. package/src/execution/parallel.ts +5 -404
  38. package/src/execution/pid-registry.ts +3 -8
  39. package/src/execution/runner-completion.ts +160 -0
  40. package/src/execution/runner-execution.ts +221 -0
  41. package/src/execution/runner-setup.ts +82 -0
  42. package/src/execution/runner.ts +53 -202
  43. package/src/execution/timeout-handler.ts +100 -0
  44. package/src/hooks/runner.ts +11 -21
  45. package/src/metrics/tracker.ts +7 -30
  46. package/src/pipeline/runner.ts +2 -1
  47. package/src/pipeline/stages/completion.ts +0 -1
  48. package/src/pipeline/stages/context.ts +2 -1
  49. package/src/plugins/extensions.ts +225 -0
  50. package/src/plugins/loader.ts +2 -1
  51. package/src/plugins/types.ts +16 -221
  52. package/src/prd/index.ts +2 -1
  53. package/src/prd/validate.ts +41 -0
  54. package/src/precheck/checks-blockers.ts +15 -419
  55. package/src/precheck/checks-cli.ts +68 -0
  56. package/src/precheck/checks-config.ts +102 -0
  57. package/src/precheck/checks-git.ts +87 -0
  58. package/src/precheck/checks-system.ts +163 -0
  59. package/src/review/orchestrator.ts +19 -6
  60. package/src/review/runner.ts +17 -5
  61. package/src/routing/chain.ts +2 -1
  62. package/src/routing/loader.ts +2 -5
  63. package/src/tdd/orchestrator.ts +2 -1
  64. package/src/tdd/verdict-reader.ts +266 -0
  65. package/src/tdd/verdict.ts +6 -271
  66. package/src/utils/errors.ts +12 -0
  67. package/src/utils/git.ts +12 -5
  68. package/src/utils/json-file.ts +72 -0
  69. package/src/verification/executor.ts +2 -1
  70. package/src/verification/smart-runner.ts +23 -3
  71. package/src/worktree/manager.ts +9 -3
  72. package/src/worktree/merge.ts +3 -2
@@ -1,18 +1,17 @@
1
1
  /**
2
- * Verifier Verdict — types and reader
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 interprets that verdict.
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
- /** File name written by the verifier agent */
15
- export const VERDICT_FILE = ".nax-verifier-verdict.json";
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 = Bun.spawn(["git", ...args], {
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 = Bun.spawn(["git", "status", "--porcelain"], {
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 = Bun.spawn(["git", "add", "-A"], { cwd: workdir, stdout: "pipe", stderr: "pipe" });
172
+ const addProc = _gitDeps.spawn(["git", "add", "-A"], { cwd: workdir, stdout: "pipe", stderr: "pipe" });
166
173
  await addProc.exited;
167
174
 
168
- const commitProc = Bun.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
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: error instanceof Error ? error.message : String(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 = new Bun.Glob(pattern);
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 Bun.file(testFile).text();
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 Bun.file(candidate).exists()) {
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,
@@ -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: ${error instanceof Error ? error.message : String(error)}`);
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: ${error instanceof Error ? error.message : String(error)}`);
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: error instanceof Error ? error.message : String(error),
131
+ error: errorMessage(error),
126
132
  });
127
133
  }
128
134
  }
@@ -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: error instanceof Error ? error.message : String(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: error instanceof Error ? error.message : String(error),
298
+ error: errorMessage(error),
298
299
  });
299
300
  }
300
301
  }