From 6636abfc39557d5a274e639c482ad07305df0f25 Mon Sep 17 00:00:00 2001 From: xcs Date: Sun, 30 Aug 2026 07:05:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(auto):=20=E9=85=8D=E6=96=B9=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=8C=89=E4=BE=9D=E8=B5=96=E6=8B=93=E6=89=91=E6=B1=82?= =?UTF-8?q?=E5=80=BC=20=E2=80=94=E2=80=94=20jsonb=20=E4=BC=9A=E9=87=8D?= =?UTF-8?q?=E6=8E=92=E5=AF=B9=E8=B1=A1=E9=94=AE,=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E9=9D=A0=E4=B8=8D=E4=BD=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真跑 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) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- api-report/packages/utils/src/index.d.ts | 22 ++ .../__tests__/acceptance-predicate.test.ts | 111 ++++++++ .../internal/domain/static-audit.ts | 82 +++++- .../loop-engine/__tests__/auto-order.test.ts | 121 +++++++++ .../loop-engine/__tests__/auto-runner.test.ts | 245 +++++++++++++++++- .../loop-engine/internal/domain/auto-order.ts | 82 ++++++ .../internal/usecase/auto-runner.ts | 32 ++- docs/debt.md | 43 +++ packages/utils/__tests__/index.test.ts | 59 ++++- packages/utils/src/index.ts | 49 ++++ 10 files changed, 840 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/modules/loop-engine/__tests__/auto-order.test.ts create mode 100644 apps/api/src/modules/loop-engine/internal/domain/auto-order.ts diff --git a/api-report/packages/utils/src/index.d.ts b/api-report/packages/utils/src/index.d.ts index e838780..baf40f9 100644 --- a/api-report/packages/utils/src/index.d.ts +++ b/api-report/packages/utils/src/index.d.ts @@ -10,3 +10,25 @@ export declare function isDefined(value: T | null | undefined): value is T; export declare function groupBy(items: readonly T[], key: (item: T) => K): Record; /** 稳定排序的键值对遍历(避免 Object.keys 顺序依赖) */ export declare function sortedEntries(record: Readonly>): 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; diff --git a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts index e161809..75426f7 100644 --- a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts +++ b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts @@ -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); + }); }); diff --git a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts index 1a46d4a..8fa0c1c 100644 --- a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts +++ b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts @@ -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>): readonly string[] | undefined { + const entries = Object.entries(fields); + const names = new Set(entries.map(([name]) => name)); + const deps = new Map( + 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); diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-order.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-order.test.ts new file mode 100644 index 0000000..5788850 --- /dev/null +++ b/apps/api/src/modules/loop-engine/__tests__/auto-order.test.ts @@ -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>) => orderFields(fields).map(([name]) => name); + +describe('orderFields', () => { + it('没有 when 的字段:谁先谁后都行,数量对得上就好', () => { + const fields: Record = { 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 = { + 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 = { + /* 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 = {}; + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { 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]]); + }); +}); diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index fc36ba3..673cbce 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -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 = { + 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(fields: Readonly>): Record { + const keys = Object.keys(fields).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0)); + const out: Record = {}; + 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> = [ + { 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> = [ + { 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 = { + 使能阈值: { 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) { + const drafts = new Map; patch: Record }>(); + 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; patch: Record }) => { + 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) => 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): 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) => + 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); + } + }); +}); diff --git a/apps/api/src/modules/loop-engine/internal/domain/auto-order.ts b/apps/api/src/modules/loop-engine/internal/domain/auto-order.ts new file mode 100644 index 0000000..da4f327 --- /dev/null +++ b/apps/api/src/modules/loop-engine/internal/domain/auto-order.ts @@ -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>): 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); +} diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index 9855b0e..6bc3456 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -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>, index: number): ReadonlyArray { + 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 = {}; - 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 = {}; @@ -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 = {}; - 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; diff --git a/docs/debt.md b/docs/debt.md index 263069b..b2a3b69 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -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` 上时,概率最高。 diff --git a/packages/utils/__tests__/index.test.ts b/packages/utils/__tests__/index.test.ts index 987aa34..9ab47e8 100644 --- a/packages/utils/__tests__/index.test.ts +++ b/packages/utils/__tests__/index.test.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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'] }); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e6d73d1..a860fe5 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -27,3 +27,52 @@ export function groupBy(items: readonly T[], key: (item: T) export function sortedEntries(record: Readonly>): 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(nodes.map((n) => [n, 0])); + const dependents = new Map(); + 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(); + 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 }; +}