@subhamxcod/apiguard 1.0.3 → 1.1.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/README.md +207 -61
- package/dist/{chunk-RQVNHPHC.js → chunk-EJQ5XOY6.js} +1012 -16
- package/dist/cli/main.cjs +1127 -26
- package/dist/cli/main.js +118 -9
- package/dist/index.cjs +171 -168
- package/dist/index.d.cts +54 -2
- package/dist/index.d.ts +54 -2
- package/dist/index.js +9 -1
- package/package.json +20 -1
package/dist/cli/main.cjs
CHANGED
|
@@ -39,7 +39,7 @@ __export(markdown_exports, {
|
|
|
39
39
|
function truncate(s, max = 140) {
|
|
40
40
|
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
41
41
|
}
|
|
42
|
-
function
|
|
42
|
+
function endpointOf2(change) {
|
|
43
43
|
const loc = change.location;
|
|
44
44
|
if (loc.method) return `${loc.method} ${loc.path}`;
|
|
45
45
|
if (loc.path && loc.path !== "/") return loc.path;
|
|
@@ -57,7 +57,7 @@ function renderMarkdown(report) {
|
|
|
57
57
|
lines.push("|---|---|---|---|---|");
|
|
58
58
|
for (const c of report.changes) {
|
|
59
59
|
lines.push(
|
|
60
|
-
`| \`${c.ruleId}\` | ${c.severity} | ${
|
|
60
|
+
`| \`${c.ruleId}\` | ${c.severity} | ${endpointOf2(c)} | ${truncate(c.message.replace(/\|/g, "\\|"))} | ${truncate(c.suggestion.replace(/\|/g, "\\|"))} |`
|
|
61
61
|
);
|
|
62
62
|
}
|
|
63
63
|
for (const sev of ["error", "warning", "info"]) {
|
|
@@ -68,7 +68,7 @@ function renderMarkdown(report) {
|
|
|
68
68
|
lines.push(`<summary>${LABEL[sev]} (${group.length})</summary>`);
|
|
69
69
|
lines.push("");
|
|
70
70
|
for (const c of group) {
|
|
71
|
-
lines.push(`#### \`${c.ruleId}\` \u2014 ${
|
|
71
|
+
lines.push(`#### \`${c.ruleId}\` \u2014 ${endpointOf2(c)}`);
|
|
72
72
|
lines.push("");
|
|
73
73
|
lines.push(c.message);
|
|
74
74
|
lines.push("");
|
|
@@ -133,7 +133,7 @@ var ignoreSchema = import_zod.z.strictObject({
|
|
|
133
133
|
"show-ignored": import_zod.z.boolean().optional().default(false)
|
|
134
134
|
});
|
|
135
135
|
var outputSchema = import_zod.z.strictObject({
|
|
136
|
-
format: import_zod.z.enum(["terminal", "json", "markdown"]).optional().default("terminal"),
|
|
136
|
+
format: import_zod.z.enum(["terminal", "json", "markdown", "html", "sarif"]).optional().default("terminal"),
|
|
137
137
|
"include-non-breaking": import_zod.z.boolean().optional().default(false),
|
|
138
138
|
color: import_zod.z.enum(["auto", "always", "never"]).optional().default("auto")
|
|
139
139
|
});
|
|
@@ -141,13 +141,17 @@ var versioningSchema = import_zod.z.strictObject({
|
|
|
141
141
|
suggest: import_zod.z.boolean().optional().default(true)
|
|
142
142
|
});
|
|
143
143
|
var loaderSchema = import_zod.z.strictObject({
|
|
144
|
-
"allow-remote-refs": import_zod.z.boolean().optional().default(false)
|
|
144
|
+
"allow-remote-refs": import_zod.z.boolean().optional().default(false),
|
|
145
|
+
"allow-external-files": import_zod.z.boolean().optional().default(false),
|
|
146
|
+
"allow-private-ips": import_zod.z.boolean().optional().default(false),
|
|
147
|
+
headers: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional().default({})
|
|
145
148
|
});
|
|
146
149
|
var githubSchema = import_zod.z.strictObject({
|
|
147
150
|
comment: import_zod.z.boolean().optional().default(true),
|
|
148
151
|
"check-run": import_zod.z.boolean().optional().default(true),
|
|
149
152
|
"update-existing-comment": import_zod.z.boolean().optional().default(true)
|
|
150
153
|
});
|
|
154
|
+
var profileSchema = import_zod.z.enum(["default", "strict-sdk", "loose-web"]).default("default");
|
|
151
155
|
var configSchema = import_zod.z.strictObject({
|
|
152
156
|
schemaVersion: import_zod.z.literal(1, {
|
|
153
157
|
error: "schemaVersion must be 1 (other versions are rejected; see docs/configuration.md)"
|
|
@@ -157,6 +161,7 @@ var configSchema = import_zod.z.strictObject({
|
|
|
157
161
|
output: outputSchema.optional(),
|
|
158
162
|
"fail-on": import_zod.z.enum(["error", "warning", "never"]).optional(),
|
|
159
163
|
versioning: versioningSchema.optional(),
|
|
164
|
+
profile: profileSchema.optional(),
|
|
160
165
|
loader: loaderSchema.optional(),
|
|
161
166
|
github: githubSchema.optional()
|
|
162
167
|
});
|
|
@@ -180,7 +185,13 @@ var DEFAULT_CONFIG = {
|
|
|
180
185
|
failOn: "error",
|
|
181
186
|
// §13 golden output shows the bump line "only with --suggest-version"; config may opt in.
|
|
182
187
|
versioning: { suggest: false },
|
|
183
|
-
|
|
188
|
+
profile: "default",
|
|
189
|
+
loader: {
|
|
190
|
+
allowRemoteRefs: false,
|
|
191
|
+
allowExternalFiles: false,
|
|
192
|
+
allowPrivateIps: false,
|
|
193
|
+
headers: {}
|
|
194
|
+
},
|
|
184
195
|
github: { comment: true, checkRun: true, updateExistingComment: true }
|
|
185
196
|
};
|
|
186
197
|
|
|
@@ -273,7 +284,13 @@ function toResolved(parsed) {
|
|
|
273
284
|
},
|
|
274
285
|
failOn: parsed["fail-on"] ?? "error",
|
|
275
286
|
versioning: { suggest: parsed.versioning?.suggest ?? true },
|
|
276
|
-
|
|
287
|
+
profile: parsed.profile ?? "default",
|
|
288
|
+
loader: {
|
|
289
|
+
allowRemoteRefs: parsed.loader?.["allow-remote-refs"] ?? false,
|
|
290
|
+
allowExternalFiles: parsed.loader?.["allow-external-files"] ?? false,
|
|
291
|
+
allowPrivateIps: parsed.loader?.["allow-private-ips"] ?? false,
|
|
292
|
+
headers: parsed.loader?.headers ?? {}
|
|
293
|
+
},
|
|
277
294
|
github: {
|
|
278
295
|
comment: parsed.github?.comment ?? true,
|
|
279
296
|
checkRun: parsed.github?.["check-run"] ?? true,
|
|
@@ -353,7 +370,8 @@ function mergeWithFlags(base, flags) {
|
|
|
353
370
|
failOn: flags.failOn ?? base.failOn,
|
|
354
371
|
versioning: {
|
|
355
372
|
suggest: flags.suggestVersion ?? base.versioning.suggest
|
|
356
|
-
}
|
|
373
|
+
},
|
|
374
|
+
profile: flags.profile ?? base.profile
|
|
357
375
|
};
|
|
358
376
|
}
|
|
359
377
|
|
|
@@ -385,10 +403,13 @@ function renderError(err, verbose = false) {
|
|
|
385
403
|
|
|
386
404
|
// src/cli/diff-command.ts
|
|
387
405
|
var import_node_fs3 = __toESM(require("fs"), 1);
|
|
406
|
+
var import_node_child_process2 = require("child_process");
|
|
388
407
|
|
|
389
408
|
// src/loaders/spec-loader.ts
|
|
390
409
|
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
391
410
|
var import_node_path2 = __toESM(require("path"), 1);
|
|
411
|
+
var import_node_os = __toESM(require("os"), 1);
|
|
412
|
+
var import_node_child_process = require("child_process");
|
|
392
413
|
var import_swagger_parser = __toESM(require("@apidevtools/swagger-parser"), 1);
|
|
393
414
|
var import_yaml2 = require("yaml");
|
|
394
415
|
|
|
@@ -487,6 +508,87 @@ function toNormalizedSpec(loaded, normalized) {
|
|
|
487
508
|
}
|
|
488
509
|
|
|
489
510
|
// src/loaders/spec-loader.ts
|
|
511
|
+
function isPrivateHost(hostname) {
|
|
512
|
+
const h = hostname.toLowerCase().trim();
|
|
513
|
+
if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0" || h === "::") {
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
const ipv4Match = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
517
|
+
if (ipv4Match) {
|
|
518
|
+
const [b0, b1] = [Number(ipv4Match[1]), Number(ipv4Match[2])];
|
|
519
|
+
if (b0 === 127) return true;
|
|
520
|
+
if (b0 === 10) return true;
|
|
521
|
+
if (b0 === 172 && b1 >= 16 && b1 <= 31) return true;
|
|
522
|
+
if (b0 === 192 && b1 === 168) return true;
|
|
523
|
+
if (b0 === 169 && b1 === 254) return true;
|
|
524
|
+
if (b0 === 0) return true;
|
|
525
|
+
}
|
|
526
|
+
if (h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80:")) {
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
async function fetchRemoteSpec(urlStr, options = {}) {
|
|
532
|
+
let url;
|
|
533
|
+
try {
|
|
534
|
+
url = new URL(urlStr);
|
|
535
|
+
} catch (e) {
|
|
536
|
+
throw new SpecLoadError(`${urlStr}: invalid URL: ${errorMessage(e)}`);
|
|
537
|
+
}
|
|
538
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
539
|
+
throw new SpecLoadError(
|
|
540
|
+
`${urlStr}: unsupported protocol "${url.protocol}" (expected http: or https:)`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (!options.allowPrivateIps && isPrivateHost(url.hostname)) {
|
|
544
|
+
throw new SpecLoadError(
|
|
545
|
+
`${urlStr}: remote spec blocked: private or loopback host "${url.hostname}" denied by SSRF protection. Set loader.allow-private-ips: true in apiguard.yaml or CLI to opt in.`
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
let text;
|
|
549
|
+
try {
|
|
550
|
+
const res = await fetch(urlStr, {
|
|
551
|
+
headers: options.headers ?? {},
|
|
552
|
+
signal: AbortSignal.timeout(15e3)
|
|
553
|
+
});
|
|
554
|
+
if (!res.ok) {
|
|
555
|
+
throw new SpecLoadError(
|
|
556
|
+
`${urlStr}: failed to fetch spec (HTTP ${res.status} ${res.statusText})`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
text = await res.text();
|
|
560
|
+
} catch (e) {
|
|
561
|
+
if (e instanceof SpecLoadError) throw e;
|
|
562
|
+
throw new SpecLoadError(`${urlStr}: network error fetching spec: ${errorMessage(e)}`);
|
|
563
|
+
}
|
|
564
|
+
const tmpDir = import_node_fs2.default.mkdtempSync(import_node_path2.default.join(import_node_os.default.tmpdir(), "apiguard-remote-"));
|
|
565
|
+
const ext = url.pathname.toLowerCase().endsWith(".json") ? ".json" : ".yaml";
|
|
566
|
+
const localPath = import_node_path2.default.join(tmpDir, "spec" + ext);
|
|
567
|
+
import_node_fs2.default.writeFileSync(localPath, text, "utf8");
|
|
568
|
+
return { localPath, text };
|
|
569
|
+
}
|
|
570
|
+
function fetchGitSpec(gitUri, cwd = process.cwd()) {
|
|
571
|
+
const stripped = gitUri.replace(/^git:\/\//i, "").replace(/^git:/i, "");
|
|
572
|
+
const colonIdx = stripped.indexOf(":");
|
|
573
|
+
if (colonIdx === -1) {
|
|
574
|
+
throw new SpecLoadError(`${gitUri}: invalid git spec URI (expected git://<rev>:<path>)`);
|
|
575
|
+
}
|
|
576
|
+
const rev = stripped.slice(0, colonIdx);
|
|
577
|
+
const relPath = stripped.slice(colonIdx + 1);
|
|
578
|
+
let text;
|
|
579
|
+
try {
|
|
580
|
+
text = (0, import_node_child_process.execFileSync)("git", ["show", `${rev}:${relPath}`], { cwd, encoding: "utf8" });
|
|
581
|
+
} catch (e) {
|
|
582
|
+
throw new SpecLoadError(
|
|
583
|
+
`${gitUri}: failed to retrieve spec from git (${rev}:${relPath}): ${errorMessage(e)}`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
const tmpDir = import_node_fs2.default.mkdtempSync(import_node_path2.default.join(import_node_os.default.tmpdir(), "apiguard-git-"));
|
|
587
|
+
const ext = relPath.toLowerCase().endsWith(".json") ? ".json" : ".yaml";
|
|
588
|
+
const localPath = import_node_path2.default.join(tmpDir, import_node_path2.default.basename(relPath) || "spec" + ext);
|
|
589
|
+
import_node_fs2.default.writeFileSync(localPath, text, "utf8");
|
|
590
|
+
return { localPath, text };
|
|
591
|
+
}
|
|
490
592
|
function parseSpecText(text, source) {
|
|
491
593
|
let parsed;
|
|
492
594
|
const isJsonByExt = source.trimEnd().toLowerCase().endsWith(".json");
|
|
@@ -564,12 +666,23 @@ function findExternalFileRefs(document, specDir) {
|
|
|
564
666
|
return found;
|
|
565
667
|
}
|
|
566
668
|
async function loadSpec(filePath, options = {}) {
|
|
567
|
-
|
|
669
|
+
let abs;
|
|
568
670
|
let text;
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
671
|
+
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
|
|
672
|
+
const fetched = await fetchRemoteSpec(filePath, options.loader);
|
|
673
|
+
abs = fetched.localPath;
|
|
674
|
+
text = fetched.text;
|
|
675
|
+
} else if (filePath.startsWith("git://") || filePath.startsWith("git:")) {
|
|
676
|
+
const fetched = fetchGitSpec(filePath, options.cwd);
|
|
677
|
+
abs = fetched.localPath;
|
|
678
|
+
text = fetched.text;
|
|
679
|
+
} else {
|
|
680
|
+
abs = import_node_path2.default.resolve(filePath);
|
|
681
|
+
try {
|
|
682
|
+
text = import_node_fs2.default.readFileSync(abs, "utf8");
|
|
683
|
+
} catch (e) {
|
|
684
|
+
throw new SpecLoadError(`${filePath}: cannot read file: ${errorMessage(e)}`);
|
|
685
|
+
}
|
|
573
686
|
}
|
|
574
687
|
if (text.trim() === "") {
|
|
575
688
|
throw new SpecLoadError(`${filePath}: file is empty \u2014 a spec must contain an OpenAPI document`);
|
|
@@ -614,7 +727,8 @@ async function loadSpec(filePath, options = {}) {
|
|
|
614
727
|
source: filePath,
|
|
615
728
|
openapiVersion: openapi,
|
|
616
729
|
title,
|
|
617
|
-
sha256: ""
|
|
730
|
+
sha256: "",
|
|
731
|
+
rawText: text
|
|
618
732
|
};
|
|
619
733
|
return toNormalizedSpec(loaded, normalizeDocument(document));
|
|
620
734
|
}
|
|
@@ -1471,6 +1585,9 @@ function getRule(id) {
|
|
|
1471
1585
|
if (!rule) throw new Error(`internal error: unknown rule id "${id}"`);
|
|
1472
1586
|
return rule;
|
|
1473
1587
|
}
|
|
1588
|
+
function getAllRules() {
|
|
1589
|
+
return [...RULES];
|
|
1590
|
+
}
|
|
1474
1591
|
function constraintText(c) {
|
|
1475
1592
|
const name = nameOf(c);
|
|
1476
1593
|
const oldV = c.oldValue === void 0 ? void 0 : JSON.stringify(c.oldValue);
|
|
@@ -1539,7 +1656,21 @@ function classify(ruleId, context, options, doctrine) {
|
|
|
1539
1656
|
"request-body-removed",
|
|
1540
1657
|
"removed-parameter"
|
|
1541
1658
|
]);
|
|
1542
|
-
|
|
1659
|
+
let baseSeverity = DOCTRINE_RULES.has(ruleId) ? resolveContextSeverity(ruleId, context, doctrine ?? {}) : rule.defaultSeverity;
|
|
1660
|
+
if (override === void 0 && options.profile) {
|
|
1661
|
+
if (options.profile === "strict-sdk") {
|
|
1662
|
+
if (ruleId === "enum-value-added") {
|
|
1663
|
+
baseSeverity = "error";
|
|
1664
|
+
} else if (ruleId === "anyOf-oneOf-member-added") {
|
|
1665
|
+
baseSeverity = "warning";
|
|
1666
|
+
}
|
|
1667
|
+
} else if (options.profile === "loose-web") {
|
|
1668
|
+
if (ruleId === "enum-value-added") {
|
|
1669
|
+
baseSeverity = "info";
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
const severity = override ?? baseSeverity;
|
|
1543
1674
|
return { severity, kind: rule.defaultKind };
|
|
1544
1675
|
}
|
|
1545
1676
|
|
|
@@ -2413,7 +2544,7 @@ function emit(ctx, opts) {
|
|
|
2413
2544
|
const resolved = classify(
|
|
2414
2545
|
opts.ruleId,
|
|
2415
2546
|
opts.location.context,
|
|
2416
|
-
{ rules: ctx.config.rules },
|
|
2547
|
+
{ rules: ctx.config.rules, profile: ctx.config.profile },
|
|
2417
2548
|
opts.doctrine
|
|
2418
2549
|
);
|
|
2419
2550
|
const rule = getRule(opts.ruleId);
|
|
@@ -3831,6 +3962,71 @@ function satisfiesAny(oldReq, newReqs) {
|
|
|
3831
3962
|
});
|
|
3832
3963
|
}
|
|
3833
3964
|
|
|
3965
|
+
// src/utils/source-map.ts
|
|
3966
|
+
var import_yaml3 = require("yaml");
|
|
3967
|
+
var SpecSourceMap = class {
|
|
3968
|
+
doc;
|
|
3969
|
+
lineCounter;
|
|
3970
|
+
constructor(rawText) {
|
|
3971
|
+
this.lineCounter = new import_yaml3.LineCounter();
|
|
3972
|
+
this.doc = (0, import_yaml3.parseDocument)(rawText, { lineCounter: this.lineCounter });
|
|
3973
|
+
}
|
|
3974
|
+
/** Resolve 1-based line & column for a JSON pointer. */
|
|
3975
|
+
findPosition(pointer) {
|
|
3976
|
+
if (!pointer || pointer === "/") {
|
|
3977
|
+
return { line: 1, column: 1 };
|
|
3978
|
+
}
|
|
3979
|
+
const tokens = pointer.split("/").filter(Boolean).map((t) => t.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
3980
|
+
for (let i = tokens.length; i >= 1; i--) {
|
|
3981
|
+
const subTokens = tokens.slice(0, i);
|
|
3982
|
+
try {
|
|
3983
|
+
const node = this.doc.getIn(subTokens, true);
|
|
3984
|
+
if (node && Array.isArray(node.range) && typeof node.range[0] === "number") {
|
|
3985
|
+
const pos = this.lineCounter.linePos(node.range[0]);
|
|
3986
|
+
return { line: pos.line, column: pos.col };
|
|
3987
|
+
}
|
|
3988
|
+
} catch {
|
|
3989
|
+
}
|
|
3990
|
+
}
|
|
3991
|
+
return void 0;
|
|
3992
|
+
}
|
|
3993
|
+
};
|
|
3994
|
+
function attachSourceLocations(changes, baselineSpec, currentSpec) {
|
|
3995
|
+
let currentMap;
|
|
3996
|
+
let baselineMap;
|
|
3997
|
+
if (currentSpec.rawText) {
|
|
3998
|
+
try {
|
|
3999
|
+
currentMap = new SpecSourceMap(currentSpec.rawText);
|
|
4000
|
+
} catch {
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
if (baselineSpec.rawText) {
|
|
4004
|
+
try {
|
|
4005
|
+
baselineMap = new SpecSourceMap(baselineSpec.rawText);
|
|
4006
|
+
} catch {
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
for (const c of changes) {
|
|
4010
|
+
if (currentMap && c.location.pointerNew) {
|
|
4011
|
+
const pos = currentMap.findPosition(c.location.pointerNew);
|
|
4012
|
+
if (pos) {
|
|
4013
|
+
c.location.file = currentSpec.source;
|
|
4014
|
+
c.location.line = pos.line;
|
|
4015
|
+
c.location.column = pos.column;
|
|
4016
|
+
continue;
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
if (baselineMap && c.location.pointerOld) {
|
|
4020
|
+
const pos = baselineMap.findPosition(c.location.pointerOld);
|
|
4021
|
+
if (pos) {
|
|
4022
|
+
c.location.file = baselineSpec.source;
|
|
4023
|
+
c.location.line = pos.line;
|
|
4024
|
+
c.location.column = pos.column;
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
|
|
3834
4030
|
// src/core/ignore/ignore.ts
|
|
3835
4031
|
var import_micromatch = __toESM(require("micromatch"), 1);
|
|
3836
4032
|
function changePath(change) {
|
|
@@ -3943,8 +4139,8 @@ async function analyze(baselinePath, currentPath, options = {}) {
|
|
|
3943
4139
|
const loaded = options.config ? { config: options.config, sourcePath: "", warnings: [] } : discoverAndLoadConfig({ explicit: options.configPath });
|
|
3944
4140
|
const config = loaded.config;
|
|
3945
4141
|
const [baseline, current] = await Promise.all([
|
|
3946
|
-
loadSpec(baselinePath, { loader:
|
|
3947
|
-
loadSpec(currentPath, { loader:
|
|
4142
|
+
loadSpec(baselinePath, { loader: config.loader }),
|
|
4143
|
+
loadSpec(currentPath, { loader: config.loader })
|
|
3948
4144
|
]);
|
|
3949
4145
|
structuralDeref(baseline.normalized);
|
|
3950
4146
|
structuralDeref(current.normalized);
|
|
@@ -3958,6 +4154,7 @@ async function analyze(baselinePath, currentPath, options = {}) {
|
|
|
3958
4154
|
};
|
|
3959
4155
|
runStructuralDiff(ctx);
|
|
3960
4156
|
const deduped = dedupeChanges(sortChanges([...ctx.candidates]));
|
|
4157
|
+
attachSourceLocations(deduped, baseline, current);
|
|
3961
4158
|
const { kept, ignored } = applyIgnores(deduped, config);
|
|
3962
4159
|
const summary = summarize(kept, ignored.length);
|
|
3963
4160
|
const semver = suggestVersion(kept);
|
|
@@ -4060,18 +4257,627 @@ function renderJson(report) {
|
|
|
4060
4257
|
return JSON.stringify(report, null, 2);
|
|
4061
4258
|
}
|
|
4062
4259
|
|
|
4260
|
+
// src/reporters/html.ts
|
|
4261
|
+
function esc(s) {
|
|
4262
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4263
|
+
}
|
|
4264
|
+
function endpointOf(change) {
|
|
4265
|
+
const loc = change.location;
|
|
4266
|
+
if (loc.method && loc.path) return `${loc.method} ${loc.path}`;
|
|
4267
|
+
if (loc.path && loc.path !== "/") return loc.path;
|
|
4268
|
+
if (loc.context === "webhook") return `webhook ${loc.componentName ?? ""}`.trim();
|
|
4269
|
+
if (loc.componentName) return `component ${loc.componentName}`;
|
|
4270
|
+
return "API Root";
|
|
4271
|
+
}
|
|
4272
|
+
function valueRepr(v) {
|
|
4273
|
+
if (v === void 0) return "absent";
|
|
4274
|
+
if (v === null) return "null";
|
|
4275
|
+
if (typeof v === "string") return v;
|
|
4276
|
+
return JSON.stringify(v);
|
|
4277
|
+
}
|
|
4278
|
+
function renderHtml(report) {
|
|
4279
|
+
const breaking = report.summary.bySeverity.error;
|
|
4280
|
+
const warnings = report.summary.bySeverity.warning;
|
|
4281
|
+
const infos = report.summary.bySeverity.info;
|
|
4282
|
+
const total = report.summary.total;
|
|
4283
|
+
const isPass = breaking === 0;
|
|
4284
|
+
return `<!DOCTYPE html>
|
|
4285
|
+
<html lang="en">
|
|
4286
|
+
<head>
|
|
4287
|
+
<meta charset="utf-8">
|
|
4288
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4289
|
+
<title>API Guard Report - ${isPass ? "Passed" : "Breaking Changes Found"}</title>
|
|
4290
|
+
<style>
|
|
4291
|
+
/* Hallmark stamp: modern-minimal developer tool contract dashboard */
|
|
4292
|
+
:root {
|
|
4293
|
+
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
4294
|
+
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
4295
|
+
--bg-page: #f8fafc;
|
|
4296
|
+
--bg-card: #ffffff;
|
|
4297
|
+
--bg-input: #ffffff;
|
|
4298
|
+
--border-subtle: #e2e8f0;
|
|
4299
|
+
--border-strong: #cbd5e1;
|
|
4300
|
+
--text-main: #0f172a;
|
|
4301
|
+
--text-muted: #64748b;
|
|
4302
|
+
--color-pass: #059669;
|
|
4303
|
+
--color-pass-bg: #ecfdf5;
|
|
4304
|
+
--color-pass-border: #a7f3d0;
|
|
4305
|
+
--color-fail: #dc2626;
|
|
4306
|
+
--color-fail-bg: #fef2f2;
|
|
4307
|
+
--color-fail-border: #fecaca;
|
|
4308
|
+
--color-warn: #d97706;
|
|
4309
|
+
--color-warn-bg: #fffbeb;
|
|
4310
|
+
--color-warn-border: #fde68a;
|
|
4311
|
+
--color-info: #0284c7;
|
|
4312
|
+
--color-info-bg: #f0f9ff;
|
|
4313
|
+
--color-info-border: #bae6fd;
|
|
4314
|
+
--color-accent: #2563eb;
|
|
4315
|
+
--color-accent-bg: #eff6ff;
|
|
4316
|
+
--focus-ring: #3b82f6;
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
@media (prefers-color-scheme: dark) {
|
|
4320
|
+
:root {
|
|
4321
|
+
--bg-page: #090d16;
|
|
4322
|
+
--bg-card: #131b2e;
|
|
4323
|
+
--bg-input: #1a243b;
|
|
4324
|
+
--border-subtle: #1e293b;
|
|
4325
|
+
--border-strong: #334155;
|
|
4326
|
+
--text-main: #f1f5f9;
|
|
4327
|
+
--text-muted: #94a3b8;
|
|
4328
|
+
--color-pass: #10b981;
|
|
4329
|
+
--color-pass-bg: #064e3b33;
|
|
4330
|
+
--color-pass-border: #065f46;
|
|
4331
|
+
--color-fail: #f87171;
|
|
4332
|
+
--color-fail-bg: #7f1d1d33;
|
|
4333
|
+
--color-fail-border: #991b1b;
|
|
4334
|
+
--color-warn: #fbbf24;
|
|
4335
|
+
--color-warn-bg: #78350f33;
|
|
4336
|
+
--color-warn-border: #92400e;
|
|
4337
|
+
--color-info: #38bdf8;
|
|
4338
|
+
--color-info-bg: #0c4a6e33;
|
|
4339
|
+
--color-info-border: #075985;
|
|
4340
|
+
--color-accent: #60a5fa;
|
|
4341
|
+
--color-accent-bg: #1e3a8a33;
|
|
4342
|
+
--focus-ring: #60a5fa;
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
|
|
4346
|
+
html, body {
|
|
4347
|
+
margin: 0;
|
|
4348
|
+
padding: 0;
|
|
4349
|
+
overflow-x: clip;
|
|
4350
|
+
background: var(--bg-page);
|
|
4351
|
+
color: var(--text-main);
|
|
4352
|
+
font-family: var(--font-body);
|
|
4353
|
+
line-height: 1.5;
|
|
4354
|
+
font-size: 15px;
|
|
4355
|
+
}
|
|
4356
|
+
|
|
4357
|
+
* {
|
|
4358
|
+
box-sizing: border-box;
|
|
4359
|
+
}
|
|
4360
|
+
|
|
4361
|
+
*:focus-visible {
|
|
4362
|
+
outline: 2px solid var(--focus-ring);
|
|
4363
|
+
outline-offset: 2px;
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4366
|
+
.container {
|
|
4367
|
+
max-width: 1200px;
|
|
4368
|
+
margin: 0 auto;
|
|
4369
|
+
padding: 2.5rem 1.5rem 5rem 1.5rem;
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
.header {
|
|
4373
|
+
background: var(--bg-card);
|
|
4374
|
+
border: 1px solid var(--border-subtle);
|
|
4375
|
+
border-radius: 8px;
|
|
4376
|
+
padding: 1.75rem 2rem;
|
|
4377
|
+
margin-bottom: 2rem;
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
.header-top {
|
|
4381
|
+
display: flex;
|
|
4382
|
+
justify-content: space-between;
|
|
4383
|
+
align-items: center;
|
|
4384
|
+
flex-wrap: wrap;
|
|
4385
|
+
gap: 1rem;
|
|
4386
|
+
margin-bottom: 1.25rem;
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
.title-row {
|
|
4390
|
+
display: flex;
|
|
4391
|
+
align-items: center;
|
|
4392
|
+
gap: 1rem;
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
.brand-title {
|
|
4396
|
+
font-size: 1.35rem;
|
|
4397
|
+
font-weight: 700;
|
|
4398
|
+
letter-spacing: -0.02em;
|
|
4399
|
+
margin: 0;
|
|
4400
|
+
}
|
|
4401
|
+
|
|
4402
|
+
.status-badge {
|
|
4403
|
+
display: inline-flex;
|
|
4404
|
+
align-items: center;
|
|
4405
|
+
font-size: 0.825rem;
|
|
4406
|
+
font-weight: 600;
|
|
4407
|
+
padding: 0.25rem 0.75rem;
|
|
4408
|
+
border-radius: 9999px;
|
|
4409
|
+
border: 1px solid;
|
|
4410
|
+
}
|
|
4411
|
+
|
|
4412
|
+
.status-badge.pass {
|
|
4413
|
+
background: var(--color-pass-bg);
|
|
4414
|
+
color: var(--color-pass);
|
|
4415
|
+
border-color: var(--color-pass-border);
|
|
4416
|
+
}
|
|
4417
|
+
|
|
4418
|
+
.status-badge.fail {
|
|
4419
|
+
background: var(--color-fail-bg);
|
|
4420
|
+
color: var(--color-fail);
|
|
4421
|
+
border-color: var(--color-fail-border);
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
.spec-metadata {
|
|
4425
|
+
display: grid;
|
|
4426
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
4427
|
+
gap: 1.25rem;
|
|
4428
|
+
padding-top: 1.25rem;
|
|
4429
|
+
border-top: 1px solid var(--border-subtle);
|
|
4430
|
+
font-size: 0.875rem;
|
|
4431
|
+
color: var(--text-muted);
|
|
4432
|
+
}
|
|
4433
|
+
|
|
4434
|
+
.meta-box strong {
|
|
4435
|
+
color: var(--text-main);
|
|
4436
|
+
display: block;
|
|
4437
|
+
margin-bottom: 0.2rem;
|
|
4438
|
+
}
|
|
4439
|
+
|
|
4440
|
+
.meta-code {
|
|
4441
|
+
font-family: var(--font-mono);
|
|
4442
|
+
font-size: 0.8rem;
|
|
4443
|
+
}
|
|
4444
|
+
|
|
4445
|
+
.stats-row {
|
|
4446
|
+
display: grid;
|
|
4447
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
4448
|
+
gap: 1rem;
|
|
4449
|
+
margin-bottom: 2rem;
|
|
4450
|
+
}
|
|
4451
|
+
|
|
4452
|
+
.stat-card {
|
|
4453
|
+
background: var(--bg-card);
|
|
4454
|
+
border: 1px solid var(--border-subtle);
|
|
4455
|
+
border-radius: 8px;
|
|
4456
|
+
padding: 1.25rem 1.5rem;
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
.stat-label {
|
|
4460
|
+
font-size: 0.8rem;
|
|
4461
|
+
color: var(--text-muted);
|
|
4462
|
+
margin-bottom: 0.35rem;
|
|
4463
|
+
}
|
|
4464
|
+
|
|
4465
|
+
.stat-value {
|
|
4466
|
+
font-size: 1.75rem;
|
|
4467
|
+
font-weight: 700;
|
|
4468
|
+
line-height: 1;
|
|
4469
|
+
}
|
|
4470
|
+
|
|
4471
|
+
.stat-card.error .stat-value { color: var(--color-fail); }
|
|
4472
|
+
.stat-card.warning .stat-value { color: var(--color-warn); }
|
|
4473
|
+
.stat-card.info .stat-value { color: var(--color-info); }
|
|
4474
|
+
.stat-card.total .stat-value { color: var(--text-main); }
|
|
4475
|
+
|
|
4476
|
+
.semver-banner {
|
|
4477
|
+
background: var(--color-accent-bg);
|
|
4478
|
+
border: 1px solid var(--border-subtle);
|
|
4479
|
+
border-left: 4px solid var(--color-accent);
|
|
4480
|
+
border-radius: 6px;
|
|
4481
|
+
padding: 1rem 1.25rem;
|
|
4482
|
+
margin-bottom: 2rem;
|
|
4483
|
+
font-size: 0.9rem;
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4486
|
+
.semver-banner strong {
|
|
4487
|
+
color: var(--text-main);
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4490
|
+
.controls-bar {
|
|
4491
|
+
display: flex;
|
|
4492
|
+
flex-wrap: wrap;
|
|
4493
|
+
gap: 1rem;
|
|
4494
|
+
align-items: center;
|
|
4495
|
+
justify-content: space-between;
|
|
4496
|
+
margin-bottom: 1.5rem;
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4499
|
+
.search-input {
|
|
4500
|
+
flex: 1 1 260px;
|
|
4501
|
+
padding: 0.6rem 0.9rem;
|
|
4502
|
+
font-size: 0.9rem;
|
|
4503
|
+
border: 1px solid var(--border-strong);
|
|
4504
|
+
border-radius: 6px;
|
|
4505
|
+
background: var(--bg-input);
|
|
4506
|
+
color: var(--text-main);
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
.filter-group {
|
|
4510
|
+
display: flex;
|
|
4511
|
+
flex-wrap: wrap;
|
|
4512
|
+
gap: 0.5rem;
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4515
|
+
.filter-btn {
|
|
4516
|
+
padding: 0.45rem 0.85rem;
|
|
4517
|
+
font-size: 0.825rem;
|
|
4518
|
+
font-weight: 500;
|
|
4519
|
+
border: 1px solid var(--border-strong);
|
|
4520
|
+
border-radius: 6px;
|
|
4521
|
+
background: var(--bg-card);
|
|
4522
|
+
color: var(--text-muted);
|
|
4523
|
+
cursor: pointer;
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
.filter-btn.active {
|
|
4527
|
+
background: var(--color-accent);
|
|
4528
|
+
color: #ffffff;
|
|
4529
|
+
border-color: var(--color-accent);
|
|
4530
|
+
}
|
|
4531
|
+
|
|
4532
|
+
.changes-list {
|
|
4533
|
+
display: flex;
|
|
4534
|
+
flex-direction: column;
|
|
4535
|
+
gap: 1rem;
|
|
4536
|
+
}
|
|
4537
|
+
|
|
4538
|
+
.change-card {
|
|
4539
|
+
background: var(--bg-card);
|
|
4540
|
+
border: 1px solid var(--border-subtle);
|
|
4541
|
+
border-radius: 8px;
|
|
4542
|
+
padding: 1.25rem 1.5rem;
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
.change-card.error { border-left: 4px solid var(--color-fail); }
|
|
4546
|
+
.change-card.warning { border-left: 4px solid var(--color-warn); }
|
|
4547
|
+
.change-card.info { border-left: 4px solid var(--color-info); }
|
|
4548
|
+
|
|
4549
|
+
.change-header {
|
|
4550
|
+
display: flex;
|
|
4551
|
+
justify-content: space-between;
|
|
4552
|
+
align-items: baseline;
|
|
4553
|
+
flex-wrap: wrap;
|
|
4554
|
+
gap: 0.5rem;
|
|
4555
|
+
margin-bottom: 0.75rem;
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4558
|
+
.change-title-group {
|
|
4559
|
+
display: flex;
|
|
4560
|
+
align-items: center;
|
|
4561
|
+
flex-wrap: wrap;
|
|
4562
|
+
gap: 0.6rem;
|
|
4563
|
+
}
|
|
4564
|
+
|
|
4565
|
+
.severity-tag {
|
|
4566
|
+
font-size: 0.725rem;
|
|
4567
|
+
font-weight: 700;
|
|
4568
|
+
padding: 0.15rem 0.5rem;
|
|
4569
|
+
border-radius: 4px;
|
|
4570
|
+
text-transform: uppercase;
|
|
4571
|
+
letter-spacing: 0.04em;
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
.severity-tag.error { background: var(--color-fail-bg); color: var(--color-fail); border: 1px solid var(--color-fail-border); }
|
|
4575
|
+
.severity-tag.warning { background: var(--color-warn-bg); color: var(--color-warn); border: 1px solid var(--color-warn-border); }
|
|
4576
|
+
.severity-tag.info { background: var(--color-info-bg); color: var(--color-info); border: 1px solid var(--color-info-border); }
|
|
4577
|
+
|
|
4578
|
+
.rule-name {
|
|
4579
|
+
font-family: var(--font-mono);
|
|
4580
|
+
font-size: 0.85rem;
|
|
4581
|
+
font-weight: 600;
|
|
4582
|
+
color: var(--text-main);
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
.endpoint-pill {
|
|
4586
|
+
font-family: var(--font-mono);
|
|
4587
|
+
font-size: 0.825rem;
|
|
4588
|
+
color: var(--text-muted);
|
|
4589
|
+
background: var(--bg-page);
|
|
4590
|
+
border: 1px solid var(--border-subtle);
|
|
4591
|
+
padding: 0.15rem 0.5rem;
|
|
4592
|
+
border-radius: 4px;
|
|
4593
|
+
}
|
|
4594
|
+
|
|
4595
|
+
.location-info {
|
|
4596
|
+
font-family: var(--font-mono);
|
|
4597
|
+
font-size: 0.775rem;
|
|
4598
|
+
color: var(--text-muted);
|
|
4599
|
+
}
|
|
4600
|
+
|
|
4601
|
+
.change-message {
|
|
4602
|
+
font-size: 0.95rem;
|
|
4603
|
+
margin: 0 0 0.75rem 0;
|
|
4604
|
+
color: var(--text-main);
|
|
4605
|
+
word-break: break-word;
|
|
4606
|
+
}
|
|
4607
|
+
|
|
4608
|
+
.suggestion-box {
|
|
4609
|
+
font-size: 0.85rem;
|
|
4610
|
+
color: var(--text-muted);
|
|
4611
|
+
background: var(--bg-page);
|
|
4612
|
+
border-radius: 6px;
|
|
4613
|
+
padding: 0.6rem 0.9rem;
|
|
4614
|
+
border: 1px solid var(--border-subtle);
|
|
4615
|
+
}
|
|
4616
|
+
|
|
4617
|
+
.suggestion-box strong {
|
|
4618
|
+
color: var(--text-main);
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
.diff-values {
|
|
4622
|
+
margin-top: 0.75rem;
|
|
4623
|
+
display: flex;
|
|
4624
|
+
flex-wrap: wrap;
|
|
4625
|
+
gap: 1rem;
|
|
4626
|
+
font-family: var(--font-mono);
|
|
4627
|
+
font-size: 0.8rem;
|
|
4628
|
+
}
|
|
4629
|
+
|
|
4630
|
+
.val-block {
|
|
4631
|
+
padding: 0.35rem 0.65rem;
|
|
4632
|
+
border-radius: 4px;
|
|
4633
|
+
background: var(--bg-page);
|
|
4634
|
+
border: 1px solid var(--border-subtle);
|
|
4635
|
+
}
|
|
4636
|
+
|
|
4637
|
+
.empty-state {
|
|
4638
|
+
text-align: center;
|
|
4639
|
+
padding: 4rem 2rem;
|
|
4640
|
+
background: var(--bg-card);
|
|
4641
|
+
border: 1px solid var(--border-subtle);
|
|
4642
|
+
border-radius: 8px;
|
|
4643
|
+
color: var(--text-muted);
|
|
4644
|
+
}
|
|
4645
|
+
|
|
4646
|
+
@media (prefers-reduced-motion: reduce) {
|
|
4647
|
+
* {
|
|
4648
|
+
animation-duration: 0.01ms !important;
|
|
4649
|
+
transition-duration: 0.01ms !important;
|
|
4650
|
+
}
|
|
4651
|
+
}
|
|
4652
|
+
</style>
|
|
4653
|
+
</head>
|
|
4654
|
+
<body>
|
|
4655
|
+
<div class="container">
|
|
4656
|
+
<header class="header">
|
|
4657
|
+
<div class="header-top">
|
|
4658
|
+
<div class="title-row">
|
|
4659
|
+
<h1 class="brand-title">API Guard Diff Report</h1>
|
|
4660
|
+
<span class="status-badge ${isPass ? "pass" : "fail"}">
|
|
4661
|
+
${isPass ? "PASS: No Breaking Changes" : "FAIL: Breaking Changes Detected"}
|
|
4662
|
+
</span>
|
|
4663
|
+
</div>
|
|
4664
|
+
<div style="font-size: 0.85rem; color: var(--text-muted);">
|
|
4665
|
+
Generated by apiguard ${esc(report.tool.version)}
|
|
4666
|
+
</div>
|
|
4667
|
+
</div>
|
|
4668
|
+
<div class="spec-metadata">
|
|
4669
|
+
<div class="meta-box">
|
|
4670
|
+
<strong>Baseline Specification</strong>
|
|
4671
|
+
<div>${esc(report.baseline.title || report.baseline.source)}</div>
|
|
4672
|
+
<div class="meta-code">SHA: ${esc(report.baseline.sha256.slice(0, 16))} (${esc(report.baseline.openapiVersion)})</div>
|
|
4673
|
+
</div>
|
|
4674
|
+
<div class="meta-box">
|
|
4675
|
+
<strong>Current Specification</strong>
|
|
4676
|
+
<div>${esc(report.current.title || report.current.source)}</div>
|
|
4677
|
+
<div class="meta-code">SHA: ${esc(report.current.sha256.slice(0, 16))} (${esc(report.current.openapiVersion)})</div>
|
|
4678
|
+
</div>
|
|
4679
|
+
</div>
|
|
4680
|
+
</header>
|
|
4681
|
+
|
|
4682
|
+
<section class="stats-row">
|
|
4683
|
+
<div class="stat-card error">
|
|
4684
|
+
<div class="stat-label">Breaking Errors</div>
|
|
4685
|
+
<div class="stat-value">${breaking}</div>
|
|
4686
|
+
</div>
|
|
4687
|
+
<div class="stat-card warning">
|
|
4688
|
+
<div class="stat-label">Warnings</div>
|
|
4689
|
+
<div class="stat-value">${warnings}</div>
|
|
4690
|
+
</div>
|
|
4691
|
+
<div class="stat-card info">
|
|
4692
|
+
<div class="stat-label">Informational</div>
|
|
4693
|
+
<div class="stat-value">${infos}</div>
|
|
4694
|
+
</div>
|
|
4695
|
+
<div class="stat-card total">
|
|
4696
|
+
<div class="stat-label">Total Changes</div>
|
|
4697
|
+
<div class="stat-value">${total}</div>
|
|
4698
|
+
</div>
|
|
4699
|
+
</section>
|
|
4700
|
+
|
|
4701
|
+
<div class="semver-banner">
|
|
4702
|
+
<strong>Suggested Version Bump:</strong> ${esc(report.semver.bump.toUpperCase())} — ${esc(report.semver.reason)}
|
|
4703
|
+
</div>
|
|
4704
|
+
|
|
4705
|
+
<section class="controls-bar">
|
|
4706
|
+
<input type="search" id="searchInput" class="search-input" placeholder="Search by rule, path, or message..." aria-label="Search changes">
|
|
4707
|
+
<div class="filter-group">
|
|
4708
|
+
<button class="filter-btn active" data-severity="all">All (${total})</button>
|
|
4709
|
+
<button class="filter-btn" data-severity="error">Errors (${breaking})</button>
|
|
4710
|
+
<button class="filter-btn" data-severity="warning">Warnings (${warnings})</button>
|
|
4711
|
+
<button class="filter-btn" data-severity="info">Info (${infos})</button>
|
|
4712
|
+
</div>
|
|
4713
|
+
</section>
|
|
4714
|
+
|
|
4715
|
+
<main class="changes-list" id="changesContainer">
|
|
4716
|
+
${report.changes.length === 0 ? `<div class="empty-state"><h3>No contract changes detected</h3><p>The current specification is fully compatible with the baseline.</p></div>` : report.changes.map((c) => {
|
|
4717
|
+
const ep = endpointOf(c);
|
|
4718
|
+
const locStr = c.location.file && c.location.line ? `${c.location.file}:${c.location.line}:${c.location.column ?? 1}` : c.location.pointerNew || c.location.pointerOld;
|
|
4719
|
+
const hasDiff = c.oldValue !== void 0 || c.newValue !== void 0;
|
|
4720
|
+
return `
|
|
4721
|
+
<article class="change-card ${c.severity}" data-severity="${c.severity}" data-search="${esc(`${c.ruleId} ${ep} ${c.message} ${locStr}`).toLowerCase()}">
|
|
4722
|
+
<div class="change-header">
|
|
4723
|
+
<div class="change-title-group">
|
|
4724
|
+
<span class="severity-tag ${c.severity}">${c.severity}</span>
|
|
4725
|
+
<span class="rule-name">${esc(c.ruleId)}</span>
|
|
4726
|
+
<span class="endpoint-pill">${esc(ep)}</span>
|
|
4727
|
+
</div>
|
|
4728
|
+
<div class="location-info">${esc(locStr)}</div>
|
|
4729
|
+
</div>
|
|
4730
|
+
<p class="change-message">${esc(c.message)}</p>
|
|
4731
|
+
<div class="suggestion-box">
|
|
4732
|
+
<strong>Suggestion:</strong> ${esc(c.suggestion)}
|
|
4733
|
+
</div>
|
|
4734
|
+
${hasDiff ? `<div class="diff-values">
|
|
4735
|
+
${c.oldValue !== void 0 ? `<div class="val-block">Old: <code>${esc(valueRepr(c.oldValue))}</code></div>` : ""}
|
|
4736
|
+
${c.newValue !== void 0 ? `<div class="val-block">New: <code>${esc(valueRepr(c.newValue))}</code></div>` : ""}
|
|
4737
|
+
</div>` : ""}
|
|
4738
|
+
</article>`;
|
|
4739
|
+
}).join("\n")}
|
|
4740
|
+
</main>
|
|
4741
|
+
</div>
|
|
4742
|
+
|
|
4743
|
+
<script>
|
|
4744
|
+
(function() {
|
|
4745
|
+
const searchInput = document.getElementById('searchInput');
|
|
4746
|
+
const filterBtns = document.querySelectorAll('.filter-btn');
|
|
4747
|
+
const cards = document.querySelectorAll('.change-card');
|
|
4748
|
+
let currentSeverity = 'all';
|
|
4749
|
+
let currentQuery = '';
|
|
4750
|
+
|
|
4751
|
+
function applyFilters() {
|
|
4752
|
+
cards.forEach(card => {
|
|
4753
|
+
const sev = card.getAttribute('data-severity');
|
|
4754
|
+
const searchData = card.getAttribute('data-search') || '';
|
|
4755
|
+
const matchesSev = (currentSeverity === 'all' || sev === currentSeverity);
|
|
4756
|
+
const matchesQuery = (!currentQuery || searchData.includes(currentQuery));
|
|
4757
|
+
card.style.display = (matchesSev && matchesQuery) ? '' : 'none';
|
|
4758
|
+
});
|
|
4759
|
+
}
|
|
4760
|
+
|
|
4761
|
+
if (searchInput) {
|
|
4762
|
+
searchInput.addEventListener('input', e => {
|
|
4763
|
+
currentQuery = e.target.value.trim().toLowerCase();
|
|
4764
|
+
applyFilters();
|
|
4765
|
+
});
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
filterBtns.forEach(btn => {
|
|
4769
|
+
btn.addEventListener('click', () => {
|
|
4770
|
+
filterBtns.forEach(b => b.classList.remove('active'));
|
|
4771
|
+
btn.classList.add('active');
|
|
4772
|
+
currentSeverity = btn.getAttribute('data-severity') || 'all';
|
|
4773
|
+
applyFilters();
|
|
4774
|
+
});
|
|
4775
|
+
});
|
|
4776
|
+
})();
|
|
4777
|
+
</script>
|
|
4778
|
+
</body>
|
|
4779
|
+
</html>`;
|
|
4780
|
+
}
|
|
4781
|
+
|
|
4782
|
+
// src/reporters/sarif.ts
|
|
4783
|
+
function sarifLevel(severity) {
|
|
4784
|
+
switch (severity) {
|
|
4785
|
+
case "error":
|
|
4786
|
+
return "error";
|
|
4787
|
+
case "warning":
|
|
4788
|
+
return "warning";
|
|
4789
|
+
case "info":
|
|
4790
|
+
return "note";
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4793
|
+
function renderSarif(report) {
|
|
4794
|
+
const rules = getAllRules().map((r) => ({
|
|
4795
|
+
id: r.id,
|
|
4796
|
+
shortDescription: { text: r.id },
|
|
4797
|
+
fullDescription: { text: r.trigger },
|
|
4798
|
+
defaultConfiguration: {
|
|
4799
|
+
level: sarifLevel(r.defaultSeverity)
|
|
4800
|
+
},
|
|
4801
|
+
help: {
|
|
4802
|
+
text: `${r.trigger}
|
|
4803
|
+
Suggestion: ${r.suggestion({ location: { path: "/", pointerOld: "", pointerNew: "", context: "api" } })}`,
|
|
4804
|
+
markdown: `**Trigger:** ${r.trigger}
|
|
4805
|
+
|
|
4806
|
+
[API Guard Rule Reference](https://github.com/subhamxaura/apiguard/blob/main/docs/rules.md#${r.id})`
|
|
4807
|
+
},
|
|
4808
|
+
properties: {
|
|
4809
|
+
tags: ["api-contract", "openapi", "breaking-change", ...r.contexts]
|
|
4810
|
+
}
|
|
4811
|
+
}));
|
|
4812
|
+
const results = report.changes.map((c) => {
|
|
4813
|
+
const fileUri = (c.location.file || report.current.source).replace(/\\/g, "/");
|
|
4814
|
+
const line = c.location.line && c.location.line > 0 ? c.location.line : 1;
|
|
4815
|
+
const col = c.location.column && c.location.column > 0 ? c.location.column : 1;
|
|
4816
|
+
return {
|
|
4817
|
+
ruleId: c.ruleId,
|
|
4818
|
+
level: sarifLevel(c.severity),
|
|
4819
|
+
message: {
|
|
4820
|
+
text: `${c.message}. Suggestion: ${c.suggestion}`
|
|
4821
|
+
},
|
|
4822
|
+
locations: [
|
|
4823
|
+
{
|
|
4824
|
+
physicalLocation: {
|
|
4825
|
+
artifactLocation: {
|
|
4826
|
+
uri: fileUri,
|
|
4827
|
+
uriBaseId: "%SRCROOT%"
|
|
4828
|
+
},
|
|
4829
|
+
region: {
|
|
4830
|
+
startLine: line,
|
|
4831
|
+
startColumn: col
|
|
4832
|
+
}
|
|
4833
|
+
}
|
|
4834
|
+
}
|
|
4835
|
+
],
|
|
4836
|
+
properties: {
|
|
4837
|
+
kind: c.kind,
|
|
4838
|
+
breaking: c.breaking,
|
|
4839
|
+
context: c.location.context,
|
|
4840
|
+
path: c.location.path,
|
|
4841
|
+
method: c.location.method
|
|
4842
|
+
}
|
|
4843
|
+
};
|
|
4844
|
+
});
|
|
4845
|
+
const sarifLog = {
|
|
4846
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
4847
|
+
version: "2.1.0",
|
|
4848
|
+
runs: [
|
|
4849
|
+
{
|
|
4850
|
+
tool: {
|
|
4851
|
+
driver: {
|
|
4852
|
+
name: "apiguard",
|
|
4853
|
+
version: report.tool.version,
|
|
4854
|
+
informationUri: "https://github.com/subhamxaura/apiguard",
|
|
4855
|
+
rules
|
|
4856
|
+
}
|
|
4857
|
+
},
|
|
4858
|
+
results
|
|
4859
|
+
}
|
|
4860
|
+
]
|
|
4861
|
+
};
|
|
4862
|
+
return JSON.stringify(sarifLog, null, 2);
|
|
4863
|
+
}
|
|
4864
|
+
|
|
4063
4865
|
// src/cli/diff-command.ts
|
|
4064
4866
|
function resolveFormat(opts) {
|
|
4065
4867
|
if (opts.json) return "json";
|
|
4066
4868
|
if (opts.markdown) return "markdown";
|
|
4869
|
+
if (opts.html) return "html";
|
|
4870
|
+
if (opts.sarif) return "sarif";
|
|
4067
4871
|
if (opts.format !== void 0) {
|
|
4068
|
-
if (opts.format === "terminal" || opts.format === "json" || opts.format === "markdown") {
|
|
4872
|
+
if (opts.format === "terminal" || opts.format === "json" || opts.format === "markdown" || opts.format === "html" || opts.format === "sarif") {
|
|
4069
4873
|
return opts.format;
|
|
4070
4874
|
}
|
|
4071
|
-
throw new CliUsageError(
|
|
4875
|
+
throw new CliUsageError(
|
|
4876
|
+
`invalid --format "${opts.format}" (expected terminal|json|markdown|html|sarif)`
|
|
4877
|
+
);
|
|
4072
4878
|
}
|
|
4073
4879
|
const envFormat = process.env.APIGUARD_FORMAT;
|
|
4074
|
-
if (envFormat === "json" || envFormat === "markdown" || envFormat === "terminal")
|
|
4880
|
+
if (envFormat === "json" || envFormat === "markdown" || envFormat === "terminal" || envFormat === "html" || envFormat === "sarif")
|
|
4075
4881
|
return envFormat;
|
|
4076
4882
|
return "terminal";
|
|
4077
4883
|
}
|
|
@@ -4089,6 +4895,17 @@ function colorEnabled(opts, configColor) {
|
|
|
4089
4895
|
return process.stdout.isTTY === true;
|
|
4090
4896
|
}
|
|
4091
4897
|
async function runDiff(oldSpec, newSpec, opts, io) {
|
|
4898
|
+
if (opts.gitBase) {
|
|
4899
|
+
if (!newSpec && oldSpec) {
|
|
4900
|
+
newSpec = oldSpec;
|
|
4901
|
+
oldSpec = `git:${opts.gitBase}:${newSpec}`;
|
|
4902
|
+
} else if (newSpec && !oldSpec) {
|
|
4903
|
+
oldSpec = `git:${opts.gitBase}:${newSpec}`;
|
|
4904
|
+
}
|
|
4905
|
+
}
|
|
4906
|
+
if (!oldSpec || !newSpec) {
|
|
4907
|
+
throw new CliUsageError("missing required spec arguments (or use --git-base <ref>)");
|
|
4908
|
+
}
|
|
4092
4909
|
if (opts.verbose) io.stderr(`verbose: loading baseline ${oldSpec} and current ${newSpec}`);
|
|
4093
4910
|
const loaded = discoverAndLoadConfig({ explicit: opts.config, env: io.env.APIGUARD_CONFIG });
|
|
4094
4911
|
for (const w of loaded.warnings) io.stderr(`warning: ${w}`);
|
|
@@ -4097,7 +4914,8 @@ async function runDiff(oldSpec, newSpec, opts, io) {
|
|
|
4097
4914
|
failOn: resolveFailOn(opts),
|
|
4098
4915
|
includeNonBreaking: opts.strict || opts.includeNonBreaking,
|
|
4099
4916
|
suggestVersion: opts.suggestVersion,
|
|
4100
|
-
noColor: !colorEnabled(opts, loaded.config.output.color)
|
|
4917
|
+
noColor: !colorEnabled(opts, loaded.config.output.color),
|
|
4918
|
+
profile: opts.profile
|
|
4101
4919
|
});
|
|
4102
4920
|
const { report } = await analyze(oldSpec, newSpec, {
|
|
4103
4921
|
config,
|
|
@@ -4112,6 +4930,10 @@ async function runDiff(oldSpec, newSpec, opts, io) {
|
|
|
4112
4930
|
} else if (config.output.format === "markdown") {
|
|
4113
4931
|
const { renderMarkdown: renderMarkdown2 } = await Promise.resolve().then(() => (init_markdown(), markdown_exports));
|
|
4114
4932
|
body = renderMarkdown2(report);
|
|
4933
|
+
} else if (config.output.format === "html") {
|
|
4934
|
+
body = renderHtml(report);
|
|
4935
|
+
} else if (config.output.format === "sarif") {
|
|
4936
|
+
body = renderSarif(report);
|
|
4115
4937
|
} else {
|
|
4116
4938
|
const lines = renderTerminal(report, {
|
|
4117
4939
|
includeNonBreaking: config.output.includeNonBreaking,
|
|
@@ -4124,9 +4946,17 @@ async function runDiff(oldSpec, newSpec, opts, io) {
|
|
|
4124
4946
|
if (bump) body += "\n\n" + bump;
|
|
4125
4947
|
}
|
|
4126
4948
|
io.stdout(body);
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4949
|
+
const outputPath = opts.output ?? (opts.open ? "apiguard-report.html" : void 0);
|
|
4950
|
+
if (outputPath) {
|
|
4951
|
+
import_node_fs3.default.writeFileSync(outputPath, body + "\n", "utf8");
|
|
4952
|
+
if (!opts.quiet) io.stderr(`report written to ${outputPath}`);
|
|
4953
|
+
if (opts.open && (config.output.format === "html" || outputPath.endsWith(".html"))) {
|
|
4954
|
+
const openCmd = process.platform === "win32" ? `start "" "${outputPath}"` : process.platform === "darwin" ? `open "${outputPath}"` : `xdg-open "${outputPath}"`;
|
|
4955
|
+
try {
|
|
4956
|
+
(0, import_node_child_process2.exec)(openCmd);
|
|
4957
|
+
} catch {
|
|
4958
|
+
}
|
|
4959
|
+
}
|
|
4130
4960
|
}
|
|
4131
4961
|
if (config.failOn === "never") return ExitCode.Ok;
|
|
4132
4962
|
const threshold = config.failOn === "error" ? "error" : "warning";
|
|
@@ -4284,6 +5114,260 @@ use --force to overwrite`
|
|
|
4284
5114
|
io.stdout('next: run "apiguard diff examples/baseline.yaml examples/current.yaml"');
|
|
4285
5115
|
}
|
|
4286
5116
|
|
|
5117
|
+
// src/cli/changelog-command.ts
|
|
5118
|
+
var import_node_fs5 = __toESM(require("fs"), 1);
|
|
5119
|
+
|
|
5120
|
+
// src/reporters/changelog.ts
|
|
5121
|
+
function endpointOf3(change) {
|
|
5122
|
+
const loc = change.location;
|
|
5123
|
+
if (loc.method && loc.path) return `\`${loc.method} ${loc.path}\``;
|
|
5124
|
+
if (loc.path && loc.path !== "/") return `\`${loc.path}\``;
|
|
5125
|
+
if (loc.componentName) return `component \`${loc.componentName}\``;
|
|
5126
|
+
return "API";
|
|
5127
|
+
}
|
|
5128
|
+
function renderChangelog(report) {
|
|
5129
|
+
const lines = [];
|
|
5130
|
+
const version = report.semver.bump === "none" ? "Current Release (No version bump)" : `Next Release (Recommended bump: ${report.semver.bump.toUpperCase()})`;
|
|
5131
|
+
lines.push(`## ${version}`);
|
|
5132
|
+
lines.push("");
|
|
5133
|
+
const breaking = report.changes.filter((c) => c.severity === "error");
|
|
5134
|
+
const additions = report.changes.filter((c) => c.severity !== "error" && c.kind === "addition");
|
|
5135
|
+
const modifications = report.changes.filter(
|
|
5136
|
+
(c) => c.severity !== "error" && c.kind === "modification"
|
|
5137
|
+
);
|
|
5138
|
+
const relaxations = report.changes.filter(
|
|
5139
|
+
(c) => c.severity !== "error" && (c.kind === "relaxation" || c.kind === "documentation")
|
|
5140
|
+
);
|
|
5141
|
+
if (breaking.length > 0) {
|
|
5142
|
+
lines.push("### \u{1F6D1} Breaking Changes");
|
|
5143
|
+
lines.push("");
|
|
5144
|
+
for (const c of breaking) {
|
|
5145
|
+
lines.push(`- **${endpointOf3(c)}**: ${c.message} (\`${c.ruleId}\`)`);
|
|
5146
|
+
lines.push(` *Suggestion: ${c.suggestion}*`);
|
|
5147
|
+
}
|
|
5148
|
+
lines.push("");
|
|
5149
|
+
}
|
|
5150
|
+
if (additions.length > 0) {
|
|
5151
|
+
lines.push("### \u{1F680} Added");
|
|
5152
|
+
lines.push("");
|
|
5153
|
+
for (const c of additions) {
|
|
5154
|
+
lines.push(`- **${endpointOf3(c)}**: ${c.message}`);
|
|
5155
|
+
}
|
|
5156
|
+
lines.push("");
|
|
5157
|
+
}
|
|
5158
|
+
if (modifications.length > 0) {
|
|
5159
|
+
lines.push("### \u{1F504} Changed");
|
|
5160
|
+
lines.push("");
|
|
5161
|
+
for (const c of modifications) {
|
|
5162
|
+
lines.push(`- **${endpointOf3(c)}**: ${c.message}`);
|
|
5163
|
+
}
|
|
5164
|
+
lines.push("");
|
|
5165
|
+
}
|
|
5166
|
+
if (relaxations.length > 0) {
|
|
5167
|
+
lines.push("### \u2139\uFE0F Other Improvements");
|
|
5168
|
+
lines.push("");
|
|
5169
|
+
for (const c of relaxations) {
|
|
5170
|
+
lines.push(`- **${endpointOf3(c)}**: ${c.message}`);
|
|
5171
|
+
}
|
|
5172
|
+
lines.push("");
|
|
5173
|
+
}
|
|
5174
|
+
if (report.changes.length === 0) {
|
|
5175
|
+
lines.push("No functional contract changes detected between specifications.");
|
|
5176
|
+
}
|
|
5177
|
+
return lines.join("\n").trim();
|
|
5178
|
+
}
|
|
5179
|
+
|
|
5180
|
+
// src/cli/changelog-command.ts
|
|
5181
|
+
async function runChangelog(oldSpec, newSpec, opts, io) {
|
|
5182
|
+
if (opts.gitBase) {
|
|
5183
|
+
if (!newSpec && oldSpec) {
|
|
5184
|
+
newSpec = oldSpec;
|
|
5185
|
+
oldSpec = `git:${opts.gitBase}:${newSpec}`;
|
|
5186
|
+
}
|
|
5187
|
+
}
|
|
5188
|
+
if (!oldSpec || !newSpec) {
|
|
5189
|
+
throw new CliUsageError("missing required spec arguments (or use --git-base <ref>)");
|
|
5190
|
+
}
|
|
5191
|
+
const loaded = discoverAndLoadConfig({ explicit: opts.config, env: io.env.APIGUARD_CONFIG });
|
|
5192
|
+
const { report } = await analyze(oldSpec, newSpec, { config: loaded.config });
|
|
5193
|
+
const body = opts.json ? JSON.stringify(
|
|
5194
|
+
{ semver: report.semver, summary: report.summary, changes: report.changes },
|
|
5195
|
+
null,
|
|
5196
|
+
2
|
|
5197
|
+
) : renderChangelog(report);
|
|
5198
|
+
io.stdout(body);
|
|
5199
|
+
if (opts.output) {
|
|
5200
|
+
import_node_fs5.default.writeFileSync(opts.output, body + "\n", "utf8");
|
|
5201
|
+
io.stderr(`changelog written to ${opts.output}`);
|
|
5202
|
+
}
|
|
5203
|
+
return ExitCode.Ok;
|
|
5204
|
+
}
|
|
5205
|
+
|
|
5206
|
+
// src/cli/codegen-command.ts
|
|
5207
|
+
var import_node_fs6 = __toESM(require("fs"), 1);
|
|
5208
|
+
|
|
5209
|
+
// src/codegen/shim.ts
|
|
5210
|
+
function generateExpressShim(report) {
|
|
5211
|
+
const breaking = report.changes.filter((c) => c.severity === "error");
|
|
5212
|
+
const pathRewrites = [];
|
|
5213
|
+
const paramDefaults = [];
|
|
5214
|
+
for (const c of breaking) {
|
|
5215
|
+
if (c.ruleId === "operation-removed" && c.location.path) {
|
|
5216
|
+
pathRewrites.push({ oldPath: c.location.path });
|
|
5217
|
+
} else if (c.ruleId === "parameter-added-required" || c.ruleId === "parameter-made-required") {
|
|
5218
|
+
const paramName = c.location.pointerNew.split("/").pop() || "param";
|
|
5219
|
+
paramDefaults.push({
|
|
5220
|
+
method: c.location.method,
|
|
5221
|
+
path: c.location.path,
|
|
5222
|
+
param: paramName,
|
|
5223
|
+
defaultVal: c.newValue ?? ""
|
|
5224
|
+
});
|
|
5225
|
+
}
|
|
5226
|
+
}
|
|
5227
|
+
return `// Generated by API Guard (apiguard codegen-shim --target express)
|
|
5228
|
+
// Backward compatibility shim for baseline @ ${report.baseline.sha256.slice(0, 12)}
|
|
5229
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
5230
|
+
|
|
5231
|
+
export function apiGuardCompatibilityShim(req: Request, res: Response, next: NextFunction) {
|
|
5232
|
+
const method = req.method.toUpperCase();
|
|
5233
|
+
const path = req.path;
|
|
5234
|
+
|
|
5235
|
+
// 1. Parameter defaults for newly required parameters
|
|
5236
|
+
${paramDefaults.map(
|
|
5237
|
+
(p) => ` if (${p.method ? `method === '${p.method}' && ` : ""}path.startsWith('${p.path.replace(/\{.*\}/, "")}')) {
|
|
5238
|
+
if (req.query['${p.param}'] === undefined && req.body?.['${p.param}'] === undefined) {
|
|
5239
|
+
req.query['${p.param}'] = '${String(p.defaultVal)}';
|
|
5240
|
+
}
|
|
5241
|
+
}`
|
|
5242
|
+
).join("\n")}
|
|
5243
|
+
|
|
5244
|
+
// 2. Response adapter interception
|
|
5245
|
+
const originalJson = res.json;
|
|
5246
|
+
res.json = function(body: any) {
|
|
5247
|
+
// Intercept and maintain legacy schema structure
|
|
5248
|
+
if (body && typeof body === 'object') {
|
|
5249
|
+
// Compatibility mutations can be inserted here
|
|
5250
|
+
}
|
|
5251
|
+
return originalJson.call(this, body);
|
|
5252
|
+
};
|
|
5253
|
+
|
|
5254
|
+
next();
|
|
5255
|
+
}
|
|
5256
|
+
`;
|
|
5257
|
+
}
|
|
5258
|
+
function generateCloudflareWorkerShim(report) {
|
|
5259
|
+
const breaking = report.changes.filter((c) => c.severity === "error");
|
|
5260
|
+
return `// Generated by API Guard (apiguard codegen-shim --target cloudflare-worker)
|
|
5261
|
+
// Edge backward-compatibility worker
|
|
5262
|
+
export default {
|
|
5263
|
+
async fetch(request: Request, env: any, ctx: any): Promise<Response> {
|
|
5264
|
+
const url = new URL(request.url);
|
|
5265
|
+
const method = request.method.toUpperCase();
|
|
5266
|
+
|
|
5267
|
+
// Clone request headers for mutation
|
|
5268
|
+
const modifiedHeaders = new Headers(request.headers);
|
|
5269
|
+
|
|
5270
|
+
// Track downstream upstream response
|
|
5271
|
+
const response = await fetch(new Request(url.toString(), {
|
|
5272
|
+
method: request.method,
|
|
5273
|
+
headers: modifiedHeaders,
|
|
5274
|
+
body: request.body,
|
|
5275
|
+
redirect: 'manual'
|
|
5276
|
+
}));
|
|
5277
|
+
|
|
5278
|
+
// Intercept JSON responses for schema backward compatibility
|
|
5279
|
+
const contentType = response.headers.get('content-type') || '';
|
|
5280
|
+
if (contentType.includes('application/json')) {
|
|
5281
|
+
try {
|
|
5282
|
+
const data = await response.json();
|
|
5283
|
+
// ${breaking.length} breaking changes monitored
|
|
5284
|
+
return new Response(JSON.stringify(data), {
|
|
5285
|
+
status: response.status,
|
|
5286
|
+
headers: response.headers
|
|
5287
|
+
});
|
|
5288
|
+
} catch {
|
|
5289
|
+
// Pass through unmodified if not json
|
|
5290
|
+
}
|
|
5291
|
+
}
|
|
5292
|
+
|
|
5293
|
+
return response;
|
|
5294
|
+
}
|
|
5295
|
+
};
|
|
5296
|
+
`;
|
|
5297
|
+
}
|
|
5298
|
+
function generateEnvoyLuaShim(report) {
|
|
5299
|
+
return `-- Generated by API Guard (apiguard codegen-shim --target envoy-lua)
|
|
5300
|
+
-- Envoy Proxy Lua filter for API backward compatibility (baseline: ${report.baseline.sha256.slice(0, 12)})
|
|
5301
|
+
|
|
5302
|
+
function envoy_on_request(request_handle)
|
|
5303
|
+
local headers = request_handle:headers()
|
|
5304
|
+
local path = headers:get(":path")
|
|
5305
|
+
local method = headers:get(":method")
|
|
5306
|
+
|
|
5307
|
+
-- Check for API compatibility headers
|
|
5308
|
+
if not headers:get("x-apiguard-shimmed") then
|
|
5309
|
+
headers:add("x-apiguard-shimmed", "true")
|
|
5310
|
+
end
|
|
5311
|
+
end
|
|
5312
|
+
|
|
5313
|
+
function envoy_on_response(response_handle)
|
|
5314
|
+
local headers = response_handle:headers()
|
|
5315
|
+
headers:add("x-apiguard-adapter", "v1-compat")
|
|
5316
|
+
end
|
|
5317
|
+
`;
|
|
5318
|
+
}
|
|
5319
|
+
function generateGatewayShim(report, target = "express") {
|
|
5320
|
+
const breaking = report.changes.filter((c) => c.severity === "error");
|
|
5321
|
+
let code;
|
|
5322
|
+
switch (target) {
|
|
5323
|
+
case "express":
|
|
5324
|
+
code = generateExpressShim(report);
|
|
5325
|
+
break;
|
|
5326
|
+
case "cloudflare-worker":
|
|
5327
|
+
code = generateCloudflareWorkerShim(report);
|
|
5328
|
+
break;
|
|
5329
|
+
case "envoy-lua":
|
|
5330
|
+
code = generateEnvoyLuaShim(report);
|
|
5331
|
+
break;
|
|
5332
|
+
}
|
|
5333
|
+
return {
|
|
5334
|
+
target,
|
|
5335
|
+
code,
|
|
5336
|
+
handledChanges: breaking.length
|
|
5337
|
+
};
|
|
5338
|
+
}
|
|
5339
|
+
|
|
5340
|
+
// src/cli/codegen-command.ts
|
|
5341
|
+
async function runCodegenShim(oldSpec, newSpec, opts, io) {
|
|
5342
|
+
if (opts.gitBase) {
|
|
5343
|
+
if (!newSpec && oldSpec) {
|
|
5344
|
+
newSpec = oldSpec;
|
|
5345
|
+
oldSpec = `git:${opts.gitBase}:${newSpec}`;
|
|
5346
|
+
}
|
|
5347
|
+
}
|
|
5348
|
+
if (!oldSpec || !newSpec) {
|
|
5349
|
+
throw new CliUsageError("missing required spec arguments (or use --git-base <ref>)");
|
|
5350
|
+
}
|
|
5351
|
+
const rawTarget = opts.target ?? "express";
|
|
5352
|
+
if (rawTarget !== "express" && rawTarget !== "cloudflare-worker" && rawTarget !== "envoy-lua") {
|
|
5353
|
+
throw new CliUsageError(
|
|
5354
|
+
`invalid --target "${rawTarget}" (expected express|cloudflare-worker|envoy-lua)`
|
|
5355
|
+
);
|
|
5356
|
+
}
|
|
5357
|
+
const target = rawTarget;
|
|
5358
|
+
const loaded = discoverAndLoadConfig({ explicit: opts.config, env: io.env.APIGUARD_CONFIG });
|
|
5359
|
+
const { report } = await analyze(oldSpec, newSpec, { config: loaded.config });
|
|
5360
|
+
const result = generateGatewayShim(report, target);
|
|
5361
|
+
io.stdout(result.code);
|
|
5362
|
+
if (opts.output) {
|
|
5363
|
+
import_node_fs6.default.writeFileSync(opts.output, result.code, "utf8");
|
|
5364
|
+
io.stderr(
|
|
5365
|
+
`compatibility shim written to ${opts.output} (${result.handledChanges} breaking change(s) addressed)`
|
|
5366
|
+
);
|
|
5367
|
+
}
|
|
5368
|
+
return ExitCode.Ok;
|
|
5369
|
+
}
|
|
5370
|
+
|
|
4287
5371
|
// src/cli/run.ts
|
|
4288
5372
|
var defaultIo = {
|
|
4289
5373
|
stdout: (s) => process.stdout.write(s + "\n"),
|
|
@@ -4299,7 +5383,10 @@ function buildProgram(io) {
|
|
|
4299
5383
|
writeErr: (s) => io.stderr(s.trimEnd())
|
|
4300
5384
|
});
|
|
4301
5385
|
program.name("apiguard").version(TOOL_VERSION).description("Detect breaking changes between OpenAPI specifications");
|
|
4302
|
-
const diff = program.command("diff").description("Diff two OpenAPI specs and report breaking changes").argument("<old-spec>", "baseline spec path").argument("<new-spec>", "current spec path").option(
|
|
5386
|
+
const diff = program.command("diff").description("Diff two OpenAPI specs and report breaking changes").argument("<old-spec>", "baseline spec path").argument("<new-spec>", "current spec path").option(
|
|
5387
|
+
"--format <f>",
|
|
5388
|
+
"terminal|json|markdown|html|sarif (aliases: --json, --markdown, --html, --sarif)"
|
|
5389
|
+
).option("--json", "shorthand for --format json").option("--markdown", "shorthand for --format markdown").option("--html", "shorthand for --format html").option("--sarif", "shorthand for --format sarif").option("--open", "open interactive HTML report in browser").option("--git-base <ref>", "resolve baseline against git branch or commit").option("--profile <p>", "client persona profile (default|strict-sdk|loose-web)").option("--config <path>", "explicit config file (missing file: exit 2)").option("--fail-on <level>", "error|warning|never", "error").option("--strict", "shorthand for --fail-on warning AND --include-non-breaking").option("--quiet", "print only the one-line verdict").option("--suggest-version", "print suggested version bump after the report").option("--include-non-breaking", "also render warning+info changes").option("--output <path>", "also write the full report to a file").option("--no-color", "disable ANSI color (auto-detected for non-TTY)").option("--show-ignored", "render changes suppressed by ignore rules, tagged ignored").option("--verbose", "log parse/config steps to stderr").action(async (oldSpec, newSpec, opts) => {
|
|
4303
5390
|
const code = await runDiff(oldSpec, newSpec, opts, io);
|
|
4304
5391
|
if (code !== ExitCode.Ok) {
|
|
4305
5392
|
process.exitCode = code;
|
|
@@ -4307,6 +5394,20 @@ function buildProgram(io) {
|
|
|
4307
5394
|
}
|
|
4308
5395
|
});
|
|
4309
5396
|
void diff;
|
|
5397
|
+
program.command("changelog").description("Generate a Keep-a-Changelog formatted release summary from spec diff").argument("<old-spec>", "baseline spec path").argument("<new-spec>", "current spec path").option("--config <path>", "explicit config file").option("--output <path>", "write changelog to file").option("--git-base <ref>", "resolve baseline against git branch or commit").option("--json", "output changelog as JSON").action(async (oldSpec, newSpec, opts) => {
|
|
5398
|
+
const code = await runChangelog(oldSpec, newSpec, opts, io);
|
|
5399
|
+
if (code !== ExitCode.Ok) {
|
|
5400
|
+
process.exitCode = code;
|
|
5401
|
+
program.__apiguardExitCode = code;
|
|
5402
|
+
}
|
|
5403
|
+
});
|
|
5404
|
+
program.command("codegen-shim").description("Generate backward-compatibility gateway adapter for breaking changes").argument("<old-spec>", "baseline spec path").argument("<new-spec>", "current spec path").option("--target <t>", "express|cloudflare-worker|envoy-lua", "express").option("--config <path>", "explicit config file").option("--output <path>", "write generated shim code to file").option("--git-base <ref>", "resolve baseline against git branch or commit").action(async (oldSpec, newSpec, opts) => {
|
|
5405
|
+
const code = await runCodegenShim(oldSpec, newSpec, opts, io);
|
|
5406
|
+
if (code !== ExitCode.Ok) {
|
|
5407
|
+
process.exitCode = code;
|
|
5408
|
+
program.__apiguardExitCode = code;
|
|
5409
|
+
}
|
|
5410
|
+
});
|
|
4310
5411
|
program.command("validate").description("Load and validate a spec").argument("<spec>", "spec path").option("--config <path>", "explicit config file").option("--strict", "exit 1 when semantic warnings exist").option("--verbose", "log parse steps to stderr").action(async (spec, opts) => {
|
|
4311
5412
|
const outcome = await runValidate(spec, opts, io);
|
|
4312
5413
|
if (outcome.exitCode !== ExitCode.Ok) {
|