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

MohamedGamil / MapX / 26714863446

31 May 2026 02:09PM UTC coverage: 79.877% (-0.6%) from 80.508%
26714863446

push

github

MohamedGamil
feat: update changelog and package dependencies to resolve native addon compilation errors in CI

2937 of 4057 branches covered (72.39%)

Branch coverage included in aggregate %.

4962 of 5832 relevant lines covered (85.08%)

11.88 hits per line

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

70.43
/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);
1✔
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();
139✔
40
  return resolve(raw);
139✔
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 }> = {
1✔
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;
11✔
97
  let p: any = null;
11✔
98
  let s: any = null;
11✔
99
  let lastCurrent = 0;
11✔
100

101
  const callback = (progressData: ScanProgress) => {
11✔
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) => {
11✔
162
    if (s) {
11✔
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) {
11✔
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;
11✔
175
}
176

177
const MAPX_MARKER_START = '<!-- mapx -->';
1✔
178
const MAPX_MARKER_END = '<!-- /mapx -->';
1✔
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();
×
271
  const providers = generator.listProviders();
×
272

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

279
  if (clack.isCancel(selected)) {
×
280
    clack.cancel('Operation cancelled.');
×
281
    process.exit(0);
×
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();
189✔
290

291
  program
189✔
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
189✔
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
189✔
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
189✔
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
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
565
      const dir = path ? resolve(path) : resolveDir({}, program.opts());
6✔
566
      const { config, store, graph } = await loadContext(dir);
6✔
567

568
      const onProgress = createProgressRenderer();
5✔
569
      const scanner = new Scanner(store, config, graph, onProgress, {
5✔
570
        excludes: opts.exclude as string[],
571
        includes: opts.include as string[],
572
      });
573

574
      const onSigInt = () => {
5✔
575
        scanner.abort();
×
576
        onProgress.stop('Canceled');
×
577
        process.stderr.write('\n');
×
578
      };
579
      process.once('SIGINT', onSigInt);
5✔
580

581
      let repoNames: string[] | undefined = undefined;
5✔
582
      if (opts.repo) {
5✔
583
        repoNames = [opts.repo as string];
1✔
584
      } else if (opts.all) {
4✔
585
        repoNames = ['all'];
1✔
586
      }
587

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

597
      process.removeListener('SIGINT', onSigInt);
5✔
598
      onProgress.stop();
5✔
599

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

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

623
      const handleLockError = (err: Error): never => {
5✔
624
        if (err.message.includes('Another scan is already running')) {
×
625
          console.error(`Error: ${err.message}`);
×
626
          process.exit(1);
×
627
        }
628
        throw err;
×
629
      };
630

631
      let repoNames: string[] | undefined = undefined;
5✔
632
      if (opts.repo) {
5✔
633
        repoNames = [opts.repo as string];
1✔
634
      } else if (opts.all) {
4✔
635
        repoNames = ['all'];
1✔
636
      }
637

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

653
      process.removeListener('SIGINT', onSigInt);
5✔
654
      onProgress.stop();
5✔
655
      if (result.interrupted) {
5!
656
        clack.log.warn(`Update interrupted after ${result.filesScanned} files.`);
×
657
      } else {
658
        clack.log.success(`Updated ${result.filesScanned} files in ${result.durationMs}ms`);
5✔
659
        clack.log.info(`${result.symbolsFound} symbols updated, ${result.edgesFound} edges updated`);
5✔
660
      }
661
    });
662

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

673
      const lastScan    = store.getMeta('last_scan_time:' + config.repo.name) || store.getMeta('last_scan_time');
3!
674
      const lastCommit  = store.getMeta('last_scan_commit:' + config.repo.name) || store.getMeta('last_scan_commit');
3!
675
      const schemaVer   = store.getMeta('schema_version');
3✔
676
      const dbPath      = resolve(dir, '.mapx', 'mapx.db');
3✔
677

678
      const activeExcludes = [
3✔
679
        ...(config.settings.excludePatterns ?? []),
3!
680
        ...((opts.exclude as string[]) ?? []),
3!
681
      ];
682
      const activeIncludes = [
3✔
683
        ...(config.settings.includePatterns ?? []),
3!
684
        ...((opts.include as string[]) ?? []),
3!
685
      ];
686

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

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

706
      console.log('\n── Collected data ───────────────────────────────────');
3✔
707
      console.log(`  Files:       ${fileCount}`);
3✔
708
      console.log(`  Symbols:     ${symbolCount}`);
3✔
709
      console.log(`  Edges:       ${edgeCount} (verified: ${verifiedEdgeCount}, inferred: ${inferredEdgeCount})`);
3✔
710

711
      // Language breakdown
712
      const langs = Object.entries(breakdown).sort((a, b) => b[1] - a[1]);
3✔
713
      if (langs.length > 0) {
3!
714
        console.log(`  Languages:`);
×
715
        for (const [lang, cnt] of langs) {
×
716
          console.log(`    ${lang.padEnd(14)} ${cnt} files`);
×
717
        }
718
      }
719

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

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

742
      // ── Graph metrics ──────────────────────────────────────────────────
743
      if (fileCount > 0) {
3!
744
        const densityNum = edgeCount / Math.max(fileCount * (fileCount - 1), 1);
×
745
        const density = (densityNum * 100).toFixed(2);
×
746

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

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

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

775
      // ── PageRank Importance ──────────────────────────────
776
      console.log('\n── PageRank Importance ──────────────────────────────');
3✔
777
      const topFiles = store.getTopFilesByPageRank(graph, 5);
3✔
778
      const topSymbols = store.getTopSymbolsByPageRank(graph, 5);
3✔
779

780
      if (topFiles.length > 0) {
3!
781
        console.log('  Top files by PageRank:');
×
782
        for (const tf of topFiles) {
×
783
          console.log(`    ${tf.pagerank.toFixed(6)}  ${tf.path}`);
×
784
        }
785
      } else {
786
        console.log('  No ranked files (run a scan first)');
3✔
787
      }
788

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

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

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

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

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

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

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

885
      const kind = opts.kind as string | undefined;
7✔
886
      const filePrefix = opts.file as string | undefined;
7✔
887
      const exact = !!opts.exact;
7✔
888
      const limit = parseInt(opts.limit as string, 10);
7✔
889
      const format = (opts.format as string) || 'text';
7!
890

891
      let results = store.searchSymbolsFiltered({ term, kind, filePrefix, exact, limit });
7✔
892
      let broadened = false;
7✔
893

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

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

925
      const rankedAll = graph.getRankedSymbols();
4✔
926
      const rankMap = new Map<string, number>();
4✔
927
      for (const item of rankedAll) {
4✔
928
        rankMap.set(`${item.filePath}::${item.name}`, item.pagerank);
×
929
      }
930

931
      // JSON format
932
      if (format === 'json') {
4✔
933
        const jsonResults = results.map(sym => ({
2✔
934
          name: sym.name,
935
          kind: sym.kind,
936
          scope: sym.scope || null,
4✔
937
          file: sym.file_path,
938
          line: sym.start_line,
939
          endLine: sym.end_line,
940
          signature: sym.signature || '',
3✔
941
          pagerank: rankMap.get(`${sym.file_path}::${sym.name}`) || 0,
4✔
942
        }));
943
        console.log(JSON.stringify({ total: results.length, broadened, term, kind: kind || null, results: jsonResults }, null, 2));
2✔
944
        return;
2✔
945
      }
946

947
      // Summary header
948
      let header = `Found ${results.length} symbol${results.length !== 1 ? 's' : ''}`;
2!
949
      if (kind && !broadened) header += ` of kind "${kind}"`;
7!
950
      if (filePrefix) header += ` in ${filePrefix}`;
2!
951
      if (broadened) header += ` (broadened from kind="${kind}" which had 0 results)`;
2✔
952
      console.log(header + ':\n');
2✔
953

954
      for (const sym of results) {
2✔
955
        const scope = sym.scope ? `${sym.scope}::` : '';
2!
956
        const key = `${sym.file_path}::${sym.name}`;
2✔
957
        const pagerankVal = rankMap.get(key) || 0;
2✔
958
        console.log(`  ${sym.kind} ${scope}${sym.name} [pagerank: ${pagerankVal.toFixed(6)}]`);
2✔
959
        console.log(`    @ ${sym.file_path}:${sym.start_line}`);
2✔
960
        if (sym.signature && sym.signature !== sym.name) {
2✔
961
          console.log(`    signature: ${sym.signature}`);
1✔
962
        }
963
      }
964
    });
965

966
  program
189✔
967
    .command('callers <symbol>')
968
    .description('Show callers of a symbol')
969
    .option('-d, --dir <path>', 'Target directory')
970
    .option('--depth <depth>', 'Traversal depth', '1')
971
    .action(async (symbol: string, opts: Record<string, unknown>) => {
972
      const dir = resolveDir(opts, program.opts());
4✔
973
      const { store } = await loadContext(dir);
4✔
974
      checkAndPrintStaleness(store, dir);
4✔
975
      const maxDepth = parseInt(opts.depth as string, 10);
4✔
976

977
      const queue: Array<{ symName: string; depth: number }> = [{ symName: symbol, depth: 0 }];
4✔
978
      const visited = new Set<string>([symbol]);
4✔
979
      const results: Array<{ caller: string; callee: string; file: string; line: number; depth: number }> = [];
4✔
980

981
      while (queue.length > 0) {
4✔
982
        const { symName, depth } = queue.shift()!;
5✔
983
        if (depth >= maxDepth) continue;
5✔
984

985
        const callers = store.getCallersOfSymbol(symName);
4✔
986
        for (const edge of callers) {
4✔
987
          const callerName = edge.source_symbol ? `${edge.source_symbol}` : '<top-level>';
1!
988
          const calleeName = edge.target_symbol || symName;
1!
989
          const meta = edge.metadata ? JSON.parse(edge.metadata) : {};
1!
990

991
          results.push({
1✔
992
            caller: callerName,
993
            callee: calleeName,
994
            file: edge.source_file,
995
            line: meta.startLine || 1,
1!
996
            depth: depth + 1
997
          });
998

999
          const nextSym = edge.source_symbol;
1✔
1000
          if (nextSym && !visited.has(nextSym)) {
1!
1001
            visited.add(nextSym);
1✔
1002
            queue.push({ symName: nextSym, depth: depth + 1 });
1✔
1003
          }
1004
        }
1005
      }
1006

1007
      if (results.length === 0) {
4✔
1008
        // Fuzzy fallback: check if symbol exists
1009
        const sym = store.getSymbolByName(symbol);
3✔
1010
        if (!sym) {
3✔
1011
          const candidates = store.getSymbolCandidatesForFuzzy();
2✔
1012
          const suggestions = findSimilarSymbols(symbol, candidates);
2✔
1013
          console.log(`Symbol "${symbol}" not found.`);
2✔
1014
          if (suggestions.length > 0) {
2✔
1015
            console.log(`\nDid you mean:`);
1✔
1016
            for (const s of suggestions) {
1✔
1017
              console.log(`  • ${s.name} (${s.kind} @ ${s.filePath})`);
1✔
1018
            }
1019
          }
1020
        } else {
1021
          console.log(`No callers found for "${symbol}"`);
1✔
1022
        }
1023
        return;
3✔
1024
      }
1025

1026
      console.log(`${results.length} caller${results.length !== 1 ? 's' : ''} of "${symbol}"${maxDepth > 1 ? ` (depth ${maxDepth})` : ''}:`);
1!
1027
      for (const res of results) {
4✔
1028
        const indent = '  '.repeat(res.depth);
1✔
1029
        console.log(`${indent}← ${res.caller} (calls ${res.callee})`);
1✔
1030
        console.log(`${indent}  @ ${res.file}:${res.line}`);
1✔
1031
      }
1032
    });
1033

1034
  program
189✔
1035
    .command('callees <symbol>')
1036
    .description('Show callees of a symbol')
1037
    .option('-d, --dir <path>', 'Target directory')
1038
    .option('--depth <depth>', 'Traversal depth', '1')
1039
    .action(async (symbol: string, opts: Record<string, unknown>) => {
1040
      const dir = resolveDir(opts, program.opts());
4✔
1041
      const { store } = await loadContext(dir);
4✔
1042
      checkAndPrintStaleness(store, dir);
4✔
1043
      const maxDepth = parseInt(opts.depth as string, 10);
4✔
1044

1045
      const queue: Array<{ symName: string; depth: number }> = [{ symName: symbol, depth: 0 }];
4✔
1046
      const visited = new Set<string>([symbol]);
4✔
1047
      const results: Array<{ caller: string; callee: string; file: string; line: number; depth: number }> = [];
4✔
1048

1049
      while (queue.length > 0) {
4✔
1050
        const { symName, depth } = queue.shift()!;
5✔
1051
        if (depth >= maxDepth) continue;
5✔
1052

1053
        const callees = store.getCalleesOfSymbol(symName);
4✔
1054
        for (const edge of callees) {
4✔
1055
          const calleeName = edge.target_symbol || '<unknown>';
1!
1056
          const callerName = edge.source_symbol || symName;
1!
1057
          const meta = edge.metadata ? JSON.parse(edge.metadata) : {};
1!
1058

1059
          results.push({
1✔
1060
            caller: callerName,
1061
            callee: calleeName,
1062
            file: edge.target_file,
1063
            line: meta.startLine || 1,
1!
1064
            depth: depth + 1
1065
          });
1066

1067
          if (edge.target_symbol && !visited.has(edge.target_symbol)) {
1!
1068
            visited.add(edge.target_symbol);
1✔
1069
            queue.push({ symName: edge.target_symbol, depth: depth + 1 });
1✔
1070
          }
1071
        }
1072
      }
1073

1074
      if (results.length === 0) {
4✔
1075
        // Fuzzy fallback: check if symbol exists
1076
        const sym = store.getSymbolByName(symbol);
3✔
1077
        if (!sym) {
3✔
1078
          const candidates = store.getSymbolCandidatesForFuzzy();
2✔
1079
          const suggestions = findSimilarSymbols(symbol, candidates);
2✔
1080
          console.log(`Symbol "${symbol}" not found.`);
2✔
1081
          if (suggestions.length > 0) {
2✔
1082
            console.log(`\nDid you mean:`);
1✔
1083
            for (const s of suggestions) {
1✔
1084
              console.log(`  • ${s.name} (${s.kind} @ ${s.filePath})`);
1✔
1085
            }
1086
          }
1087
        } else {
1088
          console.log(`No callees found for "${symbol}"`);
1✔
1089
        }
1090
        return;
3✔
1091
      }
1092

1093
      console.log(`${results.length} callee${results.length !== 1 ? 's' : ''} of "${symbol}"${maxDepth > 1 ? ` (depth ${maxDepth})` : ''}:`);
1!
1094
      for (const res of results) {
4✔
1095
        const indent = '  '.repeat(res.depth);
1✔
1096
        console.log(`${indent}→ ${res.callee} (called by ${res.caller})`);
1✔
1097
        console.log(`${indent}  @ ${res.file}:${res.line}`);
1✔
1098
      }
1099
    });
