|
| 1 | +"""A cross-reference role must name something the tree has, or send the reader nowhere. |
| 2 | +
|
| 3 | +The defect this holds |
| 4 | +--------------------- |
| 5 | +A docstring wrote that a legacy row "is moved by `migrate_rant_agent_field` before any reader |
| 6 | +sees it", and no such function existed anywhere -- `git grep` found exactly one hit for the name, |
| 7 | +the sentence that claimed it. The reader who wants to know how the migration works goes looking |
| 8 | +for it, finds nothing, and cannot tell a rename from a rewrite from a promise. The path opens and |
| 9 | +the module parses, so nothing complains. |
| 10 | +
|
| 11 | +Sibling guards, and why this is a third |
| 12 | +-------------------------------------- |
| 13 | +Two guards already read a pointer in prose: `tests/test_check_citation_resolves.py` (through |
| 14 | +`scripts/check-citation-resolves.py`) for a `tests/test_x.py::node` citation that must collect, |
| 15 | +and `tests/test_docstring_title_citations_resolve.py` for "see `Title` in the module docstring" |
| 16 | +that must land on a heading the module carries. This one holds a third subject with a third |
| 17 | +resolution rule: a **Sphinx cross-reference role** names a code symbol, and what decides it is |
| 18 | +whether that symbol exists. |
| 19 | +
|
| 20 | +What is measured, and what a role claims |
| 21 | +---------------------------------------- |
| 22 | +A role is a *declaration*: `func` says "callable", `class` says "type", `meth` says "method", |
| 23 | +`data` says "a value". Each is a claim the tree can answer, because the claim is that the cited |
| 24 | +name exists. Two spellings resolve differently: |
| 25 | +
|
| 26 | +* a **bare** name is the claim that the tree defines it -- any def, class, assignment or import |
| 27 | + anywhere under the scanned directories, or a builtin; |
| 28 | +* a **dotted** name is a claim made *through* something, and static resolution cannot follow the |
| 29 | + tail of `os.path.expandvars`. So the bound is the head: the first segment must be a name the |
| 30 | + citing file can see -- imported by it, bound somewhere in the tree, or a builtin. That is the |
| 31 | + decidable half of the claim ("do not cite through a module you never opened") and it is where |
| 32 | + the guard stops. |
| 33 | +
|
| 34 | +The `attr` role is deliberately outside the scan. It names a member of an object, not a binding |
| 35 | +a module holds, so the tree's `attr` references are `ctypes` structure fields -- three sites a |
| 36 | +rule reading them as module bindings would report and be wrong about every time. |
| 37 | +
|
| 38 | +What this does not measure |
| 39 | +-------------------------- |
| 40 | +Whether the cited name is the *right* one. A reference to a real function that has since stopped |
| 41 | +doing what the sentence says is invisible here; this guard answers only whether the pointer lands |
| 42 | +on a name the tree has. It also does not read a **bare backticked name in prose** -- the shape |
| 43 | +the defect actually took. A backtick in prose is not a declaration, and the tree holds hundreds |
| 44 | +that name files, config keys and protocol frames rather than code; a rule over them would report |
| 45 | +prose and be deleted. |
| 46 | +""" |
| 47 | + |
| 48 | +from __future__ import annotations |
| 49 | + |
| 50 | +import ast |
| 51 | +import builtins |
| 52 | +import re |
| 53 | +from pathlib import Path |
| 54 | + |
| 55 | +REPO_ROOT = Path(__file__).resolve().parent.parent |
| 56 | + |
| 57 | +#: The directories whose Python sources carry this convention. |
| 58 | +SCANNED = ("emrg", "scripts", "tests") |
| 59 | + |
| 60 | +#: Vendored trees inside a scanned directory, whose sources are not this project's. |
| 61 | +SKIPPED = ("node_modules", "site-packages") |
| 62 | + |
| 63 | +#: A cross-reference role whose subject is a symbol the tree can be asked about. `attr` is out |
| 64 | +#: for the reason the module docstring gives: it names a member, not a binding. |
| 65 | +ROLE = re.compile(r":(?P<role>func|class|meth|data):`~?(?P<name>[^`\n]+)`") |
| 66 | + |
| 67 | + |
| 68 | +def tree_roots(root: Path = REPO_ROOT) -> list[Path]: |
| 69 | + """The directories this convention covers, under one root.""" |
| 70 | + return [Path(root) / name for name in SCANNED] |
| 71 | + |
| 72 | + |
| 73 | +def sources(roots) -> list[Path]: |
| 74 | + """Every Python file under the given roots, in a stable order.""" |
| 75 | + found = [] |
| 76 | + for root in roots: |
| 77 | + root = Path(root) |
| 78 | + for path in root.rglob("*.py"): |
| 79 | + # Judged on the path relative to the root it was walked from: an absolute path's |
| 80 | + # parts let any ancestor named `node_modules` or `.venv` silence the walk, and the |
| 81 | + # reading then reports a clean nothing. |
| 82 | + relative = path.relative_to(root) |
| 83 | + if any(part in SKIPPED for part in relative.parts): |
| 84 | + continue |
| 85 | + if path.is_file(): |
| 86 | + found.append(path) |
| 87 | + return sorted(set(found)) |
| 88 | + |
| 89 | + |
| 90 | +def bound(path: Path) -> set[str]: |
| 91 | + """Every name one module binds: a def, a class, an assignment target, an import.""" |
| 92 | + try: |
| 93 | + tree = ast.parse(Path(path).read_text(encoding="utf-8")) |
| 94 | + except (OSError, UnicodeDecodeError, SyntaxError): |
| 95 | + return set() |
| 96 | + names: set[str] = set() |
| 97 | + for node in ast.walk(tree): |
| 98 | + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): |
| 99 | + names.add(node.name) |
| 100 | + elif isinstance(node, (ast.Assign, ast.AnnAssign)): |
| 101 | + targets = node.targets if isinstance(node, ast.Assign) else [node.target] |
| 102 | + for target in targets: |
| 103 | + for inner in ast.walk(target): |
| 104 | + if isinstance(inner, ast.Name): |
| 105 | + names.add(inner.id) |
| 106 | + elif isinstance(node, ast.Import): |
| 107 | + for alias in node.names: |
| 108 | + names.add((alias.asname or alias.name).split(".")[0]) |
| 109 | + elif isinstance(node, ast.ImportFrom): |
| 110 | + for alias in node.names: |
| 111 | + names.add(alias.asname or alias.name) |
| 112 | + return names |
| 113 | + |
| 114 | + |
| 115 | +def scan(roots) -> list[str]: |
| 116 | + """One finding per cross-reference that lands on no name the scanned tree has.""" |
| 117 | + paths = sources(roots) |
| 118 | + per_file = {path: bound(path) for path in paths} |
| 119 | + tree_names: set[str] = set() |
| 120 | + for names in per_file.values(): |
| 121 | + tree_names |= names |
| 122 | + |
| 123 | + findings = [] |
| 124 | + for path in paths: |
| 125 | + try: |
| 126 | + text = Path(path).read_text(encoding="utf-8") |
| 127 | + except (OSError, UnicodeDecodeError) as exc: |
| 128 | + findings.append(f"{path}: could not be read ({exc}); unmeasured, never a pass") |
| 129 | + continue |
| 130 | + visible = per_file[path] |
| 131 | + for match in ROLE.finditer(text): |
| 132 | + role = match.group("role") |
| 133 | + name = match.group("name").strip() |
| 134 | + if not name: |
| 135 | + continue |
| 136 | + line = text.count("\n", 0, match.start()) + 1 |
| 137 | + if "." in name: |
| 138 | + head = name.split(".")[0] |
| 139 | + if head in visible or head in tree_names or head in vars(builtins): |
| 140 | + continue |
| 141 | + findings.append( |
| 142 | + f"{path}:{line}: cites {name!r} as a {role}, and {head!r} is neither " |
| 143 | + f"imported in this file nor bound anywhere in the tree — the reader is " |
| 144 | + f"sent through a module nothing here has opened" |
| 145 | + ) |
| 146 | + continue |
| 147 | + if name in tree_names or name in vars(builtins): |
| 148 | + continue |
| 149 | + findings.append( |
| 150 | + f"{path}:{line}: cites {name!r} as a {role}, and no def, class, assignment or " |
| 151 | + f"import under {', '.join(str(r) for r in roots)} binds that name — the " |
| 152 | + f"reader follows the pointer to something that is not there" |
| 153 | + ) |
| 154 | + return findings |
| 155 | + |
| 156 | + |
| 157 | +# ── the tree ────────────────────────────────────────────────────────────────── |
| 158 | +# |
| 159 | +# Fixtures below assemble the role from parts on purpose. Written inline, each would be a |
| 160 | +# cross-reference site in *this* file, and the tree reading above would report this guard's own |
| 161 | +# fixtures back at it — a guard that cannot be run on the tree that holds it is no guard. |
| 162 | + |
| 163 | + |
| 164 | +def _ref(role: str, name: str) -> str: |
| 165 | + """A cross-reference role around a name, built rather than typed.""" |
| 166 | + return f":{role}:`{name}`" |
| 167 | + |
| 168 | + |
| 169 | +def test_every_cross_reference_in_the_tree_resolves(): |
| 170 | + """The subject is this tree, and the reading names every site that does not land.""" |
| 171 | + findings = scan(tree_roots()) |
| 172 | + assert not findings, ( |
| 173 | + "a cross-reference names something the tree does not have; the reader who follows it " |
| 174 | + "finds nothing, and nothing else in the suite asks:\n " + "\n ".join(findings) |
| 175 | + ) |
| 176 | + |
| 177 | + |
| 178 | +def test_a_name_the_tree_does_not_define_is_reported(tmp_path): |
| 179 | + """The defect's shape: a role pointing at a callable no module here defines.""" |
| 180 | + site = tmp_path / "citing.py" |
| 181 | + site.write_text( |
| 182 | + f'"""{_ref("func", "migrate_rant_agent_field")} moves the old key first."""\n\n\n' |
| 183 | + "def migrate_something_else():\n pass\n", |
| 184 | + encoding="utf-8", |
| 185 | + ) |
| 186 | + findings = scan([tmp_path]) |
| 187 | + assert findings, ( |
| 188 | + "a cross-reference to a name no module defines was read as resolved; this is the " |
| 189 | + "defect the guard exists for" |
| 190 | + ) |
| 191 | + assert "migrate_rant_agent_field" in findings[0], findings[0] |
| 192 | + |
| 193 | + |
| 194 | +def test_a_name_the_tree_does_define_resolves(tmp_path): |
| 195 | + """The convention's ordinary case, so the guard is not green only on the defect.""" |
| 196 | + site = tmp_path / "defined.py" |
| 197 | + site.write_text( |
| 198 | + f'"""Uses {_ref("func", "real_helper")} below."""\n\n\n' |
| 199 | + "def real_helper():\n pass\n", |
| 200 | + encoding="utf-8", |
| 201 | + ) |
| 202 | + assert scan([tmp_path]) == [], "a cross-reference to a def in the same file was reported" |
| 203 | + |
| 204 | + |
| 205 | +def test_a_name_bound_in_another_module_resolves(tmp_path): |
| 206 | + """A pointer to a sibling module's symbol is a fair pointer — the tree has it.""" |
| 207 | + (tmp_path / "owner.py").write_text("class ExtraRootVerdict:\n pass\n", encoding="utf-8") |
| 208 | + site = tmp_path / "citing.py" |
| 209 | + site.write_text( |
| 210 | + f'"""{_ref("class", "ExtraRootVerdict")} documents what the store holds."""\n', |
| 211 | + encoding="utf-8", |
| 212 | + ) |
| 213 | + assert scan([tmp_path]) == [], ( |
| 214 | + "a cross-reference to a class another module in the tree defines was reported; the " |
| 215 | + "tree answers that claim" |
| 216 | + ) |
| 217 | + |
| 218 | + |
| 219 | +def test_a_dotted_reference_through_an_import_resolves(tmp_path): |
| 220 | + """`os.path.expandvars` is undecidable past the head, and the head is the claim.""" |
| 221 | + site = tmp_path / "dotted.py" |
| 222 | + site.write_text( |
| 223 | + f'"""Resolved by {_ref("func", "os.path.expandvars")}."""\n\nimport os\n', |
| 224 | + encoding="utf-8", |
| 225 | + ) |
| 226 | + assert scan([tmp_path]) == [], ( |
| 227 | + "a dotted reference whose head the file imports was reported; static resolution " |
| 228 | + "cannot follow the tail and must not pretend to" |
| 229 | + ) |
| 230 | + |
| 231 | + |
| 232 | +def test_a_dotted_reference_through_a_module_never_opened_is_reported(tmp_path): |
| 233 | + """The decidable half: a head the file cannot see is a claim nothing supports.""" |
| 234 | + site = tmp_path / "unopened.py" |
| 235 | + site.write_text( |
| 236 | + f'"""Resolved by {_ref("func", "elsewhere.helper")}."""\n', |
| 237 | + encoding="utf-8", |
| 238 | + ) |
| 239 | + findings = scan([tmp_path]) |
| 240 | + assert findings, ( |
| 241 | + "a dotted reference whose head is neither imported nor bound anywhere was read as " |
| 242 | + "resolved" |
| 243 | + ) |
| 244 | + assert "elsewhere" in findings[0], findings[0] |
| 245 | + |
| 246 | + |
| 247 | +def test_a_builtin_resolves(tmp_path): |
| 248 | + """A role naming a builtin is a real symbol, so the guard must not report it.""" |
| 249 | + site = tmp_path / "builtin.py" |
| 250 | + site.write_text(f'"""Wraps {_ref("func", "len")}."""\n', encoding="utf-8") |
| 251 | + assert scan([tmp_path]) == [], "a cross-reference to a builtin was reported" |
| 252 | + |
| 253 | + |
| 254 | +def test_a_bare_backticked_name_in_prose_is_not_a_reference(tmp_path): |
| 255 | + """The lookalikes are the load-bearing half: a guard that reports prose gets deleted.""" |
| 256 | + site = tmp_path / "prose.py" |
| 257 | + site.write_text( |
| 258 | + '"""Rows are stored in `rants.jsonl` and `tasks.yml`, keyed by `agent`."""\n', |
| 259 | + encoding="utf-8", |
| 260 | + ) |
| 261 | + assert scan([tmp_path]) == [], ( |
| 262 | + "a bare backticked word in prose was read as a cross-reference; a backtick is not a " |
| 263 | + "declaration" |
| 264 | + ) |
| 265 | + |
| 266 | + |
| 267 | +def test_a_member_role_is_not_a_module_binding(tmp_path): |
| 268 | + """`attr` names a field of a structure, not a name the module binds.""" |
| 269 | + site = tmp_path / "fields.py" |
| 270 | + site.write_text( |
| 271 | + f'"""{_ref("attr", "SubAuthority")} is a field of the struct below."""\n\n\n' |
| 272 | + "class Struct:\n _fields_ = [(\"SubAuthority\", 1)]\n", |
| 273 | + encoding="utf-8", |
| 274 | + ) |
| 275 | + assert scan([tmp_path]) == [], ( |
| 276 | + "an `attr` reference to a structure field was reported as a missing module binding; " |
| 277 | + "that role makes no such claim" |
| 278 | + ) |
| 279 | + |
| 280 | + |
| 281 | +def test_a_reference_in_a_comment_is_still_a_reference(tmp_path): |
| 282 | + """A reader follows the pointer wherever it is written, the code block included.""" |
| 283 | + site = tmp_path / "comment.py" |
| 284 | + site.write_text( |
| 285 | + f"# The move is done by {_ref('func', 'absent_mover')} before any read.\nVALUE = 1\n", |
| 286 | + encoding="utf-8", |
| 287 | + ) |
| 288 | + findings = scan([tmp_path]) |
| 289 | + assert findings, ( |
| 290 | + "a cross-reference written in a comment was not read at all; the docstring-only reader " |
| 291 | + "misses the half of this class that lives outside docstrings" |
| 292 | + ) |
0 commit comments