@plures/praxis 1.2.13 → 1.2.41
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/README.md +44 -0
- package/dist/browser/{chunk-VOMLVI6V.js → chunk-BBP2F7TT.js} +70 -1
- package/dist/browser/{chunk-K377RW4V.js → chunk-FCEH7WMH.js} +1 -1
- package/dist/browser/{engine-YJZV4SLD.js → engine-65QDGCAN.js} +1 -1
- package/dist/browser/index.d.ts +104 -2
- package/dist/browser/index.js +181 -5
- package/dist/browser/integrations/svelte.d.ts +2 -2
- package/dist/browser/integrations/svelte.js +2 -2
- package/dist/browser/{reactive-engine.svelte-9aS0kTa8.d.ts → reactive-engine.svelte-Cqd8Mod2.d.ts} +56 -1
- package/dist/node/{chunk-PRPQO6R5.js → chunk-32YFEEML.js} +1 -1
- package/dist/node/{chunk-VOMLVI6V.js → chunk-BBP2F7TT.js} +70 -1
- package/dist/node/{chunk-5RH7UAQC.js → chunk-PTH6MD6P.js} +1 -0
- package/dist/node/cli/index.cjs +1553 -839
- package/dist/node/cli/index.js +39 -2
- package/dist/node/cloud/index.d.cts +1 -1
- package/dist/node/cloud/index.d.ts +1 -1
- package/dist/node/components/index.d.cts +2 -2
- package/dist/node/components/index.d.ts +2 -2
- package/dist/node/conversations-KQBXTP3N.js +596 -0
- package/dist/node/{engine-2DQBKBJC.js → engine-7CXQV6RC.js} +1 -1
- package/dist/node/index.cjs +408 -3
- package/dist/node/index.d.cts +308 -7
- package/dist/node/index.d.ts +308 -7
- package/dist/node/index.js +336 -6
- package/dist/node/integrations/svelte.cjs +70 -1
- package/dist/node/integrations/svelte.d.cts +3 -3
- package/dist/node/integrations/svelte.d.ts +3 -3
- package/dist/node/integrations/svelte.js +2 -2
- package/dist/node/{protocol-Qek7ebBl.d.ts → protocol-BocKczNv.d.cts} +1 -1
- package/dist/node/{protocol-Qek7ebBl.d.cts → protocol-BocKczNv.d.ts} +1 -1
- package/dist/node/{reactive-engine.svelte-CRNqHlbv.d.ts → reactive-engine.svelte-CGe8SpVE.d.cts} +57 -2
- package/dist/node/{reactive-engine.svelte-BFIZfawz.d.cts → reactive-engine.svelte-D-xTDxT5.d.ts} +57 -2
- package/dist/node/{terminal-adapter-B-UK_Vdz.d.ts → terminal-adapter-CvIvgTo4.d.ts} +1 -1
- package/dist/node/{terminal-adapter-BQSIF5bf.d.cts → terminal-adapter-Db-snPJ3.d.cts} +1 -1
- package/dist/node/{validate-CNHUULQE.js → validate-EN3M4FUR.js} +1 -1
- package/dist/node/{verify-KLJRXVJS.js → verify-7VZRP2WS.js} +2 -2
- package/docs/BOT_UPDATE_POLICY.md +125 -0
- package/docs/DOGFOODING_CHECKLIST.md +254 -0
- package/docs/DOGFOODING_INDEX.md +169 -0
- package/docs/DOGFOODING_QUICK_START.md +140 -0
- package/docs/KNO_ENG_EXTRACTION_PLAN.md +577 -0
- package/docs/PLURES_TOOLS_INVENTORY.md +170 -0
- package/docs/README.md +12 -0
- package/docs/TESTING_BOT_WORKFLOWS.md +154 -0
- package/docs/conversations/INTEGRATION_POINTS.md +719 -0
- package/docs/conversations/README.md +168 -0
- package/docs/core/extending-praxis-core.md +604 -0
- package/docs/core/praxis-core-api.md +385 -0
- package/docs/decision-ledger/contract-index.json +2 -2
- package/docs/decision-ledger/decisions/2026-02-01-monorepo-organization.md +130 -0
- package/docs/examples/DOGFOODING_WORKFLOW_EXAMPLE.md +295 -0
- package/docs/examples/README.md +41 -0
- package/docs/workflows/pr-overlap-guard.md +50 -0
- package/package.json +7 -2
- package/src/__tests__/chronicle.test.ts +512 -0
- package/src/__tests__/conversations.test.ts +312 -0
- package/src/__tests__/edge-cases.test.ts +1 -1
- package/src/__tests__/engine-dx.test.ts +355 -0
- package/src/cli/commands/conversations.ts +252 -0
- package/src/cli/index.ts +73 -0
- package/src/conversations/README.md +230 -0
- package/src/conversations/candidate.schema.json +123 -0
- package/src/conversations/candidates.ts +114 -0
- package/src/conversations/capture.ts +56 -0
- package/src/conversations/classify.ts +110 -0
- package/src/conversations/conversation.schema.json +106 -0
- package/src/conversations/emitters/fs.ts +65 -0
- package/src/conversations/emitters/github.ts +115 -0
- package/src/conversations/gate.ts +102 -0
- package/src/conversations/index.ts +28 -0
- package/src/conversations/normalize.ts +51 -0
- package/src/conversations/redact.ts +57 -0
- package/src/conversations/types.ts +96 -0
- package/src/core/chronicle/chronicle.ts +227 -0
- package/src/core/chronicle/context.ts +80 -0
- package/src/core/chronicle/index.ts +53 -0
- package/src/core/chronicle/mcp.ts +135 -0
- package/src/core/chronicle/types.ts +61 -0
- package/src/core/engine.ts +99 -1
- package/src/core/pluresdb/index.ts +22 -0
- package/src/core/pluresdb/store.ts +162 -5
- package/src/core/rules.ts +12 -0
- package/src/dsl/index.ts +6 -0
- package/src/index.ts +18 -0
- package/src/integrations/pluresdb.ts +22 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for praxis-conversations subsystem
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import {
|
|
10
|
+
captureConversation,
|
|
11
|
+
loadConversation,
|
|
12
|
+
redactConversation,
|
|
13
|
+
redactText,
|
|
14
|
+
normalizeConversation,
|
|
15
|
+
classifyConversation,
|
|
16
|
+
generateCandidate,
|
|
17
|
+
applyGates,
|
|
18
|
+
candidatePassed,
|
|
19
|
+
emitToFS,
|
|
20
|
+
type Conversation,
|
|
21
|
+
} from '../conversations/index.js';
|
|
22
|
+
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = path.dirname(__filename);
|
|
25
|
+
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
26
|
+
const fixturesDir = path.join(projectRoot, 'test/fixtures/conversations');
|
|
27
|
+
|
|
28
|
+
describe('Conversations Subsystem', () => {
|
|
29
|
+
describe('Capture', () => {
|
|
30
|
+
it('should capture a conversation from input', () => {
|
|
31
|
+
const conversation = captureConversation({
|
|
32
|
+
turns: [
|
|
33
|
+
{ role: 'user', content: 'Hello' },
|
|
34
|
+
{ role: 'assistant', content: 'Hi there!' },
|
|
35
|
+
],
|
|
36
|
+
metadata: {
|
|
37
|
+
source: 'test',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(conversation).toBeDefined();
|
|
42
|
+
expect(conversation.id).toBeDefined();
|
|
43
|
+
expect(conversation.turns).toHaveLength(2);
|
|
44
|
+
expect(conversation.metadata.source).toBe('test');
|
|
45
|
+
expect(conversation.redacted).toBe(false);
|
|
46
|
+
expect(conversation.normalized).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should load conversation from JSON', async () => {
|
|
50
|
+
const bugReportPath = path.join(fixturesDir, 'bug-report.json');
|
|
51
|
+
const content = await fs.readFile(bugReportPath, 'utf-8');
|
|
52
|
+
const conversation = loadConversation(content);
|
|
53
|
+
|
|
54
|
+
expect(conversation.id).toBe('test-conversation-1');
|
|
55
|
+
expect(conversation.turns).toHaveLength(3);
|
|
56
|
+
expect(conversation.metadata.source).toBe('github-copilot');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Redaction', () => {
|
|
61
|
+
it('should redact email addresses', () => {
|
|
62
|
+
const text = 'Contact me at john.doe@example.com for more info';
|
|
63
|
+
const redacted = redactText(text);
|
|
64
|
+
|
|
65
|
+
expect(redacted).toContain('[EMAIL_REDACTED]');
|
|
66
|
+
expect(redacted).not.toContain('john.doe@example.com');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should redact phone numbers', () => {
|
|
70
|
+
const text = 'Call me at 555-123-4567';
|
|
71
|
+
const redacted = redactText(text);
|
|
72
|
+
|
|
73
|
+
expect(redacted).toContain('[PHONE_REDACTED]');
|
|
74
|
+
expect(redacted).not.toContain('555-123-4567');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should redact IP addresses', () => {
|
|
78
|
+
const text = 'Server IP is 192.168.1.100';
|
|
79
|
+
const redacted = redactText(text);
|
|
80
|
+
|
|
81
|
+
expect(redacted).toContain('[IP_REDACTED]');
|
|
82
|
+
expect(redacted).not.toContain('192.168.1.100');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should redact conversation content', async () => {
|
|
86
|
+
const bugReportPath = path.join(fixturesDir, 'bug-report.json');
|
|
87
|
+
const content = await fs.readFile(bugReportPath, 'utf-8');
|
|
88
|
+
const conversation = loadConversation(content);
|
|
89
|
+
|
|
90
|
+
const redacted = redactConversation(conversation);
|
|
91
|
+
|
|
92
|
+
expect(redacted.redacted).toBe(true);
|
|
93
|
+
expect(redacted.turns[0].content).toContain('[EMAIL_REDACTED]');
|
|
94
|
+
expect(redacted.turns[0].content).not.toContain('test@example.com');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('Normalization', () => {
|
|
99
|
+
it('should normalize whitespace', () => {
|
|
100
|
+
const conversation = captureConversation({
|
|
101
|
+
turns: [
|
|
102
|
+
{ role: 'user', content: 'Hello\r\n\r\n\r\nWorld\t\t\tTest' },
|
|
103
|
+
],
|
|
104
|
+
metadata: {},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const normalized = normalizeConversation(conversation);
|
|
108
|
+
|
|
109
|
+
expect(normalized.normalized).toBe(true);
|
|
110
|
+
expect(normalized.turns[0].content).not.toContain('\r');
|
|
111
|
+
expect(normalized.turns[0].content).not.toContain('\t');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should normalize code blocks', () => {
|
|
115
|
+
const conversation = captureConversation({
|
|
116
|
+
turns: [
|
|
117
|
+
{ role: 'user', content: '```JAVASCRIPT\nconst x = 1;\n```' },
|
|
118
|
+
],
|
|
119
|
+
metadata: {},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const normalized = normalizeConversation(conversation);
|
|
123
|
+
|
|
124
|
+
expect(normalized.turns[0].content).toContain('```javascript');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('Classification', () => {
|
|
129
|
+
it('should classify bug report correctly', async () => {
|
|
130
|
+
const bugReportPath = path.join(fixturesDir, 'bug-report.json');
|
|
131
|
+
const content = await fs.readFile(bugReportPath, 'utf-8');
|
|
132
|
+
let conversation = loadConversation(content);
|
|
133
|
+
|
|
134
|
+
conversation = classifyConversation(conversation);
|
|
135
|
+
|
|
136
|
+
expect(conversation.classified).toBe(true);
|
|
137
|
+
expect(conversation.classification?.category).toBe('bug-report');
|
|
138
|
+
expect(conversation.classification?.confidence).toBeGreaterThan(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should classify feature request correctly', async () => {
|
|
142
|
+
const featurePath = path.join(fixturesDir, 'feature-request.json');
|
|
143
|
+
const content = await fs.readFile(featurePath, 'utf-8');
|
|
144
|
+
let conversation = loadConversation(content);
|
|
145
|
+
|
|
146
|
+
conversation = classifyConversation(conversation);
|
|
147
|
+
|
|
148
|
+
expect(conversation.classified).toBe(true);
|
|
149
|
+
expect(conversation.classification?.category).toBe('feature-request');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should classify question correctly', async () => {
|
|
153
|
+
const questionPath = path.join(fixturesDir, 'question.json');
|
|
154
|
+
const content = await fs.readFile(questionPath, 'utf-8');
|
|
155
|
+
let conversation = loadConversation(content);
|
|
156
|
+
|
|
157
|
+
conversation = classifyConversation(conversation);
|
|
158
|
+
|
|
159
|
+
expect(conversation.classified).toBe(true);
|
|
160
|
+
expect(conversation.classification?.category).toBe('question');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('Candidate Generation', () => {
|
|
165
|
+
it('should generate candidate from classified conversation', async () => {
|
|
166
|
+
const bugReportPath = path.join(fixturesDir, 'bug-report.json');
|
|
167
|
+
const content = await fs.readFile(bugReportPath, 'utf-8');
|
|
168
|
+
let conversation = loadConversation(content);
|
|
169
|
+
|
|
170
|
+
conversation = classifyConversation(conversation);
|
|
171
|
+
const candidate = generateCandidate(conversation);
|
|
172
|
+
|
|
173
|
+
expect(candidate).toBeDefined();
|
|
174
|
+
expect(candidate?.id).toBeDefined();
|
|
175
|
+
expect(candidate?.conversationId).toBe(conversation.id);
|
|
176
|
+
expect(candidate?.title).toContain('Bug Report');
|
|
177
|
+
expect(candidate?.body).toContain('Conversation Summary');
|
|
178
|
+
// Priority is medium by default unless confidence > 0.7
|
|
179
|
+
expect(['medium', 'high']).toContain(candidate?.metadata.priority);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should throw error if conversation not classified', () => {
|
|
183
|
+
const conversation = captureConversation({
|
|
184
|
+
turns: [{ role: 'user', content: 'test' }],
|
|
185
|
+
metadata: {},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(() => generateCandidate(conversation)).toThrow();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('Gating', () => {
|
|
193
|
+
it('should pass gates for valid candidate', async () => {
|
|
194
|
+
const bugReportPath = path.join(fixturesDir, 'bug-report.json');
|
|
195
|
+
const content = await fs.readFile(bugReportPath, 'utf-8');
|
|
196
|
+
let conversation = loadConversation(content);
|
|
197
|
+
|
|
198
|
+
conversation = classifyConversation(conversation);
|
|
199
|
+
const candidate = generateCandidate(conversation);
|
|
200
|
+
|
|
201
|
+
if (candidate) {
|
|
202
|
+
const gated = applyGates(candidate);
|
|
203
|
+
|
|
204
|
+
expect(gated.gateStatus).toBeDefined();
|
|
205
|
+
expect(gated.gateStatus?.gates).toHaveLength(4);
|
|
206
|
+
expect(candidatePassed(gated)).toBe(true);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should fail gate for short content', () => {
|
|
211
|
+
let conversation = captureConversation({
|
|
212
|
+
turns: [{ role: 'user', content: 'Hi' }],
|
|
213
|
+
metadata: {},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
conversation = classifyConversation(conversation);
|
|
217
|
+
const candidate = generateCandidate(conversation);
|
|
218
|
+
|
|
219
|
+
if (candidate) {
|
|
220
|
+
const gated = applyGates(candidate);
|
|
221
|
+
const passed = candidatePassed(gated);
|
|
222
|
+
|
|
223
|
+
expect(passed).toBe(false);
|
|
224
|
+
// Could fail on minimum-length or valid-title
|
|
225
|
+
expect(gated.gateStatus?.passed).toBe(false);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('Emitters', () => {
|
|
231
|
+
describe('FS Emitter', () => {
|
|
232
|
+
it('should emit candidate to filesystem', async () => {
|
|
233
|
+
const tmpDir = path.join(projectRoot, 'test/.tmp/emit-test');
|
|
234
|
+
|
|
235
|
+
const bugReportPath = path.join(fixturesDir, 'bug-report.json');
|
|
236
|
+
const content = await fs.readFile(bugReportPath, 'utf-8');
|
|
237
|
+
let conversation = loadConversation(content);
|
|
238
|
+
|
|
239
|
+
conversation = classifyConversation(conversation);
|
|
240
|
+
const candidate = generateCandidate(conversation);
|
|
241
|
+
|
|
242
|
+
if (candidate) {
|
|
243
|
+
const gated = applyGates(candidate);
|
|
244
|
+
|
|
245
|
+
const result = await emitToFS(gated, {
|
|
246
|
+
outputDir: tmpDir,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result.emitted).toBe(true);
|
|
250
|
+
expect(result.emissionResult?.success).toBe(true);
|
|
251
|
+
|
|
252
|
+
// Verify file was created
|
|
253
|
+
const files = await fs.readdir(tmpDir);
|
|
254
|
+
expect(files.length).toBeGreaterThan(0);
|
|
255
|
+
|
|
256
|
+
// Cleanup
|
|
257
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should support dry run mode', async () => {
|
|
262
|
+
let conversation = captureConversation({
|
|
263
|
+
turns: [{ role: 'user', content: 'Test message for dry run mode' }],
|
|
264
|
+
metadata: {},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
conversation = classifyConversation(conversation);
|
|
268
|
+
const candidate = generateCandidate(conversation);
|
|
269
|
+
|
|
270
|
+
if (candidate) {
|
|
271
|
+
const gated = applyGates(candidate);
|
|
272
|
+
|
|
273
|
+
const result = await emitToFS(gated, {
|
|
274
|
+
outputDir: '/tmp/nonexistent',
|
|
275
|
+
dryRun: true,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(result.emitted).toBe(true);
|
|
279
|
+
expect(result.emissionResult?.success).toBe(true);
|
|
280
|
+
expect(result.emissionResult?.externalId).toContain('fs://');
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('Full Pipeline', () => {
|
|
287
|
+
it('should process conversation through full pipeline', async () => {
|
|
288
|
+
const bugReportPath = path.join(fixturesDir, 'bug-report.json');
|
|
289
|
+
const content = await fs.readFile(bugReportPath, 'utf-8');
|
|
290
|
+
let conversation = loadConversation(content);
|
|
291
|
+
|
|
292
|
+
// Pipeline: redact -> normalize -> classify
|
|
293
|
+
conversation = redactConversation(conversation);
|
|
294
|
+
expect(conversation.redacted).toBe(true);
|
|
295
|
+
|
|
296
|
+
conversation = normalizeConversation(conversation);
|
|
297
|
+
expect(conversation.normalized).toBe(true);
|
|
298
|
+
|
|
299
|
+
conversation = classifyConversation(conversation);
|
|
300
|
+
expect(conversation.classified).toBe(true);
|
|
301
|
+
|
|
302
|
+
// Generate and gate candidate
|
|
303
|
+
const candidate = generateCandidate(conversation);
|
|
304
|
+
expect(candidate).toBeDefined();
|
|
305
|
+
|
|
306
|
+
if (candidate) {
|
|
307
|
+
const gated = applyGates(candidate);
|
|
308
|
+
expect(candidatePassed(gated)).toBe(true);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -488,7 +488,7 @@ describe('Edge Cases and Failure Paths', () => {
|
|
|
488
488
|
if (events.some(TestEvent.is)) {
|
|
489
489
|
const facts = [];
|
|
490
490
|
for (let i = 0; i < 1000; i++) {
|
|
491
|
-
facts.push(TestFact
|
|
491
|
+
facts.push({ tag: `TestFact-${i}`, payload: { index: i } });
|
|
492
492
|
}
|
|
493
493
|
return facts;
|
|
494
494
|
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for engine DX improvements:
|
|
3
|
+
* 1. eventTypes filter on RuleDescriptor
|
|
4
|
+
* 2. Atomic stepWithContext()
|
|
5
|
+
* 3. Fact deduplication + maxFacts
|
|
6
|
+
* 4. checkConstraints() convenience method
|
|
7
|
+
* 5. defineRule/defineModule type inference helpers
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { createPraxisEngine, PraxisRegistry, type PraxisFact, type PraxisEvent } from '../index.js';
|
|
11
|
+
import type { RuleDescriptor, ConstraintDescriptor, PraxisModule } from '../core/rules.js';
|
|
12
|
+
import { defineRule, defineConstraint, defineModule } from '../dsl/index.js';
|
|
13
|
+
|
|
14
|
+
interface TestContext {
|
|
15
|
+
count: number;
|
|
16
|
+
name: string;
|
|
17
|
+
items: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── 1. eventTypes filter ──────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
describe('eventTypes filter', () => {
|
|
23
|
+
it('rule with eventTypes only fires on matching events', () => {
|
|
24
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
25
|
+
registry.registerRule({
|
|
26
|
+
id: 'count-rule',
|
|
27
|
+
description: 'Only fires on count.increment',
|
|
28
|
+
eventTypes: 'count.increment',
|
|
29
|
+
impl: (state) => [{ tag: 'count.incremented', payload: { count: state.context.count } }],
|
|
30
|
+
});
|
|
31
|
+
registry.registerRule({
|
|
32
|
+
id: 'catch-all',
|
|
33
|
+
description: 'Fires on everything',
|
|
34
|
+
impl: () => [{ tag: 'catch-all.fired', payload: {} }],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const engine = createPraxisEngine<TestContext>({
|
|
38
|
+
initialContext: { count: 1, name: 'test', items: [] },
|
|
39
|
+
registry,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Fire unrelated event — only catch-all should fire
|
|
43
|
+
const r1 = engine.step([{ tag: 'name.changed', payload: {} }]);
|
|
44
|
+
const tags1 = r1.state.facts.map(f => f.tag);
|
|
45
|
+
expect(tags1).toContain('catch-all.fired');
|
|
46
|
+
expect(tags1).not.toContain('count.incremented');
|
|
47
|
+
|
|
48
|
+
// Fire matching event — both should fire
|
|
49
|
+
engine.clearFacts();
|
|
50
|
+
const r2 = engine.step([{ tag: 'count.increment', payload: {} }]);
|
|
51
|
+
const tags2 = r2.state.facts.map(f => f.tag);
|
|
52
|
+
expect(tags2).toContain('catch-all.fired');
|
|
53
|
+
expect(tags2).toContain('count.incremented');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rule with eventTypes array matches any of the listed tags', () => {
|
|
57
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
58
|
+
registry.registerRule({
|
|
59
|
+
id: 'multi-event',
|
|
60
|
+
description: 'Fires on count.increment or count.decrement',
|
|
61
|
+
eventTypes: ['count.increment', 'count.decrement'],
|
|
62
|
+
impl: () => [{ tag: 'multi.fired', payload: {} }],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const engine = createPraxisEngine<TestContext>({
|
|
66
|
+
initialContext: { count: 0, name: '', items: [] },
|
|
67
|
+
registry,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Match first
|
|
71
|
+
let r = engine.step([{ tag: 'count.increment', payload: {} }]);
|
|
72
|
+
expect(r.state.facts.some(f => f.tag === 'multi.fired')).toBe(true);
|
|
73
|
+
|
|
74
|
+
engine.clearFacts();
|
|
75
|
+
|
|
76
|
+
// Match second
|
|
77
|
+
r = engine.step([{ tag: 'count.decrement', payload: {} }]);
|
|
78
|
+
expect(r.state.facts.some(f => f.tag === 'multi.fired')).toBe(true);
|
|
79
|
+
|
|
80
|
+
engine.clearFacts();
|
|
81
|
+
|
|
82
|
+
// No match
|
|
83
|
+
r = engine.step([{ tag: 'other.event', payload: {} }]);
|
|
84
|
+
expect(r.state.facts.some(f => f.tag === 'multi.fired')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('empty events array skips rules with eventTypes', () => {
|
|
88
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
89
|
+
registry.registerRule({
|
|
90
|
+
id: 'filtered-rule',
|
|
91
|
+
description: 'Needs specific event',
|
|
92
|
+
eventTypes: 'specific.event',
|
|
93
|
+
impl: () => [{ tag: 'filtered.fired', payload: {} }],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const engine = createPraxisEngine<TestContext>({
|
|
97
|
+
initialContext: { count: 0, name: '', items: [] },
|
|
98
|
+
registry,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const r = engine.step([]);
|
|
102
|
+
expect(r.state.facts).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ─── 2. Atomic stepWithContext ──────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe('stepWithContext', () => {
|
|
109
|
+
it('updates context before rules evaluate', () => {
|
|
110
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
111
|
+
registry.registerRule({
|
|
112
|
+
id: 'check-context',
|
|
113
|
+
description: 'Reads context.count',
|
|
114
|
+
impl: (state) => {
|
|
115
|
+
// This should see the UPDATED count, not the old one
|
|
116
|
+
return [{ tag: 'seen-count', payload: { count: state.context.count } }];
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const engine = createPraxisEngine<TestContext>({
|
|
121
|
+
initialContext: { count: 0, name: '', items: [] },
|
|
122
|
+
registry,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = engine.stepWithContext(
|
|
126
|
+
(ctx) => ({ ...ctx, count: 42 }),
|
|
127
|
+
[{ tag: 'test', payload: {} }]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const seenFact = result.state.facts.find(f => f.tag === 'seen-count');
|
|
131
|
+
expect(seenFact).toBeDefined();
|
|
132
|
+
expect((seenFact!.payload as any).count).toBe(42);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns proper diagnostics from constraints', () => {
|
|
136
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
137
|
+
registry.registerConstraint({
|
|
138
|
+
id: 'max-count',
|
|
139
|
+
description: 'Count must be <= 100',
|
|
140
|
+
impl: (state) => state.context.count <= 100 ? true : `Count too high: ${state.context.count}`,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const engine = createPraxisEngine<TestContext>({
|
|
144
|
+
initialContext: { count: 0, name: '', items: [] },
|
|
145
|
+
registry,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const result = engine.stepWithContext(
|
|
149
|
+
(ctx) => ({ ...ctx, count: 200 }),
|
|
150
|
+
[{ tag: 'test', payload: {} }]
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(result.diagnostics).toHaveLength(1);
|
|
154
|
+
expect(result.diagnostics[0].message).toContain('200');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ─── 3. Fact deduplication ─────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe('fact deduplication', () => {
|
|
161
|
+
it('last-write-wins deduplicates by tag (default)', () => {
|
|
162
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
163
|
+
registry.registerRule({
|
|
164
|
+
id: 'counter',
|
|
165
|
+
description: 'Emits count fact',
|
|
166
|
+
impl: (state) => [{ tag: 'current-count', payload: { value: state.context.count } }],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const engine = createPraxisEngine<TestContext>({
|
|
170
|
+
initialContext: { count: 1, name: '', items: [] },
|
|
171
|
+
registry,
|
|
172
|
+
// default factDedup is 'last-write-wins'
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
engine.step([{ tag: 'tick', payload: {} }]);
|
|
176
|
+
engine.updateContext(ctx => ({ ...ctx, count: 2 }));
|
|
177
|
+
engine.step([{ tag: 'tick', payload: {} }]);
|
|
178
|
+
engine.updateContext(ctx => ({ ...ctx, count: 3 }));
|
|
179
|
+
engine.step([{ tag: 'tick', payload: {} }]);
|
|
180
|
+
|
|
181
|
+
const facts = engine.getFacts();
|
|
182
|
+
const countFacts = facts.filter(f => f.tag === 'current-count');
|
|
183
|
+
// Should only have ONE current-count fact (the latest)
|
|
184
|
+
expect(countFacts).toHaveLength(1);
|
|
185
|
+
expect((countFacts[0].payload as any).value).toBe(3);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('none mode accumulates all facts', () => {
|
|
189
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
190
|
+
registry.registerRule({
|
|
191
|
+
id: 'counter',
|
|
192
|
+
description: 'Emits count fact',
|
|
193
|
+
impl: (state) => [{ tag: 'current-count', payload: { value: state.context.count } }],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const engine = createPraxisEngine<TestContext>({
|
|
197
|
+
initialContext: { count: 1, name: '', items: [] },
|
|
198
|
+
registry,
|
|
199
|
+
factDedup: 'none',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
engine.step([{ tag: 'tick', payload: {} }]);
|
|
203
|
+
engine.updateContext(ctx => ({ ...ctx, count: 2 }));
|
|
204
|
+
engine.step([{ tag: 'tick', payload: {} }]);
|
|
205
|
+
|
|
206
|
+
const countFacts = engine.getFacts().filter(f => f.tag === 'current-count');
|
|
207
|
+
expect(countFacts).toHaveLength(2);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('maxFacts limits fact accumulation', () => {
|
|
211
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
212
|
+
registry.registerRule({
|
|
213
|
+
id: 'emitter',
|
|
214
|
+
description: 'Emits unique fact each step',
|
|
215
|
+
impl: (state) => [{ tag: `fact-${state.context.count}`, payload: {} }],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const engine = createPraxisEngine<TestContext>({
|
|
219
|
+
initialContext: { count: 0, name: '', items: [] },
|
|
220
|
+
registry,
|
|
221
|
+
factDedup: 'none',
|
|
222
|
+
maxFacts: 5,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < 10; i++) {
|
|
226
|
+
engine.updateContext(ctx => ({ ...ctx, count: i }));
|
|
227
|
+
engine.step([{ tag: 'tick', payload: {} }]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
expect(engine.getFacts().length).toBeLessThanOrEqual(5);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ─── 4. checkConstraints convenience ───────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
describe('checkConstraints', () => {
|
|
237
|
+
it('returns empty array when all constraints pass', () => {
|
|
238
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
239
|
+
registry.registerConstraint({
|
|
240
|
+
id: 'positive-count',
|
|
241
|
+
description: 'Count must be positive',
|
|
242
|
+
impl: (state) => state.context.count >= 0,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const engine = createPraxisEngine<TestContext>({
|
|
246
|
+
initialContext: { count: 5, name: '', items: [] },
|
|
247
|
+
registry,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(engine.checkConstraints()).toHaveLength(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('returns violations without running rules', () => {
|
|
254
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
255
|
+
let ruleRan = false;
|
|
256
|
+
registry.registerRule({
|
|
257
|
+
id: 'side-effect-detector',
|
|
258
|
+
description: 'Should NOT run during checkConstraints',
|
|
259
|
+
impl: () => {
|
|
260
|
+
ruleRan = true;
|
|
261
|
+
return [{ tag: 'should-not-appear', payload: {} }];
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
registry.registerConstraint({
|
|
265
|
+
id: 'has-name',
|
|
266
|
+
description: 'Name must not be empty',
|
|
267
|
+
impl: (state) => state.context.name.length > 0 ? true : 'Name is empty',
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const engine = createPraxisEngine<TestContext>({
|
|
271
|
+
initialContext: { count: 0, name: '', items: [] },
|
|
272
|
+
registry,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const violations = engine.checkConstraints();
|
|
276
|
+
expect(violations).toHaveLength(1);
|
|
277
|
+
expect(violations[0].message).toBe('Name is empty');
|
|
278
|
+
expect(ruleRan).toBe(false);
|
|
279
|
+
expect(engine.getFacts().some(f => f.tag === 'should-not-appear')).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ─── 5. Type inference helpers ─────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
describe('type inference helpers', () => {
|
|
286
|
+
it('defineRule preserves eventTypes', () => {
|
|
287
|
+
const rule = defineRule<TestContext>({
|
|
288
|
+
id: 'typed-rule',
|
|
289
|
+
description: 'A typed rule with event filter',
|
|
290
|
+
eventTypes: ['sprint.update'],
|
|
291
|
+
impl: (state, _events) => {
|
|
292
|
+
// TypeScript should infer state.context as TestContext here
|
|
293
|
+
const _count: number = state.context.count;
|
|
294
|
+
return [];
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(rule.id).toBe('typed-rule');
|
|
299
|
+
expect(rule.eventTypes).toEqual(['sprint.update']);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('defineModule bundles rules and constraints with proper types', () => {
|
|
303
|
+
const mod = defineModule<TestContext>({
|
|
304
|
+
rules: [
|
|
305
|
+
{
|
|
306
|
+
id: 'mod-rule',
|
|
307
|
+
description: 'Module rule',
|
|
308
|
+
eventTypes: 'test.event',
|
|
309
|
+
impl: (state) => [{ tag: 'mod-fact', payload: { count: state.context.count } }],
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
constraints: [
|
|
313
|
+
{
|
|
314
|
+
id: 'mod-constraint',
|
|
315
|
+
description: 'Module constraint',
|
|
316
|
+
impl: (state) => state.context.count >= 0,
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
expect(mod.rules).toHaveLength(1);
|
|
322
|
+
expect(mod.constraints).toHaveLength(1);
|
|
323
|
+
expect(mod.rules[0].eventTypes).toBe('test.event');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('defineRule + PraxisRegistry.registerModule round-trips correctly', () => {
|
|
327
|
+
const mod = defineModule<TestContext>({
|
|
328
|
+
rules: [
|
|
329
|
+
{
|
|
330
|
+
id: 'round-trip',
|
|
331
|
+
description: 'Test',
|
|
332
|
+
eventTypes: ['a', 'b'],
|
|
333
|
+
impl: () => [{ tag: 'ok', payload: {} }],
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
constraints: [],
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const registry = new PraxisRegistry<TestContext>();
|
|
340
|
+
registry.registerModule(mod);
|
|
341
|
+
|
|
342
|
+
const engine = createPraxisEngine<TestContext>({
|
|
343
|
+
initialContext: { count: 0, name: '', items: [] },
|
|
344
|
+
registry,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Should NOT fire on unmatched event
|
|
348
|
+
let r = engine.step([{ tag: 'c', payload: {} }]);
|
|
349
|
+
expect(r.state.facts.some(f => f.tag === 'ok')).toBe(false);
|
|
350
|
+
|
|
351
|
+
// Should fire on matched event
|
|
352
|
+
r = engine.step([{ tag: 'a', payload: {} }]);
|
|
353
|
+
expect(r.state.facts.some(f => f.tag === 'ok')).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
});
|