1100

1101
  program
189✔
1102
    .command('impact <symbol>')
1103
    .description('Show transitive blast-radius of changing a symbol')
1104
    .option('-d, --dir <path>', 'Target directory')
1105
    .option('--depth <depth>', 'Traversal depth', '3')
1106
    .option('--format <format>', 'text | json', 'text')
1107
    .action(async (symbol: string, opts: Record<string, unknown>) => {
1108
      const dir = resolveDir(opts, program.opts());
5✔
1109
      const { store } = await loadContext(dir);
5✔
1110
      checkAndPrintStaleness(store, dir);
5✔
1111
      const maxDepth = parseInt(opts.depth as string, 10);
5✔
1112

1113
      // Fuzzy pre-check: verify symbol exists before running analysis
1114
      const sym = store.getSymbolByName(symbol);
5✔
1115
      if (!sym) {
5✔
1116
        const candidates = store.getSymbolCandidatesForFuzzy();
2✔
1117
        const suggestions = findSimilarSymbols(symbol, candidates);
2✔
1118
        console.error(`Error: Symbol "${symbol}" not found.`);
2✔
1119
        if (suggestions.length > 0) {
2✔
1120
          console.log(`\nDid you mean:`);
1✔
1121
          for (const s of suggestions) {
1✔
1122
            console.log(`  • ${s.name} (${s.kind} @ ${s.filePath})`);
1✔
1123
          }
1124
        }
1125
        process.exit(1);
2✔
1126
      }
1127

1128
      const analyzer = new ImpactAnalyzer(store);
3✔
1129
      const result = analyzer.analyze(symbol, maxDepth, dir);
3✔
1130

1131
      if (opts.format === 'json') {
3✔
1132
        console.log(JSON.stringify(result, null, 2));
1✔
1133
      } else {
1134
        if (result.affected.length === 0) {
2✔
1135
          console.log(`No callers affected by changing "${symbol}"`);
1✔
1136
        } else {
1137
          console.log(`Impact analysis for "${symbol}":`);
1✔
1138
          for (const item of result.affected) {
1✔
1139
            console.log(`  [${item.risk}] ${item.symbol} (${item.file}) [depth: ${item.depth}, type: ${item.edgeType}]`);
1✔
1140
          }
1141
        }
1142
        console.log(`\nRecommendation: ${result.recommendation}`);
2✔
1143
      }
1144
    });
1145

1146
  program
189✔
1147
    .command('node <symbol>')
1148
    .description('Show full symbol details and optional source code')
1149
    .option('-d, --dir <path>', 'Target directory')
1150
    .option('--source', 'Extract and display source code', false)
1151
    .option('--format <format>', 'Output format: text | json', 'text')
1152
    .action(async (symbol: string, opts: Record<string, unknown>) => {
1153
      const dir = resolveDir(opts, program.opts());
8✔
1154
      const { store } = await loadContext(dir);
8✔
1155
      checkAndPrintStaleness(store, dir);
8✔
1156

1157
      const sym = store.getSymbolByName(symbol);
8✔
1158
      if (!sym) {
8✔
1159
        // Fuzzy fallback
1160
        const candidates = store.getSymbolCandidatesForFuzzy();
2✔
1161
        const suggestions = findSimilarSymbols(symbol, candidates);
2✔
1162
        console.error(`Error: Symbol "${symbol}" not found.`);
2✔
1163
        if (suggestions.length > 0) {
2✔
1164
          console.log(`\nDid you mean:`);
1✔
1165
          for (const s of suggestions) {
1✔
1166
            console.log(`  • ${s.name} (${s.kind} @ ${s.filePath})`);
1✔
1167
          }
1168
        }
1169
        console.log(`\nTip: Use mapx search to find symbols by pattern. Example: mapx search "${symbol.length > 3 ? symbol.slice(0, 4) : symbol}*"`);
2!
1170
        process.exit(1);
2✔
1171
      }
1172

1173
      const callers = store.getCallersOfSymbol(symbol);
6✔
1174
      const callees = store.getCalleesOfSymbol(symbol);
6✔
1175
      const format = (opts.format as string) || 'text';
6!
1176

1177
      // JSON format
1178
      if (format === 'json') {
8✔
1179
        const result: Record<string, any> = {
3✔
1180
          name: sym.name,
1181
          kind: sym.kind,
1182
          scope: sym.scope || null,
6✔
1183
          file: sym.file_path,
1184
          startLine: sym.start_line,
1185
          endLine: sym.end_line,
1186
          signature: sym.signature || '',
4✔
1187
          callerCount: callers.length,
1188
          calleeCount: callees.length,
1189
        };
1190
        if (opts.source) {
3✔
1191
          try {
1✔
1192
            const absolutePath = resolve(dir, sym.file_path as string);
1✔
1193
            const content = readFileSync(absolutePath, 'utf8');
1✔
1194
            const lines = content.split('\n');
1✔
1195
            const start = (sym.start_line as number) - 1;
1✔
1196
            const end = (sym.end_line as number);
1✔
1197
            result.source = lines.slice(start, end).join('\n');
1✔
1198
          } catch (err: any) {
1199
            result.sourceError = err.message;
1✔
1200
          }
1201
        }
1202
        console.log(JSON.stringify(result, null, 2));
3✔
1203
        return;
3✔
1204
      }
1205

1206
      console.log(`Symbol: ${sym.scope ? `${sym.scope}::` : ''}${sym.name}`);
3!
1207
      console.log(`Kind:   ${sym.kind}`);
8✔
1208
      console.log(`File:   ${sym.file_path}`);
8✔
1209
      console.log(`Lines:  ${sym.start_line}-${sym.end_line}`);
8✔
1210
      console.log(`Signature: ${sym.signature}`);
8✔
1211
      console.log(`Callers: ${callers.length}`);
8✔
1212
      console.log(`Callees: ${callees.length}`);
8✔
1213

1214
      if (opts.source) {
8✔
1215
        try {
1✔
1216
          const absolutePath = resolve(dir, sym.file_path as string);
1✔
1217
          const content = readFileSync(absolutePath, 'utf8');
1✔
1218
          const lines = content.split('\n');
1✔
1219
          const start = (sym.start_line as number) - 1;
1✔
1220
          const end = (sym.end_line as number);
1✔
1221
          const sliced = lines.slice(start, end).join('\n');
1✔
1222
          console.log('\nSource Code:');
1✔
1223
          console.log('----------------------------------------');
1✔
1224
          console.log(sliced);
1✔
1225
          console.log('----------------------------------------');
1✔
1226
        } catch (err: any) {
1227
          console.error(`Failed to read source code: ${err.message}`);
1✔
1228
        }
1229
      }
1230
    });
1231

1232
  program
189✔
1233
    .command('files')
1234
    .description('List indexed files with prefix/lang/sort filters')
1235
    .option('-d, --dir <path>', 'Target directory')
1236
    .option('--path <pattern>', 'Filter by path prefix or glob (e.g. src/core/*.ts)')
1237
    .option('--lang <lang>', 'Filter by language')
1238
    .option('--sort <sort>', 'lines | path', 'path')
1239
    .option('--limit <limit>', 'Max files to return', '50')
1240
    .action(async (opts: Record<string, unknown>) => {
1241
      const dir = resolveDir(opts, program.opts());
8✔
1242
      const { store } = await loadContext(dir);
8✔
1243
      checkAndPrintStaleness(store, dir);
8✔
1244

1245
      const results = store.getFilesFiltered({
8✔
1246
        pathPrefix: opts.path as string,
1247
        lang: opts.lang as string,
1248
        sort: opts.sort as 'lines' | 'path',
1249
        limit: parseInt(opts.limit as string, 10),
1250
      });
1251

1252
      if (results.length === 0) {
8✔
1253
        console.log('No files found matching filters');
3✔
1254
        return;
3✔
1255
      }
1256

1257
      for (const file of results) {
5✔
1258
        console.log(`  ${file.path} (${file.language}, ${file.lines} lines, ${file.size_bytes} bytes)`);
6✔
1259
      }
1260
    });
1261

1262
  program
189✔
1263
    .command('deps <file>')
1264
    .description('Show dependencies for a file')
1265
    .option('-d, --dir <path>', 'Target directory')
1266
    .action(async (file: string, opts: Record<string, unknown>) => {
1267
      const dir = resolveDir(opts, program.opts());
7✔
1268
      const { store, graph } = await loadContext(dir);
7✔
1269
      checkAndPrintStaleness(store, dir);
7✔
1270

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

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

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

1283
        const deps = graph.getDependencies(filePath);
7✔
1284
        const rdeps = graph.getReverseDependencies(filePath);
7✔
1285

1286
        if (deps.length > 0) {
7✔
1287
          console.log('Dependencies:');
2✔
1288
          for (const dep of deps) {
2✔
1289
            console.log(`  → ${dep.target} (${dep.type})`);
2✔
1290
          }
1291
        } else {
1292
          console.log('No dependencies found');
5✔
1293
        }
1294

1295
        if (rdeps.length > 0) {
7✔
1296
          console.log('\nDepended on by:');
2✔
1297
          for (const rdep of rdeps) {
2✔
1298
            console.log(`  ← ${rdep.source} (${rdep.type})`);
2✔
1299
          }
1300
        }
1301
      }
1302
    });
1303

1304
  program
189✔
1305
    .command('trace [symbol-or-file]')
1306
    .description('Trace data flow paths from a starting symbol or file')
1307
    .option('-d, --dir <path>', 'Target directory')
1308
    .option('--direction <dir>', 'up | down | both', 'both')
1309
    .option('--depth <n>', 'Maximum traversal depth', '3')
1310
    .option('--max-depth <n>', 'Maximum traversal depth (alias for --depth)')
1311
    .option('--format <fmt>', 'text | dot | json', 'text')
1312
    .option('--include-structural', 'Include import/extends edges in trace', false)
1313
    .option('--sources', 'Show entry points', false)
1314
    .option('--sinks', 'Show terminal consumers', false)
1315
    .option('--to <target>', 'Find the shortest path to target symbol/file')
