fix(w05): Task 7b 评审 Critical —— data-edit 挂外层 table 后「加一行」克隆到表头,不是数据行

外层 data-edit 是为了让 collect() 同时收到静态种子行与动态行,副作用是 ViewContainer.bodyOf()
的单个 querySelector 从此解析到 <table> 本身,addRow() 接着在这个 host 里找 tr:last-of-type ——
CSS :last-of-type 按各自父元素判定,<thead> 里孤零零的那个 <tr> 在它自己的父元素里必然「最后」,
若 <thead> 排在数据行前面就会被文档序优先命中:克隆出来的是四个 <th> 表头文字、零个
input[data-col],还被 appendChild 到 <table> 底下、不在任何 <tbody> 里。parts.type_param 那张
更要紧——它的加行功能在这次改动之前本来是好的(data-edit 只挂 tbody 时,querySelectorAll 的
搜索范围本就不含 thead),外层 data-edit 一挂就成了真回归。

修法(不改 ViewContainer.tsx/view-render.ts):把两张表的 <thead> 挪到两个 <tbody> 之后。CSS 的
table-header-group 排版规则视觉上永远排最前、不看它在 DOM 里排第几(与 HTML5 允许 tfoot 写在
thead 之后、tbody 之前同一条原理)——呈现不变,而 querySelector/:last-of-type 认的是文档序,
thead 排最后,选择器就再也碰不到表头行。

