阶段1-rpa-core建包: engine+31个纯Node能力迁入packages/rpa-core, 领域类型单一源化, createEngine工厂收拢模块级状态
- engine/(registry/types/runtime/expression)物理迁入包, 解耦shared/types(域类型归包, 宿主type-only re-export) - 31个纯Node能力+27个测试迁入包; 宿主留守Electron/浏览器/AI依赖能力, index.ts经@rpa/core/capabilities拉起 - 新增createEngine()/defaultEngine: excel缓存/Excel句柄/db连接/FTP客户端/OCR实例/拟真设置挂引擎实例 - remaining-core子流程依赖改为host-bridge注入, 包内不再import宿主flows - 宿主main/index.ts启动时注入子流程源; electron-vite/vitest/tsconfig配@rpa/core源码别名 - 验证: typecheck绿, 快照909个id diff为零, 332测试过(2个桌面冒烟因会话无交互SetForegroundWindow被拒, 环境阻塞)
This commit is contained in:
1 parent
4139400dbd
commit
caa8e19332
111 files changed
+581
-337
No files matched your search
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* 轻量表达式求值器:支持 ${expr} 插值、变量、成员/下标、算术/比较/逻辑/三元与白名单函数。
|
||||
* 不使用 eval / new Function;错误带字符位置,便于在节点日志里定位。
|
||||
*/
|
||||
|
||||
type TokenType = "num" | "str" | "ident" | "op" | "eof";
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export class ExpressionError extends Error {
|
||||
position: number;
|
||||
constructor(message: string, position = 0) {
|
||||
super(message);
|
||||
this.name = "ExpressionError";
|
||||
this.position = position;
|
||||
}
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
"===",
|
||||
"!==",
|
||||
"==",
|
||||
"!=",
|
||||
">=",
|
||||
"<=",
|
||||
"&&",
|
||||
"||",
|
||||
"??",
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/",
|
||||
"%",
|
||||
">",
|
||||
"<",
|
||||
"!",
|
||||
"?",
|
||||
":",
|
||||
"(",
|
||||
")",
|
||||
"[",
|
||||
"]",
|
||||
".",
|
||||
","
|
||||
];
|
||||
|
||||
function tokenize(source: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (/\s/.test(ch)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(source[i + 1] || ""))) {
|
||||
const start = i;
|
||||
while (i < source.length && /[0-9.]/.test(source[i])) i += 1;
|
||||
tokens.push({ type: "num", value: source.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
const quote = ch;
|
||||
const start = i;
|
||||
i += 1;
|
||||
let value = "";
|
||||
while (i < source.length && source[i] !== quote) {
|
||||
if (source[i] === "\\") {
|
||||
i += 1;
|
||||
const next = source[i];
|
||||
if (next === "n") value += "\n";
|
||||
else if (next === "t") value += "\t";
|
||||
else if (next === "r") value += "\r";
|
||||
else value += next ?? "";
|
||||
i += 1;
|
||||
} else {
|
||||
value += source[i];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if (source[i] !== quote) throw new ExpressionError("字符串未闭合", start);
|
||||
i += 1;
|
||||
tokens.push({ type: "str", value, pos: start });
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_\u4e00-\u9fa5$]/.test(ch)) {
|
||||
const start = i;
|
||||
while (i < source.length && /[A-Za-z0-9_\u4e00-\u9fa5$]/.test(source[i])) i += 1;
|
||||
tokens.push({ type: "ident", value: source.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
const op = OPERATORS.find((candidate) => source.startsWith(candidate, i));
|
||||
if (op) {
|
||||
tokens.push({ type: "op", value: op, pos: i });
|
||||
i += op.length;
|
||||
continue;
|
||||
}
|
||||
throw new ExpressionError(`无法识别的字符「${ch}」`, i);
|
||||
}
|
||||
tokens.push({ type: "eof", value: "", pos: source.length });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
type Ast =
|
||||
| { type: "literal"; value: unknown }
|
||||
| { type: "identifier"; name: string; pos: number }
|
||||
| { type: "member"; object: Ast; property: Ast; computed: boolean; pos: number }
|
||||
| { type: "call"; callee: Ast; args: Ast[]; pos: number }
|
||||
| { type: "unary"; op: string; argument: Ast; pos: number }
|
||||
| { type: "binary"; op: string; left: Ast; right: Ast; pos: number }
|
||||
| { type: "ternary"; test: Ast; consequent: Ast; alternate: Ast; pos: number }
|
||||
| { type: "array"; items: Ast[]; pos: number };
|
||||
|
||||
const PRECEDENCE: Record<string, number> = {
|
||||
"||": 1,
|
||||
"??": 1,
|
||||
"&&": 2,
|
||||
"==": 3,
|
||||
"!=": 3,
|
||||
"===": 3,
|
||||
"!==": 3,
|
||||
">": 4,
|
||||
">=": 4,
|
||||
"<": 4,
|
||||
"<=": 4,
|
||||
"+": 5,
|
||||
"-": 5,
|
||||
"*": 6,
|
||||
"/": 6,
|
||||
"%": 6
|
||||
};
|
||||
|
||||
class Parser {
|
||||
private tokens: Token[];
|
||||
private index = 0;
|
||||
|
||||
constructor(source: string) {
|
||||
this.tokens = tokenize(source);
|
||||
}
|
||||
|
||||
parse(): Ast {
|
||||
const expression = this.parseExpression(0);
|
||||
const token = this.peek();
|
||||
if (token.type !== "eof") throw new ExpressionError(`多余的内容「${token.value}」`, token.pos);
|
||||
return expression;
|
||||
}
|
||||
|
||||
private peek(): Token {
|
||||
return this.tokens[this.index];
|
||||
}
|
||||
|
||||
private next(): Token {
|
||||
return this.tokens[this.index++];
|
||||
}
|
||||
|
||||
private expect(value: string): Token {
|
||||
const token = this.next();
|
||||
if (token.value !== value) throw new ExpressionError(`期望「${value}」,实际「${token.value || "结束"}」`, token.pos);
|
||||
return token;
|
||||
}
|
||||
|
||||
private parseExpression(minPrecedence: number): Ast {
|
||||
let left = this.parsePrefix();
|
||||
for (;;) {
|
||||
const token = this.peek();
|
||||
if (token.type === "op" && token.value === "?") {
|
||||
if (minPrecedence > 0) break;
|
||||
this.next();
|
||||
const consequent = this.parseExpression(0);
|
||||
this.expect(":");
|
||||
const alternate = this.parseExpression(0);
|
||||
left = { type: "ternary", test: left, consequent, alternate, pos: token.pos };
|
||||
continue;
|
||||
}
|
||||
const precedence = token.type === "op" ? PRECEDENCE[token.value] : undefined;
|
||||
if (precedence === undefined || precedence < minPrecedence) break;
|
||||
this.next();
|
||||
const right = this.parseExpression(precedence + 1);
|
||||
left = { type: "binary", op: token.value, left, right, pos: token.pos };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parsePrefix(): Ast {
|
||||
const token = this.next();
|
||||
if (token.type === "num") return { type: "literal", value: Number(token.value) };
|
||||
if (token.type === "str") return { type: "literal", value: token.value };
|
||||
if (token.type === "ident") {
|
||||
if (token.value === "true") return { type: "literal", value: true };
|
||||
if (token.value === "false") return { type: "literal", value: false };
|
||||
if (token.value === "null") return { type: "literal", value: null };
|
||||
if (token.value === "undefined") return { type: "literal", value: undefined };
|
||||
return this.parsePostfix({ type: "identifier", name: token.value, pos: token.pos });
|
||||
}
|
||||
if (token.type === "op") {
|
||||
if (token.value === "(") {
|
||||
const expression = this.parseExpression(0);
|
||||
this.expect(")");
|
||||
return this.parsePostfix(expression);
|
||||
}
|
||||
if (token.value === "[" ) {
|
||||
const items: Ast[] = [];
|
||||
if (this.peek().value !== "]") {
|
||||
for (;;) {
|
||||
items.push(this.parseExpression(0));
|
||||
if (this.peek().value === ",") {
|
||||
this.next();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.expect("]");
|
||||
return { type: "array", items, pos: token.pos };
|
||||
}
|
||||
if (token.value === "!" || token.value === "-") {
|
||||
return { type: "unary", op: token.value, argument: this.parseExpression(7), pos: token.pos };
|
||||
}
|
||||
}
|
||||
throw new ExpressionError(`意外的「${token.value || "结束"}」`, token.pos);
|
||||
}
|
||||
|
||||
private parsePostfix(base: Ast): Ast {
|
||||
let expression = base;
|
||||
for (;;) {
|
||||
const token = this.peek();
|
||||
if (token.value === ".") {
|
||||
this.next();
|
||||
const property = this.next();
|
||||
if (property.type !== "ident") throw new ExpressionError("属性名缺失", property.pos);
|
||||
expression = {
|
||||
type: "member",
|
||||
object: expression,
|
||||
property: { type: "literal", value: property.value },
|
||||
computed: false,
|
||||
pos: token.pos
|
||||
};
|
||||
} else if (token.value === "[") {
|
||||
this.next();
|
||||
const property = this.parseExpression(0);
|
||||
this.expect("]");
|
||||
expression = { type: "member", object: expression, property, computed: true, pos: token.pos };
|
||||
} else if (token.value === "(") {
|
||||
this.next();
|
||||
const args: Ast[] = [];
|
||||
if (this.peek().value !== ")") {
|
||||
for (;;) {
|
||||
args.push(this.parseExpression(0));
|
||||
if (this.peek().value === ",") {
|
||||
this.next();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.expect(")");
|
||||
expression = { type: "call", callee: expression, args, pos: token.pos };
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return expression;
|
||||
}
|
||||
}
|
||||
|
||||
const astCache = new Map<string, Ast>();
|
||||
|
||||
function parse(source: string): Ast {
|
||||
const cached = astCache.get(source);
|
||||
if (cached) return cached;
|
||||
const ast = new Parser(source).parse();
|
||||
if (astCache.size > 500) astCache.clear();
|
||||
astCache.set(source, ast);
|
||||
return ast;
|
||||
}
|
||||
|
||||
type Scope = Record<string, unknown>;
|
||||
|
||||
function hasOwn(scope: Scope, name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(scope, name);
|
||||
}
|
||||
|
||||
function formatDate(value: unknown, pattern = "YYYY-MM-DD HH:mm:ss"): string {
|
||||
const date = value instanceof Date ? value : new Date(value as string | number);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return pattern
|
||||
.replace(/YYYY/g, String(date.getFullYear()))
|
||||
.replace(/MM/g, pad(date.getMonth() + 1))
|
||||
.replace(/DD/g, pad(date.getDate()))
|
||||
.replace(/HH/g, pad(date.getHours()))
|
||||
.replace(/mm/g, pad(date.getMinutes()))
|
||||
.replace(/ss/g, pad(date.getSeconds()));
|
||||
}
|
||||
|
||||
const FUNCTIONS: Record<string, (...args: unknown[]) => unknown> = {
|
||||
len: (value) => (value == null ? 0 : (value as string | unknown[]).length ?? 0),
|
||||
upper: (value) => String(value ?? "").toUpperCase(),
|
||||
lower: (value) => String(value ?? "").toLowerCase(),
|
||||
trim: (value) => String(value ?? "").trim(),
|
||||
replace: (value, from, to) => String(value ?? "").split(String(from ?? "")).join(String(to ?? "")),
|
||||
split: (value, separator) => String(value ?? "").split(String(separator ?? "")),
|
||||
join: (value, separator = ",") => (Array.isArray(value) ? value.join(String(separator)) : ""),
|
||||
contains: (value, needle) => String(value ?? "").includes(String(needle ?? "")),
|
||||
startsWith: (value, prefix) => String(value ?? "").startsWith(String(prefix ?? "")),
|
||||
endsWith: (value, suffix) => String(value ?? "").endsWith(String(suffix ?? "")),
|
||||
matches: (value, pattern) => {
|
||||
try {
|
||||
return new RegExp(String(pattern ?? "")).test(String(value ?? ""));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
number: (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
},
|
||||
string: (value) => (value == null ? "" : String(value)),
|
||||
round: (value, digits = 0) => {
|
||||
const factor = 10 ** Number(digits || 0);
|
||||
return Math.round(Number(value) * factor) / factor;
|
||||
},
|
||||
abs: (value) => Math.abs(Number(value)),
|
||||
min: (...args) => Math.min(...args.map(Number)),
|
||||
max: (...args) => Math.max(...args.map(Number)),
|
||||
random: () => Math.random(),
|
||||
now: () => new Date().toISOString(),
|
||||
today: () => formatDate(new Date(), "YYYY-MM-DD"),
|
||||
formatDate: (value, pattern) => formatDate(value, pattern == null ? undefined : String(pattern)),
|
||||
json: (value) => JSON.stringify(value),
|
||||
parseJson: (value) => {
|
||||
try {
|
||||
return JSON.parse(String(value ?? ""));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
sum: (value) => (Array.isArray(value) ? value.reduce((sum, item) => sum + Number(item || 0), 0) : 0),
|
||||
avg: (value) => (Array.isArray(value) && value.length ? value.reduce((sum, item) => sum + Number(item || 0), 0) / value.length : 0),
|
||||
coalesce: (...args) => args.find((item) => item !== undefined && item !== null && item !== "") ?? ""
|
||||
};
|
||||
|
||||
function evaluateAst(ast: Ast, scope: Scope): unknown {
|
||||
switch (ast.type) {
|
||||
case "literal":
|
||||
return ast.value;
|
||||
case "identifier": {
|
||||
if (hasOwn(scope, ast.name)) return scope[ast.name];
|
||||
if (ast.name in FUNCTIONS) return FUNCTIONS[ast.name];
|
||||
return undefined;
|
||||
}
|
||||
case "array":
|
||||
return ast.items.map((item) => evaluateAst(item, scope));
|
||||
case "member": {
|
||||
const object = evaluateAst(ast.object, scope);
|
||||
const property = evaluateAst(ast.property, scope);
|
||||
if (object == null) return undefined;
|
||||
if (Array.isArray(object) && property === "length") return object.length;
|
||||
if (typeof object === "string" && property === "length") return object.length;
|
||||
return (object as Record<string | number, unknown>)[property as string | number];
|
||||
}
|
||||
case "call": {
|
||||
if (ast.callee.type !== "identifier") throw new ExpressionError("只允许调用白名单函数", ast.pos);
|
||||
const fn = FUNCTIONS[ast.callee.name];
|
||||
if (!fn) throw new ExpressionError(`未知函数「${ast.callee.name}」`, ast.pos);
|
||||
return fn(...ast.args.map((arg) => evaluateAst(arg, scope)));
|
||||
}
|
||||
case "unary": {
|
||||
const value = evaluateAst(ast.argument, scope);
|
||||
if (ast.op === "!") return !value;
|
||||
return -Number(value);
|
||||
}
|
||||
case "ternary": {
|
||||
return evaluateAst(ast.test, scope) ? evaluateAst(ast.consequent, scope) : evaluateAst(ast.alternate, scope);
|
||||
}
|
||||
case "binary": {
|
||||
if (ast.op === "&&") {
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
return left ? evaluateAst(ast.right, scope) : left;
|
||||
}
|
||||
if (ast.op === "||") {
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
return left ? left : evaluateAst(ast.right, scope);
|
||||
}
|
||||
if (ast.op === "??") {
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
return left ?? evaluateAst(ast.right, scope);
|
||||
}
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
const right = evaluateAst(ast.right, scope);
|
||||
switch (ast.op) {
|
||||
case "+":
|
||||
return typeof left === "string" || typeof right === "string"
|
||||
? String(left ?? "") + String(right ?? "")
|
||||
: Number(left) + Number(right);
|
||||
case "-":
|
||||
return Number(left) - Number(right);
|
||||
case "*":
|
||||
return Number(left) * Number(right);
|
||||
case "/":
|
||||
return Number(left) / Number(right);
|
||||
case "%":
|
||||
return Number(left) % Number(right);
|
||||
case ">":
|
||||
return left! > right!;
|
||||
case ">=":
|
||||
return left! >= right!;
|
||||
case "<":
|
||||
return left! < right!;
|
||||
case "<=":
|
||||
return left! <= right!;
|
||||
case "==":
|
||||
return left == right;
|
||||
case "!=":
|
||||
return left != right;
|
||||
case "===":
|
||||
return left === right;
|
||||
case "!==":
|
||||
return left !== right;
|
||||
default:
|
||||
throw new ExpressionError(`不支持的运算符「${ast.op}」`, ast.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluate(expression: string, scope: Scope = {}): unknown {
|
||||
const source = expression.trim();
|
||||
if (!source) return "";
|
||||
return evaluateAst(parse(source), scope);
|
||||
}
|
||||
|
||||
/** 整个字符串是 ${expr} 时保留类型;否则按字符串插值。 */
|
||||
export function interpolate(value: unknown, scope: Scope): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
const whole = value.match(/^\s*\$\{([\s\S]*)\}\s*$/);
|
||||
if (whole) return evaluate(whole[1], scope);
|
||||
if (!value.includes("${")) return value;
|
||||
return value.replace(/\$\{([\s\S]*?)\}/g, (_match, expression: string) => {
|
||||
const result = evaluate(expression, scope);
|
||||
return result == null ? "" : typeof result === "object" ? JSON.stringify(result) : String(result);
|
||||
});
|
||||
}
|
||||
|
||||
/** 条件表达式允许直接写变量名或 ${...} 包裹。 */
|
||||
export function evaluateCondition(value: unknown, scope: Scope): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value !== "string") return Boolean(value);
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
const inner = trimmed.match(/^\$\{([\s\S]*)\}$/);
|
||||
const result = interpolate(inner ? trimmed : `\${${trimmed}}`, scope);
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
export function toNumber(value: unknown, fallback = 0): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
Reference in new issue
Block a user