M3(1/5): @sfeel —— S-FEEL 子集的解析与求值,前后端同源
- 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
This commit is contained in:
co-authored by
Claude Opus 5
parent
f0444f1417
commit
146e9bbfad
+11
-11
@@ -38,8 +38,8 @@ export declare const formFieldKindSchema: z.ZodEnum<{
|
||||
number: "number";
|
||||
file: "file";
|
||||
text: "text";
|
||||
check: "check";
|
||||
date: "date";
|
||||
check: "check";
|
||||
textarea: "textarea";
|
||||
radio: "radio";
|
||||
select: "select";
|
||||
@@ -54,8 +54,8 @@ export declare const formFieldSchema: z.ZodObject<{
|
||||
number: "number";
|
||||
file: "file";
|
||||
text: "text";
|
||||
check: "check";
|
||||
date: "date";
|
||||
check: "check";
|
||||
textarea: "textarea";
|
||||
radio: "radio";
|
||||
select: "select";
|
||||
@@ -191,8 +191,8 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"mail">;
|
||||
dir: z.ZodEnum<{
|
||||
out: "out";
|
||||
in: "in";
|
||||
out: "out";
|
||||
}>;
|
||||
statusLabel: z.ZodOptional<z.ZodString>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
@@ -418,8 +418,8 @@ export declare const deliverableSchema: z.ZodObject<{
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"mail">;
|
||||
dir: z.ZodEnum<{
|
||||
out: "out";
|
||||
in: "in";
|
||||
out: "out";
|
||||
}>;
|
||||
statusLabel: z.ZodOptional<z.ZodString>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
@@ -652,8 +652,8 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"mail">;
|
||||
dir: z.ZodEnum<{
|
||||
out: "out";
|
||||
in: "in";
|
||||
out: "out";
|
||||
}>;
|
||||
statusLabel: z.ZodOptional<z.ZodString>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
@@ -773,8 +773,8 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
||||
number: "number";
|
||||
file: "file";
|
||||
text: "text";
|
||||
check: "check";
|
||||
date: "date";
|
||||
check: "check";
|
||||
textarea: "textarea";
|
||||
radio: "radio";
|
||||
select: "select";
|
||||
@@ -863,8 +863,8 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
||||
number: "number";
|
||||
file: "file";
|
||||
text: "text";
|
||||
check: "check";
|
||||
date: "date";
|
||||
check: "check";
|
||||
textarea: "textarea";
|
||||
radio: "radio";
|
||||
select: "select";
|
||||
@@ -995,8 +995,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
t: z.ZodLiteral<"mail">;
|
||||
dir: z.ZodEnum<{
|
||||
out: "out";
|
||||
in: "in";
|
||||
out: "out";
|
||||
}>;
|
||||
statusLabel: z.ZodOptional<z.ZodString>;
|
||||
editable: z.ZodOptional<z.ZodBoolean>;
|
||||
@@ -1116,8 +1116,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
number: "number";
|
||||
file: "file";
|
||||
text: "text";
|
||||
check: "check";
|
||||
date: "date";
|
||||
check: "check";
|
||||
textarea: "textarea";
|
||||
radio: "radio";
|
||||
select: "select";
|
||||
@@ -1180,8 +1180,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
number: "number";
|
||||
file: "file";
|
||||
text: "text";
|
||||
check: "check";
|
||||
date: "date";
|
||||
check: "check";
|
||||
textarea: "textarea";
|
||||
radio: "radio";
|
||||
select: "select";
|
||||
@@ -1375,9 +1375,9 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"system">;
|
||||
type: z.ZodEnum<{
|
||||
time: "time";
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
|
||||
+5
-5
@@ -8,9 +8,9 @@
|
||||
import { z } from 'zod';
|
||||
export declare const EVENT_TYPES: readonly ["data", "time", "human", "control"];
|
||||
export declare const eventTypeSchema: z.ZodEnum<{
|
||||
time: "time";
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
export type EventType = z.infer<typeof eventTypeSchema>;
|
||||
@@ -71,9 +71,9 @@ export type EventPayload = z.infer<typeof eventPayloadSchema>;
|
||||
export declare const envelopeSchema: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "EventId", "out">;
|
||||
type: z.ZodEnum<{
|
||||
time: "time";
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
correlation: z.ZodObject<{
|
||||
@@ -134,9 +134,9 @@ export type Envelope = z.infer<typeof envelopeSchema>;
|
||||
*/
|
||||
export declare const envelopeDraftSchema: z.ZodObject<{
|
||||
type: z.ZodEnum<{
|
||||
time: "time";
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
payload: z.ZodObject<{
|
||||
@@ -235,9 +235,9 @@ export declare const listenerSpecSchema: z.ZodObject<{
|
||||
dynamic: "dynamic";
|
||||
}>;
|
||||
eventType: z.ZodEnum<{
|
||||
time: "time";
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
keyFilter: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
@@ -260,9 +260,9 @@ export declare const registeredListenerSchema: z.ZodObject<{
|
||||
dynamic: "dynamic";
|
||||
}>;
|
||||
eventType: z.ZodEnum<{
|
||||
time: "time";
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
keyFilter: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
|
||||
+2
-2
@@ -77,9 +77,9 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
|
||||
dynamic: "dynamic";
|
||||
}>;
|
||||
eventType: z.ZodEnum<{
|
||||
time: "time";
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
keyFilter: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
@@ -954,9 +954,9 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
dynamic: "dynamic";
|
||||
}>;
|
||||
eventType: z.ZodEnum<{
|
||||
time: "time";
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
keyFilter: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
|
||||
+1
-1
@@ -535,9 +535,9 @@ export declare const triggerConfigSchema: z.ZodObject<{
|
||||
kind: z.ZodLiteral<"event">;
|
||||
rule: z.ZodString;
|
||||
eventType: z.ZodDefault<z.ZodEnum<{
|
||||
time: "time";
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
|
||||
+17
-2
@@ -1,5 +1,20 @@
|
||||
/**
|
||||
* @sfeel —— S-FEEL 子集(OS 03 定义域)的解析与求值,纯函数(编码规范 §8.3)。
|
||||
* 被 process / flow-engine / trigger 与前端 gov-process 共用:求值语义一份代码,前后端同源。
|
||||
*
|
||||
* 被 `process`(边条件 / 失败走向)、`flow-engine`(条件谓词)、`trigger`(判定)、
|
||||
* `loop-engine`(前提 ifPart / DMN 单元格)与前端 `gov-process`(画布即时校验预览)共用 ——
|
||||
* **求值语义一份代码,前后端同源**;后端裁决为权威。
|
||||
*
|
||||
* 子集封顶(ADR-006:自研,不引第三方 FEEL 引擎):
|
||||
* 比较 · 逻辑 · 算术 · 区间 · 列表与 in · 封闭的内建函数集(字符串 / 数字 / 日期)。
|
||||
* **无外部调用,静态可检。**
|
||||
*/
|
||||
export declare const SFEEL_VERSION = "0.0.0-m0";
|
||||
export type { Node, UnaryTest } from './ast.ts';
|
||||
export type { Token, TokenKind } from './lexer.ts';
|
||||
export { SfeelSyntaxError, tokenize } from './lexer.ts';
|
||||
export { parse, parseUnaryTest } from './parser.ts';
|
||||
export type { Scope } from './evaluate.ts';
|
||||
export { BUILTIN_FUNCTIONS, evaluate, evaluateCondition, evaluateNode, evaluateUnaryTest, SfeelEvalError, testMatches, truthy, } from './evaluate.ts';
|
||||
export type { StaticCheckResult, StaticIssue } from './static-check.ts';
|
||||
export { staticCheck } from './static-check.ts';
|
||||
export declare const SFEEL_VERSION = "0.1.0-m3";
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
|
||||
import type { EventService, TriggerMatcher } from '@modules/event';
|
||||
@@ -83,8 +84,20 @@ const emptyInFlightProbe: InFlightWorkProbe = {
|
||||
/** 触发器判定 —— trigger(L7)随 M5 落地,当下恒不命中(docs/debt.md · M1-1) */
|
||||
const noTriggerMatcher: TriggerMatcher = { match: async () => [] };
|
||||
|
||||
/** S-FEEL 求值 —— @sfeel 随 M3 落地;在此之前监听只用类型 + 关联键过滤 */
|
||||
const passthroughEvaluator = (): boolean => true;
|
||||
/**
|
||||
* 监听内容条件的求值 —— @sfeel 的求值语义**前后端同源**(§8.3)。
|
||||
* 表达式写坏了不该让事件流停摆:求值异常按「不命中」处置并留痕。
|
||||
*/
|
||||
const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>): boolean => {
|
||||
try {
|
||||
return evaluateCondition(expression, scope);
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`[listener] 条件求值失败,按不命中处置:${expression} —— ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 第一方工具实现 —— 由 app 注册进 ToolRuntime;活动只见调用契约 */
|
||||
export const firstPartyToolHandlers = new Map<string, ToolHandler>();
|
||||
@@ -133,7 +146,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
store: createEventStore(db, ids),
|
||||
clock: systemClock,
|
||||
ids,
|
||||
evaluate: passthroughEvaluator,
|
||||
evaluate: sfeelEvaluator,
|
||||
triggers: noTriggerMatcher,
|
||||
messages: message,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/** S-FEEL 子集 —— 纯函数,零 mock(编码规范 §8.3;定义域 OS 03) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
evaluate,
|
||||
evaluateCondition,
|
||||
evaluateUnaryTest,
|
||||
parse,
|
||||
SfeelEvalError,
|
||||
SfeelSyntaxError,
|
||||
staticCheck,
|
||||
} from '../src/index.ts';
|
||||
|
||||
const scope = {
|
||||
amount: 12000,
|
||||
grade: 'A',
|
||||
customer: { code: 'C001', grade: '战略客户', payment: 30 },
|
||||
type: 'B',
|
||||
quotedAt: '2026-07-20T00:00:00.000Z',
|
||||
today: '2026-07-27T00:00:00.000Z',
|
||||
tags: ['urgent', 'auto'],
|
||||
};
|
||||
|
||||
describe('字面量与路径', () => {
|
||||
it('数字 / 字符串 / 布尔 / null', () => {
|
||||
expect(evaluate('1', {})).toBe(1);
|
||||
expect(evaluate('1.5', {})).toBe(1.5);
|
||||
expect(evaluate('"hi"', {})).toBe('hi');
|
||||
expect(evaluate("'hi'", {})).toBe('hi');
|
||||
expect(evaluate('true', {})).toBe(true);
|
||||
expect(evaluate('null', {})).toBe(null);
|
||||
});
|
||||
|
||||
it('多级路径', () => {
|
||||
expect(evaluate('customer.grade', scope)).toBe('战略客户');
|
||||
expect(evaluate('customer.missing', scope)).toBeUndefined();
|
||||
expect(evaluate('nothing.deep.deeper', scope)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('算术与字符串拼接', () => {
|
||||
it('四则与优先级', () => {
|
||||
expect(evaluate('1 + 2 * 3', {})).toBe(7);
|
||||
expect(evaluate('(1 + 2) * 3', {})).toBe(9);
|
||||
expect(evaluate('10 / 4', {})).toBe(2.5);
|
||||
expect(evaluate('-amount + 1', scope)).toBe(-11999);
|
||||
});
|
||||
|
||||
it('除零报错,不静默出 Infinity', () => {
|
||||
expect(() => evaluate('1 / 0', {})).toThrow(SfeelEvalError);
|
||||
});
|
||||
|
||||
it('任一侧是字符串即拼接', () => {
|
||||
expect(evaluate('"Q" + 202601', {})).toBe('Q202601');
|
||||
});
|
||||
});
|
||||
|
||||
describe('比较与逻辑', () => {
|
||||
it('比较运算', () => {
|
||||
expect(evaluate('amount > 10000', scope)).toBe(true);
|
||||
expect(evaluate('amount >= 12000', scope)).toBe(true);
|
||||
expect(evaluate('grade = "A"', scope)).toBe(true);
|
||||
expect(evaluate('grade != "B"', scope)).toBe(true);
|
||||
});
|
||||
|
||||
it('字符串按字典序比较', () => {
|
||||
expect(evaluate('"a" < "b"', {})).toBe(true);
|
||||
});
|
||||
|
||||
it('and / or 短路', () => {
|
||||
expect(evaluate('amount > 10000 and grade = "A"', scope)).toBe(true);
|
||||
expect(evaluate('amount > 999999 and nothing.x = 1', scope)).toBe(false);
|
||||
expect(evaluate('grade = "A" or nothing.x = 1', scope)).toBe(true);
|
||||
});
|
||||
|
||||
it('not()', () => {
|
||||
expect(evaluate('not(grade = "B")', scope)).toBe(true);
|
||||
});
|
||||
|
||||
it('缺失值不为真', () => {
|
||||
expect(evaluateCondition('missing', scope)).toBe(false);
|
||||
expect(evaluateCondition('customer.code', scope)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('区间、列表与 in', () => {
|
||||
it('闭区间', () => {
|
||||
expect(evaluate('amount in [10000..20000]', scope)).toBe(true);
|
||||
expect(evaluate('amount in [12001..20000]', scope)).toBe(false);
|
||||
});
|
||||
|
||||
it('开区间', () => {
|
||||
expect(evaluate('12000 in (12000..20000]', {})).toBe(false);
|
||||
expect(evaluate('12000 in [12000..20000)', {})).toBe(true);
|
||||
});
|
||||
|
||||
it('列表', () => {
|
||||
expect(evaluate('type in ["A", "B"]', scope)).toBe(true);
|
||||
expect(evaluate('type in ["A", "C"]', scope)).toBe(false);
|
||||
});
|
||||
|
||||
it('空列表', () => {
|
||||
expect(evaluate('type in []', scope)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('内建函数(封闭集合)', () => {
|
||||
it('字符串', () => {
|
||||
expect(evaluate('upper case("ab")', {})).toBe('AB');
|
||||
expect(evaluate('lower case("AB")', {})).toBe('ab');
|
||||
expect(evaluate('contains("LM358DR", "358")', {})).toBe(true);
|
||||
expect(evaluate('starts with("LM358", "LM")', {})).toBe(true);
|
||||
expect(evaluate('ends with("LM358", "58")', {})).toBe(true);
|
||||
expect(evaluate('string length("LM358")', {})).toBe(5);
|
||||
expect(evaluate('substring("LM358DR", 3, 3)', {})).toBe('358');
|
||||
});
|
||||
|
||||
it('数字', () => {
|
||||
expect(evaluate('abs(-3)', {})).toBe(3);
|
||||
expect(evaluate('floor(2.7)', {})).toBe(2);
|
||||
expect(evaluate('ceiling(2.1)', {})).toBe(3);
|
||||
expect(evaluate('round(2.345, 2)', {})).toBe(2.35);
|
||||
expect(evaluate('min(3, 1, 2)', {})).toBe(1);
|
||||
expect(evaluate('max([3, 1, 2])', {})).toBe(3);
|
||||
expect(evaluate('sum([1, 2, 3])', {})).toBe(6);
|
||||
expect(evaluate('count(tags)', scope)).toBe(2);
|
||||
});
|
||||
|
||||
it('日期与天数差 —— 时限与偏移计算的底盘', () => {
|
||||
expect(evaluate('days and time duration(quotedAt, today)', scope)).toBe(7);
|
||||
expect(evaluate('date("2026-07-27") > date("2026-07-20")', {})).toBe(true);
|
||||
});
|
||||
|
||||
it('未知函数报错 —— 子集封顶,不可扩展', () => {
|
||||
expect(() => evaluate('fetch("http://x")', {})).toThrow(/未知函数/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('语法错误', () => {
|
||||
it('字符串未闭合', () => {
|
||||
expect(() => parse('"abc')).toThrow(SfeelSyntaxError);
|
||||
});
|
||||
it('括号不匹配', () => {
|
||||
expect(() => parse('(1 + 2')).toThrow(SfeelSyntaxError);
|
||||
});
|
||||
it('尾部多余记号', () => {
|
||||
expect(() => parse('1 2')).toThrow(SfeelSyntaxError);
|
||||
});
|
||||
it('无法识别的字符', () => {
|
||||
expect(() => parse('1 # 2')).toThrow(SfeelSyntaxError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('单元格谓词 —— DMN 决策表的一格', () => {
|
||||
it('`-` 与空格代表任意', () => {
|
||||
expect(evaluateUnaryTest('-', '任何值')).toBe(true);
|
||||
expect(evaluateUnaryTest(' ', 42)).toBe(true);
|
||||
});
|
||||
|
||||
it('等值(裸值即等值)', () => {
|
||||
expect(evaluateUnaryTest('"战略客户"', '战略客户')).toBe(true);
|
||||
expect(evaluateUnaryTest('"战略客户"', '普通客户')).toBe(false);
|
||||
});
|
||||
|
||||
it('比较', () => {
|
||||
expect(evaluateUnaryTest('< 10000', 9000)).toBe(true);
|
||||
expect(evaluateUnaryTest('>= 10000', 10000)).toBe(true);
|
||||
expect(evaluateUnaryTest('!= "A"', 'B')).toBe(true);
|
||||
});
|
||||
|
||||
it('区间', () => {
|
||||
expect(evaluateUnaryTest('[1..5]', 3)).toBe(true);
|
||||
expect(evaluateUnaryTest('[1..5]', 6)).toBe(false);
|
||||
expect(evaluateUnaryTest('(1..5)', 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('逗号即析取', () => {
|
||||
expect(evaluateUnaryTest('"A", "B"', 'B')).toBe(true);
|
||||
expect(evaluateUnaryTest('"A", "B"', 'C')).toBe(false);
|
||||
expect(evaluateUnaryTest('< 0, > 100', 200)).toBe(true);
|
||||
});
|
||||
|
||||
it('三分类规则 P-03 的行谓词', () => {
|
||||
// A:历史价 ≤90 天且行情稳
|
||||
const row = (days: number, stable: boolean) =>
|
||||
evaluateUnaryTest('<= 90', days) && evaluateUnaryTest('true', stable);
|
||||
expect(row(30, true)).toBe(true);
|
||||
expect(row(120, true)).toBe(false);
|
||||
expect(row(30, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staticCheck —— 画图即校验', () => {
|
||||
it('语法正确即 ok,并列出引用的路径', () => {
|
||||
const r = staticCheck('customer.grade = "战略客户" and amount > 10000');
|
||||
expect(r.ok).toBe(true);
|
||||
expect([...r.references].sort()).toEqual(['amount', 'customer.grade']);
|
||||
});
|
||||
|
||||
it('语法错误即拦下', () => {
|
||||
const r = staticCheck('customer.grade = ');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.issues[0]?.kind).toBe('syntax');
|
||||
});
|
||||
|
||||
it('未知函数即拦下 —— 无外部调用', () => {
|
||||
const r = staticCheck('fetch("http://x")');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.issues.some((i) => i.kind === 'unknown-function')).toBe(true);
|
||||
});
|
||||
|
||||
it('单元格谓词也可静态检查', () => {
|
||||
expect(staticCheck('<= 90', 'unary-test').ok).toBe(true);
|
||||
expect(staticCheck('[1..', 'unary-test').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* S-FEEL 子集的抽象语法树(编码规范 §8.3)。
|
||||
* AST 是可 diff、可静态检查的中间形态 —— 前端画布的即时校验与后端裁决同源。
|
||||
*/
|
||||
|
||||
export type Node =
|
||||
| { readonly kind: 'literal'; readonly value: number | string | boolean | null }
|
||||
| { readonly kind: 'path'; readonly segments: readonly string[] }
|
||||
| { readonly kind: 'unary'; readonly op: '-' | 'not'; readonly operand: Node }
|
||||
| {
|
||||
readonly kind: 'binary';
|
||||
readonly op: '+' | '-' | '*' | '/' | '=' | '!=' | '<' | '<=' | '>' | '>=' | 'and' | 'or';
|
||||
readonly left: Node;
|
||||
readonly right: Node;
|
||||
}
|
||||
| { readonly kind: 'list'; readonly items: readonly Node[] }
|
||||
| {
|
||||
readonly kind: 'range';
|
||||
readonly startOpen: boolean;
|
||||
readonly endOpen: boolean;
|
||||
readonly start: Node;
|
||||
readonly end: Node;
|
||||
}
|
||||
| { readonly kind: 'in'; readonly value: Node; readonly candidates: Node }
|
||||
| { readonly kind: 'call'; readonly name: string; readonly args: readonly Node[] };
|
||||
|
||||
/** 单元格谓词(DMN 决策表的一格):`-` 任意、`<10`、`[1..5]`、`"A","B"` 析取 */
|
||||
export type UnaryTest =
|
||||
| { readonly kind: 'any' }
|
||||
| { readonly kind: 'compare'; readonly op: '=' | '!=' | '<' | '<=' | '>' | '>='; readonly operand: Node }
|
||||
| { readonly kind: 'range'; readonly node: Extract<Node, { kind: 'range' }> }
|
||||
| { readonly kind: 'disjunction'; readonly tests: readonly UnaryTest[] };
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,30 @@
|
||||
/**
|
||||
* @sfeel —— S-FEEL 子集(OS 03 定义域)的解析与求值,纯函数(编码规范 §8.3)。
|
||||
* 被 process / flow-engine / trigger 与前端 gov-process 共用:求值语义一份代码,前后端同源。
|
||||
*
|
||||
* 被 `process`(边条件 / 失败走向)、`flow-engine`(条件谓词)、`trigger`(判定)、
|
||||
* `loop-engine`(前提 ifPart / DMN 单元格)与前端 `gov-process`(画布即时校验预览)共用 ——
|
||||
* **求值语义一份代码,前后端同源**;后端裁决为权威。
|
||||
*
|
||||
* 子集封顶(ADR-006:自研,不引第三方 FEEL 引擎):
|
||||
* 比较 · 逻辑 · 算术 · 区间 · 列表与 in · 封闭的内建函数集(字符串 / 数字 / 日期)。
|
||||
* **无外部调用,静态可检。**
|
||||
*/
|
||||
export const SFEEL_VERSION = '0.0.0-m0';
|
||||
export type { Node, UnaryTest } from './ast.ts';
|
||||
export type { Token, TokenKind } from './lexer.ts';
|
||||
export { SfeelSyntaxError, tokenize } from './lexer.ts';
|
||||
export { parse, parseUnaryTest } from './parser.ts';
|
||||
export type { Scope } from './evaluate.ts';
|
||||
export {
|
||||
BUILTIN_FUNCTIONS,
|
||||
evaluate,
|
||||
evaluateCondition,
|
||||
evaluateNode,
|
||||
evaluateUnaryTest,
|
||||
SfeelEvalError,
|
||||
testMatches,
|
||||
truthy,
|
||||
} from './evaluate.ts';
|
||||
export type { StaticCheckResult, StaticIssue } from './static-check.ts';
|
||||
export { staticCheck } from './static-check.ts';
|
||||
|
||||
export const SFEEL_VERSION = '0.1.0-m3';
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* S-FEEL 子集的词法分析 —— 纯函数(编码规范 §8.3;定义域 OS 03)。
|
||||
*
|
||||
* 子集范围:比较、逻辑、算术、字符串与日期函数,**无外部调用,静态可检**。
|
||||
*/
|
||||
|
||||
export const TOKEN_KINDS = [
|
||||
'number',
|
||||
'string',
|
||||
'name',
|
||||
'punct',
|
||||
'eof',
|
||||
] as const;
|
||||
export type TokenKind = (typeof TOKEN_KINDS)[number];
|
||||
|
||||
export type Token = {
|
||||
readonly kind: TokenKind;
|
||||
readonly text: string;
|
||||
readonly value?: number | string;
|
||||
readonly pos: number;
|
||||
};
|
||||
|
||||
export class SfeelSyntaxError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly pos: number,
|
||||
) {
|
||||
super(`S-FEEL 语法错误(第 ${pos} 字符):${message}`);
|
||||
this.name = 'SfeelSyntaxError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 多字符符号先于单字符匹配 */
|
||||
const PUNCT = ['<=', '>=', '!=', '==', '..', '<', '>', '=', '+', '-', '*', '/', '(', ')', '[', ']', ',', '.'] as const;
|
||||
|
||||
const NAME_START = /[A-Za-z_一-龥]/;
|
||||
const NAME_PART = /[A-Za-z0-9_一-龥]/;
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
// 字符串:单引号或双引号,支持 \" \\ \n \t
|
||||
if (ch === '"' || ch === "'") {
|
||||
const quote = ch;
|
||||
const start = i;
|
||||
i += 1;
|
||||
let value = '';
|
||||
while (i < source.length && source[i] !== quote) {
|
||||
if (source[i] === '\\') {
|
||||
const next = source[i + 1];
|
||||
value +=
|
||||
next === 'n' ? '\n' : next === 't' ? '\t' : next === '\\' ? '\\' : next === quote ? quote : (next ?? '');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
value += source[i];
|
||||
i += 1;
|
||||
}
|
||||
if (i >= source.length) throw new SfeelSyntaxError('字符串未闭合', start);
|
||||
i += 1;
|
||||
tokens.push({ kind: 'string', text: source.slice(start, i), value, pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 数字:整数、小数;不支持科学计数(子集封顶)
|
||||
if (/[0-9]/.test(ch)) {
|
||||
const start = i;
|
||||
while (i < source.length && /[0-9]/.test(source[i]!)) i += 1;
|
||||
// `..` 是区间符号,不是小数点
|
||||
if (source[i] === '.' && source[i + 1] !== '.') {
|
||||
i += 1;
|
||||
while (i < source.length && /[0-9]/.test(source[i]!)) i += 1;
|
||||
}
|
||||
const text = source.slice(start, i);
|
||||
tokens.push({ kind: 'number', text, value: Number(text), pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 名字:标识符与关键字;允许空格分隔的多词函数名(如 `starts with`)在 parser 里合并
|
||||
if (NAME_START.test(ch)) {
|
||||
const start = i;
|
||||
while (i < source.length && NAME_PART.test(source[i]!)) i += 1;
|
||||
tokens.push({ kind: 'name', text: source.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
const punct = PUNCT.find((p) => source.startsWith(p, i));
|
||||
if (punct !== undefined) {
|
||||
tokens.push({ kind: 'punct', text: punct, pos: i });
|
||||
i += punct.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new SfeelSyntaxError(`无法识别的字符 ${JSON.stringify(ch)}`, i);
|
||||
}
|
||||
|
||||
tokens.push({ kind: 'eof', text: '', pos: source.length });
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* S-FEEL 子集的语法分析 —— 递归下降,纯函数(编码规范 §8.3)。
|
||||
*
|
||||
* 优先级(低 → 高):or < and < 比较/in < 加减 < 乘除 < 一元 < 基元
|
||||
*/
|
||||
import type { Node, UnaryTest } from './ast.ts';
|
||||
import { SfeelSyntaxError, tokenize } from './lexer.ts';
|
||||
import type { Token } from './lexer.ts';
|
||||
|
||||
/** 多词函数名 —— 子集封顶,只认这些(OS 03:无外部调用) */
|
||||
const MULTIWORD = [
|
||||
['starts', 'with'],
|
||||
['ends', 'with'],
|
||||
['string', 'length'],
|
||||
['date', 'and', 'time'],
|
||||
['days', 'and', 'time', 'duration'],
|
||||
['upper', 'case'],
|
||||
['lower', 'case'],
|
||||
] as const;
|
||||
|
||||
const KEYWORDS = new Set(['and', 'or', 'not', 'true', 'false', 'null', 'in']);
|
||||
|
||||
class Parser {
|
||||
private index = 0;
|
||||
|
||||
constructor(private readonly tokens: readonly Token[]) {}
|
||||
|
||||
private peek(offset = 0): Token {
|
||||
return this.tokens[Math.min(this.index + offset, this.tokens.length - 1)]!;
|
||||
}
|
||||
|
||||
private next(): Token {
|
||||
const token = this.peek();
|
||||
this.index += 1;
|
||||
return token;
|
||||
}
|
||||
|
||||
private eat(text: string): boolean {
|
||||
if (this.peek().text === text && this.peek().kind !== 'string') {
|
||||
this.index += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private expect(text: string): Token {
|
||||
if (!this.eat(text)) throw new SfeelSyntaxError(`期望 ${text},实际 ${this.peek().text || 'EOF'}`, this.peek().pos);
|
||||
return this.tokens[this.index - 1]!;
|
||||
}
|
||||
|
||||
atEnd(): boolean {
|
||||
return this.peek().kind === 'eof';
|
||||
}
|
||||
|
||||
parseExpression(): Node {
|
||||
return this.parseOr();
|
||||
}
|
||||
|
||||
private parseOr(): Node {
|
||||
let left = this.parseAnd();
|
||||
while (this.peek().kind === 'name' && this.peek().text === 'or') {
|
||||
this.next();
|
||||
left = { kind: 'binary', op: 'or', left, right: this.parseAnd() };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseAnd(): Node {
|
||||
let left = this.parseComparison();
|
||||
while (this.peek().kind === 'name' && this.peek().text === 'and') {
|
||||
this.next();
|
||||
left = { kind: 'binary', op: 'and', left, right: this.parseComparison() };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseComparison(): Node {
|
||||
const left = this.parseAdditive();
|
||||
const token = this.peek();
|
||||
|
||||
if (token.kind === 'name' && token.text === 'in') {
|
||||
this.next();
|
||||
return { kind: 'in', value: left, candidates: this.parseAdditive() };
|
||||
}
|
||||
|
||||
const ops = ['<=', '>=', '!=', '==', '<', '>', '='] as const;
|
||||
for (const op of ops) {
|
||||
if (token.kind === 'punct' && token.text === op) {
|
||||
this.next();
|
||||
const normalized = op === '==' ? '=' : op;
|
||||
return { kind: 'binary', op: normalized, left, right: this.parseAdditive() };
|
||||
}
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseAdditive(): Node {
|
||||
let left = this.parseMultiplicative();
|
||||
for (;;) {
|
||||
const token = this.peek();
|
||||
if (token.kind === 'punct' && (token.text === '+' || token.text === '-')) {
|
||||
this.next();
|
||||
left = { kind: 'binary', op: token.text, left, right: this.parseMultiplicative() };
|
||||
continue;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
}
|
||||
|
||||
private parseMultiplicative(): Node {
|
||||
let left = this.parseUnary();
|
||||
for (;;) {
|
||||
const token = this.peek();
|
||||
if (token.kind === 'punct' && (token.text === '*' || token.text === '/')) {
|
||||
this.next();
|
||||
left = { kind: 'binary', op: token.text, left, right: this.parseUnary() };
|
||||
continue;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
}
|
||||
|
||||
private parseUnary(): Node {
|
||||
const token = this.peek();
|
||||
if (token.kind === 'punct' && token.text === '-') {
|
||||
this.next();
|
||||
return { kind: 'unary', op: '-', operand: this.parseUnary() };
|
||||
}
|
||||
if (token.kind === 'name' && token.text === 'not') {
|
||||
this.next();
|
||||
this.expect('(');
|
||||
const operand = this.parseExpression();
|
||||
this.expect(')');
|
||||
return { kind: 'unary', op: 'not', operand };
|
||||
}
|
||||
return this.parsePrimary();
|
||||
}
|
||||
|
||||
/** 尝试合并多词函数名;不匹配则返回单词 */
|
||||
private readName(first: Token): string {
|
||||
for (const words of MULTIWORD) {
|
||||
if (words[0] !== first.text) continue;
|
||||
const matches = words.every((word, offset) => (offset === 0 ? true : this.peek(offset - 1).text === word));
|
||||
if (matches) {
|
||||
for (let k = 1; k < words.length; k += 1) this.next();
|
||||
return words.join(' ');
|
||||
}
|
||||
}
|
||||
return first.text;
|
||||
}
|
||||
|
||||
private parsePrimary(): Node {
|
||||
const token = this.next();
|
||||
|
||||
if (token.kind === 'number') return { kind: 'literal', value: token.value as number };
|
||||
if (token.kind === 'string') return { kind: 'literal', value: token.value as string };
|
||||
|
||||
// `(` 兼两职:分组与开区间起点 —— 先解析内部,再看是不是 `..`
|
||||
if (token.kind === 'punct' && token.text === '(') {
|
||||
const inner = this.parseExpression();
|
||||
if (this.peek().kind === 'punct' && this.peek().text === '..') {
|
||||
this.next();
|
||||
return this.finishRange(true, inner);
|
||||
}
|
||||
this.expect(')');
|
||||
return inner;
|
||||
}
|
||||
|
||||
// 列表或区间:[a, b] / [1..10]
|
||||
if (token.kind === 'punct' && token.text === '[') {
|
||||
return this.parseListOrRange(false);
|
||||
}
|
||||
|
||||
if (token.kind === 'name') {
|
||||
if (token.text === 'true') return { kind: 'literal', value: true };
|
||||
if (token.text === 'false') return { kind: 'literal', value: false };
|
||||
if (token.text === 'null') return { kind: 'literal', value: null };
|
||||
|
||||
const name = this.readName(token);
|
||||
|
||||
if (this.peek().kind === 'punct' && this.peek().text === '(') {
|
||||
this.next();
|
||||
const args: Node[] = [];
|
||||
if (!(this.peek().kind === 'punct' && this.peek().text === ')')) {
|
||||
args.push(this.parseExpression());
|
||||
while (this.eat(',')) args.push(this.parseExpression());
|
||||
}
|
||||
this.expect(')');
|
||||
return { kind: 'call', name, args };
|
||||
}
|
||||
|
||||
if (KEYWORDS.has(name)) throw new SfeelSyntaxError(`关键字 ${name} 不能作为路径`, token.pos);
|
||||
|
||||
const segments = [name];
|
||||
while (this.peek().kind === 'punct' && this.peek().text === '.') {
|
||||
this.next();
|
||||
const part = this.next();
|
||||
if (part.kind !== 'name') throw new SfeelSyntaxError('路径分段必须是名字', part.pos);
|
||||
segments.push(part.text);
|
||||
}
|
||||
return { kind: 'path', segments };
|
||||
}
|
||||
|
||||
throw new SfeelSyntaxError(`意外的记号 ${token.text || 'EOF'}`, token.pos);
|
||||
}
|
||||
|
||||
private parseListOrRange(startOpen: boolean): Node {
|
||||
// 空列表
|
||||
if (this.peek().kind === 'punct' && this.peek().text === ']') {
|
||||
this.next();
|
||||
return { kind: 'list', items: [] };
|
||||
}
|
||||
const first = this.parseExpression();
|
||||
if (this.peek().kind === 'punct' && this.peek().text === '..') {
|
||||
this.next();
|
||||
return this.finishRange(startOpen, first);
|
||||
}
|
||||
const items = [first];
|
||||
while (this.eat(',')) items.push(this.parseExpression());
|
||||
this.expect(']');
|
||||
return { kind: 'list', items };
|
||||
}
|
||||
|
||||
/** 区间收尾:`..` 已吃掉,读上界与闭合符 */
|
||||
private finishRange(startOpen: boolean, start: Node): Node {
|
||||
const end = this.parseExpression();
|
||||
const closing = this.next();
|
||||
if (closing.kind !== 'punct' || (closing.text !== ']' && closing.text !== ')')) {
|
||||
throw new SfeelSyntaxError('区间必须以 ] 或 ) 收尾', closing.pos);
|
||||
}
|
||||
return { kind: 'range', startOpen, endOpen: closing.text === ')', start, end };
|
||||
}
|
||||
|
||||
/** 单元格谓词:`-` / `<10` / `[1..5]` / `"A","B"` */
|
||||
parseUnaryTest(): UnaryTest {
|
||||
if (this.peek().kind === 'punct' && this.peek().text === '-' && this.peek(1).kind === 'eof') {
|
||||
this.next();
|
||||
return { kind: 'any' };
|
||||
}
|
||||
|
||||
const tests: UnaryTest[] = [this.parseSingleUnaryTest()];
|
||||
while (this.eat(',')) tests.push(this.parseSingleUnaryTest());
|
||||
return tests.length === 1 ? tests[0]! : { kind: 'disjunction', tests };
|
||||
}
|
||||
|
||||
private parseSingleUnaryTest(): UnaryTest {
|
||||
const token = this.peek();
|
||||
const ops = ['<=', '>=', '!=', '<', '>', '='] as const;
|
||||
for (const op of ops) {
|
||||
if (token.kind === 'punct' && token.text === op) {
|
||||
this.next();
|
||||
return { kind: 'compare', op, operand: this.parseAdditive() };
|
||||
}
|
||||
}
|
||||
const node = this.parseExpression();
|
||||
if (node.kind === 'range') return { kind: 'range', node };
|
||||
return { kind: 'compare', op: '=', operand: node };
|
||||
}
|
||||
}
|
||||
|
||||
export function parse(source: string): Node {
|
||||
const parser = new Parser(tokenize(source));
|
||||
const node = parser.parseExpression();
|
||||
if (!parser.atEnd()) throw new SfeelSyntaxError('表达式尾部有多余记号', 0);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function parseUnaryTest(source: string): UnaryTest {
|
||||
const trimmed = source.trim();
|
||||
if (trimmed === '' || trimmed === '-') return { kind: 'any' };
|
||||
const parser = new Parser(tokenize(trimmed));
|
||||
const test = parser.parseUnaryTest();
|
||||
if (!parser.atEnd()) throw new SfeelSyntaxError('单元格谓词尾部有多余记号', 0);
|
||||
return test;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 静态可检 —— 编排画布「画图即校验」与安装静态审的依据(编码规范 §8.3;OS 03)。
|
||||
*
|
||||
* 检三样:①语法能不能过 ②引用的函数在不在封闭集合里 ③引用了哪些路径(供必填字段核对)。
|
||||
*/
|
||||
import type { Node, UnaryTest } from './ast.ts';
|
||||
import { SfeelSyntaxError } from './lexer.ts';
|
||||
import { parse, parseUnaryTest } from './parser.ts';
|
||||
import { BUILTIN_FUNCTIONS } from './evaluate.ts';
|
||||
|
||||
export type StaticIssue = {
|
||||
readonly kind: 'syntax' | 'unknown-function';
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
export type StaticCheckResult = {
|
||||
readonly ok: boolean;
|
||||
readonly issues: readonly StaticIssue[];
|
||||
/** 表达式引用的全部根路径 —— 编排器据此核对上游载荷是否供得上 */
|
||||
readonly references: readonly string[];
|
||||
};
|
||||
|
||||
export function staticCheck(expression: string, mode: 'expression' | 'unary-test' = 'expression'): StaticCheckResult {
|
||||
let node: Node | undefined;
|
||||
const issues: StaticIssue[] = [];
|
||||
|
||||
try {
|
||||
if (mode === 'unary-test') {
|
||||
const test = parseUnaryTest(expression);
|
||||
const nodes = collectTestNodes(test);
|
||||
const references = new Set<string>();
|
||||
for (const n of nodes) walk(n, issues, references);
|
||||
return { ok: issues.length === 0, issues, references: [...references] };
|
||||
}
|
||||
node = parse(expression);
|
||||
} catch (error) {
|
||||
if (error instanceof SfeelSyntaxError) return { ok: false, issues: [{ kind: 'syntax', message: error.message }], references: [] };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const references = new Set<string>();
|
||||
walk(node, issues, references);
|
||||
return { ok: issues.length === 0, issues, references: [...references] };
|
||||
}
|
||||
|
||||
function collectTestNodes(test: UnaryTest): Node[] {
|
||||
switch (test.kind) {
|
||||
case 'any':
|
||||
return [];
|
||||
case 'compare':
|
||||
return [test.operand];
|
||||
case 'range':
|
||||
return [test.node];
|
||||
case 'disjunction':
|
||||
return test.tests.flatMap(collectTestNodes);
|
||||
}
|
||||
}
|
||||
|
||||
function walk(node: Node, issues: StaticIssue[], references: Set<string>): void {
|
||||
switch (node.kind) {
|
||||
case 'literal':
|
||||
return;
|
||||
case 'path':
|
||||
references.add(node.segments.join('.'));
|
||||
return;
|
||||
case 'unary':
|
||||
walk(node.operand, issues, references);
|
||||
return;
|
||||
case 'binary':
|
||||
walk(node.left, issues, references);
|
||||
walk(node.right, issues, references);
|
||||
return;
|
||||
case 'list':
|
||||
for (const item of node.items) walk(item, issues, references);
|
||||
return;
|
||||
case 'range':
|
||||
walk(node.start, issues, references);
|
||||
walk(node.end, issues, references);
|
||||
return;
|
||||
case 'in':
|
||||
walk(node.value, issues, references);
|
||||
walk(node.candidates, issues, references);
|
||||
return;
|
||||
case 'call':
|
||||
if (!BUILTIN_FUNCTIONS.includes(node.name)) {
|
||||
issues.push({ kind: 'unknown-function', message: `未知函数 ${node.name}(S-FEEL 子集封顶,无外部调用)` });
|
||||
}
|
||||
for (const arg of node.args) walk(arg, issues, references);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user