@mainahq/core 1.0.3 → 1.1.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/package.json +1 -1
- package/src/ai/__tests__/delegation.test.ts +55 -1
- package/src/ai/delegation.ts +5 -3
- package/src/context/__tests__/budget.test.ts +29 -6
- package/src/context/__tests__/engine.test.ts +1 -0
- package/src/context/__tests__/selector.test.ts +23 -3
- package/src/context/__tests__/wiki.test.ts +349 -0
- package/src/context/budget.ts +12 -8
- package/src/context/engine.ts +37 -0
- package/src/context/selector.ts +30 -4
- package/src/context/wiki.ts +296 -0
- package/src/db/index.ts +12 -0
- package/src/feedback/__tests__/capture.test.ts +166 -0
- package/src/feedback/__tests__/signals.test.ts +144 -0
- package/src/feedback/__tests__/tmp-capture-1775575256633-lah0etnzlj/feedback.db +0 -0
- package/src/feedback/__tests__/tmp-capture-1775575256640-2xmjme4qraa/feedback.db +0 -0
- package/src/feedback/capture.ts +102 -0
- package/src/feedback/signals.ts +68 -0
- package/src/index.ts +104 -0
- package/src/init/__tests__/init.test.ts +400 -3
- package/src/init/index.ts +368 -12
- package/src/language/__tests__/__fixtures__/detect/composer.lock +1 -0
- package/src/prompts/defaults/index.ts +3 -1
- package/src/prompts/defaults/wiki-compile.md +20 -0
- package/src/prompts/defaults/wiki-query.md +18 -0
- package/src/stats/__tests__/tool-usage.test.ts +133 -0
- package/src/stats/tracker.ts +92 -0
- package/src/verify/__tests__/pipeline.test.ts +11 -8
- package/src/verify/pipeline.ts +13 -1
- package/src/verify/tools/__tests__/wiki-lint.test.ts +784 -0
- package/src/verify/tools/wiki-lint-runner.ts +38 -0
- package/src/verify/tools/wiki-lint.ts +898 -0
- package/src/wiki/__tests__/compiler.test.ts +389 -0
- package/src/wiki/__tests__/extractors/code.test.ts +99 -0
- package/src/wiki/__tests__/extractors/decision.test.ts +323 -0
- package/src/wiki/__tests__/extractors/feature.test.ts +186 -0
- package/src/wiki/__tests__/extractors/workflow.test.ts +131 -0
- package/src/wiki/__tests__/graph.test.ts +344 -0
- package/src/wiki/__tests__/hooks.test.ts +119 -0
- package/src/wiki/__tests__/indexer.test.ts +285 -0
- package/src/wiki/__tests__/linker.test.ts +230 -0
- package/src/wiki/__tests__/louvain.test.ts +229 -0
- package/src/wiki/__tests__/query.test.ts +316 -0
- package/src/wiki/__tests__/schema.test.ts +114 -0
- package/src/wiki/__tests__/signals.test.ts +474 -0
- package/src/wiki/__tests__/state.test.ts +168 -0
- package/src/wiki/__tests__/tracking.test.ts +118 -0
- package/src/wiki/__tests__/types.test.ts +387 -0
- package/src/wiki/compiler.ts +1075 -0
- package/src/wiki/extractors/code.ts +90 -0
- package/src/wiki/extractors/decision.ts +217 -0
- package/src/wiki/extractors/feature.ts +206 -0
- package/src/wiki/extractors/workflow.ts +112 -0
- package/src/wiki/graph.ts +445 -0
- package/src/wiki/hooks.ts +49 -0
- package/src/wiki/indexer.ts +105 -0
- package/src/wiki/linker.ts +117 -0
- package/src/wiki/louvain.ts +190 -0
- package/src/wiki/prompts/compile-architecture.md +59 -0
- package/src/wiki/prompts/compile-decision.md +66 -0
- package/src/wiki/prompts/compile-entity.md +56 -0
- package/src/wiki/prompts/compile-feature.md +60 -0
- package/src/wiki/prompts/compile-module.md +42 -0
- package/src/wiki/prompts/wiki-query.md +25 -0
- package/src/wiki/query.ts +338 -0
- package/src/wiki/schema.ts +111 -0
- package/src/wiki/signals.ts +368 -0
- package/src/wiki/state.ts +89 -0
- package/src/wiki/tracking.ts +30 -0
- package/src/wiki/types.ts +169 -0
- package/src/workflow/context.ts +26 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } 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 { saveState } from "../../../wiki/state";
|
|
6
|
+
import type { WikiState } from "../../../wiki/types";
|
|
7
|
+
import { runWikiLint, wikiLintToFindings } from "../wiki-lint";
|
|
8
|
+
|
|
9
|
+
// ─── Setup ───────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let wikiDir: string;
|
|
13
|
+
let repoRoot: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmpDir = join(
|
|
17
|
+
tmpdir(),
|
|
18
|
+
`wiki-lint-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
19
|
+
);
|
|
20
|
+
repoRoot = tmpDir;
|
|
21
|
+
wikiDir = join(tmpDir, ".maina", "wiki");
|
|
22
|
+
mkdirSync(wikiDir, { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function writeArticle(relPath: string, content: string): void {
|
|
32
|
+
const fullPath = join(wikiDir, relPath);
|
|
33
|
+
const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
writeFileSync(fullPath, content, "utf-8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeSourceFile(relPath: string, content: string): void {
|
|
39
|
+
const fullPath = join(repoRoot, relPath);
|
|
40
|
+
const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
writeFileSync(fullPath, content, "utf-8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Tests ───────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("Wiki Lint Tool", () => {
|
|
48
|
+
describe("graceful skip", () => {
|
|
49
|
+
it("should return empty result when wiki dir does not exist", () => {
|
|
50
|
+
const result = runWikiLint({
|
|
51
|
+
wikiDir: join(tmpDir, "nonexistent", "wiki"),
|
|
52
|
+
repoRoot,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.stale).toHaveLength(0);
|
|
56
|
+
expect(result.orphans).toHaveLength(0);
|
|
57
|
+
expect(result.gaps).toHaveLength(0);
|
|
58
|
+
expect(result.brokenLinks).toHaveLength(0);
|
|
59
|
+
expect(result.coveragePercent).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should return empty result with zero coverage for empty wiki dir", () => {
|
|
63
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
64
|
+
|
|
65
|
+
// Empty wiki dir: no articles at all → info-level "missing" finding
|
|
66
|
+
expect(result.gaps.length).toBeGreaterThanOrEqual(1);
|
|
67
|
+
expect(result.gaps[0]?.severity).toBe("info");
|
|
68
|
+
expect(result.coveragePercent).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("stale detection", () => {
|
|
73
|
+
it("should detect stale source files with mismatched hashes", () => {
|
|
74
|
+
// Write a source file
|
|
75
|
+
writeSourceFile("src/auth.ts", "export function login() {}");
|
|
76
|
+
|
|
77
|
+
// Create state with an outdated hash
|
|
78
|
+
const state: WikiState = {
|
|
79
|
+
fileHashes: { "src/auth.ts": "old_hash_that_does_not_match" },
|
|
80
|
+
articleHashes: {},
|
|
81
|
+
lastFullCompile: "2026-04-01T00:00:00.000Z",
|
|
82
|
+
lastIncrementalCompile: "",
|
|
83
|
+
compilationPromptHash: "",
|
|
84
|
+
};
|
|
85
|
+
saveState(wikiDir, state);
|
|
86
|
+
|
|
87
|
+
// Write at least one article so the "missing" check doesn't dominate
|
|
88
|
+
writeArticle(
|
|
89
|
+
"modules/auth.md",
|
|
90
|
+
"# Auth Module\n\nHandles authentication.",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
94
|
+
|
|
95
|
+
// Should find stale source file
|
|
96
|
+
const staleFindings = result.stale;
|
|
97
|
+
expect(staleFindings.length).toBeGreaterThanOrEqual(1);
|
|
98
|
+
expect(staleFindings[0]?.severity).toBe("warning");
|
|
99
|
+
expect(staleFindings[0]?.check).toBe("stale");
|
|
100
|
+
expect(staleFindings[0]?.message).toContain("src/auth.ts");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should not flag files with matching hashes", () => {
|
|
104
|
+
writeSourceFile("src/ok.ts", "export const x = 1;");
|
|
105
|
+
|
|
106
|
+
// Compute correct hash from the file
|
|
107
|
+
const { hashContent } = require("../../../wiki/state");
|
|
108
|
+
const correctHash = hashContent("export const x = 1;");
|
|
109
|
+
|
|
110
|
+
const state: WikiState = {
|
|
111
|
+
fileHashes: { "src/ok.ts": correctHash },
|
|
112
|
+
articleHashes: {},
|
|
113
|
+
lastFullCompile: "2026-04-01T00:00:00.000Z",
|
|
114
|
+
lastIncrementalCompile: "",
|
|
115
|
+
compilationPromptHash: "",
|
|
116
|
+
};
|
|
117
|
+
saveState(wikiDir, state);
|
|
118
|
+
|
|
119
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
120
|
+
|
|
121
|
+
const staleSourceFindings = result.stale.filter((f) =>
|
|
122
|
+
f.message.includes("src/ok.ts"),
|
|
123
|
+
);
|
|
124
|
+
expect(staleSourceFindings).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("broken link detection", () => {
|
|
129
|
+
it("should detect broken [[entity:nonexistent]] links", () => {
|
|
130
|
+
writeArticle(
|
|
131
|
+
"modules/auth.md",
|
|
132
|
+
"# Auth Module\n\nReferences [[entity:nonexistent]] and [[module:missing]].",
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
136
|
+
|
|
137
|
+
expect(result.brokenLinks.length).toBe(2);
|
|
138
|
+
expect(result.brokenLinks[0]?.severity).toBe("error");
|
|
139
|
+
expect(result.brokenLinks[0]?.check).toBe("broken_link");
|
|
140
|
+
expect(result.brokenLinks[0]?.message).toContain("entity:nonexistent");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should not flag valid links to existing articles", () => {
|
|
144
|
+
writeArticle("modules/auth.md", "# Auth Module\n\nSee [[entity:user]].");
|
|
145
|
+
writeArticle("entities/user.md", "# User Entity\n\nThe user model.");
|
|
146
|
+
|
|
147
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
148
|
+
|
|
149
|
+
expect(result.brokenLinks).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should detect multiple broken links across articles", () => {
|
|
153
|
+
writeArticle("modules/auth.md", "# Auth\n\n[[entity:ghost]]");
|
|
154
|
+
writeArticle("modules/db.md", "# DB\n\n[[decision:phantom]]");
|
|
155
|
+
|
|
156
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
157
|
+
|
|
158
|
+
expect(result.brokenLinks.length).toBe(2);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("coverage calculation", () => {
|
|
163
|
+
it("should report 0% coverage when no state exists", () => {
|
|
164
|
+
writeArticle("modules/auth.md", "# Auth Module");
|
|
165
|
+
|
|
166
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
167
|
+
|
|
168
|
+
expect(result.coveragePercent).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("should calculate correct coverage percentage", () => {
|
|
172
|
+
const state: WikiState = {
|
|
173
|
+
fileHashes: {
|
|
174
|
+
"src/a.ts": "h1",
|
|
175
|
+
"src/b.ts": "h2",
|
|
176
|
+
"src/c.ts": "h3",
|
|
177
|
+
"src/d.ts": "h4",
|
|
178
|
+
},
|
|
179
|
+
articleHashes: {
|
|
180
|
+
"modules/a.md": "ah1",
|
|
181
|
+
"modules/b.md": "ah2",
|
|
182
|
+
},
|
|
183
|
+
lastFullCompile: "2026-04-01T00:00:00.000Z",
|
|
184
|
+
lastIncrementalCompile: "",
|
|
185
|
+
compilationPromptHash: "",
|
|
186
|
+
};
|
|
187
|
+
saveState(wikiDir, state);
|
|
188
|
+
|
|
189
|
+
// Write enough articles to avoid the "missing" check
|
|
190
|
+
for (let i = 0; i < 5; i++) {
|
|
191
|
+
writeArticle(`modules/m${i}.md`, `# Module ${i}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
195
|
+
|
|
196
|
+
// 2 articles / 4 source files = 50%
|
|
197
|
+
expect(result.coveragePercent).toBe(50);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("missing articles", () => {
|
|
202
|
+
it("should warn when wiki has fewer than 5 articles", () => {
|
|
203
|
+
writeArticle("modules/auth.md", "# Auth Module");
|
|
204
|
+
writeArticle("modules/db.md", "# DB Module");
|
|
205
|
+
|
|
206
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
207
|
+
|
|
208
|
+
const missingFindings = result.gaps.filter((f) =>
|
|
209
|
+
f.message.includes("article(s)"),
|
|
210
|
+
);
|
|
211
|
+
expect(missingFindings.length).toBe(1);
|
|
212
|
+
expect(missingFindings[0]?.severity).toBe("info");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("should not warn when wiki has 5+ articles", () => {
|
|
216
|
+
for (let i = 0; i < 6; i++) {
|
|
217
|
+
writeArticle(`modules/mod${i}.md`, `# Module ${i}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
221
|
+
|
|
222
|
+
const missingFindings = result.gaps.filter((f) =>
|
|
223
|
+
f.message.includes("article(s)"),
|
|
224
|
+
);
|
|
225
|
+
expect(missingFindings).toHaveLength(0);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("wikiLintToFindings conversion", () => {
|
|
230
|
+
it("should convert WikiLintResult to Finding[]", () => {
|
|
231
|
+
writeArticle("modules/auth.md", "# Auth\n\n[[entity:broken]]");
|
|
232
|
+
|
|
233
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
234
|
+
const findings = wikiLintToFindings(result);
|
|
235
|
+
|
|
236
|
+
expect(findings.length).toBeGreaterThan(0);
|
|
237
|
+
for (const finding of findings) {
|
|
238
|
+
expect(finding.tool).toBe("wiki-lint");
|
|
239
|
+
expect(finding.severity).toBeDefined();
|
|
240
|
+
expect(finding.message).toBeTruthy();
|
|
241
|
+
expect(finding.ruleId).toMatch(/^wiki\//);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("should map broken link to error severity", () => {
|
|
246
|
+
writeArticle("modules/auth.md", "# Auth\n\n[[entity:missing-ref]]");
|
|
247
|
+
|
|
248
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
249
|
+
const findings = wikiLintToFindings(result);
|
|
250
|
+
|
|
251
|
+
const brokenLinkFindings = findings.filter(
|
|
252
|
+
(f) => f.ruleId === "wiki/broken_link",
|
|
253
|
+
);
|
|
254
|
+
expect(brokenLinkFindings.length).toBe(1);
|
|
255
|
+
expect(brokenLinkFindings[0]?.severity).toBe("error");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("should return empty findings for empty result", () => {
|
|
259
|
+
const result = runWikiLint({
|
|
260
|
+
wikiDir: join(tmpDir, "nonexistent"),
|
|
261
|
+
repoRoot,
|
|
262
|
+
});
|
|
263
|
+
const findings = wikiLintToFindings(result);
|
|
264
|
+
expect(findings).toHaveLength(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should include new check types in findings conversion", () => {
|
|
268
|
+
// Set up spec drift scenario
|
|
269
|
+
const featuresDir = join(tmpDir, ".maina", "features");
|
|
270
|
+
const featureDir = join(featuresDir, "001-auth");
|
|
271
|
+
mkdirSync(featureDir, { recursive: true });
|
|
272
|
+
|
|
273
|
+
writeFileSync(
|
|
274
|
+
join(featureDir, "spec.md"),
|
|
275
|
+
"# Feature: Auth\n\n## Acceptance Criteria\n\n- [ ] Must use Result<T,E> pattern\n",
|
|
276
|
+
);
|
|
277
|
+
writeFileSync(
|
|
278
|
+
join(featureDir, "plan.md"),
|
|
279
|
+
"# Implementation Plan: Auth\n",
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Create source file with throw
|
|
283
|
+
const srcDir = join(tmpDir, "src");
|
|
284
|
+
mkdirSync(srcDir, { recursive: true });
|
|
285
|
+
writeFileSync(
|
|
286
|
+
join(srcDir, "auth.ts"),
|
|
287
|
+
'export function login() { throw new Error("fail"); }\n',
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const result = runWikiLint({
|
|
291
|
+
wikiDir,
|
|
292
|
+
repoRoot,
|
|
293
|
+
featuresDir,
|
|
294
|
+
});
|
|
295
|
+
const findings = wikiLintToFindings(result);
|
|
296
|
+
|
|
297
|
+
const specDriftFindings = findings.filter(
|
|
298
|
+
(f) => f.ruleId === "wiki/spec_drift",
|
|
299
|
+
);
|
|
300
|
+
expect(specDriftFindings.length).toBeGreaterThanOrEqual(1);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ─── Check 6: Spec Drift ────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
describe("spec drift detection", () => {
|
|
307
|
+
it("should detect throw in code when spec says Result pattern", () => {
|
|
308
|
+
const featuresDir = join(tmpDir, ".maina", "features");
|
|
309
|
+
const featureDir = join(featuresDir, "001-auth");
|
|
310
|
+
mkdirSync(featureDir, { recursive: true });
|
|
311
|
+
|
|
312
|
+
writeFileSync(
|
|
313
|
+
join(featureDir, "spec.md"),
|
|
314
|
+
"# Feature: Auth\n\n## Acceptance Criteria\n\n- [ ] All errors use Result<T,E> pattern\n",
|
|
315
|
+
);
|
|
316
|
+
writeFileSync(
|
|
317
|
+
join(featureDir, "plan.md"),
|
|
318
|
+
"# Implementation Plan: Auth\n",
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// Create source file with throw
|
|
322
|
+
const srcDir = join(tmpDir, "src");
|
|
323
|
+
mkdirSync(srcDir, { recursive: true });
|
|
324
|
+
writeFileSync(
|
|
325
|
+
join(srcDir, "auth.ts"),
|
|
326
|
+
'export function login() {\n throw new Error("failed");\n}\n',
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const result = runWikiLint({
|
|
330
|
+
wikiDir,
|
|
331
|
+
repoRoot,
|
|
332
|
+
featuresDir,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
expect(result.specDrift.length).toBeGreaterThanOrEqual(1);
|
|
336
|
+
expect(result.specDrift[0]?.check).toBe("spec_drift");
|
|
337
|
+
expect(result.specDrift[0]?.severity).toBe("warning");
|
|
338
|
+
expect(result.specDrift[0]?.message).toContain("Result/never-throw");
|
|
339
|
+
expect(result.specDrift[0]?.message).toContain("throw");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("should detect throw when spec says never throw", () => {
|
|
343
|
+
const featuresDir = join(tmpDir, ".maina", "features");
|
|
344
|
+
const featureDir = join(featuresDir, "002-errors");
|
|
345
|
+
mkdirSync(featureDir, { recursive: true });
|
|
346
|
+
|
|
347
|
+
writeFileSync(
|
|
348
|
+
join(featureDir, "spec.md"),
|
|
349
|
+
"# Feature: Errors\n\n## Acceptance Criteria\n\n- [ ] Functions must never throw exceptions\n",
|
|
350
|
+
);
|
|
351
|
+
writeFileSync(
|
|
352
|
+
join(featureDir, "plan.md"),
|
|
353
|
+
"# Implementation Plan: Errors\n",
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const srcDir = join(tmpDir, "src");
|
|
357
|
+
mkdirSync(srcDir, { recursive: true });
|
|
358
|
+
writeFileSync(
|
|
359
|
+
join(srcDir, "handler.ts"),
|
|
360
|
+
'export function handle() {\n throw "oops";\n}\n',
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const result = runWikiLint({
|
|
364
|
+
wikiDir,
|
|
365
|
+
repoRoot,
|
|
366
|
+
featuresDir,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
expect(result.specDrift.length).toBeGreaterThanOrEqual(1);
|
|
370
|
+
expect(result.specDrift[0]?.message).toContain("never-throw");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("should not flag when code uses Result correctly (no throw)", () => {
|
|
374
|
+
const featuresDir = join(tmpDir, ".maina", "features");
|
|
375
|
+
const featureDir = join(featuresDir, "001-auth");
|
|
376
|
+
mkdirSync(featureDir, { recursive: true });
|
|
377
|
+
|
|
378
|
+
writeFileSync(
|
|
379
|
+
join(featureDir, "spec.md"),
|
|
380
|
+
"# Feature: Auth\n\n## Acceptance Criteria\n\n- [ ] All errors use Result<T,E> pattern\n",
|
|
381
|
+
);
|
|
382
|
+
writeFileSync(
|
|
383
|
+
join(featureDir, "plan.md"),
|
|
384
|
+
"# Implementation Plan: Auth\n",
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Create source file that uses Result correctly
|
|
388
|
+
const srcDir = join(tmpDir, "src");
|
|
389
|
+
mkdirSync(srcDir, { recursive: true });
|
|
390
|
+
writeFileSync(
|
|
391
|
+
join(srcDir, "auth.ts"),
|
|
392
|
+
'export function login(): Result<User, string> {\n return { ok: true, value: { name: "test" } };\n}\n',
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const result = runWikiLint({
|
|
396
|
+
wikiDir,
|
|
397
|
+
repoRoot,
|
|
398
|
+
featuresDir,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
expect(result.specDrift).toHaveLength(0);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("should skip comments containing throw", () => {
|
|
405
|
+
const featuresDir = join(tmpDir, ".maina", "features");
|
|
406
|
+
const featureDir = join(featuresDir, "001-auth");
|
|
407
|
+
mkdirSync(featureDir, { recursive: true });
|
|
408
|
+
|
|
409
|
+
writeFileSync(
|
|
410
|
+
join(featureDir, "spec.md"),
|
|
411
|
+
"# Feature: Auth\n\n## Acceptance Criteria\n\n- [ ] Must use Result<T,E> pattern\n",
|
|
412
|
+
);
|
|
413
|
+
writeFileSync(
|
|
414
|
+
join(featureDir, "plan.md"),
|
|
415
|
+
"# Implementation Plan: Auth\n",
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
const srcDir = join(tmpDir, "src");
|
|
419
|
+
mkdirSync(srcDir, { recursive: true });
|
|
420
|
+
writeFileSync(
|
|
421
|
+
join(srcDir, "auth.ts"),
|
|
422
|
+
"// We never throw here, we use Result\nexport function login() { return { ok: true, value: null }; }\n",
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
const result = runWikiLint({
|
|
426
|
+
wikiDir,
|
|
427
|
+
repoRoot,
|
|
428
|
+
featuresDir,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
expect(result.specDrift).toHaveLength(0);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("should handle missing features dir gracefully", () => {
|
|
435
|
+
const result = runWikiLint({
|
|
436
|
+
wikiDir,
|
|
437
|
+
repoRoot,
|
|
438
|
+
featuresDir: join(tmpDir, "nonexistent", "features"),
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
expect(result.specDrift).toHaveLength(0);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ─── Check 7: Decision Violations ───────────────────────────────────
|
|
446
|
+
|
|
447
|
+
describe("decision violation detection", () => {
|
|
448
|
+
it("should detect jest import when ADR says bun:test", () => {
|
|
449
|
+
const adrDir = join(tmpDir, "adr");
|
|
450
|
+
mkdirSync(adrDir, { recursive: true });
|
|
451
|
+
|
|
452
|
+
writeFileSync(
|
|
453
|
+
join(adrDir, "0001-testing.md"),
|
|
454
|
+
"# ADR-0001: Use Bun Test\n\n## Status\n\nAccepted\n\n## Context\n\nWe need a test runner.\n\n## Decision\n\nWe will use bun:test for all tests.\n",
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
// Create source file with jest import
|
|
458
|
+
const srcDir = join(tmpDir, "src");
|
|
459
|
+
mkdirSync(srcDir, { recursive: true });
|
|
460
|
+
writeFileSync(
|
|
461
|
+
join(srcDir, "auth.test.ts"),
|
|
462
|
+
'import { describe, it } from "jest";\n\ndescribe("auth", () => {});\n',
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const result = runWikiLint({
|
|
466
|
+
wikiDir,
|
|
467
|
+
repoRoot,
|
|
468
|
+
adrDir,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
expect(result.decisionViolations.length).toBeGreaterThanOrEqual(1);
|
|
472
|
+
expect(result.decisionViolations[0]?.check).toBe("decision_violation");
|
|
473
|
+
expect(result.decisionViolations[0]?.severity).toBe("error");
|
|
474
|
+
expect(result.decisionViolations[0]?.message).toContain("bun:test");
|
|
475
|
+
expect(result.decisionViolations[0]?.message).toContain("jest");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("should detect vitest import when ADR says bun:test", () => {
|
|
479
|
+
const adrDir = join(tmpDir, "adr");
|
|
480
|
+
mkdirSync(adrDir, { recursive: true });
|
|
481
|
+
|
|
482
|
+
writeFileSync(
|
|
483
|
+
join(adrDir, "0001-testing.md"),
|
|
484
|
+
"# ADR-0001: Use Bun Test\n\n## Status\n\nAccepted\n\n## Context\n\nNeed tests.\n\n## Decision\n\nUse bun:test exclusively.\n",
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
const srcDir = join(tmpDir, "src");
|
|
488
|
+
mkdirSync(srcDir, { recursive: true });
|
|
489
|
+
writeFileSync(
|
|
490
|
+
join(srcDir, "utils.test.ts"),
|
|
491
|
+
'import { expect } from "vitest";\n',
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const result = runWikiLint({
|
|
495
|
+
wikiDir,
|
|
496
|
+
repoRoot,
|
|
497
|
+
adrDir,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(result.decisionViolations.length).toBeGreaterThanOrEqual(1);
|
|
501
|
+
expect(result.decisionViolations[0]?.message).toContain("vitest");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("should detect eslintrc when ADR says Biome", () => {
|
|
505
|
+
const adrDir = join(tmpDir, "adr");
|
|
506
|
+
mkdirSync(adrDir, { recursive: true });
|
|
507
|
+
|
|
508
|
+
writeFileSync(
|
|
509
|
+
join(adrDir, "0002-linting.md"),
|
|
510
|
+
"# ADR-0002: Use Biome\n\n## Status\n\nAccepted\n\n## Context\n\nLinting.\n\n## Decision\n\nUse Biome for linting and formatting.\n",
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
// Create eslintrc file at repo root
|
|
514
|
+
writeFileSync(join(tmpDir, ".eslintrc.json"), '{ "extends": [] }');
|
|
515
|
+
|
|
516
|
+
const result = runWikiLint({
|
|
517
|
+
wikiDir,
|
|
518
|
+
repoRoot,
|
|
519
|
+
adrDir,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
expect(result.decisionViolations.length).toBeGreaterThanOrEqual(1);
|
|
523
|
+
const eslintViolation = result.decisionViolations.find((f) =>
|
|
524
|
+
f.message.includes("ESLint"),
|
|
525
|
+
);
|
|
526
|
+
expect(eslintViolation).toBeDefined();
|
|
527
|
+
expect(eslintViolation?.severity).toBe("error");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("should not flag compliant code", () => {
|
|
531
|
+
const adrDir = join(tmpDir, "adr");
|
|
532
|
+
mkdirSync(adrDir, { recursive: true });
|
|
533
|
+
|
|
534
|
+
writeFileSync(
|
|
535
|
+
join(adrDir, "0001-testing.md"),
|
|
536
|
+
"# ADR-0001: Use Bun Test\n\n## Status\n\nAccepted\n\n## Context\n\nTests.\n\n## Decision\n\nUse bun:test.\n",
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
// Source file uses bun:test (compliant)
|
|
540
|
+
const srcDir = join(tmpDir, "src");
|
|
541
|
+
mkdirSync(srcDir, { recursive: true });
|
|
542
|
+
writeFileSync(
|
|
543
|
+
join(srcDir, "auth.test.ts"),
|
|
544
|
+
'import { describe, it, expect } from "bun:test";\n\ndescribe("auth", () => { it("works", () => { expect(true).toBe(true); }); });\n',
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const result = runWikiLint({
|
|
548
|
+
wikiDir,
|
|
549
|
+
repoRoot,
|
|
550
|
+
adrDir,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
expect(result.decisionViolations).toHaveLength(0);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("should ignore non-accepted decisions", () => {
|
|
557
|
+
const adrDir = join(tmpDir, "adr");
|
|
558
|
+
mkdirSync(adrDir, { recursive: true });
|
|
559
|
+
|
|
560
|
+
writeFileSync(
|
|
561
|
+
join(adrDir, "0001-testing.md"),
|
|
562
|
+
"# ADR-0001: Use Bun Test\n\n## Status\n\nProposed\n\n## Context\n\nTests.\n\n## Decision\n\nUse bun:test.\n",
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// Jest import — but ADR is only proposed, not accepted
|
|
566
|
+
const srcDir = join(tmpDir, "src");
|
|
567
|
+
mkdirSync(srcDir, { recursive: true });
|
|
568
|
+
writeFileSync(
|
|
569
|
+
join(srcDir, "auth.test.ts"),
|
|
570
|
+
'import { describe } from "jest";\n',
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const result = runWikiLint({
|
|
574
|
+
wikiDir,
|
|
575
|
+
repoRoot,
|
|
576
|
+
adrDir,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(result.decisionViolations).toHaveLength(0);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("should handle missing adr dir gracefully", () => {
|
|
583
|
+
const result = runWikiLint({
|
|
584
|
+
wikiDir,
|
|
585
|
+
repoRoot,
|
|
586
|
+
adrDir: join(tmpDir, "nonexistent", "adr"),
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
expect(result.decisionViolations).toHaveLength(0);
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// ─── Check 8: Missing Rationale ─────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
describe("missing rationale detection", () => {
|
|
596
|
+
it("should return empty when no wiki state exists", () => {
|
|
597
|
+
const result = runWikiLint({
|
|
598
|
+
wikiDir,
|
|
599
|
+
repoRoot,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
expect(result.missingRationale).toHaveLength(0);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("should not flag files mentioned in an ADR", () => {
|
|
606
|
+
// Create state with tracked file
|
|
607
|
+
const state: WikiState = {
|
|
608
|
+
fileHashes: { "src/auth.ts": "hash1" },
|
|
609
|
+
articleHashes: {},
|
|
610
|
+
lastFullCompile: "2026-04-01T00:00:00.000Z",
|
|
611
|
+
lastIncrementalCompile: "",
|
|
612
|
+
compilationPromptHash: "",
|
|
613
|
+
};
|
|
614
|
+
saveState(wikiDir, state);
|
|
615
|
+
|
|
616
|
+
// Create ADR that mentions the file
|
|
617
|
+
const adrDir = join(tmpDir, "adr");
|
|
618
|
+
mkdirSync(adrDir, { recursive: true });
|
|
619
|
+
writeFileSync(
|
|
620
|
+
join(adrDir, "0001-auth.md"),
|
|
621
|
+
"# ADR-0001: Auth Design\n\n## Status\n\nAccepted\n\n## Context\n\nAuth.\n\n## Decision\n\nUse JWT.\n\n## Entities\n\n- src/auth.ts\n",
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
// Enough articles to avoid missing articles check
|
|
625
|
+
for (let i = 0; i < 5; i++) {
|
|
626
|
+
writeArticle(`modules/mod${i}.md`, `# Module ${i}`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const result = runWikiLint({
|
|
630
|
+
wikiDir,
|
|
631
|
+
repoRoot,
|
|
632
|
+
adrDir,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// File is mentioned in ADR, so no missing rationale
|
|
636
|
+
const authRationale = result.missingRationale.filter((f) =>
|
|
637
|
+
f.message.includes("src/auth.ts"),
|
|
638
|
+
);
|
|
639
|
+
expect(authRationale).toHaveLength(0);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("should handle missing adr dir gracefully for rationale check", () => {
|
|
643
|
+
const state: WikiState = {
|
|
644
|
+
fileHashes: { "src/foo.ts": "hash1" },
|
|
645
|
+
articleHashes: {},
|
|
646
|
+
lastFullCompile: "2026-04-01T00:00:00.000Z",
|
|
647
|
+
lastIncrementalCompile: "",
|
|
648
|
+
compilationPromptHash: "",
|
|
649
|
+
};
|
|
650
|
+
saveState(wikiDir, state);
|
|
651
|
+
|
|
652
|
+
// No adr dir — should still run without errors
|
|
653
|
+
const result = runWikiLint({
|
|
654
|
+
wikiDir,
|
|
655
|
+
repoRoot,
|
|
656
|
+
adrDir: join(tmpDir, "nonexistent", "adr"),
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Should not throw; findings depend on git commit count (0 in test temp)
|
|
660
|
+
expect(result.missingRationale).toBeDefined();
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// ─── Check 9: Contradiction Detection ───────────────────────────────
|
|
665
|
+
|
|
666
|
+
describe("contradiction detection", () => {
|
|
667
|
+
it("should detect entity article pointing to non-existent file", () => {
|
|
668
|
+
writeArticle(
|
|
669
|
+
"entities/user.md",
|
|
670
|
+
"# User Entity\n\n<!-- source: src/models/user.ts:42 -->\n",
|
|
671
|
+
);
|
|
672
|
+
// src/models/user.ts does NOT exist
|
|
673
|
+
|
|
674
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
675
|
+
|
|
676
|
+
expect(result.contradictions.length).toBeGreaterThanOrEqual(1);
|
|
677
|
+
expect(result.contradictions[0]?.check).toBe("contradiction");
|
|
678
|
+
expect(result.contradictions[0]?.severity).toBe("warning");
|
|
679
|
+
expect(result.contradictions[0]?.message).toContain("no longer exists");
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("should detect entity article with wrong line number", () => {
|
|
683
|
+
// Create a source file with only 3 lines
|
|
684
|
+
writeSourceFile("src/tiny.ts", "line1\nline2\nline3\n");
|
|
685
|
+
|
|
686
|
+
// Entity article says line 100
|
|
687
|
+
writeArticle(
|
|
688
|
+
"entities/tiny.md",
|
|
689
|
+
"# Tiny Entity\n\n<!-- source: src/tiny.ts:100 -->\n",
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
693
|
+
|
|
694
|
+
const lineContradictions = result.contradictions.filter((f) =>
|
|
695
|
+
f.message.includes("only has"),
|
|
696
|
+
);
|
|
697
|
+
expect(lineContradictions.length).toBeGreaterThanOrEqual(1);
|
|
698
|
+
expect(lineContradictions[0]?.message).toContain("100");
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("should not flag entity with valid line reference", () => {
|
|
702
|
+
// Create a source file with 50 lines
|
|
703
|
+
const lines = Array.from(
|
|
704
|
+
{ length: 50 },
|
|
705
|
+
(_, i) => `// line ${i + 1}`,
|
|
706
|
+
).join("\n");
|
|
707
|
+
writeSourceFile("src/big.ts", lines);
|
|
708
|
+
|
|
709
|
+
writeArticle(
|
|
710
|
+
"entities/big.md",
|
|
711
|
+
"# Big Entity\n\n<!-- source: src/big.ts:10 -->\n",
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
715
|
+
|
|
716
|
+
const lineContradictions = result.contradictions.filter(
|
|
717
|
+
(f) => f.message.includes("only has") && f.message.includes("big.ts"),
|
|
718
|
+
);
|
|
719
|
+
expect(lineContradictions).toHaveLength(0);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("should detect module listing non-existent entity", () => {
|
|
723
|
+
writeArticle(
|
|
724
|
+
"modules/auth.md",
|
|
725
|
+
"# Auth Module\n\n<!-- entity: src/auth/handler.ts -->\n<!-- entity: src/auth/gone.ts -->\n",
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
// Only handler exists, gone does not
|
|
729
|
+
writeSourceFile("src/auth/handler.ts", "export function handle() {}");
|
|
730
|
+
|
|
731
|
+
const result = runWikiLint({ wikiDir, repoRoot });
|
|
732
|
+
|
|
733
|
+
const moduleContradictions = result.contradictions.filter((f) =>
|
|
734
|
+
f.message.includes("gone.ts"),
|
|
735
|
+
);
|
|
736
|
+
expect(moduleContradictions.length).toBeGreaterThanOrEqual(1);
|
|
737
|
+
expect(moduleContradictions[0]?.message).toContain("no longer exists");
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("should detect feature task status mismatch", () => {
|
|
741
|
+
const featuresDir = join(tmpDir, ".maina", "features");
|
|
742
|
+
const featureDir = join(featuresDir, "001-auth");
|
|
743
|
+
mkdirSync(featureDir, { recursive: true });
|
|
744
|
+
|
|
745
|
+
// tasks.md says T001 is incomplete
|
|
746
|
+
writeFileSync(
|
|
747
|
+
join(featureDir, "tasks.md"),
|
|
748
|
+
"# Tasks\n\n- [ ] T001: Implement login\n- [x] T002: Add tests\n",
|
|
749
|
+
);
|
|
750
|
+
writeFileSync(
|
|
751
|
+
join(featureDir, "plan.md"),
|
|
752
|
+
"# Implementation Plan: Auth\n",
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
// Wiki article says T001 is completed (contradiction)
|
|
756
|
+
writeArticle(
|
|
757
|
+
"features/auth.md",
|
|
758
|
+
"# Auth Feature\n\n<!-- feature: 001-auth -->\n\n- [x] T001: Implement login\n- [x] T002: Add tests\n",
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
const result = runWikiLint({
|
|
762
|
+
wikiDir,
|
|
763
|
+
repoRoot,
|
|
764
|
+
featuresDir,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const taskContradictions = result.contradictions.filter((f) =>
|
|
768
|
+
f.message.includes("T001"),
|
|
769
|
+
);
|
|
770
|
+
expect(taskContradictions.length).toBeGreaterThanOrEqual(1);
|
|
771
|
+
expect(taskContradictions[0]?.message).toContain("completed");
|
|
772
|
+
expect(taskContradictions[0]?.message).toContain("incomplete");
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("should handle empty wiki gracefully", () => {
|
|
776
|
+
const result = runWikiLint({
|
|
777
|
+
wikiDir: join(tmpDir, "nonexistent-wiki"),
|
|
778
|
+
repoRoot,
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
expect(result.contradictions).toHaveLength(0);
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
});
|