From 87cac9ee85a0192f440fb70a820d3b6ce8cf075a Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 15 Jun 2026 15:50:49 +0900 Subject: [PATCH] Ignore type-only import cycles --- scripts/check-import-cycles.mjs | 30 +++++++++++++++++++++-- tests/scripts/development-scripts.test.ts | 15 +++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/scripts/check-import-cycles.mjs b/scripts/check-import-cycles.mjs index 377b3bbd..ba4bfaaf 100644 --- a/scripts/check-import-cycles.mjs +++ b/scripts/check-import-cycles.mjs @@ -75,11 +75,16 @@ function importEdges(sourceFile) { const edges = []; for (const statement of sourceFile.statements) { - if (ts.isImportDeclaration(statement) && stringLiteralText(statement.moduleSpecifier)) { + if (ts.isImportDeclaration(statement) && hasRuntimeImportEdge(statement) && stringLiteralText(statement.moduleSpecifier)) { edges.push({ specifier: stringLiteralText(statement.moduleSpecifier), }); - } else if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && stringLiteralText(statement.moduleSpecifier)) { + } else if ( + ts.isExportDeclaration(statement) && + hasRuntimeExportEdge(statement) && + statement.moduleSpecifier && + stringLiteralText(statement.moduleSpecifier) + ) { edges.push({ specifier: stringLiteralText(statement.moduleSpecifier), }); @@ -89,6 +94,27 @@ function importEdges(sourceFile) { return edges; } +function hasRuntimeImportEdge(statement) { + const importClause = statement.importClause; + if (!importClause) return true; + if (importClause.isTypeOnly) return false; + if (importClause.name) return true; + + const namedBindings = importClause.namedBindings; + if (!namedBindings) return false; + if (ts.isNamespaceImport(namedBindings)) return true; + return namedBindings.elements.some((element) => !element.isTypeOnly); +} + +function hasRuntimeExportEdge(statement) { + if (statement.isTypeOnly) return false; + + const exportClause = statement.exportClause; + if (!exportClause) return true; + if (ts.isNamespaceExport(exportClause)) return true; + return exportClause.elements.some((element) => !element.isTypeOnly); +} + function stringLiteralText(node) { return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : null; } diff --git a/tests/scripts/development-scripts.test.ts b/tests/scripts/development-scripts.test.ts index 5ad7e3d2..745698c2 100644 --- a/tests/scripts/development-scripts.test.ts +++ b/tests/scripts/development-scripts.test.ts @@ -171,7 +171,7 @@ describe("development scripts", () => { expect(result.stderr).toContain("src/a.ts -> src/b.ts -> src/a.ts"); }); - it("detects type-only import cycles", async () => { + it("ignores type-only import cycles", async () => { const cwd = await tempWorkspace(); await writeImportCycleFixture(cwd, { "src/a.ts": 'import type { B } from "./b";\nexport interface A { b?: B }\n', @@ -180,6 +180,19 @@ describe("development scripts", () => { const result = runNodeScript("scripts/check-import-cycles.mjs", [], cwd); + expect(result.status).toBe(0); + expect(result.stdout).toContain("No import cycles found."); + }); + + it("detects import cycles when mixed imports include runtime values", async () => { + const cwd = await tempWorkspace(); + await writeImportCycleFixture(cwd, { + "src/a.ts": 'import { type B, b } from "./b";\nexport interface A { b?: B }\nexport const a = b;\n', + "src/b.ts": 'import { a, type A } from "./a";\nexport interface B { a?: A }\nexport const b = a;\n', + }); + + const result = runNodeScript("scripts/check-import-cycles.mjs", [], cwd); + expect(result.status).toBe(1); expect(result.stderr).toContain("found 1 cycle"); expect(result.stderr).toContain("src/a.ts -> src/b.ts -> src/a.ts");