@chappibunny/repolens 1.16.0 → 1.16.1

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,32 @@
2
2
 
3
3
  All notable changes to RepoLens will be documented in this file.
4
4
 
5
+ ## 1.16.1
6
+
7
+ Fixes three problems found by running 1.16.0 on orval-labs/orval (a TypeScript monorepo whose test snapshots and samples dwarf the packages).
8
+
9
+ ### Scan defaults
10
+
11
+ - 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).
12
+ - `repolens badge` skips the same directories. It does not read `.repolens.yml`.
13
+
14
+ ### Cycle priority
15
+
16
+ - A cycle is no longer always "Priority: High refactor".
17
+ - **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.
18
+ - **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`.
19
+ - **High refactor** is kept for a cycle that touches three or more parent directories.
20
+ - A two-directory cycle with only one import each way is **Medium**.
21
+ - 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.
22
+ - 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).
23
+
24
+ ### npx and devEngines
25
+
26
+ - 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.
27
+ - `--ignore-engines` is not an npm flag. On npm versions that enforce `devEngines`, npm warns that it is an unknown config and still errors.
28
+ - `--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`.
29
+ - See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
30
+
5
31
  ## 1.16.0
6
32
 
7
33
  ### 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.0`). `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`.
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.1`). `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.0`):
248
+ Publishing docs (the npm package at tag `v1.16.1`):
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.0
264
+ - uses: CHAPIBUNNY/repolens@v1.16.1
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.0` 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.1` 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.0
288
+ - uses: CHAPIBUNNY/repolens@v1.16.1
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.0` has to exist before `uses: CHAPIBUNNY/repolens@v1.16.0` resolves, and `@chappibunny/repolens@1.16.0` 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.1` has to exist before `uses: CHAPIBUNNY/repolens@v1.16.1` resolves, and `@chappibunny/repolens@1.16.1` 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.0
333
+ - uses: CHAPIBUNNY/repolens@v1.16.1
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chappibunny/repolens",
3
- "version": "1.16.0",
3
+ "version": "1.16.1",
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",
@@ -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
- output += `| Circular Dependencies | ${stats.cycles === 0 ? "cycle-free" : stats.cycles} | ${stats.cycles === 0 ? "✅ None" : `🔴 ${stats.cycles} detected`} |\n`;
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 += `- 🔴 **Architectural concern** — ${stats.cycles} circular dependenc${stats.cycles === 1 ? "y" : "ies"} detected. These create tight coupling and make testing difficult.\n`;
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
- output += `| Circular Deps | ${stats.cycles} | ${stats.cycles === 0 ? "✅" : "🔴"} | ${stats.cycles === 0 ? "Clean module boundaries" : "Refactor to break cycles"} |\n`;
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
- if (stats.cycles > 0) {
860
- concerns.push(`**Circular dependencies detected** — ${stats.cycles} circular dependenc${stats.cycles === 1 ? 'y' : 'ies'} create tight coupling, complicate testing, and hinder refactoring. Priority: High.`);
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
- // Circular dependencies are a major concern
1629
- if (stats.cycles > 0) score -= Math.min(30, stats.cycles * 10);
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.typeOnly) continue;
210
- if (isRelativeImport(imported.specifier)) {
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
- imports.push({ specifier, typeOnly: Boolean(typeOnly) });
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
- j = skipAsBinding(source, j);
253
- j = skipWs(source, j);
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
- add(clause.value, onlyTypes);
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?.ignore || [],
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. Circular imports can cause initialization errors, increase bundle sizes, and make modules harder to test in isolation. Consider refactoring shared logic into a separate module.`);
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
- for (let i = 0; i < Math.min(depResult.cycles.length, 20); i++) {
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
- lines.push(`${i + 1}. \`${cycle.join("` → `")}\``);
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");
@@ -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", "testdata", "e2e", "cypress",
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));