@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,291 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
accessEntry,
|
|
7
|
+
addEntry,
|
|
8
|
+
calculateDecay,
|
|
9
|
+
decayAllEntries,
|
|
10
|
+
getEntries,
|
|
11
|
+
pruneEntries,
|
|
12
|
+
} from "../episodic.ts";
|
|
13
|
+
|
|
14
|
+
const TEST_DIR = join(tmpdir(), `maina-episodic-test-${Date.now()}`);
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("calculateDecay", () => {
|
|
25
|
+
test("returns 1.0 for just-accessed entry with 0 count", () => {
|
|
26
|
+
// daysSinceAccess=0, accessCount=0
|
|
27
|
+
// exp(-0.1 * 0) + 0.1 * min(0, 5) = 1 + 0 = 1.0, clamped to 1.0
|
|
28
|
+
const result = calculateDecay(0, 0);
|
|
29
|
+
expect(result).toBeCloseTo(1.0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns lower value for entries accessed days ago", () => {
|
|
33
|
+
// daysSinceAccess=10, accessCount=0
|
|
34
|
+
// exp(-0.1 * 10) + 0 = exp(-1) ≈ 0.368
|
|
35
|
+
const recent = calculateDecay(0, 0);
|
|
36
|
+
const older = calculateDecay(10, 0);
|
|
37
|
+
expect(older).toBeLessThan(recent);
|
|
38
|
+
expect(older).toBeCloseTo(Math.exp(-1), 3);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("calculateDecay with high accessCount returns higher base", () => {
|
|
42
|
+
// Same daysSinceAccess, higher accessCount gives higher relevance
|
|
43
|
+
const lowAccess = calculateDecay(5, 0);
|
|
44
|
+
const highAccess = calculateDecay(5, 5);
|
|
45
|
+
expect(highAccess).toBeGreaterThan(lowAccess);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("calculateDecay is clamped between 0 and 1", () => {
|
|
49
|
+
// Even with 0 days and max access count, should not exceed 1
|
|
50
|
+
const maxResult = calculateDecay(0, 100);
|
|
51
|
+
expect(maxResult).toBeLessThanOrEqual(1.0);
|
|
52
|
+
expect(maxResult).toBeGreaterThanOrEqual(0.0);
|
|
53
|
+
|
|
54
|
+
// With very large daysSinceAccess and 0 count, should approach 0 but not go below
|
|
55
|
+
const minResult = calculateDecay(1000, 0);
|
|
56
|
+
expect(minResult).toBeGreaterThanOrEqual(0.0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("accessCount is capped at 5 for bonus calculation", () => {
|
|
60
|
+
// accessCount=5 and accessCount=100 should give same result
|
|
61
|
+
const atCap = calculateDecay(10, 5);
|
|
62
|
+
const overCap = calculateDecay(10, 100);
|
|
63
|
+
expect(atCap).toBeCloseTo(overCap);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("addEntry", () => {
|
|
68
|
+
test("creates entry with correct defaults", () => {
|
|
69
|
+
const mainaDir = join(TEST_DIR, "add-entry-test");
|
|
70
|
+
const before = new Date().toISOString();
|
|
71
|
+
|
|
72
|
+
const entry = addEntry(mainaDir, {
|
|
73
|
+
content: "User implemented authentication module",
|
|
74
|
+
summary: "Auth implementation",
|
|
75
|
+
type: "session",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const after = new Date().toISOString();
|
|
79
|
+
|
|
80
|
+
expect(entry.id).toBeDefined();
|
|
81
|
+
expect(typeof entry.id).toBe("string");
|
|
82
|
+
expect(entry.id.length).toBeGreaterThan(0);
|
|
83
|
+
expect(entry.content).toBe("User implemented authentication module");
|
|
84
|
+
expect(entry.summary).toBe("Auth implementation");
|
|
85
|
+
expect(entry.type).toBe("session");
|
|
86
|
+
expect(entry.relevance).toBe(1.0);
|
|
87
|
+
expect(entry.accessCount).toBe(0);
|
|
88
|
+
expect(entry.createdAt >= before).toBe(true);
|
|
89
|
+
expect(entry.createdAt <= after).toBe(true);
|
|
90
|
+
expect(entry.lastAccessedAt >= before).toBe(true);
|
|
91
|
+
expect(entry.lastAccessedAt <= after).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("generates unique IDs for each entry", () => {
|
|
95
|
+
const mainaDir = join(TEST_DIR, "unique-id-test");
|
|
96
|
+
|
|
97
|
+
const entry1 = addEntry(mainaDir, {
|
|
98
|
+
content: "Entry 1",
|
|
99
|
+
summary: "Summary 1",
|
|
100
|
+
type: "session",
|
|
101
|
+
});
|
|
102
|
+
const entry2 = addEntry(mainaDir, {
|
|
103
|
+
content: "Entry 2",
|
|
104
|
+
summary: "Summary 2",
|
|
105
|
+
type: "session",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(entry1.id).not.toBe(entry2.id);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("accessEntry", () => {
|
|
113
|
+
test("increments accessCount and updates lastAccessedAt", () => {
|
|
114
|
+
const mainaDir = join(TEST_DIR, "access-entry-test");
|
|
115
|
+
|
|
116
|
+
const created = addEntry(mainaDir, {
|
|
117
|
+
content: "Some session content",
|
|
118
|
+
summary: "Session summary",
|
|
119
|
+
type: "session",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(created.accessCount).toBe(0);
|
|
123
|
+
const beforeAccess = new Date().toISOString();
|
|
124
|
+
|
|
125
|
+
const accessed = accessEntry(mainaDir, created.id);
|
|
126
|
+
const afterAccess = new Date().toISOString();
|
|
127
|
+
|
|
128
|
+
expect(accessed).not.toBeNull();
|
|
129
|
+
if (!accessed) return;
|
|
130
|
+
|
|
131
|
+
expect(accessed.accessCount).toBe(1);
|
|
132
|
+
expect(accessed.lastAccessedAt >= beforeAccess).toBe(true);
|
|
133
|
+
expect(accessed.lastAccessedAt <= afterAccess).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("returns null for non-existent entry", () => {
|
|
137
|
+
const mainaDir = join(TEST_DIR, "access-null-test");
|
|
138
|
+
// Initialize by adding a dummy entry so db exists
|
|
139
|
+
addEntry(mainaDir, {
|
|
140
|
+
content: "dummy",
|
|
141
|
+
summary: "dummy",
|
|
142
|
+
type: "session",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const result = accessEntry(mainaDir, "non-existent-id");
|
|
146
|
+
expect(result).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("multiple accesses increment count correctly", () => {
|
|
150
|
+
const mainaDir = join(TEST_DIR, "multi-access-test");
|
|
151
|
+
|
|
152
|
+
const entry = addEntry(mainaDir, {
|
|
153
|
+
content: "Repeatedly accessed entry",
|
|
154
|
+
summary: "Repeated",
|
|
155
|
+
type: "review",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
accessEntry(mainaDir, entry.id);
|
|
159
|
+
accessEntry(mainaDir, entry.id);
|
|
160
|
+
const third = accessEntry(mainaDir, entry.id);
|
|
161
|
+
|
|
162
|
+
expect(third).not.toBeNull();
|
|
163
|
+
if (!third) return;
|
|
164
|
+
expect(third.accessCount).toBe(3);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("getEntries", () => {
|
|
169
|
+
test("returns entries sorted by relevance descending", () => {
|
|
170
|
+
const mainaDir = join(TEST_DIR, "get-entries-test");
|
|
171
|
+
|
|
172
|
+
// Add entries
|
|
173
|
+
const _e1 = addEntry(mainaDir, {
|
|
174
|
+
content: "Low relevance entry",
|
|
175
|
+
summary: "Low",
|
|
176
|
+
type: "session",
|
|
177
|
+
});
|
|
178
|
+
const e2 = addEntry(mainaDir, {
|
|
179
|
+
content: "High relevance entry",
|
|
180
|
+
summary: "High",
|
|
181
|
+
type: "session",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Access e2 multiple times to boost its relevance
|
|
185
|
+
accessEntry(mainaDir, e2.id);
|
|
186
|
+
accessEntry(mainaDir, e2.id);
|
|
187
|
+
accessEntry(mainaDir, e2.id);
|
|
188
|
+
|
|
189
|
+
const entries = getEntries(mainaDir);
|
|
190
|
+
expect(entries.length).toBeGreaterThanOrEqual(2);
|
|
191
|
+
|
|
192
|
+
// Verify sorted by relevance descending
|
|
193
|
+
for (let i = 0; i < entries.length - 1; i++) {
|
|
194
|
+
const current = entries[i]?.relevance ?? 0;
|
|
195
|
+
const next = entries[i + 1]?.relevance ?? 0;
|
|
196
|
+
expect(current).toBeGreaterThanOrEqual(next);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("filters by type when specified", () => {
|
|
201
|
+
const mainaDir = join(TEST_DIR, "filter-type-test");
|
|
202
|
+
|
|
203
|
+
addEntry(mainaDir, {
|
|
204
|
+
content: "Session entry",
|
|
205
|
+
summary: "Session",
|
|
206
|
+
type: "session",
|
|
207
|
+
});
|
|
208
|
+
addEntry(mainaDir, {
|
|
209
|
+
content: "Commit entry",
|
|
210
|
+
summary: "Commit",
|
|
211
|
+
type: "commit",
|
|
212
|
+
});
|
|
213
|
+
addEntry(mainaDir, {
|
|
214
|
+
content: "Review entry",
|
|
215
|
+
summary: "Review",
|
|
216
|
+
type: "review",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const sessionEntries = getEntries(mainaDir, "session");
|
|
220
|
+
expect(sessionEntries.length).toBe(1);
|
|
221
|
+
expect(sessionEntries[0]?.type).toBe("session");
|
|
222
|
+
|
|
223
|
+
const allEntries = getEntries(mainaDir);
|
|
224
|
+
expect(allEntries.length).toBe(3);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("pruneEntries", () => {
|
|
229
|
+
test("removes low-relevance entries", () => {
|
|
230
|
+
const mainaDir = join(TEST_DIR, "prune-low-relevance-test");
|
|
231
|
+
|
|
232
|
+
// We can only directly add entries with relevance=1.0 initially
|
|
233
|
+
// To get low-relevance entries, we need to use decayAllEntries
|
|
234
|
+
// Instead, let's just verify pruneEntries runs and returns a count
|
|
235
|
+
addEntry(mainaDir, {
|
|
236
|
+
content: "Normal entry",
|
|
237
|
+
summary: "Normal",
|
|
238
|
+
type: "session",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const pruned = pruneEntries(mainaDir);
|
|
242
|
+
// With fresh entries (relevance=1.0), nothing should be pruned
|
|
243
|
+
expect(pruned).toBe(0);
|
|
244
|
+
|
|
245
|
+
const remaining = getEntries(mainaDir);
|
|
246
|
+
expect(remaining.length).toBe(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("enforces max 100 entries keeping highest relevance", () => {
|
|
250
|
+
const mainaDir = join(TEST_DIR, "prune-max-entries-test");
|
|
251
|
+
|
|
252
|
+
// Add 110 entries
|
|
253
|
+
for (let i = 0; i < 110; i++) {
|
|
254
|
+
addEntry(mainaDir, {
|
|
255
|
+
content: `Entry ${i}`,
|
|
256
|
+
summary: `Summary ${i}`,
|
|
257
|
+
type: "session",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const beforePrune = getEntries(mainaDir);
|
|
262
|
+
expect(beforePrune.length).toBe(110);
|
|
263
|
+
|
|
264
|
+
const pruned = pruneEntries(mainaDir);
|
|
265
|
+
expect(pruned).toBe(10);
|
|
266
|
+
|
|
267
|
+
const afterPrune = getEntries(mainaDir);
|
|
268
|
+
expect(afterPrune.length).toBe(100);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("decayAllEntries", () => {
|
|
273
|
+
test("recalculates relevance for all entries", () => {
|
|
274
|
+
const mainaDir = join(TEST_DIR, "decay-all-test");
|
|
275
|
+
|
|
276
|
+
addEntry(mainaDir, {
|
|
277
|
+
content: "Entry to decay",
|
|
278
|
+
summary: "Decay me",
|
|
279
|
+
type: "session",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Should not throw
|
|
283
|
+
decayAllEntries(mainaDir);
|
|
284
|
+
|
|
285
|
+
const entries = getEntries(mainaDir);
|
|
286
|
+
expect(entries.length).toBe(1);
|
|
287
|
+
// Relevance should still be valid (between 0 and 1)
|
|
288
|
+
expect(entries[0]?.relevance).toBeGreaterThanOrEqual(0);
|
|
289
|
+
expect(entries[0]?.relevance).toBeLessThanOrEqual(1);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
buildGraph,
|
|
7
|
+
pageRank,
|
|
8
|
+
scoreRelevance,
|
|
9
|
+
type TaskContext,
|
|
10
|
+
} from "../relevance";
|
|
11
|
+
|
|
12
|
+
const TEST_DIR = join(tmpdir(), `maina-relevance-test-${Date.now()}`);
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
16
|
+
|
|
17
|
+
// Create a simple dependency graph of TS files for testing
|
|
18
|
+
// fileA imports fileB and fileC
|
|
19
|
+
// fileB imports fileC
|
|
20
|
+
// fileC has no imports
|
|
21
|
+
writeFileSync(
|
|
22
|
+
join(TEST_DIR, "fileA.ts"),
|
|
23
|
+
`import { foo } from "./fileB";\nimport { bar } from "./fileC";\nexport function doA() {}\n`,
|
|
24
|
+
);
|
|
25
|
+
writeFileSync(
|
|
26
|
+
join(TEST_DIR, "fileB.ts"),
|
|
27
|
+
`import { bar } from "./fileC";\nexport function foo() {}\n`,
|
|
28
|
+
);
|
|
29
|
+
writeFileSync(join(TEST_DIR, "fileC.ts"), `export function bar() {}\n`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("buildGraph", () => {
|
|
37
|
+
test("creates nodes for each file", async () => {
|
|
38
|
+
const files = [
|
|
39
|
+
join(TEST_DIR, "fileA.ts"),
|
|
40
|
+
join(TEST_DIR, "fileB.ts"),
|
|
41
|
+
join(TEST_DIR, "fileC.ts"),
|
|
42
|
+
];
|
|
43
|
+
const graph = await buildGraph(files);
|
|
44
|
+
|
|
45
|
+
expect(graph.nodes.has(join(TEST_DIR, "fileA.ts"))).toBe(true);
|
|
46
|
+
expect(graph.nodes.has(join(TEST_DIR, "fileB.ts"))).toBe(true);
|
|
47
|
+
expect(graph.nodes.has(join(TEST_DIR, "fileC.ts"))).toBe(true);
|
|
48
|
+
expect(graph.nodes.size).toBe(3);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("creates edges between files with imports", async () => {
|
|
52
|
+
const files = [
|
|
53
|
+
join(TEST_DIR, "fileA.ts"),
|
|
54
|
+
join(TEST_DIR, "fileB.ts"),
|
|
55
|
+
join(TEST_DIR, "fileC.ts"),
|
|
56
|
+
];
|
|
57
|
+
const graph = await buildGraph(files);
|
|
58
|
+
|
|
59
|
+
// fileA imports fileB and fileC
|
|
60
|
+
const aEdges = graph.edges.get(join(TEST_DIR, "fileA.ts"));
|
|
61
|
+
expect(aEdges).toBeDefined();
|
|
62
|
+
expect(aEdges?.has(join(TEST_DIR, "fileB.ts"))).toBe(true);
|
|
63
|
+
expect(aEdges?.has(join(TEST_DIR, "fileC.ts"))).toBe(true);
|
|
64
|
+
|
|
65
|
+
// fileB imports fileC
|
|
66
|
+
const bEdges = graph.edges.get(join(TEST_DIR, "fileB.ts"));
|
|
67
|
+
expect(bEdges?.has(join(TEST_DIR, "fileC.ts"))).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("assigns weight 1.0 for normal imports", async () => {
|
|
71
|
+
const files = [
|
|
72
|
+
join(TEST_DIR, "fileA.ts"),
|
|
73
|
+
join(TEST_DIR, "fileB.ts"),
|
|
74
|
+
join(TEST_DIR, "fileC.ts"),
|
|
75
|
+
];
|
|
76
|
+
const graph = await buildGraph(files);
|
|
77
|
+
|
|
78
|
+
const aEdges = graph.edges.get(join(TEST_DIR, "fileA.ts"));
|
|
79
|
+
const weightToB = aEdges?.get(join(TEST_DIR, "fileB.ts"));
|
|
80
|
+
expect(weightToB).toBeCloseTo(1.0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("assigns weight 0.5 for type-only imports", async () => {
|
|
84
|
+
const typeFile = join(TEST_DIR, "typeImporter.ts");
|
|
85
|
+
const typeTarget = join(TEST_DIR, "typeTarget.ts");
|
|
86
|
+
writeFileSync(
|
|
87
|
+
typeFile,
|
|
88
|
+
`import type { SomeType } from "./typeTarget";\nexport const x = 1;\n`,
|
|
89
|
+
);
|
|
90
|
+
writeFileSync(typeTarget, `export type SomeType = string;\n`);
|
|
91
|
+
|
|
92
|
+
const graph = await buildGraph([typeFile, typeTarget]);
|
|
93
|
+
|
|
94
|
+
const edges = graph.edges.get(typeFile);
|
|
95
|
+
const weight = edges?.get(typeTarget);
|
|
96
|
+
expect(weight).toBeCloseTo(0.5);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("assigns weight 0.1 for private name imports (starting with _)", async () => {
|
|
100
|
+
const privateFile = join(TEST_DIR, "privateImporter.ts");
|
|
101
|
+
const privateTarget = join(TEST_DIR, "privateTarget.ts");
|
|
102
|
+
writeFileSync(
|
|
103
|
+
privateFile,
|
|
104
|
+
`import { _helper } from "./privateTarget";\nexport const x = 1;\n`,
|
|
105
|
+
);
|
|
106
|
+
writeFileSync(privateTarget, `export function _helper() {}\n`);
|
|
107
|
+
|
|
108
|
+
const graph = await buildGraph([privateFile, privateTarget]);
|
|
109
|
+
|
|
110
|
+
const edges = graph.edges.get(privateFile);
|
|
111
|
+
const weight = edges?.get(privateTarget);
|
|
112
|
+
expect(weight).toBeCloseTo(0.1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("ignores non-relative imports (node_modules)", async () => {
|
|
116
|
+
const extFile = join(TEST_DIR, "externalImporter.ts");
|
|
117
|
+
writeFileSync(
|
|
118
|
+
extFile,
|
|
119
|
+
`import { something } from "some-package";\nexport const x = 1;\n`,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const graph = await buildGraph([extFile]);
|
|
123
|
+
|
|
124
|
+
// The external package should not be added as a node or edge
|
|
125
|
+
const edges = graph.edges.get(extFile);
|
|
126
|
+
// No edges to external packages
|
|
127
|
+
expect(edges?.size ?? 0).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("returns empty graph for empty file list", async () => {
|
|
131
|
+
const graph = await buildGraph([]);
|
|
132
|
+
expect(graph.nodes.size).toBe(0);
|
|
133
|
+
expect(graph.edges.size).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("pageRank", () => {
|
|
138
|
+
test("returns scores for all nodes", async () => {
|
|
139
|
+
const files = [
|
|
140
|
+
join(TEST_DIR, "fileA.ts"),
|
|
141
|
+
join(TEST_DIR, "fileB.ts"),
|
|
142
|
+
join(TEST_DIR, "fileC.ts"),
|
|
143
|
+
];
|
|
144
|
+
const graph = await buildGraph(files);
|
|
145
|
+
const scores = pageRank(graph);
|
|
146
|
+
|
|
147
|
+
expect(scores.has(join(TEST_DIR, "fileA.ts"))).toBe(true);
|
|
148
|
+
expect(scores.has(join(TEST_DIR, "fileB.ts"))).toBe(true);
|
|
149
|
+
expect(scores.has(join(TEST_DIR, "fileC.ts"))).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("returns scores that sum approximately to 1", async () => {
|
|
153
|
+
const files = [
|
|
154
|
+
join(TEST_DIR, "fileA.ts"),
|
|
155
|
+
join(TEST_DIR, "fileB.ts"),
|
|
156
|
+
join(TEST_DIR, "fileC.ts"),
|
|
157
|
+
];
|
|
158
|
+
const graph = await buildGraph(files);
|
|
159
|
+
const scores = pageRank(graph);
|
|
160
|
+
|
|
161
|
+
const total = Array.from(scores.values()).reduce((a, b) => a + b, 0);
|
|
162
|
+
expect(total).toBeCloseTo(1.0, 1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("pageRank with personalization biases scores toward personalized nodes", async () => {
|
|
166
|
+
const files = [
|
|
167
|
+
join(TEST_DIR, "fileA.ts"),
|
|
168
|
+
join(TEST_DIR, "fileB.ts"),
|
|
169
|
+
join(TEST_DIR, "fileC.ts"),
|
|
170
|
+
];
|
|
171
|
+
const graph = await buildGraph(files);
|
|
172
|
+
|
|
173
|
+
// Personalize heavily toward fileA
|
|
174
|
+
const personalization = new Map<string, number>([
|
|
175
|
+
[join(TEST_DIR, "fileA.ts"), 100],
|
|
176
|
+
[join(TEST_DIR, "fileB.ts"), 1],
|
|
177
|
+
[join(TEST_DIR, "fileC.ts"), 1],
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
const scoresPersonalized = pageRank(graph, { personalization });
|
|
181
|
+
const scoresUniform = pageRank(graph);
|
|
182
|
+
|
|
183
|
+
const aScorePersonalized =
|
|
184
|
+
scoresPersonalized.get(join(TEST_DIR, "fileA.ts")) ?? 0;
|
|
185
|
+
const aScoreUniform = scoresUniform.get(join(TEST_DIR, "fileA.ts")) ?? 0;
|
|
186
|
+
|
|
187
|
+
// fileA should have higher score when personalized toward it
|
|
188
|
+
expect(aScorePersonalized).toBeGreaterThan(aScoreUniform);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("pageRank without personalization distributes scores more evenly", async () => {
|
|
192
|
+
const files = [
|
|
193
|
+
join(TEST_DIR, "fileA.ts"),
|
|
194
|
+
join(TEST_DIR, "fileB.ts"),
|
|
195
|
+
join(TEST_DIR, "fileC.ts"),
|
|
196
|
+
];
|
|
197
|
+
const graph = await buildGraph(files);
|
|
198
|
+
|
|
199
|
+
// No personalization — uniform distribution baseline
|
|
200
|
+
const scoresUniform = pageRank(graph);
|
|
201
|
+
|
|
202
|
+
// With extreme personalization toward fileC (heavily-linked target),
|
|
203
|
+
// fileC should dominate more than in the uniform case.
|
|
204
|
+
const personalization = new Map<string, number>([
|
|
205
|
+
[join(TEST_DIR, "fileA.ts"), 1],
|
|
206
|
+
[join(TEST_DIR, "fileB.ts"), 1],
|
|
207
|
+
[join(TEST_DIR, "fileC.ts"), 1000],
|
|
208
|
+
]);
|
|
209
|
+
const scoresPersonalized = pageRank(graph, { personalization });
|
|
210
|
+
|
|
211
|
+
// fileC score should be higher when personalized toward it
|
|
212
|
+
const cUniform = scoresUniform.get(join(TEST_DIR, "fileC.ts")) ?? 0;
|
|
213
|
+
const cPersonalized =
|
|
214
|
+
scoresPersonalized.get(join(TEST_DIR, "fileC.ts")) ?? 0;
|
|
215
|
+
expect(cPersonalized).toBeGreaterThan(cUniform);
|
|
216
|
+
|
|
217
|
+
// fileA score (not personalized heavily) should be lower in personalized run
|
|
218
|
+
const aUniform = scoresUniform.get(join(TEST_DIR, "fileA.ts")) ?? 0;
|
|
219
|
+
const aPersonalized =
|
|
220
|
+
scoresPersonalized.get(join(TEST_DIR, "fileA.ts")) ?? 0;
|
|
221
|
+
expect(aPersonalized).toBeLessThan(aUniform);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("returns empty map for empty graph", () => {
|
|
225
|
+
const graph = {
|
|
226
|
+
nodes: new Set<string>(),
|
|
227
|
+
edges: new Map<string, Map<string, number>>(),
|
|
228
|
+
};
|
|
229
|
+
const scores = pageRank(graph);
|
|
230
|
+
expect(scores.size).toBe(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("respects custom dampingFactor and iterations options", async () => {
|
|
234
|
+
const files = [
|
|
235
|
+
join(TEST_DIR, "fileA.ts"),
|
|
236
|
+
join(TEST_DIR, "fileB.ts"),
|
|
237
|
+
join(TEST_DIR, "fileC.ts"),
|
|
238
|
+
];
|
|
239
|
+
const graph = await buildGraph(files);
|
|
240
|
+
|
|
241
|
+
// Should not throw with custom options
|
|
242
|
+
const scores = pageRank(graph, { dampingFactor: 0.5, iterations: 5 });
|
|
243
|
+
const total = Array.from(scores.values()).reduce((a, b) => a + b, 0);
|
|
244
|
+
expect(total).toBeCloseTo(1.0, 1);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("scoreRelevance", () => {
|
|
249
|
+
test("ranks touched files higher", async () => {
|
|
250
|
+
const files = [
|
|
251
|
+
join(TEST_DIR, "fileA.ts"),
|
|
252
|
+
join(TEST_DIR, "fileB.ts"),
|
|
253
|
+
join(TEST_DIR, "fileC.ts"),
|
|
254
|
+
];
|
|
255
|
+
const graph = await buildGraph(files);
|
|
256
|
+
|
|
257
|
+
const taskContext: TaskContext = {
|
|
258
|
+
touchedFiles: [join(TEST_DIR, "fileC.ts")],
|
|
259
|
+
mentionedFiles: [],
|
|
260
|
+
currentTicketTerms: [],
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const scores = scoreRelevance(graph, taskContext);
|
|
264
|
+
|
|
265
|
+
// fileC is touched, should rank higher than without personalization
|
|
266
|
+
const cScore = scores.get(join(TEST_DIR, "fileC.ts")) ?? 0;
|
|
267
|
+
const uniformScores = pageRank(graph);
|
|
268
|
+
const cUniformScore = uniformScores.get(join(TEST_DIR, "fileC.ts")) ?? 0;
|
|
269
|
+
|
|
270
|
+
expect(cScore).toBeGreaterThan(cUniformScore);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("mentioned files get boosted scores (though less than touched)", async () => {
|
|
274
|
+
const files = [
|
|
275
|
+
join(TEST_DIR, "fileA.ts"),
|
|
276
|
+
join(TEST_DIR, "fileB.ts"),
|
|
277
|
+
join(TEST_DIR, "fileC.ts"),
|
|
278
|
+
];
|
|
279
|
+
const graph = await buildGraph(files);
|
|
280
|
+
|
|
281
|
+
// Task1: fileA is touched (weight 50), fileB/fileC are mentioned (weight 10 each)
|
|
282
|
+
const taskWithTouched: TaskContext = {
|
|
283
|
+
touchedFiles: [join(TEST_DIR, "fileA.ts")],
|
|
284
|
+
mentionedFiles: [join(TEST_DIR, "fileB.ts"), join(TEST_DIR, "fileC.ts")],
|
|
285
|
+
currentTicketTerms: [],
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Task2: fileA is mentioned (weight 10), fileB/fileC are touched (weight 50 each)
|
|
289
|
+
const taskWithMentioned: TaskContext = {
|
|
290
|
+
touchedFiles: [join(TEST_DIR, "fileB.ts"), join(TEST_DIR, "fileC.ts")],
|
|
291
|
+
mentionedFiles: [join(TEST_DIR, "fileA.ts")],
|
|
292
|
+
currentTicketTerms: [],
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const scoresTouched = scoreRelevance(graph, taskWithTouched);
|
|
296
|
+
const scoresMentioned = scoreRelevance(graph, taskWithMentioned);
|
|
297
|
+
|
|
298
|
+
const aTouched = scoresTouched.get(join(TEST_DIR, "fileA.ts")) ?? 0;
|
|
299
|
+
const aMentioned = scoresMentioned.get(join(TEST_DIR, "fileA.ts")) ?? 0;
|
|
300
|
+
|
|
301
|
+
// When fileA is touched (weight 50), it should score higher than when only mentioned (weight 10)
|
|
302
|
+
expect(aTouched).toBeGreaterThan(aMentioned);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("returns scores summing to approximately 1", async () => {
|
|
306
|
+
const files = [
|
|
307
|
+
join(TEST_DIR, "fileA.ts"),
|
|
308
|
+
join(TEST_DIR, "fileB.ts"),
|
|
309
|
+
join(TEST_DIR, "fileC.ts"),
|
|
310
|
+
];
|
|
311
|
+
const graph = await buildGraph(files);
|
|
312
|
+
|
|
313
|
+
const taskContext: TaskContext = {
|
|
314
|
+
touchedFiles: [join(TEST_DIR, "fileA.ts")],
|
|
315
|
+
mentionedFiles: [join(TEST_DIR, "fileB.ts")],
|
|
316
|
+
currentTicketTerms: ["doA", "foo"],
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const scores = scoreRelevance(graph, taskContext);
|
|
320
|
+
const total = Array.from(scores.values()).reduce((a, b) => a + b, 0);
|
|
321
|
+
expect(total).toBeCloseTo(1.0, 1);
|
|
322
|
+
});
|
|
323
|
+
});
|