fix(w05): S2 测试的 stepOf 类型保护改用泛型 Extract,补 {} 兜底的第二个坑

评审判定 PlanStep 标注太松:.fields 落在契约的开放索引签名上,拼写保护回来了但
14 处方括号取值(含 S1 既有三条)全部失去编译期拼写检查。换成泛型 Extract 版
(case-model.ts 四处 item 补 as const 配合),按查的 item 精确narrow 到那一步自己的形状。

Extract 单独应用后 tsc 仍报 13 个错——根因是 brief 给的 7 处测试断言用 `: {}` 兜底,
而"有形对象类型 | {}"在 TS 里恒被吸收成裸 {}(与 stepOf 精不精确无关,5 个隔离复现验证过),
S1 既有断言用的是 `: undefined` 从未踩过。补齐 `: {}` → `: undefined` + `?.` 与两处
for-of 数组补 as const 后,拼错实验复现 TS2551 + "Did you mean" 建议,改回后零错误。

debt.md 补一条:analyze 出参靠 requiredWhen 兜底的字段漏填时,全 auto 步没有就近恢复点
(ADR-014 通用特性,不是 W05 的病)。

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 16:18:46 +08:00
co-authored by Claude Opus 5
parent 89f0be8b81
commit 1c6978818c
3 changed files with 73 additions and 34 deletions
@@ -1,19 +1,29 @@
import { describe, expect, it } from 'vitest';
import { planStepSchema, type PlanStep } from '@contracts';
import { planStepSchema } from '@contracts';
import { ASSESSMENT_STEPS, ASSESSMENT_PHASES, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release'];
/*
* **返回类型显式标 `PlanStep`**(2026-08-29,Task 5 逼出来的修正,与「文件里没有的名字先核一遍」同一课)——
* **泛型 `Extract`,不是 `PlanStep` 标注**(2026-08-29,评审 fix round 1 改判)——
* `ASSESSMENT_STEPS` 不是元组,`.find()` 的返回类型是数组**元素类型**(四步 `auto.do` 里所有动作形状的并集),
* 与查的是哪个 `item` 无关。S2 从占位 `say` 换成真配方之前,并集里只有 S1 自己的四种 `project` 形状,
* S1 的断言侥幸压得住;换上真配方后并集里混进了 S2 的三种 `project` 形状(字段名完全不同),
* S1 原有的 `fields?.['found_pn_ref']` 这类按属性名取值当场过不了 `tsc`(TS7053)。
* 标成契约类型 `PlanStep` 之后 `.auto.do[i]` 落回 `AutoAction`(`fields: Readonly<Record<string, AutoExpr>>`
* 本就有索引签名),按哪个字符串查步骤才真正决定读到的形状 —— 这也是测试原本就该核的东西。
* 与查的是哪个 `item` 无关。第一版把返回类型标成契约 `PlanStep`,而 `PlanStep['auto']` 落在
* `fields: Readonly<Record<string, AutoExpr>>` 这个**开放索引签名**上 —— 拼写保护是回来了,但整个
* 文件里按方括号取字段值的 14 处(含 S1 原有 `:155`/`:156`/`:166` 三条)全部失去编译期拼写检查,
* `evidence_note` 手滑打成 `evidence_noet` 之类的错字,`tsc` 会**静默放行**,只能等 `vitest` 断言
* 落空才发现,反馈反而往后挪了一步。
*
* 现在按查的 `item` 精确 narrow:四步各自的 `item` 字段已经在 `case-model.ts` 加了 `as const`,
* `(typeof ASSESSMENT_STEPS)[number]` 因此是四种带字面量 `item` 标签的形状的并集;`stepOf('S2_assess')`
* 传的是字符串字面量,泛型 `T` 原样推成 `'S2_assess'`,`Extract<并集, {item: T}>` 精确挑出**那一步自己的**
* 形状 —— 别的步的 `project`/`analyze` 形状不再混进来,`.fields['xxx']` 落回的是那一步真实写的字面量对象
* 类型(有名字的那几个属性),不是开放索引签名。拼错字段名会报 `TS2551` 并带拼写建议。
*/
const stepOf = (item: string): PlanStep | undefined => ASSESSMENT_STEPS.find((s) => s.item === item);
function stepOf<T extends string>(item: T) {
return ASSESSMENT_STEPS.find(
(s): s is Extract<(typeof ASSESSMENT_STEPS)[number], { item: T }> => s.item === item,
);
}
describe('四步案例模型', () => {
it('恰好四步,次序即业务次序', () => {
@@ -211,25 +221,33 @@ describe('S2 配方 —— AI 只出场一次', () => {
it('**行由字典决定,不由 AI 决定** —— 明细 each 的是 type_param,不是 result:items', () => {
const items = acts[1];
expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' });
const fields = items?.kind === 'project' ? items.fields : {};
/*
* **`: undefined`,不是 `: {}`**(2026-08-29,评审 fix round 1 逼出来的第二处修正)——
* `SomeShape | {}` 在按方括号取值时会被 TS 整体收窄成裸 `{}`(`{}` 是任何对象的父类型,
* 联合化简会把更具体的那一支吃掉),于是**全部**字段访问失去编译期保护、报
* 「不能索引类型 {}」,与 `stepOf` 标没标 `Extract` 无关(受控实验见任务报告)。
* `: undefined` 不会被这样吃掉,后续按 `?.[key]` 取值,与 S1 既有的
* `:164`/`:172`/`:175` 三处同一写法 —— 断言真跑到的值不变(条件为真时 `?.` 无副作用)。
*/
const fields = items?.kind === 'project' ? items.fields : undefined;
/* AI 的出参只是一张查表:漏一条,那一格就是空 —— required 立刻把它变成 _missing > 0 */
expect(fields['difference_status']).toMatchObject({ lookup: 'result:items', take: 'difference_status' });
expect(fields['impact_statement']).toMatchObject({ lookup: 'result:items' });
expect(fields['evidence_note']).toMatchObject({ lookup: 'result:items' });
expect(fields?.['difference_status']).toMatchObject({ lookup: 'result:items', take: 'difference_status' });
expect(fields?.['impact_statement']).toMatchObject({ lookup: 'result:items' });
expect(fields?.['evidence_note']).toMatchObject({ lookup: 'result:items' });
});
it('**值不取 AI 出参,一律 lookup 台账**', () => {
const fields = acts[1]?.kind === 'project' ? acts[1].fields : {};
for (const col of ['original_value', 'candidate_value']) {
expect(fields[col]).toMatchObject({ lookup: 'parts.parameter' });
const fields = acts[1]?.kind === 'project' ? acts[1].fields : undefined;
for (const col of ['original_value', 'candidate_value'] as const) {
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
}
/* 约束类型来自字典这一行本身,不是 AI 说的 */
expect(fields['constraint_type']).toEqual({ col: 'constraint_type' });
expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' });
});
it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => {
const fields = acts[1]?.kind === 'project' ? acts[1].fields : {};
const jc = JSON.stringify(fields['judgement_class']);
const fields = acts[1]?.kind === 'project' ? acts[1].fields : undefined;
const jc = JSON.stringify(fields?.['judgement_class']);
expect(jc).toContain('hard_mismatch');
expect(jc).toContain('soft_mismatch');
/* 顺序要紧:先认四态,最后才按约束类型分硬软 */
@@ -237,8 +255,8 @@ describe('S2 配方 —— AI 只出场一次', () => {
});
it('推荐等级读 judgement_class 的 tally 计数 —— 不读 AI 出参、不读自报列', () => {
const rel = acts[2]?.kind === 'project' ? acts[2].fields : {};
const level = JSON.stringify(rel['recommendation_level']);
const rel = acts[2]?.kind === 'project' ? acts[2].fields : undefined;
const level = JSON.stringify(rel?.['recommendation_level']);
expect(level).toContain('substitution_difference_item_judgement_class_hard_mismatch > 0');
expect(level).toContain('not_recommended');
/* 硬约束不符必须直接落到「不推荐」,排在最前 */
@@ -249,9 +267,9 @@ describe('S2 配方 —— AI 只出场一次', () => {
const val = acts[3];
expect(val?.kind === 'project' && val.into).toBe('validation.record');
expect(val?.kind === 'project' && val.from).toEqual({ once: true });
const fields = val?.kind === 'project' ? val.fields : {};
expect(fields['validation_status']).toBe('not_started');
expect(fields['validation_type']).toBe('parametric');
const fields = val?.kind === 'project' ? val.fields : undefined;
expect(fields?.['validation_status']).toBe('not_started');
expect(fields?.['validation_type']).toBe('parametric');
});
it('提示词:三条硬规矩都写进去了,且不引任何真实料号与参数名', () => {
@@ -264,18 +282,18 @@ describe('S2 配方 —— AI 只出场一次', () => {
expect(prompt).not.toContain(forbidden);
}
/* 出参 schema 顶层是对象,数组字段名与 into 对得上 */
const out = analyze?.kind === 'analyze' ? analyze.output : {};
expect(out['type']).toBe('object');
const out = analyze?.kind === 'analyze' ? analyze.output : undefined;
expect(out?.['type']).toBe('object');
expect(analyze?.kind === 'analyze' && analyze.into).toBe('items');
});
it('AI 的入参**每一条都带说明** —— 参数名本身说不清「它为什么要紧」', () => {
const analyze = acts[0];
const fields = analyze?.kind === 'analyze' ? analyze.input.fields : {};
expect(fields['param_desc']).toEqual({ col: 'param_desc' });
expect(fields['constraint_type']).toEqual({ col: 'constraint_type' });
for (const col of ['original_value', 'candidate_value']) {
expect(fields[col]).toMatchObject({ lookup: 'parts.parameter' });
const fields = analyze?.kind === 'analyze' ? analyze.input.fields : undefined;
expect(fields?.['param_desc']).toEqual({ col: 'param_desc' });
expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' });
for (const col of ['original_value', 'candidate_value'] as const) {
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
}
});
});