@nathapp/nax 0.22.3 → 0.23.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.
- package/README.md +21 -2
- package/docs/ROADMAP.md +6 -0
- package/docs/specs/central-run-registry.md +13 -1
- package/docs/tdd/strategies.md +97 -0
- package/nax/config.json +4 -3
- package/nax/features/diagnose/acceptance.test.ts +3 -1
- package/nax/features/status-file-consolidation/prd.json +52 -7
- package/nax/status.json +17 -8
- package/package.json +4 -4
- package/src/cli/diagnose.ts +1 -1
- package/src/cli/status-features.ts +55 -7
- package/src/config/schemas.ts +3 -0
- package/src/config/types.ts +2 -0
- package/src/execution/crash-recovery.ts +30 -7
- package/src/execution/lifecycle/run-setup.ts +6 -1
- package/src/execution/runner.ts +8 -0
- package/src/execution/status-writer.ts +42 -0
- package/src/pipeline/stages/verify.ts +21 -2
- package/src/verification/orchestrator-types.ts +2 -0
- package/src/verification/smart-runner.ts +5 -2
- package/src/verification/strategies/scoped.ts +9 -2
- package/src/verification/types.ts +2 -0
- package/src/version.ts +23 -0
- package/test/e2e/plan-analyze-run.test.ts +5 -0
- package/test/integration/cli/cli-diagnose.test.ts +3 -1
- package/test/integration/execution/feature-status-write.test.ts +302 -0
- package/test/integration/execution/status-file-integration.test.ts +1 -1
- package/test/integration/execution/status-writer.test.ts +112 -0
- package/test/unit/cli-status-project-level.test.ts +283 -0
- package/test/unit/config/quality-commands-schema.test.ts +72 -0
- package/test/unit/execution/sfc-004-dead-code-cleanup.test.ts +89 -0
- package/test/unit/verification/smart-runner.test.ts +16 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* write failure counter. Provides atomic status file writes via writeStatusFile.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { join } from "node:path";
|
|
9
10
|
import type { NaxConfig } from "../config";
|
|
10
11
|
import { getSafeLogger } from "../logger";
|
|
11
12
|
import type { PRD } from "../prd";
|
|
@@ -136,4 +137,45 @@ export class StatusWriter {
|
|
|
136
137
|
});
|
|
137
138
|
}
|
|
138
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Write the current status snapshot to feature-level status.json file.
|
|
143
|
+
*
|
|
144
|
+
* Called on run completion, failure, or crash to persist the final state
|
|
145
|
+
* to <featureDir>/status.json. Uses the same NaxStatusFile schema as
|
|
146
|
+
* the project-level status file.
|
|
147
|
+
*
|
|
148
|
+
* No-ops if _prd has not been set.
|
|
149
|
+
* On failure, logs a warning/error but does not throw (non-fatal).
|
|
150
|
+
*
|
|
151
|
+
* @param featureDir - Feature directory (e.g., nax/features/auth-system)
|
|
152
|
+
* @param totalCost - Accumulated cost at this write point
|
|
153
|
+
* @param iterations - Loop iteration count at this write point
|
|
154
|
+
* @param overrides - Optional partial snapshot overrides (spread last)
|
|
155
|
+
*/
|
|
156
|
+
async writeFeatureStatus(
|
|
157
|
+
featureDir: string,
|
|
158
|
+
totalCost: number,
|
|
159
|
+
iterations: number,
|
|
160
|
+
overrides: Partial<RunStateSnapshot> = {},
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
if (!this._prd) return;
|
|
163
|
+
const safeLogger = getSafeLogger();
|
|
164
|
+
const featureStatusPath = join(featureDir, "status.json");
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const base = this.getSnapshot(totalCost, iterations);
|
|
168
|
+
if (!base) {
|
|
169
|
+
throw new Error("Failed to get snapshot");
|
|
170
|
+
}
|
|
171
|
+
const state: RunStateSnapshot = { ...base, ...overrides };
|
|
172
|
+
await writeStatusFile(featureStatusPath, buildStatusSnapshot(state));
|
|
173
|
+
safeLogger?.debug("status-file", "Feature status written", { path: featureStatusPath });
|
|
174
|
+
} catch (err) {
|
|
175
|
+
safeLogger?.warn("status-file", "Failed to write feature status file (non-fatal)", {
|
|
176
|
+
path: featureStatusPath,
|
|
177
|
+
error: (err as Error).message,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
139
181
|
}
|
|
@@ -31,6 +31,18 @@ function coerceSmartTestRunner(val: boolean | SmartTestRunnerConfig | undefined)
|
|
|
31
31
|
return val;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Build the scoped test command from discovered test files.
|
|
36
|
+
* Uses the testScoped template (with {{files}} placeholder) if configured,
|
|
37
|
+
* otherwise falls back to buildSmartTestCommand heuristic.
|
|
38
|
+
*/
|
|
39
|
+
function buildScopedCommand(testFiles: string[], baseCommand: string, testScopedTemplate?: string): string {
|
|
40
|
+
if (testScopedTemplate) {
|
|
41
|
+
return testScopedTemplate.replace("{{files}}", testFiles.join(" "));
|
|
42
|
+
}
|
|
43
|
+
return _smartRunnerDeps.buildSmartTestCommand(testFiles, baseCommand);
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
export const verifyStage: PipelineStage = {
|
|
35
47
|
name: "verify",
|
|
36
48
|
enabled: () => true,
|
|
@@ -46,6 +58,7 @@ export const verifyStage: PipelineStage = {
|
|
|
46
58
|
|
|
47
59
|
// Skip verification if no test command is configured
|
|
48
60
|
const testCommand = ctx.config.review?.commands?.test ?? ctx.config.quality.commands.test;
|
|
61
|
+
const testScopedTemplate = ctx.config.quality.commands.testScoped;
|
|
49
62
|
if (!testCommand) {
|
|
50
63
|
logger.debug("verify", "Skipping verification (no test command configured)", { storyId: ctx.story.id });
|
|
51
64
|
return { action: "continue" };
|
|
@@ -68,7 +81,7 @@ export const verifyStage: PipelineStage = {
|
|
|
68
81
|
logger.info("verify", `[smart-runner] Pass 1: path convention matched ${pass1Files.length} test files`, {
|
|
69
82
|
storyId: ctx.story.id,
|
|
70
83
|
});
|
|
71
|
-
effectiveCommand =
|
|
84
|
+
effectiveCommand = buildScopedCommand(pass1Files, testCommand, testScopedTemplate);
|
|
72
85
|
isFullSuite = false;
|
|
73
86
|
} else if (smartRunnerConfig.fallback === "import-grep") {
|
|
74
87
|
// Pass 2: import-grep fallback
|
|
@@ -81,7 +94,7 @@ export const verifyStage: PipelineStage = {
|
|
|
81
94
|
logger.info("verify", `[smart-runner] Pass 2: import-grep matched ${pass2Files.length} test files`, {
|
|
82
95
|
storyId: ctx.story.id,
|
|
83
96
|
});
|
|
84
|
-
effectiveCommand =
|
|
97
|
+
effectiveCommand = buildScopedCommand(pass2Files, testCommand, testScopedTemplate);
|
|
85
98
|
isFullSuite = false;
|
|
86
99
|
}
|
|
87
100
|
}
|
|
@@ -102,6 +115,12 @@ export const verifyStage: PipelineStage = {
|
|
|
102
115
|
});
|
|
103
116
|
}
|
|
104
117
|
|
|
118
|
+
// BUG-044: Log the effective command for observability
|
|
119
|
+
logger.info("verify", isFullSuite ? "Running full suite" : "Running scoped tests", {
|
|
120
|
+
storyId: ctx.story.id,
|
|
121
|
+
command: effectiveCommand,
|
|
122
|
+
});
|
|
123
|
+
|
|
105
124
|
// Use unified regression gate (includes 2s wait for agent process cleanup)
|
|
106
125
|
const result = await _verifyDeps.regression({
|
|
107
126
|
workdir: ctx.workdir,
|
|
@@ -23,6 +23,8 @@ export type VerifyStrategy = "scoped" | "regression" | "deferred-regression" | "
|
|
|
23
23
|
export interface VerifyContext {
|
|
24
24
|
workdir: string;
|
|
25
25
|
testCommand: string;
|
|
26
|
+
/** Scoped test command template with {{files}} placeholder — overrides buildSmartTestCommand heuristic */
|
|
27
|
+
testScopedTemplate?: string;
|
|
26
28
|
timeoutSeconds: number;
|
|
27
29
|
storyId: string;
|
|
28
30
|
storyGitRef?: string;
|
|
@@ -174,8 +174,11 @@ export function buildSmartTestCommand(testFiles: string[], baseCommand: string):
|
|
|
174
174
|
return `${baseCommand} ${testFiles.join(" ")}`;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
// Replace the last path argument with the specific test files
|
|
178
|
-
|
|
177
|
+
// Replace the last path argument with the specific test files,
|
|
178
|
+
// preserving any flags that appear after the path (e.g. --timeout=60000).
|
|
179
|
+
const beforePath = parts.slice(0, lastPathIndex);
|
|
180
|
+
const afterPath = parts.slice(lastPathIndex + 1);
|
|
181
|
+
const newParts = [...beforePath, ...testFiles, ...afterPath];
|
|
179
182
|
return newParts.join(" ");
|
|
180
183
|
}
|
|
181
184
|
|
|
@@ -29,6 +29,13 @@ function coerceSmartRunner(val: unknown) {
|
|
|
29
29
|
return val as typeof DEFAULT_SMART_RUNNER_CONFIG;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function buildScopedCommand(testFiles: string[], baseCommand: string, testScopedTemplate?: string): string {
|
|
33
|
+
if (testScopedTemplate) {
|
|
34
|
+
return testScopedTemplate.replace("{{files}}", testFiles.join(" "));
|
|
35
|
+
}
|
|
36
|
+
return _scopedDeps.buildSmartTestCommand(testFiles, baseCommand);
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
export class ScopedStrategy implements IVerificationStrategy {
|
|
33
40
|
readonly name = "scoped" as const;
|
|
34
41
|
|
|
@@ -48,7 +55,7 @@ export class ScopedStrategy implements IVerificationStrategy {
|
|
|
48
55
|
logger.info("verify[scoped]", `Pass 1: path convention matched ${pass1Files.length} test files`, {
|
|
49
56
|
storyId: ctx.storyId,
|
|
50
57
|
});
|
|
51
|
-
effectiveCommand =
|
|
58
|
+
effectiveCommand = buildScopedCommand(pass1Files, ctx.testCommand, ctx.testScopedTemplate);
|
|
52
59
|
isFullSuite = false;
|
|
53
60
|
} else if (smartCfg.fallback === "import-grep") {
|
|
54
61
|
const pass2Files = await _scopedDeps.importGrepFallback(sourceFiles, ctx.workdir, smartCfg.testFilePatterns);
|
|
@@ -56,7 +63,7 @@ export class ScopedStrategy implements IVerificationStrategy {
|
|
|
56
63
|
logger.info("verify[scoped]", `Pass 2: import-grep matched ${pass2Files.length} test files`, {
|
|
57
64
|
storyId: ctx.storyId,
|
|
58
65
|
});
|
|
59
|
-
effectiveCommand =
|
|
66
|
+
effectiveCommand = buildScopedCommand(pass2Files, ctx.testCommand, ctx.testScopedTemplate);
|
|
60
67
|
isFullSuite = false;
|
|
61
68
|
}
|
|
62
69
|
}
|
|
@@ -112,4 +112,6 @@ export interface VerificationGateOptions {
|
|
|
112
112
|
acceptOnTimeout?: boolean;
|
|
113
113
|
/** Scoped test paths (for scoped verification) */
|
|
114
114
|
scopedTestPaths?: string[];
|
|
115
|
+
/** Scoped test command template with {{files}} placeholder — overrides buildSmartTestCommand heuristic */
|
|
116
|
+
testScopedTemplate?: string;
|
|
115
117
|
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version and build info for nax.
|
|
3
|
+
*
|
|
4
|
+
* GIT_COMMIT is injected at build time via --define in the bun build script.
|
|
5
|
+
* When running from source (bun run dev), it falls back to "dev".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import pkg from "../package.json";
|
|
9
|
+
|
|
10
|
+
declare const GIT_COMMIT: string;
|
|
11
|
+
|
|
12
|
+
export const NAX_VERSION: string = pkg.version;
|
|
13
|
+
|
|
14
|
+
/** Short git commit hash, injected at build time. Falls back to "dev" from source. */
|
|
15
|
+
export const NAX_COMMIT: string = (() => {
|
|
16
|
+
try {
|
|
17
|
+
return GIT_COMMIT ?? "dev";
|
|
18
|
+
} catch {
|
|
19
|
+
return "dev";
|
|
20
|
+
}
|
|
21
|
+
})();
|
|
22
|
+
|
|
23
|
+
export const NAX_BUILD_INFO = `v${NAX_VERSION} (${NAX_COMMIT})`;
|
|
@@ -402,6 +402,7 @@ describe("E2E: plan → analyze → run workflow", () => {
|
|
|
402
402
|
featureDir,
|
|
403
403
|
dryRun: false,
|
|
404
404
|
useBatch: true, // Enable batching
|
|
405
|
+
statusFile: join(testDir, "nax", "status.json"),
|
|
405
406
|
skipPrecheck: true, // Skip precheck for E2E test (no git repo in temp dir)
|
|
406
407
|
});
|
|
407
408
|
|
|
@@ -479,6 +480,7 @@ describe("E2E: plan → analyze → run workflow", () => {
|
|
|
479
480
|
feature: "simple-task",
|
|
480
481
|
featureDir,
|
|
481
482
|
dryRun: false,
|
|
483
|
+
statusFile: join(testDir, "nax", "status.json"),
|
|
482
484
|
skipPrecheck: true, // Skip precheck for E2E test (no git repo in temp dir)
|
|
483
485
|
});
|
|
484
486
|
|
|
@@ -560,6 +562,7 @@ describe("E2E: plan → analyze → run workflow", () => {
|
|
|
560
562
|
feature: "fail-task",
|
|
561
563
|
featureDir,
|
|
562
564
|
dryRun: false,
|
|
565
|
+
statusFile: join(testDir, "nax", "status.json"),
|
|
563
566
|
skipPrecheck: true, // Skip precheck for E2E test (no git repo in temp dir)
|
|
564
567
|
});
|
|
565
568
|
|
|
@@ -623,6 +626,7 @@ describe("E2E: plan → analyze → run workflow", () => {
|
|
|
623
626
|
feature: "rate-limit-task",
|
|
624
627
|
featureDir,
|
|
625
628
|
dryRun: false,
|
|
629
|
+
statusFile: join(testDir, "nax", "status.json"),
|
|
626
630
|
skipPrecheck: true, // Skip precheck for E2E test (no git repo in temp dir)
|
|
627
631
|
});
|
|
628
632
|
|
|
@@ -729,6 +733,7 @@ describe("E2E: plan → analyze → run workflow", () => {
|
|
|
729
733
|
featureDir,
|
|
730
734
|
dryRun: false,
|
|
731
735
|
useBatch: true,
|
|
736
|
+
statusFile: join(testDir, "nax", "status.json"),
|
|
732
737
|
skipPrecheck: true, // Skip precheck for E2E test (no git repo in temp dir)
|
|
733
738
|
});
|
|
734
739
|
|
|
@@ -102,7 +102,9 @@ async function createStatusFile(dir: string, feature: string, overrides: Partial
|
|
|
102
102
|
...overrides,
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
// Ensure nax directory exists
|
|
106
|
+
mkdirSync(join(dir, "nax"), { recursive: true });
|
|
107
|
+
await Bun.write(join(dir, "nax", "status.json"), JSON.stringify(status, null, 2));
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
/**
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature-Level Status File Writing Tests (SFC-002)
|
|
3
|
+
*
|
|
4
|
+
* Tests for writing feature-level status.json files on run end.
|
|
5
|
+
* Verifies all three acceptance criteria:
|
|
6
|
+
* - Status 'completed' after successful run
|
|
7
|
+
* - Status 'failed' after unsuccessful run
|
|
8
|
+
* - Status 'crashed' after crash (simulated)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import type { NaxConfig } from "../../../src/config";
|
|
17
|
+
import type { NaxStatusFile } from "../../../src/execution/status-file";
|
|
18
|
+
import { StatusWriter, type StatusWriterContext } from "../../../src/execution/status-writer";
|
|
19
|
+
import type { PRD, UserStory } from "../../../src/prd";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Helpers
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
function makeStory(id: string, status: UserStory["status"] = "pending"): UserStory {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
title: `Story ${id}`,
|
|
29
|
+
description: `Description for ${id}`,
|
|
30
|
+
acceptanceCriteria: ["AC-1"],
|
|
31
|
+
tags: [],
|
|
32
|
+
dependencies: [],
|
|
33
|
+
status,
|
|
34
|
+
passes: status === "passed",
|
|
35
|
+
escalations: [],
|
|
36
|
+
attempts: 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makePrd(count = 1, storyStatus: UserStory["status"] = "pending"): PRD {
|
|
41
|
+
return {
|
|
42
|
+
project: "test-project",
|
|
43
|
+
feature: "test-feature",
|
|
44
|
+
branchName: "feat/test",
|
|
45
|
+
createdAt: "2026-02-25T10:00:00.000Z",
|
|
46
|
+
updatedAt: "2026-02-25T10:00:00.000Z",
|
|
47
|
+
userStories: Array.from({ length: count }, (_, i) => makeStory(`US-00${i + 1}`, storyStatus)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeConfig(costLimit = 5.0): NaxConfig {
|
|
52
|
+
return {
|
|
53
|
+
execution: {
|
|
54
|
+
costLimit,
|
|
55
|
+
maxIterations: 10,
|
|
56
|
+
maxStoriesPerFeature: 50,
|
|
57
|
+
iterationDelayMs: 0,
|
|
58
|
+
},
|
|
59
|
+
} as unknown as NaxConfig;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeCtx(overrides: Partial<StatusWriterContext> = {}): StatusWriterContext {
|
|
63
|
+
return {
|
|
64
|
+
runId: "run-test-001",
|
|
65
|
+
feature: "auth-feature",
|
|
66
|
+
startedAt: "2026-02-25T10:00:00.000Z",
|
|
67
|
+
dryRun: false,
|
|
68
|
+
startTimeMs: Date.now() - 1000,
|
|
69
|
+
pid: process.pid,
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readFeatureStatus(featureDir: string): NaxStatusFile {
|
|
75
|
+
const path = join(featureDir, "status.json");
|
|
76
|
+
const content = readFileSync(path, "utf8");
|
|
77
|
+
return JSON.parse(content) as NaxStatusFile;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// Acceptance Criteria Tests
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
describe("SFC-002: Feature-level status writing — Acceptance Criteria", () => {
|
|
85
|
+
let tmpDir: string;
|
|
86
|
+
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
tmpDir = await mkdtemp(join(tmpdir(), "sfc-002-test-"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── AC-1: After a completed run, status is 'completed' ─────────────────
|
|
96
|
+
test("AC-1: After completed run, feature status.json has status 'completed'", async () => {
|
|
97
|
+
const featureDir = join(tmpDir, "nax", "features", "auth-system");
|
|
98
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
99
|
+
|
|
100
|
+
// Simulate successful run: all stories completed
|
|
101
|
+
const prd = makePrd(3, "passed");
|
|
102
|
+
sw.setPrd(prd);
|
|
103
|
+
sw.setRunStatus("completed");
|
|
104
|
+
|
|
105
|
+
// Write feature-level status
|
|
106
|
+
await sw.writeFeatureStatus(featureDir, 2.5, 5);
|
|
107
|
+
|
|
108
|
+
// Verify status.json exists in feature directory
|
|
109
|
+
const statusPath = join(featureDir, "status.json");
|
|
110
|
+
expect(existsSync(statusPath)).toBe(true);
|
|
111
|
+
|
|
112
|
+
// Verify status is 'completed'
|
|
113
|
+
const status = readFeatureStatus(featureDir);
|
|
114
|
+
expect(status.run.status).toBe("completed");
|
|
115
|
+
expect(status.progress.total).toBe(3);
|
|
116
|
+
expect(status.progress.passed).toBe(3);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── AC-2: After a failed run, status is 'failed' ───────────────────────
|
|
120
|
+
test("AC-2: After failed run, feature status.json has status 'failed'", async () => {
|
|
121
|
+
const featureDir = join(tmpDir, "nax", "features", "auth-system");
|
|
122
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
123
|
+
|
|
124
|
+
// Simulate failed run: some stories failed
|
|
125
|
+
const prd = makePrd(3);
|
|
126
|
+
prd.userStories[0].status = "passed";
|
|
127
|
+
prd.userStories[1].status = "failed";
|
|
128
|
+
prd.userStories[2].status = "pending";
|
|
129
|
+
|
|
130
|
+
sw.setPrd(prd);
|
|
131
|
+
sw.setRunStatus("failed");
|
|
132
|
+
|
|
133
|
+
// Write feature-level status
|
|
134
|
+
await sw.writeFeatureStatus(featureDir, 1.0, 3);
|
|
135
|
+
|
|
136
|
+
// Verify status is 'failed'
|
|
137
|
+
const status = readFeatureStatus(featureDir);
|
|
138
|
+
expect(status.run.status).toBe("failed");
|
|
139
|
+
expect(status.progress.passed).toBe(1);
|
|
140
|
+
expect(status.progress.failed).toBe(1);
|
|
141
|
+
expect(status.progress.pending).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── AC-3: After a crash, status is 'crashed' ───────────────────────────
|
|
145
|
+
test("AC-3: After crash, feature status.json has status 'crashed' with crash metadata", async () => {
|
|
146
|
+
const featureDir = join(tmpDir, "nax", "features", "auth-system");
|
|
147
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
148
|
+
|
|
149
|
+
const prd = makePrd(2);
|
|
150
|
+
sw.setPrd(prd);
|
|
151
|
+
sw.setRunStatus("crashed");
|
|
152
|
+
|
|
153
|
+
const crashTime = new Date().toISOString();
|
|
154
|
+
await sw.writeFeatureStatus(featureDir, 0.5, 1, {
|
|
155
|
+
crashedAt: crashTime,
|
|
156
|
+
crashSignal: "SIGTERM",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Verify status is 'crashed' with metadata
|
|
160
|
+
const status = readFeatureStatus(featureDir);
|
|
161
|
+
expect(status.run.status).toBe("crashed");
|
|
162
|
+
expect(status.run.crashedAt).toBe(crashTime);
|
|
163
|
+
expect(status.run.crashSignal).toBe("SIGTERM");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── AC-4: Uses same NaxStatusFile schema as project-level ──────────────
|
|
167
|
+
test("AC-4: Feature status.json uses same NaxStatusFile schema as project-level", async () => {
|
|
168
|
+
const projectStatusPath = join(tmpDir, "status.json");
|
|
169
|
+
const featureDir = join(tmpDir, "nax", "features", "auth-system");
|
|
170
|
+
|
|
171
|
+
const sw = new StatusWriter(projectStatusPath, makeConfig(), makeCtx());
|
|
172
|
+
const prd = makePrd(2, "passed");
|
|
173
|
+
sw.setPrd(prd);
|
|
174
|
+
sw.setRunStatus("completed");
|
|
175
|
+
|
|
176
|
+
// Write both project and feature status
|
|
177
|
+
await sw.update(2.0, 4);
|
|
178
|
+
await sw.writeFeatureStatus(featureDir, 2.0, 4);
|
|
179
|
+
|
|
180
|
+
// Read both files
|
|
181
|
+
const projectStatus = JSON.parse(readFileSync(projectStatusPath, "utf8")) as NaxStatusFile;
|
|
182
|
+
const featureStatus = readFeatureStatus(featureDir);
|
|
183
|
+
|
|
184
|
+
// Verify same schema structure and version
|
|
185
|
+
expect(projectStatus.version).toBe(1);
|
|
186
|
+
expect(featureStatus.version).toBe(1);
|
|
187
|
+
|
|
188
|
+
// Verify key fields are present in both
|
|
189
|
+
expect(projectStatus.run).toBeDefined();
|
|
190
|
+
expect(featureStatus.run).toBeDefined();
|
|
191
|
+
expect(projectStatus.progress).toBeDefined();
|
|
192
|
+
expect(featureStatus.progress).toBeDefined();
|
|
193
|
+
expect(projectStatus.cost).toBeDefined();
|
|
194
|
+
expect(featureStatus.cost).toBeDefined();
|
|
195
|
+
expect(projectStatus.iterations).toBeDefined();
|
|
196
|
+
expect(featureStatus.iterations).toBeDefined();
|
|
197
|
+
|
|
198
|
+
// Verify status values match
|
|
199
|
+
expect(projectStatus.run.status).toBe(featureStatus.run.status);
|
|
200
|
+
expect(projectStatus.cost.spent).toBe(featureStatus.cost.spent);
|
|
201
|
+
expect(projectStatus.progress.total).toBe(featureStatus.progress.total);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// Edge Cases and Error Handling
|
|
207
|
+
// ============================================================================
|
|
208
|
+
|
|
209
|
+
describe("Feature status writing — edge cases", () => {
|
|
210
|
+
let tmpDir: string;
|
|
211
|
+
|
|
212
|
+
beforeEach(async () => {
|
|
213
|
+
tmpDir = await mkdtemp(join(tmpdir(), "sfc-002-edge-"));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
afterEach(async () => {
|
|
217
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("creates nested feature directory if it doesn't exist", async () => {
|
|
221
|
+
const featureDir = join(tmpDir, "nax", "features", "deeply", "nested", "feature");
|
|
222
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
223
|
+
sw.setPrd(makePrd());
|
|
224
|
+
sw.setRunStatus("completed");
|
|
225
|
+
|
|
226
|
+
await sw.writeFeatureStatus(featureDir, 1.0, 1);
|
|
227
|
+
|
|
228
|
+
expect(existsSync(join(featureDir, "status.json"))).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("overwrites existing feature status file on subsequent writes", async () => {
|
|
232
|
+
const featureDir = join(tmpDir, "nax", "features", "auth-system");
|
|
233
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
234
|
+
sw.setPrd(makePrd());
|
|
235
|
+
|
|
236
|
+
// First write: running
|
|
237
|
+
sw.setRunStatus("running");
|
|
238
|
+
await sw.writeFeatureStatus(featureDir, 1.0, 1);
|
|
239
|
+
let status = readFeatureStatus(featureDir);
|
|
240
|
+
expect(status.run.status).toBe("running");
|
|
241
|
+
|
|
242
|
+
// Second write: completed
|
|
243
|
+
sw.setRunStatus("completed");
|
|
244
|
+
await sw.writeFeatureStatus(featureDir, 2.0, 2);
|
|
245
|
+
status = readFeatureStatus(featureDir);
|
|
246
|
+
expect(status.run.status).toBe("completed");
|
|
247
|
+
expect(status.cost.spent).toBe(2.0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("feature status includes accurate progress counts", async () => {
|
|
251
|
+
const featureDir = join(tmpDir, "nax", "features", "auth-system");
|
|
252
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
253
|
+
|
|
254
|
+
// Create PRD with mixed statuses
|
|
255
|
+
const prd = makePrd(10);
|
|
256
|
+
prd.userStories[0].status = "passed";
|
|
257
|
+
prd.userStories[1].status = "passed";
|
|
258
|
+
prd.userStories[2].status = "failed";
|
|
259
|
+
prd.userStories[3].status = "paused";
|
|
260
|
+
prd.userStories[4].status = "blocked";
|
|
261
|
+
// 5-9 remain pending
|
|
262
|
+
|
|
263
|
+
sw.setPrd(prd);
|
|
264
|
+
sw.setRunStatus("failed");
|
|
265
|
+
await sw.writeFeatureStatus(featureDir, 1.5, 2);
|
|
266
|
+
|
|
267
|
+
const status = readFeatureStatus(featureDir);
|
|
268
|
+
expect(status.progress.total).toBe(10);
|
|
269
|
+
expect(status.progress.passed).toBe(2);
|
|
270
|
+
expect(status.progress.failed).toBe(1);
|
|
271
|
+
expect(status.progress.paused).toBe(1);
|
|
272
|
+
expect(status.progress.blocked).toBe(1);
|
|
273
|
+
expect(status.progress.pending).toBe(5);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("feature status reflects cost limit from config", async () => {
|
|
277
|
+
const featureDir = join(tmpDir, "nax", "features", "auth-system");
|
|
278
|
+
const config = makeConfig(10.0); // $10 limit
|
|
279
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), config, makeCtx());
|
|
280
|
+
|
|
281
|
+
sw.setPrd(makePrd());
|
|
282
|
+
sw.setRunStatus("completed");
|
|
283
|
+
await sw.writeFeatureStatus(featureDir, 5.0, 1);
|
|
284
|
+
|
|
285
|
+
const status = readFeatureStatus(featureDir);
|
|
286
|
+
expect(status.cost.limit).toBe(10.0);
|
|
287
|
+
expect(status.cost.spent).toBe(5.0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("feature status with no cost limit shows null", async () => {
|
|
291
|
+
const featureDir = join(tmpDir, "nax", "features", "auth-system");
|
|
292
|
+
const config = makeConfig(Number.POSITIVE_INFINITY); // No limit
|
|
293
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), config, makeCtx());
|
|
294
|
+
|
|
295
|
+
sw.setPrd(makePrd());
|
|
296
|
+
sw.setRunStatus("completed");
|
|
297
|
+
await sw.writeFeatureStatus(featureDir, 3.0, 1);
|
|
298
|
+
|
|
299
|
+
const status = readFeatureStatus(featureDir);
|
|
300
|
+
expect(status.cost.limit).toBeNull();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -82,7 +82,7 @@ function createTestConfig(): NaxConfig {
|
|
|
82
82
|
return {
|
|
83
83
|
...DEFAULT_CONFIG,
|
|
84
84
|
autoMode: { ...DEFAULT_CONFIG.autoMode, defaultAgent: "mock" },
|
|
85
|
-
execution: { ...DEFAULT_CONFIG.execution, maxIterations: 20, maxStoriesPerFeature: 500 },
|
|
85
|
+
execution: { ...DEFAULT_CONFIG.execution, maxIterations: 20, maxStoriesPerFeature: 500, iterationDelayMs: 0 },
|
|
86
86
|
review: { ...DEFAULT_CONFIG.review, enabled: false },
|
|
87
87
|
acceptance: { ...DEFAULT_CONFIG.acceptance, enabled: false },
|
|
88
88
|
};
|
|
@@ -333,3 +333,115 @@ describe("StatusWriter.update BUG-2 failure counter", () => {
|
|
|
333
333
|
await rm(dir, { recursive: true, force: true });
|
|
334
334
|
});
|
|
335
335
|
});
|
|
336
|
+
|
|
337
|
+
// ============================================================================
|
|
338
|
+
// writeFeatureStatus — feature-level status writes (SFC-002)
|
|
339
|
+
// ============================================================================
|
|
340
|
+
|
|
341
|
+
describe("StatusWriter.writeFeatureStatus", () => {
|
|
342
|
+
let tmpDir: string;
|
|
343
|
+
|
|
344
|
+
beforeEach(async () => {
|
|
345
|
+
tmpDir = await mkdtemp(join(tmpdir(), "sw-feature-test-"));
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
afterEach(async () => {
|
|
349
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("no-op when prd not yet set", async () => {
|
|
353
|
+
const featureDir = join(tmpDir, "features", "auth-feature");
|
|
354
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
355
|
+
await sw.writeFeatureStatus(featureDir, 0, 0);
|
|
356
|
+
expect(existsSync(join(featureDir, "status.json"))).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("writes feature status.json in feature directory", async () => {
|
|
360
|
+
const featureDir = join(tmpDir, "features", "auth-feature");
|
|
361
|
+
const statusPath = join(featureDir, "status.json");
|
|
362
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
363
|
+
sw.setPrd(makePrd());
|
|
364
|
+
sw.setRunStatus("completed");
|
|
365
|
+
await sw.writeFeatureStatus(featureDir, 2.5, 5);
|
|
366
|
+
|
|
367
|
+
expect(existsSync(statusPath)).toBe(true);
|
|
368
|
+
const content = JSON.parse(readFileSync(statusPath, "utf8")) as NaxStatusFile;
|
|
369
|
+
expect(content.version).toBe(1);
|
|
370
|
+
expect(content.run.status).toBe("completed");
|
|
371
|
+
expect(content.cost.spent).toBe(2.5);
|
|
372
|
+
expect(content.iterations).toBe(5);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("writes feature status with 'completed' status after successful run", async () => {
|
|
376
|
+
const featureDir = join(tmpDir, "features", "auth-feature");
|
|
377
|
+
const statusPath = join(featureDir, "status.json");
|
|
378
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
379
|
+
sw.setPrd(makePrd(3));
|
|
380
|
+
sw.setRunStatus("completed");
|
|
381
|
+
await sw.writeFeatureStatus(featureDir, 1.0, 1);
|
|
382
|
+
|
|
383
|
+
const content = JSON.parse(readFileSync(statusPath, "utf8")) as NaxStatusFile;
|
|
384
|
+
expect(content.run.status).toBe("completed");
|
|
385
|
+
expect(content.progress.total).toBe(3);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("writes feature status with 'failed' status after unsuccessful run", async () => {
|
|
389
|
+
const featureDir = join(tmpDir, "features", "auth-feature");
|
|
390
|
+
const statusPath = join(featureDir, "status.json");
|
|
391
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
392
|
+
sw.setPrd(makePrd(2));
|
|
393
|
+
sw.setRunStatus("failed");
|
|
394
|
+
await sw.writeFeatureStatus(featureDir, 0.5, 2);
|
|
395
|
+
|
|
396
|
+
const content = JSON.parse(readFileSync(statusPath, "utf8")) as NaxStatusFile;
|
|
397
|
+
expect(content.run.status).toBe("failed");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("writes feature status with 'crashed' status on crash with overrides", async () => {
|
|
401
|
+
const featureDir = join(tmpDir, "features", "auth-feature");
|
|
402
|
+
const statusPath = join(featureDir, "status.json");
|
|
403
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
404
|
+
sw.setPrd(makePrd());
|
|
405
|
+
sw.setRunStatus("crashed");
|
|
406
|
+
const crashTime = new Date().toISOString();
|
|
407
|
+
await sw.writeFeatureStatus(featureDir, 1.0, 2, {
|
|
408
|
+
crashedAt: crashTime,
|
|
409
|
+
crashSignal: "SIGTERM",
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const content = JSON.parse(readFileSync(statusPath, "utf8")) as NaxStatusFile;
|
|
413
|
+
expect(content.run.status).toBe("crashed");
|
|
414
|
+
expect(content.run.crashedAt).toBe(crashTime);
|
|
415
|
+
expect(content.run.crashSignal).toBe("SIGTERM");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("fails gracefully when feature directory cannot be created", async () => {
|
|
419
|
+
const invalidFeatureDir = "/root/cannot/create/here/feature";
|
|
420
|
+
const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
|
|
421
|
+
sw.setPrd(makePrd());
|
|
422
|
+
// Should not throw — failure is logged, not re-thrown
|
|
423
|
+
await expect(sw.writeFeatureStatus(invalidFeatureDir, 0, 0)).resolves.toBeUndefined();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("uses same schema as project-level status file", async () => {
|
|
427
|
+
const projectStatusPath = join(tmpDir, "status.json");
|
|
428
|
+
const featureDir = join(tmpDir, "features", "auth-feature");
|
|
429
|
+
const featureStatusPath = join(featureDir, "status.json");
|
|
430
|
+
|
|
431
|
+
const sw = new StatusWriter(projectStatusPath, makeConfig(), makeCtx());
|
|
432
|
+
sw.setPrd(makePrd(2));
|
|
433
|
+
sw.setRunStatus("completed");
|
|
434
|
+
await sw.update(2.0, 4);
|
|
435
|
+
await sw.writeFeatureStatus(featureDir, 2.0, 4);
|
|
436
|
+
|
|
437
|
+
const projectContent = JSON.parse(readFileSync(projectStatusPath, "utf8")) as NaxStatusFile;
|
|
438
|
+
const featureContent = JSON.parse(readFileSync(featureStatusPath, "utf8")) as NaxStatusFile;
|
|
439
|
+
|
|
440
|
+
// Verify both have same schema version and structure
|
|
441
|
+
expect(projectContent.version).toBe(featureContent.version);
|
|
442
|
+
expect(projectContent.version).toBe(1);
|
|
443
|
+
expect(projectContent.run.status).toBe(featureContent.run.status);
|
|
444
|
+
expect(projectContent.cost.spent).toBe(featureContent.cost.spent);
|
|
445
|
+
expect(projectContent.progress.total).toBe(featureContent.progress.total);
|
|
446
|
+
});
|
|
447
|
+
});
|