principles-disciple 1.40.0 → 1.41.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/esbuild.config.js +32 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/scripts/compile-principles.mjs +94 -0
- package/scripts/sync-plugin.mjs +96 -281
- package/src/core/principle-compiler/code-validator.ts +120 -0
- package/src/core/principle-compiler/compiler.ts +242 -0
- package/src/core/principle-compiler/index.ts +10 -0
- package/src/core/principle-compiler/ledger-registrar.ts +107 -0
- package/src/core/principle-compiler/template-generator.ts +108 -0
- package/src/core/reflection/reflection-context.ts +228 -0
- package/tests/core/code-validator.test.ts +197 -0
- package/tests/core/ledger-registrar.test.ts +232 -0
- package/tests/core/principle-compiler.test.ts +348 -0
- package/tests/core/reflection-context.test.ts +356 -0
- package/tests/core/template-generator.test.ts +101 -0
- package/tests/integration/principle-compiler-e2e.test.ts +335 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReflectionContextCollector — Unified Pipeline Input
|
|
3
|
+
* ====================================================
|
|
4
|
+
*
|
|
5
|
+
* PURPOSE: Collect all grounding context for a principle into a single
|
|
6
|
+
* ReflectionContext object. This is the input to the nocturnal reflection
|
|
7
|
+
* pipeline: principle + painEvents + sessionSnapshot + lineage.
|
|
8
|
+
*
|
|
9
|
+
* DESIGN DECISIONS:
|
|
10
|
+
* - If a principle has no derivedFromPainIds, collect() returns null
|
|
11
|
+
* (nothing to ground code on).
|
|
12
|
+
* - painId -> sessionId resolution is a known gap. For now, we attempt
|
|
13
|
+
* best-effort lookup but return what we have with sessionSnapshot = null
|
|
14
|
+
* if we can't resolve.
|
|
15
|
+
*
|
|
16
|
+
* REUSES:
|
|
17
|
+
* - principle-tree-ledger: loadLedger() for principle lookup
|
|
18
|
+
* - nocturnal-trajectory-extractor: for session snapshots
|
|
19
|
+
* - trajectory: TrajectoryDatabase for pain event queries
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { loadLedger, type LedgerPrinciple } from '../principle-tree-ledger.js';
|
|
23
|
+
import {
|
|
24
|
+
NocturnalTrajectoryExtractor,
|
|
25
|
+
type NocturnalPainEvent,
|
|
26
|
+
type NocturnalSessionSnapshot,
|
|
27
|
+
} from '../nocturnal-trajectory-extractor.js';
|
|
28
|
+
import type { TrajectoryDatabase } from '../trajectory.js';
|
|
29
|
+
import type { Principle } from '../../types/principle-tree-schema.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Unified reflection context for the nocturnal pipeline.
|
|
37
|
+
*/
|
|
38
|
+
export interface ReflectionContext {
|
|
39
|
+
/** The principle being reflected upon */
|
|
40
|
+
principle: Principle;
|
|
41
|
+
/** Pain events associated with this principle (via derivedFromPainIds) */
|
|
42
|
+
painEvents: NocturnalPainEvent[];
|
|
43
|
+
/** Session snapshot if resolvable, null otherwise */
|
|
44
|
+
sessionSnapshot: NocturnalSessionSnapshot | null;
|
|
45
|
+
/** Lineage metadata connecting principle to source pain signals */
|
|
46
|
+
lineage: {
|
|
47
|
+
sourcePainIds: string[];
|
|
48
|
+
sessionId: string | null;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Collector
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Collects ReflectionContext for principles by joining ledger data with
|
|
58
|
+
* trajectory data.
|
|
59
|
+
*/
|
|
60
|
+
export class ReflectionContextCollector {
|
|
61
|
+
private readonly stateDir: string;
|
|
62
|
+
private readonly trajectory: TrajectoryDatabase;
|
|
63
|
+
|
|
64
|
+
constructor(stateDir: string, trajectory: TrajectoryDatabase) {
|
|
65
|
+
this.stateDir = stateDir;
|
|
66
|
+
this.trajectory = trajectory;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Collect full reflection context for a single principle.
|
|
71
|
+
*
|
|
72
|
+
* Returns null if:
|
|
73
|
+
* - The principle is not found in the ledger
|
|
74
|
+
* - The principle has no derivedFromPainIds (nothing to ground on)
|
|
75
|
+
*/
|
|
76
|
+
collect(principleId: string): ReflectionContext | null {
|
|
77
|
+
const ledger = loadLedger(this.stateDir);
|
|
78
|
+
const principle = ledger.tree.principles[principleId];
|
|
79
|
+
|
|
80
|
+
if (!principle) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!principle.derivedFromPainIds || principle.derivedFromPainIds.length === 0) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return this.buildContext(principle);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Collect reflection contexts for multiple principles, optionally filtered.
|
|
93
|
+
*
|
|
94
|
+
* Skips principles without derivedFromPainIds.
|
|
95
|
+
*/
|
|
96
|
+
collectBatch(filter?: { status?: string }): ReflectionContext[] {
|
|
97
|
+
const ledger = loadLedger(this.stateDir);
|
|
98
|
+
const principles = Object.values(ledger.tree.principles);
|
|
99
|
+
|
|
100
|
+
const results: ReflectionContext[] = [];
|
|
101
|
+
|
|
102
|
+
for (const principle of principles) {
|
|
103
|
+
// Apply status filter if provided
|
|
104
|
+
if (filter?.status && principle.status !== filter.status) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Skip principles without pain grounding
|
|
109
|
+
if (!principle.derivedFromPainIds || principle.derivedFromPainIds.length === 0) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ctx = this.buildContext(principle);
|
|
114
|
+
if (ctx) {
|
|
115
|
+
results.push(ctx);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -----------------------------------------------------------------------
|
|
123
|
+
// Private helpers
|
|
124
|
+
// -----------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a ReflectionContext from a principle.
|
|
128
|
+
*
|
|
129
|
+
* Attempts to resolve painIds to sessions via best-effort lookup.
|
|
130
|
+
* Since painId -> sessionId mapping is a known gap, we may return
|
|
131
|
+
* empty painEvents and null sessionSnapshot.
|
|
132
|
+
*/
|
|
133
|
+
private buildContext(principle: LedgerPrinciple): ReflectionContext {
|
|
134
|
+
const sourcePainIds = principle.derivedFromPainIds;
|
|
135
|
+
|
|
136
|
+
// Best-effort: try to find a session containing pain events related to this principle.
|
|
137
|
+
// The pain_events table uses auto-increment IDs, not the string painIds stored in
|
|
138
|
+
// derivedFromPainIds. This is the known gap — for now we attempt session resolution
|
|
139
|
+
// but gracefully handle the case where we can't match.
|
|
140
|
+
const { painEvents, sessionId } = this.resolvePainEvents(sourcePainIds);
|
|
141
|
+
|
|
142
|
+
// If we found a session, get the snapshot
|
|
143
|
+
let sessionSnapshot: NocturnalSessionSnapshot | null = null;
|
|
144
|
+
if (sessionId) {
|
|
145
|
+
const extractor = new NocturnalTrajectoryExtractor(this.trajectory);
|
|
146
|
+
sessionSnapshot = extractor.getNocturnalSessionSnapshot(sessionId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
principle,
|
|
151
|
+
painEvents,
|
|
152
|
+
sessionSnapshot,
|
|
153
|
+
lineage: {
|
|
154
|
+
sourcePainIds,
|
|
155
|
+
sessionId,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Attempt to resolve painIds to actual pain events and a session.
|
|
162
|
+
*
|
|
163
|
+
* Two-phase strategy:
|
|
164
|
+
* 1. Exact ID match: sourcePainIds are stringified pain_events row IDs.
|
|
165
|
+
* If any match String(pe.id) exactly, use those and stop.
|
|
166
|
+
* 2. Heuristic fallback: substring match on reason/origin fields.
|
|
167
|
+
* Only used when no exact matches are found.
|
|
168
|
+
*/
|
|
169
|
+
private resolvePainEvents(sourcePainIds: string[]): {
|
|
170
|
+
painEvents: NocturnalPainEvent[];
|
|
171
|
+
sessionId: string | null;
|
|
172
|
+
} {
|
|
173
|
+
const sessions = this.trajectory.listRecentSessions({ limit: 100 });
|
|
174
|
+
const sourcePainIdSet = new Set(sourcePainIds);
|
|
175
|
+
|
|
176
|
+
const exactMatches: NocturnalPainEvent[] = [];
|
|
177
|
+
const heuristicMatches: NocturnalPainEvent[] = [];
|
|
178
|
+
let exactSessionId: string | null = null;
|
|
179
|
+
let heuristicSessionId: string | null = null;
|
|
180
|
+
|
|
181
|
+
for (const session of sessions) {
|
|
182
|
+
const sessionPainEvents = this.trajectory.listPainEventsForSession(session.sessionId);
|
|
183
|
+
|
|
184
|
+
for (const pe of sessionPainEvents) {
|
|
185
|
+
// Phase 1: exact ID match
|
|
186
|
+
if (sourcePainIdSet.has(String(pe.id))) {
|
|
187
|
+
exactMatches.push({
|
|
188
|
+
source: pe.source,
|
|
189
|
+
score: pe.score,
|
|
190
|
+
severity: pe.severity,
|
|
191
|
+
reason: pe.reason,
|
|
192
|
+
createdAt: pe.createdAt,
|
|
193
|
+
});
|
|
194
|
+
if (!exactSessionId) {
|
|
195
|
+
exactSessionId = session.sessionId;
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Phase 2: heuristic substring match on reason/origin only
|
|
201
|
+
const peText = [pe.reason, pe.origin].filter(Boolean);
|
|
202
|
+
const isMatch = sourcePainIds.some((painId) =>
|
|
203
|
+
peText.some((field) => field?.includes(painId)),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (isMatch) {
|
|
207
|
+
heuristicMatches.push({
|
|
208
|
+
source: pe.source,
|
|
209
|
+
score: pe.score,
|
|
210
|
+
severity: pe.severity,
|
|
211
|
+
reason: pe.reason,
|
|
212
|
+
createdAt: pe.createdAt,
|
|
213
|
+
});
|
|
214
|
+
if (!heuristicSessionId) {
|
|
215
|
+
heuristicSessionId = session.sessionId;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Prefer exact matches over heuristic matches
|
|
222
|
+
if (exactMatches.length > 0) {
|
|
223
|
+
return { painEvents: exactMatches, sessionId: exactSessionId };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { painEvents: heuristicMatches, sessionId: heuristicSessionId };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { validateGeneratedCode } from '../../src/core/principle-compiler/code-validator.js';
|
|
3
|
+
|
|
4
|
+
describe('validateGeneratedCode', () => {
|
|
5
|
+
// --- Valid code ---
|
|
6
|
+
|
|
7
|
+
it('accepts valid rule implementation with evaluate and meta', () => {
|
|
8
|
+
const code = `
|
|
9
|
+
export const meta = {
|
|
10
|
+
name: 'test-rule',
|
|
11
|
+
version: '1.0.0',
|
|
12
|
+
ruleId: 'R-001',
|
|
13
|
+
coversCondition: 'test condition'
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function evaluate(input) {
|
|
17
|
+
return {
|
|
18
|
+
matched: input.action.toolName === 'bash',
|
|
19
|
+
reason: 'checked toolName'
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
const result = validateGeneratedCode(code);
|
|
24
|
+
expect(result.valid).toBe(true);
|
|
25
|
+
expect(result.errors).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('accepts evaluate that returns matched: false', () => {
|
|
29
|
+
const code = `
|
|
30
|
+
export const meta = { name: 'never-match', version: '1.0.0', ruleId: 'R-002', coversCondition: 'none' };
|
|
31
|
+
|
|
32
|
+
export function evaluate(_input) {
|
|
33
|
+
return { matched: false, reason: 'never matches' };
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
const result = validateGeneratedCode(code);
|
|
37
|
+
expect(result.valid).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// --- Syntax check ---
|
|
41
|
+
|
|
42
|
+
it('rejects code with syntax errors', () => {
|
|
43
|
+
const code = `function broken( {`;
|
|
44
|
+
const result = validateGeneratedCode(code);
|
|
45
|
+
expect(result.valid).toBe(false);
|
|
46
|
+
expect(result.errors.some((e) => /syntax/i.test(e))).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// --- Forbidden patterns ---
|
|
50
|
+
|
|
51
|
+
it('rejects code containing require(', () => {
|
|
52
|
+
const code = `
|
|
53
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
54
|
+
export function evaluate() { const fs = require('fs'); return { matched: false, reason: '' }; }
|
|
55
|
+
`;
|
|
56
|
+
const result = validateGeneratedCode(code);
|
|
57
|
+
expect(result.valid).toBe(false);
|
|
58
|
+
expect(result.errors.some((e) => /require/i.test(e))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects code containing import statement', () => {
|
|
62
|
+
const code = `import { something } from 'module';
|
|
63
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
64
|
+
export function evaluate() { return { matched: false, reason: '' }; }
|
|
65
|
+
`;
|
|
66
|
+
const result = validateGeneratedCode(code);
|
|
67
|
+
expect(result.valid).toBe(false);
|
|
68
|
+
expect(result.errors.some((e) => /import/i.test(e))).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects code containing fetch(', () => {
|
|
72
|
+
const code = `
|
|
73
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
74
|
+
export function evaluate() { fetch('http://evil.com'); return { matched: false, reason: '' }; }
|
|
75
|
+
`;
|
|
76
|
+
const result = validateGeneratedCode(code);
|
|
77
|
+
expect(result.valid).toBe(false);
|
|
78
|
+
expect(result.errors.some((e) => /fetch/i.test(e))).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects code containing eval(', () => {
|
|
82
|
+
const code = `
|
|
83
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
84
|
+
export function evaluate() { eval('42'); return { matched: false, reason: '' }; }
|
|
85
|
+
`;
|
|
86
|
+
const result = validateGeneratedCode(code);
|
|
87
|
+
expect(result.valid).toBe(false);
|
|
88
|
+
expect(result.errors.some((e) => /eval/i.test(e))).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects code containing Function(', () => {
|
|
92
|
+
const code = `
|
|
93
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
94
|
+
export function evaluate() { new Function('return 1')(); return { matched: false, reason: '' }; }
|
|
95
|
+
`;
|
|
96
|
+
const result = validateGeneratedCode(code);
|
|
97
|
+
expect(result.valid).toBe(false);
|
|
98
|
+
expect(result.errors.some((e) => /Function/i.test(e))).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rejects code containing process', () => {
|
|
102
|
+
const code = `
|
|
103
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
104
|
+
export function evaluate() { const x = process.env; return { matched: false, reason: '' }; }
|
|
105
|
+
`;
|
|
106
|
+
const result = validateGeneratedCode(code);
|
|
107
|
+
expect(result.valid).toBe(false);
|
|
108
|
+
expect(result.errors.some((e) => /process/i.test(e))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects code containing globalThis', () => {
|
|
112
|
+
const code = `
|
|
113
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
114
|
+
export function evaluate() { globalThis.foo = 'bar'; return { matched: false, reason: '' }; }
|
|
115
|
+
`;
|
|
116
|
+
const result = validateGeneratedCode(code);
|
|
117
|
+
expect(result.valid).toBe(false);
|
|
118
|
+
expect(result.errors.some((e) => /globalThis/i.test(e))).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('reports all forbidden patterns at once', () => {
|
|
122
|
+
const code = `
|
|
123
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
124
|
+
export function evaluate() {
|
|
125
|
+
require('fs');
|
|
126
|
+
fetch('http://x');
|
|
127
|
+
eval('1');
|
|
128
|
+
process.env;
|
|
129
|
+
return { matched: false, reason: '' };
|
|
130
|
+
}
|
|
131
|
+
`;
|
|
132
|
+
const result = validateGeneratedCode(code);
|
|
133
|
+
expect(result.valid).toBe(false);
|
|
134
|
+
expect(result.errors.length).toBeGreaterThanOrEqual(4);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// --- Export check ---
|
|
138
|
+
|
|
139
|
+
it('rejects code that does not export evaluate', () => {
|
|
140
|
+
const code = `
|
|
141
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
142
|
+
// no evaluate function at all
|
|
143
|
+
`;
|
|
144
|
+
const result = validateGeneratedCode(code);
|
|
145
|
+
expect(result.valid).toBe(false);
|
|
146
|
+
expect(result.errors.some((e) => /evaluate/i.test(e))).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('rejects code that does not export meta', () => {
|
|
150
|
+
const code = `
|
|
151
|
+
// no meta at all
|
|
152
|
+
export function evaluate() { return { matched: false, reason: '' }; }
|
|
153
|
+
`;
|
|
154
|
+
const result = validateGeneratedCode(code);
|
|
155
|
+
expect(result.valid).toBe(false);
|
|
156
|
+
expect(result.errors.some((e) => /meta/i.test(e))).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- Return shape check ---
|
|
160
|
+
|
|
161
|
+
it('rejects evaluate that returns object without matched', () => {
|
|
162
|
+
const code = `
|
|
163
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
164
|
+
export function evaluate() { return { reason: 'no matched field' }; }
|
|
165
|
+
`;
|
|
166
|
+
const result = validateGeneratedCode(code);
|
|
167
|
+
expect(result.valid).toBe(false);
|
|
168
|
+
expect(result.errors.some((e) => /matched/i.test(e))).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('accepts evaluate even when it throws on mock input', () => {
|
|
172
|
+
// evaluate throws because it accesses a nested property that doesn't exist in mock
|
|
173
|
+
const code = `
|
|
174
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
175
|
+
export function evaluate(input) {
|
|
176
|
+
if (input.action.toolName === 'bash') {
|
|
177
|
+
return { matched: true, reason: 'ok' };
|
|
178
|
+
}
|
|
179
|
+
throw new Error('unexpected input');
|
|
180
|
+
}
|
|
181
|
+
`;
|
|
182
|
+
// The mock input has toolName: 'bash', so it returns successfully
|
|
183
|
+
const result = validateGeneratedCode(code);
|
|
184
|
+
expect(result.valid).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('accepts evaluate that throws as long as it has correct shape when it returns', () => {
|
|
188
|
+
const code = `
|
|
189
|
+
export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
|
|
190
|
+
export function evaluate(_input) {
|
|
191
|
+
return { matched: true, reason: 'ok' };
|
|
192
|
+
}
|
|
193
|
+
`;
|
|
194
|
+
const result = validateGeneratedCode(code);
|
|
195
|
+
expect(result.valid).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger Registrar Tests (Task 4)
|
|
3
|
+
*
|
|
4
|
+
* TDD test suite for registerCompiledRule — creates a gate rule + code
|
|
5
|
+
* implementation in the principle tree ledger for a compiled principle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
11
|
+
import { safeRmDir } from '../test-utils.js';
|
|
12
|
+
import {
|
|
13
|
+
loadLedger,
|
|
14
|
+
saveLedger,
|
|
15
|
+
type HybridLedgerStore,
|
|
16
|
+
type LedgerPrinciple,
|
|
17
|
+
type LedgerRule,
|
|
18
|
+
} from '../../src/core/principle-tree-ledger.js';
|
|
19
|
+
import { registerCompiledRule, type RegisterInput, type RegisterResult } from '../../src/core/principle-compiler/ledger-registrar.js';
|
|
20
|
+
|
|
21
|
+
describe('ledger-registrar', () => {
|
|
22
|
+
let tempDir: string;
|
|
23
|
+
let stateDir: string;
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
tempDir = fs.mkdtempSync(path.join(process.env.TMP || '/tmp', 'pd-registrar-'));
|
|
27
|
+
stateDir = path.join(tempDir, '.state');
|
|
28
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
safeRmDir(tempDir);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
const stateFile = path.join(stateDir, 'principle_training_state.json');
|
|
37
|
+
if (fs.existsSync(stateFile)) {
|
|
38
|
+
fs.unlinkSync(stateFile);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Helper: Create a minimal ledger principle
|
|
43
|
+
function createLedgerPrinciple(principleId: string, overrides: Partial<LedgerPrinciple> = {}): LedgerPrinciple {
|
|
44
|
+
return {
|
|
45
|
+
id: principleId,
|
|
46
|
+
version: 1,
|
|
47
|
+
text: `Test principle ${principleId}`,
|
|
48
|
+
triggerPattern: 'test',
|
|
49
|
+
action: 'test action',
|
|
50
|
+
status: 'active',
|
|
51
|
+
priority: 'P1',
|
|
52
|
+
scope: 'general',
|
|
53
|
+
evaluability: 'deterministic',
|
|
54
|
+
valueScore: 0,
|
|
55
|
+
adherenceRate: 0,
|
|
56
|
+
painPreventedCount: 0,
|
|
57
|
+
derivedFromPainIds: [],
|
|
58
|
+
ruleIds: [],
|
|
59
|
+
conflictsWithPrincipleIds: [],
|
|
60
|
+
createdAt: '2026-04-10T00:00:00.000Z',
|
|
61
|
+
updatedAt: '2026-04-10T00:00:00.000Z',
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Helper: Setup ledger with a single principle
|
|
67
|
+
function setupLedgerWithPrinciple(principleId: string): void {
|
|
68
|
+
const tree = {
|
|
69
|
+
principles: {} as Record<string, LedgerPrinciple>,
|
|
70
|
+
rules: {} as Record<string, LedgerRule>,
|
|
71
|
+
implementations: {},
|
|
72
|
+
metrics: {},
|
|
73
|
+
lastUpdated: new Date().toISOString(),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
tree.principles[principleId] = createLedgerPrinciple(principleId);
|
|
77
|
+
|
|
78
|
+
const store: HybridLedgerStore = {
|
|
79
|
+
trainingStore: {},
|
|
80
|
+
tree,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
saveLedger(stateDir, store);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('registerCompiledRule', () => {
|
|
87
|
+
it('creates a gate rule and code implementation in the ledger', () => {
|
|
88
|
+
setupLedgerWithPrinciple('P_001');
|
|
89
|
+
|
|
90
|
+
const input: RegisterInput = {
|
|
91
|
+
principleId: 'P_001',
|
|
92
|
+
codeContent: 'export function check() { return true; }',
|
|
93
|
+
coversCondition: 'file_write',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const result = registerCompiledRule(stateDir, input);
|
|
97
|
+
|
|
98
|
+
// Verify result structure
|
|
99
|
+
expect(result.success).toBe(true);
|
|
100
|
+
expect(result.ruleId).toBe('R_P_001_auto');
|
|
101
|
+
expect(result.implementationId).toBe('IMPL_P_001_auto');
|
|
102
|
+
|
|
103
|
+
// Verify rule in ledger
|
|
104
|
+
const ledger = loadLedger(stateDir);
|
|
105
|
+
const rule = ledger.tree.rules['R_P_001_auto'];
|
|
106
|
+
expect(rule).toBeDefined();
|
|
107
|
+
expect(rule.id).toBe('R_P_001_auto');
|
|
108
|
+
expect(rule.type).toBe('gate');
|
|
109
|
+
expect(rule.enforcement).toBe('block');
|
|
110
|
+
expect(rule.status).toBe('proposed');
|
|
111
|
+
expect(rule.principleId).toBe('P_001');
|
|
112
|
+
expect(rule.implementationIds).toContain('IMPL_P_001_auto');
|
|
113
|
+
|
|
114
|
+
// Verify implementation in ledger
|
|
115
|
+
const impl = ledger.tree.implementations['IMPL_P_001_auto'];
|
|
116
|
+
expect(impl).toBeDefined();
|
|
117
|
+
expect(impl.id).toBe('IMPL_P_001_auto');
|
|
118
|
+
expect(impl.ruleId).toBe('R_P_001_auto');
|
|
119
|
+
expect(impl.type).toBe('code');
|
|
120
|
+
expect(impl.coversCondition).toBe('file_write');
|
|
121
|
+
expect(impl.lifecycleState).toBe('active');
|
|
122
|
+
|
|
123
|
+
// Verify principle linked to rule
|
|
124
|
+
const principle = ledger.tree.principles['P_001'];
|
|
125
|
+
expect(principle.ruleIds).toContain('R_P_001_auto');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns the codePath in the result', () => {
|
|
129
|
+
setupLedgerWithPrinciple('P_042');
|
|
130
|
+
|
|
131
|
+
const input: RegisterInput = {
|
|
132
|
+
principleId: 'P_042',
|
|
133
|
+
codeContent: '// some code',
|
|
134
|
+
coversCondition: 'git_push',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = registerCompiledRule(stateDir, input);
|
|
138
|
+
|
|
139
|
+
expect(result.success).toBe(true);
|
|
140
|
+
expect(result.codePath).toContain('P_042');
|
|
141
|
+
expect(result.codePath).toMatch(/\.ts$/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('throws if the principle does not exist', () => {
|
|
145
|
+
// Empty ledger — no principles
|
|
146
|
+
const store: HybridLedgerStore = {
|
|
147
|
+
trainingStore: {},
|
|
148
|
+
tree: {
|
|
149
|
+
principles: {},
|
|
150
|
+
rules: {},
|
|
151
|
+
implementations: {},
|
|
152
|
+
metrics: {},
|
|
153
|
+
lastUpdated: new Date().toISOString(),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
saveLedger(stateDir, store);
|
|
157
|
+
|
|
158
|
+
const input: RegisterInput = {
|
|
159
|
+
principleId: 'P_NONEXISTENT',
|
|
160
|
+
codeContent: 'export function check() { return true; }',
|
|
161
|
+
coversCondition: 'test',
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
expect(() => registerCompiledRule(stateDir, input)).toThrow(/missing principle.*P_NONEXISTENT/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('sets correct timestamps on rule and implementation', () => {
|
|
168
|
+
setupLedgerWithPrinciple('P_100');
|
|
169
|
+
|
|
170
|
+
const before = new Date().toISOString();
|
|
171
|
+
const input: RegisterInput = {
|
|
172
|
+
principleId: 'P_100',
|
|
173
|
+
codeContent: 'export const x = 1;',
|
|
174
|
+
coversCondition: 'test_condition',
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const result = registerCompiledRule(stateDir, input);
|
|
178
|
+
const after = new Date().toISOString();
|
|
179
|
+
|
|
180
|
+
expect(result.success).toBe(true);
|
|
181
|
+
|
|
182
|
+
const ledger = loadLedger(stateDir);
|
|
183
|
+
const rule = ledger.tree.rules['R_P_100_auto'];
|
|
184
|
+
const impl = ledger.tree.implementations['IMPL_P_100_auto'];
|
|
185
|
+
|
|
186
|
+
// Timestamps should be between before and after
|
|
187
|
+
expect(rule.createdAt >= before).toBe(true);
|
|
188
|
+
expect(rule.createdAt <= after).toBe(true);
|
|
189
|
+
expect(rule.updatedAt).toBe(rule.createdAt);
|
|
190
|
+
|
|
191
|
+
expect(impl.createdAt >= before).toBe(true);
|
|
192
|
+
expect(impl.createdAt <= after).toBe(true);
|
|
193
|
+
expect(impl.updatedAt).toBe(impl.createdAt);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('handles multiple registrations for different principles', () => {
|
|
197
|
+
// Setup ledger with two principles
|
|
198
|
+
const tree = {
|
|
199
|
+
principles: {} as Record<string, LedgerPrinciple>,
|
|
200
|
+
rules: {} as Record<string, LedgerRule>,
|
|
201
|
+
implementations: {},
|
|
202
|
+
metrics: {},
|
|
203
|
+
lastUpdated: new Date().toISOString(),
|
|
204
|
+
};
|
|
205
|
+
tree.principles['P_001'] = createLedgerPrinciple('P_001');
|
|
206
|
+
tree.principles['P_002'] = createLedgerPrinciple('P_002');
|
|
207
|
+
|
|
208
|
+
const store: HybridLedgerStore = { trainingStore: {}, tree };
|
|
209
|
+
saveLedger(stateDir, store);
|
|
210
|
+
|
|
211
|
+
const result1 = registerCompiledRule(stateDir, {
|
|
212
|
+
principleId: 'P_001',
|
|
213
|
+
codeContent: '// code 1',
|
|
214
|
+
coversCondition: 'cond_1',
|
|
215
|
+
});
|
|
216
|
+
const result2 = registerCompiledRule(stateDir, {
|
|
217
|
+
principleId: 'P_002',
|
|
218
|
+
codeContent: '// code 2',
|
|
219
|
+
coversCondition: 'cond_2',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result1.success).toBe(true);
|
|
223
|
+
expect(result2.success).toBe(true);
|
|
224
|
+
expect(result1.ruleId).toBe('R_P_001_auto');
|
|
225
|
+
expect(result2.ruleId).toBe('R_P_002_auto');
|
|
226
|
+
|
|
227
|
+
const ledger = loadLedger(stateDir);
|
|
228
|
+
expect(Object.keys(ledger.tree.rules)).toHaveLength(2);
|
|
229
|
+
expect(Object.keys(ledger.tree.implementations)).toHaveLength(2);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|