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
+171 -3
View File
@@ -1277,6 +1277,101 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
但也有活动是让人在件上加第一行的(那时零行合法)—— 得由活动声明区分。
**现状**:活动侧把「发卡前清单必须先有行」写进说明书(每拍都在),配测试。
## `modules → app` 是全仓第一条这个方向的 import(2026-08-30,Ruling H9,Task 4/7b 装 `@app/*` 别名时记的)
`apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts` 与
`w05-s3-review-recompute.test.ts` 两份测试用新增的路径别名 `@app/*`(`tsconfig.base.json` +
`vitest.config.ts` 各加一行,与既有 `@modules/*`/`@shared/*`/`@features/*`/`@web-shared/*`
同一种写法)直接 import `apps/api/src/app/wiring/engines.ts` 的具名导出(`rowReadings`/
`missingReadings`/`tallyReadings`/`distinctReadings`)——按本仓的模块层级(`docs/编码规范.md`:
`apps/api/src/app/` 是「唯一组装层」,`modules/*` 是被组装的那一层,依赖方向只许
`app → modules`),这是**全仓第一条 `modules → app`** 的 import,且目前只出现在测试文件里
(production 代码里一条都没有)。
**为什么会有它**:这两份测试要把真 `createAutoRunner` 跑起来验证「引擎逐 action 刷新
时机」这类只有真求值才测得出的回归;读数层四个函数(`missingReadings` 等)只在
`engines.ts` 里有实现,第一版手抄了一份副本,**同一笔提交里就漂了**(`missingReadings`
拼缺项名的真身多带一句 `label ?? `,手抄件漏掉)——「手抄会漂」这个仓已经踩过三次
(读数函数 / `let` 镜像 / 选择器复刻),这次选择 import 真身而不是再抄一遍。
**要治**(方向,未裁定,是全仓架构决定不是单个活动包能裁的):
- `.dependency-cruiser.cjs` 现在只有 `packages-are-leaves`(禁 `packages → apps`)管这类
方向,`modules → app` 完全不受约束——该不该补一条规则(禁 `modules → app`,但要给
测试文件开口子,否则这条测试会被打回手抄、重蹈「手抄会漂」的覆辙),需要专门讨论。
- 或者认为「测试文件例外」本就是可接受的常态(`packages/ui-kit/__tests__/
view-container-collect.test.ts` 已有 `apps → packages` 方向的先例),把这条也当先例
记下就够,不必新增规则。
**排查法**:`grep -rn "from '@app/" apps/api/src/modules` 排除 `__tests__` 目录,
确认这个方向至今只活在测试里。
## `<thead>` 排在 `<tbody>` 之后:违反 HTML5 表格内容模型、读屏器列头关联失效(2026-08-30,Ruling H31)
`activities/part-substitution-assessment/src/views/report.ts` 的 `CHIP_TYPE_TABLE` /
`TYPE_PARAM_TABLE`(Task 7b 修「新建行按钮克隆到表头」那次 Critical 回归)把 `<thead>`
写在两个 `<tbody>` 之后——**挪位本身是那次修复的正确解法**(`data-edit` 两处写法让
`ViewContainer.bodyOf()` 解析到外层 `<table>`,`addRow()` 在这个 host 里找
`tr:last-of-type`;`<thead>` 若排在数据行前面会被优先命中、克隆出表头文字而不是数据格),
但这个修法本身留了一笔新账:
- **违反 HTML5 的 table 内容模型**:标准写法是 `<thead>` 排最前(`<tfoot>` 可以排在
`<thead>` 之后、`<tbody>` 之前,但 `<thead>` 本身不许排在 `<tbody>` 之后)。
- **会让读屏器的自动列头关联失效**:无障碍算法按**文档序**从数据格向上扫、找同列的
`<th>` 来读出「这一格是哪一列」,`<thead>` 排在最后时这条自动关联找不到表头。
呈现不受影响(CSS `table-header-group` 让 `<thead>` **视觉上**永远排最前、不看它在
DOM 里排第几,`querySelector`/`:last-of-type` 认的却是文档序——呈现与选择器认的不是
同一回事),走查看不出来,只有读屏器 / 标记校验器能测出来。复审当时判断:本仓从未
提出过可访问性(a11y)要求,故非违反既有标准、是此前不存在的隐性缺口;真要治得动
`bodyOf`/`addRow` 或重构成单个 `<tbody>`,都超出了那次修复「零平台改动」的前提。
**要治**(方向,未验证,留给认真做无障碍的时候一起评估):
- 平台侧治本:`bodyOf`/`addRow` 改成不依赖 `<thead>`/`<tbody>` 的相对顺序(直接按
`data-rows` 标记的那个 `<tbody>` 取,不用 `tr:last-of-type` 在整个 `<table>` 范围里找),
`<thead>` 就能挪回最前面,两全。
- 或者把两个 `<tbody>`(动态行 + 静态种子行)合并成一个,`<thead>` 天然留在最前——但
种子行「不受 `data-rows` 管辖、行数再少也在」这条设计意图需要另一种表达方式。
**影响面**:目前只有这两张表这么写(其余活动的可编辑表 `<thead>` 都在最前,因为它们
没有「零行也要有种子行」这个需求、`data-edit` 也没有写两遍)。
**排查法**:`grep -n '<thead>' activities/*/src/views/*.ts`,看它是不是排在最后一个
`</tbody>` 之后——目前全仓只有这两张表命中。
## `data-by` 嵌套查表:父键在本行里缺席时会悄悄落回外层同名字段(2026-08-30,终审 I3,浏览器真人走查缺陷①逮到)
**现象**(ACT-WORK-05 task-12,浏览器走查):数据工单件上,候选料那一行的「芯片类型」
只读态**显示着一个值**,而红带同时说它「缺芯片类型」——编辑态与直接查库都核实过
那一格确实是空的,屏上两处自相矛盾。
**病根**:`apps/api/src/modules/interaction/internal/domain/view-render.ts` 的
`renderRows`(:191)递归处理嵌套 `data-rows` 时用 `{ ...scope, ...one }` 合并「外层
作用域」与「本行」——`one`(本行)**没有**的字段会悄悄落回 `scope`(外层)里同名的
那一个。这个「悄悄落回」平时无害(未填的草稿行缺一个键,落回外层同名字段多半也是
空的),但当外层作用域**恰好**有一个同名、非空的字段时,`data-by` 嵌套查表
(`byParent`,:201)会拿这个不该被拿到的值去查表,查出一个不属于这一行的结果。
ACT-WORK-05 恰好撞上这个「恰好」:`engines.ts` 的 `taskData.view()` 会把「各对象最新
一行的字段名」铺到顶层(`task.data`),而 `chip_type_id` 这个名字同时是 `sub_eval.part`
/ `sub_eval.case` / `parts.profile` 三张表的列名——`case-model.ts:134` 那条注释早就为
同一个撞名问题给 S-FEEL 求值加过护栏(「这个名字在三张表上都有,顶层同名以后写的赢,
改用对准一张表的 `lookup`,不歧义」),但那条护栏只保护了 `case-model.ts` 自己的配方
求值,没有覆盖到 `view-render.ts` 这一侧的 `data-by` 嵌套查表——同一类撞名在这里
原样重演。
**已钉进套件、未修**(`apps/api/src/modules/interaction/__tests__/view-render.test.ts:460`
的 `it.fails`,已用最小复现核对过不是测试自己写错):这是共享面(E04/I01 也用
`data-by`),改动需要先报给设计者裁定修法,当时只坐实、不改 `view-render.ts`。
**要治**(方向,未验证):`renderRows` 合并作用域时,`one` 里**没有**的键不该落回
`scope` 同名字段——要么显式置 `undefined`(阻断落回),要么 `byParent` 取父键值时
只认 `one`(本行)自己有没有这个键,不看合并后的结果。两条都要过现有测试(「父值
缺席一行不留」那条已经钉死了「本行显式给出空字符串」这个不同的场景,不能被误伤)。
**排查法**:嵌套 `data-by` 查出来的值与红带/编辑态对不上,先怀疑这条——尤其当查表用
的那个列名在**多张表上撞名**、且当前这一行**没有**这个字段(不是空字符串,是键都不
存在)时。
## 运营中心 · 活动模块接口(2026-08-27 落地时记下的)
- **实例清单的过滤 / 分页在内存里做**:`listInstances` 全量取该活动的 `sys.task`,非终态行逐单读记忆与
@@ -1500,6 +1595,79 @@ declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `aut
`type:'integer'` 且没有其它非 auto 主键列(单主键、无前缀的场景下,`resolveRowKey` 续出的
号从 `1` 起,最容易撞上种子数据里最早的几行)。
## `resolveRowKey` 的「全表一致」没有排除 `auto` 列 —— 第二形(2026-08-30,全分支终审 Critical 1)
上一条记的是 `type:'integer'` 那一形(§③ 续号步骤抢跑);把 `type_id` 改成 `text` 绕开它
之后,**同一个函数的另一段**(`resolveRowKey` §② 「全表一致」,`apps/api/src/app/routes/
desk.ts:144-168`)在**一个更窄的组合**下现出第二形:一张表的主键**唯一**一列就是
`auto:true`(没有别的非 auto 主键列陪衬,如 W05 的 `parts.chip_type.type_id`)。
**病灶链条**(`part-substitution-assessment@2.1.8` 的 `views/report.ts` 曾经这样写):
视图把这一列的种子行(件上「新建一行」时用来兜底提交的那一条静态行)干脆不给 `data-col`
(想法是「自增列不问人,索性连格子都不摆」)——`ViewContainer.collect()` 只认
`input/select/textarea[data-col]`,这一列于是**压根不出现在回传的 `cells` 里**。
`resolveRowKey` 的 `barred` 判断认的是 `k in cells`(这一列在回传里露没露过面),**不是
「值是否非空」**——漏挂 `data-col` 在它眼里等同「这张表压根没有这一列」,专门为「auto 列
件上摆着却空着」设计的 `barred` 保护(:135)因此从未触发,`known` 退回
`findKnownRow`(对空 `key` 恒返回 `undefined`),`isSeq('type_id')` 因为声明是 `text`
也是假 —— 两条已有保护都不成立,直接落进「全表一致」:表里此刻**恰好一行**(活动侧
的自投影机制保证了这一点,是巧合但不是意外)时,`seen.size===1` 把新建的那一行判成
「就是那唯一一行」,一次保存就把既有那一行的其余字段**静默覆盖**。
**这次只在活动侧绕开**(给种子行的这一格换一个真的、`disabled` 的 `<input data-col=
"type_id">`,值恒是空字符串——`barred` 就会正确命中,交回 `undefined` 让
`mintable`/`autoKeys()` 接手续号),`resolveRowKey` 一行没改,见 `views/report.ts` 里
`CHIP_TYPE_TABLE` 头上的大注释与 `apps/api/src/app/__tests__/view-table-edit.test.ts`
「Critical 1」那组用真实函数逐字重放的两种 `cells` 形状。
**要治**(与上一条「§③ 续号加 `if (input.autos?.includes(k)) continue;`」是**同一刀**,
建议一起做):`resolveRowKey` 的 §②(全表一致循环,:144-168)在**进入这个 `for` 循环给
某一列赋值之前**,先判 `input.autos?.includes(k)` 就直接 `continue`(与 §③ 那条建议
补的守卫完全对称)。这样一来,声明了 `auto` 的列会**无条件**被这两段循环跳过 ——
不再依赖视图是否记得给它挂一个「看不见但露面」的 `data-col`;§① 那段「件上摆了、人也
填了」的正常改行路径不受影响(它在这两段循环**之前**就已经把值定下来,轮不到 §②③)。
补了这一刀,`barred` 这个专门的旁路判断其实就可以退休(它存在的唯一理由就是在 §②③
没有这条守卫时,给「视图记得开个空位」这种个例开个后门)——但退休 `barred` 是更大的
改动,不必和这次的守卫补丁绑在一起做。
**排查法**:与上一条同一句判词,但**不要求** `type:'integer'`——只要该表的主键里
`auto:true` 的那一列**是唯一的主键列**(没有别的非 auto 主键列陪衬,靠不上
`findKnownRow` 的「至少一格对得上」),且视图的种子/新增行**没有**给它挂 `data-col`,
就该怀疑这条坑,不论声明的类型是 `integer` 还是 `text`。
## 范围放行(`releaseWrite` 的 `only` 形)没有对 `refersTo` 做传递闭包(2026-08-30,ACT-WORK-05 e2e 撞见,H35 落地时在活动侧绕开)
**现象**:「数据工单确认即入库」(`apps/api/src/app/data-order-landing.ts`,2026-08-27)
把**这张卡的提交闸盯着的那几张表**(`dataOrderLedgers` 算出的 `only` 名单,取自卡绑的
`blocks` 或它自报的 `gateLedgers`)并进正式表(`ledger.releaseWrite(taskId,
{ only, cause })`)——只并名单上的表,不看这些表**引用**着哪些别的表。
如果 A 表(如 `parts.profile`)在 `only` 名单里、被并进正式表,而 A 表某一列声明了
`refersTo: 'B.x'`(如 `parts.profile.chip_type_id` 指向 `parts.chip_type.type_id`),
B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留在草稿层,**正式表里就会
出现一条引用着「只在草稿层存在、正式表查无此号」的悬空引用**:下一个任务(或同一个
任务的下一步)在正式表侧查 B 表,查无此值,`refersTo` 校验拒它,或 `data-by` 嵌套
查表显示不出来。
**这一次没有真的炸出悬空引用**——`part-substitution-assessment` 在活动自己的声明层
把 A/B 两张表都放进了同一张卡的 `gateLedgers`(Ruling H35,`case-model.ts` 那一步
`card` 定义处的大注释),绕开了这个触发点。但那是**这一个活动的选择**,不是平台
保证:下一个活动如果只把「主表」列进某张卡的 `gateLedgers`、忘了把它引用的「字典表」
也列进去,同样的悬空引用会在**正式表**里真实出现,不是测试环境才有的问题。
**要治**(方向,未验证):`releaseWrite` 的 `only` 形在计算真正要并的表集合时,应该
对 `only` 名单里每张表声明的 `refersTo` 做一次**传递闭包**——A 引用 B、B 又引用 C,
落地范围就该自动扩成 {A, B, C},而不是原样信任调用方给的 `only` 名单已经把引用链
配全了。
**同族**(下一条,紧接在后):都是「数据工单确认即入库」这个 2026-08-27 新加的机制,
与**别的**既有机制(这一条是 `refersTo` 的传递闭包,下一条是 `prune`)之间没有对齐
接缝造成的。
**排查法**:数据工单确认之后,别的任务查某张「被引用」的表查无此值(而草稿层里其实
有),先看确认那张卡的 `gateLedgers`/绑定 `blocks` 是不是只列了「主表」、漏了它
`refersTo` 指向的「字典表」。
## `prune` 与「数据工单确认即入库」的架构性冲突(2026-08-30,ACT-WORK-05 案例 2 真跑撞见)
**现象**:数据工单**确认落地之后**再改动会影响行集合的字段(如 W05 的芯片类型),
@@ -1525,9 +1693,9 @@ declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `aut
撤掉之后任务视图少一行、`mine:`/`_rows` 这类读数说谎。**合起来对同一批行形成死锁**:
`prune` 想撤、平台不让撤。
与 2026-08-30 已记的另一条(**范围放行没有对 `refersTo` 做传递闭包**,见「ACT-WORK-05 走查
逼出来的三条平台欠账」附近的记录)同族——两条都是「数据工单确认即入库」这个 2026-08-27
新加的机制与**别的**既有机制(`prune`、`refersTo` 的传递闭包)之间没有对齐接缝造成的。
与上一条(**范围放行没有对 `refersTo` 做传递闭包**)同族——两条都是「数据工单确认即
入库」这个 2026-08-27 新加的机制与**别的**既有机制(这一条是 `prune`,上一条是
`refersTo` 的传递闭包)之间没有对齐接缝造成的。
**要治**(方向,未验证,留给真正动手时评估):
- 已落地行改为「先撤『已落地』记号,再撤行」——即 `prune` 遇到已落地行时先做等价于