@principles/pd-cli 1.119.0 → 1.120.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.
Files changed (35) hide show
  1. package/dist/commands/__tests__/legacy-cleanup.test.d.ts +18 -0
  2. package/dist/commands/__tests__/legacy-cleanup.test.d.ts.map +1 -0
  3. package/dist/commands/__tests__/legacy-cleanup.test.js +459 -0
  4. package/dist/commands/__tests__/legacy-cleanup.test.js.map +1 -0
  5. package/dist/commands/__tests__/rulecode-flag-wiring.test.d.ts +21 -0
  6. package/dist/commands/__tests__/rulecode-flag-wiring.test.d.ts.map +1 -0
  7. package/dist/commands/__tests__/rulecode-flag-wiring.test.js +179 -0
  8. package/dist/commands/__tests__/rulecode-flag-wiring.test.js.map +1 -0
  9. package/dist/commands/__tests__/rulecode-handler.test.d.ts +16 -0
  10. package/dist/commands/__tests__/rulecode-handler.test.d.ts.map +1 -0
  11. package/dist/commands/__tests__/rulecode-handler.test.js +285 -0
  12. package/dist/commands/__tests__/rulecode-handler.test.js.map +1 -0
  13. package/dist/commands/legacy-cleanup.d.ts +72 -6
  14. package/dist/commands/legacy-cleanup.d.ts.map +1 -1
  15. package/dist/commands/legacy-cleanup.js +243 -23
  16. package/dist/commands/legacy-cleanup.js.map +1 -1
  17. package/dist/commands/rulecode.d.ts +85 -0
  18. package/dist/commands/rulecode.d.ts.map +1 -0
  19. package/dist/commands/rulecode.js +356 -0
  20. package/dist/commands/rulecode.js.map +1 -0
  21. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
  22. package/dist/commands/runtime-internalization-run-rulehost.js +4 -7
  23. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
  24. package/dist/index.js +30 -9
  25. package/dist/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/scripts/llm-dogfood.ts +8 -12
  28. package/src/commands/__tests__/legacy-cleanup.test.ts +596 -0
  29. package/src/commands/__tests__/rulecode-flag-wiring.test.ts +230 -0
  30. package/src/commands/__tests__/rulecode-handler.test.ts +369 -0
  31. package/src/commands/legacy-cleanup.ts +335 -27
  32. package/src/commands/rulecode.ts +434 -0
  33. package/src/commands/runtime-internalization-run-rulehost.ts +3 -8
  34. package/src/index.ts +31 -9
  35. package/tests/commands/cli-command-tree.test.ts +40 -0
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Parser-level tests for `pd rulecode spec|validate|replay` flags (PRI-439 Phase 5).
3
+ *
4
+ * CLI gate rule 7: "Test the real command wiring — when behavior depends on
5
+ * Commander options, add a command-registration or parser test that exercises
6
+ * the actual flags."
7
+ *
8
+ * Tests the real `registerRulecodeCommand` helper (single source of truth
9
+ * shared with `index.ts`). Flag typos in production surface here at
10
+ * parseAsync time, not at handler dispatch.
11
+ *
12
+ * Covers:
13
+ * - `spec` subcommand: --json, --workspace/-w registered; no --code
14
+ * - `validate` subcommand: --code required, --code-file, --json, --workspace/-w
15
+ * - `replay` subcommand: --code required, --code-file, --golden-trace required,
16
+ * --json, --workspace/-w
17
+ * - --no-* negations are NOT registered (no accidental negation)
18
+ * - parseAsync actually dispatches the right opts to the handler
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { Command } from 'commander';
23
+ import { registerRulecodeCommand } from '../rulecode.js';
24
+
25
+ type ActionOptions = Record<string, unknown>;
26
+
27
+ interface CapturedAction {
28
+ opts: ActionOptions | null;
29
+ }
30
+
31
+ function attachCapture(cmd: Command, state: CapturedAction): void {
32
+ cmd.action(function captureAction(...args: unknown[]): void {
33
+ let optsArg: unknown = null;
34
+ for (let i = args.length - 1; i >= 0; i--) {
35
+ const arg: unknown = args[i];
36
+ if (arg !== null && typeof arg === 'object' && !(arg instanceof Command)) {
37
+ optsArg = arg;
38
+ break;
39
+ }
40
+ }
41
+ if (optsArg !== null && typeof optsArg === 'object') {
42
+ state.opts = optsArg as ActionOptions;
43
+ } else {
44
+ state.opts = {};
45
+ }
46
+ });
47
+ }
48
+
49
+ function freshProgram(): Command {
50
+ const program = new Command();
51
+ program.name('pd').exitOverride();
52
+ return program;
53
+ }
54
+
55
+ describe('pd rulecode — flag wiring (CLI gate rule 7)', () => {
56
+ // ── spec subcommand ───────────────────────────────────────────────────────
57
+
58
+ it('registers spec subcommand with --json and --workspace', () => {
59
+ const program = freshProgram();
60
+ const rulecodeCmd = registerRulecodeCommand(program);
61
+
62
+ const specCmd = rulecodeCmd.commands.find((c) => c.name() === 'spec');
63
+ expect(specCmd).toBeDefined();
64
+
65
+ const jsonOpt = specCmd!.options.find((o) => o.long === '--json');
66
+ expect(jsonOpt).toBeDefined();
67
+
68
+ const wsOpt = specCmd!.options.find((o) => o.long === '--workspace');
69
+ expect(wsOpt).toBeDefined();
70
+ expect(wsOpt?.short).toBe('-w');
71
+ });
72
+
73
+ it('spec subcommand does NOT register --code', () => {
74
+ const program = freshProgram();
75
+ const rulecodeCmd = registerRulecodeCommand(program);
76
+
77
+ const specCmd = rulecodeCmd.commands.find((c) => c.name() === 'spec');
78
+ const codeOpt = specCmd!.options.find((o) => o.long === '--code');
79
+ expect(codeOpt).toBeUndefined();
80
+ });
81
+
82
+ // ── validate subcommand ───────────────────────────────────────────────────
83
+
84
+ it('registers validate subcommand with --code, --code-file, --json, --workspace', () => {
85
+ const program = freshProgram();
86
+ const rulecodeCmd = registerRulecodeCommand(program);
87
+
88
+ const validateCmd = rulecodeCmd.commands.find((c) => c.name() === 'validate');
89
+ expect(validateCmd).toBeDefined();
90
+
91
+ const codeOpt = validateCmd!.options.find((o) => o.long === '--code');
92
+ expect(codeOpt).toBeDefined();
93
+
94
+ const codeFileOpt = validateCmd!.options.find((o) => o.long === '--code-file');
95
+ expect(codeFileOpt).toBeDefined();
96
+
97
+ const jsonOpt = validateCmd!.options.find((o) => o.long === '--json');
98
+ expect(jsonOpt).toBeDefined();
99
+
100
+ const wsOpt = validateCmd!.options.find((o) => o.long === '--workspace');
101
+ expect(wsOpt).toBeDefined();
102
+ expect(wsOpt?.short).toBe('-w');
103
+ });
104
+
105
+ it('validate --code is NOT required at parser level (can use --code-file instead)', async () => {
106
+ const program = freshProgram();
107
+ const rulecodeCmd = registerRulecodeCommand(program);
108
+ const validateCmd = rulecodeCmd.commands.find((c) => c.name() === 'validate')!;
109
+ const captured: CapturedAction = { opts: null };
110
+ attachCapture(validateCmd, captured);
111
+
112
+ // parseAsync should NOT reject when --code is missing (handler validates)
113
+ await program.parseAsync(['node', 'pd', 'rulecode', 'validate', '--json']);
114
+
115
+ expect(captured.opts).not.toBeNull();
116
+ expect(captured.opts!.code).toBeUndefined();
117
+ });
118
+
119
+ // ── replay subcommand ─────────────────────────────────────────────────────
120
+
121
+ it('registers replay subcommand with --code, --code-file, --golden-trace, --json, --workspace', () => {
122
+ const program = freshProgram();
123
+ const rulecodeCmd = registerRulecodeCommand(program);
124
+
125
+ const replayCmd = rulecodeCmd.commands.find((c) => c.name() === 'replay');
126
+ expect(replayCmd).toBeDefined();
127
+
128
+ const codeOpt = replayCmd!.options.find((o) => o.long === '--code');
129
+ expect(codeOpt).toBeDefined();
130
+
131
+ const codeFileOpt = replayCmd!.options.find((o) => o.long === '--code-file');
132
+ expect(codeFileOpt).toBeDefined();
133
+
134
+ const gtOpt = replayCmd!.options.find((o) => o.long === '--golden-trace');
135
+ expect(gtOpt).toBeDefined();
136
+
137
+ const jsonOpt = replayCmd!.options.find((o) => o.long === '--json');
138
+ expect(jsonOpt).toBeDefined();
139
+
140
+ const wsOpt = replayCmd!.options.find((o) => o.long === '--workspace');
141
+ expect(wsOpt).toBeDefined();
142
+ expect(wsOpt?.short).toBe('-w');
143
+ });
144
+
145
+ it('replay --golden-trace is required', () => {
146
+ const program = freshProgram();
147
+ const rulecodeCmd = registerRulecodeCommand(program);
148
+
149
+ const replayCmd = rulecodeCmd.commands.find((c) => c.name() === 'replay');
150
+ const gtOpt = replayCmd!.options.find((o) => o.long === '--golden-trace');
151
+ expect(gtOpt?.required).toBe(true);
152
+ });
153
+
154
+ // ── No accidental negations ───────────────────────────────────────────────
155
+
156
+ it('does NOT register --no-json on any subcommand', () => {
157
+ const program = freshProgram();
158
+ const rulecodeCmd = registerRulecodeCommand(program);
159
+
160
+ for (const sub of rulecodeCmd.commands) {
161
+ const noJson = sub.options.find((o) => o.long === '--no-json');
162
+ expect(noJson).toBeUndefined();
163
+ }
164
+ });
165
+
166
+ // ── Parser-level dispatch ─────────────────────────────────────────────────
167
+
168
+ it('parseAsync dispatches spec subcommand with json=true', async () => {
169
+ const program = freshProgram();
170
+ const rulecodeCmd = registerRulecodeCommand(program);
171
+ const specCmd = rulecodeCmd.commands.find((c) => c.name() === 'spec')!;
172
+ const captured: CapturedAction = { opts: null };
173
+ attachCapture(specCmd, captured);
174
+
175
+ await program.parseAsync(['node', 'pd', 'rulecode', 'spec', '--json']);
176
+
177
+ expect(captured.opts).not.toBeNull();
178
+ expect(captured.opts!.json).toBe(true);
179
+ });
180
+
181
+ it('parseAsync dispatches validate with --code', async () => {
182
+ const program = freshProgram();
183
+ const rulecodeCmd = registerRulecodeCommand(program);
184
+ const validateCmd = rulecodeCmd.commands.find((c) => c.name() === 'validate')!;
185
+ const captured: CapturedAction = { opts: null };
186
+ attachCapture(validateCmd, captured);
187
+
188
+ await program.parseAsync([
189
+ 'node', 'pd', 'rulecode', 'validate',
190
+ '--code', 'function evaluate(input, helpers) { return { decision: "allow", matched: false, reason: "x" }; }',
191
+ '--json',
192
+ ]);
193
+
194
+ expect(captured.opts).not.toBeNull();
195
+ expect(captured.opts!.code).toContain('function evaluate');
196
+ expect(captured.opts!.json).toBe(true);
197
+ });
198
+
199
+ it('parseAsync dispatches replay with --code and --golden-trace', async () => {
200
+ const program = freshProgram();
201
+ const rulecodeCmd = registerRulecodeCommand(program);
202
+ const replayCmd = rulecodeCmd.commands.find((c) => c.name() === 'replay')!;
203
+ const captured: CapturedAction = { opts: null };
204
+ attachCapture(replayCmd, captured);
205
+
206
+ await program.parseAsync([
207
+ 'node', 'pd', 'rulecode', 'replay',
208
+ '--code', 'function evaluate(input, helpers) { return { decision: "allow", matched: false, reason: "x" }; }',
209
+ '--golden-trace', '/tmp/trace.json',
210
+ '--json',
211
+ ]);
212
+
213
+ expect(captured.opts).not.toBeNull();
214
+ expect(captured.opts!.code).toContain('function evaluate');
215
+ expect(captured.opts!.goldenTrace).toBe('/tmp/trace.json');
216
+ expect(captured.opts!.json).toBe(true);
217
+ });
218
+
219
+ it('parseAsync rejects replay without --golden-trace (requiredOption)', async () => {
220
+ const program = freshProgram();
221
+ registerRulecodeCommand(program);
222
+
223
+ await expect(
224
+ program.parseAsync([
225
+ 'node', 'pd', 'rulecode', 'replay',
226
+ '--code', 'function evaluate() {}',
227
+ ]),
228
+ ).rejects.toThrow(/golden-trace/);
229
+ });
230
+ });
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Handler tests for `pd rulecode spec|validate|replay` (PRI-439 Phase 5).
3
+ *
4
+ * Tests the actual handler logic (not Commander parser wiring — that's in
5
+ * rulecode-flag-wiring.test.ts). Verifies:
6
+ * - spec returns the canonical RuleCode dialect spec text
7
+ * - validate detects forbidden patterns, missing return fields, matched=false
8
+ * - validate passes clean code
9
+ * - replay runs sandbox replay against a golden trace file
10
+ * - failure paths include structured reason + nextAction (CLI gate rule 6)
11
+ * - --json outputs exactly one parseable JSON object (CLI gate rule 1)
12
+ * - missing --code/--code-file fails loud with reason (ERR-009)
13
+ * - missing/malformed --golden-trace fails loud with reason (ERR-009)
14
+ */
15
+
16
+ import { describe, it, expect, vi } from 'vitest';
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import * as os from 'node:os';
20
+ import {
21
+ handleRulecodeSpec,
22
+ handleRulecodeValidate,
23
+ handleRulecodeReplay,
24
+ } from '../rulecode.js';
25
+
26
+ // ── Helpers ──────────────────────────────────────────────────────────────────
27
+
28
+ async function runHandler<T>(fn: () => Promise<T>): Promise<{ stdout: string; stderr: string; exitCode: number | undefined }> {
29
+ const stdoutChunks: string[] = [];
30
+ const stderrChunks: string[] = [];
31
+ const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
32
+ stdoutChunks.push(args.map(String).join(' '));
33
+ });
34
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
35
+ stderrChunks.push(args.map(String).join(' '));
36
+ });
37
+ process.exitCode = undefined;
38
+
39
+ try {
40
+ await fn();
41
+ } finally {
42
+ logSpy.mockRestore();
43
+ errorSpy.mockRestore();
44
+ }
45
+
46
+ const exitCode = process.exitCode;
47
+ process.exitCode = undefined;
48
+
49
+ return {
50
+ stdout: stdoutChunks.join(''),
51
+ stderr: stderrChunks.join(''),
52
+ exitCode,
53
+ };
54
+ }
55
+
56
+ function parseJson(stdout: string): unknown {
57
+ return JSON.parse(stdout);
58
+ }
59
+
60
+ const CLEAN_CODE = `function evaluate(input, helpers) {
61
+ if (helpers.getToolName() === 'Bash') {
62
+ return { decision: "block", matched: true, reason: "bash commands blocked" };
63
+ }
64
+ return { decision: "allow", matched: false, reason: "non-bash allowed" };
65
+ }`;
66
+
67
+ const FORBIDDEN_CODE = `function evaluate(input, helpers) {
68
+ require('fs');
69
+ return { decision: "allow", matched: false, reason: "x" };
70
+ }`;
71
+
72
+ const MISSING_FIELDS_CODE = `function evaluate(input, helpers) {
73
+ if (helpers.isRiskPath()) {
74
+ return { matched: true };
75
+ }
76
+ return { decision: "allow", matched: false, reason: "safe" };
77
+ }`;
78
+
79
+ // ── Tests ────────────────────────────────────────────────────────────────────
80
+
81
+ describe('pd rulecode spec', () => {
82
+ it('returns the canonical spec text as JSON', async () => {
83
+ const { stdout, exitCode } = await runHandler(() => handleRulecodeSpec({ json: true }));
84
+ const output = parseJson(stdout) as { status: string; spec: string };
85
+ expect(output.status).toBe('ok');
86
+ expect(output.spec).toContain('RuleCode Dialect Spec');
87
+ expect(output.spec).toContain('CANONICAL FORM');
88
+ expect(output.spec).toContain('FORBIDDEN PATTERNS');
89
+ expect(exitCode).toBeUndefined();
90
+ });
91
+
92
+ it('outputs text when --json is false', async () => {
93
+ const { stdout, exitCode } = await runHandler(() => handleRulecodeSpec({ json: false }));
94
+ expect(stdout).toContain('RuleCode Dialect Spec');
95
+ expect(stdout.startsWith('{')).toBe(false);
96
+ expect(exitCode).toBeUndefined();
97
+ });
98
+
99
+ it('does not set exit code on success', async () => {
100
+ const { exitCode } = await runHandler(() => handleRulecodeSpec({ json: true }));
101
+ expect(exitCode).toBeUndefined();
102
+ });
103
+ });
104
+
105
+ describe('pd rulecode validate', () => {
106
+ it('passes clean code with valid=true', async () => {
107
+ const { stdout, exitCode } = await runHandler(() =>
108
+ handleRulecodeValidate({ code: CLEAN_CODE, json: true }),
109
+ );
110
+ const output = parseJson(stdout) as {
111
+ status: string; valid: boolean; violationCount: number; violations: string[];
112
+ };
113
+ expect(output.status).toBe('ok');
114
+ expect(output.valid).toBe(true);
115
+ expect(output.violationCount).toBe(0);
116
+ expect(output.violations).toEqual([]);
117
+ expect(exitCode).toBeUndefined();
118
+ });
119
+
120
+ it('detects forbidden patterns', async () => {
121
+ const { stdout, exitCode } = await runHandler(() =>
122
+ handleRulecodeValidate({ code: FORBIDDEN_CODE, json: true }),
123
+ );
124
+ const output = parseJson(stdout) as {
125
+ status: string; valid: boolean; violationCount: number; violations: string[];
126
+ reason?: string; nextAction?: string;
127
+ };
128
+ expect(output.status).toBe('failed');
129
+ expect(output.valid).toBe(false);
130
+ expect(output.violationCount).toBeGreaterThan(0);
131
+ expect(output.violations.some((v) => v.includes('forbidden pattern'))).toBe(true);
132
+ expect(output.reason).toBeDefined();
133
+ expect(output.nextAction).toBeDefined();
134
+ expect(exitCode).toBe(1);
135
+ });
136
+
137
+ it('detects missing return fields', async () => {
138
+ const { stdout, exitCode } = await runHandler(() =>
139
+ handleRulecodeValidate({ code: MISSING_FIELDS_CODE, json: true }),
140
+ );
141
+ const output = parseJson(stdout) as { valid: boolean; violations: string[] };
142
+ expect(output.valid).toBe(false);
143
+ expect(output.violations.length).toBeGreaterThan(0);
144
+ expect(exitCode).toBe(1);
145
+ });
146
+
147
+ it('fails loud when no --code or --code-file provided', async () => {
148
+ const { stdout, exitCode } = await runHandler(() =>
149
+ handleRulecodeValidate({ json: true }),
150
+ );
151
+ const output = parseJson(stdout) as {
152
+ status: string; valid: boolean; reason: string; nextAction: string;
153
+ };
154
+ expect(output.status).toBe('failed');
155
+ expect(output.valid).toBe(false);
156
+ expect(output.reason).toContain('no code provided');
157
+ expect(output.nextAction).toContain('--code');
158
+ expect(exitCode).toBe(1);
159
+ });
160
+
161
+ it('reads code from --code-file', async () => {
162
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-rulecode-test-'));
163
+ const codeFile = path.join(tmpDir, 'rule.js');
164
+ fs.writeFileSync(codeFile, CLEAN_CODE, 'utf8');
165
+
166
+ try {
167
+ const { stdout, exitCode } = await runHandler(() =>
168
+ handleRulecodeValidate({ codeFile, json: true }),
169
+ );
170
+ const output = parseJson(stdout) as { valid: boolean };
171
+ expect(output.valid).toBe(true);
172
+ expect(exitCode).toBeUndefined();
173
+ } finally {
174
+ fs.rmSync(tmpDir, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ it('fails loud when --code-file does not exist', async () => {
179
+ const { stdout, exitCode } = await runHandler(() =>
180
+ handleRulecodeValidate({ codeFile: '/nonexistent/path/rule.js', json: true }),
181
+ );
182
+ const output = parseJson(stdout) as {
183
+ status: string; reason: string; nextAction: string;
184
+ };
185
+ expect(output.status).toBe('failed');
186
+ expect(output.reason).toContain('cannot read');
187
+ expect(output.nextAction).toBeDefined();
188
+ expect(exitCode).toBe(1);
189
+ });
190
+ });
191
+
192
+ describe('pd rulecode replay', () => {
193
+ const GOLDEN_TRACE_CASES = [
194
+ {
195
+ caseId: 'pos-1',
196
+ kind: 'positive' as const,
197
+ toolName: 'Write',
198
+ params: { normalizedPath: 'src/safe.ts' },
199
+ expectedDecision: 'allow' as const,
200
+ },
201
+ {
202
+ caseId: 'neg-1',
203
+ kind: 'negative' as const,
204
+ toolName: 'Bash',
205
+ params: { command: 'rm -rf /' },
206
+ expectedDecision: 'block' as const,
207
+ },
208
+ ];
209
+
210
+ function writeGoldenTraceFile(cases: unknown): string {
211
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-rulecode-replay-'));
212
+ const filePath = path.join(tmpDir, 'trace.json');
213
+ fs.writeFileSync(filePath, JSON.stringify(cases, null, 2), 'utf8');
214
+ return filePath;
215
+ }
216
+
217
+ it('passes replay with clean code and valid golden trace', async () => {
218
+ const traceFile = writeGoldenTraceFile(GOLDEN_TRACE_CASES);
219
+ try {
220
+ const { stdout, exitCode } = await runHandler(() =>
221
+ handleRulecodeReplay({ code: CLEAN_CODE, goldenTrace: traceFile, json: true }),
222
+ );
223
+ const output = parseJson(stdout) as {
224
+ status: string; decision: string; reasons: string[];
225
+ };
226
+ expect(output.status).toBe('ok');
227
+ expect(output.decision).toBe('accepted_shadow');
228
+ expect(exitCode).toBeUndefined();
229
+ } finally {
230
+ fs.rmSync(path.dirname(traceFile), { recursive: true, force: true });
231
+ }
232
+ });
233
+
234
+ it('fails loud when --golden-trace file does not exist', async () => {
235
+ const { stdout, exitCode } = await runHandler(() =>
236
+ handleRulecodeReplay({
237
+ code: CLEAN_CODE,
238
+ goldenTrace: '/nonexistent/trace.json',
239
+ json: true,
240
+ }),
241
+ );
242
+ const output = parseJson(stdout) as {
243
+ status: string; reason: string; nextAction: string;
244
+ };
245
+ expect(output.status).toBe('failed');
246
+ expect(output.reason).toContain('cannot read');
247
+ expect(exitCode).toBe(1);
248
+ });
249
+
250
+ it('fails loud when golden trace is not valid JSON', async () => {
251
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-rulecode-replay-'));
252
+ const traceFile = path.join(tmpDir, 'trace.json');
253
+ fs.writeFileSync(traceFile, '{ not valid json', 'utf8');
254
+
255
+ try {
256
+ const { stdout, exitCode } = await runHandler(() =>
257
+ handleRulecodeReplay({ code: CLEAN_CODE, goldenTrace: traceFile, json: true }),
258
+ );
259
+ const output = parseJson(stdout) as {
260
+ status: string; reason: string; nextAction: string;
261
+ };
262
+ expect(output.status).toBe('failed');
263
+ expect(output.reason).toContain('not valid JSON');
264
+ expect(exitCode).toBe(1);
265
+ } finally {
266
+ fs.rmSync(tmpDir, { recursive: true, force: true });
267
+ }
268
+ });
269
+
270
+ it('fails loud when golden trace is not an array', async () => {
271
+ const traceFile = writeGoldenTraceFile({ not: 'an array' });
272
+
273
+ try {
274
+ const { stdout, exitCode } = await runHandler(() =>
275
+ handleRulecodeReplay({ code: CLEAN_CODE, goldenTrace: traceFile, json: true }),
276
+ );
277
+ const output = parseJson(stdout) as {
278
+ status: string; reason: string; nextAction: string;
279
+ };
280
+ expect(output.status).toBe('failed');
281
+ expect(output.reason).toContain('must contain a JSON array');
282
+ expect(exitCode).toBe(1);
283
+ } finally {
284
+ fs.rmSync(path.dirname(traceFile), { recursive: true, force: true });
285
+ }
286
+ });
287
+
288
+ it('fails loud when golden trace has fewer than 2 cases', async () => {
289
+ const traceFile = writeGoldenTraceFile([GOLDEN_TRACE_CASES[0]]);
290
+
291
+ try {
292
+ const { stdout, exitCode } = await runHandler(() =>
293
+ handleRulecodeReplay({ code: CLEAN_CODE, goldenTrace: traceFile, json: true }),
294
+ );
295
+ const output = parseJson(stdout) as {
296
+ status: string; reason: string; nextAction: string;
297
+ };
298
+ expect(output.status).toBe('failed');
299
+ expect(output.reason).toContain('at least 2 cases');
300
+ expect(exitCode).toBe(1);
301
+ } finally {
302
+ fs.rmSync(path.dirname(traceFile), { recursive: true, force: true });
303
+ }
304
+ });
305
+
306
+ it('fails loud when a golden trace case is malformed', async () => {
307
+ const traceFile = writeGoldenTraceFile([
308
+ { caseId: 'x' }, // missing required fields
309
+ GOLDEN_TRACE_CASES[1],
310
+ ]);
311
+
312
+ try {
313
+ const { stdout, exitCode } = await runHandler(() =>
314
+ handleRulecodeReplay({ code: CLEAN_CODE, goldenTrace: traceFile, json: true }),
315
+ );
316
+ const output = parseJson(stdout) as {
317
+ status: string; reason: string; nextAction: string;
318
+ };
319
+ expect(output.status).toBe('failed');
320
+ expect(output.reason).toContain('malformed');
321
+ expect(exitCode).toBe(1);
322
+ } finally {
323
+ fs.rmSync(path.dirname(traceFile), { recursive: true, force: true });
324
+ }
325
+ });
326
+
327
+ it('fails loud when no --code or --code-file provided', async () => {
328
+ const traceFile = writeGoldenTraceFile(GOLDEN_TRACE_CASES);
329
+ try {
330
+ const { stdout, exitCode } = await runHandler(() =>
331
+ handleRulecodeReplay({ goldenTrace: traceFile, json: true }),
332
+ );
333
+ const output = parseJson(stdout) as {
334
+ status: string; reason: string; nextAction: string;
335
+ };
336
+ expect(output.status).toBe('failed');
337
+ expect(output.reason).toContain('no code provided');
338
+ expect(exitCode).toBe(1);
339
+ } finally {
340
+ fs.rmSync(path.dirname(traceFile), { recursive: true, force: true });
341
+ }
342
+ });
343
+
344
+ it('reports sandbox failures with structured reason + nextAction', async () => {
345
+ const traceFile = writeGoldenTraceFile(GOLDEN_TRACE_CASES);
346
+ // Code with forbidden pattern — sandbox will reject
347
+ const badCode = `function evaluate(input, helpers) {
348
+ eval('1');
349
+ return { decision: "allow", matched: false, reason: "x" };
350
+ }`;
351
+
352
+ try {
353
+ const { stdout, exitCode } = await runHandler(() =>
354
+ handleRulecodeReplay({ code: badCode, goldenTrace: traceFile, json: true }),
355
+ );
356
+ const output = parseJson(stdout) as {
357
+ status: string; decision: string; reason?: string; nextAction?: string;
358
+ forbiddenPatternViolations: string[];
359
+ };
360
+ expect(output.status).toBe('failed');
361
+ expect(output.decision).not.toBe('accepted_shadow');
362
+ expect(output.reason).toBeDefined();
363
+ expect(output.nextAction).toBeDefined();
364
+ expect(exitCode).toBe(1);
365
+ } finally {
366
+ fs.rmSync(path.dirname(traceFile), { recursive: true, force: true });
367
+ }
368
+ });
369
+ });