Archived
**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
154 lines
8.5 KiB
TypeScript
154 lines
8.5 KiB
TypeScript
/**
|
|
* 配方字段(与 `let`)求值序 —— 纯函数,零 IO(编码规范 §5.3)。
|
|
*
|
|
* `AutoAction.fields` / `analyze.input.fields` / `AutoRecipe.let` 存进
|
|
* `sys.activity_package.manifest`(`jsonb` 列)之后,对象键的**原始书写顺序不再保证**:
|
|
* PostgreSQL 的 `jsonb` 按「先短后长、同长字典序」把每一层嵌套对象的键重新排列(实测见
|
|
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`)。
|
|
*
|
|
* `auto-runner` 逐字段求值时把已经算出来的值放进 `ctx.computed`,后面的字段能通过
|
|
* S-FEEL `when` 链读到前面字段的结果(`evalExpr` 唯一把 `computed` 摊进求值作用域的
|
|
* 分支 —— 见 `auto-expr.ts`)。这条依赖因此**只可能藏在 `when` 字符串里**:字段 A 的
|
|
* `when`(或它嵌套的 `then`/`else`/`lookup.by`/`cat`/`fmt`)引用了同一个 `fields` 对象
|
|
* 里另一个字段的名字,A 就依赖那个字段先算。
|
|
*
|
|
* 这个文件按**字段名之间的真实依赖**(而不是声明顺序 / jsonb 存取后的顺序)排出一个
|
|
* 安全的求值序 —— 与存储格式无关,`fields` 不必改成数组(那是破坏性契约变更)。
|
|
* 真正的环(A 依赖 B、B 依赖 A)排不出来,直接抛错 —— 不默默兜底,兜底正是这次出事的形态。
|
|
*
|
|
* **`AutoRecipe.let` 是同一个地基缺陷的第二个受害者**(2026-08-30 全分支终审 Important 1,
|
|
* H47 当时只治了 `fields` 那一半):`auto-runner.ts` 从前逐 `Object.entries(recipe.let)`
|
|
* 按声明顺序求值,而 `let` 同样住在这份 jsonb 里、同样会被重排。且 `let` 与 `fields` 的
|
|
* 依赖读法**不一样**——`fields` 的字段之间只能通过 `when` 字符串里的裸标识符互相看见
|
|
* (`{in:X}` 在 `fields` 里读的是 `lets`/`scope`,永远读不到同一个 `fields` 对象里的
|
|
* 另一个字段);而 `let` 的**官方读法就是 `{in:X}`**(见 `packages/contracts/src/
|
|
* auto-recipe.ts` 的注释:「先算一遍,后面所有表达式都能 {in:'名'} 引用」,运行时
|
|
* `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——`whenReferences` 故意不认
|
|
* 裸 `{in:X}` 是对的(对 `fields` 而言),但直接照搬给 `let` 用就会**漏掉最主要的那种
|
|
* 依赖**,排序等于没排。所以 `let` 用的是下面单独一份 `letReferences`/`orderLets`,
|
|
* 不是简单复用 `whenReferences`/`orderFields`。
|
|
*/
|
|
import type { AutoExpr } from '@contracts';
|
|
import { staticCheck } from '@sfeel';
|
|
import { topoOrder } from '@utils';
|
|
|
|
/**
|
|
* `noun` 让同一个错误类型给 `fields`(默认「字段」)与 `let`(见下面 `orderLets`)
|
|
* 各自说准话:两者的依赖读法不一样(`let` 多认 `{in:X}`,提示语也得多提这一句),
|
|
* 但都是"排不出安全求值序"这同一件事,不必另开一个类。
|
|
*/
|
|
export class AutoFieldCycleError extends Error {
|
|
constructor(
|
|
readonly cycle: readonly string[],
|
|
noun: '字段' | 'let' = '字段',
|
|
) {
|
|
const how = noun === 'let' ? '互相用 `{in:…}` 或 when 引用了对方' : '互相引用了对方';
|
|
super(`${noun}之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个${noun}是否${how})`);
|
|
this.name = 'AutoFieldCycleError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 递归找出表达式里全部 `when` 字符串引用的根路径名。
|
|
*
|
|
* 名字取 S-FEEL AST 的 `references`,不用正则扫标识符 —— 与 `static-audit.ts` 的
|
|
* `checkWhen` 同一个理由:正则会把 `count(...)` 这类内建函数名也当成字段名。
|
|
* `when` 语法本该在装包静态审那关就被拦住(`autoRecipeFindings` 的 `checkWhen`),
|
|
* 这里万一撞见解析不了的表达式,当作没有引用处理(宁可漏报不可误报)。
|
|
*
|
|
* **与 `activity-registry/internal/domain/static-audit.ts` 里的 `fieldReferences`
|
|
* 是同一个算法,写了两遍**:装包静态审(L4)不能 import 配方执行器(L5,模块分层
|
|
* 只准低层被高层引用),这一段小小的、纯粹依附 `AutoExpr` 形状的逻辑只能各自留一份;
|
|
* 真正通用、不含业务语义的图算法(`topoOrder`)已经提到 `@utils` 共用了。
|
|
* 改动这个函数时,记得看看那边要不要一起改。
|
|
*/
|
|
function whenReferences(expr: AutoExpr): readonly string[] {
|
|
if (typeof expr !== 'object' || expr === null) return [];
|
|
if ('when' in expr) {
|
|
const verdict = staticCheck(expr.when);
|
|
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
|
return [...names, ...whenReferences(expr.then), ...whenReferences(expr.else)];
|
|
}
|
|
if ('lookup' in expr) {
|
|
const byNames = Object.values(expr.by).flatMap((value) => whenReferences(value));
|
|
return expr.else === undefined ? byNames : [...byNames, ...whenReferences(expr.else)];
|
|
}
|
|
if ('cat' in expr) return expr.cat.flatMap((part) => whenReferences(part));
|
|
if ('fmt' in expr) return whenReferences(expr.fmt);
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 把 `fields` 排出一个安全的求值序:被依赖的字段排前面,返回 `[name, expr]` 有序对
|
|
* (调用方直接拿它替掉 `Object.entries(fields)`,别的都不用变)。
|
|
*
|
|
* 只认**同一个 fields 对象内**的字段名当依赖 —— 台账列名、`case:` 这类全局名字与
|
|
* 求值顺序无关(它们一直都在 `scope` 里,不经 `computed` 这条通道),排了反而可能把
|
|
* 「本笔内」不存在的依赖当真。找不出安全序(真正的环)抛 `AutoFieldCycleError`。
|
|
*/
|
|
export function orderFields(fields: Readonly<Record<string, AutoExpr>>): ReadonlyArray<readonly [string, AutoExpr]> {
|
|
const entries = Object.entries(fields);
|
|
const names = new Set(entries.map(([name]) => name));
|
|
const deps = new Map<string, readonly string[]>(
|
|
entries.map(([name, expr]) => [name, [...new Set(whenReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
|
);
|
|
const byName = new Map(entries);
|
|
|
|
const result = topoOrder(
|
|
entries.map(([name]) => name),
|
|
(name) => deps.get(name) ?? [],
|
|
);
|
|
if (!result.ok) throw new AutoFieldCycleError(result.cycle);
|
|
return result.order.map((name) => [name, byName.get(name)!] as const);
|
|
}
|
|
|
|
/**
|
|
* `letReferences` 与 `whenReferences` 几乎同一套遍历,只多认一种入口:
|
|
* **裸 `{in:X}` 本身就是依赖**——`let` 唯一的官方跨条目读法(见文件头注)。
|
|
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `whenReferences` 完全一致
|
|
* (`let` 的 `when` 子句同样进的是含 `...ctx.lets` 的合并作用域,见 `auto-expr.ts`
|
|
* `evalExpr` 最后那段 `scope` 拼装,裸标识符引用另一个 `let` 的名字同样成立)。
|
|
*
|
|
* **与 `activity-registry/internal/domain/static-audit.ts` 里的 `letReferences` 是
|
|
* 同一个算法,写了两遍**——理由与 `whenReferences` 头注同款(L4 装包静态审不能 import
|
|
* L5 配方执行器)。改动这个函数时,记得看看那边要不要一起改。
|
|
*/
|
|
function letReferences(expr: AutoExpr): readonly string[] {
|
|
if (typeof expr !== 'object' || expr === null) return [];
|
|
if ('in' in expr) return [expr.in];
|
|
if ('when' in expr) {
|
|
const verdict = staticCheck(expr.when);
|
|
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
|
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
|
|
}
|
|
if ('lookup' in expr) {
|
|
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
|
|
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
|
|
}
|
|
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
|
|
if ('fmt' in expr) return letReferences(expr.fmt);
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 把 `recipe.let` 排出一个安全的求值序 —— 与 `orderFields` 同一个用法(调用方直接拿它
|
|
* 替掉 `Object.entries(recipe.let)`),只是依赖读法换成 `letReferences`(认 `{in:X}`)。
|
|
* 找不出安全序(真正的环,如 `a: {in:'b'}, b: {in:'a'}`)抛 `AutoFieldCycleError`
|
|
* (`noun: 'let'`,提示语说的是 `let` 不是字段)。
|
|
*/
|
|
export function orderLets(lets: Readonly<Record<string, AutoExpr>>): ReadonlyArray<readonly [string, AutoExpr]> {
|
|
const entries = Object.entries(lets);
|
|
const names = new Set(entries.map(([name]) => name));
|
|
const deps = new Map<string, readonly string[]>(
|
|
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
|
);
|
|
const byName = new Map(entries);
|
|
|
|
const result = topoOrder(
|
|
entries.map(([name]) => name),
|
|
(name) => deps.get(name) ?? [],
|
|
);
|
|
if (!result.ok) throw new AutoFieldCycleError(result.cycle, 'let');
|
|
return result.order.map((name) => [name, byName.get(name)!] as const);
|
|
}
|