ai-spec-dev 0.37.0 → 0.41.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 +381 -1796
- package/RELEASE_LOG.md +231 -0
- package/cli/commands/create.ts +9 -1176
- package/cli/commands/dashboard.ts +1 -1
- package/cli/pipeline/helpers.ts +34 -0
- package/cli/pipeline/multi-repo.ts +483 -0
- package/cli/pipeline/single-repo.ts +755 -0
- package/cli/utils.ts +2 -0
- package/core/code-generator.ts +52 -341
- package/core/codegen/helpers.ts +219 -0
- package/core/codegen/topo-sort.ts +98 -0
- package/core/constitution-consolidator.ts +2 -2
- package/core/dsl-coverage-checker.ts +298 -0
- package/core/dsl-extractor.ts +19 -46
- package/core/dsl-feedback.ts +1 -1
- package/core/dsl-validator.ts +74 -0
- package/core/error-feedback.ts +95 -11
- package/core/frontend-context-loader.ts +27 -5
- package/core/knowledge-memory.ts +52 -0
- package/core/mock/fixtures.ts +89 -0
- package/core/mock/proxy.ts +380 -0
- package/core/mock-server-generator.ts +12 -460
- package/core/requirement-decomposer.ts +4 -28
- package/core/reviewer.ts +1 -1
- package/core/safe-json.ts +76 -0
- package/core/spec-updater.ts +5 -21
- package/core/token-budget.ts +124 -0
- package/core/vcr.ts +20 -1
- package/dist/cli/index.js +4110 -3534
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +4237 -3661
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +18 -16
- package/dist/index.d.ts +18 -16
- package/dist/index.js +310 -182
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +308 -180
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/purpose.md +173 -33
- package/tests/auto-consolidation.test.ts +109 -0
- package/tests/combined-generator.test.ts +81 -0
- package/tests/constitution-consolidator.test.ts +161 -0
- package/tests/constitution-generator.test.ts +94 -0
- package/tests/contract-bridge.test.ts +201 -0
- package/tests/design-dialogue.test.ts +108 -0
- package/tests/dsl-coverage-checker.test.ts +230 -0
- package/tests/dsl-feedback.test.ts +45 -0
- package/tests/dsl-validator-xref.test.ts +99 -0
- package/tests/error-feedback-repair.test.ts +319 -0
- package/tests/error-feedback-validation.test.ts +91 -0
- package/tests/frontend-context-loader.test.ts +609 -0
- package/tests/global-constitution.test.ts +110 -0
- package/tests/key-store.test.ts +73 -0
- package/tests/knowledge-memory.test.ts +327 -0
- package/tests/project-index.test.ts +206 -0
- package/tests/prompt-hasher.test.ts +19 -0
- package/tests/requirement-decomposer.test.ts +171 -0
- package/tests/reviewer.test.ts +4 -1
- package/tests/run-logger.test.ts +289 -0
- package/tests/run-snapshot.test.ts +113 -0
- package/tests/safe-json.test.ts +63 -0
- package/tests/spec-updater.test.ts +161 -0
- package/tests/test-generator.test.ts +146 -0
- package/tests/token-budget.test.ts +124 -0
- package/tests/vcr-hash.test.ts +101 -0
- package/tests/workspace-loader.test.ts +277 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs-extra";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import {
|
|
6
|
+
INDEX_FILE,
|
|
7
|
+
loadIndex,
|
|
8
|
+
saveIndex,
|
|
9
|
+
runScan,
|
|
10
|
+
} from "../core/project-index";
|
|
11
|
+
|
|
12
|
+
describe("loadIndex / saveIndex", () => {
|
|
13
|
+
let tmpDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
tmpDir = path.join(os.tmpdir(), `pi-io-${Date.now()}`);
|
|
17
|
+
await fs.ensureDir(tmpDir);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await fs.remove(tmpDir);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("loadIndex returns null when no file exists", async () => {
|
|
25
|
+
expect(await loadIndex(tmpDir)).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("saveIndex writes and loadIndex reads back", async () => {
|
|
29
|
+
const index = {
|
|
30
|
+
scanRoot: tmpDir,
|
|
31
|
+
lastScanned: new Date().toISOString(),
|
|
32
|
+
projects: [],
|
|
33
|
+
};
|
|
34
|
+
const filePath = await saveIndex(tmpDir, index);
|
|
35
|
+
expect(filePath).toBe(path.join(tmpDir, INDEX_FILE));
|
|
36
|
+
|
|
37
|
+
const loaded = await loadIndex(tmpDir);
|
|
38
|
+
expect(loaded).not.toBeNull();
|
|
39
|
+
expect(loaded!.scanRoot).toBe(tmpDir);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("runScan", () => {
|
|
44
|
+
let tmpDir: string;
|
|
45
|
+
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
tmpDir = path.join(os.tmpdir(), `pi-scan-${Date.now()}`);
|
|
48
|
+
await fs.ensureDir(tmpDir);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await fs.remove(tmpDir);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("discovers a Node.js project", async () => {
|
|
56
|
+
const projectDir = path.join(tmpDir, "my-app");
|
|
57
|
+
await fs.ensureDir(projectDir);
|
|
58
|
+
await fs.writeJson(path.join(projectDir, "package.json"), {
|
|
59
|
+
dependencies: { express: "4.0.0" },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await runScan(tmpDir);
|
|
63
|
+
expect(result.added).toHaveLength(1);
|
|
64
|
+
expect(result.added[0].name).toBe("my-app");
|
|
65
|
+
expect(result.added[0].type).toBe("node-express");
|
|
66
|
+
expect(result.added[0].role).toBe("backend");
|
|
67
|
+
expect(result.added[0].techStack).toContain("express");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("discovers a Go project", async () => {
|
|
71
|
+
const projectDir = path.join(tmpDir, "go-svc");
|
|
72
|
+
await fs.ensureDir(projectDir);
|
|
73
|
+
await fs.writeFile(path.join(projectDir, "go.mod"), "module example.com/go-svc");
|
|
74
|
+
|
|
75
|
+
const result = await runScan(tmpDir);
|
|
76
|
+
expect(result.added).toHaveLength(1);
|
|
77
|
+
expect(result.added[0].type).toBe("go");
|
|
78
|
+
expect(result.added[0].techStack).toContain("go");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("skips node_modules and hidden directories", async () => {
|
|
82
|
+
await fs.ensureDir(path.join(tmpDir, "node_modules", "pkg"));
|
|
83
|
+
await fs.writeJson(path.join(tmpDir, "node_modules", "pkg", "package.json"), {});
|
|
84
|
+
await fs.ensureDir(path.join(tmpDir, ".hidden"));
|
|
85
|
+
await fs.writeJson(path.join(tmpDir, ".hidden", "package.json"), {});
|
|
86
|
+
|
|
87
|
+
const result = await runScan(tmpDir);
|
|
88
|
+
expect(result.added).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("detects hasConstitution flag", async () => {
|
|
92
|
+
const projectDir = path.join(tmpDir, "app");
|
|
93
|
+
await fs.ensureDir(projectDir);
|
|
94
|
+
await fs.writeJson(path.join(projectDir, "package.json"), {});
|
|
95
|
+
await fs.writeFile(path.join(projectDir, ".ai-spec-constitution.md"), "rules");
|
|
96
|
+
|
|
97
|
+
const result = await runScan(tmpDir);
|
|
98
|
+
expect(result.added[0].hasConstitution).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("detects hasWorkspace flag", async () => {
|
|
102
|
+
const projectDir = path.join(tmpDir, "mono");
|
|
103
|
+
await fs.ensureDir(projectDir);
|
|
104
|
+
await fs.writeJson(path.join(projectDir, "package.json"), {});
|
|
105
|
+
await fs.writeJson(path.join(projectDir, ".ai-spec-workspace.json"), { name: "ws", repos: [] });
|
|
106
|
+
|
|
107
|
+
const result = await runScan(tmpDir);
|
|
108
|
+
expect(result.added[0].hasWorkspace).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("incremental scan: new project → added", async () => {
|
|
112
|
+
// First scan
|
|
113
|
+
const p1 = path.join(tmpDir, "app1");
|
|
114
|
+
await fs.ensureDir(p1);
|
|
115
|
+
await fs.writeJson(path.join(p1, "package.json"), {});
|
|
116
|
+
const r1 = await runScan(tmpDir);
|
|
117
|
+
await saveIndex(tmpDir, r1.index);
|
|
118
|
+
|
|
119
|
+
// Add new project
|
|
120
|
+
const p2 = path.join(tmpDir, "app2");
|
|
121
|
+
await fs.ensureDir(p2);
|
|
122
|
+
await fs.writeJson(path.join(p2, "package.json"), {});
|
|
123
|
+
|
|
124
|
+
const r2 = await runScan(tmpDir);
|
|
125
|
+
expect(r2.added).toHaveLength(1);
|
|
126
|
+
expect(r2.added[0].name).toBe("app2");
|
|
127
|
+
expect(r2.unchanged).toHaveLength(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("incremental scan: removed project → nowMissing", async () => {
|
|
131
|
+
const p1 = path.join(tmpDir, "app1");
|
|
132
|
+
await fs.ensureDir(p1);
|
|
133
|
+
await fs.writeJson(path.join(p1, "package.json"), {});
|
|
134
|
+
const r1 = await runScan(tmpDir);
|
|
135
|
+
await saveIndex(tmpDir, r1.index);
|
|
136
|
+
|
|
137
|
+
// Remove project
|
|
138
|
+
await fs.remove(p1);
|
|
139
|
+
const r2 = await runScan(tmpDir);
|
|
140
|
+
expect(r2.nowMissing).toHaveLength(1);
|
|
141
|
+
expect(r2.nowMissing[0].name).toBe("app1");
|
|
142
|
+
expect(r2.nowMissing[0].missing).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("incremental scan: changed type → updated", async () => {
|
|
146
|
+
const p1 = path.join(tmpDir, "app");
|
|
147
|
+
await fs.ensureDir(p1);
|
|
148
|
+
await fs.writeJson(path.join(p1, "package.json"), {});
|
|
149
|
+
const r1 = await runScan(tmpDir);
|
|
150
|
+
await saveIndex(tmpDir, r1.index);
|
|
151
|
+
|
|
152
|
+
// Add express → changes type from unknown to node-express
|
|
153
|
+
await fs.writeJson(path.join(p1, "package.json"), {
|
|
154
|
+
dependencies: { express: "4.0.0" },
|
|
155
|
+
});
|
|
156
|
+
const r2 = await runScan(tmpDir);
|
|
157
|
+
expect(r2.updated).toHaveLength(1);
|
|
158
|
+
expect(r2.updated[0].type).toBe("node-express");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("respects maxDepth", async () => {
|
|
162
|
+
// depth 0
|
|
163
|
+
const d1 = path.join(tmpDir, "level1");
|
|
164
|
+
await fs.ensureDir(d1);
|
|
165
|
+
// depth 1
|
|
166
|
+
const d2 = path.join(d1, "level2");
|
|
167
|
+
await fs.ensureDir(d2);
|
|
168
|
+
// depth 2
|
|
169
|
+
const d3 = path.join(d2, "level3");
|
|
170
|
+
await fs.ensureDir(d3);
|
|
171
|
+
await fs.writeJson(path.join(d3, "package.json"), {});
|
|
172
|
+
|
|
173
|
+
// With maxDepth=1, should NOT find level3
|
|
174
|
+
const r1 = await runScan(tmpDir, 1);
|
|
175
|
+
expect(r1.added).toHaveLength(0);
|
|
176
|
+
|
|
177
|
+
// With maxDepth=3, should find it
|
|
178
|
+
const r2 = await runScan(tmpDir, 3);
|
|
179
|
+
expect(r2.added).toHaveLength(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("projects are sorted by path", async () => {
|
|
183
|
+
for (const name of ["charlie", "alpha", "bravo"]) {
|
|
184
|
+
await fs.ensureDir(path.join(tmpDir, name));
|
|
185
|
+
await fs.writeJson(path.join(tmpDir, name, "package.json"), {});
|
|
186
|
+
}
|
|
187
|
+
const result = await runScan(tmpDir);
|
|
188
|
+
const names = result.index.projects.map((p) => p.name);
|
|
189
|
+
expect(names).toEqual(["alpha", "bravo", "charlie"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("extracts key dependencies in techStack", async () => {
|
|
193
|
+
const p = path.join(tmpDir, "app");
|
|
194
|
+
await fs.ensureDir(p);
|
|
195
|
+
await fs.writeJson(path.join(p, "package.json"), {
|
|
196
|
+
dependencies: { express: "4.0.0", prisma: "5.0.0" },
|
|
197
|
+
devDependencies: { vitest: "2.0.0", typescript: "5.0.0" },
|
|
198
|
+
});
|
|
199
|
+
const result = await runScan(tmpDir);
|
|
200
|
+
const stack = result.added[0].techStack;
|
|
201
|
+
expect(stack).toContain("express");
|
|
202
|
+
expect(stack).toContain("prisma");
|
|
203
|
+
expect(stack).toContain("vitest");
|
|
204
|
+
expect(stack).toContain("typescript");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { computePromptHash } from "../core/prompt-hasher";
|
|
3
|
+
|
|
4
|
+
describe("computePromptHash", () => {
|
|
5
|
+
it("returns an 8-char hex string", () => {
|
|
6
|
+
const hash = computePromptHash();
|
|
7
|
+
expect(hash).toMatch(/^[a-f0-9]{8}$/);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("is deterministic — same prompts produce same hash", () => {
|
|
11
|
+
const a = computePromptHash();
|
|
12
|
+
const b = computePromptHash();
|
|
13
|
+
expect(a).toBe(b);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("is a non-empty string", () => {
|
|
17
|
+
expect(computePromptHash().length).toBe(8);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { RequirementDecomposer } from "../core/requirement-decomposer";
|
|
3
|
+
import type { RepoRequirement } from "../core/requirement-decomposer";
|
|
4
|
+
|
|
5
|
+
// ─── sortByDependency (pure logic, no AI) ────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe("RequirementDecomposer.sortByDependency", () => {
|
|
8
|
+
it("puts repos with no dependencies first", () => {
|
|
9
|
+
const repos: RepoRequirement[] = [
|
|
10
|
+
{ repoName: "web", role: "frontend", specIdea: "UI", isContractProvider: false, dependsOnRepos: ["api"], uxDecisions: null },
|
|
11
|
+
{ repoName: "api", role: "backend", specIdea: "API", isContractProvider: true, dependsOnRepos: [], uxDecisions: null },
|
|
12
|
+
];
|
|
13
|
+
const sorted = RequirementDecomposer.sortByDependency(repos);
|
|
14
|
+
expect(sorted[0].repoName).toBe("api");
|
|
15
|
+
expect(sorted[1].repoName).toBe("web");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("handles multiple dependency levels", () => {
|
|
19
|
+
const repos: RepoRequirement[] = [
|
|
20
|
+
{ repoName: "mobile", role: "mobile", specIdea: "M", isContractProvider: false, dependsOnRepos: ["api"], uxDecisions: null },
|
|
21
|
+
{ repoName: "api", role: "backend", specIdea: "A", isContractProvider: true, dependsOnRepos: ["db"], uxDecisions: null },
|
|
22
|
+
{ repoName: "db", role: "backend", specIdea: "D", isContractProvider: true, dependsOnRepos: [], uxDecisions: null },
|
|
23
|
+
];
|
|
24
|
+
const sorted = RequirementDecomposer.sortByDependency(repos);
|
|
25
|
+
expect(sorted.map((r) => r.repoName)).toEqual(["db", "api", "mobile"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("handles circular dependencies without infinite loop", () => {
|
|
29
|
+
const repos: RepoRequirement[] = [
|
|
30
|
+
{ repoName: "a", role: "backend", specIdea: "A", isContractProvider: false, dependsOnRepos: ["b"], uxDecisions: null },
|
|
31
|
+
{ repoName: "b", role: "backend", specIdea: "B", isContractProvider: false, dependsOnRepos: ["a"], uxDecisions: null },
|
|
32
|
+
];
|
|
33
|
+
const sorted = RequirementDecomposer.sortByDependency(repos);
|
|
34
|
+
expect(sorted).toHaveLength(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("preserves order for independent repos", () => {
|
|
38
|
+
const repos: RepoRequirement[] = [
|
|
39
|
+
{ repoName: "x", role: "backend", specIdea: "X", isContractProvider: false, dependsOnRepos: [], uxDecisions: null },
|
|
40
|
+
{ repoName: "y", role: "backend", specIdea: "Y", isContractProvider: false, dependsOnRepos: [], uxDecisions: null },
|
|
41
|
+
];
|
|
42
|
+
const sorted = RequirementDecomposer.sortByDependency(repos);
|
|
43
|
+
expect(sorted[0].repoName).toBe("x");
|
|
44
|
+
expect(sorted[1].repoName).toBe("y");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("handles empty array", () => {
|
|
48
|
+
expect(RequirementDecomposer.sortByDependency([])).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ─── decompose (with AI mock) ────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe("RequirementDecomposer.decompose", () => {
|
|
55
|
+
const mockProvider = {
|
|
56
|
+
generate: vi.fn(),
|
|
57
|
+
providerName: "test",
|
|
58
|
+
modelName: "test-model",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
it("parses a valid decomposition response", async () => {
|
|
62
|
+
mockProvider.generate.mockResolvedValueOnce(JSON.stringify({
|
|
63
|
+
summary: "Order feature across API and web",
|
|
64
|
+
coordinationNotes: "Shared types needed",
|
|
65
|
+
repos: [
|
|
66
|
+
{
|
|
67
|
+
repoName: "api",
|
|
68
|
+
role: "backend",
|
|
69
|
+
specIdea: "Build order CRUD endpoints",
|
|
70
|
+
isContractProvider: true,
|
|
71
|
+
dependsOnRepos: [],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
repoName: "web",
|
|
75
|
+
role: "frontend",
|
|
76
|
+
specIdea: "Build order management UI",
|
|
77
|
+
isContractProvider: false,
|
|
78
|
+
dependsOnRepos: ["api"],
|
|
79
|
+
uxDecisions: { optimisticUpdate: true, errorRollback: true, loadingState: true },
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
const decomposer = new RequirementDecomposer(mockProvider);
|
|
85
|
+
const result = await decomposer.decompose(
|
|
86
|
+
"Build order system",
|
|
87
|
+
{ name: "ws", repos: [] },
|
|
88
|
+
new Map()
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(result.summary).toContain("Order");
|
|
92
|
+
expect(result.repos).toHaveLength(2);
|
|
93
|
+
expect(result.repos[0].isContractProvider).toBe(true);
|
|
94
|
+
expect(result.repos[1].uxDecisions?.optimisticUpdate).toBe(true);
|
|
95
|
+
expect(result.originalRequirement).toBe("Build order system");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("handles fenced JSON in response", async () => {
|
|
99
|
+
mockProvider.generate.mockResolvedValueOnce(
|
|
100
|
+
"Here's the decomposition:\n```json\n" +
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
summary: "Test",
|
|
103
|
+
coordinationNotes: "",
|
|
104
|
+
repos: [{ repoName: "api", role: "backend", specIdea: "Build API", isContractProvider: false, dependsOnRepos: [] }],
|
|
105
|
+
}) +
|
|
106
|
+
"\n```"
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const decomposer = new RequirementDecomposer(mockProvider);
|
|
110
|
+
const result = await decomposer.decompose("test", { name: "ws", repos: [] }, new Map());
|
|
111
|
+
expect(result.repos).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("throws on invalid JSON response", async () => {
|
|
115
|
+
mockProvider.generate.mockResolvedValueOnce("not json at all");
|
|
116
|
+
|
|
117
|
+
const decomposer = new RequirementDecomposer(mockProvider);
|
|
118
|
+
await expect(
|
|
119
|
+
decomposer.decompose("test", { name: "ws", repos: [] }, new Map())
|
|
120
|
+
).rejects.toThrow("Failed to parse");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("throws on missing summary field", async () => {
|
|
124
|
+
mockProvider.generate.mockResolvedValueOnce(JSON.stringify({
|
|
125
|
+
coordinationNotes: "",
|
|
126
|
+
repos: [{ repoName: "api", specIdea: "x" }],
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
const decomposer = new RequirementDecomposer(mockProvider);
|
|
130
|
+
await expect(
|
|
131
|
+
decomposer.decompose("test", { name: "ws", repos: [] }, new Map())
|
|
132
|
+
).rejects.toThrow("summary");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("throws on empty repos array", async () => {
|
|
136
|
+
mockProvider.generate.mockResolvedValueOnce(JSON.stringify({
|
|
137
|
+
summary: "Test",
|
|
138
|
+
coordinationNotes: "",
|
|
139
|
+
repos: [],
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
const decomposer = new RequirementDecomposer(mockProvider);
|
|
143
|
+
await expect(
|
|
144
|
+
decomposer.decompose("test", { name: "ws", repos: [] }, new Map())
|
|
145
|
+
).rejects.toThrow("non-empty array");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("throws when AI call fails", async () => {
|
|
149
|
+
mockProvider.generate.mockRejectedValueOnce(new Error("API timeout"));
|
|
150
|
+
|
|
151
|
+
const decomposer = new RequirementDecomposer(mockProvider);
|
|
152
|
+
await expect(
|
|
153
|
+
decomposer.decompose("test", { name: "ws", repos: [] }, new Map())
|
|
154
|
+
).rejects.toThrow("AI call for requirement decomposition failed");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("defaults missing fields in repo requirements", async () => {
|
|
158
|
+
mockProvider.generate.mockResolvedValueOnce(JSON.stringify({
|
|
159
|
+
summary: "Test",
|
|
160
|
+
coordinationNotes: "",
|
|
161
|
+
repos: [{ repoName: "api", specIdea: "Build API" }],
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
const decomposer = new RequirementDecomposer(mockProvider);
|
|
165
|
+
const result = await decomposer.decompose("test", { name: "ws", repos: [] }, new Map());
|
|
166
|
+
expect(result.repos[0].role).toBe("backend"); // default
|
|
167
|
+
expect(result.repos[0].isContractProvider).toBe(false); // default
|
|
168
|
+
expect(result.repos[0].dependsOnRepos).toEqual([]); // default
|
|
169
|
+
expect(result.repos[0].uxDecisions).toBeNull(); // default
|
|
170
|
+
});
|
|
171
|
+
});
|
package/tests/reviewer.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { AIProvider } from "../core/spec-generator";
|
|
|
8
8
|
import * as fs from "fs-extra";
|
|
9
9
|
import * as path from "path";
|
|
10
10
|
import * as os from "os";
|
|
11
|
+
import { execSync } from "child_process";
|
|
11
12
|
|
|
12
13
|
// ─── extractComplianceScore ──────────────────────────────────────────────────
|
|
13
14
|
|
|
@@ -103,9 +104,11 @@ describe("CodeReviewer", () => {
|
|
|
103
104
|
};
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
it("returns 'No changes' when git diff is empty (
|
|
107
|
+
it("returns 'No changes' when git diff is empty (isolated git repo)", async () => {
|
|
107
108
|
const provider = makeProvider("review result");
|
|
108
109
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
110
|
+
// Create an isolated git repo with no commits/diff
|
|
111
|
+
execSync("git init", { cwd: tmpDir, stdio: "pipe" });
|
|
109
112
|
const reviewer = new CodeReviewer(provider, tmpDir);
|
|
110
113
|
const result = await reviewer.reviewCode("spec content");
|
|
111
114
|
expect(result).toBe("No changes");
|