projscan 1.4.0 → 1.5.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 (64) hide show
  1. package/README.md +13 -6
  2. package/dist/cli/commands/doctor.js +18 -2
  3. package/dist/cli/commands/doctor.js.map +1 -1
  4. package/dist/cli/commands/explain.js +1 -0
  5. package/dist/cli/commands/explain.js.map +1 -1
  6. package/dist/cli/commands/memory.d.ts +11 -0
  7. package/dist/cli/commands/memory.js +175 -0
  8. package/dist/cli/commands/memory.js.map +1 -0
  9. package/dist/cli/index.js +2 -0
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/core/codeGraph.js +190 -241
  12. package/dist/core/codeGraph.js.map +1 -1
  13. package/dist/core/fileInspector.js +40 -44
  14. package/dist/core/fileInspector.js.map +1 -1
  15. package/dist/core/hotspotAnalyzer.js +65 -19
  16. package/dist/core/hotspotAnalyzer.js.map +1 -1
  17. package/dist/core/issueEngine.js +24 -0
  18. package/dist/core/issueEngine.js.map +1 -1
  19. package/dist/core/languages/csharpImports.js +6 -4
  20. package/dist/core/languages/csharpImports.js.map +1 -1
  21. package/dist/core/memory.d.ts +154 -0
  22. package/dist/core/memory.js +277 -0
  23. package/dist/core/memory.js.map +1 -0
  24. package/dist/core/review.d.ts +25 -1
  25. package/dist/core/review.js +84 -0
  26. package/dist/core/review.js.map +1 -1
  27. package/dist/mcp/prompts.js +272 -0
  28. package/dist/mcp/prompts.js.map +1 -1
  29. package/dist/mcp/server.js +162 -146
  30. package/dist/mcp/server.js.map +1 -1
  31. package/dist/mcp/tokenBudget.d.ts +22 -0
  32. package/dist/mcp/tokenBudget.js +26 -0
  33. package/dist/mcp/tokenBudget.js.map +1 -1
  34. package/dist/mcp/tools/doctor.js +65 -2
  35. package/dist/mcp/tools/doctor.js.map +1 -1
  36. package/dist/mcp/tools/explain.js +4 -3
  37. package/dist/mcp/tools/explain.js.map +1 -1
  38. package/dist/mcp/tools/explainIssue.js +3 -2
  39. package/dist/mcp/tools/explainIssue.js.map +1 -1
  40. package/dist/mcp/tools/file.js +3 -2
  41. package/dist/mcp/tools/file.js.map +1 -1
  42. package/dist/mcp/tools/graph.js +16 -11
  43. package/dist/mcp/tools/graph.js.map +1 -1
  44. package/dist/mcp/tools/impact.js +2 -2
  45. package/dist/mcp/tools/impact.js.map +1 -1
  46. package/dist/mcp/tools/memory.d.ts +19 -0
  47. package/dist/mcp/tools/memory.js +134 -0
  48. package/dist/mcp/tools/memory.js.map +1 -0
  49. package/dist/mcp/tools/review.js +25 -4
  50. package/dist/mcp/tools/review.js.map +1 -1
  51. package/dist/mcp/tools/upgrade.js +3 -2
  52. package/dist/mcp/tools/upgrade.js.map +1 -1
  53. package/dist/mcp/tools.js +2 -0
  54. package/dist/mcp/tools.js.map +1 -1
  55. package/dist/reporters/consoleReporter.d.ts +12 -1
  56. package/dist/reporters/consoleReporter.js +289 -179
  57. package/dist/reporters/consoleReporter.js.map +1 -1
  58. package/dist/reporters/markdownReporter.js +185 -128
  59. package/dist/reporters/markdownReporter.js.map +1 -1
  60. package/dist/tool-manifest.json +43 -6
  61. package/dist/types.d.ts +21 -0
  62. package/dist/utils/config.js +76 -51
  63. package/dist/utils/config.js.map +1 -1
  64. package/package.json +8 -1
@@ -79,11 +79,21 @@ export function reportAnalysis(report) {
79
79
  }
80
80
  console.log('');
81
81
  }
