- lexer / parser(递归下降)/ evaluate / static-check,四文件纯函数,零 IO 零外部调用 - 子集封顶(ADR-006):比较 · 逻辑 · 算术 · 区间(开闭四态)· 列表与 in · 封闭内建函数集(字符串 / 数字 / 日期);未知函数一律拦下 - 单元格谓词(DMN 一格):`-` 任意 / 比较 / 区间 / 逗号析取 - staticCheck 给「画图即校验」用:语法 + 未知函数 + 引用路径清单 - app 侧以它替下 passthroughEvaluator:监听内容条件真求值,求值异常按不命中处置并留痕 32 个 sfeel 测试;全仓 182 个测试通过,门禁五道全绿 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
249 lines
9.1 KiB
TypeScript
249 lines
9.1 KiB
TypeScript
/**
|
|
* S-FEEL 子集的求值 —— 纯函数,零 IO、零外部调用(编码规范 §8.3;定义域 OS 03)。
|
|
*
|
|
* 求值输入(scope):网关处 = 流转载荷与流程变量;触发器处 = 事件信封与载荷字段;
|
|
* 监听处同触发器。**后端裁决为权威**,前端用同一份代码做即时预览。
|
|
*/
|
|
import type { Node, UnaryTest } from './ast.ts';
|
|
import { parse, parseUnaryTest } from './parser.ts';
|
|
|
|
export type Scope = Readonly<Record<string, unknown>>;
|
|
|
|
export class SfeelEvalError extends Error {
|
|
constructor(message: string) {
|
|
super(`S-FEEL 求值错误:${message}`);
|
|
this.name = 'SfeelEvalError';
|
|
}
|
|
}
|
|
|
|
/** 内建函数表 —— 封闭集合,**不可扩展**(无外部调用,静态可检) */
|
|
const FUNCTIONS: Readonly<Record<string, (args: readonly unknown[]) => unknown>> = {
|
|
// 字符串
|
|
'upper case': ([s]) => String(s ?? '').toUpperCase(),
|
|
'lower case': ([s]) => String(s ?? '').toLowerCase(),
|
|
contains: ([s, sub]) => String(s ?? '').includes(String(sub ?? '')),
|
|
'starts with': ([s, sub]) => String(s ?? '').startsWith(String(sub ?? '')),
|
|
'ends with': ([s, sub]) => String(s ?? '').endsWith(String(sub ?? '')),
|
|
'string length': ([s]) => String(s ?? '').length,
|
|
substring: ([s, from, len]) => {
|
|
const text = String(s ?? '');
|
|
const start = Math.max(Number(from ?? 1) - 1, 0);
|
|
return len === undefined ? text.slice(start) : text.slice(start, start + Number(len));
|
|
},
|
|
string: ([v]) => (v === null || v === undefined ? '' : String(v)),
|
|
// 数字
|
|
abs: ([n]) => Math.abs(toNumber(n)),
|
|
floor: ([n]) => Math.floor(toNumber(n)),
|
|
ceiling: ([n]) => Math.ceil(toNumber(n)),
|
|
round: ([n, d]) => {
|
|
const factor = 10 ** Number(d ?? 0);
|
|
return Math.round(toNumber(n) * factor) / factor;
|
|
},
|
|
min: (args) => Math.min(...flatten(args).map(toNumber)),
|
|
max: (args) => Math.max(...flatten(args).map(toNumber)),
|
|
sum: (args) => flatten(args).reduce<number>((acc, v) => acc + toNumber(v), 0),
|
|
count: (args) => flatten(args).length,
|
|
number: ([v]) => toNumber(v),
|
|
// 日期:date("2026-07-27") / date and time("2026-07-27T10:00:00Z")
|
|
date: ([v]) => toDate(v),
|
|
'date and time': ([v]) => toDate(v),
|
|
// 天数差(整天),用于时限与偏移计算
|
|
'days and time duration': ([a, b]) => (toDate(b).getTime() - toDate(a).getTime()) / 86_400_000,
|
|
// 列表
|
|
list: (args) => flatten(args),
|
|
};
|
|
|
|
export const BUILTIN_FUNCTIONS = Object.keys(FUNCTIONS);
|
|
|
|
export function evaluateNode(node: Node, scope: Scope): unknown {
|
|
switch (node.kind) {
|
|
case 'literal':
|
|
return node.value;
|
|
|
|
case 'path': {
|
|
let current: unknown = scope;
|
|
for (const segment of node.segments) {
|
|
if (current === null || current === undefined) return undefined;
|
|
if (typeof current !== 'object') return undefined;
|
|
current = (current as Record<string, unknown>)[segment];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
case 'unary':
|
|
if (node.op === '-') return -toNumber(evaluateNode(node.operand, scope));
|
|
return !truthy(evaluateNode(node.operand, scope));
|
|
|
|
case 'binary':
|
|
return evaluateBinary(node, scope);
|
|
|
|
case 'list':
|
|
return node.items.map((item) => evaluateNode(item, scope));
|
|
|
|
case 'range':
|
|
return {
|
|
__range: true,
|
|
startOpen: node.startOpen,
|
|
endOpen: node.endOpen,
|
|
start: evaluateNode(node.start, scope),
|
|
end: evaluateNode(node.end, scope),
|
|
};
|
|
|
|
case 'in': {
|
|
const value = evaluateNode(node.value, scope);
|
|
const candidates = evaluateNode(node.candidates, scope);
|
|
return inCandidates(value, candidates);
|
|
}
|
|
|
|
case 'call': {
|
|
const fn = FUNCTIONS[node.name];
|
|
if (fn === undefined) throw new SfeelEvalError(`未知函数 ${node.name}(子集封顶,不可扩展)`);
|
|
return fn(node.args.map((arg) => evaluateNode(arg, scope)));
|
|
}
|
|
}
|
|
}
|
|
|
|
function evaluateBinary(node: Extract<Node, { kind: 'binary' }>, scope: Scope): unknown {
|
|
// 短路:and / or
|
|
if (node.op === 'and') {
|
|
return truthy(evaluateNode(node.left, scope)) ? truthy(evaluateNode(node.right, scope)) : false;
|
|
}
|
|
if (node.op === 'or') {
|
|
return truthy(evaluateNode(node.left, scope)) ? true : truthy(evaluateNode(node.right, scope));
|
|
}
|
|
|
|
const left = evaluateNode(node.left, scope);
|
|
const right = evaluateNode(node.right, scope);
|
|
|
|
switch (node.op) {
|
|
case '+':
|
|
return typeof left === 'string' || typeof right === 'string'
|
|
? `${String(left ?? '')}${String(right ?? '')}`
|
|
: toNumber(left) + toNumber(right);
|
|
case '-':
|
|
return toNumber(left) - toNumber(right);
|
|
case '*':
|
|
return toNumber(left) * toNumber(right);
|
|
case '/': {
|
|
const divisor = toNumber(right);
|
|
if (divisor === 0) throw new SfeelEvalError('除零');
|
|
return toNumber(left) / divisor;
|
|
}
|
|
case '=':
|
|
return equals(left, right);
|
|
case '!=':
|
|
return !equals(left, right);
|
|
case '<':
|
|
return compare(left, right) < 0;
|
|
case '<=':
|
|
return compare(left, right) <= 0;
|
|
case '>':
|
|
return compare(left, right) > 0;
|
|
case '>=':
|
|
return compare(left, right) >= 0;
|
|
}
|
|
}
|
|
|
|
/** 布尔求值:表达式作条件时用它;非布尔一律按 JS 真值,但 null / undefined 为假 */
|
|
export function truthy(value: unknown): boolean {
|
|
if (value === null || value === undefined) return false;
|
|
if (typeof value === 'boolean') return value;
|
|
if (typeof value === 'number') return !Number.isNaN(value);
|
|
if (typeof value === 'string') return value !== '';
|
|
return true;
|
|
}
|
|
|
|
function equals(a: unknown, b: unknown): boolean {
|
|
if (a === b) return true;
|
|
if (a === null || a === undefined || b === null || b === undefined) return false;
|
|
if (a instanceof Date || b instanceof Date) return toDate(a).getTime() === toDate(b).getTime();
|
|
if (typeof a === 'number' || typeof b === 'number') {
|
|
const na = Number(a);
|
|
const nb = Number(b);
|
|
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na === nb;
|
|
}
|
|
return String(a) === String(b);
|
|
}
|
|
|
|
function compare(a: unknown, b: unknown): number {
|
|
if (a instanceof Date || b instanceof Date) {
|
|
return toDate(a).getTime() - toDate(b).getTime();
|
|
}
|
|
if (typeof a === 'string' && typeof b === 'string' && Number.isNaN(Number(a))) {
|
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
}
|
|
return toNumber(a) - toNumber(b);
|
|
}
|
|
|
|
function inCandidates(value: unknown, candidates: unknown): boolean {
|
|
if (isRange(candidates)) return withinRange(value, candidates);
|
|
if (Array.isArray(candidates)) return candidates.some((c) => (isRange(c) ? withinRange(value, c) : equals(value, c)));
|
|
return equals(value, candidates);
|
|
}
|
|
|
|
type RangeValue = { __range: true; startOpen: boolean; endOpen: boolean; start: unknown; end: unknown };
|
|
|
|
function isRange(value: unknown): value is RangeValue {
|
|
return typeof value === 'object' && value !== null && (value as { __range?: boolean }).__range === true;
|
|
}
|
|
|
|
function withinRange(value: unknown, range: RangeValue): boolean {
|
|
const low = compare(value, range.start);
|
|
const high = compare(value, range.end);
|
|
return (range.startOpen ? low > 0 : low >= 0) && (range.endOpen ? high < 0 : high <= 0);
|
|
}
|
|
|
|
function toNumber(value: unknown): number {
|
|
if (typeof value === 'number') return value;
|
|
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
if (value === null || value === undefined || value === '') return Number.NaN;
|
|
const n = Number(value);
|
|
return n;
|
|
}
|
|
|
|
function toDate(value: unknown): Date {
|
|
if (value instanceof Date) return value;
|
|
const date = new Date(String(value));
|
|
if (Number.isNaN(date.getTime())) throw new SfeelEvalError(`不是合法日期:${String(value)}`);
|
|
return date;
|
|
}
|
|
|
|
function flatten(args: readonly unknown[]): unknown[] {
|
|
return args.flatMap((a) => (Array.isArray(a) ? a : [a]));
|
|
}
|
|
|
|
/* ── 对外三个入口 ──────────────────────────────────────────────────── */
|
|
|
|
/** 求值任意表达式 */
|
|
export function evaluate(expression: string, scope: Scope): unknown {
|
|
return evaluateNode(parse(expression), scope);
|
|
}
|
|
|
|
/** 条件求值 —— 网关、监听、触发器都用它;非布尔结果按 truthy 归一 */
|
|
export function evaluateCondition(expression: string, scope: Scope): boolean {
|
|
return truthy(evaluate(expression, scope));
|
|
}
|
|
|
|
/** DMN 单元格谓词:输入值是否命中该格 */
|
|
export function evaluateUnaryTest(cell: string, value: unknown, scope: Scope = {}): boolean {
|
|
return testMatches(parseUnaryTest(cell), value, scope);
|
|
}
|
|
|
|
export function testMatches(test: UnaryTest, value: unknown, scope: Scope): boolean {
|
|
switch (test.kind) {
|
|
case 'any':
|
|
return true;
|
|
case 'disjunction':
|
|
return test.tests.some((t) => testMatches(t, value, scope));
|
|
case 'range':
|
|
return withinRange(value, evaluateNode(test.node, scope) as RangeValue);
|
|
case 'compare': {
|
|
const operand = evaluateNode(test.operand, scope);
|
|
if (test.op === '=') return isRange(operand) ? withinRange(value, operand) : equals(value, operand);
|
|
if (test.op === '!=') return !equals(value, operand);
|
|
const c = compare(value, operand);
|
|
return test.op === '<' ? c < 0 : test.op === '<=' ? c <= 0 : test.op === '>' ? c > 0 : c >= 0;
|
|
}
|
|
}
|
|
}
|