Spaces:
Running
Running
| import * as fs from 'node:fs'; | |
| import * as path from 'node:path'; | |
| import { execSync } from 'node:child_process'; | |
| import { scanProject } from './scanner.js'; | |
| import { parseFile } from './parser.js'; | |
| import { buildGraph } from './graph-builder.js'; | |
| import { detectLayers } from './layer-detector.js'; | |
| import { buildTour } from './tour-builder.js'; | |
| /** | |
| * Reads the existing meta.json from the target path. | |
| * Returns null if the file doesn't exist or can't be parsed. | |
| */ | |
| function readExistingMeta(targetPath) { | |
| try { | |
| const metaPath = path.join(targetPath, '.understand-anything', 'meta.json'); | |
| const content = fs.readFileSync(metaPath, 'utf-8'); | |
| return JSON.parse(content); | |
| } | |
| catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * Gets the list of files changed since a given git commit hash. | |
| * Returns null if git diff fails or the commit is not found. | |
| */ | |
| function getChangedFiles(targetPath, sinceCommit) { | |
| try { | |
| const output = execSync(`git diff --name-only ${sinceCommit} HEAD`, { | |
| cwd: targetPath, | |
| encoding: 'utf-8', | |
| stdio: ['pipe', 'pipe', 'pipe'], | |
| }); | |
| const files = output.trim().split('\n').filter(f => f.length > 0); | |
| return files.length > 0 ? files : null; | |
| } | |
| catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * Reads the existing knowledge-graph.json and extracts nodes and edges. | |
| * Returns null if the file doesn't exist or can't be parsed. | |
| */ | |
| function readExistingGraph(targetPath) { | |
| try { | |
| const graphPath = path.join(targetPath, '.understand-anything', 'knowledge-graph.json'); | |
| const content = fs.readFileSync(graphPath, 'utf-8'); | |
| const data = JSON.parse(content); | |
| if (data.nodes && data.edges) { | |
| return { nodes: data.nodes, edges: data.edges }; | |
| } | |
| return null; | |
| } | |
| catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * Computes statistics from the analysis results. | |
| */ | |
| function computeStats(filesAnalyzed, nodes, edges, layers) { | |
| const nodesByType = {}; | |
| for (const node of nodes) { | |
| nodesByType[node.type] = (nodesByType[node.type] || 0) + 1; | |
| } | |
| return { | |
| filesAnalyzed, | |
| nodesByType, | |
| edgesCreated: edges.length, | |
| layersIdentified: layers.length, | |
| }; | |
| } | |
| /** | |
| * Creates an empty result with zero stats. | |
| */ | |
| function createEmptyResult() { | |
| const emptyMetadata = { | |
| name: '', | |
| description: '', | |
| languages: [], | |
| frameworks: [], | |
| analyzedAt: new Date().toISOString(), | |
| gitCommitHash: '', | |
| }; | |
| return { | |
| dashboard: { | |
| version: '1.0.0', | |
| project: emptyMetadata, | |
| nodes: [], | |
| edges: [], | |
| layers: [], | |
| tour: [], | |
| }, | |
| stats: { | |
| filesAnalyzed: 0, | |
| nodesByType: {}, | |
| edgesCreated: 0, | |
| layersIdentified: 0, | |
| }, | |
| files: [], | |
| }; | |
| } | |
| /** | |
| * Parses all files and returns a map of relativePath → ParseResult. | |
| * Skips files that fail to parse (logs warning to stderr). | |
| */ | |
| function parseFiles(files) { | |
| const parseResults = new Map(); | |
| for (const file of files) { | |
| try { | |
| const result = parseFile(file.path, file.language); | |
| parseResults.set(file.relativePath, result); | |
| } | |
| catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| process.stderr.write(`Warning: Failed to parse ${file.relativePath}: ${message}\n`); | |
| } | |
| } | |
| return parseResults; | |
| } | |
| /** | |
| * Runs the full analysis pipeline on all files. | |
| */ | |
| function runFullPipeline(scanResult) { | |
| const { files, metadata } = scanResult; | |
| if (files.length === 0) { | |
| return createEmptyResult(); | |
| } | |
| // Parse all files | |
| const parseResults = parseFiles(files); | |
| // Build graph | |
| const { nodes, edges } = buildGraph(files, parseResults); | |
| // Detect layers | |
| const layers = detectLayers(nodes, edges); | |
| // Build tour | |
| const tour = buildTour(nodes, edges, layers); | |
| // Assemble dashboard | |
| const dashboard = { | |
| version: '1.0.0', | |
| project: { | |
| ...metadata, | |
| analyzedAt: new Date().toISOString(), | |
| }, | |
| nodes, | |
| edges, | |
| layers, | |
| tour, | |
| }; | |
| const stats = computeStats(files.length, nodes, edges, layers); | |
| return { dashboard, stats, files }; | |
| } | |
| /** | |
| * Main entry point for the static analysis engine. | |
| * | |
| * Scans the target directory, parses source files, builds a knowledge graph, | |
| * detects architectural layers, and generates a guided tour — all without | |
| * requiring an AI agent. | |
| * | |
| * Supports incremental mode: when an existing meta.json is found and --full | |
| * is not specified, only re-analyzes files changed since the last commit hash. | |
| * | |
| * @param targetPath - Absolute path to the directory to analyze | |
| * @param options - Analysis options (e.g., full rebuild vs incremental) | |
| * @returns AnalyzeResult with the generated dashboard and statistics | |
| */ | |
| export async function analyze(targetPath, options) { | |
| try { | |
| // Scan the project | |
| const scanResult = scanProject(targetPath); | |
| if (scanResult.files.length === 0) { | |
| return createEmptyResult(); | |
| } | |
| // Determine if we should use incremental mode | |
| if (!options.full) { | |
| const existingMeta = readExistingMeta(targetPath); | |
| if (existingMeta && existingMeta.gitCommitHash) { | |
| const changedFiles = getChangedFiles(targetPath, existingMeta.gitCommitHash); | |
| if (changedFiles) { | |
| // Attempt incremental analysis | |
| const incrementalResult = runIncrementalPipeline(targetPath, scanResult, changedFiles); | |
| if (incrementalResult) { | |
| return incrementalResult; | |
| } | |
| // Fall through to full rebuild if incremental fails | |
| } | |
| } | |
| } | |
| // Full rebuild | |
| return runFullPipeline(scanResult); | |
| } | |
| catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| process.stderr.write(`Error during analysis: ${message}\n`); | |
| return createEmptyResult(); | |
| } | |
| } | |
| /** | |
| * Runs incremental analysis: only re-parses changed files, reuses existing | |
| * nodes/edges for unchanged files, then rebuilds the full graph. | |
| * | |
| * Returns null if incremental analysis cannot be completed (caller should | |
| * fall back to full rebuild). | |
| */ | |
| function runIncrementalPipeline(targetPath, scanResult, changedFiles) { | |
| // Read existing graph for unchanged file data | |
| const existingGraph = readExistingGraph(targetPath); | |
| if (!existingGraph) { | |
| return null; // Fall back to full rebuild | |
| } | |
| const changedSet = new Set(changedFiles); | |
| const { files, metadata } = scanResult; | |
| // Split files into changed and unchanged | |
| const changedFileEntries = files.filter(f => changedSet.has(f.relativePath)); | |
| const unchangedFileEntries = files.filter(f => !changedSet.has(f.relativePath)); | |
| // Parse only changed files | |
| const changedParseResults = parseFiles(changedFileEntries); | |
| // For unchanged files, we need their parse results too for graph building. | |
| // Re-parse them (this is cheaper than storing full parse results in the graph). | |
| const unchangedParseResults = parseFiles(unchangedFileEntries); | |
| // Combine all parse results | |
| const allParseResults = new Map(); | |
| for (const [key, value] of changedParseResults) { | |
| allParseResults.set(key, value); | |
| } | |
| for (const [key, value] of unchangedParseResults) { | |
| allParseResults.set(key, value); | |
| } | |
| // Rebuild the full graph from combined results | |
| const { nodes, edges } = buildGraph(files, allParseResults); | |
| // Detect layers | |
| const layers = detectLayers(nodes, edges); | |
| // Build tour | |
| const tour = buildTour(nodes, edges, layers); | |
| // Assemble dashboard | |
| const dashboard = { | |
| version: '1.0.0', | |
| project: { | |
| ...metadata, | |
| analyzedAt: new Date().toISOString(), | |
| }, | |
| nodes, | |
| edges, | |
| layers, | |
| tour, | |
| }; | |
| // Stats reflect only the changed files as "analyzed" | |
| const stats = computeStats(changedFileEntries.length, nodes, edges, layers); | |
| return { dashboard, stats, files }; | |
| } | |
| export { scanProject } from './scanner.js'; | |
| export { parseFile } from './parser.js'; | |
| export { buildGraph } from './graph-builder.js'; | |
| export { detectLayers } from './layer-detector.js'; | |
| export { buildTour } from './tour-builder.js'; | |
| export { generateDomainContext } from './domain-context.js'; | |