Spaces:
Running
Running
| import * as path from 'node:path'; | |
| import * as posix from 'node:path/posix'; | |
| /** | |
| * Generates a summary string from a file's parse result and metadata. | |
| * | |
| * Priority: | |
| * 1. Exports list (max 5, then "...") | |
| * 2. Classes list | |
| * 3. Functions list (if no exports) | |
| * 4. Fallback: "{lineCount} lines of {language}" | |
| */ | |
| function generateSummary(parseResult, file) { | |
| if (!parseResult) { | |
| return `${file.lineCount} lines of ${file.language}`; | |
| } | |
| if (parseResult.exports.length > 0) { | |
| const maxExports = 5; | |
| const shown = parseResult.exports.slice(0, maxExports); | |
| const suffix = parseResult.exports.length > maxExports ? ', ...' : ''; | |
| return `Exports: ${shown.join(', ')}${suffix}`; | |
| } | |
| if (parseResult.classes.length > 0) { | |
| const classNames = parseResult.classes.map(c => c.name); | |
| return `Classes: ${classNames.join(', ')}`; | |
| } | |
| if (parseResult.functions.length > 0) { | |
| const funcNames = parseResult.functions.map(f => f.name); | |
| return `Functions: ${funcNames.join(', ')}`; | |
| } | |
| return `${file.lineCount} lines of ${file.language}`; | |
| } | |
| /** | |
| * Assigns tags to a file node based on language, category, path patterns, and complexity. | |
| */ | |
| function assignTags(file) { | |
| const tags = []; | |
| // Language tag | |
| tags.push(file.language); | |
| // Category tag | |
| tags.push(file.category); | |
| // Path-based tags | |
| const relativeLower = file.relativePath.toLowerCase(); | |
| if (/(?:^|\/)(test|spec|__test__|__tests__|__spec__)(?:\/|$)/.test(relativeLower) || | |
| /\.(test|spec)\.[^/]+$/.test(relativeLower)) { | |
| tags.push('test'); | |
| } | |
| if (/(?:^|\/)components?(?:\/|$)/.test(relativeLower)) { | |
| tags.push('component'); | |
| } | |
| if (/(?:^\/|\/)(utils?|lib|helpers?)(?:\/|$)/.test(relativeLower) || | |
| /^(utils?|lib|helpers?)(?:\/|$)/.test(relativeLower)) { | |
| tags.push('util'); | |
| } | |
| if (/(?:^|\/)(?:api|routes?|controllers?)(?:\/|$)/.test(relativeLower)) { | |
| tags.push('api'); | |
| } | |
| if (/(?:^|\/)(?:models?|entities)(?:\/|$)/.test(relativeLower)) { | |
| tags.push('model'); | |
| } | |
| // Complexity tag based on line count | |
| if (file.lineCount < 50) { | |
| tags.push('simple'); | |
| } | |
| else if (file.lineCount <= 200) { | |
| tags.push('moderate'); | |
| } | |
| else { | |
| tags.push('complex'); | |
| } | |
| return tags; | |
| } | |
| /** | |
| * Builds file-level graph nodes from scanned files and their parse results. | |
| * | |
| * For each file, creates a DashboardNode with: | |
| * - id: the file's relative path | |
| * - type: "file" | |
| * - name: the file's basename | |
| * - summary: generated from parse result (exports, classes, functions, or fallback) | |
| * - tags: language, category, path-based patterns, and complexity | |
| * | |
| * @param files - Scanned file entries with metadata | |
| * @param parseResults - Map of file relative path to its parse result | |
| * @returns Array of DashboardNode for each file | |
| */ | |
| export function buildFileNodes(files, parseResults) { | |
| return files.map(file => { | |
| const parseResult = parseResults.get(file.relativePath); | |
| return { | |
| id: file.relativePath, | |
| type: 'file', | |
| name: path.basename(file.relativePath), | |
| summary: generateSummary(parseResult, file), | |
| tags: assignTags(file), | |
| }; | |
| }); | |
| } | |
| /** | |
| * Builds function-level graph nodes for significant functions. | |
| * | |
| * A function is "significant" if it is exported OR has 10+ lines (endLine - startLine + 1 >= 10). | |
| * | |
| * For each significant function, creates a DashboardNode with: | |
| * - id: "{relativePath}::{functionName}" | |
| * - type: "function" | |
| * - name: the function name | |
| * - summary: "({params}) → {lineCount} lines" | |
| * - tags: language, "exported" if exported, "async" if name suggests async | |
| * | |
| * Also creates a `contains` edge from the file node to the function node. | |
| * | |
| * @param files - Scanned file entries with metadata | |
| * @param parseResults - Map of file relative path to its parse result | |
| * @returns Object with nodes and edges arrays | |
| */ | |
| export function buildFunctionNodes(files, parseResults) { | |
| const nodes = []; | |
| const edges = []; | |
| for (const file of files) { | |
| const parseResult = parseResults.get(file.relativePath); | |
| if (!parseResult) | |
| continue; | |
| for (const func of parseResult.functions) { | |
| const lineCount = func.endLine - func.startLine + 1; | |
| const isSignificant = func.isExported || lineCount >= 10; | |
| if (!isSignificant) | |
| continue; | |
| const nodeId = `${file.relativePath}::${func.name}`; | |
| const tags = [file.language]; | |
| if (func.isExported) | |
| tags.push('exported'); | |
| if (func.name.startsWith('async') || func.name.includes('Async') || func.name.includes('async')) { | |
| tags.push('async'); | |
| } | |
| nodes.push({ | |
| id: nodeId, | |
| type: 'function', | |
| name: func.name, | |
| summary: `(${func.params.join(', ')}) → ${lineCount} lines`, | |
| tags, | |
| }); | |
| edges.push({ | |
| source: file.relativePath, | |
| target: nodeId, | |
| type: 'contains', | |
| }); | |
| } | |
| } | |
| return { nodes, edges }; | |
| } | |
| /** | |
| * Builds class-level graph nodes for all classes found in parse results. | |
| * | |
| * For each class, creates a DashboardNode with: | |
| * - id: "{relativePath}::{className}" | |
| * - type: "class" | |
| * - name: the class name | |
| * - summary: "Methods: {methods}" or "Empty class" if no methods | |
| * - tags: language, "exported" if exported, size tag (small/medium/large-class) | |
| * | |
| * Also creates a `contains` edge from the file node to the class node. | |
| * | |
| * @param files - Scanned file entries with metadata | |
| * @param parseResults - Map of file relative path to its parse result | |
| * @returns Object with nodes and edges arrays | |
| */ | |
| export function buildClassNodes(files, parseResults) { | |
| const nodes = []; | |
| const edges = []; | |
| for (const file of files) { | |
| const parseResult = parseResults.get(file.relativePath); | |
| if (!parseResult) | |
| continue; | |
| for (const cls of parseResult.classes) { | |
| const nodeId = `${file.relativePath}::${cls.name}`; | |
| const tags = [file.language]; | |
| if (cls.isExported) | |
| tags.push('exported'); | |
| // Size tag based on method count | |
| const methodCount = cls.methods.length; | |
| if (methodCount <= 3) { | |
| tags.push('small-class'); | |
| } | |
| else if (methodCount <= 10) { | |
| tags.push('medium-class'); | |
| } | |
| else { | |
| tags.push('large-class'); | |
| } | |
| const summary = cls.methods.length > 0 | |
| ? `Methods: ${cls.methods.join(', ')}` | |
| : 'Empty class'; | |
| nodes.push({ | |
| id: nodeId, | |
| type: 'class', | |
| name: cls.name, | |
| summary, | |
| tags, | |
| }); | |
| edges.push({ | |
| source: file.relativePath, | |
| target: nodeId, | |
| type: 'contains', | |
| }); | |
| } | |
| } | |
| return { nodes, edges }; | |
| } | |
| /** | |
| * Common file extensions to try when resolving import paths. | |
| */ | |
| const RESOLVE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.go', '.rs', '.java']; | |
| /** | |
| * Index file names to try for directory imports. | |
| */ | |
| const INDEX_FILES = ['index.ts', 'index.js']; | |
| /** | |
| * Resolves a relative import path to a known file's relativePath. | |
| * | |
| * Tries the following resolution strategies: | |
| * 1. Exact path (if it already has an extension and matches) | |
| * 2. Path + each common extension | |
| * 3. Path as directory + index files | |
| * | |
| * @param importSource - The raw import path (e.g., './utils' or '../lib/helper') | |
| * @param importingFilePath - The relativePath of the file containing the import | |
| * @param knownFiles - Set of all known file relative paths | |
| * @returns The resolved relativePath or undefined if not found | |
| */ | |
| function resolveImportPath(importSource, importingFilePath, knownFiles) { | |
| // Get the directory of the importing file | |
| const importingDir = posix.dirname(importingFilePath); | |
| // Resolve the relative import path against the importing file's directory | |
| const resolved = posix.normalize(posix.join(importingDir, importSource)); | |
| // Try exact match first (path already has extension) | |
| if (knownFiles.has(resolved)) { | |
| return resolved; | |
| } | |
| // Try appending common extensions | |
| for (const ext of RESOLVE_EXTENSIONS) { | |
| const withExt = resolved + ext; | |
| if (knownFiles.has(withExt)) { | |
| return withExt; | |
| } | |
| } | |
| // Try as directory import (index files) | |
| for (const indexFile of INDEX_FILES) { | |
| const asIndex = posix.join(resolved, indexFile); | |
| if (knownFiles.has(asIndex)) { | |
| return asIndex; | |
| } | |
| } | |
| return undefined; | |
| } | |
| /** | |
| * Builds import edges from resolved relative import paths. | |
| * | |
| * For each file's imports: | |
| * - Only processes imports with relative paths (starting with '.' or '..') | |
| * - Resolves the import path relative to the importing file's directory | |
| * - If the resolved path matches a known file, creates an 'imports' edge | |
| * - Skips external package imports and unresolvable imports | |
| * | |
| * @param files - Scanned file entries with metadata | |
| * @param parseResults - Map of file relative path to its parse result | |
| * @returns Array of DashboardEdge with type 'imports' | |
| */ | |
| export function buildImportEdges(files, parseResults) { | |
| const edges = []; | |
| const knownFiles = new Set(files.map(f => f.relativePath)); | |
| for (const file of files) { | |
| const parseResult = parseResults.get(file.relativePath); | |
| if (!parseResult) | |
| continue; | |
| for (const imp of parseResult.imports) { | |
| // Skip external package imports (not starting with . or ..) | |
| if (!imp.source.startsWith('.')) | |
| continue; | |
| const resolvedPath = resolveImportPath(imp.source, file.relativePath, knownFiles); | |
| if (resolvedPath) { | |
| edges.push({ | |
| source: file.relativePath, | |
| target: resolvedPath, | |
| type: 'imports', | |
| }); | |
| } | |
| } | |
| } | |
| return edges; | |
| } | |
| /** | |
| * Builds call edges using a simple heuristic based on named imports. | |
| * | |
| * For each file's imports that resolve to a known file: | |
| * - If the import has named imports (e.g., `import { formatDate } from './utils'`) | |
| * - And the target file has a significant function node with that name | |
| * - Creates a 'calls' edge from the importing file to the function node | |
| * | |
| * This is a best-effort heuristic — it won't catch all calls but provides useful edges. | |
| * | |
| * @param files - Scanned file entries with metadata | |
| * @param parseResults - Map of file relative path to its parse result | |
| * @returns Array of DashboardEdge with type 'calls' | |
| */ | |
| export function buildCallEdges(files, parseResults) { | |
| const edges = []; | |
| const knownFiles = new Set(files.map(f => f.relativePath)); | |
| // Build a set of significant function node IDs for quick lookup | |
| const significantFunctions = new Set(); | |
| for (const file of files) { | |
| const parseResult = parseResults.get(file.relativePath); | |
| if (!parseResult) | |
| continue; | |
| for (const func of parseResult.functions) { | |
| const lineCount = func.endLine - func.startLine + 1; | |
| const isSignificant = func.isExported || lineCount >= 10; | |
| if (isSignificant) { | |
| significantFunctions.add(`${file.relativePath}::${func.name}`); | |
| } | |
| } | |
| } | |
| for (const file of files) { | |
| const parseResult = parseResults.get(file.relativePath); | |
| if (!parseResult) | |
| continue; | |
| for (const imp of parseResult.imports) { | |
| // Skip external package imports | |
| if (!imp.source.startsWith('.')) | |
| continue; | |
| const resolvedPath = resolveImportPath(imp.source, file.relativePath, knownFiles); | |
| if (!resolvedPath) | |
| continue; | |
| // Check each named import against significant functions in the target file | |
| for (const name of imp.names) { | |
| const targetNodeId = `${resolvedPath}::${name}`; | |
| if (significantFunctions.has(targetNodeId)) { | |
| edges.push({ | |
| source: file.relativePath, | |
| target: targetNodeId, | |
| type: 'calls', | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| return edges; | |
| } | |
| /** | |
| * Builds graph nodes and edges from scanned files and their parse results. | |
| * | |
| * Generates: | |
| * - File nodes for each source file | |
| * - Function nodes for each extracted function | |
| * - Class nodes for each extracted class | |
| * - Import edges between files | |
| * - Containment edges (file → function/class) | |
| * - Call relationship edges where detected | |
| * | |
| * @param files - Scanned file entries with metadata | |
| * @param parseResults - Map of file relative path to its parse result | |
| * @returns GraphOutput with all generated nodes and edges | |
| */ | |
| export function buildGraph(files, parseResults) { | |
| const nodes = []; | |
| const edges = []; | |
| // Build file nodes | |
| const fileNodes = buildFileNodes(files, parseResults); | |
| nodes.push(...fileNodes); | |
| // Build function nodes and containment edges | |
| const functionResult = buildFunctionNodes(files, parseResults); | |
| nodes.push(...functionResult.nodes); | |
| edges.push(...functionResult.edges); | |
| // Build class nodes and containment edges | |
| const classResult = buildClassNodes(files, parseResults); | |
| nodes.push(...classResult.nodes); | |
| edges.push(...classResult.edges); | |
| // Build import edges | |
| const importEdges = buildImportEdges(files, parseResults); | |
| edges.push(...importEdges); | |
| // Build call edges | |
| const callEdges = buildCallEdges(files, parseResults); | |
| edges.push(...callEdges); | |
| return { | |
| nodes, | |
| edges, | |
| }; | |
| } | |