import * as path from 'node:path/posix'; /** * Entry point file names in priority order. * The first match found wins. */ const ENTRY_POINT_NAMES = [ 'index.ts', 'index.tsx', 'index.js', 'main.ts', 'main.py', 'App.tsx', 'App.ts', 'app.ts', 'app.py', 'main.go', 'main.rs', 'Main.java', ]; /** * Finds the entry point node from the list of file nodes. * * Strategy: * 1. Check for common entry point file names (with and without src/ prefix) * 2. Fallback: use the file with the most outgoing import edges */ export function findEntryPoint(fileNodes, edges) { if (fileNodes.length === 0) return undefined; // Try each entry point name in priority order for (const entryName of ENTRY_POINT_NAMES) { const match = fileNodes.find(node => { const basename = path.basename(node.id); if (basename !== entryName) return false; // Match root-level or src/-level files const dir = path.dirname(node.id); return dir === '.' || dir === 'src' || node.id === entryName || node.id === `src/${entryName}`; }); if (match) return match; } // Fallback: file with the most outgoing import edges const importEdges = edges.filter(e => e.type === 'imports'); let maxOutgoing = -1; let bestNode; for (const node of fileNodes) { const outgoing = importEdges.filter(e => e.source === node.id).length; if (outgoing > maxOutgoing) { maxOutgoing = outgoing; bestNode = node; } } return bestNode; } /** * Finds the layer a file node belongs to. */ function getNodeLayer(nodeId, layers) { return layers.find(layer => layer.nodeIds.includes(nodeId)); } /** * Gets child node IDs (functions/classes) that belong to a file. * These are nodes whose ID starts with the file path followed by "::". */ function getChildNodeIds(fileNodeId, allNodes) { const prefix = fileNodeId + '::'; return allNodes .filter(n => n.id.startsWith(prefix)) .map(n => n.id); } /** * Generates a description for a tour step based on the node's summary and layer. */ function generateDescription(node, layer) { const layerInfo = layer ? ` (${layer.name} layer)` : ''; if (node.summary) { return `${node.summary}${layerInfo}`; } return `Source file${layerInfo}`; } /** * Performs BFS from the entry point following import edges. * Returns file node IDs in BFS order (no duplicates). */ function bfsFromEntryPoint(entryPointId, fileNodes, edges, maxSteps) { const fileNodeIds = new Set(fileNodes.map(n => n.id)); const importEdges = edges.filter(e => e.type === 'imports'); const visited = new Set(); const queue = [entryPointId]; const result = []; visited.add(entryPointId); while (queue.length > 0 && result.length < maxSteps) { const current = queue.shift(); result.push(current); if (result.length >= maxSteps) break; // Find all files imported by the current file const neighbors = importEdges .filter(e => e.source === current) .map(e => e.target) .filter(target => fileNodeIds.has(target) && !visited.has(target)); for (const neighbor of neighbors) { visited.add(neighbor); queue.push(neighbor); } } return result; } /** * Supplements BFS results with representative files from uncovered layers. * Picks the file with the most edges (most connected) from each uncovered layer. */ function supplementWithLayers(bfsFileIds, fileNodes, edges, layers, maxSteps) { if (layers.length === 0) return bfsFileIds; const coveredIds = new Set(bfsFileIds); // Find which layers are already covered const coveredLayerIds = new Set(); for (const fileId of bfsFileIds) { const layer = getNodeLayer(fileId, layers); if (layer) coveredLayerIds.add(layer.id); } const result = [...bfsFileIds]; const fileNodeIds = new Set(fileNodes.map(n => n.id)); // For each uncovered layer, pick the most connected file for (const layer of layers) { if (result.length >= maxSteps) break; if (coveredLayerIds.has(layer.id)) continue; // Get file nodes in this layer const layerFileIds = layer.nodeIds.filter(id => fileNodeIds.has(id) && !coveredIds.has(id)); if (layerFileIds.length === 0) continue; // Pick the file with the most edges (incoming + outgoing) let bestFileId = layerFileIds[0]; let maxEdges = 0; for (const fileId of layerFileIds) { const edgeCount = edges.filter(e => e.source === fileId || e.target === fileId).length; if (edgeCount > maxEdges) { maxEdges = edgeCount; bestFileId = fileId; } } result.push(bestFileId); coveredIds.add(bestFileId); coveredLayerIds.add(layer.id); } return result; } /** * Generates a guided tour through the project architecture. * * Tour generation strategy: * 1. Find entry point (index.ts, main.py, App.tsx, etc.) * 2. Follow import chain from entry point (BFS, max 10 steps) * 3. Each step covers a new layer or key component * 4. Generate title from file/function name, description from structure * * If BFS produces fewer than 5 steps, supplements with one representative * file from each layer not yet covered (most connected file). * * @param nodes - All graph nodes * @param edges - All graph edges (used to follow import chains) * @param layers - Detected architectural layers * @returns Ordered array of tour steps */ export function buildTour(nodes, edges, layers) { // Edge cases if (nodes.length === 0) return []; const fileNodes = nodes.filter(n => n.type === 'file'); if (fileNodes.length === 0) return []; const maxSteps = 10; const minBfsSteps = 5; // Find entry point const entryPoint = findEntryPoint(fileNodes, edges); if (!entryPoint) return []; // BFS from entry point let tourFileIds = bfsFromEntryPoint(entryPoint.id, fileNodes, edges, maxSteps); // Supplement with layer representatives if BFS is short if (tourFileIds.length < minBfsSteps) { tourFileIds = supplementWithLayers(tourFileIds, fileNodes, edges, layers, maxSteps); } // Generate tour steps const steps = []; for (let i = 0; i < tourFileIds.length; i++) { const fileId = tourFileIds[i]; const fileNode = fileNodes.find(n => n.id === fileId); if (!fileNode) continue; const layer = getNodeLayer(fileId, layers); const childIds = getChildNodeIds(fileId, nodes); steps.push({ order: i + 1, title: fileNode.name, description: generateDescription(fileNode, layer), nodeIds: [fileId, ...childIds], }); } return steps; }