cliguard 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cliguard contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # cliguard
2
+
3
+ Snapshot testing for CLI contracts.
4
+
5
+ ## The problem
6
+
7
+ REST and GraphQL APIs have contract testing (Pact, Specmatic, oasdiff) baked into every serious CI pipeline. CLIs don't. So a CLI's breaking changes ship silently: a flag quietly goes from optional to required, a subcommand gets renamed, a default value changes - and the first place anyone finds out is a Slack message from whoever's automation script just started failing in production.
8
+
9
+ ## The solution
10
+
11
+ `cliguard` captures your CLI's real contract - every command, flag, default, and required argument - straight from your CLI framework's own object graph, not by parsing `--help` text. Commit that contract like a snapshot test. From then on, `cliguard check` fails your build the moment a change would break an existing caller, and passes straight through anything additive or cosmetic.
12
+
13
+ ```text
14
+ 🔴 [root -> build -> option[--target]] Required option "--target" was removed.
15
+ 🟢 [root -> build -> option[--dry-run]] New optional option "--dry-run" was added.
16
+ ```
17
+
18
+ The first line fails your CI. The second one doesn't - `--dry-run` is new and optional, so nothing that already calls your CLI can break because of it.
19
+
20
+ ## Quick start
21
+
22
+ ```sh
23
+ npm install --save-dev cliguard
24
+ ```
25
+
26
+ Your CLI's entry file needs to **export** its Commander `Command` instance instead of calling `.parse()` itself:
27
+
28
+ ```js
29
+ // bin/cli.js
30
+ const { Command } = require("commander");
31
+
32
+ const program = new Command();
33
+ program.command("build").requiredOption("-t, --target <target>", "build target");
34
+ // ...
35
+
36
+ module.exports = { program }; // <- cliguard reads this, never runs it
37
+ ```
38
+
39
+ Then:
40
+
41
+ ```sh
42
+ # Capture the current contract - commit .cliguard/contract.json
43
+ npx cliguard init ./bin/cli.js
44
+
45
+ # In CI: fail the build on any breaking change
46
+ npx cliguard check ./bin/cli.js
47
+
48
+ # You changed something on purpose? Accept the new contract.
49
+ npx cliguard update ./bin/cli.js
50
+ ```
51
+
52
+ `cliguard check` exits `1` if it finds even one `BREAKING` change, and `0` otherwise - safe to drop straight into any CI pipeline.
53
+
54
+ ## How changes get classified
55
+
56
+ | | Removed | Added | Required flipped | Value type / default changed |
57
+ |---|---|---|---|---|
58
+ | **Command** | 🔴 BREAKING | 🟢 ADDITIVE | - | - |
59
+ | **Option / argument** | 🔴 BREAKING | 🟢 ADDITIVE (optional) / 🔴 BREAKING (required) | 🔴 optional→required · 🟡 required→optional | 🔴 BREAKING |
60
+ | **Alias** | 🔴 BREAKING | 🟡 PATCH | - | - |
61
+ | **Description** | - | - | - | 🟡 PATCH |
62
+
63
+ 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.
64
+
65
+ ## CI integration
66
+
67
+ ```yaml
68
+ # .github/workflows/cliguard.yml
69
+ name: CLI contract
70
+ on: [pull_request]
71
+ jobs:
72
+ check:
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - uses: actions/checkout@v4
76
+ - uses: actions/setup-node@v4
77
+ with: { node-version: 22.x }
78
+ - run: npm ci
79
+ - run: npx cliguard check ./bin/cli.js
80
+ ```
81
+
82
+ ## Supported frameworks
83
+
84
+ [Commander.js](https://github.com/tj/commander.js) today - it's what Vue CLI, Prettier, and a large share of the npm CLI ecosystem is built on. 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. See the [good first issues](https://github.com/Bryandero98/cliguard/labels/good%20first%20issue) for exactly that.
85
+
86
+ 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 - Yargs and CAC are both open. 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.
87
+
88
+ ## Roadmap
89
+
90
+ The CLI and core diffing engine are, and will stay, free and open-source. Planned next: a hosted add-on for teams that want more than a CI exit code - a dashboard with the history of contract changes across releases, and Slack/webhook alerts the moment a breaking change lands. See [issue: Webhook reporter for SaaS integration](https://github.com/Bryandero98/cliguard/issues) for the first building block.
91
+
92
+ ## Contributing
93
+
94
+ See [CONTRIBUTING.md](./CONTRIBUTING.md).
95
+
96
+ ## License
97
+
98
+ [MIT](./LICENSE)
@@ -0,0 +1,13 @@
1
+ import type { Contract } from "../core/types";
2
+ /**
3
+ * Everything the diff engine and the `cliguard` CLI commands need from a
4
+ * framework adapter. Each adapter owns exactly one framework's introspection
5
+ * details; nothing outside `src/adapters/` should ever import a framework
6
+ * package (`commander`, eventually `yargs`, etc.) directly.
7
+ */
8
+ export interface CliAdapter {
9
+ /** Adapter identifier stored in `Contract.adapter`, e.g. "commander". */
10
+ readonly id: string;
11
+ /** Loads `entryPath` and extracts its full command surface as a Contract. */
12
+ extract(entryPath: string): Promise<Contract>;
13
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,24 @@
1
+ import type { Contract } from "../core/types";
2
+ import type { CliAdapter } from "./adapter.interface";
3
+ /**
4
+ * Extracts a Contract from a target file that exports a Commander.js
5
+ * `Command` instance. Never parses --help output - every field comes
6
+ * straight from Commander's own object graph (`.options`, `.commands`,
7
+ * `.registeredArguments`), so a change here can only ever be a mapping bug,
8
+ * never a text-format regression.
9
+ */
10
+ export declare class CommanderAdapter implements CliAdapter {
11
+ readonly id = "commander";
12
+ extract(entryPath: string): Promise<Contract>;
13
+ /** Tries `import()` first, then falls back to `require()` for entry points that don't support ESM dynamic import. */
14
+ private loadCommand;
15
+ private tryLoad;
16
+ /** Handles `export default`, `module.exports = program`, and named exports. */
17
+ private findCommand;
18
+ /** Recurses into `command.commands` so root and every subcommand at any depth go through the same mapping. */
19
+ private mapCommand;
20
+ private mapOption;
21
+ /** `<value>` = required value, `[value]` = optional value, neither = boolean flag. Read from Commander's own flag declaration, not rendered --help text. */
22
+ private inferValueType;
23
+ private mapArguments;
24
+ }
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CommanderAdapter = void 0;
37
+ const path_1 = require("path");
38
+ const commander_1 = require("commander");
39
+ /**
40
+ * Extracts a Contract from a target file that exports a Commander.js
41
+ * `Command` instance. Never parses --help output - every field comes
42
+ * straight from Commander's own object graph (`.options`, `.commands`,
43
+ * `.registeredArguments`), so a change here can only ever be a mapping bug,
44
+ * never a text-format regression.
45
+ */
46
+ class CommanderAdapter {
47
+ constructor() {
48
+ this.id = "commander";
49
+ }
50
+ async extract(entryPath) {
51
+ const program = await this.loadCommand(entryPath);
52
+ return {
53
+ contractVersion: 1,
54
+ adapter: this.id,
55
+ capturedAt: new Date().toISOString(),
56
+ root: this.mapCommand(program),
57
+ };
58
+ }
59
+ /** Tries `import()` first, then falls back to `require()` for entry points that don't support ESM dynamic import. */
60
+ async loadCommand(entryPath) {
61
+ // A relative entryPath (as typed on the command line) must resolve
62
+ // against the caller's cwd, not against this file's own location -
63
+ // both import() and require() would otherwise resolve it relative to
64
+ // dist/, silently loading the wrong (or no) file.
65
+ const absolutePath = (0, path_1.resolve)(process.cwd(), entryPath);
66
+ const viaImport = await this.tryLoad(() => Promise.resolve(`${absolutePath}`).then(s => __importStar(require(s))));
67
+ if (viaImport)
68
+ return viaImport;
69
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- deliberate fallback for target CLIs that aren't import()-able
70
+ const viaRequire = await this.tryLoad(() => Promise.resolve(require(absolutePath)));
71
+ if (viaRequire)
72
+ return viaRequire;
73
+ throw new Error(`cliguard: no Commander.js Command instance found in "${entryPath}". ` +
74
+ "Export it as `export default program`, `module.exports = program`, " +
75
+ "or a named export (e.g. `export const program = new Command()`).");
76
+ }
77
+ async tryLoad(load) {
78
+ let moduleExports;
79
+ try {
80
+ moduleExports = await load();
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ return this.findCommand(moduleExports);
86
+ }
87
+ /** Handles `export default`, `module.exports = program`, and named exports. */
88
+ findCommand(moduleExports) {
89
+ if (moduleExports instanceof commander_1.Command) {
90
+ return moduleExports;
91
+ }
92
+ if (moduleExports && typeof moduleExports === "object") {
93
+ const exportsObject = moduleExports;
94
+ if (exportsObject.default instanceof commander_1.Command) {
95
+ return exportsObject.default;
96
+ }
97
+ for (const value of Object.values(exportsObject)) {
98
+ if (value instanceof commander_1.Command)
99
+ return value;
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+ /** Recurses into `command.commands` so root and every subcommand at any depth go through the same mapping. */
105
+ mapCommand(command) {
106
+ return {
107
+ name: command.name(),
108
+ description: command.description() ?? "",
109
+ aliases: command.aliases(),
110
+ options: command.options.map((option) => this.mapOption(option)),
111
+ arguments: this.mapArguments(command),
112
+ subcommands: command.commands.map((subcommand) => this.mapCommand(subcommand)),
113
+ };
114
+ }
115
+ mapOption(option) {
116
+ return {
117
+ flags: option.flags,
118
+ name: option.name(),
119
+ aliases: option.short ? [option.short] : [],
120
+ description: option.description ?? "",
121
+ required: option.mandatory ?? false,
122
+ valueType: this.inferValueType(option.flags),
123
+ variadic: option.variadic ?? false,
124
+ defaultValue: option.defaultValue ?? null,
125
+ };
126
+ }
127
+ /** `<value>` = required value, `[value]` = optional value, neither = boolean flag. Read from Commander's own flag declaration, not rendered --help text. */
128
+ inferValueType(flags) {
129
+ return flags.includes("<") || flags.includes("[") ? "string" : "boolean";
130
+ }
131
+ mapArguments(command) {
132
+ const modern = command
133
+ .registeredArguments;
134
+ const legacy = command._args;
135
+ const args = modern ?? legacy ?? [];
136
+ return args.map((arg) => ({
137
+ name: arg.name(),
138
+ required: arg.required,
139
+ variadic: arg.variadic,
140
+ description: arg.description ?? "",
141
+ }));
142
+ }
143
+ }
144
+ exports.CommanderAdapter = CommanderAdapter;
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const commander_1 = require("commander");
5
+ const commander_adapter_1 = require("./adapters/commander.adapter");
6
+ const diff_engine_1 = require("./core/diff.engine");
7
+ const storage_1 = require("./core/storage");
8
+ const types_1 = require("./core/types");
9
+ const adapter = new commander_adapter_1.CommanderAdapter();
10
+ const diffEngine = new diff_engine_1.DiffEngine();
11
+ const program = new commander_1.Command();
12
+ program
13
+ .name("cliguard")
14
+ .description("Snapshot-tests your CLI's contract so you never ship a breaking change by accident.");
15
+ program
16
+ .command("init")
17
+ .description("Capture the current CLI surface as the committed contract")
18
+ .argument("<entry>", "path to the target CLI's entry file")
19
+ .action(async (entry) => {
20
+ if ((0, storage_1.contractExists)()) {
21
+ console.warn(`El contrato ya existe. Usa "cliguard update" para sobrescribirlo.`);
22
+ process.exit(1);
23
+ }
24
+ const contract = await adapter.extract(entry);
25
+ (0, storage_1.writeContract)(contract);
26
+ console.log(`✅ Contrato de CLI inicializado con éxito en ${(0, storage_1.getContractDisplayPath)()}.`);
27
+ });
28
+ program
29
+ .command("check")
30
+ .description("Compare the current CLI surface against the committed contract")
31
+ .argument("<entry>", "path to the target CLI's entry file")
32
+ .action(async (entry) => {
33
+ const oldContract = (0, storage_1.readContract)();
34
+ const newContract = await adapter.extract(entry);
35
+ const diff = diffEngine.compare(oldContract, newContract);
36
+ if (diff.length === 0) {
37
+ console.log("✅ El contrato de la CLI está intacto.");
38
+ process.exit(0);
39
+ }
40
+ printDiff(diff);
41
+ const hasBreaking = diff.some((entry) => entry.type === types_1.ChangeType.BREAKING);
42
+ process.exit(hasBreaking ? 1 : 0);
43
+ });
44
+ program
45
+ .command("update")
46
+ .description("Overwrite the committed contract with the CLI's current surface")
47
+ .argument("<entry>", "path to the target CLI's entry file")
48
+ .action(async (entry) => {
49
+ const contract = await adapter.extract(entry);
50
+ (0, storage_1.writeContract)(contract);
51
+ console.log("🔄 Contrato de CLI actualizado con éxito.");
52
+ });
53
+ function printDiff(diff) {
54
+ for (const entry of diff) {
55
+ console.log(`${emojiFor(entry.type)} [${entry.path}] ${entry.message}`);
56
+ }
57
+ }
58
+ function emojiFor(type) {
59
+ switch (type) {
60
+ case types_1.ChangeType.BREAKING:
61
+ return "🔴";
62
+ case types_1.ChangeType.PATCH:
63
+ return "🟡";
64
+ case types_1.ChangeType.ADDITIVE:
65
+ return "🟢";
66
+ default:
67
+ return "⚪";
68
+ }
69
+ }
70
+ program.parseAsync(process.argv).catch((error) => {
71
+ console.error(error instanceof Error ? error.message : error);
72
+ process.exit(1);
73
+ });
@@ -0,0 +1,29 @@
1
+ import { ChangeType, type Contract } from "./types";
2
+ export interface DiffResult {
3
+ readonly type: ChangeType;
4
+ /** Where in the command tree the change happened, e.g. "root -> build -> option[--target]". */
5
+ readonly path: string;
6
+ /** Human-readable description of exactly what changed. */
7
+ readonly message: string;
8
+ }
9
+ /**
10
+ * Compares two Contracts and returns every difference between them,
11
+ * classified as BREAKING, ADDITIVE, or PATCH. Purely structural: it only
12
+ * ever reads the Contract shape from `core/types.ts`, never a specific
13
+ * adapter, so it works identically regardless of which framework produced
14
+ * either contract. `capturedAt` is intentionally never read.
15
+ */
16
+ export declare class DiffEngine {
17
+ compare(oldContract: Contract, newContract: Contract): DiffResult[];
18
+ private compareCommands;
19
+ private compareSubcommands;
20
+ private compareOptions;
21
+ private compareOption;
22
+ private compareArguments;
23
+ private compareArgument;
24
+ /** Shared by command aliases and option aliases - the rules are identical for both. */
25
+ private compareAliases;
26
+ private indexByName;
27
+ /** Deliberately simple: option/argument defaults are JSON-serializable primitives or arrays, never objects where key order would matter. */
28
+ private deepEqual;
29
+ }
@@ -0,0 +1,265 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DiffEngine = void 0;
4
+ const types_1 = require("./types");
5
+ /**
6
+ * Compares two Contracts and returns every difference between them,
7
+ * classified as BREAKING, ADDITIVE, or PATCH. Purely structural: it only
8
+ * ever reads the Contract shape from `core/types.ts`, never a specific
9
+ * adapter, so it works identically regardless of which framework produced
10
+ * either contract. `capturedAt` is intentionally never read.
11
+ */
12
+ class DiffEngine {
13
+ compare(oldContract, newContract) {
14
+ const results = [];
15
+ if (oldContract.adapter !== newContract.adapter) {
16
+ results.push({
17
+ type: types_1.ChangeType.BREAKING,
18
+ path: "contract",
19
+ message: `Contract adapter changed from "${oldContract.adapter}" to "${newContract.adapter}" - the two snapshots are not comparable.`,
20
+ });
21
+ }
22
+ if (oldContract.contractVersion !== newContract.contractVersion) {
23
+ results.push({
24
+ type: types_1.ChangeType.BREAKING,
25
+ path: "contract",
26
+ message: `Contract format version changed from ${oldContract.contractVersion} to ${newContract.contractVersion}.`,
27
+ });
28
+ }
29
+ results.push(...this.compareCommands(oldContract.root, newContract.root, "root"));
30
+ return results;
31
+ }
32
+ compareCommands(oldCmd, newCmd, path) {
33
+ const results = [];
34
+ if (oldCmd.description !== newCmd.description) {
35
+ results.push({
36
+ type: types_1.ChangeType.PATCH,
37
+ path,
38
+ message: `Description changed for command "${oldCmd.name}".`,
39
+ });
40
+ }
41
+ results.push(...this.compareAliases(oldCmd.aliases, newCmd.aliases, path, `command "${oldCmd.name}"`));
42
+ results.push(...this.compareOptions(oldCmd.options, newCmd.options, path));
43
+ results.push(...this.compareArguments(oldCmd.arguments, newCmd.arguments, path));
44
+ results.push(...this.compareSubcommands(oldCmd.subcommands, newCmd.subcommands, path));
45
+ return results;
46
+ }
47
+ compareSubcommands(oldSubs, newSubs, path) {
48
+ const results = [];
49
+ const oldByName = this.indexByName(oldSubs);
50
+ const newByName = this.indexByName(newSubs);
51
+ for (const [name, oldSub] of oldByName) {
52
+ const childPath = `${path} -> ${name}`;
53
+ const newSub = newByName.get(name);
54
+ if (!newSub) {
55
+ results.push({
56
+ type: types_1.ChangeType.BREAKING,
57
+ path: childPath,
58
+ message: `Command "${name}" was removed.`,
59
+ });
60
+ continue;
61
+ }
62
+ results.push(...this.compareCommands(oldSub, newSub, childPath));
63
+ }
64
+ for (const name of newByName.keys()) {
65
+ if (oldByName.has(name))
66
+ continue;
67
+ results.push({
68
+ type: types_1.ChangeType.ADDITIVE,
69
+ path: `${path} -> ${name}`,
70
+ message: `Command "${name}" was added.`,
71
+ });
72
+ }
73
+ return results;
74
+ }
75
+ compareOptions(oldOptions, newOptions, path) {
76
+ const results = [];
77
+ const oldByName = this.indexByName(oldOptions);
78
+ const newByName = this.indexByName(newOptions);
79
+ for (const [name, oldOption] of oldByName) {
80
+ const optionPath = `${path} -> option[--${name}]`;
81
+ const newOption = newByName.get(name);
82
+ if (!newOption) {
83
+ results.push({
84
+ type: types_1.ChangeType.BREAKING,
85
+ path: optionPath,
86
+ message: `Required option "--${name}" was removed.`,
87
+ });
88
+ continue;
89
+ }
90
+ results.push(...this.compareOption(oldOption, newOption, optionPath));
91
+ }
92
+ for (const [name, newOption] of newByName) {
93
+ if (oldByName.has(name))
94
+ continue;
95
+ const optionPath = `${path} -> option[--${name}]`;
96
+ if (newOption.required) {
97
+ results.push({
98
+ type: types_1.ChangeType.BREAKING,
99
+ path: optionPath,
100
+ message: `New required option "--${name}" was added - existing invocations that don't pass it will now fail.`,
101
+ });
102
+ }
103
+ else {
104
+ results.push({
105
+ type: types_1.ChangeType.ADDITIVE,
106
+ path: optionPath,
107
+ message: `New optional option "--${name}" was added.`,
108
+ });
109
+ }
110
+ }
111
+ return results;
112
+ }
113
+ compareOption(oldOption, newOption, path) {
114
+ const results = [];
115
+ const label = `Option "--${oldOption.name}"`;
116
+ if (!oldOption.required && newOption.required) {
117
+ results.push({
118
+ type: types_1.ChangeType.BREAKING,
119
+ path,
120
+ message: `${label} became required - existing invocations that don't pass it will now fail.`,
121
+ });
122
+ }
123
+ else if (oldOption.required && !newOption.required) {
124
+ results.push({
125
+ type: types_1.ChangeType.PATCH,
126
+ path,
127
+ message: `${label} became optional - backward compatible.`,
128
+ });
129
+ }
130
+ if (oldOption.valueType !== newOption.valueType) {
131
+ results.push({
132
+ type: types_1.ChangeType.BREAKING,
133
+ path,
134
+ message: `${label} changed value type from "${oldOption.valueType}" to "${newOption.valueType}".`,
135
+ });
136
+ }
137
+ if (oldOption.variadic !== newOption.variadic) {
138
+ results.push({
139
+ type: types_1.ChangeType.BREAKING,
140
+ path,
141
+ message: `${label} ${newOption.variadic ? "became variadic" : "stopped being variadic"} - the number of values it accepts changed.`,
142
+ });
143
+ }
144
+ if (!this.deepEqual(oldOption.defaultValue, newOption.defaultValue)) {
145
+ results.push({
146
+ type: types_1.ChangeType.BREAKING,
147
+ path,
148
+ message: `${label} default value changed from ${JSON.stringify(oldOption.defaultValue)} to ${JSON.stringify(newOption.defaultValue)}.`,
149
+ });
150
+ }
151
+ results.push(...this.compareAliases(oldOption.aliases, newOption.aliases, path, label));
152
+ if (oldOption.description !== newOption.description) {
153
+ results.push({
154
+ type: types_1.ChangeType.PATCH,
155
+ path,
156
+ message: `${label} description changed.`,
157
+ });
158
+ }
159
+ return results;
160
+ }
161
+ compareArguments(oldArgs, newArgs, path) {
162
+ const results = [];
163
+ const oldByName = this.indexByName(oldArgs);
164
+ const newByName = this.indexByName(newArgs);
165
+ for (const [name, oldArg] of oldByName) {
166
+ const argPath = `${path} -> argument[<${name}>]`;
167
+ const newArg = newByName.get(name);
168
+ if (!newArg) {
169
+ results.push({
170
+ type: types_1.ChangeType.BREAKING,
171
+ path: argPath,
172
+ message: `Argument "<${name}>" was removed.`,
173
+ });
174
+ continue;
175
+ }
176
+ results.push(...this.compareArgument(oldArg, newArg, argPath));
177
+ }
178
+ for (const [name, newArg] of newByName) {
179
+ if (oldByName.has(name))
180
+ continue;
181
+ const argPath = `${path} -> argument[<${name}>]`;
182
+ if (newArg.required) {
183
+ results.push({
184
+ type: types_1.ChangeType.BREAKING,
185
+ path: argPath,
186
+ message: `New required argument "<${name}>" was added - existing invocations that don't pass it will now fail.`,
187
+ });
188
+ }
189
+ else {
190
+ results.push({
191
+ type: types_1.ChangeType.ADDITIVE,
192
+ path: argPath,
193
+ message: `New optional argument "<${name}>" was added.`,
194
+ });
195
+ }
196
+ }
197
+ return results;
198
+ }
199
+ compareArgument(oldArg, newArg, path) {
200
+ const results = [];
201
+ const label = `Argument "<${oldArg.name}>"`;
202
+ if (!oldArg.required && newArg.required) {
203
+ results.push({
204
+ type: types_1.ChangeType.BREAKING,
205
+ path,
206
+ message: `${label} became required - existing invocations that don't pass it will now fail.`,
207
+ });
208
+ }
209
+ else if (oldArg.required && !newArg.required) {
210
+ results.push({
211
+ type: types_1.ChangeType.PATCH,
212
+ path,
213
+ message: `${label} became optional - backward compatible.`,
214
+ });
215
+ }
216
+ if (oldArg.variadic !== newArg.variadic) {
217
+ results.push({
218
+ type: types_1.ChangeType.BREAKING,
219
+ path,
220
+ message: `${label} ${newArg.variadic ? "became variadic" : "stopped being variadic"} - the number of values it accepts changed.`,
221
+ });
222
+ }
223
+ if (oldArg.description !== newArg.description) {
224
+ results.push({
225
+ type: types_1.ChangeType.PATCH,
226
+ path,
227
+ message: `${label} description changed.`,
228
+ });
229
+ }
230
+ return results;
231
+ }
232
+ /** Shared by command aliases and option aliases - the rules are identical for both. */
233
+ compareAliases(oldAliases, newAliases, path, label) {
234
+ const results = [];
235
+ const oldSet = new Set(oldAliases);
236
+ const newSet = new Set(newAliases);
237
+ for (const alias of oldSet) {
238
+ if (!newSet.has(alias)) {
239
+ results.push({
240
+ type: types_1.ChangeType.BREAKING,
241
+ path,
242
+ message: `Alias "${alias}" was removed from ${label}.`,
243
+ });
244
+ }
245
+ }
246
+ for (const alias of newSet) {
247
+ if (!oldSet.has(alias)) {
248
+ results.push({
249
+ type: types_1.ChangeType.PATCH,
250
+ path,
251
+ message: `Alias "${alias}" was added to ${label}.`,
252
+ });
253
+ }
254
+ }
255
+ return results;
256
+ }
257
+ indexByName(items) {
258
+ return new Map(items.map((item) => [item.name, item]));
259
+ }
260
+ /** Deliberately simple: option/argument defaults are JSON-serializable primitives or arrays, never objects where key order would matter. */
261
+ deepEqual(a, b) {
262
+ return JSON.stringify(a) === JSON.stringify(b);
263
+ }
264
+ }
265
+ exports.DiffEngine = DiffEngine;
@@ -0,0 +1,6 @@
1
+ import type { Contract } from "./types";
2
+ /** Contract path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
3
+ export declare function getContractDisplayPath(): string;
4
+ export declare function contractExists(): boolean;
5
+ export declare function readContract(): Contract;
6
+ export declare function writeContract(contract: Contract): void;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getContractDisplayPath = getContractDisplayPath;
4
+ exports.contractExists = contractExists;
5
+ exports.readContract = readContract;
6
+ exports.writeContract = writeContract;
7
+ const fs_1 = require("fs");
8
+ const path_1 = require("path");
9
+ const CONTRACT_PATH = (0, path_1.join)(process.cwd(), ".cliguard", "contract.json");
10
+ /** Contract path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
11
+ function getContractDisplayPath() {
12
+ return (0, path_1.relative)(process.cwd(), CONTRACT_PATH).split("\\").join("/");
13
+ }
14
+ function contractExists() {
15
+ return (0, fs_1.existsSync)(CONTRACT_PATH);
16
+ }
17
+ function readContract() {
18
+ if (!(0, fs_1.existsSync)(CONTRACT_PATH)) {
19
+ throw new Error(`cliguard: no contract found at "${getContractDisplayPath()}". Run \`cliguard init <entry.js>\` first.`);
20
+ }
21
+ return JSON.parse((0, fs_1.readFileSync)(CONTRACT_PATH, "utf-8"));
22
+ }
23
+ function writeContract(contract) {
24
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(CONTRACT_PATH), { recursive: true });
25
+ (0, fs_1.writeFileSync)(CONTRACT_PATH, JSON.stringify(contract, null, 2) + "\n", "utf-8");
26
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Framework-agnostic representation of a CLI's public surface. Every
3
+ * adapter (Commander today, Yargs/Clap/Cobra later) normalizes whatever
4
+ * that framework exposes internally into exactly these shapes - the
5
+ * diff engine and the `.cliguard/contract.json` file on disk never know
6
+ * which framework produced a Contract.
7
+ */
8
+ /** How a captured option carries its value, as declared by the framework - never guessed from parsing text. */
9
+ export type OptionValueType = "boolean" | "string";
10
+ export interface OptionContract {
11
+ /** Raw flag declaration as the framework received it, e.g. "-o, --output <path>". Informational only - never diffed directly. */
12
+ readonly flags: string;
13
+ /** Normalized long-form name with no leading dashes, e.g. "output". This is the diff key. */
14
+ readonly name: string;
15
+ /** Short forms / synonyms, e.g. ["-o"]. Order is not significant. */
16
+ readonly aliases: readonly string[];
17
+ readonly description: string;
18
+ /** True for a mandatory option (e.g. Commander's requiredOption). */
19
+ readonly required: boolean;
20
+ readonly valueType: OptionValueType;
21
+ /** True if the option can be passed more than once / collects multiple values. */
22
+ readonly variadic: boolean;
23
+ /** JSON-serializable default, or null if the framework declared none. */
24
+ readonly defaultValue: unknown;
25
+ }
26
+ export interface ArgumentContract {
27
+ /** Positional argument name, e.g. "file" from "<file>" or "[file]". */
28
+ readonly name: string;
29
+ readonly required: boolean;
30
+ readonly variadic: boolean;
31
+ readonly description: string;
32
+ }
33
+ export interface CommandContract {
34
+ readonly name: string;
35
+ readonly description: string;
36
+ readonly aliases: readonly string[];
37
+ readonly options: readonly OptionContract[];
38
+ readonly arguments: readonly ArgumentContract[];
39
+ readonly subcommands: readonly CommandContract[];
40
+ }
41
+ /**
42
+ * The full committed shape of `.cliguard/contract.json`. `contractVersion`
43
+ * is this *format's* own schema version (bumped only if we change what a
44
+ * Contract can express), never the target CLI's version.
45
+ */
46
+ export interface Contract {
47
+ readonly contractVersion: 1;
48
+ /** Identifier of the adapter that produced this contract, e.g. "commander". */
49
+ readonly adapter: string;
50
+ /** ISO-8601 capture timestamp. Informational only - excluded from diffing. */
51
+ readonly capturedAt: string;
52
+ readonly root: CommandContract;
53
+ }
54
+ /** Severity of a single detected difference between two contracts. */
55
+ export declare enum ChangeType {
56
+ /** Removes or narrows something a caller may already depend on. */
57
+ BREAKING = "BREAKING",
58
+ /** Purely additive - existing callers are unaffected. */
59
+ ADDITIVE = "ADDITIVE",
60
+ /** Cosmetic only (help text, alias reordering with no collision). */
61
+ PATCH = "PATCH",
62
+ /** No difference at all. Not expected to appear in a diff result list. */
63
+ NONE = "NONE"
64
+ }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ /**
3
+ * Framework-agnostic representation of a CLI's public surface. Every
4
+ * adapter (Commander today, Yargs/Clap/Cobra later) normalizes whatever
5
+ * that framework exposes internally into exactly these shapes - the
6
+ * diff engine and the `.cliguard/contract.json` file on disk never know
7
+ * which framework produced a Contract.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.ChangeType = void 0;
11
+ /** Severity of a single detected difference between two contracts. */
12
+ var ChangeType;
13
+ (function (ChangeType) {
14
+ /** Removes or narrows something a caller may already depend on. */
15
+ ChangeType["BREAKING"] = "BREAKING";
16
+ /** Purely additive - existing callers are unaffected. */
17
+ ChangeType["ADDITIVE"] = "ADDITIVE";
18
+ /** Cosmetic only (help text, alias reordering with no collision). */
19
+ ChangeType["PATCH"] = "PATCH";
20
+ /** No difference at all. Not expected to appear in a diff result list. */
21
+ ChangeType["NONE"] = "NONE";
22
+ })(ChangeType || (exports.ChangeType = ChangeType = {}));
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "cliguard",
3
+ "version": "0.1.0",
4
+ "description": "Snapshot-tests your CLI's contract (commands, flags, defaults) so you never ship a breaking change by accident.",
5
+ "keywords": ["cli", "contract-testing", "snapshot-testing", "commander", "ci"],
6
+ "author": "Bryandero98",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/Bryandero98/cliguard",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Bryandero98/cliguard.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/Bryandero98/cliguard/issues"
15
+ },
16
+ "bin": {
17
+ "cliguard": "dist/bin.js"
18
+ },
19
+ "files": ["dist"],
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "lint": "eslint src --ext .ts",
23
+ "lint:fix": "eslint src --ext .ts --fix",
24
+ "format": "prettier --write \"src/**/*.ts\"",
25
+ "test": "jest",
26
+ "test:watch": "jest --watch"
27
+ },
28
+ "dependencies": {
29
+ "commander": "^12.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/jest": "^29.5.13",
33
+ "@types/node": "^22.7.4",
34
+ "@typescript-eslint/eslint-plugin": "^8.8.0",
35
+ "@typescript-eslint/parser": "^8.8.0",
36
+ "eslint": "^8.57.1",
37
+ "eslint-config-prettier": "^9.1.0",
38
+ "jest": "^29.7.0",
39
+ "prettier": "^3.3.3",
40
+ "ts-jest": "^29.2.5",
41
+ "typescript": "^5.6.2"
42
+ },
43
+ "engines": {
44
+ "node": ">=18"
45
+ }
46
+ }