ai-spec-dev 0.38.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/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,146 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import * as fs from "fs-extra";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import { TestGenerator } from "../core/test-generator";
|
|
6
|
+
import { SpecDSL } from "../core/dsl-types";
|
|
7
|
+
|
|
8
|
+
function makeDsl(): SpecDSL {
|
|
9
|
+
return {
|
|
10
|
+
feature: { title: "Orders", description: "Order management" },
|
|
11
|
+
models: [
|
|
12
|
+
{
|
|
13
|
+
name: "Order",
|
|
14
|
+
fields: [
|
|
15
|
+
{ name: "id", type: "Int", required: true, unique: true },
|
|
16
|
+
{ name: "total", type: "Float", required: true, unique: false },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
endpoints: [
|
|
21
|
+
{
|
|
22
|
+
id: "createOrder",
|
|
23
|
+
method: "POST",
|
|
24
|
+
path: "/api/orders",
|
|
25
|
+
auth: true,
|
|
26
|
+
description: "Create order",
|
|
27
|
+
request: { body: { total: "number" }, params: {}, query: {} },
|
|
28
|
+
successStatus: 201,
|
|
29
|
+
successDescription: "Created",
|
|
30
|
+
errors: [{ status: 400, code: "INVALID", description: "Bad input" }],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
behaviors: [
|
|
34
|
+
{ description: "Order total must be positive", constraints: ["total > 0"] },
|
|
35
|
+
],
|
|
36
|
+
} as SpecDSL;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("TestGenerator", () => {
|
|
40
|
+
let tmpDir: string;
|
|
41
|
+
const mockProvider = {
|
|
42
|
+
generate: vi.fn(),
|
|
43
|
+
providerName: "test",
|
|
44
|
+
modelName: "test-model",
|
|
45
|
+
};
|
|
46
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
tmpDir = path.join(os.tmpdir(), `tg-test-${Date.now()}`);
|
|
50
|
+
await fs.ensureDir(tmpDir);
|
|
51
|
+
mockProvider.generate.mockReset();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
await fs.remove(tmpDir);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("generate() writes test files returned by AI", async () => {
|
|
59
|
+
// No package.json → backend mode
|
|
60
|
+
mockProvider.generate.mockResolvedValueOnce(
|
|
61
|
+
JSON.stringify([
|
|
62
|
+
{ file: "tests/order.test.ts", content: 'describe("Order", () => {});\n' },
|
|
63
|
+
])
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const gen = new TestGenerator(mockProvider);
|
|
67
|
+
const files = await gen.generate(makeDsl(), tmpDir);
|
|
68
|
+
expect(files).toEqual(["tests/order.test.ts"]);
|
|
69
|
+
expect(await fs.readFile(path.join(tmpDir, "tests/order.test.ts"), "utf-8")).toContain("Order");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("generate() returns empty array when AI returns invalid JSON", async () => {
|
|
73
|
+
mockProvider.generate.mockResolvedValueOnce("not json");
|
|
74
|
+
const gen = new TestGenerator(mockProvider);
|
|
75
|
+
const files = await gen.generate(makeDsl(), tmpDir);
|
|
76
|
+
expect(files).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("generate() returns empty array when AI call fails", async () => {
|
|
80
|
+
mockProvider.generate.mockRejectedValueOnce(new Error("timeout"));
|
|
81
|
+
const gen = new TestGenerator(mockProvider);
|
|
82
|
+
const files = await gen.generate(makeDsl(), tmpDir);
|
|
83
|
+
expect(files).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("generate() detects frontend mode from package.json", async () => {
|
|
87
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
88
|
+
dependencies: { react: "18.0.0" },
|
|
89
|
+
devDependencies: { vitest: "2.0.0" },
|
|
90
|
+
});
|
|
91
|
+
mockProvider.generate.mockResolvedValueOnce("[]");
|
|
92
|
+
|
|
93
|
+
const gen = new TestGenerator(mockProvider);
|
|
94
|
+
await gen.generate(makeDsl(), tmpDir);
|
|
95
|
+
// Should have called with frontend system prompt (we just verify it doesn't crash)
|
|
96
|
+
expect(mockProvider.generate).toHaveBeenCalledOnce();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("generate() uses existing test directory when found", async () => {
|
|
100
|
+
await fs.ensureDir(path.join(tmpDir, "__tests__"));
|
|
101
|
+
mockProvider.generate.mockResolvedValueOnce(
|
|
102
|
+
JSON.stringify([{ file: "__tests__/order.test.ts", content: "test" }])
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const gen = new TestGenerator(mockProvider);
|
|
106
|
+
const files = await gen.generate(makeDsl(), tmpDir);
|
|
107
|
+
expect(files).toEqual(["__tests__/order.test.ts"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("generate() handles fenced JSON response", async () => {
|
|
111
|
+
mockProvider.generate.mockResolvedValueOnce(
|
|
112
|
+
'```json\n[{"file":"tests/a.test.ts","content":"test code"}]\n```'
|
|
113
|
+
);
|
|
114
|
+
const gen = new TestGenerator(mockProvider);
|
|
115
|
+
const files = await gen.generate(makeDsl(), tmpDir);
|
|
116
|
+
expect(files).toEqual(["tests/a.test.ts"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("generateTdd() writes TDD test files", async () => {
|
|
120
|
+
mockProvider.generate.mockResolvedValueOnce(
|
|
121
|
+
JSON.stringify([
|
|
122
|
+
{ file: "tests/order.tdd.test.ts", content: 'it("should create order", () => { expect(true).toBe(false); });\n' },
|
|
123
|
+
])
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const gen = new TestGenerator(mockProvider);
|
|
127
|
+
const files = await gen.generateTdd(makeDsl(), tmpDir);
|
|
128
|
+
expect(files).toEqual(["tests/order.tdd.test.ts"]);
|
|
129
|
+
const content = await fs.readFile(path.join(tmpDir, "tests/order.tdd.test.ts"), "utf-8");
|
|
130
|
+
expect(content).toContain("should create order");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("generateTdd() returns empty on AI failure", async () => {
|
|
134
|
+
mockProvider.generate.mockRejectedValueOnce(new Error("fail"));
|
|
135
|
+
const gen = new TestGenerator(mockProvider);
|
|
136
|
+
const files = await gen.generateTdd(makeDsl(), tmpDir);
|
|
137
|
+
expect(files).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("generateTdd() returns empty on invalid JSON", async () => {
|
|
141
|
+
mockProvider.generate.mockResolvedValueOnce("not json");
|
|
142
|
+
const gen = new TestGenerator(mockProvider);
|
|
143
|
+
const files = await gen.generateTdd(makeDsl(), tmpDir);
|
|
144
|
+
expect(files).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
estimateTokens,
|
|
4
|
+
assembleSections,
|
|
5
|
+
getDefaultBudget,
|
|
6
|
+
BudgetSection,
|
|
7
|
+
} from "../core/token-budget";
|
|
8
|
+
|
|
9
|
+
// Suppress console.log from assembleSections warnings
|
|
10
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
11
|
+
|
|
12
|
+
// ─── estimateTokens ─────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe("estimateTokens", () => {
|
|
15
|
+
it("returns 0 for empty string", () => {
|
|
16
|
+
expect(estimateTokens("")).toBe(0);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("estimates English text (~4 chars per token)", () => {
|
|
20
|
+
const text = "Hello world, this is a test string";
|
|
21
|
+
const tokens = estimateTokens(text);
|
|
22
|
+
// 34 chars / 4 ≈ 9 tokens
|
|
23
|
+
expect(tokens).toBeGreaterThanOrEqual(8);
|
|
24
|
+
expect(tokens).toBeLessThanOrEqual(12);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("estimates CJK text (~1 char per token)", () => {
|
|
28
|
+
const text = "你好世界这是测试";
|
|
29
|
+
const tokens = estimateTokens(text);
|
|
30
|
+
// 8 CJK chars ≈ 8 tokens
|
|
31
|
+
expect(tokens).toBe(8);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("handles mixed CJK + English", () => {
|
|
35
|
+
const text = "Hello 你好";
|
|
36
|
+
const tokens = estimateTokens(text);
|
|
37
|
+
// "Hello " = 6 non-CJK / 4 = 1.5, "你好" = 2 CJK → ~4 total
|
|
38
|
+
expect(tokens).toBeGreaterThanOrEqual(3);
|
|
39
|
+
expect(tokens).toBeLessThanOrEqual(5);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles code content", () => {
|
|
43
|
+
const code = 'export function foo(bar: string): number {\n return bar.length;\n}';
|
|
44
|
+
const tokens = estimateTokens(code);
|
|
45
|
+
expect(tokens).toBeGreaterThan(10);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ─── assembleSections ───────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe("assembleSections", () => {
|
|
52
|
+
it("includes all sections when within budget", () => {
|
|
53
|
+
const sections: BudgetSection[] = [
|
|
54
|
+
{ name: "spec", content: "Feature spec content", priority: 2 },
|
|
55
|
+
{ name: "dsl", content: "DSL context", priority: 2 },
|
|
56
|
+
];
|
|
57
|
+
const result = assembleSections(sections, 10000);
|
|
58
|
+
expect(result.trimmedSections).toHaveLength(0);
|
|
59
|
+
expect(result.assembledPrompt).toContain("Feature spec content");
|
|
60
|
+
expect(result.assembledPrompt).toContain("DSL context");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("sorts by priority (higher priority included first)", () => {
|
|
64
|
+
const sections: BudgetSection[] = [
|
|
65
|
+
{ name: "low", content: "L".repeat(100), priority: 5 },
|
|
66
|
+
{ name: "high", content: "H".repeat(100), priority: 1 },
|
|
67
|
+
];
|
|
68
|
+
const result = assembleSections(sections, 10000);
|
|
69
|
+
const hIdx = result.assembledPrompt.indexOf("H");
|
|
70
|
+
const lIdx = result.assembledPrompt.indexOf("L");
|
|
71
|
+
expect(hIdx).toBeLessThan(lIdx);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("trims lower-priority sections when budget exceeded", () => {
|
|
75
|
+
const sections: BudgetSection[] = [
|
|
76
|
+
{ name: "critical", content: "A".repeat(400), priority: 1 },
|
|
77
|
+
{ name: "nice-to-have", content: "B".repeat(4000), priority: 5 },
|
|
78
|
+
];
|
|
79
|
+
// Budget of ~200 tokens ≈ 800 chars English
|
|
80
|
+
const result = assembleSections(sections, 200);
|
|
81
|
+
expect(result.trimmedSections).toContain("nice-to-have");
|
|
82
|
+
expect(result.assembledPrompt).toContain("A".repeat(400));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("drops sections entirely when no room at all", () => {
|
|
86
|
+
const sections: BudgetSection[] = [
|
|
87
|
+
{ name: "critical", content: "A".repeat(4000), priority: 1 },
|
|
88
|
+
{ name: "dropped", content: "B".repeat(4000), priority: 5 },
|
|
89
|
+
];
|
|
90
|
+
// Very tight budget
|
|
91
|
+
const result = assembleSections(sections, 1000);
|
|
92
|
+
expect(result.trimmedSections.length).toBeGreaterThan(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("skips empty sections", () => {
|
|
96
|
+
const sections: BudgetSection[] = [
|
|
97
|
+
{ name: "empty", content: "", priority: 1 },
|
|
98
|
+
{ name: "content", content: "Real content", priority: 2 },
|
|
99
|
+
];
|
|
100
|
+
const result = assembleSections(sections, 10000);
|
|
101
|
+
expect(result.assembledPrompt).toBe("Real content");
|
|
102
|
+
expect(result.trimmedSections).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns empty prompt when all sections are empty", () => {
|
|
106
|
+
const result = assembleSections([], 10000);
|
|
107
|
+
expect(result.assembledPrompt).toBe("");
|
|
108
|
+
expect(result.totalTokens).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ─── getDefaultBudget ───────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe("getDefaultBudget", () => {
|
|
115
|
+
it("returns known provider budgets", () => {
|
|
116
|
+
expect(getDefaultBudget("gemini")).toBe(900_000);
|
|
117
|
+
expect(getDefaultBudget("claude")).toBe(180_000);
|
|
118
|
+
expect(getDefaultBudget("openai")).toBe(120_000);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns default for unknown providers", () => {
|
|
122
|
+
expect(getDefaultBudget("unknown-provider")).toBe(100_000);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { VcrReplayProvider, VcrRecording, VcrRecordingProvider } from "../core/vcr";
|
|
4
|
+
|
|
5
|
+
function makeRecording(entries: Array<{ prompt: string; system?: string; response: string }>): VcrRecording {
|
|
6
|
+
return {
|
|
7
|
+
runId: "test-run",
|
|
8
|
+
recordedAt: "2026-04-02T00:00:00Z",
|
|
9
|
+
entryCount: entries.length,
|
|
10
|
+
providers: ["test/model"],
|
|
11
|
+
entries: entries.map((e, i) => ({
|
|
12
|
+
index: i,
|
|
13
|
+
promptPreview: e.prompt.slice(0, 200),
|
|
14
|
+
callHash: createHash("sha256")
|
|
15
|
+
.update(e.prompt + "\x00" + (e.system ?? ""))
|
|
16
|
+
.digest("hex")
|
|
17
|
+
.slice(0, 8),
|
|
18
|
+
response: e.response,
|
|
19
|
+
providerName: "test",
|
|
20
|
+
modelName: "model",
|
|
21
|
+
ts: new Date().toISOString(),
|
|
22
|
+
durationMs: 0,
|
|
23
|
+
})),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("VcrReplayProvider prompt hash validation", () => {
|
|
28
|
+
it("reports no mismatches when prompts match", async () => {
|
|
29
|
+
const recording = makeRecording([
|
|
30
|
+
{ prompt: "hello", system: "sys", response: "world" },
|
|
31
|
+
]);
|
|
32
|
+
const replay = new VcrReplayProvider(recording);
|
|
33
|
+
const result = await replay.generate("hello", "sys");
|
|
34
|
+
expect(result).toBe("world");
|
|
35
|
+
expect(replay.hasMismatches).toBe(false);
|
|
36
|
+
expect(replay.mismatches).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("detects mismatch when prompt changes", async () => {
|
|
40
|
+
const recording = makeRecording([
|
|
41
|
+
{ prompt: "hello", response: "world" },
|
|
42
|
+
]);
|
|
43
|
+
const replay = new VcrReplayProvider(recording);
|
|
44
|
+
await replay.generate("different prompt");
|
|
45
|
+
expect(replay.hasMismatches).toBe(true);
|
|
46
|
+
expect(replay.mismatches).toHaveLength(1);
|
|
47
|
+
expect(replay.mismatches[0].index).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("detects mismatch when system instruction changes", async () => {
|
|
51
|
+
const recording = makeRecording([
|
|
52
|
+
{ prompt: "hello", system: "original-system", response: "world" },
|
|
53
|
+
]);
|
|
54
|
+
const replay = new VcrReplayProvider(recording);
|
|
55
|
+
await replay.generate("hello", "modified-system");
|
|
56
|
+
expect(replay.hasMismatches).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("tracks multiple mismatches across calls", async () => {
|
|
60
|
+
const recording = makeRecording([
|
|
61
|
+
{ prompt: "a", response: "1" },
|
|
62
|
+
{ prompt: "b", response: "2" },
|
|
63
|
+
{ prompt: "c", response: "3" },
|
|
64
|
+
]);
|
|
65
|
+
const replay = new VcrReplayProvider(recording);
|
|
66
|
+
await replay.generate("a"); // match
|
|
67
|
+
await replay.generate("changed"); // mismatch
|
|
68
|
+
await replay.generate("c"); // match
|
|
69
|
+
expect(replay.mismatches).toHaveLength(1);
|
|
70
|
+
expect(replay.mismatches[0].index).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("still returns response even on mismatch", async () => {
|
|
74
|
+
const recording = makeRecording([
|
|
75
|
+
{ prompt: "original", response: "recorded-response" },
|
|
76
|
+
]);
|
|
77
|
+
const replay = new VcrReplayProvider(recording);
|
|
78
|
+
const result = await replay.generate("different");
|
|
79
|
+
expect(result).toBe("recorded-response");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("throws when recording is exhausted", async () => {
|
|
83
|
+
const recording = makeRecording([{ prompt: "a", response: "1" }]);
|
|
84
|
+
const replay = new VcrReplayProvider(recording);
|
|
85
|
+
await replay.generate("a");
|
|
86
|
+
await expect(replay.generate("b")).rejects.toThrow(/exhausted/);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("VcrRecordingProvider", () => {
|
|
91
|
+
it("records calls with correct hash", async () => {
|
|
92
|
+
const inner = {
|
|
93
|
+
providerName: "test",
|
|
94
|
+
modelName: "model",
|
|
95
|
+
generate: async () => "response",
|
|
96
|
+
};
|
|
97
|
+
const recorder = new VcrRecordingProvider(inner);
|
|
98
|
+
await recorder.generate("prompt", "system");
|
|
99
|
+
expect(recorder.callCount).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
detectRepoType,
|
|
7
|
+
WorkspaceLoader,
|
|
8
|
+
WORKSPACE_CONFIG_FILE,
|
|
9
|
+
} from "../core/workspace-loader";
|
|
10
|
+
|
|
11
|
+
// ─── detectRepoType ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("detectRepoType", () => {
|
|
14
|
+
let tmpDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
tmpDir = path.join(os.tmpdir(), `wl-detect-${Date.now()}`);
|
|
18
|
+
await fs.ensureDir(tmpDir);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await fs.remove(tmpDir);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("detects Go project", async () => {
|
|
26
|
+
await fs.writeFile(path.join(tmpDir, "go.mod"), "module example.com/app");
|
|
27
|
+
const result = await detectRepoType(tmpDir);
|
|
28
|
+
expect(result).toEqual({ type: "go", role: "backend" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("detects Rust project", async () => {
|
|
32
|
+
await fs.writeFile(path.join(tmpDir, "Cargo.toml"), "[package]");
|
|
33
|
+
const result = await detectRepoType(tmpDir);
|
|
34
|
+
expect(result).toEqual({ type: "rust", role: "backend" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("detects Java (pom.xml)", async () => {
|
|
38
|
+
await fs.writeFile(path.join(tmpDir, "pom.xml"), "<project/>");
|
|
39
|
+
const result = await detectRepoType(tmpDir);
|
|
40
|
+
expect(result).toEqual({ type: "java", role: "backend" });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("detects Java (build.gradle)", async () => {
|
|
44
|
+
await fs.writeFile(path.join(tmpDir, "build.gradle"), "plugins {}");
|
|
45
|
+
const result = await detectRepoType(tmpDir);
|
|
46
|
+
expect(result).toEqual({ type: "java", role: "backend" });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("detects Python (requirements.txt)", async () => {
|
|
50
|
+
await fs.writeFile(path.join(tmpDir, "requirements.txt"), "flask");
|
|
51
|
+
const result = await detectRepoType(tmpDir);
|
|
52
|
+
expect(result).toEqual({ type: "python", role: "backend" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("detects Python (pyproject.toml)", async () => {
|
|
56
|
+
await fs.writeFile(path.join(tmpDir, "pyproject.toml"), "[tool.poetry]");
|
|
57
|
+
const result = await detectRepoType(tmpDir);
|
|
58
|
+
expect(result).toEqual({ type: "python", role: "backend" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("detects PHP", async () => {
|
|
62
|
+
await fs.writeFile(path.join(tmpDir, "composer.json"), "{}");
|
|
63
|
+
const result = await detectRepoType(tmpDir);
|
|
64
|
+
expect(result).toEqual({ type: "php", role: "backend" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("detects React Native", async () => {
|
|
68
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
69
|
+
dependencies: { "react-native": "0.74.0" },
|
|
70
|
+
});
|
|
71
|
+
const result = await detectRepoType(tmpDir);
|
|
72
|
+
expect(result).toEqual({ type: "react-native", role: "mobile" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("detects Next.js", async () => {
|
|
76
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
77
|
+
dependencies: { next: "14.0.0", react: "18.0.0" },
|
|
78
|
+
});
|
|
79
|
+
const result = await detectRepoType(tmpDir);
|
|
80
|
+
expect(result).toEqual({ type: "next", role: "frontend" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("detects React", async () => {
|
|
84
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
85
|
+
dependencies: { react: "18.0.0" },
|
|
86
|
+
});
|
|
87
|
+
const result = await detectRepoType(tmpDir);
|
|
88
|
+
expect(result).toEqual({ type: "react", role: "frontend" });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("detects Vue", async () => {
|
|
92
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
93
|
+
dependencies: { vue: "3.4.0" },
|
|
94
|
+
});
|
|
95
|
+
const result = await detectRepoType(tmpDir);
|
|
96
|
+
expect(result).toEqual({ type: "vue", role: "frontend" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("detects Koa", async () => {
|
|
100
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
101
|
+
dependencies: { koa: "2.0.0" },
|
|
102
|
+
});
|
|
103
|
+
const result = await detectRepoType(tmpDir);
|
|
104
|
+
expect(result).toEqual({ type: "node-koa", role: "backend" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("detects Express", async () => {
|
|
108
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
109
|
+
dependencies: { express: "4.18.0" },
|
|
110
|
+
});
|
|
111
|
+
const result = await detectRepoType(tmpDir);
|
|
112
|
+
expect(result).toEqual({ type: "node-express", role: "backend" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("detects NestJS as node-express", async () => {
|
|
116
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
117
|
+
dependencies: { "@nestjs/core": "10.0.0" },
|
|
118
|
+
});
|
|
119
|
+
const result = await detectRepoType(tmpDir);
|
|
120
|
+
expect(result).toEqual({ type: "node-express", role: "backend" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("detects Prisma-only as node-express backend", async () => {
|
|
124
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
125
|
+
dependencies: { "@prisma/client": "5.0.0" },
|
|
126
|
+
});
|
|
127
|
+
const result = await detectRepoType(tmpDir);
|
|
128
|
+
expect(result).toEqual({ type: "node-express", role: "backend" });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns unknown/shared for empty package.json", async () => {
|
|
132
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {});
|
|
133
|
+
const result = await detectRepoType(tmpDir);
|
|
134
|
+
expect(result).toEqual({ type: "unknown", role: "shared" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns unknown/shared when no manifest exists", async () => {
|
|
138
|
+
const result = await detectRepoType(tmpDir);
|
|
139
|
+
expect(result).toEqual({ type: "unknown", role: "shared" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns unknown/shared for corrupt package.json", async () => {
|
|
143
|
+
await fs.writeFile(path.join(tmpDir, "package.json"), "not json");
|
|
144
|
+
const result = await detectRepoType(tmpDir);
|
|
145
|
+
expect(result).toEqual({ type: "unknown", role: "shared" });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ─── WorkspaceLoader ─────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe("WorkspaceLoader", () => {
|
|
152
|
+
let tmpDir: string;
|
|
153
|
+
|
|
154
|
+
beforeEach(async () => {
|
|
155
|
+
tmpDir = path.join(os.tmpdir(), `wl-load-${Date.now()}`);
|
|
156
|
+
await fs.ensureDir(tmpDir);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
afterEach(async () => {
|
|
160
|
+
await fs.remove(tmpDir);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("load() returns null when no config file exists", async () => {
|
|
164
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
165
|
+
expect(await loader.load()).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("load() throws on invalid JSON", async () => {
|
|
169
|
+
await fs.writeFile(path.join(tmpDir, WORKSPACE_CONFIG_FILE), "not json");
|
|
170
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
171
|
+
await expect(loader.load()).rejects.toThrow("Failed to parse");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("load() throws on missing required fields", async () => {
|
|
175
|
+
await fs.writeJson(path.join(tmpDir, WORKSPACE_CONFIG_FILE), { foo: "bar" });
|
|
176
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
177
|
+
await expect(loader.load()).rejects.toThrow("missing required fields");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("load() throws on empty repos array", async () => {
|
|
181
|
+
await fs.writeJson(path.join(tmpDir, WORKSPACE_CONFIG_FILE), {
|
|
182
|
+
name: "test-ws",
|
|
183
|
+
repos: [],
|
|
184
|
+
});
|
|
185
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
186
|
+
await expect(loader.load()).rejects.toThrow("non-empty array");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("load() returns config with resolved repos", async () => {
|
|
190
|
+
const repoDir = path.join(tmpDir, "my-api");
|
|
191
|
+
await fs.ensureDir(repoDir);
|
|
192
|
+
await fs.writeJson(path.join(tmpDir, WORKSPACE_CONFIG_FILE), {
|
|
193
|
+
name: "test-ws",
|
|
194
|
+
repos: [{ name: "my-api", path: "my-api", type: "node-express", role: "backend" }],
|
|
195
|
+
});
|
|
196
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
197
|
+
const config = await loader.load();
|
|
198
|
+
expect(config).not.toBeNull();
|
|
199
|
+
expect(config!.name).toBe("test-ws");
|
|
200
|
+
expect(config!.repos).toHaveLength(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("load() loads constitution when present", async () => {
|
|
204
|
+
const repoDir = path.join(tmpDir, "my-api");
|
|
205
|
+
await fs.ensureDir(repoDir);
|
|
206
|
+
await fs.writeFile(path.join(repoDir, ".ai-spec-constitution.md"), "my rules");
|
|
207
|
+
await fs.writeJson(path.join(tmpDir, WORKSPACE_CONFIG_FILE), {
|
|
208
|
+
name: "ws",
|
|
209
|
+
repos: [{ name: "my-api", path: "my-api", type: "node-express", role: "backend" }],
|
|
210
|
+
});
|
|
211
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
212
|
+
const config = await loader.load();
|
|
213
|
+
expect(config!.repos[0].constitution).toBe("my rules");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("resolveAbsPath returns absolute path", () => {
|
|
217
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
218
|
+
const abs = loader.resolveAbsPath({ name: "api", path: "api", type: "node-express", role: "backend" });
|
|
219
|
+
expect(path.isAbsolute(abs)).toBe(true);
|
|
220
|
+
expect(abs).toBe(path.join(tmpDir, "api"));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("save() writes config without runtime constitution", async () => {
|
|
224
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
225
|
+
await loader.save({
|
|
226
|
+
name: "ws",
|
|
227
|
+
repos: [{ name: "api", path: "api", type: "node-express", role: "backend", constitution: "should be stripped" }],
|
|
228
|
+
});
|
|
229
|
+
const saved = await fs.readJson(path.join(tmpDir, WORKSPACE_CONFIG_FILE));
|
|
230
|
+
expect(saved.repos[0].constitution).toBeUndefined();
|
|
231
|
+
expect(saved.name).toBe("ws");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("autoDetect() discovers repos with package.json", async () => {
|
|
235
|
+
const repoDir = path.join(tmpDir, "my-app");
|
|
236
|
+
await fs.ensureDir(repoDir);
|
|
237
|
+
await fs.writeJson(path.join(repoDir, "package.json"), {
|
|
238
|
+
dependencies: { express: "4.0.0" },
|
|
239
|
+
});
|
|
240
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
241
|
+
const repos = await loader.autoDetect();
|
|
242
|
+
expect(repos).toHaveLength(1);
|
|
243
|
+
expect(repos[0].name).toBe("my-app");
|
|
244
|
+
expect(repos[0].type).toBe("node-express");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("autoDetect() skips dotfiles and node_modules", async () => {
|
|
248
|
+
await fs.ensureDir(path.join(tmpDir, ".hidden"));
|
|
249
|
+
await fs.writeJson(path.join(tmpDir, ".hidden", "package.json"), {});
|
|
250
|
+
await fs.ensureDir(path.join(tmpDir, "node_modules", "pkg"));
|
|
251
|
+
await fs.writeJson(path.join(tmpDir, "node_modules", "pkg", "package.json"), {});
|
|
252
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
253
|
+
const repos = await loader.autoDetect();
|
|
254
|
+
expect(repos).toHaveLength(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("autoDetect() filters by names when provided", async () => {
|
|
258
|
+
for (const name of ["alpha", "beta", "gamma"]) {
|
|
259
|
+
await fs.ensureDir(path.join(tmpDir, name));
|
|
260
|
+
await fs.writeJson(path.join(tmpDir, name, "package.json"), {});
|
|
261
|
+
}
|
|
262
|
+
const loader = new WorkspaceLoader(tmpDir);
|
|
263
|
+
const repos = await loader.autoDetect(["alpha", "gamma"]);
|
|
264
|
+
expect(repos.map((r) => r.name).sort()).toEqual(["alpha", "gamma"]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("getProcessingOrder sorts backend → shared → frontend → mobile", () => {
|
|
268
|
+
const repos = [
|
|
269
|
+
{ name: "web", path: "web", type: "react" as const, role: "frontend" as const },
|
|
270
|
+
{ name: "api", path: "api", type: "node-express" as const, role: "backend" as const },
|
|
271
|
+
{ name: "app", path: "app", type: "react-native" as const, role: "mobile" as const },
|
|
272
|
+
{ name: "lib", path: "lib", type: "unknown" as const, role: "shared" as const },
|
|
273
|
+
];
|
|
274
|
+
const sorted = WorkspaceLoader.getProcessingOrder(repos);
|
|
275
|
+
expect(sorted.map((r) => r.role)).toEqual(["backend", "shared", "frontend", "mobile"]);
|
|
276
|
+
});
|
|
277
|
+
});
|