@chappibunny/repolens 1.16.0 → 1.16.2
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 +34 -0
- package/README.md +8 -8
- package/llms-install.md +1 -0
- package/package.json +1 -1
- package/src/ai/generate-sections.js +11 -8
- package/src/analyzers/cycle-priority.js +169 -0
- package/src/analyzers/dependency-graph.js +94 -5
- package/src/analyzers/import-parsers.js +48 -8
- package/src/badge.js +3 -0
- package/src/core/config-schema.js +4 -0
- package/src/core/scan-defaults.js +36 -0
- package/src/core/scan.js +2 -1
- package/src/renderers/renderAnalysis.js +16 -4
- package/src/utils/path-roles.js +29 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to RepoLens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.16.2
|
|
6
|
+
|
|
7
|
+
### Branding
|
|
8
|
+
|
|
9
|
+
- 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).
|
|
10
|
+
- The gallery site adds `favicon.ico` (16 and 32 px) next to the PNG icon.
|
|
11
|
+
- The VS Code extension (0.2.4) uses the same icon.
|
|
12
|
+
|
|
13
|
+
## 1.16.1
|
|
14
|
+
|
|
15
|
+
Fixes three problems found by running 1.16.0 on orval-labs/orval (a TypeScript monorepo whose test snapshots and samples dwarf the packages).
|
|
16
|
+
|
|
17
|
+
### Scan defaults
|
|
18
|
+
|
|
19
|
+
- Snapshot directories (`__snapshots__`, `*.snap`), sample / fixture / example / e2e-fixture directories, and `generated/` trees are dev tooling. They are left out of the file count, orphan detection, and the type graph unless `scan.include_dev_artifacts: true`. Other `scan.ignore` patterns still apply. Orval's second copy of the snapshot tree lives in `tests/generated`, so `**/generated/**` is part of that default (it was already in the `init` ignore list).
|
|
20
|
+
- `repolens badge` skips the same directories. It does not read `.repolens.yml`.
|
|
21
|
+
|
|
22
|
+
### Cycle priority
|
|
23
|
+
|
|
24
|
+
- A cycle is no longer always "Priority: High refactor".
|
|
25
|
+
- **Low — mutual recursion, probably intentional; verify** when the cycle stays inside two parent directories that import each other both ways (at least two runtime edges each way) and both directions call the imported bindings (at least two calls each way). Orval's core getters and resolvers are this shape.
|
|
26
|
+
- **Low — type-only imports; verify** when every edge is `import type` or `export type`. Those cycles are not runtime edges and are not counted in `stats.cycles`.
|
|
27
|
+
- **High refactor** is kept for a cycle that touches three or more parent directories.
|
|
28
|
+
- A two-directory cycle with only one import each way is **Medium**.
|
|
29
|
+
- The health score subtracts points for high and medium cycles. Low-priority cycles do not. Stats that do not carry the priority split keep the previous per-cycle penalty.
|
|
30
|
+
- The heuristic is documented in `src/analyzers/cycle-priority.js`, [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), and [docs/KNOWN_ISSUES.md](docs/KNOWN_ISSUES.md).
|
|
31
|
+
|
|
32
|
+
### npx and devEngines
|
|
33
|
+
|
|
34
|
+
- npm versions that enforce `devEngines` (newer npm) check that field on the package.json in the current directory before `npx` starts the requested package. A repo that pins a Node or package-manager the host does not meet exits `EBADDEVENGINES`. Node 20's bundled npm does not enforce `devEngines`. RepoLens never gets to run when the check fails, so this cannot be fixed inside the CLI.
|
|
35
|
+
- `--ignore-engines` is not an npm flag. On npm versions that enforce `devEngines`, npm warns that it is an unknown config and still errors.
|
|
36
|
+
- `--force` turns that failure into a warning and runs the command. The GitHub Action passes `--force` on its `npx` step. A manual run is `npx --force --yes @chappibunny/repolens demo`. A global install, or `node node_modules/@chappibunny/repolens/bin/repolens.js`, never asks npm to read the scanned repo's `devEngines`.
|
|
37
|
+
- See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
|
|
38
|
+
|
|
5
39
|
## 1.16.0
|
|
6
40
|
|
|
7
41
|
### GitHub App
|
package/README.md
CHANGED
|
@@ -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 --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.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`.
|
|
247
247
|
|
|
248
|
-
Publishing docs (the npm package at tag `v1.16.
|
|
248
|
+
Publishing docs (the npm package at tag `v1.16.2`):
|
|
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.2
|
|
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.2` 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.2
|
|
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.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.
|
|
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.2
|
|
334
334
|
with:
|
|
335
335
|
command: badge
|
|
336
336
|
badge-url: https://USER.github.io/REPO/repolens-badge.json
|
|
@@ -359,7 +359,7 @@ Gallery repositories already publish this. Each one has a [score page](https://c
|
|
|
359
359
|
|
|
360
360
|
Published system maps are Unicode diagrams drawn from import edges. RepoLens does not emit Mermaid.
|
|
361
361
|
|
|
362
|
-
The publisher set is Notion, Confluence, GitHub Wiki, Obsidian, and Markdown (`src/publishers`, 7 files). 1.14.0 adds the MCP server, the Marketplace action, and the docs badge. 1.14.1 sets the registry name to `io.github.CHAPIBUNNY/repolens`. 1.14.2 adds the gallery link to the badge line, `glama.json`, and `CITATION.cff`. 1.14.3 adds Cline setup docs and `llms-install.md`. 1.15.0 detects repository languages in `init --quick`, refuses empty scans, and adds per-command help. 1.15.1 makes `watch` exit 2 when those globs match nothing, and scores code health for Python and Go as well as JavaScript and TypeScript. 1.15.2 reads Python and Go imports in the dependency graph, skips comment and type-only imports, adds `repolens badge` and `repolens comment`, and adds gallery explained pages plus `llms.txt`. 1.15.3 skips Python `TYPE_CHECKING` imports, omits Node.js built-ins from external packages, groups Go hubs by package, and ignores test files as architecture-pattern evidence. 1.15.4 names the project from `package.json`, scores maturity from version, history, tests, CI, and docs, reports circular dependencies as findings, drops self-imports and noise data flows, and stops classifying `index` and `core` files by name. 1.15.5 reads a flat `src/` as one module, tells a library from a CLI by its `package.json`, splits runtime from development packages, keeps tests, examples, and build scripts out of hubs and security severity, and skips empty Route Map and GraphQL pages. 1.16.0 adds `repolens app` (a self-hosted GitHub App webhook), names a flat package after its directory, treats a framework description as a library when `main` is absent, and keeps test and example routes out of the product API. Layout and counts are in [ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
362
|
+
The publisher set is Notion, Confluence, GitHub Wiki, Obsidian, and Markdown (`src/publishers`, 7 files). 1.14.0 adds the MCP server, the Marketplace action, and the docs badge. 1.14.1 sets the registry name to `io.github.CHAPIBUNNY/repolens`. 1.14.2 adds the gallery link to the badge line, `glama.json`, and `CITATION.cff`. 1.14.3 adds Cline setup docs and `llms-install.md`. 1.15.0 detects repository languages in `init --quick`, refuses empty scans, and adds per-command help. 1.15.1 makes `watch` exit 2 when those globs match nothing, and scores code health for Python and Go as well as JavaScript and TypeScript. 1.15.2 reads Python and Go imports in the dependency graph, skips comment and type-only imports, adds `repolens badge` and `repolens comment`, and adds gallery explained pages plus `llms.txt`. 1.15.3 skips Python `TYPE_CHECKING` imports, omits Node.js built-ins from external packages, groups Go hubs by package, and ignores test files as architecture-pattern evidence. 1.15.4 names the project from `package.json`, scores maturity from version, history, tests, CI, and docs, reports circular dependencies as findings, drops self-imports and noise data flows, and stops classifying `index` and `core` files by name. 1.15.5 reads a flat `src/` as one module, tells a library from a CLI by its `package.json`, splits runtime from development packages, keeps tests, examples, and build scripts out of hubs and security severity, and skips empty Route Map and GraphQL pages. 1.16.0 adds `repolens app` (a self-hosted GitHub App webhook), names a flat package after its directory, treats a framework description as a library when `main` is absent, and keeps test and example routes out of the product API. 1.16.1 skips snapshot, sample, fixture, example, and generated trees in the file count, orphan list, and type graph, and labels a call-heavy two-folder cycle as mutual recursion rather than a high-priority refactor. Layout and counts are in [ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
363
363
|
|
|
364
364
|
### System Overview (Technical Profile)
|
|
365
365
|
|
package/llms-install.md
CHANGED
|
@@ -22,3 +22,4 @@
|
|
|
22
22
|
13. CLI (1.15.4): the project name comes from `package.json` or the real directory (never `.`). Maturity uses version, history, tests, CI, and docs. Circular dependencies are findings, not architectural patterns. A package's own name is not an external dependency. `index` and `main` files are not classified by name. Noise data flows are dropped.
|
|
23
23
|
14. CLI (1.15.5): a flat `src/` is one module. A package with `main`, `module`, or `exports` is a library, not a CLI. Hubs and security severity ignore tests, examples, and build scripts. External packages are split into runtime and development tooling. Route Map and GraphQL Schema are skipped when empty.
|
|
24
24
|
15. CLI (1.16.0): `repolens app` self-hosts a GitHub App webhook (manifest in `app.yml`, check run plus the sticky architecture comment). A framework description is a library even without `main`. A flat package is named after its directory. Test and example routes are not the product API. Test-only imports are dev tooling on Python and Go.
|
|
25
|
+
16. CLI (1.16.1): snapshot, sample, fixture, example, and generated directories are out of the file count, orphan list, and type graph unless `scan.include_dev_artifacts` is true. A two-folder call-heavy cycle is "mutual recursion, probably intentional; verify", not "Priority: High refactor". On npm versions that enforce `devEngines` (newer npm), `npx` inside a repo with `devEngines` needs `--force`. Node 20's bundled npm does not enforce that field. `--ignore-engines` does not skip the check.
|
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ import { identifyFlowDependencies, filterNoiseFlows } from "../analyzers/flow-in
|
|
|
16
16
|
import { isCliProject, isLibraryProject } from "../analyzers/context-builder.js";
|
|
17
17
|
import { scoreMaturity } from "../analyzers/maturity.js";
|
|
18
18
|
import { isNonProductPath, isTestPath } from "../utils/path-roles.js";
|
|
19
|
+
import { cycleConcern, cycleHealthPenalty, cycleInsight, cycleStatusCell } from "../analyzers/cycle-priority.js";
|
|
19
20
|
import { info, warn } from "../utils/logger.js";
|
|
20
21
|
|
|
21
22
|
// Strip conversational patterns that LLMs sometimes inject into documentation
|
|
@@ -288,7 +289,8 @@ The system is organized around **${context.domains.length} functional domains**,
|
|
|
288
289
|
output += `|--------|-------|--------|\n`;
|
|
289
290
|
output += `| Internal imports | ${stats.totalEdges} edges | ${stats.totalEdges > 0 ? "✅ Connected" : "⚠️ Isolated"} |\n`;
|
|
290
291
|
output += `| External Packages | ${externalPackagesLabel(stats)} | ${stats.externalDeps < 50 ? "✅ Manageable" : stats.externalDeps < 100 ? "⚠️ Consider audit" : "🔴 High dependency count"} |\n`;
|
|
291
|
-
|
|
292
|
+
const cycleCell = cycleStatusCell(stats);
|
|
293
|
+
output += `| Circular Dependencies | ${stats.cycles === 0 ? "cycle-free" : stats.cycles} | ${stats.cycles === 0 ? "✅ None" : `${cycleCell.mark} ${stats.cycles} detected`} |\n`;
|
|
292
294
|
output += `| Orphan Files | ${stats.orphanFiles} | ${orphanStatus(stats)} |\n`;
|
|
293
295
|
|
|
294
296
|
// Key insights
|
|
@@ -296,7 +298,7 @@ The system is organized around **${context.domains.length} functional domains**,
|
|
|
296
298
|
if (stats.cycles === 0) {
|
|
297
299
|
output += `- ✅ **Clean module boundaries** — No circular dependencies detected, enabling safe refactoring and testing.\n`;
|
|
298
300
|
} else {
|
|
299
|
-
output += `-
|
|
301
|
+
output += `- ${cycleInsight(stats)}\n`;
|
|
300
302
|
}
|
|
301
303
|
if (stats.hubs && stats.hubs.length > 0) {
|
|
302
304
|
const topHub = stats.hubs[0];
|
|
@@ -828,7 +830,8 @@ function getFallbackArchitectureOverview(context, enrichment = {}) {
|
|
|
828
830
|
output += `|--------|-------|--------|----------------|\n`;
|
|
829
831
|
output += `| Import Edges | ${stats.totalEdges} | ${stats.totalEdges > 0 ? "✅" : "⚠️"} | ${stats.totalEdges > 0 ? "Healthy module connectivity" : "Consider connecting modules"} |\n`;
|
|
830
832
|
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`;
|
|
831
|
-
|
|
833
|
+
const archCycle = cycleStatusCell(stats);
|
|
834
|
+
output += `| Circular Deps | ${stats.cycles} | ${stats.cycles === 0 ? "✅" : archCycle.mark} | ${stats.cycles === 0 ? "Clean module boundaries" : archCycle.advice} |\n`;
|
|
832
835
|
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`;
|
|
833
836
|
|
|
834
837
|
// Architectural strengths and concerns
|
|
@@ -856,9 +859,8 @@ function getFallbackArchitectureOverview(context, enrichment = {}) {
|
|
|
856
859
|
|
|
857
860
|
// Concerns
|
|
858
861
|
const concerns = [];
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
}
|
|
862
|
+
const concern = cycleConcern(stats);
|
|
863
|
+
if (concern) concerns.push(concern);
|
|
862
864
|
if (stats.orphanFiles > stats.totalFiles * 0.2) {
|
|
863
865
|
concerns.push(`**High orphan file ratio** — ${stats.orphanFiles}/${stats.totalFiles} files (${Math.round(stats.orphanFiles / stats.totalFiles * 100)}%) are not imported. Consider removing dead code. Priority: Medium.`);
|
|
864
866
|
}
|
|
@@ -1625,8 +1627,9 @@ function externalPackagesLabel(stats) {
|
|
|
1625
1627
|
function calculateHealthScore(stats, context) {
|
|
1626
1628
|
let score = 100;
|
|
1627
1629
|
|
|
1628
|
-
//
|
|
1629
|
-
|
|
1630
|
+
// High and medium cycles cost points. Mutual recursion and type-only cycles do not.
|
|
1631
|
+
// A stats object without the priority split keeps the older per-cycle penalty.
|
|
1632
|
+
score -= cycleHealthPenalty(stats);
|
|
1630
1633
|
|
|
1631
1634
|
// Orphan files indicate potential dead code
|
|
1632
1635
|
const ratio = orphanRatio(stats);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Cycle priority.
|
|
2
|
+
//
|
|
3
|
+
// Not every circular import is a refactor. The heuristic:
|
|
4
|
+
//
|
|
5
|
+
// 1. Type-only. Every edge in the cycle is `import type` or `export type`.
|
|
6
|
+
// There is no runtime cycle. Priority: Low. Wording: type-only imports; verify.
|
|
7
|
+
// These stay out of the runtime cycle list (`cycles` / `stats.cycles`).
|
|
8
|
+
//
|
|
9
|
+
// 2. Recursive-descent pair (mutual recursion). The cycle stays inside two parent
|
|
10
|
+
// directories, and those two directories import each other in both directions
|
|
11
|
+
// with many edges (at least 2 each way) and both directions are call-heavy
|
|
12
|
+
// (at least 2 calls of the imported bindings each way). Schema getters calling
|
|
13
|
+
// resolvers that call getters is this shape. The pair is small: two folders,
|
|
14
|
+
// not a chain across the repo. Priority: Low. Wording: mutual recursion,
|
|
15
|
+
// probably intentional; verify.
|
|
16
|
+
//
|
|
17
|
+
// 3. Large multi-module cycle. The cycle touches 3 or more parent directories.
|
|
18
|
+
// Priority: High refactor.
|
|
19
|
+
//
|
|
20
|
+
// 4. Anything else (a short accidental cycle, one import each way, no real call
|
|
21
|
+
// traffic) is Medium: review, not a blanket high-priority refactor.
|
|
22
|
+
//
|
|
23
|
+
// "Folder" is the file's parent directory. `index.ts` stays in its own directory
|
|
24
|
+
// after the graph strips the `/index` suffix. Call counts use ESM bindings
|
|
25
|
+
// (`import { fn }` then `fn(` or `ns.fn(`). A `require()` edge has no binding
|
|
26
|
+
// names, so it does not count as call-heavy.
|
|
27
|
+
|
|
28
|
+
export const MUTUAL_EDGE_MINIMUM = 2;
|
|
29
|
+
export const MUTUAL_CALL_MINIMUM = 2;
|
|
30
|
+
export const HIGH_FOLDER_MINIMUM = 3;
|
|
31
|
+
|
|
32
|
+
export function folderOfFile(file) {
|
|
33
|
+
const norm = String(file || "").replace(/\\/g, "/");
|
|
34
|
+
const slash = norm.lastIndexOf("/");
|
|
35
|
+
if (slash <= 0) return ".";
|
|
36
|
+
return norm.slice(0, slash);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cycleNodes(path) {
|
|
40
|
+
if (!Array.isArray(path) || path.length < 2) return [];
|
|
41
|
+
return path[0] === path[path.length - 1] ? path.slice(0, -1) : path.slice();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function report(path, priority, kind, label) {
|
|
45
|
+
return { path, priority, kind, label, priorityLine: priorityLine(priority, label) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function priorityLine(priority, label) {
|
|
49
|
+
if (priority === "high") return "Priority: High refactor";
|
|
50
|
+
const text = label || "review";
|
|
51
|
+
const name = priority === "low" ? "Low" : "Medium";
|
|
52
|
+
return `Priority: ${name} — ${text}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Classify one runtime cycle.
|
|
57
|
+
* `edges` are runtime import edges: { from, to, fromFile, toFile, calls }.
|
|
58
|
+
* `fileForKey(key)` returns the source path used for the parent directory.
|
|
59
|
+
*/
|
|
60
|
+
export function classifyRuntimeCycle(path, edges, fileForKey) {
|
|
61
|
+
const nodes = cycleNodes(path);
|
|
62
|
+
const folders = nodes.map((key) => folderOfFile(fileForKey(key)));
|
|
63
|
+
const uniqueFolders = [...new Set(folders)];
|
|
64
|
+
|
|
65
|
+
if (uniqueFolders.length >= HIGH_FOLDER_MINIMUM) {
|
|
66
|
+
return report(path, "high", "multi-module", "large multi-module cycle");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (uniqueFolders.length === 2 && isRecursiveDescentPair(uniqueFolders[0], uniqueFolders[1], edges)) {
|
|
70
|
+
return report(path, "low", "mutual-recursion", "mutual recursion, probably intentional; verify");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return report(path, "medium", "small", "small cycle; review");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isRecursiveDescentPair(folderA, folderB, edges) {
|
|
77
|
+
let ab = 0;
|
|
78
|
+
let ba = 0;
|
|
79
|
+
let callsAB = 0;
|
|
80
|
+
let callsBA = 0;
|
|
81
|
+
for (const edge of edges || []) {
|
|
82
|
+
if (edge.typeOnly) continue;
|
|
83
|
+
const from = folderOfFile(edge.fromFile || edge.from);
|
|
84
|
+
const to = folderOfFile(edge.toFile || edge.to);
|
|
85
|
+
if (from === folderA && to === folderB) {
|
|
86
|
+
ab += 1;
|
|
87
|
+
callsAB += edge.calls || 0;
|
|
88
|
+
} else if (from === folderB && to === folderA) {
|
|
89
|
+
ba += 1;
|
|
90
|
+
callsBA += edge.calls || 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return ab >= MUTUAL_EDGE_MINIMUM
|
|
94
|
+
&& ba >= MUTUAL_EDGE_MINIMUM
|
|
95
|
+
&& callsAB >= MUTUAL_CALL_MINIMUM
|
|
96
|
+
&& callsBA >= MUTUAL_CALL_MINIMUM;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function classifyRuntimeCycles(cycles, edges, fileForKey) {
|
|
100
|
+
return (cycles || []).map((path) => classifyRuntimeCycle(path, edges, fileForKey));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function typeOnlyReport(path) {
|
|
104
|
+
return report(path, "low", "type-only", "type-only imports; verify");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function priorityCounts(reports) {
|
|
108
|
+
const counts = { high: 0, medium: 0, low: 0 };
|
|
109
|
+
for (const item of reports || []) {
|
|
110
|
+
if (item.priority === "high") counts.high += 1;
|
|
111
|
+
else if (item.priority === "low") counts.low += 1;
|
|
112
|
+
else counts.medium += 1;
|
|
113
|
+
}
|
|
114
|
+
return counts;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function hasCyclePriorities(stats) {
|
|
118
|
+
return Boolean(stats) && (stats.highCycles != null || stats.mediumCycles != null || stats.lowCycles != null);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Points removed from the architecture health score. Low-priority cycles do not count. */
|
|
122
|
+
export function cycleHealthPenalty(stats) {
|
|
123
|
+
if (!stats) return 0;
|
|
124
|
+
if (!hasCyclePriorities(stats)) {
|
|
125
|
+
return stats.cycles > 0 ? Math.min(30, stats.cycles * 10) : 0;
|
|
126
|
+
}
|
|
127
|
+
const high = stats.highCycles || 0;
|
|
128
|
+
const medium = stats.mediumCycles || 0;
|
|
129
|
+
return Math.min(30, high * 10 + medium * 5);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function cycleStatusCell(stats) {
|
|
133
|
+
if (!stats || stats.cycles === 0) return { mark: "✅", advice: "Clean module boundaries" };
|
|
134
|
+
if (hasCyclePriorities(stats) && (stats.highCycles || 0) === 0 && (stats.lowCycles || 0) > 0 && !(stats.mediumCycles > 0)) {
|
|
135
|
+
return { mark: "🟡", advice: "mutual recursion, probably intentional; verify" };
|
|
136
|
+
}
|
|
137
|
+
if (hasCyclePriorities(stats) && (stats.highCycles || 0) === 0) {
|
|
138
|
+
return { mark: "🟡", advice: "Small cycle; review" };
|
|
139
|
+
}
|
|
140
|
+
return { mark: "🔴", advice: "Refactor to break cycles" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function cycleConcern(stats) {
|
|
144
|
+
if (!stats || !(stats.cycles > 0)) return null;
|
|
145
|
+
const noun = `circular dependenc${stats.cycles === 1 ? "y" : "ies"}`;
|
|
146
|
+
if (!hasCyclePriorities(stats)) {
|
|
147
|
+
return `**Circular dependencies detected** — ${stats.cycles} ${noun} create tight coupling, complicate testing, and hinder refactoring. Priority: High.`;
|
|
148
|
+
}
|
|
149
|
+
if ((stats.highCycles || 0) > 0) {
|
|
150
|
+
const high = stats.highCycles;
|
|
151
|
+
const highNoun = `circular dependenc${high === 1 ? "y" : "ies"}`;
|
|
152
|
+
return `**Circular dependencies detected** — ${high} large multi-module ${highNoun}. Priority: High refactor.`;
|
|
153
|
+
}
|
|
154
|
+
if ((stats.lowCycles || 0) > 0 && !(stats.mediumCycles > 0)) {
|
|
155
|
+
return `**Circular dependencies detected** — ${stats.cycles} ${noun}. mutual recursion, probably intentional; verify. Priority: Low.`;
|
|
156
|
+
}
|
|
157
|
+
return `**Circular dependencies detected** — ${stats.cycles} ${noun}. Priority: Medium.`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function cycleInsight(stats) {
|
|
161
|
+
if (!stats || !(stats.cycles > 0)) return null;
|
|
162
|
+
if (!hasCyclePriorities(stats)) {
|
|
163
|
+
return `🔴 **Architectural concern** — ${stats.cycles} circular dependenc${stats.cycles === 1 ? "y" : "ies"} detected. These create tight coupling and make testing difficult.`;
|
|
164
|
+
}
|
|
165
|
+
if ((stats.highCycles || 0) === 0) {
|
|
166
|
+
return `🟡 **Circular dependencies** — ${stats.cycles} detected. mutual recursion, probably intentional; verify. Priority: Low.`;
|
|
167
|
+
}
|
|
168
|
+
return `🔴 **Architectural concern** — ${stats.highCycles} large multi-module circular dependenc${stats.highCycles === 1 ? "y" : "ies"} detected. Priority: High refactor.`;
|
|
169
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Dependency graph analysis with cycle detection.
|
|
2
2
|
// JavaScript, TypeScript, Python, and Go imports become edges.
|
|
3
|
-
// Comments and strings are ignored. Type-only TypeScript imports are not edges.
|
|
3
|
+
// Comments and strings are ignored. Type-only TypeScript imports are not runtime edges.
|
|
4
|
+
// Cycle priority is in cycle-priority.js: mutual recursion and type-only cycles are
|
|
5
|
+
// not "Priority: High refactor". Large multi-module cycles stay high.
|
|
4
6
|
|
|
5
7
|
import fs from "node:fs/promises";
|
|
6
8
|
import path from "node:path";
|
|
@@ -17,6 +19,11 @@ import {
|
|
|
17
19
|
parseGoModule,
|
|
18
20
|
} from "./import-parsers.js";
|
|
19
21
|
import { isNonProductPath } from "../utils/path-roles.js";
|
|
22
|
+
import {
|
|
23
|
+
classifyRuntimeCycles,
|
|
24
|
+
priorityCounts,
|
|
25
|
+
typeOnlyReport,
|
|
26
|
+
} from "./cycle-priority.js";
|
|
20
27
|
|
|
21
28
|
const JS_EXTENSIONS = new Set([".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"]);
|
|
22
29
|
const PY_EXTENSIONS = new Set([".py", ".pyw"]);
|
|
@@ -70,6 +77,8 @@ function emptyResult() {
|
|
|
70
77
|
nodes: [],
|
|
71
78
|
edges: [],
|
|
72
79
|
cycles: [],
|
|
80
|
+
cycleReports: [],
|
|
81
|
+
typeOnlyReports: [],
|
|
73
82
|
externalDeps: [],
|
|
74
83
|
externalRuntime: [],
|
|
75
84
|
externalDev: [],
|
|
@@ -78,6 +87,23 @@ function emptyResult() {
|
|
|
78
87
|
};
|
|
79
88
|
}
|
|
80
89
|
|
|
90
|
+
/** How many times imported bindings are called. `foo(`, `foo.bar(`, `ns?.method(`. */
|
|
91
|
+
export function countBindingCalls(source, names) {
|
|
92
|
+
if (!names || names.length === 0) return 0;
|
|
93
|
+
const stripped = String(source)
|
|
94
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
95
|
+
.replace(/(^|[^:\\])\/\/.*$/gm, "$1");
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
|
|
99
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
100
|
+
const direct = stripped.match(new RegExp(`\\b${escaped}\\s*(?:\\?\\.\\s*)?\\(`, "g"));
|
|
101
|
+
const member = stripped.match(new RegExp(`\\b${escaped}\\s*(?:\\?\\.|\\.)\\s*[A-Za-z_$][\\w$]*\\s*\\(`, "g"));
|
|
102
|
+
total += (direct ? direct.length : 0) + (member ? member.length : 0);
|
|
103
|
+
}
|
|
104
|
+
return total;
|
|
105
|
+
}
|
|
106
|
+
|
|
81
107
|
async function loadGoModule(repoRoot) {
|
|
82
108
|
const text = await readFileSafe(path.join(repoRoot, "go.mod"));
|
|
83
109
|
return parseGoModule(text);
|
|
@@ -200,21 +226,66 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
200
226
|
const pyIndex = indexPythonModules(pyFiles);
|
|
201
227
|
const goModulePath = goFiles.length > 0 ? await loadGoModule(repoRoot) : null;
|
|
202
228
|
|
|
229
|
+
// Runtime edges carry call counts. Type-only edges stay off the runtime graph
|
|
230
|
+
// and are classified on their own so they are never "Priority: High refactor".
|
|
231
|
+
const edgeRecords = new Map();
|
|
232
|
+
|
|
233
|
+
function rememberEdge(fromKey, toKey, fromFile, typeOnly, calls) {
|
|
234
|
+
if (!toKey || toKey === fromKey || !allFileKeys.has(toKey)) return;
|
|
235
|
+
const id = `${fromKey}->${toKey}`;
|
|
236
|
+
let record = edgeRecords.get(id);
|
|
237
|
+
if (!record) {
|
|
238
|
+
record = {
|
|
239
|
+
from: fromKey,
|
|
240
|
+
to: toKey,
|
|
241
|
+
fromFile,
|
|
242
|
+
toFile: fileKeyToFile.get(toKey) || toKey,
|
|
243
|
+
typeOnly: true,
|
|
244
|
+
calls: 0,
|
|
245
|
+
};
|
|
246
|
+
edgeRecords.set(id, record);
|
|
247
|
+
}
|
|
248
|
+
if (!typeOnly) {
|
|
249
|
+
record.typeOnly = false;
|
|
250
|
+
record.calls += calls;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
203
254
|
for (const file of jsFiles) {
|
|
204
255
|
const content = await readFileSafe(path.join(repoRoot, file));
|
|
205
256
|
if (!content) continue;
|
|
206
257
|
const fromKey = normalizeFileKey(file);
|
|
207
258
|
const selfName = await ownPackageName(file);
|
|
259
|
+
const grouped = new Map();
|
|
208
260
|
for (const imported of extractJsImports(content)) {
|
|
209
|
-
if (imported.
|
|
210
|
-
|
|
211
|
-
addEdge(fromKey, resolveImportPath(file, imported.specifier));
|
|
212
|
-
} else {
|
|
261
|
+
if (!isRelativeImport(imported.specifier)) {
|
|
262
|
+
if (imported.typeOnly) continue;
|
|
213
263
|
const name = externalPackageName(imported.specifier);
|
|
214
264
|
if (name && name === selfName) continue;
|
|
215
265
|
addExternal(fromKey, name);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const toKey = resolveImportPath(file, imported.specifier);
|
|
269
|
+
if (!toKey) continue;
|
|
270
|
+
const id = `${fromKey}->${toKey}`;
|
|
271
|
+
let slot = grouped.get(id);
|
|
272
|
+
if (!slot) {
|
|
273
|
+
slot = { toKey, typeOnly: true, names: new Set() };
|
|
274
|
+
grouped.set(id, slot);
|
|
275
|
+
}
|
|
276
|
+
if (!imported.typeOnly) {
|
|
277
|
+
slot.typeOnly = false;
|
|
278
|
+
for (const binding of imported.names || []) slot.names.add(binding);
|
|
216
279
|
}
|
|
217
280
|
}
|
|
281
|
+
for (const slot of grouped.values()) {
|
|
282
|
+
const calls = slot.typeOnly ? 0 : countBindingCalls(content, [...slot.names]);
|
|
283
|
+
rememberEdge(fromKey, slot.toKey, file, slot.typeOnly, calls);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const record of edgeRecords.values()) {
|
|
288
|
+
if (!record.typeOnly) addEdge(record.from, record.to);
|
|
218
289
|
}
|
|
219
290
|
|
|
220
291
|
for (const file of pyFiles) {
|
|
@@ -275,6 +346,21 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
275
346
|
// Detect cycles using iterative DFS (Tarjan-lite / coloring)
|
|
276
347
|
result.cycles = detectCycles(adjacency);
|
|
277
348
|
|
|
349
|
+
const fileForKey = (key) => fileKeyToFile.get(key) || key;
|
|
350
|
+
const runtimeEdgeList = [...edgeRecords.values()].filter((edge) => !edge.typeOnly);
|
|
351
|
+
result.cycleReports = classifyRuntimeCycles(result.cycles, runtimeEdgeList, fileForKey);
|
|
352
|
+
|
|
353
|
+
const typeOnlyAdj = new Map();
|
|
354
|
+
for (const edge of edgeRecords.values()) {
|
|
355
|
+
if (!edge.typeOnly) continue;
|
|
356
|
+
if (!typeOnlyAdj.has(edge.from)) typeOnlyAdj.set(edge.from, new Set());
|
|
357
|
+
if (!typeOnlyAdj.has(edge.to)) typeOnlyAdj.set(edge.to, new Set());
|
|
358
|
+
typeOnlyAdj.get(edge.from).add(edge.to);
|
|
359
|
+
}
|
|
360
|
+
result.typeOnlyReports = detectCycles(typeOnlyAdj).map((path) => typeOnlyReport(path));
|
|
361
|
+
|
|
362
|
+
const priorities = priorityCounts(result.cycleReports);
|
|
363
|
+
|
|
278
364
|
// Stats
|
|
279
365
|
const totalImports = result.edges.length;
|
|
280
366
|
const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0);
|
|
@@ -295,6 +381,9 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
295
381
|
externalRuntime: externalRuntime.length,
|
|
296
382
|
externalDev: externalDev.length,
|
|
297
383
|
cycles: result.cycles.length,
|
|
384
|
+
highCycles: priorities.high,
|
|
385
|
+
mediumCycles: priorities.medium,
|
|
386
|
+
lowCycles: priorities.low,
|
|
298
387
|
orphanFiles: orphans.length,
|
|
299
388
|
hubs,
|
|
300
389
|
};
|
|
@@ -147,6 +147,33 @@ function braceIsTypeOnly(inner) {
|
|
|
147
147
|
return parts.every((part) => /^type\s+/.test(part) && !/^type\s+as\b/.test(part));
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/** Local bindings in an import brace. `type Foo` is skipped. `foo as bar` yields `bar`. */
|
|
151
|
+
function localNames(inner) {
|
|
152
|
+
const names = [];
|
|
153
|
+
for (const part of splitTopCommas(inner)) {
|
|
154
|
+
const trimmed = part.trim().replace(/\s+/g, " ");
|
|
155
|
+
if (!trimmed || /^type\s+/.test(trimmed)) continue;
|
|
156
|
+
const asMatch = trimmed.match(/^[A-Za-z_$][\w$]*\s+as\s+([A-Za-z_$][\w$]*)$/);
|
|
157
|
+
if (asMatch) {
|
|
158
|
+
names.push(asMatch[1]);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const id = trimmed.match(/^([A-Za-z_$][\w$]*)$/);
|
|
162
|
+
if (id) names.push(id[1]);
|
|
163
|
+
}
|
|
164
|
+
return names;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readAlias(source, i) {
|
|
168
|
+
let j = skipWs(source, i);
|
|
169
|
+
const word = readWord(source, j);
|
|
170
|
+
if (word.word !== "as") return { name: null, next: i };
|
|
171
|
+
j = skipWs(source, word.next);
|
|
172
|
+
const name = readWord(source, j);
|
|
173
|
+
if (!name.word) return { name: null, next: j };
|
|
174
|
+
return { name: name.word, next: name.next };
|
|
175
|
+
}
|
|
176
|
+
|
|
150
177
|
function findMatchingBrace(source, openIndex) {
|
|
151
178
|
let depth = 0;
|
|
152
179
|
for (let i = openIndex; i < source.length; i += 1) {
|
|
@@ -179,16 +206,25 @@ function readFromClause(source, i) {
|
|
|
179
206
|
}
|
|
180
207
|
|
|
181
208
|
/**
|
|
182
|
-
* @returns {{ specifier: string, typeOnly: boolean }[]}
|
|
209
|
+
* @returns {{ specifier: string, typeOnly: boolean, names: string[] }[]}
|
|
210
|
+
* `names` are the local bindings (`import { a as b }` → `b`) so callers can
|
|
211
|
+
* count `b(` as a use of this import. Type-only imports still list names.
|
|
183
212
|
*/
|
|
184
213
|
export function extractJsImports(source) {
|
|
185
214
|
const imports = [];
|
|
186
215
|
const n = source.length;
|
|
187
216
|
let i = 0;
|
|
188
217
|
|
|
189
|
-
function add(specifier, typeOnly) {
|
|
218
|
+
function add(specifier, typeOnly, names = []) {
|
|
190
219
|
if (typeof specifier !== "string") return;
|
|
191
|
-
|
|
220
|
+
const locals = [];
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
for (const name of names) {
|
|
223
|
+
if (typeof name !== "string" || !name || seen.has(name)) continue;
|
|
224
|
+
seen.add(name);
|
|
225
|
+
locals.push(name);
|
|
226
|
+
}
|
|
227
|
+
imports.push({ specifier, typeOnly: Boolean(typeOnly), names: locals });
|
|
192
228
|
}
|
|
193
229
|
|
|
194
230
|
function parseRequire(at) {
|
|
@@ -228,7 +264,7 @@ export function extractJsImports(source) {
|
|
|
228
264
|
const clause = readFromClause(source, close + 1);
|
|
229
265
|
if (!clause) return close + 1;
|
|
230
266
|
const onlyTypes = typeOnly || braceIsTypeOnly(inner);
|
|
231
|
-
add(clause.value, onlyTypes);
|
|
267
|
+
add(clause.value, onlyTypes, typeOnly ? [] : localNames(inner));
|
|
232
268
|
return clause.next;
|
|
233
269
|
}
|
|
234
270
|
|
|
@@ -240,8 +276,10 @@ export function extractJsImports(source) {
|
|
|
240
276
|
}
|
|
241
277
|
// default binding, namespace, or `name, { ... }`
|
|
242
278
|
let sawValue = !typeOnly;
|
|
279
|
+
const names = [];
|
|
243
280
|
if (source[j] !== "*") {
|
|
244
281
|
const binding = readWord(source, j);
|
|
282
|
+
if (binding.word && !typeOnly) names.push(binding.word);
|
|
245
283
|
j = skipWs(source, binding.next);
|
|
246
284
|
if (source[j] === ",") {
|
|
247
285
|
sawValue = !typeOnly;
|
|
@@ -249,8 +287,9 @@ export function extractJsImports(source) {
|
|
|
249
287
|
}
|
|
250
288
|
} else {
|
|
251
289
|
j = skipWs(source, j + 1);
|
|
252
|
-
|
|
253
|
-
|
|
290
|
+
const alias = readAlias(source, j);
|
|
291
|
+
if (alias.name && !typeOnly) names.push(alias.name);
|
|
292
|
+
j = skipWs(source, alias.next);
|
|
254
293
|
}
|
|
255
294
|
if (source[j] === "{") {
|
|
256
295
|
const close = findMatchingBrace(source, j);
|
|
@@ -259,12 +298,13 @@ export function extractJsImports(source) {
|
|
|
259
298
|
const clause = readFromClause(source, close + 1);
|
|
260
299
|
if (!clause) return close + 1;
|
|
261
300
|
const onlyTypes = typeOnly || (!sawValue && braceIsTypeOnly(inner));
|
|
262
|
-
|
|
301
|
+
if (!onlyTypes) names.push(...localNames(inner));
|
|
302
|
+
add(clause.value, onlyTypes, names);
|
|
263
303
|
return clause.next;
|
|
264
304
|
}
|
|
265
305
|
const clause = readFromClause(source, j);
|
|
266
306
|
if (!clause) return at;
|
|
267
|
-
add(clause.value, typeOnly);
|
|
307
|
+
add(clause.value, typeOnly, typeOnly ? [] : names);
|
|
268
308
|
return clause.next;
|
|
269
309
|
}
|
|
270
310
|
|
package/src/badge.js
CHANGED
|
@@ -10,6 +10,8 @@ const SOURCE_EXT = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py",
|
|
|
10
10
|
const SKIP_DIR = new Set([
|
|
11
11
|
"node_modules", ".git", "dist", "build", "vendor", "target", "coverage",
|
|
12
12
|
".repolens", "__pycache__", ".cache",
|
|
13
|
+
"__snapshots__", "samples", "sample", "examples", "example",
|
|
14
|
+
"fixtures", "fixture", "__fixtures__", "e2e-fixtures", "e2e-fixture", "generated",
|
|
13
15
|
]);
|
|
14
16
|
|
|
15
17
|
const COLOR_HEX = {
|
|
@@ -143,6 +145,7 @@ export async function listScoreFiles(root, { maxFiles = 2500 } = {}) {
|
|
|
143
145
|
continue;
|
|
144
146
|
}
|
|
145
147
|
if (!entry.isFile()) continue;
|
|
148
|
+
if (entry.name.endsWith(".snap")) continue;
|
|
146
149
|
if (!SOURCE_EXT.has(path.extname(entry.name))) continue;
|
|
147
150
|
if (out.length >= maxFiles) {
|
|
148
151
|
capped = true;
|
|
@@ -107,6 +107,10 @@ export function validateConfig(config) {
|
|
|
107
107
|
if (!config.scan.ignore || !Array.isArray(config.scan.ignore)) {
|
|
108
108
|
errors.push("scan.ignore is required and must be an array");
|
|
109
109
|
}
|
|
110
|
+
|
|
111
|
+
if (config.scan.include_dev_artifacts !== undefined && typeof config.scan.include_dev_artifacts !== "boolean") {
|
|
112
|
+
errors.push("scan.include_dev_artifacts must be a boolean");
|
|
113
|
+
}
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
// Validate module_roots (optional but must be array if present)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Directories the scan skips unless scan.include_dev_artifacts is true.
|
|
2
|
+
// Snapshots, samples, fixtures, examples, and generated output are dev tooling.
|
|
3
|
+
// A project's own scan.ignore is still applied. The flag turns off only this list.
|
|
4
|
+
|
|
5
|
+
export const DEV_ARTIFACT_IGNORE = [
|
|
6
|
+
"**/__snapshots__/**",
|
|
7
|
+
"**/*.snap",
|
|
8
|
+
"**/samples/**",
|
|
9
|
+
"**/sample/**",
|
|
10
|
+
"**/examples/**",
|
|
11
|
+
"**/example/**",
|
|
12
|
+
"**/fixtures/**",
|
|
13
|
+
"**/fixture/**",
|
|
14
|
+
"**/__fixtures__/**",
|
|
15
|
+
"**/e2e-fixtures/**",
|
|
16
|
+
"**/e2e-fixture/**",
|
|
17
|
+
"**/generated/**",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ignore globs for one scan.
|
|
22
|
+
* `include_dev_artifacts: true` keeps the caller's list and does not add the defaults,
|
|
23
|
+
* so a config can scan samples or snapshots on purpose. Other ignore entries always apply.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveScanIgnore(scan = {}) {
|
|
26
|
+
const user = Array.isArray(scan.ignore) ? scan.ignore.filter((pattern) => typeof pattern === "string") : [];
|
|
27
|
+
if (scan.include_dev_artifacts === true) return user;
|
|
28
|
+
const seen = new Set(user);
|
|
29
|
+
const merged = [...user];
|
|
30
|
+
for (const pattern of DEV_ARTIFACT_IGNORE) {
|
|
31
|
+
if (seen.has(pattern)) continue;
|
|
32
|
+
seen.add(pattern);
|
|
33
|
+
merged.push(pattern);
|
|
34
|
+
}
|
|
35
|
+
return merged;
|
|
36
|
+
}
|
package/src/core/scan.js
CHANGED
|
@@ -6,6 +6,7 @@ import { trackScan } from "../utils/telemetry.js";
|
|
|
6
6
|
import { detectMonorepo } from "../analyzers/monorepo-detector.js";
|
|
7
7
|
import { isInsideRoot } from "../utils/safe-path.js";
|
|
8
8
|
import { isNonProductPath, isTestPath } from "../utils/path-roles.js";
|
|
9
|
+
import { resolveScanIgnore } from "./scan-defaults.js";
|
|
9
10
|
import { replaceBracketParams } from "../utils/route-params.js";
|
|
10
11
|
|
|
11
12
|
const norm = (p) => p.replace(/\\/g, "/");
|
|
@@ -559,7 +560,7 @@ export async function listMatchedFiles(cfg) {
|
|
|
559
560
|
const rootResolved = path.resolve(repoRoot);
|
|
560
561
|
const files = await fg(cfg.scan.include, {
|
|
561
562
|
cwd: rootResolved,
|
|
562
|
-
ignore: cfg.scan
|
|
563
|
+
ignore: resolveScanIgnore(cfg.scan),
|
|
563
564
|
onlyFiles: true,
|
|
564
565
|
followSymbolicLinks: false,
|
|
565
566
|
absolute: false
|
|
@@ -366,15 +366,20 @@ export function renderDependencyGraph(depResult) {
|
|
|
366
366
|
lines.push("");
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
-
// Circular dependencies
|
|
369
|
+
// Circular dependencies. Priority comes from the cycle heuristic in cycle-priority.js.
|
|
370
370
|
if (depResult.cycles.length > 0) {
|
|
371
|
+
const reports = Array.isArray(depResult.cycleReports) ? depResult.cycleReports : [];
|
|
371
372
|
lines.push("## Circular Dependencies");
|
|
372
373
|
lines.push("");
|
|
373
|
-
lines.push(`**${depResult.cycles.length}** circular dependency chain${depResult.cycles.length === 1 ? " was" : "s were"} detected.
|
|
374
|
+
lines.push(`**${depResult.cycles.length}** circular dependency chain${depResult.cycles.length === 1 ? " was" : "s were"} detected. A large cycle across three or more folders is a high-priority refactor. A two-folder recursive-descent pair (many imports and calls both ways) is mutual recursion, probably intentional; verify. A cycle that exists only through type-only imports is not a runtime cycle.`);
|
|
374
375
|
lines.push("");
|
|
375
|
-
|
|
376
|
+
const limit = Math.min(depResult.cycles.length, 20);
|
|
377
|
+
for (let i = 0; i < limit; i++) {
|
|
376
378
|
const cycle = depResult.cycles[i];
|
|
377
|
-
|
|
379
|
+
const report = reports[i];
|
|
380
|
+
const pathText = Array.isArray(cycle) ? cycle.join("` → `") : String(cycle);
|
|
381
|
+
lines.push(`${i + 1}. \`${pathText}\``);
|
|
382
|
+
if (report?.priorityLine) lines.push(` ${report.priorityLine}`);
|
|
378
383
|
}
|
|
379
384
|
if (depResult.cycles.length > 20) {
|
|
380
385
|
lines.push(`\n*...and ${depResult.cycles.length - 20} more cycles*`);
|
|
@@ -382,6 +387,13 @@ export function renderDependencyGraph(depResult) {
|
|
|
382
387
|
lines.push("");
|
|
383
388
|
}
|
|
384
389
|
|
|
390
|
+
if (Array.isArray(depResult.typeOnlyReports) && depResult.typeOnlyReports.length > 0) {
|
|
391
|
+
lines.push("## Type-only cycles");
|
|
392
|
+
lines.push("");
|
|
393
|
+
lines.push(`**${depResult.typeOnlyReports.length}** cycle${depResult.typeOnlyReports.length === 1 ? " exists" : "s exist"} only through \`import type\` / \`export type\`. They are not runtime dependencies. Priority: Low — type-only imports; verify.`);
|
|
394
|
+
lines.push("");
|
|
395
|
+
}
|
|
396
|
+
|
|
385
397
|
// External dependencies
|
|
386
398
|
if (depResult.externalDeps.length > 0) {
|
|
387
399
|
lines.push("## External Dependencies");
|
package/src/utils/path-roles.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// so tests and examples never drive hub lists, coupling advice, or severity.
|
|
4
4
|
|
|
5
5
|
const TEST_DIRECTORIES = new Set([
|
|
6
|
-
"test", "tests", "spec", "specs", "__tests__", "__mocks__", "fixtures", "
|
|
6
|
+
"test", "tests", "spec", "specs", "__tests__", "__mocks__", "fixtures", "fixture", "__fixtures__",
|
|
7
|
+
"testdata", "e2e", "e2e-fixture", "e2e-fixtures", "cypress", "__snapshots__",
|
|
7
8
|
]);
|
|
8
9
|
|
|
9
10
|
// Directories holding tooling, samples, and docs. Code in here describes a build script
|
|
@@ -31,12 +32,39 @@ export function isTestPath(filePath) {
|
|
|
31
32
|
const parts = segments(filePath);
|
|
32
33
|
if (directorySegments(filePath).some((segment) => TEST_DIRECTORIES.has(segment))) return true;
|
|
33
34
|
const base = parts[parts.length - 1] || "";
|
|
35
|
+
if (base.endsWith(".snap") || base.includes(".snap.")) return true;
|
|
34
36
|
if (/^(test_|spec_)/.test(base)) return true;
|
|
35
37
|
if (/(_test|_spec)\.[a-z0-9]+$/.test(base)) return true;
|
|
36
38
|
if (/\.(test|spec)\.[a-z0-9]+$/.test(base)) return true;
|
|
37
39
|
return false;
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Snapshot, sample, fixture, example, and generated trees.
|
|
44
|
+
* These are dev tooling, like tests: they describe the suite or a demo, not the product.
|
|
45
|
+
* Scan ignores them unless `scan.include_dev_artifacts` is true.
|
|
46
|
+
*/
|
|
47
|
+
export const DEV_ARTIFACT_DIRECTORIES = new Set([
|
|
48
|
+
"__snapshots__",
|
|
49
|
+
"sample", "samples",
|
|
50
|
+
"example", "examples",
|
|
51
|
+
"fixture", "fixtures", "__fixtures__",
|
|
52
|
+
"e2e-fixture", "e2e-fixtures",
|
|
53
|
+
"generated",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
export function isSnapshotPath(filePath) {
|
|
57
|
+
const parts = segments(filePath);
|
|
58
|
+
if (parts.some((segment) => segment === "__snapshots__")) return true;
|
|
59
|
+
const base = parts[parts.length - 1] || "";
|
|
60
|
+
return base.endsWith(".snap") || base.includes(".snap.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isDevArtifactPath(filePath) {
|
|
64
|
+
if (isSnapshotPath(filePath)) return true;
|
|
65
|
+
return directorySegments(filePath).some((segment) => DEV_ARTIFACT_DIRECTORIES.has(segment));
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
/** True when a directory above the file is a tooling, example, or docs directory. */
|
|
41
69
|
export function isToolingPath(filePath) {
|
|
42
70
|
return directorySegments(filePath).some((segment) => TOOLING_DIRECTORIES.has(segment));
|