• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MohamedGamil / MapX / 26731444457

01 Jun 2026 02:06AM UTC coverage: 88.416% (+8.5%) from 79.877%
26731444457

push

github

MohamedGamil
fix: update worker initialization to use 'tsx/esm/api' for improved module loading

3466 of 4316 branches covered (80.31%)

Branch coverage included in aggregate %.

5800 of 6164 relevant lines covered (94.09%)

17.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.78
/src/cli.ts
1
import { Command } from 'commander';
2
import { resolve, join, dirname, relative, basename } from 'node:path';
3
import { existsSync, readFileSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
4
import { createRequire } from 'node:module';
5
import { fileURLToPath } from 'node:url';
6
import * as readline from 'node:readline';
7
import { Store } from './core/store.js';
8
import { MapxGraph } from './core/graph.js';
9
import { Scanner, buildMatcher } from './core/scanner.js';
10
import { Config } from './core/config.js';
11
import { FlowTracer, TraceNode } from './core/flow-tracer.js';
12
import { ContextBuilder } from './core/context-builder.js';
13
import { AgentGenerator } from './agents/generator.js';
14
import { WorkspaceManager } from './core/workspace-manager.js';
15
import { LLMExporter } from './exporters/llm-exporter.js';
16
import { GraphExporter } from './exporters/graph-exporter.js';
17
import { DotExporter } from './exporters/dot-exporter.js';
18
import { SvgExporter } from './exporters/svg-exporter.js';
19
import { ToonExporter } from './exporters/toon-exporter.js';
20
import { calculateMetrics, calculateDSM } from './core/metrics.js';
21
import { getChangedFiles, isGitRepo } from './core/git-tracker.js';
22
import { ImpactAnalyzer, checkTryCatch as coreCheckTryCatch } from './core/impact-analyzer.js';
23
import { getBuiltinLanguages } from './languages/registry.js';
24
import { isLanguageInstalled, installLanguage, uninstallLanguage } from './languages/installer.js';
25
import type { ScanProgress, ProgressCallback } from './types.js';
26
import { RouteRegistry } from './frameworks/route-registry.js';
27
import { VERSION } from './version.js';
28
import { findSimilarSymbols, isGlobPattern, globToLike } from './core/fuzzy-matcher.js';
29
import picomatch from 'picomatch';
30
import * as clack from '@clack/prompts';
31

32
const dynamicRequire = createRequire(import.meta.url);
2✔
33

34
function collectPatterns(val: string, memo: string[]): string[] {
35
  return memo.concat(val.split(',').map(s => s.trim()));
4✔
36
}
37

38
function resolveDir(cmdOpts: Record<string, unknown>, programOpts: Record<string, unknown>): string {
39
  const raw = (cmdOpts.dir as string) || (programOpts.dir as string) || process.cwd();
174✔
40
  return resolve(raw);
174✔
41
}
42

43
/**
44
 * Resolve a file argument to one or more tracked file paths.
45
 *
46
 * Resolution order:
47
 *  1. Exact match in the tracked file set.
48
 *  2. Glob / wildcard match (*, ?) against all tracked paths — returns all matches.
49
 *  3. Substring / prefix match — returns all paths containing the input as a substring.
50
 *
51
 * Returns `null` when no match is found so callers can print a helpful error.
52
 */
53
function resolveFilePaths(input: string, allFiles: Array<{ path: unknown }>): string[] | null {
54
  const paths = allFiles.map(f => f.path as string);
8✔
55

56
  // 1. Exact match
57
  if (paths.includes(input)) return [input];
7✔
58

59
  // 2. Glob wildcard: *, ?, **
60
  if (isGlobPattern(input) || input.includes('/')) {
3✔
61
    const matchBase = !input.includes('/');
1✔
62
    const isMatch = picomatch(input, { dot: true, nocase: true, matchBase });
1✔
63
    const matches = paths.filter(p => isMatch(p) || p.endsWith(input) || p.includes(input));
2!
64
    if (matches.length > 0) return matches;
1!
65
  }
66

67
  // 3. Substring fallback
68
  const lower = input.toLowerCase();
2✔
69
  const substr = paths.filter(p => p.toLowerCase().includes(lower));
2✔
70
  if (substr.length > 0) return substr;
2✔
71

72
  return null;
1✔
73
}
74

75
const PHASE_LABELS: Record<ScanProgress['phase'], { active: string; done: string }> = {
2✔
76
  discover: { active: 'Discovering files', done: 'Discovered files' },
77
  index: { active: 'Indexing files', done: 'Indexed files' },
78
  parse: { active: 'Parsing files', done: 'Parsed files' },
79
  resolve: { active: 'Resolving references', done: 'Resolved references' },
80
  detect: { active: 'Detecting changes', done: 'Detected changes' },
81
  cluster: { active: 'Detecting clusters', done: 'Detected clusters' },
82
};
83

84
interface ProgressRendererCallback {
85
  (progress: ScanProgress): void;
86
  stop: (title?: string) => void;
87
}
88

89
function truncatePath(path: string, maxLength: number): string {
90
  if (path.length <= maxLength) return path;
23✔
91
  if (maxLength <= 3) return '...';
17!
92
  return '...' + path.slice(path.length - maxLength + 3);
17✔
93
}
94

95
function createProgressRenderer(): ProgressRendererCallback {
96
  let lastPhase: ScanProgress['phase'] | null = null;
20✔
97
  let p: any = null;
20✔
98
  let s: any = null;
20✔
99
  let lastCurrent = 0;
20✔
100

101
  const callback = (progressData: ScanProgress) => {
20✔
102
    const { phase, current, total, file } = progressData;
51✔
103
    const label = PHASE_LABELS[phase];
51✔
104
    if (!label) return;
51!
105
    const isNewPhase = phase !== lastPhase;
51✔
106

107
    if (isNewPhase) {
51✔
108
      if (s) {
28✔
109
        const prevLabel = lastPhase ? PHASE_LABELS[lastPhase] : null;
11!
110
        s.stop(prevLabel ? `✔ ${prevLabel.done}` : '✔ Done');
11!
111
        s = null;
11✔
112
      }
113
      if (p) {
28✔
114
        const prevLabel = lastPhase ? PHASE_LABELS[lastPhase] : null;
6!
115
        p.stop(prevLabel ? `✔ ${prevLabel.done}` : '✔ Done');
6!
116
        p = null;
6✔
117
      }
118

119
      lastPhase = phase;
28✔
120
      lastCurrent = 0;
28✔
121

122
      if (total > 0) {
28✔
123
        p = clack.progress({
11✔
124
          style: 'heavy',
125
          max: total,
126
          size: 40,
127
        });
128
        p.start(label.active);
11✔
129
      } else {
130
        s = clack.spinner();
17✔
131
        s.start(label.active);
17✔
132
      }
133
    }
134

135
    const cols = process.stdout.columns || 80;
51✔
136
    let fileLabel = '';
51✔
137
    if (file) {
51✔
138
      const prefixText = p 
23✔
139
        ? `${label.active} (${current}/${total}) - `
140
        : `${label.active} (${current}) - `;
141
      const clackDecorationLength = p ? 45 : 5;
23✔
142
      const reserved = prefixText.length + clackDecorationLength;
23✔
143
      const maxFileLen = Math.max(10, cols - reserved - 3);
23✔
144
      fileLabel = ` - ${truncatePath(file, maxFileLen)}`;
23✔
145
    }
146

147
    if (p) {
51✔
148
      const diff = current - lastCurrent;
28✔
149
      const msg = `${label.active} (${current}/${total})${fileLabel}`;
28✔
150
      if (diff > 0) {
28✔
151
        p.advance(diff, msg);
17✔
152
        lastCurrent = current;
17✔
153
      } else {
154
        p.message(msg);
11✔
155
      }
156
    } else if (s) {
23!
157
      s.message(`${label.active} (${current})${fileLabel}`);
23✔
158
    }
159
  };
160

161
  callback.stop = (title?: string) => {
20✔
162
    if (s) {
21✔
163
      const prevLabel = lastPhase ? PHASE_LABELS[lastPhase] : null;
6!
164
      s.stop(title || (prevLabel ? `✔ ${prevLabel.done}` : '✔ Done'));
6!
165
      s = null;
6✔
166
    }
167
    if (p) {
21✔
168
      const prevLabel = lastPhase ? PHASE_LABELS[lastPhase] : null;
5!
169
      p.stop(title || (prevLabel ? `✔ ${prevLabel.done}` : '✔ Done'));
5!
170
      p = null;
5✔
171
    }
172
  };
173

174
  return callback;
20✔
175
}
176

177
const MAPX_MARKER_START = '<!-- mapx -->';
2✔
178
const MAPX_MARKER_END = '<!-- /mapx -->';
2✔
179

180
function readStubContent(): string {
181
  try {
×
182
    const thisDir = dirname(fileURLToPath(import.meta.url));
×
183
    const stubPath = resolve(thisDir, 'agents.stub.md');
×
184
    return readFileSync(stubPath, 'utf-8');
×
185
  } catch {
186
    return [
×
187
      '# MapxGraph - LLM Integration Guide',
188
      '',
189
      'This project uses **MapxGraph** — a local code graph memory system that provides persistent, structured understanding of the codebase across LLM sessions.',
190
      '',
191
      '## Commands',
192
      '',
193
      'All commands accept a target directory. Three ways to specify:',
194
      '',
195
      '```bash',
196
      '# 1. Positional path argument',
197
      'mapx scan /path/to/project',
198
      '',
199
      '# 2. --dir / -d flag',
200
      'mapx scan --dir /path/to/project',
201
      'mapx query "MyClass" -d /path/to/project',
202
      '',
203
      '# 3. Global flag (works with any subcommand)',
204
      'mapx -d /path/to/project scan',
205
      '```',
206
      '',
207
      '```bash',
208
      'mapx init [/path]                                # First-time setup',
209
      'mapx uninit [/path]                              # Reverse installation',
210
      'mapx scan [/path]                                # Full scan (survives Ctrl+C)',
211
      'mapx update [/path]                              # Incremental update',
212
      'mapx export [--dir /path]                        # LLM summary (8K tokens)',
213
      'mapx export --format=json                        # Full JSON graph',
214
      'mapx export --format=dot                         # GraphViz DOT',
215
      'mapx export --format=svg                         # SVG visualization',
216
      'mapx export -o summary.txt                       # Export to file',
217
      'mapx export --format=svg -o graph.svg            # SVG to file',
218
      'mapx query <term>                                # Search symbols',
219
      'mapx deps <file>                                 # File dependencies',
220
      'mapx summary [/path]                             # Project summary',
221
      'mapx serve --dir /path                           # Start MCP server (stdio)',
222
      'mapx serve --sse --port 3456 --dir /path         # SSE (HTTP) transport',
223
      '```',
224
      '',
225
      '## MCP Tools',
226
      '',
227
      '- `mapx_scan` — Scan/update the code graph',
228
      '- `mapx_query` — Search symbols by name',
229
      '- `mapx_dependencies` — Get deps for a file',
230
      '- `mapx_export` — Export graph (llm, json, dot, svg)',
231
      '- `mapx_status` — Check scan status',
232
      '',
233
      '## When to Use',
234
      '',
235
      '1. Start of session: `mapx export`',
236
      '2. Find something: `mapx query <term>`',
237
      '3. Understand a file: `mapx deps <file>`',
238
      '4. Files changed: `mapx update`',
239
      '5. Major changes: `mapx scan`',
240
      '6. Visual overview: `mapx export --format=svg -o graph.svg`',
241
      '',
242
      '## Supported Languages',
243
      '',
244
      '- **PHP**: classes, methods, functions, interfaces, traits, enums, constants',
245
      '- **JavaScript**: classes, methods, functions, arrow functions',
246
      '- **TypeScript**: classes, methods, functions, interfaces, enums, type aliases, properties',
247
    ].join('\n');
248
  }
249
}
250

251
function generateAgentsBlock(): string {
252
  const content = readStubContent();
×
253
  return `${MAPX_MARKER_START}\n${content}\n${MAPX_MARKER_END}`;
×
254
}
255

256
function hasMarkers(content: string): boolean {
257
  return content.includes(MAPX_MARKER_START) && content.includes(MAPX_MARKER_END);
×
258
}
259

260
function replaceBetweenMarkers(existing: string, block: string): string {
261
  const startIdx = existing.indexOf(MAPX_MARKER_START);
×
262
  const endIdx = existing.indexOf(MAPX_MARKER_END);
×
263
  if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
×
264
    return existing;
×
265
  }
266
  return existing.slice(0, startIdx) + block + existing.slice(endIdx + MAPX_MARKER_END.length);
×
267
}
268

269
async function selectProvidersInteractive(): Promise<string[]> {
270
  const generator = new AgentGenerator();
1✔
271
  const providers = generator.listProviders();
1✔
272

273
  const selected = await clack.multiselect({
1✔
274
    message: 'Which LLM/agent tools do you use in this project?',
275
    options: providers.map(p => ({ value: p, label: p })),
11✔
276
    required: false,
277
  });
278

279
  if (clack.isCancel(selected)) {
1!
280
    clack.cancel('Operation cancelled.');
1✔
281
    process.exit(0);
1✔
282
  }
283

284
  const result = selected as string[];
×
285
  return result.length === 0 ? ['generic'] : result;
×
286
}
287

288
export function buildCLI(): Command {
289
  const program = new Command();
237✔
290

291
  program
237✔
292
    .name('mapx')
293
    .description('Multi-language code graph memory system for LLMs')
294
    .version(VERSION)
295
    .option('-d, --dir <path>', 'Target project directory (default: current directory)');
296

297
function detectLaravel(workspaceRoot: string): boolean {
298
  const composerPath = join(workspaceRoot, 'composer.json');
6✔
299
  if (existsSync(composerPath)) {
6!
300
    try {
×
301
      const content = readFileSync(composerPath, 'utf-8');
×
302
      const composer = JSON.parse(content);
×
303
      if (composer.require && composer.require['laravel/framework']) {
×
304
        return true;
×
305
      }
306
    } catch {
307
      // ignore
308
    }
309
  }
310

311
  if (existsSync(join(workspaceRoot, 'artisan'))) {
6!
312
    return true;
×
313
  }
314

315
  if (existsSync(join(workspaceRoot, 'app', 'Http', 'Kernel.php'))) {
6!
316
    return true;
×
317
  }
318

319
  if (
6!
320
    existsSync(join(workspaceRoot, 'app')) &&
321
    existsSync(join(workspaceRoot, 'routes')) &&
322
    existsSync(join(workspaceRoot, 'config')) &&
323
    existsSync(join(workspaceRoot, 'database'))
324
  ) {
325
    return true;
×
326
  }
327

328
  return false;
6✔
329
}
330

331

332
async function confirmLaravelExcludes(noSuggestions: boolean): Promise<boolean> {
333
  if (noSuggestions) return false;
×
334
  if (!process.stdin.isTTY) {
×
335
    return true;
×
336
  }
337
  
338
  console.log('\nDetected Laravel project.');
×
339
  console.log('\nSuggested exclusions (recommended):');
×
340
  console.log('  ✓ database/migrations/**   (schema DDL — no app logic)');
×
341
  console.log('  ✓ database/seeders/**      (data fixtures)');
×
342
  console.log('  ✓ database/factories/**    (test data)');
×
343
  console.log('  ✓ storage/**               (runtime-generated)');
×
344
  console.log('  ✓ bootstrap/cache/**       (artisan-generated cache)');
×
345
  console.log('  ✓ public/**                (web assets)');
×
346
  console.log('  ✓ resources/views/**       (Blade templates — not yet supported)');
×
347
  console.log('  ✓ **/*.blade.php           (Blade files)');
×
348
  
349
  const answer = await clack.confirm({
×
350
    message: 'Add these to .mapx/config.json?',
351
    initialValue: true,
352
  });
353
  if (clack.isCancel(answer)) {
×
354
    clack.cancel('Operation cancelled.');
×
355
    process.exit(0);
×
356
  }
357
  return answer;
×
358
}
359

360
  program
237✔
361
    .command('init')
362
    .description('Initialize mapx for a project')
363
    .argument('[path]', 'Target directory')
364
    .option('--name <name>', 'Repository name')
365
    .option('--no-agents', 'Skip AGENTS.md creation')
366
    .option('--no-suggestions', 'Skip interactive framework suggestions')
367
    .option('--no-mcp-configs', 'Skip auto-generating MCP config files for detected agent tools')
368
    .option('--no-discover', 'Skip monorepo / nested-repo discovery step')
369
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
370
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
6✔
371
      const isLaravel = detectLaravel(dir);
6✔
372
      let shouldAddLaravelExcludes = false;
6✔
373
      if (isLaravel) {
6!
374
        shouldAddLaravelExcludes = await confirmLaravelExcludes(opts.suggestions === false);
×
375
      }
376
      const config = await Config.init(dir, opts.name as string | undefined, isLaravel, shouldAddLaravelExcludes);
6✔
377
      if (opts.agents !== false) {
6✔
378
        if (process.stdin.isTTY && opts.suggestions !== false) {
2!
379
          const selected = await selectProvidersInteractive();
×
380
          clack.log.step(`Generating integration files for: ${selected.join(', ')}...`);
×
381
          const generator = new AgentGenerator();
×
382
          const actions = generator.plan(selected, { dir });
×
383
          for (const action of actions) {
×
384
            generator.execute(action);
×
385
            clack.log.success(`Generated ${action.filename} (${action.status})`);
×
386
          }
387
        } else {
388
          const generator = new AgentGenerator();
2✔
389
          const actions = generator.plan(['generic'], { dir });
2✔
390
          for (const action of actions) {
2✔
391
            generator.execute(action);
2✔
392
            clack.log.success(`Generated ${action.filename} (${action.status})`);
2✔
393
          }
394
        }
395
      }
396
 
397
      // Auto-generate MCP config files for detected agent tools
398
      if (opts.mcpConfigs !== false) {
6✔
399
        const generator = new AgentGenerator();
2✔
400
        const detected = generator.detectAgentTools(dir);
2✔
401
        if (detected.length > 0) {
2✔
402
          const mcpActions = generator.generateMcpConfigs(detected, { dir });
1✔
403
          for (const action of mcpActions) {
1✔
404
            if (action.status === 'up_to_date') continue;
1!
405
            generator.executeMcpConfig(action);
1✔
406
            const verb = action.status === 'merge' ? 'merged into' : action.status === 'create' ? 'created' : 'updated';
1!
407
            clack.log.success(`MCP config ${verb} ${action.filename} (${action.tool})`);
1✔
408
          }
409
        }
410
      }
411
 
412
      // Auto-add .mapx/ to .gitignore
413
      const gitignorePath = join(dir, '.gitignore');
6✔
414
      const hasGitignore = existsSync(gitignorePath);
6✔
415
      const isGit = isGitRepo(dir);
6✔
416
      if (hasGitignore || isGit) {
6!
417
        const content = hasGitignore ? readFileSync(gitignorePath, 'utf-8') : '';
6!
418
        const lines = content.split('\n').map(l => l.trim());
294✔
419
        if (!lines.includes('.mapx/') && !lines.includes('.mapx')) {
6!
420
          const entry = content.length > 0 && !content.endsWith('\n') ? '\n.mapx/\n' : '.mapx/\n';
×
421
          writeFileSync(gitignorePath, content + entry);
×
422
          clack.log.success(`Added .mapx/ to .gitignore`);
×
423
        }
424
      }
425

426
      // Monorepo / nested-repo discovery step (default on, disable with --no-discover)
427
      if (opts.discover !== false && process.stdin.isTTY) {
6!
428
        const nestedRepos = WorkspaceManager.discoverNestedGitRepos(dir);
×
429
        const monoPkgs = WorkspaceManager.discoverMonorepoPackages(dir);
×
430
        const registeredPaths = new Set(config.repos.map(r => resolve(dir, r.path)));
×
431
        const newNested = nestedRepos.filter(n => !registeredPaths.has(resolve(dir, n.path)));
×
432
        const newMono   = monoPkgs.filter(p => !registeredPaths.has(resolve(dir, p.path)));
×
433

434
        if (newNested.length > 0 || newMono.length > 0) {
×
435
          const totalFound = newNested.length + newMono.length;
×
436
          clack.log.info(`Found ${totalFound} additional package${totalFound === 1 ? '' : 's'} / nested repo${totalFound === 1 ? '' : 's'} in this workspace.`);
×
437

438
          const shouldDiscover = await clack.confirm({
×
439
            message: `Discover and register them now?`,
440
            initialValue: true,
441
          });
442

443
          if (!clack.isCancel(shouldDiscover) && shouldDiscover) {
×
444
            const toRegister: Array<{ name: string; path: string }> = [];
×
445

446
            if (newNested.length > 0) {
×
447
              const chosenNested = await clack.multiselect({
×
448
                message: `Select nested git repositories to register (${newNested.length} found):`,
449
                options: newNested.map(n => ({ value: n.path, label: `${n.name}  (${n.path})` })),
×
450
                required: false,
451
              });
452
              if (!clack.isCancel(chosenNested)) {
×
453
                for (const p of chosenNested as string[]) {
×
454
                  const n = newNested.find(x => x.path === p)!;
×
455
                  toRegister.push({ name: n.name, path: n.path });
×
456
                }
457
              }
458
            }
459

460
            if (newMono.length > 0) {
×
461
              const mgr = newMono[0].packageManager;
×
462
              const chosenMono = await clack.multiselect({
×
463
                message: `Select monorepo packages to register [${mgr}] (${newMono.length} found):`,
464
                options: newMono.map(p => ({ value: p.path, label: `${p.name}  (${p.path})` })),
×
465
                required: false,
466
              });
467
              if (!clack.isCancel(chosenMono)) {
×
468
                for (const p of chosenMono as string[]) {
×
469
                  const pkg = newMono.find(x => x.path === p);
×
470
                  if (pkg) toRegister.push({ name: pkg.name, path: pkg.path });
×
471
                }
472
              }
473
            }
474

475
            if (toRegister.length > 0) {
×
476
              for (const item of toRegister) {
×
477
                config.addRepo(item.name, item.path);
×
478
                clack.log.success(`Registered: ${item.name} -> ${item.path}`);
×
479
              }
480
              await config.save();
×
481
            }
482
          }
483
        }
484
      }
485

486
      clack.log.success(`Initialized mapx in ${dir}/.mapx/`);
6✔
487
      clack.log.info(`Repo: ${config.repo.name}`);
6✔
488
    });
