principles-disciple 1.52.0 → 1.54.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.
@@ -18,6 +18,7 @@ import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
18
18
  import type { PrincipleEvaluability } from '../types/principle-tree-schema.js';
19
19
  export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
20
20
  import { atomicWriteFileSync } from '../utils/io.js';
21
+ import { validatePainSignal, type PainSignalValidationResult } from '../core/pain-signal.js';
21
22
 
22
23
  // Re-export queue I/O (extracted to queue-io.ts)
23
24
  export { loadEvolutionQueue, saveEvolutionQueue, withQueueLock, acquireQueueLock, requireQueueLock } from './queue-io.js';
@@ -51,6 +52,7 @@ import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
51
52
  import { classifyFailure, type ClassifiableTaskKind } from './failure-classifier.js';
52
53
  import { recordPersistentFailure, resetFailureState, isTaskKindInCooldown } from './cooldown-strategy.js';
53
54
  import { reconcileStartup } from './startup-reconciler.js';
55
+ import { clearPainFlag } from '../core/pain-lifecycle.js';
54
56
  import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
55
57
  import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
56
58
 
@@ -330,6 +332,27 @@ async function doEnqueuePainTask(
330
332
  return result;
331
333
  }
332
334
 
335
+ // Validate pain signal through TypeBox schema before enqueuing.
336
+ // Malformed signals are logged and skipped — they never enter the queue.
337
+ const signalInput = {
338
+ source: v.source,
339
+ score: v.score,
340
+ timestamp: new Date().toISOString(),
341
+ reason: v.reason,
342
+ sessionId: v.sessionId ?? undefined,
343
+ agentId: v.agentId ?? undefined,
344
+ traceId: v.traceId ?? undefined,
345
+ triggerTextPreview: v.preview,
346
+ };
347
+ const validation: PainSignalValidationResult = validatePainSignal(signalInput);
348
+ if (!validation.valid) {
349
+ result.skipped_reason = `invalid_pain_signal (${validation.errors.join('; ')})`;
350
+ if (logger) logger.warn(`[PD:EvolutionWorker] Pain signal validation failed, skipping enqueue: ${validation.errors.join('; ')}`);
351
+ SystemLogger.log(wctx.workspaceDir, 'PAIN_SIGNAL_INVALID', `Validation errors: ${validation.errors.join('; ')} | source=${v.source} score=${v.score}`);
352
+ clearPainFlag(wctx.workspaceDir);
353
+ return result;
354
+ }
355
+
333
356
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
334
357
  const releaseLock = await requireQueueLock(queuePath, logger, 'checkPainFlag');
335
358
  try {
@@ -344,6 +367,7 @@ async function doEnqueuePainTask(
344
367
  result.enqueued = true;
345
368
  result.skipped_reason = 'duplicate';
346
369
  if (logger) logger.info(`[PD:EvolutionWorker] Duplicate pain task skipped for source=${v.source} preview=${v.preview || 'N/A'}`);
370
+ clearPainFlag(wctx.workspaceDir);
347
371
  return result;
348
372
  }
349
373
 
@@ -387,6 +411,7 @@ async function doEnqueuePainTask(
387
411
  enqueuedAt: nowIso,
388
412
  });
389
413
  } finally { releaseLock(); }
414
+ clearPainFlag(wctx.workspaceDir);
390
415
  return result;
391
416
  }
392
417
 
@@ -419,6 +444,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
419
444
  if (isQueued) {
420
445
  result.skipped_reason = 'already_queued';
421
446
  if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${score}, source=${source})`);
447
+ clearPainFlag(wctx.workspaceDir, painEventId);
422
448
  return result;
423
449
  }
424
450
 
@@ -432,6 +458,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
432
458
  result.exists = true;
433
459
  result.skipped_reason = `invalid_pain_flag (${contract.missingFields.join(', ') || contract.format})`;
434
460
  if (logger) logger.warn(`[PD:EvolutionWorker] Invalid pain flag skipped: ${result.skipped_reason}`);
461
+ clearPainFlag(wctx.workspaceDir);
435
462
  return result;
436
463
  }
437
464
 
@@ -472,6 +499,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
472
499
  result.enqueued = true;
473
500
  result.skipped_reason = 'already_queued';
474
501
  if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${jsonScore}, source=${jsonSource})`);
