Files
Jyotisha/tests/test_api_server_growth_contract.py
T
Jesse_ChenandClaude Opus 5 5582091851
Independent Staging Quality Gate / validate (push) Canceled after 2m18s
Independent Staging Quality Gate / publish (push) Canceled after 0s
refactor(api): 抽出 offline compute mixins,关掉 2/4 处 handler 伪造
spike 闸门(任务书 §5.2)结果为红,按 §6.3 退回 A 方案。

spike:115 个闭包方法整体移入 ConsultationComputeMixin 后,
tests/test_api_server_security.py 一字不改跑出 1 failed / 128 passed。
test_chart_async_job_executes_in_background 挂在
monkeypatch.setattr(jyotish_api_server, '_write_async_job_record', ...):
调用方法随 mixin 搬走后从新模块 globals 解析,补丁落在旧模块绑定上不生效。
已实证把同一 fake 打到 mixin 模块即恢复原行为,故为落点问题而非搬坏。
循环 import 不是障碍(移动集不引用 JyotishAPIHandler)。

退回 A 的实际交付:新建 scripts/offline_compute_mixins.py,
收 BadRequest、3 个模块级助手,以及 RequestParamMixin /
VedastroEvidenceMixin / SynastryMixin 共 7 个方法(300 行),
JyotishAPIHandler 通过继承保留全部方法,HTTP 侧零变化。
consultation_workflow_service.build_runtime_evidence_helpers 与
local_accuracy_report 改为直接实例化 mixin,不再伪造 handler。

11 个搬走的定义经 SHA-256 逐个比对与搬走前字节级相同;
jyotish_api_server.py 的 diff 为 11 行插入 / 378 行删除,无重排。

scripts 侧 __new__ 4 → 2(剩 2 处都在咨询工作流链上);
类方法 225 → 218;行数 11,291 → 10,924。
合同测试收紧基线并新增 scripts 侧专门断言与反向 import 断言;
两次反向验证(加回 __new__ / 加类方法)均正确变红。
新增 tests/test_offline_compute_mixins.py 覆盖此前零覆盖的 MCP 路径,
并加入 CORE_PYTEST_TARGETS。

tests/test_api_server_security.py 一字未改,129 passed。
快速门 pytest 段 798 passed / 1 skipped / 0 failed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-16 03:46:01 +00:00

141 lines
6.0 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
JyotishAPIHandler method count and JyotishAPIHandler.__new__ forgery sites.
Baselines re-measured 2026-09-16 after TASK-api-server-backdoor-close-20260916
moved seven compute-only methods into scripts/offline_compute_mixins.py and
closed two of the four production forgeries.
"""
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.
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).
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 218
# `JyotishAPIHandler.__new__` in scripts/ and tests/ `*.py`, excluding this file.
# Re-measured 2026-09-16 after TASK-api-server-backdoor-close-20260916:
# scripts/ production forgeries 4 -> 2, tests/ unchanged at 29.
JYOTISH_API_HANDLER_NEW_COUNT_BASELINE = 31
# Production forgeries only. The two survivors both sit on the consultation
# workflow chain (`consultation_workflow_service.execute_consultation_workflow`
# and `capture_report_blocked_repairs_golden`); closing them needs the 4,086-line
# mixin extraction that the 2026-09-16 spike showed breaks cross-module
# monkeypatching in tests/test_api_server_security.py. Target stays 0.
JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE = 2
# 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