@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.
Files changed (71) hide show
  1. package/package.json +1 -1
  2. package/src/ai/__tests__/delegation.test.ts +55 -1
  3. package/src/ai/delegation.ts +5 -3
  4. package/src/context/__tests__/budget.test.ts +29 -6
  5. package/src/context/__tests__/engine.test.ts +1 -0
  6. package/src/context/__tests__/selector.test.ts +23 -3
  7. package/src/context/__tests__/wiki.test.ts +349 -0
  8. package/src/context/budget.ts +12 -8
  9. package/src/context/engine.ts +37 -0
  10. package/src/context/selector.ts +30 -4
  11. package/src/context/wiki.ts +296 -0
  12. package/src/db/index.ts +12 -0
  13. package/src/feedback/__tests__/capture.test.ts +166 -0
  14. package/src/feedback/__tests__/signals.test.ts +144 -0
  15. package/src/feedback/__tests__/tmp-capture-1775575256633-lah0etnzlj/feedback.db +0 -0
  16. package/src/feedback/__tests__/tmp-capture-1775575256640-2xmjme4qraa/feedback.db +0 -0
  17. package/src/feedback/capture.ts +102 -0
  18. package/src/feedback/signals.ts +68 -0
  19. package/src/index.ts +104 -0
  20. package/src/init/__tests__/init.test.ts +400 -3
  21. package/src/init/index.ts +368 -12
  22. package/src/language/__tests__/__fixtures__/detect/composer.lock +1 -0
  23. package/src/prompts/defaults/index.ts +3 -1
  24. package/src/prompts/defaults/wiki-compile.md +20 -0
  25. package/src/prompts/defaults/wiki-query.md +18 -0
  26. package/src/stats/__tests__/tool-usage.test.ts +133 -0
  27. package/src/stats/tracker.ts +92 -0
  28. package/src/verify/__tests__/pipeline.test.ts +11 -8
  29. package/src/verify/pipeline.ts +13 -1
  30. package/src/verify/tools/__tests__/wiki-lint.test.ts +784 -0
  31. package/src/verify/tools/wiki-lint-runner.ts +38 -0
  32. package/src/verify/tools/wiki-lint.ts +898 -0
  33. package/src/wiki/__tests__/compiler.test.ts +389 -0
  34. package/src/wiki/__tests__/extractors/code.test.ts +99 -0
  35. package/src/wiki/__tests__/extractors/decision.test.ts +323 -0
  36. package/src/wiki/__tests__/extractors/feature.test.ts +186 -0
  37. package/src/wiki/__tests__/extractors/workflow.test.ts +131 -0
  38. package/src/wiki/__tests__/graph.test.ts +344 -0
  39. package/src/wiki/__tests__/hooks.test.ts +119 -0
  40. package/src/wiki/__tests__/indexer.test.ts +285 -0
  41. package/src/wiki/__tests__/linker.test.ts +230 -0
  42. package/src/wiki/__tests__/louvain.test.ts +229 -0
  43. package/src/wiki/__tests__/query.test.ts +316 -0
  44. package/src/wiki/__tests__/schema.test.ts +114 -0
  45. package/src/wiki/__tests__/signals.test.ts +474 -0
  46. package/src/wiki/__tests__/state.test.ts +168 -0
  47. package/src/wiki/__tests__/tracking.test.ts +118 -0
  48. package/src/wiki/__tests__/types.test.ts +387 -0
  49. package/src/wiki/compiler.ts +1075 -0
  50. package/src/wiki/extractors/code.ts +90 -0
  51. package/src/wiki/extractors/decision.ts +217 -0
  52. package/src/wiki/extractors/feature.ts +206 -0
  53. package/src/wiki/extractors/workflow.ts +112 -0
  54. package/src/wiki/graph.ts +445 -0
  55. package/src/wiki/hooks.ts +49 -0
  56. package/src/wiki/indexer.ts +105 -0
  57. package/src/wiki/linker.ts +117 -0
  58. package/src/wiki/louvain.ts +190 -0
  59. package/src/wiki/prompts/compile-architecture.md +59 -0
  60. package/src/wiki/prompts/compile-decision.md +66 -0
  61. package/src/wiki/prompts/compile-entity.md +56 -0
  62. package/src/wiki/prompts/compile-feature.md +60 -0
  63. package/src/wiki/prompts/compile-module.md +42 -0
  64. package/src/wiki/prompts/wiki-query.md +25 -0
  65. package/src/wiki/query.ts +338 -0
  66. package/src/wiki/schema.ts +111 -0
  67. package/src/wiki/signals.ts +368 -0
  68. package/src/wiki/state.ts +89 -0
  69. package/src/wiki/tracking.ts +30 -0
  70. package/src/wiki/types.ts +169 -0
  71. 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
+ });