/** * 配方字段(与 `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>): ReadonlyArray { const entries = Object.entries(fields); const names = new Set(entries.map(([name]) => name)); const deps = new Map( 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>): ReadonlyArray { const entries = Object.entries(lets); const names = new Set(entries.map(([name]) => name)); const deps = new Map( 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); }