@plures/praxis 1.2.41 → 1.3.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 (36) hide show
  1. package/dist/browser/{chunk-BBP2F7TT.js → chunk-MJK3IYTJ.js} +123 -5
  2. package/dist/browser/{chunk-FCEH7WMH.js → chunk-N63K4KWS.js} +1 -1
  3. package/dist/browser/{engine-65QDGCAN.js → engine-YIEGSX7U.js} +1 -1
  4. package/dist/browser/index.d.ts +2 -2
  5. package/dist/browser/index.js +10 -5
  6. package/dist/browser/integrations/svelte.d.ts +2 -2
  7. package/dist/browser/integrations/svelte.js +2 -2
  8. package/dist/browser/{reactive-engine.svelte-Cqd8Mod2.d.ts → reactive-engine.svelte-DjynI82A.d.ts} +83 -4
  9. package/dist/node/{chunk-32YFEEML.js → chunk-5JQJZADT.js} +1 -1
  10. package/dist/node/{chunk-BBP2F7TT.js → chunk-KMJWAFZV.js} +128 -5
  11. package/dist/node/cloud/index.d.cts +1 -1
  12. package/dist/node/cloud/index.d.ts +1 -1
  13. package/dist/node/{engine-7CXQV6RC.js → engine-FEN5IYZ5.js} +1 -1
  14. package/dist/node/index.cjs +522 -59
  15. package/dist/node/index.d.cts +271 -5
  16. package/dist/node/index.d.ts +271 -5
  17. package/dist/node/index.js +355 -39
  18. package/dist/node/integrations/svelte.cjs +123 -5
  19. package/dist/node/integrations/svelte.d.cts +3 -3
  20. package/dist/node/integrations/svelte.d.ts +3 -3
  21. package/dist/node/integrations/svelte.js +2 -2
  22. package/dist/node/{protocol-BocKczNv.d.ts → protocol-DcyGMmWY.d.cts} +7 -0
  23. package/dist/node/{protocol-BocKczNv.d.cts → protocol-DcyGMmWY.d.ts} +7 -0
  24. package/dist/node/{reactive-engine.svelte-D-xTDxT5.d.ts → reactive-engine.svelte-Cg0Yc2Hs.d.cts} +90 -6
  25. package/dist/node/{reactive-engine.svelte-CGe8SpVE.d.cts → reactive-engine.svelte-DekxqFu0.d.ts} +90 -6
  26. package/package.json +2 -2
  27. package/src/__tests__/engine-v2.test.ts +532 -0
  28. package/src/core/completeness.ts +274 -0
  29. package/src/core/engine.ts +47 -5
  30. package/src/core/pluresdb/store.ts +9 -3
  31. package/src/core/protocol.ts +7 -0
  32. package/src/core/rule-result.ts +130 -0
  33. package/src/core/rules.ts +12 -5
  34. package/src/core/ui-rules.ts +340 -0
  35. package/src/index.ts +27 -0
  36. package/src/vite/completeness-plugin.ts +72 -0
