gitnexus 1.3.11 → 1.4.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.
Files changed (52) hide show
  1. package/README.md +194 -194
  2. package/dist/cli/ai-context.js +87 -105
  3. package/dist/cli/analyze.js +0 -8
  4. package/dist/cli/index.js +25 -15
  5. package/dist/cli/setup.js +19 -17
  6. package/dist/core/augmentation/engine.js +20 -20
  7. package/dist/core/embeddings/embedding-pipeline.js +26 -26
  8. package/dist/core/ingestion/ast-cache.js +2 -3
  9. package/dist/core/ingestion/call-processor.js +5 -7
  10. package/dist/core/ingestion/cluster-enricher.js +16 -16
  11. package/dist/core/ingestion/pipeline.js +2 -23
  12. package/dist/core/ingestion/tree-sitter-queries.js +484 -484
  13. package/dist/core/ingestion/utils.js +5 -1
  14. package/dist/core/ingestion/workers/worker-pool.js +0 -8
  15. package/dist/core/kuzu/kuzu-adapter.js +19 -11
  16. package/dist/core/kuzu/schema.js +287 -287
  17. package/dist/core/search/bm25-index.js +6 -7
  18. package/dist/core/search/hybrid-search.js +3 -3
  19. package/dist/core/wiki/diagrams.d.ts +27 -0
  20. package/dist/core/wiki/diagrams.js +163 -0
  21. package/dist/core/wiki/generator.d.ts +50 -2
  22. package/dist/core/wiki/generator.js +548 -49
  23. package/dist/core/wiki/graph-queries.d.ts +42 -0
  24. package/dist/core/wiki/graph-queries.js +276 -97
  25. package/dist/core/wiki/html-viewer.js +192 -192
  26. package/dist/core/wiki/llm-client.js +73 -11
  27. package/dist/core/wiki/prompts.d.ts +52 -8
  28. package/dist/core/wiki/prompts.js +200 -86
  29. package/dist/mcp/core/kuzu-adapter.d.ts +3 -1
  30. package/dist/mcp/core/kuzu-adapter.js +44 -13
  31. package/dist/mcp/local/local-backend.js +128 -128
  32. package/dist/mcp/resources.js +42 -42
  33. package/dist/mcp/server.js +19 -18
  34. package/dist/mcp/tools.js +103 -93
  35. package/hooks/claude/gitnexus-hook.cjs +155 -238
  36. package/hooks/claude/pre-tool-use.sh +79 -79
  37. package/hooks/claude/session-start.sh +42 -42
  38. package/package.json +96 -96
  39. package/scripts/patch-tree-sitter-swift.cjs +74 -74
  40. package/skills/gitnexus-cli.md +82 -82
  41. package/skills/gitnexus-debugging.md +89 -89
  42. package/skills/gitnexus-exploring.md +78 -78
  43. package/skills/gitnexus-guide.md +64 -64
  44. package/skills/gitnexus-impact-analysis.md +97 -97
  45. package/skills/gitnexus-pr-review.md +163 -163
  46. package/skills/gitnexus-refactoring.md +121 -121
  47. package/vendor/leiden/index.cjs +355 -355
  48. package/vendor/leiden/utils.cjs +392 -392
  49. package/dist/cli/lazy-action.d.ts +0 -6
  50. package/dist/cli/lazy-action.js +0 -18
  51. package/dist/mcp/compatible-stdio-transport.d.ts +0 -25
  52. package/dist/mcp/compatible-stdio-transport.js +0 -200
@@ -12,10 +12,11 @@
12
12
  import fs from 'fs/promises';
13
13
  import path from 'path';
14
14
  import { execSync, execFileSync } from 'child_process';
15
- import { initWikiDb, closeWikiDb, getFilesWithExports, getAllFiles, getIntraModuleCallEdges, getInterModuleCallEdges, getProcessesForFiles, getAllProcesses, getInterModuleEdgesForOverview, } from './graph-queries.js';
15
+ import { initWikiDb, closeWikiDb, getFilesWithExports, getAllFiles, getIntraModuleCallEdges, getInterModuleCallEdges, getProcessesForFiles, getAllProcesses, getInterModuleEdgesForOverview, getCallGraphNeighborFiles, getCommunityFileMapping, getInterCommunityCallEdges, getCrossCommunityProcesses, } from './graph-queries.js';
16
16
  import { generateHTMLViewer } from './html-viewer.js';
17
17
  import { callLLM, estimateTokens, } from './llm-client.js';
18
- import { GROUPING_SYSTEM_PROMPT, GROUPING_USER_PROMPT, MODULE_SYSTEM_PROMPT, MODULE_USER_PROMPT, PARENT_SYSTEM_PROMPT, PARENT_USER_PROMPT, OVERVIEW_SYSTEM_PROMPT, OVERVIEW_USER_PROMPT, fillTemplate, formatFileListForGrouping, formatDirectoryTree, formatCallEdges, formatProcesses, } from './prompts.js';
18
+ import { GROUPING_SYSTEM_PROMPT, GROUPING_USER_PROMPT, GROUPING_SYSTEM_PROMPT_LEGACY, GROUPING_USER_PROMPT_LEGACY, MODULE_SYSTEM_PROMPT, MODULE_USER_PROMPT, PARENT_SYSTEM_PROMPT, PARENT_USER_PROMPT, OVERVIEW_SYSTEM_PROMPT, OVERVIEW_USER_PROMPT, fillTemplate, formatFileListForGrouping, formatDirectoryTree, formatCallEdges, formatProcesses, formatCommunityGroups, formatInterCommunityEdges, formatCrossCommunityProcesses, formatModuleRegistry, } from './prompts.js';
19
+ import { buildCallGraphMermaid, buildSequenceDiagram, buildInterModuleDiagram, } from './diagrams.js';
19
20
  import { shouldIgnorePath } from '../../config/ignore-service.js';