1316
    .action(async (start: string | undefined, opts: Record<string, unknown>) => {
1317
      const dir = resolveDir(opts, program.opts());
17✔
1318
      const { config, store } = await loadContext(dir);
17✔
1319
      checkAndPrintStaleness(store, dir);
17✔
1320

1321
      const tracer = new FlowTracer(store);
17✔
1322

1323
      if (opts.sources) {
17✔
1324
        const sources = tracer.findSources(config.repo.name);
2✔
1325
        console.log(`\nEntry points (data sources) — ${sources.length} found:`);
2✔
1326
        for (const s of sources) {
2✔
1327
          let extra = '[no incoming data edges]';
4✔
1328
          if (s.file.includes('routes/')) {
4!
1329
            const routes = store.getEdgesForFile(s.file).filter(e => e.edge_type === 'route');
×
1330
            extra = `[route file — ${routes.length} controller endpoints]`;
×
1331
          } else if (s.file.includes('app/Jobs/')) {
4✔
1332
            extra = '[dispatched externally — queue worker]';
1✔
1333
          } else if (s.file.includes('app/Listeners/')) {
3✔
1334
            extra = '[event listener — external trigger]';
1✔
1335
          } else if (s.file.includes('app/Http/Middleware/')) {
2✔
1336
            extra = '[middleware — filter chain entry]';
1✔
1337
          }
1338
          console.log(`  ${s.file.padEnd(40)} ${extra}`);
4✔
1339
        }
1340
        return;
2✔
1341
      }
1342

1343
      if (opts.sinks) {
15✔
1344
        const sinks = tracer.findSinks(config.repo.name);
2✔
1345
        console.log(`\nTerminal consumers (data sinks) — ${sinks.length} found:`);
2✔
1346
        for (const s of sinks) {
2✔
1347
          const inEdges = store.getReverseEdges(s.file).filter(e => [
4✔
1348
            'call', 'instantiation', 'param_type', 'return_type', 'relation', 'dispatch', 'notify', 'route', 'render'
1349
          ].includes(e.edge_type as string));
1350
          let extra = `[terminal — no outgoing data edges]`;
4✔
1351
          if (s.file.includes('DatabaseManager') || s.file.includes('database')) {
4!
1352
            extra = `[DB facade → raw SQL — ${inEdges.length} in-edges]`;
×
1353
          } else if (s.file.includes('CacheManager') || s.file.includes('cache')) {
4✔
1354
            extra = `[Cache facade → Redis/Memcache — ${inEdges.length} in-edges]`;
1✔
1355
          } else if (s.file.includes('Mailer') || s.file.includes('mail')) {
3✔
1356
            extra = `[Mail facade → SMTP — ${inEdges.length} in-edges]`;
1✔
1357
          } else if (s.file.includes('QueueManager') || s.file.includes('queue')) {
2✔
1358
            extra = `[Queue::push — ${inEdges.length} in-edges]`;
1✔
1359
          }
1360
          console.log(`  ${s.file.padEnd(40)} ${extra}`);
4✔
1361
        }
1362
        return;
2✔
1363
      }
1364

1365
      if (!start) {
13✔
1366
        console.error('Error: start symbol or file is required unless --sources or --sinks is specified.');
1✔
1367
        process.exit(1);
1✔
1368
      }
1369

1370
      if (opts.to) {
12✔
1371
        const path = tracer.findCriticalPath(start, opts.to as string, config.repo.name);
2✔
1372
        if (!path) {
2✔
1373
          console.log(`No path found from "${start}" to "${opts.to}"`);
1✔
1374
          return;
1✔
1375
        }
1376

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

1380
        for (let i = 0; i < path.nodes.length; i++) {
1✔
1381
          const node = path.nodes[i];
2✔
1382
          const indent = '  '.repeat(i);
2✔
1383
          const prefix = i === 0 ? '' : `└─[${node.incomingEdgeType}]─→  `;
2✔
1384
          const suffix = i === path.nodes.length - 1 ? '  ⊗' : '';
2✔
1385
          const name = node.symbol ? node.symbol : node.file;
2!
1386
          console.log(`${indent}${prefix}${name}${suffix}`);
2✔
1387
        }
1388
        return;
1✔
1389
      }
1390

1391
      const requestedDepth = opts.maxDepth !== undefined ? opts.maxDepth : opts.depth;
10!
1392
      const parsedDepth = parseInt(requestedDepth as string, 10);
17✔
1393

1394
      const result = tracer.trace({
17✔
1395
        startSymbol: start,
1396
        direction: opts.direction as any,
1397
        maxDepth: parsedDepth,
1398
        includeStructural: !!opts.includeStructural,
1399
        repo: config.repo.name,
1400
      });
1401

1402
      if (opts.format === 'json') {
17✔
1403
        const jsonOutput = {
3✔
1404
          start: result.start,
1405
          direction: result.direction,
1406
          maxDepth: parsedDepth,
1407
          nodeCount: result.nodeCount,
1408
          edgeCount: result.edgeCount,
1409
          maxDepthReached: result.maxDepthReached,
1410
          sources: result.sources.map(s => ({ file: s.file, symbol: s.symbol })),
1✔
1411
          sinks: result.sinks.map(s => ({ file: s.file, symbol: s.symbol })),
1✔
1412
          cycles: result.cycles,
1413
          nodes: Array.from(new Map(result.paths.flatMap(p => p.nodes).map(n => [`${n.file}::${n.symbol || ''}`, n])).values()).map(n => ({
4!
1414
            file: n.file,
1415
            symbol: n.symbol,
1416
            depth: n.depth,
1417
            incomingEdgeType: n.incomingEdgeType,
1418
          })),
1419
          edges: Array.from(new Set(result.paths.flatMap(p => {
1420
            const arr = [];
2✔
1421
            for (let i = 1; i < p.nodes.length; i++) {
2✔
1422
              arr.push(JSON.stringify({
2✔
1423
                from: p.nodes[i - 1].file,
1424
                to: p.nodes[i].file,
1425
                edgeType: p.nodes[i].incomingEdgeType,
1426
                fromSymbol: p.nodes[i - 1].symbol,
1427
                toSymbol: p.nodes[i].symbol,
1428
              }));
1429
            }
1430
            return arr;
2✔
1431
          }))).map(s => JSON.parse(s)),
2✔
1432
        };
1433
        console.log(JSON.stringify(jsonOutput, null, 2));
3✔
1434
        return;
3✔
1435
      }
1436

1437
      if (opts.format === 'dot') {
7✔
1438
        const lines: string[] = [];
3✔
1439
        const safeStartName = (result.start.symbol || result.start.file).replace(/[^a-zA-Z0-9]/g, '_');
3!
1440
        lines.push(`digraph Trace_${safeStartName} {`);
3✔
1441
        lines.push('  rankdir=TB;');
3✔
1442
        lines.push(`  label="Trace: ${result.start.symbol || result.start.file} (${result.direction}stream, depth≤${parsedDepth})";`);
3!
1443
        lines.push('  fontsize=12;');
3✔
1444
        lines.push('  node [shape=box, style=filled, fontsize=10];');
3✔
1445
        lines.push('');
3✔
1446

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

1450
        for (const p of result.paths) {
3✔
1451
          for (let i = 0; i < p.nodes.length; i++) {
2✔
1452
            const n = p.nodes[i];
4✔
1453
            const key = `${n.file}::${n.symbol || ''}`;
4!
1454
            if (!uniqueNodes.has(key)) {
4!
1455
              let shape = 'box';
4✔
1456
              let color = '#E8F4FD';
4✔
1457

1458
              const isStart = n.file === result.start.file && n.symbol === result.start.symbol;
4✔
1459
              const isSink = result.sinks.some(s => s.file === n.file && s.symbol === n.symbol);
4✔
1460
              const isSource = result.sources.some(s => s.file === n.file && s.symbol === n.symbol);
4✔
1461

1462
              if (isStart) {
4✔
1463
                shape = 'diamond';
2✔
1464
                color = '#FFE0B2';
2✔
1465
              } else if (isSink) {
2!
1466
                shape = 'octagon';
2✔
1467
                color = '#FFEBEE';
2✔
1468
              } else if (isSource) {
×
1469
                shape = 'ellipse';
×
1470
                color = '#E8F5E9';
×
1471
              }
1472

1473
              uniqueNodes.set(key, { file: n.file, symbol: n.symbol, shape, color });
4✔
1474
            }
1475

1476
            if (i > 0) {
4✔
1477
              const fromNode = p.nodes[i - 1];
2✔
1478
              const toNode = p.nodes[i];
2✔
1479
              edgesSet.add(JSON.stringify({
2✔
1480
                from: `${fromNode.file}::${fromNode.symbol || ''}`,
2!
1481
                to: `${toNode.file}::${toNode.symbol || ''}`,
2!
1482
                type: toNode.incomingEdgeType,
1483
              }));
1484
            }
1485
          }
1486
        }
1487

1488
        for (const [key, n] of uniqueNodes.entries()) {
3✔
1489
          const label = n.symbol || n.file.split('/').pop() || n.file;
4!
1490
          lines.push(`  "${key}" [label="${label}", fillcolor="${n.color}", shape=${n.shape}];`);
4✔
1491
        }
1492

1493
        lines.push('');
3✔
1494

1495
        for (const edgeStr of edgesSet) {
3✔
1496
          const e = JSON.parse(edgeStr);
2✔
1497
          lines.push(`  "${e.from}" -> "${e.to}" [label="${e.type}"];`);
2✔
1498
        }
1499

1500
        lines.push('}');
3✔
1501
        console.log(lines.join('\n'));
3✔
1502
        return;
3✔
1503
      }
1504

1505
      const dirSymbol = result.direction === 'down' ? '↓ downstream' : result.direction === 'up' ? '↑ upstream' : '↕ bidirectional';
4!
1506
      console.log(`\nTrace: ${start}  ${dirSymbol}  depth≤${parsedDepth}`);
17✔
1507
      console.log('─'.repeat(53));
17✔
1508
      console.log('');
17✔
1509

1510
      const printNode = (node: TraceNode, indentLevel: number) => {
17✔
1511
        const indent = '  '.repeat(indentLevel);
7✔
1512
        const prefix = indentLevel === 0 ? '' : `└─[${node.incomingEdgeType}]─→  `;
7✔
1513
        const displayName = node.symbol || node.file;
7!
1514
        const filePart = node.symbol ? `  (${node.file})` : '';
7!
1515

1516
        const isSink = result.sinks.some(s => s.file === node.file && s.symbol === node.symbol);
7✔
1517
        const sinkStr = isSink ? '  ⊗ sink' : '';
7✔
1518

1519
        const cycle = result.cycles.find(c => c.fromFile === node.file && c.fromSymbol === node.symbol);
7✔
1520
        const cycleStr = cycle ? '  ↻ cycle' : '';
7✔
1521

1522
        console.log(`${indent}${prefix}${displayName}${filePart}${sinkStr}${cycleStr}`);
7✔
1523

1524
        if (!cycle) {
7✔
1525
          const children: TraceNode[] = [];
6✔
1526
          const seenChildKeys = new Set<string>();
6✔
1527
          for (const path of result.paths) {
6✔
1528
            const idx = path.nodes.findIndex(n => n.file === node.file && n.symbol === node.symbol && n.depth === node.depth);
7✔
1529
            if (idx !== -1 && idx + 1 < path.nodes.length) {
5✔
1530
              const nextNode = path.nodes[idx + 1];
3✔
1531
              const key = `${nextNode.file}::${nextNode.symbol || ''}::${nextNode.depth}`;
3!
1532
              if (!seenChildKeys.has(key)) {
3!
1533
                seenChildKeys.add(key);
3✔
1534
                children.push(nextNode);
3✔
1535
              }
1536
            }
1537
          }
1538

1539
          for (const child of children) {
6✔
1540
            printNode(child, indentLevel + 1);
3✔
1541
          }
1542
        }
1543
      };
1544

1545
      const startNode: TraceNode = {
17✔
1546
        file: result.start.file,
1547
        symbol: result.start.symbol,
1548
        depth: 0,
1549
        incomingEdgeType: 'start',
1550
      };
1551
      printNode(startNode, 0);
17✔
1552

1553
      console.log('');
17✔
1554
      const cyclesStr = result.cycles.length > 0 ? `   Cycles: ${result.cycles.length}` : '';
17✔
1555
      console.log(`Nodes: ${result.nodeCount}   Edges: ${result.edgeCount}   Max depth: ${parsedDepth}${cyclesStr}`);
17✔
1556
      if (result.sinks.length > 0) {
17✔
1557
        const sinkNames = result.sinks.map(s => s.symbol || s.file.split('/').pop() || s.file);
3!
1558
        console.log(`Sinks: ${sinkNames.join(', ')}`);
3✔
1559
      }
1560
    });
1561

1562
  program
189✔
1563
    .command('sources')
1564
    .description('Find entry points (data sources) in the codebase')
1565
    .option('-d, --dir <path>', 'Target directory')
1566
    .action(async (opts: Record<string, unknown>) => {
1567
      const dir = resolveDir(opts, program.opts());
6✔
1568
      const { config, store } = await loadContext(dir);
6✔
1569
      checkAndPrintStaleness(store, dir);
6✔
1570

1571
      const tracer = new FlowTracer(store);
6✔
1572
      const sources = tracer.findSources(config.repo.name);
6✔
1573
      console.log(`\nEntry points (data sources) — ${sources.length} found:`);
6✔
1574
      for (const s of sources) {
6✔
1575
        let extra = '[no incoming data edges]';
5✔
1576
        if (s.file.includes('routes/')) {
5✔
1577
          const routes = store.getEdgesForFile(s.file).filter(e => e.edge_type === 'route');
2✔
1578
          extra = `[route file — ${routes.length} controller endpoints]`;
2✔
1579
        } else if (s.file.includes('app/Jobs/')) {
3✔
1580
          extra = '[dispatched externally — queue worker]';
1✔
1581
        } else if (s.file.includes('app/Listeners/')) {
2✔
1582
          extra = '[event listener — external trigger]';
1✔
1583
        } else if (s.file.includes('app/Http/Middleware/')) {
1!
1584
          extra = '[middleware — filter chain entry]';
×
1585
        }
1586
        console.log(`  ${s.file.padEnd(40)} ${extra}`);
5✔
1587
      }
1588
    });
1589

1590
  program
189✔
1591
    .command('sinks')
1592
    .description('Find terminal consumers (data sinks) in the codebase')
1593
    .option('-d, --dir <path>', 'Target directory')
1594
    .action(async (opts: Record<string, unknown>) => {
1595
      const dir = resolveDir(opts, program.opts());
6✔
1596
      const { config, store } = await loadContext(dir);
6✔
1597
      checkAndPrintStaleness(store, dir);
6✔
1598

1599
      const tracer = new FlowTracer(store);
6✔
1600
      const sinks = tracer.findSinks(config.repo.name);
6✔
1601
      console.log(`\nTerminal consumers (data sinks) — ${sinks.length} found:`);
6✔
1602
      for (const s of sinks) {
6✔
1603
        const inEdges = store.getReverseEdges(s.file).filter(e => [
5✔
1604
          'call', 'instantiation', 'param_type', 'return_type', 'relation', 'dispatch', 'notify', 'route', 'render'
1605
        ].includes(e.edge_type as string));
1606
        let extra = `[terminal — no outgoing data edges]`;
5✔
1607
        if (s.file.includes('DatabaseManager') || s.file.includes('database')) {
5✔
1608
          extra = `[DB facade → raw SQL — ${inEdges.length} in-edges]`;
2✔
1609
        } else if (s.file.includes('CacheManager') || s.file.includes('cache')) {
3✔
1610
          extra = `[Cache facade → Redis/Memcache — ${inEdges.length} in-edges]`;
1✔
1611
        } else if (s.file.includes('Mailer') || s.file.includes('mail')) {
2✔
1612
          extra = `[Mail facade → SMTP — ${inEdges.length} in-edges]`;
1✔
1613
        } else if (s.file.includes('QueueManager') || s.file.includes('queue')) {
1!
1614
          extra = `[Queue::push — ${inEdges.length} in-edges]`;
×
1615
        }
1616
        console.log(`  ${s.file.padEnd(40)} ${extra}`);
5✔
1617
      }
1618
    });
1619

1620
  program
189✔
1621
    .command('context <task>')
1622
    .description('Generate task-specific workspace context within a token budget')
1623
    .option('-d, --dir <path>', 'Target directory')
1624
    .option('--seeds <list>', 'Comma-separated list of seed symbols or file paths')
1625
    .option('--tokens <budget>', 'Maximum estimated token budget', '8192')
1626
    .option('--depth <n>', 'Graph traversal depth', '2')
1627
    .option('--format <format>', 'Output format: text | json', 'text')
