This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/packages/utils/src/index.ts
T
xcsandClaude Opus 5 6636abfc39 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
2026-08-30 07:05:48 +08:00

79 lines
3.3 KiB
TypeScript

/**
* @utils —— 纯函数,零依赖(编码规范 §2 / §5.1:叶子包)。
* 无业务语义;有业务语义的东西不属于这里。
*/
/** 穷尽性检查:switch 漏分支时编译期报错 */
export function assertNever(value: never, message = '未覆盖的分支'): never {
throw new Error(`${message}: ${JSON.stringify(value)}`);
}
/** 类型守卫:滤掉 null / undefined */
export function isDefined<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
/** 按键分组 */
export function groupBy<T, K extends string>(items: readonly T[], key: (item: T) => K): Record<K, T[]> {
const out = {} as Record<K, T[]>;
for (const item of items) {
const k = key(item);
(out[k] ??= []).push(item);
}
return out;
}
/** 稳定排序的键值对遍历(避免 Object.keys 顺序依赖) */
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 };
}