Archived
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:
@@ -7,9 +7,10 @@
|
||||
* 需要查库的两项(工具契约是否已注册、模型名是否已绑定)由 usecase 补进来 ——
|
||||
* 本文件只做**纯**判定。
|
||||
*/
|
||||
import type { ActivityManifest, StaticAuditFinding } from '@contracts';
|
||||
import type { ActivityManifest, AutoExpr, StaticAuditFinding } from '@contracts';
|
||||
/* domain 只准 @contracts / @sfeel / @utils(编码规范 §5.3)—— S-FEEL 是纯函数包,零 IO */
|
||||
import { staticCheck } from '@sfeel';
|
||||
import { topoOrder } from '@utils';
|
||||
|
||||
export type ExternalChecks = {
|
||||
/** 声明引用但工具箱里没有的工具 */
|
||||
@@ -378,6 +379,58 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归找出表达式里全部 `when` 字符串引用的根路径名(用于判「这个字段依赖同笔内的哪几个字段」)。
|
||||
*
|
||||
* 名字取 S-FEEL AST 的 `references`,不用正则扫标识符 —— 与 `checkWhen` 同一个理由:
|
||||
* 正则会把 `count(...)` 这类内建函数名也当成字段名。`when` 语法本该被 `checkWhen` 先一步
|
||||
* 拦住,这里万一撞见解析不了的表达式,当作没有引用处理(宁可漏报不可误报)。
|
||||
*
|
||||
* **与 `loop-engine/internal/domain/auto-order.ts` 里的 `whenReferences` 是同一个算法,
|
||||
* 写了两遍**:装包静态审(L4 `activity-registry`)不能 import 配方执行器(L5
|
||||
* `loop-engine`,模块分层只准低层被高层引用,§4),这一段小小的、纯粹依附 `AutoExpr`
|
||||
* 形状的逻辑只能各自留一份;真正通用、不含业务语义的图算法(`topoOrder`)已经
|
||||
* 提到 `@utils` 共用了。改动这个函数时,记得看看那边要不要一起改。
|
||||
*/
|
||||
function fieldReferences(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, ...fieldReferences(expr.then), ...fieldReferences(expr.else)];
|
||||
}
|
||||
if ('lookup' in expr) {
|
||||
const byNames = Object.values(expr.by).flatMap((value) => fieldReferences(value));
|
||||
return expr.else === undefined ? byNames : [...byNames, ...fieldReferences(expr.else)];
|
||||
}
|
||||
if ('cat' in expr) return expr.cat.flatMap((part) => fieldReferences(part));
|
||||
if ('fmt' in expr) return fieldReferences(expr.fmt);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* `fields` 内部字段求值依赖能不能排出安全序(与运行时 `orderFields` 同一件事,见上面
|
||||
* `fieldReferences` 的头注)。排得出来给 `undefined`;排不出来(真正的环)把牵涉的字段名
|
||||
* 摆出来 —— **这条错误 2026-08-30 才补上**:jsonb 会把 `fields` 对象的键序打乱,
|
||||
* `judgement_class`/`risk_level` 这类依赖同笔内另一个字段的写法一旦被排到依赖对象前面,
|
||||
* 求值时读到的是 `undefined`,`when` 链全部落空却不报错(详见
|
||||
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`);
|
||||
* 运行时已经改成按依赖排序求值,但**真正的环**(A 依赖 B、B 又依赖 A,排不出任何安全序)
|
||||
* 是配方写错了 —— 别留到运行时才炸,装包这一刻就能看出来。
|
||||
*/
|
||||
function fieldsCycle(fields: Readonly<Record<string, AutoExpr>>): readonly string[] | undefined {
|
||||
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(fieldReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||
);
|
||||
const result = topoOrder(
|
||||
entries.map(([name]) => name),
|
||||
(name) => deps.get(name) ?? [],
|
||||
);
|
||||
return result.ok ? undefined : result.cycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* **配方静态审**(2026-08-29)—— 与 `refersTo` 目标检查同款:**写错地址 = 每一单都错**。
|
||||
*
|
||||
@@ -544,6 +597,22 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
|
||||
});
|
||||
}
|
||||
}
|
||||
/*
|
||||
* **字段求值依赖不能成环**(2026-08-30)。`fields` 存进 jsonb 之后键序不再保证,
|
||||
* 运行时已经按依赖排出安全序求值(见上面 `fieldsCycle` 的头注),但 A 依赖 B、
|
||||
* B 又依赖 A 排不出任何安全序 —— 那是配方写错了,装的时候就该拒,不留到运行时才炸。
|
||||
*/
|
||||
{
|
||||
const cycle = fieldsCycle(action.fields);
|
||||
if (cycle !== undefined) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `${action.into} 的 fields 之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`,
|
||||
at: where,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action.kind === 'analyze') {
|
||||
walkSource(action.input.from, where);
|
||||
@@ -551,6 +620,17 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
|
||||
if (!manifest.declarations.models.includes(action.model)) {
|
||||
out.push({ code: 'schema-invalid', severity: 'error', message: `配方用的模型没在 declarations.models 里声明:${action.model}`, at: where });
|
||||
}
|
||||
{
|
||||
const cycle = fieldsCycle(action.input.fields);
|
||||
if (cycle !== undefined) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `analyze 入参字段之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`,
|
||||
at: where,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action.kind === 'card') {
|
||||
for (const address of action.gateLedgers ?? []) checkAddress(address, where);
|
||||
|
||||
Reference in New Issue
Block a user