1628
    .action(async (task: string, opts: Record<string, unknown>) => {
1629
      const dir = resolveDir(opts, program.opts());
5✔
1630
      const { config, store, graph } = await loadContext(dir);
5✔
1631
      checkAndPrintStaleness(store, dir);
5✔
1632

1633
      const builder = new ContextBuilder(store, graph);
5✔
1634
      const parsedTokens = parseInt(opts.tokens as string, 10) || 8192;
5!
1635
      const parsedDepth = parseInt(opts.depth as string, 10) || 2;
5!
1636
      const format = opts.format as string;
5✔
1637
      const seeds = opts.seeds ? (opts.seeds as string).split(',').map(s => s.trim()) : undefined;
5!
1638

1639
      const result = await builder.buildContext({
5✔
1640
        task,
1641
        seeds,
1642
        tokens: parsedTokens,
1643
        depth: parsedDepth,
1644
      });
1645

1646
      if (format === 'json') {
5✔
1647
        console.log(JSON.stringify(result, null, 2));
2✔
1648
        return;
2✔
1649
      }
1650

1651
      console.log('# MapX Smart Context');
3✔
1652
      console.log(`*Estimated tokens:* ${result.estimatedTokens}\n`);
3✔
1653
      
1654
      console.log('## Included Files');
3✔
1655
      if (result.includedFiles.length === 0) {
3✔
1656
        console.log('None');
1✔
1657
      } else {
1658
        for (const f of result.includedFiles) {
2✔
1659
          console.log(`### [${f.path}](file://${resolve(dir, f.path)})`);
2✔
1660
          console.log(`- Language: ${f.language}`);
2✔
1661
          console.log(`- Lines: ${f.lineCount} | Size: ${f.sizeBytes} bytes`);
2✔
1662
          if (f.symbols.length > 0) {
2✔
1663
            console.log('- Symbols:');
1✔
1664
            for (const sym of f.symbols) {
1✔
1665
              const scopeStr = sym.scope ? `${sym.scope}::` : '';
1!
1666
              console.log(`  - \`${sym.kind}\` \`${scopeStr}${sym.name}\` (lines ${sym.startLine}-${sym.endLine})`);
1✔
1667
            }
1668
          }
1669
        }
1670
      }
1671

1672
      if (result.edges.length > 0) {
3✔
1673
        console.log('\n## Cross-File Dependencies');
2✔
1674
        for (const edge of result.edges) {
2✔
1675
          const srcSym = edge.sourceSymbol ? `#${edge.sourceSymbol}` : '';
2!
1676
          const tgtSym = edge.targetSymbol ? `#${edge.targetSymbol}` : '';
2!
1677
          console.log(`- \`${edge.sourceFile}${srcSym}\` → \`${edge.targetFile}${tgtSym}\` (${edge.edgeType})`);
2✔
1678
        }
1679
      }
1680

1681
      if (result.excludedFiles.length > 0) {
3✔
1682
        console.log('\n## Excluded Files (Token budget exhausted)');
2✔
1683
        for (const f of result.excludedFiles) {
2✔
1684
          console.log(`- ${f}`);
2✔
1685
        }
1686
      }
1687
    });
1688

1689
  program
189✔
1690
    .command('export')
1691
    .description('Export code graph for LLM consumption')
1692
    .option('-d, --dir <path>', 'Target directory')
1693
    .option('--format <format>', 'Output format: llm, json, dot, svg, toon', 'llm')
1694
    .option('--tokens <budget>', 'Token budget for LLM export', '8192')
1695
    .option('--repo <name>', 'Filter by repo name')
1696
    .option('-o, --output <file>', 'Write output to file instead of stdout')
1697
    .option('--exclude <glob>', 'Exclude glob pattern(s)', collectPatterns, [])
1698
    .option('--include <glob>', 'Include glob pattern(s)', collectPatterns, [])
1699
    .option('--delimiter <delimiter>', 'Delimiter for TOON format: comma, tab, pipe', 'comma')
1700
    .option('--key-folding', 'Collapse single-key chains into dotted paths for TOON', false)
1701
    .option('--cluster <mode>', 'Cluster rendering mode for DOT/SVG: none, auto', 'none')
1702
    .option('--depth <n>', 'Maximum cluster nesting depth for DOT/SVG export', '3')
1703
    .option('--fallback-grid', 'Force using fallback grid SVG export', false)
1704
    .action(async (opts: Record<string, unknown>) => {
1705
      const dir = resolveDir(opts, program.opts());
12✔
1706
      const { config, store, graph } = await loadContext(dir);
12✔
1707

1708
      const format = opts.format as string;
12✔
1709
      const tokenBudget = parseInt(opts.tokens as string, 10) || 8192;
12!
1710
      const outputPath = opts.output as string | undefined;
12✔
1711
      const delimiter = opts.delimiter as 'comma' | 'tab' | 'pipe' | undefined;
12✔
1712
      const keyFolding = !!opts.keyFolding;
12✔
1713
      const clusterMode = (opts.cluster as string) === 'none' ? 'none' as const : 'auto' as const;
12!
1714
      const clusterDepth = opts.depth ? parseInt(opts.depth as string, 10) : 3;
12!
1715
      const fallbackGrid = !!opts.fallbackGrid;
12✔
1716
      const clusterOpts = { cluster: clusterMode, depth: clusterDepth, forceFallback: fallbackGrid };
12✔
1717

1718
      if (outputPath) {
12✔
1719
        const outputDir = resolve(outputPath, '..');
1✔
1720
        if (!existsSync(outputDir)) {
1!
1721
          console.error(`Error: output directory does not exist: ${outputDir}`);
×
1722
          process.exit(1);
×
1723
        }
1724
        try {
1✔
1725
          writeFileSync(outputPath, '', 'utf-8');
1✔
1726
        } catch {
1727
          console.error(`Error: cannot write to: ${resolve(outputPath)}`);
×
1728
          process.exit(1);
×
1729
        }
1730
      }
1731

1732
      const excludes = [
12✔
1733
        ...(config.settings.excludePatterns ?? []),
12!
1734
        ...((opts.exclude as string[]) ?? []),
12!
1735
      ];
1736
      const includes = [
12✔
1737
        ...(config.settings.includePatterns ?? []),
12!
1738
        ...((opts.include as string[]) ?? []),
12!
1739
      ];
1740
      const matcher = buildMatcher(excludes, includes);
12✔
1741
      const allFiles = store.getAllFiles(opts.repo as string | undefined).map(f => f.path as string);
12✔
1742
      const filteredFiles = allFiles.filter(f => matcher(f));
12✔
1743

1744
      let output: string;
1745

1746
      switch (format) {
12✔
1747
        case 'json': {
1748
          const exporter = new GraphExporter(store, graph);
2✔
1749
          output = exporter.exportAsJSONString(opts.repo as string | undefined, filteredFiles);
2✔
1750
          break;
2✔
1751
        }
1752
        case 'dot': {
1753
          const exporter = new DotExporter(store, graph);
2✔
1754
          output = exporter.export(opts.repo as string | undefined, filteredFiles, clusterOpts);
2✔
1755
          break;
2✔
1756
        }
1757
        case 'svg': {
1758
          const exporter = new SvgExporter(store, graph);
2✔
1759
          output = exporter.export(opts.repo as string | undefined, filteredFiles, clusterOpts);
2✔
1760
          break;
2✔
1761
        }
1762
        case 'toon': {
1763
          const exporter = new ToonExporter(store, graph);
3✔
1764
          output = exporter.export({
3✔
1765
            format: 'toon',
1766
            tokenBudget,
1767
            repo: opts.repo as string | undefined,
1768
            files: filteredFiles,
1769
            delimiter,
1770
            keyFolding,
1771
          });
1772
          break;
3✔
1773
        }
1774
        case 'llm':
1775
        default: {
1776
          const exporter = new LLMExporter(store, graph);
3✔
1777
          output = exporter.export({
3✔
1778
            format: 'llm',
1779
            tokenBudget,
1780
            repo: opts.repo as string | undefined,
1781
            files: filteredFiles,
1782
          });
1783
          break;
3✔
1784
        }
1785
      }
1786

1787
      if (outputPath) {
12✔
1788
        writeFileSync(resolve(outputPath), output, 'utf-8');
1✔
1789
        console.log(`Exported ${format} to ${resolve(outputPath)} (${Buffer.byteLength(output, 'utf-8')} bytes)`);
1✔
1790
      } else {
1791
        console.log(output);
11✔
1792
      }
1793
    });
1794

1795
  program
189✔
1796
    .command('summary')
1797
    .description('Show project summary')
1798
    .argument('[path]', 'Target directory')
1799
    .action(async (path: string | undefined) => {
1800
      const dir = path ? resolve(path) : resolveDir({}, program.opts());
3✔
1801
      const { store, graph, config } = await loadContext(dir);
3✔
1802

1803
      const fileCount = store.getFileCount();
3✔
1804
      const symbolCount = store.getSymbolCount();
3✔
1805
      const edgeCount = store.getEdgeCount();
3✔
1806
      const breakdown = store.getLanguageBreakdown();
3✔
1807

1808
      console.log(`Project: ${config.repo.name} (${dir})`);
3✔
1809
      console.log(`Files: ${fileCount}`);
3✔
1810
      console.log(`Symbols: ${symbolCount}`);
3✔
1811
      console.log(`Dependencies: ${edgeCount}`);
3✔
1812
      console.log(`Languages: ${Object.entries(breakdown).map(([l, c]) => `${l} (${c})`).join(', ')}`);
3✔
1813
    });
1814

1815
  const langCmd = program
189✔
1816
    .command('lang')
1817
    .description('Manage language grammars and configuration');
1818

1819
  langCmd
189✔
1820
    .command('list')
1821
    .description('List all supported languages, their extensions, tier, and status')
1822
    .action(() => {
1823
      const langs = getBuiltinLanguages();
2✔
1824
      console.log('Supported languages:');
2✔
1825
      for (const [name, def] of Object.entries(langs)) {
2✔
1826
        const installed = isLanguageInstalled(name) ? 'Installed' : 'Not Installed';
×
1827
        console.log(`  - ${name} (${def.extensions.join(', ')} | tier: ${def.tier} | status: ${installed})`);
×
1828
      }
1829
    });
1830

1831
  langCmd
189✔
1832
    .command('install <lang>')
1833
    .description('Install grammar and query files for an installable language')
1834
    .action(async (lang: string) => {
1835
      try {
2✔
1836
        clack.log.step(`Installing language '${lang}'...`);
2✔
1837
        await installLanguage(lang);
2✔
1838
        clack.log.success(`Successfully installed language '${lang}'.`);
2✔
1839
      } catch (err: any) {
1840
        clack.log.error(`Error installing language '${lang}': ${err.message}`);
×
1841
        process.exit(1);
×
1842
      }
1843
    });
1844

1845
  langCmd
189✔
1846
    .command('uninstall <lang>')
1847
    .description('Uninstall grammar and query files for an installable language')
1848
    .action(async (lang: string) => {
1849
      try {
2✔
1850
        clack.log.step(`Uninstalling language '${lang}'...`);
2✔
1851
        await uninstallLanguage(lang);
2✔
1852
        clack.log.success(`Successfully uninstalled language '${lang}'.`);
2✔
1853
      } catch (err: any) {
1854
        clack.log.error(`Error uninstalling language '${lang}': ${err.message}`);
×
1855
        process.exit(1);
×
1856
      }
1857
    });
1858

1859
  program
189✔
1860
    .command('serve')
1861
    .description('Start MCP server (stdio transport)')
1862
    .option('-d, --dir <path>', 'Default target directory for MCP tools')
1863
    .option('--port <port>', 'Port for SSE transport (default: 45123)', '45123')
1864
    .option('--sse', 'Enable SSE transport instead of stdio')
1865
    .option('--ui', 'Enable UI dashboard alongside MCP server')
1866
    .option('--ui-port <port>', 'Port to run UI on (default: 45124)', '45124')
1867
    .option('--ui-host <host>', 'Host to run UI on (default: 127.0.0.1)', '127.0.0.1')
1868
    .option('--ui-token <token>', 'Bearer token for authorization')
1869
    .option('--debug', 'Enable verbose debug logging of MCP calls to stderr')
1870
    .action(async (opts: Record<string, unknown>) => {
1871
      const defaultDir = resolveDir(opts, program.opts());
1✔
1872
      const { startMcpServer } = await import('./mcp.js');
1✔
1873
      
1874
      if (opts.ui) {
1!
1875
        const { startUiServer } = await import('./ui-server.js');
×
1876
        const uiPort = parseInt(opts.uiPort as string, 10) || 45124;
×
1877
        const uiHost = (opts.uiHost as string) || '127.0.0.1';
×
1878
        const uiToken = opts.uiToken as string | undefined;
×
1879
        startUiServer({ port: uiPort, host: uiHost, token: uiToken, dir: defaultDir });
×
1880
      }
1881

1882
      await startMcpServer(defaultDir, {
1✔
1883
        sse: opts.sse as boolean | undefined,
1884
        port: parseInt(opts.port as string, 10) || 45123,
1!
1885
        debug: opts.debug as boolean | undefined,
1886
      });
1887
    });
1888

1889
  program
189✔
1890
    .command('ui')
1891
    .description('Start the Web Dashboard')
1892
    .argument('[path]', 'Target directory')
1893
    .option('-d, --dir <path>', 'Target directory')
1894
    .option('-p, --port <port>', 'Port to run UI on (default: 45124)', '45124')
1895
    .option('--host <host>', 'Host to run UI on (default: 127.0.0.1)', '127.0.0.1')
1896
    .option('--token <token>', 'Bearer token for authorization')
1897
    .option('--no-open', 'Do not open the dashboard in the browser automatically')
1898
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
1899
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
×
1900
      const port = parseInt(opts.port as string, 10) || 45124;
×
1901
      const host = (opts.host as string) || '127.0.0.1';
×
1902
      const token = opts.token as string | undefined;
×
1903

1904
      const { startUiServer } = await import('./ui-server.js');
×
1905
      startUiServer({ port, host, token, dir });
×
1906

1907
      const url = `http://${host}:${port}`;
×
1908
      console.log(`Mapx Web Dashboard started at ${url}`);
×
1909

1910
      if (opts.open !== false) {
×
1911
        const { exec } = await import('node:child_process');
×
1912
        const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
×
1913
        exec(`${openCmd} ${url}`).unref();
×
1914
      }
