@plures/praxis 1.2.41 → 1.4.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.
- package/dist/browser/{chunk-BBP2F7TT.js → chunk-MJK3IYTJ.js} +123 -5
- package/dist/browser/{chunk-FCEH7WMH.js → chunk-N63K4KWS.js} +1 -1
- package/dist/browser/{engine-65QDGCAN.js → engine-YIEGSX7U.js} +1 -1
- package/dist/browser/index.d.ts +2 -2
- package/dist/browser/index.js +10 -5
- package/dist/browser/integrations/svelte.d.ts +2 -2
- package/dist/browser/integrations/svelte.js +2 -2
- package/dist/browser/{reactive-engine.svelte-Cqd8Mod2.d.ts → reactive-engine.svelte-DjynI82A.d.ts} +83 -4
- package/dist/node/chunk-2IUFZBH3.js +87 -0
- package/dist/node/{chunk-WZ6B3LZ6.js → chunk-7CSWBDFL.js} +3 -56
- package/dist/node/{chunk-32YFEEML.js → chunk-7M3HV4XR.js} +4 -4
- package/dist/node/{chunk-PTH6MD6P.js → chunk-FWOXU4MM.js} +1 -1
- package/dist/node/{chunk-BBP2F7TT.js → chunk-KMJWAFZV.js} +128 -5
- package/dist/node/chunk-PGVSB6NR.js +59 -0
- package/dist/node/cli/index.cjs +1078 -211
- package/dist/node/cli/index.js +21 -2
- package/dist/node/cloud/index.d.cts +1 -1
- package/dist/node/cloud/index.d.ts +1 -1
- package/dist/node/{engine-7CXQV6RC.js → engine-FEN5IYZ5.js} +1 -1
- package/dist/node/index.cjs +1633 -59
- package/dist/node/index.d.cts +769 -5
- package/dist/node/index.d.ts +769 -5
- package/dist/node/index.js +1375 -45
- package/dist/node/integrations/svelte.cjs +123 -5
- package/dist/node/integrations/svelte.d.cts +3 -3
- package/dist/node/integrations/svelte.d.ts +3 -3
- package/dist/node/integrations/svelte.js +3 -3
- package/dist/node/{protocol-BocKczNv.d.ts → protocol-DcyGMmWY.d.cts} +7 -0
- package/dist/node/{protocol-BocKczNv.d.cts → protocol-DcyGMmWY.d.ts} +7 -0
- package/dist/node/{reactive-engine.svelte-CGe8SpVE.d.cts → reactive-engine.svelte-Cg0Yc2Hs.d.cts} +90 -6
- package/dist/node/{reactive-engine.svelte-D-xTDxT5.d.ts → reactive-engine.svelte-DekxqFu0.d.ts} +90 -6
- package/dist/node/{reverse-W7THPV45.js → reverse-YD3CWIGM.js} +3 -2
- package/dist/node/rules-4DAJ4Z4N.js +7 -0
- package/dist/node/server-SYZPDULV.js +361 -0
- package/dist/node/{validate-EN3M4FUR.js → validate-TQGVIG7G.js} +4 -3
- package/package.json +29 -3
- package/src/__tests__/engine-v2.test.ts +532 -0
- package/src/__tests__/expectations.test.ts +364 -0
- package/src/__tests__/factory.test.ts +426 -0
- package/src/__tests__/mcp-server.test.ts +310 -0
- package/src/__tests__/project.test.ts +396 -0
- package/src/cli/index.ts +28 -0
- package/src/core/completeness.ts +274 -0
- package/src/core/engine.ts +47 -5
- package/src/core/pluresdb/store.ts +9 -3
- package/src/core/protocol.ts +7 -0
- package/src/core/rule-result.ts +130 -0
- package/src/core/rules.ts +12 -5
- package/src/core/ui-rules.ts +340 -0
- package/src/expectations/expectations.ts +471 -0
- package/src/expectations/index.ts +29 -0
- package/src/expectations/types.ts +95 -0
- package/src/factory/factory.ts +634 -0
- package/src/factory/index.ts +27 -0
- package/src/factory/types.ts +64 -0
- package/src/index.ts +84 -0
- package/src/mcp/index.ts +33 -0
- package/src/mcp/server.ts +485 -0
- package/src/mcp/types.ts +161 -0
- package/src/project/index.ts +31 -0
- package/src/project/project.ts +423 -0
- package/src/project/types.ts +87 -0
- package/src/vite/completeness-plugin.ts +72 -0
- /package/dist/node/{chunk-R2PSBPKQ.js → chunk-TEMFJOIH.js} +0 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Praxis MCP Server
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
6
|
+
import { createPraxisMcpServer } from '../mcp/server.js';
|
|
7
|
+
import { PraxisRegistry } from '../core/rules.js';
|
|
8
|
+
import { RuleResult, fact } from '../core/rule-result.js';
|
|
9
|
+
import type { PraxisModule } from '../core/rules.js';
|
|
10
|
+
import type { Contract } from '../decision-ledger/types.js';
|
|
11
|
+
|
|
12
|
+
// ─── Test Fixtures ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
interface TestContext {
|
|
15
|
+
counter: number;
|
|
16
|
+
name: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const incrementContract: Contract = {
|
|
20
|
+
ruleId: 'test/increment',
|
|
21
|
+
behavior: 'Increments counter when increment event fires',
|
|
22
|
+
examples: [
|
|
23
|
+
{ given: 'counter is 5', when: 'increment event fires', then: 'counter.incremented emitted with value 6' },
|
|
24
|
+
],
|
|
25
|
+
invariants: ['Counter must always increase by 1'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const testModule: PraxisModule<TestContext> = {
|
|
29
|
+
rules: [
|
|
30
|
+
{
|
|
31
|
+
id: 'test/increment',
|
|
32
|
+
description: 'Increments the counter',
|
|
33
|
+
eventTypes: 'counter.increment',
|
|
34
|
+
contract: incrementContract,
|
|
35
|
+
impl: (state, events) => {
|
|
36
|
+
const hasIncrement = events.some(e => e.tag === 'counter.increment');
|
|
37
|
+
if (!hasIncrement) return RuleResult.skip('No increment event');
|
|
38
|
+
return RuleResult.emit([
|
|
39
|
+
fact('counter.incremented', { value: state.context.counter + 1 }),
|
|
40
|
+
]);
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'test/greet',
|
|
45
|
+
description: 'Emits greeting when greet event fires',
|
|
46
|
+
eventTypes: 'greet',
|
|
47
|
+
contract: {
|
|
48
|
+
ruleId: 'test/greet',
|
|
49
|
+
behavior: 'Emits greeting fact with name',
|
|
50
|
+
examples: [
|
|
51
|
+
{ given: 'name is Alice', when: 'greet event fires', then: 'greeting emitted for Alice' },
|
|
52
|
+
],
|
|
53
|
+
invariants: ['Greeting must include the name from context'],
|
|
54
|
+
},
|
|
55
|
+
impl: (state, events) => {
|
|
56
|
+
const hasGreet = events.some(e => e.tag === 'greet');
|
|
57
|
+
if (!hasGreet) return RuleResult.skip('No greet event');
|
|
58
|
+
return RuleResult.emit([
|
|
59
|
+
fact('greeting', { message: `Hello, ${state.context.name}!` }),
|
|
60
|
+
]);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
constraints: [
|
|
65
|
+
{
|
|
66
|
+
id: 'test/positive-counter',
|
|
67
|
+
description: 'Counter must be non-negative',
|
|
68
|
+
contract: {
|
|
69
|
+
ruleId: 'test/positive-counter',
|
|
70
|
+
behavior: 'Ensures counter is never negative',
|
|
71
|
+
examples: [
|
|
72
|
+
{ given: 'counter is 0', when: 'checked', then: 'passes' },
|
|
73
|
+
{ given: 'counter is -1', when: 'checked', then: 'fails' },
|
|
74
|
+
],
|
|
75
|
+
invariants: ['Counter >= 0'],
|
|
76
|
+
},
|
|
77
|
+
impl: (state) => {
|
|
78
|
+
if (state.context.counter < 0) return 'Counter must be non-negative';
|
|
79
|
+
return true;
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe('Praxis MCP Server', () => {
|
|
88
|
+
let registry: PraxisRegistry<TestContext>;
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
registry = new PraxisRegistry<TestContext>({
|
|
92
|
+
compliance: { enabled: false },
|
|
93
|
+
});
|
|
94
|
+
registry.registerModule(testModule);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('createPraxisMcpServer', () => {
|
|
98
|
+
it('should create a server with engine and mcpServer', () => {
|
|
99
|
+
const server = createPraxisMcpServer({
|
|
100
|
+
initialContext: { counter: 0, name: 'Test' },
|
|
101
|
+
registry,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(server).toBeDefined();
|
|
105
|
+
expect(server.engine).toBeDefined();
|
|
106
|
+
expect(server.mcpServer).toBeDefined();
|
|
107
|
+
expect(server.start).toBeInstanceOf(Function);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should accept custom name and version', () => {
|
|
111
|
+
const server = createPraxisMcpServer({
|
|
112
|
+
name: 'my-praxis',
|
|
113
|
+
version: '2.0.0',
|
|
114
|
+
initialContext: { counter: 0, name: 'Test' },
|
|
115
|
+
registry,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(server).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('engine operations via MCP server', () => {
|
|
123
|
+
it('should expose a working engine that can step', () => {
|
|
124
|
+
const server = createPraxisMcpServer({
|
|
125
|
+
initialContext: { counter: 5, name: 'Alice' },
|
|
126
|
+
registry,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const result = server.engine.step([{ tag: 'counter.increment', payload: {} }]);
|
|
130
|
+
expect(result.state.facts).toContainEqual(
|
|
131
|
+
expect.objectContaining({ tag: 'counter.incremented', payload: { value: 6 } }),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should get current facts from engine', () => {
|
|
136
|
+
const server = createPraxisMcpServer({
|
|
137
|
+
initialContext: { counter: 0, name: 'Test' },
|
|
138
|
+
registry,
|
|
139
|
+
initialFacts: [{ tag: 'init', payload: { started: true } }],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const facts = server.engine.getFacts();
|
|
143
|
+
expect(facts).toContainEqual(
|
|
144
|
+
expect.objectContaining({ tag: 'init' }),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should handle rule evaluation via engine', () => {
|
|
149
|
+
const server = createPraxisMcpServer({
|
|
150
|
+
initialContext: { counter: 10, name: 'Bob' },
|
|
151
|
+
registry,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const rule = registry.getRule('test/greet');
|
|
155
|
+
expect(rule).toBeDefined();
|
|
156
|
+
|
|
157
|
+
const state = server.engine.getState();
|
|
158
|
+
const events = [{ tag: 'greet', payload: {} }];
|
|
159
|
+
const stateWithEvents = { ...state, events };
|
|
160
|
+
const result = rule!.impl(stateWithEvents as Parameters<typeof rule!.impl>[0], events);
|
|
161
|
+
|
|
162
|
+
expect(result).toBeInstanceOf(RuleResult);
|
|
163
|
+
const rr = result as RuleResult;
|
|
164
|
+
expect(rr.kind).toBe('emit');
|
|
165
|
+
expect(rr.facts).toContainEqual(
|
|
166
|
+
expect.objectContaining({ tag: 'greeting', payload: { message: 'Hello, Bob!' } }),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should skip rule when no matching events', () => {
|
|
171
|
+
const server = createPraxisMcpServer({
|
|
172
|
+
initialContext: { counter: 0, name: 'Test' },
|
|
173
|
+
registry,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const rule = registry.getRule('test/increment');
|
|
177
|
+
const state = server.engine.getState();
|
|
178
|
+
const events = [{ tag: 'unrelated', payload: {} }];
|
|
179
|
+
const stateWithEvents = { ...state, events };
|
|
180
|
+
const result = rule!.impl(stateWithEvents as Parameters<typeof rule!.impl>[0], events);
|
|
181
|
+
|
|
182
|
+
expect(result).toBeInstanceOf(RuleResult);
|
|
183
|
+
expect((result as RuleResult).kind).toBe('skip');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('registry introspection', () => {
|
|
188
|
+
it('should list all rules', () => {
|
|
189
|
+
const rules = registry.getAllRules();
|
|
190
|
+
expect(rules).toHaveLength(2);
|
|
191
|
+
expect(rules.map(r => r.id)).toContain('test/increment');
|
|
192
|
+
expect(rules.map(r => r.id)).toContain('test/greet');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should list all constraints', () => {
|
|
196
|
+
const constraints = registry.getAllConstraints();
|
|
197
|
+
expect(constraints).toHaveLength(1);
|
|
198
|
+
expect(constraints[0].id).toBe('test/positive-counter');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should provide contract details for rules', () => {
|
|
202
|
+
const rule = registry.getRule('test/increment');
|
|
203
|
+
expect(rule?.contract).toBeDefined();
|
|
204
|
+
expect(rule?.contract?.behavior).toContain('Increments counter');
|
|
205
|
+
expect(rule?.contract?.examples).toHaveLength(1);
|
|
206
|
+
expect(rule?.contract?.invariants).toContain('Counter must always increase by 1');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('completeness audit integration', () => {
|
|
211
|
+
it('should run audit via engine', async () => {
|
|
212
|
+
const { auditCompleteness, formatReport } = await import('../core/completeness.js');
|
|
213
|
+
|
|
214
|
+
const manifest = {
|
|
215
|
+
branches: [
|
|
216
|
+
{
|
|
217
|
+
location: 'test.ts:1',
|
|
218
|
+
condition: 'counter > 0',
|
|
219
|
+
kind: 'domain' as const,
|
|
220
|
+
coveredBy: 'test/increment',
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
stateFields: [
|
|
224
|
+
{ source: 'store', field: 'counter', inContext: true, usedByRule: true },
|
|
225
|
+
],
|
|
226
|
+
transitions: [
|
|
227
|
+
{ description: 'increment counter', eventTag: 'counter.increment', location: 'test.ts:5' },
|
|
228
|
+
],
|
|
229
|
+
rulesNeedingContracts: ['test/increment', 'test/greet'],
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const rulesWithContracts = registry.getAllRules()
|
|
233
|
+
.filter(r => r.contract)
|
|
234
|
+
.map(r => r.id);
|
|
235
|
+
|
|
236
|
+
const report = auditCompleteness(
|
|
237
|
+
manifest,
|
|
238
|
+
registry.getRuleIds(),
|
|
239
|
+
registry.getConstraintIds(),
|
|
240
|
+
rulesWithContracts,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(report.score).toBeGreaterThan(0);
|
|
244
|
+
expect(report.rating).toBeDefined();
|
|
245
|
+
expect(formatReport(report)).toContain('Praxis Completeness');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('suggestion engine', () => {
|
|
250
|
+
it('should generate rule suggestions for behavioral gaps', () => {
|
|
251
|
+
// Test the suggestId logic indirectly through server creation
|
|
252
|
+
const server = createPraxisMcpServer({
|
|
253
|
+
initialContext: { counter: 0, name: 'Test' },
|
|
254
|
+
registry,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(server).toBeDefined();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('step and state management', () => {
|
|
262
|
+
it('should step engine and accumulate facts', () => {
|
|
263
|
+
const server = createPraxisMcpServer({
|
|
264
|
+
initialContext: { counter: 5, name: 'Alice' },
|
|
265
|
+
registry,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Step with increment event
|
|
269
|
+
const result1 = server.engine.step([{ tag: 'counter.increment', payload: {} }]);
|
|
270
|
+
expect(result1.state.facts.some(f => f.tag === 'counter.incremented')).toBe(true);
|
|
271
|
+
|
|
272
|
+
// Step with greet event
|
|
273
|
+
const result2 = server.engine.step([{ tag: 'greet', payload: {} }]);
|
|
274
|
+
expect(result2.state.facts.some(f => f.tag === 'greeting')).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should check constraints after step', () => {
|
|
278
|
+
const negativeRegistry = new PraxisRegistry<TestContext>({
|
|
279
|
+
compliance: { enabled: false },
|
|
280
|
+
});
|
|
281
|
+
negativeRegistry.registerModule(testModule);
|
|
282
|
+
|
|
283
|
+
const server = createPraxisMcpServer({
|
|
284
|
+
initialContext: { counter: -1, name: 'Test' },
|
|
285
|
+
registry: negativeRegistry,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const result = server.engine.step([{ tag: 'counter.increment', payload: {} }]);
|
|
289
|
+
// The constraint should report a violation for negative counter
|
|
290
|
+
const violation = result.diagnostics.find(
|
|
291
|
+
d => d.kind === 'constraint-violation' && d.message.includes('non-negative'),
|
|
292
|
+
);
|
|
293
|
+
expect(violation).toBeDefined();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('contract coverage', () => {
|
|
298
|
+
it('should track which rules have contracts', () => {
|
|
299
|
+
const rules = registry.getAllRules();
|
|
300
|
+
const withContracts = rules.filter(r => r.contract);
|
|
301
|
+
expect(withContracts).toHaveLength(2); // both test rules have contracts
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should track which constraints have contracts', () => {
|
|
305
|
+
const constraints = registry.getAllConstraints();
|
|
306
|
+
const withContracts = constraints.filter(c => c.contract);
|
|
307
|
+
expect(withContracts).toHaveLength(1);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Project Logic
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
defineGate,
|
|
8
|
+
semverContract,
|
|
9
|
+
commitFromState,
|
|
10
|
+
branchRules,
|
|
11
|
+
lintGate,
|
|
12
|
+
formatGate,
|
|
13
|
+
expectationGate,
|
|
14
|
+
} from '../project/project.js';
|
|
15
|
+
import { PraxisRegistry } from '../core/rules.js';
|
|
16
|
+
import { LogicEngine } from '../core/engine.js';
|
|
17
|
+
import type { PraxisDiff } from '../project/types.js';
|
|
18
|
+
|
|
19
|
+
// ─── Helper ─────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const emptyDiff: PraxisDiff = {
|
|
22
|
+
rulesAdded: [],
|
|
23
|
+
rulesRemoved: [],
|
|
24
|
+
rulesModified: [],
|
|
25
|
+
contractsAdded: [],
|
|
26
|
+
contractsRemoved: [],
|
|
27
|
+
expectationsAdded: [],
|
|
28
|
+
expectationsRemoved: [],
|
|
29
|
+
gateChanges: [],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ─── defineGate ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe('Project Logic', () => {
|
|
35
|
+
describe('defineGate', () => {
|
|
36
|
+
it('should create a gate module with rule and constraint', () => {
|
|
37
|
+
const mod = defineGate('deploy', {
|
|
38
|
+
expects: ['tests-pass', 'lint-clean'],
|
|
39
|
+
onSatisfied: 'deploy-allowed',
|
|
40
|
+
onViolation: 'deploy-blocked',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(mod.rules).toHaveLength(1);
|
|
44
|
+
expect(mod.constraints).toHaveLength(1);
|
|
45
|
+
expect(mod.rules![0].id).toBe('gate/deploy');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should open gate when all expectations satisfied', () => {
|
|
49
|
+
const mod = defineGate('deploy', {
|
|
50
|
+
expects: ['tests-pass', 'lint-clean'],
|
|
51
|
+
});
|
|
52
|
+
const registry = new PraxisRegistry({ compliance: { enabled: false } });
|
|
53
|
+
registry.registerModule(mod);
|
|
54
|
+
|
|
55
|
+
const engine = new LogicEngine({
|
|
56
|
+
initialContext: {
|
|
57
|
+
expectations: { 'tests-pass': true, 'lint-clean': true },
|
|
58
|
+
},
|
|
59
|
+
registry,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = engine.step([{ tag: 'gate.check', payload: {} }]);
|
|
63
|
+
const status = result.state.facts.find(f => f.tag === 'gate.deploy.status');
|
|
64
|
+
expect(status).toBeDefined();
|
|
65
|
+
expect((status!.payload as { status: string }).status).toBe('open');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should block gate when expectations not met', () => {
|
|
69
|
+
const mod = defineGate('deploy', {
|
|
70
|
+
expects: ['tests-pass', 'lint-clean'],
|
|
71
|
+
onViolation: 'deploy-blocked',
|
|
72
|
+
});
|
|
73
|
+
const registry = new PraxisRegistry({ compliance: { enabled: false } });
|
|
74
|
+
registry.registerModule(mod);
|
|
75
|
+
|
|
76
|
+
const engine = new LogicEngine({
|
|
77
|
+
initialContext: {
|
|
78
|
+
expectations: { 'tests-pass': true, 'lint-clean': false },
|
|
79
|
+
},
|
|
80
|
+
registry,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const result = engine.step([{ tag: 'gate.check', payload: {} }]);
|
|
84
|
+
const status = result.state.facts.find(f => f.tag === 'gate.deploy.status');
|
|
85
|
+
expect((status!.payload as { status: string }).status).toBe('blocked');
|
|
86
|
+
expect((status!.payload as { unsatisfied: string[] }).unsatisfied).toContain('lint-clean');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should emit action fact on satisfied', () => {
|
|
90
|
+
const mod = defineGate('deploy', {
|
|
91
|
+
expects: ['ready'],
|
|
92
|
+
onSatisfied: 'go-deploy',
|
|
93
|
+
});
|
|
94
|
+
const registry = new PraxisRegistry({ compliance: { enabled: false } });
|
|
95
|
+
registry.registerModule(mod);
|
|
96
|
+
|
|
97
|
+
const engine = new LogicEngine({
|
|
98
|
+
initialContext: { expectations: { ready: true } },
|
|
99
|
+
registry,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const result = engine.step([{ tag: 'gate.deploy.check', payload: {} }]);
|
|
103
|
+
const action = result.state.facts.find(f => f.tag === 'gate.deploy.action');
|
|
104
|
+
expect(action).toBeDefined();
|
|
105
|
+
expect((action!.payload as { action: string }).action).toBe('go-deploy');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should emit action fact on violation', () => {
|
|
109
|
+
const mod = defineGate('deploy', {
|
|
110
|
+
expects: ['ready'],
|
|
111
|
+
onViolation: 'block-deploy',
|
|
112
|
+
});
|
|
113
|
+
const registry = new PraxisRegistry({ compliance: { enabled: false } });
|
|
114
|
+
registry.registerModule(mod);
|
|
115
|
+
|
|
116
|
+
const engine = new LogicEngine({
|
|
117
|
+
initialContext: { expectations: { ready: false } },
|
|
118
|
+
registry,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const result = engine.step([{ tag: 'gate.check', payload: {} }]);
|
|
122
|
+
const action = result.state.facts.find(f => f.tag === 'gate.deploy.action');
|
|
123
|
+
expect((action!.payload as { action: string }).action).toBe('block-deploy');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should include contract with invariants', () => {
|
|
127
|
+
const mod = defineGate('test', { expects: ['a', 'b'] });
|
|
128
|
+
expect(mod.rules![0].contract).toBeDefined();
|
|
129
|
+
expect(mod.rules![0].contract!.invariants.length).toBeGreaterThan(0);
|
|
130
|
+
expect(mod.constraints![0].contract).toBeDefined();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ─── Predefined Gates ──────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
describe('predefined gates', () => {
|
|
137
|
+
it('lintGate creates a lint gate', () => {
|
|
138
|
+
const mod = lintGate();
|
|
139
|
+
expect(mod.rules![0].id).toBe('gate/lint');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('formatGate creates a format gate', () => {
|
|
143
|
+
const mod = formatGate();
|
|
144
|
+
expect(mod.rules![0].id).toBe('gate/format');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('expectationGate creates an expectations gate', () => {
|
|
148
|
+
const mod = expectationGate();
|
|
149
|
+
expect(mod.rules![0].id).toBe('gate/expectations');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('predefined gates support additionalExpects', () => {
|
|
153
|
+
const mod = lintGate({ additionalExpects: ['custom-check'] });
|
|
154
|
+
expect(mod.rules![0].description).toContain('custom-check');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ─── semverContract ─────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe('semverContract', () => {
|
|
161
|
+
it('should create a semver check rule', () => {
|
|
162
|
+
const mod = semverContract({
|
|
163
|
+
sources: ['package.json', 'version.ts'],
|
|
164
|
+
invariants: ['All must match'],
|
|
165
|
+
});
|
|
166
|
+
expect(mod.rules![0].id).toBe('project/semver-check');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should emit consistent when versions match', () => {
|
|
170
|
+
const mod = semverContract({
|
|
171
|
+
sources: ['package.json', 'version.ts'],
|
|
172
|
+
invariants: [],
|
|
173
|
+
});
|
|
174
|
+
const registry = new PraxisRegistry({ compliance: { enabled: false } });
|
|
175
|
+
registry.registerModule(mod);
|
|
176
|
+
|
|
177
|
+
const engine = new LogicEngine({ initialContext: {}, registry });
|
|
178
|
+
|
|
179
|
+
const result = engine.step([{
|
|
180
|
+
tag: 'project.version-check',
|
|
181
|
+
payload: {
|
|
182
|
+
versions: { 'package.json': '1.2.3', 'version.ts': '1.2.3' },
|
|
183
|
+
},
|
|
184
|
+
}]);
|
|
185
|
+
|
|
186
|
+
const consistent = result.state.facts.find(f => f.tag === 'semver.consistent');
|
|
187
|
+
expect(consistent).toBeDefined();
|
|
188
|
+
expect((consistent!.payload as { version: string }).version).toBe('1.2.3');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should emit inconsistent when versions differ', () => {
|
|
192
|
+
const mod = semverContract({
|
|
193
|
+
sources: ['package.json', 'version.ts'],
|
|
194
|
+
invariants: [],
|
|
195
|
+
});
|
|
196
|
+
const registry = new PraxisRegistry({ compliance: { enabled: false } });
|
|
197
|
+
registry.registerModule(mod);
|
|
198
|
+
|
|
199
|
+
const engine = new LogicEngine({ initialContext: {}, registry });
|
|
200
|
+
|
|
201
|
+
const result = engine.step([{
|
|
202
|
+
tag: 'project.version-check',
|
|
203
|
+
payload: {
|
|
204
|
+
versions: { 'package.json': '1.2.3', 'version.ts': '1.2.0' },
|
|
205
|
+
},
|
|
206
|
+
}]);
|
|
207
|
+
|
|
208
|
+
const inconsistent = result.state.facts.find(f => f.tag === 'semver.inconsistent');
|
|
209
|
+
expect(inconsistent).toBeDefined();
|
|
210
|
+
expect((inconsistent!.payload as { consistent: boolean }).consistent).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ─── commitFromState ──────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
describe('commitFromState', () => {
|
|
217
|
+
it('should generate feat message for added rules', () => {
|
|
218
|
+
const msg = commitFromState({
|
|
219
|
+
...emptyDiff,
|
|
220
|
+
rulesAdded: ['auth/login', 'auth/logout'],
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(msg).toContain('feat(auth)');
|
|
224
|
+
expect(msg).toContain('auth/login');
|
|
225
|
+
expect(msg).toContain('auth/logout');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should generate refactor message for removed rules', () => {
|
|
229
|
+
const msg = commitFromState({
|
|
230
|
+
...emptyDiff,
|
|
231
|
+
rulesRemoved: ['old/deprecated-rule'],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(msg).toContain('refactor');
|
|
235
|
+
expect(msg).toContain('remove');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should generate refactor message for modified rules', () => {
|
|
239
|
+
const msg = commitFromState({
|
|
240
|
+
...emptyDiff,
|
|
241
|
+
rulesModified: ['auth/login'],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(msg).toContain('refactor(auth)');
|
|
245
|
+
expect(msg).toContain('update');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should generate feat message for contract additions', () => {
|
|
249
|
+
const msg = commitFromState({
|
|
250
|
+
...emptyDiff,
|
|
251
|
+
contractsAdded: ['auth/login'],
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(msg).toContain('feat(contracts)');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should generate feat message for expectation additions', () => {
|
|
258
|
+
const msg = commitFromState({
|
|
259
|
+
...emptyDiff,
|
|
260
|
+
expectationsAdded: ['toast-behavior'],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(msg).toContain('feat(expectations)');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should handle gate changes', () => {
|
|
267
|
+
const msg = commitFromState({
|
|
268
|
+
...emptyDiff,
|
|
269
|
+
gateChanges: [{ gate: 'deploy', from: 'blocked', to: 'open' }],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(msg).toContain('gate');
|
|
273
|
+
expect(msg).toContain('deploy');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should include body with details', () => {
|
|
277
|
+
const msg = commitFromState({
|
|
278
|
+
...emptyDiff,
|
|
279
|
+
rulesAdded: ['ui/toast'],
|
|
280
|
+
contractsAdded: ['ui/toast'],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(msg).toContain('\n\n');
|
|
284
|
+
expect(msg).toContain('Rules added: ui/toast');
|
|
285
|
+
expect(msg).toContain('Contracts added: ui/toast');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should truncate long rule lists', () => {
|
|
289
|
+
const msg = commitFromState({
|
|
290
|
+
...emptyDiff,
|
|
291
|
+
rulesAdded: ['a/1', 'a/2', 'a/3', 'a/4', 'a/5'],
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(msg).toContain('+3 more');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should handle empty diff', () => {
|
|
298
|
+
const msg = commitFromState(emptyDiff);
|
|
299
|
+
expect(msg).toContain('chore');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should infer scope from common prefix', () => {
|
|
303
|
+
const msg = commitFromState({
|
|
304
|
+
...emptyDiff,
|
|
305
|
+
rulesAdded: ['ui/toast', 'ui/modal'],
|
|
306
|
+
});
|
|
307
|
+
expect(msg).toContain('feat(ui)');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should use "rules" scope for mixed prefixes', () => {
|
|
311
|
+
const msg = commitFromState({
|
|
312
|
+
...emptyDiff,
|
|
313
|
+
rulesAdded: ['ui/toast', 'auth/login'],
|
|
314
|
+
});
|
|
315
|
+
expect(msg).toContain('feat(rules)');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ─── branchRules ──────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
describe('branchRules', () => {
|
|
322
|
+
it('should create a branch check rule', () => {
|
|
323
|
+
const mod = branchRules({
|
|
324
|
+
naming: 'feat/{name}',
|
|
325
|
+
mergeConditions: ['tests-pass'],
|
|
326
|
+
});
|
|
327
|
+
expect(mod.rules![0].id).toBe('project/branch-check');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should validate correct branch name', () => {
|
|
331
|
+
const mod = branchRules({
|
|
332
|
+
naming: 'feat/{name}',
|
|
333
|
+
mergeConditions: ['tests-pass'],
|
|
334
|
+
});
|
|
335
|
+
const registry = new PraxisRegistry({ compliance: { enabled: false } });
|
|
336
|
+
registry.registerModule(mod);
|
|
337
|
+
const engine = new LogicEngine({ initialContext: {}, registry });
|
|
338
|
+
|
|
339
|
+
const result = engine.step([{
|
|
340
|
+
tag: 'project.branch-check',
|
|
341
|
+
payload: {
|
|
342
|
+
branch: 'feat/my-feature',
|
|
343
|
+
conditions: { 'tests-pass': true },
|
|
344
|
+
},
|
|
345
|
+
}]);
|
|
346
|
+
|
|
347
|
+
const valid = result.state.facts.find(f => f.tag === 'branch.valid');
|
|
348
|
+
expect(valid).toBeDefined();
|
|
349
|
+
expect((valid!.payload as { mergeReady: boolean }).mergeReady).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should reject invalid branch name', () => {
|
|
353
|
+
const mod = branchRules({
|
|
354
|
+
naming: 'feat/{name}',
|
|
355
|
+
mergeConditions: ['tests-pass'],
|
|
356
|
+
});
|
|
357
|
+
const registry = new PraxisRegistry({ compliance: { enabled: false } });
|
|
358
|
+
registry.registerModule(mod);
|
|
359
|
+
const engine = new LogicEngine({ initialContext: {}, registry });
|
|
360
|
+
|
|
361
|
+
const result = engine.step([{
|
|
362
|
+
tag: 'project.branch-check',
|
|
363
|
+
payload: {
|
|
364
|
+
branch: 'random-branch',
|
|
365
|
+
conditions: { 'tests-pass': true },
|
|
366
|
+
},
|
|
367
|
+
}]);
|
|
368
|
+
|
|
369
|
+
const invalid = result.state.facts.find(f => f.tag === 'branch.invalid');
|
|
370
|
+
expect(invalid).toBeDefined();
|
|
371
|
+
expect((invalid!.payload as { reasons: string[] }).reasons[0]).toContain('pattern');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should reject when merge conditions not met', () => {
|
|
375
|
+
const mod = branchRules({
|
|
376
|
+
naming: 'feat/{name}',
|
|
377
|
+
mergeConditions: ['tests-pass', 'review-approved'],
|
|
378
|
+
});
|
|
379
|
+
const registry = new PraxisRegistry({ compliance: { enabled: false } });
|
|
380
|
+
registry.registerModule(mod);
|
|
381
|
+
const engine = new LogicEngine({ initialContext: {}, registry });
|
|
382
|
+
|
|
383
|
+
const result = engine.step([{
|
|
384
|
+
tag: 'project.branch-check',
|
|
385
|
+
payload: {
|
|
386
|
+
branch: 'feat/my-feature',
|
|
387
|
+
conditions: { 'tests-pass': true, 'review-approved': false },
|
|
388
|
+
},
|
|
389
|
+
}]);
|
|
390
|
+
|
|
391
|
+
const invalid = result.state.facts.find(f => f.tag === 'branch.invalid');
|
|
392
|
+
expect(invalid).toBeDefined();
|
|
393
|
+
expect((invalid!.payload as { reasons: string[] }).reasons[0]).toContain('review-approved');
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
});
|