fix(i18n): read refusals along a closed list of shapes, report the rest

The scan followed many shapes and called the rest unread, but each shape it learned opened a silent
branch: a parameter rebound in the body, a second visit of a followed helper, an `import app.x`, a name
bound twice or under an `if`, a spread or a store that replaces the code, a detail rebuilt from its
variable, a None reason. It now reads only what the app writes and reports everything else: a
branch the app never takes is gone (an `or`, a default, a base class's attribute, string and union
annotations, a bare module constant), a spread is clean only from an untouched `**kwargs` no call puts
a `code` in, and the detail's variable may only grow by `|=` or `["reason"]`. The real tree reads
exactly as before; every exit and every kept branch has a self-test that a mutant of it fails.

The detail test also fails a dict without a `code`, a JSONResponse sentence and a detail-less
HTTPException; the two that are no refusal (the setup gate's 503, an unmatched api path's 404) are
listed with why. The contract's assertion that compared the reason map with itself is gone.
This commit is contained in:
2026-10-31 18:45:02 +01:00
parent 5e8589bbf8
commit f8a8e905da
5 changed files with 985 additions and 373 deletions
+91
View File
@@ -51,3 +51,94 @@ def test_the_scan_sees_a_sentence_when_there_is_one():
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value
assert _is_text(sample.keywords[1].value)
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value)
# Answers that are no refusal of the app's, by the function they are sent from: the client shows a
# detail only when it is an object (`lib/api/core.ts`), so these read as its generic line, and no screen
# needs more from them.
NO_REFUSAL = {
("main.py", "setup_gate"): "the 503 before setup: the setup screen is what the user sees",
("main.py", "spa_fallback"): "a bare 404 for an api path no route matches: no screen calls one",
}
def _detail_offenders(source: str, path: str) -> list[str]:
"""Each detail the client could not say: a dict without a `code`, a sentence, or none at all.
`HTTPException(status, detail)` and `JSONResponse({"detail": …})`; a variable or a call is the
refusal scan's to read (`refusal_sites.py`, which follows each `code` dict)."""
tree = ast.parse(source)
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
out = []
for node in ast.walk(tree):
name = isinstance(node, ast.Call) and getattr(
node.func, "id", getattr(node.func, "attr", "")
)
if name == "HTTPException":
kw = {k.arg: k.value for k in node.keywords}
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
elif name == "JSONResponse":
content = {k.arg: k.value for k in node.keywords}.get(
"content", node.args[0] if node.args else None
)
if not isinstance(content, ast.Dict) or not any(
isinstance(k, ast.Constant) and k.value == "detail" for k in content.keys
):
continue # an answer, not a refusal
detail = next(
v
for k, v in zip(content.keys, content.values, strict=True)
if isinstance(k, ast.Constant) and k.value == "detail"
)
else:
continue
fn = parents.get(node)
while fn is not None and not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
fn = parents.get(fn)
if (path, getattr(fn, "name", None)) in NO_REFUSAL:
continue
keys = (
[k.value for k in detail.keys if isinstance(k, ast.Constant)]
if isinstance(detail, ast.Dict)
else [k.arg for k in detail.keywords]
if isinstance(detail, ast.Call) and getattr(detail.func, "id", "") == "dict"
else None
)
if detail is None or _is_text(detail) or (keys is not None and "code" not in keys):
out.append(f"{path}:{node.lineno}")
return out
def test_every_detail_is_one_the_client_can_say():
offenders = [
o
for p in sorted(APP.rglob("*.py"))
for o in _detail_offenders(p.read_text(encoding="utf-8"), p.relative_to(APP).as_posix())
]
assert offenders == [], (
"a refusal the client can only answer with its generic line — send `{code, reason}` "
f"(`refused(status, reason)`), or list it in NO_REFUSAL with why: {offenders}"
)
def test_the_detail_scan_sees_each_shape_the_client_cannot_say():
src = """
def f(x):
raise HTTPException(409, detail={"reason": "view_name_taken"})
def g(x):
raise HTTPException(409, dict(reason="taken"))
def h(x):
raise HTTPException(status_code=404)
def i(x):
return JSONResponse({"detail": "This instance isn't set up yet."}, status_code=503)
def j(x):
return JSONResponse(content={"detail": {"reason": "x"}}, status_code=409)
def ok(x):
raise HTTPException(409, {"code": "refused", "reason": "x"})
raise HTTPException(409, detail(x))
return JSONResponse({"detail": {"code": "too_large"}}, status_code=413)
return JSONResponse({"ok": True})
def setup_gate(x):
return JSONResponse({"detail": "listed"}, status_code=503)
"""
assert _detail_offenders(src, "main.py") == [f"main.py:{n}" for n in (3, 5, 7, 9, 11)]