@@ -0,0 +1,274 @@
1
+ /**
2
+ * @plures/praxis — Completeness Analysis
3
+ *
4
+ * This module provides tools to measure and enforce "Praxis Completeness" —
5
+ * the degree to which an application's logic is expressed through Praxis
6
+ * rules, constraints, and contracts rather than scattered conditionals.
7
+ *
8
+ * ## Definition: Praxis Logic Completeness
9
+ *
10
+ * An application is "Praxis Complete" when:
11
+ *
12
+ * 1. **DOMAIN RULES (100%)** — Every business decision that produces user-visible
13
+ * behavior change is expressed as a Praxis rule. "If the sprint is behind pace,
14
+ * show a warning" is domain logic. It belongs in Praxis.
15
+ *
16
+ * 2. **INVARIANTS (100%)** — Every data validity assertion is a Praxis constraint.
17
+ * "Sprint hours must not exceed 80" is an invariant. It belongs in Praxis.
18
+ *
19
+ * 3. **CONTRACTS (>80%)** — Rules that encode non-obvious behavior have contracts
20
+ * (behavior description + examples + invariants). Contracts are documentation
21
+ * AND test vectors — they prove the tool isn't the bug.
22
+ *
23
+ * 4. **CONTEXT COVERAGE (100%)** — Every piece of application state that rules
24
+ * reason about is in the Praxis context. If a rule needs to know about
25
+ * connection status, connection status must be in the context.
26
+ *
27
+ * 5. **EVENT COVERAGE (100%)** — Every state transition that should trigger rule
28
+ * evaluation fires a Praxis event. If notes can be saved, there's a note.save
29
+ * event. If sprint refreshes, there's a sprint.refresh event.
30
+ *
31
+ * ## What Is NOT Praxis Logic
32
+ *
33
+ * - **UI mechanics**: Panel toggle, scroll position, animation state, focus management
34
+ * - **Data transport**: fetch() calls, WebSocket plumbing, file I/O
35
+ * - **Framework wiring**: Svelte subscriptions, onMount, store creation
36
+ * - **Data transformation**: Parsing, formatting, serialization
37
+ * - **Routing/navigation**: URL handling, panel switching (unless it has business rules)
38
+ *
39
+ * The line: "Does this `if` statement encode a business decision or an app invariant?"
40
+ * If yes → Praxis rule/constraint. If no → leave it.
41
+ *
42
+ * ## Measuring Completeness
43
+ *
44
+ * ### Quantitative Metrics
45
+ * - **Rule Coverage**: (domain `if` branches in Praxis) / (total domain `if` branches)
46
+ * - **Constraint Coverage**: (data invariants in Praxis) / (total data invariants)
47
+ * - **Contract Coverage**: (rules with contracts) / (rules that need contracts)
48
+ * - **Context Coverage**: (state fields wired to context) / (state fields rules need)
49
+ * - **Event Coverage**: (state transitions with events) / (state transitions that matter)
50
+ *
51
+ * ### Qualitative Indicators
52
+ * - Can you change a business rule by editing ONE rule definition? (single source of truth)
53
+ * - Can you test a business rule without rendering UI? (Praxis engine is headless)
54
+ * - Can you explain every business rule to a PM by reading the registry? (self-documenting)
55
+ * - Does the PraxisPanel show all active concerns? (observable)
56
+ *
57
+ * ## The Completeness Score
58
+ *
59
+ * ```
60
+ * Score = (
61
+ * rulesCovered / totalDomainBranches * 40 + // Rules are king (40%)
62
+ * constraintsCovered / totalInvariants * 20 + // Invariants matter (20%)
63
+ * contractsCovered / rulesNeedingContracts * 15 + // Contracts prevent bugs (15%)
64
+ * contextFieldsCovered / totalNeeded * 15 + // Context = visibility (15%)
65
+ * eventsCovered / totalTransitions * 10 // Events = reactivity (10%)
66
+ * )
67
+ * ```
68
+ *
69
+ * 90+ = Complete | 70-89 = Good | 50-69 = Partial | <50 = Incomplete
70
+ */
71
+
72
+ // ─── Types ──────────────────────────────────────────────────────────────────
73
+
74
+ export interface LogicBranch {
75
+ /** Source file + line */
76
+ location: string;
77
+ /** The condition expression */
78
+ condition: string;
79
+ /** Classification */
80
+ kind: 'domain' | 'invariant' | 'ui' | 'transport' | 'wiring' | 'transform';
81
+ /** If domain/invariant: the Praxis rule/constraint that covers it, or null */
82
+ coveredBy: string | null;
83
+ /** Human note */
84
+ note?: string;
85
+ }
86
+
87
+ export interface StateField {
88
+ /** Store or source name */
89
+ source: string;
90
+ /** Field path */
91
+ field: string;
92
+ /** Whether it's in the Praxis context */
93
+ inContext: boolean;
94
+ /** Whether any rule references it */
95
+ usedByRule: boolean;
96
+ }
97
+
98
+ export interface StateTransition {
99
+ /** What changes */
100
+ description: string;
101
+ /** The Praxis event tag, or null if missing */
102
+ eventTag: string | null;
103
+ /** Source location */
104
+ location: string;
105
+ }
106
+
107
+ export interface CompletenessReport {
108
+ /** Overall score (0-100) */
109
+ score: number;
110
+ /** Rating */
111
+ rating: 'complete' | 'good' | 'partial' | 'incomplete';
112
+
113
+ rules: {
114
+ total: number;
115
+ covered: number;
116
+ uncovered: LogicBranch[];
117
+ };
118
+ constraints: {
119
+ total: number;
120
+ covered: number;
121
+ uncovered: LogicBranch[];
122
+ };
123
+ contracts: {
124
+ total: number;
125
+ withContracts: number;
126
+ missing: string[];
127
+ };
128
+ context: {
129
+ total: number;
130
+ covered: number;
131
+ missing: StateField[];
132
+ };
133
+ events: {
134
+ total: number;
135
+ covered: number;
136
+ missing: StateTransition[];
137
+ };
138
+ }
139
+
140
+ export interface CompletenessConfig {
141
+ /** Minimum score to pass (default: 90) */
142
+ threshold?: number;
143
+ /** Whether to throw on failure (for CI) */
144
+ strict?: boolean;
145
+ }
146
+
147
+ // ─── Audit Helper ───────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Run a completeness audit against a Praxis registry and app manifest.
151
+ *
152
+ * The manifest is a developer-authored declaration of all logic branches,
153
+ * state fields, and state transitions in the app. The auditor checks which
154
+ * ones are covered by Praxis.
155
+ */
156
+ export function auditCompleteness(
157
+ manifest: {
158
+ branches: LogicBranch[];
159
+ stateFields: StateField[];
160
+ transitions: StateTransition[];
161
+ rulesNeedingContracts: string[];
162
+ },
163
+ registryRuleIds: string[],
164
+ registryConstraintIds: string[],
165
+ rulesWithContracts: string[],
166
+ config?: CompletenessConfig,
167
+ ): CompletenessReport {
168
+ const threshold = config?.threshold ?? 90;
169
+
170
+ // Rules
171
+ const domainBranches = manifest.branches.filter(b => b.kind === 'domain');
172
+ const coveredDomain = domainBranches.filter(b => b.coveredBy && registryRuleIds.includes(b.coveredBy));
173
+ const uncoveredDomain = domainBranches.filter(b => !b.coveredBy || !registryRuleIds.includes(b.coveredBy));
174
+
175
+ // Constraints
176
+ const invariantBranches = manifest.branches.filter(b => b.kind === 'invariant');
177
+ const coveredInvariants = invariantBranches.filter(b => b.coveredBy && registryConstraintIds.includes(b.coveredBy));
178
+ const uncoveredInvariants = invariantBranches.filter(b => !b.coveredBy || !registryConstraintIds.includes(b.coveredBy));
179
+
180
+ // Contracts
181
+ const needContracts = manifest.rulesNeedingContracts;
182
+ const haveContracts = needContracts.filter(id => rulesWithContracts.includes(id));
183
+ const missingContracts = needContracts.filter(id => !rulesWithContracts.includes(id));
184
+
185
+ // Context
186
+ const neededFields = manifest.stateFields.filter(f => f.usedByRule);
187
+ const coveredFields = neededFields.filter(f => f.inContext);
188
+ const missingFields = neededFields.filter(f => !f.inContext);
189
+
190
+ // Events
191
+ const coveredTransitions = manifest.transitions.filter(t => t.eventTag);
192
+ const missingTransitions = manifest.transitions.filter(t => !t.eventTag);
193
+
194
+ // Score
195
+ const ruleScore = domainBranches.length > 0 ? (coveredDomain.length / domainBranches.length) * 40 : 40;
196
+ const constraintScore = invariantBranches.length > 0 ? (coveredInvariants.length / invariantBranches.length) * 20 : 20;
197
+ const contractScore = needContracts.length > 0 ? (haveContracts.length / needContracts.length) * 15 : 15;
198
+ const contextScore = neededFields.length > 0 ? (coveredFields.length / neededFields.length) * 15 : 15;
199
+ const eventScore = manifest.transitions.length > 0 ? (coveredTransitions.length / manifest.transitions.length) * 10 : 10;
200
+
201
+ const score = Math.round(ruleScore + constraintScore + contractScore + contextScore + eventScore);
202
+ const rating = score >= 90 ? 'complete' : score >= 70 ? 'good' : score >= 50 ? 'partial' : 'incomplete';
203
+
204
+ const report: CompletenessReport = {
205
+ score,
206
+ rating,
207
+ rules: { total: domainBranches.length, covered: coveredDomain.length, uncovered: uncoveredDomain },
208
+ constraints: { total: invariantBranches.length, covered: coveredInvariants.length, uncovered: uncoveredInvariants },
209
+ contracts: { total: needContracts.length, withContracts: haveContracts.length, missing: missingContracts },
210
+ context: { total: neededFields.length, covered: coveredFields.length, missing: missingFields },
211
+ events: { total: manifest.transitions.length, covered: coveredTransitions.length, missing: missingTransitions },
212
+ };
213
+
214
+ if (config?.strict && score < threshold) {
215
+ throw new Error(`Praxis completeness ${score}/100 (${rating}) — below threshold ${threshold}. ${uncoveredDomain.length} uncovered rules, ${uncoveredInvariants.length} uncovered invariants, ${missingContracts.length} missing contracts.`);
216
+ }
217
+
218
+ return report;
219
+ }
220
+
221
+ /**
222
+ * Format a completeness report as human-readable text.
223
+ */
224
+ export function formatReport(report: CompletenessReport): string {
225
+ const lines: string[] = [];
226
+ const icon = report.rating === 'complete' ? '✅' : report.rating === 'good' ? '🟢' : report.rating === 'partial' ? '🟡' : '🔴';
227
+
228
+ lines.push(`${icon} Praxis Completeness: ${report.score}/100 (${report.rating})`);
229
+ lines.push('');
230
+ lines.push(`Rules: ${report.rules.covered}/${report.rules.total} domain branches covered (${pct(report.rules.covered, report.rules.total)})`);
231
+ lines.push(`Constraints: ${report.constraints.covered}/${report.constraints.total} invariants covered (${pct(report.constraints.covered, report.constraints.total)})`);
232
+ lines.push(`Contracts: ${report.contracts.withContracts}/${report.contracts.total} rules have contracts (${pct(report.contracts.withContracts, report.contracts.total)})`);
233
+ lines.push(`Context: ${report.context.covered}/${report.context.total} state fields in context (${pct(report.context.covered, report.context.total)})`);
234
+ lines.push(`Events: ${report.events.covered}/${report.events.total} transitions have events (${pct(report.events.covered, report.events.total)})`);
235
+
236
+ if (report.rules.uncovered.length > 0) {
237
+ lines.push('');
238
+ lines.push('Uncovered domain logic:');
239
+ for (const b of report.rules.uncovered) {
240
+ lines.push(` ❌ ${b.location}: ${b.condition}${b.note ? ` — ${b.note}` : ''}`);
241
+ }
242
+ }
243
+
244
+ if (report.constraints.uncovered.length > 0) {
245
+ lines.push('');
246
+ lines.push('Uncovered invariants:');
247
+ for (const b of report.constraints.uncovered) {
248
+ lines.push(` ❌ ${b.location}: ${b.condition}${b.note ? ` — ${b.note}` : ''}`);
249
+ }
250
+ }
251
+
252
+ if (report.contracts.missing.length > 0) {
253
+ lines.push('');
254
+ lines.push('Rules missing contracts:');
255
+ for (const id of report.contracts.missing) {
256
+ lines.push(` 📝 ${id}`);
257
+ }
258
+ }
259
+
260
+ if (report.events.missing.length > 0) {
261
+ lines.push('');
262
+ lines.push('State transitions without events:');
263
+ for (const t of report.events.missing) {
264
+ lines.push(` ⚡ ${t.location}: ${t.description}`);
265
+ }
266
+ }
267
+
268
+ return lines.join('\n');
269
+ }
270
+
271
+ function pct(a: number, b: number): string {
272
+ if (b === 0) return '100%';
273
+ return Math.round((a / b) * 100) + '%';
274
+ }
@@ -15,6 +15,7 @@ import type {
15
15
  } from './protocol.js';