1915
    });
1916

1917
  program
189✔
1918
    .command('metrics')
1919
    .description('Show coupling and instability metrics for files')
1920
    .argument('[path]', 'Target directory')
1921
    .option('-d, --dir <path>', 'Target directory')
1922
    .option('--lang <language>', 'Filter metrics by language')
1923
    .option('--verified-only', 'Only compute metrics using verified edges')
1924
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
1925
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
3!
1926
      const { config, store } = await loadContext(dir);
3✔
1927
      checkAndPrintStaleness(store, dir);
3✔
1928
      const metrics = calculateMetrics(store, {
3✔
1929
        repo: config.repo.name,
1930
        language: opts.lang as string | undefined,
1931
        verifiedOnly: !!opts.verifiedOnly,
1932
      });
1933

1934
      if (metrics.length === 0) {
3✔
1935
        console.log('No metrics found.');
1✔
1936
        return;
1✔
1937
      }
1938

1939
      console.log('\n── Coupling & Instability Metrics ─────────────────────');
2✔
1940
      console.log(`${'File Path'.padEnd(45)} | ${'Lang'.padEnd(10)} | ${'Ca'.padStart(4)} | ${'Ce'.padStart(4)} | ${'Instability'.padStart(11)}`);
2✔
1941
      console.log('-'.repeat(85));
2✔
1942
      for (const m of metrics) {
2✔
1943
        const pathTrunc = m.path.length > 45 ? '...' + m.path.substring(m.path.length - 42) : m.path;
2!
1944
        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)}`);
2✔
1945
      }
1946
      console.log('');
2✔
1947
    });
1948

1949
  program
189✔
1950
    .command('clusters')
1951
    .description('List detected code clusters/modules')
1952
    .argument('[clusterOrPath]', 'Target directory or a specific cluster name to inspect')
1953
    .option('-d, --dir <path>', 'Target directory')
1954
    .option('--source <source>', 'Filter by cluster source: namespace, directory, community, layer, or all', 'all')
1955
    .option('--json', 'Output results as JSON')
1956
    .action(async (clusterOrPath: string | undefined, opts: Record<string, unknown>) => {
1957
      let dir = resolveDir(opts, program.opts());
7✔
1958
      let clusterQuery: string | undefined = undefined;
7✔
1959

1960
      if (clusterOrPath) {
7✔
1961
        const resolvedPath = resolve(clusterOrPath);
4✔
1962
        if (existsSync(resolvedPath)) {
4!
1963
          dir = resolvedPath;
×
1964
        } else {
1965
          clusterQuery = clusterOrPath;
4✔
1966
        }
1967
      }
1968

1969
      const { config, store } = await loadContext(dir);
7✔
1970
      const source = opts.source as string;
7✔
1971
      const json = !!opts.json;
7✔
1972

1973
      const clusters = store.getClusters(config.repo.name);
7✔
1974

1975
      let filtered = clusters;
7✔
1976
      if (source && source !== 'all') {
7!
1977
        filtered = clusters.filter((c: any) => c.source === source);
×
1978
      }
1979

1980
      if (clusterQuery) {
7✔
1981
        const targetCluster = clusters.find((c: any) => c.name === clusterQuery);
4✔
1982
        if (!targetCluster) {
4✔
1983
          console.error(`Cluster "${clusterQuery}" not found.`);
1✔
1984
          process.exit(1);
1✔
1985
        }
1986

1987
        const files = store.getClusterFiles(targetCluster.name as string, config.repo.name);
3✔
1988
        const clusterEdges = store.getClusterEdges(targetCluster.name as string, config.repo.name);
3✔
1989

1990
        if (json) {
3✔
1991
          console.log(JSON.stringify({
1✔
1992
            cluster: targetCluster,
1993
            files,
1994
            edges: clusterEdges
1995
          }, null, 2));
1996
          return;
1✔
1997
        }
1998

1999
        console.log(`\n${targetCluster.name}  [${targetCluster.source}]  ${targetCluster.file_count} files`);
2✔
2000
        for (const f of files) {
2✔
2001
          console.log(`  ${f}`);
3✔
2002
        }
2003

2004
        const dependsOn = clusterEdges.filter(e => e.sourceCluster === targetCluster.name);
2✔
2005
        console.log('\nDepends on:');
2✔
2006
        if (dependsOn.length === 0) {
2✔
2007
          console.log('  (none)');
1✔
2008
        } else {
2009
          for (const dep of dependsOn) {
1✔
2010
            console.log(`  ${dep.targetCluster.padEnd(25)} [${dep.edgeCount} edges — dominant: ${dep.dominantType}]`);
1✔
2011
          }
2012
        }
2013

2014
        const dependedOnBy = clusterEdges.filter(e => e.targetCluster === targetCluster.name);
2✔
2015
        console.log('\nDepended on by:');
2✔
2016
        if (dependedOnBy.length === 0) {
2✔
2017
          console.log('  (none)');
1✔
2018
        } else {
2019
          for (const dep of dependedOnBy) {
1✔
2020
            console.log(`  ${dep.sourceCluster.padEnd(25)} [${dep.edgeCount} edges — dominant: ${dep.dominantType}]`);
1✔
2021
          }
2022
        }
2023
        console.log('');
2✔
2024
        return;
2✔
2025
      }
2026

2027
      if (json) {
3✔
2028
        console.log(JSON.stringify({ clusters: filtered }, null, 2));
1✔
2029
        return;
1✔
2030
      }
2031

2032
      const roots: any[] = [];
2✔
2033
      const childrenMap = new Map<string, any[]>();
2✔
2034
      
2035
      for (const c of filtered) {
2✔
2036
        if (!c.parent_name) {
3✔
2037
          roots.push(c);
2✔
2038
        } else {
2039
          const parentName = c.parent_name as string;
1✔
2040
          if (!childrenMap.has(parentName)) {
1!
2041
            childrenMap.set(parentName, []);
1✔
2042
          }
2043
          childrenMap.get(parentName)!.push(c);
1✔
2044
        }
2045
      }
2046

2047
      for (const list of childrenMap.values()) {
2✔
2048
        list.sort((a, b) => a.name.localeCompare(b.name));
1✔
2049
      }
2050
      roots.sort((a, b) => a.name.localeCompare(b.name));
2✔
2051

2052
      const printTree = (node: any, indent: number) => {
2✔
2053
        const padding = '  '.repeat(indent);
3✔
2054
        const namePart = node.name;
3✔
2055
        const sourcePart = `(${node.source})`;
3✔
2056
        const filesPart = `[${node.file_count} files]`;
3✔
2057
        
2058
        const formatted = `${padding}${namePart.padEnd(35 - indent * 2)}${sourcePart.padEnd(15)} ${filesPart}`;
3✔
2059
        console.log(formatted);
3✔
2060

2061
        const children = childrenMap.get(node.name) || [];
3✔
2062
        for (const child of children) {
3✔
2063
          printTree(child, indent + 1);
1✔
2064
        }
2065
      };
2066

2067
      console.log('');
2✔
2068
      for (const root of roots) {
2✔
2069
        printTree(root, 0);
2✔
2070
      }
2071

2072
      const nsCount = filtered.filter((c: any) => c.source === 'namespace').length;
3✔
2073
      const dirCount = filtered.filter((c: any) => c.source === 'directory').length;
3✔
2074
      const commCount = filtered.filter((c: any) => c.source === 'community').length;
3✔
2075
      const layerCount = filtered.filter((c: any) => c.source === 'layer').length;
3✔
2076
      const layerSuffix = layerCount > 0 ? `, ${layerCount} layer` : '';
2!
2077
      console.log(`\n${filtered.length} clusters detected (${nsCount} namespace, ${dirCount} directory, ${commCount} community${layerSuffix})\n`);
7✔
2078
    });
2079

2080
  program
189✔
2081
    .command('edges')
2082
    .description('Granular query of dependency edges')
2083
    .argument('[path]', 'Target directory')
2084
    .option('-d, --dir <path>', 'Target directory')
2085
    .option('--type <type>', 'Filter edges by type')
2086
    .option('--from <file>', 'Filter edges originating from a file pattern')
2087
    .option('--to <file>', 'Filter edges targeting a file pattern')
2088
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
2089
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
3!
2090
      const { config, store } = await loadContext(dir);
3✔
2091
      checkAndPrintStaleness(store, dir);
3✔
2092
      const edges = store.queryEdges({
3✔
2093
        repo: config.repo.name,
2094
        type: opts.type as string | undefined,
2095
        from: opts.from as string | undefined,
2096
        to: opts.to as string | undefined,
2097
      });
2098

2099
      if (edges.length === 0) {
3✔
2100
        console.log('No matching edges found.');
1✔
2101
        return;
1✔
2102
      }
2103

2104
      console.log(`\nFound ${edges.length} matching edges:`);
2✔
2105
      for (const e of edges) {
2✔
2106
        const srcSym = e.source_symbol ? `#${e.source_symbol}` : '';
2✔
2107
        const tgtSym = e.target_symbol ? `#${e.target_symbol}` : '';
2✔
2108
        const infSuffix = e.verifiability === 'inferred' ? ' [inferred]' : '';
2✔
2109
        console.log(`- ${e.source_file}${srcSym} → ${e.target_file}${tgtSym} (${e.edge_type})${infSuffix}`);
2✔
2110
      }
2111
      console.log('');
2✔
2112
    });
2113

2114
  program
189✔
2115
    .command('routes')
2116
    .description('Show routes from all detected frameworks')
2117
    .argument('[path]', 'Target directory')
2118
    .option('-d, --dir <path>', 'Target directory')
2119
    .option('--framework <name>', 'Filter by framework name')
2120
    .option('--method <verb>', 'Filter by HTTP method (GET, POST, etc.)')
2121
    .option('--path-pattern <pattern>', 'Filter by route path pattern')
2122
    .option('--json', 'Output routes as JSON')
2123
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
2124
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
3!
2125
      const routeRegistry = new RouteRegistry();
3✔
2126
      await routeRegistry.load(dir);
3✔
2127

2128
      const routes = routeRegistry.queryRoutes({
3✔
2129
        framework: opts.framework as string | undefined,
2130
        method: opts.method as string | undefined,
2131
        path: opts.pathPattern as string | undefined,
2132
      });
2133

2134
      if (opts.json) {
3✔
2135
        console.log(JSON.stringify(routes, null, 2));
1✔
2136
        return;
1✔
2137
      }
2138

2139
      if (routes.length === 0) {
2✔
2140
        console.log('No routes found.');
1✔
2141
        return;
1✔
2142
      }
2143

2144
      console.log(`\nDetected Routes (${routes.length}):`);
1✔
2145
      console.log(''.padEnd(80, '-'));
1✔
2146
      console.log(`${'Framework'.padEnd(12)} | ${'Method'.padEnd(8)} | ${'Path'.padEnd(30)} | ${'Handler'}`);
1✔
2147
      console.log(''.padEnd(80, '-'));
1✔
2148
      for (const r of routes) {
1✔
2149
        const handler = r.handlerSymbol || r.handlerFile;
1!
2150
        console.log(`${r.framework.padEnd(12)} | ${r.method.toUpperCase().padEnd(8)} | ${r.path.padEnd(30)} | ${handler}`);
1✔
2151
      }
2152
      console.log(''.padEnd(80, '-'));
1✔
2153
      console.log('');
1✔
2154
    });
2155

2156
  program
189✔
2157
    .command('hooks')
2158
    .description('Show hooks from all detected frameworks')
2159
    .argument('[path]', 'Target directory')
2160
    .option('-d, --dir <path>', 'Target directory')
2161
    .option('--framework <name>', 'Filter by framework name')
2162
    .option('--type <type>', 'Filter by hook type')
2163
    .option('--name <pattern>', 'Filter by hook name pattern')
2164
    .option('--json', 'Output hooks as JSON')
2165
    .action(async (path: string | undefined, opts: Record<string, unknown>) => {
2166
      const dir = path ? resolve(path) : resolveDir(opts, program.opts());
3!
2167
      const routeRegistry = new RouteRegistry();
3✔
2168
      await routeRegistry.load(dir);
3✔
2169

2170
      const hooks = routeRegistry.queryHooks({
3✔
2171
        framework: opts.framework as string | undefined,
2172
        hookType: opts.type as string | undefined,
2173
        hookName: opts.name as string | undefined,
2174
      });
2175

2176
      if (opts.json) {
3✔
2177
        console.log(JSON.stringify(hooks, null, 2));
1✔
2178
        return;
1✔
2179
      }
2180

2181
      if (hooks.length === 0) {
2✔
2182
        console.log('No hooks found.');
1✔
2183
        return;
1✔
2184
      }
2185

2186
      console.log(`\nDetected Hooks (${hooks.length}):`);
1✔
2187
      console.log(''.padEnd(80, '-'));
1✔
2188
      console.log(`${'Framework'.padEnd(12)} | ${'Type'.padEnd(15)} | ${'Hook Name'.padEnd(25)} | ${'Handler'}`);
1✔
2189
      console.log(''.padEnd(80, '-'));
1✔
2190
      for (const h of hooks) {
1✔
2191
        const handler = h.handlerSymbol || h.handlerFile;
1!
2192
        console.log(`${h.framework.padEnd(12)} | ${h.hookType.padEnd(15)} | ${h.hookName.padEnd(25)} | ${handler}`);
1✔
2193
      }
2194
      console.log(''.padEnd(80, '-'));
1✔
2195
      console.log('');
1✔
2196
    });
2197

2198
  program
189✔
2199
    .command('profile')
2200
    .description('Show codebase profile: archetype, frameworks, and patterns')
2201
    .argument('[path]', 'Target directory')
2202
    .option('-d, --dir <path>', 'Target directory')