489

490
  program
237✔
491
    .command('uninit')
492
    .description('Remove .mapx/ directory and reverse project integration changes')
493
    .argument('[path]', 'Target directory')
494
    .option('-f, --force', 'Skip confirmation prompt')
495
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
496
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
2✔
497
      const hasMapx = existsSync(join(dir, '.mapx'));
2✔
498

499
      if (!opts.force && process.stdin.isTTY) {
2!
500
        const answer = await clack.confirm({
×
501
          message: `Are you sure you want to remove .mapx/ and reverse all mapx integrations in ${dir}?`,
502
          initialValue: false,
503
        });
504
        if (clack.isCancel(answer) || !answer) {
×
505
          clack.cancel('Aborted.');
×
506
          return;
×
507
        }
508
      }
509

510
      // 1. Revert LLM agent integrations
511
      const generator = new AgentGenerator();
2✔
512
      generator.revert({ dir });
2✔
513

514
      // 2. Remove mapx entries from MCP config files
515
      generator.revertMcpConfigs({ dir });
2✔
516

517
      // 3. Remove .mapx/ from .gitignore
518
      const gitignorePath = join(dir, '.gitignore');
2✔
519
      if (existsSync(gitignorePath)) {
2!
520
        try {
2✔
521
          const content = readFileSync(gitignorePath, 'utf-8');
2✔
522
          const lines = content.split('\n');
2✔
523
          let removed = false;
2✔
524
          const filteredLines = lines.filter(line => {
2✔
525
            const trimmed = line.trim();
98✔
526
            if (trimmed === '.mapx' || trimmed === '.mapx/') {
98✔
527
              removed = true;
2✔
528
              return false;
2✔
529
            }
530
            return true;
96✔
531
          });
532
          if (removed) {
2!
533
            writeFileSync(gitignorePath, filteredLines.join('\n'), 'utf-8');
2✔
534
            clack.log.success(`Removed .mapx/ from .gitignore`);
2✔
535
          }
536
        } catch (err: any) {
537
          clack.log.error(`Failed to update .gitignore: ${err.message}`);
×
538
        }
539
      }
540

541
      // 4. Delete .mapx/ directory
542
      if (hasMapx) {
2!
543
        try {
2✔
544
          rmSync(join(dir, '.mapx'), { recursive: true, force: true });
2✔
545
          clack.log.success(`Removed .mapx/ directory`);
2✔
546
        } catch (err: any) {
547
          clack.log.error(`Failed to remove .mapx/ directory: ${err.message}`);
×
548
        }
549
      }
550

551
      clack.log.success(`Successfully uninitialized mapx for project: ${dir}`);
2✔
552
    });
553

554

555
  program
237✔
556
    .command('scan')
557
    .description('Full scan: parse all files, build graph')
558
    .argument('[path]', 'Target directory')
559
    .option('--exclude <glob>', 'Exclude glob pattern(s)', collectPatterns, [])
560
    .option('--include <glob>', 'Include glob pattern(s)', collectPatterns, [])
561
    .option('--repo <name>', 'Scan only a specific registered repository')
562
    .option('--all', 'Scan all registered repositories')
563
    .option('--force', 'Force re-parsing of all files (bypass cache)', false)
564
    .option('--no-parallel', 'Disable parallel parsing (use single thread)')
565
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
566
      const dir = path ? resolve(path) : resolveDir({}, program.opts());
10✔
567
      const { config, store, graph } = await loadContext(dir);
10✔
568

569
      const onProgress = createProgressRenderer();
9✔
570
      const scanner = new Scanner(store, config, graph, onProgress, {
9✔
571
        excludes: opts.exclude as string[],
572
        includes: opts.include as string[],
573
        parallel: opts.parallel as boolean | undefined,
574
      });
575

576
      const onSigInt = () => {
9✔
577
        scanner.abort();
1✔
578
        onProgress.stop('Canceled');
1✔
579
        process.stderr.write('\n');
1✔
580
      };
581
      process.once('SIGINT', onSigInt);
9✔
582

583
      let repoNames: string[] | undefined = undefined;
9✔
584
      if (opts.repo) {
9✔
585
        repoNames = [opts.repo as string];
1✔
586
      } else if (opts.all) {
8✔
587
        repoNames = ['all'];
1✔
588
      }
589

590
      const result = await scanner.scanFull(repoNames, { force: !!opts.force }).catch((err: Error) => {
9✔
591
        onProgress.stop();
2✔
592
        if (err.message.includes('Another scan is already running')) {
2✔
593
          clack.log.error(`Error: ${err.message}`);
1✔
594
          process.exit(1);
1✔
595
        }
596
        throw err;
1✔
597
      });
598

599
      process.removeListener('SIGINT', onSigInt);
7✔
600
      onProgress.stop();
7✔
601

602
      if (result.interrupted) {
7✔
603
        clack.log.warn(`Scan interrupted after ${result.filesScanned}/${result.totalFiles} files. Progress saved — run \`scan\` again to resume.`);
2✔
604
      } else {
605
        clack.log.success(`Scanned ${result.filesScanned} files in ${result.durationMs}ms`);
5✔
606
      }
607
      clack.log.info(`Languages: ${Object.entries(result.languageBreakdown).map(([l, c]) => `${l}: ${c}`).join(', ')}`);
7✔
608
      clack.log.info(`Found ${result.symbolsFound} symbols, ${result.edgesFound} edges`);
7✔
609
    });
610

611
  program
237✔
612
    .command('update')
613
    .alias('sync')
614
    .description('Incremental scan: re-scan only changed files')
615
    .argument('[path]', 'Target directory')
616
    .option('--exclude <glob>', 'Exclude glob pattern(s)', collectPatterns, [])
617
    .option('--include <glob>', 'Include glob pattern(s)', collectPatterns, [])
618
    .option('--repo <name>', 'Update only a specific registered repository')
619
    .option('--all', 'Update all registered repositories')
620
    .option('--no-parallel', 'Disable parallel parsing (use single thread)')
621
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
622
      const dir = path ? resolve(path) : resolveDir({}, program.opts());
8✔
623
      const { config, store, graph } = await loadContext(dir);
8✔
624
      const onProgress = createProgressRenderer();
8✔
625

626
      const handleLockError = (err: Error): never => {
8✔
627
        if (err.message.includes('Another scan is already running')) {
2✔
628
          console.error(`Error: ${err.message}`);
1✔
629
          process.exit(1);
1✔
630
        }
631
        throw err;
1✔
632
      };
633

634
      let repoNames: string[] | undefined = undefined;
8✔
635
      if (opts.repo) {
8✔
636
        repoNames = [opts.repo as string];
1✔
637
      } else if (opts.all) {
7✔
638
        repoNames = ['all'];
1✔
639
      }
640

641
      const scanner = new Scanner(store, config, graph, onProgress, {
8✔
642
        excludes: opts.exclude as string[],
643
        includes: opts.include as string[],
644
        parallel: opts.parallel as boolean | undefined,
645
      });
646
      const onSigInt = () => {
8✔
647
        scanner.abort();
×
648
        onProgress.stop('Canceled');
×
649
      };
650
      process.once('SIGINT', onSigInt);
8✔
651
      const result = await scanner.scanIncremental(repoNames).catch((err: Error) => {
8✔
652
        onProgress.stop();
2✔
653
        handleLockError(err);
2✔
654
        throw err;
2✔
655
      });
656

657
      process.removeListener('SIGINT', onSigInt);
6✔
658
      onProgress.stop();
6✔
659
      if (result.interrupted) {
6✔
660
        clack.log.warn(`Update interrupted after ${result.filesScanned} files.`);
1✔
661
      } else {
662
        clack.log.success(`Updated ${result.filesScanned} files in ${result.durationMs}ms`);
5✔
663
        clack.log.info(`${result.symbolsFound} symbols updated, ${result.edgesFound} edges updated`);
5✔
664
      }
665
    });
666

667
  program
237✔
668
    .command('status')
669
    .description('Show scan status, collected metrics, and changed files')
670
    .argument('[path]', 'Target directory')
671
    .option('--exclude <glob>', 'Exclude glob pattern(s)', collectPatterns, [])
672
    .option('--include <glob>', 'Include glob pattern(s)', collectPatterns, [])
673
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
674
      const dir = path ? resolve(path) : resolveDir({}, program.opts());
4✔
675
      const { config, store, graph } = await loadContext(dir);
4✔
676

677
      const lastScan    = store.getMeta('last_scan_time:' + config.repo.name) || store.getMeta('last_scan_time');
4✔
678
      const lastCommit  = store.getMeta('last_scan_commit:' + config.repo.name) || store.getMeta('last_scan_commit');
4✔
679
      const schemaVer   = store.getMeta('schema_version');
4✔
680
      const dbPath      = resolve(dir, '.mapx', 'mapx.db');
4✔
681

682
      const activeExcludes = [
4✔
683
        ...(config.settings.excludePatterns ?? []),
4!
684
        ...((opts.exclude as string[]) ?? []),
4!
685
      ];
686
      const activeIncludes = [
4✔
687
        ...(config.settings.includePatterns ?? []),
5✔
688
        ...((opts.include as string[]) ?? []),
4!
689
      ];
690

691
      // ── Scan info ──────────────────────────────────────────────────────
692
      console.log('\n── Scan ─────────────────────────────────────────────');
4✔
693
      console.log(`  Project:     ${config.repo.name}`);
4✔
694
      console.log(`  Framework:   ${config.repo.framework || 'generic'}`);
4✔
695
      console.log(`  Directory:   ${dir}`);
4✔
696
      console.log(`  Last scan:   ${lastScan  || 'never'}`);
4✔
697
      console.log(`  Last commit: ${lastCommit || 'none'}`);
4✔
698
      console.log(`  Schema:      v${schemaVer || '?'}`);
4!
699
      console.log(`  Excludes:    [${activeExcludes.join(', ')}]`);
4✔
700
      console.log(`  Includes:    [${activeIncludes.join(', ')}]`);
4✔
701

702
      // ── Collected data ─────────────────────────────────────────────────
703
      const fileCount   = store.getFileCount();
4✔
704
      const symbolCount = store.getSymbolCount();
4✔
705
      const edgeCount   = store.getEdgeCount();
4✔
706
      const breakdown   = store.getLanguageBreakdown();
4✔
707
      const verifiedEdgeCount = (store.raw.prepare("SELECT COUNT(*) as cnt FROM edges WHERE verifiability = 'verified'").get() as any)?.cnt || 0;
4✔
708
      const inferredEdgeCount = (store.raw.prepare("SELECT COUNT(*) as cnt FROM edges WHERE verifiability = 'inferred'").get() as any)?.cnt || 0;
4✔
709

710
      console.log('\n── Collected data ───────────────────────────────────');
4✔
711
      console.log(`  Files:       ${fileCount}`);
4✔
712
      console.log(`  Symbols:     ${symbolCount}`);
4✔
713
      console.log(`  Edges:       ${edgeCount} (verified: ${verifiedEdgeCount}, inferred: ${inferredEdgeCount})`);
4✔
714

715
      // Language breakdown
716
      const langs = Object.entries(breakdown).sort((a, b) => b[1] - a[1]);
4✔
717
      if (langs.length > 0) {
4✔
718
        console.log(`  Languages:`);
1✔
719
        for (const [lang, cnt] of langs) {
1✔
720
          console.log(`    ${lang.padEnd(14)} ${cnt} files`);
1✔
721
        }
722
      }
723

724
      // Symbol kind breakdown (direct SQL aggregation)
725
      const kindRows = store.raw.prepare(
4✔
726
        'SELECT kind, COUNT(*) as cnt FROM symbols GROUP BY kind ORDER BY cnt DESC'
727
      ).all() as Array<{ kind: string; cnt: number }>;
728
      if (kindRows.length > 0) {
4✔
729
        console.log(`  Symbol kinds:`);
1✔
730
        for (const row of kindRows) {
1✔
731
          console.log(`    ${row.kind.padEnd(14)} ${row.cnt}`);
1✔
732
        }
733
      }
734

735
      // Edge type breakdown
736
      const edgeTypeRows = store.raw.prepare(
4✔
737
        'SELECT edge_type, COUNT(*) as cnt FROM edges GROUP BY edge_type ORDER BY cnt DESC'
738
      ).all() as Array<{ edge_type: string; cnt: number }>;
739
      if (edgeTypeRows.length > 0) {
4!
740
        console.log(`  Edge types:`);
×
741
        for (const row of edgeTypeRows) {
×
742
          console.log(`    ${row.edge_type.padEnd(14)} ${row.cnt}`);
×
743
        }
744
      }
745

746
      // ── Graph metrics ──────────────────────────────────────────────────
747
      if (fileCount > 0) {
4✔
748
        const densityNum = edgeCount / Math.max(fileCount * (fileCount - 1), 1);
1✔
749
        const density = (densityNum * 100).toFixed(2);
1✔
750

751
        // Top 5 most-connected files (highest out-degree = most dependencies)
752
        const connRows = store.raw.prepare(`
1✔
753
          SELECT source_file, COUNT(*) as cnt FROM edges
754
          GROUP BY source_file ORDER BY cnt DESC LIMIT 5
755
        `).all() as Array<{ source_file: string; cnt: number }>;
756

757
        console.log('\n── Graph metrics ────────────────────────────────────');
1✔
758
        console.log(`  Density:     ${density}%`);
1✔
759
        const avgEdges = fileCount > 0 ? (edgeCount / fileCount).toFixed(1) : '0';
1!
760
        console.log(`  Avg edges/file: ${avgEdges}`);
1✔
761
        if (connRows.length > 0) {
1!
762
          console.log(`  Most connected files:`);
×
763
          for (const row of connRows) {
×
764
            const rel = row.source_file.replace(dir + '/', '');
×
765
            console.log(`    ${String(row.cnt).padStart(4)} edges  ${rel}`);
×
766
          }
767
        }
768
      }
769

770
      // ── Storage ────────────────────────────────────────────────────────
771
      try {
4✔
772
        const { statSync } = await import('node:fs');
4✔
773
        const dbSize = statSync(dbPath).size;
4✔
774
        const kb = (dbSize / 1024).toFixed(1);
4✔
775
        console.log('\n── Storage ──────────────────────────────────────────');
4✔
776
        console.log(`  Database:    ${kb} KB  (${dbPath})`);
4✔
777
      } catch { /* db may not exist yet */ }
778

779
      // ── PageRank Importance ──────────────────────────────
780
      console.log('\n── PageRank Importance ──────────────────────────────');
4✔
781
      const topFiles = store.getTopFilesByPageRank(graph, 5);
4✔
782
      const topSymbols = store.getTopSymbolsByPageRank(graph, 5);
4✔
783

784
      if (topFiles.length > 0) {
4✔
785
        console.log('  Top files by PageRank:');
1✔
786
        for (const tf of topFiles) {
1✔
787
          console.log(`    ${tf.pagerank.toFixed(6)}  ${tf.path}`);
1✔
788
        }
789
      } else {
790
        console.log('  No ranked files (run a scan first)');
3✔
791
      }
792

793
      if (topSymbols.length > 0) {
4✔
794
        console.log('\n  Top symbols by PageRank:');
1✔
795
        for (const ts of topSymbols) {
1✔
796
          const scope = ts.scope ? `${ts.scope}::` : '';
1!
797
          console.log(`    ${ts.pagerank.toFixed(6)}  ${scope}${ts.name} (${ts.filePath})`);
1✔
798
        }
799
      } else {
800
        console.log('\n  No ranked symbols (run a scan first)');
3✔
801
      }
802

803
      // ── Git changes ────────────────────────────────────────────────────
804
      const repoRoot = resolve(dir, config.repo.path);
4✔
805
      let isStale = false;
