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;
}