fix(w05): 终审两条 Critical + 地基缺陷的另一半 + 文档与欠账收口

**C1 新建芯片类型会改写既有字典行(共享主数据被静默污染)**
链条逐行复核成立:件上种子行漏挂 data-col → barred 与 isSeq 双保护同时失效
(isSeq 那条是因为 Task 7b 把 type_id 从 integer 改成了 text)→ 落进「全表一致」,
而 H35 的自投影恰好保证这张表只有一行 → 人新建的类型被认成既有 type_id=1 →
确认时随范围放行把既有字典行改名并落进正式表,屏上只说「改了 1 行」。
三条各自成立的裁定叠出来的静默污染。
修法:种子行改成 disabled 的真实 <input data-col=type_id>(desk.ts 零改动),
格子里摆「由系统生成」——不能一声不吭地拿掉,那样人会以为这张档少了一格。
测试不止断言结果:另加两条用**真实 resolveRowKey** 重放病灶/修法两种 cells 形状,
直接证明因果机制。平台根治(全表一致应排除 auto 列)记 debt。

**C2 两个启动参数都选填 → 留空则整单永久无解**
gate_reason 说「在下面把两格补上」,而块① 没有加一行、也不在 gateLedgers ——
一句指向空气的指路。改为必填;第一档改口为唯一真无解的那条路径(两格填了同一个号)。

**I1 地基缺陷只修了一半:recipe.let 同样按对象键序求值**
与 fields 是一模一样的假设,同样住 jsonb 列、同样被 PG 重排;今天不炸只因 W05 那五条
互不引用,而 ADR-014 是给后续所有活动用的。新增 orderLets/letReferences
(把 {in:X} 视为依赖 —— 语义与 fields 不同,故单独实现),auto-runner 与静态审同步接入。

**I2 审核卡按「重做」照样收步推进** —— 坐实为真实平台缺陷(「卡答过了就收步」快道不看裁决),
并证明活动侧加选项救不了、chosenGuidance 那条也救不了(那是喂模型的提示词,
而这条快道存在的目的就是跳过模型)。按裁定只留复现测试,平台未改。

**I4** 同一堵墙撞第二次即发兜底执行工单交人 + 立监听(头一次软化留痕),
不再空转到 exhausted 而人无处可改。

debt +4(C1 根治 / modules→app / thead-after-tbody / data-by 落回外层),
并补写此前被两处引用却从不存在的「refersTo 传递闭包」条目。
文档:ADR-014:201「按声明顺序求值」改对(照它写配方的人会重犯本轮的地基缺陷)、
陈旧注释四处、spec 四处分叉逐条判断处置、端到端测试记录标为历史、
CLAUDE.md 纠正「新建类型端到端通了」那句(它没通过真人路)并更新版本与文档表。

