Compare commits

...
Author SHA1 Message Date
jesse-ux f8e607c29a refactor(home): 校正面 15 个状态从 Home 下沉
Independent Staging Quality Gate / validate (push) Failing after 6m43s
Independent Staging Quality Gate / publish (push) Skipped
Home() useState 66->52, useRef 41->39. Shell-facing fields merge into one object;
subtree state lives in useRectificationSurface. Chat props 20->7. Growth contract
rebaselined. Zero behavior/copy change.
2026-09-16 08:33:48 +08:00
jesse-ux 3b17c1b242 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 规则。
2026-09-16 08:02:43 +08:00
14 changed files with 653 additions and 126 deletions
+4 -2
View File
@@ -92,8 +92,10 @@
## 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 余量)
- `frontend/src/app/page.tsx` 已从 4,766 行拆到 2,000 行以下,**不得再增长**:新逻辑进 `frontend/src/hooks/``frontend/src/lib/` 或组件;参数式 hook 内部保持 0 个 React hook 的既定模式。
行数是耦合的影子:盯行数时,`Home()` 的状态数和 `JyotishAPIHandler` 的类方法数仍在涨。主门换成耦合指标,行数只作粗护栏
- `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 / 骨架 / "正在加载",流式生成中除外)。
## 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`
@@ -0,0 +1,127 @@
# PROGRESS · 把校正面的 15 个状态从首页搬下去(2026-09-16)
工作树:`.worktrees/home-state-lowering-20260915`
分支:`codex/home-state-lowering-20260915`
任务书基线写 `6b3248bf`;开工时 `origin/staging` = **`3b17c1b2`**(含 freeze-metric / C2 / R3)。
本机 Windows。无 Docker。本单不占 BUG 号,不改 `CHANGELOG.md` / `BUG_HISTORY.md` / `docs/tasks/README.md`。未 `git push origin HEAD:staging`。未改数据库、API 路由、DESIGN.md(无可见样式变化)。
## 开工核对
`git log --oneline origin/staging` 前 20 行含:
| 依赖 | SHA | 证据 |
| --- | --- | --- |
| freeze-metric-change | `3b17c1b2` | `test(freeze): 增长冻结改盯耦合,行数降为粗护栏`HEAD |
| C2 consultation-context-memory | `149e1ec4` | `fix(consult): drop traces, budget checkpoints, silent summary inherit` |
| R3 settled-render-split | `58ccafb6` | `fix(rectification): stop re-parsing settled messages on every stream frame` |
AGENTS.md §6 已是新口径:抽出去的 hook 与子组件应当持有自己的状态。
## 开工实测(不要抄任务书 66/41)
数法与合同测试相同:`Home()` 切片上 `\buseState[<(]` / `\buseRef[<(]`
| 指标 | 实测 |
| ---: | ---: |
| `Home()` `useState` | **66** |
| `Home()` `useRef` | **41** |
| `const [rectification…` | **15** |
| `page.tsx` 行数 | **1951** |
| `useRectificationSurface` 参数(type 字段) | **39**(任务书约 56,按 type 实测) |
| `ConversationalBirthTimeRectification` propstype 字段) | **20**(任务书 24,按 type 实测;JSX 含 key 为 21 |
全量 `npm test`**3218** tests / **3134** pass / **69** fail / **15** skipped。失败名单见下,全是环境缺口。
## 5.1 分类表(15 个,不得砍)
| 状态 | 类别 | 谁在读 | 处置 |
| --- | --- | --- | --- |
| `rectificationSessionId` | 外壳也要读 | `Home``rectificationSurfaceOpen``?c=` 自动打开、bootstrap `rectificationSettled`、组件 `key``useSessionManagement`:历史校正是否延后切换 | 留在外壳对象 `sessionId` |
| `rectificationCaseId` | 外壳也要读 | `Home`:是否挂上校正面(`rectificationSurfaceOpen && rectificationCaseId`)、`key` | 留在外壳对象 `caseId` |
| `rectificationOpeningSessionId` | 外壳也要读 | `AppSidebar` `openingSessionId`(侧栏「正在打开」) | 留在外壳对象 `openingSessionId` |
| `rectificationLoading` | 外壳也要读 | 首页卡文案「正在打开」、自动打开守卫、`StarterHome` `data-opening` | 留在外壳对象 `loading` |
| `rectificationMutationPending` | 外壳也要读 | `productEntrypointsDisabled`、自动打开守卫、首页卡 disabled | 留在外壳对象 `mutationPending` |
| `rectificationEntrySummary` | 外壳也要读 | 首页卡 action/文案、`StarterHome` 卡片说明 | 留在外壳对象 `entrySummary` |
| `rectificationEntrySummarySettled` | 外壳也要读 | `bootstrapPrepareReady` 揭幕门 | 留在外壳对象 `entrySummarySettled` |
| `rectificationError` | 外壳也要读 | 自动打开守卫、bootstrap settled、首页卡报错、`rectificationErrorMessage` | 留在外壳对象 `error` |
| `rectificationErrorSessionId` | 外壳也要读 | 侧栏行内错误;首页卡在 session 失败时藏同一条 | 留在外壳对象 `errorSessionId` |
| `rectificationShouldStartOpening` | 只服务子树 | 校正面是否自动开场;`onOpeningConsumed` 把它关掉 | 搬进 `useRectificationSurface` |
| `rectificationTurns` | 只服务子树 | 挂载前 hydrate 的 `initialTurns` | 搬进 hook |
| `rectificationSnapshot` | 只服务子树 | 挂载前 hydrate 的 `initialSnapshot` | 搬进 hook |
| `rectificationPendingQuestion` | 只服务子树 | 校正面 `pendingConsultationQuestion` | 搬进 hook |
| `rectificationReadonly` | 只服务子树 | 校正面只读 | 搬进 hook |
| `rectificationHeaderSlot` | 只服务子树 | 值只给校正面 portal**setter** 仍是外壳 header 的 `ref` | 状态在 hook`Home` 只用 `setRectificationHeaderSlot` |
外壳留下 **1 个** `useState``rectification` 对象,9 个字段)。没有多留第二个散装校正 state。`rectificationSessionOpenerRef` 仍在 `Home``useSessionManagement` 必须在 surface hook 之前调用,用这个 ref 把 opener 递过去。
## 收尾实测
| 指标 | 改前 | 改后 |
| ---: | ---: | ---: |
| `Home()` `useState` | 66 | **52**(≤53;搬走 14 |
| `Home()` `useRef` | 41 | **39**(未上升;`resumeRectificationSession` / `rectificationOpenInFlight` 进 hook |
| `page.tsx` 行数 | 1951 | **1931** |
| hook 参数(type 字段) | 39 | **24** |
| `ConversationalBirthTimeRectification` propstype 字段) | 20 | **7**`declaredTime` / `models` / `selectedModelId` / `onSelectModel` / `onOpenBilling` / `onSaved` / `panel` |
`useRectificationSurface` 现在自己 `useState` / `useEffect` / `useRef`。子树字段打成 `rectificationPanel` 交给包装组件;包装仍 `return <RectificationAgenticChat {...props} />`
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `home-shell-growth-contract.test.ts` 三门 | useState **66** / useRef **41** / 行数基线 **1951** | **52** / **39** / **1931** | 本单下沉后的新基线 |
| `consultation-entrypoint.test.ts` 三处 pending / 一处 onPendingChange / 一处挂载守卫 | JSX `pendingConsultationQuestion={rectificationPendingQuestion}``onPendingChange={setRectificationMutationPending}` | hook panel 字段 `pendingConsultationQuestion: rectificationPendingQuestion``onPendingChange: setRectificationMutationPending`;守卫内 `panel={rectificationPanel}` | 子树 props 收到 panel |
| `rectification-agentic-entry.test.ts` shouldStartOpening / onOpeningConsumed / initialTurns / onMessagesChange / headerSlot | 同上,JSX `={...}` | hook panel 冒号字段;page 仍 `ref={setRectificationHeaderSlot}` | 同上 |
| `rectification-surface-contract.test.ts` initialSnapshot | JSX `initialSnapshot={rectificationSnapshot}` | `initialSnapshot: rectificationSnapshot` | 同上 |
| `rectification-history-open-20260909.test.ts` | `const [rectificationErrorSessionId, setRectificationErrorSessionId]` | `errorSessionId: rectificationErrorSessionId` + `setRectificationErrorSessionId` | 外壳 9 字段合成一个对象 |
未弱化其它既有断言。`rectification-*.test.ts` 除 Windows symlink 整文件红的 `rectification-v9-agent.test.ts`(基线已红)外全绿。
## 反向验证
`Home()` 里插入 `const [_growthContractProbe, _setGrowthContractProbe] = useState(false);` 后:
```
not ok 1 - Home() useState count must not grow
error: 'Home() has 53 useState calls; cap is 52. ...'
# tests 3 / pass 2 / fail 1
```
`useRef` 与行数粗护栏仍绿。随后删掉探针,文件回到 52/39/1931。
## 测试
| 命令 | 结果 |
| --- | --- |
| `node node_modules/typescript/bin/tsc --noEmit` | **0 错** |
| `npm run lint` | **0 error** / **120** warningfreeze-metric 记 119;本单多 1 条:入口摘要 effect 的两个外壳 setter 不是 `useState` setter`exhaustive-deps` 提醒。不能把它们放进 deps,否则每帧重拉摘要。未顺手修既有 warning) |
| 定向 `npx tsx --test tests/home-shell-growth-contract.test.ts tests/consultation-entrypoint.test.ts tests/home-bootstrap-reveal.test.ts tests/rectification-agentic-entry.test.ts tests/rectification-history-open-20260909.test.ts tests/rectification-surface-contract.test.ts` | **98 pass / 0 fail** |
| `npx tsx --test tests/rectification-*.test.ts`(加上述合同) | 除 `rectification-v9-agent.test.ts` 整文件 `SkillPackageRegistryError`EPERM symlink,基线已有)外 fail=0 |
| `npm test` | **3218** / **3134** pass / **69** fail / **15** skipped。失败名单与开工基线 **69 条逐条相同** |
| `npm run build` / `next build --webpack` | 见下 |
## 全量失败清单(与开工基线逐条相同,环境缺口)
`admin code functions reject immutable codes…``operation-level admin email reauthentication…``rectification agent maps setup failures…``public code never imports the internal dynamic choice contract``tests\\birth-time-guide-agent.test.ts``candidate scores stay out of the specified client ownership boundary``tests\\consultation-agentic-runtime.test.ts``tests\\consultation-birth-time-mode.test.ts`methodology 8 条(`the route's own strict checklist…``the delivered method is quoted…`);`tests\\consultation-workflow-request.test.ts`database/backup/env 一批(`admin customer reset…``database roles have no cluster privileges`);`every color token used as a Tailwind utility…`compose/env/YAML/sync/deploy 一批;`tests\\rectification-v9-agent.test.ts``tests\\skill-binding.test.ts``checked-in registry verifies hashed product packages…`symlink 3 条;`live consult skill reads a hand-updated tree…``changed staging workflows are syntactically valid YAML``live staging sync…` 两条;`first immutable deployment…``deployment scripts pass shell syntax validation``is-docs-only-range.sh…`
无 Docker、无 Git Bash、Windows 不能为 skill runtime 建 symlink。不是本单回归。
## `next build`
默认 Turbopack 在 worktree `node_modules` junction 上会 panic(与 R3 相同)。`next build --webpack`Compile + TypeScript 预期能过;Collecting page data 在 Windows `EPERM` 无法为 `SKILL.md` 建 runtime symlink 时失败。本机看不到 `○ /` 路由表。源码:`page.tsx``force-dynamic`;未改 `layout.tsx` / `globals.css`。首屏 gzip 上次实测 584,413 B,本机未能从构建产物复核 ±2%。
## 真人走查
`docs/testing/home-state-lowering-20260915.md`:进入 / 退出 / 刷新 / 首页卡片打开 / 历史校正 / 开场自动触发 / 只读 / 报错 / 换模型 / 采用后回首页。本仓无登录态、无 Chrome,未测。
## 发现的交互缺陷(本单不修)
无新增。入口摘要 effect 多了一条既有风格的 `exhaustive-deps` warning,见上。
## 未做(范围外)
- `session*` / `profile*` / `synastry*` / `account*` 四簇
- Context Provider 或外部 store
-`origin HEAD:staging`
-`docs/tasks/README.md` 状态列
@@ -0,0 +1,88 @@
# 真机清单 · 校正面状态下沉后行为是否与改前一致(2026-09-15)
对应 `TASK-home-state-lowering-20260915`。自动化锁的是状态住处与源码合同,**锁不住**真实进入/退出/刷新/开场/只读/报错。本仓没有浏览器与登录态,下面这些必须用已登录的 staging 做。
地址:`https://staging.jyotisha.chat`。截图不要带真实姓名、出生资料或会话内容。本单**零文案、零交互改动**;任何和改前不一样的地方都记为失败。
先看 `GET /api/health``deployment.gitCommit` 是否等于本单合入 staging 后的 SHA。不一致 = 先别测。
## 1. 从首页卡片进入
1. 停在首页(资料已齐、有「生时校正」卡)。
2. 点卡片。
- ✅ 卡面在打开期间显示「正在打开」,光标 `progress`;没有转圈、没有骨架。
- ✅ 主栏一次揭开校正面:已有对话的话不是空白再闪出来。
- ✅ 左侧当前会话高亮对上;地址栏带 `?c=`
- ❌ 先闪普通聊天再切校正;或空白题目区等加载。
## 2. 退出校正面
1. 在校正面点左侧一条普通咨询会话,或点「新对话」。
- ✅ 校正面关掉,普通聊天/首页回来;输入框是原来那一套,没有第二套。
- ✅ 校正会话仍在左侧历史里,标题还在。
## 3. 刷新
1. 打开一场校正后按浏览器刷新。
- ✅ 刷新后仍是这场校正(`?c=` 还在),不是掉回首页、也不是空白。
- ✅ 已结算的回合还在,不是重跑开场。
## 4. 首页卡片打开(新建 / 继续)
1. 没有未完成校正时点卡片:应新建。
2. 有未完成校正时点卡片:文案仍是「新建一段独立校正…」,行为与改前一致(左侧历史可继续未完成的)。
- ❌ 点卡片却打开了别人的场,或首页卡把侧栏错误画到卡下。
## 5. 历史校正
1. 停在首页,不要先点卡片。
2. 点左侧一条历史生时校正。
- ✅ 主栏打开该场;点了有反应。
- ✅ 若打开失败:错误出现在被点的那一行下面,首页起始卡不重复同一条。
## 6. 开场自动触发
1. 打开一场**还没有回合**、服务端标记要开场的新校正。
- ✅ 进入后自动开始第一轮,不必再点「开始」。
- ✅ 已有回合的历史场不会再自动开一场。
## 7. 只读
1. 打开一场已经结束 / 只读的校正。
- ✅ 输入框不可用;不能再采题。
- ✅ 已有区间卡/对照仍在原位。
## 8. 报错
1. 资料不齐时点卡片:应回到资料补全,提示「请先完成出生资料,再开始生时校正。」
2. 打开失败(网络或服务):首页卡下或侧栏行下有可见错误,可以再点。
- ❌ 点了没反应、也没有错误。
## 9. 换模型
1. 校正面底部的模型选择与改前同一套控件。
2. 换一个模型后再发一条(或等当前轮结束再换)。
- ✅ 仍是这场校正,没有重挂、没有丢历史。
## 10. 采用后回首页
1. 若账号里有已采用结束的场:从左侧打开,再切回首页(新对话或一条普通咨询)。
- ✅ 回得去;首页校正卡还在;没有第二套输入框、没有转圈。
## 375px
视口 375px 再走 1、2、5。窄屏下进入/退出仍是一次揭开,没有第二套输入框。
## 结论
> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。测不了就写「未测」,不要写成通过。
+31 -51
View File
@@ -21,13 +21,11 @@ import {
entrySummaryFromResponse,
rectificationEntryLabels,
resolveRectificationEntryAction,
type RectificationEntrySummary,
} from "@/lib/rectification-entry";
import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import {
declaredBirthTime,
RECTIFICATION_OPENING_LABEL,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import {
toggleChatMessageFeedback,
@@ -72,7 +70,11 @@ import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey
import { useConsultationRun } from "@/hooks/use-consultation-run";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { useProfileOnboarding } from "@/hooks/use-profile-onboarding";
import { useRectificationSurface } from "@/hooks/use-rectification-surface";
import {
createRectificationShellSetters,
EMPTY_RECTIFICATION_SHELL,
useRectificationSurface,
} from "@/hooks/use-rectification-surface";
import { useSessionManagement } from "@/hooks/use-session-management";
import { sortSessions } from "@/lib/session-groups";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
@@ -282,23 +284,26 @@ export default function Home() {
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
createBirthTimeConsultationConsentState,
);
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
const [rectificationCaseId, setRectificationCaseId] = useState<string | null>(null);
const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState<HTMLElement | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationLoading, setRectificationLoading] = useState(false);
const [rectificationMutationPending, setRectificationMutationPending] = useState(false);
const [rectificationError, setRectificationError] = useState("");
const [rectificationErrorSessionId, setRectificationErrorSessionId] = useState<string | null>(null);
const [rectificationReadonly, setRectificationReadonly] = useState(false);
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
const [rectificationSnapshot, setRectificationSnapshot] = useState<RectificationCaseSnapshotPayload | null>(null);
const [rectificationOpeningSessionId, setRectificationOpeningSessionId] = useState<string | null>(null);
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
const [rectification, setRectification] = useState(EMPTY_RECTIFICATION_SHELL);
const {
sessionId: rectificationSessionId,
caseId: rectificationCaseId,
openingSessionId: rectificationOpeningSessionId,
loading: rectificationLoading,
mutationPending: rectificationMutationPending,
entrySummary: rectificationEntrySummary,
entrySummarySettled: rectificationEntrySummarySettled,
error: rectificationError,
errorSessionId: rectificationErrorSessionId,
} = rectification;
const {
setRectificationError,
setRectificationErrorSessionId,
setRectificationEntrySummary,
setRectificationEntrySummarySettled,
} = createRectificationShellSetters(setRectification);
const [hydrated, setHydrated] = useState(false);
const [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>("account");
const [rectificationEntrySummarySettled, setRectificationEntrySummarySettled] = useState(false);
const prepareStartedAt = useRef<number | null>(null);
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
@@ -347,8 +352,6 @@ export default function Home() {
onFollowUp() {},
});
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
const rectificationOpenInFlight = useRef(false);
const sessionDetailInFlight = useRef(new Set<string>());
const sessionsRef = useRef(sessions);
const rectificationSessionOpenerRef = useRef(async (_exactSessionId: string) => {});
@@ -465,21 +468,15 @@ export default function Home() {
});
const {
refreshRectificationCase,
setRectificationHeaderSlot,
rectificationPanel,
resumeRectificationSession,
openRectificationFromHomepage,
startNewRectification,
handleRectificationProfileIncomplete,
handleRectificationMessagesChange,
} = useRectificationSurface({
account, activeChartId, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
persistSession, profile, rectificationLoading, rectificationMutationPending,
rectificationOpenInFlight, rectificationSessionId, resumeRectificationSession,
persistSession, profile, rectification, setRectification,
sessionSelectionSource, sessions, setActiveSessionId, setDraft, setDraftEntrypoint,
setDraftTheme, setOnboardingStep, setProfileNotice, setRectificationCaseId,
setRectificationEntrySummary, setRectificationError, setRectificationErrorSessionId, setRectificationLoading,
setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId,
setRectificationShouldStartOpening, setRectificationSnapshot, setRectificationOpeningSessionId,
setRectificationTurns, setSessions, uiPreview,
setDraftTheme, setOnboardingStep, setProfileNotice, setSessions, uiPreview,
updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef,
});
@@ -540,6 +537,7 @@ export default function Home() {
rectificationLoading,
rectificationMutationPending,
rectificationSessionId,
resumeRectificationSession,
]);
useEffect(() => {
@@ -1855,31 +1853,13 @@ export default function Home() {
{rectificationSurfaceOpen && rectificationCaseId && (
<ConversationalBirthTimeRectification
key={`${rectificationSessionId}-${rectificationCaseId}`}
caseId={rectificationCaseId}
sessionId={rectificationSessionId ?? ""}
readonly={rectificationReadonly}
shouldStartOpening={rectificationShouldStartOpening}
initialTurns={rectificationTurns}
initialSnapshot={rectificationSnapshot}
declaredTime={rectificationDeclaredTime}
models={modelCatalog?.models ?? []}
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
onMessagesChange={handleRectificationMessagesChange}
onOpeningConsumed={() => setRectificationShouldStartOpening(false)}
onCompleted={() => {
void refreshAccount();
if (rectificationCaseId && rectificationSessionId) {
void refreshRectificationCase(rectificationCaseId, rectificationSessionId);
}
}}
onPendingChange={setRectificationMutationPending}
onProfileIncomplete={handleRectificationProfileIncomplete}
onOpenBilling={(options) => openAccountDialog("billing", { source: options?.source ?? "rectification" })}
onSaved={() => void refreshAccount()}
pendingConsultationQuestion={rectificationPendingQuestion}
onRestart={() => void startNewRectification()}
headerSlot={rectificationHeaderSlot}
panel={rectificationPanel}
/>
)}
@@ -31,7 +31,7 @@ export type PersistedRectificationTurn = Readonly<{
}> | null;
}>;
export type ConversationalBirthTimeRectificationProps = Readonly<{
export type RectificationChatPanel = Readonly<{
caseId: string;
sessionId: string;
readonly: boolean;
@@ -39,23 +39,44 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
initialTurns: readonly PersistedRectificationTurn[];
/** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
pendingConsultationQuestion?: string | null;
headerSlot: HTMLElement | null;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onOpeningConsumed?: () => void;
onRestart?: () => void;
}>;
export type ConversationalBirthTimeRectificationProps = Readonly<{
/** The declared birth minute from the profile, shown on the board before any candidate exists. */
declaredTime: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onOpenBilling?: (options?: { source?: string }) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
headerSlot: HTMLElement | null;
panel: RectificationChatPanel;
}>;
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
export function ConversationalBirthTimeRectification({
declaredTime,
models,
selectedModelId,
onSelectModel,
onOpenBilling,
onSaved,
panel,
}: ConversationalBirthTimeRectificationProps) {
const props = {
declaredTime,
models,
selectedModelId,
onSelectModel,
onOpenBilling,
onSaved,
...panel,
};
return <RectificationAgenticChat {...props} />;
}
+120 -42
View File
@@ -1,6 +1,6 @@
"use client";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { resolveSessionTitle } from "@/lib/agent-reply";
import { rectificationOpenIdentity } from "@/lib/rectification-session-open";
@@ -27,7 +27,10 @@ import {
openResponseFromPayload,
type RectificationEntrySummary,
} from "@/lib/rectification-entry";
import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import type {
PersistedRectificationTurn,
RectificationChatPanel,
} from "@/components/conversational-birth-time-rectification";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import {
hydrateRectificationCase,
@@ -38,6 +41,60 @@ import {
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
export type RectificationShellState = {
sessionId: string | null;
caseId: string | null;
openingSessionId: string | null;
loading: boolean;
mutationPending: boolean;
entrySummary: RectificationEntrySummary | null;
entrySummarySettled: boolean;
error: string;
errorSessionId: string | null;
};
export const EMPTY_RECTIFICATION_SHELL: RectificationShellState = {
sessionId: null,
caseId: null,
openingSessionId: null,
loading: false,
mutationPending: false,
entrySummary: null,
entrySummarySettled: false,
error: "",
errorSessionId: null,
};
function fieldSetter<K extends keyof RectificationShellState>(
setRectification: Dispatch<SetStateAction<RectificationShellState>>,
key: K,
): Dispatch<SetStateAction<RectificationShellState[K]>> {
return (value) => {
setRectification((current) => ({
...current,
[key]: typeof value === "function"
? (value as (prev: RectificationShellState[K]) => RectificationShellState[K])(current[key])
: value,
}));
};
}
export function createRectificationShellSetters(
setRectification: Dispatch<SetStateAction<RectificationShellState>>,
) {
return {
setRectificationSessionId: fieldSetter(setRectification, "sessionId"),
setRectificationCaseId: fieldSetter(setRectification, "caseId"),
setRectificationOpeningSessionId: fieldSetter(setRectification, "openingSessionId"),
setRectificationLoading: fieldSetter(setRectification, "loading"),
setRectificationMutationPending: fieldSetter(setRectification, "mutationPending"),
setRectificationEntrySummary: fieldSetter(setRectification, "entrySummary"),
setRectificationEntrySummarySettled: fieldSetter(setRectification, "entrySummarySettled"),
setRectificationError: fieldSetter(setRectification, "error"),
setRectificationErrorSessionId: fieldSetter(setRectification, "errorSessionId"),
};
}
export type RectificationSurfaceParams = {
account: Account | null;
activeChartId: string;
@@ -47,11 +104,8 @@ export type RectificationSurfaceParams = {
modelCatalog: PublicLanguageModelCatalog | null;
persistSession: (session: ChatSession, mode?: "create" | "update") => Promise<void>;
profile: Profile;
rectificationLoading: boolean;
rectificationMutationPending: boolean;
rectificationOpenInFlight: MutableRefObject<boolean>;
rectificationSessionId: string | null;
resumeRectificationSession: MutableRefObject<(session: ChatSession) => void>;
rectification: RectificationShellState;
setRectification: Dispatch<SetStateAction<RectificationShellState>>;
sessionSelectionSource: MutableRefObject<"user" | "history">;
sessions: ChatSession[];
setActiveSessionId: Dispatch<SetStateAction<string>>;
@@ -60,18 +114,6 @@ export type RectificationSurfaceParams = {
setDraftTheme: (theme: Theme | null) => void;
setOnboardingStep: Dispatch<SetStateAction<OnboardingStep>>;
setProfileNotice: Dispatch<SetStateAction<string>>;
setRectificationCaseId: Dispatch<SetStateAction<string | null>>;
setRectificationEntrySummary: Dispatch<SetStateAction<RectificationEntrySummary | null>>;
setRectificationError: Dispatch<SetStateAction<string>>;
setRectificationErrorSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationLoading: Dispatch<SetStateAction<boolean>>;
setRectificationPendingQuestion: Dispatch<SetStateAction<string | null>>;
setRectificationReadonly: Dispatch<SetStateAction<boolean>>;
setRectificationSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationShouldStartOpening: Dispatch<SetStateAction<boolean>>;
setRectificationSnapshot: Dispatch<SetStateAction<RectificationCaseSnapshotPayload | null>>;
setRectificationOpeningSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationTurns: Dispatch<SetStateAction<PersistedRectificationTurn[]>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
uiPreview: MutableRefObject<boolean>;
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
@@ -90,11 +132,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
modelCatalog,
persistSession,
profile,
rectificationLoading,
rectificationMutationPending,
rectificationOpenInFlight,
rectificationSessionId,
resumeRectificationSession,
rectification,
setRectification,
sessionSelectionSource,
sessions,
setActiveSessionId,
@@ -103,18 +142,6 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setDraftTheme,
setOnboardingStep,
setProfileNotice,
setRectificationCaseId,
setRectificationEntrySummary,
setRectificationError,
setRectificationErrorSessionId,
setRectificationLoading,
setRectificationPendingQuestion,
setRectificationReadonly,
setRectificationSessionId,
setRectificationShouldStartOpening,
setRectificationSnapshot,
setRectificationOpeningSessionId,
setRectificationTurns,
setSessions,
uiPreview,
updateSession,
@@ -123,6 +150,29 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
rectificationSessionOpenerRef,
} = params;
const rectificationSessionId = rectification.sessionId;
const rectificationCaseId = rectification.caseId;
const rectificationLoading = rectification.loading;
const rectificationMutationPending = rectification.mutationPending;
const setRectificationSessionId = fieldSetter(setRectification, "sessionId");
const setRectificationCaseId = fieldSetter(setRectification, "caseId");
const setRectificationOpeningSessionId = fieldSetter(setRectification, "openingSessionId");
const setRectificationLoading = fieldSetter(setRectification, "loading");
const setRectificationMutationPending = fieldSetter(setRectification, "mutationPending");
const setRectificationEntrySummary = fieldSetter(setRectification, "entrySummary");
const setRectificationError = fieldSetter(setRectification, "error");
const setRectificationErrorSessionId = fieldSetter(setRectification, "errorSessionId");
const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState<HTMLElement | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationReadonly, setRectificationReadonly] = useState(false);
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
const [rectificationSnapshot, setRectificationSnapshot] = useState<RectificationCaseSnapshotPayload | null>(null);
const rectificationOpenInFlight = useRef(false);
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
async function refreshRectificationEntrySummary() {
if (!account) return;
try {
@@ -289,12 +339,6 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
await openRectificationCase("new", null, null);
}
resumeRectificationSession.current = (session) => {
sessionSelectionSource.current = "history";
void openRectificationSession(session.id);
};
rectificationSessionOpenerRef.current = openRectificationSession;
function handleRectificationProfileIncomplete() {
setRectificationError("profile_incomplete");
setRectificationSessionId(null);
@@ -320,7 +364,41 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
}));
}
useEffect(() => {
resumeRectificationSession.current = (session) => {
sessionSelectionSource.current = "history";
void openRectificationSession(session.id);
};
rectificationSessionOpenerRef.current = openRectificationSession;
});
const rectificationPanel: RectificationChatPanel = {
caseId: rectificationCaseId ?? "",
sessionId: rectificationSessionId ?? "",
readonly: rectificationReadonly,
shouldStartOpening: rectificationShouldStartOpening,
initialTurns: rectificationTurns,
initialSnapshot: rectificationSnapshot,
pendingConsultationQuestion: rectificationPendingQuestion,
headerSlot: rectificationHeaderSlot,
onMessagesChange: handleRectificationMessagesChange,
onCompleted: () => {
void refreshAccount();
if (rectificationCaseId && rectificationSessionId) {
void refreshRectificationCase(rectificationCaseId, rectificationSessionId);
}
},
onPendingChange: setRectificationMutationPending,
onProfileIncomplete: handleRectificationProfileIncomplete,
onOpeningConsumed: () => setRectificationShouldStartOpening(false),
onRestart: () => void startNewRectification(),
};
return {
rectificationHeaderSlot,
setRectificationHeaderSlot,
rectificationPanel,
resumeRectificationSession,
refreshRectificationEntrySummary,
refreshRectificationCase,
openRectificationCase,
+3 -2
View File
@@ -296,8 +296,9 @@ test("the golden chart-view envelope stays inside the page contract", () => {
assert.match(COORDINATE_BOUNDARY.qizheng, /角宿/);
});
test("page.tsx does not grow to host the chart page", () => {
assert.ok((pageSource.match(/\n/g) ?? []).length <= 1951);
test("page.tsx does not host the chart page", () => {
// 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/);
});
+16 -4
View File
@@ -173,7 +173,10 @@ test("homepage birth-time card opens the V9 Agentic surface via the server case
assert.match(source, /openRectificationFromHomepage/);
assert.match(source, /<ConversationalBirthTimeRectification/);
assert.match(component, /<RectificationAgenticChat \{\.\.\.props\} \/>/);
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
// 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX
// 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel
// 原因:校正面子树状态收到 panel,Home 不再逐个传
assert.match(source, /pendingConsultationQuestion: rectificationPendingQuestion/);
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
});
@@ -203,7 +206,10 @@ test("homepage opens through the server Case API and merges the returned session
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.doesNotMatch(handler, /onNarrativeDelta/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
assert.match(source, /rectificationSurfaceOpen && rectificationCaseId && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
// 原值:同一守卫内 JSX 写 pendingConsultationQuestion={rectificationPendingQuestion}
// 新值:同一守卫内 JSX 写 panel={rectificationPanel}pending 字段在 hook panel
// 原因:校正面子树状态收到 panel
assert.match(source, /rectificationSurfaceOpen && rectificationCaseId && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?panel=\{rectificationPanel\}/);
});
test("the page never creates the session shell locally; the server owns session creation", () => {
@@ -278,7 +284,10 @@ test("rectify-first handoffs stay as Agent context", () => {
const source = homeSurface;
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
// 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX
// 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel
// 原因:校正面子树状态收到 panel,Home 不再逐个传
assert.match(source, /pendingConsultationQuestion: rectificationPendingQuestion/);
assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/);
// 旧 横幅承诺按钮会带回原问题 → 新 按钮已删,改成结束后新建对话再问 → 保留 pendingConsultationQuestion 传递链
assert.match(chat, /结束后新建对话,按采用的时间再问/);
@@ -299,7 +308,10 @@ test("ordinary consultation uses current birth data without a rectification noti
test("rectification mutations report pending state while session-level return controls stay absent", () => {
const source = homeSurface;
assert.match(source, /onPendingChange=\{setRectificationMutationPending\}/);
// 原值:onPendingChange={setRectificationMutationPending} 写在 page.tsx JSX
// 新值:onPendingChange: setRectificationMutationPending 写在 hook 的 panel
// 原因:mutationPending 仍由外壳对象持有,但 setter 经 panel 交给子树
assert.match(source, /onPendingChange: setRectificationMutationPending/);
assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending\}/);
assert.doesNotMatch(source, /重试恢复/);
assert.doesNotMatch(source, /返回并恢复原问题|返回首页/);
@@ -0,0 +1,54 @@
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 after home-state-lowering on origin/staging @ 3b17c1b2.
// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1931.
// Hook counts use \buseState[<(] / \buseRef[<(] so `useState<Type>(` is not missed.
// Former caps were useState 66 / useRef 41 / lines 1951 on freeze-metric @ 51a65d92.
// This round moved 14 rectification useStates out of Home (15 → 1 shell object)
// and 2 rectification refs into useRectificationSurface.
const PAGE_LINE_COUNT_BASELINE = 1931;
const PAGE_LINE_COUNT_CAP = PAGE_LINE_COUNT_BASELINE + 150;
const HOME_USE_STATE_CAP = 52;
const HOME_USE_REF_CAP = 39;
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).`,
);
});
@@ -124,9 +124,15 @@ test("opening is server-owned: shouldStartOpening drives the first turn, never c
// 新:增加 skip_probe(核对卡「这题跳过」)
// 原因:决策 4,跳过只关本题
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/);
assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/);
// 原值:shouldStartOpening={rectificationShouldStartOpening} 写在 page.tsx JSX
// 新值:shouldStartOpening: rectificationShouldStartOpening 写在 hook 的 panel
// 原因:开场开关只服务子树,随 panel 下沉
assert.match(page, /shouldStartOpening: rectificationShouldStartOpening/);
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
// 原值:onOpeningConsumed={() => setRectificationShouldStartOpening(false)} 写在 page.tsx JSX
// 新值:onOpeningConsumed: () => setRectificationShouldStartOpening(false) 写在 hook 的 panel
// 原因:同上
assert.match(page, /onOpeningConsumed: \(\) => setRectificationShouldStartOpening\(false\)/);
});
test("incomplete profiles stay in the shared onboarding flow before any open request", () => {
@@ -221,9 +227,12 @@ test("persisted turns survive remounts; duplicate openings are suppressed by the
// Was: the "ready"/"loading" key suffix (see above). Persisted turns now survive
// because nothing remounts: the key is the session/Case binding only (BUG-505).
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.match(page, /initialTurns=\{rectificationTurns\}/);
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
// 原值:initialTurns={rectificationTurns} / onMessagesChange={handleRectificationMessagesChange} / onOpeningConsumed={...} 写在 page.tsx JSX
// 新值:同名字段写在 hook 的 panel
// 原因:子树自有状态与回调收到 panel,Home 只传外壳还要读的绑定
assert.match(page, /initialTurns: rectificationTurns/);
assert.match(page, /onMessagesChange: handleRectificationMessagesChange/);
assert.match(page, /onOpeningConsumed: \(\) => setRectificationShouldStartOpening\(false\)/);
});
test("the agent route verifies the exact Case/Session binding before any turn", () => {
@@ -579,7 +588,10 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
assert.match(page, /className="chat-header-rectification"/);
assert.match(page, /data-rectification-header-slot=""/);
assert.match(page, /ref=\{setRectificationHeaderSlot\}/);
assert.match(page, /headerSlot=\{rectificationHeaderSlot\}/);
// 原值:headerSlot={rectificationHeaderSlot} 写在 page.tsx JSX
// 新值:headerSlot: rectificationHeaderSlot 写在 hook 的 panelpage 仍 ref={setRectificationHeaderSlot}
// 原因:header 节点在外壳,值只服务子树
assert.match(page, /headerSlot: rectificationHeaderSlot/);
assert.match(styles, /\.chat-header-rectification \.rectification-board-peek \{[\s\S]*width: auto/);
assert.doesNotMatch(chat, /rectification-workspace__board-trigger/);
assert.doesNotMatch(chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("<form className=\"composer\"")), /RectificationBoardPeek/);
@@ -46,7 +46,11 @@ test("a history-list open failure shows under the clicked row, not on the starte
// 原值:rectificationError 只由 starter-home 画;selectSession 不切会话,表现为点了没反应
// 新值:session 失败绑到被点的那一行;首页起始卡不重复同一条
// 决策 3:错误就地显示
assert.match(pageSource, /const \[rectificationErrorSessionId, setRectificationErrorSessionId\]/);
// 原值:const [rectificationErrorSessionId, setRectificationErrorSessionId] = useState(...)
// 新值:外壳 9 个校正字段合成一个 rectification 对象;errorSessionId 仍叫 rectificationErrorSessionIdsetter 仍叫 setRectificationErrorSessionId
// 原因:外壳也要读的校正状态合并成一个对象,散装 useState 下降
assert.match(pageSource, /errorSessionId: rectificationErrorSessionId,/);
assert.match(pageSource, /setRectificationErrorSessionId,/);
assert.match(
pageSource,
/rectificationError=\{rectificationErrorSessionId \? "" : rectificationError\}/,
@@ -35,7 +35,10 @@ test("the rectification surface is revealed once: Case hydration precedes the sw
// Panel: one key per binding, snapshot and turns as initial state, later turns as a prop update.
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.doesNotMatch(page, /"ready" : "loading"/);
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
// 原值:initialSnapshot={rectificationSnapshot} 写在 page.tsx JSX
// 新值:initialSnapshot: rectificationSnapshot 写在 hook 的 panel
// 原因:snapshot 只服务子树,随 panel 下沉
assert.match(page, /initialSnapshot: rectificationSnapshot/);
assert.match(page, /declaredTime=\{rectificationDeclaredTime\}/);
assert.match(wrapper, /initialSnapshot: RectificationCaseSnapshotPayload \| null;/);
assert.match(chat, /useState\(initialSnapshot !== null\)/);
+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
from the main file. This cap is the live line count at freeze (11063 on
2026-09-02, via `wc -l`) plus 300 lines of bugfix slack.
from the main file. Line count is only a coarse guardrail; the live gates are
JyotishAPIHandler method count and JyotishAPIHandler.__new__ forgery sites.
"""
from __future__ import annotations
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` at freeze. New features must not
# consume this budget; open a module instead.
JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 11063
# 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 = 11291
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:
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 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."
)