autotel-eventcatalog 1.0.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 (54) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/CONTRIBUTING.md +212 -0
  3. package/README.md +307 -0
  4. package/action.yml +155 -0
  5. package/dist/cli.cjs +1071 -0
  6. package/dist/cli.cjs.map +1 -0
  7. package/dist/cli.d.cts +2 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +1065 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/index.cjs +794 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +267 -0
  14. package/dist/index.d.ts +267 -0
  15. package/dist/index.js +764 -0
  16. package/dist/index.js.map +1 -0
  17. package/docs/CONTRACT.md +280 -0
  18. package/docs/EXTENDING.md +248 -0
  19. package/docs/TROUBLESHOOTING.md +220 -0
  20. package/docs/UPGRADING.md +202 -0
  21. package/package.json +78 -0
  22. package/schemas/README.md +44 -0
  23. package/schemas/drift-report-v0.1.0.json +107 -0
  24. package/schemas/drift-report-v0.2.0.json +137 -0
  25. package/schemas/drift-summary-v0.1.0.json +74 -0
  26. package/schemas/drift-summary-v0.2.0.json +74 -0
  27. package/schemas/stamp-summary-v0.1.0.json +54 -0
  28. package/src/__fixtures__/drift-report-all.golden.json +33 -0
  29. package/src/__fixtures__/drift-summary-clean.golden.json +17 -0
  30. package/src/__fixtures__/drift-summary-drifty.golden.json +17 -0
  31. package/src/__fixtures__/stamp-summary-noop.golden.json +10 -0
  32. package/src/catalog.test.ts +63 -0
  33. package/src/catalog.ts +169 -0
  34. package/src/cli.e2e.test.ts +310 -0
  35. package/src/cli.ts +402 -0
  36. package/src/contract.test.ts +395 -0
  37. package/src/diff-vs-base.test.ts +145 -0
  38. package/src/diff-vs-base.ts +242 -0
  39. package/src/diff.test.ts +384 -0
  40. package/src/diff.ts +296 -0
  41. package/src/index.ts +73 -0
  42. package/src/policy.test.ts +75 -0
  43. package/src/policy.ts +41 -0
  44. package/src/renderers/index.ts +35 -0
  45. package/src/renderers/json.ts +33 -0
  46. package/src/renderers/markdown.ts +223 -0
  47. package/src/renderers/renderers.test.ts +79 -0
  48. package/src/renderers/terminal.ts +30 -0
  49. package/src/renderers/types.ts +26 -0
  50. package/src/report.test.ts +205 -0
  51. package/src/report.ts +27 -0
  52. package/src/snapshot.ts +25 -0
  53. package/src/stamp.test.ts +283 -0
  54. package/src/stamp.ts +232 -0
