Archived
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:
@@ -110,10 +110,43 @@ export const NARRATIVE = `§0 本活动只回答一件事:**候选内部料号
|
|||||||
· 硬约束 MISMATCH 不许被措辞改判成 MATCH,要改只能补证据后重比;
|
· 硬约束 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 共用同一份**。
|
||||||
* S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 ——
|
* S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 ——
|
||||||
* 各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的等级。
|
* 各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的等级。
|
||||||
|
*
|
||||||
|
* 关系一行本身:**等级、风险、摘要、理由全由规则与模板拼,不让 AI 编** —— 于是永远不会出现
|
||||||
|
* 「有硬约束不符却写着推荐」;人在审核件上改了某一条四态,这一笔重跑,等级跟着变。
|
||||||
*/
|
*/
|
||||||
const RELATION_PROJECTION = {
|
const RELATION_PROJECTION = {
|
||||||
kind: 'project' as const,
|
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_difference_item_rows > 0 and substitution_difference_item_missing = 0 and ' +
|
||||||
'substitution_relation_rows = 1 and validation_record_rows = 1',
|
'substitution_relation_rows = 1 and validation_record_rows = 1',
|
||||||
auto: {
|
auto: {
|
||||||
/** **为什么不用 `{in:'chip_type_id'}`**:这个名字在三张表上都有,顶层同名以后写的赢 —— 拿它取值等于赌哪张表最后写。改成对准一张表 `lookup`,不歧义。 */
|
/* 绑定的真身在 `SHARED_LET`(本文件靠前处)—— S3 复用同一份投影时也要靠它,原地写一份会漂 */
|
||||||
let: {
|
let: 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: '' }] },
|
|
||||||
},
|
|
||||||
do: [
|
do: [
|
||||||
/*
|
/*
|
||||||
* ① **整个活动唯一一次模型调用**。系统把两颗料在该类型下的每一条核心参数
|
* ① **整个活动唯一一次模型调用**。系统把两颗料在该类型下的每一条核心参数
|
||||||
@@ -535,6 +562,12 @@ export const ASSESSMENT_STEPS = [
|
|||||||
'影响说明站得住:说的是「换上去会不会比现在差」,不是复述参数值',
|
'影响说明站得住:说的是「换上去会不会比现在差」,不是复述参数值',
|
||||||
],
|
],
|
||||||
auto: {
|
auto: {
|
||||||
|
/*
|
||||||
|
* **这一步自己的 `let`,不是 S2 那份算好的结果**(2026-08-29 评审 Critical 修正)——
|
||||||
|
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 里的 `{in:'relationId'}` 等名字要靠它才
|
||||||
|
* 绑得上,少了这一行两笔投影的主键在这一步就是空的(详见 `SHARED_LET` 定义处的头注)。
|
||||||
|
*/
|
||||||
|
let: SHARED_LET,
|
||||||
do: [
|
do: [
|
||||||
/* 人改了四态,等级 / 风险 / 摘要 / 验证要求跟着重算 —— 摘要只读,改的是四态 */
|
/* 人改了四态,等级 / 风险 / 摘要 / 验证要求跟着重算 —— 摘要只读,改的是四态 */
|
||||||
RELATION_PROJECTION,
|
RELATION_PROJECTION,
|
||||||
|
|||||||
@@ -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<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||||
|
const cards: Array<Record<string, unknown>> = [];
|
||||||
|
const notes: string[] = [];
|
||||||
|
|
||||||
|
const view = (): Record<string, unknown> => {
|
||||||
|
const mine: Record<string, unknown[]> = {};
|
||||||
|
for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch });
|
||||||
|
const flattened: Record<string, unknown> = {};
|
||||||
|
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<string, string>; patch: Record<string, unknown>; 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<string, string> }) => {
|
||||||
|
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<Record<string, unknown>>) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1431,3 +1431,30 @@ nudge:[{after:'PT2H',action:'nudge'}], retry:{times:2,backoff:'PT30S'}`)慢慢
|
|||||||
本身表达不了的跨字段约束)就地重问一次」的档,与 `agent-runner` 对模型输出解析不成形时
|
本身表达不了的跨字段约束)就地重问一次」的档,与 `agent-runner` 对模型输出解析不成形时
|
||||||
「就地重问一次,原文经 `note` 存工作日志内部痕,第二次仍不成形才交人」同一族做法
|
「就地重问一次,原文经 `note` 存工作日志内部痕,第二次仍不成形才交人」同一族做法
|
||||||
(2026-08-03,见 CLAUDE.md「兜底卡不再摆模型原话」条)——`analyze` 目前没有对应机制。
|
(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 新落地的机制,这个洞会在下一个用到跨步共享投影的活动上重复咬人。
|
||||||
|
|||||||
Reference in New Issue
Block a user