@nathapp/nax 0.18.1 → 0.18.2

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.
@@ -101,7 +101,7 @@ describe("US-002: Precheck orchestrator acceptance criteria", () => {
101
101
  const config = createConfig(testDir);
102
102
  const prd = createPRD();
103
103
 
104
- const { result } = await runPrecheck(config, prd, { workdir: testDir, format: "json" });
104
+ const { result } = await runPrecheck(config, prd, { workdir: testDir, format: "json", silent: true });
105
105
 
106
106
  // Should have exactly 1 blocker (fail-fast)
107
107
  expect(result.blockers.length).toBe(1);
@@ -116,7 +116,7 @@ describe("US-002: Precheck orchestrator acceptance criteria", () => {
116
116
  const config = createConfig(testDir);
117
117
  const prd = createPRD();
118
118
 
119
- const { result } = await runPrecheck(config, prd, { workdir: testDir, format: "json" });
119
+ const { result } = await runPrecheck(config, prd, { workdir: testDir, format: "json", silent: true });
120
120
 
121
121
  // No blockers
122
122
  expect(result.blockers.length).toBe(0);
@@ -195,13 +195,13 @@ describe("US-002: Precheck orchestrator acceptance criteria", () => {
195
195
 
196
196
  let config = createConfig(testDir);
197
197
  const prd = createPRD();
198
- let result = await runPrecheck(config, prd, { workdir: testDir, format: "json" });
198
+ let result = await runPrecheck(config, prd, { workdir: testDir, format: "json", silent: true });
199
199
  expect(result.exitCode).toBe(EXIT_CODES.SUCCESS);
200
200
 
201
201
  // Test exit code 1 (blocker)
202
202
  const testDir2 = mkdtempSync(join(tmpdir(), "nax-test-blocker-"));
203
203
  config = createConfig(testDir2);
204
- result = await runPrecheck(config, prd, { workdir: testDir2, format: "json" });
204
+ result = await runPrecheck(config, prd, { workdir: testDir2, format: "json", silent: true });
205
205
  expect(result.exitCode).toBe(EXIT_CODES.BLOCKER);
206
206
  rmSync(testDir2, { recursive: true, force: true });
207
207
 
@@ -214,7 +214,7 @@ describe("US-002: Precheck orchestrator acceptance criteria", () => {
214
214
  ...prd,
215
215
  userStories: [{ ...prd.userStories[0], id: "", title: "", description: "" }],
216
216
  };
217
- result = await runPrecheck(config, invalidPRD, { workdir: testDir3, format: "json" });
217
+ result = await runPrecheck(config, invalidPRD, { workdir: testDir3, format: "json", silent: true });
218
218
  expect(result.exitCode).toBe(EXIT_CODES.INVALID_PRD);
219
219
  rmSync(testDir3, { recursive: true, force: true });
220
220
  });
@@ -0,0 +1,225 @@
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 true 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).toBe(true);
110
+ }
111
+ });
112
+
113
+ test("Zod schema accepts smartTestRunner: false", () => {
114
+ const result = NaxConfigSchema.safeParse({
115
+ ...buildMinimalConfig(),
116
+ execution: { ...buildMinimalConfig().execution, smartTestRunner: false },
117
+ });
118
+ expect(result.success).toBe(true);
119
+ if (result.success) {
120
+ expect(result.data.execution.smartTestRunner).toBe(false);
121
+ }
122
+ });
123
+
124
+ test("Zod schema accepts smartTestRunner: true explicitly", () => {
125
+ const result = NaxConfigSchema.safeParse({
126
+ ...buildMinimalConfig(),
127
+ execution: { ...buildMinimalConfig().execution, smartTestRunner: true },
128
+ });
129
+ expect(result.success).toBe(true);
130
+ if (result.success) {
131
+ expect(result.data.execution.smartTestRunner).toBe(true);
132
+ }
133
+ });
134
+
135
+ test("loadConfig defaults smartTestRunner to true when not in project config", async () => {
136
+ const configPath = join(tempDir, "nax", "config.json");
137
+ writeFileSync(configPath, JSON.stringify({ routing: { strategy: "keyword" } }, null, 2));
138
+
139
+ const config = await loadConfig(join(tempDir, "nax"));
140
+ expect(config.execution.smartTestRunner).toBe(true);
141
+ });
142
+
143
+ test("loadConfig respects smartTestRunner: false from project config", async () => {
144
+ const configPath = join(tempDir, "nax", "config.json");
145
+ writeFileSync(
146
+ configPath,
147
+ JSON.stringify({ execution: { smartTestRunner: false } }, null, 2),
148
+ );
149
+
150
+ const config = await loadConfig(join(tempDir, "nax"));
151
+ expect(config.execution.smartTestRunner).toBe(false);
152
+ });
153
+
154
+ test("loadConfig loads correctly without the field (backward compat)", async () => {
155
+ const configPath = join(tempDir, "nax", "config.json");
156
+ // Config without smartTestRunner field at all
157
+ writeFileSync(configPath, JSON.stringify({}, null, 2));
158
+
159
+ const config = await loadConfig(join(tempDir, "nax"));
160
+ expect(config.execution.smartTestRunner).toBe(true);
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Minimal valid config helper
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function buildMinimalConfig() {
169
+ return {
170
+ version: 1,
171
+ models: {
172
+ fast: { provider: "anthropic", model: "haiku" },
173
+ balanced: { provider: "anthropic", model: "sonnet" },
174
+ powerful: { provider: "anthropic", model: "opus" },
175
+ },
176
+ autoMode: {
177
+ enabled: true,
178
+ defaultAgent: "claude",
179
+ fallbackOrder: [],
180
+ complexityRouting: { simple: "fast", medium: "balanced", complex: "powerful", expert: "powerful" },
181
+ escalation: { enabled: true, tierOrder: [{ tier: "fast", attempts: 1 }] },
182
+ },
183
+ routing: { strategy: "keyword" as const },
184
+ execution: {
185
+ maxIterations: 10,
186
+ iterationDelayMs: 0,
187
+ costLimit: 1,
188
+ sessionTimeoutSeconds: 60,
189
+ maxStoriesPerFeature: 10,
190
+ rectification: {
191
+ enabled: true,
192
+ maxRetries: 1,
193
+ fullSuiteTimeoutSeconds: 30,
194
+ maxFailureSummaryChars: 500,
195
+ abortOnIncreasingFailures: true,
196
+ },
197
+ regressionGate: { enabled: true, timeoutSeconds: 30 },
198
+ contextProviderTokenBudget: 100,
199
+ },
200
+ quality: {
201
+ requireTypecheck: true,
202
+ requireLint: true,
203
+ requireTests: true,
204
+ commands: {},
205
+ forceExit: false,
206
+ detectOpenHandles: true,
207
+ detectOpenHandlesRetries: 1,
208
+ gracePeriodMs: 500,
209
+ drainTimeoutMs: 0,
210
+ shell: "/bin/sh",
211
+ stripEnvVars: [],
212
+ environmentalEscalationDivisor: 2,
213
+ },
214
+ tdd: { maxRetries: 0, autoVerifyIsolation: false, autoApproveVerifier: false },
215
+ constitution: { enabled: false, path: "constitution.md", maxTokens: 100 },
216
+ analyze: { llmEnhanced: false, model: "balanced", fallbackToKeywords: true, maxCodebaseSummaryTokens: 100 },
217
+ review: { enabled: false, checks: [] as Array<"typecheck" | "lint" | "test">, commands: {} },
218
+ plan: { model: "balanced", outputPath: "spec.md" },
219
+ acceptance: { enabled: false, maxRetries: 0, generateTests: false, testPath: "acceptance.test.ts" },
220
+ context: {
221
+ testCoverage: { enabled: false, detail: "names-only" as const, maxTokens: 50, testPattern: "**/*.test.ts", scopeToStory: false },
222
+ autoDetect: { enabled: false, maxFiles: 1, traceImports: false },
223
+ },
224
+ };
225
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tests for partial routing override in routing stage (FIX-001)
3
+ *
4
+ * Verifies that story.routing fields only override LLM-classified results
5
+ * when they are actually set. Prevents undefined values from clobbering
6
+ * a fresh classification.
7
+ */
8
+
9
+ import { beforeEach, afterEach, describe, expect, mock, test } from "bun:test";
10
+ import { initLogger, resetLogger } from "../../../src/logger";
11
+ import type { PipelineContext } from "../../../src/pipeline/types";
12
+ import type { NaxConfig } from "../../../src/config";
13
+ import type { UserStory } from "../../../src/prd/types";
14
+
15
+ // ── Module mocks (must be declared before dynamic imports) ────────────────────
16
+
17
+ const mockRouteStory = mock(async () => ({
18
+ complexity: "medium",
19
+ modelTier: "balanced",
20
+ testStrategy: "three-session-tdd",
21
+ reasoning: "LLM classified as medium",
22
+ }));
23
+
24
+ const mockComplexityToModelTier = mock((_complexity: string, _config: unknown) => "balanced" as const);
25
+
26
+ mock.module("../../../src/routing", () => ({
27
+ routeStory: mockRouteStory,
28
+ complexityToModelTier: mockComplexityToModelTier,
29
+ }));
30
+
31
+ // Greenfield check: return false so it never interferes with test strategy
32
+ mock.module("../../../src/context/greenfield", () => ({
33
+ isGreenfieldStory: mock(async () => false),
34
+ }));
35
+
36
+ // LLM batch cache is not relevant here
37
+ mock.module("../../../src/routing/strategies/llm", () => ({
38
+ clearCache: mock(() => {}),
39
+ routeBatch: mock(async () => []),
40
+ }));
41
+
42
+ // ── Dynamic imports after mocks ───────────────────────────────────────────────
43
+
44
+ const { routingStage } = await import("../../../src/pipeline/stages/routing");
45
+
46
+ // ── Fixtures ──────────────────────────────────────────────────────────────────
47
+
48
+ function makeStory(routingOverride?: Partial<UserStory["routing"]>): UserStory {
49
+ const story: UserStory = {
50
+ id: "FIX-001-test",
51
+ title: "Partial routing override test",
52
+ description: "Tests that story.routing only overrides when set",
53
+ acceptanceCriteria: [],
54
+ status: "pending",
55
+ passes: false,
56
+ escalations: [],
57
+ attempts: 0,
58
+ tags: [],
59
+ dependencies: [],
60
+ };
61
+
62
+ if (routingOverride !== undefined) {
63
+ story.routing = routingOverride as UserStory["routing"];
64
+ }
65
+
66
+ return story;
67
+ }
68
+
69
+ function makeCtx(story: UserStory): PipelineContext {
70
+ return {
71
+ config: {
72
+ tdd: { greenfieldDetection: false },
73
+ autoMode: { complexityRouting: {} },
74
+ routing: { strategy: "complexity", llm: { mode: "per-story" } },
75
+ } as unknown as NaxConfig,
76
+ story,
77
+ stories: [story],
78
+ routing: {} as PipelineContext["routing"],
79
+ workdir: "/tmp/nax-test-partial-routing",
80
+ prd: { feature: "test", userStories: [story] } as PipelineContext["prd"],
81
+ hooks: {} as PipelineContext["hooks"],
82
+ } as PipelineContext;
83
+ }
84
+
85
+ // ── Logger setup ──────────────────────────────────────────────────────────────
86
+
87
+ beforeEach(() => {
88
+ resetLogger();
89
+ initLogger({ level: "error", useChalk: false });
90
+ mockRouteStory.mockClear();
91
+ mockComplexityToModelTier.mockClear();
92
+ });
93
+
94
+ afterEach(() => {
95
+ resetLogger();
96
+ });
97
+
98
+ // ── Tests ─────────────────────────────────────────────────────────────────────
99
+
100
+ describe("routing stage — partial override (FIX-001)", () => {
101
+ test("(1) partial override with only testStrategy preserves LLM complexity", async () => {
102
+ // Story sets only testStrategy — complexity should come from LLM
103
+ const story = makeStory({ testStrategy: "test-after", complexity: undefined as any, reasoning: "manual" });
104
+ const ctx = makeCtx(story);
105
+
106
+ await routingStage.execute(ctx);
107
+
108
+ // testStrategy is overridden by the story field
109
+ expect(ctx.routing.testStrategy).toBe("test-after");
110
+ // complexity should remain from the LLM result ("medium"), not undefined
111
+ expect(ctx.routing.complexity).toBe("medium");
112
+ });
113
+
114
+ test("(2) LLM-classified complexity is preserved when story.routing has no complexity", async () => {
115
+ // story.routing is present but complexity is undefined (falsy)
116
+ const story = makeStory({ testStrategy: "test-after", complexity: undefined as any, reasoning: "" });
117
+ const ctx = makeCtx(story);
118
+
119
+ await routingStage.execute(ctx);
120
+
121
+ // LLM returned "medium" — it must not be overwritten with undefined
122
+ expect(ctx.routing.complexity).toBe("medium");
123
+ expect(ctx.routing.complexity).not.toBeUndefined();
124
+ });
125
+
126
+ test("(3) full override works when both complexity and testStrategy are set", async () => {
127
+ // Story has explicit values for both fields
128
+ const story = makeStory({
129
+ complexity: "simple",
130
+ testStrategy: "test-after",
131
+ reasoning: "manual override",
132
+ });
133
+ const ctx = makeCtx(story);
134
+
135
+ await routingStage.execute(ctx);
136
+
137
+ // Both fields should be overridden from the story
138
+ expect(ctx.routing.complexity).toBe("simple");
139
+ expect(ctx.routing.testStrategy).toBe("test-after");
140
+ });
141
+ });