@cleocode/contracts 2026.5.78 → 2026.5.79

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,501 @@
1
+ /**
2
+ * Zod schema tests for the Release Plan envelope (T9527).
3
+ *
4
+ * Validates the canonical SPEC-T9345 §6.1 shape against the Zod schema defined
5
+ * in {@link ./plan.ts}. Coverage targets:
6
+ *
7
+ * - Happy-path parse round-trip
8
+ * - Each enum field rejects unknown literal values
9
+ * - Required fields cannot be missing
10
+ * - `evidenceAtoms` stays permissive (verb-level enforcement, NOT contract)
11
+ * - All 8 `status` FSM values parse
12
+ * - `meta` is forward-compat (unknown keys pass through via `.catchall`)
13
+ *
14
+ * @task T9527
15
+ */
16
+
17
+ import { describe, expect, it } from 'vitest';
18
+ import {
19
+ GATE_NAME,
20
+ GATE_STATUS,
21
+ IMPACT,
22
+ PLATFORM_TUPLE,
23
+ PUBLISHER,
24
+ parseReleasePlan,
25
+ RELEASE_CHANNEL,
26
+ RELEASE_KIND,
27
+ RELEASE_PLAN_SCHEMA_URL,
28
+ RELEASE_PLAN_SCHEMA_VERSION,
29
+ RELEASE_SCHEME,
30
+ RELEASE_STATUS,
31
+ RESOLVED_SOURCE,
32
+ ReleaseGateSchema,
33
+ type ReleasePlan,
34
+ ReleasePlanChangelogSchema,
35
+ ReleasePlanMetaSchema,
36
+ ReleasePlanSchema,
37
+ ReleasePlanTaskSchema,
38
+ ReleasePlatformMatrixEntrySchema,
39
+ ReleasePreflightSummarySchema,
40
+ safeParseReleasePlan,
41
+ TASK_KIND,
42
+ } from './plan.js';
43
+
44
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Returns a fresh deep clone of a minimal-but-complete valid plan. Each test
48
+ * mutates its own copy to test field-level rejection paths.
49
+ */
50
+ function makeValidPlan(): ReleasePlan {
51
+ return {
52
+ $schema: RELEASE_PLAN_SCHEMA_URL,
53
+ version: 'v2026.6.0',
54
+ resolvedVersion: 'v2026.6.0',
55
+ suffixApplied: false,
56
+ scheme: 'calver',
57
+ channel: 'latest',
58
+ epicId: 'T9999',
59
+ releaseKind: 'regular',
60
+ createdAt: '2026-06-01T12:00:00Z',
61
+ createdBy: 'cleo-prime',
62
+ previousVersion: 'v2026.5.74',
63
+ previousTag: 'v2026.5.74',
64
+ previousShippedAt: '2026-05-15T08:00:00Z',
65
+ tasks: [
66
+ {
67
+ id: 'T10001',
68
+ kind: 'feat',
69
+ impact: 'minor',
70
+ userFacingSummary: 'Add release plan schema',
71
+ evidenceAtoms: ['commit:abc123', 'test-run:vitest.json', 'tool:lint'],
72
+ ivtrPhaseAtPlan: 'released',
73
+ epicAncestor: 'T9999',
74
+ },
75
+ ],
76
+ changelog: {
77
+ features: ['T10001'],
78
+ fixes: [],
79
+ chores: [],
80
+ breaking: [],
81
+ },
82
+ gates: [
83
+ {
84
+ name: 'test',
85
+ atom: 'tool:test',
86
+ status: 'passed',
87
+ lastVerifiedAt: '2026-06-01T11:50:00Z',
88
+ resolvedCommand: 'pnpm run test',
89
+ resolvedSource: 'project-context',
90
+ },
91
+ ],
92
+ platformMatrix: [
93
+ {
94
+ platform: 'linux-x64',
95
+ publisher: 'npm',
96
+ package: '@cleocode/cleo',
97
+ smoke: true,
98
+ },
99
+ ],
100
+ preflightSummary: {
101
+ esbuildExternalsDrift: false,
102
+ lockfileDrift: false,
103
+ epicCompletenessClean: true,
104
+ doubleListingClean: true,
105
+ preflightWarnings: [],
106
+ },
107
+ workflowRunUrl: null,
108
+ prUrl: null,
109
+ mergeCommitSha: null,
110
+ status: 'planned',
111
+ meta: {
112
+ firstEverRelease: false,
113
+ unresolvedTools: [],
114
+ archetype: 'monorepo-w-workspaces',
115
+ },
116
+ };
117
+ }
118
+
119
+ // ─── Constant tuples ─────────────────────────────────────────────────────────
120
+
121
+ describe('Release plan constant tuples', () => {
122
+ it('exports an 8-state status FSM', () => {
123
+ expect(RELEASE_STATUS).toEqual([
124
+ 'planned',
125
+ 'pr-opened',
126
+ 'pr-merged',
127
+ 'published',
128
+ 'reconciled',
129
+ 'rolled_back',
130
+ 'failed',
131
+ 'cancelled',
132
+ ]);
133
+ expect(RELEASE_STATUS).toHaveLength(8);
134
+ });
135
+
136
+ it('exports the 4-channel set (latest|beta|alpha|rc)', () => {
137
+ expect(new Set(RELEASE_CHANNEL)).toEqual(new Set(['latest', 'beta', 'alpha', 'rc']));
138
+ });
139
+
140
+ it('exports the 3-scheme set including calver-suffix', () => {
141
+ expect(RELEASE_SCHEME).toContain('calver');
142
+ expect(RELEASE_SCHEME).toContain('semver');
143
+ expect(RELEASE_SCHEME).toContain('calver-suffix');
144
+ });
145
+
146
+ it('exports release-kind variants', () => {
147
+ expect(new Set(RELEASE_KIND)).toEqual(new Set(['regular', 'hotfix', 'prerelease']));
148
+ });
149
+
150
+ it('exports the 4-status gate enum', () => {
151
+ expect(new Set(GATE_STATUS)).toEqual(new Set(['passed', 'failed', 'skipped', 'unresolved']));
152
+ });
153
+
154
+ it('exports canonical gate names (R-310)', () => {
155
+ expect(GATE_NAME).toContain('test');
156
+ expect(GATE_NAME).toContain('build');
157
+ expect(GATE_NAME).toContain('lint');
158
+ expect(GATE_NAME).toContain('typecheck');
159
+ expect(GATE_NAME).toContain('audit');
160
+ expect(GATE_NAME).toContain('security-scan');
161
+ });
162
+
163
+ it('exports platform tuples aligned with T1737', () => {
164
+ expect(PLATFORM_TUPLE).toContain('linux-x64');
165
+ expect(PLATFORM_TUPLE).toContain('linux-arm64');
166
+ expect(PLATFORM_TUPLE).toContain('macos-x64');
167
+ expect(PLATFORM_TUPLE).toContain('macos-arm64');
168
+ expect(PLATFORM_TUPLE).toContain('windows-x64');
169
+ expect(PLATFORM_TUPLE).toContain('any');
170
+ });
171
+
172
+ it('exports publisher backends covering npm/cargo/docker/pypi/github-release/binary', () => {
173
+ expect(new Set(PUBLISHER)).toEqual(
174
+ new Set(['npm', 'cargo', 'docker', 'pypi', 'github-release', 'binary']),
175
+ );
176
+ });
177
+
178
+ it('exports task-kind classification', () => {
179
+ expect(TASK_KIND).toContain('feat');
180
+ expect(TASK_KIND).toContain('fix');
181
+ expect(TASK_KIND).toContain('hotfix');
182
+ expect(TASK_KIND).toContain('breaking');
183
+ });
184
+
185
+ it('exports SemVer impact tuple', () => {
186
+ expect(IMPACT).toEqual(['major', 'minor', 'patch']);
187
+ });
188
+
189
+ it('exports resolved-source attribution per ADR-061', () => {
190
+ expect(RESOLVED_SOURCE).toEqual(['project-context', 'language-default', 'legacy-alias']);
191
+ });
192
+
193
+ it('exposes a stable schema version + URL', () => {
194
+ expect(RELEASE_PLAN_SCHEMA_VERSION).toBe('1.0.0');
195
+ expect(RELEASE_PLAN_SCHEMA_URL).toBe('https://cleocode.io/schemas/release-plan/v1.json');
196
+ });
197
+ });
198
+
199
+ // ─── Happy path ──────────────────────────────────────────────────────────────
200
+
201
+ describe('ReleasePlanSchema — happy path', () => {
202
+ it('parses a complete valid plan', () => {
203
+ const plan = parseReleasePlan(makeValidPlan());
204
+ expect(plan.version).toBe('v2026.6.0');
205
+ expect(plan.status).toBe('planned');
206
+ expect(plan.tasks).toHaveLength(1);
207
+ expect(plan.tasks[0]?.id).toBe('T10001');
208
+ expect(plan.platformMatrix[0]?.publisher).toBe('npm');
209
+ });
210
+
211
+ it('safeParse returns success for a valid plan', () => {
212
+ const result = safeParseReleasePlan(makeValidPlan());
213
+ expect(result.success).toBe(true);
214
+ });
215
+
216
+ it('round-trips through JSON.stringify / parse', () => {
217
+ const original = makeValidPlan();
218
+ const serialized = JSON.stringify(original);
219
+ const reparsed = parseReleasePlan(JSON.parse(serialized));
220
+ expect(reparsed).toEqual(original);
221
+ });
222
+
223
+ it('accepts a first-ever release with null previousVersion + meta.firstEverRelease', () => {
224
+ const plan = makeValidPlan();
225
+ plan.previousVersion = null;
226
+ plan.previousTag = null;
227
+ plan.previousShippedAt = null;
228
+ plan.meta = { firstEverRelease: true };
229
+ expect(() => parseReleasePlan(plan)).not.toThrow();
230
+ });
231
+ });
232
+
233
+ // ─── Status FSM coverage ─────────────────────────────────────────────────────
234
+
235
+ describe('ReleasePlanSchema — status FSM', () => {
236
+ it.each(RELEASE_STATUS)('accepts status = "%s"', (status) => {
237
+ const plan = makeValidPlan();
238
+ plan.status = status;
239
+ expect(() => parseReleasePlan(plan)).not.toThrow();
240
+ });
241
+
242
+ it('rejects unknown status literals', () => {
243
+ const plan = { ...makeValidPlan(), status: 'in-flight' };
244
+ expect(() => parseReleasePlan(plan)).toThrow();
245
+ });
246
+ });
247
+
248
+ // ─── Enum rejection per field ────────────────────────────────────────────────
249
+
250
+ describe('ReleasePlanSchema — enum rejection', () => {
251
+ it('rejects unknown channel', () => {
252
+ const plan = { ...makeValidPlan(), channel: 'canary' };
253
+ expect(() => parseReleasePlan(plan)).toThrow();
254
+ });
255
+
256
+ it('rejects unknown scheme', () => {
257
+ const plan = { ...makeValidPlan(), scheme: 'rolling' };
258
+ expect(() => parseReleasePlan(plan)).toThrow();
259
+ });
260
+
261
+ it('rejects unknown releaseKind', () => {
262
+ const plan = { ...makeValidPlan(), releaseKind: 'patch' };
263
+ expect(() => parseReleasePlan(plan)).toThrow();
264
+ });
265
+
266
+ it('rejects unknown task kind', () => {
267
+ const plan = makeValidPlan();
268
+ plan.tasks[0] = { ...plan.tasks[0]!, kind: 'misc' as never };
269
+ expect(() => parseReleasePlan(plan)).toThrow();
270
+ });
271
+
272
+ it('rejects unknown impact', () => {
273
+ const plan = makeValidPlan();
274
+ plan.tasks[0] = { ...plan.tasks[0]!, impact: 'huge' as never };
275
+ expect(() => parseReleasePlan(plan)).toThrow();
276
+ });
277
+
278
+ it('rejects unknown gate name', () => {
279
+ const plan = makeValidPlan();
280
+ plan.gates[0] = { ...plan.gates[0]!, name: 'fmt' as never };
281
+ expect(() => parseReleasePlan(plan)).toThrow();
282
+ });
283
+
284
+ it('rejects unknown gate status', () => {
285
+ const plan = makeValidPlan();
286
+ plan.gates[0] = { ...plan.gates[0]!, status: 'flaky' as never };
287
+ expect(() => parseReleasePlan(plan)).toThrow();
288
+ });
289
+
290
+ it('rejects unknown platform tuple', () => {
291
+ const plan = makeValidPlan();
292
+ plan.platformMatrix[0] = { ...plan.platformMatrix[0]!, platform: 'haiku-x64' as never };
293
+ expect(() => parseReleasePlan(plan)).toThrow();
294
+ });
295
+
296
+ it('rejects unknown publisher', () => {
297
+ const plan = makeValidPlan();
298
+ plan.platformMatrix[0] = { ...plan.platformMatrix[0]!, publisher: 'gemfury' as never };
299
+ expect(() => parseReleasePlan(plan)).toThrow();
300
+ });
301
+
302
+ it('rejects unknown resolvedSource', () => {
303
+ const plan = makeValidPlan();
304
+ plan.gates[0] = { ...plan.gates[0]!, resolvedSource: 'env-override' as never };
305
+ expect(() => parseReleasePlan(plan)).toThrow();
306
+ });
307
+ });
308
+
309
+ // ─── Required-field rejection ────────────────────────────────────────────────
310
+
311
+ describe('ReleasePlanSchema — required fields', () => {
312
+ it('rejects missing version', () => {
313
+ const plan = makeValidPlan() as Partial<ReleasePlan>;
314
+ delete plan.version;
315
+ expect(() => parseReleasePlan(plan)).toThrow();
316
+ });
317
+
318
+ it('rejects missing epicId', () => {
319
+ const plan = makeValidPlan() as Partial<ReleasePlan>;
320
+ delete plan.epicId;
321
+ expect(() => parseReleasePlan(plan)).toThrow();
322
+ });
323
+
324
+ it('rejects empty epicId string', () => {
325
+ const plan = { ...makeValidPlan(), epicId: '' };
326
+ expect(() => parseReleasePlan(plan)).toThrow();
327
+ });
328
+
329
+ it('rejects missing tasks array', () => {
330
+ const plan = makeValidPlan() as Partial<ReleasePlan>;
331
+ delete plan.tasks;
332
+ expect(() => parseReleasePlan(plan)).toThrow();
333
+ });
334
+
335
+ it('rejects missing changelog object', () => {
336
+ const plan = makeValidPlan() as Partial<ReleasePlan>;
337
+ delete plan.changelog;
338
+ expect(() => parseReleasePlan(plan)).toThrow();
339
+ });
340
+
341
+ it('rejects missing preflightSummary', () => {
342
+ const plan = makeValidPlan() as Partial<ReleasePlan>;
343
+ delete plan.preflightSummary;
344
+ expect(() => parseReleasePlan(plan)).toThrow();
345
+ });
346
+
347
+ it('rejects missing epicAncestor on a task row', () => {
348
+ const plan = makeValidPlan();
349
+ const taskWithoutAncestor = { ...plan.tasks[0]! } as Partial<ReleasePlan['tasks'][number]>;
350
+ delete taskWithoutAncestor.epicAncestor;
351
+ plan.tasks[0] = taskWithoutAncestor as ReleasePlan['tasks'][number];
352
+ expect(() => parseReleasePlan(plan)).toThrow();
353
+ });
354
+
355
+ it('rejects malformed createdAt timestamp', () => {
356
+ const plan = { ...makeValidPlan(), createdAt: 'yesterday' };
357
+ expect(() => parseReleasePlan(plan)).toThrow();
358
+ });
359
+ });
360
+
361
+ // ─── Evidence atoms (contract permissive — R-301 verb-enforced) ─────────────
362
+
363
+ describe('ReleasePlanTaskSchema — evidenceAtoms', () => {
364
+ it('accepts a string[] for evidenceAtoms', () => {
365
+ const result = ReleasePlanTaskSchema.parse({
366
+ id: 'T1',
367
+ kind: 'feat',
368
+ impact: 'patch',
369
+ userFacingSummary: '',
370
+ evidenceAtoms: ['commit:abc'],
371
+ epicAncestor: 'E1',
372
+ });
373
+ expect(result.evidenceAtoms).toEqual(['commit:abc']);
374
+ });
375
+
376
+ it('permits an empty evidenceAtoms array at the contract layer (R-301 is verb-enforced)', () => {
377
+ const result = ReleasePlanTaskSchema.safeParse({
378
+ id: 'T1',
379
+ kind: 'feat',
380
+ impact: 'patch',
381
+ userFacingSummary: '',
382
+ evidenceAtoms: [],
383
+ epicAncestor: 'E1',
384
+ });
385
+ expect(result.success).toBe(true);
386
+ });
387
+
388
+ it('rejects non-array evidenceAtoms', () => {
389
+ const result = ReleasePlanTaskSchema.safeParse({
390
+ id: 'T1',
391
+ kind: 'feat',
392
+ impact: 'patch',
393
+ userFacingSummary: '',
394
+ evidenceAtoms: 'commit:abc',
395
+ epicAncestor: 'E1',
396
+ });
397
+ expect(result.success).toBe(false);
398
+ });
399
+
400
+ it('rejects empty-string atom entries (NonEmptyString)', () => {
401
+ const result = ReleasePlanTaskSchema.safeParse({
402
+ id: 'T1',
403
+ kind: 'feat',
404
+ impact: 'patch',
405
+ userFacingSummary: '',
406
+ evidenceAtoms: [''],
407
+ epicAncestor: 'E1',
408
+ });
409
+ expect(result.success).toBe(false);
410
+ });
411
+ });
412
+
413
+ // ─── meta forward-compat ─────────────────────────────────────────────────────
414
+
415
+ describe('ReleasePlanMetaSchema — forward compatibility', () => {
416
+ it('preserves unknown keys via catchall', () => {
417
+ const result = ReleasePlanMetaSchema.parse({
418
+ firstEverRelease: false,
419
+ futureField: 'someValue',
420
+ anotherUnknown: { nested: true },
421
+ });
422
+ expect((result as Record<string, unknown>).futureField).toBe('someValue');
423
+ expect((result as Record<string, unknown>).anotherUnknown).toEqual({ nested: true });
424
+ });
425
+
426
+ it('still validates known fields strictly', () => {
427
+ const result = ReleasePlanMetaSchema.safeParse({
428
+ firstEverRelease: 'yes',
429
+ });
430
+ expect(result.success).toBe(false);
431
+ });
432
+
433
+ it('accepts a fully-empty meta object', () => {
434
+ expect(() => ReleasePlanMetaSchema.parse({})).not.toThrow();
435
+ });
436
+ });
437
+
438
+ // ─── Nested-schema sanity ────────────────────────────────────────────────────
439
+
440
+ describe('Nested schemas — sanity', () => {
441
+ it('ReleaseGateSchema requires lastVerifiedAt to be ISO-8601', () => {
442
+ const result = ReleaseGateSchema.safeParse({
443
+ name: 'test',
444
+ atom: 'tool:test',
445
+ status: 'passed',
446
+ lastVerifiedAt: 'Tuesday',
447
+ });
448
+ expect(result.success).toBe(false);
449
+ });
450
+
451
+ it('ReleasePlatformMatrixEntrySchema applies smoke=true default when omitted', () => {
452
+ const result = ReleasePlatformMatrixEntrySchema.parse({
453
+ platform: 'any',
454
+ publisher: 'npm',
455
+ package: '@cleocode/lafs',
456
+ });
457
+ expect(result.smoke).toBe(true);
458
+ });
459
+
460
+ it('ReleasePreflightSummarySchema requires the four boolean preflight gates', () => {
461
+ const result = ReleasePreflightSummarySchema.safeParse({
462
+ esbuildExternalsDrift: false,
463
+ lockfileDrift: false,
464
+ epicCompletenessClean: true,
465
+ // doubleListingClean omitted
466
+ });
467
+ expect(result.success).toBe(false);
468
+ });
469
+
470
+ it('ReleasePlanChangelogSchema applies empty-array defaults for omitted buckets', () => {
471
+ const result = ReleasePlanChangelogSchema.parse({});
472
+ expect(result.features).toEqual([]);
473
+ expect(result.fixes).toEqual([]);
474
+ expect(result.chores).toEqual([]);
475
+ expect(result.breaking).toEqual([]);
476
+ });
477
+
478
+ it('ReleasePlanSchema accepts plans with no tasks (e.g. config-only releases)', () => {
479
+ const plan = makeValidPlan();
480
+ plan.tasks = [];
481
+ plan.changelog.features = [];
482
+ expect(() => parseReleasePlan(plan)).not.toThrow();
483
+ });
484
+
485
+ it('safeParseReleasePlan surfaces issues for bad enums', () => {
486
+ const result = safeParseReleasePlan({ ...makeValidPlan(), channel: 'invalid-channel' });
487
+ expect(result.success).toBe(false);
488
+ if (!result.success) {
489
+ expect(result.error.issues.length).toBeGreaterThan(0);
490
+ }
491
+ });
492
+ });
493
+
494
+ // ─── Schema export sanity ────────────────────────────────────────────────────
495
+
496
+ describe('ReleasePlanSchema — module surface', () => {
497
+ it('exports a Zod object schema at the top level', () => {
498
+ expect(typeof ReleasePlanSchema.parse).toBe('function');
499
+ expect(typeof ReleasePlanSchema.safeParse).toBe('function');
500
+ });
501
+ });