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/README.md CHANGED
@@ -97,6 +97,9 @@ Then:
97
97
  # Capture the current contract - commit .cliguard/contract.json
98
98
  npx cliguard init ./bin/cli.js
99
99
 
100
+ # Same, plus scaffold .github/workflows/cliguard.yml so CI is wired up too
101
+ npx cliguard init ./bin/cli.js --with-ci
102
+
100
103
  # In CI: fail the build on any breaking change
101
104
  npx cliguard check ./bin/cli.js
102
105
 
@@ -125,6 +128,43 @@ npx cliguard check ./bin/cli.js --json
125
128
 
126
129
  `suggestedBump` is the semver bump this diff implies (`"major"`, `"minor"`, `"patch"`, or `null` if nothing changed) - a direct read of the same BREAKING/ADDITIVE/PATCH classification the emoji output already uses, so a release script never has to re-derive it.
127
130
 
131
+ ### Marking something unstable right where it's declared
132
+
133
+ `cliguard.config.js` is a separate file - useful for a blanket rule, but one more place to keep in sync as flags get renamed or removed. For a single command/option/argument that isn't stable yet, mark it in its own description instead:
134
+
135
+ ```js
136
+ program.option("--fast", "skip checks [unstable]");
137
+ ```
138
+
139
+ Any BREAKING change to a path whose own description contains `[unstable]` reports as PATCH instead - the marker travels with the code, so it can't silently point at a flag that no longer exists the way an external ignore list can.
140
+
141
+ ### Project-wide policy: ignoring or downgrading a whole class of change
142
+
143
+ `accept`/`deprecate` handle one breaking change at a time. For a rule that applies to a whole class of changes - "alias changes are never breaking for us," "ignore everything under the `debug` subcommand" - write `cliguard.config.js` (or `.cjs`) instead:
144
+
145
+ ```js
146
+ // cliguard.config.js
147
+ module.exports = {
148
+ // Dropped from the report entirely - never shown, never fails the build.
149
+ ignore: ["root -> debug -> *"],
150
+
151
+ // Reclassified, not dropped - still visible, just not BREAKING anymore.
152
+ severityOverrides: [{ pattern: /alias/, severity: "PATCH" }],
153
+ };
154
+ ```
155
+
156
+ `pattern` in either field is a `RegExp` or a glob string (`*` matches any run of characters) matched against a change's path (the same string `check`'s own output shows, e.g. `"root -> build -> option[--target]"`). Applied before `accept`/`deprecate` ever run, so a change this config already downgraded has nothing left for either of those to act on. No `cliguard.config.js` present is a no-op - every project behaves exactly as it always has.
157
+
158
+ ### Comparing against a git ref instead of a local file
159
+
160
+ `check` normally diffs against `.cliguard/contract.json` on disk, but a CI runner checking out a PR branch often doesn't have a freshly-updated one - `--against <ref>` reads the contract straight out of git instead, no local file required:
161
+
162
+ ```sh
163
+ npx cliguard check ./bin/cli.js --against origin/main
164
+ ```
165
+
166
+ Works with any ref `git show` understands - a branch, a tag, a commit sha. Combine with `--json` the same way as the file-based path.
167
+
128
168
  ### Accepting an intentional breaking change
129
169
 
130
170
  Sometimes a `BREAKING` change is exactly what you meant to ship - a flag genuinely needed to go away in a major version. Running `cliguard update` after a real, intentional break re-baselines the *entire* contract silently; it doesn't leave a record of what changed or why. `cliguard accept` does:
@@ -135,6 +175,20 @@ npx cliguard accept ./bin/cli.js "root -> build -> option[--target]" --reason "r
135
175
 
136
176
  This only works against a change `check` would currently report as `BREAKING` - it reads the exact `path` from your own `check` output (text or `--json`), so there's nothing to guess. It writes `.cliguard/accepted-breaks.json` (commit this file); from then on, `check` still shows that change - now as a 🟣 acknowledged line with the reason attached - but stops counting it toward the `BREAKING` total that fails your build. Any *other*, un-accepted breaking change still fails CI as normal. Once you're done, `cliguard update` still re-baselines the contract to match reality, same as always.
137
177
 
