@mainahq/core 1.0.3 → 1.1.1
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,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Graph — unified graph with 11 edge types for wiki compilation.
|
|
3
|
+
*
|
|
4
|
+
* Builds a graph from code entities, features, decisions, and workflow traces.
|
|
5
|
+
* Supports PageRank computation and mapping graph nodes to wiki article paths.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CodeEntity } from "./extractors/code";
|
|
9
|
+
import type {
|
|
10
|
+
EdgeType,
|
|
11
|
+
ExtractedDecision,
|
|
12
|
+
ExtractedFeature,
|
|
13
|
+
ExtractedWorkflowTrace,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface GraphNode {
|
|
19
|
+
id: string;
|
|
20
|
+
type: "entity" | "module" | "feature" | "decision" | "workflow";
|
|
21
|
+
label: string;
|
|
22
|
+
file?: string;
|
|
23
|
+
pageRank: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GraphEdge {
|
|
27
|
+
source: string;
|
|
28
|
+
target: string;
|
|
29
|
+
type: EdgeType;
|
|
30
|
+
weight: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface KnowledgeGraph {
|
|
34
|
+
nodes: Map<string, GraphNode>;
|
|
35
|
+
edges: GraphEdge[];
|
|
36
|
+
adjacency: Map<string, Set<string>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Graph Construction ─────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function addNode(graph: KnowledgeGraph, node: GraphNode): void {
|
|
42
|
+
if (!graph.nodes.has(node.id)) {
|
|
43
|
+
graph.nodes.set(node.id, node);
|
|
44
|
+
}
|
|
45
|
+
if (!graph.adjacency.has(node.id)) {
|
|
46
|
+
graph.adjacency.set(node.id, new Set<string>());
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function addEdge(graph: KnowledgeGraph, edge: GraphEdge): void {
|
|
51
|
+
graph.edges.push(edge);
|
|
52
|
+
|
|
53
|
+
// Ensure both nodes are in adjacency
|
|
54
|
+
if (!graph.adjacency.has(edge.source)) {
|
|
55
|
+
graph.adjacency.set(edge.source, new Set<string>());
|
|
56
|
+
}
|
|
57
|
+
if (!graph.adjacency.has(edge.target)) {
|
|
58
|
+
graph.adjacency.set(edge.target, new Set<string>());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
graph.adjacency.get(edge.source)?.add(edge.target);
|
|
62
|
+
graph.adjacency.get(edge.target)?.add(edge.source);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Derive a module name from a file path.
|
|
67
|
+
* "packages/core/src/wiki/state.ts" -> "wiki"
|
|
68
|
+
* "src/auth/jwt.ts" -> "auth"
|
|
69
|
+
*/
|
|
70
|
+
function deriveModule(file: string): string {
|
|
71
|
+
const parts = file.replace(/\\/g, "/").split("/");
|
|
72
|
+
// Find the directory containing the file
|
|
73
|
+
if (parts.length >= 2) {
|
|
74
|
+
return parts[parts.length - 2] ?? "root";
|
|
75
|
+
}
|
|
76
|
+
return "root";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function addCodeEntities(graph: KnowledgeGraph, entities: CodeEntity[]): void {
|
|
80
|
+
// Group entities by module (directory)
|
|
81
|
+
const moduleEntities = new Map<string, CodeEntity[]>();
|
|
82
|
+
|
|
83
|
+
for (const entity of entities) {
|
|
84
|
+
const moduleName = deriveModule(entity.file);
|
|
85
|
+
|
|
86
|
+
// Add entity node
|
|
87
|
+
const entityId = `entity:${entity.name}`;
|
|
88
|
+
addNode(graph, {
|
|
89
|
+
id: entityId,
|
|
90
|
+
type: "entity",
|
|
91
|
+
label: entity.name,
|
|
92
|
+
file: entity.file,
|
|
93
|
+
pageRank: 0,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Track module membership
|
|
97
|
+
const list = moduleEntities.get(moduleName) ?? [];
|
|
98
|
+
list.push(entity);
|
|
99
|
+
moduleEntities.set(moduleName, list);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Create module nodes and member_of edges
|
|
103
|
+
for (const [moduleName, members] of moduleEntities) {
|
|
104
|
+
const moduleId = `module:${moduleName}`;
|
|
105
|
+
addNode(graph, {
|
|
106
|
+
id: moduleId,
|
|
107
|
+
type: "module",
|
|
108
|
+
label: moduleName,
|
|
109
|
+
pageRank: 0,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
for (const member of members) {
|
|
113
|
+
addEdge(graph, {
|
|
114
|
+
source: `entity:${member.name}`,
|
|
115
|
+
target: moduleId,
|
|
116
|
+
type: "member_of",
|
|
117
|
+
weight: 1.0,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Add imports edges between entities in the same file
|
|
122
|
+
// (simplified: entities from the same file reference each other)
|
|
123
|
+
const fileGroups = new Map<string, CodeEntity[]>();
|
|
124
|
+
for (const member of members) {
|
|
125
|
+
const fg = fileGroups.get(member.file) ?? [];
|
|
126
|
+
fg.push(member);
|
|
127
|
+
fileGroups.set(member.file, fg);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const fileEntities of fileGroups.values()) {
|
|
131
|
+
for (let i = 0; i < fileEntities.length; i++) {
|
|
132
|
+
for (let j = i + 1; j < fileEntities.length; j++) {
|
|
133
|
+
const a = fileEntities[i];
|
|
134
|
+
const b = fileEntities[j];
|
|
135
|
+
if (a && b) {
|
|
136
|
+
addEdge(graph, {
|
|
137
|
+
source: `entity:${a.name}`,
|
|
138
|
+
target: `entity:${b.name}`,
|
|
139
|
+
type: "references",
|
|
140
|
+
weight: 0.5,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function addFeatures(
|
|
150
|
+
graph: KnowledgeGraph,
|
|
151
|
+
features: ExtractedFeature[],
|
|
152
|
+
): void {
|
|
153
|
+
for (const feature of features) {
|
|
154
|
+
const featureId = `feature:${feature.id}`;
|
|
155
|
+
addNode(graph, {
|
|
156
|
+
id: featureId,
|
|
157
|
+
type: "feature",
|
|
158
|
+
label: feature.title || feature.id,
|
|
159
|
+
pageRank: 0,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Link to modified entities
|
|
163
|
+
for (const entityName of feature.entitiesModified) {
|
|
164
|
+
const entityId = `entity:${entityName}`;
|
|
165
|
+
if (graph.nodes.has(entityId)) {
|
|
166
|
+
addEdge(graph, {
|
|
167
|
+
source: entityId,
|
|
168
|
+
target: featureId,
|
|
169
|
+
type: "modified_by",
|
|
170
|
+
weight: 1.0,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Link to created decisions
|
|
176
|
+
for (const decisionName of feature.decisionsCreated) {
|
|
177
|
+
const decisionId = `decision:${decisionName}`;
|
|
178
|
+
if (graph.nodes.has(decisionId)) {
|
|
179
|
+
addEdge(graph, {
|
|
180
|
+
source: featureId,
|
|
181
|
+
target: decisionId,
|
|
182
|
+
type: "motivated_by",
|
|
183
|
+
weight: 0.8,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Spec assertions create specified_by edges
|
|
189
|
+
if (feature.specAssertions.length > 0) {
|
|
190
|
+
addEdge(graph, {
|
|
191
|
+
source: featureId,
|
|
192
|
+
target: featureId,
|
|
193
|
+
type: "specified_by",
|
|
194
|
+
weight: 0.3,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function addDecisions(
|
|
201
|
+
graph: KnowledgeGraph,
|
|
202
|
+
decisions: ExtractedDecision[],
|
|
203
|
+
): void {
|
|
204
|
+
for (const decision of decisions) {
|
|
205
|
+
const decisionId = `decision:${decision.id}`;
|
|
206
|
+
addNode(graph, {
|
|
207
|
+
id: decisionId,
|
|
208
|
+
type: "decision",
|
|
209
|
+
label: decision.title || decision.id,
|
|
210
|
+
pageRank: 0,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Link to entity mentions
|
|
214
|
+
for (const mention of decision.entityMentions) {
|
|
215
|
+
// Try to find the entity by matching the end of the path
|
|
216
|
+
for (const [nodeId, node] of graph.nodes) {
|
|
217
|
+
if (node.type === "entity" && node.file === mention) {
|
|
218
|
+
addEdge(graph, {
|
|
219
|
+
source: nodeId,
|
|
220
|
+
target: decisionId,
|
|
221
|
+
type: "decided_by",
|
|
222
|
+
weight: 0.8,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Constitution alignment creates aligns_with edges
|
|
229
|
+
for (const alignment of decision.constitutionAlignment) {
|
|
230
|
+
if (alignment) {
|
|
231
|
+
addEdge(graph, {
|
|
232
|
+
source: decisionId,
|
|
233
|
+
target: decisionId,
|
|
234
|
+
type: "aligns_with",
|
|
235
|
+
weight: 0.2,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// If decision constrains entities, add constrains edges
|
|
241
|
+
if (decision.status === "accepted") {
|
|
242
|
+
for (const mention of decision.entityMentions) {
|
|
243
|
+
for (const [nodeId, node] of graph.nodes) {
|
|
244
|
+
if (node.type === "entity" && node.file === mention) {
|
|
245
|
+
addEdge(graph, {
|
|
246
|
+
source: decisionId,
|
|
247
|
+
target: nodeId,
|
|
248
|
+
type: "constrains",
|
|
249
|
+
weight: 0.6,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function addWorkflowTraces(
|
|
259
|
+
graph: KnowledgeGraph,
|
|
260
|
+
traces: ExtractedWorkflowTrace[],
|
|
261
|
+
): void {
|
|
262
|
+
for (const trace of traces) {
|
|
263
|
+
if (!trace.featureId) continue;
|
|
264
|
+
|
|
265
|
+
const workflowId = `workflow:${trace.featureId}`;
|
|
266
|
+
addNode(graph, {
|
|
267
|
+
id: workflowId,
|
|
268
|
+
type: "workflow",
|
|
269
|
+
label: `Workflow: ${trace.featureId}`,
|
|
270
|
+
pageRank: 0,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Link to the feature
|
|
274
|
+
const featureId = `feature:${trace.featureId}`;
|
|
275
|
+
if (graph.nodes.has(featureId)) {
|
|
276
|
+
addEdge(graph, {
|
|
277
|
+
source: workflowId,
|
|
278
|
+
target: featureId,
|
|
279
|
+
type: "references",
|
|
280
|
+
weight: 0.5,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Public API ──────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build a unified knowledge graph from all extracted data.
|
|
290
|
+
*/
|
|
291
|
+
export function buildKnowledgeGraph(
|
|
292
|
+
entities: CodeEntity[],
|
|
293
|
+
features: ExtractedFeature[],
|
|
294
|
+
decisions: ExtractedDecision[],
|
|
295
|
+
traces: ExtractedWorkflowTrace[],
|
|
296
|
+
): KnowledgeGraph {
|
|
297
|
+
const graph: KnowledgeGraph = {
|
|
298
|
+
nodes: new Map(),
|
|
299
|
+
edges: [],
|
|
300
|
+
adjacency: new Map(),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
addCodeEntities(graph, entities);
|
|
304
|
+
addDecisions(graph, decisions);
|
|
305
|
+
addFeatures(graph, features);
|
|
306
|
+
addWorkflowTraces(graph, traces);
|
|
307
|
+
|
|
308
|
+
return graph;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Compute PageRank scores for all nodes in the graph.
|
|
313
|
+
* Uses iterative power method with damping factor 0.85.
|
|
314
|
+
*/
|
|
315
|
+
export function computePageRank(
|
|
316
|
+
graph: KnowledgeGraph,
|
|
317
|
+
iterations = 20,
|
|
318
|
+
): Map<string, number> {
|
|
319
|
+
const nodeIds = [...graph.nodes.keys()];
|
|
320
|
+
const n = nodeIds.length;
|
|
321
|
+
|
|
322
|
+
if (n === 0) return new Map();
|
|
323
|
+
|
|
324
|
+
const damping = 0.85;
|
|
325
|
+
const scores = new Map<string, number>();
|
|
326
|
+
|
|
327
|
+
// Initialize with uniform distribution
|
|
328
|
+
for (const id of nodeIds) {
|
|
329
|
+
scores.set(id, 1 / n);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Build outgoing edge map
|
|
333
|
+
const outgoing = new Map<string, string[]>();
|
|
334
|
+
for (const edge of graph.edges) {
|
|
335
|
+
const list = outgoing.get(edge.source) ?? [];
|
|
336
|
+
list.push(edge.target);
|
|
337
|
+
outgoing.set(edge.source, list);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Iterative computation
|
|
341
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
342
|
+
const newScores = new Map<string, number>();
|
|
343
|
+
|
|
344
|
+
for (const id of nodeIds) {
|
|
345
|
+
newScores.set(id, (1 - damping) / n);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
for (const id of nodeIds) {
|
|
349
|
+
const outs = outgoing.get(id) ?? [];
|
|
350
|
+
if (outs.length === 0) {
|
|
351
|
+
// Dangling node: distribute evenly
|
|
352
|
+
const share = (scores.get(id) ?? 0) / n;
|
|
353
|
+
for (const target of nodeIds) {
|
|
354
|
+
newScores.set(target, (newScores.get(target) ?? 0) + damping * share);
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
const share = (scores.get(id) ?? 0) / outs.length;
|
|
358
|
+
for (const target of outs) {
|
|
359
|
+
newScores.set(target, (newScores.get(target) ?? 0) + damping * share);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Update scores
|
|
365
|
+
for (const [id, score] of newScores) {
|
|
366
|
+
scores.set(id, score);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Update graph nodes with computed PageRank
|
|
371
|
+
for (const [id, score] of scores) {
|
|
372
|
+
const node = graph.nodes.get(id);
|
|
373
|
+
if (node) {
|
|
374
|
+
node.pageRank = score;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return scores;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Map graph nodes to wiki article paths based on PageRank and community assignment.
|
|
383
|
+
* - Top 20% PageRank entities -> wiki/entities/
|
|
384
|
+
* - Louvain clusters -> wiki/modules/
|
|
385
|
+
* - Features -> wiki/features/
|
|
386
|
+
* - Decisions -> wiki/decisions/
|
|
387
|
+
*/
|
|
388
|
+
export function mapToArticles(
|
|
389
|
+
graph: KnowledgeGraph,
|
|
390
|
+
communities: Map<number, string[]>,
|
|
391
|
+
): Map<string, string> {
|
|
392
|
+
const articleMap = new Map<string, string>();
|
|
393
|
+
|
|
394
|
+
// Compute PageRank threshold for top 20%
|
|
395
|
+
const entityNodes = [...graph.nodes.entries()]
|
|
396
|
+
.filter(([, node]) => node.type === "entity")
|
|
397
|
+
.sort(([, a], [, b]) => b.pageRank - a.pageRank);
|
|
398
|
+
|
|
399
|
+
const top20Idx = Math.max(1, Math.ceil(entityNodes.length * 0.2));
|
|
400
|
+
const threshold =
|
|
401
|
+
entityNodes.length > 0
|
|
402
|
+
? (entityNodes[Math.min(top20Idx - 1, entityNodes.length - 1)]?.[1]
|
|
403
|
+
?.pageRank ?? 0)
|
|
404
|
+
: 0;
|
|
405
|
+
|
|
406
|
+
// Map top 20% entities
|
|
407
|
+
for (const [id, node] of entityNodes) {
|
|
408
|
+
if (node.pageRank >= threshold && threshold > 0) {
|
|
409
|
+
const safeName = node.label.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
410
|
+
articleMap.set(id, `wiki/entities/${safeName}.md`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Map Louvain clusters to module articles
|
|
415
|
+
for (const [commId, members] of communities) {
|
|
416
|
+
// Find a representative label for the community
|
|
417
|
+
const moduleNodes = members.filter(
|
|
418
|
+
(m) => graph.nodes.get(m)?.type === "module",
|
|
419
|
+
);
|
|
420
|
+
const label =
|
|
421
|
+
moduleNodes.length > 0
|
|
422
|
+
? (graph.nodes.get(moduleNodes[0] ?? "")?.label ?? `cluster-${commId}`)
|
|
423
|
+
: `cluster-${commId}`;
|
|
424
|
+
const safeName = label.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
425
|
+
articleMap.set(`community:${commId}`, `wiki/modules/${safeName}.md`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Map features
|
|
429
|
+
for (const [id, node] of graph.nodes) {
|
|
430
|
+
if (node.type === "feature") {
|
|
431
|
+
const safeName = node.label.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
432
|
+
articleMap.set(id, `wiki/features/${safeName}.md`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Map decisions
|
|
437
|
+
for (const [id, node] of graph.nodes) {
|
|
438
|
+
if (node.type === "decision") {
|
|
439
|
+
const safeName = node.label.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
440
|
+
articleMap.set(id, `wiki/decisions/${safeName}.md`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return articleMap;
|
|
445
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiki Post-Commit Hook — triggers incremental compilation after commits.
|
|
3
|
+
*
|
|
4
|
+
* Runs silently in the background. Never breaks the commit flow —
|
|
5
|
+
* all errors are caught and swallowed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { compile } from "./compiler";
|
|
11
|
+
import { loadState } from "./state";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Post-commit hook for wiki incremental compilation.
|
|
15
|
+
*
|
|
16
|
+
* Checks if the wiki has been initialized (has .state.json),
|
|
17
|
+
* then runs an incremental compile. Swallows all errors to
|
|
18
|
+
* never break the commit flow.
|
|
19
|
+
*/
|
|
20
|
+
export async function onPostCommit(
|
|
21
|
+
mainaDir: string,
|
|
22
|
+
repoRoot: string,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
try {
|
|
25
|
+
const wikiDir = join(mainaDir, "wiki");
|
|
26
|
+
|
|
27
|
+
// Skip if wiki directory does not exist
|
|
28
|
+
if (!existsSync(wikiDir)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Skip if wiki has not been initialized (no .state.json)
|
|
33
|
+
const state = loadState(wikiDir);
|
|
34
|
+
if (!state) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Run incremental compilation (not full)
|
|
39
|
+
await compile({
|
|
40
|
+
repoRoot,
|
|
41
|
+
mainaDir,
|
|
42
|
+
wikiDir,
|
|
43
|
+
full: false,
|
|
44
|
+
dryRun: false,
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
// Swallow all errors — never break the commit flow
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiki Indexer — generates the index.md table of contents for the wiki.
|
|
3
|
+
*
|
|
4
|
+
* Groups articles by type, sorts by PageRank within each group,
|
|
5
|
+
* and includes freshness indicators based on last compilation time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ArticleType, WikiArticle } from "./types";
|
|
9
|
+
|
|
10
|
+
// ─── Freshness Indicators ───────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compute a freshness indicator based on how recently the article was compiled.
|
|
14
|
+
* Returns a symbol: fresh (< 1 day), recent (< 7 days), aging (< 30 days), stale (> 30 days).
|
|
15
|
+
*/
|
|
16
|
+
function freshnessIndicator(lastCompiled: string): string {
|
|
17
|
+
if (!lastCompiled) return "[stale]";
|
|
18
|
+
|
|
19
|
+
const compiled = new Date(lastCompiled).getTime();
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const daysAgo = (now - compiled) / (1000 * 60 * 60 * 24);
|
|
22
|
+
|
|
23
|
+
if (daysAgo < 1) return "[fresh]";
|
|
24
|
+
if (daysAgo < 7) return "[recent]";
|
|
25
|
+
if (daysAgo < 30) return "[aging]";
|
|
26
|
+
return "[stale]";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Type Labels ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const TYPE_LABELS: Record<ArticleType, string> = {
|
|
32
|
+
architecture: "Architecture",
|
|
33
|
+
module: "Modules",
|
|
34
|
+
entity: "Entities",
|
|
35
|
+
feature: "Features",
|
|
36
|
+
decision: "Decisions",
|
|
37
|
+
raw: "Other",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const TYPE_ORDER: ArticleType[] = [
|
|
41
|
+
"architecture",
|
|
42
|
+
"module",
|
|
43
|
+
"entity",
|
|
44
|
+
"feature",
|
|
45
|
+
"decision",
|
|
46
|
+
"raw",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ─── Public API ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate the wiki index.md content.
|
|
53
|
+
* Groups articles by type, sorts by PageRank (descending) within each group,
|
|
54
|
+
* and includes freshness indicators.
|
|
55
|
+
*/
|
|
56
|
+
export function generateIndex(articles: WikiArticle[]): string {
|
|
57
|
+
const lines: string[] = [];
|
|
58
|
+
|
|
59
|
+
lines.push("# Wiki Index");
|
|
60
|
+
lines.push("");
|
|
61
|
+
lines.push(
|
|
62
|
+
`> Auto-generated index. ${articles.length} articles across ${countTypes(articles)} categories.`,
|
|
63
|
+
);
|
|
64
|
+
lines.push("");
|
|
65
|
+
|
|
66
|
+
// Group articles by type
|
|
67
|
+
const grouped = new Map<ArticleType, WikiArticle[]>();
|
|
68
|
+
for (const article of articles) {
|
|
69
|
+
const list = grouped.get(article.type) ?? [];
|
|
70
|
+
list.push(article);
|
|
71
|
+
grouped.set(article.type, list);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Render each type section in defined order
|
|
75
|
+
for (const type of TYPE_ORDER) {
|
|
76
|
+
const group = grouped.get(type);
|
|
77
|
+
if (!group || group.length === 0) continue;
|
|
78
|
+
|
|
79
|
+
// Sort by PageRank descending
|
|
80
|
+
const sorted = [...group].sort((a, b) => b.pageRank - a.pageRank);
|
|
81
|
+
|
|
82
|
+
const label = TYPE_LABELS[type];
|
|
83
|
+
lines.push(`## ${label}`);
|
|
84
|
+
lines.push("");
|
|
85
|
+
|
|
86
|
+
for (const article of sorted) {
|
|
87
|
+
const freshness = freshnessIndicator(article.lastCompiled);
|
|
88
|
+
lines.push(`- [${article.title}](${article.path}) ${freshness}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
lines.push("");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function countTypes(articles: WikiArticle[]): number {
|
|
100
|
+
const types = new Set<ArticleType>();
|
|
101
|
+
for (const article of articles) {
|
|
102
|
+
types.add(article.type);
|
|
103
|
+
}
|
|
104
|
+
return types.size;
|
|
105
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiki Linker — generates forward links and backlinks between wiki articles.
|
|
3
|
+
*
|
|
4
|
+
* Traverses the knowledge graph edges and maps them to wiki article paths
|
|
5
|
+
* to produce bidirectional link maps used for wikilink injection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { KnowledgeGraph } from "./graph";
|
|
9
|
+
import type { WikiLink } from "./types";
|
|
10
|
+
|
|
11
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface LinkResult {
|
|
14
|
+
forwardLinks: Map<string, WikiLink[]>;
|
|
15
|
+
backlinks: Map<string, WikiLink[]>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function addToLinkMap(
|
|
21
|
+
map: Map<string, WikiLink[]>,
|
|
22
|
+
key: string,
|
|
23
|
+
link: WikiLink,
|
|
24
|
+
): void {
|
|
25
|
+
const list = map.get(key) ?? [];
|
|
26
|
+
list.push(link);
|
|
27
|
+
map.set(key, list);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Find the article path for a given node ID.
|
|
32
|
+
* Checks direct mapping first, then checks if the node belongs to a community.
|
|
33
|
+
*/
|
|
34
|
+
function resolveArticlePath(
|
|
35
|
+
nodeId: string,
|
|
36
|
+
articleMap: Map<string, string>,
|
|
37
|
+
graph: KnowledgeGraph,
|
|
38
|
+
): string | null {
|
|
39
|
+
// Direct match
|
|
40
|
+
const direct = articleMap.get(nodeId);
|
|
41
|
+
if (direct) return direct;
|
|
42
|
+
|
|
43
|
+
// Check if the node is part of a community that has an article
|
|
44
|
+
for (const [key, path] of articleMap) {
|
|
45
|
+
if (key.startsWith("community:")) {
|
|
46
|
+
// This is a module article — check if the node is in this community's adjacency
|
|
47
|
+
const node = graph.nodes.get(nodeId);
|
|
48
|
+
if (node?.type === "module") {
|
|
49
|
+
const moduleName = node.label;
|
|
50
|
+
if (path.includes(moduleName)) {
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Public API ──────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate forward links and backlinks between wiki articles based on graph edges.
|
|
64
|
+
*
|
|
65
|
+
* For each edge in the knowledge graph, if both source and target map to
|
|
66
|
+
* wiki articles, create a forward link from source article to target article
|
|
67
|
+
* and a backlink from target article to source article.
|
|
68
|
+
*/
|
|
69
|
+
export function generateLinks(
|
|
70
|
+
graph: KnowledgeGraph,
|
|
71
|
+
articleMap: Map<string, string>,
|
|
72
|
+
): LinkResult {
|
|
73
|
+
const forwardLinks = new Map<string, WikiLink[]>();
|
|
74
|
+
const backlinks = new Map<string, WikiLink[]>();
|
|
75
|
+
|
|
76
|
+
for (const edge of graph.edges) {
|
|
77
|
+
const sourcePath = resolveArticlePath(edge.source, articleMap, graph);
|
|
78
|
+
const targetPath = resolveArticlePath(edge.target, articleMap, graph);
|
|
79
|
+
|
|
80
|
+
if (!sourcePath || !targetPath) continue;
|
|
81
|
+
if (sourcePath === targetPath) continue;
|
|
82
|
+
|
|
83
|
+
// Forward link: source -> target
|
|
84
|
+
addToLinkMap(forwardLinks, sourcePath, {
|
|
85
|
+
target: targetPath,
|
|
86
|
+
type: edge.type,
|
|
87
|
+
weight: edge.weight,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Backlink: target -> source
|
|
91
|
+
addToLinkMap(backlinks, targetPath, {
|
|
92
|
+
target: sourcePath,
|
|
93
|
+
type: edge.type,
|
|
94
|
+
weight: edge.weight,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Deduplicate links (same target + type = single link with max weight)
|
|
99
|
+
const dedup = (map: Map<string, WikiLink[]>): void => {
|
|
100
|
+
for (const [key, links] of map) {
|
|
101
|
+
const seen = new Map<string, WikiLink>();
|
|
102
|
+
for (const link of links) {
|
|
103
|
+
const dedupKey = `${link.target}:${link.type}`;
|
|
104
|
+
const existing = seen.get(dedupKey);
|
|
105
|
+
if (!existing || link.weight > existing.weight) {
|
|
106
|
+
seen.set(dedupKey, link);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
map.set(key, [...seen.values()]);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
dedup(forwardLinks);
|
|
114
|
+
dedup(backlinks);
|
|
115
|
+
|
|
116
|
+
return { forwardLinks, backlinks };
|
|
117
|
+
}
|