From 70e0f360d9c561dca21941158e075874bef4ade9 Mon Sep 17 00:00:00 2001 From: Cause Chung Date: Tue, 11 Aug 2026 19:50:58 -0400 Subject: [PATCH] fix(module-interface-import): block export-from, dynamic import, .js fallback, and directory index resolution The Devin pre-tool-use hook was not blocking imports from specific files in several edge cases: - export { x } / export * from '../module/file' - await import('../module/file') - import { x } from '../module/file.js' resolving to a .ts file - import { x } from '../module' resolving to module/index.ts Update the regex in extractJsImportPaths to also match export-from and import() patterns, and refactor resolveRelativeImport to fall back through JS/TS extensions and directory index files. Bump version to 1.1.3. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .devin-plugin/plugin.json | 2 +- package-lock.json | 4 +- package.json | 2 +- .../module-interface-import-gate.test.ts | 159 ++++++++++++++++++ .../gates/module-interface-import-gate.ts | 52 +++++- 5 files changed, 212 insertions(+), 7 deletions(-) diff --git a/.devin-plugin/plugin.json b/.devin-plugin/plugin.json index 1e65764..74960a1 100644 --- a/.devin-plugin/plugin.json +++ b/.devin-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "module-gates", - "version": "1.1.2", + "version": "1.1.3", "description": "Enforce module boundary contracts for Devin CLI.", "author": { "name": "Cause Chung", diff --git a/package-lock.json b/package-lock.json index 8e42e0e..a22f31d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cuzfrog/module-gates", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cuzfrog/module-gates", - "version": "1.1.2", + "version": "1.1.3", "license": "MIT", "dependencies": { "jiti": "2.7.0", diff --git a/package.json b/package.json index fb5851c..edf880b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cuzfrog/module-gates", - "version": "1.1.2", + "version": "1.1.3", "description": "Controls the entropy of the codebase by enforcing code module boundaries. Ships bridges for pi, Claude Code, and Devin CLI.", "keywords": [ "pi-package", diff --git a/src/core/gates/module-interface-import-gate.test.ts b/src/core/gates/module-interface-import-gate.test.ts index 404f5d3..fa68b48 100644 --- a/src/core/gates/module-interface-import-gate.test.ts +++ b/src/core/gates/module-interface-import-gate.test.ts @@ -543,4 +543,163 @@ describe("checkModuleInterfaceImports", () => { expect(result.blocked).toBe(true); }); + + it("blocks export from non-interface file in another module", () => { + setup(); + createFile("", "src", "module1", "module.md"); + createFile("export function foo() {}", "src", "module1", "file1.ts"); + createFile("", "src", "module2", "module.md"); + createDir("src", "module2"); + + const dirToModule = new Map(); + dirToModule.set(join(tmpDir, "src", "module1"), join(tmpDir, "src", "module1")); + dirToModule.set(join(tmpDir, "src", "module2"), join(tmpDir, "src", "module2")); + const index = makeIndex(dirToModule); + + const content = 'export { foo } from "../module1/file1";\n'; + const result = checkModuleInterfaceImports( + "src/module2/app.ts", + content, + index, + tmpDir, + false, + ["src/"], + ); + + expect(result.blocked).toBe(true); + if (result.blocked) { + expect(result.reason).toContain("file1.ts"); + } + }); + + it("blocks dynamic import of non-interface file in another module", () => { + setup(); + createFile("", "src", "module1", "module.md"); + createFile("export function foo() {}", "src", "module1", "file1.ts"); + createFile("", "src", "module2", "module.md"); + createDir("src", "module2"); + + const dirToModule = new Map(); + dirToModule.set(join(tmpDir, "src", "module1"), join(tmpDir, "src", "module1")); + dirToModule.set(join(tmpDir, "src", "module2"), join(tmpDir, "src", "module2")); + const index = makeIndex(dirToModule); + + const content = 'const { foo } = await import("../module1/file1");\n'; + const result = checkModuleInterfaceImports( + "src/module2/app.ts", + content, + index, + tmpDir, + false, + ["src/"], + ); + + expect(result.blocked).toBe(true); + if (result.blocked) { + expect(result.reason).toContain("file1.ts"); + } + }); + + it("blocks import with .js extension resolving to .ts non-interface file", () => { + setup(); + createFile("", "src", "module1", "module.md"); + createFile("export function foo() {}", "src", "module1", "file1.ts"); + createFile("", "src", "module2", "module.md"); + createDir("src", "module2"); + + const dirToModule = new Map(); + dirToModule.set(join(tmpDir, "src", "module1"), join(tmpDir, "src", "module1")); + dirToModule.set(join(tmpDir, "src", "module2"), join(tmpDir, "src", "module2")); + const index = makeIndex(dirToModule); + + const content = 'import { foo } from "../module1/file1.js";\n'; + const result = checkModuleInterfaceImports( + "src/module2/app.ts", + content, + index, + tmpDir, + false, + ["src/"], + ); + + expect(result.blocked).toBe(true); + if (result.blocked) { + expect(result.reason).toContain("file1.ts"); + } + }); + + it("allows import from module directory resolving to index.ts", () => { + setup(); + createFile("", "src", "module1", "module.md"); + createFile("export function foo() {}", "src", "module1", "index.ts"); + createFile("", "src", "module2", "module.md"); + createDir("src", "module2"); + + const dirToModule = new Map(); + dirToModule.set(join(tmpDir, "src", "module1"), join(tmpDir, "src", "module1")); + dirToModule.set(join(tmpDir, "src", "module2"), join(tmpDir, "src", "module2")); + const index = makeIndex(dirToModule); + + const content = 'import { foo } from "../module1";\n'; + const result = checkModuleInterfaceImports( + "src/module2/app.ts", + content, + index, + tmpDir, + false, + ["src/"], + ); + + expect(result.blocked).toBe(false); + }); + + it("blocks export * from non-interface file", () => { + setup(); + createFile("", "src", "module1", "module.md"); + createFile("export function foo() {}", "src", "module1", "file1.ts"); + createFile("", "src", "module2", "module.md"); + createDir("src", "module2"); + + const dirToModule = new Map(); + dirToModule.set(join(tmpDir, "src", "module1"), join(tmpDir, "src", "module1")); + dirToModule.set(join(tmpDir, "src", "module2"), join(tmpDir, "src", "module2")); + const index = makeIndex(dirToModule); + + const content = 'export * from "../module1/file1";\n'; + const result = checkModuleInterfaceImports( + "src/module2/app.ts", + content, + index, + tmpDir, + false, + ["src/"], + ); + + expect(result.blocked).toBe(true); + }); + + it("blocks import with .js extension when .js file exists and is non-interface", () => { + setup(); + createFile("", "src", "module1", "module.md"); + createFile("export function foo() {}", "src", "module1", "file1.js"); + createFile("", "src", "module2", "module.md"); + createDir("src", "module2"); + + const dirToModule = new Map(); + dirToModule.set(join(tmpDir, "src", "module1"), join(tmpDir, "src", "module1")); + dirToModule.set(join(tmpDir, "src", "module2"), join(tmpDir, "src", "module2")); + const index = makeIndex(dirToModule); + + const content = 'import { foo } from "../module1/file1.js";\n'; + const result = checkModuleInterfaceImports( + "src/module2/app.ts", + content, + index, + tmpDir, + false, + ["src/"], + ); + + expect(result.blocked).toBe(true); + }); }); diff --git a/src/core/gates/module-interface-import-gate.ts b/src/core/gates/module-interface-import-gate.ts index fef0a21..38c4733 100644 --- a/src/core/gates/module-interface-import-gate.ts +++ b/src/core/gates/module-interface-import-gate.ts @@ -77,6 +77,14 @@ function extractJsImportPaths(content: string): string[] { results.push(m[1]); } + for (const m of content.matchAll(/^\s*export\s+[\s\S]*?\s+from\s+["']([^"']+)["']/gm)) { + results.push(m[1]); + } + + for (const m of content.matchAll(/import\s*\(\s*["']([^"']+)["']\s*\)/g)) { + results.push(m[1]); + } + for (const m of content.matchAll(/^\s*(?:const|let|var)\s+[\s\S]*?=\s*require\s*\(\s*["']([^"']+)["']\s*\)/gm)) { results.push(m[1]); } @@ -97,6 +105,10 @@ function extractRustUsePaths(content: string): string[] { return results; } +const JS_TS_EXTS = [".ts", ".tsx", ".js", ".jsx"]; +const FILE_EXTS = [...JS_TS_EXTS, ".rs"]; +const DIRECTORY_INDEX_NAMES = ["index.ts", "index.tsx", "index.js", "index.jsx", "mod.rs"]; + function resolveRelativeImport(importPath: string, fileDir: string): string | undefined { if (!importPath.startsWith(".")) return undefined; @@ -104,17 +116,51 @@ function resolveRelativeImport(importPath: string, fileDir: string): string | un const ext = path.extname(resolved); if (ext) { - return fs.existsSync(resolved) ? resolved : undefined; + return resolveWithExtension(resolved, ext); + } + + const asFile = resolveAsFile(resolved); + if (asFile) return asFile; + + return resolveAsDirectory(resolved); +} + +function resolveWithExtension(filePath: string, ext: string): string | undefined { + if (!JS_TS_EXTS.includes(ext)) { + return fs.existsSync(filePath) ? filePath : undefined; } - for (const tryExt of [".ts", ".tsx", ".js", ".jsx", ".rs"]) { - const candidate = resolved + tryExt; + const base = filePath.slice(0, -ext.length); + for (const tryExt of [ext, ...JS_TS_EXTS.filter((e) => e !== ext)]) { + const candidate = base + tryExt; if (fs.existsSync(candidate)) return candidate; } return undefined; } +function resolveAsFile(basePath: string): string | undefined { + for (const tryExt of FILE_EXTS) { + const candidate = basePath + tryExt; + if (fs.existsSync(candidate)) return candidate; + } + return undefined; +} + +function resolveAsDirectory(dirPath: string): string | undefined { + try { + if (!fs.lstatSync(dirPath).isDirectory()) return undefined; + } catch { + return undefined; + } + + for (const name of DIRECTORY_INDEX_NAMES) { + const candidate = path.join(dirPath, name); + if (fs.existsSync(candidate)) return candidate; + } + return undefined; +} + function resolveRustCratePath(modulePath: string, srcRoots: string[]): string | undefined { const segments = modulePath.split("/");