@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,198 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export interface MainaConfig {
5
+ models: {
6
+ mechanical: string;
7
+ standard: string;
8
+ architectural: string;
9
+ local: string;
10
+ };
11
+ provider: string;
12
+ budget: {
13
+ daily: number;
14
+ perTask: number;
15
+ alertAt: number;
16
+ };
17
+ apiKey?: string;
18
+ }
19
+
20
+ const DEFAULT_CONFIG: MainaConfig = {
21
+ models: {
22
+ mechanical: "google/gemini-2.5-flash",
23
+ standard: "anthropic/claude-sonnet-4",
24
+ architectural: "anthropic/claude-sonnet-4",
25
+ local: "ollama/qwen3-coder-8b",
26
+ },
27
+ provider: "openrouter",
28
+ budget: {
29
+ daily: 5.0,
30
+ perTask: 0.5,
31
+ alertAt: 0.8,
32
+ },
33
+ };
34
+
35
+ /**
36
+ * Returns a deep copy of the default config so callers cannot mutate the
37
+ * internal defaults.
38
+ */
39
+ export function getDefaultConfig(): MainaConfig {
40
+ return {
41
+ ...DEFAULT_CONFIG,
42
+ models: { ...DEFAULT_CONFIG.models },
43
+ budget: { ...DEFAULT_CONFIG.budget },
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Walks up the directory tree starting at `startDir` (defaults to
49
+ * process.cwd()) looking for `maina.config.ts` then `maina.config.js`.
50
+ * Returns the absolute path of the first match, or null if none found.
51
+ */
52
+ export function findConfigFile(startDir?: string): string | null {
53
+ let dir = startDir ?? process.cwd();
54
+ const names = ["maina.config.ts", "maina.config.js"];
55
+
56
+ while (true) {
57
+ for (const name of names) {
58
+ const candidate = join(dir, name);
59
+ if (existsSync(candidate)) {
60
+ return candidate;
61
+ }
62
+ }
63
+
64
+ const parent = dirname(dir);
65
+ // Reached filesystem root — stop
66
+ if (parent === dir) {
67
+ return null;
68
+ }
69
+ dir = parent;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Finds and dynamically imports the maina config file, then deep-merges it
75
+ * with the defaults. Falls back to defaults silently on any error.
76
+ */
77
+ export async function loadConfig(startDir?: string): Promise<MainaConfig> {
78
+ const configPath = findConfigFile(startDir);
79
+
80
+ if (configPath === null) {
81
+ return getDefaultConfig();
82
+ }
83
+
84
+ try {
85
+ const mod = await import(configPath);
86
+ const userConfig: Partial<MainaConfig> = mod.default ?? mod;
87
+ return { ...DEFAULT_CONFIG, ...userConfig };
88
+ } catch {
89
+ return getDefaultConfig();
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Returns the API key from environment variables, preferring MAINA_API_KEY
95
+ * over OPENROUTER_API_KEY. When MAINA_HOST_MODE is set, also checks for
96
+ * ANTHROPIC_API_KEY (set by Claude Code and similar host agents).
97
+ * Returns null when no key is found.
98
+ */
99
+ export function getApiKey(): string | null {
100
+ return (
101
+ process.env.MAINA_API_KEY ??
102
+ process.env.OPENROUTER_API_KEY ??
103
+ process.env.ANTHROPIC_API_KEY ??
104
+ null
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Resolves the active provider, allowing the MAINA_PROVIDER environment
110
+ * variable to override whatever is in the config.
111
+ *
112
+ * When running in host mode (MAINA_HOST_MODE=true or ANTHROPIC_API_KEY is set
113
+ * without explicit provider), auto-detects the appropriate provider:
114
+ * - ANTHROPIC_API_KEY → "anthropic"
115
+ * - Otherwise → config default
116
+ */
117
+ export function resolveProvider(config: MainaConfig): string {
118
+ // Explicit override always wins
119
+ if (process.env.MAINA_PROVIDER) {
120
+ return process.env.MAINA_PROVIDER;
121
+ }
122
+
123
+ // Host mode auto-detection: if running inside Claude Code or similar,
124
+ // ANTHROPIC_API_KEY is available but no explicit Maina key
125
+ if (isHostMode()) {
126
+ if (
127
+ process.env.ANTHROPIC_API_KEY &&
128
+ !process.env.MAINA_API_KEY &&
129
+ !process.env.OPENROUTER_API_KEY
130
+ ) {
131
+ return "anthropic";
132
+ }
133
+ }
134
+
135
+ return config.provider;
136
+ }
137
+
138
+ /**
139
+ * Detect if Maina is running inside a host agent environment
140
+ * (e.g., Claude Code, Cursor, Codex).
141
+ *
142
+ * Checks for:
143
+ * - MAINA_HOST_MODE=true (explicit opt-in)
144
+ * - CLAUDECODE=1 (Claude Code sets this — note: no underscore)
145
+ * - CLAUDE_CODE_ENTRYPOINT (Claude Code sets this to "cli")
146
+ * - CURSOR=1 (Cursor sets this)
147
+ * - ANTHROPIC_API_KEY without MAINA_API_KEY
148
+ */
149
+ export function isHostMode(): boolean {
150
+ if (process.env.MAINA_HOST_MODE === "true") return true;
151
+ // Claude Code sets CLAUDECODE=1 (no underscore) and CLAUDE_CODE_ENTRYPOINT
152
+ if (process.env.CLAUDECODE === "1") return true;
153
+ if (process.env.CLAUDE_CODE_ENTRYPOINT) return true;
154
+ if (process.env.CURSOR === "1") return true;
155
+ // Infer host mode when we have an Anthropic key but no explicit Maina config
156
+ if (
157
+ process.env.ANTHROPIC_API_KEY &&
158
+ !process.env.MAINA_API_KEY &&
159
+ !process.env.OPENROUTER_API_KEY
160
+ ) {
161
+ return true;
162
+ }
163
+ return false;
164
+ }
165
+
166
+ /**
167
+ * When running inside a host agent (Claude Code, Cursor), AI calls should
168
+ * be delegated to the host rather than making direct API calls.
169
+ *
170
+ * Returns structured prompt data that the host agent can process.
171
+ * The MCP server or skills package uses this to pass context to the host.
172
+ *
173
+ * NOTE: Currently unused — retained for MCP/skills host delegation (Sprint 10+).
174
+ */
175
+ export interface HostDelegation {
176
+ mode: "host";
177
+ systemPrompt: string;
178
+ userPrompt: string;
179
+ task: string;
180
+ }
181
+
182
+ /**
183
+ * Check if AI should be delegated to host instead of direct API call.
184
+ *
185
+ * NOTE: In practice this currently returns true only when MAINA_HOST_MODE=true
186
+ * is set explicitly with no API keys. The common Claude Code scenario
187
+ * (CLAUDECODE=1 without API keys) triggers isHostMode() but also triggers
188
+ * this function's delegation. The generate() function handles this by
189
+ * returning a [HOST_DELEGATION] prompt string.
190
+ */
191
+ export function shouldDelegateToHost(): boolean {
192
+ if (!isHostMode()) return false;
193
+ // If user has their own API key, use it directly
194
+ if (process.env.MAINA_API_KEY || process.env.OPENROUTER_API_KEY) return false;
195
+ if (process.env.ANTHROPIC_API_KEY) return false;
196
+ // In host mode with no key — delegate to host agent
197
+ return true;
198
+ }
@@ -0,0 +1,179 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BudgetAllocation, LayerContent } from "../budget";
3
+ import {
4
+ assembleBudget,
5
+ calculateTokens,
6
+ getBudgetRatio,
7
+ truncateToFit,
8
+ } from "../budget";
9
+
10
+ describe("calculateTokens", () => {
11
+ test("returns approximately chars/3.5", () => {
12
+ const text = "hello world"; // 11 chars → ceil(11/3.5) = ceil(3.14) = 4
13
+ expect(calculateTokens(text)).toBe(Math.ceil(text.length / 3.5));
14
+ });
15
+
16
+ test("returns 0 for empty string", () => {
17
+ expect(calculateTokens("")).toBe(0);
18
+ });
19
+
20
+ test("always returns a whole number", () => {
21
+ const text = "abcde"; // 5 chars → ceil(5/3.5) = ceil(1.43) = 2
22
+ const result = calculateTokens(text);
23
+ expect(Number.isInteger(result)).toBe(true);
24
+ expect(result).toBe(2);
25
+ });
26
+ });
27
+
28
+ describe("getBudgetRatio", () => {
29
+ test("focused mode returns 0.4", () => {
30
+ expect(getBudgetRatio("focused")).toBe(0.4);
31
+ });
32
+
33
+ test("default mode returns 0.6", () => {
34
+ expect(getBudgetRatio("default")).toBe(0.6);
35
+ });
36
+
37
+ test("explore mode returns 0.8", () => {
38
+ expect(getBudgetRatio("explore")).toBe(0.8);
39
+ });
40
+ });
41
+
42
+ describe("assembleBudget", () => {
43
+ test("allocations sum correctly for default mode with default context window", () => {
44
+ const allocation = assembleBudget("default");
45
+ // working + episodic + semantic + retrieval + headroom === total
46
+ const layerSum =
47
+ allocation.working +
48
+ allocation.episodic +
49
+ allocation.semantic +
50
+ allocation.retrieval +
51
+ allocation.headroom;
52
+ expect(layerSum).toBe(allocation.total);
53
+ });
54
+
55
+ test("total equals model context window", () => {
56
+ const allocation = assembleBudget("default", 100_000);
57
+ expect(allocation.total).toBe(100_000);
58
+ });
59
+
60
+ test("headroom is correct for focused mode", () => {
61
+ const modelContext = 200_000;
62
+ const allocation = assembleBudget("focused", modelContext);
63
+ const ratio = 0.4;
64
+ const budget = Math.floor(modelContext * ratio);
65
+ const expectedHeadroom = modelContext - budget;
66
+ expect(allocation.headroom).toBe(expectedHeadroom);
67
+ });
68
+
69
+ test("layer allocations sum to budget (not total) for explore mode", () => {
70
+ const modelContext = 200_000;
71
+ const allocation = assembleBudget("explore", modelContext);
72
+ const ratio = 0.8;
73
+ const budget = Math.floor(modelContext * ratio);
74
+ const layerSum =
75
+ allocation.working +
76
+ allocation.episodic +
77
+ allocation.semantic +
78
+ allocation.retrieval;
79
+ expect(layerSum).toBe(budget);
80
+ });
81
+
82
+ test("working layer is always fully allocated", () => {
83
+ const modelContext = 200_000;
84
+ const allocationDefault = assembleBudget("default", modelContext);
85
+ const allocationFocused = assembleBudget("focused", modelContext);
86
+ // working = ~25% of budget; for focused: budget = 0.4 * 200_000 = 80_000
87
+ // working = floor(80_000 * 0.25) = 20_000
88
+ expect(allocationFocused.working).toBe(
89
+ Math.floor(Math.floor(modelContext * 0.4) * 0.25),
90
+ );
91
+ expect(allocationDefault.working).toBe(
92
+ Math.floor(Math.floor(modelContext * 0.6) * 0.25),
93
+ );
94
+ });
95
+ });
96
+
97
+ describe("truncateToFit", () => {
98
+ const makeLayers = (): LayerContent[] => [
99
+ { name: "working", text: "working context", tokens: 100, priority: 0 },
100
+ { name: "semantic", text: "semantic context", tokens: 200, priority: 1 },
101
+ { name: "episodic", text: "episodic context", tokens: 300, priority: 2 },
102
+ { name: "retrieval", text: "retrieval context", tokens: 400, priority: 3 },
103
+ ];
104
+
105
+ const makeBudget = (
106
+ overrides: Partial<BudgetAllocation> = {},
107
+ ): BudgetAllocation => ({
108
+ working: 100,
109
+ semantic: 200,
110
+ episodic: 300,
111
+ retrieval: 400,
112
+ headroom: 0,
113
+ total: 1000,
114
+ ...overrides,
115
+ });
116
+
117
+ test("returns all layers unchanged when within budget", () => {
118
+ const layers = makeLayers(); // total 1000 tokens
119
+ const budget = makeBudget(); // total 1000
120
+ const result = truncateToFit(layers, budget);
121
+ expect(result).toHaveLength(4);
122
+ });
123
+
124
+ test("removes retrieval first when over budget", () => {
125
+ const layers = makeLayers(); // total 1000 tokens
126
+ // Budget only allows 600 tokens total (working+semantic+episodic = 600)
127
+ const budget = makeBudget({ retrieval: 0, total: 600 });
128
+ const result = truncateToFit(layers, budget);
129
+ const names = result.map((l) => l.name);
130
+ expect(names).not.toContain("retrieval");
131
+ });
132
+
133
+ test("removes episodic after retrieval when still over budget", () => {
134
+ const layers = makeLayers(); // total 1000 tokens
135
+ // Budget only allows 300 tokens (working+semantic = 300)
136
+ const budget = makeBudget({ retrieval: 0, episodic: 0, total: 300 });
137
+ const result = truncateToFit(layers, budget);
138
+ const names = result.map((l) => l.name);
139
+ expect(names).not.toContain("retrieval");
140
+ expect(names).not.toContain("episodic");
141
+ });
142
+
143
+ test("never removes working context", () => {
144
+ const layers = makeLayers();
145
+ // Tiny budget — only enough for working
146
+ const budget = makeBudget({
147
+ semantic: 0,
148
+ episodic: 0,
149
+ retrieval: 0,
150
+ total: 100,
151
+ });
152
+ const result = truncateToFit(layers, budget);
153
+ const names = result.map((l) => l.name);
154
+ expect(names).toContain("working");
155
+ });
156
+
157
+ test("working context is always present even when over budget", () => {
158
+ const layers = makeLayers();
159
+ // Absurdly small budget
160
+ const budget = makeBudget({
161
+ working: 1,
162
+ semantic: 0,
163
+ episodic: 0,
164
+ retrieval: 0,
165
+ total: 1,
166
+ });
167
+ const result = truncateToFit(layers, budget);
168
+ expect(result.some((l) => l.name === "working")).toBe(true);
169
+ });
170
+
171
+ test("returns layers sorted by priority after truncation", () => {
172
+ const layers = makeLayers();
173
+ const budget = makeBudget({ retrieval: 0, total: 600 });
174
+ const result = truncateToFit(layers, budget);
175
+ const priorities = result.map((l) => l.priority);
176
+ const sorted = [...priorities].sort((a, b) => a - b);
177
+ expect(priorities).toEqual(sorted);
178
+ });
179
+ });
@@ -0,0 +1,163 @@
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 { assembleContext } from "../engine";
6
+
7
+ // Create a temporary .maina dir for tests
8
+ let tempMainaDir: string;
9
+ const repoRoot = process.cwd();
10
+
11
+ beforeAll(() => {
12
+ tempMainaDir = join(tmpdir(), `maina-engine-test-${Date.now()}`);
13
+ mkdirSync(join(tempMainaDir, "context"), { recursive: true });
14
+ });
15
+
16
+ afterAll(() => {
17
+ try {
18
+ rmSync(tempMainaDir, { recursive: true, force: true });
19
+ } catch {
20
+ // ignore cleanup errors
21
+ }
22
+ });
23
+
24
+ describe("assembleContext", () => {
25
+ test("assembleContext('commit') returns an AssembledContext object", async () => {
26
+ const result = await assembleContext("commit", {
27
+ repoRoot,
28
+ mainaDir: tempMainaDir,
29
+ });
30
+
31
+ expect(result).toBeDefined();
32
+ expect(typeof result.text).toBe("string");
33
+ expect(typeof result.tokens).toBe("number");
34
+ expect(Array.isArray(result.layers)).toBe(true);
35
+ expect(result.mode).toBeDefined();
36
+ expect(result.budget).toBeDefined();
37
+ });
38
+
39
+ test("assembleContext('commit') has budget mode 'focused'", async () => {
40
+ const result = await assembleContext("commit", {
41
+ repoRoot,
42
+ mainaDir: tempMainaDir,
43
+ });
44
+
45
+ expect(result.mode).toBe("focused");
46
+ });
47
+
48
+ test("assembleContext('commit') includes working layer", async () => {
49
+ const result = await assembleContext("commit", {
50
+ repoRoot,
51
+ mainaDir: tempMainaDir,
52
+ });
53
+
54
+ const workingLayer = result.layers.find((l) => l.name === "working");
55
+ expect(workingLayer).toBeDefined();
56
+ expect(workingLayer?.included).toBe(true);
57
+ });
58
+
59
+ test("assembleContext returns tokens count > 0", async () => {
60
+ const result = await assembleContext("commit", {
61
+ repoRoot,
62
+ mainaDir: tempMainaDir,
63
+ });
64
+
65
+ expect(result.tokens).toBeGreaterThan(0);
66
+ });
67
+
68
+ test("assembleContext returns layer reports", async () => {
69
+ const result = await assembleContext("commit", {
70
+ repoRoot,
71
+ mainaDir: tempMainaDir,
72
+ });
73
+
74
+ expect(result.layers.length).toBeGreaterThan(0);
75
+ for (const layer of result.layers) {
76
+ expect(typeof layer.name).toBe("string");
77
+ expect(typeof layer.tokens).toBe("number");
78
+ expect(typeof layer.entries).toBe("number");
79
+ expect(typeof layer.included).toBe("boolean");
80
+ }
81
+ });
82
+
83
+ test("assembled text is non-empty", async () => {
84
+ const result = await assembleContext("commit", {
85
+ repoRoot,
86
+ mainaDir: tempMainaDir,
87
+ });
88
+
89
+ expect(result.text.length).toBeGreaterThan(0);
90
+ });
91
+
92
+ test("working layer is always present in layer reports", async () => {
93
+ const result = await assembleContext("commit", {
94
+ repoRoot,
95
+ mainaDir: tempMainaDir,
96
+ });
97
+
98
+ const workingLayer = result.layers.find((l) => l.name === "working");
99
+ expect(workingLayer).toBeDefined();
100
+ });
101
+
102
+ test("assembleContext('context') includes more layers than 'commit'", async () => {
103
+ const [commitResult, contextResult] = await Promise.all([
104
+ assembleContext("commit", { repoRoot, mainaDir: tempMainaDir }),
105
+ assembleContext("context", { repoRoot, mainaDir: tempMainaDir }),
106
+ ]);
107
+
108
+ const commitIncluded = commitResult.layers.filter((l) => l.included).length;
109
+ const contextIncluded = contextResult.layers.filter(
110
+ (l) => l.included,
111
+ ).length;
112
+
113
+ expect(contextIncluded).toBeGreaterThanOrEqual(commitIncluded);
114
+ });
115
+
116
+ test("assembleContext('context') has budget mode 'explore'", async () => {
117
+ const result = await assembleContext("context", {
118
+ repoRoot,
119
+ mainaDir: tempMainaDir,
120
+ });
121
+
122
+ expect(result.mode).toBe("explore");
123
+ });
124
+
125
+ test("assembleContext is resilient — returns valid result even with bad mainaDir", async () => {
126
+ const result = await assembleContext("commit", {
127
+ repoRoot,
128
+ mainaDir: join(tmpdir(), "nonexistent-maina-dir-xyz"),
129
+ });
130
+
131
+ // Should not throw; should return a valid (possibly minimal) context
132
+ expect(result).toBeDefined();
133
+ expect(typeof result.text).toBe("string");
134
+ expect(typeof result.tokens).toBe("number");
135
+ });
136
+
137
+ test("assembleContext with searchQuery includes retrieval layer for 'context' command", async () => {
138
+ const result = await assembleContext("context", {
139
+ repoRoot,
140
+ mainaDir: tempMainaDir,
141
+ searchQuery: "assembleContext",
142
+ });
143
+
144
+ const retrievalLayer = result.layers.find((l) => l.name === "retrieval");
145
+ // retrieval layer should be present (included or not depending on results)
146
+ expect(retrievalLayer).toBeDefined();
147
+ });
148
+
149
+ test("budget allocation is populated with numeric values", async () => {
150
+ const result = await assembleContext("commit", {
151
+ repoRoot,
152
+ mainaDir: tempMainaDir,
153
+ });
154
+
155
+ expect(typeof result.budget.working).toBe("number");
156
+ expect(typeof result.budget.episodic).toBe("number");
157
+ expect(typeof result.budget.semantic).toBe("number");
158
+ expect(typeof result.budget.retrieval).toBe("number");
159
+ expect(typeof result.budget.total).toBe("number");
160
+ expect(typeof result.budget.headroom).toBe("number");
161
+ expect(result.budget.total).toBeGreaterThan(0);
162
+ });
163
+ });