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
@@ -978,3 +978,107 @@ describe('回归 · jsonb 打乱 fields 键序,project 求值仍要与原序一
}
});
});
/**
* **回归:jsonb 打乱 `recipe.let` 键序,同样必须求出与原声明序一致的结果**
* (2026-08-30 全分支终审 Important 1)。
*
* H47(上面那一大段)只治了 `fields` 那一半 —— `recipe.let` 同样住在
* `sys.activity_package.manifest` 这个 jsonb 列,同样会被 PG 按「先短后长、同长字典序」
* 重排。而 `let` 的官方跨条目读法**就是** `{in:X}`(见 `packages/contracts/src/
* auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能 {in:'名'} 引用」),后一条 let
* 靠它读前一条已经算出的值 —— 键序被打乱,`{in:X}` 会读到 `undefined`,与 `fields` 那次
* 事故是**同一种病灶、同一种沉默失败**(不报错、结果看着完整、只是算错)。
*
* 下面 `LETS` 故意让**被依赖的那一条名字更长**(`originalStamp`,13 字)、**依赖它的那一条
* 名字更短**(`tag`,3 字)——jsonb「先短后长」重排会把 `tag` 排到 `originalStamp` **前面**,
* 恰好是「后一条 let 引用前一条,键序被 jsonb 重排会颠倒」这个具体形状。
*/
describe('回归 · jsonb 打乱 recipe.let 键序,求值仍要与原声明序一致(2026-08-30)', () => {
/** 与上面 `reorderLikeJsonb` 同一个规则,这个 describe 块单独起一份(两处各自独立,互不依赖对方的实现细节) */
function reorderLikeJsonb<T>(fields: Readonly<Record<string, T>>): Record<string, T> {
const keys = Object.keys(fields).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0));
const out: Record<string, T> = {};
for (const key of keys) out[key] = fields[key] as T;
return out;
}
/** 被依赖的那一条(13 字)排在依赖它的那一条(3 字)前面 —— 声明序是对的,jsonb 会把它排错 */
const LETS: Record<string, AutoExpr> = {
originalStamp: { in: 'caseSeed' },
tag: { cat: [{ in: 'originalStamp' }, '-X'] },
};
it('reorderLikeJsonb 确实会把依赖的那一条排到被依赖的前面(不然这条用例什么都没测)', () => {
expect(Object.keys(reorderLikeJsonb(LETS))).toEqual(['tag', 'originalStamp']);
});
function harness(seed: Record<string, unknown>) {
const drafts = new Map<string, { key: Record<string, string>; patch: Record<string, unknown> }>();
const deps = {
data: {
view: () => Promise.resolve({ ...seed }),
writable: () =>
Promise.resolve([{ domain: 'demo', object: 'record', label: '演示记录', keys: ['case_ref'], fields: [] }]),
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
drafts.set(id, { key: i.key, patch: i.patch });
return Promise.resolve({ changed: true });
},
draftDrop: () => Promise.resolve({ dropped: false }),
},
cards: {
issue: () => Promise.reject(new Error('本用例不该发卡')),
decide: () => Promise.reject(new Error('本用例不该发卡')),
entry: () => Promise.reject(new Error('本用例不该发卡')),
ask: () => Promise.reject(new Error('本用例不该发卡')),
unanswered: () => Promise.resolve([]),
},
memory: { append: () => Promise.resolve() },
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
sfeel: {
condition: (expr: string, scope: Record<string, unknown>) => evaluateCondition(expr, scope),
expression: () => undefined,
unaryTest: () => false,
},
};
const ctx = { taskId: TASK, stepId: 'S1_inputs', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
return { runner: createAutoRunner(deps as never), drafts, ctx };
}
const recipeOf = (lets: Record<string, AutoExpr>): AutoRecipe => ({
let: lets,
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'CR-1', computed_tag: { in: 'tag' } } }],
});
it('声明序与打乱成 jsonb 重排序,求出的 let 值必须完全相同、且是对的(不是空字符串兜底)', async () => {
const seed = { caseSeed: 'W05-20260830' };
const declared = harness(seed);
const declaredOut = await declared.runner.run(recipeOf(LETS), declared.ctx);
const shuffled = harness(seed);
const shuffledOut = await shuffled.runner.run(recipeOf(reorderLikeJsonb(LETS)), shuffled.ctx);
expect(declaredOut.failedAt).toBeUndefined();
expect(shuffledOut.failedAt).toBeUndefined();
const declaredTag = [...declared.drafts.values()][0]?.patch['computed_tag'];
const shuffledTag = [...shuffled.drafts.values()][0]?.patch['computed_tag'];
/* 正确答案:originalStamp = 'W05-20260830'(读 scope),tag = 'W05-20260830' + '-X' —— 打乱前不改变这一点 */
expect(declaredTag).toBe('W05-20260830-X');
/* 求值序被治好之前,这里会是 '-X'(打乱后 `originalStamp` 还没算,`{in:'originalStamp'}` 读到 undefined→'') */
expect(shuffledTag).toBe('W05-20260830-X');
expect(shuffledTag).toBe(declaredTag);
});
it('真正的环(a 依赖 b、b 依赖 a)—— 不抛出去炸掉整个回合,交回 failedAt 且话里点名是 let', async () => {
const cyclic: Record<string, AutoExpr> = { a: { in: 'b' }, b: { in: 'a' } };
const h = harness({});
const out = await h.runner.run(recipeOf(cyclic), h.ctx);
expect(out.failedAt).toBeDefined();
expect(out.failedAt?.message).toContain('循环依赖');
expect(out.failedAt?.message).toContain('let');
});
});