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.
@@ -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
+ });