@nathapp/nax 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CLAUDE.md +38 -8
  2. package/docs/ROADMAP.md +42 -17
  3. package/nax/features/prompt-builder/prd.json +152 -0
  4. package/nax/features/prompt-builder/progress.txt +3 -0
  5. package/nax/status.json +14 -14
  6. package/package.json +1 -1
  7. package/src/cli/config.ts +40 -1
  8. package/src/cli/prompts.ts +18 -6
  9. package/src/config/defaults.ts +1 -0
  10. package/src/config/schemas.ts +10 -0
  11. package/src/config/types.ts +7 -0
  12. package/src/pipeline/runner.ts +2 -1
  13. package/src/pipeline/stages/autofix.ts +5 -0
  14. package/src/pipeline/stages/execution.ts +5 -0
  15. package/src/pipeline/stages/prompt.ts +13 -4
  16. package/src/pipeline/stages/rectify.ts +5 -0
  17. package/src/pipeline/stages/regression.ts +6 -1
  18. package/src/pipeline/stages/verify.ts +2 -1
  19. package/src/pipeline/types.ts +9 -0
  20. package/src/precheck/checks-warnings.ts +37 -0
  21. package/src/precheck/checks.ts +1 -0
  22. package/src/precheck/index.ts +14 -7
  23. package/src/prompts/builder.ts +178 -0
  24. package/src/prompts/index.ts +2 -0
  25. package/src/prompts/loader.ts +43 -0
  26. package/src/prompts/sections/conventions.ts +15 -0
  27. package/src/prompts/sections/index.ts +11 -0
  28. package/src/prompts/sections/isolation.ts +24 -0
  29. package/src/prompts/sections/role-task.ts +32 -0
  30. package/src/prompts/sections/story.ts +13 -0
  31. package/src/prompts/sections/verdict.ts +70 -0
  32. package/src/prompts/templates/implementer.ts +6 -0
  33. package/src/prompts/templates/single-session.ts +6 -0
  34. package/src/prompts/templates/test-writer.ts +6 -0
  35. package/src/prompts/templates/verifier.ts +6 -0
  36. package/src/prompts/types.ts +21 -0
  37. package/src/tdd/orchestrator.ts +11 -1
  38. package/src/tdd/rectification-gate.ts +18 -13
  39. package/src/tdd/session-runner.ts +12 -12
  40. package/src/tdd/types.ts +2 -0
  41. package/test/integration/cli/cli-config-prompts-explain.test.ts +74 -0
  42. package/test/integration/prompts/pb-004-migration.test.ts +523 -0
  43. package/test/unit/precheck/checks-warnings.test.ts +114 -0
  44. package/test/unit/prompts/builder.test.ts +258 -0
  45. package/test/unit/prompts/loader.test.ts +355 -0
  46. package/test/unit/prompts/sections/conventions.test.ts +30 -0
  47. package/test/unit/prompts/sections/isolation.test.ts +35 -0
  48. package/test/unit/prompts/sections/role-task.test.ts +40 -0
  49. package/test/unit/prompts/sections/sections.test.ts +238 -0
  50. package/test/unit/prompts/sections/story.test.ts +45 -0
  51. package/test/unit/prompts/sections/verdict.test.ts +58 -0
