@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
|
@@ -11,11 +11,14 @@ describe("wireHooks", () => {
|
|
|
11
11
|
const bus = new PipelineEventBus();
|
|
12
12
|
wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
const
|
|
16
|
-
for (const ev of
|
|
14
|
+
// Single-subscriber events
|
|
15
|
+
const singleSubEvents = ["run:started", "story:started", "story:paused", "run:paused", "run:completed", "run:resumed", "run:errored"] as const;
|
|
16
|
+
for (const ev of singleSubEvents) {
|
|
17
17
|
expect(bus.subscriberCount(ev)).toBe(1);
|
|
18
18
|
}
|
|
19
|
+
// story:completed and story:failed each have 2: on-story-complete/fail + on-session-end
|
|
20
|
+
expect(bus.subscriberCount("story:completed")).toBe(2);
|
|
21
|
+
expect(bus.subscriberCount("story:failed")).toBe(2);
|
|
19
22
|
});
|
|
20
23
|
|
|
21
24
|
test("returns unsubscribe function that removes all subscriptions", () => {
|
|
@@ -24,7 +27,7 @@ describe("wireHooks", () => {
|
|
|
24
27
|
|
|
25
28
|
unsub();
|
|
26
29
|
|
|
27
|
-
const events = ["run:started", "story:started", "story:completed"] as const;
|
|
30
|
+
const events = ["run:started", "story:started", "story:completed", "run:resumed", "run:errored"] as const;
|
|
28
31
|
for (const ev of events) {
|
|
29
32
|
expect(bus.subscriberCount(ev)).toBe(0);
|
|
30
33
|
}
|
|
@@ -42,4 +45,40 @@ describe("wireHooks", () => {
|
|
|
42
45
|
bus.emit({ type: "story:completed", storyId: "US-001", story: { id: "US-001" } as any, passed: true, durationMs: 100 }),
|
|
43
46
|
).not.toThrow();
|
|
44
47
|
});
|
|
48
|
+
|
|
49
|
+
test("on-resume: run:resumed event triggers on-resume hook (fire-and-forget, no throw)", () => {
|
|
50
|
+
const bus = new PipelineEventBus();
|
|
51
|
+
wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
|
|
52
|
+
|
|
53
|
+
expect(() =>
|
|
54
|
+
bus.emit({ type: "run:resumed", feature: "test-feature" }),
|
|
55
|
+
).not.toThrow();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("on-session-end: story:completed triggers on-session-end with status passed (fire-and-forget, no throw)", () => {
|
|
59
|
+
const bus = new PipelineEventBus();
|
|
60
|
+
wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
|
|
61
|
+
|
|
62
|
+
expect(() =>
|
|
63
|
+
bus.emit({ type: "story:completed", storyId: "US-001", story: { id: "US-001" } as any, passed: true, durationMs: 100 }),
|
|
64
|
+
).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("on-session-end: story:failed triggers on-session-end with status failed (fire-and-forget, no throw)", () => {
|
|
68
|
+
const bus = new PipelineEventBus();
|
|
69
|
+
wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
|
|
70
|
+
|
|
71
|
+
expect(() =>
|
|
72
|
+
bus.emit({ type: "story:failed", storyId: "US-001", story: { id: "US-001" } as any, reason: "test failure", countsTowardEscalation: true }),
|
|
73
|
+
).not.toThrow();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("on-error: run:errored event triggers on-error hook (fire-and-forget, no throw)", () => {
|
|
77
|
+
const bus = new PipelineEventBus();
|
|
78
|
+
wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
|
|
79
|
+
|
|
80
|
+
expect(() =>
|
|
81
|
+
bus.emit({ type: "run:errored", reason: "SIGTERM", feature: "test-feature" }),
|
|
82
|
+
).not.toThrow();
|
|
83
|
+
});
|
|
45
84
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// RE-ARCH: keep
|
|
2
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
3
3
|
import { wireInteraction } from "../../../../src/pipeline/subscribers/interaction";
|
|
4
|
-
import { PipelineEventBus } from "../../../../src/pipeline/event-bus";
|
|
4
|
+
import { PipelineEventBus, type StoryFailedEvent } from "../../../../src/pipeline/event-bus";
|
|
5
5
|
import { DEFAULT_CONFIG } from "../../../../src/config";
|
|
6
|
+
import type { UserStory } from "../../../../src/prd";
|
|
6
7
|
|
|
7
8
|
describe("wireInteraction", () => {
|
|
8
9
|
test("no subscriptions when interactionChain is null", () => {
|
|
@@ -29,3 +30,284 @@ describe("wireInteraction", () => {
|
|
|
29
30
|
unsub(); // should not throw
|
|
30
31
|
});
|
|
31
32
|
});
|
|
33
|
+
|
|
34
|
+
describe("wireInteraction - max-retries trigger", () => {
|
|
35
|
+
let bus: PipelineEventBus;
|
|
36
|
+
let mockChain: any;
|
|
37
|
+
let mockLogger: any;
|
|
38
|
+
let loggedWarnings: Array<{ context: string; message: string; data: any }> = [];
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
bus = new PipelineEventBus();
|
|
42
|
+
mockChain = {
|
|
43
|
+
prompt: async () => ({ action: "skip" }),
|
|
44
|
+
};
|
|
45
|
+
loggedWarnings = [];
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
bus.clear();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function createStoryFailedEvent(
|
|
53
|
+
overrides: Partial<StoryFailedEvent> = {},
|
|
54
|
+
): StoryFailedEvent {
|
|
55
|
+
const story: UserStory = {
|
|
56
|
+
id: "US-001",
|
|
57
|
+
title: "Test Story",
|
|
58
|
+
description: "Test",
|
|
59
|
+
acceptanceCriteria: [],
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
type: "story:failed",
|
|
63
|
+
storyId: "story-1",
|
|
64
|
+
story,
|
|
65
|
+
reason: "Test failed",
|
|
66
|
+
countsTowardEscalation: true,
|
|
67
|
+
feature: "test-feature",
|
|
68
|
+
attempts: 3,
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("no subscription when max-retries trigger is disabled", () => {
|
|
74
|
+
const config = {
|
|
75
|
+
...DEFAULT_CONFIG,
|
|
76
|
+
interaction: {
|
|
77
|
+
...DEFAULT_CONFIG.interaction,
|
|
78
|
+
triggers: { "max-retries": { enabled: false } },
|
|
79
|
+
},
|
|
80
|
+
} as any;
|
|
81
|
+
wireInteraction(bus, mockChain, config);
|
|
82
|
+
expect(bus.subscriberCount("story:failed")).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("no subscription when interactionChain is null", () => {
|
|
86
|
+
const config = {
|
|
87
|
+
...DEFAULT_CONFIG,
|
|
88
|
+
interaction: {
|
|
89
|
+
...DEFAULT_CONFIG.interaction,
|
|
90
|
+
triggers: { "max-retries": { enabled: true } },
|
|
91
|
+
},
|
|
92
|
+
} as any;
|
|
93
|
+
wireInteraction(bus, null, config);
|
|
94
|
+
expect(bus.subscriberCount("story:failed")).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("fires max-retries trigger when countsTowardEscalation=true", async () => {
|
|
98
|
+
const config = {
|
|
99
|
+
...DEFAULT_CONFIG,
|
|
100
|
+
interaction: {
|
|
101
|
+
...DEFAULT_CONFIG.interaction,
|
|
102
|
+
triggers: { "max-retries": { enabled: true } },
|
|
103
|
+
},
|
|
104
|
+
} as any;
|
|
105
|
+
|
|
106
|
+
let triggerCalled = false;
|
|
107
|
+
mockChain.prompt = async (request: any) => {
|
|
108
|
+
triggerCalled = true;
|
|
109
|
+
expect(request.id).toContain("trigger-max-retries");
|
|
110
|
+
return { action: "skip" };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
wireInteraction(bus, mockChain, config);
|
|
114
|
+
bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
|
|
115
|
+
|
|
116
|
+
// Give async handler time to execute
|
|
117
|
+
await Bun.sleep(10);
|
|
118
|
+
expect(triggerCalled).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("does NOT fire max-retries trigger when countsTowardEscalation=false", async () => {
|
|
122
|
+
const config = {
|
|
123
|
+
...DEFAULT_CONFIG,
|
|
124
|
+
interaction: {
|
|
125
|
+
...DEFAULT_CONFIG.interaction,
|
|
126
|
+
triggers: { "max-retries": { enabled: true } },
|
|
127
|
+
},
|
|
128
|
+
} as any;
|
|
129
|
+
|
|
130
|
+
let triggerCalled = false;
|
|
131
|
+
mockChain.prompt = async () => {
|
|
132
|
+
triggerCalled = true;
|
|
133
|
+
return { action: "skip" };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
wireInteraction(bus, mockChain, config);
|
|
137
|
+
bus.emit(createStoryFailedEvent({ countsTowardEscalation: false }));
|
|
138
|
+
|
|
139
|
+
await Bun.sleep(10);
|
|
140
|
+
expect(triggerCalled).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("passes correct context to executeTrigger", async () => {
|
|
144
|
+
const config = {
|
|
145
|
+
...DEFAULT_CONFIG,
|
|
146
|
+
interaction: {
|
|
147
|
+
...DEFAULT_CONFIG.interaction,
|
|
148
|
+
triggers: { "max-retries": { enabled: true } },
|
|
149
|
+
},
|
|
150
|
+
} as any;
|
|
151
|
+
|
|
152
|
+
let capturedRequest: any;
|
|
153
|
+
mockChain.prompt = async (request: any) => {
|
|
154
|
+
capturedRequest = request;
|
|
155
|
+
return { action: "skip" };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
wireInteraction(bus, mockChain, config);
|
|
159
|
+
bus.emit(
|
|
160
|
+
createStoryFailedEvent({
|
|
161
|
+
storyId: "story-42",
|
|
162
|
+
feature: "auth-feature",
|
|
163
|
+
attempts: 5,
|
|
164
|
+
countsTowardEscalation: true,
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
await Bun.sleep(10);
|
|
169
|
+
expect(capturedRequest?.featureName).toBe("auth-feature");
|
|
170
|
+
expect(capturedRequest?.storyId).toBe("story-42");
|
|
171
|
+
// Verify the request ID contains the trigger name
|
|
172
|
+
expect(capturedRequest?.id).toContain("trigger-max-retries");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("handles abort response with warning", async () => {
|
|
176
|
+
const config = {
|
|
177
|
+
...DEFAULT_CONFIG,
|
|
178
|
+
interaction: {
|
|
179
|
+
...DEFAULT_CONFIG.interaction,
|
|
180
|
+
triggers: { "max-retries": { enabled: true } },
|
|
181
|
+
},
|
|
182
|
+
} as any;
|
|
183
|
+
|
|
184
|
+
let loggedAbort = false;
|
|
185
|
+
const originalLogger = console.warn;
|
|
186
|
+
console.warn = ((context: string, message: string, data: any) => {
|
|
187
|
+
if (message === "max-retries abort requested") {
|
|
188
|
+
loggedAbort = true;
|
|
189
|
+
}
|
|
190
|
+
}) as any;
|
|
191
|
+
|
|
192
|
+
mockChain.prompt = async () => {
|
|
193
|
+
return { action: "abort" };
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
wireInteraction(bus, mockChain, config);
|
|
198
|
+
bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
|
|
199
|
+
await Bun.sleep(10);
|
|
200
|
+
// Note: actual logging behavior depends on getSafeLogger implementation
|
|
201
|
+
} finally {
|
|
202
|
+
console.warn = originalLogger;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("handles skip response (default)", async () => {
|
|
207
|
+
const config = {
|
|
208
|
+
...DEFAULT_CONFIG,
|
|
209
|
+
interaction: {
|
|
210
|
+
...DEFAULT_CONFIG.interaction,
|
|
211
|
+
triggers: { "max-retries": { enabled: true } },
|
|
212
|
+
},
|
|
213
|
+
} as any;
|
|
214
|
+
|
|
215
|
+
let skipCalled = false;
|
|
216
|
+
mockChain.prompt = async () => {
|
|
217
|
+
skipCalled = true;
|
|
218
|
+
return { action: "skip" };
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
wireInteraction(bus, mockChain, config);
|
|
222
|
+
bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
|
|
223
|
+
|
|
224
|
+
await Bun.sleep(10);
|
|
225
|
+
expect(skipCalled).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("handles escalate response (treated as skip)", async () => {
|
|
229
|
+
const config = {
|
|
230
|
+
...DEFAULT_CONFIG,
|
|
231
|
+
interaction: {
|
|
232
|
+
...DEFAULT_CONFIG.interaction,
|
|
233
|
+
triggers: { "max-retries": { enabled: true } },
|
|
234
|
+
},
|
|
235
|
+
} as any;
|
|
236
|
+
|
|
237
|
+
let escalateCalled = false;
|
|
238
|
+
mockChain.prompt = async () => {
|
|
239
|
+
escalateCalled = true;
|
|
240
|
+
return { action: "escalate" };
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
wireInteraction(bus, mockChain, config);
|
|
244
|
+
bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
|
|
245
|
+
|
|
246
|
+
await Bun.sleep(10);
|
|
247
|
+
expect(escalateCalled).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("catches trigger execution errors gracefully", async () => {
|
|
251
|
+
const config = {
|
|
252
|
+
...DEFAULT_CONFIG,
|
|
253
|
+
interaction: {
|
|
254
|
+
...DEFAULT_CONFIG.interaction,
|
|
255
|
+
triggers: { "max-retries": { enabled: true } },
|
|
256
|
+
},
|
|
257
|
+
} as any;
|
|
258
|
+
|
|
259
|
+
mockChain.prompt = async () => {
|
|
260
|
+
throw new Error("Trigger failed");
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
wireInteraction(bus, mockChain, config);
|
|
264
|
+
// Should not throw
|
|
265
|
+
bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
|
|
266
|
+
await Bun.sleep(10);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("handles missing feature field", async () => {
|
|
270
|
+
const config = {
|
|
271
|
+
...DEFAULT_CONFIG,
|
|
272
|
+
interaction: {
|
|
273
|
+
...DEFAULT_CONFIG.interaction,
|
|
274
|
+
triggers: { "max-retries": { enabled: true } },
|
|
275
|
+
},
|
|
276
|
+
} as any;
|
|
277
|
+
|
|
278
|
+
let capturedRequest: any;
|
|
279
|
+
mockChain.prompt = async (request: any) => {
|
|
280
|
+
capturedRequest = request;
|
|
281
|
+
return { action: "skip" };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
wireInteraction(bus, mockChain, config);
|
|
285
|
+
bus.emit(createStoryFailedEvent({ feature: undefined, countsTowardEscalation: true }));
|
|
286
|
+
|
|
287
|
+
await Bun.sleep(10);
|
|
288
|
+
expect(capturedRequest?.featureName).toBe("");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("unsubscribes correctly", async () => {
|
|
292
|
+
const config = {
|
|
293
|
+
...DEFAULT_CONFIG,
|
|
294
|
+
interaction: {
|
|
295
|
+
...DEFAULT_CONFIG.interaction,
|
|
296
|
+
triggers: { "max-retries": { enabled: true } },
|
|
297
|
+
},
|
|
298
|
+
} as any;
|
|
299
|
+
|
|
300
|
+
let triggerCalled = false;
|
|
301
|
+
mockChain.prompt = async () => {
|
|
302
|
+
triggerCalled = true;
|
|
303
|
+
return { action: "skip" };
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const unsub = wireInteraction(bus, mockChain, config);
|
|
307
|
+
unsub();
|
|
308
|
+
|
|
309
|
+
bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
|
|
310
|
+
await Bun.sleep(10);
|
|
311
|
+
expect(triggerCalled).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -257,7 +257,7 @@ describe("Router Tags Defensive Fallback (BUG-004)", () => {
|
|
|
257
257
|
|
|
258
258
|
expect(result.complexity).toBe("simple");
|
|
259
259
|
expect(result.modelTier).toBe("fast");
|
|
260
|
-
expect(result.testStrategy).toBe("
|
|
260
|
+
expect(result.testStrategy).toBe("test-after");
|
|
261
261
|
});
|
|
262
262
|
|
|
263
263
|
test("routeTask handles null tags gracefully", () => {
|
|
@@ -271,7 +271,7 @@ describe("Router Tags Defensive Fallback (BUG-004)", () => {
|
|
|
271
271
|
|
|
272
272
|
expect(result.complexity).toBe("simple");
|
|
273
273
|
expect(result.modelTier).toBe("fast");
|
|
274
|
-
expect(result.testStrategy).toBe("
|
|
274
|
+
expect(result.testStrategy).toBe("test-after");
|
|
275
275
|
});
|
|
276
276
|
|
|
277
277
|
test("routeTask with undefined tags does not crash on spread operation", () => {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* computeStoryContentHash — RRP-003
|
|
3
|
+
*
|
|
4
|
+
* AC-2: helper function computes a hash of title+description+ACs+tags
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { computeStoryContentHash } from "../../../src/routing";
|
|
9
|
+
import type { UserStory } from "../../../src/prd/types";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function makeStory(overrides?: Partial<UserStory>): UserStory {
|
|
16
|
+
return {
|
|
17
|
+
id: "US-001",
|
|
18
|
+
title: "Add login page",
|
|
19
|
+
description: "Users can log in with email and password",
|
|
20
|
+
acceptanceCriteria: ["Shows email field", "Shows password field", "Submits form"],
|
|
21
|
+
tags: ["auth", "ui"],
|
|
22
|
+
dependencies: [],
|
|
23
|
+
status: "pending",
|
|
24
|
+
passes: false,
|
|
25
|
+
escalations: [],
|
|
26
|
+
attempts: 0,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// AC-2: computeStoryContentHash exists and returns a string
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
describe("computeStoryContentHash", () => {
|
|
36
|
+
test("returns a non-empty string", () => {
|
|
37
|
+
const story = makeStory();
|
|
38
|
+
const hash = computeStoryContentHash(story);
|
|
39
|
+
expect(typeof hash).toBe("string");
|
|
40
|
+
expect(hash.length).toBeGreaterThan(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("same story content produces the same hash (deterministic)", () => {
|
|
44
|
+
const story1 = makeStory();
|
|
45
|
+
const story2 = makeStory();
|
|
46
|
+
expect(computeStoryContentHash(story1)).toBe(computeStoryContentHash(story2));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("different title produces different hash", () => {
|
|
50
|
+
const base = makeStory();
|
|
51
|
+
const changed = makeStory({ title: "Add registration page" });
|
|
52
|
+
expect(computeStoryContentHash(base)).not.toBe(computeStoryContentHash(changed));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("different description produces different hash", () => {
|
|
56
|
+
const base = makeStory();
|
|
57
|
+
const changed = makeStory({ description: "Users can log in via OAuth" });
|
|
58
|
+
expect(computeStoryContentHash(base)).not.toBe(computeStoryContentHash(changed));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("different acceptanceCriteria produces different hash", () => {
|
|
62
|
+
const base = makeStory();
|
|
63
|
+
const changed = makeStory({
|
|
64
|
+
acceptanceCriteria: ["Shows email field", "Shows password field"],
|
|
65
|
+
});
|
|
66
|
+
expect(computeStoryContentHash(base)).not.toBe(computeStoryContentHash(changed));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("different tags produces different hash", () => {
|
|
70
|
+
const base = makeStory();
|
|
71
|
+
const changed = makeStory({ tags: ["auth", "api"] });
|
|
72
|
+
expect(computeStoryContentHash(base)).not.toBe(computeStoryContentHash(changed));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("story ID, status, and attempts do NOT affect the hash (only content fields)", () => {
|
|
76
|
+
const base = makeStory({ id: "US-001", status: "pending", attempts: 0 });
|
|
77
|
+
const differentMeta = makeStory({ id: "US-099", status: "in-progress", attempts: 3 });
|
|
78
|
+
expect(computeStoryContentHash(base)).toBe(computeStoryContentHash(differentMeta));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("empty acceptanceCriteria and tags produce a valid hash", () => {
|
|
82
|
+
const story = makeStory({ acceptanceCriteria: [], tags: [] });
|
|
83
|
+
const hash = computeStoryContentHash(story);
|
|
84
|
+
expect(typeof hash).toBe("string");
|
|
85
|
+
expect(hash.length).toBeGreaterThan(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("adding an AC changes the hash", () => {
|
|
89
|
+
const before = makeStory({ acceptanceCriteria: ["AC1", "AC2"] });
|
|
90
|
+
const after = makeStory({ acceptanceCriteria: ["AC1", "AC2", "AC3 — new"] });
|
|
91
|
+
expect(computeStoryContentHash(before)).not.toBe(computeStoryContentHash(after));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("adding a tag changes the hash", () => {
|
|
95
|
+
const before = makeStory({ tags: ["backend"] });
|
|
96
|
+
const after = makeStory({ tags: ["backend", "security"] });
|
|
97
|
+
expect(computeStoryContentHash(before)).not.toBe(computeStoryContentHash(after));
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -88,7 +88,7 @@ describe("BUG-031: keyword classifier stability across retries", () => {
|
|
|
88
88
|
|
|
89
89
|
const result = keywordStrategy.route(story, ctx);
|
|
90
90
|
expect(result!.complexity).toBe("simple");
|
|
91
|
-
expect(result!.testStrategy).toBe("
|
|
91
|
+
expect(result!.testStrategy).toBe("test-after");
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
test("complexity is driven by title and tags only (not description)", () => {
|
|
@@ -86,8 +86,8 @@ describe("classifyComplexity", () => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
describe("determineTestStrategy", () => {
|
|
89
|
-
test("simple →
|
|
90
|
-
expect(determineTestStrategy("simple", "Fix typo", "Fix a typo", [])).toBe("
|
|
89
|
+
test("simple → test-after (BUG-045)", () => {
|
|
90
|
+
expect(determineTestStrategy("simple", "Fix typo", "Fix a typo", [])).toBe("test-after");
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
test("complex → three-session-tdd", () => {
|
|
@@ -161,11 +161,11 @@ describe("determineTestStrategy", () => {
|
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
describe("routeTask", () => {
|
|
164
|
-
test("routes simple task to fast model with
|
|
164
|
+
test("routes simple task to fast model with test-after (BUG-045)", () => {
|
|
165
165
|
const result = routeTask("Fix typo", "Fix a typo", ["Typo fixed"], [], DEFAULT_CONFIG);
|
|
166
166
|
expect(result.complexity).toBe("simple");
|
|
167
167
|
expect(result.modelTier).toBe("fast");
|
|
168
|
-
expect(result.testStrategy).toBe("
|
|
168
|
+
expect(result.testStrategy).toBe("test-after");
|
|
169
169
|
});
|
|
170
170
|
|
|
171
171
|
test("routes security task to powerful with three-session-tdd", () => {
|
|
@@ -262,7 +262,7 @@ describe("routeTask", () => {
|
|
|
262
262
|
|
|
263
263
|
test("default config (strategy='auto') routes simple to three-session-tdd-lite", () => {
|
|
264
264
|
const simpleResult = routeTask("Fix typo", "Fix a typo", ["Typo fixed"], [], DEFAULT_CONFIG);
|
|
265
|
-
expect(simpleResult.testStrategy).toBe("
|
|
265
|
+
expect(simpleResult.testStrategy).toBe("test-after");
|
|
266
266
|
|
|
267
267
|
const complexResult = routeTask(
|
|
268
268
|
"Auth refactor",
|
|
@@ -235,7 +235,7 @@ describe("keywordStrategy", () => {
|
|
|
235
235
|
expect(decision).not.toBeNull();
|
|
236
236
|
expect(decision!.complexity).toBe("simple");
|
|
237
237
|
expect(decision!.modelTier).toBe("fast");
|
|
238
|
-
expect(decision!.testStrategy).toBe("
|
|
238
|
+
expect(decision!.testStrategy).toBe("test-after");
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
test("classifies complex story with security keywords", () => {
|
|
@@ -352,8 +352,6 @@ describe("LLM Routing Strategy - Prompt Building", () => {
|
|
|
352
352
|
expect(prompt).toContain("fast: Simple changes");
|
|
353
353
|
expect(prompt).toContain("balanced: Standard features");
|
|
354
354
|
expect(prompt).toContain("powerful: Complex architecture");
|
|
355
|
-
expect(prompt).toContain("test-after: Write implementation first");
|
|
356
|
-
expect(prompt).toContain("three-session-tdd: Separate test-writer");
|
|
357
355
|
});
|
|
358
356
|
|
|
359
357
|
test("buildBatchPrompt formats multiple stories", () => {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for git utility functions (TC-003)
|
|
3
|
+
*
|
|
4
|
+
* Covers: detectMergeConflict helper
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { detectMergeConflict } from "../../../src/utils/git";
|
|
9
|
+
|
|
10
|
+
describe("detectMergeConflict", () => {
|
|
11
|
+
test("returns true when output contains uppercase CONFLICT", () => {
|
|
12
|
+
expect(detectMergeConflict("CONFLICT (content): Merge conflict in src/foo.ts")).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("returns true when output contains lowercase conflict", () => {
|
|
16
|
+
expect(detectMergeConflict("Auto-merging failed due to conflict in file")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns true for typical git merge CONFLICT output", () => {
|
|
20
|
+
const output = [
|
|
21
|
+
"Auto-merging src/index.ts",
|
|
22
|
+
"CONFLICT (content): Merge conflict in src/index.ts",
|
|
23
|
+
"Automatic merge failed; fix conflicts and then commit the result.",
|
|
24
|
+
].join("\n");
|
|
25
|
+
expect(detectMergeConflict(output)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns true for git rebase CONFLICT output", () => {
|
|
29
|
+
const output = "CONFLICT (modify/delete): src/bar.ts deleted in HEAD";
|
|
30
|
+
expect(detectMergeConflict(output)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("returns false when output has no conflict markers", () => {
|
|
34
|
+
expect(detectMergeConflict("All changes committed successfully.")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("returns false for empty string", () => {
|
|
38
|
+
expect(detectMergeConflict("")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns false for unrelated git output", () => {
|
|
42
|
+
const output = "3 files changed, 10 insertions(+), 2 deletions(-)";
|
|
43
|
+
expect(detectMergeConflict(output)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns true when CONFLICT appears in stderr portion of combined output", () => {
|
|
47
|
+
const combined = "stdout: commit abc123\nstderr: CONFLICT detected in merge";
|
|
48
|
+
expect(detectMergeConflict(combined)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|