Spaces:
Running
Running
File size: 7,093 Bytes
fd8cdf5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | 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;
}
|