@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,173 @@
|
|
|
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 { getContextDb } from "../../db/index.ts";
|
|
6
|
+
import { generateDependencyDiagram, generateModuleSummary } from "../index.ts";
|
|
7
|
+
|
|
8
|
+
const TEST_DIR = join(tmpdir(), `maina-explain-test-${Date.now()}`);
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterAll(() => {
|
|
15
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function makeDir(sub: string): string {
|
|
19
|
+
const d = join(TEST_DIR, sub);
|
|
20
|
+
mkdirSync(d, { recursive: true });
|
|
21
|
+
return d;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── generateDependencyDiagram ───────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe("generateDependencyDiagram", () => {
|
|
27
|
+
test("no edges returns empty graph", () => {
|
|
28
|
+
const mainaDir = makeDir("diagram-empty");
|
|
29
|
+
const result = generateDependencyDiagram(mainaDir);
|
|
30
|
+
expect(result.ok).toBe(true);
|
|
31
|
+
if (!result.ok) return;
|
|
32
|
+
expect(result.value).toBe("graph LR\n");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("with edges returns valid Mermaid syntax", () => {
|
|
36
|
+
const mainaDir = makeDir("diagram-edges");
|
|
37
|
+
|
|
38
|
+
const dbResult = getContextDb(mainaDir);
|
|
39
|
+
expect(dbResult.ok).toBe(true);
|
|
40
|
+
if (!dbResult.ok) return;
|
|
41
|
+
const { db } = dbResult.value;
|
|
42
|
+
|
|
43
|
+
db.exec(`
|
|
44
|
+
INSERT INTO dependency_edges (id, source_file, target_file, weight, type)
|
|
45
|
+
VALUES
|
|
46
|
+
('e1', 'src/context/engine.ts', 'src/context/budget.ts', 1.0, 'import'),
|
|
47
|
+
('e2', 'src/context/engine.ts', 'src/context/selector.ts', 1.0, 'import'),
|
|
48
|
+
('e3', 'src/verify/pipeline.ts', 'src/verify/syntax-guard.ts', 1.0, 'import')
|
|
49
|
+
`);
|
|
50
|
+
|
|
51
|
+
const result = generateDependencyDiagram(mainaDir);
|
|
52
|
+
expect(result.ok).toBe(true);
|
|
53
|
+
if (!result.ok) return;
|
|
54
|
+
|
|
55
|
+
expect(result.value).toContain("graph LR");
|
|
56
|
+
expect(result.value).toContain("context/engine --> context/budget");
|
|
57
|
+
expect(result.value).toContain("context/engine --> context/selector");
|
|
58
|
+
expect(result.value).toContain("verify/pipeline --> verify/syntax-guard");
|
|
59
|
+
db.close();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("with scope filter only returns matching edges", () => {
|
|
63
|
+
const mainaDir = makeDir("diagram-scope");
|
|
64
|
+
|
|
65
|
+
const dbResult = getContextDb(mainaDir);
|
|
66
|
+
expect(dbResult.ok).toBe(true);
|
|
67
|
+
if (!dbResult.ok) return;
|
|
68
|
+
const { db } = dbResult.value;
|
|
69
|
+
|
|
70
|
+
db.exec(`
|
|
71
|
+
INSERT INTO dependency_edges (id, source_file, target_file, weight, type)
|
|
72
|
+
VALUES
|
|
73
|
+
('e1', 'src/context/engine.ts', 'src/context/budget.ts', 1.0, 'import'),
|
|
74
|
+
('e2', 'src/verify/pipeline.ts', 'src/verify/syntax-guard.ts', 1.0, 'import'),
|
|
75
|
+
('e3', 'src/context/engine.ts', 'src/verify/detect.ts', 0.5, 'reference')
|
|
76
|
+
`);
|
|
77
|
+
|
|
78
|
+
const result = generateDependencyDiagram(mainaDir, {
|
|
79
|
+
scope: "context",
|
|
80
|
+
});
|
|
81
|
+
expect(result.ok).toBe(true);
|
|
82
|
+
if (!result.ok) return;
|
|
83
|
+
|
|
84
|
+
expect(result.value).toContain("context/engine --> context/budget");
|
|
85
|
+
// Edge e3 involves context, so it should be included
|
|
86
|
+
expect(result.value).toContain("context/engine --> verify/detect");
|
|
87
|
+
// Edge e2 is verify-only, should not be included
|
|
88
|
+
expect(result.value).not.toContain(
|
|
89
|
+
"verify/pipeline --> verify/syntax-guard",
|
|
90
|
+
);
|
|
91
|
+
db.close();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("DB error returns error Result", () => {
|
|
95
|
+
// Use a path that can't be a DB (e.g. inside a non-existent deeply nested path with null bytes)
|
|
96
|
+
const result = generateDependencyDiagram("/dev/null/nonexistent");
|
|
97
|
+
expect(result.ok).toBe(false);
|
|
98
|
+
if (result.ok) return;
|
|
99
|
+
expect(typeof result.error).toBe("string");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── generateModuleSummary ───────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe("generateModuleSummary", () => {
|
|
106
|
+
test("no entities returns empty array", () => {
|
|
107
|
+
const mainaDir = makeDir("summary-empty");
|
|
108
|
+
const result = generateModuleSummary(mainaDir);
|
|
109
|
+
expect(result.ok).toBe(true);
|
|
110
|
+
if (!result.ok) return;
|
|
111
|
+
expect(result.value).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("with entities returns correct counts per module", () => {
|
|
115
|
+
const mainaDir = makeDir("summary-entities");
|
|
116
|
+
|
|
117
|
+
const dbResult = getContextDb(mainaDir);
|
|
118
|
+
expect(dbResult.ok).toBe(true);
|
|
119
|
+
if (!dbResult.ok) return;
|
|
120
|
+
const { db } = dbResult.value;
|
|
121
|
+
|
|
122
|
+
db.exec(`
|
|
123
|
+
INSERT INTO semantic_entities (id, file_path, name, kind, start_line, end_line, updated_at)
|
|
124
|
+
VALUES
|
|
125
|
+
('s1', 'src/context/engine.ts', 'assembleContext', 'function', 1, 50, '2026-01-01'),
|
|
126
|
+
('s2', 'src/context/engine.ts', 'ContextOptions', 'interface', 51, 60, '2026-01-01'),
|
|
127
|
+
('s3', 'src/context/budget.ts', 'calculateTokens', 'function', 1, 30, '2026-01-01'),
|
|
128
|
+
('s4', 'src/verify/pipeline.ts', 'runPipeline', 'function', 1, 100, '2026-01-01'),
|
|
129
|
+
('s5', 'src/verify/pipeline.ts', 'PipelineResult', 'type', 101, 110, '2026-01-01'),
|
|
130
|
+
('s6', 'src/verify/pipeline.ts', 'PipelineRunner', 'class', 111, 200, '2026-01-01')
|
|
131
|
+
`);
|
|
132
|
+
|
|
133
|
+
const result = generateModuleSummary(mainaDir);
|
|
134
|
+
expect(result.ok).toBe(true);
|
|
135
|
+
if (!result.ok) return;
|
|
136
|
+
|
|
137
|
+
const summaries = result.value;
|
|
138
|
+
|
|
139
|
+
// Should have entries for context/engine, context/budget, verify/pipeline
|
|
140
|
+
expect(summaries.length).toBe(3);
|
|
141
|
+
|
|
142
|
+
const contextEngine = summaries.find((s) => s.module === "context/engine");
|
|
143
|
+
expect(contextEngine).toBeDefined();
|
|
144
|
+
expect(contextEngine?.entityCount).toBe(2);
|
|
145
|
+
expect(contextEngine?.functions).toBe(1);
|
|
146
|
+
expect(contextEngine?.interfaces).toBe(1);
|
|
147
|
+
expect(contextEngine?.classes).toBe(0);
|
|
148
|
+
expect(contextEngine?.types).toBe(0);
|
|
149
|
+
|
|
150
|
+
const verifyPipeline = summaries.find(
|
|
151
|
+
(s) => s.module === "verify/pipeline",
|
|
152
|
+
);
|
|
153
|
+
expect(verifyPipeline).toBeDefined();
|
|
154
|
+
expect(verifyPipeline?.entityCount).toBe(3);
|
|
155
|
+
expect(verifyPipeline?.functions).toBe(1);
|
|
156
|
+
expect(verifyPipeline?.classes).toBe(1);
|
|
157
|
+
expect(verifyPipeline?.types).toBe(1);
|
|
158
|
+
|
|
159
|
+
const contextBudget = summaries.find((s) => s.module === "context/budget");
|
|
160
|
+
expect(contextBudget).toBeDefined();
|
|
161
|
+
expect(contextBudget?.entityCount).toBe(1);
|
|
162
|
+
expect(contextBudget?.functions).toBe(1);
|
|
163
|
+
|
|
164
|
+
db.close();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("DB error returns error Result", () => {
|
|
168
|
+
const result = generateModuleSummary("/dev/null/nonexistent");
|
|
169
|
+
expect(result.ok).toBe(false);
|
|
170
|
+
if (result.ok) return;
|
|
171
|
+
expect(typeof result.error).toBe("string");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explain Module — Generate Mermaid dependency diagrams and module summaries.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Context Engine semantic layer (dependency_edges and semantic_entities
|
|
5
|
+
* tables) to visualize codebase structure.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Result } from "../db/index.ts";
|
|
9
|
+
import { getContextDb } from "../db/index.ts";
|
|
10
|
+
|
|
11
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface ModuleSummary {
|
|
14
|
+
module: string;
|
|
15
|
+
entityCount: number;
|
|
16
|
+
functions: number;
|
|
17
|
+
classes: number;
|
|
18
|
+
interfaces: number;
|
|
19
|
+
types: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DiagramOptions {
|
|
23
|
+
scope?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract a short module label from a file path.
|
|
30
|
+
* e.g. "src/context/engine.ts" -> "context/engine"
|
|
31
|
+
* "context/budget.ts" -> "context/budget"
|
|
32
|
+
*/
|
|
33
|
+
function toModuleLabel(filePath: string): string {
|
|
34
|
+
// Strip leading src/ if present
|
|
35
|
+
const stripped = filePath.replace(/^src\//, "");
|
|
36
|
+
// Remove file extension
|
|
37
|
+
return stripped.replace(/\.[^/.]+$/, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── generateDependencyDiagram ────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read the dependency graph from the Context Engine DB (dependency_edges table)
|
|
44
|
+
* and generate a Mermaid flowchart diagram string.
|
|
45
|
+
*
|
|
46
|
+
* If `scope` is provided, filters to only edges where source or target
|
|
47
|
+
* involves that directory.
|
|
48
|
+
*
|
|
49
|
+
* Returns a Mermaid markdown string on success, or an error on failure.
|
|
50
|
+
* Never throws.
|
|
51
|
+
*/
|
|
52
|
+
export function generateDependencyDiagram(
|
|
53
|
+
mainaDir: string,
|
|
54
|
+
options?: DiagramOptions,
|
|
55
|
+
): Result<string> {
|
|
56
|
+
try {
|
|
57
|
+
const dbResult = getContextDb(mainaDir);
|
|
58
|
+
if (!dbResult.ok) return { ok: false, error: dbResult.error };
|
|
59
|
+
|
|
60
|
+
const { db } = dbResult.value;
|
|
61
|
+
|
|
62
|
+
const rows = db
|
|
63
|
+
.prepare("SELECT source_file, target_file FROM dependency_edges")
|
|
64
|
+
.all() as Array<{ source_file: string; target_file: string }>;
|
|
65
|
+
|
|
66
|
+
let lines = rows.map((row) => ({
|
|
67
|
+
source: toModuleLabel(row.source_file),
|
|
68
|
+
target: toModuleLabel(row.target_file),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
// Apply scope filter if provided
|
|
72
|
+
if (options?.scope) {
|
|
73
|
+
const scope = options.scope;
|
|
74
|
+
lines = lines.filter(
|
|
75
|
+
(edge) =>
|
|
76
|
+
edge.source.startsWith(scope) || edge.target.startsWith(scope),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Build Mermaid diagram
|
|
81
|
+
let diagram = "graph LR\n";
|
|
82
|
+
for (const edge of lines) {
|
|
83
|
+
diagram += ` ${edge.source} --> ${edge.target}\n`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
db.close();
|
|
87
|
+
return { ok: true, value: diagram };
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── generateModuleSummary ───────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read semantic_entities table from Context Engine DB, group entities by
|
|
97
|
+
* file/module, and return a summary of each module.
|
|
98
|
+
*
|
|
99
|
+
* Returns an array of ModuleSummary on success, or an error on failure.
|
|
100
|
+
* Never throws.
|
|
101
|
+
*/
|
|
102
|
+
export function generateModuleSummary(
|
|
103
|
+
mainaDir: string,
|
|
104
|
+
): Result<ModuleSummary[]> {
|
|
105
|
+
try {
|
|
106
|
+
const dbResult = getContextDb(mainaDir);
|
|
107
|
+
if (!dbResult.ok) return { ok: false, error: dbResult.error };
|
|
108
|
+
|
|
109
|
+
const { db } = dbResult.value;
|
|
110
|
+
|
|
111
|
+
const rows = db
|
|
112
|
+
.prepare("SELECT file_path, kind FROM semantic_entities")
|
|
113
|
+
.all() as Array<{ file_path: string; kind: string }>;
|
|
114
|
+
|
|
115
|
+
if (rows.length === 0) {
|
|
116
|
+
db.close();
|
|
117
|
+
return { ok: true, value: [] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Group by module label
|
|
121
|
+
const moduleMap = new Map<
|
|
122
|
+
string,
|
|
123
|
+
{
|
|
124
|
+
functions: number;
|
|
125
|
+
classes: number;
|
|
126
|
+
interfaces: number;
|
|
127
|
+
types: number;
|
|
128
|
+
total: number;
|
|
129
|
+
}
|
|
130
|
+
>();
|
|
131
|
+
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
const mod = toModuleLabel(row.file_path);
|
|
134
|
+
const entry = moduleMap.get(mod) ?? {
|
|
135
|
+
functions: 0,
|
|
136
|
+
classes: 0,
|
|
137
|
+
interfaces: 0,
|
|
138
|
+
types: 0,
|
|
139
|
+
total: 0,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
entry.total++;
|
|
143
|
+
switch (row.kind) {
|
|
144
|
+
case "function":
|
|
145
|
+
entry.functions++;
|
|
146
|
+
break;
|
|
147
|
+
case "class":
|
|
148
|
+
entry.classes++;
|
|
149
|
+
break;
|
|
150
|
+
case "interface":
|
|
151
|
+
entry.interfaces++;
|
|
152
|
+
break;
|
|
153
|
+
case "type":
|
|
154
|
+
entry.types++;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
moduleMap.set(mod, entry);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const summaries: ModuleSummary[] = [];
|
|
162
|
+
for (const [mod, entry] of moduleMap) {
|
|
163
|
+
summaries.push({
|
|
164
|
+
module: mod,
|
|
165
|
+
entityCount: entry.total,
|
|
166
|
+
functions: entry.functions,
|
|
167
|
+
classes: entry.classes,
|
|
168
|
+
interfaces: entry.interfaces,
|
|
169
|
+
types: entry.types,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Sort by module name for deterministic output
|
|
174
|
+
summaries.sort((a, b) => a.module.localeCompare(b.module));
|
|
175
|
+
|
|
176
|
+
db.close();
|
|
177
|
+
return { ok: true, value: summaries };
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { analyze } from "../analyzer";
|
|
6
|
+
|
|
7
|
+
function makeTmpDir(): string {
|
|
8
|
+
const dir = join(
|
|
9
|
+
tmpdir(),
|
|
10
|
+
`maina-analyzer-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
11
|
+
);
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SPEC_CONTENT = `# Feature: User Auth
|
|
17
|
+
|
|
18
|
+
## User stories
|
|
19
|
+
- As a user, I want to log in with email/password
|
|
20
|
+
|
|
21
|
+
## Acceptance criteria
|
|
22
|
+
- Login validates email format
|
|
23
|
+
- Failed login shows error message
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const PLAN_CONTENT = `# Implementation Plan
|
|
27
|
+
|
|
28
|
+
## Architecture
|
|
29
|
+
- JWT authentication with bcrypt
|
|
30
|
+
|
|
31
|
+
## Tasks
|
|
32
|
+
- T001: Implement login with email format validation
|
|
33
|
+
- T002: Add error messages for failed login attempts
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const TASKS_CONTENT = `# Tasks
|
|
37
|
+
|
|
38
|
+
- [ ] T001: Implement login with email format validation
|
|
39
|
+
- [ ] T002: Add error messages for failed login attempts
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
describe("analyze", () => {
|
|
43
|
+
let tmpDir: string;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
tmpDir = makeTmpDir();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// --- Feature dir doesn't exist → error Result ---
|
|
54
|
+
test("non-existent feature dir returns error Result", () => {
|
|
55
|
+
const result = analyze(join(tmpDir, "non-existent"));
|
|
56
|
+
expect(result.ok).toBe(false);
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
expect(result.error).toContain("does not exist");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- All three files present and consistent → no findings ---
|
|
63
|
+
test("all three consistent files produce no findings", () => {
|
|
64
|
+
writeFileSync(join(tmpDir, "spec.md"), SPEC_CONTENT);
|
|
65
|
+
writeFileSync(join(tmpDir, "plan.md"), PLAN_CONTENT);
|
|
66
|
+
writeFileSync(join(tmpDir, "tasks.md"), TASKS_CONTENT);
|
|
67
|
+
|
|
68
|
+
const result = analyze(tmpDir);
|
|
69
|
+
expect(result.ok).toBe(true);
|
|
70
|
+
if (!result.ok) return;
|
|
71
|
+
|
|
72
|
+
expect(result.value.findings).toHaveLength(0);
|
|
73
|
+
expect(result.value.summary.errors).toBe(0);
|
|
74
|
+
expect(result.value.summary.warnings).toBe(0);
|
|
75
|
+
expect(result.value.summary.info).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// --- Missing spec.md → finding with category "missing-file" ---
|
|
79
|
+
test("missing spec.md produces missing-file finding", () => {
|
|
80
|
+
writeFileSync(join(tmpDir, "plan.md"), PLAN_CONTENT);
|
|
81
|
+
writeFileSync(join(tmpDir, "tasks.md"), TASKS_CONTENT);
|
|
82
|
+
|
|
83
|
+
const result = analyze(tmpDir);
|
|
84
|
+
expect(result.ok).toBe(true);
|
|
85
|
+
if (!result.ok) return;
|
|
86
|
+
|
|
87
|
+
const missingFile = result.value.findings.find(
|
|
88
|
+
(f) => f.category === "missing-file" && f.file === "spec.md",
|
|
89
|
+
);
|
|
90
|
+
expect(missingFile).toBeDefined();
|
|
91
|
+
expect(missingFile?.severity).toBe("warning");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// --- Missing plan.md → finding with category "missing-file" ---
|
|
95
|
+
test("missing plan.md produces missing-file finding", () => {
|
|
96
|
+
writeFileSync(join(tmpDir, "spec.md"), SPEC_CONTENT);
|
|
97
|
+
writeFileSync(join(tmpDir, "tasks.md"), TASKS_CONTENT);
|
|
98
|
+
|
|
99
|
+
const result = analyze(tmpDir);
|
|
100
|
+
expect(result.ok).toBe(true);
|
|
101
|
+
if (!result.ok) return;
|
|
102
|
+
|
|
103
|
+
const missingFile = result.value.findings.find(
|
|
104
|
+
(f) => f.category === "missing-file" && f.file === "plan.md",
|
|
105
|
+
);
|
|
106
|
+
expect(missingFile).toBeDefined();
|
|
107
|
+
expect(missingFile?.severity).toBe("warning");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// --- Missing tasks.md → finding with category "missing-file" ---
|
|
111
|
+
test("missing tasks.md produces missing-file finding", () => {
|
|
112
|
+
writeFileSync(join(tmpDir, "spec.md"), SPEC_CONTENT);
|
|
113
|
+
writeFileSync(join(tmpDir, "plan.md"), PLAN_CONTENT);
|
|
114
|
+
|
|
115
|
+
const result = analyze(tmpDir);
|
|
116
|
+
expect(result.ok).toBe(true);
|
|
117
|
+
if (!result.ok) return;
|
|
118
|
+
|
|
119
|
+
const missingFile = result.value.findings.find(
|
|
120
|
+
(f) => f.category === "missing-file" && f.file === "tasks.md",
|
|
121
|
+
);
|
|
122
|
+
expect(missingFile).toBeDefined();
|
|
123
|
+
expect(missingFile?.severity).toBe("info");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// --- Spec criterion not covered by any task → "spec-coverage" error ---
|
|
127
|
+
test("uncovered spec criterion produces spec-coverage error", () => {
|
|
128
|
+
writeFileSync(
|
|
129
|
+
join(tmpDir, "spec.md"),
|
|
130
|
+
`# Feature: User Auth
|
|
131
|
+
|
|
132
|
+
## Acceptance criteria
|
|
133
|
+
- Login validates email format
|
|
134
|
+
- Failed login shows error message
|
|
135
|
+
- Password reset sends email within 30 seconds
|
|
136
|
+
`,
|
|
137
|
+
);
|
|
138
|
+
writeFileSync(
|
|
139
|
+
join(tmpDir, "plan.md"),
|
|
140
|
+
`# Implementation Plan
|
|
141
|
+
|
|
142
|
+
## Tasks
|
|
143
|
+
- T001: Implement login with email format validation
|
|
144
|
+
- T002: Add error messages for failed login attempts
|
|
145
|
+
`,
|
|
146
|
+
);
|
|
147
|
+
writeFileSync(
|
|
148
|
+
join(tmpDir, "tasks.md"),
|
|
149
|
+
`# Tasks
|
|
150
|
+
|
|
151
|
+
- [ ] T001: Implement login with email format validation
|
|
152
|
+
- [ ] T002: Add error messages for failed login attempts
|
|
153
|
+
`,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const result = analyze(tmpDir);
|
|
157
|
+
expect(result.ok).toBe(true);
|
|
158
|
+
if (!result.ok) return;
|
|
159
|
+
|
|
160
|
+
const coverageFindings = result.value.findings.filter(
|
|
161
|
+
(f) => f.category === "spec-coverage",
|
|
162
|
+
);
|
|
163
|
+
expect(coverageFindings.length).toBeGreaterThan(0);
|
|
164
|
+
expect(coverageFindings[0]?.severity).toBe("error");
|
|
165
|
+
expect(
|
|
166
|
+
coverageFindings.some((f) =>
|
|
167
|
+
f.message.toLowerCase().includes("password reset"),
|
|
168
|
+
),
|
|
169
|
+
).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// --- Task in plan.md not mapping to spec → "orphaned-task" warning ---
|
|
173
|
+
test("orphaned task in plan.md produces orphaned-task warning", () => {
|
|
174
|
+
writeFileSync(
|
|
175
|
+
join(tmpDir, "spec.md"),
|
|
176
|
+
`# Feature: User Auth
|
|
177
|
+
|
|
178
|
+
## Acceptance criteria
|
|
179
|
+
- Login validates email format
|
|
180
|
+
`,
|
|
181
|
+
);
|
|
182
|
+
writeFileSync(
|
|
183
|
+
join(tmpDir, "plan.md"),
|
|
184
|
+
`# Implementation Plan
|
|
185
|
+
|
|
186
|
+
## Tasks
|
|
187
|
+
- T001: Implement login with email format validation
|
|
188
|
+
- T002: Set up Kubernetes deployment pipeline
|
|
189
|
+
`,
|
|
190
|
+
);
|
|
191
|
+
writeFileSync(
|
|
192
|
+
join(tmpDir, "tasks.md"),
|
|
193
|
+
`# Tasks
|
|
194
|
+
|
|
195
|
+
- [ ] T001: Implement login with email format validation
|
|
196
|
+
- [ ] T002: Set up Kubernetes deployment pipeline
|
|
197
|
+
`,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const result = analyze(tmpDir);
|
|
201
|
+
expect(result.ok).toBe(true);
|
|
202
|
+
if (!result.ok) return;
|
|
203
|
+
|
|
204
|
+
const orphaned = result.value.findings.filter(
|
|
205
|
+
(f) => f.category === "orphaned-task",
|
|
206
|
+
);
|
|
207
|
+
expect(orphaned.length).toBeGreaterThan(0);
|
|
208
|
+
expect(orphaned[0]?.severity).toBe("warning");
|
|
209
|
+
expect(
|
|
210
|
+
orphaned.some((f) => f.message.toLowerCase().includes("kubernetes")),
|
|
211
|
+
).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// --- spec.md contains implementation keywords → "separation-violation" warning ---
|
|
215
|
+
test("spec.md with implementation keywords produces separation-violation", () => {
|
|
216
|
+
writeFileSync(
|
|
217
|
+
join(tmpDir, "spec.md"),
|
|
218
|
+
`# Feature: User Auth
|
|
219
|
+
|
|
220
|
+
## Acceptance criteria
|
|
221
|
+
- Login validates email format using JWT endpoint
|
|
222
|
+
- Failed login shows error message via REST API
|
|
223
|
+
`,
|
|
224
|
+
);
|
|
225
|
+
writeFileSync(join(tmpDir, "plan.md"), PLAN_CONTENT);
|
|
226
|
+
writeFileSync(join(tmpDir, "tasks.md"), TASKS_CONTENT);
|
|
227
|
+
|
|
228
|
+
const result = analyze(tmpDir);
|
|
229
|
+
expect(result.ok).toBe(true);
|
|
230
|
+
if (!result.ok) return;
|
|
231
|
+
|
|
232
|
+
const violations = result.value.findings.filter(
|
|
233
|
+
(f) => f.category === "separation-violation" && f.file === "spec.md",
|
|
234
|
+
);
|
|
235
|
+
expect(violations.length).toBeGreaterThan(0);
|
|
236
|
+
expect(violations[0]?.severity).toBe("warning");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// --- plan.md contains user story language → "separation-violation" warning ---
|
|
240
|
+
test("plan.md with user story language produces separation-violation", () => {
|
|
241
|
+
writeFileSync(join(tmpDir, "spec.md"), SPEC_CONTENT);
|
|
242
|
+
writeFileSync(
|
|
243
|
+
join(tmpDir, "plan.md"),
|
|
244
|
+
`# Implementation Plan
|
|
245
|
+
|
|
246
|
+
## Architecture
|
|
247
|
+
- JWT authentication with bcrypt
|
|
248
|
+
- As a user, I want fast login
|
|
249
|
+
|
|
250
|
+
## Tasks
|
|
251
|
+
- T001: Implement login with email format validation
|
|
252
|
+
- T002: Add error messages for failed login attempts
|
|
253
|
+
`,
|
|
254
|
+
);
|
|
255
|
+
writeFileSync(join(tmpDir, "tasks.md"), TASKS_CONTENT);
|
|
256
|
+
|
|
257
|
+
const result = analyze(tmpDir);
|
|
258
|
+
expect(result.ok).toBe(true);
|
|
259
|
+
if (!result.ok) return;
|
|
260
|
+
|
|
261
|
+
const violations = result.value.findings.filter(
|
|
262
|
+
(f) => f.category === "separation-violation" && f.file === "plan.md",
|
|
263
|
+
);
|
|
264
|
+
expect(violations.length).toBeGreaterThan(0);
|
|
265
|
+
expect(violations[0]?.severity).toBe("warning");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// --- Different task counts between plan.md and tasks.md → "task-consistency" warning ---
|
|
269
|
+
test("different task counts between plan.md and tasks.md produces task-consistency warning", () => {
|
|
270
|
+
writeFileSync(join(tmpDir, "spec.md"), SPEC_CONTENT);
|
|
271
|
+
writeFileSync(
|
|
272
|
+
join(tmpDir, "plan.md"),
|
|
273
|
+
`# Implementation Plan
|
|
274
|
+
|
|
275
|
+
## Tasks
|
|
276
|
+
- T001: Implement login with email format validation
|
|
277
|
+
- T002: Add error messages for failed login attempts
|
|
278
|
+
- T003: Add rate limiting
|
|
279
|
+
`,
|
|
280
|
+
);
|
|
281
|
+
writeFileSync(join(tmpDir, "tasks.md"), TASKS_CONTENT);
|
|
282
|
+
|
|
283
|
+
const result = analyze(tmpDir);
|
|
284
|
+
expect(result.ok).toBe(true);
|
|
285
|
+
if (!result.ok) return;
|
|
286
|
+
|
|
287
|
+
const consistency = result.value.findings.filter(
|
|
288
|
+
(f) => f.category === "task-consistency",
|
|
289
|
+
);
|
|
290
|
+
expect(consistency.length).toBeGreaterThan(0);
|
|
291
|
+
expect(consistency[0]?.severity).toBe("warning");
|
|
292
|
+
expect(consistency[0]?.message).toContain("3");
|
|
293
|
+
expect(consistency[0]?.message).toContain("2");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// --- Contradicting task descriptions → "contradiction" warning ---
|
|
297
|
+
test("contradicting task descriptions produce contradiction warning", () => {
|
|
298
|
+
writeFileSync(join(tmpDir, "spec.md"), SPEC_CONTENT);
|
|
299
|
+
writeFileSync(
|
|
300
|
+
join(tmpDir, "plan.md"),
|
|
301
|
+
`# Implementation Plan
|
|
302
|
+
|
|
303
|
+
## Tasks
|
|
304
|
+
- T001: Implement login with email format validation
|
|
305
|
+
- T002: Add error messages for failed login attempts
|
|
306
|
+
`,
|
|
307
|
+
);
|
|
308
|
+
writeFileSync(
|
|
309
|
+
join(tmpDir, "tasks.md"),
|
|
310
|
+
`# Tasks
|
|
311
|
+
|
|
312
|
+
- [ ] T001: Implement login with email format validation
|
|
313
|
+
- [ ] T002: Build notification service for alerts
|
|
314
|
+
`,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const result = analyze(tmpDir);
|
|
318
|
+
expect(result.ok).toBe(true);
|
|
319
|
+
if (!result.ok) return;
|
|
320
|
+
|
|
321
|
+
const contradictions = result.value.findings.filter(
|
|
322
|
+
(f) => f.category === "contradiction",
|
|
323
|
+
);
|
|
324
|
+
expect(contradictions.length).toBeGreaterThan(0);
|
|
325
|
+
expect(contradictions[0]?.severity).toBe("warning");
|
|
326
|
+
expect(contradictions[0]?.message).toContain("T002");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// --- Summary correctly counts errors/warnings/info ---
|
|
330
|
+
test("summary correctly counts errors, warnings, and info", () => {
|
|
331
|
+
// Missing spec → warning, missing plan → warning, missing tasks → info
|
|
332
|
+
const result = analyze(tmpDir);
|
|
333
|
+
expect(result.ok).toBe(true);
|
|
334
|
+
if (!result.ok) return;
|
|
335
|
+
|
|
336
|
+
const { summary } = result.value;
|
|
337
|
+
// 3 missing-file findings: spec (warning), plan (warning), tasks (info)
|
|
338
|
+
expect(summary.warnings).toBe(2);
|
|
339
|
+
expect(summary.info).toBe(1);
|
|
340
|
+
expect(summary.errors).toBe(0);
|
|
341
|
+
expect(summary.errors + summary.warnings + summary.info).toBe(
|
|
342
|
+
result.value.findings.length,
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// --- featureDir is captured in report ---
|
|
347
|
+
test("report includes featureDir", () => {
|
|
348
|
+
writeFileSync(join(tmpDir, "spec.md"), SPEC_CONTENT);
|
|
349
|
+
writeFileSync(join(tmpDir, "plan.md"), PLAN_CONTENT);
|
|
350
|
+
writeFileSync(join(tmpDir, "tasks.md"), TASKS_CONTENT);
|
|
351
|
+
|
|
352
|
+
const result = analyze(tmpDir);
|
|
353
|
+
expect(result.ok).toBe(true);
|
|
354
|
+
if (!result.ok) return;
|
|
355
|
+
|
|
356
|
+
expect(result.value.featureDir).toBe(tmpDir);
|
|
357
|
+
});
|
|
358
|
+
});
|