codebase-brain-mcp 0.1.0__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.
- codebase_brain_mcp/__init__.py +8 -0
- codebase_brain_mcp/__main__.py +118 -0
- codebase_brain_mcp/analysis.py +427 -0
- codebase_brain_mcp/brain.py +395 -0
- codebase_brain_mcp/chunking.py +196 -0
- codebase_brain_mcp/config.py +58 -0
- codebase_brain_mcp/embeddings.py +79 -0
- codebase_brain_mcp/evals.py +372 -0
- codebase_brain_mcp/history.py +214 -0
- codebase_brain_mcp/py.typed +0 -0
- codebase_brain_mcp/remote.py +112 -0
- codebase_brain_mcp/server.py +415 -0
- codebase_brain_mcp/sources.py +186 -0
- codebase_brain_mcp/store.py +349 -0
- codebase_brain_mcp-0.1.0.dist-info/METADATA +293 -0
- codebase_brain_mcp-0.1.0.dist-info/RECORD +19 -0
- codebase_brain_mcp-0.1.0.dist-info/WHEEL +4 -0
- codebase_brain_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- codebase_brain_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Codebase Brain: a cited, searchable memory of a codebase, served over MCP."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("codebase-brain-mcp")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree that isn't installed
|
|
8
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Command line: run the MCP server (default), or index and search from the shell for debugging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .brain import Brain, BrainError
|
|
12
|
+
from .config import load_settings
|
|
13
|
+
from .sources import SourceError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int:
|
|
17
|
+
parser = argparse.ArgumentParser(prog="codebase-brain", description="Codebase Brain MCP server.")
|
|
18
|
+
commands = parser.add_subparsers(dest="command")
|
|
19
|
+
serve = commands.add_parser("serve", help="Run the MCP server (the default).")
|
|
20
|
+
serve.add_argument("--transport", choices=["stdio", "streamable-http"], default="stdio")
|
|
21
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
22
|
+
serve.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8765")))
|
|
23
|
+
index = commands.add_parser("index", help="Index a local path or an https git URL.")
|
|
24
|
+
index.add_argument("source")
|
|
25
|
+
index.add_argument("--name")
|
|
26
|
+
search = commands.add_parser("search", help="Hybrid search an indexed repo.")
|
|
27
|
+
search.add_argument("query")
|
|
28
|
+
search.add_argument("--repo")
|
|
29
|
+
search.add_argument("-k", type=int, default=5)
|
|
30
|
+
evaluate = commands.add_parser("eval", help="Measure retrieval quality on a benchmark dataset.")
|
|
31
|
+
evaluate.add_argument("dataset", type=Path, help="A TOML dataset, e.g. evals/datasets/itsdangerous.toml.")
|
|
32
|
+
evaluate.add_argument("--modes", default="hybrid,keyword,vector", help="Comma-separated search modes.")
|
|
33
|
+
evaluate.add_argument("--out", type=Path, help="Directory for the Markdown and JSON reports.")
|
|
34
|
+
args = parser.parse_args(argv)
|
|
35
|
+
|
|
36
|
+
if args.command == "eval":
|
|
37
|
+
return _evaluate(args)
|
|
38
|
+
|
|
39
|
+
if args.command in (None, "serve"):
|
|
40
|
+
from .server import build_server
|
|
41
|
+
|
|
42
|
+
if getattr(args, "transport", "stdio") == "streamable-http":
|
|
43
|
+
return _serve_http(args)
|
|
44
|
+
build_server().run("stdio")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
brain = Brain(load_settings())
|
|
48
|
+
try:
|
|
49
|
+
if args.command == "index":
|
|
50
|
+
report = brain.index(
|
|
51
|
+
args.source,
|
|
52
|
+
name=args.name,
|
|
53
|
+
progress=lambda done, total, msg: print(f" [{done}/{total}] {msg}", file=sys.stderr),
|
|
54
|
+
)
|
|
55
|
+
print(report)
|
|
56
|
+
else:
|
|
57
|
+
for hit in brain.search(args.query, args.repo, args.k):
|
|
58
|
+
print(f"{hit.score:.4f} {hit.chunk.citation} {hit.chunk.kind} {hit.chunk.symbol or ''}")
|
|
59
|
+
except (BrainError, SourceError) as exc:
|
|
60
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
finally:
|
|
63
|
+
brain.close()
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _serve_http(args: argparse.Namespace) -> int:
|
|
68
|
+
import dataclasses
|
|
69
|
+
import os
|
|
70
|
+
|
|
71
|
+
from .remote import RemoteConfig, RemoteConfigError, serve
|
|
72
|
+
from .server import build_server
|
|
73
|
+
|
|
74
|
+
settings = load_settings()
|
|
75
|
+
if os.environ.get("CODEBASE_BRAIN_ALLOW_LOCAL_PATHS") != "1":
|
|
76
|
+
settings = dataclasses.replace(settings, allow_local_paths=False) # remote clients: git URLs only
|
|
77
|
+
hosts = tuple(h.strip() for h in os.environ.get("CODEBASE_BRAIN_ALLOWED_HOSTS", "").split(",") if h.strip())
|
|
78
|
+
config = RemoteConfig(
|
|
79
|
+
host=args.host, port=args.port, token=os.environ.get("CODEBASE_BRAIN_TOKEN") or None, allowed_hosts=hosts
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
config.validate()
|
|
83
|
+
except RemoteConfigError as exc:
|
|
84
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
85
|
+
return 2
|
|
86
|
+
serve(build_server(Brain(settings)), config)
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _evaluate(args: argparse.Namespace) -> int:
|
|
91
|
+
from . import evals
|
|
92
|
+
from .brain import SEARCH_MODES
|
|
93
|
+
|
|
94
|
+
modes = [m.strip() for m in args.modes.split(",") if m.strip()]
|
|
95
|
+
if unknown := [m for m in modes if m not in SEARCH_MODES]:
|
|
96
|
+
print(f"error: unknown mode(s) {', '.join(unknown)}; use {', '.join(SEARCH_MODES)}", file=sys.stderr)
|
|
97
|
+
return 2
|
|
98
|
+
brain = Brain(load_settings())
|
|
99
|
+
try:
|
|
100
|
+
dataset = evals.load_dataset(args.dataset)
|
|
101
|
+
repo = evals.prepare(brain, dataset)
|
|
102
|
+
report = evals.run(brain, repo, dataset, modes) # type: ignore[arg-type]
|
|
103
|
+
except (evals.DatasetError, BrainError, SourceError) as exc:
|
|
104
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
finally:
|
|
107
|
+
brain.close()
|
|
108
|
+
markdown = evals.to_markdown(report)
|
|
109
|
+
print(markdown)
|
|
110
|
+
if args.out:
|
|
111
|
+
args.out.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
(args.out / f"{dataset.name}.md").write_text(markdown, encoding="utf-8")
|
|
113
|
+
(args.out / f"{dataset.name}.json").write_text(json.dumps(evals.to_json(report), indent=2), encoding="utf-8")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Structural analyses over an index: what depends on a symbol, and where the docs disagree with the code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import builtins
|
|
7
|
+
import difflib
|
|
8
|
+
import keyword
|
|
9
|
+
import re
|
|
10
|
+
import textwrap
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import history
|
|
16
|
+
from .sources import SourceError
|
|
17
|
+
from .store import Store, StoredChunk
|
|
18
|
+
|
|
19
|
+
MAX_PER_LEVEL = 60
|
|
20
|
+
STALE_AFTER = timedelta(days=180)
|
|
21
|
+
MAX_STALENESS_CHECKS = 40
|
|
22
|
+
_FENCE = re.compile(r"^(`{3,}|~{3,})\s*([\w+-]*)[^\n]*\n(.*?)^\1\s*$", re.MULTILINE | re.DOTALL)
|
|
23
|
+
_INLINE = re.compile(r"(?<!`)`([^`\n]+)`(?!`)")
|
|
24
|
+
_DOUBLE = re.compile(r"``([^`\n]+)``")
|
|
25
|
+
_RST_ROLE = re.compile(r":(?:py:)?(?:func|meth|class|attr|mod|data|exc|obj|const):`([^`]+)`")
|
|
26
|
+
_RST_BLOCK = re.compile(
|
|
27
|
+
r"^\.\. (?:code-block|code|sourcecode)::[ \t]*(\w*)[^\n]*\n((?:[ \t]*\n|[ \t]+[^\n]*\n?)+)", re.MULTILINE
|
|
28
|
+
)
|
|
29
|
+
_DOCTEST = re.compile(r"^([ \t]*>>> [^\n]*\n(?:[ \t]*(?:>>>|\.\.\.)[^\n]*\n|[ \t]+[^\n]+\n)*)", re.MULTILINE)
|
|
30
|
+
_PATH = re.compile(r"^\.?/?[\w.-]+(/[\w.-]+)+/?$")
|
|
31
|
+
_ENV = re.compile(r"^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$")
|
|
32
|
+
_FLAG = re.compile(r"^(--[a-z][a-z0-9-]+)(=\S*)?$")
|
|
33
|
+
_SYMBOL = re.compile(r"^[A-Za-z_]\w*(\.[A-Za-z_]\w*)*(\(\))?$")
|
|
34
|
+
_TEST_PATH = re.compile(r"(^|/)(tests?|testing)/|(^|/)test_[^/]+$|_test\.\w+$")
|
|
35
|
+
_COMMON = set(dir(builtins)) | set(keyword.kwlist) | {"true", "false", "null", "none", "self", "cls"}
|
|
36
|
+
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
|
37
|
+
_CHANGELOG = re.compile(
|
|
38
|
+
r"(^|/)(CHANGES|CHANGELOG|HISTORY|NEWS|RELEASES?|RELEASE[-_]NOTES)[^/]*$|(^|/)changelog/", re.IGNORECASE
|
|
39
|
+
)
|
|
40
|
+
"""Changelogs are meant to mention removed and renamed things, so they're never drift."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def definition_span(matches: list[StoredChunk]) -> tuple[str, int, int, str | None]:
|
|
44
|
+
"""The real definition among same-named chunks: windows of one long function are merged back
|
|
45
|
+
together, and the biggest span wins, so ``@overload`` stubs lose to the implementation."""
|
|
46
|
+
first = matches[0]
|
|
47
|
+
same = sorted((c for c in matches if c.path == first.path and c.symbol == first.symbol), key=lambda c: c.start_line)
|
|
48
|
+
spans: list[list[int]] = []
|
|
49
|
+
for chunk in same:
|
|
50
|
+
if spans and chunk.start_line <= spans[-1][1] + 1:
|
|
51
|
+
spans[-1][1] = max(spans[-1][1], chunk.end_line)
|
|
52
|
+
else:
|
|
53
|
+
spans.append([chunk.start_line, chunk.end_line])
|
|
54
|
+
start, end = max(spans, key=lambda span: span[1] - span[0])
|
|
55
|
+
return first.path, start, end, first.symbol
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---- impact_of_change -------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Dependent:
|
|
63
|
+
chunk: StoredChunk
|
|
64
|
+
via: str
|
|
65
|
+
"""The name through which it depends on the level above."""
|
|
66
|
+
level: int
|
|
67
|
+
"""1 = uses the target directly; 2 = uses something that uses it."""
|
|
68
|
+
is_test: bool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ImpactReport:
|
|
73
|
+
symbol: str
|
|
74
|
+
path: str
|
|
75
|
+
start_line: int
|
|
76
|
+
end_line: int
|
|
77
|
+
dependents: list[Dependent]
|
|
78
|
+
same_name_definitions: list[str]
|
|
79
|
+
notes: list[str] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def impact_of_change(store: Store, target: StoredChunk, span: tuple[int, int], depth: int = 2) -> ImpactReport:
|
|
83
|
+
"""Everything that uses ``target`` by name, directly and (up to ``depth``) transitively."""
|
|
84
|
+
symbol = target.symbol or ""
|
|
85
|
+
short = symbol.rsplit(".", 1)[-1]
|
|
86
|
+
excluded = {c.id for c in store.find_symbol(symbol, target.path)}
|
|
87
|
+
dependents: list[Dependent] = []
|
|
88
|
+
frontier = [short]
|
|
89
|
+
for level in range(1, max(1, min(depth, 3)) + 1):
|
|
90
|
+
next_frontier: list[str] = []
|
|
91
|
+
for name in frontier:
|
|
92
|
+
for chunk in store.chunks_referencing(name, MAX_PER_LEVEL):
|
|
93
|
+
inside_target = chunk.path == target.path and span[0] <= chunk.start_line <= span[1]
|
|
94
|
+
if chunk.id in excluded or inside_target:
|
|
95
|
+
continue
|
|
96
|
+
excluded.add(chunk.id)
|
|
97
|
+
dependents.append(
|
|
98
|
+
Dependent(chunk=chunk, via=name, level=level, is_test=bool(_TEST_PATH.search(chunk.path)))
|
|
99
|
+
)
|
|
100
|
+
if chunk.symbol and chunk.kind in ("function", "method", "class"):
|
|
101
|
+
next_frontier.append(chunk.symbol.rsplit(".", 1)[-1])
|
|
102
|
+
frontier = list(dict.fromkeys(next_frontier))
|
|
103
|
+
if not frontier:
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
twins = sorted(
|
|
107
|
+
f"{s} ({p})"
|
|
108
|
+
for s, p in store.symbols()
|
|
109
|
+
if s.rsplit(".", 1)[-1] == short and not (s == symbol and p == target.path)
|
|
110
|
+
)
|
|
111
|
+
notes = []
|
|
112
|
+
if twins:
|
|
113
|
+
notes.append(
|
|
114
|
+
f"{len(twins)} other definition(s) share the name {short!r}; some dependents may use those "
|
|
115
|
+
"instead. Check the import or receiver before changing anything."
|
|
116
|
+
)
|
|
117
|
+
if not target.path.endswith((".py", ".pyi")):
|
|
118
|
+
notes.append("Only Python is analysed structurally; for other languages, also run search for the name.")
|
|
119
|
+
notes.append("Found by name, so dynamic calls (getattr, string dispatch, callbacks) aren't included.")
|
|
120
|
+
return ImpactReport(
|
|
121
|
+
symbol=symbol,
|
|
122
|
+
path=target.path,
|
|
123
|
+
start_line=span[0],
|
|
124
|
+
end_line=span[1],
|
|
125
|
+
dependents=dependents,
|
|
126
|
+
same_name_definitions=twins,
|
|
127
|
+
notes=notes,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---- find_doc_drift ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class Finding:
|
|
136
|
+
severity: str
|
|
137
|
+
"""high: almost certainly wrong. medium: probably wrong. low: worth a look."""
|
|
138
|
+
kind: str
|
|
139
|
+
doc: str
|
|
140
|
+
"""Citation of the doc section, path:start-end."""
|
|
141
|
+
claim: str
|
|
142
|
+
detail: str
|
|
143
|
+
suggestion: str | None = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class DriftReport:
|
|
148
|
+
findings: list[Finding]
|
|
149
|
+
sections_checked: int
|
|
150
|
+
references_checked: int
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def find_doc_drift(
|
|
154
|
+
store: Store, root: Path, path_prefix: str | None = None, check_history: bool = True, limit: int = 50
|
|
155
|
+
) -> DriftReport:
|
|
156
|
+
"""Documentation claims that no longer match the code: missing files, symbols, settings and flags,
|
|
157
|
+
examples calling functions with parameters they don't take, and docs older than the code they describe."""
|
|
158
|
+
checker = _DriftChecker(store, root)
|
|
159
|
+
for section in store.doc_chunks(path_prefix):
|
|
160
|
+
if not _CHANGELOG.search(section.path):
|
|
161
|
+
checker.check(section)
|
|
162
|
+
if check_history and history.is_git_repo(root):
|
|
163
|
+
checker.check_staleness()
|
|
164
|
+
findings = sorted(checker.findings.values(), key=lambda f: (_SEVERITY_ORDER[f.severity], f.doc))
|
|
165
|
+
return DriftReport(
|
|
166
|
+
findings=findings[:limit], sections_checked=checker.sections, references_checked=checker.references
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _DriftChecker:
|
|
171
|
+
def __init__(self, store: Store, root: Path):
|
|
172
|
+
self.store = store
|
|
173
|
+
self.root = root
|
|
174
|
+
self.files = store.file_paths()
|
|
175
|
+
self.dirs = {"/".join(p.split("/")[:i]) for p in self.files for i in range(1, p.count("/") + 1)}
|
|
176
|
+
self.symbols = store.symbols()
|
|
177
|
+
self.short_names = {s.rsplit(".", 1)[-1] for s, _ in self.symbols}
|
|
178
|
+
self.full_names = {s for s, _ in self.symbols}
|
|
179
|
+
self.packages = {
|
|
180
|
+
(p.split("/")[1] if p.startswith("src/") else p.split("/")[0]).removesuffix(".py")
|
|
181
|
+
for p in self.files
|
|
182
|
+
if p.endswith(".py") and not p.startswith(("tests/", "docs/"))
|
|
183
|
+
}
|
|
184
|
+
self.findings: dict[tuple[str, str, str], Finding] = {}
|
|
185
|
+
self.sections = 0
|
|
186
|
+
self.references = 0
|
|
187
|
+
self._resolved: list[tuple[StoredChunk, str]] = []
|
|
188
|
+
self._mentions: dict[str, bool] = {}
|
|
189
|
+
|
|
190
|
+
def check(self, section: StoredChunk) -> None:
|
|
191
|
+
"""Markdown (`code`, ``` fences) and reStructuredText (``code``, :func: roles, code-block, doctest)."""
|
|
192
|
+
self.sections += 1
|
|
193
|
+
prose = section.text
|
|
194
|
+
for pattern, language_group, body_group in ((_FENCE, 2, 3), (_RST_BLOCK, 1, 2), (_DOCTEST, None, 1)):
|
|
195
|
+
for match in pattern.finditer(prose):
|
|
196
|
+
language = match.group(language_group).lower() if language_group else "python"
|
|
197
|
+
if language in ("", "python", "py", "pycon", "python3"):
|
|
198
|
+
self._check_code_block(section, match.group(body_group))
|
|
199
|
+
prose = prose.replace(match.group(0), "\n")
|
|
200
|
+
for match in _RST_ROLE.finditer(prose):
|
|
201
|
+
target = match.group(1)
|
|
202
|
+
target = target.split("<", 1)[1].rstrip(">") if "<" in target else target
|
|
203
|
+
self._check_symbol(section, target.strip().lstrip("~!.").removesuffix("()"), explicit=True)
|
|
204
|
+
prose = _RST_ROLE.sub(" ", prose)
|
|
205
|
+
for match in _DOUBLE.finditer(prose):
|
|
206
|
+
self._check_inline(section, match.group(1).strip())
|
|
207
|
+
for match in _INLINE.finditer(_DOUBLE.sub(" ", prose)):
|
|
208
|
+
self._check_inline(section, match.group(1).strip())
|
|
209
|
+
|
|
210
|
+
# -- inline references ---------------------------------------------------------------
|
|
211
|
+
def _check_inline(self, section: StoredChunk, ref: str) -> None:
|
|
212
|
+
if not ref or "://" in ref or ref.startswith(("$", "<", "@")):
|
|
213
|
+
return
|
|
214
|
+
if flag := _FLAG.match(ref):
|
|
215
|
+
self._expect_in_code(section, flag.group(1), "flag", "The command-line flag")
|
|
216
|
+
elif _ENV.match(ref):
|
|
217
|
+
self._expect_in_code(section, ref, "setting", "The setting")
|
|
218
|
+
elif (_PATH.match(ref) and "." in ref.rsplit("/", 1)[-1]) or ref.endswith("/"):
|
|
219
|
+
self._check_path(section, ref)
|
|
220
|
+
elif _SYMBOL.match(ref):
|
|
221
|
+
self._check_symbol(section, ref)
|
|
222
|
+
|
|
223
|
+
def _check_path(self, section: StoredChunk, ref: str) -> None:
|
|
224
|
+
self.references += 1
|
|
225
|
+
path = ref.lstrip("./").rstrip("/")
|
|
226
|
+
if path in self.files or path in self.dirs:
|
|
227
|
+
return
|
|
228
|
+
basename = path.rsplit("/", 1)[-1]
|
|
229
|
+
moved = sorted(p for p in self.files if p.rsplit("/", 1)[-1] == basename)
|
|
230
|
+
close = moved or difflib.get_close_matches(path, list(self.files), n=1, cutoff=0.8)
|
|
231
|
+
self._add(
|
|
232
|
+
Finding(
|
|
233
|
+
severity="high" if close else "medium",
|
|
234
|
+
kind="missing_file",
|
|
235
|
+
doc=section.citation,
|
|
236
|
+
claim=f"`{ref}`",
|
|
237
|
+
detail=f"No file or directory {path!r} exists in the repository.",
|
|
238
|
+
suggestion=f"Did it move to {close[0]!r}?" if close else None,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _check_symbol(self, section: StoredChunk, ref: str, explicit: bool = False) -> None:
|
|
243
|
+
name = ref.removesuffix("()")
|
|
244
|
+
short = name.rsplit(".", 1)[-1]
|
|
245
|
+
head = name.split(".", 1)[0]
|
|
246
|
+
if "." in name and head not in self.packages and head not in self.short_names:
|
|
247
|
+
return # another library's API (os.urandom, json.dumps): not this project's to check
|
|
248
|
+
if not explicit and not _looks_like_code(ref):
|
|
249
|
+
return
|
|
250
|
+
self.references += 1
|
|
251
|
+
if name in self.full_names or short in self.short_names:
|
|
252
|
+
self._resolved.append((section, short))
|
|
253
|
+
return
|
|
254
|
+
if self._mentioned(short):
|
|
255
|
+
return # used in code (a variable, attribute or third-party name); not ours to judge
|
|
256
|
+
close = difflib.get_close_matches(short, sorted(self.short_names), n=1, cutoff=0.75)
|
|
257
|
+
self._add(
|
|
258
|
+
Finding(
|
|
259
|
+
severity="high" if close else "medium",
|
|
260
|
+
kind="missing_symbol",
|
|
261
|
+
doc=section.citation,
|
|
262
|
+
claim=f"`{ref}`",
|
|
263
|
+
detail=f"{short!r} isn't defined or used anywhere in the code.",
|
|
264
|
+
suggestion=f"Was it renamed to {close[0]!r}?" if close else None,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def _expect_in_code(self, section: StoredChunk, term: str, kind: str, label: str) -> None:
|
|
269
|
+
self.references += 1
|
|
270
|
+
if not self._mentioned(term):
|
|
271
|
+
self._add(
|
|
272
|
+
Finding(
|
|
273
|
+
severity="medium",
|
|
274
|
+
kind=f"unknown_{kind}",
|
|
275
|
+
doc=section.citation,
|
|
276
|
+
claim=f"`{term}`",
|
|
277
|
+
detail=f"{label} {term} doesn't appear anywhere in the code.",
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# -- code examples -------------------------------------------------------------------
|
|
282
|
+
def _check_code_block(self, section: StoredChunk, body: str) -> None:
|
|
283
|
+
body = textwrap.dedent(body)
|
|
284
|
+
lines = [
|
|
285
|
+
re.sub(r"^(>>>|\.\.\.) ?", "", line)
|
|
286
|
+
for line in body.splitlines()
|
|
287
|
+
if not body.lstrip().startswith(">>>") or line.startswith((">>>", "..."))
|
|
288
|
+
]
|
|
289
|
+
try:
|
|
290
|
+
tree = ast.parse(textwrap.dedent("\n".join(lines)))
|
|
291
|
+
except (SyntaxError, ValueError):
|
|
292
|
+
return # shell commands, pseudo-code, or another language
|
|
293
|
+
for node in ast.walk(tree):
|
|
294
|
+
if isinstance(node, ast.Call) and node.keywords:
|
|
295
|
+
self._check_call(section, node)
|
|
296
|
+
elif isinstance(node, ast.ImportFrom) and node.module and self._is_project_module(node.module):
|
|
297
|
+
for alias in node.names:
|
|
298
|
+
if alias.name != "*":
|
|
299
|
+
self._check_import(section, node.module, alias.name)
|
|
300
|
+
|
|
301
|
+
def _check_call(self, section: StoredChunk, call: ast.Call) -> None:
|
|
302
|
+
func = call.func
|
|
303
|
+
name = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None
|
|
304
|
+
keywords = [k.arg for k in call.keywords if k.arg]
|
|
305
|
+
if not name or not keywords:
|
|
306
|
+
return
|
|
307
|
+
if name[:1].isupper(): # probably a class: its constructor is what the call reaches
|
|
308
|
+
signatures = [s for s in self.store.signatures("__init__") if s[0].endswith(f"{name}.__init__")]
|
|
309
|
+
if not signatures and name in self.short_names:
|
|
310
|
+
return # a class without its own __init__ (inherited or a dataclass): can't check
|
|
311
|
+
else:
|
|
312
|
+
signatures = self.store.signatures(name)
|
|
313
|
+
if not signatures:
|
|
314
|
+
return
|
|
315
|
+
self.references += 1
|
|
316
|
+
if any(any_kw or set(keywords) <= set(params) for _, _, params, any_kw in signatures):
|
|
317
|
+
return
|
|
318
|
+
symbol, path, params, _ = signatures[0]
|
|
319
|
+
unknown = [k for k in keywords if k not in params]
|
|
320
|
+
suggestion = "; ".join(
|
|
321
|
+
f"{k!r} → {m[0]!r}?" for k in unknown if (m := difflib.get_close_matches(k, params, n=1, cutoff=0.6))
|
|
322
|
+
)
|
|
323
|
+
self._add(
|
|
324
|
+
Finding(
|
|
325
|
+
severity="high",
|
|
326
|
+
kind="wrong_parameters",
|
|
327
|
+
doc=section.citation,
|
|
328
|
+
claim=f"{name}({', '.join(f'{k}=…' for k in keywords)})",
|
|
329
|
+
detail=f"{symbol} in {path} takes ({', '.join(params)}); the example passes "
|
|
330
|
+
f"{', '.join(unknown)}, which it doesn't accept.",
|
|
331
|
+
suggestion=suggestion or None,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def _check_import(self, section: StoredChunk, module: str, name: str) -> None:
|
|
336
|
+
self.references += 1
|
|
337
|
+
if name in self.short_names or self._mentioned(f"{name} =") or self._mentioned(f"import {name}"):
|
|
338
|
+
return
|
|
339
|
+
close = difflib.get_close_matches(name, sorted(self.short_names), n=1, cutoff=0.75)
|
|
340
|
+
self._add(
|
|
341
|
+
Finding(
|
|
342
|
+
severity="high",
|
|
343
|
+
kind="broken_import",
|
|
344
|
+
doc=section.citation,
|
|
345
|
+
claim=f"from {module} import {name}",
|
|
346
|
+
detail=f"{name!r} isn't defined in the project, so this example fails with ImportError.",
|
|
347
|
+
suggestion=f"Was it renamed to {close[0]!r}?" if close else None,
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def _is_project_module(self, module: str) -> bool:
|
|
352
|
+
top = module.split(".")[0]
|
|
353
|
+
return top in self.dirs or f"src/{top}" in self.dirs or f"{top}.py" in self.files
|
|
354
|
+
|
|
355
|
+
# -- staleness -----------------------------------------------------------------------
|
|
356
|
+
def check_staleness(self) -> None:
|
|
357
|
+
"""Doc sections describing functions that changed long after the docs were last edited.
|
|
358
|
+
One finding per section, listing what changed."""
|
|
359
|
+
doc_dates: dict[str, datetime | None] = {}
|
|
360
|
+
stale: dict[str, tuple[StoredChunk, list[str]]] = {}
|
|
361
|
+
seen: set[tuple[str, str]] = set()
|
|
362
|
+
checked = 0
|
|
363
|
+
for section, short in self._resolved:
|
|
364
|
+
if checked >= MAX_STALENESS_CHECKS or (section.citation, short) in seen:
|
|
365
|
+
continue
|
|
366
|
+
seen.add((section.citation, short))
|
|
367
|
+
matches = self.store.find_symbol(short)
|
|
368
|
+
if not matches:
|
|
369
|
+
continue
|
|
370
|
+
path, start, end, symbol = definition_span(matches)
|
|
371
|
+
checked += 1
|
|
372
|
+
if section.path not in doc_dates:
|
|
373
|
+
doc_dates[section.path] = self._last_change(section.path)
|
|
374
|
+
doc_date = doc_dates[section.path]
|
|
375
|
+
try:
|
|
376
|
+
changes = history.line_history(self.root, path, start, end, 1)
|
|
377
|
+
except SourceError:
|
|
378
|
+
continue
|
|
379
|
+
if not doc_date or not changes:
|
|
380
|
+
continue
|
|
381
|
+
commit = changes[0].commit
|
|
382
|
+
code_date = datetime.fromisoformat(commit.date)
|
|
383
|
+
if code_date - doc_date > STALE_AFTER:
|
|
384
|
+
entry = stale.setdefault(section.citation, (section, []))
|
|
385
|
+
entry[1].append(
|
|
386
|
+
f"{symbol} ({path}:{start}-{end}) changed {code_date.date()} in "
|
|
387
|
+
f"{commit.sha[:10]} ({commit.subject!r})"
|
|
388
|
+
)
|
|
389
|
+
for citation, (section, changed) in stale.items():
|
|
390
|
+
doc_date = doc_dates[section.path]
|
|
391
|
+
if doc_date is None:
|
|
392
|
+
continue
|
|
393
|
+
self._add(
|
|
394
|
+
Finding(
|
|
395
|
+
severity="low",
|
|
396
|
+
kind="docs_older_than_code",
|
|
397
|
+
doc=citation,
|
|
398
|
+
claim=", ".join(f"`{c.split(' ', 1)[0]}`" for c in changed),
|
|
399
|
+
detail=f"{section.path} was last edited on {doc_date.date()}, but since then: "
|
|
400
|
+
+ "; ".join(changed),
|
|
401
|
+
suggestion="Check this section still describes the current behaviour.",
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def _last_change(self, path: str) -> datetime | None:
|
|
406
|
+
try:
|
|
407
|
+
changes = history.file_history(self.root, path, 1)
|
|
408
|
+
except SourceError:
|
|
409
|
+
return None
|
|
410
|
+
return datetime.fromisoformat(changes[0].commit.date) if changes else None
|
|
411
|
+
|
|
412
|
+
# -- helpers -------------------------------------------------------------------------
|
|
413
|
+
def _mentioned(self, term: str) -> bool:
|
|
414
|
+
if term not in self._mentions:
|
|
415
|
+
self._mentions[term] = self.store.code_mentions(term)
|
|
416
|
+
return self._mentions[term]
|
|
417
|
+
|
|
418
|
+
def _add(self, finding: Finding) -> None:
|
|
419
|
+
self.findings.setdefault((finding.doc, finding.kind, finding.claim), finding)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _looks_like_code(ref: str) -> bool:
|
|
423
|
+
"""Skip plain words in backticks; keep things shaped like identifiers."""
|
|
424
|
+
name = ref.removesuffix("()")
|
|
425
|
+
if name.lower() in _COMMON or len(name) < 3:
|
|
426
|
+
return False
|
|
427
|
+
return ref.endswith("()") or "_" in name or "." in name or bool(re.search(r"[a-z][A-Z]", name))
|