import { execSync, spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdir, readdir, stat, writeFile, readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, extname, join, relative, resolve, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; import type { KnowledgeGraph, KnowledgeGraphNode } from '../index.js'; import { analyzeProjectWithTreeSitter } from './tree-sitter-analyzer.js'; const GRAPH_RELATIVE_PATH = '.understand-anything/knowledge-graph.json'; const GRAPH_TTL_MS = 24 * 60 * 60 * 1000; const UA_PLUGIN_ROOT = join(homedir(), '.qwen-s', 'understand-anything', 'understand-anything-plugin'); const UA_CORE_DIR = join( UA_PLUGIN_ROOT, 'packages', 'core', ); const ALLOWED_EXTENSIONS = new Set([ // Python '.py', // TypeScript / JavaScript '.ts', '.tsx', '.js', '.jsx', // Go '.go', // C# / .NET '.cs', // Java / Kotlin '.java', '.kt', // Rust '.rs', // C / C++ '.c', '.cpp', '.cc', '.cxx', '.h', '.hpp', // Swift '.swift', // PHP '.php', // Ruby '.rb', ]); export const EXCLUDED_DIRS = new Set([ 'node_modules', '.next', '.git', 'supreme-max-env', '__pycache__', '.mypy_cache', '.pytest_cache', 'dist', 'build', '.venv', 'venv', 'env', 'virtualenv', '.tox', 'site-packages', '.env', 'coverage', '.nyc_output', '.supreme2l', '.understand-anything', 'out', '.cache', 'tmp', 'temp', ]); /** * Uses cached Understand-Anything graph or falls back to static file-level graph. */ export async function runUnderstandAnything( projectPath: string, options?: { skipUa?: boolean }, onProgress?: (message: string) => void, ): Promise { const graphPath = resolve(projectPath, GRAPH_RELATIVE_PATH); if (options?.skipUa === true) { if (existsSync(graphPath)) { onProgress?.('✓ Using cached knowledge graph'); return; } onProgress?.('⚠️ Understand-Anything unavailable, using tree-sitter analyzer...'); await buildTreeSitterGraph(projectPath, onProgress); return; } if (await isFreshGraph(graphPath)) { onProgress?.('✓ Using cached knowledge graph'); return; } onProgress?.('🧠 Attempting Understand-Anything pipeline...'); const success = await runUAPipelineDirectly(projectPath, onProgress); if (success) { onProgress?.('✓ Knowledge graph built with real edges!'); return; } onProgress?.('⚠️ Understand-Anything unavailable, using tree-sitter analyzer...'); await buildTreeSitterGraph(projectPath, onProgress); } /** * Reads and validates the generated knowledge graph. */ export async function readKnowledgeGraph(projectPath: string): Promise { const graphPath = resolve(projectPath, GRAPH_RELATIVE_PATH); let raw: string; try { raw = await readFile(graphPath, 'utf8'); } catch { throw new Error(`Knowledge graph not found: ${graphPath}. Run architecture builder first.`); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown JSON parse error'; throw new Error(`Failed to parse knowledge graph JSON: ${message}`); } if (!isKnowledgeGraph(parsed)) { throw new Error('Invalid knowledge graph format: expected object with nodes[] and edges[] arrays.'); } return parsed; } /** * Builds a minimal file-level graph when Understand-Anything is unavailable. */ export async function buildFallbackGraph( projectPath: string, onProgress?: (message: string) => void, ): Promise { const graphPath = resolve(projectPath, GRAPH_RELATIVE_PATH); if (await hasExistingGraphWithEdges(graphPath)) { onProgress?.('✓ Using existing knowledge graph (UA analysis preserved)'); return; } const files = await collectSourceFiles(projectPath, onProgress); onProgress?.(`✓ Found ${String(files.length)} source files`); const nodes: KnowledgeGraphNode[] = []; for (const filePath of files) { const relativePath = normalizePath(relative(projectPath, filePath)); onProgress?.(`🔍 Analyzing: ${relativePath}`); nodes.push({ id: relativePath, type: 'file', name: basename(relativePath), path: relativePath, layer: inferLayer(relativePath), summary: '', functions: [], imports: [], }); } const graph: KnowledgeGraph = { nodes, edges: [], }; await mkdir(resolve(projectPath, '.understand-anything'), { recursive: true }); await writeFile(graphPath, JSON.stringify(graph, null, 2), 'utf8'); onProgress?.(`✓ Graph built: ${String(nodes.length)} nodes, 0 edges`); console.warn('⚠️ Using fallback graph (no edges). Install understand-anything for full analysis.'); } async function buildTreeSitterGraph( projectPath: string, onProgress?: (message: string) => void, ): Promise { try { const graph = await analyzeProjectWithTreeSitter(projectPath, onProgress); const graphPath = resolve(projectPath, GRAPH_RELATIVE_PATH); await mkdir(resolve(projectPath, '.understand-anything'), { recursive: true }); await writeFile(graphPath, JSON.stringify(graph, null, 2), 'utf8'); onProgress?.(`✓ Tree-sitter graph saved: ${String(graph.nodes.length)} nodes, ${String(graph.edges.length)} edges`); } catch (error) { if (isMissingTreeSitterError(error)) { throw new Error('tree-sitter not found. Run: npm install -g qwen-code-s-edition'); } throw error; } } function isMissingTreeSitterError(error: unknown): boolean { if (!(error instanceof Error)) { return false; } return error.message.includes("Cannot find module 'tree-sitter'") || error.message.includes('tree-sitter'); } type UaAnalyzeModule = { GraphBuilder: new (projectName: string, gitHash: string) => { addFileWithAnalysis: ( filePath: string, analysis: { functions: Array<{ name: string }>; classes: Array<{ name: string }>; imports: Array<{ source: string; specifiers: string[]; lineNumber: number }>; exports: Array<{ name: string; lineNumber: number }>; }, meta: { summaries: Record; fileSummary: string; summary: string; tags: string[]; complexity: 'simple' | 'moderate' | 'complex'; }, ) => void; addImportEdge: (fromFile: string, toFile: string) => void; addCallEdge: ( callerFile: string, callerFunc: string, calleeFile: string, calleeFunc: string, ) => void; build: () => { nodes: Array<{ id: string; type: string; name: string; filePath?: string; summary?: string }>; edges: Array<{ source: string; target: string; type: string }>; }; }; TreeSitterPlugin: new () => { init: () => Promise; analyzeFile: ( filePath: string, content: string, ) => { functions: Array<{ name: string }>; classes: Array<{ name: string }>; imports: Array<{ source: string; specifiers: string[]; lineNumber: number }>; exports: Array<{ name: string; lineNumber: number }>; }; resolveImports: ( filePath: string, content: string, ) => Array<{ source: string; resolvedPath: string; specifiers: string[] }>; extractCallGraph?: ( filePath: string, content: string, ) => Array<{ caller: string; callee: string; lineNumber: number }>; }; }; type UaTreeSitterModule = { TreeSitterPlugin: new () => { init: () => Promise; analyzeFile: ( filePath: string, content: string, ) => { functions: Array<{ name: string }>; classes: Array<{ name: string }>; imports: Array<{ source: string; specifiers: string[]; lineNumber: number }>; exports: Array<{ name: string; lineNumber: number }>; }; resolveImports: ( filePath: string, content: string, ) => Array<{ source: string; resolvedPath: string; specifiers: string[] }>; extractCallGraph?: ( filePath: string, content: string, ) => Array<{ caller: string; callee: string; lineNumber: number }>; }; }; async function runUAPipelineDirectly( projectPath: string, onProgress?: (message: string) => void, ): Promise { if (!existsSync(UA_CORE_DIR)) { return false; } const webTreeSitterPath = join(UA_PLUGIN_ROOT, 'node_modules', 'web-tree-sitter'); if (!existsSync(webTreeSitterPath)) { onProgress?.('📦 Installing UA dependencies (first run)...'); // UA uses pnpm workspaces; npm does not support workspace:* protocol. const pnpmCheck = spawnSync('pnpm', ['--version'], { stdio: 'pipe' }); if (pnpmCheck.status !== 0) { onProgress?.('⚠️ pnpm required for UA dependencies. Install: npm install -g pnpm'); return false; } try { execSync('pnpm install --no-frozen-lockfile', { cwd: UA_PLUGIN_ROOT, stdio: 'pipe', timeout: 300000, }); onProgress?.('✓ UA dependencies installed'); } catch (e) { const err = e as { stderr?: Buffer; message?: string }; const detail = err.stderr?.toString().slice(0, 200) || String(e); onProgress?.(`⚠️ UA dependency install failed: ${detail}`); return false; } } const distIndexPath = join(UA_CORE_DIR, 'dist', 'index.js'); if (!existsSync(distIndexPath)) { onProgress?.('🔨 Building UA core...'); try { execSync('pnpm --filter @understand-anything/core build', { cwd: UA_PLUGIN_ROOT, stdio: 'pipe', }); } catch (error) { try { execSync('pnpm run build', { cwd: UA_CORE_DIR, stdio: 'pipe' }); } catch { const message = error instanceof Error ? error.message : 'unknown error'; onProgress?.(`❌ UA core build failed: ${message}`); return false; } } } if (!existsSync(distIndexPath)) { return false; } onProgress?.(` Loading from: ${distIndexPath}`); try { const graphBuilderModulePath = pathToFileURL(join(UA_CORE_DIR, 'dist', 'analyzer', 'graph-builder.js')).href; const treeSitterModulePath = pathToFileURL(join(UA_CORE_DIR, 'dist', 'plugins', 'tree-sitter-plugin.js')).href; const graphBuilderModule: UaAnalyzeModule = await import(graphBuilderModulePath); const treeSitterModule: UaTreeSitterModule = await import(treeSitterModulePath); const GraphBuilder = graphBuilderModule.GraphBuilder; const TreeSitterPlugin = treeSitterModule.TreeSitterPlugin; if (typeof GraphBuilder !== 'function' || typeof TreeSitterPlugin !== 'function') { return false; } onProgress?.('✓ UA core imported successfully'); onProgress?.('🧠 Running Understand-Anything pipeline...'); const treeSitterPlugin = new TreeSitterPlugin(); try { onProgress?.(' Initializing TreeSitterPlugin...'); await treeSitterPlugin.init(); onProgress?.(' ✓ TreeSitterPlugin initialized'); } catch (e) { onProgress?.(` ❌ TreeSitterPlugin.init failed: ${String(e).slice(0, 300)}`); return false; } const projectRoot = resolve(projectPath); const graphPath = resolve(projectRoot, GRAPH_RELATIVE_PATH); let graph: KnowledgeGraph; try { onProgress?.(' Building graph...'); const files = await collectSourceFiles(projectRoot, onProgress); if (files.length === 0) { return false; } const hasSupportedLanguageFile = files.some((filePath) => { const extension = extname(filePath).toLowerCase(); return extension === '.py' || extension === '.ts' || extension === '.tsx' || extension === '.js' || extension === '.jsx' || extension === '.go'; }); const hasCSharpFiles = files.some((filePath) => extname(filePath).toLowerCase() === '.cs'); if (!hasSupportedLanguageFile && hasCSharpFiles) { onProgress?.('⚠️ Project uses C# which is not yet supported by UA pipeline. Using static analysis instead.'); return false; } const relativeFiles = new Set(); const lookup = new Map(); for (const filePath of files) { const normalizedAbsolute = normalizePath(resolve(filePath)); const relativePath = normalizePath(relative(projectRoot, filePath)); relativeFiles.add(relativePath); addImportLookupVariants(normalizedAbsolute, relativePath, lookup); } const graphBuilder = new GraphBuilder(basename(projectRoot), 'unknown'); const fileFunctions = new Map>(); const functionToFiles = new Map>(); const pendingCalls: Array<{ file: string; caller: string; callee: string }> = []; for (const filePath of files) { const relativePath = normalizePath(relative(projectRoot, filePath)); onProgress?.(` Processing file: ${relativePath}`); try { const content = await readFile(filePath, 'utf8'); const analysis = treeSitterPlugin.analyzeFile(filePath, content); const summaries: Record = {}; for (const fn of analysis.functions) { summaries[fn.name] = ''; } for (const cls of analysis.classes) { summaries[cls.name] = ''; } graphBuilder.addFileWithAnalysis(relativePath, analysis, { summary: '', tags: [], complexity: 'moderate', summaries, fileSummary: '', }); const localFunctionNames = new Set(); for (const fn of analysis.functions) { localFunctionNames.add(fn.name); indexFunctionName(functionToFiles, fn.name, relativePath); } fileFunctions.set(relativePath, localFunctionNames); const resolvedImports = treeSitterPlugin.resolveImports(filePath, content); for (const imp of resolvedImports) { const targetFile = resolveProjectImportTarget(imp.resolvedPath, lookup); if (!targetFile || targetFile === relativePath || !relativeFiles.has(targetFile)) { continue; } graphBuilder.addImportEdge(relativePath, targetFile); } const callGraph = treeSitterPlugin.extractCallGraph?.(filePath, content) ?? []; for (const call of callGraph) { const caller = normalizeSymbolName(call.caller); const callee = normalizeSymbolName(call.callee); if (!caller || !callee) { continue; } pendingCalls.push({ file: relativePath, caller, callee }); } } catch (e) { onProgress?.(` ❌ Failed on file ${relativePath}: ${String(e).slice(0, 200)}`); continue; } } for (const call of pendingCalls) { const localNames = fileFunctions.get(call.file); if (!localNames || !localNames.has(call.caller)) { continue; } const candidates = functionToFiles.get(call.callee); if (!candidates || candidates.size === 0) { continue; } const targetFile = candidates.has(call.file) ? call.file : Array.from(candidates)[0]; if (!targetFile) { continue; } graphBuilder.addCallEdge(call.file, call.caller, targetFile, call.callee); } const uaGraph = graphBuilder.build(); graph = { nodes: uaGraph.nodes.map((node) => ({ id: node.id, type: node.type, name: node.name, path: node.filePath, layer: node.filePath ? inferLayer(node.filePath) : 'unknown', summary: node.summary ?? '', functions: [], imports: [], })), edges: uaGraph.edges .map((edge) => ({ from: edge.source, to: edge.target, type: edge.type, })) .filter((edge) => edge.from.length > 0 && edge.to.length > 0), }; if (graph.edges.length === 0) { return false; } onProgress?.(' ✓ Graph construction done'); } catch (e) { onProgress?.(` ❌ Graph build failed: ${String(e).slice(0, 300)}`); return false; } try { onProgress?.(' Writing knowledge-graph.json...'); const outDir = resolve(projectRoot, '.understand-anything'); await mkdir(outDir, { recursive: true }); await writeFile(graphPath, JSON.stringify(graph, null, 2), 'utf8'); onProgress?.(' ✓ File written'); } catch (e) { onProgress?.(` ❌ File write failed: ${String(e).slice(0, 300)}`); return false; } return existsSync(graphPath) && graph.edges.length > 0; } catch (error) { const errMsg = error instanceof Error ? error.message + '\n' + (error.stack ?? '') : String(error); onProgress?.(`⚠️ UA pipeline error: ${errMsg.slice(0, 500)}`); return false; } } function addImportLookupVariants( normalizedAbsolutePath: string, relativePath: string, lookup: Map, ): void { lookup.set(normalizedAbsolutePath, relativePath); const extension = extname(normalizedAbsolutePath).toLowerCase(); if (extension.length > 0) { lookup.set(normalizedAbsolutePath.slice(0, -extension.length), relativePath); } } function resolveProjectImportTarget( resolvedPath: string, lookup: Map, ): string | undefined { const normalized = normalizePath(resolvedPath); if (!isAbsoluteNormalizedPath(normalized)) { return undefined; } const direct = lookup.get(normalized); if (direct) { return direct; } for (const extension of ALLOWED_EXTENSIONS) { const withExtension = lookup.get(`${normalized}${extension}`); if (withExtension) { return withExtension; } const asIndexFile = lookup.get(`${normalized}/index${extension}`); if (asIndexFile) { return asIndexFile; } } return undefined; } function indexFunctionName( index: Map>, functionName: string, filePath: string, ): void { const normalizedName = normalizeSymbolName(functionName); if (!normalizedName) { return; } const current = index.get(normalizedName) ?? new Set(); current.add(filePath); index.set(normalizedName, current); } function normalizeSymbolName(value: string): string { const trimmed = value.trim(); if (trimmed.length === 0) { return ''; } const withoutOptional = trimmed.replace(/\?\./g, '.'); const tail = withoutOptional.includes('.') ? withoutOptional.split('.').at(-1) ?? '' : withoutOptional; return tail.replace(/[^A-Za-z0-9_$]/g, ''); } function isAbsoluteNormalizedPath(value: string): boolean { return value.startsWith('/') || /^[A-Za-z]:\//.test(value); } async function hasExistingGraphWithEdges(graphPath: string): Promise { if (!existsSync(graphPath)) { return false; } try { const raw = await readFile(graphPath, 'utf8'); const parsed: unknown = JSON.parse(raw); if (!isKnowledgeGraph(parsed)) { return false; } return parsed.edges.length > 0; } catch { return false; } } async function isFreshGraph(graphPath: string): Promise { try { const graphStat = await stat(graphPath); const ageMs = Date.now() - graphStat.mtimeMs; return ageMs <= GRAPH_TTL_MS; } catch { return false; } } async function collectSourceFiles( directory: string, onProgress?: (message: string) => void, state: { count: number } = { count: 0 }, ): Promise { const normalizedDirectory = normalizePath(directory).toLowerCase(); if (normalizedDirectory.includes('/site-packages/')) { return []; } const collected: string[] = []; const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(directory, entry.name); if (entry.isDirectory()) { if (EXCLUDED_DIRS.has(entry.name)) { continue; } if (existsSync(join(directory, entry.name, 'pyvenv.cfg'))) { continue; } const nested = await collectSourceFiles(fullPath, onProgress, state); collected.push(...nested); continue; } if (!entry.isFile()) { continue; } if (ALLOWED_EXTENSIONS.has(extname(entry.name).toLowerCase())) { collected.push(fullPath); state.count += 1; if (state.count % 10 === 0) { onProgress?.(`📂 Scanning files... (found ${String(state.count)} files)`); } } } return collected; } function inferLayer(filePath: string): string { const normalized = normalizePath(filePath).toLowerCase(); if (normalized.includes('/api/') || normalized.includes('/routes/')) { return 'api'; } if (normalized.includes('/service/') || normalized.includes('/services/')) { return 'service'; } if (normalized.includes('/db/') || normalized.includes('/models/') || normalized.includes('/database/')) { return 'data'; } if (normalized.includes('/components/') || normalized.includes('/pages/') || normalized.includes('/ui/')) { return 'ui'; } if (normalized.includes('/utils/') || normalized.includes('/helpers/') || normalized.includes('/lib/')) { return 'utility'; } return 'unknown'; } function normalizePath(value: string): string { return value.split(sep).join('/'); } function isKnowledgeGraph(value: unknown): value is KnowledgeGraph { if (!isRecord(value)) { return false; } return Array.isArray(value.nodes) && Array.isArray(value.edges); } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; }