Two rows of the canonical-patterns table had drifted, found while closing the roadmap audit:
- 63 mutation responses carried no `ok` — `{"unsubscribed": true}`, `{"status": "ok"}`,
`{"added": 3, …}` and so on. Each now leads with `"ok": true` (additive: every field a client
reads is still there).
- Twelve route helpers committed on their own. Six were just the end of their handler's work and now
flush: the playlist item add/remove and Watch later get-or-create, the Plex series/season bulk
mark, and the invite decision + account activation — which an approval used to land in separate
transactions. The other six commit on purpose (a token durable before its link is mailed, a
single-use token burned as its own unit, the Google callback's tail, a background registration
with its own session, the staged search ingest around YouTube calls), each listed with its reason.
`tests/test_route_conventions.py` holds both rules in code; CLAUDE.md's two rows point at it.
69 lines
3.0 KiB
Python
69 lines
3.0 KiB
Python
"""Two rows of CLAUDE.md's canonical-patterns table, held in code rather than prose.
|
|
|
|
A rule stated only in a comment drifts: this one had — 63 mutation responses without `ok`, and
|
|
twelve route helpers that committed on their own, among them an approval whose decision, activation
|
|
and token landed in three separate transactions. The debt burn of 2026-09 fixed the drift; these keep
|
|
it fixed.
|
|
"""
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
ROUTES = Path(__file__).parent.parent / "app" / "routes"
|
|
MUTATING = {"post", "put", "patch", "delete"}
|
|
|
|
# A route helper that commits where it stands, and why the commit has to be there rather than in
|
|
# the handler. Anything else flushes and leaves the commit to its handler.
|
|
COMMITTING_HELPERS = {
|
|
"auth.py:_issue_token": "the token must be durable before the link that carries it is mailed",
|
|
"auth.py:_consume_token": "burning a single-use token is its own unit: a later failure must "
|
|
"not hand the token back",
|
|
"auth.py:_complete_link": "the tail of the Google callback route, which answers with its result",
|
|
"auth.py:_register_account": "a background task with its own session, i.e. a job wrapper",
|
|
"search.py:_ingest_candidates": "stages around YouTube calls: the stubs must survive an enrich "
|
|
"that fails",
|
|
"search.py:_classify_shorts": "the last stage of that ingest, after its probes",
|
|
}
|
|
|
|
|
|
def _modules():
|
|
for path in sorted(ROUTES.rglob("*.py")):
|
|
yield path.relative_to(ROUTES).as_posix(), ast.parse(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _is_mutation_route(fn: ast.FunctionDef) -> bool:
|
|
return any(
|
|
isinstance(d, ast.Call) and getattr(d.func, "attr", "") in MUTATING
|
|
for d in fn.decorator_list
|
|
)
|
|
|
|
|
|
def test_every_mutation_answers_ok():
|
|
"""`{"ok": true, …}` (or `{"deleted": id}`) for every mutation that answers with a literal —
|
|
one field a client can read the same way everywhere. A serializer's return is exempt: it
|
|
describes a resource, not an acknowledgement."""
|
|
missing = []
|
|
for name, tree in _modules():
|
|
for fn in ast.walk(tree):
|
|
if not (isinstance(fn, ast.FunctionDef) and _is_mutation_route(fn)):
|
|
continue
|
|
for node in ast.walk(fn):
|
|
if isinstance(node, ast.Return) and isinstance(node.value, ast.Dict):
|
|
keys = {k.value for k in node.value.keys if isinstance(k, ast.Constant)}
|
|
if not keys & {"ok", "deleted"}:
|
|
missing.append(f"{name}:{node.lineno} {fn.name}")
|
|
assert missing == []
|
|
|
|
|
|
def test_route_helpers_flush_and_leave_the_commit_to_the_handler():
|
|
committing = set()
|
|
for name, tree in _modules():
|
|
for fn in ast.walk(tree):
|
|
if isinstance(fn, ast.FunctionDef) and fn.name.startswith("_"):
|
|
if any(
|
|
isinstance(n, ast.Call) and getattr(n.func, "attr", "") == "commit"
|
|
for n in ast.walk(fn)
|
|
):
|
|
committing.add(f"{name}:{fn.name}")
|
|
assert committing == set(COMMITTING_HELPERS)
|