Archived
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
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 配方表达式求值 —— **纯函数,零 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);
|
||||
}
|
||||
Reference in New Issue
Block a user