@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,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
|
+
}
|