@eventcatalog/cli 0.5.3 → 0.5.5

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.
@@ -1235,7 +1235,10 @@ var snapshotList = async (opts) => {
1235
1235
  // src/cli/governance/rules.ts
1236
1236
  import fs from "fs";
1237
1237
  import path2 from "path";
1238
+ import { createHash } from "crypto";
1238
1239
  import yaml from "js-yaml";
1240
+ import { satisfies, validRange } from "semver";
1241
+ import createSDK6 from "@eventcatalog/sdk";
1239
1242
  var loadGovernanceConfig = (catalogDir) => {
1240
1243
  const yamlPath = path2.join(catalogDir, "governance.yaml");
1241
1244
  const ymlPath = path2.join(catalogDir, "governance.yml");
@@ -1245,7 +1248,15 @@ var loadGovernanceConfig = (catalogDir) => {
1245
1248
  }
1246
1249
  const content = fs.readFileSync(configPath, "utf-8");
1247
1250
  const parsed = yaml.load(content);
1248
- return { rules: parsed?.rules || [] };
1251
+ const rules = parsed?.rules || [];
1252
+ for (const rule of rules) {
1253
+ for (const action of rule.actions) {
1254
+ if (action.type === "fail" && action.message !== void 0 && typeof action.message !== "string") {
1255
+ throw new Error(`Invalid "message" in fail action for rule "${rule.name}". Must be a string.`);
1256
+ }
1257
+ }
1258
+ }
1259
+ return { rules };
1249
1260
  };
1250
1261
  var TRIGGER_FILTERS = {
1251
1262
  consumer_added: (c2) => c2.direction === "receives" && c2.changeType === "added",
@@ -1293,15 +1304,16 @@ var buildMessageMap = (snapshot2) => {
1293
1304
  for (const msg of snapshot2.resources.messages.queries) map.set(msg.id, msg);
1294
1305
  return map;
1295
1306
  };
1296
- var buildProducerIndex = (snapshot2) => {
1307
+ var buildServiceIndex = (snapshot2, direction) => {
1297
1308
  const index = /* @__PURE__ */ new Map();
1298
1309
  for (const service of snapshot2.resources.services) {
1299
- if (!service.sends) continue;
1300
- for (const s of service.sends) {
1301
- let producers = index.get(s.id);
1302
- if (!producers) {
1303
- producers = [];
1304
- index.set(s.id, producers);
1310
+ const pointers = service[direction];
1311
+ if (!pointers) continue;
1312
+ for (const pointer of pointers) {
1313
+ let entries = index.get(pointer.id);
1314
+ if (!entries) {
1315
+ entries = [];
1316
+ index.set(pointer.id, entries);
1305
1317
  }
1306
1318
  const entry = {
1307
1319
  id: service.id,
@@ -1310,17 +1322,85 @@ var buildProducerIndex = (snapshot2) => {
1310
1322
  if (service.owners && Array.isArray(service.owners) && service.owners.length > 0) {
1311
1323
  entry.owners = service.owners;
1312
1324
  }
1313
- producers.push(entry);
1325
+ entries.push(entry);
1314
1326
  }
1315
1327
  }
1316
1328
  return index;
1317
1329
  };
1330
+ var getMessageTypeKey = (resourceId, type) => `${type}:${resourceId}`;
1331
+ var buildLatestMessageVersionMap = (snapshot2) => {
1332
+ const versions = /* @__PURE__ */ new Map();
1333
+ for (const event of snapshot2.resources.messages.events) {
1334
+ versions.set(getMessageTypeKey(event.id, "event"), event.version);
1335
+ }
1336
+ for (const command of snapshot2.resources.messages.commands) {
1337
+ versions.set(getMessageTypeKey(command.id, "command"), command.version);
1338
+ }
1339
+ for (const query of snapshot2.resources.messages.queries) {
1340
+ versions.set(getMessageTypeKey(query.id, "query"), query.version);
1341
+ }
1342
+ return versions;
1343
+ };
1344
+ var getTargetMessageVersion = (resourceChange) => {
1345
+ if (resourceChange.changeType === "versioned") {
1346
+ return resourceChange.newVersion || resourceChange.version;
1347
+ }
1348
+ return resourceChange.version;
1349
+ };
1350
+ var pointerTargetsChangedVersion = (pointer, resourceChange, latestMessageVersions) => {
1351
+ if (pointer.id !== resourceChange.resourceId) return false;
1352
+ const targetVersion = getTargetMessageVersion(resourceChange);
1353
+ const pointerVersion = pointer.version;
1354
+ if (!pointerVersion || pointerVersion === "latest") {
1355
+ const latestVersion = latestMessageVersions.get(getMessageTypeKey(resourceChange.resourceId, resourceChange.type));
1356
+ if (!latestVersion) return true;
1357
+ return latestVersion === targetVersion;
1358
+ }
1359
+ if (validRange(pointerVersion)) {
1360
+ try {
1361
+ return satisfies(targetVersion, pointerVersion);
1362
+ } catch {
1363
+ return false;
1364
+ }
1365
+ }
1366
+ return pointerVersion === targetVersion;
1367
+ };
1368
+ var getServicesForSchemaChange = (snapshot2, direction, resourceChange, latestMessageVersions) => {
1369
+ const matches = [];
1370
+ for (const service of snapshot2.resources.services) {
1371
+ const pointers = service[direction];
1372
+ if (!pointers) continue;
1373
+ const hasMatch = pointers.some((pointer) => pointerTargetsChangedVersion(pointer, resourceChange, latestMessageVersions));
1374
+ if (!hasMatch) continue;
1375
+ const entry = {
1376
+ id: service.id,
1377
+ version: service.version
1378
+ };
1379
+ if (service.owners && Array.isArray(service.owners) && service.owners.length > 0) {
1380
+ entry.owners = service.owners;
1381
+ }
1382
+ matches.push(entry);
1383
+ }
1384
+ return matches;
1385
+ };
1386
+ var matchesSchemaChangeResource = (schemaChange, resources) => {
1387
+ return resources.some((resource) => {
1388
+ if (resource === "*") return true;
1389
+ if (resource.startsWith("message:")) return schemaChange.resourceChange.resourceId === resource.slice(8);
1390
+ if (resource.startsWith("consumes:"))
1391
+ return schemaChange.consumerServices.some((service) => service.id === resource.slice(9));
1392
+ if (resource.startsWith("produces:"))
1393
+ return schemaChange.producerServices.some((service) => service.id === resource.slice(9));
1394
+ if (resource.startsWith("service:")) return schemaChange.producerServices.some((service) => service.id === resource.slice(8));
1395
+ return false;
1396
+ });
1397
+ };
1318
1398
  var evaluateDeprecationRules = (diff, config, targetSnapshot, targetMessageSets, baseSnapshot) => {
1319
1399
  const deprecationRules = config.rules.filter((rule) => rule.when.includes("message_deprecated"));
1320
1400
  if (deprecationRules.length === 0) return [];
1321
1401
  const targetMessages = buildMessageMap(targetSnapshot);
1322
1402
  const baseMessages = baseSnapshot ? buildMessageMap(baseSnapshot) : void 0;
1323
- const producerIndex = buildProducerIndex(targetSnapshot);
1403
+ const producerIndex = buildServiceIndex(targetSnapshot, "sends");
1324
1404
  const deprecatedResources = diff.resources.filter((rc) => {
1325
1405
  if (!MESSAGE_RESOURCE_TYPES.has(rc.type)) return false;
1326
1406
  if (!rc.changedFields?.includes("deprecated")) return false;
@@ -1347,6 +1427,29 @@ var evaluateDeprecationRules = (diff, config, targetSnapshot, targetMessageSets,
1347
1427
  }
1348
1428
  return results;
1349
1429
  };
1430
+ var evaluateSchemaChangeRules = (diff, config, targetSnapshot) => {
1431
+ const schemaRules = config.rules.filter((rule) => rule.when.includes("schema_changed"));
1432
+ if (schemaRules.length === 0) return [];
1433
+ const schemaChangedResources = diff.resources.filter((rc) => {
1434
+ if (!MESSAGE_RESOURCE_TYPES.has(rc.type)) return false;
1435
+ return rc.changedFields?.includes("schemaHash");
1436
+ });
1437
+ if (schemaChangedResources.length === 0) return [];
1438
+ const latestMessageVersions = buildLatestMessageVersionMap(targetSnapshot);
1439
+ const schemaChanges = schemaChangedResources.map((resourceChange) => ({
1440
+ resourceChange,
1441
+ producerServices: getServicesForSchemaChange(targetSnapshot, "sends", resourceChange, latestMessageVersions),
1442
+ consumerServices: getServicesForSchemaChange(targetSnapshot, "receives", resourceChange, latestMessageVersions)
1443
+ }));
1444
+ const results = [];
1445
+ for (const rule of schemaRules) {
1446
+ const matched = schemaChanges.filter((schemaChange) => matchesSchemaChangeResource(schemaChange, rule.resources));
1447
+ if (matched.length > 0) {
1448
+ results.push({ rule, trigger: "schema_changed", matchedChanges: [], schemaChanges: matched });
1449
+ }
1450
+ }
1451
+ return results;
1452
+ };
1350
1453
  var evaluateGovernanceRules = (diff, config, targetSnapshot, baseSnapshot) => {
1351
1454
  const results = [];
1352
1455
  const targetMessageSets = targetSnapshot ? buildServiceMessageSets(targetSnapshot) : void 0;
@@ -1366,6 +1469,7 @@ var evaluateGovernanceRules = (diff, config, targetSnapshot, baseSnapshot) => {
1366
1469
  }
1367
1470
  if (targetSnapshot && targetMessageSets) {
1368
1471
  results.push(...evaluateDeprecationRules(diff, config, targetSnapshot, targetMessageSets, baseSnapshot));
1472
+ results.push(...evaluateSchemaChangeRules(diff, config, targetSnapshot));
1369
1473
  }
1370
1474
  return results;
1371
1475
  };
@@ -1384,6 +1488,48 @@ var resolveEnvVars = (value) => {
1384
1488
  return envValue;
1385
1489
  });
1386
1490
  };
1491
+ var readSchemaDetails = async (sdk, resourceId, version2, type) => {
1492
+ if (!MESSAGE_RESOURCE_TYPES.has(type)) return {};
1493
+ try {
1494
+ const schema = await sdk.getSchemaForMessage(resourceId, version2);
1495
+ if (!schema) return {};
1496
+ return {
1497
+ content: schema.schema,
1498
+ schemaPath: schema.fileName,
1499
+ schemaHash: createHash("sha256").update(schema.schema).digest("hex")
1500
+ };
1501
+ } catch {
1502
+ return {};
1503
+ }
1504
+ };
1505
+ var enrichSchemaContent = async (results, baseCatalogDir, targetCatalogDir) => {
1506
+ const baseSDK = createSDK6(baseCatalogDir);
1507
+ const targetSDK = createSDK6(targetCatalogDir);
1508
+ const promises = [];
1509
+ for (const result of results) {
1510
+ if (!result.schemaChanges) continue;
1511
+ for (const sc of result.schemaChanges) {
1512
+ const { resourceId, version: version2, type, changeType, previousVersion, newVersion } = sc.resourceChange;
1513
+ const baseVersion = changeType === "versioned" ? previousVersion || version2 : version2;
1514
+ const targetVersion = changeType === "versioned" ? newVersion || version2 : version2;
1515
+ promises.push(
1516
+ (async () => {
1517
+ const [before, after] = await Promise.all([
1518
+ readSchemaDetails(baseSDK, resourceId, baseVersion, type),
1519
+ readSchemaDetails(targetSDK, resourceId, targetVersion, type)
1520
+ ]);
1521
+ sc.before = before.content;
1522
+ sc.after = after.content;
1523
+ sc.beforeSchemaPath = before.schemaPath;
1524
+ sc.afterSchemaPath = after.schemaPath;
1525
+ sc.beforeSchemaHash = before.schemaHash;
1526
+ sc.afterSchemaHash = after.schemaHash;
1527
+ })()
1528
+ );
1529
+ }
1530
+ }
1531
+ await Promise.all(promises);
1532
+ };
1387
1533
 
1388
1534
  // src/cli/governance/actions.ts
1389
1535
  import { randomUUID as randomUUID2 } from "crypto";
@@ -1410,7 +1556,7 @@ var buildServiceOwnersMap = (snapshot2) => {
1410
1556
  return map;
1411
1557
  };
1412
1558
  var executeGovernanceActions = async (results, opts = {}) => {
1413
- const { messageTypes, status, serviceOwners } = opts;
1559
+ const { messageTypes, status, serviceOwners, baseRef, targetRef } = opts;
1414
1560
  const webhookCalls = [];
1415
1561
  const now = (/* @__PURE__ */ new Date()).toISOString();
1416
1562
  for (const result of results) {
@@ -1423,6 +1569,46 @@ var executeGovernanceActions = async (results, opts = {}) => {
1423
1569
  headers[key] = resolveEnvVars(value);
1424
1570
  }
1425
1571
  }
1572
+ if (result.schemaChanges && result.schemaChanges.length > 0) {
1573
+ for (const sc of result.schemaChanges) {
1574
+ const messageType = messageTypes?.get(sc.resourceChange.resourceId) || "message";
1575
+ const payload = {
1576
+ specversion: "1.0",
1577
+ type: "eventcatalog.governance.schema_changed",
1578
+ source: "eventcatalog/governance",
1579
+ id: randomUUID2(),
1580
+ time: now,
1581
+ datacontenttype: "application/json",
1582
+ data: {
1583
+ schemaVersion: 1,
1584
+ ...status && { status },
1585
+ summary: `Schema changed for ${messageType} ${sc.resourceChange.resourceId}`,
1586
+ message: {
1587
+ id: sc.resourceChange.resourceId,
1588
+ version: sc.resourceChange.version,
1589
+ type: messageType
1590
+ },
1591
+ schema: {
1592
+ beforeHash: sc.beforeSchemaHash ?? null,
1593
+ afterHash: sc.afterSchemaHash ?? null,
1594
+ beforePath: sc.beforeSchemaPath ?? null,
1595
+ afterPath: sc.afterSchemaPath ?? null
1596
+ },
1597
+ refs: {
1598
+ base: baseRef ?? null,
1599
+ target: targetRef ?? null
1600
+ },
1601
+ consumers: sc.consumerServices,
1602
+ producers: sc.producerServices
1603
+ }
1604
+ };
1605
+ webhookCalls.push({
1606
+ urlTemplate: action.url,
1607
+ request: fetch(url, { method: "POST", headers, body: JSON.stringify(payload) })
1608
+ });
1609
+ }
1610
+ continue;
1611
+ }
1426
1612
  if (result.deprecationChanges && result.deprecationChanges.length > 0) {
1427
1613
  for (const dc of result.deprecationChanges) {
1428
1614
  const messageType = messageTypes?.get(dc.resourceChange.resourceId) || "message";
@@ -1515,7 +1701,14 @@ var formatGovernanceOutput = (results) => {
1515
1701
  const lines = ["Governance:", ""];
1516
1702
  for (const result of results) {
1517
1703
  lines.push(` Rule "${result.rule.name}" triggered (${result.trigger}):`);
1518
- if (result.deprecationChanges && result.deprecationChanges.length > 0) {
1704
+ if (result.schemaChanges && result.schemaChanges.length > 0) {
1705
+ for (const sc of result.schemaChanges) {
1706
+ const consumers = sc.consumerServices.length > 0 ? sc.consumerServices.map((c2) => c2.id).join(", ") : "no known consumers";
1707
+ lines.push(
1708
+ ` ! Schema changed for ${sc.resourceChange.resourceId} (${sc.resourceChange.type}) \u2014 consumers: ${consumers}`
1709
+ );
1710
+ }
1711
+ } else if (result.deprecationChanges && result.deprecationChanges.length > 0) {
1519
1712
  for (const dc of result.deprecationChanges) {
1520
1713
  const producers = dc.producerServices.length > 0 ? dc.producerServices.map((p) => p.id).join(", ") : "unknown producer";
1521
1714
  lines.push(` ! ${dc.resourceChange.resourceId} (${dc.resourceChange.type}) deprecated by ${producers}`);
@@ -1531,6 +1724,17 @@ var formatGovernanceOutput = (results) => {
1531
1724
  }
1532
1725
  return lines.join("\n");
1533
1726
  };
1727
+ var formatFailureOutput = (failures) => {
1728
+ if (failures.length === 0) return "";
1729
+ const lines = [];
1730
+ for (const f of failures) {
1731
+ lines.push(`FAILED: ${f.ruleName}`);
1732
+ for (const msg of f.messages) {
1733
+ lines.push(` ${msg}`);
1734
+ }
1735
+ }
1736
+ return lines.join("\n");
1737
+ };
1534
1738
 
1535
1739
  // src/cli/governance/check.ts
1536
1740
  import path3 from "path";
@@ -1538,7 +1742,7 @@ import { execSync } from "child_process";
1538
1742
  import { mkdtempSync, rmSync as rmSync2 } from "fs";
1539
1743
  import { tmpdir } from "os";
1540
1744
  import dotenv from "dotenv";
1541
- import createSDK6 from "@eventcatalog/sdk";
1745
+ import createSDK7 from "@eventcatalog/sdk";
1542
1746
  import { isEventCatalogScaleEnabled } from "@eventcatalog/license";
1543
1747
  var BRANCH_NAME_RE = /^[a-zA-Z0-9._\-/]+$/;
1544
1748
  var extractBranchToTempDir = (branch, catalogDir, tempDirs) => {
@@ -1572,32 +1776,56 @@ var governanceCheck = async (opts) => {
1572
1776
  const baseTmpDir = extractBranchToTempDir(baseBranch, dir, tempDirs);
1573
1777
  const baseSnapshotDir = trackTempDir("ec-snap-base-");
1574
1778
  const targetSnapshotDir = trackTempDir("ec-snap-target-");
1575
- const baseSDK = createSDK6(baseTmpDir);
1779
+ const baseSDK = createSDK7(baseTmpDir);
1576
1780
  const baseResult = await baseSDK.createSnapshot({ label: `base-${baseBranch}`, outputDir: baseSnapshotDir });
1577
1781
  let targetResult;
1782
+ let targetCatalogDir;
1578
1783
  if (opts.target) {
1579
- const targetTmpDir = extractBranchToTempDir(opts.target, dir, tempDirs);
1580
- const targetSDK = createSDK6(targetTmpDir);
1784
+ targetCatalogDir = extractBranchToTempDir(opts.target, dir, tempDirs);
1785
+ const targetSDK = createSDK7(targetCatalogDir);
1581
1786
  targetResult = await targetSDK.createSnapshot({ label: `target-${opts.target}`, outputDir: targetSnapshotDir });
1582
1787
  } else {
1583
- const targetSDK = createSDK6(dir);
1788
+ targetCatalogDir = dir;
1789
+ const targetSDK = createSDK7(dir);
1584
1790
  targetResult = await targetSDK.createSnapshot({ label: "current", outputDir: targetSnapshotDir });
1585
1791
  }
1586
1792
  const diff = await baseSDK.diffSnapshots(baseResult.filePath, targetResult.filePath);
1587
1793
  const config = loadGovernanceConfig(dir);
1588
1794
  if (config.rules.length === 0) {
1589
- return "No governance.yaml (or governance.yml) found or no rules defined.";
1795
+ return { output: "No governance.yaml (or governance.yml) found or no rules defined.", exitCode: 0, failures: [] };
1590
1796
  }
1591
1797
  const results = evaluateGovernanceRules(diff, config, targetResult.snapshot, baseResult.snapshot);
1798
+ for (const result of results) {
1799
+ const failActions = result.rule.actions.filter((a) => a.type === "fail");
1800
+ if (failActions.length > 0) {
1801
+ result.failed = true;
1802
+ result.failMessages = failActions.map((a) => "message" in a && a.message ? resolveEnvVars(a.message) : void 0).filter((m) => m !== void 0);
1803
+ }
1804
+ }
1805
+ await enrichSchemaContent(results, baseTmpDir, targetCatalogDir);
1592
1806
  const messageTypes = buildMessageTypeMap(targetResult.snapshot);
1593
1807
  const serviceOwners = buildServiceOwnersMap(targetResult.snapshot);
1594
1808
  const actionOutput = await executeGovernanceActions(results, {
1595
1809
  messageTypes,
1596
1810
  status: opts.status,
1597
- serviceOwners
1811
+ serviceOwners,
1812
+ baseRef: baseBranch,
1813
+ targetRef: opts.target || "working-directory"
1598
1814
  });
1815
+ const failures = results.filter((r) => r.failed).map((r) => ({ ruleName: r.rule.name, messages: r.failMessages || [] }));
1599
1816
  if (opts.format === "json") {
1600
- return JSON.stringify({ baseBranch, target: opts.target || "working directory", results, diff: diff.summary }, null, 2);
1817
+ const jsonOutput = {
1818
+ baseBranch,
1819
+ target: opts.target || "working directory",
1820
+ results,
1821
+ summary: {
1822
+ rulesTriggered: results.length,
1823
+ failures: failures.length,
1824
+ passed: failures.length === 0
1825
+ },
1826
+ diff: diff.summary
1827
+ };
1828
+ return { output: JSON.stringify(jsonOutput, null, 2), exitCode: failures.length > 0 ? 1 : 0, failures };
1601
1829
  }
1602
1830
  const targetLabel = opts.target || "working directory";
1603
1831
  const lines = [`Governance check: comparing ${targetLabel} against ${baseBranch}`, ""];
@@ -1613,7 +1841,12 @@ var governanceCheck = async (opts) => {
1613
1841
  lines.push("");
1614
1842
  lines.push(parts.join(", ") + ".");
1615
1843
  }
1616
- return lines.join("\n");
1844
+ const failureOutput = formatFailureOutput(failures);
1845
+ if (failureOutput) {
1846
+ lines.push("");
1847
+ lines.push(failureOutput);
1848
+ }
1849
+ return { output: lines.join("\n"), exitCode: failures.length > 0 ? 1 : 0, failures };
1617
1850
  } finally {
1618
1851
  for (const d of tempDirs) {
1619
1852
  rmSync2(d, { recursive: true, force: true });
@@ -1739,7 +1972,8 @@ governance.command("check").description("Compare catalog against a base branch a
1739
1972
  status: opts.status,
1740
1973
  dir
1741
1974
  });
1742
- console.log(result);
1975
+ console.log(result.output);
1976
+ process.exitCode = result.exitCode;
1743
1977
  } catch (error) {
1744
1978
  console.error(error instanceof Error ? error.message : String(error));
1745
1979
  process.exit(1);