Batch 1 (dispatch now): rect-scoring-perf, node-runtime-resources, home-sidebar-rerender, api-disk-cache-retention, report-reference-perf, consult-prep-parallel, frontend-dead-code. Batch 2 (queued on predecessors): api-chart-subprocess-gate, answer-effort-ab, consult-route-split, pl9-render-split, python-hygiene, dasha-unify, api-server-service-extract, data-layer-types, contracts-helpers-dedupe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
16 KiB
16 KiB
TASK · 前后端契约与小工具函数去重(第一阶段)(issue #待定)
- 日期:2026-10-13
- 来源:「2026-10-13 全仓屎山与性能排查」· 重复契约与私有小工具条目
- 执行方:Grok(Windows + Docker),分支
codex/contracts-helpers-dedupe-20261013,只推这个分支 - 验收:Claude(Linux)
- BUG 编号:无 BUG 编号(纯类型与搬移重构),记录写 PROGRESS。开工时核对
docs/BUG_HISTORY.md最大号(写作时 BUG-1317);若执行中发现真实缺陷,回报「需拍板」并另申请号,不在本单修。(另:TASK-upstream-sync7-20261014占 BUG-1318~1322,本批号段整体从 BUG-1323 起) - 开工前置:
frontend-dead-code、consult-prep-parallel、consult-route-split三单均已合入origin/staging(会碰同一批文件:personal-report-document-view.tsx用到ClaimStatus;consult 路由与 route-service 有私有record/errorMessage)。第二批。
1. 基线
- 写作基线:
origin/staging77057a819;开工时以三单合入后的origin/staging为准,§2 的行号需重新 grep 定位(按符号名)。
2. 事故实证(77057a819,按符号定位)
2.1 同一契约两份,已有漂移
| 契约 | 份 1 | 份 2 | 差异 |
|---|---|---|---|
| 报告创建请求 | lib/personal-report-route-core.ts::personalReportCreateRequestSchema(zod,themes 为 6 值枚举,带 .default) |
components/personal-report/generate-personal-report-button.tsx::PersonalReportCreateRequest(手写 interface,themes: readonly string[]) |
客户端可以发出服务端必拒的主题而编译不报;DEFAULT_REPORT_THEMES 与 schema 默认值又是两份 |
| 校正 Case 打开响应 | lib/rectification-entry.ts::OpenRectificationCaseResponse(status: string) |
lib/rectification-agentic/v9/open-request.ts::OpenRectificationCaseResponse(status: RectificationCaseStatus) |
已漂移 |
| 首页校正入口摘要 | rectification-entry.ts::RectificationEntrySummary(status: string) |
v9/open-request.ts::RectificationEntrySummary(status: RectificationCaseStatus) |
已漂移;BUG-1041 就是客户端解析与服务端形状脱节(snake/camel)造成的 |
| 可恢复/终态状态集合 | rectification-entry.ts::RESUMABLE_STATUSES/TERMINAL_STATUSES(Set) |
v9/case-status.ts::RESUMABLE_CASE_STATUSES/TERMINAL_CASE_STATUSES |
内容现在相同,两处维护 |
ReportDepth |
lib/personal-report-plan.ts::REPORT_DEPTHS |
lib/personal-report-contract.ts::REPORT_DEPTHS |
再加 personal-report-route-core.ts zod 里第三份字面量 |
ClaimStatus |
lib/report-evidence-bundle-v2.ts(手写联合) |
lib/personal-report-contract.ts::CLAIM_STATUSES |
内容相同,两处维护 |
ChoiceKey |
v9/choice-card.ts |
v9/probe-explain.ts |
都是 `"A" |
2.2 同名不同义(应改名,不应合并)
| 名字 | 份 1 | 份 2 | 判断 |
|---|---|---|---|
EvidenceDomain |
lib/birth-time-question-planner.ts(6 值:旧引导协议 legacy-guided-v1 的提问领域,存量数据在用) |
v9/evidence-model.ts(13 值:v9 证据账本领域) |
两个概念。旧的改名 LegacyGuidedEvidenceDomain(仅类型名;运行时值不变) |
CandidateResult |
lib/birth-time-evidence.ts(旧协议 zod 推导) |
lib/rectification-chat-snapshot.ts(v9 结果或 null) |
两个概念。旧的改名 LegacyCandidateResult |
2.3 私有小工具(实测)
| 名字 | 份数 | 语义变体 |
|---|---|---|
isRecord |
6 | 5 份「非 null、object、非数组」类型守卫;1 份(core/compose-receipt.ts)返回 Readonly<Record>——同义 |
asRecord |
7 | 全部「非数组对象否则 null」(value && 与 value !== null && 对对象等价) |
recordOf |
3 | 同上 |
record |
21 | 4 个语义变体:非数组否则 null(13);含数组否则 null(1,rectification-adopted-chart-tiers.ts);非数组否则 {}(6);含数组否则 {}(1,geoapify-location-service.ts) |
finiteNumber |
5 | 4 份「有限数否则 null」;1 份(server-owned-birth-profile.ts)接受数字字符串、返回 undefined——不同义 |
errorMessage |
4 | 3 个变体:instanceof Error(synastry-reports/route.ts)、鸭子类型 .message + fallback(chart-profiles 两份)、无 fallback 返回 ""(admin/admin-error-response.ts) |
| sha256 hex | personal-report-generation.ts::sha256Hex 已导出;同体包装 personal-report-longform-appendix.ts::hashLongformMarkdown、supabase/codes.ts::hashRedeemCode、v9/message-origin.ts |
全部 createHash("sha256").update(x[, "utf8"]).digest("hex"),字符串默认编码即 utf8,等价 |
复现:
cd frontend
grep -rnE "^(export )?(const|function) (isRecord|asRecord|recordOf|record|finiteNumber|errorMessage)\b" src
grep -rnE "(type|interface) (PersonalReportCreateRequest|OpenRectificationCaseResponse|RectificationEntrySummary|EvidenceDomain|ChoiceKey|ClaimStatus|ReportDepth|CandidateResult)\b" src
3. 根因
客户端组件不能 import 服务端模块(personal-report-route-core.ts 引了 @/mastra、计费、权限等服务端代码;report-evidence-bundle-v2.ts 引 node:crypto),于是每次需要类型就在客户端手抄一份;小工具同理,每个文件各写一个。没有「客户端也能安全 import 的契约目录」。BUG-1041 是这种漂移已经造成的用户可见故障。不是回归,是从未设计。
4. 决策记录
- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。
- 新建
frontend/src/lib/contracts/:只放纯 zod + 类型 + 常量,禁止 import 任何服务端模块(node:*、server-only、@/mastra、@/lib/supabase、@/lib/db);客户端只用import type,类型从 schema 推导。 - 新建
frontend/src/lib/guards.ts:只放无依赖的纯函数。每个语义变体一个名字,不得把不同语义合并成一个函数:isRecord(v): v is Record<string, unknown>(非 null 非数组对象)asRecord(v): Record<string, unknown> | null(同上,否则 null)recordOrEmpty(v): Record<string, unknown>(同上,否则{})finiteNumberOrNull(v): number | null- 「含数组」两个变体、
finiteNumber的字符串版、三种errorMessage:本单不合并,保留原地私有实现(只有 1~2 份,合并会改变语义或命名收益小),写进 PROGRESS。
- sha256:
sha256Hex移到frontend/src/lib/hash.ts(含node:crypto,仅服务端模块可 import,文件头注释写明);hashLongformMarkdown、hashRedeemCode保留领域名,函数体改为调用sha256Hex。哈希值逐字节不变(这些值已落库)。 - 契约对齐时行为不变:客户端解析器(
entrySummaryFromResponse、openResponseFromPayload)照旧接受任意字符串 status(首页缓存与旧响应会带未知值),所以「线上形状」类型 status 仍为string,服务端类型由它派生收窄。 - 不推翻任何既有红线。
5. 硬红线
- 纯类型与搬移:除删除重复定义、改 import、在调用处换成共享函数名外,不改任何逻辑;被替换的私有函数必须与目标函数逐字语义相同(按 §2.3 变体表核对),有疑问就不合并。
src/lib/contracts/**与src/lib/guards.ts不得 import 服务端模块;加一条测试读源码断言这一点。- 客户端组件对 contracts 只能
import type(或 zod 已在该组件 bundle 内的情况下例外,需在 PROGRESS 说明);首屏 gzip ±2%。 - 持久化值不变:哈希、状态字符串、主题枚举值、
legacy-guided-v1存量字段名一概不改。改名只改 TypeScript 类型名。 - BUG-1041 回归测试(
starter-home-polish-20260926.test.tsx中 camelCase/snake_case 两种形状)必须原样通过。 - 测试读源码文本的断言会因搬移失效:开工先
grep -rnE "function (isRecord|asRecord|recordOf|record|finiteNumber)|PersonalReportCreateRequest|RESUMABLE_STATUSES|REPORT_DEPTHS|CLAIM_STATUSES" frontend/tests tests,改任何断言写三栏,测试总数不得低于开工基线。 - 每个 PR 范围 diff < 1500 行;
frontend/src/app/(app)/page.tsx不增长;scripts/jyotish_api_server.py不碰;不改表、迁移、workflow;不升级依赖。
6. 任务分解
T1 · 报告创建请求契约
- 新建
src/lib/contracts/personal-report-request.ts:搬入reportRequestThemes、personalReportCreateRequestSchema、DEFAULT_REPORT_THEMES(与 schema.default同源)、REPORT_DEPTHS;导出PersonalReportCreateRequest = z.infer<...>(服务端用)与PersonalReportCreateRequestInput = z.input<...>(客户端发送用——注意有.default,客户端必须用 input 类型)。 personal-report-route-core.ts从这里 import(原导出名保留 re-export,避免波及调用方)。generate-personal-report-button.tsx:删手写 interface,buildPersonalReportCreateRequest返回PersonalReportCreateRequestInput & { reportType: "personal_full"; presentationMode: "default"; depth: "standard" }(保留现有字面量收窄);DEFAULT_REPORT_THEMES改为 import(若测试从该组件 import 它,保留 re-export)。personal-report-plan.ts、personal-report-contract.ts的REPORT_DEPTHS改为从 contracts 导入并 re-export。- 验收:
personal-report-entry.test.ts不改即绿(buildPersonalReportCreateRequest请求体逐字段相同);新增一条类型级测试:把themes: ["bogus"]赋给PersonalReportCreateRequestInput用// @ts-expect-error锁定;tsc --noEmit0 错。
T2 · 校正入口契约
- 新建
src/lib/contracts/rectification-entry.ts:RECTIFICATION_CASE_STATUSES及 resumable/terminal 列表(从v9/case-status.ts搬入或由其 re-export,二选一,以 import 图无环为准);OpenRectificationDisposition;线上形状OpenRectificationCaseResponseWire(status: string)与RectificationEntrySummaryWire;服务端类型OpenRectificationCaseResponse = Omit<Wire, "status"> & { status: RectificationCaseStatus },摘要同理。 lib/rectification-entry.ts:删本地类型与两个 Set,改用共享常量(new Set<string>(RESUMABLE_CASE_STATUSES)),解析器返回 Wire 类型;导出名保留(RectificationEntrySummary作为 Wire 的别名 re-export),14 个引用文件尽量不动。v9/open-request.ts:删本地两份类型,改为从 contracts 派生并 re-export。- 验收:
starter-home-polish-20260926.test.tsx、rectification-surface-contract.test.ts、rectification-explicit-entry-20261012.test.ts不改即绿;grep -rn "RESUMABLE_STATUSES\b" frontend/src无结果。
T3 · 同名不同义改名 + 同义类型合一
EvidenceDomain(birth-time-question-planner.ts)→LegacyGuidedEvidenceDomain;evidenceDomains常量 →legacyGuidedEvidenceDomains(运行时值不变)。引用方:birth-time-journey-turn-persistence.ts、birth-time-journey-transitions.ts、birth-time-journey-stored-protocol.ts、birth-time-journey-turn.ts、components/birth-time-evidence-draft-card.tsx、tests/birth-time-journey-test-support.ts。CandidateResult(birth-time-evidence.ts)→LegacyCandidateResult。ChoiceKey:probe-explain.ts删本地定义,从choice-card.tsimport(检查无循环依赖;有则反向)。ClaimStatus:report-evidence-bundle-v2.ts删手写联合,改import type { ClaimStatus } from "./personal-report-contract"(若 personal-report-contract 反向依赖 bundle-v2 形成环,改放contracts/report-claim.ts)。- 验收:
tsc --noEmit0 错;grep -rnE "type (EvidenceDomain|CandidateResult|ChoiceKey|ClaimStatus)\b" frontend/src每个名字恰 1 处。
T4 · guards.ts 与 hash.ts
- 按 §4 建
guards.ts,把 §2.3 中同义的私有副本替换为 import:isRecord6、asRecord7、recordOf3(→asRecord)、record中「非数组否则 null」13 份(→asRecord)与「非数组否则{}」6 份(→recordOrEmpty)、finiteNumber4 份(→finiteNumberOrNull)。函数返回类型有Readonly<...>的,在调用处保持原有类型(必要时用局部类型别名,不改逻辑)。 hash.ts:sha256Hex移入;personal-report-generation.ts保留 re-export;hashLongformMarkdown、hashRedeemCode、message-origin.ts的同体实现改调sha256Hex。- 新测试
frontend/tests/guards-and-hash.test.ts:每个 guard 对null / undefined / 0 / "" / [] / {} / new Date()的返回值表(与被替换的旧实现逐一相同——测试里内联一份旧实现作对照);sha256Hex、hashLongformMarkdown、hashRedeemCode对固定输入(ASCII + 中文 + emoji)给出与createHash直接计算相同的值;contracts/**、guards.ts源码不含node:/server-only/@/mastra/@/lib/supabase/@/lib/db。 - 验收:
grep -rnE "^function (isRecord|asRecord|recordOf)\b" frontend/src无结果;record只剩 §4 列出的 2 个含数组变体;PROGRESS 列出替换清单(文件 → 新函数)。
T5 · 差分与记录
- 类型层改动文件用 esbuild 转译差分(命令同
TASK-data-layer-types-20261013T3),只有 import 行变化的文件在 PROGRESS 标注「仅 import」。 docs/tasks/PROGRESS-contracts-helpers-dedupe-20261013.md:替换清单、未合并变体及原因、三栏断言、差分结果、开工/完工测试数。- 不写 BUG_HISTORY、CHANGELOG(用户不可感知);DESIGN.md 不改。
docs/agents/domain.md或CONTEXT.md若有 EvidenceDomain 术语,补一句两个概念的区别。
7. 让步顺序
时间不够依次砍:T4 的 hash 部分 → T4 的 record 21 份(保留 isRecord/asRecord/recordOf 16 份)→ T3。T1、T2 不得砍(契约漂移有过 BUG-1041)。若 diff 超 1500 行,T4 拆到后续单。
8. 验收命令分工
| 谁 | 命令 |
|---|---|
| Grok 自跑 | tsc --noEmit、npm run lint、新增测试与改到的测试单跑(fail、cancelled 为 0)、T5 差分。test:db 不需要(不动数据访问层;若替换波及 lib/db/** 或 supabase/** 则必须跑并按 windows-known-failures.txt 比对) |
| Claude 验收 | 上述 + 前端全量 npm test 与基线逐条比对、测试总数 ≥ 开工基线 + next build(/ 仍 Static,首屏 gzip ±2%)+ Python 快速门(确认 Python 侧读前端源码的测试未被搬移打破) |
9. 开工前置命令
git fetch origin --prune
for t in frontend-dead-code consult-prep-parallel consult-route-split; do
git log origin/staging --oneline -60 | grep -qi "$t" && echo "OK $t" || echo "前置未合入:$t —— 停止开工"
done
test ! -e frontend/src/components/personal-report/personal-report-document-view.tsx || echo "frontend-dead-code 未合入"
git worktree add -b codex/contracts-helpers-dedupe-20261013 .worktrees/contracts-helpers-dedupe-20261013 origin/staging
cd .worktrees/contracts-helpers-dedupe-20261013
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3
grep -rnE "function (isRecord|asRecord|recordOf|record|finiteNumber)|PersonalReportCreateRequest|RESUMABLE_STATUSES|REPORT_DEPTHS|CLAIM_STATUSES" frontend/tests tests
10. 交付物
分支 codex/contracts-helpers-dedupe-20261013;src/lib/contracts/*.ts、src/lib/guards.ts、src/lib/hash.ts;tests/guards-and-hash.test.ts;PROGRESS。无 BUG_HISTORY / CHANGELOG / DESIGN.md。
11. 后续阶段(本单不做)
- 现有
*-contract.ts(chart-view-contract、ephemeris-contract、location-contract、chat-session-write-contract等)迁入lib/contracts/。 - 剩余变体(含数组的
record、字符串版finiteNumber、三种errorMessage)统一命名或消除。 - 其余客户端手抄的 API 响应类型逐个换成
z.input/z.infer(需要逐接口盘点)。 - 加 lint 规则禁止新增私有
isRecord/asRecord(需改 eslint 配置,单独拍板)。