4✔
806
      if (!isGitRepo(repoRoot)) {
4!
807
        console.log('\n── Git ──────────────────────────────────────────────');
×
808
        console.log('  Not a git repository');
×
809
      } else {
810
        const changes = getChangedFiles(repoRoot, lastCommit || undefined);
4✔
811
        console.log('\n── Git changes since last scan ──────────────────────');
4✔
812
        if (changes.length === 0) {
4✔
813
          console.log('  No changes since last scan  (✓ index is current)');
3✔
814
        } else {
815
          isStale = true;
1✔
816
          const byStatus = { added: 0, modified: 0, removed: 0, renamed: 0, unchanged: 0 };
1✔
817
          for (const c of changes) byStatus[c.status] = (byStatus[c.status] || 0) + 1;
3✔
818
          const summary = (Object.entries(byStatus) as Array<[string, number]>)
1✔
819
            .filter(([, n]) => n > 0)
5✔
820
            .map(([s, n]) => `${n} ${s}`)
3✔
821
            .join(', ');
822
          console.log(`  ${changes.length} changed files  (${summary})  (⚠ stale)`);
1✔
823
          const icon = { added: '+', modified: '~', removed: '-', renamed: '>', unchanged: '=' };
1✔
824
          for (const change of changes) {
1✔
825
            console.log(`    ${icon[change.status]} ${change.path}`);
3✔
826
          }
827
        }
828
      }
829

830
      console.log('\n── Recommendations ──────────────────────────────────');
4✔
831
      if (isStale) {
4✔
832
        console.log('  ⚠ Index is stale. Run `mapx sync` or `mapx update` to bring it up to date.');
1✔
833
      } else {
834
        console.log('  ✓ Index is up to date.');
3✔
835
      }
836
      console.log('');
4✔
837
    });
838

839
  program
237✔
840
    .command('query <term>')
841
    .description('Search for symbols by name (supports glob patterns: *Service, get*)')
842
    .option('-d, --dir <path>', 'Target directory')
843
    .action(async (term: string, opts: Record<string, unknown>) => {
844
      const dir = resolveDir(opts, program.opts());
8✔
845
      const { store } = await loadContext(dir);
8✔
846
      checkAndPrintStaleness(store, dir);
8✔
847

848
      const results = store.searchSymbols(term);
8✔
849
      if (results.length === 0) {
8✔
850
        // Fuzzy fallback: suggest similar symbol names
851
        const candidates = store.getSymbolCandidatesForFuzzy();
5✔
852
        const suggestions = findSimilarSymbols(term, candidates);
5✔
853
        console.log(`No symbols matching "${term}"`);
5✔
854
        if (suggestions.length > 0) {
5✔
855
          console.log(`\nDid you mean:`);
1✔
856
          for (const s of suggestions) {
1✔
857
            console.log(`  • ${s.name} (${s.kind} @ ${s.filePath})`);
1✔
858
          }
859
        }
860
        console.log(`\nTip: Use glob patterns like "${term.length > 3 ? term.slice(0, 4) : term}*" or "*" to list all.`);
5!
861
        return;
5✔
862
      }
863

864
      console.log(`Found ${results.length} symbol${results.length !== 1 ? 's' : ''} matching "${term}":\n`);
3!
865
      for (const sym of results) {
8✔
866
        const scope = sym.scope ? `${sym.scope}::` : '';
3!
867
        console.log(`  ${sym.kind} ${scope}${sym.name}`);
3✔
868
        console.log(`    @ ${sym.file_path}:${sym.start_line}`);
3✔
869
        if (sym.signature && sym.signature !== sym.name) {
3✔
870
          console.log(`    signature: ${sym.signature}`);
2✔
871
        }
872
      }
873
    });
874

875
  program
237✔
876
    .command('search <term>')
877
    .description('Symbol search with kind/file/exact filters and glob patterns (*, ?)')
878
    .option('-d, --dir <path>', 'Target directory')
879
    .option('--kind <kind>', 'Filter by symbol kind (class, method, function, interface, trait, constant, enum, property, namespace, struct, module)')
880
    .option('--file <prefix>', 'Filter by file path prefix')
881
    .option('--exact', 'Only match exact name (case-insensitive)', false)
882
    .option('--limit <limit>', 'Max results to return', '20')
883
    .option('--format <format>', 'Output format: text | json', 'text')
884
    .action(async (term: string, opts: Record<string, unknown>) => {
885
      const dir = resolveDir(opts, program.opts());
9✔
886
      const { store, graph } = await loadContext(dir);
9✔
887
      checkAndPrintStaleness(store, dir);
9✔
888

889
      const kind = opts.kind as string | undefined;
9✔
890
      const filePrefix = opts.file as string | undefined;
9✔
891
      const exact = !!opts.exact;
9✔
892
      const limit = parseInt(opts.limit as string, 10);
9✔
893
      const format = (opts.format as string) || 'text';
9!
894

895
      let results = store.searchSymbolsFiltered({ term, kind, filePrefix, exact, limit });
9✔
896
      let broadened = false;
9✔
897

898
      // Auto-expand: if kind filter yields 0 results, retry without kind
899
      if (results.length === 0 && kind && term !== '*' && term !== '') {
9✔
900
        const withoutKind = store.searchSymbolsFiltered({ term, filePrefix, exact, limit });
2✔
901
        if (withoutKind.length > 0) {
2✔
902
          results = withoutKind;
1✔
903
          broadened = true;
1✔
904
        }
905
      }
906

907
      if (results.length === 0) {
9✔
908
        // Fuzzy fallback
909
        console.log(`No symbols matching "${term}"`);
3✔
910
        if (kind) {
3✔
911
          const kinds = store.listSymbolKinds();
1✔
912
          console.log(`\nAvailable kinds:`);
1✔
913
          for (const k of kinds) console.log(`  ${k.kind}: ${k.count} symbols`);
1✔
914
        }
915
        if (term !== '*' && term !== '') {
3!
916
          const candidates = store.getSymbolCandidatesForFuzzy();
3✔
917
          const suggestions = findSimilarSymbols(term, candidates);
3✔
918
          if (suggestions.length > 0) {
3✔
919
            console.log(`\nDid you mean:`);
1✔
920
            for (const s of suggestions) {
1✔
921
              console.log(`  • ${s.name} (${s.kind} @ ${s.filePath})`);
1✔
922
            }
923
          }
924
          console.log(`\nTip: Use term="*" to list all, or glob patterns like "${term.length > 3 ? term.slice(0, 4) : term}*"`);
3!
925
        }
926
        return;
3✔
927
      }
928

929
      const rankMap = new Map<string, number>();
6✔
930
      if (results.length > 0) {
6!
931
        const filePaths = Array.from(new Set(results.map(r => r.file_path)));
6✔
932
        const placeholders = filePaths.map(() => '?').join(',');
6✔
933
        const fileRanks = store.raw.prepare(`
6✔
934
          SELECT path, pagerank FROM files WHERE path IN (${placeholders})
935
        `).all(...filePaths) as any[];
936
        const fileRankMap = new Map<string, number>(fileRanks.map(f => [f.path, f.pagerank]));
6✔
937
        for (const sym of results) {
6✔
938
          const parentFileRank = fileRankMap.get(sym.file_path) || 0;
6✔
939
          const symbolRank = parentFileRank * (1 + (sym.scope ? 2 : 1) * 0.1);
6!
940
          rankMap.set(`${sym.file_path}::${sym.name}`, symbolRank);
6✔
941
        }
942
      }
943

944
      // JSON format
945
      if (format === 'json') {
6✔
946
        const jsonResults = results.map(sym => ({
3✔
947
          name: sym.name,
948
          kind: sym.kind,
949
          scope: sym.scope || null,
6✔
950
          file: sym.file_path,
951
          line: sym.start_line,
952
          endLine: sym.end_line,
953
          signature: sym.signature || '',
4✔
954
          pagerank: rankMap.get(`${sym.file_path}::${sym.name}`) || 0,
6✔
955
        }));
956
        console.log(JSON.stringify({ total: results.length, broadened, term, kind: kind || null, results: jsonResults }, null, 2));
3✔
957
        return;
3✔
958
      }
959

960
      // Summary header
961
      let header = `Found ${results.length} symbol${results.length !== 1 ? 's' : ''}`;
3!
962
      if (kind && !broadened) header += ` of kind "${kind}"`;
9!
963
      if (filePrefix) header += ` in ${filePrefix}`;
3!
964
      if (broadened) header += ` (broadened from kind="${kind}" which had 0 results)`;
3✔
965
      console.log(header + ':\n');
3✔
966

967
      for (const sym of results) {
3✔
968
        const scope = sym.scope ? `${sym.scope}::` : '';
3!
969
        const key = `${sym.file_path}::${sym.name}`;
3✔
970
        const pagerankVal = rankMap.get(key) || 0;
3✔
971
        console.log(`  ${sym.kind} ${scope}${sym.name} [pagerank: ${pagerankVal.toFixed(6)}]`);
3✔
972
        console.log(`    @ ${sym.file_path}:${sym.start_line}`);
3✔
973
        if (sym.signature && sym.signature !== sym.name) {
3✔
974
          console.log(`    signature: ${sym.signature}`);
2✔
975
        }
976
      }
977
    });
978

979
  program
237✔
980
    .command('callers <symbol>')
981
    .description('Show callers of a symbol')
982
    .option('-d, --dir <path>', 'Target directory')
983
    .option('--depth <depth>', 'Traversal depth', '1')
984
    .action(async (symbol: string, opts: Record<string, unknown>) => {
985
      const dir = resolveDir(opts, program.opts());
4✔
986
      const { store } = await loadContext(dir);
4✔
987
      checkAndPrintStaleness(store, dir);
4✔
988
      const maxDepth = parseInt(opts.depth as string, 10);
4✔
989

990
      const queue: Array<{ symName: string; depth: number }> = [{ symName: symbol, depth: 0 }];
4✔
991
      const visited = new Set<string>([symbol]);
4✔
992
      const results: Array<{ caller: string; callee: string; file: string; line: number; depth: number }> = [];
4✔
993

994
      while (queue.length > 0) {
4✔
995
        const { symName, depth } = queue.shift()!;
5✔
996
        if (depth >= maxDepth) continue;
5✔
997

998
        const callers = store.getCallersOfSymbol(symName);
4✔
999
        for (const edge of callers) {
4✔
1000
          const callerName = edge.source_symbol ? `${edge.source_symbol}` : '<top-level>';
1!
1001
          const calleeName = edge.target_symbol || symName;
1!
1002
          const meta = edge.metadata ? JSON.parse(edge.metadata) : {};
1!
1003

1004
          results.push({
1✔
1005
            caller: callerName,
1006
            callee: calleeName,
1007
            file: edge.source_file,
1008
            line: meta.startLine || 1,
1!
1009
            depth: depth + 1
1010
          });
1011

1012
          const nextSym = edge.source_symbol;
1✔
1013
          if (nextSym && !visited.has(nextSym)) {
1!
1014
            visited.add(nextSym);
1✔
1015
            queue.push({ symName: nextSym, depth: depth + 1 });
1✔
1016
          }
1017
        }
1018
      }
1019

1020
      if (results.length === 0) {
4✔
1021
        // Fuzzy fallback: check if symbol exists
1022
        const sym = store.getSymbolByName(symbol);
3✔
1023
        if (!sym) {
3✔
1024
          const candidates = store.getSymbolCandidatesForFuzzy();
2✔
1025
          const suggestions = findSimilarSymbols(symbol, candidates);
2✔
1026
          console.log(`Symbol "${symbol}" not found.`);
2✔
1027
          if (suggestions.length > 0) {
2✔
1028
            console.log(`\nDid you mean:`);
1✔
1029
            for (const s of suggestions) {
1✔
1030
              console.log(`  • ${s.name} (${s.kind} @ ${s.filePath})`);
1✔
1031
            }
1032
          }
1033
        } else {
1034
          console.log(`No callers found for "${symbol}"`);
1✔
1035
        }
1036
        return;
3✔
1037
      }
1038

1039
      console.log(`${results.length} caller${results.length !== 1 ? 's' : ''} of "${symbol}"${maxDepth > 1 ? ` (depth ${maxDepth})` : ''}:`);
1!
1040
      for (const res of results) {
4✔
1041
        const indent = '  '.repeat(res.depth);
1✔
1042
        console.log(`${indent}← ${res.caller} (calls ${res.callee})`);
1✔
1043
        console.log(`${indent}  @ ${res.file}:${res.line}`);
1✔
1044
      }
1045
    });
1046

1047
  program
237✔
1048
    .command('callees <symbol>')
1049
    .description('Show callees of a symbol')
1050
    .option('-d, --dir <path>', 'Target directory')
1051
    .option('--depth <depth>', 'Traversal depth', '1')
1052
    .action(async (symbol: string, opts: Record<string, unknown>) => {
1053
      const dir = resolveDir(opts, program.opts());
4✔
1054
      const { store } = await loadContext(dir);
4✔
1055
      checkAndPrintStaleness(store, dir);
4✔
1056
      const maxDepth = parseInt(opts.depth as string, 10);
4✔
1057

1058
      const queue: Array<{ symName: string; depth: number }> = [{ symName: symbol, depth: 0 }];
4✔
1059
      const visited = new Set<string>([symbol]);
4✔
1060
      const results: Array<{ caller: string; callee: string; file: string; line: number; depth: number }> = [];
4✔
1061

1062
      while (queue.length > 0) {
4✔
1063
        const { symName, depth } = queue.shift()!;
5✔
1064
        if (depth >= maxDepth) continue;
5✔
1065

1066
        const callees = store.getCalleesOfSymbol(symName);
4✔
1067
        for (const edge of callees) {
4✔
1068
          const calleeName = edge.target_symbol || '<unknown>';
1!
1069
          const callerName = edge.source_symbol || symName;
1!
1070
          const meta = edge.metadata ? JSON.parse(edge.metadata) : {};
1!
1071

1072
          results.push({
1✔
1073
            caller: callerName,
1074
            callee: calleeName,
1075
            file: edge.target_file,
1076
            line: meta.startLine || 1,
1!
1077
            depth: depth + 1
1078
          });
1079

1080
          if (edge.target_symbol && !visited.has(edge.target_symbol)) {
1!
1081
            visited.add(edge.target_symbol);
1✔
1082
            queue.push({ symName: edge.target_symbol, depth: depth + 1 });
1✔
1083
          }
1084
        }
1085
      }
1086

1087
      if (results.length === 0) {
4✔
1088
        // Fuzzy fallback: check if symbol exists
1089
        const sym = store.getSymbolByName(symbol);
3✔
1090
        if (!sym) {
3✔
1091
          const candidates = store.getSymbolCandidatesForFuzzy();
2✔
1092
          const suggestions = findSimilarSymbols(symbol, candidates);
2✔
1093
          console.log(`Symbol "${symbol}" not found.`);
2✔
1094
          if (suggestions.length > 0) {
2✔
1095
            console.log(`\nDid you mean:`);
1✔
1096
            for (const s of suggestions) {
1✔
1097
              console.log(`  • ${s.name} (${s.kind} @ ${s.filePath})`);
1✔
1098
            }
1099
          }
1100
        } else {
1101
          console.log(`No callees found for "${symbol}"`);
1✔
1102
        }
1103
        return;
3✔
1104
      }
1105

1106
      console.log(`${results.length} callee${results.length !== 1 ? 's' : ''} of "${symbol}"${maxDepth > 1 ? ` (depth ${maxDepth})` : ''}:`);
1!
1107
      for (const res of results) {
4✔
1108
        const indent = '  '.repeat(res.depth);
1✔
1109
        console.log(`${indent}→ ${res.callee} (called by ${res.caller})`);
1✔
1110
        console.log(`${indent}  @ ${res.file}:${res.line}`);
1✔
1111
      }
1112
    });
1113

1114
  program
237✔
1115
    .command('impact <symbol>')
1116
    .description('Show transitive blast-radius of changing a symbol')
1117
    .option('-d, --dir <path>', 'Target directory')
1118
    .option('--depth <depth>', 'Traversal depth', '3')
1119
    .option('--format <format>', 'text | json', 'text')
1120
    .action(async (symbol: string, opts: Record<string, unknown>) => {
1121
      const dir = resolveDir(opts, program.opts());
5✔
1122
      const { store } = await loadContext(dir);
5✔
1123
      checkAndPrintStaleness(store, dir);
5✔
1124
      const maxDepth = parseInt(opts.depth as string, 10);
5✔
1125

1126
      // Fuzzy pre-check: verify symbol exists before running analysis
1127
      const sym = store.getSymbolByName(symbol);
5✔
1128
      if (!sym) {
5✔
1129
        const candidates = store.getSymbolCandidatesForFuzzy();
2✔
1130
        const suggestions = findSimilarSymbols(symbol, candidates);
2✔
1131
        console.error(`Error: Symbol "${symbol}" not found.`);
2✔
1132
        if (suggestions.length > 0) {
2✔
1133
          console.log(`\nDid you mean:`);
1✔
1134
          for (const s of suggestions) {
1✔
1135
            console.log(`  • ${s.name} (${s.kind} @ ${s.filePath})`);
1✔
1136
          }
1137
        }
1138
        process.exit(1);
2✔
1139
      }
1140

1141
      const analyzer = new ImpactAnalyzer(store);
3✔
1142
      const result = analyzer.analyze(symbol, maxDepth, dir);
3✔
1143

1144
      if (opts.format === 'json') {
3✔
1145
        console.log(JSON.stringify(result, null, 2));
1✔
1146
      } else {
1147
        if (result.affected.length === 0) {
2✔
1148
          console.log(`No callers affected by changing "${symbol}"`);
1✔
1149
        } else {
1150
          console.log(`Impact analysis for "${symbol}":`);
1✔
1151
          for (const item of result.affected) {
1✔
1152
            console.log(`  [${item.risk}] ${item.symbol} (${item.file}) [depth: ${item.depth}, type: ${item.edgeType}]`);
1✔
1153
          }
1154
        }
1155
        console.log(`\nRecommendation: ${result.recommendation}`);
2✔
1156
      }
1157
    });
1158

1159
  program
237✔
1160
    .command('node <symbol>')
1161
    .description('Show full symbol details and optional source code')
1162
    .option('-d, --dir <path>', 'Target directory')
1163
    .option('--source', 'Extract and display source code', false)
1164
    .option('--format <format>', 'Output format: text | json', 'text')
