Skip to content

Commit 22e0111

Browse files
argszeroEMRG Evolution
andauthored
emrg: a cross-reference names a symbol the tree has (#2539) (#2540)
Co-authored-by: EMRG Evolution <emrg@argszero.dev>
1 parent 450c3f0 commit 22e0111

2 files changed

Lines changed: 293 additions & 1 deletion

File tree

‎emrg/server/scheduler.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,7 @@ def read_task_run_records(path: Path) -> list[dict]:
690690
**One derivation of the durable task-run store**, shared by its readers: a handler
691691
restores its own file at init (:meth:`TaskHandler._load_task_runs`) and the daemon
692692
reports the instance's record from every file in the store
693-
(:meth:`Daemon._evolution_count` and the ``evolution_summary`` frame).
693+
(:meth:`EmrgServer._evolution_count` and the ``evolution_summary`` frame).
694694
695695
Those two daemon readings used to walk ``<logs>/evolution-*.json`` instead — a
696696
directory whose writer was removed with the store it belonged to. Measured on this
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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

Comments
 (0)