cliguard 0.6.0 → 0.7.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/dist/bin.js CHANGED
@@ -2,28 +2,13 @@
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const commander_1 = require("commander");
5
- const cac_adapter_1 = require("./adapters/cac.adapter");
6
- const commander_adapter_1 = require("./adapters/commander.adapter");
7
- const yargs_adapter_1 = require("./adapters/yargs.adapter");
5
+ const path_1 = require("path");
6
+ const registry_1 = require("./adapters/registry");
7
+ const config_1 = require("./core/config");
8
8
  const diff_engine_1 = require("./core/diff.engine");
9
+ const report_formats_1 = require("./core/report-formats");
9
10
  const storage_1 = require("./core/storage");
10
11
  const types_1 = require("./core/types");
11
- // Constructing an adapter here is cheap (no eager require of its
12
- // framework - CacAdapter only loads `cac` lazily, inside extract()), so
13
- // every adapter is always registered regardless of which one a given
14
- // invocation actually uses.
15
- const adapters = {
16
- commander: new commander_adapter_1.CommanderAdapter(),
17
- cac: new cac_adapter_1.CacAdapter(),
18
- yargs: new yargs_adapter_1.YargsAdapter(),
19
- };
20
- function resolveAdapter(name) {
21
- const adapter = adapters[name];
22
- if (!adapter) {
23
- throw new Error(`cliguard: unknown adapter "${name}". Available: ${Object.keys(adapters).join(", ")}.`);
24
- }
25
- return adapter;
26
- }
27
12
  const diffEngine = new diff_engine_1.DiffEngine();
28
13
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- package.json has no type declarations to import against; require() is the simplest correct read here
29
14
  const packageJson = require("../package.json");
@@ -37,20 +22,52 @@ const adapterOption = [
37
22
  "CLI framework adapter to use",
38
23
  "commander",
39
24
  ];
25
+ const REPORT_FORMATS = ["text", "json", "junit", "gitlab-codequality", "rdjsonl"];
26
+ /** `--json` is a shorthand kept for backward compatibility - equivalent to `--format json` when no explicit `--format` is given. Returns null for an unrecognized `--format` value, distinct from every valid one including "text". */
27
+ function resolveFormat(explicit, jsonFlag) {
28
+ const format = explicit ?? (jsonFlag ? "json" : "text");
29
+ return REPORT_FORMATS.includes(format) ? format : null;
30
+ }
31
+ /** Renders every format except "text" (which each command still prints itself, since its "nothing changed" message differs between `check` and `diff`). */
32
+ function formatReport(diff, acceptedPaths, format, contractPath) {
33
+ if (format === "json") {
34
+ return JSON.stringify(toJsonResult(diff, acceptedPaths), null, 2);
35
+ }
36
+ const annotated = annotateChanges(diff, acceptedPaths);
37
+ switch (format) {
38
+ case "junit":
39
+ return (0, report_formats_1.toJUnitXml)(annotated);
40
+ case "gitlab-codequality":
41
+ return (0, report_formats_1.toGitLabCodeQuality)(annotated, contractPath);
42
+ case "rdjsonl":
43
+ return (0, report_formats_1.toRdjsonl)(annotated, contractPath);
44
+ }
45
+ }
40
46
  program
41
47
  .command("init")
42
48
  .description("Capture the current CLI surface as the committed contract")
43
49
  .argument("<entry>", "path to the target CLI's entry file")
44
50
  .option(...adapterOption)
