Skip to content

scanner: Python relative imports (from .mod import x) resolve to nothing — intra-package edges silently lost #136

Description

@JordanCoin

Found by running codemap cold on an unfamiliar Python repo. It reports the package's modules as unconnected and gives no indication anything was dropped.

Symptom

~/Code/agentgate — a 14-module Python package:

$ codemap --deps .
Src ════════════════════════════════════════
  +12 standalone files

Tests ══════════════════════════════════════
  test_agentgate ──┬──▶ src/agentgate/auth
                   ├──▶ src/agentgate/config
                   └──▶ ... 8 total

13 files · 86 functions · 8 deps

Every source module is "standalone". The only edges in the whole project come from the test file.

That is not what the code says. src/agentgate/cli.py:12:

from .client import call
from .config import Paths, default_home, init_home, load_config, save_config
from .crypto_store import EncryptedStore

Counted across src/agentgate/:

import form count codemap resolved
relative — from .config import ... 31 0
absolute — from agentgate.config import ... 0
absolute, in tests/ 8 8

So --importers src/agentgate/config.py reports importer_count: 1 (the test file). cli.py imports it on line 13 and is not counted.

Relative imports are the dominant intra-package convention in modern Python, so this is not an edge case — it is most Python packages.

Cause

fuzzyResolveWithWorkspace routes any leading-dot import to resolveRelative (scanner/filegraph.go:316):

if strings.HasPrefix(imp, ".") {
    return resolveRelative(imp, fromDir, idx, sourceLanguage)
}

But resolveRelative (filegraph.go:384) is written for filesystem-style specifiers and only understands a dot followed by a slash:

for strings.HasPrefix(rest, "../") { levels++; rest = strings.TrimPrefix(rest, "../") }
rest = strings.TrimPrefix(rest, "./")

Python's form has no slash. .client matches neither ../ nor ./, so rest stays .client and the lookup becomes <dir>/.client — a dotfile that does not exist. Nothing resolves, and nothing is reported.

Python's actual semantics are dot-counting, not path-prefix:

specifier means
.client sibling module client in the current package
..pkg.mod up one package, then pkg/mod
from . import x the current package itself

Suggested fix

Give Python its own branch before the generic relative handler: count leading dots (first dot = current package, each additional = one level up), then translate the remaining dotted path to a path separator, then resolve <dir>/<path>.py or <dir>/<path>/__init__.py.

The bySuffix and byExact machinery already handles the final lookup — only the specifier translation is missing. Worth checking the same shape for other dot-separated languages before assuming Python is unique here.

Why this is the serious kind

Same class as #132 (TypeScript ESM). The graph is not merely incomplete — it is confidently wrong and says nothing:

  • --deps presents "12 standalone files" as a finding
  • --importers config.py answers 1 when the true answer is 4
  • coverage.status reports no problem

An agent asked "what depends on config.py?" gets a number that is quietly wrong by 4x and no signal to distrust it. This is the failure mode that makes a blast-radius answer unsafe to act on.

Related: #132 (same class, TS/ESM), #111 (per-query coverage would surface it), #133.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions