@praxisui/specification 1.0.0-beta.30 → 1.0.0-beta.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,13 @@ var ComparisonOperator;
13
13
  ComparisonOperator["STARTS_WITH"] = "startsWith";
14
14
  ComparisonOperator["ENDS_WITH"] = "endsWith";
15
15
  ComparisonOperator["IN"] = "in";
16
+ ComparisonOperator["NOT_IN"] = "notIn";
17
+ ComparisonOperator["MATCHES"] = "matches";
18
+ ComparisonOperator["NOT_MATCHES"] = "notMatches";
19
+ ComparisonOperator["IS_NULL"] = "isNull";
20
+ ComparisonOperator["IS_NOT_NULL"] = "isNotNull";
21
+ ComparisonOperator["IS_EMPTY"] = "isEmpty";
22
+ ComparisonOperator["IS_NOT_EMPTY"] = "isNotEmpty";
16
23
  })(ComparisonOperator || (ComparisonOperator = {}));
17
24
  const OPERATOR_SYMBOLS = {
18
25
  [ComparisonOperator.EQUALS]: '==',
@@ -24,7 +31,14 @@ const OPERATOR_SYMBOLS = {
24
31
  [ComparisonOperator.CONTAINS]: 'contains',
25
32
  [ComparisonOperator.STARTS_WITH]: 'startsWith',
26
33
  [ComparisonOperator.ENDS_WITH]: 'endsWith',
27
- [ComparisonOperator.IN]: 'in'
34
+ [ComparisonOperator.IN]: 'in',
35
+ [ComparisonOperator.NOT_IN]: 'not in',
36
+ [ComparisonOperator.MATCHES]: 'matches',
37
+ [ComparisonOperator.NOT_MATCHES]: '!~',
38
+ [ComparisonOperator.IS_NULL]: 'is null',
39
+ [ComparisonOperator.IS_NOT_NULL]: 'is not null',
40
+ [ComparisonOperator.IS_EMPTY]: 'is empty',
41
+ [ComparisonOperator.IS_NOT_EMPTY]: 'is not empty'
28
42
  };
29
43
 
30
44
  class FieldSpecification extends Specification {
@@ -60,6 +74,26 @@ class FieldSpecification extends Specification {
60
74
  return String(fieldValue).endsWith(String(this.value));
61
75
  case ComparisonOperator.IN:
62
76
  return Array.isArray(this.value) && this.value.includes(fieldValue);
77
+ case ComparisonOperator.NOT_IN:
78
+ return Array.isArray(this.value) && !this.value.includes(fieldValue);
79
+ case ComparisonOperator.MATCHES:
80
+ if (this.value === undefined || this.value === null) {
81
+ return false;
82
+ }
83
+ return this.toRegExp(this.value).test(String(fieldValue));
84
+ case ComparisonOperator.NOT_MATCHES:
85
+ if (this.value === undefined || this.value === null) {
86
+ return false;
87
+ }
88
+ return !this.toRegExp(this.value).test(String(fieldValue));
89
+ case ComparisonOperator.IS_NULL:
90
+ return fieldValue === null || fieldValue === undefined;
91
+ case ComparisonOperator.IS_NOT_NULL:
92
+ return fieldValue !== null && fieldValue !== undefined;
93
+ case ComparisonOperator.IS_EMPTY:
94
+ return this.isEmpty(fieldValue);
95
+ case ComparisonOperator.IS_NOT_EMPTY:
96
+ return this.isNotEmpty(fieldValue);
63
97
  default:
64
98
  throw new Error(`Unsupported operator: ${this.operator}`);
65
99
  }
@@ -90,10 +124,25 @@ class FieldSpecification extends Specification {
90
124
  case ComparisonOperator.ENDS_WITH:
91
125
  return `endsWith(${fieldName}, ${JSON.stringify(this.value)})`;
92
126
  case ComparisonOperator.IN:
127
+ case ComparisonOperator.NOT_IN: {
93
128
  const values = Array.isArray(this.value)
94
129
  ? this.value.map((v) => JSON.stringify(v)).join(', ')
95
130
  : JSON.stringify(this.value);
96
- return `${fieldName} in (${values})`;
131
+ const keyword = this.operator === ComparisonOperator.NOT_IN ? 'not in' : 'in';
132
+ return `${fieldName} ${keyword} [${values}]`;
133
+ }
134
+ case ComparisonOperator.MATCHES:
135
+ return `${fieldName} matches ${this.formatMatchesValue(this.value)}`;
136
+ case ComparisonOperator.NOT_MATCHES:
137
+ return `${fieldName} !~ ${this.formatMatchesValue(this.value)}`;
138
+ case ComparisonOperator.IS_NULL:
139
+ return `${fieldName} is null`;
140
+ case ComparisonOperator.IS_NOT_NULL:
141
+ return `${fieldName} is not null`;
142
+ case ComparisonOperator.IS_EMPTY:
143
+ return `${fieldName} is empty`;
144
+ case ComparisonOperator.IS_NOT_EMPTY:
145
+ return `${fieldName} is not empty`;
97
146
  default:
98
147
  return `${fieldName} ${symbol} ${JSON.stringify(this.value)}`;
99
148
  }
@@ -107,6 +156,53 @@ class FieldSpecification extends Specification {
107
156
  getValue() {
108
157
  return this.value;
109
158
  }
159
+ toRegExp(value) {
160
+ if (value instanceof RegExp) {
161
+ return value;
162
+ }
163
+ if (typeof value === 'string') {
164
+ const regexLiteral = value.match(/^\/(.+)\/([gimsuy]*)$/);
165
+ if (regexLiteral) {
166
+ return new RegExp(regexLiteral[1], regexLiteral[2]);
167
+ }
168
+ return new RegExp(value);
169
+ }
170
+ return new RegExp(String(value));
171
+ }
172
+ isEmpty(value) {
173
+ // Consider null/undefined as empty to align with DSL semantics for "is empty".
174
+ if (value === null || value === undefined) {
175
+ return true;
176
+ }
177
+ if (typeof value === 'string' || Array.isArray(value)) {
178
+ return value.length === 0;
179
+ }
180
+ if (value instanceof Set || value instanceof Map) {
181
+ return value.size === 0;
182
+ }
183
+ if (typeof value === 'object') {
184
+ return Object.keys(value).length === 0;
185
+ }
186
+ return false;
187
+ }
188
+ isNotEmpty(value) {
189
+ if (value === null || value === undefined) {
190
+ return false;
191
+ }
192
+ return !this.isEmpty(value);
193
+ }
194
+ formatMatchesValue(value) {
195
+ if (value instanceof RegExp) {
196
+ return value.toString();
197
+ }
198
+ if (typeof value === 'string') {
199
+ const regexLiteral = value.match(/^\/.+\/[gimsuy]*$/);
200
+ if (regexLiteral) {
201
+ return value;
202
+ }
203
+ }
204
+ return JSON.stringify(value);
205
+ }
110
206
  }
111
207
 
112
208
  class AndSpecification extends Specification {
@@ -125,6 +221,7 @@ class AndSpecification extends Specification {
125
221
  return {
126
222
  type: 'and',
127
223
  specs: this.specifications.map((spec) => spec.toJSON()),
224
+ metadata: this.metadata,
128
225
  };
129
226
  }
130
227
  static fromJSON(json) {
@@ -176,6 +273,7 @@ class OrSpecification extends Specification {
176
273
  return {
177
274
  type: 'or',
178
275
  specs: this.specifications.map((spec) => spec.toJSON()),
276
+ metadata: this.metadata,
179
277
  };
180
278
  }
181
279
  static fromJSON(json) {
@@ -401,7 +499,8 @@ class FunctionSpecification extends Specification {
401
499
  }
402
500
  isSatisfiedBy(obj) {
403
501
  const registry = this.registry || FunctionRegistry.getInstance();
404
- return registry.execute(this.functionName, obj, ...this.args);
502
+ const resolvedArgs = this.args.map((arg) => this.resolveArg(obj, arg));
503
+ return registry.execute(this.functionName, obj, ...resolvedArgs);
405
504
  }
406
505
  toJSON() {
407
506
  return {
@@ -439,6 +538,59 @@ class FunctionSpecification extends Specification {
439
538
  clone() {
440
539
  return new FunctionSpecification(this.functionName, [...this.args], this.registry, this.metadata);
441
540
  }
541
+ resolveArg(obj, arg) {
542
+ if (Array.isArray(arg)) {
543
+ return arg.map((entry) => this.resolveArg(obj, entry));
544
+ }
545
+ if (this.isFieldArg(arg)) {
546
+ const lookup = this.getPathValue(obj, arg.field);
547
+ if (lookup.found)
548
+ return lookup.value;
549
+ // Backward-compatible fallback when field path is not present.
550
+ return arg.field;
551
+ }
552
+ return arg;
553
+ }
554
+ isFieldArg(arg) {
555
+ return !!(arg
556
+ && typeof arg === 'object'
557
+ && arg.type === 'field'
558
+ && typeof arg.field === 'string');
559
+ }
560
+ getPathValue(source, path) {
561
+ if (!source || typeof source !== 'object') {
562
+ return { found: false, value: undefined };
563
+ }
564
+ const raw = String(path || '').trim();
565
+ if (!raw) {
566
+ return { found: false, value: undefined };
567
+ }
568
+ const blocked = new Set(['__proto__', 'prototype', 'constructor']);
569
+ if (blocked.has(raw)) {
570
+ return { found: false, value: undefined };
571
+ }
572
+ if (raw in source) {
573
+ return { found: true, value: source[raw] };
574
+ }
575
+ if (!raw.includes('.')) {
576
+ return { found: false, value: undefined };
577
+ }
578
+ const segments = raw.split('.').filter(Boolean);
579
+ let cursor = source;
580
+ for (const segment of segments) {
581
+ if (blocked.has(segment)) {
582
+ return { found: false, value: undefined };
583
+ }
584
+ if (cursor == null || (typeof cursor !== 'object' && typeof cursor !== 'function')) {
585
+ return { found: false, value: undefined };
586
+ }
587
+ if (!(segment in cursor)) {
588
+ return { found: false, value: undefined };
589
+ }
590
+ cursor = cursor[segment];
591
+ }
592
+ return { found: true, value: cursor };
593
+ }
442
594
  }
443
595
 
444
596
  class AtLeastSpecification extends Specification {
@@ -649,6 +801,26 @@ class FieldToFieldSpecification extends Specification {
649
801
  return String(valueA).endsWith(String(valueB));
650
802
  case ComparisonOperator.IN:
651
803
  return Array.isArray(valueB) && valueB.includes(valueA);
804
+ case ComparisonOperator.NOT_IN:
805
+ return Array.isArray(valueB) && !valueB.includes(valueA);
806
+ case ComparisonOperator.MATCHES:
807
+ if (valueB === undefined || valueB === null) {
808
+ return false;
809
+ }
810
+ return this.toRegExp(valueB).test(String(valueA));
811
+ case ComparisonOperator.NOT_MATCHES:
812
+ if (valueB === undefined || valueB === null) {
813
+ return false;
814
+ }
815
+ return !this.toRegExp(valueB).test(String(valueA));
816
+ case ComparisonOperator.IS_NULL:
817
+ return valueA === null || valueA === undefined;
818
+ case ComparisonOperator.IS_NOT_NULL:
819
+ return valueA !== null && valueA !== undefined;
820
+ case ComparisonOperator.IS_EMPTY:
821
+ return this.isEmpty(valueA);
822
+ case ComparisonOperator.IS_NOT_EMPTY:
823
+ return this.isNotEmpty(valueA);
652
824
  default:
653
825
  throw new Error(`Unsupported operator: ${this.operator}`);
654
826
  }
@@ -688,7 +860,22 @@ class FieldToFieldSpecification extends Specification {
688
860
  case ComparisonOperator.ENDS_WITH:
689
861
  return `endsWith(${leftField}, ${rightField})`;
690
862
  case ComparisonOperator.IN:
691
- return `${leftField} in ${rightField}`;
863
+ case ComparisonOperator.NOT_IN: {
864
+ const keyword = this.operator === ComparisonOperator.NOT_IN ? 'not in' : 'in';
865
+ return `${leftField} ${keyword} ${rightField}`;
866
+ }
867
+ case ComparisonOperator.MATCHES:
868
+ return `${leftField} matches ${rightField}`;
869
+ case ComparisonOperator.NOT_MATCHES:
870
+ return `${leftField} !~ ${rightField}`;
871
+ case ComparisonOperator.IS_NULL:
872
+ return `${leftField} is null`;
873
+ case ComparisonOperator.IS_NOT_NULL:
874
+ return `${leftField} is not null`;
875
+ case ComparisonOperator.IS_EMPTY:
876
+ return `${leftField} is empty`;
877
+ case ComparisonOperator.IS_NOT_EMPTY:
878
+ return `${leftField} is not empty`;
692
879
  default:
693
880
  return `${leftField} ${symbol} ${rightField}`;
694
881
  }
@@ -711,6 +898,40 @@ class FieldToFieldSpecification extends Specification {
711
898
  clone() {
712
899
  return new FieldToFieldSpecification(this.fieldA, this.operator, this.fieldB, this.transformA ? [...this.transformA] : undefined, this.transformB ? [...this.transformB] : undefined, this.transformRegistry, this.metadata);
713
900
  }
901
+ toRegExp(value) {
902
+ if (value instanceof RegExp) {
903
+ return value;
904
+ }
905
+ if (typeof value === 'string') {
906
+ const regexLiteral = value.match(/^\/(.+)\/([gimsuy]*)$/);
907
+ if (regexLiteral) {
908
+ return new RegExp(regexLiteral[1], regexLiteral[2]);
909
+ }
910
+ return new RegExp(value);
911
+ }
912
+ return new RegExp(String(value));
913
+ }
914
+ isEmpty(value) {
915
+ if (value === null || value === undefined) {
916
+ return true;
917
+ }
918
+ if (typeof value === 'string' || Array.isArray(value)) {
919
+ return value.length === 0;
920
+ }
921
+ if (value instanceof Set || value instanceof Map) {
922
+ return value.size === 0;
923
+ }
924
+ if (typeof value === 'object') {
925
+ return Object.keys(value).length === 0;
926
+ }
927
+ return false;
928
+ }
929
+ isNotEmpty(value) {
930
+ if (value === null || value === undefined) {
931
+ return false;
932
+ }
933
+ return !this.isEmpty(value);
934
+ }
714
935
  }
715
936
 
716
937
  class DefaultContextProvider {
@@ -1778,6 +1999,7 @@ var TokenType;
1778
1999
  TokenType["NUMBER"] = "NUMBER";
1779
2000
  TokenType["BOOLEAN"] = "BOOLEAN";
1780
2001
  TokenType["NULL"] = "NULL";
2002
+ TokenType["REGEX"] = "REGEX";
1781
2003
  // Operators
1782
2004
  TokenType["AND"] = "AND";
1783
2005
  TokenType["OR"] = "OR";
@@ -1792,6 +2014,13 @@ var TokenType;
1792
2014
  TokenType["GREATER_THAN"] = "GREATER_THAN";
1793
2015
  TokenType["GREATER_THAN_OR_EQUAL"] = "GREATER_THAN_OR_EQUAL";
1794
2016
  TokenType["IN"] = "IN";
2017
+ TokenType["NOT_IN"] = "NOT_IN";
2018
+ TokenType["MATCHES"] = "MATCHES";
2019
+ TokenType["NOT_MATCHES"] = "NOT_MATCHES";
2020
+ TokenType["IS_NULL"] = "IS_NULL";
2021
+ TokenType["IS_NOT_NULL"] = "IS_NOT_NULL";
2022
+ TokenType["IS_EMPTY"] = "IS_EMPTY";
2023
+ TokenType["IS_NOT_EMPTY"] = "IS_NOT_EMPTY";
1795
2024
  // Function tokens
1796
2025
  TokenType["CONTAINS"] = "CONTAINS";
1797
2026
  TokenType["STARTS_WITH"] = "STARTS_WITH";
@@ -1818,8 +2047,29 @@ const OPERATOR_KEYWORDS = {
1818
2047
  'not': TokenType.NOT,
1819
2048
  '!': TokenType.NOT,
1820
2049
  'xor': TokenType.XOR,
2050
+ '^': TokenType.XOR,
1821
2051
  'implies': TokenType.IMPLIES,
2052
+ '=>': TokenType.IMPLIES,
2053
+ 'eq': TokenType.EQUALS,
2054
+ 'equals': TokenType.EQUALS,
2055
+ 'neq': TokenType.NOT_EQUALS,
2056
+ 'notequals': TokenType.NOT_EQUALS,
2057
+ 'lt': TokenType.LESS_THAN,
2058
+ 'lte': TokenType.LESS_THAN_OR_EQUAL,
2059
+ 'gt': TokenType.GREATER_THAN,
2060
+ 'gte': TokenType.GREATER_THAN_OR_EQUAL,
1822
2061
  'in': TokenType.IN,
2062
+ 'matches': TokenType.MATCHES,
2063
+ '!~': TokenType.NOT_MATCHES,
2064
+ 'notmatches': TokenType.NOT_MATCHES,
2065
+ 'isnull': TokenType.IS_NULL,
2066
+ 'isNull': TokenType.IS_NULL,
2067
+ 'isnotnull': TokenType.IS_NOT_NULL,
2068
+ 'isNotNull': TokenType.IS_NOT_NULL,
2069
+ 'isempty': TokenType.IS_EMPTY,
2070
+ 'isEmpty': TokenType.IS_EMPTY,
2071
+ 'isnotempty': TokenType.IS_NOT_EMPTY,
2072
+ 'isNotEmpty': TokenType.IS_NOT_EMPTY,
1823
2073
  'true': TokenType.BOOLEAN,
1824
2074
  'false': TokenType.BOOLEAN,
1825
2075
  'null': TokenType.NULL
@@ -1827,6 +2077,8 @@ const OPERATOR_KEYWORDS = {
1827
2077
  const COMPARISON_OPERATORS = {
1828
2078
  '==': TokenType.EQUALS,
1829
2079
  '!=': TokenType.NOT_EQUALS,
2080
+ '!~': TokenType.NOT_MATCHES,
2081
+ '~': TokenType.MATCHES,
1830
2082
  '<': TokenType.LESS_THAN,
1831
2083
  '<=': TokenType.LESS_THAN_OR_EQUAL,
1832
2084
  '>': TokenType.GREATER_THAN,
@@ -1863,6 +2115,10 @@ class DslTokenizer {
1863
2115
  if (this.position >= this.input.length) {
1864
2116
  return this.createToken(TokenType.EOF, '');
1865
2117
  }
2118
+ const multiWordOperator = this.readMultiWordOperator();
2119
+ if (multiWordOperator) {
2120
+ return multiWordOperator;
2121
+ }
1866
2122
  const char = this.input[this.position];
1867
2123
  // Field references ${...}
1868
2124
  if (char === '$' && this.peek() === '{') {
@@ -1872,11 +2128,25 @@ class DslTokenizer {
1872
2128
  if (char === '"' || char === "'") {
1873
2129
  return this.readString(char);
1874
2130
  }
2131
+ // Regex literals /pattern/flags
2132
+ if (char === '/') {
2133
+ return this.readRegex();
2134
+ }
1875
2135
  // Numbers
1876
2136
  if (this.isDigit(char) || (char === '-' && this.isDigit(this.peek()))) {
1877
2137
  return this.readNumber();
1878
2138
  }
1879
2139
  // Multi-character operators
2140
+ if (char === '!' && this.peek() === '~') {
2141
+ const token = this.createToken(TokenType.NOT_MATCHES, '!~');
2142
+ this.advance(2);
2143
+ return token;
2144
+ }
2145
+ if (char === '=' && this.peek() === '>') {
2146
+ const token = this.createToken(TokenType.IMPLIES, '=>');
2147
+ this.advance(2);
2148
+ return token;
2149
+ }
1880
2150
  const twoChar = this.input.substr(this.position, 2);
1881
2151
  if (COMPARISON_OPERATORS[twoChar]) {
1882
2152
  const token = this.createToken(COMPARISON_OPERATORS[twoChar], twoChar);
@@ -1904,6 +2174,8 @@ class DslTokenizer {
1904
2174
  return token;
1905
2175
  }
1906
2176
  return this.createTokenAndAdvance(TokenType.NOT, char);
2177
+ case '~':
2178
+ return this.createTokenAndAdvance(TokenType.MATCHES, char);
1907
2179
  case '<':
1908
2180
  if (this.peek() === '=') {
1909
2181
  const token = this.createToken(TokenType.LESS_THAN_OR_EQUAL, '<=');
@@ -1939,6 +2211,8 @@ class DslTokenizer {
1939
2211
  return token;
1940
2212
  }
1941
2213
  break;
2214
+ case '^':
2215
+ return this.createTokenAndAdvance(TokenType.XOR, char);
1942
2216
  }
1943
2217
  // Identifiers and keywords
1944
2218
  if (this.isAlpha(char) || char === '_') {
@@ -2006,6 +2280,39 @@ class DslTokenizer {
2006
2280
  this.advance(); // Skip closing quote
2007
2281
  return this.createToken(TokenType.STRING, value, start);
2008
2282
  }
2283
+ readRegex() {
2284
+ const start = this.position;
2285
+ this.advance(); // Skip opening /
2286
+ let pattern = '';
2287
+ while (this.position < this.input.length) {
2288
+ const current = this.input[this.position];
2289
+ if (current === '\\') {
2290
+ pattern += current;
2291
+ this.advance();
2292
+ if (this.position < this.input.length) {
2293
+ pattern += this.input[this.position];
2294
+ this.advance();
2295
+ }
2296
+ continue;
2297
+ }
2298
+ if (current === '/') {
2299
+ break;
2300
+ }
2301
+ pattern += current;
2302
+ this.advance();
2303
+ }
2304
+ if (this.position >= this.input.length) {
2305
+ throw new Error('Unterminated regex literal');
2306
+ }
2307
+ this.advance(); // Skip closing /
2308
+ let flags = '';
2309
+ while (this.position < this.input.length && this.isAlpha(this.input[this.position])) {
2310
+ flags += this.input[this.position];
2311
+ this.advance();
2312
+ }
2313
+ const value = `/${pattern}/${flags}`;
2314
+ return this.createToken(TokenType.REGEX, value, start);
2315
+ }
2009
2316
  readNumber() {
2010
2317
  const start = this.position;
2011
2318
  let value = '';
@@ -2038,6 +2345,27 @@ class DslTokenizer {
2038
2345
  const tokenType = OPERATOR_KEYWORDS[value] || TokenType.IDENTIFIER;
2039
2346
  return this.createToken(tokenType, value, start);
2040
2347
  }
2348
+ readMultiWordOperator() {
2349
+ const remaining = this.input.slice(this.position);
2350
+ const patterns = [
2351
+ { regex: /^is\s+not\s+empty\b/i, type: TokenType.IS_NOT_EMPTY },
2352
+ { regex: /^is\s+not\s+null\b/i, type: TokenType.IS_NOT_NULL },
2353
+ { regex: /^is\s+empty\b/i, type: TokenType.IS_EMPTY },
2354
+ { regex: /^is\s+null\b/i, type: TokenType.IS_NULL },
2355
+ { regex: /^not\s+in\b/i, type: TokenType.NOT_IN },
2356
+ { regex: /^not\s+matches\b/i, type: TokenType.NOT_MATCHES },
2357
+ ];
2358
+ for (const pattern of patterns) {
2359
+ const match = remaining.match(pattern.regex);
2360
+ if (match && match[0]) {
2361
+ const tokenValue = remaining.substring(0, match[0].length);
2362
+ const token = this.createToken(pattern.type, tokenValue, this.position);
2363
+ this.advance(match[0].length);
2364
+ return token;
2365
+ }
2366
+ }
2367
+ return null;
2368
+ }
2041
2369
  skipWhitespace() {
2042
2370
  while (this.position < this.input.length && this.isWhitespace(this.input[this.position])) {
2043
2371
  if (this.input[this.position] === '\n') {
@@ -2098,6 +2426,7 @@ class DslParser {
2098
2426
  functionRegistry;
2099
2427
  tokens = [];
2100
2428
  current = 0;
2429
+ lastArgumentTokenType = null;
2101
2430
  constructor(functionRegistry) {
2102
2431
  this.functionRegistry = functionRegistry;
2103
2432
  }
@@ -2171,16 +2500,19 @@ class DslParser {
2171
2500
  parseComparison() {
2172
2501
  let left = this.parsePrimary();
2173
2502
  if (this.matchComparison()) {
2174
- const operator = this.previous().type;
2503
+ const operatorToken = this.previous();
2504
+ const compOp = this.tokenTypeToComparisonOperator(operatorToken.type);
2175
2505
  // Parse RHS allowing literals, arrays and identifiers (as values)
2176
- const rightValue = this.parseArgument();
2177
- // Field to field comparison not implemented
2178
- if (left instanceof FieldSpecification && rightValue instanceof FieldSpecification) {
2179
- throw new Error('Field to field comparison not yet implemented in parser');
2180
- }
2506
+ const rightValue = this.operatorRequiresValue(compOp)
2507
+ ? this.parseArgument()
2508
+ : this.defaultValueForOperator(compOp);
2181
2509
  if (left instanceof FieldSpecification) {
2182
2510
  const field = left.getField();
2183
- const compOp = this.tokenTypeToComparisonOperator(operator);
2511
+ if ((this.lastArgumentTokenType === TokenType.FIELD_REFERENCE ||
2512
+ this.lastArgumentTokenType === TokenType.IDENTIFIER) &&
2513
+ typeof rightValue === 'string') {
2514
+ return new FieldToFieldSpecification(field, compOp, rightValue);
2515
+ }
2184
2516
  return new FieldSpecification(field, compOp, rightValue);
2185
2517
  }
2186
2518
  throw new Error('Invalid comparison expression');
@@ -2197,7 +2529,7 @@ class DslParser {
2197
2529
  const fieldName = this.previous().value;
2198
2530
  return new FieldSpecification(fieldName, ComparisonOperator.EQUALS, true);
2199
2531
  }
2200
- if (this.match(TokenType.IDENTIFIER)) {
2532
+ if (this.match(TokenType.IDENTIFIER, TokenType.MATCHES, TokenType.NOT_MATCHES)) {
2201
2533
  const identifier = this.previous().value;
2202
2534
  // Check if this is a function call
2203
2535
  if (this.match(TokenType.LEFT_PAREN)) {
@@ -2210,14 +2542,17 @@ class DslParser {
2210
2542
  }
2211
2543
  parseFunctionCall(functionName) {
2212
2544
  const args = [];
2545
+ const argTokenTypes = [];
2213
2546
  if (!this.check(TokenType.RIGHT_PAREN)) {
2214
2547
  do {
2215
2548
  args.push(this.parseArgument());
2549
+ argTokenTypes.push(this.lastArgumentTokenType ?? TokenType.EOF);
2216
2550
  } while (this.match(TokenType.COMMA));
2217
2551
  }
2218
2552
  this.consume(TokenType.RIGHT_PAREN, "Expected ')' after function arguments");
2219
2553
  // Handle special built-in functions
2220
- switch (functionName) {
2554
+ const normalizedFunctionName = this.normalizeFunctionName(functionName);
2555
+ switch (normalizedFunctionName) {
2221
2556
  case 'atLeast':
2222
2557
  if (args.length !== 2) {
2223
2558
  throw new Error('atLeast requires exactly 2 arguments');
@@ -2241,38 +2576,61 @@ class DslParser {
2241
2576
  case 'contains':
2242
2577
  case 'startsWith':
2243
2578
  case 'endsWith':
2579
+ case 'matches':
2580
+ case 'notMatches':
2244
2581
  if (args.length !== 2) {
2245
- throw new Error(`${functionName} requires exactly 2 arguments`);
2582
+ throw new Error(`${normalizedFunctionName} requires exactly 2 arguments`);
2246
2583
  }
2247
2584
  const field = args[0];
2248
2585
  const value = args[1];
2249
- const op = this.functionNameToOperator(functionName);
2586
+ const op = this.functionNameToOperator(normalizedFunctionName);
2250
2587
  return new FieldSpecification(field, op, value);
2251
2588
  default:
2252
- return new FunctionSpecification(functionName, args, this.functionRegistry);
2589
+ // Custom functions keep identifier args as raw strings (legacy),
2590
+ // but preserve explicit field references (${field}) as typed args.
2591
+ const customArgs = args.map((arg, index) => {
2592
+ const tokenType = argTokenTypes[index];
2593
+ if (tokenType === TokenType.FIELD_REFERENCE && typeof arg === 'string') {
2594
+ return { type: 'field', field: arg };
2595
+ }
2596
+ return arg;
2597
+ });
2598
+ return new FunctionSpecification(normalizedFunctionName, customArgs, this.functionRegistry);
2253
2599
  }
2254
2600
  }
2255
2601
  parseArgument() {
2602
+ this.lastArgumentTokenType = null;
2256
2603
  if (this.match(TokenType.STRING)) {
2604
+ this.lastArgumentTokenType = TokenType.STRING;
2257
2605
  return this.previous().value;
2258
2606
  }
2259
2607
  if (this.match(TokenType.NUMBER)) {
2260
2608
  const value = this.previous().value;
2609
+ this.lastArgumentTokenType = TokenType.NUMBER;
2261
2610
  return value.includes('.') ? parseFloat(value) : parseInt(value, 10);
2262
2611
  }
2612
+ if (this.match(TokenType.REGEX)) {
2613
+ this.lastArgumentTokenType = TokenType.REGEX;
2614
+ return this.parseRegexLiteral(this.previous().value);
2615
+ }
2263
2616
  if (this.match(TokenType.BOOLEAN)) {
2617
+ this.lastArgumentTokenType = TokenType.BOOLEAN;
2264
2618
  return this.previous().value === 'true';
2265
2619
  }
2266
2620
  if (this.match(TokenType.NULL)) {
2621
+ this.lastArgumentTokenType = TokenType.NULL;
2267
2622
  return null;
2268
2623
  }
2269
2624
  if (this.match(TokenType.FIELD_REFERENCE)) {
2625
+ this.lastArgumentTokenType = TokenType.FIELD_REFERENCE;
2270
2626
  return this.previous().value;
2271
2627
  }
2272
- if (this.match(TokenType.IDENTIFIER)) {
2628
+ if (this.match(TokenType.IDENTIFIER, TokenType.MATCHES, TokenType.NOT_MATCHES)) {
2629
+ this.lastArgumentTokenType = this.previous().type;
2273
2630
  return this.previous().value;
2274
2631
  }
2275
2632
  if (this.match(TokenType.LEFT_BRACKET)) {
2633
+ this.lastArgumentTokenType = TokenType.LEFT_BRACKET;
2276
2634
  const elements = [];
2277
2635
  if (!this.check(TokenType.RIGHT_BRACKET)) {
2278
2636
  do {
@@ -2282,10 +2640,11 @@ class DslParser {
2282
2640
  this.consume(TokenType.RIGHT_BRACKET, "Expected ']' after array elements");
2283
2641
  return elements;
2284
2642
  }
2643
+ this.lastArgumentTokenType = null;
2285
2644
  throw new Error(`Unexpected token in argument: ${this.peek().value}`);
2286
2645
  }
2287
2646
  matchComparison() {
2288
- return this.match(TokenType.EQUALS, TokenType.NOT_EQUALS, TokenType.LESS_THAN, TokenType.LESS_THAN_OR_EQUAL, TokenType.GREATER_THAN, TokenType.GREATER_THAN_OR_EQUAL, TokenType.IN);
2647
+ return this.match(TokenType.EQUALS, TokenType.NOT_EQUALS, TokenType.LESS_THAN, TokenType.LESS_THAN_OR_EQUAL, TokenType.GREATER_THAN, TokenType.GREATER_THAN_OR_EQUAL, TokenType.IN, TokenType.NOT_IN, TokenType.MATCHES, TokenType.NOT_MATCHES, TokenType.IS_NULL, TokenType.IS_NOT_NULL, TokenType.IS_EMPTY, TokenType.IS_NOT_EMPTY);
2289
2648
  }
2290
2649
  tokenTypeToComparisonOperator(tokenType) {
2291
2650
  switch (tokenType) {
@@ -2303,22 +2662,50 @@ class DslParser {
2303
2662
  return ComparisonOperator.GREATER_THAN_OR_EQUAL;
2304
2663
  case TokenType.IN:
2305
2664
  return ComparisonOperator.IN;
2665
+ case TokenType.NOT_IN:
2666
+ return ComparisonOperator.NOT_IN;
2667
+ case TokenType.MATCHES:
2668
+ return ComparisonOperator.MATCHES;
2669
+ case TokenType.NOT_MATCHES:
2670
+ return ComparisonOperator.NOT_MATCHES;
2671
+ case TokenType.IS_NULL:
2672
+ return ComparisonOperator.IS_NULL;
2673
+ case TokenType.IS_NOT_NULL:
2674
+ return ComparisonOperator.IS_NOT_NULL;
2675
+ case TokenType.IS_EMPTY:
2676
+ return ComparisonOperator.IS_EMPTY;
2677
+ case TokenType.IS_NOT_EMPTY:
2678
+ return ComparisonOperator.IS_NOT_EMPTY;
2306
2679
  default:
2307
2680
  throw new Error(`Unknown comparison operator: ${tokenType}`);
2308
2681
  }
2309
2682
  }
2310
2683
  functionNameToOperator(functionName) {
2311
- switch (functionName) {
2684
+ const normalizedFunctionName = this.normalizeFunctionName(functionName);
2685
+ switch (normalizedFunctionName) {
2312
2686
  case 'contains':
2313
2687
  return ComparisonOperator.CONTAINS;
2314
2688
  case 'startsWith':
2315
2689
  return ComparisonOperator.STARTS_WITH;
2316
2690
  case 'endsWith':
2317
2691
  return ComparisonOperator.ENDS_WITH;
2692
+ case 'matches':
2693
+ return ComparisonOperator.MATCHES;
2694
+ case 'notMatches':
2695
+ return ComparisonOperator.NOT_MATCHES;
2318
2696
  default:
2319
- throw new Error(`Unknown function: ${functionName}`);
2697
+ throw new Error(`Unknown function: ${normalizedFunctionName}`);
2320
2698
  }
2321
2699
  }
2700
+ normalizeFunctionName(functionName) {
2701
+ const raw = String(functionName || '').trim();
2702
+ const compactLower = raw.replace(/\s+/g, '').toLowerCase();
2703
+ if (compactLower === 'matches')
2704
+ return 'matches';
2705
+ if (compactLower === 'notmatches')
2706
+ return 'notMatches';
2707
+ return raw;
2708
+ }
2322
2709
  extractValue(spec) {
2323
2710
  // This is a simplified extraction - in a real implementation
2324
2711
  // we'd need to handle more complex cases
@@ -2327,6 +2714,34 @@ class DslParser {
2327
2714
  }
2328
2715
  return spec;
2329
2716
  }
2717
+ operatorRequiresValue(operator) {
2718
+ switch (operator) {
2719
+ case ComparisonOperator.IS_NULL:
2720
+ case ComparisonOperator.IS_NOT_NULL:
2721
+ case ComparisonOperator.IS_EMPTY:
2722
+ case ComparisonOperator.IS_NOT_EMPTY:
2723
+ return false;
2724
+ default:
2725
+ return true;
2726
+ }
2727
+ }
2728
+ defaultValueForOperator(operator) {
2729
+ switch (operator) {
2730
+ case ComparisonOperator.IS_NULL:
2731
+ case ComparisonOperator.IS_NOT_NULL:
2732
+ case ComparisonOperator.IS_EMPTY:
2733
+ case ComparisonOperator.IS_NOT_EMPTY:
2734
+ return null;
2735
+ default:
2736
+ return undefined;
2737
+ }
2738
+ }
2739
+ parseRegexLiteral(raw) {
2740
+ const lastSlash = raw.lastIndexOf('/');
2741
+ const pattern = raw.slice(1, lastSlash);
2742
+ const flags = raw.slice(lastSlash + 1);
2743
+ return new RegExp(pattern, flags);
2744
+ }
2330
2745
  match(...types) {
2331
2746
  for (const type of types) {
2332
2747
  if (this.check(type)) {
@@ -2666,7 +3081,8 @@ class DslValidator {
2666
3081
  'contains', 'startsWith', 'endsWith', 'atLeast', 'exactly',
2667
3082
  'forEach', 'uniqueBy', 'minLength', 'maxLength',
2668
3083
  'requiredIf', 'visibleIf', 'disabledIf', 'readonlyIf',
2669
- 'ifDefined', 'ifNotNull', 'ifExists', 'withDefault'
3084
+ 'ifDefined', 'ifNotNull', 'ifExists', 'withDefault',
3085
+ 'matches', 'notMatches'
2670
3086
  ];
2671
3087
  constructor(config = {}) {
2672
3088
  this.config = {
@@ -2799,7 +3215,9 @@ class DslValidator {
2799
3215
  }
2800
3216
  }
2801
3217
  validateTokens(tokens, input, issues) {
2802
- for (const token of tokens) {
3218
+ for (let i = 0; i < tokens.length; i++) {
3219
+ const token = tokens[i];
3220
+ const nextToken = tokens[i + 1];
2803
3221
  if (token.type === TokenType.EOF)
2804
3222
  continue;
2805
3223
  // Validate numbers
@@ -2821,6 +3239,11 @@ class DslValidator {
2821
3239
  }
2822
3240
  // Validate field references
2823
3241
  if (token.type === TokenType.FIELD_REFERENCE || token.type === TokenType.IDENTIFIER) {
3242
+ // Function names are identifiers too, but should be validated as functions
3243
+ // (validateSemantics), not as field references.
3244
+ const isFunctionIdentifier = this.isFunctionToken(token, nextToken);
3245
+ if (isFunctionIdentifier)
3246
+ continue;
2824
3247
  if (this.config.knownFields.length > 0) {
2825
3248
  if (!this.config.knownFields.includes(token.value)) {
2826
3249
  issues.push({
@@ -2848,6 +3271,14 @@ class DslValidator {
2848
3271
  const prevToken = tokens[i - 1];
2849
3272
  // Check for consecutive operators
2850
3273
  if (this.isOperatorToken(token) && this.isOperatorToken(nextToken)) {
3274
+ // `matches(` and `notMatches(` can be tokenized as operator-like function names.
3275
+ if (this.isFunctionToken(nextToken, tokens[i + 2])) {
3276
+ continue;
3277
+ }
3278
+ // Allow NOT after binary operators (e.g. AND NOT, OR NOT)
3279
+ if (this.isBinaryOperatorToken(token) && nextToken.type === TokenType.NOT) {
3280
+ continue;
3281
+ }
2851
3282
  issues.push({
2852
3283
  type: ValidationIssueType.UNEXPECTED_TOKEN,
2853
3284
  severity: ValidationSeverity.ERROR,
@@ -2863,6 +3294,9 @@ class DslValidator {
2863
3294
  }
2864
3295
  // Check for operators at the beginning/end
2865
3296
  if (i === 0 && this.isBinaryOperatorToken(token)) {
3297
+ if (this.isFunctionToken(token, nextToken)) {
3298
+ continue;
3299
+ }
2866
3300
  issues.push({
2867
3301
  type: ValidationIssueType.UNEXPECTED_TOKEN,
2868
3302
  severity: ValidationSeverity.ERROR,
@@ -2897,8 +3331,8 @@ class DslValidator {
2897
3331
  for (let i = 0; i < tokens.length; i++) {
2898
3332
  const token = tokens[i];
2899
3333
  const nextToken = tokens[i + 1];
2900
- if (token.type === TokenType.IDENTIFIER && nextToken?.type === TokenType.LEFT_PAREN) {
2901
- const functionName = token.value;
3334
+ if (this.isFunctionToken(token, nextToken)) {
3335
+ const functionName = this.normalizeFunctionTokenName(token);
2902
3336
  // Check if function is known
2903
3337
  const allKnownFunctions = [
2904
3338
  ...this.BUILT_IN_FUNCTIONS,
@@ -3010,16 +3444,35 @@ class DslValidator {
3010
3444
  'ifDefined': 2,
3011
3445
  'ifNotNull': 2,
3012
3446
  'ifExists': 2,
3013
- 'withDefault': 3
3447
+ 'withDefault': 3,
3448
+ 'matches': 2,
3449
+ 'notMatches': 2
3014
3450
  };
3015
3451
  return argCounts[functionName] ?? null;
3016
3452
  }
3453
+ isFunctionToken(token, nextToken) {
3454
+ if (!token || !nextToken)
3455
+ return false;
3456
+ if (nextToken.type !== TokenType.LEFT_PAREN)
3457
+ return false;
3458
+ return token.type === TokenType.IDENTIFIER
3459
+ || token.type === TokenType.MATCHES
3460
+ || token.type === TokenType.NOT_MATCHES;
3461
+ }
3462
+ normalizeFunctionTokenName(token) {
3463
+ if (token.type === TokenType.MATCHES)
3464
+ return 'matches';
3465
+ if (token.type === TokenType.NOT_MATCHES)
3466
+ return 'notMatches';
3467
+ return token.value;
3468
+ }
3017
3469
  isOperatorToken(token) {
3018
3470
  return [
3019
3471
  TokenType.AND, TokenType.OR, TokenType.NOT, TokenType.XOR, TokenType.IMPLIES,
3020
3472
  TokenType.EQUALS, TokenType.NOT_EQUALS, TokenType.LESS_THAN,
3021
3473
  TokenType.LESS_THAN_OR_EQUAL, TokenType.GREATER_THAN, TokenType.GREATER_THAN_OR_EQUAL,
3022
- TokenType.IN
3474
+ TokenType.IN, TokenType.NOT_IN, TokenType.MATCHES, TokenType.NOT_MATCHES,
3475
+ TokenType.IS_NULL, TokenType.IS_NOT_NULL, TokenType.IS_EMPTY, TokenType.IS_NOT_EMPTY
3023
3476
  ].includes(token.type);
3024
3477
  }
3025
3478
  isBinaryOperatorToken(token) {
@@ -3027,7 +3480,7 @@ class DslValidator {
3027
3480
  TokenType.AND, TokenType.OR, TokenType.XOR, TokenType.IMPLIES,
3028
3481
  TokenType.EQUALS, TokenType.NOT_EQUALS, TokenType.LESS_THAN,
3029
3482
  TokenType.LESS_THAN_OR_EQUAL, TokenType.GREATER_THAN, TokenType.GREATER_THAN_OR_EQUAL,
3030
- TokenType.IN
3483
+ TokenType.IN, TokenType.NOT_IN, TokenType.MATCHES, TokenType.NOT_MATCHES
3031
3484
  ].includes(token.type);
3032
3485
  }
3033
3486
  suggestSimilarField(fieldName) {
@@ -3222,8 +3675,8 @@ class SpecificationFactory {
3222
3675
  static exactly(exact, specs) {
3223
3676
  return new ExactlySpecification(exact, specs);
3224
3677
  }
3225
- static fieldToField(fieldA, operator, fieldB, transformA, transformB, registry) {
3226
- return new FieldToFieldSpecification(fieldA, operator, fieldB, transformA, transformB, registry);
3678
+ static fieldToField(fieldA, operator, fieldB, transformA, transformB, registry, metadata) {
3679
+ return new FieldToFieldSpecification(fieldA, operator, fieldB, transformA, transformB, registry, metadata);
3227
3680
  }
3228
3681
  static contextual(template, provider) {
3229
3682
  return new ContextualSpecification(template, provider);