Files
Jyotisha/docs/tasks/TASK-contracts-helpers-dedupe-20261013.md
T
Jesse_ChenandClaude Opus 5.5 0be4cb299c docs(tasks): code-health + performance audit briefs, 16 tasks (BUG-1323 onward)
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
2026-10-14 00:34:35 +08:00

16 KiB
Raw Blame History

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/staging 77057a819;开工时以三单合入后的 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. 硬红线

  1. 纯类型与搬移:除删除重复定义、改 import、在调用处换成共享函数名外,不改任何逻辑;被替换的私有函数必须与目标函数逐字语义相同(按 §2.3 变体表核对),有疑问就不合并。
  2. src/lib/contracts/** 与 src/lib/guards.ts 不得 import 服务端模块;加一条测试读源码断言这一点。
  3. 客户端组件对 contracts 只能 import type(或 zod 已在该组件 bundle 内的情况下例外,需在 PROGRESS 说明);首屏 gzip ±2%。
  4. 持久化值不变:哈希、状态字符串、主题枚举值、legacy-guided-v1 存量字段名一概不改。改名只改 TypeScript 类型名。
  5. BUG-1041 回归测试(starter-home-polish-20260926.test.tsx 中 camelCase/snake_case 两种形状)必须原样通过。
  6. 测试读源码文本的断言会因搬移失效:开工先 grep -rnE "function (isRecord|asRecord|recordOf|record|finiteNumber)|PersonalReportCreateRequest|RESUMABLE_STATUSES|REPORT_DEPTHS|CLAIM_STATUSES" frontend/tests tests,改任何断言写三栏,测试总数不得低于开工基线。
  7. 每个 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 --noEmit 0 错。

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.ts import(检查无循环依赖;有则反向)。
  • ClaimStatus:report-evidence-bundle-v2.ts 删手写联合,改 import type { ClaimStatus } from "./personal-report-contract"(若 personal-report-contract 反向依赖 bundle-v2 形成环,改放 contracts/report-claim.ts)。
  • 验收:tsc --noEmit 0 错;grep -rnE "type (EvidenceDomain|CandidateResult|ChoiceKey|ClaimStatus)\b" frontend/src 每个名字恰 1 处。

T4 · guards.ts 与 hash.ts

  • 按 §4 建 guards.ts,把 §2.3 中同义的私有副本替换为 import:isRecord 6、asRecord 7、recordOf 3(→ asRecord)、record 中「非数组否则 null」13 份(→ asRecord)与「非数组否则 {}」6 份(→ recordOrEmpty)、finiteNumber 4 份(→ 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-20261013 T3),只有 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. 后续阶段(本单不做)

  1. 现有 *-contract.ts(chart-view-contract、ephemeris-contract、location-contract、chat-session-write-contract 等)迁入 lib/contracts/。
  2. 剩余变体(含数组的 record、字符串版 finiteNumber、三种 errorMessage)统一命名或消除。
  3. 其余客户端手抄的 API 响应类型逐个换成 z.input/z.infer(需要逐接口盘点)。
  4. 加 lint 规则禁止新增私有 isRecord/asRecord(需改 eslint 配置,单独拍板)。