@nathapp/nax 0.22.4 → 0.24.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 (32) hide show
  1. package/bin/nax.ts +20 -2
  2. package/docs/tdd/strategies.md +97 -0
  3. package/nax/features/central-run-registry/prd.json +105 -0
  4. package/nax/features/diagnose/acceptance.test.ts +3 -1
  5. package/package.json +3 -3
  6. package/src/cli/diagnose.ts +1 -1
  7. package/src/cli/status-features.ts +55 -7
  8. package/src/commands/index.ts +1 -0
  9. package/src/commands/logs.ts +87 -17
  10. package/src/commands/runs.ts +220 -0
  11. package/src/config/schemas.ts +3 -0
  12. package/src/execution/crash-recovery.ts +30 -7
  13. package/src/execution/lifecycle/run-setup.ts +6 -1
  14. package/src/execution/runner.ts +8 -0
  15. package/src/execution/sequential-executor.ts +4 -0
  16. package/src/execution/status-writer.ts +42 -0
  17. package/src/pipeline/subscribers/events-writer.ts +121 -0
  18. package/src/pipeline/subscribers/registry.ts +73 -0
  19. package/src/version.ts +23 -0
  20. package/test/e2e/plan-analyze-run.test.ts +5 -0
  21. package/test/integration/cli/cli-diagnose.test.ts +3 -1
  22. package/test/integration/cli/cli-logs.test.ts +40 -17
  23. package/test/integration/execution/feature-status-write.test.ts +302 -0
  24. package/test/integration/execution/status-file-integration.test.ts +1 -1
  25. package/test/integration/execution/status-writer.test.ts +112 -0
  26. package/test/unit/cli-status-project-level.test.ts +283 -0
  27. package/test/unit/commands/logs.test.ts +63 -22
  28. package/test/unit/commands/runs.test.ts +303 -0
  29. package/test/unit/config/quality-commands-schema.test.ts +72 -0
  30. package/test/unit/execution/sfc-004-dead-code-cleanup.test.ts +89 -0
  31. package/test/unit/pipeline/subscribers/events-writer.test.ts +227 -0
  32. package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Tests for project-level status display in status-features.ts