1165
    .action(async (symbol: string, opts: Record<string, unknown>) => {
1166
      const dir = resolveDir(opts, program.opts());
8✔
1167
      const { store } = await loadContext(dir);
8✔
1168
      checkAndPrintStaleness(store, dir);
8✔
1169

1170
      const sym = store.getSymbolByName(symbol);
8✔
1171
      if (!sym) {
8✔
1172
        // Fuzzy fallback
1173
        const candidates = store.getSymbolCandidatesForFuzzy();
2✔
1174
        const suggestions = findSimilarSymbols(symbol, candidates);
2✔
1175
        console.error(`Error: Symbol "${symbol}" not found.`);
2✔
1176
        if (suggestions.length > 0) {
2✔
1177
          console.log(`\nDid you mean:`);
1✔
1178
          for (const s of suggestions) {
1✔
1179
            console.log(`  • ${s.name} (${s.kind} @ ${s.filePath})`);
1✔
1180
          }
1181
        }
1182
        console.log(`\nTip: Use mapx search to find symbols by pattern. Example: mapx search "${symbol.length > 3 ? symbol.slice(0, 4) : symbol}*"`);
2!
1183
        process.exit(1);
2✔
1184
      }
1185

1186
      const callers = store.getCallersOfSymbol(symbol);
6✔
1187
      const callees = store.getCalleesOfSymbol(symbol);
6✔
1188
      const format = (opts.format as string) || 'text';
6!
1189

1190
      // JSON format
1191
      if (format === 'json') {
8✔
1192
        const result: Record<string, any> = {
3✔
1193
          name: sym.name,
1194
          kind: sym.kind,
1195
          scope: sym.scope || null,
6✔
1196
          file: sym.file_path,
1197
          startLine: sym.start_line,
1198
          endLine: sym.end_line,
1199
          signature: sym.signature || '',
4✔
1200
          callerCount: callers.length,
1201
          calleeCount: callees.length,
1202
        };
1203
        if (opts.source) {
3✔
1204
          try {
1✔
1205
            const absolutePath = resolve(dir, sym.file_path as string);
1✔
1206
            const content = readFileSync(absolutePath, 'utf8');
1✔
1207
            const lines = content.split('\n');
1✔
1208
            const start = (sym.start_line as number) - 1;
1✔
1209
            const end = (sym.end_line as number);
1✔
1210
            result.source = lines.slice(start, end).join('\n');
1✔
1211
          } catch (err: any) {
1212
            result.sourceError = err.message;
1✔
1213
          }
1214
        }
1215
        console.log(JSON.stringify(result, null, 2));
3✔
1216
        return;
3✔
1217
      }
1218

1219
      console.log(`Symbol: ${sym.scope ? `${sym.scope}::` : ''}${sym.name}`);
3!
1220
      console.log(`Kind:   ${sym.kind}`);
8✔
1221
      console.log(`File:   ${sym.file_path}`);
8✔
1222
      console.log(`Lines:  ${sym.start_line}-${sym.end_line}`);
8✔
1223
      console.log(`Signature: ${sym.signature}`);
8✔
1224
      console.log(`Callers: ${callers.length}`);
8✔
1225
      console.log(`Callees: ${callees.length}`);
8✔
1226

1227
      if (opts.source) {
8✔
1228
        try {
1✔
1229
          const absolutePath = resolve(dir, sym.file_path as string);
1✔
1230
          const content = readFileSync(absolutePath, 'utf8');
1✔
1231
          const lines = content.split('\n');
1✔
1232
          const start = (sym.start_line as number) - 1;
1✔
1233
          const end = (sym.end_line as number);
1✔
1234
          const sliced = lines.slice(start, end).join('\n');
1✔
1235
          console.log('\nSource Code:');
1✔
1236
          console.log('----------------------------------------');
1✔
1237
          console.log(sliced);
1✔
1238
          console.log('----------------------------------------');
1✔
1239
        } catch (err: any) {
1240
          console.error(`Failed to read source code: ${err.message}`);
1✔
1241
        }
1242
      }
1243
    });
1244

1245
  program
237✔
1246
    .command('files')
1247
    .description('List indexed files with prefix/lang/sort filters')
1248
    .option('-d, --dir <path>', 'Target directory')
1249
    .option('--path <pattern>', 'Filter by path prefix or glob (e.g. src/core/*.ts)')
1250
    .option('--lang <lang>', 'Filter by language')
1251
    .option('--sort <sort>', 'lines | path', 'path')
1252
    .option('--limit <limit>', 'Max files to return', '50')
1253
    .action(async (opts: Record<string, unknown>) => {
1254
      const dir = resolveDir(opts, program.opts());
8✔
1255
      const { store } = await loadContext(dir);
8✔
1256
      checkAndPrintStaleness(store, dir);
8✔
1257

1258
      const results = store.getFilesFiltered({
8✔
1259
        pathPrefix: opts.path as string,
1260
        lang: opts.lang as string,
1261
        sort: opts.sort as 'lines' | 'path',
1262
        limit: parseInt(opts.limit as string, 10),
1263
      });
1264

1265
      if (results.length === 0) {
8✔
1266
        console.log('No files found matching filters');
3✔
1267
        return;
3✔
1268
      }
1269

1270
      for (const file of results) {
5✔
1271
        console.log(`  ${file.path} (${file.language}, ${file.lines} lines, ${file.size_bytes} bytes)`);
6✔
1272
      }
1273
    });
1274

1275
  program
237✔
1276
    .command('deps <file>')
1277
    .description('Show dependencies for a file')
1278
    .option('-d, --dir <path>', 'Target directory')
1279
    .action(async (file: string, opts: Record<string, unknown>) => {
1280
      const dir = resolveDir(opts, program.opts());
7✔
1281
      const { store, graph } = await loadContext(dir);
7✔
1282
      checkAndPrintStaleness(store, dir);
7✔
1283

1284
      const allFiles = store.getAllFiles() as Array<{ path: unknown }>;
7✔
1285
      const resolved = resolveFilePaths(file, allFiles);
7✔
1286

1287
      if (!resolved) {
7✔
1288
        console.error(`File "${file}" not found in index.`);
1✔
1289
        console.log(`\nTip: Use globs (src/core/*.ts), partial names (scanner), or run mapx files to list all tracked files.`);
1✔
1290
        process.exit(1);
1✔
1291
      }
1292

1293
      for (const filePath of resolved) {
6✔
1294
        if (resolved.length > 1) console.log(`\n── ${filePath} ──`);
7✔
1295

1296
        const deps = graph.getDependencies(filePath);
7✔
1297
        const rdeps = graph.getReverseDependencies(filePath);
7✔
1298

1299
        if (deps.length > 0) {
7✔
1300
          console.log('Dependencies:');
2✔
1301
          for (const dep of deps) {
2✔
1302
            console.log(`  → ${dep.target} (${dep.type})`);
2✔
1303
          }
1304
        } else {
1305
          console.log('No dependencies found');
5✔
1306
        }
1307

1308
        if (rdeps.length > 0) {
7✔
1309
          console.log('\nDepended on by:');
2✔
1310
          for (const rdep of rdeps) {
2✔
1311
            console.log(`  ← ${rdep.source} (${rdep.type})`);
2✔
1312
          }
1313
        }
1314
      }
1315
    });
1316

1317
  program
237✔
1318
    .command('trace [symbol-or-file]')
1319
    .description('Trace data flow paths from a starting symbol or file')
1320
    .option('-d, --dir <path>', 'Target directory')
1321
    .option('--direction <dir>', 'up | down | both', 'both')
1322
    .option('--depth <n>', 'Maximum traversal depth', '3')
1323
    .option('--max-depth <n>', 'Maximum traversal depth (alias for --depth)')
1324
    .option('--format <fmt>', 'text | dot | json', 'text')
1325
    .option('--include-structural', 'Include import/extends edges in trace', false)
1326
    .option('--sources', 'Show entry points', false)
1327
    .option('--sinks', 'Show terminal consumers', false)
1328
    .option('--include-leaf-libraries', 'Include high-import leaf libraries in terminal consumers output', false)
1329
    .option('--to <target>', 'Find the shortest path to target symbol/file')
1330
    .action(async (start: string | undefined, opts: Record<string, unknown>) => {
1331
      const dir = resolveDir(opts, program.opts());
18✔
1332
      const { config, store } = await loadContext(dir);
18✔
1333
      checkAndPrintStaleness(store, dir);
18✔
1334

1335
      const tracer = new FlowTracer(store);
18✔
1336

1337
      if (opts.sources) {
18✔
1338
        const sources = tracer.findSources(config.repo.name);
2✔
1339
        console.log(`\nEntry points (data sources) — ${sources.length} found:`);
2✔
1340
        for (const s of sources) {
2✔
1341
          let extra = '[no incoming data edges]';
4✔
1342
          if (s.file.includes('routes/')) {
4!
1343
            const routes = store.getEdgesForFile(s.file).filter(e => e.edge_type === 'route');
×
1344
            extra = `[route file — ${routes.length} controller endpoints]`;
×
1345
          } else if (s.file.includes('app/Jobs/')) {
4✔
1346
            extra = '[dispatched externally — queue worker]';
1✔
1347
          } else if (s.file.includes('app/Listeners/')) {
3✔
1348
            extra = '[event listener — external trigger]';
1✔
1349
          } else if (s.file.includes('app/Http/Middleware/')) {
2✔
1350
            extra = '[middleware — filter chain entry]';
1✔
1351
          }
1352
          console.log(`  ${s.file.padEnd(40)} ${extra}`);
4✔
1353
        }
1354
        return;
2✔
1355
      }
1356

1357
      if (opts.sinks) {
16✔
1358
        const includeLeaf = !!opts.includeLeafLibraries;
2✔
1359
        const sinks = tracer.findSinks(config.repo.name, includeLeaf);
2✔
1360
        console.log(`\nTerminal consumers (data sinks) — ${sinks.length} found:`);
2✔
1361
        for (const s of sinks) {
2✔
1362
          const inEdges = store.getReverseEdges(s.file).filter(e => [
4✔
1363
            'call', 'instantiation', 'param_type', 'return_type', 'relation', 'dispatch', 'notify', 'route', 'render'
1364
          ].includes(e.edge_type as string));
1365
          let extra = `[terminal — no outgoing data edges]`;
4✔
1366
          if (s.file.includes('DatabaseManager') || s.file.includes('database')) {
4!
1367
            extra = `[DB facade → raw SQL — ${inEdges.length} in-edges]`;
×
1368
          } else if (s.file.includes('CacheManager') || s.file.includes('cache')) {
4✔
1369
            extra = `[Cache facade → Redis/Memcache — ${inEdges.length} in-edges]`;
1✔
1370
          } else if (s.file.includes('Mailer') || s.file.includes('mail')) {
3✔
1371
            extra = `[Mail facade → SMTP — ${inEdges.length} in-edges]`;
1✔
1372
          } else if (s.file.includes('QueueManager') || s.file.includes('queue')) {
2✔
1373
            extra = `[Queue::push — ${inEdges.length} in-edges]`;
1✔
1374
          }
1375
          console.log(`  ${s.file.padEnd(40)} ${extra}`);
4✔
1376
        }
1377
        return;
2✔
1378
      }
1379

1380
      if (!start) {
14✔
1381
        console.error('Error: start symbol or file is required unless --sources or --sinks is specified.');
1✔
1382
        process.exit(1);
1✔
1383
      }
1384

1385
      if (opts.to) {
13✔
1386
        const path = tracer.findCriticalPath(start, opts.to as string, config.repo.name);
2✔
1387
        if (!path) {
2✔
1388
          console.log(`No path found from "${start}" to "${opts.to}"`);
1✔
1389
          return;
1✔
1390
        }
1391

1392
        console.log(`\nCritical data path: ${start} → ${opts.to}`);
1✔
1393
        console.log(`Length: ${path.nodes.length - 1} hops\n`);
1✔
1394

1395
        for (let i = 0; i < path.nodes.length; i++) {
1✔
1396
          const node = path.nodes[i];
2✔
1397
          const indent = '  '.repeat(i);
2✔
1398
          const prefix = i === 0 ? '' : `└─[${node.incomingEdgeType}]─→  `;
2✔
1399
          const suffix = i === path.nodes.length - 1 ? '  ⊗' : '';
2✔
1400
          const name = node.symbol ? node.symbol : node.file;
2!
1401
          console.log(`${indent}${prefix}${name}${suffix}`);
2✔
1402
        }
1403
        return;
1✔
1404
      }
1405

1406
      const requestedDepth = opts.maxDepth !== undefined ? opts.maxDepth : opts.depth;
11!
1407
      const parsedDepth = parseInt(requestedDepth as string, 10);
18✔
1408

1409
      const result = tracer.trace({
18✔
1410
        startSymbol: start,
1411
        direction: opts.direction as any,
1412
        maxDepth: parsedDepth,
1413
        includeStructural: !!opts.includeStructural,
1414
        repo: config.repo.name,
1415
      });
1416

1417
      if (opts.format === 'json') {
18✔
1418
        const jsonOutput = {
3✔
1419
          start: result.start,
1420
          direction: result.direction,
1421
          maxDepth: parsedDepth,
1422
          nodeCount: result.nodeCount,
1423
          edgeCount: result.edgeCount,
1424
          maxDepthReached: result.maxDepthReached,
1425
          sources: result.sources.map(s => ({ file: s.file, symbol: s.symbol })),
1✔
1426
          sinks: result.sinks.map(s => ({ file: s.file, symbol: s.symbol })),
1✔
1427
          cycles: result.cycles,
1428
          nodes: Array.from(new Map(result.paths.flatMap(p => p.nodes).map(n => [`${n.file}::${n.symbol || ''}`, n])).values()).map(n => ({
4!
1429
            file: n.file,
1430
            symbol: n.symbol,
1431
            depth: n.depth,
1432
            incomingEdgeType: n.incomingEdgeType,
1433
          })),
1434
          edges: Array.from(new Set(result.paths.flatMap(p => {
1435
            const arr = [];
2✔
1436
            for (let i = 1; i < p.nodes.length; i++) {
2✔
1437
              arr.push(JSON.stringify({
2✔
1438
                from: p.nodes[i - 1].file,
1439
                to: p.nodes[i].file,
1440
                edgeType: p.nodes[i].incomingEdgeType,
1441
                fromSymbol: p.nodes[i - 1].symbol,
1442
                toSymbol: p.nodes[i].symbol,
1443
              }));
1444
            }
1445
            return arr;
2✔
1446
          }))).map(s => JSON.parse(s)),
2✔
1447
        };
1448
        console.log(JSON.stringify(jsonOutput, null, 2));
3✔
1449
        return;
3✔
1450
      }
1451

1452
      if (opts.format === 'dot') {
8✔
1453
        const lines: string[] = [];
3✔
1454
        const safeStartName = (result.start.symbol || result.start.file).replace(/[^a-zA-Z0-9]/g, '_');
3!
1455
        lines.push(`digraph Trace_${safeStartName} {`);
3✔
1456
        lines.push('  rankdir=TB;');
3✔
1457
        lines.push(`  label="Trace: ${result.start.symbol || result.start.file} (${result.direction}stream, depth≤${parsedDepth})";`);
3!
1458
        lines.push('  fontsize=12;');
3✔
1459
        lines.push('  node [shape=box, style=filled, fontsize=10];');
3✔
1460
        lines.push('');
3✔
1461

1462
        const uniqueNodes = new Map<string, { file: string; symbol: string | null; shape: string; color: string }>();
3✔
1463
        const edgesSet = new Set<string>();
3✔
1464

1465
        for (const p of result.paths) {
3✔
1466
          for (let i = 0; i < p.nodes.length; i++) {
2✔
1467
            const n = p.nodes[i];
4✔
1468
            const key = `${n.file}::${n.symbol || ''}`;
4!
1469
            if (!uniqueNodes.has(key)) {
4!
1470
              let shape = 'box';
4✔
1471
              let color = '#E8F4FD';
4✔
1472

1473
              const isStart = n.file === result.start.file && n.symbol === result.start.symbol;
4✔
1474
              const isSink = result.sinks.some(s => s.file === n.file && s.symbol === n.symbol);
4✔
1475
              const isSource = result.sources.some(s => s.file === n.file && s.symbol === n.symbol);
4✔
1476

1477
              if (isStart) {
4✔
1478
                shape = 'diamond';
2✔
1479
                color = '#FFE0B2';
2✔
1480
              } else if (isSink) {
2!
1481
                shape = 'octagon';
2✔
1482
                color = '#FFEBEE';
2✔
1483
              } else if (isSource) {
×
1484
                shape = 'ellipse';
×
1485
                color = '#E8F5E9';
×
1486
              }
1487

1488
              uniqueNodes.set(key, { file: n.file, symbol: n.symbol, shape, color });
4✔
1489
            }
1490

1491
            if (i > 0) {
4✔
1492
              const fromNode = p.nodes[i - 1];
2✔
1493
              const toNode = p.nodes[i];
2✔
1494
              edgesSet.add(JSON.stringify({
2✔
1495
                from: `${fromNode.file}::${fromNode.symbol || ''}`,
2!
1496
                to: `${toNode.file}::${toNode.symbol || ''}`,
2!
1497
                type: toNode.incomingEdgeType,
1498
              }));
1499
            }
1500
          }
1501
        }
1502

1503
        for (const [key, n] of uniqueNodes.entries()) {
3✔
1504
          const label = n.symbol || n.file.split('/').pop() || n.file;
4!
1505
          lines.push(`  "${key}" [label="${label}", fillcolor="${n.color}", shape=${n.shape}];`);
4✔
1506
        }
1507

1508
        lines.push('');
3✔
1509

1510
        for (const edgeStr of edgesSet) {
3✔
1511
          const e = JSON.parse(edgeStr);
2✔
1512
          lines.push(`  "${e.from}" -> "${e.to}" [label="${e.type}"];`);
2✔
1513
        }
1514

1515
        lines.push('}');
3✔
1516
        console.log(lines.join('\n'));
3✔
1517
        return;
3✔
1518
      }
1519

1520
      const dirSymbol = result.direction === 'down' ? '↓ downstream' : result.direction === 'up' ? '↑ upstream' : '↕ bidirectional';
5!
1521
      console.log(`\nTrace: ${start}  ${dirSymbol}  depth≤${parsedDepth}`);
18✔
1522
      console.log('─'.repeat(53));
18✔
1523
      console.log('');
18✔
1524

1525
      const printNode = (node: TraceNode, indentLevel: number) => {
18✔
1526
        const indent = '  '.repeat(indentLevel);
8✔
1527
        const prefix = indentLevel === 0 ? '' : `└─[${node.incomingEdgeType}]─→  `;
8✔
1528
        const displayName = node.symbol || node.file;
8!
1529
        const filePart = node.symbol ? `  (${node.file})` : '';
8!
1530

1531
        const isSink = result.sinks.some(s => s.file === node.file && s.symbol === node.symbol);
8✔
1532
        const sinkStr = isSink ? '  ⊗ sink' : '';
8✔
1533

1534
        const cycle = result.cycles.find(c => c.fromFile === node.file && c.fromSymbol === node.symbol);
8✔
1535
        const cycleStr = cycle ? '  ↻ cycle' : '';
8✔
1536

1537
        console.log(`${indent}${prefix}${displayName}${filePart}${sinkStr}${cycleStr}`);
8✔
1538

1539
        if (!cycle) {
8✔
1540
          const children: TraceNode[] = [];
7✔
1541
          const seenChildKeys = new Set<string>();
7✔
1542
          for (const path of result.paths) {
7✔
1543
            const idx = path.nodes.findIndex(n => n.file === node.file && n.symbol === node.symbol && n.depth === node.depth);
9✔
1544
            if (idx !== -1 && idx + 1 < path.nodes.length) {
7✔
1545
              const nextNode = path.nodes[idx + 1];
3✔
1546
              const key = `${nextNode.file}::${nextNode.symbol || ''}::${nextNode.depth}`;
3!
1547
              if (!seenChildKeys.has(key)) {
3!
1548
                seenChildKeys.add(key);
3✔
1549
                children.push(nextNode);
3✔
1550
              }
1551
            }
1552
          }
1553

1554
          for (const child of children) {
7✔
1555
            printNode(child, indentLevel + 1);
3✔
1556
          }
1557
        }
1558
      };
1559

1560
      const startNode: TraceNode = {
18✔
1561
        file: result.start.file,
1562
        symbol: result.start.symbol,
1563
        depth: 0,
1564
        incomingEdgeType: 'start',
1565
      };
1566
      printNode(startNode, 0);
18✔
1567

1568
      console.log('');
18✔
1569
      const cyclesStr = result.cycles.length > 0 ? `   Cycles: ${result.cycles.length}` : '';
18✔
1570
      console.log(`Nodes: ${result.nodeCount}   Edges: ${result.edgeCount}   Max depth: ${parsedDepth}${cyclesStr}`);
18✔
1571
      if (result.sinks.length > 0) {
18✔
1572
        const sinkNames = result.sinks.map(s => s.symbol || s.file.split('/').pop() || s.file);
4!
1573
        console.log(`Sinks: ${sinkNames.join(', ')}`);
4✔
1574
      }
1575
    });
