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.
Files changed (67) hide show
  1. package/README.md +381 -1796
  2. package/RELEASE_LOG.md +231 -0
  3. package/cli/commands/create.ts +9 -1176
  4. package/cli/commands/dashboard.ts +1 -1
  5. package/cli/pipeline/helpers.ts +34 -0
  6. package/cli/pipeline/multi-repo.ts +483 -0
  7. package/cli/pipeline/single-repo.ts +755 -0
  8. package/cli/utils.ts +2 -0
  9. package/core/code-generator.ts +52 -341
  10. package/core/codegen/helpers.ts +219 -0
  11. package/core/codegen/topo-sort.ts +98 -0
  12. package/core/constitution-consolidator.ts +2 -2
  13. package/core/dsl-coverage-checker.ts +298 -0
  14. package/core/dsl-extractor.ts +19 -46
  15. package/core/dsl-feedback.ts +1 -1
  16. package/core/dsl-validator.ts +74 -0
  17. package/core/error-feedback.ts +95 -11
  18. package/core/frontend-context-loader.ts +27 -5
  19. package/core/knowledge-memory.ts +52 -0
  20. package/core/mock/fixtures.ts +89 -0
  21. package/core/mock/proxy.ts +380 -0
  22. package/core/mock-server-generator.ts +12 -460
  23. package/core/requirement-decomposer.ts +4 -28
  24. package/core/reviewer.ts +1 -1
  25. package/core/safe-json.ts +76 -0
  26. package/core/spec-updater.ts +5 -21
  27. package/core/token-budget.ts +124 -0
  28. package/core/vcr.ts +20 -1
  29. package/dist/cli/index.js +4110 -3534
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/cli/index.mjs +4237 -3661
  32. package/dist/cli/index.mjs.map +1 -1
  33. package/dist/index.d.mts +18 -16
  34. package/dist/index.d.ts +18 -16
  35. package/dist/index.js +310 -182
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +308 -180
  38. package/dist/index.mjs.map +1 -1
  39. package/package.json +2 -2
  40. package/purpose.md +173 -33
  41. package/tests/auto-consolidation.test.ts +109 -0
  42. package/tests/combined-generator.test.ts +81 -0
  43. package/tests/constitution-consolidator.test.ts +161 -0
  44. package/tests/constitution-generator.test.ts +94 -0
  45. package/tests/contract-bridge.test.ts +201 -0
  46. package/tests/design-dialogue.test.ts +108 -0
  47. package/tests/dsl-coverage-checker.test.ts +230 -0
  48. package/tests/dsl-feedback.test.ts +45 -0
  49. package/tests/dsl-validator-xref.test.ts +99 -0
  50. package/tests/error-feedback-repair.test.ts +319 -0
  51. package/tests/error-feedback-validation.test.ts +91 -0
  52. package/tests/frontend-context-loader.test.ts +609 -0
  53. package/tests/global-constitution.test.ts +110 -0
  54. package/tests/key-store.test.ts +73 -0
  55. package/tests/knowledge-memory.test.ts +327 -0
  56. package/tests/project-index.test.ts +206 -0
  57. package/tests/prompt-hasher.test.ts +19 -0
  58. package/tests/requirement-decomposer.test.ts +171 -0
  59. package/tests/reviewer.test.ts +4 -1
  60. package/tests/run-logger.test.ts +289 -0
  61. package/tests/run-snapshot.test.ts +113 -0
  62. package/tests/safe-json.test.ts +63 -0
  63. package/tests/spec-updater.test.ts +161 -0
  64. package/tests/test-generator.test.ts +146 -0
  65. package/tests/token-budget.test.ts +124 -0
  66. package/tests/vcr-hash.test.ts +101 -0
  67. 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
+ });
@@ -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 (not a git repo)", async () => {
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");