cliguard 0.4.0 → 0.6.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 +65 -6
- package/dist/adapters/yargs.adapter.d.ts +96 -0
- package/dist/adapters/yargs.adapter.js +286 -0
- package/dist/bin.js +96 -8
- package/dist/core/storage.d.ts +15 -1
- package/dist/core/storage.js +51 -0
- package/dist/core/types.d.ts +15 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# cliguard
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/cliguard)
|
|
4
|
+
[](https://www.npmjs.com/package/cliguard)
|
|
5
|
+
[](https://github.com/Bryandero98/cliguard/actions/workflows/ci.yml)
|
|
6
|
+
[](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
|
-
|
|
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
|
|
@@ -56,11 +61,31 @@ npx cliguard init ./bin/cli.js --adapter cac
|
|
|
56
61
|
|
|
57
62
|
`cac` itself is an optional dependency of cliguard - only installed if you actually use `--adapter cac`.
|
|
58
63
|
|
|
64
|
+
Built with [Yargs](https://github.com/yargs/yargs) instead? Export the instance the same way and pass `--adapter yargs`:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
// bin/cli.js
|
|
68
|
+
const yargs = require("yargs/yargs");
|
|
69
|
+
|
|
70
|
+
const cli = yargs([])
|
|
71
|
+
.command("build <entry>", "build the project", (y) =>
|
|
72
|
+
y.option("target", { alias: "t", describe: "build target", type: "string" }).demandOption("target"),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
module.exports = { cli };
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npx cliguard init ./bin/cli.js --adapter yargs
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Like `cac`, `yargs` itself is an optional dependency of cliguard - only installed if you actually use `--adapter yargs`.
|
|
83
|
+
|
|
59
84
|
### Entry files that build the CLI lazily
|
|
60
85
|
|
|
61
86
|
Not every real CLI exports its instance - plenty build it inside a function that only runs when something actually calls it, or just never had a reason to export it. Pointing cliguard straight at a file like that would fail with "no instance found" under the rule above alone.
|
|
62
87
|
|
|
63
|
-
So when the direct export lookup finds nothing, cliguard tries one more thing automatically, no flag needed: it patches the exact copy of `commander`/`cac` your entry file will itself `require()`, so any `new Command()` (or CAC's `cac()`) call anywhere in your file's own top-level code is captured - even though nothing was ever exported. This covers most real CLIs, since even ones that never bother exporting still build (and often `.parse()`) at the top of their own file as a matter of course.
|
|
88
|
+
So when the direct export lookup finds nothing, cliguard tries one more thing automatically, no flag needed: it patches the exact copy of `commander`/`cac`/`yargs` your entry file will itself `require()`, so any `new Command()` (or CAC's `cac()`, or a `yargs(...)` call) anywhere in your file's own top-level code is captured - even though nothing was ever exported. This covers most real CLIs, since even ones that never bother exporting still build (and often `.parse()`) at the top of their own file as a matter of course.
|
|
64
89
|
|
|
65
90
|
It can't reach an instance built strictly *inside* a function that's only invoked later, never automatically at load time (a `main()` some other file calls, not the file cliguard is pointed at) - there's no safe, generic way for cliguard to know which function to call or with what arguments. For that shape, write a small wrapper file that reaches into the target's own internals to get (or construct) the instance, and point cliguard at the wrapper instead of the original entry file. The exact shape of that wrapper is inherently project-specific - it's standing in for whatever that project's own entry point would otherwise do - but the command stays the same either way: `cliguard init ./your-wrapper.mjs`.
|
|
66
91
|
|
|
@@ -93,13 +118,33 @@ npx cliguard check ./bin/cli.js --json
|
|
|
93
118
|
"changes": [
|
|
94
119
|
{ "type": "BREAKING", "path": "root -> build -> option[--target]", "message": "Option \"--target\" was removed." }
|
|
95
120
|
],
|
|
96
|
-
"summary": { "breaking": 1, "additive": 0, "patch": 0 },
|
|
121
|
+
"summary": { "breaking": 1, "acknowledgedBreaking": 0, "additive": 0, "patch": 0 },
|
|
97
122
|
"suggestedBump": "major"
|
|
98
123
|
}
|
|
99
124
|
```
|
|
100
125
|
|
|
101
126
|
`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.
|
|
102
127
|
|
|
128
|
+
### Accepting an intentional breaking change
|
|
129
|
+
|
|
130
|
+
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:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
npx cliguard accept ./bin/cli.js "root -> build -> option[--target]" --reason "removed in v2.0, replaced by --targets"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
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
|
+
|
|
138
|
+
### Comparing two contracts directly
|
|
139
|
+
|
|
140
|
+
`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:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
npx cliguard diff old-contract.json new-contract.json --json
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It respects `.cliguard/accepted-breaks.json` the same way `check` does, and exits `1` on an un-acknowledged `BREAKING` change.
|
|
147
|
+
|
|
103
148
|
## How changes get classified
|
|
104
149
|
|
|
105
150
|
| | Removed | Added | Required flipped | Value type / default changed |
|
|
@@ -113,10 +158,14 @@ Full rules live in [`src/core/diff.engine.ts`](src/core/diff.engine.ts) - it's t
|
|
|
113
158
|
|
|
114
159
|
## CI integration
|
|
115
160
|
|
|
161
|
+
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
|
+
|
|
116
163
|
```yaml
|
|
117
164
|
# .github/workflows/cliguard.yml
|
|
118
165
|
name: CLI contract
|
|
119
166
|
on: [pull_request]
|
|
167
|
+
permissions:
|
|
168
|
+
pull-requests: write # needed for the PR comment
|
|
120
169
|
jobs:
|
|
121
170
|
check:
|
|
122
171
|
runs-on: ubuntu-latest
|
|
@@ -125,14 +174,24 @@ jobs:
|
|
|
125
174
|
- uses: actions/setup-node@v4
|
|
126
175
|
with: { node-version: 22.x }
|
|
127
176
|
- run: npm ci
|
|
128
|
-
-
|
|
177
|
+
- uses: Bryandero98/cliguard@v1
|
|
178
|
+
with:
|
|
179
|
+
entry: ./bin/cli.js
|
|
180
|
+
# adapter: yargs # default: commander
|
|
181
|
+
# comment-on-pr: false # default: true
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
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:
|
|
185
|
+
|
|
186
|
+
```yaml
|
|
187
|
+
- run: npx cliguard check ./bin/cli.js
|
|
129
188
|
```
|
|
130
189
|
|
|
131
190
|
## Supported frameworks
|
|
132
191
|
|
|
133
|
-
[Commander.js](https://github.com/tj/commander.js) (default)
|
|
192
|
+
[Commander.js](https://github.com/tj/commander.js) (default), [CAC](https://github.com/cacjs/cac) (`--adapter cac`), and [Yargs](https://github.com/yargs/yargs) (`--adapter yargs`) today. The core (types + diff engine) is 100% framework-agnostic by design: every framework-specific detail lives behind the `CliAdapter` interface in [`src/adapters/`](src/adapters/), so adding a new adapter never touches the diffing logic. Click, Clap, and Cobra are the next open gaps - see the [good first issue](https://github.com/Bryandero98/cliguard/labels/good%20first%20issue).
|
|
134
193
|
|
|
135
|
-
A couple of `OptionContract`/`ArgumentContract` fields carry real, framework-specific limitations rather than a mapping gap - see [`src/adapters/cac.adapter.ts`](src/adapters/cac.adapter.ts)'s own doc comment for exactly which ones and why (CAC has no declarative "this flag must be passed" concept, and no per-argument description).
|
|
194
|
+
A couple of `OptionContract`/`ArgumentContract` fields carry real, framework-specific limitations rather than a mapping gap - see [`src/adapters/cac.adapter.ts`](src/adapters/cac.adapter.ts)'s own doc comment for exactly which ones and why (CAC has no declarative "this flag must be passed" concept, and no per-argument description). Yargs's own real limitation is the opposite kind - see [`src/adapters/yargs.adapter.ts`](src/adapters/yargs.adapter.ts)'s doc comment for why each command's options are read from a fresh, isolated instance rather than the shared one the target CLI actually built.
|
|
136
195
|
|
|
137
196
|
The current adapter mechanism loads the target CLI's entry file into the Node process (`import()`/`require()`) and reads its object graph directly, so the next targets are other Node frameworks. Cross-language support (Python's Click, Rust's Clap, Go's Cobra) is a real future direction, but needs a different extraction strategy first, since a compiled Clap/Cobra binary can't be `require()`'d into Node the way a JS CLI can - most likely each of those would introspect via a structured `--help` output (some frameworks support a JSON mode) rather than the same in-process approach.
|
|
138
197
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Contract } from "../core/types";
|
|
2
|
+
import type { CliAdapter } from "./adapter.interface";
|
|
3
|
+
/**
|
|
4
|
+
* Extracts a Contract from a target file that builds a yargs CLI. Never
|
|
5
|
+
* parses --help output - every field comes straight from yargs's own
|
|
6
|
+
* `getOptions()`/`getInternalMethods()` object graph, matching
|
|
7
|
+
* CommanderAdapter/CacAdapter's approach.
|
|
8
|
+
*
|
|
9
|
+
* Yargs has no per-command object tree the way Commander does - every
|
|
10
|
+
* `.option()` call (wherever it happens) mutates one shared options bag on
|
|
11
|
+
* the instance, and a command's own options only get registered when its
|
|
12
|
+
* `builder` function actually runs (normally deferred until that command
|
|
13
|
+
* matches at real parse time). To read a command's options in isolation,
|
|
14
|
+
* this adapter calls each command's `builder` against a **fresh, empty**
|
|
15
|
+
* yargs instance rather than the shared one - the same thing yargs itself
|
|
16
|
+
* does internally via `getInternalMethods().reset()` before running a
|
|
17
|
+
* command's builder (verified in yargs's own `command.js`), just without
|
|
18
|
+
* needing yargs to have actually matched and parsed real argv first.
|
|
19
|
+
*/
|
|
20
|
+
export declare class YargsAdapter implements CliAdapter {
|
|
21
|
+
readonly id = "yargs";
|
|
22
|
+
extract(entryPath: string): Promise<Contract>;
|
|
23
|
+
private loadYargs;
|
|
24
|
+
/**
|
|
25
|
+
* Captures every instance produced by calling `require("yargs")` or
|
|
26
|
+
* `require("yargs/yargs")` as a function, from the target's own
|
|
27
|
+
* resolved copy of the package - real CLIs almost always reach yargs
|
|
28
|
+
* through one of these two calls (`yargs(hideBin(process.argv))` or
|
|
29
|
+
* `require("yargs/yargs")(args)`), usually without ever exporting the
|
|
30
|
+
* result.
|
|
31
|
+
*
|
|
32
|
+
* Unlike commander/cac's `captureConstructions` (which patches a
|
|
33
|
+
* *named property* on the required module, since `Command`/`cac` are
|
|
34
|
+
* named exports), yargs's own package export *is itself* the callable
|
|
35
|
+
* factory - `require("yargs")` returns a function directly, not a
|
|
36
|
+
* container object with a factory property on it. So this replaces the
|
|
37
|
+
* entire cached module's `.exports` with a `Proxy` around that
|
|
38
|
+
* function, trapping `apply` instead of `construct`: the proxy is
|
|
39
|
+
* still callable exactly like the original (and forwards every other
|
|
40
|
+
* property read/write straight through, since no `get`/`set` trap is
|
|
41
|
+
* defined - which is what makes `require("yargs")`'s other form, using
|
|
42
|
+
* it directly as a pre-built singleton instance without ever calling
|
|
43
|
+
* it, keep working unmodified). `require("yargs")` and
|
|
44
|
+
* `require("yargs/yargs")` resolve to two different files
|
|
45
|
+
* (`index.cjs` vs `yargs.cjs`), so both are patched independently -
|
|
46
|
+
* whichever the target actually uses is the one that ever fires.
|
|
47
|
+
*/
|
|
48
|
+
private captureYargsFactoryCalls;
|
|
49
|
+
/** Among every instance captured during construction, the one that looks most like the real, fully-built root program. */
|
|
50
|
+
private pickBestCandidate;
|
|
51
|
+
private score;
|
|
52
|
+
/** Handles `export default`, `module.exports = cli`, and named exports. */
|
|
53
|
+
private findYargs;
|
|
54
|
+
/**
|
|
55
|
+
* Structural check, not `instanceof` - see CommanderAdapter's
|
|
56
|
+
* identical-purpose `looksLikeCommand` for why (the target's own
|
|
57
|
+
* `yargs` install is almost always a separate copy from cliguard's).
|
|
58
|
+
* `getInternalMethods` is fairly distinctive to yargs among CLI
|
|
59
|
+
* frameworks, so it's included alongside the more generic
|
|
60
|
+
* `command`/`option`/`getOptions` to keep this from ever
|
|
61
|
+
* false-matching a Commander or CAC instance.
|
|
62
|
+
*/
|
|
63
|
+
private looksLikeYargs;
|
|
64
|
+
private mapRoot;
|
|
65
|
+
/**
|
|
66
|
+
* Runs `handler.builder` against a fresh, empty yargs instance rather
|
|
67
|
+
* than the shared one the target built - see this class's own doc
|
|
68
|
+
* comment for why. A side effect worth calling out: every fresh
|
|
69
|
+
* instance auto-registers its own `help`/`version` options (yargs's
|
|
70
|
+
* own default, not something this specific command declared), so
|
|
71
|
+
* those two names are always excluded below - matching
|
|
72
|
+
* CommanderAdapter/CacAdapter, neither of which surfaces their
|
|
73
|
+
* framework's built-in help/version as a regular option either.
|
|
74
|
+
*/
|
|
75
|
+
private mapCommand;
|
|
76
|
+
private freshInstance;
|
|
77
|
+
private mapArguments;
|
|
78
|
+
/**
|
|
79
|
+
* Yargs stores an option's own name as a real entry in `options.string`
|
|
80
|
+
* / `options.boolean` *and* separately re-registers every alias as its
|
|
81
|
+
* own addressable entry in the same arrays (so `-o` shows up next to
|
|
82
|
+
* `output`, not just inside `output`'s own alias list) - excluded here
|
|
83
|
+
* via `aliasTargets` so each alias surfaces exactly once, nested under
|
|
84
|
+
* its canonical option, matching CommanderAdapter/CacAdapter's shape.
|
|
85
|
+
* Positional names go through this same shared bag too (`.positional()`
|
|
86
|
+
* is implemented in terms of the same option-registration machinery
|
|
87
|
+
* internally) - excluded via `positionalNames` so they surface only in
|
|
88
|
+
* `arguments`, never duplicated into `options`.
|
|
89
|
+
*/
|
|
90
|
+
private mapOptions;
|
|
91
|
+
private describe;
|
|
92
|
+
/** `-x` for a single-character name, `--xray` otherwise - yargs's own alias lists carry neither dash. */
|
|
93
|
+
private dashPrefix;
|
|
94
|
+
/** Everything not declared `boolean`/`array`/`number` defaults to yargs's own "string" bucket - collapsed to this Contract's two-value OptionValueType the same way CacAdapter does. */
|
|
95
|
+
private inferValueType;
|
|
96
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.YargsAdapter = void 0;
|
|
4
|
+
const path_1 = require("path");
|
|
5
|
+
const load_module_1 = require("./load-module");
|
|
6
|
+
const YARGS_STRING_MARKER = "__yargsString__:";
|
|
7
|
+
/**
|
|
8
|
+
* Extracts a Contract from a target file that builds a yargs CLI. Never
|
|
9
|
+
* parses --help output - every field comes straight from yargs's own
|
|
10
|
+
* `getOptions()`/`getInternalMethods()` object graph, matching
|
|
11
|
+
* CommanderAdapter/CacAdapter's approach.
|
|
12
|
+
*
|
|
13
|
+
* Yargs has no per-command object tree the way Commander does - every
|
|
14
|
+
* `.option()` call (wherever it happens) mutates one shared options bag on
|
|
15
|
+
* the instance, and a command's own options only get registered when its
|
|
16
|
+
* `builder` function actually runs (normally deferred until that command
|
|
17
|
+
* matches at real parse time). To read a command's options in isolation,
|
|
18
|
+
* this adapter calls each command's `builder` against a **fresh, empty**
|
|
19
|
+
* yargs instance rather than the shared one - the same thing yargs itself
|
|
20
|
+
* does internally via `getInternalMethods().reset()` before running a
|
|
21
|
+
* command's builder (verified in yargs's own `command.js`), just without
|
|
22
|
+
* needing yargs to have actually matched and parsed real argv first.
|
|
23
|
+
*/
|
|
24
|
+
class YargsAdapter {
|
|
25
|
+
constructor() {
|
|
26
|
+
this.id = "yargs";
|
|
27
|
+
}
|
|
28
|
+
async extract(entryPath) {
|
|
29
|
+
const cli = await this.loadYargs(entryPath);
|
|
30
|
+
return {
|
|
31
|
+
contractVersion: 1,
|
|
32
|
+
adapter: this.id,
|
|
33
|
+
capturedAt: new Date().toISOString(),
|
|
34
|
+
root: this.mapRoot(cli),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async loadYargs(entryPath) {
|
|
38
|
+
// Patched *before* the target loads, so a `yargs(args)` or
|
|
39
|
+
// `require("yargs/yargs")(args)` call anywhere in its own top-level
|
|
40
|
+
// code gets captured as a side effect of loadModule() below - even if
|
|
41
|
+
// the target never exports the result anywhere. See
|
|
42
|
+
// captureYargsFactoryCalls's own doc for why this is safe.
|
|
43
|
+
const captured = this.captureYargsFactoryCalls(entryPath);
|
|
44
|
+
const { viaImport, viaRequire } = await (0, load_module_1.loadModule)(entryPath);
|
|
45
|
+
const cli = this.findYargs(viaImport.moduleExports) ??
|
|
46
|
+
this.findYargs(viaRequire.moduleExports) ??
|
|
47
|
+
this.pickBestCandidate(captured);
|
|
48
|
+
if (cli)
|
|
49
|
+
return cli;
|
|
50
|
+
// See CommanderAdapter's identical block for why both real load
|
|
51
|
+
// errors - not a swallowed, generic guess - matter here.
|
|
52
|
+
throw new Error(`cliguard: no yargs instance found in "${entryPath}". ` +
|
|
53
|
+
"Export it as `export default cli`, `module.exports = cli`, " +
|
|
54
|
+
"or a named export (e.g. `export const cli = yargs(hideBin(process.argv))`). If " +
|
|
55
|
+
"the file builds its yargs instance inside a function that only runs when " +
|
|
56
|
+
"something calls it (never at the top level), point cliguard at a small wrapper " +
|
|
57
|
+
"file that calls that function and exports the result instead - see the README's " +
|
|
58
|
+
'"Entry files that build the CLI lazily" section.\n' +
|
|
59
|
+
` import() failed: ${viaImport.error ?? "module loaded, but exported no yargs instance"}\n` +
|
|
60
|
+
` require() failed: ${viaRequire.error ?? "module loaded, but exported no yargs instance"}`);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Captures every instance produced by calling `require("yargs")` or
|
|
64
|
+
* `require("yargs/yargs")` as a function, from the target's own
|
|
65
|
+
* resolved copy of the package - real CLIs almost always reach yargs
|
|
66
|
+
* through one of these two calls (`yargs(hideBin(process.argv))` or
|
|
67
|
+
* `require("yargs/yargs")(args)`), usually without ever exporting the
|
|
68
|
+
* result.
|
|
69
|
+
*
|
|
70
|
+
* Unlike commander/cac's `captureConstructions` (which patches a
|
|
71
|
+
* *named property* on the required module, since `Command`/`cac` are
|
|
72
|
+
* named exports), yargs's own package export *is itself* the callable
|
|
73
|
+
* factory - `require("yargs")` returns a function directly, not a
|
|
74
|
+
* container object with a factory property on it. So this replaces the
|
|
75
|
+
* entire cached module's `.exports` with a `Proxy` around that
|
|
76
|
+
* function, trapping `apply` instead of `construct`: the proxy is
|
|
77
|
+
* still callable exactly like the original (and forwards every other
|
|
78
|
+
* property read/write straight through, since no `get`/`set` trap is
|
|
79
|
+
* defined - which is what makes `require("yargs")`'s other form, using
|
|
80
|
+
* it directly as a pre-built singleton instance without ever calling
|
|
81
|
+
* it, keep working unmodified). `require("yargs")` and
|
|
82
|
+
* `require("yargs/yargs")` resolve to two different files
|
|
83
|
+
* (`index.cjs` vs `yargs.cjs`), so both are patched independently -
|
|
84
|
+
* whichever the target actually uses is the one that ever fires.
|
|
85
|
+
*/
|
|
86
|
+
captureYargsFactoryCalls(entryPath) {
|
|
87
|
+
const captured = [];
|
|
88
|
+
const record = (instance) => {
|
|
89
|
+
if (instance && typeof instance === "object")
|
|
90
|
+
captured.push(instance);
|
|
91
|
+
};
|
|
92
|
+
const targetDir = (0, path_1.dirname)((0, path_1.resolve)(process.cwd(), entryPath));
|
|
93
|
+
for (const packageName of ["yargs", "yargs/yargs"]) {
|
|
94
|
+
let resolvedPath;
|
|
95
|
+
try {
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- resolving the target's own copy of a CJS package, not a static dependency of this file
|
|
97
|
+
resolvedPath = require.resolve(packageName, { paths: [targetDir] });
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- ensures the module is loaded into require.cache before this patches it
|
|
99
|
+
require(resolvedPath);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Not resolvable from the target's own location (e.g. the target
|
|
103
|
+
// only uses one of the two import styles) - nothing to patch.
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const cacheEntry = require.cache[resolvedPath];
|
|
107
|
+
const original = cacheEntry?.exports;
|
|
108
|
+
if (!cacheEntry || typeof original !== "function")
|
|
109
|
+
continue;
|
|
110
|
+
cacheEntry.exports = new Proxy(original, {
|
|
111
|
+
apply(target, thisArg, args) {
|
|
112
|
+
const instance = Reflect.apply(target, thisArg, args);
|
|
113
|
+
record(instance);
|
|
114
|
+
return instance;
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return captured;
|
|
119
|
+
}
|
|
120
|
+
/** Among every instance captured during construction, the one that looks most like the real, fully-built root program. */
|
|
121
|
+
pickBestCandidate(candidates) {
|
|
122
|
+
const valid = candidates.filter((candidate) => this.looksLikeYargs(candidate));
|
|
123
|
+
if (valid.length === 0)
|
|
124
|
+
return undefined;
|
|
125
|
+
return valid.reduce((best, candidate) => this.score(candidate) > this.score(best) ? candidate : best);
|
|
126
|
+
}
|
|
127
|
+
score(instance) {
|
|
128
|
+
const options = instance.getOptions();
|
|
129
|
+
const handlerCount = Object.keys(instance.getInternalMethods().getCommandInstance().handlers).length;
|
|
130
|
+
return (handlerCount +
|
|
131
|
+
options.boolean.length +
|
|
132
|
+
options.string.length +
|
|
133
|
+
options.array.length +
|
|
134
|
+
options.number.length);
|
|
135
|
+
}
|
|
136
|
+
/** Handles `export default`, `module.exports = cli`, and named exports. */
|
|
137
|
+
findYargs(moduleExports) {
|
|
138
|
+
if (this.looksLikeYargs(moduleExports)) {
|
|
139
|
+
return moduleExports;
|
|
140
|
+
}
|
|
141
|
+
if (moduleExports && typeof moduleExports === "object") {
|
|
142
|
+
const exportsObject = moduleExports;
|
|
143
|
+
if (this.looksLikeYargs(exportsObject.default)) {
|
|
144
|
+
return exportsObject.default;
|
|
145
|
+
}
|
|
146
|
+
for (const value of Object.values(exportsObject)) {
|
|
147
|
+
if (this.looksLikeYargs(value))
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Structural check, not `instanceof` - see CommanderAdapter's
|
|
155
|
+
* identical-purpose `looksLikeCommand` for why (the target's own
|
|
156
|
+
* `yargs` install is almost always a separate copy from cliguard's).
|
|
157
|
+
* `getInternalMethods` is fairly distinctive to yargs among CLI
|
|
158
|
+
* frameworks, so it's included alongside the more generic
|
|
159
|
+
* `command`/`option`/`getOptions` to keep this from ever
|
|
160
|
+
* false-matching a Commander or CAC instance.
|
|
161
|
+
*/
|
|
162
|
+
looksLikeYargs(value) {
|
|
163
|
+
if (!value || typeof value !== "object")
|
|
164
|
+
return false;
|
|
165
|
+
const candidate = value;
|
|
166
|
+
return (typeof candidate.command === "function" &&
|
|
167
|
+
typeof candidate.option === "function" &&
|
|
168
|
+
typeof candidate.getOptions === "function" &&
|
|
169
|
+
typeof candidate.getInternalMethods === "function");
|
|
170
|
+
}
|
|
171
|
+
mapRoot(cli) {
|
|
172
|
+
const commandInstance = cli.getInternalMethods().getCommandInstance();
|
|
173
|
+
const options = cli.getOptions();
|
|
174
|
+
const descriptions = cli.getInternalMethods().getUsageInstance().getDescriptions();
|
|
175
|
+
return {
|
|
176
|
+
name: cli.$0,
|
|
177
|
+
description: "",
|
|
178
|
+
aliases: [],
|
|
179
|
+
options: this.mapOptions(options, descriptions, new Set()),
|
|
180
|
+
arguments: [],
|
|
181
|
+
subcommands: Object.entries(commandInstance.handlers).map(([name, handler]) => this.mapCommand(name, handler, commandInstance.aliasMap)),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Runs `handler.builder` against a fresh, empty yargs instance rather
|
|
186
|
+
* than the shared one the target built - see this class's own doc
|
|
187
|
+
* comment for why. A side effect worth calling out: every fresh
|
|
188
|
+
* instance auto-registers its own `help`/`version` options (yargs's
|
|
189
|
+
* own default, not something this specific command declared), so
|
|
190
|
+
* those two names are always excluded below - matching
|
|
191
|
+
* CommanderAdapter/CacAdapter, neither of which surfaces their
|
|
192
|
+
* framework's built-in help/version as a regular option either.
|
|
193
|
+
*/
|
|
194
|
+
mapCommand(name, handler, parentAliasMap) {
|
|
195
|
+
const scoped = this.freshInstance();
|
|
196
|
+
if (typeof handler.builder === "function") {
|
|
197
|
+
handler.builder(scoped, false);
|
|
198
|
+
}
|
|
199
|
+
else if (handler.builder && typeof handler.builder === "object") {
|
|
200
|
+
scoped.options(handler.builder);
|
|
201
|
+
}
|
|
202
|
+
const commandInstance = scoped.getInternalMethods().getCommandInstance();
|
|
203
|
+
const options = scoped.getOptions();
|
|
204
|
+
const descriptions = scoped.getInternalMethods().getUsageInstance().getDescriptions();
|
|
205
|
+
const positionals = [...handler.demanded, ...handler.optional];
|
|
206
|
+
const positionalNames = new Set(positionals.map((positional) => positional.cmd[0]));
|
|
207
|
+
return {
|
|
208
|
+
name,
|
|
209
|
+
description: handler.description || "",
|
|
210
|
+
aliases: Object.entries(parentAliasMap)
|
|
211
|
+
.filter(([, canonical]) => canonical === name)
|
|
212
|
+
.map(([alias]) => alias),
|
|
213
|
+
options: this.mapOptions(options, descriptions, positionalNames),
|
|
214
|
+
arguments: this.mapArguments(handler, descriptions),
|
|
215
|
+
subcommands: Object.entries(commandInstance.handlers).map(([subName, subHandler]) => this.mapCommand(subName, subHandler, commandInstance.aliasMap)),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
freshInstance() {
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- cliguard's own pinned copy, deliberately separate from the target's - this instance never touches the target's code, it's purely scratch space for reading one command's own builder output in isolation
|
|
220
|
+
const factory = require("yargs/yargs");
|
|
221
|
+
return factory([])
|
|
222
|
+
.exitProcess(false)
|
|
223
|
+
.fail(() => undefined);
|
|
224
|
+
}
|
|
225
|
+
mapArguments(handler, descriptions) {
|
|
226
|
+
const required = handler.demanded.map((positional) => ({ positional, required: true }));
|
|
227
|
+
const optional = handler.optional.map((positional) => ({ positional, required: false }));
|
|
228
|
+
return [...required, ...optional].map(({ positional, required: isRequired }) => ({
|
|
229
|
+
name: positional.cmd[0],
|
|
230
|
+
required: isRequired,
|
|
231
|
+
variadic: positional.variadic,
|
|
232
|
+
description: this.describe(descriptions, positional.cmd[0]),
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Yargs stores an option's own name as a real entry in `options.string`
|
|
237
|
+
* / `options.boolean` *and* separately re-registers every alias as its
|
|
238
|
+
* own addressable entry in the same arrays (so `-o` shows up next to
|
|
239
|
+
* `output`, not just inside `output`'s own alias list) - excluded here
|
|
240
|
+
* via `aliasTargets` so each alias surfaces exactly once, nested under
|
|
241
|
+
* its canonical option, matching CommanderAdapter/CacAdapter's shape.
|
|
242
|
+
* Positional names go through this same shared bag too (`.positional()`
|
|
243
|
+
* is implemented in terms of the same option-registration machinery
|
|
244
|
+
* internally) - excluded via `positionalNames` so they surface only in
|
|
245
|
+
* `arguments`, never duplicated into `options`.
|
|
246
|
+
*/
|
|
247
|
+
mapOptions(options, descriptions, positionalNames) {
|
|
248
|
+
const aliasTargets = new Set(Object.values(options.alias).flat());
|
|
249
|
+
const allNames = new Set([
|
|
250
|
+
...options.boolean,
|
|
251
|
+
...options.string,
|
|
252
|
+
...options.array,
|
|
253
|
+
...options.number,
|
|
254
|
+
]);
|
|
255
|
+
return [...allNames]
|
|
256
|
+
.filter((name) => name !== "help" && name !== "version")
|
|
257
|
+
.filter((name) => !positionalNames.has(name))
|
|
258
|
+
.filter((name) => !aliasTargets.has(name))
|
|
259
|
+
.map((name) => ({
|
|
260
|
+
flags: [
|
|
261
|
+
`--${name}`,
|
|
262
|
+
...(options.alias[name] ?? []).map((alias) => this.dashPrefix(alias)),
|
|
263
|
+
].join(", "),
|
|
264
|
+
name,
|
|
265
|
+
aliases: (options.alias[name] ?? []).map((alias) => this.dashPrefix(alias)),
|
|
266
|
+
description: this.describe(descriptions, name),
|
|
267
|
+
required: name in options.demandedOptions,
|
|
268
|
+
valueType: this.inferValueType(options, name),
|
|
269
|
+
variadic: options.array.includes(name),
|
|
270
|
+
defaultValue: name in options.default ? options.default[name] : null,
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
describe(descriptions, name) {
|
|
274
|
+
const raw = descriptions[name] ?? "";
|
|
275
|
+
return raw.startsWith(YARGS_STRING_MARKER) ? raw.slice(YARGS_STRING_MARKER.length) : raw;
|
|
276
|
+
}
|
|
277
|
+
/** `-x` for a single-character name, `--xray` otherwise - yargs's own alias lists carry neither dash. */
|
|
278
|
+
dashPrefix(name) {
|
|
279
|
+
return name.length === 1 ? `-${name}` : `--${name}`;
|
|
280
|
+
}
|
|
281
|
+
/** Everything not declared `boolean`/`array`/`number` defaults to yargs's own "string" bucket - collapsed to this Contract's two-value OptionValueType the same way CacAdapter does. */
|
|
282
|
+
inferValueType(options, name) {
|
|
283
|
+
return options.boolean.includes(name) ? "boolean" : "string";
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
exports.YargsAdapter = YargsAdapter;
|
package/dist/bin.js
CHANGED
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
const commander_1 = require("commander");
|
|
5
5
|
const cac_adapter_1 = require("./adapters/cac.adapter");
|
|
6
6
|
const commander_adapter_1 = require("./adapters/commander.adapter");
|
|
7
|
+
const yargs_adapter_1 = require("./adapters/yargs.adapter");
|
|
7
8
|
const diff_engine_1 = require("./core/diff.engine");
|
|
8
9
|
const storage_1 = require("./core/storage");
|
|
9
10
|
const types_1 = require("./core/types");
|
|
@@ -14,6 +15,7 @@ const types_1 = require("./core/types");
|
|
|
14
15
|
const adapters = {
|
|
15
16
|
commander: new commander_adapter_1.CommanderAdapter(),
|
|
16
17
|
cac: new cac_adapter_1.CacAdapter(),
|
|
18
|
+
yargs: new yargs_adapter_1.YargsAdapter(),
|
|
17
19
|
};
|
|
18
20
|
function resolveAdapter(name) {
|
|
19
21
|
const adapter = adapters[name];
|
|
@@ -64,20 +66,63 @@ program
|
|
|
64
66
|
const oldContract = (0, storage_1.readContract)();
|
|
65
67
|
const newContract = await resolveAdapter(options.adapter).extract(entry);
|
|
66
68
|
const diff = diffEngine.compare(oldContract, newContract);
|
|
67
|
-
const
|
|
69
|
+
const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)());
|
|
70
|
+
const hasBreaking = diff.some((change) => change.type === types_1.ChangeType.BREAKING && !acceptedPaths.has(change.path));
|
|
68
71
|
if (options.json) {
|
|
69
|
-
console.log(JSON.stringify(toJsonResult(diff), null, 2));
|
|
72
|
+
console.log(JSON.stringify(toJsonResult(diff, acceptedPaths), null, 2));
|
|
70
73
|
return hasBreaking ? 1 : 0;
|
|
71
74
|
}
|
|
72
75
|
if (diff.length === 0) {
|
|
73
76
|
console.log("✅ CLI contract is intact.");
|
|
74
77
|
return 0;
|
|
75
78
|
}
|
|
76
|
-
printDiff(diff);
|
|
79
|
+
printDiff(diff, acceptedPaths);
|
|
77
80
|
return hasBreaking ? 1 : 0;
|
|
78
81
|
});
|
|
79
82
|
process.exit(exitCode);
|
|
80
83
|
});
|
|
84
|
+
program
|
|
85
|
+
.command("accept")
|
|
86
|
+
.description("Record that a specific BREAKING change is intentional, so `check` stops failing CI for it")
|
|
87
|
+
.argument("<entry>", "path to the target CLI's entry file")
|
|
88
|
+
.argument("<changePath>", 'the exact DiffResult path to accept, e.g. "root -> build -> option[--target]"')
|
|
89
|
+
.requiredOption("-r, --reason <text>", "why this break is intentional - shown in check output")
|
|
90
|
+
.option(...adapterOption)
|
|
91
|
+
.action(async (entry, changePath, options) => {
|
|
92
|
+
const exitCode = await withSuppressedExit(async () => {
|
|
93
|
+
const reason = options.reason.trim();
|
|
94
|
+
if (!reason) {
|
|
95
|
+
console.error("cliguard: --reason can't be blank - it's the audit trail for why this break is OK.");
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
const oldContract = (0, storage_1.readContract)();
|
|
99
|
+
const newContract = await resolveAdapter(options.adapter).extract(entry);
|
|
100
|
+
const diff = diffEngine.compare(oldContract, newContract);
|
|
101
|
+
const match = diff.find((change) => change.type === types_1.ChangeType.BREAKING && change.path === changePath);
|
|
102
|
+
if (!match) {
|
|
103
|
+
const breaking = diff.filter((change) => change.type === types_1.ChangeType.BREAKING);
|
|
104
|
+
console.error(`cliguard: no current BREAKING change at path "${changePath}".` +
|
|
105
|
+
(breaking.length === 0
|
|
106
|
+
? " There are no BREAKING changes right now - nothing to accept."
|
|
107
|
+
: ` Currently breaking:\n${breaking.map((change) => ` - ${change.path}`).join("\n")}`));
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
// Replaces any earlier acceptance at the same path rather than
|
|
111
|
+
// accumulating duplicates - re-running `accept` updates the reason.
|
|
112
|
+
const remaining = (0, storage_1.readAcceptedBreaks)().filter((accepted) => accepted.path !== changePath);
|
|
113
|
+
const accepted = {
|
|
114
|
+
path: changePath,
|
|
115
|
+
reason,
|
|
116
|
+
acceptedAt: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
(0, storage_1.writeAcceptedBreaks)([...remaining, accepted]);
|
|
119
|
+
console.log(`✅ Accepted: [${changePath}] ${match.message}`);
|
|
120
|
+
console.log(` Reason: ${reason}`);
|
|
121
|
+
console.log(` Recorded in ${(0, storage_1.getAcceptedBreaksDisplayPath)()} - commit this file.`);
|
|
122
|
+
return 0;
|
|
123
|
+
});
|
|
124
|
+
process.exit(exitCode);
|
|
125
|
+
});
|
|
81
126
|
program
|
|
82
127
|
.command("update")
|
|
83
128
|
.description("Overwrite the committed contract with the CLI's current surface")
|
|
@@ -92,6 +137,33 @@ program
|
|
|
92
137
|
});
|
|
93
138
|
process.exit(exitCode);
|
|
94
139
|
});
|
|
140
|
+
program
|
|
141
|
+
.command("diff")
|
|
142
|
+
.description("Compare two contract files directly, without running any CLI")
|
|
143
|
+
.argument("<oldContract>", "path to the older contract JSON file")
|
|
144
|
+
.argument("<newContract>", "path to the newer contract JSON file")
|
|
145
|
+
.option("--json", "print a machine-readable JSON result instead of text", false)
|
|
146
|
+
.action((oldPath, newPath, options) => {
|
|
147
|
+
// No adapter, no target CLI ever loaded here - just two files off
|
|
148
|
+
// disk - so none of withSuppressedExit's process.exit-race concerns
|
|
149
|
+
// apply. A thrown Error (bad path, corrupt JSON) still surfaces via
|
|
150
|
+
// this program's own top-level parseAsync().catch() below.
|
|
151
|
+
const oldContract = (0, storage_1.readContractFile)(oldPath);
|
|
152
|
+
const newContract = (0, storage_1.readContractFile)(newPath);
|
|
153
|
+
const diff = diffEngine.compare(oldContract, newContract);
|
|
154
|
+
const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)());
|
|
155
|
+
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));
|
|
158
|
+
process.exit(hasBreaking ? 1 : 0);
|
|
159
|
+
}
|
|
160
|
+
if (diff.length === 0) {
|
|
161
|
+
console.log("✅ Contracts are identical.");
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
printDiff(diff, acceptedPaths);
|
|
165
|
+
process.exit(hasBreaking ? 1 : 0);
|
|
166
|
+
});
|
|
95
167
|
/**
|
|
96
168
|
* Runs `action` with `process.exit` neutralized, restoring the real one
|
|
97
169
|
* the instant `action` settles - then the caller calls the *real*
|
|
@@ -123,9 +195,19 @@ async function withSuppressedExit(action) {
|
|
|
123
195
|
process.exit = realExit;
|
|
124
196
|
}
|
|
125
197
|
}
|
|
126
|
-
function
|
|
198
|
+
function indexAcceptedBreaks(accepted) {
|
|
199
|
+
return new Map(accepted.map((entry) => [entry.path, entry]));
|
|
200
|
+
}
|
|
201
|
+
function toJsonResult(diff, acceptedPaths) {
|
|
202
|
+
const changes = diff.map((entry) => {
|
|
203
|
+
if (entry.type !== types_1.ChangeType.BREAKING)
|
|
204
|
+
return entry;
|
|
205
|
+
const accepted = acceptedPaths.get(entry.path);
|
|
206
|
+
return accepted ? { ...entry, acknowledged: true, reason: accepted.reason } : entry;
|
|
207
|
+
});
|
|
127
208
|
const summary = {
|
|
128
|
-
breaking:
|
|
209
|
+
breaking: changes.filter((change) => change.type === types_1.ChangeType.BREAKING && !change.acknowledged).length,
|
|
210
|
+
acknowledgedBreaking: changes.filter((change) => change.type === types_1.ChangeType.BREAKING && change.acknowledged).length,
|
|
129
211
|
additive: diff.filter((entry) => entry.type === types_1.ChangeType.ADDITIVE).length,
|
|
130
212
|
patch: diff.filter((entry) => entry.type === types_1.ChangeType.PATCH).length,
|
|
131
213
|
};
|
|
@@ -136,11 +218,17 @@ function toJsonResult(diff) {
|
|
|
136
218
|
: summary.patch > 0
|
|
137
219
|
? "patch"
|
|
138
220
|
: null;
|
|
139
|
-
return { ok: summary.breaking === 0, changes
|
|
221
|
+
return { ok: summary.breaking === 0, changes, summary, suggestedBump };
|
|
140
222
|
}
|
|
141
|
-
function printDiff(diff) {
|
|
223
|
+
function printDiff(diff, acceptedPaths) {
|
|
142
224
|
for (const entry of diff) {
|
|
143
|
-
|
|
225
|
+
const accepted = entry.type === types_1.ChangeType.BREAKING ? acceptedPaths.get(entry.path) : undefined;
|
|
226
|
+
if (accepted) {
|
|
227
|
+
console.log(`🟣 [${entry.path}] ${entry.message} (acknowledged: ${accepted.reason})`);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
console.log(`${emojiFor(entry.type)} [${entry.path}] ${entry.message}`);
|
|
231
|
+
}
|
|
144
232
|
}
|
|
145
233
|
}
|
|
146
234
|
function emojiFor(type) {
|
package/dist/core/storage.d.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import type { Contract } from "./types";
|
|
1
|
+
import type { AcceptedBreak, Contract } from "./types";
|
|
2
2
|
/** Contract path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
3
3
|
export declare function getContractDisplayPath(): string;
|
|
4
|
+
/** Accepted-breaks path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
5
|
+
export declare function getAcceptedBreaksDisplayPath(): string;
|
|
4
6
|
export declare function contractExists(): boolean;
|
|
5
7
|
export declare function readContract(): Contract;
|
|
6
8
|
export declare function writeContract(contract: Contract): void;
|
|
9
|
+
/**
|
|
10
|
+
* Reads a Contract from an arbitrary path, not the committed
|
|
11
|
+
* `.cliguard/contract.json` - for `cliguard diff <a> <b>`, comparing two
|
|
12
|
+
* contract files directly (e.g. two tags' committed contracts pulled via
|
|
13
|
+
* `git show`) without running any real CLI. `displayPath` is what error
|
|
14
|
+
* messages name; defaults to `path` itself since a caller-supplied path is
|
|
15
|
+
* already about as displayable as it gets.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readContractFile(path: string, displayPath?: string): Contract;
|
|
18
|
+
/** Unlike readContract, a missing file is normal (most projects never accept a break) - returns [] rather than throwing. */
|
|
19
|
+
export declare function readAcceptedBreaks(): AcceptedBreak[];
|
|
20
|
+
export declare function writeAcceptedBreaks(breaks: readonly AcceptedBreak[]): void;
|
package/dist/core/storage.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getContractDisplayPath = getContractDisplayPath;
|
|
4
|
+
exports.getAcceptedBreaksDisplayPath = getAcceptedBreaksDisplayPath;
|
|
4
5
|
exports.contractExists = contractExists;
|
|
5
6
|
exports.readContract = readContract;
|
|
6
7
|
exports.writeContract = writeContract;
|
|
8
|
+
exports.readContractFile = readContractFile;
|
|
9
|
+
exports.readAcceptedBreaks = readAcceptedBreaks;
|
|
10
|
+
exports.writeAcceptedBreaks = writeAcceptedBreaks;
|
|
7
11
|
const fs_1 = require("fs");
|
|
8
12
|
const path_1 = require("path");
|
|
9
13
|
const CONTRACT_PATH = (0, path_1.join)(process.cwd(), ".cliguard", "contract.json");
|
|
14
|
+
const ACCEPTED_BREAKS_PATH = (0, path_1.join)(process.cwd(), ".cliguard", "accepted-breaks.json");
|
|
10
15
|
/** Contract path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
11
16
|
function getContractDisplayPath() {
|
|
12
17
|
return (0, path_1.relative)(process.cwd(), CONTRACT_PATH).split("\\").join("/");
|
|
13
18
|
}
|
|
19
|
+
/** Accepted-breaks path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
20
|
+
function getAcceptedBreaksDisplayPath() {
|
|
21
|
+
return (0, path_1.relative)(process.cwd(), ACCEPTED_BREAKS_PATH).split("\\").join("/");
|
|
22
|
+
}
|
|
14
23
|
function contractExists() {
|
|
15
24
|
return (0, fs_1.existsSync)(CONTRACT_PATH);
|
|
16
25
|
}
|
|
@@ -39,3 +48,45 @@ function writeContract(contract) {
|
|
|
39
48
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(CONTRACT_PATH), { recursive: true });
|
|
40
49
|
(0, fs_1.writeFileSync)(CONTRACT_PATH, JSON.stringify(contract, null, 2) + "\n", "utf-8");
|
|
41
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Reads a Contract from an arbitrary path, not the committed
|
|
53
|
+
* `.cliguard/contract.json` - for `cliguard diff <a> <b>`, comparing two
|
|
54
|
+
* contract files directly (e.g. two tags' committed contracts pulled via
|
|
55
|
+
* `git show`) without running any real CLI. `displayPath` is what error
|
|
56
|
+
* messages name; defaults to `path` itself since a caller-supplied path is
|
|
57
|
+
* already about as displayable as it gets.
|
|
58
|
+
*/
|
|
59
|
+
function readContractFile(path, displayPath = path) {
|
|
60
|
+
if (!(0, fs_1.existsSync)(path)) {
|
|
61
|
+
throw new Error(`cliguard: no such file: "${displayPath}".`);
|
|
62
|
+
}
|
|
63
|
+
const raw = (0, fs_1.readFileSync)(path, "utf-8");
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(raw);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
69
|
+
throw new Error(`cliguard: "${displayPath}" is not valid JSON (${reason}).`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Unlike readContract, a missing file is normal (most projects never accept a break) - returns [] rather than throwing. */
|
|
73
|
+
function readAcceptedBreaks() {
|
|
74
|
+
if (!(0, fs_1.existsSync)(ACCEPTED_BREAKS_PATH))
|
|
75
|
+
return [];
|
|
76
|
+
const raw = (0, fs_1.readFileSync)(ACCEPTED_BREAKS_PATH, "utf-8");
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
// See readContract's identical-purpose catch for why naming the file
|
|
82
|
+
// and the fix matters here too.
|
|
83
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
84
|
+
throw new Error(`cliguard: "${getAcceptedBreaksDisplayPath()}" is not valid JSON (${reason}). ` +
|
|
85
|
+
"If this file was hand-edited or came out of a bad merge, fix it or delete it " +
|
|
86
|
+
"and re-run `cliguard accept` for whatever was in it.");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function writeAcceptedBreaks(breaks) {
|
|
90
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(ACCEPTED_BREAKS_PATH), { recursive: true });
|
|
91
|
+
(0, fs_1.writeFileSync)(ACCEPTED_BREAKS_PATH, JSON.stringify(breaks, null, 2) + "\n", "utf-8");
|
|
92
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -51,6 +51,21 @@ export interface Contract {
|
|
|
51
51
|
readonly capturedAt: string;
|
|
52
52
|
readonly root: CommandContract;
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* A specific BREAKING change the maintainer has deliberately accepted -
|
|
56
|
+
* written by `cliguard accept` and committed to `.cliguard/accepted-breaks.json`
|
|
57
|
+
* so the decision is auditable in the repo, not a silent CLI flag. `check`
|
|
58
|
+
* matches these against a diff's `DiffResult.path` and stops counting a
|
|
59
|
+
* match toward its exit code, while still showing it in the output.
|
|
60
|
+
*/
|
|
61
|
+
export interface AcceptedBreak {
|
|
62
|
+
/** Must equal the DiffResult.path of the breaking change being accepted, e.g. "root -> build -> option[--target]". */
|
|
63
|
+
readonly path: string;
|
|
64
|
+
/** Why this break is intentional - required, never blank, shown alongside the change. */
|
|
65
|
+
readonly reason: string;
|
|
66
|
+
/** ISO-8601 timestamp of when `cliguard accept` recorded this. */
|
|
67
|
+
readonly acceptedAt: string;
|
|
68
|
+
}
|
|
54
69
|
/** Severity of a single detected difference between two contracts. */
|
|
55
70
|
export declare enum ChangeType {
|
|
56
71
|
/** Removes or narrows something a caller may already depend on. */
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cliguard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Snapshot-tests your CLI's contract (commands, flags, defaults) so you never ship a breaking change by accident.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
7
7
|
"contract-testing",
|
|
8
8
|
"snapshot-testing",
|
|
9
9
|
"commander",
|
|
10
|
+
"yargs",
|
|
10
11
|
"ci"
|
|
11
12
|
],
|
|
12
13
|
"author": "Bryandero98",
|
|
@@ -39,16 +40,21 @@
|
|
|
39
40
|
"commander": "^12.1.0"
|
|
40
41
|
},
|
|
41
42
|
"peerDependencies": {
|
|
42
|
-
"cac": "^6.0.0"
|
|
43
|
+
"cac": "^6.0.0",
|
|
44
|
+
"yargs": "^17.0.0"
|
|
43
45
|
},
|
|
44
46
|
"peerDependenciesMeta": {
|
|
45
47
|
"cac": {
|
|
46
48
|
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"yargs": {
|
|
51
|
+
"optional": true
|
|
47
52
|
}
|
|
48
53
|
},
|
|
49
54
|
"devDependencies": {
|
|
50
55
|
"@types/jest": "^29.5.13",
|
|
51
56
|
"@types/node": "^22.7.4",
|
|
57
|
+
"@types/yargs": "^17.0.33",
|
|
52
58
|
"@typescript-eslint/eslint-plugin": "^8.8.0",
|
|
53
59
|
"@typescript-eslint/parser": "^8.8.0",
|
|
54
60
|
"cac": "^6.7.14",
|
|
@@ -57,7 +63,8 @@
|
|
|
57
63
|
"jest": "^29.7.0",
|
|
58
64
|
"prettier": "^3.3.3",
|
|
59
65
|
"ts-jest": "^29.2.5",
|
|
60
|
-
"typescript": "^5.6.2"
|
|
66
|
+
"typescript": "^5.6.2",
|
|
67
|
+
"yargs": "^17.7.2"
|
|
61
68
|
},
|
|
62
69
|
"engines": {
|
|
63
70
|
"node": ">=18"
|