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.
- package/CHANGELOG.md +196 -0
- package/CONTRIBUTING.md +212 -0
- package/README.md +307 -0
- package/action.yml +155 -0
- package/dist/cli.cjs +1071 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1065 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +794 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +267 -0
- package/dist/index.d.ts +267 -0
- package/dist/index.js +764 -0
- package/dist/index.js.map +1 -0
- package/docs/CONTRACT.md +280 -0
- package/docs/EXTENDING.md +248 -0
- package/docs/TROUBLESHOOTING.md +220 -0
- package/docs/UPGRADING.md +202 -0
- package/package.json +78 -0
- package/schemas/README.md +44 -0
- package/schemas/drift-report-v0.1.0.json +107 -0
- package/schemas/drift-report-v0.2.0.json +137 -0
- package/schemas/drift-summary-v0.1.0.json +74 -0
- package/schemas/drift-summary-v0.2.0.json +74 -0
- package/schemas/stamp-summary-v0.1.0.json +54 -0
- package/src/__fixtures__/drift-report-all.golden.json +33 -0
- package/src/__fixtures__/drift-summary-clean.golden.json +17 -0
- package/src/__fixtures__/drift-summary-drifty.golden.json +17 -0
- package/src/__fixtures__/stamp-summary-noop.golden.json +10 -0
- package/src/catalog.test.ts +63 -0
- package/src/catalog.ts +169 -0
- package/src/cli.e2e.test.ts +310 -0
- package/src/cli.ts +402 -0
- package/src/contract.test.ts +395 -0
- package/src/diff-vs-base.test.ts +145 -0
- package/src/diff-vs-base.ts +242 -0
- package/src/diff.test.ts +384 -0
- package/src/diff.ts +296 -0
- package/src/index.ts +73 -0
- package/src/policy.test.ts +75 -0
- package/src/policy.ts +41 -0
- package/src/renderers/index.ts +35 -0
- package/src/renderers/json.ts +33 -0
- package/src/renderers/markdown.ts +223 -0
- package/src/renderers/renderers.test.ts +79 -0
- package/src/renderers/terminal.ts +30 -0
- package/src/renderers/types.ts +26 -0
- package/src/report.test.ts +205 -0
- package/src/report.ts +27 -0
- package/src/snapshot.ts +25 -0
- package/src/stamp.test.ts +283 -0
- package/src/stamp.ts +232 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// GitHub-flavoured Markdown — the default. Used directly as the body of
|
|
2
|
+
// the sticky PR comment posted by the bundled action.
|
|
3
|
+
|
|
4
|
+
import type { DriftReport, FieldDrift } from '../diff';
|
|
5
|
+
import { hasDrift } from '../diff';
|
|
6
|
+
import type { DriftDelta, DriftEntries } from '../diff-vs-base';
|
|
7
|
+
import type { Renderer } from './types';
|
|
8
|
+
|
|
9
|
+
export function renderMarkdown(report: DriftReport): string {
|
|
10
|
+
const lines: string[] = [
|
|
11
|
+
'# Architecture drift report',
|
|
12
|
+
'',
|
|
13
|
+
`_Snapshot from \`${report.snapshotService}\` at ${report.snapshotGeneratedAt}_`,
|
|
14
|
+
'',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
if (!hasDrift(report)) {
|
|
18
|
+
lines.push('No drift detected. Catalog and runtime agree.', '');
|
|
19
|
+
return lines.join('\n');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (report.events.observedButUndocumented.length > 0) {
|
|
23
|
+
lines.push(
|
|
24
|
+
'## Events observed but undocumented',
|
|
25
|
+
'',
|
|
26
|
+
'These event names appear in the snapshot but no matching entry',
|
|
27
|
+
'exists in the catalog. Add them or stop emitting them.',
|
|
28
|
+
'',
|
|
29
|
+
);
|
|
30
|
+
for (const name of report.events.observedButUndocumented) {
|
|
31
|
+
lines.push(`- \`${name}\``);
|
|
32
|
+
}
|
|
33
|
+
lines.push('');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (report.events.documentedButUnseen.length > 0) {
|
|
37
|
+
lines.push(
|
|
38
|
+
'## Events documented but never observed',
|
|
39
|
+
'',
|
|
40
|
+
'These events exist in the catalog but no payload was captured.',
|
|
41
|
+
'Either the tests do not exercise this event, or it has been removed.',
|
|
42
|
+
'',
|
|
43
|
+
);
|
|
44
|
+
for (const name of report.events.documentedButUnseen) {
|
|
45
|
+
lines.push(`- \`${name}\``);
|
|
46
|
+
}
|
|
47
|
+
lines.push('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (report.events.fieldDrift.length > 0) {
|
|
51
|
+
lines.push('## Field-path drift', '');
|
|
52
|
+
for (const drift of report.events.fieldDrift) {
|
|
53
|
+
lines.push(`### \`${drift.event}\``, '');
|
|
54
|
+
if (drift.extra.length > 0) {
|
|
55
|
+
lines.push(
|
|
56
|
+
'**Extra fields in payloads (not in declared schema):**',
|
|
57
|
+
'',
|
|
58
|
+
);
|
|
59
|
+
for (const p of drift.extra) lines.push(`- \`${p}\``);
|
|
60
|
+
lines.push('');
|
|
61
|
+
}
|
|
62
|
+
if (drift.missing.length > 0) {
|
|
63
|
+
lines.push('**Fields declared but never observed:**', '');
|
|
64
|
+
for (const p of drift.missing) lines.push(`- \`${p}\``);
|
|
65
|
+
lines.push('');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if ((report.events.typeDrift ?? []).length > 0) {
|
|
71
|
+
lines.push('## Type drift', '');
|
|
72
|
+
for (const drift of report.events.typeDrift ?? []) {
|
|
73
|
+
lines.push(
|
|
74
|
+
`- \`${drift.event}\` \`${drift.path}\``,
|
|
75
|
+
` declared: \`${drift.declared.join(' | ')}\`, observed: \`${drift.observed.join(' | ')}\``,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
lines.push('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if ((report.events.valueDrift ?? []).length > 0) {
|
|
82
|
+
lines.push('## Value drift', '');
|
|
83
|
+
for (const drift of report.events.valueDrift ?? []) {
|
|
84
|
+
lines.push(
|
|
85
|
+
`- \`${drift.event}\` \`${drift.path}\``,
|
|
86
|
+
` declared enum: \`${drift.declared.map((v) => JSON.stringify(v)).join(', ')}\`, observed: \`${drift.observed.map((v) => JSON.stringify(v)).join(', ')}\``,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
lines.push('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (report.services.observedButUndocumented.length > 0) {
|
|
93
|
+
lines.push('## Services observed but undocumented', '');
|
|
94
|
+
for (const id of report.services.observedButUndocumented) {
|
|
95
|
+
lines.push(`- \`${id}\``);
|
|
96
|
+
}
|
|
97
|
+
lines.push('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (report.channels.observedButUndocumented.length > 0) {
|
|
101
|
+
lines.push('## Channels observed but undocumented', '');
|
|
102
|
+
for (const id of report.channels.observedButUndocumented) {
|
|
103
|
+
lines.push(`- \`${id}\``);
|
|
104
|
+
}
|
|
105
|
+
lines.push('');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return lines.join('\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Render the diff-of-diffs as a PR-comment-friendly markdown block. Sections
|
|
113
|
+
* appear only when they have content, so a clean PR produces a tight message.
|
|
114
|
+
*/
|
|
115
|
+
export function renderDeltaMarkdown(delta: DriftDelta): string {
|
|
116
|
+
const lines: string[] = [
|
|
117
|
+
'# Architecture drift — what this change introduces',
|
|
118
|
+
'',
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
if (!delta.hasNewDrift) {
|
|
122
|
+
const fixedAny = entriesHasContent(delta.resolved);
|
|
123
|
+
if (fixedAny) {
|
|
124
|
+
lines.push('No new drift. The changes below resolve existing drift:', '');
|
|
125
|
+
renderEntries(delta.resolved, lines, { sign: '−' });
|
|
126
|
+
} else {
|
|
127
|
+
lines.push('No new drift detected. Catalog and runtime agree.');
|
|
128
|
+
}
|
|
129
|
+
lines.push('');
|
|
130
|
+
return lines.join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
lines.push('This change introduces drift:', '');
|
|
134
|
+
renderEntries(delta.introduced, lines, { sign: '+' });
|
|
135
|
+
|
|
136
|
+
if (entriesHasContent(delta.resolved)) {
|
|
137
|
+
lines.push('', '### Resolved by this change', '');
|
|
138
|
+
renderEntries(delta.resolved, lines, { sign: '−' });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function entriesHasContent(e: DriftEntries): boolean {
|
|
145
|
+
return (
|
|
146
|
+
e.events.observedButUndocumented.length > 0 ||
|
|
147
|
+
e.events.documentedButUnseen.length > 0 ||
|
|
148
|
+
e.events.fieldDrift.length > 0 ||
|
|
149
|
+
(e.events.typeDrift ?? []).length > 0 ||
|
|
150
|
+
(e.events.valueDrift ?? []).length > 0 ||
|
|
151
|
+
e.services.observedButUndocumented.length > 0 ||
|
|
152
|
+
e.channels.observedButUndocumented.length > 0
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderEntries(
|
|
157
|
+
entries: DriftEntries,
|
|
158
|
+
out: string[],
|
|
159
|
+
options: { sign: '+' | '−' },
|
|
160
|
+
): void {
|
|
161
|
+
if (entries.events.observedButUndocumented.length > 0) {
|
|
162
|
+
out.push('**Events observed but undocumented**', '');
|
|
163
|
+
for (const n of entries.events.observedButUndocumented) {
|
|
164
|
+
out.push(`- \`${n}\``);
|
|
165
|
+
}
|
|
166
|
+
out.push('');
|
|
167
|
+
}
|
|
168
|
+
if (entries.events.documentedButUnseen.length > 0) {
|
|
169
|
+
out.push('**Events documented but never observed**', '');
|
|
170
|
+
for (const n of entries.events.documentedButUnseen) {
|
|
171
|
+
out.push(`- \`${n}\``);
|
|
172
|
+
}
|
|
173
|
+
out.push('');
|
|
174
|
+
}
|
|
175
|
+
for (const fd of entries.events.fieldDrift) {
|
|
176
|
+
out.push(`**Field drift on \`${fd.event}\`**`, '');
|
|
177
|
+
for (const p of fd.extra) out.push(`- ${options.sign} \`${p}\` (extra)`);
|
|
178
|
+
for (const p of fd.missing)
|
|
179
|
+
out.push(`- ${options.sign} \`${p}\` (missing)`);
|
|
180
|
+
out.push('');
|
|
181
|
+
}
|
|
182
|
+
for (const td of entries.events.typeDrift ?? []) {
|
|
183
|
+
out.push(
|
|
184
|
+
`**Type drift on \`${td.event}\` \`${td.path}\`**`,
|
|
185
|
+
'',
|
|
186
|
+
`- ${options.sign} declared \`${td.declared.join(' | ')}\`, observed \`${td.observed.join(' | ')}\``,
|
|
187
|
+
'',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
for (const vd of entries.events.valueDrift ?? []) {
|
|
191
|
+
out.push(
|
|
192
|
+
`**Value drift on \`${vd.event}\` \`${vd.path}\`**`,
|
|
193
|
+
'',
|
|
194
|
+
`- ${options.sign} declared enum \`${vd.declared.map((v) => JSON.stringify(v)).join(', ')}\`, observed \`${vd.observed.map((v) => JSON.stringify(v)).join(', ')}\``,
|
|
195
|
+
'',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (entries.services.observedButUndocumented.length > 0) {
|
|
199
|
+
out.push('**Services observed but undocumented**', '');
|
|
200
|
+
for (const id of entries.services.observedButUndocumented) {
|
|
201
|
+
out.push(`- \`${id}\``);
|
|
202
|
+
}
|
|
203
|
+
out.push('');
|
|
204
|
+
}
|
|
205
|
+
if (entries.channels.observedButUndocumented.length > 0) {
|
|
206
|
+
out.push('**Channels observed but undocumented**', '');
|
|
207
|
+
for (const id of entries.channels.observedButUndocumented) {
|
|
208
|
+
out.push(`- \`${id}\``);
|
|
209
|
+
}
|
|
210
|
+
out.push('');
|
|
211
|
+
}
|
|
212
|
+
// FieldDrift import kept for typing of the for..of above; reference here so
|
|
213
|
+
// the import isn't flagged as unused by some linter configurations.
|
|
214
|
+
void (null as unknown as FieldDrift);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const markdownRenderer: Renderer = {
|
|
218
|
+
name: 'markdown',
|
|
219
|
+
description:
|
|
220
|
+
'GitHub-flavoured Markdown (default). Suitable for sticky PR comments.',
|
|
221
|
+
renderReport: renderMarkdown,
|
|
222
|
+
renderDelta: renderDeltaMarkdown,
|
|
223
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Tests for the renderer registry (the adapter shape). Per-renderer
|
|
2
|
+
// behavioural tests live in ../report.test.ts.
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { RENDERERS, RENDERER_NAMES, getRenderer } from './index';
|
|
6
|
+
import type { DriftReport } from '../diff';
|
|
7
|
+
import type { DriftDelta } from '../diff-vs-base';
|
|
8
|
+
|
|
9
|
+
const emptyReport: DriftReport = {
|
|
10
|
+
snapshotGeneratedAt: '2026-05-22T00:00:00.000Z',
|
|
11
|
+
snapshotService: 'fixture',
|
|
12
|
+
events: {
|
|
13
|
+
observedButUndocumented: [],
|
|
14
|
+
documentedButUnseen: [],
|
|
15
|
+
fieldDrift: [],
|
|
16
|
+
},
|
|
17
|
+
services: { observedButUndocumented: [] },
|
|
18
|
+
channels: { observedButUndocumented: [] },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const cleanDelta: DriftDelta = {
|
|
22
|
+
hasNewDrift: false,
|
|
23
|
+
introduced: emptyReport.events && {
|
|
24
|
+
events: {
|
|
25
|
+
observedButUndocumented: [],
|
|
26
|
+
documentedButUnseen: [],
|
|
27
|
+
fieldDrift: [],
|
|
28
|
+
},
|
|
29
|
+
services: { observedButUndocumented: [] },
|
|
30
|
+
channels: { observedButUndocumented: [] },
|
|
31
|
+
},
|
|
32
|
+
resolved: {
|
|
33
|
+
events: {
|
|
34
|
+
observedButUndocumented: [],
|
|
35
|
+
documentedButUnseen: [],
|
|
36
|
+
fieldDrift: [],
|
|
37
|
+
},
|
|
38
|
+
services: { observedButUndocumented: [] },
|
|
39
|
+
channels: { observedButUndocumented: [] },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('renderer registry', () => {
|
|
44
|
+
it('ships at least the three built-in renderers', () => {
|
|
45
|
+
expect(RENDERER_NAMES).toEqual(
|
|
46
|
+
expect.arrayContaining(['markdown', 'terminal', 'json']),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('every renderer has a name, description, and both render functions', () => {
|
|
51
|
+
for (const r of RENDERERS) {
|
|
52
|
+
expect(typeof r.name).toBe('string');
|
|
53
|
+
expect(r.name.length).toBeGreaterThan(0);
|
|
54
|
+
expect(typeof r.description).toBe('string');
|
|
55
|
+
expect(typeof r.renderReport).toBe('function');
|
|
56
|
+
expect(typeof r.renderDelta).toBe('function');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('every renderer produces a non-empty string for both shapes', () => {
|
|
61
|
+
for (const r of RENDERERS) {
|
|
62
|
+
const reportOutput = r.renderReport(emptyReport);
|
|
63
|
+
const deltaOutput = r.renderDelta(cleanDelta);
|
|
64
|
+
expect(reportOutput.length).toBeGreaterThan(0);
|
|
65
|
+
expect(deltaOutput.length).toBeGreaterThan(0);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('getRenderer looks up by name', () => {
|
|
70
|
+
expect(getRenderer('markdown')?.name).toBe('markdown');
|
|
71
|
+
expect(getRenderer('json')?.name).toBe('json');
|
|
72
|
+
expect(getRenderer('terminal')?.name).toBe('terminal');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('getRenderer returns undefined for unknown names', () => {
|
|
76
|
+
expect(getRenderer('sarif')).toBeUndefined();
|
|
77
|
+
expect(getRenderer('')).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Plain-text rendering — for terminals, log files, Slack messages, anywhere
|
|
2
|
+
// markdown decorations would render as noise. Reuses the markdown renderer
|
|
3
|
+
// and strips heading marks, inline code backticks, and bold emphasis.
|
|
4
|
+
|
|
5
|
+
import type { DriftReport } from '../diff';
|
|
6
|
+
import type { DriftDelta } from '../diff-vs-base';
|
|
7
|
+
import { renderMarkdown, renderDeltaMarkdown } from './markdown';
|
|
8
|
+
import type { Renderer } from './types';
|
|
9
|
+
|
|
10
|
+
export function renderTerminal(report: DriftReport): string {
|
|
11
|
+
return stripMarkdownDecorations(renderMarkdown(report));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderDeltaTerminal(delta: DriftDelta): string {
|
|
15
|
+
return stripMarkdownDecorations(renderDeltaMarkdown(delta));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stripMarkdownDecorations(md: string): string {
|
|
19
|
+
return md
|
|
20
|
+
.replaceAll(/^#+\s+/gm, '')
|
|
21
|
+
.replaceAll('`', '')
|
|
22
|
+
.replaceAll(/\*\*([^*]+)\*\*/g, '$1');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const terminalRenderer: Renderer = {
|
|
26
|
+
name: 'terminal',
|
|
27
|
+
description: 'Plain text. Same content as markdown, decorations stripped.',
|
|
28
|
+
renderReport: renderTerminal,
|
|
29
|
+
renderDelta: renderDeltaTerminal,
|
|
30
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Renderers are pluggable adapters that turn a drift result into output text.
|
|
2
|
+
// The core domain (diff / diff-vs-base / policy) is renderer-agnostic; new
|
|
3
|
+
// output targets (SARIF, GitHub Check Runs API, Slack-flavoured markdown) plug
|
|
4
|
+
// in here without touching the core.
|
|
5
|
+
//
|
|
6
|
+
// Two render shapes:
|
|
7
|
+
// - `RenderReport` for full reports (mode === 'all')
|
|
8
|
+
// - `RenderDelta` for diff-of-diffs (mode === 'new-only')
|
|
9
|
+
//
|
|
10
|
+
// A `Renderer` implements both — most renderers can share helpers between the
|
|
11
|
+
// two — so the registry can dispatch by `(mode, format)` without branches.
|
|
12
|
+
|
|
13
|
+
import type { DriftReport } from '../diff';
|
|
14
|
+
import type { DriftDelta } from '../diff-vs-base';
|
|
15
|
+
|
|
16
|
+
export type RenderReport = (report: DriftReport) => string;
|
|
17
|
+
export type RenderDelta = (delta: DriftDelta) => string;
|
|
18
|
+
|
|
19
|
+
export interface Renderer {
|
|
20
|
+
/** Short name used by the CLI's `--format` flag. */
|
|
21
|
+
readonly name: string;
|
|
22
|
+
/** One-line human description shown in CLI help and the README. */
|
|
23
|
+
readonly description: string;
|
|
24
|
+
readonly renderReport: RenderReport;
|
|
25
|
+
readonly renderDelta: RenderDelta;
|
|
26
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
renderMarkdown,
|
|
4
|
+
renderJson,
|
|
5
|
+
renderTerminal,
|
|
6
|
+
renderDeltaTerminal,
|
|
7
|
+
REPORT_SPEC,
|
|
8
|
+
} from './report';
|
|
9
|
+
import type { DriftReport } from './diff';
|
|
10
|
+
import type { DriftDelta } from './diff-vs-base';
|
|
11
|
+
|
|
12
|
+
const emptyReport: DriftReport = {
|
|
13
|
+
snapshotGeneratedAt: '2026-05-21T18:04:00.000Z',
|
|
14
|
+
snapshotService: 'orders',
|
|
15
|
+
events: {
|
|
16
|
+
observedButUndocumented: [],
|
|
17
|
+
documentedButUnseen: [],
|
|
18
|
+
fieldDrift: [],
|
|
19
|
+
},
|
|
20
|
+
services: { observedButUndocumented: [] },
|
|
21
|
+
channels: { observedButUndocumented: [] },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('renderMarkdown', () => {
|
|
25
|
+
it('renders a clean report when there is no drift', () => {
|
|
26
|
+
const md = renderMarkdown(emptyReport);
|
|
27
|
+
expect(md).toContain('No drift detected');
|
|
28
|
+
expect(md).toContain('orders');
|
|
29
|
+
expect(md).toContain('2026-05-21T18:04:00.000Z');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders each drift section under a clear heading', () => {
|
|
33
|
+
const md = renderMarkdown({
|
|
34
|
+
...emptyReport,
|
|
35
|
+
events: {
|
|
36
|
+
observedButUndocumented: ['order.cancelled'],
|
|
37
|
+
documentedButUnseen: ['LegacyEvent'],
|
|
38
|
+
fieldDrift: [
|
|
39
|
+
{
|
|
40
|
+
event: 'recommendation.generated',
|
|
41
|
+
extra: ['personalization_seed'],
|
|
42
|
+
missing: ['recommendations[].reason'],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
services: { observedButUndocumented: ['GhostService'] },
|
|
47
|
+
channels: { observedButUndocumented: ['rogue.events'] },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(md).toContain('## Events observed but undocumented');
|
|
51
|
+
expect(md).toContain('`order.cancelled`');
|
|
52
|
+
expect(md).toContain('## Events documented but never observed');
|
|
53
|
+
expect(md).toContain('`LegacyEvent`');
|
|
54
|
+
expect(md).toContain('## Field-path drift');
|
|
55
|
+
expect(md).toContain('### `recommendation.generated`');
|
|
56
|
+
expect(md).toContain('`personalization_seed`');
|
|
57
|
+
expect(md).toContain('`recommendations[].reason`');
|
|
58
|
+
expect(md).toContain('## Services observed but undocumented');
|
|
59
|
+
expect(md).toContain('`GhostService`');
|
|
60
|
+
expect(md).toContain('## Channels observed but undocumented');
|
|
61
|
+
expect(md).toContain('`rogue.events`');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('renderJson', () => {
|
|
66
|
+
it('renders a machine-readable all-mode payload', () => {
|
|
67
|
+
const json = renderJson({ mode: 'all', report: emptyReport });
|
|
68
|
+
const parsed = JSON.parse(json) as {
|
|
69
|
+
spec: string;
|
|
70
|
+
mode: string;
|
|
71
|
+
report: DriftReport;
|
|
72
|
+
};
|
|
73
|
+
expect(parsed.spec).toBe(REPORT_SPEC);
|
|
74
|
+
expect(parsed.mode).toBe('all');
|
|
75
|
+
expect(parsed.report.snapshotService).toBe('orders');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('stamps the spec marker on every envelope shape', () => {
|
|
79
|
+
const allJson = JSON.parse(
|
|
80
|
+
renderJson({ mode: 'all', report: emptyReport }),
|
|
81
|
+
);
|
|
82
|
+
const deltaJson = JSON.parse(
|
|
83
|
+
renderJson({
|
|
84
|
+
mode: 'new-only',
|
|
85
|
+
delta: {
|
|
86
|
+
hasNewDrift: false,
|
|
87
|
+
introduced: {
|
|
88
|
+
events: {
|
|
89
|
+
observedButUndocumented: [],
|
|
90
|
+
documentedButUnseen: [],
|
|
91
|
+
fieldDrift: [],
|
|
92
|
+
},
|
|
93
|
+
services: { observedButUndocumented: [] },
|
|
94
|
+
channels: { observedButUndocumented: [] },
|
|
95
|
+
},
|
|
96
|
+
resolved: {
|
|
97
|
+
events: {
|
|
98
|
+
observedButUndocumented: [],
|
|
99
|
+
documentedButUnseen: [],
|
|
100
|
+
fieldDrift: [],
|
|
101
|
+
},
|
|
102
|
+
services: { observedButUndocumented: [] },
|
|
103
|
+
channels: { observedButUndocumented: [] },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
expect(allJson.spec).toBe(REPORT_SPEC);
|
|
109
|
+
expect(deltaJson.spec).toBe(REPORT_SPEC);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const driftyReport: DriftReport = {
|
|
114
|
+
...emptyReport,
|
|
115
|
+
events: {
|
|
116
|
+
observedButUndocumented: ['order.cancelled'],
|
|
117
|
+
documentedButUnseen: ['LegacyEvent'],
|
|
118
|
+
fieldDrift: [
|
|
119
|
+
{
|
|
120
|
+
event: 'recommendation.generated',
|
|
121
|
+
extra: ['personalization_seed'],
|
|
122
|
+
missing: ['recommendations[].reason'],
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
describe('renderTerminal', () => {
|
|
129
|
+
it('preserves structure but strips markdown decorations', () => {
|
|
130
|
+
const text = renderTerminal(driftyReport);
|
|
131
|
+
// Section heads survive, but the leading '#' marks are gone.
|
|
132
|
+
expect(text).not.toMatch(/^#/m);
|
|
133
|
+
expect(text).toContain('Events observed but undocumented');
|
|
134
|
+
expect(text).toContain('Field-path drift');
|
|
135
|
+
// No backticks around event/field names.
|
|
136
|
+
expect(text).not.toContain('`order.cancelled`');
|
|
137
|
+
expect(text).toContain('order.cancelled');
|
|
138
|
+
expect(text).toContain('personalization_seed');
|
|
139
|
+
// No ** bold marks.
|
|
140
|
+
expect(text).not.toMatch(/\*\*/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('renders cleanly when there is no drift', () => {
|
|
144
|
+
const text = renderTerminal(emptyReport);
|
|
145
|
+
expect(text).toContain('No drift detected');
|
|
146
|
+
expect(text).not.toContain('#');
|
|
147
|
+
expect(text).not.toMatch(/\*\*/);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('renderDeltaTerminal', () => {
|
|
152
|
+
const noNewDrift: DriftDelta = {
|
|
153
|
+
hasNewDrift: false,
|
|
154
|
+
introduced: {
|
|
155
|
+
events: {
|
|
156
|
+
observedButUndocumented: [],
|
|
157
|
+
documentedButUnseen: [],
|
|
158
|
+
fieldDrift: [],
|
|
159
|
+
},
|
|
160
|
+
services: { observedButUndocumented: [] },
|
|
161
|
+
channels: { observedButUndocumented: [] },
|
|
162
|
+
},
|
|
163
|
+
resolved: {
|
|
164
|
+
events: {
|
|
165
|
+
observedButUndocumented: [],
|
|
166
|
+
documentedButUnseen: [],
|
|
167
|
+
fieldDrift: [],
|
|
168
|
+
},
|
|
169
|
+
services: { observedButUndocumented: [] },
|
|
170
|
+
channels: { observedButUndocumented: [] },
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
const newDrift: DriftDelta = {
|
|
174
|
+
hasNewDrift: true,
|
|
175
|
+
introduced: {
|
|
176
|
+
events: {
|
|
177
|
+
observedButUndocumented: ['order.cancelled'],
|
|
178
|
+
documentedButUnseen: [],
|
|
179
|
+
fieldDrift: [],
|
|
180
|
+
},
|
|
181
|
+
services: { observedButUndocumented: [] },
|
|
182
|
+
channels: { observedButUndocumented: [] },
|
|
183
|
+
},
|
|
184
|
+
resolved: {
|
|
185
|
+
events: {
|
|
186
|
+
observedButUndocumented: [],
|
|
187
|
+
documentedButUnseen: [],
|
|
188
|
+
fieldDrift: [],
|
|
189
|
+
},
|
|
190
|
+
services: { observedButUndocumented: [] },
|
|
191
|
+
channels: { observedButUndocumented: [] },
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
it('strips markdown for delta-style output', () => {
|
|
196
|
+
const text = renderDeltaTerminal(newDrift);
|
|
197
|
+
expect(text).toContain('order.cancelled');
|
|
198
|
+
expect(text).not.toMatch(/\*\*|`|^#/m);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('renders cleanly when there is no new drift', () => {
|
|
202
|
+
const text = renderDeltaTerminal(noNewDrift);
|
|
203
|
+
expect(text).toContain('No new drift detected');
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Backwards-compatible re-export of the renderer surface.
|
|
2
|
+
//
|
|
3
|
+
// The actual implementations live in `./renderers/<name>.ts` — see the
|
|
4
|
+
// Renderer adapter pattern there. This module exists so any consumer (or
|
|
5
|
+
// internal test) that imports from `'./report'` continues to work.
|
|
6
|
+
//
|
|
7
|
+
// New renderers should be added under `./renderers/` and registered in
|
|
8
|
+
// `./renderers/index.ts`; do not add new exports here.
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
renderMarkdown,
|
|
12
|
+
renderDeltaMarkdown,
|
|
13
|
+
renderTerminal,
|
|
14
|
+
renderDeltaTerminal,
|
|
15
|
+
renderJson,
|
|
16
|
+
REPORT_SPEC,
|
|
17
|
+
RENDERERS,
|
|
18
|
+
RENDERER_NAMES,
|
|
19
|
+
getRenderer,
|
|
20
|
+
} from './renderers/index';
|
|
21
|
+
|
|
22
|
+
export type {
|
|
23
|
+
Renderer,
|
|
24
|
+
RendererName,
|
|
25
|
+
JsonReport,
|
|
26
|
+
JsonReportEnvelope,
|
|
27
|
+
} from './renderers/index';
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Re-export the snapshot types from autotel-subscribers and provide a
|
|
2
|
+
// convenience loader. Keeping the types defined in one place makes the
|
|
3
|
+
// contract between subscriber and generator unambiguous.
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
ArchitectureSnapshot,
|
|
9
|
+
EventObservation,
|
|
10
|
+
} from 'autotel-subscribers/architecture-snapshot';
|
|
11
|
+
|
|
12
|
+
import type { ArchitectureSnapshot } from 'autotel-subscribers/architecture-snapshot';
|
|
13
|
+
|
|
14
|
+
export async function loadSnapshot(
|
|
15
|
+
path: string,
|
|
16
|
+
): Promise<ArchitectureSnapshot> {
|
|
17
|
+
const raw = await readFile(path, 'utf8');
|
|
18
|
+
const parsed = JSON.parse(raw) as ArchitectureSnapshot;
|
|
19
|
+
if (!parsed?.spec?.startsWith('autotel-architecture/')) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Not an autotel architecture snapshot (missing spec marker): ${path}`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|