2203
    .action(async (pathArg: string | undefined, opts: Record<string, unknown>) => {
2204
      const dir = pathArg ? resolve(pathArg) : resolveDir(opts, program.opts());
2!
2205
      const { config, store } = await loadContext(dir);
2✔
2206
      checkAndPrintStaleness(store, dir);
2✔
2207
      const repo = config.repo.name;
2✔
2208
      const profileStr = store.getMeta('codebase_profile:' + repo);
2✔
2209
      if (!profileStr) {
2✔
2210
        console.log(`No codebase profile found for repo "${repo}". Run a scan first.`);
1✔
2211
        return;
1✔
2212
      }
2213
      const profile = JSON.parse(profileStr);
1✔
2214
      console.log('\n── Codebase Profile ───────────────────────────────────');
1✔
2215
      console.log(`  Archetype:             ${profile.archetype} (confidence: ${profile.archetypeConfidence.toFixed(2)})`);
1✔
2216
      console.log(`  Detected Frameworks:   ${profile.detectedFrameworks.join(', ') || 'none'}`);
1!
2217
      console.log(`  Architecture Patterns: ${profile.detectedPatterns.join(', ') || 'none'}`);
2!
2218
      console.log(`  Dominant Languages:    ${profile.dominantLanguages.join(', ')}`);
2✔
2219
      console.log(`  Has Backend:           ${profile.hasBackend}`);
2✔
2220
      console.log(`  Has Frontend:          ${profile.hasFrontend}`);
2✔
2221
      console.log(`  Is Monorepo:           ${profile.isMonorepo}`);
2✔
2222
      console.log('');
2✔
2223
    });
2224

2225
  program
189✔
2226
    .command('arch')
2227
    .description('Show full architecture report: profile, layers, smells, and DSM')
2228
    .argument('[path]', 'Target directory')
2229
    .option('-d, --dir <path>', 'Target directory')
2230
    .option('--smells', 'Show only architectural smells')
2231
    .option('--dsm', 'Show only Dependency Structure Matrix (DSM)')
2232
    .option('--violations', 'Show only layer violations')
2233
    .action(async (pathArg: string | undefined, opts: Record<string, unknown>) => {
2234
      const dir = pathArg ? resolve(pathArg) : resolveDir(opts, program.opts());
×
2235
      const { config, store } = await loadContext(dir);
×
2236
      checkAndPrintStaleness(store, dir);
×
2237
      const repo = config.repo.name;
×
2238

2239
      const showAll = !opts.smells && !opts.dsm && !opts.violations;
×
2240

2241
      if (showAll || opts.smells || opts.violations) {
×
2242
        const smells = store.getArchSmells(repo);
×
2243
        const filtered = opts.violations ? smells.filter((s: any) => s.type === 'layer-violation') : smells;
×
2244
        
2245
        console.log(`\n── Architectural Smells & Violations (${filtered.length}) ─────────────────`);
×
2246
        if (filtered.length === 0) {
×
2247
          console.log('🟢 Clean! No architectural smells detected.');
×
2248
        } else {
2249
          for (const s of filtered) {
×
2250
            console.log(`\n  [${s.severity.toUpperCase()}] ${s.type}`);
×
2251
            console.log(`    Description: ${s.description}`);
×
2252
            if (s.involvedFiles.length > 0) {
×
2253
              console.log(`    Files:       ${s.involvedFiles.join(', ')}`);
×
2254
            }
2255
            if (s.involvedClusters && s.involvedClusters.length > 0) {
×
2256
              console.log(`    Clusters:    ${s.involvedClusters.join(', ')}`);
×
2257
            }
2258
            console.log(`    Suggestion:  ${s.suggestion}`);
×
2259
          }
2260
        }
2261
        console.log('');
×
2262
        if (!showAll) return;
×
2263
      }
2264

2265
      if (showAll || opts.dsm) {
×
2266
        const dsm = calculateDSM(store, repo);
×
2267
        if (dsm.clusterNames.length === 0) {
×
2268
          console.log('No clusters found to build DSM.');
×
2269
        } else {
2270
          console.log('\n── Dependency Structure Matrix (DSM) ──────────────────');
×
2271
          const maxLength = Math.max(...dsm.clusterNames.map((n: string) => n.length), 5);
×
2272
          const headers = [''.padEnd(maxLength), ...dsm.clusterNames.map((_, idx) => `C${idx + 1}`.padStart(4))].join(' ');
×
2273
          console.log(headers);
×
2274
          console.log('-'.repeat(headers.length));
×
2275
          dsm.clusterNames.forEach((name, i) => {
×
2276
            const cells = dsm.matrix[i].map((val, j) => {
×
2277
              if (i === j) return '   *';
×
2278
              if (val === 0) return '   .';
×
2279
              return val.toString().padStart(4);
×
2280
            }).join(' ');
2281
            console.log(`${`C${i + 1} ${name}`.padEnd(maxLength)} ${cells}`);
×
2282
          });
2283
          console.log(`\nLegend:\n  * = self\n  . = no dependency\n  number = edge count from row cluster to column cluster`);
×
2284
          console.log('\nCluster Key:');
×
2285
          dsm.clusterNames.forEach((name, idx) => {
×
2286
            console.log(`  C${idx + 1}: ${name}`);
×
2287
          });
2288
        }
2289
        console.log('');
×
2290
        if (!showAll) return;
×
2291
      }
2292

2293
      if (showAll) {
×
2294
        const profileStr = store.getMeta('codebase_profile:' + repo);
×
2295
        if (profileStr) {
×
2296
          const profile = JSON.parse(profileStr);
×
2297
          console.log('── Codebase Profile Summary ───────────────────────────');
×
2298
          console.log(`  Archetype:  ${profile.archetype} (confidence: ${profile.archetypeConfidence.toFixed(2)})`);
×
2299
          console.log(`  Languages:  ${profile.dominantLanguages.join(', ')}`);
×
2300
          console.log('');
×
2301
        }
2302

2303
        const smells = store.getArchSmells(repo);
×
2304
        console.log('── Architecture Health ────────────────────────────────');
×
2305
        if (smells.length === 0) {
×
2306
          console.log('🟢 Clean! No smells detected.');
×
2307
        } else {
2308
          console.log(`⚠️  Detected ${smells.length} architectural smell(s). Run 'mapx arch --smells' for details.`);
×
2309
        }
2310
        console.log('');
×
2311
      }
2312
    });
2313

2314
  program
189✔
2315
    .command('explain <file>')
2316
    .description('Explain why a file was classified with a specific role')
2317
    .option('-d, --dir <path>', 'Target directory')
2318
    .action(async (file: string, opts: Record<string, unknown>) => {
2319
      const dir = resolveDir(opts, program.opts());
×
2320
      const { store } = await loadContext(dir);
×
2321
      checkAndPrintStaleness(store, dir);
×
2322
      
2323
      const fileRecord = store.getFile(file);
×
2324
      if (!fileRecord) {
×
2325
        console.log(`File "${file}" not found in graph.`);
×
2326
        return;
×
2327
      }
2328

2329
      const signals = store.getClassificationSignals(file);
×
2330
      const role = fileRecord.role || 'other';
×
2331
      const confidence = typeof fileRecord.role_confidence === 'number' ? fileRecord.role_confidence : 0.5;
×
2332

2333
      console.log(`\n── Classification Explanation: ${file} ──`);
×
2334
      console.log(`  Role:       ${role} (confidence: ${confidence.toFixed(2)})`);
×
2335
      console.log(`\n  Signals:`);
×
2336
      for (const s of signals) {
×
2337
        console.log(`    [${s.source.padEnd(9)}] ${s.role.padEnd(12)} (conf: ${s.confidence.toFixed(2)}) - ${s.reason}`);
×
2338
      }
2339
      if (signals.length === 0) {
×
2340
        console.log('    (none)');
×
2341
      }
2342
      console.log('');
×
2343
    });
2344

2345
  program
189✔
2346
    .command('layers')
2347
    .description('List files grouped by architectural roles/layers')
2348
    .argument('[path]', 'Target directory')
2349
    .option('-d, --dir <path>', 'Target directory')
2350
    .option('--json', 'Output results as JSON')
2351
    .action(async (pathArg: string | undefined, opts: Record<string, unknown>) => {
2352
      const dir = pathArg ? resolve(pathArg) : resolveDir(opts, program.opts());
×
2353
      const { config, store } = await loadContext(dir);
×
2354
      checkAndPrintStaleness(store, dir);
×
2355
      const repo = config.repo.name;
×
2356

2357
      const files = store.getAllFiles(repo);
×
2358
      const roleGroups = new Map<string, string[]>();
×
2359
      for (const f of files) {
×
2360
        const role = (f.role as string) || 'other';
×
2361
        if (!roleGroups.has(role)) {
×
2362
          roleGroups.set(role, []);
×
2363
        }
2364
        roleGroups.get(role)!.push(f.path as string);
×
2365
      }
2366

2367
      if (opts.json) {
×
2368
        const out: Record<string, string[]> = {};
×
2369
        for (const [r, paths] of roleGroups.entries()) {
×
2370
          out[r] = paths;
×
2371
        }
2372
        console.log(JSON.stringify(out, null, 2));
×
2373
        return;
×
2374
      }
2375

2376
      console.log(`\n── Architectural Layers / Roles for "${repo}" ─────────`);
×
2377
      const sortedRoles = Array.from(roleGroups.keys()).sort();
×
2378
      for (const role of sortedRoles) {
×
2379
        const list = roleGroups.get(role)!;
×
2380
        console.log(`\n  ${role.toUpperCase()} (${list.length} files):`);
×
2381
        for (const f of list.slice(0, 10)) {
×
2382
          console.log(`    • ${f}`);
×
2383
        }
2384
        if (list.length > 10) {
×
2385
          console.log(`    • ...and ${list.length - 10} more`);
×
2386
        }
2387
      }
2388
      console.log('');
×
2389
    });
2390

2391
  const agentsCmd = program.command('agents').description('Manage LLM agent integration files');
189✔
2392

2393
  agentsCmd
189✔
2394
    .command('list')
2395
    .description('List all supported LLM integration providers')
2396
    .action(() => {
2397
      const generator = new AgentGenerator();
2✔
2398
      const providers = generator.listProviders();
2✔
2399
      console.log('\nSupported LLM integration providers:');
2✔
2400
      for (const p of providers) {
2✔
2401
        const temp = generator.getTemplate(p);
4✔
2402
        const appendStr = temp?.isAppend ? ' (append-mode)' : '';
4!
2403
        console.log(`  - ${p.padEnd(12)} -> ${temp?.filename}${appendStr}`);
4✔
2404
      }
2405
      console.log('');
2✔
2406
    });
2407

2408
  agentsCmd
189✔
2409
    .command('generate')
2410
    .description('Generate/overwrite LLM integration files')
2411
    .option('--providers <list>', 'Comma-separated list of providers to generate')
2412
    .option('--all', 'Generate integration files for all supported providers')
2413
    .option('--dry-run', 'Show actions without writing files')
2414
    .option('--force', 'Force overwrite of existing files without prompt')
2415
    .option('--mcp-port <number>', 'Port for the MCP SSE transport server', '3456')
2416
    .action(async (opts: Record<string, any>) => {
2417
      const dir = program.opts().dir ? resolve(program.opts().dir) : process.cwd();
6!
2418
      const generator = new AgentGenerator();
6✔
2419
      const available = generator.listProviders();
6✔
2420
      let targets: string[] = [];
6✔
2421

2422
      if (opts.all) {
6✔
2423
        targets = available;
4✔
2424
      } else if (opts.providers) {
2!
2425
        targets = opts.providers.split(',').map((s: string) => s.trim().toLowerCase()).filter((p: string) => available.includes(p));
2✔
2426
      } else {
2427
        if (process.stdin.isTTY) {
×
2428
          targets = await selectProvidersInteractive();
×
2429
        } else {
2430
          targets = ['generic'];
×
2431
        }
2432
      }
2433

2434
      if (targets.length === 0) {
6!
2435
        clack.log.error('No valid providers specified.');
×
2436
        process.exit(1);
×
2437
      }
2438

2439
      const actions = generator.plan(targets, { dir, mcpPort: parseInt(opts.mcpPort, 10) });
6✔
2440

2441
      for (const action of actions) {
6✔
2442
        if (action.status === 'up_to_date') {
6✔
2443
          clack.log.info(`${action.filename}: Up to date. Skipping.`);
1✔
2444
          continue;
1✔
2445
        }
2446

2447
        if (action.status === 'update_conflict' || action.status === 'no_sentinel') {
5✔
2448
          clack.log.warn(`Conflict/Modification detected in ${action.filename}:`);
1✔
2449
          if (action.diff) {
1!
2450
            console.log(action.diff);
1✔
2451
          }
2452
          if (!opts.force) {
1!
2453
            const confirm = await clack.confirm({
×
2454
              message: `Overwrite ${action.filename}?`,
2455
              initialValue: false,
2456
            });
2457
            if (clack.isCancel(confirm) || !confirm) {
×
2458
              clack.log.warn(`Skipped ${action.filename}.`);
×
2459
              continue;
×
2460
            }
2461
          }
2462
        }
2463

2464
        if (opts.dryRun) {
5✔
2465
          clack.log.info(`[DRY RUN] Would write to ${action.filepath} (status: ${action.status})`);
1✔
2466
        } else {
2467
          generator.execute(action);
4✔
2468
          clack.log.success(`Wrote to ${action.filename} (status: ${action.status})`);
4✔
2469
        }
2470
      }
2471
    });
2472

2473
  agentsCmd
189✔
2474
    .command('update')
2475
    .description('Update existing LLM integration files to the current MapxGraph version')
2476
    .option('--dry-run', 'Show updates without writing files')
2477
    .option('--force', 'Force overwrite of customized blocks without prompt')
2478
    .option('--mcp-port <number>', 'Port for the MCP SSE transport server', '3456')