20
21
  // ─── Constants ────────────────────────────────────────────────────────
21
22
  const DEFAULT_MAX_TOKENS_PER_MODULE = 30_000;
@@ -32,6 +33,7 @@ export class WikiGenerator {
32
33
  options;
33
34
  onProgress;
34
35
  failedModules = [];
36
+ moduleRegistry = new Map();
35
37
  constructor(repoPath, storagePath, kuzuPath, llmConfig, options = {}, onProgress) {
36
38
  this.repoPath = repoPath;
37
39
  this.storagePath = storagePath;
@@ -76,12 +78,20 @@ export class WikiGenerator {
76
78
  await this.ensureHTMLViewer();
77
79
  return { pagesGenerated: 0, mode: 'up-to-date', failedModules: [] };
78
80
  }
79
- // Force mode: delete snapshot to force full re-grouping
81
+ // Force mode: clean all state and delete pages
80
82
  if (forceMode) {
81
83
  try {
82
84
  await fs.unlink(path.join(this.wikiDir, 'first_module_tree.json'));
83
85
  }
84
86
  catch { }
87
+ try {
88
+ await fs.unlink(path.join(this.wikiDir, 'meta.json'));
89
+ }
90
+ catch { }
91
+ try {
92
+ await fs.unlink(path.join(this.wikiDir, 'module_tree.json'));
93
+ }
94
+ catch { }
85
95
  // Delete existing module pages so they get regenerated
86
96
  const existingFiles = await fs.readdir(this.wikiDir).catch(() => []);
87
97
  for (const f of existingFiles) {
@@ -106,10 +116,14 @@ export class WikiGenerator {
106
116
  }
107
117
  }
108
118
  finally {
119
+ console.log('[wiki] Closing KuzuDB...');
109
120
  await closeWikiDb();
121
+ console.log('[wiki] KuzuDB closed');
110
122
  }
111
123
  // Always generate the HTML viewer after wiki content changes
124
+ console.log('[wiki] Building HTML viewer...');
112
125
  await this.ensureHTMLViewer();
126
+ console.log('[wiki] HTML viewer done');
113
127
  return result;
114
128
  }
115
129
  // ─── HTML Viewer ─────────────────────────────────────────────────────
@@ -144,6 +158,8 @@ export class WikiGenerator {
144
158
  // Phase 1: Build module tree
145
159
  const moduleTree = await this.buildModuleTree(enrichedFiles);
146
160
  pagesGenerated = 0;
161
+ // Build module registry for cross-references
162
+ this.moduleRegistry = this.buildModuleRegistry(moduleTree, enrichedFiles);
147
163
  // Phase 2: Generate module pages (parallel with concurrency limit)
148
164
  const totalModules = this.countModules(moduleTree);
149
165
  let modulesProcessed = 0;
@@ -195,9 +211,18 @@ export class WikiGenerator {
195
211
  }
196
212
  // Phase 3: Generate overview
197
213
  this.onProgress('overview', 88, 'Generating overview page...');
198
- await this.generateOverview(moduleTree);
199
- pagesGenerated++;
214
+ try {
215
+ await this.generateOverview(moduleTree);
216
+ pagesGenerated++;
217
+ console.log('[wiki] Overview generated successfully');
218
+ }
219
+ catch (err) {
220
+ console.error('[wiki] Overview generation failed:', err.message);
221
+ this.failedModules.push('_overview');
222
+ this.onProgress('overview', 90, `Overview generation failed: ${err.message?.slice(0, 120) || 'unknown error'}`);
223
+ }
200
224
  // Save metadata
225
+ console.log('[wiki] Saving metadata...');
201
226
  this.onProgress('finalize', 95, 'Saving metadata...');
202
227
  const moduleFiles = this.extractModuleFiles(moduleTree);
203
228
  await this.saveModuleTree(moduleTree);
@@ -226,15 +251,41 @@ export class WikiGenerator {
226
251
  catch {
227
252
  // No snapshot, generate new
228
253
  }
229
- this.onProgress('grouping', 15, 'Grouping files into modules (LLM)...');
254
+ this.onProgress('grouping', 12, 'Querying graph communities...');
255
+ // Try to get community data for graph-driven decomposition
256
+ const communityGroups = await getCommunityFileMapping();
257
+ const useCommunities = communityGroups.length > 0;
258
+ let systemPrompt;
259
+ let prompt;
230
260
  const fileList = formatFileListForGrouping(files);
231
261
  const dirTree = formatDirectoryTree(files.map(f => f.filePath));
232
- const prompt = fillTemplate(GROUPING_USER_PROMPT, {
233
- FILE_LIST: fileList,
234
- DIRECTORY_TREE: dirTree,
235
- });
236
- const response = await callLLM(prompt, this.llmConfig, GROUPING_SYSTEM_PROMPT, this.streamOpts('Grouping files', 15));
237
- const grouping = this.parseGroupingResponse(response.content, files);
262
+ if (useCommunities) {
263
+ // Graph-aware grouping: use communities as primary signal
264
+ this.onProgress('grouping', 15, 'Grouping files into modules (graph-driven LLM)...');
265
+ const [interEdges, crossProcs] = await Promise.all([
266
+ getInterCommunityCallEdges(),
267
+ getCrossCommunityProcesses(),
268
+ ]);
269
+ systemPrompt = GROUPING_SYSTEM_PROMPT;
270
+ prompt = fillTemplate(GROUPING_USER_PROMPT, {
271
+ COMMUNITY_GROUPS: formatCommunityGroups(communityGroups),
272
+ INTER_COMMUNITY_EDGES: formatInterCommunityEdges(interEdges),
273
+ CROSS_COMMUNITY_PROCESSES: formatCrossCommunityProcesses(crossProcs),
274
+ FILE_LIST: fileList,
275
+ DIRECTORY_TREE: dirTree,
276
+ });
277
+ }
278
+ else {
279
+ // Legacy grouping: file-only approach
280
+ this.onProgress('grouping', 15, 'Grouping files into modules (LLM)...');
281
+ systemPrompt = GROUPING_SYSTEM_PROMPT_LEGACY;
282
+ prompt = fillTemplate(GROUPING_USER_PROMPT_LEGACY, {
283
+ FILE_LIST: fileList,
284
+ DIRECTORY_TREE: dirTree,
285
+ });
286
+ }
287
+ const response = await callLLM(prompt, this.llmConfig, systemPrompt, this.streamOpts('Grouping files', 15));
288
+ const grouping = this.parseGroupingResponse(response.content, files, communityGroups);
238
289
  // Convert to tree nodes
239
290
  const tree = [];
240
291
  for (const [moduleName, modulePaths] of Object.entries(grouping)) {
@@ -243,7 +294,9 @@ export class WikiGenerator {
243
294
  // Token budget check — split if too large
244
295
  const totalTokens = await this.estimateModuleTokens(modulePaths);
245
296
  if (totalTokens > this.maxTokensPerModule && modulePaths.length > 3) {
246
- node.children = this.splitBySubdirectory(moduleName, modulePaths);
297
+ node.children = useCommunities
298
+ ? this.splitByCommunity(moduleName, modulePaths, communityGroups)
299
+ : this.splitBySubdirectory(moduleName, modulePaths);
247
300
  node.files = []; // Parent doesn't own files directly when split
248
301
  }
249
302
  tree.push(node);
@@ -256,7 +309,7 @@ export class WikiGenerator {
256
309
  /**
257
310
  * Parse LLM grouping response. Validates all files are assigned.
258
311
  */
259
- parseGroupingResponse(content, files) {
312
+ parseGroupingResponse(content, files, communityGroups) {
260
313
  // Extract JSON from response (handle markdown fences)
261
314
  let jsonStr = content.trim();
262
315
  const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
@@ -268,11 +321,11 @@ export class WikiGenerator {
268
321
  parsed = JSON.parse(jsonStr);
269
322
  }
270
323
  catch {
271
- // Fallback: group by top-level directory
272
- return this.fallbackGrouping(files);
324
+ // Fallback: group by community or top-level directory
325
+ return this.fallbackGrouping(files, communityGroups);
273
326
  }
274
327
  if (typeof parsed !== 'object' || Array.isArray(parsed)) {
275
- return this.fallbackGrouping(files);
328
+ return this.fallbackGrouping(files, communityGroups);
276
329
  }
277
330
  // Validate — ensure all files are assigned
278
331
  const allFilePaths = new Set(files.map(f => f.filePath));
@@ -301,12 +354,33 @@ export class WikiGenerator {
301
354
  }
302
355
  return Object.keys(validGrouping).length > 0
303
356
  ? validGrouping
304
- : this.fallbackGrouping(files);
357
+ : this.fallbackGrouping(files, communityGroups);
305
358
  }
306
359
  /**
307
- * Fallback grouping by top-level directory when LLM parsing fails.
360
+ * Fallback grouping. Uses community file mapping when available,
361
+ * otherwise groups by top-level directory.
308
362
  */
309
- fallbackGrouping(files) {
363
+ fallbackGrouping(files, communityGroups) {
364
+ // Use community data if available
365
+ if (communityGroups && communityGroups.length > 0) {
366
+ const result = {};
367
+ const assignedFiles = new Set();
368
+ for (const group of communityGroups) {
369
+ if (group.files.length > 0) {
370
+ result[group.label] = [...group.files];
371
+ for (const f of group.files)
372
+ assignedFiles.add(f);
373
+ }
374
+ }
375
+ // Assign unassigned files
376
+ const unassigned = files.map(f => f.filePath).filter(fp => !assignedFiles.has(fp));
377
+ if (unassigned.length > 0) {
378
+ result['Other'] = unassigned;
379
+ }
380
+ if (Object.keys(result).length > 0)
381
+ return result;
382
+ }
383
+ // Directory-based fallback
310
384
  const groups = new Map();
311
385
  for (const f of files) {
312
386
  const parts = f.filePath.replace(/\\/g, '/').split('/');
@@ -342,6 +416,159 @@ export class WikiGenerator {
342
416
  files: subFiles,
343
417
  }));
344
418
  }
419
+ /**
420
+ * Split a large module into sub-modules using community data.
421
+ * Falls back to subdirectory splitting if community data doesn't help.
422
+ */
423
+ splitByCommunity(moduleName, files, communityGroups) {
424
+ const subGroups = new Map();
425
+ const unassigned = [];
426
+ // Group files by their community membership
427
+ for (const fp of files) {
428
+ let bestCommunity = '';
429
+ let bestCount = 0;
430
+ for (const group of communityGroups) {
431
+ const count = group.files.filter(f => f === fp).length +
432
+ group.secondaryFiles.filter(f => f === fp).length;
433
+ if (count > bestCount) {
434
+ bestCount = count;
435
+ bestCommunity = group.label;
436
+ }
437
+ }
438
+ if (bestCommunity) {
439
+ let group = subGroups.get(bestCommunity);
440
+ if (!group) {
441
+ group = [];
442
+ subGroups.set(bestCommunity, group);
443
+ }
444
+ group.push(fp);
445
+ }
446
+ else {
447
+ unassigned.push(fp);
448
+ }
449
+ }
450
+ // If community split didn't produce meaningful groups, fall back to subdirectory
451
+ if (subGroups.size <= 1) {
452
+ return this.splitBySubdirectory(moduleName, files);
453
+ }
454
+ // Add unassigned to largest group
455
+ if (unassigned.length > 0) {
456
+ let largestKey = '';
457
+ let largestSize = 0;
458
+ for (const [key, group] of subGroups) {
459
+ if (group.length > largestSize) {
460
+ largestSize = group.length;
461
+ largestKey = key;
462
+ }
463
+ }
464
+ if (largestKey) {
465
+ subGroups.get(largestKey).push(...unassigned);
466
+ }
467
+ }
468
+ return Array.from(subGroups.entries()).map(([label, subFiles]) => ({
469
+ name: `${moduleName} — ${label}`,
470
+ slug: this.slugify(`${moduleName}-${label}`),
471
+ files: subFiles,
472
+ }));
473
+ }
474
+ // ─── Module Registry (Cross-References) ─────────────────────────────
475
+ /**
476
+ * Build a registry mapping module slugs to their names and exported symbols.
477
+ */
478
+ buildModuleRegistry(tree, filesWithExports) {
479
+ const exportMap = new Map(filesWithExports.map(f => [f.filePath, f]));
480
+ const registry = new Map();
481
+ const addNode = (node) => {
482
+ const symbols = [];
483
+ const nodeFiles = node.children
484
+ ? node.children.flatMap(c => c.files)
485
+ : node.files;
486
+ for (const fp of nodeFiles) {
487
+ const fileEntry = exportMap.get(fp);
488
+ if (fileEntry) {
489
+ for (const sym of fileEntry.symbols) {
490
+ if (symbols.length < 10)
491
+ symbols.push(sym.name);
492
+ }
493
+ }
494
+ }
495
+ registry.set(node.slug, { name: node.name, slug: node.slug, symbols });
496
+ if (node.children) {
497
+ for (const child of node.children) {
498
+ addNode(child);
499
+ }
500
+ }
501
+ };
502
+ for (const node of tree) {
503
+ addNode(node);
504
+ }
505
+ return registry;
506
+ }
507
+ // ─── Cross-Reference Validation ─────────────────────────────────────
508
+ /**
509
+ * Validate and fix cross-reference links in generated markdown.
510
+ * Rewrites invalid slug references using fuzzy matching.
511
+ */
512
+ validateAndFixCrossReferences(markdown, registry) {
513
+ const validSlugs = new Set(Array.from(registry.values()).map(e => e.slug));
514
+ const slugByName = new Map();
515
+ for (const entry of registry.values()) {
516
+ slugByName.set(entry.name.toLowerCase(), entry.slug);
517
+ }
518
+ return markdown.replace(/\[([^\]]+)\]\(([a-z0-9-]+)\.md\)/g, (match, text, slug) => {
519
+ if (validSlugs.has(slug))
520
+ return match;
521
+ // Try fuzzy match by link text
522
+ const fuzzySlug = slugByName.get(text.toLowerCase());
523
+ if (fuzzySlug)
524
+ return `[${text}](${fuzzySlug}.md)`;
525
+ // Try matching slug as partial
526
+ for (const validSlug of validSlugs) {
527
+ if (validSlug.includes(slug) || slug.includes(validSlug)) {
528
+ return `[${text}](${validSlug}.md)`;
529
+ }
530
+ }
531
+ // Strip broken link
532
+ return text;
533
+ });
534
+ }
535
+ // ─── Summary Extraction ─────────────────────────────────────────────
536
+ /**
537
+ * Extract the overview summary from a generated page.
538
+ * Uses structured markers when available, falls back to heuristics.
539
+ */
540
+ extractSummary(content, maxLength = 800) {
541
+ // Priority 1: <!-- summary-end --> marker
542
+ const markerIdx = content.indexOf('<!-- summary-end -->');
543
+ if (markerIdx > 0) {
544
+ return content.slice(0, markerIdx).trim();
545
+ }
546
+ // Priority 2: Content up to first ## heading (skip # title)
547
+ const lines = content.split('\n');
548
+ let pastTitle = false;
549
+ let result = '';
550
+ for (const line of lines) {
551
+ if (!pastTitle && line.startsWith('# ')) {
552
+ pastTitle = true;
553
+ result += line + '\n';
554
+ continue;
555
+ }
556
+ if (pastTitle && /^##\s/.test(line)) {
557
+ break;
558
+ }
559
+ result += line + '\n';
560
+ }
561
+ if (result.trim().length > 20) {
562
+ return result.trim().slice(0, maxLength);
563
+ }
564
+ // Priority 3: Truncate at sentence boundary near maxLength
565
+ const truncated = content.slice(0, maxLength);
566
+ const lastSentence = truncated.lastIndexOf('. ');
567
+ if (lastSentence > maxLength * 0.5) {
568
+ return truncated.slice(0, lastSentence + 1).trim();
569
+ }
570
+ return truncated.trim();
571
+ }
345
572
  // ─── Phase 2: Generate Module Pages ─────────────────────────────────
346
573
  /**
347
574
  * Generate a leaf module page from source code + graph data.
@@ -369,10 +596,25 @@ export class WikiGenerator {
369
596
  OUTGOING_CALLS: formatCallEdges(interCalls.outgoing),
370
597
  INCOMING_CALLS: formatCallEdges(interCalls.incoming),
371
598
  PROCESSES: formatProcesses(processes),
599
+ MODULE_REGISTRY: formatModuleRegistry(this.moduleRegistry, node.slug),
372
600
  });
373
601
  const response = await callLLM(prompt, this.llmConfig, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name));
374
- // Write page with front matter
375
- const pageContent = `# ${node.name}\n\n${response.content}`;
602
+ // Build deterministic diagrams
603
+ let diagramSection = '';
604
+ const callGraph = buildCallGraphMermaid(node.name, intraCalls);
605
+ if (callGraph) {
606
+ diagramSection += `\n\n## Internal Call Graph\n\n\`\`\`mermaid\n${callGraph}\n\`\`\``;
607
+ }
608
+ const topProcesses = processes.slice(0, 3);
609
+ for (const proc of topProcesses) {
610
+ const seqDiagram = buildSequenceDiagram(proc);
611
+ if (seqDiagram) {
612
+ diagramSection += `\n\n## Workflow: ${proc.label}\n\n\`\`\`mermaid\n${seqDiagram}\n\`\`\``;
613
+ }
614
+ }
615
+ // Assemble page, validate cross-references, and write
616
+ let pageContent = `# ${node.name}\n\n${response.content}${diagramSection}`;
617
+ pageContent = this.validateAndFixCrossReferences(pageContent, this.moduleRegistry);
376
618
  await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
377
619
  }
378
620
  /**
@@ -387,9 +629,7 @@ export class WikiGenerator {
387
629
  const childPage = path.join(this.wikiDir, `${child.slug}.md`);
388
630
  try {
389
631
  const content = await fs.readFile(childPage, 'utf-8');
390
- // Extract overview section (first ~500 chars or up to "### Architecture")
391
- const overviewEnd = content.indexOf('### Architecture');
392
- const overview = overviewEnd > 0 ? content.slice(0, overviewEnd).trim() : content.slice(0, 800).trim();
632
+ const overview = this.extractSummary(content);
393
633
  childDocs.push(`#### ${child.name}\n${overview}`);
394
634
  }
395
635
  catch {
@@ -398,16 +638,26 @@ export class WikiGenerator {
398
638
  }
399
639
  // Get cross-child call edges
400
640
  const allChildFiles = node.children.flatMap(c => c.files);
401
- const crossCalls = await getIntraModuleCallEdges(allChildFiles);
402
- const processes = await getProcessesForFiles(allChildFiles, 3);
641
+ const [crossCalls, processes] = await Promise.all([
642
+ getIntraModuleCallEdges(allChildFiles),
643
+ getProcessesForFiles(allChildFiles, 3),
644
+ ]);
403
645
  const prompt = fillTemplate(PARENT_USER_PROMPT, {
404
646
  MODULE_NAME: node.name,
405
647
  CHILDREN_DOCS: childDocs.join('\n\n'),
406
648
  CROSS_MODULE_CALLS: formatCallEdges(crossCalls),
407
649
  CROSS_PROCESSES: formatProcesses(processes),
650
+ MODULE_REGISTRY: formatModuleRegistry(this.moduleRegistry, node.slug),
408
651
  });
409
652
  const response = await callLLM(prompt, this.llmConfig, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name));
410
- const pageContent = `# ${node.name}\n\n${response.content}`;
653
+ // Append cross-child call graph diagram
654
+ let diagramSection = '';
655
+ const callGraph = buildCallGraphMermaid(node.name, crossCalls);
656
+ if (callGraph) {
657
+ diagramSection += `\n\n## Cross-Module Call Graph\n\n\`\`\`mermaid\n${callGraph}\n\`\`\``;
658
+ }
659
+ let pageContent = `# ${node.name}\n\n${response.content}${diagramSection}`;
660
+ pageContent = this.validateAndFixCrossReferences(pageContent, this.moduleRegistry);
411
661
  await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
412
662
  }
413
663
  // ─── Phase 3: Generate Overview ─────────────────────────────────────
@@ -418,8 +668,7 @@ export class WikiGenerator {
418
668
  const pagePath = path.join(this.wikiDir, `${node.slug}.md`);
419
669
  try {
420
670
  const content = await fs.readFile(pagePath, 'utf-8');
421
- const overviewEnd = content.indexOf('### Architecture');
422
- const overview = overviewEnd > 0 ? content.slice(0, overviewEnd).trim() : content.slice(0, 600).trim();
671
+ const overview = this.extractSummary(content, 600);
423
672
  moduleSummaries.push(`#### ${node.name}\n${overview}`);
424
673
  }
425
674
  catch {
@@ -436,22 +685,49 @@ export class WikiGenerator {
436
685
  const edgesText = moduleEdges.length > 0
437
686
  ? moduleEdges.map(e => `${e.from} → ${e.to} (${e.count} calls)`).join('\n')
438
687
  : 'No inter-module call edges detected';
688
+ // Cap module summaries to avoid blowing up the prompt
689
+ let summariesText = moduleSummaries.join('\n\n');
690
+ const MAX_SUMMARIES_CHARS = 30_000; // ~7.5k tokens
691
+ if (summariesText.length > MAX_SUMMARIES_CHARS) {
692
+ summariesText = summariesText.slice(0, MAX_SUMMARIES_CHARS) + '\n\n(... remaining modules truncated for brevity)';
693
+ }
439
694
  const prompt = fillTemplate(OVERVIEW_USER_PROMPT, {
440
695
  PROJECT_INFO: projectInfo,
441
- MODULE_SUMMARIES: moduleSummaries.join('\n\n'),
696
+ MODULE_SUMMARIES: summariesText,
442
697
  MODULE_EDGES: edgesText,
443
698
  TOP_PROCESSES: formatProcesses(topProcesses),
699
+ MODULE_REGISTRY: formatModuleRegistry(this.moduleRegistry),
444
700
  });
701
+ const promptTokens = estimateTokens(prompt + OVERVIEW_SYSTEM_PROMPT);
702
+ this.onProgress('overview', 88, `Generating overview (~${promptTokens} input tokens)...`);
445
703
  const response = await callLLM(prompt, this.llmConfig, OVERVIEW_SYSTEM_PROMPT, this.streamOpts('Generating overview', 88));
446
- const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`;
704
+ // Append architecture diagram
705
+ let diagramSection = '';
706
+ const archDiagram = buildInterModuleDiagram(moduleEdges);
707
+ if (archDiagram) {
708
+ diagramSection += `\n\n## Architecture Diagram\n\n\`\`\`mermaid\n${archDiagram}\n\`\`\``;
709
+ }
710
+ const displayName = this.options.repoName || path.basename(this.repoPath);
711
+ let pageContent = `# ${displayName} — Wiki\n\n${response.content}${diagramSection}`;
712
+ pageContent = this.validateAndFixCrossReferences(pageContent, this.moduleRegistry);
447
713
  await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8');
448
714
  }
449
715
  // ─── Incremental Updates ────────────────────────────────────────────
450
716
  async incrementalUpdate(existingMeta, currentCommit) {
451
717
  this.onProgress('incremental', 5, 'Detecting changes...');
452
- // Get changed files since last generation
453
- const changedFiles = this.getChangedFiles(existingMeta.fromCommit, currentCommit);
454
- if (changedFiles.length === 0) {
718
+ // Get changed files with status since last generation
719
+ const changedEntries = this.getChangedFilesWithStatus(existingMeta.fromCommit, currentCommit);
720
+ // Shallow clone fallback — commit history too shallow for incremental diff
721
+ if (changedEntries.some(e => e.filePath === '__SHALLOW_CLONE_FALLBACK__')) {
722
+ this.onProgress('incremental', 10, 'Commit history too shallow — running full generation...');
723
+ try {
724
+ await fs.unlink(path.join(this.wikiDir, 'first_module_tree.json'));
725
+ }
726
+ catch { }
727
+ const fullResult = await this.fullGeneration(currentCommit);
728
+ return { ...fullResult, mode: 'incremental' };
729
+ }
730
+ if (changedEntries.length === 0) {
455
731
  // No file changes but commit differs (e.g. merge commit)
456
732
  await this.saveWikiMeta({
457
733
  ...existingMeta,
@@ -460,11 +736,27 @@ export class WikiGenerator {
460
736
  });
461
737
  return { pagesGenerated: 0, mode: 'incremental', failedModules: [] };
462
738
  }
463
- this.onProgress('incremental', 10, `${changedFiles.length} files changed`);
464
- // Determine affected modules
739
+ // Categorize changes
740
+ const deletedFiles = [];
741
+ const addedFiles = [];
742
+ const modifiedFiles = [];
743
+ for (const entry of changedEntries) {
744
+ if (entry.status === 'D')
745
+ deletedFiles.push(entry.filePath);
746
+ else if (entry.status === 'A')
747
+ addedFiles.push(entry.filePath);
748
+ else
749
+ modifiedFiles.push(entry.filePath);
750
+ }
751
+ this.onProgress('incremental', 10, `${changedEntries.length} files changed (${addedFiles.length}A/${modifiedFiles.length}M/${deletedFiles.length}D)`);
752
+ // Purge deleted files from metadata and tree
753
+ if (deletedFiles.length > 0) {
754
+ this.purgeDeletedFiles(existingMeta, deletedFiles);
755
+ }
756
+ // Determine affected modules from modified files
465
757
  const affectedModules = new Set();
466
758
  const newFiles = [];
467
- for (const fp of changedFiles) {
759
+ for (const fp of [...modifiedFiles, ...addedFiles]) {
468
760
  let found = false;
469
761
  for (const [mod, files] of Object.entries(existingMeta.moduleFiles)) {
470
762
  if (files.includes(fp)) {
@@ -477,6 +769,15 @@ export class WikiGenerator {
477
769
  newFiles.push(fp);
478
770
  }
479
771
  }
772
+ // Also mark modules that lost files as affected
773
+ for (const fp of deletedFiles) {
774
+ for (const [mod, files] of Object.entries(existingMeta.moduleFiles)) {
775
+ if (files.includes(fp)) {
776
+ affectedModules.add(mod);
777
+ break;
778
+ }
779
+ }
780
+ }
480
781
  // If significant new files exist, re-run full grouping
481
782
  if (newFiles.length > 5) {
482
783
  this.onProgress('incremental', 15, 'Significant new files detected, running full generation...');
@@ -488,14 +789,17 @@ export class WikiGenerator {
488
789
  const fullResult = await this.fullGeneration(currentCommit);
489
790
  return { ...fullResult, mode: 'incremental' };
490
791
  }
491
- // Add new files to nearest module or "Other"
792
+ // Assign new files to nearest module using call-graph neighbors
492
793
  if (newFiles.length > 0) {
493
- if (!existingMeta.moduleFiles['Other']) {
494
- existingMeta.moduleFiles['Other'] = [];
794
+ const assignments = await this.assignNewFilesToModules(newFiles, existingMeta);
795
+ this.syncNewFilesToTree(existingMeta, assignments);
796
+ for (const mod of new Set(Object.values(assignments))) {
797
+ affectedModules.add(mod);
495
798
  }
496
- existingMeta.moduleFiles['Other'].push(...newFiles);
497
- affectedModules.add('Other');
498
799
  }
800
+ // Build registry for cross-references
801
+ const enrichedFiles = await getFilesWithExports();
802
+ this.moduleRegistry = this.buildModuleRegistry(existingMeta.moduleTree, enrichedFiles);
499
803
  // Regenerate affected module pages (parallel)
500
804
  let pagesGenerated = 0;
501
805
  const moduleTree = existingMeta.moduleTree;
@@ -539,8 +843,9 @@ export class WikiGenerator {
539
843
  await this.generateOverview(moduleTree);
540
844
  pagesGenerated++;
541
845
  }
542
- // Save updated metadata
846
+ // Save updated metadata and module tree
543
847
  this.onProgress('incremental', 95, 'Saving metadata...');
848
+ await this.saveModuleTree(moduleTree);
544
849
  await this.saveWikiMeta({
545
850
  ...existingMeta,
546
851
  fromCommit: currentCommit,
@@ -550,6 +855,119 @@ export class WikiGenerator {
550
855
  this.onProgress('done', 100, 'Incremental update complete');
551
856
  return { pagesGenerated, mode: 'incremental', failedModules: [...this.failedModules] };
552
857
  }
858
+ // ─── Incremental Helpers ───────────────────────────────────────────
859
+ /**
860
+ * Purge deleted files from module metadata and tree.
861
+ * Removes orphaned modules that lost all files.
862
+ */
863
+ purgeDeletedFiles(meta, deletedFiles) {
864
+ const deletedSet = new Set(deletedFiles);
865
+ // Remove from moduleFiles
866
+ for (const [mod, files] of Object.entries(meta.moduleFiles)) {
867
+ meta.moduleFiles[mod] = files.filter(f => !deletedSet.has(f));
868
+ }
869
+ // Prune empty modules from moduleFiles
870
+ for (const mod of Object.keys(meta.moduleFiles)) {
871
+ if (meta.moduleFiles[mod].length === 0) {
872
+ delete meta.moduleFiles[mod];
873
+ // Delete orphaned markdown page
874
+ const slug = this.slugify(mod);
875
+ fs.unlink(path.join(this.wikiDir, `${slug}.md`)).catch(() => { });
876
+ }
877
+ }
878
+ // Walk moduleTree recursively, filter files, prune empty nodes
879
+ const pruneTree = (nodes) => {
880
+ return nodes.filter(node => {
881
+ node.files = node.files.filter(f => !deletedSet.has(f));
882
+ if (node.children) {
883
+ node.children = pruneTree(node.children);
884
+ // If parent lost all children and has no files, prune it
885
+ if (node.children.length === 0 && node.files.length === 0) {
886
+ fs.unlink(path.join(this.wikiDir, `${node.slug}.md`)).catch(() => { });
887
+ return false;
888
+ }
889
+ }
890
+ else if (node.files.length === 0) {
891
+ fs.unlink(path.join(this.wikiDir, `${node.slug}.md`)).catch(() => { });
892
+ return false;
893
+ }
894
+ return true;
895
+ });
896
+ };
897
+ meta.moduleTree = pruneTree(meta.moduleTree);
898
+ }
899
+ /**
900
+ * Assign new files to existing modules using call-graph neighbor analysis.
901
+ * Falls back to "Other" if no neighbors found.
902
+ */
903
+ async assignNewFilesToModules(newFiles, meta) {
904
+ const assignments = {};
905
+ // Build file-to-module lookup from existing metadata
906
+ const fileToModule = new Map();
907
+ for (const [mod, files] of Object.entries(meta.moduleFiles)) {
908
+ for (const f of files)
909
+ fileToModule.set(f, mod);
910
+ }
911
+ // Query per-file (newFiles.length <= 5 since >5 triggers full regen)
912
+ for (const fp of newFiles) {
913
+ const neighbors = await getCallGraphNeighborFiles([fp]);
914
+ const knownNeighbors = neighbors.filter(n => fileToModule.has(n));
915
+ if (knownNeighbors.length > 0) {
916
+ // Count hits per module
917
+ const moduleCounts = new Map();
918
+ for (const n of knownNeighbors) {
919
+ const mod = fileToModule.get(n);
920
+ moduleCounts.set(mod, (moduleCounts.get(mod) || 0) + 1);
921
+ }
922
+ // Assign to module with most neighbor hits
923
+ let bestMod = 'Other';
924
+ let bestCount = 0;
925
+ for (const [mod, count] of moduleCounts) {
926
+ if (count > bestCount) {
927
+ bestCount = count;
928
+ bestMod = mod;
929
+ }
930
+ }
931
+ assignments[fp] = bestMod;
932
+ }
933
+ else {
934
+ assignments[fp] = 'Other';
935
+ }
936
+ }
937
+ return assignments;
938
+ }
939
+ /**
940
+ * Sync new file assignments into both moduleFiles and moduleTree.
941
+ */
942
+ syncNewFilesToTree(meta, assignments) {
943
+ for (const [fp, mod] of Object.entries(assignments)) {
944
+ // Update moduleFiles
945
+ if (!meta.moduleFiles[mod]) {
946
+ meta.moduleFiles[mod] = [];
947
+ }
948
+ meta.moduleFiles[mod].push(fp);
949
+ // Update moduleTree
950
+ const modSlug = this.slugify(mod);
951
+ const node = this.findNodeBySlug(meta.moduleTree, modSlug);
952
+ if (node) {
953
+ if (node.children && node.children.length > 0) {
954
+ // Add to first child as default
955
+ node.children[0].files.push(fp);
956
+ }
957
+ else {
958
+ node.files.push(fp);
959
+ }
960
+ }
961
+ else {
962
+ // Create new "Other" node if needed
963
+ meta.moduleTree.push({
964
+ name: mod,
965
+ slug: modSlug,
966
+ files: [fp],
967
+ });
968
+ }
969
+ }
970
+ }
553
971
  // ─── Helpers ────────────────────────────────────────────────────────
554
972
  getCurrentCommit() {
555
973
  try {
@@ -559,13 +977,84 @@ export class WikiGenerator {
559
977
  return '';
560
978
  }
561
979
  }
562
- getChangedFiles(fromCommit, toCommit) {
980
+ /**
981
+ * Parse git diff --name-status output into structured entries.
982
+ */
983
+ parseNameStatusOutput(output) {
984
+ if (!output)
985
+ return [];
986
+ const results = [];
987
+ for (const line of output.split('\n').filter(Boolean)) {
988
+ const parts = line.split('\t');
989
+ if (parts.length < 2)
990
+ continue;
991
+ const rawStatus = parts[0].charAt(0);
992
+ if (rawStatus === 'R' && parts.length >= 3) {
993
+ // Rename: treat as delete old + add new
994
+ results.push({ status: 'D', filePath: parts[1] });
995
+ results.push({ status: 'A', filePath: parts[2] });
996
+ }
997
+ else if (rawStatus === 'A' || rawStatus === 'M' || rawStatus === 'D') {
998
+ results.push({ status: rawStatus, filePath: parts[1] });
999
+ }
1000
+ else {
1001
+ // Default to modified for unknown statuses (C for copy, etc.)
1002
+ results.push({ status: 'M', filePath: parts[parts.length - 1] });
1003
+ }
1004
+ }
1005
+ return results;
1006
+ }
1007
+ /**
1008
+ * Get changed files with their status (Added, Modified, Deleted).
1009
+ * Handles renames as Delete + Add.
1010
+ * Detects shallow clones and attempts to unshallow if needed.
1011
+ */
1012
+ getChangedFilesWithStatus(fromCommit, toCommit) {
563
1013
  try {
564
- const output = execFileSync('git', ['diff', `${fromCommit}..${toCommit}`, '--name-only'], { cwd: this.repoPath }).toString().trim();
565
- return output ? output.split('\n').filter(Boolean) : [];
1014
+ const output = execFileSync('git', ['diff', `${fromCommit}..${toCommit}`, '--name-status'], { cwd: this.repoPath }).toString().trim();
1015
+ if (output)
1016
+ return this.parseNameStatusOutput(output);
1017
+ // Empty output — verify fromCommit is reachable (not a shallow clone issue)
1018
+ try {
1019
+ execFileSync('git', ['cat-file', '-t', fromCommit], { cwd: this.repoPath });
1020
+ // Commit is reachable, genuinely no changes
1021
+ return [];
1022
+ }
1023
+ catch {
1024
+ // fromCommit not reachable — fall through to unshallow logic
1025
+ }
566
1026
  }
567
1027
  catch {
568
- return [];
1028
+ // git diff threw — most common shallow clone symptom ("fatal: bad revision")
1029
+ // Verify it's actually a reachability issue before attempting unshallow
1030
+ try {
1031
+ execFileSync('git', ['cat-file', '-t', fromCommit], { cwd: this.repoPath });
1032
+ // Commit exists but diff still failed — genuine error, not shallow
1033
+ return [];
1034
+ }
1035
+ catch {
1036
+ // fromCommit not reachable — fall through to unshallow logic
1037
+ }
1038
+ }
1039
+ // fromCommit not reachable — likely a shallow clone
1040
+ console.log(`[wiki] fromCommit ${fromCommit.slice(0, 8)} not reachable, attempting to unshallow...`);
1041
+ try {
1042
+ execFileSync('git', ['fetch', '--unshallow'], {
1043
+ cwd: this.repoPath,
1044
+ timeout: 120_000,
1045
+ });
1046
+ }
1047
+ catch (fetchErr) {
1048
+ console.warn(`[wiki] git fetch --unshallow failed:`, fetchErr);
1049
+ return [{ status: 'A', filePath: '__SHALLOW_CLONE_FALLBACK__' }];
1050
+ }
1051
+ // Retry the diff after unshallowing
1052
+ try {
1053
+ const retryOutput = execFileSync('git', ['diff', `${fromCommit}..${toCommit}`, '--name-status'], { cwd: this.repoPath }).toString().trim();
1054
+ return this.parseNameStatusOutput(retryOutput);
1055
+ }
1056
+ catch {
1057
+ return [{ status: 'A', filePath: '__SHALLOW_CLONE_FALLBACK__' }];
569
1058
  }
570
1059
  }
571
1060
  async readSourceFiles(filePaths) {
@@ -752,12 +1241,22 @@ export class WikiGenerator {
752
1241
  }
753
1242
  return null;
754
1243
  }
1244
+ /** Set of all slugs assigned so far — used to prevent collisions. */
1245
+ assignedSlugs = new Set();
755
1246
  slugify(name) {
756
- return name
1247
+ let base = name
757
1248
  .toLowerCase()
758
1249
  .replace(/[^a-z0-9]+/g, '-')
759
1250
  .replace(/^-+|-+$/g, '')
760
1251
  .slice(0, 60);
1252
+ // Deduplicate: append -2, -3, etc. if slug already taken
1253
+ let slug = base;
1254
+ let counter = 2;
1255
+ while (this.assignedSlugs.has(slug)) {
1256
+ slug = `${base}-${counter++}`;
1257
+ }
1258
+ this.assignedSlugs.add(slug);
1259
+ return slug;
761
1260
  }
762
1261
  async fileExists(fp) {
763
1262
  try {