@via-profit/ability 3.5.4 → 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,43 +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
  });
183
218
  const filteredStructure = {};
184
- Object.entries(typeStructure).forEach(([action, fields]) => {
219
+ Object.entries(resorceStructure).forEach(([action, fields]) => {
185
220
  if (!action.endsWith('.*')) {
186
221
  filteredStructure[action] = fields;
187
222
  }
188
223
  });
189
224
  // Transform flat structure into nested structure for easier use
190
225
  const nestedStructure = this.buildNestedStructure(filteredStructure);
191
- return this.formatTypeDefinitions(nestedStructure);
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('.');
192
237
  }
193
238
  /**
194
239
  * Determines TypeScript type based on the rule
@@ -196,25 +241,32 @@ class AbilityTypeGenerator {
196
241
  * @returns TypeScript type as string
197
242
  */
198
243
  determineTypeFromRule(rule) {
199
- if (rule.condition.isEqual(AbilityCondition.never) ||
200
- rule.condition.isEqual(AbilityCondition.always)) {
244
+ if (rule.condition === AbilityCondition.never || rule.condition === AbilityCondition.always) {
201
245
  return null;
202
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
+ }
203
256
  // Numeric comparisons - always number
204
- if (rule.condition.isEqual(AbilityCondition.greater_than) ||
205
- rule.condition.isEqual(AbilityCondition.less_than) ||
206
- rule.condition.isEqual(AbilityCondition.greater_or_equal) ||
207
- 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) {
208
261
  return 'number';
209
262
  }
210
263
  // Array operations
211
- if (rule.condition.isEqual(AbilityCondition.in) ||
212
- rule.condition.isEqual(AbilityCondition.not_in)) {
213
- return this.getArrayType(rule.resource);
264
+ if (rule.condition === AbilityCondition.in || rule.condition === AbilityCondition.not_in) {
265
+ return this.getInArrayType(rule.resource);
214
266
  }
215
267
  // Equality/Inequality operations
216
- if (rule.condition.isEqual(AbilityCondition.equals) ||
217
- rule.condition.isEqual(AbilityCondition.not_equals)) {
268
+ if (rule.condition === AbilityCondition.equals ||
269
+ rule.condition === AbilityCondition.not_equals) {
218
270
  return this.getPrimitiveType(rule.resource);
219
271
  }
220
272
  return 'any';
@@ -225,20 +277,38 @@ class AbilityTypeGenerator {
225
277
  * @returns TypeScript array type as string
226
278
  */
227
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) {
228
299
  if (Array.isArray(resource)) {
229
300
  if (resource.length === 0) {
230
- return 'any[]';
301
+ return 'unknown';
231
302
  }
232
303
  // Determine types of array elements
233
304
  const elementTypes = new Set(resource.map(item => this.getPrimitiveType(item)));
234
- const elementType = elementTypes.size === 1
305
+ return elementTypes.size === 1
235
306
  ? Array.from(elementTypes)[0]
236
307
  : `(${Array.from(elementTypes).join(' | ')})`;
237
- return `readonly ${elementType}[]`;
238
308
  }
239
309
  // If resource is not an array but condition is in/not_in,
240
310
  // it expects an array of such elements
241
- return `readonly ${this.getPrimitiveType(resource)}[]`;
311
+ return this.getPrimitiveType(resource);
242
312
  }
243
313
  /**
244
314
  * Gets primitive TypeScript type for a value
@@ -247,11 +317,15 @@ class AbilityTypeGenerator {
247
317
  */
248
318
  getPrimitiveType(value) {
249
319
  if (value === null) {
250
- return 'null';
320
+ return 'null | unknown';
251
321
  }
252
322
  if (value === undefined) {
253
323
  return 'undefined';
254
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
+ }
255
329
  switch (typeof value) {
256
330
  case 'string':
257
331
  return 'string';
@@ -304,9 +378,11 @@ class AbilityTypeGenerator {
304
378
  /**
305
379
  * Formats type structure into a string
306
380
  * @param structure - Nested type structure
381
+ * @param environment
382
+ * @param allTags
307
383
  * @returns Formatted TypeScript type definition string
308
384
  */
309
- formatTypeDefinitions(structure) {
385
+ formatTypeDefinitions(structure, environment, allTags) {
310
386
  let output = '// Automatically generated by via-profit/ability\n';
311
387
  output += '// Do not edit manually\n';
312
388
  output += 'export type Resources = {\n';
@@ -326,6 +402,29 @@ class AbilityTypeGenerator {
326
402
  }
327
403
  });
328
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
329
428
  return output;
330
429
  }
331
430
  /**
@@ -369,12 +468,12 @@ class AbilityExplain {
369
468
  }
370
469
  toString(indent = 0) {
371
470
  const pad = ' '.repeat(indent);
372
- const mark = this.match.code === AbilityMatch.match.code ? '✓' : '✗';
471
+ const mark = this.match === AbilityMatch.match ? '✓' : '✗';
373
472
  let out = '';
374
473
  if (this.type === 'policy') {
375
474
  out += '\n';
376
475
  }
377
- out += `${pad}${mark} ${this.type} «${this.name}» is ${this.match.code}`;
476
+ out += `${pad}${mark} ${this.type} «${this.name}» is ${this.match}`;
378
477
  this.children.forEach(child => {
379
478
  out += '\n' + child.toString(indent + 1);
380
479
  });
@@ -405,7 +504,7 @@ class AbilityExplainPolicy extends AbilityExplain {
405
504
  const children = policy.ruleSet.map(ruleSet => new AbilityExplainRuleSet(ruleSet));
406
505
  super({
407
506
  type: 'policy',
408
- name: policy.name,
507
+ name: policy.priority > -1 ? `@priority ${policy.priority} ${policy.name}` : policy.name,
409
508
  match: policy.matchState,
410
509
  }, children);
411
510
  }
@@ -432,6 +531,7 @@ class AbilityPolicy {
432
531
  * Policy name
433
532
  */
434
533
  name;
534
+ description;
435
535
  /**
436
536
  * Policy ID
437
537
  */
@@ -441,13 +541,20 @@ class AbilityPolicy {
441
541
  * will select only those from all passed policies that fall under the specified permission key.
442
542
  */
443
543
  permission;
544
+ priority = -1;
545
+ disabled;
546
+ tags;
444
547
  constructor(params) {
445
- const { name, id, permission, effect, compareMethod = AbilityCompare.and } = params;
446
- this.name = name;
447
- 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;
448
551
  this.permission = permission;
552
+ this.description = description;
449
553
  this.effect = effect;
450
554
  this.compareMethod = compareMethod;
555
+ this.priority = typeof priority === 'number' ? priority : -1;
556
+ this.disabled = typeof disabled === 'boolean' ? disabled : false;
557
+ this.tags = (tags || []);
451
558
  }
452
559
  /**
453
560
  * Add rule set to the policy
@@ -467,6 +574,18 @@ class AbilityPolicy {
467
574
  }
468
575
  return this;
469
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
+ }
470
589
  /**
471
590
  * Check if the policy is matched
472
591
  * @param resource - The resource to check
@@ -474,31 +593,57 @@ class AbilityPolicy {
474
593
  */
475
594
  check(resource, environment) {
476
595
  this.matchState = AbilityMatch.mismatch;
596
+ if (this.disabled) {
597
+ this.matchState = AbilityMatch.disabled;
598
+ return this.matchState;
599
+ }
477
600
  if (!this.ruleSet.length) {
478
601
  return this.matchState;
479
602
  }
480
- const rulesetCheckStates = [];
481
- for (const ruleSet of this.ruleSet) {
482
- const state = ruleSet.check(resource, environment);
483
- rulesetCheckStates.push(state);
484
- if (AbilityCompare.and.isEqual(this.compareMethod) && AbilityMatch.mismatch.isEqual(state)) {
485
- 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;
486
609
  }
487
- if (AbilityCompare.or.isEqual(this.compareMethod) && AbilityMatch.match.isEqual(state)) {
488
- 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;
489
614
  return this.matchState;
490
615
  }
491
- }
492
- if (AbilityCompare.and.isEqual(this.compareMethod)) {
493
- if (rulesetCheckStates.every(s => AbilityMatch.match.isEqual(s))) {
616
+ if (AbilityCompare.or === this.compareMethod && AbilityMatch.match === state) {
494
617
  this.matchState = AbilityMatch.match;
618
+ // break to check except-rule sets
619
+ break;
495
620
  }
496
621
  }
497
- if (AbilityCompare.or.isEqual(this.compareMethod)) {
498
- if (rulesetCheckStates.some(s => AbilityMatch.match.isEqual(s))) {
499
- 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;
500
643
  }
501
644
  }
645
+ // 5. match
646
+ this.matchState = AbilityMatch.match;
502
647
  return this.matchState;
503
648
  }
504
649
  explain() {
@@ -511,6 +656,8 @@ class AbilityPolicy {
511
656
  const policy = new AbilityPolicy({
512
657
  id: props.id ?? this.id,
513
658
  name: props.name ?? this.name,
659
+ description: props.description ?? this.description,
660
+ priority: typeof props.priority !== 'undefined' ? props.priority : this.priority,
514
661
  permission: props.permission ?? this.permission,
515
662
  effect: props.effect ?? this.effect,
516
663
  compareMethod: props.compareMethod ?? this.compareMethod,
@@ -523,24 +670,20 @@ class AbilityPolicy {
523
670
  }
524
671
  }
525
672
 
526
- class AbilityPolicyEffect extends AbilityCode {
527
- static deny = new AbilityPolicyEffect('deny');
528
- static permit = new AbilityPolicyEffect('permit');
529
- }
530
-
531
- class AbilityResultState extends AbilityCode {
532
- static allow = new AbilityResultState('allow');
533
- static deny = new AbilityResultState('deny');
534
- static neutral = new AbilityResultState('neutral');
673
+ function brand$1(code) {
674
+ return code;
535
675
  }
676
+ const AbilityPolicyEffect = {
677
+ deny: brand$1('deny'),
678
+ permit: brand$1('permit'),
679
+ };
536
680
 
537
681
  class AbilityResult {
538
- /**
539
- * Already checked policies (after call the policy.check())
540
- */
541
- policies;
542
- constructor(policies) {
543
- this.policies = policies;
682
+ effect;
683
+ strategy;
684
+ constructor(effect, strategy) {
685
+ this.effect = effect;
686
+ this.strategy = strategy;
544
687
  }
545
688
  /**
546
689
  * Returns a list of explanations for each policy involved in the ability evaluation.
@@ -549,53 +692,37 @@ class AbilityResult {
549
692
  * Useful for debugging, logging, or building UI tools that visualize permission logic.
550
693
  */
551
694
  explain() {
552
- return this.policies.map(policy => {
695
+ return this.strategy.policies.map(policy => {
553
696
  return new AbilityExplainPolicy(policy);
554
697
  });
555
698
  }
556
- getLastMatchedPolicy() {
557
- for (let i = this.policies.length - 1; i >= 0; i--) {
558
- if (this.policies[i].matchState.isEqual(AbilityMatch.match)) {
559
- return this.policies[i];
560
- }
561
- }
562
- return null;
563
- }
564
- getFinalState() {
565
- let state = AbilityResultState.neutral;
566
- for (const p of this.policies) {
567
- if (p.matchState.isEqual(AbilityMatch.match)) {
568
- if (p.effect.isEqual(AbilityPolicyEffect.permit)) {
569
- state = AbilityResultState.allow;
570
- }
571
- else if (p.effect.isEqual(AbilityPolicyEffect.deny)) {
572
- state = AbilityResultState.deny;
573
- }
574
- }
575
- else if (p.matchState.isEqual(AbilityMatch.mismatch)) {
576
- state = AbilityResultState.neutral;
577
- }
578
- }
579
- return state;
580
- }
581
699
  isAllowed() {
582
- return this.getFinalState().isEqual(AbilityResultState.allow);
700
+ return this.strategy.isAllowed();
583
701
  }
584
702
  isDenied() {
585
- return this.getFinalState().isNotEqual(AbilityResultState.allow);
703
+ return this.strategy.isDenied();
586
704
  }
587
705
  }
588
706
 
589
707
  class AbilityResolver {
590
- policies;
708
+ StrategyClass;
709
+ policyEntries;
591
710
  constructor(
592
711
  /**
593
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.
594
713
  */
595
- policyOrListOfPolicies) {
596
- this.policies = Array.isArray(policyOrListOfPolicies)
597
- ? policyOrListOfPolicies
598
- : [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;
599
726
  }
600
727
  /**
601
728
  * Resolve policy for the resource and permission key
@@ -605,24 +732,30 @@ class AbilityResolver {
605
732
  * @param environment
606
733
  */
607
734
  resolve(permission, resource, environment) {
608
- 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
609
741
  for (const policy of filteredPolicies) {
742
+ if (policy.disabled) {
743
+ continue;
744
+ }
610
745
  const policyMatchState = policy.check(resource, environment);
611
746
  if (policyMatchState === AbilityMatch.pending) {
612
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.`);
613
748
  }
614
749
  }
615
- 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);
616
754
  }
617
755
  enforce(permission, resource, environment) {
618
756
  const result = this.resolve(permission, resource, environment);
619
757
  if (result.isDenied()) {
620
- const lastPolicy = result.getLastMatchedPolicy();
621
- if (lastPolicy) {
622
- throw new AbilityError(`Permission denied by policy "${lastPolicy.name.toString()}"`);
623
- }
624
- // No policy matched → implicit deny
625
- throw new AbilityError(`Permission denied: no matching policy found (implicit deny)`);
758
+ throw new AbilityError(`Permission denied`);
626
759
  }
627
760
  }
628
761
  /**
@@ -638,6 +771,32 @@ class AbilityResolver {
638
771
  return chunk === '*' || longer[i] === '*' || chunk === longer[i];
639
772
  });
640
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
+ }
641
800
  }
642
801
 
643
802
  /**
@@ -654,8 +813,10 @@ class AbilityRule {
654
813
  resource;
655
814
  condition;
656
815
  name;
816
+ description;
657
817
  id;
658
818
  state = AbilityMatch.pending;
819
+ disabled;
659
820
  /**
660
821
  * Creates an instance of AbilityRule.
661
822
  * @param {string} params.id - The unique identifier of the rule.
@@ -663,33 +824,37 @@ class AbilityRule {
663
824
  * @param {AbilityCondition} params.condition - The condition to evaluate.
664
825
  * @param {string} params.subject - The subject of the rule.
665
826
  * @param {string} params.resource - The resource to compare against.
827
+ * @param {boolean} params.disabled - Disabling flag.
666
828
  * @param params
667
829
  */
668
830
  constructor(params) {
669
- const { id, name, subject, resource, condition } = params;
670
- 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)}`;
671
833
  this.id = id || this.name;
834
+ this.description = description;
835
+ this.disabled = typeof disabled === 'boolean' ? disabled : false;
672
836
  this.subject = subject;
673
837
  this.resource = resource;
674
838
  this.condition = condition;
839
+ this.state = this.disabled ? AbilityMatch.disabled : this.state;
675
840
  }
676
- isPrimitive(v) {
841
+ static isPrimitive(v) {
677
842
  return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null;
678
843
  }
679
- isNumber(v) {
844
+ static isNumber(v) {
680
845
  return typeof v === 'number';
681
846
  }
682
- isString(v) {
847
+ static isString(v) {
683
848
  return typeof v === 'string';
684
849
  }
685
- valueLen = (v) => this.isString(v) || Array.isArray(v) ? v.length : null;
686
- operatorHandlers = {
687
- [AbilityCondition.always.literal]: () => true,
688
- [AbilityCondition.never.literal]: () => false,
689
- [AbilityCondition.equals.literal]: (a, b) => a === b,
690
- [AbilityCondition.not_equals.literal]: (a, b) => a !== b,
691
- [AbilityCondition.contains.literal]: (a, b) => {
692
- 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)) {
693
858
  return a.includes(b);
694
859
  }
695
860
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -697,8 +862,8 @@ class AbilityRule {
697
862
  }
698
863
  return false;
699
864
  },
700
- [AbilityCondition.not_contains.literal]: (a, b) => {
701
- if (Array.isArray(a) && this.isPrimitive(b)) {
865
+ [toLiteral(AbilityCondition.not_contains)]: (a, b) => {
866
+ if (Array.isArray(a) && AbilityRule.isPrimitive(b)) {
702
867
  return !a.includes(b);
703
868
  }
704
869
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -706,8 +871,8 @@ class AbilityRule {
706
871
  }
707
872
  return false;
708
873
  },
709
- [AbilityCondition.in.literal]: (a, b) => {
710
- if (this.isPrimitive(a) && Array.isArray(b)) {
874
+ [toLiteral(AbilityCondition.in)]: (a, b) => {
875
+ if (AbilityRule.isPrimitive(a) && Array.isArray(b)) {
711
876
  return b.includes(a);
712
877
  }
713
878
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -715,8 +880,8 @@ class AbilityRule {
715
880
  }
716
881
  return false;
717
882
  },
718
- [AbilityCondition.not_in.literal]: (a, b) => {
719
- if (this.isPrimitive(a) && Array.isArray(b)) {
883
+ [toLiteral(AbilityCondition.not_in)]: (a, b) => {
884
+ if (AbilityRule.isPrimitive(a) && Array.isArray(b)) {
720
885
  return !b.includes(a);
721
886
  }
722
887
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -724,55 +889,55 @@ class AbilityRule {
724
889
  }
725
890
  return false;
726
891
  },
727
- [AbilityCondition.greater_than.literal]: (a, b) => {
728
- 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;
729
894
  },
730
- [AbilityCondition.less_than.literal]: (a, b) => {
731
- 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;
732
897
  },
733
- [AbilityCondition.greater_or_equal.literal]: (a, b) => {
734
- 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;
735
900
  },
736
- [AbilityCondition.less_or_equal.literal]: (a, b) => {
737
- 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;
738
903
  },
739
- [AbilityCondition.length_greater_than.literal]: (a, b) => {
740
- const alen = this.valueLen(a);
904
+ [toLiteral(AbilityCondition.length_greater_than)]: (a, b) => {
905
+ const alen = AbilityRule.valueLen(a);
741
906
  if (alen === null) {
742
907
  return false;
743
908
  }
744
- if (this.isNumber(b)) {
909
+ if (AbilityRule.isNumber(b)) {
745
910
  return alen > b;
746
911
  }
747
- const bLen = this.valueLen(b);
912
+ const bLen = AbilityRule.valueLen(b);
748
913
  if (bLen !== null) {
749
914
  return alen > bLen;
750
915
  }
751
916
  return false;
752
917
  },
753
- [AbilityCondition.length_less_than.literal]: (a, b) => {
754
- const alen = this.valueLen(a);
918
+ [toLiteral(AbilityCondition.length_less_than)]: (a, b) => {
919
+ const alen = AbilityRule.valueLen(a);
755
920
  if (alen === null) {
756
921
  return false;
757
922
  }
758
- if (this.isNumber(b)) {
923
+ if (AbilityRule.isNumber(b)) {
759
924
  return alen < b;
760
925
  }
761
- const bLen = this.valueLen(b);
926
+ const bLen = AbilityRule.valueLen(b);
762
927
  if (bLen !== null) {
763
928
  return alen < bLen;
764
929
  }
765
930
  return false;
766
931
  },
767
- [AbilityCondition.length_equals.literal]: (a, b) => {
768
- const alen = this.valueLen(a);
932
+ [toLiteral(AbilityCondition.length_equals)]: (a, b) => {
933
+ const alen = AbilityRule.valueLen(a);
769
934
  if (alen === null) {
770
935
  return false;
771
936
  }
772
- if (this.isNumber(b)) {
937
+ if (AbilityRule.isNumber(b)) {
773
938
  return alen === b;
774
939
  }
775
- const bLen = this.valueLen(b);
940
+ const bLen = AbilityRule.valueLen(b);
776
941
  if (bLen !== null) {
777
942
  return alen === bLen;
778
943
  }
@@ -785,8 +950,12 @@ class AbilityRule {
785
950
  * @param environment
786
951
  */
787
952
  check(resource, environment) {
953
+ if (this.disabled) {
954
+ this.state = AbilityMatch.disabled;
955
+ return this.state;
956
+ }
788
957
  const [subjectValue, resourceValue] = this.extractValues(resource, environment);
789
- const handler = this.operatorHandlers[this.condition.literal];
958
+ const handler = AbilityRule.operatorHandlers[toLiteral(this.condition)];
790
959
  const result = handler(subjectValue, resourceValue);
791
960
  this.state = result ? AbilityMatch.match : AbilityMatch.mismatch;
792
961
  return this.state;
@@ -833,41 +1002,62 @@ class AbilityRule {
833
1002
  }
834
1003
  return [subjectValue, resourceValue];
835
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
+ }
836
1028
  /**
837
1029
  * Get the value of the object by dot notation
838
1030
  * @param resource - The object to get the value from
839
1031
  * @param desc - The dot notation string
840
1032
  */
841
1033
  getDotNotationValue(resource, desc) {
842
- const arr = desc.split('.');
843
- while (arr.length && resource) {
844
- const comp = arr.shift() || '';
845
- const match = new RegExp('(.+)\\[([0-9]*)]').exec(comp);
846
- if (match !== null && match.length == 3) {
847
- const arrayData = {
848
- arrName: match[1],
849
- arrIndex: match[2],
850
- };
851
- if (resource[arrayData.arrName] !== undefined) {
852
- resource = resource[arrayData.arrName][arrayData.arrIndex];
853
- }
854
- else {
855
- resource = undefined;
856
- }
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];
857
1045
  }
858
1046
  else {
859
- resource = resource[comp];
1047
+ const arr = current[seg.prop];
1048
+ current = Array.isArray(arr) ? arr[seg.index] : undefined;
860
1049
  }
861
1050
  }
862
- return resource;
1051
+ return current;
863
1052
  }
864
1053
  toString() {
865
- 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()}"`;
866
1055
  }
867
1056
  copyWith(props) {
868
1057
  return new AbilityRule({
869
1058
  id: props.id ?? this.id,
870
1059
  name: props.name ?? this.name,
1060
+ description: props.description ?? this.description,
871
1061
  subject: props.subject ?? this.subject,
872
1062
  resource: props.resource ?? this.resource,
873
1063
  condition: props.condition ?? this.condition,
@@ -969,15 +1159,22 @@ class AbilityRuleSet {
969
1159
  * Group name
970
1160
  */
971
1161
  name;
1162
+ description;
972
1163
  /**
973
1164
  * Group ID
974
1165
  */
975
1166
  id;
1167
+ isExcept = false;
1168
+ disabled;
976
1169
  constructor(params) {
977
- const { name, id, compareMethod } = params;
978
- this.name = name || '';
1170
+ const { name, id, compareMethod, isExcept, disabled, description } = params;
1171
+ this.name = name || `ruleset:${compareMethod}`;
979
1172
  this.id = id || this.name;
1173
+ this.description = description;
980
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;
981
1178
  }
982
1179
  addRule(rule) {
983
1180
  this.rules.push(rule);
@@ -989,40 +1186,48 @@ class AbilityRuleSet {
989
1186
  }
990
1187
  check(resources, environment) {
991
1188
  this.state = AbilityMatch.mismatch;
1189
+ if (this.disabled) {
1190
+ this.state = AbilityMatch.disabled;
1191
+ return this.state;
1192
+ }
992
1193
  if (!this.rules.length) {
993
1194
  return this.state;
994
1195
  }
995
1196
  const ruleCheckStates = [];
996
1197
  for (const rule of this.rules) {
1198
+ if (rule.disabled) {
1199
+ continue;
1200
+ }
997
1201
  const state = rule.check(resources, environment);
998
1202
  ruleCheckStates.push(state);
999
- if (AbilityCompare.and.isEqual(this.compareMethod) && AbilityMatch.mismatch.isEqual(state)) {
1203
+ if (AbilityCompare.and === this.compareMethod && AbilityMatch.mismatch === state) {
1000
1204
  return this.state; // mismatch
1001
1205
  }
1002
- if (AbilityCompare.or.isEqual(this.compareMethod) && AbilityMatch.match.isEqual(state)) {
1206
+ if (AbilityCompare.or === this.compareMethod && AbilityMatch.match === state) {
1003
1207
  this.state = AbilityMatch.match;
1004
1208
  return this.state;
1005
1209
  }
1006
1210
  }
1007
- if (AbilityCompare.and.isEqual(this.compareMethod)) {
1008
- if (ruleCheckStates.every(s => AbilityMatch.match.isEqual(s))) {
1211
+ if (AbilityCompare.and === this.compareMethod) {
1212
+ if (ruleCheckStates.every(s => AbilityMatch.match === s)) {
1009
1213
  this.state = AbilityMatch.match;
1010
1214
  }
1011
1215
  }
1012
- if (AbilityCompare.or.isEqual(this.compareMethod)) {
1013
- if (ruleCheckStates.some(s => AbilityMatch.match.isEqual(s))) {
1216
+ if (AbilityCompare.or === this.compareMethod) {
1217
+ if (ruleCheckStates.some(s => AbilityMatch.match === s)) {
1014
1218
  this.state = AbilityMatch.match;
1015
1219
  }
1016
1220
  }
1017
1221
  return this.state;
1018
1222
  }
1019
1223
  toString() {
1020
- 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')}`;
1021
1225
  }
1022
1226
  copyWith(props) {
1023
1227
  const next = new AbilityRuleSet({
1024
1228
  id: props.id ?? this.id,
1025
1229
  name: props.name ?? this.name,
1230
+ description: props.description ?? this.description,
1026
1231
  compareMethod: props.compareMethod ?? this.compareMethod,
1027
1232
  });
1028
1233
  const nextRules = props.rules ?? this.rules;
@@ -1053,37 +1258,42 @@ class AbilityJSONParser {
1053
1258
  return configs.map(config => AbilityJSONParser.parsePolicy(config));
1054
1259
  }
1055
1260
  static parsePolicy(config) {
1056
- const { id, name, ruleSet, compareMethod, permission, effect } = config;
1261
+ const { id, name, ruleSet, compareMethod, permission, effect, priority, disabled, tags } = config;
1057
1262
  // Create the empty policy
1058
1263
  const policy = new AbilityPolicy({
1059
1264
  name,
1060
1265
  id,
1061
1266
  permission: permission,
1062
- effect: new AbilityPolicyEffect(effect),
1267
+ priority: priority,
1268
+ effect: effect,
1269
+ disabled,
1270
+ tags,
1063
1271
  });
1064
- policy.compareMethod = new AbilityCompare(compareMethod);
1272
+ policy.compareMethod = compareMethod;
1065
1273
  ruleSet.forEach(ruleSetConfig => {
1066
1274
  policy.addRuleSet(AbilityJSONParser.parseRuleSet(ruleSetConfig));
1067
1275
  });
1068
1276
  return policy;
1069
1277
  }
1070
1278
  static parseRule(config) {
1071
- const { id, name, subject, resource, condition } = config;
1279
+ const { id, name, subject, resource, condition, disabled } = config;
1072
1280
  return new AbilityRule({
1073
1281
  id,
1074
1282
  name,
1075
1283
  subject,
1076
1284
  resource,
1077
- condition: new AbilityCondition(condition),
1285
+ disabled,
1286
+ condition,
1078
1287
  });
1079
1288
  }
1080
1289
  /**
1081
1290
  * Parse the config JSON format to Group class instance
1082
1291
  */
1083
1292
  static parseRuleSet(config) {
1084
- const { id, name, rules, compareMethod } = config;
1293
+ const { id, name, rules, compareMethod, disabled } = config;
1085
1294
  const ruleSet = new AbilityRuleSet({
1086
- compareMethod: new AbilityCompare(compareMethod),
1295
+ disabled,
1296
+ compareMethod: compareMethod,
1087
1297
  name,
1088
1298
  id,
1089
1299
  });
@@ -1100,25 +1310,30 @@ class AbilityJSONParser {
1100
1310
  name: rule.name,
1101
1311
  subject: rule.subject,
1102
1312
  resource: rule.resource,
1103
- condition: rule.condition.code,
1313
+ condition: rule.condition,
1314
+ disabled: rule.disabled,
1104
1315
  };
1105
1316
  }
1106
1317
  static ruleSetToJSON(ruleSet) {
1107
1318
  return {
1108
1319
  id: ruleSet.id.toString(),
1109
1320
  name: ruleSet.name.toString(),
1110
- compareMethod: ruleSet.compareMethod.code.toString(),
1321
+ compareMethod: ruleSet.compareMethod,
1111
1322
  rules: ruleSet.rules.map(rule => AbilityJSONParser.ruleToJSON(rule)),
1323
+ disabled: ruleSet.disabled,
1112
1324
  };
1113
1325
  }
1114
1326
  static policyToJSON(policy) {
1115
1327
  return {
1116
1328
  id: policy.id.toString(),
1117
1329
  name: policy.name.toString(),
1118
- compareMethod: policy.compareMethod.code.toString(),
1330
+ compareMethod: policy.compareMethod,
1119
1331
  ruleSet: policy.ruleSet.map(ruleSet => AbilityJSONParser.ruleSetToJSON(ruleSet)),
1120
1332
  permission: policy.permission,
1121
- effect: policy.effect.code,
1333
+ effect: policy.effect,
1334
+ priority: policy.priority,
1335
+ disabled: policy.disabled,
1336
+ tags: policy.tags,
1122
1337
  };
1123
1338
  }
1124
1339
  static toJSON(policies) {
@@ -1126,67 +1341,66 @@ class AbilityJSONParser {
1126
1341
  }
1127
1342
  }
1128
1343
 
1129
- /**
1130
- * Represents a single token produced by the Ability DSL lexer.
1131
- * Each token carries a type (e.g., EFFECT, IDENTIFIER, STRING) and its raw string value.
1132
- */
1133
- class AbilityDSLToken extends AbilityCode {
1134
- /** The literal text of the token as it appeared in the input (e.g., "permit", "user.roles", "admin"). */
1135
- value = '';
1136
- /** The line number in DSL */
1137
- line = 1;
1138
- /** The column in dsl */
1139
- 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;
1140
1395
  constructor(type, value, line, column) {
1141
- super(type);
1396
+ this.type = type;
1142
1397
  this.value = value;
1143
1398
  this.line = line;
1144
1399
  this.column = column;
1145
1400
  }
1146
- /**
1147
- * Returns a human-readable representation of the token, useful for debugging.
1148
- * Example output: "AbilityDSLToken([EFFECT] permit"
1149
- */
1150
1401
  toString() {
1151
- return `AbilityDSLToken([${this.code}] "${this.value}" at ${this.line}:${this.column})`;
1152
- }
1153
- static EFFECT = 'EFFECT';
1154
- static IF = 'IF';
1155
- static PERMISSION = 'PERMISSION';
1156
- static IDENTIFIER = 'IDENTIFIER';
1157
- static COLON = 'COLON';
1158
- static COMMA = 'COMMA';
1159
- static DOT = 'DOT';
1160
- static LBRACKET = 'LBRACKET';
1161
- static RBRACKET = 'RBRACKET';
1162
- static ALL = 'ALL';
1163
- static ANY = 'ANY';
1164
- static OF = 'OF';
1165
- static EOF = 'EOF';
1166
- static COMMENT = 'COMMENT';
1167
- static EQ = 'EQ';
1168
- static CONTAINS = 'CONTAINS';
1169
- static IN = 'IN';
1170
- static NOT_IN = 'NOT_IN';
1171
- static NOT_CONTAINS = 'NOT_CONTAINS';
1172
- static GT = 'GT';
1173
- static GTE = 'GTE';
1174
- static LT = 'LT';
1175
- static LTE = 'LTE';
1176
- static NULL = 'NULL';
1177
- static EQ_NULL = 'EQ_NULL';
1178
- static NOT_EQ_NULL = 'NOT_EQ_NULL';
1179
- static LEN_GT = 'LEN_GT';
1180
- static LEN_LT = 'LEN_LT';
1181
- static LEN_EQ = 'LEN_EQ';
1182
- static NOT_EQ = 'NOT_EQ';
1183
- static ALWAYS = 'ALWAYS';
1184
- static NEVER = 'NEVER';
1185
- static STRING = 'STRING';
1186
- static NUMBER = 'NUMBER';
1187
- static BOOLEAN = 'BOOLEAN';
1188
- static SYMBOL = 'SYMBOL';
1189
- static KEYWORD = 'KEYWORD';
1402
+ return `AbilityDSLToken([${this.type}] "${this.value}" at ${this.line}:${this.column})`;
1403
+ }
1190
1404
  }
1191
1405
 
1192
1406
  class AbilityDSLLexer {
@@ -1195,7 +1409,6 @@ class AbilityDSLLexer {
1195
1409
  tokens = [];
1196
1410
  line = 1;
1197
1411
  column = 1;
1198
- // Список ключевых слов
1199
1412
  keywords = new Set([
1200
1413
  'if',
1201
1414
  'all',
@@ -1226,6 +1439,8 @@ class AbilityDSLLexer {
1226
1439
  'than',
1227
1440
  'always',
1228
1441
  'never',
1442
+ 'except',
1443
+ 'alias',
1229
1444
  ]);
1230
1445
  constructor(input) {
1231
1446
  this.input = input;
@@ -1236,6 +1451,10 @@ class AbilityDSLLexer {
1236
1451
  if (this.isAtEnd())
1237
1452
  break;
1238
1453
  const char = this.peek();
1454
+ if (char === '@') {
1455
+ this.tokens.push(this.readAnnotation());
1456
+ continue;
1457
+ }
1239
1458
  if (char === '#') {
1240
1459
  this.tokens.push(this.readComment());
1241
1460
  continue;
@@ -1258,7 +1477,7 @@ class AbilityDSLLexer {
1258
1477
  }
1259
1478
  throw new Error(`Unexpected character '${char}' at ${this.line}:${this.column}`);
1260
1479
  }
1261
- this.tokens.push(new AbilityDSLToken(AbilityDSLToken.EOF, '', this.line, this.column));
1480
+ this.tokens.push(new AbilityDSLToken(TokenTypes.EOF, '', this.line, this.column));
1262
1481
  return this.tokens;
1263
1482
  }
1264
1483
  readComment() {
@@ -1269,7 +1488,31 @@ class AbilityDSLLexer {
1269
1488
  while (!this.isAtEnd() && !this.isNewline()) {
1270
1489
  value += this.advance();
1271
1490
  }
1272
- 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);
1273
1516
  }
1274
1517
  readString() {
1275
1518
  const startLine = this.line;
@@ -1289,7 +1532,7 @@ class AbilityDSLLexer {
1289
1532
  continue;
1290
1533
  }
1291
1534
  if (char === quote) {
1292
- return new AbilityDSLToken(AbilityDSLToken.STRING, value, startLine, startColumn);
1535
+ return new AbilityDSLToken(TokenTypes.STRING, value, startLine, startColumn);
1293
1536
  }
1294
1537
  value += char;
1295
1538
  }
@@ -1303,7 +1546,7 @@ class AbilityDSLLexer {
1303
1546
  this.advance();
1304
1547
  }
1305
1548
  const value = this.input.slice(start, this.pos);
1306
- return new AbilityDSLToken(AbilityDSLToken.NUMBER, value, startLine, startColumn);
1549
+ return new AbilityDSLToken(TokenTypes.NUMBER, value, startLine, startColumn);
1307
1550
  }
1308
1551
  readSymbol() {
1309
1552
  const startLine = this.line;
@@ -1311,41 +1554,41 @@ class AbilityDSLLexer {
1311
1554
  const char = this.advance();
1312
1555
  switch (char) {
1313
1556
  case '.':
1314
- return new AbilityDSLToken(AbilityDSLToken.DOT, char, startLine, startColumn);
1557
+ return new AbilityDSLToken(TokenTypes.DOT, char, startLine, startColumn);
1315
1558
  case ':':
1316
- return new AbilityDSLToken(AbilityDSLToken.COLON, char, startLine, startColumn);
1559
+ return new AbilityDSLToken(TokenTypes.COLON, char, startLine, startColumn);
1317
1560
  case ',':
1318
- return new AbilityDSLToken(AbilityDSLToken.COMMA, char, startLine, startColumn);
1561
+ return new AbilityDSLToken(TokenTypes.COMMA, char, startLine, startColumn);
1319
1562
  case '[':
1320
- return new AbilityDSLToken(AbilityDSLToken.LBRACKET, char, startLine, startColumn);
1563
+ return new AbilityDSLToken(TokenTypes.LBRACKET, char, startLine, startColumn);
1321
1564
  case ']':
1322
- return new AbilityDSLToken(AbilityDSLToken.RBRACKET, char, startLine, startColumn);
1565
+ return new AbilityDSLToken(TokenTypes.RBRACKET, char, startLine, startColumn);
1323
1566
  case '>':
1324
1567
  if (this.peek() === '=') {
1325
1568
  this.advance();
1326
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '>=', startLine, startColumn);
1569
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '>=', startLine, startColumn);
1327
1570
  }
1328
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '>', startLine, startColumn);
1571
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '>', startLine, startColumn);
1329
1572
  case '<':
1330
1573
  if (this.peek() === '=') {
1331
1574
  this.advance();
1332
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '<=', startLine, startColumn);
1575
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '<=', startLine, startColumn);
1333
1576
  }
1334
1577
  if (this.peek() === '>') {
1335
1578
  this.advance();
1336
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '<>', startLine, startColumn);
1579
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '<>', startLine, startColumn);
1337
1580
  }
1338
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '<', startLine, startColumn);
1581
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '<', startLine, startColumn);
1339
1582
  case '=':
1340
1583
  if (this.peek() === '=') {
1341
1584
  this.advance();
1342
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '==', startLine, startColumn);
1585
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '==', startLine, startColumn);
1343
1586
  }
1344
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '=', startLine, startColumn);
1587
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '=', startLine, startColumn);
1345
1588
  case '!':
1346
1589
  if (this.peek() === '=') {
1347
1590
  this.advance();
1348
- return new AbilityDSLToken(AbilityDSLToken.SYMBOL, '!=', startLine, startColumn);
1591
+ return new AbilityDSLToken(TokenTypes.SYMBOL, '!=', startLine, startColumn);
1349
1592
  }
1350
1593
  throw new Error(`Unexpected symbol '!' at ${this.line}:${this.column}`);
1351
1594
  default:
@@ -1372,60 +1615,66 @@ class AbilityDSLLexer {
1372
1615
  }
1373
1616
  const word = this.input.slice(start, this.pos);
1374
1617
  if (word === 'always') {
1375
- return new AbilityDSLToken(AbilityDSLToken.ALWAYS, word, startLine, startColumn);
1618
+ return new AbilityDSLToken(TokenTypes.ALWAYS, word, startLine, startColumn);
1376
1619
  }
1377
1620
  if (word === 'never') {
1378
- return new AbilityDSLToken(AbilityDSLToken.NEVER, word, startLine, startColumn);
1621
+ return new AbilityDSLToken(TokenTypes.NEVER, word, startLine, startColumn);
1379
1622
  }
1380
1623
  // Если есть точка — это путь (identifier или permission)
1381
1624
  if (word.includes('.')) {
1382
1625
  const last = this.tokens[this.tokens.length - 1];
1383
- if (last?.code === AbilityDSLToken.EFFECT) {
1626
+ if (last?.type === TokenTypes.EFFECT) {
1384
1627
  if (word.startsWith('permission.')) {
1385
- return new AbilityDSLToken(AbilityDSLToken.PERMISSION, word, startLine, startColumn);
1628
+ return new AbilityDSLToken(TokenTypes.PERMISSION, word, startLine, startColumn);
1386
1629
  }
1387
1630
  }
1388
- return new AbilityDSLToken(AbilityDSLToken.IDENTIFIER, word, startLine, startColumn);
1631
+ return new AbilityDSLToken(TokenTypes.IDENTIFIER, word, startLine, startColumn);
1389
1632
  }
1390
1633
  // Ключевые слова
1391
1634
  if (this.keywords.has(word)) {
1392
1635
  // Эффекты
1393
1636
  if (word === 'permit' || word === 'allow') {
1394
- return new AbilityDSLToken(AbilityDSLToken.EFFECT, 'permit', startLine, startColumn);
1637
+ return new AbilityDSLToken(TokenTypes.EFFECT, 'permit', startLine, startColumn);
1395
1638
  }
1396
1639
  if (word === 'deny' || word === 'forbidden') {
1397
- return new AbilityDSLToken(AbilityDSLToken.EFFECT, 'deny', startLine, startColumn);
1640
+ return new AbilityDSLToken(TokenTypes.EFFECT, 'deny', startLine, startColumn);
1398
1641
  }
1399
1642
  // Групповые ключевые слова
1400
1643
  if (word === 'all') {
1401
- return new AbilityDSLToken(AbilityDSLToken.ALL, word, startLine, startColumn);
1644
+ return new AbilityDSLToken(TokenTypes.ALL, word, startLine, startColumn);
1402
1645
  }
1403
1646
  if (word === 'any') {
1404
- return new AbilityDSLToken(AbilityDSLToken.ANY, word, startLine, startColumn);
1647
+ return new AbilityDSLToken(TokenTypes.ANY, word, startLine, startColumn);
1405
1648
  }
1406
1649
  if (word === 'of') {
1407
- return new AbilityDSLToken(AbilityDSLToken.OF, word, startLine, startColumn);
1650
+ return new AbilityDSLToken(TokenTypes.OF, word, startLine, startColumn);
1408
1651
  }
1409
1652
  if (word === 'if') {
1410
- return new AbilityDSLToken(AbilityDSLToken.IF, word, startLine, startColumn);
1653
+ return new AbilityDSLToken(TokenTypes.IF, word, startLine, startColumn);
1411
1654
  }
1412
1655
  // Булевы и null
1413
1656
  if (word === 'true' || word === 'false') {
1414
- return new AbilityDSLToken(AbilityDSLToken.BOOLEAN, word, startLine, startColumn);
1657
+ return new AbilityDSLToken(TokenTypes.BOOLEAN, word, startLine, startColumn);
1415
1658
  }
1416
1659
  if (word === 'null') {
1417
- 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);
1418
1667
  }
1419
1668
  // Остальные ключевые слова (contains, in, equals, greater, less, not, is, or, than, equal)
1420
- return new AbilityDSLToken(AbilityDSLToken.KEYWORD, word, startLine, startColumn);
1669
+ return new AbilityDSLToken(TokenTypes.KEYWORD, word, startLine, startColumn);
1421
1670
  }
1422
1671
  // Если после EFFECT и нет точки — действие (например, "create")
1423
1672
  const lastToken = this.tokens[this.tokens.length - 1];
1424
- if (lastToken?.code === AbilityDSLToken.EFFECT) {
1425
- return new AbilityDSLToken(AbilityDSLToken.PERMISSION, word, startLine, startColumn);
1673
+ if (lastToken?.type === TokenTypes.EFFECT) {
1674
+ return new AbilityDSLToken(TokenTypes.PERMISSION, word, startLine, startColumn);
1426
1675
  }
1427
1676
  // Обычный идентификатор
1428
- return new AbilityDSLToken(AbilityDSLToken.IDENTIFIER, word, startLine, startColumn);
1677
+ return new AbilityDSLToken(TokenTypes.IDENTIFIER, word, startLine, startColumn);
1429
1678
  }
1430
1679
  skipWhitespace() {
1431
1680
  while (!this.isAtEnd() && /\s/.test(this.peek())) {
@@ -1525,6 +1774,215 @@ class AbilityDSLSyntaxError extends Error {
1525
1774
  }
1526
1775
  }
1527
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
+
1528
1986
  /**
1529
1987
  * Parser for the Ability DSL.
1530
1988
  *
@@ -1543,12 +2001,9 @@ class AbilityDSLSyntaxError extends Error {
1543
2001
  */
1544
2002
  class AbilityDSLParser {
1545
2003
  dsl;
1546
- tokens = [];
1547
- pos = 0;
1548
- annotationBuffer = {
1549
- name: null,
1550
- description: null,
1551
- };
2004
+ stream;
2005
+ annBuffer = new AbilityDSLAnnotations();
2006
+ aliasBuffer = new AbilityDSLAliases();
1552
2007
  constructor(dsl) {
1553
2008
  this.dsl = dsl;
1554
2009
  }
@@ -1557,17 +2012,19 @@ class AbilityDSLParser {
1557
2012
  * @returns Array of AbilityPolicy instances.
1558
2013
  */
1559
2014
  parse() {
1560
- // Tokenize the entire DSL string.
1561
- this.tokens = new AbilityDSLLexer(this.dsl).tokenize();
1562
- 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);
1563
2020
  const policies = [];
1564
- // Keep parsing until we've consumed all tokens.
1565
- while (!this.isAtEnd()) {
2021
+ while (!this.stream.eof()) {
1566
2022
  this.consumeLeadingComments();
1567
- // Every policy must start with an EFFECT token.
2023
+ this.consumeLeadingAnnotations();
2024
+ this.consumeLeadingAliases();
1568
2025
  if (!this.isStartOfPolicy()) {
1569
- const token = this.peek();
1570
- 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]);
1571
2028
  }
1572
2029
  policies.push(this.parsePolicy());
1573
2030
  }
@@ -1584,31 +2041,37 @@ class AbilityDSLParser {
1584
2041
  */
1585
2042
  parsePolicy() {
1586
2043
  this.consumeLeadingComments();
1587
- const meta = this.takeAnnotations();
2044
+ this.consumeLeadingAnnotations();
2045
+ this.consumeLeadingAliases();
2046
+ const annotations = this.takeAnnotations('policy');
1588
2047
  // Effect: "permit" or "deny"
1589
- const effectToken = this.consume(AbilityDSLToken.EFFECT, 'Expected effect');
2048
+ const effectToken = this.stream.expect(TokenTypes.EFFECT, 'Expected effect');
1590
2049
  const effect = effectToken.value;
1591
2050
  // Permission: e.g. "order.update"
1592
- const permissionToken = this.consume(AbilityDSLToken.PERMISSION, 'Expected permission');
2051
+ const permissionToken = this.stream.expect(TokenTypes.PERMISSION, 'Expected permission');
1593
2052
  const permission = permissionToken.value;
1594
2053
  if (!permission.startsWith('permission.')) {
1595
- 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);
1596
2055
  }
1597
2056
  // "if" keyword
1598
- this.consume(AbilityDSLToken.IF, 'Expected "if"');
2057
+ this.stream.expect(TokenTypes.IF, 'Expected "if"');
1599
2058
  // Group selector: "all" or "any" – determines how the top‑level rule sets are combined.
1600
- const compareToken = this.consumeOneOf([AbilityDSLToken.ALL, AbilityDSLToken.ANY], 'Expected "all" or "any"');
1601
- 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;
1602
2061
  // Colon after the group keyword
1603
- this.consume(AbilityDSLToken.COLON, 'Expected ":"');
2062
+ this.stream.expect(TokenTypes.COLON, 'Expected ":"');
1604
2063
  // Parse the list of rule sets (each "all of:" or "any of:" block)
1605
2064
  const ruleSets = this.parseRuleSets(compareMethod);
1606
2065
  // Construct the policy instance.
1607
2066
  return new AbilityPolicy({
1608
- id: `${effect}:${permission}:${Math.random()}`,
1609
- 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,
1610
2071
  permission: permission.replace(/^permission\./, ''),
1611
2072
  effect: effect === 'permit' ? AbilityPolicyEffect.permit : AbilityPolicyEffect.deny,
2073
+ disabled: annotations.disabled?.value ?? undefined,
2074
+ tags: annotations.tags?.value ?? undefined,
1612
2075
  compareMethod,
1613
2076
  }).addRuleSets(ruleSets);
1614
2077
  }
@@ -1620,32 +2083,41 @@ class AbilityDSLParser {
1620
2083
  */
1621
2084
  parseRuleSets(policyCompareMethod) {
1622
2085
  const sets = [];
1623
- while (!this.isAtEnd() && !this.isStartOfPolicy()) {
2086
+ while (!this.stream.eof() && !this.isStartOfPolicy()) {
1624
2087
  this.consumeLeadingComments();
2088
+ this.consumeLeadingAnnotations();
2089
+ // Если начинается новая except группа — парсим её
2090
+ if (this.isStartOfExcept()) {
2091
+ sets.push(this.parseExceptGroup(policyCompareMethod));
2092
+ continue;
2093
+ }
1625
2094
  // Если начинается новая группа — парсим её
1626
2095
  if (this.isStartOfGroup()) {
1627
2096
  sets.push(this.parseGroup());
1628
2097
  continue;
1629
2098
  }
1630
- // Иначе implicit group (all-of по умолчанию)
1631
- const meta = this.takeAnnotations();
2099
+ const annotation = this.takeAnnotations('ruleSet');
1632
2100
  const group = new AbilityRuleSet({
2101
+ id: annotation.id?.value || null,
1633
2102
  compareMethod: policyCompareMethod,
1634
- name: meta.name,
2103
+ name: annotation.name?.value ?? null,
2104
+ description: annotation.description?.value || null,
2105
+ disabled: annotation.disabled?.value ?? undefined,
1635
2106
  });
1636
2107
  // Читаем правила implicit-группы
1637
- while (!this.isAtEnd()) {
2108
+ while (!this.stream.eof()) {
1638
2109
  this.consumeLeadingComments();
1639
- if (this.isStartOfGroup() || this.isStartOfPolicy()) {
2110
+ this.consumeLeadingAnnotations();
2111
+ if (this.isStartOfGroup() || this.isStartOfPolicy() || this.isStartOfExcept()) {
1640
2112
  break;
1641
2113
  }
1642
- if (this.check(AbilityDSLToken.IDENTIFIER) ||
1643
- this.check(AbilityDSLToken.ALWAYS) ||
1644
- this.check(AbilityDSLToken.NEVER)) {
2114
+ if (this.stream.check(TokenTypes.IDENTIFIER) ||
2115
+ this.stream.check(TokenTypes.ALWAYS) ||
2116
+ this.stream.check(TokenTypes.NEVER)) {
1645
2117
  group.addRule(this.parseRule());
1646
2118
  }
1647
2119
  else {
1648
- 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());
1649
2121
  }
1650
2122
  }
1651
2123
  sets.push(group);
@@ -1657,24 +2129,83 @@ class AbilityDSLParser {
1657
2129
  */
1658
2130
  parseGroup() {
1659
2131
  this.consumeLeadingComments();
1660
- const meta = this.takeAnnotations();
1661
- const compareToken = this.consumeOneOf([AbilityDSLToken.ALL, AbilityDSLToken.ANY, AbilityDSLToken.ALWAYS, AbilityDSLToken.NEVER], 'Expected "all" or "any" or "always" or "never"');
1662
- const compareMethod = compareToken.code === AbilityDSLToken.ALL ? AbilityCompare.and : AbilityCompare.or;
1663
- if (this.check(AbilityDSLToken.OF)) {
1664
- this.advance();
1665
- }
1666
- this.consume(AbilityDSLToken.COLON, 'Expected ":"');
1667
- const group = new AbilityRuleSet({ compareMethod, name: meta.name });
1668
- 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()) {
1669
2148
  this.consumeLeadingComments();
2149
+ this.consumeLeadingAnnotations();
2150
+ if (this.isStartOfExcept()) {
2151
+ break;
2152
+ }
1670
2153
  if (this.isStartOfGroup() || this.isStartOfPolicy()) {
1671
2154
  break;
1672
2155
  }
1673
- if (this.check(AbilityDSLToken.IDENTIFIER)) {
2156
+ if (this.stream.check(TokenTypes.IDENTIFIER)) {
2157
+ group.addRule(this.parseRule());
2158
+ }
2159
+ else {
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)) {
1674
2205
  group.addRule(this.parseRule());
1675
2206
  }
1676
2207
  else {
1677
- this.syntaxError(`Unexpected token in group: ${this.peek().code}`, this.peek());
2208
+ this.stream.syntaxError(`Unexpected token in except group: ${this.stream.peek().type}`, this.stream.peek());
1678
2209
  }
1679
2210
  }
1680
2211
  return group;
@@ -1687,57 +2218,54 @@ class AbilityDSLParser {
1687
2218
  */
1688
2219
  parseRule() {
1689
2220
  this.consumeLeadingComments();
1690
- const meta = this.takeAnnotations();
1691
- // if (this.check(AbilityDSLToken.ALWAYS) || this.check(AbilityDSLToken.NEVER)) {
1692
- // // Checking that there are no extra tokens after the value
1693
- // // (skip comments)
1694
- // this.consumeLeadingComments();
1695
- // const specOperator = this.consume();
1696
- // // return new AbilityRule({
1697
- // // subject: '',
1698
- // // resource,
1699
- // // condition,
1700
- // // name: meta.name,
1701
- // // });
1702
- // }
1703
- const isNeverAlways = this.check(AbilityDSLToken.ALWAYS) || this.check(AbilityDSLToken.NEVER);
1704
- if (!isNeverAlways && !this.check(AbilityDSLToken.IDENTIFIER)) {
1705
- 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());
1706
2226
  }
1707
- // Subject (e.g., "user.roles")
2227
+ // subject
1708
2228
  const subject = isNeverAlways
1709
2229
  ? ''
1710
- : this.consume(AbilityDSLToken.IDENTIFIER, 'Expected field').value;
1711
- // Operator (e.g., "contains", "equals", "is not null")
1712
- const { condition, operator } = this.parseConditionOperator();
1713
- let resource;
1714
- let beforePos = this.pos;
1715
- // Special operators that don't consume a value token.
1716
- if (operator === AbilityDSLToken.EQ_NULL ||
1717
- operator === AbilityDSLToken.NOT_EQ_NULL ||
1718
- operator === AbilityDSLToken.NULL ||
1719
- operator === AbilityDSLToken.ALWAYS ||
1720
- operator === AbilityDSLToken.NEVER) {
1721
- 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);
1722
2234
  }
1723
- else {
1724
- 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();
1725
2247
  resource = this.parseValue();
2248
+ valueToken = this.stream.lookPrev();
2249
+ this.stream.commit();
1726
2250
  }
1727
- // Checking that there are no extra tokens after the value
1728
- // (skip comments)
1729
2251
  this.consumeLeadingComments();
1730
- const resourceToken = this.tokens[beforePos];
2252
+ this.consumeLeadingAnnotations();
2253
+ this.consumeLeadingAliases();
2254
+ // validation: identifier without dot → error
1731
2255
  if (typeof resource === 'string' &&
1732
- resourceToken.code === AbilityDSLToken.IDENTIFIER &&
1733
- !resourceToken.value.includes('.')) {
1734
- 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]);
1735
2260
  }
1736
2261
  return new AbilityRule({
2262
+ id: annotations.id?.value || null,
1737
2263
  subject,
1738
2264
  resource,
1739
2265
  condition,
1740
- name: meta.name,
2266
+ name: annotations.name?.value || null,
2267
+ description: annotations.description?.value || null,
2268
+ disabled: annotations.disabled?.value ?? undefined,
1741
2269
  });
1742
2270
  }
1743
2271
  // -------------------------------------------------------------------------
@@ -1748,200 +2276,241 @@ class AbilityDSLParser {
1748
2276
  * Returns both the resulting AbilityCondition and the token type that was consumed.
1749
2277
  */
1750
2278
  parseConditionOperator() {
1751
- const savedPos = this.pos;
1752
2279
  // "always"
2280
+ this.stream.mark();
1753
2281
  if (this.matchWord('always')) {
1754
- return { condition: AbilityCondition.always, operator: AbilityDSLToken.ALWAYS };
2282
+ this.stream.commit();
2283
+ return { condition: AbilityCondition.always, operator: TokenTypes.ALWAYS };
1755
2284
  }
1756
- this.pos = savedPos;
2285
+ this.stream.reset();
1757
2286
  // "never"
2287
+ this.stream.mark();
1758
2288
  if (this.matchWord('never')) {
1759
- return { condition: AbilityCondition.never, operator: AbilityDSLToken.NEVER };
2289
+ this.stream.commit();
2290
+ return { condition: AbilityCondition.never, operator: TokenTypes.NEVER };
1760
2291
  }
1761
- this.pos = savedPos;
2292
+ this.stream.reset();
1762
2293
  // "length equals"
2294
+ this.stream.mark();
1763
2295
  if (this.matchWord('length') && this.matchWord('equals')) {
1764
- return { condition: AbilityCondition.length_equals, operator: AbilityDSLToken.LEN_EQ };
2296
+ this.stream.commit();
2297
+ return { condition: AbilityCondition.length_equals, operator: TokenTypes.LEN_EQ };
1765
2298
  }
1766
- this.pos = savedPos;
2299
+ this.stream.reset();
1767
2300
  // "length ="
2301
+ this.stream.mark();
1768
2302
  if (this.matchWord('length') && this.matchSymbol('=')) {
1769
- return { condition: AbilityCondition.length_equals, operator: AbilityDSLToken.LEN_EQ };
2303
+ this.stream.commit();
2304
+ return { condition: AbilityCondition.length_equals, operator: TokenTypes.LEN_EQ };
1770
2305
  }
1771
- this.pos = savedPos;
2306
+ this.stream.reset();
1772
2307
  // "length greater than"
2308
+ this.stream.mark();
1773
2309
  if (this.matchWord('length') && this.matchWord('greater') && this.matchWord('than')) {
1774
- 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 };
1775
2312
  }
1776
- this.pos = savedPos;
2313
+ this.stream.reset();
1777
2314
  // "length >"
2315
+ this.stream.mark();
1778
2316
  if (this.matchWord('length') && this.matchSymbol('>')) {
1779
- 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 };
1780
2319
  }
1781
- this.pos = savedPos;
2320
+ this.stream.reset();
1782
2321
  // "length less than"
2322
+ this.stream.mark();
1783
2323
  if (this.matchWord('length') && this.matchWord('less') && this.matchWord('than')) {
1784
- 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 };
1785
2326
  }
1786
- this.pos = savedPos;
2327
+ this.stream.reset();
1787
2328
  // "length <"
2329
+ this.stream.mark();
1788
2330
  if (this.matchWord('length') && this.matchSymbol('<')) {
1789
- 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 };
1790
2333
  }
1791
- this.pos = savedPos;
2334
+ this.stream.reset();
1792
2335
  // "greater than or equal"
2336
+ this.stream.mark();
1793
2337
  if (this.matchWord('greater') &&
1794
2338
  this.matchWord('than') &&
1795
2339
  this.matchWord('or') &&
1796
2340
  this.matchWord('equal')) {
1797
- return { condition: AbilityCondition.greater_or_equal, operator: AbilityDSLToken.GTE };
2341
+ this.stream.commit();
2342
+ return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
1798
2343
  }
1799
- this.pos = savedPos;
2344
+ this.stream.reset();
1800
2345
  // greater than
2346
+ this.stream.mark();
1801
2347
  if (this.matchWord('greater') && this.matchWord('than')) {
1802
- return { condition: AbilityCondition.greater_than, operator: AbilityDSLToken.GT };
2348
+ this.stream.commit();
2349
+ return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
1803
2350
  }
1804
- this.pos = savedPos;
2351
+ this.stream.reset();
1805
2352
  // less than or equal
2353
+ this.stream.mark();
1806
2354
  if (this.matchWord('less') &&
1807
2355
  this.matchWord('than') &&
1808
2356
  this.matchWord('or') &&
1809
2357
  this.matchWord('equal')) {
1810
- return { condition: AbilityCondition.less_or_equal, operator: AbilityDSLToken.LTE };
2358
+ this.stream.commit();
2359
+ return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
1811
2360
  }
1812
- this.pos = savedPos;
2361
+ this.stream.reset();
1813
2362
  // less than
1814
2363
  if (this.matchWord('less') && this.matchWord('than')) {
1815
- return { condition: AbilityCondition.less_than, operator: AbilityDSLToken.LT };
2364
+ return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
1816
2365
  }
1817
- this.pos = savedPos;
2366
+ this.stream.reset();
1818
2367
  // "not contains"
2368
+ this.stream.mark();
1819
2369
  if (this.matchWord('not') && this.matchWord('contains')) {
2370
+ this.stream.commit();
1820
2371
  return {
1821
2372
  condition: AbilityCondition.not_contains,
1822
- operator: AbilityDSLToken.NOT_CONTAINS,
2373
+ operator: TokenTypes.NOT_CONTAINS,
1823
2374
  };
1824
2375
  }
1825
- this.pos = savedPos;
2376
+ this.stream.reset();
1826
2377
  // "not includes"
2378
+ this.stream.mark();
1827
2379
  if (this.matchWord('not') && this.matchWord('includes')) {
2380
+ this.stream.commit();
1828
2381
  return {
1829
2382
  condition: AbilityCondition.not_contains,
1830
- operator: AbilityDSLToken.NOT_CONTAINS,
2383
+ operator: TokenTypes.NOT_CONTAINS,
1831
2384
  };
1832
2385
  }
1833
- this.pos = savedPos;
2386
+ this.stream.reset();
1834
2387
  // "not includes"
2388
+ this.stream.mark();
1835
2389
  if (this.matchWord('not') && this.matchWord('has')) {
2390
+ this.stream.commit();
1836
2391
  return {
1837
2392
  condition: AbilityCondition.not_contains,
1838
- operator: AbilityDSLToken.NOT_CONTAINS,
2393
+ operator: TokenTypes.NOT_CONTAINS,
1839
2394
  };
1840
2395
  }
1841
- this.pos = savedPos;
2396
+ this.stream.reset();
1842
2397
  // "is equals"
2398
+ this.stream.mark();
1843
2399
  if (this.matchWord('is') && this.matchWord('equals')) {
1844
- return { condition: AbilityCondition.equals, operator: AbilityDSLToken.EQ };
2400
+ this.stream.commit();
2401
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
1845
2402
  }
1846
- this.pos = savedPos;
2403
+ this.stream.reset();
1847
2404
  // not equal
2405
+ this.stream.mark();
1848
2406
  if (this.matchWord('not') && this.matchWord('equals')) {
1849
- return { condition: AbilityCondition.not_equals, operator: AbilityDSLToken.NOT_EQ };
2407
+ this.stream.commit();
2408
+ return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
1850
2409
  }
1851
- this.pos = savedPos;
2410
+ this.stream.reset();
1852
2411
  // is not equals
2412
+ this.stream.mark();
1853
2413
  if (this.matchWord('is') && this.matchWord('not') && this.matchWord('equals')) {
1854
- return { condition: AbilityCondition.not_equals, operator: AbilityDSLToken.NOT_EQ };
2414
+ this.stream.commit();
2415
+ return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
1855
2416
  }
1856
- this.pos = savedPos;
2417
+ this.stream.reset();
1857
2418
  // is in
2419
+ this.stream.mark();
1858
2420
  if (this.matchWord('is') && this.matchWord('in')) {
1859
- return { condition: AbilityCondition.in, operator: AbilityDSLToken.IN };
2421
+ this.stream.commit();
2422
+ return { condition: AbilityCondition.in, operator: TokenTypes.IN };
1860
2423
  }
1861
- this.pos = savedPos;
2424
+ this.stream.reset();
1862
2425
  // not in
2426
+ this.stream.mark();
1863
2427
  if (this.matchWord('not') && this.matchWord('in')) {
1864
- return { condition: AbilityCondition.not_in, operator: AbilityDSLToken.NOT_IN };
2428
+ this.stream.commit();
2429
+ return { condition: AbilityCondition.not_in, operator: TokenTypes.NOT_IN };
1865
2430
  }
1866
- this.pos = savedPos;
2431
+ this.stream.reset();
1867
2432
  // is not null
2433
+ this.stream.mark();
1868
2434
  if (this.matchWord('is') && this.matchWord('not')) {
1869
- if (this.check(AbilityDSLToken.NULL)) {
1870
- this.advance();
2435
+ if (this.stream.check(TokenTypes.NULL)) {
2436
+ this.stream.next();
2437
+ this.stream.commit();
1871
2438
  return {
1872
2439
  condition: AbilityCondition.not_equals,
1873
- operator: AbilityDSLToken.NOT_EQ_NULL,
2440
+ operator: TokenTypes.NOT_EQ_NULL,
1874
2441
  };
1875
2442
  }
1876
2443
  }
1877
- this.pos = savedPos;
2444
+ this.stream.reset();
1878
2445
  // is null
2446
+ this.stream.mark();
1879
2447
  if (this.matchWord('is') && this.matchWord('null')) {
1880
- if (this.check(AbilityDSLToken.NULL)) {
1881
- this.advance();
2448
+ if (this.stream.check(TokenTypes.NULL)) {
2449
+ this.stream.commit();
2450
+ this.stream.next();
1882
2451
  return {
1883
2452
  condition: AbilityCondition.equals,
1884
- operator: AbilityDSLToken.EQ_NULL,
2453
+ operator: TokenTypes.EQ_NULL,
1885
2454
  };
1886
2455
  }
1887
2456
  }
1888
- this.pos = savedPos;
2457
+ this.stream.reset();
1889
2458
  // Single token (symbol or keyword)
1890
- const token = this.peek();
1891
- if (token.code !== AbilityDSLToken.SYMBOL &&
1892
- token.code !== AbilityDSLToken.KEYWORD &&
1893
- token.code !== AbilityDSLToken.NULL) {
1894
- this.syntaxError(`Expected comparison operator, got \`${token.value}\``, token, [
1895
- AbilityDSLToken.SYMBOL,
1896
- AbilityDSLToken.KEYWORD,
1897
- 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,
1898
2467
  ]);
1899
2468
  }
1900
- this.advance();
1901
- switch (token.code) {
1902
- case AbilityDSLToken.SYMBOL:
2469
+ this.stream.next();
2470
+ switch (token.type) {
2471
+ case TokenTypes.SYMBOL:
1903
2472
  if (token.value === '=' || token.value === '==')
1904
- return { condition: AbilityCondition.equals, operator: AbilityDSLToken.EQ };
2473
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
1905
2474
  if (token.value === '!=' || token.value === '<>')
1906
- return { condition: AbilityCondition.not_equals, operator: AbilityDSLToken.NOT_EQ };
2475
+ return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
1907
2476
  if (token.value === '>')
1908
- return { condition: AbilityCondition.greater_than, operator: AbilityDSLToken.GT };
2477
+ return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
1909
2478
  if (token.value === '<')
1910
- return { condition: AbilityCondition.less_than, operator: AbilityDSLToken.LT };
2479
+ return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
1911
2480
  if (token.value === '>=')
1912
- return { condition: AbilityCondition.greater_or_equal, operator: AbilityDSLToken.GTE };
2481
+ return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
1913
2482
  if (token.value === '<=')
1914
- return { condition: AbilityCondition.less_or_equal, operator: AbilityDSLToken.LTE };
2483
+ return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
1915
2484
  break;
1916
- case AbilityDSLToken.KEYWORD:
2485
+ case TokenTypes.KEYWORD:
1917
2486
  if (token.value === 'contains' || token.value === 'includes' || token.value === 'has')
1918
- return { condition: AbilityCondition.contains, operator: AbilityDSLToken.CONTAINS };
2487
+ return { condition: AbilityCondition.contains, operator: TokenTypes.CONTAINS };
1919
2488
  if (token.value === 'in')
1920
- return { condition: AbilityCondition.in, operator: AbilityDSLToken.IN };
2489
+ return { condition: AbilityCondition.in, operator: TokenTypes.IN };
1921
2490
  if (token.value === 'equals')
1922
- return { condition: AbilityCondition.equals, operator: AbilityDSLToken.EQ };
2491
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
1923
2492
  if (token.value === 'gte') {
1924
- return { condition: AbilityCondition.greater_or_equal, operator: AbilityDSLToken.GTE };
2493
+ return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
1925
2494
  }
1926
2495
  if (token.value === 'greater' || token.value === 'gt') {
1927
2496
  // If we come here, it means "greater" without "than" – treat as '>'
1928
- return { condition: AbilityCondition.greater_than, operator: AbilityDSLToken.GT };
2497
+ return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
1929
2498
  }
1930
2499
  if (token.value === 'less' || token.value === 'lt') {
1931
- return { condition: AbilityCondition.less_than, operator: AbilityDSLToken.LT };
2500
+ return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
1932
2501
  }
1933
2502
  if (token.value === 'lte') {
1934
- return { condition: AbilityCondition.less_or_equal, operator: AbilityDSLToken.LTE };
2503
+ return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
1935
2504
  }
1936
2505
  if (token.value === 'is') {
1937
2506
  // "is" alone -> equals
1938
- return { condition: AbilityCondition.equals, operator: AbilityDSLToken.EQ };
2507
+ return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
1939
2508
  }
1940
2509
  break;
1941
2510
  }
1942
- return this.syntaxError(`Unexpected operator token \`${token.value}\``, token, [
1943
- AbilityDSLToken.SYMBOL,
1944
- AbilityDSLToken.KEYWORD,
2511
+ return this.stream.syntaxError(`Unexpected operator token \`${token.value}\``, token, [
2512
+ TokenTypes.SYMBOL,
2513
+ TokenTypes.KEYWORD,
1945
2514
  ]);
1946
2515
  }
1947
2516
  /**
@@ -1950,26 +2519,26 @@ class AbilityDSLParser {
1950
2519
  * @returns True if the next token has that value.
1951
2520
  */
1952
2521
  matchWord(word) {
1953
- if (this.isAtEnd()) {
2522
+ if (this.stream.eof()) {
1954
2523
  return false;
1955
2524
  }
1956
- const token = this.peek();
1957
- if ((token.code === AbilityDSLToken.KEYWORD ||
1958
- token.code === AbilityDSLToken.IDENTIFIER ||
1959
- token.code === AbilityDSLToken.ALWAYS ||
1960
- 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) &&
1961
2530
  token.value === word) {
1962
- this.advance();
2531
+ this.stream.next();
1963
2532
  return true;
1964
2533
  }
1965
2534
  return false;
1966
2535
  }
1967
2536
  matchSymbol(symbol) {
1968
- if (this.isAtEnd())
2537
+ if (this.stream.eof())
1969
2538
  return false;
1970
- const token = this.peek();
1971
- if (token.code === AbilityDSLToken.SYMBOL && token.value === symbol) {
1972
- this.advance();
2539
+ const token = this.stream.peek();
2540
+ if (token.type === TokenTypes.SYMBOL && token.value === symbol) {
2541
+ this.stream.next();
1973
2542
  return true;
1974
2543
  }
1975
2544
  return false;
@@ -1983,32 +2552,34 @@ class AbilityDSLParser {
1983
2552
  */
1984
2553
  parseValue() {
1985
2554
  // Arrays start with a left bracket
1986
- if (this.check(AbilityDSLToken.LBRACKET)) {
1987
- this.advance();
2555
+ if (this.stream.check(TokenTypes.LBRACKET)) {
2556
+ this.stream.next();
1988
2557
  return this.parseArray();
1989
2558
  }
1990
2559
  // Ensure we are not about to read a structural token as a value.
1991
- const token = this.peek();
1992
- if (token.code === AbilityDSLToken.ALL ||
1993
- token.code === AbilityDSLToken.ANY ||
1994
- token.code === AbilityDSLToken.EFFECT) {
1995
- 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);
1996
2565
  }
1997
- this.advance();
2566
+ this.stream.next();
1998
2567
  // CHECK THIS SWITCH COMPARE
1999
- switch (token.code) {
2000
- case AbilityDSLToken.STRING:
2568
+ switch (token.type) {
2569
+ case TokenTypes.STRING:
2001
2570
  return token.value;
2002
- case AbilityDSLToken.NUMBER:
2571
+ case TokenTypes.NUMBER:
2003
2572
  return Number(token.value);
2004
- case AbilityDSLToken.BOOLEAN:
2573
+ case TokenTypes.BOOLEAN:
2005
2574
  return token.value === 'true';
2006
- case AbilityDSLToken.NULL:
2575
+ case TokenTypes.NULL:
2007
2576
  return null;
2008
- case AbilityDSLToken.IDENTIFIER:
2577
+ case TokenTypes.IDENTIFIER:
2009
2578
  return token.value;
2010
2579
  default: {
2011
- 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
+ ]);
2012
2583
  }
2013
2584
  }
2014
2585
  }
@@ -2019,11 +2590,11 @@ class AbilityDSLParser {
2019
2590
  parseArray() {
2020
2591
  const arr = [];
2021
2592
  // Handle empty array
2022
- if (this.check(AbilityDSLToken.RBRACKET)) {
2023
- this.advance();
2593
+ if (this.stream.check(TokenTypes.RBRACKET)) {
2594
+ this.stream.next();
2024
2595
  return arr;
2025
2596
  }
2026
- while (!this.isAtEnd() && !this.check(AbilityDSLToken.RBRACKET)) {
2597
+ while (!this.stream.eof() && !this.stream.check(TokenTypes.RBRACKET)) {
2027
2598
  const value = this.parseValue();
2028
2599
  // Flatten nested arrays if they appear (though grammar doesn't currently allow nesting).
2029
2600
  if (Array.isArray(value)) {
@@ -2036,155 +2607,359 @@ class AbilityDSLParser {
2036
2607
  }
2037
2608
  else if (value === null) {
2038
2609
  // Null is allowed in arrays? Currently, we throw.
2039
- this.syntaxError('Unexpected null in array', this.peek());
2610
+ this.stream.syntaxError('Unexpected null in array', this.stream.peek());
2040
2611
  }
2041
2612
  // Optional comma between elements
2042
- if (this.check(AbilityDSLToken.COMMA)) {
2043
- this.advance();
2613
+ if (this.stream.check(TokenTypes.COMMA)) {
2614
+ this.stream.next();
2044
2615
  }
2045
2616
  }
2046
- this.consume(AbilityDSLToken.RBRACKET, 'Expected "]"');
2617
+ this.stream.expect(TokenTypes.RBRACKET, 'Expected "]"');
2047
2618
  return arr;
2048
2619
  }
2049
2620
  // -------------------------------------------------------------------------
2050
- // #region Annotations and comments
2621
+ // #region comments
2051
2622
  // -------------------------------------------------------------------------
2052
2623
  consumeLeadingComments() {
2053
- while (this.check(AbilityDSLToken.COMMENT)) {
2054
- const token = this.advance();
2055
- this.processCommentToken(token);
2056
- }
2057
- }
2058
- processCommentToken(token) {
2059
- const text = token.value.trim();
2060
- if (text.startsWith('@name ')) {
2061
- this.annotationBuffer.name = text.slice(6).trim();
2062
- }
2063
- if (text.startsWith('@description ')) {
2064
- this.annotationBuffer.description = text.slice(13).trim();
2065
- }
2066
- }
2067
- takeAnnotations() {
2068
- const meta = { ...this.annotationBuffer };
2069
- this.annotationBuffer = {
2070
- name: null,
2071
- description: null,
2072
- };
2073
- return meta;
2074
- }
2075
- // -------------------------------------------------------------------------
2076
- // #region Errors
2077
- // -------------------------------------------------------------------------
2078
- syntaxError(details, token, expected) {
2079
- const lines = this.dsl.split(/\r?\n/);
2080
- const lineIdx = token.line - 1;
2081
- const lineBefore = lineIdx > 0 ? lines[lineIdx - 1] : '';
2082
- const current = lines[lineIdx];
2083
- const linesAfter = lineIdx + 1 < lines.length ? lines[lineIdx + 1] : '';
2084
- const wave = ' '.repeat(Math.max(0, token.column - 1)) + '~'.repeat(token.value.length);
2085
- const lineNumWidth = String(token.line + 1).length;
2086
- const num = (n) => String(n).padStart(lineNumWidth, ' ');
2087
- let context = '';
2088
- if (lineBefore.trim() !== '') {
2089
- context += `${num(token.line - 1)} | ${lineBefore}\n`;
2090
- }
2091
- context += `${num(token.line)} | ${current}\n`;
2092
- context += `${' '.repeat(lineNumWidth)} | ${wave}\n`;
2093
- if (linesAfter.trim() !== '') {
2094
- context += `${num(token.line + 1)} | ${linesAfter}`;
2095
- }
2096
- let finalDetails = details;
2097
- if (expected && expected?.length > 0) {
2098
- const actual = token.value;
2099
- const suggestion = this.suggest(actual, expected);
2100
- const detailsMsg = `${details}\nDetails: Unexpected value token \`${actual}\``;
2101
- 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
+ }
2102
2651
  }
2103
- throw new AbilityDSLSyntaxError(token.line, token.column, context + '\n', finalDetails);
2104
2652
  }
2105
- suggest(actual, expectedTypes) {
2106
- const candidates = [];
2107
- for (const type of expectedTypes) {
2108
- candidates.push(type);
2109
- }
2110
- const uniqueCandidates = [...new Set(candidates)];
2111
- let best = null;
2112
- let bestDist = 3;
2113
- for (const candidate of uniqueCandidates) {
2114
- const d = this.levenshteinDistance(actual.toLowerCase(), candidate.toLowerCase());
2115
- if (d < bestDist) {
2116
- bestDist = d;
2117
- 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);
2118
2680
  }
2119
2681
  }
2120
- return best;
2121
2682
  }
2122
- levenshteinDistance(a, b) {
2123
- const matrix = Array.from({ length: b.length + 1 }, () => Array.from({ length: a.length + 1 }, () => 0));
2124
- for (let i = 0; i <= a.length; i++)
2125
- matrix[0][i] = i;
2126
- for (let j = 0; j <= b.length; j++)
2127
- matrix[j][0] = j;
2128
- for (let j = 1; j <= b.length; j++) {
2129
- for (let i = 1; i <= a.length; i++) {
2130
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
2131
- 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());
2132
2695
  }
2133
2696
  }
2134
- return matrix[b.length][a.length];
2697
+ return ann;
2135
2698
  }
2136
2699
  // -------------------------------------------------------------------------
2137
- // #region Helper / lookahead methods
2700
+ // #region Helpers
2138
2701
  // -------------------------------------------------------------------------
2139
- consumeOneOf(types, message) {
2140
- const token = this.peek();
2141
- for (const t of types) {
2142
- if (token && token.code === t) {
2143
- return this.advance();
2144
- }
2145
- }
2146
- const expected = types.map(t => t).join(', ');
2147
- const actual = token ? token.value : AbilityDSLToken.EOF;
2148
- const suggestion = this.suggest(actual, types);
2149
- const details = `${message}\nDetails: Unexpected token \`${actual}\`, expected one of: ${expected}.`;
2150
- const finalMsg = suggestion ? `${details} Did you mean \`${suggestion}\`?` : details;
2151
- this.syntaxError(finalMsg, token ?? this.tokens[this.tokens.length - 1]);
2702
+ isStartOfPolicy() {
2703
+ return this.stream.check(TokenTypes.EFFECT);
2152
2704
  }
2153
- consume(type, message) {
2154
- const token = this.peek();
2155
- if (token && token.code === type) {
2156
- 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;
2157
2773
  }
2158
- const expected = type;
2159
- const actual = token ? token.value : AbilityDSLToken.EOF;
2160
- const suggestion = this.suggest(actual, [type]);
2161
- const details = `${message}\nDetails: Unexpected token \`${actual}\`, expected "${expected}".`;
2162
- const finalMsg = suggestion ? `${details} Did you mean \`${suggestion}\`?` : details;
2163
- 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;
2164
2776
  }
2165
- check(type) {
2166
- if (this.isAtEnd())
2167
- return false;
2168
- 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;
2169
2799
  }
2170
- isStartOfPolicy() {
2171
- 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;
2172
2829
  }
2173
- isStartOfGroup() {
2174
- 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;
2175
2853
  }
2176
- advance() {
2177
- 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;
2178
2879
  }
2179
- peek() {
2180
- 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;
2181
2909
  }
2182
- isAtEnd() {
2183
- 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;
2184
2960
  }
2185
2961
  }
2186
2962
 
2187
- exports.AbilityCode = AbilityCode;
2188
2963
  exports.AbilityCompare = AbilityCompare;
2189
2964
  exports.AbilityCondition = AbilityCondition;
2190
2965
  exports.AbilityDSLLexer = AbilityDSLLexer;
@@ -2202,7 +2977,21 @@ exports.AbilityPolicy = AbilityPolicy;
2202
2977
  exports.AbilityPolicyEffect = AbilityPolicyEffect;
2203
2978
  exports.AbilityResolver = AbilityResolver;
2204
2979
  exports.AbilityResult = AbilityResult;
2205
- exports.AbilityResultState = AbilityResultState;
2206
2980
  exports.AbilityRule = AbilityRule;
2207
2981
  exports.AbilityRuleSet = AbilityRuleSet;
2982
+ exports.AbilityStrategy = AbilityStrategy;
2208
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;