/**
|
* 判定规则引擎
|
* <p>
|
* 递归下降解析成 AST 后自己求值,全程不碰 eval / new Function:
|
* 规则文本来自用户,一旦能被当作代码执行就是注入口子。
|
* 支持的语法见 docs/智能质检报告平台-方案设计.md §13。
|
*/
|
import { parsePath, readPath, toNumber, toNumberArray } from './path';
|
|
/** 规则文本存在语法错误 */
|
export class RuleSyntaxError extends Error {
|
constructor(
|
message: string,
|
readonly position: number,
|
) {
|
super(message);
|
this.name = 'RuleSyntaxError';
|
}
|
}
|
|
/** 规则运行期错误(函数不存在、参数类型不对等) */
|
export class RuleRuntimeError extends Error {
|
constructor(message: string) {
|
super(message);
|
this.name = 'RuleRuntimeError';
|
}
|
}
|
|
type TokenType = 'comma' | 'eof' | 'lparen' | 'number' | 'operator' | 'rparen' | 'string' | 'word';
|
|
interface Token {
|
type: TokenType;
|
value: string;
|
position: number;
|
}
|
|
export type RuleNode =
|
| { type: 'literal'; value: boolean | null | number | string }
|
| { type: 'path'; path: string }
|
| { type: 'call'; name: string; args: RuleNode[] }
|
| { type: 'unary'; operator: string; operand: RuleNode }
|
| { type: 'binary'; operator: string; left: RuleNode; right: RuleNode }
|
| { type: 'between'; value: RuleNode; lower: RuleNode; upper: RuleNode; negated: boolean }
|
| { type: 'in'; value: RuleNode; items: RuleNode[]; negated: boolean };
|
|
const KEYWORDS = new Set([
|
'AND',
|
'BETWEEN',
|
'FALSE',
|
'IN',
|
'NOT',
|
'NOT_IN',
|
'NULL',
|
'OR',
|
'TRUE',
|
]);
|
|
const IDENT_START = /[A-Za-z_$]/;
|
const IDENT_PART = /[\w$]/;
|
const TWO_CHAR_OPERATORS = new Set(['<=', '>=', '!=', '<>', '==']);
|
|
/* ------------------------------ 词法分析 ------------------------------ */
|
|
function tokenize(source: string): Token[] {
|
const tokens: Token[] = [];
|
const length = source.length;
|
let index = 0;
|
|
while (index < length) {
|
const char = source[index]!;
|
|
if (/\s/.test(char)) {
|
index += 1;
|
continue;
|
}
|
|
if (char === "'" || char === '"') {
|
const quote = char;
|
let value = '';
|
let cursor = index + 1;
|
while (cursor < length && source[cursor] !== quote) {
|
if (source[cursor] === '\\' && cursor + 1 < length) {
|
value += source[cursor + 1];
|
cursor += 2;
|
continue;
|
}
|
value += source[cursor];
|
cursor += 1;
|
}
|
if (cursor >= length) {
|
throw new RuleSyntaxError('字符串缺少结尾引号', index);
|
}
|
tokens.push({ type: 'string', value, position: index });
|
index = cursor + 1;
|
continue;
|
}
|
|
if (/[0-9]/.test(char)) {
|
let cursor = index;
|
while (cursor < length && /[0-9.]/.test(source[cursor]!)) {
|
cursor += 1;
|
}
|
const text = source.slice(index, cursor);
|
if (!/^\d+(?:\.\d+)?$/.test(text)) {
|
throw new RuleSyntaxError(`数字格式不正确:${text}`, index);
|
}
|
tokens.push({ type: 'number', value: text, position: index });
|
index = cursor;
|
continue;
|
}
|
|
if (IDENT_START.test(char)) {
|
let cursor = index;
|
while (cursor < length && IDENT_PART.test(source[cursor]!)) {
|
cursor += 1;
|
}
|
let value = source.slice(index, cursor);
|
// 路径尾巴:.属性 与 [下标]
|
for (;;) {
|
const next = source[cursor];
|
if (next === '.' && IDENT_START.test(source[cursor + 1] ?? '')) {
|
let end = cursor + 1;
|
while (end < length && IDENT_PART.test(source[end]!)) {
|
end += 1;
|
}
|
value += source.slice(cursor, end);
|
cursor = end;
|
continue;
|
}
|
if (next === '[') {
|
const close = source.indexOf(']', cursor);
|
if (close === -1) {
|
throw new RuleSyntaxError('数组下标缺少 ]', cursor);
|
}
|
const inner = source.slice(cursor + 1, close).trim();
|
if (!/^\d+$/.test(inner)) {
|
throw new RuleSyntaxError(`数组下标只能是数字:${inner}`, cursor);
|
}
|
value += `[${inner}]`;
|
cursor = close + 1;
|
continue;
|
}
|
break;
|
}
|
tokens.push({ type: 'word', value, position: index });
|
index = cursor;
|
continue;
|
}
|
|
const two = source.slice(index, index + 2);
|
if (TWO_CHAR_OPERATORS.has(two)) {
|
tokens.push({ type: 'operator', value: two, position: index });
|
index += 2;
|
continue;
|
}
|
|
if (char === '(' || char === ')') {
|
tokens.push({ type: char === '(' ? 'lparen' : 'rparen', value: char, position: index });
|
index += 1;
|
continue;
|
}
|
if (char === ',') {
|
tokens.push({ type: 'comma', value: char, position: index });
|
index += 1;
|
continue;
|
}
|
if ('><=+-*/!'.includes(char)) {
|
tokens.push({ type: 'operator', value: char, position: index });
|
index += 1;
|
continue;
|
}
|
|
throw new RuleSyntaxError(`无法识别的字符「${char}」`, index);
|
}
|
|
tokens.push({ type: 'eof', value: '', position: length });
|
return tokens;
|
}
|
|
/* ------------------------------ 语法分析 ------------------------------ */
|
|
class Parser {
|
private cursor = 0;
|
|
constructor(private readonly tokens: Token[]) {}
|
|
parse(): RuleNode {
|
const node = this.parseOr();
|
const token = this.peek();
|
if (token.type !== 'eof') {
|
throw new RuleSyntaxError(`多余的内容:${token.value}`, token.position);
|
}
|
return node;
|
}
|
|
private parseOr(): RuleNode {
|
let left = this.parseAnd();
|
while (this.matchWord('OR')) {
|
left = { type: 'binary', operator: 'OR', left, right: this.parseAnd() };
|
}
|
return left;
|
}
|
|
private parseAnd(): RuleNode {
|
let left = this.parseComparison();
|
while (this.matchWord('AND')) {
|
left = { type: 'binary', operator: 'AND', left, right: this.parseComparison() };
|
}
|
return left;
|
}
|
|
private parseComparison(): RuleNode {
|
const left = this.parseAdditive();
|
|
if (this.matchWord('BETWEEN')) {
|
const lower = this.parseAdditive();
|
if (!this.matchWord('AND')) {
|
throw new RuleSyntaxError('BETWEEN 缺少 AND 与上界', this.peek().position);
|
}
|
const upper = this.parseAdditive();
|
return { type: 'between', value: left, lower, upper, negated: false };
|
}
|
|
if (this.matchWord('NOT')) {
|
if (this.matchWord('IN') || this.matchWord('NOT_IN')) {
|
return { type: 'in', value: left, items: this.parseList(), negated: true };
|
}
|
throw new RuleSyntaxError('NOT 只能用于 NOT IN', this.peek().position);
|
}
|
|
if (this.matchWord('IN')) {
|
return { type: 'in', value: left, items: this.parseList(), negated: false };
|
}
|
|
const token = this.peek();
|
if (token.type === 'operator' && ['<', '<=', '=', '==', '!=', '<>', '>', '>='].includes(token.value)) {
|
this.cursor += 1;
|
const operator = token.value === '==' ? '=' : token.value === '<>' ? '!=' : token.value;
|
return { type: 'binary', operator, left, right: this.parseAdditive() };
|
}
|
|
return left;
|
}
|
|
private parseList(): RuleNode[] {
|
this.expect('lparen');
|
const items: RuleNode[] = [];
|
if (this.peek().type !== 'rparen') {
|
items.push(this.parseOr());
|
while (this.peek().type === 'comma') {
|
this.cursor += 1;
|
items.push(this.parseOr());
|
}
|
}
|
this.expect('rparen');
|
return items;
|
}
|
|
private parseAdditive(): RuleNode {
|
let left = this.parseMultiplicative();
|
for (;;) {
|
const token = this.peek();
|
if (token.type !== 'operator' || (token.value !== '+' && token.value !== '-')) {
|
return left;
|
}
|
this.cursor += 1;
|
left = { type: 'binary', operator: token.value, left, right: this.parseMultiplicative() };
|
}
|
}
|
|
private parseMultiplicative(): RuleNode {
|
let left = this.parseUnary();
|
for (;;) {
|
const token = this.peek();
|
if (token.type !== 'operator' || (token.value !== '*' && token.value !== '/')) {
|
return left;
|
}
|
this.cursor += 1;
|
left = { type: 'binary', operator: token.value, left, right: this.parseUnary() };
|
}
|
}
|
|
private parseUnary(): RuleNode {
|
const token = this.peek();
|
if (token.type === 'operator' && (token.value === '-' || token.value === '!')) {
|
this.cursor += 1;
|
return { type: 'unary', operator: token.value, operand: this.parseUnary() };
|
}
|
return this.parsePrimary();
|
}
|
|
private parsePrimary(): RuleNode {
|
const token = this.peek();
|
|
if (token.type === 'number') {
|
this.cursor += 1;
|
return { type: 'literal', value: Number(token.value) };
|
}
|
if (token.type === 'string') {
|
this.cursor += 1;
|
return { type: 'literal', value: token.value };
|
}
|
if (token.type === 'lparen') {
|
this.cursor += 1;
|
const node = this.parseOr();
|
this.expect('rparen');
|
return node;
|
}
|
if (token.type === 'word') {
|
this.cursor += 1;
|
const upper = token.value.toUpperCase();
|
if (upper === 'TRUE') {
|
return { type: 'literal', value: true };
|
}
|
if (upper === 'FALSE') {
|
return { type: 'literal', value: false };
|
}
|
if (upper === 'NULL') {
|
return { type: 'literal', value: null };
|
}
|
if (this.peek().type === 'lparen') {
|
return { type: 'call', name: upper, args: this.parseList() };
|
}
|
if (KEYWORDS.has(upper)) {
|
throw new RuleSyntaxError(`关键字 ${upper} 的位置不正确`, token.position);
|
}
|
return { type: 'path', path: token.value };
|
}
|
|
throw new RuleSyntaxError(
|
token.type === 'eof' ? '表达式不完整' : `无法解析的内容:${token.value}`,
|
token.position,
|
);
|
}
|
|
private peek(): Token {
|
return this.tokens[this.cursor]!;
|
}
|
|
private matchWord(word: string): boolean {
|
const token = this.peek();
|
if (token.type === 'word' && token.value.toUpperCase() === word) {
|
this.cursor += 1;
|
return true;
|
}
|
return false;
|
}
|
|
private expect(type: TokenType): void {
|
const token = this.peek();
|
if (token.type !== type) {
|
throw new RuleSyntaxError(`应该是 ${type},实际是「${token.value}」`, token.position);
|
}
|
this.cursor += 1;
|
}
|
}
|
|
/** 解析规则文本,语法错误会抛 RuleSyntaxError */
|
export function parseRule(expression: string): RuleNode {
|
const trimmed = expression.trim();
|
if (!trimmed) {
|
throw new RuleSyntaxError('规则表达式为空', 0);
|
}
|
return new Parser(tokenize(trimmed)).parse();
|
}
|
|
/* ------------------------------ 求值 ------------------------------ */
|
|
function isBlank(value: unknown): boolean {
|
return value === null || value === undefined || value === '';
|
}
|
|
function truthy(value: unknown): boolean {
|
if (Array.isArray(value)) {
|
return value.length > 0;
|
}
|
if (isBlank(value)) {
|
return false;
|
}
|
if (typeof value === 'boolean') {
|
return value;
|
}
|
const numeric = toNumber(value);
|
if (Number.isFinite(numeric)) {
|
return numeric !== 0;
|
}
|
return true;
|
}
|
|
/** 两侧都能当数字时按数值比较,否则按字符串比较 */
|
function compare(left: unknown, right: unknown): number {
|
const leftNumber = toNumber(left);
|
const rightNumber = toNumber(right);
|
if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
|
return leftNumber === rightNumber ? 0 : leftNumber > rightNumber ? 1 : -1;
|
}
|
const leftText = left === null || left === undefined ? '' : String(left);
|
const rightText = right === null || right === undefined ? '' : String(right);
|
return leftText === rightText ? 0 : leftText > rightText ? 1 : -1;
|
}
|
|
/** 判定结果类取值统一成 PASS / FAIL:兼容中文、布尔与数字 1/0 */
|
const PASS_TOKENS = new Set(['PASS', 'TRUE', '合格', 'OK', '1', '是']);
|
const FAIL_TOKENS = new Set(['FAIL', 'FALSE', '不合格', 'NG', '0', '否']);
|
|
function normalizeResultToken(value: unknown): string {
|
if (typeof value === 'boolean') {
|
return value ? 'PASS' : 'FAIL';
|
}
|
const text = value === null || value === undefined ? '' : String(value).trim().toUpperCase();
|
if (PASS_TOKENS.has(text)) {
|
return 'PASS';
|
}
|
if (FAIL_TOKENS.has(text)) {
|
return 'FAIL';
|
}
|
return '';
|
}
|
|
function mean(values: number[]): number {
|
return values.reduce((sum, item) => sum + item, 0) / values.length;
|
}
|
|
/** 样本标准差(n-1),SPC 计算 CP/CPK 用这个口径 */
|
function standardDeviation(values: number[]): number {
|
if (values.length < 2) {
|
return Number.NaN;
|
}
|
const average = mean(values);
|
const variance =
|
values.reduce((sum, item) => sum + (item - average) ** 2, 0) / (values.length - 1);
|
return Math.sqrt(variance);
|
}
|
|
function requireNumbers(name: string, value: unknown): number[] {
|
const values = toNumberArray(value);
|
if (values.length === 0) {
|
throw new RuleRuntimeError(`函数 ${name} 需要数值型参数,实际取到「${String(value)}」`);
|
}
|
return values;
|
}
|
|
function requireNumber(name: string, value: unknown): number {
|
const numeric = toNumber(value);
|
if (!Number.isFinite(numeric)) {
|
throw new RuleRuntimeError(`函数 ${name} 需要数字参数,实际取到「${String(value)}」`);
|
}
|
return numeric;
|
}
|
|
/** 白名单函数表,规则文本只能调用这里面的函数 */
|
const FUNCTIONS: Record<string, (args: unknown[]) => unknown> = {
|
ABS: (args) => Math.abs(requireNumber('ABS', args[0])),
|
AVG: (args) => mean(requireNumbers('AVG', args[0])),
|
COUNT: (args) => (Array.isArray(args[0]) ? args[0].length : toNumberArray(args[0]).length),
|
/** 过程能力指数 CP=(USL-LSL)/(6σ) */
|
CP: (args) => {
|
const values = requireNumbers('CP', args[0]);
|
const deviation = standardDeviation(values);
|
if (!Number.isFinite(deviation) || deviation === 0) {
|
return Number.NaN;
|
}
|
return (requireNumber('CP', args[1]) - requireNumber('CP', args[2])) / (6 * deviation);
|
},
|
/** 过程能力指数 CPK=min(USL-μ, μ-LSL)/(3σ) */
|
CPK: (args) => {
|
const values = requireNumbers('CPK', args[0]);
|
const deviation = standardDeviation(values);
|
if (!Number.isFinite(deviation) || deviation === 0) {
|
return Number.NaN;
|
}
|
const upper = requireNumber('CPK', args[1]);
|
const lower = requireNumber('CPK', args[2]);
|
const average = mean(values);
|
return Math.min(upper - average, average - lower) / (3 * deviation);
|
},
|
/** 不合格率(百分比),口径与 PASS_RATE 一致 */
|
FAIL_RATE: (args) => {
|
const values = Array.isArray(args[0]) ? args[0].flat(Number.POSITIVE_INFINITY) : [args[0]];
|
if (values.length === 0) {
|
return Number.NaN;
|
}
|
const failCount = values.filter((item) => normalizeResultToken(item) === 'FAIL').length;
|
return Number(((failCount / values.length) * 100).toFixed(2));
|
},
|
MAX: (args) => Math.max(...requireNumbers('MAX', args[0])),
|
MIN: (args) => Math.min(...requireNumbers('MIN', args[0])),
|
/** 合格率(百分比,保留两位) */
|
PASS_RATE: (args) => {
|
const values = Array.isArray(args[0]) ? args[0].flat(Number.POSITIVE_INFINITY) : [args[0]];
|
if (values.length === 0) {
|
return Number.NaN;
|
}
|
const passCount = values.filter((item) => normalizeResultToken(item) === 'PASS').length;
|
return Number(((passCount / values.length) * 100).toFixed(2));
|
},
|
ROUND: (args) => {
|
const digits = args.length > 1 ? requireNumber('ROUND', args[1]) : 0;
|
const factor = 10 ** digits;
|
return Math.round(requireNumber('ROUND', args[0]) * factor) / factor;
|
},
|
STDDEV: (args) => standardDeviation(requireNumbers('STDDEV', args[0])),
|
SUM: (args) => requireNumbers('SUM', args[0]).reduce((sum, item) => sum + item, 0),
|
};
|
|
/**
|
* 白名单里的全部函数名,供保存前校验与界面提示使用。
|
* <p>
|
* 不要另抄一份到 UI 上:白名单是引擎的一部分,抄一份就会在加函数时漏改,
|
* 用户看到的可用函数列表与引擎实际支持的悄悄对不上。
|
*/
|
export function listAllFunctionNames(): string[] {
|
return Object.keys(FUNCTIONS);
|
}
|
|
/** 静态检查表达式里调用的函数是否在白名单内,用于保存前校验 */
|
export function listFunctionNames(node: RuleNode): string[] {
|
if (node.type === 'call') {
|
return [node.name, ...node.args.flatMap(listFunctionNames)];
|
}
|
if (node.type === 'unary') {
|
return listFunctionNames(node.operand);
|
}
|
if (node.type === 'binary') {
|
return [...listFunctionNames(node.left), ...listFunctionNames(node.right)];
|
}
|
if (node.type === 'between') {
|
return [
|
...listFunctionNames(node.value),
|
...listFunctionNames(node.lower),
|
...listFunctionNames(node.upper),
|
];
|
}
|
if (node.type === 'in') {
|
return [...listFunctionNames(node.value), ...node.items.flatMap(listFunctionNames)];
|
}
|
return [];
|
}
|
|
/** 未知函数名返回 undefined,供调用方给出可读报错 */
|
export function findUnknownFunction(node: RuleNode): string | undefined {
|
return listFunctionNames(node).find((name) => !FUNCTIONS[name]);
|
}
|
|
function evaluateNode(node: RuleNode, context: unknown): unknown {
|
switch (node.type) {
|
case 'literal': {
|
return node.value;
|
}
|
case 'path': {
|
return readPath(context, node.path);
|
}
|
case 'call': {
|
const handler = FUNCTIONS[node.name];
|
if (!handler) {
|
throw new RuleRuntimeError(
|
`不支持函数 ${node.name},可用函数:${Object.keys(FUNCTIONS).join('、')}`,
|
);
|
}
|
return handler(node.args.map((arg) => evaluateNode(arg, context)));
|
}
|
case 'unary': {
|
const value = evaluateNode(node.operand, context);
|
return node.operator === '!' ? !truthy(value) : -toNumber(value);
|
}
|
case 'between': {
|
const value = evaluateNode(node.value, context);
|
const hit =
|
compare(value, evaluateNode(node.lower, context)) >= 0 &&
|
compare(value, evaluateNode(node.upper, context)) <= 0;
|
return node.negated ? !hit : hit;
|
}
|
case 'in': {
|
const value = evaluateNode(node.value, context);
|
const hit = node.items.some(
|
(item) => compare(value, evaluateNode(item, context)) === 0,
|
);
|
return node.negated ? !hit : hit;
|
}
|
case 'binary': {
|
return evaluateBinary(node, context);
|
}
|
default: {
|
throw new RuleRuntimeError('规则节点类型不支持');
|
}
|
}
|
}
|
|
function evaluateBinary(
|
node: Extract<RuleNode, { type: 'binary' }>,
|
context: unknown,
|
): unknown {
|
if (node.operator === 'AND') {
|
return truthy(evaluateNode(node.left, context)) && truthy(evaluateNode(node.right, context));
|
}
|
if (node.operator === 'OR') {
|
return truthy(evaluateNode(node.left, context)) || truthy(evaluateNode(node.right, context));
|
}
|
|
const left = evaluateNode(node.left, context);
|
const right = evaluateNode(node.right, context);
|
|
switch (node.operator) {
|
case '=': {
|
return compare(left, right) === 0;
|
}
|
case '!=': {
|
return compare(left, right) !== 0;
|
}
|
case '>': {
|
return compare(left, right) > 0;
|
}
|
case '>=': {
|
return compare(left, right) >= 0;
|
}
|
case '<': {
|
return compare(left, right) < 0;
|
}
|
case '<=': {
|
return compare(left, right) <= 0;
|
}
|
case '+': {
|
const leftNumber = toNumber(left);
|
const rightNumber = toNumber(right);
|
// 两侧都是数值才做加法,否则按文本拼接(编号类字段常用)
|
if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
|
return leftNumber + rightNumber;
|
}
|
return `${left ?? ''}${right ?? ''}`;
|
}
|
case '-': {
|
return toNumber(left) - toNumber(right);
|
}
|
case '*': {
|
return toNumber(left) * toNumber(right);
|
}
|
case '/': {
|
const divisor = toNumber(right);
|
if (divisor === 0) {
|
throw new RuleRuntimeError('规则里出现了除以 0');
|
}
|
return toNumber(left) / divisor;
|
}
|
default: {
|
throw new RuleRuntimeError(`不支持的运算符 ${node.operator}`);
|
}
|
}
|
}
|
|
/** 求值,返回原始结果(可能是数字、字符串、布尔) */
|
export function evaluateRule(expression: string, context: unknown): unknown {
|
return evaluateNode(parseRule(expression), context);
|
}
|
|
/** 求值并转成布尔判定,用于「是否合格」这类条件 */
|
export function evaluateCondition(expression: string, context: unknown): boolean {
|
return truthy(evaluateRule(expression, context));
|
}
|
|
/** 对已解析的语法树求值,避免同一条规则在多行上重复解析 */
|
export function evaluateRuleNode(node: RuleNode, context: unknown): unknown {
|
return evaluateNode(node, context);
|
}
|
|
/** 对已解析的语法树做布尔判定 */
|
export function evaluateConditionNode(node: RuleNode, context: unknown): boolean {
|
return truthy(evaluateRuleNode(node, context));
|
}
|
|
/** 解析后的路径依赖,渲染前可据此检查数据是否齐备 */
|
export function listRulePaths(node: RuleNode): string[] {
|
const paths: string[] = [];
|
const visit = (current: RuleNode): void => {
|
switch (current.type) {
|
case 'path': {
|
paths.push(current.path);
|
return;
|
}
|
case 'call': {
|
current.args.forEach(visit);
|
return;
|
}
|
case 'unary': {
|
visit(current.operand);
|
return;
|
}
|
case 'binary': {
|
visit(current.left);
|
visit(current.right);
|
return;
|
}
|
case 'between': {
|
visit(current.value);
|
visit(current.lower);
|
visit(current.upper);
|
return;
|
}
|
case 'in': {
|
visit(current.value);
|
current.items.forEach(visit);
|
return;
|
}
|
default: {
|
return;
|
}
|
}
|
};
|
visit(node);
|
// 统计函数作用在数组路径上,这里去掉末端字段只保留数组本身
|
return [...new Set(paths.map((path) => parsePath(path).join('.')))];
|
}
|