82
- // ── Report: doctor ────────────────────────────────────────
83
- export function reportHealth(issues, scanTimeMs) {
82
+ export function reportHealth(issues, scanTimeMsOrOptions) {
83
+ const opts = typeof scanTimeMsOrOptions === 'number'
84
+ ? { scanTimeMs: scanTimeMsOrOptions }
85
+ : (scanTimeMsOrOptions ?? {});
84
86
  console.log(header('Project Health Report'));
85
87
  const { score, grade } = calculateScore(issues);
86
- const gradeColor = grade === 'A' ? chalk.green : grade === 'B' ? chalk.green : grade === 'C' ? chalk.yellow : grade === 'D' ? chalk.yellow : chalk.red;
88
+ const gradeColor = grade === 'A'
89
+ ? chalk.green
90
+ : grade === 'B'
91
+ ? chalk.green
92
+ : grade === 'C'
93
+ ? chalk.yellow
94
+ : grade === 'D'
95
+ ? chalk.yellow
96
+ : chalk.red;
87
97
  console.log(`\n Health Score: ${gradeColor(chalk.bold(`${grade} (${score}/100)`))}`);
88
98
  if (issues.length === 0) {
89
99
  console.log(` ${chalk.green('✓')} ${chalk.bold('No issues detected!')} Your project looks healthy.\n`);
@@ -101,8 +111,8 @@ export function reportHealth(issues, scanTimeMs) {
101
111
  if (infos.length > 0)
102
112
  parts.push(chalk.blue(`${infos.length} info`));
103
113
  console.log(` Found ${parts.join(', ')}`);
104
- if (scanTimeMs !== undefined) {
105
- console.log(` Scanned in ${chalk.dim(scanTimeMs.toFixed(0) + 'ms')}`);
114
+ if (opts.scanTimeMs !== undefined) {
115
+ console.log(` Scanned in ${chalk.dim(opts.scanTimeMs.toFixed(0) + 'ms')}`);
106
116
  }
107
117
  // Issues
108
118
  console.log(header('Issues Detected'));
@@ -122,6 +132,11 @@ export function reportHealth(issues, scanTimeMs) {
122
132
  }
123
133
  console.log(`\n Run ${chalk.bold.cyan('projscan fix')} to auto-fix ${fixable.length} issue${fixable.length > 1 ? 's' : ''}.\n`);
124
134
  }
135
+ // 1.5+ — Project Memory tip. Only fires when the user has stable
136
+ // rules accumulating; quiet otherwise.
137
+ if (opts.stableRuleCount && opts.stableRuleCount > 0) {
138
+ console.log(` ${chalk.cyan('▲')} ${chalk.dim(`${opts.stableRuleCount} rule${opts.stableRuleCount === 1 ? ' has' : 's have'} been open across enough runs to count as accepted. Run`)} ${chalk.bold.cyan('projscan memory stable')} ${chalk.dim('to review and silence them in .projscanrc.')}\n`);
139
+ }
125
140
  console.log('');
126
141
  }
127
142
  // ── Report: ci ────────────────────────────────────────────
@@ -140,58 +155,77 @@ export function reportCi(issues, threshold) {
140
155
  // ── Report: diff ──────────────────────────────────────────
141
156
  export function reportDiff(diff) {
142
157
  console.log(header('Health Diff'));
158
+ printDiffScoreLine(diff);
159
+ printDiffIssueLists(diff);
160
+ printHotspotDiff(diff);
161
+ console.log(`\n Baseline: ${chalk.dim(diff.before.timestamp)}`);
162
+ console.log('');
163
+ }
164
+ function printDiffScoreLine(diff) {
143
165
  const arrow = diff.scoreDelta > 0 ? chalk.green('↑') : diff.scoreDelta < 0 ? chalk.red('↓') : chalk.dim('-');
144
166
  const delta = diff.scoreDelta > 0 ? `+${diff.scoreDelta}` : String(diff.scoreDelta);
145
167
  console.log(`\n Score: ${diff.before.score} → ${diff.after.score} (${delta}) ${arrow}`);
146
168
  console.log(` Grade: ${diff.before.grade} → ${diff.after.grade}`);
169
+ }
170
+ function printDiffIssueLists(diff) {
147
171
  if (diff.resolvedIssues.length > 0) {
148
172
  console.log(`\n ${chalk.green('✓')} Resolved (${diff.resolvedIssues.length}):`);
149
- for (const title of diff.resolvedIssues) {
173
+ for (const title of diff.resolvedIssues)
150
174
  console.log(` ${chalk.green('-')} ${title}`);
151
- }
152
175
  }
153
176
  if (diff.newIssues.length > 0) {
154
177
  console.log(`\n ${chalk.red('✗')} New (${diff.newIssues.length}):`);
155
- for (const title of diff.newIssues) {
178
+ for (const title of diff.newIssues)
156
179
  console.log(` ${chalk.red('-')} ${title}`);
157
- }
158
180
  }
159
181
  if (diff.resolvedIssues.length === 0 && diff.newIssues.length === 0) {
160
182
  console.log(`\n ${chalk.dim('No change in issues.')}`);
161
183
  }
162
- if (diff.hotspotDiff) {
163
- const hd = diff.hotspotDiff;
164
- const total = hd.rose.length + hd.fell.length + hd.appeared.length + hd.resolved.length;
165
- if (total > 0) {
166
- console.log(header('Hotspot Changes'));
167
- if (hd.rose.length > 0) {
168
- console.log(`\n ${chalk.red('▲')} Worsening (${hd.rose.length}):`);
169
- for (const delta of hd.rose.slice(0, 10)) {
170
- console.log(` ${chalk.red('+' + delta.scoreDelta.toFixed(1))} ${delta.relativePath} ${chalk.dim(`${delta.beforeScore?.toFixed(1)} → ${delta.afterScore?.toFixed(1)}`)}`);
171
- }
172
- }
173
- if (hd.appeared.length > 0) {
174
- console.log(`\n ${chalk.yellow('●')} Newly risky (${hd.appeared.length}):`);
175
- for (const delta of hd.appeared.slice(0, 10)) {
176
- console.log(` ${chalk.yellow(delta.afterScore?.toFixed(1) ?? '?')} ${delta.relativePath}`);
177
- }
178
- }
179
- if (hd.fell.length > 0) {
180
- console.log(`\n ${chalk.green('▼')} Improving (${hd.fell.length}):`);
181
- for (const delta of hd.fell.slice(0, 10)) {
182
- console.log(` ${chalk.green(delta.scoreDelta.toFixed(1))} ${delta.relativePath} ${chalk.dim(`${delta.beforeScore?.toFixed(1)} → ${delta.afterScore?.toFixed(1)}`)}`);
183
- }
184
- }
185
- if (hd.resolved.length > 0) {
186
- console.log(`\n ${chalk.green('✓')} No longer tracked (${hd.resolved.length}):`);
187
- for (const delta of hd.resolved.slice(0, 5)) {
188
- console.log(` ${chalk.green('-')} ${delta.relativePath}`);
189
- }
190
- }
191
- }
184
+ }
185
+ function printHotspotDiff(diff) {
186
+ if (!diff.hotspotDiff)
187
+ return;
188
+ const hd = diff.hotspotDiff;
189
+ const total = hd.rose.length + hd.fell.length + hd.appeared.length + hd.resolved.length;
190
+ if (total === 0)
191
+ return;
192
+ console.log(header('Hotspot Changes'));
193
+ printHotspotRose(hd.rose);
194
+ printHotspotAppeared(hd.appeared);
195
+ printHotspotFell(hd.fell);
196
+ printHotspotResolved(hd.resolved);
197
+ }
198
+ function printHotspotRose(rose) {
199
+ if (rose.length === 0)
200
+ return;
201
+ console.log(`\n ${chalk.red('▲')} Worsening (${rose.length}):`);
202
+ for (const delta of rose.slice(0, 10)) {
203
+ console.log(` ${chalk.red('+' + delta.scoreDelta.toFixed(1))} ${delta.relativePath} ${chalk.dim(`${delta.beforeScore?.toFixed(1)} → ${delta.afterScore?.toFixed(1)}`)}`);
204
+ }
205
+ }
206
+ function printHotspotAppeared(appeared) {
207
+ if (appeared.length === 0)
208
+ return;
209
+ console.log(`\n ${chalk.yellow('●')} Newly risky (${appeared.length}):`);
210
+ for (const delta of appeared.slice(0, 10)) {
211
+ console.log(` ${chalk.yellow(delta.afterScore?.toFixed(1) ?? '?')} ${delta.relativePath}`);
212
+ }
213
+ }
214
+ function printHotspotFell(fell) {
215
+ if (fell.length === 0)
216
+ return;
217
+ console.log(`\n ${chalk.green('▼')} Improving (${fell.length}):`);
218
+ for (const delta of fell.slice(0, 10)) {
219
+ console.log(` ${chalk.green(delta.scoreDelta.toFixed(1))} ${delta.relativePath} ${chalk.dim(`${delta.beforeScore?.toFixed(1)} → ${delta.afterScore?.toFixed(1)}`)}`);
220
+ }
221
+ }
222
+ function printHotspotResolved(resolved) {
223
+ if (resolved.length === 0)
224
+ return;
225
+ console.log(`\n ${chalk.green('✓')} No longer tracked (${resolved.length}):`);
226
+ for (const delta of resolved.slice(0, 5)) {
227
+ console.log(` ${chalk.green('-')} ${delta.relativePath}`);
192
228
  }
193
- console.log(`\n Baseline: ${chalk.dim(diff.before.timestamp)}`);
194
- console.log('');
195
229
  }
196
230
  // ── Report: fix ───────────────────────────────────────────
197
231
  export function reportDetectedIssues(issues, fixes) {
@@ -327,15 +361,25 @@ export function reportHotspots(report) {
327
361
  }
328
362
  console.log(chalk.dim(`\n ${report.window.commitsScanned} commit${report.window.commitsScanned === 1 ? '' : 's'} since ${report.window.since} · ${report.totalFilesRanked} file${report.totalFilesRanked === 1 ? '' : 's'} ranked\n`));
329
363
  const maxScore = report.hotspots[0]?.riskScore ?? 1;
364
+ let hasAccepted = false;
330
365
  for (let i = 0; i < report.hotspots.length; i++) {
331
366
  const h = report.hotspots[i];
332
367
  const rank = chalk.bold(String(i + 1).padStart(2, ' ') + '.');
333
368
  const scoreLabel = chalk.bold(h.riskScore.toFixed(1).padStart(5, ' '));
334
369
  const barPct = Math.min(100, Math.round((h.riskScore / maxScore) * 100));
335
- console.log(` ${rank} ${scoreLabel} ${bar(barPct, 14)} ${chalk.cyan(h.relativePath)}`);
370
+ // 1.5+ — accepted hotspots (Project Memory marked them as
371
+ // implicitly-accepted load-bearing debt) get a dim [accepted] tag
372
+ // so users aren't repeatedly pestered about the same files.
373
+ const acceptedTag = h.accepted ? ` ${chalk.dim('[accepted]')}` : '';
374
+ if (h.accepted)
375
+ hasAccepted = true;
376
+ console.log(` ${rank} ${scoreLabel} ${bar(barPct, 14)} ${chalk.cyan(h.relativePath)}${acceptedTag}`);
336
377
  const reasonStr = h.reasons.length > 0 ? h.reasons.join(', ') : 'ranked by risk';
337
378
  console.log(` ${chalk.dim(reasonStr)}`);
338
379
  }
380
+ if (hasAccepted) {
381
+ console.log(chalk.dim(`\n ${chalk.cyan('▲')} [accepted] = top-5 for ≥ 5 runs over ≥ 7 days without improving (Project Memory).`));
382
+ }
339
383
  console.log(chalk.dim(`\n Tip: run ${chalk.bold.cyan('projscan file <file>')} to drill into a hotspot.\n`));
340
384
  }
341
385
  // ── Report: file (drill-down) ─────────────────────────────
@@ -345,6 +389,16 @@ export function reportFileInspection(insp) {
345
389
  console.log(`\n ${chalk.red('✗')} ${insp.reason ?? 'File unavailable.'}\n`);
346
390
  return;
347
391
  }
392
+ printFileSummary(insp);
393
+ printFileHotspot(insp);
394
+ printFileIssues(insp);
395
+ printFilePotentialIssues(insp);
396
+ printFileImports(insp);
397
+ printFileExports(insp);
398
+ printFileFunctions(insp);
399
+ console.log('');
400
+ }
401
+ function printFileSummary(insp) {
348
402
  console.log(`\n ${chalk.bold('File:')} ${insp.relativePath}`);
349
403
  console.log(` ${chalk.bold('Purpose:')} ${insp.purpose}`);
350
404
  console.log(` ${chalk.bold('Lines:')} ${insp.lineCount}`);
@@ -355,66 +409,83 @@ export function reportFileInspection(insp) {
355
409
  if (typeof insp.fanIn === 'number' || typeof insp.fanOut === 'number') {
356
410
  console.log(` ${chalk.bold('Coupling:')} fan-in ${insp.fanIn ?? '-'}, fan-out ${insp.fanOut ?? '-'}`);
357
411
  }
358
- if (insp.hotspot) {
359
- const h = insp.hotspot;
360
- console.log(header('Risk'));
361
- console.log(` ${chalk.bold('Risk Score:')} ${chalk.bold(h.riskScore.toFixed(1))}`);
362
- console.log(` ${chalk.bold('Commits:')} ${h.churn}`);
363
- console.log(` ${chalk.bold('Authors:')} ${h.distinctAuthors}${h.primaryAuthor ? ` (primary: ${formatAuthorEmail(h.primaryAuthor)}, ${Math.round(h.primaryAuthorShare * 100)}%)` : ''}`);
364
- if (h.daysSinceLastChange !== null) {
365
- console.log(` ${chalk.bold('Last change:')} ${h.daysSinceLastChange} days ago`);
366
- }
367
- if (h.busFactorOne) {
368
- console.log(` ${chalk.red('⚠')} Bus factor 1 - only one author has touched this.`);
369
- }
370
- if (h.reasons.length > 0) {
371
- console.log(` ${chalk.dim(h.reasons.join(', '))}`);
372
- }
373
- }
374
- else {
412
+ }
413
+ function printFileHotspot(insp) {
414
+ if (!insp.hotspot) {
375
415
  console.log(chalk.dim('\n No hotspot data (file is untouched in git window or outside analysis scope).'));
416
+ return;
376
417
  }
377
- if (insp.issues.length > 0) {
378
- console.log(header('Related Issues'));
379
- for (const issue of insp.issues) {
380
- console.log(` ${severityIcon(issue.severity)} ${issue.title}`);
381
- }
418
+ const h = insp.hotspot;
419
+ console.log(header('Risk'));
420
+ console.log(` ${chalk.bold('Risk Score:')} ${chalk.bold(h.riskScore.toFixed(1))}`);
421
+ console.log(` ${chalk.bold('Commits:')} ${h.churn}`);
422
+ const primary = h.primaryAuthor
423
+ ? ` (primary: ${formatAuthorEmail(h.primaryAuthor)}, ${Math.round(h.primaryAuthorShare * 100)}%)`
424
+ : '';
425
+ console.log(` ${chalk.bold('Authors:')} ${h.distinctAuthors}${primary}`);
426
+ if (h.daysSinceLastChange !== null) {
427
+ console.log(` ${chalk.bold('Last change:')} ${h.daysSinceLastChange} days ago`);
382
428
  }
383
- if (insp.potentialIssues.length > 0) {
384
- console.log(header('Potential Issues'));
385
- for (const issue of insp.potentialIssues) {
386
- console.log(` ${chalk.yellow('⚠')} ${issue}`);
387
- }
429
+ if (h.busFactorOne) {
430
+ console.log(` ${chalk.red('')} Bus factor 1 - only one author has touched this.`);
388
431
  }
389
- if (insp.imports.length > 0) {
390
- console.log(header('Dependencies'));
391
- for (const imp of insp.imports.slice(0, 20)) {
392
- const prefix = imp.isRelative ? chalk.dim('(local)') : chalk.cyan('(package)');
393
- console.log(` ${prefix} ${imp.source}`);
394
- }
395
- if (insp.imports.length > 20) {
396
- console.log(chalk.dim(` ... and ${insp.imports.length - 20} more`));
397
- }
432
+ if (h.reasons.length > 0) {
433
+ console.log(` ${chalk.dim(h.reasons.join(', '))}`);
398
434
  }
399
- if (insp.exports.length > 0) {
400
- console.log(header('Exports'));
401
- for (const exp of insp.exports) {
402
- console.log(` ${chalk.dim('•')} ${chalk.bold(exp.name)} ${chalk.dim(`(${exp.type})`)}`);
403
- }
435
+ }
436
+ function printFileIssues(insp) {
437
+ if (insp.issues.length === 0)
438
+ return;
439
+ console.log(header('Related Issues'));
440
+ for (const issue of insp.issues) {
441
+ console.log(` ${severityIcon(issue.severity)} ${issue.title}`);
404
442
  }
405
- if (insp.functions && insp.functions.length > 0) {
406
- console.log(header('Functions (top by CC)'));
407
- const top = insp.functions.slice(0, 10);
408
- for (const fn of top) {
409
- const ccColor = fn.cyclomaticComplexity >= 10 ? chalk.red : fn.cyclomaticComplexity >= 5 ? chalk.yellow : chalk.dim;
410
- const fiStr = typeof fn.fanIn === 'number' ? `fan-in ${String(fn.fanIn).padStart(2)}` : ' ';
411
- console.log(` ${ccColor(`CC ${String(fn.cyclomaticComplexity).padStart(3)}`)} ${chalk.dim(fiStr)} ${chalk.bold(fn.name)} ${chalk.dim(`L${fn.line}-${fn.endLine}`)}`);
412
- }
413
- if (insp.functions.length > 10) {
414
- console.log(chalk.dim(` ... and ${insp.functions.length - 10} more`));
415
- }
443
+ }
444
+ function printFilePotentialIssues(insp) {
445
+ if (insp.potentialIssues.length === 0)
446
+ return;
447
+ console.log(header('Potential Issues'));
448
+ for (const issue of insp.potentialIssues) {
449
+ console.log(` ${chalk.yellow('⚠')} ${issue}`);
450
+ }
451
+ }
452
+ function printFileImports(insp) {
453
+ if (insp.imports.length === 0)
454
+ return;
455
+ console.log(header('Dependencies'));
456
+ for (const imp of insp.imports.slice(0, 20)) {
457
+ const prefix = imp.isRelative ? chalk.dim('(local)') : chalk.cyan('(package)');
458
+ console.log(` ${prefix} ${imp.source}`);
459
+ }
460
+ if (insp.imports.length > 20) {
461
+ console.log(chalk.dim(` ... and ${insp.imports.length - 20} more`));
462
+ }
463
+ }
464
+ function printFileExports(insp) {
465
+ if (insp.exports.length === 0)
466
+ return;
467
+ console.log(header('Exports'));
468
+ for (const exp of insp.exports) {
469
+ console.log(` ${chalk.dim('•')} ${chalk.bold(exp.name)} ${chalk.dim(`(${exp.type})`)}`);
470
+ }
471
+ }
472
+ function printFileFunctions(insp) {
473
+ if (!insp.functions || insp.functions.length === 0)
474
+ return;
475
+ console.log(header('Functions (top by CC)'));
476
+ const top = insp.functions.slice(0, 10);
477
+ for (const fn of top) {
478
+ const ccColor = fn.cyclomaticComplexity >= 10
479
+ ? chalk.red
480
+ : fn.cyclomaticComplexity >= 5
481
+ ? chalk.yellow
482
+ : chalk.dim;
483
+ const fiStr = typeof fn.fanIn === 'number' ? `fan-in ${String(fn.fanIn).padStart(2)}` : ' ';
484
+ console.log(` ${ccColor(`CC ${String(fn.cyclomaticComplexity).padStart(3)}`)} ${chalk.dim(fiStr)} ${chalk.bold(fn.name)} ${chalk.dim(`L${fn.line}-${fn.endLine}`)}`);
485
+ }
486
+ if (insp.functions.length > 10) {
487
+ console.log(chalk.dim(` ... and ${insp.functions.length - 10} more`));
416
488
  }
417
- console.log('');
418
489
  }
419
490
  function formatSize(bytes) {
420
491
  if (bytes < 1024)
@@ -640,46 +711,64 @@ export function reportPrDiff(report) {
640
711
  console.log(`\n ${chalk.yellow('⚠')} ${report.reason ?? 'PR diff unavailable.'}\n`);
641
712
  return;
642
713
  }
714
+ printPrDiffRefs(report);
715
+ printPrDiffFileTotals(report);
716
+ printPrDiffAdded(report);
717
+ printPrDiffRemoved(report);
718
+ printPrDiffModified(report);
719
+ }
720
+ function printPrDiffRefs(report) {
643
721
  console.log(chalk.dim(`\n base ${report.base.ref} (${report.base.resolvedSha?.slice(0, 7) ?? '?'}) → head ${report.head.ref} (${report.head.resolvedSha?.slice(0, 7) ?? '?'})\n`));
644
- console.log(` ${chalk.bold(report.totalFilesChanged.toString())} file${report.totalFilesChanged === 1 ? '' : 's'} changed: ${chalk.green(`+${report.filesAdded.length}`)} ${chalk.red(`-${report.filesRemoved.length}`)} ${chalk.yellow(`~${report.filesModified.length}`)}\n`);
645
- if (report.filesAdded.length > 0) {
646
- console.log(chalk.bold(' Added:'));
647
- for (const f of report.filesAdded)
648
- console.log(` ${chalk.green('+')} ${f}`);
649
- console.log('');
722
+ }
723
+ function printPrDiffFileTotals(report) {
724
+ const fileLabel = report.totalFilesChanged === 1 ? '' : 's';
725
+ console.log(` ${chalk.bold(report.totalFilesChanged.toString())} file${fileLabel} changed: ${chalk.green(`+${report.filesAdded.length}`)} ${chalk.red(`-${report.filesRemoved.length}`)} ${chalk.yellow(`~${report.filesModified.length}`)}\n`);
726
+ }
727
+ function printPrDiffAdded(report) {
728
+ if (report.filesAdded.length === 0)
729
+ return;
730
+ console.log(chalk.bold(' Added:'));
731
+ for (const f of report.filesAdded)
732
+ console.log(` ${chalk.green('+')} ${f}`);
733
+ console.log('');
734
+ }
735
+ function printPrDiffRemoved(report) {
736
+ if (report.filesRemoved.length === 0)
737
+ return;
738
+ console.log(chalk.bold(' Removed:'));
739
+ for (const f of report.filesRemoved)
740
+ console.log(` ${chalk.red('-')} ${f}`);
741
+ console.log('');
742
+ }
743
+ function printPrDiffModified(report) {
744
+ if (report.filesModified.length === 0)
745
+ return;
746
+ console.log(chalk.bold(' Modified:'));
747
+ for (const m of report.filesModified)
748
+ printPrDiffModifiedFile(m);
749
+ console.log('');
750
+ }
751
+ function printPrDiffModifiedFile(m) {
752
+ const ccDelta = m.cyclomaticDelta;
753
+ const fiDelta = m.fanInDelta;
754
+ const ccStr = ccDelta === null ? '' : `, ΔCC ${signed(ccDelta)}`;
755
+ const finStr = fiDelta === null || fiDelta === 0 ? '' : `, Δfan-in ${signed(fiDelta)}`;
756
+ console.log(` ${chalk.yellow('~')} ${chalk.cyan(m.relativePath)}${chalk.dim(ccStr + finStr)}`);
757
+ if (m.exportsAdded.length > 0) {
758
+ console.log(` ${chalk.green('+exports:')} ${m.exportsAdded.join(', ')}`);
650
759
  }
651
- if (report.filesRemoved.length > 0) {
652
- console.log(chalk.bold(' Removed:'));
653
- for (const f of report.filesRemoved)
654
- console.log(` ${chalk.red('-')} ${f}`);
655
- console.log('');
760
+ if (m.exportsRemoved.length > 0) {
761
+ console.log(` ${chalk.red('-exports:')} ${m.exportsRemoved.join(', ')}`);
656
762
  }
657
- if (report.filesModified.length > 0) {
658
- console.log(chalk.bold(' Modified:'));
659
- for (const m of report.filesModified) {
660
- const ccDelta = m.cyclomaticDelta;
661
- const fiDelta = m.fanInDelta;
662
- const ccStr = ccDelta === null ? '' : `, ΔCC ${signed(ccDelta)}`;
663
- const finStr = fiDelta === null || fiDelta === 0 ? '' : `, Δfan-in ${signed(fiDelta)}`;
664
- console.log(` ${chalk.yellow('~')} ${chalk.cyan(m.relativePath)}${chalk.dim(ccStr + finStr)}`);
665
- if (m.exportsAdded.length > 0) {
666
- console.log(` ${chalk.green('+exports:')} ${m.exportsAdded.join(', ')}`);
667
- }
668
- if (m.exportsRemoved.length > 0) {
669
- console.log(` ${chalk.red('-exports:')} ${m.exportsRemoved.join(', ')}`);
670
- }
671
- if (m.exportsRenamed.length > 0) {
672
- const pairs = m.exportsRenamed.map((r) => `${r.from} → ${r.to}`).join(', ');
673
- console.log(` ${chalk.yellow('~exports:')} ${pairs}`);
674
- }
675
- if (m.importsAdded.length > 0) {
676
- console.log(` ${chalk.green('+imports:')} ${m.importsAdded.join(', ')}`);
677
- }
678
- if (m.importsRemoved.length > 0) {
679
- console.log(` ${chalk.red('-imports:')} ${m.importsRemoved.join(', ')}`);
680
- }
681
- }
682
- console.log('');
763
+ if (m.exportsRenamed.length > 0) {
764
+ const pairs = m.exportsRenamed.map((r) => `${r.from} → ${r.to}`).join(', ');
765
+ console.log(` ${chalk.yellow('~exports:')} ${pairs}`);
766
+ }
767
+ if (m.importsAdded.length > 0) {
768
+ console.log(` ${chalk.green('+imports:')} ${m.importsAdded.join(', ')}`);
769
+ }
770
+ if (m.importsRemoved.length > 0) {
771
+ console.log(` ${chalk.red('-imports:')} ${m.importsRemoved.join(', ')}`);
683
772
  }
684
773
  }
685
774
  function signed(n) {
@@ -827,66 +916,87 @@ export function reportReview(report) {
827
916
  console.log(`\n ${chalk.yellow('⚠')} ${report.reason ?? 'Review unavailable.'}\n`);
828
917
  return;
829
918
  }
919
+ printReviewRefs(report);
920
+ printReviewVerdict(report);
921
+ printReviewSummary(report);
922
+ printReviewChangedFiles(report);
923
+ printReviewCycles(report);
924
+ printReviewRiskyFunctions(report);
925
+ printReviewDependencyChanges(report);
926
+ }
927
+ function printReviewRefs(report) {
830
928
  console.log(chalk.dim(`\n base ${report.base.ref} (${report.base.resolvedSha?.slice(0, 7) ?? '?'}) → head ${report.head.ref} (${report.head.resolvedSha?.slice(0, 7) ?? '?'})\n`));
929
+ }
930
+ function printReviewVerdict(report) {
831
931
  const verdictColor = report.verdict === 'block' ? chalk.red : report.verdict === 'review' ? chalk.yellow : chalk.green;
832
932
  const verdictLabel = report.verdict === 'block' ? '🚫 BLOCK' : report.verdict === 'review' ? '👀 REVIEW' : '✅ OK';
833
933
  console.log(` ${chalk.bold('Verdict:')} ${verdictColor(verdictLabel)}\n`);
934
+ }
935
+ function printReviewSummary(report) {
834
936
  for (const s of report.summary) {
835
937
  console.log(` ${chalk.dim('•')} ${s}`);
836
938
  }
837
939
  if (report.summary.length > 0)
838
940
  console.log('');
839
- if (report.changedFiles.length > 0) {
840
- console.log(chalk.bold(' Changed files (top by risk):'));
841
- for (const f of report.changedFiles.slice(0, 15)) {
842
- const risk = f.riskScore !== null ? f.riskScore.toFixed(1).padStart(6) : ' - ';
843
- const cc = f.cyclomaticComplexity !== null ? String(f.cyclomaticComplexity).padStart(3) : ' -';
844
- const dcc = f.cyclomaticDelta === null ? ' ' : signed(f.cyclomaticDelta).padStart(3);
845
- const statusColor = f.status === 'added' ? chalk.green : f.status === 'removed' ? chalk.red : chalk.yellow;
846
- console.log(` ${statusColor(f.status.padEnd(8))} risk ${risk} CC ${cc} (Δ${dcc}) ${chalk.cyan(f.relativePath)}`);
847
- }
848
- if (report.changedFiles.length > 15) {
849
- console.log(chalk.dim(` ... and ${report.changedFiles.length - 15} more`));
850
- }
851
- console.log('');
941
+ }
942
+ function printReviewChangedFiles(report) {
943
+ if (report.changedFiles.length === 0)
944
+ return;
945
+ console.log(chalk.bold(' Changed files (top by risk):'));
946
+ for (const f of report.changedFiles.slice(0, 15)) {
947
+ const risk = f.riskScore !== null ? f.riskScore.toFixed(1).padStart(6) : ' - ';
948
+ const cc = f.cyclomaticComplexity !== null ? String(f.cyclomaticComplexity).padStart(3) : ' -';
949
+ const dcc = f.cyclomaticDelta === null ? ' ' : signed(f.cyclomaticDelta).padStart(3);
950
+ const statusColor = f.status === 'added' ? chalk.green : f.status === 'removed' ? chalk.red : chalk.yellow;
951
+ console.log(` ${statusColor(f.status.padEnd(8))} risk ${risk} CC ${cc} (Δ${dcc}) ${chalk.cyan(f.relativePath)}`);
852
952
  }
853
- if (report.newCycles.length > 0) {
854
- console.log(chalk.bold(` New / expanded cycles (${report.newCycles.length}):`));
855
- for (const c of report.newCycles.slice(0, 5)) {
856
- const tag = c.classification === 'new' ? chalk.red('NEW') : chalk.yellow('EXP');
857
- console.log(` ${tag} (${c.size}): ${c.files.join(' → ')}`);
858
- }
859
- if (report.newCycles.length > 5) {
860
- console.log(chalk.dim(` ... and ${report.newCycles.length - 5} more`));
861
- }
862
- console.log('');
953
+ if (report.changedFiles.length > 15) {
954
+ console.log(chalk.dim(` ... and ${report.changedFiles.length - 15} more`));
863
955
  }
864
- if (report.riskyFunctions.length > 0) {
865
- console.log(chalk.bold(` Risky functions (${report.riskyFunctions.length}):`));
866
- for (const fn of report.riskyFunctions.slice(0, 10)) {
867
- const cc = fn.cyclomaticComplexity >= 15 ? chalk.red : chalk.yellow;
868
- const transition = fn.baseCc === null ? `(new)` : `(${fn.baseCc} → ${fn.cyclomaticComplexity})`;
869
- console.log(` ${cc(`CC ${String(fn.cyclomaticComplexity).padStart(3)}`)} ${chalk.bold(fn.name)} ${chalk.dim(`${fn.file}:${fn.line}`)} ${chalk.dim(`[${fn.reason}] ${transition}`)}`);
870
- }
871
- if (report.riskyFunctions.length > 10) {
872
- console.log(chalk.dim(` ... and ${report.riskyFunctions.length - 10} more`));
873
- }
874
- console.log('');
956
+ console.log('');
957
+ }
958
+ function printReviewCycles(report) {
959
+ if (report.newCycles.length === 0)
960
+ return;
961
+ console.log(chalk.bold(` New / expanded cycles (${report.newCycles.length}):`));
962
+ for (const c of report.newCycles.slice(0, 5)) {
963
+ const tag = c.classification === 'new' ? chalk.red('NEW') : chalk.yellow('EXP');
964
+ console.log(` ${tag} (${c.size}): ${c.files.join(' ')}`);
875
965
  }
876
- if (report.dependencyChanges.length > 0) {
877
- console.log(chalk.bold(' Dependency changes:'));
878
- for (const d of report.dependencyChanges) {
879
- const wsLabel = d.workspace ? ` (${d.workspace})` : '';
880
- console.log(` ${chalk.cyan(d.manifestFile)}${chalk.dim(wsLabel)}`);
881
- for (const a of d.added)
882
- console.log(` ${chalk.green('+')} ${a.name}@${a.version} ${chalk.dim(`(${a.kind})`)}`);
883
- for (const r of d.removed)
884
- console.log(` ${chalk.red('-')} ${r.name}@${r.version} ${chalk.dim(`(${r.kind})`)}`);
885
- for (const b of d.bumped)
886
- console.log(` ${chalk.yellow('~')} ${b.name}: ${b.from} ${b.to} ${chalk.dim(`(${b.kind})`)}`);
887
- }
888
- console.log('');
966
+ if (report.newCycles.length > 5) {
967
+ console.log(chalk.dim(` ... and ${report.newCycles.length - 5} more`));
968
+ }
969
+ console.log('');
970
+ }
971
+ function printReviewRiskyFunctions(report) {
972
+ if (report.riskyFunctions.length === 0)
973
+ return;
974
+ console.log(chalk.bold(` Risky functions (${report.riskyFunctions.length}):`));
975
+ for (const fn of report.riskyFunctions.slice(0, 10)) {
976
+ const cc = fn.cyclomaticComplexity >= 15 ? chalk.red : chalk.yellow;
977
+ const transition = fn.baseCc === null ? `(new)` : `(${fn.baseCc} → ${fn.cyclomaticComplexity})`;
978
+ console.log(` ${cc(`CC ${String(fn.cyclomaticComplexity).padStart(3)}`)} ${chalk.bold(fn.name)} ${chalk.dim(`${fn.file}:${fn.line}`)} ${chalk.dim(`[${fn.reason}] ${transition}`)}`);
979
+ }
980
+ if (report.riskyFunctions.length > 10) {
981
+ console.log(chalk.dim(` ... and ${report.riskyFunctions.length - 10} more`));
889
982
  }
983
+ console.log('');
984
+ }
985
+ function printReviewDependencyChanges(report) {
986
+ if (report.dependencyChanges.length === 0)
987
+ return;
988
+ console.log(chalk.bold(' Dependency changes:'));
989
+ for (const d of report.dependencyChanges) {
990
+ const wsLabel = d.workspace ? ` (${d.workspace})` : '';
991
+ console.log(` ${chalk.cyan(d.manifestFile)}${chalk.dim(wsLabel)}`);
992
+ for (const a of d.added)
993
+ console.log(` ${chalk.green('+')} ${a.name}@${a.version} ${chalk.dim(`(${a.kind})`)}`);
994
+ for (const r of d.removed)
995
+ console.log(` ${chalk.red('-')} ${r.name}@${r.version} ${chalk.dim(`(${r.kind})`)}`);
996
+ for (const b of d.bumped)
997
+ console.log(` ${chalk.yellow('~')} ${b.name}: ${b.from} → ${b.to} ${chalk.dim(`(${b.kind})`)}`);
998
+ }
999
+ console.log('');
890
1000
  }
891
1001
  // ── Report: workspaces ────────────────────────────────────
892
1002
  export function reportWorkspaces(info) {