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/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,3 @@
1
+ from foothold.narrator.client import NarratorError, narrate
2
+
3
+ __all__ = ["NarratorError", "narrate"]
@@ -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,5 @@
1
+ from foothold.render.markdown import render_architecture
2
+ from foothold.render.mermaid import render_mermaid
3
+ from foothold.render.terminal import render_map
4
+
5
+ __all__ = ["render_architecture", "render_map", "render_mermaid"]
@@ -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
+ [![CI](https://github.com/serdairy/foothold/actions/workflows/ci.yml/badge.svg)](https://github.com/serdairy/foothold/actions/workflows/ci.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/foothold.svg)](https://pypi.org/project/foothold/)
37
+ [![Python](https://img.shields.io/pypi/pyversions/foothold.svg)](https://pypi.org/project/foothold/)
38
+ [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
39
+ [![Checked with mypy](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy-lang.org/)
40
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ foothold = foothold.cli:app