@plures/praxis 1.4.0 → 2.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/dist/browser/{chunk-N63K4KWS.js → chunk-4IRUGWR3.js} +1 -1
- package/dist/browser/chunk-6MVRT7CK.js +363 -0
- package/dist/browser/chunk-6SJ44Q64.js +473 -0
- package/dist/browser/chunk-BQOYZBWA.js +282 -0
- package/dist/browser/chunk-IG5BJ2MT.js +91 -0
- package/dist/browser/{chunk-MJK3IYTJ.js → chunk-JZDJU2DO.js} +4 -84
- package/dist/browser/chunk-ZEW4LJAJ.js +353 -0
- package/dist/browser/{engine-YIEGSX7U.js → engine-3B5WJPGT.js} +2 -1
- package/dist/browser/expectations/index.d.ts +180 -0
- package/dist/browser/expectations/index.js +14 -0
- package/dist/browser/factory/index.d.ts +150 -0
- package/dist/browser/factory/index.js +15 -0
- package/dist/browser/index.d.ts +277 -3
- package/dist/browser/index.js +425 -60
- package/dist/browser/integrations/svelte.d.ts +4 -2
- package/dist/browser/integrations/svelte.js +3 -2
- package/dist/browser/project/index.d.ts +177 -0
- package/dist/browser/project/index.js +19 -0
- package/dist/browser/reactive-engine.svelte-BwWadvAW.d.ts +224 -0
- package/dist/browser/rule-result-DcXWe9tn.d.ts +206 -0
- package/dist/browser/rules-BaWMqxuG.d.ts +277 -0
- package/dist/browser/unified/index.d.ts +239 -0
- package/dist/browser/unified/index.js +20 -0
- package/dist/node/chunk-6MVRT7CK.js +363 -0
- package/dist/node/chunk-AZLNISFI.js +1690 -0
- package/dist/node/chunk-IG5BJ2MT.js +91 -0
- package/dist/node/{chunk-KMJWAFZV.js → chunk-JZDJU2DO.js} +4 -89
- package/dist/node/{chunk-7M3HV4XR.js → chunk-WFRHXZBP.js} +3 -3
- package/dist/node/cli/index.cjs +48 -0
- package/dist/node/cli/index.js +2 -2
- package/dist/node/{engine-FEN5IYZ5.js → engine-VFHCIEM4.js} +2 -1
- package/dist/node/index.cjs +2114 -0
- package/dist/node/index.d.cts +964 -280
- package/dist/node/index.d.ts +964 -280
- package/dist/node/index.js +575 -10
- package/dist/node/integrations/svelte.d.cts +3 -2
- package/dist/node/integrations/svelte.d.ts +3 -2
- package/dist/node/integrations/svelte.js +3 -2
- package/dist/node/{reactive-engine.svelte-DekxqFu0.d.ts → reactive-engine.svelte-BBZLMzus.d.ts} +3 -79
- package/dist/node/{reactive-engine.svelte-Cg0Yc2Hs.d.cts → reactive-engine.svelte-Cbq_V20o.d.cts} +3 -79
- package/dist/node/rule-result-B9GMivAn.d.cts +80 -0
- package/dist/node/rule-result-Bo3sFMmN.d.ts +80 -0
- package/dist/node/{server-SYZPDULV.js → server-FKLVY57V.js} +4 -2
- package/dist/node/unified/index.cjs +484 -0
- package/dist/node/unified/index.d.cts +240 -0
- package/dist/node/unified/index.d.ts +240 -0
- package/dist/node/unified/index.js +21 -0
- package/dist/node/{validate-TQGVIG7G.js → validate-BY7JNY7H.js} +2 -1
- package/package.json +38 -11
- package/src/__tests__/chronos-project.test.ts +799 -0
- package/src/__tests__/decision-ledger.test.ts +857 -402
- package/src/chronos/diff.ts +336 -0
- package/src/chronos/hooks.ts +227 -0
- package/src/chronos/index.ts +83 -0
- package/src/chronos/project-chronicle.ts +198 -0
- package/src/chronos/timeline.ts +152 -0
- package/src/decision-ledger/analyzer-types.ts +280 -0
- package/src/decision-ledger/analyzer.ts +518 -0
- package/src/decision-ledger/contract-verification.ts +456 -0
- package/src/decision-ledger/derivation.ts +158 -0
- package/src/decision-ledger/index.ts +59 -0
- package/src/decision-ledger/report.ts +378 -0
- package/src/decision-ledger/suggestions.ts +287 -0
- package/src/index.browser.ts +103 -0
- package/src/index.ts +98 -0
- package/src/unified/__tests__/unified.test.ts +396 -0
- package/src/unified/core.ts +517 -0
- package/src/unified/index.ts +32 -0
- package/src/unified/rules.ts +66 -0
- package/src/unified/types.ts +148 -0
- package/dist/browser/reactive-engine.svelte-DjynI82A.d.ts +0 -688
- package/dist/node/chunk-FWOXU4MM.js +0 -487
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision Ledger — Graph Analysis Engine
|
|
3
|
+
*
|
|
4
|
+
* Builds the fact dependency graph and analyzes the rule registry
|
|
5
|
+
* for dead rules, unreachable states, shadowed rules, contradictions,
|
|
6
|
+
* and gaps in behavioral expectations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PraxisRegistry, RuleDescriptor } from '../core/rules.js';
|
|
10
|
+
import type { PraxisFact, PraxisEvent } from '../core/protocol.js';
|
|
11
|
+
import { RuleResult } from '../core/rule-result.js';
|
|
12
|
+
import type {
|
|
13
|
+
DependencyGraph,
|
|
14
|
+
FactNode,
|
|
15
|
+
DependencyEdge,
|
|
16
|
+
DeadRule,
|
|
17
|
+
UnreachableState,
|
|
18
|
+
ShadowedRule,
|
|
19
|
+
Contradiction,
|
|
20
|
+
Gap,
|
|
21
|
+
} from './analyzer-types.js';
|
|
22
|
+
import type { ExpectationSet } from '../expectations/expectations.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the fact dependency graph from a registry.
|
|
26
|
+
*
|
|
27
|
+
* This runs each rule with synthetic probe events to discover which facts
|
|
28
|
+
* it reads from state and which facts it produces. For static analysis we
|
|
29
|
+
* inspect rule metadata, contracts, event types, and probe execution.
|
|
30
|
+
*/
|
|
31
|
+
export function analyzeDependencyGraph<TContext = unknown>(
|
|
32
|
+
registry: PraxisRegistry<TContext>,
|
|
33
|
+
): DependencyGraph {
|
|
34
|
+
const facts = new Map<string, FactNode>();
|
|
35
|
+
const edges: DependencyEdge[] = [];
|
|
36
|
+
const producers = new Map<string, string[]>(); // ruleId → fact tags
|
|
37
|
+
const consumers = new Map<string, string[]>(); // ruleId → fact tags
|
|
38
|
+
|
|
39
|
+
const rules = registry.getAllRules();
|
|
40
|
+
|
|
41
|
+
for (const rule of rules) {
|
|
42
|
+
const ruleId = rule.id;
|
|
43
|
+
const produced: string[] = [];
|
|
44
|
+
const consumed: string[] = [];
|
|
45
|
+
|
|
46
|
+
// Strategy 1: Probe execution with synthetic state
|
|
47
|
+
const probeFacts = probeRuleExecution(rule);
|
|
48
|
+
for (const tag of probeFacts.produced) {
|
|
49
|
+
produced.push(tag);
|
|
50
|
+
}
|
|
51
|
+
for (const tag of probeFacts.consumed) {
|
|
52
|
+
consumed.push(tag);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Strategy 2: Contract analysis — examples declare expected facts
|
|
56
|
+
if (rule.contract) {
|
|
57
|
+
for (const example of rule.contract.examples) {
|
|
58
|
+
// Parse "then" for fact tags (convention: "emit factTag" or "produces factTag")
|
|
59
|
+
const thenTags = extractFactTagsFromText(example.then);
|
|
60
|
+
for (const tag of thenTags) {
|
|
61
|
+
if (!produced.includes(tag)) produced.push(tag);
|
|
62
|
+
}
|
|
63
|
+
// Parse "given" for fact tags the rule reads
|
|
64
|
+
const givenTags = extractFactTagsFromText(example.given);
|
|
65
|
+
for (const tag of givenTags) {
|
|
66
|
+
if (!consumed.includes(tag)) consumed.push(tag);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
producers.set(ruleId, produced);
|
|
72
|
+
consumers.set(ruleId, consumed);
|
|
73
|
+
|
|
74
|
+
// Update fact nodes
|
|
75
|
+
for (const tag of produced) {
|
|
76
|
+
const node = getOrCreateFactNode(facts, tag);
|
|
77
|
+
if (!node.producedBy.includes(ruleId)) {
|
|
78
|
+
node.producedBy.push(ruleId);
|
|
79
|
+
}
|
|
80
|
+
edges.push({ from: ruleId, to: tag, type: 'produces' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const tag of consumed) {
|
|
84
|
+
const node = getOrCreateFactNode(facts, tag);
|
|
85
|
+
if (!node.consumedBy.includes(ruleId)) {
|
|
86
|
+
node.consumedBy.push(ruleId);
|
|
87
|
+
}
|
|
88
|
+
edges.push({ from: tag, to: ruleId, type: 'consumes' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { facts, edges, producers, consumers };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Find rules that can never fire given known event types.
|
|
97
|
+
*/
|
|
98
|
+
export function findDeadRules<TContext = unknown>(
|
|
99
|
+
registry: PraxisRegistry<TContext>,
|
|
100
|
+
knownEventTypes: string[],
|
|
101
|
+
): DeadRule[] {
|
|
102
|
+
const dead: DeadRule[] = [];
|
|
103
|
+
const known = new Set(knownEventTypes);
|
|
104
|
+
const rules = registry.getAllRules();
|
|
105
|
+
|
|
106
|
+
for (const rule of rules) {
|
|
107
|
+
if (!rule.eventTypes) continue; // catch-all rules are never dead
|
|
108
|
+
|
|
109
|
+
const required = Array.isArray(rule.eventTypes) ? rule.eventTypes : [rule.eventTypes];
|
|
110
|
+
const hasMatch = required.some(t => known.has(t));
|
|
111
|
+
|
|
112
|
+
if (!hasMatch) {
|
|
113
|
+
dead.push({
|
|
114
|
+
ruleId: rule.id,
|
|
115
|
+
description: rule.description,
|
|
116
|
+
requiredEventTypes: required,
|
|
117
|
+
reason: `Rule requires event types [${required.join(', ')}] but none are in the known event types [${knownEventTypes.join(', ')}]`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return dead;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Find fact combinations that no rule sequence can produce.
|
|
127
|
+
*
|
|
128
|
+
* This checks pairs of facts where each fact can be produced individually
|
|
129
|
+
* but no single rule or chain produces both. This is conservative —
|
|
130
|
+
* if two facts are produced by completely independent rules that never
|
|
131
|
+
* fire together, they form an unreachable state pair.
|
|
132
|
+
*/
|
|
133
|
+
export function findUnreachableStates<TContext = unknown>(
|
|
134
|
+
registry: PraxisRegistry<TContext>,
|
|
135
|
+
): UnreachableState[] {
|
|
136
|
+
const graph = analyzeDependencyGraph(registry);
|
|
137
|
+
const unreachable: UnreachableState[] = [];
|
|
138
|
+
|
|
139
|
+
// Find facts that are consumed but never produced by any rule
|
|
140
|
+
for (const [tag, node] of graph.facts) {
|
|
141
|
+
if (node.producedBy.length === 0 && node.consumedBy.length > 0) {
|
|
142
|
+
unreachable.push({
|
|
143
|
+
factTags: [tag],
|
|
144
|
+
reason: `Fact "${tag}" is consumed by rules [${node.consumedBy.join(', ')}] but never produced by any rule`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Find mutually exclusive fact pairs — produced by rules with
|
|
150
|
+
// conflicting event types (one requires X, other requires Y, no rule handles both)
|
|
151
|
+
const allProducedTags = Array.from(graph.facts.keys()).filter(
|
|
152
|
+
tag => graph.facts.get(tag)!.producedBy.length > 0,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < allProducedTags.length; i++) {
|
|
156
|
+
for (let j = i + 1; j < allProducedTags.length; j++) {
|
|
157
|
+
const tagA = allProducedTags[i];
|
|
158
|
+
const tagB = allProducedTags[j];
|
|
159
|
+
const producersA = graph.facts.get(tagA)!.producedBy;
|
|
160
|
+
const producersB = graph.facts.get(tagB)!.producedBy;
|
|
161
|
+
|
|
162
|
+
// Check if any single rule produces both
|
|
163
|
+
const sharedProducer = producersA.find(p => producersB.includes(p));
|
|
164
|
+
if (sharedProducer) continue; // reachable together
|
|
165
|
+
|
|
166
|
+
// Check if producers share any event types (could fire in same batch)
|
|
167
|
+
const rules = registry.getAllRules();
|
|
168
|
+
const getEventTypes = (ruleId: string): string[] => {
|
|
169
|
+
const rule = rules.find(r => r.id === ruleId);
|
|
170
|
+
if (!rule?.eventTypes) return [];
|
|
171
|
+
return Array.isArray(rule.eventTypes) ? rule.eventTypes : [rule.eventTypes];
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const eventTypesA = new Set(producersA.flatMap(getEventTypes));
|
|
175
|
+
const eventTypesB = new Set(producersB.flatMap(getEventTypes));
|
|
176
|
+
|
|
177
|
+
// If both have event type filters and they don't overlap, these facts
|
|
178
|
+
// can't be produced in the same event batch
|
|
179
|
+
if (
|
|
180
|
+
eventTypesA.size > 0 &&
|
|
181
|
+
eventTypesB.size > 0 &&
|
|
182
|
+
![...eventTypesA].some(t => eventTypesB.has(t))
|
|
183
|
+
) {
|
|
184
|
+
// Only flag if one of the fact's consumers also consumes the other
|
|
185
|
+
const consumersA = graph.facts.get(tagA)!.consumedBy;
|
|
186
|
+
const consumersB = graph.facts.get(tagB)!.consumedBy;
|
|
187
|
+
const sharedConsumer = consumersA.find(c => consumersB.includes(c));
|
|
188
|
+
|
|
189
|
+
if (sharedConsumer) {
|
|
190
|
+
unreachable.push({
|
|
191
|
+
factTags: [tagA, tagB],
|
|
192
|
+
reason: `Facts "${tagA}" and "${tagB}" are both consumed by rule "${sharedConsumer}" but are produced by rules with non-overlapping event types`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return unreachable;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Find rules where another rule with same event types always produces
|
|
204
|
+
* a superset of the facts.
|
|
205
|
+
*/
|
|
206
|
+
export function findShadowedRules<TContext = unknown>(
|
|
207
|
+
registry: PraxisRegistry<TContext>,
|
|
208
|
+
): ShadowedRule[] {
|
|
209
|
+
const shadowed: ShadowedRule[] = [];
|
|
210
|
+
const graph = analyzeDependencyGraph(registry);
|
|
211
|
+
const rules = registry.getAllRules();
|
|
212
|
+
|
|
213
|
+
for (let i = 0; i < rules.length; i++) {
|
|
214
|
+
for (let j = 0; j < rules.length; j++) {
|
|
215
|
+
if (i === j) continue;
|
|
216
|
+
|
|
217
|
+
const ruleA = rules[i];
|
|
218
|
+
const ruleB = rules[j];
|
|
219
|
+
|
|
220
|
+
// Both must have event types and they must overlap
|
|
221
|
+
const typesA = normalizeEventTypes(ruleA.eventTypes);
|
|
222
|
+
const typesB = normalizeEventTypes(ruleB.eventTypes);
|
|
223
|
+
if (typesA.length === 0 || typesB.length === 0) continue;
|
|
224
|
+
|
|
225
|
+
const shared = typesA.filter(t => typesB.includes(t));
|
|
226
|
+
if (shared.length === 0) continue;
|
|
227
|
+
|
|
228
|
+
// Check if ruleB's produced facts are a superset of ruleA's
|
|
229
|
+
const producedA = graph.producers.get(ruleA.id) ?? [];
|
|
230
|
+
const producedB = graph.producers.get(ruleB.id) ?? [];
|
|
231
|
+
if (producedA.length === 0) continue;
|
|
232
|
+
|
|
233
|
+
const isSuperset = producedA.every(tag => producedB.includes(tag));
|
|
234
|
+
const isProperSuperset = isSuperset && producedB.length > producedA.length;
|
|
235
|
+
|
|
236
|
+
if (isProperSuperset) {
|
|
237
|
+
shadowed.push({
|
|
238
|
+
ruleId: ruleA.id,
|
|
239
|
+
shadowedBy: ruleB.id,
|
|
240
|
+
sharedEventTypes: shared,
|
|
241
|
+
reason: `Rule "${ruleB.id}" produces a superset of facts [${producedB.join(', ')}] compared to "${ruleA.id}" [${producedA.join(', ')}] for the same event types [${shared.join(', ')}]`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return shadowed;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Find rules that produce facts with the same tag but potentially conflicting
|
|
252
|
+
* payloads under the same event conditions.
|
|
253
|
+
*/
|
|
254
|
+
export function findContradictions<TContext = unknown>(
|
|
255
|
+
registry: PraxisRegistry<TContext>,
|
|
256
|
+
): Contradiction[] {
|
|
257
|
+
const contradictions: Contradiction[] = [];
|
|
258
|
+
const graph = analyzeDependencyGraph(registry);
|
|
259
|
+
const rules = registry.getAllRules();
|
|
260
|
+
|
|
261
|
+
// Group facts by tag — find tags produced by multiple rules
|
|
262
|
+
for (const [tag, node] of graph.facts) {
|
|
263
|
+
if (node.producedBy.length < 2) continue;
|
|
264
|
+
|
|
265
|
+
// Check pairs of producers
|
|
266
|
+
for (let i = 0; i < node.producedBy.length; i++) {
|
|
267
|
+
for (let j = i + 1; j < node.producedBy.length; j++) {
|
|
268
|
+
const ruleIdA = node.producedBy[i];
|
|
269
|
+
const ruleIdB = node.producedBy[j];
|
|
270
|
+
|
|
271
|
+
const ruleA = rules.find(r => r.id === ruleIdA);
|
|
272
|
+
const ruleB = rules.find(r => r.id === ruleIdB);
|
|
273
|
+
if (!ruleA || !ruleB) continue;
|
|
274
|
+
|
|
275
|
+
// If they respond to the same event types, they could both fire
|
|
276
|
+
const typesA = normalizeEventTypes(ruleA.eventTypes);
|
|
277
|
+
const typesB = normalizeEventTypes(ruleB.eventTypes);
|
|
278
|
+
|
|
279
|
+
// Both catch-all, or shared event types → potential conflict
|
|
280
|
+
const bothCatchAll = typesA.length === 0 && typesB.length === 0;
|
|
281
|
+
const sharedTypes = typesA.filter(t => typesB.includes(t));
|
|
282
|
+
const hasOverlap = sharedTypes.length > 0;
|
|
283
|
+
|
|
284
|
+
if (bothCatchAll || hasOverlap) {
|
|
285
|
+
// Check contract examples for conflicting payloads
|
|
286
|
+
const conflictDetail = checkContractConflict(ruleA, ruleB, tag);
|
|
287
|
+
if (conflictDetail || bothCatchAll || hasOverlap) {
|
|
288
|
+
contradictions.push({
|
|
289
|
+
ruleA: ruleIdA,
|
|
290
|
+
ruleB: ruleIdB,
|
|
291
|
+
conflictingTag: tag,
|
|
292
|
+
reason: conflictDetail ??
|
|
293
|
+
`Rules "${ruleIdA}" and "${ruleIdB}" both produce fact "${tag}" and respond to ${bothCatchAll ? 'all events' : `event types [${sharedTypes.join(', ')}]`}`,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return contradictions;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Find expectations that have no covering rule or only partial coverage.
|
|
306
|
+
*/
|
|
307
|
+
export function findGaps<TContext = unknown>(
|
|
308
|
+
registry: PraxisRegistry<TContext>,
|
|
309
|
+
expectations: ExpectationSet,
|
|
310
|
+
): Gap[] {
|
|
311
|
+
const gaps: Gap[] = [];
|
|
312
|
+
const rules = registry.getAllRules();
|
|
313
|
+
const constraints = registry.getAllConstraints();
|
|
314
|
+
|
|
315
|
+
for (const exp of expectations.expectations) {
|
|
316
|
+
const nameLower = exp.name.toLowerCase();
|
|
317
|
+
const nameParts = nameLower.split(/[-_./\s]+/);
|
|
318
|
+
|
|
319
|
+
// Find related rules by name matching
|
|
320
|
+
const related = rules.filter(r => {
|
|
321
|
+
const idLower = r.id.toLowerCase();
|
|
322
|
+
const descLower = r.description.toLowerCase();
|
|
323
|
+
const behaviorLower = r.contract?.behavior?.toLowerCase() ?? '';
|
|
324
|
+
|
|
325
|
+
// Direct match
|
|
326
|
+
if (idLower.includes(nameLower) || nameLower.includes(idLower)) return true;
|
|
327
|
+
if (descLower.includes(nameLower) || behaviorLower.includes(nameLower)) return true;
|
|
328
|
+
|
|
329
|
+
// Part-based match
|
|
330
|
+
const minParts = Math.min(2, nameParts.length);
|
|
331
|
+
const matches = nameParts.filter(
|
|
332
|
+
part => part.length > 2 && (idLower.includes(part) || descLower.includes(part)),
|
|
333
|
+
);
|
|
334
|
+
return matches.length >= minParts;
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const relatedConstraints = constraints.filter(c => {
|
|
338
|
+
const idLower = c.id.toLowerCase();
|
|
339
|
+
const descLower = c.description.toLowerCase();
|
|
340
|
+
return idLower.includes(nameLower) || nameLower.includes(idLower) || descLower.includes(nameLower);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (related.length === 0 && relatedConstraints.length === 0) {
|
|
344
|
+
gaps.push({
|
|
345
|
+
expectationName: exp.name,
|
|
346
|
+
description: `No rules or constraints found for expectation "${exp.name}"`,
|
|
347
|
+
partialCoverage: [],
|
|
348
|
+
type: 'no-rule',
|
|
349
|
+
});
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Check if related rules have contracts covering the conditions
|
|
354
|
+
const uncoveredConditions = exp.conditions.filter(cond => {
|
|
355
|
+
const condLower = cond.description.toLowerCase();
|
|
356
|
+
return !related.some(r =>
|
|
357
|
+
r.contract?.examples.some(
|
|
358
|
+
ex =>
|
|
359
|
+
ex.given.toLowerCase().includes(condLower) ||
|
|
360
|
+
ex.when.toLowerCase().includes(condLower) ||
|
|
361
|
+
ex.then.toLowerCase().includes(condLower) ||
|
|
362
|
+
condLower.includes(ex.given.toLowerCase()) ||
|
|
363
|
+
condLower.includes(ex.when.toLowerCase()),
|
|
364
|
+
) ||
|
|
365
|
+
r.contract?.invariants.some(
|
|
366
|
+
inv => inv.toLowerCase().includes(condLower) || condLower.includes(inv.toLowerCase()),
|
|
367
|
+
) ||
|
|
368
|
+
r.contract?.behavior.toLowerCase().includes(condLower) ||
|
|
369
|
+
r.description.toLowerCase().includes(condLower),
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
if (uncoveredConditions.length > 0 && uncoveredConditions.length < exp.conditions.length) {
|
|
374
|
+
gaps.push({
|
|
375
|
+
expectationName: exp.name,
|
|
376
|
+
description: `Expectation "${exp.name}" is partially covered. Uncovered conditions: ${uncoveredConditions.map(c => c.description).join('; ')}`,
|
|
377
|
+
partialCoverage: related.map(r => r.id),
|
|
378
|
+
type: 'partial-coverage',
|
|
379
|
+
});
|
|
380
|
+
} else if (uncoveredConditions.length === exp.conditions.length && exp.conditions.length > 0) {
|
|
381
|
+
// All conditions uncovered despite having related rules
|
|
382
|
+
const rulesWithoutContracts = related.filter(r => !r.contract);
|
|
383
|
+
if (rulesWithoutContracts.length > 0) {
|
|
384
|
+
gaps.push({
|
|
385
|
+
expectationName: exp.name,
|
|
386
|
+
description: `Rules related to "${exp.name}" lack contracts: [${rulesWithoutContracts.map(r => r.id).join(', ')}]`,
|
|
387
|
+
partialCoverage: related.map(r => r.id),
|
|
388
|
+
type: 'no-contract',
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return gaps;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
function getOrCreateFactNode(facts: Map<string, FactNode>, tag: string): FactNode {
|
|
400
|
+
let node = facts.get(tag);
|
|
401
|
+
if (!node) {
|
|
402
|
+
node = { tag, producedBy: [], consumedBy: [] };
|
|
403
|
+
facts.set(tag, node);
|
|
404
|
+
}
|
|
405
|
+
return node;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function normalizeEventTypes(eventTypes?: string | string[]): string[] {
|
|
409
|
+
if (!eventTypes) return [];
|
|
410
|
+
return Array.isArray(eventTypes) ? eventTypes : [eventTypes];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Probe a rule's execution with synthetic state to discover produced/consumed facts.
|
|
415
|
+
*/
|
|
416
|
+
function probeRuleExecution<TContext>(
|
|
417
|
+
rule: RuleDescriptor<TContext>,
|
|
418
|
+
): { produced: string[]; consumed: string[] } {
|
|
419
|
+
const produced: string[] = [];
|
|
420
|
+
const consumed: string[] = [];
|
|
421
|
+
|
|
422
|
+
// Build synthetic events from eventTypes
|
|
423
|
+
const eventTypes = normalizeEventTypes(rule.eventTypes);
|
|
424
|
+
const syntheticEvents: PraxisEvent[] = eventTypes.length > 0
|
|
425
|
+
? eventTypes.map(tag => ({ tag, payload: {} }))
|
|
426
|
+
: [{ tag: '__probe__', payload: {} }];
|
|
427
|
+
|
|
428
|
+
const syntheticState = {
|
|
429
|
+
context: {} as TContext,
|
|
430
|
+
facts: [] as PraxisFact[],
|
|
431
|
+
events: syntheticEvents,
|
|
432
|
+
meta: {},
|
|
433
|
+
protocolVersion: '1.0.0',
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
const result = rule.impl(syntheticState, syntheticEvents);
|
|
438
|
+
|
|
439
|
+
if (result instanceof RuleResult) {
|
|
440
|
+
if (result.kind === 'emit') {
|
|
441
|
+
for (const fact of result.facts) {
|
|
442
|
+
if (!produced.includes(fact.tag)) {
|
|
443
|
+
produced.push(fact.tag);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
} else if (result.kind === 'retract') {
|
|
447
|
+
for (const tag of result.retractTags) {
|
|
448
|
+
if (!consumed.includes(tag)) {
|
|
449
|
+
consumed.push(tag);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} else if (Array.isArray(result)) {
|
|
454
|
+
for (const fact of result) {
|
|
455
|
+
if (fact.tag && !produced.includes(fact.tag)) {
|
|
456
|
+
produced.push(fact.tag);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
// Probe failed — rule needs real context. That's fine.
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { produced, consumed };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Extract fact tags from text (contract examples, descriptions).
|
|
469
|
+
* Looks for patterns like: emit "factTag", produces "factTag", fact "factTag"
|
|
470
|
+
*/
|
|
471
|
+
function extractFactTagsFromText(text: string): string[] {
|
|
472
|
+
const tags: string[] = [];
|
|
473
|
+
// Match patterns like: emit factTag, produces factTag, fact factTag
|
|
474
|
+
// Also match dotted identifiers like: sprint.behind, user.loggedIn
|
|
475
|
+
const patterns = [
|
|
476
|
+
/(?:emit|produce|retract|read|consume|fact)\s+['"]?([a-zA-Z][a-zA-Z0-9._-]+)['"]?/gi,
|
|
477
|
+
/['"]([a-zA-Z][a-zA-Z0-9._-]*\.[a-zA-Z][a-zA-Z0-9._-]*)['"]?/g,
|
|
478
|
+
];
|
|
479
|
+
|
|
480
|
+
for (const pattern of patterns) {
|
|
481
|
+
let match;
|
|
482
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
483
|
+
const tag = match[1];
|
|
484
|
+
if (tag && !tags.includes(tag)) {
|
|
485
|
+
tags.push(tag);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return tags;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Check contract examples of two rules for conflicting payloads on the same fact tag.
|
|
495
|
+
*/
|
|
496
|
+
function checkContractConflict<TContext>(
|
|
497
|
+
ruleA: RuleDescriptor<TContext>,
|
|
498
|
+
ruleB: RuleDescriptor<TContext>,
|
|
499
|
+
_factTag: string,
|
|
500
|
+
): string | null {
|
|
501
|
+
if (!ruleA.contract || !ruleB.contract) return null;
|
|
502
|
+
|
|
503
|
+
// Look for examples where both rules produce the same tag with different outcomes
|
|
504
|
+
for (const exA of ruleA.contract.examples) {
|
|
505
|
+
for (const exB of ruleB.contract.examples) {
|
|
506
|
+
// Same precondition / trigger but different outcomes
|
|
507
|
+
const sameGiven = exA.given.toLowerCase() === exB.given.toLowerCase();
|
|
508
|
+
const sameWhen = exA.when.toLowerCase() === exB.when.toLowerCase();
|
|
509
|
+
const differentThen = exA.then.toLowerCase() !== exB.then.toLowerCase();
|
|
510
|
+
|
|
511
|
+
if ((sameGiven || sameWhen) && differentThen) {
|
|
512
|
+
return `Contract conflict: "${ruleA.id}" expects "${exA.then}" but "${ruleB.id}" expects "${exB.then}" under similar conditions`;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return null;
|
|
518
|
+
}
|