@plures/praxis 1.2.13 → 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 (93) hide show
  1. package/README.md +44 -0
  2. package/dist/browser/chunk-MJK3IYTJ.js +384 -0
  3. package/dist/browser/{chunk-K377RW4V.js → chunk-N63K4KWS.js} +1 -1
  4. package/dist/browser/{engine-YJZV4SLD.js → engine-YIEGSX7U.js} +1 -1
  5. package/dist/browser/index.d.ts +104 -2
  6. package/dist/browser/index.js +188 -7
  7. package/dist/browser/integrations/svelte.d.ts +2 -2
  8. package/dist/browser/integrations/svelte.js +2 -2
  9. package/dist/browser/{reactive-engine.svelte-9aS0kTa8.d.ts → reactive-engine.svelte-DjynI82A.d.ts} +139 -5
  10. package/dist/node/{chunk-PRPQO6R5.js → chunk-5JQJZADT.js} +1 -1
  11. package/dist/node/chunk-KMJWAFZV.js +389 -0
  12. package/dist/node/{chunk-5RH7UAQC.js → chunk-PTH6MD6P.js} +1 -0
  13. package/dist/node/cli/index.cjs +1553 -839
  14. package/dist/node/cli/index.js +39 -2
  15. package/dist/node/cloud/index.d.cts +1 -1
  16. package/dist/node/cloud/index.d.ts +1 -1
  17. package/dist/node/components/index.d.cts +2 -2
  18. package/dist/node/components/index.d.ts +2 -2
  19. package/dist/node/conversations-KQBXTP3N.js +596 -0
  20. package/dist/node/{engine-2DQBKBJC.js → engine-FEN5IYZ5.js} +1 -1
  21. package/dist/node/index.cjs +911 -43
  22. package/dist/node/index.d.cts +574 -7
  23. package/dist/node/index.d.ts +574 -7
  24. package/dist/node/index.js +672 -26
  25. package/dist/node/integrations/svelte.cjs +190 -3
  26. package/dist/node/integrations/svelte.d.cts +3 -3
  27. package/dist/node/integrations/svelte.d.ts +3 -3
  28. package/dist/node/integrations/svelte.js +2 -2
  29. package/dist/node/{protocol-Qek7ebBl.d.ts → protocol-DcyGMmWY.d.cts} +8 -1
  30. package/dist/node/{protocol-Qek7ebBl.d.cts → protocol-DcyGMmWY.d.ts} +8 -1
  31. package/dist/node/{reactive-engine.svelte-CRNqHlbv.d.ts → reactive-engine.svelte-Cg0Yc2Hs.d.cts} +145 -6
  32. package/dist/node/{reactive-engine.svelte-BFIZfawz.d.cts → reactive-engine.svelte-DekxqFu0.d.ts} +145 -6
  33. package/dist/node/{terminal-adapter-B-UK_Vdz.d.ts → terminal-adapter-CvIvgTo4.d.ts} +1 -1
  34. package/dist/node/{terminal-adapter-BQSIF5bf.d.cts → terminal-adapter-Db-snPJ3.d.cts} +1 -1
  35. package/dist/node/{validate-CNHUULQE.js → validate-EN3M4FUR.js} +1 -1
  36. package/dist/node/{verify-KLJRXVJS.js → verify-7VZRP2WS.js} +2 -2
  37. package/docs/BOT_UPDATE_POLICY.md +125 -0
  38. package/docs/DOGFOODING_CHECKLIST.md +254 -0
  39. package/docs/DOGFOODING_INDEX.md +169 -0
  40. package/docs/DOGFOODING_QUICK_START.md +140 -0
  41. package/docs/KNO_ENG_EXTRACTION_PLAN.md +577 -0
  42. package/docs/PLURES_TOOLS_INVENTORY.md +170 -0
  43. package/docs/README.md +12 -0
  44. package/docs/TESTING_BOT_WORKFLOWS.md +154 -0
  45. package/docs/conversations/INTEGRATION_POINTS.md +719 -0
  46. package/docs/conversations/README.md +168 -0
  47. package/docs/core/extending-praxis-core.md +604 -0
  48. package/docs/core/praxis-core-api.md +385 -0
  49. package/docs/decision-ledger/contract-index.json +2 -2
  50. package/docs/decision-ledger/decisions/2026-02-01-monorepo-organization.md +130 -0
  51. package/docs/examples/DOGFOODING_WORKFLOW_EXAMPLE.md +295 -0
  52. package/docs/examples/README.md +41 -0
  53. package/docs/workflows/pr-overlap-guard.md +50 -0
  54. package/package.json +8 -3
  55. package/src/__tests__/chronicle.test.ts +512 -0
  56. package/src/__tests__/conversations.test.ts +312 -0
  57. package/src/__tests__/edge-cases.test.ts +1 -1
  58. package/src/__tests__/engine-dx.test.ts +355 -0
  59. package/src/__tests__/engine-v2.test.ts +532 -0
  60. package/src/cli/commands/conversations.ts +252 -0
  61. package/src/cli/index.ts +73 -0
  62. package/src/conversations/README.md +230 -0
  63. package/src/conversations/candidate.schema.json +123 -0
  64. package/src/conversations/candidates.ts +114 -0
  65. package/src/conversations/capture.ts +56 -0
  66. package/src/conversations/classify.ts +110 -0
  67. package/src/conversations/conversation.schema.json +106 -0
  68. package/src/conversations/emitters/fs.ts +65 -0
  69. package/src/conversations/emitters/github.ts +115 -0
  70. package/src/conversations/gate.ts +102 -0
  71. package/src/conversations/index.ts +28 -0
  72. package/src/conversations/normalize.ts +51 -0
  73. package/src/conversations/redact.ts +57 -0
  74. package/src/conversations/types.ts +96 -0
  75. package/src/core/chronicle/chronicle.ts +227 -0
  76. package/src/core/chronicle/context.ts +80 -0
  77. package/src/core/chronicle/index.ts +53 -0
  78. package/src/core/chronicle/mcp.ts +135 -0
  79. package/src/core/chronicle/types.ts +61 -0
  80. package/src/core/completeness.ts +274 -0
  81. package/src/core/engine.ts +143 -3
  82. package/src/core/pluresdb/index.ts +22 -0
  83. package/src/core/pluresdb/store.ts +171 -8
  84. package/src/core/protocol.ts +7 -0
  85. package/src/core/rule-result.ts +130 -0
  86. package/src/core/rules.ts +24 -5
  87. package/src/core/ui-rules.ts +340 -0
  88. package/src/dsl/index.ts +6 -0
  89. package/src/index.ts +45 -0
  90. package/src/integrations/pluresdb.ts +22 -0
  91. package/src/vite/completeness-plugin.ts +72 -0
  92. package/dist/browser/chunk-VOMLVI6V.js +0 -197
  93. package/dist/node/chunk-VOMLVI6V.js +0 -197
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Tests for Praxis v2 engine improvements:
3
+ * 1. RuleResult typed returns (no empty arrays)
4
+ * 2. state.events passthrough
5
+ * 3. RuleResult.retract() for fact retraction
6
+ * 4. UI rules module
7
+ * 5. RuleResult.noop/skip tracing
8
+ */
9
+ import { describe, it, expect } from 'vitest';
10
+ import {
11
+ createPraxisEngine,
12
+ PraxisRegistry,
13
+ RuleResult,
14
+ fact,
15
+ uiModule,
16
+ createUIModule,
17
+ uiStateChanged,
18
+ } from '../index.js';
19
+ import type { RuleDescriptor } from '../core/rules.js';
20
+ import type { UIContext } from '../core/ui-rules.js';
21
+
22
+ interface TestContext {
23
+ count: number;
24
+ name: string;
25
+ active: boolean;
26
+ }
27
+
28
+ // ─── 1. RuleResult typed returns ────────────────────────────────────────────
29
+
30
+ describe('RuleResult', () => {
31
+ it('emit() requires at least one fact', () => {
32
+ expect(() => RuleResult.emit([])).toThrow('RuleResult.emit() requires at least one fact');
33
+ });
34
+
35
+ it('emit() creates a result with facts', () => {
36
+ const result = RuleResult.emit([fact('test.fact', { value: 42 })]);
37
+ expect(result.kind).toBe('emit');
38
+ expect(result.hasFacts).toBe(true);
39
+ expect(result.facts).toHaveLength(1);
40
+ expect(result.facts[0].tag).toBe('test.fact');
41
+ });
42
+
43
+ it('noop() creates a traceable no-op', () => {
44
+ const result = RuleResult.noop('Nothing to report');
45
+ expect(result.kind).toBe('noop');
46
+ expect(result.hasFacts).toBe(false);
47
+ expect(result.reason).toBe('Nothing to report');
48
+ });
49
+
50
+ it('skip() creates a traceable skip', () => {
51
+ const result = RuleResult.skip('Precondition not met');
52
+ expect(result.kind).toBe('skip');
53
+ expect(result.hasFacts).toBe(false);
54
+ expect(result.reason).toBe('Precondition not met');
55
+ });
56
+
57
+ it('retract() requires at least one tag', () => {
58
+ expect(() => RuleResult.retract([])).toThrow('RuleResult.retract() requires at least one tag');
59
+ });
60
+
61
+ it('retract() creates a retraction result', () => {
62
+ const result = RuleResult.retract(['sprint.behind'], 'Sprint caught up');
63
+ expect(result.kind).toBe('retract');
64
+ expect(result.hasRetractions).toBe(true);
65
+ expect(result.retractTags).toEqual(['sprint.behind']);
66
+ });
67
+ });
68
+
69
+ // ─── 2. state.events passthrough ────────────────────────────────────────────
70
+
71
+ describe('state.events passthrough', () => {
72
+ it('rules can access events via state.events', () => {
73
+ const registry = new PraxisRegistry<TestContext>();
74
+ let capturedEvents: any[] = [];
75
+
76
+ registry.registerRule({
77
+ id: 'event-reader',
78
+ description: 'Reads events from state',
79
+ impl: (state, _events) => {
80
+ capturedEvents = state.events ?? [];
81
+ return RuleResult.emit([fact('events.read', { count: capturedEvents.length })]);
82
+ },
83
+ });
84
+
85
+ const engine = createPraxisEngine<TestContext>({
86
+ initialContext: { count: 0, name: '', active: false },
87
+ registry,
88
+ });
89
+
90
+ const events = [
91
+ { tag: 'test.event', payload: { value: 'hello' } },
92
+ { tag: 'other.event', payload: { value: 42 } },
93
+ ];
94
+
95
+ engine.step(events);
96
+
97
+ expect(capturedEvents).toHaveLength(2);
98
+ expect(capturedEvents[0].tag).toBe('test.event');
99
+ expect(capturedEvents[0].payload).toEqual({ value: 'hello' });
100
+ expect(capturedEvents[1].tag).toBe('other.event');
101
+ expect(capturedEvents[1].payload).toEqual({ value: 42 });
102
+ });
103
+
104
+ it('state.events matches the events parameter exactly', () => {
105
+ const registry = new PraxisRegistry<TestContext>();
106
+ let stateEventsRef: any;
107
+ let paramEventsRef: any;
108
+
109
+ registry.registerRule({
110
+ id: 'compare-refs',
111
+ description: 'Compares state.events to events param',
112
+ impl: (state, events) => {
113
+ stateEventsRef = state.events;
114
+ paramEventsRef = events;
115
+ return RuleResult.noop();
116
+ },
117
+ });
118
+
119
+ const engine = createPraxisEngine<TestContext>({
120
+ initialContext: { count: 0, name: '', active: false },
121
+ registry,
122
+ });
123
+
124
+ engine.step([{ tag: 'sync.complete', payload: { updated: 3, errors: 0 } }]);
125
+
126
+ // state.events and events param should be the same array
127
+ expect(stateEventsRef).toBe(paramEventsRef);
128
+ });
129
+
130
+ it('event payload data is preserved through the pipeline', () => {
131
+ const registry = new PraxisRegistry<TestContext>();
132
+
133
+ registry.registerRule({
134
+ id: 'sync-classifier',
135
+ description: 'Classifies sync results from event data',
136
+ eventTypes: 'sync.complete',
137
+ impl: (state) => {
138
+ const syncEvent = state.events?.find(e => e.tag === 'sync.complete');
139
+ if (!syncEvent) return RuleResult.skip('No sync event');
140
+
141
+ const payload = syncEvent.payload as { updated: number; errors: number };
142
+ const severity = payload.errors > 0 ? 'error' : payload.updated > 0 ? 'success' : 'info';
143
+
144
+ return RuleResult.emit([fact('sync.outcome', {
145
+ severity,
146
+ updated: payload.updated,
147
+ errors: payload.errors,
148
+ })]);
149
+ },
150
+ });
151
+
152
+ const engine = createPraxisEngine<TestContext>({
153
+ initialContext: { count: 0, name: '', active: false },
154
+ registry,
155
+ });
156
+
157
+ engine.step([{ tag: 'sync.complete', payload: { updated: 3, errors: 1 } }]);
158
+
159
+ const outcome = engine.getFacts().find(f => f.tag === 'sync.outcome');
160
+ expect(outcome).toBeDefined();
161
+ expect((outcome!.payload as any).severity).toBe('error');
162
+ expect((outcome!.payload as any).updated).toBe(3);
163
+ });
164
+ });
165
+
166
+ // ─── 3. RuleResult retraction ───────────────────────────────────────────────
167
+
168
+ describe('fact retraction', () => {
169
+ it('RuleResult.retract() removes existing facts by tag', () => {
170
+ const registry = new PraxisRegistry<TestContext>();
171
+
172
+ registry.registerRule({
173
+ id: 'behind-checker',
174
+ description: 'Checks if behind, retracts when caught up',
175
+ impl: (state) => {
176
+ if (state.context.count < 5) {
177
+ return RuleResult.emit([fact('behind', { count: state.context.count })]);
178
+ }
179
+ return RuleResult.retract(['behind'], 'Caught up');
180
+ },
181
+ });
182
+
183
+ const engine = createPraxisEngine<TestContext>({
184
+ initialContext: { count: 2, name: '', active: false },
185
+ registry,
186
+ });
187
+
188
+ // First step: behind
189
+ engine.step([{ tag: 'tick', payload: {} }]);
190
+ expect(engine.getFacts().some(f => f.tag === 'behind')).toBe(true);
191
+
192
+ // Update: caught up
193
+ engine.updateContext(ctx => ({ ...ctx, count: 10 }));
194
+ engine.step([{ tag: 'tick', payload: {} }]);
195
+ expect(engine.getFacts().some(f => f.tag === 'behind')).toBe(false);
196
+ });
197
+
198
+ it('retraction removes multiple tags at once', () => {
199
+ const registry = new PraxisRegistry<TestContext>();
200
+
201
+ registry.registerRule({
202
+ id: 'multi-emitter',
203
+ description: 'Emits multiple facts',
204
+ eventTypes: 'init',
205
+ impl: () => RuleResult.emit([
206
+ fact('fact.a', {}),
207
+ fact('fact.b', {}),
208
+ fact('fact.c', {}),
209
+ ]),
210
+ });
211
+
212
+ registry.registerRule({
213
+ id: 'multi-retractor',
214
+ description: 'Retracts a and c',
215
+ eventTypes: 'retract',
216
+ impl: () => RuleResult.retract(['fact.a', 'fact.c']),
217
+ });
218
+
219
+ const engine = createPraxisEngine<TestContext>({
220
+ initialContext: { count: 0, name: '', active: false },
221
+ registry,
222
+ });
223
+
224
+ engine.step([{ tag: 'init', payload: {} }]);
225
+ expect(engine.getFacts().map(f => f.tag).sort()).toEqual(['fact.a', 'fact.b', 'fact.c']);
226
+
227
+ engine.step([{ tag: 'retract', payload: {} }]);
228
+ expect(engine.getFacts().map(f => f.tag)).toEqual(['fact.b']);
229
+ });
230
+ });
231
+
232
+ // ─── 4. RuleResult backward compatibility ───────────────────────────────────
233
+
234
+ describe('backward compatibility', () => {
235
+ it('legacy PraxisFact[] return still works', () => {
236
+ const registry = new PraxisRegistry<TestContext>();
237
+
238
+ registry.registerRule({
239
+ id: 'legacy-rule',
240
+ description: 'Returns plain array (legacy)',
241
+ impl: (state) => [{ tag: 'legacy.fact', payload: { count: state.context.count } }],
242
+ });
243
+
244
+ const engine = createPraxisEngine<TestContext>({
245
+ initialContext: { count: 42, name: '', active: false },
246
+ registry,
247
+ });
248
+
249
+ engine.step([{ tag: 'test', payload: {} }]);
250
+ const facts = engine.getFacts();
251
+ expect(facts.some(f => f.tag === 'legacy.fact')).toBe(true);
252
+ expect((facts.find(f => f.tag === 'legacy.fact')!.payload as any).count).toBe(42);
253
+ });
254
+
255
+ it('mixed RuleResult and legacy rules work together', () => {
256
+ const registry = new PraxisRegistry<TestContext>();
257
+
258
+ registry.registerRule({
259
+ id: 'new-style',
260
+ description: 'Uses RuleResult',
261
+ impl: () => RuleResult.emit([fact('new.fact', {})]),
262
+ });
263
+
264
+ registry.registerRule({
265
+ id: 'old-style',
266
+ description: 'Returns array',
267
+ impl: () => [{ tag: 'old.fact', payload: {} }],
268
+ });
269
+
270
+ const engine = createPraxisEngine<TestContext>({
271
+ initialContext: { count: 0, name: '', active: false },
272
+ registry,
273
+ });
274
+
275
+ engine.step([{ tag: 'test', payload: {} }]);
276
+ const tags = engine.getFacts().map(f => f.tag).sort();
277
+ expect(tags).toEqual(['new.fact', 'old.fact']);
278
+ });
279
+ });
280
+
281
+ // ─── 5. Noop/skip diagnostics ───────────────────────────────────────────────
282
+
283
+ describe('noop/skip diagnostics', () => {
284
+ it('noop with reason appears in diagnostics', () => {
285
+ const registry = new PraxisRegistry<TestContext>();
286
+
287
+ registry.registerRule({
288
+ id: 'maybe-rule',
289
+ description: 'Sometimes noops',
290
+ impl: () => RuleResult.noop('Nothing interesting happening'),
291
+ });
292
+
293
+ const engine = createPraxisEngine<TestContext>({
294
+ initialContext: { count: 0, name: '', active: false },
295
+ registry,
296
+ });
297
+
298
+ const result = engine.step([{ tag: 'test', payload: {} }]);
299
+ const trace = result.diagnostics.find(d =>
300
+ d.message.includes('maybe-rule') && d.message.includes('noop')
301
+ );
302
+ expect(trace).toBeDefined();
303
+ expect(trace!.message).toContain('Nothing interesting happening');
304
+ });
305
+
306
+ it('skip with reason appears in diagnostics', () => {
307
+ const registry = new PraxisRegistry<TestContext>();
308
+
309
+ registry.registerRule({
310
+ id: 'guarded-rule',
311
+ description: 'Skips when inactive',
312
+ impl: (state) => {
313
+ if (!state.context.active) return RuleResult.skip('Inactive');
314
+ return RuleResult.emit([fact('active.signal', {})]);
315
+ },
316
+ });
317
+
318
+ const engine = createPraxisEngine<TestContext>({
319
+ initialContext: { count: 0, name: '', active: false },
320
+ registry,
321
+ });
322
+
323
+ const result = engine.step([{ tag: 'test', payload: {} }]);
324
+ const trace = result.diagnostics.find(d =>
325
+ d.message.includes('guarded-rule') && d.message.includes('skip')
326
+ );
327
+ expect(trace).toBeDefined();
328
+ expect(trace!.message).toContain('Inactive');
329
+ });
330
+ });
331
+
332
+ // ─── 6. UI Rules Module ────────────────────────────────────────────────────
333
+
334
+ describe('UI Rules Module', () => {
335
+ it('uiModule has all predefined rules and constraints', () => {
336
+ expect(uiModule.rules).toHaveLength(6);
337
+ expect(uiModule.constraints).toHaveLength(2);
338
+
339
+ const ruleIds = uiModule.rules.map(r => r.id);
340
+ expect(ruleIds).toContain('ui/loading-gate');
341
+ expect(ruleIds).toContain('ui/error-display');
342
+ expect(ruleIds).toContain('ui/offline-indicator');
343
+ expect(ruleIds).toContain('ui/dirty-guard');
344
+ expect(ruleIds).toContain('ui/init-gate');
345
+ expect(ruleIds).toContain('ui/viewport-class');
346
+ });
347
+
348
+ it('loading gate emits and retracts', () => {
349
+ const registry = new PraxisRegistry<UIContext>();
350
+ registry.registerModule(uiModule as any);
351
+
352
+ const engine = createPraxisEngine<UIContext>({
353
+ initialContext: { loading: true, initialized: true },
354
+ registry,
355
+ });
356
+
357
+ // Loading → gate active
358
+ engine.step([uiStateChanged()]);
359
+ expect(engine.getFacts().some(f => f.tag === 'ui.loading-gate')).toBe(true);
360
+
361
+ // Not loading → gate retracted
362
+ engine.updateContext(ctx => ({ ...ctx, loading: false }));
363
+ engine.step([uiStateChanged()]);
364
+ expect(engine.getFacts().some(f => f.tag === 'ui.loading-gate')).toBe(false);
365
+ });
366
+
367
+ it('error display emits and retracts', () => {
368
+ const registry = new PraxisRegistry<UIContext>();
369
+ registry.registerModule(uiModule as any);
370
+
371
+ const engine = createPraxisEngine<UIContext>({
372
+ initialContext: { error: 'Network error', initialized: true },
373
+ registry,
374
+ });
375
+
376
+ engine.step([uiStateChanged()]);
377
+ const errorFact = engine.getFacts().find(f => f.tag === 'ui.error-display');
378
+ expect(errorFact).toBeDefined();
379
+ expect((errorFact!.payload as any).message).toBe('Network error');
380
+
381
+ // Clear error
382
+ engine.updateContext(ctx => ({ ...ctx, error: null }));
383
+ engine.step([uiStateChanged()]);
384
+ expect(engine.getFacts().some(f => f.tag === 'ui.error-display')).toBe(false);
385
+ });
386
+
387
+ it('dirty guard signals unsaved changes', () => {
388
+ const registry = new PraxisRegistry<UIContext>();
389
+ registry.registerModule(uiModule as any);
390
+
391
+ const engine = createPraxisEngine<UIContext>({
392
+ initialContext: { dirty: true, initialized: true },
393
+ registry,
394
+ });
395
+
396
+ engine.step([uiStateChanged()]);
397
+ const unsaved = engine.getFacts().find(f => f.tag === 'ui.unsaved-warning');
398
+ expect(unsaved).toBeDefined();
399
+ expect((unsaved!.payload as any).blocking).toBe(true);
400
+ });
401
+
402
+ it('init gate blocks until initialized', () => {
403
+ const registry = new PraxisRegistry<UIContext>();
404
+ registry.registerModule(uiModule as any);
405
+
406
+ const engine = createPraxisEngine<UIContext>({
407
+ initialContext: { initialized: false },
408
+ registry,
409
+ });
410
+
411
+ engine.step([uiStateChanged()]);
412
+ expect(engine.getFacts().some(f => f.tag === 'ui.init-pending')).toBe(true);
413
+
414
+ engine.updateContext(ctx => ({ ...ctx, initialized: true }));
415
+ engine.step([uiStateChanged()]);
416
+ expect(engine.getFacts().some(f => f.tag === 'ui.init-pending')).toBe(false);
417
+ });
418
+
419
+ it('createUIModule selects specific rules', () => {
420
+ const custom = createUIModule({
421
+ rules: ['ui/loading-gate', 'ui/dirty-guard'],
422
+ constraints: ['ui/must-be-initialized'],
423
+ });
424
+
425
+ expect(custom.rules).toHaveLength(2);
426
+ expect(custom.constraints).toHaveLength(1);
427
+ expect(custom.rules.map(r => r.id)).toEqual(['ui/loading-gate', 'ui/dirty-guard']);
428
+ });
429
+
430
+ it('UI rules do not interfere with domain rules', () => {
431
+ const registry = new PraxisRegistry<TestContext & UIContext>();
432
+
433
+ // Domain rule
434
+ registry.registerRule({
435
+ id: 'domain/count-check',
436
+ description: 'Business logic',
437
+ impl: (state) => {
438
+ if (state.context.count > 10) {
439
+ return RuleResult.emit([fact('domain.high-count', { count: state.context.count })]);
440
+ }
441
+ return RuleResult.noop();
442
+ },
443
+ });
444
+
445
+ // UI rules (separate namespace)
446
+ registry.registerModule(uiModule as any);
447
+
448
+ const engine = createPraxisEngine<TestContext & UIContext>({
449
+ initialContext: { count: 20, name: '', active: true, loading: true, initialized: true },
450
+ registry,
451
+ });
452
+
453
+ engine.step([uiStateChanged()]);
454
+ const facts = engine.getFacts();
455
+
456
+ // Both domain and UI facts coexist
457
+ expect(facts.some(f => f.tag === 'domain.high-count')).toBe(true);
458
+ expect(facts.some(f => f.tag === 'ui.loading-gate')).toBe(true);
459
+
460
+ // Domain facts don't have ui. prefix
461
+ const domainFacts = facts.filter(f => !f.tag.startsWith('ui.'));
462
+ const uiFacts = facts.filter(f => f.tag.startsWith('ui.'));
463
+ expect(domainFacts.length).toBeGreaterThan(0);
464
+ expect(uiFacts.length).toBeGreaterThan(0);
465
+ });
466
+ });
467
+
468
+ // ─── 7. Completeness audit in package ───────────────────────────────────────
469
+
470
+ describe('completeness audit', () => {
471
+ it('auditCompleteness is exported from package', async () => {
472
+ const { auditCompleteness, formatReport } = await import('../index.js');
473
+ expect(typeof auditCompleteness).toBe('function');
474
+ expect(typeof formatReport).toBe('function');
475
+ });
476
+
477
+ it('produces correct report', async () => {
478
+ const { auditCompleteness, formatReport } = await import('../index.js');
479
+
480
+ const report = auditCompleteness(
481
+ {
482
+ branches: [
483
+ { location: 'app.ts:10', condition: 'if behind', kind: 'domain', coveredBy: 'sprint-behind' },
484
+ { location: 'app.ts:20', condition: 'if blocked', kind: 'domain', coveredBy: null },
485
+ { location: 'app.ts:30', condition: 'if too many', kind: 'invariant', coveredBy: 'max-hours' },
486
+ ],
487
+ stateFields: [
488
+ { source: 'store', field: 'hours', inContext: true, usedByRule: true },
489
+ { source: 'store', field: 'connection', inContext: false, usedByRule: true },
490
+ ],
491
+ transitions: [
492
+ { description: 'Sprint updated', eventTag: 'sprint.update', location: 'store.ts' },
493
+ { description: 'User saved', eventTag: null, location: 'editor.ts' },
494
+ ],
495
+ rulesNeedingContracts: ['sprint-behind', 'blocked-check'],
496
+ },
497
+ ['sprint-behind'],
498
+ ['max-hours'],
499
+ ['sprint-behind'],
500
+ );
501
+
502
+ expect(report.score).toBeLessThan(90); // Not complete — missing coverage
503
+ expect(report.rules.covered).toBe(1);
504
+ expect(report.rules.uncovered).toHaveLength(1);
505
+ expect(report.constraints.covered).toBe(1);
506
+ expect(report.context.missing).toHaveLength(1);
507
+ expect(report.events.missing).toHaveLength(1);
508
+
509
+ const text = formatReport(report);
510
+ expect(text).toContain('Praxis Completeness');
511
+ expect(text).toContain('domain branches');
512
+ });
513
+
514
+ it('strict mode throws on low score', async () => {
515
+ const { auditCompleteness } = await import('../index.js');
516
+
517
+ expect(() => auditCompleteness(
518
+ {
519
+ branches: [
520
+ { location: 'a', condition: 'b', kind: 'domain', coveredBy: null },
521
+ ],
522
+ stateFields: [],
523
+ transitions: [],
524
+ rulesNeedingContracts: [],
525
+ },
526
+ [],
527
+ [],
528
+ [],
529
+ { strict: true, threshold: 90 },
530
+ )).toThrow('below threshold');
531
+ });
532
+ });