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.
- package/README.md +25 -7
- package/dist/cli/index.js +294 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/core/ast.d.ts +2 -0
- package/dist/core/ast.js +35 -2
- package/dist/core/ast.js.map +1 -1
- package/dist/core/codeGraph.d.ts +2 -0
- package/dist/core/codeGraph.js +2 -0
- package/dist/core/codeGraph.js.map +1 -1
- package/dist/core/couplingAnalyzer.d.ts +18 -0
- package/dist/core/couplingAnalyzer.js +174 -0
- package/dist/core/couplingAnalyzer.js.map +1 -0
- package/dist/core/fileInspector.d.ts +1 -1
- package/dist/core/fileInspector.js +31 -1
- package/dist/core/fileInspector.js.map +1 -1
- package/dist/core/hotspotAnalyzer.d.ts +13 -0
- package/dist/core/hotspotAnalyzer.js +29 -6
- package/dist/core/hotspotAnalyzer.js.map +1 -1
- package/dist/core/indexCache.js +6 -3
- package/dist/core/indexCache.js.map +1 -1
- package/dist/core/languages/LanguageAdapter.d.ts +1 -1
- package/dist/core/languages/goAdapter.d.ts +2 -0
- package/dist/core/languages/goAdapter.js +136 -0
- package/dist/core/languages/goAdapter.js.map +1 -0
- package/dist/core/languages/goCyclomatic.d.ts +21 -0
- package/dist/core/languages/goCyclomatic.js +55 -0
- package/dist/core/languages/goCyclomatic.js.map +1 -0
- package/dist/core/languages/goExports.d.ts +26 -0
- package/dist/core/languages/goExports.js +89 -0
- package/dist/core/languages/goExports.js.map +1 -0
- package/dist/core/languages/goImports.d.ts +26 -0
- package/dist/core/languages/goImports.js +64 -0
- package/dist/core/languages/goImports.js.map +1 -0
- package/dist/core/languages/goManifests.d.ts +19 -0
- package/dist/core/languages/goManifests.js +56 -0
- package/dist/core/languages/goManifests.js.map +1 -0
- package/dist/core/languages/pythonAdapter.js +5 -0
- package/dist/core/languages/pythonAdapter.js.map +1 -1
- package/dist/core/languages/pythonCyclomatic.d.ts +18 -0
- package/dist/core/languages/pythonCyclomatic.js +45 -0
- package/dist/core/languages/pythonCyclomatic.js.map +1 -0
- package/dist/core/languages/registry.js +2 -1
- package/dist/core/languages/registry.js.map +1 -1
- package/dist/core/languages/treeSitterLoader.js +2 -1
- package/dist/core/languages/treeSitterLoader.js.map +1 -1
- package/dist/core/monorepo.d.ts +20 -0
- package/dist/core/monorepo.js +270 -0
- package/dist/core/monorepo.js.map +1 -0
- package/dist/core/prDiff.d.ts +43 -0
- package/dist/core/prDiff.js +298 -0
- package/dist/core/prDiff.js.map +1 -0
- package/dist/core/telemetry.d.ts +90 -0
- package/dist/core/telemetry.js +199 -0
- package/dist/core/telemetry.js.map +1 -0
- package/dist/grammars/tree-sitter-go.wasm +0 -0
- package/dist/mcp/server.js +22 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.js +269 -20
- package/dist/mcp/tools.js.map +1 -1
- package/dist/reporters/consoleReporter.d.ts +4 -1
- package/dist/reporters/consoleReporter.js +113 -0
- package/dist/reporters/consoleReporter.js.map +1 -1
- package/dist/reporters/jsonReporter.d.ts +4 -1
- package/dist/reporters/jsonReporter.js +9 -0
- package/dist/reporters/jsonReporter.js.map +1 -1
- package/dist/reporters/markdownReporter.d.ts +4 -1
- package/dist/reporters/markdownReporter.js +103 -3
- package/dist/reporters/markdownReporter.js.map +1 -1
- package/dist/types.d.ts +113 -0
- package/dist/utils/cache.d.ts +3 -0
- package/dist/utils/cache.js +51 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/config.js +10 -0
- package/dist/utils/config.js.map +1 -1
- 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
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
444
|
+
### The 17 MCP tools
|
|
433
445
|
|
|
434
|
-
**Structural (0.6.0
|
|
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
|
-
.
|
|
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(
|
|
573
|
+
reportStructureJson(tree);
|
|
533
574
|
break;
|
|
534
575
|
case 'markdown':
|
|
535
|
-
reportStructureMarkdown(
|
|
576
|
+
reportStructureMarkdown(tree);
|
|
536
577
|
break;
|
|
537
578
|
default:
|
|
538
|
-
reportStructure(
|
|
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
|
|
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
|
|
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) {
|