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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user