projscan 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +25 -7
  2. package/dist/cli/index.js +294 -10
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/core/ast.d.ts +2 -0
  5. package/dist/core/ast.js +35 -2
  6. package/dist/core/ast.js.map +1 -1
  7. package/dist/core/codeGraph.d.ts +2 -0
  8. package/dist/core/codeGraph.js +2 -0
  9. package/dist/core/codeGraph.js.map +1 -1
  10. package/dist/core/couplingAnalyzer.d.ts +18 -0
  11. package/dist/core/couplingAnalyzer.js +174 -0
  12. package/dist/core/couplingAnalyzer.js.map +1 -0
  13. package/dist/core/fileInspector.d.ts +1 -1
  14. package/dist/core/fileInspector.js +31 -1
  15. package/dist/core/fileInspector.js.map +1 -1
  16. package/dist/core/hotspotAnalyzer.d.ts +13 -0
  17. package/dist/core/hotspotAnalyzer.js +29 -6
  18. package/dist/core/hotspotAnalyzer.js.map +1 -1
  19. package/dist/core/indexCache.js +6 -3
  20. package/dist/core/indexCache.js.map +1 -1
  21. package/dist/core/languages/LanguageAdapter.d.ts +1 -1
  22. package/dist/core/languages/goAdapter.d.ts +2 -0
  23. package/dist/core/languages/goAdapter.js +136 -0
  24. package/dist/core/languages/goAdapter.js.map +1 -0
  25. package/dist/core/languages/goCyclomatic.d.ts +21 -0
  26. package/dist/core/languages/goCyclomatic.js +55 -0
  27. package/dist/core/languages/goCyclomatic.js.map +1 -0
  28. package/dist/core/languages/goExports.d.ts +26 -0
  29. package/dist/core/languages/goExports.js +89 -0
  30. package/dist/core/languages/goExports.js.map +1 -0
  31. package/dist/core/languages/goImports.d.ts +26 -0
  32. package/dist/core/languages/goImports.js +64 -0
  33. package/dist/core/languages/goImports.js.map +1 -0
  34. package/dist/core/languages/goManifests.d.ts +19 -0
  35. package/dist/core/languages/goManifests.js +56 -0
  36. package/dist/core/languages/goManifests.js.map +1 -0
  37. package/dist/core/languages/pythonAdapter.js +5 -0
  38. package/dist/core/languages/pythonAdapter.js.map +1 -1
  39. package/dist/core/languages/pythonCyclomatic.d.ts +18 -0
  40. package/dist/core/languages/pythonCyclomatic.js +45 -0
  41. package/dist/core/languages/pythonCyclomatic.js.map +1 -0
  42. package/dist/core/languages/registry.js +2 -1
  43. package/dist/core/languages/registry.js.map +1 -1
  44. package/dist/core/languages/treeSitterLoader.js +2 -1
  45. package/dist/core/languages/treeSitterLoader.js.map +1 -1
  46. package/dist/core/monorepo.d.ts +20 -0
  47. package/dist/core/monorepo.js +270 -0
  48. package/dist/core/monorepo.js.map +1 -0
  49. package/dist/core/prDiff.d.ts +43 -0
  50. package/dist/core/prDiff.js +298 -0
  51. package/dist/core/prDiff.js.map +1 -0
  52. package/dist/core/telemetry.d.ts +90 -0
  53. package/dist/core/telemetry.js +199 -0
  54. package/dist/core/telemetry.js.map +1 -0
  55. package/dist/grammars/tree-sitter-go.wasm +0 -0
  56. package/dist/mcp/server.js +22 -0
  57. package/dist/mcp/server.js.map +1 -1
  58. package/dist/mcp/tools.js +269 -20
  59. package/dist/mcp/tools.js.map +1 -1
  60. package/dist/reporters/consoleReporter.d.ts +4 -1
  61. package/dist/reporters/consoleReporter.js +113 -0
  62. package/dist/reporters/consoleReporter.js.map +1 -1
  63. package/dist/reporters/jsonReporter.d.ts +4 -1
  64. package/dist/reporters/jsonReporter.js +9 -0
  65. package/dist/reporters/jsonReporter.js.map +1 -1
  66. package/dist/reporters/markdownReporter.d.ts +4 -1
  67. package/dist/reporters/markdownReporter.js +103 -3
  68. package/dist/reporters/markdownReporter.js.map +1 -1
  69. package/dist/types.d.ts +113 -0
  70. package/dist/utils/cache.d.ts +3 -0
  71. package/dist/utils/cache.js +51 -0
  72. package/dist/utils/cache.js.map +1 -0
  73. package/dist/utils/config.js +10 -0
  74. package/dist/utils/config.js.map +1 -1
  75. package/package.json +3 -2
package/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  AI coding agents are becoming the primary interface to code. Today, when you ask your agent *"which files implement auth?"* or *"what breaks if I bump React from 18 to 19?"* - it either guesses from names, or it shells out to grep and reads raw output not built for it.
22
22
 