502
+ clearPainFlag(wctx.workspaceDir, jsonPain.pain_event_id ? parseInt(jsonPain.pain_event_id, 10) : undefined);
475
503
  return result;
476
504
  }
477
505
 
@@ -763,9 +791,38 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
763
791
  }
764
792
 
765
793
  // V2: Migrate queue to current schema if needed
766
- const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
794
+ let queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
795
+
796
+ // Validate queue items — filter out malformed entries before processing.
797
+ // Malformed items are logged + skipped; they never crash the evolution cycle.
798
+ const beforeValidation = queue.length;
799
+ queue = queue.filter((item) => {
800
+ const errors: string[] = [];
801
+ if (!item.id || typeof item.id !== 'string') errors.push('missing/invalid id');
802
+ if (!item.source || typeof item.source !== 'string') errors.push('missing/invalid source');
803
+ if (typeof item.score !== 'number') errors.push('missing/invalid score');
804
+ if (!item.status || typeof item.status !== 'string') errors.push('missing/invalid status');
805
+ if (!item.taskKind || typeof item.taskKind !== 'string') errors.push('missing/invalid taskKind');
806
+ else {
807
+ const validTaskKinds = ['pain_diagnosis', 'sleep_reflection', 'model_eval', 'keyword_optimization'];
808
+ if (!validTaskKinds.includes(item.taskKind)) {
809
+ errors.push(`invalid taskKind value '${item.taskKind}' (expected one of: ${validTaskKinds.join(', ')})`);
810
+ }
811
+ }
812
+ if (typeof item.retryCount !== 'number') errors.push('missing/invalid retryCount');
813
+ if (typeof item.maxRetries !== 'number') errors.push('missing/invalid maxRetries');
814
+ if (errors.length > 0) {
815
+ logger?.warn?.(`[PD:EvolutionWorker] Skipping malformed queue item: ${errors.join(', ')} | ${JSON.stringify(item).slice(0, 200)}`);
816
+ SystemLogger.log(wctx.workspaceDir, 'QUEUE_ITEM_MALFORMED', `Skipped: ${errors.join(', ')} | id=${item.id || 'N/A'}`);
817
+ return false;
818
+ }
819
+ return true;
820
+ });
821
+ if (queue.length < beforeValidation) {
822
+ logger?.info?.(`[PD:EvolutionWorker] Filtered ${beforeValidation - queue.length} malformed queue item(s)`);
823
+ }
767
824
 
768
- let queueChanged = rawQueue.some(isLegacyQueueItem);
825
+ let queueChanged = rawQueue.some(isLegacyQueueItem) || queue.length < beforeValidation;
769
826
 
770
827
  // Guard: Skip keyword_optimization if one is already pending/in-progress (CORR-08)
