Skip to content

Add cross-file import tracking functionality - #1

Merged
cmyui merged 27 commits into
mainfrom
cross-file
Jan 10, 2026
Merged

Add cross-file import tracking functionality#1
cmyui merged 27 commits into
mainfrom
cross-file

Conversation

@cmyui

@cmyui cmyui commented Jan 10, 2026

Copy link
Copy Markdown
Owner

Cross-File Import Tracking

Summary

This document describes the cross-file import tracking feature that was implemented for the unused import detector. The feature transforms the tool from single-file analysis to cross-file import tracking, following imports like Python's runtime and detecting unused imports across an entire project.


What Was Implemented

Core Features

  1. Cross-file import tracking - Follow imports from an entry point, building an import graph
  2. Re-export detection - Imports used by other files are NOT flagged as unused
  3. Cascade detection - When an import becomes unused, cascade to find additional unused imports
  4. Implicit re-export warnings - Warn when imports are re-exported without being in __all__
  5. Circular import detection - Detect and report circular import chains
  6. Unreachable file warnings - Warn about files that become truly dead code after fixing imports
  7. Submodule traversal - Handle from pkg import submodule where submodule isn't in pkg/__init__.py

CLI Interface (Actual)

# Cross-file mode (default) - pass entry point file or directory
remove-unused-imports main.py                    # Entry point file
remove-unused-imports src/                       # Directory (analyzes all .py files)

# With options
remove-unused-imports main.py --fix              # Auto-fix unused imports
remove-unused-imports main.py --quiet            # Only show summary
remove-unused-imports main.py --warn-implicit-reexports  # Warn about implicit re-exports
remove-unused-imports main.py --warn-circular    # Warn about circular imports
remove-unused-imports main.py --warn-unreachable # Warn about unreachable files

