@mainahq/core 0.2.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 (156) hide show
  1. package/README.md +31 -0
  2. package/package.json +37 -0
  3. package/src/ai/__tests__/ai.test.ts +207 -0
  4. package/src/ai/__tests__/design-approaches.test.ts +192 -0
  5. package/src/ai/__tests__/spec-questions.test.ts +191 -0
  6. package/src/ai/__tests__/tiers.test.ts +110 -0
  7. package/src/ai/commit-msg.ts +28 -0
  8. package/src/ai/design-approaches.ts +76 -0
  9. package/src/ai/index.ts +205 -0
  10. package/src/ai/pr-summary.ts +60 -0
  11. package/src/ai/spec-questions.ts +74 -0
  12. package/src/ai/tiers.ts +52 -0
  13. package/src/ai/try-generate.ts +89 -0
  14. package/src/ai/validate.ts +66 -0
  15. package/src/benchmark/__tests__/reporter.test.ts +525 -0
  16. package/src/benchmark/__tests__/runner.test.ts +113 -0
  17. package/src/benchmark/__tests__/story-loader.test.ts +152 -0
  18. package/src/benchmark/reporter.ts +332 -0
  19. package/src/benchmark/runner.ts +91 -0
  20. package/src/benchmark/story-loader.ts +88 -0
  21. package/src/benchmark/types.ts +95 -0
  22. package/src/cache/__tests__/keys.test.ts +97 -0
  23. package/src/cache/__tests__/manager.test.ts +312 -0
  24. package/src/cache/__tests__/ttl.test.ts +94 -0
  25. package/src/cache/keys.ts +44 -0
  26. package/src/cache/manager.ts +231 -0
  27. package/src/cache/ttl.ts +77 -0
  28. package/src/config/__tests__/config.test.ts +376 -0
  29. package/src/config/index.ts +198 -0
  30. package/src/context/__tests__/budget.test.ts +179 -0
  31. package/src/context/__tests__/engine.test.ts +163 -0
  32. package/src/context/__tests__/episodic.test.ts +291 -0
  33. package/src/context/__tests__/relevance.test.ts +323 -0
  34. package/src/context/__tests__/retrieval.test.ts +143 -0
  35. package/src/context/__tests__/selector.test.ts +174 -0
  36. package/src/context/__tests__/semantic.test.ts +252 -0
  37. package/src/context/__tests__/treesitter.test.ts +229 -0
  38. package/src/context/__tests__/working.test.ts +236 -0
  39. package/src/context/budget.ts +130 -0
  40. package/src/context/engine.ts +394 -0
  41. package/src/context/episodic.ts +251 -0
  42. package/src/context/relevance.ts +325 -0
  43. package/src/context/retrieval.ts +325 -0
  44. package/src/context/selector.ts +93 -0
  45. package/src/context/semantic.ts +331 -0
  46. package/src/context/treesitter.ts +216 -0
  47. package/src/context/working.ts +192 -0
  48. package/src/db/__tests__/db.test.ts +151 -0
  49. package/src/db/index.ts +211 -0
  50. package/src/db/schema.ts +84 -0
  51. package/src/design/__tests__/design.test.ts +310 -0
  52. package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
  53. package/src/design/__tests__/review.test.ts +561 -0
  54. package/src/design/index.ts +297 -0
  55. package/src/design/review.ts +327 -0
  56. package/src/explain/__tests__/explain.test.ts +173 -0
  57. package/src/explain/index.ts +181 -0
  58. package/src/features/__tests__/analyzer.test.ts +358 -0
  59. package/src/features/__tests__/checklist.test.ts +454 -0
  60. package/src/features/__tests__/numbering.test.ts +319 -0
  61. package/src/features/__tests__/quality.test.ts +295 -0
  62. package/src/features/__tests__/traceability.test.ts +147 -0
  63. package/src/features/analyzer.ts +445 -0
  64. package/src/features/checklist.ts +366 -0
  65. package/src/features/index.ts +18 -0
  66. package/src/features/numbering.ts +404 -0
  67. package/src/features/quality.ts +349 -0
  68. package/src/features/test-stubs.ts +157 -0
  69. package/src/features/traceability.ts +260 -0
  70. package/src/feedback/__tests__/async-feedback.test.ts +52 -0
  71. package/src/feedback/__tests__/collector.test.ts +219 -0
  72. package/src/feedback/__tests__/compress.test.ts +150 -0
  73. package/src/feedback/__tests__/preferences.test.ts +169 -0
  74. package/src/feedback/collector.ts +135 -0
  75. package/src/feedback/compress.ts +92 -0
  76. package/src/feedback/preferences.ts +108 -0
  77. package/src/git/__tests__/git.test.ts +62 -0
  78. package/src/git/index.ts +110 -0
  79. package/src/hooks/__tests__/runner.test.ts +266 -0
  80. package/src/hooks/index.ts +8 -0
  81. package/src/hooks/runner.ts +130 -0
  82. package/src/index.ts +356 -0
  83. package/src/init/__tests__/init.test.ts +228 -0
  84. package/src/init/index.ts +364 -0
  85. package/src/language/__tests__/detect.test.ts +77 -0
  86. package/src/language/__tests__/profile.test.ts +51 -0
  87. package/src/language/detect.ts +70 -0
  88. package/src/language/profile.ts +110 -0
  89. package/src/prompts/__tests__/defaults.test.ts +52 -0
  90. package/src/prompts/__tests__/engine.test.ts +183 -0
  91. package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
  92. package/src/prompts/__tests__/evolution.test.ts +187 -0
  93. package/src/prompts/__tests__/loader.test.ts +105 -0
  94. package/src/prompts/candidates/review-v2.md +55 -0
  95. package/src/prompts/defaults/ai-review.md +49 -0
  96. package/src/prompts/defaults/commit.md +30 -0
  97. package/src/prompts/defaults/context.md +26 -0
  98. package/src/prompts/defaults/design-approaches.md +57 -0
  99. package/src/prompts/defaults/design-hld-lld.md +55 -0
  100. package/src/prompts/defaults/design.md +53 -0
  101. package/src/prompts/defaults/explain.md +31 -0
  102. package/src/prompts/defaults/fix.md +32 -0
  103. package/src/prompts/defaults/index.ts +38 -0
  104. package/src/prompts/defaults/review.md +41 -0
  105. package/src/prompts/defaults/spec-questions.md +59 -0
  106. package/src/prompts/defaults/tests.md +72 -0
  107. package/src/prompts/engine.ts +137 -0
  108. package/src/prompts/evolution.ts +409 -0
  109. package/src/prompts/loader.ts +71 -0
  110. package/src/review/__tests__/review.test.ts +288 -0
  111. package/src/review/comprehensive.ts +362 -0
  112. package/src/review/index.ts +417 -0
  113. package/src/stats/__tests__/tracker.test.ts +323 -0
  114. package/src/stats/index.ts +11 -0
  115. package/src/stats/tracker.ts +492 -0
  116. package/src/ticket/__tests__/ticket.test.ts +273 -0
  117. package/src/ticket/index.ts +185 -0
  118. package/src/utils.ts +87 -0
  119. package/src/verify/__tests__/ai-review.test.ts +242 -0
  120. package/src/verify/__tests__/coverage.test.ts +83 -0
  121. package/src/verify/__tests__/detect.test.ts +175 -0
  122. package/src/verify/__tests__/diff-filter.test.ts +338 -0
  123. package/src/verify/__tests__/fix.test.ts +478 -0
  124. package/src/verify/__tests__/linters/clippy.test.ts +45 -0
  125. package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
  126. package/src/verify/__tests__/linters/ruff.test.ts +64 -0
  127. package/src/verify/__tests__/mutation.test.ts +141 -0
  128. package/src/verify/__tests__/pipeline.test.ts +553 -0
  129. package/src/verify/__tests__/proof.test.ts +97 -0
  130. package/src/verify/__tests__/secretlint.test.ts +190 -0
  131. package/src/verify/__tests__/semgrep.test.ts +217 -0
  132. package/src/verify/__tests__/slop.test.ts +366 -0
  133. package/src/verify/__tests__/sonar.test.ts +113 -0
  134. package/src/verify/__tests__/syntax-guard.test.ts +227 -0
  135. package/src/verify/__tests__/trivy.test.ts +191 -0
  136. package/src/verify/__tests__/visual.test.ts +139 -0
  137. package/src/verify/ai-review.ts +276 -0
  138. package/src/verify/coverage.ts +134 -0
  139. package/src/verify/detect.ts +171 -0
  140. package/src/verify/diff-filter.ts +183 -0
  141. package/src/verify/fix.ts +317 -0
  142. package/src/verify/linters/clippy.ts +52 -0
  143. package/src/verify/linters/go-vet.ts +32 -0
  144. package/src/verify/linters/ruff.ts +47 -0
  145. package/src/verify/mutation.ts +143 -0
  146. package/src/verify/pipeline.ts +328 -0
  147. package/src/verify/proof.ts +277 -0
  148. package/src/verify/secretlint.ts +168 -0
  149. package/src/verify/semgrep.ts +170 -0
  150. package/src/verify/slop.ts +493 -0
  151. package/src/verify/sonar.ts +146 -0
  152. package/src/verify/syntax-guard.ts +251 -0
  153. package/src/verify/trivy.ts +161 -0
  154. package/src/verify/visual.ts +460 -0
  155. package/src/workflow/__tests__/context.test.ts +110 -0
  156. package/src/workflow/context.ts +81 -0
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @maina/core
2
+
3
+ Core engines for the Maina verification-first developer OS.
4
+
5
+ Three engines: **Context** (observes), **Prompt** (learns), **Verify** (verifies).
6
+
7
+ ## Features
8
+
9
+ - **Context Engine** — 4-layer retrieval (Working, Episodic, Semantic, Retrieval) with PageRank relevance and dynamic token budget
10
+ - **Prompt Engine** — Constitution + custom prompts + A/B testing + feedback-driven evolution
11
+ - **Verify Engine** — 12-tool pipeline (Biome, Semgrep, Trivy, Secretlint, SonarQube, Stryker, diff-cover, AI review, slop detection, visual regression) with diff-only filtering
12
+ - **Multi-language** — TypeScript, Python, Go, Rust with language-specific profiles
13
+ - **Workflow context** — rolling summary forwarded between lifecycle steps
14
+ - **Background RL** — async feedback recording at each workflow step
15
+
16
+ ## Requirements
17
+
18
+ **Runtime: Bun** (not Node.js). This package ships TypeScript source directly and requires a TypeScript-aware runtime.
19
+
20
+ ## Usage
21
+
22
+ ```typescript
23
+ import { runPipeline, detectLanguages, tryAIGenerate } from "@maina/core";
24
+
25
+ const result = await runPipeline({ cwd: ".", diffOnly: true });
26
+ console.log(result.passed ? "Clean" : `${result.findings.length} findings`);
27
+ ```
28
+
29
+ ## License
30
+
31
+ Apache-2.0
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@mainahq/core",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "description": "Maina core engines — Context, Prompt, and Verify for verification-first development",
7
+ "homepage": "https://beeeku.github.io/maina/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/beeeku/maina.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "engines": {
14
+ "bun": ">=1.0.0"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "package.json",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "maina",
23
+ "verification",
24
+ "context-engine",
25
+ "prompt-engine",
26
+ "ai-code-review"
27
+ ],
28
+ "main": "src/index.ts",
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@ai-sdk/openai": "^3.0.50",
34
+ "ai": "^6.0.145",
35
+ "drizzle-orm": "^0.45.2"
36
+ }
37
+ }
@@ -0,0 +1,207 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ it,
9
+ test,
10
+ } from "bun:test";
11
+ import { mkdirSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { buildCacheKey, hashContent } from "../../cache/keys";
15
+ import { createCacheManager } from "../../cache/manager";
16
+ import { generate } from "../index";
17
+ import { getTaskTier } from "../tiers";
18
+
19
+ const TEST_DIR = join(tmpdir(), `maina-ai-test-${Date.now()}`);
20
+
21
+ /** Env vars that can trigger host-mode delegation, provide API keys, or override config. */
22
+ const HOST_ENV_VARS = [
23
+ "MAINA_API_KEY",
24
+ "OPENROUTER_API_KEY",
25
+ "ANTHROPIC_API_KEY",
26
+ "CLAUDECODE",
27
+ "CLAUDE_CODE_ENTRYPOINT",
28
+ "CURSOR",
29
+ "MAINA_HOST_MODE",
30
+ "MAINA_PROVIDER",
31
+ ] as const;
32
+
33
+ /** Saved env values restored after each test. */
34
+ let savedEnv: Record<string, string | undefined> = {};
35
+
36
+ beforeAll(() => {
37
+ mkdirSync(TEST_DIR, { recursive: true });
38
+ });
39
+
40
+ afterAll(() => {
41
+ rmSync(TEST_DIR, { recursive: true, force: true });
42
+ });
43
+
44
+ beforeEach(() => {
45
+ savedEnv = {};
46
+ for (const key of HOST_ENV_VARS) {
47
+ savedEnv[key] = process.env[key];
48
+ }
49
+ });
50
+
51
+ afterEach(() => {
52
+ for (const key of HOST_ENV_VARS) {
53
+ const original = savedEnv[key];
54
+ if (original !== undefined) {
55
+ process.env[key] = original;
56
+ } else {
57
+ delete process.env[key];
58
+ }
59
+ }
60
+ });
61
+
62
+ function makeDir(sub: string): string {
63
+ const d = join(TEST_DIR, sub);
64
+ mkdirSync(d, { recursive: true });
65
+ return d;
66
+ }
67
+
68
+ describe("generate — cache hit", () => {
69
+ test("returns cached result when cache has matching entry", async () => {
70
+ // Clear all env vars that can influence model resolution / host delegation
71
+ for (const key of HOST_ENV_VARS) {
72
+ delete process.env[key];
73
+ }
74
+
75
+ const mainaDir = makeDir("cache-hit");
76
+ const cache = createCacheManager(mainaDir);
77
+
78
+ const task = "commit";
79
+ const systemPrompt = "You are a helpful assistant.";
80
+ const userPrompt = "Write a commit message for adding tests.";
81
+ const promptHash = hashContent(systemPrompt + userPrompt);
82
+ const cacheKey = await buildCacheKey({
83
+ task,
84
+ promptHash,
85
+ model: "google/gemini-2.5-flash",
86
+ });
87
+
88
+ // Pre-populate cache
89
+ const cachedValue = JSON.stringify({
90
+ text: "feat: add test suite",
91
+ model: "google/gemini-2.5-flash",
92
+ tokens: { input: 100, output: 20 },
93
+ });
94
+ cache.set(cacheKey, cachedValue);
95
+
96
+ const result = await generate({
97
+ task,
98
+ systemPrompt,
99
+ userPrompt,
100
+ mainaDir,
101
+ });
102
+
103
+ expect(result.cached).toBe(true);
104
+ expect(result.text).toBe("feat: add test suite");
105
+ expect(result.model).toBe("google/gemini-2.5-flash");
106
+ });
107
+ });
108
+
109
+ describe("generate — no API key", () => {
110
+ test("returns helpful error message when no API key is set", async () => {
111
+ // Clear ALL env vars that could provide keys or trigger host delegation
112
+ for (const key of HOST_ENV_VARS) {
113
+ delete process.env[key];
114
+ }
115
+
116
+ const mainaDir = makeDir("no-api-key");
117
+
118
+ const result = await generate({
119
+ task: "review",
120
+ systemPrompt: "You are a code reviewer.",
121
+ userPrompt: "Review this code: const x = 1;",
122
+ mainaDir,
123
+ });
124
+
125
+ expect(result.cached).toBe(false);
126
+ expect(result.text).toContain("API key");
127
+ expect(result.model).toBe("");
128
+ });
129
+ });
130
+
131
+ describe("generate — cache key construction", () => {
132
+ test("same inputs produce same cache key (idempotent)", async () => {
133
+ const task = "tests";
134
+ const systemPrompt = "Generate tests.";
135
+ const userPrompt = "Write tests for add(a, b).";
136
+ const promptHash = hashContent(systemPrompt + userPrompt);
137
+
138
+ const key1 = await buildCacheKey({
139
+ task,
140
+ promptHash,
141
+ model: "google/gemini-2.5-flash",
142
+ });
143
+ const key2 = await buildCacheKey({
144
+ task,
145
+ promptHash,
146
+ model: "google/gemini-2.5-flash",
147
+ });
148
+
149
+ expect(key1).toBe(key2);
150
+ });
151
+
152
+ test("different prompts produce different cache keys", async () => {
153
+ const task = "commit";
154
+ const promptHash1 = hashContent("system1" + "user1");
155
+ const promptHash2 = hashContent("system2" + "user2");
156
+
157
+ const key1 = await buildCacheKey({
158
+ task,
159
+ promptHash: promptHash1,
160
+ model: "google/gemini-2.5-flash",
161
+ });
162
+ const key2 = await buildCacheKey({
163
+ task,
164
+ promptHash: promptHash2,
165
+ model: "google/gemini-2.5-flash",
166
+ });
167
+
168
+ expect(key1).not.toBe(key2);
169
+ });
170
+
171
+ test("different tasks produce different cache keys", async () => {
172
+ const promptHash = hashContent("system" + "user");
173
+ const key1 = await buildCacheKey({
174
+ task: "commit",
175
+ promptHash,
176
+ model: "google/gemini-2.5-flash",
177
+ });
178
+ const key2 = await buildCacheKey({
179
+ task: "review",
180
+ promptHash,
181
+ model: "anthropic/claude-sonnet-4",
182
+ });
183
+
184
+ expect(key1).not.toBe(key2);
185
+ });
186
+ });
187
+
188
+ describe("getTaskTier — code-review tasks", () => {
189
+ it("should map code-review to mechanical tier", () => {
190
+ expect(getTaskTier("code-review")).toBe("mechanical");
191
+ });
192
+
193
+ it("should map deep-code-review to standard tier", () => {
194
+ expect(getTaskTier("deep-code-review")).toBe("standard");
195
+ });
196
+ });
197
+
198
+ describe("tryAIGenerate host delegation", () => {
199
+ it("should extract user prompt from delegation text", async () => {
200
+ // This tests the extraction logic directly
201
+ const delegationText =
202
+ "[HOST_DELEGATION] Task: design-hld-lld\n\nSystem: system prompt\n\nUser: Generate HLD and LLD sections";
203
+ const userIdx = delegationText.indexOf("\n\nUser: ");
204
+ const extracted = userIdx !== -1 ? delegationText.slice(userIdx + 8) : null;
205
+ expect(extracted).toBe("Generate HLD and LLD sections");
206
+ });
207
+ });
@@ -0,0 +1,192 @@
1
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ── Mock State ──────────────────────────────────────────────────────────────
4
+
5
+ let mockAIResponse: string | null = null;
6
+
7
+ // ── Mocks ───────────────────────────────────────────────────────────────────
8
+
9
+ mock.module("../try-generate", () => ({
10
+ tryAIGenerate: async (
11
+ _task: string,
12
+ _mainaDir: string,
13
+ _variables: Record<string, string>,
14
+ _userPrompt: string,
15
+ ) => ({
16
+ text: mockAIResponse,
17
+ fromAI: mockAIResponse !== null,
18
+ hostDelegation: false,
19
+ }),
20
+ }));
21
+
22
+ afterAll(() => {
23
+ mock.restore();
24
+ });
25
+
26
+ // ── Import after mocks ──────────────────────────────────────────────────────
27
+
28
+ const { generateDesignApproaches } = await import("../design-approaches");
29
+
30
+ // ── Tests ───────────────────────────────────────────────────────────────────
31
+
32
+ describe("generateDesignApproaches", () => {
33
+ beforeEach(() => {
34
+ mockAIResponse = null;
35
+ });
36
+
37
+ // ── Happy Path ──────────────────────────────────────────────────────────
38
+
39
+ describe("happy path", () => {
40
+ test("returns parsed approaches from valid AI JSON response", async () => {
41
+ mockAIResponse = JSON.stringify([
42
+ {
43
+ name: "Event-driven",
44
+ description: "Steps emit events consumed by next step.",
45
+ pros: ["Parallel", "Decoupled"],
46
+ cons: ["Complex debugging"],
47
+ recommended: true,
48
+ },
49
+ {
50
+ name: "Middleware chain",
51
+ description: "Sequential middleware functions.",
52
+ pros: ["Simple"],
53
+ cons: ["No parallelism"],
54
+ recommended: false,
55
+ },
56
+ ]);
57
+
58
+ const result = await generateDesignApproaches(
59
+ "Design a verification pipeline",
60
+ ".maina",
61
+ );
62
+
63
+ expect(result.ok).toBe(true);
64
+ if (result.ok) {
65
+ expect(result.value).toHaveLength(2);
66
+ expect(result.value[0]?.name).toBe("Event-driven");
67
+ expect(result.value[0]?.recommended).toBe(true);
68
+ expect(result.value[0]?.pros).toEqual(["Parallel", "Decoupled"]);
69
+ expect(result.value[1]?.name).toBe("Middleware chain");
70
+ expect(result.value[1]?.recommended).toBe(false);
71
+ }
72
+ });
73
+
74
+ test("returns empty array when AI says no approaches needed", async () => {
75
+ mockAIResponse = "[]";
76
+
77
+ const result = await generateDesignApproaches(
78
+ "Simple decision",
79
+ ".maina",
80
+ );
81
+
82
+ expect(result.ok).toBe(true);
83
+ if (result.ok) {
84
+ expect(result.value).toHaveLength(0);
85
+ }
86
+ });
87
+ });
88
+
89
+ // ── Edge Cases ──────────────────────────────────────────────────────────
90
+
91
+ describe("edge cases", () => {
92
+ test("returns empty array when context is empty", async () => {
93
+ const result = await generateDesignApproaches("", ".maina");
94
+
95
+ expect(result.ok).toBe(true);
96
+ if (result.ok) {
97
+ expect(result.value).toHaveLength(0);
98
+ }
99
+ });
100
+
101
+ test("handles AI response with markdown fences around JSON", async () => {
102
+ mockAIResponse =
103
+ '```json\n[{"name":"A","description":"D","pros":["p"],"cons":["c"],"recommended":true}]\n```';
104
+
105
+ const result = await generateDesignApproaches("Test context", ".maina");
106
+
107
+ expect(result.ok).toBe(true);
108
+ if (result.ok) {
109
+ expect(result.value).toHaveLength(1);
110
+ expect(result.value[0]?.name).toBe("A");
111
+ }
112
+ });
113
+
114
+ test("caps approaches at 3 even if AI returns more", async () => {
115
+ const fourApproaches = Array.from({ length: 4 }, (_, i) => ({
116
+ name: `Approach ${i + 1}`,
117
+ description: `Description ${i + 1}`,
118
+ pros: ["pro"],
119
+ cons: ["con"],
120
+ recommended: i === 0,
121
+ }));
122
+ mockAIResponse = JSON.stringify(fourApproaches);
123
+
124
+ const result = await generateDesignApproaches("Test context", ".maina");
125
+
126
+ expect(result.ok).toBe(true);
127
+ if (result.ok) {
128
+ expect(result.value.length).toBeLessThanOrEqual(3);
129
+ }
130
+ });
131
+ });
132
+
133
+ // ── Error Handling ──────────────────────────────────────────────────────
134
+
135
+ describe("error handling", () => {
136
+ test("returns empty array when AI is not available", async () => {
137
+ mockAIResponse = null;
138
+
139
+ const result = await generateDesignApproaches("Test context", ".maina");
140
+
141
+ expect(result.ok).toBe(true);
142
+ if (result.ok) {
143
+ expect(result.value).toHaveLength(0);
144
+ }
145
+ });
146
+
147
+ test("returns empty array when AI returns malformed JSON", async () => {
148
+ mockAIResponse = "not valid json at all";
149
+
150
+ const result = await generateDesignApproaches("Test context", ".maina");
151
+
152
+ expect(result.ok).toBe(true);
153
+ if (result.ok) {
154
+ expect(result.value).toHaveLength(0);
155
+ }
156
+ });
157
+
158
+ test("filters out approaches missing required fields", async () => {
159
+ mockAIResponse = JSON.stringify([
160
+ {
161
+ name: "Valid",
162
+ description: "D",
163
+ pros: ["p"],
164
+ cons: ["c"],
165
+ recommended: true,
166
+ },
167
+ {
168
+ description: "Missing name",
169
+ pros: ["p"],
170
+ cons: ["c"],
171
+ recommended: false,
172
+ },
173
+ {
174
+ name: "Also valid",
175
+ description: "D2",
176
+ pros: ["p"],
177
+ cons: ["c"],
178
+ recommended: false,
179
+ },
180
+ ]);
181
+
182
+ const result = await generateDesignApproaches("Test context", ".maina");
183
+
184
+ expect(result.ok).toBe(true);
185
+ if (result.ok) {
186
+ expect(result.value).toHaveLength(2);
187
+ expect(result.value[0]?.name).toBe("Valid");
188
+ expect(result.value[1]?.name).toBe("Also valid");
189
+ }
190
+ });
191
+ });
192
+ });
@@ -0,0 +1,191 @@
1
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ── Mock State ──────────────────────────────────────────────────────────────
4
+
5
+ let mockAIResponse: string | null = null;
6
+
7
+ // ── Mocks ───────────────────────────────────────────────────────────────────
8
+
9
+ mock.module("../try-generate", () => ({
10
+ tryAIGenerate: async (
11
+ _task: string,
12
+ _mainaDir: string,
13
+ _variables: Record<string, string>,
14
+ _userPrompt: string,
15
+ ) => ({
16
+ text: mockAIResponse,
17
+ fromAI: mockAIResponse !== null,
18
+ hostDelegation: false,
19
+ }),
20
+ }));
21
+
22
+ afterAll(() => {
23
+ mock.restore();
24
+ });
25
+
26
+ // ── Import after mocks ──────────────────────────────────────────────────────
27
+
28
+ const { generateSpecQuestions } = await import("../spec-questions");
29
+
30
+ // ── Tests ───────────────────────────────────────────────────────────────────
31
+
32
+ describe("generateSpecQuestions", () => {
33
+ beforeEach(() => {
34
+ mockAIResponse = null;
35
+ });
36
+
37
+ // ── Happy Path ──────────────────────────────────────────────────────────
38
+
39
+ describe("happy path", () => {
40
+ test("returns parsed questions from valid AI JSON response", async () => {
41
+ mockAIResponse = JSON.stringify([
42
+ {
43
+ question: "Should cache invalidate on branch switch?",
44
+ type: "select",
45
+ options: ["Yes", "No", "Both"],
46
+ reason: "Not specified in plan",
47
+ },
48
+ {
49
+ question: "What error message for missing API key?",
50
+ type: "text",
51
+ reason: "Error handling unspecified",
52
+ },
53
+ ]);
54
+
55
+ const result = await generateSpecQuestions(
56
+ "## Tasks\n- T001: Add cache",
57
+ ".maina",
58
+ );
59
+
60
+ expect(result.ok).toBe(true);
61
+ if (result.ok) {
62
+ expect(result.value).toHaveLength(2);
63
+ expect(result.value[0]?.question).toBe(
64
+ "Should cache invalidate on branch switch?",
65
+ );
66
+ expect(result.value[0]?.type).toBe("select");
67
+ expect(result.value[0]?.options).toEqual(["Yes", "No", "Both"]);
68
+ expect(result.value[1]?.type).toBe("text");
69
+ }
70
+ });
71
+
72
+ test("returns empty array when AI says plan is clear", async () => {
73
+ mockAIResponse = "[]";
74
+
75
+ const result = await generateSpecQuestions(
76
+ "## Tasks\n- T001: Clear task",
77
+ ".maina",
78
+ );
79
+
80
+ expect(result.ok).toBe(true);
81
+ if (result.ok) {
82
+ expect(result.value).toHaveLength(0);
83
+ }
84
+ });
85
+ });
86
+
87
+ // ── Edge Cases ──────────────────────────────────────────────────────────
88
+
89
+ describe("edge cases", () => {
90
+ test("returns empty array when plan content is empty", async () => {
91
+ const result = await generateSpecQuestions("", ".maina");
92
+
93
+ expect(result.ok).toBe(true);
94
+ if (result.ok) {
95
+ expect(result.value).toHaveLength(0);
96
+ }
97
+ });
98
+
99
+ test("handles AI response with markdown fences around JSON", async () => {
100
+ mockAIResponse =
101
+ '```json\n[{"question":"Test?","type":"text","reason":"r"}]\n```';
102
+
103
+ const result = await generateSpecQuestions(
104
+ "## Tasks\n- T001: Test",
105
+ ".maina",
106
+ );
107
+
108
+ expect(result.ok).toBe(true);
109
+ if (result.ok) {
110
+ expect(result.value).toHaveLength(1);
111
+ expect(result.value[0]?.question).toBe("Test?");
112
+ }
113
+ });
114
+
115
+ test("caps questions at 5 even if AI returns more", async () => {
116
+ const sixQuestions = Array.from({ length: 6 }, (_, i) => ({
117
+ question: `Question ${i + 1}?`,
118
+ type: "text",
119
+ reason: `Reason ${i + 1}`,
120
+ }));
121
+ mockAIResponse = JSON.stringify(sixQuestions);
122
+
123
+ const result = await generateSpecQuestions(
124
+ "## Tasks\n- T001: Test",
125
+ ".maina",
126
+ );
127
+
128
+ expect(result.ok).toBe(true);
129
+ if (result.ok) {
130
+ expect(result.value.length).toBeLessThanOrEqual(5);
131
+ }
132
+ });
133
+ });
134
+
135
+ // ── Error Handling ──────────────────────────────────────────────────────
136
+
137
+ describe("error handling", () => {
138
+ test("returns empty array when AI is not available (null response)", async () => {
139
+ mockAIResponse = null;
140
+
141
+ const result = await generateSpecQuestions(
142
+ "## Tasks\n- T001: Test",
143
+ ".maina",
144
+ );
145
+
146
+ expect(result.ok).toBe(true);
147
+ if (result.ok) {
148
+ expect(result.value).toHaveLength(0);
149
+ }
150
+ });
151
+
152
+ test("returns empty array when AI returns malformed JSON", async () => {
153
+ mockAIResponse = "not valid json at all";
154
+
155
+ const result = await generateSpecQuestions(
156
+ "## Tasks\n- T001: Test",
157
+ ".maina",
158
+ );
159
+
160
+ expect(result.ok).toBe(true);
161
+ if (result.ok) {
162
+ expect(result.value).toHaveLength(0);
163
+ }
164
+ });
165
+
166
+ test("filters out questions missing required fields", async () => {
167
+ mockAIResponse = JSON.stringify([
168
+ { question: "Valid?", type: "text", reason: "Valid reason" },
169
+ { type: "text", reason: "Missing question field" },
170
+ {
171
+ question: "Also valid?",
172
+ type: "select",
173
+ options: ["A", "B"],
174
+ reason: "OK",
175
+ },
176
+ ]);
177
+
178
+ const result = await generateSpecQuestions(
179
+ "## Tasks\n- T001: Test",
180
+ ".maina",
181
+ );
182
+
183
+ expect(result.ok).toBe(true);
184
+ if (result.ok) {
185
+ expect(result.value).toHaveLength(2);
186
+ expect(result.value[0]?.question).toBe("Valid?");
187
+ expect(result.value[1]?.question).toBe("Also valid?");
188
+ }
189
+ });
190
+ });
191
+ });