foothold 0.1.1__py3-none-any.whl

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.
foothold/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Foothold: turn an unfamiliar repository into a reading path."""
2
+
3
+ __version__ = "0.1.0"
foothold/analyze.py ADDED
@@ -0,0 +1,40 @@
1
+ """Orchestrates the offline pass: collect -> graph -> rank -> RepoMap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from foothold.collectors import churn_by_path, collect_markers, collect_modules
8
+ from foothold.config import Config
9
+ from foothold.graph import build_graph, find_cycles, find_entrypoints, rank
10
+ from foothold.models import Module, RepoMap, Score
11
+
12
+
13
+ def _untested(modules: dict[str, Module], scores: list[Score]) -> list[str]:
14
+ """Source modules with no test file whose name references them."""
15
+ blob = " ".join(m.path for m in modules.values() if m.is_test)
16
+ out = []
17
+ for score in scores[:50]:
18
+ stem = Path(score.path).stem
19
+ if stem not in blob:
20
+ out.append(score.path)
21
+ return out
22
+
23
+
24
+ def analyze(root: Path, config: Config | None = None) -> RepoMap:
25
+ config = config or Config.load(root)
26
+ modules, edges = collect_modules(root, config.excludes)
27
+ graph = build_graph(modules, edges, root_alias=root.name)
28
+ churn = churn_by_path(root)
29
+ scores = rank(graph, modules, churn, config.weights)
30
+ source_paths = [m.path for m in modules.values() if not m.is_test]
31
+ return RepoMap(
32
+ root=str(root),
33
+ modules=modules,
34
+ edges=edges,
35
+ scores=scores,
36
+ entrypoints=find_entrypoints(graph, modules),
37
+ markers=collect_markers(root, source_paths),
38
+ untested=_untested(modules, scores),
39
+ cycles=find_cycles(graph),
40
+ )
foothold/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from foothold import __version__
13
+ from foothold.analyze import analyze
14
+ from foothold.config import Config
15
+ from foothold.issues import propose
16
+ from foothold.narrator import NarratorError, narrate
17
+ from foothold.narrator.client import build_context, estimate_tokens
18
+ from foothold.render import render_architecture, render_map
19
+
20
+ app = typer.Typer(
21
+ add_completion=False,
22
+ help="Turn an unfamiliar repository into a reading path.",
23
+ no_args_is_help=True,
24
+ )
25
+ console = Console()
26
+ err = Console(stderr=True)
27
+
28
+
29
+ def _version(value: bool) -> None:
30
+ if value:
31
+ console.print(__version__)
32
+ raise typer.Exit
33
+
34
+
35
+ @app.callback()
36
+ def main(
37
+ version: bool = typer.Option(
38
+ False, "--version", callback=_version, is_eager=True, help="Show version and exit."
39
+ ),
40
+ ) -> None:
41
+ """Foothold reads a repository the way a senior engineer does on day one."""
42
+
43
+
44
+ @app.command("map")
45
+ def map_cmd(
46
+ path: Path = typer.Argument(Path("."), help="Repository root."),
47
+ top: int = typer.Option(30, "--top", "-n", help="How many files to show."),
48
+ as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
49
+ ) -> None:
50
+ """Rank the files that hold the repository together. Offline, no API key."""
51
+ repo = analyze(path.resolve())
52
+ if not repo.modules:
53
+ err.print("[red]No Python modules found.[/red] Foothold v0.1 is Python-only.")
54
+ raise typer.Exit(1)
55
+ if as_json:
56
+ console.print_json(
57
+ json.dumps(
58
+ {
59
+ "root": repo.root,
60
+ "modules": len(repo.modules),
61
+ "loc": repo.loc_total,
62
+ "entrypoints": [repo.modules[n].path for n in repo.entrypoints],
63
+ "cycles": repo.cycles,
64
+ "scores": [asdict(s) for s in repo.scores[:top]],
65
+ }
66
+ )
67
+ )
68
+ return
69
+ render_map(repo, top=top)
70
+
71
+
72
+ @app.command("docs")
73
+ def docs_cmd(
74
+ path: Path = typer.Argument(Path("."), help="Repository root."),
75
+ out: Path = typer.Option(Path("ARCHITECTURE.md"), "--out", "-o"),
76
+ top: int = typer.Option(20, "--top", "-n"),
77
+ narrate_flag: bool = typer.Option(False, "--narrate", help="Add model-written prose."),
78
+ yes: bool = typer.Option(False, "--yes", help="Skip the spend confirmation."),
79
+ ) -> None:
80
+ """Write ARCHITECTURE.md. Deterministic unless --narrate is passed."""
81
+ repo = analyze(path.resolve())
82
+ narrative = None
83
+ if narrate_flag:
84
+ config = Config.load(path.resolve())
85
+ tokens = estimate_tokens(build_context(repo, top=top))
86
+ if not yes:
87
+ typer.confirm(f"Send ~{tokens:,} tokens to {config.model}?", abort=True)
88
+ try:
89
+ narrative, usage = narrate(repo, model=config.model, top=top)
90
+ err.print(
91
+ f"[dim]{usage.prompt_tokens}+{usage.completion_tokens} tokens, "
92
+ f"~${usage.cost_usd():.4f}[/dim]"
93
+ )
94
+ except NarratorError as exc:
95
+ err.print(f"[yellow]{exc} Falling back to the deterministic document.[/yellow]")
96
+ try:
97
+ out.write_text(render_architecture(repo, top=top, narrative=narrative), encoding="utf-8")
98
+ except OSError as exc:
99
+ # A bad --out path is user error, not a crash: no traceback, just the reason.
100
+ err.print(f"[red]Cannot write {out}: {exc.strerror}[/red]")
101
+ raise typer.Exit(1) from exc
102
+ console.print(f"Wrote [bold]{out}[/bold]")
103
+
104
+
105
+ @app.command("explain")
106
+ def explain_cmd(
107
+ path: Path = typer.Argument(Path("."), help="Repository root."),
108
+ top: int = typer.Option(25, "--top", "-n"),
109
+ yes: bool = typer.Option(False, "--yes"),
110
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print the context, send nothing."),
111
+ ) -> None:
112
+ """Explain the repository in prose, grounded in the ranked map."""
113
+ repo = analyze(path.resolve())
114
+ config = Config.load(path.resolve())
115
+ context = build_context(repo, top=top)
116
+ if dry_run:
117
+ console.print(context)
118
+ return
119
+ if not yes:
120
+ typer.confirm(f"Send ~{estimate_tokens(context):,} tokens to {config.model}?", abort=True)
121
+ try:
122
+ text, usage = narrate(repo, model=config.model, top=top)
123
+ except NarratorError as exc:
124
+ err.print(f"[red]{exc}[/red]")
125
+ raise typer.Exit(1) from exc
126
+ console.print(text)
127
+ err.print(f"[dim]~${usage.cost_usd():.4f}[/dim]")
128
+
129
+
130
+ @app.command("issues")
131
+ def issues_cmd(
132
+ path: Path = typer.Argument(Path("."), help="Repository root."),
133
+ limit: int = typer.Option(10, "--max", "-n"),
134
+ as_markdown: bool = typer.Option(False, "--markdown"),
135
+ ) -> None:
136
+ """Propose good-first-issue candidates. Offline, no API key."""
137
+ repo = analyze(path.resolve())
138
+ candidates = propose(repo, limit=limit)
139
+ if not candidates:
140
+ console.print("No candidates found.")
141
+ return
142
+ for candidate in candidates:
143
+ if as_markdown:
144
+ console.print(f"### {candidate.title}\n\n{candidate.body}\n")
145
+ else:
146
+ console.print(f"[bold]{candidate.title}[/bold]")
147
+ console.print(f" [dim]{candidate.labels} · {candidate.difficulty}[/dim]")
148
+
149
+
150
+ if __name__ == "__main__":
151
+ app()
@@ -0,0 +1,5 @@
1
+ from foothold.collectors.git_history import churn_by_path
2
+ from foothold.collectors.markers import collect_markers
3
+ from foothold.collectors.python_ast import collect_modules
4
+
5
+ __all__ = ["churn_by_path", "collect_markers", "collect_modules"]
@@ -0,0 +1,43 @@
1
+ """Churn signal derived from git history via subprocess.
2
+
3
+ Only ``git log`` is invoked, with an explicit argv list and no shell. A repo
4
+ without git, or a shallow clone, degrades to a zero churn signal rather than
5
+ failing the run.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from collections import Counter
12
+ from pathlib import Path
13
+
14
+
15
+ def churn_by_path(root: Path, since: str = "18.months") -> dict[str, int]:
16
+ """Return {relative path: number of commits touching it}."""
17
+ try:
18
+ proc = subprocess.run(
19
+ [
20
+ "git",
21
+ "-C",
22
+ str(root),
23
+ "log",
24
+ f"--since={since}",
25
+ "--name-only",
26
+ "--pretty=format:",
27
+ "--no-merges",
28
+ ],
29
+ capture_output=True,
30
+ text=True,
31
+ timeout=60,
32
+ check=False,
33
+ )
34
+ except (OSError, subprocess.SubprocessError):
35
+ return {}
36
+ if proc.returncode != 0:
37
+ return {}
38
+ counts: Counter[str] = Counter()
39
+ for line in proc.stdout.splitlines():
40
+ line = line.strip()
41
+ if line.endswith(".py"):
42
+ counts[line] += 1
43
+ return dict(counts)
@@ -0,0 +1,31 @@
1
+ """Extract TODO/FIXME/HACK/XXX markers - raw material for issue candidates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from foothold.models import Marker
9
+
10
+ PATTERN = re.compile(r"#\s*(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)", re.IGNORECASE)
11
+
12
+
13
+ def collect_markers(root: Path, paths: list[str]) -> list[Marker]:
14
+ markers: list[Marker] = []
15
+ for rel in paths:
16
+ try:
17
+ text = (root / rel).read_text(encoding="utf-8", errors="replace")
18
+ except OSError:
19
+ continue
20
+ for lineno, line in enumerate(text.splitlines(), start=1):
21
+ match = PATTERN.search(line)
22
+ if match:
23
+ markers.append(
24
+ Marker(
25
+ path=rel,
26
+ lineno=lineno,
27
+ kind=match.group(1).upper(),
28
+ text=match.group(2).strip()[:160],
29
+ )
30
+ )
31
+ return markers
@@ -0,0 +1,108 @@
1
+ """Parse Python sources into Module records and raw import edges.
2
+
3
+ Deliberately uses the stdlib ``ast`` module: no third-party parser, no network,
4
+ no execution of the analysed code. Foothold never imports the repository it is
5
+ reading.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ from pathlib import Path
12
+
13
+ from foothold.models import Edge, Module
14
+
15
+ TEST_HINTS = ("tests/", "test_", "_test.py", "conftest.py")
16
+
17
+
18
+ def _is_test(rel: str) -> bool:
19
+ name = Path(rel).name
20
+ return (
21
+ rel.startswith("tests/")
22
+ or "/tests/" in rel
23
+ or name.startswith("test_")
24
+ or name.endswith("_test.py")
25
+ or name == "conftest.py"
26
+ )
27
+
28
+
29
+ def module_name(rel: str) -> str:
30
+ """Map ``src/pkg/mod.py`` -> ``pkg.mod`` and ``pkg/__init__.py`` -> ``pkg``."""
31
+ parts = list(Path(rel).with_suffix("").parts)
32
+ if parts and parts[0] in {"src", "lib"}:
33
+ parts = parts[1:]
34
+ if parts and parts[-1] == "__init__":
35
+ parts = parts[:-1]
36
+ return ".".join(parts)
37
+
38
+
39
+ def _public_defs(tree: ast.Module) -> tuple[str, ...]:
40
+ out: list[str] = []
41
+ for node in tree.body:
42
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) and (
43
+ not node.name.startswith("_")
44
+ ):
45
+ out.append(node.name)
46
+ return tuple(out)
47
+
48
+
49
+ def _resolve_relative(current: str, node: ast.ImportFrom) -> str | None:
50
+ """Turn ``from ..graph import build`` inside ``a.b.c`` into ``a.graph.build``."""
51
+ if node.level == 0:
52
+ return node.module
53
+ base = current.split(".")
54
+ anchor = base[: len(base) - node.level + 1] if node.level > 1 else base
55
+ prefix = ".".join(anchor[:-1] if node.level == 1 else anchor)
56
+ if not prefix:
57
+ # ``from . import x`` inside a top-level module: the anchor is the root
58
+ return node.module or ""
59
+ return f"{prefix}.{node.module}" if node.module else prefix
60
+
61
+
62
+ def parse_file(root: Path, rel: str) -> tuple[Module, list[Edge]]:
63
+ source = (root / rel).read_text(encoding="utf-8", errors="replace")
64
+ tree = ast.parse(source, filename=rel)
65
+ mod = module_name(rel)
66
+ edges: list[Edge] = []
67
+ for node in ast.walk(tree):
68
+ if isinstance(node, ast.Import):
69
+ edges.extend(Edge(mod, alias.name, node.lineno) for alias in node.names)
70
+ elif isinstance(node, ast.ImportFrom):
71
+ target = _resolve_relative(mod, node)
72
+ if target is None:
73
+ continue
74
+ if target:
75
+ edges.append(Edge(mod, target, node.lineno))
76
+ edges.extend(Edge(mod, f"{target}.{a.name}", node.lineno) for a in node.names)
77
+ else:
78
+ # ``from . import x`` anchored at the repository root
79
+ edges.extend(Edge(mod, a.name, node.lineno) for a in node.names)
80
+ module = Module(
81
+ path=rel,
82
+ module=mod,
83
+ loc=source.count("\n") + 1,
84
+ is_test=_is_test(rel),
85
+ is_package_init=Path(rel).name == "__init__.py",
86
+ defines=_public_defs(tree),
87
+ docstring=ast.get_docstring(tree),
88
+ )
89
+ return module, edges
90
+
91
+
92
+ def collect_modules(root: Path, excludes: tuple[str, ...]) -> tuple[dict[str, Module], list[Edge]]:
93
+ """Walk the tree once, returning modules keyed by dotted name and raw edges."""
94
+ modules: dict[str, Module] = {}
95
+ edges: list[Edge] = []
96
+ for file in sorted(root.rglob("*.py")):
97
+ rel = file.relative_to(root).as_posix()
98
+ if any(part in excludes for part in Path(rel).parts):
99
+ continue
100
+ try:
101
+ module, file_edges = parse_file(root, rel)
102
+ except (SyntaxError, UnicodeDecodeError, ValueError):
103
+ continue
104
+ if not module.module:
105
+ continue
106
+ modules[module.module] = module
107
+ edges.extend(file_edges)
108
+ return modules, edges
foothold/config.py ADDED
@@ -0,0 +1,70 @@
1
+ """Configuration loading and ranking weights."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ if sys.version_info >= (3, 11):
10
+ import tomllib
11
+
12
+ HAVE_TOML = True
13
+ else: # pragma: no cover - exercised on 3.10 only
14
+ try:
15
+ import tomli as tomllib
16
+
17
+ HAVE_TOML = True
18
+ except ModuleNotFoundError:
19
+ HAVE_TOML = False
20
+
21
+ DEFAULT_EXCLUDES = (
22
+ ".git",
23
+ ".venv",
24
+ "venv",
25
+ "node_modules",
26
+ "__pycache__",
27
+ "build",
28
+ "dist",
29
+ ".mypy_cache",
30
+ ".pytest_cache",
31
+ ".ruff_cache",
32
+ ".tox",
33
+ "site-packages",
34
+ )
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Weights:
39
+ """Relative weight of each ranking signal. Must be documented, not magic."""
40
+
41
+ centrality: float = 0.45
42
+ churn: float = 0.30
43
+ fan_in: float = 0.15
44
+ size: float = 0.10
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Config:
49
+ excludes: tuple[str, ...] = DEFAULT_EXCLUDES
50
+ weights: Weights = Weights()
51
+ top: int = 30
52
+ model: str = "gpt-4o-mini"
53
+ max_input_tokens: int = 12_000
54
+
55
+ @staticmethod
56
+ def load(root: Path) -> Config:
57
+ """Read .foothold.toml if present; fall back to defaults."""
58
+ path = root / ".foothold.toml"
59
+ if not path.exists() or not HAVE_TOML:
60
+ return Config()
61
+ with path.open("rb") as fh:
62
+ raw = tomllib.load(fh).get("foothold", {})
63
+ weights = Weights(**raw.get("weights", {}))
64
+ return Config(
65
+ excludes=tuple(raw.get("excludes", DEFAULT_EXCLUDES)),
66
+ weights=weights,
67
+ top=int(raw.get("top", 30)),
68
+ model=str(raw.get("model", "gpt-4o-mini")),
69
+ max_input_tokens=int(raw.get("max_input_tokens", 12_000)),
70
+ )
@@ -0,0 +1,4 @@
1
+ from foothold.graph.build import build_graph, find_cycles, find_entrypoints
2
+ from foothold.graph.rank import rank
3
+
4
+ __all__ = ["build_graph", "find_cycles", "find_entrypoints", "rank"]
@@ -0,0 +1,79 @@
1
+ """Build the in-project import graph.
2
+
3
+ External imports are dropped on purpose: the question Foothold answers is
4
+ "which files in *this* repository hold it together", and third-party packages
5
+ add nodes without adding signal.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import networkx as nx
11
+
12
+ from foothold.models import Edge, Module
13
+
14
+
15
+ def _longest_known_prefix(target: str, known: set[str]) -> str | None:
16
+ """``pkg.mod.func`` imported from ``pkg.mod`` resolves to ``pkg.mod``."""
17
+ parts = target.split(".")
18
+ for cut in range(len(parts), 0, -1):
19
+ candidate = ".".join(parts[:cut])
20
+ if candidate in known:
21
+ return candidate
22
+ return None
23
+
24
+
25
+ def _resolve(target: str, known: set[str], root_alias: str | None) -> str | None:
26
+ """Resolve an import target to an in-project module, or None if external.
27
+
28
+ ``root_alias`` handles the common case of pointing Foothold at a package
29
+ directory (``foothold map ./rich``) rather than the repository root: inside
30
+ that tree ``from rich.console import X`` must resolve to the local
31
+ ``console`` module. Only that one prefix is stripped - blanket suffix
32
+ matching would map ``os.path`` onto a local ``path.py``.
33
+ """
34
+ direct = _longest_known_prefix(target, known)
35
+ if direct is not None:
36
+ return direct
37
+ if root_alias and target.startswith(f"{root_alias}."):
38
+ return _longest_known_prefix(target[len(root_alias) + 1 :], known)
39
+ return None
40
+
41
+
42
+ def build_graph(
43
+ modules: dict[str, Module], edges: list[Edge], root_alias: str | None = None
44
+ ) -> nx.DiGraph:
45
+ graph = nx.DiGraph()
46
+ for name, module in modules.items():
47
+ graph.add_node(name, path=module.path, loc=module.loc, is_test=module.is_test)
48
+ known = set(modules)
49
+ for edge in edges:
50
+ dst = _resolve(edge.dst, known, root_alias)
51
+ if dst is None or dst == edge.src or edge.src not in known:
52
+ continue
53
+ if graph.has_edge(edge.src, dst):
54
+ graph[edge.src][dst]["weight"] += 1
55
+ else:
56
+ graph.add_edge(edge.src, dst, weight=1, lineno=edge.lineno)
57
+ return graph
58
+
59
+
60
+ def find_entrypoints(graph: nx.DiGraph, modules: dict[str, Module]) -> list[str]:
61
+ """Modules nothing else imports, excluding tests and empty ``__init__`` files."""
62
+ out = [
63
+ name
64
+ for name in graph.nodes
65
+ if graph.in_degree(name) == 0
66
+ and not modules[name].is_test
67
+ and not (modules[name].is_package_init and modules[name].loc < 5)
68
+ ]
69
+ return sorted(out, key=lambda n: (-modules[n].loc, n))
70
+
71
+
72
+ def find_cycles(graph: nx.DiGraph, limit: int = 10) -> list[list[str]]:
73
+ """Import cycles - the places where a newcomer's mental model breaks first."""
74
+ cycles: list[list[str]] = []
75
+ for cycle in nx.simple_cycles(graph):
76
+ cycles.append(sorted(cycle))
77
+ if len(cycles) >= limit:
78
+ break
79
+ return cycles
foothold/graph/rank.py ADDED
@@ -0,0 +1,117 @@
1
+ """Rank modules by how much of the system they explain.
2
+
3
+ score = w_c * pagerank + w_h * churn + w_f * fan_in + w_s * size
4
+
5
+ All four components are min-max normalised to [0, 1] over the repository, so a
6
+ score is comparable within a repo but not across repos. The weights live in
7
+ ``Config.weights`` and are printable via ``foothold map --explain-scoring``:
8
+ a ranking a user cannot interrogate is a ranking a user cannot trust.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+
15
+ import networkx as nx
16
+
17
+ from foothold.config import Weights
18
+ from foothold.models import Module, Score
19
+
20
+
21
+ def pagerank(
22
+ graph: nx.DiGraph, alpha: float = 0.85, iterations: int = 60, tol: float = 1.0e-8
23
+ ) -> dict[str, float]:
24
+ """Power-iteration PageRank in pure Python.
25
+
26
+ ``networkx.pagerank`` pulls in scipy and numpy - roughly 100 MB of wheels for
27
+ a CLI whose graphs have hundreds of nodes, not millions. Sixty iterations of
28
+ a dict-based power method is exact enough for a ranking and keeps the install
29
+ to three small dependencies.
30
+ """
31
+ nodes = list(graph.nodes)
32
+ if not nodes:
33
+ return {}
34
+ n = len(nodes)
35
+ rank_of = dict.fromkeys(nodes, 1.0 / n)
36
+ out_weight = {
37
+ u: sum(d.get("weight", 1) for _, _, d in graph.out_edges(u, data=True)) for u in nodes
38
+ }
39
+ dangling = [u for u in nodes if out_weight[u] == 0]
40
+
41
+ for _ in range(iterations):
42
+ previous = rank_of
43
+ leaked = alpha * sum(previous[u] for u in dangling) / n
44
+ rank_of = {u: (1.0 - alpha) / n + leaked for u in nodes}
45
+ for u, v, data in graph.edges(data=True):
46
+ if out_weight[u]:
47
+ rank_of[v] += alpha * previous[u] * data.get("weight", 1) / out_weight[u]
48
+ if sum(abs(rank_of[u] - previous[u]) for u in nodes) < tol * n:
49
+ break
50
+ return rank_of
51
+
52
+
53
+ def _normalise(values: dict[str, float]) -> dict[str, float]:
54
+ if not values:
55
+ return {}
56
+ lo, hi = min(values.values()), max(values.values())
57
+ if math.isclose(lo, hi):
58
+ return dict.fromkeys(values, 0.0)
59
+ return {k: (v - lo) / (hi - lo) for k, v in values.items()}
60
+
61
+
62
+ def rank(
63
+ graph: nx.DiGraph,
64
+ modules: dict[str, Module],
65
+ churn: dict[str, int],
66
+ weights: Weights,
67
+ ) -> list[Score]:
68
+ targets = [n for n in graph.nodes if not modules[n].is_test]
69
+ if not targets:
70
+ return []
71
+ # Edges point importer -> imported, so PageRank on the graph as-built already
72
+ # rewards "many things depend on me". Reversing it would rank the modules with
73
+ # the most imports, which is the opposite of what a newcomer needs.
74
+ scores_pr = pagerank(graph) if graph.number_of_edges() else {}
75
+
76
+ raw_centrality = {n: float(scores_pr.get(n, 0.0)) for n in targets}
77
+ raw_churn = {n: float(churn.get(modules[n].path, 0)) for n in targets}
78
+ raw_fan_in = {n: float(graph.in_degree(n)) for n in targets}
79
+ raw_size = {n: math.log1p(modules[n].loc) for n in targets}
80
+
81
+ norm_c = _normalise(raw_centrality)
82
+ norm_h = _normalise(raw_churn)
83
+ norm_f = _normalise(raw_fan_in)
84
+ norm_s = _normalise(raw_size)
85
+
86
+ scores: list[Score] = []
87
+ for name in targets:
88
+ total = (
89
+ weights.centrality * norm_c[name]
90
+ + weights.churn * norm_h[name]
91
+ + weights.fan_in * norm_f[name]
92
+ + weights.size * norm_s[name]
93
+ )
94
+ reasons = []
95
+ if norm_f[name] > 0.6:
96
+ reasons.append(f"imported by {int(raw_fan_in[name])} modules")
97
+ if norm_h[name] > 0.6:
98
+ reasons.append(f"{int(raw_churn[name])} commits in 18 months")
99
+ if norm_c[name] > 0.6:
100
+ reasons.append("high transitive reach")
101
+ if modules[name].loc > 400:
102
+ reasons.append(f"{modules[name].loc} lines")
103
+ scores.append(
104
+ Score(
105
+ module=name,
106
+ path=modules[name].path,
107
+ total=round(total, 4),
108
+ centrality=round(norm_c[name], 4),
109
+ churn=round(norm_h[name], 4),
110
+ fan_in=int(raw_fan_in[name]),
111
+ fan_out=int(graph.out_degree(name)),
112
+ loc=modules[name].loc,
113
+ reasons=reasons,
114
+ )
115
+ )
116
+ scores.sort(key=lambda s: (-s.total, s.module))
117
+ return scores