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.
- package/README.md +194 -194
- package/dist/cli/ai-context.js +87 -105
- package/dist/cli/analyze.js +0 -8
- package/dist/cli/index.js +25 -15
- package/dist/cli/setup.js +19 -17
- package/dist/core/augmentation/engine.js +20 -20
- package/dist/core/embeddings/embedding-pipeline.js +26 -26
- package/dist/core/ingestion/ast-cache.js +2 -3
- package/dist/core/ingestion/call-processor.js +5 -7
- package/dist/core/ingestion/cluster-enricher.js +16 -16
- package/dist/core/ingestion/pipeline.js +2 -23
- package/dist/core/ingestion/tree-sitter-queries.js +484 -484
- package/dist/core/ingestion/utils.js +5 -1
- package/dist/core/ingestion/workers/worker-pool.js +0 -8
- package/dist/core/kuzu/kuzu-adapter.js +19 -11
- package/dist/core/kuzu/schema.js +287 -287
- package/dist/core/search/bm25-index.js +6 -7
- package/dist/core/search/hybrid-search.js +3 -3
- package/dist/core/wiki/diagrams.d.ts +27 -0
- package/dist/core/wiki/diagrams.js +163 -0
- package/dist/core/wiki/generator.d.ts +50 -2
- package/dist/core/wiki/generator.js +548 -49
- package/dist/core/wiki/graph-queries.d.ts +42 -0
- package/dist/core/wiki/graph-queries.js +276 -97
- package/dist/core/wiki/html-viewer.js +192 -192
- package/dist/core/wiki/llm-client.js +73 -11
- package/dist/core/wiki/prompts.d.ts +52 -8
- package/dist/core/wiki/prompts.js +200 -86
- package/dist/mcp/core/kuzu-adapter.d.ts +3 -1
- package/dist/mcp/core/kuzu-adapter.js +44 -13
- package/dist/mcp/local/local-backend.js +128 -128
- package/dist/mcp/resources.js +42 -42
- package/dist/mcp/server.js +19 -18
- package/dist/mcp/tools.js +103 -93
- package/hooks/claude/gitnexus-hook.cjs +155 -238
- package/hooks/claude/pre-tool-use.sh +79 -79
- package/hooks/claude/session-start.sh +42 -42
- package/package.json +96 -96
- package/scripts/patch-tree-sitter-swift.cjs +74 -74
- package/skills/gitnexus-cli.md +82 -82
- package/skills/gitnexus-debugging.md +89 -89
- package/skills/gitnexus-exploring.md +78 -78
- package/skills/gitnexus-guide.md +64 -64
- package/skills/gitnexus-impact-analysis.md +97 -97
- package/skills/gitnexus-pr-review.md +163 -163
- package/skills/gitnexus-refactoring.md +121 -121
- package/vendor/leiden/index.cjs +355 -355
- package/vendor/leiden/utils.cjs +392 -392
- package/dist/cli/lazy-action.d.ts +0 -6
- package/dist/cli/lazy-action.js +0 -18
- package/dist/mcp/compatible-stdio-transport.d.ts +0 -25
- 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:
|
|
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
|
-
|
|
199
|
-
|
|
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',
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
375
|
-
|
|
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
|
-
|
|
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
|
|
402
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
454
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
|
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
|
-
//
|
|
792
|
+
// Assign new files to nearest module using call-graph neighbors
|
|
492
793
|
if (newFiles.length > 0) {
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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-
|
|
565
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|