3
+ *
4
+ * Verifies that nax status shows current run info from nax/status.json at the top.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
8
+ import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { displayFeatureStatus } from "../../src/cli/status";
12
+ import type { NaxStatusFile } from "../../src/execution/status-file";
13
+ import type { PRD } from "../../src/prd";
14
+
15
+ describe("displayFeatureStatus - Project-level status (nax/status.json)", () => {
16
+ let testDir: string;
17
+ let originalCwd: string;
18
+ let consoleOutput: string[];
19
+ const originalLog = console.log;
20
+
21
+ beforeEach(() => {
22
+ // Create temp directory for test
23
+ const rawTestDir = join(tmpdir(), `nax-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
24
+ mkdirSync(rawTestDir, { recursive: true });
25
+ testDir = realpathSync(rawTestDir);
26
+ originalCwd = process.cwd();
27
+
28
+ // Mock console.log to capture output
29
+ consoleOutput = [];
30
+ console.log = mock((message: string) => {
31
+ consoleOutput.push(message);
32
+ });
33
+ });
34
+
35
+ afterEach(() => {
36
+ // Restore original CWD and console.log
37
+ process.chdir(originalCwd);
38
+ console.log = originalLog;
39
+
40
+ // Clean up test directory
41
+ if (testDir) {
42
+ rmSync(testDir, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ function createTestPRD(featureName: string): PRD {
47
+ return {
48
+ project: "test-project",
49
+ feature: featureName,
50
+ branchName: `feat/${featureName}`,
51
+ createdAt: "2026-01-01T00:00:00.000Z",
52
+ updatedAt: "2026-01-01T00:00:00.000Z",
53
+ userStories: [
54
+ {
55
+ id: "US-001",
56
+ title: "First story",
57
+ description: "Test story 1",
58
+ acceptanceCriteria: ["AC-1"],
59
+ tags: [],
60
+ dependencies: [],
61
+ status: "passed",
62
+ passes: true,
63
+ escalations: [],
64
+ attempts: 1,
65
+ },
66
+ ],
67
+ };
68
+ }
69
+
70
+ function createProjectStatus(feature: string, overrides: Partial<NaxStatusFile> = {}): NaxStatusFile {
71
+ return {
72
+ version: 1,
73
+ run: {
74
+ id: "run-2026-01-01T00-00-00-000Z",
75
+ feature,
76
+ startedAt: "2026-01-01T10:00:00.000Z",
77
+ status: "running",
78
+ dryRun: false,
79
+ pid: process.pid, // Use current process PID (alive)
80
+ ...overrides.run,
81
+ },
82
+ progress: {
83
+ total: 5,
84
+ passed: 2,
85
+ failed: 1,
86
+ paused: 0,
87
+ blocked: 0,
88
+ pending: 2,
89
+ ...overrides.progress,
90
+ },
91
+ cost: {
92
+ spent: 0.5678,
93
+ limit: null,
94
+ ...overrides.cost,
95
+ },
96
+ current: {
97
+ storyId: "US-002",
98
+ title: "Test current story",
99
+ complexity: "medium",
100
+ tddStrategy: "red-green-refactor",
101
+ model: "claude-opus",
102
+ attempt: 1,
103
+ phase: "implementation",
104
+ },
105
+ iterations: 10,
106
+ updatedAt: "2026-01-01T10:30:00.000Z",
107
+ durationMs: 1800000,
108
+ ...overrides,
109
+ };
110
+ }
111
+
112
+ describe("AC1: Shows project-level current run info at top", () => {
113
+ test("displays current run info when active run exists in nax/status.json", async () => {
114
+ // Setup: Create project with feature and project-level status
115
+ const naxDir = join(testDir, "nax");
116
+ const featuresDir = join(naxDir, "features");
117
+ mkdirSync(featuresDir, { recursive: true });
118
+ writeFileSync(join(naxDir, "config.json"), "{}");
119
+
120
+ const featureDir = join(featuresDir, "current-feature");
121
+ mkdirSync(featureDir, { recursive: true });
122
+ const prd = createTestPRD("current-feature");
123
+ writeFileSync(join(featureDir, "prd.json"), JSON.stringify(prd, null, 2));
124
+
125
+ // Create project-level status.json
126
+ const projectStatus = createProjectStatus("current-feature", {
127
+ run: {
128
+ id: "run-2026-01-01T00-00-00-000Z",
129
+ feature: "current-feature",
130
+ startedAt: "2026-01-01T10:00:00.000Z",
131
+ status: "running",
132
+ dryRun: false,
133
+ pid: process.pid,
134
+ },
135
+ });
136
+ mkdirSync(join(naxDir), { recursive: true });
137
+ writeFileSync(join(naxDir, "status.json"), JSON.stringify(projectStatus, null, 2));
138
+
139
+ // Act
140
+ await displayFeatureStatus({ dir: testDir });
141
+
142
+ // Assert
143
+ const output = consoleOutput.join("\n");
144
+ expect(output).toContain("⚡ Currently Running:");
145
+ expect(output).toContain("current-feature");
146
+ expect(output).toContain("run-2026-01-01T00-00-00-000Z");
147
+ expect(output).toContain("2026-01-01T10:00:00.000Z");
148
+ expect(output).toContain("2/5 stories");
149
+ expect(output).toContain("$0.5678");
150
+ expect(output).toContain("US-002");
151
+ expect(output).toContain("Test current story");
152
+ });
153
+
154
+ test("does not show current run info when nax/status.json missing", async () => {
155
+ // Setup: Create project with feature but no project-level status
156
+ const naxDir = join(testDir, "nax");
157
+ const featuresDir = join(naxDir, "features");
158
+ mkdirSync(featuresDir, { recursive: true });
159
+ writeFileSync(join(naxDir, "config.json"), "{}");
160
+
161
+ const featureDir = join(featuresDir, "no-status-feature");
162
+ mkdirSync(featureDir, { recursive: true });
163
+ const prd = createTestPRD("no-status-feature");
164
+ writeFileSync(join(featureDir, "prd.json"), JSON.stringify(prd, null, 2));
165
+
166
+ // Act (no status.json created)
167
+ await displayFeatureStatus({ dir: testDir });
168
+
169
+ // Assert
170
+ const output = consoleOutput.join("\n");
171
+ expect(output).not.toContain("⚡ Currently Running:");
172
+ });
173
+
174
+ test("shows crashed run detected when PID is dead", async () => {
175
+ // Setup: Create project with feature and crashed status
176
+ const naxDir = join(testDir, "nax");
177
+ const featuresDir = join(naxDir, "features");
178
+ mkdirSync(featuresDir, { recursive: true });
179
+ writeFileSync(join(naxDir, "config.json"), "{}");
180
+
181
+ const featureDir = join(featuresDir, "crashed-feature");
182
+ mkdirSync(featureDir, { recursive: true });
183
+ const prd = createTestPRD("crashed-feature");
184
+ writeFileSync(join(featureDir, "prd.json"), JSON.stringify(prd, null, 2));
185
+
186
+ // Create project-level status.json with dead PID
187
+ const projectStatus = createProjectStatus("crashed-feature", {
188
+ run: {
189
+ id: "run-2026-01-01T00-00-00-000Z",
190
+ feature: "crashed-feature",
191
+ startedAt: "2026-01-01T10:00:00.000Z",
192
+ status: "running",
193
+ dryRun: false,
194
+ pid: 999999, // Non-existent PID
195
+ },
196
+ });
197
+ writeFileSync(join(naxDir, "status.json"), JSON.stringify(projectStatus, null, 2));
198
+
199
+ // Act
200
+ await displayFeatureStatus({ dir: testDir });
201
+
202
+ // Assert
203
+ const output = consoleOutput.join("\n");
204
+ expect(output).toContain("💥 Crashed Run Detected:");
205
+ expect(output).toContain("999999");
206
+ expect(output).toContain("dead");
207
+ });
208
+
209
+ test("shows crash info when run status is 'crashed'", async () => {
210
+ // Setup: Create project with feature and crashed status
211
+ const naxDir = join(testDir, "nax");
212
+ const featuresDir = join(naxDir, "features");
213
+ mkdirSync(featuresDir, { recursive: true });
214
+ writeFileSync(join(naxDir, "config.json"), "{}");
215
+
216
+ const featureDir = join(featuresDir, "crashed-feature");
217
+ mkdirSync(featureDir, { recursive: true });
218
+ const prd = createTestPRD("crashed-feature");
219
+ writeFileSync(join(featureDir, "prd.json"), JSON.stringify(prd, null, 2));
220
+
221
+ // Create project-level status.json with crashed status
222
+ const projectStatus = createProjectStatus("crashed-feature", {
223
+ run: {
224
+ id: "run-2026-01-01T00-00-00-000Z",
225
+ feature: "crashed-feature",
226
+ startedAt: "2026-01-01T10:00:00.000Z",
227
+ status: "crashed",
228
+ dryRun: false,
229
+ pid: 12345,
230
+ crashedAt: "2026-01-01T10:15:00.000Z",
231
+ crashSignal: "SIGKILL",
232
+ },
233
+ });
234
+ writeFileSync(join(naxDir, "status.json"), JSON.stringify(projectStatus, null, 2));
235
+
236
+ // Act
237
+ await displayFeatureStatus({ dir: testDir });
238
+
239
+ // Assert
240
+ const output = consoleOutput.join("\n");
241
+ expect(output).toContain("💥 Crashed Run Detected:");
242
+ expect(output).toContain("SIGKILL");
243
+ expect(output).toContain("2026-01-01T10:15:00.000Z");
244
+ });
245
+ });
246
+
247
+ describe("AC2: Shows per-feature historical status", () => {
248
+ test("shows feature-level status from nax/features/<feature>/status.json", async () => {
249
+ // Setup: Create project with feature and feature-level status
250
+ const naxDir = join(testDir, "nax");
251
+ const featuresDir = join(naxDir, "features");
252
+ mkdirSync(featuresDir, { recursive: true });
253
+ writeFileSync(join(naxDir, "config.json"), "{}");
254
+
255
+ const featureDir = join(featuresDir, "test-feature");
256
+ mkdirSync(featureDir, { recursive: true });
257
+ const prd = createTestPRD("test-feature");
258
+ writeFileSync(join(featureDir, "prd.json"), JSON.stringify(prd, null, 2));
259
+
260
+ // Create feature-level status.json
261
+ const featureStatus = createProjectStatus("test-feature", {
262
+ run: {
263
+ id: "run-feature-level",
264
+ feature: "test-feature",
265
+ startedAt: "2026-01-01T09:00:00.000Z",
266
+ status: "completed",
267
+ dryRun: false,
268
+ pid: 12345,
269
+ },
270
+ });
271
+ writeFileSync(join(featureDir, "status.json"), JSON.stringify(featureStatus, null, 2));
272
+
273
+ // Act
274
+ await displayFeatureStatus({ dir: testDir, feature: "test-feature" });
275
+
276
+ // Assert
277
+ const output = consoleOutput.join("\n");
278
+ // Feature details view should show the feature-level status
279
+ expect(output).toContain("test-feature");
280
+ });
281
+ });
282
+
283
+ });
@@ -16,7 +16,7 @@
16
16
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
17
17
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
- import { type LogsOptions, logsCommand } from "../../../src/commands/logs";
19
+ import { type LogsOptions, _deps, logsCommand } from "../../../src/commands/logs";
20
20
 
21
21
  const TEST_WORKSPACE = join(import.meta.dir, "..", "..", "tmp", "logs-test");
22
22
 
@@ -110,13 +110,43 @@ function cleanup(projectDir: string) {
110
110
 
111
111
  describe("logsCommand", () => {
112
112
  let projectDir: string;
113
+ let registryDir: string;
114
+ let originalGetRunsDir: () => string;
113
115
 
114
116
  beforeEach(() => {
115
117
  projectDir = setupTestProject("test-feature");
118
+
119
+ // Set up a temp registry dir and override _deps
120
+ registryDir = join(TEST_WORKSPACE, `registry-${Date.now()}`);
121
+ mkdirSync(registryDir, { recursive: true });
122
+ originalGetRunsDir = _deps.getRunsDir;
123
+ _deps.getRunsDir = () => registryDir;
124
+
125
+ // Create registry entries pointing to the test runs
126
+ const runsDir = join(projectDir, "nax", "features", "test-feature", "runs");
127
+
128
+ for (const runId of ["2026-02-27T10-00-00", "2026-02-26T09-00-00"]) {
129
+ const entryDir = join(registryDir, `testproject-test-feature-${runId}`);
130
+ mkdirSync(entryDir, { recursive: true });
131
+ writeFileSync(
132
+ join(entryDir, "meta.json"),
133
+ JSON.stringify({
134
+ runId,
135
+ project: "testproject",
136
+ feature: "test-feature",
137
+ workdir: projectDir,
138
+ statusPath: join(projectDir, "nax", "features", "test-feature", "status.json"),
139
+ eventsDir: runsDir,
140
+ registeredAt: "2026-02-27T10:00:00.000Z",
141
+ }),
142
+ );
143
+ }
116
144
  });
117
145
 
118
146
  afterEach(() => {
147
+ _deps.getRunsDir = originalGetRunsDir;
119
148
  cleanup(projectDir);
149
+ cleanup(registryDir);
120
150
  });
121
151
 
122
152
  describe("default behavior (latest run formatted)", () => {
@@ -259,33 +289,46 @@ describe("logsCommand", () => {
259
289
  });
260
290
  });
261
291
 
262
- describe("--run (specific run selection)", () => {
263
- test("displays specific run by timestamp", async () => {
264
- const options: LogsOptions = {
265
- dir: projectDir,
266
- run: "2026-02-26T09-00-00",
267
- };
292
+ describe("--run (registry-based run selection)", () => {
293
+ test("displays run resolved from central registry by exact runId", async () => {
294
+ const options: LogsOptions = { run: "2026-02-26T09-00-00" };
268
295
 
269
- // Should display the older run
270
296
  await expect(logsCommand(options)).resolves.toBeUndefined();
271
297
  });
272
298
 
273
- test("throws when specified run does not exist", async () => {
274
- const options: LogsOptions = {
275
- dir: projectDir,
276
- run: "2026-01-01T00-00-00",
277
- };
299
+ test("displays run resolved from central registry by prefix match", async () => {
300
+ const options: LogsOptions = { run: "2026-02-26" };
278
301
 
279
- await expect(logsCommand(options)).rejects.toThrow(/run not found/i);
302
+ // Should match "2026-02-26T09-00-00"
303
+ await expect(logsCommand(options)).resolves.toBeUndefined();
280
304
  });
281
305
 
282
- test("works with partial timestamp matching", async () => {
283
- const options: LogsOptions = {
284
- dir: projectDir,
285
- run: "2026-02-26",
286
- };
306
+ test("throws with clear error when runId not found in registry", async () => {
307
+ const options: LogsOptions = { run: "2026-01-01T00-00-00" };
287
308
 
288
- // Should match "2026-02-26T09-00-00"
309
+ await expect(logsCommand(options)).rejects.toThrow(/run not found in registry/i);
310
+ });
311
+
312
+ test("shows unavailable message when eventsDir does not exist", async () => {
313
+ // Add a registry entry pointing to a non-existent eventsDir
314
+ const entryDir = join(registryDir, "proj-feat-ghost-run");
315
+ mkdirSync(entryDir, { recursive: true });
316
+ writeFileSync(
317
+ join(entryDir, "meta.json"),
318
+ JSON.stringify({
319
+ runId: "ghost-run",
320
+ project: "proj",
321
+ feature: "feat",
322
+ workdir: "/nonexistent",
323
+ statusPath: "/nonexistent/nax/features/feat/status.json",
324
+ eventsDir: "/nonexistent/nax/features/feat/runs",
325
+ registeredAt: "2026-01-01T00:00:00.000Z",
326
+ }),
327
+ );
328
+
329
+ const options: LogsOptions = { run: "ghost-run" };
330
+
331
+ // Should resolve without throwing — prints unavailable message
289
332
  await expect(logsCommand(options)).resolves.toBeUndefined();
290
333
  });
291
334
  });
@@ -350,7 +393,6 @@ describe("logsCommand", () => {
350
393
 
351
394
  test("--run + --story + --level", async () => {
352
395
  const options: LogsOptions = {
353
- dir: projectDir,
354
396
  run: "2026-02-27T10-00-00",
355
397
  story: "US-001",
356
398
  level: "info",
@@ -361,7 +403,6 @@ describe("logsCommand", () => {
361
403
 
362
404
  test("all filters combined", async () => {
363
405
  const options: LogsOptions = {
364
- dir: projectDir,
365
406
  run: "2026-02-27T10-00-00",
366
407
  story: "US-001",
367
408
  level: "info",