@nathapp/nax 0.24.0 → 0.26.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/CLAUDE.md +70 -56
- package/docs/ROADMAP.md +45 -15
- package/docs/specs/trigger-completion.md +145 -0
- package/nax/features/routing-persistence/prd.json +104 -0
- package/nax/features/routing-persistence/progress.txt +1 -0
- package/nax/features/trigger-completion/prd.json +150 -0
- package/nax/features/trigger-completion/progress.txt +7 -0
- package/nax/status.json +15 -16
- package/package.json +1 -1
- package/src/config/types.ts +3 -1
- package/src/execution/crash-recovery.ts +11 -0
- package/src/execution/executor-types.ts +1 -1
- package/src/execution/iteration-runner.ts +1 -0
- package/src/execution/lifecycle/run-setup.ts +4 -0
- package/src/execution/sequential-executor.ts +45 -7
- package/src/interaction/plugins/auto.ts +10 -1
- package/src/metrics/aggregator.ts +2 -1
- package/src/metrics/tracker.ts +26 -14
- package/src/metrics/types.ts +2 -0
- package/src/pipeline/event-bus.ts +14 -1
- package/src/pipeline/stages/completion.ts +20 -0
- package/src/pipeline/stages/execution.ts +62 -0
- package/src/pipeline/stages/review.ts +25 -1
- package/src/pipeline/stages/routing.ts +42 -8
- package/src/pipeline/subscribers/hooks.ts +32 -0
- package/src/pipeline/subscribers/interaction.ts +36 -1
- package/src/pipeline/types.ts +2 -0
- package/src/prd/types.ts +4 -0
- package/src/routing/content-hash.ts +25 -0
- package/src/routing/index.ts +3 -0
- package/src/routing/router.ts +3 -2
- package/src/routing/strategies/keyword.ts +2 -1
- package/src/routing/strategies/llm-prompts.ts +29 -28
- package/src/utils/git.ts +21 -0
- package/test/integration/routing/plugin-routing-core.test.ts +1 -1
- package/test/unit/execution/sequential-executor.test.ts +235 -0
- package/test/unit/interaction/auto-plugin.test.ts +162 -0
- package/test/unit/interaction-plugins.test.ts +308 -1
- package/test/unit/metrics/aggregator.test.ts +164 -0
- package/test/unit/metrics/tracker.test.ts +186 -0
- package/test/unit/pipeline/stages/completion-review-gate.test.ts +218 -0
- package/test/unit/pipeline/stages/execution-ambiguity.test.ts +311 -0
- package/test/unit/pipeline/stages/execution-merge-conflict.test.ts +218 -0
- package/test/unit/pipeline/stages/review.test.ts +201 -0
- package/test/unit/pipeline/stages/routing-idempotence.test.ts +139 -0
- package/test/unit/pipeline/stages/routing-initial-complexity.test.ts +321 -0
- package/test/unit/pipeline/stages/routing-persistence.test.ts +380 -0
- package/test/unit/pipeline/subscribers/hooks.test.ts +43 -4
- package/test/unit/pipeline/subscribers/interaction.test.ts +284 -2
- package/test/unit/prd-auto-default.test.ts +2 -2
- package/test/unit/routing/content-hash.test.ts +99 -0
- package/test/unit/routing/routing-stability.test.ts +1 -1
- package/test/unit/routing-core.test.ts +5 -5
- package/test/unit/routing-strategies.test.ts +1 -3
- package/test/unit/utils/git.test.ts +50 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for merge-conflict trigger wiring in execution stage (TC-003)
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Agent output with CONFLICT + trigger enabled + chain aborts → fail
|
|
6
|
+
* - Agent output with CONFLICT + trigger enabled + chain approves → continue
|
|
7
|
+
* - Agent output with CONFLICT + trigger disabled → no trigger call
|
|
8
|
+
* - Agent output without CONFLICT + trigger enabled → no trigger call
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
12
|
+
import type { NaxConfig } from "../../../../src/config";
|
|
13
|
+
import { InteractionChain } from "../../../../src/interaction/chain";
|
|
14
|
+
import type { InteractionPlugin, InteractionResponse } from "../../../../src/interaction/types";
|
|
15
|
+
import { _executionDeps } from "../../../../src/pipeline/stages/execution";
|
|
16
|
+
import type { PipelineContext } from "../../../../src/pipeline/types";
|
|
17
|
+
import type { PRD, UserStory } from "../../../../src/prd";
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// Save originals for restoration
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const originalDetectMergeConflict = _executionDeps.detectMergeConflict;
|
|
24
|
+
const originalCheckMergeConflict = _executionDeps.checkMergeConflict;
|
|
25
|
+
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// Helpers
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function makeChain(action: InteractionResponse["action"]): InteractionChain {
|
|
31
|
+
const chain = new InteractionChain({ defaultTimeout: 5000, defaultFallback: "abort" });
|
|
32
|
+
const plugin: InteractionPlugin = {
|
|
33
|
+
name: "test",
|
|
34
|
+
send: mock(async () => {}),
|
|
35
|
+
receive: mock(async (id: string): Promise<InteractionResponse> => ({
|
|
36
|
+
requestId: id,
|
|
37
|
+
action,
|
|
38
|
+
respondedBy: "user",
|
|
39
|
+
respondedAt: Date.now(),
|
|
40
|
+
})),
|
|
41
|
+
};
|
|
42
|
+
chain.register(plugin);
|
|
43
|
+
return chain;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeConfig(triggers: Record<string, unknown>): NaxConfig {
|
|
47
|
+
return {
|
|
48
|
+
autoMode: { defaultAgent: "test-agent" },
|
|
49
|
+
models: { fast: "claude-haiku-4-5", balanced: "claude-sonnet-4-5", powerful: "claude-opus-4-5" },
|
|
50
|
+
execution: {
|
|
51
|
+
sessionTimeoutSeconds: 60,
|
|
52
|
+
dangerouslySkipPermissions: false,
|
|
53
|
+
costLimit: 10,
|
|
54
|
+
maxIterations: 10,
|
|
55
|
+
rectification: { maxRetries: 3 },
|
|
56
|
+
},
|
|
57
|
+
interaction: {
|
|
58
|
+
plugin: "cli",
|
|
59
|
+
defaults: { timeout: 30000, fallback: "abort" as const },
|
|
60
|
+
triggers,
|
|
61
|
+
},
|
|
62
|
+
} as unknown as NaxConfig;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeStory(): UserStory {
|
|
66
|
+
return {
|
|
67
|
+
id: "US-001",
|
|
68
|
+
title: "Test Story",
|
|
69
|
+
description: "Test",
|
|
70
|
+
acceptanceCriteria: [],
|
|
71
|
+
tags: [],
|
|
72
|
+
dependencies: [],
|
|
73
|
+
status: "in-progress",
|
|
74
|
+
passes: false,
|
|
75
|
+
escalations: [],
|
|
76
|
+
attempts: 1,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makePRD(): PRD {
|
|
81
|
+
return {
|
|
82
|
+
project: "test",
|
|
83
|
+
feature: "my-feature",
|
|
84
|
+
branchName: "test-branch",
|
|
85
|
+
createdAt: new Date().toISOString(),
|
|
86
|
+
updatedAt: new Date().toISOString(),
|
|
87
|
+
userStories: [makeStory()],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function makeSuccessfulAgent() {
|
|
92
|
+
return {
|
|
93
|
+
name: "test-agent",
|
|
94
|
+
capabilities: { supportedTiers: ["fast", "balanced", "powerful"] },
|
|
95
|
+
run: mock(async () => ({
|
|
96
|
+
success: true,
|
|
97
|
+
exitCode: 0,
|
|
98
|
+
output: "CONFLICT (content): Merge conflict in src/foo.ts",
|
|
99
|
+
stderr: "",
|
|
100
|
+
rateLimited: false,
|
|
101
|
+
durationMs: 100,
|
|
102
|
+
estimatedCost: 0.01,
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function makeCtx(config: NaxConfig, interaction?: InteractionChain): PipelineContext {
|
|
108
|
+
return {
|
|
109
|
+
config,
|
|
110
|
+
prd: makePRD(),
|
|
111
|
+
story: makeStory(),
|
|
112
|
+
stories: [makeStory()],
|
|
113
|
+
routing: { complexity: "simple", modelTier: "fast", testStrategy: "test-after", reasoning: "" },
|
|
114
|
+
workdir: "/tmp/test",
|
|
115
|
+
prompt: "Do something",
|
|
116
|
+
hooks: {} as PipelineContext["hooks"],
|
|
117
|
+
interaction,
|
|
118
|
+
} as unknown as PipelineContext;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
mock.restore();
|
|
123
|
+
_executionDeps.detectMergeConflict = originalDetectMergeConflict;
|
|
124
|
+
_executionDeps.checkMergeConflict = originalCheckMergeConflict;
|
|
125
|
+
_executionDeps.getAgent = _executionDeps.getAgent; // restored via mock.restore()
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
// Merge conflict trigger tests (via _executionDeps injection)
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe("executionStage — merge-conflict trigger", () => {
|
|
133
|
+
test("returns fail when conflict detected and trigger responds abort", async () => {
|
|
134
|
+
const { executionStage } = await import("../../../../src/pipeline/stages/execution");
|
|
135
|
+
const agent = makeSuccessfulAgent();
|
|
136
|
+
_executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
|
|
137
|
+
_executionDeps.detectMergeConflict = mock(() => true);
|
|
138
|
+
_executionDeps.checkMergeConflict = mock(async () => false);
|
|
139
|
+
|
|
140
|
+
const config = makeConfig({ "merge-conflict": { enabled: true } });
|
|
141
|
+
const chain = makeChain("abort");
|
|
142
|
+
const ctx = makeCtx(config, chain);
|
|
143
|
+
|
|
144
|
+
const result = await executionStage.execute(ctx);
|
|
145
|
+
|
|
146
|
+
expect(result.action).toBe("fail");
|
|
147
|
+
expect((result as { reason?: string }).reason).toContain("Merge conflict");
|
|
148
|
+
expect(_executionDeps.detectMergeConflict).toHaveBeenCalled();
|
|
149
|
+
expect(_executionDeps.checkMergeConflict).toHaveBeenCalledTimes(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("returns continue when conflict detected but trigger approves", async () => {
|
|
153
|
+
const { executionStage } = await import("../../../../src/pipeline/stages/execution");
|
|
154
|
+
const agent = makeSuccessfulAgent();
|
|
155
|
+
_executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
|
|
156
|
+
_executionDeps.detectMergeConflict = mock(() => true);
|
|
157
|
+
_executionDeps.checkMergeConflict = mock(async () => true);
|
|
158
|
+
|
|
159
|
+
const config = makeConfig({ "merge-conflict": { enabled: true } });
|
|
160
|
+
const chain = makeChain("approve");
|
|
161
|
+
const ctx = makeCtx(config, chain);
|
|
162
|
+
|
|
163
|
+
const result = await executionStage.execute(ctx);
|
|
164
|
+
|
|
165
|
+
expect(result.action).toBe("continue");
|
|
166
|
+
expect(_executionDeps.checkMergeConflict).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("does not call trigger when trigger is disabled", async () => {
|
|
170
|
+
const { executionStage } = await import("../../../../src/pipeline/stages/execution");
|
|
171
|
+
const agent = makeSuccessfulAgent();
|
|
172
|
+
_executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
|
|
173
|
+
_executionDeps.detectMergeConflict = mock(() => true);
|
|
174
|
+
_executionDeps.checkMergeConflict = mock(async () => false);
|
|
175
|
+
|
|
176
|
+
const config = makeConfig({});
|
|
177
|
+
const chain = makeChain("abort");
|
|
178
|
+
const ctx = makeCtx(config, chain);
|
|
179
|
+
|
|
180
|
+
const result = await executionStage.execute(ctx);
|
|
181
|
+
|
|
182
|
+
expect(result.action).toBe("continue");
|
|
183
|
+
expect(_executionDeps.checkMergeConflict).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("does not call trigger when no conflict detected", async () => {
|
|
187
|
+
const { executionStage } = await import("../../../../src/pipeline/stages/execution");
|
|
188
|
+
const agent = makeSuccessfulAgent();
|
|
189
|
+
_executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
|
|
190
|
+
_executionDeps.detectMergeConflict = mock(() => false);
|
|
191
|
+
_executionDeps.checkMergeConflict = mock(async () => false);
|
|
192
|
+
|
|
193
|
+
const config = makeConfig({ "merge-conflict": { enabled: true } });
|
|
194
|
+
const chain = makeChain("abort");
|
|
195
|
+
const ctx = makeCtx(config, chain);
|
|
196
|
+
|
|
197
|
+
const result = await executionStage.execute(ctx);
|
|
198
|
+
|
|
199
|
+
expect(result.action).toBe("continue");
|
|
200
|
+
expect(_executionDeps.checkMergeConflict).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("does not call trigger when no interaction chain", async () => {
|
|
204
|
+
const { executionStage } = await import("../../../../src/pipeline/stages/execution");
|
|
205
|
+
const agent = makeSuccessfulAgent();
|
|
206
|
+
_executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
|
|
207
|
+
_executionDeps.detectMergeConflict = mock(() => true);
|
|
208
|
+
_executionDeps.checkMergeConflict = mock(async () => false);
|
|
209
|
+
|
|
210
|
+
const config = makeConfig({ "merge-conflict": { enabled: true } });
|
|
211
|
+
const ctx = makeCtx(config); // no chain
|
|
212
|
+
|
|
213
|
+
const result = await executionStage.execute(ctx);
|
|
214
|
+
|
|
215
|
+
expect(result.action).toBe("continue");
|
|
216
|
+
expect(_executionDeps.checkMergeConflict).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for security-review trigger wiring in review stage (TC-003)
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Plugin reviewer failure with no trigger → always fail
|
|
6
|
+
* - Plugin reviewer failure + trigger abort → fail
|
|
7
|
+
* - Plugin reviewer failure + trigger non-abort → escalate
|
|
8
|
+
* - Built-in check failure → escalate (unchanged)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
12
|
+
import type { NaxConfig } from "../../../../src/config";
|
|
13
|
+
import { InteractionChain } from "../../../../src/interaction/chain";
|
|
14
|
+
import type { InteractionPlugin, InteractionResponse } from "../../../../src/interaction/types";
|
|
15
|
+
import { _reviewDeps, reviewStage } from "../../../../src/pipeline/stages/review";
|
|
16
|
+
import type { PipelineContext } from "../../../../src/pipeline/types";
|
|
17
|
+
import type { PRD, UserStory } from "../../../../src/prd";
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// Helpers
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const originalCheckSecurityReview = _reviewDeps.checkSecurityReview;
|
|
24
|
+
|
|
25
|
+
function makeChain(action: InteractionResponse["action"]): InteractionChain {
|
|
26
|
+
const chain = new InteractionChain({ defaultTimeout: 5000, defaultFallback: "abort" });
|
|
27
|
+
const plugin: InteractionPlugin = {
|
|
28
|
+
name: "test",
|
|
29
|
+
send: mock(async () => {}),
|
|
30
|
+
receive: mock(async (id: string): Promise<InteractionResponse> => ({
|
|
31
|
+
requestId: id,
|
|
32
|
+
action,
|
|
33
|
+
respondedBy: "user",
|
|
34
|
+
respondedAt: Date.now(),
|
|
35
|
+
})),
|
|
36
|
+
};
|
|
37
|
+
chain.register(plugin);
|
|
38
|
+
return chain;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeConfig(triggers: Record<string, unknown>): NaxConfig {
|
|
42
|
+
return {
|
|
43
|
+
review: { enabled: true },
|
|
44
|
+
interaction: {
|
|
45
|
+
plugin: "cli",
|
|
46
|
+
defaults: { timeout: 30000, fallback: "abort" as const },
|
|
47
|
+
triggers,
|
|
48
|
+
},
|
|
49
|
+
} as unknown as NaxConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeStory(overrides?: Partial<UserStory>): UserStory {
|
|
53
|
+
return {
|
|
54
|
+
id: "US-001",
|
|
55
|
+
title: "Test Story",
|
|
56
|
+
description: "Test",
|
|
57
|
+
acceptanceCriteria: [],
|
|
58
|
+
tags: [],
|
|
59
|
+
dependencies: [],
|
|
60
|
+
status: "in-progress",
|
|
61
|
+
passes: false,
|
|
62
|
+
escalations: [],
|
|
63
|
+
attempts: 1,
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makePRD(): PRD {
|
|
69
|
+
return {
|
|
70
|
+
project: "test",
|
|
71
|
+
feature: "my-feature",
|
|
72
|
+
branchName: "test-branch",
|
|
73
|
+
createdAt: new Date().toISOString(),
|
|
74
|
+
updatedAt: new Date().toISOString(),
|
|
75
|
+
userStories: [makeStory()],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function makeCtx(overrides: Partial<PipelineContext>): PipelineContext {
|
|
80
|
+
return {
|
|
81
|
+
config: makeConfig({}),
|
|
82
|
+
prd: makePRD(),
|
|
83
|
+
story: makeStory(),
|
|
84
|
+
stories: [makeStory()],
|
|
85
|
+
routing: { complexity: "simple", modelTier: "fast", testStrategy: "test-after", reasoning: "" },
|
|
86
|
+
workdir: "/tmp/test",
|
|
87
|
+
hooks: {} as PipelineContext["hooks"],
|
|
88
|
+
...overrides,
|
|
89
|
+
} as unknown as PipelineContext;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
mock.restore();
|
|
94
|
+
_reviewDeps.checkSecurityReview = originalCheckSecurityReview;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
// Plugin reviewer failure — no trigger configured (today behavior)
|
|
99
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("reviewStage — plugin failure, no trigger", () => {
|
|
102
|
+
test("returns fail when plugin reviewer fails and trigger not enabled", async () => {
|
|
103
|
+
const reviewResult = { success: false, pluginFailed: true, failureReason: "semgrep found issues", builtIn: { totalDurationMs: 0 } };
|
|
104
|
+
const orchestratorMock = mock(async () => reviewResult);
|
|
105
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only import override
|
|
106
|
+
const { reviewOrchestrator } = await import("../../../../src/review/orchestrator");
|
|
107
|
+
const original = reviewOrchestrator.review;
|
|
108
|
+
reviewOrchestrator.review = orchestratorMock as typeof reviewOrchestrator.review;
|
|
109
|
+
|
|
110
|
+
const ctx = makeCtx({ config: makeConfig({}) });
|
|
111
|
+
const result = await reviewStage.execute(ctx);
|
|
112
|
+
|
|
113
|
+
expect(result.action).toBe("fail");
|
|
114
|
+
reviewOrchestrator.review = original;
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// Plugin reviewer failure — trigger wired via _reviewDeps
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
describe("reviewStage — security-review trigger via _reviewDeps", () => {
|
|
123
|
+
test("returns fail when trigger responds abort (checkSecurityReview returns false)", async () => {
|
|
124
|
+
_reviewDeps.checkSecurityReview = mock(async () => false);
|
|
125
|
+
|
|
126
|
+
const reviewResult = { success: false, pluginFailed: true, failureReason: "semgrep critical", builtIn: { totalDurationMs: 0 } };
|
|
127
|
+
const { reviewOrchestrator } = await import("../../../../src/review/orchestrator");
|
|
128
|
+
const original = reviewOrchestrator.review;
|
|
129
|
+
reviewOrchestrator.review = mock(async () => reviewResult) as typeof reviewOrchestrator.review;
|
|
130
|
+
|
|
131
|
+
const chain = makeChain("abort");
|
|
132
|
+
const ctx = makeCtx({
|
|
133
|
+
config: makeConfig({ "security-review": { enabled: true } }),
|
|
134
|
+
interaction: chain,
|
|
135
|
+
});
|
|
136
|
+
const result = await reviewStage.execute(ctx);
|
|
137
|
+
|
|
138
|
+
expect(result.action).toBe("fail");
|
|
139
|
+
expect(_reviewDeps.checkSecurityReview).toHaveBeenCalledTimes(1);
|
|
140
|
+
reviewOrchestrator.review = original;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("returns escalate when trigger responds non-abort (checkSecurityReview returns true)", async () => {
|
|
144
|
+
_reviewDeps.checkSecurityReview = mock(async () => true);
|
|
145
|
+
|
|
146
|
+
const reviewResult = { success: false, pluginFailed: true, failureReason: "semgrep warning", builtIn: { totalDurationMs: 0 } };
|
|
147
|
+
const { reviewOrchestrator } = await import("../../../../src/review/orchestrator");
|
|
148
|
+
const original = reviewOrchestrator.review;
|
|
149
|
+
reviewOrchestrator.review = mock(async () => reviewResult) as typeof reviewOrchestrator.review;
|
|
150
|
+
|
|
151
|
+
const chain = makeChain("approve");
|
|
152
|
+
const ctx = makeCtx({
|
|
153
|
+
config: makeConfig({ "security-review": { enabled: true } }),
|
|
154
|
+
interaction: chain,
|
|
155
|
+
});
|
|
156
|
+
const result = await reviewStage.execute(ctx);
|
|
157
|
+
|
|
158
|
+
expect(result.action).toBe("escalate");
|
|
159
|
+
expect(_reviewDeps.checkSecurityReview).toHaveBeenCalledTimes(1);
|
|
160
|
+
reviewOrchestrator.review = original;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("does not call trigger when no interaction chain present", async () => {
|
|
164
|
+
_reviewDeps.checkSecurityReview = mock(async () => true);
|
|
165
|
+
|
|
166
|
+
const reviewResult = { success: false, pluginFailed: true, failureReason: "semgrep error", builtIn: { totalDurationMs: 0 } };
|
|
167
|
+
const { reviewOrchestrator } = await import("../../../../src/review/orchestrator");
|
|
168
|
+
const original = reviewOrchestrator.review;
|
|
169
|
+
reviewOrchestrator.review = mock(async () => reviewResult) as typeof reviewOrchestrator.review;
|
|
170
|
+
|
|
171
|
+
const ctx = makeCtx({
|
|
172
|
+
config: makeConfig({ "security-review": { enabled: true } }),
|
|
173
|
+
// no interaction
|
|
174
|
+
});
|
|
175
|
+
const result = await reviewStage.execute(ctx);
|
|
176
|
+
|
|
177
|
+
expect(result.action).toBe("fail");
|
|
178
|
+
expect(_reviewDeps.checkSecurityReview).not.toHaveBeenCalled();
|
|
179
|
+
reviewOrchestrator.review = original;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("built-in check failure still returns escalate (unchanged behavior)", async () => {
|
|
183
|
+
_reviewDeps.checkSecurityReview = mock(async () => false);
|
|
184
|
+
|
|
185
|
+
const reviewResult = { success: false, pluginFailed: false, failureReason: "lint failed", builtIn: { totalDurationMs: 0 } };
|
|
186
|
+
const { reviewOrchestrator } = await import("../../../../src/review/orchestrator");
|
|
187
|
+
const original = reviewOrchestrator.review;
|
|
188
|
+
reviewOrchestrator.review = mock(async () => reviewResult) as typeof reviewOrchestrator.review;
|
|
189
|
+
|
|
190
|
+
const ctx = makeCtx({
|
|
191
|
+
config: makeConfig({ "security-review": { enabled: true } }),
|
|
192
|
+
interaction: makeChain("abort"),
|
|
193
|
+
});
|
|
194
|
+
const result = await reviewStage.execute(ctx);
|
|
195
|
+
|
|
196
|
+
expect(result.action).toBe("escalate");
|
|
197
|
+
// security-review trigger should NOT fire for built-in check failures
|
|
198
|
+
expect(_reviewDeps.checkSecurityReview).not.toHaveBeenCalled();
|
|
199
|
+
reviewOrchestrator.review = original;
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing Stage — RRP-001: Idempotence and Dependencies
|
|
3
|
+
*
|
|
4
|
+
* AC-4: Tests for idempotent persistence and dependency exposure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
8
|
+
import { DEFAULT_CONFIG } from "../../../../src/config/defaults";
|
|
9
|
+
import type { NaxConfig } from "../../../../src/config";
|
|
10
|
+
import type { PRD, UserStory } from "../../../../src/prd";
|
|
11
|
+
import type { PipelineContext } from "../../../../src/pipeline/types";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
function makeStory(overrides?: Partial<UserStory>): UserStory {
|
|
18
|
+
return {
|
|
19
|
+
id: "US-001",
|
|
20
|
+
title: "Test Story",
|
|
21
|
+
description: "Test description",
|
|
22
|
+
acceptanceCriteria: [],
|
|
23
|
+
tags: [],
|
|
24
|
+
dependencies: [],
|
|
25
|
+
status: "in-progress",
|
|
26
|
+
passes: false,
|
|
27
|
+
escalations: [],
|
|
28
|
+
attempts: 0,
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makePRD(story: UserStory): PRD {
|
|
34
|
+
return {
|
|
35
|
+
project: "test-project",
|
|
36
|
+
feature: "test-feature",
|
|
37
|
+
branchName: "feat/test",
|
|
38
|
+
createdAt: new Date().toISOString(),
|
|
39
|
+
updatedAt: new Date().toISOString(),
|
|
40
|
+
userStories: [story],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeConfig(): NaxConfig {
|
|
45
|
+
return {
|
|
46
|
+
...DEFAULT_CONFIG,
|
|
47
|
+
tdd: {
|
|
48
|
+
...DEFAULT_CONFIG.tdd,
|
|
49
|
+
greenfieldDetection: false,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeCtx(story: UserStory, overrides?: Partial<PipelineContext>): PipelineContext & { prdPath: string } {
|
|
55
|
+
const prd = makePRD(story);
|
|
56
|
+
return {
|
|
57
|
+
config: makeConfig(),
|
|
58
|
+
prd,
|
|
59
|
+
story,
|
|
60
|
+
stories: [story],
|
|
61
|
+
routing: {
|
|
62
|
+
complexity: "simple",
|
|
63
|
+
modelTier: "fast",
|
|
64
|
+
testStrategy: "test-after",
|
|
65
|
+
reasoning: "test",
|
|
66
|
+
},
|
|
67
|
+
workdir: "/tmp/nax-routing-test",
|
|
68
|
+
hooks: { hooks: {} },
|
|
69
|
+
prdPath: "/tmp/nax-routing-test/nax/prd.json",
|
|
70
|
+
...overrides,
|
|
71
|
+
} as PipelineContext & { prdPath: string };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const FRESH_ROUTING_RESULT = {
|
|
75
|
+
complexity: "medium" as const,
|
|
76
|
+
modelTier: "balanced" as const,
|
|
77
|
+
testStrategy: "three-session-tdd" as const,
|
|
78
|
+
reasoning: "classified by routeStory",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// AC-4: savePRD called once per story, not on every iteration
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe("routingStage - savePRD called exactly once per story (not per iteration)", () => {
|
|
86
|
+
let origRoutingDeps: typeof import("../../../../src/pipeline/stages/routing")["_routingDeps"];
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
mock.restore();
|
|
90
|
+
if (origRoutingDeps) {
|
|
91
|
+
const { _routingDeps } = require("../../../../src/pipeline/stages/routing");
|
|
92
|
+
Object.assign(_routingDeps, origRoutingDeps);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("calling routingStage twice with routing already set only triggers savePRD once (first call)", async () => {
|
|
97
|
+
const { routingStage, _routingDeps } = await import(
|
|
98
|
+
"../../../../src/pipeline/stages/routing"
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
origRoutingDeps = { ..._routingDeps };
|
|
102
|
+
|
|
103
|
+
let savePRDCallCount = 0;
|
|
104
|
+
|
|
105
|
+
_routingDeps.routeStory = mock(() =>
|
|
106
|
+
Promise.resolve({ ...FRESH_ROUTING_RESULT }),
|
|
107
|
+
);
|
|
108
|
+
_routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
|
|
109
|
+
_routingDeps.savePRD = mock((_prd: PRD, _path: string) => {
|
|
110
|
+
savePRDCallCount++;
|
|
111
|
+
return Promise.resolve();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// First iteration: story.routing is undefined → should persist
|
|
115
|
+
const story = makeStory({ routing: undefined });
|
|
116
|
+
const ctx = makeCtx(story);
|
|
117
|
+
|
|
118
|
+
await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
|
|
119
|
+
|
|
120
|
+
// After first execution, story.routing is populated (simulating resume after crash)
|
|
121
|
+
// Second iteration: story.routing is now set → should NOT persist again
|
|
122
|
+
await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
|
|
123
|
+
|
|
124
|
+
expect(savePRDCallCount).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Sanity: _routingDeps exposes savePRD (fail if not added to deps object)
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
describe("routingStage - _routingDeps exposes savePRD", () => {
|
|
133
|
+
test("_routingDeps has a savePRD function", async () => {
|
|
134
|
+
const { _routingDeps } = await import(
|
|
135
|
+
"../../../../src/pipeline/stages/routing"
|
|
136
|
+
);
|
|
137
|
+
expect(typeof _routingDeps.savePRD).toBe("function");
|
|
138
|
+
});
|
|
139
|
+
});
|