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/apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts
T
xcsandClaude Opus 5 f950a4419d feat(loop-engine): 配方表达式求值 —— 八种形状,S-FEEL 仍由外面注入
地址四种、取不到一律空数组(首拍很多表本来就没行,抛了等于开局交人);
比值一律按字符串(台账里数字与文本混着存);谓词算不出来走 else,不拖垮整拍。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 03:14:46 +08:00

79 lines
3.5 KiB
TypeScript

/**
* 配方表达式求值 —— **纯函数,零 IO**(编码规范:domain 层不碰端口)。
*
* S-FEEL 不在这里实现:`condition` 由调用方注入(与哨兵、`doneWhen` 同一个求值器)。
* **不发明第二套求值** 是 ADR-014 的一条硬约束。
*/
import type { AutoExpr } from '@contracts';
export type Row = Readonly<Record<string, unknown>>;
export type ExprCtx = {
/** 任务视图(正式 + 本单草稿叠加)+ 基座读数 + 起单入参,外加本拍的 `result:*` */
readonly scope: Readonly<Record<string, unknown>>;
/** 配方级 `let` 算好的值 —— 与 scope 同一命名空间,它优先 */
readonly lets: Readonly<Record<string, unknown>>;
readonly row?: Row | undefined;
readonly outer?: Row | undefined;
/** 同一行里**已经算出来**的字段 —— 「先 lookup 出依据,再按依据判 found/absent」靠它 */
readonly computed?: Row | undefined;
readonly condition: (expr: string, scope: Readonly<Record<string, unknown>>) => boolean;
};
/**
* 地址取行 —— 四种地址(spec §5.1),取不到一律空数组。
*
* **取不到不抛**:首拍很多表本来就一行都还没有,抛了等于每一单开局就交人。
*/
export function rowsAt(scope: Readonly<Record<string, unknown>>, address: string): readonly Row[] {
const raw = address.startsWith('in:') ? scope[address.slice('in:'.length)] : scope[address];
if (!Array.isArray(raw)) return [];
return raw.map((item) =>
typeof item === 'object' && item !== null ? (item as Row) : ({ value: item } satisfies Row),
);
}
/** 一个值参与比较时的归一:台账里数字与文本混着存,一律按字符串比 */
function asKey(value: unknown): string {
return value === null || value === undefined ? '' : String(value);
}
export function evalExpr(expr: AutoExpr, ctx: ExprCtx): unknown {
if (typeof expr === 'string' || typeof expr === 'number' || typeof expr === 'boolean') return expr;
if ('in' in expr) {
const fromLet = ctx.lets[expr.in];
return fromLet !== undefined ? fromLet : (ctx.scope[expr.in] ?? '');
}
if ('col' in expr) return ctx.row?.[expr.col] ?? '';
if ('outer' in expr) return ctx.outer?.[expr.outer] ?? '';
if ('cat' in expr) return expr.cat.map((part) => evalText(part, ctx)).join('');
if ('fmt' in expr) return evalText(expr.fmt, ctx).replace(/\D/g, '');
if ('lookup' in expr) {
const pairs = Object.entries(expr.by).map(([column, valueExpr]) => [column, asKey(evalExpr(valueExpr, ctx))] as const);
const hit = rowsAt(ctx.scope, expr.lookup).find((row) => pairs.every(([column, want]) => asKey(row[column]) === want));
if (hit === undefined) return expr.else === undefined ? '' : evalExpr(expr.else, ctx);
return hit[expr.take] ?? (expr.else === undefined ? '' : evalExpr(expr.else, ctx));
}
/*
* 条件走注入的 S-FEEL。谓词写坏了**不该拖垮整拍** —— 与 `doneWhen` 同一条处置:
* 当作「没算出来」走 else。真写错了,静态审在装包时就该拦下(Task 9)。
*/
const scope = { ...ctx.scope, ...ctx.lets, ...(ctx.outer ?? {}), ...(ctx.row ?? {}), ...(ctx.computed ?? {}) };
let hit = false;
try {
hit = ctx.condition(expr.when, scope);
} catch {
hit = false;
}
return evalExpr(hit ? expr.then : expr.else, ctx);
}
/** 出字符串的那一路 —— 拼接、标题、留痕都要它 */
export function evalText(expr: AutoExpr, ctx: ExprCtx): string {
const value = evalExpr(expr, ctx);
return value === null || value === undefined ? '' : String(value);
}