From 4be963fdd12d93dd6c3c707210a9abbd48a8c725 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Mon, 18 May 2026 00:26:19 +0200 Subject: [PATCH] build: ignore type-only circular edges --- scripts/check-circular-deps.mjs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/scripts/check-circular-deps.mjs b/scripts/check-circular-deps.mjs index 8309b7878..3503f0b46 100644 --- a/scripts/check-circular-deps.mjs +++ b/scripts/check-circular-deps.mjs @@ -161,14 +161,35 @@ function extractImports(filePath) { const visit = (node) => { // import X from "..." | import "..." | import { X } from "..." + // + // Skip top-level type-only imports (`import type { X } from "..."`) — + // TypeScript erases them at compile time and they cannot cause runtime + // cycles. Same reasoning as skipping dynamic imports below. if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { - specs.push(node.moduleSpecifier.text); + const isTypeOnly = node.importClause?.isTypeOnly === true; + if (!isTypeOnly) { + // Also skip if EVERY named specifier is marked `type` individually + // — that's the `import { type X, type Y } from "..."` shape. + const namedBindings = node.importClause?.namedBindings; + const allSpecifiersTypeOnly = + namedBindings && + ts.isNamedImports(namedBindings) && + namedBindings.elements.length > 0 && + namedBindings.elements.every((e) => e.isTypeOnly === true) && + // If there's a default-import binding (importClause.name), that's + // a runtime binding even if all named ones are type-only. + !node.importClause?.name; + if (!allSpecifiersTypeOnly) { + specs.push(node.moduleSpecifier.text); + } + } } - // export { X } from "..." | export * from "..." + // export { X } from "..." | export * from "..." — skip `export type` if ( ts.isExportDeclaration(node) && node.moduleSpecifier && - ts.isStringLiteral(node.moduleSpecifier) + ts.isStringLiteral(node.moduleSpecifier) && + node.isTypeOnly !== true ) { specs.push(node.moduleSpecifier.text); }