@plures/praxis 1.3.0 → 1.4.4
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-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 +149 -0
- package/dist/browser/factory/index.js +15 -0
- package/dist/browser/index.d.ts +274 -3
- package/dist/browser/index.js +407 -54
- package/dist/browser/integrations/svelte.d.ts +3 -2
- package/dist/browser/integrations/svelte.js +3 -2
- package/dist/browser/project/index.d.ts +176 -0
- package/dist/browser/project/index.js +19 -0
- package/dist/browser/reactive-engine.svelte-DgVTqHLc.d.ts +223 -0
- package/dist/browser/{reactive-engine.svelte-DjynI82A.d.ts → rules-i1LHpnGd.d.ts} +13 -221
- package/dist/node/chunk-2IUFZBH3.js +87 -0
- package/dist/node/{chunk-WZ6B3LZ6.js → chunk-7CSWBDFL.js} +3 -56
- 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-PGVSB6NR.js +59 -0
- package/dist/node/{chunk-5JQJZADT.js → chunk-ZO2LU4G4.js} +4 -4
- package/dist/node/cli/index.cjs +1126 -211
- package/dist/node/cli/index.js +21 -2
- package/dist/node/{engine-FEN5IYZ5.js → engine-VFHCIEM4.js} +2 -1
- package/dist/node/index.cjs +5623 -2765
- package/dist/node/index.d.cts +1181 -1
- package/dist/node/index.d.ts +1181 -1
- package/dist/node/index.js +1646 -79
- package/dist/node/integrations/svelte.js +4 -3
- package/dist/node/{reverse-W7THPV45.js → reverse-YD3CWIGM.js} +3 -2
- package/dist/node/rules-4DAJ4Z4N.js +7 -0
- package/dist/node/server-FKLVY57V.js +363 -0
- package/dist/node/{validate-EN3M4FUR.js → validate-5PSWJTIC.js} +5 -3
- package/package.json +50 -3
- package/src/__tests__/chronos-project.test.ts +799 -0
- package/src/__tests__/decision-ledger.test.ts +857 -402
- package/src/__tests__/expectations.test.ts +364 -0
- package/src/__tests__/factory.test.ts +426 -0
- package/src/__tests__/mcp-server.test.ts +310 -0
- package/src/__tests__/project.test.ts +396 -0
- 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/cli/index.ts +28 -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/expectations/expectations.ts +471 -0
- package/src/expectations/index.ts +29 -0
- package/src/expectations/types.ts +95 -0
- package/src/factory/factory.ts +634 -0
- package/src/factory/index.ts +27 -0
- package/src/factory/types.ts +64 -0
- package/src/index.browser.ts +83 -0
- package/src/index.ts +134 -0
- package/src/mcp/index.ts +33 -0
- package/src/mcp/server.ts +485 -0
- package/src/mcp/types.ts +161 -0
- package/src/project/index.ts +31 -0
- package/src/project/project.ts +423 -0
- package/src/project/types.ts +87 -0
- package/dist/node/chunk-PTH6MD6P.js +0 -487
- /package/dist/node/{chunk-R2PSBPKQ.js → chunk-TEMFJOIH.js} +0 -0
|
@@ -1,485 +1,940 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Decision Ledger
|
|
2
|
+
* Decision Ledger — Comprehensive Tests
|
|
3
3
|
*
|
|
4
|
-
* Tests
|
|
5
|
-
*
|
|
6
|
-
* These tests are derived from the behavior ledger examples and assumptions.
|
|
4
|
+
* Tests the graph analysis engine, derivation tracing, contract verification,
|
|
5
|
+
* report generation, suggestions, and ledger diffing.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
|
-
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
10
9
|
import { PraxisRegistry } from '../core/rules.js';
|
|
11
|
-
import {
|
|
10
|
+
import { LogicEngine, createPraxisEngine } from '../core/engine.js';
|
|
11
|
+
import { RuleResult, fact } from '../core/rule-result.js';
|
|
12
|
+
import { defineRule, defineConstraint } from '../dsl/index.js';
|
|
13
|
+
import { defineContract } from '../decision-ledger/types.js';
|
|
14
|
+
import { expectBehavior, ExpectationSet } from '../expectations/expectations.js';
|
|
15
|
+
import type { PraxisState, PraxisEvent, PraxisFact } from '../core/protocol.js';
|
|
16
|
+
|
|
17
|
+
// Import analyzer modules
|
|
12
18
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
19
|
+
analyzeDependencyGraph,
|
|
20
|
+
findDeadRules,
|
|
21
|
+
findUnreachableStates,
|
|
22
|
+
findShadowedRules,
|
|
23
|
+
findContradictions,
|
|
24
|
+
findGaps,
|
|
25
|
+
} from '../decision-ledger/analyzer.js';
|
|
26
|
+
import {
|
|
27
|
+
traceDerivation,
|
|
28
|
+
traceImpact,
|
|
29
|
+
} from '../decision-ledger/derivation.js';
|
|
30
|
+
import {
|
|
31
|
+
verifyContractExamples,
|
|
32
|
+
verifyInvariants,
|
|
33
|
+
findContractGaps,
|
|
34
|
+
crossReferenceContracts,
|
|
35
|
+
} from '../decision-ledger/contract-verification.js';
|
|
36
|
+
import {
|
|
37
|
+
suggest,
|
|
38
|
+
suggestAll,
|
|
39
|
+
} from '../decision-ledger/suggestions.js';
|
|
40
|
+
import {
|
|
41
|
+
generateLedger,
|
|
42
|
+
formatLedger,
|
|
43
|
+
formatBuildOutput,
|
|
44
|
+
diffLedgers,
|
|
45
|
+
} from '../decision-ledger/report.js';
|
|
46
|
+
|
|
47
|
+
// ─── Test Helpers ───────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
interface TestContext {
|
|
50
|
+
user?: { name: string; role: string };
|
|
51
|
+
cart?: { items: number; total: number };
|
|
52
|
+
sprint?: { name: string; hours: number; target: number };
|
|
53
|
+
network?: { connected: boolean };
|
|
54
|
+
settings?: { changed: boolean };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createTestRegistry() {
|
|
58
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
59
|
+
|
|
60
|
+
// Rule 1: Login handler (produces user.loggedIn)
|
|
61
|
+
registry.registerRule(defineRule<TestContext>({
|
|
62
|
+
id: 'auth.login',
|
|
63
|
+
description: 'Process login events and create user session',
|
|
64
|
+
eventTypes: ['LOGIN'],
|
|
65
|
+
impl: (state, events) => {
|
|
66
|
+
const loginEvent = events.find(e => e.tag === 'LOGIN');
|
|
67
|
+
if (!loginEvent) return RuleResult.skip('No login event');
|
|
68
|
+
return RuleResult.emit([
|
|
69
|
+
fact('user.loggedIn', { name: (loginEvent.payload as { username?: string })?.username ?? 'unknown' }),
|
|
70
|
+
]);
|
|
71
|
+
},
|
|
72
|
+
contract: defineContract({
|
|
73
|
+
ruleId: 'auth.login',
|
|
74
|
+
behavior: 'Process login events and emit user session facts',
|
|
75
|
+
examples: [
|
|
76
|
+
{ given: 'User provides valid credentials', when: 'LOGIN event', then: 'emit user.loggedIn fact' },
|
|
77
|
+
{ given: 'User provides invalid credentials', when: 'LOGIN event', then: 'skip — invalid credentials' },
|
|
78
|
+
],
|
|
79
|
+
invariants: ['Session must have a username'],
|
|
80
|
+
}),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
// Rule 2: Logout handler (retracts user.loggedIn)
|
|
84
|
+
registry.registerRule(defineRule<TestContext>({
|
|
85
|
+
id: 'auth.logout',
|
|
86
|
+
description: 'Process logout events and clear user session',
|
|
87
|
+
eventTypes: ['LOGOUT'],
|
|
88
|
+
impl: (_state, events) => {
|
|
89
|
+
const logoutEvent = events.find(e => e.tag === 'LOGOUT');
|
|
90
|
+
if (!logoutEvent) return RuleResult.skip('No logout event');
|
|
91
|
+
return RuleResult.retract(['user.loggedIn'], 'User logged out');
|
|
92
|
+
},
|
|
93
|
+
contract: defineContract({
|
|
94
|
+
ruleId: 'auth.logout',
|
|
95
|
+
behavior: 'Clear user session on logout',
|
|
96
|
+
examples: [
|
|
97
|
+
{ given: 'User is logged in', when: 'LOGOUT event', then: 'retract user.loggedIn' },
|
|
98
|
+
],
|
|
99
|
+
invariants: ['No session should remain after logout'],
|
|
100
|
+
}),
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
// Rule 3: Cart add item (produces cart.updated)
|
|
104
|
+
registry.registerRule(defineRule<TestContext>({
|
|
105
|
+
id: 'cart.addItem',
|
|
106
|
+
description: 'Add item to cart',
|
|
107
|
+
eventTypes: ['ADD_TO_CART'],
|
|
108
|
+
impl: (state, events) => {
|
|
109
|
+
const addEvent = events.find(e => e.tag === 'ADD_TO_CART');
|
|
110
|
+
if (!addEvent) return RuleResult.skip('No add event');
|
|
111
|
+
const items = (state.context.cart?.items ?? 0) + 1;
|
|
112
|
+
return RuleResult.emit([
|
|
113
|
+
fact('cart.updated', { items, total: items * 10 }),
|
|
114
|
+
]);
|
|
115
|
+
},
|
|
116
|
+
contract: defineContract({
|
|
117
|
+
ruleId: 'cart.addItem',
|
|
118
|
+
behavior: 'Update cart when items are added',
|
|
119
|
+
examples: [
|
|
120
|
+
{ given: 'Cart has 2 items', when: 'ADD_TO_CART event', then: 'emit cart.updated with 3 items' },
|
|
121
|
+
],
|
|
122
|
+
invariants: ['Cart total must be positive', 'Item count must not exceed 100'],
|
|
123
|
+
}),
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
// Rule 4: Sprint pace check (produces sprint.behind or sprint.onPace)
|
|
127
|
+
registry.registerRule(defineRule<TestContext>({
|
|
128
|
+
id: 'sprint.paceCheck',
|
|
129
|
+
description: 'Check if sprint is on pace',
|
|
130
|
+
eventTypes: ['SPRINT_UPDATE'],
|
|
131
|
+
impl: (state, _events) => {
|
|
132
|
+
const sprint = state.context.sprint;
|
|
133
|
+
if (!sprint) return RuleResult.skip('No sprint data');
|
|
134
|
+
if (sprint.hours < sprint.target) {
|
|
135
|
+
return RuleResult.emit([fact('sprint.behind', { deficit: sprint.target - sprint.hours })]);
|
|
136
|
+
}
|
|
137
|
+
return RuleResult.emit([fact('sprint.onPace', { surplus: sprint.hours - sprint.target })]);
|
|
138
|
+
},
|
|
139
|
+
contract: defineContract({
|
|
140
|
+
ruleId: 'sprint.paceCheck',
|
|
141
|
+
behavior: 'Evaluate sprint pace and produce status facts',
|
|
142
|
+
examples: [
|
|
143
|
+
{ given: 'Sprint has 20 of 40 hours', when: 'SPRINT_UPDATE event', then: 'emit sprint.behind' },
|
|
144
|
+
{ given: 'Sprint has 45 of 40 hours', when: 'SPRINT_UPDATE event', then: 'emit sprint.onPace' },
|
|
145
|
+
],
|
|
146
|
+
invariants: ['Either sprint.behind or sprint.onPace must be emitted, never both'],
|
|
147
|
+
}),
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
// Rule 5: Network status (produces network.status)
|
|
151
|
+
registry.registerRule(defineRule<TestContext>({
|
|
152
|
+
id: 'network.status',
|
|
153
|
+
description: 'Track network connectivity status',
|
|
154
|
+
eventTypes: ['NETWORK_CHANGE'],
|
|
155
|
+
impl: (state, _events) => {
|
|
156
|
+
return RuleResult.emit([
|
|
157
|
+
fact('network.status', { connected: state.context.network?.connected ?? false }),
|
|
158
|
+
]);
|
|
159
|
+
},
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
// Rule 6: Dead rule — requires IMPORT_DATA event that no one sends
|
|
163
|
+
registry.registerRule(defineRule<TestContext>({
|
|
164
|
+
id: 'data.import',
|
|
165
|
+
description: 'Import data from external source',
|
|
166
|
+
eventTypes: ['IMPORT_DATA'],
|
|
167
|
+
impl: () => {
|
|
168
|
+
return RuleResult.emit([fact('data.imported', { count: 0 })]);
|
|
169
|
+
},
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
// Rule 7: Another cart rule that ALSO produces cart.updated (contradiction)
|
|
173
|
+
registry.registerRule(defineRule<TestContext>({
|
|
174
|
+
id: 'cart.recalculate',
|
|
175
|
+
description: 'Recalculate cart totals',
|
|
176
|
+
eventTypes: ['ADD_TO_CART'],
|
|
177
|
+
impl: (state, _events) => {
|
|
178
|
+
const items = state.context.cart?.items ?? 0;
|
|
179
|
+
return RuleResult.emit([
|
|
180
|
+
fact('cart.updated', { items, total: items * 12 }), // Different price!
|
|
181
|
+
]);
|
|
182
|
+
},
|
|
183
|
+
contract: defineContract({
|
|
184
|
+
ruleId: 'cart.recalculate',
|
|
185
|
+
behavior: 'Recalculate cart totals with tax',
|
|
186
|
+
examples: [
|
|
187
|
+
{ given: 'Cart has 2 items', when: 'ADD_TO_CART event', then: 'emit cart.updated with tax' },
|
|
188
|
+
],
|
|
189
|
+
invariants: ['Cart total must include tax'],
|
|
190
|
+
}),
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
// Rule 8: Settings save (produces settings.saved)
|
|
194
|
+
registry.registerRule(defineRule<TestContext>({
|
|
195
|
+
id: 'settings.save',
|
|
196
|
+
description: 'Save settings when changed',
|
|
197
|
+
eventTypes: ['SAVE_SETTINGS'],
|
|
198
|
+
impl: (state, _events) => {
|
|
199
|
+
if (!state.context.settings?.changed) {
|
|
200
|
+
return RuleResult.skip('No settings changes');
|
|
201
|
+
}
|
|
202
|
+
return RuleResult.emit([
|
|
203
|
+
fact('settings.saved', { timestamp: Date.now() }),
|
|
204
|
+
]);
|
|
205
|
+
},
|
|
206
|
+
contract: defineContract({
|
|
207
|
+
ruleId: 'settings.save',
|
|
208
|
+
behavior: 'Persist changed settings',
|
|
209
|
+
examples: [
|
|
210
|
+
{ given: 'Settings have been modified', when: 'SAVE_SETTINGS event', then: 'emit settings.saved' },
|
|
211
|
+
],
|
|
212
|
+
invariants: ['Only save when settings actually changed'],
|
|
213
|
+
}),
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
// Rule 9: Shadowed rule — same event type as sprint.paceCheck but produces subset
|
|
217
|
+
registry.registerRule(defineRule<TestContext>({
|
|
218
|
+
id: 'sprint.simpleCheck',
|
|
219
|
+
description: 'Simple sprint status check',
|
|
220
|
+
eventTypes: ['SPRINT_UPDATE'],
|
|
221
|
+
impl: (state, _events) => {
|
|
222
|
+
const sprint = state.context.sprint;
|
|
223
|
+
if (!sprint) return RuleResult.skip('No sprint');
|
|
224
|
+
if (sprint.hours < sprint.target) {
|
|
225
|
+
return RuleResult.emit([fact('sprint.behind', { deficit: sprint.target - sprint.hours })]);
|
|
226
|
+
}
|
|
227
|
+
return RuleResult.noop('On pace');
|
|
228
|
+
},
|
|
229
|
+
}));
|
|
230
|
+
|
|
231
|
+
// Rule 10: Notification rule (reads user.loggedIn, produces notification.sent)
|
|
232
|
+
registry.registerRule(defineRule<TestContext>({
|
|
233
|
+
id: 'notification.welcome',
|
|
234
|
+
description: 'Send welcome notification when user logs in',
|
|
235
|
+
eventTypes: ['LOGIN'],
|
|
236
|
+
impl: (state, _events) => {
|
|
237
|
+
const userFact = state.facts.find(f => f.tag === 'user.loggedIn');
|
|
238
|
+
if (!userFact) return RuleResult.skip('No user session');
|
|
239
|
+
return RuleResult.emit([
|
|
240
|
+
fact('notification.sent', { type: 'welcome', to: (userFact.payload as { name: string }).name }),
|
|
241
|
+
]);
|
|
242
|
+
},
|
|
243
|
+
contract: defineContract({
|
|
244
|
+
ruleId: 'notification.welcome',
|
|
245
|
+
behavior: 'Send welcome notification to logged-in user',
|
|
246
|
+
examples: [
|
|
247
|
+
{ given: 'fact "user.loggedIn" exists', when: 'LOGIN event', then: 'emit notification.sent' },
|
|
248
|
+
],
|
|
249
|
+
invariants: ['Notification must reference the logged-in user'],
|
|
250
|
+
}),
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
// Rule 11: Another dead rule — requires WEBHOOK event
|
|
254
|
+
registry.registerRule(defineRule<TestContext>({
|
|
255
|
+
id: 'webhook.handler',
|
|
256
|
+
description: 'Process incoming webhooks',
|
|
257
|
+
eventTypes: ['WEBHOOK'],
|
|
258
|
+
impl: () => {
|
|
259
|
+
return RuleResult.emit([fact('webhook.processed', { success: true })]);
|
|
260
|
+
},
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
// Constraint 1: Cart item limit
|
|
264
|
+
registry.registerConstraint(defineConstraint<TestContext>({
|
|
265
|
+
id: 'cart.maxItems',
|
|
266
|
+
description: 'Cart cannot exceed 100 items',
|
|
267
|
+
impl: (state) => {
|
|
268
|
+
const items = state.context.cart?.items ?? 0;
|
|
269
|
+
return items <= 100 || `Cart has ${items} items, max is 100`;
|
|
270
|
+
},
|
|
271
|
+
}));
|
|
272
|
+
|
|
273
|
+
// Constraint 2: Sprint hours positive
|
|
274
|
+
registry.registerConstraint(defineConstraint<TestContext>({
|
|
275
|
+
id: 'sprint.positiveHours',
|
|
276
|
+
description: 'Sprint hours must be non-negative',
|
|
277
|
+
impl: (state) => {
|
|
278
|
+
return (state.context.sprint?.hours ?? 0) >= 0 || 'Sprint hours are negative';
|
|
279
|
+
},
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
return registry;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
describe('Decision Ledger Analyzer', () => {
|
|
288
|
+
let registry: PraxisRegistry<TestContext>;
|
|
289
|
+
|
|
290
|
+
beforeEach(() => {
|
|
291
|
+
registry = createTestRegistry();
|
|
292
|
+
});
|
|
51
293
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
expect(() => {
|
|
64
|
-
defineContract({
|
|
65
|
-
ruleId: 'test.rule',
|
|
66
|
-
behavior: 'Test behavior',
|
|
67
|
-
examples: [],
|
|
68
|
-
invariants: [],
|
|
69
|
-
});
|
|
70
|
-
}).toThrow('Contract must have at least one example');
|
|
294
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
295
|
+
// 1. Dependency Graph Analysis
|
|
296
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
297
|
+
|
|
298
|
+
describe('analyzeDependencyGraph', () => {
|
|
299
|
+
it('should build a dependency graph from the registry', () => {
|
|
300
|
+
const graph = analyzeDependencyGraph(registry);
|
|
301
|
+
|
|
302
|
+
expect(graph.facts.size).toBeGreaterThan(0);
|
|
303
|
+
expect(graph.edges.length).toBeGreaterThan(0);
|
|
304
|
+
expect(graph.producers.size).toBeGreaterThan(0);
|
|
71
305
|
});
|
|
72
306
|
|
|
73
|
-
it('should
|
|
74
|
-
const
|
|
75
|
-
ruleId: 'test.rule',
|
|
76
|
-
behavior: 'Test behavior',
|
|
77
|
-
examples: [{ given: 'a', when: 'b', then: 'c' }],
|
|
78
|
-
invariants: ['test'],
|
|
79
|
-
};
|
|
307
|
+
it('should identify fact producers', () => {
|
|
308
|
+
const graph = analyzeDependencyGraph(registry);
|
|
80
309
|
|
|
81
|
-
|
|
310
|
+
// auth.login should produce user.loggedIn
|
|
311
|
+
const loginProduced = graph.producers.get('auth.login') ?? [];
|
|
312
|
+
expect(loginProduced).toContain('user.loggedIn');
|
|
313
|
+
});
|
|
82
314
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
behavior: 'Test behavior',
|
|
86
|
-
examples: [],
|
|
87
|
-
invariants: [],
|
|
88
|
-
};
|
|
315
|
+
it('should identify fact consumers', () => {
|
|
316
|
+
const graph = analyzeDependencyGraph(registry);
|
|
89
317
|
|
|
90
|
-
|
|
318
|
+
// notification.welcome should consume user.loggedIn (via contract given text)
|
|
319
|
+
const userLoggedInNode = graph.facts.get('user.loggedIn');
|
|
320
|
+
expect(userLoggedInNode).toBeDefined();
|
|
321
|
+
// The node should have at least one producer
|
|
322
|
+
expect(userLoggedInNode!.producedBy.length).toBeGreaterThan(0);
|
|
91
323
|
});
|
|
92
324
|
|
|
93
|
-
it('should
|
|
94
|
-
const
|
|
95
|
-
ruleId: 'test.rule',
|
|
96
|
-
behavior: 'Test behavior',
|
|
97
|
-
examples: [{ given: 'a', when: 'b', then: 'c' }],
|
|
98
|
-
invariants: [],
|
|
99
|
-
});
|
|
325
|
+
it('should track edges correctly', () => {
|
|
326
|
+
const graph = analyzeDependencyGraph(registry);
|
|
100
327
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
328
|
+
const producesEdges = graph.edges.filter(e => e.type === 'produces');
|
|
329
|
+
expect(producesEdges.length).toBeGreaterThan(0);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
334
|
+
// 2. Dead Rules Detection
|
|
335
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
107
336
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
337
|
+
describe('findDeadRules', () => {
|
|
338
|
+
it('should find rules with event types not in known set', () => {
|
|
339
|
+
const knownEvents = ['LOGIN', 'LOGOUT', 'ADD_TO_CART', 'SPRINT_UPDATE', 'NETWORK_CHANGE', 'SAVE_SETTINGS'];
|
|
340
|
+
const dead = findDeadRules(registry, knownEvents);
|
|
341
|
+
|
|
342
|
+
const deadIds = dead.map(d => d.ruleId);
|
|
343
|
+
expect(deadIds).toContain('data.import');
|
|
344
|
+
expect(deadIds).toContain('webhook.handler');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should not flag rules that match known event types', () => {
|
|
348
|
+
const knownEvents = ['LOGIN', 'LOGOUT', 'ADD_TO_CART', 'SPRINT_UPDATE', 'NETWORK_CHANGE', 'SAVE_SETTINGS'];
|
|
349
|
+
const dead = findDeadRules(registry, knownEvents);
|
|
350
|
+
|
|
351
|
+
const deadIds = dead.map(d => d.ruleId);
|
|
352
|
+
expect(deadIds).not.toContain('auth.login');
|
|
353
|
+
expect(deadIds).not.toContain('cart.addItem');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should include required event types in dead rule info', () => {
|
|
357
|
+
const dead = findDeadRules(registry, ['LOGIN']);
|
|
358
|
+
const dataImport = dead.find(d => d.ruleId === 'data.import');
|
|
359
|
+
|
|
360
|
+
expect(dataImport).toBeDefined();
|
|
361
|
+
expect(dataImport!.requiredEventTypes).toContain('IMPORT_DATA');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should find all dead rules when no events are known', () => {
|
|
365
|
+
const dead = findDeadRules(registry, []);
|
|
366
|
+
// All rules with event types should be dead
|
|
367
|
+
expect(dead.length).toBeGreaterThan(5);
|
|
111
368
|
});
|
|
112
369
|
});
|
|
113
370
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
371
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
372
|
+
// 3. Unreachable States Detection
|
|
373
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
374
|
+
|
|
375
|
+
describe('findUnreachableStates', () => {
|
|
376
|
+
it('should find consumed facts that are never produced', () => {
|
|
377
|
+
// Add a rule that consumes a non-existent fact
|
|
378
|
+
registry.registerRule(defineRule<TestContext>({
|
|
379
|
+
id: 'orphan.reader',
|
|
380
|
+
description: 'Reads a fact nobody produces',
|
|
381
|
+
eventTypes: ['LOGIN'],
|
|
382
|
+
impl: (state) => {
|
|
383
|
+
const orphan = state.facts.find(f => f.tag === 'orphan.fact');
|
|
384
|
+
if (!orphan) return RuleResult.skip();
|
|
385
|
+
return RuleResult.emit([fact('derived.fact', {})]);
|
|
386
|
+
},
|
|
387
|
+
contract: defineContract({
|
|
388
|
+
ruleId: 'orphan.reader',
|
|
389
|
+
behavior: 'Reads orphan.fact and produces derived.fact',
|
|
390
|
+
examples: [
|
|
391
|
+
{ given: 'fact "orphan.fact" exists', when: 'LOGIN', then: 'emit derived.fact' },
|
|
392
|
+
],
|
|
393
|
+
invariants: [],
|
|
394
|
+
}),
|
|
395
|
+
}));
|
|
126
396
|
|
|
127
|
-
registry
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
impl: () => [],
|
|
132
|
-
meta: { contract: completeContract },
|
|
133
|
-
})
|
|
134
|
-
);
|
|
397
|
+
const unreachable = findUnreachableStates(registry);
|
|
398
|
+
const orphanState = unreachable.find(u => u.factTags.includes('orphan.fact'));
|
|
399
|
+
expect(orphanState).toBeDefined();
|
|
400
|
+
});
|
|
135
401
|
|
|
136
|
-
|
|
137
|
-
registry
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
402
|
+
it('should return empty array when all consumed facts are produced', () => {
|
|
403
|
+
// Use a clean registry with matching producers and consumers
|
|
404
|
+
const cleanRegistry = new PraxisRegistry<TestContext>();
|
|
405
|
+
cleanRegistry.registerRule(defineRule<TestContext>({
|
|
406
|
+
id: 'simple.rule',
|
|
407
|
+
description: 'Simple',
|
|
408
|
+
eventTypes: ['TEST'],
|
|
409
|
+
impl: () => RuleResult.emit([fact('simple.fact', {})]),
|
|
410
|
+
}));
|
|
411
|
+
|
|
412
|
+
const unreachable = findUnreachableStates(cleanRegistry);
|
|
413
|
+
expect(unreachable.length).toBe(0);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
144
416
|
|
|
145
|
-
|
|
417
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
418
|
+
// 4. Shadowed Rules Detection
|
|
419
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
420
|
+
|
|
421
|
+
describe('findShadowedRules', () => {
|
|
422
|
+
it('should find rules where another produces a superset of facts', () => {
|
|
423
|
+
const shadowed = findShadowedRules(registry);
|
|
424
|
+
|
|
425
|
+
// sprint.simpleCheck should be shadowed by sprint.paceCheck
|
|
426
|
+
// paceCheck produces sprint.behind AND sprint.onPace, simpleCheck only sprint.behind
|
|
427
|
+
const simpleCheckShadowed = shadowed.find(s => s.ruleId === 'sprint.simpleCheck');
|
|
428
|
+
if (simpleCheckShadowed) {
|
|
429
|
+
expect(simpleCheckShadowed.shadowedBy).toBe('sprint.paceCheck');
|
|
430
|
+
expect(simpleCheckShadowed.sharedEventTypes).toContain('SPRINT_UPDATE');
|
|
431
|
+
}
|
|
432
|
+
});
|
|
146
433
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// Rules without contracts only appear in missing array, not incomplete
|
|
153
|
-
expect(report.incomplete).toHaveLength(0);
|
|
434
|
+
it('should include shared event types', () => {
|
|
435
|
+
const shadowed = findShadowedRules(registry);
|
|
436
|
+
for (const s of shadowed) {
|
|
437
|
+
expect(s.sharedEventTypes.length).toBeGreaterThan(0);
|
|
438
|
+
}
|
|
154
439
|
});
|
|
440
|
+
});
|
|
155
441
|
|
|
156
|
-
|
|
157
|
-
|
|
442
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
443
|
+
// 5. Contradiction Detection
|
|
444
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
158
445
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
behavior: '', // Empty behavior
|
|
163
|
-
examples: [{ given: 'a', when: 'b', then: 'c' }],
|
|
164
|
-
invariants: [],
|
|
165
|
-
});
|
|
446
|
+
describe('findContradictions', () => {
|
|
447
|
+
it('should find rules producing the same fact tag with same event types', () => {
|
|
448
|
+
const contradictions = findContradictions(registry);
|
|
166
449
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
meta: { contract: incompleteContract },
|
|
173
|
-
})
|
|
450
|
+
// cart.addItem and cart.recalculate both produce cart.updated
|
|
451
|
+
const cartConflict = contradictions.find(
|
|
452
|
+
c =>
|
|
453
|
+
(c.ruleA === 'cart.addItem' && c.ruleB === 'cart.recalculate') ||
|
|
454
|
+
(c.ruleA === 'cart.recalculate' && c.ruleB === 'cart.addItem'),
|
|
174
455
|
);
|
|
456
|
+
expect(cartConflict).toBeDefined();
|
|
457
|
+
expect(cartConflict!.conflictingTag).toBe('cart.updated');
|
|
458
|
+
});
|
|
175
459
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
460
|
+
it('should include the conflicting fact tag', () => {
|
|
461
|
+
const contradictions = findContradictions(registry);
|
|
462
|
+
for (const c of contradictions) {
|
|
463
|
+
expect(c.conflictingTag).toBeTruthy();
|
|
464
|
+
}
|
|
465
|
+
});
|
|
179
466
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
registry.registerRule(
|
|
188
|
-
defineRule({
|
|
189
|
-
id: 'test.rule',
|
|
190
|
-
description: 'Test',
|
|
191
|
-
impl: () => [],
|
|
192
|
-
meta: {
|
|
193
|
-
contract: defineContract({
|
|
194
|
-
ruleId: 'test.rule',
|
|
195
|
-
behavior: 'Test',
|
|
196
|
-
examples: [{ given: 'a', when: 'b', then: 'c' }],
|
|
197
|
-
invariants: [],
|
|
198
|
-
}),
|
|
199
|
-
},
|
|
200
|
-
})
|
|
467
|
+
it('should not flag rules with non-overlapping event types as contradictions', () => {
|
|
468
|
+
// auth.login and cart.addItem both produce facts but with different event types
|
|
469
|
+
const contradictions = findContradictions(registry);
|
|
470
|
+
const falsePositive = contradictions.find(
|
|
471
|
+
c =>
|
|
472
|
+
(c.ruleA === 'auth.login' && c.ruleB === 'cart.addItem') ||
|
|
473
|
+
(c.ruleA === 'cart.addItem' && c.ruleB === 'auth.login'),
|
|
201
474
|
);
|
|
475
|
+
expect(falsePositive).toBeUndefined();
|
|
476
|
+
});
|
|
477
|
+
});
|
|
202
478
|
|
|
203
|
-
|
|
204
|
-
|
|
479
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
480
|
+
// 6. Gap Detection
|
|
481
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
482
|
+
|
|
483
|
+
describe('findGaps', () => {
|
|
484
|
+
it('should find expectations with no covering rules', () => {
|
|
485
|
+
const expectations = new ExpectationSet({ name: 'test' });
|
|
486
|
+
expectations.add(
|
|
487
|
+
expectBehavior('payment-processing')
|
|
488
|
+
.onlyWhen('cart total is positive')
|
|
489
|
+
.never('when cart is empty'),
|
|
490
|
+
);
|
|
205
491
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
expect(
|
|
492
|
+
const gaps = findGaps(registry, expectations);
|
|
493
|
+
const paymentGap = gaps.find(g => g.expectationName === 'payment-processing');
|
|
494
|
+
expect(paymentGap).toBeDefined();
|
|
495
|
+
expect(paymentGap!.type).toBe('no-rule');
|
|
209
496
|
});
|
|
210
497
|
|
|
211
|
-
it('should
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
impl: () => [],
|
|
219
|
-
})
|
|
498
|
+
it('should find expectations with partial coverage', () => {
|
|
499
|
+
const expectations = new ExpectationSet({ name: 'test' });
|
|
500
|
+
expectations.add(
|
|
501
|
+
expectBehavior('auth-login')
|
|
502
|
+
.onlyWhen('valid credentials provided')
|
|
503
|
+
.never('when account is locked')
|
|
504
|
+
.always('produces a session token'),
|
|
220
505
|
);
|
|
221
506
|
|
|
222
|
-
const
|
|
507
|
+
const gaps = findGaps(registry, expectations);
|
|
508
|
+
// auth.login exists but may not cover all conditions
|
|
509
|
+
const authGap = gaps.find(g => g.expectationName === 'auth-login');
|
|
510
|
+
// This may or may not be a gap depending on contract matching
|
|
511
|
+
// We just verify the function runs without error
|
|
512
|
+
expect(gaps).toBeDefined();
|
|
513
|
+
});
|
|
223
514
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
expect(
|
|
515
|
+
it('should return empty array when all expectations are covered', () => {
|
|
516
|
+
const expectations = new ExpectationSet({ name: 'empty' });
|
|
517
|
+
const gaps = findGaps(registry, expectations);
|
|
518
|
+
expect(gaps).toEqual([]);
|
|
228
519
|
});
|
|
229
520
|
});
|
|
230
521
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const fact = ContractMissing.create({
|
|
235
|
-
ruleId: 'test.rule',
|
|
236
|
-
missing: ['behavior', 'examples'],
|
|
237
|
-
severity: 'warning',
|
|
238
|
-
});
|
|
522
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
523
|
+
// 7. Derivation Tracing
|
|
524
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
239
525
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
526
|
+
describe('traceDerivation', () => {
|
|
527
|
+
it('should trace a fact back through rule chains', () => {
|
|
528
|
+
const engine = createPraxisEngine({ registry });
|
|
529
|
+
|
|
530
|
+
// Step with login event to produce user.loggedIn
|
|
531
|
+
engine.step([{ tag: 'LOGIN', payload: { username: 'alice' } }]);
|
|
532
|
+
|
|
533
|
+
const chain = traceDerivation('user.loggedIn', engine, registry);
|
|
534
|
+
expect(chain.targetFact).toBe('user.loggedIn');
|
|
535
|
+
expect(chain.steps.length).toBeGreaterThan(0);
|
|
536
|
+
|
|
537
|
+
// Should have at least a rule-fired step
|
|
538
|
+
const ruleFired = chain.steps.find(s => s.type === 'rule-fired' && s.id === 'auth.login');
|
|
539
|
+
expect(ruleFired).toBeDefined();
|
|
244
540
|
});
|
|
245
541
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
ruleId: 'legacy.process',
|
|
250
|
-
missing: ['spec', 'tests'],
|
|
251
|
-
justification: 'Legacy rule to be deprecated in v2.0',
|
|
252
|
-
expiresAt: '2025-12-31',
|
|
253
|
-
});
|
|
542
|
+
it('should include event triggers in the chain', () => {
|
|
543
|
+
const engine = createPraxisEngine({ registry });
|
|
544
|
+
engine.step([{ tag: 'LOGIN', payload: { username: 'alice' } }]);
|
|
254
545
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
expect(
|
|
546
|
+
const chain = traceDerivation('user.loggedIn', engine, registry);
|
|
547
|
+
|
|
548
|
+
const eventStep = chain.steps.find(s => s.type === 'event' && s.id === 'LOGIN');
|
|
549
|
+
expect(eventStep).toBeDefined();
|
|
259
550
|
});
|
|
260
551
|
|
|
261
|
-
it('should
|
|
262
|
-
const
|
|
263
|
-
ruleId: 'test',
|
|
264
|
-
missing: ['contract'],
|
|
265
|
-
severity: 'warning',
|
|
266
|
-
});
|
|
552
|
+
it('should handle multi-hop derivation', () => {
|
|
553
|
+
const engine = createPraxisEngine({ registry });
|
|
267
554
|
|
|
268
|
-
|
|
555
|
+
// notification.welcome reads user.loggedIn (from auth.login)
|
|
556
|
+
const chain = traceDerivation('notification.sent', engine, registry);
|
|
557
|
+
expect(chain.steps.length).toBeGreaterThan(0);
|
|
269
558
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
});
|
|
559
|
+
// Should trace back through the chain
|
|
560
|
+
const hasRuleStep = chain.steps.some(s => s.type === 'rule-fired');
|
|
561
|
+
expect(hasRuleStep).toBe(true);
|
|
562
|
+
});
|
|
275
563
|
|
|
276
|
-
|
|
564
|
+
it('should report depth correctly', () => {
|
|
565
|
+
const engine = createPraxisEngine({ registry });
|
|
566
|
+
const chain = traceDerivation('user.loggedIn', engine, registry);
|
|
567
|
+
expect(chain.depth).toBeGreaterThanOrEqual(1);
|
|
277
568
|
});
|
|
278
569
|
});
|
|
279
570
|
|
|
280
|
-
describe('
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const ledger = createBehaviorLedger();
|
|
571
|
+
describe('traceImpact', () => {
|
|
572
|
+
it('should find rules affected by removing a fact', () => {
|
|
573
|
+
const impact = traceImpact('user.loggedIn', registry);
|
|
284
574
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
});
|
|
575
|
+
expect(impact.factTag).toBe('user.loggedIn');
|
|
576
|
+
// notification.welcome consumes user.loggedIn
|
|
577
|
+
// (detected via contract reference in "given")
|
|
578
|
+
expect(impact.affectedRules.length).toBeGreaterThanOrEqual(0);
|
|
579
|
+
});
|
|
291
580
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
});
|
|
581
|
+
it('should find transitively affected facts', () => {
|
|
582
|
+
const impact = traceImpact('user.loggedIn', registry);
|
|
583
|
+
// If notification.welcome stops firing, notification.sent disappears
|
|
584
|
+
expect(impact.depth).toBeGreaterThanOrEqual(0);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
299
587
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
expect(() => {
|
|
304
|
-
ledger.append({
|
|
305
|
-
id: 'entry-1',
|
|
306
|
-
timestamp: new Date().toISOString(),
|
|
307
|
-
status: 'active',
|
|
308
|
-
author: 'system',
|
|
309
|
-
contract: contract1,
|
|
310
|
-
});
|
|
311
|
-
}).toThrow('already exists');
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
// Invariant: Ledger Unique IDs
|
|
315
|
-
it('should enforce unique entry IDs', () => {
|
|
316
|
-
const ledger = createBehaviorLedger();
|
|
317
|
-
|
|
318
|
-
const contract = defineContract({
|
|
319
|
-
ruleId: 'test',
|
|
320
|
-
behavior: 'test',
|
|
321
|
-
examples: [{ given: 'a', when: 'b', then: 'c' }],
|
|
322
|
-
invariants: [],
|
|
323
|
-
});
|
|
588
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
589
|
+
// 8. Contract Verification
|
|
590
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
324
591
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
author: 'system',
|
|
330
|
-
contract,
|
|
331
|
-
});
|
|
592
|
+
describe('verifyContractExamples', () => {
|
|
593
|
+
it('should run rule against contract examples', () => {
|
|
594
|
+
const rule = registry.getRule('auth.login')!;
|
|
595
|
+
const result = verifyContractExamples(rule, rule.contract!);
|
|
332
596
|
|
|
333
|
-
expect(()
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
author: 'system',
|
|
339
|
-
contract,
|
|
340
|
-
});
|
|
341
|
-
}).toThrow();
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it('should supersede previous entries', () => {
|
|
345
|
-
const ledger = createBehaviorLedger();
|
|
346
|
-
|
|
347
|
-
const contract1 = defineContract({
|
|
348
|
-
ruleId: 'test.rule',
|
|
349
|
-
behavior: 'Version 1',
|
|
350
|
-
examples: [{ given: 'a', when: 'b', then: 'c' }],
|
|
351
|
-
invariants: [],
|
|
352
|
-
version: '1.0.0',
|
|
353
|
-
});
|
|
597
|
+
expect(result.ruleId).toBe('auth.login');
|
|
598
|
+
expect(result.examples.length).toBe(2);
|
|
599
|
+
// At least some examples should have a result
|
|
600
|
+
expect(result.passCount + result.failCount).toBe(2);
|
|
601
|
+
});
|
|
354
602
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
603
|
+
it('should detect wrong implementations', () => {
|
|
604
|
+
// Create a rule with a contract that doesn't match
|
|
605
|
+
const badRule = defineRule<TestContext>({
|
|
606
|
+
id: 'bad.rule',
|
|
607
|
+
description: 'A rule with wrong implementation',
|
|
608
|
+
eventTypes: ['TEST'],
|
|
609
|
+
impl: () => {
|
|
610
|
+
// Contract says it should emit, but it always noops
|
|
611
|
+
return RuleResult.noop('Always noop');
|
|
612
|
+
},
|
|
613
|
+
contract: defineContract({
|
|
614
|
+
ruleId: 'bad.rule',
|
|
615
|
+
behavior: 'Should emit test.fact',
|
|
616
|
+
examples: [
|
|
617
|
+
{ given: 'Normal state', when: 'TEST event', then: 'emit test.fact' },
|
|
618
|
+
],
|
|
619
|
+
invariants: [],
|
|
620
|
+
}),
|
|
361
621
|
});
|
|
362
622
|
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
invariants: [],
|
|
368
|
-
version: '2.0.0',
|
|
369
|
-
});
|
|
623
|
+
const result = verifyContractExamples(badRule, badRule.contract!);
|
|
624
|
+
expect(result.allPassed).toBe(false);
|
|
625
|
+
expect(result.failCount).toBeGreaterThan(0);
|
|
626
|
+
});
|
|
370
627
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
628
|
+
it('should pass when implementation matches contract', () => {
|
|
629
|
+
// Create a rule that matches its contract
|
|
630
|
+
const goodRule = defineRule<TestContext>({
|
|
631
|
+
id: 'good.rule',
|
|
632
|
+
description: 'A correctly implemented rule',
|
|
633
|
+
eventTypes: ['TEST'],
|
|
634
|
+
impl: () => {
|
|
635
|
+
return RuleResult.emit([fact('test.fact', { value: 1 })]);
|
|
636
|
+
},
|
|
637
|
+
contract: defineContract({
|
|
638
|
+
ruleId: 'good.rule',
|
|
639
|
+
behavior: 'Should emit test.fact',
|
|
640
|
+
examples: [
|
|
641
|
+
{ given: 'Normal state', when: 'TEST event', then: 'emit test.fact' },
|
|
642
|
+
],
|
|
643
|
+
invariants: [],
|
|
644
|
+
}),
|
|
378
645
|
});
|
|
379
646
|
|
|
380
|
-
const
|
|
381
|
-
expect(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const entry1 = ledger.getEntry('entry-1');
|
|
385
|
-
expect(entry1?.status).toBe('superseded');
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
it('should track assumptions', () => {
|
|
389
|
-
const ledger = createBehaviorLedger();
|
|
390
|
-
|
|
391
|
-
const contract = defineContract({
|
|
392
|
-
ruleId: 'test.rule',
|
|
393
|
-
behavior: 'Test',
|
|
394
|
-
examples: [{ given: 'a', when: 'b', then: 'c' }],
|
|
395
|
-
invariants: [],
|
|
396
|
-
assumptions: [
|
|
397
|
-
{
|
|
398
|
-
id: 'test-assumption',
|
|
399
|
-
statement: 'Test assumption',
|
|
400
|
-
confidence: 0.8,
|
|
401
|
-
justification: 'For testing',
|
|
402
|
-
impacts: ['tests'],
|
|
403
|
-
status: 'active',
|
|
404
|
-
},
|
|
405
|
-
],
|
|
406
|
-
});
|
|
647
|
+
const result = verifyContractExamples(goodRule, goodRule.contract!);
|
|
648
|
+
expect(result.allPassed).toBe(true);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
407
651
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
author: 'system',
|
|
413
|
-
contract,
|
|
414
|
-
});
|
|
652
|
+
describe('verifyInvariants', () => {
|
|
653
|
+
it('should check invariants across all rules', () => {
|
|
654
|
+
const checks = verifyInvariants(registry);
|
|
655
|
+
expect(checks.length).toBeGreaterThan(0);
|
|
415
656
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
657
|
+
// All should be related to a rule
|
|
658
|
+
for (const check of checks) {
|
|
659
|
+
expect(check.ruleId).toBeTruthy();
|
|
660
|
+
expect(check.invariant).toBeTruthy();
|
|
661
|
+
}
|
|
419
662
|
});
|
|
420
663
|
|
|
421
|
-
it('should
|
|
422
|
-
const
|
|
664
|
+
it('should report invariant status', () => {
|
|
665
|
+
const checks = verifyInvariants(registry);
|
|
666
|
+
for (const check of checks) {
|
|
667
|
+
expect(typeof check.holds).toBe('boolean');
|
|
668
|
+
expect(check.explanation).toBeTruthy();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
});
|
|
423
672
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
examples: [{ given: 'a', when: 'b', then: 'c' }],
|
|
428
|
-
invariants: [],
|
|
429
|
-
});
|
|
673
|
+
describe('findContractGaps', () => {
|
|
674
|
+
it('should find rules missing error path examples', () => {
|
|
675
|
+
const gaps = findContractGaps(registry);
|
|
430
676
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
author: 'system',
|
|
436
|
-
contract,
|
|
437
|
-
});
|
|
677
|
+
// Several rules only have happy path examples
|
|
678
|
+
const errorPathGaps = gaps.filter(g => g.type === 'missing-error-path');
|
|
679
|
+
expect(errorPathGaps.length).toBeGreaterThan(0);
|
|
680
|
+
});
|
|
438
681
|
|
|
439
|
-
|
|
440
|
-
const
|
|
682
|
+
it('should find rules with only 1 example', () => {
|
|
683
|
+
const gaps = findContractGaps(registry);
|
|
684
|
+
const boundaryGaps = gaps.filter(g => g.type === 'missing-boundary');
|
|
441
685
|
|
|
442
|
-
|
|
443
|
-
expect(
|
|
686
|
+
// Rules with exactly 1 example should be flagged
|
|
687
|
+
expect(boundaryGaps.length).toBeGreaterThan(0);
|
|
444
688
|
});
|
|
689
|
+
});
|
|
445
690
|
|
|
446
|
-
|
|
447
|
-
|
|
691
|
+
describe('crossReferenceContracts', () => {
|
|
692
|
+
it('should find cross-references between rules', () => {
|
|
693
|
+
const refs = crossReferenceContracts(registry);
|
|
448
694
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
695
|
+
// notification.welcome references user.loggedIn in its contract
|
|
696
|
+
const welcomeRef = refs.find(
|
|
697
|
+
r => r.sourceRuleId === 'notification.welcome' && r.referencedFactTag === 'user.loggedIn',
|
|
698
|
+
);
|
|
699
|
+
expect(welcomeRef).toBeDefined();
|
|
700
|
+
if (welcomeRef) {
|
|
701
|
+
expect(welcomeRef.valid).toBe(true);
|
|
702
|
+
expect(welcomeRef.producerRuleId).toBe('auth.login');
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
});
|
|
455
706
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
707
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
708
|
+
// 9. Suggestions
|
|
709
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
710
|
+
|
|
711
|
+
describe('suggest', () => {
|
|
712
|
+
it('should generate actionable suggestion for dead rules', () => {
|
|
713
|
+
const dead = findDeadRules(registry, ['LOGIN']);
|
|
714
|
+
expect(dead.length).toBeGreaterThan(0);
|
|
715
|
+
|
|
716
|
+
const suggestion = suggest(dead[0], 'dead-rule');
|
|
717
|
+
expect(suggestion.findingType).toBe('dead-rule');
|
|
718
|
+
expect(suggestion.message).toBeTruthy();
|
|
719
|
+
expect(suggestion.message).not.toBe('fix it');
|
|
720
|
+
expect(suggestion.action).toBeTruthy();
|
|
721
|
+
expect(suggestion.priority).toBeGreaterThan(0);
|
|
722
|
+
});
|
|
462
723
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
contract: contract1,
|
|
469
|
-
});
|
|
724
|
+
it('should generate suggestion with code skeleton', () => {
|
|
725
|
+
const dead = findDeadRules(registry, ['LOGIN']);
|
|
726
|
+
const suggestion = suggest(dead[0], 'dead-rule');
|
|
727
|
+
expect(suggestion.skeleton).toBeTruthy();
|
|
728
|
+
});
|
|
470
729
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
730
|
+
it('should generate specific gap suggestions', () => {
|
|
731
|
+
const expectations = new ExpectationSet({ name: 'test' });
|
|
732
|
+
expectations.add(
|
|
733
|
+
expectBehavior('payment-processing')
|
|
734
|
+
.onlyWhen('cart total positive'),
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
const gaps = findGaps(registry, expectations);
|
|
738
|
+
expect(gaps.length).toBeGreaterThan(0);
|
|
739
|
+
|
|
740
|
+
const suggestion = suggest(gaps[0], 'gap');
|
|
741
|
+
expect(suggestion.findingType).toBe('gap');
|
|
742
|
+
expect(suggestion.message).toContain('payment-processing');
|
|
743
|
+
expect(suggestion.skeleton).toBeTruthy();
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it('should generate contradiction suggestions', () => {
|
|
747
|
+
const contradictions = findContradictions(registry);
|
|
748
|
+
if (contradictions.length > 0) {
|
|
749
|
+
const suggestion = suggest(contradictions[0], 'contradiction');
|
|
750
|
+
expect(suggestion.findingType).toBe('contradiction');
|
|
751
|
+
expect(suggestion.action).toBe('add-priority');
|
|
752
|
+
expect(suggestion.priority).toBeGreaterThanOrEqual(9);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
describe('suggestAll', () => {
|
|
758
|
+
it('should generate suggestions for all findings', () => {
|
|
759
|
+
const suggestions = suggestAll({
|
|
760
|
+
deadRules: findDeadRules(registry, ['LOGIN']),
|
|
761
|
+
contradictions: findContradictions(registry),
|
|
477
762
|
});
|
|
478
763
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
764
|
+
expect(suggestions.length).toBeGreaterThan(0);
|
|
765
|
+
// Should be sorted by priority
|
|
766
|
+
for (let i = 1; i < suggestions.length; i++) {
|
|
767
|
+
expect(suggestions[i - 1].priority).toBeGreaterThanOrEqual(suggestions[i].priority);
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
773
|
+
// 10. Report Generation
|
|
774
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
775
|
+
|
|
776
|
+
describe('generateLedger', () => {
|
|
777
|
+
it('should produce a complete analysis report', () => {
|
|
778
|
+
const engine = createPraxisEngine({ registry });
|
|
779
|
+
const report = generateLedger(registry, engine);
|
|
780
|
+
|
|
781
|
+
expect(report.timestamp).toBeTruthy();
|
|
782
|
+
expect(report.summary.totalRules).toBe(11);
|
|
783
|
+
expect(report.summary.totalConstraints).toBe(2);
|
|
784
|
+
expect(report.summary.healthScore).toBeGreaterThanOrEqual(0);
|
|
785
|
+
expect(report.summary.healthScore).toBeLessThanOrEqual(100);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('should include expectations gaps when provided', () => {
|
|
789
|
+
const engine = createPraxisEngine({ registry });
|
|
790
|
+
const expectations = new ExpectationSet({ name: 'test' });
|
|
791
|
+
expectations.add(
|
|
792
|
+
expectBehavior('payment-processing')
|
|
793
|
+
.onlyWhen('cart total positive'),
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const report = generateLedger(registry, engine, expectations);
|
|
797
|
+
expect(report.gaps.length).toBeGreaterThan(0);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('should include dead rules in report', () => {
|
|
801
|
+
const engine = createPraxisEngine({ registry });
|
|
802
|
+
const report = generateLedger(registry, engine);
|
|
803
|
+
|
|
804
|
+
// data.import and webhook.handler should NOT be dead because
|
|
805
|
+
// generateLedger uses all known event types from rules themselves
|
|
806
|
+
// They use IMPORT_DATA and WEBHOOK which ARE known from the rules
|
|
807
|
+
expect(report.deadRules.length).toBe(0);
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
describe('formatLedger', () => {
|
|
812
|
+
it('should produce markdown output', () => {
|
|
813
|
+
const engine = createPraxisEngine({ registry });
|
|
814
|
+
const report = generateLedger(registry, engine);
|
|
815
|
+
const markdown = formatLedger(report);
|
|
816
|
+
|
|
817
|
+
expect(markdown).toContain('# ');
|
|
818
|
+
expect(markdown).toContain('Decision Ledger Analysis');
|
|
819
|
+
expect(markdown).toContain('Health Score');
|
|
820
|
+
expect(markdown).toContain('Summary');
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('should include all sections when findings exist', () => {
|
|
824
|
+
const engine = createPraxisEngine({ registry });
|
|
825
|
+
const expectations = new ExpectationSet({ name: 'test' });
|
|
826
|
+
expectations.add(
|
|
827
|
+
expectBehavior('payment-processing')
|
|
828
|
+
.onlyWhen('cart total positive'),
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
const report = generateLedger(registry, engine, expectations);
|
|
832
|
+
const markdown = formatLedger(report);
|
|
833
|
+
|
|
834
|
+
// Should have gaps section
|
|
835
|
+
if (report.gaps.length > 0) {
|
|
836
|
+
expect(markdown).toContain('Gaps');
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Should have suggestions
|
|
840
|
+
if (report.suggestions.length > 0) {
|
|
841
|
+
expect(markdown).toContain('Suggestions');
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
describe('formatBuildOutput', () => {
|
|
847
|
+
it('should produce CI-friendly annotations', () => {
|
|
848
|
+
const engine = createPraxisEngine({ registry });
|
|
849
|
+
const report = generateLedger(registry, engine);
|
|
850
|
+
const output = formatBuildOutput(report);
|
|
851
|
+
|
|
852
|
+
expect(output).toContain('::group::');
|
|
853
|
+
expect(output).toContain('::endgroup::');
|
|
854
|
+
expect(output).toContain('Score:');
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it('should include errors for contradictions', () => {
|
|
858
|
+
const engine = createPraxisEngine({ registry });
|
|
859
|
+
const report = generateLedger(registry, engine);
|
|
860
|
+
const output = formatBuildOutput(report);
|
|
861
|
+
|
|
862
|
+
if (report.contradictions.length > 0) {
|
|
863
|
+
expect(output).toContain('::error');
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
869
|
+
// 11. Ledger Diffing
|
|
870
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
871
|
+
|
|
872
|
+
describe('diffLedgers', () => {
|
|
873
|
+
it('should detect added findings', () => {
|
|
874
|
+
const engine = createPraxisEngine({ registry });
|
|
875
|
+
const before = generateLedger(registry, engine);
|
|
876
|
+
|
|
877
|
+
// Add a problematic rule
|
|
878
|
+
registry.registerRule(defineRule<TestContext>({
|
|
879
|
+
id: 'orphan.consumer',
|
|
880
|
+
description: 'Consumes unknown fact',
|
|
881
|
+
eventTypes: ['MYSTERY_EVENT'],
|
|
882
|
+
impl: () => RuleResult.noop(),
|
|
883
|
+
contract: defineContract({
|
|
884
|
+
ruleId: 'orphan.consumer',
|
|
885
|
+
behavior: 'Reads mystery.fact',
|
|
886
|
+
examples: [
|
|
887
|
+
{ given: 'fact "mystery.fact" exists', when: 'MYSTERY_EVENT', then: 'emit result.fact' },
|
|
888
|
+
],
|
|
889
|
+
invariants: [],
|
|
890
|
+
}),
|
|
891
|
+
}));
|
|
892
|
+
|
|
893
|
+
const after = generateLedger(registry, engine);
|
|
894
|
+
const diff = diffLedgers(before, after);
|
|
895
|
+
|
|
896
|
+
expect(diff.changes.length).toBeGreaterThanOrEqual(0);
|
|
897
|
+
expect(diff.summary).toBeTruthy();
|
|
898
|
+
expect(diff.beforeTimestamp).toBe(before.timestamp);
|
|
899
|
+
expect(diff.afterTimestamp).toBe(after.timestamp);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it('should detect removed findings', () => {
|
|
903
|
+
const engine = createPraxisEngine({ registry });
|
|
904
|
+
|
|
905
|
+
// First report with expectations
|
|
906
|
+
const expectations = new ExpectationSet({ name: 'test' });
|
|
907
|
+
expectations.add(
|
|
908
|
+
expectBehavior('payment-processing')
|
|
909
|
+
.onlyWhen('cart total positive'),
|
|
910
|
+
);
|
|
911
|
+
const before = generateLedger(registry, engine, expectations);
|
|
912
|
+
|
|
913
|
+
// Second report without expectations (gap removed)
|
|
914
|
+
const after = generateLedger(registry, engine);
|
|
915
|
+
const diff = diffLedgers(before, after);
|
|
916
|
+
|
|
917
|
+
const removedGaps = diff.changes.filter(c => c.type === 'removed' && c.category === 'gap');
|
|
918
|
+
expect(removedGaps.length).toBeGreaterThan(0);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it('should calculate score delta', () => {
|
|
922
|
+
const engine = createPraxisEngine({ registry });
|
|
923
|
+
const before = generateLedger(registry, engine);
|
|
924
|
+
const after = generateLedger(registry, engine);
|
|
925
|
+
|
|
926
|
+
const diff = diffLedgers(before, after);
|
|
927
|
+
expect(typeof diff.scoreDelta).toBe('number');
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it('should produce human-readable summary', () => {
|
|
931
|
+
const engine = createPraxisEngine({ registry });
|
|
932
|
+
const before = generateLedger(registry, engine);
|
|
933
|
+
const after = generateLedger(registry, engine);
|
|
934
|
+
const diff = diffLedgers(before, after);
|
|
935
|
+
|
|
936
|
+
expect(diff.summary).toContain('Score');
|
|
937
|
+
expect(diff.summary).toContain('changes');
|
|
483
938
|
});
|
|
484
939
|
});
|
|
485
940
|
});
|