16
16
  import { PRAXIS_PROTOCOL_VERSION } from './protocol.js';
17
17
  import { PraxisRegistry } from './rules.js';
18
+ import { RuleResult } from './rule-result.js';
18
19
 
19
20
  /**
20
21
  * Options for creating a Praxis engine
@@ -150,8 +151,15 @@ export class LogicEngine<TContext = unknown> {
150
151
  const diagnostics: PraxisDiagnostics[] = [];
151
152
  let newState = { ...this.state };
152
153
 
154
+ // ── Inject events into state so rules can access them via state.events ──
155
+ const stateWithEvents = {
156
+ ...newState,
157
+ events, // current batch — rules can read state.events
158
+ };
159
+
153
160
  // Apply rules
154
161
  const newFacts: PraxisFact[] = [];
162
+ const retractions: string[] = [];
155
163
  const eventTags = new Set(events.map(e => e.tag));
156
164
  for (const ruleId of config.ruleIds) {
157
165
  const rule = this.registry.getRule(ruleId);
@@ -174,8 +182,35 @@ export class LogicEngine<TContext = unknown> {
174
182
  }
175
183
 
176
184
  try {
177
- const ruleFacts = rule.impl(newState, events);
178
- newFacts.push(...ruleFacts);
185
+ const rawResult = rule.impl(stateWithEvents, events);
186
+
187
+ // Support both legacy PraxisFact[] return and new RuleResult return
188
+ if (rawResult instanceof RuleResult) {
189
+ rawResult.ruleId = ruleId;
190
+
191
+ switch (rawResult.kind) {
192
+ case 'emit':
193
+ newFacts.push(...rawResult.facts);
194
+ break;
195
+ case 'retract':
196
+ retractions.push(...rawResult.retractTags);
197
+ break;
198
+ case 'noop':
199
+ case 'skip':
200
+ // Traceable no-ops — store in diagnostics for introspection
201
+ if (rawResult.reason) {
202
+ diagnostics.push({
203
+ kind: 'rule-error', // reused kind — could add 'rule-trace' in protocol v2
204
+ message: `[${rawResult.kind}] ${ruleId}: ${rawResult.reason}`,
205
+ data: { ruleId, resultKind: rawResult.kind, reason: rawResult.reason },
206
+ });
207
+ }
208
+ break;
209
+ }
210
+ } else if (Array.isArray(rawResult)) {
211
+ // Legacy: PraxisFact[] — backward compatible
212
+ newFacts.push(...rawResult);
213
+ }
179
214
  } catch (error) {
180
215
  diagnostics.push({
181
216
  kind: 'rule-error',
@@ -185,23 +220,30 @@ export class LogicEngine<TContext = unknown> {
185
220
  }
186
221
  }
187
222
 
223
+ // ── Apply retractions ──
224
+ let existingFacts = newState.facts;
225
+ if (retractions.length > 0) {
226
+ const retractSet = new Set(retractions);
227
+ existingFacts = existingFacts.filter(f => !retractSet.has(f.tag));
228
+ }
229
+
188
230
  // Merge new facts with deduplication
189
231
  let mergedFacts: PraxisFact[];
190
232
  switch (this.factDedup) {
191
233
  case 'last-write-wins': {
192
234
  // Build a map keyed by tag — new facts overwrite old ones with same tag
193
235
  const factMap = new Map<string, PraxisFact>();
194
- for (const f of newState.facts) factMap.set(f.tag, f);
236
+ for (const f of existingFacts) factMap.set(f.tag, f);
195
237
  for (const f of newFacts) factMap.set(f.tag, f);
196
238
  mergedFacts = Array.from(factMap.values());
197
239
  break;
198
240
  }
199
241
  case 'append':
200
- mergedFacts = [...newState.facts, ...newFacts];
242
+ mergedFacts = [...existingFacts, ...newFacts];
201
243
  break;
202
244
  case 'none':
203
245
  default:
204
- mergedFacts = [...newState.facts, ...newFacts];
246
+ mergedFacts = [...existingFacts, ...newFacts];
205
247
  break;
206
248
  }
207
249
 
@@ -502,9 +502,10 @@ export class PraxisDBStore<TContext = unknown> {
502
502
  const rules = this.registry.getAllRules();
503
503
 
504
504
  // Build state for rule evaluation
505
- const state: PraxisState & { context: TContext } = {
505
+ const state: PraxisState & { context: TContext; events: PraxisEvent[] } = {
506
506
  context: this.context,
507
507
  facts: [],
508
+ events,
508
509
  meta: {},
509
510
  };
510
511
 
@@ -512,8 +513,13 @@ export class PraxisDBStore<TContext = unknown> {
512
513
  const derivedFacts: PraxisFact[] = [];
513
514
  for (const rule of rules) {
514
515
  try {
515
- const facts = rule.impl(state, events);
516
- derivedFacts.push(...facts);
516
+ const result = rule.impl(state, events);
517
+ if (Array.isArray(result)) {
518
+ derivedFacts.push(...result);
519
+ } else if (result && 'kind' in result && result.kind === 'emit') {
520
+ derivedFacts.push(...(result as any).facts);
521
+ }
522
+ // noop/skip/retract handled by engine, not store
517
523
  } catch (error) {
518
524
  this.onRuleError(rule.id, error);
519
525
  }
@@ -75,6 +75,13 @@ export interface PraxisState {
75
75
  context: unknown;
76
76
  /** Current facts about the domain */