# Single-file mode (original behavior, no cross-file tracking)
remove-unused-imports --single-file myfile.py
remove-unused-imports --single-file src/

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                         Entry Point                              │
│                      main.py or src/                             │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                   Module Resolver (_resolution.py)               │
│  - Resolves import statements to file paths                      │
│  - Handles relative imports (from . import x)                    │
│  - Detects external modules (stdlib/third-party)                 │
│  - Prevents resolution beyond source root                        │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                   Graph Builder (_graph.py)                      │
│  - BFS from entry point, following imports                       │
│  - Builds ImportGraph with nodes (files) and edges (imports)     │
│  - Handles submodule traversal for implicit imports              │
│  - Cycle detection (Tarjan's algorithm)                          │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                Cross-File Analyzer (_cross_file.py)              │
│  - Runs single-file analysis on each module                      │
│  - Detects re-export-only imports (in __all__ but unused locally)│
│  - Detects implicit re-export-only imports (__init__.py)         │
│  - CASCADE: Iterates until stable, tracking file reachability    │
│  - Finds implicit re-exports (not in __all__)                    │
│  - Aggregates external module usage                              │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                   Output Formatter (_format.py)                  │
│  - Groups unused imports by file and line                        │
│  - Formats implicit re-export warnings                           │
│  - Formats circular import warnings                              │
│  - Formats unreachable file warnings                             │
│  - Summary with totals                                           │
└─────────────────────────────────────────────────────────────────┘

Data Structures

_data.py

@dataclass
class ImportInfo:
    name: str              # Bound name (considering aliases)
    original_name: str     # Original name before aliasing
    module: str            # Module being imported from
    lineno: int
    is_from_import: bool
    level: int             # 0=absolute, 1=from ., 2=from .., etc.

@dataclass
class ModuleInfo:
    file_path: Path
    module_name: str          # "mypackage.submodule.utils"
    is_package: bool          # True for __init__.py
    imports: list[ImportInfo]
    exports: set[str]         # Names in __all__
    defined_names: set[str]   # Classes, functions, variables defined

@dataclass
class ImportEdge:
    importer: Path
    imported: Path | None     # None = external module
    module_name: str          # The module string from import statement
    names: set[str]           # Names being imported
    is_external: bool
    level: int

@dataclass
class ImplicitReexport:
    source_file: Path
    import_name: str
    used_by: set[Path]

_cross_file.py

@dataclass
class CrossFileResult:
    unused_imports: dict[Path, list[ImportInfo]]
    implicit_reexports: list[ImplicitReexport]
    external_usage: dict[str, set[Path]]  # module -> files using it
    circular_imports: list[list[Path]]
    unreachable_files: set[Path]          # Truly dead code files

Key Algorithms

Cascade Detection

The cascade algorithm iteratively finds unused imports until stable:

1. Run single-file analysis on each module
2. Find "reexport-only" imports (in __all__ but unused locally)
3. Find "implicit-reexport-only" imports (__init__.py without __all__)
4. LOOP until no changes:
   a. Update file reachability based on removed imports
   b. Find re-exported imports (excluding unreachable files as consumers)
   c. Mark imports as removed if:
      - Unused locally AND not re-exported, OR
      - Reexport-only AND not re-exported, OR
      - Implicit-reexport-only AND not re-exported
5. Build final unused_imports from stable removed set

Unreachable File Detection

Two concepts are tracked separately:

Concept Used For Criteria
Potentially unreachable Cascade detection No direct import edges after removing unused imports
Truly unreachable User warning No direct edges AND no reachable ancestor packages

This distinction handles patterns where import pkg + pkg.submodule.x keeps submodules accessible at runtime even if from pkg import submodule is unused.

Submodule Traversal

When processing from pkg import name:

  1. Resolve pkg to pkg/__init__.py
  2. Check if name is already in pkg/__init__.py (imports or definitions)
  3. If NOT, try resolving pkg.name as a submodule
  4. If found, traverse it (handles implicit submodule imports)

Files Created/Modified

File Action Description
_data.py Modified Added ModuleInfo, ImportEdge, ImplicitReexport, level field
_resolution.py Created Module resolver, external module detection
_graph.py Created Import graph, graph builder, cycle detection, submodule traversal
_cross_file.py Created Cross-file analyzer with cascade detection
_format.py Created Output formatting for CLI
_ast_helpers.py Modified Added level to ImportExtractor, added collect_dunder_all_names
_main.py Modified New CLI interface, cross-file mode as default
_detection.py Modified Added ignore_all parameter for reexport-only detection
tests/cross_file_test.py Created 22 tests for cross-file functionality

Test Coverage

Cross-file tests (tests/cross_file_test.py)

  • Re-export detection: test_reexported_import_not_unused, test_chain_of_reexports, test_partial_reexport
  • Implicit re-exports: test_implicit_reexport_detected
  • External usage: test_external_usage_aggregated
  • Explicit all: test_explicit_reexport_not_flagged
  • Circular imports: test_circular_import_detected, test_no_circular_when_none
  • Defined names: test_defined_name_not_reexport
  • Cascade detection:
    • test_cascade_unused_when_consumer_removed
    • test_cascade_partial_chain
    • test_cascade_multiple_consumers
    • test_cascade_reexport_only_via_dunder_all
    • test_cascade_reexport_only_keeps_used
    • test_cascade_file_unreachable
    • test_cascade_file_still_reachable_via_other_path
  • Submodule traversal: test_submodule_not_in_init_is_traversed
  • Unreachable files:
    • test_submodule_not_unreachable_when_parent_reachable
    • test_truly_unreachable_file_is_reported
    • test_cascade_works_for_potentially_unreachable_but_not_reported
    • test_cascade_detects_more_unused_via_potentially_unreachable

Total: 333 tests passing


Example Output

$ remove-unused-imports app.py --warn-unreachable

app.py
   15: Unused 'Dict' from 'typing'

utils/__init__.py
     7: Unused 'EmailClient' from 'utils.email'
     8: Unused 'CacheManager' from 'utils.cache'

models/__init__.py
    42: Unused 'LegacyModel' from 'models.legacy'

───────────────────────────────────────────────────────────────────────────────
Unreachable Files (will become dead code after fixing imports)
───────────────────────────────────────────────────────────────────────────────
  • orphaned_module.py

═══════════════════════════════════════════════════════════════════════════════
Found 45 unused import(s) in 12 file(s), 1 unreachable file(s)
═══════════════════════════════════════════════════════════════════════════════

Edge Cases Handled

  1. Circular imports: Detected via Tarjan's algorithm, reported with --warn-circular
  2. TYPE_CHECKING blocks: Treated as annotation-only imports (existing behavior)
  3. __init__.py re-exports: Tracked via exports field (names in __all__)
  4. Implicit submodule imports: from pkg import submod where submod isn't in __init__.py
  5. Case-insensitive filesystems: Using Path.samefile() for comparisons (macOS)
  6. Parent package imports: import pkg + pkg.submod.x keeps submodules accessible

Known Limitations

  1. Dynamic imports: importlib.import_module() cannot be analyzed statically
  2. Star imports: from x import * uses __all__ if available, otherwise skipped
  3. Namespace packages: PEP 420 namespace packages not fully supported
  4. Conditional imports: Imports inside if blocks are always considered
  5. Runtime attribute access: pkg.submod access without explicit import is not tracked

Performance

Tested on a real-world web application (~100 Python files):

  • Graph construction: < 1 second
  • Cross-file analysis with cascade: < 2 seconds
  • Detects 250+ unused imports in one shot

Future Improvements

  1. Parallel graph construction: Process files concurrently
  2. Incremental analysis: Cache graph between runs
  3. IDE integration: LSP server for real-time feedback
  4. Attribute access tracking: Detect pkg.submod usage patterns

cmyui added 4 commits January 10, 2026 12:40
Introduce a new plan for cross-file import tracking, transforming the tool to analyze imports across multiple files. Implement a new CLI interface for cross-file mode, along with a detailed architecture overview. Add new data structures and modules for module resolution, import graph construction, and cross-file analysis. Outline implementation phases and edge cases, ensuring comprehensive testing and verification strategies.
@cmyui
cmyui marked this pull request as ready for review January 10, 2026 05:10
@cmyui cmyui self-assigned this Jan 10, 2026
@cmyui
cmyui requested a review from Copilot January 10, 2026 05:10
@cmyui cmyui added the enhancement New feature or request label Jan 10, 2026
@cmyui cmyui changed the title Draft: Add cross-file import tracking functionality Add cross-file import tracking functionality Jan 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces cross-file import tracking functionality, transforming the tool from single-file analysis to understanding imports across an entire codebase. The implementation adds the ability to detect re-exported imports (where a module imports something and other files import it from that module), identify implicit re-exports (re-exports not declared in __all__), and detect circular import chains.

Changes:

  • Implements a complete module resolution system that mirrors Python's import mechanism
  • Adds import graph construction to track dependencies between files
  • Introduces cross-file analysis that identifies re-exported imports to avoid false positives
  • Updates CLI to default to cross-file mode with --single-file flag for backward compatibility

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
CROSS_FILE_PLAN.md Comprehensive design document outlining architecture, data structures, and implementation phases
remove_unused_imports/_data.py Adds ModuleInfo, ImportEdge, and ImplicitReexport data classes; extends ImportInfo with level field
remove_unused_imports/_resolution.py New module implementing Python-like import resolution with support for relative imports and PYTHONPATH
remove_unused_imports/_graph.py New module for building and analyzing import graphs with cycle detection and topological ordering
remove_unused_imports/_cross_file.py Implements cross-file analysis to identify re-exports and aggregate usage across modules
remove_unused_imports/_main.py Updates CLI to support cross-file mode by default with new flags for warnings and backward compatibility
remove_unused_imports/_ast_helpers.py Adds level parameter to ImportExtractor for relative import support
remove_unused_imports/init.py Exports new public APIs for cross-file analysis
tests/resolution_test.py Comprehensive tests for module resolution including relative imports and PYTHONPATH
tests/graph_test.py Tests for import graph construction, cycle detection, and topological ordering
tests/cross_file_test.py Tests for re-export detection, implicit re-exports, and chained re-exports
tests/cli_test.py Tests for CLI with cross-file mode flags and backward compatibility
.pre-commit-config.yaml Increases max line length from 100 to 110 characters

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread import_analyzer/_main.py
Comment thread tests/cli_test.py
Comment thread .pre-commit-config.yaml
Comment thread import_analyzer/_resolution.py
Comment thread remove_unused_imports/_resolution.py Outdated
Comment thread remove_unused_imports/_resolution.py Outdated
Comment thread import_analyzer/_graph.py
Comment thread import_analyzer/_cross_file.py
Comment thread import_analyzer/_resolution.py
cmyui and others added 12 commits January 10, 2026 13:38
Refactor the CrossFileAnalyzer to compute a full cascade of unused imports, allowing for accurate identification of imports that become unused when their consumers are removed. Update the logic for re-exported imports and add comprehensive tests to validate the new cascading behavior.
Refactor the CrossFileAnalyzer to track and report files that become unreachable when unused imports are removed. Introduce logic to identify "reexport-only" and "implicit-reexport-only" imports, improving the accuracy of unused import detection. Update the CLI to support warnings for unreachable files and enhance the formatting of results to include this information. Comprehensive tests added to validate the new functionality.
Test across 20 combinations:
- ubuntu-latest (x64)
- windows-latest (x64)
- macos-13 (Intel x64)
- macos-14 (Apple Silicon ARM64)

Each with Python 3.10, 3.11, 3.12, 3.13, and 3.14.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- macos-13 -> macos-15-large (Intel x64)
- macos-14 -> macos-latest (Apple Silicon ARM64)

See: actions/runner-images#13046

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
macos-15-large requires a paid plan. Free tier now only has ARM64
macOS runners available.

CI matrix is now 15 jobs:
- ubuntu-latest (x64)
- windows-latest (x64)
- macos-latest (ARM64)

Each with Python 3.10-3.14.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread import_analyzer/_graph.py
Comment thread .github/workflows/ci.yml Outdated
Comment thread import_analyzer/_resolution.py
cmyui and others added 4 commits January 10, 2026 15:20
Two critical bugs fixed:

1. Module resolution for package directories:
   When analyzing a package directory (e.g., models/), the source root
   was incorrectly set to the package itself. This caused imports like
   `from models import X` within models/user.py to fail resolution
   (looking for models/models/__init__.py instead of models/__init__.py).

   Fix: When the target directory has __init__.py, use its parent as
   the source root so imports resolve correctly.

2. Scope filtering for results:
   When analyzing a package directory, the graph correctly follows
   imports to sibling packages (needed for re-export detection), but
   the results should only report on files within the requested scope.

   Fix: Filter unused imports, implicit reexports, circular imports,
   and unreachable files to only include files under the target path.

Also:
- Added is_under_path() utility to _data.py (single definition)
- Added 3 tests covering the fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Decorators are evaluated before the function/class name is bound, so
`@foo` must resolve before `def foo` creates a binding. Previously,
we bound the name first, causing decorators with the same name as the
decorated function/class to be incorrectly flagged as unused.

Example that was incorrectly flagged:
```python
from sqlalchemy.orm import reconstructor

class Model:
    @ReConstructor  # This was flagged as unused
    def reconstructor(self):
        pass
```

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Test all Python versions (3.10-3.14) on Ubuntu, plus Python 3.12 on
Windows and macOS for OS-specific coverage. This is sufficient for a
pure Python linter without native extensions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements standard noqa comment handling matching flake8 behavior:
- `# noqa` suppresses all warnings on the line
- `# noqa: F401` specifically suppresses unused import warnings
- Keyword is case-insensitive, but codes are case-sensitive
- Works with multi-line imports (per-name noqa on each line)
- Works with backslash continuation lines
- Works with semicolon-separated imports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
cmyui and others added 5 commits January 10, 2026 16:17
The tool now skips directories like .venv, node_modules, __pycache__,
.git, build, dist, etc. when scanning for Python files. This prevents:

- RecursionError crashes from test files with deeply nested AST nodes
  (e.g., astroid's joined_strings.py test data)
- Unnecessary analysis of third-party code and build artifacts
- Faster analysis on large codebases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Document noqa comment support (# noqa: F401)
- Document directory exclusions (.venv, node_modules, etc.)
- Add noqa examples section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The tests were using Unix-style absolute paths like `/project/src/a.py`
which don't work properly on Windows. When `base_path.resolve()` is called,
it converts `/project/src` to something like `D:\project\src`, but the
test file paths remain as `/project/src/a.py`, causing `is_under_path()`
to fail.

Fixed by using pytest's `tmp_path` fixture to create real temporary
directories that work correctly on all platforms.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Added a feature comparison table showing how remove-unused-imports
compares to other Python unused import tools (Ruff, Autoflake,
Flake8/Pyflakes, Pylint).

Key differentiators highlighted:
- Cross-file analysis (unique)
- Re-export tracking (unique)
- Cascade detection (unique)
- Circular import warnings (unique)
- Unreachable file warnings (unique)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Deep analysis of Pyflakes, Autoflake, Pylint, Ruff, and Unimport source
code to create accurate feature comparison.

Key findings:
- Added Unimport to comparison (has star import suggestions, type comments)
- Corrected Pyflakes noqa support (handled by Flake8 wrapper, not Pyflakes)
- Added features we're missing: type comments, redundant alias, star
  import suggestions, redefinition warnings
- All other tools are single-file only; cross-file analysis is unique

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@cmyui
cmyui enabled auto-merge (squash) January 10, 2026 09:24
cmyui and others added 2 commits January 10, 2026 17:30
Renamed from remove-unused-imports to import-analyzer-py to better
reflect the expanded scope of the tool:
- Cross-file import analysis
- Re-export tracking
- Cascade detection
- Circular import warnings
- Unreachable file detection

Changes:
- Renamed module directory: remove_unused_imports/ -> import_analyzer/
- Updated package name in pyproject.toml
- Updated CLI entry point: remove-unused-imports -> import-analyzer
- Updated all internal imports
- Updated all test imports
- Updated README.md and CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@cmyui
cmyui disabled auto-merge January 10, 2026 09:34
@cmyui
cmyui merged commit 82d1d2a into main Jan 10, 2026
8 checks passed
@cmyui
cmyui deleted the cross-file branch January 10, 2026 09:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants