@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
|
@@ -16,7 +16,7 @@ var ignoreSchema = z.strictObject({
|
|
|
16
16
|
"show-ignored": z.boolean().optional().default(false)
|
|
17
17
|
});
|
|
18
18
|
var outputSchema = z.strictObject({
|
|
19
|
-
format: z.enum(["terminal", "json", "markdown"]).optional().default("terminal"),
|
|
19
|
+
format: z.enum(["terminal", "json", "markdown", "html", "sarif"]).optional().default("terminal"),
|
|
20
20
|
"include-non-breaking": z.boolean().optional().default(false),
|
|
21
21
|
color: z.enum(["auto", "always", "never"]).optional().default("auto")
|
|
22
22
|
});
|
|
@@ -24,13 +24,17 @@ var versioningSchema = z.strictObject({
|
|
|
24
24
|
suggest: z.boolean().optional().default(true)
|
|
25
25
|
});
|
|
26
26
|
var loaderSchema = z.strictObject({
|
|
27
|
-
"allow-remote-refs": z.boolean().optional().default(false)
|
|
27
|
+
"allow-remote-refs": z.boolean().optional().default(false),
|
|
28
|
+
"allow-external-files": z.boolean().optional().default(false),
|
|
29
|
+
"allow-private-ips": z.boolean().optional().default(false),
|
|
30
|
+
headers: z.record(z.string(), z.string()).optional().default({})
|
|
28
31
|
});
|
|
29
32
|
var githubSchema = z.strictObject({
|
|
30
33
|
comment: z.boolean().optional().default(true),
|
|
31
34
|
"check-run": z.boolean().optional().default(true),
|
|
32
35
|
"update-existing-comment": z.boolean().optional().default(true)
|
|
33
36
|
});
|
|
37
|
+
var profileSchema = z.enum(["default", "strict-sdk", "loose-web"]).default("default");
|
|
34
38
|
var configSchema = z.strictObject({
|
|
35
39
|
schemaVersion: z.literal(1, {
|
|
36
40
|
error: "schemaVersion must be 1 (other versions are rejected; see docs/configuration.md)"
|
|
@@ -40,6 +44,7 @@ var configSchema = z.strictObject({
|
|
|
40
44
|
output: outputSchema.optional(),
|
|
41
45
|
"fail-on": z.enum(["error", "warning", "never"]).optional(),
|
|
42
46
|
versioning: versioningSchema.optional(),
|
|
47
|
+
profile: profileSchema.optional(),
|
|
43
48
|
loader: loaderSchema.optional(),
|
|
44
49
|
github: githubSchema.optional()
|
|
45
50
|
});
|
|
@@ -63,7 +68,13 @@ var DEFAULT_CONFIG = {
|
|
|
63
68
|
failOn: "error",
|
|
64
69
|
// §13 golden output shows the bump line "only with --suggest-version"; config may opt in.
|
|
65
70
|
versioning: { suggest: false },
|
|
66
|
-
|
|
71
|
+
profile: "default",
|
|
72
|
+
loader: {
|
|
73
|
+
allowRemoteRefs: false,
|
|
74
|
+
allowExternalFiles: false,
|
|
75
|
+
allowPrivateIps: false,
|
|
76
|
+
headers: {}
|
|
77
|
+
},
|
|
67
78
|
github: { comment: true, checkRun: true, updateExistingComment: true }
|
|
68
79
|
};
|
|
69
80
|
|
|
@@ -156,7 +167,13 @@ function toResolved(parsed) {
|
|
|
156
167
|
},
|
|
157
168
|
failOn: parsed["fail-on"] ?? "error",
|
|
158
169
|
versioning: { suggest: parsed.versioning?.suggest ?? true },
|
|
159
|
-
|
|
170
|
+
profile: parsed.profile ?? "default",
|
|
171
|
+
loader: {
|
|
172
|
+
allowRemoteRefs: parsed.loader?.["allow-remote-refs"] ?? false,
|
|
173
|
+
allowExternalFiles: parsed.loader?.["allow-external-files"] ?? false,
|
|
174
|
+
allowPrivateIps: parsed.loader?.["allow-private-ips"] ?? false,
|
|
175
|
+
headers: parsed.loader?.headers ?? {}
|
|
176
|
+
},
|
|
160
177
|
github: {
|
|
161
178
|
comment: parsed.github?.comment ?? true,
|
|
162
179
|
checkRun: parsed.github?.["check-run"] ?? true,
|
|
@@ -236,13 +253,16 @@ function mergeWithFlags(base, flags) {
|
|
|
236
253
|
failOn: flags.failOn ?? base.failOn,
|
|
237
254
|
versioning: {
|
|
238
255
|
suggest: flags.suggestVersion ?? base.versioning.suggest
|
|
239
|
-
}
|
|
256
|
+
},
|
|
257
|
+
profile: flags.profile ?? base.profile
|
|
240
258
|
};
|
|
241
259
|
}
|
|
242
260
|
|
|
243
261
|
// src/loaders/spec-loader.ts
|
|
244
262
|
import fs2 from "fs";
|
|
245
263
|
import path2 from "path";
|
|
264
|
+
import os from "os";
|
|
265
|
+
import { execFileSync } from "child_process";
|
|
246
266
|
import SwaggerParser from "@apidevtools/swagger-parser";
|
|
247
267
|
import { parse as parseYaml2 } from "yaml";
|
|
248
268
|
|
|
@@ -341,6 +361,87 @@ function toNormalizedSpec(loaded, normalized) {
|
|
|
341
361
|
}
|
|
342
362
|
|
|
343
363
|
// src/loaders/spec-loader.ts
|
|
364
|
+
function isPrivateHost(hostname) {
|
|
365
|
+
const h = hostname.toLowerCase().trim();
|
|
366
|
+
if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0" || h === "::") {
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
const ipv4Match = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
370
|
+
if (ipv4Match) {
|
|
371
|
+
const [b0, b1] = [Number(ipv4Match[1]), Number(ipv4Match[2])];
|
|
372
|
+
if (b0 === 127) return true;
|
|
373
|
+
if (b0 === 10) return true;
|
|
374
|
+
if (b0 === 172 && b1 >= 16 && b1 <= 31) return true;
|
|
375
|
+
if (b0 === 192 && b1 === 168) return true;
|
|
376
|
+
if (b0 === 169 && b1 === 254) return true;
|
|
377
|
+
if (b0 === 0) return true;
|
|
378
|
+
}
|
|
379
|
+
if (h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80:")) {
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
async function fetchRemoteSpec(urlStr, options = {}) {
|
|
385
|
+
let url;
|
|
386
|
+
try {
|
|
387
|
+
url = new URL(urlStr);
|
|
388
|
+
} catch (e) {
|
|
389
|
+
throw new SpecLoadError(`${urlStr}: invalid URL: ${errorMessage(e)}`);
|
|
390
|
+
}
|
|
391
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
392
|
+
throw new SpecLoadError(
|
|
393
|
+
`${urlStr}: unsupported protocol "${url.protocol}" (expected http: or https:)`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (!options.allowPrivateIps && isPrivateHost(url.hostname)) {
|
|
397
|
+
throw new SpecLoadError(
|
|
398
|
+
`${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.`
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
let text;
|
|
402
|
+
try {
|
|
403
|
+
const res = await fetch(urlStr, {
|
|
404
|
+
headers: options.headers ?? {},
|
|
405
|
+
signal: AbortSignal.timeout(15e3)
|
|
406
|
+
});
|
|
407
|
+
if (!res.ok) {
|
|
408
|
+
throw new SpecLoadError(
|
|
409
|
+
`${urlStr}: failed to fetch spec (HTTP ${res.status} ${res.statusText})`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
text = await res.text();
|
|
413
|
+
} catch (e) {
|
|
414
|
+
if (e instanceof SpecLoadError) throw e;
|
|
415
|
+
throw new SpecLoadError(`${urlStr}: network error fetching spec: ${errorMessage(e)}`);
|
|
416
|
+
}
|
|
417
|
+
const tmpDir = fs2.mkdtempSync(path2.join(os.tmpdir(), "apiguard-remote-"));
|
|
418
|
+
const ext = url.pathname.toLowerCase().endsWith(".json") ? ".json" : ".yaml";
|
|
419
|
+
const localPath = path2.join(tmpDir, "spec" + ext);
|
|
420
|
+
fs2.writeFileSync(localPath, text, "utf8");
|
|
421
|
+
return { localPath, text };
|
|
422
|
+
}
|
|
423
|
+
function fetchGitSpec(gitUri, cwd = process.cwd()) {
|
|
424
|
+
const stripped = gitUri.replace(/^git:\/\//i, "").replace(/^git:/i, "");
|
|
425
|
+
const colonIdx = stripped.indexOf(":");
|
|
426
|
+
if (colonIdx === -1) {
|
|
427
|
+
throw new SpecLoadError(`${gitUri}: invalid git spec URI (expected git://<rev>:<path>)`);
|
|
428
|
+
}
|
|
429
|
+
const rev = stripped.slice(0, colonIdx);
|
|
430
|
+
const relPath = stripped.slice(colonIdx + 1);
|
|
431
|
+
let text;
|
|
432
|
+
try {
|
|
433
|
+
text = execFileSync("git", ["show", `${rev}:${relPath}`], { cwd, encoding: "utf8" });
|
|
434
|
+
} catch (e) {
|
|
435
|
+
throw new SpecLoadError(
|
|
436
|
+
`${gitUri}: failed to retrieve spec from git (${rev}:${relPath}): ${errorMessage(e)}`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
const tmpDir = fs2.mkdtempSync(path2.join(os.tmpdir(), "apiguard-git-"));
|
|
440
|
+
const ext = relPath.toLowerCase().endsWith(".json") ? ".json" : ".yaml";
|
|
441
|
+
const localPath = path2.join(tmpDir, path2.basename(relPath) || "spec" + ext);
|
|
442
|
+
fs2.writeFileSync(localPath, text, "utf8");
|
|
443
|
+
return { localPath, text };
|
|
444
|
+
}
|
|
344
445
|
function parseSpecText(text, source) {
|
|
345
446
|
let parsed;
|
|
346
447
|
const isJsonByExt = source.trimEnd().toLowerCase().endsWith(".json");
|
|
@@ -418,12 +519,23 @@ function findExternalFileRefs(document, specDir) {
|
|
|
418
519
|
return found;
|
|
419
520
|
}
|
|
420
521
|
async function loadSpec(filePath, options = {}) {
|
|
421
|
-
|
|
522
|
+
let abs;
|
|
422
523
|
let text;
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
524
|
+
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
|
|
525
|
+
const fetched = await fetchRemoteSpec(filePath, options.loader);
|
|
526
|
+
abs = fetched.localPath;
|
|
527
|
+
text = fetched.text;
|
|
528
|
+
} else if (filePath.startsWith("git://") || filePath.startsWith("git:")) {
|
|
529
|
+
const fetched = fetchGitSpec(filePath, options.cwd);
|
|
530
|
+
abs = fetched.localPath;
|
|
531
|
+
text = fetched.text;
|
|
532
|
+
} else {
|
|
533
|
+
abs = path2.resolve(filePath);
|
|
534
|
+
try {
|
|
535
|
+
text = fs2.readFileSync(abs, "utf8");
|
|
536
|
+
} catch (e) {
|
|
537
|
+
throw new SpecLoadError(`${filePath}: cannot read file: ${errorMessage(e)}`);
|
|
538
|
+
}
|
|
427
539
|
}
|
|
428
540
|
if (text.trim() === "") {
|
|
429
541
|
throw new SpecLoadError(`${filePath}: file is empty \u2014 a spec must contain an OpenAPI document`);
|
|
@@ -468,7 +580,8 @@ async function loadSpec(filePath, options = {}) {
|
|
|
468
580
|
source: filePath,
|
|
469
581
|
openapiVersion: openapi,
|
|
470
582
|
title,
|
|
471
|
-
sha256: ""
|
|
583
|
+
sha256: "",
|
|
584
|
+
rawText: text
|
|
472
585
|
};
|
|
473
586
|
return toNormalizedSpec(loaded, normalizeDocument(document));
|
|
474
587
|
}
|
|
@@ -1509,6 +1622,9 @@ function getRule(id) {
|
|
|
1509
1622
|
if (!rule) throw new Error(`internal error: unknown rule id "${id}"`);
|
|
1510
1623
|
return rule;
|
|
1511
1624
|
}
|
|
1625
|
+
function getAllRules() {
|
|
1626
|
+
return [...RULES];
|
|
1627
|
+
}
|
|
1512
1628
|
function constraintText(c) {
|
|
1513
1629
|
const name = nameOf(c);
|
|
1514
1630
|
const oldV = c.oldValue === void 0 ? void 0 : JSON.stringify(c.oldValue);
|
|
@@ -1577,7 +1693,21 @@ function classify(ruleId, context, options, doctrine) {
|
|
|
1577
1693
|
"request-body-removed",
|
|
1578
1694
|
"removed-parameter"
|
|
1579
1695
|
]);
|
|
1580
|
-
|
|
1696
|
+
let baseSeverity = DOCTRINE_RULES.has(ruleId) ? resolveContextSeverity(ruleId, context, doctrine ?? {}) : rule.defaultSeverity;
|
|
1697
|
+
if (override === void 0 && options.profile) {
|
|
1698
|
+
if (options.profile === "strict-sdk") {
|
|
1699
|
+
if (ruleId === "enum-value-added") {
|
|
1700
|
+
baseSeverity = "error";
|
|
1701
|
+
} else if (ruleId === "anyOf-oneOf-member-added") {
|
|
1702
|
+
baseSeverity = "warning";
|
|
1703
|
+
}
|
|
1704
|
+
} else if (options.profile === "loose-web") {
|
|
1705
|
+
if (ruleId === "enum-value-added") {
|
|
1706
|
+
baseSeverity = "info";
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
const severity = override ?? baseSeverity;
|
|
1581
1711
|
return { severity, kind: rule.defaultKind };
|
|
1582
1712
|
}
|
|
1583
1713
|
|
|
@@ -2303,7 +2433,7 @@ function emit(ctx, opts) {
|
|
|
2303
2433
|
const resolved = classify(
|
|
2304
2434
|
opts.ruleId,
|
|
2305
2435
|
opts.location.context,
|
|
2306
|
-
{ rules: ctx.config.rules },
|
|
2436
|
+
{ rules: ctx.config.rules, profile: ctx.config.profile },
|
|
2307
2437
|
opts.doctrine
|
|
2308
2438
|
);
|
|
2309
2439
|
const rule = getRule(opts.ruleId);
|
|
@@ -3721,6 +3851,71 @@ function satisfiesAny(oldReq, newReqs) {
|
|
|
3721
3851
|
});
|
|
3722
3852
|
}
|
|
3723
3853
|
|
|
3854
|
+
// src/utils/source-map.ts
|
|
3855
|
+
import { parseDocument, LineCounter } from "yaml";
|
|
3856
|
+
var SpecSourceMap = class {
|
|
3857
|
+
doc;
|
|
3858
|
+
lineCounter;
|
|
3859
|
+
constructor(rawText) {
|
|
3860
|
+
this.lineCounter = new LineCounter();
|
|
3861
|
+
this.doc = parseDocument(rawText, { lineCounter: this.lineCounter });
|
|
3862
|
+
}
|
|
3863
|
+
/** Resolve 1-based line & column for a JSON pointer. */
|
|
3864
|
+
findPosition(pointer) {
|
|
3865
|
+
if (!pointer || pointer === "/") {
|
|
3866
|
+
return { line: 1, column: 1 };
|
|
3867
|
+
}
|
|
3868
|
+
const tokens = pointer.split("/").filter(Boolean).map((t) => t.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
3869
|
+
for (let i = tokens.length; i >= 1; i--) {
|
|
3870
|
+
const subTokens = tokens.slice(0, i);
|
|
3871
|
+
try {
|
|
3872
|
+
const node = this.doc.getIn(subTokens, true);
|
|
3873
|
+
if (node && Array.isArray(node.range) && typeof node.range[0] === "number") {
|
|
3874
|
+
const pos = this.lineCounter.linePos(node.range[0]);
|
|
3875
|
+
return { line: pos.line, column: pos.col };
|
|
3876
|
+
}
|
|
3877
|
+
} catch {
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
return void 0;
|
|
3881
|
+
}
|
|
3882
|
+
};
|
|
3883
|
+
function attachSourceLocations(changes, baselineSpec, currentSpec) {
|
|
3884
|
+
let currentMap;
|
|
3885
|
+
let baselineMap;
|
|
3886
|
+
if (currentSpec.rawText) {
|
|
3887
|
+
try {
|
|
3888
|
+
currentMap = new SpecSourceMap(currentSpec.rawText);
|
|
3889
|
+
} catch {
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
if (baselineSpec.rawText) {
|
|
3893
|
+
try {
|
|
3894
|
+
baselineMap = new SpecSourceMap(baselineSpec.rawText);
|
|
3895
|
+
} catch {
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
for (const c of changes) {
|
|
3899
|
+
if (currentMap && c.location.pointerNew) {
|
|
3900
|
+
const pos = currentMap.findPosition(c.location.pointerNew);
|
|
3901
|
+
if (pos) {
|
|
3902
|
+
c.location.file = currentSpec.source;
|
|
3903
|
+
c.location.line = pos.line;
|
|
3904
|
+
c.location.column = pos.column;
|
|
3905
|
+
continue;
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
if (baselineMap && c.location.pointerOld) {
|
|
3909
|
+
const pos = baselineMap.findPosition(c.location.pointerOld);
|
|
3910
|
+
if (pos) {
|
|
3911
|
+
c.location.file = baselineSpec.source;
|
|
3912
|
+
c.location.line = pos.line;
|
|
3913
|
+
c.location.column = pos.column;
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3724
3919
|
// src/core/ignore/ignore.ts
|
|
3725
3920
|
import micromatch from "micromatch";
|
|
3726
3921
|
function changePath(change) {
|
|
@@ -3797,8 +3992,8 @@ async function analyze(baselinePath, currentPath, options = {}) {
|
|
|
3797
3992
|
const loaded = options.config ? { config: options.config, sourcePath: "", warnings: [] } : discoverAndLoadConfig({ explicit: options.configPath });
|
|
3798
3993
|
const config = loaded.config;
|
|
3799
3994
|
const [baseline, current] = await Promise.all([
|
|
3800
|
-
loadSpec(baselinePath, { loader:
|
|
3801
|
-
loadSpec(currentPath, { loader:
|
|
3995
|
+
loadSpec(baselinePath, { loader: config.loader }),
|
|
3996
|
+
loadSpec(currentPath, { loader: config.loader })
|
|
3802
3997
|
]);
|
|
3803
3998
|
structuralDeref(baseline.normalized);
|
|
3804
3999
|
structuralDeref(current.normalized);
|
|
@@ -3812,6 +4007,7 @@ async function analyze(baselinePath, currentPath, options = {}) {
|
|
|
3812
4007
|
};
|
|
3813
4008
|
runStructuralDiff(ctx);
|
|
3814
4009
|
const deduped = dedupeChanges(sortChanges([...ctx.candidates]));
|
|
4010
|
+
attachSourceLocations(deduped, baseline, current);
|
|
3815
4011
|
const { kept, ignored } = applyIgnores(deduped, config);
|
|
3816
4012
|
const summary = summarize(kept, ignored.length);
|
|
3817
4013
|
const semver = suggestVersion(kept);
|
|
@@ -3830,6 +4026,802 @@ async function analyze(baselinePath, currentPath, options = {}) {
|
|
|
3830
4026
|
return { report, ignored };
|
|
3831
4027
|
}
|
|
3832
4028
|
|
|
4029
|
+
// src/reporters/html.ts
|
|
4030
|
+
function esc(s) {
|
|
4031
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4032
|
+
}
|
|
4033
|
+
function endpointOf(change) {
|
|
4034
|
+
const loc = change.location;
|
|
4035
|
+
if (loc.method && loc.path) return `${loc.method} ${loc.path}`;
|
|
4036
|
+
if (loc.path && loc.path !== "/") return loc.path;
|
|
4037
|
+
if (loc.context === "webhook") return `webhook ${loc.componentName ?? ""}`.trim();
|
|
4038
|
+
if (loc.componentName) return `component ${loc.componentName}`;
|
|
4039
|
+
return "API Root";
|
|
4040
|
+
}
|
|
4041
|
+
function valueRepr(v) {
|
|
4042
|
+
if (v === void 0) return "absent";
|
|
4043
|
+
if (v === null) return "null";
|
|
4044
|
+
if (typeof v === "string") return v;
|
|
4045
|
+
return JSON.stringify(v);
|
|
4046
|
+
}
|
|
4047
|
+
function renderHtml(report) {
|
|
4048
|
+
const breaking = report.summary.bySeverity.error;
|
|
4049
|
+
const warnings = report.summary.bySeverity.warning;
|
|
4050
|
+
const infos = report.summary.bySeverity.info;
|
|
4051
|
+
const total = report.summary.total;
|
|
4052
|
+
const isPass = breaking === 0;
|
|
4053
|
+
return `<!DOCTYPE html>
|
|
4054
|
+
<html lang="en">
|
|
4055
|
+
<head>
|
|
4056
|
+
<meta charset="utf-8">
|
|
4057
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4058
|
+
<title>API Guard Report - ${isPass ? "Passed" : "Breaking Changes Found"}</title>
|
|
4059
|
+
<style>
|
|
4060
|
+
/* Hallmark stamp: modern-minimal developer tool contract dashboard */
|
|
4061
|
+
:root {
|
|
4062
|
+
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
4063
|
+
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
4064
|
+
--bg-page: #f8fafc;
|
|
4065
|
+
--bg-card: #ffffff;
|
|
4066
|
+
--bg-input: #ffffff;
|
|
4067
|
+
--border-subtle: #e2e8f0;
|
|
4068
|
+
--border-strong: #cbd5e1;
|
|
4069
|
+
--text-main: #0f172a;
|
|
4070
|
+
--text-muted: #64748b;
|
|
4071
|
+
--color-pass: #059669;
|
|
4072
|
+
--color-pass-bg: #ecfdf5;
|
|
4073
|
+
--color-pass-border: #a7f3d0;
|
|
4074
|
+
--color-fail: #dc2626;
|
|
4075
|
+
--color-fail-bg: #fef2f2;
|
|
4076
|
+
--color-fail-border: #fecaca;
|
|
4077
|
+
--color-warn: #d97706;
|
|
4078
|
+
--color-warn-bg: #fffbeb;
|
|
4079
|
+
--color-warn-border: #fde68a;
|
|
4080
|
+
--color-info: #0284c7;
|
|
4081
|
+
--color-info-bg: #f0f9ff;
|
|
4082
|
+
--color-info-border: #bae6fd;
|
|
4083
|
+
--color-accent: #2563eb;
|
|
4084
|
+
--color-accent-bg: #eff6ff;
|
|
4085
|
+
--focus-ring: #3b82f6;
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
@media (prefers-color-scheme: dark) {
|
|
4089
|
+
:root {
|
|
4090
|
+
--bg-page: #090d16;
|
|
4091
|
+
--bg-card: #131b2e;
|
|
4092
|
+
--bg-input: #1a243b;
|
|
4093
|
+
--border-subtle: #1e293b;
|
|
4094
|
+
--border-strong: #334155;
|
|
4095
|
+
--text-main: #f1f5f9;
|
|
4096
|
+
--text-muted: #94a3b8;
|
|
4097
|
+
--color-pass: #10b981;
|
|
4098
|
+
--color-pass-bg: #064e3b33;
|
|
4099
|
+
--color-pass-border: #065f46;
|
|
4100
|
+
--color-fail: #f87171;
|
|
4101
|
+
--color-fail-bg: #7f1d1d33;
|
|
4102
|
+
--color-fail-border: #991b1b;
|
|
4103
|
+
--color-warn: #fbbf24;
|
|
4104
|
+
--color-warn-bg: #78350f33;
|
|
4105
|
+
--color-warn-border: #92400e;
|
|
4106
|
+
--color-info: #38bdf8;
|
|
4107
|
+
--color-info-bg: #0c4a6e33;
|
|
4108
|
+
--color-info-border: #075985;
|
|
4109
|
+
--color-accent: #60a5fa;
|
|
4110
|
+
--color-accent-bg: #1e3a8a33;
|
|
4111
|
+
--focus-ring: #60a5fa;
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
html, body {
|
|
4116
|
+
margin: 0;
|
|
4117
|
+
padding: 0;
|
|
4118
|
+
overflow-x: clip;
|
|
4119
|
+
background: var(--bg-page);
|
|
4120
|
+
color: var(--text-main);
|
|
4121
|
+
font-family: var(--font-body);
|
|
4122
|
+
line-height: 1.5;
|
|
4123
|
+
font-size: 15px;
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
* {
|
|
4127
|
+
box-sizing: border-box;
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
*:focus-visible {
|
|
4131
|
+
outline: 2px solid var(--focus-ring);
|
|
4132
|
+
outline-offset: 2px;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
.container {
|
|
4136
|
+
max-width: 1200px;
|
|
4137
|
+
margin: 0 auto;
|
|
4138
|
+
padding: 2.5rem 1.5rem 5rem 1.5rem;
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
.header {
|
|
4142
|
+
background: var(--bg-card);
|
|
4143
|
+
border: 1px solid var(--border-subtle);
|
|
4144
|
+
border-radius: 8px;
|
|
4145
|
+
padding: 1.75rem 2rem;
|
|
4146
|
+
margin-bottom: 2rem;
|
|
4147
|
+
}
|
|
4148
|
+
|
|
4149
|
+
.header-top {
|
|
4150
|
+
display: flex;
|
|
4151
|
+
justify-content: space-between;
|
|
4152
|
+
align-items: center;
|
|
4153
|
+
flex-wrap: wrap;
|
|
4154
|
+
gap: 1rem;
|
|
4155
|
+
margin-bottom: 1.25rem;
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
.title-row {
|
|
4159
|
+
display: flex;
|
|
4160
|
+
align-items: center;
|
|
4161
|
+
gap: 1rem;
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
.brand-title {
|
|
4165
|
+
font-size: 1.35rem;
|
|
4166
|
+
font-weight: 700;
|
|
4167
|
+
letter-spacing: -0.02em;
|
|
4168
|
+
margin: 0;
|
|
4169
|
+
}
|
|
4170
|
+
|
|
4171
|
+
.status-badge {
|
|
4172
|
+
display: inline-flex;
|
|
4173
|
+
align-items: center;
|
|
4174
|
+
font-size: 0.825rem;
|
|
4175
|
+
font-weight: 600;
|
|
4176
|
+
padding: 0.25rem 0.75rem;
|
|
4177
|
+
border-radius: 9999px;
|
|
4178
|
+
border: 1px solid;
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4181
|
+
.status-badge.pass {
|
|
4182
|
+
background: var(--color-pass-bg);
|
|
4183
|
+
color: var(--color-pass);
|
|
4184
|
+
border-color: var(--color-pass-border);
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
.status-badge.fail {
|
|
4188
|
+
background: var(--color-fail-bg);
|
|
4189
|
+
color: var(--color-fail);
|
|
4190
|
+
border-color: var(--color-fail-border);
|
|
4191
|
+
}
|
|
4192
|
+
|
|
4193
|
+
.spec-metadata {
|
|
4194
|
+
display: grid;
|
|
4195
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
4196
|
+
gap: 1.25rem;
|
|
4197
|
+
padding-top: 1.25rem;
|
|
4198
|
+
border-top: 1px solid var(--border-subtle);
|
|
4199
|
+
font-size: 0.875rem;
|
|
4200
|
+
color: var(--text-muted);
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
.meta-box strong {
|
|
4204
|
+
color: var(--text-main);
|
|
4205
|
+
display: block;
|
|
4206
|
+
margin-bottom: 0.2rem;
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
.meta-code {
|
|
4210
|
+
font-family: var(--font-mono);
|
|
4211
|
+
font-size: 0.8rem;
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4214
|
+
.stats-row {
|
|
4215
|
+
display: grid;
|
|
4216
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
4217
|
+
gap: 1rem;
|
|
4218
|
+
margin-bottom: 2rem;
|
|
4219
|
+
}
|
|
4220
|
+
|
|
4221
|
+
.stat-card {
|
|
4222
|
+
background: var(--bg-card);
|
|
4223
|
+
border: 1px solid var(--border-subtle);
|
|
4224
|
+
border-radius: 8px;
|
|
4225
|
+
padding: 1.25rem 1.5rem;
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
.stat-label {
|
|
4229
|
+
font-size: 0.8rem;
|
|
4230
|
+
color: var(--text-muted);
|
|
4231
|
+
margin-bottom: 0.35rem;
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
.stat-value {
|
|
4235
|
+
font-size: 1.75rem;
|
|
4236
|
+
font-weight: 700;
|
|
4237
|
+
line-height: 1;
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
.stat-card.error .stat-value { color: var(--color-fail); }
|
|
4241
|
+
.stat-card.warning .stat-value { color: var(--color-warn); }
|
|
4242
|
+
.stat-card.info .stat-value { color: var(--color-info); }
|
|
4243
|
+
.stat-card.total .stat-value { color: var(--text-main); }
|
|
4244
|
+
|
|
4245
|
+
.semver-banner {
|
|
4246
|
+
background: var(--color-accent-bg);
|
|
4247
|
+
border: 1px solid var(--border-subtle);
|
|
4248
|
+
border-left: 4px solid var(--color-accent);
|
|
4249
|
+
border-radius: 6px;
|
|
4250
|
+
padding: 1rem 1.25rem;
|
|
4251
|
+
margin-bottom: 2rem;
|
|
4252
|
+
font-size: 0.9rem;
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
.semver-banner strong {
|
|
4256
|
+
color: var(--text-main);
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
.controls-bar {
|
|
4260
|
+
display: flex;
|
|
4261
|
+
flex-wrap: wrap;
|
|
4262
|
+
gap: 1rem;
|
|
4263
|
+
align-items: center;
|
|
4264
|
+
justify-content: space-between;
|
|
4265
|
+
margin-bottom: 1.5rem;
|
|
4266
|
+
}
|
|
4267
|
+
|
|
4268
|
+
.search-input {
|
|
4269
|
+
flex: 1 1 260px;
|
|
4270
|
+
padding: 0.6rem 0.9rem;
|
|
4271
|
+
font-size: 0.9rem;
|
|
4272
|
+
border: 1px solid var(--border-strong);
|
|
4273
|
+
border-radius: 6px;
|
|
4274
|
+
background: var(--bg-input);
|
|
4275
|
+
color: var(--text-main);
|
|
4276
|
+
}
|
|
4277
|
+
|
|
4278
|
+
.filter-group {
|
|
4279
|
+
display: flex;
|
|
4280
|
+
flex-wrap: wrap;
|
|
4281
|
+
gap: 0.5rem;
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
.filter-btn {
|
|
4285
|
+
padding: 0.45rem 0.85rem;
|
|
4286
|
+
font-size: 0.825rem;
|
|
4287
|
+
font-weight: 500;
|
|
4288
|
+
border: 1px solid var(--border-strong);
|
|
4289
|
+
border-radius: 6px;
|
|
4290
|
+
background: var(--bg-card);
|
|
4291
|
+
color: var(--text-muted);
|
|
4292
|
+
cursor: pointer;
|
|
4293
|
+
}
|
|
4294
|
+
|
|
4295
|
+
.filter-btn.active {
|
|
4296
|
+
background: var(--color-accent);
|
|
4297
|
+
color: #ffffff;
|
|
4298
|
+
border-color: var(--color-accent);
|
|
4299
|
+
}
|
|
4300
|
+
|
|
4301
|
+
.changes-list {
|
|
4302
|
+
display: flex;
|
|
4303
|
+
flex-direction: column;
|
|
4304
|
+
gap: 1rem;
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
.change-card {
|
|
4308
|
+
background: var(--bg-card);
|
|
4309
|
+
border: 1px solid var(--border-subtle);
|
|
4310
|
+
border-radius: 8px;
|
|
4311
|
+
padding: 1.25rem 1.5rem;
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4314
|
+
.change-card.error { border-left: 4px solid var(--color-fail); }
|
|
4315
|
+
.change-card.warning { border-left: 4px solid var(--color-warn); }
|
|
4316
|
+
.change-card.info { border-left: 4px solid var(--color-info); }
|
|
4317
|
+
|
|
4318
|
+
.change-header {
|
|
4319
|
+
display: flex;
|
|
4320
|
+
justify-content: space-between;
|
|
4321
|
+
align-items: baseline;
|
|
4322
|
+
flex-wrap: wrap;
|
|
4323
|
+
gap: 0.5rem;
|
|
4324
|
+
margin-bottom: 0.75rem;
|
|
4325
|
+
}
|
|
4326
|
+
|
|
4327
|
+
.change-title-group {
|
|
4328
|
+
display: flex;
|
|
4329
|
+
align-items: center;
|
|
4330
|
+
flex-wrap: wrap;
|
|
4331
|
+
gap: 0.6rem;
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
.severity-tag {
|
|
4335
|
+
font-size: 0.725rem;
|
|
4336
|
+
font-weight: 700;
|
|
4337
|
+
padding: 0.15rem 0.5rem;
|
|
4338
|
+
border-radius: 4px;
|
|
4339
|
+
text-transform: uppercase;
|
|
4340
|
+
letter-spacing: 0.04em;
|
|
4341
|
+
}
|
|
4342
|
+
|
|
4343
|
+
.severity-tag.error { background: var(--color-fail-bg); color: var(--color-fail); border: 1px solid var(--color-fail-border); }
|
|
4344
|
+
.severity-tag.warning { background: var(--color-warn-bg); color: var(--color-warn); border: 1px solid var(--color-warn-border); }
|
|
4345
|
+
.severity-tag.info { background: var(--color-info-bg); color: var(--color-info); border: 1px solid var(--color-info-border); }
|
|
4346
|
+
|
|
4347
|
+
.rule-name {
|
|
4348
|
+
font-family: var(--font-mono);
|
|
4349
|
+
font-size: 0.85rem;
|
|
4350
|
+
font-weight: 600;
|
|
4351
|
+
color: var(--text-main);
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
.endpoint-pill {
|
|
4355
|
+
font-family: var(--font-mono);
|
|
4356
|
+
font-size: 0.825rem;
|
|
4357
|
+
color: var(--text-muted);
|
|
4358
|
+
background: var(--bg-page);
|
|
4359
|
+
border: 1px solid var(--border-subtle);
|
|
4360
|
+
padding: 0.15rem 0.5rem;
|
|
4361
|
+
border-radius: 4px;
|
|
4362
|
+
}
|
|
4363
|
+
|
|
4364
|
+
.location-info {
|
|
4365
|
+
font-family: var(--font-mono);
|
|
4366
|
+
font-size: 0.775rem;
|
|
4367
|
+
color: var(--text-muted);
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
.change-message {
|
|
4371
|
+
font-size: 0.95rem;
|
|
4372
|
+
margin: 0 0 0.75rem 0;
|
|
4373
|
+
color: var(--text-main);
|
|
4374
|
+
word-break: break-word;
|
|
4375
|
+
}
|
|
4376
|
+
|
|
4377
|
+
.suggestion-box {
|
|
4378
|
+
font-size: 0.85rem;
|
|
4379
|
+
color: var(--text-muted);
|
|
4380
|
+
background: var(--bg-page);
|
|
4381
|
+
border-radius: 6px;
|
|
4382
|
+
padding: 0.6rem 0.9rem;
|
|
4383
|
+
border: 1px solid var(--border-subtle);
|
|
4384
|
+
}
|
|
4385
|
+
|
|
4386
|
+
.suggestion-box strong {
|
|
4387
|
+
color: var(--text-main);
|
|
4388
|
+
}
|
|
4389
|
+
|
|
4390
|
+
.diff-values {
|
|
4391
|
+
margin-top: 0.75rem;
|
|
4392
|
+
display: flex;
|
|
4393
|
+
flex-wrap: wrap;
|
|
4394
|
+
gap: 1rem;
|
|
4395
|
+
font-family: var(--font-mono);
|
|
4396
|
+
font-size: 0.8rem;
|
|
4397
|
+
}
|
|
4398
|
+
|
|
4399
|
+
.val-block {
|
|
4400
|
+
padding: 0.35rem 0.65rem;
|
|
4401
|
+
border-radius: 4px;
|
|
4402
|
+
background: var(--bg-page);
|
|
4403
|
+
border: 1px solid var(--border-subtle);
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
.empty-state {
|
|
4407
|
+
text-align: center;
|
|
4408
|
+
padding: 4rem 2rem;
|
|
4409
|
+
background: var(--bg-card);
|
|
4410
|
+
border: 1px solid var(--border-subtle);
|
|
4411
|
+
border-radius: 8px;
|
|
4412
|
+
color: var(--text-muted);
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
@media (prefers-reduced-motion: reduce) {
|
|
4416
|
+
* {
|
|
4417
|
+
animation-duration: 0.01ms !important;
|
|
4418
|
+
transition-duration: 0.01ms !important;
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
</style>
|
|
4422
|
+
</head>
|
|
4423
|
+
<body>
|
|
4424
|
+
<div class="container">
|
|
4425
|
+
<header class="header">
|
|
4426
|
+
<div class="header-top">
|
|
4427
|
+
<div class="title-row">
|
|
4428
|
+
<h1 class="brand-title">API Guard Diff Report</h1>
|
|
4429
|
+
<span class="status-badge ${isPass ? "pass" : "fail"}">
|
|
4430
|
+
${isPass ? "PASS: No Breaking Changes" : "FAIL: Breaking Changes Detected"}
|
|
4431
|
+
</span>
|
|
4432
|
+
</div>
|
|
4433
|
+
<div style="font-size: 0.85rem; color: var(--text-muted);">
|
|
4434
|
+
Generated by apiguard ${esc(report.tool.version)}
|
|
4435
|
+
</div>
|
|
4436
|
+
</div>
|
|
4437
|
+
<div class="spec-metadata">
|
|
4438
|
+
<div class="meta-box">
|
|
4439
|
+
<strong>Baseline Specification</strong>
|
|
4440
|
+
<div>${esc(report.baseline.title || report.baseline.source)}</div>
|
|
4441
|
+
<div class="meta-code">SHA: ${esc(report.baseline.sha256.slice(0, 16))} (${esc(report.baseline.openapiVersion)})</div>
|
|
4442
|
+
</div>
|
|
4443
|
+
<div class="meta-box">
|
|
4444
|
+
<strong>Current Specification</strong>
|
|
4445
|
+
<div>${esc(report.current.title || report.current.source)}</div>
|
|
4446
|
+
<div class="meta-code">SHA: ${esc(report.current.sha256.slice(0, 16))} (${esc(report.current.openapiVersion)})</div>
|
|
4447
|
+
</div>
|
|
4448
|
+
</div>
|
|
4449
|
+
</header>
|
|
4450
|
+
|
|
4451
|
+
<section class="stats-row">
|
|
4452
|
+
<div class="stat-card error">
|
|
4453
|
+
<div class="stat-label">Breaking Errors</div>
|
|
4454
|
+
<div class="stat-value">${breaking}</div>
|
|
4455
|
+
</div>
|
|
4456
|
+
<div class="stat-card warning">
|
|
4457
|
+
<div class="stat-label">Warnings</div>
|
|
4458
|
+
<div class="stat-value">${warnings}</div>
|
|
4459
|
+
</div>
|
|
4460
|
+
<div class="stat-card info">
|
|
4461
|
+
<div class="stat-label">Informational</div>
|
|
4462
|
+
<div class="stat-value">${infos}</div>
|
|
4463
|
+
</div>
|
|
4464
|
+
<div class="stat-card total">
|
|
4465
|
+
<div class="stat-label">Total Changes</div>
|
|
4466
|
+
<div class="stat-value">${total}</div>
|
|
4467
|
+
</div>
|
|
4468
|
+
</section>
|
|
4469
|
+
|
|
4470
|
+
<div class="semver-banner">
|
|
4471
|
+
<strong>Suggested Version Bump:</strong> ${esc(report.semver.bump.toUpperCase())} — ${esc(report.semver.reason)}
|
|
4472
|
+
</div>
|
|
4473
|
+
|
|
4474
|
+
<section class="controls-bar">
|
|
4475
|
+
<input type="search" id="searchInput" class="search-input" placeholder="Search by rule, path, or message..." aria-label="Search changes">
|
|
4476
|
+
<div class="filter-group">
|
|
4477
|
+
<button class="filter-btn active" data-severity="all">All (${total})</button>
|
|
4478
|
+
<button class="filter-btn" data-severity="error">Errors (${breaking})</button>
|
|
4479
|
+
<button class="filter-btn" data-severity="warning">Warnings (${warnings})</button>
|
|
4480
|
+
<button class="filter-btn" data-severity="info">Info (${infos})</button>
|
|
4481
|
+
</div>
|
|
4482
|
+
</section>
|
|
4483
|
+
|
|
4484
|
+
<main class="changes-list" id="changesContainer">
|
|
4485
|
+
${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) => {
|
|
4486
|
+
const ep = endpointOf(c);
|
|
4487
|
+
const locStr = c.location.file && c.location.line ? `${c.location.file}:${c.location.line}:${c.location.column ?? 1}` : c.location.pointerNew || c.location.pointerOld;
|
|
4488
|
+
const hasDiff = c.oldValue !== void 0 || c.newValue !== void 0;
|
|
4489
|
+
return `
|
|
4490
|
+
<article class="change-card ${c.severity}" data-severity="${c.severity}" data-search="${esc(`${c.ruleId} ${ep} ${c.message} ${locStr}`).toLowerCase()}">
|
|
4491
|
+
<div class="change-header">
|
|
4492
|
+
<div class="change-title-group">
|
|
4493
|
+
<span class="severity-tag ${c.severity}">${c.severity}</span>
|
|
4494
|
+
<span class="rule-name">${esc(c.ruleId)}</span>
|
|
4495
|
+
<span class="endpoint-pill">${esc(ep)}</span>
|
|
4496
|
+
</div>
|
|
4497
|
+
<div class="location-info">${esc(locStr)}</div>
|
|
4498
|
+
</div>
|
|
4499
|
+
<p class="change-message">${esc(c.message)}</p>
|
|
4500
|
+
<div class="suggestion-box">
|
|
4501
|
+
<strong>Suggestion:</strong> ${esc(c.suggestion)}
|
|
4502
|
+
</div>
|
|
4503
|
+
${hasDiff ? `<div class="diff-values">
|
|
4504
|
+
${c.oldValue !== void 0 ? `<div class="val-block">Old: <code>${esc(valueRepr(c.oldValue))}</code></div>` : ""}
|
|
4505
|
+
${c.newValue !== void 0 ? `<div class="val-block">New: <code>${esc(valueRepr(c.newValue))}</code></div>` : ""}
|
|
4506
|
+
</div>` : ""}
|
|
4507
|
+
</article>`;
|
|
4508
|
+
}).join("\n")}
|
|
4509
|
+
</main>
|
|
4510
|
+
</div>
|
|
4511
|
+
|
|
4512
|
+
<script>
|
|
4513
|
+
(function() {
|
|
4514
|
+
const searchInput = document.getElementById('searchInput');
|
|
4515
|
+
const filterBtns = document.querySelectorAll('.filter-btn');
|
|
4516
|
+
const cards = document.querySelectorAll('.change-card');
|
|
4517
|
+
let currentSeverity = 'all';
|
|
4518
|
+
let currentQuery = '';
|
|
4519
|
+
|
|
4520
|
+
function applyFilters() {
|
|
4521
|
+
cards.forEach(card => {
|
|
4522
|
+
const sev = card.getAttribute('data-severity');
|
|
4523
|
+
const searchData = card.getAttribute('data-search') || '';
|
|
4524
|
+
const matchesSev = (currentSeverity === 'all' || sev === currentSeverity);
|
|
4525
|
+
const matchesQuery = (!currentQuery || searchData.includes(currentQuery));
|
|
4526
|
+
card.style.display = (matchesSev && matchesQuery) ? '' : 'none';
|
|
4527
|
+
});
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
if (searchInput) {
|
|
4531
|
+
searchInput.addEventListener('input', e => {
|
|
4532
|
+
currentQuery = e.target.value.trim().toLowerCase();
|
|
4533
|
+
applyFilters();
|
|
4534
|
+
});
|
|
4535
|
+
}
|
|
4536
|
+
|
|
4537
|
+
filterBtns.forEach(btn => {
|
|
4538
|
+
btn.addEventListener('click', () => {
|
|
4539
|
+
filterBtns.forEach(b => b.classList.remove('active'));
|
|
4540
|
+
btn.classList.add('active');
|
|
4541
|
+
currentSeverity = btn.getAttribute('data-severity') || 'all';
|
|
4542
|
+
applyFilters();
|
|
4543
|
+
});
|
|
4544
|
+
});
|
|
4545
|
+
})();
|
|
4546
|
+
</script>
|
|
4547
|
+
</body>
|
|
4548
|
+
</html>`;
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4551
|
+
// src/reporters/sarif.ts
|
|
4552
|
+
function sarifLevel(severity) {
|
|
4553
|
+
switch (severity) {
|
|
4554
|
+
case "error":
|
|
4555
|
+
return "error";
|
|
4556
|
+
case "warning":
|
|
4557
|
+
return "warning";
|
|
4558
|
+
case "info":
|
|
4559
|
+
return "note";
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
function renderSarif(report) {
|
|
4563
|
+
const rules = getAllRules().map((r) => ({
|
|
4564
|
+
id: r.id,
|
|
4565
|
+
shortDescription: { text: r.id },
|
|
4566
|
+
fullDescription: { text: r.trigger },
|
|
4567
|
+
defaultConfiguration: {
|
|
4568
|
+
level: sarifLevel(r.defaultSeverity)
|
|
4569
|
+
},
|
|
4570
|
+
help: {
|
|
4571
|
+
text: `${r.trigger}
|
|
4572
|
+
Suggestion: ${r.suggestion({ location: { path: "/", pointerOld: "", pointerNew: "", context: "api" } })}`,
|
|
4573
|
+
markdown: `**Trigger:** ${r.trigger}
|
|
4574
|
+
|
|
4575
|
+
[API Guard Rule Reference](https://github.com/subhamxaura/apiguard/blob/main/docs/rules.md#${r.id})`
|
|
4576
|
+
},
|
|
4577
|
+
properties: {
|
|
4578
|
+
tags: ["api-contract", "openapi", "breaking-change", ...r.contexts]
|
|
4579
|
+
}
|
|
4580
|
+
}));
|
|
4581
|
+
const results = report.changes.map((c) => {
|
|
4582
|
+
const fileUri = (c.location.file || report.current.source).replace(/\\/g, "/");
|
|
4583
|
+
const line = c.location.line && c.location.line > 0 ? c.location.line : 1;
|
|
4584
|
+
const col = c.location.column && c.location.column > 0 ? c.location.column : 1;
|
|
4585
|
+
return {
|
|
4586
|
+
ruleId: c.ruleId,
|
|
4587
|
+
level: sarifLevel(c.severity),
|
|
4588
|
+
message: {
|
|
4589
|
+
text: `${c.message}. Suggestion: ${c.suggestion}`
|
|
4590
|
+
},
|
|
4591
|
+
locations: [
|
|
4592
|
+
{
|
|
4593
|
+
physicalLocation: {
|
|
4594
|
+
artifactLocation: {
|
|
4595
|
+
uri: fileUri,
|
|
4596
|
+
uriBaseId: "%SRCROOT%"
|
|
4597
|
+
},
|
|
4598
|
+
region: {
|
|
4599
|
+
startLine: line,
|
|
4600
|
+
startColumn: col
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
],
|
|
4605
|
+
properties: {
|
|
4606
|
+
kind: c.kind,
|
|
4607
|
+
breaking: c.breaking,
|
|
4608
|
+
context: c.location.context,
|
|
4609
|
+
path: c.location.path,
|
|
4610
|
+
method: c.location.method
|
|
4611
|
+
}
|
|
4612
|
+
};
|
|
4613
|
+
});
|
|
4614
|
+
const sarifLog = {
|
|
4615
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
4616
|
+
version: "2.1.0",
|
|
4617
|
+
runs: [
|
|
4618
|
+
{
|
|
4619
|
+
tool: {
|
|
4620
|
+
driver: {
|
|
4621
|
+
name: "apiguard",
|
|
4622
|
+
version: report.tool.version,
|
|
4623
|
+
informationUri: "https://github.com/subhamxaura/apiguard",
|
|
4624
|
+
rules
|
|
4625
|
+
}
|
|
4626
|
+
},
|
|
4627
|
+
results
|
|
4628
|
+
}
|
|
4629
|
+
]
|
|
4630
|
+
};
|
|
4631
|
+
return JSON.stringify(sarifLog, null, 2);
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
// src/reporters/changelog.ts
|
|
4635
|
+
function endpointOf2(change) {
|
|
4636
|
+
const loc = change.location;
|
|
4637
|
+
if (loc.method && loc.path) return `\`${loc.method} ${loc.path}\``;
|
|
4638
|
+
if (loc.path && loc.path !== "/") return `\`${loc.path}\``;
|
|
4639
|
+
if (loc.componentName) return `component \`${loc.componentName}\``;
|
|
4640
|
+
return "API";
|
|
4641
|
+
}
|
|
4642
|
+
function renderChangelog(report) {
|
|
4643
|
+
const lines = [];
|
|
4644
|
+
const version = report.semver.bump === "none" ? "Current Release (No version bump)" : `Next Release (Recommended bump: ${report.semver.bump.toUpperCase()})`;
|
|
4645
|
+
lines.push(`## ${version}`);
|
|
4646
|
+
lines.push("");
|
|
4647
|
+
const breaking = report.changes.filter((c) => c.severity === "error");
|
|
4648
|
+
const additions = report.changes.filter((c) => c.severity !== "error" && c.kind === "addition");
|
|
4649
|
+
const modifications = report.changes.filter(
|
|
4650
|
+
(c) => c.severity !== "error" && c.kind === "modification"
|
|
4651
|
+
);
|
|
4652
|
+
const relaxations = report.changes.filter(
|
|
4653
|
+
(c) => c.severity !== "error" && (c.kind === "relaxation" || c.kind === "documentation")
|
|
4654
|
+
);
|
|
4655
|
+
if (breaking.length > 0) {
|
|
4656
|
+
lines.push("### \u{1F6D1} Breaking Changes");
|
|
4657
|
+
lines.push("");
|
|
4658
|
+
for (const c of breaking) {
|
|
4659
|
+
lines.push(`- **${endpointOf2(c)}**: ${c.message} (\`${c.ruleId}\`)`);
|
|
4660
|
+
lines.push(` *Suggestion: ${c.suggestion}*`);
|
|
4661
|
+
}
|
|
4662
|
+
lines.push("");
|
|
4663
|
+
}
|
|
4664
|
+
if (additions.length > 0) {
|
|
4665
|
+
lines.push("### \u{1F680} Added");
|
|
4666
|
+
lines.push("");
|
|
4667
|
+
for (const c of additions) {
|
|
4668
|
+
lines.push(`- **${endpointOf2(c)}**: ${c.message}`);
|
|
4669
|
+
}
|
|
4670
|
+
lines.push("");
|
|
4671
|
+
}
|
|
4672
|
+
if (modifications.length > 0) {
|
|
4673
|
+
lines.push("### \u{1F504} Changed");
|
|
4674
|
+
lines.push("");
|
|
4675
|
+
for (const c of modifications) {
|
|
4676
|
+
lines.push(`- **${endpointOf2(c)}**: ${c.message}`);
|
|
4677
|
+
}
|
|
4678
|
+
lines.push("");
|
|
4679
|
+
}
|
|
4680
|
+
if (relaxations.length > 0) {
|
|
4681
|
+
lines.push("### \u2139\uFE0F Other Improvements");
|
|
4682
|
+
lines.push("");
|
|
4683
|
+
for (const c of relaxations) {
|
|
4684
|
+
lines.push(`- **${endpointOf2(c)}**: ${c.message}`);
|
|
4685
|
+
}
|
|
4686
|
+
lines.push("");
|
|
4687
|
+
}
|
|
4688
|
+
if (report.changes.length === 0) {
|
|
4689
|
+
lines.push("No functional contract changes detected between specifications.");
|
|
4690
|
+
}
|
|
4691
|
+
return lines.join("\n").trim();
|
|
4692
|
+
}
|
|
4693
|
+
|
|
4694
|
+
// src/codegen/shim.ts
|
|
4695
|
+
function generateExpressShim(report) {
|
|
4696
|
+
const breaking = report.changes.filter((c) => c.severity === "error");
|
|
4697
|
+
const pathRewrites = [];
|
|
4698
|
+
const paramDefaults = [];
|
|
4699
|
+
for (const c of breaking) {
|
|
4700
|
+
if (c.ruleId === "operation-removed" && c.location.path) {
|
|
4701
|
+
pathRewrites.push({ oldPath: c.location.path });
|
|
4702
|
+
} else if (c.ruleId === "parameter-added-required" || c.ruleId === "parameter-made-required") {
|
|
4703
|
+
const paramName = c.location.pointerNew.split("/").pop() || "param";
|
|
4704
|
+
paramDefaults.push({
|
|
4705
|
+
method: c.location.method,
|
|
4706
|
+
path: c.location.path,
|
|
4707
|
+
param: paramName,
|
|
4708
|
+
defaultVal: c.newValue ?? ""
|
|
4709
|
+
});
|
|
4710
|
+
}
|
|
4711
|
+
}
|
|
4712
|
+
return `// Generated by API Guard (apiguard codegen-shim --target express)
|
|
4713
|
+
// Backward compatibility shim for baseline @ ${report.baseline.sha256.slice(0, 12)}
|
|
4714
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
4715
|
+
|
|
4716
|
+
export function apiGuardCompatibilityShim(req: Request, res: Response, next: NextFunction) {
|
|
4717
|
+
const method = req.method.toUpperCase();
|
|
4718
|
+
const path = req.path;
|
|
4719
|
+
|
|
4720
|
+
// 1. Parameter defaults for newly required parameters
|
|
4721
|
+
${paramDefaults.map(
|
|
4722
|
+
(p) => ` if (${p.method ? `method === '${p.method}' && ` : ""}path.startsWith('${p.path.replace(/\{.*\}/, "")}')) {
|
|
4723
|
+
if (req.query['${p.param}'] === undefined && req.body?.['${p.param}'] === undefined) {
|
|
4724
|
+
req.query['${p.param}'] = '${String(p.defaultVal)}';
|
|
4725
|
+
}
|
|
4726
|
+
}`
|
|
4727
|
+
).join("\n")}
|
|
4728
|
+
|
|
4729
|
+
// 2. Response adapter interception
|
|
4730
|
+
const originalJson = res.json;
|
|
4731
|
+
res.json = function(body: any) {
|
|
4732
|
+
// Intercept and maintain legacy schema structure
|
|
4733
|
+
if (body && typeof body === 'object') {
|
|
4734
|
+
// Compatibility mutations can be inserted here
|
|
4735
|
+
}
|
|
4736
|
+
return originalJson.call(this, body);
|
|
4737
|
+
};
|
|
4738
|
+
|
|
4739
|
+
next();
|
|
4740
|
+
}
|
|
4741
|
+
`;
|
|
4742
|
+
}
|
|
4743
|
+
function generateCloudflareWorkerShim(report) {
|
|
4744
|
+
const breaking = report.changes.filter((c) => c.severity === "error");
|
|
4745
|
+
return `// Generated by API Guard (apiguard codegen-shim --target cloudflare-worker)
|
|
4746
|
+
// Edge backward-compatibility worker
|
|
4747
|
+
export default {
|
|
4748
|
+
async fetch(request: Request, env: any, ctx: any): Promise<Response> {
|
|
4749
|
+
const url = new URL(request.url);
|
|
4750
|
+
const method = request.method.toUpperCase();
|
|
4751
|
+
|
|
4752
|
+
// Clone request headers for mutation
|
|
4753
|
+
const modifiedHeaders = new Headers(request.headers);
|
|
4754
|
+
|
|
4755
|
+
// Track downstream upstream response
|
|
4756
|
+
const response = await fetch(new Request(url.toString(), {
|
|
4757
|
+
method: request.method,
|
|
4758
|
+
headers: modifiedHeaders,
|
|
4759
|
+
body: request.body,
|
|
4760
|
+
redirect: 'manual'
|
|
4761
|
+
}));
|
|
4762
|
+
|
|
4763
|
+
// Intercept JSON responses for schema backward compatibility
|
|
4764
|
+
const contentType = response.headers.get('content-type') || '';
|
|
4765
|
+
if (contentType.includes('application/json')) {
|
|
4766
|
+
try {
|
|
4767
|
+
const data = await response.json();
|
|
4768
|
+
// ${breaking.length} breaking changes monitored
|
|
4769
|
+
return new Response(JSON.stringify(data), {
|
|
4770
|
+
status: response.status,
|
|
4771
|
+
headers: response.headers
|
|
4772
|
+
});
|
|
4773
|
+
} catch {
|
|
4774
|
+
// Pass through unmodified if not json
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
return response;
|
|
4779
|
+
}
|
|
4780
|
+
};
|
|
4781
|
+
`;
|
|
4782
|
+
}
|
|
4783
|
+
function generateEnvoyLuaShim(report) {
|
|
4784
|
+
return `-- Generated by API Guard (apiguard codegen-shim --target envoy-lua)
|
|
4785
|
+
-- Envoy Proxy Lua filter for API backward compatibility (baseline: ${report.baseline.sha256.slice(0, 12)})
|
|
4786
|
+
|
|
4787
|
+
function envoy_on_request(request_handle)
|
|
4788
|
+
local headers = request_handle:headers()
|
|
4789
|
+
local path = headers:get(":path")
|
|
4790
|
+
local method = headers:get(":method")
|
|
4791
|
+
|
|
4792
|
+
-- Check for API compatibility headers
|
|
4793
|
+
if not headers:get("x-apiguard-shimmed") then
|
|
4794
|
+
headers:add("x-apiguard-shimmed", "true")
|
|
4795
|
+
end
|
|
4796
|
+
end
|
|
4797
|
+
|
|
4798
|
+
function envoy_on_response(response_handle)
|
|
4799
|
+
local headers = response_handle:headers()
|
|
4800
|
+
headers:add("x-apiguard-adapter", "v1-compat")
|
|
4801
|
+
end
|
|
4802
|
+
`;
|
|
4803
|
+
}
|
|
4804
|
+
function generateGatewayShim(report, target = "express") {
|
|
4805
|
+
const breaking = report.changes.filter((c) => c.severity === "error");
|
|
4806
|
+
let code;
|
|
4807
|
+
switch (target) {
|
|
4808
|
+
case "express":
|
|
4809
|
+
code = generateExpressShim(report);
|
|
4810
|
+
break;
|
|
4811
|
+
case "cloudflare-worker":
|
|
4812
|
+
code = generateCloudflareWorkerShim(report);
|
|
4813
|
+
break;
|
|
4814
|
+
case "envoy-lua":
|
|
4815
|
+
code = generateEnvoyLuaShim(report);
|
|
4816
|
+
break;
|
|
4817
|
+
}
|
|
4818
|
+
return {
|
|
4819
|
+
target,
|
|
4820
|
+
code,
|
|
4821
|
+
handledChanges: breaking.length
|
|
4822
|
+
};
|
|
4823
|
+
}
|
|
4824
|
+
|
|
3833
4825
|
export {
|
|
3834
4826
|
ExitCode,
|
|
3835
4827
|
ApiguardError,
|
|
@@ -3844,5 +4836,9 @@ export {
|
|
|
3844
4836
|
validateSemantics,
|
|
3845
4837
|
formatIssues,
|
|
3846
4838
|
suggestVersion,
|
|
3847
|
-
analyze
|
|
4839
|
+
analyze,
|
|
4840
|
+
renderHtml,
|
|
4841
|
+
renderSarif,
|
|
4842
|
+
renderChangelog,
|
|
4843
|
+
generateGatewayShim
|
|
3848
4844
|
};
|