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/issues.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Good-first-issue candidates, derived deterministically.
|
|
2
|
+
|
|
3
|
+
Three sources, all offline: TODO/FIXME markers, public functions without a
|
|
4
|
+
docstring in low-centrality modules, and source files with no matching test.
|
|
5
|
+
Low centrality matters - a first issue should not sit on the critical path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from foothold.models import RepoMap
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class IssueCandidate:
|
|
17
|
+
title: str
|
|
18
|
+
body: str
|
|
19
|
+
labels: list[str]
|
|
20
|
+
difficulty: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def propose(repo: RepoMap, limit: int = 10) -> list[IssueCandidate]:
|
|
24
|
+
out: list[IssueCandidate] = []
|
|
25
|
+
rank_of = {s.path: i for i, s in enumerate(repo.scores)}
|
|
26
|
+
peripheral = len(repo.scores) // 2
|
|
27
|
+
|
|
28
|
+
for marker in repo.markers:
|
|
29
|
+
if rank_of.get(marker.path, 0) < peripheral // 2:
|
|
30
|
+
continue # too central for a newcomer
|
|
31
|
+
out.append(
|
|
32
|
+
IssueCandidate(
|
|
33
|
+
title=f"{marker.kind.title()}: {marker.text or marker.path}"[:80],
|
|
34
|
+
body=(
|
|
35
|
+
f"`{marker.path}:{marker.lineno}` carries a {marker.kind} marker:\n\n"
|
|
36
|
+
f"> {marker.text}\n\n"
|
|
37
|
+
"Self-contained and off the critical path — a good first change."
|
|
38
|
+
),
|
|
39
|
+
labels=["good first issue"],
|
|
40
|
+
difficulty="small",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
for path in repo.untested[:limit]:
|
|
45
|
+
out.append(
|
|
46
|
+
IssueCandidate(
|
|
47
|
+
title=f"Add tests for {path}",
|
|
48
|
+
body=(
|
|
49
|
+
f"`{path}` has no test file referencing it. Adding coverage is a "
|
|
50
|
+
"low-risk way to learn the module."
|
|
51
|
+
),
|
|
52
|
+
labels=["good first issue", "tests"],
|
|
53
|
+
difficulty="small",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
for score in repo.scores[peripheral:]:
|
|
58
|
+
module = repo.modules[score.module]
|
|
59
|
+
if module.docstring is None and module.defines:
|
|
60
|
+
out.append(
|
|
61
|
+
IssueCandidate(
|
|
62
|
+
title=f"Document the public API of {score.path}",
|
|
63
|
+
body=(
|
|
64
|
+
f"`{score.path}` exports {', '.join(module.defines[:5])} with no "
|
|
65
|
+
"module docstring."
|
|
66
|
+
),
|
|
67
|
+
labels=["good first issue", "documentation"],
|
|
68
|
+
difficulty="small",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return out[:limit]
|
foothold/models.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Core data structures shared across collectors, graph and renderers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Module:
|
|
10
|
+
"""A single source file resolved to a dotted module path."""
|
|
11
|
+
|
|
12
|
+
path: str
|
|
13
|
+
module: str
|
|
14
|
+
loc: int
|
|
15
|
+
is_test: bool
|
|
16
|
+
is_package_init: bool
|
|
17
|
+
defines: tuple[str, ...] = ()
|
|
18
|
+
docstring: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Edge:
|
|
23
|
+
"""A directed import edge between two in-project modules."""
|
|
24
|
+
|
|
25
|
+
src: str
|
|
26
|
+
dst: str
|
|
27
|
+
lineno: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Marker:
|
|
32
|
+
"""A TODO/FIXME/HACK comment left in the source."""
|
|
33
|
+
|
|
34
|
+
path: str
|
|
35
|
+
lineno: int
|
|
36
|
+
kind: str
|
|
37
|
+
text: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Score:
|
|
42
|
+
"""Ranking output for one module."""
|
|
43
|
+
|
|
44
|
+
module: str
|
|
45
|
+
path: str
|
|
46
|
+
total: float
|
|
47
|
+
centrality: float
|
|
48
|
+
churn: float
|
|
49
|
+
fan_in: int
|
|
50
|
+
fan_out: int
|
|
51
|
+
loc: int
|
|
52
|
+
reasons: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class RepoMap:
|
|
57
|
+
"""Everything Foothold knows about a repository after the offline pass."""
|
|
58
|
+
|
|
59
|
+
root: str
|
|
60
|
+
modules: dict[str, Module]
|
|
61
|
+
edges: list[Edge]
|
|
62
|
+
scores: list[Score]
|
|
63
|
+
entrypoints: list[str]
|
|
64
|
+
markers: list[Marker]
|
|
65
|
+
untested: list[str]
|
|
66
|
+
cycles: list[list[str]]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def loc_total(self) -> int:
|
|
70
|
+
return sum(m.loc for m in self.modules.values())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The only component that talks to a model.
|
|
2
|
+
|
|
3
|
+
Design constraints, in order of importance:
|
|
4
|
+
|
|
5
|
+
1. The model receives a *pruned* context - the ranked file list and selected
|
|
6
|
+
docstrings - never the repository. Cost scales with the summary, not the code.
|
|
7
|
+
2. Every call reports its estimated spend before it happens; non-interactive
|
|
8
|
+
runs must pass ``--yes``.
|
|
9
|
+
3. The dependency is optional. Without ``pip install foothold[narrate]``
|
|
10
|
+
every other command still works.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from foothold.models import RepoMap
|
|
19
|
+
|
|
20
|
+
SYSTEM = (
|
|
21
|
+
"You are documenting an unfamiliar codebase for a new contributor. You are given a "
|
|
22
|
+
"ranked file list produced by static analysis, not the source. Describe what the "
|
|
23
|
+
"system does and in what order to read it. State only what the data supports; if a "
|
|
24
|
+
"module's purpose is unclear from its name and docstring, say so."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NarratorError(RuntimeError):
|
|
29
|
+
"""Raised when narration is requested but unavailable."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Usage:
|
|
34
|
+
prompt_tokens: int
|
|
35
|
+
completion_tokens: int
|
|
36
|
+
|
|
37
|
+
def cost_usd(self, per_m_in: float = 0.15, per_m_out: float = 0.60) -> float:
|
|
38
|
+
return (self.prompt_tokens * per_m_in + self.completion_tokens * per_m_out) / 1e6
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_context(repo: RepoMap, top: int = 25) -> str:
|
|
42
|
+
"""Assemble the pruned context. Kept pure so it can be snapshot-tested."""
|
|
43
|
+
lines = [f"Repository: {repo.root}", f"{len(repo.modules)} modules, {repo.loc_total} lines", ""]
|
|
44
|
+
lines.append("Entry points:")
|
|
45
|
+
lines += [f"- {repo.modules[n].path}" for n in repo.entrypoints[:6]]
|
|
46
|
+
lines.append("")
|
|
47
|
+
lines.append("Ranked core modules (score, imported-by, lines, docstring first line):")
|
|
48
|
+
for score in repo.scores[:top]:
|
|
49
|
+
doc = (repo.modules[score.module].docstring or "").strip().splitlines()
|
|
50
|
+
lines.append(
|
|
51
|
+
f"- {score.path} | {score.total:.3f} | in={score.fan_in} | loc={score.loc} | "
|
|
52
|
+
f"{doc[0] if doc else ''}"
|
|
53
|
+
)
|
|
54
|
+
if repo.cycles:
|
|
55
|
+
lines += ["", "Import cycles:"] + [f"- {' -> '.join(c)}" for c in repo.cycles[:5]]
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def estimate_tokens(context: str) -> int:
|
|
60
|
+
"""Deliberately crude: 4 chars/token. Over-estimates, which is the safe direction."""
|
|
61
|
+
return len(context) // 4 + 400
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def narrate(repo: RepoMap, model: str, top: int = 25) -> tuple[str, Usage]:
|
|
65
|
+
try:
|
|
66
|
+
from openai import OpenAI
|
|
67
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
68
|
+
raise NarratorError(
|
|
69
|
+
"Narration needs the optional dependency: pip install 'foothold[narrate]'"
|
|
70
|
+
) from exc
|
|
71
|
+
if not os.getenv("OPENAI_API_KEY"):
|
|
72
|
+
raise NarratorError("OPENAI_API_KEY is not set.")
|
|
73
|
+
|
|
74
|
+
context = build_context(repo, top=top)
|
|
75
|
+
client = OpenAI()
|
|
76
|
+
response = client.chat.completions.create(
|
|
77
|
+
model=model,
|
|
78
|
+
messages=[{"role": "system", "content": SYSTEM}, {"role": "user", "content": context}],
|
|
79
|
+
temperature=0.2,
|
|
80
|
+
)
|
|
81
|
+
usage = Usage(
|
|
82
|
+
prompt_tokens=getattr(response.usage, "prompt_tokens", 0),
|
|
83
|
+
completion_tokens=getattr(response.usage, "completion_tokens", 0),
|
|
84
|
+
)
|
|
85
|
+
return response.choices[0].message.content or "", usage
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Deterministic ARCHITECTURE.md.
|
|
2
|
+
|
|
3
|
+
This renderer never calls a model. ``foothold docs --narrate`` adds prose on
|
|
4
|
+
top of exactly this structure, so the document remains useful, and correct,
|
|
5
|
+
without an API key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from foothold.models import RepoMap
|
|
11
|
+
from foothold.render.mermaid import render_mermaid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render_architecture(repo: RepoMap, top: int = 20, narrative: str | None = None) -> str:
|
|
15
|
+
sources = [m for m in repo.modules.values() if not m.is_test]
|
|
16
|
+
parts = [
|
|
17
|
+
"# Architecture",
|
|
18
|
+
"",
|
|
19
|
+
"<!-- Generated by foothold. Re-run `foothold docs` to refresh. -->",
|
|
20
|
+
"",
|
|
21
|
+
f"{len(repo.modules)} modules ({len(sources)} source), {repo.loc_total:,} lines, "
|
|
22
|
+
f"{len(repo.edges)} import statements.",
|
|
23
|
+
"",
|
|
24
|
+
]
|
|
25
|
+
if narrative:
|
|
26
|
+
parts += ["## Overview", "", narrative, ""]
|
|
27
|
+
parts += ["## Where to start", ""]
|
|
28
|
+
for name in repo.entrypoints[:6]:
|
|
29
|
+
module = repo.modules[name]
|
|
30
|
+
summary = (module.docstring or "").strip().splitlines()
|
|
31
|
+
parts.append(f"- `{module.path}` — {summary[0] if summary else 'no docstring'}")
|
|
32
|
+
parts += [
|
|
33
|
+
"",
|
|
34
|
+
"## Core modules",
|
|
35
|
+
"",
|
|
36
|
+
"| File | Score | Imported by | Lines | Signals |",
|
|
37
|
+
"|---|---:|---:|---:|---|",
|
|
38
|
+
]
|
|
39
|
+
for score in repo.scores[:top]:
|
|
40
|
+
parts.append(
|
|
41
|
+
f"| `{score.path}` | {score.total:.3f} | {score.fan_in} | {score.loc} | "
|
|
42
|
+
f"{', '.join(score.reasons) or '—'} |"
|
|
43
|
+
)
|
|
44
|
+
parts += ["", "## Dependency graph", "", render_mermaid(repo), ""]
|
|
45
|
+
if repo.cycles:
|
|
46
|
+
parts += ["## Import cycles", ""]
|
|
47
|
+
parts += [f"- {' → '.join(c)}" for c in repo.cycles[:5]]
|
|
48
|
+
parts.append("")
|
|
49
|
+
parts += [
|
|
50
|
+
"## How this file is scored",
|
|
51
|
+
"",
|
|
52
|
+
"`score = 0.45·pagerank + 0.30·churn + 0.15·fan-in + 0.10·log(loc)`, each term "
|
|
53
|
+
"min-max normalised across the repository. Weights are configurable in "
|
|
54
|
+
"`.foothold.toml`.",
|
|
55
|
+
"",
|
|
56
|
+
]
|
|
57
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Mermaid diagram of the top-N subgraph - GitHub renders it natively."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from foothold.models import RepoMap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _slug(name: str) -> str:
|
|
9
|
+
return name.replace(".", "_").replace("-", "_")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_mermaid(repo: RepoMap, top: int = 12) -> str:
|
|
13
|
+
keep = {s.module for s in repo.scores[:top]}
|
|
14
|
+
lines = ["```mermaid", "graph LR"]
|
|
15
|
+
for score in repo.scores[:top]:
|
|
16
|
+
lines.append(f' {_slug(score.module)}["{score.path}"]')
|
|
17
|
+
seen: set[tuple[str, str]] = set()
|
|
18
|
+
for edge in repo.edges:
|
|
19
|
+
src, dst = edge.src, edge.dst
|
|
20
|
+
if src in keep and dst in keep and (src, dst) not in seen and src != dst:
|
|
21
|
+
seen.add((src, dst))
|
|
22
|
+
lines.append(f" {_slug(src)} --> {_slug(dst)}")
|
|
23
|
+
lines.append("```")
|
|
24
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Rich terminal output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from foothold.models import RepoMap
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_map(repo: RepoMap, top: int, console: Console | None = None) -> None:
|
|
12
|
+
console = console or Console()
|
|
13
|
+
sources = [m for m in repo.modules.values() if not m.is_test]
|
|
14
|
+
tests = len(repo.modules) - len(sources)
|
|
15
|
+
console.print(
|
|
16
|
+
f"[bold]{len(repo.modules)}[/bold] modules "
|
|
17
|
+
f"([bold]{len(sources)}[/bold] source, [bold]{tests}[/bold] test) · "
|
|
18
|
+
f"[bold]{repo.loc_total:,}[/bold] lines · "
|
|
19
|
+
f"[bold]{len(repo.edges)}[/bold] import statements"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
table = Table(title=f"Top {top} files by structural weight", title_justify="left")
|
|
23
|
+
table.add_column("#", justify="right", style="dim")
|
|
24
|
+
table.add_column("file")
|
|
25
|
+
table.add_column("score", justify="right")
|
|
26
|
+
table.add_column("in", justify="right")
|
|
27
|
+
table.add_column("loc", justify="right")
|
|
28
|
+
table.add_column("why", style="dim")
|
|
29
|
+
for i, score in enumerate(repo.scores[:top], start=1):
|
|
30
|
+
table.add_row(
|
|
31
|
+
str(i),
|
|
32
|
+
score.path,
|
|
33
|
+
f"{score.total:.3f}",
|
|
34
|
+
str(score.fan_in),
|
|
35
|
+
str(score.loc),
|
|
36
|
+
", ".join(score.reasons) or "—",
|
|
37
|
+
)
|
|
38
|
+
console.print(table)
|
|
39
|
+
|
|
40
|
+
if repo.entrypoints:
|
|
41
|
+
console.print("\n[bold]Entry points[/bold] (imported by nothing in-project)")
|
|
42
|
+
for name in repo.entrypoints[:8]:
|
|
43
|
+
console.print(f" · {repo.modules[name].path}")
|
|
44
|
+
if repo.cycles:
|
|
45
|
+
console.print(f"\n[bold yellow]{len(repo.cycles)} import cycle(s)[/bold yellow]")
|
|
46
|
+
for cycle in repo.cycles[:3]:
|
|
47
|
+
console.print(f" · {' → '.join(cycle)}")
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: foothold
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Turn an unfamiliar repository into a reading path
|
|
5
|
+
Project-URL: Homepage, https://github.com/serdairy/foothold
|
|
6
|
+
Project-URL: Issues, https://github.com/serdairy/foothold/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/serdairy/foothold/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Sergei Petrov <pserg@me.com>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,developer-tools,documentation,onboarding,static-analysis
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: networkx>=3.1
|
|
22
|
+
Requires-Dist: rich>=13.7
|
|
23
|
+
Requires-Dist: typer>=0.12
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Provides-Extra: narrate
|
|
30
|
+
Requires-Dist: openai>=1.40; extra == 'narrate'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Foothold
|
|
34
|
+
|
|
35
|
+
[](https://github.com/serdairy/foothold/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/foothold/)
|
|
37
|
+
[](https://pypi.org/project/foothold/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](https://mypy-lang.org/)
|
|
40
|
+
[](https://github.com/astral-sh/ruff)
|
|
41
|
+
|
|
42
|
+
**Which 20 files should I read first?** Foothold answers that for a Python repository in
|
|
43
|
+
under a second, without an API key — the foothold you need before you can start climbing
|
|
44
|
+
an unfamiliar codebase.
|
|
45
|
+
|
|
46
|
+
```console
|
|
47
|
+
$ foothold map ~/src/rich
|
|
48
|
+
99 modules (99 source, 0 test) · 38,437 lines · 1,884 import statements
|
|
49
|
+
Top 6 files by structural weight
|
|
50
|
+
┏━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
51
|
+
┃ # ┃ file ┃ score ┃ in ┃ loc ┃ why ┃
|
|
52
|
+
┡━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
|
53
|
+
│ 1 │ console.py │ 0.695 │ 49 │ 2699 │ imported by 49 modules │
|
|
54
|
+
│ 2 │ cells.py │ 0.542 │ 31 │ 353 │ imported by 31 modules │
|
|
55
|
+
│ 3 │ _unicode_data/__init__.py │ 0.380 │ 1 │ 94 │ high transitive reach │
|
|
56
|
+
│ 4 │ text.py │ 0.353 │ 31 │ 1364 │ imported by 31 modules │
|
|
57
|
+
│ 5 │ style.py │ 0.299 │ 30 │ 797 │ imported by 30 modules │
|
|
58
|
+
│ 6 │ segment.py │ 0.220 │ 21 │ 781 │ 781 lines │
|
|
59
|
+
└───┴───────────────────────────┴───────┴────┴──────┴──────────────────────────┘
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
That ordering is not a guess. It falls out of the import graph, weighted by how often each
|
|
63
|
+
file has been edited.
|
|
64
|
+
|
|
65
|
+
## The problem
|
|
66
|
+
|
|
67
|
+
Contributor onboarding is the most expensive unpaid work in open source, and it is paid
|
|
68
|
+
twice — once by the newcomer who spends a weekend deciding which of 400 files matter, and
|
|
69
|
+
once by the maintainer answering the same orientation question in every issue thread.
|
|
70
|
+
|
|
71
|
+
The usual mitigations do not hold. `ARCHITECTURE.md` is written once at project inception
|
|
72
|
+
and drifts within two releases. Generated API references list every symbol and rank none
|
|
73
|
+
of them. Pasting a repository into a chat window costs ~46,000 tokens for a project the
|
|
74
|
+
size of networkx, and produces fluent prose with no grounding in the actual import graph.
|
|
75
|
+
|
|
76
|
+
Foothold splits the problem in two. **Ranking is deterministic** — a graph, a churn
|
|
77
|
+
count, a formula you can read. **Prose is optional** and sits on top of an already-correct,
|
|
78
|
+
already-pruned selection. The expensive part is the part that does not need a model.
|
|
79
|
+
|
|
80
|
+
## Install
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv tool install foothold # or: pipx install foothold
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Three runtime dependencies: `typer`, `rich`, `networkx`. PageRank is implemented in pure
|
|
87
|
+
Python specifically to avoid pulling ~100 MB of scipy and numpy into a CLI.
|
|
88
|
+
|
|
89
|
+
## Commands
|
|
90
|
+
|
|
91
|
+
| Command | What it does | Network |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `foothold map .` | Rank the files that hold the repo together | none |
|
|
94
|
+
| `foothold docs . -o ARCHITECTURE.md` | Write a deterministic architecture document with a Mermaid graph | none |
|
|
95
|
+
| `foothold issues . --max 10` | Propose good-first-issue candidates, off the critical path | none |
|
|
96
|
+
| `foothold explain . --dry-run` | Print the exact payload a model would receive | none |
|
|
97
|
+
| `foothold explain .` | Prose walkthrough grounded in the ranked map | OpenAI API |
|
|
98
|
+
| `foothold docs . --narrate` | The same document, with an overview section | OpenAI API |
|
|
99
|
+
|
|
100
|
+
The two commands that cost money print an estimate and require confirmation; `--yes` is
|
|
101
|
+
mandatory for non-interactive use.
|
|
102
|
+
|
|
103
|
+
## How the ranking works
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
score = 0.45·pagerank + 0.30·churn + 0.15·fan-in + 0.10·log(loc)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Each term is min-max normalised across the repository, so scores compare within a repo but
|
|
110
|
+
not across repos. The weights live in `.foothold.toml` and are printed in every generated
|
|
111
|
+
document — a ranking you cannot interrogate is a ranking you cannot trust.
|
|
112
|
+
|
|
113
|
+
- **PageRank** over the in-project import graph. Edges point *importer → imported*, so a
|
|
114
|
+
module everything depends on scores high. External and stdlib imports are dropped: they
|
|
115
|
+
add nodes without adding signal. (`test_pagerank_ranks_dependencies_above_dependents`
|
|
116
|
+
guards the direction — reversing it silently inverts the whole tool.)
|
|
117
|
+
- **Churn** from `git log --since=18.months`. A file edited in every release is a file a
|
|
118
|
+
newcomer will have to touch. Repositories without git history degrade to a zero churn
|
|
119
|
+
signal rather than failing.
|
|
120
|
+
- **Fan-in** as a plain, legible count, so the top of the list is explainable without
|
|
121
|
+
understanding PageRank.
|
|
122
|
+
- **Size**, log-scaled, as a weak tiebreaker.
|
|
123
|
+
|
|
124
|
+
Tests are excluded from the ranking and used instead to detect untested modules.
|
|
125
|
+
|
|
126
|
+
## What it sends, and what it does not
|
|
127
|
+
|
|
128
|
+
`foothold explain . --dry-run` prints the complete payload. It contains file paths,
|
|
129
|
+
scores, entry points, import cycles and the first line of each module docstring. **It does
|
|
130
|
+
not contain source code** — there is a test asserting exactly that.
|
|
131
|
+
|
|
132
|
+
The consequence is that context size tracks `--top`, not repository size:
|
|
133
|
+
|
|
134
|
+
| Repository | Modules | Lines of code | Context sent | Budgeted tokens |
|
|
135
|
+
|---|---:|---:|---:|---:|
|
|
136
|
+
| foothold | 31 | 1,284 | 1,999 chars | 899 |
|
|
137
|
+
| rich | 99 | 38,437 | 2,314 chars | 978 |
|
|
138
|
+
| networkx | 565 | 183,241 | 2,837 chars | 1,109 |
|
|
139
|
+
|
|
140
|
+
A 183,000-line codebase is described in under 3 KB. Full numbers and method in
|
|
141
|
+
[docs/cost-model.md](docs/cost-model.md).
|
|
142
|
+
|
|
143
|
+
Foothold also never executes the code it reads — parsing is stdlib `ast`, which does not
|
|
144
|
+
evaluate. See [SECURITY.md](SECURITY.md).
|
|
145
|
+
|
|
146
|
+
## Architecture
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
src/foothold/
|
|
150
|
+
├── cli.py # Typer entry point
|
|
151
|
+
├── analyze.py # orchestration: collect → graph → rank → RepoMap
|
|
152
|
+
├── models.py # the shared vocabulary; imported by 10 modules
|
|
153
|
+
├── config.py # .foothold.toml, ranking weights
|
|
154
|
+
├── collectors/ # python_ast · git_history · markers (offline)
|
|
155
|
+
├── graph/ # build (import graph) · rank (pagerank + weights)
|
|
156
|
+
├── issues.py # good-first-issue heuristics (offline)
|
|
157
|
+
├── render/ # terminal · markdown · mermaid (offline)
|
|
158
|
+
└── narrator/ # the only module that talks to a model
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
[ARCHITECTURE.md](ARCHITECTURE.md) is generated by `foothold docs` and refreshed at each
|
|
162
|
+
release. It is deliberately not pinned by a CI equality check: churn is an input, so the
|
|
163
|
+
ranking moves as history accumulates, and a byte-for-byte assertion would fail on every
|
|
164
|
+
commit. What CI does assert is that the generator runs against this repository on all
|
|
165
|
+
twelve OS and Python combinations.
|
|
166
|
+
|
|
167
|
+
## Limitations
|
|
168
|
+
|
|
169
|
+
Stated plainly, because the alternative wastes your time:
|
|
170
|
+
|
|
171
|
+
- **Python only.** Other languages are parsed as nothing. tree-sitter support is v0.3.
|
|
172
|
+
- Dynamic imports (`importlib`, plugin registries, `__getattr__` re-exports) are invisible
|
|
173
|
+
to static analysis and will under-rank plugin-heavy architectures.
|
|
174
|
+
- Churn needs real git history. CI must use `fetch-depth: 0`; shallow clones silently lose
|
|
175
|
+
that signal.
|
|
176
|
+
- Monorepos with several independent packages are ranked as one graph. v0.5.
|
|
177
|
+
- Scores are comparable within a repository, never across repositories.
|
|
178
|
+
- Scores also move over time within one repository: churn is measured over a rolling
|
|
179
|
+
18-month window, so the same commit ranked today and in six months can differ. The
|
|
180
|
+
ranking describes a repository's present, not a fixed property of its files.
|
|
181
|
+
|
|
182
|
+
## Roadmap
|
|
183
|
+
|
|
184
|
+
| Version | Scope | Status |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| **v0.1** | `map`, `docs`, `issues`, `explain`; Python; 85% coverage | **shipped** |
|
|
187
|
+
| v0.2 | Content-hash cache; incremental re-analysis on diff; `--since` | next |
|
|
188
|
+
| v0.3 | tree-sitter parsers: TypeScript, JavaScript, Go | planned |
|
|
189
|
+
| v0.4 | `tour` with personas; GitHub Action for CI-regenerated docs | planned |
|
|
190
|
+
| v0.5 | Monorepo support; call-graph edges, not just imports | planned |
|
|
191
|
+
| v1.0 | Stable JSON schema; benchmark suite against hand-written docs | planned |
|
|
192
|
+
|
|
193
|
+
Non-goals: replacing hand-written design documents, reviewing code, running as a hosted
|
|
194
|
+
service. Foothold is a local tool that produces files you own and commit.
|
|
195
|
+
|
|
196
|
+
## Contributing
|
|
197
|
+
|
|
198
|
+
Foothold exists because onboarding is hard, so its own onboarding has to be good:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
git clone https://github.com/serdairy/foothold && cd foothold
|
|
202
|
+
uv sync --all-extras
|
|
203
|
+
uv run foothold map . # start here
|
|
204
|
+
uv run pytest # 23 tests, ~0.2s, no network
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
If `map` gives you a confusing reading order on your project, open a **bad ranking** issue
|
|
208
|
+
— that is the most useful report this project can receive. See
|
|
209
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
Apache-2.0 — chosen over MIT for the explicit patent grant, which matters for a tool that
|
|
214
|
+
parses other people's code. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
foothold/__init__.py,sha256=bdoIUJJotvwJJVIol1KyhDd1R49_2a--WKJ5KM_OQDE,90
|
|
2
|
+
foothold/analyze.py,sha256=m6LMevjjlCsRkV0xxCOwSRpLoJryuygvgOEa4FdNhmI,1452
|
|
3
|
+
foothold/cli.py,sha256=VWoY_oulKWibYbG_sUhrgwpVS34HwXSXWlu0aUrF6oY,5357
|
|
4
|
+
foothold/config.py,sha256=SdK8Mgx9974Zu7OBAIPdMl27shzQNmAsXQzO-ufMjrI,1731
|
|
5
|
+
foothold/issues.py,sha256=VyNcYnpDzlY_vSqpIuGTCEgcBQuFoShgeKAwZD1vhZk,2404
|
|
6
|
+
foothold/models.py,sha256=NGsTnvjAWf1OmC9PzGD4hnx3QV41ZZkVb9OJOVxaNHs,1348
|
|
7
|
+
foothold/collectors/__init__.py,sha256=egbW0cc6lKrfDH1EXjx5hfET-HUN2Q85e35fv0HBjdg,240
|
|
8
|
+
foothold/collectors/git_history.py,sha256=tuJS55gWsmJW2l_B3dyJ3AVrwV_PtN9TdtcwpMl6BSU,1209
|
|
9
|
+
foothold/collectors/markers.py,sha256=GCrYf_t3cYnMnfPHziH2XeD81ovRHAI24z8XJAdhoFo,958
|
|
10
|
+
foothold/collectors/python_ast.py,sha256=dy-SNVOsZ8gGqx88114aB0CvBti-3uJwkvFQXNaaT_0,3799
|
|
11
|
+
foothold/graph/__init__.py,sha256=f-NiKBD-t1RwzNXtOZpSQvqQFnXEjTMlifJH7ZyOLGk,183
|
|
12
|
+
foothold/graph/build.py,sha256=6XT4GdD5NKRrmJfcZG9tc0e_Ah52eWX_jFNd0D4cIso,2884
|
|
13
|
+
foothold/graph/rank.py,sha256=Lyd9nicmu8lpwVVFqxHr8xi9eqCZ0W6YsMcPYKYgNTE,4249
|
|
14
|
+
foothold/narrator/__init__.py,sha256=gdBGBl4c5IazvJD9o9Chy2Gvpzr7nsZw657I0xP93QQ,100
|
|
15
|
+
foothold/narrator/client.py,sha256=2Jna2xt6PpO1QuG_DrO24lfUro67LagZyAsXddM-u4U,3229
|
|
16
|
+
foothold/render/__init__.py,sha256=cfD8TslJvzinR-czFbUdH-MMWWLU2AtlOS1_gXmgjtQ,223
|
|
17
|
+
foothold/render/markdown.py,sha256=OBPhBALtCYL0GbuDIH_SOrkkTSh4g_MShbF0AKaM38k,2048
|
|
18
|
+
foothold/render/mermaid.py,sha256=irYTouXP9_1XcSN7NV_dfmSVmXHCWRPxdOxTX3hbVhY,815
|
|
19
|
+
foothold/render/terminal.py,sha256=T7DRSx135Vi5WhUgCFxoeSpfIiJyXFApJazAXCXhfS0,1734
|
|
20
|
+
foothold-0.1.1.dist-info/METADATA,sha256=PWBH9vffoAEragtptB4pc5Q47wCb_fEsd6Yrozcy_Ns,11012
|
|
21
|
+
foothold-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
22
|
+
foothold-0.1.1.dist-info/entry_points.txt,sha256=1drN3lESuKX_JcU3707la3PnJ4vB_lI5WxsNppNA320,46
|
|
23
|
+
foothold-0.1.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
24
|
+
foothold-0.1.1.dist-info/RECORD,,
|