@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
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision Ledger — Contract Verification
|
|
3
|
+
*
|
|
4
|
+
* Goes beyond simple contract existence checking to actually run rules
|
|
5
|
+
* against their contract examples, verify invariants, and cross-reference
|
|
6
|
+
* fact dependencies between contracts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PraxisRegistry, RuleDescriptor } from '../core/rules.js';
|
|
10
|
+
import type { PraxisState, PraxisEvent, PraxisFact } from '../core/protocol.js';
|
|
11
|
+
import { RuleResult } from '../core/rule-result.js';
|
|
12
|
+
import type { Contract } from './types.js';
|
|
13
|
+
import type {
|
|
14
|
+
ContractVerificationResult,
|
|
15
|
+
ExampleVerification,
|
|
16
|
+
InvariantCheck,
|
|
17
|
+
ContractCoverageGap,
|
|
18
|
+
CrossReference,
|
|
19
|
+
} from './analyzer-types.js';
|
|
20
|
+
import { analyzeDependencyGraph } from './analyzer.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Actually run a rule's implementation against each contract example's
|
|
24
|
+
* `given` state and verify the output matches `then`.
|
|
25
|
+
*
|
|
26
|
+
* This is deeper than contract existence checking — it executes the rule.
|
|
27
|
+
*/
|
|
28
|
+
export function verifyContractExamples<TContext = unknown>(
|
|
29
|
+
rule: RuleDescriptor<TContext>,
|
|
30
|
+
contract: Contract,
|
|
31
|
+
): ContractVerificationResult {
|
|
32
|
+
const examples: ExampleVerification[] = [];
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < contract.examples.length; i++) {
|
|
35
|
+
const example = contract.examples[i];
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Build synthetic state from `given`
|
|
39
|
+
const state = buildStateFromDescription<TContext>(example.given);
|
|
40
|
+
|
|
41
|
+
// Build events from `when`
|
|
42
|
+
const events = buildEventsFromDescription(example.when, rule.eventTypes);
|
|
43
|
+
|
|
44
|
+
// Add events to state so rules can access state.events
|
|
45
|
+
const stateWithEvents = { ...state, events };
|
|
46
|
+
|
|
47
|
+
// Execute the rule
|
|
48
|
+
const result = rule.impl(stateWithEvents, events);
|
|
49
|
+
|
|
50
|
+
// Check if output matches `then`
|
|
51
|
+
const verification = verifyOutput(result, example.then, rule.id);
|
|
52
|
+
|
|
53
|
+
examples.push({
|
|
54
|
+
index: i,
|
|
55
|
+
given: example.given,
|
|
56
|
+
when: example.when,
|
|
57
|
+
expectedThen: example.then,
|
|
58
|
+
passed: verification.passed,
|
|
59
|
+
actualOutput: verification.actualOutput,
|
|
60
|
+
error: verification.error,
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
examples.push({
|
|
64
|
+
index: i,
|
|
65
|
+
given: example.given,
|
|
66
|
+
when: example.when,
|
|
67
|
+
expectedThen: example.then,
|
|
68
|
+
passed: false,
|
|
69
|
+
error: `Execution error: ${error instanceof Error ? error.message : String(error)}`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const passCount = examples.filter(e => e.passed).length;
|
|
75
|
+
const failCount = examples.filter(e => !e.passed).length;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
ruleId: rule.id,
|
|
79
|
+
examples,
|
|
80
|
+
allPassed: failCount === 0,
|
|
81
|
+
passCount,
|
|
82
|
+
failCount,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check that stated invariants hold across all rules.
|
|
88
|
+
*
|
|
89
|
+
* For each rule with a contract, check if the invariants are consistent
|
|
90
|
+
* with the rule's behavior description and examples.
|
|
91
|
+
*/
|
|
92
|
+
export function verifyInvariants<TContext = unknown>(
|
|
93
|
+
registry: PraxisRegistry<TContext>,
|
|
94
|
+
): InvariantCheck[] {
|
|
95
|
+
const checks: InvariantCheck[] = [];
|
|
96
|
+
const rules = registry.getAllRules();
|
|
97
|
+
|
|
98
|
+
for (const rule of rules) {
|
|
99
|
+
if (!rule.contract) continue;
|
|
100
|
+
|
|
101
|
+
for (const invariant of rule.contract.invariants) {
|
|
102
|
+
// Check if the invariant is consistent with examples
|
|
103
|
+
const consistent = rule.contract.examples.every(example => {
|
|
104
|
+
return isConsistentWithInvariant(example, invariant);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
checks.push({
|
|
108
|
+
invariant,
|
|
109
|
+
ruleId: rule.id,
|
|
110
|
+
holds: consistent,
|
|
111
|
+
explanation: consistent
|
|
112
|
+
? `Invariant "${invariant}" is consistent with all ${rule.contract.examples.length} examples`
|
|
113
|
+
: `Invariant "${invariant}" may be violated by one or more examples`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return checks;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Find rules with contracts that don't cover all code paths.
|
|
123
|
+
*
|
|
124
|
+
* Analyzes contract examples to find:
|
|
125
|
+
* - Rules with only happy-path examples (no error cases)
|
|
126
|
+
* - Rules with no boundary condition examples
|
|
127
|
+
* - Rules that handle multiple event types but only have examples for some
|
|
128
|
+
*/
|
|
129
|
+
export function findContractGaps<TContext = unknown>(
|
|
130
|
+
registry: PraxisRegistry<TContext>,
|
|
131
|
+
): ContractCoverageGap[] {
|
|
132
|
+
const gaps: ContractCoverageGap[] = [];
|
|
133
|
+
const rules = registry.getAllRules();
|
|
134
|
+
|
|
135
|
+
for (const rule of rules) {
|
|
136
|
+
if (!rule.contract) continue;
|
|
137
|
+
|
|
138
|
+
const examples = rule.contract.examples;
|
|
139
|
+
|
|
140
|
+
// Check: no error/failure examples
|
|
141
|
+
const hasErrorExamples = examples.some(
|
|
142
|
+
ex =>
|
|
143
|
+
ex.then.toLowerCase().includes('error') ||
|
|
144
|
+
ex.then.toLowerCase().includes('fail') ||
|
|
145
|
+
ex.then.toLowerCase().includes('skip') ||
|
|
146
|
+
ex.then.toLowerCase().includes('noop') ||
|
|
147
|
+
ex.then.toLowerCase().includes('retract') ||
|
|
148
|
+
ex.then.toLowerCase().includes('violation'),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (!hasErrorExamples && examples.length > 0) {
|
|
152
|
+
gaps.push({
|
|
153
|
+
ruleId: rule.id,
|
|
154
|
+
description: `No error/failure path examples. All ${examples.length} examples show happy paths`,
|
|
155
|
+
type: 'missing-error-path',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check: multiple event types but not all covered in examples
|
|
160
|
+
if (rule.eventTypes) {
|
|
161
|
+
const eventTypes = Array.isArray(rule.eventTypes) ? rule.eventTypes : [rule.eventTypes];
|
|
162
|
+
if (eventTypes.length > 1) {
|
|
163
|
+
const coveredTypes = new Set<string>();
|
|
164
|
+
for (const ex of examples) {
|
|
165
|
+
for (const et of eventTypes) {
|
|
166
|
+
if (
|
|
167
|
+
ex.when.toLowerCase().includes(et.toLowerCase()) ||
|
|
168
|
+
ex.when.toLowerCase().includes(et.replace('.', ' ').toLowerCase())
|
|
169
|
+
) {
|
|
170
|
+
coveredTypes.add(et);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const uncovered = eventTypes.filter(et => !coveredTypes.has(et));
|
|
176
|
+
if (uncovered.length > 0) {
|
|
177
|
+
gaps.push({
|
|
178
|
+
ruleId: rule.id,
|
|
179
|
+
description: `Event types [${uncovered.join(', ')}] not covered by any example`,
|
|
180
|
+
type: 'missing-edge-case',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check: only one example (likely insufficient)
|
|
187
|
+
if (examples.length === 1) {
|
|
188
|
+
gaps.push({
|
|
189
|
+
ruleId: rule.id,
|
|
190
|
+
description: `Only 1 contract example — likely missing boundary conditions and edge cases`,
|
|
191
|
+
type: 'missing-boundary',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return gaps;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Find contracts that reference facts from other rules and verify
|
|
201
|
+
* those producing rules actually exist.
|
|
202
|
+
*/
|
|
203
|
+
export function crossReferenceContracts<TContext = unknown>(
|
|
204
|
+
registry: PraxisRegistry<TContext>,
|
|
205
|
+
): CrossReference[] {
|
|
206
|
+
const graph = analyzeDependencyGraph(registry);
|
|
207
|
+
const references: CrossReference[] = [];
|
|
208
|
+
const rules = registry.getAllRules();
|
|
209
|
+
|
|
210
|
+
for (const rule of rules) {
|
|
211
|
+
if (!rule.contract) continue;
|
|
212
|
+
|
|
213
|
+
// Scan contract examples for references to fact tags
|
|
214
|
+
for (const example of rule.contract.examples) {
|
|
215
|
+
const referencedTags = extractReferencedFactTags(example.given + ' ' + example.when);
|
|
216
|
+
|
|
217
|
+
for (const tag of referencedTags) {
|
|
218
|
+
const factNode = graph.facts.get(tag);
|
|
219
|
+
const producerRuleId = factNode?.producedBy[0] ?? null;
|
|
220
|
+
const valid = factNode ? factNode.producedBy.length > 0 : false;
|
|
221
|
+
|
|
222
|
+
// Don't self-reference
|
|
223
|
+
if (producerRuleId === rule.id) continue;
|
|
224
|
+
|
|
225
|
+
references.push({
|
|
226
|
+
sourceRuleId: rule.id,
|
|
227
|
+
referencedFactTag: tag,
|
|
228
|
+
producerRuleId,
|
|
229
|
+
valid,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return references;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Build a minimal synthetic state from a "given" description.
|
|
242
|
+
*/
|
|
243
|
+
function buildStateFromDescription<TContext>(given: string): PraxisState & { context: TContext } {
|
|
244
|
+
// Parse simple key-value patterns from given text
|
|
245
|
+
const context = {} as TContext;
|
|
246
|
+
const facts: PraxisFact[] = [];
|
|
247
|
+
|
|
248
|
+
// Extract fact references from given (e.g., "fact 'user.loggedIn' exists")
|
|
249
|
+
const factPattern = /fact\s+['"]([^'"]+)['"]/gi;
|
|
250
|
+
let match;
|
|
251
|
+
while ((match = factPattern.exec(given)) !== null) {
|
|
252
|
+
facts.push({ tag: match[1], payload: {} });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Also check for dotted identifiers that look like facts
|
|
256
|
+
const dottedPattern = /['"]([a-zA-Z][a-zA-Z0-9]*\.[a-zA-Z][a-zA-Z0-9.]*)['"]/g;
|
|
257
|
+
while ((match = dottedPattern.exec(given)) !== null) {
|
|
258
|
+
const tag = match[1];
|
|
259
|
+
if (!facts.some(f => f.tag === tag)) {
|
|
260
|
+
facts.push({ tag, payload: {} });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
context,
|
|
266
|
+
facts,
|
|
267
|
+
meta: {},
|
|
268
|
+
protocolVersion: '1.0.0',
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Build synthetic events from a "when" description.
|
|
274
|
+
*/
|
|
275
|
+
function buildEventsFromDescription(
|
|
276
|
+
when: string,
|
|
277
|
+
eventTypes?: string | string[],
|
|
278
|
+
): PraxisEvent[] {
|
|
279
|
+
const events: PraxisEvent[] = [];
|
|
280
|
+
|
|
281
|
+
// Use rule's declared event types first
|
|
282
|
+
if (eventTypes) {
|
|
283
|
+
const types = Array.isArray(eventTypes) ? eventTypes : [eventTypes];
|
|
284
|
+
for (const type of types) {
|
|
285
|
+
if (when.toLowerCase().includes(type.toLowerCase()) || when.toLowerCase().includes(type.replace('.', ' ').toLowerCase())) {
|
|
286
|
+
events.push({ tag: type, payload: {} });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// If no events built from types, create one from the when description
|
|
292
|
+
if (events.length === 0) {
|
|
293
|
+
// Look for event tag patterns in the when text
|
|
294
|
+
const eventPattern = /['"]([A-Z][A-Z0-9._-]+)['"]/g;
|
|
295
|
+
let match;
|
|
296
|
+
while ((match = eventPattern.exec(when)) !== null) {
|
|
297
|
+
events.push({ tag: match[1], payload: {} });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Fallback: create a generic event
|
|
302
|
+
if (events.length === 0) {
|
|
303
|
+
const types = eventTypes
|
|
304
|
+
? (Array.isArray(eventTypes) ? eventTypes : [eventTypes])
|
|
305
|
+
: ['__test__'];
|
|
306
|
+
events.push({ tag: types[0], payload: {} });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return events;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Verify rule output against expected "then" description.
|
|
314
|
+
*/
|
|
315
|
+
function verifyOutput(
|
|
316
|
+
result: unknown,
|
|
317
|
+
expectedThen: string,
|
|
318
|
+
ruleId: string,
|
|
319
|
+
): { passed: boolean; actualOutput?: string; error?: string } {
|
|
320
|
+
const thenLower = expectedThen.toLowerCase();
|
|
321
|
+
|
|
322
|
+
if (result instanceof RuleResult) {
|
|
323
|
+
switch (result.kind) {
|
|
324
|
+
case 'emit': {
|
|
325
|
+
// Check if the emitted facts match the expected output
|
|
326
|
+
const factTags = result.facts.map(f => f.tag);
|
|
327
|
+
const actualOutput = `Emitted: [${factTags.join(', ')}]`;
|
|
328
|
+
|
|
329
|
+
// Check for specific fact tag mentions
|
|
330
|
+
const emitExpected =
|
|
331
|
+
thenLower.includes('emit') ||
|
|
332
|
+
thenLower.includes('produce') ||
|
|
333
|
+
thenLower.includes('fact');
|
|
334
|
+
|
|
335
|
+
if (emitExpected) {
|
|
336
|
+
// Check if any mentioned fact tags are in the output
|
|
337
|
+
const expectedTags = extractReferencedFactTags(expectedThen);
|
|
338
|
+
if (expectedTags.length > 0) {
|
|
339
|
+
const allFound = expectedTags.every(tag =>
|
|
340
|
+
factTags.some(ft => ft.toLowerCase() === tag.toLowerCase()),
|
|
341
|
+
);
|
|
342
|
+
return { passed: allFound, actualOutput };
|
|
343
|
+
}
|
|
344
|
+
return { passed: true, actualOutput };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { passed: true, actualOutput };
|
|
348
|
+
}
|
|
349
|
+
case 'noop': {
|
|
350
|
+
const expectNoop =
|
|
351
|
+
thenLower.includes('noop') ||
|
|
352
|
+
thenLower.includes('nothing') ||
|
|
353
|
+
thenLower.includes('no fact') ||
|
|
354
|
+
thenLower.includes('skip');
|
|
355
|
+
return {
|
|
356
|
+
passed: expectNoop,
|
|
357
|
+
actualOutput: `Noop: ${result.reason ?? 'no reason'}`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
case 'skip': {
|
|
361
|
+
const expectSkip =
|
|
362
|
+
thenLower.includes('skip') ||
|
|
363
|
+
thenLower.includes('noop') ||
|
|
364
|
+
thenLower.includes('not fire') ||
|
|
365
|
+
thenLower.includes('nothing');
|
|
366
|
+
return {
|
|
367
|
+
passed: expectSkip,
|
|
368
|
+
actualOutput: `Skip: ${result.reason ?? 'no reason'}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
case 'retract': {
|
|
372
|
+
const expectRetract =
|
|
373
|
+
thenLower.includes('retract') ||
|
|
374
|
+
thenLower.includes('remove') ||
|
|
375
|
+
thenLower.includes('clear');
|
|
376
|
+
return {
|
|
377
|
+
passed: expectRetract,
|
|
378
|
+
actualOutput: `Retract: [${result.retractTags.join(', ')}]`,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
} else if (Array.isArray(result)) {
|
|
383
|
+
// Legacy PraxisFact[] return
|
|
384
|
+
const factTags = (result as PraxisFact[]).map(f => f.tag);
|
|
385
|
+
const actualOutput = `Facts: [${factTags.join(', ')}]`;
|
|
386
|
+
return { passed: factTags.length > 0 || thenLower.includes('nothing'), actualOutput };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { passed: false, error: `Unexpected result type from rule "${ruleId}"` };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Check if a contract example is consistent with an invariant statement.
|
|
394
|
+
*/
|
|
395
|
+
function isConsistentWithInvariant(
|
|
396
|
+
example: { given: string; when: string; then: string },
|
|
397
|
+
invariant: string,
|
|
398
|
+
): boolean {
|
|
399
|
+
const invLower = invariant.toLowerCase();
|
|
400
|
+
const thenLower = example.then.toLowerCase();
|
|
401
|
+
|
|
402
|
+
// Check for explicit contradictions
|
|
403
|
+
// "must not" invariant vs "does" in then
|
|
404
|
+
if (invLower.includes('must not') || invLower.includes('never')) {
|
|
405
|
+
const keyword = invLower
|
|
406
|
+
.replace(/must not|never|should not/g, '')
|
|
407
|
+
.trim()
|
|
408
|
+
.split(/\s+/)[0];
|
|
409
|
+
if (keyword && thenLower.includes(keyword)) {
|
|
410
|
+
return false; // Potential violation
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// "must" invariant — check the then doesn't contradict
|
|
415
|
+
if (invLower.includes('must ') && !invLower.includes('must not')) {
|
|
416
|
+
// Extract what must happen
|
|
417
|
+
const mustPart = invLower.split('must ')[1]?.split(/[.,;]/)[0]?.trim();
|
|
418
|
+
if (mustPart) {
|
|
419
|
+
// If then explicitly contradicts
|
|
420
|
+
const negations = ['no ', 'not ', 'never ', 'without '];
|
|
421
|
+
for (const neg of negations) {
|
|
422
|
+
if (thenLower.includes(neg + mustPart.split(/\s+/)[0])) {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return true; // No contradiction detected
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Extract fact tags referenced in text.
|
|
434
|
+
*/
|
|
435
|
+
function extractReferencedFactTags(text: string): string[] {
|
|
436
|
+
const tags: string[] = [];
|
|
437
|
+
|
|
438
|
+
// Match dotted identifiers that look like fact tags
|
|
439
|
+
const patterns = [
|
|
440
|
+
/['"]([a-zA-Z][a-zA-Z0-9]*\.[a-zA-Z][a-zA-Z0-9.]*)['"]/g,
|
|
441
|
+
/fact\s+['"]?([a-zA-Z][a-zA-Z0-9._-]+)['"]?/gi,
|
|
442
|
+
/(?:emit|produce|retract)\s+['"]?([a-zA-Z][a-zA-Z0-9._-]+)['"]?/gi,
|
|
443
|
+
];
|
|
444
|
+
|
|
445
|
+
for (const pattern of patterns) {
|
|
446
|
+
let match;
|
|
447
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
448
|
+
const tag = match[1];
|
|
449
|
+
if (tag && !tags.includes(tag)) {
|
|
450
|
+
tags.push(tag);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return tags;
|
|
456
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision Ledger — Fact Derivation Tracing
|
|
3
|
+
*
|
|
4
|
+
* Trace how facts are derived through chains of rule firings,
|
|
5
|
+
* and analyze the impact of removing a fact from the system.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PraxisRegistry } from '../core/rules.js';
|
|
9
|
+
import type { LogicEngine } from '../core/engine.js';
|
|
10
|
+
import type { DerivationChain, DerivationStep, ImpactReport } from './analyzer-types.js';
|
|
11
|
+
import { analyzeDependencyGraph } from './analyzer.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Trace how a fact was derived through the rule chain.
|
|
15
|
+
*
|
|
16
|
+
* Starting from the fact tag, walks backward through the dependency graph
|
|
17
|
+
* to find the full derivation chain: event → rule A → fact X → rule B → fact Y
|
|
18
|
+
*
|
|
19
|
+
* Uses the engine's current state to identify which rules actually fired.
|
|
20
|
+
*/
|
|
21
|
+
export function traceDerivation<TContext = unknown>(
|
|
22
|
+
factTag: string,
|
|
23
|
+
_engine: LogicEngine<TContext>,
|
|
24
|
+
registry: PraxisRegistry<TContext>,
|
|
25
|
+
): DerivationChain {
|
|
26
|
+
const graph = analyzeDependencyGraph(registry);
|
|
27
|
+
const steps: DerivationStep[] = [];
|
|
28
|
+
const visited = new Set<string>();
|
|
29
|
+
|
|
30
|
+
function walkBackward(tag: string, depth: number): void {
|
|
31
|
+
if (visited.has(tag) || depth > 20) return; // prevent cycles
|
|
32
|
+
visited.add(tag);
|
|
33
|
+
|
|
34
|
+
const factNode = graph.facts.get(tag);
|
|
35
|
+
if (!factNode) {
|
|
36
|
+
steps.unshift({
|
|
37
|
+
type: 'fact-produced',
|
|
38
|
+
id: tag,
|
|
39
|
+
description: `Fact "${tag}" (origin unknown — not in dependency graph)`,
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// This fact was produced
|
|
45
|
+
steps.unshift({
|
|
46
|
+
type: 'fact-produced',
|
|
47
|
+
id: tag,
|
|
48
|
+
description: `Fact "${tag}" produced`,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Walk to producers
|
|
52
|
+
for (const ruleId of factNode.producedBy) {
|
|
53
|
+
if (visited.has(ruleId)) continue;
|
|
54
|
+
visited.add(ruleId);
|
|
55
|
+
|
|
56
|
+
const rule = registry.getRule(ruleId);
|
|
57
|
+
steps.unshift({
|
|
58
|
+
type: 'rule-fired',
|
|
59
|
+
id: ruleId,
|
|
60
|
+
description: `Rule "${ruleId}" fired${rule ? `: ${rule.description}` : ''}`,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// What does this rule consume?
|
|
64
|
+
const consumed = graph.consumers.get(ruleId) ?? [];
|
|
65
|
+
for (const consumedTag of consumed) {
|
|
66
|
+
steps.unshift({
|
|
67
|
+
type: 'fact-read',
|
|
68
|
+
id: consumedTag,
|
|
69
|
+
description: `Rule "${ruleId}" reads fact "${consumedTag}"`,
|
|
70
|
+
});
|
|
71
|
+
walkBackward(consumedTag, depth + 1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// What events trigger this rule?
|
|
75
|
+
if (rule?.eventTypes) {
|
|
76
|
+
const types = Array.isArray(rule.eventTypes) ? rule.eventTypes : [rule.eventTypes];
|
|
77
|
+
for (const eventType of types) {
|
|
78
|
+
steps.unshift({
|
|
79
|
+
type: 'event',
|
|
80
|
+
id: eventType,
|
|
81
|
+
description: `Event "${eventType}" triggers rule "${ruleId}"`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
walkBackward(factTag, 0);
|
|
89
|
+
|
|
90
|
+
// Deduplicate steps while preserving order
|
|
91
|
+
const seen = new Set<string>();
|
|
92
|
+
const dedupedSteps = steps.filter(step => {
|
|
93
|
+
const key = `${step.type}:${step.id}`;
|
|
94
|
+
if (seen.has(key)) return false;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
targetFact: factTag,
|
|
101
|
+
steps: dedupedSteps,
|
|
102
|
+
depth: dedupedSteps.filter(s => s.type === 'rule-fired').length,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Trace the impact of removing a fact from the system.
|
|
108
|
+
*
|
|
109
|
+
* Returns which rules would stop firing and which downstream facts
|
|
110
|
+
* would disappear if the given fact were removed.
|
|
111
|
+
*/
|
|
112
|
+
export function traceImpact<TContext = unknown>(
|
|
113
|
+
factTag: string,
|
|
114
|
+
registry: PraxisRegistry<TContext>,
|
|
115
|
+
): ImpactReport {
|
|
116
|
+
const graph = analyzeDependencyGraph(registry);
|
|
117
|
+
const affectedRules: string[] = [];
|
|
118
|
+
const affectedFacts: string[] = [];
|
|
119
|
+
const visited = new Set<string>();
|
|
120
|
+
|
|
121
|
+
function walkForward(tag: string, depth: number): number {
|
|
122
|
+
if (visited.has(tag) || depth > 20) return depth;
|
|
123
|
+
visited.add(tag);
|
|
124
|
+
|
|
125
|
+
const factNode = graph.facts.get(tag);
|
|
126
|
+
if (!factNode) return depth;
|
|
127
|
+
|
|
128
|
+
let maxDepth = depth;
|
|
129
|
+
|
|
130
|
+
// Find rules that consume this fact
|
|
131
|
+
for (const ruleId of factNode.consumedBy) {
|
|
132
|
+
if (!affectedRules.includes(ruleId)) {
|
|
133
|
+
affectedRules.push(ruleId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// What facts do those rules produce? Those would also disappear.
|
|
137
|
+
const produced = graph.producers.get(ruleId) ?? [];
|
|
138
|
+
for (const producedTag of produced) {
|
|
139
|
+
if (producedTag !== factTag && !affectedFacts.includes(producedTag)) {
|
|
140
|
+
affectedFacts.push(producedTag);
|
|
141
|
+
const childDepth = walkForward(producedTag, depth + 1);
|
|
142
|
+
if (childDepth > maxDepth) maxDepth = childDepth;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return maxDepth;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const depth = walkForward(factTag, 0);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
factTag,
|
|
154
|
+
affectedRules,
|
|
155
|
+
affectedFacts,
|
|
156
|
+
depth,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -68,3 +68,62 @@ export {
|
|
|
68
68
|
type GenerationResult,
|
|
69
69
|
generateContractFromRule,
|
|
70
70
|
} from './reverse-generator.js';
|
|
71
|
+
|
|
72
|
+
// ─── Analyzer Engine ────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
type FactNode,
|
|
76
|
+
type DependencyEdge,
|
|
77
|
+
type DependencyGraph,
|
|
78
|
+
type DerivationStep,
|
|
79
|
+
type DerivationChain,
|
|
80
|
+
type DeadRule,
|
|
81
|
+
type UnreachableState,
|
|
82
|
+
type ShadowedRule,
|
|
83
|
+
type Contradiction,
|
|
84
|
+
type Gap,
|
|
85
|
+
type ImpactReport,
|
|
86
|
+
type ExampleVerification,
|
|
87
|
+
type ContractVerificationResult,
|
|
88
|
+
type InvariantCheck,
|
|
89
|
+
type ContractCoverageGap,
|
|
90
|
+
type CrossReference,
|
|
91
|
+
type FindingType,
|
|
92
|
+
type Suggestion,
|
|
93
|
+
type AnalysisReport,
|
|
94
|
+
type LedgerDiffEntry,
|
|
95
|
+
type LedgerDiff,
|
|
96
|
+
} from './analyzer-types.js';
|
|
97
|
+
|
|
98
|
+
export {
|
|
99
|
+
analyzeDependencyGraph,
|
|
100
|
+
findDeadRules,
|
|
101
|
+
findUnreachableStates,
|
|
102
|
+
findShadowedRules,
|
|
103
|
+
findContradictions,
|
|
104
|
+
findGaps,
|
|
105
|
+
} from './analyzer.js';
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
traceDerivation,
|
|
109
|
+
traceImpact,
|
|
110
|
+
} from './derivation.js';
|
|
111
|
+
|
|
112
|
+
export {
|
|
113
|
+
verifyContractExamples,
|
|
114
|
+
verifyInvariants,
|
|
115
|
+
findContractGaps,
|
|
116
|
+
crossReferenceContracts,
|
|
117
|
+
} from './contract-verification.js';
|
|
118
|
+
|
|
119
|
+
export {
|
|
120
|
+
suggest,
|
|
121
|
+
suggestAll,
|
|
122
|
+
} from './suggestions.js';
|
|
123
|
+
|
|
124
|
+
export {
|
|
125
|
+
generateLedger,
|
|
126
|
+
formatLedger,
|
|
127
|
+
formatBuildOutput,
|
|
128
|
+
diffLedgers,
|
|
129
|
+
} from './report.js';
|