2479
    .action(async (opts: Record<string, any>) => {
2480
      const dir = program.opts().dir ? resolve(program.opts().dir) : process.cwd();
4!
2481
      const generator = new AgentGenerator();
4✔
2482
      const available = generator.listProviders();
4✔
2483

2484
      const existingProviders = available.filter(p => {
4✔
2485
        const temp = generator.getTemplate(p);
8✔
2486
        return temp && existsSync(join(dir, temp.filename));
8✔
2487
      });
2488

2489
      if (existingProviders.length === 0) {
4!
2490
        clack.log.info('No existing LLM integration files found to update.');
×
2491
        return;
×
2492
      }
2493

2494
      const actions = generator.plan(existingProviders, { dir, mcpPort: parseInt(opts.mcpPort, 10) });
4✔
2495
      let updatedCount = 0;
4✔
2496

2497
      for (const action of actions) {
4✔
2498
        if (action.status === 'up_to_date') {
4!
2499
          continue;
×
2500
        }
2501

2502
        if (action.status === 'update_conflict') {
4✔
2503
          clack.log.warn(`Customized content detected in ${action.filename}:`);
1✔
2504
          if (action.diff) {
1!
2505
            console.log(action.diff);
1✔
2506
          }
2507
          if (!opts.force) {
1!
2508
            const confirm = await clack.confirm({
×
2509
              message: `Overwrite customizations in ${action.filename}?`,
2510
              initialValue: false,
2511
            });
2512
            if (clack.isCancel(confirm) || !confirm) {
×
2513
              clack.log.warn(`Skipped ${action.filename}.`);
×
2514
              continue;
×
2515
            }
2516
          }
2517
        }
2518

2519
        if (opts.dryRun) {
4✔
2520
          clack.log.info(`[DRY RUN] Would update ${action.filepath}`);
1✔
2521
        } else {
2522
          generator.execute(action);
3✔
2523
          clack.log.success(`Updated ${action.filename}`);
3✔
2524
          updatedCount++;
3✔
2525
        }
2526
      }
2527

2528
      if (updatedCount === 0 && !opts.dryRun) {
4!
2529
        clack.log.success('All integration files are already up to date.');
×
2530
      }
2531
    });
2532

2533
  agentsCmd
189✔
2534
    .command('mcp')
2535
    .description('Auto-detect agent tools and generate/update MCP config files')
2536
    .option('--tools <list>', 'Comma-separated list of tools to generate configs for (opencode, gemini-cli, cursor-mcp, vscode-mcp)')
2537
    .option('--all', 'Generate MCP configs for all supported tools')
2538
    .option('--detect', 'Only detect agent tools without writing files')
2539
    .option('--dry-run', 'Show actions without writing files')
2540
    .action(async (opts: Record<string, any>) => {
2541
      const dir = program.opts().dir ? resolve(program.opts().dir) : process.cwd();
12!
2542
      const generator = new AgentGenerator();
12✔
2543
      const allConfigs = generator.listMcpConfigs();
12✔
2544

2545
      // Select targets
2546
      let targets: typeof allConfigs;
2547
      if (opts.all) {
12✔
2548
        targets = allConfigs;
5✔
2549
      } else if (opts.tools) {
7✔
2550
        const requested = opts.tools.split(',').map((s: string) => s.trim().toLowerCase());
2✔
2551
        targets = allConfigs.filter(c => requested.includes(c.name));
2✔
2552
      } else {
2553
        // Auto-detect
2554
        targets = generator.detectAgentTools(dir);
5✔
2555
      }
2556

2557
      if (opts.detect) {
12✔
2558
        if (targets.length === 0) {
3✔
2559
          clack.log.info('No agent tools detected in this project.');
2✔
2560
        } else {
2561
          clack.log.info(`Detected agent tools (${targets.length}):`);
1✔
2562
          for (const t of targets) {
1✔
2563
            clack.log.success(`${t.name.padEnd(15)} → ${t.filename}`);
1✔
2564
          }
2565
        }
2566
        clack.log.info(`All available targets:`);
3✔
2567
        for (const c of allConfigs) {
3✔
2568
          const detected = targets.includes(c);
3✔
2569
          const icon = detected ? '✓' : '·';
3!
2570
          clack.log.info(`${icon} ${c.name.padEnd(15)} → ${c.filename}`);
3✔
2571
        }
2572
        return;
3✔
2573
      }
2574

2575
      if (targets.length === 0) {
9✔
2576
        clack.log.warn('No agent tools detected. Use --all or --tools to specify targets.');
2✔
2577
        return;
2✔
2578
      }
2579

2580
      const actions = generator.generateMcpConfigs(targets, { dir });
7✔
2581
      for (const action of actions) {
7✔
2582
        if (action.status === 'up_to_date') {
6✔
2583
          clack.log.info(`${action.filename}: Up to date.`);
1✔
2584
          continue;
1✔
2585
        }
2586
        if (opts.dryRun) {
5✔
2587
          clack.log.info(`[DRY RUN] Would ${action.status} ${action.filename} (${action.tool})`);
2✔
2588
        } else {
2589
          generator.executeMcpConfig(action);
3✔
2590
          const verb = action.status === 'merge' ? 'merged into' : action.status === 'create' ? 'created' : 'updated';
3!
2591
          clack.log.success(`MCP config ${verb} ${action.filename} (${action.tool})`);
3✔
2592
        }
2593
      }
2594
    });
2595

2596
  const workspacesCmd = program.command('workspaces').description('Manage multi-repository workspace contexts');
189✔
2597

2598
  workspacesCmd
189✔
2599
    .command('list')
2600
    .alias('show')
2601
    .description('List registered repositories and other discovered peer/submodule directories')
2602
    .action(async () => {
2603
      const dir = resolveDir({}, program.opts());
4✔
2604
      const { config } = await loadContext(dir);
4✔
2605

2606
      console.log('\nRegistered repositories:');
4✔
2607
      const registeredPaths = new Set<string>();
4✔
2608
      for (const r of config.repos) {
4✔
2609
        const absPath = resolve(dir, r.path);
4✔
2610
        registeredPaths.add(absPath);
4✔
2611
        const fwStr = r.framework ? ` [${r.framework}]` : '';
4!
2612
        console.log(`  - ${r.name.padEnd(15)} -> ${r.path} (active)${fwStr}`);
4✔
2613
      }
2614

2615
      // Discover uninitialized submodules
2616
      const submodules = WorkspaceManager.discoverSubmodules(dir);
4✔
2617
      const uninitSubmodules = submodules.filter(s => !registeredPaths.has(resolve(dir, s.path)));
4✔
2618
      if (uninitSubmodules.length > 0) {
4✔
2619
        console.log('\nDiscovered submodules:');
1✔
2620
        for (const s of uninitSubmodules) {
1✔
2621
          const status = s.isInitialized ? 'available' : 'uninitialized';
1!
2622
          console.log(`  - ${s.name.padEnd(15)} -> ${s.path} (${status})`);
1✔
2623
        }
2624
      }
2625

2626
      // Discover peer repos
2627
      const peers = WorkspaceManager.discoverPeerRepos(dir);
4✔
2628
      const uninitPeers = peers.filter(p => !registeredPaths.has(resolve(dir, p.path)));
4✔
2629
      if (uninitPeers.length > 0) {
4✔
2630
        console.log('\nDiscovered peer repositories:');
1✔
2631
        for (const p of uninitPeers) {
1✔
2632
          console.log(`  - ${p.name.padEnd(15)} -> ${p.path} (available)`);
1✔
2633
        }
2634
      }
2635

2636
      // Discover VS Code workspace files
2637
      const wsFiles = readdirSync(dir).filter(f => f.endsWith('.code-workspace'));
4✔
2638
      if (wsFiles.length > 0) {
4!
2639
        console.log('\nDiscovered VS Code Workspace folders:');
×
2640
        for (const f of wsFiles) {
×
2641
          const wsFolderRepos = WorkspaceManager.discoverVSCodeWorkspace(join(dir, f), dir);
×
2642
          const uninitWs = wsFolderRepos.filter(p => !registeredPaths.has(resolve(dir, p.path)));
×
2643
          for (const p of uninitWs) {
×
2644
            console.log(`  - ${p.name.padEnd(15)} -> ${p.path} (available)`);
×
2645
          }
2646
        }
2647
      }
2648

2649
      // Deep-scan for nested git repos (up to 3 levels)
2650
      const nested = WorkspaceManager.discoverNestedGitRepos(dir);
4✔
2651
      const uninitNested = nested.filter(n => !registeredPaths.has(resolve(dir, n.path)));
4✔
2652
      if (uninitNested.length > 0) {
4!
2653
        console.log('\nNested git repositories (deep scan):');
×
2654
        for (const n of uninitNested) {
×
2655
          console.log(`  - ${n.name.padEnd(15)} -> ${n.path} (available)`);
×
2656
        }
2657
      }
2658

2659
      // Monorepo packages discovered via workspace manifests or common dirs
2660
      const monoPkgs = WorkspaceManager.discoverMonorepoPackages(dir);
4✔
2661
      const uninitMono = monoPkgs.filter(p => !registeredPaths.has(resolve(dir, p.path)));
4✔
2662
      if (uninitMono.length > 0) {
4!
2663
        console.log('\nMonorepo packages:');
×
2664
        for (const p of uninitMono) {
×
2665
          console.log(`  - ${p.name.padEnd(15)} -> ${p.path} [${p.packageManager}]`);
×
2666
        }
2667
      }
2668

2669
      console.log('');
4✔
2670
    });
2671

2672
  workspacesCmd
189✔
2673
    .command('add <path>')
2674
    .description('Register a repository path')
2675
    .option('--name <name>', 'Repository name (defaults to folder name)')
2676
    .action(async (repoPath: string, opts: Record<string, unknown>) => {
2677
      const dir = resolveDir({}, program.opts());
×
2678
      const { config, store, graph } = await loadContext(dir);
×
2679

2680
      const absPath = resolve(dir, repoPath);
×
2681
      if (!existsSync(absPath)) {
×
2682
        clack.log.error(`Path ${repoPath} does not exist.`);
×
2683
        process.exit(1);
×
2684
      }
2685
      if (!isGitRepo(absPath)) {
×
2686
        clack.log.error(`Path ${repoPath} is not a git repository.`);
×
2687
        process.exit(1);
×
2688
      }
2689

2690
      const relPath = relative(dir, absPath);
×
2691
      const name = (opts.name as string) || basename(absPath);
×
2692

2693
      if (config.repos.some(r => r.name === name || r.path === relPath)) {
×
2694
        clack.log.warn(`Repository already registered: ${name} (${relPath})`);
×
2695
        return;
×
2696
      }
2697

2698
      config.addRepo(name, relPath);
×
2699
      await config.save();
×
2700
      clack.log.success(`Registered repository: ${name} -> ${relPath}`);
×
2701

2702
      clack.log.step('Running initial full scan for the new repository...');
×
2703
      const onProgress = createProgressRenderer();
×
2704
      const scanner = new Scanner(store, config, graph, onProgress);
×
2705

2706
      const onSigInt = () => {
×
2707
        scanner.abort();
×
2708
        onProgress.stop('Canceled');
×
2709
      };
2710
      process.once('SIGINT', onSigInt);
×
2711

2712
      const result = await scanner.scanFull([name]).catch((err) => {
×
2713
        onProgress.stop();
×
2714
        throw err;
×
2715
      });
2716

2717
      process.removeListener('SIGINT', onSigInt);
×
2718
      onProgress.stop();
×
2719
      clack.log.success(`Scanned ${result.filesScanned} files, ${result.symbolsFound} symbols, ${result.edgesFound} edges in ${result.durationMs}ms`);
×
2720
    });
2721

2722
  workspacesCmd
189✔
2723
    .command('remove <name>')
2724
    .description('Unregister a repository by name or path')
2725
    .action(async (name: string) => {
2726
      const dir = resolveDir({}, program.opts());
4✔
2727
      const { config, store } = await loadContext(dir);
4✔
2728

2729
      const repo = config.repos.find(r => r.name === name || r.path === name);
4✔
2730
      if (!repo) {
4✔
2731
        clack.log.error(`Repository ${name} is not registered.`);
2✔
2732
        process.exit(1);
2✔
2733
      }
2734

2735
      const repoName = repo.name;
2✔
2736
      config.removeRepo(name);
2✔
2737
      await config.save();
2✔
2738
      clack.log.success(`Unregistered repository: ${repoName}`);
2✔
2739

2740
      clack.log.step(`Cleaning up stored data for repository: ${repoName}...`);
2✔
2741
      store.deleteRepo(repoName);
2✔
2742
      clack.log.success(`Done.`);
2✔
2743
    });
2744

2745
  workspacesCmd
189✔
2746
    .command('discover')
2747
    .description('Discover unregistered submodules, peer repos, and VS Code workspace folders (read-only)')
