fix(w05): S3 复用 S2 投影必须自带 let —— 主键靠它绑,少了就写丢或拼出垃圾行

评审 Critical:S3_review.auto 复用 RELATION_PROJECTION/VALIDATION_PROJECTION 时没有自己的
let,而两笔投影的主键/外键(relationId/evalRef/originalPn/candidatePn/typeId)全靠 let 绑的
名字 —— createAutoRunner.run() 每次都从它收到的那份 recipe.let 现算 lets,不跨步传递
(auto-runner.ts:93-97)。后果:关系表主键算成空串,写被"主键算不出来先不写"悄悄丢掉,人在
审核件上改的四态永远不会反映到推荐等级上;验证记录的主键 cat:['VAL-',{in:'evalRef'}] 反而
拼出非空的 'VAL-',写出一行垃圾,破了"P-06 一单固定一条"的不变量。

brief 让两步共享同一个对象引用(toBe 断言),推翻了 spec"S3 的配方里复制一份 ③④ 两笔……
一步一份配方,不做跨步回跳"的原话。采纳评审方案 (i):把 let 也提成模块级常量 SHARED_LET,
S2 与 S3 的 auto 各自持有 let: SHARED_LET —— 绑定的文本仍只有一处真源,运行时各自完整可求值。
核对过 SHARED_LET 五条绑定只读 sub_eval.case/sub_eval.part(S1 写、S2/S3 都不改),S3 那一拍
与 S2 那一拍算出的值逐字相同,方案 (i) 直接适用。

新增执行级回归测试(w05-s3-review-recompute.test.ts,真 createAutoRunner + 手写假端口,
参照既有 w05-gate-reason-order.test.ts 的搭法):模拟人在审核件上把一条四态改成 hard_mismatch
后重跑 S3,断言关系表真的被重算更新、验证记录仍只有一行。变异验证:去掉 let 复现两种失败
(主键算不出来的行丢失 / 拼出 'VAL-' 垃圾行),加回来两个断言都过。

顺带补 docs/debt.md 一条平台缺口:static-audit.ts 的 walkExpr 对 when/lookup 有校验分支,
唯独没有裸 {in:X} 这一支 —— 未绑定的名字装包时不出声,只在运行期现形。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 17:32:29 +08:00
co-authored by Claude Opus 5
parent 725f1ecb0c
commit 392d085f51
3 changed files with 565 additions and 8 deletions
@@ -110,10 +110,43 @@ export const NARRATIVE = `§0 本活动只回答一件事:**候选内部料号
· 硬约束 MISMATCH 不许被措辞改判成 MATCH,要改只能补证据后重比;
· 不写客户批准状态——那是独立审批流的事,本活动只答技术可替代性。`;
/*
* **两步各自的 `auto.let` 都要指向这份绑定,不能只有一步有**(2026-08-29 评审 Critical 修正)。
*
* `createAutoRunner.run()` **每次调用都从它收到的那份 `recipe.let` 现算一遍** `lets`
* (`auto-runner.ts:93-97`)—— 步骤之间不传递 `let`,turn-runner 的两个调用点
* (`:1725-1738` 的 quiet 分支、`:2020-2022` 的常规分支)都是把 `step.auto` 原样递进去。
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 引用的 `{in:'relationId'}`/`{in:'evalRef'}`/
* `{in:'originalPn'}`/`{in:'candidatePn'}`/`{in:'typeId'}` 若只在 S2 的 `auto.let` 里定义过、
* S3 没有自己的 `let`,这几个名字在 S3 那一拍就是**未绑定**——`{in:X}` 找不到就退回 `''`
* (`auto-expr.ts:44-47`)。后果两条,已用执行级测试实证(`apps/api/src/modules/loop-engine/
* __tests__/w05-s3-review-recompute.test.ts`):
* ① 关系表 `substitution_relation_id` 算成空串,整笔写被「主键算不出来先不写」悄悄丢掉
* ——**人在审核件上改了四态,那一行永远不会更新**,这正是 S3 存在的全部理由;
* ② 验证记录的主键 `cat:['VAL-', {in:'evalRef'}]` 反而拼出非空的 `'VAL-'`,被当成一个
* 新主键写出**第二行垃圾**,破了「P-06 一单固定一条」的不变量。
*
* spec(`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md:293-294`)说的是
* 「S3 的配方里**复制一份** ③④ 两笔(纯投影、幂等)……一步一份配方,不做跨步回跳」——**复制**,
* 不是共享一个只算一次的结果。两步各自持有一份**完整可求值**的 `let` 才是这句话的本意;
* 绑定的**文本**仍只有一处真源(这个常量),避免真的抄两份而分叉。
*/
/** **为什么不用 `{in:'chip_type_id'}`**:这个名字在三张表上都有,顶层同名以后写的赢 —— 拿它取值等于赌哪张表最后写。改成对准一张表 `lookup`,不歧义。 */
const SHARED_LET = {
evalRef: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' },
typeId: { lookup: 'mine:sub_eval.case', by: {}, take: 'chip_type_id', else: '' },
originalPn: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' },
candidatePn: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' },
relationId: { cat: ['SUB-', { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }] },
} as const;
/*
* **关系与验证记录的投影,S2 与 S3 共用同一份**。
* S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 ——
* 各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的等级。
*
* 关系一行本身:**等级、风险、摘要、理由全由规则与模板拼,不让 AI 编** —— 于是永远不会出现
* 「有硬约束不符却写着推荐」;人在审核件上改了某一条四态,这一笔重跑,等级跟着变。
*/
const RELATION_PROJECTION = {
kind: 'project' as const,
@@ -390,14 +423,8 @@ export const ASSESSMENT_STEPS = [
'substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0 and ' +
'substitution_relation_rows = 1 and validation_record_rows = 1',
auto: {
/** **为什么不用 `{in:'chip_type_id'}`**:这个名字在三张表上都有,顶层同名以后写的赢 —— 拿它取值等于赌哪张表最后写。改成对准一张表 `lookup`,不歧义。 */
let: {
evalRef: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' },
typeId: { lookup: 'mine:sub_eval.case', by: {}, take: 'chip_type_id', else: '' },
originalPn: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' },
candidatePn: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' },
relationId: { cat: ['SUB-', { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }] },
},
/* 绑定的真身在 `SHARED_LET`(本文件靠前处)—— S3 复用同一份投影时也要靠它,原地写一份会漂 */
let: SHARED_LET,
do: [
/*
* ① **整个活动唯一一次模型调用**。系统把两颗料在该类型下的每一条核心参数
@@ -535,6 +562,12 @@ export const ASSESSMENT_STEPS = [
'影响说明站得住:说的是「换上去会不会比现在差」,不是复述参数值',
],
auto: {
/*
* **这一步自己的 `let`,不是 S2 那份算好的结果**(2026-08-29 评审 Critical 修正)——
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 里的 `{in:'relationId'}` 等名字要靠它才
* 绑得上,少了这一行两笔投影的主键在这一步就是空的(详见 `SHARED_LET` 定义处的头注)。
*/
let: SHARED_LET,
do: [
/* 人改了四态,等级 / 风险 / 摘要 / 验证要求跟着重算 —— 摘要只读,改的是四态 */
RELATION_PROJECTION,