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,283 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ stampCatalog,
7
+ buildStampBlock,
8
+ buildStampSummary,
9
+ STAMP_START,
10
+ STAMP_END,
11
+ STAMP_SUMMARY_SPEC,
12
+ } from './stamp';
13
+ import type { ArchitectureSnapshot, EventObservation } from './snapshot';
14
+
15
+ function obs(overrides: Partial<EventObservation> = {}): EventObservation {
16
+ return {
17
+ name: 'order.placed',
18
+ observedCount: 12,
19
+ firstSeen: '2026-05-22T05:20:00.000Z',
20
+ lastSeen: '2026-05-22T05:23:50.024Z',
21
+ fieldPaths: ['orderId', 'customerId', 'items[].sku'],
22
+ sampleTraceIds: [],
23
+ producer: 'OrdersService',
24
+ channel: 'orders.events',
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ function snap(events: ArchitectureSnapshot['events']): ArchitectureSnapshot {
30
+ return {
31
+ spec: 'autotel-architecture/v0.1.0',
32
+ generatedAt: '2026-05-22T05:24:00.000Z',
33
+ service: 'example',
34
+ events,
35
+ };
36
+ }
37
+
38
+ describe('buildStampBlock', () => {
39
+ it('includes volume, last-seen, producer, channel', () => {
40
+ const b = buildStampBlock(obs());
41
+ expect(b).toContain(STAMP_START);
42
+ expect(b).toContain(STAMP_END);
43
+ expect(b).toContain('**Volume**: 12 events');
44
+ expect(b).toContain('**Last seen**: 2026-05-22 05:23 UTC');
45
+ expect(b).toContain('**Producer**: OrdersService');
46
+ expect(b).toContain('**Channel**: `orders.events`');
47
+ });
48
+
49
+ it('lists field paths as inline code', () => {
50
+ const b = buildStampBlock(obs({ fieldPaths: ['a', 'b.c'] }));
51
+ expect(b).toContain('`a`, `b.c`');
52
+ });
53
+
54
+ it('omits sample-traces section when none observed', () => {
55
+ const b = buildStampBlock(obs({ sampleTraceIds: [] }));
56
+ expect(b).not.toContain('Sample traces');
57
+ });
58
+
59
+ it('includes sample-traces section when present', () => {
60
+ const b = buildStampBlock(obs({ sampleTraceIds: ['t1', 't2'] }));
61
+ expect(b).toContain('**Sample traces**: `t1`, `t2`');
62
+ });
63
+ });
64
+
65
+ describe('stampCatalog', () => {
66
+ let dir: string;
67
+ beforeEach(async () => {
68
+ dir = await mkdtemp(join(tmpdir(), 'autotel-stamp-'));
69
+ });
70
+ afterEach(async () => {
71
+ await rm(dir, { recursive: true, force: true });
72
+ });
73
+
74
+ async function writeEventFile(
75
+ catalogId: string,
76
+ body: string,
77
+ ): Promise<string> {
78
+ const evDir = join(
79
+ dir,
80
+ 'domains',
81
+ 'X',
82
+ 'services',
83
+ 'S',
84
+ 'events',
85
+ catalogId,
86
+ );
87
+ await mkdir(evDir, { recursive: true });
88
+ const file = join(evDir, 'index.mdx');
89
+ const frontmatter = `---\nid: ${catalogId}\nversion: 1.0.0\n---\n\n`;
90
+ await writeFile(file, frontmatter + body, 'utf8');
91
+ return file;
92
+ }
93
+
94
+ it('inserts a stamp block before <Footer /> on first run', async () => {
95
+ const file = await writeEventFile(
96
+ 'OrderPlaced',
97
+ '## Overview\n\nA description.\n\n<Footer />\n',
98
+ );
99
+
100
+ const result = await stampCatalog({
101
+ snapshot: snap({ 'order.placed': obs() }),
102
+ catalogPath: dir,
103
+ });
104
+
105
+ expect(result.updates).toHaveLength(1);
106
+ expect(result.updates[0]).toMatchObject({
107
+ catalogId: 'OrderPlaced',
108
+ action: 'insert',
109
+ });
110
+
111
+ const content = await readFile(file, 'utf8');
112
+ expect(content).toContain(STAMP_START);
113
+ expect(content).toContain('**Volume**: 12 events');
114
+ // Stamp should come before <Footer />
115
+ expect(content.indexOf(STAMP_START)).toBeLessThan(
116
+ content.indexOf('<Footer />'),
117
+ );
118
+ });
119
+
120
+ it('replaces the content between markers on subsequent runs', async () => {
121
+ const file = await writeEventFile(
122
+ 'OrderPlaced',
123
+ `## Overview\n\n${STAMP_START}\n\n<div>old block</div>\n\n${STAMP_END}\n\n<Footer />\n`,
124
+ );
125
+
126
+ const result = await stampCatalog({
127
+ snapshot: snap({ 'order.placed': obs({ observedCount: 999 }) }),
128
+ catalogPath: dir,
129
+ });
130
+
131
+ expect(result.updates[0].action).toBe('replace');
132
+ const content = await readFile(file, 'utf8');
133
+ expect(content).not.toContain('old block');
134
+ expect(content).toContain('**Volume**: 999 events');
135
+ expect(content.match(new RegExp(STAMP_START, 'g'))!).toHaveLength(1);
136
+ });
137
+
138
+ it('skips events that have no matching catalog entry', async () => {
139
+ await writeEventFile('OrderPlaced', '## Overview\n\n<Footer />\n');
140
+
141
+ const result = await stampCatalog({
142
+ snapshot: snap({
143
+ 'order.placed': obs(),
144
+ 'mystery.event': obs({ name: 'mystery.event' }),
145
+ }),
146
+ catalogPath: dir,
147
+ });
148
+
149
+ expect(result.updates).toHaveLength(1);
150
+ expect(result.skips).toHaveLength(1);
151
+ expect(result.skips[0]).toMatchObject({
152
+ snapshotName: 'mystery.event',
153
+ reason: 'no-catalog-match',
154
+ });
155
+ });
156
+
157
+ it('appends at file end when no <Footer /> is present', async () => {
158
+ const file = await writeEventFile(
159
+ 'OrderPlaced',
160
+ '## Overview\n\nJust prose, no footer.\n',
161
+ );
162
+
163
+ await stampCatalog({
164
+ snapshot: snap({ 'order.placed': obs() }),
165
+ catalogPath: dir,
166
+ });
167
+
168
+ const content = await readFile(file, 'utf8');
169
+ expect(content).toContain(STAMP_START);
170
+ expect(content).toContain('Just prose, no footer.');
171
+ expect(content.indexOf('Just prose, no footer.')).toBeLessThan(
172
+ content.indexOf(STAMP_START),
173
+ );
174
+ });
175
+
176
+ it('matches dotted snapshot names to PascalCase catalog ids', async () => {
177
+ await writeEventFile('OrderPlaced', '## o\n');
178
+ const result = await stampCatalog({
179
+ snapshot: snap({ 'order.placed': obs() }),
180
+ catalogPath: dir,
181
+ });
182
+ expect(result.updates[0]?.catalogId).toBe('OrderPlaced');
183
+ });
184
+
185
+ it('dryRun returns the plan without writing files', async () => {
186
+ const file = await writeEventFile('OrderPlaced', '## o\n');
187
+ const before = await readFile(file, 'utf8');
188
+
189
+ await stampCatalog({
190
+ snapshot: snap({ 'order.placed': obs() }),
191
+ catalogPath: dir,
192
+ dryRun: true,
193
+ });
194
+
195
+ const after = await readFile(file, 'utf8');
196
+ expect(after).toBe(before);
197
+ });
198
+
199
+ it('reports `changed: true` on the first stamp and `changed: false` on a no-op replace', async () => {
200
+ await writeEventFile('OrderPlaced', '## o\n\n<Footer />\n');
201
+ const fixedSnap = snap({ 'order.placed': obs({ observedCount: 100 }) });
202
+
203
+ const first = await stampCatalog({ snapshot: fixedSnap, catalogPath: dir });
204
+ expect(first.updates[0]).toMatchObject({ action: 'insert', changed: true });
205
+
206
+ // Re-running with the same data should be a no-op replace.
207
+ const second = await stampCatalog({
208
+ snapshot: fixedSnap,
209
+ catalogPath: dir,
210
+ });
211
+ expect(second.updates[0]).toMatchObject({
212
+ action: 'replace',
213
+ changed: false,
214
+ });
215
+ });
216
+ });
217
+
218
+ describe('buildStampSummary', () => {
219
+ it('rolls up inserts, replaces, skipped, and changed-files counts', () => {
220
+ const summary = buildStampSummary(
221
+ {
222
+ updates: [
223
+ {
224
+ catalogId: 'A',
225
+ snapshotName: 'a',
226
+ filePath: '/x/a.mdx',
227
+ action: 'insert',
228
+ changed: true,
229
+ },
230
+ {
231
+ catalogId: 'B',
232
+ snapshotName: 'b',
233
+ filePath: '/x/b.mdx',
234
+ action: 'replace',
235
+ changed: true,
236
+ },
237
+ {
238
+ catalogId: 'C',
239
+ snapshotName: 'c',
240
+ filePath: '/x/c.mdx',
241
+ action: 'replace',
242
+ changed: false,
243
+ },
244
+ ],
245
+ skips: [{ snapshotName: 'mystery', reason: 'no-catalog-match' }],
246
+ },
247
+ false,
248
+ );
249
+ expect(summary.spec).toBe(STAMP_SUMMARY_SPEC);
250
+ expect(summary.dryRun).toBe(false);
251
+ expect(summary.attempted).toBe(4); // 3 updates + 1 skip
252
+ expect(summary.skipped).toBe(1);
253
+ expect(summary.inserts).toBe(1);
254
+ expect(summary.replaces).toBe(2);
255
+ expect(summary.changedFiles).toBe(2);
256
+ expect(summary.hadChanges).toBe(true);
257
+ });
258
+
259
+ it('hadChanges is false when every update was a no-op', () => {
260
+ const summary = buildStampSummary(
261
+ {
262
+ updates: [
263
+ {
264
+ catalogId: 'A',
265
+ snapshotName: 'a',
266
+ filePath: '/x/a.mdx',
267
+ action: 'replace',
268
+ changed: false,
269
+ },
270
+ ],
271
+ skips: [],
272
+ },
273
+ false,
274
+ );
275
+ expect(summary.hadChanges).toBe(false);
276
+ expect(summary.changedFiles).toBe(0);
277
+ });
278
+
279
+ it('propagates dryRun into the summary', () => {
280
+ const summary = buildStampSummary({ updates: [], skips: [] }, true);
281
+ expect(summary.dryRun).toBe(true);
282
+ });
283
+ });
package/src/stamp.ts ADDED
@@ -0,0 +1,232 @@
1
+ // Stamp an architecture snapshot into a catalog's event mdx files.
2
+ //
3
+ // For each event in the snapshot that matches an event in the catalog (by
4
+ // normalised name), the stamp command writes an evidence block between
5
+ // `<!-- autotel:stamp-start -->` and `<!-- autotel:stamp-end -->` markers.
6
+ // Subsequent runs replace the content between the markers idempotently, so
7
+ // re-stamping is safe to run on every commit / in CI.
8
+ //
9
+ // If the markers do not yet exist in a file, they are inserted at the most
10
+ // natural place: just before the `<Footer />` component if present, else
11
+ // just before the closing `</...>` tag of the last visible content, else
12
+ // appended to the file.
13
+
14
+ import { readFile, writeFile } from 'node:fs/promises';
15
+ import type { ArchitectureSnapshot, EventObservation } from './snapshot';
16
+ import { readCatalogState } from './catalog';
17
+
18
+ export const STAMP_START = '<!-- autotel:stamp-start -->';
19
+ export const STAMP_END = '<!-- autotel:stamp-end -->';
20
+
21
+ export interface StampOptions {
22
+ /** Loaded snapshot, OR pass `loadSnapshot(path)` from the caller. */
23
+ snapshot: ArchitectureSnapshot;
24
+ /** Catalog root (the directory containing eventcatalog.config.*). */
25
+ catalogPath: string;
26
+ /** If true, do not write files — just return the diff plan. */
27
+ dryRun?: boolean;
28
+ /** Override "now" for deterministic tests. */
29
+ now?: () => Date;
30
+ }
31
+
32
+ export type StampUpdate = {
33
+ /** Catalog event id, e.g. `OrderPlaced`. */
34
+ catalogId: string;
35
+ /** Snapshot event name, e.g. `order.placed`. */
36
+ snapshotName: string;
37
+ /** Absolute path to the mdx file that was (or would be) updated. */
38
+ filePath: string;
39
+ /** Was this an insert (no prior markers) or a replace? */
40
+ action: 'insert' | 'replace';
41
+ /**
42
+ * True if the proposed content differs from what's on disk. False when a
43
+ * replace would write byte-identical content — meaning no real change.
44
+ * Used by `--summary-output` so CI can answer "did this PR need stamping?"
45
+ * without diffing files.
46
+ */
47
+ changed: boolean;
48
+ };
49
+
50
+ export type StampSkip = {
51
+ snapshotName: string;
52
+ reason: 'no-catalog-match';
53
+ };
54
+
55
+ export type StampResult = {
56
+ updates: StampUpdate[];
57
+ skips: StampSkip[];
58
+ };
59
+
60
+ export async function stampCatalog(opts: StampOptions): Promise<StampResult> {
61
+ const { snapshot, catalogPath, dryRun = false } = opts;
62
+ const catalog = await readCatalogState(catalogPath);
63
+
64
+ const catalogByNormalised = new Map<
65
+ string,
66
+ { id: string; filePath: string }
67
+ >();
68
+ for (const [id, ev] of catalog.events) {
69
+ catalogByNormalised.set(normaliseEventId(id), {
70
+ id,
71
+ filePath: ev.filePath,
72
+ });
73
+ }
74
+
75
+ const updates: StampUpdate[] = [];
76
+ const skips: StampSkip[] = [];
77
+
78
+ for (const [name, obs] of Object.entries(snapshot.events)) {
79
+ const match = catalogByNormalised.get(normaliseEventId(name));
80
+ if (!match) {
81
+ skips.push({ snapshotName: name, reason: 'no-catalog-match' });
82
+ continue;
83
+ }
84
+
85
+ const block = buildStampBlock(obs);
86
+ const { action, changed } = await stampFile(match.filePath, block, dryRun);
87
+
88
+ updates.push({
89
+ catalogId: match.id,
90
+ snapshotName: name,
91
+ filePath: match.filePath,
92
+ action,
93
+ changed,
94
+ });
95
+ }
96
+
97
+ return { updates, skips };
98
+ }
99
+
100
+ /**
101
+ * Render the evidence block. Designed to be readable in raw mdx AND visually
102
+ * distinct when rendered by EventCatalog (uses the existing
103
+ * `.evidence-callout` class, plus a small header label).
104
+ */
105
+ export function buildStampBlock(obs: EventObservation): string {
106
+ const lines: string[] = [
107
+ STAMP_START,
108
+ '',
109
+ '<div class="evidence-callout">',
110
+ '<strong>Observed in autotel snapshot</strong>',
111
+ '',
112
+ ];
113
+ const facts: string[] = [];
114
+ facts.push(`**Volume**: ${obs.observedCount.toLocaleString()} events`);
115
+ facts.push(`**Last seen**: ${formatTimestamp(obs.lastSeen)}`);
116
+ if (obs.producer) facts.push(`**Producer**: ${obs.producer}`);
117
+ if (obs.channel) facts.push(`**Channel**: \`${obs.channel}\``);
118
+ lines.push(facts.join(' · '));
119
+ if (obs.fieldPaths.length > 0) {
120
+ lines.push('');
121
+ lines.push(
122
+ `**Field paths observed**: ${obs.fieldPaths.map((p) => `\`${p}\``).join(', ')}`,
123
+ );
124
+ }
125
+ if (obs.sampleTraceIds.length > 0) {
126
+ lines.push('');
127
+ lines.push(
128
+ `**Sample traces**: ${obs.sampleTraceIds.map((t) => `\`${t}\``).join(', ')}`,
129
+ );
130
+ }
131
+ lines.push('</div>', '', STAMP_END);
132
+
133
+ return lines.join('\n');
134
+ }
135
+
136
+ async function stampFile(
137
+ filePath: string,
138
+ block: string,
139
+ dryRun: boolean,
140
+ ): Promise<{ action: 'insert' | 'replace'; changed: boolean }> {
141
+ const content = await readFile(filePath, 'utf8');
142
+
143
+ const startIdx = content.indexOf(STAMP_START);
144
+ const endIdx = content.indexOf(STAMP_END);
145
+
146
+ let next: string;
147
+ let action: 'insert' | 'replace';
148
+
149
+ if (startIdx !== -1 && endIdx > startIdx) {
150
+ // Replace existing block (including markers).
151
+ const before = content.slice(0, startIdx);
152
+ const after = content.slice(endIdx + STAMP_END.length);
153
+ next = before + block + after;
154
+ action = 'replace';
155
+ } else {
156
+ // Insert. Prefer just before <Footer /> if present, else append.
157
+ const footerIdx = content.search(/<Footer\s*\/>/);
158
+ const insertion = '\n\n' + block + '\n';
159
+ next =
160
+ footerIdx >= 0
161
+ ? content.slice(0, footerIdx) +
162
+ insertion +
163
+ '\n' +
164
+ content.slice(footerIdx)
165
+ : content.replace(/\s*$/, '') + insertion;
166
+ action = 'insert';
167
+ }
168
+
169
+ const changed = next !== content;
170
+ if (!dryRun && changed) {
171
+ await writeFile(filePath, next, 'utf8');
172
+ }
173
+ return { action, changed };
174
+ }
175
+
176
+ /** Versioned identifier for the stamp summary JSON file. */
177
+ export const STAMP_SUMMARY_SPEC =
178
+ 'autotel-eventcatalog-stamp-summary/v0.1.0' as const;
179
+
180
+ export type StampSummary = {
181
+ spec: typeof STAMP_SUMMARY_SPEC;
182
+ dryRun: boolean;
183
+ /** Total snapshot events the stamp run considered (matched + skipped). */
184
+ attempted: number;
185
+ /** Skipped events (no catalog match). */
186
+ skipped: number;
187
+ /** Matched events that resulted in an insert action. */
188
+ inserts: number;
189
+ /** Matched events that resulted in a replace action. */
190
+ replaces: number;
191
+ /** Number of files whose content actually changed (or would change in dry-run). */
192
+ changedFiles: number;
193
+ /**
194
+ * True when the run produced (or would produce) any real change. CI can
195
+ * gate on this: "if the committed catalog is stamped, this should be
196
+ * false after running stamp; if not, the PR forgot to re-stamp."
197
+ */
198
+ hadChanges: boolean;
199
+ };
200
+
201
+ export function buildStampSummary(
202
+ result: StampResult,
203
+ dryRun: boolean,
204
+ ): StampSummary {
205
+ const inserts = result.updates.filter((u) => u.action === 'insert').length;
206
+ const replaces = result.updates.filter((u) => u.action === 'replace').length;
207
+ const changedFiles = result.updates.filter((u) => u.changed).length;
208
+ return {
209
+ spec: STAMP_SUMMARY_SPEC,
210
+ dryRun,
211
+ attempted: result.updates.length + result.skips.length,
212
+ skipped: result.skips.length,
213
+ inserts,
214
+ replaces,
215
+ changedFiles,
216
+ hadChanges: changedFiles > 0,
217
+ };
218
+ }
219
+
220
+ function normaliseEventId(id: string): string {
221
+ return id.toLowerCase().replaceAll(/[._\-\s]/g, '');
222
+ }
223
+
224
+ function formatTimestamp(iso: string): string {
225
+ // 2026-05-22T05:23:50.024Z → 2026-05-22 05:23 UTC
226
+ const d = new Date(iso);
227
+ const pad = (n: number) => String(n).padStart(2, '0');
228
+ return (
229
+ `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +
230
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`
231
+ );
232
+ }