包版本 2.1.9(已装,SQL 直查库内 version/required/gate_reason/视图 HTML 四项核实)。
pnpm gates 退出码 0(2476 通过 / 34 跳过);E04 92/92、I01 55/55 无回归。

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-30 16:09:18 +08:00
co-authored by Claude Opus 5
parent 9f5b1c99d3
commit 45cec82984
19 changed files with 1078 additions and 69 deletions
@@ -127,9 +127,14 @@ describe('清单本身合规', () => {
* 2026-08-29 重写:启动表单从「需求 + 原PN + 候选数组 + datasheet」四件收到「原PN + 候选」两件
* (单数,一次评估恰一对一候选)—— `requestDescription` / `candidateInternalPns`(数组)/
* `datasheets` 三键随 P-01 需求主记录一起撤下(本活动不再碰它)。逐条断言见「契约 > 启动两参」。
*
* **2026-08-30 终审 Critical 2 改判**:两格从「都选填」改成**都必填**——旧断言
* `toEqual([])` 钉的正是那条留空即永久卡死在 S1 的死路(gate_reason 指向一条不存在的
* 「加一行」路径),详见「契约 > 启动两参」那条测试与 `manifest.ts`/`case-model.ts`
* 里的大注释。「客户与需求两类字段已随重写撤下」这半句结论不变。
*/
it('起单一格必填都不设(2026-08-28 用户定);客户与需求两类字段已随重写从启动表单撤下', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
it('起单两格都必填(2026-08-30 修 Critical 2 改判);客户与需求两类字段已随重写从启动表单撤下', () => {
expect(manifest.contract.input.data['required']).toEqual(['originalInternalPn', 'candidateInternalPn']);
const launchProps = Object.keys(manifest.contract.input.data['properties'] as Record<string, unknown>);
expect(launchProps).not.toContain('customerCode');
expect(launchProps).not.toContain('customerName');
@@ -262,11 +267,20 @@ describe('台账声明', () => {
});
describe('契约', () => {
it('启动两参,都选填 —— 缺了由第一步派工单补', () => {
/*
* **2026-08-30 终审 Critical 2**:两颗料号是这件事的身份,不是可以事后补的细节 ——
* 都选填时留空,`sub_eval.part` 的两行会因为同一个空主键 `internal_pn=''` 撞成一行,
* `gate_reason` 判「还没填全」而块① 没有「加一行」的路,doneWhen 永不成立,整单
* 永久停在 S1(链条见 `manifest.ts`/`case-model.ts` 的大注释)。改必填不是把人堵死——
* 平台既有的「受理归一」机制(`task-service.ts` 的 `missingRequired`/`askCompletion`)
* 接住它:起单缺这两格,任务落成 `awaiting-human`,一张补全卡问负责人要,答完才开跑,
* 不是门口拒单。主数据(品牌/类型/参数)仍然缺了才由第一步派数据工单补。
*/
it('启动两参,都必填 —— 两颗料号是身份不是细节,留空会让整单永久卡死在 S1', () => {
const props = manifest.contract.input.data['properties'] as Record<string, Record<string, unknown>>;
expect(Object.keys(props)).toEqual(['originalInternalPn', 'candidateInternalPn']);
expect(manifest.contract.input.data['required']).toEqual([]);
/* 运营端启动表单:两格都是「可选可新写」的下拉 */
expect(manifest.contract.input.data['required']).toEqual(['originalInternalPn', 'candidateInternalPn']);
/* 运营端启动表单:两格仍是「可选可新写」的下拉 —— 必填只挡空值,不挡「库里没有就自己写一个新的」 */
for (const key of ['originalInternalPn', 'candidateInternalPn']) {
expect(props[key]?.['x-control']).toBe('combo');
expect(props[key]?.['x-options']).toMatchObject({ ledger: 'parts.profile', value: 'internal_pn' });
@@ -574,15 +588,29 @@ describe('件① 评估输入补全', () => {
});
/*
* `auto:true` 的主键(`type_id`)**不该出现在种子行里**——自增列不问人(与联系人编码
* `contact_code` 同一条纪律)。种子行只有 `type_name`/`description` 两个手写输入格。
* `auto:true` 的主键(`type_id`)**人不填它**——自增列不问人(与联系人编码
* `contact_code` 同一条纪律)。种子行只有 `type_name`/`description` 两个可填输入格,
* `type_id` 自己那一格**必须**是一个真的、禁用的 `data-col` 输入格(值恒空),
* **不能干脆不给** `data-col`。
*
* **2026-08-30 终审 Critical 1 的回归钉**(反着来的教训:这条测试原来断言
* `not.toContain('data-col="type_id"')`,而正是那个「不给 data-col」的形状让
* `desk.ts` 的 `resolveRowKey` 认不出「这一列人碰过没有」,退回「全表一致」把
* 新建的类型误判成种子字典里唯一那一行、静默改名覆盖它——详见 `views/report.ts`
* `CHIP_TYPE_TABLE` 头上的大注释与 `apps/api/src/app/__tests__/view-table-edit.test.ts`
* 里对真实 `resolveRowKey` 的因果验证)。这条测试当年"证明"的是病灶本身,现在改成
* 钉住修法:`type_id` 必须挂 `data-col`,且必须是禁用的 —— 人看得见「由系统生成」
* 这句占位,但怎么点都改不了它的值。
*/
it('**新建类型的种子行不问 type_id**(自增列不问人):已有行的 type_id 只读、没有孪生输入框', () => {
expect(html).toContain('系统生成');
// 「系统生成」只出现在 chip_type 那条种子行里 —— 精确抓出那一行,断言里面没有 data-col="type_id"
const chipTypeStaticRow = /<tr class="jc-row-static">(?:(?!<\/tr>)[\s\S])*?系统生成(?:(?!<\/tr>)[\s\S])*?<\/tr>/.exec(html)?.[0] ?? '';
it('**新建类型的种子行:type_id 挂 data-col、但禁用恒空**(2026-08-30 修 Critical 1 —— 漏挂会让 resolveRowKey 的"全表一致"把新建的类型误判成既有那一行,静默覆盖它)', () => {
expect(html).toContain('由系统生成');
// 「由系统生成」只出现在 chip_type 那条种子行里 —— 精确抓出那一行
const chipTypeStaticRow = /<tr class="jc-row-static">(?:(?!<\/tr>)[\s\S])*?由系统生成(?:(?!<\/tr>)[\s\S])*?<\/tr>/.exec(html)?.[0] ?? '';
expect(chipTypeStaticRow).not.toBe('');
expect(chipTypeStaticRow).not.toContain('data-col="type_id"');
/* 必须是一个真的 <input data-col>(collect() 只认 input/select/textarea[data-col]),
不能是纯文本 <span> —— 否则这一格永远不会出现在回传的 cells 里,barred 的保护无从谈起 */
expect(chipTypeStaticRow).toMatch(/<input[^>]*data-col="type_id"[^>]*disabled/);
/* disabled 意味着人怎么点都改不了它的值 —— 保存时这一格恒是空串,才谈得上「barred」 */
expect(chipTypeStaticRow).toContain('data-col="type_name"');
expect(chipTypeStaticRow).toContain('data-col="description"');
});
@@ -194,6 +194,23 @@ describe('S1 配方', () => {
expect(fields?.['exists_status']).toEqual({ when: 'found_pn_ref != ""', then: 'found', else: 'absent' });
});
/*
* **2026-08-30 修 Critical 2**:两个启动参数改成必填之后(`manifest.ts` 的
* `contract.input.required`),`sub_eval_part_rows < 2` 只剩一条能触发它的路径 ——
* 两颗料号填成了同一个(`internal_pn` 撞成同一把主键,两行覆盖成一行)。旧文案
* 「原料号与候选料号还没填全 —— 在下面把两格补上」指向一条不存在的路(块① 没有
* 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。
*/
it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => {
const kase = acts[5];
const fields = kase?.kind === 'project' ? kase.fields : undefined;
const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined;
expect(gateReason?.when).toBe('sub_eval_part_rows < 2');
expect(gateReason?.then).not.toContain('还没填全');
expect(gateReason?.then).not.toContain('把两格补上');
expect(gateReason?.then).toContain('终止');
});
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
const params = acts[4];
expect(params?.kind === 'project' && params.prune).toBe(true);