@chappibunny/repolens 1.16.2 → 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 CHANGED
@@ -2,6 +2,37 @@
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
+
5
36
  ## 1.16.2
6
37
 
7
38
  ### Branding
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. Code health scores JavaScript/TypeScript, Python, and Go.
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.2`). `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`.
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.2`):
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.2
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.2` or a commit SHA in production.
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.2
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.2` has to exist before `uses: CHAPIBUNNY/repolens@v1.16.2` resolves, and `@chappibunny/repolens@1.16.2` has to be on npm before `publish` can install it. `command: comment` and `command: badge` do not install that package.
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.2
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@chappibunny/repolens",
3
- "version": "1.16.2",
3
+ "version": "1.16.3",
4
4
  "mcpName": "io.github.CHAPIBUNNY/repolens",
5
5
  "description": "AI-assisted documentation intelligence system for technical and non-technical audiences",
6
6
  "license": "MIT",
@@ -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 ? "✅ Clean" : "⚠️ Needs attention"} |\n`;
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 ? "Clean module boundaries" : archCycle.advice} |\n`;
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("**Clean module boundaries** — No circular dependencies detected. This enables safe refactoring, isolated testing, and clear ownership boundaries.");
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 sourceFiles = files
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 summary = totalFiles > 0
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
- if (depGraph.stats.cycles === 0 && depGraph.stats.totalEdges > 10) {
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 packageNameByDir = new Map();
195
- async function ownPackageName(file) {
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 (packageNameByDir.has(dir)) {
200
- const cached = packageNameByDir.get(dir);
201
- for (const d of visited) packageNameByDir.set(d, cached);
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 name = null;
286
+ let found = null;
208
287
  try {
209
288
  const parsed = JSON.parse(raw);
210
- if (typeof parsed?.name === "string") name = parsed.name;
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 (name) {
215
- for (const d of visited) packageNameByDir.set(d, name);
216
- return name;
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) packageNameByDir.set(d, null);
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 selfName = await ownPackageName(file);
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
- addExternal(fromKey, name);
266
- continue;
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("✓ no circular dependencies");
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.split("/").pop();
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
+ }