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
package/src/diff.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Compute drift between an autotel architecture snapshot and an existing
|
|
2
|
+
// EventCatalog. The diff is intentionally conservative: it reports only
|
|
3
|
+
// existence and field-path drift. Type drift, value drift, and enum-value
|
|
4
|
+
// checks are deferred — they need richer signal than v0 carries.
|
|
5
|
+
|
|
6
|
+
import type { ArchitectureSnapshot } from './snapshot';
|
|
7
|
+
import type { CatalogState } from './catalog';
|
|
8
|
+
|
|
9
|
+
export type EventDrift = {
|
|
10
|
+
/** Event names observed in the snapshot but not present in the catalog. */
|
|
11
|
+
observedButUndocumented: string[];
|
|
12
|
+
/** Event names declared in the catalog but never observed in the snapshot. */
|
|
13
|
+
documentedButUnseen: string[];
|
|
14
|
+
/** Per-event field-path mismatches. */
|
|
15
|
+
fieldDrift: FieldDrift[];
|
|
16
|
+
/** Per-event field type mismatches against declared schema types. */
|
|
17
|
+
typeDrift: TypeDrift[];
|
|
18
|
+
/** Per-event enum/value mismatches against declared schema enums. */
|
|
19
|
+
valueDrift: ValueDrift[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type FieldDrift = {
|
|
23
|
+
event: string;
|
|
24
|
+
/** Field paths in the observed payload but not declared in the schema. */
|
|
25
|
+
extra: string[];
|
|
26
|
+
/** Field paths declared in the schema but never observed in a payload. */
|
|
27
|
+
missing: string[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TypeDrift = {
|
|
31
|
+
event: string;
|
|
32
|
+
path: string;
|
|
33
|
+
declared: string[];
|
|
34
|
+
observed: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ValueDrift = {
|
|
38
|
+
event: string;
|
|
39
|
+
path: string;
|
|
40
|
+
declared: unknown[];
|
|
41
|
+
observed: unknown[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ServiceDrift = {
|
|
45
|
+
/** Producers in the snapshot but not present in the catalog as services. */
|
|
46
|
+
observedButUndocumented: string[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ChannelDrift = {
|
|
50
|
+
/** Channels in the snapshot but not present in the catalog. */
|
|
51
|
+
observedButUndocumented: string[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type DriftReport = {
|
|
55
|
+
snapshotGeneratedAt: string;
|
|
56
|
+
snapshotService: string;
|
|
57
|
+
events: EventDrift;
|
|
58
|
+
services: ServiceDrift;
|
|
59
|
+
channels: ChannelDrift;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** True if the report contains any drift worth surfacing in a PR check. */
|
|
63
|
+
export function hasDrift(report: DriftReport): boolean {
|
|
64
|
+
const c = countDriftReport(report);
|
|
65
|
+
return c.total > 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Per-category counts for a DriftReport. Keeps the dashboard's hero meter,
|
|
70
|
+
* the CLI's summary-output, and the action's structured output all agreeing
|
|
71
|
+
* on what "N findings" means.
|
|
72
|
+
*/
|
|
73
|
+
export type DriftCounts = {
|
|
74
|
+
/** Total of all categories — what a dashboard "drift findings" badge shows. */
|
|
75
|
+
total: number;
|
|
76
|
+
observedButUndocumentedEvents: number;
|
|
77
|
+
documentedButUnseenEvents: number;
|
|
78
|
+
/** Number of distinct events with field-path drift entries. */
|
|
79
|
+
fieldDriftEvents: number;
|
|
80
|
+
/** Sum of every individual extra + missing path across all fieldDrift entries. */
|
|
81
|
+
fieldDriftPaths: number;
|
|
82
|
+
typeDriftPaths: number;
|
|
83
|
+
valueDriftPaths: number;
|
|
84
|
+
undocumentedServices: number;
|
|
85
|
+
undocumentedChannels: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export function countDriftReport(report: DriftReport): DriftCounts {
|
|
89
|
+
const typeDrift = report.events.typeDrift ?? [];
|
|
90
|
+
const valueDrift = report.events.valueDrift ?? [];
|
|
91
|
+
const fieldDriftEvents = report.events.fieldDrift.length;
|
|
92
|
+
const fieldDriftPaths = report.events.fieldDrift.reduce(
|
|
93
|
+
(sum, fd) => sum + fd.extra.length + fd.missing.length,
|
|
94
|
+
0,
|
|
95
|
+
);
|
|
96
|
+
const typeDriftPaths = typeDrift.length;
|
|
97
|
+
const valueDriftPaths = valueDrift.length;
|
|
98
|
+
const observedButUndocumentedEvents =
|
|
99
|
+
report.events.observedButUndocumented.length;
|
|
100
|
+
const documentedButUnseenEvents = report.events.documentedButUnseen.length;
|
|
101
|
+
const undocumentedServices = report.services.observedButUndocumented.length;
|
|
102
|
+
const undocumentedChannels = report.channels.observedButUndocumented.length;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
observedButUndocumentedEvents,
|
|
106
|
+
documentedButUnseenEvents,
|
|
107
|
+
fieldDriftEvents,
|
|
108
|
+
fieldDriftPaths,
|
|
109
|
+
typeDriftPaths,
|
|
110
|
+
valueDriftPaths,
|
|
111
|
+
undocumentedServices,
|
|
112
|
+
undocumentedChannels,
|
|
113
|
+
total:
|
|
114
|
+
observedButUndocumentedEvents +
|
|
115
|
+
documentedButUnseenEvents +
|
|
116
|
+
fieldDriftPaths +
|
|
117
|
+
typeDriftPaths +
|
|
118
|
+
valueDriftPaths +
|
|
119
|
+
undocumentedServices +
|
|
120
|
+
undocumentedChannels,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function diffCatalogAgainstSnapshot(
|
|
125
|
+
snapshot: ArchitectureSnapshot,
|
|
126
|
+
catalog: CatalogState,
|
|
127
|
+
): DriftReport {
|
|
128
|
+
const snapshotEvents = new Set(Object.keys(snapshot.events));
|
|
129
|
+
const catalogEventIds = new Set(catalog.events.keys());
|
|
130
|
+
|
|
131
|
+
// Catalog event IDs are PascalCase ("OrderPlaced") while track() names are
|
|
132
|
+
// dotted ("order.placed"). We compare on a normalised form so the same
|
|
133
|
+
// event isn't reported as both missing and extra.
|
|
134
|
+
const catalogEventByNormalised = new Map<string, string>();
|
|
135
|
+
for (const id of catalogEventIds) {
|
|
136
|
+
catalogEventByNormalised.set(normaliseEventId(id), id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const observedButUndocumented: string[] = [];
|
|
140
|
+
const matchedCatalogIds = new Set<string>();
|
|
141
|
+
for (const name of snapshotEvents) {
|
|
142
|
+
const matched = catalogEventByNormalised.get(normaliseEventId(name));
|
|
143
|
+
if (matched) {
|
|
144
|
+
matchedCatalogIds.add(matched);
|
|
145
|
+
} else {
|
|
146
|
+
observedButUndocumented.push(name);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const documentedButUnseen: string[] = [];
|
|
151
|
+
for (const id of catalogEventIds) {
|
|
152
|
+
if (!matchedCatalogIds.has(id)) documentedButUnseen.push(id);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const fieldDrift: FieldDrift[] = [];
|
|
156
|
+
const typeDrift: TypeDrift[] = [];
|
|
157
|
+
const valueDrift: ValueDrift[] = [];
|
|
158
|
+
for (const [snapName, obs] of Object.entries(snapshot.events)) {
|
|
159
|
+
const catalogId = catalogEventByNormalised.get(normaliseEventId(snapName));
|
|
160
|
+
if (!catalogId) continue;
|
|
161
|
+
const eventDecl = catalog.events.get(catalogId);
|
|
162
|
+
const declared = eventDecl?.declaredFieldPaths;
|
|
163
|
+
if (!declared) continue;
|
|
164
|
+
|
|
165
|
+
const declaredSet = new Set(declared);
|
|
166
|
+
const observedSet = new Set(obs.fieldPaths);
|
|
167
|
+
|
|
168
|
+
const extra = obs.fieldPaths.filter((p) => !declaredSet.has(p));
|
|
169
|
+
const missing = declared.filter((p) => !observedSet.has(p));
|
|
170
|
+
|
|
171
|
+
if (extra.length > 0 || missing.length > 0) {
|
|
172
|
+
fieldDrift.push({ event: snapName, extra, missing });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const constraints = eventDecl?.declaredSchemaConstraints ?? {};
|
|
176
|
+
const stats =
|
|
177
|
+
(
|
|
178
|
+
obs as {
|
|
179
|
+
fieldStats?: Record<
|
|
180
|
+
string,
|
|
181
|
+
{ types: string[]; sampleValues: unknown[] }
|
|
182
|
+
>;
|
|
183
|
+
}
|
|
184
|
+
).fieldStats ?? {};
|
|
185
|
+
for (const [path, declaredConstraint] of Object.entries(constraints)) {
|
|
186
|
+
const observed = stats[path];
|
|
187
|
+
if (!observed) continue;
|
|
188
|
+
if (declaredConstraint.types && declaredConstraint.types.length > 0) {
|
|
189
|
+
// JSON Schema has `integer`; JavaScript has only `number`. Treat the
|
|
190
|
+
// two as compatible at the runtime-type level, then use sample values
|
|
191
|
+
// to flag the real signal (a non-integer value seen against an
|
|
192
|
+
// integer-only declaration).
|
|
193
|
+
const accepts = expandDeclaredTypes(declaredConstraint.types);
|
|
194
|
+
const badTypes = observed.types.filter((t: string) => !accepts.has(t));
|
|
195
|
+
const integerDeclared =
|
|
196
|
+
declaredConstraint.types.includes('integer') &&
|
|
197
|
+
!declaredConstraint.types.includes('number');
|
|
198
|
+
const nonIntegerSamples = integerDeclared
|
|
199
|
+
? observed.sampleValues.filter(
|
|
200
|
+
(v: unknown) => typeof v === 'number' && !Number.isInteger(v),
|
|
201
|
+
)
|
|
202
|
+
: [];
|
|
203
|
+
if (badTypes.length > 0 || nonIntegerSamples.length > 0) {
|
|
204
|
+
typeDrift.push({
|
|
205
|
+
event: snapName,
|
|
206
|
+
path,
|
|
207
|
+
declared: declaredConstraint.types,
|
|
208
|
+
observed: [...new Set(observed.types)].toSorted(),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (
|
|
213
|
+
declaredConstraint.enumValues &&
|
|
214
|
+
declaredConstraint.enumValues.length > 0 &&
|
|
215
|
+
observed.sampleValues.length > 0
|
|
216
|
+
) {
|
|
217
|
+
const observedOutsideEnum = observed.sampleValues.filter(
|
|
218
|
+
(v: unknown) =>
|
|
219
|
+
!declaredConstraint.enumValues?.some((d) => Object.is(d, v)),
|
|
220
|
+
);
|
|
221
|
+
if (observedOutsideEnum.length > 0) {
|
|
222
|
+
valueDrift.push({
|
|
223
|
+
event: snapName,
|
|
224
|
+
path,
|
|
225
|
+
declared: declaredConstraint.enumValues,
|
|
226
|
+
observed: [...new Set(observedOutsideEnum)] as unknown[],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const snapshotServices = collectProducers(snapshot);
|
|
234
|
+
const catalogServiceIds = new Set(catalog.services.keys());
|
|
235
|
+
const undocumentedServices = [...snapshotServices].filter(
|
|
236
|
+
(id) => !catalogServiceIds.has(id),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const snapshotChannels = collectChannels(snapshot);
|
|
240
|
+
const catalogChannelIds = new Set(catalog.channels.keys());
|
|
241
|
+
const undocumentedChannels = [...snapshotChannels].filter(
|
|
242
|
+
(id) => !catalogChannelIds.has(id),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
snapshotGeneratedAt: snapshot.generatedAt,
|
|
247
|
+
snapshotService: snapshot.service,
|
|
248
|
+
events: {
|
|
249
|
+
observedButUndocumented: observedButUndocumented.sort(),
|
|
250
|
+
documentedButUnseen: documentedButUnseen.sort(),
|
|
251
|
+
fieldDrift: fieldDrift.sort((a, b) => a.event.localeCompare(b.event)),
|
|
252
|
+
typeDrift: typeDrift.sort((a, b) =>
|
|
253
|
+
`${a.event}.${a.path}`.localeCompare(`${b.event}.${b.path}`),
|
|
254
|
+
),
|
|
255
|
+
valueDrift: valueDrift.sort((a, b) =>
|
|
256
|
+
`${a.event}.${a.path}`.localeCompare(`${b.event}.${b.path}`),
|
|
257
|
+
),
|
|
258
|
+
},
|
|
259
|
+
services: { observedButUndocumented: undocumentedServices.sort() },
|
|
260
|
+
channels: { observedButUndocumented: undocumentedChannels.sort() },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Map declared JSON Schema types to the set of runtime types we accept at
|
|
266
|
+
* `typeof` level. JSON Schema's `integer` is a refinement of `number` (JS
|
|
267
|
+
* does not have a separate integer type), so we accept observed `number` for
|
|
268
|
+
* either declaration. The integer-vs-fractional distinction is then enforced
|
|
269
|
+
* separately against sample values.
|
|
270
|
+
*/
|
|
271
|
+
function expandDeclaredTypes(declared: string[]): Set<string> {
|
|
272
|
+
const accepts = new Set<string>(declared);
|
|
273
|
+
if (declared.includes('integer')) accepts.add('number');
|
|
274
|
+
return accepts;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function normaliseEventId(id: string): string {
|
|
278
|
+
// "order.placed" -> "orderplaced", "OrderPlaced" -> "orderplaced".
|
|
279
|
+
return id.toLowerCase().replaceAll(/[._\-\s]/g, '');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function collectProducers(snapshot: ArchitectureSnapshot): Set<string> {
|
|
283
|
+
const out = new Set<string>();
|
|
284
|
+
for (const obs of Object.values(snapshot.events)) {
|
|
285
|
+
if (obs.producer) out.add(obs.producer);
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function collectChannels(snapshot: ArchitectureSnapshot): Set<string> {
|
|
291
|
+
const out = new Set<string>();
|
|
292
|
+
for (const obs of Object.values(snapshot.events)) {
|
|
293
|
+
if (obs.channel) out.add(obs.channel);
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// autotel-eventcatalog
|
|
2
|
+
//
|
|
3
|
+
// Diff your autotel architecture snapshot against an EventCatalog and report
|
|
4
|
+
// drift. Same shape as Pact, for event architectures.
|
|
5
|
+
|
|
6
|
+
export { loadSnapshot } from './snapshot';
|
|
7
|
+
export type { ArchitectureSnapshot, EventObservation } from './snapshot';
|
|
8
|
+
|
|
9
|
+
export { readCatalogState, extractDeclaredFieldPaths } from './catalog';
|
|
10
|
+
export type {
|
|
11
|
+
CatalogState,
|
|
12
|
+
CatalogEvent,
|
|
13
|
+
CatalogService,
|
|
14
|
+
CatalogChannel,
|
|
15
|
+
} from './catalog';
|
|
16
|
+
|
|
17
|
+
export { diffCatalogAgainstSnapshot, hasDrift, countDriftReport } from './diff';
|
|
18
|
+
export type {
|
|
19
|
+
DriftReport,
|
|
20
|
+
EventDrift,
|
|
21
|
+
FieldDrift,
|
|
22
|
+
ServiceDrift,
|
|
23
|
+
ChannelDrift,
|
|
24
|
+
DriftCounts,
|
|
25
|
+
} from './diff';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
compareDriftReports,
|
|
29
|
+
countDriftEntries,
|
|
30
|
+
countDriftDelta,
|
|
31
|
+
} from './diff-vs-base';
|
|
32
|
+
export type { DriftDelta, DriftEntries } from './diff-vs-base';
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
renderMarkdown,
|
|
36
|
+
renderDeltaMarkdown,
|
|
37
|
+
renderTerminal,
|
|
38
|
+
renderDeltaTerminal,
|
|
39
|
+
renderJson,
|
|
40
|
+
REPORT_SPEC,
|
|
41
|
+
RENDERERS,
|
|
42
|
+
RENDERER_NAMES,
|
|
43
|
+
getRenderer,
|
|
44
|
+
} from './report';
|
|
45
|
+
export type {
|
|
46
|
+
JsonReport,
|
|
47
|
+
JsonReportEnvelope,
|
|
48
|
+
Renderer,
|
|
49
|
+
RendererName,
|
|
50
|
+
} from './report';
|
|
51
|
+
|
|
52
|
+
export { evaluatePolicy } from './policy';
|
|
53
|
+
export type {
|
|
54
|
+
DriftPolicyMode,
|
|
55
|
+
PolicyEvaluationInput,
|
|
56
|
+
PolicyEvaluationResult,
|
|
57
|
+
} from './policy';
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
stampCatalog,
|
|
61
|
+
buildStampBlock,
|
|
62
|
+
buildStampSummary,
|
|
63
|
+
STAMP_START,
|
|
64
|
+
STAMP_END,
|
|
65
|
+
STAMP_SUMMARY_SPEC,
|
|
66
|
+
} from './stamp';
|
|
67
|
+
export type {
|
|
68
|
+
StampOptions,
|
|
69
|
+
StampResult,
|
|
70
|
+
StampUpdate,
|
|
71
|
+
StampSkip,
|
|
72
|
+
StampSummary,
|
|
73
|
+
} from './stamp';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { evaluatePolicy } from './policy';
|
|
3
|
+
import type { DriftReport } from './diff';
|
|
4
|
+
import type { DriftDelta } from './diff-vs-base';
|
|
5
|
+
|
|
6
|
+
const cleanReport: DriftReport = {
|
|
7
|
+
snapshotGeneratedAt: '2026-05-21T18:04:00.000Z',
|
|
8
|
+
snapshotService: 'orders',
|
|
9
|
+
events: {
|
|
10
|
+
observedButUndocumented: [],
|
|
11
|
+
documentedButUnseen: [],
|
|
12
|
+
fieldDrift: [],
|
|
13
|
+
},
|
|
14
|
+
services: { observedButUndocumented: [] },
|
|
15
|
+
channels: { observedButUndocumented: [] },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function delta(hasNewDrift: boolean): DriftDelta {
|
|
19
|
+
return {
|
|
20
|
+
hasNewDrift,
|
|
21
|
+
introduced: {
|
|
22
|
+
events: {
|
|
23
|
+
observedButUndocumented: [],
|
|
24
|
+
documentedButUnseen: [],
|
|
25
|
+
fieldDrift: [],
|
|
26
|
+
},
|
|
27
|
+
services: { observedButUndocumented: [] },
|
|
28
|
+
channels: { observedButUndocumented: [] },
|
|
29
|
+
},
|
|
30
|
+
resolved: {
|
|
31
|
+
events: {
|
|
32
|
+
observedButUndocumented: [],
|
|
33
|
+
documentedButUnseen: [],
|
|
34
|
+
fieldDrift: [],
|
|
35
|
+
},
|
|
36
|
+
services: { observedButUndocumented: [] },
|
|
37
|
+
channels: { observedButUndocumented: [] },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('evaluatePolicy', () => {
|
|
43
|
+
it('fails in all mode when drift exists', () => {
|
|
44
|
+
const result = evaluatePolicy({
|
|
45
|
+
mode: 'all',
|
|
46
|
+
report: {
|
|
47
|
+
...cleanReport,
|
|
48
|
+
events: {
|
|
49
|
+
...cleanReport.events,
|
|
50
|
+
observedButUndocumented: ['order.cancelled'],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
expect(result.shouldFail).toBe(true);
|
|
55
|
+
expect(result.reason).toMatch(/Drift detected/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('passes in all mode when drift does not exist', () => {
|
|
59
|
+
const result = evaluatePolicy({ mode: 'all', report: cleanReport });
|
|
60
|
+
expect(result.shouldFail).toBe(false);
|
|
61
|
+
expect(result.reason).toMatch(/No drift detected/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('fails in new-only mode when new drift exists', () => {
|
|
65
|
+
const result = evaluatePolicy({ mode: 'new-only', delta: delta(true) });
|
|
66
|
+
expect(result.shouldFail).toBe(true);
|
|
67
|
+
expect(result.reason).toMatch(/New drift introduced/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('passes in new-only mode when no new drift exists', () => {
|
|
71
|
+
const result = evaluatePolicy({ mode: 'new-only', delta: delta(false) });
|
|
72
|
+
expect(result.shouldFail).toBe(false);
|
|
73
|
+
expect(result.reason).toMatch(/No new drift/);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Decide whether a drift outcome should fail a CI job.
|
|
2
|
+
//
|
|
3
|
+
// `policy` answers the gating question; it deliberately doesn't render or
|
|
4
|
+
// print anything. `reason` carries a short, log-friendly explanation that
|
|
5
|
+
// CLI / Action layers can surface in their exit messages.
|
|
6
|
+
|
|
7
|
+
import type { DriftDelta } from './diff-vs-base';
|
|
8
|
+
import type { DriftReport } from './diff';
|
|
9
|
+
import { hasDrift } from './diff';
|
|
10
|
+
|
|
11
|
+
export type DriftPolicyMode = 'all' | 'new-only';
|
|
12
|
+
|
|
13
|
+
export type PolicyEvaluationInput =
|
|
14
|
+
| { mode: 'all'; report: DriftReport }
|
|
15
|
+
| { mode: 'new-only'; delta: DriftDelta };
|
|
16
|
+
|
|
17
|
+
export type PolicyEvaluationResult = {
|
|
18
|
+
shouldFail: boolean;
|
|
19
|
+
reason: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function evaluatePolicy(
|
|
23
|
+
input: PolicyEvaluationInput,
|
|
24
|
+
): PolicyEvaluationResult {
|
|
25
|
+
if (input.mode === 'all') {
|
|
26
|
+
const shouldFail = hasDrift(input.report);
|
|
27
|
+
return {
|
|
28
|
+
shouldFail,
|
|
29
|
+
reason: shouldFail
|
|
30
|
+
? 'Drift detected in current snapshot.'
|
|
31
|
+
: 'No drift detected.',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const shouldFail = input.delta.hasNewDrift;
|
|
35
|
+
return {
|
|
36
|
+
shouldFail,
|
|
37
|
+
reason: shouldFail
|
|
38
|
+
? 'New drift introduced compared to baseline snapshot.'
|
|
39
|
+
: 'No new drift introduced compared to baseline snapshot.',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Registry of available renderers. The CLI dispatches `--format <name>`
|
|
2
|
+
// through here; future renderers (sarif, slack, github-check-runs) drop in
|
|
3
|
+
// by adding an entry to `RENDERERS`.
|
|
4
|
+
|
|
5
|
+
import type { Renderer } from './types';
|
|
6
|
+
import { markdownRenderer } from './markdown';
|
|
7
|
+
import { terminalRenderer } from './terminal';
|
|
8
|
+
import { jsonRenderer } from './json';
|
|
9
|
+
|
|
10
|
+
export const RENDERERS: readonly Renderer[] = [
|
|
11
|
+
markdownRenderer,
|
|
12
|
+
terminalRenderer,
|
|
13
|
+
jsonRenderer,
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export const RENDERER_NAMES = RENDERERS.map((r) => r.name);
|
|
17
|
+
|
|
18
|
+
export function getRenderer(name: string): Renderer | undefined {
|
|
19
|
+
return RENDERERS.find((r) => r.name === name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type RendererName = (typeof RENDERER_NAMES)[number];
|
|
23
|
+
|
|
24
|
+
// Re-export the individual functions for backwards compatibility — they have
|
|
25
|
+
// been the public API since v0.1.0 and consumers may import them directly.
|
|
26
|
+
|
|
27
|
+
export { renderMarkdown, renderDeltaMarkdown } from './markdown';
|
|
28
|
+
export { renderTerminal, renderDeltaTerminal } from './terminal';
|
|
29
|
+
export {
|
|
30
|
+
renderJson,
|
|
31
|
+
REPORT_SPEC,
|
|
32
|
+
type JsonReport,
|
|
33
|
+
type JsonReportEnvelope,
|
|
34
|
+
} from './json';
|
|
35
|
+
export { type Renderer } from './types';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Versioned JSON envelope. Downstream tooling (dashboards, custom CI steps,
|
|
2
|
+
// Slack bots) should read this rather than scraping the Markdown body. The
|
|
3
|
+
// shape is governed by the published schema at
|
|
4
|
+
// `schemas/drift-report-v0.2.0.json` and locked by golden contract tests.
|
|
5
|
+
|
|
6
|
+
import type { DriftReport } from '../diff';
|
|
7
|
+
import type { DriftDelta } from '../diff-vs-base';
|
|
8
|
+
import type { Renderer } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Versioned identifier baked into the JSON envelope. Bumping it is a
|
|
12
|
+
* breaking change for downstream consumers — add fields rather than rename.
|
|
13
|
+
*/
|
|
14
|
+
export const REPORT_SPEC = 'autotel-eventcatalog-report/v0.2.0' as const;
|
|
15
|
+
|
|
16
|
+
export type JsonReport =
|
|
17
|
+
| { mode: 'all'; report: DriftReport }
|
|
18
|
+
| { mode: 'new-only'; delta: DriftDelta };
|
|
19
|
+
|
|
20
|
+
export type JsonReportEnvelope = { spec: typeof REPORT_SPEC } & JsonReport;
|
|
21
|
+
|
|
22
|
+
export function renderJson(data: JsonReport): string {
|
|
23
|
+
const envelope: JsonReportEnvelope = { spec: REPORT_SPEC, ...data };
|
|
24
|
+
return JSON.stringify(envelope, null, 2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const jsonRenderer: Renderer = {
|
|
28
|
+
name: 'json',
|
|
29
|
+
description:
|
|
30
|
+
'Versioned JSON envelope. Validate against schemas/drift-report-v0.2.0.json.',
|
|
31
|
+
renderReport: (report) => renderJson({ mode: 'all', report }),
|
|
32
|
+
renderDelta: (delta) => renderJson({ mode: 'new-only', delta }),
|
|
33
|
+
};
|