@via-profit/ability 3.5.3 → 3.6.0

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.
package/dist/index.js CHANGED
@@ -1,25 +1,12 @@
1
1
  'use strict';
2
2
 
3
- class AbilityCode {
4
- _code;
5
- constructor(code) {
6
- this._code = code;
7
- }
8
- get code() {
9
- return this._code;
10
- }
11
- isEqual(compareWith) {
12
- return compareWith !== null && this.code === compareWith.code;
13
- }
14
- isNotEqual(compareWith) {
15
- return !this.isEqual(compareWith);
16
- }
17
- }
18
-
19
- class AbilityCompare extends AbilityCode {
20
- static and = new AbilityCompare('and');
21
- static or = new AbilityCompare('or');
3
+ function brand$4(code) {
4
+ return code;
22
5
  }
6
+ const AbilityCompare = {
7
+ or: brand$4('or'),
8
+ and: brand$4('and'),
9
+ };
23
10
 
24
11
  class AbilityError extends Error {
25
12
  constructor(message, options) {
@@ -40,106 +27,104 @@ class AbilityParserError extends Error {
40
27
  }
41
28
  }
42
29
 
43
- class AbilityCondition extends AbilityCode {
44
- static equals;
45
- static not_equals;
46
- static greater_than;
47
- static less_than;
48
- static less_or_equal;
49
- static greater_or_equal;
50
- static in;
51
- static not_in;
52
- static contains;
53
- static not_contains;
54
- static length_greater_than;
55
- static length_less_than;
56
- static length_equals;
57
- static always;
58
- static never;
59
- static {
60
- this.equals = new AbilityCondition('=');
61
- this.not_equals = new AbilityCondition('<>');
62
- this.greater_than = new AbilityCondition('>');
63
- this.less_than = new AbilityCondition('<');
64
- this.less_or_equal = new AbilityCondition('<=');
65
- this.greater_or_equal = new AbilityCondition('>=');
66
- this.in = new AbilityCondition('in');
67
- this.not_in = new AbilityCondition('not in');
68
- this.contains = new AbilityCondition('contains');
69
- this.not_contains = new AbilityCondition('not contains');
70
- this.length_greater_than = new AbilityCondition('length greater than');
71
- this.length_less_than = new AbilityCondition('length less than');
72
- this.length_equals = new AbilityCondition('length equals');
73
- this.always = new AbilityCondition('always');
74
- this.never = new AbilityCondition('never');
75
- }
76
- static fromLiteral(literal) {
77
- const map = {
78
- equals: this.equals,
79
- not_equals: this.not_equals,
80
- greater_than: this.greater_than,
81
- less_than: this.less_than,
82
- less_or_equal: this.less_or_equal,
83
- greater_or_equal: this.greater_or_equal,
84
- in: this.in,
85
- not_in: this.not_in,
86
- contains: this.contains,
87
- not_contains: this.not_contains,
88
- length_greater_than: this.length_greater_than,
89
- length_equals: this.length_equals,
90
- always: this.always,
91
- never: this.never,
92
- length_less_than: this.length_less_than,
93
- };
94
- const condition = map[literal];
95
- if (!condition) {
96
- throw new AbilityParserError(`Literal "${literal}" does not found in AbilityCondition class`);
97
- }
98
- return condition;
30
+ function brand$3(code) {
31
+ return code;
32
+ }
33
+ const AbilityCondition = {
34
+ equals: brand$3('='),
35
+ not_equals: brand$3('<>'),
36
+ greater_than: brand$3('>'),
37
+ less_than: brand$3('<'),
38
+ less_or_equal: brand$3('<='),
39
+ greater_or_equal: brand$3('>='),
40
+ in: brand$3('in'),
41
+ not_in: brand$3('not in'),
42
+ contains: brand$3('contains'),
43
+ not_contains: brand$3('not contains'),
44
+ length_greater_than: brand$3('length greater than'),
45
+ length_less_than: brand$3('length less than'),
46
+ length_equals: brand$3('length equals'),
47
+ always: brand$3('always'),
48
+ never: brand$3('never'),
49
+ };
50
+ function fromLiteral(literal) {
51
+ const map = {
52
+ equals: AbilityCondition.equals,
53
+ not_equals: AbilityCondition.not_equals,
54
+ greater_than: AbilityCondition.greater_than,
55
+ less_than: AbilityCondition.less_than,
56
+ less_or_equal: AbilityCondition.less_or_equal,
57
+ greater_or_equal: AbilityCondition.greater_or_equal,
58
+ in: AbilityCondition.in,
59
+ not_in: AbilityCondition.not_in,
60
+ contains: AbilityCondition.contains,
61
+ not_contains: AbilityCondition.not_contains,
62
+ length_greater_than: AbilityCondition.length_greater_than,
63
+ length_less_than: AbilityCondition.length_less_than,
64
+ length_equals: AbilityCondition.length_equals,
65
+ always: AbilityCondition.always,
66
+ never: AbilityCondition.never,
67
+ };
68
+ const value = map[literal];
69
+ if (!value) {
70
+ const expected = Object.keys(map).join(', ');
71
+ throw new AbilityParserError(`Literal "${literal}" does not found in AbilityCondition. Expected one of: ${expected}`);
99
72
  }
100
- get literal() {
101
- switch (this.code) {
102
- case '=':
103
- return 'equals';
104
- case '<>':
105
- return 'not_equals';
106
- case '>':
107
- return 'greater_than';
108
- case '<':
109
- return 'less_than';
110
- case '>=':
111
- return 'greater_or_equal';
112
- case '<=':
113
- return 'less_or_equal';
114
- case 'in':
115
- return 'in';
116
- case 'not in':
117
- return 'not_in';
118
- case 'contains':
119
- return 'contains';
120
- case 'not contains':
121
- return 'not_contains';
122
- case 'length greater than':
123
- return 'length_greater_than';
124
- case 'length less than':
125
- return 'length_less_than';
126
- case 'length equals':
127
- return 'length_equals';
128
- case 'always':
129
- return 'always';
130
- case 'never':
131
- return 'never';
132
- default:
133
- throw new Error(`Unknown condition code: ${String(this.code)}`);
134
- }
73
+ return value;
74
+ }
75
+ function toLiteral(cond) {
76
+ switch (cond) {
77
+ case AbilityCondition.equals:
78
+ return 'equals';
79
+ case AbilityCondition.not_equals:
80
+ return 'not_equals';
81
+ case AbilityCondition.greater_than:
82
+ return 'greater_than';
83
+ case AbilityCondition.less_than:
84
+ return 'less_than';
85
+ case AbilityCondition.less_or_equal:
86
+ return 'less_or_equal';
87
+ case AbilityCondition.greater_or_equal:
88
+ return 'greater_or_equal';
89
+ case AbilityCondition.in:
90
+ return 'in';
91
+ case AbilityCondition.not_in:
92
+ return 'not_in';
93
+ case AbilityCondition.contains:
94
+ return 'contains';
95
+ case AbilityCondition.not_contains:
96
+ return 'not_contains';
97
+ case AbilityCondition.length_greater_than:
98
+ return 'length_greater_than';
99
+ case AbilityCondition.length_less_than:
100
+ return 'length_less_than';
101
+ case AbilityCondition.length_equals:
102
+ return 'length_equals';
103
+ case AbilityCondition.always:
104
+ return 'always';
105
+ case AbilityCondition.never:
106
+ return 'never';
107
+ default:
108
+ return 'never';
135
109
  }
136
110
  }
111
+ function isConditionEqual(a, b) {
112
+ return a !== null && b !== null && a === b;
113
+ }
114
+ function isConditionNotEqual(a, b) {
115
+ return !isConditionEqual(a, b);
116
+ }
137
117
 
138
- class AbilityMatch extends AbilityCode {
139
- static pending = new AbilityMatch('pending');
140
- static match = new AbilityMatch('match');
141
- static mismatch = new AbilityMatch('mismatch');
118
+ function brand$2(code) {
119
+ return code;
142
120
  }
121
+ const AbilityMatch = {
122
+ pending: brand$2('pending'),
123
+ match: brand$2('match'),
124
+ mismatch: brand$2('mismatch'),
125
+ exceptMismatch: brand$2('except-mismatch'),
126
+ disabled: brand$2('disabled'),
127
+ };
143
128
 
144
129
  class AbilityTypeGenerator {
145
130
  policies;
@@ -152,37 +137,103 @@ class AbilityTypeGenerator {
152
137
  */
153
138
  generateTypeDefs() {
154
139
  // Structure to store types: { [action]: { [subjectPath]: type } }
155
- const typeStructure = {};
140
+ const resorceStructure = {};
141
+ const environmentStructure = {};
142
+ // tags
143
+ const allTags = new Set();
156
144
  // Iterate through all policies
157
145
  this.policies.forEach(policy => {
146
+ policy.tags.forEach(tag => allTags.add(tag));
158
147
  const action = policy.permission;
159
148
  // Initialize object for action if it doesn't exist
160
- if (!typeStructure[action]) {
161
- typeStructure[action] = {};
149
+ if (!resorceStructure[action]) {
150
+ resorceStructure[action] = {};
162
151
  }
163
152
  // Iterate through all ruleSets in the policy
164
153
  policy.ruleSet.forEach(ruleSet => {
165
154
  // Iterate through all rules in the ruleSet
166
155
  ruleSet.rules.forEach(rule => {
167
156
  const subjectPath = rule.subject;
168
- const existingType = typeStructure[action][subjectPath];
169
157
  const ruleType = this.determineTypeFromRule(rule);
170
158
  if (!ruleType) {
171
159
  return;
172
160
  }
173
- if (existingType && existingType !== ruleType) {
174
- // If a type already exists for this path, create a union
175
- typeStructure[action][subjectPath] = `${existingType} | ${ruleType}`;
161
+ // -----------------------------
162
+ // ENVIRONMENT HANDLING (subject)
163
+ // -----------------------------
164
+ if (subjectPath.startsWith('env.')) {
165
+ const envPath = subjectPath.replace(/^env\./, '');
166
+ if (!environmentStructure[action]) {
167
+ environmentStructure[action] = {};
168
+ }
169
+ environmentStructure[action][envPath] = ruleType;
176
170
  }
177
171
  else {
178
- typeStructure[action][subjectPath] = ruleType;
172
+ const existingType = resorceStructure[action][subjectPath];
173
+ if (existingType && existingType !== ruleType) {
174
+ resorceStructure[action][subjectPath] = `${existingType} | ${ruleType}`;
175
+ }
176
+ else {
177
+ resorceStructure[action][subjectPath] = ruleType;
178
+ }
179
+ }
180
+ // -----------------------------
181
+ // RESOURCE PATH HANDLING (right side)
182
+ // -----------------------------
183
+ if (typeof rule.resource === 'string' && this.isPath(rule.resource)) {
184
+ const resourcePath = rule.resource;
185
+ // env.* справа
186
+ if (resourcePath.startsWith('env.')) {
187
+ const envPath = resourcePath.replace(/^env\./, '');
188
+ if (!environmentStructure[action]) {
189
+ environmentStructure[action] = {};
190
+ }
191
+ const existingEnvType = environmentStructure[action][envPath];
192
+ const targetType = ruleType; // или 'unknown', если хочешь жёстко
193
+ if (existingEnvType && existingEnvType !== targetType) {
194
+ environmentStructure[action][envPath] = `${existingEnvType} | ${targetType}`;
195
+ }
196
+ else {
197
+ environmentStructure[action][envPath] = targetType;
198
+ }
199
+ }
200
+ else {
201
+ // обычный ресурс справа
202
+ if (!resorceStructure[action]) {
203
+ resorceStructure[action] = {};
204
+ }
205
+ const existingResType = resorceStructure[action][resourcePath];
206
+ const targetType = ruleType; // или 'unknown'
207
+ if (existingResType && existingResType !== targetType) {
208
+ resorceStructure[action][resourcePath] = `${existingResType} | ${targetType}`;
209
+ }
210
+ else {
211
+ resorceStructure[action][resourcePath] = targetType;
212
+ }
213
+ }
179
214
  }
180
215
  });
181
216
  });
182
217
  });
218
+ const filteredStructure = {};
219
+ Object.entries(resorceStructure).forEach(([action, fields]) => {
220
+ if (!action.endsWith('.*')) {
221
+ filteredStructure[action] = fields;
222
+ }
223
+ });
183
224
  // Transform flat structure into nested structure for easier use
184
- const nestedStructure = this.buildNestedStructure(typeStructure);
185
- return this.formatTypeDefinitions(nestedStructure);
225
+ const nestedStructure = this.buildNestedStructure(filteredStructure);
226
+ const nestedEnvironment = this.buildNestedStructure(environmentStructure);
227
+ return this.formatTypeDefinitions(nestedStructure, nestedEnvironment, allTags);
228
+ }
229
+ isPath(value) {
230
+ if (typeof value !== 'string') {
231
+ return false;
232
+ }
233
+ if (value.startsWith('"') || value.startsWith("'")) {
234
+ return false;
235
+ }
236
+ return value.includes('.');
186
237
  }
187
238
  /**
188
239
  * Determines TypeScript type based on the rule
@@ -190,25 +241,32 @@ class AbilityTypeGenerator {
190
241
  * @returns TypeScript type as string
191
242
  */
192
243
  determineTypeFromRule(rule) {
193
- if (rule.condition.isEqual(AbilityCondition.never) ||
194
- rule.condition.isEqual(AbilityCondition.always)) {
244
+ if (rule.condition === AbilityCondition.never || rule.condition === AbilityCondition.always) {
195
245
  return null;
196
246
  }
247
+ if (rule.condition === AbilityCondition.contains ||
248
+ rule.condition === AbilityCondition.not_contains) {
249
+ return this.getArrayType(rule.resource);
250
+ }
251
+ if (rule.condition === AbilityCondition.length_equals ||
252
+ rule.condition === AbilityCondition.length_greater_than ||
253
+ rule.condition === AbilityCondition.length_less_than) {
254
+ return 'string | readonly unknown[]';
255
+ }
197
256
  // Numeric comparisons - always number
198
- if (rule.condition.isEqual(AbilityCondition.greater_than) ||
199
- rule.condition.isEqual(AbilityCondition.less_than) ||
200
- rule.condition.isEqual(AbilityCondition.greater_or_equal) ||
201
- rule.condition.isEqual(AbilityCondition.less_or_equal)) {
257
+ if (rule.condition === AbilityCondition.greater_than ||
258
+ rule.condition === AbilityCondition.greater_or_equal ||
259
+ rule.condition === AbilityCondition.less_than ||
260
+ rule.condition === AbilityCondition.less_or_equal) {
202
261
  return 'number';
203
262
  }
204
263
  // Array operations
205
- if (rule.condition.isEqual(AbilityCondition.in) ||
206
- rule.condition.isEqual(AbilityCondition.not_in)) {
207
- return this.getArrayType(rule.resource);
264
+ if (rule.condition === AbilityCondition.in || rule.condition === AbilityCondition.not_in) {
265
+ return this.getInArrayType(rule.resource);
208
266
  }
209
267
  // Equality/Inequality operations
210
- if (rule.condition.isEqual(AbilityCondition.equals) ||
211
- rule.condition.isEqual(AbilityCondition.not_equals)) {
268
+ if (rule.condition === AbilityCondition.equals ||
269
+ rule.condition === AbilityCondition.not_equals) {
212
270
  return this.getPrimitiveType(rule.resource);
213
271
  }
214
272
  return 'any';
@@ -219,20 +277,38 @@ class AbilityTypeGenerator {
219
277
  * @returns TypeScript array type as string
220
278
  */
221
279
  getArrayType(resource) {
280
+ // if (Array.isArray(resource)) {
281
+ // if (resource.length === 0) {
282
+ // return 'readonly unknown[]';
283
+ // }
284
+ // // Determine types of array elements
285
+ // const elementTypes = new Set(resource.map(item => this.getPrimitiveType(item)));
286
+ // const elementType =
287
+ // elementTypes.size === 1
288
+ // ? Array.from(elementTypes)[0]
289
+ // : `(${Array.from(elementTypes).join(' | ')})`;
290
+ // return `readonly ${elementType}[]`;
291
+ // }
292
+ // // If resource is not an array but condition is in/not_in,
293
+ // // it expects an array of such elements
294
+ // return `readonly ${this.getPrimitiveType(resource)}[]`;
295
+ const elementType = this.getInArrayType(resource);
296
+ return `readonly ${elementType}[]`;
297
+ }
298
+ getInArrayType(resource) {
222
299
  if (Array.isArray(resource)) {
223
300
  if (resource.length === 0) {
224
- return 'any[]';
301
+ return 'unknown';
225
302
  }
226
303
  // Determine types of array elements
227
304
  const elementTypes = new Set(resource.map(item => this.getPrimitiveType(item)));
228
- const elementType = elementTypes.size === 1
305
+ return elementTypes.size === 1
229
306
  ? Array.from(elementTypes)[0]
230
307
  : `(${Array.from(elementTypes).join(' | ')})`;
231
- return `readonly ${elementType}[]`;
232
308
  }
233
309
  // If resource is not an array but condition is in/not_in,
234
310
  // it expects an array of such elements
235
- return `readonly ${this.getPrimitiveType(resource)}[]`;
311
+ return this.getPrimitiveType(resource);
236
312
  }
237
313
  /**
238
314
  * Gets primitive TypeScript type for a value
@@ -241,11 +317,15 @@ class AbilityTypeGenerator {
241
317
  */
242
318
  getPrimitiveType(value) {
243
319
  if (value === null) {
244
- return 'null';
320
+ return 'null | unknown';
245
321
  }
246
322
  if (value === undefined) {
247
323
  return 'undefined';
248
324
  }
325
+ if (typeof value === 'string' && this.isPath(value)) {
326
+ // This is not a string literal, but a path to another field.
327
+ return 'unknown';
328
+ }
249
329
  switch (typeof value) {
250
330
  case 'string':
251
331
  return 'string';
@@ -298,9 +378,11 @@ class AbilityTypeGenerator {
298
378
  /**
299
379
  * Formats type structure into a string
300
380
  * @param structure - Nested type structure
381
+ * @param environment
382
+ * @param allTags
301
383
  * @returns Formatted TypeScript type definition string
302
384
  */
303
- formatTypeDefinitions(structure) {
385
+ formatTypeDefinitions(structure, environment, allTags) {
304
386
  let output = '// Automatically generated by via-profit/ability\n';
305
387
  output += '// Do not edit manually\n';
306
388
  output += 'export type Resources = {\n';
@@ -320,6 +402,29 @@ class AbilityTypeGenerator {
320
402
  }
321
403
  });
322
404
  output += '}\n';
405
+ // tags
406
+ const tagsUnion = allTags.size > 0
407
+ ? Array.from(allTags)
408
+ .sort()
409
+ .map(tag => `'${tag}'`)
410
+ .join(' | ')
411
+ : 'never';
412
+ output += `\n\nexport type PolicyTags = ${tagsUnion};\n`;
413
+ // environments
414
+ output += '\n\nexport type Environment = {\n';
415
+ Object.entries(environment).forEach(([action, envObj]) => {
416
+ const isEmpty = Object.keys(envObj).length === 0;
417
+ if (isEmpty) {
418
+ output += ` ['${action}']: undefined;\n`;
419
+ }
420
+ else {
421
+ output += ` ['${action}']: {\n`;
422
+ output += this.formatNestedObject(envObj, 4);
423
+ output += ' };\n';
424
+ }
425
+ });
426
+ output += '}\n';
427
+ // complex
323
428
  return output;
324
429
  }
325
430
  /**
@@ -363,12 +468,12 @@ class AbilityExplain {
363
468
  }
364
469
  toString(indent = 0) {
365
470
  const pad = ' '.repeat(indent);
366
- const mark = this.match.code === AbilityMatch.match.code ? '✓' : '✗';
471
+ const mark = this.match === AbilityMatch.match ? '✓' : '✗';
367
472
  let out = '';
368
473
  if (this.type === 'policy') {
369
474
  out += '\n';
370
475
  }
371
- out += `${pad}${mark} ${this.type} «${this.name}» is ${this.match.code}`;
476
+ out += `${pad}${mark} ${this.type} «${this.name}» is ${this.match}`;
372
477
  this.children.forEach(child => {
373
478
  out += '\n' + child.toString(indent + 1);
374
479
  });
@@ -399,7 +504,7 @@ class AbilityExplainPolicy extends AbilityExplain {
399
504
  const children = policy.ruleSet.map(ruleSet => new AbilityExplainRuleSet(ruleSet));
400
505
  super({
401
506
  type: 'policy',
402
- name: policy.name,
507
+ name: policy.priority > -1 ? `@priority ${policy.priority} ${policy.name}` : policy.name,
403
508
  match: policy.matchState,
404
509
  }, children);
405
510
  }
@@ -426,6 +531,7 @@ class AbilityPolicy {
426
531
  * Policy name
427
532
  */
428
533
  name;
534
+ description;
429
535
  /**
430
536
  * Policy ID
431
537
  */
@@ -435,13 +541,20 @@ class AbilityPolicy {
435
541
  * will select only those from all passed policies that fall under the specified permission key.
436
542
  */
437
543
  permission;
544
+ priority = -1;
545
+ disabled;
546
+ tags;
438
547
  constructor(params) {
439
- const { name, id, permission, effect, compareMethod = AbilityCompare.and } = params;
440
- this.name = name;
441
- this.id = id;
548
+ const { name, description, id, permission, effect, compareMethod = AbilityCompare.and, priority, disabled, tags, } = params;
549
+ this.id = id || `policy:${effect}:${permission}`;
550
+ this.name = name || this.id;
442
551
  this.permission = permission;
552
+ this.description = description;
443
553
  this.effect = effect;
444
554
  this.compareMethod = compareMethod;
555
+ this.priority = typeof priority === 'number' ? priority : -1;
556
+ this.disabled = typeof disabled === 'boolean' ? disabled : false;
557
+ this.tags = (tags || []);
445
558
  }
446
559
  /**
447
560
  * Add rule set to the policy
@@ -461,6 +574,18 @@ class AbilityPolicy {
461
574
  }
462
575
  return this;
463
576
  }
577
+ /**
578
+ * Extract all rules of all ruleSets of this policy
579
+ */
580
+ extractRules() {
581
+ const rules = [];
582
+ for (const ruleSet of this.ruleSet) {
583
+ for (const rule of ruleSet.rules) {
584
+ rules.push(rule);
585
+ }
586
+ }
587
+ return rules;
588
+ }
464
589
  /**
465
590
  * Check if the policy is matched
466
591
  * @param resource - The resource to check
@@ -468,31 +593,57 @@ class AbilityPolicy {
468
593
  */
469
594
  check(resource, environment) {
470
595
  this.matchState = AbilityMatch.mismatch;
596
+ if (this.disabled) {
597
+ this.matchState = AbilityMatch.disabled;
598
+ return this.matchState;
599
+ }
471
600
  if (!this.ruleSet.length) {
472
601
  return this.matchState;
473
602
  }
474
- const rulesetCheckStates = [];
475
- for (const ruleSet of this.ruleSet) {
476
- const state = ruleSet.check(resource, environment);
477
- rulesetCheckStates.push(state);
478
- if (AbilityCompare.and.isEqual(this.compareMethod) && AbilityMatch.mismatch.isEqual(state)) {
479
- return this.matchState; // mismatch
603
+ const normalGroups = this.ruleSet.filter(g => !g.isExcept);
604
+ const exceptGroups = this.ruleSet.filter(g => g.isExcept);
605
+ const normalStates = [];
606
+ for (const group of normalGroups) {
607
+ if (group.disabled) {
608
+ continue;
480
609
  }
481
- if (AbilityCompare.or.isEqual(this.compareMethod) && AbilityMatch.match.isEqual(state)) {
482
- this.matchState = AbilityMatch.match;
610
+ const state = group.check(resource, environment);
611
+ normalStates.push(state);
612
+ if (AbilityCompare.and === this.compareMethod && AbilityMatch.mismatch === state) {
613
+ this.matchState = AbilityMatch.mismatch;
483
614
  return this.matchState;
484
615
  }
485
- }
486
- if (AbilityCompare.and.isEqual(this.compareMethod)) {
487
- if (rulesetCheckStates.every(s => AbilityMatch.match.isEqual(s))) {
616
+ if (AbilityCompare.or === this.compareMethod && AbilityMatch.match === state) {
488
617
  this.matchState = AbilityMatch.match;
618
+ // break to check except-rule sets
619
+ break;
489
620
  }
490
621
  }
491
- if (AbilityCompare.or.isEqual(this.compareMethod)) {
492
- if (rulesetCheckStates.some(s => AbilityMatch.match.isEqual(s))) {
493
- this.matchState = AbilityMatch.match;
622
+ // 3. Simple rule sets
623
+ let normalMatch = false;
624
+ if (AbilityCompare.and === this.compareMethod) {
625
+ normalMatch = normalStates.every(s => AbilityMatch.match === s);
626
+ }
627
+ else {
628
+ normalMatch = normalStates.some(s => AbilityMatch.match === s);
629
+ }
630
+ if (!normalMatch) {
631
+ this.matchState = AbilityMatch.mismatch;
632
+ return this.matchState;
633
+ }
634
+ // 4. except-rule sets
635
+ for (const group of exceptGroups) {
636
+ if (group.disabled) {
637
+ continue;
638
+ }
639
+ const state = group.check(resource, environment);
640
+ if (AbilityMatch.match === state) {
641
+ this.matchState = AbilityMatch.exceptMismatch;
642
+ return this.matchState;
494
643
  }
495
644
  }
645
+ // 5. match
646
+ this.matchState = AbilityMatch.match;
496
647
  return this.matchState;
497
648
  }
498
649
  explain() {
@@ -505,6 +656,8 @@ class AbilityPolicy {
505
656
  const policy = new AbilityPolicy({
506
657
  id: props.id ?? this.id,
507
658
  name: props.name ?? this.name,
659
+ description: props.description ?? this.description,
660
+ priority: typeof props.priority !== 'undefined' ? props.priority : this.priority,
508
661
  permission: props.permission ?? this.permission,
509
662
  effect: props.effect ?? this.effect,
510
663
  compareMethod: props.compareMethod ?? this.compareMethod,
@@ -517,24 +670,20 @@ class AbilityPolicy {
517
670
  }
518
671
  }
519
672
 
520
- class AbilityPolicyEffect extends AbilityCode {
521
- static deny = new AbilityPolicyEffect('deny');
522
- static permit = new AbilityPolicyEffect('permit');
523
- }
524
-
525
- class AbilityResultState extends AbilityCode {
526
- static allow = new AbilityResultState('allow');
527
- static deny = new AbilityResultState('deny');
528
- static neutral = new AbilityResultState('neutral');
673
+ function brand$1(code) {
674
+ return code;
529
675
  }
676
+ const AbilityPolicyEffect = {
677
+ deny: brand$1('deny'),
678
+ permit: brand$1('permit'),
679
+ };
530
680
 
531
681
  class AbilityResult {
532
- /**
533
- * Already checked policies (after call the policy.check())
534
- */
535
- policies;
536
- constructor(policies) {
537
- this.policies = policies;
682
+ effect;
683
+ strategy;
684
+ constructor(effect, strategy) {
685
+ this.effect = effect;
686
+ this.strategy = strategy;
538
687
  }
539
688
  /**
540
689
  * Returns a list of explanations for each policy involved in the ability evaluation.
@@ -543,53 +692,37 @@ class AbilityResult {
543
692
  * Useful for debugging, logging, or building UI tools that visualize permission logic.
544
693
  */
545
694
  explain() {
546
- return this.policies.map(policy => {
695
+ return this.strategy.policies.map(policy => {
547
696
  return new AbilityExplainPolicy(policy);
548
697
  });
549
698
  }
550
- getLastMatchedPolicy() {
551
- for (let i = this.policies.length - 1; i >= 0; i--) {
552
- if (this.policies[i].matchState.isEqual(AbilityMatch.match)) {
553
- return this.policies[i];
554
- }
555
- }
556
- return null;
557
- }
558
- getFinalState() {
559
- let state = AbilityResultState.neutral;
560
- for (const p of this.policies) {
561
- if (p.matchState.isEqual(AbilityMatch.match)) {
562
- if (p.effect.isEqual(AbilityPolicyEffect.permit)) {
563
- state = AbilityResultState.allow;
564
- }
565
- else if (p.effect.isEqual(AbilityPolicyEffect.deny)) {
566
- state = AbilityResultState.deny;
567
- }
568
- }
569
- else if (p.matchState.isEqual(AbilityMatch.mismatch)) {
570
- state = AbilityResultState.neutral;
571
- }
572
- }
573
- return state;
574
- }
575
699
  isAllowed() {
576
- return this.getFinalState().isEqual(AbilityResultState.allow);
700
+ return this.strategy.isAllowed();
577
701
  }
578
702
  isDenied() {
579
- return this.getFinalState().isNotEqual(AbilityResultState.allow);
703
+ return this.strategy.isDenied();
580
704
  }
581
705
  }
582
706
 
583
707
  class AbilityResolver {
584
- policies;
708
+ StrategyClass;
709
+ policyEntries;
585
710
  constructor(
586
711
  /**
587
712
  * `Important!` The incorrect Resources type was intentionally passed to AbilityPolicy so that TypeScript could suggest the name of the permission and the structure of its resource in the parse method.
588
713
  */
589
- policyOrListOfPolicies) {
590
- this.policies = Array.isArray(policyOrListOfPolicies)
591
- ? policyOrListOfPolicies
592
- : [policyOrListOfPolicies];
714
+ policyOrListOfPolicies, strategy, options = {}) {
715
+ const policies = this.toArray(policyOrListOfPolicies);
716
+ const filtered = options.tags
717
+ ? policies.filter(p => p.tags.some(tag => options.tags.includes(tag)))
718
+ : policies;
719
+ const sorted = [...filtered].sort((a, b) => b.priority - a.priority);
720
+ this.policyEntries = sorted.map(policy => ({
721
+ policy,
722
+ normalizedPermission: this.normalizePermission(policy.permission),
723
+ segments: this.normalizePermission(policy.permission).split('.'),
724
+ }));
725
+ this.StrategyClass = strategy;
593
726
  }
594
727
  /**
595
728
  * Resolve policy for the resource and permission key
@@ -599,24 +732,30 @@ class AbilityResolver {
599
732
  * @param environment
600
733
  */
601
734
  resolve(permission, resource, environment) {
602
- const filteredPolicies = this.policies.filter(policy => AbilityResolver.isInPermissionContain(policy.permission, String(permission).replace(/^permission\./, '')));
735
+ const inputNormalized = this.normalizePermission(String(permission));
736
+ const inputSegments = inputNormalized.split('.');
737
+ const filteredPolicies = this.policyEntries
738
+ .filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
739
+ .map(entry => entry.policy);
740
+ // 2. check the policies
603
741
  for (const policy of filteredPolicies) {
742
+ if (policy.disabled) {
743
+ continue;
744
+ }
604
745
  const policyMatchState = policy.check(resource, environment);
605
746
  if (policyMatchState === AbilityMatch.pending) {
606
747
  throw new AbilityError(`The policy "${policy.name}" is still in a pending state. Make sure to call "check" to evaluate the policy before resolving permissions.`);
607
748
  }
608
749
  }
609
- return new AbilityResult(filteredPolicies);
750
+ // 3. Use strategy
751
+ const strategy = new this.StrategyClass(filteredPolicies);
752
+ const effect = strategy.evaluate();
753
+ return new AbilityResult(effect, strategy);
610
754
  }
611
755
  enforce(permission, resource, environment) {
612
756
  const result = this.resolve(permission, resource, environment);
613
757
  if (result.isDenied()) {
614
- const lastPolicy = result.getLastMatchedPolicy();
615
- if (lastPolicy) {
616
- throw new AbilityError(`Permission denied by policy "${lastPolicy.name.toString()}"`);
617
- }
618
- // No policy matched → implicit deny
619
- throw new AbilityError(`Permission denied: no matching policy found (implicit deny)`);
758
+ throw new AbilityError(`Permission denied`);
620
759
  }
621
760
  }
622
761
  /**
@@ -632,6 +771,32 @@ class AbilityResolver {
632
771
  return chunk === '*' || longer[i] === '*' || chunk === longer[i];
633
772
  });
634
773
  }
774
+ toArray(value) {
775
+ return [...(Array.isArray(value) ? value : [value])];
776
+ }
777
+ normalizePermission(permission) {
778
+ return permission
779
+ .trim()
780
+ .replace(/^permission\./, '') // remove prefix
781
+ .replace(/\.+/g, '.') // collapse multiple dots
782
+ .toLowerCase(); // optional: make case-insensitive
783
+ }
784
+ static matchPermissions(policySegments, inputSegments) {
785
+ const maxLen = Math.max(policySegments.length, inputSegments.length);
786
+ for (let i = 0; i < maxLen; i++) {
787
+ const pSeg = policySegments[i];
788
+ const iSeg = inputSegments[i];
789
+ if (pSeg === undefined)
790
+ return false; // policy короче – не матчит
791
+ if (pSeg === '*')
792
+ continue; // '*' матчит любой сегмент
793
+ if (iSeg === undefined)
794
+ return false; // входной permission короче
795
+ if (pSeg !== iSeg)
796
+ return false;
797
+ }
798
+ return true;
799
+ }
635
800
  }
636
801
 
637
802
  /**
@@ -648,8 +813,10 @@ class AbilityRule {
648
813
  resource;
649
814
  condition;
650
815
  name;
816
+ description;
651
817
  id;
652
818
  state = AbilityMatch.pending;
819
+ disabled;
653
820
  /**
654
821
  * Creates an instance of AbilityRule.
655
822
  * @param {string} params.id - The unique identifier of the rule.
@@ -657,33 +824,37 @@ class AbilityRule {
657
824
  * @param {AbilityCondition} params.condition - The condition to evaluate.
658
825
  * @param {string} params.subject - The subject of the rule.
659
826
  * @param {string} params.resource - The resource to compare against.
827
+ * @param {boolean} params.disabled - Disabling flag.
660
828
  * @param params
661
829
  */
662
830
  constructor(params) {
663
- const { id, name, subject, resource, condition } = params;
664
- this.name = name || `${JSON.stringify(subject)} ${condition.code} ${JSON.stringify(resource)}`;
831
+ const { id, name, subject, resource, condition, disabled, description } = params;
832
+ this.name = name || `rule:${JSON.stringify(subject)}:${condition}:${JSON.stringify(resource)}`;
665
833
  this.id = id || this.name;
834
+ this.description = description;
835
+ this.disabled = typeof disabled === 'boolean' ? disabled : false;
666
836
  this.subject = subject;
667
837
  this.resource = resource;
668
838
  this.condition = condition;
839
+ this.state = this.disabled ? AbilityMatch.disabled : this.state;
669
840
  }
670
- isPrimitive(v) {
841
+ static isPrimitive(v) {
671
842
  return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null;
672
843
  }
673
- isNumber(v) {
844
+ static isNumber(v) {
674
845
  return typeof v === 'number';
675
846
  }
676
- isString(v) {
847
+ static isString(v) {
677
848
  return typeof v === 'string';
678
849
  }
679
- valueLen = (v) => this.isString(v) || Array.isArray(v) ? v.length : null;
680
- operatorHandlers = {
681
- [AbilityCondition.always.literal]: () => true,
682
- [AbilityCondition.never.literal]: () => false,
683
- [AbilityCondition.equals.literal]: (a, b) => a === b,
684
- [AbilityCondition.not_equals.literal]: (a, b) => a !== b,
685
- [AbilityCondition.contains.literal]: (a, b) => {
686
- if (Array.isArray(a) && this.isPrimitive(b)) {
850
+ static valueLen = (v) => this.isString(v) || Array.isArray(v) ? v.length : null;
851
+ static operatorHandlers = {
852
+ [toLiteral(AbilityCondition.always)]: () => true,
853
+ [toLiteral(AbilityCondition.never)]: () => false,
854
+ [toLiteral(AbilityCondition.equals)]: (a, b) => a === b,
855
+ [toLiteral(AbilityCondition.not_equals)]: (a, b) => a !== b,
856
+ [toLiteral(AbilityCondition.contains)]: (a, b) => {
857
+ if (Array.isArray(a) && AbilityRule.isPrimitive(b)) {
687
858
  return a.includes(b);
688
859
  }
689
860
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -691,8 +862,8 @@ class AbilityRule {
691
862
  }
692
863
  return false;
693
864
  },
694
- [AbilityCondition.not_contains.literal]: (a, b) => {
695
- if (Array.isArray(a) && this.isPrimitive(b)) {
865
+ [toLiteral(AbilityCondition.not_contains)]: (a, b) => {
866
+ if (Array.isArray(a) && AbilityRule.isPrimitive(b)) {
696
867
  return !a.includes(b);
697
868
  }
698
869
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -700,8 +871,8 @@ class AbilityRule {
700
871
  }
701
872
  return false;
702
873
  },
703
- [AbilityCondition.in.literal]: (a, b) => {
704
- if (this.isPrimitive(a) && Array.isArray(b)) {
874
+ [toLiteral(AbilityCondition.in)]: (a, b) => {
875
+ if (AbilityRule.isPrimitive(a) && Array.isArray(b)) {
705
876
  return b.includes(a);
706
877
  }
707
878
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -709,8 +880,8 @@ class AbilityRule {
709
880
  }
710
881
  return false;
711
882
  },
712
- [AbilityCondition.not_in.literal]: (a, b) => {
713
- if (this.isPrimitive(a) && Array.isArray(b)) {
883
+ [toLiteral(AbilityCondition.not_in)]: (a, b) => {
884
+ if (AbilityRule.isPrimitive(a) && Array.isArray(b)) {
714
885
  return !b.includes(a);
715
886
  }
716
887
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -718,55 +889,55 @@ class AbilityRule {
718
889
  }
719
890
  return false;
720
891
  },
721
- [AbilityCondition.greater_than.literal]: (a, b) => {
722
- return this.isNumber(a) && this.isNumber(b) ? a > b : false;
892
+ [toLiteral(AbilityCondition.greater_than)]: (a, b) => {
893
+ return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a > b : false;
723
894
  },
724
- [AbilityCondition.less_than.literal]: (a, b) => {
725
- return this.isNumber(a) && this.isNumber(b) ? a < b : false;
895
+ [toLiteral(AbilityCondition.less_than)]: (a, b) => {
896
+ return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a < b : false;
726
897
  },
727
- [AbilityCondition.greater_or_equal.literal]: (a, b) => {
728
- return this.isNumber(a) && this.isNumber(b) ? a >= b : false;
898
+ [toLiteral(AbilityCondition.greater_or_equal)]: (a, b) => {
899
+ return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a >= b : false;
729
900
  },
730
- [AbilityCondition.less_or_equal.literal]: (a, b) => {
731
- return this.isNumber(a) && this.isNumber(b) ? a <= b : false;
901
+ [toLiteral(AbilityCondition.less_or_equal)]: (a, b) => {
902
+ return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a <= b : false;
732
903
  },
733
- [AbilityCondition.length_greater_than.literal]: (a, b) => {
734
- const alen = this.valueLen(a);
904
+ [toLiteral(AbilityCondition.length_greater_than)]: (a, b) => {
905
+ const alen = AbilityRule.valueLen(a);
735
906
  if (alen === null) {
736
907
  return false;
737
908
  }
738
- if (this.isNumber(b)) {
909
+ if (AbilityRule.isNumber(b)) {
739
910
  return alen > b;
740
911
  }
741
- const bLen = this.valueLen(b);
912
+ const bLen = AbilityRule.valueLen(b);
742
913
  if (bLen !== null) {
743
914
  return alen > bLen;
744
915
  }
745
916
  return false;
746
917
  },
747
- [AbilityCondition.length_less_than.literal]: (a, b) => {
748
- const alen = this.valueLen(a);
918
+ [toLiteral(AbilityCondition.length_less_than)]: (a, b) => {
919
+ const alen = AbilityRule.valueLen(a);
749
920
  if (alen === null) {
750
921
  return false;
751
922
  }
752
- if (this.isNumber(b)) {
923
+ if (AbilityRule.isNumber(b)) {
753
924
  return alen < b;
754
925
  }
755
- const bLen = this.valueLen(b);
926
+ const bLen = AbilityRule.valueLen(b);
756
927
  if (bLen !== null) {
757
928
  return alen < bLen;
758
929
  }
759
930
  return false;
760
931
  },
761
- [AbilityCondition.length_equals.literal]: (a, b) => {
762
- const alen = this.valueLen(a);
932
+ [toLiteral(AbilityCondition.length_equals)]: (a, b) => {
933
+ const alen = AbilityRule.valueLen(a);
763
934
  if (alen === null) {
764
935
  return false;
765
936
  }
766
- if (this.isNumber(b)) {
937
+ if (AbilityRule.isNumber(b)) {
767
938
  return alen === b;
768
939
  }
769
- const bLen = this.valueLen(b);
940
+ const bLen = AbilityRule.valueLen(b);
770
941
  if (bLen !== null) {
771
942
  return alen === bLen;
772
943
  }
@@ -779,8 +950,12 @@ class AbilityRule {
779
950
  * @param environment
780
951
  */
781
952
  check(resource, environment) {
953
+ if (this.disabled) {
954
+ this.state = AbilityMatch.disabled;
955
+ return this.state;
956
+ }
782
957
  const [subjectValue, resourceValue] = this.extractValues(resource, environment);
783
- const handler = this.operatorHandlers[this.condition.literal];
958
+ const handler = AbilityRule.operatorHandlers[toLiteral(this.condition)];
784
959
  const result = handler(subjectValue, resourceValue);
785
960
  this.state = result ? AbilityMatch.match : AbilityMatch.mismatch;
786
961
  return this.state;
@@ -827,41 +1002,62 @@ class AbilityRule {
827
1002
  }
828
1003
  return [subjectValue, resourceValue];
829
1004
  }
1005
+ static _pathCache = new Map();
1006
+ static _parsePath(desc) {
1007
+ const cached = AbilityRule._pathCache.get(desc);
1008
+ if (cached)
1009
+ return cached;
1010
+ const parts = desc.split('.');
1011
+ const segments = [];
1012
+ for (const part of parts) {
1013
+ const bracketIdx = part.indexOf('[');
1014
+ if (bracketIdx !== -1) {
1015
+ // формат: "prop[index]" (индекс может быть только числом)
1016
+ const prop = part.slice(0, bracketIdx);
1017
+ const indexStr = part.slice(bracketIdx + 1, -1);
1018
+ const index = Number(indexStr);
1019
+ segments.push({ prop, index });
1020
+ }
1021
+ else {
1022
+ segments.push(part);
1023
+ }
1024
+ }
1025
+ AbilityRule._pathCache.set(desc, segments);
1026
+ return segments;
1027
+ }
830
1028
  /**
831
1029
  * Get the value of the object by dot notation
832
1030
  * @param resource - The object to get the value from
833
1031
  * @param desc - The dot notation string
834
1032
  */
835
1033
  getDotNotationValue(resource, desc) {
836
- const arr = desc.split('.');
837
- while (arr.length && resource) {
838
- const comp = arr.shift() || '';
839
- const match = new RegExp('(.+)\\[([0-9]*)]').exec(comp);
840
- if (match !== null && match.length == 3) {
841
- const arrayData = {
842
- arrName: match[1],
843
- arrIndex: match[2],
844
- };
845
- if (resource[arrayData.arrName] !== undefined) {
846
- resource = resource[arrayData.arrName][arrayData.arrIndex];
847
- }
848
- else {
849
- resource = undefined;
850
- }
1034
+ if (resource == null) {
1035
+ return undefined;
1036
+ }
1037
+ const segments = AbilityRule._parsePath(desc);
1038
+ let current = resource;
1039
+ for (const seg of segments) {
1040
+ if (current == null) {
1041
+ return undefined;
1042
+ }
1043
+ if (typeof seg === 'string') {
1044
+ current = current[seg];
851
1045
  }
852
1046
  else {
853
- resource = resource[comp];
1047
+ const arr = current[seg.prop];
1048
+ current = Array.isArray(arr) ? arr[seg.index] : undefined;
854
1049
  }
855
1050
  }
856
- return resource;
1051
+ return current;
857
1052
  }
858
1053
  toString() {
859
- return `AbilityRule: ${this.name} condition: ${this.condition.code} subject: "${this.subject?.toString()}" resource: "${this.resource?.toString()}"`;
1054
+ return `AbilityRule: ${this.name} condition: ${toLiteral(this.condition)} subject: "${this.subject?.toString()}" resource: "${this.resource?.toString()}"`;
860
1055
  }
861
1056
  copyWith(props) {
862
1057
  return new AbilityRule({
863
1058
  id: props.id ?? this.id,
864
1059
  name: props.name ?? this.name,
1060
+ description: props.description ?? this.description,
865
1061
  subject: props.subject ?? this.subject,
866
1062
  resource: props.resource ?? this.resource,
867
1063
  condition: props.condition ?? this.condition,
@@ -963,15 +1159,22 @@ class AbilityRuleSet {
963
1159
  * Group name
964
1160
  */
965
1161
  name;
1162
+ description;
966
1163
  /**
967
1164
  * Group ID
968
1165
  */
969
1166
  id;
1167
+ isExcept = false;
1168
+ disabled;
970
1169
  constructor(params) {
971
- const { name, id, compareMethod } = params;
972
- this.name = name || '';
1170
+ const { name, id, compareMethod, isExcept, disabled, description } = params;
1171
+ this.name = name || `ruleset:${compareMethod}`;
973
1172
  this.id = id || this.name;
1173
+ this.description = description;
974
1174
  this.compareMethod = compareMethod;
1175
+ this.isExcept = isExcept;
1176
+ this.disabled = typeof disabled === 'boolean' ? disabled : false;
1177
+ this.state = this.disabled ? AbilityMatch.disabled : this.state;
975
1178
  }
976
1179
  addRule(rule) {
977
1180
  this.rules.push(rule);
@@ -983,40 +1186,48 @@ class AbilityRuleSet {
983
1186
  }
984
1187
  check(resources, environment) {
985
1188
  this.state = AbilityMatch.mismatch;
1189
+ if (this.disabled) {
1190
+ this.state = AbilityMatch.disabled;
1191
+ return this.state;
1192
+ }
986
1193
  if (!this.rules.length) {
987
1194
  return this.state;
988
1195
  }
989
1196
  const ruleCheckStates = [];
990
1197
  for (const rule of this.rules) {
1198
+ if (rule.disabled) {
1199
+ continue;
1200
+ }
991
1201
  const state = rule.check(resources, environment);
992
1202
  ruleCheckStates.push(state);
993
- if (AbilityCompare.and.isEqual(this.compareMethod) && AbilityMatch.mismatch.isEqual(state)) {
1203
+ if (AbilityCompare.and === this.compareMethod && AbilityMatch.mismatch === state) {
994
1204
  return this.state; // mismatch
995
1205
  }
996
- if (AbilityCompare.or.isEqual(this.compareMethod) && AbilityMatch.match.isEqual(state)) {
1206
+ if (AbilityCompare.or === this.compareMethod && AbilityMatch.match === state) {
997
1207
  this.state = AbilityMatch.match;
998
1208
  return this.state;
999
1209
  }
1000
1210
  }
1001
- if (AbilityCompare.and.isEqual(this.compareMethod)) {
1002
- if (ruleCheckStates.every(s => AbilityMatch.match.isEqual(s))) {
1211
+ if (AbilityCompare.and === this.compareMethod) {
1212
+ if (ruleCheckStates.every(s => AbilityMatch.match === s)) {
1003
1213
  this.state = AbilityMatch.match;
1004
1214
  }
1005
1215
  }
1006
- if (AbilityCompare.or.isEqual(this.compareMethod)) {
1007
- if (ruleCheckStates.some(s => AbilityMatch.match.isEqual(s))) {
1216
+ if (AbilityCompare.or === this.compareMethod) {
1217
+ if (ruleCheckStates.some(s => AbilityMatch.match === s)) {
1008
1218
  this.state = AbilityMatch.match;
1009
1219
  }
1010
1220
  }
1011
1221
  return this.state;
1012
1222
  }
1013
1223
  toString() {
1014
- return `AbilityRuleSet: ${this.name} compareMethod: ${this.compareMethod.code}, rules: ${this.rules.map(rule => rule.toString()).join('\n')}`;
1224
+ return `AbilityRuleSet: ${this.name} compareMethod: ${this.compareMethod}, rules: ${this.rules.map(rule => rule.toString()).join('\n')}`;
1015
1225
  }
1016
1226
  copyWith(props) {
1017
1227
  const next = new AbilityRuleSet({
1018
1228
  id: props.id ?? this.id,
1019
1229
  name: props.name ?? this.name,
1230
+ description: props.description ?? this.description,
1020
1231
  compareMethod: props.compareMethod ?? this.compareMethod,
1021
1232
  });
1022
1233
  const nextRules = props.rules ?? this.rules;
@@ -1047,37 +1258,42 @@ class AbilityJSONParser {
1047
1258
  return configs.map(config => AbilityJSONParser.parsePolicy(config));
1048
1259
  }
1049
1260
  static parsePolicy(config) {
1050
- const { id, name, ruleSet, compareMethod, permission, effect } = config;
1261
+ const { id, name, ruleSet, compareMethod, permission, effect, priority, disabled, tags } = config;
1051
1262
  // Create the empty policy
1052
1263
  const policy = new AbilityPolicy({
1053
1264
  name,
1054
1265
  id,
1055
1266
  permission: permission,
1056
- effect: new AbilityPolicyEffect(effect),
1267
+ priority: priority,
1268
+ effect: effect,
1269
+ disabled,
1270
+ tags,
1057
1271
  });
1058
- policy.compareMethod = new AbilityCompare(compareMethod);
1272
+ policy.compareMethod = compareMethod;
1059
1273
  ruleSet.forEach(ruleSetConfig => {
1060
1274
  policy.addRuleSet(AbilityJSONParser.parseRuleSet(ruleSetConfig));
1061
1275
  });
1062
1276
  return policy;
1063
1277
  }
1064
1278
  static parseRule(config) {
1065
- const { id, name, subject, resource, condition } = config;
1279
+ const { id, name, subject, resource, condition, disabled } = config;
1066
1280
  return new AbilityRule({
1067
1281
  id,
1068
1282
  name,
1069
1283
  subject,
1070
1284
  resource,
1071
- condition: new AbilityCondition(condition),
1285
+ disabled,
1286
+ condition,
1072
1287
  });
1073
1288
  }
1074
1289
  /**
1075
1290
  * Parse the config JSON format to Group class instance
1076
1291
  */
1077
1292
  static parseRuleSet(config) {
1078
- const { id, name, rules, compareMethod } = config;
1293
+ const { id, name, rules, compareMethod, disabled } = config;
1079
1294
  const ruleSet = new AbilityRuleSet({
1080
- compareMethod: new AbilityCompare(compareMethod),
1295
+ disabled,
1296
+ compareMethod: compareMethod,
1081
1297
  name,
1082
1298
  id,
1083
1299
  });
@@ -1094,25 +1310,30 @@ class AbilityJSONParser {
1094
1310
  name: rule.name,
1095
1311
  subject: rule.subject,
1096
1312
  resource: rule.resource,
1097
- condition: rule.condition.code,
1313
+ condition: rule.condition,
1314
+ disabled: rule.disabled,
1098
1315
  };
1099
1316
  }
1100
1317
  static ruleSetToJSON(ruleSet) {
1101
1318
  return {
1102
1319
  id: ruleSet.id.toString(),
1103
1320
  name: ruleSet.name.toString(),
1104
- compareMethod: ruleSet.compareMethod.code.toString(),
1321
+ compareMethod: ruleSet.compareMethod,
1105
1322
  rules: ruleSet.rules.map(rule => AbilityJSONParser.ruleToJSON(rule)),
1323
+ disabled: ruleSet.disabled,
1106
1324
  };
1107
1325
  }
1108
1326
  static policyToJSON(policy) {
1109
1327
  return {
1110
1328
  id: policy.id.toString(),
1111
1329
  name: policy.name.toString(),
1112
- compareMethod: policy.compareMethod.code.toString(),
1330
+ compareMethod: policy.compareMethod,
1113
1331
  ruleSet: policy.ruleSet.map(ruleSet => AbilityJSONParser.ruleSetToJSON(ruleSet)),
1114
1332
  permission: policy.permission,
1115
- effect: policy.effect.code,
1333
+ effect: policy.effect,
1334
+ priority: policy.priority,
1335
+ disabled: policy.disabled,
1336
+ tags: policy.tags,
1116
1337
  };
1117
1338
  }
1118
1339
  static toJSON(policies) {
@@ -1120,67 +1341,66 @@ class AbilityJSONParser {
1120
1341
  }
1121
1342
  }
1122
1343
 
1123
- /**
1124
- * Represents a single token produced by the Ability DSL lexer.
1125
- * Each token carries a type (e.g., EFFECT, IDENTIFIER, STRING) and its raw string value.
1126
- */
1127
- class AbilityDSLToken extends AbilityCode {
1128
- /** The literal text of the token as it appeared in the input (e.g., "permit", "user.roles", "admin"). */
1129
- value = '';
1130
- /** The line number in DSL */
1131
- line = 1;
1132
- /** The column in dsl */
1133
- column = 1;
1344
+ function brand(code) {
1345
+ return code;
1346
+ }
1347
+ const TokenTypes = {
1348
+ EFFECT: brand('EFFECT'),
1349
+ IF: brand('IF'),
1350
+ PERMISSION: brand('PERMISSION'),
1351
+ IDENTIFIER: brand('IDENTIFIER'),
1352
+ COLON: brand('COLON'),
1353
+ COMMA: brand('COMMA'),
1354
+ DOT: brand('DOT'),
1355
+ LBRACKET: brand('LBRACKET'),
1356
+ RBRACKET: brand('RBRACKET'),
1357
+ ALL: brand('ALL'),
1358
+ ANY: brand('ANY'),
1359
+ OF: brand('OF'),
1360
+ EOF: brand('EOF'),
1361
+ COMMENT: brand('COMMENT'),
1362
+ EQ: brand('EQ'),
1363
+ CONTAINS: brand('CONTAINS'),
1364
+ IN: brand('IN'),
1365
+ NOT_IN: brand('NOT_IN'),
1366
+ NOT_CONTAINS: brand('NOT_CONTAINS'),
1367
+ GT: brand('GT'),
1368
+ GTE: brand('GTE'),
1369
+ LT: brand('LT'),
1370
+ LTE: brand('LTE'),
1371
+ NULL: brand('NULL'),
1372
+ EQ_NULL: brand('EQ_NULL'),
1373
+ NOT_EQ_NULL: brand('NOT_EQ_NULL'),
1374
+ NOT_EQ: brand('NOT_EQ'),
1375
+ LEN_GT: brand('LEN_GT'),
1376
+ LEN_LT: brand('LEN_LT'),
1377
+ LEN_EQ: brand('LEN_EQ'),
1378
+ ALWAYS: brand('ALWAYS'),
1379
+ NEVER: brand('NEVER'),
1380
+ EXCEPT: brand('EXCEPT'),
1381
+ ANNOTATION: brand('ANNOTATION'),
1382
+ STRING: brand('STRING'),
1383
+ NUMBER: brand('NUMBER'),
1384
+ BOOLEAN: brand('BOOLEAN'),
1385
+ SYMBOL: brand('SYMBOL'),
1386
+ KEYWORD: brand('KEYWORD'),
1387
+ ALIAS: brand('ALIAS'),
1388
+ UNKNOWN: brand('UNKNOWN'),
1389
+ };
1390
+ class AbilityDSLToken {
1391
+ type;
1392
+ value;
1393
+ line;
1394
+ column;
1134
1395
  constructor(type, value, line, column) {
1135
- super(type);
1396
+ this.type = type;
1136
1397
  this.value = value;
1137
1398
  this.line = line;
1138
1399
  this.column = column;
1139
1400
  }
1140
- /**
1141
- * Returns a human-readable representation of the token, useful for debugging.
1142
- * Example output: "AbilityDSLToken([EFFECT] permit"
1143
- */
1144
1401
  toString() {
1145
- return `AbilityDSLToken([${this.code}] "${this.value}" at ${this.line}:${this.column})`;
1146
- }
1147
- static EFFECT = 'EFFECT';
1148
- static IF = 'IF';
1149
- static PERMISSION = 'PERMISSION';
1150
- static IDENTIFIER = 'IDENTIFIER';
1151
- static COLON = 'COLON';
1152
- static COMMA = 'COMMA';
1153
- static DOT = 'DOT';
1154
- static LBRACKET = 'LBRACKET';
1155
- static RBRACKET = 'RBRACKET';
1156
- static ALL = 'ALL';
1157
- static ANY = 'ANY';
1158
- static OF = 'OF';
1159
- static EOF = 'EOF';
1160
- static COMMENT = 'COMMENT';
1161
- static EQ = 'EQ';
1162
- static CONTAINS = 'CONTAINS';
1163
- static IN = 'IN';
1164
- static NOT_IN = 'NOT_IN';
1165
- static NOT_CONTAINS = 'NOT_CONTAINS';
1166
- static GT = 'GT';
1167
- static GTE = 'GTE';
1168
- static LT = 'LT';
1169
- static LTE = 'LTE';
1170
- static NULL = 'NULL';
1171
- static EQ_NULL = 'EQ_NULL';
1172
- static NOT_EQ_NULL = 'NOT_EQ_NULL';
1173
- static LEN_GT = 'LEN_GT';
1174
- static LEN_LT = 'LEN_LT';
1175
- static LEN_EQ = 'LEN_EQ';
1176
- static NOT_EQ = 'NOT_EQ';
1177
- static ALWAYS = 'ALWAYS';
1178
- static NEVER = 'NEVER';
1179
- static STRING = 'STRING';
1180
- static NUMBER = 'NUMBER';
1181
- static BOOLEAN = 'BOOLEAN';
1182
- static SYMBOL = 'SYMBOL';
1183
- static KEYWORD = 'KEYWORD';
1402
+ return `AbilityDSLToken([${this.type}] "${this.value}" at ${this.line}:${this.column})`;
1403
+ }
1184
1404
  }
1185
1405
 
1186
1406
  class AbilityDSLLexer {
@@ -1189,7 +1409,6 @@ class AbilityDSLLexer {
1189
1409
  tokens = [];
1190
1410
  line = 1;
1191
1411
  column = 1;
1192
- // Список ключевых слов
1193
1412
  keywords = new Set([
1194
1413
  'if',
1195
1414
  'all',
@@ -1220,6 +1439,8 @@ class AbilityDSLLexer {
1220
1439
  'than',
1221
1440
  'always',
1222
1441
  'never',
1442
+ 'except',
1443
+ 'alias',
1223
1444
  ]);
1224
1445
  constructor(input) {
1225
1446
  this.input = input;
@@ -1230,6 +1451,10 @@ class AbilityDSLLexer {
1230
1451
  if (this.isAtEnd())
1231
1452
  break;
1232
1453
  const char = this.peek();
1454
+ if (char === '@') {
1455
+ this.tokens.push(this.readAnnotation());
1456
+ continue;
1457
+ }
1233
1458
  if (char === '#') {
1234
1459
  this.tokens.push(this.readComment());
1235
1460
  continue;
@@ -1252,7 +1477,7 @@ class AbilityDSLLexer {
1252
1477
  }
1253
1478
  throw new Error(`Unexpected character '${char}' at ${this.line}:${this.column}`);
1254
1479
  }
1255
- this.tokens.push(new AbilityDSLToken(AbilityDSLToken.EOF, '', this.line, this.column));
1480
+ this.tokens.push(new AbilityDSLToken(TokenTypes.EOF, '', this.line, this.column));
1256
1481
  return this.tokens;
1257
1482
  }
1258
1483
  readComment() {
@@ -1263,7 +1488,31 @@ class AbilityDSLLexer {
1263
1488
  while (!this.isAtEnd() && !this.isNewline()) {
1264
1489
  value += this.advance();
1265
1490
  }
1266
- return new AbilityDSLToken(AbilityDSLToken.COMMENT, value.trim(), startLine, startColumn);
1491
+ return new AbilityDSLToken(TokenTypes.COMMENT, value.trim(), startLine, startColumn);
1492
+ }
1493
+ // private readAlias(): AbilityDSLToken {
1494
+ // const startLine = this.line;
1495
+ // const startColumn = this.column;
1496
+ //
1497
+ // this.advance(); // skip "alias" keyword
1498
+ //
1499
+ // // Read colon
1500
+ // this.readSymbol();
1501
+ //
1502
+ // let value = '';
1503
+ // while (!this.isAtEnd() && !this.isNewline()) {
1504
+ // value += this.advance();
1505
+ // }
1506
+ // return new AbilityDSLToken(TokenTypes.ALIAS, value.trim(), startLine, startColumn);
1507
+ // }
1508
+ readAnnotation() {
1509
+ const startLine = this.line;
1510
+ const startColumn = this.column;
1511
+ let value = '';
1512
+ while (!this.isAtEnd() && !this.isNewline()) {
1513
+ value += this.advance();
1514
+ }
1515
+ return new AbilityDSLToken(TokenTypes.ANNOTATION, value.trim(), startLine, startColumn);
1267
1516
  }
1268
1517
  readString() {
1269
1518
  const startLine = this.line;
@@ -1283,7 +1532,7 @@ class AbilityDSLLexer {
1283
1532
  continue;
1284
1533
  }
1285
1534
  if (char === quote) {
1286
- return new AbilityDSLToken(AbilityDSLToken.STRING, value, startLine, startColumn);
1535
+ return new AbilityDSLToken(TokenTypes.STRING, value, startLine, startColumn);
1287
1536
  }
1288
1537
  value += char;
1289
1538
  }
@@ -1297,7 +1546,7 @@ class AbilityDSLLexer {
1297
1546
  this.advance();
1298
1547
  }
1299
1548
  const value = this.input.slice(start, this.pos);
1300
- return new AbilityDSLToken(AbilityDSLToken.NUMBER, value, startLine, startColumn);
1549
+ return new AbilityDSLToken(TokenTypes.NUMBER, value, startLine, startColumn);
1301
1550
  }
1302
1551
  readSymbol() {
1303
1552
  const startLine = this.line;
@@ -1305,41 +1554,41 @@ class AbilityDSLLexer {
1305
1554
  const char = this.advance();
1306
1555
  switch (char) {
1307
1556
  case '.':
1308
- return new AbilityDSLToken(AbilityDSLToken.DOT, char, startLine, startColumn);
1557
+ return new AbilityDSLToken(TokenTypes.DOT, char, startLine, startColumn);
1309
1558
  case ':':
1310
- return new AbilityDSLToken(AbilityDSLToken.COLON, char, startLine, startColumn);
1559
+ return new AbilityDSLToken(TokenTypes.COLON, char, startLine, startColumn);
1311
1560
  case ',':
1312
- return new AbilityDSLToken(AbilityDSLToken.COMMA, char, startLine, startColumn);
1561
+ return new AbilityDSLToken(TokenTypes.COMMA, char, startLine, startColumn);
1313
1562
  case '[':
1314
- return new AbilityDSLToken(AbilityDSLToken.LBRACKET, char, startLine, startColumn);
1563
+ return new AbilityDSLToken(TokenTypes.LBRACKET, char, startLine, startColumn);
1315
1564
  case ']':
1316
- return new AbilityDSLToken(AbilityDSLToken.RBRACKET, char, startLine, startColumn);
1565
+ return new AbilityDSLToken(TokenTypes.RBRACKET, char, startLine, startColumn);
1317
1566
  case '>':
1318
1567
  if (this.peek() === '=') {
1319
1568
  this.advance();
1320
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '>=', startLine, startColumn);
1569
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '>=', startLine, startColumn);
1321
1570
  }
1322
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '>', startLine, startColumn);
1571
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '>', startLine, startColumn);
1323
1572
  case '<':
1324
1573
  if (this.peek() === '=') {
1325
1574
  this.advance();
1326
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '<=', startLine, startColumn);
1575
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '<=', startLine, startColumn);
1327
1576
  }
1328
1577
  if (this.peek() === '>') {
1329
1578
  this.advance();
1330
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '<>', startLine, startColumn);
1579
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '<>', startLine, startColumn);
1331
1580
  }
1332
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '<', startLine, startColumn);
1581
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '<', startLine, startColumn);
1333
1582
  case '=':
1334
1583
  if (this.peek() === '=') {
1335
1584
  this.advance();
1336
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '==', startLine, startColumn);
1585
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '==', startLine, startColumn);
1337
1586
  }
1338
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '=', startLine, startColumn);
1587
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '=', startLine, startColumn);
1339
1588
  case '!':
1340
1589
  if (this.peek() === '=') {
1341
1590
  this.advance();
1342
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '!=', startLine, startColumn);
1591
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '!=', startLine, startColumn);
1343
1592
  }
1344
1593
  throw new Error(`Unexpected symbol '!' at ${this.line}:${this.column}`);
1345
1594
  default:
@@ -1366,60 +1615,66 @@ class AbilityDSLLexer {
1366
1615
  }
1367
1616
  const word = this.input.slice(start, this.pos);
1368
1617
  if (word === 'always') {
1369
- return new AbilityDSLToken(AbilityDSLToken.ALWAYS, word, startLine, startColumn);
1618
+ return new AbilityDSLToken(TokenTypes.ALWAYS, word, startLine, startColumn);
1370
1619
  }
1371
1620
  if (word === 'never') {
1372
- return new AbilityDSLToken(AbilityDSLToken.NEVER, word, startLine, startColumn);
1621
+ return new AbilityDSLToken(TokenTypes.NEVER, word, startLine, startColumn);
1373
1622
  }
1374
1623
  // Если есть точка — это путь (identifier или permission)
1375
1624
  if (word.includes('.')) {
1376
1625
  const last = this.tokens[this.tokens.length - 1];
1377
- if (last?.code === AbilityDSLToken.EFFECT) {
1626
+ if (last?.type === TokenTypes.EFFECT) {
1378
1627
  if (word.startsWith('permission.')) {
1379
- return new AbilityDSLToken(AbilityDSLToken.PERMISSION, word, startLine, startColumn);
1628
+ return new AbilityDSLToken(TokenTypes.PERMISSION, word, startLine, startColumn);
1380
1629
  }
1381
1630
  }
1382
- return new AbilityDSLToken(AbilityDSLToken.IDENTIFIER, word, startLine, startColumn);
1631
+ return new AbilityDSLToken(TokenTypes.IDENTIFIER, word, startLine, startColumn);
1383
1632
  }
1384
1633
  // Ключевые слова
1385
1634
  if (this.keywords.has(word)) {
1386
1635
  // Эффекты
1387
1636
  if (word === 'permit' || word === 'allow') {
1388
- return new AbilityDSLToken(AbilityDSLToken.EFFECT, 'permit', startLine, startColumn);
1637
+ return new AbilityDSLToken(TokenTypes.EFFECT, 'permit', startLine, startColumn);
1389
1638
  }
1390
1639
  if (word === 'deny' || word === 'forbidden') {
1391
- return new AbilityDSLToken(AbilityDSLToken.EFFECT, 'deny', startLine, startColumn);
1640
+ return new AbilityDSLToken(TokenTypes.EFFECT, 'deny', startLine, startColumn);
1392
1641
  }
1393
1642
  // Групповые ключевые слова
1394
1643
  if (word === 'all') {
1395
- return new AbilityDSLToken(AbilityDSLToken.ALL, word, startLine, startColumn);
1644
+ return new AbilityDSLToken(TokenTypes.ALL, word, startLine, startColumn);
1396
1645
  }
1397
1646
  if (word === 'any') {
1398
- return new AbilityDSLToken(AbilityDSLToken.ANY, word, startLine, startColumn);
1647
+ return new AbilityDSLToken(TokenTypes.ANY, word, startLine, startColumn);
1399
1648
  }
1400
1649
  if (word === 'of') {
1401
- return new AbilityDSLToken(AbilityDSLToken.OF, word, startLine, startColumn);
1650
+ return new AbilityDSLToken(TokenTypes.OF, word, startLine, startColumn);
1402
1651
  }
1403
1652
  if (word === 'if') {
1404
- return new AbilityDSLToken(AbilityDSLToken.IF, word, startLine, startColumn);
1653
+ return new AbilityDSLToken(TokenTypes.IF, word, startLine, startColumn);
1405
1654
  }
1406
1655
  // Булевы и null
1407
1656
  if (word === 'true' || word === 'false') {
1408
- return new AbilityDSLToken(AbilityDSLToken.BOOLEAN, word, startLine, startColumn);
1657
+ return new AbilityDSLToken(TokenTypes.BOOLEAN, word, startLine, startColumn);
1409
1658
  }
1410
1659
  if (word === 'null') {
1411
- return new AbilityDSLToken(AbilityDSLToken.NULL, word, startLine, startColumn);
1660
+ return new AbilityDSLToken(TokenTypes.NULL, word, startLine, startColumn);
1661
+ }
1662
+ if (word === 'except') {
1663
+ return new AbilityDSLToken(TokenTypes.EXCEPT, word, startLine, startColumn);
1664
+ }
1665
+ if (word === 'alias') {
1666
+ return new AbilityDSLToken(TokenTypes.ALIAS, word, startLine, startColumn);
1412
1667
  }
1413
1668
  // Остальные ключевые слова (contains, in, equals, greater, less, not, is, or, than, equal)
1414
- return new AbilityDSLToken(AbilityDSLToken.KEYWORD, word, startLine, startColumn);
1669
+ return new AbilityDSLToken(TokenTypes.KEYWORD, word, startLine, startColumn);
1415
1670
  }
1416
1671
  // Если после EFFECT и нет точки — действие (например, "create")
1417
1672
  const lastToken = this.tokens[this.tokens.length - 1];
1418
- if (lastToken?.code === AbilityDSLToken.EFFECT) {
1419
- return new AbilityDSLToken(AbilityDSLToken.PERMISSION, word, startLine, startColumn);
1673
+ if (lastToken?.type === TokenTypes.EFFECT) {
1674
+ return new AbilityDSLToken(TokenTypes.PERMISSION, word, startLine, startColumn);
1420
1675
  }
1421
1676
  // Обычный идентификатор
1422
- return new AbilityDSLToken(AbilityDSLToken.IDENTIFIER, word, startLine, startColumn);
1677
+ return new AbilityDSLToken(TokenTypes.IDENTIFIER, word, startLine, startColumn);
1423
1678
  }
1424
1679
  skipWhitespace() {
1425
1680
  while (!this.isAtEnd() && /\s/.test(this.peek())) {
@@ -1519,6 +1774,215 @@ class AbilityDSLSyntaxError extends Error {
1519
1774
  }
1520
1775
  }
1521
1776
 
1777
+ class AbilityDSLTokenStream {
1778
+ tokens;
1779
+ pos = 0;
1780
+ dsl;
1781
+ marks = [];
1782
+ lastToken = null;
1783
+ next() {
1784
+ const token = this.tokens[this.pos++];
1785
+ this.lastToken = token;
1786
+ return token;
1787
+ }
1788
+ prev() {
1789
+ if (this.pos === 0) {
1790
+ return null;
1791
+ }
1792
+ const token = this.tokens[this.pos--];
1793
+ this.lastToken = token;
1794
+ return token;
1795
+ }
1796
+ lookPrev() {
1797
+ return this.lastToken;
1798
+ }
1799
+ constructor(tokens, dsl) {
1800
+ this.tokens = tokens;
1801
+ this.dsl = dsl;
1802
+ }
1803
+ peek() {
1804
+ return this.tokens[this.pos];
1805
+ }
1806
+ eof() {
1807
+ return this.peek().type === TokenTypes.EOF;
1808
+ }
1809
+ check(type) {
1810
+ if (this.eof()) {
1811
+ return false;
1812
+ }
1813
+ return this.peek().type === type;
1814
+ }
1815
+ match(type) {
1816
+ if (this.check(type)) {
1817
+ return this.next();
1818
+ }
1819
+ return null;
1820
+ }
1821
+ expect(type, message) {
1822
+ const token = this.peek();
1823
+ if (token && token.type === type) {
1824
+ return this.next();
1825
+ }
1826
+ this.syntaxError(message, token, [type]);
1827
+ }
1828
+ expectOneOf(types, message) {
1829
+ const token = this.peek();
1830
+ for (const t of types) {
1831
+ if (token && token.type === t) {
1832
+ return this.next();
1833
+ }
1834
+ }
1835
+ this.syntaxError(message, token, types);
1836
+ }
1837
+ mark() {
1838
+ this.marks.push(this.pos);
1839
+ }
1840
+ reset() {
1841
+ const pos = this.marks.pop();
1842
+ if (pos !== undefined) {
1843
+ this.pos = pos;
1844
+ }
1845
+ }
1846
+ commit() {
1847
+ this.marks.pop();
1848
+ }
1849
+ syntaxError(details, token, expected) {
1850
+ const lines = this.dsl.split(/\r?\n/);
1851
+ const lineIdx = token.line - 1;
1852
+ const lineBefore = lineIdx > 0 ? lines[lineIdx - 1] : '';
1853
+ const current = lines[lineIdx];
1854
+ const linesAfter = lineIdx + 1 < lines.length ? lines[lineIdx + 1] : '';
1855
+ const wave = ' '.repeat(Math.max(0, token.column - 1)) + '~'.repeat(token.value.length);
1856
+ const lineNumWidth = String(token.line + 1).length;
1857
+ const num = (n) => String(n).padStart(lineNumWidth, ' ');
1858
+ let context = '';
1859
+ if (lineBefore.trim() !== '') {
1860
+ context += `${num(token.line - 1)} | ${lineBefore}\n`;
1861
+ }
1862
+ context += `${num(token.line)} | ${current}\n`;
1863
+ context += `${' '.repeat(lineNumWidth)} | ${wave}\n`;
1864
+ if (linesAfter.trim() !== '') {
1865
+ context += `${num(token.line + 1)} | ${linesAfter}`;
1866
+ }
1867
+ let finalDetails = details;
1868
+ if (expected && expected?.length > 0) {
1869
+ const actual = token.value;
1870
+ const suggestion = this.suggest(actual, expected);
1871
+ const detailsMsg = `${details}\nDetails: Unexpected value token \`${actual}\``;
1872
+ finalDetails = suggestion ? `${detailsMsg} Did you mean \`${suggestion}\`?` : detailsMsg;
1873
+ }
1874
+ throw new AbilityDSLSyntaxError(token.line, token.column, context + '\n', finalDetails);
1875
+ }
1876
+ suggest(actual, expectedTypes) {
1877
+ const candidates = [];
1878
+ for (const type of expectedTypes) {
1879
+ candidates.push(type);
1880
+ }
1881
+ const uniqueCandidates = [...new Set(candidates)];
1882
+ let best = null;
1883
+ let bestDist = 3;
1884
+ for (const candidate of uniqueCandidates) {
1885
+ const d = this.levenshteinDistance(actual.toLowerCase(), candidate.toLowerCase());
1886
+ if (d < bestDist) {
1887
+ bestDist = d;
1888
+ best = candidate;
1889
+ }
1890
+ }
1891
+ return best;
1892
+ }
1893
+ levenshteinDistance(a, b) {
1894
+ const matrix = Array.from({ length: b.length + 1 }, () => Array.from({ length: a.length + 1 }, () => 0));
1895
+ for (let i = 0; i <= a.length; i++)
1896
+ matrix[0][i] = i;
1897
+ for (let j = 0; j <= b.length; j++)
1898
+ matrix[j][0] = j;
1899
+ for (let j = 1; j <= b.length; j++) {
1900
+ for (let i = 1; i <= a.length; i++) {
1901
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1902
+ matrix[j][i] = Math.min(matrix[j][i - 1] + 1, matrix[j - 1][i] + 1, matrix[j - 1][i - 1] + cost);
1903
+ }
1904
+ }
1905
+ return matrix[b.length][a.length];
1906
+ }
1907
+ }
1908
+
1909
+ class AbilityDSLAnnotations {
1910
+ store = {
1911
+ id: undefined,
1912
+ name: undefined,
1913
+ priority: undefined,
1914
+ description: undefined,
1915
+ disabled: undefined,
1916
+ tags: undefined,
1917
+ };
1918
+ get(key) {
1919
+ return this.store[key] ?? null;
1920
+ }
1921
+ set(key, value, token) {
1922
+ if (value === null) {
1923
+ this.store[key] = undefined;
1924
+ }
1925
+ else {
1926
+ this.store[key] = {
1927
+ key,
1928
+ value,
1929
+ token,
1930
+ };
1931
+ }
1932
+ return this;
1933
+ }
1934
+ clear() {
1935
+ for (const key of Object.keys(this.store)) {
1936
+ this.store[key] = undefined;
1937
+ }
1938
+ }
1939
+ clone() {
1940
+ const cloned = new AbilityDSLAnnotations();
1941
+ for (const key of Object.keys(this.store)) {
1942
+ const entry = this.store[key];
1943
+ cloned.store[key] = entry
1944
+ ? { ...entry }
1945
+ : undefined;
1946
+ }
1947
+ return cloned;
1948
+ }
1949
+ // convenience getters
1950
+ get id() { return this.get('id'); }
1951
+ get name() { return this.get('name'); }
1952
+ get description() { return this.get('description'); }
1953
+ get priority() { return this.get('priority'); }
1954
+ get disabled() { return this.get('disabled'); }
1955
+ get tags() { return this.get('tags'); }
1956
+ // convenience setters
1957
+ setID(v, t) { return this.set('id', v, t); }
1958
+ setName(v, t) { return this.set('name', v, t); }
1959
+ setDescription(v, t) { return this.set('description', v, t); }
1960
+ setPriority(v, t) { return this.set('priority', v, t); }
1961
+ setDisabled(v, t) { return this.set('disabled', v, t); }
1962
+ setTags(v, t) { return this.set('tags', v, t); }
1963
+ }
1964
+
1965
+ const AnnotationAllowed = {
1966
+ policy: new Set(["id", "name", "description", "priority", "disabled", "tags"]),
1967
+ ruleSet: new Set(["id", "name", "description", "disabled"]),
1968
+ rule: new Set(["id", "name", "disabled"]),
1969
+ alias: new Set(["name", "disabled"]),
1970
+ };
1971
+
1972
+ class AbilityDSLAliases {
1973
+ store = new Map();
1974
+ get(alias) {
1975
+ return this.store.get(alias) || null;
1976
+ }
1977
+ set(alias, rule) {
1978
+ this.store.set(alias, rule);
1979
+ return this;
1980
+ }
1981
+ has(alias) {
1982
+ return this.store.has(alias);
1983
+ }
1984
+ }
1985
+
1522
1986
  /**
1523
1987
  * Parser for the Ability DSL.
1524
1988
  *
@@ -1537,12 +2001,9 @@ class AbilityDSLSyntaxError extends Error {
1537
2001
  */
1538
2002
  class AbilityDSLParser {
1539
2003
  dsl;
1540
- tokens = [];
1541
- pos = 0;
1542
- annotationBuffer = {
1543
- name: null,
1544
- description: null,
1545
- };
2004
+ stream;
2005
+ annBuffer = new AbilityDSLAnnotations();
2006
+ aliasBuffer = new AbilityDSLAliases();
1546
2007
  constructor(dsl) {
1547
2008
  this.dsl = dsl;
1548
2009
  }
@@ -1551,17 +2012,19 @@ class AbilityDSLParser {
1551
2012
  * @returns Array of AbilityPolicy instances.
1552
2013
  */
1553
2014
  parse() {
1554
- // Tokenize the entire DSL string.
1555
- this.tokens = new AbilityDSLLexer(this.dsl).tokenize();
1556
- this.pos = 0;
2015
+ this.annBuffer.clear();
2016
+ // 1. Лексер токены
2017
+ const tokens = new AbilityDSLLexer(this.dsl).tokenize();
2018
+ // 2. Создаём TokenStream
2019
+ this.stream = new AbilityDSLTokenStream(tokens, this.dsl);
1557
2020
  const policies = [];
1558
- // Keep parsing until we've consumed all tokens.
1559
- while (!this.isAtEnd()) {
2021
+ while (!this.stream.eof()) {
1560
2022
  this.consumeLeadingComments();
1561
- // Every policy must start with an EFFECT token.
2023
+ this.consumeLeadingAnnotations();
2024
+ this.consumeLeadingAliases();
1562
2025
  if (!this.isStartOfPolicy()) {
1563
- const token = this.peek();
1564
- this.syntaxError(`Expected policy, got ${token.code}.`, token, ['EFFECT']);
2026
+ const token = this.stream.peek();
2027
+ this.stream.syntaxError(`Expected policy, got ${token.type}.`, token, [TokenTypes.EFFECT]);
1565
2028
  }
1566
2029
  policies.push(this.parsePolicy());
1567
2030
  }
@@ -1578,31 +2041,37 @@ class AbilityDSLParser {
1578
2041
  */
1579
2042
  parsePolicy() {
1580
2043
  this.consumeLeadingComments();
1581
- const meta = this.takeAnnotations();
2044
+ this.consumeLeadingAnnotations();
2045
+ this.consumeLeadingAliases();
2046
+ const annotations = this.takeAnnotations('policy');
1582
2047
  // Effect: "permit" or "deny"
1583
- const effectToken = this.consume(AbilityDSLToken.EFFECT, 'Expected effect');
2048
+ const effectToken = this.stream.expect(TokenTypes.EFFECT, 'Expected effect');
1584
2049
  const effect = effectToken.value;
1585
2050
  // Permission: e.g. "order.update"
1586
- const permissionToken = this.consume(AbilityDSLToken.PERMISSION, 'Expected permission');
2051
+ const permissionToken = this.stream.expect(TokenTypes.PERMISSION, 'Expected permission');
1587
2052
  const permission = permissionToken.value;
1588
2053
  if (!permission.startsWith('permission.')) {
1589
- return this.syntaxError(`Unexpected token. The permission key, must be starts with prefix \`permission.\`, but got \`${permission}\`.\nDid you mean \`permission.${permission}\`?`, permissionToken);
2054
+ return this.stream.syntaxError(`Unexpected token. The permission key, must be starts with prefix \`permission.\`, but got \`${permission}\`.\nDid you mean \`permission.${permission}\`?`, permissionToken);
1590
2055
  }
1591
2056
  // "if" keyword
1592
- this.consume(AbilityDSLToken.IF, 'Expected "if"');
2057
+ this.stream.expect(TokenTypes.IF, 'Expected "if"');
1593
2058
  // Group selector: "all" or "any" – determines how the top‑level rule sets are combined.
1594
- const compareToken = this.consumeOneOf([AbilityDSLToken.ALL, AbilityDSLToken.ANY], 'Expected "all" or "any"');
1595
- const compareMethod = compareToken.code === AbilityDSLToken.ALL ? AbilityCompare.and : AbilityCompare.or;
2059
+ const compareToken = this.stream.expectOneOf([TokenTypes.ALL, TokenTypes.ANY], 'Expected "all" or "any"');
2060
+ const compareMethod = compareToken.type === TokenTypes.ALL ? AbilityCompare.and : AbilityCompare.or;
1596
2061
  // Colon after the group keyword
1597
- this.consume(AbilityDSLToken.COLON, 'Expected ":"');
2062
+ this.stream.expect(TokenTypes.COLON, 'Expected ":"');
1598
2063
  // Parse the list of rule sets (each "all of:" or "any of:" block)
1599
2064
  const ruleSets = this.parseRuleSets(compareMethod);
1600
2065
  // Construct the policy instance.
1601
2066
  return new AbilityPolicy({
1602
- id: `${effect}:${permission}:${Math.random()}`,
1603
- name: meta.name ?? `${effect} ${permission}`,
2067
+ id: annotations.id?.value || null,
2068
+ name: annotations.name?.value || null,
2069
+ description: annotations.description?.value || null,
2070
+ priority: annotations.priority?.value || null,
1604
2071
  permission: permission.replace(/^permission\./, ''),
1605
2072
  effect: effect === 'permit' ? AbilityPolicyEffect.permit : AbilityPolicyEffect.deny,
2073
+ disabled: annotations.disabled?.value ?? undefined,
2074
+ tags: annotations.tags?.value ?? undefined,
1606
2075
  compareMethod,
1607
2076
  }).addRuleSets(ruleSets);
1608
2077
  }
@@ -1614,32 +2083,41 @@ class AbilityDSLParser {
1614
2083
  */
1615
2084
  parseRuleSets(policyCompareMethod) {
1616
2085
  const sets = [];
1617
- while (!this.isAtEnd() && !this.isStartOfPolicy()) {
2086
+ while (!this.stream.eof() && !this.isStartOfPolicy()) {
1618
2087
  this.consumeLeadingComments();
2088
+ this.consumeLeadingAnnotations();
2089
+ // Если начинается новая except группа — парсим её
2090
+ if (this.isStartOfExcept()) {
2091
+ sets.push(this.parseExceptGroup(policyCompareMethod));
2092
+ continue;
2093
+ }
1619
2094
  // Если начинается новая группа — парсим её
1620
2095
  if (this.isStartOfGroup()) {
1621
2096
  sets.push(this.parseGroup());
1622
2097
  continue;
1623
2098
  }
1624
- // Иначе implicit group (all-of по умолчанию)
1625
- const meta = this.takeAnnotations();
2099
+ const annotation = this.takeAnnotations('ruleSet');
1626
2100
  const group = new AbilityRuleSet({
2101
+ id: annotation.id?.value || null,
1627
2102
  compareMethod: policyCompareMethod,
1628
- name: meta.name,
2103
+ name: annotation.name?.value ?? null,
2104
+ description: annotation.description?.value || null,
2105
+ disabled: annotation.disabled?.value ?? undefined,
1629
2106
  });
1630
2107
  // Читаем правила implicit-группы
1631
- while (!this.isAtEnd()) {
2108
+ while (!this.stream.eof()) {
1632
2109
  this.consumeLeadingComments();
1633
- if (this.isStartOfGroup() || this.isStartOfPolicy()) {
2110
+ this.consumeLeadingAnnotations();
2111
+ if (this.isStartOfGroup() || this.isStartOfPolicy() || this.isStartOfExcept()) {
1634
2112
  break;
1635
2113
  }
1636
- if (this.check(AbilityDSLToken.IDENTIFIER) ||
1637
- this.check(AbilityDSLToken.ALWAYS) ||
1638
- this.check(AbilityDSLToken.NEVER)) {
2114
+ if (this.stream.check(TokenTypes.IDENTIFIER) ||
2115
+ this.stream.check(TokenTypes.ALWAYS) ||
2116
+ this.stream.check(TokenTypes.NEVER)) {
1639
2117
  group.addRule(this.parseRule());
1640
2118
  }
1641
2119
  else {
1642
- this.syntaxError(`Unexpected token in implicit group: ${this.peek().code}`, this.peek());
2120
+ this.stream.syntaxError(`Unexpected token in implicit group: ${this.stream.peek().type}`, this.stream.peek());
1643
2121
  }
1644
2122
  }
1645
2123
  sets.push(group);
@@ -1651,24 +2129,83 @@ class AbilityDSLParser {
1651
2129
  */
1652
2130
  parseGroup() {
1653
2131
  this.consumeLeadingComments();
1654
- const meta = this.takeAnnotations();
1655
- const compareToken = this.consumeOneOf([AbilityDSLToken.ALL, AbilityDSLToken.ANY, AbilityDSLToken.ALWAYS, AbilityDSLToken.NEVER], 'Expected "all" or "any" or "always" or "never"');
1656
- const compareMethod = compareToken.code === AbilityDSLToken.ALL ? AbilityCompare.and : AbilityCompare.or;
1657
- if (this.check(AbilityDSLToken.OF)) {
1658
- this.advance();
1659
- }
1660
- this.consume(AbilityDSLToken.COLON, 'Expected ":"');
1661
- const group = new AbilityRuleSet({ compareMethod, name: meta.name });
1662
- while (!this.isAtEnd()) {
2132
+ this.consumeLeadingAnnotations();
2133
+ const annotations = this.takeAnnotations('ruleSet');
2134
+ const compareToken = this.stream.expectOneOf([TokenTypes.ALL, TokenTypes.ANY, TokenTypes.ALWAYS, TokenTypes.NEVER], 'Expected "all" or "any" or "always" or "never"');
2135
+ const compareMethod = compareToken.type === TokenTypes.ALL ? AbilityCompare.and : AbilityCompare.or;
2136
+ if (this.stream.check(TokenTypes.OF)) {
2137
+ this.stream.next();
2138
+ }
2139
+ this.stream.expect(TokenTypes.COLON, 'Expected ":"');
2140
+ const group = new AbilityRuleSet({
2141
+ id: annotations.id?.value || null,
2142
+ compareMethod,
2143
+ name: annotations.name?.value || null,
2144
+ description: annotations.description?.value || null,
2145
+ disabled: annotations.disabled?.value ?? undefined,
2146
+ });
2147
+ while (!this.stream.eof()) {
1663
2148
  this.consumeLeadingComments();
2149
+ this.consumeLeadingAnnotations();
2150
+ if (this.isStartOfExcept()) {
2151
+ break;
2152
+ }
1664
2153
  if (this.isStartOfGroup() || this.isStartOfPolicy()) {
1665
2154
  break;
1666
2155
  }
1667
- if (this.check(AbilityDSLToken.IDENTIFIER)) {
2156
+ if (this.stream.check(TokenTypes.IDENTIFIER)) {
1668
2157
  group.addRule(this.parseRule());
1669
2158
  }
1670
2159
  else {
1671
- this.syntaxError(`Unexpected token in group: ${this.peek().code}`, this.peek());
2160
+ this.stream.syntaxError(`Unexpected token in group: ${this.stream.peek().type}`, this.stream.peek());
2161
+ }
2162
+ }
2163
+ return group;
2164
+ }
2165
+ // -------------------------------------------------------------------------
2166
+ // #region Except RuleSet parsing
2167
+ // -------------------------------------------------------------------------
2168
+ parseExceptGroup(policyCompareMethod) {
2169
+ this.consumeLeadingComments();
2170
+ this.consumeLeadingAnnotations();
2171
+ const annotations = this.takeAnnotations('ruleSet');
2172
+ // consume "except"
2173
+ this.stream.expect(TokenTypes.EXCEPT, 'Expected "except"');
2174
+ let compareMethod = policyCompareMethod;
2175
+ // optional: "all" / "any"
2176
+ if (this.stream.check(TokenTypes.ALL) || this.stream.check(TokenTypes.ANY)) {
2177
+ const compareToken = this.stream.next();
2178
+ compareMethod = compareToken.type === TokenTypes.ALL ? AbilityCompare.and : AbilityCompare.or;
2179
+ if (this.stream.check(TokenTypes.OF)) {
2180
+ this.stream.next();
2181
+ }
2182
+ this.stream.expect(TokenTypes.COLON, 'Expected ":" after except group');
2183
+ }
2184
+ else {
2185
+ // implicit except group — no "all/any of:"
2186
+ // but still must end with colon
2187
+ this.stream.expect(TokenTypes.COLON, 'Expected ":" after "except"');
2188
+ }
2189
+ const group = new AbilityRuleSet({
2190
+ id: annotations.id?.value || null,
2191
+ compareMethod,
2192
+ name: annotations.name?.value || null,
2193
+ description: annotations.description?.value || null,
2194
+ isExcept: true,
2195
+ disabled: annotations.disabled?.value ?? undefined,
2196
+ });
2197
+ // read rules
2198
+ while (!this.stream.eof()) {
2199
+ this.consumeLeadingComments();
2200
+ this.consumeLeadingAnnotations();
2201
+ if (this.isStartOfGroup() || this.isStartOfPolicy() || this.isStartOfExcept()) {
2202
+ break;
2203
+ }
2204
+ if (this.stream.check(TokenTypes.IDENTIFIER)) {
2205
+ group.addRule(this.parseRule());
2206
+ }
2207
+ else {
2208
+ this.stream.syntaxError(`Unexpected token in except group: ${this.stream.peek().type}`, this.stream.peek());
1672
2209
  }
1673
2210
  }
1674
2211
  return group;
@@ -1681,57 +2218,54 @@ class AbilityDSLParser {
1681
2218
  */
1682
2219
  parseRule() {
1683
2220
  this.consumeLeadingComments();
1684
- const meta = this.takeAnnotations();
1685
- // if (this.check(AbilityDSLToken.ALWAYS) || this.check(AbilityDSLToken.NEVER)) {
1686
- // // Checking that there are no extra tokens after the value
1687
- // // (skip comments)
1688
- // this.consumeLeadingComments();
1689
- // const specOperator = this.consume();
1690
- // // return new AbilityRule({
1691
- // // subject: '',
1692
- // // resource,
1693
- // // condition,
1694
- // // name: meta.name,
1695
- // // });
1696
- // }
1697
- const isNeverAlways = this.check(AbilityDSLToken.ALWAYS) || this.check(AbilityDSLToken.NEVER);
1698
- if (!isNeverAlways && !this.check(AbilityDSLToken.IDENTIFIER)) {
1699
- this.syntaxError(`Expected identifier, but got ${this.peek().code}`, this.peek());
2221
+ this.consumeLeadingAnnotations();
2222
+ const annotations = this.takeAnnotations('rule');
2223
+ const isNeverAlways = this.stream.check(TokenTypes.ALWAYS) || this.stream.check(TokenTypes.NEVER);
2224
+ if (!isNeverAlways && !this.stream.check(TokenTypes.IDENTIFIER)) {
2225
+ this.stream.syntaxError(`Expected identifier, but got ${this.stream.peek().type}`, this.stream.peek());
1700
2226
  }
1701
- // Subject (e.g., "user.roles")
2227
+ // subject
1702
2228
  const subject = isNeverAlways
1703
2229
  ? ''
1704
- : this.consume(AbilityDSLToken.IDENTIFIER, 'Expected field').value;
1705
- // Operator (e.g., "contains", "equals", "is not null")
1706
- const { condition, operator } = this.parseConditionOperator();
1707
- let resource;
1708
- let beforePos = this.pos;
1709
- // Special operators that don't consume a value token.
1710
- if (operator === AbilityDSLToken.EQ_NULL ||
1711
- operator === AbilityDSLToken.NOT_EQ_NULL ||
1712
- operator === AbilityDSLToken.NULL ||
1713
- operator === AbilityDSLToken.ALWAYS ||
1714
- operator === AbilityDSLToken.NEVER) {
1715
- resource = null;
2230
+ : this.stream.expect(TokenTypes.IDENTIFIER, 'Expected field').value;
2231
+ // check alias
2232
+ if (this.aliasBuffer.has(subject)) {
2233
+ return this.aliasBuffer.get(subject);
1716
2234
  }
1717
- else {
1718
- beforePos = this.pos;
2235
+ // operator
2236
+ const { condition, operator } = this.parseConditionOperator();
2237
+ // value
2238
+ let resource = null;
2239
+ let valueToken = null;
2240
+ const operatorConsumesValue = operator !== TokenTypes.EQ_NULL &&
2241
+ operator !== TokenTypes.NOT_EQ_NULL &&
2242
+ operator !== TokenTypes.NULL &&
2243
+ operator !== TokenTypes.ALWAYS &&
2244
+ operator !== TokenTypes.NEVER;
2245
+ if (operatorConsumesValue) {
2246
+ this.stream.mark();
1719
2247
  resource = this.parseValue();
2248
+ valueToken = this.stream.lookPrev();
2249
+ this.stream.commit();
1720
2250
  }
1721
- // Checking that there are no extra tokens after the value
1722
- // (skip comments)
1723
2251
  this.consumeLeadingComments();
1724
- const resourceToken = this.tokens[beforePos];
2252
+ this.consumeLeadingAnnotations();
2253
+ this.consumeLeadingAliases();
2254
+ // validation: identifier without dot → error
1725
2255
  if (typeof resource === 'string' &&
1726
- resourceToken.code === AbilityDSLToken.IDENTIFIER &&
1727
- !resourceToken.value.includes('.')) {
1728
- this.syntaxError(`Expected comparison operator or value, got \`${resource}\``, this.tokens[beforePos], [AbilityDSLToken.KEYWORD]);
2256
+ valueToken &&
2257
+ valueToken.type === TokenTypes.IDENTIFIER &&
2258
+ !valueToken.value.includes('.')) {
2259
+ this.stream.syntaxError(`Expected comparison operator or value, got \`${resource}\``, valueToken, [TokenTypes.KEYWORD]);
1729
2260
  }
1730
2261
  return new AbilityRule({
2262
+ id: annotations.id?.value || null,
1731
2263
  subject,
1732
2264
  resource,
1733
2265
  condition,
1734
- name: meta.name,
2266
+ name: annotations.name?.value || null,
2267
+ description: annotations.description?.value || null,
2268
+ disabled: annotations.disabled?.value ?? undefined,
1735
2269
  });
1736
2270
  }
1737
2271
  // -------------------------------------------------------------------------
@@ -1742,200 +2276,241 @@ class AbilityDSLParser {
1742
2276
  * Returns both the resulting AbilityCondition and the token type that was consumed.
1743
2277
  */
1744
2278
  parseConditionOperator() {
1745
- const savedPos = this.pos;
1746
2279
  // "always"
2280
+ this.stream.mark();
1747
2281
  if (this.matchWord('always')) {
1748
- return { condition: AbilityCondition.always, operator: AbilityDSLToken.ALWAYS };
2282
+ this.stream.commit();
2283
+ return { condition: AbilityCondition.always, operator: TokenTypes.ALWAYS };
1749
2284
  }
1750
- this.pos = savedPos;
2285
+ this.stream.reset();
1751
2286
  // "never"
2287
+ this.stream.mark();
1752
2288
  if (this.matchWord('never')) {
1753
- return { condition: AbilityCondition.never, operator: AbilityDSLToken.NEVER };
2289
+ this.stream.commit();
2290
+ return { condition: AbilityCondition.never, operator: TokenTypes.NEVER };
1754
2291
  }
1755
- this.pos = savedPos;
2292
+ this.stream.reset();
1756
2293
  // "length equals"
2294
+ this.stream.mark();
1757
2295
  if (this.matchWord('length') && this.matchWord('equals')) {
1758
- return { condition: AbilityCondition.length_equals, operator: AbilityDSLToken.LEN_EQ };
2296
+ this.stream.commit();
2297
+ return { condition: AbilityCondition.length_equals, operator: TokenTypes.LEN_EQ };
1759
2298
  }
1760
- this.pos = savedPos;
2299
+ this.stream.reset();
1761
2300
  // "length ="
2301
+ this.stream.mark();
1762
2302
  if (this.matchWord('length') && this.matchSymbol('=')) {
1763
- return { condition: AbilityCondition.length_equals, operator: AbilityDSLToken.LEN_EQ };
2303
+ this.stream.commit();
2304
+ return { condition: AbilityCondition.length_equals, operator: TokenTypes.LEN_EQ };
1764
2305
  }
1765
- this.pos = savedPos;
2306
+ this.stream.reset();
1766
2307
  // "length greater than"
2308
+ this.stream.mark();
1767
2309
  if (this.matchWord('length') && this.matchWord('greater') && this.matchWord('than')) {
1768
- return { condition: AbilityCondition.length_greater_than, operator: AbilityDSLToken.LEN_GT };
2310
+ this.stream.commit();
2311
+ return { condition: AbilityCondition.length_greater_than, operator: TokenTypes.LEN_GT };
1769
2312
  }
1770
- this.pos = savedPos;
2313
+ this.stream.reset();
1771
2314
  // "length >"
2315
+ this.stream.mark();
1772
2316
  if (this.matchWord('length') && this.matchSymbol('>')) {
1773
- return { condition: AbilityCondition.length_greater_than, operator: AbilityDSLToken.LEN_GT };
2317
+ this.stream.commit();
2318
+ return { condition: AbilityCondition.length_greater_than, operator: TokenTypes.LEN_GT };
1774
2319
  }
1775
- this.pos = savedPos;
2320
+ this.stream.reset();
1776
2321
  // "length less than"
2322
+ this.stream.mark();
1777
2323
  if (this.matchWord('length') && this.matchWord('less') && this.matchWord('than')) {
1778
- return { condition: AbilityCondition.length_less_than, operator: AbilityDSLToken.LEN_LT };
2324
+ this.stream.commit();
2325
+ return { condition: AbilityCondition.length_less_than, operator: TokenTypes.LEN_LT };
1779
2326
  }
1780
- this.pos = savedPos;
2327
+ this.stream.reset();
1781
2328
  // "length <"
2329
+ this.stream.mark();
1782
2330
  if (this.matchWord('length') && this.matchSymbol('<')) {
1783
- return { condition: AbilityCondition.length_less_than, operator: AbilityDSLToken.LEN_LT };
2331
+ this.stream.commit();
2332
+ return { condition: AbilityCondition.length_less_than, operator: TokenTypes.LEN_LT };
1784
2333
  }
1785
- this.pos = savedPos;
2334
+ this.stream.reset();
1786
2335
  // "greater than or equal"
2336
+ this.stream.mark();
1787
2337
  if (this.matchWord('greater') &&
1788
2338
  this.matchWord('than') &&
1789
2339
  this.matchWord('or') &&
1790
2340
  this.matchWord('equal')) {
1791
- return { condition: AbilityCondition.greater_or_equal, operator: AbilityDSLToken.GTE };
2341
+ this.stream.commit();
2342
+ return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
1792
2343
  }
1793
- this.pos = savedPos;
2344
+ this.stream.reset();
1794
2345
  // greater than
2346
+ this.stream.mark();
1795
2347
  if (this.matchWord('greater') && this.matchWord('than')) {
1796
- return { condition: AbilityCondition.greater_than, operator: AbilityDSLToken.GT };
2348
+ this.stream.commit();
2349
+ return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
1797
2350
  }
1798
- this.pos = savedPos;
2351
+ this.stream.reset();
1799
2352
  // less than or equal
2353
+ this.stream.mark();
1800
2354
  if (this.matchWord('less') &&
1801
2355
  this.matchWord('than') &&
1802
2356
  this.matchWord('or') &&
1803
2357
  this.matchWord('equal')) {
1804
- return { condition: AbilityCondition.less_or_equal, operator: AbilityDSLToken.LTE };
2358
+ this.stream.commit();
2359
+ return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
1805
2360
  }
1806
- this.pos = savedPos;
2361
+ this.stream.reset();
1807
2362
  // less than
1808
2363
  if (this.matchWord('less') && this.matchWord('than')) {
1809
- return { condition: AbilityCondition.less_than, operator: AbilityDSLToken.LT };
2364
+ return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
1810
2365
  }
1811
- this.pos = savedPos;
2366
+ this.stream.reset();
1812
2367
  // "not contains"
2368
+ this.stream.mark();
1813
2369
  if (this.matchWord('not') && this.matchWord('contains')) {
2370
+ this.stream.commit();
1814
2371
  return {
1815
2372
  condition: AbilityCondition.not_contains,
1816
- operator: AbilityDSLToken.NOT_CONTAINS,
2373
+ operator: TokenTypes.NOT_CONTAINS,
1817
2374
  };
1818
2375
  }
1819
- this.pos = savedPos;
2376
+ this.stream.reset();
1820
2377
  // "not includes"
2378
+ this.stream.mark();
1821
2379
  if (this.matchWord('not') && this.matchWord('includes')) {
2380
+ this.stream.commit();
1822
2381
  return {
1823
2382
  condition: AbilityCondition.not_contains,
1824
- operator: AbilityDSLToken.NOT_CONTAINS,
2383
+ operator: TokenTypes.NOT_CONTAINS,
1825
2384
  };
1826
2385
  }
1827
- this.pos = savedPos;
2386
+ this.stream.reset();
1828
2387
  // "not includes"
2388
+ this.stream.mark();
1829
2389
  if (this.matchWord('not') && this.matchWord('has')) {
2390
+ this.stream.commit();
1830
2391
  return {
1831
2392
  condition: AbilityCondition.not_contains,
1832
- operator: AbilityDSLToken.NOT_CONTAINS,
2393
+ operator: TokenTypes.NOT_CONTAINS,
1833
2394
  };
1834
2395
  }
1835
- this.pos = savedPos;
2396
+ this.stream.reset();
1836
2397
  // "is equals"
2398
+ this.stream.mark();
1837
2399
  if (this.matchWord('is') && this.matchWord('equals')) {
1838
- return { condition: AbilityCondition.equals, operator: AbilityDSLToken.EQ };
2400
+ this.stream.commit();
2401
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
1839
2402
  }
1840
- this.pos = savedPos;
2403
+ this.stream.reset();
1841
2404
  // not equal
2405
+ this.stream.mark();
1842
2406
  if (this.matchWord('not') && this.matchWord('equals')) {
1843
- return { condition: AbilityCondition.not_equals, operator: AbilityDSLToken.NOT_EQ };
2407
+ this.stream.commit();
2408
+ return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
1844
2409
  }
1845
- this.pos = savedPos;
2410
+ this.stream.reset();
1846
2411
  // is not equals
2412
+ this.stream.mark();
1847
2413
  if (this.matchWord('is') && this.matchWord('not') && this.matchWord('equals')) {
1848
- return { condition: AbilityCondition.not_equals, operator: AbilityDSLToken.NOT_EQ };
2414
+ this.stream.commit();
2415
+ return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
1849
2416
  }
1850
- this.pos = savedPos;
2417
+ this.stream.reset();
1851
2418
  // is in
2419
+ this.stream.mark();
1852
2420
  if (this.matchWord('is') && this.matchWord('in')) {
1853
- return { condition: AbilityCondition.in, operator: AbilityDSLToken.IN };
2421
+ this.stream.commit();
2422
+ return { condition: AbilityCondition.in, operator: TokenTypes.IN };
1854
2423
  }
1855
- this.pos = savedPos;
2424
+ this.stream.reset();
1856
2425
  // not in
2426
+ this.stream.mark();
1857
2427
  if (this.matchWord('not') && this.matchWord('in')) {
1858
- return { condition: AbilityCondition.not_in, operator: AbilityDSLToken.NOT_IN };
2428
+ this.stream.commit();
2429
+ return { condition: AbilityCondition.not_in, operator: TokenTypes.NOT_IN };
1859
2430
  }
1860
- this.pos = savedPos;
2431
+ this.stream.reset();
1861
2432
  // is not null
2433
+ this.stream.mark();
1862
2434
  if (this.matchWord('is') && this.matchWord('not')) {
1863
- if (this.check(AbilityDSLToken.NULL)) {
1864
- this.advance();
2435
+ if (this.stream.check(TokenTypes.NULL)) {
2436
+ this.stream.next();
2437
+ this.stream.commit();
1865
2438
  return {
1866
2439
  condition: AbilityCondition.not_equals,
1867
- operator: AbilityDSLToken.NOT_EQ_NULL,
2440
+ operator: TokenTypes.NOT_EQ_NULL,
1868
2441
  };
1869
2442
  }
1870
2443
  }
1871
- this.pos = savedPos;
2444
+ this.stream.reset();
1872
2445
  // is null
2446
+ this.stream.mark();
1873
2447
  if (this.matchWord('is') && this.matchWord('null')) {
1874
- if (this.check(AbilityDSLToken.NULL)) {
1875
- this.advance();
2448
+ if (this.stream.check(TokenTypes.NULL)) {
2449
+ this.stream.commit();
2450
+ this.stream.next();
1876
2451
  return {
1877
2452
  condition: AbilityCondition.equals,
1878
- operator: AbilityDSLToken.EQ_NULL,
2453
+ operator: TokenTypes.EQ_NULL,
1879
2454
  };
1880
2455
  }
1881
2456
  }
1882
- this.pos = savedPos;
2457
+ this.stream.reset();
1883
2458
  // Single token (symbol or keyword)
1884
- const token = this.peek();
1885
- if (token.code !== AbilityDSLToken.SYMBOL &&
1886
- token.code !== AbilityDSLToken.KEYWORD &&
1887
- token.code !== AbilityDSLToken.NULL) {
1888
- this.syntaxError(`Expected comparison operator, got \`${token.value}\``, token, [
1889
- AbilityDSLToken.SYMBOL,
1890
- AbilityDSLToken.KEYWORD,
1891
- AbilityDSLToken.NULL,
2459
+ const token = this.stream.peek();
2460
+ if (token.type !== TokenTypes.SYMBOL &&
2461
+ token.type !== TokenTypes.KEYWORD &&
2462
+ token.type !== TokenTypes.NULL) {
2463
+ this.stream.syntaxError(`Expected comparison operator, got \`${token.value}\``, token, [
2464
+ TokenTypes.SYMBOL,
2465
+ TokenTypes.KEYWORD,
2466
+ TokenTypes.NULL,
1892
2467
  ]);
1893
2468
  }
1894
- this.advance();
1895
- switch (token.code) {
1896
- case AbilityDSLToken.SYMBOL:
2469
+ this.stream.next();
2470
+ switch (token.type) {
2471
+ case TokenTypes.SYMBOL:
1897
2472
  if (token.value === '=' || token.value === '==')
1898
- return { condition: AbilityCondition.equals, operator: AbilityDSLToken.EQ };
2473
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
1899
2474
  if (token.value === '!=' || token.value === '<>')
1900
- return { condition: AbilityCondition.not_equals, operator: AbilityDSLToken.NOT_EQ };
2475
+ return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
1901
2476
  if (token.value === '>')
1902
- return { condition: AbilityCondition.greater_than, operator: AbilityDSLToken.GT };
2477
+ return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
1903
2478
  if (token.value === '<')
1904
- return { condition: AbilityCondition.less_than, operator: AbilityDSLToken.LT };
2479
+ return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
1905
2480
  if (token.value === '>=')
1906
- return { condition: AbilityCondition.greater_or_equal, operator: AbilityDSLToken.GTE };
2481
+ return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
1907
2482
  if (token.value === '<=')
1908
- return { condition: AbilityCondition.less_or_equal, operator: AbilityDSLToken.LTE };
2483
+ return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
1909
2484
  break;
1910
- case AbilityDSLToken.KEYWORD:
2485
+ case TokenTypes.KEYWORD:
1911
2486
  if (token.value === 'contains' || token.value === 'includes' || token.value === 'has')
1912
- return { condition: AbilityCondition.contains, operator: AbilityDSLToken.CONTAINS };
2487
+ return { condition: AbilityCondition.contains, operator: TokenTypes.CONTAINS };
1913
2488
  if (token.value === 'in')
1914
- return { condition: AbilityCondition.in, operator: AbilityDSLToken.IN };
2489
+ return { condition: AbilityCondition.in, operator: TokenTypes.IN };
1915
2490
  if (token.value === 'equals')
1916
- return { condition: AbilityCondition.equals, operator: AbilityDSLToken.EQ };
2491
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
1917
2492
  if (token.value === 'gte') {
1918
- return { condition: AbilityCondition.greater_or_equal, operator: AbilityDSLToken.GTE };
2493
+ return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
1919
2494
  }
1920
2495
  if (token.value === 'greater' || token.value === 'gt') {
1921
2496
  // If we come here, it means "greater" without "than" – treat as '>'
1922
- return { condition: AbilityCondition.greater_than, operator: AbilityDSLToken.GT };
2497
+ return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
1923
2498
  }
1924
2499
  if (token.value === 'less' || token.value === 'lt') {
1925
- return { condition: AbilityCondition.less_than, operator: AbilityDSLToken.LT };
2500
+ return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
1926
2501
  }
1927
2502
  if (token.value === 'lte') {
1928
- return { condition: AbilityCondition.less_or_equal, operator: AbilityDSLToken.LTE };
2503
+ return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
1929
2504
  }
1930
2505
  if (token.value === 'is') {
1931
2506
  // "is" alone -> equals
1932
- return { condition: AbilityCondition.equals, operator: AbilityDSLToken.EQ };
2507
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
1933
2508
  }
1934
2509
  break;
1935
2510
  }
1936
- return this.syntaxError(`Unexpected operator token \`${token.value}\``, token, [
1937
- AbilityDSLToken.SYMBOL,
1938
- AbilityDSLToken.KEYWORD,
2511
+ return this.stream.syntaxError(`Unexpected operator token \`${token.value}\``, token, [
2512
+ TokenTypes.SYMBOL,
2513
+ TokenTypes.KEYWORD,
1939
2514
  ]);
1940
2515
  }
1941
2516
  /**
@@ -1944,26 +2519,26 @@ class AbilityDSLParser {
1944
2519
  * @returns True if the next token has that value.
1945
2520
  */
1946
2521
  matchWord(word) {
1947
- if (this.isAtEnd()) {
2522
+ if (this.stream.eof()) {
1948
2523
  return false;
1949
2524
  }
1950
- const token = this.peek();
1951
- if ((token.code === AbilityDSLToken.KEYWORD ||
1952
- token.code === AbilityDSLToken.IDENTIFIER ||
1953
- token.code === AbilityDSLToken.ALWAYS ||
1954
- token.code === AbilityDSLToken.NEVER) &&
2525
+ const token = this.stream.peek();
2526
+ if ((token.type === TokenTypes.KEYWORD ||
2527
+ token.type === TokenTypes.IDENTIFIER ||
2528
+ token.type === TokenTypes.ALWAYS ||
2529
+ token.type === TokenTypes.NEVER) &&
1955
2530
  token.value === word) {
1956
- this.advance();
2531
+ this.stream.next();
1957
2532
  return true;
1958
2533
  }
1959
2534
  return false;
1960
2535
  }
1961
2536
  matchSymbol(symbol) {
1962
- if (this.isAtEnd())
2537
+ if (this.stream.eof())
1963
2538
  return false;
1964
- const token = this.peek();
1965
- if (token.code === AbilityDSLToken.SYMBOL && token.value === symbol) {
1966
- this.advance();
2539
+ const token = this.stream.peek();
2540
+ if (token.type === TokenTypes.SYMBOL && token.value === symbol) {
2541
+ this.stream.next();
1967
2542
  return true;
1968
2543
  }
1969
2544
  return false;
@@ -1977,32 +2552,34 @@ class AbilityDSLParser {
1977
2552
  */
1978
2553
  parseValue() {
1979
2554
  // Arrays start with a left bracket
1980
- if (this.check(AbilityDSLToken.LBRACKET)) {
1981
- this.advance();
2555
+ if (this.stream.check(TokenTypes.LBRACKET)) {
2556
+ this.stream.next();
1982
2557
  return this.parseArray();
1983
2558
  }
1984
2559
  // Ensure we are not about to read a structural token as a value.
1985
- const token = this.peek();
1986
- if (token.code === AbilityDSLToken.ALL ||
1987
- token.code === AbilityDSLToken.ANY ||
1988
- token.code === AbilityDSLToken.EFFECT) {
1989
- this.syntaxError(`Unexpected ${token.code} in value position`, token);
2560
+ const token = this.stream.peek();
2561
+ if (token.type === TokenTypes.ALL ||
2562
+ token.type === TokenTypes.ANY ||
2563
+ token.type === TokenTypes.EFFECT) {
2564
+ this.stream.syntaxError(`Unexpected ${token.type} in value position`, token);
1990
2565
  }
1991
- this.advance();
2566
+ this.stream.next();
1992
2567
  // CHECK THIS SWITCH COMPARE
1993
- switch (token.code) {
1994
- case AbilityDSLToken.STRING:
2568
+ switch (token.type) {
2569
+ case TokenTypes.STRING:
1995
2570
  return token.value;
1996
- case AbilityDSLToken.NUMBER:
2571
+ case TokenTypes.NUMBER:
1997
2572
  return Number(token.value);
1998
- case AbilityDSLToken.BOOLEAN:
2573
+ case TokenTypes.BOOLEAN:
1999
2574
  return token.value === 'true';
2000
- case AbilityDSLToken.NULL:
2575
+ case TokenTypes.NULL:
2001
2576
  return null;
2002
- case AbilityDSLToken.IDENTIFIER:
2577
+ case TokenTypes.IDENTIFIER:
2003
2578
  return token.value;
2004
2579
  default: {
2005
- this.syntaxError(`Unexpected value token "${token.value}"`, token ?? this.tokens[this.tokens.length - 1], [AbilityDSLToken.KEYWORD]);
2580
+ this.stream.syntaxError(`Unexpected value token "${token.value}"`, token, [
2581
+ TokenTypes.KEYWORD,
2582
+ ]);
2006
2583
  }
2007
2584
  }
2008
2585
  }
@@ -2013,11 +2590,11 @@ class AbilityDSLParser {
2013
2590
  parseArray() {
2014
2591
  const arr = [];
2015
2592
  // Handle empty array
2016
- if (this.check(AbilityDSLToken.RBRACKET)) {
2017
- this.advance();
2593
+ if (this.stream.check(TokenTypes.RBRACKET)) {
2594
+ this.stream.next();
2018
2595
  return arr;
2019
2596
  }
2020
- while (!this.isAtEnd() && !this.check(AbilityDSLToken.RBRACKET)) {
2597
+ while (!this.stream.eof() && !this.stream.check(TokenTypes.RBRACKET)) {
2021
2598
  const value = this.parseValue();
2022
2599
  // Flatten nested arrays if they appear (though grammar doesn't currently allow nesting).
2023
2600
  if (Array.isArray(value)) {
@@ -2030,155 +2607,359 @@ class AbilityDSLParser {
2030
2607
  }
2031
2608
  else if (value === null) {
2032
2609
  // Null is allowed in arrays? Currently, we throw.
2033
- this.syntaxError('Unexpected null in array', this.peek());
2610
+ this.stream.syntaxError('Unexpected null in array', this.stream.peek());
2034
2611
  }
2035
2612
  // Optional comma between elements
2036
- if (this.check(AbilityDSLToken.COMMA)) {
2037
- this.advance();
2613
+ if (this.stream.check(TokenTypes.COMMA)) {
2614
+ this.stream.next();
2038
2615
  }
2039
2616
  }
2040
- this.consume(AbilityDSLToken.RBRACKET, 'Expected "]"');
2617
+ this.stream.expect(TokenTypes.RBRACKET, 'Expected "]"');
2041
2618
  return arr;
2042
2619
  }
2043
2620
  // -------------------------------------------------------------------------
2044
- // #region Annotations and comments
2621
+ // #region comments
2045
2622
  // -------------------------------------------------------------------------
2046
2623
  consumeLeadingComments() {
2047
- while (this.check(AbilityDSLToken.COMMENT)) {
2048
- const token = this.advance();
2049
- this.processCommentToken(token);
2050
- }
2051
- }
2052
- processCommentToken(token) {
2053
- const text = token.value.trim();
2054
- if (text.startsWith('@name ')) {
2055
- this.annotationBuffer.name = text.slice(6).trim();
2056
- }
2057
- if (text.startsWith('@description ')) {
2058
- this.annotationBuffer.description = text.slice(13).trim();
2059
- }
2060
- }
2061
- takeAnnotations() {
2062
- const meta = { ...this.annotationBuffer };
2063
- this.annotationBuffer = {
2064
- name: null,
2065
- description: null,
2066
- };
2067
- return meta;
2068
- }
2069
- // -------------------------------------------------------------------------
2070
- // #region Errors
2071
- // -------------------------------------------------------------------------
2072
- syntaxError(details, token, expected) {
2073
- const lines = this.dsl.split(/\r?\n/);
2074
- const lineIdx = token.line - 1;
2075
- const lineBefore = lineIdx > 0 ? lines[lineIdx - 1] : '';
2076
- const current = lines[lineIdx];
2077
- const linesAfter = lineIdx + 1 < lines.length ? lines[lineIdx + 1] : '';
2078
- const wave = ' '.repeat(Math.max(0, token.column - 1)) + '~'.repeat(token.value.length);
2079
- const lineNumWidth = String(token.line + 1).length;
2080
- const num = (n) => String(n).padStart(lineNumWidth, ' ');
2081
- let context = '';
2082
- if (lineBefore.trim() !== '') {
2083
- context += `${num(token.line - 1)} | ${lineBefore}\n`;
2084
- }
2085
- context += `${num(token.line)} | ${current}\n`;
2086
- context += `${' '.repeat(lineNumWidth)} | ${wave}\n`;
2087
- if (linesAfter.trim() !== '') {
2088
- context += `${num(token.line + 1)} | ${linesAfter}`;
2089
- }
2090
- let finalDetails = details;
2091
- if (expected && expected?.length > 0) {
2092
- const actual = token.value;
2093
- const suggestion = this.suggest(actual, expected);
2094
- const detailsMsg = `${details}\nDetails: Unexpected value token \`${actual}\``;
2095
- finalDetails = suggestion ? `${detailsMsg} Did you mean \`${suggestion}\`?` : detailsMsg;
2624
+ while (this.stream.check(TokenTypes.COMMENT)) {
2625
+ this.stream.next();
2626
+ // this.processCommentToken(token);
2627
+ }
2628
+ }
2629
+ // private _consumeLeadingAnnotations() {
2630
+ // while (this.stream.check(TokenTypes.ANNOTATION)) {
2631
+ // const token = this.stream.next();
2632
+ // this.processAnnotationToken(token);
2633
+ // }
2634
+ // }
2635
+ consumeLeadingAliases() {
2636
+ while (this.stream.check(TokenTypes.ALIAS)) {
2637
+ this.stream.next(); // consume "alias"
2638
+ const nameToken = this.stream.expect(TokenTypes.IDENTIFIER, `Expected alias name`);
2639
+ const aliasKey = nameToken.value;
2640
+ this.stream.expect(TokenTypes.COLON, `Expected colon after an alias`);
2641
+ const annotations = this.takeAnnotations('alias');
2642
+ while (!this.stream.eof() && !this.isStartOfAlias() && !this.isStartOfPolicy()) {
2643
+ const rule = this.parseRule();
2644
+ rule.name = annotations.get('name')?.value || aliasKey;
2645
+ rule.description = annotations.get('description')?.value;
2646
+ if (annotations.get('disabled')?.value === true) {
2647
+ rule.disabled = true;
2648
+ }
2649
+ this.aliasBuffer.set(aliasKey, rule);
2650
+ }
2096
2651
  }
2097
- throw new AbilityDSLSyntaxError(token.line, token.column, context + '\n', finalDetails);
2098
2652
  }
2099
- suggest(actual, expectedTypes) {
2100
- const candidates = [];
2101
- for (const type of expectedTypes) {
2102
- candidates.push(type);
2103
- }
2104
- const uniqueCandidates = [...new Set(candidates)];
2105
- let best = null;
2106
- let bestDist = 3;
2107
- for (const candidate of uniqueCandidates) {
2108
- const d = this.levenshteinDistance(actual.toLowerCase(), candidate.toLowerCase());
2109
- if (d < bestDist) {
2110
- bestDist = d;
2111
- best = candidate;
2653
+ consumeLeadingAnnotations() {
2654
+ while (this.stream.check(TokenTypes.ANNOTATION)) {
2655
+ const token = this.stream.next();
2656
+ const text = token.value.trim();
2657
+ if (text.startsWith('@id ')) {
2658
+ this.annBuffer.setID(text.slice(4).trim(), token);
2659
+ }
2660
+ if (text.startsWith('@name ')) {
2661
+ this.annBuffer.setName(text.slice(6).trim(), token);
2662
+ }
2663
+ if (text.startsWith('@description ')) {
2664
+ this.annBuffer.setDescription(text.slice(13).trim(), token);
2665
+ }
2666
+ if (text.startsWith('@priority ')) {
2667
+ this.annBuffer.setPriority(parseInt(text.slice(10).trim(), 10), token);
2668
+ }
2669
+ if (text.startsWith('@disabled')) {
2670
+ const value = text.slice(9).trim();
2671
+ this.annBuffer.setDisabled(value.length === 0 ? true : text.slice(9).trim() === 'true', token);
2672
+ }
2673
+ if (text.startsWith('@tags ')) {
2674
+ const value = text
2675
+ .slice(6)
2676
+ .trim()
2677
+ .split(',')
2678
+ .map(tag => tag.trim());
2679
+ this.annBuffer.setTags(value, token);
2112
2680
  }
2113
2681
  }
2114
- return best;
2115
2682
  }
2116
- levenshteinDistance(a, b) {
2117
- const matrix = Array.from({ length: b.length + 1 }, () => Array.from({ length: a.length + 1 }, () => 0));
2118
- for (let i = 0; i <= a.length; i++)
2119
- matrix[0][i] = i;
2120
- for (let j = 0; j <= b.length; j++)
2121
- matrix[j][0] = j;
2122
- for (let j = 1; j <= b.length; j++) {
2123
- for (let i = 1; i <= a.length; i++) {
2124
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
2125
- matrix[j][i] = Math.min(matrix[j][i - 1] + 1, matrix[j - 1][i] + 1, matrix[j - 1][i - 1] + cost);
2683
+ takeAnnotations(owner) {
2684
+ const ann = this.annBuffer.clone();
2685
+ this.annBuffer.clear();
2686
+ const allowed = AnnotationAllowed[owner];
2687
+ for (const key of Object.keys(ann['store'])) {
2688
+ const entry = ann.get(key);
2689
+ if (!entry)
2690
+ continue;
2691
+ if (!allowed.has(key)) {
2692
+ this.stream.syntaxError(`Annotation @${key} is not allowed on ${owner}. Allowed: ${[...allowed]
2693
+ .map(a => '@' + a)
2694
+ .join(', ')}`, entry.token ?? this.stream.peek());
2126
2695
  }
2127
2696
  }
2128
- return matrix[b.length][a.length];
2697
+ return ann;
2129
2698
  }
2130
2699
  // -------------------------------------------------------------------------
2131
- // #region Helper / lookahead methods
2700
+ // #region Helpers
2132
2701
  // -------------------------------------------------------------------------
2133
- consumeOneOf(types, message) {
2134
- const token = this.peek();
2135
- for (const t of types) {
2136
- if (token && token.code === t) {
2137
- return this.advance();
2138
- }
2139
- }
2140
- const expected = types.map(t => t).join(', ');
2141
- const actual = token ? token.value : AbilityDSLToken.EOF;
2142
- const suggestion = this.suggest(actual, types);
2143
- const details = `${message}\nDetails: Unexpected token \`${actual}\`, expected one of: ${expected}.`;
2144
- const finalMsg = suggestion ? `${details} Did you mean \`${suggestion}\`?` : details;
2145
- this.syntaxError(finalMsg, token ?? this.tokens[this.tokens.length - 1]);
2702
+ isStartOfPolicy() {
2703
+ return this.stream.check(TokenTypes.EFFECT);
2146
2704
  }
2147
- consume(type, message) {
2148
- const token = this.peek();
2149
- if (token && token.code === type) {
2150
- return this.advance();
2705
+ isStartOfGroup() {
2706
+ return this.stream.check(TokenTypes.ALL) || this.stream.check(TokenTypes.ANY);
2707
+ }
2708
+ isStartOfExcept() {
2709
+ return this.stream.check(TokenTypes.EXCEPT);
2710
+ }
2711
+ isStartOfAlias() {
2712
+ return this.stream.check(TokenTypes.ALIAS);
2713
+ }
2714
+ }
2715
+
2716
+ function ability(strings, ...expr) {
2717
+ const dsl = strings.reduce((acc, s, i) => acc + s + (expr[i] ?? ''), '');
2718
+ return new AbilityDSLParser(dsl).parse();
2719
+ }
2720
+
2721
+ class AbilityStrategy {
2722
+ policies;
2723
+ matched;
2724
+ constructor(policies) {
2725
+ this.policies = policies;
2726
+ this.matched = policies.filter(p => p.matchState === AbilityMatch.match);
2727
+ }
2728
+ matchedPolicies() {
2729
+ return this.matched;
2730
+ }
2731
+ firstMatched() {
2732
+ return this.matched[0] ?? null;
2733
+ }
2734
+ lastMatched() {
2735
+ return this.matched.length > 0 ? this.matched[this.matched.length - 1] : null;
2736
+ }
2737
+ hasPermit() {
2738
+ return this.matched.some(p => p.effect === AbilityPolicyEffect.permit);
2739
+ }
2740
+ hasDeny() {
2741
+ return this.matched.some(p => p.effect === AbilityPolicyEffect.deny);
2742
+ }
2743
+ isAllowed() {
2744
+ return this.evaluate() === AbilityPolicyEffect.permit;
2745
+ }
2746
+ isDenied() {
2747
+ return this.evaluate() === AbilityPolicyEffect.deny;
2748
+ }
2749
+ }
2750
+
2751
+ /**
2752
+ * AllMustPermitStrategy
2753
+ *
2754
+ * This strategy requires *every applicable policy* to return "permit".
2755
+ * If at least one policy returns "deny" or "not applicable", the final result is "deny".
2756
+ *
2757
+ * Use this strategy when:
2758
+ * - You want strict, conservative access control.
2759
+ * - All rules must explicitly allow the action.
2760
+ *
2761
+ * Example:
2762
+ * Policies:
2763
+ * P1 → permit
2764
+ * P2 → permit
2765
+ * P3 → deny
2766
+ * Result: deny (because not all policies permitted)
2767
+ */
2768
+ class AllMustPermitStrategy extends AbilityStrategy {
2769
+ evaluate() {
2770
+ const matched = this.matchedPolicies();
2771
+ if (matched.length === 0) {
2772
+ return AbilityPolicyEffect.deny;
2151
2773
  }
2152
- const expected = type;
2153
- const actual = token ? token.value : AbilityDSLToken.EOF;
2154
- const suggestion = this.suggest(actual, [type]);
2155
- const details = `${message}\nDetails: Unexpected token \`${actual}\`, expected "${expected}".`;
2156
- const finalMsg = suggestion ? `${details} Did you mean \`${suggestion}\`?` : details;
2157
- this.syntaxError(finalMsg, token ?? this.tokens[this.tokens.length - 1]);
2774
+ const allPermit = matched.every(p => p.effect === AbilityPolicyEffect.permit);
2775
+ return allPermit ? AbilityPolicyEffect.permit : AbilityPolicyEffect.deny;
2158
2776
  }
2159
- check(type) {
2160
- if (this.isAtEnd())
2161
- return false;
2162
- return this.peek().code === type;
2777
+ }
2778
+
2779
+ /**
2780
+ * AnyPermitStrategy
2781
+ *
2782
+ * This strategy returns "permit" as soon as *any* applicable policy permits the action.
2783
+ * If no policy permits, the result is "deny".
2784
+ *
2785
+ * Use this strategy when:
2786
+ * - You want optimistic access control.
2787
+ * - A single positive rule should be enough to grant access.
2788
+ *
2789
+ * Example:
2790
+ * Policies:
2791
+ * P1 → deny
2792
+ * P2 → permit
2793
+ * P3 → deny
2794
+ * Result: permit (because at least one policy permitted)
2795
+ */
2796
+ class AnyPermitStrategy extends AbilityStrategy {
2797
+ evaluate() {
2798
+ return this.hasPermit() ? AbilityPolicyEffect.permit : AbilityPolicyEffect.deny;
2163
2799
  }
2164
- isStartOfPolicy() {
2165
- return this.check(AbilityDSLToken.EFFECT);
2800
+ }
2801
+
2802
+ /**
2803
+ * DenyOverridesStrategy
2804
+ *
2805
+ * This strategy gives absolute priority to "deny".
2806
+ * If any applicable policy returns "deny", the final result is "deny".
2807
+ * Otherwise, if at least one policy permits, the result is "permit".
2808
+ *
2809
+ * Use this strategy when:
2810
+ * - Security is critical.
2811
+ * - A single denial must block access.
2812
+ *
2813
+ * Example:
2814
+ * Policies:
2815
+ * P1 → permit
2816
+ * P2 → deny
2817
+ * P3 → permit
2818
+ * Result: deny (because deny overrides everything)
2819
+ */
2820
+ class DenyOverridesStrategy extends AbilityStrategy {
2821
+ evaluate() {
2822
+ if (this.hasDeny()) {
2823
+ return AbilityPolicyEffect.deny;
2824
+ }
2825
+ if (this.hasPermit()) {
2826
+ return AbilityPolicyEffect.permit;
2827
+ }
2828
+ return AbilityPolicyEffect.deny;
2166
2829
  }
2167
- isStartOfGroup() {
2168
- return this.check(AbilityDSLToken.ALL) || this.check(AbilityDSLToken.ANY);
2830
+ }
2831
+
2832
+ /**
2833
+ * FirstMatchStrategy
2834
+ *
2835
+ * This strategy evaluates policies in order and returns the result of the *first applicable* policy.
2836
+ * Remaining policies are ignored.
2837
+ *
2838
+ * Use this strategy when:
2839
+ * - Policy order matters.
2840
+ * - You want predictable, sequential rule evaluation.
2841
+ *
2842
+ * Example:
2843
+ * Policies:
2844
+ * P1 → not applicable
2845
+ * P2 → permit
2846
+ * P3 → deny
2847
+ * Result: permit (P2 is the first applicable)
2848
+ */
2849
+ class FirstMatchStrategy extends AbilityStrategy {
2850
+ evaluate() {
2851
+ const first = this.firstMatched();
2852
+ return first?.effect ?? AbilityPolicyEffect.deny;
2169
2853
  }
2170
- advance() {
2171
- return this.tokens[this.pos++];
2854
+ }
2855
+
2856
+ /**
2857
+ * OnlyOneApplicableStrategy
2858
+ *
2859
+ * This strategy requires that *exactly one* policy is applicable.
2860
+ * If zero or more than one policy applies, the result is "deny".
2861
+ *
2862
+ * Use this strategy when:
2863
+ * - Policies must be mutually exclusive.
2864
+ * - You want to detect ambiguous or conflicting rules.
2865
+ *
2866
+ * Example:
2867
+ * Policies:
2868
+ * P1 → applicable
2869
+ * P2 → applicable
2870
+ * Result: deny (more than one applicable policy)
2871
+ */
2872
+ class OnlyOneApplicableStrategy extends AbilityStrategy {
2873
+ evaluate() {
2874
+ const matched = this.matchedPolicies();
2875
+ if (matched.length === 1) {
2876
+ return matched[0].effect;
2877
+ }
2878
+ return AbilityPolicyEffect.deny;
2172
2879
  }
2173
- peek() {
2174
- return this.tokens[this.pos];
2880
+ }
2881
+
2882
+ /**
2883
+ * PermitOverridesStrategy
2884
+ *
2885
+ * This strategy gives priority to "permit".
2886
+ * If any applicable policy permits, the final result is "permit".
2887
+ * Deny is returned only if no policy permits.
2888
+ *
2889
+ * Use this strategy when:
2890
+ * - You want permissive behavior.
2891
+ * - A single positive rule should override denials.
2892
+ *
2893
+ * Example:
2894
+ * Policies:
2895
+ * P1 → deny
2896
+ * P2 → permit
2897
+ * P3 → deny
2898
+ * Result: permit (permit overrides deny)
2899
+ */
2900
+ class PermitOverridesStrategy extends AbilityStrategy {
2901
+ evaluate() {
2902
+ if (this.hasPermit()) {
2903
+ return AbilityPolicyEffect.permit;
2904
+ }
2905
+ if (this.hasDeny()) {
2906
+ return AbilityPolicyEffect.deny;
2907
+ }
2908
+ return AbilityPolicyEffect.deny;
2175
2909
  }
2176
- isAtEnd() {
2177
- return this.peek().code === AbilityDSLToken.EOF;
2910
+ }
2911
+
2912
+ /**
2913
+ * SequentialLastMatchStrategy
2914
+ *
2915
+ * This strategy evaluates all applicable policies in order and returns the result of the *last* applicable one.
2916
+ *
2917
+ * Use this strategy when:
2918
+ * - Later policies should override earlier ones.
2919
+ * - You want a "last rule wins" behavior.
2920
+ *
2921
+ * Example:
2922
+ * Policies:
2923
+ * P1 → permit
2924
+ * P2 → deny
2925
+ * P3 → permit
2926
+ * Result: permit (P3 is the last applicable)
2927
+ */
2928
+ class SequentialLastMatchStrategy extends AbilityStrategy {
2929
+ evaluate() {
2930
+ const last = this.lastMatched();
2931
+ return last?.effect ?? AbilityPolicyEffect.deny;
2932
+ }
2933
+ }
2934
+
2935
+ /**
2936
+ * PriorityStrategy
2937
+ *
2938
+ * This strategy evaluates policies based on their numeric priority.
2939
+ * The policy with the highest priority (lowest number or highest number depending on implementation)
2940
+ * determines the final result.
2941
+ *
2942
+ * Use this strategy when:
2943
+ * - Policies have explicit priority levels.
2944
+ * - You want deterministic resolution based on ranking.
2945
+ *
2946
+ * Example:
2947
+ * Policies:
2948
+ * P1 (priority 10) → deny
2949
+ * P2 (priority 1) → permit
2950
+ * Result: permit (P2 has higher priority)
2951
+ */
2952
+ class PriorityStrategy extends AbilityStrategy {
2953
+ evaluate() {
2954
+ const matched = this.matchedPolicies();
2955
+ if (matched.length === 0) {
2956
+ return AbilityPolicyEffect.deny;
2957
+ }
2958
+ const sorted = [...matched].sort((a, b) => b.priority - a.priority);
2959
+ return sorted[0].effect;
2178
2960
  }
2179
2961
  }
2180
2962
 
2181
- exports.AbilityCode = AbilityCode;
2182
2963
  exports.AbilityCompare = AbilityCompare;
2183
2964
  exports.AbilityCondition = AbilityCondition;
2184
2965
  exports.AbilityDSLLexer = AbilityDSLLexer;
@@ -2196,7 +2977,21 @@ exports.AbilityPolicy = AbilityPolicy;
2196
2977
  exports.AbilityPolicyEffect = AbilityPolicyEffect;
2197
2978
  exports.AbilityResolver = AbilityResolver;
2198
2979
  exports.AbilityResult = AbilityResult;
2199
- exports.AbilityResultState = AbilityResultState;
2200
2980
  exports.AbilityRule = AbilityRule;
2201
2981
  exports.AbilityRuleSet = AbilityRuleSet;
2982
+ exports.AbilityStrategy = AbilityStrategy;
2202
2983
  exports.AbilityTypeGenerator = AbilityTypeGenerator;
2984
+ exports.AllMustPermitStrategy = AllMustPermitStrategy;
2985
+ exports.AnyPermitStrategy = AnyPermitStrategy;
2986
+ exports.DenyOverridesStrategy = DenyOverridesStrategy;
2987
+ exports.FirstMatchStrategy = FirstMatchStrategy;
2988
+ exports.OnlyOneApplicableStrategy = OnlyOneApplicableStrategy;
2989
+ exports.PermitOverridesStrategy = PermitOverridesStrategy;
2990
+ exports.PriorityStrategy = PriorityStrategy;
2991
+ exports.SequentialLastMatchStrategy = SequentialLastMatchStrategy;
2992
+ exports.TokenTypes = TokenTypes;
2993
+ exports.ability = ability;
2994
+ exports.fromLiteral = fromLiteral;
2995
+ exports.isConditionEqual = isConditionEqual;
2996
+ exports.isConditionNotEqual = isConditionNotEqual;
2997
+ exports.toLiteral = toLiteral;