Skip to content

Commit e901cc9

Browse files
committed
test(astra): validate decision anchors and universe pins
1 parent dae9e1b commit e901cc9

2 files changed

Lines changed: 334 additions & 0 deletions

File tree

‎tests/helpers/astra_record.py‎

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
"""Reusable parsing and resolution helpers for ShapePipe's ASTRA record."""
2+
3+
import ast
4+
import configparser
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
import re
8+
9+
import yaml
10+
11+
12+
@dataclass(frozen=True)
13+
class Anchor:
14+
"""An anchor sentence found in a YAML value."""
15+
16+
location: str
17+
references: tuple[str, ...]
18+
error: str | None = None
19+
20+
21+
def load_yaml(path):
22+
"""Load YAML with PyYAML's safe loader."""
23+
24+
return yaml.safe_load(Path(path).read_text(encoding="utf-8"))
25+
26+
27+
def _walk(value, location=""):
28+
if isinstance(value, dict):
29+
for key, child in value.items():
30+
path = f"{location}.{key}" if location else str(key)
31+
yield from _walk(child, path)
32+
elif isinstance(value, list):
33+
for index, child in enumerate(value):
34+
yield from _walk(child, f"{location}[{index}]")
35+
else:
36+
yield location, value
37+
38+
39+
def _rationales(document):
40+
for location, value in _walk(document):
41+
if location.endswith(".rationale"):
42+
yield location, value
43+
44+
45+
def extract_anchors(document):
46+
"""Parse all ``Anchor:`` sentences and check every rationale has one."""
47+
48+
anchors = []
49+
for location, value in _walk(document):
50+
if not isinstance(value, str) or "Anchor:" not in value:
51+
continue
52+
tail = value.split("Anchor:", 1)[1].strip()
53+
error = None
54+
refs = ()
55+
if value.count("Anchor:") != 1:
56+
count = value.count("Anchor:")
57+
error = f"expected one Anchor: marker, found {count}"
58+
elif not tail.endswith("."):
59+
error = "anchor sentence must end with a period"
60+
else:
61+
refs = tuple(part.strip() for part in tail[:-1].split(";"))
62+
if not refs or any(not ref for ref in refs):
63+
error = "anchor sentence contains an empty ref"
64+
anchors.append(Anchor(location, refs, error))
65+
66+
for location, value in _rationales(document):
67+
if (
68+
not isinstance(value, str)
69+
or value.count("Anchor:") != 1
70+
or not value.rstrip().endswith(".")
71+
):
72+
anchors.append(
73+
Anchor(
74+
location,
75+
(),
76+
"rationale must end with exactly one Anchor: sentence",
77+
)
78+
)
79+
return anchors
80+
81+
82+
def _parse_reference(reference):
83+
if "::" in reference:
84+
path, symbol = reference.split("::", 1)
85+
return "code", path, symbol
86+
if "#" in reference:
87+
path, key = reference.split("#", 1)
88+
return "config", path, key
89+
return "path", reference, ""
90+
91+
92+
def resolve_anchor(root, reference):
93+
"""Return ``None`` if a reference resolves, otherwise a diagnostic."""
94+
95+
kind, relative, selector = _parse_reference(reference)
96+
path = Path(relative)
97+
if path.is_absolute() or ".." in path.parts:
98+
return "path must be relative to the repository root"
99+
target = Path(root) / path
100+
if not target.exists():
101+
return "path does not exist"
102+
if kind == "path":
103+
return None
104+
if not target.is_file():
105+
return "code/config refs must name a file"
106+
107+
try:
108+
text = target.read_text(encoding="utf-8")
109+
except (OSError, UnicodeError) as error:
110+
return f"cannot read file: {error}"
111+
112+
if kind == "code":
113+
if target.suffix != ".py":
114+
return "code-symbol refs must name a .py file"
115+
try:
116+
tree = ast.parse(text, filename=str(target))
117+
except SyntaxError as error:
118+
return f"cannot parse Python file: {error}"
119+
if not _has_symbol(tree, selector):
120+
return f"no def/class/assignment target named {selector!r}"
121+
return None
122+
123+
suffix = target.suffix.lower()
124+
if suffix == ".ini":
125+
return _ini_key(text, selector)
126+
if suffix == ".setools":
127+
return _setools_key(text, selector)
128+
if suffix in {".sex", ".psfex", ".ww", ".param", ".conf"}:
129+
key = selector.rsplit(".", 1)[-1]
130+
pattern = re.compile(rf"^\s*(?:#\s*)?{re.escape(key)}(?=$|\s|=|\()")
131+
if any(pattern.search(line) for line in text.splitlines()):
132+
return None
133+
return f"no line starts with key {key!r} (commented keys are allowed)"
134+
return f"unsupported config-key file type {suffix or '(no extension)'}"
135+
136+
137+
def _ini_key(text, selector):
138+
if "." not in selector:
139+
return "INI config ref needs SECTION.KEY"
140+
section, key = selector.rsplit(".", 1)
141+
parser = configparser.ConfigParser(
142+
interpolation=None, strict=False, allow_no_value=True
143+
)
144+
try:
145+
parser.read_string(text)
146+
except configparser.Error as error:
147+
return f"cannot parse INI file: {error}"
148+
if not parser.has_section(section):
149+
return f"INI section {section!r} is missing"
150+
if not parser.has_option(section, key):
151+
return f"INI key {key!r} is missing from section {section!r}"
152+
return None
153+
154+
155+
def _setools_key(text, selector):
156+
if "." not in selector:
157+
return "SETools config ref needs SECTION.KEY"
158+
section, key = selector.rsplit(".", 1)
159+
pattern = re.compile(rf"^\s*(?:#\s*)?{re.escape(key)}(?=$|\s|=|<|>)")
160+
active = False
161+
for line in text.splitlines():
162+
stripped = line.strip()
163+
if stripped.startswith("[") and stripped.endswith("]"):
164+
active = stripped[1:-1].strip() == section
165+
elif active and pattern.search(line):
166+
return None
167+
return f"SETools key {key!r} is missing from section {section!r}"
168+
169+
170+
def _target_names(target):
171+
if isinstance(target, ast.Name):
172+
return [target.id]
173+
if isinstance(target, ast.Attribute):
174+
return [target.attr]
175+
if isinstance(target, (ast.Tuple, ast.List)):
176+
return [name for item in target.elts for name in _target_names(item)]
177+
if isinstance(target, ast.Starred):
178+
return _target_names(target.value)
179+
return []
180+
181+
182+
def _bindings(scope):
183+
"""Collect declarations and assignment targets in one lexical scope."""
184+
185+
result = {}
186+
187+
def visit(node):
188+
if isinstance(
189+
node,
190+
(ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef),
191+
):
192+
result[node.name] = node
193+
return
194+
if isinstance(node, ast.Lambda):
195+
return
196+
if isinstance(node, ast.Assign):
197+
targets = node.targets
198+
elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.NamedExpr)):
199+
targets = [node.target]
200+
elif isinstance(node, (ast.For, ast.AsyncFor)):
201+
targets = [node.target]
202+
elif isinstance(node, (ast.With, ast.AsyncWith)):
203+
targets = [item.optional_vars for item in node.items]
204+
else:
205+
targets = []
206+
for target in targets:
207+
if target is not None:
208+
result.update(dict.fromkeys(_target_names(target), node))
209+
if isinstance(node, ast.ExceptHandler) and node.name:
210+
result[node.name] = node
211+
for child in ast.iter_child_nodes(node):
212+
visit(child)
213+
214+
for statement in scope.body:
215+
visit(statement)
216+
return result
217+
218+
219+
def _has_symbol(tree, symbol):
220+
scope = tree
221+
parts = symbol.split(".")
222+
for index, part in enumerate(parts):
223+
declaration = _bindings(scope).get(part)
224+
if declaration is None:
225+
return False
226+
if index == len(parts) - 1:
227+
return True
228+
if not isinstance(
229+
declaration,
230+
(ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef),
231+
):
232+
return False
233+
scope = declaration
234+
return False
235+
236+
237+
def universe_errors(record, universe):
238+
"""Check scoped decision IDs and options against the ASTRA record."""
239+
240+
record_decisions = _decisions(record)
241+
pinned = _decisions(universe)
242+
errors = []
243+
for location in sorted(pinned.keys() - record_decisions.keys()):
244+
errors.append(
245+
f"{location}: universe decision is absent from astra.yaml"
246+
)
247+
for location in sorted(record_decisions.keys() - pinned.keys()):
248+
errors.append(
249+
f"{location}: astra.yaml decision is not pinned in the universe"
250+
)
251+
for location in sorted(record_decisions.keys() & pinned.keys()):
252+
definition = record_decisions[location]
253+
options = (
254+
definition.get("options", {})
255+
if isinstance(definition, dict)
256+
else {}
257+
)
258+
if not isinstance(options, dict) or pinned[location] not in options:
259+
errors.append(
260+
f"{location}: pinned option {pinned[location]!r} is not in "
261+
"ASTRA options"
262+
)
263+
return errors
264+
265+
266+
def _decisions(document, location=""):
267+
scope = document if isinstance(document, dict) else {}
268+
result = {}
269+
for decision_id, definition in (scope.get("decisions") or {}).items():
270+
key = (
271+
f"{location}.decisions.{decision_id}"
272+
if location
273+
else f"decisions.{decision_id}"
274+
)
275+
result[key] = definition
276+
for analysis_id, analysis in (scope.get("analyses") or {}).items():
277+
child = (
278+
f"{location}.analyses.{analysis_id}"
279+
if location
280+
else f"analyses.{analysis_id}"
281+
)
282+
if isinstance(analysis, dict):
283+
result.update(_decisions(analysis, child))
284+
return result

