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
package/dist/cli.cjs ADDED
@@ -0,0 +1,1071 @@
1
+ 'use strict';
2
+
3
+ var promises = require('fs/promises');
4
+ var path = require('path');
5
+ var utils = require('@eventcatalog/sdk');
6
+
7
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
8
+
9
+ var utils__default = /*#__PURE__*/_interopDefault(utils);
10
+
11
+ // src/cli.ts
12
+ async function loadSnapshot(path) {
13
+ const raw = await promises.readFile(path, "utf8");
14
+ const parsed = JSON.parse(raw);
15
+ if (!parsed?.spec?.startsWith("autotel-architecture/")) {
16
+ throw new Error(
17
+ `Not an autotel architecture snapshot (missing spec marker): ${path}`
18
+ );
19
+ }
20
+ return parsed;
21
+ }
22
+ async function readCatalogState(catalogPath) {
23
+ const sdk = utils__default.default(catalogPath);
24
+ const [events, services, channels] = await Promise.all([
25
+ sdk.getEvents({ latestOnly: true, attachSchema: true }),
26
+ sdk.getServices({ latestOnly: true }),
27
+ sdk.getChannels({ latestOnly: true })
28
+ ]);
29
+ const state = {
30
+ events: /* @__PURE__ */ new Map(),
31
+ services: /* @__PURE__ */ new Map(),
32
+ channels: /* @__PURE__ */ new Map()
33
+ };
34
+ const resolveFilePath = async (id, version) => {
35
+ const paths = await sdk.getResourcePath(catalogPath, id, version);
36
+ return paths?.fullPath ?? "";
37
+ };
38
+ for (const e of events ?? []) {
39
+ const filePath = await resolveFilePath(e.id, e.version);
40
+ const schemaExtractions = e.schema ? {
41
+ declaredFieldPaths: extractDeclaredFieldPaths(e.schema),
42
+ declaredSchemaConstraints: extractDeclaredSchemaConstraints(e.schema)
43
+ } : {};
44
+ state.events.set(e.id, { ...e, filePath, ...schemaExtractions });
45
+ }
46
+ for (const s of services ?? []) {
47
+ const filePath = await resolveFilePath(s.id, s.version);
48
+ state.services.set(s.id, { ...s, filePath });
49
+ }
50
+ for (const c of channels ?? []) {
51
+ const filePath = await resolveFilePath(c.id, c.version);
52
+ state.channels.set(c.id, { ...c, filePath });
53
+ }
54
+ return state;
55
+ }
56
+ function extractDeclaredFieldPaths(schema, prefix = "") {
57
+ const out = /* @__PURE__ */ new Set();
58
+ walkSchema(schema, prefix, out);
59
+ return [...out].toSorted();
60
+ }
61
+ function walkSchema(schema, prefix, out) {
62
+ if (!schema || typeof schema !== "object") return;
63
+ const s = schema;
64
+ if (s.properties && typeof s.properties === "object") {
65
+ for (const [key, sub] of Object.entries(
66
+ s.properties
67
+ )) {
68
+ const path = prefix === "" ? key : `${prefix}.${key}`;
69
+ out.add(path);
70
+ walkSchema(sub, path, out);
71
+ }
72
+ }
73
+ if (s.items) {
74
+ const arrayPrefix = prefix + "[]";
75
+ walkSchema(s.items, arrayPrefix, out);
76
+ }
77
+ }
78
+ function extractDeclaredSchemaConstraints(schema, prefix = "") {
79
+ const out = /* @__PURE__ */ new Map();
80
+ walkSchemaConstraints(schema, prefix, out);
81
+ const obj = {};
82
+ for (const [path, c] of out) obj[path] = c;
83
+ return obj;
84
+ }
85
+ function walkSchemaConstraints(schema, prefix, out) {
86
+ if (!schema || typeof schema !== "object") return;
87
+ const s = schema;
88
+ const typeVal = s.type;
89
+ const enumVal = s.enum;
90
+ if (prefix !== "" && (typeVal !== void 0 || enumVal !== void 0)) {
91
+ const types = toTypeArray(typeVal);
92
+ const enumValues = Array.isArray(enumVal) ? [...enumVal] : void 0;
93
+ out.set(prefix, {
94
+ ...types.length > 0 ? { types } : {},
95
+ ...enumValues ? { enumValues } : {}
96
+ });
97
+ }
98
+ if (s.properties && typeof s.properties === "object") {
99
+ for (const [key, sub] of Object.entries(
100
+ s.properties
101
+ )) {
102
+ const path = prefix === "" ? key : `${prefix}.${key}`;
103
+ walkSchemaConstraints(sub, path, out);
104
+ }
105
+ }
106
+ if (s.items) {
107
+ const arrayPrefix = prefix + "[]";
108
+ walkSchemaConstraints(s.items, arrayPrefix, out);
109
+ }
110
+ }
111
+ function toTypeArray(typeVal) {
112
+ if (typeof typeVal === "string") return [typeVal];
113
+ if (Array.isArray(typeVal)) {
114
+ return typeVal.filter((t) => typeof t === "string").toSorted();
115
+ }
116
+ return [];
117
+ }
118
+
119
+ // src/diff.ts
120
+ function hasDrift(report) {
121
+ const c = countDriftReport(report);
122
+ return c.total > 0;
123
+ }
124
+ function countDriftReport(report) {
125
+ const typeDrift = report.events.typeDrift ?? [];
126
+ const valueDrift = report.events.valueDrift ?? [];
127
+ const fieldDriftEvents = report.events.fieldDrift.length;
128
+ const fieldDriftPaths = report.events.fieldDrift.reduce(
129
+ (sum, fd) => sum + fd.extra.length + fd.missing.length,
130
+ 0
131
+ );
132
+ const typeDriftPaths = typeDrift.length;
133
+ const valueDriftPaths = valueDrift.length;
134
+ const observedButUndocumentedEvents = report.events.observedButUndocumented.length;
135
+ const documentedButUnseenEvents = report.events.documentedButUnseen.length;
136
+ const undocumentedServices = report.services.observedButUndocumented.length;
137
+ const undocumentedChannels = report.channels.observedButUndocumented.length;
138
+ return {
139
+ observedButUndocumentedEvents,
140
+ documentedButUnseenEvents,
141
+ fieldDriftEvents,
142
+ fieldDriftPaths,
143
+ typeDriftPaths,
144
+ valueDriftPaths,
145
+ undocumentedServices,
146
+ undocumentedChannels,
147
+ total: observedButUndocumentedEvents + documentedButUnseenEvents + fieldDriftPaths + typeDriftPaths + valueDriftPaths + undocumentedServices + undocumentedChannels
148
+ };
149
+ }
150
+ function diffCatalogAgainstSnapshot(snapshot, catalog) {
151
+ const snapshotEvents = new Set(Object.keys(snapshot.events));
152
+ const catalogEventIds = new Set(catalog.events.keys());
153
+ const catalogEventByNormalised = /* @__PURE__ */ new Map();
154
+ for (const id of catalogEventIds) {
155
+ catalogEventByNormalised.set(normaliseEventId(id), id);
156
+ }
157
+ const observedButUndocumented = [];
158
+ const matchedCatalogIds = /* @__PURE__ */ new Set();
159
+ for (const name of snapshotEvents) {
160
+ const matched = catalogEventByNormalised.get(normaliseEventId(name));
161
+ if (matched) {
162
+ matchedCatalogIds.add(matched);
163
+ } else {
164
+ observedButUndocumented.push(name);
165
+ }
166
+ }
167
+ const documentedButUnseen = [];
168
+ for (const id of catalogEventIds) {
169
+ if (!matchedCatalogIds.has(id)) documentedButUnseen.push(id);
170
+ }
171
+ const fieldDrift = [];
172
+ const typeDrift = [];
173
+ const valueDrift = [];
174
+ for (const [snapName, obs] of Object.entries(snapshot.events)) {
175
+ const catalogId = catalogEventByNormalised.get(normaliseEventId(snapName));
176
+ if (!catalogId) continue;
177
+ const eventDecl = catalog.events.get(catalogId);
178
+ const declared = eventDecl?.declaredFieldPaths;
179
+ if (!declared) continue;
180
+ const declaredSet = new Set(declared);
181
+ const observedSet = new Set(obs.fieldPaths);
182
+ const extra = obs.fieldPaths.filter((p) => !declaredSet.has(p));
183
+ const missing = declared.filter((p) => !observedSet.has(p));
184
+ if (extra.length > 0 || missing.length > 0) {
185
+ fieldDrift.push({ event: snapName, extra, missing });
186
+ }
187
+ const constraints = eventDecl?.declaredSchemaConstraints ?? {};
188
+ const stats = obs.fieldStats ?? {};
189
+ for (const [path, declaredConstraint] of Object.entries(constraints)) {
190
+ const observed = stats[path];
191
+ if (!observed) continue;
192
+ if (declaredConstraint.types && declaredConstraint.types.length > 0) {
193
+ const accepts = expandDeclaredTypes(declaredConstraint.types);
194
+ const badTypes = observed.types.filter((t) => !accepts.has(t));
195
+ const integerDeclared = declaredConstraint.types.includes("integer") && !declaredConstraint.types.includes("number");
196
+ const nonIntegerSamples = integerDeclared ? observed.sampleValues.filter(
197
+ (v) => typeof v === "number" && !Number.isInteger(v)
198
+ ) : [];
199
+ if (badTypes.length > 0 || nonIntegerSamples.length > 0) {
200
+ typeDrift.push({
201
+ event: snapName,
202
+ path,
203
+ declared: declaredConstraint.types,
204
+ observed: [...new Set(observed.types)].toSorted()
205
+ });
206
+ }
207
+ }
208
+ if (declaredConstraint.enumValues && declaredConstraint.enumValues.length > 0 && observed.sampleValues.length > 0) {
209
+ const observedOutsideEnum = observed.sampleValues.filter(
210
+ (v) => !declaredConstraint.enumValues?.some((d) => Object.is(d, v))
211
+ );
212
+ if (observedOutsideEnum.length > 0) {
213
+ valueDrift.push({
214
+ event: snapName,
215
+ path,
216
+ declared: declaredConstraint.enumValues,
217
+ observed: [...new Set(observedOutsideEnum)]
218
+ });
219
+ }
220
+ }
221
+ }
222
+ }
223
+ const snapshotServices = collectProducers(snapshot);
224
+ const catalogServiceIds = new Set(catalog.services.keys());
225
+ const undocumentedServices = [...snapshotServices].filter(
226
+ (id) => !catalogServiceIds.has(id)
227
+ );
228
+ const snapshotChannels = collectChannels(snapshot);
229
+ const catalogChannelIds = new Set(catalog.channels.keys());
230
+ const undocumentedChannels = [...snapshotChannels].filter(
231
+ (id) => !catalogChannelIds.has(id)
232
+ );
233
+ return {
234
+ snapshotGeneratedAt: snapshot.generatedAt,
235
+ snapshotService: snapshot.service,
236
+ events: {
237
+ observedButUndocumented: observedButUndocumented.sort(),
238
+ documentedButUnseen: documentedButUnseen.sort(),
239
+ fieldDrift: fieldDrift.sort((a, b) => a.event.localeCompare(b.event)),
240
+ typeDrift: typeDrift.sort(
241
+ (a, b) => `${a.event}.${a.path}`.localeCompare(`${b.event}.${b.path}`)
242
+ ),
243
+ valueDrift: valueDrift.sort(
244
+ (a, b) => `${a.event}.${a.path}`.localeCompare(`${b.event}.${b.path}`)
245
+ )
246
+ },
247
+ services: { observedButUndocumented: undocumentedServices.sort() },
248
+ channels: { observedButUndocumented: undocumentedChannels.sort() }
249
+ };
250
+ }
251
+ function expandDeclaredTypes(declared) {
252
+ const accepts = new Set(declared);
253
+ if (declared.includes("integer")) accepts.add("number");
254
+ return accepts;
255
+ }
256
+ function normaliseEventId(id) {
257
+ return id.toLowerCase().replaceAll(/[._\-\s]/g, "");
258
+ }
259
+ function collectProducers(snapshot) {
260
+ const out = /* @__PURE__ */ new Set();
261
+ for (const obs of Object.values(snapshot.events)) {
262
+ if (obs.producer) out.add(obs.producer);
263
+ }
264
+ return out;
265
+ }
266
+ function collectChannels(snapshot) {
267
+ const out = /* @__PURE__ */ new Set();
268
+ for (const obs of Object.values(snapshot.events)) {
269
+ if (obs.channel) out.add(obs.channel);
270
+ }
271
+ return out;
272
+ }
273
+
274
+ // src/diff-vs-base.ts
275
+ function compareDriftReports(base, head) {
276
+ const introducedEvents = diffStringList(
277
+ base.events.observedButUndocumented,
278
+ head.events.observedButUndocumented
279
+ );
280
+ const introducedMissing = diffStringList(
281
+ base.events.documentedButUnseen,
282
+ head.events.documentedButUnseen
283
+ );
284
+ const introducedFieldDrift = diffFieldDrift(
285
+ base.events.fieldDrift,
286
+ head.events.fieldDrift
287
+ );
288
+ const introducedServices = diffStringList(
289
+ base.services.observedButUndocumented,
290
+ head.services.observedButUndocumented
291
+ );
292
+ const introducedChannels = diffStringList(
293
+ base.channels.observedButUndocumented,
294
+ head.channels.observedButUndocumented
295
+ );
296
+ const introduced = {
297
+ events: {
298
+ observedButUndocumented: introducedEvents.added,
299
+ documentedButUnseen: introducedMissing.added,
300
+ fieldDrift: introducedFieldDrift.added,
301
+ typeDrift: diffTypeDrift(
302
+ base.events.typeDrift ?? [],
303
+ head.events.typeDrift ?? []
304
+ ).added,
305
+ valueDrift: diffValueDrift(
306
+ base.events.valueDrift ?? [],
307
+ head.events.valueDrift ?? []
308
+ ).added
309
+ },
310
+ services: { observedButUndocumented: introducedServices.added },
311
+ channels: { observedButUndocumented: introducedChannels.added }
312
+ };
313
+ const resolved = {
314
+ events: {
315
+ observedButUndocumented: introducedEvents.removed,
316
+ documentedButUnseen: introducedMissing.removed,
317
+ fieldDrift: introducedFieldDrift.removed,
318
+ typeDrift: diffTypeDrift(
319
+ base.events.typeDrift ?? [],
320
+ head.events.typeDrift ?? []
321
+ ).removed,
322
+ valueDrift: diffValueDrift(
323
+ base.events.valueDrift ?? [],
324
+ head.events.valueDrift ?? []
325
+ ).removed
326
+ },
327
+ services: { observedButUndocumented: introducedServices.removed },
328
+ channels: { observedButUndocumented: introducedChannels.removed }
329
+ };
330
+ const hasNewDrift = introduced.events.observedButUndocumented.length > 0 || introduced.events.documentedButUnseen.length > 0 || introduced.events.fieldDrift.length > 0 || introduced.events.typeDrift.length > 0 || introduced.events.valueDrift.length > 0 || introduced.services.observedButUndocumented.length > 0 || introduced.channels.observedButUndocumented.length > 0;
331
+ return { introduced, resolved, hasNewDrift };
332
+ }
333
+ function countDriftEntries(entries) {
334
+ const fieldDriftEvents = entries.events.fieldDrift.length;
335
+ const fieldDriftPaths = entries.events.fieldDrift.reduce(
336
+ (sum, fd) => sum + fd.extra.length + fd.missing.length,
337
+ 0
338
+ );
339
+ const observedButUndocumentedEvents = entries.events.observedButUndocumented.length;
340
+ const documentedButUnseenEvents = entries.events.documentedButUnseen.length;
341
+ const undocumentedServices = entries.services.observedButUndocumented.length;
342
+ const undocumentedChannels = entries.channels.observedButUndocumented.length;
343
+ const typeDriftPaths = (entries.events.typeDrift ?? []).length;
344
+ const valueDriftPaths = (entries.events.valueDrift ?? []).length;
345
+ return {
346
+ observedButUndocumentedEvents,
347
+ documentedButUnseenEvents,
348
+ fieldDriftEvents,
349
+ fieldDriftPaths,
350
+ typeDriftPaths,
351
+ valueDriftPaths,
352
+ undocumentedServices,
353
+ undocumentedChannels,
354
+ total: observedButUndocumentedEvents + documentedButUnseenEvents + fieldDriftPaths + typeDriftPaths + valueDriftPaths + undocumentedServices + undocumentedChannels
355
+ };
356
+ }
357
+ function diffStringList(base, head) {
358
+ const baseSet = new Set(base);
359
+ const headSet = new Set(head);
360
+ return {
361
+ added: head.filter((s) => !baseSet.has(s)).sort(),
362
+ removed: base.filter((s) => !headSet.has(s)).sort()
363
+ };
364
+ }
365
+ function diffFieldDrift(base, head) {
366
+ const baseByEvent = new Map(base.map((d) => [d.event, d]));
367
+ const headByEvent = new Map(head.map((d) => [d.event, d]));
368
+ const added = [];
369
+ const removed = [];
370
+ for (const [event, h] of headByEvent) {
371
+ const b = baseByEvent.get(event);
372
+ if (!b) {
373
+ added.push(h);
374
+ continue;
375
+ }
376
+ const addedExtra = h.extra.filter((p) => !b.extra.includes(p));
377
+ const addedMissing = h.missing.filter((p) => !b.missing.includes(p));
378
+ if (addedExtra.length > 0 || addedMissing.length > 0) {
379
+ added.push({ event, extra: addedExtra, missing: addedMissing });
380
+ }
381
+ }
382
+ for (const [event, b] of baseByEvent) {
383
+ const h = headByEvent.get(event);
384
+ if (!h) {
385
+ removed.push(b);
386
+ continue;
387
+ }
388
+ const removedExtra = b.extra.filter((p) => !h.extra.includes(p));
389
+ const removedMissing = b.missing.filter((p) => !h.missing.includes(p));
390
+ if (removedExtra.length > 0 || removedMissing.length > 0) {
391
+ removed.push({ event, extra: removedExtra, missing: removedMissing });
392
+ }
393
+ }
394
+ return { added, removed };
395
+ }
396
+ function diffTypeDrift(base, head) {
397
+ return diffStructuredByKey(base, head, (x) => `${x.event}::${x.path}`);
398
+ }
399
+ function diffValueDrift(base, head) {
400
+ return diffStructuredByKey(base, head, (x) => `${x.event}::${x.path}`);
401
+ }
402
+ function diffStructuredByKey(base, head, keyOf) {
403
+ const baseMap = new Map(base.map((v) => [keyOf(v), v]));
404
+ const headMap = new Map(head.map((v) => [keyOf(v), v]));
405
+ const added = [];
406
+ const removed = [];
407
+ for (const [k, hv] of headMap) {
408
+ const bv = baseMap.get(k);
409
+ if (!bv || JSON.stringify(bv) !== JSON.stringify(hv)) added.push(hv);
410
+ }
411
+ for (const [k, bv] of baseMap) {
412
+ const hv = headMap.get(k);
413
+ if (!hv || JSON.stringify(hv) !== JSON.stringify(bv)) removed.push(bv);
414
+ }
415
+ return { added, removed };
416
+ }
417
+
418
+ // src/renderers/markdown.ts
419
+ function renderMarkdown(report) {
420
+ const lines = [
421
+ "# Architecture drift report",
422
+ "",
423
+ `_Snapshot from \`${report.snapshotService}\` at ${report.snapshotGeneratedAt}_`,
424
+ ""
425
+ ];
426
+ if (!hasDrift(report)) {
427
+ lines.push("No drift detected. Catalog and runtime agree.", "");
428
+ return lines.join("\n");
429
+ }
430
+ if (report.events.observedButUndocumented.length > 0) {
431
+ lines.push(
432
+ "## Events observed but undocumented",
433
+ "",
434
+ "These event names appear in the snapshot but no matching entry",
435
+ "exists in the catalog. Add them or stop emitting them.",
436
+ ""
437
+ );
438
+ for (const name of report.events.observedButUndocumented) {
439
+ lines.push(`- \`${name}\``);
440
+ }
441
+ lines.push("");
442
+ }
443
+ if (report.events.documentedButUnseen.length > 0) {
444
+ lines.push(
445
+ "## Events documented but never observed",
446
+ "",
447
+ "These events exist in the catalog but no payload was captured.",
448
+ "Either the tests do not exercise this event, or it has been removed.",
449
+ ""
450
+ );
451
+ for (const name of report.events.documentedButUnseen) {
452
+ lines.push(`- \`${name}\``);
453
+ }
454
+ lines.push("");
455
+ }
456
+ if (report.events.fieldDrift.length > 0) {
457
+ lines.push("## Field-path drift", "");
458
+ for (const drift of report.events.fieldDrift) {
459
+ lines.push(`### \`${drift.event}\``, "");
460
+ if (drift.extra.length > 0) {
461
+ lines.push(
462
+ "**Extra fields in payloads (not in declared schema):**",
463
+ ""
464
+ );
465
+ for (const p of drift.extra) lines.push(`- \`${p}\``);
466
+ lines.push("");
467
+ }
468
+ if (drift.missing.length > 0) {
469
+ lines.push("**Fields declared but never observed:**", "");
470
+ for (const p of drift.missing) lines.push(`- \`${p}\``);
471
+ lines.push("");
472
+ }
473
+ }
474
+ }
475
+ if ((report.events.typeDrift ?? []).length > 0) {
476
+ lines.push("## Type drift", "");
477
+ for (const drift of report.events.typeDrift ?? []) {
478
+ lines.push(
479
+ `- \`${drift.event}\` \`${drift.path}\``,
480
+ ` declared: \`${drift.declared.join(" | ")}\`, observed: \`${drift.observed.join(" | ")}\``
481
+ );
482
+ }
483
+ lines.push("");
484
+ }
485
+ if ((report.events.valueDrift ?? []).length > 0) {
486
+ lines.push("## Value drift", "");
487
+ for (const drift of report.events.valueDrift ?? []) {
488
+ lines.push(
489
+ `- \`${drift.event}\` \`${drift.path}\``,
490
+ ` declared enum: \`${drift.declared.map((v) => JSON.stringify(v)).join(", ")}\`, observed: \`${drift.observed.map((v) => JSON.stringify(v)).join(", ")}\``
491
+ );
492
+ }
493
+ lines.push("");
494
+ }
495
+ if (report.services.observedButUndocumented.length > 0) {
496
+ lines.push("## Services observed but undocumented", "");
497
+ for (const id of report.services.observedButUndocumented) {
498
+ lines.push(`- \`${id}\``);
499
+ }
500
+ lines.push("");
501
+ }
502
+ if (report.channels.observedButUndocumented.length > 0) {
503
+ lines.push("## Channels observed but undocumented", "");
504
+ for (const id of report.channels.observedButUndocumented) {
505
+ lines.push(`- \`${id}\``);
506
+ }
507
+ lines.push("");
508
+ }
509
+ return lines.join("\n");
510
+ }
511
+ function renderDeltaMarkdown(delta) {
512
+ const lines = [
513
+ "# Architecture drift \u2014 what this change introduces",
514
+ ""
515
+ ];
516
+ if (!delta.hasNewDrift) {
517
+ const fixedAny = entriesHasContent(delta.resolved);
518
+ if (fixedAny) {
519
+ lines.push("No new drift. The changes below resolve existing drift:", "");
520
+ renderEntries(delta.resolved, lines, { sign: "\u2212" });
521
+ } else {
522
+ lines.push("No new drift detected. Catalog and runtime agree.");
523
+ }
524
+ lines.push("");
525
+ return lines.join("\n");
526
+ }
527
+ lines.push("This change introduces drift:", "");
528
+ renderEntries(delta.introduced, lines, { sign: "+" });
529
+ if (entriesHasContent(delta.resolved)) {
530
+ lines.push("", "### Resolved by this change", "");
531
+ renderEntries(delta.resolved, lines, { sign: "\u2212" });
532
+ }
533
+ return lines.join("\n");
534
+ }
535
+ function entriesHasContent(e) {
536
+ return e.events.observedButUndocumented.length > 0 || e.events.documentedButUnseen.length > 0 || e.events.fieldDrift.length > 0 || (e.events.typeDrift ?? []).length > 0 || (e.events.valueDrift ?? []).length > 0 || e.services.observedButUndocumented.length > 0 || e.channels.observedButUndocumented.length > 0;
537
+ }
538
+ function renderEntries(entries, out, options) {
539
+ if (entries.events.observedButUndocumented.length > 0) {
540
+ out.push("**Events observed but undocumented**", "");
541
+ for (const n of entries.events.observedButUndocumented) {
542
+ out.push(`- \`${n}\``);
543
+ }
544
+ out.push("");
545
+ }
546
+ if (entries.events.documentedButUnseen.length > 0) {
547
+ out.push("**Events documented but never observed**", "");
548
+ for (const n of entries.events.documentedButUnseen) {
549
+ out.push(`- \`${n}\``);
550
+ }
551
+ out.push("");
552
+ }
553
+ for (const fd of entries.events.fieldDrift) {
554
+ out.push(`**Field drift on \`${fd.event}\`**`, "");
555
+ for (const p of fd.extra) out.push(`- ${options.sign} \`${p}\` (extra)`);
556
+ for (const p of fd.missing)
557
+ out.push(`- ${options.sign} \`${p}\` (missing)`);
558
+ out.push("");
559
+ }
560
+ for (const td of entries.events.typeDrift ?? []) {
561
+ out.push(
562
+ `**Type drift on \`${td.event}\` \`${td.path}\`**`,
563
+ "",
564
+ `- ${options.sign} declared \`${td.declared.join(" | ")}\`, observed \`${td.observed.join(" | ")}\``,
565
+ ""
566
+ );
567
+ }
568
+ for (const vd of entries.events.valueDrift ?? []) {
569
+ out.push(
570
+ `**Value drift on \`${vd.event}\` \`${vd.path}\`**`,
571
+ "",
572
+ `- ${options.sign} declared enum \`${vd.declared.map((v) => JSON.stringify(v)).join(", ")}\`, observed \`${vd.observed.map((v) => JSON.stringify(v)).join(", ")}\``,
573
+ ""
574
+ );
575
+ }
576
+ if (entries.services.observedButUndocumented.length > 0) {
577
+ out.push("**Services observed but undocumented**", "");
578
+ for (const id of entries.services.observedButUndocumented) {
579
+ out.push(`- \`${id}\``);
580
+ }
581
+ out.push("");
582
+ }
583
+ if (entries.channels.observedButUndocumented.length > 0) {
584
+ out.push("**Channels observed but undocumented**", "");
585
+ for (const id of entries.channels.observedButUndocumented) {
586
+ out.push(`- \`${id}\``);
587
+ }
588
+ out.push("");
589
+ }
590
+ }
591
+ var markdownRenderer = {
592
+ name: "markdown",
593
+ description: "GitHub-flavoured Markdown (default). Suitable for sticky PR comments.",
594
+ renderReport: renderMarkdown,
595
+ renderDelta: renderDeltaMarkdown
596
+ };
597
+
598
+ // src/renderers/terminal.ts
599
+ function renderTerminal(report) {
600
+ return stripMarkdownDecorations(renderMarkdown(report));
601
+ }
602
+ function renderDeltaTerminal(delta) {
603
+ return stripMarkdownDecorations(renderDeltaMarkdown(delta));
604
+ }
605
+ function stripMarkdownDecorations(md) {
606
+ return md.replaceAll(/^#+\s+/gm, "").replaceAll("`", "").replaceAll(/\*\*([^*]+)\*\*/g, "$1");
607
+ }
608
+ var terminalRenderer = {
609
+ name: "terminal",
610
+ description: "Plain text. Same content as markdown, decorations stripped.",
611
+ renderReport: renderTerminal,
612
+ renderDelta: renderDeltaTerminal
613
+ };
614
+
615
+ // src/renderers/json.ts
616
+ var REPORT_SPEC = "autotel-eventcatalog-report/v0.2.0";
617
+ function renderJson(data) {
618
+ const envelope = { spec: REPORT_SPEC, ...data };
619
+ return JSON.stringify(envelope, null, 2);
620
+ }
621
+ var jsonRenderer = {
622
+ name: "json",
623
+ description: "Versioned JSON envelope. Validate against schemas/drift-report-v0.2.0.json.",
624
+ renderReport: (report) => renderJson({ mode: "all", report }),
625
+ renderDelta: (delta) => renderJson({ mode: "new-only", delta })
626
+ };
627
+
628
+ // src/renderers/index.ts
629
+ var RENDERERS = [
630
+ markdownRenderer,
631
+ terminalRenderer,
632
+ jsonRenderer
633
+ ];
634
+ var RENDERER_NAMES = RENDERERS.map((r) => r.name);
635
+ function getRenderer(name) {
636
+ return RENDERERS.find((r) => r.name === name);
637
+ }
638
+
639
+ // src/policy.ts
640
+ function evaluatePolicy(input) {
641
+ if (input.mode === "all") {
642
+ const shouldFail2 = hasDrift(input.report);
643
+ return {
644
+ shouldFail: shouldFail2,
645
+ reason: shouldFail2 ? "Drift detected in current snapshot." : "No drift detected."
646
+ };
647
+ }
648
+ const shouldFail = input.delta.hasNewDrift;
649
+ return {
650
+ shouldFail,
651
+ reason: shouldFail ? "New drift introduced compared to baseline snapshot." : "No new drift introduced compared to baseline snapshot."
652
+ };
653
+ }
654
+ var STAMP_START = "<!-- autotel:stamp-start -->";
655
+ var STAMP_END = "<!-- autotel:stamp-end -->";
656
+ async function stampCatalog(opts) {
657
+ const { snapshot, catalogPath, dryRun = false } = opts;
658
+ const catalog = await readCatalogState(catalogPath);
659
+ const catalogByNormalised = /* @__PURE__ */ new Map();
660
+ for (const [id, ev] of catalog.events) {
661
+ catalogByNormalised.set(normaliseEventId2(id), {
662
+ id,
663
+ filePath: ev.filePath
664
+ });
665
+ }
666
+ const updates = [];
667
+ const skips = [];
668
+ for (const [name, obs] of Object.entries(snapshot.events)) {
669
+ const match = catalogByNormalised.get(normaliseEventId2(name));
670
+ if (!match) {
671
+ skips.push({ snapshotName: name, reason: "no-catalog-match" });
672
+ continue;
673
+ }
674
+ const block = buildStampBlock(obs);
675
+ const { action, changed } = await stampFile(match.filePath, block, dryRun);
676
+ updates.push({
677
+ catalogId: match.id,
678
+ snapshotName: name,
679
+ filePath: match.filePath,
680
+ action,
681
+ changed
682
+ });
683
+ }
684
+ return { updates, skips };
685
+ }
686
+ function buildStampBlock(obs) {
687
+ const lines = [
688
+ STAMP_START,
689
+ "",
690
+ '<div class="evidence-callout">',
691
+ "<strong>Observed in autotel snapshot</strong>",
692
+ ""
693
+ ];
694
+ const facts = [];
695
+ facts.push(`**Volume**: ${obs.observedCount.toLocaleString()} events`);
696
+ facts.push(`**Last seen**: ${formatTimestamp(obs.lastSeen)}`);
697
+ if (obs.producer) facts.push(`**Producer**: ${obs.producer}`);
698
+ if (obs.channel) facts.push(`**Channel**: \`${obs.channel}\``);
699
+ lines.push(facts.join(" \xB7 "));
700
+ if (obs.fieldPaths.length > 0) {
701
+ lines.push("");
702
+ lines.push(
703
+ `**Field paths observed**: ${obs.fieldPaths.map((p) => `\`${p}\``).join(", ")}`
704
+ );
705
+ }
706
+ if (obs.sampleTraceIds.length > 0) {
707
+ lines.push("");
708
+ lines.push(
709
+ `**Sample traces**: ${obs.sampleTraceIds.map((t) => `\`${t}\``).join(", ")}`
710
+ );
711
+ }
712
+ lines.push("</div>", "", STAMP_END);
713
+ return lines.join("\n");
714
+ }
715
+ async function stampFile(filePath, block, dryRun) {
716
+ const content = await promises.readFile(filePath, "utf8");
717
+ const startIdx = content.indexOf(STAMP_START);
718
+ const endIdx = content.indexOf(STAMP_END);
719
+ let next;
720
+ let action;
721
+ if (startIdx !== -1 && endIdx > startIdx) {
722
+ const before = content.slice(0, startIdx);
723
+ const after = content.slice(endIdx + STAMP_END.length);
724
+ next = before + block + after;
725
+ action = "replace";
726
+ } else {
727
+ const footerIdx = content.search(/<Footer\s*\/>/);
728
+ const insertion = "\n\n" + block + "\n";
729
+ next = footerIdx >= 0 ? content.slice(0, footerIdx) + insertion + "\n" + content.slice(footerIdx) : content.replace(/\s*$/, "") + insertion;
730
+ action = "insert";
731
+ }
732
+ const changed = next !== content;
733
+ if (!dryRun && changed) {
734
+ await promises.writeFile(filePath, next, "utf8");
735
+ }
736
+ return { action, changed };
737
+ }
738
+ var STAMP_SUMMARY_SPEC = "autotel-eventcatalog-stamp-summary/v0.1.0";
739
+ function buildStampSummary(result, dryRun) {
740
+ const inserts = result.updates.filter((u) => u.action === "insert").length;
741
+ const replaces = result.updates.filter((u) => u.action === "replace").length;
742
+ const changedFiles = result.updates.filter((u) => u.changed).length;
743
+ return {
744
+ spec: STAMP_SUMMARY_SPEC,
745
+ dryRun,
746
+ attempted: result.updates.length + result.skips.length,
747
+ skipped: result.skips.length,
748
+ inserts,
749
+ replaces,
750
+ changedFiles,
751
+ hadChanges: changedFiles > 0
752
+ };
753
+ }
754
+ function normaliseEventId2(id) {
755
+ return id.toLowerCase().replaceAll(/[._\-\s]/g, "");
756
+ }
757
+ function formatTimestamp(iso) {
758
+ const d = new Date(iso);
759
+ const pad = (n) => String(n).padStart(2, "0");
760
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
761
+ }
762
+
763
+ // src/cli.ts
764
+ function parseArgs(argv) {
765
+ const [command, ...rest] = argv;
766
+ if (command !== "drift" && command !== "stamp") {
767
+ usage();
768
+ process.exit(2);
769
+ }
770
+ if (command === "stamp") {
771
+ return parseStampArgs(rest);
772
+ }
773
+ return parseDriftArgs(rest);
774
+ }
775
+ function parseDriftArgs(rest) {
776
+ let snapshot;
777
+ let baseSnapshot;
778
+ let catalog;
779
+ let output;
780
+ let summaryOutput;
781
+ let failOnDrift = false;
782
+ let policy;
783
+ let format = "markdown";
784
+ for (let i = 0; i < rest.length; i++) {
785
+ const arg = rest[i];
786
+ const next = rest[i + 1];
787
+ switch (arg) {
788
+ case "--snapshot": {
789
+ snapshot = next;
790
+ i++;
791
+ break;
792
+ }
793
+ case "--base-snapshot": {
794
+ baseSnapshot = next;
795
+ i++;
796
+ break;
797
+ }
798
+ case "--catalog": {
799
+ catalog = next;
800
+ i++;
801
+ break;
802
+ }
803
+ case "--output": {
804
+ output = next;
805
+ i++;
806
+ break;
807
+ }
808
+ case "--summary-output": {
809
+ summaryOutput = next;
810
+ i++;
811
+ break;
812
+ }
813
+ case "--fail-on-drift": {
814
+ failOnDrift = true;
815
+ break;
816
+ }
817
+ case "--policy": {
818
+ if (next !== "all" && next !== "new-only") {
819
+ process.stderr.write(
820
+ `Invalid --policy value: ${next}. Use 'all' or 'new-only'.
821
+ `
822
+ );
823
+ process.exit(2);
824
+ }
825
+ policy = next;
826
+ i++;
827
+ break;
828
+ }
829
+ case "--format": {
830
+ if (!getRenderer(next)) {
831
+ process.stderr.write(
832
+ `Invalid --format value: ${next}. Available renderers: ${RENDERER_NAMES.join(", ")}.
833
+ `
834
+ );
835
+ process.exit(2);
836
+ }
837
+ format = next;
838
+ i++;
839
+ break;
840
+ }
841
+ case "-h":
842
+ case "--help": {
843
+ usage();
844
+ process.exit(0);
845
+ break;
846
+ }
847
+ default: {
848
+ process.stderr.write(`Unknown argument: ${arg}
849
+ `);
850
+ usage();
851
+ process.exit(2);
852
+ }
853
+ }
854
+ }
855
+ if (!snapshot || !catalog) {
856
+ process.stderr.write("Both --snapshot and --catalog are required.\n");
857
+ usage();
858
+ process.exit(2);
859
+ }
860
+ if (policy === "new-only" && !baseSnapshot) {
861
+ process.stderr.write("--policy new-only requires --base-snapshot.\n");
862
+ process.exit(2);
863
+ }
864
+ return {
865
+ command: "drift",
866
+ snapshot: path.resolve(snapshot),
867
+ baseSnapshot: baseSnapshot ? path.resolve(baseSnapshot) : void 0,
868
+ catalog: path.resolve(catalog),
869
+ output: output ? path.resolve(output) : void 0,
870
+ summaryOutput: summaryOutput ? path.resolve(summaryOutput) : void 0,
871
+ failOnDrift,
872
+ policy,
873
+ format
874
+ };
875
+ }
876
+ function parseStampArgs(rest) {
877
+ let snapshot;
878
+ let catalog;
879
+ let dryRun = false;
880
+ let summaryOutput;
881
+ for (let i = 0; i < rest.length; i++) {
882
+ const arg = rest[i];
883
+ const next = rest[i + 1];
884
+ switch (arg) {
885
+ case "--snapshot": {
886
+ snapshot = next;
887
+ i++;
888
+ break;
889
+ }
890
+ case "--catalog": {
891
+ catalog = next;
892
+ i++;
893
+ break;
894
+ }
895
+ case "--dry-run": {
896
+ dryRun = true;
897
+ break;
898
+ }
899
+ case "--summary-output": {
900
+ summaryOutput = next;
901
+ i++;
902
+ break;
903
+ }
904
+ case "-h":
905
+ case "--help": {
906
+ usage();
907
+ process.exit(0);
908
+ break;
909
+ }
910
+ default: {
911
+ process.stderr.write(`Unknown argument: ${arg}
912
+ `);
913
+ usage();
914
+ process.exit(2);
915
+ }
916
+ }
917
+ }
918
+ if (!snapshot || !catalog) {
919
+ process.stderr.write(
920
+ "Both --snapshot and --catalog are required for stamp.\n"
921
+ );
922
+ usage();
923
+ process.exit(2);
924
+ }
925
+ return {
926
+ command: "stamp",
927
+ snapshot: path.resolve(snapshot),
928
+ catalog: path.resolve(catalog),
929
+ dryRun,
930
+ summaryOutput: summaryOutput ? path.resolve(summaryOutput) : void 0
931
+ };
932
+ }
933
+ function usage() {
934
+ process.stderr.write(
935
+ [
936
+ "Usage:",
937
+ " autotel-eventcatalog drift --snapshot <path> --catalog <path> [options]",
938
+ " autotel-eventcatalog stamp --snapshot <path> --catalog <path> [--dry-run]",
939
+ "",
940
+ "drift options:",
941
+ " --snapshot <path> Path to the autotel architecture snapshot JSON",
942
+ " --base-snapshot <path> Path to a baseline snapshot (typically PR base branch).",
943
+ " --catalog <path> Path to the EventCatalog root",
944
+ " --output <path> Write the report to this file",
945
+ " --summary-output <path> Write a machine-readable drift summary JSON file",
946
+ " --policy <mode> Drift fail policy: 'all' | 'new-only'",
947
+ ` --format <kind> Output format: ${RENDERER_NAMES.join(" | ")}`,
948
+ " --fail-on-drift Exit non-zero when policy marks drift as failing",
949
+ "",
950
+ "stamp options:",
951
+ " --snapshot <path> Architecture snapshot JSON",
952
+ " --catalog <path> EventCatalog root",
953
+ " --dry-run Print the update plan without writing files",
954
+ " --summary-output <path> Write a machine-readable stamp summary JSON file",
955
+ "",
956
+ " -h, --help Show this help",
957
+ ""
958
+ ].join("\n")
959
+ );
960
+ }
961
+ async function runDrift(args) {
962
+ const headSnapshot = await loadSnapshot(args.snapshot);
963
+ const catalog = await readCatalogState(args.catalog);
964
+ const headReport = diffCatalogAgainstSnapshot(headSnapshot, catalog);
965
+ const delta = args.baseSnapshot ? compareDriftReports(
966
+ diffCatalogAgainstSnapshot(
967
+ await loadSnapshot(args.baseSnapshot),
968
+ catalog
969
+ ),
970
+ headReport
971
+ ) : void 0;
972
+ const effectivePolicy = args.policy ?? (delta ? "new-only" : "all");
973
+ const policyResult = effectivePolicy === "new-only" && delta ? evaluatePolicy({ mode: "new-only", delta }) : evaluatePolicy({ mode: "all", report: headReport });
974
+ const renderer = getRenderer(args.format);
975
+ if (!renderer) {
976
+ process.stderr.write(`Unknown renderer: ${args.format}
977
+ `);
978
+ process.exit(2);
979
+ }
980
+ const output = effectivePolicy === "new-only" && delta ? renderer.renderDelta(delta) : renderer.renderReport(headReport);
981
+ process.stdout.write(output);
982
+ if (!output.endsWith("\n")) process.stdout.write("\n");
983
+ if (args.output) {
984
+ await promises.mkdir(path.dirname(args.output), { recursive: true });
985
+ await promises.writeFile(args.output, output, "utf8");
986
+ process.stderr.write(`
987
+ Wrote drift report: ${args.output}
988
+ `);
989
+ }
990
+ const summary = buildSummary(
991
+ effectivePolicy,
992
+ headReport,
993
+ delta,
994
+ policyResult
995
+ );
996
+ if (args.summaryOutput) {
997
+ await promises.mkdir(path.dirname(args.summaryOutput), { recursive: true });
998
+ await promises.writeFile(
999
+ args.summaryOutput,
1000
+ JSON.stringify(summary, null, 2),
1001
+ "utf8"
1002
+ );
1003
+ process.stderr.write(`Wrote drift summary: ${args.summaryOutput}
1004
+ `);
1005
+ }
1006
+ process.stderr.write(`
1007
+ ${policyResult.reason}
1008
+ `);
1009
+ if (args.failOnDrift && policyResult.shouldFail) {
1010
+ process.exit(1);
1011
+ }
1012
+ }
1013
+ var DRIFT_SUMMARY_SPEC = "autotel-eventcatalog-drift-summary/v0.2.0";
1014
+ function buildSummary(mode, headReport, delta, policyResult) {
1015
+ const counts = mode === "new-only" && delta ? countDriftEntries(delta.introduced) : countDriftReport(headReport);
1016
+ return {
1017
+ spec: DRIFT_SUMMARY_SPEC,
1018
+ mode,
1019
+ shouldFail: policyResult.shouldFail,
1020
+ reason: policyResult.reason,
1021
+ counts
1022
+ };
1023
+ }
1024
+ async function runStamp(args) {
1025
+ const snapshot = await loadSnapshot(args.snapshot);
1026
+ const result = await stampCatalog({
1027
+ snapshot,
1028
+ catalogPath: args.catalog,
1029
+ dryRun: args.dryRun
1030
+ });
1031
+ for (const upd of result.updates) {
1032
+ const verb = args.dryRun ? `would ${upd.action}` : upd.changed ? upd.action : "unchanged";
1033
+ process.stdout.write(`${verb} ${upd.catalogId} -> ${upd.filePath}
1034
+ `);
1035
+ }
1036
+ for (const skip of result.skips) {
1037
+ process.stdout.write(`skip ${skip.snapshotName} (${skip.reason})
1038
+ `);
1039
+ }
1040
+ const summary = buildStampSummary(result, args.dryRun);
1041
+ process.stderr.write(
1042
+ `
1043
+ ${summary.changedFiles} changed, ${summary.replaces} replaces, ${summary.inserts} inserts, ${summary.skipped} skipped${args.dryRun ? " (dry run)" : ""}
1044
+ `
1045
+ );
1046
+ if (args.summaryOutput) {
1047
+ await promises.mkdir(path.dirname(args.summaryOutput), { recursive: true });
1048
+ await promises.writeFile(
1049
+ args.summaryOutput,
1050
+ JSON.stringify(summary, null, 2),
1051
+ "utf8"
1052
+ );
1053
+ process.stderr.write(`Wrote stamp summary: ${args.summaryOutput}
1054
+ `);
1055
+ }
1056
+ }
1057
+ async function main() {
1058
+ const args = parseArgs(process.argv.slice(2));
1059
+ if (args.command === "stamp") {
1060
+ await runStamp(args);
1061
+ return;
1062
+ }
1063
+ await runDrift(args);
1064
+ }
1065
+ main().catch((error) => {
1066
+ process.stderr.write(`autotel-eventcatalog: ${error.message}
1067
+ `);
1068
+ process.exit(1);
1069
+ });
1070
+ //# sourceMappingURL=cli.cjs.map
1071
+ //# sourceMappingURL=cli.cjs.map