"""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). # 原值:217 # 新值:217 # 原因:BUG-1341 已把基线降到 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 == []