contract-drift-detection 0.1.4 → 0.1.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 0.1.5 - 2026-03-16
6
+
7
+ ### Added
8
+
9
+ - New drift reporting outputs via `--reporter pretty|json|junit`.
10
+ - Optional report destination via `--report-file <path>`.
11
+ - Strict drift mode via `--strict` to enforce `additionalProperties: false` behavior during validation.
12
+ - Path-based drift ignore controls via repeated `--drift-ignore <path>` and `.driftignore` support.
13
+ - New diagnostics endpoint: `GET /__drift` for in-memory drift issue visibility.
14
+ - New unit test coverage for drift analyzer strict/ignore behavior.
15
+ - New CLI e2e coverage for discover failure UX.
16
+
17
+ ### Improved
18
+
19
+ - CI defaults now support fail-fast drift behavior with `--fail-on-drift`.
20
+ - Drift validation pipeline now supports machine-readable reporting for CI gates.
21
+ - Server-level drift callback flow for automated fail/report handling.
22
+
5
23
  ## 0.1.4 - 2026-03-16
6
24
 
7
25
  ### Improved
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import process from "process";
4
+ import process2 from "process";
5
5
 
6
6
  // src/config.ts
7
- import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
7
+ import { mkdir as mkdir2, readFile, writeFile as writeFile2 } from "fs/promises";
8
8
  import path2 from "path";
9
9
  import { Command } from "commander";
10
10
 
@@ -238,11 +238,26 @@ function resolvePathFromCwd(cwd, value) {
238
238
  return path2.isAbsolute(value) ? value : path2.join(cwd, value);
239
239
  }
240
240
  function applyServeOptions(command) {
241
- return command.option("--spec <path>", "Path to an OpenAPI 3.x file").option("--spec-url <url>", "Remote URL to an OpenAPI 3.x file").option("--discover <backend-url>", "Discover OpenAPI from a backend URL and cache it locally").option("--port <port>", "Port to bind the server", "4010").option("--host <host>", "Host to bind the server", "0.0.0.0").option("--db <path>", "JSON database path", ".mock-db.json").option("--cors-origin <origin>", "CORS origin (default is *)", "*").option("--drift-check <url>", "Forward traffic to a real backend and validate responses").option("--fallback-to-mock", "Fallback to mock responses when proxying fails", false).option("--verbose", "Enable verbose logging", false);
241
+ return command.option("--spec <path>", "Path to an OpenAPI 3.x file").option("--spec-url <url>", "Remote URL to an OpenAPI 3.x file").option("--discover <backend-url>", "Discover OpenAPI from a backend URL and cache it locally").option("--port <port>", "Port to bind the server", "4010").option("--host <host>", "Host to bind the server", "0.0.0.0").option("--db <path>", "JSON database path", ".mock-db.json").option("--cors-origin <origin>", "CORS origin (default is *)", "*").option("--drift-check <url>", "Forward traffic to a real backend and validate responses").option("--strict", "Treat additional fields as drift", false).option("--drift-ignore <paths>", "Comma-separated JSON pointer paths to ignore during drift checks").option("--reporter <type>", "Drift reporter: pretty|json|junit", "pretty").option("--report-file <path>", "Output file path for json/junit reporters").option("--fail-on-drift", "Exit with code 1 when drift is detected (enabled by default in CI)", false).option("--fallback-to-mock", "Fallback to mock responses when proxying fails", false).option("--verbose", "Enable verbose logging", false);
242
+ }
243
+ function parseCsvOption(value) {
244
+ if (!value || typeof value !== "string") {
245
+ return [];
246
+ }
247
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
248
+ }
249
+ async function loadDriftIgnoreFromFile(cwd) {
250
+ const ignoreFilePath = path2.join(cwd, ".driftignore");
251
+ try {
252
+ const raw = await readFile(ignoreFilePath, "utf8");
253
+ return raw.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
254
+ } catch {
255
+ return [];
256
+ }
242
257
  }
