@cleocode/playbooks 2026.4.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/approval.d.ts +113 -0
- package/dist/approval.js +244 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +32 -0
- package/dist/parser.d.ts +60 -0
- package/dist/parser.js +509 -0
- package/dist/policy.d.ts +55 -0
- package/dist/policy.js +85 -0
- package/dist/schema.d.ts +374 -0
- package/dist/schema.js +34 -0
- package/dist/state.d.ts +96 -0
- package/dist/state.js +322 -0
- package/package.json +51 -0
- package/src/__tests__/approval.test.ts +295 -0
- package/src/__tests__/parser.test.ts +456 -0
- package/src/__tests__/policy.test.ts +91 -0
- package/src/__tests__/schema.test.ts +209 -0
- package/src/__tests__/smoke.test.ts +9 -0
- package/src/__tests__/state.test.ts +258 -0
- package/src/approval.ts +321 -0
- package/src/index.ts +66 -0
- package/src/parser.ts +712 -0
- package/src/policy.ts +111 -0
- package/src/schema.ts +44 -0
- package/src/state.ts +471 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W4-7 real-YAML parser tests (no mocks, no fixture files).
|
|
3
|
+
* Uses template-literal YAML strings inline so every expectation is readable
|
|
4
|
+
* in situ and reviewers can trace input → output without chasing fixtures.
|
|
5
|
+
*
|
|
6
|
+
* @task T889 / T904 / W4-7
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { PlaybookParseError, parsePlaybook } from '../parser.js';
|
|
11
|
+
|
|
12
|
+
describe('W4-7: parsePlaybook', () => {
|
|
13
|
+
describe('happy path', () => {
|
|
14
|
+
it('parses a minimal valid playbook (1 agentic node, 0 edges)', () => {
|
|
15
|
+
const yaml = `
|
|
16
|
+
version: "1.0"
|
|
17
|
+
name: minimal
|
|
18
|
+
nodes:
|
|
19
|
+
- id: start
|
|
20
|
+
type: agentic
|
|
21
|
+
skill: ct-research-agent
|
|
22
|
+
`;
|
|
23
|
+
const { definition, sourceHash } = parsePlaybook(yaml);
|
|
24
|
+
expect(definition.version).toBe('1.0');
|
|
25
|
+
expect(definition.name).toBe('minimal');
|
|
26
|
+
expect(definition.nodes).toHaveLength(1);
|
|
27
|
+
expect(definition.nodes[0]).toMatchObject({
|
|
28
|
+
id: 'start',
|
|
29
|
+
type: 'agentic',
|
|
30
|
+
skill: 'ct-research-agent',
|
|
31
|
+
});
|
|
32
|
+
expect(definition.edges).toEqual([]);
|
|
33
|
+
expect(sourceHash).toMatch(/^[a-f0-9]{64}$/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('parses a full rcasd-style playbook (5 nodes + edges)', () => {
|
|
37
|
+
const yaml = `
|
|
38
|
+
version: "1.0"
|
|
39
|
+
name: rcasd
|
|
40
|
+
description: Research -> Constraint -> Architect -> Spec -> Decompose
|
|
41
|
+
inputs:
|
|
42
|
+
- name: epicId
|
|
43
|
+
required: true
|
|
44
|
+
description: Parent epic id
|
|
45
|
+
- name: scope
|
|
46
|
+
default: global
|
|
47
|
+
nodes:
|
|
48
|
+
- id: research
|
|
49
|
+
type: agentic
|
|
50
|
+
skill: ct-research-agent
|
|
51
|
+
role: lead
|
|
52
|
+
inputs:
|
|
53
|
+
topic: "{{inputs.epicId}}"
|
|
54
|
+
- id: constraint
|
|
55
|
+
type: agentic
|
|
56
|
+
agent: ct-validator
|
|
57
|
+
role: worker
|
|
58
|
+
depends:
|
|
59
|
+
- research
|
|
60
|
+
- id: lint
|
|
61
|
+
type: deterministic
|
|
62
|
+
command: pnpm
|
|
63
|
+
args:
|
|
64
|
+
- biome
|
|
65
|
+
- ci
|
|
66
|
+
- .
|
|
67
|
+
timeout_ms: 60000
|
|
68
|
+
depends:
|
|
69
|
+
- constraint
|
|
70
|
+
- id: approve
|
|
71
|
+
type: approval
|
|
72
|
+
prompt: "Approve RCASD plan?"
|
|
73
|
+
policy: conservative
|
|
74
|
+
depends:
|
|
75
|
+
- lint
|
|
76
|
+
- id: decompose
|
|
77
|
+
type: agentic
|
|
78
|
+
skill: ct-epic-architect
|
|
79
|
+
depends:
|
|
80
|
+
- approve
|
|
81
|
+
on_failure:
|
|
82
|
+
max_iterations: 3
|
|
83
|
+
escalate: true
|
|
84
|
+
edges:
|
|
85
|
+
- from: research
|
|
86
|
+
to: constraint
|
|
87
|
+
contract:
|
|
88
|
+
requires: ["topic"]
|
|
89
|
+
ensures: ["plan"]
|
|
90
|
+
- from: constraint
|
|
91
|
+
to: lint
|
|
92
|
+
- from: lint
|
|
93
|
+
to: approve
|
|
94
|
+
- from: approve
|
|
95
|
+
to: decompose
|
|
96
|
+
error_handlers:
|
|
97
|
+
- on: agentic_timeout
|
|
98
|
+
action: inject_hint
|
|
99
|
+
message: "retry with narrower scope"
|
|
100
|
+
- on: iteration_cap_exceeded
|
|
101
|
+
action: hitl_escalate
|
|
102
|
+
`;
|
|
103
|
+
const { definition } = parsePlaybook(yaml);
|
|
104
|
+
expect(definition.nodes).toHaveLength(5);
|
|
105
|
+
expect(definition.edges).toHaveLength(4);
|
|
106
|
+
expect(definition.inputs).toHaveLength(2);
|
|
107
|
+
expect(definition.error_handlers).toHaveLength(2);
|
|
108
|
+
|
|
109
|
+
const research = definition.nodes.find((n) => n.id === 'research');
|
|
110
|
+
expect(research?.type).toBe('agentic');
|
|
111
|
+
if (research?.type === 'agentic') {
|
|
112
|
+
expect(research.skill).toBe('ct-research-agent');
|
|
113
|
+
expect(research.role).toBe('lead');
|
|
114
|
+
expect(research.inputs?.topic).toBe('{{inputs.epicId}}');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lint = definition.nodes.find((n) => n.id === 'lint');
|
|
118
|
+
expect(lint?.type).toBe('deterministic');
|
|
119
|
+
if (lint?.type === 'deterministic') {
|
|
120
|
+
expect(lint.command).toBe('pnpm');
|
|
121
|
+
expect(lint.args).toEqual(['biome', 'ci', '.']);
|
|
122
|
+
expect(lint.timeout_ms).toBe(60000);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const approve = definition.nodes.find((n) => n.id === 'approve');
|
|
126
|
+
expect(approve?.type).toBe('approval');
|
|
127
|
+
if (approve?.type === 'approval') {
|
|
128
|
+
expect(approve.prompt).toBe('Approve RCASD plan?');
|
|
129
|
+
expect(approve.policy).toBe('conservative');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const decompose = definition.nodes.find((n) => n.id === 'decompose');
|
|
133
|
+
expect(decompose?.on_failure?.max_iterations).toBe(3);
|
|
134
|
+
expect(decompose?.on_failure?.escalate).toBe(true);
|
|
135
|
+
|
|
136
|
+
const firstEdge = definition.edges[0];
|
|
137
|
+
expect(firstEdge?.contract?.requires).toEqual(['topic']);
|
|
138
|
+
expect(firstEdge?.contract?.ensures).toEqual(['plan']);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('version validation', () => {
|
|
143
|
+
it('rejects missing version', () => {
|
|
144
|
+
const yaml = `
|
|
145
|
+
name: nope
|
|
146
|
+
nodes:
|
|
147
|
+
- id: a
|
|
148
|
+
type: agentic
|
|
149
|
+
skill: x
|
|
150
|
+
`;
|
|
151
|
+
expect(() => parsePlaybook(yaml)).toThrow(PlaybookParseError);
|
|
152
|
+
expect(() => parsePlaybook(yaml)).toThrow(/Unsupported version/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects unsupported version "2.0"', () => {
|
|
156
|
+
const yaml = `
|
|
157
|
+
version: "2.0"
|
|
158
|
+
name: nope
|
|
159
|
+
nodes:
|
|
160
|
+
- id: a
|
|
161
|
+
type: agentic
|
|
162
|
+
skill: x
|
|
163
|
+
`;
|
|
164
|
+
expect(() => parsePlaybook(yaml)).toThrow(/Unsupported version: "2\.0"/);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('structural validation', () => {
|
|
169
|
+
it('rejects YAML that is not a top-level map', () => {
|
|
170
|
+
expect(() => parsePlaybook('- just\n- a list\n')).toThrow(/top level/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects YAML syntax errors', () => {
|
|
174
|
+
expect(() => parsePlaybook('version: "1.0"\n : broken')).toThrow(/YAML syntax error/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('rejects empty name', () => {
|
|
178
|
+
const yaml = `
|
|
179
|
+
version: "1.0"
|
|
180
|
+
name: ""
|
|
181
|
+
nodes:
|
|
182
|
+
- id: a
|
|
183
|
+
type: agentic
|
|
184
|
+
skill: x
|
|
185
|
+
`;
|
|
186
|
+
expect(() => parsePlaybook(yaml)).toThrow(/name must be a non-empty string/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('rejects empty nodes array', () => {
|
|
190
|
+
const yaml = `
|
|
191
|
+
version: "1.0"
|
|
192
|
+
name: x
|
|
193
|
+
nodes: []
|
|
194
|
+
`;
|
|
195
|
+
expect(() => parsePlaybook(yaml)).toThrow(/nodes must be a non-empty array/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('rejects duplicate node ids', () => {
|
|
199
|
+
const yaml = `
|
|
200
|
+
version: "1.0"
|
|
201
|
+
name: dup
|
|
202
|
+
nodes:
|
|
203
|
+
- id: same
|
|
204
|
+
type: agentic
|
|
205
|
+
skill: a
|
|
206
|
+
- id: same
|
|
207
|
+
type: agentic
|
|
208
|
+
skill: b
|
|
209
|
+
`;
|
|
210
|
+
expect(() => parsePlaybook(yaml)).toThrow(/duplicate node id: same/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('rejects edge referencing unknown node (from)', () => {
|
|
214
|
+
const yaml = `
|
|
215
|
+
version: "1.0"
|
|
216
|
+
name: e1
|
|
217
|
+
nodes:
|
|
218
|
+
- id: a
|
|
219
|
+
type: agentic
|
|
220
|
+
skill: x
|
|
221
|
+
edges:
|
|
222
|
+
- from: ghost
|
|
223
|
+
to: a
|
|
224
|
+
`;
|
|
225
|
+
expect(() => parsePlaybook(yaml)).toThrow(/from references unknown node ghost/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('rejects edge referencing unknown node (to)', () => {
|
|
229
|
+
const yaml = `
|
|
230
|
+
version: "1.0"
|
|
231
|
+
name: e2
|
|
232
|
+
nodes:
|
|
233
|
+
- id: a
|
|
234
|
+
type: agentic
|
|
235
|
+
skill: x
|
|
236
|
+
edges:
|
|
237
|
+
- from: a
|
|
238
|
+
to: missing
|
|
239
|
+
`;
|
|
240
|
+
expect(() => parsePlaybook(yaml)).toThrow(/to references unknown node missing/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('rejects cycle (A -> B -> A)', () => {
|
|
244
|
+
const yaml = `
|
|
245
|
+
version: "1.0"
|
|
246
|
+
name: cyc
|
|
247
|
+
nodes:
|
|
248
|
+
- id: A
|
|
249
|
+
type: agentic
|
|
250
|
+
skill: x
|
|
251
|
+
- id: B
|
|
252
|
+
type: agentic
|
|
253
|
+
skill: y
|
|
254
|
+
edges:
|
|
255
|
+
- from: A
|
|
256
|
+
to: B
|
|
257
|
+
- from: B
|
|
258
|
+
to: A
|
|
259
|
+
`;
|
|
260
|
+
expect(() => parsePlaybook(yaml)).toThrow(/cycle/);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('rejects cycle via depends self-loop', () => {
|
|
264
|
+
const yaml = `
|
|
265
|
+
version: "1.0"
|
|
266
|
+
name: cyc2
|
|
267
|
+
nodes:
|
|
268
|
+
- id: A
|
|
269
|
+
type: agentic
|
|
270
|
+
skill: x
|
|
271
|
+
depends:
|
|
272
|
+
- B
|
|
273
|
+
- id: B
|
|
274
|
+
type: agentic
|
|
275
|
+
skill: y
|
|
276
|
+
depends:
|
|
277
|
+
- A
|
|
278
|
+
`;
|
|
279
|
+
expect(() => parsePlaybook(yaml)).toThrow(/cycle/);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('node-kind validation', () => {
|
|
284
|
+
it('rejects deterministic node missing command', () => {
|
|
285
|
+
const yaml = `
|
|
286
|
+
version: "1.0"
|
|
287
|
+
name: d1
|
|
288
|
+
nodes:
|
|
289
|
+
- id: n
|
|
290
|
+
type: deterministic
|
|
291
|
+
args: [x]
|
|
292
|
+
`;
|
|
293
|
+
expect(() => parsePlaybook(yaml)).toThrow(/must have a non-empty 'command'/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('rejects approval node missing prompt', () => {
|
|
297
|
+
const yaml = `
|
|
298
|
+
version: "1.0"
|
|
299
|
+
name: ap
|
|
300
|
+
nodes:
|
|
301
|
+
- id: n
|
|
302
|
+
type: approval
|
|
303
|
+
`;
|
|
304
|
+
expect(() => parsePlaybook(yaml)).toThrow(/must have a non-empty 'prompt'/);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('rejects agentic node missing both skill and agent', () => {
|
|
308
|
+
const yaml = `
|
|
309
|
+
version: "1.0"
|
|
310
|
+
name: ag
|
|
311
|
+
nodes:
|
|
312
|
+
- id: n
|
|
313
|
+
type: agentic
|
|
314
|
+
`;
|
|
315
|
+
expect(() => parsePlaybook(yaml)).toThrow(/must define at least one of 'skill' or 'agent'/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('accepts agentic node with only agent (no skill)', () => {
|
|
319
|
+
const yaml = `
|
|
320
|
+
version: "1.0"
|
|
321
|
+
name: ag2
|
|
322
|
+
nodes:
|
|
323
|
+
- id: n
|
|
324
|
+
type: agentic
|
|
325
|
+
agent: ct-validator
|
|
326
|
+
`;
|
|
327
|
+
const { definition } = parsePlaybook(yaml);
|
|
328
|
+
expect(definition.nodes).toHaveLength(1);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('rejects unknown node type', () => {
|
|
332
|
+
const yaml = `
|
|
333
|
+
version: "1.0"
|
|
334
|
+
name: bad
|
|
335
|
+
nodes:
|
|
336
|
+
- id: n
|
|
337
|
+
type: whoops
|
|
338
|
+
`;
|
|
339
|
+
expect(() => parsePlaybook(yaml)).toThrow(/one of agentic \| deterministic \| approval/);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('bounds checks', () => {
|
|
344
|
+
it('rejects max_iterations > 10', () => {
|
|
345
|
+
const yaml = `
|
|
346
|
+
version: "1.0"
|
|
347
|
+
name: cap
|
|
348
|
+
nodes:
|
|
349
|
+
- id: n
|
|
350
|
+
type: agentic
|
|
351
|
+
skill: x
|
|
352
|
+
on_failure:
|
|
353
|
+
max_iterations: 11
|
|
354
|
+
`;
|
|
355
|
+
expect(() => parsePlaybook(yaml)).toThrow(/max_iterations must be 0\.\.10 \(got 11\)/);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('rejects max_iterations < 0', () => {
|
|
359
|
+
const yaml = `
|
|
360
|
+
version: "1.0"
|
|
361
|
+
name: cap2
|
|
362
|
+
nodes:
|
|
363
|
+
- id: n
|
|
364
|
+
type: agentic
|
|
365
|
+
skill: x
|
|
366
|
+
on_failure:
|
|
367
|
+
max_iterations: -1
|
|
368
|
+
`;
|
|
369
|
+
expect(() => parsePlaybook(yaml)).toThrow(/max_iterations must be 0\.\.10 \(got -1\)/);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('accepts max_iterations at the upper bound (10)', () => {
|
|
373
|
+
const yaml = `
|
|
374
|
+
version: "1.0"
|
|
375
|
+
name: cap3
|
|
376
|
+
nodes:
|
|
377
|
+
- id: n
|
|
378
|
+
type: agentic
|
|
379
|
+
skill: x
|
|
380
|
+
on_failure:
|
|
381
|
+
max_iterations: 10
|
|
382
|
+
`;
|
|
383
|
+
const { definition } = parsePlaybook(yaml);
|
|
384
|
+
expect(definition.nodes[0]?.on_failure?.max_iterations).toBe(10);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('rejects depends pointing to unknown node', () => {
|
|
388
|
+
const yaml = `
|
|
389
|
+
version: "1.0"
|
|
390
|
+
name: dep
|
|
391
|
+
nodes:
|
|
392
|
+
- id: a
|
|
393
|
+
type: agentic
|
|
394
|
+
skill: x
|
|
395
|
+
depends:
|
|
396
|
+
- missing_one
|
|
397
|
+
`;
|
|
398
|
+
expect(() => parsePlaybook(yaml)).toThrow(/depends on unknown node missing_one/);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe('determinism', () => {
|
|
403
|
+
it('produces deterministic sourceHash (same input -> same hash)', () => {
|
|
404
|
+
const yaml = `
|
|
405
|
+
version: "1.0"
|
|
406
|
+
name: stable
|
|
407
|
+
nodes:
|
|
408
|
+
- id: a
|
|
409
|
+
type: agentic
|
|
410
|
+
skill: x
|
|
411
|
+
`;
|
|
412
|
+
const h1 = parsePlaybook(yaml).sourceHash;
|
|
413
|
+
const h2 = parsePlaybook(yaml).sourceHash;
|
|
414
|
+
expect(h1).toBe(h2);
|
|
415
|
+
expect(h1).toMatch(/^[a-f0-9]{64}$/);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('produces different sourceHash for different input', () => {
|
|
419
|
+
const a = parsePlaybook(`
|
|
420
|
+
version: "1.0"
|
|
421
|
+
name: a
|
|
422
|
+
nodes:
|
|
423
|
+
- id: n
|
|
424
|
+
type: agentic
|
|
425
|
+
skill: x
|
|
426
|
+
`).sourceHash;
|
|
427
|
+
const b = parsePlaybook(`
|
|
428
|
+
version: "1.0"
|
|
429
|
+
name: b
|
|
430
|
+
nodes:
|
|
431
|
+
- id: n
|
|
432
|
+
type: agentic
|
|
433
|
+
skill: x
|
|
434
|
+
`).sourceHash;
|
|
435
|
+
expect(a).not.toBe(b);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
describe('PlaybookParseError shape', () => {
|
|
440
|
+
it('carries code + exitCode + field', () => {
|
|
441
|
+
try {
|
|
442
|
+
parsePlaybook(
|
|
443
|
+
'version: "9.9"\nname: x\nnodes:\n - id: n\n type: agentic\n skill: y',
|
|
444
|
+
);
|
|
445
|
+
throw new Error('should have thrown');
|
|
446
|
+
} catch (err) {
|
|
447
|
+
expect(err).toBeInstanceOf(PlaybookParseError);
|
|
448
|
+
const e = err as PlaybookParseError;
|
|
449
|
+
expect(e.code).toBe('E_PLAYBOOK_PARSE');
|
|
450
|
+
expect(e.exitCode).toBe(70);
|
|
451
|
+
expect(e.field).toBe('version');
|
|
452
|
+
expect(e.value).toBe('9.9');
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W4-9 HITL auto-policy tests. Pure-function — no DB, no mocks needed.
|
|
3
|
+
*
|
|
4
|
+
* @task T889 / T908 / W4-9
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { DEFAULT_POLICY_RULES, evaluatePolicy, type PolicyRule } from '../policy.js';
|
|
9
|
+
|
|
10
|
+
describe('W4-9: evaluatePolicy — conservative defaults', () => {
|
|
11
|
+
it('npm publish → require-human, reason=publish', () => {
|
|
12
|
+
const r = evaluatePolicy('npm publish --access public');
|
|
13
|
+
expect(r.action).toBe('require-human');
|
|
14
|
+
expect(r.reason).toBe('publish');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('pnpm test → auto-approve, reason=safe-qa-tool', () => {
|
|
18
|
+
const r = evaluatePolicy('pnpm test');
|
|
19
|
+
expect(r.action).toBe('auto-approve');
|
|
20
|
+
expect(r.reason).toBe('safe-qa-tool');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rm -rf / → require-human, reason=destructive', () => {
|
|
24
|
+
const r = evaluatePolicy('rm -rf /');
|
|
25
|
+
expect(r.action).toBe('require-human');
|
|
26
|
+
expect(r.reason).toBe('destructive');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('cleo show T123 → auto-approve, reason=safe-cleo-read', () => {
|
|
30
|
+
const r = evaluatePolicy('cleo show T123');
|
|
31
|
+
expect(r.action).toBe('auto-approve');
|
|
32
|
+
expect(r.reason).toBe('safe-cleo-read');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('git push origin main → require-human, reason=push', () => {
|
|
36
|
+
const r = evaluatePolicy('git push origin main');
|
|
37
|
+
expect(r.action).toBe('require-human');
|
|
38
|
+
expect(r.reason).toBe('push');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('git tag v1.0.0 → require-human, reason=tag', () => {
|
|
42
|
+
const r = evaluatePolicy('git tag v1.0.0');
|
|
43
|
+
expect(r.action).toBe('require-human');
|
|
44
|
+
expect(r.reason).toBe('tag');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('gh release create → require-human, reason=release', () => {
|
|
48
|
+
const r = evaluatePolicy('gh release create v2026.4.86 --notes "..."');
|
|
49
|
+
expect(r.action).toBe('require-human');
|
|
50
|
+
expect(r.reason).toBe('release');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('unknown command → require-human, reason=default', () => {
|
|
54
|
+
const r = evaluatePolicy('some-random-binary --weird-flag');
|
|
55
|
+
expect(r.action).toBe('require-human');
|
|
56
|
+
expect(r.reason).toBe('default');
|
|
57
|
+
expect(r.matchedPattern).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('curl https://api.example.com → require-human, reason=external-api', () => {
|
|
61
|
+
const r = evaluatePolicy('curl -X POST https://api.example.com/v1/webhook');
|
|
62
|
+
expect(r.action).toBe('require-human');
|
|
63
|
+
expect(r.reason).toBe('external-api');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('require-human rules cannot be bypassed by a later auto-approve override', () => {
|
|
67
|
+
const custom: PolicyRule[] = [
|
|
68
|
+
{ pattern: /git/, action: 'auto-approve', reason: 'custom-git-allow' },
|
|
69
|
+
...DEFAULT_POLICY_RULES,
|
|
70
|
+
];
|
|
71
|
+
const r = evaluatePolicy('git push origin main', custom);
|
|
72
|
+
expect(r.action).toBe('require-human');
|
|
73
|
+
expect(r.reason).toBe('push');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('pnpm biome + pnpm tsc also auto-approve as safe-qa-tool', () => {
|
|
77
|
+
expect(evaluatePolicy('pnpm biome ci .').action).toBe('auto-approve');
|
|
78
|
+
expect(evaluatePolicy('pnpm tsc --noEmit').action).toBe('auto-approve');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('exposes matchedPattern on hits', () => {
|
|
82
|
+
const r = evaluatePolicy('pnpm publish');
|
|
83
|
+
expect(r.action).toBe('require-human');
|
|
84
|
+
expect(r.matchedPattern).toBeDefined();
|
|
85
|
+
expect(typeof r.matchedPattern).toBe('string');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('DEFAULT_POLICY_RULES is frozen so callers cannot mutate the defaults', () => {
|
|
89
|
+
expect(Object.isFrozen(DEFAULT_POLICY_RULES)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|