@f-o-t/rules-engine 2.0.2 → 3.0.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/CHANGELOG.md +168 -0
- package/LICENSE.md +16 -4
- package/README.md +106 -23
- package/__tests__/builder.test.ts +363 -0
- package/__tests__/cache.test.ts +130 -0
- package/__tests__/config.test.ts +35 -0
- package/__tests__/engine.test.ts +1213 -0
- package/__tests__/evaluate.test.ts +339 -0
- package/__tests__/exports.test.ts +30 -0
- package/__tests__/filter-sort.test.ts +303 -0
- package/__tests__/integration.test.ts +419 -0
- package/__tests__/money-integration.test.ts +149 -0
- package/__tests__/validation.test.ts +862 -0
- package/biome.json +39 -0
- package/docs/MIGRATION-v3.md +118 -0
- package/fot.config.ts +5 -0
- package/package.json +31 -67
- package/src/analyzer/analysis.ts +401 -0
- package/src/builder/conditions.ts +321 -0
- package/src/builder/rule.ts +192 -0
- package/src/cache/cache.ts +135 -0
- package/src/cache/noop.ts +20 -0
- package/src/core/evaluate.ts +185 -0
- package/src/core/filter.ts +85 -0
- package/src/core/group.ts +103 -0
- package/src/core/sort.ts +90 -0
- package/src/engine/engine.ts +462 -0
- package/src/engine/hooks.ts +235 -0
- package/src/engine/state.ts +322 -0
- package/src/index.ts +303 -0
- package/src/optimizer/index-builder.ts +381 -0
- package/src/serialization/serializer.ts +408 -0
- package/src/simulation/simulator.ts +359 -0
- package/src/types/config.ts +184 -0
- package/src/types/consequence.ts +38 -0
- package/src/types/evaluation.ts +87 -0
- package/src/types/rule.ts +112 -0
- package/src/types/state.ts +116 -0
- package/src/utils/conditions.ts +108 -0
- package/src/utils/hash.ts +30 -0
- package/src/utils/id.ts +6 -0
- package/src/utils/time.ts +42 -0
- package/src/validation/conflicts.ts +440 -0
- package/src/validation/integrity.ts +473 -0
- package/src/validation/schema.ts +386 -0
- package/src/versioning/version-store.ts +337 -0
- package/tsconfig.json +29 -0
- package/dist/index.cjs +0 -3088
- package/dist/index.d.cts +0 -1173
- package/dist/index.d.ts +0 -1173
- package/dist/index.js +0 -3072
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Condition,
|
|
3
|
+
type ConditionGroup,
|
|
4
|
+
isConditionGroup,
|
|
5
|
+
} from "@f-o-t/condition-evaluator";
|
|
6
|
+
import type {
|
|
7
|
+
ConsequenceDefinitions,
|
|
8
|
+
DefaultConsequences,
|
|
9
|
+
} from "../types/consequence";
|
|
10
|
+
import type { Rule } from "../types/rule";
|
|
11
|
+
|
|
12
|
+
export type ConflictType =
|
|
13
|
+
| "DUPLICATE_ID"
|
|
14
|
+
| "DUPLICATE_CONDITIONS"
|
|
15
|
+
| "OVERLAPPING_CONDITIONS"
|
|
16
|
+
| "CONTRADICTORY_CONSEQUENCES"
|
|
17
|
+
| "PRIORITY_COLLISION"
|
|
18
|
+
| "UNREACHABLE_RULE";
|
|
19
|
+
|
|
20
|
+
export type Conflict<
|
|
21
|
+
TContext = unknown,
|
|
22
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
23
|
+
> = {
|
|
24
|
+
readonly type: ConflictType;
|
|
25
|
+
readonly severity: "error" | "warning" | "info";
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly ruleIds: ReadonlyArray<string>;
|
|
28
|
+
readonly rules: ReadonlyArray<Rule<TContext, TConsequences>>;
|
|
29
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ConflictDetectionOptions = {
|
|
33
|
+
readonly checkDuplicateIds?: boolean;
|
|
34
|
+
readonly checkDuplicateConditions?: boolean;
|
|
35
|
+
readonly checkOverlappingConditions?: boolean;
|
|
36
|
+
readonly checkPriorityCollisions?: boolean;
|
|
37
|
+
readonly checkUnreachableRules?: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const DEFAULT_OPTIONS: ConflictDetectionOptions = {
|
|
41
|
+
checkDuplicateIds: true,
|
|
42
|
+
checkDuplicateConditions: true,
|
|
43
|
+
checkOverlappingConditions: true,
|
|
44
|
+
checkPriorityCollisions: true,
|
|
45
|
+
checkUnreachableRules: true,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const collectConditionFields = (
|
|
49
|
+
condition: Condition | ConditionGroup,
|
|
50
|
+
): Set<string> => {
|
|
51
|
+
const fields = new Set<string>();
|
|
52
|
+
|
|
53
|
+
if (isConditionGroup(condition)) {
|
|
54
|
+
for (const child of condition.conditions) {
|
|
55
|
+
const childFields = collectConditionFields(
|
|
56
|
+
child as Condition | ConditionGroup,
|
|
57
|
+
);
|
|
58
|
+
for (const field of childFields) {
|
|
59
|
+
fields.add(field);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
fields.add(condition.field);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return fields;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const hashConditionGroup = (condition: ConditionGroup): string => {
|
|
70
|
+
const serialize = (c: Condition | ConditionGroup): string => {
|
|
71
|
+
if (isConditionGroup(c)) {
|
|
72
|
+
const sortedConditions = [...c.conditions]
|
|
73
|
+
.map((child) => serialize(child as Condition | ConditionGroup))
|
|
74
|
+
.sort();
|
|
75
|
+
return `GROUP:${c.operator}:[${sortedConditions.join(",")}]`;
|
|
76
|
+
}
|
|
77
|
+
return `COND:${c.type}:${c.field}:${c.operator}:${JSON.stringify(c.value)}`;
|
|
78
|
+
};
|
|
79
|
+
return serialize(condition);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const getConditionOperatorValues = (
|
|
83
|
+
condition: ConditionGroup,
|
|
84
|
+
field: string,
|
|
85
|
+
): Array<{ operator: string; value: unknown }> => {
|
|
86
|
+
const results: Array<{ operator: string; value: unknown }> = [];
|
|
87
|
+
|
|
88
|
+
const traverse = (c: Condition | ConditionGroup) => {
|
|
89
|
+
if (isConditionGroup(c)) {
|
|
90
|
+
for (const child of c.conditions) {
|
|
91
|
+
traverse(child as Condition | ConditionGroup);
|
|
92
|
+
}
|
|
93
|
+
} else if (c.field === field) {
|
|
94
|
+
results.push({ operator: c.operator, value: c.value });
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
traverse(condition);
|
|
99
|
+
return results;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const areConditionsOverlapping = (
|
|
103
|
+
conditions1: ConditionGroup,
|
|
104
|
+
conditions2: ConditionGroup,
|
|
105
|
+
): boolean => {
|
|
106
|
+
const fields1 = collectConditionFields(conditions1);
|
|
107
|
+
const fields2 = collectConditionFields(conditions2);
|
|
108
|
+
|
|
109
|
+
const commonFields = new Set([...fields1].filter((f) => fields2.has(f)));
|
|
110
|
+
if (commonFields.size === 0) return false;
|
|
111
|
+
|
|
112
|
+
for (const field of commonFields) {
|
|
113
|
+
const ops1 = getConditionOperatorValues(conditions1, field);
|
|
114
|
+
const ops2 = getConditionOperatorValues(conditions2, field);
|
|
115
|
+
|
|
116
|
+
for (const op1 of ops1) {
|
|
117
|
+
for (const op2 of ops2) {
|
|
118
|
+
if (op1.operator === op2.operator && op1.value === op2.value) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
(op1.operator === "gt" && op2.operator === "lt") ||
|
|
124
|
+
(op1.operator === "lt" && op2.operator === "gt") ||
|
|
125
|
+
(op1.operator === "gte" && op2.operator === "lte") ||
|
|
126
|
+
(op1.operator === "lte" && op2.operator === "gte")
|
|
127
|
+
) {
|
|
128
|
+
const val1 = op1.value as number;
|
|
129
|
+
const val2 = op2.value as number;
|
|
130
|
+
if (typeof val1 === "number" && typeof val2 === "number") {
|
|
131
|
+
if (op1.operator === "gt" && op2.operator === "lt") {
|
|
132
|
+
if (val1 < val2) return true;
|
|
133
|
+
}
|
|
134
|
+
if (op1.operator === "lt" && op2.operator === "gt") {
|
|
135
|
+
if (val1 > val2) return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const findDuplicateIds = <
|
|
147
|
+
TContext = unknown,
|
|
148
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
149
|
+
>(
|
|
150
|
+
rules: ReadonlyArray<Rule<TContext, TConsequences>>,
|
|
151
|
+
): ReadonlyArray<Conflict<TContext, TConsequences>> => {
|
|
152
|
+
const conflicts: Conflict<TContext, TConsequences>[] = [];
|
|
153
|
+
const idMap = new Map<string, Rule<TContext, TConsequences>[]>();
|
|
154
|
+
|
|
155
|
+
for (const rule of rules) {
|
|
156
|
+
const existing = idMap.get(rule.id) ?? [];
|
|
157
|
+
existing.push(rule);
|
|
158
|
+
idMap.set(rule.id, existing);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const [id, duplicates] of idMap) {
|
|
162
|
+
if (duplicates.length > 1) {
|
|
163
|
+
conflicts.push({
|
|
164
|
+
type: "DUPLICATE_ID",
|
|
165
|
+
severity: "error",
|
|
166
|
+
message: `Multiple rules share the same ID: ${id}`,
|
|
167
|
+
ruleIds: duplicates.map((r) => r.id),
|
|
168
|
+
rules: duplicates,
|
|
169
|
+
details: { duplicateId: id, count: duplicates.length },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return conflicts;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const findDuplicateConditions = <
|
|
178
|
+
TContext = unknown,
|
|
179
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
180
|
+
>(
|
|
181
|
+
rules: ReadonlyArray<Rule<TContext, TConsequences>>,
|
|
182
|
+
): ReadonlyArray<Conflict<TContext, TConsequences>> => {
|
|
183
|
+
const conflicts: Conflict<TContext, TConsequences>[] = [];
|
|
184
|
+
const hashMap = new Map<string, Rule<TContext, TConsequences>[]>();
|
|
185
|
+
|
|
186
|
+
for (const rule of rules) {
|
|
187
|
+
const hash = hashConditionGroup(rule.conditions);
|
|
188
|
+
const existing = hashMap.get(hash) ?? [];
|
|
189
|
+
existing.push(rule);
|
|
190
|
+
hashMap.set(hash, existing);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const [hash, duplicates] of hashMap) {
|
|
194
|
+
if (duplicates.length > 1) {
|
|
195
|
+
conflicts.push({
|
|
196
|
+
type: "DUPLICATE_CONDITIONS",
|
|
197
|
+
severity: "warning",
|
|
198
|
+
message: `Multiple rules have identical conditions: ${duplicates.map((r) => r.name).join(", ")}`,
|
|
199
|
+
ruleIds: duplicates.map((r) => r.id),
|
|
200
|
+
rules: duplicates,
|
|
201
|
+
details: { conditionHash: hash, count: duplicates.length },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return conflicts;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const findOverlappingConditions = <
|
|
210
|
+
TContext = unknown,
|
|
211
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
212
|
+
>(
|
|
213
|
+
rules: ReadonlyArray<Rule<TContext, TConsequences>>,
|
|
214
|
+
): ReadonlyArray<Conflict<TContext, TConsequences>> => {
|
|
215
|
+
const conflicts: Conflict<TContext, TConsequences>[] = [];
|
|
216
|
+
const checked = new Set<string>();
|
|
217
|
+
|
|
218
|
+
for (let i = 0; i < rules.length; i++) {
|
|
219
|
+
for (let j = i + 1; j < rules.length; j++) {
|
|
220
|
+
const rule1 = rules[i];
|
|
221
|
+
const rule2 = rules[j];
|
|
222
|
+
if (!rule1 || !rule2) continue;
|
|
223
|
+
const key = [rule1.id, rule2.id].sort().join(":");
|
|
224
|
+
|
|
225
|
+
if (checked.has(key)) continue;
|
|
226
|
+
checked.add(key);
|
|
227
|
+
|
|
228
|
+
if (areConditionsOverlapping(rule1.conditions, rule2.conditions)) {
|
|
229
|
+
conflicts.push({
|
|
230
|
+
type: "OVERLAPPING_CONDITIONS",
|
|
231
|
+
severity: "info",
|
|
232
|
+
message: `Rules "${rule1.name}" and "${rule2.name}" have overlapping conditions`,
|
|
233
|
+
ruleIds: [rule1.id, rule2.id],
|
|
234
|
+
rules: [rule1, rule2],
|
|
235
|
+
details: {
|
|
236
|
+
rule1Fields: [...collectConditionFields(rule1.conditions)],
|
|
237
|
+
rule2Fields: [...collectConditionFields(rule2.conditions)],
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return conflicts;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const findPriorityCollisions = <
|
|
248
|
+
TContext = unknown,
|
|
249
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
250
|
+
>(
|
|
251
|
+
rules: ReadonlyArray<Rule<TContext, TConsequences>>,
|
|
252
|
+
): ReadonlyArray<Conflict<TContext, TConsequences>> => {
|
|
253
|
+
const conflicts: Conflict<TContext, TConsequences>[] = [];
|
|
254
|
+
const priorityMap = new Map<number, Rule<TContext, TConsequences>[]>();
|
|
255
|
+
|
|
256
|
+
for (const rule of rules) {
|
|
257
|
+
const existing = priorityMap.get(rule.priority) ?? [];
|
|
258
|
+
existing.push(rule);
|
|
259
|
+
priorityMap.set(rule.priority, existing);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const [priority, rulesWithPriority] of priorityMap) {
|
|
263
|
+
if (rulesWithPriority.length > 1) {
|
|
264
|
+
const overlappingPairs: Set<string> = new Set();
|
|
265
|
+
|
|
266
|
+
for (let i = 0; i < rulesWithPriority.length; i++) {
|
|
267
|
+
for (let j = i + 1; j < rulesWithPriority.length; j++) {
|
|
268
|
+
const r1 = rulesWithPriority[i];
|
|
269
|
+
const r2 = rulesWithPriority[j];
|
|
270
|
+
if (!r1 || !r2) continue;
|
|
271
|
+
if (areConditionsOverlapping(r1.conditions, r2.conditions)) {
|
|
272
|
+
overlappingPairs.add(r1.id);
|
|
273
|
+
overlappingPairs.add(r2.id);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (overlappingPairs.size > 1) {
|
|
279
|
+
const overlapping = rulesWithPriority.filter((r) =>
|
|
280
|
+
overlappingPairs.has(r.id),
|
|
281
|
+
);
|
|
282
|
+
conflicts.push({
|
|
283
|
+
type: "PRIORITY_COLLISION",
|
|
284
|
+
severity: "warning",
|
|
285
|
+
message: `Multiple overlapping rules share priority ${priority}: ${overlapping.map((r) => r.name).join(", ")}`,
|
|
286
|
+
ruleIds: overlapping.map((r) => r.id),
|
|
287
|
+
rules: overlapping,
|
|
288
|
+
details: { priority, count: overlapping.length },
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return conflicts;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const findUnreachableRules = <
|
|
298
|
+
TContext = unknown,
|
|
299
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
300
|
+
>(
|
|
301
|
+
rules: ReadonlyArray<Rule<TContext, TConsequences>>,
|
|
302
|
+
): ReadonlyArray<Conflict<TContext, TConsequences>> => {
|
|
303
|
+
const conflicts: Conflict<TContext, TConsequences>[] = [];
|
|
304
|
+
const sortedRules = [...rules].sort((a, b) => b.priority - a.priority);
|
|
305
|
+
|
|
306
|
+
for (let i = 0; i < sortedRules.length; i++) {
|
|
307
|
+
const rule = sortedRules[i];
|
|
308
|
+
if (!rule) continue;
|
|
309
|
+
|
|
310
|
+
if (!rule.enabled) continue;
|
|
311
|
+
|
|
312
|
+
for (let j = 0; j < i; j++) {
|
|
313
|
+
const higherPriorityRule = sortedRules[j];
|
|
314
|
+
if (!higherPriorityRule) continue;
|
|
315
|
+
|
|
316
|
+
if (!higherPriorityRule.enabled || !higherPriorityRule.stopOnMatch) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (
|
|
321
|
+
hashConditionGroup(rule.conditions) ===
|
|
322
|
+
hashConditionGroup(higherPriorityRule.conditions)
|
|
323
|
+
) {
|
|
324
|
+
conflicts.push({
|
|
325
|
+
type: "UNREACHABLE_RULE",
|
|
326
|
+
severity: "warning",
|
|
327
|
+
message: `Rule "${rule.name}" may be unreachable because rule "${higherPriorityRule.name}" has higher priority and stops on match`,
|
|
328
|
+
ruleIds: [rule.id, higherPriorityRule.id],
|
|
329
|
+
rules: [rule, higherPriorityRule],
|
|
330
|
+
details: {
|
|
331
|
+
unreachableRule: rule.id,
|
|
332
|
+
blockingRule: higherPriorityRule.id,
|
|
333
|
+
priorityDifference:
|
|
334
|
+
higherPriorityRule.priority - rule.priority,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return conflicts;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export const detectConflicts = <
|
|
345
|
+
TContext = unknown,
|
|
346
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
347
|
+
>(
|
|
348
|
+
rules: ReadonlyArray<Rule<TContext, TConsequences>>,
|
|
349
|
+
options: ConflictDetectionOptions = {},
|
|
350
|
+
): ReadonlyArray<Conflict<TContext, TConsequences>> => {
|
|
351
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
352
|
+
const conflicts: Conflict<TContext, TConsequences>[] = [];
|
|
353
|
+
|
|
354
|
+
if (opts.checkDuplicateIds) {
|
|
355
|
+
conflicts.push(...findDuplicateIds(rules));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (opts.checkDuplicateConditions) {
|
|
359
|
+
conflicts.push(...findDuplicateConditions(rules));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (opts.checkOverlappingConditions) {
|
|
363
|
+
conflicts.push(...findOverlappingConditions(rules));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (opts.checkPriorityCollisions) {
|
|
367
|
+
conflicts.push(...findPriorityCollisions(rules));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (opts.checkUnreachableRules) {
|
|
371
|
+
conflicts.push(...findUnreachableRules(rules));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return conflicts;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
export const hasConflicts = <
|
|
378
|
+
TContext = unknown,
|
|
379
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
380
|
+
>(
|
|
381
|
+
rules: ReadonlyArray<Rule<TContext, TConsequences>>,
|
|
382
|
+
options: ConflictDetectionOptions = {},
|
|
383
|
+
): boolean => {
|
|
384
|
+
return detectConflicts(rules, options).length > 0;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
export const hasErrors = <
|
|
388
|
+
TContext = unknown,
|
|
389
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
390
|
+
>(
|
|
391
|
+
rules: ReadonlyArray<Rule<TContext, TConsequences>>,
|
|
392
|
+
options: ConflictDetectionOptions = {},
|
|
393
|
+
): boolean => {
|
|
394
|
+
return detectConflicts(rules, options).some((c) => c.severity === "error");
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
export const getConflictsByType = <
|
|
398
|
+
TContext = unknown,
|
|
399
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
400
|
+
>(
|
|
401
|
+
conflicts: ReadonlyArray<Conflict<TContext, TConsequences>>,
|
|
402
|
+
type: ConflictType,
|
|
403
|
+
): ReadonlyArray<Conflict<TContext, TConsequences>> => {
|
|
404
|
+
return conflicts.filter((c) => c.type === type);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
export const getConflictsBySeverity = <
|
|
408
|
+
TContext = unknown,
|
|
409
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
410
|
+
>(
|
|
411
|
+
conflicts: ReadonlyArray<Conflict<TContext, TConsequences>>,
|
|
412
|
+
severity: "error" | "warning" | "info",
|
|
413
|
+
): ReadonlyArray<Conflict<TContext, TConsequences>> => {
|
|
414
|
+
return conflicts.filter((c) => c.severity === severity);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
export const formatConflicts = <
|
|
418
|
+
TContext = unknown,
|
|
419
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
420
|
+
>(
|
|
421
|
+
conflicts: ReadonlyArray<Conflict<TContext, TConsequences>>,
|
|
422
|
+
): string => {
|
|
423
|
+
if (conflicts.length === 0) {
|
|
424
|
+
return "No conflicts detected";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const lines: string[] = [`Found ${conflicts.length} conflict(s):`];
|
|
428
|
+
|
|
429
|
+
for (const conflict of conflicts) {
|
|
430
|
+
const severityIcon =
|
|
431
|
+
conflict.severity === "error"
|
|
432
|
+
? "[ERROR]"
|
|
433
|
+
: conflict.severity === "warning"
|
|
434
|
+
? "[WARN]"
|
|
435
|
+
: "[INFO]";
|
|
436
|
+
lines.push(` ${severityIcon} ${conflict.type}: ${conflict.message}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return lines.join("\n");
|
|
440
|
+
};
|