23
- **projscan is the first code-intelligence tool built for agents, not for humans.** Your agent gets a fast, AST-accurate, context-budget-aware view of your codebase through 13 structured MCP tools. It can query the import graph, find symbol definitions, preview upgrades, rank hotspots - without loading the file tree into its context.
23
+ **projscan is the first code-intelligence tool built for agents, not for humans.** Your agent gets a fast, AST-accurate, context-budget-aware view of your codebase through 17 structured MCP tools. It can query the import graph, find symbol definitions, preview upgrades, rank hotspots, diff structural changes between refs, and surface coupling/cycle hotspots - without loading the file tree into its context.
24
24
 
25
25
  Humans get the same thing through the CLI.
26
26
 
@@ -190,7 +190,7 @@ This outputs a [shields.io](https://shields.io) badge URL and markdown snippet y
190
190
 
191
191
  ## What It Detects
192
192
 
193
- **Languages**: TypeScript, JavaScript, Python (full AST analysis for all three), plus file-level detection for Go, Rust, Java, Ruby, C/C++, PHP, Swift, Kotlin, and 20+ more.
193
+ **Languages**: TypeScript, JavaScript, Python, and Go (full AST analysis for all four), plus file-level detection for Rust, Java, Ruby, C/C++, PHP, Swift, Kotlin, and 20+ more.
194
194
 
195
195
  **Frameworks**: React, Next.js, Vue, Nuxt, Svelte, Angular, Express, Fastify, NestJS, Vite, Tailwind CSS, Prisma, and more
196
196
 
@@ -206,6 +206,18 @@ Python repos now get the same treatment JS/TS has had since 0.6:
206
206
 
207
207
  `projscan_upgrade` remains Node-only for now - a Python equivalent (reading pip / poetry metadata) is on the roadmap.
208
208
 
209
+ ### Bundle (0.11)
210
+
211
+ 0.11.0 is a multi-theme bundle. Five releases of work shipped under one version:
212
+
213
+ - **Signal Quality (was 0.11)** - the hotspot risk score now uses AST-derived **cyclomatic complexity** instead of a line-count proxy. Per-file CC is exposed via `projscan_file` and the new `projscan_coupling` tool. Coupling metrics (fan-in / fan-out / instability) and **circular-import detection** (Tarjan SCC) ship as a first-class `projscan coupling` command and MCP tool.
214
+ - **PR Native (was 0.12)** - new `projscan_pr_diff` tool returns the **structural** diff between two refs: exports added/removed, imports added/removed, call sites added/removed, ΔCC, Δfan-in. Stands up a temporary git worktree at the base ref to get a clean second graph. What an agent reviewing a PR actually wants to know.
215
+ - **Monorepo (was 0.13)** - workspace detection for npm/yarn workspaces, pnpm-workspace.yaml, and Nx/Turbo/Lerna fallback. New `projscan workspaces` command lists every package; `--package <name>` (and the `package` MCP arg) scope `hotspots` and `coupling` to a single workspace.
216
+ - **Observability (was 0.14)** - **opt-in**, privacy-preserving telemetry. Records only tool name, duration, success, version, timestamp; never source content, paths, or arguments. Off by default; enable via `.projscanrc` `telemetry.enabled` or `PROJSCAN_TELEMETRY=1`. Sink is a local JSONL file you control. New `projscan_telemetry` tool surfaces effective state.
217
+ - **Second Language (was 0.15)** - **Go** via tree-sitter-go. Go files now flow through the same graph / hotspot / coupling / pr-diff pipeline as JS/TS and Python. `go.mod` parsed for module-path resolution; capitalization rule applied for export visibility.
218
+
219
+ Cache version bumped 2 → 3 (CC stored per file). Existing v2 caches are discarded on first 0.11 run and rebuilt automatically.
220
+
209
221
  **Issues**:
210
222
  - Missing linting (ESLint) and formatting (Prettier) configuration
211
223
  - Missing test framework
@@ -222,7 +234,7 @@ Python repos now get the same treatment JS/TS has had since 0.6:
222
234
  - **5,000 files** analyzed in under 1.5 seconds
223
235
  - **20,000 files** analyzed in under 3 seconds
224
236
  - **Zero network requests** - everything runs locally
225
- - **6 runtime dependencies** - still minimal footprint (the two tree-sitter packages add ~640 KB of vendored wasm)
237
+ - **9 runtime dependencies** - still minimal (the three tree-sitter packages bring ~850 KB of vendored wasm: web-tree-sitter ~190 KB, tree-sitter-python ~450 KB, tree-sitter-go ~210 KB)
226
238
 
227
239
  ## CI/CD Integration
228
240
 
@@ -429,17 +441,19 @@ claude mcp add projscan -- npx projscan mcp
429
441
  - *"What breaks if I bump chalk to 6?"* → `projscan_upgrade { package: "chalk" }`
430
442
  - *"Where should I refactor first?"* → `projscan_hotspots`
431
443
 
432
- ### The 13 MCP tools
444
+ ### The 17 MCP tools
433
445
 
434
- **Structural (0.6.0 - new, agent-native):**
446
+ **Structural (0.6.0 / 0.11 agent-native):**
435
447
  - **`projscan_graph`** - query the AST-based code graph. Directions: `imports`, `exports`, `importers`, `symbol_defs`, `package_importers`. Millisecond responses on a warm cache.
436
448
  - **`projscan_search`** - fast search across `symbols` (exported names), `files` (path substring), or `content` (source substring with line + excerpt). Replaces the temptation to shell out to grep.
449
+ - **`projscan_coupling`** *(0.11)* - per-file fan-in / fan-out / instability + circular-import cycles (Tarjan SCC). Filter by `direction: cycles_only | high_fan_in | high_fan_out`.
450
+ - **`projscan_pr_diff`** *(0.11)* - structural diff between two git refs. Returns added/removed/modified files with explicit lists of exports, imports, and call sites that changed, plus ΔCC and Δfan-in.
437
451
 
438
452
  **Analysis:**
439
453
  - `projscan_analyze` - full project report
440
454
  - `projscan_doctor` - health score + issues
441
- - `projscan_hotspots` - risk-ranked files (churn × complexity × issues × ownership × coverage)
442
- - `projscan_file` - per-file risk + ownership + related issues
455
+ - `projscan_hotspots` - risk-ranked files (churn × **AST cyclomatic complexity** × issues × ownership × coverage; falls back to LOC for non-AST languages)
456
+ - `projscan_file` - per-file risk + ownership + related issues + CC + fan-in/fan-out
443
457
  - `projscan_explain` - per-file purpose, imports, exports, smells
444
458
  - `projscan_structure` - directory tree
445
459
  - `projscan_coverage` - scariest untested files (coverage × hotspots)
@@ -450,6 +464,10 @@ claude mcp add projscan -- npx projscan mcp
450
464
  - `projscan_audit` - normalized `npm audit`
451
465
  - `projscan_upgrade` - upgrade preview (CHANGELOG + importers, offline)
452
466
 
467
+ **Workspace + observability (0.11):**
468
+ - `projscan_workspaces` - list monorepo packages (npm/yarn/pnpm/Nx/Turbo/Lerna). Use the `name` as the `package` arg on `projscan_hotspots` / `projscan_coupling` to scope.
469
+ - `projscan_telemetry` - inspect opt-in telemetry state (enabled?, sink path, env override).
470
+
453
471
  ### Context-window budgeting
454
472
 
455
473
  **Every MCP tool accepts an optional `max_tokens` argument.** Set it and projscan serializes the result, and - if over budget - truncates the largest array field record-by-record until it fits. Responses include a `_budget` sidecar when truncated so your agent knows it got a partial view.
package/dist/cli/index.js CHANGED
@@ -22,6 +22,10 @@ import { parseCoverage, coverageMap } from '../core/coverageParser.js';
22
22
  import { joinCoverageWithHotspots } from '../core/coverageJoin.js';
23
23
  import { buildCodeGraph } from '../core/codeGraph.js';
24
24
  import { loadCachedGraph, saveCachedGraph } from '../core/indexCache.js';
25
+ import { computeCoupling, filterCoupling } from '../core/couplingAnalyzer.js';
26
+ import { computePrDiff } from '../core/prDiff.js';
27
+ import { detectWorkspaces, filterFilesByPackage } from '../core/monorepo.js';
28
+ import { describeTelemetryConfig, aggregateTelemetry } from '../core/telemetry.js';
25
29
  import { buildSearchIndex, search as searchIndex, attachExcerpts, expandQuery } from '../core/searchIndex.js';
26
30
  import { buildSemanticIndex, semanticSearch, reciprocalRankFusion, } from '../core/semanticSearch.js';
27
31
  import { isSemanticAvailable } from '../core/embeddings.js';
@@ -34,9 +38,9 @@ import { saveBaseline, loadBaseline, computeDiff } from '../utils/baseline.js';
34
38
  import { loadConfig, applyConfigToIssues } from '../utils/config.js';
35
39
  import { getChangedFiles } from '../utils/changedFiles.js';
36
40
  import { runMcpServer } from '../mcp/server.js';
37
- import { reportAnalysis, reportHealth, reportCi, reportDiff, reportDetectedIssues, reportExplanation, reportDiagram, reportStructure, reportDependencies, reportHotspots, reportFileInspection, reportOutdated, reportAudit, reportUpgrade, reportCoverage, } from '../reporters/consoleReporter.js';
38
- import { reportAnalysisJson, reportHealthJson, reportCiJson, reportDiffJson, reportExplanationJson, reportDiagramJson, reportStructureJson, reportDependenciesJson, reportHotspotsJson, reportFileJson, reportOutdatedJson, reportAuditJson, reportUpgradeJson, reportCoverageJson, } from '../reporters/jsonReporter.js';
39
- import { reportAnalysisMarkdown, reportHealthMarkdown, reportCiMarkdown, reportDiffMarkdown, reportExplanationMarkdown, reportDiagramMarkdown, reportStructureMarkdown, reportDependenciesMarkdown, reportHotspotsMarkdown, reportFileMarkdown, reportOutdatedMarkdown, reportAuditMarkdown, reportUpgradeMarkdown, reportCoverageMarkdown, } from '../reporters/markdownReporter.js';
41
+ import { reportAnalysis, reportHealth, reportCi, reportDiff, reportDetectedIssues, reportExplanation, reportDiagram, reportStructure, reportDependencies, reportHotspots, reportFileInspection, reportOutdated, reportAudit, reportUpgrade, reportCoverage, reportCoupling, reportPrDiff, reportWorkspaces, } from '../reporters/consoleReporter.js';
42
+ import { reportAnalysisJson, reportHealthJson, reportCiJson, reportDiffJson, reportExplanationJson, reportDiagramJson, reportStructureJson, reportDependenciesJson, reportHotspotsJson, reportFileJson, reportOutdatedJson, reportAuditJson, reportUpgradeJson, reportCoverageJson, reportCouplingJson, reportPrDiffJson, reportWorkspacesJson, } from '../reporters/jsonReporter.js';
43
+ import { reportAnalysisMarkdown, reportHealthMarkdown, reportCiMarkdown, reportDiffMarkdown, reportExplanationMarkdown, reportDiagramMarkdown, reportStructureMarkdown, reportDependenciesMarkdown, reportHotspotsMarkdown, reportFileMarkdown, reportOutdatedMarkdown, reportAuditMarkdown, reportUpgradeMarkdown, reportCoverageMarkdown, reportCouplingMarkdown, reportPrDiffMarkdown, reportWorkspacesMarkdown, } from '../reporters/markdownReporter.js';
40
44
  import { reportAnalysisSarif, reportHealthSarif, reportCiSarif, issuesToSarif, } from '../reporters/sarifReporter.js';
41
45
  // ── CLI Setup ─────────────────────────────────────────────
42
46
  const program = new Command();
@@ -137,12 +141,24 @@ function maybeCompactBanner() {
137
141
  }
138
142
  }
