projscan 0.9.2 → 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 (115) hide show
  1. package/README.md +37 -7
  2. package/dist/analyzers/deadCodeCheck.d.ts +10 -12
  3. package/dist/analyzers/deadCodeCheck.js +41 -69
  4. package/dist/analyzers/deadCodeCheck.js.map +1 -1
  5. package/dist/analyzers/pythonDependencyRiskCheck.d.ts +2 -0
  6. package/dist/analyzers/pythonDependencyRiskCheck.js +114 -0
  7. package/dist/analyzers/pythonDependencyRiskCheck.js.map +1 -0
  8. package/dist/analyzers/pythonLinterCheck.d.ts +2 -0
  9. package/dist/analyzers/pythonLinterCheck.js +119 -0
  10. package/dist/analyzers/pythonLinterCheck.js.map +1 -0
  11. package/dist/analyzers/pythonTestCheck.d.ts +2 -0
  12. package/dist/analyzers/pythonTestCheck.js +97 -0
  13. package/dist/analyzers/pythonTestCheck.js.map +1 -0
  14. package/dist/analyzers/pythonUnusedDependencyCheck.d.ts +2 -0
  15. package/dist/analyzers/pythonUnusedDependencyCheck.js +76 -0
  16. package/dist/analyzers/pythonUnusedDependencyCheck.js.map +1 -0
  17. package/dist/cli/index.js +294 -10
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/core/ast.d.ts +2 -0
  20. package/dist/core/ast.js +35 -2
  21. package/dist/core/ast.js.map +1 -1
  22. package/dist/core/codeGraph.d.ts +8 -7
  23. package/dist/core/codeGraph.js +50 -72
  24. package/dist/core/codeGraph.js.map +1 -1
  25. package/dist/core/couplingAnalyzer.d.ts +18 -0
  26. package/dist/core/couplingAnalyzer.js +174 -0
  27. package/dist/core/couplingAnalyzer.js.map +1 -0
  28. package/dist/core/fileInspector.d.ts +3 -0
  29. package/dist/core/fileInspector.js +78 -3
  30. package/dist/core/fileInspector.js.map +1 -1
  31. package/dist/core/hotspotAnalyzer.d.ts +13 -0
  32. package/dist/core/hotspotAnalyzer.js +29 -6
  33. package/dist/core/hotspotAnalyzer.js.map +1 -1
  34. package/dist/core/indexCache.js +8 -1
  35. package/dist/core/indexCache.js.map +1 -1
  36. package/dist/core/issueEngine.js +10 -0
  37. package/dist/core/issueEngine.js.map +1 -1
  38. package/dist/core/languages/LanguageAdapter.d.ts +36 -0
  39. package/dist/core/languages/LanguageAdapter.js +2 -0
  40. package/dist/core/languages/LanguageAdapter.js.map +1 -0
  41. package/dist/core/languages/goAdapter.d.ts +2 -0
  42. package/dist/core/languages/goAdapter.js +136 -0
  43. package/dist/core/languages/goAdapter.js.map +1 -0
  44. package/dist/core/languages/goCyclomatic.d.ts +21 -0
  45. package/dist/core/languages/goCyclomatic.js +55 -0
  46. package/dist/core/languages/goCyclomatic.js.map +1 -0
  47. package/dist/core/languages/goExports.d.ts +26 -0
  48. package/dist/core/languages/goExports.js +89 -0
  49. package/dist/core/languages/goExports.js.map +1 -0
  50. package/dist/core/languages/goImports.d.ts +26 -0
  51. package/dist/core/languages/goImports.js +64 -0
  52. package/dist/core/languages/goImports.js.map +1 -0
  53. package/dist/core/languages/goManifests.d.ts +19 -0
  54. package/dist/core/languages/goManifests.js +56 -0
  55. package/dist/core/languages/goManifests.js.map +1 -0
  56. package/dist/core/languages/javascriptAdapter.d.ts +2 -0
  57. package/dist/core/languages/javascriptAdapter.js +68 -0
  58. package/dist/core/languages/javascriptAdapter.js.map +1 -0
  59. package/dist/core/languages/pythonAdapter.d.ts +6 -0
  60. package/dist/core/languages/pythonAdapter.js +147 -0
  61. package/dist/core/languages/pythonAdapter.js.map +1 -0
  62. package/dist/core/languages/pythonCyclomatic.d.ts +18 -0
  63. package/dist/core/languages/pythonCyclomatic.js +45 -0
  64. package/dist/core/languages/pythonCyclomatic.js.map +1 -0
  65. package/dist/core/languages/pythonExports.d.ts +28 -0
  66. package/dist/core/languages/pythonExports.js +169 -0
  67. package/dist/core/languages/pythonExports.js.map +1 -0
  68. package/dist/core/languages/pythonImports.d.ts +22 -0
  69. package/dist/core/languages/pythonImports.js +104 -0
  70. package/dist/core/languages/pythonImports.js.map +1 -0
  71. package/dist/core/languages/pythonManifests.d.ts +34 -0
  72. package/dist/core/languages/pythonManifests.js +344 -0
  73. package/dist/core/languages/pythonManifests.js.map +1 -0
  74. package/dist/core/languages/registry.d.ts +5 -0
  75. package/dist/core/languages/registry.js +31 -0
  76. package/dist/core/languages/registry.js.map +1 -0
  77. package/dist/core/languages/treeSitterLoader.d.ts +14 -0
  78. package/dist/core/languages/treeSitterLoader.js +76 -0
  79. package/dist/core/languages/treeSitterLoader.js.map +1 -0
  80. package/dist/core/monorepo.d.ts +20 -0
  81. package/dist/core/monorepo.js +270 -0
  82. package/dist/core/monorepo.js.map +1 -0
  83. package/dist/core/prDiff.d.ts +43 -0
  84. package/dist/core/prDiff.js +298 -0
  85. package/dist/core/prDiff.js.map +1 -0
  86. package/dist/core/searchIndex.js +8 -0
  87. package/dist/core/searchIndex.js.map +1 -1
  88. package/dist/core/telemetry.d.ts +90 -0
  89. package/dist/core/telemetry.js +199 -0
  90. package/dist/core/telemetry.js.map +1 -0
  91. package/dist/grammars/tree-sitter-go.wasm +0 -0
  92. package/dist/grammars/tree-sitter-python.wasm +0 -0
  93. package/dist/grammars/web-tree-sitter.wasm +0 -0
  94. package/dist/mcp/server.js +22 -0
  95. package/dist/mcp/server.js.map +1 -1
  96. package/dist/mcp/tools.js +317 -20
  97. package/dist/mcp/tools.js.map +1 -1
  98. package/dist/reporters/consoleReporter.d.ts +4 -1
  99. package/dist/reporters/consoleReporter.js +113 -0
  100. package/dist/reporters/consoleReporter.js.map +1 -1
  101. package/dist/reporters/jsonReporter.d.ts +4 -1
  102. package/dist/reporters/jsonReporter.js +9 -0
  103. package/dist/reporters/jsonReporter.js.map +1 -1
  104. package/dist/reporters/markdownReporter.d.ts +4 -1
  105. package/dist/reporters/markdownReporter.js +103 -3
  106. package/dist/reporters/markdownReporter.js.map +1 -1
  107. package/dist/types.d.ts +115 -0
  108. package/dist/utils/cache.d.ts +3 -0
  109. package/dist/utils/cache.js +51 -0
  110. package/dist/utils/cache.js.map +1 -0
  111. package/dist/utils/config.js +10 -0
  112. package/dist/utils/config.js.map +1 -1
  113. package/dist/utils/fileWalker.js +14 -0
  114. package/dist/utils/fileWalker.js.map +1 -1
  115. package/package.json +11 -5
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const TEST_FRAMEWORKS = ['pytest', 'unittest', 'nose', 'nose2', 'ward'];
4
+ function isPythonTestFile(rel) {
5
+ const base = path.basename(rel);
6
+ if (/^test_.+\.py$/.test(base))
7
+ return true;
8
+ if (/^.+_test\.py$/.test(base))
9
+ return true;
10
+ if (rel.startsWith('tests/') || rel.includes('/tests/'))
11
+ return base.endsWith('.py');
12
+ return false;
13
+ }
14
+ async function tryRead(absolutePath) {
15
+ try {
16
+ return await fs.readFile(absolutePath, 'utf-8');
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export async function check(rootPath, files) {
23
+ const hasPython = files.some((f) => f.extension === '.py' || f.extension === '.pyw');
24
+ if (!hasPython)
25
+ return [];
26
+ const pyproject = await tryRead(path.join(rootPath, 'pyproject.toml'));
27
+ const setupCfg = await tryRead(path.join(rootPath, 'setup.cfg'));
28
+ const pytestIni = await tryRead(path.join(rootPath, 'pytest.ini'));
29
+ const toxIni = await tryRead(path.join(rootPath, 'tox.ini'));
30
+ // Collect all requirements*.txt contents into one searchable blob.
31
+ const reqRels = files
32
+ .filter((f) => (!f.directory || f.directory === '.') &&
33
+ /^requirements(-.*)?\.txt$/i.test(path.basename(f.relativePath)))
34
+ .map((f) => f.relativePath);
35
+ let requirementsBlob = '';
36
+ for (const rel of reqRels) {
37
+ const content = await tryRead(path.join(rootPath, rel));
38
+ if (content)
39
+ requirementsBlob += content + '\n';
40
+ }
41
+ let hasFramework = false;
42
+ const manifestHaystack = [pyproject ?? '', setupCfg ?? '', requirementsBlob].join('\n');
43
+ for (const fw of TEST_FRAMEWORKS) {
44
+ // Use a word-boundary-ish match so "pytest" doesn't spuriously match "pytest-cov"
45
+ // for purposes of "is pytest the framework?" - but we also accept pytest-*, so
46
+ // a simple case-insensitive containment is fine in practice.
47
+ const re = new RegExp(`(^|[\\s"'\`\\[\\],={}><~^!;])${fw}(\\b|[^a-zA-Z0-9_.-])`, 'im');
48
+ if (re.test(manifestHaystack)) {
49
+ hasFramework = true;
50
+ break;
51
+ }
52
+ }
53
+ if (!hasFramework && pytestIni !== null)
54
+ hasFramework = true;
55
+ if (!hasFramework && toxIni !== null && /\[pytest\]|\[tool:pytest\]|pytest/i.test(toxIni)) {
56
+ hasFramework = true;
57
+ }
58
+ if (!hasFramework && pyproject !== null && /\[tool\.pytest\.ini_options\]/.test(pyproject)) {
59
+ hasFramework = true;
60
+ }
61
+ // Last-resort detection: `import unittest` (or `from unittest ...`) inside a
62
+ // pytest-conventional test file. Some projects rely on stdlib unittest and
63
+ // never declare a framework in manifests.
64
+ const testFiles = files.filter((f) => isPythonTestFile(f.relativePath));
65
+ if (!hasFramework) {
66
+ for (const f of testFiles) {
67
+ const content = await tryRead(f.absolutePath);
68
+ if (content && /^\s*(import\s+unittest|from\s+unittest\s+import)/m.test(content)) {
69
+ hasFramework = true;
70
+ break;
71
+ }
72
+ }
73
+ }
74
+ const issues = [];
75
+ if (!hasFramework) {
76
+ issues.push({
77
+ id: 'missing-python-test-framework',
78
+ title: 'No Python test framework detected',
79
+ description: 'No pytest/unittest configuration or dependency found. Testing is essential for code quality and reliability.',
80
+ severity: 'warning',
81
+ category: 'testing',
82
+ fixAvailable: false,
83
+ });
84
+ }
85
+ else if (testFiles.length === 0) {
86
+ issues.push({
87
+ id: 'no-python-test-files',
88
+ title: 'No Python test files found',
89
+ description: 'A Python test framework is configured but no test files were found (expected test_*.py, *_test.py, or under tests/).',
90
+ severity: 'info',
91
+ category: 'testing',
92
+ fixAvailable: false,
93
+ });
94
+ }
95
+ return issues;
96
+ }
97
+ //# sourceMappingURL=pythonTestCheck.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pythonTestCheck.js","sourceRoot":"","sources":["../../src/analyzers/pythonTestCheck.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import type { FileEntry, Issue } from '../types.js';
2
+ export declare function check(rootPath: string, files: FileEntry[]): Promise<Issue[]>;
@@ -0,0 +1,76 @@
1
+ import { detectPythonProject } from '../core/languages/pythonManifests.js';
2
+ import { buildCodeGraph } from '../core/codeGraph.js';
3
+ // Packages whose presence in manifests is implicit (build-time, test-time,
4
+ // or tooling) and which typically won't appear in `import` statements.
5
+ const IMPLICIT_USE = new Set([
6
+ 'pytest',
7
+ 'pytest-cov',
8
+ 'pytest-mock',
9
+ 'pytest-asyncio',
10
+ 'pytest-xdist',
11
+ 'ruff',
12
+ 'black',
13
+ 'mypy',
14
+ 'coverage',
15
+ 'wheel',
16
+ 'build',
17
+ 'setuptools',
18
+ 'setuptools-scm',
19
+ 'pip',
20
+ 'pip-tools',
21
+ 'twine',
22
+ 'flake8',
23
+ 'pylint',
24
+ 'pyflakes',
25
+ 'isort',
26
+ 'bandit',
27
+ 'tox',
28
+ 'pre-commit',
29
+ 'hatch',
30
+ 'hatchling',
31
+ 'poetry-core',
32
+ 'maturin',
33
+ ]);
34
+ /**
35
+ * Normalize a package name for comparison. PyPI names are case-insensitive
36
+ * and `_` / `-` / `.` are interchangeable per PEP 503.
37
+ */
38
+ function normalize(name) {
39
+ return name.toLowerCase().replace(/[_.]/g, '-');
40
+ }
41
+ export async function check(rootPath, files) {
42
+ const info = await detectPythonProject(rootPath, files);
43
+ if (!info || info.declared.length === 0)
44
+ return [];
45
+ const pythonFiles = files.filter((f) => f.extension === '.py' || f.extension === '.pyw');
46
+ if (pythonFiles.length === 0)
47
+ return [];
48
+ const graph = await buildCodeGraph(rootPath, pythonFiles);
49
+ const usedPackages = new Set();
50
+ for (const pkg of graph.packageImporters.keys()) {
51
+ usedPackages.add(normalize(pkg));
52
+ }
53
+ const issues = [];
54
+ const seen = new Set();
55
+ for (const dep of info.declared) {
56
+ const norm = normalize(dep.name);
57
+ if (seen.has(norm))
58
+ continue;
59
+ if (IMPLICIT_USE.has(norm))
60
+ continue;
61
+ if (usedPackages.has(norm))
62
+ continue;
63
+ seen.add(norm);
64
+ issues.push({
65
+ id: `unused-python-dependency-${dep.name}`,
66
+ title: `Unused Python dependency: ${dep.name}`,
67
+ description: `\`${dep.name}\` is declared in ${dep.source} but no source file imports it. Either remove it or add it to the implicit-use allowlist.`,
68
+ severity: dep.scope === 'dev' ? 'info' : 'warning',
69
+ category: 'dependencies',
70
+ fixAvailable: false,
71
+ locations: [{ file: dep.source, line: dep.line }],
72
+ });
73
+ }
74
+ return issues;
75
+ }
76
+ //# sourceMappingURL=pythonUnusedDependencyCheck.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pythonUnusedDependencyCheck.js","sourceRoot":"","sources":["../../src/analyzers/pythonUnusedDependencyCheck.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAC3E,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,2EAA2E;AAC3E,uEAAuE;AACvE,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC;IAC3B,QAAQ;IACR,YAAY;IACZ,aAAa;IACb,gBAAgB;IAChB,cAAc;IACd,MAAM;IACN,OAAO;IACP,MAAM;IACN,UAAU;IACV,OAAO;IACP,OAAO;IACP,YAAY;IACZ,gBAAgB;IAChB,KAAK;IACL,WAAW;IACX,OAAO;IACP,QAAQ;IACR,QAAQ;IACR,UAAU;IACV,OAAO;IACP,QAAQ;IACR,KAAK;IACL,YAAY;IACZ,OAAO;IACP,WAAW;IACX,aAAa;IACb,SAAS;CACV,CAAC,CAAC;AAEH;;;GAGG;AACH,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;AAClD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,QAAgB,EAAE,KAAkB;IAC9D,MAAM,IAAI,GAAG,MAAM,mBAAmB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IACxD,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEnD,MAAM,WAAW,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,KAAK,IAAI,CAAC,CAAC,SAAS,KAAK,MAAM,CAAC,CAAC;IACzF,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAExC,MAAM,KAAK,GAAG,MAAM,cAAc,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAC;IAC1D,MAAM,YAAY,GAAG,IAAI,GAAG,EAAU,CAAC;IACvC,KAAK,MAAM,GAAG,IAAI,KAAK,CAAC,gBAAgB,CAAC,IAAI,EAAE,EAAE,CAAC;QAChD,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;IACnC,CAAC;IAED,MAAM,MAAM,GAAY,EAAE,CAAC;IAC3B,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAE/B,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QAChC,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjC,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,SAAS;QAC7B,IAAI,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,SAAS;QACrC,IAAI,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,SAAS;QAErC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACf,MAAM,CAAC,IAAI,CAAC;YACV,EAAE,EAAE,4BAA4B,GAAG,CAAC,IAAI,EAAE;YAC1C,KAAK,EAAE,6BAA6B,GAAG,CAAC,IAAI,EAAE;YAC9C,WAAW,EAAE,KAAK,GAAG,CAAC,IAAI,qBAAqB,GAAG,CAAC,MAAM,2FAA2F;YACpJ,QAAQ,EAAE,GAAG,CAAC,KAAK,KAAK,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS;YAClD,QAAQ,EAAE,cAAc;YACxB,YAAY,EAAE,KAAK;YACnB,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,CAAC;SAClD,CAAC,CAAC;IACL,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC"}
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) {