‎tests/unit/test_astra_anchors.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Keep ASTRA decision anchors and the committed universe resolvable."""
2+
3+
from pathlib import Path
4+
5+
from tests.helpers.astra_record import (
6+
extract_anchors,
7+
load_yaml,
8+
resolve_anchor,
9+
universe_errors,
10+
)
11+
12+
13+
REPO_ROOT = Path(__file__).resolve().parents[2]
14+
15+
16+
def test_every_astra_anchor_resolves():
17+
record = load_yaml(REPO_ROOT / "astra.yaml")
18+
anchors = extract_anchors(record)
19+
errors = []
20+
21+
assert anchors, "astra.yaml contains no Anchor: sentences"
22+
for anchor in anchors:
23+
if anchor.error:
24+
errors.append(f"{anchor.location}: {anchor.error}")
25+
continue
26+
for reference in anchor.references:
27+
problem = resolve_anchor(REPO_ROOT, reference)
28+
if problem:
29+
errors.append(
30+
f"{anchor.location}: {reference}: {problem}"
31+
)
32+
33+
message = (
34+
"Unresolved ASTRA anchors or rationales:\n - "
35+
+ "\n - ".join(errors)
36+
)
37+
assert not errors, message
38+
39+
40+
def test_committed_universe_matches_astra_decisions():
41+
record = load_yaml(REPO_ROOT / "astra.yaml")
42+
universe = load_yaml(REPO_ROOT / "universes" / "committed.yaml")
43+
44+
errors = universe_errors(record, universe)
45+
46+
message = (
47+
"ASTRA / committed universe mismatch:\n - "
48+
+ "\n - ".join(errors)
49+
)
50+
assert not errors, message

0 commit comments

Comments
 (0)