771
828
  if (hasPendingTask(queue, 'keyword_optimization')) {
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EvolutionHook, PrincipleCreatedEvent, PrinciplePromotedEvent } from '../../src/core/evolution-hook.js';
3
+ import { noOpEvolutionHook } from '../../src/core/evolution-hook.js';
4
+ import type { PainSignal } from '../../src/core/pain-signal.js';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function validPainSignal(overrides: Partial<PainSignal> = {}): PainSignal {
11
+ return {
12
+ source: 'tool_failure',
13
+ score: 75,
14
+ timestamp: '2026-04-17T00:00:00.000Z',
15
+ reason: 'File not found',
16
+ sessionId: 'session-001',
17
+ agentId: 'main',
18
+ traceId: 'trace-001',
19
+ triggerTextPreview: 'File not found: test.ts',
20
+ domain: 'coding',
21
+ severity: 'high',
22
+ context: {},
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Tests
29
+ // ---------------------------------------------------------------------------
30
+
31
+ describe('EvolutionHook', () => {
32
+ it('implements all 3 methods', () => {
33
+ const calls: string[] = [];
34
+ const hook: EvolutionHook = {
35
+ onPainDetected(signal: PainSignal): void { calls.push(`pain:${signal.source}`); },
36
+ onPrincipleCreated(event: PrincipleCreatedEvent): void { calls.push(`created:${event.id}`); },
37
+ onPrinciplePromoted(event: PrinciplePromotedEvent): void { calls.push(`promoted:${event.id}`); },
38
+ };
39
+
40
+ hook.onPainDetected(validPainSignal());
41
+ hook.onPrincipleCreated({ id: 'p-1', text: 'Test principle', trigger: 'tool failure' });
42
+ hook.onPrinciplePromoted({ id: 'p-1', from: 'candidate', to: 'active' });
43
+
44
+ expect(calls).toEqual(['pain:tool_failure', 'created:p-1', 'promoted:p-1']);
45
+ });
46
+
47
+ it('onPainDetected receives a PainSignal', () => {
48
+ let received: PainSignal | undefined;
49
+ const hook: EvolutionHook = {
50
+ ...noOpEvolutionHook,
51
+ onPainDetected(signal: PainSignal): void { received = signal; },
52
+ };
53
+
54
+ const signal = validPainSignal();
55
+ hook.onPainDetected(signal);
56
+ expect(received).toEqual(signal);
57
+ });
58
+
59
+ it('onPrincipleCreated receives a PrincipleCreatedEvent', () => {
60
+ let received: PrincipleCreatedEvent | undefined;
61
+ const hook: EvolutionHook = {
62
+ ...noOpEvolutionHook,
63
+ onPrincipleCreated(event: PrincipleCreatedEvent): void { received = event; },
64
+ };
65
+
66
+ const event = { id: 'p-1', text: 'Test principle', trigger: 'tool failure' };
67
+ hook.onPrincipleCreated(event);
68
+ expect(received).toEqual(event);
69
+ });
70
+
71
+ it('onPrinciplePromoted receives a PrinciplePromotedEvent', () => {
72
+ let received: PrinciplePromotedEvent | undefined;
73
+ const hook: EvolutionHook = {
74
+ ...noOpEvolutionHook,
75
+ onPrinciplePromoted(event: PrinciplePromotedEvent): void { received = event; },
76
+ };
77
+
78
+ const event = { id: 'p-1', from: 'candidate', to: 'active' };
79
+ hook.onPrinciplePromoted(event);
80
+ expect(received).toEqual(event);
81
+ });
82
+ });
83
+
84
+ describe('noOpEvolutionHook', () => {
85
+ it('implements all 3 methods as no-ops', () => {
86
+ expect(() => {
87
+ noOpEvolutionHook.onPainDetected(validPainSignal());
88
+ noOpEvolutionHook.onPrincipleCreated({ id: 'p-1', text: 'Test', trigger: 'test' });
89
+ noOpEvolutionHook.onPrinciplePromoted({ id: 'p-1', from: 'candidate', to: 'active' });
90
+ }).not.toThrow();
91
+ });
92
+
93
+ it('can be spread to override individual methods', () => {
94
+ const calls: string[] = [];
95
+ const hook: EvolutionHook = {
96
+ ...noOpEvolutionHook,
97
+ onPainDetected(_signal: PainSignal): void { calls.push('pain'); },
98
+ };
99
+
100
+ hook.onPainDetected(validPainSignal());
101
+ hook.onPrincipleCreated({ id: 'p-1', text: 'Test', trigger: 'test' });
102
+
103
+ expect(calls).toEqual(['pain']);
104
+ });
105
+ });
106
+
107
+ describe('PrincipleCreatedEvent', () => {
108
+ it('has required fields: id, text, trigger', () => {
109
+ const event: PrincipleCreatedEvent = { id: 'p-1', text: 'Always verify', trigger: 'tool failure' };
110
+ expect(event.id).toBe('p-1');
111
+ expect(event.text).toBe('Always verify');
112
+ expect(event.trigger).toBe('tool failure');
113
+ });
114
+ });
115
+
116
+ describe('PrinciplePromotedEvent', () => {
117
+ it('has required fields: id, from, to', () => {
118
+ const event: PrinciplePromotedEvent = { id: 'p-1', from: 'candidate', to: 'active' };
119
+ expect(event.id).toBe('p-1');
120
+ expect(event.from).toBe('candidate');
121
+ expect(event.to).toBe('active');
122
+ });
123
+ });
@@ -0,0 +1,285 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { FileStorageAdapter } from '../../src/core/file-storage-adapter.js';
6
+ import type { HybridLedgerStore } from '../../src/core/principle-tree-ledger.js';
7
+ import { TREE_NAMESPACE, loadLedger } from '../../src/core/principle-tree-ledger.js';
8
+ import { safeRmDir } from '../test-utils.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function createTmpDir(): string {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-file-storage-adapter-test-'));
16
+ }
17
+
18
+ function createEmptyStore(): HybridLedgerStore {
19
+ return {
20
+ trainingStore: {},
21
+ tree: {
22
+ principles: {},
23
+ rules: {},
24
+ implementations: {},
25
+ metrics: {},
26
+ lastUpdated: new Date(0).toISOString(),
27
+ },
28
+ };
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Tests
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe('FileStorageAdapter', () => {
36
+ let tmpDir: string;
37
+ let adapter: FileStorageAdapter;
38
+
39
+ beforeEach(() => {
40
+ tmpDir = createTmpDir();
41
+ adapter = new FileStorageAdapter(tmpDir, tmpDir);
42
+ });
43
+
44
+ afterEach(() => {
45
+ safeRmDir(tmpDir);
46
+ });
47
+
48
+ // -------------------------------------------------------------------------
49
+ // loadLedger
50
+ // -------------------------------------------------------------------------
51
+
52
+ describe('loadLedger', () => {
53
+ it('returns empty store when no file exists', async () => {
54
+ const store = await adapter.loadLedger();
55
+ expect(store.trainingStore).toEqual({});
56
+ expect(store.tree.principles).toEqual({});
57
+ expect(store.tree.rules).toEqual({});
58
+ });
59
+
60
+ it('loads existing persisted store', async () => {
61
+ const original = createEmptyStore();
62
+ original.tree.principles['P-001'] = {
63
+ id: 'P-001',
64
+ version: 1,
65
+ text: 'Write before delete',
66
+ triggerPattern: 'delete',
67
+ action: 'write first',
68
+ status: 'active',
69
+ priority: 'P1',
70
+ scope: 'general',
71
+ evaluability: 'deterministic',
72
+ valueScore: 0,
73
+ adherenceRate: 0,
74
+ painPreventedCount: 0,
75
+ derivedFromPainIds: [],
76
+ ruleIds: [],
77
+ conflictsWithPrincipleIds: [],
78
+ createdAt: '2026-04-17T00:00:00.000Z',
79
+ updatedAt: '2026-04-17T00:00:00.000Z',
80
+ };
81
+
82
+ // Persist using the low-level ledger to seed the file
83
+ await adapter.saveLedger(original);
84
+ const loaded = await adapter.loadLedger();
85
+ expect(loaded.tree.principles['P-001']).toBeDefined();
86
+ expect(loaded.tree.principles['P-001'].text).toBe('Write before delete');
87
+ });
88
+ });
89
+
90
+ // -------------------------------------------------------------------------
91
+ // saveLedger
92
+ // -------------------------------------------------------------------------
93
+
94
+ describe('saveLedger', () => {
95
+ it('persists store to disk', async () => {
96
+ const store = createEmptyStore();
97
+ store.trainingStore['test-principle'] = {
98
+ principleId: 'test-principle',
99
+ evaluability: 'manual_only',
100
+ applicableOpportunityCount: 0,
101
+ observedViolationCount: 0,
102
+ complianceRate: 0,
103
+ violationTrend: 0,
104
+ generatedSampleCount: 0,
105
+ approvedSampleCount: 0,
106
+ includedTrainRunIds: [],
107
+ deployedCheckpointIds: [],
108
+ internalizationStatus: 'prompt_only',
109
+ };
110
+
111
+ await adapter.saveLedger(store);
112
+
113
+ // Verify file exists and contains the data
114
+ const filePath = path.join(tmpDir, 'principle_training_state.json');
115
+ expect(fs.existsSync(filePath)).toBe(true);
116
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
117
+ expect(raw['test-principle']).toBeDefined();
118
+ expect(raw[TREE_NAMESPACE]).toBeDefined();
119
+ });
120
+
121
+ it('round-trips data through save and load', async () => {
122
+ const store = createEmptyStore();
123
+ store.trainingStore['p-1'] = {
124
+ principleId: 'p-1',
125
+ evaluability: 'weak_heuristic',
126
+ applicableOpportunityCount: 5,
127
+ observedViolationCount: 2,
128
+ complianceRate: 0.6,
129
+ violationTrend: -0.1,
130
+ generatedSampleCount: 3,
131
+ approvedSampleCount: 2,
132
+ includedTrainRunIds: ['run-1'],
133
+ deployedCheckpointIds: [],
134
+ internalizationStatus: 'in_training',
135
+ };
136
+
137
+ await adapter.saveLedger(store);
138
+ const loaded = await adapter.loadLedger();
139
+ expect(loaded.trainingStore['p-1'].evaluability).toBe('weak_heuristic');
140
+ expect(loaded.trainingStore['p-1'].applicableOpportunityCount).toBe(5);
141
+ });
142
+ });
143
+
144
+ // -------------------------------------------------------------------------
145
+ // mutateLedger
146
+ // -------------------------------------------------------------------------
147
+
148
+ describe('mutateLedger', () => {
149
+ it('reads, mutates, and writes atomically', async () => {
150
+ // Start with empty store
151
+ await adapter.saveLedger(createEmptyStore());
152
+
153
+ const result = await adapter.mutateLedger((store) => {
154
+ store.tree.principles['P-002'] = {
155
+ id: 'P-002',
156
+ version: 1,
157
+ text: 'Test principle',
158
+ triggerPattern: 'test',
159
+ action: 'do something',
160
+ status: 'candidate',
161
+ priority: 'P2',
162
+ scope: 'general',
163
+ evaluability: 'manual_only',
164
+ valueScore: 0,
165
+ adherenceRate: 0,
166
+ painPreventedCount: 0,
167
+ derivedFromPainIds: [],
168
+ ruleIds: [],
169
+ conflictsWithPrincipleIds: [],
170
+ createdAt: '2026-04-17T00:00:00.000Z',
171
+ updatedAt: '2026-04-17T00:00:00.000Z',
172
+ };
173
+ return 42;
174
+ });
175
+
176
+ expect(result).toBe(42);
177
+ const loaded = await adapter.loadLedger();
178
+ expect(loaded.tree.principles['P-002']).toBeDefined();
179
+ expect(loaded.tree.principles['P-002'].text).toBe('Test principle');
180
+ });
181
+
182
+ it('returns the value from the mutate function', async () => {
183
+ await adapter.saveLedger(createEmptyStore());
184
+
185
+ const count = await adapter.mutateLedger((store) => {
186
+ return Object.keys(store.tree.principles).length;
187
+ });
188
+
189
+ expect(count).toBe(0);
190
+ });
191
+
192
+ it('supports async mutate functions', async () => {
193
+ await adapter.saveLedger(createEmptyStore());
194
+
195
+ const result = await adapter.mutateLedger(async (store) => {
196
+ // Simulate async work
197
+ await new Promise((r) => setTimeout(r, 10));
198
+ store.trainingStore['async-test'] = {
199
+ principleId: 'async-test',
200
+ evaluability: 'deterministic',
201
+ applicableOpportunityCount: 1,
202
+ observedViolationCount: 0,
203
+ complianceRate: 1.0,
204
+ violationTrend: 0,
205
+ generatedSampleCount: 0,
206
+ approvedSampleCount: 0,
207
+ includedTrainRunIds: [],
208
+ deployedCheckpointIds: [],
209
+ internalizationStatus: 'prompt_only',
210
+ };
211
+ return 'async-done';
212
+ });
213
+
214
+ expect(result).toBe('async-done');
215
+ const loaded = await adapter.loadLedger();
216
+ expect(loaded.trainingStore['async-test']).toBeDefined();
217
+ });
218
+
219
+ it('persists via atomicWriteFileSync (file is not corrupted)', async () => {
220
+ await adapter.saveLedger(createEmptyStore());
221
+
222
+ await adapter.mutateLedger((store) => {
223
+ store.tree.principles['P-003'] = {
224
+ id: 'P-003',
225
+ version: 1,
226
+ text: 'Atomic write test',
227
+ triggerPattern: 'test',
228
+ action: 'verify atomicity',
229
+ status: 'candidate',
230
+ priority: 'P1',
231
+ scope: 'general',
232
+ evaluability: 'manual_only',
233
+ valueScore: 0,
234
+ adherenceRate: 0,
235
+ painPreventedCount: 0,
236
+ derivedFromPainIds: [],
237
+ ruleIds: [],
238
+ conflictsWithPrincipleIds: [],
239
+ createdAt: '2026-04-17T00:00:00.000Z',
240
+ updatedAt: '2026-04-17T00:00:00.000Z',
241
+ };
242
+ });
243
+
244
+ // Verify no leftover temp file
245
+ const filePath = path.join(tmpDir, 'principle_training_state.json');
246
+ expect(fs.existsSync(filePath + '.tmp')).toBe(false);
247
+ expect(fs.existsSync(filePath)).toBe(true);
248
+
249
+ // File is valid JSON
250
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
251
+ expect(raw[TREE_NAMESPACE].principles['P-003']).toBeDefined();
252
+ });
253
+
254
+ it('is compatible with low-level loadLedger', async () => {
255
+ await adapter.saveLedger(createEmptyStore());
256
+
257
+ await adapter.mutateLedger((store) => {
258
+ store.tree.principles['P-COMPAT'] = {
259
+ id: 'P-COMPAT',
260
+ version: 1,
261
+ text: 'Compatibility test',
262
+ triggerPattern: 'compat',
263
+ action: 'verify',
264
+ status: 'active',
265
+ priority: 'P1',
266
+ scope: 'general',
267
+ evaluability: 'deterministic',
268
+ valueScore: 10,
269
+ adherenceRate: 0.8,
270
+ painPreventedCount: 5,
271
+ derivedFromPainIds: ['pain-1'],
272
+ ruleIds: [],
273
+ conflictsWithPrincipleIds: [],
274
+ createdAt: '2026-04-17T00:00:00.000Z',
275
+ updatedAt: '2026-04-17T00:00:00.000Z',
276
+ };
277
+ });
278
+
279
+ // Load with the low-level function — should see the same data
280
+ const ledger = loadLedger(tmpDir);
281
+ expect(ledger.tree.principles['P-COMPAT']).toBeDefined();
282
+ expect(ledger.tree.principles['P-COMPAT'].text).toBe('Compatibility test');
283
+ });
284
+ });
285
+ });