77
77
  facts: PraxisFact[];
78
+ /**
79
+ * Events currently being processed in this step.
80
+ * Available to rules during execution — guaranteed to contain the exact
81
+ * events passed to step()/stepWithContext().
82
+ * Empty outside of step execution.
83
+ */
84
+ events?: PraxisEvent[];
78
85
  /** Optional metadata (timestamps, version, etc.) */
79
86
  meta?: Record<string, unknown>;
80
87
  /** Protocol version (for cross-language compatibility) */
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Typed Rule Results
3
+ *
4
+ * Rules must always return a RuleResult — never an empty array.
5
+ * A rule that has nothing to say returns RuleResult.noop().
6
+ * This makes every rule evaluation traceable and eliminates
7
+ * the ambiguity of "did the rule run but produce nothing,
8
+ * or did it not run at all?"
9
+ */
10
+
11
+ import type { PraxisFact } from './protocol.js';
12
+
13
+ /**
14
+ * The result of evaluating a rule. Every rule MUST return one of:
15
+ * - `RuleResult.emit(facts)` — rule produced facts
16
+ * - `RuleResult.noop(reason?)` — rule evaluated but had nothing to say
17
+ * - `RuleResult.skip(reason?)` — rule decided to skip (preconditions not met)
18
+ * - `RuleResult.retract(tags)` — rule retracts previously emitted facts
19
+ */
20
+ export class RuleResult {
21
+ /** The kind of result */
22
+ readonly kind: 'emit' | 'noop' | 'skip' | 'retract';
23
+ /** Facts produced (only for 'emit') */
24
+ readonly facts: PraxisFact[];
25
+ /** Fact tags to retract (only for 'retract') */
26
+ readonly retractTags: string[];
27
+ /** Optional reason (for noop/skip/retract — useful for debugging) */
28
+ readonly reason?: string;
29
+ /** The rule ID that produced this result (set by engine) */
30
+ ruleId?: string;
31
+
32
+ private constructor(
33
+ kind: 'emit' | 'noop' | 'skip' | 'retract',
34
+ facts: PraxisFact[],
35
+ retractTags: string[],
36
+ reason?: string,
37
+ ) {
38
+ this.kind = kind;
39
+ this.facts = facts;
40
+ this.retractTags = retractTags;
41
+ this.reason = reason;
42
+ }
43
+
44
+ /**
45
+ * Rule produced facts.
46
+ *
47
+ * @example
48
+ * return RuleResult.emit([
49
+ * { tag: 'sprint.behind', payload: { deficit: 5 } }
50
+ * ]);
51
+ */
52
+ static emit(facts: PraxisFact[]): RuleResult {
53
+ if (facts.length === 0) {
54
+ throw new Error(
55
+ 'RuleResult.emit() requires at least one fact. ' +
56
+ 'Use RuleResult.noop() or RuleResult.skip() when a rule has nothing to say.'
57
+ );
58
+ }
59
+ return new RuleResult('emit', facts, []);
60
+ }
61
+
62
+ /**
63
+ * Rule evaluated but had nothing to report.
64
+ * Unlike returning [], this is explicit and traceable.
65
+ *
66
+ * @example
67
+ * if (ctx.completedHours >= expectedHours) {
68
+ * return RuleResult.noop('Sprint is on pace');
69
+ * }
70
+ */
71
+ static noop(reason?: string): RuleResult {
72
+ return new RuleResult('noop', [], [], reason);
73
+ }
74
+
75
+ /**
76
+ * Rule decided to skip because preconditions were not met.
77
+ * Distinct from noop: skip means "I can't evaluate", noop means "I evaluated and found nothing".
78
+ *
79
+ * @example
80
+ * if (!ctx.sprintName) {
81
+ * return RuleResult.skip('No active sprint');
82
+ * }
83
+ */
84
+ static skip(reason?: string): RuleResult {
85
+ return new RuleResult('skip', [], [], reason);
86
+ }
87
+
88
+ /**
89
+ * Rule retracts previously emitted facts by tag.
90
+ * Used when a condition that previously produced facts is no longer true.
91
+ *
92
+ * @example
93
+ * // Sprint was behind, but caught up
94
+ * if (ctx.completedHours >= expectedHours) {
95
+ * return RuleResult.retract(['sprint.behind'], 'Sprint caught up');
96
+ * }
97
+ */
98
+ static retract(tags: string[], reason?: string): RuleResult {
99
+ if (tags.length === 0) {
100
+ throw new Error('RuleResult.retract() requires at least one tag.');
101
+ }
102
+ return new RuleResult('retract', [], tags, reason);
103
+ }
104
+
105
+ /** Whether this result produced facts */
106
+ get hasFacts(): boolean {
107
+ return this.facts.length > 0;
108
+ }
109
+
110
+ /** Whether this result retracts facts */
111
+ get hasRetractions(): boolean {
112
+ return this.retractTags.length > 0;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * A rule function that returns a typed RuleResult.
118
+ * New API — replaces the old PraxisFact[] return type.
119
+ */
120
+ export type TypedRuleFn<TContext = unknown> = (
121
+ state: import('./protocol.js').PraxisState & { context: TContext; events: import('./protocol.js').PraxisEvent[] },
122
+ events: import('./protocol.js').PraxisEvent[]
123
+ ) => RuleResult;
124
+
125
+ /**
126
+ * Convenience: create a fact object (just a shorthand)
127
+ */
128
+ export function fact(tag: string, payload: unknown): PraxisFact {
129
+ return { tag, payload };
130
+ }
package/src/core/rules.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { PraxisEvent, PraxisFact, PraxisState } from './protocol.js';
10
10
  import type { Contract, ContractGap, MissingArtifact, Severity } from '../decision-ledger/types.js';
11
+ import type { RuleResult } from './rule-result.js';
11
12
 
12
13
  declare const process:
13
14
  | {
@@ -31,14 +32,20 @@ export type ConstraintId = string;
31
32
  * A rule function derives new facts or transitions from context + input facts/events.
32
33
  * Rules must be pure - no side effects.
33
34
  *
34
- * @param state Current Praxis state
35
- * @param events Events to process
36
- * @returns Array of new facts to add to the state
35
+ * Returns either:
36
+ * - `RuleResult` (new API — typed, traceable, supports retraction)
37
+ * - `PraxisFact[]` (legacy backward compatible, will be deprecated)
38
+ *
39
+ * The state parameter includes `events` — the current batch being processed.
40
+ *
41
+ * @param state Current Praxis state (includes state.events for current batch)
42
+ * @param events Events to process (same as state.events, provided for convenience)
43
+ * @returns RuleResult or array of new facts
37
44
  */
38
45
  export type RuleFn<TContext = unknown> = (
39
- state: PraxisState & { context: TContext },
46
+ state: PraxisState & { context: TContext; events: PraxisEvent[] },
40
47
  events: PraxisEvent[]
41
- ) => PraxisFact[];
48
+ ) => RuleResult | PraxisFact[];
42
49
 
43
50
  /**
44
51
  * A constraint function checks that an invariant holds.