178
+ ### Deprecating something ahead of its removal
179
+
180
+ `accept` forgives a break that already happened. `deprecate` is the other half - announce a removal *before* it happens, so when it eventually does, it's a PATCH instead of a BREAKING change:
181
+
182
+ ```sh
183
+ # The option still exists today - deprecate marks it for a future removal
184
+ npx cliguard deprecate ./bin/cli.js "root -> build -> option[--target]" \
185
+ --remove-by 2.0.0 --reason "replaced by --targets"
186
+ ```
187
+
188
+ This only works against a path that currently exists (it reads the same `path` shape `check`/`accept` use) - it writes `.cliguard/deprecations.json` (commit this file). From then on, whenever that command/option/argument actually gets removed, `check` reports it as PATCH, with the deprecation's reason and `--remove-by` folded into the message, instead of failing the build. Removing anything that was never deprecated first still fails exactly as before - deprecation has to be announced ahead of the break, not applied retroactively.
189
+
190
+ `--remove-by` is informational only (a version or a date, whichever fits your release process) - cliguard never checks it against the clock or your `package.json` version, it's just carried through into the message so whoever's reading a changelog or a PR comment knows the plan.
191
+
138
192
  ### Comparing two contracts directly
139
193
 
140
194
  `cliguard diff <old.json> <new.json>` runs the same comparison as `check`, but reads both sides straight off disk instead of running any CLI - useful for comparing two tags' committed contracts (`git show v1.0.0:.cliguard/contract.json > old.json`), or reviewing a contract change in a PR without a working copy of the target CLI at all:
@@ -145,6 +199,75 @@ npx cliguard diff old-contract.json new-contract.json --json
145
199
 
146
200
  It respects `.cliguard/accepted-breaks.json` the same way `check` does, and exits `1` on an un-acknowledged `BREAKING` change.
147
201
 
