@subhamxcod/apiguard 1.0.2

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.
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ApiguardError,
4
+ CliUsageError,
5
+ ExitCode,
6
+ SpecValidationError,
7
+ TOOL_VERSION,
8
+ analyze,
9
+ discoverAndLoadConfig,
10
+ errorMessage,
11
+ formatIssues,
12
+ loadSpec,
13
+ mergeWithFlags,
14
+ validateSemantics
15
+ } from "../chunk-RQVNHPHC.js";
16
+
17
+ // src/cli/run.ts
18
+ import { Command } from "commander";
19
+
20
+ // src/cli/exit-codes.ts
21
+ function isCommanderError(err) {
22
+ return err instanceof Error && typeof err.name === "string" && err.name === "CommanderError";
23
+ }
24
+ function exitCodeFor(err) {
25
+ if (isCommanderError(err)) {
26
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.version") {
27
+ return ExitCode.Ok;
28
+ }
29
+ return err.exitCode > 0 ? err.exitCode : ExitCode.Usage;
30
+ }
31
+ if (err instanceof ApiguardError) return err.exitCode;
32
+ return ExitCode.Internal;
33
+ }
34
+ function renderError(err, verbose = false) {
35
+ if (isCommanderError(err)) {
36
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.version") return "";
37
+ return `error: ${err.message}`;
38
+ }
39
+ if (err instanceof ApiguardError) {
40
+ const hint = err.exitCode === ExitCode.Internal ? " (please report this bug)" : "";
41
+ return `error: ${err.message}${hint}${verbose && err.stack ? "\n" + err.stack : ""}`;
42
+ }
43
+ return `error: unexpected internal error: ${errorMessage(err)} (please report this bug)`;
44
+ }
45
+
46
+ // src/cli/diff-command.ts
47
+ import fs from "fs";
48
+
49
+ // src/reporters/terminal.ts
50
+ var MARKER = { error: "ERROR", warning: "WARN", info: "INFO" };
51
+ var ANSI = {
52
+ red: (s) => `\x1B[31m${s}\x1B[0m`,
53
+ yellow: (s) => `\x1B[33m${s}\x1B[0m`,
54
+ cyan: (s) => `\x1B[36m${s}\x1B[0m`,
55
+ bold: (s) => `\x1B[1m${s}\x1B[0m`,
56
+ green: (s) => `\x1B[32m${s}\x1B[0m`
57
+ };
58
+ function markerFor(sev, color) {
59
+ const marker = MARKER[sev].padEnd(5);
60
+ if (!color) return marker;
61
+ if (sev === "error") return ANSI.red(marker);
62
+ if (sev === "warning") return ANSI.yellow(marker);
63
+ return ANSI.cyan(marker);
64
+ }
65
+ function groupKey(change) {
66
+ const loc = change.location;
67
+ if (loc.method) return `${loc.method} ${loc.path}`;
68
+ if (loc.path && loc.path !== "/") return loc.path;
69
+ if (loc.context === "webhook") return `webhook ${loc.componentName ?? ""}`.trim();
70
+ if (loc.componentName) return `component ${loc.componentName}`;
71
+ return "API";
72
+ }
73
+ function renderTerminal(report, options) {
74
+ const lines = [];
75
+ const breakingCount = report.summary.bySeverity.error;
76
+ const infoCount = report.summary.bySeverity.info + report.summary.bySeverity.warning;
77
+ const renderable = report.changes.filter((c) => {
78
+ if (c.severity === "error") return true;
79
+ return options.includeNonBreaking;
80
+ });
81
+ if (!options.quiet) {
82
+ if (renderable.length > 0) {
83
+ const groups = /* @__PURE__ */ new Map();
84
+ for (const c of renderable) {
85
+ const key = groupKey(c);
86
+ const arr = groups.get(key);
87
+ if (arr) arr.push(c);
88
+ else groups.set(key, [c]);
89
+ }
90
+ for (const [key, changes] of groups) {
91
+ lines.push(key);
92
+ for (const c of changes) {
93
+ const marker = markerFor(c.severity, options.color);
94
+ lines.push(` ${marker} ${c.message}`);
95
+ lines.push(` Suggestion: ${c.suggestion}`);
96
+ }
97
+ lines.push("");
98
+ }
99
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
100
+ }
101
+ if (options.showIgnored && report.ignored && report.ignored.length > 0) {
102
+ if (lines.length > 0) lines.push("");
103
+ lines.push("Ignored changes:");
104
+ for (const c of report.ignored) {
105
+ const marker = markerFor(c.severity, options.color);
106
+ lines.push(` ${marker} [ignored] ${c.message}`);
107
+ lines.push(` Suggestion: ${c.suggestion}`);
108
+ }
109
+ }
110
+ }
111
+ if (breakingCount > 0) {
112
+ const verdict = "\u274C Breaking API changes found";
113
+ lines.push(options.color ? ANSI.red(ANSI.bold(verdict)) : verdict);
114
+ } else {
115
+ const suffix = infoCount > 0 ? ` (${infoCount} informational change${infoCount === 1 ? "" : "s"})` : "";
116
+ const verdict = `\u2713 No breaking API changes found${suffix}`;
117
+ lines.push(options.color ? ANSI.green(verdict) : verdict);
118
+ }
119
+ return lines;
120
+ }
121
+ function suggestLine(report) {
122
+ const bump = report.semver.bump;
123
+ if (bump === "none") return null;
124
+ const label = bump === "major" ? "MAJOR" : bump === "minor" ? "MINOR" : "PATCH";
125
+ return `Suggested version bump: ${label}`;
126
+ }
127
+
128
+ // src/reporters/json.ts
129
+ function renderJson(report) {
130
+ return JSON.stringify(report, null, 2);
131
+ }
132
+
133
+ // src/cli/diff-command.ts
134
+ function resolveFormat(opts) {
135
+ if (opts.json) return "json";
136
+ if (opts.markdown) return "markdown";
137
+ if (opts.format !== void 0) {
138
+ if (opts.format === "terminal" || opts.format === "json" || opts.format === "markdown") {
139
+ return opts.format;
140
+ }
141
+ throw new CliUsageError(`invalid --format "${opts.format}" (expected terminal|json|markdown)`);
142
+ }
143
+ const envFormat = process.env.APIGUARD_FORMAT;
144
+ if (envFormat === "json" || envFormat === "markdown" || envFormat === "terminal")
145
+ return envFormat;
146
+ return "terminal";
147
+ }
148
+ function resolveFailOn(opts) {
149
+ const raw = opts.strict ? "warning" : opts.failOn ?? process.env.APIGUARD_FAIL_ON ?? "error";
150
+ if (raw === "error" || raw === "warning" || raw === "never") return raw;
151
+ throw new CliUsageError(`invalid --fail-on "${raw}" (expected error|warning|never)`);
152
+ }
153
+ function colorEnabled(opts, configColor) {
154
+ if (opts.color === false) return false;
155
+ if (process.env.APIGUARD_NO_COLOR !== void 0 || process.env.NO_COLOR !== void 0)
156
+ return false;
157
+ if (configColor === "always") return true;
158
+ if (configColor === "never") return false;
159
+ return process.stdout.isTTY === true;
160
+ }
161
+ async function runDiff(oldSpec, newSpec, opts, io) {
162
+ if (opts.verbose) io.stderr(`verbose: loading baseline ${oldSpec} and current ${newSpec}`);
163
+ const loaded = discoverAndLoadConfig({ explicit: opts.config, env: io.env.APIGUARD_CONFIG });
164
+ for (const w of loaded.warnings) io.stderr(`warning: ${w}`);
165
+ const config = mergeWithFlags(loaded.config, {
166
+ format: resolveFormat(opts),
167
+ failOn: resolveFailOn(opts),
168
+ includeNonBreaking: opts.strict || opts.includeNonBreaking,
169
+ suggestVersion: opts.suggestVersion,
170
+ noColor: !colorEnabled(opts, loaded.config.output.color)
171
+ });
172
+ const { report } = await analyze(oldSpec, newSpec, {
173
+ config,
174
+ showIgnored: Boolean(opts.showIgnored) || loaded.config.ignore.showIgnored === true
175
+ });
176
+ let body;
177
+ const wantBump = !opts.quiet && (config.versioning.suggest || opts.suggestVersion === true);
178
+ if (opts.quiet) {
179
+ body = report.summary.bySeverity.error > 0 ? "\u274C Breaking API changes found" : "\u2713 No breaking API changes found";
180
+ } else if (config.output.format === "json") {
181
+ body = renderJson(report);
182
+ } else if (config.output.format === "markdown") {
183
+ const { renderMarkdown } = await import("../markdown-J2EC3VFS.js");
184
+ body = renderMarkdown(report);
185
+ } else {
186
+ const lines = renderTerminal(report, {
187
+ includeNonBreaking: config.output.includeNonBreaking,
188
+ showIgnored: Boolean(opts.showIgnored) || loaded.config.ignore.showIgnored === true,
189
+ color: config.output.color === "always" || config.output.color === "auto" && process.stdout.isTTY === true,
190
+ quiet: Boolean(opts.quiet)
191
+ });
192
+ body = lines.join("\n");
193
+ const bump = wantBump ? suggestLine(report) : null;
194
+ if (bump) body += "\n\n" + bump;
195
+ }
196
+ io.stdout(body);
197
+ if (opts.output) {
198
+ fs.writeFileSync(opts.output, body + "\n", "utf8");
199
+ if (!opts.quiet) io.stderr(`report written to ${opts.output}`);
200
+ }
201
+ if (config.failOn === "never") return ExitCode.Ok;
202
+ const threshold = config.failOn === "error" ? "error" : "warning";
203
+ const hits = threshold === "error" ? report.summary.bySeverity.error > 0 : report.summary.bySeverity.error + report.summary.bySeverity.warning > 0;
204
+ return hits ? ExitCode.BreakingChanges : ExitCode.Ok;
205
+ }
206
+
207
+ // src/cli/validate-command.ts
208
+ async function runValidate(spec, opts, io) {
209
+ if (opts.verbose) io.stderr(`verbose: loading ${spec}`);
210
+ const loaded = await loadSpec(spec);
211
+ const result = validateSemantics(loaded.document, spec);
212
+ if (result.issues.length > 0) {
213
+ for (const line of formatIssues(result.issues)) io.stdout(line);
214
+ }
215
+ const structuralErrors = result.issues.some((i) => i.level === "error");
216
+ if (structuralErrors) {
217
+ const first = result.issues.find((i) => i.level === "error");
218
+ throw new SpecValidationError(`${spec}: semantic validation failed: ${first?.message ?? ""}`);
219
+ }
220
+ const warnings = result.issues.filter((i) => i.level === "warning");
221
+ if (opts.strict && warnings.length > 0) {
222
+ io.stdout(`\u2717 ${warnings.length} semantic warning(s)`);
223
+ return { exitCode: 1 };
224
+ }
225
+ io.stdout(`\u2713 ${spec} is valid${warnings.length > 0 ? ` (${warnings.length} warning(s))` : ""}`);
226
+ return { exitCode: 0 };
227
+ }
228
+
229
+ // src/cli/init-command.ts
230
+ import fs2 from "fs";
231
+ import path from "path";
232
+
233
+ // src/core/config/template.ts
234
+ var CONFIG_TEMPLATE = `# API Guard configuration
235
+ # Docs: https://github.com/apiguard/apiguard/blob/main/docs/configuration.md
236
+ schemaVersion: 1 # required; other versions are rejected with exit 2
237
+ rules: # optional; values: error|warning|info|off; key = ruleId, or
238
+ removed-path: error # ruleId@context-class to override one context (see docs/breaking-changes.md)
239
+ removed-method: error
240
+ property-removed@request-body: warning
241
+ property-removed@response-body: error
242
+ required-property-added: error
243
+ property-type-changed: error
244
+ enum-value-removed: error
245
+ ignore: # optional; globs via micromatch; applied after classification
246
+ paths: [] # matches path templates, all methods, e.g. ["/internal/**", "/metrics"]
247
+ operations: [] # exact "METHOD /path" entries, e.g. ["GET /health", "POST /users"]
248
+ schemas: [] # component names / pointer prefixes, e.g. ["InternalModel", "components/schemas/V1*"]
249
+ changes: [] # ruleId globs, e.g. ["operation-id-changed", "server-*"]
250
+ show-ignored: false # if true: records appear flagged, excluded from counts+exit
251
+ output:
252
+ format: terminal # terminal|json|markdown
253
+ include-non-breaking: false
254
+ color: auto # auto|always|never
255
+ fail-on: error # error|warning|never
256
+ versioning:
257
+ suggest: false # print bump suggestion after every diff (same as --suggest-version)
258
+ loader:
259
+ allow-remote-refs: false
260
+ github: # read only by the GitHub Action, ignored by the CLI
261
+ comment: true
262
+ check-run: true
263
+ update-existing-comment: true
264
+ `;
265
+
266
+ // src/cli/init-command.ts
267
+ var EXAMPLE_BASELINE = `openapi: 3.0.3
268
+ info:
269
+ title: Sample API
270
+ version: 1.0.0
271
+ paths:
272
+ /users:
273
+ get:
274
+ operationId: listUsers
275
+ responses:
276
+ '200':
277
+ description: OK
278
+ content:
279
+ application/json:
280
+ schema:
281
+ type: object
282
+ properties:
283
+ id:
284
+ type: string
285
+ name:
286
+ type: string
287
+ `;
288
+ var EXAMPLE_CURRENT = `openapi: 3.0.3
289
+ info:
290
+ title: Sample API
291
+ version: 1.1.0
292
+ paths:
293
+ /users:
294
+ get:
295
+ operationId: listUsers
296
+ responses:
297
+ '200':
298
+ description: OK
299
+ content:
300
+ application/json:
301
+ schema:
302
+ type: object
303
+ properties:
304
+ id:
305
+ type: string
306
+ name:
307
+ type: string
308
+ email:
309
+ type: string
310
+ post:
311
+ operationId: createUser
312
+ requestBody:
313
+ required: true
314
+ content:
315
+ application/json:
316
+ schema:
317
+ type: object
318
+ required: [email]
319
+ properties:
320
+ email:
321
+ type: string
322
+ responses:
323
+ '201':
324
+ description: Created
325
+ `;
326
+ function filesFor(_dir, withExamples) {
327
+ const files = [{ relPath: "apiguard.yaml", content: CONFIG_TEMPLATE }];
328
+ if (withExamples) {
329
+ files.push({ relPath: path.join("examples", "baseline.yaml"), content: EXAMPLE_BASELINE });
330
+ files.push({ relPath: path.join("examples", "current.yaml"), content: EXAMPLE_CURRENT });
331
+ }
332
+ return files;
333
+ }
334
+ async function runInit(dir, opts, io) {
335
+ const target = path.resolve(dir);
336
+ const planned = filesFor(target, opts.examples === true);
337
+ const conflicts = planned.filter((f) => fs2.existsSync(path.join(target, f.relPath)));
338
+ if (conflicts.length > 0 && !opts.force) {
339
+ const lines = conflicts.map(
340
+ (f) => ` would write ${f.relPath} (${f.content.split("\n").length} lines)`
341
+ );
342
+ throw new CliUsageError(
343
+ `refusing to overwrite existing file(s):
344
+ ${lines.join("\n")}
345
+ use --force to overwrite`
346
+ );
347
+ }
348
+ for (const f of planned) {
349
+ const abs = path.join(target, f.relPath);
350
+ fs2.mkdirSync(path.dirname(abs), { recursive: true });
351
+ fs2.writeFileSync(abs, f.content, "utf8");
352
+ io.stdout(`wrote ${f.relPath}`);
353
+ }
354
+ io.stdout('next: run "apiguard diff examples/baseline.yaml examples/current.yaml"');
355
+ }
356
+
357
+ // src/cli/run.ts
358
+ var defaultIo = {
359
+ stdout: (s) => process.stdout.write(s + "\n"),
360
+ stderr: (s) => process.stderr.write(s + "\n"),
361
+ argv: process.argv,
362
+ env: process.env
363
+ };
364
+ function buildProgram(io) {
365
+ const program = new Command();
366
+ program.exitOverride();
367
+ program.configureOutput({
368
+ writeOut: (s) => io.stdout(s.trimEnd()),
369
+ writeErr: (s) => io.stderr(s.trimEnd())
370
+ });
371
+ program.name("apiguard").version(TOOL_VERSION).description("Detect breaking changes between OpenAPI specifications");
372
+ 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("--format <f>", "terminal|json|markdown (aliases: --json, --markdown)").option("--json", "shorthand for --format json").option("--markdown", "shorthand for --format markdown").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) => {
373
+ const code = await runDiff(oldSpec, newSpec, opts, io);
374
+ if (code !== ExitCode.Ok) {
375
+ process.exitCode = code;
376
+ program.__apiguardExitCode = code;
377
+ }
378
+ });
379
+ void diff;
380
+ 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) => {
381
+ const outcome = await runValidate(spec, opts, io);
382
+ if (outcome.exitCode !== ExitCode.Ok) {
383
+ program.__apiguardExitCode = outcome.exitCode;
384
+ }
385
+ });
386
+ program.command("init").description("Scaffold an apiguard.yaml config template").argument("[dir]", "target directory (default: cwd)", ".").option("--examples", "also write two sample specs for a first diff run").option("--force", "overwrite existing files").action(async (dir, opts) => {
387
+ await runInit(dir, opts, io);
388
+ });
389
+ return program;
390
+ }
391
+ async function run(io = defaultIo) {
392
+ const program = buildProgram(io);
393
+ try {
394
+ await program.parseAsync(io.argv, { from: "node" });
395
+ const code = program.__apiguardExitCode;
396
+ return code ?? ExitCode.Ok;
397
+ } catch (err) {
398
+ io.stderr(renderError(err, io.env.APIGUARD_VERBOSE === "1"));
399
+ return exitCodeFor(err);
400
+ }
401
+ }
402
+
403
+ // src/cli/main.ts
404
+ run().then((code) => {
405
+ process.exitCode = code;
406
+ });