@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.
Files changed (72) hide show
  1. package/dist/browser/{chunk-N63K4KWS.js → chunk-4IRUGWR3.js} +1 -1
  2. package/dist/browser/chunk-6MVRT7CK.js +363 -0
  3. package/dist/browser/chunk-6SJ44Q64.js +473 -0
  4. package/dist/browser/chunk-BQOYZBWA.js +282 -0
  5. package/dist/browser/chunk-IG5BJ2MT.js +91 -0
  6. package/dist/browser/{chunk-MJK3IYTJ.js → chunk-JZDJU2DO.js} +4 -84
  7. package/dist/browser/chunk-ZEW4LJAJ.js +353 -0
  8. package/dist/browser/{engine-YIEGSX7U.js → engine-3B5WJPGT.js} +2 -1
  9. package/dist/browser/expectations/index.d.ts +180 -0
  10. package/dist/browser/expectations/index.js +14 -0
  11. package/dist/browser/factory/index.d.ts +150 -0
  12. package/dist/browser/factory/index.js +15 -0
  13. package/dist/browser/index.d.ts +277 -3
  14. package/dist/browser/index.js +425 -60
  15. package/dist/browser/integrations/svelte.d.ts +4 -2
  16. package/dist/browser/integrations/svelte.js +3 -2
  17. package/dist/browser/project/index.d.ts +177 -0
  18. package/dist/browser/project/index.js +19 -0
  19. package/dist/browser/reactive-engine.svelte-BwWadvAW.d.ts +224 -0
  20. package/dist/browser/rule-result-DcXWe9tn.d.ts +206 -0
  21. package/dist/browser/rules-BaWMqxuG.d.ts +277 -0
  22. package/dist/browser/unified/index.d.ts +239 -0
  23. package/dist/browser/unified/index.js +20 -0
  24. package/dist/node/chunk-6MVRT7CK.js +363 -0
  25. package/dist/node/chunk-AZLNISFI.js +1690 -0
  26. package/dist/node/chunk-IG5BJ2MT.js +91 -0
  27. package/dist/node/{chunk-KMJWAFZV.js → chunk-JZDJU2DO.js} +4 -89
  28. package/dist/node/{chunk-7M3HV4XR.js → chunk-WFRHXZBP.js} +3 -3
  29. package/dist/node/cli/index.cjs +48 -0
  30. package/dist/node/cli/index.js +2 -2
  31. package/dist/node/{engine-FEN5IYZ5.js → engine-VFHCIEM4.js} +2 -1
  32. package/dist/node/index.cjs +2114 -0
  33. package/dist/node/index.d.cts +964 -280
  34. package/dist/node/index.d.ts +964 -280
  35. package/dist/node/index.js +575 -10
  36. package/dist/node/integrations/svelte.d.cts +3 -2
  37. package/dist/node/integrations/svelte.d.ts +3 -2
  38. package/dist/node/integrations/svelte.js +3 -2
  39. package/dist/node/{reactive-engine.svelte-DekxqFu0.d.ts → reactive-engine.svelte-BBZLMzus.d.ts} +3 -79
  40. package/dist/node/{reactive-engine.svelte-Cg0Yc2Hs.d.cts → reactive-engine.svelte-Cbq_V20o.d.cts} +3 -79
  41. package/dist/node/rule-result-B9GMivAn.d.cts +80 -0
  42. package/dist/node/rule-result-Bo3sFMmN.d.ts +80 -0
  43. package/dist/node/{server-SYZPDULV.js → server-FKLVY57V.js} +4 -2
  44. package/dist/node/unified/index.cjs +484 -0
  45. package/dist/node/unified/index.d.cts +240 -0
  46. package/dist/node/unified/index.d.ts +240 -0
  47. package/dist/node/unified/index.js +21 -0
  48. package/dist/node/{validate-TQGVIG7G.js → validate-BY7JNY7H.js} +2 -1
  49. package/package.json +38 -11
  50. package/src/__tests__/chronos-project.test.ts +799 -0
  51. package/src/__tests__/decision-ledger.test.ts +857 -402
  52. package/src/chronos/diff.ts +336 -0
  53. package/src/chronos/hooks.ts +227 -0
  54. package/src/chronos/index.ts +83 -0
  55. package/src/chronos/project-chronicle.ts +198 -0
  56. package/src/chronos/timeline.ts +152 -0
  57. package/src/decision-ledger/analyzer-types.ts +280 -0
  58. package/src/decision-ledger/analyzer.ts +518 -0
  59. package/src/decision-ledger/contract-verification.ts +456 -0
  60. package/src/decision-ledger/derivation.ts +158 -0
  61. package/src/decision-ledger/index.ts +59 -0
  62. package/src/decision-ledger/report.ts +378 -0
  63. package/src/decision-ledger/suggestions.ts +287 -0
  64. package/src/index.browser.ts +103 -0
  65. package/src/index.ts +98 -0
  66. package/src/unified/__tests__/unified.test.ts +396 -0
  67. package/src/unified/core.ts +517 -0
  68. package/src/unified/index.ts +32 -0
  69. package/src/unified/rules.ts +66 -0
  70. package/src/unified/types.ts +148 -0
  71. package/dist/browser/reactive-engine.svelte-DjynI82A.d.ts +0 -688
  72. package/dist/node/chunk-FWOXU4MM.js +0 -487
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Decision Ledger — Report Generation
3
+ *
4
+ * Generates the full analysis report, human-readable markdown,
5
+ * CI-friendly output, and diffs between analysis runs.
6
+ */
7
+
8
+ import type { PraxisRegistry } from '../core/rules.js';
9
+ import type { LogicEngine } from '../core/engine.js';
10
+ import type { ExpectationSet } from '../expectations/expectations.js';
11
+ import type {
12
+ AnalysisReport,
13
+ LedgerDiff,
14
+ LedgerDiffEntry,
15
+ } from './analyzer-types.js';
16
+ import {
17
+ findDeadRules,
18
+ findUnreachableStates,
19
+ findShadowedRules,
20
+ findContradictions,
21
+ findGaps,
22
+ } from './analyzer.js';
23
+ import { traceDerivation } from './derivation.js';
24
+ import { suggestAll } from './suggestions.js';
25
+ import { findContractGaps } from './contract-verification.js';
26
+
27
+ /**
28
+ * Generate the full analysis report.
29
+ *
30
+ * This is the main entry point for the Decision Ledger analyzer.
31
+ * It runs all analyses and produces a comprehensive report.
32
+ */
33
+ export function generateLedger<TContext = unknown>(
34
+ registry: PraxisRegistry<TContext>,
35
+ engine: LogicEngine<TContext>,
36
+ expectations?: ExpectationSet,
37
+ ): AnalysisReport {
38
+ // Collect all known event types from rules
39
+ const allEventTypes = new Set<string>();
40
+ for (const rule of registry.getAllRules()) {
41
+ if (rule.eventTypes) {
42
+ const types = Array.isArray(rule.eventTypes) ? rule.eventTypes : [rule.eventTypes];
43
+ for (const t of types) allEventTypes.add(t);
44
+ }
45
+ }
46
+
47
+ // Run analyses
48
+ const deadRules = findDeadRules(registry, [...allEventTypes]);
49
+ const unreachableStates = findUnreachableStates(registry);
50
+ const shadowedRules = findShadowedRules(registry);
51
+ const contradictions = findContradictions(registry);
52
+ const contractGaps = findContractGaps(registry);
53
+ const gaps = expectations ? findGaps(registry, expectations) : [];
54
+
55
+ // Build derivation chains for current facts
56
+ const currentFacts = engine.getFacts();
57
+ const factDerivationChains = currentFacts.map(fact =>
58
+ traceDerivation(fact.tag, engine, registry),
59
+ );
60
+
61
+ // Generate suggestions
62
+ const suggestions = suggestAll({
63
+ deadRules,
64
+ gaps,
65
+ contradictions,
66
+ unreachableStates,
67
+ shadowedRules,
68
+ contractGaps,
69
+ });
70
+
71
+ // Calculate health score
72
+ const totalRules = registry.getRuleIds().length;
73
+ const totalConstraints = registry.getConstraintIds().length;
74
+ const totalIssues =
75
+ deadRules.length +
76
+ unreachableStates.length +
77
+ shadowedRules.length +
78
+ contradictions.length +
79
+ gaps.length;
80
+
81
+ const maxIssues = Math.max(totalRules + totalConstraints, 1);
82
+ const healthScore = Math.max(0, Math.round(100 - (totalIssues / maxIssues) * 100));
83
+
84
+ return {
85
+ timestamp: new Date().toISOString(),
86
+ factDerivationChains,
87
+ deadRules,
88
+ unreachableStates,
89
+ shadowedRules,
90
+ contradictions,
91
+ gaps,
92
+ suggestions,
93
+ summary: {
94
+ totalRules,
95
+ totalConstraints,
96
+ deadRuleCount: deadRules.length,
97
+ unreachableStateCount: unreachableStates.length,
98
+ shadowedRuleCount: shadowedRules.length,
99
+ contradictionCount: contradictions.length,
100
+ gapCount: gaps.length,
101
+ suggestionCount: suggestions.length,
102
+ healthScore,
103
+ },
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Format an analysis report as human-readable markdown.
109
+ */
110
+ export function formatLedger(report: AnalysisReport): string {
111
+ const lines: string[] = [];
112
+
113
+ // Header
114
+ const icon = report.summary.healthScore >= 90 ? '✅' : report.summary.healthScore >= 70 ? '🟡' : '🔴';
115
+ lines.push(`# ${icon} Decision Ledger Analysis`);
116
+ lines.push('');
117
+ lines.push(`**Health Score:** ${report.summary.healthScore}/100`);
118
+ lines.push(`**Timestamp:** ${report.timestamp}`);
119
+ lines.push('');
120
+
121
+ // Summary
122
+ lines.push('## Summary');
123
+ lines.push('');
124
+ lines.push(`| Metric | Count |`);
125
+ lines.push(`|--------|-------|`);
126
+ lines.push(`| Rules | ${report.summary.totalRules} |`);
127
+ lines.push(`| Constraints | ${report.summary.totalConstraints} |`);
128
+ lines.push(`| Dead Rules | ${report.summary.deadRuleCount} |`);
129
+ lines.push(`| Unreachable States | ${report.summary.unreachableStateCount} |`);
130
+ lines.push(`| Shadowed Rules | ${report.summary.shadowedRuleCount} |`);
131
+ lines.push(`| Contradictions | ${report.summary.contradictionCount} |`);
132
+ lines.push(`| Gaps | ${report.summary.gapCount} |`);
133
+ lines.push(`| Suggestions | ${report.summary.suggestionCount} |`);
134
+ lines.push('');
135
+
136
+ // Dead Rules
137
+ if (report.deadRules.length > 0) {
138
+ lines.push('## 💀 Dead Rules');
139
+ lines.push('');
140
+ for (const dr of report.deadRules) {
141
+ lines.push(`- **${dr.ruleId}**: ${dr.reason}`);
142
+ }
143
+ lines.push('');
144
+ }
145
+
146
+ // Unreachable States
147
+ if (report.unreachableStates.length > 0) {
148
+ lines.push('## 🚫 Unreachable States');
149
+ lines.push('');
150
+ for (const us of report.unreachableStates) {
151
+ lines.push(`- **[${us.factTags.join(', ')}]**: ${us.reason}`);
152
+ }
153
+ lines.push('');
154
+ }
155
+
156
+ // Shadowed Rules
157
+ if (report.shadowedRules.length > 0) {
158
+ lines.push('## 👻 Shadowed Rules');
159
+ lines.push('');
160
+ for (const sr of report.shadowedRules) {
161
+ lines.push(`- **${sr.ruleId}** shadowed by **${sr.shadowedBy}**: ${sr.reason}`);
162
+ }
163
+ lines.push('');
164
+ }
165
+
166
+ // Contradictions
167
+ if (report.contradictions.length > 0) {
168
+ lines.push('## ⚡ Contradictions');
169
+ lines.push('');
170
+ for (const c of report.contradictions) {
171
+ lines.push(`- **${c.ruleA}** ↔ **${c.ruleB}** on \`${c.conflictingTag}\`: ${c.reason}`);
172
+ }
173
+ lines.push('');
174
+ }
175
+
176
+ // Gaps
177
+ if (report.gaps.length > 0) {
178
+ lines.push('## 🕳️ Gaps');
179
+ lines.push('');
180
+ for (const g of report.gaps) {
181
+ lines.push(`- **${g.expectationName}** (${g.type}): ${g.description}`);
182
+ }
183
+ lines.push('');
184
+ }
185
+
186
+ // Derivation Chains
187
+ if (report.factDerivationChains.length > 0) {
188
+ lines.push('## 🔗 Fact Derivation Chains');
189
+ lines.push('');
190
+ for (const chain of report.factDerivationChains) {
191
+ if (chain.steps.length === 0) continue;
192
+ lines.push(`### \`${chain.targetFact}\` (depth: ${chain.depth})`);
193
+ for (const step of chain.steps) {
194
+ const icon = step.type === 'event' ? '⚡' : step.type === 'rule-fired' ? '⚙️' : step.type === 'fact-produced' ? '📦' : '📖';
195
+ lines.push(` ${icon} ${step.description}`);
196
+ }
197
+ lines.push('');
198
+ }
199
+ }
200
+
201
+ // Suggestions
202
+ if (report.suggestions.length > 0) {
203
+ lines.push('## 💡 Suggestions');
204
+ lines.push('');
205
+ for (const s of report.suggestions) {
206
+ const priorityIcon = s.priority >= 8 ? '🔴' : s.priority >= 5 ? '🟡' : '🟢';
207
+ lines.push(`${priorityIcon} **[${s.findingType}]** ${s.message}`);
208
+ if (s.skeleton) {
209
+ lines.push('```typescript');
210
+ lines.push(s.skeleton);
211
+ lines.push('```');
212
+ }
213
+ lines.push('');
214
+ }
215
+ }
216
+
217
+ return lines.join('\n');
218
+ }
219
+
220
+ /**
221
+ * Format report as CI-friendly output with warnings and errors.
222
+ */
223
+ export function formatBuildOutput(report: AnalysisReport): string {
224
+ const lines: string[] = [];
225
+
226
+ // Header
227
+ lines.push(`::group::Decision Ledger Analysis (Score: ${report.summary.healthScore}/100)`);
228
+
229
+ // Errors (contradictions, high-priority gaps)
230
+ for (const c of report.contradictions) {
231
+ lines.push(`::error title=Contradiction::Rules "${c.ruleA}" and "${c.ruleB}" both produce "${c.conflictingTag}"`);
232
+ }
233
+
234
+ for (const g of report.gaps) {
235
+ if (g.type === 'no-rule') {
236
+ lines.push(`::error title=Missing Rule::No rule covers expectation "${g.expectationName}"`);
237
+ }
238
+ }
239
+
240
+ // Warnings
241
+ for (const dr of report.deadRules) {
242
+ lines.push(`::warning title=Dead Rule::Rule "${dr.ruleId}" can never fire (requires [${dr.requiredEventTypes.join(', ')}])`);
243
+ }
244
+
245
+ for (const sr of report.shadowedRules) {
246
+ lines.push(`::warning title=Shadowed Rule::Rule "${sr.ruleId}" is always superseded by "${sr.shadowedBy}"`);
247
+ }
248
+
249
+ for (const us of report.unreachableStates) {
250
+ lines.push(`::warning title=Unreachable State::Facts [${us.factTags.join(', ')}] cannot be produced together`);
251
+ }
252
+
253
+ for (const g of report.gaps) {
254
+ if (g.type === 'partial-coverage') {
255
+ lines.push(`::warning title=Partial Coverage::Expectation "${g.expectationName}" is only partially covered`);
256
+ } else if (g.type === 'no-contract') {
257
+ lines.push(`::warning title=Missing Contract::Rules for "${g.expectationName}" lack contracts`);
258
+ }
259
+ }
260
+
261
+ // Summary
262
+ lines.push('');
263
+ lines.push(`Rules: ${report.summary.totalRules} | Constraints: ${report.summary.totalConstraints} | Health: ${report.summary.healthScore}/100`);
264
+ lines.push(`Dead: ${report.summary.deadRuleCount} | Unreachable: ${report.summary.unreachableStateCount} | Shadowed: ${report.summary.shadowedRuleCount} | Contradictions: ${report.summary.contradictionCount} | Gaps: ${report.summary.gapCount}`);
265
+
266
+ lines.push('::endgroup::');
267
+
268
+ return lines.join('\n');
269
+ }
270
+
271
+ /**
272
+ * Diff two analysis reports to find what changed between them.
273
+ */
274
+ export function diffLedgers(before: AnalysisReport, after: AnalysisReport): LedgerDiff {
275
+ const changes: LedgerDiffEntry[] = [];
276
+
277
+ // Dead rules diff
278
+ diffItems(
279
+ before.deadRules,
280
+ after.deadRules,
281
+ dr => dr.ruleId,
282
+ dr => `Dead rule: ${dr.ruleId} — ${dr.reason}`,
283
+ 'dead-rule',
284
+ changes,
285
+ );
286
+
287
+ // Unreachable states diff
288
+ diffItems(
289
+ before.unreachableStates,
290
+ after.unreachableStates,
291
+ us => us.factTags.join('+'),
292
+ us => `Unreachable state: [${us.factTags.join(', ')}]`,
293
+ 'unreachable-state',
294
+ changes,
295
+ );
296
+
297
+ // Shadowed rules diff
298
+ diffItems(
299
+ before.shadowedRules,
300
+ after.shadowedRules,
301
+ sr => sr.ruleId,
302
+ sr => `Shadowed rule: ${sr.ruleId} by ${sr.shadowedBy}`,
303
+ 'shadowed-rule',
304
+ changes,
305
+ );
306
+
307
+ // Contradictions diff
308
+ diffItems(
309
+ before.contradictions,
310
+ after.contradictions,
311
+ c => `${c.ruleA}↔${c.ruleB}`,
312
+ c => `Contradiction: ${c.ruleA} ↔ ${c.ruleB} on ${c.conflictingTag}`,
313
+ 'contradiction',
314
+ changes,
315
+ );
316
+
317
+ // Gaps diff
318
+ diffItems(
319
+ before.gaps,
320
+ after.gaps,
321
+ g => g.expectationName,
322
+ g => `Gap: ${g.expectationName} (${g.type})`,
323
+ 'gap',
324
+ changes,
325
+ );
326
+
327
+ const scoreDelta = after.summary.healthScore - before.summary.healthScore;
328
+ const scoreDirection = scoreDelta > 0 ? 'improved' : scoreDelta < 0 ? 'degraded' : 'unchanged';
329
+
330
+ return {
331
+ timestamp: new Date().toISOString(),
332
+ beforeTimestamp: before.timestamp,
333
+ afterTimestamp: after.timestamp,
334
+ changes,
335
+ scoreDelta,
336
+ summary: `Score ${scoreDirection} by ${Math.abs(scoreDelta)} points (${before.summary.healthScore} → ${after.summary.healthScore}). ${changes.length} changes: ${changes.filter(c => c.type === 'added').length} added, ${changes.filter(c => c.type === 'removed').length} removed, ${changes.filter(c => c.type === 'changed').length} changed.`,
337
+ };
338
+ }
339
+
340
+ // ─── Helpers ────────────────────────────────────────────────────────────────
341
+
342
+ function diffItems<T>(
343
+ before: T[],
344
+ after: T[],
345
+ getKey: (item: T) => string,
346
+ describe: (item: T) => string,
347
+ category: LedgerDiffEntry['category'],
348
+ changes: LedgerDiffEntry[],
349
+ ): void {
350
+ const beforeKeys = new Set(before.map(getKey));
351
+ const afterKeys = new Set(after.map(getKey));
352
+
353
+ // Added
354
+ for (const item of after) {
355
+ const key = getKey(item);
356
+ if (!beforeKeys.has(key)) {
357
+ changes.push({
358
+ type: 'added',
359
+ category,
360
+ description: describe(item),
361
+ entityId: key,
362
+ });
363
+ }
364
+ }
365
+
366
+ // Removed
367
+ for (const item of before) {
368
+ const key = getKey(item);
369
+ if (!afterKeys.has(key)) {
370
+ changes.push({
371
+ type: 'removed',
372
+ category,
373
+ description: describe(item),
374
+ entityId: key,
375
+ });
376
+ }
377
+ }
378
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Decision Ledger — Actionable Fix Suggestions
3
+ *
4
+ * For each finding from the analyzer, generates concrete,
5
+ * actionable suggestions with optional code skeletons.
6
+ */
7
+
8
+ import type {
9
+ DeadRule,
10
+ UnreachableState,
11
+ ShadowedRule,
12
+ Contradiction,
13
+ Gap,
14
+ ContractCoverageGap,
15
+ Suggestion,
16
+ FindingType,
17
+ } from './analyzer-types.js';
18
+
19
+ /**
20
+ * Generate a suggestion for any type of finding.
21
+ */
22
+ export function suggest(
23
+ finding: DeadRule | UnreachableState | ShadowedRule | Contradiction | Gap | ContractCoverageGap,
24
+ type: FindingType,
25
+ ): Suggestion {
26
+ switch (type) {
27
+ case 'dead-rule':
28
+ return suggestForDeadRule(finding as DeadRule);
29
+ case 'gap':
30
+ return suggestForGap(finding as Gap);
31
+ case 'contradiction':
32
+ return suggestForContradiction(finding as Contradiction);
33
+ case 'unreachable-state':
34
+ return suggestForUnreachableState(finding as UnreachableState);
35
+ case 'shadowed-rule':
36
+ return suggestForShadowedRule(finding as ShadowedRule);
37
+ case 'contract-gap':
38
+ return suggestForContractGap(finding as ContractCoverageGap);
39
+ default:
40
+ return {
41
+ findingType: type,
42
+ entityId: 'unknown',
43
+ message: 'Unknown finding type',
44
+ action: 'modify',
45
+ priority: 1,
46
+ };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Generate suggestions for all findings at once.
52
+ */
53
+ export function suggestAll(findings: {
54
+ deadRules?: DeadRule[];
55
+ gaps?: Gap[];
56
+ contradictions?: Contradiction[];
57
+ unreachableStates?: UnreachableState[];
58
+ shadowedRules?: ShadowedRule[];
59
+ contractGaps?: ContractCoverageGap[];
60
+ }): Suggestion[] {
61
+ const suggestions: Suggestion[] = [];
62
+
63
+ if (findings.deadRules) {
64
+ for (const f of findings.deadRules) {
65
+ suggestions.push(suggestForDeadRule(f));
66
+ }
67
+ }
68
+ if (findings.gaps) {
69
+ for (const f of findings.gaps) {
70
+ suggestions.push(suggestForGap(f));
71
+ }
72
+ }
73
+ if (findings.contradictions) {
74
+ for (const f of findings.contradictions) {
75
+ suggestions.push(suggestForContradiction(f));
76
+ }
77
+ }
78
+ if (findings.unreachableStates) {
79
+ for (const f of findings.unreachableStates) {
80
+ suggestions.push(suggestForUnreachableState(f));
81
+ }
82
+ }
83
+ if (findings.shadowedRules) {
84
+ for (const f of findings.shadowedRules) {
85
+ suggestions.push(suggestForShadowedRule(f));
86
+ }
87
+ }
88
+ if (findings.contractGaps) {
89
+ for (const f of findings.contractGaps) {
90
+ suggestions.push(suggestForContractGap(f));
91
+ }
92
+ }
93
+
94
+ // Sort by priority (higher first)
95
+ suggestions.sort((a, b) => b.priority - a.priority);
96
+
97
+ return suggestions;
98
+ }
99
+
100
+ // ─── Individual Suggestion Generators ───────────────────────────────────────
101
+
102
+ function suggestForDeadRule(finding: DeadRule): Suggestion {
103
+ const eventList = finding.requiredEventTypes.join(', ');
104
+ return {
105
+ findingType: 'dead-rule',
106
+ entityId: finding.ruleId,
107
+ message: `Remove rule "${finding.ruleId}" or add event type "${finding.requiredEventTypes[0]}" to your event sources. Rule requires [${eventList}] but none are emitted by the application.`,
108
+ action: finding.requiredEventTypes.length === 1 ? 'add-event-type' : 'remove',
109
+ priority: 5,
110
+ skeleton: `// Option 1: Remove the dead rule
111
+ registry.removeRule('${finding.ruleId}');
112
+
113
+ // Option 2: Add the missing event type
114
+ engine.step([{ tag: '${finding.requiredEventTypes[0]}', payload: {} }]);`,
115
+ };
116
+ }
117
+
118
+ function suggestForGap(finding: Gap): Suggestion {
119
+ const ruleId = finding.expectationName
120
+ .toLowerCase()
121
+ .replace(/\s+/g, '-')
122
+ .replace(/[^a-z0-9-]/g, '');
123
+
124
+ switch (finding.type) {
125
+ case 'no-rule':
126
+ return {
127
+ findingType: 'gap',
128
+ entityId: finding.expectationName,
129
+ message: `Add a rule covering: "${finding.expectationName}". No rules or constraints address this expected behavior.`,
130
+ action: 'add-rule',
131
+ priority: 8,
132
+ skeleton: `defineRule({
133
+ id: '${ruleId}',
134
+ description: '${finding.description}',
135
+ eventTypes: ['TODO_EVENT_TYPE'],
136
+ impl: (state, events) => {
137
+ // TODO: Implement logic for "${finding.expectationName}"
138
+ return RuleResult.noop('Not implemented');
139
+ },
140
+ contract: defineContract({
141
+ ruleId: '${ruleId}',
142
+ behavior: '${finding.expectationName}',
143
+ examples: [
144
+ { given: 'TODO', when: 'TODO', then: 'TODO' },
145
+ ],
146
+ invariants: [],
147
+ }),
148
+ });`,
149
+ };
150
+
151
+ case 'partial-coverage':
152
+ return {
153
+ findingType: 'gap',
154
+ entityId: finding.expectationName,
155
+ message: `Expectation "${finding.expectationName}" is partially covered by rules [${finding.partialCoverage.join(', ')}]. Add contract examples or additional rules for uncovered conditions.`,
156
+ action: 'modify',
157
+ priority: 6,
158
+ };
159
+
160
+ case 'no-contract':
161
+ return {
162
+ findingType: 'gap',
163
+ entityId: finding.expectationName,
164
+ message: `Rules related to "${finding.expectationName}" ([${finding.partialCoverage.join(', ')}]) lack contracts. Add contracts with examples covering the expected behavior.`,
165
+ action: 'add-contract',
166
+ priority: 7,
167
+ };
168
+
169
+ default:
170
+ return {
171
+ findingType: 'gap',
172
+ entityId: finding.expectationName,
173
+ message: finding.description,
174
+ action: 'add-rule',
175
+ priority: 5,
176
+ };
177
+ }
178
+ }
179
+
180
+ function suggestForContradiction(finding: Contradiction): Suggestion {
181
+ return {
182
+ findingType: 'contradiction',
183
+ entityId: `${finding.ruleA}↔${finding.ruleB}`,
184
+ message: `Rules "${finding.ruleA}" and "${finding.ruleB}" both produce fact "${finding.conflictingTag}" with potentially different payloads. Add priority ordering, merge the rules, or add distinguishing conditions.`,
185
+ action: 'add-priority',
186
+ priority: 9,
187
+ skeleton: `// Option 1: Add priority via meta
188
+ defineRule({
189
+ id: '${finding.ruleA}',
190
+ meta: { priority: 10 }, // Higher priority wins
191
+ // ...
192
+ });
193
+
194
+ // Option 2: Add distinguishing conditions
195
+ defineRule({
196
+ id: '${finding.ruleA}',
197
+ impl: (state, events) => {
198
+ // Add condition to distinguish from "${finding.ruleB}"
199
+ if (/* specific condition */) {
200
+ return RuleResult.emit([{ tag: '${finding.conflictingTag}', payload: { /* ... */ } }]);
201
+ }
202
+ return RuleResult.skip('Deferred to ${finding.ruleB}');
203
+ },
204
+ });`,
205
+ };
206
+ }
207
+
208
+ function suggestForUnreachableState(finding: UnreachableState): Suggestion {
209
+ const tags = finding.factTags.join(', ');
210
+ return {
211
+ findingType: 'unreachable-state',
212
+ entityId: finding.factTags.join('+'),
213
+ message: `No rule produces facts [${tags}] together. If this state combination is valid, add a composite rule that produces all required facts.`,
214
+ action: 'add-rule',
215
+ priority: 4,
216
+ skeleton: `defineRule({
217
+ id: 'composite-${finding.factTags[0]?.replace('.', '-') ?? 'unknown'}',
218
+ description: 'Produces facts [${tags}] together',
219
+ impl: (state, events) => {
220
+ return RuleResult.emit([
221
+ ${finding.factTags.map(t => ` { tag: '${t}', payload: {} },`).join('\n')}
222
+ ]);
223
+ },
224
+ });`,
225
+ };
226
+ }
227
+
228
+ function suggestForShadowedRule(finding: ShadowedRule): Suggestion {
229
+ return {
230
+ findingType: 'shadowed-rule',
231
+ entityId: finding.ruleId,
232
+ message: `Rule "${finding.ruleId}" is always superseded by "${finding.shadowedBy}" for event types [${finding.sharedEventTypes.join(', ')}]. Consider merging the rules or adding a distinguishing condition to "${finding.ruleId}".`,
233
+ action: 'merge',
234
+ priority: 3,
235
+ skeleton: `// Option 1: Remove the shadowed rule
236
+ registry.removeRule('${finding.ruleId}');
237
+
238
+ // Option 2: Add unique behavior to the shadowed rule
239
+ defineRule({
240
+ id: '${finding.ruleId}',
241
+ impl: (state, events) => {
242
+ // Add condition that "${finding.shadowedBy}" doesn't cover
243
+ if (/* unique condition */) {
244
+ return RuleResult.emit([/* unique facts */]);
245
+ }
246
+ return RuleResult.skip('Handled by ${finding.shadowedBy}');
247
+ },
248
+ });`,
249
+ };
250
+ }
251
+
252
+ function suggestForContractGap(finding: ContractCoverageGap): Suggestion {
253
+ let message: string;
254
+ let action: Suggestion['action'] = 'add-contract';
255
+ let priority: number;
256
+
257
+ switch (finding.type) {
258
+ case 'missing-error-path':
259
+ message = `Rule "${finding.ruleId}" has no error/failure examples in its contract. Add examples showing what happens when preconditions fail, inputs are invalid, or the rule needs to skip.`;
260
+ priority = 6;
261
+ break;
262
+ case 'missing-edge-case':
263
+ message = `Rule "${finding.ruleId}": ${finding.description}. Add contract examples covering all declared event types.`;
264
+ priority = 5;
265
+ break;
266
+ case 'missing-boundary':
267
+ message = `Rule "${finding.ruleId}" has only 1 contract example. Add boundary condition examples (empty input, maximum values, concurrent events).`;
268
+ priority = 4;
269
+ break;
270
+ case 'cross-reference-broken':
271
+ message = `Rule "${finding.ruleId}": ${finding.description}. Verify the referenced fact producer exists.`;
272
+ action = 'modify';
273
+ priority = 7;
274
+ break;
275
+ default:
276
+ message = finding.description;
277
+ priority = 3;
278
+ }
279
+
280
+ return {
281
+ findingType: 'contract-gap',
282
+ entityId: finding.ruleId,
283
+ message,
284
+ action,
285
+ priority,
286
+ };
287
+ }