1576

1577
  program
237✔
1578
    .command('sources')
1579
    .description('Find entry points (data sources) in the codebase')
1580
    .option('-d, --dir <path>', 'Target directory')
1581
    .action(async (opts: Record<string, unknown>) => {
1582
      const dir = resolveDir(opts, program.opts());
6✔
1583
      const { config, store } = await loadContext(dir);
6✔
1584
      checkAndPrintStaleness(store, dir);
6✔
1585

1586
      const tracer = new FlowTracer(store);
6✔
1587
      const sources = tracer.findSources(config.repo.name);
6✔
1588
      console.log(`\nEntry points (data sources) — ${sources.length} found:`);
6✔
1589
      for (const s of sources) {
6✔
1590
        let extra = '[no incoming data edges]';
5✔
1591
        if (s.file.includes('routes/')) {
5✔
1592
          const routes = store.getEdgesForFile(s.file).filter(e => e.edge_type === 'route');
2✔
1593
          extra = `[route file — ${routes.length} controller endpoints]`;
2✔
1594
        } else if (s.file.includes('app/Jobs/')) {
3✔
1595
          extra = '[dispatched externally — queue worker]';
1✔
1596
        } else if (s.file.includes('app/Listeners/')) {
2✔
1597
          extra = '[event listener — external trigger]';
1✔
1598
        } else if (s.file.includes('app/Http/Middleware/')) {
1!
1599
          extra = '[middleware — filter chain entry]';
×
1600
        }
1601
        console.log(`  ${s.file.padEnd(40)} ${extra}`);
5✔
1602
      }
1603
    });
1604

1605
  program
237✔
1606
    .command('sinks')
1607
    .description('Find terminal consumers (data sinks) in the codebase')
1608
    .option('-d, --dir <path>', 'Target directory')
1609
    .option('--include-leaf-libraries', 'Include high-import leaf libraries in output', false)
1610
    .action(async (opts: Record<string, unknown>) => {
1611
      const dir = resolveDir(opts, program.opts());
6✔
1612
      const { config, store } = await loadContext(dir);
6✔
1613
      checkAndPrintStaleness(store, dir);
6✔
1614

1615
      const tracer = new FlowTracer(store);
6✔
1616
      const includeLeaf = !!opts.includeLeafLibraries;
6✔
1617
      const sinks = tracer.findSinks(config.repo.name, includeLeaf);
6✔
1618
      console.log(`\nTerminal consumers (data sinks) — ${sinks.length} found:`);
6✔
1619
      for (const s of sinks) {
6✔
1620
        const inEdges = store.getReverseEdges(s.file).filter(e => [
5✔
1621
          'call', 'instantiation', 'param_type', 'return_type', 'relation', 'dispatch', 'notify', 'route', 'render'
1622
        ].includes(e.edge_type as string));
1623
        let extra = `[terminal — no outgoing data edges]`;
5✔
1624
        if (s.file.includes('DatabaseManager') || s.file.includes('database')) {
5✔
1625
          extra = `[DB facade → raw SQL — ${inEdges.length} in-edges]`;
2✔
1626
        } else if (s.file.includes('CacheManager') || s.file.includes('cache')) {
3✔
1627
          extra = `[Cache facade → Redis/Memcache — ${inEdges.length} in-edges]`;
1✔
1628
        } else if (s.file.includes('Mailer') || s.file.includes('mail')) {
2✔
1629
          extra = `[Mail facade → SMTP — ${inEdges.length} in-edges]`;
1✔
1630
        } else if (s.file.includes('QueueManager') || s.file.includes('queue')) {
1!
1631
          extra = `[Queue::push — ${inEdges.length} in-edges]`;
×
1632
        }
1633
        console.log(`  ${s.file.padEnd(40)} ${extra}`);
5✔
1634
      }
1635
    });
1636

1637
  program
237✔
1638
    .command('context <task>')
1639
    .description('Generate task-specific workspace context within a token budget')
1640
    .option('-d, --dir <path>', 'Target directory')
1641
    .option('--seeds <list>', 'Comma-separated list of seed symbols or file paths')
1642
    .option('--tokens <budget>', 'Maximum estimated token budget', '8192')
1643
    .option('--depth <n>', 'Graph traversal depth', '2')
1644
    .option('--format <format>', 'Output format: text | json', 'text')
1645
    .action(async (task: string, opts: Record<string, unknown>) => {
1646
      const dir = resolveDir(opts, program.opts());
5✔
1647
      const { config, store, graph } = await loadContext(dir);
5✔
1648
      checkAndPrintStaleness(store, dir);
5✔
1649

1650
      const builder = new ContextBuilder(store, graph);
5✔
1651
      const parsedTokens = parseInt(opts.tokens as string, 10) || 8192;
5!
1652
      const parsedDepth = parseInt(opts.depth as string, 10) || 2;
5!
1653
      const format = opts.format as string;
5✔
1654
      const seeds = opts.seeds ? (opts.seeds as string).split(',').map(s => s.trim()) : undefined;
5!
1655

1656
      const result = await builder.buildContext({
5✔
1657
        task,
1658
        seeds,
1659
        tokens: parsedTokens,
1660
        depth: parsedDepth,
1661
      });
1662

1663
      if (format === 'json') {
5✔
1664
        console.log(JSON.stringify(result, null, 2));
2✔
1665
        return;
2✔
1666
      }
1667

1668
      console.log('# MapX Smart Context');
3✔
1669
      console.log(`*Estimated tokens:* ${result.estimatedTokens}\n`);
3✔
1670
      
1671
      console.log('## Included Files');
3✔
1672
      if (result.includedFiles.length === 0) {
3✔
1673
        console.log('None');
1✔
1674
      } else {
1675
        for (const f of result.includedFiles) {
2✔
1676
          console.log(`### [${f.path}](file://${resolve(dir, f.path)})`);
2✔
1677
          console.log(`- Language: ${f.language}`);
2✔
1678
          console.log(`- Lines: ${f.lineCount} | Size: ${f.sizeBytes} bytes`);
2✔
1679
          if (f.symbols.length > 0) {
2✔
1680
            console.log('- Symbols:');
1✔
1681
            for (const sym of f.symbols) {
1✔
1682
              const scopeStr = sym.scope ? `${sym.scope}::` : '';
1!
1683
              console.log(`  - \`${sym.kind}\` \`${scopeStr}${sym.name}\` (lines ${sym.startLine}-${sym.endLine})`);
1✔
1684
            }
1685
          }
1686
        }
1687
      }
1688

1689
      if (result.edges.length > 0) {
3✔
1690
        console.log('\n## Cross-File Dependencies');
2✔
1691
        for (const edge of result.edges) {
2✔
1692
          const srcSym = edge.sourceSymbol ? `#${edge.sourceSymbol}` : '';
2!
1693
          const tgtSym = edge.targetSymbol ? `#${edge.targetSymbol}` : '';
2!
1694
          console.log(`- \`${edge.sourceFile}${srcSym}\` → \`${edge.targetFile}${tgtSym}\` (${edge.edgeType})`);
2✔
1695
        }
1696
      }
1697

1698
      if (result.excludedFiles.length > 0) {
3✔
1699
        console.log('\n## Excluded Files (Token budget exhausted)');
2✔
1700
        for (const f of result.excludedFiles) {
2✔
1701
          console.log(`- ${f}`);
2✔
1702
        }
1703
      }
1704
    });
1705

1706
  program
237✔
1707
    .command('export')
1708
    .description('Export code graph for LLM consumption')
1709
    .option('-d, --dir <path>', 'Target directory')
1710
    .option('--format <format>', 'Output format: llm, json, dot, svg, toon', 'llm')
1711
    .option('--tokens <budget>', 'Token budget for LLM export', '8192')
1712
    .option('--repo <name>', 'Filter by repo name')
1713
    .option('-o, --output <file>', 'Write output to file instead of stdout')
1714
    .option('--exclude <glob>', 'Exclude glob pattern(s)', collectPatterns, [])
1715
    .option('--include <glob>', 'Include glob pattern(s)', collectPatterns, [])
1716
    .option('--delimiter <delimiter>', 'Delimiter for TOON format: comma, tab, pipe', 'comma')
1717
    .option('--key-folding', 'Collapse single-key chains into dotted paths for TOON', false)
1718
    .option('--cluster <mode>', 'Cluster rendering mode for DOT/SVG: none, auto', 'none')
1719
    .option('--depth <n>', 'Maximum cluster nesting depth for DOT/SVG export', '3')
1720
    .option('--fallback-grid', 'Force using fallback grid SVG export', false)
1721
    .action(async (opts: Record<string, unknown>) => {
1722
      const dir = resolveDir(opts, program.opts());
14✔
1723
      const { config, store, graph } = await loadContext(dir);
14✔
1724

1725
      const format = opts.format as string;
14✔
1726
      const tokenBudget = parseInt(opts.tokens as string, 10) || 8192;
14!
1727
      const outputPath = opts.output as string | undefined;
14✔
1728
      const delimiter = opts.delimiter as 'comma' | 'tab' | 'pipe' | undefined;
14✔
1729
      const keyFolding = !!opts.keyFolding;
14✔
1730
      const clusterMode = (opts.cluster as string) === 'none' ? 'none' as const : 'auto' as const;
14!
1731
      const clusterDepth = opts.depth ? parseInt(opts.depth as string, 10) : 3;
14!
1732
      const fallbackGrid = !!opts.fallbackGrid;
14✔
1733
      const clusterOpts = { cluster: clusterMode, depth: clusterDepth, forceFallback: fallbackGrid };
14✔
1734

1735
      if (outputPath) {
14✔
1736
        const outputDir = resolve(outputPath, '..');
1✔
1737
        if (!existsSync(outputDir)) {
1!
1738
          console.error(`Error: output directory does not exist: ${outputDir}`);
×
1739
          process.exit(1);
×
1740
        }
1741
        try {
1✔
1742
          writeFileSync(outputPath, '', 'utf-8');
1✔
1743
        } catch {
1744
          console.error(`Error: cannot write to: ${resolve(outputPath)}`);
×
1745
          process.exit(1);
×
1746
        }
1747
      }
1748

1749
      const excludes = [
14✔
1750
        ...(config.settings.excludePatterns ?? []),
14!
1751
        ...((opts.exclude as string[]) ?? []),
14!
1752
      ];
1753
      const includes = [
14✔
1754
        ...(config.settings.includePatterns ?? []),
16✔
1755
        ...((opts.include as string[]) ?? []),
14!
1756
      ];
1757
      const matcher = buildMatcher(excludes, includes);
14✔
1758
      const allFiles = store.getAllFiles(opts.repo as string | undefined).map(f => f.path as string);
14✔
1759
      const filteredFiles = allFiles.filter(f => matcher(f));
14✔
1760

1761
      let output: string;
1762

1763
      switch (format) {
14✔
1764
        case 'json': {
1765
          const exporter = new GraphExporter(store, graph);
2✔
1766
          output = exporter.exportAsJSONString(opts.repo as string | undefined, filteredFiles);
2✔
1767
          break;
2✔
1768
        }
1769
        case 'dot': {
1770
          const exporter = new DotExporter(store, graph);
3✔
1771
          output = exporter.export(opts.repo as string | undefined, filteredFiles, clusterOpts);
3✔
1772
          break;
3✔
1773
        }
1774
        case 'svg': {
1775
          const exporter = new SvgExporter(store, graph);
3✔
1776
          output = exporter.export(opts.repo as string | undefined, filteredFiles, clusterOpts);
3✔
1777
          break;
3✔
1778
        }
1779
        case 'toon': {
1780
          const exporter = new ToonExporter(store, graph);
3✔
1781
          output = exporter.export({
3✔
1782
            format: 'toon',
1783
            tokenBudget,
1784
            repo: opts.repo as string | undefined,
1785
            files: filteredFiles,
1786
            delimiter,
1787
            keyFolding,
1788
          });
1789
          break;
3✔
1790
        }
1791
        case 'llm':
1792
        default: {
1793
          const exporter = new LLMExporter(store, graph);
3✔
1794
          output = exporter.export({
3✔
1795
            format: 'llm',
1796
            tokenBudget,
1797
            repo: opts.repo as string | undefined,
1798
            files: filteredFiles,
1799
          });
1800
          break;
3✔
1801
        }
1802
      }
1803

1804
      if (outputPath) {
14✔
1805
        writeFileSync(resolve(outputPath), output, 'utf-8');
1✔
1806
        console.log(`Exported ${format} to ${resolve(outputPath)} (${Buffer.byteLength(output, 'utf-8')} bytes)`);
1✔
1807
      } else {
1808
        console.log(output);
13✔
1809
      }
1810
    });
1811

1812
  program
237✔
1813
    .command('summary')
1814
    .description('Show project summary')
1815
    .argument('[path]', 'Target directory')
1816
    .action(async (path: string | undefined) => {
1817
      const dir = path ? resolve(path) : resolveDir({}, program.opts());
4✔
1818
      const { store, graph, config } = await loadContext(dir);
4✔
1819

1820
      const fileCount = store.getFileCount();
4✔
1821
      const symbolCount = store.getSymbolCount();
4✔
1822
      const edgeCount = store.getEdgeCount();
4✔
1823
      const breakdown = store.getLanguageBreakdown();
4✔
1824

1825
      console.log(`Project: ${config.repo.name} (${dir})`);
4✔
1826
      console.log(`Files: ${fileCount}`);
4✔
1827
      console.log(`Symbols: ${symbolCount}`);
4✔
1828
      console.log(`Dependencies: ${edgeCount}`);
4✔
1829
      console.log(`Languages: ${Object.entries(breakdown).map(([l, c]) => `${l} (${c})`).join(', ')}`);
4✔
1830
    });
1831

1832
  const langCmd = program
237✔
1833
    .command('lang')
1834
    .description('Manage language grammars and configuration');
1835

1836
  langCmd
237✔
1837
    .command('list')
1838
    .description('List all supported languages, their extensions, tier, and status')
1839
    .action(() => {
1840
      const langs = getBuiltinLanguages();
3✔
1841
      console.log('Supported languages:');
3✔
1842
      for (const [name, def] of Object.entries(langs)) {
3✔
1843
        const installed = isLanguageInstalled(name) ? 'Installed' : 'Not Installed';
28✔
1844
        console.log(`  - ${name} (${def.extensions.join(', ')} | tier: ${def.tier} | status: ${installed})`);
28✔
1845
      }
1846
    });
1847

1848
  langCmd
237✔
1849
    .command('install <lang>')
1850
    .description('Install grammar and query files for an installable language')
1851
    .action(async (lang: string) => {
1852
      try {
4✔
1853
        clack.log.step(`Installing language '${lang}'...`);
4✔
1854
        await installLanguage(lang);
4✔
1855
        clack.log.success(`Successfully installed language '${lang}'.`);
3✔
1856
      } catch (err: any) {
1857
        clack.log.error(`Error installing language '${lang}': ${err.message}`);
1✔
1858
        process.exit(1);
1✔
1859
      }
1860
    });
1861

1862
  langCmd
237✔
1863
    .command('uninstall <lang>')
1864
    .description('Uninstall grammar and query files for an installable language')
1865
    .action(async (lang: string) => {
1866
      try {
4✔
1867
        clack.log.step(`Uninstalling language '${lang}'...`);
4✔
1868
        await uninstallLanguage(lang);
4✔
1869
        clack.log.success(`Successfully uninstalled language '${lang}'.`);
3✔
1870
      } catch (err: any) {
1871
        clack.log.error(`Error uninstalling language '${lang}': ${err.message}`);
1✔
1872
        process.exit(1);
1✔
1873
      }
1874
    });
1875

1876
  program
237✔
1877
    .command('serve')
1878
    .description('Start MCP server (stdio transport)')
1879
    .option('-d, --dir <path>', 'Default target directory for MCP tools')
1880
    .option('--port <port>', 'Port for SSE transport (default: 45123)', '45123')
1881
    .option('--sse', 'Enable SSE transport instead of stdio')
1882
    .option('--ui', 'Enable UI dashboard alongside MCP server')
1883
    .option('--ui-port <port>', 'Port to run UI on (default: 45124)', '45124')
1884
    .option('--ui-host <host>', 'Host to run UI on (default: 127.0.0.1)', '127.0.0.1')
1885
    .option('--ui-token <token>', 'Bearer token for authorization')
1886
    .option('--debug', 'Enable verbose debug logging of MCP calls to stderr')
1887
    .action(async (opts: Record<string, unknown>) => {
1888
      const defaultDir = resolveDir(opts, program.opts());
4✔
1889
      const { startMcpServer } = await import('./mcp.js');
4✔
1890
      
1891
      if (opts.ui) {
4✔
1892
        const { startUiServer } = await import('./ui-server.js');
1✔
1893
        const uiPort = parseInt(opts.uiPort as string, 10) || 45124;
1!
1894
        const uiHost = (opts.uiHost as string) || '127.0.0.1';
1!
1895
        const uiToken = opts.uiToken as string | undefined;
1✔
1896
        startUiServer({ port: uiPort, host: uiHost, token: uiToken, dir: defaultDir });
1✔
1897
      }
1898

1899
      await startMcpServer(defaultDir, {
4✔
1900
        sse: opts.sse as boolean | undefined,
1901
        port: parseInt(opts.port as string, 10) || 45123,
4!
1902
        debug: opts.debug as boolean | undefined,
1903
      });
1904
    });
1905

1906
  program
237✔
1907
    .command('ui')
1908
    .description('Start the Web Dashboard')
1909
    .argument('[path]', 'Target directory')
1910
    .option('-d, --dir <path>', 'Target directory')
1911
    .option('-p, --port <port>', 'Port to run UI on (default: 45124)', '45124')
1912
    .option('--host <host>', 'Host to run UI on (default: 127.0.0.1)', '127.0.0.1')
1913
    .option('--token <token>', 'Bearer token for authorization')
