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.
@@ -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))