51
+ .option("--with-ci", "also scaffold a GitHub Actions workflow that runs cliguard on every pull request", false)
45
52
  .action(async (entry, options) => {
46
53
  if ((0, storage_1.contractExists)()) {
47
54
  console.warn(`A contract already exists. Run "cliguard update" to overwrite it.`);
48
55
  process.exit(1);
49
56
  }
50
57
  const exitCode = await withSuppressedExit(async () => {
51
- const contract = await resolveAdapter(options.adapter).extract(entry);
58
+ const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
52
59
  (0, storage_1.writeContract)(contract);
53
60
  console.log(`✅ CLI contract initialized successfully at ${(0, storage_1.getContractDisplayPath)()}.`);
61
+ if (options.withCi) {
62
+ if ((0, storage_1.ciWorkflowExists)()) {
63
+ console.log(`ℹ️ ${(0, storage_1.getCiWorkflowDisplayPath)()} already exists - left it untouched.`);
64
+ }
65
+ else {
66
+ const entryPath = (0, path_1.relative)(process.cwd(), (0, path_1.resolve)(entry)).split("\\").join("/");
67
+ (0, storage_1.writeCiWorkflow)(buildCiWorkflowYaml(entryPath, options.adapter));
68
+ console.log(`✅ GitHub Actions workflow scaffolded at ${(0, storage_1.getCiWorkflowDisplayPath)()}.`);
69
+ }
70
+ }
54
71
  return 0;
55
72
  });
56
73
  process.exit(exitCode);
@@ -60,16 +77,24 @@ program
60
77
  .description("Compare the current CLI surface against the committed contract")
61
78
  .argument("<entry>", "path to the target CLI's entry file")
62
79
  .option(...adapterOption)
63
- .option("--json", "print a machine-readable JSON result instead of text", false)
80
+ .option("--json", "print a machine-readable JSON result instead of text (shorthand for --format json)", false)
81
+ .option("--format <format>", `output format: ${REPORT_FORMATS.join(", ")}`)
82
+ .option("--against <ref>", "compare against a git ref's committed contract (e.g. origin/main, a tag, a commit sha) instead of the .cliguard/contract.json on disk")
83
+ .option("--strict", "enable extra rules for currently-silent risky changes (e.g. a positional argument reorder)", false)
64
84
  .action(async (entry, options) => {
65
85
  const exitCode = await withSuppressedExit(async () => {
66
- const oldContract = (0, storage_1.readContract)();
67
- const newContract = await resolveAdapter(options.adapter).extract(entry);
68
- const diff = diffEngine.compare(oldContract, newContract);
86
+ const format = resolveFormat(options.format, options.json);
87
+ if (!format) {
88
+ console.error(`cliguard: unknown --format "${options.format}". Use ${REPORT_FORMATS.join(", ")}.`);
89
+ return 1;
90
+ }
91
+ const oldContract = options.against ? (0, storage_1.readContractAtRef)(options.against) : (0, storage_1.readContract)();
92
+ const newContract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
93
+ const diff = applyDeprecations(diffEngine.applyUnstableMarkers((0, config_1.applyConfig)(diffEngine.compare(oldContract, newContract, { strict: options.strict }), (0, config_1.loadConfig)()), oldContract, newContract), indexDeprecations((0, storage_1.readDeprecations)()));
69
94
  const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)());
70
95
  const hasBreaking = diff.some((change) => change.type === types_1.ChangeType.BREAKING && !acceptedPaths.has(change.path));
71
- if (options.json) {
72
- console.log(JSON.stringify(toJsonResult(diff, acceptedPaths), null, 2));
96
+ if (format !== "text") {
97
+ console.log(formatReport(diff, acceptedPaths, format, (0, storage_1.getContractDisplayPath)()));
73
98
  return hasBreaking ? 1 : 0;
74
99
  }
75
100
  if (diff.length === 0) {
@@ -96,8 +121,8 @@ program
96
121
  return 1;
97
122
  }
98
123
  const oldContract = (0, storage_1.readContract)();
99
- const newContract = await resolveAdapter(options.adapter).extract(entry);
100
- const diff = diffEngine.compare(oldContract, newContract);
124
+ const newContract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
125
+ const diff = applyDeprecations(diffEngine.applyUnstableMarkers((0, config_1.applyConfig)(diffEngine.compare(oldContract, newContract), (0, config_1.loadConfig)()), oldContract, newContract), indexDeprecations((0, storage_1.readDeprecations)()));
101
126
  const match = diff.find((change) => change.type === types_1.ChangeType.BREAKING && change.path === changePath);
102
127
  if (!match) {
103
128
  const breaking = diff.filter((change) => change.type === types_1.ChangeType.BREAKING);
@@ -123,6 +148,44 @@ program
123
148
  });
124
149
  process.exit(exitCode);
125
150
  });
151
+ program
152
+ .command("deprecate")
153
+ .description("Schedule a command/option/argument for removal ahead of time, so that removal counts as PATCH instead of BREAKING")
154
+ .argument("<entry>", "path to the target CLI's entry file")
155
+ .argument("<path>", 'the exact Contract path to deprecate, e.g. "root -> build -> option[--target]" - still present today, not yet removed')
156
+ .requiredOption("--remove-by <versionOrDate>", 'when this is expected to actually go away (e.g. "2.0.0" or "2026-12-01") - informational, never enforced by cliguard itself')
157
+ .option("-r, --reason <text>", "why this is being deprecated - shown once it's removed")
158
+ .option(...adapterOption)
159
+ .action(async (entry, path, options) => {
160
+ const exitCode = await withSuppressedExit(async () => {
161
+ const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
162
+ const validPaths = diffEngine.collectPaths(contract);
163
+ if (!validPaths.has(path)) {
164
+ console.error(`cliguard: no such path "${path}" in the current contract - it may already be ` +
165
+ "removed, or never existed. Run `cliguard preview <entry>` to see the current contract.");
166
+ return 1;
167
+ }
168
+ // Replaces any earlier deprecation at the same path rather than
169
+ // accumulating duplicates - re-running `deprecate` updates the
170
+ // remove-by/reason, same as `accept` does for its own reason.
171
+ const remaining = (0, storage_1.readDeprecations)().filter((existing) => existing.path !== path);
172
+ const deprecation = {
173
+ path,
174
+ removeBy: options.removeBy,
175
+ reason: options.reason,
176
+ deprecatedAt: new Date().toISOString(),
177
+ };
178
+ (0, storage_1.writeDeprecations)([...remaining, deprecation]);
179
+ console.log(`✅ Deprecated: [${path}]`);
180
+ console.log(` Remove by: ${options.removeBy}`);
181
+ if (options.reason)
182
+ console.log(` Reason: ${options.reason}`);
183
+ console.log(` Recorded in ${(0, storage_1.getDeprecationsDisplayPath)()} - commit this file. ` +
184
+ "Its eventual removal will count as PATCH, not BREAKING.");
185
+ return 0;
186
+ });
187
+ process.exit(exitCode);
188
+ });
126
189
  program
127
190
  .command("update")
128
191
  .description("Overwrite the committed contract with the CLI's current surface")
@@ -130,31 +193,108 @@ program
130
193
  .option(...adapterOption)
131
194
  .action(async (entry, options) => {
132
195
  const exitCode = await withSuppressedExit(async () => {
133
- const contract = await resolveAdapter(options.adapter).extract(entry);
196
+ const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
134
197
  (0, storage_1.writeContract)(contract);
135
198
  console.log("🔄 CLI contract updated successfully.");
136
199
  return 0;
137
200
  });
138
201
  process.exit(exitCode);
139
202
  });
203
+ program
204
+ .command("preview")
205
+ .description("Extract the current CLI's contract and print it, without writing .cliguard/contract.json")
206
+ .argument("<entry>", "path to the target CLI's entry file")
207
+ .option(...adapterOption)
208
+ .action(async (entry, options) => {
209
+ const exitCode = await withSuppressedExit(async () => {
210
+ const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
211
+ console.log(JSON.stringify(contract, null, 2));
212
+ return 0;
213
+ });
214
+ process.exit(exitCode);
215
+ });
216
+ program
217
+ .command("doctor")
218
+ .description("Show every adapter's known limitations, or sanity-check one against a real entry file")
219
+ .argument("[entry]", "optional: path to a target CLI's entry file to actually test extraction")
220
+ .option(...adapterOption)
221
+ .action(async (entry, options) => {
222
+ if (!entry) {
223
+ printAdapterLimitations();
224
+ process.exit(0);
225
+ }
226
+ const exitCode = await withSuppressedExit(async () => {
227
+ const adapter = (0, registry_1.resolveAdapter)(options.adapter);
228
+ console.log(`Adapter: ${adapter.id}`);
229
+ if (adapter.limitations.length === 0) {
230
+ console.log(" No known limitations.");
231
+ }
232
+ else {
233
+ for (const limitation of adapter.limitations) {
234
+ console.log(` ⚠️ ${limitation}`);
235
+ }
236
+ }
237
+ console.log("");
238
+ try {
239
+ const contract = await adapter.extract(entry);
240
+ const summary = summarizeCommand(contract.root);
241
+ console.log(`✅ Extraction succeeded: ${summary.commands} command(s), ` +
242
+ `${summary.options} option(s), ${summary.arguments} argument(s).`);
243
+ return 0;
244
+ }
245
+ catch (error) {
246
+ console.error(`❌ Extraction failed: ${error instanceof Error ? error.message : String(error)}`);
247
+ return 1;
248
+ }
249
+ });
250
+ process.exit(exitCode);
251
+ });
252
+ const HOOK_NAMES = ["pre-commit", "pre-push"];
253
+ program
254
+ .command("install-hook")
255
+ .description("Install a git hook that runs `cliguard check` automatically, catching a breaking change before it ever reaches CI")
256
+ .argument("<entry>", "path to the target CLI's entry file")
257
+ .option(...adapterOption)
258
+ .option("--hook <name>", `which git hook to install: ${HOOK_NAMES.join(" or ")}`, "pre-push")
259
+ .action((entry, options) => {
260
+ if (!isHookName(options.hook)) {
261
+ console.error(`cliguard: --hook must be one of ${HOOK_NAMES.join(", ")} - got "${options.hook}".`);
262
+ process.exit(1);
263
+ }
264
+ if ((0, storage_1.hookExists)(options.hook)) {
265
+ console.log(`ℹ️ ${(0, storage_1.getHookDisplayPath)(options.hook)} already exists - left it untouched.`);
266
+ process.exit(0);
267
+ }
268
+ const entryPath = (0, path_1.relative)(process.cwd(), (0, path_1.resolve)(entry)).split("\\").join("/");
269
+ (0, storage_1.writeHook)(options.hook, buildHookScript(entryPath, options.adapter));
270
+ console.log(`✅ Git hook installed at ${(0, storage_1.getHookDisplayPath)(options.hook)}.`);
271
+ process.exit(0);
272
+ });
140
273
  program
141
274
  .command("diff")
142
275
  .description("Compare two contract files directly, without running any CLI")
143
276
  .argument("<oldContract>", "path to the older contract JSON file")
144
277
  .argument("<newContract>", "path to the newer contract JSON file")
145
- .option("--json", "print a machine-readable JSON result instead of text", false)
278
+ .option("--json", "print a machine-readable JSON result instead of text (shorthand for --format json)", false)
279
+ .option("--format <format>", `output format: ${REPORT_FORMATS.join(", ")}`)
280
+ .option("--strict", "enable extra rules for currently-silent risky changes (e.g. a positional argument reorder)", false)
146
281
  .action((oldPath, newPath, options) => {
147
282
  // No adapter, no target CLI ever loaded here - just two files off
148
283
  // disk - so none of withSuppressedExit's process.exit-race concerns
149
284
  // apply. A thrown Error (bad path, corrupt JSON) still surfaces via
150
285
  // this program's own top-level parseAsync().catch() below.
286
+ const format = resolveFormat(options.format, options.json);
287
+ if (!format) {
288
+ console.error(`cliguard: unknown --format "${options.format}". Use ${REPORT_FORMATS.join(", ")}.`);
289
+ process.exit(1);
290
+ }
151
291
  const oldContract = (0, storage_1.readContractFile)(oldPath);
152
292
  const newContract = (0, storage_1.readContractFile)(newPath);
153
- const diff = diffEngine.compare(oldContract, newContract);
293
+ const diff = applyDeprecations(diffEngine.applyUnstableMarkers((0, config_1.applyConfig)(diffEngine.compare(oldContract, newContract, { strict: options.strict }), (0, config_1.loadConfig)()), oldContract, newContract), indexDeprecations((0, storage_1.readDeprecations)()));
154
294
  const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)());
155
295
  const hasBreaking = diff.some((change) => change.type === types_1.ChangeType.BREAKING && !acceptedPaths.has(change.path));
156
- if (options.json) {
157
- console.log(JSON.stringify(toJsonResult(diff, acceptedPaths), null, 2));
296
+ if (format !== "text") {
297
+ console.log(formatReport(diff, acceptedPaths, format, newPath));
158
298
  process.exit(hasBreaking ? 1 : 0);
159
299
  }
160
300
  if (diff.length === 0) {
@@ -164,6 +304,81 @@ program
164
304
  printDiff(diff, acceptedPaths);
165
305
  process.exit(hasBreaking ? 1 : 0);
166
306
  });
307
+ /**
308
+ * The same workflow the README's "CI integration" section documents by
309
+ * hand - generated here so `init --with-ci` never drifts from it. `entry`
310
+ * is expected already normalized (forward slashes, relative to cwd); the
311
+ * adapter line is only emitted for a non-default adapter, matching the
312
+ * Action's own `adapter` input default of "commander".
313
+ */
314
+ function buildCiWorkflowYaml(entryPath, adapter) {
315
+ const lines = [
316
+ "# Generated by `cliguard init --with-ci` - edit freely, cliguard won't touch this file again.",
317
+ "name: CLI contract",
318
+ "on: [pull_request]",
319
+ "permissions:",
320
+ " pull-requests: write # needed for the PR comment",
321
+ "jobs:",
322
+ " check:",
323
+ " runs-on: ubuntu-latest",
324
+ " steps:",
325
+ " - uses: actions/checkout@v4",
326
+ " - uses: actions/setup-node@v4",
327
+ " with: { node-version: 22.x }",
328
+ " - run: npm ci",
329
+ " - uses: Bryandero98/cliguard@v1",
330
+ " with:",
331
+ ` entry: ./${entryPath}`,
332
+ ];
333
+ if (adapter !== "commander") {
334
+ lines.push(` adapter: ${adapter}`);
335
+ }
336
+ return lines.join("\n") + "\n";
337
+ }
338
+ function printAdapterLimitations() {
339
+ console.log("Registered adapters and their known limitations:\n");
340
+ for (const adapter of Object.values(registry_1.adapters)) {
341
+ console.log(adapter.id + ":");
342
+ if (adapter.limitations.length === 0) {
343
+ console.log(" No known limitations.");
344
+ }
345
+ else {
346
+ for (const limitation of adapter.limitations) {
347
+ console.log(` ⚠️ ${limitation}`);
348
+ }
349
+ }
350
+ console.log("");
351
+ }
352
+ }
353
+ function summarizeCommand(cmd) {
354
+ let commands = 1;
355
+ let options = cmd.options.length;
356
+ let args = cmd.arguments.length;
357
+ for (const sub of cmd.subcommands) {
358
+ const subSummary = summarizeCommand(sub);
359
+ commands += subSummary.commands;
360
+ options += subSummary.options;
361
+ args += subSummary.arguments;
362
+ }
363
+ return { commands, options, arguments: args };
364
+ }
365
+ function isHookName(value) {
366
+ return HOOK_NAMES.includes(value);
367
+ }
368
+ /**
369
+ * A minimal POSIX-sh script - Git for Windows runs hooks through its own
370
+ * bundled sh.exe via the shebang, same as any other platform, so one
371
+ * script body works everywhere without a separate Windows path.
372
+ */
373
+ function buildHookScript(entryPath, adapter) {
374
+ const adapterFlag = adapter === "commander" ? "" : ` --adapter ${adapter}`;
375
+ return [
376
+ "#!/bin/sh",
377
+ "# Installed by `cliguard install-hook` - edit freely, cliguard won't touch this file again.",
378
+ `npx cliguard check "${entryPath}"${adapterFlag}`,
379
+ "",
380
+ ].join("\n");
381
+ }
167
382
  /**
168
383
  * Runs `action` with `process.exit` neutralized, restoring the real one
169
384
  * the instant `action` settles - then the caller calls the *real*
@@ -198,13 +413,47 @@ async function withSuppressedExit(action) {
198
413
  function indexAcceptedBreaks(accepted) {
199
414
  return new Map(accepted.map((entry) => [entry.path, entry]));
200
415
  }
201
- function toJsonResult(diff, acceptedPaths) {
202
- const changes = diff.map((entry) => {
416
+ function indexDeprecations(deprecations) {
417
+ return new Map(deprecations.map((entry) => [entry.path, entry]));
418
+ }
419
+ /**
420
+ * Reclassifies a BREAKING removal at a deprecated path as PATCH, folding
421
+ * the deprecation's own record into the message - a scheduled, announced
422
+ * removal is no longer a surprise to whoever's relying on the flag, so it
423
+ * shouldn't fail the build the way an unannounced one still does. Only
424
+ * ever touches entries with `removal: true` (see DiffResult) - a
425
+ * *different* kind of BREAKING change at the same path (e.g. a default
426
+ * value change on an option that also happens to be deprecated) is left
427
+ * alone, since deprecating a removal says nothing about other changes.
428
+ */
429
+ function applyDeprecations(diff, deprecations) {
430
+ return diff.map((entry) => {
431
+ if (entry.type !== types_1.ChangeType.BREAKING || !entry.removal)
432
+ return entry;
433
+ const deprecation = deprecations.get(entry.path);
434
+ if (!deprecation)
435
+ return entry;
436
+ return {
437
+ type: types_1.ChangeType.PATCH,
438
+ path: entry.path,
439
+ message: `${entry.message} Deprecated ${deprecation.deprecatedAt.slice(0, 10)}, ` +
440
+ `scheduled removal by ${deprecation.removeBy}` +
441
+ (deprecation.reason ? ` (${deprecation.reason})` : "") +
442
+ " - this removal was expected.",
443
+ };
444
+ });
445
+ }
446
+ /** A BREAKING entry matched by `cliguard accept` gains `acknowledged: true` and its recorded `reason`; every other entry passes through unchanged. Shared by --json, --format junit, and --format gitlab-codequality so all three agree on what "acknowledged" means. */
447
+ function annotateChanges(diff, acceptedPaths) {
448
+ return diff.map((entry) => {
203
449
  if (entry.type !== types_1.ChangeType.BREAKING)
204
450
  return entry;
205
451
  const accepted = acceptedPaths.get(entry.path);
206
452
  return accepted ? { ...entry, acknowledged: true, reason: accepted.reason } : entry;
207
453
  });
454
+ }
455
+ function toJsonResult(diff, acceptedPaths) {
456
+ const changes = annotateChanges(diff, acceptedPaths);
208
457
  const summary = {
209
458
  breaking: changes.filter((change) => change.type === types_1.ChangeType.BREAKING && !change.acknowledged).length,
210
459
  acknowledgedBreaking: changes.filter((change) => change.type === types_1.ChangeType.BREAKING && change.acknowledged).length,
@@ -0,0 +1,24 @@
1
+ import type { DiffResult } from "./diff.engine";
2
+ import { ChangeType } from "./types";
3
+ export interface SeverityOverride {
4
+ readonly pattern: string | RegExp;
5
+ readonly severity: ChangeType;
6
+ }
7
+ export interface CliguardConfig {
8
+ /** A change whose DiffResult.path matches any of these is dropped from the report entirely - never shown, never counted, never fails the build. */
9
+ readonly ignore?: readonly (string | RegExp)[];
10
+ /** A change whose DiffResult.path matches `pattern` gets reclassified to `severity` - the first matching entry wins. */
11
+ readonly severityOverrides?: readonly SeverityOverride[];
12
+ }
13
+ /** Display-only path of whichever config file was actually found, or the first candidate name if none was - only meaningful in an error message alongside `configExists()`. */
14
+ export declare function getConfigDisplayPath(): string;
15
+ export declare function configExists(): boolean;
16
+ /** Returns `{}` (no policy applied) when no config file exists - a project with no `cliguard.config.js` behaves exactly as it always has. */
17
+ export declare function loadConfig(): CliguardConfig;
18
+ /**
19
+ * Applies project-wide policy before any per-instance override (`cliguard
20
+ * accept`/`deprecate`) gets a chance to run - an ignored or downgraded
21
+ * change simply isn't BREAKING by the time either of those look at it, so
22
+ * there's nothing left to accept or deprecate for it.
23
+ */
24
+ export declare function applyConfig(diff: readonly DiffResult[], config: CliguardConfig): DiffResult[];
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getConfigDisplayPath = getConfigDisplayPath;
4
+ exports.configExists = configExists;
5
+ exports.loadConfig = loadConfig;
6
+ exports.applyConfig = applyConfig;
7
+ const fs_1 = require("fs");
8
+ const path_1 = require("path");
9
+ const types_1 = require("./types");
10
+ const CANDIDATE_NAMES = ["cliguard.config.js", "cliguard.config.cjs"];
11
+ function resolveConfigPath() {
12
+ for (const name of CANDIDATE_NAMES) {
13
+ const path = (0, path_1.join)(process.cwd(), name);
14
+ if ((0, fs_1.existsSync)(path))
15
+ return path;
16
+ }
17
+ return null;
18
+ }
19
+ /** Display-only path of whichever config file was actually found, or the first candidate name if none was - only meaningful in an error message alongside `configExists()`. */
20
+ function getConfigDisplayPath() {
21
+ const path = resolveConfigPath();
22
+ return path ? (0, path_1.relative)(process.cwd(), path).split("\\").join("/") : CANDIDATE_NAMES[0];
23
+ }
24
+ function configExists() {
25
+ return resolveConfigPath() !== null;
26
+ }
27
+ const VALID_SEVERITIES = [
28
+ types_1.ChangeType.BREAKING,
29
+ types_1.ChangeType.ADDITIVE,
30
+ types_1.ChangeType.PATCH,
31
+ ];
32
+ function validateConfig(raw, displayPath) {
33
+ if (raw === null || typeof raw !== "object") {
34
+ throw new Error(`cliguard: ${displayPath} must export an object (module.exports = {...}).`);
35
+ }
36
+ const config = raw;
37
+ if (config.ignore !== undefined) {
38
+ if (!Array.isArray(config.ignore) ||
39
+ !config.ignore.every((entry) => typeof entry === "string" || entry instanceof RegExp)) {
40
+ throw new Error(`cliguard: ${displayPath}'s "ignore" must be an array of strings/RegExp.`);
41
+ }
42
+ }
43
+ if (config.severityOverrides !== undefined) {
44
+ if (!Array.isArray(config.severityOverrides)) {
45
+ throw new Error(`cliguard: ${displayPath}'s "severityOverrides" must be an array.`);
46
+ }
47
+ for (const entry of config.severityOverrides) {
48
+ const override = entry;
49
+ const patternOk = typeof override.pattern === "string" || override.pattern instanceof RegExp;
50
+ const severityOk = typeof override.severity === "string" && VALID_SEVERITIES.includes(override.severity);
51
+ if (!patternOk || !severityOk) {
52
+ throw new Error(`cliguard: ${displayPath}'s "severityOverrides" entries must look like ` +
53
+ `{ pattern: string | RegExp, severity: "BREAKING" | "ADDITIVE" | "PATCH" } - got ${JSON.stringify(entry)}.`);
54
+ }
55
+ }
56
+ }
57
+ return config;
58
+ }
59
+ /** Returns `{}` (no policy applied) when no config file exists - a project with no `cliguard.config.js` behaves exactly as it always has. */
60
+ function loadConfig() {
61
+ const path = resolveConfigPath();
62
+ if (!path)
63
+ return {};
64
+ const displayPath = getConfigDisplayPath();
65
+ let raw;
66
+ try {
67
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- a config file is plain CommonJS by design, same convention as eslint.config.js/jest.config.js
68
+ raw = require(path);
69
+ }
70
+ catch (error) {
71
+ throw new Error(`cliguard: failed to load ${displayPath}: ${error instanceof Error ? error.message : String(error)}`);
72
+ }
73
+ return validateConfig(raw, displayPath);
74
+ }
75
+ /** Glob-lite: `*` matches any run of characters, everything else is matched literally - just enough to write "root -> * -> option[--debug]" without a full glob dependency for one wildcard character. */
76
+ function matchesGlob(pattern, path) {
77
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
78
+ return new RegExp(`^${escaped}$`).test(path);
79
+ }
80
+ function matches(pattern, path) {
81
+ return pattern instanceof RegExp ? pattern.test(path) : matchesGlob(pattern, path);
82
+ }
83
+ /**
84
+ * Applies project-wide policy before any per-instance override (`cliguard
85
+ * accept`/`deprecate`) gets a chance to run - an ignored or downgraded
86
+ * change simply isn't BREAKING by the time either of those look at it, so
87
+ * there's nothing left to accept or deprecate for it.
88
+ */
89
+ function applyConfig(diff, config) {
90
+ const ignore = config.ignore ?? [];
91
+ const overrides = config.severityOverrides ?? [];
92
+ if (ignore.length === 0 && overrides.length === 0)
93
+ return diff;
94
+ return diff
95
+ .filter((entry) => !ignore.some((pattern) => matches(pattern, entry.path)))
96
+ .map((entry) => {
97
+ const override = overrides.find((candidate) => matches(candidate.pattern, entry.path));
98
+ if (!override || override.severity === entry.type)
99
+ return entry;
100
+ return {
101
+ ...entry,
102
+ type: override.severity,
103
+ message: `${entry.message} [config: severity overridden to ${override.severity}]`,
104
+ };
105
+ });
106
+ }
@@ -5,6 +5,20 @@ export interface DiffResult {
5
5
  readonly path: string;
6
6
  /** Human-readable description of exactly what changed. */
7
7
  readonly message: string;
8
+ /** True only for "a command/option/argument was removed" - lets `cliguard deprecate` reclassify exactly this kind of BREAKING change, never any other kind at the same path. */
9
+ readonly removal?: true;
10
+ }
11
+ export interface CompareOptions {
12
+ /**
13
+ * Enables extra rules for changes that are currently silent (no diff
14
+ * entry at all) but can still break an existing caller - today, only a
15
+ * pure reorder of a command's positional arguments (same names, same
16
+ * required/variadic shape, different sequence), which the default,
17
+ * name-indexed comparison can't see since it never looks at position.
18
+ * Off by default so existing callers/CI configs keep today's behavior
19
+ * exactly - this is opt-in stricter enforcement, not a bug fix.
20
+ */
21
+ readonly strict?: boolean;
8
22
  }
9
23
  /**
10
24
  * Compares two Contracts and returns every difference between them,
@@ -14,7 +28,38 @@ export interface DiffResult {
14
28
  * either contract. `capturedAt` is intentionally never read.
15
29
  */
16
30
  export declare class DiffEngine {
17
- compare(oldContract: Contract, newContract: Contract): DiffResult[];
31
+ compare(oldContract: Contract, newContract: Contract, options?: CompareOptions): DiffResult[];
32
+ /**
33
+ * Every valid DiffResult.path reachable from a Contract's root - the
34
+ * same path shapes `compareCommands`/`compareOptions`/`compareArguments`
35
+ * build ("root", "root -> build", "root -> build -> option[--target]",
36
+ * "root -> build -> argument[<file>]"). Used by `cliguard deprecate` to
37
+ * validate a path actually exists in the current contract before
38
+ * recording it, without duplicating the path-building logic here.
39
+ */
40
+ collectPaths(contract: Contract): Set<string>;
41
+ private walkCommand;
42
+ /**
43
+ * Every DiffResult.path whose own description contains `marker` - a
44
+ * command/option/argument the target CLI's own author has marked this
45
+ * way is signaling "not stable yet" right at the point where it's
46
+ * declared (inspired by `buf`'s `ignore_unstable_packages`), rather
47
+ * than through a separately maintained ignore list
48
+ * (`cliguard.config.js`) that can silently drift out of sync as flags
49
+ * get renamed, removed, or re-added under a different name.
50
+ */
51
+ collectUnstablePaths(contract: Contract, marker: string): Set<string>;
52
+ private walkUnstable;
53
+ /**
54
+ * Reclassifies a BREAKING change as PATCH when either side of the diff
55
+ * marks that path unstable - checking both the old *and* new contract
56
+ * matters because a removal only exists in the old one (the entry is
57
+ * simply gone from the new tree) while an added-then-changed entry only
58
+ * exists in the new one. `marker` defaults to `[unstable]`; every other
59
+ * kind of change (ADDITIVE, PATCH) is already non-blocking and left
60
+ * untouched either way.
61
+ */
62
+ applyUnstableMarkers(diff: readonly DiffResult[], oldContract: Contract, newContract: Contract, marker?: string): DiffResult[];
18
63
  private compareCommands;
19
64
  private compareSubcommands;
20
65
  /** CAC's default command (declared with no leading name, e.g. `cli.command("[...files]", ...)`) has name === "" - a blank path segment reads as a typo, not a real command. */
@@ -22,6 +67,16 @@ export declare class DiffEngine {
22
67
  private compareOptions;
23
68
  private compareOption;
24
69
  private compareArguments;
70
+ /**
71
+ * `--strict`-only: positional arguments are matched by name everywhere
72
+ * above, so a pure reorder (same names, same shape, different sequence)
73
+ * produces no diff at all under the default rules - but position is
74
+ * exactly what a caller passing values positionally relies on, so it's
75
+ * a real, silent break. Only fires when the two argument lists are the
76
+ * same *set* of names (any actual add/remove is already reported by the
77
+ * per-name loop above; this would just be redundant noise on top of it).
78
+ */
79
+ private compareArgumentOrder;
25
80
  private compareArgument;
26
81
  /** Shared by command aliases and option aliases - the rules are identical for both. */
27
82
  private compareAliases;