@@ -0,0 +1,395 @@
1
+ // Contract tests for the public JSON surface.
2
+ //
3
+ // These are *not* about whether the renderer produces nice-looking output —
4
+ // `report.test.ts` covers that. These tests treat the JSON shape as a public
5
+ // contract that downstream tooling (the GitHub Action, Slack bots, dashboards)
6
+ // can depend on. A failure here should read as:
7
+ //
8
+ // "You changed the published contract for vX.Y.Z. Either revert the change
9
+ // or bump the spec version and update consumers."
10
+ //
11
+ // We deliberately avoid taking an ajv dependency for runtime validation; the
12
+ // schemas are tiny and the validator below is hand-rolled in ~60 lines. The
13
+ // JSON Schema files in `schemas/` are the shipped artifact that consumers can
14
+ // validate against using whichever validator they prefer.
15
+
16
+ import { describe, it, expect } from 'vitest';
17
+ import { readFileSync } from 'node:fs';
18
+ import { join, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ import { REPORT_SPEC, renderJson, type JsonReportEnvelope } from './report';
22
+ import { buildStampSummary, STAMP_SUMMARY_SPEC } from './stamp';
23
+ import { evaluatePolicy } from './policy';
24
+ import { countDriftReport } from './diff';
25
+ import type { DriftReport } from './diff';
26
+ import type { DriftDelta } from './diff-vs-base';
27
+
28
+ const HERE = dirname(fileURLToPath(import.meta.url));
29
+ const SCHEMAS = join(HERE, '..', 'schemas');
30
+
31
+ type JsonSchema = {
32
+ required?: string[];
33
+ oneOf?: JsonSchema[];
34
+ allOf?: JsonSchema[];
35
+ properties?: Record<string, JsonSchema>;
36
+ additionalProperties?: boolean;
37
+ type?: string;
38
+ const?: unknown;
39
+ enum?: unknown[];
40
+ items?: JsonSchema;
41
+ minimum?: number;
42
+ $ref?: string;
43
+ $defs?: Record<string, JsonSchema>;
44
+ };
45
+
46
+ /**
47
+ * Minimal JSON Schema validator. Supports the subset of keywords used in
48
+ * our shipped schemas: type, const, enum, required, properties,
49
+ * additionalProperties, items, minimum, oneOf, allOf, $defs / $ref.
50
+ *
51
+ * Throws on unsupported keywords so we notice if we drift past the subset.
52
+ */
53
+ function validate(
54
+ data: unknown,
55
+ schema: JsonSchema,
56
+ defs: Record<string, JsonSchema> = schema.$defs ?? {},
57
+ path = '$',
58
+ ): string[] {
59
+ const errors: string[] = [];
60
+
61
+ if (schema.$ref) {
62
+ const name = schema.$ref.replace('#/$defs/', '');
63
+ const target = defs[name];
64
+ if (!target) {
65
+ errors.push(`${path}: unresolved $ref ${schema.$ref}`);
66
+ return errors;
67
+ }
68
+ return validate(data, target, defs, path);
69
+ }
70
+
71
+ if (schema.oneOf) {
72
+ const matched = schema.oneOf.filter(
73
+ (s) => validate(data, s, defs, path).length === 0,
74
+ );
75
+ if (matched.length !== 1) {
76
+ errors.push(
77
+ `${path}: expected to match exactly one oneOf branch, matched ${matched.length}`,
78
+ );
79
+ }
80
+ return errors;
81
+ }
82
+
83
+ if (schema.allOf) {
84
+ for (const s of schema.allOf) errors.push(...validate(data, s, defs, path));
85
+ return errors;
86
+ }
87
+
88
+ if (schema.const !== undefined && data !== schema.const) {
89
+ errors.push(
90
+ `${path}: expected const ${JSON.stringify(schema.const)}, got ${JSON.stringify(data)}`,
91
+ );
92
+ }
93
+
94
+ if (schema.enum && !schema.enum.includes(data)) {
95
+ errors.push(
96
+ `${path}: value ${JSON.stringify(data)} not in enum ${JSON.stringify(schema.enum)}`,
97
+ );
98
+ }
99
+
100
+ if (schema.type) {
101
+ const actual =
102
+ data === null
103
+ ? 'null'
104
+ : Array.isArray(data)
105
+ ? 'array'
106
+ : typeof data === 'number' && Number.isInteger(data)
107
+ ? 'integer'
108
+ : typeof data;
109
+ const ok =
110
+ (schema.type === 'integer' && actual === 'integer') ||
111
+ (schema.type === 'number' &&
112
+ (actual === 'number' || actual === 'integer')) ||
113
+ schema.type === actual;
114
+ if (!ok)
115
+ errors.push(`${path}: expected type ${schema.type}, got ${actual}`);
116
+ }
117
+
118
+ if (
119
+ schema.minimum !== undefined &&
120
+ typeof data === 'number' &&
121
+ data < schema.minimum
122
+ ) {
123
+ errors.push(`${path}: ${data} < minimum ${schema.minimum}`);
124
+ }
125
+
126
+ if (schema.required && typeof data === 'object' && data !== null) {
127
+ for (const key of schema.required) {
128
+ if (!(key in (data as Record<string, unknown>))) {
129
+ errors.push(`${path}: missing required property "${key}"`);
130
+ }
131
+ }
132
+ }
133
+
134
+ if (schema.properties && typeof data === 'object' && data !== null) {
135
+ const obj = data as Record<string, unknown>;
136
+ for (const [key, sub] of Object.entries(schema.properties)) {
137
+ if (key in obj) {
138
+ errors.push(...validate(obj[key], sub, defs, `${path}.${key}`));
139
+ }
140
+ }
141
+ if (schema.additionalProperties === false) {
142
+ for (const key of Object.keys(obj)) {
143
+ if (!(key in schema.properties)) {
144
+ errors.push(`${path}: unexpected property "${key}"`);
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ if (schema.items && Array.isArray(data)) {
151
+ for (let i = 0; i < data.length; i++) {
152
+ errors.push(...validate(data[i], schema.items, defs, `${path}[${i}]`));
153
+ }
154
+ }
155
+
156
+ return errors;
157
+ }
158
+
159
+ function loadSchema(name: string): JsonSchema {
160
+ return JSON.parse(readFileSync(join(SCHEMAS, name), 'utf8'));
161
+ }
162
+
163
+ // ─── Fixtures ──────────────────────────────────────────────
164
+ const emptyReport: DriftReport = {
165
+ snapshotGeneratedAt: '2026-05-22T00:00:00.000Z',
166
+ snapshotService: 'fixture',
167
+ events: {
168
+ observedButUndocumented: [],
169
+ documentedButUnseen: [],
170
+ fieldDrift: [],
171
+ typeDrift: [],
172
+ valueDrift: [],
173
+ },
174
+ services: { observedButUndocumented: [] },
175
+ channels: { observedButUndocumented: [] },
176
+ };
177
+
178
+ const driftyReport: DriftReport = {
179
+ ...emptyReport,
180
+ events: {
181
+ observedButUndocumented: ['order.cancelled'],
182
+ documentedButUnseen: ['LegacyEvent'],
183
+ fieldDrift: [
184
+ {
185
+ event: 'recommendation.generated',
186
+ extra: ['personalization_seed'],
187
+ missing: [],
188
+ },
189
+ ],
190
+ typeDrift: [],
191
+ valueDrift: [],
192
+ },
193
+ };
194
+
195
+ const cleanDelta: DriftDelta = {
196
+ hasNewDrift: false,
197
+ introduced: {
198
+ events: {
199
+ observedButUndocumented: [],
200
+ documentedButUnseen: [],
201
+ fieldDrift: [],
202
+ typeDrift: [],
203
+ valueDrift: [],
204
+ },
205
+ services: { observedButUndocumented: [] },
206
+ channels: { observedButUndocumented: [] },
207
+ },
208
+ resolved: {
209
+ events: {
210
+ observedButUndocumented: [],
211
+ documentedButUnseen: [],
212
+ fieldDrift: [],
213
+ typeDrift: [],
214
+ valueDrift: [],
215
+ },
216
+ services: { observedButUndocumented: [] },
217
+ channels: { observedButUndocumented: [] },
218
+ },
219
+ };
220
+
221
+ // ─── Contract: drift report JSON envelope ──────────────────
222
+ describe('drift report JSON envelope (autotel-eventcatalog-report/v0.2.0)', () => {
223
+ const schema = loadSchema('drift-report-v0.2.0.json');
224
+
225
+ it('mode=all output validates against the published schema', () => {
226
+ const json = renderJson({ mode: 'all', report: driftyReport });
227
+ const parsed = JSON.parse(json);
228
+ expect(validate(parsed, schema)).toEqual([]);
229
+ expect(parsed.spec).toBe(REPORT_SPEC);
230
+ });
231
+
232
+ it('mode=new-only output validates against the published schema', () => {
233
+ const json = renderJson({ mode: 'new-only', delta: cleanDelta });
234
+ const parsed = JSON.parse(json);
235
+ expect(validate(parsed, schema)).toEqual([]);
236
+ });
237
+
238
+ it('byte-equal to golden fixture for a representative drifty report', () => {
239
+ // Lock the exact bytes for the all-mode envelope. If a future change
240
+ // re-orders keys or changes whitespace, this test fails with a clear
241
+ // message: "You changed the published contract."
242
+ const json = renderJson({ mode: 'all', report: driftyReport });
243
+ const golden = readFileSync(
244
+ join(HERE, '__fixtures__', 'drift-report-all.golden.json'),
245
+ 'utf8',
246
+ );
247
+ // Goldens are written with a trailing newline (POSIX convention); the
248
+ // renderer omits it. Compare with the trailing newline normalised so the
249
+ // contract is "every byte of the JSON envelope", not "do you end with \n".
250
+ expect(json).toBe(golden.replace(/\n$/, ''));
251
+ });
252
+
253
+ it('spec marker is the only place the version lives — bumping it is a breaking change', () => {
254
+ // Sanity: if someone renames REPORT_SPEC by accident, the published
255
+ // contract changes silently. Pin it to the schema's const here.
256
+ const schemaConst = (
257
+ (schema.oneOf ?? [])[0]?.properties?.spec as
258
+ | { const?: string }
259
+ | undefined
260
+ )?.const;
261
+ expect(REPORT_SPEC).toBe(schemaConst);
262
+ });
263
+ });
264
+
265
+ // ─── Contract: drift summary JSON ──────────────────────────
266
+ describe('drift summary JSON (autotel-eventcatalog-drift-summary/v0.2.0)', () => {
267
+ const schema = loadSchema('drift-summary-v0.2.0.json');
268
+ // The CLI builds the summary inline; we replicate the shape here so the
269
+ // contract test is independent of CLI implementation details. The
270
+ // important thing is that the shape we ship matches the published schema.
271
+ function buildSummary(
272
+ mode: 'all' | 'new-only',
273
+ report: DriftReport,
274
+ ): unknown {
275
+ const policy = evaluatePolicy({ mode: 'all', report });
276
+ return {
277
+ spec: 'autotel-eventcatalog-drift-summary/v0.2.0',
278
+ mode,
279
+ shouldFail: policy.shouldFail,
280
+ reason: policy.reason,
281
+ counts: countDriftReport(report),
282
+ };
283
+ }
284
+
285
+ it('clean drift summary validates against the schema', () => {
286
+ expect(validate(buildSummary('all', emptyReport), schema)).toEqual([]);
287
+ });
288
+
289
+ it('drifty summary validates against the schema', () => {
290
+ expect(validate(buildSummary('all', driftyReport), schema)).toEqual([]);
291
+ });
292
+
293
+ it('byte-equal to golden fixture for a clean run', () => {
294
+ const summary = buildSummary('all', emptyReport);
295
+ const json = JSON.stringify(summary, null, 2);
296
+ const golden = readFileSync(
297
+ join(HERE, '__fixtures__', 'drift-summary-clean.golden.json'),
298
+ 'utf8',
299
+ );
300
+ expect(json).toBe(golden.trimEnd());
301
+ });
302
+
303
+ it('byte-equal to golden fixture for a drifty run', () => {
304
+ const summary = buildSummary('all', driftyReport);
305
+ const json = JSON.stringify(summary, null, 2);
306
+ const golden = readFileSync(
307
+ join(HERE, '__fixtures__', 'drift-summary-drifty.golden.json'),
308
+ 'utf8',
309
+ );
310
+ expect(json).toBe(golden.trimEnd());
311
+ });
312
+ });
313
+
314
+ // ─── Contract: stamp summary JSON ──────────────────────────
315
+ describe('stamp summary JSON (autotel-eventcatalog-stamp-summary/v0.1.0)', () => {
316
+ const schema = loadSchema('stamp-summary-v0.1.0.json');
317
+
318
+ it('mixed-results summary validates', () => {
319
+ const summary = buildStampSummary(
320
+ {
321
+ updates: [
322
+ {
323
+ catalogId: 'A',
324
+ snapshotName: 'a',
325
+ filePath: '/x/a.mdx',
326
+ action: 'insert',
327
+ changed: true,
328
+ },
329
+ {
330
+ catalogId: 'B',
331
+ snapshotName: 'b',
332
+ filePath: '/x/b.mdx',
333
+ action: 'replace',
334
+ changed: false,
335
+ },
336
+ ],
337
+ skips: [{ snapshotName: 'gone', reason: 'no-catalog-match' }],
338
+ },
339
+ false,
340
+ );
341
+ expect(summary.spec).toBe(STAMP_SUMMARY_SPEC);
342
+ expect(validate(summary, schema)).toEqual([]);
343
+ });
344
+
345
+ it('byte-equal to golden fixture for a no-op run', () => {
346
+ const summary = buildStampSummary({ updates: [], skips: [] }, false);
347
+ const json = JSON.stringify(summary, null, 2);
348
+ const golden = readFileSync(
349
+ join(HERE, '__fixtures__', 'stamp-summary-noop.golden.json'),
350
+ 'utf8',
351
+ );
352
+ expect(json).toBe(golden.trimEnd());
353
+ });
354
+ });
355
+
356
+ // ─── Validator self-check ─────────────────────────────────
357
+ describe('tiny JSON Schema validator (used by the contract tests above)', () => {
358
+ it('catches missing required properties', () => {
359
+ const errs = validate({ a: 1 }, { required: ['a', 'b'], properties: {} });
360
+ expect(errs).toEqual([
361
+ expect.stringContaining('missing required property "b"'),
362
+ ]);
363
+ });
364
+
365
+ it('rejects const mismatches', () => {
366
+ const errs = validate(
367
+ { spec: 'wrong' },
368
+ {
369
+ properties: { spec: { const: 'right' } },
370
+ },
371
+ );
372
+ expect(errs[0]).toContain('expected const');
373
+ });
374
+
375
+ it('integer vs number distinction', () => {
376
+ expect(validate(1.5, { type: 'integer' })).toHaveLength(1);
377
+ expect(validate(2, { type: 'integer' })).toEqual([]);
378
+ expect(validate(2, { type: 'number' })).toEqual([]);
379
+ expect(validate(1.5, { type: 'number' })).toEqual([]);
380
+ });
381
+
382
+ it('rejects additional properties when additionalProperties: false', () => {
383
+ const errs = validate(
384
+ { a: 1, b: 2 },
385
+ { properties: { a: { type: 'number' } }, additionalProperties: false },
386
+ );
387
+ expect(errs[0]).toContain('unexpected property "b"');
388
+ });
389
+
390
+ // Silence the unused-envelope-type import without polluting runtime.
391
+ it('exports the envelope type', () => {
392
+ const _envelope = null as unknown as JsonReportEnvelope;
393
+ expect(_envelope).toBe(null);
394
+ });
395
+ });
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ compareDriftReports,
4
+ countDriftEntries,
5
+ countDriftDelta,
6
+ } from './diff-vs-base';
7
+ import type { DriftReport } from './diff';
8
+
9
+ function report(
10
+ overrides: Partial<DriftReport['events']> & Partial<DriftReport> = {},
11
+ ): DriftReport {
12
+ return {
13
+ snapshotGeneratedAt: '2026-05-22T00:00:00.000Z',
14
+ snapshotService: 'svc',
15
+ events: {
16
+ observedButUndocumented: overrides.observedButUndocumented ?? [],
17
+ documentedButUnseen: overrides.documentedButUnseen ?? [],
18
+ fieldDrift: overrides.fieldDrift ?? [],
19
+ },
20
+ services: { observedButUndocumented: [] },
21
+ channels: { observedButUndocumented: [] },
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe('compareDriftReports', () => {
27
+ it('reports nothing when base and head are identical', () => {
28
+ const r = compareDriftReports(report(), report());
29
+ expect(r.hasNewDrift).toBe(false);
30
+ expect(r.introduced.events.observedButUndocumented).toEqual([]);
31
+ expect(r.resolved.events.observedButUndocumented).toEqual([]);
32
+ });
33
+
34
+ it('reports newly observed-but-undocumented events as introduced', () => {
35
+ const base = report({ observedButUndocumented: ['existing.event'] });
36
+ const head = report({
37
+ observedButUndocumented: ['existing.event', 'pr.new.event'],
38
+ });
39
+ const r = compareDriftReports(base, head);
40
+ expect(r.introduced.events.observedButUndocumented).toEqual([
41
+ 'pr.new.event',
42
+ ]);
43
+ expect(r.hasNewDrift).toBe(true);
44
+ });
45
+
46
+ it('reports a drift entry that disappeared as resolved', () => {
47
+ const base = report({
48
+ documentedButUnseen: ['LegacyEvent', 'OtherEvent'],
49
+ });
50
+ const head = report({ documentedButUnseen: ['OtherEvent'] });
51
+ const r = compareDriftReports(base, head);
52
+ expect(r.resolved.events.documentedButUnseen).toEqual(['LegacyEvent']);
53
+ expect(r.hasNewDrift).toBe(false);
54
+ });
55
+
56
+ it('reports added field paths inside an existing fieldDrift entry', () => {
57
+ const base = report({
58
+ fieldDrift: [
59
+ { event: 'order.placed', extra: ['preExisting'], missing: [] },
60
+ ],
61
+ });
62
+ const head = report({
63
+ fieldDrift: [
64
+ {
65
+ event: 'order.placed',
66
+ extra: ['preExisting', 'newField'],
67
+ missing: [],
68
+ },
69
+ ],
70
+ });
71
+ const r = compareDriftReports(base, head);
72
+ expect(r.introduced.events.fieldDrift).toEqual([
73
+ { event: 'order.placed', extra: ['newField'], missing: [] },
74
+ ]);
75
+ expect(r.hasNewDrift).toBe(true);
76
+ });
77
+
78
+ it('treats a completely new fieldDrift event as introduced', () => {
79
+ const base = report();
80
+ const head = report({
81
+ fieldDrift: [{ event: 'new.event', extra: ['field'], missing: [] }],
82
+ });
83
+ const r = compareDriftReports(base, head);
84
+ expect(r.introduced.events.fieldDrift).toEqual([
85
+ { event: 'new.event', extra: ['field'], missing: [] },
86
+ ]);
87
+ });
88
+
89
+ it('treats a fieldDrift event that disappeared as resolved', () => {
90
+ const base = report({
91
+ fieldDrift: [{ event: 'gone', extra: ['x'], missing: ['y'] }],
92
+ });
93
+ const head = report();
94
+ const r = compareDriftReports(base, head);
95
+ expect(r.resolved.events.fieldDrift).toEqual([
96
+ { event: 'gone', extra: ['x'], missing: ['y'] },
97
+ ]);
98
+ });
99
+
100
+ it('hasNewDrift is true only when introduced has content', () => {
101
+ const onlyResolved = compareDriftReports(
102
+ report({ observedButUndocumented: ['fixed'] }),
103
+ report(),
104
+ );
105
+ expect(onlyResolved.hasNewDrift).toBe(false);
106
+ expect(onlyResolved.resolved.events.observedButUndocumented).toEqual([
107
+ 'fixed',
108
+ ]);
109
+ });
110
+ });
111
+
112
+ describe('countDriftEntries / countDriftDelta', () => {
113
+ it('counts the introduced side of a delta', () => {
114
+ const delta = compareDriftReports(
115
+ report(),
116
+ report({
117
+ observedButUndocumented: ['order.cancelled'],
118
+ fieldDrift: [
119
+ {
120
+ event: 'order.placed',
121
+ extra: ['extra1', 'extra2'],
122
+ missing: ['miss1'],
123
+ },
124
+ ],
125
+ }),
126
+ );
127
+ const counts = countDriftEntries(delta.introduced);
128
+ expect(counts.observedButUndocumentedEvents).toBe(1);
129
+ expect(counts.fieldDriftEvents).toBe(1);
130
+ expect(counts.fieldDriftPaths).toBe(3); // 2 extra + 1 missing
131
+ expect(counts.total).toBe(4); // 1 observedButUndoc + 3 fieldDriftPaths
132
+ });
133
+
134
+ it('countDriftDelta returns both sides', () => {
135
+ const delta = compareDriftReports(
136
+ report({ observedButUndocumented: ['fixed.event'] }),
137
+ report({ observedButUndocumented: ['new.event'] }),
138
+ );
139
+ const both = countDriftDelta(delta);
140
+ expect(both.introduced.observedButUndocumentedEvents).toBe(1);
141
+ expect(both.introduced.total).toBe(1);
142
+ expect(both.resolved.observedButUndocumentedEvents).toBe(1);
143
+ expect(both.resolved.total).toBe(1);
144
+ });
145
+ });