2748
    .action(async () => {
2749
      const dir = resolveDir({}, program.opts());
4✔
2750
      const { config } = await loadContext(dir);
4✔
2751

2752
      const registeredPaths = new Set<string>();
4✔
2753
      for (const r of config.repos) {
4✔
2754
        registeredPaths.add(resolve(dir, r.path));
4✔
2755
      }
2756

2757
      let found = 0;
4✔
2758

2759
      // Submodules
2760
      const submodules = WorkspaceManager.discoverSubmodules(dir);
4✔
2761
      const uninitSubs = submodules.filter(s => !registeredPaths.has(resolve(dir, s.path)));
4✔
2762
      if (uninitSubs.length > 0) {
4✔
2763
        console.log('\nSubmodules:');
1✔
2764
        for (const s of uninitSubs) {
1✔
2765
          const status = s.isInitialized ? 'available' : 'uninitialized';
1!
2766
          console.log(`  - ${s.name.padEnd(20)} -> ${s.path} (${status})`);
1✔
2767
        }
2768
        found += uninitSubs.length;
1✔
2769
      }
2770

2771
      // Peer repos
2772
      const peers = WorkspaceManager.discoverPeerRepos(dir);
4✔
2773
      const uninitPeers = peers.filter(p => !registeredPaths.has(resolve(dir, p.path)));
4✔
2774
      if (uninitPeers.length > 0) {
4✔
2775
        console.log('\nPeer repositories:');
1✔
2776
        for (const p of uninitPeers) {
1✔
2777
          console.log(`  - ${p.name.padEnd(20)} -> ${p.path} (available)`);
1✔
2778
        }
2779
        found += uninitPeers.length;
1✔
2780
      }
2781

2782
      // VS Code workspace folders
2783
      const wsFiles = readdirSync(dir).filter(f => f.endsWith('.code-workspace'));
4✔
2784
      const vsEntries: Array<{ name: string; path: string }> = [];
4✔
2785
      for (const f of wsFiles) {
4✔
2786
        const wsFolderRepos = WorkspaceManager.discoverVSCodeWorkspace(join(dir, f), dir);
×
2787
        for (const p of wsFolderRepos) {
×
2788
          if (!registeredPaths.has(resolve(dir, p.path))) {
×
2789
            vsEntries.push({ name: p.name, path: p.path });
×
2790
          }
2791
        }
2792
      }
2793
      if (vsEntries.length > 0) {
4!
2794
        console.log('\nVS Code workspace folders:');
×
2795
        for (const p of vsEntries) {
×
2796
          console.log(`  - ${p.name.padEnd(20)} -> ${p.path} (available)`);
×
2797
        }
2798
        found += vsEntries.length;
×
2799
      }
2800

2801
      // Deep-scan for nested git repos (up to 3 levels)
2802
      const nestedRepos = WorkspaceManager.discoverNestedGitRepos(dir);
4✔
2803
      const uninitNestedDiscover = nestedRepos.filter(n => !registeredPaths.has(resolve(dir, n.path)));
4✔
2804
      if (uninitNestedDiscover.length > 0) {
4!
2805
        console.log('\nNested git repositories (deep scan ≤3 levels):');
×
2806
        for (const n of uninitNestedDiscover) {
×
2807
          console.log(`  - ${n.name.padEnd(20)} -> ${n.path} (available)`);
×
2808
        }
2809
        found += uninitNestedDiscover.length;
×
2810
      }
2811

2812
      // Monorepo packages
2813
      const monoDiscover = WorkspaceManager.discoverMonorepoPackages(dir);
4✔
2814
      const uninitMonoDiscover = monoDiscover.filter(p => !registeredPaths.has(resolve(dir, p.path)));
4✔
2815
      if (uninitMonoDiscover.length > 0) {
4!
2816
        console.log('\nMonorepo packages:');
×
2817
        for (const p of uninitMonoDiscover) {
×
2818
          console.log(`  - ${p.name.padEnd(20)} -> ${p.path} [${p.packageManager}]`);
×
2819
        }
2820
        found += uninitMonoDiscover.length;
×
2821
      }
2822

2823
      if (found === 0) {
4✔
2824
        console.log('No unregistered repositories discovered.');
3✔
2825
      } else {
2826
        console.log(`\n${found} unregistered repositor${found === 1 ? 'y' : 'ies'} discovered. Use \`mapx workspaces add <path>\` to register.`);
1!
2827
      }
2828
    });
2829

2830
  workspacesCmd
189✔
2831
    .command('sync')
2832
    .description('Sync all discovered submodules, peer repos, and VS Code workspace folders')
2833
    .action(async () => {
2834
      const dir = resolveDir({}, program.opts());
4✔
2835
      const { config, store, graph } = await loadContext(dir);
4✔
2836

2837
      const registeredPaths = new Set<string>();
4✔
2838
      for (const r of config.repos) {
4✔
2839
        registeredPaths.add(resolve(dir, r.path));
4✔
2840
      }
2841

2842
      const toAdd: Array<{ name: string; path: string }> = [];
4✔
2843

2844
      // 1. Submodules
2845
      const submodules = WorkspaceManager.discoverSubmodules(dir);
4✔
2846
      for (const s of submodules) {
4✔
2847
        if (s.isInitialized) {
1!
2848
          const abs = resolve(dir, s.path);
1✔
2849
          if (!registeredPaths.has(abs)) {
1!
2850
            toAdd.push({ name: s.name, path: s.path });
1✔
2851
            registeredPaths.add(abs);
1✔
2852
          }
2853
        }
2854
      }
2855

2856
      // 2. Peer repos
2857
      const peers = WorkspaceManager.discoverPeerRepos(dir);
4✔
2858
      for (const p of peers) {
4✔
2859
        const abs = resolve(dir, p.path);
×
2860
        if (!registeredPaths.has(abs)) {
×
2861
          toAdd.push({ name: p.name, path: p.path });
×
2862
          registeredPaths.add(abs);
×
2863
        }
2864
      }
2865

2866
      // 3. VS Code Workspaces
2867
      const wsFiles = readdirSync(dir).filter(f => f.endsWith('.code-workspace'));
4✔
2868
      for (const f of wsFiles) {
4✔
2869
        const wsFolderRepos = WorkspaceManager.discoverVSCodeWorkspace(join(dir, f), dir);
×
2870
        for (const p of wsFolderRepos) {
×
2871
          const abs = resolve(dir, p.path);
×
2872
          if (!registeredPaths.has(abs)) {
×
2873
            toAdd.push({ name: p.name, path: p.path });
×
2874
            registeredPaths.add(abs);
×
2875
          }
2876
        }
2877
      }
2878

2879
      // 4. Nested git repositories — prompt user to select which to add
2880
      const nestedReposSync = WorkspaceManager.discoverNestedGitRepos(dir);
4✔
2881
      const newNestedRepos = nestedReposSync.filter(n => !registeredPaths.has(resolve(dir, n.path)));
4✔
2882
      if (newNestedRepos.length > 0) {
4!
2883
        clack.log.step(`Found ${newNestedRepos.length} nested git repositor${newNestedRepos.length === 1 ? 'y' : 'ies'} via deep scan (up to 3 levels):`);
×
2884
        const chosen = await clack.multiselect({
×
2885
          message: 'Select nested repositories to register and scan:',
2886
          options: newNestedRepos.map(n => ({ value: n.path, label: `${n.name}  (${n.path})` })),
×
2887
          required: false,
2888
        });
2889
        if (clack.isCancel(chosen)) {
×
2890
          clack.cancel('Sync cancelled.');
×
2891
          process.exit(0);
×
2892
        }
2893
        for (const chosenPath of chosen as string[]) {
×
2894
          const nested = newNestedRepos.find(n => n.path === chosenPath)!;
×
2895
          toAdd.push({ name: nested.name, path: nested.path });
×
2896
          registeredPaths.add(resolve(dir, nested.path));
×
2897
        }
2898
      }
2899

2900
      // 5. Monorepo packages — prompt user to select which to register
2901
      const monoPkgsSync = WorkspaceManager.discoverMonorepoPackages(dir);
4✔
2902
      const newMonoPkgs = monoPkgsSync.filter(p => !registeredPaths.has(resolve(dir, p.path)));
4✔
2903
      if (newMonoPkgs.length > 0) {
4!
2904
        const mgr = newMonoPkgs[0].packageManager;
×
2905
        clack.log.step(`Found ${newMonoPkgs.length} monorepo package${newMonoPkgs.length === 1 ? '' : 's'} [${mgr}]:`);
×
2906
        const chosenMono = await clack.multiselect({
×
2907
          message: 'Select monorepo packages to register and scan:',
2908
          options: newMonoPkgs.map(p => ({ value: p.path, label: `${p.name}  (${p.path})` })),
×
2909
          required: false,
2910
        });
2911
        if (clack.isCancel(chosenMono)) {
×
2912
          clack.cancel('Sync cancelled.');
×
2913
          process.exit(0);
×
2914
        }
2915
        for (const chosenPath of chosenMono as string[]) {
×
2916
          const pkg = newMonoPkgs.find(p => p.path === chosenPath);
×
2917
          if (pkg) {
×
2918
            toAdd.push({ name: pkg.name, path: pkg.path });
×
2919
            registeredPaths.add(resolve(dir, pkg.path));
×
2920
          }
2921
        }
2922
      }
2923

2924
      if (toAdd.length === 0) {
4✔
2925
        clack.log.info('No new repositories discovered to sync.');
3✔
2926
        return;
3✔
2927
      }
2928

2929
      clack.log.step(`Syncing ${toAdd.length} newly discovered repositories:`);
1✔
2930
      for (const item of toAdd) {
1✔
2931
        config.addRepo(item.name, item.path);
1✔
2932
        clack.log.success(`Registered: ${item.name} -> ${item.path}`);
1✔
2933
      }
2934
      await config.save();
1✔
2935

2936
      clack.log.step('Running initial full scan for new repositories...');
1✔
2937
      const newNames = toAdd.map(item => item.name);
1✔
2938
      const onProgress = createProgressRenderer();
1✔
2939
      const scanner = new Scanner(store, config, graph, onProgress);
1✔
2940

2941
      const onSigInt = () => {
1✔
2942
        scanner.abort();
×
2943
        onProgress.stop('Canceled');
×
2944
      };
2945
      process.once('SIGINT', onSigInt);
1✔
2946

2947
      const result = await scanner.scanFull(newNames).catch((err) => {
1✔
2948
        onProgress.stop();
×
2949
        throw err;
×
2950
      });
2951

2952
      process.removeListener('SIGINT', onSigInt);
1✔
2953
      onProgress.stop();
1✔
2954
      clack.log.success(`Scanned ${result.filesScanned} files, ${result.symbolsFound} symbols, ${result.edgesFound} edges in ${result.durationMs}ms`);
1✔
2955
    });
2956

2957
  return program;
189✔
2958
}
2959

2960
export async function loadContext(dir: string): Promise<{
2961
  config: Config;
2962
  store: Store;
2963
  graph: MapxGraph;
2964
}> {
2965
  const configPath = resolve(dir, '.mapx', 'config.json');
143✔
2966
  if (!existsSync(configPath)) {
143✔
2967
    console.error(`MapxGraph not initialized in ${dir}. Run \`mapx init ${dir}\` first.`);
1✔
2968
    process.exit(1);
1✔
2969
  }
2970

2971
  const config = await Config.load(dir);
142✔
2972
  const dbPath = resolve(dir, '.mapx', 'mapx.db');
142✔
2973
  const store = new Store(dbPath);
142✔
2974

2975
  // Ensure the DB connection is closed when the process exits normally or
2976
  // after an unhandled error — this prevents the process from hanging after
2977
  // command completion due to SQLite's open file descriptor keeping the event
2978
  // loop alive.
2979
  const closeStore = () => { try { store.close(); } catch { /* already closed */ } };
142✔
2980
  process.once('exit', closeStore);
142✔
2981
  process.once('SIGINT', () => { closeStore(); process.exit(130); });
142✔
2982
  process.once('SIGTERM', () => { closeStore(); process.exit(143); });
142✔
2983

2984
  const graph = new MapxGraph(config.repo.name);
142✔
2985

2986
  const files = store.getAllFiles();
142✔
2987
  for (const file of files) {
142✔
2988
    graph.addFileNode(
143✔
2989
      file.path as string,
2990
      file.language as string,
2991
      file.size_bytes as number,
2992
      file.lines as number
2993
    );
2994
  }
2995

2996
  const symbols = store.getAllSymbols();
142✔
2997
  for (const sym of symbols) {
142✔
2998
    graph.addSymbolNode(
×
2999
      sym.name as string,
3000
      sym.file_path as string,
3001
      sym.name as string,
3002
      sym.kind as any,
3003
      sym.start_line as number,
3004
      sym.end_line as number,
3005
      sym.scope as string | null
3006
    );
3007
  }
3008

3009
  const edges = store.getAllEdges();
142✔
3010
  for (const edge of edges) {
142✔
3011
    graph.addDependencyEdge({
×
3012
      sourceFile: edge.source_file as string,
3013
      targetFile: edge.target_file as string,
3014
      sourceSymbol: edge.source_symbol as string | null,
3015
      targetSymbol: edge.target_symbol as string | null,
3016
      edgeType: edge.edge_type as any,
3017
      repo: edge.repo as string,
3018
      weight: edge.weight as number,
3019
      verifiability: edge.verifiability as any,
3020
      targetRepo: edge.target_repo as string | null,
3021
    });
3022
  }
3023

3024
  return { config, store, graph };
142✔
3025
}
3026

3027
export function getStaleFilesCount(store: Store, dir: string): number {
3028
  try {
97✔
3029
    const files = store.getAllFiles();
97✔
3030
    let changedCount = 0;
97✔
3031
    for (const file of files) {
97✔
3032
      const absPath = resolve(dir, file.path as string);
107✔
3033
      if (existsSync(absPath)) {
107✔
3034
        const stats = statSync(absPath);
95✔
3035
        const dbTime = new Date(file.last_scanned as string).getTime();
95✔
3036
        if (stats.mtimeMs > dbTime) {
95!
3037
          changedCount++;
×
3038
        }
3039
      } else {
3040
        changedCount++;
12✔
3041
      }
3042
    }
3043
    return changedCount;
97✔
3044
  } catch {
3045
    return 0;
×
3046
  }
3047
}
3048

3049
export function checkAndPrintStaleness(store: Store, dir: string): void {
3050
  const staleCount = getStaleFilesCount(store, dir);
94✔
3051
  if (staleCount > 0) {
94✔
3052
    // Show up to 5 changed file names
3053
    const files = store.getAllFiles();
2✔
3054
    const changed: string[] = [];
2✔
3055
    for (const file of files) {
2✔
3056
      if (changed.length >= 5) break;
7✔
3057
      const absPath = resolve(dir, file.path as string);
6✔
3058
      if (existsSync(absPath)) {
6!
3059
        const stats = statSync(absPath);
×
3060
        const dbTime = new Date(file.last_scanned as string).getTime();
×
3061
        if (stats.mtimeMs > dbTime) changed.push(file.path as string);
×
3062
      } else {
3063
        changed.push(`${file.path} (deleted)`);
6✔
3064
      }
3065
    }
3066
    const fileList = changed.length > 0
2!
3067
      ? `\nChanged: ${changed.join(', ')}${staleCount > changed.length ? ` (and ${staleCount - changed.length} more)` : ''}`
2✔
3068
      : '';
3069
    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`);
2✔
3070
  }
3071
}
3072

3073
export function checkTryCatch(content: string, lineNum: number, startLine: number, isPython: boolean): boolean {
3074
  return coreCheckTryCatch(content, lineNum, startLine, isPython);
1✔
3075
}
3076

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