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
+22
View File
@@ -10,3 +10,25 @@ export declare function isDefined<T>(value: T | null | undefined): value is T;
export declare function groupBy<T, K extends string>(items: readonly T[], key: (item: T) => K): Record<K, T[]>;
/** 稳定排序的键值对遍历(避免 Object.keys 顺序依赖) */
export declare function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]>;
export type TopoOrderResult = {
readonly ok: true;
readonly order: readonly string[];
} | {
readonly ok: false;
readonly cycle: readonly string[];
};
/**
* 拓扑排序(Kahn 算法)—— 给定节点集合与「这个节点依赖谁」,排出一个安全的处理序
* (被依赖的排前面)。
*
* 同族算法已有一份在 `apps/api/src/modules/process/internal/domain/routing.ts` 的
* `advanceOrder`(那是流程图专用的,找不齐拓扑序时把剩下的节点原样缀在末尾 ——
* 流程图的容错策略)。这里是它的**通用版**,故意搬到叶子包:两个不同层级的模块
* (`activity-registry` 装包静态审 / `loop-engine` 配方求值)都要用同一个算法判「排不排得出来」,
* 而模块分层不许它们互相 import ——图算法本身不含业务语义,该住在 `@utils`。
*
* **不兜底**:排不出来就是这几个节点之间有环,原样报出来,由调用方决定怎么处置
* (装包时机检报错 / 运行时交人),不在这一层替调用方决定「怎么办」。
* `dependsOn` 返回的名字如果不在 `nodes` 里,当作没这条依赖(忽略,不会因此误报成环)。
*/
export declare function topoOrder(nodes: readonly string[], dependsOn: (node: string) => readonly string[]): TopoOrderResult;
@@ -670,4 +670,115 @@ describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', ()
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x' }] }));
expect(findings.some((f) => f.severity === 'error' && f.message.includes('没有 basis'))).toBe(true);
});
/*
* ── 字段求值依赖成环(2026-08-30)──────────────────────────────────────
*
* `fields` 存进 jsonb 之后键序不再保证:`judgement_class` 依赖同笔内的
* `difference_status`,一旦被排到它前面,`when` 链求值恒读到 `undefined`,
* 三级判断全部落空却不报错(见 `judgement-class-probe.md`)。运行时已经改成
* 按依赖排序求值,但 A 依赖 B、B 又依赖 A 排不出任何安全序 —— 那是配方写错了,
* 装的时候就该拒。`demo.record` 默认只有 case_ref/note 两列,这里额外声明 a/b
* 两列,专给这组用例摆弄依赖关系(不然「列名不在声明里」那条审读会先报,污染断言)。
*/
const withExtraColumns = (auto: unknown) => {
const base = manifestFixture();
const [record] = base.declarations.ledger;
return {
...base,
declarations: {
...base.declarations,
ledger: [
{
...record,
fields: [...(record?.fields ?? []), { name: 'a', type: 'text', primaryKey: false }, { name: 'b', type: 'text', primaryKey: false }],
},
],
},
assets: {
...base.assets,
businessModel: {
...base.assets.businessModel,
steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }],
},
},
} as never;
};
it('project.fields 两个字段互相依赖(a 的 when 提到 b、b 的 when 提到 a)→ error,点名两个字段', () => {
const findings = autoRecipeFindings(
withExtraColumns({
do: [
{
kind: 'project',
into: 'demo.record',
from: { once: true },
fields: { case_ref: 'x', a: { when: 'b = "y"', then: '1', else: '0' }, b: { when: 'a = "y"', then: '1', else: '0' } },
},
],
}),
);
const hit = findings.find((f) => f.severity === 'error' && f.message.includes('循环依赖'));
expect(hit).toBeDefined();
expect(hit?.message).toContain('a');
expect(hit?.message).toContain('b');
});
it('project.fields 链式依赖(不是环,只是 b 引用 a)→ 不报循环依赖', () => {
const findings = autoRecipeFindings(
withExtraColumns({
do: [
{
kind: 'project',
into: 'demo.record',
from: { once: true },
fields: { case_ref: 'x', a: 'seed', b: { when: 'a = "seed"', then: '1', else: '0' } },
},
],
}),
);
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
});
it('analyze 入参字段成环 → error(与 project.fields 同一条审读)', () => {
const findings = autoRecipeFindings(
withExtraColumns({
do: [
{
kind: 'analyze',
model: 'm',
into: 'items',
instruction: 'x',
input: {
from: { once: true },
fields: { a: { when: 'b = "y"', then: '1', else: '0' }, b: { when: 'a = "y"', then: '1', else: '0' } },
},
output: { type: 'object' },
},
],
}),
);
expect(findings.some((f) => f.severity === 'error' && f.message.includes('循环依赖'))).toBe(true);
});
it('三级依赖链(与真实 W05 的 difference_status → judgement_class → risk_level 同形)→ 不报循环依赖', () => {
const findings = autoRecipeFindings(
withExtraColumns({
do: [
{
kind: 'project',
into: 'demo.record',
from: { once: true },
fields: {
case_ref: 'x',
note: { when: 'a = "hit"', then: '1', else: '0' },
a: { when: 'b = "hit"', then: 'hit', else: 'miss' },
b: 'hit',
},
},
],
}),
);
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
});
});
@@ -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);
@@ -0,0 +1,121 @@
/**
* 配方字段求值序 —— `orderFields` 的单元测试。
*
* 背景见 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`:
* `fields: {...}` 经一趟 jsonb 存取后键序不再保证,`auto-runner` 逐字段求值本依赖
* 「声明序 = 依赖序」这条隐藏假设。这里直接测 `orderFields` 本身(端到端的
* 「模拟 jsonb 重排 + 真跑 auto-runner」回归测试在 `auto-runner.test.ts`)。
*/
import { describe, expect, it } from 'vitest';
import type { AutoExpr } from '@contracts';
import { AutoFieldCycleError, orderFields } from '../internal/domain/auto-order.ts';
/** 只看排出来的字段名序列,不看表达式本身 */
const names = (fields: Readonly<Record<string, AutoExpr>>) => orderFields(fields).map(([name]) => name);
describe('orderFields', () => {
it('没有 when 的字段:谁先谁后都行,数量对得上就好', () => {
const fields: Record<string, AutoExpr> = { b: { col: 'x' }, a: 'literal', c: { in: 'y' } };
expect(names(fields).sort()).toEqual(['a', 'b', 'c']);
});
it('B 依赖 A(when 里提到 A 的名字):A 排到 B 前面,不管声明序', () => {
// 声明序故意把依赖方 b 摆在被依赖方 a 前面 —— 就是 jsonb 重排会制造出的形状
const fields: Record<string, AutoExpr> = {
b: { when: 'a = "x"', then: 'hit', else: 'miss' },
a: 'x',
};
const order = names(fields);
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
});
/* 逐字照抄 activities/part-substitution-assessment 的 S2_assess 那笔真实配方的依赖形状:
difference_status(叶子)→ judgement_class(依赖 difference_status)→ risk_level(依赖 judgement_class) */
it('三级链:C 依赖 B、B 依赖 A —— 排出 A、B、C,不管输入是哪种乱序', () => {
const chain: Record<string, AutoExpr> = {
/* A:不依赖同笔任何字段 */
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
/* B:when 链条里提到 A */
judgement_class: {
when: 'difference_status = "match"',
then: 'match',
else: { when: 'difference_status = "unknown"', then: 'unknown', else: 'mismatch' },
},
/* C:when 里提到 B */
risk_level: { when: 'judgement_class = "match"', then: 'low', else: 'high' },
};
// 用「先短后长、同长字典序」的 jsonb 重排顺序喂进去(与生产实测的重排结果同构)
const jsonbLike: Record<string, AutoExpr> = {};
for (const key of ['risk_level', 'judgement_class', 'difference_status']) jsonbLike[key] = chain[key] as AutoExpr;
const order = names(jsonbLike);
expect(order.indexOf('difference_status')).toBeLessThan(order.indexOf('judgement_class'));
expect(order.indexOf('judgement_class')).toBeLessThan(order.indexOf('risk_level'));
});
it('依赖藏在 lookup.by / else 里也认得出', () => {
const fields: Record<string, AutoExpr> = {
target: { when: 'source = "x"', then: 'hit', else: 'miss' },
source: { lookup: 'result:items', by: { k: 'v' }, take: 't', else: { when: 'flag = "y"', then: 'a', else: 'b' } },
flag: 'y',
};
const order = names(fields);
expect(order.indexOf('source')).toBeLessThan(order.indexOf('target'));
expect(order.indexOf('flag')).toBeLessThan(order.indexOf('source'));
});
it('依赖藏在 cat / fmt 里也认得出', () => {
const fields: Record<string, AutoExpr> = {
label: { cat: ['前缀-', { when: 'base != ""', then: { fmt: { in: 'x' }, as: 'digits' }, else: 'x' }] },
base: '5',
};
const order = names(fields);
expect(order.indexOf('base')).toBeLessThan(order.indexOf('label'));
});
it('只认同一个 fields 对象内的名字 —— 引用台账列名 / 基座读数这类全局名字不算依赖,不参与排序', () => {
const fields: Record<string, AutoExpr> = {
a: { when: 'some_ledger_column = "x"', then: '1', else: '0' },
b: { when: 'sub_eval_part_rows > 0', then: '1', else: '0' },
};
// 两个字段互不相干,不该抛错,数量对得上即可
expect(names(fields).sort()).toEqual(['a', 'b']);
});
it('真正的环(A 依赖 B、B 依赖 A):抛 AutoFieldCycleError,不默默给错答案', () => {
const fields: Record<string, AutoExpr> = {
a: { when: 'b = "x"', then: '1', else: '0' },
b: { when: 'a = "x"', then: '1', else: '0' },
};
expect(() => orderFields(fields)).toThrow(AutoFieldCycleError);
try {
orderFields(fields);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(AutoFieldCycleError);
expect([...(err as AutoFieldCycleError).cycle].sort()).toEqual(['a', 'b']);
expect((err as Error).message).toContain('循环依赖');
}
});
/*
* **自引用不算环,故意的**:字段 `a` 的 `when` 提到 `a` 时,那个名字未必是「我自己算出来的值」——
* `evalExpr` 的合并作用域里 `row`(源行本身的列)也在,常见写法是「拿源行同名列做校验后原样投影」
* (如 `status: { when: 'status = "active"', then: 'A', else: 'B' }`,这里的 `status` 指的是
* `ctx.row.status`)。把自引用当环拦下会把这种常见写法误判成装不上的坏配方 ——
* 排序对它也没有意义(不管排在哪一位,求值那一刻自己的 `computed` 值必然还没写进去,
* 这是求值时序的必然,不是「顺序没排对」)。
*/
it('自己依赖自己(when 里提到自己的名字)不算环 —— 常见写法是拿源行同名列做校验', () => {
const fields: Record<string, AutoExpr> = { a: { when: 'a = "x"', then: '1', else: '0' } };
expect(() => orderFields(fields)).not.toThrow();
expect(names(fields)).toEqual(['a']);
});
it('返回的 [name, expr] 就是原表达式(调用方拿它当 Object.entries 的替代品)', () => {
const expr: AutoExpr = { col: 'pn' };
const out = orderFields({ only: expr });
expect(out).toEqual([['only', expr]]);
});
});
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { AutoRecipe } from '@contracts';
import type { AutoExpr, AutoRecipe } from '@contracts';
import { taskIdSchema, staffIdSchema } from '@contracts';
import { evaluateCondition } from '@sfeel';
import { deriveSide, structuredLog } from '@modules/memory';
import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '@modules/memory';
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
@@ -735,3 +736,245 @@ describe('prune:人在填的时候不撤行(F3)', () => {
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
});
});
/**
* **回归:jsonb 打乱 `fields` 键序,`project` 的求值结果必须与原声明序一致**(2026-08-30)。
*
* 背景(专查报告 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`):
* `AutoAction.fields` 存进 `sys.activity_package.manifest`(`jsonb` 列)之后,PostgreSQL
* 按「先短后长、同长字典序」重排对象键 —— `judgement_class` 依赖同笔内先算出来的
* `difference_status`,一旦被排到它前面,`ctx.computed.difference_status` 求值时恒为
* `undefined`,三级 `when` 链全部落空,摔进 `constraint_type` 兜底档
* (`hard_mismatch`/`soft_mismatch`)—— **与这一行真实的差异状态毫无关系**;
* `risk_level` 依赖 `judgement_class`,是同一套机制的第二层级联。
*
* 下面 `DIFFERENCE_ITEM_FIELDS` **逐字段照抄**
* `activities/part-substitution-assessment/src/case-model.ts`(2026-08-30 一份)里
* `S2_assess` 步「project into substitution.difference_item」那一笔的真实配方 ——
* `apps/**` 与 `activities/**` 互不静态 import(编码规范 §2.1),depcruise 会拦真 import,
* 只能手抄;`from`/`prune` 为测试自洽而简化,不影响所测的字段求值序问题。
* 主键(`substitution_relation_id`+`difference_item_id`)与 `judgement_class`/`risk_level`
* 两列的 tally 取值抄自同一活动包的 `manifest.ts` 声明。
*/
describe('回归 · jsonb 打乱 fields 键序,project 求值仍要与原序一致(2026-08-30)', () => {
/** 与 `case-model.ts` 里的 `judgement_class`/`risk_level` 一字不差 —— 三级 when 链依赖同笔内的 difference_status,risk_level 依赖同笔内的 judgement_class */
const DIFFERENCE_ITEM_FIELDS: Record<string, AutoExpr> = {
substitution_relation_id: { in: 'relationId' },
difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] },
parameter_name: { col: 'param_name' },
parameter_desc: { col: 'param_desc' },
parameter_unit: { col: 'param_unit' },
constraint_type: { col: 'constraint_type' },
original_value: {
lookup: 'parts.parameter',
by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } },
take: 'param_value',
else: '',
},
candidate_value: {
lookup: 'parts.parameter',
by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } },
take: 'param_value',
else: '',
},
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
/* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 —— 依赖同笔的 difference_status */
judgement_class: {
when: 'difference_status = "match"',
then: 'match',
else: {
when: 'difference_status = "unknown"',
then: 'unknown',
else: {
when: 'difference_status = "conditional"',
then: 'conditional',
else: { when: 'constraint_type = "hard"', then: 'hard_mismatch', else: 'soft_mismatch' },
},
},
},
/* 依赖同笔的 judgement_class */
risk_level: {
when: 'judgement_class = "hard_mismatch"',
then: 'high',
else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' },
},
rule_version: 'RULE-V1',
};
/** PostgreSQL jsonb 对对象键的重排规则:先按字节长度升序,同长按字典序 —— 下一条用例直接核对
* 这份模拟与专查报告里从库现查出来的真实顺序逐字节一致(不是「差不多像」,是同一个函数)。*/
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;
}
it('(加分)reorderLikeJsonb 复现的顺序与生产实测(报告从库里现查出来的那一串)逐字一致', () => {
expect(Object.keys(reorderLikeJsonb(DIFFERENCE_ITEM_FIELDS))).toEqual([
'risk_level',
'rule_version',
'evidence_note',
'original_value',
'parameter_desc',
'parameter_name',
'parameter_unit',
'candidate_value',
'constraint_type',
'judgement_class',
'impact_statement',
'difference_status',
'difference_item_id',
'unknown_disposition',
'substitution_relation_id',
]);
});
/** 七行真实数据 + SQL 转录,来自专查报告「最终落库数据」那一节(6 match + 1 conditional) */
const REAL_PARAM_ROWS: Array<Record<string, string>> = [
{ param_name: '使能阈值', param_desc: '欠压保护阈值', param_unit: 'V', constraint_type: 'hard' },
{ param_name: '工作温度', param_desc: '结温范围', param_unit: '℃', constraint_type: 'hard' },
{ param_name: '开关频率', param_desc: '振荡频率', param_unit: 'kHz', constraint_type: 'soft' },
{ param_name: '输入电压范围', param_desc: '输入电压范围', param_unit: 'V', constraint_type: 'hard' },
{ param_name: '输出电压', param_desc: '输出电压', param_unit: 'V', constraint_type: 'hard' },
{ param_name: '输出电流', param_desc: '最大输出电流', param_unit: 'A', constraint_type: 'hard' },
{ param_name: '静态电流', param_desc: '静态电流', param_unit: 'µA', constraint_type: 'soft' },
];
/** 补三行合成数据 —— 七行真实数据全落在 match/conditional,覆盖不到 unknown 与 mismatch 两支 */
const SYNTHETIC_PARAM_ROWS: Array<Record<string, string>> = [
{ param_name: 'test_unknown_param', param_desc: '(补充覆盖 unknown 分支)', param_unit: '', constraint_type: 'soft' },
{ param_name: 'test_hard_mismatch_param', param_desc: '(补充覆盖 hard_mismatch 分支)', param_unit: '', constraint_type: 'hard' },
{ param_name: 'test_soft_mismatch_param', param_desc: '(补充覆盖 soft_mismatch 分支)', param_unit: '', constraint_type: 'soft' },
];
const PARAM_ROWS = [...REAL_PARAM_ROWS, ...SYNTHETIC_PARAM_ROWS];
/** 对应的 AI 出参(`result:items`)—— 七行抄自专查报告的落库数据,三行合成的覆盖剩余分支 */
const RESULT_ITEMS = [
{ param_name: '使能阈值', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
{ param_name: '工作温度', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
{ param_name: '开关频率', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
{ param_name: '输入电压范围', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
{ param_name: '输出电压', difference_status: 'conditional', impact_statement: '需外围电路验证', evidence_note: '', unknown_disposition: '' },
{ param_name: '输出电流', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
{ param_name: '静态电流', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
{ param_name: 'test_unknown_param', difference_status: 'unknown', impact_statement: '', evidence_note: '', unknown_disposition: '待实测' },
{ param_name: 'test_hard_mismatch_param', difference_status: 'mismatch', impact_statement: '不满足硬约束', evidence_note: '', unknown_disposition: '' },
{ param_name: 'test_soft_mismatch_param', difference_status: 'mismatch', impact_statement: '不满足软约束', evidence_note: '', unknown_disposition: '' },
];
/*
* 正确答案(不是猜的,是照 `judgement_class`/`risk_level` 的 when 链手算的):
* difference_status=match → judgement_class=match → risk_level=low
* difference_status=conditional → judgement_class=conditional → risk_level=medium(两条 when 都不中)
* difference_status=unknown → judgement_class=unknown → risk_level=medium
* difference_status=mismatch 且 constraint_type=hard → judgement_class=hard_mismatch → risk_level=high
* difference_status=mismatch 且 constraint_type=soft → judgement_class=soft_mismatch → risk_level=medium
*/
const EXPECTED: Record<string, { judgement_class: string; risk_level: string }> = {
使能阈值: { judgement_class: 'match', risk_level: 'low' },
工作温度: { judgement_class: 'match', risk_level: 'low' },
开关频率: { judgement_class: 'match', risk_level: 'low' },
输入电压范围: { judgement_class: 'match', risk_level: 'low' },
输出电压: { judgement_class: 'conditional', risk_level: 'medium' },
输出电流: { judgement_class: 'match', risk_level: 'low' },
静态电流: { judgement_class: 'match', risk_level: 'low' },
test_unknown_param: { judgement_class: 'unknown', risk_level: 'medium' },
test_hard_mismatch_param: { judgement_class: 'hard_mismatch', risk_level: 'high' },
test_soft_mismatch_param: { judgement_class: 'soft_mismatch', risk_level: 'medium' },
};
/**
* 专用的一次性 harness:**真 `createAutoRunner`**,`sfeel.condition` 接的是**真**
* `@sfeel` 的 `evaluateCondition`(不是本文件顶部那个只认三句话的桩)——
* `judgement_class`/`risk_level` 的三级 `when` 链要真求值,不能假装。
*/
function makeHarness(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: 'substitution',
object: 'difference_item',
label: '差异明细',
keys: ['substitution_relation_id', 'difference_item_id'],
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 —— 用 seed 直接铺 result:items,不经 analyze 那一笔')) },
sfeel: {
condition: (expr: string, scope: Record<string, unknown>) => evaluateCondition(expr, scope),
expression: () => undefined,
unaryTest: () => false,
},
};
const ctx = { taskId: TASK, stepId: 'S2_assess', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法
return { runner: createAutoRunner(deps as never), drafts, ctx };
}
const recipeOf = (fields: Record<string, AutoExpr>): AutoRecipe => ({
do: [{ kind: 'project', into: 'substitution.difference_item', from: { rows: PARAM_ROWS }, fields }],
});
it('project 求出的每一行、每一个字段,声明序与打乱成 jsonb 重排序**逐字段完全相同**', async () => {
const seed = { relationId: 'REL-1', 'result:items': RESULT_ITEMS };
const declared = makeHarness(seed);
const declaredOut = await declared.runner.run(recipeOf(DIFFERENCE_ITEM_FIELDS), declared.ctx);
const shuffled = makeHarness(seed);
const shuffledOut = await shuffled.runner.run(recipeOf(reorderLikeJsonb(DIFFERENCE_ITEM_FIELDS)), shuffled.ctx);
expect(declaredOut.failedAt).toBeUndefined();
expect(shuffledOut.failedAt).toBeUndefined();
const rowsByPn = (h: ReturnType<typeof makeHarness>) =>
new Map([...h.drafts.values()].map((d) => [String(d.patch['parameter_name']), d.patch]));
const a = rowsByPn(declared);
const b = rowsByPn(shuffled);
expect(a.size).toBe(PARAM_ROWS.length);
expect(b.size).toBe(PARAM_ROWS.length);
for (const [pn, patchFromDeclaredOrder] of a) {
expect(b.get(pn)).toEqual(patchFromDeclaredOrder);
}
});
it('且求出来的是对的:四态照 difference_status 走,不是清一色摔进 hard_mismatch(bug 复现的具体形状)', async () => {
const seed = { relationId: 'REL-1', 'result:items': RESULT_ITEMS };
const shuffled = makeHarness(seed);
await shuffled.runner.run(recipeOf(reorderLikeJsonb(DIFFERENCE_ITEM_FIELDS)), shuffled.ctx);
const rowsByPn = new Map([...shuffled.drafts.values()].map((d) => [String(d.patch['parameter_name']), d.patch]));
for (const [pn, expected] of Object.entries(EXPECTED)) {
const patch = rowsByPn.get(pn);
expect(patch, `${pn} 这一行没写下来`).toBeDefined();
expect(patch?.['difference_status'], `${pn} 的 difference_status`).toBe(
RESULT_ITEMS.find((r) => r.param_name === pn)?.difference_status,
);
expect(patch?.['judgement_class'], `${pn} 的 judgement_class`).toBe(expected.judgement_class);
expect(patch?.['risk_level'], `${pn} 的 risk_level`).toBe(expected.risk_level);
}
});
});
@@ -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;
+43
View File
@@ -1499,3 +1499,46 @@ declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `aut
名字/内容**,且改动发生在**该表第一次被本单新建一行**前后,先怀疑这条坑,查该主键是否
`type:'integer'` 且没有其它非 auto 主键列(单主键、无前缀的场景下,`resolveRowKey` 续出的
号从 `1` 起,最容易撞上种子数据里最早的几行)。
## `prune` 与「数据工单确认即入库」的架构性冲突(2026-08-30,ACT-WORK-05 案例 2 真跑撞见)
**现象**:数据工单**确认落地之后**再改动会影响行集合的字段(如 W05 的芯片类型),
`project` 动作的 `prune: true` 要把「新计算出的集合里没有」的陈旧行撤掉——而那些行如果
已经因为「数据工单确认即入库」(`landsOnConfirm`,2026-08-27)落进了正式表,`draftDrop`
在 `apps/api/src/app/wiring/engines.ts:1217-1231` 那道显式检查前会被拒
(`「…已入库的,撤不掉;要改由人在件上改」`),配方抛 `AutoRecipeError`,这一步再也办不下去,
单子吊死在 `awaiting`,只能靠人工干预。
复现路径(W05 `part-substitution-assessment@2.1.5`,案例「两颗都在库但分属不同芯片类型」):
①人对着两颗类型不一致但各自字段都齐的料,第一次提交(哪怕什么都没改,提交闸也放行,
因为两颗料本就不缺格子)——这一步**已经落地**,把其中一颗按它*当时*的类型字段集
(如 7 项)写进了正式表;②人接着把它转挂到*另一个*类型(字段集不同,如 5 项)、补上新类型
要求的字段;③配方按新类型重新铺 `parts.parameter` 骨架,`prune:true` 想把①落地的那 7 项里、
不在新的 5 项字段集中的几项(如 3 项)撤掉——它们已经落地,③在这里被 `engines.ts:1217`
拒,`AutoRecipeError`。
**这是真人会走的路径,不是测试造出来的**:人确认一张数据工单之后才发现类型挑错了、
需要改类型,是完全正常的操作顺序。
**两条机制各自都对**:`prune` 是活动清理陈旧行的正当手段(design spec §4.1 就是这么设计的:
「计算出的新集合就是这张表的全部,多出来的撤掉」);「已落地行不许 `draftDrop`」是防止
撤掉之后任务视图少一行、`mine:`/`_rows` 这类读数说谎。**合起来对同一批行形成死锁**:
`prune` 想撤、平台不让撤。
与 2026-08-30 已记的另一条(**范围放行没有对 `refersTo` 做传递闭包**,见「ACT-WORK-05 走查
逼出来的三条平台欠账」附近的记录)同族——两条都是「数据工单确认即入库」这个 2026-08-27
新加的机制与**别的**既有机制(`prune`、`refersTo` 的传递闭包)之间没有对齐接缝造成的。
**要治**(方向,未验证,留给真正动手时评估):
- 已落地行改为「先撤『已落地』记号,再撤行」——即 `prune` 遇到已落地行时先做等价于
`markLanded` 的反操作(清 `landed_at`),把它重新变回「本单还没最终确定」的草稿状态,
再走正常的 `draftDrop`;risk:这一改会让正式表暂时保留一条其实已经不该存在的行,
直到下一次真正放行(`releaseWrite`)才会被撤下——需要确认这段「暂时不一致」窗口对下游
(读表的其它任务、报表)是否可接受。
- 或者 `prune` 对已落地行降级成「跳过、留着但记一条观察」,不当错误——把「这几行已经落地、
这一笔没法帮你清」的实情说给人听,交给他在**放行那一步**的判据/清单里手动确认要不要紧,
而不是让整单卡死。
**排查法留一条**:「配方办不下去」类的 `AutoRecipeError`,若原文提到「已入库/已落地…撤不掉」,
先看是不是这条——尤其当那一步的 `project` 动作带 `prune: true`、且它写向的表**也**在
某张数据工单的 `gateLedgers` 上时,概率最高。
+58 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { assertNever, groupBy, isDefined, sortedEntries } from '../src/index.ts';
import { assertNever, groupBy, isDefined, sortedEntries, topoOrder } from '../src/index.ts';
describe('@utils', () => {
it('isDefined 滤掉 null 与 undefined', () => {
@@ -29,3 +29,60 @@ describe('@utils', () => {
expect(() => assertNever('x' as never)).toThrow('未覆盖的分支');
});
});
describe('topoOrder', () => {
it('无依赖:随便什么序都放行,谁都不欠谁', () => {
const result = topoOrder(['b', 'a', 'c'], () => []);
expect(result.ok).toBe(true);
expect(result.ok && [...result.order].sort()).toEqual(['a', 'b', 'c']);
});
/* 入参故意按「被依赖的排在后面」给 —— 证明排序看的是依赖关系,不是输入数组的次序 */
it('链式依赖:按「被依赖的排前面」重排,不管输入次序', () => {
const deps: Record<string, string[]> = { c: ['b'], b: ['a'], a: [] };
const result = topoOrder(['c', 'b', 'a'], (n) => deps[n] ?? []);
expect(result).toEqual({ ok: true, order: ['a', 'b', 'c'] });
});
it('菱形依赖:d 依赖 b 和 c,b 与 c 都依赖 a —— a 最先,d 最后,b/c 顺序不拘', () => {
const deps: Record<string, string[]> = { a: [], b: ['a'], c: ['a'], d: ['b', 'c'] };
const result = topoOrder(['a', 'b', 'c', 'd'], (n) => deps[n] ?? []);
expect(result.ok).toBe(true);
const order = result.ok ? result.order : [];
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
expect(order.indexOf('a')).toBeLessThan(order.indexOf('c'));
expect(order.indexOf('b')).toBeLessThan(order.indexOf('d'));
expect(order.indexOf('c')).toBeLessThan(order.indexOf('d'));
});
it('真正的环(a 依赖 b、b 依赖 a):当场报出来,不默默兜底', () => {
const deps: Record<string, string[]> = { a: ['b'], b: ['a'] };
const result = topoOrder(['a', 'b'], (n) => deps[n] ?? []);
expect(result.ok).toBe(false);
expect(!result.ok && [...result.cycle].sort()).toEqual(['a', 'b']);
});
it('自依赖(a 依赖自己)同样算环', () => {
const result = topoOrder(['a'], () => ['a']);
expect(result).toEqual({ ok: false, cycle: ['a'] });
});
it('三元环:a 依赖 c、c 依赖 b、b 依赖 a,三个一起报出来', () => {
const deps: Record<string, string[]> = { a: ['c'], b: ['a'], c: ['b'] };
const result = topoOrder(['a', 'b', 'c'], (n) => deps[n] ?? []);
expect(result.ok).toBe(false);
expect(!result.ok && [...result.cycle].sort()).toEqual(['a', 'b', 'c']);
});
it('环之外互不相干的节点不受连累 —— 只有环上那两个排不出来', () => {
const deps: Record<string, string[]> = { y: [], a: ['b'], b: ['a'] };
const result = topoOrder(['y', 'a', 'b'], (n) => deps[n] ?? []);
expect(result.ok).toBe(false);
expect(!result.ok && [...result.cycle].sort()).toEqual(['a', 'b']);
});
it('依赖了一个不在 nodes 里的名字:当没有这条依赖,不误报成环', () => {
const result = topoOrder(['a'], () => ['not-a-node']);
expect(result).toEqual({ ok: true, order: ['a'] });
});
});
+49
View File
@@ -27,3 +27,52 @@ export function groupBy<T, K extends string>(items: readonly T[], key: (item: T)
export function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]> {
return Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
}
export type TopoOrderResult =
| { readonly ok: true; readonly order: readonly string[] }
| { readonly ok: false; readonly cycle: readonly string[] };
/**
* 拓扑排序(Kahn 算法)—— 给定节点集合与「这个节点依赖谁」,排出一个安全的处理序
* (被依赖的排前面)。
*
* 同族算法已有一份在 `apps/api/src/modules/process/internal/domain/routing.ts` 的
* `advanceOrder`(那是流程图专用的,找不齐拓扑序时把剩下的节点原样缀在末尾 ——
* 流程图的容错策略)。这里是它的**通用版**,故意搬到叶子包:两个不同层级的模块
* (`activity-registry` 装包静态审 / `loop-engine` 配方求值)都要用同一个算法判「排不排得出来」,
* 而模块分层不许它们互相 import ——图算法本身不含业务语义,该住在 `@utils`。
*
* **不兜底**:排不出来就是这几个节点之间有环,原样报出来,由调用方决定怎么处置
* (装包时机检报错 / 运行时交人),不在这一层替调用方决定「怎么办」。
* `dependsOn` 返回的名字如果不在 `nodes` 里,当作没这条依赖(忽略,不会因此误报成环)。
*/
export function topoOrder(nodes: readonly string[], dependsOn: (node: string) => readonly string[]): TopoOrderResult {
const known = new Set(nodes);
const indegree = new Map<string, number>(nodes.map((n) => [n, 0]));
const dependents = new Map<string, string[]>();
for (const node of nodes) {
for (const dep of dependsOn(node)) {
if (!known.has(dep)) continue;
indegree.set(node, (indegree.get(node) ?? 0) + 1);
(dependents.get(dep) ?? dependents.set(dep, []).get(dep)!).push(node);
}
}
const ready = nodes.filter((n) => indegree.get(n) === 0);
const order: string[] = [];
const placed = new Set<string>();
while (ready.length > 0) {
const id = ready.shift()!;
if (placed.has(id)) continue;
placed.add(id);
order.push(id);
for (const dependent of dependents.get(id) ?? []) {
const left = (indegree.get(dependent) ?? 0) - 1;
indegree.set(dependent, left);
if (left === 0) ready.push(dependent);
}
}
const stranded = nodes.filter((n) => !placed.has(n));
return stranded.length > 0 ? { ok: false, cycle: stranded } : { ok: true, order };
}