codegraph.ts 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392
  1. #!/usr/bin/env node
  2. /**
  3. * CodeGraph CLI
  4. *
  5. * Command-line interface for CodeGraph code intelligence.
  6. *
  7. * Usage:
  8. * codegraph Run interactive installer (when no args)
  9. * codegraph install Run interactive installer
  10. * codegraph init [path] Initialize CodeGraph in a project
  11. * codegraph uninit [path] Remove CodeGraph from a project
  12. * codegraph index [path] Index all files in the project
  13. * codegraph sync [path] Sync changes since last index
  14. * codegraph status [path] Show index status
  15. * codegraph query <search> Search for symbols
  16. * codegraph files [options] Show project file structure
  17. * codegraph context <task> Build context for a task
  18. * codegraph affected [files] Find test files affected by changes
  19. * codegraph mark-dirty [path] Mark project as needing sync (hooks)
  20. * codegraph sync-if-dirty [path] Sync if marked dirty (hooks)
  21. *
  22. * Note: Git hooks have been removed. CodeGraph sync is triggered automatically
  23. * through codegraph's Claude Code hooks integration.
  24. */
  25. import { Command } from 'commander';
  26. import * as path from 'path';
  27. import * as fs from 'fs';
  28. import { spawn } from 'child_process';
  29. import { getCodeGraphDir, findNearestCodeGraphRoot, isInitialized } from '../directory';
  30. import { createShimmerProgress } from '../ui/shimmer-progress';
  31. // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
  32. async function loadCodeGraph(): Promise<typeof import('../index')> {
  33. try {
  34. return await import('../index');
  35. } catch (err) {
  36. const msg = err instanceof Error ? err.message : String(err);
  37. console.error('\x1b[31m✗\x1b[0m Failed to load CodeGraph modules.');
  38. console.error(`\n Node: ${process.version} Platform: ${process.platform} ${process.arch}`);
  39. console.error(`\n Error: ${msg}`);
  40. console.error('\n Try reinstalling with: npm install -g @colbymchenry/codegraph\n');
  41. process.exit(1);
  42. }
  43. }
  44. // Dynamic import helper — tsc compiles import() to require() in CJS mode,
  45. // which fails for ESM-only packages. This bypasses the transformation.
  46. // eslint-disable-next-line @typescript-eslint/no-implied-eval
  47. const importESM = new Function('specifier', 'return import(specifier)') as
  48. (specifier: string) => Promise<typeof import('@clack/prompts')>;
  49. // Check if running with no arguments - run installer
  50. if (process.argv.length === 2) {
  51. import('../installer').then(({ runInstaller }) =>
  52. runInstaller()
  53. ).catch((err) => {
  54. console.error('Installation failed:', err instanceof Error ? err.message : String(err));
  55. process.exit(1);
  56. });
  57. } else {
  58. // Normal CLI flow
  59. main();
  60. }
  61. process.on('uncaughtException', (error) => {
  62. console.error('[CodeGraph] Uncaught exception:', error);
  63. });
  64. process.on('unhandledRejection', (reason) => {
  65. console.error('[CodeGraph] Unhandled rejection:', reason);
  66. });
  67. function main() {
  68. const program = new Command();
  69. // Version from package.json
  70. const packageJson = JSON.parse(
  71. fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8')
  72. );
  73. // =============================================================================
  74. // ANSI Color Helpers (avoid chalk ESM issues)
  75. // =============================================================================
  76. const colors = {
  77. reset: '\x1b[0m',
  78. bold: '\x1b[1m',
  79. dim: '\x1b[2m',
  80. red: '\x1b[31m',
  81. green: '\x1b[32m',
  82. yellow: '\x1b[33m',
  83. blue: '\x1b[34m',
  84. cyan: '\x1b[36m',
  85. white: '\x1b[37m',
  86. gray: '\x1b[90m',
  87. };
  88. const chalk = {
  89. bold: (s: string) => `${colors.bold}${s}${colors.reset}`,
  90. dim: (s: string) => `${colors.dim}${s}${colors.reset}`,
  91. red: (s: string) => `${colors.red}${s}${colors.reset}`,
  92. green: (s: string) => `${colors.green}${s}${colors.reset}`,
  93. yellow: (s: string) => `${colors.yellow}${s}${colors.reset}`,
  94. blue: (s: string) => `${colors.blue}${s}${colors.reset}`,
  95. cyan: (s: string) => `${colors.cyan}${s}${colors.reset}`,
  96. white: (s: string) => `${colors.white}${s}${colors.reset}`,
  97. gray: (s: string) => `${colors.gray}${s}${colors.reset}`,
  98. };
  99. program
  100. .name('codegraph')
  101. .description('Code intelligence and knowledge graph for any codebase')
  102. .version(packageJson.version);
  103. // =============================================================================
  104. // Helper Functions
  105. // =============================================================================
  106. /**
  107. * Resolve project path from argument or current directory
  108. * Walks up parent directories to find nearest initialized CodeGraph project
  109. * (must have .codegraph/codegraph.db, not just .codegraph/lessons.db)
  110. */
  111. function resolveProjectPath(pathArg?: string): string {
  112. const absolutePath = path.resolve(pathArg || process.cwd());
  113. // If exact path is initialized (has codegraph.db), use it
  114. if (isInitialized(absolutePath)) {
  115. return absolutePath;
  116. }
  117. // Walk up to find nearest parent with CodeGraph initialized
  118. // Note: findNearestCodeGraphRoot finds any .codegraph folder, but we need one with codegraph.db
  119. let current = absolutePath;
  120. const root = path.parse(current).root;
  121. while (current !== root) {
  122. const parent = path.dirname(current);
  123. if (parent === current) break;
  124. current = parent;
  125. if (isInitialized(current)) {
  126. return current;
  127. }
  128. }
  129. // Not found - return original path (will fail later with helpful error)
  130. return absolutePath;
  131. }
  132. /**
  133. * Format a number with commas
  134. */
  135. function formatNumber(n: number): string {
  136. return n.toLocaleString();
  137. }
  138. /**
  139. * Format duration in milliseconds to human readable
  140. */
  141. function formatDuration(ms: number): string {
  142. if (ms < 1000) {
  143. return `${ms}ms`;
  144. }
  145. const seconds = ms / 1000;
  146. if (seconds < 60) {
  147. return `${seconds.toFixed(1)}s`;
  148. }
  149. const minutes = Math.floor(seconds / 60);
  150. const remainingSeconds = seconds % 60;
  151. return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
  152. }
  153. // Shimmer progress renderer (runs in a worker thread for smooth animation)
  154. // Imported at top of file from '../ui/shimmer-progress'
  155. /**
  156. * Create a plain-text progress callback for --verbose mode.
  157. * No animations, no ANSI tricks — just timestamped lines to stdout.
  158. */
  159. function createVerboseProgress(): (progress: { phase: string; current: number; total: number; currentFile?: string }) => void {
  160. let lastPhase = '';
  161. let lastPct = -1;
  162. const startTime = Date.now();
  163. return (progress) => {
  164. const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
  165. if (progress.phase !== lastPhase) {
  166. lastPhase = progress.phase;
  167. lastPct = -1;
  168. console.log(`[${elapsed}s] Phase: ${progress.phase}`);
  169. }
  170. if (progress.total > 0) {
  171. const pct = Math.floor((progress.current / progress.total) * 100);
  172. // Log every 5% to keep output manageable
  173. if (pct >= lastPct + 5 || progress.current === progress.total) {
  174. lastPct = pct;
  175. console.log(`[${elapsed}s] ${progress.current}/${progress.total} (${pct}%)${progress.currentFile ? ` — ${progress.currentFile}` : ''}`);
  176. }
  177. } else if (progress.current > 0) {
  178. // Scanning phase (no total yet) — log periodically
  179. if (progress.current % 1000 === 0 || progress.current === 1) {
  180. console.log(`[${elapsed}s] ${formatNumber(progress.current)} files found`);
  181. }
  182. }
  183. };
  184. }
  185. /**
  186. * Print success message
  187. */
  188. function success(message: string): void {
  189. console.log(chalk.green('✓') + ' ' + message);
  190. }
  191. /**
  192. * Print error message
  193. */
  194. function error(message: string): void {
  195. console.error(chalk.red('✗') + ' ' + message);
  196. }
  197. /**
  198. * Print info message
  199. */
  200. function info(message: string): void {
  201. console.log(chalk.blue('ℹ') + ' ' + message);
  202. }
  203. /**
  204. * Print warning message
  205. */
  206. function warn(message: string): void {
  207. console.log(chalk.yellow('⚠') + ' ' + message);
  208. }
  209. type IndexResult = {
  210. success: boolean;
  211. filesIndexed: number;
  212. filesSkipped: number;
  213. filesErrored: number;
  214. nodesCreated: number;
  215. edgesCreated: number;
  216. errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>;
  217. durationMs: number;
  218. };
  219. /**
  220. * Print indexing results using clack log methods
  221. */
  222. function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexResult, projectPath?: string): void {
  223. const hasErrors = result.filesErrored > 0;
  224. if (result.filesIndexed > 0) {
  225. if (hasErrors) {
  226. clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} could not be parsed)`);
  227. } else {
  228. clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`);
  229. }
  230. clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`);
  231. } else if (hasErrors) {
  232. clack.log.error(`Indexing failed — all ${formatNumber(result.filesErrored)} files had errors`);
  233. } else {
  234. clack.log.warn('No files found to index');
  235. }
  236. if (hasErrors) {
  237. const errorsByCode = new Map<string, number>();
  238. for (const err of result.errors) {
  239. if (err.severity === 'error') {
  240. const code = err.code || 'unknown';
  241. errorsByCode.set(code, (errorsByCode.get(code) || 0) + 1);
  242. }
  243. }
  244. const codeLabels: Record<string, string> = {
  245. parse_error: 'files failed to parse',
  246. read_error: 'files could not be read',
  247. size_exceeded: 'files exceeded size limit',
  248. path_traversal: 'blocked paths',
  249. unsupported_language: 'unsupported language',
  250. parser_error: 'parser initialization failures',
  251. };
  252. const breakdown = Array.from(errorsByCode)
  253. .map(([code, count]) => `${formatNumber(count)} ${codeLabels[code] || code}`)
  254. .join('\n');
  255. clack.note(breakdown, 'Error breakdown');
  256. if (projectPath) {
  257. writeErrorLog(projectPath, result.errors);
  258. clack.log.info('See .codegraph/errors.log for details');
  259. }
  260. if (result.filesIndexed > 0) {
  261. clack.log.info('The index is fully usable — only the failed files are missing.');
  262. }
  263. } else if (projectPath) {
  264. const logPath = path.join(projectPath, '.codegraph', 'errors.log');
  265. if (fs.existsSync(logPath)) {
  266. fs.unlinkSync(logPath);
  267. }
  268. }
  269. }
  270. /**
  271. * Write detailed error log to .codegraph/errors.log
  272. */
  273. function writeErrorLog(projectPath: string, errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>): void {
  274. const cgDir = path.join(projectPath, '.codegraph');
  275. if (!fs.existsSync(cgDir)) return;
  276. const logPath = path.join(cgDir, 'errors.log');
  277. // Group errors by file path
  278. const errorsByFile = new Map<string, Array<{ message: string; code?: string }>>();
  279. const noFileErrors: Array<{ message: string; code?: string }> = [];
  280. for (const err of errors) {
  281. if (err.severity !== 'error') continue;
  282. if (err.filePath) {
  283. let list = errorsByFile.get(err.filePath);
  284. if (!list) {
  285. list = [];
  286. errorsByFile.set(err.filePath, list);
  287. }
  288. list.push({ message: err.message, code: err.code });
  289. } else {
  290. noFileErrors.push({ message: err.message, code: err.code });
  291. }
  292. }
  293. const lines: string[] = [
  294. `CodeGraph Error Log — ${new Date().toISOString()}`,
  295. `${errorsByFile.size} files with errors`,
  296. '',
  297. ];
  298. for (const [filePath, fileErrors] of errorsByFile) {
  299. for (const err of fileErrors) {
  300. lines.push(`${filePath}: ${err.message}`);
  301. }
  302. }
  303. for (const err of noFileErrors) {
  304. lines.push(err.message);
  305. }
  306. fs.writeFileSync(logPath, lines.join('\n') + '\n');
  307. }
  308. // =============================================================================
  309. // Commands
  310. // =============================================================================
  311. /**
  312. * codegraph init [path]
  313. */
  314. program
  315. .command('init [path]')
  316. .description('Initialize CodeGraph in a project directory')
  317. .option('-i, --index', 'Run initial indexing after initialization')
  318. .option('-v, --verbose', 'Show detailed worker lifecycle and memory info')
  319. .action(async (pathArg: string | undefined, options: { index?: boolean; verbose?: boolean }) => {
  320. const projectPath = path.resolve(pathArg || process.cwd());
  321. const clack = await importESM('@clack/prompts');
  322. clack.intro('Initializing CodeGraph');
  323. try {
  324. if (isInitialized(projectPath)) {
  325. clack.log.warn(`Already initialized in ${projectPath}`);
  326. clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update');
  327. clack.outro('');
  328. return;
  329. }
  330. const { default: CodeGraph } = await loadCodeGraph();
  331. const cg = await CodeGraph.init(projectPath, { index: false });
  332. clack.log.success(`Initialized in ${projectPath}`);
  333. if (options.index) {
  334. let result: IndexResult;
  335. if (options.verbose) {
  336. result = await cg.indexAll({
  337. onProgress: createVerboseProgress(),
  338. verbose: true,
  339. });
  340. } else {
  341. process.stdout.write(`${colors.dim}│${colors.reset}\n`);
  342. const progress = createShimmerProgress();
  343. result = await cg.indexAll({
  344. onProgress: progress.onProgress,
  345. });
  346. await progress.stop();
  347. }
  348. printIndexResult(clack, result, projectPath);
  349. } else {
  350. clack.log.info('Run "codegraph index" to index the project');
  351. }
  352. clack.outro('Done');
  353. cg.destroy();
  354. } catch (err) {
  355. clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
  356. process.exit(1);
  357. }
  358. });
  359. /**
  360. * codegraph uninit [path]
  361. */
  362. program
  363. .command('uninit [path]')
  364. .description('Remove CodeGraph from a project (deletes .codegraph/ directory)')
  365. .option('-f, --force', 'Skip confirmation prompt')
  366. .action(async (pathArg: string | undefined, options: { force?: boolean }) => {
  367. const projectPath = resolveProjectPath(pathArg);
  368. try {
  369. if (!isInitialized(projectPath)) {
  370. warn(`CodeGraph is not initialized in ${projectPath}`);
  371. return;
  372. }
  373. if (!options.force) {
  374. // Confirm with user
  375. const readline = await import('readline');
  376. const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  377. const answer = await new Promise<string>((resolve) => {
  378. rl.question(
  379. chalk.yellow('⚠ This will permanently delete all CodeGraph data. Continue? (y/N) '),
  380. resolve
  381. );
  382. });
  383. rl.close();
  384. if (answer.toLowerCase() !== 'y') {
  385. info('Cancelled');
  386. return;
  387. }
  388. }
  389. const { default: CodeGraph } = await loadCodeGraph();
  390. const cg = CodeGraph.openSync(projectPath);
  391. cg.uninitialize();
  392. success(`Removed CodeGraph from ${projectPath}`);
  393. } catch (err) {
  394. error(`Failed to uninitialize: ${err instanceof Error ? err.message : String(err)}`);
  395. process.exit(1);
  396. }
  397. });
  398. /**
  399. * codegraph index [path]
  400. */
  401. program
  402. .command('index [path]')
  403. .description('Index all files in the project')
  404. .option('-f, --force', 'Force full re-index even if already indexed')
  405. .option('-q, --quiet', 'Suppress progress output')
  406. .option('-v, --verbose', 'Show detailed worker lifecycle and memory info')
  407. .action(async (pathArg: string | undefined, options: { force?: boolean; quiet?: boolean; verbose?: boolean }) => {
  408. const projectPath = resolveProjectPath(pathArg);
  409. try {
  410. if (!isInitialized(projectPath)) {
  411. error(`CodeGraph not initialized in ${projectPath}`);
  412. info('Run "codegraph init" first');
  413. process.exit(1);
  414. }
  415. const { default: CodeGraph } = await loadCodeGraph();
  416. const cg = await CodeGraph.open(projectPath);
  417. if (options.quiet) {
  418. // Quiet mode: no UI, just run
  419. if (options.force) cg.clear();
  420. const result = await cg.indexAll();
  421. if (!result.success) process.exit(1);
  422. cg.destroy();
  423. return;
  424. }
  425. const clack = await importESM('@clack/prompts');
  426. clack.intro('Indexing project');
  427. if (options.force) {
  428. cg.clear();
  429. clack.log.info('Cleared existing index');
  430. }
  431. let result: IndexResult;
  432. if (options.verbose) {
  433. result = await cg.indexAll({
  434. onProgress: createVerboseProgress(),
  435. verbose: true,
  436. });
  437. } else {
  438. process.stdout.write(`${colors.dim}│${colors.reset}\n`);
  439. const progress = createShimmerProgress();
  440. result = await cg.indexAll({
  441. onProgress: progress.onProgress,
  442. });
  443. await progress.stop();
  444. }
  445. printIndexResult(clack, result, projectPath);
  446. if (!result.success) {
  447. process.exit(1);
  448. }
  449. clack.outro('Done');
  450. cg.destroy();
  451. } catch (err) {
  452. error(`Failed to index: ${err instanceof Error ? err.message : String(err)}`);
  453. process.exit(1);
  454. }
  455. });
  456. /**
  457. * codegraph sync [path]
  458. */
  459. program
  460. .command('sync [path]')
  461. .description('Sync changes since last index')
  462. .option('-q, --quiet', 'Suppress output (for git hooks)')
  463. .action(async (pathArg: string | undefined, options: { quiet?: boolean }) => {
  464. const projectPath = resolveProjectPath(pathArg);
  465. try {
  466. if (!isInitialized(projectPath)) {
  467. if (!options.quiet) {
  468. error(`CodeGraph not initialized in ${projectPath}`);
  469. }
  470. process.exit(1);
  471. }
  472. const { default: CodeGraph } = await loadCodeGraph();
  473. const cg = await CodeGraph.open(projectPath);
  474. if (options.quiet) {
  475. await cg.sync();
  476. cg.destroy();
  477. return;
  478. }
  479. const clack = await importESM('@clack/prompts');
  480. clack.intro('Syncing CodeGraph');
  481. process.stdout.write(`${colors.dim}│${colors.reset}\n`);
  482. const progress = createShimmerProgress();
  483. const result = await cg.sync({
  484. onProgress: progress.onProgress,
  485. });
  486. await progress.stop();
  487. const totalChanges = result.filesAdded + result.filesModified + result.filesRemoved;
  488. if (totalChanges === 0) {
  489. clack.log.info('Already up to date');
  490. } else {
  491. clack.log.success(`Synced ${formatNumber(totalChanges)} changed files`);
  492. const details: string[] = [];
  493. if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`);
  494. if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`);
  495. if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`);
  496. clack.log.info(`${details.join(', ')} — ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`);
  497. }
  498. clack.outro('Done');
  499. cg.destroy();
  500. } catch (err) {
  501. if (!options.quiet) {
  502. error(`Failed to sync: ${err instanceof Error ? err.message : String(err)}`);
  503. }
  504. process.exit(1);
  505. }
  506. });
  507. /**
  508. * codegraph status [path]
  509. */
  510. program
  511. .command('status [path]')
  512. .description('Show index status and statistics')
  513. .option('-j, --json', 'Output as JSON')
  514. .action(async (pathArg: string | undefined, options: { json?: boolean }) => {
  515. const projectPath = resolveProjectPath(pathArg);
  516. try {
  517. if (!isInitialized(projectPath)) {
  518. if (options.json) {
  519. console.log(JSON.stringify({ initialized: false, projectPath }));
  520. return;
  521. }
  522. console.log(chalk.bold('\nCodeGraph Status\n'));
  523. info(`Project: ${projectPath}`);
  524. warn('Not initialized');
  525. info('Run "codegraph init" to initialize');
  526. return;
  527. }
  528. const { default: CodeGraph } = await loadCodeGraph();
  529. const cg = await CodeGraph.open(projectPath);
  530. const stats = cg.getStats();
  531. const changes = cg.getChangedFiles();
  532. // JSON output mode
  533. if (options.json) {
  534. console.log(JSON.stringify({
  535. initialized: true,
  536. projectPath,
  537. fileCount: stats.fileCount,
  538. nodeCount: stats.nodeCount,
  539. edgeCount: stats.edgeCount,
  540. dbSizeBytes: stats.dbSizeBytes,
  541. nodesByKind: stats.nodesByKind,
  542. languages: Object.entries(stats.filesByLanguage).filter(([, count]) => count > 0).map(([lang]) => lang),
  543. pendingChanges: {
  544. added: changes.added.length,
  545. modified: changes.modified.length,
  546. removed: changes.removed.length,
  547. },
  548. }));
  549. cg.destroy();
  550. return;
  551. }
  552. console.log(chalk.bold('\nCodeGraph Status\n'));
  553. // Project info
  554. console.log(chalk.cyan('Project:'), projectPath);
  555. console.log();
  556. // Index stats
  557. console.log(chalk.bold('Index Statistics:'));
  558. console.log(` Files: ${formatNumber(stats.fileCount)}`);
  559. console.log(` Nodes: ${formatNumber(stats.nodeCount)}`);
  560. console.log(` Edges: ${formatNumber(stats.edgeCount)}`);
  561. console.log(` DB Size: ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`);
  562. console.log();
  563. // Node breakdown
  564. console.log(chalk.bold('Nodes by Kind:'));
  565. const nodesByKind = Object.entries(stats.nodesByKind)
  566. .filter(([, count]) => count > 0)
  567. .sort((a, b) => b[1] - a[1]);
  568. for (const [kind, count] of nodesByKind) {
  569. console.log(` ${kind.padEnd(15)} ${formatNumber(count)}`);
  570. }
  571. console.log();
  572. // Language breakdown
  573. console.log(chalk.bold('Files by Language:'));
  574. const filesByLang = Object.entries(stats.filesByLanguage)
  575. .filter(([, count]) => count > 0)
  576. .sort((a, b) => b[1] - a[1]);
  577. for (const [lang, count] of filesByLang) {
  578. console.log(` ${lang.padEnd(15)} ${formatNumber(count)}`);
  579. }
  580. console.log();
  581. // Pending changes
  582. const totalChanges = changes.added.length + changes.modified.length + changes.removed.length;
  583. if (totalChanges > 0) {
  584. console.log(chalk.bold('Pending Changes:'));
  585. if (changes.added.length > 0) {
  586. console.log(` Added: ${changes.added.length} files`);
  587. }
  588. if (changes.modified.length > 0) {
  589. console.log(` Modified: ${changes.modified.length} files`);
  590. }
  591. if (changes.removed.length > 0) {
  592. console.log(` Removed: ${changes.removed.length} files`);
  593. }
  594. info('Run "codegraph sync" to update the index');
  595. } else {
  596. success('Index is up to date');
  597. }
  598. console.log();
  599. cg.destroy();
  600. } catch (err) {
  601. error(`Failed to get status: ${err instanceof Error ? err.message : String(err)}`);
  602. process.exit(1);
  603. }
  604. });
  605. /**
  606. * codegraph query <search>
  607. */
  608. program
  609. .command('query <search>')
  610. .description('Search for symbols in the codebase')
  611. .option('-p, --path <path>', 'Project path')
  612. .option('-l, --limit <number>', 'Maximum results', '10')
  613. .option('-k, --kind <kind>', 'Filter by node kind (function, class, etc.)')
  614. .option('-j, --json', 'Output as JSON')
  615. .action(async (search: string, options: { path?: string; limit?: string; kind?: string; json?: boolean }) => {
  616. const projectPath = resolveProjectPath(options.path);
  617. try {
  618. if (!isInitialized(projectPath)) {
  619. error(`CodeGraph not initialized in ${projectPath}`);
  620. process.exit(1);
  621. }
  622. const { default: CodeGraph } = await loadCodeGraph();
  623. const cg = await CodeGraph.open(projectPath);
  624. const limit = parseInt(options.limit || '10', 10);
  625. const results = cg.searchNodes(search, {
  626. limit,
  627. kinds: options.kind ? [options.kind as any] : undefined,
  628. });
  629. if (options.json) {
  630. console.log(JSON.stringify(results, null, 2));
  631. } else {
  632. if (results.length === 0) {
  633. info(`No results found for "${search}"`);
  634. } else {
  635. console.log(chalk.bold(`\nSearch Results for "${search}":\n`));
  636. for (const result of results) {
  637. const node = result.node;
  638. const location = `${node.filePath}:${node.startLine}`;
  639. const score = chalk.dim(`(${(result.score * 100).toFixed(0)}%)`);
  640. console.log(
  641. chalk.cyan(node.kind.padEnd(12)) +
  642. chalk.white(node.name) +
  643. ' ' + score
  644. );
  645. console.log(chalk.dim(` ${location}`));
  646. if (node.signature) {
  647. console.log(chalk.dim(` ${node.signature}`));
  648. }
  649. console.log();
  650. }
  651. }
  652. }
  653. cg.destroy();
  654. } catch (err) {
  655. error(`Search failed: ${err instanceof Error ? err.message : String(err)}`);
  656. process.exit(1);
  657. }
  658. });
  659. /**
  660. * codegraph files [path]
  661. */
  662. program
  663. .command('files')
  664. .description('Show project file structure from the index')
  665. .option('-p, --path <path>', 'Project path')
  666. .option('--filter <dir>', 'Filter to files under this directory')
  667. .option('--pattern <glob>', 'Filter files matching this glob pattern')
  668. .option('--format <format>', 'Output format (tree, flat, grouped)', 'tree')
  669. .option('--max-depth <number>', 'Maximum directory depth for tree format')
  670. .option('--no-metadata', 'Hide file metadata (language, symbol count)')
  671. .option('-j, --json', 'Output as JSON')
  672. .action(async (options: {
  673. path?: string;
  674. filter?: string;
  675. pattern?: string;
  676. format?: string;
  677. maxDepth?: string;
  678. metadata?: boolean;
  679. json?: boolean;
  680. }) => {
  681. const projectPath = resolveProjectPath(options.path);
  682. try {
  683. if (!isInitialized(projectPath)) {
  684. error(`CodeGraph not initialized in ${projectPath}`);
  685. process.exit(1);
  686. }
  687. const { default: CodeGraph } = await loadCodeGraph();
  688. const cg = await CodeGraph.open(projectPath);
  689. let files = cg.getFiles();
  690. if (files.length === 0) {
  691. info('No files indexed. Run "codegraph index" first.');
  692. cg.destroy();
  693. return;
  694. }
  695. // Filter by path prefix
  696. if (options.filter) {
  697. const filter = options.filter;
  698. files = files.filter(f => f.path.startsWith(filter) || f.path.startsWith('./' + filter));
  699. }
  700. // Filter by glob pattern
  701. if (options.pattern) {
  702. const regex = globToRegex(options.pattern);
  703. files = files.filter(f => regex.test(f.path));
  704. }
  705. if (files.length === 0) {
  706. info('No files found matching the criteria.');
  707. cg.destroy();
  708. return;
  709. }
  710. // JSON output
  711. if (options.json) {
  712. const output = files.map(f => ({
  713. path: f.path,
  714. language: f.language,
  715. nodeCount: f.nodeCount,
  716. size: f.size,
  717. }));
  718. console.log(JSON.stringify(output, null, 2));
  719. cg.destroy();
  720. return;
  721. }
  722. const includeMetadata = options.metadata !== false;
  723. const format = options.format || 'tree';
  724. const maxDepth = options.maxDepth ? parseInt(options.maxDepth, 10) : undefined;
  725. // Format output
  726. switch (format) {
  727. case 'flat':
  728. console.log(chalk.bold(`\nFiles (${files.length}):\n`));
  729. for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
  730. if (includeMetadata) {
  731. console.log(` ${file.path} ${chalk.dim(`(${file.language}, ${file.nodeCount} symbols)`)}`);
  732. } else {
  733. console.log(` ${file.path}`);
  734. }
  735. }
  736. break;
  737. case 'grouped':
  738. console.log(chalk.bold(`\nFiles by Language (${files.length} total):\n`));
  739. const byLang = new Map<string, typeof files>();
  740. for (const file of files) {
  741. const existing = byLang.get(file.language) || [];
  742. existing.push(file);
  743. byLang.set(file.language, existing);
  744. }
  745. const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
  746. for (const [lang, langFiles] of sortedLangs) {
  747. console.log(chalk.cyan(`${lang} (${langFiles.length}):`));
  748. for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
  749. if (includeMetadata) {
  750. console.log(` ${file.path} ${chalk.dim(`(${file.nodeCount} symbols)`)}`);
  751. } else {
  752. console.log(` ${file.path}`);
  753. }
  754. }
  755. console.log();
  756. }
  757. break;
  758. case 'tree':
  759. default:
  760. console.log(chalk.bold(`\nProject Structure (${files.length} files):\n`));
  761. printFileTree(files, includeMetadata, maxDepth, chalk);
  762. break;
  763. }
  764. console.log();
  765. cg.destroy();
  766. } catch (err) {
  767. error(`Failed to list files: ${err instanceof Error ? err.message : String(err)}`);
  768. process.exit(1);
  769. }
  770. });
  771. /**
  772. * Convert glob pattern to regex
  773. */
  774. function globToRegex(pattern: string): RegExp {
  775. const escaped = pattern
  776. .replace(/[.+^${}()|[\]\\]/g, '\\$&')
  777. .replace(/\*\*/g, '{{GLOBSTAR}}')
  778. .replace(/\*/g, '[^/]*')
  779. .replace(/\?/g, '[^/]')
  780. .replace(/\{\{GLOBSTAR\}\}/g, '.*');
  781. return new RegExp(escaped);
  782. }
  783. /**
  784. * Print files as a tree
  785. */
  786. function printFileTree(
  787. files: { path: string; language: string; nodeCount: number }[],
  788. includeMetadata: boolean,
  789. maxDepth: number | undefined,
  790. chalk: { dim: (s: string) => string; cyan: (s: string) => string }
  791. ): void {
  792. interface TreeNode {
  793. name: string;
  794. children: Map<string, TreeNode>;
  795. file?: { language: string; nodeCount: number };
  796. }
  797. const root: TreeNode = { name: '', children: new Map() };
  798. for (const file of files) {
  799. const parts = file.path.split('/');
  800. let current = root;
  801. for (let i = 0; i < parts.length; i++) {
  802. const part = parts[i];
  803. if (!part) continue;
  804. if (!current.children.has(part)) {
  805. current.children.set(part, { name: part, children: new Map() });
  806. }
  807. current = current.children.get(part)!;
  808. if (i === parts.length - 1) {
  809. current.file = { language: file.language, nodeCount: file.nodeCount };
  810. }
  811. }
  812. }
  813. const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
  814. if (maxDepth !== undefined && depth > maxDepth) return;
  815. const connector = isLast ? '└── ' : '├── ';
  816. const childPrefix = isLast ? ' ' : '│ ';
  817. if (node.name) {
  818. let line = prefix + connector + node.name;
  819. if (node.file && includeMetadata) {
  820. line += chalk.dim(` (${node.file.language}, ${node.file.nodeCount} symbols)`);
  821. }
  822. console.log(line);
  823. }
  824. const children = [...node.children.values()];
  825. children.sort((a, b) => {
  826. const aIsDir = a.children.size > 0 && !a.file;
  827. const bIsDir = b.children.size > 0 && !b.file;
  828. if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
  829. return a.name.localeCompare(b.name);
  830. });
  831. for (let i = 0; i < children.length; i++) {
  832. const child = children[i]!;
  833. const nextPrefix = node.name ? prefix + childPrefix : prefix;
  834. renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
  835. }
  836. };
  837. renderNode(root, '', true, 0);
  838. }
  839. /**
  840. * codegraph context <task>
  841. */
  842. program
  843. .command('context <task>')
  844. .description('Build context for a task (outputs markdown)')
  845. .option('-p, --path <path>', 'Project path')
  846. .option('-n, --max-nodes <number>', 'Maximum nodes to include', '50')
  847. .option('-c, --max-code <number>', 'Maximum code blocks', '10')
  848. .option('--no-code', 'Exclude code blocks')
  849. .option('-f, --format <format>', 'Output format (markdown, json)', 'markdown')
  850. .action(async (task: string, options: {
  851. path?: string;
  852. maxNodes?: string;
  853. maxCode?: string;
  854. code?: boolean;
  855. format?: string;
  856. }) => {
  857. const projectPath = resolveProjectPath(options.path);
  858. try {
  859. if (!isInitialized(projectPath)) {
  860. error(`CodeGraph not initialized in ${projectPath}`);
  861. process.exit(1);
  862. }
  863. const { default: CodeGraph } = await loadCodeGraph();
  864. const cg = await CodeGraph.open(projectPath);
  865. const context = await cg.buildContext(task, {
  866. maxNodes: parseInt(options.maxNodes || '50', 10),
  867. maxCodeBlocks: parseInt(options.maxCode || '10', 10),
  868. includeCode: options.code !== false,
  869. format: options.format as 'markdown' | 'json',
  870. });
  871. // Output the context
  872. console.log(context);
  873. cg.destroy();
  874. } catch (err) {
  875. error(`Failed to build context: ${err instanceof Error ? err.message : String(err)}`);
  876. process.exit(1);
  877. }
  878. });
  879. /**
  880. * codegraph serve
  881. */
  882. program
  883. .command('serve')
  884. .description('Start CodeGraph as an MCP server for AI assistants')
  885. .option('-p, --path <path>', 'Project path (optional for MCP mode, uses rootUri from client)')
  886. .option('--mcp', 'Run as MCP server (stdio transport)')
  887. .action(async (options: { path?: string; mcp?: boolean }) => {
  888. const projectPath = options.path ? resolveProjectPath(options.path) : undefined;
  889. try {
  890. if (options.mcp) {
  891. // Start MCP server - it handles initialization lazily based on rootUri from client
  892. const { MCPServer } = await import('../mcp/index');
  893. const server = new MCPServer(projectPath);
  894. await server.start();
  895. // Server will run until terminated
  896. } else {
  897. // Default: show info about MCP mode.
  898. // Use stderr so stdout stays clean for any piped/stdio usage.
  899. console.error(chalk.bold('\nCodeGraph MCP Server\n'));
  900. console.error(chalk.blue('ℹ') + ' Use --mcp flag to start the MCP server');
  901. console.error('\nTo use with Claude Code, add to your MCP configuration:');
  902. console.error(chalk.dim(`
  903. {
  904. "mcpServers": {
  905. "codegraph": {
  906. "command": "codegraph",
  907. "args": ["serve", "--mcp"]
  908. }
  909. }
  910. }
  911. `));
  912. console.error('Available tools:');
  913. console.error(chalk.cyan(' codegraph_search') + ' - Search for code symbols');
  914. console.error(chalk.cyan(' codegraph_context') + ' - Build context for a task');
  915. console.error(chalk.cyan(' codegraph_callers') + ' - Find callers of a symbol');
  916. console.error(chalk.cyan(' codegraph_callees') + ' - Find what a symbol calls');
  917. console.error(chalk.cyan(' codegraph_impact') + ' - Analyze impact of changes');
  918. console.error(chalk.cyan(' codegraph_node') + ' - Get symbol details');
  919. console.error(chalk.cyan(' codegraph_files') + ' - Get project file structure');
  920. console.error(chalk.cyan(' codegraph_status') + ' - Get index status');
  921. }
  922. } catch (err) {
  923. error(`Failed to start server: ${err instanceof Error ? err.message : String(err)}`);
  924. process.exit(1);
  925. }
  926. });
  927. /**
  928. * codegraph visualize [path]
  929. */
  930. program
  931. .command('visualize [path]')
  932. .description('Open interactive graph visualization in your browser')
  933. .option('-p, --port <port>', 'Port to listen on (default: auto)', parseInt)
  934. .option('--no-open', 'Do not open browser automatically')
  935. .action(async (pathArg: string | undefined, options: { port?: number; open?: boolean }) => {
  936. const projectPath = resolveProjectPath(pathArg);
  937. try {
  938. if (!isInitialized(projectPath)) {
  939. error(`CodeGraph not initialized in ${projectPath}`);
  940. info('Run "codegraph init -i" first');
  941. process.exit(1);
  942. }
  943. const { default: CodeGraph } = await loadCodeGraph();
  944. const cg = await CodeGraph.open(projectPath);
  945. const stats = cg.getStats();
  946. console.log(chalk.bold('\n CodeGraph Explorer\n'));
  947. info(`Project: ${projectPath}`);
  948. info(`Indexed: ${formatNumber(stats.nodeCount)} nodes, ${formatNumber(stats.edgeCount)} edges, ${formatNumber(stats.fileCount)} files\n`);
  949. const { VisualizerServer } = await import('../visualizer/server');
  950. const server = new VisualizerServer(cg);
  951. const { url } = await server.start({ port: options.port, openBrowser: options.open !== false });
  952. success(`Visualizer running at ${chalk.cyan(url)}`);
  953. console.log(chalk.dim(' Press Ctrl+C to stop\n'));
  954. // Open browser
  955. if (options.open !== false) {
  956. const openCmd = process.platform === 'darwin' ? 'open' :
  957. process.platform === 'win32' ? 'start' : 'xdg-open';
  958. spawn(openCmd, [url], { detached: true, stdio: 'ignore' }).unref();
  959. }
  960. // Handle shutdown — force exit on second Ctrl+C
  961. let shuttingDown = false;
  962. const shutdown = () => {
  963. if (shuttingDown) {
  964. process.exit(1);
  965. }
  966. shuttingDown = true;
  967. console.log(chalk.dim('\n Shutting down...'));
  968. server.stop().then(() => {
  969. cg.close();
  970. process.exit(0);
  971. }).catch(() => process.exit(1));
  972. // Force exit after 2s if graceful shutdown hangs
  973. setTimeout(() => process.exit(1), 2000).unref();
  974. };
  975. process.on('SIGINT', shutdown);
  976. process.on('SIGTERM', shutdown);
  977. } catch (err) {
  978. error(`Failed to start visualizer: ${err instanceof Error ? err.message : String(err)}`);
  979. process.exit(1);
  980. }
  981. });
  982. /**
  983. * codegraph mark-dirty [path]
  984. *
  985. * Touches .codegraph/.dirty to signal that files have changed.
  986. * Used by Claude Code PostToolUse hooks to batch syncs.
  987. * Runs silently and always exits 0.
  988. */
  989. program
  990. .command('mark-dirty [path]')
  991. .description('Mark project as needing sync (used by Claude Code hooks)')
  992. .action(async (pathArg: string | undefined) => {
  993. try {
  994. const startPath = path.resolve(pathArg || process.cwd());
  995. const projectRoot = findNearestCodeGraphRoot(startPath);
  996. if (!projectRoot) {
  997. // No .codegraph/ found — exit silently
  998. process.exit(0);
  999. }
  1000. const dirtyPath = path.join(getCodeGraphDir(projectRoot), '.dirty');
  1001. fs.writeFileSync(dirtyPath, Date.now().toString(), 'utf-8');
  1002. } catch {
  1003. // Never fail — this runs in the background during edits
  1004. }
  1005. process.exit(0);
  1006. });
  1007. /**
  1008. * codegraph sync-if-dirty [path]
  1009. *
  1010. * Checks if .codegraph/.dirty exists and, if so, spawns a detached
  1011. * background process to run `codegraph sync`. The hook process exits
  1012. * immediately so Claude Code's Stop hook never blocks.
  1013. *
  1014. * Removes the marker BEFORE spawning so edits during sync
  1015. * create a new marker for the next Stop event.
  1016. * Runs silently and always exits 0.
  1017. */
  1018. program
  1019. .command('sync-if-dirty [path]')
  1020. .description('Sync if project was marked dirty (used by Claude Code hooks)')
  1021. .action(async (pathArg: string | undefined) => {
  1022. try {
  1023. const startPath = path.resolve(pathArg || process.cwd());
  1024. const projectRoot = findNearestCodeGraphRoot(startPath);
  1025. if (!projectRoot) {
  1026. process.exit(0);
  1027. }
  1028. const dirtyPath = path.join(getCodeGraphDir(projectRoot!), '.dirty');
  1029. // No marker → nothing to do (sub-ms exit)
  1030. if (!fs.existsSync(dirtyPath)) {
  1031. process.exit(0);
  1032. }
  1033. // Remove marker FIRST so edits during sync create a new one
  1034. try { fs.unlinkSync(dirtyPath); } catch { /* ignore */ }
  1035. // If not fully initialized (no DB), exit
  1036. if (!isInitialized(projectRoot!)) {
  1037. process.exit(0);
  1038. }
  1039. // Spawn sync as a detached background process
  1040. // so this hook exits immediately and doesn't block Claude Code.
  1041. // Uses process.argv[0]/[1] (e.g. node /path/to/codegraph.js) so it
  1042. // works whether invoked via global install, npx, or directly.
  1043. const child = spawn(
  1044. process.argv[0]!,
  1045. [process.argv[1]!, 'sync', '--quiet', projectRoot!],
  1046. {
  1047. detached: true,
  1048. stdio: 'ignore',
  1049. windowsHide: true,
  1050. }
  1051. );
  1052. child.unref();
  1053. } catch {
  1054. // Never fail — this runs at the end of Claude responses
  1055. }
  1056. process.exit(0);
  1057. });
  1058. /**
  1059. * codegraph unlock [path]
  1060. */
  1061. program
  1062. .command('unlock [path]')
  1063. .description('Remove a stale lock file that is blocking indexing')
  1064. .action(async (pathArg: string | undefined) => {
  1065. const projectPath = resolveProjectPath(pathArg);
  1066. try {
  1067. if (!isInitialized(projectPath)) {
  1068. error(`CodeGraph not initialized in ${projectPath}`);
  1069. return;
  1070. }
  1071. const lockPath = path.join(getCodeGraphDir(projectPath), 'codegraph.lock');
  1072. if (!fs.existsSync(lockPath)) {
  1073. info('No lock file found — nothing to do');
  1074. return;
  1075. }
  1076. fs.unlinkSync(lockPath);
  1077. success('Removed lock file. You can now run indexing again.');
  1078. } catch (err) {
  1079. error(`Failed to remove lock: ${err instanceof Error ? err.message : String(err)}`);
  1080. process.exit(1);
  1081. }
  1082. });
  1083. /**
  1084. * codegraph affected [files...]
  1085. *
  1086. * Find test files affected by the given source files.
  1087. * Traces dependency edges transitively to find test files that depend on changed code.
  1088. *
  1089. * Usage:
  1090. * git diff --name-only | codegraph affected --stdin
  1091. * codegraph affected src/lib/components/Editor.svelte src/routes/+page.svelte
  1092. */
  1093. program
  1094. .command('affected [files...]')
  1095. .description('Find test files affected by changed source files')
  1096. .option('-p, --path <path>', 'Project path')
  1097. .option('--stdin', 'Read file list from stdin (one per line)')
  1098. .option('-d, --depth <number>', 'Max dependency traversal depth', '5')
  1099. .option('-f, --filter <glob>', 'Custom glob filter for test files (e.g. "e2e/*.spec.ts")')
  1100. .option('-j, --json', 'Output as JSON')
  1101. .option('-q, --quiet', 'Only output file paths, no decoration')
  1102. .action(async (fileArgs: string[], options: { path?: string; stdin?: boolean; depth?: string; filter?: string; json?: boolean; quiet?: boolean }) => {
  1103. const projectPath = resolveProjectPath(options.path);
  1104. try {
  1105. if (!isInitialized(projectPath)) {
  1106. error(`CodeGraph not initialized in ${projectPath}`);
  1107. process.exit(1);
  1108. }
  1109. // Collect changed files from args or stdin
  1110. let changedFiles: string[] = [...(fileArgs || [])];
  1111. if (options.stdin) {
  1112. const stdinData = fs.readFileSync(0, 'utf-8');
  1113. const stdinFiles = stdinData.split('\n').map(f => f.trim()).filter(Boolean);
  1114. changedFiles.push(...stdinFiles);
  1115. }
  1116. if (changedFiles.length === 0) {
  1117. if (!options.quiet) info('No files provided. Use file arguments or --stdin.');
  1118. process.exit(0);
  1119. }
  1120. const { default: CodeGraph } = await loadCodeGraph();
  1121. const cg = await CodeGraph.open(projectPath);
  1122. const maxDepth = parseInt(options.depth || '5', 10);
  1123. // Common test file patterns
  1124. const defaultTestPatterns = [
  1125. /\.spec\./,
  1126. /\.test\./,
  1127. /\/__tests__\//,
  1128. /\/tests?\//,
  1129. /\/e2e\//,
  1130. /\/spec\//,
  1131. ];
  1132. // Custom filter pattern
  1133. let customFilter: RegExp | null = null;
  1134. if (options.filter) {
  1135. // Convert glob to regex: ** → .+, * → [^/]*, . → \.
  1136. const regex = options.filter
  1137. .replace(/[+[\]{}()^$|\\]/g, '\\$&')
  1138. .replace(/\./g, '\\.')
  1139. .replace(/\*\*/g, '.+')
  1140. .replace(/\*/g, '[^/]*');
  1141. customFilter = new RegExp(regex);
  1142. }
  1143. function isTestFile(filePath: string): boolean {
  1144. if (customFilter) return customFilter.test(filePath);
  1145. return defaultTestPatterns.some(p => p.test(filePath));
  1146. }
  1147. // BFS to find all transitive dependents of changed files, filtered to test files
  1148. const affectedTests = new Set<string>();
  1149. const allDependents = new Set<string>();
  1150. for (const file of changedFiles) {
  1151. // If the changed file is itself a test file, include it
  1152. if (isTestFile(file)) {
  1153. affectedTests.add(file);
  1154. continue;
  1155. }
  1156. // BFS through dependents
  1157. const queue: Array<{ file: string; depth: number }> = [{ file, depth: 0 }];
  1158. const visited = new Set<string>();
  1159. visited.add(file);
  1160. while (queue.length > 0) {
  1161. const current = queue.shift()!;
  1162. if (current.depth >= maxDepth) continue;
  1163. const dependents = cg.getFileDependents(current.file);
  1164. for (const dep of dependents) {
  1165. if (visited.has(dep)) continue;
  1166. visited.add(dep);
  1167. allDependents.add(dep);
  1168. if (isTestFile(dep)) {
  1169. affectedTests.add(dep);
  1170. } else {
  1171. queue.push({ file: dep, depth: current.depth + 1 });
  1172. }
  1173. }
  1174. }
  1175. }
  1176. const sortedTests = Array.from(affectedTests).sort();
  1177. // Output
  1178. if (options.json) {
  1179. console.log(JSON.stringify({
  1180. changedFiles,
  1181. affectedTests: sortedTests,
  1182. totalDependentsTraversed: allDependents.size,
  1183. }, null, 2));
  1184. } else if (options.quiet) {
  1185. for (const t of sortedTests) console.log(t);
  1186. } else {
  1187. if (sortedTests.length === 0) {
  1188. info('No test files affected by the changed files.');
  1189. } else {
  1190. console.log(chalk.bold(`\nAffected test files (${sortedTests.length}):\n`));
  1191. for (const t of sortedTests) {
  1192. console.log(' ' + chalk.cyan(t));
  1193. }
  1194. console.log();
  1195. }
  1196. }
  1197. cg.destroy();
  1198. } catch (err) {
  1199. error(`Affected analysis failed: ${err instanceof Error ? err.message : String(err)}`);
  1200. process.exit(1);
  1201. }
  1202. });
  1203. /**
  1204. * codegraph install
  1205. */
  1206. program
  1207. .command('install')
  1208. .description('Run interactive installer for Claude Code integration')
  1209. .action(async () => {
  1210. const { runInstaller } = await import('../installer');
  1211. await runInstaller();
  1212. });
  1213. // Parse and run
  1214. program.parse();
  1215. } // end main()