@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 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.3`). `comment` and `badge` run from the action checkout. Inputs are `command` (`publish`, `demo`, `doctor`, `comment`, or `badge`), `config`, `node-version`, `pr-comment`, `version`, `badge-out`, `badge-url`, and `badge-link`. The action passes `--force` so a repository `devEngines` pin does not stop `npx` on npm versions that enforce `devEngines` (newer npm). Node 20's bundled npm does not enforce that field. From a shell, use the same flag: `npx --force --yes @chappibunny/repolens demo`. `--ignore-engines` does not skip `devEngines`.
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.3`):
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.3
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.3` 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.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.3
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.3` has to exist before `uses: CHAPIBUNNY/repolens@v1.16.3` resolves, and `@chappibunny/repolens@1.16.3` has to be on npm before `publish` can install it. `command: comment` and `command: badge` do not install that package.
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.3
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chappibunny/repolens",
3
- "version": "1.16.3",
3
+ "version": "1.16.4",
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",
@@ -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 (!imported.typeOnly) {
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
- const typeOnlyAdj = new Map();
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
- if (!typeOnlyAdj.has(edge.from)) typeOnlyAdj.set(edge.from, new Set());
475
- if (!typeOnlyAdj.has(edge.to)) typeOnlyAdj.set(edge.to, new Set());
476
- typeOnlyAdj.get(edge.from).add(edge.to);
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 = detectCycles(typeOnlyAdj).map((path) => typeOnlyReport(path));
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
- const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0);
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
- function add(specifier, typeOnly, names = []) {
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) names.push(binding.word);
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) names.push(alias.name);
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) names.push(...localNames(inner));
302
- add(clause.value, onlyTypes, names);
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
- add(clause.value, typeOnly || braceIsTypeOnly(inner));
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;