cliguard 0.5.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
@@ -1,5 +1,10 @@
1
1
  # cliguard
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/cliguard.svg)](https://www.npmjs.com/package/cliguard)
4
+ [![npm downloads](https://img.shields.io/npm/dm/cliguard.svg)](https://www.npmjs.com/package/cliguard)
5
+ [![CI](https://github.com/Bryandero98/cliguard/actions/workflows/ci.yml/badge.svg)](https://github.com/Bryandero98/cliguard/actions/workflows/ci.yml)
6
+ [![license](https://img.shields.io/npm/l/cliguard.svg)](https://github.com/Bryandero98/cliguard/blob/main/LICENSE)
7
+
3
8
  Snapshot testing for CLI contracts.
4
9
 
5
10
  ## The problem
@@ -23,7 +28,7 @@ The first line fails your CI. The second one doesn't - `--dry-run` is new and op
23
28
  npm install --save-dev cliguard
24
29
  ```
25
30
 
26
- Your CLI's entry file should **export** its Commander `Command` instance instead of calling `.parse()` itself - the cleanest way to adopt cliguard, since it never risks running any of your CLI's real logic:
31
+ Point cliguard straight at your existing CLI's entry file - most real CLIs work unmodified, since cliguard automatically captures the framework instance they build at load time even if they never export it (see "Entry files that build the CLI lazily" below). Exporting the instance is still the cleanest way to adopt cliguard where you can, since it never risks running any of your CLI's real logic:
27
32
 
28
33
  ```js
29
34
  // bin/cli.js
@@ -92,6 +97,9 @@ Then:
92
97
  # Capture the current contract - commit .cliguard/contract.json
93
98
  npx cliguard init ./bin/cli.js
94
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
+
95
103
  # In CI: fail the build on any breaking change
96
104
  npx cliguard check ./bin/cli.js
97
105
 
@@ -113,13 +121,153 @@ npx cliguard check ./bin/cli.js --json
113
121
  "changes": [
114
122
  { "type": "BREAKING", "path": "root -> build -> option[--target]", "message": "Option \"--target\" was removed." }
115
123
  ],
116
- "summary": { "breaking": 1, "additive": 0, "patch": 0 },
124
+ "summary": { "breaking": 1, "acknowledgedBreaking": 0, "additive": 0, "patch": 0 },
117
125
  "suggestedBump": "major"
118
126
  }
119
127
  ```
120
128
 
121
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.
122
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
+
168
+ ### Accepting an intentional breaking change
169
+
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:
171
+
172
+ ```sh
173
+ npx cliguard accept ./bin/cli.js "root -> build -> option[--target]" --reason "removed in v2.0, replaced by --targets"
174
+ ```
175
+
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.
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
+
192
+ ### Comparing two contracts directly
193
+
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:
195
+
196
+ ```sh
197
+ npx cliguard diff old-contract.json new-contract.json --json
198
+ ```
199
+
200
+ It respects `.cliguard/accepted-breaks.json` the same way `check` does, and exits `1` on an un-acknowledged `BREAKING` change.
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
+
123
271
  ## How changes get classified
124
272
 
125
273
  | | Removed | Added | Required flipped | Value type / default changed |
@@ -131,12 +279,38 @@ npx cliguard check ./bin/cli.js --json
131
279
 
132
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.
133
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
+
134
302
  ## CI integration
135
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
+
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:
307
+
136
308
  ```yaml
137
309
  # .github/workflows/cliguard.yml
138
310
  name: CLI contract
139
311
  on: [pull_request]
312
+ permissions:
313
+ pull-requests: write # needed for the PR comment
140
314
  jobs:
141
315
  check:
142
316
  runs-on: ubuntu-latest
@@ -145,7 +319,17 @@ jobs:
145
319
  - uses: actions/setup-node@v4
146
320
  with: { node-version: 22.x }
147
321
  - run: npm ci
148
- - run: npx cliguard check ./bin/cli.js
322
+ - uses: Bryandero98/cliguard@v1
323
+ with:
324
+ entry: ./bin/cli.js
325
+ # adapter: yargs # default: commander
326
+ # comment-on-pr: false # default: true
327
+ ```
328
+
329
+ Set `comment-on-pr: false` to keep the exit-code gate without the comment, or use the raw CLI directly for a non-GitHub CI provider:
330
+
331
+ ```yaml
332
+ - run: npx cliguard check ./bin/cli.js
149
333
  ```
150
334
 
151
335
  ## Supported frameworks
@@ -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);