@@ -0,0 +1,523 @@
1
+ /**
2
+ * PB-004: Migrate call sites to PromptBuilder — integration tests
3
+ *
4
+ * These tests are expected to FAIL until:
5
+ * 1. PromptBuilder gains a .withLoader(workdir, config) method
6
+ * 2. The 6 user-facing prompt functions are replaced with PromptBuilder calls
7
+ * 3. Call sites in session-runner.ts and prompt.ts stage are updated
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join, dirname } from "node:path";
14
+ import type { NaxConfig } from "../../../src/config/types";
15
+ import { PromptBuilder } from "../../../src/prompts/builder";
16
+ import type { UserStory } from "../../../src/prd";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Fixtures
20
+ // ---------------------------------------------------------------------------
21
+
22
+ function makeStory(overrides: Partial<UserStory> = {}): UserStory {
23
+ return {
24
+ id: "PB-004",
25
+ title: "Migrate call sites to PromptBuilder",
26
+ description: "Replace 6 user-facing prompt functions with PromptBuilder calls.",
27
+ acceptanceCriteria: [
28
+ "All 6 user-facing prompt functions replaced with PromptBuilder calls",
29
+ "Internal prompts remain unchanged",
30
+ "No regression in generated prompt text",
31
+ ],
32
+ tags: [],
33
+ dependencies: [],
34
+ status: "pending",
35
+ passes: false,
36
+ escalations: [],
37
+ attempts: 0,
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function makeConfig(overrides: Partial<NaxConfig> = {}): NaxConfig {
43
+ return {
44
+ version: 1,
45
+ models: {
46
+ fast: { provider: "anthropic", model: "haiku" },
47
+ balanced: { provider: "anthropic", model: "sonnet" },
48
+ powerful: { provider: "anthropic", model: "opus" },
49
+ },
50
+ autoMode: {
51
+ enabled: true,
52
+ defaultAgent: "claude",
53
+ fallbackOrder: ["claude"],
54
+ complexityRouting: { simple: "fast", medium: "balanced", complex: "powerful", expert: "powerful" },
55
+ escalation: { enabled: true, tierOrder: [{ tier: "fast", attempts: 3 }] },
56
+ },
57
+ routing: { strategy: "keyword" },
58
+ execution: {
59
+ maxIterations: 10,
60
+ iterationDelayMs: 2000,
61
+ costLimit: 5,
62
+ sessionTimeoutSeconds: 600,
63
+ verificationTimeoutSeconds: 300,
64
+ maxStoriesPerFeature: 500,
65
+ rectification: {
66
+ enabled: true,
67
+ maxRetries: 2,
68
+ fullSuiteTimeoutSeconds: 120,
69
+ maxFailureSummaryChars: 2000,
70
+ abortOnIncreasingFailures: true,
71
+ },
72
+ regressionGate: { enabled: true, timeoutSeconds: 120 },
73
+ contextProviderTokenBudget: 2000,
74
+ },
75
+ quality: {
76
+ requireTypecheck: true,
77
+ requireLint: true,
78
+ requireTests: true,
79
+ commands: {},
80
+ forceExit: false,
81
+ detectOpenHandles: true,
82
+ detectOpenHandlesRetries: 1,
83
+ gracePeriodMs: 5000,
84
+ dangerouslySkipPermissions: true,
85
+ drainTimeoutMs: 2000,
86
+ shell: "/bin/sh",
87
+ stripEnvVars: [],
88
+ environmentalEscalationDivisor: 2,
89
+ },
90
+ tdd: {
91
+ maxRetries: 2,
92
+ autoVerifyIsolation: true,
93
+ strategy: "auto",
94
+ autoApproveVerifier: true,
95
+ },
96
+ constitution: { enabled: false, path: "constitution.md", maxTokens: 2000 },
97
+ analyze: { llmEnhanced: false, model: "balanced", fallbackToKeywords: true, maxCodebaseSummaryTokens: 5000 },
98
+ review: { enabled: false, checks: [], commands: {} },
99
+ plan: { model: "balanced", outputPath: "spec.md" },
100
+ acceptance: { enabled: false, maxRetries: 2, generateTests: false, testPath: "acceptance.test.ts" },
101
+ context: {
102
+ testCoverage: {
103
+ enabled: false,
104
+ detail: "names-only",
105
+ maxTokens: 500,
106
+ testPattern: "**/*.test.ts",
107
+ scopeToStory: false,
108
+ },
109
+ autoDetect: { enabled: false, maxFiles: 5, traceImports: false },
110
+ },
111
+ ...overrides,
112
+ } as NaxConfig;
113
+ }
114
+
115
+ let tmpDir: string;
116
+
117
+ beforeEach(() => {
118
+ tmpDir = mkdtempSync(join(tmpdir(), "nax-pb004-test-"));
119
+ });
120
+
121
+ afterEach(() => {
122
+ try {
123
+ // best-effort cleanup
124
+ Bun.spawnSync(["rm", "-rf", tmpDir]);
125
+ } catch {
126
+ // ignore
127
+ }
128
+ });
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // 1. PromptBuilder.withLoader API — fails until withLoader is implemented
132
+ // ---------------------------------------------------------------------------
133
+
134
+ describe("PromptBuilder.withLoader(workdir, config)", () => {
135
+ test("withLoader is chainable and returns a PromptBuilder", () => {
136
+ const config = makeConfig();
137
+ // FAILS: withLoader does not exist on PromptBuilder
138
+ const pb = (PromptBuilder.for("test-writer") as any).withLoader(tmpDir, config);
139
+ expect(pb).toBeInstanceOf(PromptBuilder);
140
+ });
141
+
142
+ test("withLoader + no override in config: build succeeds and uses default", async () => {
143
+ const config = makeConfig(); // no prompts.overrides
144
+ const story = makeStory();
145
+ // FAILS: withLoader does not exist on PromptBuilder
146
+ const prompt = await (PromptBuilder.for("test-writer") as any)
147
+ .withLoader(tmpDir, config)
148
+ .story(story)
149
+ .build();
150
+ expect(prompt).toContain(story.title);
151
+ });
152
+
153
+ test("withLoader reads override file when config.prompts.overrides is set", async () => {
154
+ const overrideContent = "# CUSTOM_TEST_WRITER_OVERRIDE\nCustom role body from user override.";
155
+ const relPath = ".nax/prompts/test-writer.md";
156
+ const absPath = join(tmpDir, relPath);
157
+ mkdirSync(dirname(absPath), { recursive: true });
158
+ writeFileSync(absPath, overrideContent);
159
+
160
+ const config = makeConfig({ prompts: { overrides: { "test-writer": relPath } } });
161
+ const story = makeStory();
162
+
163
+ // FAILS: withLoader does not exist on PromptBuilder
164
+ const prompt = await (PromptBuilder.for("test-writer") as any)
165
+ .withLoader(tmpDir, config)
166
+ .story(story)
167
+ .build();
168
+
169
+ expect(prompt).toContain("CUSTOM_TEST_WRITER_OVERRIDE");
170
+ // Story context (non-overridable) must still appear
171
+ expect(prompt).toContain(story.title);
172
+ });
173
+
174
+ test("withLoader falls back to default when override file is absent", async () => {
175
+ const config = makeConfig({
176
+ prompts: { overrides: { "test-writer": ".nax/prompts/nonexistent.md" } },
177
+ });
178
+ const story = makeStory({ title: "FALLBACK_STORY_TITLE" });
179
+
180
+ // FAILS: withLoader does not exist on PromptBuilder
181
+ const prompt = await (PromptBuilder.for("test-writer") as any)
182
+ .withLoader(tmpDir, config)
183
+ .story(story)
184
+ .build();
185
+
186
+ expect(prompt).toContain("FALLBACK_STORY_TITLE");
187
+ });
188
+ });
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // 2. Integration — 6 roles produce semantically correct output (no override)
192
+ // Uses withLoader so it fails until migration is complete
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe("Integration: 6 roles with no override — story title and AC present", () => {
196
+ const story = makeStory({
197
+ title: "ROLE_INTEGRATION_TEST_STORY",
198
+ acceptanceCriteria: ["CRITERIA_ONE", "CRITERIA_TWO"],
199
+ });
200
+
201
+ test("test-writer (strict isolation) contains story title and acceptance criteria", async () => {
202
+ const config = makeConfig();
203
+ // FAILS: withLoader does not exist
204
+ const prompt = await (PromptBuilder.for("test-writer", { isolation: "strict" }) as any)
205
+ .withLoader(tmpDir, config)
206
+ .story(story)
207
+ .build();
208
+
209
+ expect(prompt).toContain("ROLE_INTEGRATION_TEST_STORY");
210
+ expect(prompt).toContain("CRITERIA_ONE");
211
+ expect(prompt).toContain("CRITERIA_TWO");
212
+ });
213
+
214
+ test("test-writer (strict) includes test-only isolation instructions", async () => {
215
+ const config = makeConfig();
216
+ // FAILS: withLoader does not exist
217
+ const prompt = await (PromptBuilder.for("test-writer", { isolation: "strict" }) as any)
218
+ .withLoader(tmpDir, config)
219
+ .story(story)
220
+ .build();
221
+
222
+ const lower = prompt.toLowerCase();
223
+ // Must mention writing tests or test/ directory restriction
224
+ const hasTestInstruction =
225
+ lower.includes("test") &&
226
+ (lower.includes("only") || lower.includes("do not") || lower.includes("don't") || lower.includes("src/"));
227
+ expect(hasTestInstruction).toBe(true);
228
+ });
229
+
230
+ test("test-writer (lite) contains story title and acceptance criteria", async () => {
231
+ const config = makeConfig();
232
+ // FAILS: withLoader does not exist
233
+ const prompt = await (PromptBuilder.for("test-writer", { isolation: "lite" }) as any)
234
+ .withLoader(tmpDir, config)
235
+ .story(story)
236
+ .build();
237
+
238
+ expect(prompt).toContain("ROLE_INTEGRATION_TEST_STORY");
239
+ expect(prompt).toContain("CRITERIA_ONE");
240
+ });
241
+
242
+ test("test-writer (lite) mentions allowing src/ reads or stubs", async () => {
243
+ const config = makeConfig();
244
+ // FAILS: withLoader does not exist
245
+ const prompt = await (PromptBuilder.for("test-writer", { isolation: "lite" }) as any)
246
+ .withLoader(tmpDir, config)
247
+ .story(story)
248
+ .build();
249
+
250
+ const lower = prompt.toLowerCase();
251
+ // Lite mode allows reading source files or creating stubs
252
+ const hasLiteInstruction =
253
+ lower.includes("stub") ||
254
+ lower.includes("may read") ||
255
+ lower.includes("read source") ||
256
+ lower.includes("import from source");
257
+ expect(hasLiteInstruction).toBe(true);
258
+ });
259
+
260
+ test("implementer (standard) contains story title and acceptance criteria", async () => {
261
+ const config = makeConfig();
262
+ // FAILS: withLoader does not exist
263
+ const prompt = await (PromptBuilder.for("implementer", { variant: "standard" }) as any)
264
+ .withLoader(tmpDir, config)
265
+ .story(story)
266
+ .build();
267
+
268
+ expect(prompt).toContain("ROLE_INTEGRATION_TEST_STORY");
269
+ expect(prompt).toContain("CRITERIA_ONE");
270
+ expect(prompt).toContain("CRITERIA_TWO");
271
+ });
272
+
273
+ test("implementer (standard) includes implementation instructions", async () => {
274
+ const config = makeConfig();
275
+ // FAILS: withLoader does not exist
276
+ const prompt = await (PromptBuilder.for("implementer", { variant: "standard" }) as any)
277
+ .withLoader(tmpDir, config)
278
+ .story(story)
279
+ .build();
280
+
281
+ const lower = prompt.toLowerCase();
282
+ const hasImplInstruction =
283
+ lower.includes("implement") ||
284
+ lower.includes("make") ||
285
+ lower.includes("pass");
286
+ expect(hasImplInstruction).toBe(true);
287
+ });
288
+
289
+ test("implementer (lite) contains story title and acceptance criteria", async () => {
290
+ const config = makeConfig();
291
+ // FAILS: withLoader does not exist
292
+ const prompt = await (PromptBuilder.for("implementer", { variant: "lite" }) as any)
293
+ .withLoader(tmpDir, config)
294
+ .story(story)
295
+ .build();
296
+
297
+ expect(prompt).toContain("ROLE_INTEGRATION_TEST_STORY");
298
+ expect(prompt).toContain("CRITERIA_ONE");
299
+ });
300
+
301
+ test("implementer (lite) mentions writing tests AND implementing", async () => {
302
+ const config = makeConfig();
303
+ // FAILS: withLoader does not exist
304
+ const prompt = await (PromptBuilder.for("implementer", { variant: "lite" }) as any)
305
+ .withLoader(tmpDir, config)
306
+ .story(story)
307
+ .build();
308
+
309
+ const lower = prompt.toLowerCase();
310
+ const hasTests = lower.includes("test");
311
+ const hasImpl = lower.includes("implement") || lower.includes("feature");
312
+ expect(hasTests && hasImpl).toBe(true);
313
+ });
314
+
315
+ test("verifier contains story title and acceptance criteria", async () => {
316
+ const config = makeConfig();
317
+ // FAILS: withLoader does not exist
318
+ const prompt = await (PromptBuilder.for("verifier") as any)
319
+ .withLoader(tmpDir, config)
320
+ .story(story)
321
+ .build();
322
+
323
+ expect(prompt).toContain("ROLE_INTEGRATION_TEST_STORY");
324
+ expect(prompt).toContain("CRITERIA_ONE");
325
+ expect(prompt).toContain("CRITERIA_TWO");
326
+ });
327
+
328
+ test("verifier includes verification instructions", async () => {
329
+ const config = makeConfig();
330
+ // FAILS: withLoader does not exist
331
+ const prompt = await (PromptBuilder.for("verifier") as any)
332
+ .withLoader(tmpDir, config)
333
+ .story(story)
334
+ .build();
335
+
336
+ const lower = prompt.toLowerCase();
337
+ const hasVerifyInstruction = lower.includes("verify") || lower.includes("check") || lower.includes("ensure");
338
+ expect(hasVerifyInstruction).toBe(true);
339
+ });
340
+
341
+ test("single-session contains story title and acceptance criteria", async () => {
342
+ const config = makeConfig();
343
+ // FAILS: withLoader does not exist
344
+ const prompt = await (PromptBuilder.for("single-session") as any)
345
+ .withLoader(tmpDir, config)
346
+ .story(story)
347
+ .build();
348
+
349
+ expect(prompt).toContain("ROLE_INTEGRATION_TEST_STORY");
350
+ expect(prompt).toContain("CRITERIA_ONE");
351
+ expect(prompt).toContain("CRITERIA_TWO");
352
+ });
353
+
354
+ test("single-session includes both test and implementation instructions", async () => {
355
+ const config = makeConfig();
356
+ // FAILS: withLoader does not exist
357
+ const prompt = await (PromptBuilder.for("single-session") as any)
358
+ .withLoader(tmpDir, config)
359
+ .story(story)
360
+ .build();
361
+
362
+ const lower = prompt.toLowerCase();
363
+ const hasTests = lower.includes("test");
364
+ const hasImpl = lower.includes("implement") || lower.includes("feature");
365
+ expect(hasTests && hasImpl).toBe(true);
366
+ });
367
+ });
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // 3. Structural: call sites no longer import the 6 old functions
371
+ // FAILS until migration removes/replaces imports in call sites
372
+ // ---------------------------------------------------------------------------
373
+
374
+ describe("Structural: call sites migrated away from old prompt functions", () => {
375
+ test("src/tdd/session-runner.ts does not import buildTestWriterPrompt from ./prompts", async () => {
376
+ const source = await Bun.file(
377
+ new URL("../../../src/tdd/session-runner.ts", import.meta.url).pathname,
378
+ ).text();
379
+
380
+ // After migration, session-runner should NOT import these old functions
381
+ expect(source).not.toContain("buildTestWriterPrompt");
382
+ expect(source).not.toContain("buildTestWriterLitePrompt");
383
+ expect(source).not.toContain("buildImplementerPrompt");
384
+ expect(source).not.toContain("buildImplementerLitePrompt");
385
+ expect(source).not.toContain("buildVerifierPrompt");
386
+ });
387
+
388
+ test("src/tdd/session-runner.ts imports PromptBuilder after migration", async () => {
389
+ const source = await Bun.file(
390
+ new URL("../../../src/tdd/session-runner.ts", import.meta.url).pathname,
391
+ ).text();
392
+
393
+ // After migration, session-runner should use PromptBuilder
394
+ expect(source).toContain("PromptBuilder");
395
+ });
396
+
397
+ test("src/pipeline/stages/prompt.ts does not import buildSingleSessionPrompt after migration", async () => {
398
+ const source = await Bun.file(
399
+ new URL("../../../src/pipeline/stages/prompt.ts", import.meta.url).pathname,
400
+ ).text();
401
+
402
+ // After migration, prompt stage should NOT use the old function
403
+ expect(source).not.toContain("buildSingleSessionPrompt");
404
+ });
405
+
406
+ test("src/pipeline/stages/prompt.ts imports PromptBuilder after migration", async () => {
407
+ const source = await Bun.file(
408
+ new URL("../../../src/pipeline/stages/prompt.ts", import.meta.url).pathname,
409
+ ).text();
410
+
411
+ // After migration, prompt stage should use PromptBuilder
412
+ expect(source).toContain("PromptBuilder");
413
+ });
414
+
415
+ test("src/cli/prompts.ts does not dynamically import buildTestWriterPrompt after migration", async () => {
416
+ const source = await Bun.file(
417
+ new URL("../../../src/cli/prompts.ts", import.meta.url).pathname,
418
+ ).text();
419
+
420
+ // cli/prompts.ts has a dynamic import of tdd/prompts — after migration it should use PromptBuilder
421
+ expect(source).not.toContain("buildTestWriterPrompt");
422
+ expect(source).not.toContain("buildImplementerPrompt");
423
+ expect(source).not.toContain("buildVerifierPrompt");
424
+ });
425
+ });
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // 4. Internal prompts remain unchanged (regression guard — expected to PASS)
429
+ // ---------------------------------------------------------------------------
430
+
431
+ describe("Internal prompts: not migrated, still accessible", () => {
432
+ test("buildImplementerRectificationPrompt still exported from src/tdd/prompts", async () => {
433
+ const mod = await import("../../../src/tdd/prompts");
434
+ expect(typeof mod.buildImplementerRectificationPrompt).toBe("function");
435
+ });
436
+
437
+ test("buildRectificationPrompt still exported from src/tdd/prompts", async () => {
438
+ const mod = await import("../../../src/tdd/prompts");
439
+ expect(typeof mod.buildRectificationPrompt).toBe("function");
440
+ });
441
+
442
+ test("buildBatchPrompt still exported from src/execution/prompts", async () => {
443
+ const mod = await import("../../../src/execution/prompts");
444
+ expect(typeof mod.buildBatchPrompt).toBe("function");
445
+ });
446
+
447
+ test("buildRoutingPrompt still exported from src/routing/strategies/llm-prompts", async () => {
448
+ const mod = await import("../../../src/routing/strategies/llm-prompts");
449
+ expect(typeof mod.buildRoutingPrompt).toBe("function");
450
+ });
451
+
452
+ test("buildBatchPrompt still exported from src/routing/strategies/llm-prompts", async () => {
453
+ const mod = await import("../../../src/routing/strategies/llm-prompts");
454
+ expect(typeof mod.buildBatchPrompt).toBe("function");
455
+ });
456
+ });
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // 5. withLoader override: context passed through correctly
460
+ // ---------------------------------------------------------------------------
461
+
462
+ describe("PromptBuilder.withLoader override content integration", () => {
463
+ test("override for implementer role replaces role body", async () => {
464
+ const overrideBody = "IMPLEMENTER_CUSTOM_ROLE_BODY_MARKER";
465
+ const relPath = ".nax/prompts/implementer.md";
466
+ const absPath = join(tmpDir, relPath);
467
+ mkdirSync(dirname(absPath), { recursive: true });
468
+ writeFileSync(absPath, overrideBody);
469
+
470
+ const config = makeConfig({ prompts: { overrides: { implementer: relPath } } });
471
+ const story = makeStory({ title: "OVERRIDE_STORY_TITLE" });
472
+
473
+ // FAILS: withLoader does not exist
474
+ const prompt = await (PromptBuilder.for("implementer", { variant: "standard" }) as any)
475
+ .withLoader(tmpDir, config)
476
+ .story(story)
477
+ .build();
478
+
479
+ expect(prompt).toContain(overrideBody);
480
+ // Story context still present (non-overridable)
481
+ expect(prompt).toContain("OVERRIDE_STORY_TITLE");
482
+ });
483
+
484
+ test("override for verifier role replaces role body", async () => {
485
+ const overrideBody = "VERIFIER_CUSTOM_ROLE_BODY_MARKER";
486
+ const relPath = ".nax/prompts/verifier.md";
487
+ const absPath = join(tmpDir, relPath);
488
+ mkdirSync(dirname(absPath), { recursive: true });
489
+ writeFileSync(absPath, overrideBody);
490
+
491
+ const config = makeConfig({ prompts: { overrides: { verifier: relPath } } });
492
+ const story = makeStory({ title: "VERIFIER_OVERRIDE_TITLE" });
493
+
494
+ // FAILS: withLoader does not exist
495
+ const prompt = await (PromptBuilder.for("verifier") as any)
496
+ .withLoader(tmpDir, config)
497
+ .story(story)
498
+ .build();
499
+
500
+ expect(prompt).toContain(overrideBody);
501
+ expect(prompt).toContain("VERIFIER_OVERRIDE_TITLE");
502
+ });
503
+
504
+ test("override for single-session role replaces role body", async () => {
505
+ const overrideBody = "SINGLE_SESSION_CUSTOM_ROLE_BODY_MARKER";
506
+ const relPath = ".nax/prompts/single-session.md";
507
+ const absPath = join(tmpDir, relPath);
508
+ mkdirSync(dirname(absPath), { recursive: true });
509
+ writeFileSync(absPath, overrideBody);
510
+
511
+ const config = makeConfig({ prompts: { overrides: { "single-session": relPath } } });
512
+ const story = makeStory({ title: "SINGLE_SESSION_OVERRIDE_TITLE" });
513
+
514
+ // FAILS: withLoader does not exist
515
+ const prompt = await (PromptBuilder.for("single-session") as any)
516
+ .withLoader(tmpDir, config)
517
+ .story(story)
518
+ .build();
519
+
520
+ expect(prompt).toContain(overrideBody);
521
+ expect(prompt).toContain("SINGLE_SESSION_OVERRIDE_TITLE");
522
+ });
523
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Unit tests for checks-warnings.ts — prompt override file checks (PB-005)
3
+ *
4
+ * Tests the new checkPromptOverrideFiles check which warns when a configured
5
+ * override file path does not exist. Non-blocking: run continues regardless.
6
+ */
7
+
8
+ import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import { beforeEach, describe, expect, test } from "bun:test";
12
+ import type { NaxConfig } from "../../../src/config/types";
13
+ import { checkPromptOverrideFiles } from "../../../src/precheck/checks-warnings";
14
+
15
+ function makeTmpDir(): string {
16
+ return mkdtempSync(join(tmpdir(), "nax-test-"));
17
+ }
18
+
19
+ function makeMinimalConfig(overrides?: Record<string, string>): NaxConfig {
20
+ return {
21
+ prompts: overrides ? { overrides } : undefined,
22
+ } as unknown as NaxConfig;
23
+ }
24
+
25
+ describe("checkPromptOverrideFiles", () => {
26
+ let workdir: string;
27
+
28
+ beforeEach(() => {
29
+ workdir = makeTmpDir();
30
+ });
31
+
32
+ test("no warning when config.prompts is absent", async () => {
33
+ const config = makeMinimalConfig(undefined);
34
+ const checks = await checkPromptOverrideFiles(config, workdir);
35
+ expect(checks).toHaveLength(0);
36
+ });
37
+
38
+ test("no warning when config.prompts.overrides is empty", async () => {
39
+ const config = makeMinimalConfig({});
40
+ const checks = await checkPromptOverrideFiles(config, workdir);
41
+ expect(checks).toHaveLength(0);
42
+ });
43
+
44
+ test("no warning when override file exists", async () => {
45
+ // Create the override file
46
+ const promptsDir = join(workdir, ".nax", "prompts");
47
+ mkdirSync(promptsDir, { recursive: true });
48
+ const filePath = join(promptsDir, "test-writer.md");
49
+ writeFileSync(filePath, "# Test Writer Prompt");
50
+
51
+ const config = makeMinimalConfig({
52
+ "test-writer": ".nax/prompts/test-writer.md",
53
+ });
54
+ const checks = await checkPromptOverrideFiles(config, workdir);
55
+ expect(checks).toHaveLength(0);
56
+ });
57
+
58
+ test("emits warning when override file is missing", async () => {
59
+ const config = makeMinimalConfig({
60
+ "test-writer": ".nax/prompts/test-writer.md",
61
+ });
62
+ const checks = await checkPromptOverrideFiles(config, workdir);
63
+
64
+ expect(checks).toHaveLength(1);
65
+ expect(checks[0].tier).toBe("warning");
66
+ expect(checks[0].passed).toBe(false);
67
+ expect(checks[0].message).toContain("test-writer");
68
+ expect(checks[0].message).toContain("test-writer.md");
69
+ });
70
+
71
+ test("warning message contains resolved absolute path", async () => {
72
+ const config = makeMinimalConfig({
73
+ "implementer": ".nax/prompts/implementer.md",
74
+ });
75
+ const checks = await checkPromptOverrideFiles(config, workdir);
76
+
77
+ expect(checks[0].message).toContain(workdir);
78
+ });
79
+
80
+ test("emits one warning per missing role", async () => {
81
+ const config = makeMinimalConfig({
82
+ "test-writer": ".nax/prompts/test-writer.md",
83
+ "implementer": ".nax/prompts/implementer.md",
84
+ });
85
+ const checks = await checkPromptOverrideFiles(config, workdir);
86
+
87
+ expect(checks).toHaveLength(2);
88
+ });
89
+
90
+ test("only warns for missing files, not existing ones", async () => {
91
+ const promptsDir = join(workdir, ".nax", "prompts");
92
+ mkdirSync(promptsDir, { recursive: true });
93
+ writeFileSync(join(promptsDir, "test-writer.md"), "# exists");
94
+
95
+ const config = makeMinimalConfig({
96
+ "test-writer": ".nax/prompts/test-writer.md",
97
+ "implementer": ".nax/prompts/implementer.md", // does not exist
98
+ });
99
+ const checks = await checkPromptOverrideFiles(config, workdir);
100
+
101
+ expect(checks).toHaveLength(1);
102
+ expect(checks[0].message).toContain("implementer");
103
+ });
104
+
105
+ test("warning check name identifies the role", async () => {
106
+ const config = makeMinimalConfig({
107
+ "verifier": ".nax/prompts/verifier.md",
108
+ });
109
+ const checks = await checkPromptOverrideFiles(config, workdir);
110
+
111
+ expect(checks[0].name).toContain("prompt-override");
112
+ expect(checks[0].name).toContain("verifier");
113
+ });
114
+ });