243
258
  function createCli() {
244
259
  const program = new Command();
245
- program.name("contract-drift-detection").description("Stateful OpenAPI mock server with contract drift detection").version("0.1.0");
260
+ program.name("contract-drift-detection").description("Stateful OpenAPI mock server with contract drift detection").version("0.1.5");
246
261
  applyServeOptions(program);
247
262
  applyServeOptions(program.command("serve").description("Start the mock engine"));
248
263
  program.command("init").description("Create a starter config for the current workspace").option("--spec <path>", "Default OpenAPI path", "openapi.yaml").option("--template <name>", "Template to generate (rest-crud | none)", "rest-crud").option("--db <path>", "Default JSON database path", ".mock-db.json").option("--port <port>", "Default port", "4010").option("--host <host>", "Default host", "0.0.0.0");
@@ -262,6 +277,13 @@ async function resolveServeConfig(cwd, options) {
262
277
  if (!resolvedSpecPath) {
263
278
  throw new Error("Provide one of --spec, --spec-url, or --discover");
264
279
  }
280
+ const ignoreFromCli = parseCsvOption(options.driftIgnore);
281
+ const ignoreFromFile = await loadDriftIgnoreFromFile(cwd);
282
+ const mergedIgnore = Array.from(/* @__PURE__ */ new Set([...ignoreFromFile, ...ignoreFromCli]));
283
+ const reporter = String(options.reporter ?? "pretty");
284
+ if (!["pretty", "json", "junit"].includes(reporter)) {
285
+ throw new Error(`Invalid reporter '${reporter}'. Use one of: pretty, json, junit`);
286
+ }
265
287
  return {
266
288
  specPath: resolvedSpecPath,
267
289
  port: Number(options.port ?? 4010),
@@ -269,6 +291,11 @@ async function resolveServeConfig(cwd, options) {
269
291
  dbPath: resolvePathFromCwd(cwd, String(options.db ?? ".mock-db.json")),
270
292
  corsOrigin: String(options.corsOrigin ?? "*"),
271
293
  driftCheckTarget: options.driftCheck ? String(options.driftCheck) : void 0,
294
+ strictDrift: Boolean(options.strict),
295
+ driftIgnorePaths: mergedIgnore,
296
+ reporter,
297
+ reportFile: options.reportFile ? resolvePathFromCwd(cwd, String(options.reportFile)) : void 0,
298
+ failOnDrift: Boolean(options.failOnDrift) || process.env.CI === "true",
272
299
  fallbackToMockOnProxyError: Boolean(options.fallbackToMock),
273
300
  verbose: Boolean(options.verbose)
274
301
  };
@@ -298,7 +325,7 @@ async function writeStarterConfig(cwd, initConfig) {
298
325
  }
299
326
 
300
327
  // src/server.ts
301
- import path5 from "path";
328
+ import path6 from "path";
302
329
  import Fastify from "fastify";
303
330
  import cors from "@fastify/cors";
304
331
 
@@ -453,33 +480,103 @@ function applyDslMutation(extension, request, collection, defaultIdKey) {
453
480
  import Ajv from "ajv";
454
481
  import addFormats from "ajv-formats";
455
482
  import pc from "picocolors";
483
+ function deepClone2(value) {
484
+ return JSON.parse(JSON.stringify(value));
485
+ }
486
+ function pathMatchesIgnoreRule(instancePath, rule) {
487
+ if (!rule) {
488
+ return false;
489
+ }
490
+ const pathSegments = instancePath.split("/").filter(Boolean);
491
+ const ruleSegments = rule.split("/").filter(Boolean);
492
+ if (ruleSegments.length > pathSegments.length) {
493
+ return false;
494
+ }
495
+ return ruleSegments.every((segment, index) => segment === "*" || segment === pathSegments[index]);
496
+ }
497
+ function applyStrictAdditionalProperties(schema) {
498
+ const output = deepClone2(schema);
499
+ const walk = (candidate) => {
500
+ if (candidate.type === "object" || candidate.properties) {
501
+ if (candidate.additionalProperties === void 0) {
502
+ candidate.additionalProperties = false;
503
+ }
504
+ for (const value of Object.values(candidate.properties ?? {})) {
505
+ if (value && !("$ref" in value)) {
506
+ walk(value);
507
+ }
508
+ }
509
+ }
510
+ if (candidate.type === "array" && candidate.items && !("$ref" in candidate.items)) {
511
+ walk(candidate.items);
512
+ }
513
+ for (const entry of candidate.allOf ?? []) {
514
+ if (!("$ref" in entry)) {
515
+ walk(entry);
516
+ }
517
+ }
518
+ for (const entry of candidate.oneOf ?? []) {
519
+ if (!("$ref" in entry)) {
520
+ walk(entry);
521
+ }
522
+ }
523
+ for (const entry of candidate.anyOf ?? []) {
524
+ if (!("$ref" in entry)) {
525
+ walk(entry);
526
+ }
527
+ }
528
+ };
529
+ walk(output);
530
+ return output;
531
+ }
532
+ function buildAjvSchema(schema, strictMode) {
533
+ return strictMode ? applyStrictAdditionalProperties(schema) : schema;
534
+ }
456
535
  function formatErrors(errors) {
457
536
  return (errors ?? []).map((error) => {
458
537
  const location = error.instancePath || "/";
459
538
  return `${location} ${error.message ?? "failed validation"}`.trim();
460
539
  });
461
540
  }
541
+ function analyzeDrift(ajv, schema, body, options) {
542
+ const normalizedSchema = buildAjvSchema(schema, options.strictMode);
543
+ const validate = ajv.compile(normalizedSchema);
544
+ const valid = validate(body);
545
+ if (valid) {
546
+ return [];
547
+ }
548
+ const filteredErrors = (validate.errors ?? []).filter(
549
+ (entry) => !options.ignorePaths.some((rule) => pathMatchesIgnoreRule(entry.instancePath || "/", rule))
550
+ );
551
+ return formatErrors(filteredErrors);
552
+ }
462
553
  var DriftDetector = class {
463
- constructor(logger) {
554
+ constructor(logger, options) {
464
555
  this.logger = logger;
465
556
  addFormats(this.ajv);
557
+ this.strictMode = options?.strictMode ?? false;
558
+ this.ignorePaths = options?.ignorePaths ?? [];
466
559
  }
467
560
  ajv = new Ajv({ allErrors: true, strict: false });
468
- validate(method, path6, statusCode, schema, body) {
561
+ strictMode;
562
+ ignorePaths;
563
+ validate(method, path7, statusCode, schema, body) {
469
564
  if (!schema || body === void 0 || body === null) {
470
565
  return null;
471
566
  }
472
- const validate = this.ajv.compile(schema);
473
- const valid = validate(body);
474
- if (valid) {
567
+ const errors = analyzeDrift(this.ajv, schema, body, {
568
+ strictMode: this.strictMode,
569
+ ignorePaths: this.ignorePaths
570
+ });
571
+ if (!errors.length) {
475
572
  return null;
476
573
  }
477
574
  const issue = {
478
575
  method: method.toUpperCase(),
479
- path: path6,
576
+ path: path7,
480
577
  statusCode,
481
- message: `Drift detected for ${method.toUpperCase()} ${path6} (${statusCode})`,
482
- errors: formatErrors(validate.errors)
578
+ message: `Drift detected for ${method.toUpperCase()} ${path7} (${statusCode})`,
579
+ errors
483
580
  };
484
581
  const errorLines = issue.errors.map((entry) => ` \u2022 ${entry}`).join("\n");
485
582
  this.logger.error([
@@ -635,7 +732,7 @@ async function loadOpenApiDocument(specPath) {
635
732
  }
636
733
 
637
734
  // src/state-store.ts
638
- import { mkdir as mkdir3, readFile, writeFile as writeFile3 } from "fs/promises";
735
+ import { mkdir as mkdir3, readFile as readFile2, writeFile as writeFile3 } from "fs/promises";
639
736
  import path4 from "path";
640
737
  function normalizeDatabase(input) {
641
738
  return {
@@ -676,7 +773,7 @@ var JsonStateStore = class {
676
773
  }
677
774
  }
678
775
  async read() {
679
- const raw = await readFile(this.filePath, "utf8");
776
+ const raw = await readFile2(this.filePath, "utf8");
680
777
  return normalizeDatabase(JSON.parse(raw));
681
778
  }
682
779
  async write(database) {
@@ -692,8 +789,8 @@ var JsonStateStore = class {
692
789
  };
693
790
 
694
791
  // src/proxy.ts
695
- async function proxyRequest(targetBaseUrl, path6, init) {
696
- const response = await fetch(new URL(path6, targetBaseUrl), init);
792
+ async function proxyRequest(targetBaseUrl, path7, init) {
793
+ const response = await fetch(new URL(path7, targetBaseUrl), init);
697
794
  const contentType = readContentType(response.headers);
698
795
  let body;
699
796
  let rawBody;
@@ -714,6 +811,56 @@ async function proxyRequest(targetBaseUrl, path6, init) {
714
811
  };
715
812
  }
716
813
 
814
+ // src/drift-reporter.ts
815
+ import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
816
+ import path5 from "path";
817
+ function escapeXml(value) {
818
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
819
+ }
820
+ function toJunit(issues) {
821
+ const testCases = issues.map((issue, index) => {
822
+ const name = `${issue.method} ${issue.path} (${issue.statusCode}) #${index + 1}`;
823
+ const failure = escapeXml(issue.errors.join("; "));
824
+ return ` <testcase classname="contract-drift-detection" name="${escapeXml(name)}">
825
+ <failure message="drift detected">${failure}</failure>
826
+ </testcase>`;
827
+ }).join("\n");
828
+ return [
829
+ '<?xml version="1.0" encoding="UTF-8"?>',
830
+ `<testsuite name="contract-drift-detection" tests="${issues.length}" failures="${issues.length}">`,
831
+ testCases,
832
+ "</testsuite>",
833
+ ""
834
+ ].join("\n");
835
+ }
836
+ function toJson(issues) {
837
+ const report = {
838
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
839
+ summary: {
840
+ total: issues.length
841
+ },
842
+ issues
843
+ };
844
+ return `${JSON.stringify(report, null, 2)}
845
+ `;
846
+ }
847
+ function defaultReportPath(config) {
848
+ if (config.reportFile) {
849
+ return config.reportFile;
850
+ }
851
+ return config.reporter === "junit" ? "cdd-drift-report.xml" : "cdd-drift-report.json";
852
+ }
853
+ async function writeDriftReport(config, issues) {
854
+ if (config.reporter === "pretty") {
855
+ return null;
856
+ }
857
+ const filePath = defaultReportPath(config);
858
+ const content = config.reporter === "junit" ? toJunit(issues) : toJson(issues);
859
+ await mkdir4(path5.dirname(filePath), { recursive: true });
860
+ await writeFile4(filePath, content, "utf8");
861
+ return filePath;
862
+ }
863
+
717
864
  // src/server.ts
718
865
  function toProxyHeaders(inputHeaders) {
719
866
  const passthrough = ["host", "content-length", "connection"];
@@ -886,7 +1033,7 @@ async function handleMockRoute(store, route, request) {
886
1033
  }
887
1034
  });
888
1035
  }
889
- async function handleProxyRoute(config, route, detector, request) {
1036
+ async function handleProxyRoute(config, request) {
890
1037
  const targetBaseUrl = config.driftCheckTarget;
891
1038
  if (!targetBaseUrl) {
892
1039
  throw new Error("Proxy target is not configured");
@@ -900,15 +1047,6 @@ async function handleProxyRoute(config, route, detector, request) {
900
1047
  headers,
901
1048
  body: toProxyBody(request)
902
1049
  });
903
- if (result.statusCode >= 200 && result.statusCode < 300) {
904
- detector.validate(
905
- route.method,
906
- route.path,
907
- result.statusCode,
908
- route.successResponse?.schema,
909
- result.body
910
- );
911
- }
912
1050
  return {
913
1051
  statusCode: result.statusCode,
914
1052
  headers: Object.fromEntries(result.headers.entries()),
@@ -917,7 +1055,11 @@ async function handleProxyRoute(config, route, detector, request) {
917
1055
  }
918
1056
  async function registerRoutes(app, document, config, store) {
919
1057
  const routes = buildRouteContexts(document);
920
- const detector = new DriftDetector(app.log);
1058
+ const detector = new DriftDetector(app.log, {
1059
+ strictMode: config.strictDrift,
1060
+ ignorePaths: config.driftIgnorePaths
1061
+ });
1062
+ const driftIssues = [];
921
1063
  for (const route of routes) {
922
1064
  app.route({
923
1065
  method: route.method.toUpperCase(),
@@ -925,7 +1067,21 @@ async function registerRoutes(app, document, config, store) {
925
1067
  handler: async (request, reply) => {
926
1068
  if (config.driftCheckTarget) {
927
1069
  try {
928
- const proxied = await handleProxyRoute(config, route, detector, request);
1070
+ const proxied = await handleProxyRoute(config, request);
1071
+ if (proxied.statusCode >= 200 && proxied.statusCode < 300 && proxied.body !== void 0) {
1072
+ const issue = detector.validate(
1073
+ route.method,
1074
+ route.path,
1075
+ proxied.statusCode,
1076
+ route.successResponse?.schema,
1077
+ proxied.body
1078
+ );
1079
+ if (issue) {
1080
+ driftIssues.push(issue);
1081
+ await writeDriftReport(config, driftIssues);
1082
+ await config.onDriftDetected?.(issue);
1083
+ }
1084
+ }
929
1085
  for (const [headerName, headerValue] of Object.entries(proxied.headers)) {
930
1086
  if (headerName.toLowerCase() === "content-length") {
931
1087
  continue;
@@ -955,6 +1111,10 @@ async function registerRoutes(app, document, config, store) {
955
1111
  hasDsl: Boolean(route.operation["x-mock-state"])
956
1112
  }))
957
1113
  );
1114
+ app.get("/__drift", async () => ({
1115
+ total: driftIssues.length,
1116
+ issues: driftIssues
1117
+ }));
958
1118
  }
959
1119
  async function createServer(config) {
960
1120
  const app = Fastify({
@@ -970,7 +1130,7 @@ async function createServer(config) {
970
1130
  });
971
1131
  const document = await loadOpenApiDocument(config.specPath);
972
1132
  const seedCollections = inferSeedCollections(document);
973
- const dbPath = resolveFile(path5.dirname(config.specPath), config.dbPath);
1133
+ const dbPath = resolveFile(path6.dirname(config.specPath), config.dbPath);
974
1134
  const store = new JsonStateStore(dbPath);
975
1135
  await store.initialize(seedCollections);
976
1136
  app.get("/__health", async () => ({ status: "ok" }));
@@ -986,8 +1146,12 @@ function renderStartupBanner(config) {
986
1146
  `- Spec: ${config.specPath}`,
987
1147
  `- DB: ${config.dbPath}`,
988
1148
  `- Mode: ${config.driftCheckTarget ? `proxy + drift-check (${config.driftCheckTarget})` : "stateful mock"}`,
1149
+ `- Drift policy: strict=${config.strictDrift ? "on" : "off"}, fail-on-drift=${config.failOnDrift ? "on" : "off"}`,
1150
+ `- Reporter: ${config.reporter}${config.reportFile ? ` (${config.reportFile})` : ""}`,
1151
+ `- Ignore rules: ${config.driftIgnorePaths.length ? config.driftIgnorePaths.join(", ") : "none"}`,
989
1152
  "- Health: GET /__health",
990
- "- Routes: GET /__routes"
1153
+ "- Routes: GET /__routes",
1154
+ "- Drift: GET /__drift"
991
1155
  ];
992
1156
  return `${lines.join("\n")}
993
1157
  `;
@@ -998,10 +1162,21 @@ async function main() {
998
1162
  const initCommand = cli.commands.find((command) => command.name() === "init");
999
1163
  const quickstartCommand = cli.commands.find((command) => command.name() === "quickstart");
1000
1164
  const startServer = async (rawOptions) => {
1001
- const config = await resolveServeConfig(process.cwd(), rawOptions);
1165
+ let hasFailedOnDrift = false;
1166
+ const config = await resolveServeConfig(process2.cwd(), rawOptions);
1167
+ config.onDriftDetected = async () => {
1168
+ if (!config.failOnDrift || hasFailedOnDrift) {
1169
+ return;
1170
+ }
1171
+ hasFailedOnDrift = true;
1172
+ process2.stderr.write("Failing process because drift was detected (--fail-on-drift enabled).\n");
1173
+ setTimeout(() => {
1174
+ process2.exit(1);
1175
+ }, 25);
1176
+ };
1002
1177
  const server = await createServer(config);
1003
1178
  await server.listen({ port: config.port, host: config.host });
1004
- process.stdout.write(renderStartupBanner(config));
1179
+ process2.stdout.write(renderStartupBanner(config));
1005
1180
  };
1006
1181
  serveCommand?.action(async function() {
1007
1182
  await startServer(this.opts());
@@ -1016,19 +1191,19 @@ async function main() {
1016
1191
  });
1017
1192
  initCommand?.action(async function() {
1018
1193
  const options = this.opts();
1019
- const targetPath = await writeStarterConfig(process.cwd(), {
1194
+ const targetPath = await writeStarterConfig(process2.cwd(), {
1020
1195
  spec: String(options.spec),
1021
1196
  db: String(options.db),
1022
1197
  host: String(options.host),
1023
1198
  port: Number(options.port),
1024
1199
  template: options.template === "none" ? "none" : "rest-crud"
1025
1200
  });
1026
- process.stdout.write(`Created ${targetPath}
1201
+ process2.stdout.write(`Created ${targetPath}
1027
1202
  `);
1028
1203
  });
1029
1204
  quickstartCommand?.action(async function() {
1030
1205
  const options = this.opts();
1031
- const cwd = process.cwd();
1206
+ const cwd = process2.cwd();
1032
1207
  const specPath = String(options.spec ?? "openapi.yaml");
1033
1208
  await writeStarterConfig(cwd, {
1034
1209
  spec: specPath,
@@ -1047,22 +1222,22 @@ async function main() {
1047
1222
  });
1048
1223
  const server = await createServer(config);
1049
1224
  await server.listen({ port: config.port, host: config.host });
1050
- process.stdout.write(renderStartupBanner(config));
1225
+ process2.stdout.write(renderStartupBanner(config));
1051
1226
  });
1052
- await cli.parseAsync(process.argv);
1227
+ await cli.parseAsync(process2.argv);
1053
1228
  }
1054
1229
  await main().catch((error) => {
1055
1230
  if (error instanceof Error) {
1056
- process.stderr.write(`Error: ${error.message}
1231
+ process2.stderr.write(`Error: ${error.message}
1057
1232
  `);
1058
- if (process.env.CDD_SHOW_STACK === "1") {
1059
- process.stderr.write(`${error.stack}
1233
+ if (process2.env.CDD_SHOW_STACK === "1") {
1234
+ process2.stderr.write(`${error.stack}
1060
1235
  `);
1061
1236
  }
1062
1237
  } else {
1063
- process.stderr.write(`${String(error)}
1238
+ process2.stderr.write(`${String(error)}
1064
1239
  `);
1065
1240
  }
1066
- process.exitCode = 1;
1241
+ process2.exitCode = 1;
1067
1242
  });
1068
1243
  //# sourceMappingURL=cli.js.map