202
+ ### Previewing a contract without committing it
203
+
204
+ `cliguard preview <entry>` runs the same extraction `init` would, but prints the contract to stdout instead of writing `.cliguard/contract.json` - useful for sanity-checking what a new adapter or a lazily-built target CLI actually captures before you commit to it as the baseline:
205
+
206
+ ```sh
207
+ npx cliguard preview ./bin/cli.js --adapter yargs
208
+ ```
209
+
210
+ ### Checking an adapter's real limitations, or sanity-checking one against your CLI
211
+
212
+ Every adapter has a couple of real, framework-shape gaps (see "Supported frameworks" below) - `cliguard doctor` surfaces them directly instead of leaving them to a code comment only a maintainer would read:
213
+
214
+ ```sh
215
+ npx cliguard doctor
216
+ ```
217
+
218
+ Pass an entry file to also run a real extraction against it and get a quick structural summary (or the real failure, if extraction doesn't work) instead of a full contract dump:
219
+
220
+ ```sh
221
+ npx cliguard doctor ./bin/cli.js --adapter yargs
222
+ ```
223
+
224
+ ### Catching a breaking change before it reaches CI
225
+
226
+ `cliguard install-hook <entry>` installs a git hook (`pre-push` by default) that runs `cliguard check` automatically, so a breaking change is caught locally instead of waiting for CI to say so:
227
+
228
+ ```sh
229
+ npx cliguard install-hook ./bin/cli.js
230
+ # or, to gate every commit instead of every push:
231
+ npx cliguard install-hook ./bin/cli.js --hook pre-commit
232
+ ```
233
+
234
+ Never overwrites a hook that's already there - if you're already using [husky](https://typicode.github.io/husky/) or a similar tool, add the same `npx cliguard check ...` line to your existing hook instead.
235
+
236
+ ### `--strict`: catching changes the default rules can't see
237
+
238
+ The default rules match commands/options/arguments by name, so a change that keeps every name the same is invisible to them - even when it can still break a caller. `--strict` adds rules for exactly that gap. Today, one: a pure reorder of a command's positional arguments.
239
+
240
+ ```js
241
+ // before
242
+ program.command("copy").argument("<src>").argument("<dest>");
243
+ // after - same two arguments, swapped order
244
+ program.command("copy").argument("<dest>").argument("<src>");
245
+ ```
246
+
247
+ The default rules see no change at all here (`<src>` still exists, `<dest>` still exists). But `cli copy a.txt b.txt` now copies `b.txt` over `a.txt`, not the reverse - a real, silent break for anyone calling it positionally:
248
+
249
+ ```sh
250
+ npx cliguard check ./bin/cli.js --strict
251
+ ```
252
+
253
+ Off by default so it never changes behavior for an existing CI config - opt in per project.
254
+
255
+ ## Programmatic API
256
+
257
+ Everything above is the CLI. The same extraction and diff logic is also available as a library, for a custom build script, monorepo tool, or bot that wants to embed a contract check without spawning `npx cliguard` as a subprocess:
258
+
259
+ ```js
260
+ const { extractContract, compareContracts, ChangeType } = require("cliguard");
261
+
262
+ const oldContract = await extractContract("./bin/cli.js"); // or read one off disk yourself
263
+ const newContract = await extractContract("./bin/cli.js");
264
+ const diff = compareContracts(oldContract, newContract, { strict: true });
265
+
266
+ const breaking = diff.filter((change) => change.type === ChangeType.BREAKING);
267
+ ```
268
+
269
+ `listAdapters()` returns every name `extractContract`'s second argument accepts. `DiffEngine`, every adapter class (`CommanderAdapter`/`CacAdapter`/`YargsAdapter`), and the `toJUnitXml`/`toGitLabCodeQuality`/`toRdjsonl` formatters are all exported too, for anything more custom than the two convenience functions cover.
270
+
148
271
  ## How changes get classified
149
272
 
150
273
  | | Removed | Added | Required flipped | Value type / default changed |
@@ -156,8 +279,30 @@ It respects `.cliguard/accepted-breaks.json` the same way `check` does, and exit
156
279
 
157
280
  Full rules live in [`src/core/diff.engine.ts`](src/core/diff.engine.ts) - it's the one file worth reading if you want to know exactly why something was flagged.
158
281
 
282
+ ### Reports for non-GitHub CI
283
+
284
+ The bundled GitHub Action is the recommended path on GitHub, but `check`/`diff` can also emit two other formats directly, no Action or bespoke reporter needed:
285
+
286
+ ```sh
287
+ # JUnit XML - understood natively by Jenkins, CircleCI, Azure DevOps, and GitLab's own JUnit widget
288
+ npx cliguard check ./bin/cli.js --format junit > cliguard-report.xml
289
+
290
+ # GitLab Code Quality JSON - surfaced as inline annotations on a GitLab merge request
291
+ npx cliguard check ./bin/cli.js --format gitlab-codequality > gl-code-quality-report.json
292
+ ```
293
+
294
+ A third format, `--format rdjsonl`, emits [reviewdog](https://github.com/reviewdog/reviewdog)'s own Diagnostic Format instead of a report cliguard renders itself - hand it off to whichever platform reviewdog already has a reporter for:
295
+
296
+ ```sh
297
+ npx cliguard check ./bin/cli.js --format rdjsonl | reviewdog -f=rdjsonl -reporter=github-pr-review
298
+ ```
299
+
300
+ Same exit code either way - `1` on an unacknowledged BREAKING change, `0` otherwise - so any of the three drops straight into a CI job that already fails the build on a non-zero exit.
301
+
159
302
  ## CI integration
160
303
 
304
+ `cliguard init --with-ci` scaffolds the workflow below for you - `git add .github/workflows/cliguard.yml` and you're done. Prefer to see it first, or wire it up by hand? Read on.
305
+
161
306
  The bundled GitHub Action (`Bryandero98/cliguard@v1`) is the recommended way to run this in CI: on top of the same exit-code gate as `npx cliguard check`, it posts the diff as a PR comment - updated in place on every push, not a new one each time - so a reviewer sees exactly what changed without opening the CI log:
162
307
 
163
308
  ```yaml
@@ -8,6 +8,16 @@ import type { Contract } from "../core/types";
8
8
  export interface CliAdapter {
9
9
  /** Adapter identifier stored in `Contract.adapter`, e.g. "commander". */
10
10
  readonly id: string;
11
+ /**
12
+ * Real, framework-shape limitations on what this adapter can populate in
13
+ * a Contract - not bugs, just things the target framework itself has no
14
+ * concept of (documented in more depth in each adapter's own doc
15
+ * comment). Empty for an adapter with no such gaps. Surfaced by
16
+ * `cliguard doctor` so a user hits this in a one-line summary instead of
17
+ * only discovering it by reading adapter source or being surprised by a
18
+ * `required` that's silently always `false`.
19
+ */
20
+ readonly limitations: readonly string[];
11
21
  /** Loads `entryPath` and extracts its full command surface as a Contract. */
12
22
  extract(entryPath: string): Promise<Contract>;
13
23
  }
@@ -17,6 +17,7 @@ import type { CliAdapter } from "./adapter.interface";
17
17
  */
18
18
  export declare class CacAdapter implements CliAdapter {
19
19
  readonly id = "cac";
20
+ readonly limitations: readonly string[];
20
21
  extract(entryPath: string): Promise<Contract>;
21
22
  private loadCac;
22
23
  /** Among every CAC instance captured during construction, the one that looks most like the real, fully-built root CLI. */
@@ -21,6 +21,11 @@ const load_module_1 = require("./load-module");
21
21
  class CacAdapter {
22
22
  constructor() {
23
23
  this.id = "cac";
24
+ this.limitations = [
25
+ 'OptionContract.required is always false - CAC has no declarative "this option must be passed" concept.',
26
+ "CommandContract.subcommands is always [] - CAC's commands are a flat list, not a tree.",
27
+ 'ArgumentContract.description is always "" - CAC\'s positional args carry no description field.',
28
+ ];
24
29
  }
25
30
  async extract(entryPath) {
26
31
  const cli = await this.loadCac(entryPath);
@@ -9,6 +9,8 @@ import type { CliAdapter } from "./adapter.interface";
9
9
  */
10
10
  export declare class CommanderAdapter implements CliAdapter {
11
11
  readonly id = "commander";
12
+ /** No known gaps - Commander's own object graph exposes everything a Contract needs directly. */
13
+ readonly limitations: readonly string[];
12
14
  extract(entryPath: string): Promise<Contract>;
13
15
  private loadCommand;
14
16
  /** Among every Command captured during construction, the one that looks most like the real, fully-built root program - flags on this file's own top-level code sometimes constructing more than one incidentally. */
@@ -13,6 +13,8 @@ const load_module_1 = require("./load-module");
13
13
  class CommanderAdapter {
14
14
  constructor() {
15
15
  this.id = "commander";
16
+ /** No known gaps - Commander's own object graph exposes everything a Contract needs directly. */
17
+ this.limitations = [];
16
18
  }
17
19
  async extract(entryPath) {
18
20
  const program = await this.loadCommand(entryPath);
@@ -0,0 +1,3 @@
1
+ import type { CliAdapter } from "./adapter.interface";
2
+ export declare const adapters: Readonly<Record<string, CliAdapter>>;
3
+ export declare function resolveAdapter(name: string): CliAdapter;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.adapters = void 0;
4
+ exports.resolveAdapter = resolveAdapter;
5
+ const cac_adapter_1 = require("./cac.adapter");
6
+ const commander_adapter_1 = require("./commander.adapter");
7
+ const yargs_adapter_1 = require("./yargs.adapter");
8
+ // Constructing an adapter here is cheap (no eager require of its
9
+ // framework - CacAdapter/YargsAdapter only load their framework lazily,
10
+ // inside extract()), so every adapter is always registered regardless of
11
+ // which one a given caller actually uses. Shared by bin.ts (the CLI) and
12
+ // index.ts (the programmatic API) so both agree on exactly the same set
13
+ // of adapters under exactly the same names, rather than two registries
14
+ // that could silently drift apart.
15
+ exports.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 = exports.adapters[name];
22
+ if (!adapter) {
23
+ throw new Error(`cliguard: unknown adapter "${name}". Available: ${Object.keys(exports.adapters).join(", ")}.`);
24
+ }
25
+ return adapter;
26
+ }
@@ -19,6 +19,7 @@ import type { CliAdapter } from "./adapter.interface";
19
19
  */
20
20
  export declare class YargsAdapter implements CliAdapter {
21
21
  readonly id = "yargs";
22
+ readonly limitations: readonly string[];
22
23
  extract(entryPath: string): Promise<Contract>;
23
24
  private loadYargs;
24
25
  /**
@@ -24,6 +24,9 @@ const YARGS_STRING_MARKER = "__yargsString__:";
24
24
  class YargsAdapter {
25
25
  constructor() {
26
26
  this.id = "yargs";
27
+ this.limitations = [
28
+ "Each command's options are read from a fresh, isolated yargs instance built by re-running that command's builder, not the shared instance the target CLI actually built - see this class's own doc comment for why.",
29
+ ];
27
30
  }
28
31
  async extract(entryPath) {
29
32
  const cli = await this.loadYargs(entryPath);