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