@chappibunny/repolens 1.16.3 → 1.16.4
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 +26 -0
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/analyzers/cycle-priority.js +3 -0
- package/src/analyzers/dependency-graph.js +121 -13
- package/src/analyzers/import-parsers.js +47 -14
- package/src/analyzers/ts-elision.js +921 -0
- package/src/renderers/renderAnalysis.js +22 -2
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.4 (2026-11-12)
|
|
6
|
+
|
|
7
|
+
Fix found by the 2026-11-12 gap test of 1.16.3 on lukeautry/tsoa (ce4daec).
|
|
8
|
+
|
|
9
|
+
### Upgrade notes
|
|
10
|
+
|
|
11
|
+
- **Fewer cycles and edges in TypeScript repositories.** Imports the TypeScript compiler removes are no longer runtime edges. On tsoa: circular dependencies 10 → 1, import edges 412 → 326, with 108 type-only imports and 17 type-only cycles listed separately. On trpc/trpc: edges 1,533 → 1,531, still no runtime cycles. Architecture baselines and drift reports will show the drop once; it is a measurement fix, not a code change.
|
|
12
|
+
- **Type-only cycles now include mixed cycles.** Before, only cycles made entirely of `import type` edges were listed; a cycle with one `import type` link and runtime links elsewhere was silently dropped. Both are now listed under "Type-only cycles" (trpc: 21 → 40), still Low and not counted.
|
|
13
|
+
- **Fewer orphan files.** A file connected only by type-only imports (a `types.ts` module) is no longer an orphan. On trpc: 126 → 81. The orphan share feeds the architecture health score, so that score can rise.
|
|
14
|
+
|
|
15
|
+
### Dependency graph
|
|
16
|
+
|
|
17
|
+
- In `.ts` files, an import whose names are all interfaces or type aliases in the target file, or are all used only in type positions (annotations, generic arguments and constraints, `as` / `satisfies`, `implements`, type aliases, interfaces, `declare` blocks), is type-only (*inferred*). Value uses (`new`, calls, member access, `extends` of a class, `instanceof`, decorators, `typeof` in an expression, object shorthand, default values, `export { X }`, JSX tags) keep the import a runtime edge. A name that is both an interface and a value (declaration merging) is a value.
|
|
18
|
+
- `export { X } from` is type-only when every name is an interface or type alias in the target. `export * from`, side-effect imports, `import()`, and `require()` stay runtime edges. Imports inside `.d.ts` files are type-only.
|
|
19
|
+
- In `.tsx` files only the interface / type-alias check applies, because JSX text can look like code.
|
|
20
|
+
- Inference is off where the compiler keeps the import: `verbatimModuleSyntax`, `preserveValueImports`, `importsNotUsedAsValues: "preserve"`/`"error"`, or `erasableSyntaxOnly` (Node type stripping removes only `import type`) in the nearest `tsconfig.json`, and files with decorators under `emitDecoratorMetadata`. The `extends` chain is followed for relative paths and for packages installed in `node_modules` (bare names, `package.json` `tsconfig` field, parent folders).
|
|
21
|
+
- When part of the `extends` chain is not in the checkout (an uninstalled package, a generated `.nuxt/tsconfig.json`), or the file could not be tokenized cleanly, only interface / type-alias imports count as type-only; usage-based inference is skipped so a class used only as a type stays a runtime edge.
|
|
22
|
+
- Only top-level interfaces and type aliases of the target file count. A nested one (`namespace N { interface Foo }`, inside a function or `declare module`) does not make an import of `Foo` type-only, and names the target re-exports from another module (`export { Foo } from`, `export * as Foo from`) or aliases with `import Foo = require()` are treated as values.
|
|
23
|
+
- `import type`, `export type`, and all-`type` lists (`import { type A, type B }`) are type-only as before (*syntax*). A mixed `import { type A, b }` is type-only only when `b` is used only as a type.
|
|
24
|
+
- The dependency graph page and the MCP `get_dependency_graph` tool list "Type-only cycles" with each type-only link and whether it is *syntax* or *inferred*, plus "Type-only cycles" and "Type-only imports" rows in the overview table. The CLI log says "(N type-only cycle(s) not counted)". Health score, badge, and cycle findings count runtime cycles only.
|
|
25
|
+
- External packages are unchanged: an npm import used only as a type is still listed.
|
|
26
|
+
|
|
27
|
+
### Output accuracy
|
|
28
|
+
|
|
29
|
+
- The executive summary insight no longer calls a lone medium-priority cycle "mutual recursion, probably intentional; verify. Priority: Low" (it said that whenever no cycle was high). It now reads "Small cycle; review. Priority: Medium.", matching the dependency graph page.
|
|
30
|
+
|
|
5
31
|
## 1.16.3 (2026-11-11)
|
|
6
32
|
|
|
7
33
|
Fixes found by the 2026-11-11 gap test of 1.16.2 on trpc/trpc, App-vNext/Polly, google/gson, charmbracelet/glow, and pallets/itsdangerous.
|
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 --force --yes @chappibunny/repolens@1.16.
|
|
246
|
+
`action.yml` is a composite action named **RepoLens Architecture Docs**. `publish`, `demo`, and `doctor` run the published npm package (`npx --force --yes @chappibunny/repolens@1.16.4`). `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.4`):
|
|
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.4
|
|
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.4` 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.4
|
|
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.4` has to exist before `uses: CHAPIBUNNY/repolens@v1.16.4` resolves, and `@chappibunny/repolens@1.16.4` 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.4
|
|
334
334
|
with:
|
|
335
335
|
command: badge
|
|
336
336
|
badge-url: https://USER.github.io/REPO/repolens-badge.json
|
package/package.json
CHANGED
|
@@ -162,6 +162,9 @@ export function cycleInsight(stats) {
|
|
|
162
162
|
if (!hasCyclePriorities(stats)) {
|
|
163
163
|
return `🔴 **Architectural concern** — ${stats.cycles} circular dependenc${stats.cycles === 1 ? "y" : "ies"} detected. These create tight coupling and make testing difficult.`;
|
|
164
164
|
}
|
|
165
|
+
if ((stats.highCycles || 0) === 0 && (stats.mediumCycles || 0) > 0) {
|
|
166
|
+
return `🟡 **Circular dependencies** — ${stats.cycles} detected. Small cycle; review. Priority: Medium.`;
|
|
167
|
+
}
|
|
165
168
|
if ((stats.highCycles || 0) === 0) {
|
|
166
169
|
return `🟡 **Circular dependencies** — ${stats.cycles} detected. mutual recursion, probably intentional; verify. Priority: Low.`;
|
|
167
170
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
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 runtime edges
|
|
3
|
+
// Comments and strings are ignored. Type-only TypeScript imports are not runtime edges:
|
|
4
|
+
// `import type` / `export type` / all-`type` specifier lists (syntax), and imports the
|
|
5
|
+
// TypeScript compiler removes because every binding is used only as a type or names
|
|
6
|
+
// an interface or type alias (inferred, see ts-elision.js).
|
|
4
7
|
// Cycle priority is in cycle-priority.js: mutual recursion and type-only cycles are
|
|
5
8
|
// not "Priority: High refactor". Large multi-module cycles stay high.
|
|
6
9
|
|
|
@@ -21,6 +24,7 @@ import {
|
|
|
21
24
|
import { isNonProductPath } from "../utils/path-roles.js";
|
|
22
25
|
import { computeLanguageCoverage, coverageLine } from "./language-coverage.js";
|
|
23
26
|
import { listWorkspacePackages } from "./workspace-packages.js";
|
|
27
|
+
import { usageByName, declaredKinds, createElisionSettings, isTypeScriptFile } from "./ts-elision.js";
|
|
24
28
|
import {
|
|
25
29
|
classifyRuntimeCycles,
|
|
26
30
|
priorityCounts,
|
|
@@ -152,6 +156,7 @@ function emptyResult() {
|
|
|
152
156
|
cycles: [],
|
|
153
157
|
cycleReports: [],
|
|
154
158
|
typeOnlyReports: [],
|
|
159
|
+
typeOnlyEdges: [],
|
|
155
160
|
externalDeps: [],
|
|
156
161
|
externalRuntime: [],
|
|
157
162
|
externalDev: [],
|
|
@@ -337,7 +342,7 @@ export async function analyzeDependencyGraph(files, repoRoot, { coverageFiles =
|
|
|
337
342
|
// and are classified on their own so they are never "Priority: High refactor".
|
|
338
343
|
const edgeRecords = new Map();
|
|
339
344
|
|
|
340
|
-
function rememberEdge(fromKey, toKey, fromFile, typeOnly, calls) {
|
|
345
|
+
function rememberEdge(fromKey, toKey, fromFile, typeOnly, calls, reason = null) {
|
|
341
346
|
if (!toKey || toKey === fromKey || !allFileKeys.has(toKey)) return;
|
|
342
347
|
const id = `${fromKey}->${toKey}`;
|
|
343
348
|
let record = edgeRecords.get(id);
|
|
@@ -348,16 +353,72 @@ export async function analyzeDependencyGraph(files, repoRoot, { coverageFiles =
|
|
|
348
353
|
fromFile,
|
|
349
354
|
toFile: fileKeyToFile.get(toKey) || toKey,
|
|
350
355
|
typeOnly: true,
|
|
356
|
+
typeOnlyReason: null,
|
|
351
357
|
calls: 0,
|
|
352
358
|
};
|
|
353
359
|
edgeRecords.set(id, record);
|
|
354
360
|
}
|
|
355
361
|
if (!typeOnly) {
|
|
356
362
|
record.typeOnly = false;
|
|
363
|
+
record.typeOnlyReason = null;
|
|
357
364
|
record.calls += calls;
|
|
365
|
+
} else if (record.typeOnly) {
|
|
366
|
+
// "syntax" (`import type`) wins over "inferred" when both reach the same file.
|
|
367
|
+
if (reason === "syntax" || !record.typeOnlyReason) record.typeOnlyReason = reason || "syntax";
|
|
358
368
|
}
|
|
359
369
|
}
|
|
360
370
|
|
|
371
|
+
// TypeScript import elision. Contents and declared kinds are cached per file.
|
|
372
|
+
const elisionFor = createElisionSettings(repoRoot);
|
|
373
|
+
const contentByKey = new Map();
|
|
374
|
+
const kindsByKey = new Map();
|
|
375
|
+
async function contentOf(key) {
|
|
376
|
+
if (!contentByKey.has(key)) {
|
|
377
|
+
const file = fileKeyToFile.get(key);
|
|
378
|
+
contentByKey.set(key, file ? await readFileSafe(path.join(repoRoot, file)) : "");
|
|
379
|
+
}
|
|
380
|
+
return contentByKey.get(key);
|
|
381
|
+
}
|
|
382
|
+
async function kindsOf(key) {
|
|
383
|
+
if (!kindsByKey.has(key)) {
|
|
384
|
+
const file = fileKeyToFile.get(key);
|
|
385
|
+
kindsByKey.set(key, file && isTypeScriptFile(file) ? declaredKinds(await contentOf(key)) : null);
|
|
386
|
+
}
|
|
387
|
+
return kindsByKey.get(key);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// True when the compiler drops this import or re-export from the JavaScript output.
|
|
391
|
+
async function elidedByCompiler(imported, toKey, file, content, state) {
|
|
392
|
+
// A declaration file (`.d.ts`) never runs: none of its imports load a module.
|
|
393
|
+
if (/\.d\.[cm]?ts$/.test(file)) return true;
|
|
394
|
+
if (!isTypeScriptFile(file)) return false;
|
|
395
|
+
if (imported.kind !== "import" && imported.kind !== "reexport") return false;
|
|
396
|
+
const bindings = imported.bindings || [];
|
|
397
|
+
if (bindings.length === 0) return false;
|
|
398
|
+
if (!state.settings) state.settings = await elisionFor(file);
|
|
399
|
+
if (!state.settings.elide) return false;
|
|
400
|
+
// `emitDecoratorMetadata` turns annotations on decorated members into runtime
|
|
401
|
+
// references (`design:paramtypes`); isolated transpilers (swc, esbuild) keep
|
|
402
|
+
// even interface imports there. A decorated file under it keeps every import.
|
|
403
|
+
if (!state.usage) state.usage = usageByName(content, { jsx: /\.[jt]sx$/.test(file) });
|
|
404
|
+
if (state.settings.decoratorMetadata && state.usage.decorators) return false;
|
|
405
|
+
const kinds = await kindsOf(toKey);
|
|
406
|
+
const declaredType = (name) => Boolean(kinds) && name !== "default" && name !== "*" && kinds.types.has(name);
|
|
407
|
+
if (imported.kind === "reexport") return bindings.every((item) => declaredType(item.imported));
|
|
408
|
+
// Usage-based inference: .ts/.mts/.cts only. In .tsx, JSX text can look like code
|
|
409
|
+
// (`list as they're posted`), so only interface / type alias names count there.
|
|
410
|
+
// When part of the tsconfig `extends` chain is not in the checkout, only
|
|
411
|
+
// interface / type alias names count (`inferUsage: false`).
|
|
412
|
+
// A file the tokenizer misread (`malformed`) gets the same treatment.
|
|
413
|
+
const useInference = !/\.tsx$/.test(file) && state.settings.inferUsage !== false && !state.usage.malformed;
|
|
414
|
+
return bindings.every((item) => {
|
|
415
|
+
if (declaredType(item.imported)) return true;
|
|
416
|
+
if (!useInference || !item.local) return false;
|
|
417
|
+
const use = state.usage.uses.get(item.local);
|
|
418
|
+
return !use || use.value === 0;
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
361
422
|
for (const file of jsFiles) {
|
|
362
423
|
const content = await readFileSafe(path.join(repoRoot, file));
|
|
363
424
|
if (!content) continue;
|
|
@@ -365,6 +426,7 @@ export async function analyzeDependencyGraph(files, repoRoot, { coverageFiles =
|
|
|
365
426
|
const importer = await ownPackage(file);
|
|
366
427
|
const selfName = importer ? importer.name : null;
|
|
367
428
|
const grouped = new Map();
|
|
429
|
+
const state = { settings: null, usage: null };
|
|
368
430
|
for (const imported of extractJsImports(content)) {
|
|
369
431
|
let toKey = null;
|
|
370
432
|
if (!isRelativeImport(imported.specifier)) {
|
|
@@ -385,20 +447,28 @@ export async function analyzeDependencyGraph(files, repoRoot, { coverageFiles =
|
|
|
385
447
|
toKey = resolveImportPath(file, imported.specifier);
|
|
386
448
|
}
|
|
387
449
|
if (!toKey) continue;
|
|
450
|
+
let typeOnly = imported.typeOnly;
|
|
451
|
+
let reason = typeOnly ? "syntax" : null;
|
|
452
|
+
if (!typeOnly && allFileKeys.has(toKey) && toKey !== fromKey && await elidedByCompiler(imported, toKey, file, content, state)) {
|
|
453
|
+
typeOnly = true;
|
|
454
|
+
reason = "inferred";
|
|
455
|
+
}
|
|
388
456
|
const id = `${fromKey}->${toKey}`;
|
|
389
457
|
let slot = grouped.get(id);
|
|
390
458
|
if (!slot) {
|
|
391
|
-
slot = { toKey, typeOnly: true, names: new Set() };
|
|
459
|
+
slot = { toKey, typeOnly: true, reason: null, names: new Set() };
|
|
392
460
|
grouped.set(id, slot);
|
|
393
461
|
}
|
|
394
|
-
if (!
|
|
462
|
+
if (!typeOnly) {
|
|
395
463
|
slot.typeOnly = false;
|
|
396
464
|
for (const binding of imported.names || []) slot.names.add(binding);
|
|
465
|
+
} else if (reason === "syntax" || !slot.reason) {
|
|
466
|
+
slot.reason = reason;
|
|
397
467
|
}
|
|
398
468
|
}
|
|
399
469
|
for (const slot of grouped.values()) {
|
|
400
470
|
const calls = slot.typeOnly ? 0 : countBindingCalls(content, [...slot.names]);
|
|
401
|
-
rememberEdge(fromKey, slot.toKey, file, slot.typeOnly, calls);
|
|
471
|
+
rememberEdge(fromKey, slot.toKey, file, slot.typeOnly, calls, slot.reason);
|
|
402
472
|
}
|
|
403
473
|
}
|
|
404
474
|
|
|
@@ -468,20 +538,49 @@ export async function analyzeDependencyGraph(files, repoRoot, { coverageFiles =
|
|
|
468
538
|
const runtimeEdgeList = [...edgeRecords.values()].filter((edge) => !edge.typeOnly);
|
|
469
539
|
result.cycleReports = classifyRuntimeCycles(result.cycles, runtimeEdgeList, fileForKey);
|
|
470
540
|
|
|
471
|
-
|
|
541
|
+
// Type-only cycles: cycles of runtime + type-only edges that need at least one
|
|
542
|
+
// type-only edge. They are reported on their own and never as runtime cycles.
|
|
543
|
+
const combinedAdj = new Map();
|
|
544
|
+
for (const [from, deps] of adjacency) combinedAdj.set(from, new Set(deps));
|
|
545
|
+
const typeOnlyEdgeById = new Map();
|
|
472
546
|
for (const edge of edgeRecords.values()) {
|
|
473
547
|
if (!edge.typeOnly) continue;
|
|
474
|
-
|
|
475
|
-
if (!
|
|
476
|
-
|
|
548
|
+
typeOnlyEdgeById.set(`${edge.from}->${edge.to}`, edge);
|
|
549
|
+
if (!combinedAdj.has(edge.from)) combinedAdj.set(edge.from, new Set());
|
|
550
|
+
if (!combinedAdj.has(edge.to)) combinedAdj.set(edge.to, new Set());
|
|
551
|
+
combinedAdj.get(edge.from).add(edge.to);
|
|
477
552
|
}
|
|
478
|
-
result.typeOnlyReports =
|
|
553
|
+
result.typeOnlyReports = typeOnlyEdgeById.size === 0 ? [] : detectCycles(combinedAdj)
|
|
554
|
+
.map((cyclePath) => {
|
|
555
|
+
const links = [];
|
|
556
|
+
for (let i = 0; i + 1 < cyclePath.length; i += 1) {
|
|
557
|
+
const from = cyclePath[i];
|
|
558
|
+
const to = cyclePath[i + 1];
|
|
559
|
+
if (adjacency.get(from)?.has(to)) continue;
|
|
560
|
+
const edge = typeOnlyEdgeById.get(`${from}->${to}`);
|
|
561
|
+
if (edge) links.push({ from, to, reason: edge.typeOnlyReason || "syntax" });
|
|
562
|
+
}
|
|
563
|
+
if (links.length === 0) return null;
|
|
564
|
+
return { ...typeOnlyReport(cyclePath), typeOnlyLinks: links };
|
|
565
|
+
})
|
|
566
|
+
.filter(Boolean);
|
|
567
|
+
result.typeOnlyEdges = [...typeOnlyEdgeById.values()].map((edge) => ({
|
|
568
|
+
from: edge.from,
|
|
569
|
+
to: edge.to,
|
|
570
|
+
reason: edge.typeOnlyReason || "syntax",
|
|
571
|
+
}));
|
|
479
572
|
|
|
480
573
|
const priorities = priorityCounts(result.cycleReports);
|
|
481
574
|
|
|
482
575
|
// Stats
|
|
483
576
|
const totalImports = result.edges.length;
|
|
484
|
-
|
|
577
|
+
// A file connected only by type-only imports (a types module) is not dead code.
|
|
578
|
+
const typeConnected = new Set();
|
|
579
|
+
for (const edge of result.typeOnlyEdges) {
|
|
580
|
+
typeConnected.add(edge.from);
|
|
581
|
+
typeConnected.add(edge.to);
|
|
582
|
+
}
|
|
583
|
+
const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0 && !typeConnected.has(n.key));
|
|
485
584
|
const sourceNodeCount = result.nodes.filter((n) => !isNonProductPath(n.file || n.key)).length;
|
|
486
585
|
const hubThreshold = Math.max(5, Math.round(sourceNodeCount * 0.07));
|
|
487
586
|
const hubs = aggregateHubs(result.nodes, fileKeyToFile, goModulePath, hubThreshold);
|
|
@@ -502,16 +601,22 @@ export async function analyzeDependencyGraph(files, repoRoot, { coverageFiles =
|
|
|
502
601
|
highCycles: priorities.high,
|
|
503
602
|
mediumCycles: priorities.medium,
|
|
504
603
|
lowCycles: priorities.low,
|
|
604
|
+
typeOnlyCycles: result.typeOnlyReports.length,
|
|
605
|
+
typeOnlyEdges: result.typeOnlyEdges.length,
|
|
606
|
+
inferredTypeOnlyEdges: result.typeOnlyEdges.filter((edge) => edge.reason === "inferred").length,
|
|
505
607
|
orphanFiles: orphans.length,
|
|
506
608
|
hubs,
|
|
507
609
|
};
|
|
508
610
|
|
|
509
611
|
result.summary = buildSummary(result);
|
|
510
612
|
|
|
613
|
+
const typeOnlyNote = result.typeOnlyReports.length > 0
|
|
614
|
+
? ` (${result.typeOnlyReports.length} type-only cycle(s) not counted)`
|
|
615
|
+
: "";
|
|
511
616
|
if (result.cycles.length > 0) {
|
|
512
|
-
warn(`Dependency graph: ${result.cycles.length} circular dependency cycle(s) detected`);
|
|
617
|
+
warn(`Dependency graph: ${result.cycles.length} circular dependency cycle(s) detected${typeOnlyNote}`);
|
|
513
618
|
} else {
|
|
514
|
-
info(`Dependency graph: ${totalImports} edges across ${codeFiles.length} files, no cycles`);
|
|
619
|
+
info(`Dependency graph: ${totalImports} edges across ${codeFiles.length} files, no cycles${typeOnlyNote}`);
|
|
515
620
|
}
|
|
516
621
|
|
|
517
622
|
return result;
|
|
@@ -631,6 +736,9 @@ function buildSummary(result) {
|
|
|
631
736
|
? "✓ no circular dependencies among analyzed files"
|
|
632
737
|
: "✓ no circular dependencies");
|
|
633
738
|
}
|
|
739
|
+
if (result.stats.typeOnlyCycles > 0) {
|
|
740
|
+
parts.push(`${result.stats.typeOnlyCycles} type-only cycle(s), not runtime`);
|
|
741
|
+
}
|
|
634
742
|
if (result.coverage?.status === "partial") {
|
|
635
743
|
parts.push(`partial coverage: ${result.coverage.analyzedBase} of ${result.coverage.totalBase} source files`);
|
|
636
744
|
}
|
|
@@ -164,6 +164,23 @@ function localNames(inner) {
|
|
|
164
164
|
return names;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/** `{ a, b as c, type D }` → [{ imported: "a", local: "a" }, { imported: "b", local: "c" }]. */
|
|
168
|
+
function braceBindings(inner) {
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const part of splitTopCommas(inner)) {
|
|
171
|
+
const trimmed = part.trim().replace(/\s+/g, " ");
|
|
172
|
+
if (!trimmed || /^type\s+/.test(trimmed)) continue;
|
|
173
|
+
const asMatch = trimmed.match(/^([A-Za-z_$][\w$]*|"[^"]*"|'[^']*')\s+as\s+([A-Za-z_$][\w$]*)$/);
|
|
174
|
+
if (asMatch) {
|
|
175
|
+
out.push({ imported: asMatch[1].replace(/^["']|["']$/g, ""), local: asMatch[2] });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const id = trimmed.match(/^([A-Za-z_$][\w$]*)$/);
|
|
179
|
+
if (id) out.push({ imported: id[1], local: id[1] });
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
167
184
|
function readAlias(source, i) {
|
|
168
185
|
let j = skipWs(source, i);
|
|
169
186
|
const word = readWord(source, j);
|
|
@@ -206,7 +223,7 @@ function readFromClause(source, i) {
|
|
|
206
223
|
}
|
|
207
224
|
|
|
208
225
|
/**
|
|
209
|
-
* @returns {{ specifier: string, typeOnly: boolean, names: string[] }[]}
|
|
226
|
+
* @returns {{ specifier: string, typeOnly: boolean, names: string[], kind: string, bindings: { imported: string, local: string|null }[] }[]}
|
|
210
227
|
* `names` are the local bindings (`import { a as b }` → `b`) so callers can
|
|
211
228
|
* count `b(` as a use of this import. Type-only imports still list names.
|
|
212
229
|
*/
|
|
@@ -215,7 +232,12 @@ export function extractJsImports(source) {
|
|
|
215
232
|
const n = source.length;
|
|
216
233
|
let i = 0;
|
|
217
234
|
|
|
218
|
-
|
|
235
|
+
// `kind`: "import" (has local bindings), "reexport" (`export { a } from`),
|
|
236
|
+
// "reexport-all" (`export * from`), "side-effect" (`import "x"`), or "dynamic"
|
|
237
|
+
// (`import()` / `require()`). `bindings` pair each imported name with its local
|
|
238
|
+
// name (`*` for a namespace import, `default` for a default import); for a
|
|
239
|
+
// re-export, `local` is null.
|
|
240
|
+
function add(specifier, typeOnly, names = [], kind = "import", bindings = []) {
|
|
219
241
|
if (typeof specifier !== "string") return;
|
|
220
242
|
const locals = [];
|
|
221
243
|
const seen = new Set();
|
|
@@ -224,7 +246,7 @@ export function extractJsImports(source) {
|
|
|
224
246
|
seen.add(name);
|
|
225
247
|
locals.push(name);
|
|
226
248
|
}
|
|
227
|
-
imports.push({ specifier, typeOnly: Boolean(typeOnly), names: locals });
|
|
249
|
+
imports.push({ specifier, typeOnly: Boolean(typeOnly), names: locals, kind, bindings: typeOnly ? [] : bindings });
|
|
228
250
|
}
|
|
229
251
|
|
|
230
252
|
function parseRequire(at) {
|
|
@@ -234,7 +256,7 @@ export function extractJsImports(source) {
|
|
|
234
256
|
if (!lit) return at;
|
|
235
257
|
const after = skipWs(source, lit.next);
|
|
236
258
|
if (source[after] !== ")") return at;
|
|
237
|
-
add(lit.value, false);
|
|
259
|
+
add(lit.value, false, [], "dynamic");
|
|
238
260
|
return after + 1;
|
|
239
261
|
}
|
|
240
262
|
|
|
@@ -245,7 +267,7 @@ export function extractJsImports(source) {
|
|
|
245
267
|
if (!lit) return at;
|
|
246
268
|
const after = skipWs(source, lit.next);
|
|
247
269
|
if (source[after] !== ")") return at;
|
|
248
|
-
add(lit.value, false);
|
|
270
|
+
add(lit.value, false, [], "dynamic");
|
|
249
271
|
return after + 1;
|
|
250
272
|
}
|
|
251
273
|
if (source[j] === ".") return at;
|
|
@@ -264,22 +286,26 @@ export function extractJsImports(source) {
|
|
|
264
286
|
const clause = readFromClause(source, close + 1);
|
|
265
287
|
if (!clause) return close + 1;
|
|
266
288
|
const onlyTypes = typeOnly || braceIsTypeOnly(inner);
|
|
267
|
-
add(clause.value, onlyTypes, typeOnly ? [] : localNames(inner));
|
|
289
|
+
add(clause.value, onlyTypes, typeOnly ? [] : localNames(inner), "import", typeOnly ? [] : braceBindings(inner));
|
|
268
290
|
return clause.next;
|
|
269
291
|
}
|
|
270
292
|
|
|
271
293
|
if (source[j] === "*" || isIdentStart(source[j]) || source[j] === "'" || source[j] === '"') {
|
|
272
294
|
if (source[j] === "'" || source[j] === '"') {
|
|
273
295
|
const lit = readJsString(source, j);
|
|
274
|
-
if (lit) add(lit.value, typeOnly);
|
|
296
|
+
if (lit) add(lit.value, typeOnly, [], "side-effect");
|
|
275
297
|
return lit ? lit.next : j;
|
|
276
298
|
}
|
|
277
299
|
// default binding, namespace, or `name, { ... }`
|
|
278
300
|
let sawValue = !typeOnly;
|
|
279
301
|
const names = [];
|
|
302
|
+
const bindings = [];
|
|
280
303
|
if (source[j] !== "*") {
|
|
281
304
|
const binding = readWord(source, j);
|
|
282
|
-
if (binding.word && !typeOnly)
|
|
305
|
+
if (binding.word && !typeOnly) {
|
|
306
|
+
names.push(binding.word);
|
|
307
|
+
bindings.push({ imported: "default", local: binding.word });
|
|
308
|
+
}
|
|
283
309
|
j = skipWs(source, binding.next);
|
|
284
310
|
if (source[j] === ",") {
|
|
285
311
|
sawValue = !typeOnly;
|
|
@@ -288,7 +314,10 @@ export function extractJsImports(source) {
|
|
|
288
314
|
} else {
|
|
289
315
|
j = skipWs(source, j + 1);
|
|
290
316
|
const alias = readAlias(source, j);
|
|
291
|
-
if (alias.name && !typeOnly)
|
|
317
|
+
if (alias.name && !typeOnly) {
|
|
318
|
+
names.push(alias.name);
|
|
319
|
+
bindings.push({ imported: "*", local: alias.name });
|
|
320
|
+
}
|
|
292
321
|
j = skipWs(source, alias.next);
|
|
293
322
|
}
|
|
294
323
|
if (source[j] === "{") {
|
|
@@ -298,13 +327,16 @@ export function extractJsImports(source) {
|
|
|
298
327
|
const clause = readFromClause(source, close + 1);
|
|
299
328
|
if (!clause) return close + 1;
|
|
300
329
|
const onlyTypes = typeOnly || (!sawValue && braceIsTypeOnly(inner));
|
|
301
|
-
if (!onlyTypes)
|
|
302
|
-
|
|
330
|
+
if (!onlyTypes) {
|
|
331
|
+
names.push(...localNames(inner));
|
|
332
|
+
bindings.push(...braceBindings(inner));
|
|
333
|
+
}
|
|
334
|
+
add(clause.value, onlyTypes, names, "import", bindings);
|
|
303
335
|
return clause.next;
|
|
304
336
|
}
|
|
305
337
|
const clause = readFromClause(source, j);
|
|
306
338
|
if (!clause) return at;
|
|
307
|
-
add(clause.value, typeOnly, typeOnly ? [] : names);
|
|
339
|
+
add(clause.value, typeOnly, typeOnly ? [] : names, "import", bindings);
|
|
308
340
|
return clause.next;
|
|
309
341
|
}
|
|
310
342
|
|
|
@@ -322,7 +354,7 @@ export function extractJsImports(source) {
|
|
|
322
354
|
if (source[j] === "*") {
|
|
323
355
|
const clause = readFromClause(source, j + 1);
|
|
324
356
|
if (!clause) return at;
|
|
325
|
-
add(clause.value, typeOnly);
|
|
357
|
+
add(clause.value, typeOnly, [], "reexport-all");
|
|
326
358
|
return clause.next;
|
|
327
359
|
}
|
|
328
360
|
if (source[j] === "{") {
|
|
@@ -331,7 +363,8 @@ export function extractJsImports(source) {
|
|
|
331
363
|
const inner = source.slice(j + 1, close);
|
|
332
364
|
const clause = readFromClause(source, close + 1);
|
|
333
365
|
if (!clause) return close + 1;
|
|
334
|
-
|
|
366
|
+
const bindings = braceBindings(inner).map((item) => ({ imported: item.imported, local: null }));
|
|
367
|
+
add(clause.value, typeOnly || braceIsTypeOnly(inner), [], "reexport", bindings);
|
|
335
368
|
return clause.next;
|
|
336
369
|
}
|
|
337
370
|
return at;
|