test(freeze): 增长冻结改盯耦合,行数降为粗护栏
Independent Staging Quality Gate / validate (push) Failing after 6m21s
Independent Staging Quality Gate / publish (push) Skipped

Home() useState/useRef 与 JyotishAPIHandler 类方法/__new__ 不得增长。行数改为开工实测基线加余量。产品授权删除参数式 hook 零 React hook 规则。
This commit is contained in:
jesse-ux
2026-09-16 08:02:43 +08:00
parent 2b7b45657b
commit 3b17c1b242
5 changed files with 212 additions and 11 deletions
+4 -2
View File
@@ -92,8 +92,10 @@
## 6. 代码增长冻结 ## 6. 代码增长冻结
- `scripts/jyotish_api_server.py` must not grow. New endpoints and features go in dedicated modules under `scripts/` and are thinly registered from the main file. Do not add new handler bodies, workflows, or feature branches to this file. The cap is enforced by `tests/test_api_server_growth_contract.py`(冻结时行数 + 300 行 bugfix 余量) 行数是耦合的影子:盯行数时,`Home()` 的状态数和 `JyotishAPIHandler` 的类方法数仍在涨。主门换成耦合指标,行数只作粗护栏
- `frontend/src/app/page.tsx` 已从 4,766 行拆到 2,000 行以下,**不得再增长**:新逻辑进 `frontend/src/hooks/``frontend/src/lib/` 或组件;参数式 hook 内部保持 0 个 React hook 的既定模式。
- `scripts/jyotish_api_server.py` must not grow. New endpoints and features go in dedicated modules under `scripts/` and are thinly registered from the main file. Do not add new handler bodies, workflows, or feature branches to this file. The live gate is `tests/test_api_server_growth_contract.py`**类方法数不得增长、`JyotishAPIHandler.__new__` 伪造点不得增长**;行数是粗护栏(开工实测基线 + 300)。
- `frontend/src/app/page.tsx``Home()``useState` / `useRef` 数不得增长。新逻辑进 `frontend/src/hooks/``frontend/src/lib/` 或组件;抽出去的 hook 与子组件应当持有自己的状态,`page.tsx` 只做装配。行数是粗护栏(开工实测基线 + 150),由 `frontend/tests/home-shell-growth-contract.test.ts` 执行。
- 不得再手写第二个聊天输入框(一律 `ChatComposer`,自有草稿走 `value`)、第二套滚动跟随(一律 `useConversationScrollAnchor` + `JumpToLatestButton`)、第二套加载动画(揭幕后不得出现 spinner / 骨架 / "正在加载",流式生成中除外)。 - 不得再手写第二个聊天输入框(一律 `ChatComposer`,自有草稿走 `value`)、第二套滚动跟随(一律 `useConversationScrollAnchor` + `JumpToLatestButton`)、第二套加载动画(揭幕后不得出现 spinner / 骨架 / "正在加载",流式生成中除外)。
## 7. 前端红线 ## 7. 前端红线
@@ -0,0 +1,94 @@
# PROGRESS · 增长冻结改盯耦合(2026-09-16
工作树:`.worktrees/freeze-metric-change-20260915`
分支:`codex/freeze-metric-change-20260915`
任务书基线写 `6b3248bf`;开工时 `origin/staging` = **`51a65d92`**(含 BUG-732)。
本机 Windows。无 Docker。本单不占 BUG 号,不改 `CHANGELOG.md` / `BUG_HISTORY.md` / `docs/tasks/README.md`
`page.tsx``scripts/jyotish_api_server.py` **一行未动**(反向验证后已 `git checkout` 还原)。
## 开工实测基线(2026-09-16`51a65d92`
禁止抄任务书 1951 / 66 / 41 / 11334 / 225。下列数字全部本机现测。
| 组 | 指标 | 数法 | 实测 | 新门 |
| --- | --- | --- | ---: | --- |
| 1 | `frontend/src/app/page.tsx` 行数 | `Path.read_bytes().count(b"\n")`,与 `(source.match(/\n/g) ?? []).length` 相同 | **1951** | 粗护栏 ≤ 1951 + 150 = 2101 |
| 2a | `Home()` `useState` | `\buseState[<(]`(含 `useState<Type>(` | **66** | 主门,不得增长 |
| 2b | `Home()` `useRef` | `\buseRef[<(]` | **41** | 主门,不得增长 |
| 3 | `scripts/jyotish_api_server.py` 行数 | `Path.read_bytes().count(b"\n")` | **11291** | 粗护栏 ≤ 11291 + 300 = 11591 |
| 4a | 四空格 `def`(任务书同一种数法) | `^ (?:async )?def \w+` 扫全文件 | **225** | 主门,不得增长 |
| 4b | `JyotishAPIHandler.__new__` | `scripts/` + `tests/``*.py`,排除合同测试自身 | **33** | 主门,不得增长 |
说明:
- 任务书表格写 api 行数 11,334;本机 `51a65d92` 实测 **11291**(未抄)。
- 任务书表格写 `__new__` = 4:那是 **scripts/ 生产伪造点**`consultation_workflow_service.py` ×2、`capture_report_blocked_repairs_golden.py` ×1、`local_accuracy_report.py` ×1)。任务书要求扫 `scripts/``tests/`,实测合计 **33**scripts 4 + tests 29)。主门按扫描范围合计冻结,只许降不许升。失败信息会列出命中文件。
- 全文件四空格 `def` = 225,其中 `JyotishAPIHandler` 类体 222、类前嵌套 3。冻结用任务书指定的全文件数法。
- 只按 `useState(` 会计到 26,漏掉泛型调用;合同用 `\buseState[<(]`
`frontend/AGENTS.md` 只有 Next.js 版本提示,没有 §6 重复表述,本单未改。
## 做了什么
1. `tests/test_api_server_growth_contract.py`:主门改成类方法数 + `__new__` 计数;行数 rebaseline 为 11291 + 300。`must not grow` / `thinly registered` / `CORE_PYTEST_TARGETS` 三条原样仍绿。
2. 新建 `frontend/tests/home-shell-growth-contract.test.ts``Home()` `useState` / `useRef` 不得增长,行数粗护栏 1951 + 150。
3. `frontend/tests/chart-view-route.test.ts`:删除行数断言,保留 `doesNotMatch(pageSource, /chart-page|ChartPageView|\/api\/chart-view/)`,注明搬到 home-shell 合同。
4. `AGENTS.md` §6:主门改耦合指标;删掉「参数式 hook 内部保持 0 个 React hook」;第三条三个「不得再手写第二套」原样保留;仍含 `must not grow``thinly registered`
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `chart-view-route.test.ts` `page.tsx does not grow to host the chart page` | `(pageSource.match(/\n/g) ?? []).length <= 1951``doesNotMatch(...)` | 测试改名为 `page.tsx does not host the chart page`,只留 `doesNotMatch` | 行数冻结与星盘页无关,搬到专用合同 |
| `test_api_server_growth_contract.py` 行数 baseline | 110632026-09-02+ 300 | **11291**2026-09-16 实测)+ 300 | 任务书要求开工实测 rebaseline,不得抄旧数 |
## 反向验证
### 1. `page.tsx` 人为加一个 `useState`
`Home()` 里插入 `const [_growthContractProbe, _setGrowthContractProbe] = useState(false);`,然后:
```
npx tsx --test tests/home-shell-growth-contract.test.ts
not ok 1 - Home() useState count must not grow
error: 'Home() has 67 useState calls; cap is 66. ...'
# tests 3 / pass 2 / fail 1
```
`useRef` 与行数粗护栏仍绿。随后 `git checkout -- frontend/src/app/page.tsx`,文件回到 `51a65d92`
### 2. `jyotish_api_server.py` 人为加一个类方法
`JyotishAPIHandler` 里插入 `def _growth_contract_probe(self): return None`,然后:
```
pytest tests/test_api_server_growth_contract.py
FAILED test_jyotish_api_handler_method_count_must_not_grow
AssertionError: ... has 226 four-space def methods; cap is 225.
1 failed, 4 passed
```
行数粗护栏、`__new__`、AGENTS 措辞、`CORE_PYTEST_TARGETS` 仍绿——尺子动的是耦合门。随后 `git checkout -- scripts/jyotish_api_server.py`
## 测试
本 worktree 的 `.venv` 是已跟踪的指针文件(内容 `/workspace/Jyotisha/.venv`),Windows 上不能当目录用,未覆盖。合同测试用本机 `C:\Users\74082\anaconda3\python.exe`pytest 9.1.1)。
| 命令 | 结果 |
| --- | --- |
| `python -m pytest tests/test_api_server_growth_contract.py -v` | **5 passed**(含原 `must not grow` / `thinly registered` / `CORE_PYTEST_TARGETS` 三条) |
| `npx tsx --test tests/home-shell-growth-contract.test.ts tests/chart-view-route.test.ts` | **16 pass / 0 fail**home-shell 3 + chart-view 13chart-view 无行数断言) |
| `./node_modules/.bin/tsc --noEmit` | 0 错 |
| `npm run lint` | **0 error** / 119 warning(既有,未动) |
| `npx tsx --test tests/*.test.ts` | 3196 tests / 3112 pass / **69 fail** / 15 skip。本单 3 条 + `page.tsx does not host the chart page``ok`。失败为无 Docker、Windows `SKILL.md` symlink `EPERM`、bash 部署脚本,与近期同机清单同类 |
| `run_quality_gate.py --profile quick --skip-frontend-runtime` | **blocked**`interpretation_source_inventory_gate.py``ModuleNotFoundError: No module named 'mcp'`。停在 pytest 之前,不是本单合同红。未写成通过 |
同 SHA 近期进度 `PROGRESS-consultation-session-capacity-20260915.md``tests/*.test.ts` 为 3089 / 3001 / **73** fail。本机这次 3196 / 3112 / **69** failPowerShell glob 与计数差一截,fail 未增加)。本单新增 3 条全绿,不把 69 写成通过。
未跑 `next build`:无 UI 改动;质量门在 Python 预检就停了。
## 未做(属范围外)
- 真的搬 `Home()` 状态或拆 `JyotishAPIHandler`(后续两单)
- `__new__` 收到 0
-`origin HEAD:staging`
+3 -2
View File
@@ -296,8 +296,9 @@ test("the golden chart-view envelope stays inside the page contract", () => {
assert.match(COORDINATE_BOUNDARY.qizheng, /角宿/); assert.match(COORDINATE_BOUNDARY.qizheng, /角宿/);
}); });
test("page.tsx does not grow to host the chart page", () => { test("page.tsx does not host the chart page", () => {
assert.ok((pageSource.match(/\n/g) ?? []).length <= 1951); // Line-count freeze moved to tests/home-shell-growth-contract.test.ts
// (Home() useState / useRef caps + coarse line-count guardrail).
assert.doesNotMatch(pageSource, /chart-page|ChartPageView|\/api\/chart-view/); assert.doesNotMatch(pageSource, /chart-page|ChartPageView|\/api\/chart-view/);
}); });
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
// Measured 2026-09-16 on origin/staging @ 51a65d92.
// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1951.
// Hook counts use \buseState[<(] / \buseRef[<(] so `useState<Type>(` is not missed.
// Former line-count assertion lived in tests/chart-view-route.test.ts
// (`page.tsx does not grow to host the chart page`).
const PAGE_LINE_COUNT_BASELINE = 1951;
const PAGE_LINE_COUNT_CAP = PAGE_LINE_COUNT_BASELINE + 150;
const HOME_USE_STATE_CAP = 66;
const HOME_USE_REF_CAP = 41;
const USE_STATE_RE = /\buseState[<(]/g;
const USE_REF_RE = /\buseRef[<(]/g;
function homeSource(source: string): string {
const marker = "export default function Home(";
const start = source.indexOf(marker);
assert.notEqual(start, -1, "page.tsx must export default function Home(");
return source.slice(start);
}
function countMatches(source: string, pattern: RegExp): number {
return source.match(new RegExp(pattern.source, "g"))?.length ?? 0;
}
test("Home() useState count must not grow", () => {
const n = countMatches(homeSource(pageSource), USE_STATE_RE);
assert.ok(
n <= HOME_USE_STATE_CAP,
`Home() has ${n} useState calls; cap is ${HOME_USE_STATE_CAP}. Extracted hooks and child components should own their own state.`,
);
});
test("Home() useRef count must not grow", () => {
const n = countMatches(homeSource(pageSource), USE_REF_RE);
assert.ok(
n <= HOME_USE_REF_CAP,
`Home() has ${n} useRef calls; cap is ${HOME_USE_REF_CAP}. Do not rewrite state as refs to dodge the useState freeze.`,
);
});
test("page.tsx line count stays within the coarse guardrail", () => {
const n = (pageSource.match(/\n/g) ?? []).length;
assert.ok(
n <= PAGE_LINE_COUNT_CAP,
`page.tsx has ${n} lines; cap is ${PAGE_LINE_COUNT_CAP} (${PAGE_LINE_COUNT_BASELINE} baseline + 150).`,
);
});
+58 -7
View File
@@ -1,30 +1,81 @@
"""Freeze scripts/jyotish_api_server.py growth. """Freeze coupling in scripts/jyotish_api_server.py.
New endpoints and features must live in new modules and be thinly registered New endpoints and features must live in new modules and be thinly registered
from the main file. This cap is the live line count at freeze (11063 on from the main file. Line count is only a coarse guardrail; the live gates are
2026-09-02, via `wc -l`) plus 300 lines of bugfix slack. JyotishAPIHandler method count and JyotishAPIHandler.__new__ forgery sites.
""" """
from __future__ import annotations from __future__ import annotations
import re
from collections import Counter
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
API_SERVER = ROOT / "scripts" / "jyotish_api_server.py" API_SERVER = ROOT / "scripts" / "jyotish_api_server.py"
AGENTS = ROOT / "AGENTS.md" AGENTS = ROOT / "AGENTS.md"
# Live `wc -l scripts/jyotish_api_server.py` at freeze. New features must not # Live `wc -l scripts/jyotish_api_server.py` equivalent:
# consume this budget; open a module instead. # Path.read_bytes().count(b"\n"), measured 2026-09-16 on origin/staging @ 51a65d92.
JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 11063 # New features must not consume this budget; open a module instead.
JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 11291
JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300 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. Measured 2026-09-16 @ 51a65d92.
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 225
# `JyotishAPIHandler.__new__` in scripts/ and tests/ `*.py`, excluding this file.
# Measured 2026-09-16 @ 51a65d92: scripts/ production forgeries = 4, tests/ = 29.
JYOTISH_API_HANDLER_NEW_COUNT_BASELINE = 33
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() -> Counter[str]:
hits: Counter[str] = Counter()
skip = Path(__file__).resolve()
for folder in (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_jyotish_api_server_must_not_grow_beyond_bugfix_slack() -> None: def test_jyotish_api_server_must_not_grow_beyond_bugfix_slack() -> None:
line_count = API_SERVER.read_bytes().count(b"\n") line_count = API_SERVER.read_bytes().count(b"\n")
assert line_count <= JYOTISH_API_SERVER_LINE_COUNT_CAP, ( assert line_count <= JYOTISH_API_SERVER_LINE_COUNT_CAP, (
f"{API_SERVER.as_posix()} has {line_count} lines; cap is " f"{API_SERVER.as_posix()} has {line_count} lines; cap is "
f"{JYOTISH_API_SERVER_LINE_COUNT_CAP} ({JYOTISH_API_SERVER_LINE_COUNT_BASELINE} " f"{JYOTISH_API_SERVER_LINE_COUNT_CAP} ({JYOTISH_API_SERVER_LINE_COUNT_BASELINE} "
"baseline + 300 bugfix slack). New endpoints and features must be new " "baseline + 300 coarse guardrail). New endpoints and features must be new "
"modules, thinly registered from this file." "modules, thinly registered from this file."
) )