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 +3 -0
- foothold/analyze.py +40 -0
- foothold/cli.py +151 -0
- foothold/collectors/__init__.py +5 -0
- foothold/collectors/git_history.py +43 -0
- foothold/collectors/markers.py +31 -0
- foothold/collectors/python_ast.py +108 -0
- foothold/config.py +70 -0
- foothold/graph/__init__.py +4 -0
- foothold/graph/build.py +79 -0
- foothold/graph/rank.py +117 -0
- foothold/issues.py +71 -0
- foothold/models.py +70 -0
- foothold/narrator/__init__.py +3 -0
- foothold/narrator/client.py +85 -0
- foothold/render/__init__.py +5 -0
- foothold/render/markdown.py +57 -0
- foothold/render/mermaid.py +24 -0
- foothold/render/terminal.py +47 -0
- foothold-0.1.1.dist-info/METADATA +214 -0
- foothold-0.1.1.dist-info/RECORD +24 -0
- foothold-0.1.1.dist-info/WHEEL +4 -0
- foothold-0.1.1.dist-info/entry_points.txt +2 -0
- foothold-0.1.1.dist-info/licenses/LICENSE +202 -0
foothold/__init__.py
ADDED
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,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
|
+
)
|
foothold/graph/build.py
ADDED
|
@@ -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
|