139
143
  }
144
+ /** Walk a DirectoryNode to find the node whose `path` matches targetPath. */
145
+ function sliceCliTree(node, targetPath) {
146
+ if (node.path === targetPath)
147
+ return node;
148
+ for (const child of node.children) {
149
+ const hit = sliceCliTree(child, targetPath);
150
+ if (hit)
151
+ return hit;
152
+ }
153
+ return null;
154
+ }
140
155
  // ── Command: analyze (default) ────────────────────────────
141
156
  program
142
157
  .command('analyze', { isDefault: true })
143
158
  .description('Analyze repository and show project report')
144
159
  .option('--changed-only', 'only report issues on files changed vs base ref')
145
160
  .option('--base-ref <ref>', 'git base ref for --changed-only (default: origin/main)')
161
+ .option('--package <name>', 'monorepo: scope issues to a single workspace package')
146
162
  .action(async (cmdOpts) => {
147
163
  setupLogLevel();
148
164
  maybeBanner();
@@ -168,6 +184,11 @@ program
168
184
  if (cmdOpts.changedOnly) {
169
185
  issues = await filterIssuesByChangedFiles(issues, rootPath, cmdOpts.baseRef ?? config.baseRef);
170
186
  }
187
+ if (cmdOpts.package) {
188
+ const ws = await detectWorkspaces(rootPath);
189
+ const allowed = new Set(filterFilesByPackage(ws, cmdOpts.package, scan.files.map((f) => f.relativePath)));
190
+ issues = issues.filter((i) => (i.locations ?? []).some((l) => l.file && allowed.has(l.file)));
191
+ }
171
192
  if (spinner)
172
193
  spinner.stop();
173
194
  const report = {
@@ -207,6 +228,7 @@ program
207
228
  .description('Evaluate project health and detect issues')
208
229
  .option('--changed-only', 'only report issues on files changed vs base ref')
209
230
  .option('--base-ref <ref>', 'git base ref for --changed-only (default: origin/main)')
231
+ .option('--package <name>', 'monorepo: scope issues to a single workspace package')
210
232
  .action(async (cmdOpts) => {
211
233
  setupLogLevel();
212
234
  maybeCompactBanner();
@@ -221,6 +243,11 @@ program
221
243
  if (cmdOpts.changedOnly) {
222
244
  issues = await filterIssuesByChangedFiles(issues, rootPath, cmdOpts.baseRef ?? config.baseRef);
223
245
  }
246
+ if (cmdOpts.package) {
247
+ const ws = await detectWorkspaces(rootPath);
248
+ const allowed = new Set(filterFilesByPackage(ws, cmdOpts.package, scan.files.map((f) => f.relativePath)));
249
+ issues = issues.filter((i) => (i.locations ?? []).some((l) => l.file && allowed.has(l.file)));
250
+ }
224
251
  if (spinner)
225
252
  spinner.stop();
226
253
  switch (format) {
@@ -516,7 +543,8 @@ program
516
543
  program
517
544
  .command('structure')
518
545
  .description('Show project directory structure')
519
- .action(async () => {
546
+ .option('--package <name>', 'monorepo: scope tree to a single workspace package')
547
+ .action(async (cmdOpts) => {
520
548
  setupLogLevel();
521
549
  maybeCompactBanner();
522
550
  const rootPath = getRootPath();
@@ -525,17 +553,30 @@ program
525
553
  const spinner = format === 'console' ? ora('Scanning...').start() : null;
526
554
  try {
527
555
  const scan = await scanRepository(rootPath, { ignore: config.ignore });
556
+ let tree = scan.directoryTree;
557
+ let title = path.basename(rootPath);
558
+ if (cmdOpts.package) {
559
+ const ws = await detectWorkspaces(rootPath);
560
+ const pkg = ws.packages.find((p) => p.name === cmdOpts.package);
561
+ if (pkg && !pkg.isRoot && pkg.relativePath) {
562
+ const sliced = sliceCliTree(tree, pkg.relativePath);
563
+ if (sliced) {
564
+ tree = sliced;
565
+ title = pkg.name;
566
+ }
567
+ }
568
+ }
528
569
  if (spinner)
529
570
  spinner.stop();
530
571
  switch (format) {
531
572
  case 'json':
532
- reportStructureJson(scan.directoryTree);
573
+ reportStructureJson(tree);
533
574
  break;
534
575
  case 'markdown':
535
- reportStructureMarkdown(scan.directoryTree);
576
+ reportStructureMarkdown(tree);
536
577
  break;
537
578
  default:
538
- reportStructure(scan.directoryTree, path.basename(rootPath));
579
+ reportStructure(tree, title);
539
580
  }
540
581
  }
541
582
  catch (error) {
@@ -584,9 +625,10 @@ program
584
625
  // ── Command: hotspots ─────────────────────────────────────
585
626
  program
586
627
  .command('hotspots')
587
- .description('Rank files by risk (git churn × complexity × open issues)')
628
+ .description('Rank files by risk (git churn × AST cyclomatic complexity × open issues)')
588
629
  .option('--limit <n>', 'number of hotspots to show')
589
630
  .option('--since <when>', 'git history window (e.g. "6 months ago", "2024-01-01")')
631
+ .option('--package <name>', 'monorepo: scope to a single workspace package')
590
632
  .action(async (cmdOpts) => {
591
633
  setupLogLevel();
592
634
  maybeCompactBanner();
@@ -602,11 +644,22 @@ program
602
644
  const limit = Math.max(1, Math.min(100, typeof limitRaw === 'string' ? parseInt(limitRaw, 10) || 10 : limitRaw));
603
645
  const since = cmdOpts.since ?? config.hotspots?.since ?? '12 months ago';
604
646
  const coverageReport = await parseCoverage(rootPath);
647
+ // Build the code graph so the risk score uses AST cyclomatic complexity
648
+ // instead of LOC. Cache hit makes this nearly free on repeat runs.
649
+ const cached = await loadCachedGraph(rootPath);
650
+ const graph = await buildCodeGraph(rootPath, scan.files, cached);
651
+ await saveCachedGraph(rootPath, graph);
605
652
  const report = await analyzeHotspots(rootPath, scan.files, issues, {
606
653
  since,
607
654
  limit,
608
655
  coverage: coverageReport.available ? coverageMap(coverageReport) : undefined,
656
+ graph,
609
657
  });
658
+ if (cmdOpts.package) {
659
+ const ws = await detectWorkspaces(rootPath);
660
+ const allowed = new Set(filterFilesByPackage(ws, cmdOpts.package, report.hotspots.map((h) => h.relativePath)));
661
+ report.hotspots = report.hotspots.filter((h) => allowed.has(h.relativePath));
662
+ }
610
663
  if (spinner)
611
664
  spinner.stop();
612
665
  switch (format) {
@@ -627,6 +680,209 @@ program
627
680
  process.exit(1);
628
681
  }
629
682
  });
683
+ // ── Command: coupling ─────────────────────────────────────
684
+ program
685
+ .command('coupling')
686
+ .description('Per-file fan-in / fan-out / instability and circular-import cycles (AST-derived)')
687
+ .option('--limit <n>', 'number of files to show (default 25)')
688
+ .option('--cycles-only', 'only show files participating in import cycles')
689
+ .option('--high-fan-in', 'sort by fan-in (most-depended-on first)')
690
+ .option('--high-fan-out', 'sort by fan-out (most-coupled first)')
691
+ .option('--file <path>', 'restrict output to a single file')
692
+ .option('--package <name>', 'monorepo: scope to a single workspace package')
693
+ .action(async (cmdOpts) => {
694
+ setupLogLevel();
695
+ maybeCompactBanner();
696
+ const rootPath = getRootPath();
697
+ const format = getFormat();
698
+ const config = await loadProjectConfig();
699
+ const spinner = format === 'console' ? ora('Computing coupling + cycles...').start() : null;
700
+ try {
701
+ const scan = await scanRepository(rootPath, { ignore: config.ignore });
702
+ const cached = await loadCachedGraph(rootPath);
703
+ const graph = await buildCodeGraph(rootPath, scan.files, cached);
704
+ await saveCachedGraph(rootPath, graph);
705
+ const ws = await detectWorkspaces(rootPath);
706
+ const report = computeCoupling(graph, ws);
707
+ const direction = cmdOpts.cyclesOnly
708
+ ? 'cycles_only'
709
+ : cmdOpts.highFanIn
710
+ ? 'high_fan_in'
711
+ : cmdOpts.highFanOut
712
+ ? 'high_fan_out'
713
+ : 'all';
714
+ const limitRaw = cmdOpts.limit ?? 25;
715
+ const limit = Math.max(1, Math.min(500, typeof limitRaw === 'string' ? parseInt(limitRaw, 10) || 25 : limitRaw));
716
+ let files = filterCoupling(report, direction);
717
+ if (cmdOpts.file)
718
+ files = files.filter((f) => f.relativePath === cmdOpts.file);
719
+ if (cmdOpts.package) {
720
+ const ws = await detectWorkspaces(rootPath);
721
+ const allowed = new Set(filterFilesByPackage(ws, cmdOpts.package, files.map((f) => f.relativePath)));
722
+ files = files.filter((f) => allowed.has(f.relativePath));
723
+ }
724
+ files = files.slice(0, limit);
725
+ const filtered = {
726
+ files,
727
+ cycles: report.cycles,
728
+ crossPackageEdges: report.crossPackageEdges,
729
+ totalFiles: report.totalFiles,
730
+ totalCycles: report.totalCycles,
731
+ totalCrossPackageEdges: report.totalCrossPackageEdges,
732
+ };
733
+ if (spinner)
734
+ spinner.stop();
735
+ switch (format) {
736
+ case 'json':
737
+ reportCouplingJson(filtered);
738
+ break;
739
+ case 'markdown':
740
+ reportCouplingMarkdown(filtered);
741
+ break;
742
+ default:
743
+ reportCoupling(filtered);
744
+ }
745
+ }
746
+ catch (error) {
747
+ if (spinner)
748
+ spinner.fail('Coupling analysis failed');
749
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
750
+ process.exit(1);
751
+ }
752
+ });
753
+ // ── Command: pr-diff ──────────────────────────────────────
754
+ program
755
+ .command('pr-diff')
756
+ .description('Structural (AST) diff between two refs - what changed in exports, imports, calls, CC, fan-in')
757
+ .option('--base <ref>', 'base ref (default: origin/main, falling back to main/master/HEAD~1)')
758
+ .option('--head <ref>', 'head ref (default: HEAD)')
759
+ .option('--package <name>', 'monorepo: scope diff to a single workspace package')
760
+ .action(async (cmdOpts) => {
761
+ setupLogLevel();
762
+ maybeCompactBanner();
763
+ const rootPath = getRootPath();
764
+ const format = getFormat();
765
+ const spinner = format === 'console' ? ora('Computing structural PR diff...').start() : null;
766
+ try {
767
+ const report = await computePrDiff(rootPath, { base: cmdOpts.base, head: cmdOpts.head });
768
+ if (cmdOpts.package) {
769
+ const ws = await detectWorkspaces(rootPath);
770
+ const collected = [
771
+ ...report.filesAdded,
772
+ ...report.filesRemoved,
773
+ ...report.filesModified.map((f) => f.relativePath),
774
+ ];
775
+ const allowed = new Set(filterFilesByPackage(ws, cmdOpts.package, collected));
776
+ report.filesAdded = report.filesAdded.filter((f) => allowed.has(f));
777
+ report.filesRemoved = report.filesRemoved.filter((f) => allowed.has(f));
778
+ report.filesModified = report.filesModified.filter((f) => allowed.has(f.relativePath));
779
+ report.totalFilesChanged =
780
+ report.filesAdded.length + report.filesRemoved.length + report.filesModified.length;
781
+ }
782
+ if (spinner)
783
+ spinner.stop();
784
+ switch (format) {
785
+ case 'json':
786
+ reportPrDiffJson(report);
787
+ break;
788
+ case 'markdown':
789
+ reportPrDiffMarkdown(report);
790
+ break;
791
+ default:
792
+ reportPrDiff(report);
793
+ }
794
+ }
795
+ catch (error) {
796
+ if (spinner)
797
+ spinner.fail('PR diff failed');
798
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
799
+ process.exit(1);
800
+ }
801
+ });
802
+ // ── Command: workspaces ───────────────────────────────────
803
+ program
804
+ .command('workspaces')
805
+ .description('List monorepo workspace packages (npm/yarn workspaces, pnpm-workspace.yaml, Nx/Turbo/Lerna fallback)')
806
+ .action(async () => {
807
+ setupLogLevel();
808
+ maybeCompactBanner();
809
+ const rootPath = getRootPath();
810
+ const format = getFormat();
811
+ try {
812
+ const info = await detectWorkspaces(rootPath);
813
+ switch (format) {
814
+ case 'json':
815
+ reportWorkspacesJson(info);
816
+ break;
817
+ case 'markdown':
818
+ reportWorkspacesMarkdown(info);
819
+ break;
820
+ default:
821
+ reportWorkspaces(info);
822
+ }
823
+ }
824
+ catch (error) {
825
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
826
+ process.exit(1);
827
+ }
828
+ });
829
+ // ── Command: telemetry ────────────────────────────────────
830
+ program
831
+ .command('telemetry')
832
+ .description('Inspect projscan opt-in telemetry: config state, or per-tool histograms with --aggregate')
833
+ .option('--aggregate', 'read the local sink and print per-tool latency histograms (count, p50/p95/p99, error rate)')
834
+ .action(async (cmdOpts) => {
835
+ setupLogLevel();
836
+ maybeCompactBanner();
837
+ const rootPath = getRootPath();
838
+ const format = getFormat();
839
+ try {
840
+ const { config } = await loadConfig(rootPath);
841
+ const out = cmdOpts.aggregate
842
+ ? await aggregateTelemetry(config.telemetry)
843
+ : describeTelemetryConfig(config.telemetry);
844
+ if (format === 'json') {
845
+ console.log(JSON.stringify(out, null, 2));
846
+ return;
847
+ }
848
+ // Console: hand-formatted summary so users don't have to read raw JSON.
849
+ if (cmdOpts.aggregate) {
850
+ const agg = out;
851
+ if (!agg.available) {
852
+ console.log(chalk.yellow(`\n ${agg.reason ?? 'No telemetry available.'}\n`));
853
+ return;
854
+ }
855
+ console.log(chalk.bold('\n Telemetry histograms'));
856
+ console.log(chalk.dim(` sink: ${agg.sink}`));
857
+ console.log(chalk.dim(` ${agg.totalEvents} event(s) · ${agg.windowFrom ?? '?'} → ${agg.windowTo ?? '?'}\n`));
858
+ if (agg.byTool.length === 0) {
859
+ console.log(chalk.dim(' (no events recorded yet)\n'));
860
+ return;
861
+ }
862
+ const colHead = ` ${'count'.padStart(6)} ${'err%'.padStart(5)} ${'p50'.padStart(6)} ${'p95'.padStart(6)} ${'p99'.padStart(6)} tool`;
863
+ console.log(chalk.dim(colHead));
864
+ for (const t of agg.byTool) {
865
+ const errPct = (t.errorRate * 100).toFixed(1) + '%';
866
+ console.log(` ${String(t.count).padStart(6)} ${errPct.padStart(5)} ${(t.p50Ms ?? 0).toString().padStart(6)} ${(t.p95Ms ?? 0).toString().padStart(6)} ${(t.p99Ms ?? 0).toString().padStart(6)} ${chalk.cyan(t.tool)}`);
867
+ }
868
+ console.log('');
869
+ }
870
+ else {
871
+ const cfg = out;
872
+ console.log(chalk.bold('\n Telemetry'));
873
+ console.log(` enabled: ${cfg.enabled ? chalk.green('yes') : chalk.dim('no (default)')}`);
874
+ console.log(` sink: ${cfg.sink}`);
875
+ console.log(` default: ${cfg.defaultSink}`);
876
+ console.log(` PROJSCAN_TELEMETRY env: ${cfg.envOverride ?? chalk.dim('(unset)')}`);
877
+ console.log(chalk.dim('\n Records: tool, durationMs, ok, version, ts. Never source/paths/args.'));
878
+ console.log(chalk.dim(' Re-run with --aggregate to see histograms over the recorded events.\n'));
879
+ }
880
+ }
881
+ catch (error) {
882
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
883
+ process.exit(1);
884
+ }
885
+ });
630
886
  // ── Command: outdated ─────────────────────────────────────
631
887
  program
632
888
  .command('outdated')
@@ -741,6 +997,7 @@ program
741
997
  .option('--mode <mode>', 'lexical | semantic | hybrid (content/auto scope only)', 'lexical')
742
998
  .option('--semantic', 'shortcut for --mode semantic')
743
999
  .option('--limit <n>', 'max results', '15')
1000
+ .option('--package <name>', 'monorepo: scope to a single workspace package')
744
1001
  .action(async (queryParts, cmdOpts) => {
745
1002
  setupLogLevel();
746
1003
  maybeCompactBanner();
@@ -761,6 +1018,22 @@ program
761
1018
  const cached = await loadCachedGraph(rootPath);
762
1019
  const graph = await buildCodeGraph(rootPath, scan.files, cached);
763
1020
  await saveCachedGraph(rootPath, graph);
1021
+ // Build a (file -> bool) filter once if --package is set; reused below.
1022
+ let passes = null;
1023
+ if (cmdOpts.package) {
1024
+ const ws = await detectWorkspaces(rootPath);
1025
+ const pkg = ws.packages.find((p) => p.name === cmdOpts.package);
1026
+ if (!pkg) {
1027
+ passes = () => false;
1028
+ }
1029
+ else if (pkg.isRoot) {
1030
+ passes = () => true;
1031
+ }
1032
+ else {
1033
+ const prefix = pkg.relativePath + '/';
1034
+ passes = (f) => f === pkg.relativePath || f.startsWith(prefix);
1035
+ }
1036
+ }
764
1037
  if (spinner)
765
1038
  spinner.text = 'Searching...';
766
1039
  let results;
@@ -768,6 +1041,8 @@ program
768
1041
  const q = query.toLowerCase();
769
1042
  const matches = [];
770
1043
  for (const [file, entry] of graph.files) {
1044
+ if (passes && !passes(file))
1045
+ continue;
771
1046
  for (const exp of entry.exports) {
772
1047
  if (exp.name.toLowerCase().includes(q)) {
773
1048
  matches.push({ symbol: exp.name, kind: exp.kind, file, line: exp.line });
@@ -785,6 +1060,7 @@ program
785
1060
  const q = query.toLowerCase();
786
1061
  const matches = scan.files
787
1062
  .filter((f) => f.relativePath.toLowerCase().includes(q))
1063
+ .filter((f) => !passes || passes(f.relativePath))
788
1064
  .slice(0, limit)
789
1065
  .map((f) => ({ file: f.relativePath, sizeBytes: f.sizeBytes }));
790
1066
  results = { scope, query, matches, total: matches.length };
@@ -792,7 +1068,8 @@ program
792
1068
  else {
793
1069
  const mode = cmdOpts.semantic ? 'semantic' : String(cmdOpts.mode ?? 'lexical');
794
1070
  const index = await buildSearchIndex(rootPath, scan.files, graph);
795
- const lexicalHits = searchIndex(index, query, { limit });
1071
+ const lexicalHitsAll = searchIndex(index, query, { limit });
1072
+ const lexicalHits = passes ? lexicalHitsAll.filter((h) => passes(h.file)) : lexicalHitsAll;
796
1073
  const tokens = expandQuery(query);
797
1074
  if (mode === 'lexical') {
798
1075
  const withExcerpts = await attachExcerpts(rootPath, lexicalHits, tokens);
@@ -829,7 +1106,8 @@ program
829
1106
  }
830
1107
  if (spinner)
831
1108
  spinner.text = 'Searching...';
832
- const semHits = await semanticSearch(semIndex, query, { limit });
1109
+ const semHitsAll = await semanticSearch(semIndex, query, { limit });
1110
+ const semHits = passes ? semHitsAll.filter((h) => passes(h.file)) : semHitsAll;
833
1111
  if (mode === 'semantic') {
834
1112
  const enriched = await attachExcerpts(rootPath, semHits.map((h) => ({
835
1113
  file: h.file,
@@ -934,6 +1212,7 @@ program
934
1212
  .command('coverage')
935
1213
  .description('Join test coverage with hotspots - surface the scariest untested files')
936
1214
  .option('--limit <n>', 'limit number of entries shown', '30')
1215
+ .option('--package <name>', 'monorepo: scope to a single workspace package')
937
1216
  .action(async (cmdOpts) => {
938
1217
  setupLogLevel();
939
1218
  maybeCompactBanner();
@@ -952,6 +1231,11 @@ program
952
1231
  coverage: coverage.available ? coverageMap(coverage) : undefined,
953
1232
  });
954
1233
  const joined = joinCoverageWithHotspots(hotspots, coverage);
1234
+ if (cmdOpts.package && joined.available) {
1235
+ const ws = await detectWorkspaces(rootPath);
1236
+ const allowed = new Set(filterFilesByPackage(ws, cmdOpts.package, joined.entries.map((e) => e.relativePath)));
1237
+ joined.entries = joined.entries.filter((e) => allowed.has(e.relativePath));
1238
+ }
955
1239
  if (spinner)
956
1240
  spinner.stop();
957
1241
  switch (format) {