From 13353326bb3fdecb71bfe66b3578d39535f77c7d Mon Sep 17 00:00:00 2001 From: agent-core-bot Date: Fri, 29 May 2026 10:32:37 +0000 Subject: [PATCH] chore: sync core lib and CLAUDE.md from agent-core --- lib/binary/index.js | 10 +- lib/binary/shared-helpers.js | 160 ++++++++++++ lib/repo-intel/cache.js | 171 +++++++++++++ lib/repo-intel/converter.js | 130 ++++++++++ lib/repo-intel/embed/binary.js | 242 ++++++++++++++++++ lib/repo-intel/embed/index.js | 26 ++ lib/repo-intel/embed/orchestrator.js | 239 +++++++++++++++++ lib/repo-intel/embed/preference.js | 136 ++++++++++ lib/repo-intel/enrich.js | 198 ++++++++++++++ lib/repo-intel/index.js | 370 +++++++++++++++++++++++++++ lib/repo-intel/installer.js | 78 ++++++ lib/repo-intel/queries.js | 226 +++++++++++++++- lib/repo-intel/updater.js | 104 ++++++++ lib/repo-map/index.js | 273 ++------------------ 14 files changed, 2094 insertions(+), 269 deletions(-) create mode 100644 lib/binary/shared-helpers.js create mode 100644 lib/repo-intel/cache.js create mode 100644 lib/repo-intel/converter.js create mode 100644 lib/repo-intel/embed/binary.js create mode 100644 lib/repo-intel/embed/index.js create mode 100644 lib/repo-intel/embed/orchestrator.js create mode 100644 lib/repo-intel/embed/preference.js create mode 100644 lib/repo-intel/enrich.js create mode 100644 lib/repo-intel/index.js create mode 100644 lib/repo-intel/installer.js create mode 100644 lib/repo-intel/updater.js diff --git a/lib/binary/index.js b/lib/binary/index.js index 1aaeae4..65c1d8f 100644 --- a/lib/binary/index.js +++ b/lib/binary/index.js @@ -48,6 +48,12 @@ const { promisify } = require('util'); const execFileAsync = promisify(cp.execFile); +// repo-intel artifacts grow with history: a mature repo's JSON can exceed 20 MB +// (agnix measured ~21 MB). Node's execFile default maxBuffer is 1 MB, which +// silently fails init/update/query on any real repo with "stdout maxBuffer length +// exceeded". Cap generously; callers can override via options.maxBuffer. +const ANALYZER_MAX_BUFFER = 256 * 1024 * 1024; + const { ANALYZER_MIN_VERSION, BINARY_NAME, GITHUB_REPO } = require('./version'); const PLATFORM_MAP = { @@ -957,7 +963,7 @@ function ensureBinarySync(options) { */ function runAnalyzer(args, options) { const binPath = ensureBinarySync(); - const opts = Object.assign({ encoding: 'utf8', windowsHide: true }, options); + const opts = Object.assign({ encoding: 'utf8', windowsHide: true, maxBuffer: ANALYZER_MAX_BUFFER }, options); if (!opts.stdio) opts.stdio = ['pipe', 'pipe', 'pipe']; const result = cp.execFileSync(binPath, args, opts); return typeof result === 'string' ? result : result.toString('utf8'); @@ -971,7 +977,7 @@ function runAnalyzer(args, options) { */ async function runAnalyzerAsync(args, options) { const binPath = await ensureBinary(); - const opts = Object.assign({ encoding: 'utf8', windowsHide: true }, options); + const opts = Object.assign({ encoding: 'utf8', windowsHide: true, maxBuffer: ANALYZER_MAX_BUFFER }, options); const result = await execFileAsync(binPath, args, opts); return result.stdout; } diff --git a/lib/binary/shared-helpers.js b/lib/binary/shared-helpers.js new file mode 100644 index 0000000..fea0f62 --- /dev/null +++ b/lib/binary/shared-helpers.js @@ -0,0 +1,160 @@ +'use strict'; + +/** + * Shared HTTP + archive helpers used by both binary resolvers + * (`lib/binary/index.js` for `agent-analyzer`, `lib/embed/binary.js` + * for `agent-analyzer-embed`). + * + * Extracted to keep the two resolvers from drifting on HTTP redirect + * handling, GitHub auth, and archive extraction details — a single + * fix to e.g. the timeout policy or the redirect cap lands once and + * applies to both binaries. + * + * @module lib/binary/shared-helpers + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const https = require('https'); +const cp = require('child_process'); + +const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30000; +const MAX_REDIRECTS = 5; + +/** + * Fetch a URL into an in-memory Buffer following up to 5 redirects. + * + * Honors `GITHUB_TOKEN` / `GH_TOKEN` for authenticated requests + * (raises rate limit, lets private-repo asset URLs work). Stalled + * connections are killed by the per-request timeout — without this + * a stuck socket would hang the process indefinitely. + * + * @param {string} url + * @param {Object} [options] + * @param {string} [options.userAgent='agent-sh/binary-resolver'] + * @param {number} [options.timeoutMs=30000] - per-request timeout + * @returns {Promise} + */ +function downloadToBuffer(url, options) { + const opts = options || {}; + const userAgent = opts.userAgent || 'agent-sh/binary-resolver'; + const timeoutMs = opts.timeoutMs || DEFAULT_DOWNLOAD_TIMEOUT_MS; + + return new Promise(function (resolve, reject) { + const ghToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + + function request(reqUrl, redirectCount) { + if (redirectCount > MAX_REDIRECTS) { + reject(new Error('Too many redirects fetching from ' + url)); + return; + } + const headers = { + 'User-Agent': userAgent, + 'Accept': 'application/octet-stream' + }; + if (ghToken) headers['Authorization'] = 'Bearer ' + ghToken; + + const req = https.get(reqUrl, { headers: headers, timeout: timeoutMs }, function (res) { + const sc = res.statusCode; + if (sc === 301 || sc === 302 || sc === 307 || sc === 308) { + res.resume(); + var loc = res.headers.location; + if (loc && !loc.startsWith('https://')) { + reject(new Error('Refusing non-HTTPS redirect to ' + loc)); + return; + } + request(loc, redirectCount + 1); + return; + } + if (sc !== 200) { + res.resume(); + const hint = sc === 403 ? ' (rate limited - set GITHUB_TOKEN env var)' : ''; + reject(new Error('HTTP ' + sc + hint + ' fetching ' + reqUrl)); + return; + } + const chunks = []; + res.on('data', function (chunk) { chunks.push(chunk); }); + res.on('end', function () { resolve(Buffer.concat(chunks)); }); + res.on('error', reject); + }); + req.on('error', reject); + req.on('timeout', function () { + req.destroy(); + reject(new Error('Timeout (' + timeoutMs + 'ms) fetching ' + reqUrl)); + }); + } + + request(url, 0); + }); +} + +/** + * Extract a `.tar.gz` Buffer into `destDir` using the system `tar`. + * Available on Linux, macOS, and Windows (built into recent Win10/11). + * + * @param {Buffer} buf + * @param {string} destDir + * @returns {Promise} + */ +function extractTarGz(buf, destDir) { + return new Promise(function (resolve, reject) { + const tarDest = process.platform === 'win32' ? destDir.replace(/\\/g, '/') : destDir; + const tar = cp.spawn('tar', ['xz', '-C', tarDest], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + let stderr = ''; + tar.stderr.on('data', function (d) { stderr += d; }); + tar.stdin.write(buf); + tar.stdin.end(); + tar.on('close', function (code) { + if (code !== 0) { + reject(new Error('tar extraction failed (code ' + code + '): ' + stderr)); + } else { + resolve(); + } + }); + tar.on('error', reject); + }); +} + +/** + * Extract a `.zip` Buffer into `destDir` using PowerShell's + * `Expand-Archive` (Windows-only). + * + * @param {Buffer} buf + * @param {string} destDir + * @param {string} binaryName - used as the temp-dir prefix + * @returns {Promise} + */ +function extractZip(buf, destDir, binaryName) { + return new Promise(function (resolve, reject) { + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), binaryName + '-')); + var tmpZip = path.join(tmpDir, 'archive.zip'); + fs.writeFileSync(tmpZip, buf); + var ps = cp.spawn( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', + 'Expand-Archive', '-Path', tmpZip, '-DestinationPath', destDir, '-Force'], + { stdio: ['ignore', 'pipe', 'pipe'] } + ); + var stderr = ''; + ps.stderr.on('data', function (d) { stderr += d; }); + ps.on('close', function (code) { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { /* ignore */ } + if (code !== 0) { + reject(new Error('zip extraction failed (code ' + code + '): ' + stderr)); + } else { + resolve(); + } + }); + ps.on('error', reject); + }); +} + +module.exports = { + downloadToBuffer, + extractTarGz, + extractZip, + DEFAULT_DOWNLOAD_TIMEOUT_MS +}; diff --git a/lib/repo-intel/cache.js b/lib/repo-intel/cache.js new file mode 100644 index 0000000..760c94c --- /dev/null +++ b/lib/repo-intel/cache.js @@ -0,0 +1,171 @@ +/** + * Repo map cache management + * + * @module lib/repo-intel/cache + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { getStateDirPath } = require('../platform/state-dir'); +const { writeJsonAtomic, writeFileAtomic } = require('../utils/atomic-write'); + +const MAP_FILENAME = 'repo-map.json'; +const STALE_FILENAME = 'repo-map.stale'; +const INTEL_FILENAME = 'repo-intel.json'; + +/** + * Get repo-map path (the converted view artifact). + * @param {string} basePath - Repository root + * @returns {string} + */ +function getMapPath(basePath) { + return path.join(getStateDirPath(basePath), MAP_FILENAME); +} + +/** + * Get the RAW repo-intel.json path (the binary's native artifact). + * The embed submodule (orchestrator.js) feeds this to the embed binary's + * --map-file. In the standalone layout cache.getPath returned repo-intel.json; + * the fold renamed the converted-view accessor to getMapPath, so getPath keeps + * its original raw-artifact meaning. Mirrors index.js getIntelMapPath. + * @param {string} basePath - Repository root + * @returns {string} + */ +function getPath(basePath) { + return path.join(getStateDirPath(basePath), INTEL_FILENAME); +} + +/** + * Get stale marker path + * @param {string} basePath - Repository root + * @returns {string} + */ +function getStalePath(basePath) { + return path.join(getStateDirPath(basePath), STALE_FILENAME); +} + +/** + * Ensure state directory exists + * @param {string} basePath - Repository root + * @returns {string} + */ +function ensureStateDir(basePath) { + const stateDir = getStateDirPath(basePath); + if (!fs.existsSync(stateDir)) { + fs.mkdirSync(stateDir, { recursive: true }); + } + return stateDir; +} + +/** + * Load repo-map from cache + * @param {string} basePath - Repository root + * @returns {Object|null} + */ +function load(basePath) { + const mapPath = getMapPath(basePath); + if (!fs.existsSync(mapPath)) return null; + + try { + const raw = fs.readFileSync(mapPath, 'utf8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +/** + * Save repo-map to cache + * @param {string} basePath - Repository root + * @param {Object} map - Map object + */ +function save(basePath, map) { + ensureStateDir(basePath); + const mapPath = getMapPath(basePath); + + const output = { + ...map, + updated: new Date().toISOString() + }; + + writeJsonAtomic(mapPath, output); + + // Clear stale marker if present + clearStale(basePath); +} + +/** + * Check if repo-map exists + * @param {string} basePath - Repository root + * @returns {boolean} + */ +function exists(basePath) { + return fs.existsSync(getMapPath(basePath)); +} + +/** + * Mark repo-map as stale + * @param {string} basePath - Repository root + */ +function markStale(basePath) { + ensureStateDir(basePath); + writeFileAtomic(getStalePath(basePath), new Date().toISOString()); +} + +/** + * Clear stale marker + * @param {string} basePath - Repository root + */ +function clearStale(basePath) { + const stalePath = getStalePath(basePath); + if (fs.existsSync(stalePath)) { + fs.unlinkSync(stalePath); + } +} + +/** + * Check if stale marker exists + * @param {string} basePath - Repository root + * @returns {boolean} + */ +function isMarkedStale(basePath) { + return fs.existsSync(getStalePath(basePath)); +} + +/** + * Get basic status summary + * @param {string} basePath - Repository root + * @returns {Object|null} + */ +function getStatus(basePath) { + const map = load(basePath); + if (!map) return null; + + return { + generated: map.generated, + updated: map.updated, + commit: map.git?.commit, + branch: map.git?.branch, + files: Object.keys(map.files || {}).length, + symbols: map.stats?.totalSymbols || 0, + languages: map.project?.languages || [] + }; +} + +module.exports = { + load, + save, + exists, + getStatus, + getMapPath, + // getPath -> raw repo-intel.json (embed orchestrator); getStateDirPath + // re-exported for embed/preference.js. Both delegate to platform/state-dir + // and match the names the standalone cache exposed. + getPath, + getStateDirPath, + markStale, + clearStale, + isMarkedStale +}; diff --git a/lib/repo-intel/converter.js b/lib/repo-intel/converter.js new file mode 100644 index 0000000..f468ebb --- /dev/null +++ b/lib/repo-intel/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-intel/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-intel/embed/binary.js b/lib/repo-intel/embed/binary.js new file mode 100644 index 0000000..3ac44cb --- /dev/null +++ b/lib/repo-intel/embed/binary.js @@ -0,0 +1,242 @@ +'use strict'; + +/** + * Binary resolver for `agent-analyzer-embed`. + * + * Mirrors the structure of `lib/binary/index.js` but for the separate + * embedder binary. Kept as its own module rather than parameterizing + * the existing resolver — the current single-binary helper is heavily + * specialized and a refactor would touch every call site for one + * use case. + * + * Both binaries share: + * - the same install dir (`~/.agent-sh/bin/`) + * - the same release-tag-aware download (latest tag with TTL cache) + * - the same platform map + * + * They differ in: + * - binary name (`agent-analyzer-embed` here) + * - GitHub release asset path uses the embed binary name + * + * @module lib/embed/binary + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const https = require('https'); +const cp = require('child_process'); + +// Reuse PLATFORM_MAP from the main resolver to avoid drift if a new +// platform is added. +const mainBinary = require('../../binary'); +// Reuse HTTP + archive helpers so a single bug fix to e.g. timeout +// behavior or redirect handling lands once and applies to both +// binaries (`agent-analyzer` and `agent-analyzer-embed`). +const sharedHelpers = require('../../binary/shared-helpers'); + +const EMBED_BINARY_NAME = 'agent-analyzer-embed'; +const EMBED_GITHUB_REPO = 'agent-sh/agent-analyzer'; +const LATEST_VERSION_TTL_MS = 60 * 60 * 1000; +const PLATFORM_MAP = mainBinary.PLATFORM_MAP; + +function getBinaryPath() { + const ext = process.platform === 'win32' ? '.exe' : ''; + return path.join(os.homedir(), '.agent-sh', 'bin', EMBED_BINARY_NAME + ext); +} + +// Platform-specific ONNX Runtime dylib bundled beside the embed binary in the +// release tarball (see release.yml). The Rust binary's runtime resolver +// (resolve_and_preflight_ort) looks for exactly this name next to the +// executable. Mirrored here so the JS side can tell whether an existing +// install predates the bundling and needs a re-download. +function getBundledOrtName() { + if (process.platform === 'win32') return 'onnxruntime.dll'; + if (process.platform === 'darwin') return 'libonnxruntime.dylib'; + return 'libonnxruntime.so'; +} + +function getBundledOrtPath() { + return path.join(path.dirname(getBinaryPath()), getBundledOrtName()); +} + +// musl has no bundled ORT (no MS musl build; a glibc dylib cannot dlopen under +// musl). On musl we never expect a sibling lib, so its absence must not trigger +// a perpetual re-download loop. +function platformBundlesOrt() { + const key = getPlatformKey(); + return !!key && !key.includes('musl'); +} + +function getPlatformKey() { + const key = process.platform + '-' + process.arch; + return PLATFORM_MAP[key] || null; +} + +function getVersion() { + const binPath = getBinaryPath(); + if (!fs.existsSync(binPath)) return null; + try { + const out = cp.execFileSync(binPath, ['--version'], { + timeout: 5000, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }); + const match = out.trim().match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : out.trim(); + } catch (e) { + return null; + } +} + +function isAvailable() { + return fs.existsSync(getBinaryPath()); +} + +let _latestVersionCache = null; + +async function getLatestReleaseVersion() { + if ( + _latestVersionCache && + Date.now() - _latestVersionCache.fetchedAt < LATEST_VERSION_TTL_MS + ) { + return _latestVersionCache.version; + } + return new Promise(function (resolve, reject) { + const ghToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const headers = { + 'User-Agent': 'agent-sh/embed-resolver', + 'Accept': 'application/vnd.github+json' + }; + if (ghToken) headers['Authorization'] = 'Bearer ' + ghToken; + + const url = 'https://api.github.com/repos/' + EMBED_GITHUB_REPO + '/releases/latest'; + const fail = function (msg) { + reject(new Error(msg + ' fetching ' + url)); + }; + const req = https.get(url, { headers: headers, timeout: 5000 }, function (res) { + if (res.statusCode !== 200) { + res.resume(); + fail('HTTP ' + res.statusCode); + return; + } + const chunks = []; + res.on('data', function (chunk) { chunks.push(chunk); }); + res.on('end', function () { + try { + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const tag = (body && body.tag_name) || ''; + const version = tag.replace(/^v/, ''); + if (/^\d+\.\d+\.\d+/.test(version)) { + _latestVersionCache = { version: version, fetchedAt: Date.now() }; + resolve(version); + } else { + fail('No valid release tag'); + } + } catch (e) { + fail('Failed to parse release JSON: ' + e.message); + } + }); + res.on('error', function (e) { fail(e.message); }); + }); + req.on('error', function (e) { fail(e.message); }); + req.on('timeout', function () { req.destroy(); fail('Timeout'); }); + }); +} + +function buildDownloadUrl(ver, platformKey) { + const ext = process.platform === 'win32' ? '.zip' : '.tar.gz'; + return ( + 'https://github.com/' + + EMBED_GITHUB_REPO + + '/releases/download/v' + + ver + + '/' + + EMBED_BINARY_NAME + + '-' + + platformKey + + ext + ); +} + +// Per-call shim so the embed resolver passes its own User-Agent +// header without duplicating the rest of the HTTP plumbing. +function downloadToBuffer(url) { + return sharedHelpers.downloadToBuffer(url, { userAgent: 'agent-sh/embed-resolver' }); +} + +const extractTarGz = sharedHelpers.extractTarGz; +const extractZip = sharedHelpers.extractZip; + +async function downloadBinary(ver) { + const platformKey = getPlatformKey(); + if (!platformKey) { + throw new Error( + 'Unsupported platform: ' + process.platform + '-' + process.arch + '. ' + + 'Supported: ' + Object.keys(PLATFORM_MAP).join(', ') + ); + } + const url = buildDownloadUrl(ver, platformKey); + process.stderr.write('Downloading ' + EMBED_BINARY_NAME + ' v' + ver + ' for ' + platformKey + '...\n'); + + const binPath = getBinaryPath(); + const binDir = path.dirname(binPath); + fs.mkdirSync(binDir, { recursive: true }); + + let buf; + try { + buf = await downloadToBuffer(url); + } catch (err) { + throw new Error( + 'Failed to download ' + EMBED_BINARY_NAME + ':\n' + + ' URL: ' + url + '\n' + + ' Error: ' + err.message + '\n\n' + + 'To install manually:\n' + + ' 1. Download: ' + url + '\n' + + ' 2. Extract the binary to: ' + binDir + '\n' + + ' 3. Ensure it is named: ' + path.basename(binPath) + ); + } + + if (process.platform === 'win32') { + await extractZip(buf, binDir, path.basename(binPath)); + } else { + await extractTarGz(buf, binDir); + } + if (process.platform !== 'win32') { + fs.chmodSync(binPath, 0o755); + } + return binPath; +} + +async function ensureBinary(options) { + const opts = options || {}; + const binPath = getBinaryPath(); + if (fs.existsSync(binPath)) { + // An install from before ORT bundling has the binary but no sibling + // dylib; without it the embedder fails at runtime. Re-download once to + // pick up the bundled lib. Skip on musl (never bundled) so this can't loop. + if (platformBundlesOrt() && !fs.existsSync(getBundledOrtPath())) { + const targetVer = opts.version || (await getLatestReleaseVersion()); + return downloadBinary(targetVer); + } + return binPath; + } + const targetVer = opts.version || (await getLatestReleaseVersion()); + return downloadBinary(targetVer); +} + +module.exports = { + EMBED_BINARY_NAME, + getBinaryPath, + getBundledOrtName, + getBundledOrtPath, + platformBundlesOrt, + getVersion, + getPlatformKey, + getLatestReleaseVersion, + isAvailable, + ensureBinary, + buildDownloadUrl +}; diff --git a/lib/repo-intel/embed/index.js b/lib/repo-intel/embed/index.js new file mode 100644 index 0000000..2995ee3 --- /dev/null +++ b/lib/repo-intel/embed/index.js @@ -0,0 +1,26 @@ +'use strict'; + +/** + * Public surface for the embed module — preference cache, binary + * resolver, and high-level orchestrator (scan / update / status). + * + * Skill code should import from here, not the internal modules, + * so the wiring stays swappable. + * + * @module lib/embed + */ + +const preference = require('./preference'); +const binary = require('./binary'); +const orchestrator = require('./orchestrator'); + +module.exports = { + preference, + binary, + orchestrator, + // Convenience re-exports for the common cases. + isEnabled: orchestrator.isEnabled, + runScan: orchestrator.runScan, + runUpdate: orchestrator.runUpdate, + status: orchestrator.status +}; diff --git a/lib/repo-intel/embed/orchestrator.js b/lib/repo-intel/embed/orchestrator.js new file mode 100644 index 0000000..83be8aa --- /dev/null +++ b/lib/repo-intel/embed/orchestrator.js @@ -0,0 +1,239 @@ +'use strict'; + +/** + * High-level embed orchestration. Glues together: + * + * user preference → binary download → scan/update → set-embeddings + * + * Called from the `/repo-intel enrich` command after the existing + * weighter and summarizer Haiku agents finish; degrades to a no-op + * when the user has chosen `embedder: "none"`. + * + * Also exposes `runUpdate()` for the standalone `/repo-intel embed + * update` action group (and the `npx ... embed update` CI hook). + * + * @module lib/embed/orchestrator + */ + +const fs = require('fs'); +const path = require('path'); +const cp = require('child_process'); + +const preference = require('./preference'); +const embedBinary = require('./binary'); +const mainBinary = require('../../binary'); +const cache = require('../cache'); + +/** + * Should the orchestrator run for this repo? Returns false when the + * user has not opted in (preference unset or 'none'), which lets + * callers safely no-op without wrapping every call site in a guard. + * + * @param {string} cwd + * @returns {boolean} + */ +function isEnabled(cwd) { + const pref = preference.read(cwd); + return pref.embedder === 'small' || pref.embedder === 'big'; +} + +/** + * Run a full scan: ensures the embed binary is downloaded, runs the + * scan subcommand, pipes the JSON document into `agent-analyzer + * repo-intel set-embeddings`. Returns a small status object so callers + * can report what happened. + * + * @param {string} cwd + * @returns {Promise<{ran: boolean, files?: number, durationMs?: number, reason?: string}>} + */ +async function runScan(cwd) { + if (!isEnabled(cwd)) { + return { ran: false, reason: 'embedder preference is "none" or unset' }; + } + const pref = preference.read(cwd); + const detail = preference.detailToCliArg(pref.embedderDetail || 'balanced'); + + const mapFile = cache.getPath(cwd); + if (!fs.existsSync(mapFile)) { + return { ran: false, reason: 'no repo-intel map found; run `/repo-intel init` first' }; + } + + const start = Date.now(); + const embedBin = await embedBinary.ensureBinary(); + const mainBin = await mainBinary.ensureBinary(); + + const result = await streamEmbedToSetEmbeddings( + embedBin, + ['scan', cwd, '--variant', pref.embedder, '--detail', detail], + mainBin, + mapFile + ); + return Object.assign({ ran: true, durationMs: Date.now() - start }, result); +} + +/** + * Run a delta update: only re-embeds files whose content hash differs + * from the existing sidecar. Falls back to a full scan when no + * sidecar exists yet. + * + * @param {string} cwd + * @returns {Promise<{ran: boolean, files?: number, durationMs?: number, reason?: string}>} + */ +async function runUpdate(cwd) { + if (!isEnabled(cwd)) { + return { ran: false, reason: 'embedder preference is "none" or unset' }; + } + const pref = preference.read(cwd); + const detail = preference.detailToCliArg(pref.embedderDetail || 'balanced'); + + const mapFile = cache.getPath(cwd); + if (!fs.existsSync(mapFile)) { + return { ran: false, reason: 'no repo-intel map; run `/repo-intel init` then `enrich`' }; + } + + const start = Date.now(); + const embedBin = await embedBinary.ensureBinary(); + const mainBin = await mainBinary.ensureBinary(); + + const result = await streamEmbedToSetEmbeddings( + embedBin, + ['update', cwd, '--map-file', mapFile, '--variant', pref.embedder, '--detail', detail], + mainBin, + mapFile + ); + return Object.assign({ ran: true, durationMs: Date.now() - start }, result); +} + +/** + * Status snapshot: which preference is set, whether the binary is + * installed, whether the bundled ONNX Runtime is present, whether the + * sidecar exists. + * + * `ortBundled` surfaces the prerequisite the embed binary needs at runtime: + * the binary loads libonnxruntime from beside itself (shipped in the release + * tarball). On a platform that bundles ORT, a present binary with a missing + * lib means an upgrade re-download is pending - ensureBinary handles it, but + * status reports it so callers can explain a one-time re-fetch. + * + * @param {string} cwd + * @returns {{enabled: boolean, embedder?: string, embedderDetail?: string, binaryInstalled: boolean, ortBundled: boolean, sidecarExists: boolean, sidecarPath?: string}} + */ +function status(cwd) { + const pref = preference.read(cwd); + const mapFile = cache.getPath(cwd); + const sidecarPath = deriveSidecarPath(mapFile); + return { + enabled: isEnabled(cwd), + embedder: pref.embedder, + embedderDetail: pref.embedderDetail, + binaryInstalled: embedBinary.isAvailable(), + ortBundled: !embedBinary.platformBundlesOrt() || fs.existsSync(embedBinary.getBundledOrtPath()), + sidecarExists: fs.existsSync(sidecarPath), + sidecarPath: sidecarPath + }; +} + +/** + * Stream the embed binary's stdout directly into the main binary's + * `set-embeddings --input -` stdin. The intermediate JSON document + * can run into the megabytes for big repos at high detail; piping + * keeps memory flat instead of buffering the whole document. + * + * The promise resolves with `{ files }` when both children exit cleanly; + * rejects with the failing child's exit code + captured stderr otherwise. + * + * Hardened against the failure modes a bare `pipe()` leaves open: a stream + * error (e.g. set-embeddings dies mid-write) used to leave the promise + * unsettled forever and leak the surviving process. Now any failure path + * — spawn error, stream error, or non-zero exit — kills the sibling and + * rejects once. Embed stderr is captured (not inherited) so the error + * message carries the diagnostic instead of just an exit code. + */ +function streamEmbedToSetEmbeddings(embedBinPath, embedArgs, mainBinPath, mapFile) { + return new Promise(function (resolve, reject) { + const embedChild = cp.spawn(embedBinPath, embedArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }); + const setChild = cp.spawn( + mainBinPath, + ['repo-intel', 'set-embeddings', '--map-file', mapFile, '--input', '-'], + { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } + ); + + let embedExit = null; + let setExit = null; + let settled = false; + let setStdout = ''; + let embedStderr = ''; + let setStderr = ''; + + // Single exit path. Kills the sibling on any failure so neither process + // is left running against a closed pipe (FD leak / zombie). + function done(err, value) { + if (settled) return; + settled = true; + if (err) { + try { embedChild.kill('SIGTERM'); } catch (e) { /* already gone */ } + try { setChild.kill('SIGTERM'); } catch (e) { /* already gone */ } + reject(err); + } else { + resolve(value); + } + } + + function maybeFinish() { + if (settled || embedExit === null || setExit === null) return; + if (embedExit !== 0) { + return done(new Error( + embedBinary.EMBED_BINARY_NAME + ' exited ' + embedExit + + (embedStderr.trim() ? ': ' + embedStderr.trim().slice(0, 500) : '') + )); + } + if (setExit !== 0) { + return done(new Error( + 'agent-analyzer set-embeddings exited ' + setExit + + (setStderr.trim() ? ': ' + setStderr.trim().slice(0, 500) : '') + )); + } + const m = setStdout.match(/(\d+)\s+files?/); + done(null, { files: m ? parseInt(m[1], 10) : undefined }); + } + + // stderr piped (not inherited) so failures carry a message. + embedChild.stderr.on('data', function (d) { embedStderr += d.toString('utf8'); }); + setChild.stderr.on('data', function (d) { setStderr += d.toString('utf8'); }); + setChild.stdout.on('data', function (d) { setStdout += d.toString('utf8'); }); + + // Stream wiring with error handling on BOTH ends of the pipe — a bare + // .pipe() swallows these and hangs. + embedChild.stdout.on('error', function (e) { done(e); }); + setChild.stdin.on('error', function (e) { + // EPIPE when set-embeddings has already exited is benign — its close + // handler reports the real cause. Only surface other stdin errors. + if (e && e.code !== 'EPIPE') done(e); + }); + embedChild.stdout.pipe(setChild.stdin); + + embedChild.on('error', function (e) { done(e); }); + setChild.on('error', function (e) { done(e); }); + embedChild.on('close', function (code) { embedExit = code; maybeFinish(); }); + setChild.on('close', function (code) { setExit = code; maybeFinish(); }); + }); +} + +function deriveSidecarPath(mapFile) { + if (!mapFile) return ''; + const dir = path.dirname(mapFile); + const stem = path.basename(mapFile, path.extname(mapFile)); + return path.join(dir, stem + '.embeddings.bin'); +} + +module.exports = { + isEnabled, + runScan, + runUpdate, + status, + // exported for testing the dual-process pipe in isolation + streamEmbedToSetEmbeddings +}; diff --git a/lib/repo-intel/embed/preference.js b/lib/repo-intel/embed/preference.js new file mode 100644 index 0000000..b8208da --- /dev/null +++ b/lib/repo-intel/embed/preference.js @@ -0,0 +1,136 @@ +'use strict'; + +/** + * User preference for the embedder. Persists the answer to two + * one-time prompts so subsequent enrich runs don't re-ask: + * + * embedder : "none" | "small" | "big" + * embedderDetail : "compact" | "balanced" | "maximum" + * + * Stored in `/sources/preference.json` alongside the existing + * task source preference (so the user has a single place to clear all + * cached choices via `/repo-intel embed reset`). + * + * @module lib/embed/preference + */ + +const fs = require('fs'); +const path = require('path'); +const cache = require('../cache'); + +const VALID_EMBEDDER = ['none', 'small', 'big']; +const VALID_DETAIL = ['compact', 'balanced', 'maximum']; + +// State-directory resolution delegates to `cache.getStateDirPath` so +// the embedder reads/writes the same directory the artifact loader +// uses. Reviewer-flagged split-brain risk: an inline copy here would +// drift on candidate order, env-var support, or other policy and +// silently land preferences in a different place than the map file. +function preferencePath(cwd) { + return path.join(cache.getStateDirPath(cwd), 'sources', 'preference.json'); +} + +/** + * Load preference from disk. Returns an empty object when absent or + * unreadable so callers can treat "unset" uniformly. + * + * @param {string} cwd + * @returns {{embedder?: string, embedderDetail?: string}} + */ +function read(cwd) { + const p = preferencePath(cwd); + if (!fs.existsSync(p)) return {}; + try { + const raw = JSON.parse(fs.readFileSync(p, 'utf8')); + return raw && typeof raw === 'object' ? raw : {}; + } catch (e) { + return {}; + } +} + +/** + * Merge updates into the on-disk preference file. Creates the + * containing directory if needed. + * + * @param {string} cwd + * @param {Object} patch fields to set/overwrite + * @returns {Object} the merged preference + */ +function update(cwd, patch) { + const current = read(cwd); + const next = Object.assign({}, current, patch || {}); + const p = preferencePath(cwd); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(next, null, 2)); + return next; +} + +/** + * Strip embedder fields from preference. Used by `embed reset` to + * trigger fresh prompts on the next enrich. + * + * @param {string} cwd + */ +function reset(cwd) { + const current = read(cwd); + delete current.embedder; + delete current.embedderDetail; + const p = preferencePath(cwd); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(current, null, 2)); +} + +/** + * Did the user already answer the embedder install prompt? + * + * @param {string} cwd + * @returns {boolean} + */ +function hasEmbedderChoice(cwd) { + const pref = read(cwd); + return VALID_EMBEDDER.includes(pref.embedder); +} + +/** + * Did the user already answer the detail prompt? Only meaningful when + * embedder !== 'none'. + * + * @param {string} cwd + * @returns {boolean} + */ +function hasDetailChoice(cwd) { + const pref = read(cwd); + return VALID_DETAIL.includes(pref.embedderDetail); +} + +/** + * Translate the user-facing detail label into the analyzer-embed CLI + * value. Centralizes the mapping so the CLI flag never appears as a + * string literal scattered across modules. + * + * @param {string} detail + * @returns {string} + */ +function detailToCliArg(detail) { + switch (detail) { + case 'compact': + return 'compact'; + case 'maximum': + return 'maximum'; + case 'balanced': + default: + return 'balanced'; + } +} + +module.exports = { + read, + update, + reset, + hasEmbedderChoice, + hasDetailChoice, + detailToCliArg, + preferencePath, + VALID_EMBEDDER, + VALID_DETAIL +}; diff --git a/lib/repo-intel/enrich.js b/lib/repo-intel/enrich.js new file mode 100644 index 0000000..6451ce3 --- /dev/null +++ b/lib/repo-intel/enrich.js @@ -0,0 +1,198 @@ +/** + * Post-init enrichment helpers. + * + * The repo-intel skill spawns two Haiku-backed Task subagents after + * the deterministic init/update pass: + * + * 1. `repo-intel-summarizer` reads README + manifests + hotspot + * headers and writes a 3-depth narrative summary. + * 2. `repo-intel-weighter` writes 1-2 sentence descriptors for the + * top-N most-active files, used by `find` to add semantic recall. + * + * The orchestration calls into this module to gather the agents' + * inputs, parse their JSON outputs (which arrive between marker + * blocks because subagent stdout is otherwise free-form), and pipe + * the result back through `repoIntel.applyDescriptors` / + * `applySummary`. The Rust binary stores the data; this module + * never touches the LLM directly. + * + * @module lib/repo-intel/enrich + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +/** + * Read the README, returning empty string when absent so the + * downstream JSON.stringify doesn't break. + */ +function readReadme(repoPath) { + for (const name of ['README.md', 'README.MD', 'readme.md', 'README.rst', 'README.txt', 'README']) { + const candidate = path.join(repoPath, name); + if (fs.existsSync(candidate)) { + try { return fs.readFileSync(candidate, 'utf8'); } catch { /* fall through */ } + } + } + return ''; +} + +/** + * Read whichever manifests are present and return them as a parsed + * object keyed by manifest filename. Used by the summarizer to + * understand what kind of project it's looking at. + */ +function readManifests(repoPath) { + const manifests = {}; + for (const name of ['package.json', 'Cargo.toml', 'pyproject.toml', 'go.mod', 'pom.xml', 'build.gradle']) { + const p = path.join(repoPath, name); + if (fs.existsSync(p)) { + try { + const text = fs.readFileSync(p, 'utf8'); + // Don't attempt to parse non-JSON manifests - the summarizer + // can read them as plain strings. + if (name.endsWith('.json')) { + try { manifests[name] = JSON.parse(text); } + catch { manifests[name] = text.slice(0, 4000); } + } else { + manifests[name] = text.slice(0, 4000); + } + } catch { /* skip on read error */ } + } + } + return manifests; +} + +/** + * Pick the top-N files by activity (changes + 2*recent_changes), and + * return `{path, head}` for each where `head` is the first 500 chars + * of file content. Used as `hotspots` input to the summarizer. + * + * For the weighter we just want the path list - call `topPaths()`. + */ +function topHotspots(repoPath, repoIntelData, n = 10) { + const paths = topPaths(repoIntelData, n); + return paths.map((p) => { + const abs = path.join(repoPath, p); + let head = ''; + try { + const buf = fs.readFileSync(abs); + head = buf.subarray(0, Math.min(buf.length, 500)).toString('utf8'); + } catch { /* file missing on disk, skip */ } + return { path: p, head }; + }); +} + +/** + * Rank file_activity entries by activity score and return the top N + * paths. Recent changes are weighted 2x because the agent should + * prioritize files that are still being actively touched. + */ +function topPaths(repoIntelData, n) { + const fa = repoIntelData.fileActivity || {}; + const scored = Object.entries(fa) + .map(([p, a]) => ({ + path: p, + score: (a.changes || 0) + 2 * (a.recentChanges || 0) + })) + .filter((e) => e.score > 0); + scored.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)); + return scored.slice(0, n).map((e) => e.path); +} + +/** + * Stable hash of the inputs that fed into a summary, so the skill + * can decide whether to regenerate. Mirrors what the Rust set-summary + * subcommand stores under summary.inputHash. + */ +function summaryInputHash(readme, manifests, hotspots) { + const h = crypto.createHash('sha256'); + h.update(readme); + h.update(JSON.stringify(manifests)); + h.update(JSON.stringify(hotspots)); + return 'sha256:' + h.digest('hex').slice(0, 16); +} + +/** + * Extract the JSON object between `=== _START ===` and + * `=== _END ===` markers in the agent's output. + * + * Returns the parsed object, or null if either marker is missing or + * the inner text doesn't parse as JSON. Tolerates extra whitespace + * and surrounding agent commentary. + */ +function parseMarkers(agentOutput, name) { + if (typeof agentOutput !== 'string') return null; + const startMarker = `=== ${name}_START ===`; + const endMarker = `=== ${name}_END ===`; + const startIdx = agentOutput.indexOf(startMarker); + const endIdx = agentOutput.indexOf(endMarker); + if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) return null; + const inner = agentOutput.slice(startIdx + startMarker.length, endIdx).trim(); + // Inner is usually fenced inside ```json ... ``` or just raw JSON. + // Strip code fences if present, then parse. + const stripped = inner + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```\s*$/i, '') + .trim(); + try { return JSON.parse(stripped); } + catch { return null; } +} + +/** + * Build the summarizer prompt - the literal string sent as the Task + * `prompt` argument. Kept here so the command markdown stays compact. + */ +function buildSummarizerPrompt(repoPath, readme, manifests, hotspots) { + return [ + `Generate a 3-depth summary for the repo at ${repoPath}.`, + '', + 'Inputs:', + '```json', + JSON.stringify({ repoPath, readme, manifests, hotspots }, null, 2), + '```', + '', + 'Return JSON between `=== SUMMARY_START ===` and `=== SUMMARY_END ===` markers as instructed in your system prompt.' + ].join('\n'); +} + +/** + * Build the weighter prompt for one batch of paths. + */ +function buildWeighterPrompt(repoPath, paths) { + return [ + `Generate descriptors for the following files in ${repoPath}.`, + '', + 'Inputs:', + '```json', + JSON.stringify({ repoPath, paths }, null, 2), + '```', + '', + 'Return JSON between `=== DESCRIPTORS_START ===` and `=== DESCRIPTORS_END ===` markers as instructed in your system prompt.' + ].join('\n'); +} + +/** + * Split a list into chunks of `size`. Used to keep weighter Task + * calls bounded - one big Task with 500 paths would burn context; + * 30/batch keeps each call cheap and lets the orchestrator parallelize. + */ +function chunk(arr, size) { + const out = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +module.exports = { + readReadme, + readManifests, + topHotspots, + topPaths, + summaryInputHash, + parseMarkers, + buildSummarizerPrompt, + buildWeighterPrompt, + chunk +}; diff --git a/lib/repo-intel/index.js b/lib/repo-intel/index.js new file mode 100644 index 0000000..c3a862f --- /dev/null +++ b/lib/repo-intel/index.js @@ -0,0 +1,370 @@ +'use strict'; + +/** + * Repo Intel - unified repository intelligence over agent-analyzer. + * + * One surface for the whole pipeline: + * - lifecycle: init / update / status / load / exists (was lib/repo-map) + * - queries: typed wrappers over the binary's query subcommands (queries.js) + * - install: binary availability checks + * + * The agent-analyzer binary is the engine and is auto-downloaded on first use. + * init/update run the binary, persist repo-intel.json (raw), convert to the + * repo-map.json view, and cache it. queries.* read the raw repo-intel.json. + * + * @module lib/repo-intel + */ + +const fs = require('fs'); +const path = require('path'); +const cp = require('child_process'); +const { execFileSync } = cp; + +const installer = require('./installer'); +const cache = require('./cache'); +const updater = require('./updater'); +const converter = require('./converter'); +const queries = require('./queries'); +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 + * @returns {Promise<{success: boolean, map?: Object, error?: string}>} + */ +async function init(basePath, options = {}) { + const installed = await installer.checkInstalled(); + if (!installed.found) { + return { + success: false, + error: 'agent-analyzer binary unavailable: ' + (installed.error || 'unknown error'), + installSuggestion: installer.getInstallInstructions() + }; + } + + const existing = cache.load(basePath); + if (existing && !options.force) { + return { + success: false, + error: 'Repo map already exists. Use --force to rebuild or update to refresh.', + existing: cache.getStatus(basePath) + }; + } + + const startTime = Date.now(); + + let intelJson; + try { + intelJson = await binary.runAnalyzerAsync(['repo-intel', 'init', basePath]); + } catch (e) { + return { success: false, error: 'agent-analyzer repo-intel init failed: ' + e.message }; + } + + let intel; + try { + intel = JSON.parse(intelJson); + } catch (e) { + return { success: false, error: 'Failed to parse repo-intel output: ' + e.message }; + } + + // 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 + } + + 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, + languages: map.project.languages, + duration: map.stats.scanDurationMs + } + }; +} + +/** + * 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, summary?: Object, error?: string}>} + */ +async function update(basePath, options = {}) { + const installed = await installer.checkInstalled(); + if (!installed.found) { + return { + success: false, + error: 'agent-analyzer binary unavailable: ' + (installed.error || 'unknown error'), + installSuggestion: installer.getInstallInstructions() + }; + } + + if (!cache.exists(basePath)) { + return { success: false, error: 'No repo map found. Run init first.' }; + } + + 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: 'agent-analyzer repo-intel update failed: ' + e.message }; + } + + let intel; + try { + intel = JSON.parse(intelJson); + } catch (e) { + return { success: false, error: 'Failed to parse repo-intel update output: ' + e.message }; + } + + try { + writeJsonAtomic(intelPath, intel); + } catch { + // Non-fatal + } + + 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 + } + }; +} + +/** + * Get repo map status. + * @param {string} basePath - Repository root path + * @returns {{exists: boolean, status?: Object}} + */ +function status(basePath) { + const map = cache.load(basePath); + if (!map) { + return { exists: false }; + } + + 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, + files: Object.keys(map.files).length, + symbols: map.stats?.totalSymbols || 0, + languages: map.project?.languages || [], + staleness + } + }; +} + +/** + * Load repo map (if exists). + * @param {string} basePath - Repository root path + * @returns {Object|null} + */ +function load(basePath) { + return cache.load(basePath); +} + +/** + * Check if repo map exists. + * @param {string} basePath - Repository root path + * @returns {boolean} + */ +function exists(basePath) { + return cache.exists(basePath); +} + +/** + * Load the RAW repo-intel.json (the binary's native output: fileActivity, + * coupling, symbols, importGraph, ...) — distinct from load(), which returns + * the converted repo-map.json view. enrich + any consumer needing raw git/AST + * structure uses this; queries.* also read raw under the hood. + * @param {string} basePath - Repository root path + * @returns {Object|null} parsed raw artifact, or null if absent/unreadable + */ +function loadRaw(basePath) { + const p = getIntelMapPath(basePath); + if (!fs.existsSync(p)) return null; + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } +} + +/** + * Spawn the analyzer with a JSON payload on stdin and capture stdout/stderr. + * + * Used by the post-init agent orchestration: the Haiku weighter and + * summarizer write JSON to stdout, the orchestrating skill captures it, + * then pipes it into the analyzer via this helper. Replaces what would + * otherwise be a tempfile dance. + * + * @param {string[]} args - subcommand args (must end with `--input -`) + * @param {string} stdinJson - the JSON payload to feed to stdin + * @returns {Promise<{stdout: string, stderr: string}>} + */ +async function runAnalyzerWithStdin(args, stdinJson) { + const binPath = await binary.ensureBinary(); + return new Promise((resolve, reject) => { + const proc = cp.spawn(binPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); }); + proc.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); }); + proc.on('error', reject); + proc.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error( + `agent-analyzer ${args.join(' ')} exited ${code}: ${stderr.trim() || stdout.trim()}` + )); + } + }); + proc.stdin.write(stdinJson); + proc.stdin.end(); + }); +} + +/** + * Merge per-file descriptors (from the `repo-intel-weighter` agent) into the + * cached artifact via the binary's set-descriptors subcommand. Partial updates + * are safe — entries the agent didn't refresh this run are preserved. + * + * @param {string} basePath - Repository root path + * @param {Object} descriptors - {path: descriptor, ...} + * @returns {Promise} + */ +async function applyDescriptors(basePath, descriptors) { + if (!descriptors || typeof descriptors !== 'object') { + throw new Error('applyDescriptors requires an object {path: descriptor}'); + } + const mapFile = getIntelMapPath(basePath); + if (!fs.existsSync(mapFile)) { + throw new Error('No repo-intel artifact for ' + basePath + '; run init first.'); + } + await runAnalyzerWithStdin( + ['repo-intel', 'set-descriptors', '--map-file', mapFile, '--input', '-'], + JSON.stringify(descriptors) + ); +} + +/** + * Set the 3-depth narrative summary (from the `repo-intel-summarizer` agent) + * via the binary's set-summary subcommand. Fully replaces any previous summary. + * + * @param {string} basePath - Repository root path + * @param {{depth1: string, depth3: string, depth10: string, inputHash: string}} summary + * @returns {Promise} + */ +async function applySummary(basePath, summary) { + if (!summary || !summary.depth1 || !summary.depth3 || !summary.depth10) { + throw new Error('applySummary requires {depth1, depth3, depth10, inputHash}'); + } + const mapFile = getIntelMapPath(basePath); + if (!fs.existsSync(mapFile)) { + throw new Error('No repo-intel artifact for ' + basePath + '; run init first.'); + } + await runAnalyzerWithStdin( + ['repo-intel', 'set-summary', '--map-file', mapFile, '--input', '-'], + JSON.stringify(summary) + ); +} + +/** + * Check if agent-analyzer is available. + * @returns {Promise<{found: boolean, version?: string, tool: string}>} + */ +async function checkAstGrepInstalled() { + return installer.checkInstalled(); +} + +/** + * Get install instructions. + * @returns {string} + */ +function getInstallInstructions() { + return installer.getInstallInstructions(); +} + +module.exports = { + // Lifecycle (was lib/repo-map) + init, + update, + status, + load, + loadRaw, + exists, + applyDescriptors, + applySummary, + checkAstGrepInstalled, + getInstallInstructions, + + // Typed query wrappers (read the cached repo-intel.json) + queries, + + // Submodules for advanced use + installer, + cache, + updater, + converter +}; + +// Embedder (opt-in, separate agent-analyzer-embed binary). Lazy getter so +// `require('repo-intel')` for query/lifecycle use never loads the embed binary +// resolver or its download path — it only materializes when embed is accessed. +Object.defineProperty(module.exports, 'embed', { + enumerable: true, + get() { return require('./embed'); } +}); diff --git a/lib/repo-intel/installer.js b/lib/repo-intel/installer.js new file mode 100644 index 0000000..40964a6 --- /dev/null +++ b/lib/repo-intel/installer.js @@ -0,0 +1,78 @@ +'use strict'; + +/** + * 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-intel/installer + */ + +const binary = require('../binary'); + +/** + * Check if agent-analyzer is available (async). Downloads if missing. + * @returns {Promise<{found: boolean, version?: string, tool: string}>} + */ +async function checkInstalled() { + 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' }; + } +} + +/** + * Check if agent-analyzer is available (sync). Downloads if missing. + * @returns {{found: boolean, version?: string, tool: string}} + */ +function checkInstalledSync() { + 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' }; + } +} + +/** + * Version check is handled by the binary module - always true when found. + * @returns {boolean} + */ +function meetsMinimumVersion() { + return true; +} + +/** + * Get install instructions (binary is auto-downloaded, but here for compat). + * @returns {string} + */ +function getInstallInstructions() { + return 'agent-analyzer is downloaded automatically on first use from https://github.com/agent-sh/agent-analyzer/releases'; +} + +/** + * Get minimum version string. + * @returns {string} + */ +function getMinimumVersion() { + return '0.3.0'; +} + +module.exports = { + checkInstalled, + checkInstalledSync, + meetsMinimumVersion, + getInstallInstructions, + getMinimumVersion, + // Stub: runner.js references this but is no longer the scan path + getCommand: () => null +}; diff --git a/lib/repo-intel/queries.js b/lib/repo-intel/queries.js index 3d2c832..a980ee4 100644 --- a/lib/repo-intel/queries.js +++ b/lib/repo-intel/queries.js @@ -99,6 +99,19 @@ function runQuery(queryName, extraArgs, cwd) { return parsed; } +/** + * Assert a query argument is a non-empty string. Centralizes the validation + * that file/symbol/concept-taking queries share, so a wrong-typed arg fails + * with a clear message instead of being stringified into a bogus CLI argument. + * @param {*} val - the value to check + * @param {string} label - ": " for the error message + */ +function assertString(val, label) { + if (typeof val !== 'string' || val.length === 0) { + throw new TypeError(`${label} must be a non-empty string`); + } +} + // --------------------------------------------------------------------------- // Query functions // --------------------------------------------------------------------------- @@ -123,6 +136,7 @@ function hotspots(cwd, opts = {}) { * @returns {Array} */ function coupling(cwd, file, opts = {}) { + assertString(file, 'coupling: file'); const extra = [file]; if (opts.limit != null) extra.push('--top', String(opts.limit)); return runQuery('coupling', extra, cwd); @@ -154,17 +168,6 @@ function testGaps(cwd, opts = {}) { return runQuery('test-gaps', extra, cwd); } -/** - * AI-authored ratio per file or project. - * @param {string} cwd - * @param {{ pathFilter?: string }} [opts] - * @returns {Object} - */ -function aiRatio(cwd, opts = {}) { - const extra = []; - if (opts.pathFilter != null) extra.push('--path-filter', opts.pathFilter); - return runQuery('ai-ratio', extra, cwd); -} /** * Risk score for a diff (set of changed files). @@ -197,8 +200,12 @@ function diffRisk(cwd, files) { * @returns {Object} */ function dependents(cwd, symbol, file) { + assertString(symbol, 'dependents: symbol'); const extra = [symbol]; - if (file != null) extra.push('--file', file); + if (file != null) { + assertString(file, 'dependents: file'); + extra.push('--file', file); + } return runQuery('dependents', extra, cwd); } @@ -255,6 +262,7 @@ function boundaries(cwd, opts = {}) { * @returns {Object} */ function areaOf(cwd, file) { + assertString(file, 'areaOf: file'); return runQuery('area-of', [file], cwd); } @@ -273,6 +281,171 @@ function communityHealth(cwd, id) { return runQuery('community-health', [String(id)], cwd); } +// --------------------------------------------------------------------------- +// Activity / git queries +// --------------------------------------------------------------------------- + +/** Least-changed files (no recent activity). @param {string} cwd @param {{limit?:number}} [opts] */ +function coldspots(cwd, opts = {}) { + const extra = []; + if (opts.limit != null) extra.push('--top', String(opts.limit)); + return runQuery('coldspots', extra, cwd); +} + +/** Ownership for a file or directory. @param {string} cwd @param {string} file */ +function ownership(cwd, file) { + assertString(file, 'ownership: file'); + return runQuery('ownership', [file], cwd); +} + +/** Project norms detected from git history. @param {string} cwd */ +function norms(cwd) { + return runQuery('norms', [], cwd); +} + +/** Area-level health overview. @param {string} cwd */ +function areas(cwd) { + return runQuery('areas', [], cwd); +} + +/** Contributors sorted by commit count. @param {string} cwd @param {{limit?:number}} [opts] */ +function contributors(cwd, opts = {}) { + const extra = []; + if (opts.limit != null) extra.push('--top', String(opts.limit)); + return runQuery('contributors', extra, cwd); +} + +/** Release cadence and tag info. @param {string} cwd */ +function releaseInfo(cwd) { + return runQuery('release-info', [], cwd); +} + +/** History for a specific file. @param {string} cwd @param {string} file */ +function fileHistory(cwd, file) { + assertString(file, 'fileHistory: file'); + return runQuery('file-history', [file], cwd); +} + +/** Commit message conventions. @param {string} cwd */ +function conventions(cwd) { + return runQuery('conventions', [], cwd); +} + +/** Doc files with low code coupling (likely stale). @param {string} cwd @param {{limit?:number}} [opts] */ +function docDrift(cwd, opts = {}) { + const extra = []; + if (opts.limit != null) extra.push('--top', String(opts.limit)); + return runQuery('doc-drift', extra, cwd); +} + +// --------------------------------------------------------------------------- +// Narrative / guidance queries +// --------------------------------------------------------------------------- + +/** Newcomer-oriented repo summary. @param {string} cwd */ +function onboard(cwd) { + return runQuery('onboard', [], cwd); +} + +/** Contributor guidance matching skills to areas needing work. @param {string} cwd */ +function canIHelp(cwd) { + return runQuery('can-i-help', [], cwd); +} + +/** Files ranked by pain score (hotspot x complexity x bug density). @param {string} cwd @param {{limit?:number}} [opts] */ +function painspots(cwd, opts = {}) { + const extra = []; + if (opts.limit != null) extra.push('--top', String(opts.limit)); + return runQuery('painspots', extra, cwd); +} + +/** Every place execution can start (binaries, main fns, npm scripts). @param {string} cwd @param {{files?:string[]|string}} [opts] */ +function entryPoints(cwd, opts = {}) { + const extra = []; + if (opts.files) { + const list = Array.isArray(opts.files) ? opts.files.join(',') : String(opts.files); + extra.push('--files', list); + } + return runQuery('entry-points', extra, cwd); +} + +/** Project metadata: languages, CI, license, README. @param {string} cwd */ +function projectInfo(cwd) { + return runQuery('project-info', [], cwd); +} + +// --------------------------------------------------------------------------- +// AST / symbol queries +// --------------------------------------------------------------------------- + +/** AST symbols (exports/imports/definitions) for a file. @param {string} cwd @param {string} file */ +function symbols(cwd, file) { + assertString(file, 'symbols: file'); + return runQuery('symbols', [file], cwd); +} + +/** Doc files with stale references to source symbols. @param {string} cwd @param {{limit?:number}} [opts] */ +function staleDocs(cwd, opts = {}) { + const extra = []; + if (opts.limit != null) extra.push('--top', String(opts.limit)); + return runQuery('stale-docs', extra, cwd); +} + +/** Concept-to-file search (ranked, replaces grep -r). @param {string} cwd @param {string} query @param {{limit?:number}} [opts] */ +function find(cwd, query, opts = {}) { + assertString(query, 'find: query'); + const extra = [query]; + if (opts.limit != null) extra.push('--top', String(opts.limit)); + return runQuery('find', extra, cwd); +} + +// --------------------------------------------------------------------------- +// Deslop-agent queries +// --------------------------------------------------------------------------- + +/** Structured slop fix actions for the deslop agent. @param {string} cwd */ +function slopFixes(cwd) { + return runQuery('slop-fixes', [], cwd); +} + +/** Ranked slop targets (Sonnet/Opus tiers). @param {string} cwd @param {{top?:number}} [opts] */ +function slopTargets(cwd, opts = {}) { + const extra = []; + if (opts.top != null) extra.push('--top', String(opts.top)); + return runQuery('slop-targets', extra, cwd); +} + +/** + * Cached 3-depth narrative summary. Returns null when not yet generated; + * with opts.depth returns that single depth as plain text. Bypasses the + * JSON-parse path because the binary emits the literal 'null' or raw text. + * @param {string} cwd @param {{depth?:1|3|10}} [opts] + */ +function summary(cwd, opts = {}) { + const mapFile = requireMapFile(cwd); + const extra = []; + if (opts.depth != null) extra.push('--depth', String(opts.depth)); + const args = ['repo-intel', 'query', 'summary', ...extra, '--map-file', mapFile, cwd]; + // summary bypasses runQuery (a single --depth returns plain text, not JSON), + // so it must wrap the analyzer call + parse itself to match runQuery's error + // contract instead of leaking a raw spawn / SyntaxError. + let raw; + try { + raw = binary.runAnalyzer(args).trim(); + } catch (err) { + throw new Error(`repo-intel query failed [summary]: ${err.message}`, { cause: err }); + } + if (raw === 'null') return null; + if (opts.depth != null) return raw; // plain-text single depth + try { + return JSON.parse(raw); + } catch (_parseErr) { + throw new Error( + `repo-intel query [summary] returned non-JSON output: ${raw.slice(0, 200)}` + ); + } +} + // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- @@ -286,7 +459,6 @@ module.exports = { coupling, busFactor, testGaps, - aiRatio, diffRisk, dependents, bugspots, @@ -297,4 +469,32 @@ module.exports = { boundaries, areaOf, communityHealth, + + // Activity / git queries + coldspots, + ownership, + norms, + areas, + contributors, + releaseInfo, + fileHistory, + conventions, + docDrift, + + // Narrative / guidance queries + onboard, + canIHelp, + painspots, + entryPoints, + projectInfo, + + // AST / symbol queries + symbols, + staleDocs, + find, + + // Deslop-agent queries + slopFixes, + slopTargets, + summary, }; diff --git a/lib/repo-intel/updater.js b/lib/repo-intel/updater.js new file mode 100644 index 0000000..41061b7 --- /dev/null +++ b/lib/repo-intel/updater.js @@ -0,0 +1,104 @@ +'use strict'; + +/** + * 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-intel/updater + */ + +const { execFileSync } = require('child_process'); + +const cache = require('./cache'); + +/** + * 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 = { + isStale: false, + reason: null, + commitsBehind: 0, + suggestFullRebuild: false + }; + + if (!map?.git?.commit) { + result.isStale = true; + result.reason = 'Missing base commit in repo-map'; + result.suggestFullRebuild = true; + return result; + } + + if (cache.isMarkedStale(basePath)) { + result.isStale = true; + result.reason = 'Marked stale by hook'; + } + + if (!commitExists(basePath, map.git.commit)) { + result.isStale = true; + result.reason = 'Base commit no longer exists (rebased?)'; + result.suggestFullRebuild = true; + return result; + } + + const currentBranch = getCurrentBranch(basePath); + if (currentBranch && map.git.branch && currentBranch !== map.git.branch) { + result.isStale = true; + result.reason = `Branch changed from ${map.git.branch} to ${currentBranch}`; + result.suggestFullRebuild = true; + } + + const commitsBehind = getCommitsBehind(basePath, map.git.commit); + if (commitsBehind > 0) { + result.isStale = true; + result.commitsBehind = commitsBehind; + if (!result.reason) { + result.reason = `${commitsBehind} commits behind HEAD`; + } + } + + return result; +} + +function isValidCommitHash(commit) { + return typeof commit === 'string' && /^[0-9a-fA-F]{4,40}$/.test(commit); +} + +function commitExists(basePath, commit) { + if (!isValidCommitHash(commit)) return false; + try { + execFileSync('git', ['cat-file', '-e', commit], { cwd: basePath, stdio: ['pipe', 'pipe', 'pipe'] }); + return true; + } catch { + return false; + } +} + +function getCurrentBranch(basePath) { + try { + return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: basePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + } catch { + return null; + } +} + +function getCommitsBehind(basePath, commit) { + if (!isValidCommitHash(commit)) return 0; + try { + const out = execFileSync('git', ['rev-list', `${commit}..HEAD`, '--count'], { + cwd: basePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + return Number(out) || 0; + } catch { + return 0; + } +} + +module.exports = { checkStaleness }; diff --git a/lib/repo-map/index.js b/lib/repo-map/index.js index ca21b03..336e3f3 100644 --- a/lib/repo-map/index.js +++ b/lib/repo-map/index.js @@ -1,265 +1,30 @@ 'use strict'; /** - * Repo Map - repository symbol mapping via agent-analyzer + * DEPRECATED COMPAT SHIM — repo-map folded into lib/repo-intel. * - * Uses agent-analyzer for symbol extraction. The binary is auto-downloaded - * on first use - no external tool installation required. + * repo-map was a leftover split: it produced/converted the artifact while + * lib/repo-intel/ only queried it. They are one pipeline over one binary, now + * unified under lib/repo-intel. This shim re-exports the lifecycle surface so + * existing `require('.../repo-map')` consumers keep working unchanged. + * + * Migrate callers to `require('.../repo-intel')` and remove this shim. * * @module lib/repo-map + * @deprecated use lib/repo-intel */ -const fs = require('fs'); -const path = require('path'); -const { execFileSync } = require('child_process'); - -const installer = require('./installer'); -const cache = require('./cache'); -const updater = require('./updater'); -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 - * @returns {Promise<{success: boolean, map?: Object, error?: string}>} - */ -async function init(basePath, options = {}) { - const installed = await installer.checkInstalled(); - if (!installed.found) { - return { - success: false, - error: 'agent-analyzer binary unavailable: ' + (installed.error || 'unknown error'), - installSuggestion: installer.getInstallInstructions() - }; - } - - const existing = cache.load(basePath); - if (existing && !options.force) { - return { - success: false, - error: 'Repo map already exists. Use --force to rebuild or /repo-map update to refresh.', - existing: cache.getStatus(basePath) - }; - } - - const startTime = Date.now(); - - let intelJson; - try { - intelJson = await binary.runAnalyzerAsync(['repo-intel', 'init', basePath]); - } catch (e) { - return { - success: false, - error: 'agent-analyzer repo-intel init failed: ' + e.message - }; - } - - let intel; - try { - intel = JSON.parse(intelJson); - } catch (e) { - return { - success: false, - error: 'Failed to parse repo-intel output: ' + e.message - }; - } - - // 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 - } - - 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, - languages: map.project.languages, - duration: map.stats.scanDurationMs - } - }; -} - -/** - * 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, summary?: Object, error?: string}>} - */ -async function update(basePath, options = {}) { - const installed = await installer.checkInstalled(); - if (!installed.found) { - return { - success: false, - error: 'agent-analyzer binary unavailable: ' + (installed.error || 'unknown error'), - installSuggestion: installer.getInstallInstructions() - }; - } - - if (!cache.exists(basePath)) { - return { - success: false, - error: 'No repo map found. Run /repo-map init first.' - }; - } - - 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: 'agent-analyzer repo-intel update failed: ' + e.message - }; - } - - let intel; - try { - intel = JSON.parse(intelJson); - } catch (e) { - return { - success: false, - error: 'Failed to parse repo-intel update output: ' + e.message - }; - } - - try { - writeJsonAtomic(intelPath, intel); - } catch { - // Non-fatal - } - - 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 - } - }; -} - -/** - * Get repo map status - * @param {string} basePath - Repository root path - * @returns {{exists: boolean, status?: Object}} - */ -function status(basePath) { - const map = cache.load(basePath); - if (!map) { - return { exists: false }; - } - - 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, - files: Object.keys(map.files).length, - symbols: map.stats?.totalSymbols || 0, - languages: map.project?.languages || [], - staleness - } - }; -} - -/** - * Load repo map (if exists) - * @param {string} basePath - Repository root path - * @returns {Object|null} - */ -function load(basePath) { - return cache.load(basePath); -} - -/** - * 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 (compat alias). - * @returns {string} - */ -function getInstallInstructions() { - return installer.getInstallInstructions(); -} +const repoIntel = require('../repo-intel'); module.exports = { - init, - update, - status, - load, - exists, - checkAstGrepInstalled, - getInstallInstructions, - - // Re-export submodules for advanced usage - installer, - cache, - updater + init: repoIntel.init, + update: repoIntel.update, + status: repoIntel.status, + load: repoIntel.load, + exists: repoIntel.exists, + checkAstGrepInstalled: repoIntel.checkAstGrepInstalled, + getInstallInstructions: repoIntel.getInstallInstructions, + installer: repoIntel.installer, + cache: repoIntel.cache, + updater: repoIntel.updater };