Spaces:
Running
Running
| 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; | |
| } | |