@nathapp/nax 0.18.1 → 0.18.3

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 (50) hide show
  1. package/.gitlab-ci.yml +12 -6
  2. package/bun.lock +1 -1
  3. package/bunfig.toml +2 -1
  4. package/docker-compose.test.yml +17 -0
  5. package/docs/ROADMAP.md +121 -36
  6. package/docs/specs/verification-architecture-v2.md +343 -0
  7. package/nax/config.json +13 -10
  8. package/nax/features/smart-test-runner/plan.md +7 -0
  9. package/nax/features/smart-test-runner/prd.json +203 -0
  10. package/nax/features/smart-test-runner/progress.txt +13 -0
  11. package/nax/features/smart-test-runner/spec.md +7 -0
  12. package/nax/features/smart-test-runner/tasks.md +8 -0
  13. package/nax/features/v0.18.3-execution-reliability/prd.json +80 -0
  14. package/nax/features/v0.18.3-execution-reliability/progress.txt +3 -0
  15. package/package.json +2 -2
  16. package/src/config/defaults.ts +2 -0
  17. package/src/config/schema.ts +1 -0
  18. package/src/config/schemas.ts +24 -0
  19. package/src/config/types.ts +16 -1
  20. package/src/context/builder.ts +11 -0
  21. package/src/context/elements.ts +38 -1
  22. package/src/execution/escalation/tier-escalation.ts +28 -3
  23. package/src/execution/post-verify-rectification.ts +4 -2
  24. package/src/execution/post-verify.ts +73 -9
  25. package/src/execution/progress.ts +2 -0
  26. package/src/pipeline/stages/review.ts +5 -3
  27. package/src/pipeline/stages/routing.ts +14 -9
  28. package/src/pipeline/stages/verify.ts +54 -1
  29. package/src/prd/index.ts +16 -1
  30. package/src/prd/types.ts +33 -0
  31. package/src/precheck/index.ts +9 -4
  32. package/src/routing/strategies/llm.ts +5 -0
  33. package/src/verification/gate.ts +2 -1
  34. package/src/verification/smart-runner.ts +214 -0
  35. package/src/verification/types.ts +2 -0
  36. package/test/US-002-orchestrator.test.ts +5 -5
  37. package/test/context/prior-failures.test.ts +462 -0
  38. package/test/execution/post-verify-bug026.test.ts +443 -0
  39. package/test/execution/post-verify.test.ts +32 -0
  40. package/test/execution/structured-failure.test.ts +414 -0
  41. package/test/integration/logger.test.ts +1 -1
  42. package/test/integration/review-plugin-integration.test.ts +2 -1
  43. package/test/integration/story-id-in-events.test.ts +1 -1
  44. package/test/unit/config/smart-runner-flag.test.ts +249 -0
  45. package/test/unit/pipeline/routing-partial-override.test.ts +141 -0
  46. package/test/unit/pipeline/verify-smart-runner.test.ts +344 -0
  47. package/test/unit/prd-get-next-story.test.ts +28 -0
  48. package/test/unit/routing.test.ts +102 -0
  49. package/test/unit/smart-test-runner.test.ts +512 -0
  50. package/test/unit/verification/smart-runner.test.ts +246 -0
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Unit tests for StructuredFailure and priorFailures tracking
3
+ *
4
+ * Tests the structured failure context for escalated tiers to know exactly what failed.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { loadPRD } from "../../src/prd";
9
+ import type { StructuredFailure, TestFailureContext, UserStory } from "../../src/prd";
10
+
11
+ describe("StructuredFailure Type", () => {
12
+ test("should have all required fields", () => {
13
+ const failure: StructuredFailure = {
14
+ attempt: 1,
15
+ modelTier: "balanced",
16
+ stage: "verify",
17
+ summary: "Test failed",
18
+ timestamp: new Date().toISOString(),
19
+ };
20
+
21
+ expect(failure.attempt).toBe(1);
22
+ expect(failure.modelTier).toBe("balanced");
23
+ expect(failure.stage).toBe("verify");
24
+ expect(failure.summary).toBe("Test failed");
25
+ expect(failure.timestamp).toBeDefined();
26
+ expect(typeof failure.timestamp).toBe("string");
27
+ });
28
+
29
+ test("should have optional testFailures field", () => {
30
+ const testFailure: TestFailureContext = {
31
+ file: "test/foo.test.ts",
32
+ testName: "should pass",
33
+ error: "Expected 1 to equal 2",
34
+ stackTrace: ["at foo.ts:10:15", "at Object.test (foo.ts:8:3)"],
35
+ };
36
+
37
+ const failure: StructuredFailure = {
38
+ attempt: 1,
39
+ modelTier: "balanced",
40
+ stage: "verify",
41
+ summary: "Test failed",
42
+ testFailures: [testFailure],
43
+ timestamp: new Date().toISOString(),
44
+ };
45
+
46
+ expect(failure.testFailures).toBeDefined();
47
+ expect(failure.testFailures?.length).toBe(1);
48
+ expect(failure.testFailures?.[0].file).toBe("test/foo.test.ts");
49
+ expect(failure.testFailures?.[0].testName).toBe("should pass");
50
+ expect(failure.testFailures?.[0].error).toBe("Expected 1 to equal 2");
51
+ expect(failure.testFailures?.[0].stackTrace.length).toBe(2);
52
+ });
53
+
54
+ test("should support all verification stages", () => {
55
+ const stages: Array<StructuredFailure["stage"]> = [
56
+ "verify",
57
+ "review",
58
+ "regression",
59
+ "rectification",
60
+ "agent-session",
61
+ "escalation",
62
+ ];
63
+
64
+ for (const stage of stages) {
65
+ const failure: StructuredFailure = {
66
+ attempt: 1,
67
+ modelTier: "balanced",
68
+ stage,
69
+ summary: "Test failed",
70
+ timestamp: new Date().toISOString(),
71
+ };
72
+ expect(failure.stage).toBe(stage);
73
+ }
74
+ });
75
+
76
+ test("should allow different model tiers", () => {
77
+ const tiers = ["fast", "balanced", "powerful"];
78
+
79
+ for (const tier of tiers) {
80
+ const failure: StructuredFailure = {
81
+ attempt: 1,
82
+ modelTier: tier,
83
+ stage: "verify",
84
+ summary: "Test failed",
85
+ timestamp: new Date().toISOString(),
86
+ };
87
+ expect(failure.modelTier).toBe(tier);
88
+ }
89
+ });
90
+
91
+ test("should track multiple test failures", () => {
92
+ const testFailures: TestFailureContext[] = [
93
+ {
94
+ file: "test/foo.test.ts",
95
+ testName: "test 1",
96
+ error: "Error 1",
97
+ stackTrace: ["at foo.ts:10"],
98
+ },
99
+ {
100
+ file: "test/bar.test.ts",
101
+ testName: "test 2",
102
+ error: "Error 2",
103
+ stackTrace: ["at bar.ts:20"],
104
+ },
105
+ {
106
+ file: "test/baz.test.ts",
107
+ testName: "test 3",
108
+ error: "Error 3",
109
+ stackTrace: ["at baz.ts:30"],
110
+ },
111
+ ];
112
+
113
+ const failure: StructuredFailure = {
114
+ attempt: 2,
115
+ modelTier: "balanced",
116
+ stage: "regression",
117
+ summary: "Multiple test failures",
118
+ testFailures,
119
+ timestamp: new Date().toISOString(),
120
+ };
121
+
122
+ expect(failure.testFailures?.length).toBe(3);
123
+ expect(failure.testFailures?.[0].file).toBe("test/foo.test.ts");
124
+ expect(failure.testFailures?.[1].file).toBe("test/bar.test.ts");
125
+ expect(failure.testFailures?.[2].file).toBe("test/baz.test.ts");
126
+ });
127
+ });
128
+
129
+ describe("TestFailureContext Type", () => {
130
+ test("should have all required fields", () => {
131
+ const context: TestFailureContext = {
132
+ file: "test/example.test.ts",
133
+ testName: "should do something",
134
+ error: "AssertionError: expected true to be false",
135
+ stackTrace: ["at Object.test (example.test.ts:42:10)", "at async runTest (test.ts:100:5)"],
136
+ };
137
+
138
+ expect(context.file).toBe("test/example.test.ts");
139
+ expect(context.testName).toBe("should do something");
140
+ expect(context.error).toBe("AssertionError: expected true to be false");
141
+ expect(context.stackTrace.length).toBe(2);
142
+ });
143
+
144
+ test("should handle nested test names", () => {
145
+ const context: TestFailureContext = {
146
+ file: "test/example.test.ts",
147
+ testName: "describe block > nested block > test name",
148
+ error: "Error",
149
+ stackTrace: [],
150
+ };
151
+
152
+ expect(context.testName).toContain("describe block");
153
+ expect(context.testName).toContain("nested block");
154
+ expect(context.testName).toContain("test name");
155
+ });
156
+
157
+ test("should support empty stack traces", () => {
158
+ const context: TestFailureContext = {
159
+ file: "test/example.test.ts",
160
+ testName: "test",
161
+ error: "Error",
162
+ stackTrace: [],
163
+ };
164
+
165
+ expect(context.stackTrace.length).toBe(0);
166
+ });
167
+
168
+ test("should support multiple stack trace lines", () => {
169
+ const context: TestFailureContext = {
170
+ file: "test/example.test.ts",
171
+ testName: "test",
172
+ error: "Error",
173
+ stackTrace: [
174
+ "at foo.ts:10:15",
175
+ "at bar.ts:20:10",
176
+ "at baz.ts:30:5",
177
+ "at Object.test (example.ts:40:3)",
178
+ "at async runTest (test.ts:50:5)",
179
+ ],
180
+ };
181
+
182
+ expect(context.stackTrace.length).toBe(5);
183
+ });
184
+ });
185
+
186
+ describe("UserStory priorFailures Field", () => {
187
+ test("should have optional priorFailures field", () => {
188
+ const story: UserStory = {
189
+ id: "US-001",
190
+ title: "Test Story",
191
+ description: "A test story",
192
+ acceptanceCriteria: [],
193
+ tags: [],
194
+ dependencies: [],
195
+ status: "pending",
196
+ passes: false,
197
+ escalations: [],
198
+ attempts: 0,
199
+ };
200
+
201
+ expect(story.priorFailures).toBeUndefined();
202
+ });
203
+
204
+ test("should initialize priorFailures to empty array in loadPRD", async () => {
205
+ // Create a temporary PRD file without priorFailures
206
+ const prdContent = JSON.stringify({
207
+ project: "test",
208
+ feature: "test-feature",
209
+ branchName: "test-branch",
210
+ createdAt: new Date().toISOString(),
211
+ updatedAt: new Date().toISOString(),
212
+ userStories: [
213
+ {
214
+ id: "US-001",
215
+ title: "Test Story",
216
+ description: "Description",
217
+ acceptanceCriteria: [],
218
+ tags: [],
219
+ dependencies: [],
220
+ status: "pending",
221
+ passes: false,
222
+ escalations: [],
223
+ attempts: 0,
224
+ },
225
+ ],
226
+ });
227
+
228
+ // Write to temp file
229
+ const tmpFile = "/tmp/test-prd-priorFailures.json";
230
+ await Bun.write(tmpFile, prdContent);
231
+
232
+ // Load and verify
233
+ const prd = await loadPRD(tmpFile);
234
+ expect(prd.userStories[0].priorFailures).toBeDefined();
235
+ expect(Array.isArray(prd.userStories[0].priorFailures)).toBe(true);
236
+ expect(prd.userStories[0].priorFailures?.length).toBe(0);
237
+
238
+ // Cleanup
239
+ await Bun.file(tmpFile).delete();
240
+ });
241
+
242
+ test("should preserve existing priorFailures when loading PRD", async () => {
243
+ const existingFailure: StructuredFailure = {
244
+ attempt: 1,
245
+ modelTier: "balanced",
246
+ stage: "verify",
247
+ summary: "Test failed",
248
+ timestamp: new Date().toISOString(),
249
+ };
250
+
251
+ const prdContent = JSON.stringify({
252
+ project: "test",
253
+ feature: "test-feature",
254
+ branchName: "test-branch",
255
+ createdAt: new Date().toISOString(),
256
+ updatedAt: new Date().toISOString(),
257
+ userStories: [
258
+ {
259
+ id: "US-001",
260
+ title: "Test Story",
261
+ description: "Description",
262
+ acceptanceCriteria: [],
263
+ tags: [],
264
+ dependencies: [],
265
+ status: "pending",
266
+ passes: false,
267
+ escalations: [],
268
+ attempts: 1,
269
+ priorFailures: [existingFailure],
270
+ },
271
+ ],
272
+ });
273
+
274
+ const tmpFile = "/tmp/test-prd-existing-failures.json";
275
+ await Bun.write(tmpFile, prdContent);
276
+
277
+ const prd = await loadPRD(tmpFile);
278
+ expect(prd.userStories[0].priorFailures?.length).toBe(1);
279
+ expect(prd.userStories[0].priorFailures?.[0].attempt).toBe(1);
280
+ expect(prd.userStories[0].priorFailures?.[0].stage).toBe("verify");
281
+
282
+ await Bun.file(tmpFile).delete();
283
+ });
284
+
285
+ test("should allow adding multiple priorFailures to a story", () => {
286
+ const story: UserStory = {
287
+ id: "US-001",
288
+ title: "Test Story",
289
+ description: "Description",
290
+ acceptanceCriteria: [],
291
+ tags: [],
292
+ dependencies: [],
293
+ status: "failed",
294
+ passes: false,
295
+ escalations: [],
296
+ attempts: 3,
297
+ priorFailures: [
298
+ {
299
+ attempt: 1,
300
+ modelTier: "fast",
301
+ stage: "verify",
302
+ summary: "First failure",
303
+ timestamp: new Date().toISOString(),
304
+ },
305
+ {
306
+ attempt: 2,
307
+ modelTier: "balanced",
308
+ stage: "regression",
309
+ summary: "Second failure",
310
+ timestamp: new Date().toISOString(),
311
+ },
312
+ ],
313
+ };
314
+
315
+ expect(story.priorFailures?.length).toBe(2);
316
+ expect(story.priorFailures?.[0].modelTier).toBe("fast");
317
+ expect(story.priorFailures?.[1].modelTier).toBe("balanced");
318
+ });
319
+ });
320
+
321
+ describe("StructuredFailure Attempt Tracking", () => {
322
+ test("should increment attempt number correctly", () => {
323
+ const failures: StructuredFailure[] = [];
324
+
325
+ for (let i = 1; i <= 3; i++) {
326
+ failures.push({
327
+ attempt: i,
328
+ modelTier: "balanced",
329
+ stage: "verify",
330
+ summary: `Attempt ${i} failed`,
331
+ timestamp: new Date().toISOString(),
332
+ });
333
+ }
334
+
335
+ expect(failures[0].attempt).toBe(1);
336
+ expect(failures[1].attempt).toBe(2);
337
+ expect(failures[2].attempt).toBe(3);
338
+ });
339
+
340
+ test("should track tier escalation in priorFailures", () => {
341
+ const failures: StructuredFailure[] = [
342
+ {
343
+ attempt: 1,
344
+ modelTier: "fast",
345
+ stage: "verify",
346
+ summary: "Failed on fast tier",
347
+ timestamp: new Date().toISOString(),
348
+ },
349
+ {
350
+ attempt: 2,
351
+ modelTier: "balanced",
352
+ stage: "escalation",
353
+ summary: "Escalated to balanced tier",
354
+ timestamp: new Date().toISOString(),
355
+ },
356
+ {
357
+ attempt: 3,
358
+ modelTier: "powerful",
359
+ stage: "escalation",
360
+ summary: "Escalated to powerful tier",
361
+ timestamp: new Date().toISOString(),
362
+ },
363
+ ];
364
+
365
+ expect(failures.length).toBe(3);
366
+ expect(failures[0].modelTier).toBe("fast");
367
+ expect(failures[1].modelTier).toBe("balanced");
368
+ expect(failures[2].modelTier).toBe("powerful");
369
+
370
+ // Verify escalation stages
371
+ expect(failures[1].stage).toBe("escalation");
372
+ expect(failures[2].stage).toBe("escalation");
373
+ });
374
+ });
375
+
376
+ describe("StructuredFailure Timestamps", () => {
377
+ test("should use ISO format timestamps", () => {
378
+ const failure: StructuredFailure = {
379
+ attempt: 1,
380
+ modelTier: "balanced",
381
+ stage: "verify",
382
+ summary: "Test failed",
383
+ timestamp: new Date().toISOString(),
384
+ };
385
+
386
+ // ISO format: YYYY-MM-DDTHH:mm:ss.sssZ
387
+ expect(failure.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
388
+ });
389
+
390
+ test("should have different timestamps for different failures", async () => {
391
+ const failure1: StructuredFailure = {
392
+ attempt: 1,
393
+ modelTier: "balanced",
394
+ stage: "verify",
395
+ summary: "First failure",
396
+ timestamp: new Date().toISOString(),
397
+ };
398
+
399
+ // Small delay to ensure different timestamp
400
+ await new Promise((resolve) => setTimeout(resolve, 10));
401
+
402
+ const failure2: StructuredFailure = {
403
+ attempt: 2,
404
+ modelTier: "balanced",
405
+ stage: "verify",
406
+ summary: "Second failure",
407
+ timestamp: new Date().toISOString(),
408
+ };
409
+
410
+ // While millisecond precision should make them different, we just verify they're valid
411
+ expect(failure1.timestamp).toBeDefined();
412
+ expect(failure2.timestamp).toBeDefined();
413
+ });
414
+ });
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, readFileSync, rmSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { Logger, getLogger, initLogger, resetLogger } from "../../src/logger/logger.js";
4
+ import { Logger, getLogger, initLogger, resetLogger } from "../../src/logger";
5
5
 
6
6
  const TEST_LOG_DIR = path.join(process.cwd(), "test-logs");
7
7
  const TEST_LOG_FILE = path.join(TEST_LOG_DIR, "test.jsonl");
@@ -124,7 +124,8 @@ describe("Review Stage - Plugin Integration", () => {
124
124
  const result = await reviewStage.execute(ctx);
125
125
 
126
126
  expect(pluginCalled).toBe(false);
127
- expect(result.action).toBe("fail");
127
+ // BUG-030: Review lint/typecheck failures now escalate instead of hard-failing
128
+ expect(result.action).toBe("escalate");
128
129
  expect(result.reason).toContain("Review failed");
129
130
  });
130
131
 
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import type { NaxConfig } from "../../src/config/schema";
13
13
  import { initLogger, resetLogger } from "../../src/logger";
14
- import { getLogger } from "../../src/logger/logger";
14
+ import { getLogger } from "../../src/logger";
15
15
  import type { LogEntry } from "../../src/logger/types";
16
16
  import { executionStage } from "../../src/pipeline/stages/execution";
17
17
  import { verifyStage } from "../../src/pipeline/stages/verify";
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Smart Test Runner Config Flag Tests (STR-004)
3
+ *
4
+ * Verifies that execution.smartTestRunner is present in the ExecutionConfig
5
+ * interface, defaults to true in the Zod schema, and loads correctly.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { globalConfigPath, loadConfig } from "../../../src/config/loader";
13
+ import { DEFAULT_CONFIG } from "../../../src/config/defaults";
14
+ import { NaxConfigSchema } from "../../../src/config/schemas";
15
+
16
+ describe("execution.smartTestRunner config flag", () => {
17
+ let tempDir: string;
18
+ let globalBackup: string | null = null;
19
+
20
+ beforeEach(() => {
21
+ tempDir = join(tmpdir(), `nax-str-004-${Date.now()}`);
22
+ mkdirSync(join(tempDir, "nax"), { recursive: true });
23
+
24
+ const globalPath = globalConfigPath();
25
+ if (existsSync(globalPath)) {
26
+ globalBackup = `${globalPath}.test-backup-${Date.now()}`;
27
+ renameSync(globalPath, globalBackup);
28
+ }
29
+ });
30
+
31
+ afterEach(() => {
32
+ if (tempDir) {
33
+ rmSync(tempDir, { recursive: true, force: true });
34
+ }
35
+ if (globalBackup && existsSync(globalBackup)) {
36
+ const globalPath = globalConfigPath();
37
+ if (existsSync(globalPath)) rmSync(globalPath);
38
+ renameSync(globalBackup, globalPath);
39
+ globalBackup = null;
40
+ }
41
+ });
42
+
43
+ test("DEFAULT_CONFIG has smartTestRunner: true", () => {
44
+ expect(DEFAULT_CONFIG.execution.smartTestRunner).toBe(true);
45
+ });
46
+
47
+ test("Zod schema defaults smartTestRunner to enabled object when field is absent", () => {
48
+ const minimal = {
49
+ version: 1,
50
+ models: {
51
+ fast: { provider: "anthropic", model: "haiku" },
52
+ balanced: { provider: "anthropic", model: "sonnet" },
53
+ powerful: { provider: "anthropic", model: "opus" },
54
+ },
55
+ autoMode: {
56
+ enabled: true,
57
+ defaultAgent: "claude",
58
+ fallbackOrder: [],
59
+ complexityRouting: { simple: "fast", medium: "balanced", complex: "powerful", expert: "powerful" },
60
+ escalation: { enabled: true, tierOrder: [{ tier: "fast", attempts: 1 }] },
61
+ },
62
+ routing: { strategy: "keyword" },
63
+ execution: {
64
+ maxIterations: 10,
65
+ iterationDelayMs: 0,
66
+ costLimit: 1,
67
+ sessionTimeoutSeconds: 60,
68
+ maxStoriesPerFeature: 10,
69
+ rectification: {
70
+ enabled: true,
71
+ maxRetries: 1,
72
+ fullSuiteTimeoutSeconds: 30,
73
+ maxFailureSummaryChars: 500,
74
+ abortOnIncreasingFailures: true,
75
+ },
76
+ regressionGate: { enabled: true, timeoutSeconds: 30 },
77
+ contextProviderTokenBudget: 100,
78
+ // smartTestRunner intentionally omitted
79
+ },
80
+ quality: {
81
+ requireTypecheck: true,
82
+ requireLint: true,
83
+ requireTests: true,
84
+ commands: {},
85
+ forceExit: false,
86
+ detectOpenHandles: true,
87
+ detectOpenHandlesRetries: 1,
88
+ gracePeriodMs: 500,
89
+ drainTimeoutMs: 0,
90
+ shell: "/bin/sh",
91
+ stripEnvVars: [],
92
+ environmentalEscalationDivisor: 2,
93
+ },
94
+ tdd: { maxRetries: 0, autoVerifyIsolation: false, autoApproveVerifier: false },
95
+ constitution: { enabled: false, path: "constitution.md", maxTokens: 100 },
96
+ analyze: { llmEnhanced: false, model: "balanced", fallbackToKeywords: true, maxCodebaseSummaryTokens: 100 },
97
+ review: { enabled: false, checks: [], commands: {} },
98
+ plan: { model: "balanced", outputPath: "spec.md" },
99
+ acceptance: { enabled: false, maxRetries: 0, generateTests: false, testPath: "acceptance.test.ts" },
100
+ context: {
101
+ testCoverage: { enabled: false, detail: "names-only", maxTokens: 50, testPattern: "**/*.test.ts", scopeToStory: false },
102
+ autoDetect: { enabled: false, maxFiles: 1, traceImports: false },
103
+ },
104
+ };
105
+
106
+ const result = NaxConfigSchema.safeParse(minimal);
107
+ expect(result.success).toBe(true);
108
+ if (result.success) {
109
+ expect(result.data.execution.smartTestRunner).toEqual({
110
+ enabled: true,
111
+ testFilePatterns: ["test/**/*.test.ts"],
112
+ fallback: "import-grep",
113
+ });
114
+ }
115
+ });
116
+
117
+ test("Zod schema coerces smartTestRunner: false to disabled config object", () => {
118
+ const result = NaxConfigSchema.safeParse({
119
+ ...buildMinimalConfig(),
120
+ execution: { ...buildMinimalConfig().execution, smartTestRunner: false },
121
+ });
122
+ expect(result.success).toBe(true);
123
+ if (result.success) {
124
+ expect(result.data.execution.smartTestRunner).toEqual({
125
+ enabled: false,
126
+ testFilePatterns: ["test/**/*.test.ts"],
127
+ fallback: "import-grep",
128
+ });
129
+ }
130
+ });
131
+
132
+ test("Zod schema coerces smartTestRunner: true to enabled config object", () => {
133
+ const result = NaxConfigSchema.safeParse({
134
+ ...buildMinimalConfig(),
135
+ execution: { ...buildMinimalConfig().execution, smartTestRunner: true },
136
+ });
137
+ expect(result.success).toBe(true);
138
+ if (result.success) {
139
+ expect(result.data.execution.smartTestRunner).toEqual({
140
+ enabled: true,
141
+ testFilePatterns: ["test/**/*.test.ts"],
142
+ fallback: "import-grep",
143
+ });
144
+ }
145
+ });
146
+
147
+ test("loadConfig defaults smartTestRunner to enabled object when not in project config", async () => {
148
+ const configPath = join(tempDir, "nax", "config.json");
149
+ writeFileSync(configPath, JSON.stringify({ routing: { strategy: "keyword" } }, null, 2));
150
+
151
+ const config = await loadConfig(join(tempDir, "nax"));
152
+ expect(config.execution.smartTestRunner).toEqual({
153
+ enabled: true,
154
+ testFilePatterns: ["test/**/*.test.ts"],
155
+ fallback: "import-grep",
156
+ });
157
+ });
158
+
159
+ test("loadConfig coerces smartTestRunner: false to disabled config object", async () => {
160
+ const configPath = join(tempDir, "nax", "config.json");
161
+ writeFileSync(
162
+ configPath,
163
+ JSON.stringify({ execution: { smartTestRunner: false } }, null, 2),
164
+ );
165
+
166
+ const config = await loadConfig(join(tempDir, "nax"));
167
+ expect(config.execution.smartTestRunner).toEqual({
168
+ enabled: false,
169
+ testFilePatterns: ["test/**/*.test.ts"],
170
+ fallback: "import-grep",
171
+ });
172
+ });
173
+
174
+ test("loadConfig normalizes to enabled object when field is absent (backward compat)", async () => {
175
+ const configPath = join(tempDir, "nax", "config.json");
176
+ // Config without smartTestRunner field at all
177
+ writeFileSync(configPath, JSON.stringify({}, null, 2));
178
+
179
+ const config = await loadConfig(join(tempDir, "nax"));
180
+ expect(config.execution.smartTestRunner).toEqual({
181
+ enabled: true,
182
+ testFilePatterns: ["test/**/*.test.ts"],
183
+ fallback: "import-grep",
184
+ });
185
+ });
186
+ });
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Minimal valid config helper
190
+ // ---------------------------------------------------------------------------
191
+
192
+ function buildMinimalConfig() {
193
+ return {
194
+ version: 1,
195
+ models: {
196
+ fast: { provider: "anthropic", model: "haiku" },
197
+ balanced: { provider: "anthropic", model: "sonnet" },
198
+ powerful: { provider: "anthropic", model: "opus" },
199
+ },
200
+ autoMode: {
201
+ enabled: true,
202
+ defaultAgent: "claude",
203
+ fallbackOrder: [],
204
+ complexityRouting: { simple: "fast", medium: "balanced", complex: "powerful", expert: "powerful" },
205
+ escalation: { enabled: true, tierOrder: [{ tier: "fast", attempts: 1 }] },
206
+ },
207
+ routing: { strategy: "keyword" as const },
208
+ execution: {
209
+ maxIterations: 10,
210
+ iterationDelayMs: 0,
211
+ costLimit: 1,
212
+ sessionTimeoutSeconds: 60,
213
+ maxStoriesPerFeature: 10,
214
+ rectification: {
215
+ enabled: true,
216
+ maxRetries: 1,
217
+ fullSuiteTimeoutSeconds: 30,
218
+ maxFailureSummaryChars: 500,
219
+ abortOnIncreasingFailures: true,
220
+ },
221
+ regressionGate: { enabled: true, timeoutSeconds: 30 },
222
+ contextProviderTokenBudget: 100,
223
+ },
224
+ quality: {
225
+ requireTypecheck: true,
226
+ requireLint: true,
227
+ requireTests: true,
228
+ commands: {},
229
+ forceExit: false,
230
+ detectOpenHandles: true,
231
+ detectOpenHandlesRetries: 1,
232
+ gracePeriodMs: 500,
233
+ drainTimeoutMs: 0,
234
+ shell: "/bin/sh",
235
+ stripEnvVars: [],
236
+ environmentalEscalationDivisor: 2,
237
+ },
238
+ tdd: { maxRetries: 0, autoVerifyIsolation: false, autoApproveVerifier: false },
239
+ constitution: { enabled: false, path: "constitution.md", maxTokens: 100 },
240
+ analyze: { llmEnhanced: false, model: "balanced", fallbackToKeywords: true, maxCodebaseSummaryTokens: 100 },
241
+ review: { enabled: false, checks: [] as Array<"typecheck" | "lint" | "test">, commands: {} },
242
+ plan: { model: "balanced", outputPath: "spec.md" },
243
+ acceptance: { enabled: false, maxRetries: 0, generateTests: false, testPath: "acceptance.test.ts" },
244
+ context: {
245
+ testCoverage: { enabled: false, detail: "names-only" as const, maxTokens: 50, testPattern: "**/*.test.ts", scopeToStory: false },
246
+ autoDetect: { enabled: false, maxFiles: 1, traceImports: false },
247
+ },
248
+ };
249
+ }