mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 07:37:56 +09:00
Backend: hand-shaped {'error': ...} bodies in app/api get an AST census
and ceiling (1159) — milestone B's migrate-when-touched policy made
enforceable. Frontend: check-frontend-boundaries.mjs gains adoption
ceilings for raw api.* calls in pages (460, E1), per-page toast.error
extraction (264, E1), form-group blocks (343, F2), and unencoded query
interpolations in services/api (97, C4-interim) — totals that may only go
down, with a slack guard so a ceiling cannot drift above reality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
126 lines
4.1 KiB
Python
126 lines
4.1 KiB
Python
"""Count hand-shaped API error bodies (plan 76, B).
|
|
|
|
Milestone B's door is ``app/exceptions.py`` + the global handler: a route (or
|
|
the service under it) raises a typed error and the handler owns the JSON body,
|
|
the status code, and the ``X-Request-ID`` correlation. A hand-shaped
|
|
|
|
return jsonify({'error': 'App not found'}), 404
|
|
|
|
answers with none of that — no ``code``, no ``request_id`` — and every one is
|
|
a place where the error contract can drift (and has: the same failure returns
|
|
400, 404, or 409 depending on which file answers it).
|
|
|
|
This population is large and its conversion is "migrate when touched"
|
|
(invariant 9 — no repo-wide rewrite). The census makes that policy
|
|
enforceable: the count may only go down. New endpoints raise typed errors.
|
|
|
|
A site is counted when a ``return`` in ``app/api`` yields an error-shaped
|
|
mapping — a dict literal whose keys include ``'error'`` — either directly or
|
|
through ``jsonify``. The door's own products are not counted: raising a typed
|
|
error, or answering through ``app.error_reporting``.
|
|
|
|
Usage (from ``backend/``)::
|
|
|
|
python tests/error_shape_census.py # print the count + report
|
|
python tests/error_shape_census.py --update # write the count as the ceiling
|
|
"""
|
|
|
|
import ast
|
|
import os
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
BACKEND = os.path.dirname(HERE)
|
|
API_DIR = os.path.join(BACKEND, 'app', 'api')
|
|
CEILING_FILE = os.path.join(HERE, 'ERROR_SHAPE_CEILING')
|
|
|
|
SKIP_DIRS = {'__pycache__'}
|
|
|
|
|
|
def _rel(path):
|
|
return os.path.relpath(path, BACKEND).replace(os.sep, '/')
|
|
|
|
|
|
def _is_error_dict(node):
|
|
return (isinstance(node, ast.Dict)
|
|
and any(isinstance(k, ast.Constant) and k.value == 'error'
|
|
for k in node.keys))
|
|
|
|
|
|
def _error_shaped(value):
|
|
"""The returned expression carries a hand-built {'error': ...} body."""
|
|
if _is_error_dict(value):
|
|
return True
|
|
if (isinstance(value, ast.Call) and isinstance(value.func, ast.Name)
|
|
and value.func.id == 'jsonify' and value.args
|
|
and _is_error_dict(value.args[0])):
|
|
return True
|
|
if isinstance(value, ast.Tuple) and value.elts:
|
|
return _error_shaped(value.elts[0])
|
|
return False
|
|
|
|
|
|
def count_file(path):
|
|
"""Return [(lineno, snippet)] of hand-shaped error returns in one file."""
|
|
try:
|
|
with open(path, encoding='utf-8') as f:
|
|
source = f.read()
|
|
tree = ast.parse(source)
|
|
except (SyntaxError, UnicodeDecodeError):
|
|
return []
|
|
lines = source.split('\n')
|
|
found = []
|
|
for node in ast.walk(tree):
|
|
if not (isinstance(node, ast.Return) and node.value is not None):
|
|
continue
|
|
if _error_shaped(node.value):
|
|
snippet = lines[node.lineno - 1].strip() if node.lineno <= len(lines) else ''
|
|
found.append((node.lineno, snippet[:100]))
|
|
return sorted(found)
|
|
|
|
|
|
def census():
|
|
found = {}
|
|
for dirpath, dirnames, filenames in os.walk(API_DIR):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
for name in sorted(filenames):
|
|
if not name.endswith('.py'):
|
|
continue
|
|
path = os.path.join(dirpath, name)
|
|
hits = count_file(path)
|
|
if hits:
|
|
found[_rel(path)] = hits
|
|
return found
|
|
|
|
|
|
def total(found=None):
|
|
if found is None:
|
|
found = census()
|
|
return sum(len(v) for v in found.values())
|
|
|
|
|
|
def read_ceiling():
|
|
with open(CEILING_FILE, encoding='utf-8') as f:
|
|
return int(f.read().strip())
|
|
|
|
|
|
def _write_ceiling(value):
|
|
with open(CEILING_FILE, 'w', encoding='utf-8') as f:
|
|
f.write('%d\n' % value)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
found = census()
|
|
count = total(found)
|
|
for rel in sorted(found, key=lambda p: (-len(found[p]), p))[:15]:
|
|
print('%-40s %d' % (rel, len(found[rel])))
|
|
print('\ntotal: %d' % count)
|
|
if '--update' in sys.argv:
|
|
_write_ceiling(count)
|
|
print('ceiling updated to %d' % count)
|
|
else:
|
|
try:
|
|
print('ceiling: %d' % read_ceiling())
|
|
except FileNotFoundError:
|
|
print('no ceiling file yet; run with --update')
|