1914
    .option('--no-open', 'Do not open the dashboard in the browser automatically')
1915
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
1916
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
1!
1917
      const port = parseInt(opts.port as string, 10) || 45124;
1!
1918
      const host = (opts.host as string) || '127.0.0.1';
1!
1919
      const token = opts.token as string | undefined;
1✔
1920

1921
      const { startUiServer } = await import('./ui-server.js');
1✔
1922
      startUiServer({ port, host, token, dir });
1✔
1923

1924
      const url = `http://${host}:${port}`;
1✔
1925
      console.log(`Mapx Web Dashboard started at ${url}`);
1✔
1926

1927
      if (opts.open !== false) {
1!
1928
        const { exec } = await import('node:child_process');
×
1929
        const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
×
1930
        exec(`${openCmd} ${url}`).unref();
×
1931
      }
1932
    });
1933

1934
  program
237✔
1935
    .command('metrics')
1936
    .description('Show coupling and instability metrics for files')
1937
    .argument('[path]', 'Target directory')
1938
    .option('-d, --dir <path>', 'Target directory')
1939
    .option('--lang <language>', 'Filter metrics by language')
1940
    .option('--verified-only', 'Only compute metrics using verified edges')
1941
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
1942
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
4!
1943
      const { config, store } = await loadContext(dir);
4✔
1944
      checkAndPrintStaleness(store, dir);
4✔
1945
      const metrics = calculateMetrics(store, {
4✔
1946
        repo: config.repo.name,
1947
        language: opts.lang as string | undefined,
1948
        verifiedOnly: !!opts.verifiedOnly,
1949
      });
1950

1951
      if (metrics.length === 0) {
4✔
1952
        console.log('No metrics found.');
1✔
1953
        return;
1✔
1954
      }
1955

1956
      console.log('\n── Coupling & Instability Metrics ─────────────────────');
3✔
1957
      console.log(`${'File Path'.padEnd(45)} | ${'Lang'.padEnd(10)} | ${'Ca'.padStart(4)} | ${'Ce'.padStart(4)} | ${'Instability'.padStart(11)}`);
3✔
1958
      console.log('-'.repeat(85));
3✔
1959
      for (const m of metrics) {
3✔
1960
        const pathTrunc = m.path.length > 45 ? '...' + m.path.substring(m.path.length - 42) : m.path;
3!
1961
        console.log(`${pathTrunc.padEnd(45)} | ${m.language.padEnd(10)} | ${String(m.afferent).padStart(4)} | ${String(m.efferent).padStart(4)} | ${m.instability.toFixed(4).padStart(11)}`);
3✔
1962
      }
1963
      console.log('');
3✔
1964
    });
1965

1966
  program
237✔
1967
    .command('clusters')
1968
    .description('List detected code clusters/modules')
1969
    .argument('[clusterOrPath]', 'Target directory or a specific cluster name to inspect')
1970
    .option('-d, --dir <path>', 'Target directory')
1971
    .option('--source <source>', 'Filter by cluster source: namespace, directory, community, layer, or all', 'all')
1972
    .option('--json', 'Output results as JSON')
1973
    .action(async (clusterOrPath: string | undefined, opts: Record<string, unknown>) => {
1974
      let dir = resolveDir(opts, program.opts());
8✔
1975
      let clusterQuery: string | undefined = undefined;
8✔
1976

1977
      if (clusterOrPath) {
8✔
1978
        const resolvedPath = resolve(clusterOrPath);
4✔
1979
        if (existsSync(resolvedPath)) {
4!
1980
          dir = resolvedPath;
×
1981
        } else {
1982
          clusterQuery = clusterOrPath;
4✔
1983
        }
1984
      }
1985

1986
      const { config, store } = await loadContext(dir);
8✔
1987
      const source = opts.source as string;
8✔
1988
      const json = !!opts.json;
8✔
1989

1990
      const clusters = store.getClusters(config.repo.name);
8✔
1991

1992
      let filtered = clusters;
8✔
1993
      if (source && source !== 'all') {
8!
1994
        filtered = clusters.filter((c: any) => c.source === source);
×
1995
      }
1996

1997
      if (clusterQuery) {
8✔
1998
        const targetCluster = clusters.find((c: any) => c.name === clusterQuery);
4✔
1999
        if (!targetCluster) {
4✔
2000
          console.error(`Cluster "${clusterQuery}" not found.`);
1✔
2001
          process.exit(1);
1✔
2002
        }
2003

2004
        const files = store.getClusterFiles(targetCluster.name as string, config.repo.name);
3✔
2005
        const clusterEdges = store.getClusterEdges(targetCluster.name as string, config.repo.name);
3✔
2006

2007
        if (json) {
3✔
2008
          console.log(JSON.stringify({
1✔
2009
            cluster: targetCluster,
2010
            files,
2011
            edges: clusterEdges
2012
          }, null, 2));
2013
          return;
1✔
2014
        }
2015

2016
        console.log(`\n${targetCluster.name}  [${targetCluster.source}]  ${targetCluster.file_count} files`);
2✔
2017
        for (const f of files) {
2✔
2018
          console.log(`  ${f}`);
3✔
2019
        }
2020

2021
        const dependsOn = clusterEdges.filter(e => e.sourceCluster === targetCluster.name);
2✔
2022
        console.log('\nDepends on:');
2✔
2023
        if (dependsOn.length === 0) {
2✔
2024
          console.log('  (none)');
1✔
2025
        } else {
2026
          for (const dep of dependsOn) {
1✔
2027
            console.log(`  ${dep.targetCluster.padEnd(25)} [${dep.edgeCount} edges — dominant: ${dep.dominantType}]`);
1✔
2028
          }
2029
        }
2030

2031
        const dependedOnBy = clusterEdges.filter(e => e.targetCluster === targetCluster.name);
2✔
2032
        console.log('\nDepended on by:');
2✔
2033
        if (dependedOnBy.length === 0) {
2✔
2034
          console.log('  (none)');
1✔
2035
        } else {
2036
          for (const dep of dependedOnBy) {
1✔
2037
            console.log(`  ${dep.sourceCluster.padEnd(25)} [${dep.edgeCount} edges — dominant: ${dep.dominantType}]`);
1✔
2038
          }
2039
        }
2040
        console.log('');
2✔
2041
        return;
2✔
2042
      }
2043

2044
      if (json) {
4✔
2045
        console.log(JSON.stringify({ clusters: filtered }, null, 2));
1✔
2046
        return;
1✔
2047
      }
2048

2049
      const roots: any[] = [];
3✔
2050
      const childrenMap = new Map<string, any[]>();
3✔
2051
      
2052
      for (const c of filtered) {
3✔
2053
        if (!c.parent_name) {
3✔
2054
          roots.push(c);
2✔
2055
        } else {
2056
          const parentName = c.parent_name as string;
1✔
2057
          if (!childrenMap.has(parentName)) {
1!
2058
            childrenMap.set(parentName, []);
1✔
2059
          }
2060
          childrenMap.get(parentName)!.push(c);
1✔
2061
        }
2062
      }
2063

2064
      for (const list of childrenMap.values()) {
3✔
2065
        list.sort((a, b) => a.name.localeCompare(b.name));
1✔
2066
      }
2067
      roots.sort((a, b) => a.name.localeCompare(b.name));
3✔
2068

2069
      const printTree = (node: any, indent: number) => {
3✔
2070
        const padding = '  '.repeat(indent);
3✔
2071
        const namePart = node.name;
3✔
2072
        const sourcePart = `(${node.source})`;
3✔
2073
        const filesPart = `[${node.file_count} files]`;
3✔
2074
        
2075
        const formatted = `${padding}${namePart.padEnd(35 - indent * 2)}${sourcePart.padEnd(15)} ${filesPart}`;
3✔
2076
        console.log(formatted);
3✔
2077

2078
        const children = childrenMap.get(node.name) || [];
3✔
2079
        for (const child of children) {
3✔
2080
          printTree(child, indent + 1);
1✔
2081
        }
2082
      };
2083

2084
      console.log('');
3✔
2085
      for (const root of roots) {
3✔
2086
        printTree(root, 0);
2✔
2087
      }
2088

2089
      const nsCount = filtered.filter((c: any) => c.source === 'namespace').length;
3✔
2090
      const dirCount = filtered.filter((c: any) => c.source === 'directory').length;
3✔
2091
      const commCount = filtered.filter((c: any) => c.source === 'community').length;
3✔
2092
      const layerCount = filtered.filter((c: any) => c.source === 'layer').length;
3✔
2093
      const layerSuffix = layerCount > 0 ? `, ${layerCount} layer` : '';
3!
2094
      console.log(`\n${filtered.length} clusters detected (${nsCount} namespace, ${dirCount} directory, ${commCount} community${layerSuffix})\n`);
8✔
2095
    });
2096

2097
  program
237✔
2098
    .command('edges')
2099
    .description('Granular query of dependency edges')
2100
    .argument('[path]', 'Target directory')
2101
    .option('-d, --dir <path>', 'Target directory')
2102
    .option('--type <type>', 'Filter edges by type')
2103
    .option('--from <file>', 'Filter edges originating from a file pattern')
2104
    .option('--to <file>', 'Filter edges targeting a file pattern')
2105
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
2106
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
4!
2107
      const { config, store } = await loadContext(dir);
4✔
2108
      checkAndPrintStaleness(store, dir);
4✔
2109
      const edges = store.queryEdges({
4✔
2110
        repo: config.repo.name,
2111
        type: opts.type as string | undefined,
2112
        from: opts.from as string | undefined,
2113
        to: opts.to as string | undefined,
2114
      });
2115

2116
      if (edges.length === 0) {
4✔
2117
        console.log('No matching edges found.');
2✔
2118
        return;
2✔
2119
      }
2120

2121
      console.log(`\nFound ${edges.length} matching edges:`);
2✔
2122
      for (const e of edges) {
2✔
2123
        const srcSym = e.source_symbol ? `#${e.source_symbol}` : '';
2✔
2124
        const tgtSym = e.target_symbol ? `#${e.target_symbol}` : '';
2✔
2125
        const infSuffix = e.verifiability === 'inferred' ? ' [inferred]' : '';
2✔
2126
        console.log(`- ${e.source_file}${srcSym} → ${e.target_file}${tgtSym} (${e.edge_type})${infSuffix}`);
2✔
2127
      }
2128
      console.log('');
2✔
2129
    });
2130

2131
  program
237✔
2132
    .command('routes')
2133
    .description('Show routes from all detected frameworks')
2134
    .argument('[path]', 'Target directory')
2135
    .option('-d, --dir <path>', 'Target directory')
2136
    .option('--framework <name>', 'Filter by framework name')
2137
    .option('--method <verb>', 'Filter by HTTP method (GET, POST, etc.)')
2138
    .option('--path-pattern <pattern>', 'Filter by route path pattern')
2139
    .option('--json', 'Output routes as JSON')
2140
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
2141
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
4!
2142
      const routeRegistry = new RouteRegistry();
4✔
2143
      await routeRegistry.load(dir);
4✔
2144

2145
      const routes = routeRegistry.queryRoutes({
4✔
2146
        framework: opts.framework as string | undefined,
2147
        method: opts.method as string | undefined,
2148
        path: opts.pathPattern as string | undefined,
2149
      });
2150

2151
      if (opts.json) {
4✔
2152
        console.log(JSON.stringify(routes, null, 2));
1✔
2153
        return;
1✔
2154
      }
2155

2156
      if (routes.length === 0) {
3✔
2157
        console.log('No routes found.');
2✔
2158
        return;
2✔
2159
      }
2160

2161
      console.log(`\nDetected Routes (${routes.length}):`);
1✔
2162
      console.log(''.padEnd(80, '-'));
1✔
2163
      console.log(`${'Framework'.padEnd(12)} | ${'Method'.padEnd(8)} | ${'Path'.padEnd(30)} | ${'Handler'}`);
1✔
2164
      console.log(''.padEnd(80, '-'));
1✔
2165
      for (const r of routes) {
1✔
2166
        const handler = r.handlerSymbol || r.handlerFile;
1!
2167
        console.log(`${r.framework.padEnd(12)} | ${r.method.toUpperCase().padEnd(8)} | ${r.path.padEnd(30)} | ${handler}`);
1✔
2168
      }
2169
      console.log(''.padEnd(80, '-'));
1✔
2170
      console.log('');
1✔
2171
    });
2172

2173
  program
237✔
2174
    .command('hooks')
2175
    .description('Show hooks from all detected frameworks')
2176
    .argument('[path]', 'Target directory')
2177
    .option('-d, --dir <path>', 'Target directory')
2178
    .option('--framework <name>', 'Filter by framework name')
2179
    .option('--type <type>', 'Filter by hook type')
2180
    .option('--name <pattern>', 'Filter by hook name pattern')
2181
    .option('--json', 'Output hooks as JSON')
2182
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
2183
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
4!
2184
      const routeRegistry = new RouteRegistry();
4✔
2185
      await routeRegistry.load(dir);
4✔
2186

2187
      const hooks = routeRegistry.queryHooks({
4✔
2188
        framework: opts.framework as string | undefined,
2189
        hookType: opts.type as string | undefined,
2190
        hookName: opts.name as string | undefined,
2191
      });
2192

2193
      if (opts.json) {
4✔
2194
        console.log(JSON.stringify(hooks, null, 2));
1✔
2195
        return;
1✔
2196
      }
2197

2198
      if (hooks.length === 0) {
3✔
2199
        console.log('No hooks found.');
2✔
2200
        return;
2✔
2201
      }
2202

2203
      console.log(`\nDetected Hooks (${hooks.length}):`);
1✔
2204
      console.log(''.padEnd(80, '-'));
1✔
2205
      console.log(`${'Framework'.padEnd(12)} | ${'Type'.padEnd(15)} | ${'Hook Name'.padEnd(25)} | ${'Handler'}`);
1✔
2206
      console.log(''.padEnd(80, '-'));
1✔
2207
      for (const h of hooks) {
1✔
2208
        const handler = h.handlerSymbol || h.handlerFile;
1!
2209
        console.log(`${h.framework.padEnd(12)} | ${h.hookType.padEnd(15)} | ${h.hookName.padEnd(25)} | ${handler}`);
1✔
2210
      }
2211
      console.log(''.padEnd(80, '-'));
1✔
2212
      console.log('');
1✔
2213
    });
2214

2215
  program
237✔
2216
    .command('profile')
2217
    .description('Show codebase profile: archetype, frameworks, and patterns')
2218
    .argument('[path]', 'Target directory')
2219
    .option('-d, --dir <path>', 'Target directory')
2220
    .action(async (pathArg: string | undefined, opts: Record<string, unknown>) => {
2221
      const dir = pathArg ? resolve(pathArg) : resolveDir(opts, program.opts());
3✔
2222
      const { config, store } = await loadContext(dir);
3✔
2223
      checkAndPrintStaleness(store, dir);
3✔
2224
      const repo = config.repo.name;
3✔
2225
      const profileStr = store.getMeta('codebase_profile:' + repo);
3✔
2226
      if (!profileStr) {
3✔
2227
        console.log(`No codebase profile found for repo "${repo}". Run a scan first.`);
2✔
2228
        return;
2✔
2229
      }
2230
      const profile = JSON.parse(profileStr);
1✔
2231
      console.log('\n── Codebase Profile ───────────────────────────────────');
1✔
2232
      console.log(`  Archetype:             ${profile.archetype} (confidence: ${profile.archetypeConfidence.toFixed(2)})`);
1✔
2233
      console.log(`  Detected Frameworks:   ${profile.detectedFrameworks.join(', ') || 'none'}`);
1!
2234
      console.log(`  Architecture Patterns: ${profile.detectedPatterns.join(', ') || 'none'}`);
3!
2235
      console.log(`  Dominant Languages:    ${profile.dominantLanguages.join(', ')}`);
3✔
2236
      console.log(`  Has Backend:           ${profile.hasBackend}`);
3✔
2237
      console.log(`  Has Frontend:          ${profile.hasFrontend}`);
3✔
2238
      console.log(`  Is Monorepo:           ${profile.isMonorepo}`);
3✔
2239
      console.log('');
3✔
2240
    });
2241

2242
  program
237✔
2243
    .command('arch')
2244
    .description('Show full architecture report: profile, layers, smells, and DSM')
2245
    .argument('[path]', 'Target directory')
2246
    .option('-d, --dir <path>', 'Target directory')
2247
    .option('--smells', 'Show only architectural smells')
2248
    .option('--dsm', 'Show only Dependency Structure Matrix (DSM)')
2249
    .option('--violations', 'Show only layer violations')
2250
    .action(async (pathArg: string | undefined, opts: Record<string, unknown>) => {
2251
      const dir = pathArg ? resolve(pathArg) : resolveDir(opts, program.opts());
1!
2252
      const { config, store } = await loadContext(dir);
1✔
2253
      checkAndPrintStaleness(store, dir);
1✔
2254
      const repo = config.repo.name;
1✔
2255

2256
      const showAll = !opts.smells && !opts.dsm && !opts.violations;
1✔
2257

2258
      if (showAll || opts.smells || opts.violations) {
1!
2259
        const smells = store.getArchSmells(repo);
1✔
2260
        const filtered = opts.violations ? smells.filter((s: any) => s.type === 'layer-violation') : smells;
1!
2261
        
2262
        console.log(`\n── Architectural Smells & Violations (${filtered.length}) ─────────────────`);
1✔
2263
        if (filtered.length === 0) {
1!
2264
          console.log('🟢 Clean! No architectural smells detected.');
×
2265
        } else {
2266
          for (const s of filtered) {
1✔
2267
            console.log(`\n  [${s.severity.toUpperCase()}] ${s.type}`);
1✔
2268
            console.log(`    Description: ${s.description}`);
1✔
2269
            if (s.involvedFiles.length > 0) {
1!
2270
              console.log(`    Files:       ${s.involvedFiles.join(', ')}`);
1✔
2271
            }
2272
            if (s.involvedClusters && s.involvedClusters.length > 0) {
1!
2273
              console.log(`    Clusters:    ${s.involvedClusters.join(', ')}`);
1✔
2274
            }
2275
            console.log(`    Suggestion:  ${s.suggestion}`);
1✔
2276
          }
2277
        }
2278
        console.log('');
1✔
2279
        if (!showAll) return;
1!
2280
      }
2281

2282
      if (showAll || opts.dsm) {
1!
2283
        const dsm = calculateDSM(store, repo);
1✔
2284
        if (dsm.clusterNames.length === 0) {
1!
2285
          console.log('No clusters found to build DSM.');
1✔
2286
        } else {
2287
          console.log('\n── Dependency Structure Matrix (DSM) ──────────────────');
×
2288
          const maxLength = Math.max(...dsm.clusterNames.map((n: string) => n.length), 5);
×
2289
          const headers = [''.padEnd(maxLength), ...dsm.clusterNames.map((_, idx) => `C${idx + 1}`.padStart(4))].join(' ');
×
2290
          console.log(headers);
×
2291
          console.log('-'.repeat(headers.length));
×
2292
          dsm.clusterNames.forEach((name, i) => {
×
2293
            const cells = dsm.matrix[i].map((val, j) => {
×
2294
              if (i === j) return '   *';
×
2295
              if (val === 0) return '   .';
×
2296
              return val.toString().padStart(4);
×
2297
            }).join(' ');
2298
            console.log(`${`C${i + 1} ${name}`.padEnd(maxLength)} ${cells}`);
×
2299
          });
2300
          console.log(`\nLegend:\n  * = self\n  . = no dependency\n  number = edge count from row cluster to column cluster`);
×
2301
          console.log('\nCluster Key:');
×
2302
          dsm.clusterNames.forEach((name, idx) => {
×
2303
            console.log(`  C${idx + 1}: ${name}`);
×
2304
          });
2305
        }
2306
        console.log('');
1✔
2307
        if (!showAll) return;
1!
2308
      }
2309

2310
      if (showAll) {
1!
2311
        const profileStr = store.getMeta('codebase_profile:' + repo);
1✔
2312
        if (profileStr) {
1!
2313
          const profile = JSON.parse(profileStr);
×
2314
          console.log('── Codebase Profile Summary ───────────────────────────');
×
2315
          console.log(`  Archetype:  ${profile.archetype} (confidence: ${profile.archetypeConfidence.toFixed(2)})`);
×
2316
          console.log(`  Languages:  ${profile.dominantLanguages.join(', ')}`);
×
2317
          console.log('');
×
2318
        }
2319

2320
        const smells = store.getArchSmells(repo);
1✔
2321
        console.log('── Architecture Health ────────────────────────────────');
1✔
2322
        if (smells.length === 0) {
1!
2323
          console.log('🟢 Clean! No smells detected.');
×
2324
        } else {
2325
          console.log(`⚠️  Detected ${smells.length} architectural smell(s). Run 'mapx arch --smells' for details.`);
1✔
2326
        }
2327
        console.log('');
1✔
2328
      }
2329
    });
