From 392d085f51602bf84b3af7af839bbea2c10cfeeb Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 17:32:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(w05):=20S3=20=E5=A4=8D=E7=94=A8=20S2=20?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E5=BF=85=E9=A1=BB=E8=87=AA=E5=B8=A6=20let=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E4=B8=BB=E9=94=AE=E9=9D=A0=E5=AE=83?= =?UTF-8?q?=E7=BB=91,=E5=B0=91=E4=BA=86=E5=B0=B1=E5=86=99=E4=B8=A2?= =?UTF-8?q?=E6=88=96=E6=8B=BC=E5=87=BA=E5=9E=83=E5=9C=BE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审 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) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../src/case-model.ts | 49 +- .../__tests__/w05-s3-review-recompute.test.ts | 497 ++++++++++++++++++ docs/debt.md | 27 + 3 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 19af51f..cbbb7aa 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -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, diff --git a/apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts b/apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts new file mode 100644 index 0000000..3a0e18f --- /dev/null +++ b/apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts @@ -0,0 +1,497 @@ +/** + * **这份文件测的是什么**(2026-08-29,评审 Critical 逼出来的执行级回归)。 + * + * `S3_review.auto` 复用 S2 算好的两笔投影(`RELATION_PROJECTION`/`VALIDATION_PROJECTION`, + * `case-model.ts`)—— 它们的主键 / 外键全靠 `{in:'relationId'}`/`{in:'evalRef'}`/ + * `{in:'originalPn'}`/`{in:'candidatePn'}`/`{in:'typeId'}` 这几个名字,而这几个名字**只在 + * `let` 里绑定**。`createAutoRunner.run()` 每次调用都从**它收到的那份** `recipe.let` 现算一遍 + * `lets`(`auto-runner.ts:93-97`)——步骤之间不传递 `let`。S3 若没有自己的 `let`,这几个名字 + * 在 S3 那一拍就是未绑定,`{in:X}` 退回 `''`(`auto-expr.ts:44-47`)。后果两条: + * ① 关系表 `substitution_relation_id` 算成空串 → 整笔写被「主键算不出来先不写」悄悄丢掉 —— + * **人在审核件上改了四态,那一行永远不会更新**,这正是 S3 存在的全部理由; + * ② 验证记录的主键 `cat:['VAL-', {in:'evalRef'}]` 反而拼出非空的 `'VAL-'`,被当成一个新主键 + * 写出**第二行垃圾**,破了「P-06 一单固定一条」的不变量。 + * + * 只有把真引擎(`createAutoRunner`)跑起来才测得出 —— + * `activities/part-substitution-assessment/__tests__/steps.test.ts` 的六条用例全是结构断言 + * (查 `kind`、查 `into`、`toBe` 引用相等),从来没有对配方求过值,漏不出这一类"名字没绑上"的洞。 + * + * **手写镜像,不 import 真配方** —— 与 `w05-gate-reason-order.test.ts` 同一条理由: + * `activities/*` 与 `apps/*` 互相不许 import(ADR-004,`.dependency-cruiser.cjs` 的 + * `activities-isolated` / `apps-not-into-activities`)。`REL_MIRROR`/`VAL_MIRROR`/`S3_LET_MIRROR` + * 逐字取自 `case-model.ts` 当前的 `RELATION_PROJECTION`/`VALIDATION_PROJECTION`/`SHARED_LET` + * ——在形状上代表,不是逐字同步的承诺(谁改了这三者的**形状**,该顺手核一遍这份文件还有没有 + * 代表性,欠账记在 `docs/debt.md`,与 S1 那份镜像同一条纪律)。 + * + * **读数层四个函数直接 import 真身**,理由与路径别名 `@app/*` 同 `w05-gate-reason-order.test.ts` + * 头注:`apps/api/src/modules/loop-engine/__tests__/` import `apps/api/src/app/wiring/engines.ts` + * 是 `apps → apps`,两条隔离规则都不沾。 + */ +import { describe, expect, it } from 'vitest'; +import type { AutoAction, AutoRecipe } from '@contracts'; +import { taskIdSchema, staffIdSchema } from '@contracts'; +import { evaluateCondition } from '@sfeel'; +import { distinctReadings, missingReadings, rowReadings, tallyReadings } from '@app/wiring/engines.ts'; +import { createAutoRunner } from '../internal/usecase/auto-runner.ts'; + +const TASK = taskIdSchema.parse('55555555-5555-4555-8555-555555555555'); +const OWNER = staffIdSchema.parse('66666666-6666-4666-8666-666666666666'); + +/** 五张台账的字段元数据 —— 写法与 `w05-gate-reason-order.test.ts` 的 `DECLARED` 同款镜像取舍。 */ +const DECLARED = [ + { + domain: 'sub_eval', + object: 'case', + caseKey: 'eval_ref', + fields: [ + { name: 'eval_ref', primaryKey: true }, + { name: 'chip_type_id', primaryKey: false }, + { name: 'gate_reason', primaryKey: false }, + { name: 'summary', primaryKey: false }, + ], + }, + { + domain: 'sub_eval', + object: 'part', + caseKey: 'eval_ref', + fields: [ + { name: 'eval_ref', primaryKey: true }, + { name: 'internal_pn', primaryKey: true }, + { name: 'role', primaryKey: false, required: true, label: '角色', tally: ['original', 'candidate'] }, + { name: 'found_pn_ref', primaryKey: false, label: '在库核对依据' }, + { name: 'exists_status', primaryKey: false, required: true, label: '在库状态', tally: ['found', 'absent'] }, + { name: 'chip_type_id', primaryKey: false, required: true, label: '芯片类型', distinct: true }, + ], + }, + { + domain: 'substitution', + object: 'relation', + fields: [ + { name: 'substitution_relation_id', primaryKey: true }, + { name: 'eval_ref', primaryKey: false, required: true, label: '评估案号' }, + { name: 'original_internal_pn', primaryKey: false, required: true, label: '原内部料号' }, + { name: 'candidate_internal_pn', primaryKey: false, required: true, label: '候选内部料号' }, + { name: 'chip_type_id', primaryKey: false, required: true, label: '芯片类型' }, + { name: 'recommendation_level', primaryKey: false, required: true, label: '推荐等级', tally: ['recommended', 'conditional', 'not_recommended'] }, + { name: 'recommendation_reason', primaryKey: false, required: true, label: '推荐理由' }, + { name: 'difference_summary', primaryKey: false, required: true, label: '差异摘要' }, + { name: 'risk_level', primaryKey: false, required: true, label: '替代风险等级', tally: ['low', 'medium', 'high'] }, + { name: 'verification_requirement', primaryKey: false, label: '验证要求' }, + { name: 'validation_id', primaryKey: false, label: '关联验证ID' }, + { name: 'analysis_status', primaryKey: false, required: true, label: '分析状态' }, + { name: 'rule_version', primaryKey: false, required: true, label: '差异规则版本' }, + ], + }, + { + domain: 'substitution', + object: 'difference_item', + fields: [ + { name: 'substitution_relation_id', primaryKey: true, distinct: true, label: '替代关系ID' }, + { name: 'difference_item_id', primaryKey: true }, + { name: 'parameter_name', primaryKey: false, required: true, label: '标准参数名' }, + { name: 'parameter_desc', primaryKey: false, expect: true, label: '参数说明' }, + { name: 'constraint_type', primaryKey: false, required: true, label: '约束类型', tally: ['hard', 'soft'] }, + { name: 'original_value', primaryKey: false, required: true, label: '原物料参数值' }, + { name: 'candidate_value', primaryKey: false, required: true, label: '候选物料参数值' }, + { name: 'parameter_unit', primaryKey: false, expect: true, label: '参数单位' }, + { name: 'difference_status', primaryKey: false, required: true, label: '差异状态', tally: ['match', 'mismatch', 'unknown', 'conditional'] }, + { name: 'judgement_class', primaryKey: false, required: true, label: '判定分类', tally: ['match', 'soft_mismatch', 'hard_mismatch', 'unknown', 'conditional'] }, + { name: 'impact_statement', primaryKey: false, required: true, label: '影响说明' }, + { name: 'evidence_note', primaryKey: false, required: true, label: '判断依据' }, + { name: 'unknown_disposition', primaryKey: false, label: 'UNKNOWN 去向' }, + { name: 'risk_level', primaryKey: false, required: true, label: '风险等级', tally: ['low', 'medium', 'high'] }, + { name: 'rule_version', primaryKey: false, required: true, label: '差异规则版本' }, + ], + }, + { + domain: 'validation', + object: 'record', + fields: [ + { name: 'validation_id', primaryKey: true }, + { name: 'eval_ref', primaryKey: false, required: true, label: '评估案号' }, + { name: 'validation_internal_pn', primaryKey: false, required: true, label: '验证内部料号' }, + { name: 'validation_type', primaryKey: false, required: true, label: '验证类型', tally: ['pin_to_pin', 'functional', 'reliability', 'certification', 'retest', 'parametric'] }, + { name: 'validation_status', primaryKey: false, required: true, label: '验证状态' }, + { name: 'test_condition', primaryKey: false, expect: true, label: '测试条件' }, + { name: 'next_validation_action', primaryKey: false, required: true, label: '后续验证动作' }, + ], + }, +]; + +/** 假端口 —— 手写依赖注入替身,`view()` 拼作用域的配方与 `w05-gate-reason-order.test.ts` 一致。 */ +function harness() { + const drafts = new Map; patch: Record }>(); + const cards: Array> = []; + const notes: string[] = []; + + const view = (): Record => { + const mine: Record = {}; + for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch }); + const flattened: Record = {}; + for (const d of drafts.values()) { + for (const [field, value] of Object.entries({ ...d.key, ...d.patch })) { + if (value !== null && value !== undefined) flattened[field] = value; + } + } + return { + ...rowReadings(mine, DECLARED), + ...missingReadings(mine, DECLARED), + ...tallyReadings(mine, DECLARED), + ...distinctReadings(mine, DECLARED), + ...mine, + ...flattened, + }; + }; + + const deps = { + data: { + view: () => Promise.resolve(view()), + writable: () => + Promise.resolve( + DECLARED.map((d) => ({ + domain: d.domain, + object: d.object, + label: `${d.domain}.${d.object}`, + keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name), + ...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }), + fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name })), + })), + ), + draftWrite: (i: { domain: string; object: string; key: Record; patch: Record; skipUnchanged?: boolean; skipExisting?: boolean }) => { + const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; + const before = drafts.get(id); + const changed = before === undefined || Object.entries(i.patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v)); + if (i.skipUnchanged === true && !changed) return Promise.resolve({ changed }); + drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: { ...(before?.patch ?? {}), ...i.patch } }); + return Promise.resolve({ changed }); + }, + draftDrop: (i: { domain: string; object: string; key: Record }) => { + const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; + return Promise.resolve({ dropped: drafts.delete(id) }); + }, + }, + cards: { + issue: () => Promise.reject(new Error('本用例不该发执行工单')), + decide: () => Promise.reject(new Error('quiet 拍不该发决策卡 —— 卡这一笔该被跳过')), + entry: () => Promise.reject(new Error('本用例不该发录入卡')), + ask: () => Promise.reject(new Error('本用例不该发补全卡')), + unanswered: () => Promise.resolve([]), + }, + memory: { + append: (m: { title: string }) => { + notes.push(m.title); + return Promise.resolve(); + }, + }, + analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, + sfeel: { + condition: (expr: string, scope: Readonly>) => { + try { + return evaluateCondition(expr, scope); + } catch { + return false; + } + }, + expression: () => undefined, + unaryTest: () => false, + }, + }; + const ctx = { taskId: TASK, stepId: 'S3_review', ownerId: OWNER, humanCallCap: 8, quiet: true, carded: true }; + // 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 与 auto-runner.test.ts 同款既有做法 + return { runner: createAutoRunner(deps as never), drafts, cards, notes, ctx }; +} + +const EVAL_REF = 'W05-20260829000000'; +const RELATION_ID = `SUB-${EVAL_REF}`; +const VALIDATION_ID = `VAL-${EVAL_REF}`; + +/** + * 阶段一(setup,镜像 S1+S2 已经跑完的样子):两颗料在库、两条差异明细都判 `match`、 + * 关系表的初版结论是 `recommended`、验证记录已经落了一行 `not_started`。 + */ +const SETUP_S1S2: AutoRecipe = { + do: [ + { + kind: 'project', + into: 'sub_eval.case', + from: { once: true }, + fields: { eval_ref: EVAL_REF, chip_type_id: 1, gate_reason: '', summary: '' }, + }, + { + kind: 'project', + into: 'sub_eval.part', + from: { + rows: [ + { pn: 'LM2596S-5.0', role: 'original' }, + { pn: 'XL2596S-5.0', role: 'candidate' }, + ], + }, + fields: { + eval_ref: EVAL_REF, + internal_pn: { col: 'pn' }, + role: { col: 'role' }, + found_pn_ref: { col: 'pn' }, + exists_status: 'found', + chip_type_id: 1, + }, + }, + { + kind: 'project', + into: 'substitution.difference_item', + from: { + rows: [ + { + item_id: `${RELATION_ID}-输入电压范围`, name: '输入电压范围', desc: '决定能否直接替换而不改外围', + status: 'match', jclass: 'match', orig: '4.5V~40V', cand: '4.5V~40V', unit: 'V', note: '范围相同', risk: 'low', + }, + { + item_id: `${RELATION_ID}-静态电流`, name: '静态电流', desc: '影响待机功耗', + status: 'match', jclass: 'match', orig: '50uA', cand: '50uA', unit: 'uA', note: '数值相同', risk: 'low', + }, + ], + }, + fields: { + substitution_relation_id: RELATION_ID, + difference_item_id: { col: 'item_id' }, + parameter_name: { col: 'name' }, + parameter_desc: { col: 'desc' }, + constraint_type: 'hard', + original_value: { col: 'orig' }, + candidate_value: { col: 'cand' }, + parameter_unit: { col: 'unit' }, + difference_status: { col: 'status' }, + judgement_class: { col: 'jclass' }, + impact_statement: '无', + evidence_note: { col: 'note' }, + unknown_disposition: '', + risk_level: { col: 'risk' }, + rule_version: 'R-TEST', + }, + }, + { + kind: 'project', + into: 'substitution.relation', + from: { once: true }, + fields: { + substitution_relation_id: RELATION_ID, + eval_ref: EVAL_REF, + original_internal_pn: 'LM2596S-5.0', + candidate_internal_pn: 'XL2596S-5.0', + chip_type_id: 1, + recommendation_level: 'recommended', + recommendation_reason: '每一项核心参数都判为一致,没有待确认项,可直接替代。', + difference_summary: '共 2 项核心参数:2 项一致 / 0 项硬约束不符 / 0 项软约束不符 / 0 项待确认 / 0 项有条件。', + risk_level: 'low', + verification_requirement: '', + validation_id: VALIDATION_ID, + analysis_status: 'fae_confirmed', + rule_version: 'R-TEST', + }, + }, + { + kind: 'project', + into: 'validation.record', + from: { once: true }, + fields: { + validation_id: VALIDATION_ID, + eval_ref: EVAL_REF, + validation_internal_pn: 'XL2596S-5.0', + validation_type: 'parametric', + validation_status: 'not_started', + test_condition: '无', + next_validation_action: '本轮无待验项。', + }, + }, + ], +}; + +/** + * 阶段二(setup,镜像人在审核件上就地改了一格):把「静态电流」那一行的四态从 `match` + * 改成 `hard_mismatch`(审核人对着候选规格书发现待机功耗超预算,原厂的硬约束没扛住)。 + * 与真实的就地编辑同源 —— 只改这一行,不碰另一行,不用 `prune`。 + */ +const HUMAN_EDIT: AutoRecipe = { + do: [ + { + kind: 'project', + into: 'substitution.difference_item', + from: { + rows: [ + { + item_id: `${RELATION_ID}-静态电流`, name: '静态电流', desc: '影响待机功耗', + status: 'mismatch', jclass: 'hard_mismatch', orig: '50uA', cand: '200uA', unit: 'uA', + note: '审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算', risk: 'high', + }, + ], + }, + fields: { + substitution_relation_id: RELATION_ID, + difference_item_id: { col: 'item_id' }, + parameter_name: { col: 'name' }, + parameter_desc: { col: 'desc' }, + constraint_type: 'hard', + original_value: { col: 'orig' }, + candidate_value: { col: 'cand' }, + parameter_unit: { col: 'unit' }, + difference_status: { col: 'status' }, + judgement_class: { col: 'jclass' }, + impact_statement: '候选静态电流超出原厂 4 倍,待机功耗预算不满足', + evidence_note: { col: 'note' }, + unknown_disposition: '', + risk_level: { col: 'risk' }, + rule_version: 'R-TEST', + }, + }, + ], +}; + +/** + * `RELATION_PROJECTION` 的镜像 —— 逐字取自 `case-model.ts` 当前的常量(不代表逐字同步承诺, + * 见文件头注)。**主键 / 外键全靠 `let` 绑定的名字**,这正是这份测试要钉住的那一段。 + */ +const REL_MIRROR: AutoAction = { + kind: 'project', + into: 'substitution.relation', + from: { once: true }, + fields: { + substitution_relation_id: { in: 'relationId' }, + eval_ref: { in: 'evalRef' }, + original_internal_pn: { in: 'originalPn' }, + candidate_internal_pn: { in: 'candidatePn' }, + chip_type_id: { in: 'typeId' }, + recommendation_level: { + when: 'substitution_difference_item_judgement_class_hard_mismatch > 0', then: 'not_recommended', + else: { + when: 'substitution_difference_item_judgement_class_unknown > 0', then: 'conditional', + else: { + when: 'substitution_difference_item_judgement_class_conditional > 0', then: 'conditional', + else: { when: 'substitution_difference_item_judgement_class_soft_mismatch > 0', then: 'conditional', else: 'recommended' }, + }, + }, + }, + risk_level: { + when: 'recommendation_level = "not_recommended"', then: 'high', + else: { when: 'recommendation_level = "recommended"', then: 'low', else: 'medium' }, + }, + difference_summary: { + cat: [ + '共 ', { in: 'substitution_difference_item_rows' }, ' 项核心参数:', + { in: 'substitution_difference_item_judgement_class_match' }, ' 项一致 / ', + { in: 'substitution_difference_item_judgement_class_hard_mismatch' }, ' 项硬约束不符 / ', + { in: 'substitution_difference_item_judgement_class_soft_mismatch' }, ' 项软约束不符 / ', + { in: 'substitution_difference_item_judgement_class_unknown' }, ' 项待确认 / ', + { in: 'substitution_difference_item_judgement_class_conditional' }, ' 项有条件。', + ], + }, + recommendation_reason: { + when: 'recommendation_level = "not_recommended"', + then: '有硬约束不满足 —— 逐条见差异明细里判为 MISMATCH 且约束类型为硬的那几行;硬性边界不满足,不因措辞改判。', + else: { + when: 'recommendation_level = "recommended"', + then: '每一项核心参数都判为一致,没有待确认项,可直接替代。', + else: '没有硬约束不满足,但仍有待确认或需附条件的项 —— 逐条见差异明细;闭环之后方可采用。', + }, + }, + verification_requirement: { + when: 'recommendation_level = "conditional"', + then: '实测差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,并把实测值回填 P-08 标准化参数集;回填后重跑本评估。', + else: '', + }, + validation_id: { cat: ['VAL-', { in: 'evalRef' }] }, + analysis_status: 'fae_confirmed', + rule_version: 'R-TEST', + }, +}; + +/** `VALIDATION_PROJECTION` 的镜像 —— 同上取舍;这是拼出 `'VAL-'` 垃圾行那半个 bug 的主角。 */ +const VAL_MIRROR: AutoAction = { + kind: 'project', + into: 'validation.record', + from: { once: true }, + fields: { + validation_id: { cat: ['VAL-', { in: 'evalRef' }] }, + eval_ref: { in: 'evalRef' }, + validation_internal_pn: { in: 'candidatePn' }, + validation_type: 'parametric', + validation_status: 'not_started', + test_condition: { + when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0', + then: '差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,按原厂规格书条件实测。', + else: '无', + }, + next_validation_action: { + when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0', + then: '实测上述各项并把实测值回填 P-08 标准化参数集,回填后重跑本评估。', + else: '本轮无待验项。', + }, + }, +}; + +/** S3 的卡与交代 —— `quiet:true` 时两者都不会真正生效(卡被外层过滤,`say` 会跑但不影响断言)。 */ +const CARD_MIRROR: AutoAction = { + kind: 'card', + as: 'decide', + variant: 'review', + title: '差异评估结论审核', + ask: '逐条差异结论已备好。请在工作区的件上逐条核对;要改的就地改(四态、影响说明、判断依据都可改),改完回到这里确认。', + options: [{ value: 'approve', label: '确认通过', detail: '逐条核对无误,可以进入落账放行' }], + basis: [ + { k: '原料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' } }, + { k: '候选料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' } }, + { k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } }, + ], +}; +const SAY_MIRROR: AutoAction = { kind: 'say', text: '差异结论已送审,请在件上逐条核对。' }; + +/** + * `SHARED_LET` 的镜像 —— 逐字取自 `case-model.ts`。**这是本用例要钉的核心**: + * S3 recipe 若不带这份 `let`(或带一份不完整的),`REL_MIRROR`/`VAL_MIRROR` 里 + * `{in:X}` 引用的名字就是未绑定的。 + */ +const S3_LET_MIRROR = { + 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: '' }] }, +}; + +/** + * **S3 当前(修复后)的样子**:带上自己的 `let`。case-model.ts 现在就是这样写的 + * (`let: SHARED_LET, do: [RELATION_PROJECTION, VALIDATION_PROJECTION, card, say]`)。 + */ +const S3_RECIPE: AutoRecipe = { + let: S3_LET_MIRROR, + do: [REL_MIRROR, VAL_MIRROR, CARD_MIRROR, SAY_MIRROR], +}; + +describe('S3 审核卡配方:复用 S2 投影时必须自带 let,否则两笔投影的主键在 S3 那一拍是空的', () => { + it('S3 recipe 带 SHARED_LET:关系表那一行真的被重算更新,验证记录仍然只有一行', async () => { + const { runner, drafts, cards, ctx } = harness(); + await runner.run(SETUP_S1S2, ctx); + await runner.run(HUMAN_EDIT, ctx); + + // 重算之前:关系表还是 AI 的初版结论(recommended),这是本用例要推翻的起点 + const beforeRelation = [...drafts.values()].find((d) => d.domain === 'substitution' && d.object === 'relation'); + expect(beforeRelation?.patch['recommendation_level']).toBe('recommended'); + + const out = await runner.run(S3_RECIPE, ctx); + + // ① 关系表那一行真的被重算更新了 —— 不是「写下 0 行」,推荐等级跟着人改的四态翻了 + const relationRows = [...drafts.values()].filter((d) => d.domain === 'substitution' && d.object === 'relation'); + expect(relationRows).toHaveLength(1); + expect(relationRows[0]?.key['substitution_relation_id']).toBe(RELATION_ID); + expect(relationRows[0]?.patch['recommendation_level']).toBe('not_recommended'); + expect(relationRows[0]?.patch['risk_level']).toBe('high'); + + // ② 验证记录仍然只有一行,没有 `validation_id:'VAL-'` 那种拼出来的垃圾行 + const validationRows = [...drafts.values()].filter((d) => d.domain === 'validation' && d.object === 'record'); + expect(validationRows).toHaveLength(1); + expect(validationRows[0]?.key['validation_id']).toBe(VALIDATION_ID); + expect(validationRows.some((d) => d.key['validation_id'] === 'VAL-')).toBe(false); + + // quiet:true 时卡不该真正发出去(外层过滤;cards.decide 若被调用会直接 reject 炸掉这条用例) + expect(cards).toHaveLength(0); + expect(out.issuedCardId).toBeUndefined(); + }); +}); diff --git a/docs/debt.md b/docs/debt.md index 284edd9..d9b7bfe 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -1431,3 +1431,30 @@ nudge:[{after:'PT2H',action:'nudge'}], retry:{times:2,backoff:'PT30S'}`)慢慢 本身表达不了的跨字段约束)就地重问一次」的档,与 `agent-runner` 对模型输出解析不成形时 「就地重问一次,原文经 `note` 存工作日志内部痕,第二次仍不成形才交人」同一族做法 (2026-08-03,见 CLAUDE.md「兜底卡不再摆模型原话」条)——`analyze` 目前没有对应机制。 + +## 静态审核认不出未绑定的 `{in:X}`(2026-08-29,Task 6 评审 Critical 逼出来的平台缺口) + +`activity-registry` 的 `static-audit.ts`(`walkExpr`,约 `:458-471`)会递归校验一条 `AutoExpr` +里的 `when`(→ `checkWhen`,核名字绑得上绑不上)与 `lookup`(→ `checkAddress`,核地址声明过没有), +也会递归进 `cat`/`fmt`/`then`/`else` 继续往下找 —— **唯独没有 `'in' in node` 这一支**。裸的 +`{in:X}` 引用(配方级 `let` 算出来的名字、或起单入参、或 `case:` 读数)在装包那一刻**一声不响**, +名字到底绑不绑得上、活着不活着,静态审一个字都不查。 + +这个洞在 W05 上真的咬过人(见本文件件头 Critical 记录 / `case-model.ts` 的 `SHARED_LET` 头注 / +`apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts`):`S3_review.auto` +一度没有自己的 `let`,而它的两笔投影(`RELATION_PROJECTION`/`VALIDATION_PROJECTION`)里五个 +`{in:X}` 引用的名字**只在 S2 的 `let` 里绑过**——装包、`pnpm gates`(2395 条全绿)、六条纯结构 +断言(`steps.test.ts`)全部放行,只有把真引擎(`createAutoRunner`)跑起来求值才现形:一条主键算成 +空串、整笔写被「主键算不出来先不写」悄悄丢掉;另一条主键因为 `cat` 拼接把空串垫成非空前缀 +(`'VAL-'`),反而写出一行垃圾,破了「一单固定一条」的表不变量。 + +**这是平台缺口,不是 W05 一个活动的病**——凡是拿 `{in:X}` 却没在**这一步自己的** `let` 里定义 +过(哪怕在别的步的 `let` 里定义过,`let` 不跨步传递,`auto-runner.ts:93-97`),都会在装包时蒙混 +过关、只在运行期以「写丢了」或「拼出垃圾行」两种面目现形,且不出声、不留痕说清原因(只在 +`memory.append` 的机械留痕里能看到「主键算不出来没写」,人不会去读那一行)。 + +**要治**:`walkExpr` 补 `'in' in node` 这一支,核 `node['in']` 这个名字是否落在 +`letNames ∪ declaredNames`(与现有 `checkWhen` 同一套判断)里,绑不上就 `schema-invalid` 拒装 —— +与 `checkWhen` 现有的「S-FEEL 对未绑定的名字求值为假,这一笔将永远被跳过」同一条纪律,只是 +`{in:X}` 的失败面目更隐蔽(空串会被拼接、被当合法值继续往下算,不像 S-FEEL 谓词那样直接短路)。 +`auto` 是 ADR-014 新落地的机制,这个洞会在下一个用到跨步共享投影的活动上重复咬人。