@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
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Plan-to-code traceability.
3
+ *
4
+ * Reads plan.md from a feature directory, extracts tasks (T001, T002, etc.),
5
+ * and traces each task to matching test files, implementation files, and commits.
6
+ */
7
+
8
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { Result } from "../db/index";
11
+
12
+ // ── Types ────────────────────────────────────────────────────────────────────
13
+
14
+ export interface TaskTrace {
15
+ taskId: string;
16
+ description: string;
17
+ testFile: string | null;
18
+ implFile: string | null;
19
+ commitHash: string | null;
20
+ }
21
+
22
+ export interface TraceabilityReport {
23
+ featureDir: string;
24
+ tasks: TaskTrace[];
25
+ coverage: {
26
+ withTests: number;
27
+ withImpl: number;
28
+ withCommits: number;
29
+ total: number;
30
+ };
31
+ }
32
+
33
+ export interface TraceDeps {
34
+ gitLog?: (repoRoot: string) => Promise<string>;
35
+ }
36
+
37
+ // ── Task Parsing ─────────────────────────────────────────────────────────────
38
+
39
+ interface ParsedTask {
40
+ id: string;
41
+ description: string;
42
+ }
43
+
44
+ /**
45
+ * Parse task lines from plan.md content.
46
+ * Matches: - T001: description, - [ ] T001: description, - [x] T001: description
47
+ */
48
+ function parseTasks(planContent: string): ParsedTask[] {
49
+ const lines = planContent.split("\n");
50
+ const tasks: ParsedTask[] = [];
51
+ const taskPattern = /^-\s+(?:\[[ x]\]\s+)?T(\d+):\s*(.+)$/;
52
+
53
+ for (const line of lines) {
54
+ const trimmed = line.trim();
55
+ const match = trimmed.match(taskPattern);
56
+ if (match?.[1] && match[2]) {
57
+ tasks.push({
58
+ id: `T${match[1]}`,
59
+ description: match[2].trim(),
60
+ });
61
+ }
62
+ }
63
+
64
+ return tasks;
65
+ }
66
+
67
+ // ── File Search ──────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Recursively collect file paths from a directory, skipping node_modules and hidden dirs.
71
+ */
72
+ function collectFiles(dir: string): string[] {
73
+ const results: string[] = [];
74
+
75
+ if (!existsSync(dir)) return results;
76
+
77
+ let entries: string[];
78
+ try {
79
+ entries = readdirSync(dir);
80
+ } catch {
81
+ return results;
82
+ }
83
+
84
+ for (const entry of entries) {
85
+ if (entry.startsWith(".") || entry === "node_modules") continue;
86
+
87
+ const fullPath = join(dir, entry);
88
+ try {
89
+ const stat = statSync(fullPath);
90
+ if (stat.isDirectory()) {
91
+ results.push(...collectFiles(fullPath));
92
+ } else if (stat.isFile()) {
93
+ results.push(fullPath);
94
+ }
95
+ } catch {
96
+ // Skip inaccessible files
97
+ }
98
+ }
99
+
100
+ return results;
101
+ }
102
+
103
+ /**
104
+ * Search for a test file containing the given task ID.
105
+ */
106
+ function findTestFile(taskId: string, allFiles: string[]): string | null {
107
+ const testFiles = allFiles.filter(
108
+ (f) =>
109
+ f.includes("__tests__") ||
110
+ f.endsWith(".test.ts") ||
111
+ f.endsWith(".test.tsx") ||
112
+ f.endsWith(".test.js"),
113
+ );
114
+
115
+ for (const file of testFiles) {
116
+ try {
117
+ const content = readFileSync(file, "utf-8");
118
+ if (content.includes(taskId)) {
119
+ return file;
120
+ }
121
+ } catch {
122
+ // Skip unreadable files
123
+ }
124
+ }
125
+
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Search for an implementation file containing the task ID.
131
+ * Excludes test files.
132
+ */
133
+ function findImplFile(taskId: string, allFiles: string[]): string | null {
134
+ const implFiles = allFiles.filter(
135
+ (f) =>
136
+ !f.includes("__tests__") &&
137
+ !f.endsWith(".test.ts") &&
138
+ !f.endsWith(".test.tsx") &&
139
+ !f.endsWith(".test.js") &&
140
+ (f.endsWith(".ts") || f.endsWith(".tsx") || f.endsWith(".js")),
141
+ );
142
+
143
+ for (const file of implFiles) {
144
+ try {
145
+ const content = readFileSync(file, "utf-8");
146
+ if (content.includes(taskId)) {
147
+ return file;
148
+ }
149
+ } catch {
150
+ // Skip unreadable files
151
+ }
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Search git log output for a commit mentioning the task ID.
159
+ * Returns the commit hash if found, null otherwise.
160
+ */
161
+ function findCommit(taskId: string, gitLogOutput: string): string | null {
162
+ const lines = gitLogOutput.split("\n");
163
+ for (const line of lines) {
164
+ if (line.includes(taskId)) {
165
+ const hash = line.trim().split(" ")[0];
166
+ if (hash && hash.length >= 7) {
167
+ return hash;
168
+ }
169
+ }
170
+ }
171
+
172
+ return null;
173
+ }
174
+
175
+ // ── Default git log ──────────────────────────────────────────────────────────
176
+
177
+ async function defaultGitLog(repoRoot: string): Promise<string> {
178
+ try {
179
+ const proc = Bun.spawn(["git", "log", "--oneline", "--all"], {
180
+ cwd: repoRoot,
181
+ stdout: "pipe",
182
+ stderr: "pipe",
183
+ });
184
+ const stdout = await new Response(proc.stdout).text();
185
+ await proc.exited;
186
+ return stdout;
187
+ } catch {
188
+ return "";
189
+ }
190
+ }
191
+
192
+ // ── Main ─────────────────────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Trace a feature's plan tasks to test files, implementation files, and commits.
196
+ *
197
+ * @param featureDir - Path to the feature directory (containing plan.md)
198
+ * @param repoRoot - Path to the repository root (for file and commit search)
199
+ * @param deps - Optional dependency overrides (e.g., mock git log)
200
+ */
201
+ export async function traceFeature(
202
+ featureDir: string,
203
+ repoRoot: string,
204
+ deps?: TraceDeps,
205
+ ): Promise<Result<TraceabilityReport, string>> {
206
+ if (!existsSync(featureDir)) {
207
+ return {
208
+ ok: false,
209
+ error: `Feature directory does not exist: ${featureDir}`,
210
+ };
211
+ }
212
+
213
+ const planPath = join(featureDir, "plan.md");
214
+ if (!existsSync(planPath)) {
215
+ return {
216
+ ok: false,
217
+ error: `plan.md not found at ${planPath}`,
218
+ };
219
+ }
220
+
221
+ const planContent = readFileSync(planPath, "utf-8");
222
+ const parsedTasks = parseTasks(planContent);
223
+
224
+ // Collect all files from repo root
225
+ const allFiles = collectFiles(repoRoot);
226
+
227
+ // Get git log
228
+ const gitLogFn = deps?.gitLog ?? defaultGitLog;
229
+ let gitLogOutput = "";
230
+ try {
231
+ gitLogOutput = await gitLogFn(repoRoot);
232
+ } catch {
233
+ // Git log failure should not block traceability
234
+ }
235
+
236
+ // Build task traces
237
+ const tasks: TaskTrace[] = parsedTasks.map((task) => ({
238
+ taskId: task.id,
239
+ description: task.description,
240
+ testFile: findTestFile(task.id, allFiles),
241
+ implFile: findImplFile(task.id, allFiles),
242
+ commitHash: findCommit(task.id, gitLogOutput),
243
+ }));
244
+
245
+ const coverage = {
246
+ withTests: tasks.filter((t) => t.testFile !== null).length,
247
+ withImpl: tasks.filter((t) => t.implFile !== null).length,
248
+ withCommits: tasks.filter((t) => t.commitHash !== null).length,
249
+ total: tasks.length,
250
+ };
251
+
252
+ return {
253
+ ok: true,
254
+ value: {
255
+ featureDir,
256
+ tasks,
257
+ coverage,
258
+ },
259
+ };
260
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { getWorkflowId, recordFeedbackAsync } from "../collector";
3
+
4
+ describe("recordFeedbackAsync", () => {
5
+ it("should not throw when called", () => {
6
+ // Fire-and-forget — should never throw
7
+ expect(() => {
8
+ recordFeedbackAsync(".maina", {
9
+ promptHash: "test-hash",
10
+ task: "commit",
11
+ accepted: true,
12
+ timestamp: new Date().toISOString(),
13
+ workflowStep: "commit",
14
+ workflowId: "test-wf-id",
15
+ });
16
+ }).not.toThrow();
17
+ });
18
+
19
+ it("should not block execution", () => {
20
+ const start = performance.now();
21
+ recordFeedbackAsync(".maina", {
22
+ promptHash: "test-hash",
23
+ task: "verify",
24
+ accepted: true,
25
+ timestamp: new Date().toISOString(),
26
+ workflowStep: "verify",
27
+ workflowId: "test-wf-id",
28
+ });
29
+ const elapsed = performance.now() - start;
30
+ // Should complete in <1ms (just queues a microtask)
31
+ expect(elapsed).toBeLessThan(5);
32
+ });
33
+ });
34
+
35
+ describe("getWorkflowId", () => {
36
+ it("should return consistent ID for same branch", () => {
37
+ const id1 = getWorkflowId("feature/015-test");
38
+ const id2 = getWorkflowId("feature/015-test");
39
+ expect(id1).toBe(id2);
40
+ });
41
+
42
+ it("should return different IDs for different branches", () => {
43
+ const id1 = getWorkflowId("feature/015-test");
44
+ const id2 = getWorkflowId("feature/016-other");
45
+ expect(id1).not.toBe(id2);
46
+ });
47
+
48
+ it("should return a 12-char string", () => {
49
+ const id = getWorkflowId("feature/test");
50
+ expect(id).toHaveLength(12);
51
+ });
52
+ });
@@ -0,0 +1,219 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getContextDb, getFeedbackDb } from "../../db/index";
5
+ import { recordOutcome } from "../../prompts/engine";
6
+ import {
7
+ type FeedbackRecord,
8
+ getFeedbackSummary,
9
+ recordFeedback,
10
+ recordFeedbackWithCompression,
11
+ } from "../collector";
12
+
13
+ let tmpDir: string;
14
+
15
+ beforeEach(() => {
16
+ tmpDir = join(
17
+ import.meta.dir,
18
+ `tmp-collector-${Date.now()}-${Math.random().toString(36).slice(2)}`,
19
+ );
20
+ mkdirSync(tmpDir, { recursive: true });
21
+ });
22
+
23
+ afterEach(() => {
24
+ try {
25
+ const { rmSync } = require("node:fs");
26
+ rmSync(tmpDir, { recursive: true, force: true });
27
+ } catch {
28
+ // ignore
29
+ }
30
+ });
31
+
32
+ describe("recordFeedback", () => {
33
+ test("writes to feedback.db", () => {
34
+ const record: FeedbackRecord = {
35
+ promptHash: "test-hash-123",
36
+ task: "commit",
37
+ accepted: true,
38
+ timestamp: new Date().toISOString(),
39
+ };
40
+
41
+ recordFeedback(tmpDir, record);
42
+
43
+ // Verify directly in db
44
+ const dbResult = getFeedbackDb(tmpDir);
45
+ expect(dbResult.ok).toBe(true);
46
+ if (!dbResult.ok) return;
47
+
48
+ const { db } = dbResult.value;
49
+ const rows = db
50
+ .query("SELECT * FROM feedback WHERE prompt_hash = ?")
51
+ .all("test-hash-123") as Array<{
52
+ prompt_hash: string;
53
+ command: string;
54
+ accepted: number;
55
+ context: string | null;
56
+ }>;
57
+
58
+ expect(rows.length).toBe(1);
59
+ expect(rows[0]?.prompt_hash).toBe("test-hash-123");
60
+ expect(rows[0]?.command).toBe("commit");
61
+ expect(rows[0]?.accepted).toBe(1);
62
+ });
63
+
64
+ test("records modification as context", () => {
65
+ const record: FeedbackRecord = {
66
+ promptHash: "mod-hash",
67
+ task: "commit",
68
+ accepted: true,
69
+ modification: "user edited the message",
70
+ timestamp: new Date().toISOString(),
71
+ };
72
+
73
+ recordFeedback(tmpDir, record);
74
+
75
+ const dbResult = getFeedbackDb(tmpDir);
76
+ expect(dbResult.ok).toBe(true);
77
+ if (!dbResult.ok) return;
78
+
79
+ const { db } = dbResult.value;
80
+ const rows = db
81
+ .query("SELECT * FROM feedback WHERE prompt_hash = ?")
82
+ .all("mod-hash") as Array<{ context: string | null }>;
83
+
84
+ expect(rows[0]?.context).toBe("user edited the message");
85
+ });
86
+ });
87
+
88
+ describe("getFeedbackSummary", () => {
89
+ test("returns correct counts and rate", () => {
90
+ // Record some feedback using recordOutcome directly (same underlying storage)
91
+ recordOutcome(tmpDir, "hash-1", { accepted: true, command: "commit" });
92
+ recordOutcome(tmpDir, "hash-2", { accepted: true, command: "commit" });
93
+ recordOutcome(tmpDir, "hash-3", { accepted: false, command: "commit" });
94
+ recordOutcome(tmpDir, "hash-4", { accepted: false, command: "commit" });
95
+ recordOutcome(tmpDir, "hash-5", { accepted: true, command: "review" });
96
+
97
+ const summary = getFeedbackSummary(tmpDir, "commit");
98
+
99
+ expect(summary.total).toBe(4);
100
+ expect(summary.accepted).toBe(2);
101
+ expect(summary.rejected).toBe(2);
102
+ expect(summary.acceptRate).toBeCloseTo(0.5, 5);
103
+ });
104
+
105
+ test("returns zeros when no data exists", () => {
106
+ const summary = getFeedbackSummary(tmpDir, "commit");
107
+
108
+ expect(summary.total).toBe(0);
109
+ expect(summary.accepted).toBe(0);
110
+ expect(summary.rejected).toBe(0);
111
+ expect(summary.acceptRate).toBe(0);
112
+ });
113
+ });
114
+
115
+ describe("recordFeedbackWithCompression", () => {
116
+ test("accepted review with aiOutput triggers compression and episodic storage", () => {
117
+ recordFeedbackWithCompression(tmpDir, {
118
+ promptHash: "review-hash-1",
119
+ task: "review",
120
+ accepted: true,
121
+ timestamp: new Date().toISOString(),
122
+ aiOutput: "Overall: code looks good. Warning: missing null check.",
123
+ diff: `--- a/src/index.ts
124
+ +++ b/src/index.ts
125
+ @@ -1,3 +1,5 @@
126
+ +import { bar } from './bar';`,
127
+ });
128
+
129
+ // Verify episodic entry was created in context DB
130
+ const dbResult = getContextDb(tmpDir);
131
+ expect(dbResult.ok).toBe(true);
132
+ if (!dbResult.ok) return;
133
+
134
+ const { db } = dbResult.value;
135
+ const rows = db
136
+ .query(
137
+ "SELECT * FROM episodic_entries WHERE type = 'review' ORDER BY created_at DESC",
138
+ )
139
+ .all() as Array<{
140
+ content: string;
141
+ summary: string;
142
+ type: string;
143
+ }>;
144
+
145
+ expect(rows.length).toBe(1);
146
+ expect(rows[0]?.type).toBe("review");
147
+ expect(rows[0]?.summary).toBe("Accepted review");
148
+ expect(rows[0]?.content).toContain("[review] Accepted review");
149
+ });
150
+
151
+ test("rejected review does NOT trigger compression", () => {
152
+ recordFeedbackWithCompression(tmpDir, {
153
+ promptHash: "review-hash-2",
154
+ task: "review",
155
+ accepted: false,
156
+ timestamp: new Date().toISOString(),
157
+ aiOutput: "Some review output",
158
+ diff: "some diff",
159
+ });
160
+
161
+ // Verify no episodic entry was created
162
+ const dbResult = getContextDb(tmpDir);
163
+ expect(dbResult.ok).toBe(true);
164
+ if (!dbResult.ok) return;
165
+
166
+ const { db } = dbResult.value;
167
+ const rows = db
168
+ .query("SELECT * FROM episodic_entries WHERE type = 'review'")
169
+ .all();
170
+
171
+ expect(rows.length).toBe(0);
172
+ });
173
+
174
+ test("accepted non-review task does NOT trigger compression", () => {
175
+ recordFeedbackWithCompression(tmpDir, {
176
+ promptHash: "commit-hash-1",
177
+ task: "commit",
178
+ accepted: true,
179
+ timestamp: new Date().toISOString(),
180
+ aiOutput: "Generated commit message",
181
+ diff: "some diff",
182
+ });
183
+
184
+ // Verify no episodic entry was created
185
+ const dbResult = getContextDb(tmpDir);
186
+ expect(dbResult.ok).toBe(true);
187
+ if (!dbResult.ok) return;
188
+
189
+ const { db } = dbResult.value;
190
+ const rows = db
191
+ .query("SELECT * FROM episodic_entries WHERE type = 'review'")
192
+ .all();
193
+
194
+ expect(rows.length).toBe(0);
195
+ });
196
+
197
+ test("missing aiOutput on accepted review does NOT trigger compression", () => {
198
+ recordFeedbackWithCompression(tmpDir, {
199
+ promptHash: "review-hash-3",
200
+ task: "review",
201
+ accepted: true,
202
+ timestamp: new Date().toISOString(),
203
+ // No aiOutput provided
204
+ diff: "some diff",
205
+ });
206
+
207
+ // Verify no episodic entry was created
208
+ const dbResult = getContextDb(tmpDir);
209
+ expect(dbResult.ok).toBe(true);
210
+ if (!dbResult.ok) return;
211
+
212
+ const { db } = dbResult.value;
213
+ const rows = db
214
+ .query("SELECT * FROM episodic_entries WHERE type = 'review'")
215
+ .all();
216
+
217
+ expect(rows.length).toBe(0);
218
+ });
219
+ });
@@ -0,0 +1,150 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getContextDb } from "../../db/index";
5
+ import { compressReview, storeCompressedReview } from "../compress";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = join(
11
+ import.meta.dir,
12
+ `tmp-compress-${Date.now()}-${Math.random().toString(36).slice(2)}`,
13
+ );
14
+ mkdirSync(tmpDir, { recursive: true });
15
+ });
16
+
17
+ afterEach(() => {
18
+ try {
19
+ const { rmSync } = require("node:fs");
20
+ rmSync(tmpDir, { recursive: true, force: true });
21
+ } catch {
22
+ // ignore
23
+ }
24
+ });
25
+
26
+ describe("compressReview", () => {
27
+ test("returns compressed string for accepted review", () => {
28
+ const result = compressReview({
29
+ diff: `--- a/src/index.ts
30
+ +++ b/src/index.ts
31
+ @@ -1,3 +1,5 @@
32
+ import { foo } from './foo';
33
+ +import { bar } from './bar';
34
+
35
+ -export const x = 1;
36
+ +export const x = foo() + bar();
37
+ +export const y = 2;`,
38
+ aiOutput: `## Review Summary
39
+ Found 2 issues in the changes:
40
+ 1. Warning: Missing error handling in foo() call
41
+ 2. Issue: Variable 'y' is exported but never tested
42
+
43
+ Overall: Acceptable with minor improvements suggested.`,
44
+ task: "review",
45
+ accepted: true,
46
+ });
47
+
48
+ expect(result).not.toBeNull();
49
+ expect(typeof result).toBe("string");
50
+ expect(result?.length).toBeGreaterThan(0);
51
+ });
52
+
53
+ test("returns null for rejected review", () => {
54
+ const result = compressReview({
55
+ diff: "some diff",
56
+ aiOutput: "some output",
57
+ task: "review",
58
+ accepted: false,
59
+ });
60
+
61
+ expect(result).toBeNull();
62
+ });
63
+
64
+ test("compressed output is under 2000 characters (approx 500 tokens)", () => {
65
+ // Create a large diff and review output
66
+ const longDiff = Array(100)
67
+ .fill(null)
68
+ .map(
69
+ (_, i) => `--- a/src/file${i}.ts
70
+ +++ b/src/file${i}.ts
71
+ @@ -1,3 +1,5 @@
72
+ import { something } from './something';
73
+ +import { newThing } from './new-thing';
74
+
75
+ -export const old = 1;
76
+ +export const updated = newThing();
77
+ +export const extra = 2;`,
78
+ )
79
+ .join("\n");
80
+
81
+ const longAiOutput = Array(50)
82
+ .fill(null)
83
+ .map(
84
+ (_, i) =>
85
+ `${i + 1}. Warning: issue found in file${i}.ts — missing error handling for edge case where input is null. This could lead to runtime errors in production. Fix: add null check before calling the function.`,
86
+ )
87
+ .join("\n");
88
+
89
+ const result = compressReview({
90
+ diff: longDiff,
91
+ aiOutput: longAiOutput,
92
+ task: "review",
93
+ accepted: true,
94
+ });
95
+
96
+ expect(result).not.toBeNull();
97
+ expect(result?.length).toBeLessThanOrEqual(2000);
98
+ });
99
+
100
+ test("extracts file names from diff headers", () => {
101
+ const result = compressReview({
102
+ diff: `--- a/packages/core/src/ai/index.ts
103
+ +++ b/packages/core/src/ai/index.ts
104
+ @@ -1,3 +1,5 @@
105
+ +import { bar } from './bar';
106
+ --- a/packages/cli/src/commands/verify.ts
107
+ +++ b/packages/cli/src/commands/verify.ts
108
+ @@ -10,3 +10,5 @@
109
+ +const x = 1;`,
110
+ aiOutput: `Review found no critical issues.
111
+ Minor warning: consider adding type annotations.`,
112
+ task: "review",
113
+ accepted: true,
114
+ });
115
+
116
+ expect(result).not.toBeNull();
117
+ expect(result).toContain("packages/core/src/ai/index.ts");
118
+ expect(result).toContain("packages/cli/src/commands/verify.ts");
119
+ });
120
+ });
121
+
122
+ describe("storeCompressedReview", () => {
123
+ test("writes compressed review to episodic DB", () => {
124
+ const compressed =
125
+ "[review] Files: src/index.ts | Findings: missing error handling | Verdict: accepted";
126
+
127
+ storeCompressedReview(tmpDir, compressed, "review");
128
+
129
+ // Verify entry exists in episodic_entries
130
+ const dbResult = getContextDb(tmpDir);
131
+ expect(dbResult.ok).toBe(true);
132
+ if (!dbResult.ok) return;
133
+
134
+ const { db } = dbResult.value;
135
+ const rows = db
136
+ .query(
137
+ "SELECT * FROM episodic_entries WHERE type = 'review' ORDER BY created_at DESC",
138
+ )
139
+ .all() as Array<{
140
+ content: string;
141
+ summary: string;
142
+ type: string;
143
+ }>;
144
+
145
+ expect(rows.length).toBe(1);
146
+ expect(rows[0]?.content).toBe(compressed);
147
+ expect(rows[0]?.summary).toBe("Accepted review");
148
+ expect(rows[0]?.type).toBe("review");
149
+ });
150
+ });