2330

2331
  program
237✔
2332
    .command('explain <file>')
2333
    .description('Explain why a file was classified with a specific role')
2334
    .option('-d, --dir <path>', 'Target directory')
2335
    .action(async (file: string, opts: Record<string, unknown>) => {
2336
      const dir = resolveDir(opts, program.opts());
1✔
2337
      const { store } = await loadContext(dir);
1✔
2338
      checkAndPrintStaleness(store, dir);
1✔
2339
      
2340
      const fileRecord = store.getFile(file);
1✔
2341
      if (!fileRecord) {
1!
2342
        console.log(`File "${file}" not found in graph.`);
×
2343
        return;
×
2344
      }
2345

2346
      const signals = store.getClassificationSignals(file);
1✔
2347
      const role = fileRecord.role || 'other';
1!
2348
      const confidence = typeof fileRecord.role_confidence === 'number' ? fileRecord.role_confidence : 0.5;
1!
2349

2350
      console.log(`\n── Classification Explanation: ${file} ──`);
1✔
2351
      console.log(`  Role:       ${role} (confidence: ${confidence.toFixed(2)})`);
1✔
2352
      console.log(`\n  Signals:`);
1✔
2353
      for (const s of signals) {
1✔
2354
        console.log(`    [${s.source.padEnd(9)}] ${s.role.padEnd(12)} (conf: ${s.confidence.toFixed(2)}) - ${s.reason}`);
1✔
2355
      }
2356
      if (signals.length === 0) {
1!
2357
        console.log('    (none)');
×
2358
      }
2359
      console.log('');
1✔
2360
    });
2361

2362
  program
237✔
2363
    .command('layers')
2364
    .description('List files grouped by architectural roles/layers')
2365
    .argument('[path]', 'Target directory')
2366
    .option('-d, --dir <path>', 'Target directory')
2367
    .option('--json', 'Output results as JSON')
2368
    .action(async (pathArg: string | undefined, opts: Record<string, unknown>) => {
2369
      const dir = pathArg ? resolve(pathArg) : resolveDir(opts, program.opts());
1!
2370
      const { config, store } = await loadContext(dir);
1✔
2371
      checkAndPrintStaleness(store, dir);
1✔
2372
      const repo = config.repo.name;
1✔
2373

2374
      const files = store.getAllFiles(repo);
1✔
2375
      const roleGroups = new Map<string, string[]>();
1✔
2376
      for (const f of files) {
1✔
2377
        const role = (f.role as string) || 'other';
1!
2378
        if (!roleGroups.has(role)) {
1!
2379
          roleGroups.set(role, []);
1✔
2380
        }
2381
        roleGroups.get(role)!.push(f.path as string);
1✔
2382
      }
2383

2384
      if (opts.json) {
1!
2385
        const out: Record<string, string[]> = {};
×
2386
        for (const [r, paths] of roleGroups.entries()) {
×
2387
          out[r] = paths;
×
2388
        }
2389
        console.log(JSON.stringify(out, null, 2));
×
2390
        return;
×
2391
      }
2392

2393
      console.log(`\n── Architectural Layers / Roles for "${repo}" ─────────`);
1✔
2394
      const sortedRoles = Array.from(roleGroups.keys()).sort();
1✔
2395
      for (const role of sortedRoles) {
1✔
2396
        const list = roleGroups.get(role)!;
1✔
2397
        console.log(`\n  ${role.toUpperCase()} (${list.length} files):`);
1✔
2398
        for (const f of list.slice(0, 10)) {
1✔
2399
          console.log(`    • ${f}`);
1✔
2400
        }
2401
        if (list.length > 10) {
1!
2402
          console.log(`    • ...and ${list.length - 10} more`);
×
2403
        }
2404
      }
2405
      console.log('');
1✔
2406
    });
2407

2408
  const agentsCmd = program.command('agents').description('Manage LLM agent integration files');
237✔
2409

2410
  agentsCmd
237✔
2411
    .command('list')
2412
    .description('List all supported LLM integration providers')
2413
    .action(() => {
2414
      const generator = new AgentGenerator();
3✔
2415
      const providers = generator.listProviders();
3✔
2416
      console.log('\nSupported LLM integration providers:');
3✔
2417
      for (const p of providers) {
3✔
2418
        const temp = generator.getTemplate(p);
15✔
2419
        const appendStr = temp?.isAppend ? ' (append-mode)' : '';
15✔
2420
        console.log(`  - ${p.padEnd(12)} -> ${temp?.filename}${appendStr}`);
15✔
2421
      }
2422
      console.log('');
3✔
2423
    });
2424

2425
  agentsCmd
237✔
2426
    .command('generate')
2427
    .description('Generate/overwrite LLM integration files')
2428
    .option('--providers <list>', 'Comma-separated list of providers to generate')
2429
    .option('--all', 'Generate integration files for all supported providers')
2430
    .option('--dry-run', 'Show actions without writing files')
2431
    .option('--force', 'Force overwrite of existing files without prompt')
2432
    .option('--mcp-port <number>', 'Port for the MCP SSE transport server', '3456')
2433
    .action(async (opts: Record<string, any>) => {
2434
      const dir = program.opts().dir ? resolve(program.opts().dir) : process.cwd();
9!
2435
      const generator = new AgentGenerator();
9✔
2436
      const available = generator.listProviders();
9✔
2437
      let targets: string[] = [];
9✔
2438

2439
      if (opts.all) {
9✔
2440
        targets = available;
5✔
2441
      } else if (opts.providers) {
4✔
2442
        targets = opts.providers.split(',').map((s: string) => s.trim().toLowerCase()).filter((p: string) => available.includes(p));
4✔
2443
      } else {
2444
        if (process.stdin.isTTY) {
1!
2445
          targets = await selectProvidersInteractive();
1✔
2446
        } else {
2447
          targets = ['generic'];
×
2448
        }
2449
      }
2450

2451
      if (targets.length === 0) {
8!
2452
        clack.log.error('No valid providers specified.');
×
2453
        process.exit(1);
×
2454
      }
2455

2456
      const actions = generator.plan(targets, { dir, mcpPort: parseInt(opts.mcpPort, 10) });
8✔
2457

2458
      for (const action of actions) {
8✔
2459
        if (action.status === 'up_to_date') {
21✔
2460
          clack.log.info(`${action.filename}: Up to date. Skipping.`);
1✔
2461
          continue;
1✔
2462
        }
2463

2464
        if (action.status === 'update_conflict' || action.status === 'no_sentinel') {
20✔
2465
          clack.log.warn(`Conflict/Modification detected in ${action.filename}:`);
13✔
2466
          if (action.diff) {
13!
2467
            console.log(action.diff);
13✔
2468
          }
2469
          if (!opts.force) {
13✔
2470
            const confirm = await clack.confirm({
12✔
2471
              message: `Overwrite ${action.filename}?`,
2472
              initialValue: false,
2473
            });
2474
            if (clack.isCancel(confirm) || !confirm) {
12!
2475
              clack.log.warn(`Skipped ${action.filename}.`);
×
2476
              continue;
×
2477
            }
2478
          }
2479
        }
2480

2481
        if (opts.dryRun) {
20✔
2482
          clack.log.info(`[DRY RUN] Would write to ${action.filepath} (status: ${action.status})`);
13✔
2483
        } else {
2484
          generator.execute(action);
7✔
2485
          clack.log.success(`Wrote to ${action.filename} (status: ${action.status})`);
7✔
2486
        }
2487
      }
2488
    });
2489

2490
  agentsCmd
237✔
2491
    .command('update')
2492
    .description('Update existing LLM integration files to the current MapxGraph version')
2493
    .option('--dry-run', 'Show updates without writing files')
2494
    .option('--force', 'Force overwrite of customized blocks without prompt')
2495
    .option('--mcp-port <number>', 'Port for the MCP SSE transport server', '3456')
2496
    .action(async (opts: Record<string, any>) => {
2497
      const dir = program.opts().dir ? resolve(program.opts().dir) : process.cwd();
5!
2498
      const generator = new AgentGenerator();
5✔
2499
      const available = generator.listProviders();
5✔
2500

2501
      const existingProviders = available.filter(p => {
5✔
2502
        const temp = generator.getTemplate(p);
19✔
2503
        return temp && existsSync(join(dir, temp.filename));
19✔
2504
      });
2505

2506
      if (existingProviders.length === 0) {
5!
2507
        clack.log.info('No existing LLM integration files found to update.');
×
2508
        return;
×
2509
      }
2510

2511
      const actions = generator.plan(existingProviders, { dir, mcpPort: parseInt(opts.mcpPort, 10) });
5✔
2512
      let updatedCount = 0;
5✔
2513

2514
      for (const action of actions) {
5✔
2515
        if (action.status === 'up_to_date') {
16!
2516
          continue;
×
2517
        }
2518

2519
        if (action.status === 'update_conflict') {
16✔
2520
          clack.log.warn(`Customized content detected in ${action.filename}:`);
1✔
2521
          if (action.diff) {
1!
2522
            console.log(action.diff);
1✔
2523
          }
2524
          if (!opts.force) {
1!
2525
            const confirm = await clack.confirm({
×
2526
              message: `Overwrite customizations in ${action.filename}?`,
2527
              initialValue: false,
2528
            });
2529
            if (clack.isCancel(confirm) || !confirm) {
×
2530
              clack.log.warn(`Skipped ${action.filename}.`);
×
2531
              continue;
×
2532
            }
2533
          }
2534
        }
2535

2536
        if (opts.dryRun) {
16✔
2537
          clack.log.info(`[DRY RUN] Would update ${action.filepath}`);
1✔
2538
        } else {
2539
          generator.execute(action);
15✔
2540
          clack.log.success(`Updated ${action.filename}`);
15✔
2541
          updatedCount++;
15✔
2542
        }
2543
      }
2544

2545
      if (updatedCount === 0 && !opts.dryRun) {
5!
2546
        clack.log.success('All integration files are already up to date.');
×
2547
      }
2548
    });
2549

2550
  agentsCmd
237✔
2551
    .command('mcp')
2552
    .description('Auto-detect agent tools and generate/update MCP config files')
2553
    .option('--tools <list>', 'Comma-separated list of tools to generate configs for (opencode, gemini-cli, cursor-mcp, vscode-mcp)')
2554
    .option('--all', 'Generate MCP configs for all supported tools')
2555
    .option('--detect', 'Only detect agent tools without writing files')
2556
    .option('--dry-run', 'Show actions without writing files')
2557
    .action(async (opts: Record<string, any>) => {
2558
      const dir = program.opts().dir ? resolve(program.opts().dir) : process.cwd();
15!
2559
      const generator = new AgentGenerator();
15✔
2560
      const allConfigs = generator.listMcpConfigs();
15✔
2561

2562
      // Select targets
2563
      let targets: typeof allConfigs;
2564
      if (opts.all) {
15✔
2565
        targets = allConfigs;
6✔
2566
      } else if (opts.tools) {
9✔
2567
        const requested = opts.tools.split(',').map((s: string) => s.trim().toLowerCase());
3✔
2568
        targets = allConfigs.filter(c => requested.includes(c.name));
7✔
2569
      } else {
2570
        // Auto-detect
2571
        targets = generator.detectAgentTools(dir);
6✔
2572
      }
2573

2574
      if (opts.detect) {
15✔
2575
        if (targets.length === 0) {
4✔
2576
          clack.log.info('No agent tools detected in this project.');
2✔
2577
        } else {
2578
          clack.log.info(`Detected agent tools (${targets.length}):`);
2✔
2579
          for (const t of targets) {
2✔
2580
            clack.log.success(`${t.name.padEnd(15)} → ${t.filename}`);
6✔
2581
          }
2582
        }
2583
        clack.log.info(`All available targets:`);
4✔
2584
        for (const c of allConfigs) {
4✔
2585
          const detected = targets.includes(c);
8✔
2586
          const icon = detected ? '✓' : '·';
8✔
2587
          clack.log.info(`${icon} ${c.name.padEnd(15)} → ${c.filename}`);
8✔
2588
        }
2589
        return;
4✔
2590
      }
2591

2592
      if (targets.length === 0) {
11✔
2593
        clack.log.warn('No agent tools detected. Use --all or --tools to specify targets.');
2✔
2594
        return;
2✔
2595
      }
2596

2597
      const actions = generator.generateMcpConfigs(targets, { dir });
9✔
2598
      for (const action of actions) {
9✔
2599
        if (action.status === 'up_to_date') {
12✔
2600
          clack.log.info(`${action.filename}: Up to date.`);
1✔
2601
          continue;
1✔
2602
        }
2603
        if (opts.dryRun) {
11✔
2604
          clack.log.info(`[DRY RUN] Would ${action.status} ${action.filename} (${action.tool})`);
7✔
2605
        } else {
2606
          generator.executeMcpConfig(action);
4✔
2607
          const verb = action.status === 'merge' ? 'merged into' : action.status === 'create' ? 'created' : 'updated';
4!
2608
          clack.log.success(`MCP config ${verb} ${action.filename} (${action.tool})`);
4✔
2609
        }
2610
      }
2611
    });
2612

2613
  const workspacesCmd = program.command('workspaces').description('Manage multi-repository workspace contexts');
237✔
2614

2615
  workspacesCmd
237✔
2616
    .command('list')
2617
    .alias('show')
2618
    .description('List registered repositories and other discovered peer/submodule directories')
2619
    .action(async () => {
2620
      const dir = resolveDir({}, program.opts());
4✔
2621
      const { config } = await loadContext(dir);
4✔
2622

2623
      console.log('\nRegistered repositories:');
4✔
2624
      const registeredPaths = new Set<string>();
4✔
2625
      for (const r of config.repos) {
4✔
2626
        const absPath = resolve(dir, r.path);
4✔
2627
        registeredPaths.add(absPath);
4✔
2628
        const fwStr = r.framework ? ` [${r.framework}]` : '';
4!
2629
        console.log(`  - ${r.name.padEnd(15)} -> ${r.path} (active)${fwStr}`);
4✔
2630
      }
2631

2632
      // Discover uninitialized submodules
2633
      const submodules = WorkspaceManager.discoverSubmodules(dir);
4✔
2634
      const uninitSubmodules = submodules.filter(s => !registeredPaths.has(resolve(dir, s.path)));
4✔
2635
      if (uninitSubmodules.length > 0) {
4✔
2636
        console.log('\nDiscovered submodules:');
1✔
2637
        for (const s of uninitSubmodules) {
1✔
2638
          const status = s.isInitialized ? 'available' : 'uninitialized';
1!
2639
          console.log(`  - ${s.name.padEnd(15)} -> ${s.path} (${status})`);
1✔
2640
        }
2641
      }
2642

2643
      // Discover peer repos
2644
      const peers = WorkspaceManager.discoverPeerRepos(dir);
4✔
2645
      const uninitPeers = peers.filter(p => !registeredPaths.has(resolve(dir, p.path)));
4✔
2646
      if (uninitPeers.length > 0) {
4✔
2647
        console.log('\nDiscovered peer repositories:');
1✔
2648
        for (const p of uninitPeers) {
1✔
2649
          console.log(`  - ${p.name.padEnd(15)} -> ${p.path} (available)`);
1✔
2650
        }
2651
      }
2652

2653
      // Discover VS Code workspace files
2654
      const wsFiles = readdirSync(dir).filter(f => f.endsWith('.code-workspace'));
4✔
2655
      if (wsFiles.length > 0) {
4!
2656
        console.log('\nDiscovered VS Code Workspace folders:');
×
2657
        for (const f of wsFiles) {
×
2658
          const wsFolderRepos = WorkspaceManager.discoverVSCodeWorkspace(join(dir, f), dir);
×
2659
          const uninitWs = wsFolderRepos.filter(p => !registeredPaths.has(resolve(dir, p.path)));
×
2660
          for (const p of uninitWs) {
×
2661
            console.log(`  - ${p.name.padEnd(15)} -> ${p.path} (available)`);
×
2662
          }
2663
        }
2664
      }
2665

2666
      // Deep-scan for nested git repos (up to 3 levels)
2667
      const nested = WorkspaceManager.discoverNestedGitRepos(dir);
4✔
2668
      const uninitNested = nested.filter(n => !registeredPaths.has(resolve(dir, n.path)));
4✔
2669
      if (uninitNested.length > 0) {
4!
2670
        console.log('\nNested git repositories (deep scan):');
×
2671
        for (const n of uninitNested) {
×
2672
          console.log(`  - ${n.name.padEnd(15)} -> ${n.path} (available)`);
×
2673
        }
2674
      }
2675

2676
      // Monorepo packages discovered via workspace manifests or common dirs
2677
      const monoPkgs = WorkspaceManager.discoverMonorepoPackages(dir);
4✔
2678
      const uninitMono = monoPkgs.filter(p => !registeredPaths.has(resolve(dir, p.path)));
4✔
2679
      if (uninitMono.length > 0) {
4!
2680
        console.log('\nMonorepo packages:');
×
2681
        for (const p of uninitMono) {
×
2682
          console.log(`  - ${p.name.padEnd(15)} -> ${p.path} [${p.packageManager}]`);
×
2683
        }
2684
      }
2685

2686
      console.log('');
4✔
2687
    });
2688

2689
  workspacesCmd
237✔
2690
    .command('add <path>')
2691
    .description('Register a repository path')
2692
    .option('--name <name>', 'Repository name (defaults to folder name)')
