@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.
- package/README.md +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- 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
|
+
});
|