@cleocode/contracts 2026.4.70 → 2026.4.72
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/dist/__tests__/evidence-record.test.d.ts +14 -0
- package/dist/__tests__/evidence-record.test.d.ts.map +1 -0
- package/dist/__tests__/evidence-record.test.js +362 -0
- package/dist/__tests__/evidence-record.test.js.map +1 -0
- package/dist/__tests__/invariants.test.d.ts +19 -0
- package/dist/__tests__/invariants.test.d.ts.map +1 -0
- package/dist/__tests__/invariants.test.js +351 -0
- package/dist/__tests__/invariants.test.js.map +1 -0
- package/dist/acceptance-gate-schema.d.ts +81 -4
- package/dist/acceptance-gate-schema.d.ts.map +1 -1
- package/dist/acceptance-gate-schema.js +60 -1
- package/dist/acceptance-gate-schema.js.map +1 -1
- package/dist/acceptance-gate.d.ts +99 -2
- package/dist/acceptance-gate.d.ts.map +1 -1
- package/dist/evidence-record-schema.d.ts +202 -0
- package/dist/evidence-record-schema.d.ts.map +1 -0
- package/dist/evidence-record-schema.js +130 -0
- package/dist/evidence-record-schema.js.map +1 -0
- package/dist/evidence-record.d.ts +167 -0
- package/dist/evidence-record.d.ts.map +1 -0
- package/dist/evidence-record.js +21 -0
- package/dist/evidence-record.js.map +1 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/task-evidence.d.ts +251 -0
- package/dist/task-evidence.d.ts.map +1 -0
- package/dist/task-evidence.js +89 -0
- package/dist/task-evidence.js.map +1 -0
- package/package.json +8 -2
- package/schemas/acceptance-gate.schema.json +9 -0
- package/schemas/attachment.schema.json +9 -0
- package/schemas/gate-result-details.schema.json +9 -0
- package/schemas/gate-result.schema.json +9 -0
- package/schemas/manifest-v1.json +176 -0
- package/schemas/task-evidence.schema.json +9 -0
- package/schemas/task.schema.json +9 -0
- package/src/__tests__/evidence-record.test.ts +412 -0
- package/src/__tests__/invariants.test.ts +445 -0
- package/src/acceptance-gate-schema.ts +65 -1
- package/src/acceptance-gate.ts +108 -2
- package/src/evidence-record-schema.ts +158 -0
- package/src/evidence-record.ts +182 -0
- package/src/index.ts +65 -1
- package/src/task-evidence.ts +239 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract invariant tests for @cleocode/contracts.
|
|
3
|
+
*
|
|
4
|
+
* Asserts structural rules that must hold across ALL contracts:
|
|
5
|
+
*
|
|
6
|
+
* 1. No contract field named `notes`, `details`, or `text` is typed as bare
|
|
7
|
+
* `string` when a structured shape is possible.
|
|
8
|
+
* Whitelisted exceptions are documented with inline rationale.
|
|
9
|
+
*
|
|
10
|
+
* 2. All discriminated unions use `kind` as the discriminant — NOT `type`.
|
|
11
|
+
* Convention: `type` is reserved for FileAssertion variants only (legacy).
|
|
12
|
+
*
|
|
13
|
+
* 3. Zod schemas for each major contract type are exported from the index.
|
|
14
|
+
*
|
|
15
|
+
* @epic T760
|
|
16
|
+
* @task T804
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, expect, it } from 'vitest';
|
|
20
|
+
import {
|
|
21
|
+
acceptanceGateResultSchema,
|
|
22
|
+
acceptanceGateSchema,
|
|
23
|
+
attachmentSchema,
|
|
24
|
+
fileAssertionSchema,
|
|
25
|
+
gateResultDetailsSchema,
|
|
26
|
+
taskEvidenceSchema,
|
|
27
|
+
} from '../index.js';
|
|
28
|
+
|
|
29
|
+
// ─── Internal Zod v4 type helpers ────────────────────────────────────────────
|
|
30
|
+
// Zod v4 uses `_def.discriminator` and stores literal values in `_def.values`
|
|
31
|
+
// (array). TypeScript cannot resolve the exact shape of `_def` across Zod
|
|
32
|
+
// versions so these helpers cast through `unknown` at the boundary — this is
|
|
33
|
+
// intentional and the only acceptable use of type-unsafe access in this file.
|
|
34
|
+
|
|
35
|
+
/** Opaque discriminated-union schema shape used for structural introspection. */
|
|
36
|
+
interface ZodDiscriminatedUnionDef {
|
|
37
|
+
discriminator: string;
|
|
38
|
+
options: ZodObjectDef[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Opaque object schema shape for literal field access. */
|
|
42
|
+
interface ZodObjectDef {
|
|
43
|
+
shape: Record<string, ZodLiteralDef>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Opaque literal schema shape for value extraction. */
|
|
47
|
+
interface ZodLiteralDef {
|
|
48
|
+
_def: {
|
|
49
|
+
values?: unknown[];
|
|
50
|
+
value?: unknown;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Opaque optional schema shape for unwrapping the inner type. */
|
|
55
|
+
interface ZodOptionalDef {
|
|
56
|
+
innerType: { _def: ZodDiscriminatedUnionDef };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Casts a schema to the opaque discriminated-union def shape. */
|
|
60
|
+
function asDiscriminatedUnion(schema: unknown): ZodDiscriminatedUnionDef {
|
|
61
|
+
return (schema as { _def: ZodDiscriminatedUnionDef })._def;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Returns the discriminator key for a discriminated-union schema. */
|
|
65
|
+
function zodDiscriminator(schema: unknown): string {
|
|
66
|
+
return asDiscriminatedUnion(schema).discriminator;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Returns option schemas from a discriminated-union schema. */
|
|
70
|
+
function zodOptions(schema: unknown): ZodObjectDef[] {
|
|
71
|
+
return asDiscriminatedUnion(schema).options;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Returns the literal value(s) from a literal schema field. */
|
|
75
|
+
function zodLiteralValues(literalField: ZodLiteralDef): unknown[] {
|
|
76
|
+
// Zod v4: _def.values is an array
|
|
77
|
+
if (Array.isArray(literalField._def.values)) return literalField._def.values;
|
|
78
|
+
// Zod v3 compat: _def.value is a scalar
|
|
79
|
+
if (literalField._def.value !== undefined) return [literalField._def.value];
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Unwraps a ZodOptional and returns its inner discriminated-union def. */
|
|
84
|
+
function unwrapOptionalDiscriminator(schema: unknown): string {
|
|
85
|
+
const optDef = (schema as { _def: ZodOptionalDef })._def;
|
|
86
|
+
return optDef.innerType._def.discriminator;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Rule 1: Field name invariants ────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whitelisted fields where `string` typing is acceptable.
|
|
93
|
+
*
|
|
94
|
+
* Each entry documents WHY the bare string is appropriate so future
|
|
95
|
+
* reviewers can assess whether to keep or resolve the whitelist entry.
|
|
96
|
+
*/
|
|
97
|
+
const WHITELISTED_STRING_FIELDS: Record<string, string> = {
|
|
98
|
+
// task.ts / TaskCreate — `notes?: string` is a single append note for the
|
|
99
|
+
// create flow, not a queryable structured record. Structured notes live in
|
|
100
|
+
// TaskWorkState.sessionNotes (SessionNote[]).
|
|
101
|
+
'TaskCreate.notes': 'Single append note for create flow; structured notes live in SessionNote[]',
|
|
102
|
+
|
|
103
|
+
// task.ts / Task — `notes?: string[]` is legacy timestamped notes array.
|
|
104
|
+
// Structural upgrade tracked separately.
|
|
105
|
+
'Task.notes': 'Legacy timestamped notes array; structured upgrade tracked separately',
|
|
106
|
+
|
|
107
|
+
// task-record.ts / TaskRecord — `notes?: string[]` mirrors Task.notes for
|
|
108
|
+
// the string-widened wire format used by dispatch/LAFS.
|
|
109
|
+
'TaskRecord.notes': 'Wire-format mirror of Task.notes; same legacy rationale',
|
|
110
|
+
|
|
111
|
+
// task.ts / Release — `notes?: string | null` is a release changelog text.
|
|
112
|
+
// Not a query target; free-form narrative is appropriate here.
|
|
113
|
+
'Release.notes': 'Release changelog text — free-form narrative, not query target',
|
|
114
|
+
|
|
115
|
+
// session.ts — `notes?: string[]` is a session-level log accumulator.
|
|
116
|
+
'Session.notes': 'Session log accumulator — structured upgrade tracked separately',
|
|
117
|
+
|
|
118
|
+
// facade.ts — various `notes?: string` on action params (complete, prepare, etc.)
|
|
119
|
+
// are ephemeral caller-provided notes, not persisted entities.
|
|
120
|
+
'TasksAPI.notes': 'Ephemeral caller note on action params — not a persisted entity',
|
|
121
|
+
|
|
122
|
+
// memory.ts — BridgePattern/BridgeObservation `text: string` is the
|
|
123
|
+
// primary content field; the type IS the structure here.
|
|
124
|
+
'MemoryBridge.text': 'Primary content field in memory bridge — the text IS the value',
|
|
125
|
+
|
|
126
|
+
// SessionNote — `note: string` is the actual note content, not a metadata field.
|
|
127
|
+
// The struct is already structured (has taskId, sessionId, timestamp).
|
|
128
|
+
'SessionNote.note': 'Content field on an already-structured SessionNote record',
|
|
129
|
+
|
|
130
|
+
// data-accessor.ts — `notesJson?: string` is a raw JSON column for
|
|
131
|
+
// database queries where the serialised form is needed.
|
|
132
|
+
'DataAccessor.notesJson':
|
|
133
|
+
'Raw JSON column for DB query interface — deserialization is callers job',
|
|
134
|
+
|
|
135
|
+
// evidence-record.ts / ValidateSpecCheckRecord — `details: string` is a
|
|
136
|
+
// human-readable summary of per-REQ-ID results. Structured details for
|
|
137
|
+
// machine parsing live in other fields (reqIdsChecked, passed).
|
|
138
|
+
'ValidateSpecCheckRecord.details':
|
|
139
|
+
'Human-readable per-REQ-ID summary; machine fields are reqIdsChecked+passed',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
describe('Rule 1 — no bare string on notes/details/text fields (with whitelist)', () => {
|
|
143
|
+
it('GateResultDetails does not use bare string for kind-specific payloads', () => {
|
|
144
|
+
// GateResultDetails must be a discriminated union — verify via schema shape
|
|
145
|
+
expect(zodDiscriminator(gateResultDetailsSchema)).toBe('kind');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('AcceptanceGateResult.details is typed via GateResultDetails (not bare string)', () => {
|
|
149
|
+
const resultShape = acceptanceGateResultSchema.shape;
|
|
150
|
+
// details must exist as an optional discriminated union (not a plain string)
|
|
151
|
+
expect(resultShape.details).toBeDefined();
|
|
152
|
+
// Unwrap the ZodOptional and confirm the inner type is a discriminated union
|
|
153
|
+
expect(unwrapOptionalDiscriminator(resultShape.details)).toBe('kind');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('TaskEvidence does not use bare string as the primary evidence shape', () => {
|
|
157
|
+
// taskEvidenceSchema must be a discriminated union, not a plain string type
|
|
158
|
+
expect(zodDiscriminator(taskEvidenceSchema)).toBe('kind');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('documents all whitelisted string fields', () => {
|
|
162
|
+
// This test serves as a registry assertion: the whitelist must be non-empty
|
|
163
|
+
// and each key must follow the format 'TypeName.fieldName'
|
|
164
|
+
const entries = Object.entries(WHITELISTED_STRING_FIELDS);
|
|
165
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
166
|
+
for (const [key, reason] of entries) {
|
|
167
|
+
expect(key).toMatch(/^\w[\w.]+\.\w+$/);
|
|
168
|
+
expect(reason.length).toBeGreaterThan(10);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ─── Rule 2: Discriminated unions use `kind`, not `type` ──────────────────────
|
|
174
|
+
|
|
175
|
+
describe('Rule 2 — discriminated unions use `kind` discriminant (not `type`)', () => {
|
|
176
|
+
it('acceptanceGateSchema uses kind discriminant', () => {
|
|
177
|
+
expect(zodDiscriminator(acceptanceGateSchema)).toBe('kind');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('gateResultDetailsSchema uses kind discriminant', () => {
|
|
181
|
+
expect(zodDiscriminator(gateResultDetailsSchema)).toBe('kind');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('taskEvidenceSchema uses kind discriminant', () => {
|
|
185
|
+
expect(zodDiscriminator(taskEvidenceSchema)).toBe('kind');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('attachmentSchema uses kind discriminant', () => {
|
|
189
|
+
expect(zodDiscriminator(attachmentSchema)).toBe('kind');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('FileAssertion uses type discriminant (documented exception — legacy)', () => {
|
|
193
|
+
// FileAssertion predates the `kind` convention and uses `type`.
|
|
194
|
+
// This test documents the exception, not enforces it.
|
|
195
|
+
// Resolution tracked under a future cleanup task.
|
|
196
|
+
expect(zodDiscriminator(fileAssertionSchema)).toBe('type');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ─── Rule 3: Major Zod schemas are exported from index ────────────────────────
|
|
201
|
+
|
|
202
|
+
describe('Rule 3 — major Zod schemas are exported from index', () => {
|
|
203
|
+
it('exports acceptanceGateSchema', () => {
|
|
204
|
+
expect(typeof acceptanceGateSchema.parse).toBe('function');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('exports acceptanceGateResultSchema', () => {
|
|
208
|
+
expect(typeof acceptanceGateResultSchema.parse).toBe('function');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('exports gateResultDetailsSchema', () => {
|
|
212
|
+
expect(typeof gateResultDetailsSchema.parse).toBe('function');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('exports taskEvidenceSchema', () => {
|
|
216
|
+
expect(typeof taskEvidenceSchema.parse).toBe('function');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('exports attachmentSchema', () => {
|
|
220
|
+
expect(typeof attachmentSchema.parse).toBe('function');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('acceptanceGateSchema is a discriminated union with 6 variants', () => {
|
|
224
|
+
const opts = zodOptions(acceptanceGateSchema);
|
|
225
|
+
expect(opts.length).toBe(6);
|
|
226
|
+
const kinds = opts.flatMap((opt) => zodLiteralValues(opt.shape.kind));
|
|
227
|
+
expect(kinds).toContain('test');
|
|
228
|
+
expect(kinds).toContain('file');
|
|
229
|
+
expect(kinds).toContain('command');
|
|
230
|
+
expect(kinds).toContain('lint');
|
|
231
|
+
expect(kinds).toContain('http');
|
|
232
|
+
expect(kinds).toContain('manual');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('gateResultDetailsSchema is a discriminated union with 6 variants', () => {
|
|
236
|
+
const opts = zodOptions(gateResultDetailsSchema);
|
|
237
|
+
expect(opts.length).toBe(6);
|
|
238
|
+
const kinds = opts.flatMap((opt) => zodLiteralValues(opt.shape.kind));
|
|
239
|
+
expect(kinds).toContain('test');
|
|
240
|
+
expect(kinds).toContain('file');
|
|
241
|
+
expect(kinds).toContain('command');
|
|
242
|
+
expect(kinds).toContain('lint');
|
|
243
|
+
expect(kinds).toContain('http');
|
|
244
|
+
expect(kinds).toContain('manual');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('taskEvidenceSchema is a discriminated union with 5 variants', () => {
|
|
248
|
+
const opts = zodOptions(taskEvidenceSchema);
|
|
249
|
+
expect(opts.length).toBe(5);
|
|
250
|
+
const kinds = opts.flatMap((opt) => zodLiteralValues(opt.shape.kind));
|
|
251
|
+
expect(kinds).toContain('file');
|
|
252
|
+
expect(kinds).toContain('log');
|
|
253
|
+
expect(kinds).toContain('screenshot');
|
|
254
|
+
expect(kinds).toContain('test-output');
|
|
255
|
+
expect(kinds).toContain('command-output');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ─── Structural round-trip sanity checks ──────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe('GateResultDetails — round-trip parse for all 6 kinds', () => {
|
|
262
|
+
it('parses test details', () => {
|
|
263
|
+
const result = gateResultDetailsSchema.parse({
|
|
264
|
+
kind: 'test',
|
|
265
|
+
exitCode: 0,
|
|
266
|
+
stdout: 'ok 7/7',
|
|
267
|
+
stderr: '',
|
|
268
|
+
duration: 3142,
|
|
269
|
+
});
|
|
270
|
+
expect(result.kind).toBe('test');
|
|
271
|
+
if (result.kind === 'test') {
|
|
272
|
+
expect(result.exitCode).toBe(0);
|
|
273
|
+
expect(result.duration).toBe(3142);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('parses file details', () => {
|
|
278
|
+
const result = gateResultDetailsSchema.parse({
|
|
279
|
+
kind: 'file',
|
|
280
|
+
path: 'src/timer.ts',
|
|
281
|
+
passedAssertions: ['exists', 'nonEmpty'],
|
|
282
|
+
failedAssertions: [],
|
|
283
|
+
});
|
|
284
|
+
expect(result.kind).toBe('file');
|
|
285
|
+
if (result.kind === 'file') {
|
|
286
|
+
expect(result.passedAssertions).toHaveLength(2);
|
|
287
|
+
expect(result.failedAssertions).toHaveLength(0);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('parses command details', () => {
|
|
292
|
+
const result = gateResultDetailsSchema.parse({
|
|
293
|
+
kind: 'command',
|
|
294
|
+
cmd: 'cleo doctor',
|
|
295
|
+
exitCode: 0,
|
|
296
|
+
stdout: 'all checks passed',
|
|
297
|
+
});
|
|
298
|
+
expect(result.kind).toBe('command');
|
|
299
|
+
if (result.kind === 'command') {
|
|
300
|
+
expect(result.exitCode).toBe(0);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('parses lint details', () => {
|
|
305
|
+
const result = gateResultDetailsSchema.parse({
|
|
306
|
+
kind: 'lint',
|
|
307
|
+
tool: 'biome',
|
|
308
|
+
warnings: 0,
|
|
309
|
+
errors: 0,
|
|
310
|
+
});
|
|
311
|
+
expect(result.kind).toBe('lint');
|
|
312
|
+
if (result.kind === 'lint') {
|
|
313
|
+
expect(result.errors).toBe(0);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('parses http details', () => {
|
|
318
|
+
const result = gateResultDetailsSchema.parse({
|
|
319
|
+
kind: 'http',
|
|
320
|
+
url: 'http://localhost:8080/',
|
|
321
|
+
status: 200,
|
|
322
|
+
body: '<!DOCTYPE html>',
|
|
323
|
+
});
|
|
324
|
+
expect(result.kind).toBe('http');
|
|
325
|
+
if (result.kind === 'http') {
|
|
326
|
+
expect(result.status).toBe(200);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('parses manual details', () => {
|
|
331
|
+
const result = gateResultDetailsSchema.parse({
|
|
332
|
+
kind: 'manual',
|
|
333
|
+
prompt: 'Does the dark theme toggle work?',
|
|
334
|
+
accepted: true,
|
|
335
|
+
});
|
|
336
|
+
expect(result.kind).toBe('manual');
|
|
337
|
+
if (result.kind === 'manual') {
|
|
338
|
+
expect(result.accepted).toBe(true);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('rejects unknown kind', () => {
|
|
343
|
+
expect(() =>
|
|
344
|
+
gateResultDetailsSchema.parse({
|
|
345
|
+
kind: 'unknown',
|
|
346
|
+
exitCode: 0,
|
|
347
|
+
}),
|
|
348
|
+
).toThrow();
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe('TaskEvidence — round-trip parse for all 5 kinds', () => {
|
|
353
|
+
const sha256 = 'a'.repeat(64);
|
|
354
|
+
const timestamp = '2026-04-15T10:00:00.000Z';
|
|
355
|
+
|
|
356
|
+
it('parses file evidence', () => {
|
|
357
|
+
const result = taskEvidenceSchema.parse({
|
|
358
|
+
kind: 'file',
|
|
359
|
+
sha256,
|
|
360
|
+
timestamp,
|
|
361
|
+
path: 'packages/contracts/dist/index.js',
|
|
362
|
+
mime: 'text/javascript',
|
|
363
|
+
});
|
|
364
|
+
expect(result.kind).toBe('file');
|
|
365
|
+
if (result.kind === 'file') {
|
|
366
|
+
expect(result.path).toBe('packages/contracts/dist/index.js');
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('parses log evidence', () => {
|
|
371
|
+
const result = taskEvidenceSchema.parse({
|
|
372
|
+
kind: 'log',
|
|
373
|
+
sha256,
|
|
374
|
+
timestamp,
|
|
375
|
+
source: 'pnpm test',
|
|
376
|
+
});
|
|
377
|
+
expect(result.kind).toBe('log');
|
|
378
|
+
if (result.kind === 'log') {
|
|
379
|
+
expect(result.source).toBe('pnpm test');
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('parses screenshot evidence', () => {
|
|
384
|
+
const result = taskEvidenceSchema.parse({
|
|
385
|
+
kind: 'screenshot',
|
|
386
|
+
sha256,
|
|
387
|
+
timestamp,
|
|
388
|
+
mime: 'image/png',
|
|
389
|
+
});
|
|
390
|
+
expect(result.kind).toBe('screenshot');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('parses test-output evidence', () => {
|
|
394
|
+
const result = taskEvidenceSchema.parse({
|
|
395
|
+
kind: 'test-output',
|
|
396
|
+
sha256,
|
|
397
|
+
timestamp,
|
|
398
|
+
passed: 42,
|
|
399
|
+
failed: 0,
|
|
400
|
+
skipped: 2,
|
|
401
|
+
exitCode: 0,
|
|
402
|
+
});
|
|
403
|
+
expect(result.kind).toBe('test-output');
|
|
404
|
+
if (result.kind === 'test-output') {
|
|
405
|
+
expect(result.passed).toBe(42);
|
|
406
|
+
expect(result.failed).toBe(0);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('parses command-output evidence', () => {
|
|
411
|
+
const result = taskEvidenceSchema.parse({
|
|
412
|
+
kind: 'command-output',
|
|
413
|
+
sha256,
|
|
414
|
+
timestamp,
|
|
415
|
+
cmd: 'pnpm run build',
|
|
416
|
+
exitCode: 0,
|
|
417
|
+
});
|
|
418
|
+
expect(result.kind).toBe('command-output');
|
|
419
|
+
if (result.kind === 'command-output') {
|
|
420
|
+
expect(result.exitCode).toBe(0);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('rejects sha256 with wrong length', () => {
|
|
425
|
+
expect(() =>
|
|
426
|
+
taskEvidenceSchema.parse({
|
|
427
|
+
kind: 'file',
|
|
428
|
+
sha256: 'tooshort',
|
|
429
|
+
timestamp,
|
|
430
|
+
path: 'src/foo.ts',
|
|
431
|
+
}),
|
|
432
|
+
).toThrow();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('rejects invalid timestamp', () => {
|
|
436
|
+
expect(() =>
|
|
437
|
+
taskEvidenceSchema.parse({
|
|
438
|
+
kind: 'log',
|
|
439
|
+
sha256,
|
|
440
|
+
timestamp: 'not-a-date',
|
|
441
|
+
source: 'pnpm test',
|
|
442
|
+
}),
|
|
443
|
+
).toThrow();
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -82,7 +82,16 @@ export const testGateSchema = gateBaseSchema.extend({
|
|
|
82
82
|
/** Zod schema for {@link FileGate}. */
|
|
83
83
|
export const fileGateSchema = gateBaseSchema.extend({
|
|
84
84
|
kind: z.literal('file'),
|
|
85
|
-
path
|
|
85
|
+
/** Absolute or project-root-relative file path. Mutually exclusive with `attachmentSha256`. */
|
|
86
|
+
path: z.string().min(1).optional(),
|
|
87
|
+
/**
|
|
88
|
+
* SHA-256 hex of an attachment blob. Gate runner resolves the on-disk path
|
|
89
|
+
* via AttachmentStore. Mutually exclusive with `path`.
|
|
90
|
+
*/
|
|
91
|
+
attachmentSha256: z
|
|
92
|
+
.string()
|
|
93
|
+
.regex(/^[0-9a-f]{64}$/i, 'attachmentSha256 must be a 64-char hex string')
|
|
94
|
+
.optional(),
|
|
86
95
|
assertions: z.array(fileAssertionSchema).min(1),
|
|
87
96
|
});
|
|
88
97
|
|
|
@@ -157,6 +166,59 @@ export const acceptanceGateSchema = z.discriminatedUnion('kind', [
|
|
|
157
166
|
/** Inferred TypeScript type from {@link acceptanceGateSchema}. */
|
|
158
167
|
export type AcceptanceGateSchemaInput = z.input<typeof acceptanceGateSchema>;
|
|
159
168
|
|
|
169
|
+
// ─── GateResultDetails schema (T802) ─────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Zod discriminated-union schema for {@link GateResultDetails}.
|
|
173
|
+
*
|
|
174
|
+
* Each variant mirrors the corresponding {@link AcceptanceGate} kind so
|
|
175
|
+
* callers can narrow `details` using the `kind` discriminant.
|
|
176
|
+
*
|
|
177
|
+
* @epic T760
|
|
178
|
+
* @task T802
|
|
179
|
+
*/
|
|
180
|
+
export const gateResultDetailsSchema = z.discriminatedUnion('kind', [
|
|
181
|
+
z.object({
|
|
182
|
+
kind: z.literal('test'),
|
|
183
|
+
exitCode: z.number().int(),
|
|
184
|
+
stdout: z.string(),
|
|
185
|
+
stderr: z.string(),
|
|
186
|
+
duration: z.number().nonnegative(),
|
|
187
|
+
}),
|
|
188
|
+
z.object({
|
|
189
|
+
kind: z.literal('file'),
|
|
190
|
+
path: z.string().min(1),
|
|
191
|
+
passedAssertions: z.array(z.string()),
|
|
192
|
+
failedAssertions: z.array(z.string()),
|
|
193
|
+
}),
|
|
194
|
+
z.object({
|
|
195
|
+
kind: z.literal('command'),
|
|
196
|
+
cmd: z.string().min(1),
|
|
197
|
+
exitCode: z.number().int(),
|
|
198
|
+
stdout: z.string(),
|
|
199
|
+
}),
|
|
200
|
+
z.object({
|
|
201
|
+
kind: z.literal('lint'),
|
|
202
|
+
tool: z.string().min(1),
|
|
203
|
+
warnings: z.number().int().nonnegative(),
|
|
204
|
+
errors: z.number().int().nonnegative(),
|
|
205
|
+
}),
|
|
206
|
+
z.object({
|
|
207
|
+
kind: z.literal('http'),
|
|
208
|
+
url: z.string(),
|
|
209
|
+
status: z.number().int().min(100).max(599),
|
|
210
|
+
body: z.string(),
|
|
211
|
+
}),
|
|
212
|
+
z.object({
|
|
213
|
+
kind: z.literal('manual'),
|
|
214
|
+
prompt: z.string().min(1),
|
|
215
|
+
accepted: z.boolean(),
|
|
216
|
+
}),
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
/** Inferred TypeScript type from {@link gateResultDetailsSchema}. */
|
|
220
|
+
export type GateResultDetailsInput = z.input<typeof gateResultDetailsSchema>;
|
|
221
|
+
|
|
160
222
|
// ─── Result schema ────────────────────────────────────────────────────────────
|
|
161
223
|
|
|
162
224
|
/**
|
|
@@ -171,6 +233,8 @@ export const acceptanceGateResultSchema = z.object({
|
|
|
171
233
|
kind: z.enum(['test', 'file', 'command', 'lint', 'http', 'manual']),
|
|
172
234
|
result: z.enum(['pass', 'fail', 'warn', 'skipped', 'error']),
|
|
173
235
|
durationMs: z.number().nonnegative(),
|
|
236
|
+
/** Typed kind-specific detail payload (T802). */
|
|
237
|
+
details: gateResultDetailsSchema.optional(),
|
|
174
238
|
evidence: z.string().optional(),
|
|
175
239
|
errorMessage: z.string().optional(),
|
|
176
240
|
/** ISO 8601 timestamp. */
|
package/src/acceptance-gate.ts
CHANGED
|
@@ -82,14 +82,33 @@ export interface TestGate extends GateBase {
|
|
|
82
82
|
/**
|
|
83
83
|
* Assert properties of a file on disk. Multiple assertions are AND-ed:
|
|
84
84
|
* all must hold for the gate to pass.
|
|
85
|
+
*
|
|
86
|
+
* Either `path` or `attachmentSha256` must be provided (not both).
|
|
87
|
+
* When `attachmentSha256` is set, the gate runner resolves the on-disk path
|
|
88
|
+
* via `AttachmentStore.get(sha256)` before running assertions.
|
|
85
89
|
*/
|
|
86
90
|
export interface FileGate extends GateBase {
|
|
87
91
|
kind: 'file';
|
|
88
92
|
/**
|
|
89
93
|
* Absolute or project-root-relative path. Globs are not supported here
|
|
90
94
|
* — use one gate per path.
|
|
95
|
+
*
|
|
96
|
+
* Exactly one of `path` or `attachmentSha256` must be set.
|
|
97
|
+
*/
|
|
98
|
+
path?: string;
|
|
99
|
+
/**
|
|
100
|
+
* SHA-256 hex digest of an attachment stored in the CLEO attachment store.
|
|
101
|
+
*
|
|
102
|
+
* When set, the gate runner resolves the attachment's on-disk blob path
|
|
103
|
+
* via `AttachmentStore.get(sha256)` and uses that path for all assertions.
|
|
104
|
+
* Mutually exclusive with `path`.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```json
|
|
108
|
+
* { "kind": "file", "attachmentSha256": "a1b2c3...", "assertions": [{ "type": "nonEmpty" }] }
|
|
109
|
+
* ```
|
|
91
110
|
*/
|
|
92
|
-
|
|
111
|
+
attachmentSha256?: string;
|
|
93
112
|
assertions: FileAssertion[];
|
|
94
113
|
}
|
|
95
114
|
|
|
@@ -230,6 +249,74 @@ export type AcceptanceGate = TestGate | FileGate | CommandGate | LintGate | Http
|
|
|
230
249
|
/** All valid `kind` discriminants for `AcceptanceGate`. */
|
|
231
250
|
export type AcceptanceGateKind = AcceptanceGate['kind'];
|
|
232
251
|
|
|
252
|
+
// ─── Result details (typed per-kind) ─────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Kind-specific detail payload for {@link AcceptanceGateResult}.
|
|
256
|
+
*
|
|
257
|
+
* Each variant is keyed by `kind` — the same discriminant used on
|
|
258
|
+
* {@link AcceptanceGate} — so callers can narrow `details` with a simple
|
|
259
|
+
* `if (result.details?.kind === 'test') { ... }` guard.
|
|
260
|
+
*
|
|
261
|
+
* @epic T760
|
|
262
|
+
* @task T802
|
|
263
|
+
*/
|
|
264
|
+
export type GateResultDetails =
|
|
265
|
+
| {
|
|
266
|
+
kind: 'test';
|
|
267
|
+
/** Exit code of the test process. */
|
|
268
|
+
exitCode: number;
|
|
269
|
+
/** Captured standard output (may be truncated). */
|
|
270
|
+
stdout: string;
|
|
271
|
+
/** Captured standard error (may be truncated). */
|
|
272
|
+
stderr: string;
|
|
273
|
+
/** Wall-clock duration in milliseconds. */
|
|
274
|
+
duration: number;
|
|
275
|
+
}
|
|
276
|
+
| {
|
|
277
|
+
kind: 'file';
|
|
278
|
+
/** The file path that was evaluated. */
|
|
279
|
+
path: string;
|
|
280
|
+
/** Assertions that passed. */
|
|
281
|
+
passedAssertions: string[];
|
|
282
|
+
/** Assertions that failed (empty on pass). */
|
|
283
|
+
failedAssertions: string[];
|
|
284
|
+
}
|
|
285
|
+
| {
|
|
286
|
+
kind: 'command';
|
|
287
|
+
/** The command that was run. */
|
|
288
|
+
cmd: string;
|
|
289
|
+
/** Exit code of the command process. */
|
|
290
|
+
exitCode: number;
|
|
291
|
+
/** Captured standard output (may be truncated). */
|
|
292
|
+
stdout: string;
|
|
293
|
+
}
|
|
294
|
+
| {
|
|
295
|
+
kind: 'lint';
|
|
296
|
+
/** Linting tool name (e.g. `"biome"`, `"tsc"`). */
|
|
297
|
+
tool: string;
|
|
298
|
+
/** Number of diagnostic warnings emitted. */
|
|
299
|
+
warnings: number;
|
|
300
|
+
/** Number of diagnostic errors emitted. */
|
|
301
|
+
errors: number;
|
|
302
|
+
}
|
|
303
|
+
| {
|
|
304
|
+
kind: 'http';
|
|
305
|
+
/** The URL that was probed. */
|
|
306
|
+
url: string;
|
|
307
|
+
/** HTTP status code returned by the server. */
|
|
308
|
+
status: number;
|
|
309
|
+
/** Response body (may be truncated). */
|
|
310
|
+
body: string;
|
|
311
|
+
}
|
|
312
|
+
| {
|
|
313
|
+
kind: 'manual';
|
|
314
|
+
/** The prompt shown to the reviewer. */
|
|
315
|
+
prompt: string;
|
|
316
|
+
/** Whether the reviewer accepted (`true`) or rejected (`false`) the gate. */
|
|
317
|
+
accepted: boolean;
|
|
318
|
+
};
|
|
319
|
+
|
|
233
320
|
// ─── Result ──────────────────────────────────────────────────────────────────
|
|
234
321
|
|
|
235
322
|
/**
|
|
@@ -253,7 +340,26 @@ export interface AcceptanceGateResult {
|
|
|
253
340
|
result: 'pass' | 'fail' | 'warn' | 'skipped' | 'error';
|
|
254
341
|
/** Wall-clock duration of the gate execution in milliseconds. */
|
|
255
342
|
durationMs: number;
|
|
256
|
-
/**
|
|
343
|
+
/**
|
|
344
|
+
* Typed kind-specific detail payload.
|
|
345
|
+
*
|
|
346
|
+
* Replaces the untyped `evidence?: string` pattern. The `kind` discriminant
|
|
347
|
+
* matches the gate's `kind` so callers can narrow safely:
|
|
348
|
+
*
|
|
349
|
+
* ```ts
|
|
350
|
+
* if (r.details?.kind === 'test') {
|
|
351
|
+
* console.log(r.details.exitCode, r.details.stdout);
|
|
352
|
+
* }
|
|
353
|
+
* ```
|
|
354
|
+
*/
|
|
355
|
+
details?: GateResultDetails;
|
|
356
|
+
/**
|
|
357
|
+
* Short plain-text evidence snippet shown in failure messages.
|
|
358
|
+
*
|
|
359
|
+
* Kept for backward compatibility with existing persisted records and
|
|
360
|
+
* display code that reads this field as a truncated summary.
|
|
361
|
+
* New code should prefer {@link details}.
|
|
362
|
+
*/
|
|
257
363
|
evidence?: string;
|
|
258
364
|
/** Error message when `result` is `"error"` (the gate runner itself crashed). */
|
|
259
365
|
errorMessage?: string;
|