diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2a246..b96c776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ always bump at least minor; breaking schema changes bump major. - Network errors name a closed failure class (`connection refused`, TLS / corporate CA, `proxy required`) without echoing `error.message`, headers, or body (#83). - Document corporate proxy / CA setup and the submit visibility-probe captive-proxy edge (`docs/corporate-networks.md`, #83). +### Fixed +- Tier 1 import extraction no longer credits commented-out imports inside + Go import blocks (a genuine false-attribution vector), and drops + relative/local specifiers in JS (`./`, `../`, `/`) and leading-dot + Python modules that produced empty or `.` candidates (#85, by + @eeshsaxena). + ## [0.14.2] - 2026-08-18 ### Fixed diff --git a/src/import-detect.ts b/src/import-detect.ts index b69cd04..7057487 100644 --- a/src/import-detect.ts +++ b/src/import-detect.ts @@ -127,6 +127,15 @@ function normalizeJs(raw: string): string { return raw.split("/")[0]; } +// A relative ("./x", "../x") or absolute ("/x") specifier is a local module, +// not a third-party package — never a package-map candidate. Same intent as +// Ruby's require_relative and Rust's crate/self/super exclusions below; +// without this, `import x from "./util"` normalizes to a bare "." (and +// "../lib/x" to "..") and pollutes the candidate list. +function isLocalModuleSpecifier(raw: string): boolean { + return raw.startsWith(".") || raw.startsWith("/"); +} + function extractJsImports(text: string): string[] { const found: string[] = []; // import ... from "pkg" / export ... from "pkg" (also covers `export * from`, @@ -151,12 +160,13 @@ function extractJsImports(text: string): string[] { const openQuotePos = indices[2][0] - 1; const { line, offsetInLine } = lineAndOffsetAt(text, openQuotePos); if (isInsideStringLiteral(line, offsetInLine)) continue; + if (isLocalModuleSpecifier(m[2])) continue; found.push(normalizeJs(m[2])); } // import "pkg"; (side-effect import, no `from`) const bareImportRe = /^[ \t]*import\s+["']([^"'\n]+)["']\s*;?/gm; for (const m of text.matchAll(bareImportRe)) { - if (isRealStatement(text, m.index!)) found.push(normalizeJs(m[1])); + if (isRealStatement(text, m.index!) && !isLocalModuleSpecifier(m[1])) found.push(normalizeJs(m[1])); } // require("pkg") / import("pkg") — dynamic import, anywhere a real // statement could reasonably put it (assignment, await, bare call). @@ -165,6 +175,7 @@ function extractJsImports(text: string): string[] { const { line, offsetInLine } = lineAndOffsetAt(text, m.index!); if (isCommentLine(line)) continue; if (isInsideStringLiteral(line, offsetInLine)) continue; + if (isLocalModuleSpecifier(m[1])) continue; found.push(normalizeJs(m[1])); } return found; @@ -258,7 +269,13 @@ function extractPython(text: string): string[] { // from pkg[.sub] import x const fromRe = /^[ \t]*from\s+([\w.]+)\s+import\b/gm; for (const m of text.matchAll(fromRe)) { - if (isRealStatement(text, m.index!)) found.push(m[1].split(".")[0]); + if (!isRealStatement(text, m.index!)) continue; + // A relative import (`from . import x`, `from .models import X`) has a + // leading-dot module, so its first dotted segment is empty — a local + // module, not a package. Same `if (root)` guard the `import` form above + // already applies; without it, an empty "" leaks into the candidate list. + const root = m[1].split(".")[0]; + if (root) found.push(root); } return found; } @@ -276,7 +293,14 @@ function extractGo(text: string): string[] { for (const m of text.matchAll(blockRe)) { if (!isRealStatement(text, m.index!)) continue; const pathRe = /["']([^"'\n]+)["']/g; - for (const p of m[1].matchAll(pathRe)) found.push(normalize(p[1])); + // Skip `//`-commented lines inside the block — a commented-out import + // (`// "github.com/foo/bar"`) is not a real dependency. The single-line + // form above already rejects these via isRealStatement; the block body + // needs the same check per line, or a commented-out path is attributed. + for (const line of m[1].split("\n")) { + if (isCommentLine(line)) continue; + for (const p of line.matchAll(pathRe)) found.push(normalize(p[1])); + } } return found; } diff --git a/test/import-detect.test.ts b/test/import-detect.test.ts index 2aeb803..fe754f2 100644 --- a/test/import-detect.test.ts +++ b/test/import-detect.test.ts @@ -61,6 +61,13 @@ describe("extractImportedPackages — JS/TS", () => { expect(extractImportedPackages('// see https://npmjs.com/package/stripe for docs', "a.ts")).toEqual([]); }); + it("does not treat a relative import as a package", () => { + expect(extractImportedPackages('import { a } from "./util";', "a.ts")).toEqual([]); + expect(extractImportedPackages('import b from "../lib/x";', "a.ts")).toEqual([]); + expect(extractImportedPackages('export * from "./local";', "a.ts")).toEqual([]); + expect(extractImportedPackages('const x = require("./local");', "a.ts")).toEqual([]); + }); + it("never scans a markdown file, even if it contains real-looking import syntax", () => { expect(extractImportedPackages('import Stripe from "stripe";', "README.md")).toEqual([]); }); @@ -88,6 +95,12 @@ describe("extractImportedPackages — Python", () => { expect(extractImportedPackages("from django.db import models", "a.py")).toEqual(["django"]); }); + it("does not treat a relative from-import as a package", () => { + expect(extractImportedPackages("from . import helpers", "a.py")).toEqual([]); + expect(extractImportedPackages("from .models import User", "a.py")).toEqual([]); + expect(extractImportedPackages("from ..pkg import thing", "a.py")).toEqual([]); + }); + it("does not match a # comment", () => { expect(extractImportedPackages("# import pandas as pd", "a.py")).toEqual([]); }); @@ -112,6 +125,11 @@ describe("extractImportedPackages — Go", () => { it("does not match a // comment", () => { expect(extractImportedPackages('// import "fmt"', "main.go")).toEqual([]); }); + + it("does not match a // commented import inside an import block", () => { + const diff = 'import (\n\t"fmt"\n\t// "github.com/spf13/cobra"\n\t"github.com/gin-gonic/gin"\n)'; + expect(extractImportedPackages(diff, "main.go")).toEqual(["fmt", "github.com/gin-gonic/gin"]); + }); }); describe("extractImportedPackages — Ruby", () => {