@chappibunny/repolens 1.16.1 → 1.16.3
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/CHANGELOG.md +39 -0
- package/README.md +8 -8
- package/package.json +1 -1
- package/src/ai/generate-sections.js +23 -4
- package/src/analyzers/complexity-analyzer.js +17 -4
- package/src/analyzers/context-builder.js +2 -1
- package/src/analyzers/dependency-graph.js +144 -21
- package/src/analyzers/flow-inference.js +10 -1
- package/src/analyzers/graphql-analyzer.js +80 -32
- package/src/analyzers/language-coverage.js +222 -0
- package/src/analyzers/workspace-packages.js +105 -0
- package/src/badge.js +59 -18
- package/src/core/scan.js +10 -4
- package/src/docs/generate-doc-set.js +11 -3
- package/src/help.js +8 -3
- package/src/renderers/render.js +14 -2
- package/src/renderers/renderAnalysis.js +12 -0
- package/src/utils/js-lexer.js +146 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to RepoLens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.16.3 (2026-11-11)
|
|
6
|
+
|
|
7
|
+
Fixes found by the 2026-11-11 gap test of 1.16.2 on trpc/trpc, App-vNext/Polly, google/gson, charmbracelet/glow, and pallets/itsdangerous.
|
|
8
|
+
|
|
9
|
+
### Upgrade notes (read before publishing docs)
|
|
10
|
+
|
|
11
|
+
- **Monorepo graphs change.** Imports between declared workspace packages are now edges, not external packages. Expect higher edge counts, fewer orphans, new Hub Modules, fewer external packages, and different module roles on the first run after upgrading. On trpc/trpc: 913 → 1,533 edges, orphans 254 → 126, external packages 95 → 89. If you keep an architecture baseline, the next drift report lists these as changes; they are a measurement fix, not a code change.
|
|
12
|
+
- **`repolens badge` can now exit 2 where it used to print a score.** A repository whose product source has no JavaScript/TypeScript, Python, or Go (only a docs script, test helper, or vendored copy) is reported as unsupported; App-vNext/Polly used to get a false "100". A repository where those languages are under 10% of product source files also exits 2, because one number would not stand for it. CI steps that publish the badge should tolerate exit 2 or skip the badge for such repositories. Vue (`.vue`) and Svelte (`.svelte`) component files are not analyzed (no imports, complexity, or health from them), but they do not count toward that 10% share: a component library with a few `.ts` files and many components still gets a badge, scored on its JavaScript/TypeScript only, with a partial-coverage warning on stderr and in the docs naming the component files that were not analyzed.
|
|
13
|
+
- **Empty pages are no longer written.** API Surface (no routes, no external API integrations, no documented exports) and GraphQL Schema (no real GraphQL evidence) are skipped unless `documentation.sections` lists them. Pages published by earlier versions to Notion, Confluence, GitHub Wiki, or Obsidian are not deleted: they stay as last published until you remove them. The Markdown publisher overwrites files but does not delete an old `06-api-surface.md` or `11-graphql-schema.md` in the output directory.
|
|
14
|
+
- **GraphQL detection is narrower.** Libraries count only when imported. A bare mention of "hasura" in code or comments no longer reports Hasura; a `hasura/config.yaml` (or root `config.yaml`) with `metadata_directory`, or an `@hasura/*` / `hasura-*` import, does.
|
|
15
|
+
|
|
16
|
+
### Dependency graph
|
|
17
|
+
|
|
18
|
+
- Workspace packages are internal. Members come only from declarations: root `package.json` `workspaces` (array or `{ packages }`, `!` exclusions honoured), `pnpm-workspace.yaml` `packages`, and `lerna.json` `packages`. A stray `package.json` (a test stub named `express`, a fixture) is not a workspace member. An import of `@scope/pkg` or `@scope/pkg/subpath` naming a member resolves to its source through `exports`, `main`, or `src/`, with `dist/`, `lib/`, and `build/` targets mapped back to `src/`. Targets and subpaths that leave the package (`..`, absolute paths, URLs, wildcards) are ignored. A dependency declared as `npm:alias@version` stays external. On trpc/trpc `@trpc/*` is gone from external packages, a Hub Modules section appears, and `packages/server` reads "Shared infrastructure (imported by 7 modules)" instead of "Connector (1 in, 2 out)". The MCP `get_dependency_graph` tool uses the same graph.
|
|
19
|
+
- Flow steps name a package entry `server/src` rather than a bare `src`.
|
|
20
|
+
|
|
21
|
+
### Language coverage
|
|
22
|
+
|
|
23
|
+
- The dependency graph and code health cover JavaScript/TypeScript, Python, and Go. Coverage is measured on product source: tests, docs, tooling, examples, and vendored trees (`vendor`, `third_party`, `deps`, `external`, and similar) do not count.
|
|
24
|
+
- No supported product code at all: graph and health pages say the language is not supported yet instead of building a graph or score from a docs script. Polly no longer reports "no circular dependencies" and "Average health 100/100"; gson names Java/Kotlin instead of telling the user to check `scan.include`.
|
|
25
|
+
- Supported product code next to other languages (for example 5 Python files and 80 Rust files): the graph and health are built for the supported files, and the dependency graph, code health, executive summary, system overview, and architecture overview all say "Partial coverage: … covers N of M product source files". Cycle claims read "among analyzed files", and "Acyclic dependency graph" is not listed as a pattern.
|
|
26
|
+
- `repolens badge` counts every source file in one walk (up to 20,000, with a note when that limit is hit) and analyzes up to 2,500, so the coverage ratio is not skewed by the analysis cap.
|
|
27
|
+
- `demo --help`, `init --help`, and the README say detection is not full analysis.
|
|
28
|
+
- The language list is ordered by file count (Polly reads "C#, JavaScript"), and a statically typed primary language is no longer described as "Dynamic typing".
|
|
29
|
+
|
|
30
|
+
### Output accuracy
|
|
31
|
+
|
|
32
|
+
- GraphQL libraries are detected from imports. SDL and resolver maps are read after a comment stripper that keeps strings, template literals (including `${}` expressions), and regex literals, so `'src/*.graphql'` or `' // '` in a string no longer erases real SDL. Comments that mention Apollo or urql and React Query `useQuery:` hook maps no longer produce a GraphQL page; trpc/trpc's `11-graphql-schema.md` is skipped.
|
|
33
|
+
- A module whose files are mostly tests says so ("Mostly tests and fixtures (204 of 212 files)") instead of "Application module".
|
|
34
|
+
- When API Surface is written with no routes, it no longer blames `scan.include`.
|
|
35
|
+
|
|
36
|
+
## 1.16.2
|
|
37
|
+
|
|
38
|
+
### Branding
|
|
39
|
+
|
|
40
|
+
- New RepoLens icon: emerald rounded tile with a camera aperture and a white git-branch glyph, transparent background. Replaces `assets/logo-400.png`, `assets/Avatar.png` and the gallery `gallery/assets/logo.png` (512x512).
|
|
41
|
+
- The gallery site adds `favicon.ico` (16 and 32 px) next to the PNG icon.
|
|
42
|
+
- The VS Code extension (0.2.4) uses the same icon.
|
|
43
|
+
|
|
5
44
|
## 1.16.1
|
|
6
45
|
|
|
7
46
|
Fixes three problems found by running 1.16.0 on orval-labs/orval (a TypeScript monorepo whose test snapshots and samples dwarf the packages).
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ RepoLens scans your repository, generates living architecture documentation, and
|
|
|
29
29
|
|
|
30
30
|
## 🎬 Demo
|
|
31
31
|
|
|
32
|
-
> **Try it now** — no installation required. `npx @chappibunny/repolens demo` previews the repository you point it at. It detects common languages (Python, Go, Rust, Java, JavaScript/TypeScript, and others). If the scan matches no source files, demo exits 2 and does not write empty docs.
|
|
32
|
+
> **Try it now** — no installation required. `npx @chappibunny/repolens demo` previews the repository you point it at. It detects common languages (Python, Go, Rust, Java, JavaScript/TypeScript, and others) and maps their modules and file structure. If the scan matches no source files, demo exits 2 and does not write empty docs. The dependency graph and code health cover JavaScript/TypeScript, Python, and Go only; a Java, Kotlin, or C# repository gets a "not supported yet" note on those pages, not a score.
|
|
33
33
|
|
|
34
34
|
**[See it on real repos](https://chapibunny.github.io/repolens/)** — architecture pages RepoLens generated for Express, FastAPI, Django, Gin, mdBook, and other public projects. Deterministic mode, no API keys.
|
|
35
35
|
|
|
@@ -243,9 +243,9 @@ If `npx` reports `Unknown command: mcp`, an `.npmrc` with `min-release-age` (or
|
|
|
243
243
|
|
|
244
244
|
## GitHub Action
|
|
245
245
|
|
|
246
|
-
`action.yml` is a composite action named **RepoLens Architecture Docs**. `publish`, `demo`, and `doctor` run the published npm package (`npx --force --yes @chappibunny/repolens@1.16.
|
|
246
|
+
`action.yml` is a composite action named **RepoLens Architecture Docs**. `publish`, `demo`, and `doctor` run the published npm package (`npx --force --yes @chappibunny/repolens@1.16.3`). `comment` and `badge` run from the action checkout. Inputs are `command` (`publish`, `demo`, `doctor`, `comment`, or `badge`), `config`, `node-version`, `pr-comment`, `version`, `badge-out`, `badge-url`, and `badge-link`. The action passes `--force` so a repository `devEngines` pin does not stop `npx` on npm versions that enforce `devEngines` (newer npm). Node 20's bundled npm does not enforce that field. From a shell, use the same flag: `npx --force --yes @chappibunny/repolens demo`. `--ignore-engines` does not skip `devEngines`.
|
|
247
247
|
|
|
248
|
-
Publishing docs (the npm package at tag `v1.16.
|
|
248
|
+
Publishing docs (the npm package at tag `v1.16.3`):
|
|
249
249
|
|
|
250
250
|
```yaml
|
|
251
251
|
name: Architecture docs
|
|
@@ -261,7 +261,7 @@ jobs:
|
|
|
261
261
|
runs-on: ubuntu-latest
|
|
262
262
|
steps:
|
|
263
263
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
264
|
-
- uses: CHAPIBUNNY/repolens@v1.16.
|
|
264
|
+
- uses: CHAPIBUNNY/repolens@v1.16.3
|
|
265
265
|
with:
|
|
266
266
|
command: publish
|
|
267
267
|
config: .repolens.yml
|
|
@@ -269,7 +269,7 @@ jobs:
|
|
|
269
269
|
pr-comment: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
|
|
270
270
|
```
|
|
271
271
|
|
|
272
|
-
On `v1.15.2`, `pr-comment: true` runs the architecture diff from the action checkout and turns the published package's older short summary off. The diff lists modules, files, dependencies, the code-health delta, and new hotspots. It edits one sticky comment (`<!-- repolens:architecture-diff -->`) instead of adding a new comment each push. The footer is "Generated by RepoLens" and links to this repository and the [gallery](https://chapibunny.github.io/repolens/). Tag `v1.15.1` does not contain `command: comment`. Pin `v1.16.
|
|
272
|
+
On `v1.15.2`, `pr-comment: true` runs the architecture diff from the action checkout and turns the published package's older short summary off. The diff lists modules, files, dependencies, the code-health delta, and new hotspots. It edits one sticky comment (`<!-- repolens:architecture-diff -->`) instead of adding a new comment each push. The footer is "Generated by RepoLens" and links to this repository and the [gallery](https://chapibunny.github.io/repolens/). Tag `v1.15.1` does not contain `command: comment`. Pin `v1.16.3` or a commit SHA in production.
|
|
273
273
|
|
|
274
274
|
```yaml
|
|
275
275
|
name: RepoLens architecture comment
|
|
@@ -285,7 +285,7 @@ jobs:
|
|
|
285
285
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
286
286
|
with:
|
|
287
287
|
fetch-depth: 0
|
|
288
|
-
- uses: CHAPIBUNNY/repolens@v1.16.
|
|
288
|
+
- uses: CHAPIBUNNY/repolens@v1.16.3
|
|
289
289
|
with:
|
|
290
290
|
command: comment
|
|
291
291
|
```
|
|
@@ -294,7 +294,7 @@ jobs:
|
|
|
294
294
|
|
|
295
295
|
`pr-comment: true` on `publish`, `demo`, or `doctor` (when the action ref contains this change) runs the same comment after the package and turns the package's older comment off, so the pull request still gets one comment.
|
|
296
296
|
|
|
297
|
-
The action is listed on the GitHub Marketplace only after a release is published with **Publish this Action to the GitHub Marketplace** checked. Those steps are in [docs/RELEASE.md](docs/RELEASE.md). The tag `v1.16.
|
|
297
|
+
The action is listed on the GitHub Marketplace only after a release is published with **Publish this Action to the GitHub Marketplace** checked. Those steps are in [docs/RELEASE.md](docs/RELEASE.md). The tag `v1.16.3` has to exist before `uses: CHAPIBUNNY/repolens@v1.16.3` resolves, and `@chappibunny/repolens@1.16.3` has to be on npm before `publish` can install it. `command: comment` and `command: badge` do not install that package.
|
|
298
298
|
|
|
299
299
|
## Add the badge to your repo
|
|
300
300
|
|
|
@@ -330,7 +330,7 @@ jobs:
|
|
|
330
330
|
runs-on: ubuntu-latest
|
|
331
331
|
steps:
|
|
332
332
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
333
|
-
- uses: CHAPIBUNNY/repolens@v1.16.
|
|
333
|
+
- uses: CHAPIBUNNY/repolens@v1.16.3
|
|
334
334
|
with:
|
|
335
335
|
command: badge
|
|
336
336
|
badge-url: https://USER.github.io/REPO/repolens-badge.json
|
package/package.json
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
// Generate documentation sections using AI
|
|
2
2
|
|
|
3
|
+
import { coverageLine, coverageNotice, coverageScope } from "../analyzers/language-coverage.js";
|
|
4
|
+
|
|
5
|
+
// Partial language coverage is stated next to every graph-based score, so a Rust or
|
|
6
|
+
// C# majority never inherits "clean boundaries" from the JavaScript/Python/Go part.
|
|
7
|
+
function coverageBanner(depGraph) {
|
|
8
|
+
const lines = coverageNotice(depGraph?.coverage, "import analysis");
|
|
9
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
10
|
+
}
|
|
3
11
|
import { generateText, isAIEnabled } from "./provider.js";
|
|
4
12
|
import {
|
|
5
13
|
SYSTEM_PROMPT,
|
|
@@ -285,6 +293,7 @@ The system is organized around **${context.domains.length} functional domains**,
|
|
|
285
293
|
const healthEmoji = healthScore >= 80 ? "🟢" : healthScore >= 60 ? "🟡" : "🔴";
|
|
286
294
|
|
|
287
295
|
output += `**Overall Health: ${healthEmoji} ${healthScore}/100**\n\n`;
|
|
296
|
+
output += coverageBanner(depGraph);
|
|
288
297
|
output += `| Metric | Value | Status |\n`;
|
|
289
298
|
output += `|--------|-------|--------|\n`;
|
|
290
299
|
output += `| Internal imports | ${stats.totalEdges} edges | ${stats.totalEdges > 0 ? "✅ Connected" : "⚠️ Isolated"} |\n`;
|
|
@@ -296,7 +305,7 @@ The system is organized around **${context.domains.length} functional domains**,
|
|
|
296
305
|
// Key insights
|
|
297
306
|
output += `\n### Key Insights\n\n`;
|
|
298
307
|
if (stats.cycles === 0) {
|
|
299
|
-
output += `- ✅ **Clean module boundaries** — No circular dependencies detected, enabling safe refactoring and testing.\n`;
|
|
308
|
+
output += `- ✅ **Clean module boundaries** — No circular dependencies detected${coverageScope(depGraph.coverage)}, enabling safe refactoring and testing.\n`;
|
|
300
309
|
} else {
|
|
301
310
|
output += `- ${cycleInsight(stats)}\n`;
|
|
302
311
|
}
|
|
@@ -311,6 +320,8 @@ The system is organized around **${context.domains.length} functional domains**,
|
|
|
311
320
|
if (orphanRatio(stats) > ORPHAN_WARN_RATIO) {
|
|
312
321
|
output += `- ⚠️ **Potential dead code** — ${stats.orphanFiles} files (${Math.round(stats.orphanFiles / stats.totalFiles * 100)}%) are not imported anywhere.\n`;
|
|
313
322
|
}
|
|
323
|
+
} else if (depGraph?.coverage?.status === "unsupported") {
|
|
324
|
+
output += `Import-graph health is not available: ${coverageLine(depGraph.coverage, "import analysis")}\n`;
|
|
314
325
|
}
|
|
315
326
|
|
|
316
327
|
// Data flows summary
|
|
@@ -525,11 +536,12 @@ The system is built on a modern technology foundation:\n\n`;
|
|
|
525
536
|
|
|
526
537
|
output += `## Dependency Graph\n\n`;
|
|
527
538
|
output += `**Codebase Health: ${healthEmoji} ${healthScore}/100**\n\n`;
|
|
539
|
+
output += coverageBanner(depGraph);
|
|
528
540
|
output += `| Metric | Value | Assessment |\n`;
|
|
529
541
|
output += `|--------|-------|------------|\n`;
|
|
530
542
|
output += `| Internal Imports | ${stats.totalEdges} | ${stats.totalEdges > 0 ? "Modules are connected" : "No internal imports"} |\n`;
|
|
531
543
|
output += `| External Packages | ${externalPackagesLabel(stats)} | ${stats.externalDeps < 50 ? "Lean dependencies" : stats.externalDeps < 100 ? "Moderate dependencies" : "Many dependencies"} |\n`;
|
|
532
|
-
output += `| Circular Dependencies | ${stats.cycles} | ${stats.cycles === 0 ?
|
|
544
|
+
output += `| Circular Dependencies | ${stats.cycles} | ${stats.cycles === 0 ? `✅ Clean${coverageScope(depGraph.coverage)}` : "⚠️ Needs attention"} |\n`;
|
|
533
545
|
output += `| Orphan Files | ${stats.orphanFiles} | ${orphanStatus(stats)} |\n`;
|
|
534
546
|
|
|
535
547
|
if (stats.hubs && stats.hubs.length > 0) {
|
|
@@ -825,20 +837,21 @@ function getFallbackArchitectureOverview(context, enrichment = {}) {
|
|
|
825
837
|
|
|
826
838
|
output += `\n## Dependency Health\n\n`;
|
|
827
839
|
output += `**Architecture Health Score: ${healthEmoji} ${healthScore}/100**\n\n`;
|
|
840
|
+
output += coverageBanner(depGraph);
|
|
828
841
|
|
|
829
842
|
output += `| Metric | Value | Status | Recommendation |\n`;
|
|
830
843
|
output += `|--------|-------|--------|----------------|\n`;
|
|
831
844
|
output += `| Import Edges | ${stats.totalEdges} | ${stats.totalEdges > 0 ? "✅" : "⚠️"} | ${stats.totalEdges > 0 ? "Healthy module connectivity" : "Consider connecting modules"} |\n`;
|
|
832
845
|
output += `| External Packages | ${externalPackagesLabel(stats)} | ${stats.externalDeps < 50 ? "✅" : stats.externalDeps < 100 ? "⚠️" : "🔴"} | ${stats.externalDeps >= 100 ? "Audit for unused dependencies" : stats.externalDeps >= 50 ? "Review dependency necessity" : "Lean dependency set"} |\n`;
|
|
833
846
|
const archCycle = cycleStatusCell(stats);
|
|
834
|
-
output += `| Circular Deps | ${stats.cycles} | ${stats.cycles === 0 ? "✅" : archCycle.mark} | ${stats.cycles === 0 ?
|
|
847
|
+
output += `| Circular Deps | ${stats.cycles} | ${stats.cycles === 0 ? "✅" : archCycle.mark} | ${stats.cycles === 0 ? `Clean module boundaries${coverageScope(depGraph.coverage)}` : archCycle.advice} |\n`;
|
|
835
848
|
output += `| Orphan Files | ${stats.orphanFiles} | ${orphanRatio(stats) > ORPHAN_WARN_RATIO ? "⚠️" : "✅"} | ${stats.orphanFiles === 0 ? "All code is connected" : orphanRatio(stats) > ORPHAN_WARN_RATIO ? "Review for dead code" : "A few unconnected files (examples, type tests, entry points) are normal"} |\n`;
|
|
836
849
|
|
|
837
850
|
// Architectural strengths and concerns
|
|
838
851
|
output += `\n### Architectural Strengths\n\n`;
|
|
839
852
|
const strengths = [];
|
|
840
853
|
if (stats.cycles === 0) {
|
|
841
|
-
strengths.push(
|
|
854
|
+
strengths.push(`**Clean module boundaries** — No circular dependencies detected${coverageScope(depGraph.coverage)}. This enables safe refactoring, isolated testing, and clear ownership boundaries.`);
|
|
842
855
|
}
|
|
843
856
|
if (stats.orphanFiles === 0) {
|
|
844
857
|
strengths.push("**Fully connected codebase** — Every file is part of the dependency graph. No dead code or forgotten modules.");
|
|
@@ -1780,8 +1793,13 @@ function getFrameworkArchitecturalImplication(frameworks) {
|
|
|
1780
1793
|
}
|
|
1781
1794
|
|
|
1782
1795
|
// Helper: architectural implication of languages
|
|
1796
|
+
// Languages arrive most-files-first, so the first one is the primary language. A C#
|
|
1797
|
+
// repository with one docs script is not "dynamic typing".
|
|
1798
|
+
const STATIC_PRIMARY = new Set(["c#", "java", "kotlin", "scala", "swift", "c", "c++"]);
|
|
1799
|
+
|
|
1783
1800
|
function getLanguageImplication(languages) {
|
|
1784
1801
|
const langs = (languages || []).map(l => l.toLowerCase());
|
|
1802
|
+
if (STATIC_PRIMARY.has(langs[0])) return "Static typing; compiled language";
|
|
1785
1803
|
if (langs.includes("typescript")) return "Static typing enables tooling and refactoring confidence";
|
|
1786
1804
|
if (langs.includes("javascript")) return "Dynamic typing, flexible but requires discipline";
|
|
1787
1805
|
if (langs.includes("python")) return "Readable syntax, rich ecosystem";
|
|
@@ -1892,6 +1910,7 @@ function getFrameworkLearningTip(frameworks) {
|
|
|
1892
1910
|
// Helper: language learning tip
|
|
1893
1911
|
function getLanguageLearningTip(languages) {
|
|
1894
1912
|
const langs = (languages || []).map(l => l.toLowerCase());
|
|
1913
|
+
if (STATIC_PRIMARY.has(langs[0])) return "Strong typing — let the compiler and IDE guide navigation";
|
|
1895
1914
|
if (langs.includes("typescript")) return "Strong typing — check types when debugging";
|
|
1896
1915
|
if (langs.includes("javascript")) return "Dynamic typing — use console.log for runtime inspection";
|
|
1897
1916
|
if (langs.includes("python")) return "Read docstrings and type hints";
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// (dep graph, JSDoc, security) into a per-module health score.
|
|
4
4
|
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
|
+
import { computeLanguageCoverage, coverageLine, filesForAnalysis, unanalyzedLanguageNames } from "./language-coverage.js";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { info } from "../utils/logger.js";
|
|
8
9
|
|
|
@@ -250,8 +251,9 @@ function functionsForLanguage(language, source) {
|
|
|
250
251
|
* @param {string} repoRoot - Absolute path to repo root
|
|
251
252
|
* @returns {Promise<Object>} Complexity analysis results
|
|
252
253
|
*/
|
|
253
|
-
export async function analyzeComplexity(files, repoRoot) {
|
|
254
|
-
const
|
|
254
|
+
export async function analyzeComplexity(files, repoRoot, { coverageFiles = files } = {}) {
|
|
255
|
+
const coverage = computeLanguageCoverage(coverageFiles || files);
|
|
256
|
+
const sourceFiles = filesForAnalysis(files, coverage)
|
|
255
257
|
.filter(f => languageForFile(f))
|
|
256
258
|
.filter(f => !shouldSkip(f));
|
|
257
259
|
|
|
@@ -259,6 +261,7 @@ export async function analyzeComplexity(files, repoRoot) {
|
|
|
259
261
|
files: [],
|
|
260
262
|
functions: [],
|
|
261
263
|
filesAnalyzed: sourceFiles.length,
|
|
264
|
+
coverage,
|
|
262
265
|
};
|
|
263
266
|
|
|
264
267
|
for (const file of sourceFiles) {
|
|
@@ -411,11 +414,20 @@ export function computeCodeHealth(complexityResult, depGraph, jsdocResult, secur
|
|
|
411
414
|
|
|
412
415
|
const hotspots = modules.filter(m => m.score < 60).slice(0, 20);
|
|
413
416
|
|
|
414
|
-
const
|
|
417
|
+
const coverage = complexityResult.coverage || null;
|
|
418
|
+
let summary = totalFiles > 0
|
|
415
419
|
? `${totalFiles} files analyzed · Average health: ${avgScore}/100 · ${gradeDistribution.A} A, ${gradeDistribution.B} B, ${gradeDistribution.C} C, ${gradeDistribution.D} D, ${gradeDistribution.F} F`
|
|
416
420
|
: "No source files analyzed.";
|
|
421
|
+
if (totalFiles > 0 && coverage?.status === "partial") {
|
|
422
|
+
summary += ` · ${unanalyzedLanguageNames(coverage)} not scored`;
|
|
423
|
+
}
|
|
424
|
+
if (totalFiles === 0 && coverage?.status === "unsupported") {
|
|
425
|
+
summary = `Not scored: ${coverageLine(coverage, "code health")}`;
|
|
426
|
+
}
|
|
417
427
|
|
|
418
|
-
if (totalFiles === 0) {
|
|
428
|
+
if (totalFiles === 0 && coverage?.status === "unsupported") {
|
|
429
|
+
info(`Code health: not scored — ${coverageLine(coverage, "code health")}`);
|
|
430
|
+
} else if (totalFiles === 0) {
|
|
419
431
|
info("Code health: no source files analyzed.");
|
|
420
432
|
} else {
|
|
421
433
|
info(`Code health: avg ${avgScore}/100 across ${totalFiles} files (${hotspots.length} hotspots)`);
|
|
@@ -424,6 +436,7 @@ export function computeCodeHealth(complexityResult, depGraph, jsdocResult, secur
|
|
|
424
436
|
return {
|
|
425
437
|
modules,
|
|
426
438
|
summary,
|
|
439
|
+
coverage,
|
|
427
440
|
stats: {
|
|
428
441
|
totalFiles,
|
|
429
442
|
avgScore,
|
|
@@ -406,7 +406,8 @@ function inferArchitecturalPatterns(modules, depGraph = null, projectKind = null
|
|
|
406
406
|
|
|
407
407
|
// Structural patterns from dep graph (not keyword-based)
|
|
408
408
|
if (depGraph?.stats) {
|
|
409
|
-
|
|
409
|
+
// Only a fully analyzed codebase can be called acyclic.
|
|
410
|
+
if (depGraph.stats.cycles === 0 && depGraph.stats.totalEdges > 10 && depGraph.coverage?.status !== "partial") {
|
|
410
411
|
patterns.push("Acyclic dependency graph — clean layering");
|
|
411
412
|
}
|
|
412
413
|
}
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
parseGoModule,
|
|
20
20
|
} from "./import-parsers.js";
|
|
21
21
|
import { isNonProductPath } from "../utils/path-roles.js";
|
|
22
|
+
import { computeLanguageCoverage, coverageLine } from "./language-coverage.js";
|
|
23
|
+
import { listWorkspacePackages } from "./workspace-packages.js";
|
|
22
24
|
import {
|
|
23
25
|
classifyRuntimeCycles,
|
|
24
26
|
priorityCounts,
|
|
@@ -72,6 +74,77 @@ function normalizeFileKey(file) {
|
|
|
72
74
|
return key;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
// Source-tree candidates for `pkg` or `pkg/subpath` inside a workspace package.
|
|
78
|
+
// `exports`/`main` usually point at build output (dist/, lib/, build/), so those
|
|
79
|
+
// paths are mapped back to src/ as well as tried as written.
|
|
80
|
+
function workspaceCandidates(pkg, subpath) {
|
|
81
|
+
const base = pkg.dir ? `${pkg.dir}/` : "";
|
|
82
|
+
const out = [];
|
|
83
|
+
const push = (rel) => {
|
|
84
|
+
if (typeof rel !== "string" || !rel) return;
|
|
85
|
+
let clean = rel.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
86
|
+
// exports/main targets must stay inside the package: no absolute paths, no `..`,
|
|
87
|
+
// no wildcards or URLs.
|
|
88
|
+
if (clean.startsWith("/") || /^[A-Za-z]:/.test(clean) || clean.includes("*") || clean.includes(":")) return;
|
|
89
|
+
if (clean.split("/").includes("..")) return;
|
|
90
|
+
clean = clean.replace(/\.d\.[cm]?ts$/, "").replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, "");
|
|
91
|
+
if (!clean || clean === ".") {
|
|
92
|
+
out.push(pkg.dir || ".");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
out.push(`${base}${clean}`);
|
|
96
|
+
const mapped = clean.replace(/^(dist|lib|build|out|esm|cjs)(\/(esm|cjs|es|src))?\//, "src/");
|
|
97
|
+
if (mapped !== clean) out.push(`${base}${mapped}`);
|
|
98
|
+
};
|
|
99
|
+
const manifest = pkg.manifest || {};
|
|
100
|
+
const exportKey = subpath ? `./${subpath}` : ".";
|
|
101
|
+
const exportsField = manifest.exports;
|
|
102
|
+
let target = null;
|
|
103
|
+
if (typeof exportsField === "string" && !subpath) target = exportsField;
|
|
104
|
+
else if (exportsField && typeof exportsField === "object") {
|
|
105
|
+
const entry = exportKey in exportsField ? exportsField[exportKey] : (!subpath && !Object.keys(exportsField).some((k) => k.startsWith(".")) ? exportsField : undefined);
|
|
106
|
+
target = pickExportTarget(entry);
|
|
107
|
+
}
|
|
108
|
+
if (target) push(target);
|
|
109
|
+
if (subpath) {
|
|
110
|
+
push(`src/${subpath}`);
|
|
111
|
+
push(subpath);
|
|
112
|
+
push(`lib/${subpath}`);
|
|
113
|
+
} else {
|
|
114
|
+
for (const field of ["source", "module", "main"]) {
|
|
115
|
+
if (typeof manifest[field] === "string") push(manifest[field]);
|
|
116
|
+
}
|
|
117
|
+
push("src/index");
|
|
118
|
+
push("index");
|
|
119
|
+
push("lib/index");
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function pickExportTarget(entry) {
|
|
125
|
+
if (typeof entry === "string") return entry;
|
|
126
|
+
if (Array.isArray(entry)) {
|
|
127
|
+
for (const item of entry) {
|
|
128
|
+
const found = pickExportTarget(item);
|
|
129
|
+
if (found) return found;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (entry && typeof entry === "object") {
|
|
134
|
+
for (const condition of ["source", "development", "import", "module", "default", "require", "node", "types"]) {
|
|
135
|
+
if (condition in entry) {
|
|
136
|
+
const found = pickExportTarget(entry[condition]);
|
|
137
|
+
if (found) return found;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const value of Object.values(entry)) {
|
|
141
|
+
const found = pickExportTarget(value);
|
|
142
|
+
if (found) return found;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
75
148
|
function emptyResult() {
|
|
76
149
|
return {
|
|
77
150
|
nodes: [],
|
|
@@ -151,8 +224,14 @@ function goFilesInDir(goFiles, dir) {
|
|
|
151
224
|
});
|
|
152
225
|
}
|
|
153
226
|
|
|
154
|
-
export async function analyzeDependencyGraph(files, repoRoot) {
|
|
227
|
+
export async function analyzeDependencyGraph(files, repoRoot, { coverageFiles = files } = {}) {
|
|
155
228
|
const result = emptyResult();
|
|
229
|
+
const coverage = computeLanguageCoverage(coverageFiles || files);
|
|
230
|
+
result.coverage = coverage;
|
|
231
|
+
if (coverage.status === "unsupported") {
|
|
232
|
+
info(`Dependency graph: not built — ${coverageLine(coverage, "import analysis")}`);
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
156
235
|
|
|
157
236
|
const jsFiles = files.filter((file) => JS_EXTENSIONS.has(path.extname(file)));
|
|
158
237
|
const pyFiles = files.filter((file) => PY_EXTENSIONS.has(path.extname(file)));
|
|
@@ -191,38 +270,66 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
191
270
|
|
|
192
271
|
// A package importing its own name (examples, docs, tests written like user code)
|
|
193
272
|
// is not a dependency. Resolve the nearest package.json for each file.
|
|
194
|
-
const
|
|
195
|
-
async function
|
|
196
|
-
let dir = path.dirname(file);
|
|
273
|
+
const packageByDir = new Map();
|
|
274
|
+
async function ownPackage(file) {
|
|
275
|
+
let dir = path.posix.dirname(String(file).replace(/\\/g, "/"));
|
|
197
276
|
const visited = [];
|
|
198
277
|
for (;;) {
|
|
199
|
-
if (
|
|
200
|
-
const cached =
|
|
201
|
-
for (const d of visited)
|
|
278
|
+
if (packageByDir.has(dir)) {
|
|
279
|
+
const cached = packageByDir.get(dir);
|
|
280
|
+
for (const d of visited) packageByDir.set(d, cached);
|
|
202
281
|
return cached;
|
|
203
282
|
}
|
|
204
283
|
visited.push(dir);
|
|
205
284
|
const raw = await readFileSafe(path.join(repoRoot, dir, "package.json"));
|
|
206
285
|
if (raw) {
|
|
207
|
-
let
|
|
286
|
+
let found = null;
|
|
208
287
|
try {
|
|
209
288
|
const parsed = JSON.parse(raw);
|
|
210
|
-
if (typeof parsed?.name === "string"
|
|
289
|
+
if (typeof parsed?.name === "string" && parsed.name) found = { name: parsed.name, manifest: parsed };
|
|
211
290
|
} catch {
|
|
212
291
|
// invalid package.json: keep walking up
|
|
213
292
|
}
|
|
214
|
-
if (
|
|
215
|
-
for (const d of visited)
|
|
216
|
-
return
|
|
293
|
+
if (found) {
|
|
294
|
+
for (const d of visited) packageByDir.set(d, found);
|
|
295
|
+
return found;
|
|
217
296
|
}
|
|
218
297
|
}
|
|
219
|
-
if (dir === "." || dir === "" || dir === path.dirname(dir)) break;
|
|
220
|
-
dir = path.dirname(dir);
|
|
298
|
+
if (dir === "." || dir === "" || dir === path.posix.dirname(dir)) break;
|
|
299
|
+
dir = path.posix.dirname(dir);
|
|
221
300
|
}
|
|
222
|
-
for (const d of visited)
|
|
301
|
+
for (const d of visited) packageByDir.set(d, null);
|
|
223
302
|
return null;
|
|
224
303
|
}
|
|
225
304
|
|
|
305
|
+
// Declared workspace members only (package.json workspaces, pnpm-workspace.yaml,
|
|
306
|
+
// lerna.json). In a monorepo, `@scope/pkg` naming a member is an internal edge.
|
|
307
|
+
const workspacePackages = new Map();
|
|
308
|
+
if (jsFiles.length > 0) {
|
|
309
|
+
const { packages } = await listWorkspacePackages(repoRoot);
|
|
310
|
+
for (const pkg of packages) workspacePackages.set(pkg.name, pkg);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function resolveWorkspaceImport(specifier, importer) {
|
|
314
|
+
const name = externalPackageName(specifier);
|
|
315
|
+
if (!name) return null;
|
|
316
|
+
const pkg = workspacePackages.get(name);
|
|
317
|
+
if (!pkg) return null;
|
|
318
|
+
// `"name": "npm:other@1"` (or a registry version of a same-named package installed
|
|
319
|
+
// under an alias) means the importer uses the registry package, not the member.
|
|
320
|
+
const manifest = importer?.manifest || {};
|
|
321
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
322
|
+
const spec = manifest[field]?.[name];
|
|
323
|
+
if (typeof spec === "string" && spec.startsWith("npm:")) return null;
|
|
324
|
+
}
|
|
325
|
+
const subpath = specifier.slice(name.length).replace(/^\/+/, "");
|
|
326
|
+
for (const candidate of workspaceCandidates(pkg, subpath)) {
|
|
327
|
+
const key = normalizeFileKey(candidate);
|
|
328
|
+
if (allFileKeys.has(key)) return { name, key };
|
|
329
|
+
}
|
|
330
|
+
return { name, key: null };
|
|
331
|
+
}
|
|
332
|
+
|
|
226
333
|
const pyIndex = indexPythonModules(pyFiles);
|
|
227
334
|
const goModulePath = goFiles.length > 0 ? await loadGoModule(repoRoot) : null;
|
|
228
335
|
|
|
@@ -255,17 +362,28 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
255
362
|
const content = await readFileSafe(path.join(repoRoot, file));
|
|
256
363
|
if (!content) continue;
|
|
257
364
|
const fromKey = normalizeFileKey(file);
|
|
258
|
-
const
|
|
365
|
+
const importer = await ownPackage(file);
|
|
366
|
+
const selfName = importer ? importer.name : null;
|
|
259
367
|
const grouped = new Map();
|
|
260
368
|
for (const imported of extractJsImports(content)) {
|
|
369
|
+
let toKey = null;
|
|
261
370
|
if (!isRelativeImport(imported.specifier)) {
|
|
262
|
-
if (imported.typeOnly) continue;
|
|
263
371
|
const name = externalPackageName(imported.specifier);
|
|
264
372
|
if (name && name === selfName) continue;
|
|
265
|
-
|
|
266
|
-
|
|
373
|
+
const workspace = resolveWorkspaceImport(imported.specifier, importer);
|
|
374
|
+
if (workspace) {
|
|
375
|
+
// Another package in this repository is internal: an edge when it resolves
|
|
376
|
+
// to a scanned file, and never an external dependency either way.
|
|
377
|
+
if (!workspace.key) continue;
|
|
378
|
+
toKey = workspace.key;
|
|
379
|
+
} else {
|
|
380
|
+
if (imported.typeOnly) continue;
|
|
381
|
+
addExternal(fromKey, name);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
toKey = resolveImportPath(file, imported.specifier);
|
|
267
386
|
}
|
|
268
|
-
const toKey = resolveImportPath(file, imported.specifier);
|
|
269
387
|
if (!toKey) continue;
|
|
270
388
|
const id = `${fromKey}->${toKey}`;
|
|
271
389
|
let slot = grouped.get(id);
|
|
@@ -509,7 +627,12 @@ function buildSummary(result) {
|
|
|
509
627
|
if (result.stats.cycles > 0) {
|
|
510
628
|
parts.push(`⚠️ ${result.stats.cycles} circular dependency cycle(s)`);
|
|
511
629
|
} else {
|
|
512
|
-
parts.push(
|
|
630
|
+
parts.push(result.coverage?.status === "partial"
|
|
631
|
+
? "✓ no circular dependencies among analyzed files"
|
|
632
|
+
: "✓ no circular dependencies");
|
|
633
|
+
}
|
|
634
|
+
if (result.coverage?.status === "partial") {
|
|
635
|
+
parts.push(`partial coverage: ${result.coverage.analyzedBase} of ${result.coverage.totalBase} source files`);
|
|
513
636
|
}
|
|
514
637
|
if (result.stats.orphanFiles > 0) {
|
|
515
638
|
parts.push(`${result.stats.orphanFiles} orphan file(s)`);
|
|
@@ -114,7 +114,7 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
|
|
|
114
114
|
// Classify each step in the chain
|
|
115
115
|
const steps = chain.map((nodeKey, i) => {
|
|
116
116
|
const node = depGraph.nodes.find(n => n.key === nodeKey);
|
|
117
|
-
const name = nodeKey
|
|
117
|
+
const name = stepName(nodeKey);
|
|
118
118
|
const fanIn = node?.importedBy?.length || 0;
|
|
119
119
|
const fanOut = node?.imports?.length || 0;
|
|
120
120
|
|
|
@@ -342,3 +342,12 @@ function inferExternalDependencies(flow) {
|
|
|
342
342
|
|
|
343
343
|
return external;
|
|
344
344
|
}
|
|
345
|
+
|
|
346
|
+
// A package entry is keyed by its folder (`packages/server/src` for src/index.ts), so
|
|
347
|
+
// "src" or "lib" alone says nothing. Name it after the package folder instead.
|
|
348
|
+
function stepName(nodeKey) {
|
|
349
|
+
const parts = String(nodeKey).split("/");
|
|
350
|
+
const last = parts.pop();
|
|
351
|
+
if (/^(src|lib|source)$/i.test(last) && parts.length > 0) return `${parts[parts.length - 1]}/${last}`;
|
|
352
|
+
return last;
|
|
353
|
+
}
|