diff --git a/lib/repo-map/concurrency.js b/lib/repo-map/concurrency.js deleted file mode 100644 index db832b9..0000000 --- a/lib/repo-map/concurrency.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict'; - -async function runWithConcurrency(items, limit, worker) { - if (!Array.isArray(items) || items.length === 0) { - return []; - } - - const maxConcurrency = Math.max(1, Math.min(items.length, Math.floor(limit) || 1)); - const results = new Array(items.length); - let cursor = 0; - - async function runWorker() { - while (true) { - const index = cursor; - cursor += 1; - if (index >= items.length) { - return; - } - results[index] = await worker(items[index], index); - } - } - - await Promise.all(Array.from({ length: maxConcurrency }, () => runWorker())); - return results; -} - -module.exports = { - runWithConcurrency -}; diff --git a/lib/repo-map/converter.js b/lib/repo-map/converter.js new file mode 100644 index 0000000..91b4c97 --- /dev/null +++ b/lib/repo-map/converter.js @@ -0,0 +1,130 @@ +'use strict'; + +/** + * Convert agent-analyzer repo-intel.json format to repo-map.json format. + * + * agent-analyzer outputs: { symbols: { [filePath]: { exports, imports, definitions } } } + * repo-map expects: { files: { [filePath]: { language, symbols, imports } } } + * + * @module lib/repo-map/converter + */ + +const path = require('path'); + +const LANGUAGE_BY_EXTENSION = { + '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', + '.ts': 'typescript', '.tsx': 'typescript', '.mts': 'typescript', '.cts': 'typescript', + '.py': 'python', '.pyw': 'python', + '.rs': 'rust', + '.go': 'go', + '.java': 'java' +}; + +// SymbolKind values from agent-analyzer (kebab-case serialized) +const CLASS_KINDS = new Set(['class', 'struct', 'interface', 'enum', 'impl']); +const TYPE_KINDS = new Set(['trait', 'type-alias']); +const FUNCTION_LIKE_KINDS = new Set(['method', 'arrow', 'closure']); +const CONSTANT_KINDS = new Set(['constant', 'variable', 'const', 'field', 'property']); + +function detectLanguage(filePath) { + return LANGUAGE_BY_EXTENSION[path.extname(filePath).toLowerCase()] || 'unknown'; +} + +function detectLanguagesFromFiles(filePaths) { + const langs = new Set(); + for (const fp of filePaths) { + const lang = detectLanguage(fp); + if (lang !== 'unknown') langs.add(lang); + } + return Array.from(langs); +} + +/** + * Convert a single file's symbols from repo-intel format to repo-map format. + * @param {string} filePath + * @param {Object} fileSym - { exports, imports, definitions } + * @returns {Object} repo-map file entry + */ +function convertFile(filePath, fileSym) { + const exportNames = new Set((fileSym.exports || []).map(e => e.name)); + + const exports = (fileSym.exports || []).map(e => ({ + name: e.name, + kind: e.kind, + line: e.line + })); + + const functions = []; + const classes = []; + const types = []; + const constants = []; + + for (const def of fileSym.definitions || []) { + const entry = { + name: def.name, + kind: def.kind, + line: def.line, + exported: exportNames.has(def.name) + }; + if (def.kind === 'function' || FUNCTION_LIKE_KINDS.has(def.kind)) { + functions.push(entry); + } else if (CLASS_KINDS.has(def.kind)) { + classes.push(entry); + } else if (TYPE_KINDS.has(def.kind)) { + types.push(entry); + } else if (CONSTANT_KINDS.has(def.kind)) { + constants.push(entry); + } else { + // Unknown kind - default to constants for backward compat + constants.push(entry); + } + } + + // agent-analyzer imports: [{ from, names }] → repo-map imports: [{ source, kind, names }] + const imports = (fileSym.imports || []).map(imp => ({ + source: imp.from, + kind: 'import', + names: imp.names || [] + })); + + return { + language: detectLanguage(filePath), + symbols: { exports, functions, classes, types, constants }, + imports + }; +} + +/** + * Convert a full repo-intel data object to repo-map format. + * @param {Object} intel - RepoIntelData from agent-analyzer + * @returns {Object} repo-map.json compatible object + */ +function convertIntelToRepoMap(intel) { + const files = {}; + let totalSymbols = 0; + let totalImports = 0; + + for (const [filePath, fileSym] of Object.entries(intel.symbols || {})) { + files[filePath] = convertFile(filePath, fileSym); + const s = files[filePath].symbols; + totalSymbols += s.functions.length + s.classes.length + + s.types.length + s.constants.length; + totalImports += files[filePath].imports.length; + } + + return { + version: '2.0', + generated: intel.generated || new Date().toISOString(), + git: intel.git ? { commit: intel.git.analyzedUpTo } : undefined, + project: { languages: detectLanguagesFromFiles(Object.keys(files)) }, + stats: { + totalFiles: Object.keys(files).length, + totalSymbols, + totalImports, + errors: [] + }, + files + }; +} + +module.exports = { convertIntelToRepoMap, convertFile, detectLanguage }; diff --git a/lib/repo-map/index.js b/lib/repo-map/index.js index 4f44b0e..ca21b03 100644 --- a/lib/repo-map/index.js +++ b/lib/repo-map/index.js @@ -1,74 +1,90 @@ +'use strict'; + /** - * Repo Map - AST-based repository symbol mapping + * Repo Map - repository symbol mapping via agent-analyzer * - * Uses ast-grep (sg) for accurate symbol extraction across multiple languages. - * Generates a cached map of exports, functions, classes, and imports. + * Uses agent-analyzer for symbol extraction. The binary is auto-downloaded + * on first use - no external tool installation required. * * @module lib/repo-map */ -'use strict'; +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); const installer = require('./installer'); -const runner = require('./runner'); const cache = require('./cache'); const updater = require('./updater'); -const usageAnalyzer = require('./usage-analyzer'); +const converter = require('./converter'); +const binary = require('../binary'); +const { getStateDirPath } = require('../platform/state-dir'); +const { writeJsonAtomic } = require('../utils/atomic-write'); + +const REPO_INTEL_FILENAME = 'repo-intel.json'; + +function getIntelMapPath(basePath) { + return path.join(getStateDirPath(basePath), REPO_INTEL_FILENAME); +} /** * Initialize a new repo map (full scan) * @param {string} basePath - Repository root path * @param {Object} options - Options * @param {boolean} options.force - Force rebuild even if map exists - * @param {string[]} options.languages - Languages to scan (auto-detect if not specified) * @returns {Promise<{success: boolean, map?: Object, error?: string}>} */ async function init(basePath, options = {}) { - // Check if ast-grep is installed const installed = await installer.checkInstalled(); if (!installed.found) { return { success: false, - error: 'ast-grep not found', + error: 'agent-analyzer binary unavailable: ' + (installed.error || 'unknown error'), installSuggestion: installer.getInstallInstructions() }; } - if (!installer.meetsMinimumVersion(installed.version)) { + const existing = cache.load(basePath); + if (existing && !options.force) { return { success: false, - error: `ast-grep version ${installed.version || 'unknown'} is too old. Minimum required: ${installer.getMinimumVersion()}`, - installSuggestion: installer.getInstallInstructions() + error: 'Repo map already exists. Use --force to rebuild or /repo-map update to refresh.', + existing: cache.getStatus(basePath) }; } - // Check if map already exists - const existing = cache.load(basePath); - if (existing && !options.force) { + const startTime = Date.now(); + + let intelJson; + try { + intelJson = await binary.runAnalyzerAsync(['repo-intel', 'init', basePath]); + } catch (e) { return { success: false, - error: 'Repo map already exists. Use --force to rebuild or /repo-map update to refresh.', - existing: cache.getStatus(basePath) + error: 'agent-analyzer repo-intel init failed: ' + e.message }; } - // Detect languages in the project - const languages = options.languages || await runner.detectLanguages(basePath); - if (languages.length === 0) { + let intel; + try { + intel = JSON.parse(intelJson); + } catch (e) { return { success: false, - error: 'No supported languages detected in repository' + error: 'Failed to parse repo-intel output: ' + e.message }; } - // Run full scan - const startTime = Date.now(); - const map = await runner.fullScan(basePath, languages, { - fileLimit: options.fileLimit - }); - map.stats.scanDurationMs = Date.now() - startTime; + // Persist repo-intel.json for future incremental updates + const intelPath = getIntelMapPath(basePath); + try { + writeJsonAtomic(intelPath, intel); + } catch { + // Non-fatal: update() will fall back to full init + } - // Save map + const map = converter.convertIntelToRepoMap(intel); + map.stats.scanDurationMs = Date.now() - startTime; cache.save(basePath, map); return { @@ -84,53 +100,83 @@ async function init(basePath, options = {}) { } /** - * Update an existing repo map (incremental) + * Update an existing repo map (incremental via agent-analyzer) * @param {string} basePath - Repository root path * @param {Object} options - Options * @param {boolean} options.full - Force full rebuild instead of incremental - * @returns {Promise<{success: boolean, changes?: Object, error?: string}>} + * @returns {Promise<{success: boolean, summary?: Object, error?: string}>} */ async function update(basePath, options = {}) { - // Check if ast-grep is installed const installed = await installer.checkInstalled(); if (!installed.found) { return { success: false, - error: 'ast-grep not found', + error: 'agent-analyzer binary unavailable: ' + (installed.error || 'unknown error'), installSuggestion: installer.getInstallInstructions() }; } - if (!installer.meetsMinimumVersion(installed.version)) { + if (!cache.exists(basePath)) { return { success: false, - error: `ast-grep version ${installed.version || 'unknown'} is too old. Minimum required: ${installer.getMinimumVersion()}`, - installSuggestion: installer.getInstallInstructions() + error: 'No repo map found. Run /repo-map init first.' }; } - // Load existing map - const existing = cache.load(basePath); - if (!existing) { + if (options.full) { + return init(basePath, { force: true }); + } + + const intelPath = getIntelMapPath(basePath); + if (!fs.existsSync(intelPath)) { + return init(basePath, { force: true }); + } + + const startTime = Date.now(); + + let intelJson; + try { + intelJson = await binary.runAnalyzerAsync([ + 'repo-intel', 'update', + '--map-file', intelPath, + basePath + ]); + } catch (e) { return { success: false, - error: 'No repo map found. Run /repo-map init first.' + error: 'agent-analyzer repo-intel update failed: ' + e.message }; } - // Force full rebuild if requested - if (options.full) { - return init(basePath, { force: true }); + let intel; + try { + intel = JSON.parse(intelJson); + } catch (e) { + return { + success: false, + error: 'Failed to parse repo-intel update output: ' + e.message + }; } - // Incremental update - const result = await updater.incrementalUpdate(basePath, existing); - - if (result.success) { - cache.save(basePath, result.map); + try { + writeJsonAtomic(intelPath, intel); + } catch { + // Non-fatal } - return result; + const map = converter.convertIntelToRepoMap(intel); + map.stats.scanDurationMs = Date.now() - startTime; + cache.save(basePath, map); + + return { + success: true, + map, + summary: { + files: Object.keys(map.files).length, + symbols: map.stats.totalSymbols, + duration: map.stats.scanDurationMs + } + }; } /** @@ -146,13 +192,20 @@ function status(basePath) { const staleness = updater.checkStaleness(basePath, map); + let branch; + try { + branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: basePath, encoding: 'utf8' }).trim(); + } catch { + // Non-fatal + } + return { exists: true, status: { generated: map.generated, updated: map.updated, commit: map.git?.commit, - branch: map.git?.branch, + branch, files: Object.keys(map.files).length, symbols: map.stats?.totalSymbols || 0, languages: map.project?.languages || [], @@ -164,37 +217,38 @@ function status(basePath) { /** * Load repo map (if exists) * @param {string} basePath - Repository root path - * @returns {Object|null} - The map or null if not found + * @returns {Object|null} */ function load(basePath) { return cache.load(basePath); } /** - * Check if ast-grep is installed - * @returns {Promise<{found: boolean, version?: string, path?: string}>} + * Check if repo map exists + * @param {string} basePath - Repository root path + * @returns {boolean} + */ +function exists(basePath) { + return cache.exists(basePath); +} + +/** + * Check if agent-analyzer is available (compat alias for checkInstalled). + * Previously checked ast-grep; now checks agent-analyzer. + * @returns {Promise<{found: boolean, version?: string, tool: string}>} */ async function checkAstGrepInstalled() { return installer.checkInstalled(); } /** - * Get install instructions for ast-grep + * Get install instructions (compat alias). * @returns {string} */ function getInstallInstructions() { return installer.getInstallInstructions(); } -/** - * Check if repo map exists - * @param {string} basePath - Repository root path - * @returns {boolean} - */ -function exists(basePath) { - return cache.exists(basePath); -} - module.exports = { init, update, @@ -204,19 +258,8 @@ module.exports = { checkAstGrepInstalled, getInstallInstructions, - // Usage analysis functions - buildUsageIndex: usageAnalyzer.buildUsageIndex, - findUsages: usageAnalyzer.findUsages, - findDependents: usageAnalyzer.findDependents, - findUnusedExports: usageAnalyzer.findUnusedExports, - findOrphanedInfrastructure: usageAnalyzer.findOrphanedInfrastructure, - getDependencyGraph: usageAnalyzer.getDependencyGraph, - findCircularDependencies: usageAnalyzer.findCircularDependencies, - // Re-export submodules for advanced usage installer, - runner, cache, - updater, - usageAnalyzer + updater }; diff --git a/lib/repo-map/installer.js b/lib/repo-map/installer.js index 7cacc22..41ad7e0 100644 --- a/lib/repo-map/installer.js +++ b/lib/repo-map/installer.js @@ -1,212 +1,78 @@ +'use strict'; + /** - * ast-grep installation detection and helpers + * agent-analyzer binary availability check. + * + * Replaces the former ast-grep installer. The agent-analyzer binary is + * auto-downloaded by agent-core on first use - no manual install required. * * @module lib/repo-map/installer */ -'use strict'; - -const { execSync, execFileSync, execFile } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const { promisify } = require('util'); - -const execFileAsync = promisify(execFile); - -// Commands to try (sg is the common alias) -const AST_GREP_COMMANDS = ['sg', 'ast-grep']; - -function pickCommandPath(pathOutput) { - if (!pathOutput) return null; - const candidates = pathOutput.split(/\r?\n/).map(line => line.trim()).filter(Boolean); - if (candidates.length === 0) return null; - - if (process.platform !== 'win32') { - return candidates[0]; - } - - const exe = candidates.find(candidate => candidate.toLowerCase().endsWith('.exe')); - if (exe) return exe; - - const primary = candidates[0]; - if (!primary) return null; - - const baseDir = path.dirname(primary); - const npmExe = path.join(baseDir, 'node_modules', '@ast-grep', 'cli', 'sg.exe'); - if (fs.existsSync(npmExe)) return npmExe; - - return primary; -} +const binary = require('../binary'); /** - * Check if ast-grep is installed - * @returns {Promise<{found: boolean, version?: string, path?: string, command?: string}>} + * Check if agent-analyzer is available (async). Downloads if missing. + * @returns {Promise<{found: boolean, version?: string, tool: string}>} */ async function checkInstalled() { - for (const cmd of AST_GREP_COMMANDS) { - try { - // Try to get version using execFileAsync (no shell, safe from injection) - const { stdout } = await execFileAsync(cmd, ['--version'], { - timeout: 5000, - windowsHide: true, - shell: process.platform === 'win32' // Windows needs shell for PATH lookup - }); - - const version = stdout.trim().replace(/^ast-grep\s*/i, ''); - - // Try to get path - let cmdPath = null; - try { - const whereCmd = process.platform === 'win32' ? 'where' : 'which'; - // Use execFileAsync with args array (no shell interpolation) - const { stdout: pathOut } = await execFileAsync(whereCmd, [cmd], { - timeout: 5000, - windowsHide: true, - shell: process.platform === 'win32' - }); - cmdPath = pickCommandPath(pathOut); - } catch { - // Path lookup failed, but command works - } - - return { - found: true, - version, - path: cmdPath, - command: cmdPath || cmd - }; - } catch { - // This command not found, try next - continue; - } + if (binary.isAvailable()) { + return { found: true, version: binary.getVersion(), tool: 'agent-analyzer' }; + } + try { + await binary.ensureBinary(); + return { found: true, version: binary.getVersion(), tool: 'agent-analyzer' }; + } catch (e) { + return { found: false, error: e.message, tool: 'agent-analyzer' }; } - - return { found: false }; } /** - * Check if ast-grep is installed (sync version) - * @returns {{found: boolean, version?: string, command?: string, path?: string}} + * Check if agent-analyzer is available (sync). Downloads if missing. + * @returns {{found: boolean, version?: string, tool: string}} */ function checkInstalledSync() { - for (const cmd of AST_GREP_COMMANDS) { - try { - // Use execFileSync with args array (no shell interpolation, safe from injection) - const stdout = execFileSync(cmd, ['--version'], { - timeout: 5000, - windowsHide: true, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - shell: process.platform === 'win32' // Windows needs shell for PATH lookup - }); - - const version = stdout.trim().replace(/^ast-grep\s*/i, ''); - let cmdPath = null; - - try { - const whereCmd = process.platform === 'win32' ? 'where' : 'which'; - // Use execFileSync with args array (no shell interpolation) - const pathOut = execFileSync(whereCmd, [cmd], { - timeout: 5000, - windowsHide: true, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - shell: process.platform === 'win32' - }); - cmdPath = pickCommandPath(pathOut); - } catch { - // Path lookup failed, but command works - } - - return { found: true, version, command: cmdPath || cmd, path: cmdPath }; - } catch { - continue; - } + if (binary.isAvailable()) { + return { found: true, version: binary.getVersion(), tool: 'agent-analyzer' }; + } + try { + binary.ensureBinarySync(); + return { found: true, version: binary.getVersion(), tool: 'agent-analyzer' }; + } catch (e) { + return { found: false, error: e.message, tool: 'agent-analyzer' }; } - - return { found: false }; } /** - * Get the working ast-grep command - * @returns {string|null} + * Version check is handled by the binary module - always true when found. + * @returns {boolean} */ -function getCommand() { - const result = checkInstalledSync(); - return result.found ? result.command : null; +function meetsMinimumVersion() { + return true; } /** - * Get installation instructions for ast-grep + * Get install instructions (binary is auto-downloaded, but here for compat). * @returns {string} */ function getInstallInstructions() { - return `ast-grep (sg) is required for repo-map functionality. - -Install using one of these methods: - - npm: npm install -g @ast-grep/cli - pip: pip install ast-grep-cli - brew: brew install ast-grep - cargo: cargo install ast-grep --locked - scoop: scoop install main/ast-grep - -After installation, verify with: sg --version - -Documentation: https://ast-grep.github.io/`; + return 'agent-analyzer is downloaded automatically on first use from https://github.com/agent-sh/agent-analyzer/releases'; } /** - * Get a short install suggestion (one line) - * @returns {string} - */ -function getShortInstallSuggestion() { - if (process.platform === 'win32') { - return 'Install ast-grep: npm i -g @ast-grep/cli (or scoop install ast-grep)'; - } else if (process.platform === 'darwin') { - return 'Install ast-grep: brew install ast-grep (or npm i -g @ast-grep/cli)'; - } else { - return 'Install ast-grep: npm i -g @ast-grep/cli (or pip install ast-grep-cli)'; - } -} - -/** - * Get minimum required version + * Get minimum version string. * @returns {string} */ function getMinimumVersion() { - return '0.20.0'; // Require at least this version for JSON output support -} - -/** - * Check if installed version meets minimum requirements - * @param {string} version - Installed version - * @returns {boolean} - */ -function meetsMinimumVersion(version) { - if (!version) return false; - - // Parse version (e.g., "0.25.0" or "0.25.0-beta.1") - const match = version.match(/^(\d+)\.(\d+)\.(\d+)/); - if (!match) return false; - - const [, major, minor, patch] = match.map(Number); - const [reqMajor, reqMinor, reqPatch] = getMinimumVersion().split('.').map(Number); - - if (major > reqMajor) return true; - if (major < reqMajor) return false; - if (minor > reqMinor) return true; - if (minor < reqMinor) return false; - return patch >= reqPatch; + return '0.3.0'; } module.exports = { checkInstalled, checkInstalledSync, - getCommand, + meetsMinimumVersion, getInstallInstructions, - getShortInstallSuggestion, getMinimumVersion, - meetsMinimumVersion, - AST_GREP_COMMANDS + // Stub: runner.js references this but is no longer the scan path + getCommand: () => null }; diff --git a/lib/repo-map/queries/go.js b/lib/repo-map/queries/go.js deleted file mode 100644 index 1d8a9cc..0000000 --- a/lib/repo-map/queries/go.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Go query patterns for ast-grep - */ - -'use strict'; - -module.exports = { - exports: [], - functions: [ - { pattern: 'func $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'func ($$$) $NAME($$$) { $$$ }', nameVar: 'NAME' } - ], - classes: [], - types: [ - { pattern: 'type $NAME struct { $$$ }', nameVar: 'NAME' }, - { pattern: 'type $NAME interface { $$$ }', nameVar: 'NAME' }, - { pattern: 'type $NAME = $$$', nameVar: 'NAME' } - ], - constants: [ - { pattern: 'const $NAME = $$$', nameVar: 'NAME' }, - { pattern: 'const $NAME $TYPE = $$$', nameVar: 'NAME' } - ], - imports: [ - { pattern: 'import $SOURCE', sourceVar: 'SOURCE', kind: 'import' }, - { pattern: 'import $NAME $SOURCE', sourceVar: 'SOURCE', kind: 'import' } - ] -}; diff --git a/lib/repo-map/queries/index.js b/lib/repo-map/queries/index.js deleted file mode 100644 index fe61815..0000000 --- a/lib/repo-map/queries/index.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Language-specific query patterns for ast-grep - * - * @module lib/repo-map/queries - */ - -'use strict'; - -const path = require('path'); - -const javascript = require('./javascript'); -const typescript = require('./typescript'); -const python = require('./python'); -const rust = require('./rust'); -const go = require('./go'); -const java = require('./java'); - -/** - * Get query patterns for a language - * @param {string} language - Language name - * @returns {Object|null} - */ -function getQueriesForLanguage(language) { - switch (language) { - case 'javascript': - case 'js': - case 'node': - return javascript; - case 'typescript': - case 'ts': - return typescript; - case 'python': - case 'py': - return python; - case 'rust': - return rust; - case 'go': - return go; - case 'java': - return java; - default: - return null; - } -} - -/** - * Get base ast-grep language identifier - * @param {string} language - Language name - * @returns {string} - */ -function getSgLanguage(language) { - switch (language) { - case 'javascript': - case 'js': - case 'node': - return 'javascript'; - case 'typescript': - case 'ts': - return 'typescript'; - case 'python': - case 'py': - return 'python'; - case 'rust': - return 'rust'; - case 'go': - return 'go'; - case 'java': - return 'java'; - default: - return 'javascript'; - } -} - -/** - * Get ast-grep language identifier based on file extension - * @param {string} filePath - File path - * @param {string} language - Language name - * @returns {string} - */ -function getSgLanguageForFile(filePath, language) { - const ext = path.extname(filePath).toLowerCase(); - - if (language === 'javascript') { - if (ext === '.jsx') return 'jsx'; - return 'javascript'; - } - - if (language === 'typescript') { - if (ext === '.tsx') return 'tsx'; - return 'typescript'; - } - - return getSgLanguage(language); -} - -module.exports = { - getQueriesForLanguage, - getSgLanguage, - getSgLanguageForFile -}; diff --git a/lib/repo-map/queries/java.js b/lib/repo-map/queries/java.js deleted file mode 100644 index 6490588..0000000 --- a/lib/repo-map/queries/java.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Java query patterns for ast-grep - */ - -'use strict'; - -module.exports = { - exports: [ - { pattern: 'public class $NAME { $$$ }', kind: 'class', nameVar: 'NAME' }, - { pattern: 'public interface $NAME { $$$ }', kind: 'class', nameVar: 'NAME' }, - { pattern: 'public enum $NAME { $$$ }', kind: 'class', nameVar: 'NAME' }, - { pattern: 'public record $NAME($$$) { $$$ }', kind: 'class', nameVar: 'NAME' }, - { pattern: 'public $RET $NAME($$$) { $$$ }', kind: 'function', nameVar: 'NAME' }, - { pattern: 'public static $RET $NAME($$$) { $$$ }', kind: 'function', nameVar: 'NAME' }, - { pattern: 'public $RET $NAME($$$);', kind: 'function', nameVar: 'NAME' } - ], - functions: [ - { pattern: 'public $RET $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'public static $RET $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'protected $RET $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'private $RET $NAME($$$) { $$$ }', nameVar: 'NAME' } - ], - classes: [ - { pattern: 'class $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'interface $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'enum $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'record $NAME($$$) { $$$ }', nameVar: 'NAME' } - ], - types: [], - constants: [ - { pattern: 'public static final $TYPE $NAME = $$$;', nameVar: 'NAME' }, - { pattern: 'static final $TYPE $NAME = $$$;', nameVar: 'NAME' } - ], - imports: [ - { pattern: 'import $SOURCE;', sourceVar: 'SOURCE', kind: 'import' }, - { pattern: 'import static $SOURCE;', sourceVar: 'SOURCE', kind: 'import' } - ] -}; diff --git a/lib/repo-map/queries/javascript.js b/lib/repo-map/queries/javascript.js deleted file mode 100644 index b72d740..0000000 --- a/lib/repo-map/queries/javascript.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * JavaScript query patterns for ast-grep - */ - -'use strict'; - -module.exports = { - exports: [ - { pattern: 'export function $NAME($$$) { $$$ }', kind: 'function', nameVar: 'NAME' }, - { pattern: 'export async function $NAME($$$) { $$$ }', kind: 'function', nameVar: 'NAME' }, - { pattern: 'export class $NAME { $$$ }', kind: 'class', nameVar: 'NAME' }, - { pattern: 'export const $NAME = $$$', kind: 'constant', nameVar: 'NAME' }, - { pattern: 'export let $NAME = $$$', kind: 'variable', nameVar: 'NAME' }, - { pattern: 'export var $NAME = $$$', kind: 'variable', nameVar: 'NAME' }, - { pattern: 'export default function $NAME($$$) { $$$ }', kind: 'function', nameVar: 'NAME' }, - { pattern: 'export default class $NAME { $$$ }', kind: 'class', nameVar: 'NAME' }, - { pattern: 'export default function ($$$) { $$$ }', kind: 'function', fallbackName: 'default' }, - { pattern: 'export default class { $$$ }', kind: 'class', fallbackName: 'default' }, - { pattern: 'export default $NAME', kind: 'value', nameVar: 'NAME' }, - { pattern: 'export { $$$ }', kind: 'value', multi: 'exportList' }, - { pattern: 'export { $$$ } from $SOURCE', kind: 're-export', multi: 'exportList', sourceVar: 'SOURCE' }, - { pattern: 'export * from $SOURCE', kind: 're-export', fallbackName: '*', sourceVar: 'SOURCE' }, - { pattern: 'module.exports = $NAME', kind: 'value', nameVar: 'NAME' }, - { pattern: 'module.exports = { $$$ }', kind: 'value', multi: 'objectLiteral' }, - { pattern: 'exports.$NAME = $$$', kind: 'value', nameVar: 'NAME' } - ], - functions: [ - { pattern: 'function $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'async function $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'function* $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'async function* $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'const $NAME = ($$$) => $$$', nameVar: 'NAME' }, - { pattern: 'const $NAME = async ($$$) => $$$', nameVar: 'NAME' }, - { pattern: 'const $NAME = function ($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'const $NAME = async function ($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'let $NAME = ($$$) => $$$', nameVar: 'NAME' }, - { pattern: 'var $NAME = ($$$) => $$$', nameVar: 'NAME' } - ], - classes: [ - { pattern: 'class $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'const $NAME = class { $$$ }', nameVar: 'NAME' }, - { pattern: 'const $NAME = class $CLASS { $$$ }', nameVar: 'NAME' } - ], - types: [], - constants: [], - imports: [ - { pattern: 'import $NAME from $SOURCE', sourceVar: 'SOURCE', kind: 'default' }, - { pattern: 'import * as $NAME from $SOURCE', sourceVar: 'SOURCE', kind: 'namespace' }, - { pattern: 'import { $$$ } from $SOURCE', sourceVar: 'SOURCE', kind: 'named' }, - { pattern: 'import $SOURCE', sourceVar: 'SOURCE', kind: 'side-effect' }, - { pattern: 'const $NAME = require($SOURCE)', sourceVar: 'SOURCE', kind: 'require' }, - { pattern: 'const { $$$ } = require($SOURCE)', sourceVar: 'SOURCE', kind: 'require' }, - { pattern: 'require($SOURCE)', sourceVar: 'SOURCE', kind: 'require' } - ] -}; diff --git a/lib/repo-map/queries/python.js b/lib/repo-map/queries/python.js deleted file mode 100644 index 7ad406b..0000000 --- a/lib/repo-map/queries/python.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Python query patterns for ast-grep - */ - -'use strict'; - -module.exports = { - exports: [], - functions: [ - { pattern: 'def $NAME($$$): $$$', nameVar: 'NAME' }, - { pattern: 'async def $NAME($$$): $$$', nameVar: 'NAME' } - ], - classes: [ - { pattern: 'class $NAME($$$): $$$', nameVar: 'NAME' }, - { pattern: 'class $NAME: $$$', nameVar: 'NAME' } - ], - types: [], - constants: [], - imports: [ - { pattern: 'import $SOURCE', sourceVar: 'SOURCE', kind: 'import', multiSource: true }, - { pattern: 'from $SOURCE import $NAME', sourceVar: 'SOURCE', kind: 'from' }, - { pattern: 'from $SOURCE import ($$$)', sourceVar: 'SOURCE', kind: 'from' } - ] -}; diff --git a/lib/repo-map/queries/rust.js b/lib/repo-map/queries/rust.js deleted file mode 100644 index bc492a9..0000000 --- a/lib/repo-map/queries/rust.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Rust query patterns for ast-grep - */ - -'use strict'; - -module.exports = { - exports: [ - { pattern: 'pub fn $NAME($$$) { $$$ }', kind: 'function', nameVar: 'NAME' }, - { pattern: 'pub(crate) fn $NAME($$$) { $$$ }', kind: 'function', nameVar: 'NAME' }, - { pattern: 'pub(super) fn $NAME($$$) { $$$ }', kind: 'function', nameVar: 'NAME' }, - { pattern: 'pub(in $PATH) fn $NAME($$$) { $$$ }', kind: 'function', nameVar: 'NAME' }, - { pattern: 'pub struct $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(crate) struct $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(super) struct $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(in $PATH) struct $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub enum $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(crate) enum $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(super) enum $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(in $PATH) enum $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub trait $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(crate) trait $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(super) trait $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(in $PATH) trait $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub type $NAME = $$$', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(crate) type $NAME = $$$', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(super) type $NAME = $$$', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub(in $PATH) type $NAME = $$$', kind: 'type', nameVar: 'NAME' }, - { pattern: 'pub const $NAME: $TYPE = $$$', kind: 'constant', nameVar: 'NAME' }, - { pattern: 'pub(crate) const $NAME: $TYPE = $$$', kind: 'constant', nameVar: 'NAME' }, - { pattern: 'pub(super) const $NAME: $TYPE = $$$', kind: 'constant', nameVar: 'NAME' }, - { pattern: 'pub(in $PATH) const $NAME: $TYPE = $$$', kind: 'constant', nameVar: 'NAME' }, - { pattern: 'pub static $NAME: $TYPE = $$$', kind: 'constant', nameVar: 'NAME' }, - { pattern: 'pub(crate) static $NAME: $TYPE = $$$', kind: 'constant', nameVar: 'NAME' }, - { pattern: 'pub(super) static $NAME: $TYPE = $$$', kind: 'constant', nameVar: 'NAME' }, - { pattern: 'pub(in $PATH) static $NAME: $TYPE = $$$', kind: 'constant', nameVar: 'NAME' }, - { pattern: 'pub mod $NAME { $$$ }', kind: 'module', nameVar: 'NAME' }, - { pattern: 'pub(crate) mod $NAME { $$$ }', kind: 'module', nameVar: 'NAME' }, - { pattern: 'pub(super) mod $NAME { $$$ }', kind: 'module', nameVar: 'NAME' }, - { pattern: 'pub(in $PATH) mod $NAME { $$$ }', kind: 'module', nameVar: 'NAME' }, - { pattern: 'pub mod $NAME;', kind: 'module', nameVar: 'NAME' } - ], - functions: [ - { pattern: 'fn $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'async fn $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'pub fn $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'pub(crate) fn $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'pub(super) fn $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'pub(in $PATH) fn $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'pub async fn $NAME($$$) { $$$ }', nameVar: 'NAME' }, - { pattern: 'pub(crate) async fn $NAME($$$) { $$$ }', nameVar: 'NAME' } - ], - classes: [], - types: [ - { pattern: 'struct $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'enum $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'trait $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'type $NAME = $$$', nameVar: 'NAME' }, - { pattern: 'pub struct $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'pub enum $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'pub trait $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'pub type $NAME = $$$', nameVar: 'NAME' } - ], - constants: [ - { pattern: 'const $NAME: $TYPE = $$$', nameVar: 'NAME' }, - { pattern: 'static $NAME: $TYPE = $$$', nameVar: 'NAME' } - ], - imports: [ - { pattern: 'use $SOURCE;', sourceVar: 'SOURCE', kind: 'use' }, - { pattern: 'use $SOURCE::{ $$$ };', sourceVar: 'SOURCE', kind: 'use' }, - { pattern: 'use $SOURCE::*;', sourceVar: 'SOURCE', kind: 'use' } - ] -}; diff --git a/lib/repo-map/queries/typescript.js b/lib/repo-map/queries/typescript.js deleted file mode 100644 index b85c6ff..0000000 --- a/lib/repo-map/queries/typescript.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * TypeScript query patterns for ast-grep - */ - -'use strict'; - -const javascript = require('./javascript'); - -module.exports = { - exports: [ - ...javascript.exports, - { pattern: 'export interface $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'export type $NAME = $$$', kind: 'type', nameVar: 'NAME' }, - { pattern: 'export enum $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'export namespace $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'export const enum $NAME { $$$ }', kind: 'type', nameVar: 'NAME' }, - { pattern: 'export = $NAME', kind: 'value', nameVar: 'NAME' }, - { pattern: 'export as namespace $NAME', kind: 'namespace', nameVar: 'NAME' } - ], - functions: javascript.functions, - classes: [ - ...javascript.classes, - { pattern: 'abstract class $NAME { $$$ }', nameVar: 'NAME' } - ], - types: [ - { pattern: 'interface $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'type $NAME = $$$', nameVar: 'NAME' }, - { pattern: 'enum $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'namespace $NAME { $$$ }', nameVar: 'NAME' }, - { pattern: 'const enum $NAME { $$$ }', nameVar: 'NAME' } - ], - constants: javascript.constants, - imports: [ - ...javascript.imports, - { pattern: 'import type { $$$ } from $SOURCE', sourceVar: 'SOURCE', kind: 'type' }, - { pattern: 'import type $NAME from $SOURCE', sourceVar: 'SOURCE', kind: 'type' } - ] -}; diff --git a/lib/repo-map/runner.js b/lib/repo-map/runner.js deleted file mode 100644 index 3b245be..0000000 --- a/lib/repo-map/runner.js +++ /dev/null @@ -1,1364 +0,0 @@ -/** - * ast-grep execution and result parsing - * - * @module lib/repo-map/runner - */ - -'use strict'; - -const { execFileSync, spawnSync, spawn } = require('child_process'); -const path = require('path'); -const fs = require('fs'); -const fsPromises = require('fs').promises; -const crypto = require('crypto'); - -const installer = require('./installer'); -const queries = require('./queries'); -const slopAnalyzers = require('../patterns/slop-analyzers'); -const { runWithConcurrency } = require('./concurrency'); - -// Language file extensions mapping -const LANGUAGE_EXTENSIONS = { - javascript: ['.js', '.jsx', '.mjs', '.cjs'], - typescript: ['.ts', '.tsx', '.mts', '.cts'], - python: ['.py', '.pyw'], - rust: ['.rs'], - go: ['.go'], - java: ['.java'] -}; - -// Directories to exclude from scanning (extend base list) -const EXCLUDE_DIRS = Array.from(new Set([ - ...slopAnalyzers.EXCLUDE_DIRS, - '.claude', '.opencode', '.codex', '.venv', 'venv', 'env' -])); - -const AST_GREP_BATCH_SIZE = 100; -const AST_GREP_CONCURRENCY = 4; -const LANGUAGE_EXTENSION_SCAN_LIMIT = 500; -const FILE_READ_BATCH_SIZE = 50; // Concurrent file reads - -/** - * Detect languages in a repository - * @param {string} basePath - Repository root - * @returns {Promise} - List of detected languages - */ -async function detectLanguages(basePath) { - const detected = new Set(); - - // Check for config files first (faster) - const configIndicators = { - javascript: ['package.json', 'jsconfig.json'], - typescript: ['tsconfig.json', 'tsconfig.base.json'], - python: ['pyproject.toml', 'setup.py', 'requirements.txt', 'Pipfile'], - rust: ['Cargo.toml'], - go: ['go.mod', 'go.sum'], - java: ['pom.xml', 'build.gradle', 'build.gradle.kts'] - }; - - for (const [lang, files] of Object.entries(configIndicators)) { - for (const file of files) { - if (fs.existsSync(path.join(basePath, file))) { - detected.add(lang); - break; - } - } - } - - // Supplement with extension scan to catch mixed-language repos - const extensions = scanForExtensions(basePath, LANGUAGE_EXTENSION_SCAN_LIMIT); - for (const [lang, exts] of Object.entries(LANGUAGE_EXTENSIONS)) { - if (exts.some(ext => extensions.has(ext))) { - detected.add(lang); - } - } - - return Array.from(detected); -} - -/** - * Scan repository for file extensions (sampling) - * @param {string} basePath - Repository root - * @param {number} maxFiles - Maximum files to check - * @returns {Set} - Set of extensions found - */ -function scanForExtensions(basePath, maxFiles = 100) { - const extensions = new Set(); - let count = 0; - const isIgnored = slopAnalyzers.parseGitignore(basePath, fs, path); - - function scan(dir) { - if (count >= maxFiles) return; - - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (count >= maxFiles) break; - - if (entry.isDirectory()) { - const relativePath = path.relative(basePath, path.join(dir, entry.name)); - if (slopAnalyzers.shouldExclude(relativePath, EXCLUDE_DIRS)) continue; - if (isIgnored && isIgnored(relativePath, true)) continue; - if (!entry.name.startsWith('.')) { - scan(path.join(dir, entry.name)); - } - } else if (entry.isFile()) { - const relativePath = path.relative(basePath, path.join(dir, entry.name)); - if (slopAnalyzers.shouldExclude(relativePath, EXCLUDE_DIRS)) continue; - if (isIgnored && isIgnored(relativePath, false)) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (ext) { - extensions.add(ext); - count++; - } - } - } - } catch { - // Skip directories we can't read - } - } - - scan(basePath); - return extensions; -} - -/** - * Run a full scan of the repository - * @param {string} basePath - Repository root - * @param {string[]} languages - Languages to scan - * @returns {Promise} - The generated map - */ -async function fullScan(basePath, languages, options = {}) { - const cmd = installer.getCommand(); - if (!cmd) { - throw new Error('ast-grep not found'); - } - const fileLimit = Number.isFinite(options.fileLimit) ? Math.max(0, Math.floor(options.fileLimit)) : null; - const filesByLanguage = collectFilesByLanguage(basePath, languages, { - maxFiles: fileLimit - }); - - const map = { - version: '1.0.0', - generated: new Date().toISOString(), - updated: null, - git: getGitInfo(basePath), - project: { - type: detectProjectType(languages), - languages, - frameworks: [] // Could be enhanced later - }, - stats: { - totalFiles: 0, - totalSymbols: 0, - scanDurationMs: 0, - errors: [] - }, - files: {}, - dependencies: {} - }; - - // Run queries for each language - for (const lang of languages) { - const langQueries = queries.getQueriesForLanguage(lang); - if (!langQueries) continue; - - const files = filesByLanguage.get(lang) || []; - if (files.length === 0) continue; - - const fileEntries = []; - const symbolMapsByFile = new Map(); - const importStateByFile = new Map(); - const contentByFile = new Map(); - - // Filter out already processed files first - const filesToProcess = files.filter(file => { - const relativePath = path.relative(basePath, file).replace(/\\/g, '/'); - return !map.files[relativePath]; - }); - - // Batch read all files asynchronously - const fileContents = await batchReadFiles(filesToProcess); - - for (const file of filesToProcess) { - const relativePath = path.relative(basePath, file).replace(/\\/g, '/'); - const readResult = fileContents.get(file); - - if (readResult.error || readResult.content === null) { - map.stats.errors.push({ - file: relativePath, - error: readResult.error?.message || 'Failed to read file' - }); - continue; - } - - const content = readResult.content; - const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 16); - - map.files[relativePath] = { - hash, - language: lang, - size: content.length, - symbols: { - exports: [], - functions: [], - classes: [], - types: [], - constants: [] - }, - imports: [] - }; - - map.stats.totalFiles++; - fileEntries.push({ file, relativePath }); - symbolMapsByFile.set(relativePath, createSymbolMaps()); - importStateByFile.set(relativePath, { items: [], seen: new Set() }); - contentByFile.set(relativePath, content); - } - - if (fileEntries.length === 0) continue; - - const filesBySgLang = new Map(); - for (const entry of fileEntries) { - const sgLang = queries.getSgLanguageForFile(entry.file, lang); - if (!filesBySgLang.has(sgLang)) { - filesBySgLang.set(sgLang, []); - } - filesBySgLang.get(sgLang).push(entry); - } - - for (const [sgLang, entries] of filesBySgLang) { - const filePaths = entries.map(entry => entry.file); - const experimentBatchSize = process.env.PERF_EXPERIMENT === '1' - ? Number(process.env.REPO_MAP_AST_GREP_BATCH_SIZE) - : null; - const batchSize = Number.isFinite(options.astGrepBatchSize) - ? Math.max(1, Math.floor(options.astGrepBatchSize)) - : Number.isFinite(experimentBatchSize) && experimentBatchSize > 0 - ? Math.max(1, Math.floor(experimentBatchSize)) - : AST_GREP_BATCH_SIZE; - const chunks = chunkArray(filePaths, batchSize); - - const patternGroups = [ - { category: 'exports', patterns: langQueries.exports, defaultKind: 'export' }, - { category: 'functions', patterns: langQueries.functions, defaultKind: 'function' }, - { category: 'classes', patterns: langQueries.classes, defaultKind: 'class' }, - { category: 'types', patterns: langQueries.types, defaultKind: 'type' }, - { category: 'constants', patterns: langQueries.constants, defaultKind: 'constant' }, - { category: 'imports', patterns: langQueries.imports, defaultKind: 'import' } - ]; - - for (const group of patternGroups) { - if (!group.patterns || group.patterns.length === 0) continue; - - for (const patternDef of group.patterns) { - const pattern = typeof patternDef === 'string' ? patternDef : patternDef.pattern; - if (!pattern) continue; - - const matchesByChunk = await runAstGrepPatternBatches(cmd, pattern, sgLang, basePath, chunks, { - onError: (error) => map.stats.errors.push(error), - concurrency: options.astGrepConcurrency - }); - - for (const matches of matchesByChunk) { - for (const match of matches) { - const matchedPath = normalizeMatchPath(match.file, basePath); - if (!matchedPath) continue; - - const symbolMaps = symbolMapsByFile.get(matchedPath); - const importState = importStateByFile.get(matchedPath); - if (!symbolMaps || !importState) continue; - - if (group.category === 'imports') { - const sourceResult = extractSourceFromMatch(match, patternDef); - const sources = Array.isArray(sourceResult) ? sourceResult : [sourceResult]; - for (const source of sources) { - if (!source) continue; - const kind = patternDef.kind || 'import'; - const key = `${source}:${kind}`; - if (importState.seen.has(key)) continue; - importState.seen.add(key); - importState.items.push({ - source, - kind, - line: getLine(match) - }); - } - continue; - } - - const names = extractNamesFromMatch(match, patternDef); - const targetMap = symbolMaps[group.category]; - if (!targetMap) continue; - for (const name of names) { - const kind = patternDef.kind || group.defaultKind; - addSymbolToMap(targetMap, name, match, kind, patternDef.extra); - } - } - } - } - } - } - - for (const entry of fileEntries) { - const relativePath = entry.relativePath; - const symbolMaps = symbolMapsByFile.get(relativePath); - const importState = importStateByFile.get(relativePath); - if (!symbolMaps || !importState) continue; - - const exportNames = new Set(symbolMaps.exports.keys()); - const content = contentByFile.get(relativePath) || ''; - applyLanguageExportRules(lang, content, exportNames, symbolMaps.functions, symbolMaps.classes, symbolMaps.types, symbolMaps.constants); - ensureExportEntries(symbolMaps.exports, exportNames, symbolMaps.functions, symbolMaps.classes, symbolMaps.types, symbolMaps.constants); - - const symbols = { - exports: mapToSortedArray(symbolMaps.exports), - functions: mapToSortedArray(symbolMaps.functions, exportNames), - classes: mapToSortedArray(symbolMaps.classes, exportNames), - types: mapToSortedArray(symbolMaps.types, exportNames), - constants: mapToSortedArray(symbolMaps.constants, exportNames) - }; - - map.files[relativePath].symbols = symbols; - map.files[relativePath].imports = importState.items; - - if (importState.items.length > 0) { - map.dependencies[relativePath] = Array.from(new Set(importState.items.map(imp => imp.source))); - } - - map.stats.totalSymbols += - (symbols.functions?.length || 0) + - (symbols.classes?.length || 0) + - (symbols.types?.length || 0) + - (symbols.constants?.length || 0); - } - } - - return map; -} - -/** - * Find all files for a language - * @param {string} basePath - Repository root - * @param {string} language - Language name - * @returns {string[]} - Array of file paths - */ -function findFilesForLanguage(basePath, language, options = {}) { - const extensions = LANGUAGE_EXTENSIONS[language] || []; - const files = []; - const isIgnored = slopAnalyzers.parseGitignore(basePath, fs, path); - const maxFiles = Number.isFinite(options.maxFiles) ? Math.max(0, Math.floor(options.maxFiles)) : null; - - function scan(dir) { - if (maxFiles !== null && files.length >= maxFiles) return; - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (maxFiles !== null && files.length >= maxFiles) break; - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - const relativePath = path.relative(basePath, fullPath); - if (slopAnalyzers.shouldExclude(relativePath, EXCLUDE_DIRS)) continue; - if (isIgnored && isIgnored(relativePath, true)) continue; - if (!entry.name.startsWith('.')) { - scan(fullPath); - } - } else if (entry.isFile()) { - const relativePath = path.relative(basePath, fullPath); - if (slopAnalyzers.shouldExclude(relativePath, EXCLUDE_DIRS)) continue; - if (isIgnored && isIgnored(relativePath, false)) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (extensions.includes(ext)) { - files.push(fullPath); - if (maxFiles !== null && files.length >= maxFiles) break; - } - } - } - } catch { - // Skip directories we can't read - } - } - - scan(basePath); - return files; -} - -/** - * Collect files for all languages in a single walk. - * @param {string} basePath - Repository root - * @param {string[]} languages - Languages to collect - * @param {Object} options - * @param {number} [options.maxFiles] - Global file limit - * @returns {Map} - Map of language -> file paths - */ -function collectFilesByLanguage(basePath, languages, options = {}) { - const langList = Array.isArray(languages) ? languages : []; - const filesByLanguage = new Map(); - for (const lang of langList) { - filesByLanguage.set(lang, []); - } - - const extensionToLang = new Map(); - for (const lang of langList) { - const extensions = LANGUAGE_EXTENSIONS[lang] || []; - for (const ext of extensions) { - if (!extensionToLang.has(ext)) { - extensionToLang.set(ext, lang); - } - } - } - - const isIgnored = slopAnalyzers.parseGitignore(basePath, fs, path); - const maxFiles = Number.isFinite(options.maxFiles) ? Math.max(0, Math.floor(options.maxFiles)) : null; - let count = 0; - - function scan(dir) { - if (maxFiles !== null && count >= maxFiles) return; - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (maxFiles !== null && count >= maxFiles) break; - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - const relativePath = path.relative(basePath, fullPath); - if (slopAnalyzers.shouldExclude(relativePath, EXCLUDE_DIRS)) continue; - if (isIgnored && isIgnored(relativePath, true)) continue; - if (!entry.name.startsWith('.')) { - scan(fullPath); - } - } else if (entry.isFile()) { - const relativePath = path.relative(basePath, fullPath); - if (slopAnalyzers.shouldExclude(relativePath, EXCLUDE_DIRS)) continue; - if (isIgnored && isIgnored(relativePath, false)) continue; - const ext = path.extname(entry.name).toLowerCase(); - const lang = extensionToLang.get(ext); - if (lang) { - const bucket = filesByLanguage.get(lang); - if (bucket) { - bucket.push(fullPath); - count++; - } - } - } - } - } catch { - // Skip directories we can't read - } - } - - scan(basePath); - return filesByLanguage; -} - -function createSymbolMaps() { - return { - exports: new Map(), - functions: new Map(), - classes: new Map(), - types: new Map(), - constants: new Map() - }; -} - -/** - * Read multiple files asynchronously in batches - * @param {string[]} files - Array of file paths - * @param {number} batchSize - Concurrent reads per batch - * @returns {Promise>} - */ -async function batchReadFiles(files, batchSize = FILE_READ_BATCH_SIZE) { - const results = new Map(); - - for (let i = 0; i < files.length; i += batchSize) { - const batch = files.slice(i, i + batchSize); - const batchResults = await Promise.all( - batch.map(async (file) => { - try { - const content = await fsPromises.readFile(file, 'utf8'); - return { file, content, error: null }; - } catch (err) { - return { file, content: null, error: err }; - } - }) - ); - - for (const result of batchResults) { - results.set(result.file, { content: result.content, error: result.error }); - } - } - - return results; -} - -function chunkArray(items, size) { - if (!items || items.length === 0) return []; - const chunks = []; - for (let i = 0; i < items.length; i += size) { - chunks.push(items.slice(i, i + size)); - } - return chunks; -} - -function truncatePattern(pattern, max = 120) { - if (typeof pattern !== 'string') return ''; - if (pattern.length <= max) return pattern; - return `${pattern.slice(0, max - 3)}...`; -} - -function buildAstGrepError({ reason, pattern, lang, filePaths, basePath, stderr }) { - const batchLabel = Array.isArray(filePaths) && filePaths.length === 1 - ? normalizeMatchPath(filePaths[0], basePath) - : '[batch]'; - - const details = stderr && String(stderr).trim() - ? ` (${String(stderr).trim()})` - : ''; - - return { - file: batchLabel, - error: `ast-grep ${reason} for ${lang}${details}`, - pattern: truncatePattern(pattern) - }; -} - -function runAstGrepPatternAsync(cmd, pattern, lang, basePath, filePaths, options = {}) { - if (!pattern || !filePaths || filePaths.length === 0) { - return Promise.resolve([]); - } - - return new Promise((resolve) => { - const child = spawn(cmd, [ - 'run', - '--pattern', pattern, - '--lang', lang, - '--json=stream', - ...filePaths - ], { - cwd: basePath, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let stdout = ''; - let stderr = ''; - let settled = false; - - const timeoutHandle = setTimeout(() => { - if (settled) return; - settled = true; - child.kill(); - if (typeof options.onError === 'function') { - options.onError(buildAstGrepError({ - reason: 'timed out after 300000ms', - pattern, - lang, - filePaths, - basePath, - stderr - })); - } - resolve([]); - }, 300000); - - child.stdout.on('data', (chunk) => { - stdout += chunk.toString(); - }); - - child.stderr.on('data', (chunk) => { - stderr += chunk.toString(); - }); - - child.on('error', (error) => { - if (settled) return; - settled = true; - clearTimeout(timeoutHandle); - if (typeof options.onError === 'function') { - options.onError(buildAstGrepError({ - reason: 'execution failed', - pattern, - lang, - filePaths, - basePath, - stderr: error.message - })); - } - resolve([]); - }); - - child.on('close', (code) => { - if (settled) return; - settled = true; - clearTimeout(timeoutHandle); - - if (typeof code === 'number' && code > 1) { - if (typeof options.onError === 'function') { - options.onError(buildAstGrepError({ - reason: `returned exit code ${code}`, - pattern, - lang, - filePaths, - basePath, - stderr - })); - } - resolve([]); - return; - } - - resolve(parseNdjson(stdout)); - }); - }); -} - -async function runAstGrepPatternBatches(cmd, pattern, lang, basePath, chunks, options = {}) { - const concurrency = Number.isFinite(options.concurrency) - ? Math.max(1, Math.floor(options.concurrency)) - : AST_GREP_CONCURRENCY; - - return runWithConcurrency(chunks, concurrency, async (chunk) => { - return runAstGrepPatternAsync(cmd, pattern, lang, basePath, chunk, options); - }); -} - -function normalizeMatchPath(matchFile, basePath) { - if (!matchFile) return null; - const absolutePath = path.isAbsolute(matchFile) ? matchFile : path.join(basePath, matchFile); - return path.relative(basePath, absolutePath).replace(/\\/g, '/'); -} - -function addSymbolToMap(map, name, match, kind, extra = {}) { - if (!name) return; - if (!map.has(name)) { - map.set(name, { - name, - line: getLine(match), - kind, - ...extra - }); - } -} - -function runAstGrepPattern(cmd, pattern, lang, basePath, filePaths, options = {}) { - if (!pattern || !filePaths || filePaths.length === 0) return []; - - try { - const result = spawnSync(cmd, [ - 'run', - '--pattern', pattern, - '--lang', lang, - '--json=stream', - ...filePaths - ], { - cwd: basePath, - encoding: 'utf8', - timeout: 300000, - windowsHide: true, - stdio: ['pipe', 'pipe', 'pipe'] - }); - - if (result.error) { - if (typeof options.onError === 'function') { - options.onError(buildAstGrepError({ - reason: 'execution failed', - pattern, - lang, - filePaths, - basePath, - stderr: result.error.message - })); - } - return []; - } - - if (typeof result.status === 'number' && result.status > 1) { - if (typeof options.onError === 'function') { - options.onError(buildAstGrepError({ - reason: `returned exit code ${result.status}`, - pattern, - lang, - filePaths, - basePath, - stderr: result.stderr - })); - } - return []; - } - - return parseNdjson(result.stdout); - } catch (error) { - if (typeof options.onError === 'function') { - options.onError(buildAstGrepError({ - reason: 'threw an exception', - pattern, - lang, - filePaths, - basePath, - stderr: error.message - })); - } - return []; - } -} - -/** - * Extract symbols from a file using ast-grep - * @param {string} cmd - ast-grep command - * @param {string} file - File path - * @param {string} language - Language name - * @param {Object} langQueries - Query patterns for this language - * @param {string} basePath - Repository root (for cwd) - * @param {string} content - File content - * @returns {Object} - Extracted symbols - */ -function extractSymbols(cmd, file, language, langQueries, basePath, content, options = {}) { - const symbols = { - exports: [], - functions: [], - classes: [], - types: [], - constants: [] - }; - - const sgLang = queries.getSgLanguageForFile(file, language); - - const exportMap = new Map(); - const functionMap = new Map(); - const classMap = new Map(); - const typeMap = new Map(); - const constMap = new Map(); - - const addSymbol = (map, name, match, kind, extra = {}) => { - if (!name) return; - if (!map.has(name)) { - map.set(name, { - name, - line: getLine(match), - kind, - ...extra - }); - } - }; - - const runPatternSet = (patterns, targetMap, defaultKind) => { - if (!patterns) return; - for (const patternDef of patterns) { - const pattern = patternDef.pattern || patternDef; - const results = runAstGrep(cmd, file, pattern, sgLang, basePath, options); - for (const match of results) { - const names = extractNamesFromMatch(match, patternDef); - for (const name of names) { - const kind = patternDef.kind || defaultKind; - addSymbol(targetMap, name, match, kind, patternDef.extra); - } - } - } - }; - - // Extract exports - runPatternSet(langQueries.exports, exportMap, 'export'); - - // Extract functions - runPatternSet(langQueries.functions, functionMap, 'function'); - - // Extract classes - runPatternSet(langQueries.classes, classMap, 'class'); - - // Extract types - runPatternSet(langQueries.types, typeMap, 'type'); - - // Extract constants - runPatternSet(langQueries.constants, constMap, 'constant'); - - // Infer exports for languages with implicit public rules - const exportNames = new Set(exportMap.keys()); - applyLanguageExportRules(language, content, exportNames, functionMap, classMap, typeMap, constMap); - - // Ensure export entries exist for inferred exports - ensureExportEntries(exportMap, exportNames, functionMap, classMap, typeMap, constMap); - - // Convert maps to arrays and mark exported flags - symbols.exports = mapToSortedArray(exportMap); - symbols.functions = mapToSortedArray(functionMap, exportNames); - symbols.classes = mapToSortedArray(classMap, exportNames); - symbols.types = mapToSortedArray(typeMap, exportNames); - symbols.constants = mapToSortedArray(constMap, exportNames); - - return symbols; -} - -/** - * Extract imports from a file using ast-grep - * @param {string} cmd - ast-grep command - * @param {string} file - File path - * @param {string} language - Language name - * @param {Object} langQueries - Query patterns for this language - * @param {string} basePath - Repository root (for cwd) - * @returns {Array} - Extracted imports - */ -function extractImports(cmd, file, language, langQueries, basePath, options = {}) { - const imports = []; - - if (!langQueries.imports) return imports; - - const sgLang = queries.getSgLanguageForFile(file, language); - const seen = new Set(); - - for (const patternDef of langQueries.imports) { - const pattern = patternDef.pattern || patternDef; - const results = runAstGrep(cmd, file, pattern, sgLang, basePath, options); - for (const match of results) { - const sourceResult = extractSourceFromMatch(match, patternDef); - const sources = Array.isArray(sourceResult) ? sourceResult : [sourceResult]; - for (const source of sources) { - if (!source) continue; - const key = `${source}:${patternDef.kind || 'import'}`; - if (seen.has(key)) continue; - seen.add(key); - imports.push({ - source, - kind: patternDef.kind || 'import', - line: getLine(match) - }); - } - } - } - - return imports; -} - -/** - * Run ast-grep with a pattern - * @param {string} cmd - ast-grep command - * @param {string} file - File to scan - * @param {string} pattern - Pattern to match - * @param {string} lang - ast-grep language identifier - * @param {string} basePath - Working directory - * @returns {Array} - Match results - */ -function runAstGrep(cmd, file, pattern, lang, basePath, options = {}) { - try { - const result = spawnSync(cmd, [ - 'run', - '--pattern', pattern, - '--lang', lang, - '--json=stream', - file - ], { - cwd: basePath, - encoding: 'utf8', - timeout: 30000, - windowsHide: true, - stdio: ['pipe', 'pipe', 'pipe'] - }); - - if (result.error) { - if (typeof options.onError === 'function') { - options.onError(buildAstGrepError({ - reason: 'execution failed', - pattern, - lang, - filePaths: [file], - basePath, - stderr: result.error.message - })); - } - return []; - } - - // ast-grep exits with 1 when no matches - if (typeof result.status === 'number' && result.status > 1) { - if (typeof options.onError === 'function') { - options.onError(buildAstGrepError({ - reason: `returned exit code ${result.status}`, - pattern, - lang, - filePaths: [file], - basePath, - stderr: result.stderr - })); - } - return []; - } - - return parseNdjson(result.stdout); - } catch (error) { - if (typeof options.onError === 'function') { - options.onError(buildAstGrepError({ - reason: 'threw an exception', - pattern, - lang, - filePaths: [file], - basePath, - stderr: error.message - })); - } - return []; - } -} - -function parseNdjson(output) { - const matches = []; - const lines = (output || '').split('\n').filter(Boolean); - for (const line of lines) { - try { - matches.push(JSON.parse(line)); - } catch { - // Skip malformed lines - } - } - return matches; -} - -/** - * Extract names from an ast-grep match, based on pattern metadata - * @param {Object} match - ast-grep match result - * @param {Object|string} patternDef - Pattern definition or string - * @returns {string[]} - List of names - */ -function extractNamesFromMatch(match, patternDef) { - const def = typeof patternDef === 'string' ? {} : (patternDef || {}); - - if (def.multi === 'exportList') { - return extractNamesFromExportList(match.text || ''); - } - - if (def.multi === 'objectLiteral') { - return extractNamesFromObjectLiteral(match.text || ''); - } - - const name = extractNameFromMatch(match, def.nameVar); - if (name) return [name]; - if (def.fallbackName) return [def.fallbackName]; - return []; -} - -function getMetaVariable(match, key) { - if (!match || !match.metaVariables) return null; - if (match.metaVariables[key]) return match.metaVariables[key]; - if (match.metaVariables.single && match.metaVariables.single[key]) { - return match.metaVariables.single[key]; - } - return null; -} - -/** - * Extract a single name from ast-grep match - * @param {Object} match - ast-grep match result - * @param {string|string[]} nameVar - Preferred meta variable name(s) - * @returns {string|null} - */ -function extractNameFromMatch(match, nameVar) { - const vars = []; - if (Array.isArray(nameVar)) { - vars.push(...nameVar); - } else if (nameVar) { - vars.push(nameVar); - } - vars.push('NAME', 'FUNC', 'CLASS', 'IDENT', 'N'); - - for (const key of vars) { - const variable = getMetaVariable(match, key); - if (variable && variable.text) { - return variable.text; - } - } - - // Fallback: extract from matched text - if (match.text) { - const nameMatch = match.text.match(/(?:function|class|const|let|var|def|fn|pub\s+fn|type|struct|enum|trait|interface|record)\s+([a-zA-Z_][a-zA-Z0-9_]*)/); - if (nameMatch) { - return nameMatch[1]; - } - } - - return null; -} - -/** - * Extract import source from ast-grep match - * @param {Object} match - ast-grep match result - * @param {Object|string} patternDef - Pattern definition or string - * @returns {string|null} - */ -function extractSourceFromMatch(match, patternDef) { - const def = typeof patternDef === 'string' ? {} : (patternDef || {}); - const sourceVar = def.sourceVar || 'SOURCE'; - - const variable = getMetaVariable(match, sourceVar); - if (variable && variable.text) { - const raw = variable.text.replace(/^['"]|['"]$/g, ''); - if (def.multiSource) { - return splitMultiSource(raw); - } - return raw; - } - - // Fallback: extract quoted string from match - if (match.text) { - const sourceMatch = match.text.match(/['"]([^'"]+)['"]/); - if (sourceMatch) { - return sourceMatch[1]; - } - } - - return null; -} - -/** - * Extract export names from `export { ... }` - * @param {string} text - Match text - * @returns {string[]} - */ -function extractNamesFromExportList(text) { - const match = text.match(/\{([^}]+)\}/); - if (!match) return []; - - const names = new Set(); - const parts = match[1].split(','); - for (const part of parts) { - const trimmed = part.trim(); - if (!trimmed) continue; - const aliasMatch = trimmed.split(/\s+as\s+/i).map(s => s.trim()); - const name = (aliasMatch[1] || aliasMatch[0]).replace(/[^a-zA-Z0-9_\$]/g, ''); - if (isValidIdentifier(name)) names.add(name); - } - - return Array.from(names); -} - -/** - * Extract property names from object literal - * @param {string} text - Match text - * @returns {string[]} - */ -function extractNamesFromObjectLiteral(text) { - const match = text.match(/\{([\s\S]*?)\}/); - if (!match) return []; - - const body = match[1]; - const names = new Set(); - - // Match shorthand properties and key: value pairs - const propRegex = /\b([A-Za-z_$][\w$]*)\b\s*(?=,|\}|:)/g; - let propMatch; - while ((propMatch = propRegex.exec(body)) !== null) { - const name = propMatch[1]; - if (isValidIdentifier(name)) names.add(name); - } - - return Array.from(names); -} - -/** - * Split comma-separated import sources - * @param {string} raw - Raw source text - * @returns {string[]} - */ -function splitMultiSource(raw) { - if (!raw) return []; - const parts = raw.split(',').map(p => p.trim()).filter(Boolean); - const results = []; - for (const part of parts) { - const [name] = part.split(/\s+as\s+/i); - const cleaned = name.trim().replace(/^['"]|['"]$/g, ''); - if (cleaned) results.push(cleaned); - } - return results; -} - -/** - * Determine if a name is a valid identifier - * @param {string} name - Name to check - * @returns {boolean} - */ -function isValidIdentifier(name) { - return Boolean(name) && /^[A-Za-z_$][\w$]*$/.test(name); -} - -/** - * Get 1-based line number from ast-grep match - * @param {Object} match - ast-grep match - * @returns {number|null} - */ -function getLine(match) { - const line = match?.range?.start?.line; - return typeof line === 'number' ? line + 1 : null; -} - -/** - * Apply language-specific export rules - * @param {string} language - Language name - * @param {string} content - File content - * @param {Set} exportNames - Export name set (in-place) - * @param {Map} functionMap - Function symbols - * @param {Map} classMap - Class symbols - * @param {Map} typeMap - Type symbols - * @param {Map} constMap - Constant symbols - */ -function applyLanguageExportRules(language, content, exportNames, functionMap, classMap, typeMap, constMap) { - if (language === 'python') { - const explicit = extractPythonAll(content); - if (explicit.length > 0) { - for (const name of explicit) exportNames.add(name); - } else { - addPublicNames(exportNames, functionMap, classMap, typeMap, constMap, name => !name.startsWith('_')); - } - return; - } - - if (language === 'go') { - addPublicNames(exportNames, functionMap, classMap, typeMap, constMap, name => isExportedGoName(name)); - } -} - -/** - * Extract __all__ exports from Python content - * @param {string} content - File content - * @returns {string[]} - */ -function extractPythonAll(content) { - if (!content) return []; - const match = content.match(/__all__\s*=\s*[\[(]([\s\S]*?)[\])]\s*/m); - if (!match) return []; - - const body = match[1]; - const names = []; - const stringRegex = /['"]([^'"]+)['"]/g; - let m; - while ((m = stringRegex.exec(body)) !== null) { - if (m[1]) names.push(m[1]); - } - return names; -} - -/** - * Add public names from symbol maps based on predicate - * @param {Set} exportNames - Export name set - * @param {...Map} maps - Symbol maps - * @param {Function} predicate - Function(name) => boolean - */ -function addPublicNames(exportNames, ...args) { - const predicate = args.pop(); - const maps = args; - for (const map of maps) { - for (const name of map.keys()) { - if (predicate(name)) exportNames.add(name); - } - } -} - -/** - * Determine if a Go identifier is exported - * @param {string} name - Identifier - * @returns {boolean} - */ -function isExportedGoName(name) { - if (!name) return false; - const first = name[0]; - return first.toUpperCase() === first && first.toLowerCase() !== first; -} - -/** - * Ensure export entries exist for inferred exports - * @param {Map} exportMap - Export map to populate - * @param {Set} exportNames - Names to ensure - * @param {Map} functionMap - Function map - * @param {Map} classMap - Class map - * @param {Map} typeMap - Type map - * @param {Map} constMap - Constant map - */ -function ensureExportEntries(exportMap, exportNames, functionMap, classMap, typeMap, constMap) { - const sources = [functionMap, classMap, typeMap, constMap]; - - for (const name of exportNames) { - if (exportMap.has(name)) continue; - - let entry = null; - for (const map of sources) { - if (map.has(name)) { - const item = map.get(name); - entry = { name, line: item.line, kind: item.kind }; - break; - } - } - - if (!entry) { - entry = { name, line: null, kind: 'export' }; - } - - exportMap.set(name, entry); - } -} - -/** - * Convert symbol map to sorted array and mark exported flags - * @param {Map} map - Symbol map - * @param {Set} [exportNames] - Export name set - * @returns {Array} - */ -function mapToSortedArray(map, exportNames) { - const list = Array.from(map.values()); - if (exportNames) { - for (const item of list) { - item.exported = exportNames.has(item.name); - } - } - list.sort((a, b) => a.name.localeCompare(b.name)); - return list; -} - -/** - * Get git info for the repository - * @param {string} basePath - Repository root - * @returns {Object|null} - */ -function getGitInfo(basePath) { - try { - const commit = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: basePath, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'] - }).trim(); - - const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: basePath, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'] - }).trim(); - - return { commit, branch }; - } catch { - return null; - } -} - -/** - * Detect primary project type from languages - * @param {string[]} languages - Detected languages - * @returns {string} - */ -function detectProjectType(languages) { - // Priority order - const priority = ['typescript', 'javascript', 'python', 'rust', 'go', 'java']; - for (const lang of priority) { - if (languages.includes(lang)) { - return lang === 'typescript' ? 'node' : lang; - } - } - return languages[0] || 'unknown'; -} - -/** - * Scan a single file (for incremental updates) - * @param {string} cmd - ast-grep command - * @param {string} file - File path - * @param {string} basePath - Repository root - * @returns {Object|null} - File data or null if failed - */ -function scanSingleFile(cmd, file, basePath, options = {}) { - const ext = path.extname(file).toLowerCase(); - - // Find language for this extension - let language = null; - for (const [lang, exts] of Object.entries(LANGUAGE_EXTENSIONS)) { - if (exts.includes(ext)) { - language = lang; - break; - } - } - - if (!language) return null; - - const langQueries = queries.getQueriesForLanguage(language); - if (!langQueries) return null; - - try { - const content = fs.readFileSync(file, 'utf8'); - const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 16); - - const symbols = extractSymbols(cmd, file, language, langQueries, basePath, content, options); - const imports = extractImports(cmd, file, language, langQueries, basePath, options); - - return { - hash, - language, - size: content.length, - symbols, - imports - }; - } catch (error) { - if (typeof options.onError === 'function') { - options.onError({ - file: normalizeMatchPath(file, basePath) || file, - error: `Failed to scan file: ${error.message}` - }); - } - return null; - } -} - -/** - * Scan a single file asynchronously (for incremental updates) - * Uses async file read, but ast-grep subprocess remains synchronous - * @param {string} cmd - ast-grep command - * @param {string} file - File path - * @param {string} basePath - Repository root - * @returns {Promise} - File data or null if failed - */ -async function scanSingleFileAsync(cmd, file, basePath, options = {}) { - const ext = path.extname(file).toLowerCase(); - - // Find language for this extension - let language = null; - for (const [lang, exts] of Object.entries(LANGUAGE_EXTENSIONS)) { - if (exts.includes(ext)) { - language = lang; - break; - } - } - - if (!language) return null; - - const langQueries = queries.getQueriesForLanguage(language); - if (!langQueries) return null; - - try { - const content = await fsPromises.readFile(file, 'utf8'); - const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 16); - - const symbols = extractSymbols(cmd, file, language, langQueries, basePath, content, options); - const imports = extractImports(cmd, file, language, langQueries, basePath, options); - - return { - hash, - language, - size: content.length, - symbols, - imports - }; - } catch (error) { - if (typeof options.onError === 'function') { - options.onError({ - file: normalizeMatchPath(file, basePath) || file, - error: `Failed to scan file: ${error.message}` - }); - } - return null; - } -} - -module.exports = { - detectLanguages, - fullScan, - findFilesForLanguage, - collectFilesByLanguage, - scanSingleFile, - scanSingleFileAsync, - runAstGrep, - getGitInfo, - batchReadFiles, - LANGUAGE_EXTENSIONS, - EXCLUDE_DIRS -}; diff --git a/lib/repo-map/updater.js b/lib/repo-map/updater.js index baceb90..3bd1c13 100644 --- a/lib/repo-map/updater.js +++ b/lib/repo-map/updater.js @@ -1,347 +1,23 @@ +'use strict'; + /** - * Repo map incremental updater + * Repo map staleness checker. + * + * Determines whether a cached repo-map is stale relative to the current git HEAD. + * The incremental update path is handled by agent-analyzer (repo-intel update). * * @module lib/repo-map/updater */ -'use strict'; - -const fsPromises = require('fs').promises; -const path = require('path'); const { execFileSync } = require('child_process'); -const runner = require('./runner'); const cache = require('./cache'); -const installer = require('./installer'); -const { runWithConcurrency } = require('./concurrency'); - -const SCAN_CONCURRENCY = 8; -const SCANNABLE_EXTENSIONS = new Set(Object.values(runner.LANGUAGE_EXTENSIONS).flat()); - -function isScannableFile(filePath) { - const ext = path.extname(filePath).toLowerCase(); - return SCANNABLE_EXTENSIONS.has(ext); -} - -/** - * Perform incremental update based on git diff - * @param {string} basePath - Repository root - * @param {Object} map - Existing repo map - * @returns {Promise<{success: boolean, map?: Object, changes?: Object, error?: string, needsFullRebuild?: boolean}>} - */ -async function incrementalUpdate(basePath, map) { - // Validate ast-grep - const installed = installer.checkInstalledSync(); - if (!installed.found) { - return { - success: false, - error: 'ast-grep not found', - installSuggestion: installer.getInstallInstructions() - }; - } - - if (!installer.meetsMinimumVersion(installed.version)) { - return { - success: false, - error: `ast-grep version ${installed.version || 'unknown'} is too old. Minimum required: ${installer.getMinimumVersion()}`, - installSuggestion: installer.getInstallInstructions() - }; - } - - if (!map || !map.files) { - return { - success: false, - error: 'Invalid repo map', - needsFullRebuild: true - }; - } - map.stats = map.stats || {}; - if (!Array.isArray(map.stats.errors)) { - map.stats.errors = []; - } - if (map.docs) { - delete map.docs; - } - - // Try git-based update first - const gitInfo = runner.getGitInfo(basePath); - if (!gitInfo || !map.git?.commit) { - return updateWithoutGit(basePath, map, installed.command); - } - - // Check if base commit exists - if (!commitExists(basePath, map.git.commit)) { - return { - success: false, - error: 'Base commit not found (history rewritten). Full rebuild required.', - needsFullRebuild: true - }; - } - - const diff = getGitDiff(basePath, map.git.commit); - if (diff === null) { - return updateWithoutGit(basePath, map, installed.command); - } - - const changes = parseDiff(diff); - - // No changes - just update metadata - if (changes.total === 0) { - map.git = gitInfo; - map.updated = new Date().toISOString(); - return { - success: true, - map, - changes: { total: 0, updated: 0, added: 0, deleted: 0, renamed: 0 } - }; - } - - // Apply deletions - for (const file of changes.deleted) { - delete map.files[file]; - delete map.dependencies[file]; - } - - // Apply renames - for (const { from, to } of changes.renamed) { - if (map.files[from]) { - map.files[to] = map.files[from]; - delete map.files[from]; - } - if (map.dependencies[from]) { - map.dependencies[to] = map.dependencies[from]; - delete map.dependencies[from]; - } - } - - // Apply added/modified - batch file existence checks - const updatedFiles = [...changes.added, ...changes.modified]; - const fullPaths = updatedFiles.map(file => ({ file, fullPath: path.join(basePath, file) })); - - // Batch check file existence - const existenceChecks = await Promise.all( - fullPaths.map(async ({ file, fullPath }) => { - try { - await fsPromises.access(fullPath); - return { file, fullPath, exists: true }; - } catch { - return { file, fullPath, exists: false }; - } - }) - ); - - // Process files that exist with bounded concurrency - const scanTargets = existenceChecks.filter(({ file, exists }) => exists && isScannableFile(file)); - const scanResults = await runWithConcurrency(scanTargets, SCAN_CONCURRENCY, async ({ file, fullPath }) => { - const astErrors = []; - const fileData = await runner.scanSingleFileAsync(installed.command, fullPath, basePath, { - onError: (error) => astErrors.push(error) - }); - return { file, fileData, astErrors }; - }); - - const scanFailures = []; - for (const result of scanResults) { - if (!result) continue; - - if (result.astErrors.length > 0) { - map.stats.errors.push(...result.astErrors); - } - - if (!result.fileData) { - if (result.astErrors.length > 0) { - scanFailures.push(result.file); - } - continue; - } - - map.files[result.file] = result.fileData; - if (result.fileData.imports && result.fileData.imports.length > 0) { - map.dependencies[result.file] = Array.from(new Set(result.fileData.imports.map(imp => imp.source))); - } else { - delete map.dependencies[result.file]; - } - } - - if (scanFailures.length > 0) { - return { - success: false, - error: `Failed to rescan ${scanFailures.length} file(s) during incremental update`, - needsFullRebuild: true, - failedFiles: scanFailures - }; - } - - // Recalculate stats - recalculateStats(map); - - // Update git metadata - map.git = gitInfo; - map.updated = new Date().toISOString(); - - return { - success: true, - map, - changes: { - total: changes.total, - updated: updatedFiles.length, - added: changes.added.length, - deleted: changes.deleted.length, - renamed: changes.renamed.length - } - }; -} /** - * Update without git (hash comparison) - * @param {string} basePath - Repository root - * @param {Object} map - Existing repo map - * @param {string} cmd - ast-grep command - * @returns {Promise<{success: boolean, map?: Object, changes?: Object}>} - */ -async function updateWithoutGit(basePath, map, cmd) { - const currentFiles = new Set(); - const languages = map.project?.languages || []; - map.stats = map.stats || {}; - if (!Array.isArray(map.stats.errors)) { - map.stats.errors = []; - } - if (map.docs) { - delete map.docs; - } - - for (const lang of languages) { - const files = runner.findFilesForLanguage(basePath, lang); - for (const file of files) { - currentFiles.add(path.relative(basePath, file).replace(/\\/g, '/')); - } - } - - const changes = { - added: [], - modified: [], - deleted: [], - renamed: [], - total: 0 - }; - - // Collect existing files to check - const existingFiles = Object.keys(map.files); - const filesToCheck = []; - const filesToDelete = []; - - for (const file of existingFiles) { - if (!currentFiles.has(file)) { - filesToDelete.push(file); - } else { - filesToCheck.push(file); - currentFiles.delete(file); - } - } - - // Process deletions - for (const file of filesToDelete) { - changes.deleted.push(file); - delete map.files[file]; - delete map.dependencies[file]; - } - - // Process existing files for modifications (async file reads) - const checkResults = await runWithConcurrency(filesToCheck, SCAN_CONCURRENCY, async (file) => { - const fullPath = path.join(basePath, file); - const astErrors = []; - const fileData = await runner.scanSingleFileAsync(cmd, fullPath, basePath, { - onError: (error) => astErrors.push(error) - }); - return { file, fileData, astErrors }; - }); - - const scanFailures = []; - for (const result of checkResults) { - if (!result) continue; - - if (result.astErrors.length > 0) { - map.stats.errors.push(...result.astErrors); - } - - if (!result.fileData) { - scanFailures.push(result.file); - continue; - } - - if (result.fileData.hash !== map.files[result.file].hash) { - map.files[result.file] = result.fileData; - if (result.fileData.imports && result.fileData.imports.length > 0) { - map.dependencies[result.file] = Array.from(new Set(result.fileData.imports.map(imp => imp.source))); - } else { - delete map.dependencies[result.file]; - } - changes.modified.push(result.file); - } - } - - // Process new files (async file reads) - const addedFiles = Array.from(currentFiles); - const addResults = await runWithConcurrency(addedFiles, SCAN_CONCURRENCY, async (file) => { - const fullPath = path.join(basePath, file); - const astErrors = []; - const fileData = await runner.scanSingleFileAsync(cmd, fullPath, basePath, { - onError: (error) => astErrors.push(error) - }); - return { file, fileData, astErrors }; - }); - - for (const result of addResults) { - if (!result) continue; - - if (result.astErrors.length > 0) { - map.stats.errors.push(...result.astErrors); - } - - if (!result.fileData) { - scanFailures.push(result.file); - continue; - } - - map.files[result.file] = result.fileData; - if (result.fileData.imports && result.fileData.imports.length > 0) { - map.dependencies[result.file] = Array.from(new Set(result.fileData.imports.map(imp => imp.source))); - } - changes.added.push(result.file); - } - - if (scanFailures.length > 0) { - return { - success: false, - error: `Failed to rescan ${scanFailures.length} file(s) during non-git update`, - needsFullRebuild: true, - failedFiles: scanFailures - }; - } - - changes.total = changes.added.length + changes.modified.length + changes.deleted.length; - - recalculateStats(map); - map.updated = new Date().toISOString(); - - return { - success: true, - map, - changes: { - total: changes.total, - updated: changes.modified.length, - added: changes.added.length, - deleted: changes.deleted.length, - renamed: changes.renamed.length - } - }; -} - -/** - * Check if repo-map is stale - * @param {string} basePath - Repository root - * @param {Object} map - Repo map - * @returns {Object} Staleness info + * Check whether the cached map is stale + * @param {string} basePath - Repository root path + * @param {Object} map - Cached repo map + * @returns {{isStale: boolean, reason: string|null, commitsBehind: number, suggestFullRebuild: boolean}} */ function checkStaleness(basePath, map) { const result = { @@ -389,140 +65,35 @@ function checkStaleness(basePath, map) { return result; } -/** - * Parse git diff output - * @param {string} diff - Git diff output - * @returns {Object} - */ -function parseDiff(diff) { - const changes = { - added: [], - modified: [], - deleted: [], - renamed: [], - total: 0 - }; - - const lines = diff.split('\n').filter(Boolean); - for (const line of lines) { - const parts = line.split('\t'); - const status = parts[0]; - - if (status.startsWith('R')) { - const from = normalizePath(parts[1]); - const to = normalizePath(parts[2]); - changes.renamed.push({ from, to }); - const renameScore = Number(status.slice(1)); - if (!Number.isNaN(renameScore) && renameScore < 100 && to) { - changes.modified.push(to); - } - continue; - } - - const file = normalizePath(parts[1]); - if (!file) continue; - - if (status === 'A') changes.added.push(file); - else if (status === 'M') changes.modified.push(file); - else if (status === 'D') changes.deleted.push(file); - - } - - changes.total = changes.added.length + changes.modified.length + changes.deleted.length + changes.renamed.length; - return changes; -} - -/** - * Validate git commit hash format - * @param {string} commit - Commit hash to validate - * @returns {boolean} True if valid hex commit hash - */ function isValidCommitHash(commit) { - // Git commit hashes are 4-40 hex characters (short to full SHA) return typeof commit === 'string' && /^[0-9a-fA-F]{4,40}$/.test(commit); } -/** - * Get git diff name-status - * @param {string} basePath - Repository root - * @param {string} sinceCommit - Base commit - * @returns {string|null} - */ -function getGitDiff(basePath, sinceCommit) { - // Validate commit hash to prevent command injection - if (!isValidCommitHash(sinceCommit)) { - return null; - } - try { - // Use execFileSync with arg array to prevent command injection - return execFileSync('git', ['diff', '--name-status', '-M', sinceCommit, 'HEAD'], { - cwd: basePath, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'] - }).trim(); - } catch { - return null; - } -} - -/** - * Check if commit exists - * @param {string} basePath - Repository root - * @param {string} commit - Commit hash - * @returns {boolean} - */ function commitExists(basePath, commit) { - // Validate commit hash to prevent command injection - if (!isValidCommitHash(commit)) { - return false; - } + if (!isValidCommitHash(commit)) return false; try { - // Use execFileSync with arg array to prevent command injection - execFileSync('git', ['cat-file', '-e', commit], { - cwd: basePath, - stdio: ['pipe', 'pipe', 'pipe'] - }); + execFileSync('git', ['cat-file', '-e', commit], { cwd: basePath, stdio: ['pipe', 'pipe', 'pipe'] }); return true; } catch { return false; } } -/** - * Get current branch name - * @param {string} basePath - Repository root - * @returns {string|null} - */ function getCurrentBranch(basePath) { try { - // Use execFileSync with arg array for consistency return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: basePath, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'] + cwd: basePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); } catch { return null; } } -/** - * Get number of commits behind HEAD - * @param {string} basePath - Repository root - * @param {string} commit - Base commit - * @returns {number} - */ function getCommitsBehind(basePath, commit) { - // Validate commit hash to prevent command injection - if (!isValidCommitHash(commit)) { - return 0; - } + if (!isValidCommitHash(commit)) return 0; try { - // Use execFileSync with arg array to prevent command injection const out = execFileSync('git', ['rev-list', `${commit}..HEAD`, '--count'], { - cwd: basePath, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'] + cwd: basePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); return Number(out) || 0; } catch { @@ -530,33 +101,4 @@ function getCommitsBehind(basePath, commit) { } } -/** - * Normalize file path to forward slashes - * @param {string} filePath - Path to normalize - * @returns {string} - */ -function normalizePath(filePath) { - return filePath ? filePath.replace(/\\/g, '/') : filePath; -} - -/** - * Recalculate map stats - * @param {Object} map - Repo map - */ -function recalculateStats(map) { - const files = Object.values(map.files || {}); - map.stats.totalFiles = files.length; - map.stats.totalSymbols = files.reduce((sum, file) => { - return sum + - (file.symbols?.functions?.length || 0) + - (file.symbols?.classes?.length || 0) + - (file.symbols?.types?.length || 0) + - (file.symbols?.constants?.length || 0); - }, 0); -} - -module.exports = { - incrementalUpdate, - updateWithoutGit, - checkStaleness -}; +module.exports = { checkStaleness }; diff --git a/lib/repo-map/usage-analyzer.js b/lib/repo-map/usage-analyzer.js deleted file mode 100644 index 9d33af7..0000000 --- a/lib/repo-map/usage-analyzer.js +++ /dev/null @@ -1,407 +0,0 @@ -/** - * Repo Map Usage Analyzer - * - * Cross-file usage tracking to enable deeper analysis: - * - Unused exports detection - * - Orphaned infrastructure detection - * - Symbol dependency mapping - * - * @module lib/repo-map/usage-analyzer - */ - -'use strict'; - -const path = require('path'); - -/** - * Build a reverse index mapping symbols to their importers - * @param {Object} repoMap - The repo map object from cache.load() - * @returns {Object} Usage index: { bySymbol: Map>, byFile: Map> } - */ -function buildUsageIndex(repoMap) { - if (!repoMap || !repoMap.files) { - return { bySymbol: new Map(), byFile: new Map() }; - } - - // bySymbol: symbolName -> Set of files that import it - // byFile: filePath -> Set of files that depend on it - const bySymbol = new Map(); - const byFile = new Map(); - - // Build export registry: filePath -> Set of exported symbol names - const exportsByFile = new Map(); - for (const [filePath, fileData] of Object.entries(repoMap.files)) { - const exports = new Set(); - if (fileData.symbols?.exports) { - for (const exp of fileData.symbols.exports) { - exports.add(exp.name); - } - } - exportsByFile.set(filePath, exports); - } - - // Process imports to build reverse index - for (const [importerPath, fileData] of Object.entries(repoMap.files)) { - if (!fileData.imports || fileData.imports.length === 0) continue; - - for (const imp of fileData.imports) { - const source = imp.source; - if (!source) continue; - - // Resolve the import source to a file path - const resolvedPath = resolveImportSource(importerPath, source, repoMap); - if (!resolvedPath) continue; - - // Track file-level dependency - if (!byFile.has(resolvedPath)) { - byFile.set(resolvedPath, new Set()); - } - byFile.get(resolvedPath).add(importerPath); - - // For named imports, track symbol-level usage - // The import kind tells us what type of import it is - if (imp.kind === 'named' || imp.kind === 'import') { - // Try to extract imported names from the import - const importedNames = extractImportedNames(imp, source); - for (const name of importedNames) { - const symbolKey = `${resolvedPath}:${name}`; - if (!bySymbol.has(symbolKey)) { - bySymbol.set(symbolKey, new Set()); - } - bySymbol.get(symbolKey).add(importerPath); - } - } - } - } - - return { bySymbol, byFile, exportsByFile }; -} - -/** - * Resolve an import source to a file path in the repo map - * @param {string} importerPath - Path of the importing file - * @param {string} source - Import source (e.g., './utils', 'lodash', '../lib') - * @param {Object} repoMap - The repo map - * @returns {string|null} Resolved file path or null - */ -function resolveImportSource(importerPath, source, repoMap) { - // Skip external packages - if (!source.startsWith('.') && !source.startsWith('/')) { - return null; - } - - const importerDir = path.dirname(importerPath); - let candidatePath = path.join(importerDir, source).replace(/\\/g, '/'); - - // Normalize the path - candidatePath = path.normalize(candidatePath).replace(/\\/g, '/'); - - // Try direct match - if (repoMap.files[candidatePath]) { - return candidatePath; - } - - // Try with common extensions - const extensions = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs']; - for (const ext of extensions) { - const withExt = candidatePath + ext; - if (repoMap.files[withExt]) { - return withExt; - } - } - - // Try index file - for (const ext of extensions) { - const indexPath = candidatePath + '/index' + ext; - if (repoMap.files[indexPath]) { - return indexPath; - } - } - - return null; -} - -/** - * Extract imported names from an import statement - * @param {Object} imp - Import object from repo map - * @param {string} source - Import source - * @returns {string[]} List of imported symbol names - */ -function extractImportedNames(imp, source) { - const names = []; - - // If the import has specific names recorded, use them - if (imp.names && Array.isArray(imp.names)) { - return imp.names; - } - - // For default imports, use the basename as heuristic - if (imp.kind === 'default') { - const basename = path.basename(source).replace(/\.[^.]+$/, ''); - names.push(basename); - } - - return names; -} - -/** - * Find which files import a specific symbol - * @param {Object} usageIndex - Result from buildUsageIndex - * @param {string} filePath - File containing the symbol - * @param {string} symbolName - Name of the symbol - * @returns {string[]} List of file paths that import this symbol - */ -function findUsages(usageIndex, filePath, symbolName) { - const symbolKey = `${filePath}:${symbolName}`; - const usages = usageIndex.bySymbol.get(symbolKey); - return usages ? Array.from(usages) : []; -} - -/** - * Find files that depend on a given file - * @param {Object} usageIndex - Result from buildUsageIndex - * @param {string} filePath - Path to the file - * @returns {string[]} List of file paths that import from this file - */ -function findDependents(usageIndex, filePath) { - const dependents = usageIndex.byFile.get(filePath); - return dependents ? Array.from(dependents) : []; -} - -/** - * Find exports that are never imported anywhere - * @param {Object} repoMap - The repo map - * @param {Object} usageIndex - Result from buildUsageIndex (optional, will build if not provided) - * @returns {Array} Unused exports: { file, name, line, kind } - */ -function findUnusedExports(repoMap, usageIndex = null) { - if (!repoMap || !repoMap.files) { - return []; - } - - const index = usageIndex || buildUsageIndex(repoMap); - const unusedExports = []; - - for (const [filePath, fileData] of Object.entries(repoMap.files)) { - if (!fileData.symbols?.exports) continue; - - // Check if the file itself is used - const fileDependents = index.byFile.get(filePath); - const fileIsImported = fileDependents && fileDependents.size > 0; - - for (const exp of fileData.symbols.exports) { - const symbolKey = `${filePath}:${exp.name}`; - const symbolUsages = index.bySymbol.get(symbolKey); - const symbolIsUsed = symbolUsages && symbolUsages.size > 0; - - // Check if this specific symbol is unused - // A file can be imported for some symbols but have other unused exports - if (!symbolIsUsed) { - // Skip entry points (index.js, main.js, etc.) - if (isEntryPoint(filePath)) continue; - - unusedExports.push({ - file: filePath, - name: exp.name, - line: exp.line, - kind: exp.kind || 'export', - // Higher certainty if file itself isn't imported at all - certainty: fileIsImported ? 'LOW' : 'MEDIUM' - }); - } - } - } - - return unusedExports; -} - -/** - * Check if a file is likely an entry point - * @param {string} filePath - File path - * @returns {boolean} - */ -function isEntryPoint(filePath) { - const basename = path.basename(filePath); - const entryNames = ['index', 'main', 'app', 'server', 'cli', 'bin']; - const nameWithoutExt = basename.replace(/\.[^.]+$/, '').toLowerCase(); - - return entryNames.includes(nameWithoutExt); -} - -/** - * Find orphaned infrastructure - components that are set up but never used - * Uses repo map for AST-based detection (higher certainty than regex) - * @param {Object} repoMap - The repo map - * @param {Object} usageIndex - Result from buildUsageIndex (optional) - * @returns {Array} Orphaned infrastructure: { file, name, line, kind, certainty } - */ -function findOrphanedInfrastructure(repoMap, usageIndex = null) { - if (!repoMap || !repoMap.files) { - return []; - } - - const index = usageIndex || buildUsageIndex(repoMap); - const orphaned = []; - - // Infrastructure component suffixes - const infrastructureSuffixes = [ - 'Client', 'Connection', 'Pool', 'Service', 'Provider', - 'Manager', 'Factory', 'Repository', 'Gateway', 'Adapter', - 'Handler', 'Broker', 'Queue', 'Cache', 'Store', - 'Transport', 'Channel', 'Socket', 'Server', 'Database' - ]; - - // Build regex for infrastructure detection - const suffixPattern = new RegExp(`(${infrastructureSuffixes.join('|')})$`); - - for (const [filePath, fileData] of Object.entries(repoMap.files)) { - // Check classes - if (fileData.symbols?.classes) { - for (const cls of fileData.symbols.classes) { - if (!suffixPattern.test(cls.name)) continue; - - // Check if this class is exported and used - const isExported = cls.exported === true; - if (!isExported) continue; - - const symbolKey = `${filePath}:${cls.name}`; - const usages = index.bySymbol.get(symbolKey); - const fileDependents = index.byFile.get(filePath); - - if ((!usages || usages.size === 0) && (!fileDependents || fileDependents.size === 0)) { - orphaned.push({ - file: filePath, - name: cls.name, - line: cls.line, - kind: 'class', - type: 'infrastructure', - certainty: 'HIGH' // AST-based detection - }); - } - } - } - - // Check functions that look like factory/builder patterns - if (fileData.symbols?.functions) { - const factoryPatterns = [ - /^create[A-Z]/, - /^make[A-Z]/, - /^build[A-Z]/, - /^new[A-Z]/, - /^init[A-Z]/, - /^setup[A-Z]/, - /^connect[A-Z]/ - ]; - - for (const fn of fileData.symbols.functions) { - const isFactory = factoryPatterns.some(p => p.test(fn.name)); - if (!isFactory) continue; - - const isExported = fn.exported === true; - if (!isExported) continue; - - const symbolKey = `${filePath}:${fn.name}`; - const usages = index.bySymbol.get(symbolKey); - const fileDependents = index.byFile.get(filePath); - - if ((!usages || usages.size === 0) && (!fileDependents || fileDependents.size === 0)) { - orphaned.push({ - file: filePath, - name: fn.name, - line: fn.line, - kind: 'function', - type: 'factory', - certainty: 'HIGH' - }); - } - } - } - } - - return orphaned; -} - -/** - * Get dependency graph for visualization or analysis - * @param {Object} repoMap - The repo map - * @returns {Object} Graph: { nodes: string[], edges: Array<{from, to}> } - */ -function getDependencyGraph(repoMap) { - if (!repoMap || !repoMap.files) { - return { nodes: [], edges: [] }; - } - - const nodes = Object.keys(repoMap.files); - const edges = []; - - for (const [filePath, fileData] of Object.entries(repoMap.files)) { - if (!fileData.imports) continue; - - for (const imp of fileData.imports) { - const resolved = resolveImportSource(filePath, imp.source, repoMap); - if (resolved) { - edges.push({ from: filePath, to: resolved }); - } - } - } - - return { nodes, edges }; -} - -/** - * Find circular dependencies - * @param {Object} repoMap - The repo map - * @returns {Array} List of cycles (each cycle is array of file paths) - */ -function findCircularDependencies(repoMap) { - const graph = getDependencyGraph(repoMap); - const cycles = []; - const visited = new Set(); - const recursionStack = new Set(); - const path = []; - - function dfs(node) { - visited.add(node); - recursionStack.add(node); - path.push(node); - - const neighbors = graph.edges - .filter(e => e.from === node) - .map(e => e.to); - - for (const neighbor of neighbors) { - if (!visited.has(neighbor)) { - dfs(neighbor); - } else if (recursionStack.has(neighbor)) { - // Found a cycle - const cycleStart = path.indexOf(neighbor); - const cycle = path.slice(cycleStart); - cycles.push([...cycle, neighbor]); - } - } - - path.pop(); - recursionStack.delete(node); - } - - for (const node of graph.nodes) { - if (!visited.has(node)) { - dfs(node); - } - } - - return cycles; -} - -module.exports = { - buildUsageIndex, - findUsages, - findDependents, - findUnusedExports, - findOrphanedInfrastructure, - getDependencyGraph, - findCircularDependencies, - // Expose helpers for testing - resolveImportSource, - isEntryPoint -};