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