Files
Jyotisha/tests/test_api_server_growth_contract.py
T
jesse-uxandGrok (agent loop) a7fe7e9dd5 修复拆类金样换机器就对不齐的问题,避免测试删掉共用排盘缓存。
指纹不再包含仓库绝对路径;采集时关闭缓存而不是删除目录。基线与本分支 8 个哈希一致,定向 pytest 11 通过。

Co-Authored-By: Grok (agent loop)
2026-10-16 11:48:27 +08:00

233 lines
9.1 KiB
Python

"""Freeze coupling in scripts/jyotish_api_server.py.
New endpoints and features must live in new modules and be thinly registered
from the main file. Line count is only a coarse guardrail; the live gates are
handler method count and JyotishAPIHandler.__new__ forgery sites.
Baselines re-measured 2026-10-16 after TASK-api-server-service-extract-20261013
moved HTTP methods onto JyotishAPIHandler and left calculation on
JyotishComputeService. Production forgeries went from 2 to 0. The two classes
stay in this file so existing module-attribute patches still land.
"""
from __future__ import annotations
import ast
import re
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
API_SERVER = ROOT / "scripts" / "jyotish_api_server.py"
AGENTS = ROOT / "AGENTS.md"
# Live `wc -l scripts/jyotish_api_server.py` equivalent:
# Path.read_bytes().count(b"\n"), measured 2026-09-16 on origin/staging @ 51a65d92.
# New features must not consume this budget; open a module instead.
# 原值:10924
# 新值:10924
# 原因:行数基线按任务书保持不动。拆类只加了类声明,实测行数仍不超过这条基线。
JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 10924
JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300
# Whole-file indent match `^ (?:async )?def \w+`, same count as
# TASK-freeze-metric-change-20260915 §1. Re-measured 2026-09-16 after
# TASK-api-server-backdoor-close-20260916 lifted 7 compute-only methods into
# scripts/offline_compute_mixins.py (225 -> 218).
# 原值:218(删占位回退之前)
# 新值:217
# 原因:BUG-1341 删除 JyotishAPIHandler._fallback_dasha_periods,类方法少 1 个,没有新增方法
# 本单数值未变,仍是 217:只把 HTTP 方法换到子类,不增删方法。
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 217
# 原值:31
# 新值:5
# 原因:生产 2 处和只用计算方法的测试改为 JyotishComputeService()。留下的 5 处要碰 HTTP,本单不改。
JYOTISH_API_HANDLER_NEW_COUNT_BASELINE = 5
# Production forgeries only. Offline callers instantiate JyotishComputeService.
# 原值:2
# 新值:0
# 原因:consultation_workflow_service 与 capture_report_blocked_repairs_golden 改为正常实例化计算类。
JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE = 0
# Compute-only mixins lifted out of the handler must never depend back on the
# HTTP monolith, or the backdoor simply moves house.
OFFLINE_MIXINS = ROOT / "scripts" / "offline_compute_mixins.py"
HANDLER_METHOD_RE = re.compile(r"^ (?:async )?def \w+", re.MULTILINE)
NEW_MARKER = "JyotishAPIHandler.__new__"
def _handler_method_count(source: str) -> int:
return len(HANDLER_METHOD_RE.findall(source))
def _new_hits(*folders: Path) -> Counter[str]:
hits: Counter[str] = Counter()
skip = Path(__file__).resolve()
for folder in folders or (ROOT / "scripts", ROOT / "tests"):
for path in sorted(folder.rglob("*.py")):
if path.resolve() == skip:
continue
count = path.read_text(encoding="utf-8").count(NEW_MARKER)
if count:
hits[path.relative_to(ROOT).as_posix()] = count
return hits
def test_jyotish_api_handler_method_count_must_not_grow() -> None:
source = API_SERVER.read_text(encoding="utf-8")
count = _handler_method_count(source)
assert count <= JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE, (
f"{API_SERVER.as_posix()} has {count} four-space def methods; "
f"cap is {JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE}. Move behaviour "
"into a dedicated module; thinly registered from this file."
)
def test_jyotish_api_handler_new_count_must_not_grow() -> None:
hits = _new_hits()
total = sum(hits.values())
listed = ", ".join(f"{path}:{count}" for path, count in sorted(hits.items()))
assert total <= JYOTISH_API_HANDLER_NEW_COUNT_BASELINE, (
f"{NEW_MARKER} appears {total} times under scripts/ and tests/; "
f"cap is {JYOTISH_API_HANDLER_NEW_COUNT_BASELINE}. Hits: {listed}"
)
def test_scripts_handler_forgeries_must_not_grow() -> None:
hits = _new_hits(ROOT / "scripts")
total = sum(hits.values())
listed = ", ".join(f"{path}:{count}" for path, count in sorted(hits.items()))
assert total <= JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE, (
f"{NEW_MARKER} appears {total} times under scripts/; cap is "
f"{JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE} and the target is 0. "
f"Offline callers must instantiate a compute mixin instead. Hits: {listed}"
)
def test_offline_mixins_must_not_import_the_http_monolith() -> None:
tree = ast.parse(OFFLINE_MIXINS.read_text(encoding="utf-8"))
imported: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
imported.append(node.module or "")
offenders = [name for name in imported if "jyotish_api_server" in name]
assert not offenders, (
f"{OFFLINE_MIXINS.as_posix()} imports {offenders}; the compute mixins must "
"not depend on the HTTP monolith or the __new__ backdoor just moves house."
)
def test_jyotish_api_server_must_not_grow_beyond_bugfix_slack() -> None:
line_count = API_SERVER.read_bytes().count(b"\n")
assert line_count <= JYOTISH_API_SERVER_LINE_COUNT_CAP, (
f"{API_SERVER.as_posix()} has {line_count} lines; cap is "
f"{JYOTISH_API_SERVER_LINE_COUNT_CAP} ({JYOTISH_API_SERVER_LINE_COUNT_BASELINE} "
"baseline + 300 coarse guardrail). New endpoints and features must be new "
"modules, thinly registered from this file."
)
def test_agents_forbids_growing_jyotish_api_server() -> None:
agents = AGENTS.read_text(encoding="utf-8")
assert "scripts/jyotish_api_server.py" in agents
assert "must not grow" in agents
assert "thinly registered" in agents
def test_quality_gate_runs_api_server_growth_contract() -> None:
from scripts.run_quality_gate import CORE_PYTEST_TARGETS
assert "tests/test_api_server_growth_contract.py" in CORE_PYTEST_TARGETS
quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8")
assert '"tests/test_api_server_growth_contract.py"' in quality_gate
_HTTP_ATTRS = frozenset({
"headers",
"wfile",
"rfile",
"client_address",
"send_response",
"send_header",
"end_headers",
"path",
"command",
})
_HTTP_METHOD_NAMES = frozenset({
"_json",
"_html",
"_send_cors_headers",
"_read_json_body",
"_enforce_request_security",
"_job_access_token",
"do_GET",
"do_POST",
"do_OPTIONS",
"_error_json",
"_get_chart_job",
"_get_high_rigor_job",
"_require_dynamic_rectification_token",
"_compute_dynamic_rectification_score",
"_compute_dynamic_rectification_opportunities",
})
def test_compute_service_is_not_an_http_handler() -> None:
"""JyotishComputeService can be constructed. HTTP stays on the subclass."""
from http.server import BaseHTTPRequestHandler
from scripts.jyotish_api_server import JyotishAPIHandler, JyotishComputeService
assert not issubclass(JyotishComputeService, BaseHTTPRequestHandler)
assert issubclass(JyotishAPIHandler, JyotishComputeService)
assert issubclass(JyotishAPIHandler, BaseHTTPRequestHandler)
mro_names = [item.__name__ for item in JyotishAPIHandler.__mro__]
assert mro_names.index("BaseHTTPRequestHandler") < mro_names.index("JyotishComputeService")
assert JyotishAPIHandler.server_version == "JyotishAPI/6.9.14"
assert "server_version" not in JyotishComputeService.__dict__
for name in _HTTP_METHOD_NAMES:
assert name in JyotishAPIHandler.__dict__, name
assert name not in JyotishComputeService.__dict__, name
def _http_touches(fn: ast.AST) -> list[str]:
hits: list[str] = []
for node in ast.walk(fn):
if (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "self"
and node.attr in _HTTP_ATTRS | _HTTP_METHOD_NAMES
):
hits.append(f"{node.lineno}:{node.attr}")
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "getattr"
and len(node.args) >= 2
and isinstance(node.args[0], ast.Name)
and node.args[0].id == "self"
and isinstance(node.args[1], ast.Constant)
and node.args[1].value in _HTTP_ATTRS | _HTTP_METHOD_NAMES
):
hits.append(f"{node.lineno}:getattr:{node.args[1].value}")
return hits
def test_compute_service_methods_do_not_touch_http() -> None:
tree = ast.parse(API_SERVER.read_text(encoding="utf-8"))
compute = next(
node for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "JyotishComputeService"
)
offenders: list[str] = []
for node in compute.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
offenders.extend(f"{node.name}:{hit}" for hit in _http_touches(node))
assert offenders == []