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,242 @@
1
+ // Compare two drift reports (base branch vs PR head) and produce a "what
2
+ // this PR introduces" view. Without this, a PR check fails forever on
3
+ // pre-existing drift; with it, the check only fails on drift the PR is
4
+ // responsible for.
5
+
6
+ import type {
7
+ DriftReport,
8
+ FieldDrift,
9
+ DriftCounts,
10
+ TypeDrift,
11
+ ValueDrift,
12
+ } from './diff';
13
+
14
+ export type DriftDelta = {
15
+ /** Drift entries present in head but not in base. */
16
+ introduced: DriftEntries;
17
+ /** Drift entries present in base but not in head — the PR fixed these. */
18
+ resolved: DriftEntries;
19
+ /** True if `introduced` has any non-empty section. */
20
+ hasNewDrift: boolean;
21
+ };
22
+
23
+ export type DriftEntries = {
24
+ events: {
25
+ observedButUndocumented: string[];
26
+ documentedButUnseen: string[];
27
+ fieldDrift: FieldDrift[];
28
+ typeDrift: TypeDrift[];
29
+ valueDrift: ValueDrift[];
30
+ };
31
+ services: { observedButUndocumented: string[] };
32
+ channels: { observedButUndocumented: string[] };
33
+ };
34
+
35
+ export function compareDriftReports(
36
+ base: DriftReport,
37
+ head: DriftReport,
38
+ ): DriftDelta {
39
+ const introducedEvents = diffStringList(
40
+ base.events.observedButUndocumented,
41
+ head.events.observedButUndocumented,
42
+ );
43
+ const introducedMissing = diffStringList(
44
+ base.events.documentedButUnseen,
45
+ head.events.documentedButUnseen,
46
+ );
47
+ const introducedFieldDrift = diffFieldDrift(
48
+ base.events.fieldDrift,
49
+ head.events.fieldDrift,
50
+ );
51
+ const introducedServices = diffStringList(
52
+ base.services.observedButUndocumented,
53
+ head.services.observedButUndocumented,
54
+ );
55
+ const introducedChannels = diffStringList(
56
+ base.channels.observedButUndocumented,
57
+ head.channels.observedButUndocumented,
58
+ );
59
+
60
+ const introduced: DriftEntries = {
61
+ events: {
62
+ observedButUndocumented: introducedEvents.added,
63
+ documentedButUnseen: introducedMissing.added,
64
+ fieldDrift: introducedFieldDrift.added,
65
+ typeDrift: diffTypeDrift(
66
+ base.events.typeDrift ?? [],
67
+ head.events.typeDrift ?? [],
68
+ ).added,
69
+ valueDrift: diffValueDrift(
70
+ base.events.valueDrift ?? [],
71
+ head.events.valueDrift ?? [],
72
+ ).added,
73
+ },
74
+ services: { observedButUndocumented: introducedServices.added },
75
+ channels: { observedButUndocumented: introducedChannels.added },
76
+ };
77
+
78
+ const resolved: DriftEntries = {
79
+ events: {
80
+ observedButUndocumented: introducedEvents.removed,
81
+ documentedButUnseen: introducedMissing.removed,
82
+ fieldDrift: introducedFieldDrift.removed,
83
+ typeDrift: diffTypeDrift(
84
+ base.events.typeDrift ?? [],
85
+ head.events.typeDrift ?? [],
86
+ ).removed,
87
+ valueDrift: diffValueDrift(
88
+ base.events.valueDrift ?? [],
89
+ head.events.valueDrift ?? [],
90
+ ).removed,
91
+ },
92
+ services: { observedButUndocumented: introducedServices.removed },
93
+ channels: { observedButUndocumented: introducedChannels.removed },
94
+ };
95
+
96
+ const hasNewDrift =
97
+ introduced.events.observedButUndocumented.length > 0 ||
98
+ introduced.events.documentedButUnseen.length > 0 ||
99
+ introduced.events.fieldDrift.length > 0 ||
100
+ introduced.events.typeDrift.length > 0 ||
101
+ introduced.events.valueDrift.length > 0 ||
102
+ introduced.services.observedButUndocumented.length > 0 ||
103
+ introduced.channels.observedButUndocumented.length > 0;
104
+
105
+ return { introduced, resolved, hasNewDrift };
106
+ }
107
+
108
+ /**
109
+ * Per-category counts for one side of a DriftDelta (introduced or resolved).
110
+ * Same shape as DriftCounts so dashboards and CI can render the
111
+ * introduced/resolved sections with identical accounting.
112
+ */
113
+ export function countDriftEntries(entries: DriftEntries): DriftCounts {
114
+ const fieldDriftEvents = entries.events.fieldDrift.length;
115
+ const fieldDriftPaths = entries.events.fieldDrift.reduce(
116
+ (sum, fd) => sum + fd.extra.length + fd.missing.length,
117
+ 0,
118
+ );
119
+ const observedButUndocumentedEvents =
120
+ entries.events.observedButUndocumented.length;
121
+ const documentedButUnseenEvents = entries.events.documentedButUnseen.length;
122
+ const undocumentedServices = entries.services.observedButUndocumented.length;
123
+ const undocumentedChannels = entries.channels.observedButUndocumented.length;
124
+ const typeDriftPaths = (entries.events.typeDrift ?? []).length;
125
+ const valueDriftPaths = (entries.events.valueDrift ?? []).length;
126
+
127
+ return {
128
+ observedButUndocumentedEvents,
129
+ documentedButUnseenEvents,
130
+ fieldDriftEvents,
131
+ fieldDriftPaths,
132
+ typeDriftPaths,
133
+ valueDriftPaths,
134
+ undocumentedServices,
135
+ undocumentedChannels,
136
+ total:
137
+ observedButUndocumentedEvents +
138
+ documentedButUnseenEvents +
139
+ fieldDriftPaths +
140
+ typeDriftPaths +
141
+ valueDriftPaths +
142
+ undocumentedServices +
143
+ undocumentedChannels,
144
+ };
145
+ }
146
+
147
+ export function countDriftDelta(delta: DriftDelta): {
148
+ introduced: DriftCounts;
149
+ resolved: DriftCounts;
150
+ } {
151
+ return {
152
+ introduced: countDriftEntries(delta.introduced),
153
+ resolved: countDriftEntries(delta.resolved),
154
+ };
155
+ }
156
+
157
+ function diffStringList(
158
+ base: string[],
159
+ head: string[],
160
+ ): { added: string[]; removed: string[] } {
161
+ const baseSet = new Set(base);
162
+ const headSet = new Set(head);
163
+ return {
164
+ added: head.filter((s) => !baseSet.has(s)).sort(),
165
+ removed: base.filter((s) => !headSet.has(s)).sort(),
166
+ };
167
+ }
168
+
169
+ function diffFieldDrift(
170
+ base: FieldDrift[],
171
+ head: FieldDrift[],
172
+ ): { added: FieldDrift[]; removed: FieldDrift[] } {
173
+ const baseByEvent = new Map(base.map((d) => [d.event, d]));
174
+ const headByEvent = new Map(head.map((d) => [d.event, d]));
175
+
176
+ const added: FieldDrift[] = [];
177
+ const removed: FieldDrift[] = [];
178
+
179
+ for (const [event, h] of headByEvent) {
180
+ const b = baseByEvent.get(event);
181
+ if (!b) {
182
+ // Entire event's field drift is new to head.
183
+ added.push(h);
184
+ continue;
185
+ }
186
+ const addedExtra = h.extra.filter((p) => !b.extra.includes(p));
187
+ const addedMissing = h.missing.filter((p) => !b.missing.includes(p));
188
+ if (addedExtra.length > 0 || addedMissing.length > 0) {
189
+ added.push({ event, extra: addedExtra, missing: addedMissing });
190
+ }
191
+ }
192
+
193
+ for (const [event, b] of baseByEvent) {
194
+ const h = headByEvent.get(event);
195
+ if (!h) {
196
+ removed.push(b);
197
+ continue;
198
+ }
199
+ const removedExtra = b.extra.filter((p) => !h.extra.includes(p));
200
+ const removedMissing = b.missing.filter((p) => !h.missing.includes(p));
201
+ if (removedExtra.length > 0 || removedMissing.length > 0) {
202
+ removed.push({ event, extra: removedExtra, missing: removedMissing });
203
+ }
204
+ }
205
+
206
+ return { added, removed };
207
+ }
208
+
209
+ function diffTypeDrift(
210
+ base: TypeDrift[],
211
+ head: TypeDrift[],
212
+ ): { added: TypeDrift[]; removed: TypeDrift[] } {
213
+ return diffStructuredByKey(base, head, (x) => `${x.event}::${x.path}`);
214
+ }
215
+
216
+ function diffValueDrift(
217
+ base: ValueDrift[],
218
+ head: ValueDrift[],
219
+ ): { added: ValueDrift[]; removed: ValueDrift[] } {
220
+ return diffStructuredByKey(base, head, (x) => `${x.event}::${x.path}`);
221
+ }
222
+
223
+ function diffStructuredByKey<T>(
224
+ base: T[],
225
+ head: T[],
226
+ keyOf: (v: T) => string,
227
+ ): { added: T[]; removed: T[] } {
228
+ const baseMap = new Map(base.map((v) => [keyOf(v), v]));
229
+ const headMap = new Map(head.map((v) => [keyOf(v), v]));
230
+ const added: T[] = [];
231
+ const removed: T[] = [];
232
+
233
+ for (const [k, hv] of headMap) {
234
+ const bv = baseMap.get(k);
235
+ if (!bv || JSON.stringify(bv) !== JSON.stringify(hv)) added.push(hv);
236
+ }
237
+ for (const [k, bv] of baseMap) {
238
+ const hv = headMap.get(k);
239
+ if (!hv || JSON.stringify(hv) !== JSON.stringify(bv)) removed.push(bv);
240
+ }
241
+ return { added, removed };
242
+ }
@@ -0,0 +1,384 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { diffCatalogAgainstSnapshot, hasDrift, countDriftReport } from './diff';
3
+ import type { ArchitectureSnapshot } from './snapshot';
4
+ import type { CatalogState } from './catalog';
5
+
6
+ function snap(events: ArchitectureSnapshot['events']): ArchitectureSnapshot {
7
+ return {
8
+ spec: 'autotel-architecture/v0.1.0',
9
+ generatedAt: '2026-05-21T18:04:00.000Z',
10
+ service: 'example-eventcatalog',
11
+ events,
12
+ };
13
+ }
14
+
15
+ function catalog(opts: {
16
+ events?: Array<{
17
+ id: string;
18
+ declaredFieldPaths?: string[];
19
+ declaredSchemaConstraints?: Record<
20
+ string,
21
+ { types?: string[]; enumValues?: unknown[] }
22
+ >;
23
+ }>;
24
+ services?: string[];
25
+ channels?: string[];
26
+ }): CatalogState {
27
+ return {
28
+ events: new Map(
29
+ (opts.events ?? []).map((e) => [
30
+ e.id,
31
+ {
32
+ id: e.id,
33
+ filePath: `mock/${e.id}.mdx`,
34
+ declaredFieldPaths: e.declaredFieldPaths,
35
+ declaredSchemaConstraints: e.declaredSchemaConstraints,
36
+ },
37
+ ]),
38
+ ),
39
+ services: new Map(
40
+ (opts.services ?? []).map((id) => [
41
+ id,
42
+ { id, filePath: `mock/${id}.mdx` },
43
+ ]),
44
+ ),
45
+ channels: new Map(
46
+ (opts.channels ?? []).map((id) => [
47
+ id,
48
+ { id, filePath: `mock/${id}.mdx` },
49
+ ]),
50
+ ),
51
+ };
52
+ }
53
+
54
+ const baseObservation = {
55
+ observedCount: 5,
56
+ firstSeen: '2026-05-21T18:00:00.000Z',
57
+ lastSeen: '2026-05-21T18:04:00.000Z',
58
+ sampleTraceIds: [],
59
+ };
60
+
61
+ describe('diffCatalogAgainstSnapshot — event existence', () => {
62
+ it('matches dotted snapshot names to PascalCase catalog ids', () => {
63
+ const report = diffCatalogAgainstSnapshot(
64
+ snap({
65
+ 'order.placed': {
66
+ ...baseObservation,
67
+ name: 'order.placed',
68
+ fieldPaths: ['orderId'],
69
+ },
70
+ }),
71
+ catalog({ events: [{ id: 'OrderPlaced' }] }),
72
+ );
73
+
74
+ expect(report.events.observedButUndocumented).toEqual([]);
75
+ expect(report.events.documentedButUnseen).toEqual([]);
76
+ });
77
+
78
+ it('reports events in the snapshot that have no catalog entry', () => {
79
+ const report = diffCatalogAgainstSnapshot(
80
+ snap({
81
+ 'order.placed': {
82
+ ...baseObservation,
83
+ name: 'order.placed',
84
+ fieldPaths: [],
85
+ },
86
+ 'order.cancelled': {
87
+ ...baseObservation,
88
+ name: 'order.cancelled',
89
+ fieldPaths: [],
90
+ },
91
+ }),
92
+ catalog({ events: [{ id: 'OrderPlaced' }] }),
93
+ );
94
+
95
+ expect(report.events.observedButUndocumented).toEqual(['order.cancelled']);
96
+ });
97
+
98
+ it('reports catalog entries that the snapshot never observed', () => {
99
+ const report = diffCatalogAgainstSnapshot(
100
+ snap({
101
+ 'order.placed': {
102
+ ...baseObservation,
103
+ name: 'order.placed',
104
+ fieldPaths: [],
105
+ },
106
+ }),
107
+ catalog({ events: [{ id: 'OrderPlaced' }, { id: 'PaymentCaptured' }] }),
108
+ );
109
+
110
+ expect(report.events.documentedButUnseen).toEqual(['PaymentCaptured']);
111
+ });
112
+ });
113
+
114
+ describe('diffCatalogAgainstSnapshot — field drift', () => {
115
+ it('reports extra field paths in the observed payload', () => {
116
+ const report = diffCatalogAgainstSnapshot(
117
+ snap({
118
+ 'recommendation.generated': {
119
+ ...baseObservation,
120
+ name: 'recommendation.generated',
121
+ fieldPaths: ['orderId', 'model', 'personalization_seed'],
122
+ },
123
+ }),
124
+ catalog({
125
+ events: [
126
+ {
127
+ id: 'RecommendationGenerated',
128
+ declaredFieldPaths: ['orderId', 'model'],
129
+ },
130
+ ],
131
+ }),
132
+ );
133
+
134
+ expect(report.events.fieldDrift).toEqual([
135
+ {
136
+ event: 'recommendation.generated',
137
+ extra: ['personalization_seed'],
138
+ missing: [],
139
+ },
140
+ ]);
141
+ });
142
+
143
+ it('reports declared field paths that were never observed', () => {
144
+ const report = diffCatalogAgainstSnapshot(
145
+ snap({
146
+ 'order.placed': {
147
+ ...baseObservation,
148
+ name: 'order.placed',
149
+ fieldPaths: ['orderId'],
150
+ },
151
+ }),
152
+ catalog({
153
+ events: [
154
+ {
155
+ id: 'OrderPlaced',
156
+ declaredFieldPaths: ['orderId', 'customerId', 'totalCents'],
157
+ },
158
+ ],
159
+ }),
160
+ );
161
+
162
+ expect(report.events.fieldDrift).toEqual([
163
+ {
164
+ event: 'order.placed',
165
+ extra: [],
166
+ missing: ['customerId', 'totalCents'],
167
+ },
168
+ ]);
169
+ });
170
+
171
+ it('skips events without a declared schema', () => {
172
+ const report = diffCatalogAgainstSnapshot(
173
+ snap({
174
+ 'order.placed': {
175
+ ...baseObservation,
176
+ name: 'order.placed',
177
+ fieldPaths: ['orderId', 'extra'],
178
+ },
179
+ }),
180
+ catalog({ events: [{ id: 'OrderPlaced' }] }),
181
+ );
182
+
183
+ expect(report.events.fieldDrift).toEqual([]);
184
+ });
185
+ });
186
+
187
+ describe('diffCatalogAgainstSnapshot — type/value drift', () => {
188
+ it('reports type drift and enum value drift against declared schema constraints', () => {
189
+ const report = diffCatalogAgainstSnapshot(
190
+ snap({
191
+ 'order.placed': {
192
+ ...baseObservation,
193
+ name: 'order.placed',
194
+ fieldPaths: ['amountCents', 'status'],
195
+ fieldStats: {
196
+ amountCents: {
197
+ types: ['string'],
198
+ sampleValues: ['1299'],
199
+ },
200
+ status: {
201
+ types: ['string'],
202
+ sampleValues: ['placed'],
203
+ },
204
+ },
205
+ },
206
+ }),
207
+ catalog({
208
+ events: [
209
+ {
210
+ id: 'OrderPlaced',
211
+ declaredFieldPaths: ['amountCents', 'status'],
212
+ declaredSchemaConstraints: {
213
+ amountCents: { types: ['number'] },
214
+ status: { types: ['string'], enumValues: ['pending', 'paid'] },
215
+ },
216
+ },
217
+ ],
218
+ }),
219
+ );
220
+
221
+ expect(report.events.typeDrift).toEqual([
222
+ {
223
+ event: 'order.placed',
224
+ path: 'amountCents',
225
+ declared: ['number'],
226
+ observed: ['string'],
227
+ },
228
+ ]);
229
+ expect(report.events.valueDrift).toEqual([
230
+ {
231
+ event: 'order.placed',
232
+ path: 'status',
233
+ declared: ['pending', 'paid'],
234
+ observed: ['placed'],
235
+ },
236
+ ]);
237
+ });
238
+ });
239
+
240
+ describe('diffCatalogAgainstSnapshot — services and channels', () => {
241
+ it('reports producers that are not declared as services', () => {
242
+ const report = diffCatalogAgainstSnapshot(
243
+ snap({
244
+ 'order.placed': {
245
+ ...baseObservation,
246
+ name: 'order.placed',
247
+ fieldPaths: [],
248
+ producer: 'OrdersService',
249
+ },
250
+ 'payment.captured': {
251
+ ...baseObservation,
252
+ name: 'payment.captured',
253
+ fieldPaths: [],
254
+ producer: 'GhostService',
255
+ },
256
+ }),
257
+ catalog({
258
+ events: [{ id: 'OrderPlaced' }, { id: 'PaymentCaptured' }],
259
+ services: ['OrdersService'],
260
+ }),
261
+ );
262
+
263
+ expect(report.services.observedButUndocumented).toEqual(['GhostService']);
264
+ });
265
+
266
+ it('reports channels that are not declared in the catalog', () => {
267
+ const report = diffCatalogAgainstSnapshot(
268
+ snap({
269
+ 'order.placed': {
270
+ ...baseObservation,
271
+ name: 'order.placed',
272
+ fieldPaths: [],
273
+ channel: 'orders.events',
274
+ },
275
+ 'payment.captured': {
276
+ ...baseObservation,
277
+ name: 'payment.captured',
278
+ fieldPaths: [],
279
+ channel: 'rogue.events',
280
+ },
281
+ }),
282
+ catalog({
283
+ events: [{ id: 'OrderPlaced' }, { id: 'PaymentCaptured' }],
284
+ channels: ['orders.events'],
285
+ }),
286
+ );
287
+
288
+ expect(report.channels.observedButUndocumented).toEqual(['rogue.events']);
289
+ });
290
+ });
291
+
292
+ describe('hasDrift', () => {
293
+ it('returns false when every category is empty', () => {
294
+ const report = diffCatalogAgainstSnapshot(snap({}), catalog({}));
295
+ expect(hasDrift(report)).toBe(false);
296
+ });
297
+
298
+ it('returns true when any category has drift', () => {
299
+ const report = diffCatalogAgainstSnapshot(
300
+ snap({
301
+ 'new.event': { ...baseObservation, name: 'new.event', fieldPaths: [] },
302
+ }),
303
+ catalog({}),
304
+ );
305
+ expect(hasDrift(report)).toBe(true);
306
+ });
307
+ });
308
+
309
+ describe('countDriftReport', () => {
310
+ it('zero everywhere on a clean report', () => {
311
+ const c = countDriftReport(
312
+ diffCatalogAgainstSnapshot(snap({}), catalog({})),
313
+ );
314
+ expect(c.total).toBe(0);
315
+ expect(c.observedButUndocumentedEvents).toBe(0);
316
+ expect(c.documentedButUnseenEvents).toBe(0);
317
+ expect(c.fieldDriftEvents).toBe(0);
318
+ expect(c.fieldDriftPaths).toBe(0);
319
+ expect(c.undocumentedServices).toBe(0);
320
+ expect(c.undocumentedChannels).toBe(0);
321
+ });
322
+
323
+ it('distinguishes fieldDrift events from fieldDrift paths', () => {
324
+ const report = diffCatalogAgainstSnapshot(
325
+ snap({
326
+ 'recommendation.generated': {
327
+ ...baseObservation,
328
+ name: 'recommendation.generated',
329
+ fieldPaths: ['a', 'b', 'c'],
330
+ },
331
+ }),
332
+ catalog({
333
+ events: [
334
+ {
335
+ id: 'RecommendationGenerated',
336
+ declaredFieldPaths: ['a', 'd'], // observed: a,b,c → extras b,c; declared: a,d → missing d
337
+ },
338
+ ],
339
+ }),
340
+ );
341
+ const c = countDriftReport(report);
342
+ expect(c.fieldDriftEvents).toBe(1); // one event with drift
343
+ expect(c.fieldDriftPaths).toBe(3); // b extra + c extra + d missing
344
+ expect(c.total).toBe(3); // only field drift contributes
345
+ });
346
+
347
+ it('total matches the dashboard `countDrift` semantics', () => {
348
+ // Hand-build a report with one item in every category so total has
349
+ // an unambiguous expected value.
350
+ const report = diffCatalogAgainstSnapshot(
351
+ snap({
352
+ 'a.new': {
353
+ ...baseObservation,
354
+ name: 'a.new',
355
+ fieldPaths: ['x'],
356
+ producer: 'NewService',
357
+ channel: 'new.events',
358
+ },
359
+ 'b.match': {
360
+ ...baseObservation,
361
+ name: 'b.match',
362
+ fieldPaths: ['z', 'q'],
363
+ },
364
+ }),
365
+ catalog({
366
+ events: [
367
+ { id: 'BMatch', declaredFieldPaths: ['z', 'p'] }, // extra q, missing p
368
+ { id: 'LegacyEvent' }, // documented but unseen
369
+ ],
370
+ services: [],
371
+ channels: [],
372
+ }),
373
+ );
374
+ const c = countDriftReport(report);
375
+ // 1 observed-but-undoc (a.new) + 1 doc-but-unseen (LegacyEvent)
376
+ // + 2 field paths (q extra, p missing) + 1 service + 1 channel
377
+ expect(c.total).toBe(6);
378
+ expect(c.observedButUndocumentedEvents).toBe(1);
379
+ expect(c.documentedButUnseenEvents).toBe(1);
380
+ expect(c.fieldDriftPaths).toBe(2);
381
+ expect(c.undocumentedServices).toBe(1);
382
+ expect(c.undocumentedChannels).toBe(1);
383
+ });
384
+ });