fix(auto): 配方字段按依赖拓扑求值 —— jsonb 会重排对象键,声明顺序靠不住

真跑 e2e 逼出来的地基缺陷。活动清单存在 sys.activity_package.manifest 这个
**jsonb 列**里,而 PG 按「先短后长、同长字典序」重排所有嵌套对象的键。
auto-runner 原先逐字段**按声明顺序**求值、后面的字段靠 ctx.computed 看前面的值 ——
顺序一乱,依赖就断,而且**一声不响**。

W05 实证:judgement_class(15B)被排到它依赖的 difference_status(17B)前面,
求值时后者恒 undefined、when 三级全落空,摔进 constraint_type 兜底档 ——
7 行差异结论全判错,与实际状态无关;而 difference_status 走
lookup:'result:items' 不依赖字段序,所以它是对的。「一个对一个全错」由此而来。
隔离测试复现不出来,因为它直接读 dist/manifest.json,从不经 DB 往返。

修法:字段求值改为**依赖拓扑排序**(Kahn),不用迭代到不动点 ——
后者的数值收敛判据会漏掉「A/B 互相 when 为假、恰好在错误状态下稳定」这种
结构有环却数值收敛的配方;拓扑是结构性判定,不会假阴性。
通用图算法 topoOrder 提进 @utils(契约新增,非破坏);
AutoExpr→依赖名提取因 L4 不能 import L5,在 loop-engine 与 activity-registry
各留一份并互相指路。真正的环由装包静态审当场拒装 —— 不兜底,兜底正是这次出事的形态。

变异验证:git stash 只撤 auto-runner.ts 那一处,两条新测试当场变红,
数值与专查报告的 SQL 转录逐字吻合(使能阈值/match 被误判 hard_mismatch/medium);
stash pop 后 37/37 绿。E04 92/92、I01 55/55 全绿;全仓 2447 测通过。

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 07:05:48 +08:00
co-authored by Claude Opus 5
parent 67fa226380
commit 6636abfc39
10 changed files with 840 additions and 6 deletions
@@ -0,0 +1,82 @@
/**
* 配方字段求值序 —— 纯函数,零 IO(编码规范 §5.3)。
*
* `AutoAction.fields` / `analyze.input.fields` 存进 `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)排不出来,直接抛错 —— 不默默兜底,兜底正是这次出事的形态。
*/
import type { AutoExpr } from '@contracts';
import { staticCheck } from '@sfeel';
import { topoOrder } from '@utils';
export class AutoFieldCycleError extends Error {
constructor(readonly cycle: readonly string[]) {
super(`字段之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`);
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);
}
@@ -10,7 +10,8 @@
* ③ **出错 = 交人,不重试、不退回模型**:配方抛错是活动包的 bug。
* 退回模型就是让它去凑,那正是这套机制要消灭的东西。
*/
import type { AutoAction, AutoRecipe, StaffId, TaskId } from '@contracts';
import type { AutoAction, AutoExpr, AutoRecipe, StaffId, TaskId } from '@contracts';
import { AutoFieldCycleError, orderFields } from '../domain/auto-order.ts';
import { evalExpr, evalText, rowsAt, type ExprCtx } from '../domain/auto-expr.ts';
import { expandSource } from '../domain/auto-source.ts';
import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts';
@@ -71,6 +72,20 @@ function nameList(names: readonly string[]): string {
return names.length <= 8 ? names.join('、') : `${names.slice(0, 8).join('、')}…等 ${names.length} 行`;
}
/**
* `orderFields` 的环报错翻成 `AutoRecipeError`(与本文件其余显式抛错同一个类 ——
* 外层 try/catch 只按 §头注③「出错 = 交人」一条处置,不分错误类型)。
* 真正的环本该在装包静态审那关就被拦下(`autoRecipeFindings`),这里只是最后一道兜底。
*/
function safeFieldOrder(fields: Readonly<Record<string, AutoExpr>>, index: number): ReadonlyArray<readonly [string, AutoExpr]> {
try {
return orderFields(fields);
} catch (err) {
if (err instanceof AutoFieldCycleError) throw new AutoRecipeError(index, err.message);
throw err;
}
}
/** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */
export function recipeIssuesCard(recipe: AutoRecipe): boolean {
return recipe.do.some((action) => action.kind === 'card');
@@ -133,9 +148,18 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
let localUnkeyed = 0;
/** 撤掉的是哪几行 —— 「撤要说出来、要点名」(2026-08-02 为人工撤行立的规矩,配方这一侧跟上,M2) */
const dropNames: string[] = [];
/*
* **字段按依赖排序求值,不按声明/存储顺序**(2026-08-30,修 jsonb 重排 bug ——
* 见 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`)。
* `fields` 经一趟 `sys.activity_package.manifest`(jsonb 列)存取,对象键的原始
* 书写顺序不再保证;`orderFields` 按字段名之间的真实依赖(`when` 链互相引用)
* 排出一个安全序,与存储格式无关。只算一次 —— 排序结果与 `row` 无关,
* `action.fields` 的结构对每一行都一样。
*/
const fieldOrder = safeFieldOrder(action.fields, index);
for (const { row, outer } of expandSource(action.from, base)) {
const computed: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(action.fields)) {
for (const [name, expr] of fieldOrder) {
computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed });
}
const key: Record<string, string> = {};
@@ -228,9 +252,11 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
}
if (action.kind === 'analyze') {
/* 同一个 jsonb 重排问题也可能出在 analyze 的入参字段上(同一种「对象字面量当求值计划」的写法) */
const inputFieldOrder = safeFieldOrder(action.input.fields, index);
const payload = expandSource(action.input.from, base).map(({ row, outer }) => {
const item: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(action.input.fields)) {
for (const [name, expr] of inputFieldOrder) {
item[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed: item });
}
return item;