explain-repo 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ # Byte-compiled files and Python caches
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Virtual environments
7
+ .venv/
8
+ venv/
9
+ env/
10
+ ENV/
11
+
12
+ # Packaging and build output
13
+ build/
14
+ dist/
15
+ *.egg-info/
16
+ .eggs/
17
+ eggs/
18
+ sdist/
19
+ wheels/
20
+ pip-wheel-metadata/
21
+
22
+ # Test, coverage, and analysis caches
23
+ .pytest_cache/
24
+ .coverage
25
+ .coverage.*
26
+ coverage.xml
27
+ htmlcov/
28
+ .tox/
29
+ .nox/
30
+ .mypy_cache/
31
+ .pyright/
32
+ .ruff_cache/
33
+ .hypothesis/
34
+
35
+ # Local environment and secrets
36
+ .env
37
+ .env.*
38
+ !.env.example
39
+
40
+ # Editor and operating-system files
41
+ .vscode/
42
+ .idea/
43
+ *.swp
44
+ *.swo
45
+ *~
46
+ .DS_Store
47
+ Thumbs.db
48
+
49
+ # Logs and temporary files
50
+ *.log
51
+ *.tmp
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alintm4
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.5
2
+ Name: explain-repo
3
+ Version: 0.1.0
4
+ Summary: Static-analysis guided onboarding reports for Python repositories
5
+ Project-URL: Homepage, https://github.com/alintm4/explain-repo
6
+ Project-URL: Repository, https://github.com/alintm4/explain-repo.git
7
+ Project-URL: Issues, https://github.com/alintm4/explain-repo/issues
8
+ Author-email: alintm4 <alintimilsana@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 alintm4
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Programming Language :: Python :: 3.11
34
+ Classifier: Programming Language :: Python :: 3.12
35
+ Classifier: Programming Language :: Python :: 3.13
36
+ Classifier: Programming Language :: Python :: 3.14
37
+ Requires-Python: >=3.11
38
+ Requires-Dist: click>=8.1
39
+ Requires-Dist: networkx>=3.2
40
+ Requires-Dist: rich>=13.7
41
+ Provides-Extra: dev
42
+ Requires-Dist: pytest>=8.0; extra == 'dev'
43
+ Provides-Extra: llm
44
+ Requires-Dist: anthropic>=0.40; extra == 'llm'
45
+ Description-Content-Type: text/markdown
46
+
47
+ # explain-repo
48
+
49
+ `explain-repo` statically analyzes a local Python repository and produces a
50
+ guided onboarding report. It parses Python with the standard-library `ast`
51
+ module, resolves internal imports, builds a NetworkX dependency graph, and ranks
52
+ files without reading meaning into source text.
53
+
54
+ ## Installation
55
+
56
+ After the package is published to PyPI, run it without installing it globally:
57
+
58
+ ```console
59
+ uvx explain-repo ./path/to/repository
60
+ ```
61
+
62
+ For local development:
63
+
64
+ ```console
65
+ git clone <repository-url>
66
+ cd explain-repo
67
+ uv sync
68
+ uv run pytest
69
+ uvx --from . explain-repo ./path/to/repository
70
+ ```
71
+
72
+ Python 3.11 or newer is required.
73
+
74
+ ## Usage
75
+
76
+ ```console
77
+ explain-repo [OPTIONS] PATH
78
+
79
+ Options:
80
+ --top INTEGER RANGE Number of files to show. [default: 10]
81
+ --json Output structured JSON.
82
+ --rank-method [indegree|pagerank]
83
+ Ranking algorithm. [default: pagerank]
84
+ --llm Add structure-only Anthropic descriptions.
85
+ --help Show help and exit.
86
+ ```
87
+
88
+ Examples:
89
+
90
+ ```console
91
+ uvx explain-repo . --top 5
92
+ uvx explain-repo . --rank-method indegree
93
+ uvx explain-repo . --json > report.json
94
+ uvx --from 'explain-repo[llm]' explain-repo . --llm
95
+ ```
96
+
97
+ Sample terminal output:
98
+
99
+ ```text
100
+ Suggested Reading Order
101
+ ┏━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
102
+ ┃ # ┃ File ┃ Why central ┃ Dependencies ┃
103
+ ┡━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
104
+ │ 1 │ src/app/core.py │ imported by 12 other files │ src/app/types.py │
105
+ │ 2 │ src/app/service.py │ imported by 4 other files │ src/app/core.py │
106
+ └───┴────────────────────┴───────────────────────────┴──────────────────┘
107
+
108
+ Core Abstractions
109
+ ┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
110
+ ┃ File ┃ Classes ┃ Functions ┃
111
+ ┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
112
+ │ src/app/core.py │ Repository (load) │ create_app │
113
+ │ src/app/service.py │ AnalysisService (run)│ analyze │
114
+ └────────────────────┴──────────────────────┴──────────────────┘
115
+ ```
116
+
117
+ Syntax-invalid files are skipped with a warning. Common generated directories,
118
+ including `.git`, `.venv`, `venv`, `node_modules`, `__pycache__`, `build`, and
119
+ `dist`, are excluded from scanning. Circular imports are represented as ordinary
120
+ cycles in the graph and require no recursive traversal.
121
+
122
+ ## Optional Anthropic descriptions
123
+
124
+ Install the `llm` extra and provide Anthropic credentials in the environment:
125
+
126
+ ```console
127
+ export ANTHROPIC_API_KEY="..."
128
+ uv sync --extra llm
129
+ uv run explain-repo . --llm
130
+ ```
131
+
132
+ The model receives only the file path and extracted imports, function names,
133
+ class names, and method names. Full source content is never sent. Override the
134
+ default model with `EXPLAIN_REPO_ANTHROPIC_MODEL`.
135
+
136
+ ## Publishing to PyPI
137
+
138
+ The distribution name, Python requirement, runtime dependencies, build backend,
139
+ and `[project.scripts]` entry point are defined in `pyproject.toml`. The script
140
+ entry is what lets `uvx` install the distribution and invoke `explain-repo`.
141
+
142
+ 1. Choose the next semantic version and update both `project.version` in
143
+ `pyproject.toml` and `__version__` in `src/explain_repo/__init__.py`.
144
+ 2. Run `uv lock`, `uv sync`, `uv run pytest`, and
145
+ `uvx --from . explain-repo .`.
146
+ 3. Build clean wheel and source distributions with `uv build`.
147
+ 4. Check the release files with `uvx twine check dist/*`.
148
+ 5. Create a PyPI trusted publisher for the repository's release workflow, or
149
+ create a scoped PyPI API token.
150
+ 6. Publish interactively with `uv publish`; when prompted for token credentials,
151
+ use `__token__` as the username and the PyPI token as the password. In CI,
152
+ prefer PyPI trusted publishing instead of storing a long-lived token.
153
+ 7. Verify the published release with
154
+ `uvx --refresh --from explain-repo==<version> explain-repo --help`.
155
+
156
+ PyPI makes the distribution globally discoverable. Before publication,
157
+ `uvx --from . explain-repo PATH` is the correct local equivalent.
@@ -0,0 +1,111 @@
1
+ # explain-repo
2
+
3
+ `explain-repo` statically analyzes a local Python repository and produces a
4
+ guided onboarding report. It parses Python with the standard-library `ast`
5
+ module, resolves internal imports, builds a NetworkX dependency graph, and ranks
6
+ files without reading meaning into source text.
7
+
8
+ ## Installation
9
+
10
+ After the package is published to PyPI, run it without installing it globally:
11
+
12
+ ```console
13
+ uvx explain-repo ./path/to/repository
14
+ ```
15
+
16
+ For local development:
17
+
18
+ ```console
19
+ git clone <repository-url>
20
+ cd explain-repo
21
+ uv sync
22
+ uv run pytest
23
+ uvx --from . explain-repo ./path/to/repository
24
+ ```
25
+
26
+ Python 3.11 or newer is required.
27
+
28
+ ## Usage
29
+
30
+ ```console
31
+ explain-repo [OPTIONS] PATH
32
+
33
+ Options:
34
+ --top INTEGER RANGE Number of files to show. [default: 10]
35
+ --json Output structured JSON.
36
+ --rank-method [indegree|pagerank]
37
+ Ranking algorithm. [default: pagerank]
38
+ --llm Add structure-only Anthropic descriptions.
39
+ --help Show help and exit.
40
+ ```
41
+
42
+ Examples:
43
+
44
+ ```console
45
+ uvx explain-repo . --top 5
46
+ uvx explain-repo . --rank-method indegree
47
+ uvx explain-repo . --json > report.json
48
+ uvx --from 'explain-repo[llm]' explain-repo . --llm
49
+ ```
50
+
51
+ Sample terminal output:
52
+
53
+ ```text
54
+ Suggested Reading Order
55
+ ┏━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
56
+ ┃ # ┃ File ┃ Why central ┃ Dependencies ┃
57
+ ┡━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
58
+ │ 1 │ src/app/core.py │ imported by 12 other files │ src/app/types.py │
59
+ │ 2 │ src/app/service.py │ imported by 4 other files │ src/app/core.py │
60
+ └───┴────────────────────┴───────────────────────────┴──────────────────┘
61
+
62
+ Core Abstractions
63
+ ┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
64
+ ┃ File ┃ Classes ┃ Functions ┃
65
+ ┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
66
+ │ src/app/core.py │ Repository (load) │ create_app │
67
+ │ src/app/service.py │ AnalysisService (run)│ analyze │
68
+ └────────────────────┴──────────────────────┴──────────────────┘
69
+ ```
70
+
71
+ Syntax-invalid files are skipped with a warning. Common generated directories,
72
+ including `.git`, `.venv`, `venv`, `node_modules`, `__pycache__`, `build`, and
73
+ `dist`, are excluded from scanning. Circular imports are represented as ordinary
74
+ cycles in the graph and require no recursive traversal.
75
+
76
+ ## Optional Anthropic descriptions
77
+
78
+ Install the `llm` extra and provide Anthropic credentials in the environment:
79
+
80
+ ```console
81
+ export ANTHROPIC_API_KEY="..."
82
+ uv sync --extra llm
83
+ uv run explain-repo . --llm
84
+ ```
85
+
86
+ The model receives only the file path and extracted imports, function names,
87
+ class names, and method names. Full source content is never sent. Override the
88
+ default model with `EXPLAIN_REPO_ANTHROPIC_MODEL`.
89
+
90
+ ## Publishing to PyPI
91
+
92
+ The distribution name, Python requirement, runtime dependencies, build backend,
93
+ and `[project.scripts]` entry point are defined in `pyproject.toml`. The script
94
+ entry is what lets `uvx` install the distribution and invoke `explain-repo`.
95
+
96
+ 1. Choose the next semantic version and update both `project.version` in
97
+ `pyproject.toml` and `__version__` in `src/explain_repo/__init__.py`.
98
+ 2. Run `uv lock`, `uv sync`, `uv run pytest`, and
99
+ `uvx --from . explain-repo .`.
100
+ 3. Build clean wheel and source distributions with `uv build`.
101
+ 4. Check the release files with `uvx twine check dist/*`.
102
+ 5. Create a PyPI trusted publisher for the repository's release workflow, or
103
+ create a scoped PyPI API token.
104
+ 6. Publish interactively with `uv publish`; when prompted for token credentials,
105
+ use `__token__` as the username and the PyPI token as the password. In CI,
106
+ prefer PyPI trusted publishing instead of storing a long-lived token.
107
+ 7. Verify the published release with
108
+ `uvx --refresh --from explain-repo==<version> explain-repo --help`.
109
+
110
+ PyPI makes the distribution globally discoverable. Before publication,
111
+ `uvx --from . explain-repo PATH` is the correct local equivalent.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "explain-repo"
7
+ version = "0.1.0"
8
+ description = "Static-analysis guided onboarding reports for Python repositories"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "alintm4", email = "alintimilsana@gmail.com" }]
13
+ classifiers = [
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ ]
21
+ dependencies = [
22
+ "click>=8.1",
23
+ "networkx>=3.2",
24
+ "rich>=13.7",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/alintm4/explain-repo"
29
+ Repository = "https://github.com/alintm4/explain-repo.git"
30
+ Issues = "https://github.com/alintm4/explain-repo/issues"
31
+
32
+ [project.optional-dependencies]
33
+ llm = ["anthropic>=0.40"]
34
+ dev = ["pytest>=8.0"]
35
+
36
+ [project.scripts]
37
+ explain-repo = "explain_repo.cli:main"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/explain_repo"]
41
+
42
+ [tool.pytest.ini_options]
43
+ addopts = "-q"
44
+ testpaths = ["tests"]
45
+
46
+ [dependency-groups]
47
+ dev = ["pytest>=8.0"]
@@ -0,0 +1,3 @@
1
+ """Static-analysis tools for explaining Python repositories."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,143 @@
1
+ """Command-line interface for explain-repo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from . import __version__
14
+ from .graph import build_dependency_graph, rank_files
15
+ from .parser import FileInfo, parse_repository
16
+
17
+
18
+ def _import_label(info: FileInfo) -> list[str]:
19
+ labels = []
20
+ for imported in info.imports:
21
+ prefix = "." * imported.level + (imported.module or "")
22
+ labels.append(prefix or ", ".join(imported.names))
23
+ return labels
24
+
25
+
26
+ def _build_report(
27
+ root: Path, top: int, rank_method: str, include_llm: bool
28
+ ) -> dict[str, Any]:
29
+ files = parse_repository(root)
30
+ graph = build_dependency_graph(files)
31
+ ranked = rank_files(graph, rank_method)[:top]
32
+ entries = []
33
+ for path, score in ranked:
34
+ info = files[path]
35
+ imported_by = sorted(source.as_posix() for source in graph.predecessors(path))
36
+ dependencies = sorted(target.as_posix() for target in graph.successors(path))
37
+ entry: dict[str, Any] = {
38
+ "path": path.as_posix(),
39
+ "score": score,
40
+ "why_central": f"imported by {len(imported_by)} other file{'s' if len(imported_by) != 1 else ''}",
41
+ "imported_by": imported_by,
42
+ "dependencies": dependencies,
43
+ "imports": _import_label(info),
44
+ "functions": info.functions,
45
+ "classes": [
46
+ {"name": name, "methods": info.class_methods.get(name, [])}
47
+ for name in info.classes
48
+ ],
49
+ }
50
+ if include_llm:
51
+ from .llm import describe_file
52
+
53
+ entry["description"] = describe_file(info)
54
+ entries.append(entry)
55
+ return {
56
+ "repository": str(root),
57
+ "rank_method": rank_method,
58
+ "python_file_count": len(files),
59
+ "syntax_errors": [
60
+ {"path": path.as_posix(), "error": info.syntax_error}
61
+ for path, info in files.items()
62
+ if info.syntax_error
63
+ ],
64
+ "reading_order": entries,
65
+ }
66
+
67
+
68
+ def _render_text(report: dict[str, Any], console: Console) -> None:
69
+ console.print("[bold]Suggested Reading Order[/bold]")
70
+ reading_table = Table(show_header=True, header_style="bold cyan")
71
+ reading_table.add_column("#", justify="right")
72
+ reading_table.add_column("File")
73
+ reading_table.add_column("Why central")
74
+ reading_table.add_column("Dependencies")
75
+ for index, entry in enumerate(report["reading_order"], start=1):
76
+ reading_table.add_row(
77
+ str(index),
78
+ entry["path"],
79
+ entry["why_central"],
80
+ ", ".join(entry["dependencies"]) or "None",
81
+ )
82
+ if entry.get("description"):
83
+ reading_table.add_row("", "[dim]Description[/dim]", entry["description"], "")
84
+ console.print(reading_table)
85
+
86
+ console.print("\n[bold]Core Abstractions[/bold]")
87
+ abstraction_table = Table(show_header=True, header_style="bold cyan")
88
+ abstraction_table.add_column("File")
89
+ abstraction_table.add_column("Classes")
90
+ abstraction_table.add_column("Functions")
91
+ for entry in report["reading_order"]:
92
+ classes = []
93
+ for class_info in entry["classes"]:
94
+ methods = ", ".join(class_info["methods"])
95
+ classes.append(f"{class_info['name']} ({methods})" if methods else class_info["name"])
96
+ abstraction_table.add_row(
97
+ entry["path"],
98
+ "\n".join(classes) or "None",
99
+ ", ".join(entry["functions"]) or "None",
100
+ )
101
+ console.print(abstraction_table)
102
+
103
+
104
+ @click.command()
105
+ @click.version_option(version=__version__, prog_name="explain-repo")
106
+ @click.argument(
107
+ "path",
108
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
109
+ )
110
+ @click.option("--top", type=click.IntRange(min=1), default=10, show_default=True)
111
+ @click.option("--json", "as_json", is_flag=True, help="Output structured JSON.")
112
+ @click.option(
113
+ "--rank-method",
114
+ type=click.Choice(["indegree", "pagerank"]),
115
+ default="pagerank",
116
+ show_default=True,
117
+ )
118
+ @click.option("--llm", is_flag=True, help="Add Anthropic descriptions from extracted structure.")
119
+ def main(path: Path, top: int, as_json: bool, rank_method: str, llm: bool) -> None:
120
+ """Analyze the Python repository at PATH and suggest a reading order."""
121
+ root = path.resolve()
122
+ try:
123
+ report = _build_report(root, top, rank_method, llm)
124
+ except RuntimeError as error:
125
+ raise click.ClickException(str(error)) from error
126
+ except Exception as error:
127
+ if llm:
128
+ raise click.ClickException(f"LLM request failed: {error}") from error
129
+ raise
130
+
131
+ for syntax_error in report["syntax_errors"]:
132
+ click.echo(
133
+ f"Warning: skipped {syntax_error['path']}: {syntax_error['error']}",
134
+ err=True,
135
+ )
136
+ if as_json:
137
+ click.echo(json.dumps(report, indent=2))
138
+ else:
139
+ _render_text(report, Console())
140
+
141
+
142
+ if __name__ == "__main__":
143
+ main()
@@ -0,0 +1,124 @@
1
+ """Build and rank a file-level Python dependency graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ import networkx as nx
9
+
10
+ from .parser import FileInfo, ImportInfo
11
+
12
+
13
+ def _module_name(path: Path) -> str:
14
+ pure_path = PurePosixPath(path.as_posix())
15
+ if pure_path.name == "__init__.py":
16
+ return ".".join(pure_path.parent.parts)
17
+ return ".".join(pure_path.with_suffix("").parts)
18
+
19
+
20
+ def _module_index(paths: Iterable[Path]) -> dict[str, Path]:
21
+ index: dict[str, Path] = {}
22
+ for path in paths:
23
+ module = _module_name(path)
24
+ if module:
25
+ index[module] = path
26
+ if module.startswith("src."):
27
+ index[module.removeprefix("src.")] = path
28
+ elif path.name == "__init__.py":
29
+ index["__root__"] = path
30
+ return index
31
+
32
+
33
+ def _package_parts(importer: Path) -> list[str]:
34
+ module_parts = _module_name(importer).split(".") if _module_name(importer) else []
35
+ if importer.name != "__init__.py":
36
+ module_parts = module_parts[:-1]
37
+ return module_parts
38
+
39
+
40
+ def _import_candidates(importer: Path, imported: ImportInfo) -> list[str]:
41
+ if imported.level:
42
+ package = _package_parts(importer)
43
+ keep = max(0, len(package) - imported.level + 1)
44
+ base_parts = package[:keep]
45
+ if imported.module:
46
+ base_parts.extend(imported.module.split("."))
47
+ base = ".".join(base_parts)
48
+ if imported.module:
49
+ return [*(f"{base}.{name}" for name in imported.names), base]
50
+ return [".".join([*base_parts, name]) for name in imported.names]
51
+
52
+ if imported.module:
53
+ return [
54
+ *(f"{imported.module}.{name}" for name in imported.names),
55
+ imported.module,
56
+ ]
57
+ return list(imported.names)
58
+
59
+
60
+ def resolve_import(
61
+ importer: Path, imported: ImportInfo, module_index: dict[str, Path]
62
+ ) -> set[Path]:
63
+ """Resolve an import to files indexed inside the analyzed repository."""
64
+ resolved: set[Path] = set()
65
+ for candidate in _import_candidates(importer, imported):
66
+ if candidate in module_index:
67
+ resolved.add(module_index[candidate])
68
+ continue
69
+
70
+ parts = candidate.split(".")
71
+ while len(parts) > 1:
72
+ parts.pop()
73
+ parent = ".".join(parts)
74
+ if parent in module_index:
75
+ resolved.add(module_index[parent])
76
+ break
77
+ return resolved
78
+
79
+
80
+ def build_dependency_graph(files: dict[Path, FileInfo]) -> nx.DiGraph:
81
+ """Build a graph whose edges point from importers to imported files."""
82
+ graph = nx.DiGraph()
83
+ graph.add_nodes_from(files)
84
+ index = _module_index(files)
85
+ for path, info in files.items():
86
+ for imported in info.imports:
87
+ for dependency in resolve_import(path, imported, index):
88
+ if dependency != path:
89
+ graph.add_edge(path, dependency)
90
+ return graph
91
+
92
+
93
+ def _pagerank(graph: nx.DiGraph, damping: float = 0.85) -> dict[Path, float]:
94
+ if not graph:
95
+ return {}
96
+ node_count = len(graph)
97
+ scores = {node: 1.0 / node_count for node in graph}
98
+ base_score = (1.0 - damping) / node_count
99
+ for _ in range(100):
100
+ dangling_score = sum(scores[node] for node in graph if graph.out_degree(node) == 0)
101
+ updated = {}
102
+ for node in graph:
103
+ inbound_score = sum(
104
+ scores[source] / graph.out_degree(source)
105
+ for source in graph.predecessors(node)
106
+ )
107
+ updated[node] = base_score + damping * (
108
+ inbound_score + dangling_score / node_count
109
+ )
110
+ if sum(abs(updated[node] - scores[node]) for node in graph) < node_count * 1e-6:
111
+ return updated
112
+ scores = updated
113
+ return scores
114
+
115
+
116
+ def rank_files(graph: nx.DiGraph, method: str = "pagerank") -> list[tuple[Path, float]]:
117
+ """Rank files by PageRank or in-degree centrality."""
118
+ if method == "pagerank":
119
+ scores = _pagerank(graph)
120
+ elif method == "indegree":
121
+ scores = nx.in_degree_centrality(graph) if len(graph) > 1 else {node: 0.0 for node in graph}
122
+ else:
123
+ raise ValueError(f"Unsupported rank method: {method}")
124
+ return sorted(scores.items(), key=lambda item: (-item[1], item[0].as_posix()))