@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,229 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
extractEntities,
|
|
4
|
+
extractExports,
|
|
5
|
+
extractImports,
|
|
6
|
+
parseFile,
|
|
7
|
+
} from "../treesitter";
|
|
8
|
+
|
|
9
|
+
describe("extractImports", () => {
|
|
10
|
+
test("parses named imports correctly", () => {
|
|
11
|
+
const content = `import { Foo, Bar, Baz } from "some-module";`;
|
|
12
|
+
const imports = extractImports(content);
|
|
13
|
+
expect(imports).toHaveLength(1);
|
|
14
|
+
expect(imports[0]?.source).toBe("some-module");
|
|
15
|
+
expect(imports[0]?.specifiers).toEqual(["Foo", "Bar", "Baz"]);
|
|
16
|
+
expect(imports[0]?.isDefault).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("parses default imports", () => {
|
|
20
|
+
const content = `import MyDefault from "./my-file";`;
|
|
21
|
+
const imports = extractImports(content);
|
|
22
|
+
expect(imports).toHaveLength(1);
|
|
23
|
+
expect(imports[0]?.source).toBe("./my-file");
|
|
24
|
+
expect(imports[0]?.specifiers).toEqual(["MyDefault"]);
|
|
25
|
+
expect(imports[0]?.isDefault).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("parses namespace imports (import * as X)", () => {
|
|
29
|
+
const content = `import * as Utils from "../utils";`;
|
|
30
|
+
const imports = extractImports(content);
|
|
31
|
+
expect(imports).toHaveLength(1);
|
|
32
|
+
expect(imports[0]?.source).toBe("../utils");
|
|
33
|
+
expect(imports[0]?.specifiers).toEqual(["Utils"]);
|
|
34
|
+
expect(imports[0]?.isDefault).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("parses type imports", () => {
|
|
38
|
+
const content = `import type { SomeType, AnotherType } from "types-module";`;
|
|
39
|
+
const imports = extractImports(content);
|
|
40
|
+
expect(imports).toHaveLength(1);
|
|
41
|
+
expect(imports[0]?.source).toBe("types-module");
|
|
42
|
+
expect(imports[0]?.specifiers).toEqual(["SomeType", "AnotherType"]);
|
|
43
|
+
expect(imports[0]?.isDefault).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("parses multiple imports", () => {
|
|
47
|
+
const content = [
|
|
48
|
+
`import { A } from "mod-a";`,
|
|
49
|
+
`import B from "mod-b";`,
|
|
50
|
+
`import type { C } from "mod-c";`,
|
|
51
|
+
].join("\n");
|
|
52
|
+
const imports = extractImports(content);
|
|
53
|
+
expect(imports).toHaveLength(3);
|
|
54
|
+
expect(imports[0]?.source).toBe("mod-a");
|
|
55
|
+
expect(imports[1]?.source).toBe("mod-b");
|
|
56
|
+
expect(imports[2]?.source).toBe("mod-c");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns empty array when no imports", () => {
|
|
60
|
+
const content = `const x = 1;`;
|
|
61
|
+
const imports = extractImports(content);
|
|
62
|
+
expect(imports).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("extractExports", () => {
|
|
67
|
+
test("finds exported functions", () => {
|
|
68
|
+
const content = [
|
|
69
|
+
`export function doSomething() {}`,
|
|
70
|
+
`export async function fetchData() {}`,
|
|
71
|
+
].join("\n");
|
|
72
|
+
const exports = extractExports(content);
|
|
73
|
+
const names = exports.map((e) => e.name);
|
|
74
|
+
expect(names).toContain("doSomething");
|
|
75
|
+
expect(names).toContain("fetchData");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("finds exported classes and interfaces", () => {
|
|
79
|
+
const content = [
|
|
80
|
+
`export class MyService {}`,
|
|
81
|
+
`export interface MyInterface {}`,
|
|
82
|
+
].join("\n");
|
|
83
|
+
const exports = extractExports(content);
|
|
84
|
+
const names = exports.map((e) => e.name);
|
|
85
|
+
expect(names).toContain("MyService");
|
|
86
|
+
expect(names).toContain("MyInterface");
|
|
87
|
+
|
|
88
|
+
const classExport = exports.find((e) => e.name === "MyService");
|
|
89
|
+
expect(classExport?.kind).toBe("class");
|
|
90
|
+
|
|
91
|
+
const interfaceExport = exports.find((e) => e.name === "MyInterface");
|
|
92
|
+
expect(interfaceExport?.kind).toBe("interface");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("finds exported type aliases and const", () => {
|
|
96
|
+
const content = [
|
|
97
|
+
`export type MyAlias = string | number;`,
|
|
98
|
+
`export const MY_CONST = 42;`,
|
|
99
|
+
].join("\n");
|
|
100
|
+
const exports = extractExports(content);
|
|
101
|
+
const names = exports.map((e) => e.name);
|
|
102
|
+
expect(names).toContain("MyAlias");
|
|
103
|
+
expect(names).toContain("MY_CONST");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("finds export default", () => {
|
|
107
|
+
const content = `export default class DefaultClass {}`;
|
|
108
|
+
const exports = extractExports(content);
|
|
109
|
+
const defaultExport = exports.find((e) => e.name === "default");
|
|
110
|
+
expect(defaultExport).toBeDefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("finds named re-exports: export { X, Y }", () => {
|
|
114
|
+
const content = `export { Foo, Bar } from "./somewhere";`;
|
|
115
|
+
const exports = extractExports(content);
|
|
116
|
+
const names = exports.map((e) => e.name);
|
|
117
|
+
expect(names).toContain("Foo");
|
|
118
|
+
expect(names).toContain("Bar");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("returns empty array when no exports", () => {
|
|
122
|
+
const content = `const x = 1;`;
|
|
123
|
+
const exports = extractExports(content);
|
|
124
|
+
expect(exports).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("extractEntities", () => {
|
|
129
|
+
test("finds function declarations with line numbers", () => {
|
|
130
|
+
const content = [
|
|
131
|
+
``,
|
|
132
|
+
`function greet(name: string): string {`,
|
|
133
|
+
` return "hello " + name;`,
|
|
134
|
+
`}`,
|
|
135
|
+
].join("\n");
|
|
136
|
+
const entities = extractEntities(content);
|
|
137
|
+
const fn = entities.find((e) => e.name === "greet");
|
|
138
|
+
expect(fn).toBeDefined();
|
|
139
|
+
expect(fn?.kind).toBe("function");
|
|
140
|
+
expect(fn?.startLine).toBe(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("finds class declarations", () => {
|
|
144
|
+
const content = [`class MyClass {`, ` constructor() {}`, `}`].join("\n");
|
|
145
|
+
const entities = extractEntities(content);
|
|
146
|
+
const cls = entities.find((e) => e.name === "MyClass");
|
|
147
|
+
expect(cls).toBeDefined();
|
|
148
|
+
expect(cls?.kind).toBe("class");
|
|
149
|
+
expect(cls?.startLine).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("finds interface declarations", () => {
|
|
153
|
+
const content = `interface Shape {\n area(): number;\n}`;
|
|
154
|
+
const entities = extractEntities(content);
|
|
155
|
+
const iface = entities.find((e) => e.name === "Shape");
|
|
156
|
+
expect(iface).toBeDefined();
|
|
157
|
+
expect(iface?.kind).toBe("interface");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("finds type alias declarations", () => {
|
|
161
|
+
const content = `type ID = string | number;`;
|
|
162
|
+
const entities = extractEntities(content);
|
|
163
|
+
const typeAlias = entities.find((e) => e.name === "ID");
|
|
164
|
+
expect(typeAlias).toBeDefined();
|
|
165
|
+
expect(typeAlias?.kind).toBe("type");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("finds top-level const declarations", () => {
|
|
169
|
+
const content = `const MAX_SIZE = 100;`;
|
|
170
|
+
const entities = extractEntities(content);
|
|
171
|
+
const variable = entities.find((e) => e.name === "MAX_SIZE");
|
|
172
|
+
expect(variable).toBeDefined();
|
|
173
|
+
expect(variable?.kind).toBe("variable");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("endLine is set (at least equal to startLine)", () => {
|
|
177
|
+
const content = `function hello() {}`;
|
|
178
|
+
const entities = extractEntities(content);
|
|
179
|
+
const fn = entities.find((e) => e.name === "hello");
|
|
180
|
+
expect(fn).toBeDefined();
|
|
181
|
+
expect(fn?.endLine ?? 0).toBeGreaterThanOrEqual(fn?.startLine ?? 0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("returns empty array when no entities", () => {
|
|
185
|
+
const content = `// just a comment`;
|
|
186
|
+
const entities = extractEntities(content);
|
|
187
|
+
expect(entities).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("parseFile", () => {
|
|
192
|
+
test("reads an actual TS file and returns imports/exports/entities", async () => {
|
|
193
|
+
const filePath = `${process.cwd()}/packages/core/src/git/index.ts`;
|
|
194
|
+
const result = await parseFile(filePath);
|
|
195
|
+
|
|
196
|
+
expect(result).toHaveProperty("imports");
|
|
197
|
+
expect(result).toHaveProperty("exports");
|
|
198
|
+
expect(result).toHaveProperty("entities");
|
|
199
|
+
|
|
200
|
+
expect(Array.isArray(result.imports)).toBe(true);
|
|
201
|
+
expect(Array.isArray(result.exports)).toBe(true);
|
|
202
|
+
expect(Array.isArray(result.entities)).toBe(true);
|
|
203
|
+
|
|
204
|
+
// git/index.ts exports several functions
|
|
205
|
+
const exportNames = result.exports.map((e) => e.name);
|
|
206
|
+
expect(exportNames).toContain("getCurrentBranch");
|
|
207
|
+
expect(exportNames).toContain("getRepoRoot");
|
|
208
|
+
expect(exportNames).toContain("getRecentCommits");
|
|
209
|
+
|
|
210
|
+
// git/index.ts has function/interface entities
|
|
211
|
+
const entityNames = result.entities.map((e) => e.name);
|
|
212
|
+
expect(entityNames).toContain("Commit");
|
|
213
|
+
expect(
|
|
214
|
+
entityNames.some((n) =>
|
|
215
|
+
["exec", "getCurrentBranch", "getRepoRoot"].includes(n),
|
|
216
|
+
),
|
|
217
|
+
).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("returns empty arrays for a file with no imports/exports/entities", async () => {
|
|
221
|
+
// Write a minimal temp file and parse it
|
|
222
|
+
const tmpPath = "/tmp/maina-test-empty.ts";
|
|
223
|
+
await Bun.write(tmpPath, "// empty file\n");
|
|
224
|
+
const result = await parseFile(tmpPath);
|
|
225
|
+
expect(result.imports).toHaveLength(0);
|
|
226
|
+
expect(result.exports).toHaveLength(0);
|
|
227
|
+
expect(result.entities).toHaveLength(0);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
it,
|
|
8
|
+
mock,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
// We mock getCurrentBranch before importing the module under test.
|
|
15
|
+
// Use the absolute path so it matches how working.ts resolves its import.
|
|
16
|
+
const GIT_MODULE = resolve(import.meta.dir, "../../git/index");
|
|
17
|
+
|
|
18
|
+
// Import real module first so we can spread its exports
|
|
19
|
+
const realGit = await import("../../git/index");
|
|
20
|
+
|
|
21
|
+
const mockGetCurrentBranch = mock(async (_cwd?: string) => "main");
|
|
22
|
+
|
|
23
|
+
mock.module(GIT_MODULE, () => ({
|
|
24
|
+
...realGit,
|
|
25
|
+
getCurrentBranch: mockGetCurrentBranch,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
afterAll(() => {
|
|
29
|
+
mock.restore();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Import after mocking
|
|
33
|
+
const {
|
|
34
|
+
loadWorkingContext,
|
|
35
|
+
saveWorkingContext,
|
|
36
|
+
trackFile,
|
|
37
|
+
setVerificationResult,
|
|
38
|
+
resetWorkingContext,
|
|
39
|
+
assembleWorkingText,
|
|
40
|
+
} = await import("../working");
|
|
41
|
+
|
|
42
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function makeTempDir(): string {
|
|
45
|
+
const dir = join(
|
|
46
|
+
tmpdir(),
|
|
47
|
+
`maina-working-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
48
|
+
);
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── tests ─────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe("loadWorkingContext", () => {
|
|
56
|
+
let mainaDir: string;
|
|
57
|
+
let repoRoot: string;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
mainaDir = makeTempDir();
|
|
61
|
+
repoRoot = makeTempDir();
|
|
62
|
+
mockGetCurrentBranch.mockImplementation(async () => "main");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
rmSync(mainaDir, { recursive: true, force: true });
|
|
67
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns empty context when no file exists", async () => {
|
|
71
|
+
const ctx = await loadWorkingContext(mainaDir, repoRoot);
|
|
72
|
+
expect(ctx.branch).toBe("main");
|
|
73
|
+
expect(ctx.planContent).toBeNull();
|
|
74
|
+
expect(ctx.touchedFiles).toEqual([]);
|
|
75
|
+
expect(ctx.lastVerification).toBeNull();
|
|
76
|
+
expect(typeof ctx.updatedAt).toBe("string");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("round-trips save and load", async () => {
|
|
80
|
+
const ctx = await loadWorkingContext(mainaDir, repoRoot);
|
|
81
|
+
ctx.touchedFiles = ["src/foo.ts", "src/bar.ts"];
|
|
82
|
+
ctx.branch = "main";
|
|
83
|
+
await saveWorkingContext(mainaDir, ctx);
|
|
84
|
+
|
|
85
|
+
const loaded = await loadWorkingContext(mainaDir, repoRoot);
|
|
86
|
+
expect(loaded.touchedFiles).toEqual(["src/foo.ts", "src/bar.ts"]);
|
|
87
|
+
expect(loaded.branch).toBe("main");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("resets context when branch has changed since last save", async () => {
|
|
91
|
+
// Save a context recorded on branch "feature"
|
|
92
|
+
const ctx = resetWorkingContext(mainaDir);
|
|
93
|
+
ctx.branch = "feature";
|
|
94
|
+
ctx.touchedFiles = ["old-file.ts"];
|
|
95
|
+
await saveWorkingContext(mainaDir, ctx);
|
|
96
|
+
|
|
97
|
+
// Now git says we are on "main"
|
|
98
|
+
mockGetCurrentBranch.mockImplementation(async () => "main");
|
|
99
|
+
|
|
100
|
+
const loaded = await loadWorkingContext(mainaDir, repoRoot);
|
|
101
|
+
expect(loaded.branch).toBe("main");
|
|
102
|
+
expect(loaded.touchedFiles).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("loads PLAN.md content from repoRoot if it exists", async () => {
|
|
106
|
+
await Bun.write(join(repoRoot, "PLAN.md"), "# My Plan\nDo the thing.");
|
|
107
|
+
const ctx = await loadWorkingContext(mainaDir, repoRoot);
|
|
108
|
+
expect(ctx.planContent).toBe("# My Plan\nDo the thing.");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("keeps planContent null when PLAN.md is absent", async () => {
|
|
112
|
+
const ctx = await loadWorkingContext(mainaDir, repoRoot);
|
|
113
|
+
expect(ctx.planContent).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("trackFile", () => {
|
|
118
|
+
let mainaDir: string;
|
|
119
|
+
let repoRoot: string;
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
mainaDir = makeTempDir();
|
|
123
|
+
repoRoot = makeTempDir();
|
|
124
|
+
mockGetCurrentBranch.mockImplementation(async () => "main");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
rmSync(mainaDir, { recursive: true, force: true });
|
|
129
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("adds a file to touchedFiles and persists", async () => {
|
|
133
|
+
const ctx = await trackFile(mainaDir, repoRoot, "src/hello.ts");
|
|
134
|
+
expect(ctx.touchedFiles).toContain("src/hello.ts");
|
|
135
|
+
|
|
136
|
+
const reloaded = await loadWorkingContext(mainaDir, repoRoot);
|
|
137
|
+
expect(reloaded.touchedFiles).toContain("src/hello.ts");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does not duplicate files already tracked", async () => {
|
|
141
|
+
await trackFile(mainaDir, repoRoot, "src/hello.ts");
|
|
142
|
+
const ctx = await trackFile(mainaDir, repoRoot, "src/hello.ts");
|
|
143
|
+
const occurrences = ctx.touchedFiles.filter((f) => f === "src/hello.ts");
|
|
144
|
+
expect(occurrences.length).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("setVerificationResult", () => {
|
|
149
|
+
let mainaDir: string;
|
|
150
|
+
let repoRoot: string;
|
|
151
|
+
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
mainaDir = makeTempDir();
|
|
154
|
+
repoRoot = makeTempDir();
|
|
155
|
+
mockGetCurrentBranch.mockImplementation(async () => "main");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
afterEach(() => {
|
|
159
|
+
rmSync(mainaDir, { recursive: true, force: true });
|
|
160
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("stores the last verification result and persists it", async () => {
|
|
164
|
+
const result = {
|
|
165
|
+
passed: true,
|
|
166
|
+
checks: [{ name: "lint", passed: true, output: "ok" }],
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const ctx = await setVerificationResult(mainaDir, repoRoot, result);
|
|
171
|
+
expect(ctx.lastVerification).toEqual(result);
|
|
172
|
+
|
|
173
|
+
const reloaded = await loadWorkingContext(mainaDir, repoRoot);
|
|
174
|
+
expect(reloaded.lastVerification).toEqual(result);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("assembleWorkingText", () => {
|
|
179
|
+
it("includes branch name and touched files count", () => {
|
|
180
|
+
const ctx = {
|
|
181
|
+
branch: "feature/foo",
|
|
182
|
+
planContent: null,
|
|
183
|
+
workflowContext: null,
|
|
184
|
+
touchedFiles: ["a.ts", "b.ts"],
|
|
185
|
+
lastVerification: null,
|
|
186
|
+
updatedAt: new Date().toISOString(),
|
|
187
|
+
};
|
|
188
|
+
const text = assembleWorkingText(ctx);
|
|
189
|
+
expect(text).toContain("feature/foo");
|
|
190
|
+
expect(text).toContain("2");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("includes individual touched file paths", () => {
|
|
194
|
+
const ctx = {
|
|
195
|
+
branch: "main",
|
|
196
|
+
planContent: null,
|
|
197
|
+
workflowContext: null,
|
|
198
|
+
touchedFiles: ["src/foo.ts", "src/bar.ts"],
|
|
199
|
+
lastVerification: null,
|
|
200
|
+
updatedAt: new Date().toISOString(),
|
|
201
|
+
};
|
|
202
|
+
const text = assembleWorkingText(ctx);
|
|
203
|
+
expect(text).toContain("src/foo.ts");
|
|
204
|
+
expect(text).toContain("src/bar.ts");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("includes PLAN.md content when present", () => {
|
|
208
|
+
const ctx = {
|
|
209
|
+
branch: "main",
|
|
210
|
+
planContent: "# Plan\nDo stuff.",
|
|
211
|
+
workflowContext: null,
|
|
212
|
+
touchedFiles: [],
|
|
213
|
+
lastVerification: null,
|
|
214
|
+
updatedAt: new Date().toISOString(),
|
|
215
|
+
};
|
|
216
|
+
const text = assembleWorkingText(ctx);
|
|
217
|
+
expect(text).toContain("# Plan\nDo stuff.");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("includes verification status when present", () => {
|
|
221
|
+
const ctx = {
|
|
222
|
+
branch: "main",
|
|
223
|
+
planContent: null,
|
|
224
|
+
workflowContext: null,
|
|
225
|
+
touchedFiles: [],
|
|
226
|
+
lastVerification: {
|
|
227
|
+
passed: false,
|
|
228
|
+
checks: [{ name: "typecheck", passed: false, output: "3 errors" }],
|
|
229
|
+
timestamp: new Date().toISOString(),
|
|
230
|
+
},
|
|
231
|
+
updatedAt: new Date().toISOString(),
|
|
232
|
+
};
|
|
233
|
+
const text = assembleWorkingText(ctx);
|
|
234
|
+
expect(text.toLowerCase()).toContain("fail");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export type BudgetMode = "focused" | "default" | "explore";
|
|
2
|
+
|
|
3
|
+
export interface BudgetAllocation {
|
|
4
|
+
working: number;
|
|
5
|
+
episodic: number;
|
|
6
|
+
semantic: number;
|
|
7
|
+
retrieval: number;
|
|
8
|
+
total: number;
|
|
9
|
+
headroom: number; // reserved for AI reasoning
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LayerContent {
|
|
13
|
+
name: string;
|
|
14
|
+
text: string;
|
|
15
|
+
tokens: number;
|
|
16
|
+
priority: number; // lower = higher priority (working=0, semantic=1, episodic=2, retrieval=3)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MODEL_CONTEXT_WINDOW = 200_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Approximate token count: ~1 token per 3.5 characters.
|
|
23
|
+
*/
|
|
24
|
+
export function calculateTokens(text: string): number {
|
|
25
|
+
if (text.length === 0) return 0;
|
|
26
|
+
return Math.ceil(text.length / 3.5);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the fraction of the model context window available for layer content.
|
|
31
|
+
* The remainder is reserved as headroom for AI reasoning.
|
|
32
|
+
*/
|
|
33
|
+
export function getBudgetRatio(mode: BudgetMode): number {
|
|
34
|
+
switch (mode) {
|
|
35
|
+
case "focused":
|
|
36
|
+
return 0.4;
|
|
37
|
+
case "default":
|
|
38
|
+
return 0.6;
|
|
39
|
+
case "explore":
|
|
40
|
+
return 0.8;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Calculates per-layer token allocations for a given mode and model context window.
|
|
46
|
+
*
|
|
47
|
+
* Layer proportions within the usable budget:
|
|
48
|
+
* working: ~25%
|
|
49
|
+
* semantic: ~33%
|
|
50
|
+
* episodic: ~25%
|
|
51
|
+
* retrieval: ~17% (remainder after the above three)
|
|
52
|
+
*/
|
|
53
|
+
export function assembleBudget(
|
|
54
|
+
mode: BudgetMode,
|
|
55
|
+
modelContextWindow: number = DEFAULT_MODEL_CONTEXT_WINDOW,
|
|
56
|
+
): BudgetAllocation {
|
|
57
|
+
const ratio = getBudgetRatio(mode);
|
|
58
|
+
const budget = Math.floor(modelContextWindow * ratio);
|
|
59
|
+
const headroom = modelContextWindow - budget;
|
|
60
|
+
|
|
61
|
+
const working = Math.floor(budget * 0.25);
|
|
62
|
+
const semantic = Math.floor(budget * 0.33);
|
|
63
|
+
const episodic = Math.floor(budget * 0.25);
|
|
64
|
+
// retrieval gets the exact remainder so all layer tokens sum to budget
|
|
65
|
+
const retrieval = budget - working - semantic - episodic;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
working,
|
|
69
|
+
semantic,
|
|
70
|
+
episodic,
|
|
71
|
+
retrieval,
|
|
72
|
+
headroom,
|
|
73
|
+
total: modelContextWindow,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Trims layers so the total token count fits within the budget.
|
|
79
|
+
* Drops lowest-priority layers first (retrieval → episodic → semantic).
|
|
80
|
+
* The working layer (priority 0) is never removed.
|
|
81
|
+
*
|
|
82
|
+
* Returns the surviving layers in ascending priority order.
|
|
83
|
+
*/
|
|
84
|
+
export function truncateToFit(
|
|
85
|
+
layers: LayerContent[],
|
|
86
|
+
budget: BudgetAllocation,
|
|
87
|
+
): LayerContent[] {
|
|
88
|
+
// Sort by priority ascending so highest-priority layers come first
|
|
89
|
+
const sorted = [...layers].sort((a, b) => a.priority - b.priority);
|
|
90
|
+
|
|
91
|
+
const totalTokens = sorted.reduce((sum, l) => sum + l.tokens, 0);
|
|
92
|
+
if (totalTokens <= budget.total) {
|
|
93
|
+
return sorted;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Drop layers from lowest priority (highest numeric value) until we fit,
|
|
97
|
+
// but never drop priority-0 (working) layers.
|
|
98
|
+
const mutable = [...sorted];
|
|
99
|
+
let current = totalTokens;
|
|
100
|
+
|
|
101
|
+
while (current > budget.total) {
|
|
102
|
+
// Find the lowest-priority non-working layer
|
|
103
|
+
let dropIndex = -1;
|
|
104
|
+
let maxPriority = -1;
|
|
105
|
+
for (let i = 0; i < mutable.length; i++) {
|
|
106
|
+
const layer = mutable[i];
|
|
107
|
+
if (
|
|
108
|
+
layer !== undefined &&
|
|
109
|
+
layer.priority > 0 &&
|
|
110
|
+
layer.priority > maxPriority
|
|
111
|
+
) {
|
|
112
|
+
maxPriority = layer.priority;
|
|
113
|
+
dropIndex = i;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (dropIndex === -1) {
|
|
118
|
+
// Only working layers remain — stop
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const dropped = mutable[dropIndex];
|
|
123
|
+
if (dropped !== undefined) {
|
|
124
|
+
current -= dropped.tokens;
|
|
125
|
+
}
|
|
126
|
+
mutable.splice(dropIndex, 1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return mutable;
|
|
130
|
+
}
|