diff --git a/api-report/packages/contracts/src/card.d.ts b/api-report/packages/contracts/src/card.d.ts index 9f19848..74aaf9b 100644 --- a/api-report/packages/contracts/src/card.d.ts +++ b/api-report/packages/contracts/src/card.d.ts @@ -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; editable: z.ZodOptional; @@ -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; editable: z.ZodOptional; @@ -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; editable: z.ZodOptional; @@ -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; editable: z.ZodOptional; @@ -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; diff --git a/api-report/packages/contracts/src/envelope.d.ts b/api-report/packages/contracts/src/envelope.d.ts index dc2299b..b117bb7 100644 --- a/api-report/packages/contracts/src/envelope.d.ts +++ b/api-report/packages/contracts/src/envelope.d.ts @@ -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; @@ -71,9 +71,9 @@ export type EventPayload = z.infer; export declare const envelopeSchema: z.ZodObject<{ id: z.core.$ZodBranded; 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; */ 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>; @@ -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>; diff --git a/api-report/packages/contracts/src/manifest.d.ts b/api-report/packages/contracts/src/manifest.d.ts index 21f8257..c972b16 100644 --- a/api-report/packages/contracts/src/manifest.d.ts +++ b/api-report/packages/contracts/src/manifest.d.ts @@ -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>; @@ -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>; diff --git a/api-report/packages/contracts/src/process-package.d.ts b/api-report/packages/contracts/src/process-package.d.ts index e40ad31..98b7379 100644 --- a/api-report/packages/contracts/src/process-package.d.ts +++ b/api-report/packages/contracts/src/process-package.d.ts @@ -535,9 +535,9 @@ export declare const triggerConfigSchema: z.ZodObject<{ kind: z.ZodLiteral<"event">; rule: z.ZodString; eventType: z.ZodDefault>; }, z.core.$strip>, z.ZodObject<{ diff --git a/api-report/packages/sfeel/src/index.d.ts b/api-report/packages/sfeel/src/index.d.ts index 4f6af0a..626ad87 100644 --- a/api-report/packages/sfeel/src/index.d.ts +++ b/api-report/packages/sfeel/src/index.d.ts @@ -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"; diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index a0ab6b9..0d6874e 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -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>): 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(); @@ -133,7 +146,7 @@ export async function createContainer(config: AppConfig): Promise { store: createEventStore(db, ids), clock: systemClock, ids, - evaluate: passthroughEvaluator, + evaluate: sfeelEvaluator, triggers: noTriggerMatcher, messages: message, }); diff --git a/packages/sfeel/__tests__/sfeel.test.ts b/packages/sfeel/__tests__/sfeel.test.ts new file mode 100644 index 0000000..95b8369 --- /dev/null +++ b/packages/sfeel/__tests__/sfeel.test.ts @@ -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); + }); +}); diff --git a/packages/sfeel/src/ast.ts b/packages/sfeel/src/ast.ts new file mode 100644 index 0000000..f8f6ab4 --- /dev/null +++ b/packages/sfeel/src/ast.ts @@ -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 } + | { readonly kind: 'disjunction'; readonly tests: readonly UnaryTest[] }; diff --git a/packages/sfeel/src/evaluate.ts b/packages/sfeel/src/evaluate.ts new file mode 100644 index 0000000..bea9cf8 --- /dev/null +++ b/packages/sfeel/src/evaluate.ts @@ -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>; + +export class SfeelEvalError extends Error { + constructor(message: string) { + super(`S-FEEL 求值错误:${message}`); + this.name = 'SfeelEvalError'; + } +} + +/** 内建函数表 —— 封闭集合,**不可扩展**(无外部调用,静态可检) */ +const FUNCTIONS: Readonly 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((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)[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, 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; + } + } +} diff --git a/packages/sfeel/src/index.ts b/packages/sfeel/src/index.ts index d30114c..8b1a4b4 100644 --- a/packages/sfeel/src/index.ts +++ b/packages/sfeel/src/index.ts @@ -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'; diff --git a/packages/sfeel/src/lexer.ts b/packages/sfeel/src/lexer.ts new file mode 100644 index 0000000..1e399a8 --- /dev/null +++ b/packages/sfeel/src/lexer.ts @@ -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; +} diff --git a/packages/sfeel/src/parser.ts b/packages/sfeel/src/parser.ts new file mode 100644 index 0000000..24afa7d --- /dev/null +++ b/packages/sfeel/src/parser.ts @@ -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; +} diff --git a/packages/sfeel/src/static-check.ts b/packages/sfeel/src/static-check.ts new file mode 100644 index 0000000..5efe4e4 --- /dev/null +++ b/packages/sfeel/src/static-check.ts @@ -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(); + 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(); + 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): 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; + } +}