2693
    .action(async (repoPath: string, opts: Record<string, unknown>) => {
2694
      const dir = resolveDir({}, program.opts());
4✔
2695
      const { config, store, graph } = await loadContext(dir);
4✔
2696

2697
      const absPath = resolve(dir, repoPath);
4✔
2698
      if (!existsSync(absPath)) {
4✔
2699
        clack.log.error(`Path ${repoPath} does not exist.`);
1✔
2700
        process.exit(1);
1✔
2701
      }
2702
      if (!isGitRepo(absPath)) {
3✔
2703
        clack.log.error(`Path ${repoPath} is not a git repository.`);
1✔
2704
        process.exit(1);
1✔
2705
      }
2706

2707
      const relPath = relative(dir, absPath);
2✔
2708
      const name = (opts.name as string) || basename(absPath);
2✔
2709

2710
      if (config.repos.some(r => r.name === name || r.path === relPath)) {
4✔
2711
        clack.log.warn(`Repository already registered: ${name} (${relPath})`);
1✔
2712
        return;
1✔
2713
      }
2714

2715
      config.addRepo(name, relPath);
1✔
2716
      await config.save();
1✔
2717
      clack.log.success(`Registered repository: ${name} -> ${relPath}`);
1✔
2718

2719
      clack.log.step('Running initial full scan for the new repository...');
1✔
2720
      const onProgress = createProgressRenderer();
1✔
2721
      const scanner = new Scanner(store, config, graph, onProgress);
1✔
2722

2723
      const onSigInt = () => {
1✔
2724
        scanner.abort();
×
2725
        onProgress.stop('Canceled');
×
2726
      };
2727
      process.once('SIGINT', onSigInt);
1✔
2728

2729
      const result = await scanner.scanFull([name]).catch((err) => {
1✔
2730
        onProgress.stop();
×
2731
        throw err;
×
2732
      });
2733

2734
      process.removeListener('SIGINT', onSigInt);
1✔
2735
      onProgress.stop();
1✔
2736
      clack.log.success(`Scanned ${result.filesScanned} files, ${result.symbolsFound} symbols, ${result.edgesFound} edges in ${result.durationMs}ms`);
1✔
2737
    });
2738

2739
  workspacesCmd
237✔
2740
    .command('remove <name>')
2741
    .description('Unregister a repository by name or path')
2742
    .action(async (name: string) => {
2743
      const dir = resolveDir({}, program.opts());
4✔
2744
      const { config, store } = await loadContext(dir);
4✔
2745

2746
      const repo = config.repos.find(r => r.name === name || r.path === name);
4✔
2747
      if (!repo) {
4✔
2748
        clack.log.error(`Repository ${name} is not registered.`);
2✔
2749
        process.exit(1);
2✔
2750
      }
2751

2752
      const repoName = repo.name;
2✔
2753
      config.removeRepo(name);
2✔
2754
      await config.save();
2✔
2755
      clack.log.success(`Unregistered repository: ${repoName}`);
2✔
2756

2757
      clack.log.step(`Cleaning up stored data for repository: ${repoName}...`);
2✔
2758
      store.deleteRepo(repoName);
2✔
2759
      clack.log.success(`Done.`);
2✔
2760
    });
2761

2762
  workspacesCmd
237✔
2763
    .command('discover')
2764
    .description('Discover unregistered submodules, peer repos, and VS Code workspace folders (read-only)')
2765
    .action(async () => {
2766
      const dir = resolveDir({}, program.opts());
5✔
2767
      const { config } = await loadContext(dir);
5✔
2768

2769
      const registeredPaths = new Set<string>();
5✔
2770
      for (const r of config.repos) {
5✔
2771
        registeredPaths.add(resolve(dir, r.path));
5✔
2772
      }
2773

2774
      let found = 0;
5✔
2775

2776
      // Submodules
2777
      const submodules = WorkspaceManager.discoverSubmodules(dir);
5✔
2778
      const uninitSubs = submodules.filter(s => !registeredPaths.has(resolve(dir, s.path)));
5✔
2779
      if (uninitSubs.length > 0) {
5✔
2780
        console.log('\nSubmodules:');
2✔
2781
        for (const s of uninitSubs) {
2✔
2782
          const status = s.isInitialized ? 'available' : 'uninitialized';
3✔
2783
          console.log(`  - ${s.name.padEnd(20)} -> ${s.path} (${status})`);
3✔
2784
        }
2785
        found += uninitSubs.length;
2✔
2786
      }
2787

2788
      // Peer repos
2789
      const peers = WorkspaceManager.discoverPeerRepos(dir);
5✔
2790
      const uninitPeers = peers.filter(p => !registeredPaths.has(resolve(dir, p.path)));
5✔
2791
      if (uninitPeers.length > 0) {
5✔
2792
        console.log('\nPeer repositories:');
2✔
2793
        for (const p of uninitPeers) {
2✔
2794
          console.log(`  - ${p.name.padEnd(20)} -> ${p.path} (available)`);
2✔
2795
        }
2796
        found += uninitPeers.length;
2✔
2797
      }
2798

2799
      // VS Code workspace folders
2800
      const wsFiles = readdirSync(dir).filter(f => f.endsWith('.code-workspace'));
5✔
2801
      const vsEntries: Array<{ name: string; path: string }> = [];
5✔
2802
      for (const f of wsFiles) {
5✔
2803
        const wsFolderRepos = WorkspaceManager.discoverVSCodeWorkspace(join(dir, f), dir);
1✔
2804
        for (const p of wsFolderRepos) {
1✔
2805
          if (!registeredPaths.has(resolve(dir, p.path))) {
1!
2806
            vsEntries.push({ name: p.name, path: p.path });
1✔
2807
          }
2808
        }
2809
      }
2810
      if (vsEntries.length > 0) {
5✔
2811
        console.log('\nVS Code workspace folders:');
1✔
2812
        for (const p of vsEntries) {
1✔
2813
          console.log(`  - ${p.name.padEnd(20)} -> ${p.path} (available)`);
1✔
2814
        }
2815
        found += vsEntries.length;
1✔
2816
      }
2817

2818
      // Deep-scan for nested git repos (up to 3 levels)
2819
      const nestedRepos = WorkspaceManager.discoverNestedGitRepos(dir);
5✔
2820
      const uninitNestedDiscover = nestedRepos.filter(n => !registeredPaths.has(resolve(dir, n.path)));
5✔
2821
      if (uninitNestedDiscover.length > 0) {
5✔
2822
        console.log('\nNested git repositories (deep scan ≤3 levels):');
1✔
2823
        for (const n of uninitNestedDiscover) {
1✔
2824
          console.log(`  - ${n.name.padEnd(20)} -> ${n.path} (available)`);
1✔
2825
        }
2826
        found += uninitNestedDiscover.length;
1✔
2827
      }
2828

2829
      // Monorepo packages
2830
      const monoDiscover = WorkspaceManager.discoverMonorepoPackages(dir);
5✔
2831
      const uninitMonoDiscover = monoDiscover.filter(p => !registeredPaths.has(resolve(dir, p.path)));
5✔
2832
      if (uninitMonoDiscover.length > 0) {
5✔
2833
        console.log('\nMonorepo packages:');
1✔
2834
        for (const p of uninitMonoDiscover) {
1✔
2835
          console.log(`  - ${p.name.padEnd(20)} -> ${p.path} [${p.packageManager}]`);
1✔
2836
        }
2837
        found += uninitMonoDiscover.length;
1✔
2838
      }
2839

2840
      if (found === 0) {
5✔
2841
        console.log('No unregistered repositories discovered.');
3✔
2842
      } else {
2843
        console.log(`\n${found} unregistered repositor${found === 1 ? 'y' : 'ies'} discovered. Use \`mapx workspaces add <path>\` to register.`);
2!
2844
      }
2845
    });
2846

2847
  workspacesCmd
237✔
2848
    .command('sync')
2849
    .description('Sync all discovered submodules, peer repos, and VS Code workspace folders')
2850
    .action(async () => {
2851
      const dir = resolveDir({}, program.opts());
5✔
2852
      const { config, store, graph } = await loadContext(dir);
5✔
2853

2854
      const registeredPaths = new Set<string>();
5✔
2855
      for (const r of config.repos) {
5✔
2856
        registeredPaths.add(resolve(dir, r.path));
5✔
2857
      }
2858

2859
      const toAdd: Array<{ name: string; path: string }> = [];
5✔
2860

2861
      // 1. Submodules
2862
      const submodules = WorkspaceManager.discoverSubmodules(dir);
5✔
2863
      for (const s of submodules) {
5✔
2864
        if (s.isInitialized) {
3✔
2865
          const abs = resolve(dir, s.path);
2✔
2866
          if (!registeredPaths.has(abs)) {
2!
2867
            toAdd.push({ name: s.name, path: s.path });
2✔
2868
            registeredPaths.add(abs);
2✔
2869
          }
2870
        }
2871
      }
2872

2873
      // 2. Peer repos
2874
      const peers = WorkspaceManager.discoverPeerRepos(dir);
5✔
2875
      for (const p of peers) {
5✔
2876
        const abs = resolve(dir, p.path);
1✔
2877
        if (!registeredPaths.has(abs)) {
1!
2878
          toAdd.push({ name: p.name, path: p.path });
1✔
2879
          registeredPaths.add(abs);
1✔
2880
        }
2881
      }
2882

2883
      // 3. VS Code Workspaces
2884
      const wsFiles = readdirSync(dir).filter(f => f.endsWith('.code-workspace'));
5✔
2885
      for (const f of wsFiles) {
5✔
2886
        const wsFolderRepos = WorkspaceManager.discoverVSCodeWorkspace(join(dir, f), dir);
1✔
2887
        for (const p of wsFolderRepos) {
1✔
2888
          const abs = resolve(dir, p.path);
1✔
2889
          if (!registeredPaths.has(abs)) {
1!
2890
            toAdd.push({ name: p.name, path: p.path });
1✔
2891
            registeredPaths.add(abs);
1✔
2892
          }
2893
        }
2894
      }
2895

2896
      // 4. Nested git repositories — prompt user to select which to add
2897
      const nestedReposSync = WorkspaceManager.discoverNestedGitRepos(dir);
5✔
2898
      const newNestedRepos = nestedReposSync.filter(n => !registeredPaths.has(resolve(dir, n.path)));
5✔
2899
      if (newNestedRepos.length > 0) {
5✔
2900
        clack.log.step(`Found ${newNestedRepos.length} nested git repositor${newNestedRepos.length === 1 ? 'y' : 'ies'} via deep scan (up to 3 levels):`);
1!
2901
        const chosen = await clack.multiselect({
1✔
2902
          message: 'Select nested repositories to register and scan:',
2903
          options: newNestedRepos.map(n => ({ value: n.path, label: `${n.name}  (${n.path})` })),
1✔
2904
          required: false,
2905
        });
2906
        if (clack.isCancel(chosen)) {
1!
2907
          clack.cancel('Sync cancelled.');
×
2908
          process.exit(0);
×
2909
        }
2910
        for (const chosenPath of chosen as string[]) {
1✔
2911
          const nested = newNestedRepos.find(n => n.path === chosenPath)!;
1✔
2912
          toAdd.push({ name: nested.name, path: nested.path });
1✔
2913
          registeredPaths.add(resolve(dir, nested.path));
1✔
2914
        }
2915
      }
2916

2917
      // 5. Monorepo packages — prompt user to select which to register
2918
      const monoPkgsSync = WorkspaceManager.discoverMonorepoPackages(dir);
5✔
2919
      const newMonoPkgs = monoPkgsSync.filter(p => !registeredPaths.has(resolve(dir, p.path)));
5✔
2920
      if (newMonoPkgs.length > 0) {
5✔
2921
        const mgr = newMonoPkgs[0].packageManager;
1✔
2922
        clack.log.step(`Found ${newMonoPkgs.length} monorepo package${newMonoPkgs.length === 1 ? '' : 's'} [${mgr}]:`);
1!
2923
        const chosenMono = await clack.multiselect({
1✔
2924
          message: 'Select monorepo packages to register and scan:',
2925
          options: newMonoPkgs.map(p => ({ value: p.path, label: `${p.name}  (${p.path})` })),
1✔
2926
          required: false,
2927
        });
2928
        if (clack.isCancel(chosenMono)) {
1!
2929
          clack.cancel('Sync cancelled.');
×
2930
          process.exit(0);
×
2931
        }
2932
        for (const chosenPath of chosenMono as string[]) {
1✔
2933
          const pkg = newMonoPkgs.find(p => p.path === chosenPath);
1✔
2934
          if (pkg) {
1!
2935
            toAdd.push({ name: pkg.name, path: pkg.path });
1✔
2936
            registeredPaths.add(resolve(dir, pkg.path));
1✔
2937
          }
2938
        }
2939
      }
2940

2941
      if (toAdd.length === 0) {
5✔
2942
        clack.log.info('No new repositories discovered to sync.');
3✔
2943
        return;
3✔
2944
      }
2945

2946
      clack.log.step(`Syncing ${toAdd.length} newly discovered repositories:`);
2✔
2947
      for (const item of toAdd) {
2✔
2948
        config.addRepo(item.name, item.path);
6✔
2949
        clack.log.success(`Registered: ${item.name} -> ${item.path}`);
6✔
2950
      }
2951
      await config.save();
2✔
2952

2953
      clack.log.step('Running initial full scan for new repositories...');
2✔
2954
      const newNames = toAdd.map(item => item.name);
6✔
2955
      const onProgress = createProgressRenderer();
2✔
2956
      const scanner = new Scanner(store, config, graph, onProgress);
2✔
2957

2958
      const onSigInt = () => {
2✔
2959
        scanner.abort();
×
2960
        onProgress.stop('Canceled');
×
2961
      };
2962
      process.once('SIGINT', onSigInt);
2✔
2963

2964
      const result = await scanner.scanFull(newNames).catch((err) => {
2✔
2965
        onProgress.stop();
×
2966
        throw err;
×
2967
      });
2968

2969
      process.removeListener('SIGINT', onSigInt);
2✔
2970
      onProgress.stop();
2✔
2971
      clack.log.success(`Scanned ${result.filesScanned} files, ${result.symbolsFound} symbols, ${result.edgesFound} edges in ${result.durationMs}ms`);
2✔
2972
    });
2973

2974
  return program;
237✔
2975
}
2976

2977
export async function loadContext(dir: string): Promise<{
2978
  config: Config;
2979
  store: Store;
2980
  graph: MapxGraph;
2981
}> {
2982
  const configPath = resolve(dir, '.mapx', 'config.json');
173✔
2983
  if (!existsSync(configPath)) {
173✔
2984
    console.error(`MapxGraph not initialized in ${dir}. Run \`mapx init ${dir}\` first.`);
1✔
2985
    process.exit(1);
1✔
2986
  }
2987

2988
  const config = await Config.load(dir);
172✔
2989
  const dbPath = resolve(dir, '.mapx', 'mapx.db');
172✔
2990
  const store = new Store(dbPath);
172✔
2991

2992
  // Ensure the DB connection is closed when the process exits normally or
2993
  // after an unhandled error — this prevents the process from hanging after
2994
  // command completion due to SQLite's open file descriptor keeping the event
2995
  // loop alive.
2996
  const closeStore = () => { try { store.close(); } catch { /* already closed */ } };
172✔
2997
  process.once('exit', closeStore);
172✔
2998
  process.once('SIGINT', () => { closeStore(); process.exit(130); });
172✔
2999
  process.once('SIGTERM', () => { closeStore(); process.exit(143); });
172✔
3000

3001
  const graph = new MapxGraph(config.repo.name);
172✔
3002

3003
  const files = store.getAllFiles();
172✔
3004
  for (const file of files) {
172✔
3005
    graph.addFileNode(
159✔
3006
      file.path as string,
3007
      file.language as string,
3008
      file.size_bytes as number,
3009
      file.lines as number
3010
    );
3011
  }
3012

3013
  // Symbols loading is skipped to optimize memory and context load times.
3014
  // CLI search and query use SQLite directly, avoiding graphology overhead.
3015

3016
  const edges = store.getAllEdges();
172✔
3017
  for (const edge of edges) {
172✔
3018
    graph.addDependencyEdge({
×
3019
      sourceFile: edge.source_file as string,
3020
      targetFile: edge.target_file as string,
3021
      sourceSymbol: edge.source_symbol as string | null,
3022
      targetSymbol: edge.target_symbol as string | null,
3023
      edgeType: edge.edge_type as any,
3024
      repo: edge.repo as string,
3025
      weight: edge.weight as number,
3026
      verifiability: edge.verifiability as any,
3027
      targetRepo: edge.target_repo as string | null,
3028
    });
3029
  }
3030

3031
  return { config, store, graph };
172✔
3032
}
3033

3034
export function getStaleFilesCount(store: Store, dir: string): number {
3035
  try {
111✔
3036
    const files = store.getAllFiles();
111✔
3037
    let changedCount = 0;
111✔
3038
    for (const file of files) {
111✔
3039
      const absPath = resolve(dir, file.path as string);
127✔
3040
      if (existsSync(absPath)) {
127✔
3041
        const stats = statSync(absPath);
108✔
3042
        const dbTime = new Date(file.last_scanned as string).getTime();
108✔
3043
        if (stats.mtimeMs > dbTime) {
108✔
3044
          changedCount++;
13✔
3045
        }
3046
      } else {
3047
        changedCount++;
19✔
3048
      }
3049
    }
3050
    return changedCount;
110✔
3051
  } catch {
3052
    return 0;
1✔
3053
  }
3054
}
3055

3056
export function checkAndPrintStaleness(store: Store, dir: string): void {
3057
  const staleCount = getStaleFilesCount(store, dir);
107✔
3058
  if (staleCount > 0) {
107✔
3059
    // Show up to 5 changed file names
3060
    const files = store.getAllFiles();
15✔
3061
    const changed: string[] = [];
15✔
3062
    for (const file of files) {
15✔
3063
      if (changed.length >= 5) break;
26✔
3064
      const absPath = resolve(dir, file.path as string);
24✔
3065
      if (existsSync(absPath)) {
24✔
3066
        const stats = statSync(absPath);
13✔
3067
        const dbTime = new Date(file.last_scanned as string).getTime();
13✔
3068
        if (stats.mtimeMs > dbTime) changed.push(file.path as string);
13!
3069
      } else {
3070
        changed.push(`${file.path} (deleted)`);
11✔
3071
      }
3072
    }
3073
    const fileList = changed.length > 0
15!
3074
      ? `\nChanged: ${changed.join(', ')}${staleCount > changed.length ? ` (and ${staleCount - changed.length} more)` : ''}`
15✔
3075
      : '';
3076
    console.warn(`⚠️  Warning: Graph index may be stale. ${staleCount} file(s) have changed on disk since the last scan. Run 'mapx update' to sync.${fileList}\n`);
15✔
3077
  }
3078
}
3079

3080
export function checkTryCatch(content: string, lineNum: number, startLine: number, isPython: boolean): boolean {
3081
  return coreCheckTryCatch(content, lineNum, startLine, isPython);
2✔
3082
}
3083

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc