@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,229 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { detectCommunities } from "../louvain";
|
|
3
|
+
|
|
4
|
+
describe("Louvain Community Detection", () => {
|
|
5
|
+
describe("empty graph", () => {
|
|
6
|
+
it("should return empty communities and zero modularity", () => {
|
|
7
|
+
const adjacency = new Map<string, Set<string>>();
|
|
8
|
+
const result = detectCommunities(adjacency);
|
|
9
|
+
|
|
10
|
+
expect(result.communities.size).toBe(0);
|
|
11
|
+
expect(result.modularity).toBe(0);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("single node", () => {
|
|
16
|
+
it("should place a single node in its own community", () => {
|
|
17
|
+
const adjacency = new Map<string, Set<string>>();
|
|
18
|
+
adjacency.set("A", new Set());
|
|
19
|
+
|
|
20
|
+
const result = detectCommunities(adjacency);
|
|
21
|
+
expect(result.communities.size).toBe(1);
|
|
22
|
+
|
|
23
|
+
const allNodes = [...result.communities.values()].flat();
|
|
24
|
+
expect(allNodes).toContain("A");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("two connected nodes", () => {
|
|
29
|
+
it("should place two connected nodes in the same community", () => {
|
|
30
|
+
const adjacency = new Map<string, Set<string>>();
|
|
31
|
+
adjacency.set("A", new Set(["B"]));
|
|
32
|
+
adjacency.set("B", new Set(["A"]));
|
|
33
|
+
|
|
34
|
+
const result = detectCommunities(adjacency);
|
|
35
|
+
|
|
36
|
+
// They should be in the same community
|
|
37
|
+
const allMembers = [...result.communities.values()];
|
|
38
|
+
const communityWithA = allMembers.find((m) => m.includes("A"));
|
|
39
|
+
expect(communityWithA).toContain("B");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("known graph with two clusters", () => {
|
|
44
|
+
it("should detect two communities in a barbell graph", () => {
|
|
45
|
+
// Barbell: A-B-C fully connected, D-E-F fully connected, C-D bridge
|
|
46
|
+
const adjacency = new Map<string, Set<string>>();
|
|
47
|
+
adjacency.set("A", new Set(["B", "C"]));
|
|
48
|
+
adjacency.set("B", new Set(["A", "C"]));
|
|
49
|
+
adjacency.set("C", new Set(["A", "B", "D"]));
|
|
50
|
+
adjacency.set("D", new Set(["C", "E", "F"]));
|
|
51
|
+
adjacency.set("E", new Set(["D", "F"]));
|
|
52
|
+
adjacency.set("F", new Set(["D", "E"]));
|
|
53
|
+
|
|
54
|
+
const result = detectCommunities(adjacency);
|
|
55
|
+
|
|
56
|
+
// Should detect 2 communities
|
|
57
|
+
expect(result.communities.size).toBe(2);
|
|
58
|
+
|
|
59
|
+
// Verify each cluster is together
|
|
60
|
+
const allMembers = [...result.communities.values()];
|
|
61
|
+
const clusterWithA = allMembers.find((m) => m.includes("A"));
|
|
62
|
+
const clusterWithD = allMembers.find((m) => m.includes("D"));
|
|
63
|
+
|
|
64
|
+
expect(clusterWithA).toBeDefined();
|
|
65
|
+
expect(clusterWithD).toBeDefined();
|
|
66
|
+
expect(clusterWithA).toContain("B");
|
|
67
|
+
expect(clusterWithA).toContain("C");
|
|
68
|
+
expect(clusterWithD).toContain("E");
|
|
69
|
+
expect(clusterWithD).toContain("F");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should have positive modularity for a clustered graph", () => {
|
|
73
|
+
const adjacency = new Map<string, Set<string>>();
|
|
74
|
+
adjacency.set("A", new Set(["B", "C"]));
|
|
75
|
+
adjacency.set("B", new Set(["A", "C"]));
|
|
76
|
+
adjacency.set("C", new Set(["A", "B", "D"]));
|
|
77
|
+
adjacency.set("D", new Set(["C", "E", "F"]));
|
|
78
|
+
adjacency.set("E", new Set(["D", "F"]));
|
|
79
|
+
adjacency.set("F", new Set(["D", "E"]));
|
|
80
|
+
|
|
81
|
+
const result = detectCommunities(adjacency);
|
|
82
|
+
expect(result.modularity).toBeGreaterThan(0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("disconnected components", () => {
|
|
87
|
+
it("should handle disconnected components correctly", () => {
|
|
88
|
+
const adjacency = new Map<string, Set<string>>();
|
|
89
|
+
// Component 1: A-B
|
|
90
|
+
adjacency.set("A", new Set(["B"]));
|
|
91
|
+
adjacency.set("B", new Set(["A"]));
|
|
92
|
+
// Component 2: C-D
|
|
93
|
+
adjacency.set("C", new Set(["D"]));
|
|
94
|
+
adjacency.set("D", new Set(["C"]));
|
|
95
|
+
// Isolated node
|
|
96
|
+
adjacency.set("E", new Set());
|
|
97
|
+
|
|
98
|
+
const result = detectCommunities(adjacency);
|
|
99
|
+
|
|
100
|
+
// All nodes should be assigned to a community
|
|
101
|
+
const allNodes = [...result.communities.values()].flat();
|
|
102
|
+
expect(allNodes).toContain("A");
|
|
103
|
+
expect(allNodes).toContain("B");
|
|
104
|
+
expect(allNodes).toContain("C");
|
|
105
|
+
expect(allNodes).toContain("D");
|
|
106
|
+
expect(allNodes).toContain("E");
|
|
107
|
+
expect(allNodes).toHaveLength(5);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should keep disconnected components in separate communities", () => {
|
|
111
|
+
const adjacency = new Map<string, Set<string>>();
|
|
112
|
+
adjacency.set("A", new Set(["B"]));
|
|
113
|
+
adjacency.set("B", new Set(["A"]));
|
|
114
|
+
adjacency.set("C", new Set(["D"]));
|
|
115
|
+
adjacency.set("D", new Set(["C"]));
|
|
116
|
+
|
|
117
|
+
const result = detectCommunities(adjacency);
|
|
118
|
+
|
|
119
|
+
// A and B should be together, C and D should be together
|
|
120
|
+
const allMembers = [...result.communities.values()];
|
|
121
|
+
const withA = allMembers.find((m) => m.includes("A"));
|
|
122
|
+
const withC = allMembers.find((m) => m.includes("C"));
|
|
123
|
+
expect(withA).toContain("B");
|
|
124
|
+
expect(withC).toContain("D");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("determinism", () => {
|
|
129
|
+
it("should produce the same result on repeated runs", () => {
|
|
130
|
+
const adjacency = new Map<string, Set<string>>();
|
|
131
|
+
adjacency.set("A", new Set(["B", "C"]));
|
|
132
|
+
adjacency.set("B", new Set(["A", "C"]));
|
|
133
|
+
adjacency.set("C", new Set(["A", "B", "D"]));
|
|
134
|
+
adjacency.set("D", new Set(["C", "E"]));
|
|
135
|
+
adjacency.set("E", new Set(["D"]));
|
|
136
|
+
|
|
137
|
+
const result1 = detectCommunities(adjacency);
|
|
138
|
+
const result2 = detectCommunities(adjacency);
|
|
139
|
+
|
|
140
|
+
expect(result1.communities.size).toBe(result2.communities.size);
|
|
141
|
+
expect(result1.modularity).toBe(result2.modularity);
|
|
142
|
+
|
|
143
|
+
for (const [key, members1] of result1.communities) {
|
|
144
|
+
const members2 = result2.communities.get(key);
|
|
145
|
+
expect(members2).toBeDefined();
|
|
146
|
+
expect(members1).toEqual(members2 ?? []);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("performance", () => {
|
|
152
|
+
it("should handle 1000+ nodes in under 1 second", () => {
|
|
153
|
+
const nodeCount = 1000;
|
|
154
|
+
const adjacency = new Map<string, Set<string>>();
|
|
155
|
+
|
|
156
|
+
// Create a graph with 10 clusters of 100 nodes each
|
|
157
|
+
for (let cluster = 0; cluster < 10; cluster++) {
|
|
158
|
+
for (let i = 0; i < 100; i++) {
|
|
159
|
+
const nodeId = `node-${cluster}-${i}`;
|
|
160
|
+
const neighbors = new Set<string>();
|
|
161
|
+
|
|
162
|
+
// Connect to ~10 nodes within the cluster
|
|
163
|
+
for (let j = 0; j < 10; j++) {
|
|
164
|
+
const neighborIdx = (i + j + 1) % 100;
|
|
165
|
+
neighbors.add(`node-${cluster}-${neighborIdx}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// One cross-cluster connection
|
|
169
|
+
if (i === 0 && cluster < 9) {
|
|
170
|
+
neighbors.add(`node-${cluster + 1}-0`);
|
|
171
|
+
// Also add the reverse connection
|
|
172
|
+
const nextNode = adjacency.get(`node-${cluster + 1}-0`);
|
|
173
|
+
if (nextNode) {
|
|
174
|
+
nextNode.add(nodeId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
adjacency.set(nodeId, neighbors);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Ensure reverse edges for cross-cluster
|
|
183
|
+
for (let cluster = 0; cluster < 9; cluster++) {
|
|
184
|
+
const targetNode = adjacency.get(`node-${cluster + 1}-0`);
|
|
185
|
+
if (targetNode) {
|
|
186
|
+
targetNode.add(`node-${cluster}-0`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const start = performance.now();
|
|
191
|
+
const result = detectCommunities(adjacency);
|
|
192
|
+
const elapsed = performance.now() - start;
|
|
193
|
+
|
|
194
|
+
expect(elapsed).toBeLessThan(1000);
|
|
195
|
+
expect(result.communities.size).toBeGreaterThan(0);
|
|
196
|
+
|
|
197
|
+
// All 1000 nodes should be assigned
|
|
198
|
+
const allNodes = [...result.communities.values()].flat();
|
|
199
|
+
expect(allNodes).toHaveLength(nodeCount);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("community numbering", () => {
|
|
204
|
+
it("should number communities starting from 0", () => {
|
|
205
|
+
const adjacency = new Map<string, Set<string>>();
|
|
206
|
+
adjacency.set("A", new Set(["B"]));
|
|
207
|
+
adjacency.set("B", new Set(["A"]));
|
|
208
|
+
adjacency.set("C", new Set(["D"]));
|
|
209
|
+
adjacency.set("D", new Set(["C"]));
|
|
210
|
+
|
|
211
|
+
const result = detectCommunities(adjacency);
|
|
212
|
+
const keys = [...result.communities.keys()].sort((a, b) => a - b);
|
|
213
|
+
expect(keys[0]).toBe(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("should have contiguous community IDs", () => {
|
|
217
|
+
const adjacency = new Map<string, Set<string>>();
|
|
218
|
+
adjacency.set("A", new Set(["B"]));
|
|
219
|
+
adjacency.set("B", new Set(["A"]));
|
|
220
|
+
adjacency.set("C", new Set());
|
|
221
|
+
|
|
222
|
+
const result = detectCommunities(adjacency);
|
|
223
|
+
const keys = [...result.communities.keys()].sort((a, b) => a - b);
|
|
224
|
+
for (let i = 0; i < keys.length; i++) {
|
|
225
|
+
expect(keys[i]).toBe(i);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for wiki query — AI-powered question answering over wiki articles.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
// ── Mock AI before importing queryWiki ──────────────────────────────────
|
|
10
|
+
|
|
11
|
+
let mockAIResponse: {
|
|
12
|
+
text: string | null;
|
|
13
|
+
fromAI: boolean;
|
|
14
|
+
hostDelegation: boolean;
|
|
15
|
+
promptHash?: string;
|
|
16
|
+
} = { text: null, fromAI: false, hostDelegation: false };
|
|
17
|
+
|
|
18
|
+
mock.module(join(import.meta.dir, "..", "..", "ai", "try-generate"), () => ({
|
|
19
|
+
tryAIGenerate: async () => mockAIResponse,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const { queryWiki } = await import("../query");
|
|
23
|
+
|
|
24
|
+
// ── Test Helpers ────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
let tmpDir: string;
|
|
27
|
+
let wikiDir: string;
|
|
28
|
+
|
|
29
|
+
function createTmpDir(): string {
|
|
30
|
+
const dir = join(
|
|
31
|
+
import.meta.dir,
|
|
32
|
+
`tmp-query-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
33
|
+
);
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function seedWikiArticles(wiki: string): void {
|
|
39
|
+
const subdirs = [
|
|
40
|
+
"modules",
|
|
41
|
+
"entities",
|
|
42
|
+
"features",
|
|
43
|
+
"decisions",
|
|
44
|
+
"architecture",
|
|
45
|
+
"raw",
|
|
46
|
+
];
|
|
47
|
+
for (const subdir of subdirs) {
|
|
48
|
+
mkdirSync(join(wiki, subdir), { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
writeFileSync(
|
|
52
|
+
join(wiki, "modules", "core.md"),
|
|
53
|
+
[
|
|
54
|
+
"# Core Module",
|
|
55
|
+
"",
|
|
56
|
+
"The core module provides authentication and caching functionality.",
|
|
57
|
+
"",
|
|
58
|
+
"## Exports",
|
|
59
|
+
"",
|
|
60
|
+
"- `authenticate` (function)",
|
|
61
|
+
"- `CacheManager` (class)",
|
|
62
|
+
"- `Config` (interface)",
|
|
63
|
+
].join("\n"),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
writeFileSync(
|
|
67
|
+
join(wiki, "entities", "user.md"),
|
|
68
|
+
[
|
|
69
|
+
"# User",
|
|
70
|
+
"",
|
|
71
|
+
"**Kind:** interface",
|
|
72
|
+
"**File:** `packages/core/src/user.ts:5`",
|
|
73
|
+
"",
|
|
74
|
+
"Represents an authenticated user in the system.",
|
|
75
|
+
"Contains email, role, and session token fields.",
|
|
76
|
+
].join("\n"),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(wiki, "features", "login-flow.md"),
|
|
81
|
+
[
|
|
82
|
+
"# Login Flow",
|
|
83
|
+
"",
|
|
84
|
+
"## Acceptance Criteria",
|
|
85
|
+
"",
|
|
86
|
+
"- Users can log in with email and password",
|
|
87
|
+
"- JWT tokens are issued on success",
|
|
88
|
+
"- Failed attempts are rate-limited",
|
|
89
|
+
].join("\n"),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
writeFileSync(
|
|
93
|
+
join(wiki, "decisions", "use-jwt.md"),
|
|
94
|
+
[
|
|
95
|
+
"# Use JWT for Authentication",
|
|
96
|
+
"",
|
|
97
|
+
"**Status:** accepted",
|
|
98
|
+
"",
|
|
99
|
+
"## Context",
|
|
100
|
+
"",
|
|
101
|
+
"We need stateless authentication for the API.",
|
|
102
|
+
"",
|
|
103
|
+
"## Decision",
|
|
104
|
+
"",
|
|
105
|
+
"Use JWT tokens with short expiry and refresh tokens.",
|
|
106
|
+
].join("\n"),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(wiki, "architecture", "overview.md"),
|
|
111
|
+
[
|
|
112
|
+
"# Architecture Overview",
|
|
113
|
+
"",
|
|
114
|
+
"The system uses a layered architecture with three engines:",
|
|
115
|
+
"Context, Prompt, and Verify.",
|
|
116
|
+
].join("\n"),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
tmpDir = createTmpDir();
|
|
122
|
+
wikiDir = join(tmpDir, ".maina", "wiki");
|
|
123
|
+
// Default: AI unavailable
|
|
124
|
+
mockAIResponse = { text: null, fromAI: false, hostDelegation: false };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
try {
|
|
129
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
130
|
+
} catch {
|
|
131
|
+
// ignore
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
describe("queryWiki", () => {
|
|
138
|
+
test("returns not-initialized message when wiki dir missing", async () => {
|
|
139
|
+
const result = await queryWiki({
|
|
140
|
+
wikiDir: join(tmpDir, "nonexistent"),
|
|
141
|
+
question: "test",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.ok).toBe(true);
|
|
145
|
+
if (result.ok) {
|
|
146
|
+
expect(result.value.answer).toContain("not initialized");
|
|
147
|
+
expect(result.value.sources).toHaveLength(0);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("returns empty message when wiki has no articles", async () => {
|
|
152
|
+
mkdirSync(join(wikiDir, "modules"), { recursive: true });
|
|
153
|
+
|
|
154
|
+
const result = await queryWiki({ wikiDir, question: "anything" });
|
|
155
|
+
|
|
156
|
+
expect(result.ok).toBe(true);
|
|
157
|
+
if (result.ok) {
|
|
158
|
+
expect(result.value.answer).toContain("empty");
|
|
159
|
+
expect(result.value.sources).toHaveLength(0);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("returns no-match message for unrelated query", async () => {
|
|
164
|
+
seedWikiArticles(wikiDir);
|
|
165
|
+
|
|
166
|
+
const result = await queryWiki({
|
|
167
|
+
wikiDir,
|
|
168
|
+
question: "quantum computing blockchain",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result.ok).toBe(true);
|
|
172
|
+
if (result.ok) {
|
|
173
|
+
expect(result.value.answer).toContain("No articles match");
|
|
174
|
+
expect(result.value.sources).toHaveLength(0);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("falls back to keyword excerpts when AI unavailable", async () => {
|
|
179
|
+
seedWikiArticles(wikiDir);
|
|
180
|
+
mockAIResponse = { text: null, fromAI: false, hostDelegation: false };
|
|
181
|
+
|
|
182
|
+
const result = await queryWiki({
|
|
183
|
+
wikiDir,
|
|
184
|
+
question: "authentication JWT",
|
|
185
|
+
repoRoot: tmpDir,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(result.ok).toBe(true);
|
|
189
|
+
if (result.ok) {
|
|
190
|
+
expect(result.value.sources.length).toBeGreaterThan(0);
|
|
191
|
+
expect(result.value.answer).toContain("keyword match");
|
|
192
|
+
expect(result.value.cached).toBe(false);
|
|
193
|
+
// Should find JWT decision or core module
|
|
194
|
+
const hasAuthRelated = result.value.sources.some(
|
|
195
|
+
(s) => s.includes("use-jwt") || s.includes("core"),
|
|
196
|
+
);
|
|
197
|
+
expect(hasAuthRelated).toBe(true);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("returns synthesized answer when AI is available", async () => {
|
|
202
|
+
seedWikiArticles(wikiDir);
|
|
203
|
+
mockAIResponse = {
|
|
204
|
+
text: "Authentication uses JWT tokens as described in [[decisions/use-jwt.md]]. The core module provides the authenticate function [[modules/core.md]].",
|
|
205
|
+
fromAI: true,
|
|
206
|
+
hostDelegation: false,
|
|
207
|
+
promptHash: "abc123",
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const result = await queryWiki({
|
|
211
|
+
wikiDir,
|
|
212
|
+
question: "how does authentication work?",
|
|
213
|
+
repoRoot: tmpDir,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(result.ok).toBe(true);
|
|
217
|
+
if (result.ok) {
|
|
218
|
+
expect(result.value.answer).toContain("JWT");
|
|
219
|
+
expect(result.value.answer).toContain("[[decisions/use-jwt.md]]");
|
|
220
|
+
expect(result.value.sources.length).toBeGreaterThan(0);
|
|
221
|
+
expect(result.value.cached).toBe(false);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("respects maxArticles limit", async () => {
|
|
226
|
+
seedWikiArticles(wikiDir);
|
|
227
|
+
mockAIResponse = { text: null, fromAI: false, hostDelegation: false };
|
|
228
|
+
|
|
229
|
+
// Add many more articles to exceed limit
|
|
230
|
+
for (let i = 0; i < 15; i++) {
|
|
231
|
+
writeFileSync(
|
|
232
|
+
join(wikiDir, "entities", `entity-${i}.md`),
|
|
233
|
+
`# Entity ${i}\n\nThis entity handles authentication task ${i}.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const result = await queryWiki({
|
|
238
|
+
wikiDir,
|
|
239
|
+
question: "authentication",
|
|
240
|
+
maxArticles: 3,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(result.ok).toBe(true);
|
|
244
|
+
if (result.ok) {
|
|
245
|
+
// Sources should be limited to maxArticles
|
|
246
|
+
expect(result.value.sources.length).toBeLessThanOrEqual(3);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("returns cached false for non-cached results", async () => {
|
|
251
|
+
seedWikiArticles(wikiDir);
|
|
252
|
+
|
|
253
|
+
const result = await queryWiki({
|
|
254
|
+
wikiDir,
|
|
255
|
+
question: "authentication",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(result.ok).toBe(true);
|
|
259
|
+
if (result.ok) {
|
|
260
|
+
expect(result.value.cached).toBe(false);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("handles AI error gracefully with fallback", async () => {
|
|
265
|
+
seedWikiArticles(wikiDir);
|
|
266
|
+
|
|
267
|
+
// Override mock to throw — re-mock the module
|
|
268
|
+
mock.module(
|
|
269
|
+
join(import.meta.dir, "..", "..", "ai", "try-generate"),
|
|
270
|
+
() => ({
|
|
271
|
+
tryAIGenerate: async () => {
|
|
272
|
+
throw new Error("API key invalid");
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const result = await queryWiki({
|
|
278
|
+
wikiDir,
|
|
279
|
+
question: "authentication JWT",
|
|
280
|
+
repoRoot: tmpDir,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(result.ok).toBe(true);
|
|
284
|
+
if (result.ok) {
|
|
285
|
+
// Should still return fallback results
|
|
286
|
+
expect(result.value.sources.length).toBeGreaterThan(0);
|
|
287
|
+
expect(result.value.answer).toContain("keyword match");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Restore normal mock
|
|
291
|
+
mock.module(
|
|
292
|
+
join(import.meta.dir, "..", "..", "ai", "try-generate"),
|
|
293
|
+
() => ({
|
|
294
|
+
tryAIGenerate: async () => mockAIResponse,
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("scores title matches higher than content matches", async () => {
|
|
300
|
+
seedWikiArticles(wikiDir);
|
|
301
|
+
mockAIResponse = { text: null, fromAI: false, hostDelegation: false };
|
|
302
|
+
|
|
303
|
+
const result = await queryWiki({
|
|
304
|
+
wikiDir,
|
|
305
|
+
question: "User",
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(result.ok).toBe(true);
|
|
309
|
+
if (result.ok) {
|
|
310
|
+
// "User" entity should rank high since it matches the title
|
|
311
|
+
if (result.value.sources.length > 0) {
|
|
312
|
+
expect(result.value.sources[0]).toContain("user");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_SCHEMA,
|
|
4
|
+
getArticleMaxLength,
|
|
5
|
+
getLinkSyntax,
|
|
6
|
+
validateArticleStructure,
|
|
7
|
+
} from "../schema";
|
|
8
|
+
import type { ArticleType } from "../types";
|
|
9
|
+
|
|
10
|
+
describe("Wiki Schema", () => {
|
|
11
|
+
describe("DEFAULT_SCHEMA", () => {
|
|
12
|
+
it("should define rules for all 6 article types", () => {
|
|
13
|
+
const types: ArticleType[] = [
|
|
14
|
+
"module",
|
|
15
|
+
"entity",
|
|
16
|
+
"feature",
|
|
17
|
+
"decision",
|
|
18
|
+
"architecture",
|
|
19
|
+
"raw",
|
|
20
|
+
];
|
|
21
|
+
for (const type of types) {
|
|
22
|
+
expect(DEFAULT_SCHEMA.articleRules[type]).toBeDefined();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should have a schema version", () => {
|
|
27
|
+
expect(DEFAULT_SCHEMA.version).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("getArticleMaxLength", () => {
|
|
32
|
+
it("should return max length for each article type", () => {
|
|
33
|
+
expect(getArticleMaxLength("module")).toBeGreaterThan(0);
|
|
34
|
+
expect(getArticleMaxLength("entity")).toBeGreaterThan(0);
|
|
35
|
+
expect(getArticleMaxLength("feature")).toBeGreaterThan(0);
|
|
36
|
+
expect(getArticleMaxLength("decision")).toBeGreaterThan(0);
|
|
37
|
+
expect(getArticleMaxLength("architecture")).toBeGreaterThan(0);
|
|
38
|
+
expect(getArticleMaxLength("raw")).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should have entities shorter than modules", () => {
|
|
42
|
+
expect(getArticleMaxLength("entity")).toBeLessThanOrEqual(
|
|
43
|
+
getArticleMaxLength("module"),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("getLinkSyntax", () => {
|
|
49
|
+
it("should return wikilink format for entities", () => {
|
|
50
|
+
expect(getLinkSyntax("entity", "runPipeline")).toBe(
|
|
51
|
+
"[[entity:runPipeline]]",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should return wikilink format for features", () => {
|
|
56
|
+
expect(getLinkSyntax("feature", "001-auth")).toBe("[[feature:001-auth]]");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should return wikilink format for decisions", () => {
|
|
60
|
+
expect(getLinkSyntax("decision", "002-jwt")).toBe("[[decision:002-jwt]]");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should return wikilink format for modules", () => {
|
|
64
|
+
expect(getLinkSyntax("module", "auth")).toBe("[[module:auth]]");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should return wikilink format for architecture", () => {
|
|
68
|
+
expect(getLinkSyntax("architecture", "verify-pipeline")).toBe(
|
|
69
|
+
"[[architecture:verify-pipeline]]",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("validateArticleStructure", () => {
|
|
75
|
+
it("should pass for a valid module article", () => {
|
|
76
|
+
const content = [
|
|
77
|
+
"# Auth Module",
|
|
78
|
+
"",
|
|
79
|
+
"## Overview",
|
|
80
|
+
"Handles authentication.",
|
|
81
|
+
"",
|
|
82
|
+
"## Entities",
|
|
83
|
+
"- jwt.ts",
|
|
84
|
+
"",
|
|
85
|
+
"## Dependencies",
|
|
86
|
+
"- crypto module",
|
|
87
|
+
].join("\n");
|
|
88
|
+
|
|
89
|
+
const result = validateArticleStructure("module", content);
|
|
90
|
+
expect(result.valid).toBe(true);
|
|
91
|
+
expect(result.issues).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should fail for article missing title", () => {
|
|
95
|
+
const content = "No title here, just text.";
|
|
96
|
+
const result = validateArticleStructure("entity", content);
|
|
97
|
+
expect(result.valid).toBe(false);
|
|
98
|
+
expect(result.issues.length).toBeGreaterThan(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should fail for article exceeding max length", () => {
|
|
102
|
+
const content = `# Long Article\n${"x".repeat(50_000)}`;
|
|
103
|
+
const result = validateArticleStructure("entity", content);
|
|
104
|
+
expect(result.valid).toBe(false);
|
|
105
|
+
expect(result.issues.some((i) => i.includes("length"))).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should pass for empty content with just a title", () => {
|
|
109
|
+
const content = "# Minimal Entity";
|
|
110
|
+
const result = validateArticleStructure("entity", content);
|
|
111
|
+
expect(result.valid).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|