@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
package/src/cli/index.ts
CHANGED
|
@@ -239,6 +239,34 @@ cloudCmd
|
|
|
239
239
|
}
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
+
// MCP server command
|
|
243
|
+
program
|
|
244
|
+
.command('mcp')
|
|
245
|
+
.description('Start the Praxis MCP server (Model Context Protocol) for AI assistant integration')
|
|
246
|
+
.option('--name <name>', 'Server name', '@plures/praxis')
|
|
247
|
+
.option('--version <version>', 'Server version', '1.0.0')
|
|
248
|
+
.action(async (options) => {
|
|
249
|
+
try {
|
|
250
|
+
const { createPraxisMcpServer } = await import('../mcp/server.js');
|
|
251
|
+
const { PraxisRegistry } = await import('../core/rules.js');
|
|
252
|
+
|
|
253
|
+
const registry = new PraxisRegistry({
|
|
254
|
+
compliance: { enabled: false },
|
|
255
|
+
});
|
|
256
|
+
const server = createPraxisMcpServer({
|
|
257
|
+
name: options.name,
|
|
258
|
+
version: options.version,
|
|
259
|
+
initialContext: {},
|
|
260
|
+
registry,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await server.start();
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error('Error starting MCP server:', error);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
242
270
|
// Verify command
|
|
243
271
|
program
|
|
244
272
|
.command('verify <type>')
|
|
@@ -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
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -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
|
|
178
|
-
|
|
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
|
|
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 = [...
|
|
242
|
+
mergedFacts = [...existingFacts, ...newFacts];
|
|
201
243
|
break;
|
|
202
244
|
case 'none':
|
|
203
245
|
default:
|
|
204
|
-
mergedFacts = [...
|
|
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
|
|
516
|
-
|
|
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
|
}
|
package/src/core/protocol.ts
CHANGED
|
@@ -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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|