补 6 条真 DOM 测试(apps/api/.../view-render-addrow-thead.test.ts):真实 renderView() 输出
在 jsdom 里挂起来,逐行复刻 bodyOf()/addRow() 的真实选择器链(它们没 export,双向 depcruise
规则也拦着直接 import),断言克隆样本落在 tbody 里、含 N 个具名 input[data-col],不是表头。
apps/api 只受 tsconfig.node.json 管(零 DOM lib),packages/ui-kit 又不许反向 import apps/*
(packages-are-leaves)——用 TS 标准的 per-file `/// <reference lib="dom" />` 只给这一份文件
挂 DOM 类型,不改 tsconfig 本身。activity.test.ts 另补一条字符串级结构安全网(thead 确实排在
两个 tbody 之后),把源码与镜像测试的假设锁在一起。变异验证两段输出(拿掉修复→3/6 真红,
含 report.ts 真实源码单独验证一次红/绿→改回来全绿)贴在报告里。

Important 两条:①两个平台坑(resolveRowKey 续号步骤不认 auto、coerceToColumn 削前导零)
记入 docs/debt.md,写清仍未修、只是被本活动的声明层绕开;②manifest.ts/activity.test.ts 里
「台账是 JSONB 不存在物理列类型」这句错的论据删掉——master/record 是真实物理列(bigint),
只有草稿层是 JSONB,安全的真正原因是 schema-merge.ts 的单调加宽 lattice 把 integer→text
判定成合法加宽,widen-column 生成的 ALTER TYPE text USING col::text 沿 PG 转型没有失败路径。

pnpm gates 退出码 0(2417 passed | 34 skipped);E04/I01 包内测试 92/92、55/55 全绿。
packageVersion 2.1.0 → 2.1.1。

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 00:23:47 +08:00
co-authored by Claude Opus 5
parent d9bcce9516
commit 57870329e6
5 changed files with 384 additions and 27 deletions
@@ -133,12 +133,18 @@ describe('台账声明', () => {
});
/*
* Task 7b(2026-08-29):`type_id` 声明是 `text` 不是 `integer` —— 台账是 JSONB,不存在
* 物理列类型这回事,这条纯粹是逻辑类型;改 text 是因为它是本仓唯一「auto:true 又是
* 整数型」的主键,integer 会踩两个坑(`resolveRowKey` 的续号步骤抢在 `autoKeys()` 前面
* 把它按草稿行数续成 1、撞种子的 `type_id=1`;`autoKeys()` 补零的字符串 `'03'` 经引用列的
* `coerceToColumn(_, 'integer')` 会被 `Number()` 削掉前导零,`refersTo`/查表按字符串比对
* 全部失配)。查证过程见 Task 7b 报告。
* Task 7b(2026-08-29):`type_id` 声明是 `text` 不是 `integer` —— 它是本仓唯一
* 「auto:true 又是整数型」的主键,integer 会踩两个平台坑(仍未修,见 docs/debt.md):
* ① `resolveRowKey` 的续号步骤只认 declared type 是不是 integer/number(`isSeq`),
* 不看 `auto`,抢在 `autoKeys()` 前面把它按草稿行数续成 1、撞种子的 `type_id=1`;
* ② `autoKeys()` 补零的字符串 `'03'` 经引用列的 `coerceToColumn(_, 'integer')` 会被
* `Number()` 削掉前导零,`refersTo`/查表按字符串比对全部失配。
* **改 text 不是因为「台账没有物理列类型」**——`master`/`record` 是真实物理列
* (`ledger-store.ts` 的 `PG_TYPE` 映射,`integer → bigint`);只有任务级草稿层
* `ledger.__draft` 才是 JSONB。安全的真正原因是 `schema-merge.ts` 的单调加宽 lattice
* 把 `integer → text` 判定成合法加宽(不是类型冲突),`widen-column` 那支生成的是
* `alter column … type text using col::text`——bigint 转 text 沿 PG 的转型没有失败路径。
* 查证过程见 Task 7b 报告。
*/
it('类型字典:type_id 是自增主键 —— 人在件上新建类型时留空,由基座续号', () => {
expect(colOf('parts.chip_type', 'type_id')).toMatchObject({ primaryKey: true, type: 'text', auto: true });
@@ -532,6 +538,46 @@ describe('件① 评估输入补全', () => {
}
});
/*
* **评审 Critical 回归修复的结构安全网**(2026-08-30):`data-edit` 两处写法
* (上面那条)有个副作用——`ViewContainer.bodyOf()` 的 `document.querySelector`
* 从此解析到外层 `<table>`,而 `addRow()` 接着在这个 host 里找 `tr:last-of-type`——
* CSS `:last-of-type` 是按各自父元素判定的,`<thead>` 里孤零零的那个 `<tr>` 在它
* 自己的父元素(`thead`)里必然「最后」,若 `<thead>` 排在数据行前面,它会被文档序
* 优先命中:克隆出来的是表头文字,零个 `input[data-col]`,`parts.type_param`
* 那张(改动前本来是好的)因此被做坏——一次真回归(评审用 jsdom 在 renderView() 真实
* 输出上复现两次坐实)。
*
* 修法是把 `<thead>` 挪到两个 `<tbody>` 之后(CSS 的 `table-header-group` 排版规则:
* 视觉上永远排最前,不看它在 DOM 里排第几——这条规则本来就是 HTML5 允许 `tfoot`
* 写在 `thead` 之后、`tbody` 之前的原理)——`document.querySelector`/`:last-of-type`
* 认的是文档序不是视觉序,`<thead>` 排在两个 `<tbody>` 后面,`tr:last-of-type` 的
* 搜索就再也碰不到表头行。
*
* 走 DOM 选择器的那部分行为(渲染后 `addRow`/`bodyOf` 真的会不会命中表头)由
* `apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts`
* 覆盖(activities 包按 ADR-004 只准 import @sdk/@contracts/@sfeel,够不着
* `apps/api` 的 `renderView`,所以那份测试镜像了这份源码的结构,不是 import 它)——
* 这里补的是**廉价的字符串安全网**,把这份源码与那份镜像测试的假设锁在一起:
* 谁若把 `<thead>` 挪回两个 `<tbody>` 之前(哪怕只改一张表),这条测试立刻标红,
* 提醒去核对 `view-render-addrow-thead.test.ts` 是否也需要同步。
*/
it('**回归安全网**:两张新表的 <thead> 都排在两个 <tbody> 之后(不是最前面)—— 与 view-render-addrow-thead.test.ts 的假设锁在一起', () => {
for (const address of ['parts.chip_type', 'parts.type_param']) {
const tableStart = html.indexOf(`<table data-edit="${address}">`);
expect(tableStart, address).toBeGreaterThanOrEqual(0);
const tableEnd = html.indexOf('</table>', tableStart);
const tableHtml = html.slice(tableStart, tableEnd);
const theadAt = tableHtml.indexOf('<thead>');
const tbodyPositions = [...tableHtml.matchAll(/<tbody/g)].map((m) => m.index ?? -1);
expect(theadAt, `${address}: 找不到 <thead>`).toBeGreaterThanOrEqual(0);
expect(tbodyPositions.length, `${address}: 应有两个 <tbody>`).toBe(2);
// <thead> 必须排在两个 <tbody> 的开标签**之后**(源码序;视觉呈现由 CSS 的
// table-header-group 保证仍在最前,与源码序无关)
for (const pos of tbodyPositions) expect(theadAt, address).toBeGreaterThan(pos);
}
});
/*
* 2026-08-29 评审 Minor:brief 原话是「红带显隐用 data-class-of」,但 gate_reason 是
* 中文自由文本 —— classToken 只留 [a-z0-9-],中文字符全被剥空,不论空串还是有内容的
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估',
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
packageVersion: '2.1.0',
packageVersion: '2.1.1',
category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment',
@@ -234,12 +234,27 @@ export const manifest: ActivityManifest = {
* `refersTo`/`data-by` 那几处按 `String(...)` 比对的查找会全部失配(`'03' !== '3'`),
* 写入当场被 `refersTo` 拒、或下拉/查表显示不出人刚建的类型。
* **两处病根都是「integer 类型的自增列」这个此前从未出现过的组合触发的**——本仓
* 唯一先例 `contact_code` 一直是 `text`。改成 `text`(此列与下面 5 处同引用列一起改,
* 保持同一条身份链路全程一种类型)对存储没有影响(台账是 JSONB,没有物理列类型),
* 只是把逻辑类型改得诚实;S-FEEL 那几处只做等值/去重比较,不做数值运算,不受影响
* (已逐处核对:distinct 计数 / lookup·take / refersTo 皆按字符串比,`>` 只用在
* `distinct_chip_type_id` 这个计数列上,不用在值本身)。查证过程详见
* Task 7b 报告(`.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-7b-report.md`)。
* 唯一先例 `contact_code` 一直是 `text`。**`resolveRowKey`/`coerceToColumn` 这两处
* 平台代码本身仍带着这两个坑没修**(下一个建同样组合主键的人会重新踩上,已记
* `docs/debt.md`)——这里只是在活动自己的声明层绕开,不是修复。
*
* 改成 `text` 为什么是安全的(此列与下面 5 处同引用列一起改,保持同一条身份链路
* 全程一种类型):**`ledger.__draft`(任务级草稿层)才是 JSONB**
* (`ledger-store.ts` 的 `DRAFT_TABLE`/`DRAFT_DDL`:`key`/`patch`/`baseline` 三列都是
* `jsonb`)——但 `master`/`record` 这类**正式表是真实的物理列**(同文件 `PG_TYPE`
* 映射,`integer → bigint`,`create table`/`add column`/`alter column ... type ...`
* 都拼的是真 PG 类型)。改声明安全,靠的是 `schema-merge.ts` 的**单调加宽 lattice**
* (`SCALAR_CHAIN.integer = ['integer', 'number', 'text']`,`widen()`
* 判定 `integer → text` 是**合法加宽**、不是类型冲突)配合 `widen-column` 那支生成的
* 显式转换(`alter table … alter column … type text using col::text`——整数转文本
* 沿 PG 的 `bigint::text` 转型,没有失败路径)。这次重装(`activities:install`)会
* 把 `ledger.parts__chip_type.type_id` 等六列从 `bigint` 真的 `ALTER … TYPE text`
* 过去一遍。
*
* S-FEEL 那几处只做等值/去重比较,不做数值运算,不受影响(已逐处核对:distinct
* 计数 / lookup·take / refersTo 皆按字符串比,`>` 只用在 `distinct_chip_type_id`
* 这个计数列上,不用在值本身)。查证过程详见 Task 7b 报告
* (`.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-7b-report.md`)。
*/
col({ name: 'type_id', type: 'text', label: '类型编号', primaryKey: true, auto: true }),
col({ name: 'type_name', type: 'text', label: '类型名称', required: true }),
@@ -350,14 +350,29 @@ const PART_TABLE = `<div class="jc-tbl-wrap"><table id="tbl1">
* 与「新建那一行」两处的输入,宿主必须是两者共同的祖先,外层那份是留给它看的。
* `.on` 落点用 `host.closest('table')`——外层就是 `<table>` 自身,`closest` 命中自己,
* 落的还是同一张 `<table>`,与从前只在 `<tbody>` 上挂 `data-edit` 时完全一致,CSS 不必改。
*
* **`<thead>` 排在两个 `<tbody>` 之后 —— 不是随手换的顺序,是评审逮到的 Critical 回归修复**
* (2026-08-30):`data-edit` 两处写法(上一段)有个副作用——`ViewContainer.bodyOf()`
* 的 `document.querySelector` 从此解析到外层 `<table>`,`addRow()` 接着在这个 host 里找
* `tr:last-of-type`。CSS `:last-of-type` 是**按各自父元素**判定的,`<thead>` 里孤零零的
* 那个 `<tr>` 在它自己的父元素(`thead`)里必然是「最后一个」——若 `<thead>` 排在数据行
* 前面,`querySelectorAll('tr:last-of-type')` 会**优先**命中它(文档序在前):点「+加一行」
* 克隆出来的是四个 `<th>` 表头文字、零个 `input[data-col]`,还被 `appendChild` 到
* `<table>` 底下、不在任何 `<tbody>` 里——按钮点了有反应,结果完全不可用,且不报错。
* `parts.type_param` 那张更要紧:它的加行功能在这次改动之前本来是好的(`data-edit`
* 只挂在 `<tbody>` 上时,`querySelectorAll` 的搜索范围本就不含 `<thead>`)——外层
* `data-edit` 一挂,这是一次真回归。
* 挪到两个 `<tbody>` 之后就绕开了:CSS 表格排版规范里 `table-header-group`
* (`<thead>` 的默认 display)**视觉上永远排最前,不看它在 DOM 里排第几**(这正是
* HTML5 允许 `<tfoot>` 写在 `<thead>` 之后、`<tbody>` 之前的同一条排版原理)——呈现
* 一字不变,而 `querySelector`/`:last-of-type` 认的是**文档序**,`<thead>` 排在最后,
* `tr:last-of-type` 的搜索就再也碰不到表头行(种子行保证两个 `<tbody>` 不会同时空,
* 表头因此永远轮不到)。DOM 层面的证据(真实 `renderView()` 输出 + 真实选择器路径,
* 含变异验证:挪回原位置能让新测试真红)见
* `apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts`;
* 这份源码本身的结构安全网见 `__tests__/activity.test.ts` 里那条「回归安全网」。
*/
const CHIP_TYPE_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_type">
<thead><tr>
<th style="width:38px">#</th>
<th data-h="类型编号">编号</th>
<th class="req" data-h="类型名称">类型名称</th>
<th data-h="类型说明">类型说明</th>
</tr></thead>
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"><tr>
<td class="rn"><span class="jc-rn-num"></span></td>
<td>${ro('type_id', 'mono')}</td>
@@ -370,6 +385,12 @@ const CHIP_TYPE_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_t
<td><input class="jc-cell" data-col="type_name" placeholder="新类型名称"></td>
<td><input class="jc-cell" data-col="description" placeholder="这个类型是什么(供选型参考)"></td>
</tr></tbody>
<thead><tr>
<th style="width:38px">#</th>
<th data-h="类型编号">编号</th>
<th class="req" data-h="类型名称">类型名称</th>
<th data-h="类型说明">类型说明</th>
</tr></thead>
</table></div>`;
/**
@@ -394,16 +415,13 @@ const CHIP_TYPE_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_t
* 这张表列的顺序与语义不同(所属类型编号/参数名/参数说明/约束/参考单位,6 列)——
* 位置 5、6 恰好被那两条老规则套反(约束套上「单位」的宽度、参考单位套上「约束」的宽度)。
* 不挂 id 就不会继承这几条位置写死的规则,退回普通 `table`/`th`/`td` 基础样式。
*
* **`<thead>` 排在两个 `<tbody>` 之后,同 `CHIP_TYPE_TABLE` 那条大注释**(评审逮到的 Critical 回归修复,2026-08-30)——
* 这张表比 `parts.chip_type` 更要紧:它的「加一行」在这次改动**之前本来是好的**(`data-edit`
* 只挂 `<tbody>` 时,`querySelectorAll('tr:last-of-type')` 搜索范围本就不含 `<thead>`),
* 外层 `data-edit` 一挂就撞上了同一个坑,是一次真回归。理由与做法一字不差,不重复贴。
*/
const TYPE_PARAM_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.type_param">
<thead><tr>
<th style="width:38px">#</th>
<th class="req" data-h="所属类型">所属类型</th>
<th class="req" data-h="参数名">参数名</th>
<th class="req" data-h="参数说明">参数说明</th>
<th class="req" data-h="约束类型">约束(硬/软)</th>
<th data-h="参考单位">参考单位</th>
</tr></thead>
<tbody data-rows="mine:parts.type_param" data-edit="parts.type_param"><tr>
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行" data-edit-del="parts.type_param">×</button></td>
<td>
@@ -423,6 +441,14 @@ const TYPE_PARAM_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.type_
<td><select class="jc-cell" data-col="constraint_type"><option value=""></option><option value="hard">硬约束</option><option value="soft">软约束</option></select></td>
<td><input class="jc-cell" data-col="param_unit" placeholder="单位(选填)"></td>
</tr></tbody>
<thead><tr>
<th style="width:38px">#</th>
<th class="req" data-h="所属类型">所属类型</th>
<th class="req" data-h="参数名">参数名</th>
<th class="req" data-h="参数说明">参数说明</th>
<th class="req" data-h="约束类型">约束(硬/软)</th>
<th data-h="参考单位">参考单位</th>
</tr></thead>
</table></div>`;
/**