@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,95 @@
1
+ export interface StoryConfig {
2
+ name: string;
3
+ description: string;
4
+ tier: number;
5
+ source: string;
6
+ testFiles: string[];
7
+ /** Hidden validation tests — not shown to AI during implementation */
8
+ validationFiles?: string[];
9
+ metrics: {
10
+ expectedTests: number;
11
+ expectedValidationTests?: number;
12
+ originalLOC: number;
13
+ complexity: "easy" | "medium" | "hard";
14
+ };
15
+ }
16
+
17
+ export interface BenchmarkMetrics {
18
+ pipeline: "maina" | "speckit";
19
+ storyName: string;
20
+ wallClockMs: number;
21
+ tokensInput: number;
22
+ tokensOutput: number;
23
+ testsTotal: number;
24
+ testsPassed: number;
25
+ testsFailed: number;
26
+ verifyFindings: number;
27
+ specQualityScore: number;
28
+ // Extended metrics from tier 1 learnings
29
+ implLOC: number;
30
+ attemptsToPass: number;
31
+ bugsIntroduced: number;
32
+ toolsUsed: string[];
33
+ stepTimings?: Record<string, number>;
34
+ }
35
+
36
+ export interface BenchmarkReport {
37
+ story: StoryConfig;
38
+ maina: BenchmarkMetrics | null;
39
+ speckit: BenchmarkMetrics | null;
40
+ timestamp: string;
41
+ winner: "maina" | "speckit" | "tie" | "incomplete";
42
+ }
43
+
44
+ export interface LoadedStory {
45
+ config: StoryConfig;
46
+ specContent: string;
47
+ testFiles: Array<{ name: string; content: string }>;
48
+ storyDir: string;
49
+ }
50
+
51
+ export interface StepMetrics {
52
+ name: string;
53
+ durationMs: number;
54
+ tokensInput: number;
55
+ tokensOutput: number;
56
+ artifacts: string[];
57
+ // Optional per-step data
58
+ questionsAsked?: number;
59
+ testsGenerated?: number;
60
+ approachesProposed?: number;
61
+ loc?: number;
62
+ attempts?: number;
63
+ findings?: number;
64
+ findingsBySeverity?: Record<string, number>;
65
+ issuesFound?: number;
66
+ passed?: boolean;
67
+ }
68
+
69
+ export interface Tier3Totals {
70
+ durationMs: number;
71
+ tokensInput: number;
72
+ tokensOutput: number;
73
+ bugsIntroduced: number;
74
+ bugsCaught: number;
75
+ testsPassed: number;
76
+ testsTotal: number;
77
+ /** Validation-only metrics (hidden tests, not shown during implementation) */
78
+ validationPassed?: number;
79
+ validationTotal?: number;
80
+ }
81
+
82
+ export interface Tier3Results {
83
+ story: StoryConfig;
84
+ timestamp: string;
85
+ maina: {
86
+ steps: Record<string, StepMetrics>;
87
+ totals: Tier3Totals;
88
+ };
89
+ speckit: {
90
+ steps: Record<string, StepMetrics>;
91
+ totals: Tier3Totals;
92
+ };
93
+ winner: "maina" | "speckit" | "tie" | "incomplete";
94
+ learnings: string[];
95
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { buildCacheKey, hashContent, hashFile, hashFiles } from "../keys";
3
+
4
+ describe("hashContent", () => {
5
+ it("returns a consistent hex string", () => {
6
+ const result = hashContent("hello");
7
+ expect(typeof result).toBe("string");
8
+ expect(result).toMatch(/^[0-9a-f]{64}$/);
9
+ });
10
+
11
+ it("returns the same hash for the same input", () => {
12
+ expect(hashContent("hello")).toBe(hashContent("hello"));
13
+ });
14
+
15
+ it("returns different hashes for different inputs", () => {
16
+ expect(hashContent("hello")).not.toBe(hashContent("world"));
17
+ });
18
+ });
19
+
20
+ describe("hashFile", () => {
21
+ it("returns a hash for an existing file", async () => {
22
+ // Use this test file itself as the existing file
23
+ const result = await hashFile(import.meta.path);
24
+ expect(typeof result).toBe("string");
25
+ expect(result).toMatch(/^[0-9a-f]{64}$/);
26
+ expect(result.length).toBeGreaterThan(0);
27
+ });
28
+
29
+ it("returns empty string for a non-existent file", async () => {
30
+ const result = await hashFile("/this/path/does/not/exist/at/all.ts");
31
+ expect(result).toBe("");
32
+ });
33
+ });
34
+
35
+ describe("hashFiles", () => {
36
+ it("returns a string hash for a list of files", async () => {
37
+ const result = await hashFiles([import.meta.path]);
38
+ expect(typeof result).toBe("string");
39
+ expect(result).toMatch(/^[0-9a-f]{64}$/);
40
+ });
41
+
42
+ it("is order-independent (sorted internally)", async () => {
43
+ const thisFile = import.meta.path;
44
+ // Use two real files that exist in the repo
45
+ const otherFile = import.meta.path.replace("keys.test.ts", "ttl.test.ts");
46
+ const result1 = await hashFiles([thisFile, otherFile]);
47
+ const result2 = await hashFiles([otherFile, thisFile]);
48
+ expect(result1).toBe(result2);
49
+ });
50
+
51
+ it("returns empty string for empty array", async () => {
52
+ const result = await hashFiles([]);
53
+ expect(result).toBe("");
54
+ });
55
+ });
56
+
57
+ describe("buildCacheKey", () => {
58
+ it("returns a string", async () => {
59
+ const key = await buildCacheKey({ task: "review" });
60
+ expect(typeof key).toBe("string");
61
+ expect(key.length).toBeGreaterThan(0);
62
+ });
63
+
64
+ it("same input produces same key", async () => {
65
+ const input = { task: "review", model: "gpt-4o", extra: "abc" };
66
+ const key1 = await buildCacheKey(input);
67
+ const key2 = await buildCacheKey(input);
68
+ expect(key1).toBe(key2);
69
+ });
70
+
71
+ it("different task produces different key", async () => {
72
+ const key1 = await buildCacheKey({ task: "review" });
73
+ const key2 = await buildCacheKey({ task: "commit" });
74
+ expect(key1).not.toBe(key2);
75
+ });
76
+
77
+ it("different files produce different key", async () => {
78
+ const key1 = await buildCacheKey({
79
+ task: "review",
80
+ files: [import.meta.path],
81
+ });
82
+ const key2 = await buildCacheKey({
83
+ task: "review",
84
+ files: ["/nonexistent/path.ts"],
85
+ });
86
+ expect(key1).not.toBe(key2);
87
+ });
88
+
89
+ it("returns a hex string of 64 characters", async () => {
90
+ const key = await buildCacheKey({
91
+ task: "explain",
92
+ model: "claude-3",
93
+ promptHash: "abc123",
94
+ });
95
+ expect(key).toMatch(/^[0-9a-f]{64}$/);
96
+ });
97
+ });
@@ -0,0 +1,312 @@
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 { createCacheManager } from "../manager";
6
+
7
+ const TEST_DIR = join(tmpdir(), `maina-cache-test-${Date.now()}`);
8
+
9
+ beforeAll(() => {
10
+ mkdirSync(TEST_DIR, { recursive: true });
11
+ });
12
+
13
+ afterAll(() => {
14
+ rmSync(TEST_DIR, { recursive: true, force: true });
15
+ });
16
+
17
+ function makeDir(sub: string): string {
18
+ const d = join(TEST_DIR, sub);
19
+ mkdirSync(d, { recursive: true });
20
+ return d;
21
+ }
22
+
23
+ describe("set + get", () => {
24
+ test("set then get returns cached value", () => {
25
+ const cache = createCacheManager(makeDir("set-get"));
26
+ cache.set("k1", "hello");
27
+ const entry = cache.get("k1");
28
+ expect(entry).not.toBeNull();
29
+ expect(entry?.value).toBe("hello");
30
+ expect(entry?.key).toBe("k1");
31
+ });
32
+
33
+ test("get returns null for missing key", () => {
34
+ const cache = createCacheManager(makeDir("missing"));
35
+ const entry = cache.get("nonexistent");
36
+ expect(entry).toBeNull();
37
+ });
38
+
39
+ test("set stores optional fields", () => {
40
+ const cache = createCacheManager(makeDir("optional-fields"));
41
+ cache.set("k2", "world", {
42
+ ttl: 3600,
43
+ promptVersion: "v1",
44
+ contextHash: "abc",
45
+ model: "claude-3-5-sonnet",
46
+ });
47
+ const entry = cache.get("k2");
48
+ expect(entry).not.toBeNull();
49
+ expect(entry?.ttl).toBe(3600);
50
+ expect(entry?.promptVersion).toBe("v1");
51
+ expect(entry?.contextHash).toBe("abc");
52
+ expect(entry?.model).toBe("claude-3-5-sonnet");
53
+ });
54
+ });
55
+
56
+ describe("L1 vs L2 hit behaviour", () => {
57
+ test("L1 hit does not query L2 (entry stays in memory map)", () => {
58
+ const cache = createCacheManager(makeDir("l1-hit"));
59
+ cache.set("k1", "value");
60
+
61
+ // First get populates L1; subsequent gets should be L1 hits
62
+ cache.get("k1"); // primes L1
63
+
64
+ const stats1 = cache.stats();
65
+ cache.get("k1"); // L1 hit
66
+ const stats2 = cache.stats();
67
+
68
+ // l1Hits should have incremented
69
+ expect(stats2.l1Hits).toBeGreaterThan(stats1.l1Hits);
70
+ // l2Hits should not have changed
71
+ expect(stats2.l2Hits).toBe(stats1.l2Hits);
72
+ });
73
+
74
+ test("L2 hit promotes entry to L1", () => {
75
+ const mainaDir = makeDir("l2-promote");
76
+ // Populate L2 directly via a first manager instance
77
+ const cache1 = createCacheManager(mainaDir);
78
+ cache1.set("k1", "from-l2");
79
+
80
+ // Create a fresh manager (empty L1) pointing to the same DB
81
+ const cache2 = createCacheManager(mainaDir);
82
+
83
+ // get should find it in L2 and promote to L1
84
+ const entry = cache2.get("k1");
85
+ expect(entry).not.toBeNull();
86
+ expect(entry?.value).toBe("from-l2");
87
+
88
+ const stats = cache2.stats();
89
+ expect(stats.l2Hits).toBe(1);
90
+ expect(stats.l1Hits).toBe(0);
91
+
92
+ // Second get should now be an L1 hit
93
+ cache2.get("k1");
94
+ const stats2 = cache2.stats();
95
+ expect(stats2.l1Hits).toBe(1);
96
+ });
97
+ });
98
+
99
+ describe("TTL expiry", () => {
100
+ test("expired entry returns null", () => {
101
+ const _cache = createCacheManager(makeDir("ttl"));
102
+ const _past = Date.now() - 10_000; // 10 seconds ago
103
+
104
+ // Manually insert with a past createdAt via set then manipulate via get
105
+ // We test expiry by setting ttl=1 and forging createdAt in the future check.
106
+ // Instead we use a negative ttl trick: we'll insert with ttl=1s and
107
+ // backdate by injecting via the DB. Use a fresh manager whose L2 we write to.
108
+ const m = createCacheManager(makeDir("ttl2"));
109
+
110
+ // Use internal set to write an "already expired" entry
111
+ // We can't easily backdated via the public API, so we verify via the
112
+ // has() method after testing with a ttl=0 (never expires) and ttl that
113
+ // hasn't elapsed.
114
+
115
+ // ttl=0 means forever
116
+ m.set("forever", "val", { ttl: 0 });
117
+ expect(m.get("forever")).not.toBeNull();
118
+
119
+ // ttl=100s — should not be expired yet
120
+ m.set("soon", "val", { ttl: 100 });
121
+ expect(m.get("soon")).not.toBeNull();
122
+ });
123
+
124
+ test("entry with elapsed TTL is treated as expired", () => {
125
+ // We create a cache manager, write an entry, then manually poke the
126
+ // underlying SQLite to backdate it so TTL appears elapsed.
127
+ const mainaDir = makeDir("ttl-elapsed");
128
+ const cache = createCacheManager(mainaDir);
129
+ cache.set("oldkey", "oldval", { ttl: 1 }); // 1 second TTL
130
+
131
+ // The entry is fresh right now — still valid
132
+ expect(cache.get("oldkey")).not.toBeNull();
133
+
134
+ // Now create a second manager instance that shares the DB.
135
+ // We'll insert a raw row with a very old createdAt so it looks expired.
136
+ const { initDatabase } = require("../../db/index");
137
+ const { join: pjoin } = require("node:path");
138
+ const dbResult = initDatabase(pjoin(mainaDir, "cache", "cache.db"));
139
+ if (!dbResult.ok) throw new Error("db failed");
140
+ const db = dbResult.value.db;
141
+ const nowMs = Date.now() - 5_000; // 5 seconds ago
142
+ db.prepare(
143
+ `INSERT OR REPLACE INTO cache_entries (id, key, value, created_at, ttl)
144
+ VALUES (?, ?, ?, ?, ?)`,
145
+ ).run("expired-id", "expiredkey", "expiredval", String(nowMs), 1);
146
+ db.close();
147
+
148
+ // Fresh manager: L1 is empty, will look up L2
149
+ const cache2 = createCacheManager(mainaDir);
150
+ const result = cache2.get("expiredkey");
151
+ expect(result).toBeNull();
152
+ });
153
+ });
154
+
155
+ describe("L1 eviction at capacity", () => {
156
+ test("L1 evicts oldest entry when at capacity (100 entries)", () => {
157
+ const cache = createCacheManager(makeDir("eviction"));
158
+
159
+ // Insert 100 entries
160
+ for (let i = 0; i < 100; i++) {
161
+ cache.set(`key-${i}`, `value-${i}`);
162
+ }
163
+
164
+ // At this point L1 has 100 entries (full)
165
+ expect(cache.stats().entriesL1).toBe(100);
166
+
167
+ // Insert one more — should evict key-0 (oldest)
168
+ cache.set("key-new", "value-new");
169
+
170
+ expect(cache.stats().entriesL1).toBe(100);
171
+
172
+ // key-0 should no longer be in L1 but still in L2
173
+ // We verify by checking that stats show it was evicted from L1
174
+ // A direct L1-only check: create a spy scenario or just trust the
175
+ // stats count and that key-new is now present.
176
+ const newEntry = cache.get("key-new");
177
+ expect(newEntry).not.toBeNull();
178
+
179
+ // The total entriesL1 should stay bounded at 100
180
+ expect(cache.stats().entriesL1).toBe(100);
181
+ });
182
+ });
183
+
184
+ describe("invalidate", () => {
185
+ test("invalidate removes from both L1 and L2", () => {
186
+ const cache = createCacheManager(makeDir("invalidate"));
187
+ cache.set("del", "gone");
188
+ expect(cache.get("del")).not.toBeNull();
189
+
190
+ cache.invalidate("del");
191
+
192
+ // Create fresh manager to confirm L2 removal
193
+ const _cache2 = createCacheManager(makeDir("invalidate")); // same dir
194
+ // Actually use same mainaDir
195
+ const mainaDir = makeDir("invalidate2");
196
+ const c = createCacheManager(mainaDir);
197
+ c.set("del2", "gone2");
198
+ expect(c.get("del2")).not.toBeNull();
199
+ c.invalidate("del2");
200
+ expect(c.get("del2")).toBeNull();
201
+
202
+ // Verify L2 is also gone via a fresh manager
203
+ const c2 = createCacheManager(mainaDir);
204
+ expect(c2.get("del2")).toBeNull();
205
+ expect(c2.stats().l2Hits).toBe(0);
206
+ expect(c2.stats().misses).toBe(1);
207
+ });
208
+ });
209
+
210
+ describe("clear", () => {
211
+ test("clear removes all entries from both layers", () => {
212
+ const mainaDir = makeDir("clear");
213
+ const cache = createCacheManager(mainaDir);
214
+ cache.set("a", "1");
215
+ cache.set("b", "2");
216
+ cache.set("c", "3");
217
+ expect(cache.stats().entriesL1).toBe(3);
218
+
219
+ cache.clear();
220
+
221
+ expect(cache.get("a")).toBeNull();
222
+ expect(cache.get("b")).toBeNull();
223
+ expect(cache.get("c")).toBeNull();
224
+ expect(cache.stats().entriesL1).toBe(0);
225
+
226
+ // Verify L2 cleared via fresh manager
227
+ const cache2 = createCacheManager(mainaDir);
228
+ expect(cache2.get("a")).toBeNull();
229
+ expect(cache2.stats().misses).toBe(1);
230
+ });
231
+ });
232
+
233
+ describe("stats", () => {
234
+ test("stats tracks l1Hits, l2Hits, misses, totalQueries accurately", () => {
235
+ const mainaDir = makeDir("stats");
236
+
237
+ // Seed L2 via first manager
238
+ const seed = createCacheManager(mainaDir);
239
+ seed.set("x", "xval");
240
+
241
+ // Fresh manager: empty L1
242
+ const cache = createCacheManager(mainaDir);
243
+
244
+ // miss
245
+ cache.get("nonexistent");
246
+ let s = cache.stats();
247
+ expect(s.misses).toBe(1);
248
+ expect(s.totalQueries).toBe(1);
249
+ expect(s.l1Hits).toBe(0);
250
+ expect(s.l2Hits).toBe(0);
251
+
252
+ // L2 hit (promotes to L1)
253
+ cache.get("x");
254
+ s = cache.stats();
255
+ expect(s.l2Hits).toBe(1);
256
+ expect(s.l1Hits).toBe(0);
257
+ expect(s.misses).toBe(1);
258
+ expect(s.totalQueries).toBe(2);
259
+
260
+ // L1 hit
261
+ cache.get("x");
262
+ s = cache.stats();
263
+ expect(s.l1Hits).toBe(1);
264
+ expect(s.l2Hits).toBe(1);
265
+ expect(s.misses).toBe(1);
266
+ expect(s.totalQueries).toBe(3);
267
+ });
268
+
269
+ test("stats.entriesL2 reflects number of rows in SQLite", () => {
270
+ const mainaDir = makeDir("stats-l2");
271
+ const cache = createCacheManager(mainaDir);
272
+ cache.set("p", "1");
273
+ cache.set("q", "2");
274
+ const s = cache.stats();
275
+ expect(s.entriesL2).toBe(2);
276
+ expect(s.entriesL1).toBe(2);
277
+ });
278
+ });
279
+
280
+ describe("has", () => {
281
+ test("has returns true for existing non-expired key", () => {
282
+ const cache = createCacheManager(makeDir("has-true"));
283
+ cache.set("exists", "yes");
284
+ expect(cache.has("exists")).toBe(true);
285
+ });
286
+
287
+ test("has returns false for missing key", () => {
288
+ const cache = createCacheManager(makeDir("has-false"));
289
+ expect(cache.has("nope")).toBe(false);
290
+ });
291
+
292
+ test("has returns false for expired key", () => {
293
+ const mainaDir = makeDir("has-expired");
294
+ const _cache = createCacheManager(mainaDir);
295
+
296
+ // Insert an expired row directly into SQLite
297
+ const { initDatabase } = require("../../db/index");
298
+ const { join: pjoin } = require("node:path");
299
+ const dbResult = initDatabase(pjoin(mainaDir, "cache", "cache.db"));
300
+ if (!dbResult.ok) throw new Error("db failed");
301
+ const db = dbResult.value.db;
302
+ const oldMs = Date.now() - 10_000; // 10 seconds ago
303
+ db.prepare(
304
+ `INSERT OR REPLACE INTO cache_entries (id, key, value, created_at, ttl)
305
+ VALUES (?, ?, ?, ?, ?)`,
306
+ ).run("exp-id", "expkey", "expval", String(oldMs), 1);
307
+ db.close();
308
+
309
+ const cache2 = createCacheManager(mainaDir);
310
+ expect(cache2.has("expkey")).toBe(false);
311
+ });
312
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { getAllRules, getTtl, isExpired } from "../ttl";
3
+
4
+ describe("getTtl", () => {
5
+ it("review TTL is 0 (forever)", () => {
6
+ expect(getTtl("review")).toBe(0);
7
+ });
8
+
9
+ it("tests TTL is 0 (forever)", () => {
10
+ expect(getTtl("tests")).toBe(0);
11
+ });
12
+
13
+ it("fix TTL is 0 (forever)", () => {
14
+ expect(getTtl("fix")).toBe(0);
15
+ });
16
+
17
+ it("commit TTL is 0 (forever)", () => {
18
+ expect(getTtl("commit")).toBe(0);
19
+ });
20
+
21
+ it("context TTL is 3600 (1 hour)", () => {
22
+ expect(getTtl("context")).toBe(3600);
23
+ });
24
+
25
+ it("explain TTL is 86400 (24 hours)", () => {
26
+ expect(getTtl("explain")).toBe(86400);
27
+ });
28
+
29
+ it("design TTL is 86400 (24 hours)", () => {
30
+ expect(getTtl("design")).toBe(86400);
31
+ });
32
+
33
+ it("plan TTL is 86400 (24 hours)", () => {
34
+ expect(getTtl("plan")).toBe(86400);
35
+ });
36
+ });
37
+
38
+ describe("isExpired", () => {
39
+ it("returns false when TTL is 0 (never expires)", () => {
40
+ // Even if createdAt is very old, TTL=0 means forever
41
+ expect(isExpired(0, 0)).toBe(false);
42
+ });
43
+
44
+ it("returns false when within TTL", () => {
45
+ const now = Date.now();
46
+ const createdAt = now - 1000; // 1 second ago
47
+ expect(isExpired(createdAt, 3600)).toBe(false); // TTL is 1 hour
48
+ });
49
+
50
+ it("returns true when past TTL", () => {
51
+ const now = Date.now();
52
+ const createdAt = now - 7200 * 1000; // 2 hours ago
53
+ expect(isExpired(createdAt, 3600)).toBe(true); // TTL is 1 hour
54
+ });
55
+
56
+ it("returns false exactly at TTL boundary", () => {
57
+ const now = Date.now();
58
+ const createdAt = now - 3600 * 1000; // exactly 1 hour ago
59
+ // Not strictly greater than, so at boundary it's not expired
60
+ // (Date.now() - createdAt) === ttl * 1000, not >
61
+ // This is a boundary condition — implementation uses >, so false
62
+ expect(isExpired(createdAt + 1, 3600)).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe("getAllRules", () => {
67
+ it("returns all 8 task types", () => {
68
+ const rules = getAllRules();
69
+ expect(rules.length).toBe(8);
70
+ });
71
+
72
+ it("each rule has task, ttl, and description", () => {
73
+ const rules = getAllRules();
74
+ for (const rule of rules) {
75
+ expect(typeof rule.task).toBe("string");
76
+ expect(typeof rule.ttl).toBe("number");
77
+ expect(typeof rule.description).toBe("string");
78
+ expect(rule.description.length).toBeGreaterThan(0);
79
+ }
80
+ });
81
+
82
+ it("includes all expected task types", () => {
83
+ const rules = getAllRules();
84
+ const tasks = rules.map((r) => r.task);
85
+ expect(tasks).toContain("review");
86
+ expect(tasks).toContain("tests");
87
+ expect(tasks).toContain("fix");
88
+ expect(tasks).toContain("commit");
89
+ expect(tasks).toContain("context");
90
+ expect(tasks).toContain("explain");
91
+ expect(tasks).toContain("design");
92
+ expect(tasks).toContain("plan");
93
+ });
94
+ });
@@ -0,0 +1,44 @@
1
+ export interface CacheKeyInput {
2
+ task: string;
3
+ files?: string[];
4
+ promptHash?: string;
5
+ model?: string;
6
+ extra?: string;
7
+ }
8
+
9
+ export function hashContent(content: string): string {
10
+ const hasher = new Bun.CryptoHasher("sha256");
11
+ hasher.update(content);
12
+ return hasher.digest("hex");
13
+ }
14
+
15
+ export async function hashFile(path: string): Promise<string> {
16
+ try {
17
+ const file = Bun.file(path);
18
+ const exists = await file.exists();
19
+ if (!exists) {
20
+ return "";
21
+ }
22
+ const content = await file.text();
23
+ return hashContent(content);
24
+ } catch {
25
+ return "";
26
+ }
27
+ }
28
+
29
+ export async function hashFiles(paths: string[]): Promise<string> {
30
+ if (paths.length === 0) {
31
+ return "";
32
+ }
33
+ const sorted = [...paths].sort();
34
+ const hashes = await Promise.all(sorted.map((p) => hashFile(p)));
35
+ const combined = hashes.join("");
36
+ return hashContent(combined);
37
+ }
38
+
39
+ export async function buildCacheKey(input: CacheKeyInput): Promise<string> {
40
+ const { task, files, promptHash = "", model = "", extra = "" } = input;
41
+ const filesHash = files && files.length > 0 ? await hashFiles(files) : "";
42
+ const combined = `${task}:${promptHash}:${model}:${filesHash}:${extra}`;
43
+ return hashContent(combined);
44
+ }