principles-disciple 1.124.0 → 1.126.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,163 @@
1
+ /**
2
+ * PRI-437 Adversarial Self-Review: Valid auto_correct from VM must be accepted
3
+ *
4
+ * PURPOSE: Verify that a valid auto_correct proposal created INSIDE the vm
5
+ * context is accepted by the validator (not rejected due to prototype realm
6
+ * mismatch).
7
+ *
8
+ * CONTEXT: validateCorrectionProposal uses isPlainObject which checks
9
+ * Object.getPrototypeOf() === Object.prototype. VM-created objects have
10
+ * prototypes from the VM realm, not the host realm, so isPlainObject
11
+ * returns false and rejects all VM-created proposals.
12
+ *
13
+ * ERR risk mitigation:
14
+ * - ERR-001: no `as` bypass on untrusted VM output
15
+ * - ERR-002: fail-closed with reason (not silent rejection)
16
+ */
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18
+ import * as fs from 'fs';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { SqliteConnection, SqliteActivationStateStore } from '@principles/core/runtime-v2';
22
+ import type { RuleHostInput } from '@principles/core/runtime-v2';
23
+ import { RuleHost } from '../../src/core/rule-host.js';
24
+
25
+ let tempWorkspaceDir: string;
26
+ let tempStateDir: string;
27
+ let sqliteConn: SqliteConnection;
28
+
29
+ function setupTempDirs(): void {
30
+ const baseTmp = os.tmpdir();
31
+ tempWorkspaceDir = fs.mkdtempSync(path.join(baseTmp, 'pd-rulehost-autocorrect-'));
32
+ tempStateDir = path.join(tempWorkspaceDir, '.principles');
33
+ fs.mkdirSync(tempStateDir, { recursive: true });
34
+ }
35
+
36
+ function insertRuleArtifact(
37
+ artifactId: string,
38
+ ruleId: string,
39
+ sourceTaskId: string,
40
+ code: string,
41
+ ): void {
42
+ const db = sqliteConn.getDb();
43
+ const now = new Date().toISOString();
44
+ const contentJson = JSON.stringify({
45
+ principleId: `P_${ruleId}`,
46
+ ruleId,
47
+ implementationCode: code,
48
+ goldenTrace: { traceId: `trace-${ruleId}`, cases: [], createdAt: now, version: 1 },
49
+ ruleHostGateDecision: 'accepted_shadow',
50
+ affectedTools: ['write_file'],
51
+ painReasonSummary: 'Test: auto_correct',
52
+ });
53
+
54
+ db.prepare(`
55
+ INSERT INTO pi_artifacts (artifact_id, artifact_kind, source_task_id, source_principle_id, source_rule_id, lineage_artifact_ids, validation_status, content_json, created_at, updated_at)
56
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
57
+ `).run(
58
+ artifactId, 'rule', sourceTaskId, `P_${ruleId}`, ruleId,
59
+ '[]', 'validated', contentJson, now, now,
60
+ );
61
+ }
62
+
63
+ async function insertActivation(
64
+ activationId: string,
65
+ artifactId: string,
66
+ ruleId: string,
67
+ ): Promise<void> {
68
+ const store = new SqliteActivationStateStore(sqliteConn);
69
+ const now = new Date().toISOString();
70
+ await store.recordActivation({
71
+ activationId,
72
+ idempotencyKey: `${artifactId}::code_tool_hook`,
73
+ artifactId,
74
+ channel: 'code_tool_hook',
75
+ action: 'code_tool_hook_shadow_activate',
76
+ targetRef: `impl://${ruleId}`,
77
+ activatedAt: now,
78
+ deactivatedAt: null,
79
+ });
80
+ }
81
+
82
+ function makeInput(normalizedPath: string): RuleHostInput {
83
+ return {
84
+ action: {
85
+ toolName: 'write_file',
86
+ normalizedPath,
87
+ paramsSummary: { path: normalizedPath },
88
+ },
89
+ workspace: { isRiskPath: false, planStatus: 'NONE' as const, hasPlanFile: false },
90
+ session: { sessionId: 'test-session-autocorrect', currentGfi: 0, recentThinking: false },
91
+ evolution: { epTier: 0 },
92
+ derived: { estimatedLineChanges: 1, bashRisk: 'safe' as const },
93
+ };
94
+ }
95
+
96
+ beforeEach(() => {
97
+ setupTempDirs();
98
+ sqliteConn = new SqliteConnection(tempWorkspaceDir);
99
+ sqliteConn.getDb();
100
+ });
101
+
102
+ afterEach(() => {
103
+ try { sqliteConn?.close(); } catch { /* best-effort */ }
104
+ try { fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); } catch { /* Windows */ }
105
+ });
106
+
107
+ describe('PRI-437 Adversarial Self-Review: Valid auto_correct from VM must be accepted', () => {
108
+ it('valid auto_correct proposal created inside VM is accepted (not rejected by isPlainObject)', async () => {
109
+ const RULE_ID = 'R_TEST_AUTOCORRECT_VALID';
110
+ const ARTIFACT_ID = 'art-autocorrect-valid';
111
+ const ACTIVATION_ID = `act_code_${RULE_ID}`;
112
+
113
+ // RuleCode that returns a valid auto_correct proposal.
114
+ // The proposal object is created INSIDE the vm context, so its prototype
115
+ // is the VM realm's Object.prototype, not the host's.
116
+ const VALID_AUTOCORRECT_CODE = `
117
+ function evaluate(input, helpers) {
118
+ return {
119
+ decision: 'auto_correct',
120
+ matched: true,
121
+ reason: 'path should be within workspace',
122
+ correctionProposal: {
123
+ ruleId: '${RULE_ID}',
124
+ correctedFields: [{ field: 'file_path', original: input.action.normalizedPath, proposed: '/safe/path', reason: 'redirect to safe path' }],
125
+ proposedParams: { file_path: '/safe/path' },
126
+ applicationMode: 'shadow',
127
+ confidence: 0.9,
128
+ notifyAgent: true
129
+ }
130
+ };
131
+ }
132
+ var meta = { name: 'autocorrect-rule', version: '1', ruleId: '${RULE_ID}', coversCondition: 'all' };
133
+ `;
134
+
135
+ insertRuleArtifact(ARTIFACT_ID, RULE_ID, 'task-autocorrect-valid', VALID_AUTOCORRECT_CODE);
136
+ await insertActivation(ACTIVATION_ID, ARTIFACT_ID, RULE_ID);
137
+
138
+ const warnCalls: string[] = [];
139
+ const spyLogger = {
140
+ warn: (message: string) => { warnCalls.push(message); },
141
+ error: () => {},
142
+ info: () => {},
143
+ };
144
+
145
+ const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
146
+ const result = ruleHost.evaluate(makeInput('/etc/passwd'));
147
+
148
+ // The valid auto_correct proposal MUST be accepted — not rejected due to
149
+ // VM prototype realm mismatch.
150
+ expect(result).toBeDefined();
151
+ expect(result?.decision).toBe('auto_correct');
152
+ expect(result?.matched).toBe(true);
153
+ expect(result?.correctionProposal).toBeDefined();
154
+ expect(result?.correctionProposal?.ruleId).toBe(RULE_ID);
155
+
156
+ // No warnings should be emitted about invalid results
157
+ const invalidWarn = warnCalls.find(m =>
158
+ m.toLowerCase().includes('invalid') ||
159
+ m.toLowerCase().includes('must be a plain object')
160
+ );
161
+ expect(invalidWarn).toBeUndefined();
162
+ });
163
+ });
@@ -0,0 +1,231 @@
1
+ /**
2
+ * PRI-437 Slice 3: Infinite loop and memory allocation terminate without taking down host
3
+ *
4
+ * PURPOSE: Verify that RuleCode with infinite loops or excessive memory allocation
5
+ * is terminated by the vm timeout boundary and degrades conservatively (undefined).
6
+ *
7
+ * ERR risk mitigation:
8
+ * - ERR-002: timeout/degradation must include a reason (not silent)
9
+ * - Resource boundary: RuleCode must have time AND memory limits
10
+ *
11
+ * Test approach:
12
+ * - Real SQLite activation with malicious RuleCode
13
+ * - Real RuleHost.evaluate() (public interface)
14
+ * - Verify termination within reasonable time (not hang forever)
15
+ * - Verify conservative degradation (undefined result + warn evidence)
16
+ */
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18
+ import * as fs from 'fs';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { SqliteConnection, SqliteActivationStateStore } from '@principles/core/runtime-v2';
22
+ import type { RuleHostInput } from '@principles/core/runtime-v2';
23
+ import { RuleHost } from '../../src/core/rule-host.js';
24
+
25
+ // ── Test helpers ───────────────────────────────────────────────────────────
26
+
27
+ const RULE_ID_INFINITE = 'R_TEST_INFINITE_003';
28
+ const ARTIFACT_ID_INFINITE = 'art-infinite-003';
29
+ const ACTIVATION_ID_INFINITE = `act_code_${RULE_ID_INFINITE}`;
30
+
31
+ const INFINITE_LOOP_CODE = `
32
+ function evaluate(input, helpers) {
33
+ // Infinite loop — must be terminated by vm timeout
34
+ while (true) {
35
+ // burn CPU forever
36
+ }
37
+ return { decision: 'block', matched: true, reason: 'never reached' };
38
+ }
39
+ var meta = { name: 'infinite-loop-rule', version: '1', ruleId: '${RULE_ID_INFINITE}', coversCondition: 'all' };
40
+ `;
41
+
42
+ const RULE_ID_MEMORY = 'R_TEST_MEMORY_003';
43
+ const ARTIFACT_ID_MEMORY = 'art-memory-003';
44
+ const ACTIVATION_ID_MEMORY = `act_code_${RULE_ID_MEMORY}`;
45
+
46
+ const MEMORY_BOMB_CODE = `
47
+ function evaluate(input, helpers) {
48
+ // Excessive memory allocation — must be bounded or caught
49
+ var arr = [];
50
+ for (var i = 0; i < 100000000; i++) {
51
+ arr.push(new Array(10000));
52
+ }
53
+ return { decision: 'block', matched: true, reason: 'memory bomb' };
54
+ }
55
+ var meta = { name: 'memory-bomb-rule', version: '1', ruleId: '${RULE_ID_MEMORY}', coversCondition: 'all' };
56
+ `;
57
+
58
+ let tempWorkspaceDir: string;
59
+ let tempStateDir: string;
60
+ let sqliteConn: SqliteConnection;
61
+
62
+ function setupTempDirs(): void {
63
+ const baseTmp = os.tmpdir();
64
+ tempWorkspaceDir = fs.mkdtempSync(path.join(baseTmp, 'pd-rulehost-bounds-'));
65
+ tempStateDir = path.join(tempWorkspaceDir, '.principles');
66
+ fs.mkdirSync(tempStateDir, { recursive: true });
67
+ }
68
+
69
+ function insertRuleArtifact(
70
+ artifactId: string,
71
+ ruleId: string,
72
+ sourceTaskId: string,
73
+ code: string,
74
+ ): void {
75
+ const db = sqliteConn.getDb();
76
+ const now = new Date().toISOString();
77
+ const contentJson = JSON.stringify({
78
+ principleId: `P_${ruleId}`,
79
+ ruleId,
80
+ implementationCode: code,
81
+ goldenTrace: { traceId: `trace-${ruleId}`, cases: [], createdAt: now, version: 1 },
82
+ ruleHostGateDecision: 'accepted_shadow',
83
+ affectedTools: ['write_file'],
84
+ painReasonSummary: 'Test: resource boundary',
85
+ });
86
+
87
+ db.prepare(`
88
+ INSERT INTO pi_artifacts (artifact_id, artifact_kind, source_task_id, source_principle_id, source_rule_id, lineage_artifact_ids, validation_status, content_json, created_at, updated_at)
89
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
90
+ `).run(
91
+ artifactId,
92
+ 'rule',
93
+ sourceTaskId,
94
+ `P_${ruleId}`,
95
+ ruleId,
96
+ '[]',
97
+ 'validated',
98
+ contentJson,
99
+ now,
100
+ now,
101
+ );
102
+ }
103
+
104
+ async function insertActivation(
105
+ activationId: string,
106
+ artifactId: string,
107
+ ruleId: string,
108
+ ): Promise<void> {
109
+ const store = new SqliteActivationStateStore(sqliteConn);
110
+ const now = new Date().toISOString();
111
+ await store.recordActivation({
112
+ activationId,
113
+ idempotencyKey: `${artifactId}::code_tool_hook`,
114
+ artifactId,
115
+ channel: 'code_tool_hook',
116
+ action: 'code_tool_hook_shadow_activate',
117
+ targetRef: `impl://${ruleId}`,
118
+ activatedAt: now,
119
+ deactivatedAt: null,
120
+ });
121
+ }
122
+
123
+ function makeInput(): RuleHostInput {
124
+ return {
125
+ action: {
126
+ toolName: 'write_file',
127
+ normalizedPath: '/etc/passwd',
128
+ paramsSummary: { path: '/etc/passwd' },
129
+ },
130
+ workspace: { isRiskPath: false, planStatus: 'NONE' as const, hasPlanFile: false },
131
+ session: { sessionId: 'test-session-bounds', currentGfi: 0, recentThinking: false },
132
+ evolution: { epTier: 1 },
133
+ derived: { estimatedLineChanges: 1, bashRisk: 'safe' as const },
134
+ };
135
+ }
136
+
137
+ // ── Setup / Teardown ───────────────────────────────────────────────────────
138
+
139
+ beforeEach(() => {
140
+ setupTempDirs();
141
+ sqliteConn = new SqliteConnection(tempWorkspaceDir);
142
+ sqliteConn.getDb();
143
+ });
144
+
145
+ afterEach(() => {
146
+ try { sqliteConn?.close(); } catch { /* best-effort */ }
147
+ try { fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); } catch { /* Windows */ }
148
+ });
149
+
150
+ // ── Slice 3: Resource boundaries ───────────────────────────────────────────
151
+
152
+ describe('PRI-437 Slice 3: Infinite loop and memory allocation terminate without taking down host', () => {
153
+ it('infinite loop in evaluate() is terminated by vm timeout → undefined + warn evidence', async () => {
154
+ insertRuleArtifact(ARTIFACT_ID_INFINITE, RULE_ID_INFINITE, 'task-infinite-003', INFINITE_LOOP_CODE);
155
+ await insertActivation(ACTIVATION_ID_INFINITE, ARTIFACT_ID_INFINITE, RULE_ID_INFINITE);
156
+
157
+ const warnCalls: string[] = [];
158
+ const spyLogger = {
159
+ warn: (message: string) => { warnCalls.push(message); },
160
+ error: () => {},
161
+ info: () => {},
162
+ };
163
+
164
+ const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
165
+
166
+ // Must terminate within 10 seconds (vm timeout is 1000ms, plus overhead)
167
+ const startTime = Date.now();
168
+ const result = ruleHost.evaluate(makeInput());
169
+ const elapsed = Date.now() - startTime;
170
+
171
+ // Conservative degradation: undefined (no opinion)
172
+ expect(result).toBeUndefined();
173
+
174
+ // Must not hang — should complete well under 10 seconds
175
+ expect(elapsed).toBeLessThan(10000);
176
+
177
+ // Must emit structured warn evidence (ERR-002: degradation includes reason)
178
+ expect(warnCalls.length).toBeGreaterThan(0);
179
+ const timeoutWarn = warnCalls.find(m =>
180
+ m.toLowerCase().includes('timeout') ||
181
+ m.toLowerCase().includes('timed out') ||
182
+ m.toLowerCase().includes('terminated')
183
+ );
184
+ expect(timeoutWarn).toBeDefined();
185
+ }, 15000); // 15 second test timeout (fail safe)
186
+
187
+ it('memory bomb in evaluate() is bounded or caught → undefined + warn evidence', async () => {
188
+ insertRuleArtifact(ARTIFACT_ID_MEMORY, RULE_ID_MEMORY, 'task-memory-003', MEMORY_BOMB_CODE);
189
+ await insertActivation(ACTIVATION_ID_MEMORY, ARTIFACT_ID_MEMORY, RULE_ID_MEMORY);
190
+
191
+ const warnCalls: string[] = [];
192
+ const spyLogger = {
193
+ warn: (message: string) => { warnCalls.push(message); },
194
+ error: () => {},
195
+ info: () => {},
196
+ };
197
+
198
+ const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
199
+
200
+ // Must terminate without crashing the host process
201
+ const startTime = Date.now();
202
+ let thrown: unknown;
203
+ let result: unknown;
204
+ try {
205
+ result = ruleHost.evaluate(makeInput());
206
+ } catch (err) {
207
+ thrown = err;
208
+ }
209
+ const elapsed = Date.now() - startTime;
210
+
211
+ // PRI-437 fail-closed: must NOT throw (vm timeout should catch it),
212
+ // and must return undefined (conservative degradation)
213
+ expect(thrown).toBeUndefined();
214
+ expect(result).toBeUndefined();
215
+
216
+ // Must not hang — should complete well under 30 seconds
217
+ expect(elapsed).toBeLessThan(30000);
218
+
219
+ // Must emit structured warn evidence with reason (ERR-002)
220
+ expect(warnCalls.length).toBeGreaterThan(0);
221
+ const reasonWarn = warnCalls.find(m =>
222
+ m.toLowerCase().includes('timeout') ||
223
+ m.toLowerCase().includes('timed out') ||
224
+ m.toLowerCase().includes('terminated') ||
225
+ m.toLowerCase().includes('memory') ||
226
+ m.toLowerCase().includes('heap') ||
227
+ m.toLowerCase().includes('range')
228
+ );
229
+ expect(reasonWarn).toBeDefined();
230
+ }, 60000); // 60 second test timeout (fail safe for memory pressure)
231
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * PRI-437 Slice 4: Approved compile failure is visible in EventLog, CLI and Console
3
+ *
4
+ * PURPOSE: Verify that when an approved rule fails to compile/load, the failure
5
+ * is recorded in EventLog as an unhealthy event with reason and nextAction.
6
+ * NOT just a logger.warn that's silently skipped.
7
+ *
8
+ * ERR risk mitigation:
9
+ * - ERR-002: degradation must include a reason (not silent logger.warn)
10
+ * - Observability: unhealthy state must be persisted and queryable
11
+ *
12
+ * Test approach:
13
+ * - Real SQLite activation with broken code (syntax error)
14
+ * - Real RuleHost.evaluate() (public interface)
15
+ * - Read real EventLog JSONL file to verify unhealthy record
16
+ */
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18
+ import * as fs from 'fs';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { SqliteConnection, SqliteActivationStateStore } from '@principles/core/runtime-v2';
22
+ import type { RuleHostInput } from '@principles/core/runtime-v2';
23
+ import { RuleHost } from '../../src/core/rule-host.js';
24
+ import { EventLogService } from '../../src/core/event-log.js';
25
+
26
+ // ── Test helpers ───────────────────────────────────────────────────────────
27
+
28
+ const RULE_ID_BROKEN = 'R_TEST_BROKEN_004';
29
+ const ARTIFACT_ID_BROKEN = 'art-broken-004';
30
+ const ACTIVATION_ID_BROKEN = `act_code_${RULE_ID_BROKEN}`;
31
+
32
+ // Code with a syntax error that will cause compilation failure
33
+ const BROKEN_CODE = `
34
+ function evaluate(input, helpers) {
35
+ // Syntax error: unclosed brace
36
+ return { decision: 'block', matched: true, reason: 'broken'
37
+ // Missing closing brace for function and return object
38
+ var meta = { name: 'broken-rule', version: '1', ruleId: '${RULE_ID_BROKEN}', coversCondition: 'all' };
39
+ `;
40
+
41
+ let tempWorkspaceDir: string;
42
+ let tempStateDir: string;
43
+ let sqliteConn: SqliteConnection;
44
+
45
+ function setupTempDirs(): void {
46
+ const baseTmp = os.tmpdir();
47
+ tempWorkspaceDir = fs.mkdtempSync(path.join(baseTmp, 'pd-rulehost-unhealthy-'));
48
+ tempStateDir = path.join(tempWorkspaceDir, '.principles');
49
+ fs.mkdirSync(tempStateDir, { recursive: true });
50
+ }
51
+
52
+ function insertRuleArtifact(): void {
53
+ const db = sqliteConn.getDb();
54
+ const now = new Date().toISOString();
55
+ const contentJson = JSON.stringify({
56
+ principleId: 'P_TEST_BROKEN_004',
57
+ ruleId: RULE_ID_BROKEN,
58
+ implementationCode: BROKEN_CODE,
59
+ goldenTrace: { traceId: 'trace-broken-004', cases: [], createdAt: now, version: 1 },
60
+ ruleHostGateDecision: 'accepted_shadow',
61
+ affectedTools: ['write_file'],
62
+ painReasonSummary: 'Test: broken code compilation',
63
+ });
64
+
65
+ db.prepare(`
66
+ INSERT INTO pi_artifacts (artifact_id, artifact_kind, source_task_id, source_principle_id, source_rule_id, lineage_artifact_ids, validation_status, content_json, created_at, updated_at)
67
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
68
+ `).run(
69
+ ARTIFACT_ID_BROKEN,
70
+ 'rule',
71
+ 'task-broken-004',
72
+ 'P_TEST_BROKEN_004',
73
+ RULE_ID_BROKEN,
74
+ '[]',
75
+ 'validated',
76
+ contentJson,
77
+ now,
78
+ now,
79
+ );
80
+ }
81
+
82
+ async function insertActivation(): Promise<void> {
83
+ const store = new SqliteActivationStateStore(sqliteConn);
84
+ const now = new Date().toISOString();
85
+ await store.recordActivation({
86
+ activationId: ACTIVATION_ID_BROKEN,
87
+ idempotencyKey: `${ARTIFACT_ID_BROKEN}::code_tool_hook`,
88
+ artifactId: ARTIFACT_ID_BROKEN,
89
+ channel: 'code_tool_hook',
90
+ action: 'code_tool_hook_shadow_activate',
91
+ targetRef: `impl://${RULE_ID_BROKEN}`,
92
+ activatedAt: now,
93
+ deactivatedAt: null,
94
+ });
95
+ }
96
+
97
+ function makeInput(): RuleHostInput {
98
+ return {
99
+ action: {
100
+ toolName: 'write_file',
101
+ normalizedPath: '/etc/passwd',
102
+ paramsSummary: { path: '/etc/passwd' },
103
+ },
104
+ workspace: { isRiskPath: false, planStatus: 'NONE' as const, hasPlanFile: false },
105
+ session: { sessionId: 'test-session-unhealthy', currentGfi: 0, recentThinking: false },
106
+ evolution: { epTier: 1 },
107
+ derived: { estimatedLineChanges: 1, bashRisk: 'safe' as const },
108
+ };
109
+ }
110
+
111
+ function readTodayEvents(stateDir: string): string {
112
+ const today = new Date().toISOString().slice(0, 10);
113
+ const eventsFile = path.join(stateDir, 'logs', `events_${today}.jsonl`);
114
+ if (!fs.existsSync(eventsFile)) {
115
+ return '';
116
+ }
117
+ return fs.readFileSync(eventsFile, 'utf-8');
118
+ }
119
+
120
+ // ── Setup / Teardown ───────────────────────────────────────────────────────
121
+
122
+ beforeEach(() => {
123
+ setupTempDirs();
124
+ EventLogService.disposeAll();
125
+ sqliteConn = new SqliteConnection(tempWorkspaceDir);
126
+ sqliteConn.getDb();
127
+ });
128
+
129
+ afterEach(() => {
130
+ EventLogService.disposeAll();
131
+ try { sqliteConn?.close(); } catch { /* best-effort */ }
132
+ try { fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); } catch { /* Windows */ }
133
+ });
134
+
135
+ // ── Slice 4: Compile failure visible in EventLog ───────────────────────────
136
+
137
+ describe('PRI-437 Slice 4: Approved compile failure is visible in EventLog', () => {
138
+ it('broken code compilation → EventLog records rulehost_unhealthy with reason and nextAction', async () => {
139
+ insertRuleArtifact();
140
+ await insertActivation();
141
+
142
+ const warnCalls: string[] = [];
143
+ const spyLogger = {
144
+ warn: (message: string) => { warnCalls.push(message); },
145
+ error: () => {},
146
+ info: () => {},
147
+ };
148
+
149
+ const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
150
+
151
+ // Evaluate — should attempt to compile the broken code and fail
152
+ const result = ruleHost.evaluate(makeInput());
153
+
154
+ // Conservative degradation: undefined (no opinion)
155
+ expect(result).toBeUndefined();
156
+
157
+ // Flush EventLog to ensure events are written to disk
158
+ const eventLog = EventLogService.get(tempStateDir);
159
+ eventLog.flush();
160
+
161
+ // Read the EventLog JSONL file and verify unhealthy record exists
162
+ const eventsContent = readTodayEvents(tempStateDir);
163
+
164
+ // Must contain a rulehost_unhealthy event (NOT just logger.warn)
165
+ expect(eventsContent).toContain('rulehost_unhealthy');
166
+
167
+ // Structured validation: parse JSONL lines and find the unhealthy event
168
+ const lines = eventsContent.trim().split('\n').filter(l => l.trim());
169
+ const unhealthyEvents = lines
170
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
171
+ .filter(evt => evt?.type === 'rulehost_unhealthy');
172
+
173
+ expect(unhealthyEvents.length).toBeGreaterThanOrEqual(1);
174
+
175
+ // Verify the unhealthy event has all required fields in the same record
176
+ const unhealthy = unhealthyEvents[0];
177
+ expect(unhealthy.data.activationId).toBe(ACTIVATION_ID_BROKEN);
178
+ expect(unhealthy.data.ruleId).toBe(RULE_ID_BROKEN);
179
+ expect(typeof unhealthy.data.reason).toBe('string');
180
+ expect(unhealthy.data.reason.length).toBeGreaterThan(0);
181
+ expect(typeof unhealthy.data.nextAction).toBe('string');
182
+ expect(unhealthy.data.nextAction.length).toBeGreaterThan(0);
183
+ });
184
+
185
+ it('valid rule does NOT produce rulehost_unhealthy event', async () => {
186
+ // Insert a VALID rule (not broken)
187
+ const VALID_CODE = `
188
+ function evaluate(input, helpers) {
189
+ var p = input.action.normalizedPath || '';
190
+ if (p.indexOf('/etc/') === 0) {
191
+ return { decision: 'block', matched: true, reason: 'valid block' };
192
+ }
193
+ return { decision: 'allow', matched: false, reason: 'not matched' };
194
+ }
195
+ var meta = { name: 'valid-rule', version: '1', ruleId: '${RULE_ID_BROKEN}', coversCondition: 'all' };
196
+ `;
197
+
198
+ const db = sqliteConn.getDb();
199
+ const now = new Date().toISOString();
200
+ const contentJson = JSON.stringify({
201
+ principleId: 'P_TEST_VALID_004',
202
+ ruleId: RULE_ID_BROKEN,
203
+ implementationCode: VALID_CODE,
204
+ goldenTrace: { traceId: 'trace-valid-004', cases: [], createdAt: now, version: 1 },
205
+ ruleHostGateDecision: 'accepted_shadow',
206
+ affectedTools: ['write_file'],
207
+ painReasonSummary: 'Test: valid code',
208
+ });
209
+
210
+ db.prepare(`
211
+ INSERT INTO pi_artifacts (artifact_id, artifact_kind, source_task_id, source_principle_id, source_rule_id, lineage_artifact_ids, validation_status, content_json, created_at, updated_at)
212
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
213
+ `).run(
214
+ ARTIFACT_ID_BROKEN, 'rule', 'task-valid-004', 'P_TEST_VALID_004', RULE_ID_BROKEN,
215
+ '[]', 'validated', contentJson, now, now,
216
+ );
217
+
218
+ await insertActivation();
219
+
220
+ const ruleHost = new RuleHost(tempStateDir, { warn: () => {}, error: () => {}, info: () => {} }, { workspaceDir: tempWorkspaceDir });
221
+ const result = ruleHost.evaluate(makeInput());
222
+
223
+ // Valid rule should block (not undefined)
224
+ expect(result).toBeDefined();
225
+ expect(result?.decision).toBe('block');
226
+
227
+ // Flush and verify NO unhealthy event
228
+ const eventLog = EventLogService.get(tempStateDir);
229
+ eventLog.flush();
230
+
231
+ const eventsContent = readTodayEvents(tempStateDir);
232
+ expect(eventsContent).not.toContain('rulehost_unhealthy');
233
+ });
234
+
235
+ it('runtime-invalid result is persisted as rulehost_unhealthy and never executes', async () => {
236
+ const invalidResultCode = `
237
+ function evaluate() {
238
+ return { decision: 'block', matched: 'yes', reason: 'invalid matched type' };
239
+ }
240
+ var meta = { name: 'invalid-result-rule', version: '1', ruleId: '${RULE_ID_BROKEN}', coversCondition: 'all' };
241
+ `;
242
+ const now = new Date().toISOString();
243
+ sqliteConn.getDb().prepare(`
244
+ INSERT INTO pi_artifacts (artifact_id, artifact_kind, source_task_id, source_principle_id, source_rule_id, lineage_artifact_ids, validation_status, content_json, created_at, updated_at)
245
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
246
+ `).run(
247
+ ARTIFACT_ID_BROKEN, 'rule', 'task-invalid-result', 'P_TEST_INVALID_RESULT', RULE_ID_BROKEN,
248
+ '[]', 'validated', JSON.stringify({ ruleId: RULE_ID_BROKEN, implementationCode: invalidResultCode }), now, now,
249
+ );
250
+ await insertActivation();
251
+
252
+ const ruleHost = new RuleHost(tempStateDir, { warn: () => {} }, { workspaceDir: tempWorkspaceDir });
253
+ expect(ruleHost.evaluate(makeInput())).toBeUndefined();
254
+ EventLogService.get(tempStateDir).flush();
255
+
256
+ const events = readTodayEvents(tempStateDir);
257
+ expect(events).toContain('rulehost_unhealthy');
258
+ expect(events).toContain('invalid RuleHostResult');
259
+ expect(events).toContain(ACTIVATION_ID_BROKEN);
260
+ });
261
+ });