cliguard 0.7.1 → 0.7.2

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
@@ -81,6 +81,28 @@ npx cliguard init ./bin/cli.js --adapter yargs
81
81
 
82
82
  Like `cac`, `yargs` itself is an optional dependency of cliguard - only installed if you actually use `--adapter yargs`.
83
83
 
84
+ Built with Python's [Click](https://click.palletsprojects.com/) instead of a JS framework? Point cliguard straight at the `.py` file that defines your root command or group - no export needed, since Click's own `@click.command()`/`@click.group()` decorators already bind it as a module-level name:
85
+
86
+ ```py
87
+ # cli.py
88
+ import click
89
+
90
+ @click.group()
91
+ def cli():
92
+ pass
93
+
94
+ @cli.command()
95
+ @click.option("--target", "-t", required=True, help="build target")
96
+ def build(target):
97
+ ...
98
+ ```
99
+
100
+ ```sh
101
+ npx cliguard init ./cli.py --adapter click
102
+ ```
103
+
104
+ Unlike the JS adapters, `click` needs a real Python interpreter: cliguard shells out to `python3` (falling back to `python`) with `click` installed in that same environment - there's no in-process way to introspect a Python object from Node. `pip install click` in whichever Python cliguard's shell can already reach is all that's required; nothing npm-installable covers this one.
105
+
84
106
  ### Entry files that build the CLI lazily
85
107
 
86
108
  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.
@@ -155,6 +177,35 @@ module.exports = {
155
177
 
156
178
  `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
179
 
180
+ ### Monorepos with multiple CLI entry points
181
+
182
+ A monorepo shipping more than one CLI - a `packages/*` layout where two or three packages each have their own `bin` - doesn't need N separate `.cliguard/` directories and N hand-written CI steps. Declare every entry point once, as `targets` in `cliguard.config.js`:
183
+
184
+ ```js
185
+ // cliguard.config.js
186
+ module.exports = {
187
+ targets: [
188
+ { name: "cli-a", entry: "packages/cli-a/bin/index.js", adapter: "commander" },
189
+ { name: "cli-b", entry: "packages/cli-b/bin/index.js", adapter: "yargs" },
190
+ ],
191
+ };
192
+ ```
193
+
194
+ `init`/`check`/`update`/`accept` all pick it up - omit the entry argument to run against every configured target, or pass a target's `name` in its place to run just that one:
195
+
196
+ ```sh
197
+ npx cliguard init # initializes every target: .cliguard/cli-a/contract.json, .cliguard/cli-b/contract.json, ...
198
+ npx cliguard check # checks every target - exits 1 if ANY of them has an unacknowledged breaking change
199
+ npx cliguard check cli-a # just one, by name
200
+ npx cliguard accept cli-a "root -> build -> option[--target]" --reason "..." # accept is always single-target - see below
201
+ ```
202
+
203
+ Each target's contract, accepted breaks, and deprecations live under their own `.cliguard/<name>/` directory, so two targets never collide on disk. Running more than one target at once prints a `== <name> ==` banner between them; checking a single named target (or the classic single-CLI flow below) prints exactly as it always has, with no banner at all.
204
+
205
+ **This is additive, not a replacement for the single-CLI flow that's still most of cliguard's actual usage.** An explicit file-path entry (`npx cliguard check ./bin/cli.js`) keeps working exactly as it does today, `cliguard.config.js` or not - `targets` is only ever consulted when the entry argument is omitted, or when it exactly matches a configured target's `name`.
206
+
207
+ `accept` is the one exception to "omit entry to run every target": accepting a specific breaking change is inherently a one-target operation (a `changePath` on one CLI's contract has nothing to do with another CLI's), so its entry argument stays required - a literal path or a target's `name`, same as `check`.
208
+
158
209
  ### Comparing against a git ref instead of a local file
159
210
 
160
211
  `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:
@@ -207,6 +258,20 @@ It respects `.cliguard/accepted-breaks.json` the same way `check` does, and exit
207
258
  npx cliguard preview ./bin/cli.js --adapter yargs
208
259
  ```
209
260
 
261
+ ### Generating CLI reference docs that can't drift
262
+
263
+ `cliguard docs <entry>` walks the same extracted contract `check` already fails CI over, and renders it as Markdown - one section per command, a table of its arguments, a table of its options (flag, default, description, whether it's required):
264
+
265
+ ```sh
266
+ npx cliguard docs ./bin/cli.js > CLI.md
267
+ ```
268
+
269
+ Commit `CLI.md` and add `--check` to catch it going stale the same way `check` catches the contract going stale - it exits `1` the moment the generated docs and the committed file disagree, instead of drifting silently the way hand-written or copy-pasted-from-`--help` docs do:
270
+
271
+ ```sh
272
+ npx cliguard docs ./bin/cli.js --check CLI.md
273
+ ```
274
+
210
275
  ### Checking an adapter's real limitations, or sanity-checking one against your CLI
211
276
 
212
277
  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:
@@ -0,0 +1,34 @@
1
+ import type { Contract } from "../core/types";
2
+ import type { CliAdapter } from "./adapter.interface";
3
+ /**
4
+ * Extracts a Contract from a target Python file that defines a Click
5
+ * command or group at module level. Unlike every other adapter, this one
6
+ * can't load the target in-process (a Python object graph is unreachable
7
+ * from Node) - instead it pipes a small extractor script into `python -`
8
+ * as a subprocess, and that script does the actual introspection using
9
+ * Click's own object model (`.params`, `.commands`, `click.Option`/
10
+ * `click.Argument`), then prints one JSON object on stdout. Never parses
11
+ * `--help` output.
12
+ *
13
+ * The target file is imported (not executed as `__main__`), so its own
14
+ * `if __name__ == "__main__": cli()` guard never fires - only the
15
+ * module-level `@click.command()`/`@click.group()` decorations run, which
16
+ * is all that's needed to build the command tree. When more than one
17
+ * module-level Click command exists (e.g. both the root group and its own
18
+ * decorated subcommand functions are separately addressable module
19
+ * globals), the one whose own subtree (itself plus every nested
20
+ * subcommand) is largest wins - a real subcommand's subtree is always a
21
+ * strict subset of its parent group's, so the true root always scores
22
+ * highest.
23
+ */
24
+ export declare class ClickAdapter implements CliAdapter {
25
+ readonly id = "click";
26
+ readonly limitations: readonly string[];
27
+ extract(entryPath: string): Promise<Contract>;
28
+ private runExtractor;
29
+ private mapCommand;
30
+ private mapOption;
31
+ private mapArgument;
32
+ /** "--dry-run" -> "dry_run", "-v" -> "v" - Click's own name-inference rule, used to find which raw flag is the option's primary/canonical form. */
33
+ private normalizeFlag;
34
+ }
@@ -0,0 +1,248 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClickAdapter = void 0;
4
+ const child_process_1 = require("child_process");
5
+ const fs_1 = require("fs");
6
+ const path_1 = require("path");
7
+ /**
8
+ * Piped into `python -` (via stdin) rather than shipped as a separate .py
9
+ * file - keeps the extractor inside cliguard's own compiled dist/ output
10
+ * with zero extra packaging step, and guarantees the exact script that ran
11
+ * in this session's live testing is the one that ships.
12
+ *
13
+ * Verified live against a real Click CLI (group + nested group + required/
14
+ * repeatable/choice options + required/variadic arguments) before being
15
+ * embedded here - see the class doc comment below for what each design
16
+ * choice below is actually working around.
17
+ */
18
+ const EXTRACT_SCRIPT = `
19
+ import sys, os, json, importlib.util
20
+
21
+
22
+ def main():
23
+ if len(sys.argv) < 2:
24
+ print("cliguard: internal error: no entry path given to extractor", file=sys.stderr)
25
+ sys.exit(1)
26
+ entry_path = sys.argv[1]
27
+
28
+ try:
29
+ import click
30
+ except ImportError:
31
+ print(
32
+ "cliguard: the 'click' package is not installed in this Python environment. "
33
+ "Install it in the same environment cliguard's Python is running from "
34
+ "(e.g. \`pip install click\`).",
35
+ file=sys.stderr,
36
+ )
37
+ sys.exit(1)
38
+
39
+ module_name = "__cliguard_target__"
40
+ spec = importlib.util.spec_from_file_location(module_name, entry_path)
41
+ if spec is None or spec.loader is None:
42
+ print(f'cliguard: could not load "{entry_path}" as a Python module.', file=sys.stderr)
43
+ sys.exit(1)
44
+
45
+ module = importlib.util.module_from_spec(spec)
46
+ sys.modules[module_name] = module
47
+ sys.path.insert(0, os.path.dirname(os.path.abspath(entry_path)))
48
+ try:
49
+ spec.loader.exec_module(module)
50
+ except Exception:
51
+ import traceback
52
+
53
+ print(
54
+ f'cliguard: error importing "{entry_path}":\\n' + traceback.format_exc(),
55
+ file=sys.stderr,
56
+ )
57
+ sys.exit(1)
58
+
59
+ candidates = [
60
+ (name, value) for name, value in vars(module).items() if isinstance(value, click.Command)
61
+ ]
62
+
63
+ if not candidates:
64
+ print(
65
+ f'cliguard: no Click command found in "{entry_path}". '
66
+ "Define one at module level with @click.command() or @click.group() "
67
+ "(e.g. \`@click.group()\\\\ndef cli(): ...\`).",
68
+ file=sys.stderr,
69
+ )
70
+ sys.exit(1)
71
+
72
+ def subtree_size(cmd):
73
+ size = 1
74
+ if isinstance(cmd, click.Group):
75
+ for sub in cmd.commands.values():
76
+ size += subtree_size(sub)
77
+ return size
78
+
79
+ preferred_names = {"cli", "main", "app", "entry_point", "cmd"}
80
+
81
+ def score(item):
82
+ name, cmd = item
83
+ return (subtree_size(cmd), 1 if name in preferred_names else 0)
84
+
85
+ best_name, best_cmd = max(candidates, key=score)
86
+
87
+ def safe_default(value):
88
+ try:
89
+ json.dumps(value)
90
+ return value
91
+ except TypeError:
92
+ return None
93
+
94
+ def dump_param(p):
95
+ if isinstance(p, click.Argument):
96
+ return {
97
+ "type": "Argument",
98
+ "name": p.name,
99
+ "required": bool(p.required),
100
+ "nargs": p.nargs,
101
+ }
102
+ return {
103
+ "type": "Option",
104
+ "name": p.name,
105
+ "opts": list(p.opts),
106
+ "secondary_opts": list(p.secondary_opts),
107
+ "required": bool(p.required),
108
+ "is_flag": bool(p.is_flag),
109
+ "multiple": bool(p.multiple),
110
+ "default": safe_default(p.default),
111
+ "help": p.help,
112
+ }
113
+
114
+ def dump_command(cmd, name):
115
+ result = {
116
+ "name": name,
117
+ "help": cmd.help,
118
+ "short_help": cmd.get_short_help_str(),
119
+ "params": [dump_param(p) for p in cmd.params],
120
+ }
121
+ if isinstance(cmd, click.Group):
122
+ result["commands"] = {
123
+ sub_name: dump_command(sub_cmd, sub_name) for sub_name, sub_cmd in cmd.commands.items()
124
+ }
125
+ return result
126
+
127
+ print(json.dumps(dump_command(best_cmd, best_cmd.name or best_name)))
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
132
+ `;
133
+ /**
134
+ * Extracts a Contract from a target Python file that defines a Click
135
+ * command or group at module level. Unlike every other adapter, this one
136
+ * can't load the target in-process (a Python object graph is unreachable
137
+ * from Node) - instead it pipes a small extractor script into `python -`
138
+ * as a subprocess, and that script does the actual introspection using
139
+ * Click's own object model (`.params`, `.commands`, `click.Option`/
140
+ * `click.Argument`), then prints one JSON object on stdout. Never parses
141
+ * `--help` output.
142
+ *
143
+ * The target file is imported (not executed as `__main__`), so its own
144
+ * `if __name__ == "__main__": cli()` guard never fires - only the
145
+ * module-level `@click.command()`/`@click.group()` decorations run, which
146
+ * is all that's needed to build the command tree. When more than one
147
+ * module-level Click command exists (e.g. both the root group and its own
148
+ * decorated subcommand functions are separately addressable module
149
+ * globals), the one whose own subtree (itself plus every nested
150
+ * subcommand) is largest wins - a real subcommand's subtree is always a
151
+ * strict subset of its parent group's, so the true root always scores
152
+ * highest.
153
+ */
154
+ class ClickAdapter {
155
+ constructor() {
156
+ this.id = "click";
157
+ this.limitations = [
158
+ "CommandContract.aliases is always [] - Click's base Group/Command has no built-in alias concept (unlike Commander's .alias()).",
159
+ 'ArgumentContract.description is always "" - Click\'s click.Argument carries no help/description field, only click.Option does.',
160
+ 'OptionContract.valueType collapses every non-flag Click option (string, int, float, choice, path, ...) to "string" - Contract only distinguishes boolean vs. everything else, matching how CacAdapter/YargsAdapter already collapse their own richer type systems.',
161
+ "A --flag/--no-flag paired boolean toggle surfaces as one OptionContract, same as a plain is_flag option - the negative form is only visible informationally inside `flags`, not as a separate field.",
162
+ "Requires a `python3` or `python` on PATH with `click` installed in that same environment - unlike the JS adapters, which only need the target's own node_modules.",
163
+ ];
164
+ }
165
+ async extract(entryPath) {
166
+ const absolutePath = (0, path_1.resolve)(process.cwd(), entryPath);
167
+ if (!(0, fs_1.existsSync)(absolutePath)) {
168
+ throw new Error(`cliguard: no such file: "${absolutePath}".`);
169
+ }
170
+ const root = this.mapCommand(await this.runExtractor(absolutePath));
171
+ return {
172
+ contractVersion: 1,
173
+ adapter: this.id,
174
+ capturedAt: new Date().toISOString(),
175
+ root,
176
+ };
177
+ }
178
+ async runExtractor(absolutePath) {
179
+ for (const pythonExe of ["python3", "python"]) {
180
+ const result = (0, child_process_1.spawnSync)(pythonExe, ["-", absolutePath], {
181
+ input: EXTRACT_SCRIPT,
182
+ encoding: "utf8",
183
+ });
184
+ if (result.error && result.error.code === "ENOENT") {
185
+ continue;
186
+ }
187
+ if (result.status !== 0) {
188
+ throw new Error(`cliguard: failed to extract a Click contract from "${absolutePath}":\n` +
189
+ (result.stderr.trim() || `${pythonExe} exited with code ${result.status}`));
190
+ }
191
+ try {
192
+ return JSON.parse(result.stdout);
193
+ }
194
+ catch {
195
+ throw new Error(`cliguard: internal error - the Click extractor's output wasn't valid JSON:\n${result.stdout}`);
196
+ }
197
+ }
198
+ throw new Error("cliguard: no Python interpreter found (tried `python3` and `python`). " +
199
+ "The click adapter needs Python 3 with `click` installed on PATH to introspect a Click CLI.");
200
+ }
201
+ mapCommand(json) {
202
+ return {
203
+ name: json.name,
204
+ // See class limitations: Click has no per-command alias concept.
205
+ description: json.help ?? json.short_help ?? "",
206
+ aliases: [],
207
+ options: json.params
208
+ .filter((param) => param.type === "Option")
209
+ .map((param) => this.mapOption(param)),
210
+ arguments: json.params
211
+ .filter((param) => param.type === "Argument")
212
+ .map((param) => this.mapArgument(param)),
213
+ subcommands: json.commands
214
+ ? Object.values(json.commands).map((sub) => this.mapCommand(sub))
215
+ : [],
216
+ };
217
+ }
218
+ mapOption(param) {
219
+ const opts = [...(param.opts ?? []), ...(param.secondary_opts ?? [])];
220
+ const primary = opts.find((flag) => this.normalizeFlag(flag) === param.name) ??
221
+ opts.find((flag) => flag.startsWith("--")) ??
222
+ opts[0];
223
+ return {
224
+ flags: opts.join(", "),
225
+ name: param.name,
226
+ aliases: opts.filter((flag) => flag !== primary),
227
+ description: param.help ?? "",
228
+ required: param.required,
229
+ valueType: param.is_flag ? "boolean" : "string",
230
+ variadic: param.multiple ?? false,
231
+ defaultValue: param.default ?? null,
232
+ };
233
+ }
234
+ mapArgument(param) {
235
+ return {
236
+ name: param.name,
237
+ required: param.required,
238
+ variadic: (param.nargs ?? 1) === -1,
239
+ // See class limitations: click.Argument has no help/description field.
240
+ description: "",
241
+ };
242
+ }
243
+ /** "--dry-run" -> "dry_run", "-v" -> "v" - Click's own name-inference rule, used to find which raw flag is the option's primary/canonical form. */
244
+ normalizeFlag(flag) {
245
+ return flag.replace(/^-+/, "").replace(/-/g, "_");
246
+ }
247
+ }
248
+ exports.ClickAdapter = ClickAdapter;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.adapters = void 0;
4
4
  exports.resolveAdapter = resolveAdapter;
5
5
  const cac_adapter_1 = require("./cac.adapter");
6
+ const click_adapter_1 = require("./click.adapter");
6
7
  const commander_adapter_1 = require("./commander.adapter");
7
8
  const yargs_adapter_1 = require("./yargs.adapter");
8
9
  // Constructing an adapter here is cheap (no eager require of its
@@ -16,6 +17,7 @@ exports.adapters = {
16
17
  commander: new commander_adapter_1.CommanderAdapter(),
17
18
  cac: new cac_adapter_1.CacAdapter(),
18
19
  yargs: new yargs_adapter_1.YargsAdapter(),
20
+ click: new click_adapter_1.ClickAdapter(),
19
21
  };
20
22
  function resolveAdapter(name) {
21
23
  const adapter = exports.adapters[name];
package/dist/bin.js CHANGED
@@ -6,6 +6,7 @@ const path_1 = require("path");
6
6
  const registry_1 = require("./adapters/registry");
7
7
  const config_1 = require("./core/config");
8
8
  const diff_engine_1 = require("./core/diff.engine");
9
+ const docs_1 = require("./core/docs");
9
10
  const report_formats_1 = require("./core/report-formats");
10
11
  const storage_1 = require("./core/storage");
11
12
  const types_1 = require("./core/types");
@@ -22,6 +23,38 @@ const adapterOption = [
22
23
  "CLI framework adapter to use",
23
24
  "commander",
24
25
  ];
26
+ const ENTRY_ARGUMENT_DESCRIPTION = "path to the target CLI's entry file, or a configured cliguard.config.js target's name - " +
27
+ "omit to run every configured target (see cliguard.config.js's targets)";
28
+ /** Shared by init/check/update/accept: resolves what to run against, or prints resolveTargets's own error and exits 1 - identical failure shape across all four. */
29
+ function resolveTargetsOrExit(entry, cliAdapter) {
30
+ try {
31
+ return (0, config_1.resolveTargets)(entry, (0, config_1.loadConfig)(), cliAdapter);
32
+ }
33
+ catch (error) {
34
+ console.error(error instanceof Error ? error.message : String(error));
35
+ process.exit(1);
36
+ }
37
+ }
38
+ /**
39
+ * Runs `action` once per resolved target, printing a `== <name> ==` banner
40
+ * between them only when there's more than one - a single classic target
41
+ * (the common case: no cliguard.config.js, or one that matched no target
42
+ * name) prints exactly as it always has, with no banner at all. The
43
+ * overall exit code is 1 if ANY target's own action returned non-zero,
44
+ * matching `check`'s existing "any BREAKING change fails the build"
45
+ * semantics extended across targets instead of options/arguments.
46
+ */
47
+ async function runAcrossTargets(targets, action) {
48
+ let exitCode = 0;
49
+ for (const target of targets) {
50
+ if (targets.length > 1)
51
+ console.log(`\n== ${target.namespace} ==`);
52
+ const code = await action(target);
53
+ if (code !== 0)
54
+ exitCode = 1;
55
+ }
56
+ return exitCode;
57
+ }
25
58
  const REPORT_FORMATS = ["text", "json", "junit", "gitlab-codequality", "rdjsonl"];
26
59
  /** `--json` is a shorthand kept for backward compatibility - equivalent to `--format json` when no explicit `--format` is given. Returns null for an unrecognized `--format` value, distinct from every valid one including "text". */
27
60
  function resolveFormat(explicit, jsonFlag) {
@@ -46,55 +79,66 @@ function formatReport(diff, acceptedPaths, format, contractPath) {
46
79
  program
47
80
  .command("init")
48
81
  .description("Capture the current CLI surface as the committed contract")
49
- .argument("<entry>", "path to the target CLI's entry file")
82
+ .argument("[entry]", ENTRY_ARGUMENT_DESCRIPTION)
50
83
  .option(...adapterOption)
51
84
  .option("--with-ci", "also scaffold a GitHub Actions workflow that runs cliguard on every pull request", false)
52
85
  .action(async (entry, options) => {
53
- if ((0, storage_1.contractExists)()) {
54
- console.warn(`A contract already exists. Run "cliguard update" to overwrite it.`);
86
+ const targets = resolveTargetsOrExit(entry, options.adapter);
87
+ if (options.withCi && targets.length > 1) {
88
+ console.error("cliguard: --with-ci supports a single target at a time - run " +
89
+ "`cliguard init <name> --with-ci` for one target, or write the workflow by hand " +
90
+ "for a multi-target project.");
55
91
  process.exit(1);
56
92
  }
57
- const exitCode = await withSuppressedExit(async () => {
58
- const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
59
- (0, storage_1.writeContract)(contract);
60
- console.log(`✅ CLI contract initialized successfully at ${(0, storage_1.getContractDisplayPath)()}.`);
93
+ const exitCode = await runAcrossTargets(targets, (target) => withSuppressedExit(async () => {
94
+ if ((0, storage_1.contractExists)(target.namespace)) {
95
+ const suffix = target.namespace ? ` ${target.namespace}` : "";
96
+ console.warn(`A contract already exists. Run "cliguard update${suffix}" to overwrite it.`);
97
+ return 1;
98
+ }
99
+ const contract = await (0, registry_1.resolveAdapter)(target.adapter).extract(target.entry);
100
+ (0, storage_1.writeContract)(contract, target.namespace);
101
+ console.log(`✅ CLI contract initialized successfully at ${(0, storage_1.getContractDisplayPath)(target.namespace)}.`);
61
102
  if (options.withCi) {
62
103
  if ((0, storage_1.ciWorkflowExists)()) {
63
104
  console.log(`ℹ️ ${(0, storage_1.getCiWorkflowDisplayPath)()} already exists - left it untouched.`);
64
105
  }
65
106
  else {
66
- const entryPath = (0, path_1.relative)(process.cwd(), (0, path_1.resolve)(entry)).split("\\").join("/");
67
- (0, storage_1.writeCiWorkflow)(buildCiWorkflowYaml(entryPath, options.adapter));
107
+ const entryPath = (0, path_1.relative)(process.cwd(), (0, path_1.resolve)(target.entry)).split("\\").join("/");
108
+ (0, storage_1.writeCiWorkflow)(buildCiWorkflowYaml(entryPath, target.adapter));
68
109
  console.log(`✅ GitHub Actions workflow scaffolded at ${(0, storage_1.getCiWorkflowDisplayPath)()}.`);
69
110
  }
70
111
  }
71
112
  return 0;
72
- });
113
+ }));
73
114
  process.exit(exitCode);
74
115
  });
75
116
  program
76
117
  .command("check")
77
118
  .description("Compare the current CLI surface against the committed contract")
78
- .argument("<entry>", "path to the target CLI's entry file")
119
+ .argument("[entry]", ENTRY_ARGUMENT_DESCRIPTION)
79
120
  .option(...adapterOption)
80
121
  .option("--json", "print a machine-readable JSON result instead of text (shorthand for --format json)", false)
81
122
  .option("--format <format>", `output format: ${REPORT_FORMATS.join(", ")}`)
82
123
  .option("--against <ref>", "compare against a git ref's committed contract (e.g. origin/main, a tag, a commit sha) instead of the .cliguard/contract.json on disk")
83
124
  .option("--strict", "enable extra rules for currently-silent risky changes (e.g. a positional argument reorder)", false)
84
125
  .action(async (entry, options) => {
85
- const exitCode = await withSuppressedExit(async () => {
126
+ const targets = resolveTargetsOrExit(entry, options.adapter);
127
+ const exitCode = await runAcrossTargets(targets, (target) => withSuppressedExit(async () => {
86
128
  const format = resolveFormat(options.format, options.json);
87
129
  if (!format) {
88
130
  console.error(`cliguard: unknown --format "${options.format}". Use ${REPORT_FORMATS.join(", ")}.`);
89
131
  return 1;
90
132
  }
91
- const oldContract = options.against ? (0, storage_1.readContractAtRef)(options.against) : (0, storage_1.readContract)();
92
- const newContract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
93
- const diff = applyDeprecations(diffEngine.applyUnstableMarkers((0, config_1.applyConfig)(diffEngine.compare(oldContract, newContract, { strict: options.strict }), (0, config_1.loadConfig)()), oldContract, newContract), indexDeprecations((0, storage_1.readDeprecations)()));
94
- const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)());
133
+ const oldContract = options.against
134
+ ? (0, storage_1.readContractAtRef)(options.against, target.namespace)
135
+ : (0, storage_1.readContract)(target.namespace);
136
+ const newContract = await (0, registry_1.resolveAdapter)(target.adapter).extract(target.entry);
137
+ const diff = applyDeprecations(diffEngine.applyUnstableMarkers((0, config_1.applyConfig)(diffEngine.compare(oldContract, newContract, { strict: options.strict }), (0, config_1.loadConfig)()), oldContract, newContract), indexDeprecations((0, storage_1.readDeprecations)(target.namespace)));
138
+ const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)(target.namespace));
95
139
  const hasBreaking = diff.some((change) => change.type === types_1.ChangeType.BREAKING && !acceptedPaths.has(change.path));
96
140
  if (format !== "text") {
97
- console.log(formatReport(diff, acceptedPaths, format, (0, storage_1.getContractDisplayPath)()));
141
+ console.log(formatReport(diff, acceptedPaths, format, (0, storage_1.getContractDisplayPath)(target.namespace)));
98
142
  return hasBreaking ? 1 : 0;
99
143
  }
100
144
  if (diff.length === 0) {
@@ -103,27 +147,30 @@ program
103
147
  }
104
148
  printDiff(diff, acceptedPaths);
105
149
  return hasBreaking ? 1 : 0;
106
- });
150
+ }));
107
151
  process.exit(exitCode);
108
152
  });
109
153
  program
110
154
  .command("accept")
111
155
  .description("Record that a specific BREAKING change is intentional, so `check` stops failing CI for it")
112
- .argument("<entry>", "path to the target CLI's entry file")
156
+ .argument("<entry>", "path to the target CLI's entry file, or a configured cliguard.config.js target's name - " +
157
+ "always required here (unlike check/init/update): accepting a change is inherently a " +
158
+ 'one-target operation, so there\'s no sensible "omit to run every target" mode')
113
159
  .argument("<changePath>", 'the exact DiffResult path to accept, e.g. "root -> build -> option[--target]"')
114
160
  .requiredOption("-r, --reason <text>", "why this break is intentional - shown in check output")
115
161
  .option(...adapterOption)
116
162
  .option("--strict", "match against the same --strict rules used to find this change (e.g. a positional argument reorder) - required to accept one, since it's otherwise invisible to the default comparison", false)
117
163
  .action(async (entry, changePath, options) => {
118
- const exitCode = await withSuppressedExit(async () => {
164
+ const targets = resolveTargetsOrExit(entry, options.adapter);
165
+ const exitCode = await runAcrossTargets(targets, (target) => withSuppressedExit(async () => {
119
166
  const reason = options.reason.trim();
120
167
  if (!reason) {
121
168
  console.error("cliguard: --reason can't be blank - it's the audit trail for why this break is OK.");
122
169
  return 1;
123
170
  }
124
- const oldContract = (0, storage_1.readContract)();
125
- const newContract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
126
- const diff = applyDeprecations(diffEngine.applyUnstableMarkers((0, config_1.applyConfig)(diffEngine.compare(oldContract, newContract, { strict: options.strict }), (0, config_1.loadConfig)()), oldContract, newContract), indexDeprecations((0, storage_1.readDeprecations)()));
171
+ const oldContract = (0, storage_1.readContract)(target.namespace);
172
+ const newContract = await (0, registry_1.resolveAdapter)(target.adapter).extract(target.entry);
173
+ const diff = applyDeprecations(diffEngine.applyUnstableMarkers((0, config_1.applyConfig)(diffEngine.compare(oldContract, newContract, { strict: options.strict }), (0, config_1.loadConfig)()), oldContract, newContract), indexDeprecations((0, storage_1.readDeprecations)(target.namespace)));
127
174
  const match = diff.find((change) => change.type === types_1.ChangeType.BREAKING && change.path === changePath);
128
175
  if (!match) {
129
176
  const breaking = diff.filter((change) => change.type === types_1.ChangeType.BREAKING);
@@ -135,18 +182,18 @@ program
135
182
  }
136
183
  // Replaces any earlier acceptance at the same path rather than
137
184
  // accumulating duplicates - re-running `accept` updates the reason.
138
- const remaining = (0, storage_1.readAcceptedBreaks)().filter((accepted) => accepted.path !== changePath);
185
+ const remaining = (0, storage_1.readAcceptedBreaks)(target.namespace).filter((accepted) => accepted.path !== changePath);
139
186
  const accepted = {
140
187
  path: changePath,
141
188
  reason,
142
189
  acceptedAt: new Date().toISOString(),
143
190
  };
144
- (0, storage_1.writeAcceptedBreaks)([...remaining, accepted]);
191
+ (0, storage_1.writeAcceptedBreaks)([...remaining, accepted], target.namespace);
145
192
  console.log(`✅ Accepted: [${changePath}] ${match.message}`);
146
193
  console.log(` Reason: ${reason}`);
147
- console.log(` Recorded in ${(0, storage_1.getAcceptedBreaksDisplayPath)()} - commit this file.`);
194
+ console.log(` Recorded in ${(0, storage_1.getAcceptedBreaksDisplayPath)(target.namespace)} - commit this file.`);
148
195
  return 0;
149
- });
196
+ }));
150
197
  process.exit(exitCode);
151
198
  });
152
199
  program
@@ -190,15 +237,16 @@ program
190
237
  program
191
238
  .command("update")
192
239
  .description("Overwrite the committed contract with the CLI's current surface")
193
- .argument("<entry>", "path to the target CLI's entry file")
240
+ .argument("[entry]", ENTRY_ARGUMENT_DESCRIPTION)
194
241
  .option(...adapterOption)
195
242
  .action(async (entry, options) => {
196
- const exitCode = await withSuppressedExit(async () => {
197
- const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
198
- (0, storage_1.writeContract)(contract);
243
+ const targets = resolveTargetsOrExit(entry, options.adapter);
244
+ const exitCode = await runAcrossTargets(targets, (target) => withSuppressedExit(async () => {
245
+ const contract = await (0, registry_1.resolveAdapter)(target.adapter).extract(target.entry);
246
+ (0, storage_1.writeContract)(contract, target.namespace);
199
247
  console.log("🔄 CLI contract updated successfully.");
200
248
  return 0;
201
- });
249
+ }));
202
250
  process.exit(exitCode);
203
251
  });
204
252
  program
@@ -214,6 +262,40 @@ program
214
262
  });
215
263
  process.exit(exitCode);
216
264
  });
265
+ program
266
+ .command("docs")
267
+ .description("Generate Markdown CLI reference docs from the current contract - guaranteed to match the real CLI surface, since it's the same model `check` fails CI over")
268
+ .argument("<entry>", "path to the target CLI's entry file")
269
+ .option(...adapterOption)
270
+ .option("--check <path>", "compare against a committed docs file instead of printing - exits 1 if it's stale")
271
+ .action(async (entry, options) => {
272
+ const exitCode = await withSuppressedExit(async () => {
273
+ const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
274
+ const markdown = (0, docs_1.renderMarkdownDocs)(contract, (0, path_1.basename)(entry));
275
+ if (!options.check) {
276
+ // process.stdout.write, not console.log - markdown already ends
277
+ // in exactly one trailing newline, and console.log would add a
278
+ // second one, so a file saved via `> CLI.md` would never again
279
+ // match itself under `--check` (the mismatch this comment is
280
+ // replacing was caught by a live round-trip test, not review).
281
+ process.stdout.write(markdown);
282
+ return 0;
283
+ }
284
+ const committed = (0, storage_1.readTextFileIfExists)(options.check);
285
+ if (committed === null) {
286
+ console.error(`cliguard: no such file: "${options.check}". Run \`cliguard docs ${entry} > ${options.check}\` first.`);
287
+ return 1;
288
+ }
289
+ if (committed === markdown) {
290
+ console.log(`✅ ${options.check} matches the current CLI surface.`);
291
+ return 0;
292
+ }
293
+ console.error(`cliguard: "${options.check}" is stale - it no longer matches the current CLI surface. ` +
294
+ `Regenerate with \`cliguard docs ${entry} > ${options.check}\`.`);
295
+ return 1;
296
+ });
297
+ process.exit(exitCode);
298
+ });
217
299
  program
218
300
  .command("doctor")
219
301
  .description("Show every adapter's known limitations, or sanity-check one against a real entry file")
@@ -4,15 +4,47 @@ export interface SeverityOverride {
4
4
  readonly pattern: string | RegExp;
5
5
  readonly severity: ChangeType;
6
6
  }
7
+ export interface ConfigTarget {
8
+ /** Unique within the config; namespaces this target's contract/accepted-breaks/deprecations under `.cliguard/<name>/` instead of `.cliguard/`, and is what `cliguard check <name>` matches against to run just this one target. */
9
+ readonly name: string;
10
+ readonly entry: string;
11
+ /** Defaults to "commander", same as the CLI's own `--adapter` default. */
12
+ readonly adapter?: string;
13
+ }
7
14
  export interface CliguardConfig {
8
15
  /** A change whose DiffResult.path matches any of these is dropped from the report entirely - never shown, never counted, never fails the build. */
9
16
  readonly ignore?: readonly (string | RegExp)[];
10
17
  /** A change whose DiffResult.path matches `pattern` gets reclassified to `severity` - the first matching entry wins. */
11
18
  readonly severityOverrides?: readonly SeverityOverride[];
19
+ /** A monorepo's CLI entry points - `init`/`check`/`update`/`accept` run against every target when no entry is given on the command line, or against just one by passing its `name` in place of an entry path. An explicit file-path entry always keeps working exactly as it does today, config or no config - see resolveTargets. */
20
+ readonly targets?: readonly ConfigTarget[];
12
21
  }
13
22
  /** Display-only path of whichever config file was actually found, or the first candidate name if none was - only meaningful in an error message alongside `configExists()`. */
14
23
  export declare function getConfigDisplayPath(): string;
15
24
  export declare function configExists(): boolean;
25
+ /** One target `init`/`check`/`update`/`accept` should run against - `namespace` is `null` for the classic single-CLI flow (no config, or an explicit file-path entry), and a target's own `name` when resolved from `cliguard.config.js`, namespacing that target's `.cliguard/` files under `.cliguard/<namespace>/`. */
26
+ export interface ResolvedTarget {
27
+ readonly namespace: string | null;
28
+ readonly entry: string;
29
+ readonly adapter: string;
30
+ }
31
+ /**
32
+ * Turns a command-line `[entry]` (now optional - see bin.ts) plus whatever
33
+ * `cliguard.config.js` declared into the concrete list of targets a
34
+ * command should run against:
35
+ *
36
+ * - `entry` given and it matches no configured target's `name`: exactly
37
+ * what happens today with zero config - one classic, unnamespaced
38
+ * target, using `entry` as a literal file path. This is deliberate and
39
+ * load-bearing: a project with no `targets` in its config (or no config
40
+ * at all) is completely unaffected by this feature.
41
+ * - `entry` given and it DOES match a configured target's `name`: just
42
+ * that one target, namespaced.
43
+ * - `entry` omitted and `targets` is configured: every configured target.
44
+ * - `entry` omitted and no `targets` configured: throws - there's nothing
45
+ * to run against.
46
+ */
47
+ export declare function resolveTargets(entry: string | undefined, config: CliguardConfig, cliAdapter: string): ResolvedTarget[];
16
48
  /** Returns `{}` (no policy applied) when no config file exists - a project with no `cliguard.config.js` behaves exactly as it always has. */
17
49
  export declare function loadConfig(): CliguardConfig;
18
50
  /**
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getConfigDisplayPath = getConfigDisplayPath;
4
4
  exports.configExists = configExists;
5
+ exports.resolveTargets = resolveTargets;
5
6
  exports.loadConfig = loadConfig;
6
7
  exports.applyConfig = applyConfig;
7
8
  const fs_1 = require("fs");
@@ -54,8 +55,64 @@ function validateConfig(raw, displayPath) {
54
55
  }
55
56
  }
56
57
  }
58
+ if (config.targets !== undefined) {
59
+ if (!Array.isArray(config.targets)) {
60
+ throw new Error(`cliguard: ${displayPath}'s "targets" must be an array.`);
61
+ }
62
+ const seenNames = new Set();
63
+ for (const entry of config.targets) {
64
+ const target = entry;
65
+ const nameOk = typeof target.name === "string" && target.name.length > 0;
66
+ const entryOk = typeof target.entry === "string" && target.entry.length > 0;
67
+ const adapterOk = target.adapter === undefined || typeof target.adapter === "string";
68
+ if (!nameOk || !entryOk || !adapterOk) {
69
+ throw new Error(`cliguard: ${displayPath}'s "targets" entries must look like ` +
70
+ `{ name: string, entry: string, adapter?: string } - got ${JSON.stringify(entry)}.`);
71
+ }
72
+ if (seenNames.has(target.name)) {
73
+ throw new Error(`cliguard: ${displayPath}'s "targets" has more than one entry named "${target.name}" - ` +
74
+ "names must be unique, since each one gets its own .cliguard/<name>/ directory.");
75
+ }
76
+ seenNames.add(target.name);
77
+ }
78
+ }
57
79
  return config;
58
80
  }
81
+ /**
82
+ * Turns a command-line `[entry]` (now optional - see bin.ts) plus whatever
83
+ * `cliguard.config.js` declared into the concrete list of targets a
84
+ * command should run against:
85
+ *
86
+ * - `entry` given and it matches no configured target's `name`: exactly
87
+ * what happens today with zero config - one classic, unnamespaced
88
+ * target, using `entry` as a literal file path. This is deliberate and
89
+ * load-bearing: a project with no `targets` in its config (or no config
90
+ * at all) is completely unaffected by this feature.
91
+ * - `entry` given and it DOES match a configured target's `name`: just
92
+ * that one target, namespaced.
93
+ * - `entry` omitted and `targets` is configured: every configured target.
94
+ * - `entry` omitted and no `targets` configured: throws - there's nothing
95
+ * to run against.
96
+ */
97
+ function resolveTargets(entry, config, cliAdapter) {
98
+ const targets = config.targets ?? [];
99
+ if (entry !== undefined) {
100
+ const named = targets.find((target) => target.name === entry);
101
+ if (named) {
102
+ return [{ namespace: named.name, entry: named.entry, adapter: named.adapter ?? "commander" }];
103
+ }
104
+ return [{ namespace: null, entry, adapter: cliAdapter }];
105
+ }
106
+ if (targets.length > 0) {
107
+ return targets.map((target) => ({
108
+ namespace: target.name,
109
+ entry: target.entry,
110
+ adapter: target.adapter ?? "commander",
111
+ }));
112
+ }
113
+ throw new Error("cliguard: no entry given and no targets configured. Pass an entry file path, " +
114
+ "or add `targets: [{ name, entry, adapter? }, ...]` to cliguard.config.js.");
115
+ }
59
116
  /** Returns `{}` (no policy applied) when no config file exists - a project with no `cliguard.config.js` behaves exactly as it always has. */
60
117
  function loadConfig() {
61
118
  const path = resolveConfigPath();
@@ -0,0 +1,14 @@
1
+ import type { Contract } from "./types";
2
+ /**
3
+ * Renders a Contract as Markdown CLI reference docs - one section per
4
+ * command, an arguments table and an options table for each. Walks the
5
+ * same recursive `CommandContract` tree `diff.engine.ts` walks, so a
6
+ * command that `check` can already see is a command these docs can already
7
+ * render; nothing here reads a live CLI or a specific adapter.
8
+ *
9
+ * The point of generating this from the Contract rather than hand-writing
10
+ * it: the moment the docs would go stale, `check` already fails CI for the
11
+ * same underlying reason (the CLI's surface changed) - these docs literally
12
+ * cannot drift from reality without cliguard itself catching it first.
13
+ */
14
+ export declare function renderMarkdownDocs(contract: Contract, fallbackTitle: string): string;
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderMarkdownDocs = renderMarkdownDocs;
4
+ /**
5
+ * Renders a Contract as Markdown CLI reference docs - one section per
6
+ * command, an arguments table and an options table for each. Walks the
7
+ * same recursive `CommandContract` tree `diff.engine.ts` walks, so a
8
+ * command that `check` can already see is a command these docs can already
9
+ * render; nothing here reads a live CLI or a specific adapter.
10
+ *
11
+ * The point of generating this from the Contract rather than hand-writing
12
+ * it: the moment the docs would go stale, `check` already fails CI for the
13
+ * same underlying reason (the CLI's surface changed) - these docs literally
14
+ * cannot drift from reality without cliguard itself catching it first.
15
+ */
16
+ function renderMarkdownDocs(contract, fallbackTitle) {
17
+ const title = contract.root.name || fallbackTitle;
18
+ const lines = [`# ${title}`, ""];
19
+ if (contract.root.description) {
20
+ lines.push(contract.root.description, "");
21
+ }
22
+ renderCommandBody(contract.root, lines);
23
+ renderSubcommands(contract.root, 2, lines);
24
+ return (lines
25
+ .join("\n")
26
+ .replace(/\n{3,}/g, "\n\n")
27
+ .trimEnd() + "\n");
28
+ }
29
+ function renderSubcommands(cmd, depth, lines) {
30
+ const level = Math.min(depth, 6);
31
+ for (const sub of cmd.subcommands) {
32
+ lines.push(`${"#".repeat(level)} \`${commandSignature(sub)}\``, "");
33
+ if (sub.description)
34
+ lines.push(sub.description, "");
35
+ if (sub.aliases.length > 0) {
36
+ lines.push(`_Aliases: ${sub.aliases.map((alias) => `\`${alias}\``).join(", ")}_`, "");
37
+ }
38
+ renderCommandBody(sub, lines);
39
+ renderSubcommands(sub, depth + 1, lines);
40
+ }
41
+ }
42
+ /** `<default>` (see diff.engine.ts's own commandLabel) reads as a real, if odd, name; CAC's blank-name default command instead renders as just the CLI's own usage line with no distinct heading fragment. */
43
+ function commandSignature(cmd) {
44
+ const args = cmd.arguments.map(argumentUsage).join(" ");
45
+ return [cmd.name, args].filter(Boolean).join(" ");
46
+ }
47
+ function argumentUsage(arg) {
48
+ const name = arg.variadic ? `${arg.name}...` : arg.name;
49
+ return arg.required ? `<${name}>` : `[${name}]`;
50
+ }
51
+ function renderCommandBody(cmd, lines) {
52
+ if (cmd.arguments.length > 0) {
53
+ lines.push("**Arguments**", "", "| Name | Required | Description |", "|---|---|---|");
54
+ for (const arg of cmd.arguments) {
55
+ lines.push(`| \`${escapeCell(argumentUsage(arg))}\` | ${arg.required ? "yes" : "no"} | ${escapeCell(arg.description)} |`);
56
+ }
57
+ lines.push("");
58
+ }
59
+ if (cmd.options.length > 0) {
60
+ lines.push("**Options**", "", "| Flag | Default | Description |", "|---|---|---|");
61
+ for (const option of cmd.options) {
62
+ lines.push(`| ${optionCell(option)} | ${defaultCell(option)} | ${descriptionCell(option)} |`);
63
+ }
64
+ lines.push("");
65
+ }
66
+ }
67
+ function optionCell(option) {
68
+ return `\`${escapeCell(option.flags)}\``;
69
+ }
70
+ /** A string default renders bare (`dist/out.js`) - readable as prose. Any other JSON-serializable shape (boolean, number, array) renders via JSON.stringify so it stays unambiguous (e.g. distinguishing the string "true" from the boolean true never comes up for a string default, but would for anything else). */
71
+ function defaultCell(option) {
72
+ if (option.defaultValue === null || option.defaultValue === undefined)
73
+ return "-";
74
+ const rendered = typeof option.defaultValue === "string"
75
+ ? option.defaultValue
76
+ : JSON.stringify(option.defaultValue);
77
+ return `\`${escapeCell(rendered)}\``;
78
+ }
79
+ function descriptionCell(option) {
80
+ const description = escapeCell(option.description);
81
+ return option.required ? `${description} *(required)*` : description;
82
+ }
83
+ /** Markdown table cells break on a literal `|` or an embedded newline - both are possible in a framework-supplied description string. */
84
+ function escapeCell(value) {
85
+ return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
86
+ }
@@ -1,11 +1,11 @@
1
1
  import type { AcceptedBreak, Contract, Deprecation } from "./types";
2
2
  /** Contract path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
3
- export declare function getContractDisplayPath(): string;
3
+ export declare function getContractDisplayPath(namespace?: string | null): string;
4
4
  /** Accepted-breaks path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
5
- export declare function getAcceptedBreaksDisplayPath(): string;
6
- export declare function contractExists(): boolean;
7
- export declare function readContract(): Contract;
8
- export declare function writeContract(contract: Contract): void;
5
+ export declare function getAcceptedBreaksDisplayPath(namespace?: string | null): string;
6
+ export declare function contractExists(namespace?: string | null): boolean;
7
+ export declare function readContract(namespace?: string | null): Contract;
8
+ export declare function writeContract(contract: Contract, namespace?: string | null): void;
9
9
  /**
10
10
  * Reads a Contract from an arbitrary path, not the committed
11
11
  * `.cliguard/contract.json` - for `cliguard diff <a> <b>`, comparing two
@@ -23,15 +23,15 @@ export declare function readContractFile(path: string, displayPath?: string): Co
23
23
  * already names as the manual workaround (`git show <ref>:... > old.json`
24
24
  * piped into `cliguard diff`).
25
25
  */
26
- export declare function readContractAtRef(ref: string): Contract;
26
+ export declare function readContractAtRef(ref: string, namespace?: string | null): Contract;
27
27
  /** Unlike readContract, a missing file is normal (most projects never accept a break) - returns [] rather than throwing. */
28
- export declare function readAcceptedBreaks(): AcceptedBreak[];
29
- export declare function writeAcceptedBreaks(breaks: readonly AcceptedBreak[]): void;
28
+ export declare function readAcceptedBreaks(namespace?: string | null): AcceptedBreak[];
29
+ export declare function writeAcceptedBreaks(breaks: readonly AcceptedBreak[], namespace?: string | null): void;
30
30
  /** Deprecations path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
31
- export declare function getDeprecationsDisplayPath(): string;
31
+ export declare function getDeprecationsDisplayPath(namespace?: string | null): string;
32
32
  /** Unlike readContract, a missing file is normal (most projects never deprecate anything) - returns [] rather than throwing. */
33
- export declare function readDeprecations(): Deprecation[];
34
- export declare function writeDeprecations(deprecations: readonly Deprecation[]): void;
33
+ export declare function readDeprecations(namespace?: string | null): Deprecation[];
34
+ export declare function writeDeprecations(deprecations: readonly Deprecation[], namespace?: string | null): void;
35
35
  /** CI workflow path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
36
36
  export declare function getCiWorkflowDisplayPath(): string;
37
37
  export declare function ciWorkflowExists(): boolean;
@@ -40,5 +40,7 @@ export declare function writeCiWorkflow(content: string): void;
40
40
  /** Hook path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
41
41
  export declare function getHookDisplayPath(hookName: string): string;
42
42
  export declare function hookExists(hookName: string): boolean;
43
+ /** Reads an arbitrary user-supplied text file (not one of cliguard's own fixed `.cliguard/*` paths) - `null` when it doesn't exist, since "no committed docs yet" is a normal, expected state for `cliguard docs --check`, not an error. */
44
+ export declare function readTextFileIfExists(path: string): string | null;
43
45
  /** Never called when hookExists() is true - install-hook checks first so a hand-edited hook is never clobbered. */
44
46
  export declare function writeHook(hookName: string, content: string): void;
@@ -17,30 +17,44 @@ exports.ciWorkflowExists = ciWorkflowExists;
17
17
  exports.writeCiWorkflow = writeCiWorkflow;
18
18
  exports.getHookDisplayPath = getHookDisplayPath;
19
19
  exports.hookExists = hookExists;
20
+ exports.readTextFileIfExists = readTextFileIfExists;
20
21
  exports.writeHook = writeHook;
21
22
  const child_process_1 = require("child_process");
22
23
  const fs_1 = require("fs");
23
24
  const path_1 = require("path");
24
- const CONTRACT_PATH = (0, path_1.join)(process.cwd(), ".cliguard", "contract.json");
25
- const ACCEPTED_BREAKS_PATH = (0, path_1.join)(process.cwd(), ".cliguard", "accepted-breaks.json");
26
- const DEPRECATIONS_PATH = (0, path_1.join)(process.cwd(), ".cliguard", "deprecations.json");
25
+ const CLIGUARD_DIR = (0, path_1.join)(process.cwd(), ".cliguard");
27
26
  const CI_WORKFLOW_PATH = (0, path_1.join)(process.cwd(), ".github", "workflows", "cliguard.yml");
27
+ /**
28
+ * `namespace` is a config-resolved target's own `name` (see
29
+ * core/config.ts's `resolveTargets`) - `null` for the classic single-CLI
30
+ * flow, which keeps every path exactly what it always was
31
+ * (`.cliguard/contract.json`, not `.cliguard/null/contract.json`;
32
+ * `path.join` drops an empty segment the same way). A named target gets
33
+ * its own `.cliguard/<name>/` directory so two targets' contracts,
34
+ * accepted breaks, and deprecations never collide on disk.
35
+ */
36
+ function targetPath(namespace, fileName) {
37
+ return (0, path_1.join)(CLIGUARD_DIR, namespace ?? "", fileName);
38
+ }
28
39
  /** Contract path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
29
- function getContractDisplayPath() {
30
- return (0, path_1.relative)(process.cwd(), CONTRACT_PATH).split("\\").join("/");
40
+ function getContractDisplayPath(namespace = null) {
41
+ return (0, path_1.relative)(process.cwd(), targetPath(namespace, "contract.json")).split("\\").join("/");
31
42
  }
32
43
  /** Accepted-breaks path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
33
- function getAcceptedBreaksDisplayPath() {
34
- return (0, path_1.relative)(process.cwd(), ACCEPTED_BREAKS_PATH).split("\\").join("/");
44
+ function getAcceptedBreaksDisplayPath(namespace = null) {
45
+ return (0, path_1.relative)(process.cwd(), targetPath(namespace, "accepted-breaks.json"))
46
+ .split("\\")
47
+ .join("/");
35
48
  }
36
- function contractExists() {
37
- return (0, fs_1.existsSync)(CONTRACT_PATH);
49
+ function contractExists(namespace = null) {
50
+ return (0, fs_1.existsSync)(targetPath(namespace, "contract.json"));
38
51
  }
39
- function readContract() {
40
- if (!(0, fs_1.existsSync)(CONTRACT_PATH)) {
41
- throw new Error(`cliguard: no contract found at "${getContractDisplayPath()}". Run \`cliguard init <entry.js>\` first.`);
52
+ function readContract(namespace = null) {
53
+ const contractPath = targetPath(namespace, "contract.json");
54
+ if (!(0, fs_1.existsSync)(contractPath)) {
55
+ throw new Error(`cliguard: no contract found at "${getContractDisplayPath(namespace)}". Run \`cliguard init <entry.js>\` first.`);
42
56
  }
43
- const raw = (0, fs_1.readFileSync)(CONTRACT_PATH, "utf-8");
57
+ const raw = (0, fs_1.readFileSync)(contractPath, "utf-8");
44
58
  try {
45
59
  return JSON.parse(raw);
46
60
  }
@@ -52,14 +66,15 @@ function readContract() {
52
66
  // fix (re-run init/update) turns a confusing crash into an
53
67
  // actionable message.
54
68
  const reason = error instanceof Error ? error.message : String(error);
55
- throw new Error(`cliguard: "${getContractDisplayPath()}" is not valid JSON (${reason}). ` +
69
+ throw new Error(`cliguard: "${getContractDisplayPath(namespace)}" is not valid JSON (${reason}). ` +
56
70
  "If this file was hand-edited or came out of a bad merge, re-run " +
57
71
  "`cliguard update <entry.js>` to regenerate it.");
58
72
  }
59
73
  }
60
- function writeContract(contract) {
61
- (0, fs_1.mkdirSync)((0, path_1.dirname)(CONTRACT_PATH), { recursive: true });
62
- (0, fs_1.writeFileSync)(CONTRACT_PATH, JSON.stringify(contract, null, 2) + "\n", "utf-8");
74
+ function writeContract(contract, namespace = null) {
75
+ const contractPath = targetPath(namespace, "contract.json");
76
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(contractPath), { recursive: true });
77
+ (0, fs_1.writeFileSync)(contractPath, JSON.stringify(contract, null, 2) + "\n", "utf-8");
63
78
  }
64
79
  /**
65
80
  * Reads a Contract from an arbitrary path, not the committed
@@ -90,8 +105,8 @@ function readContractFile(path, displayPath = path) {
90
105
  * already names as the manual workaround (`git show <ref>:... > old.json`
91
106
  * piped into `cliguard diff`).
92
107
  */
93
- function readContractAtRef(ref) {
94
- const contractGitPath = getContractDisplayPath();
108
+ function readContractAtRef(ref, namespace = null) {
109
+ const contractGitPath = getContractDisplayPath(namespace);
95
110
  let raw;
96
111
  try {
97
112
  raw = (0, child_process_1.execFileSync)("git", ["show", `${ref}:${contractGitPath}`], {
@@ -117,10 +132,11 @@ function readContractAtRef(ref) {
117
132
  }
118
133
  }
119
134
  /** Unlike readContract, a missing file is normal (most projects never accept a break) - returns [] rather than throwing. */
120
- function readAcceptedBreaks() {
121
- if (!(0, fs_1.existsSync)(ACCEPTED_BREAKS_PATH))
135
+ function readAcceptedBreaks(namespace = null) {
136
+ const acceptedBreaksPath = targetPath(namespace, "accepted-breaks.json");
137
+ if (!(0, fs_1.existsSync)(acceptedBreaksPath))
122
138
  return [];
123
- const raw = (0, fs_1.readFileSync)(ACCEPTED_BREAKS_PATH, "utf-8");
139
+ const raw = (0, fs_1.readFileSync)(acceptedBreaksPath, "utf-8");
124
140
  try {
125
141
  return JSON.parse(raw);
126
142
  }
@@ -128,37 +144,40 @@ function readAcceptedBreaks() {
128
144
  // See readContract's identical-purpose catch for why naming the file
129
145
  // and the fix matters here too.
130
146
  const reason = error instanceof Error ? error.message : String(error);
131
- throw new Error(`cliguard: "${getAcceptedBreaksDisplayPath()}" is not valid JSON (${reason}). ` +
147
+ throw new Error(`cliguard: "${getAcceptedBreaksDisplayPath(namespace)}" is not valid JSON (${reason}). ` +
132
148
  "If this file was hand-edited or came out of a bad merge, fix it or delete it " +
133
149
  "and re-run `cliguard accept` for whatever was in it.");
134
150
  }
135
151
  }
136
- function writeAcceptedBreaks(breaks) {
137
- (0, fs_1.mkdirSync)((0, path_1.dirname)(ACCEPTED_BREAKS_PATH), { recursive: true });
138
- (0, fs_1.writeFileSync)(ACCEPTED_BREAKS_PATH, JSON.stringify(breaks, null, 2) + "\n", "utf-8");
152
+ function writeAcceptedBreaks(breaks, namespace = null) {
153
+ const acceptedBreaksPath = targetPath(namespace, "accepted-breaks.json");
154
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(acceptedBreaksPath), { recursive: true });
155
+ (0, fs_1.writeFileSync)(acceptedBreaksPath, JSON.stringify(breaks, null, 2) + "\n", "utf-8");
139
156
  }
140
157
  /** Deprecations path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
141
- function getDeprecationsDisplayPath() {
142
- return (0, path_1.relative)(process.cwd(), DEPRECATIONS_PATH).split("\\").join("/");
158
+ function getDeprecationsDisplayPath(namespace = null) {
159
+ return (0, path_1.relative)(process.cwd(), targetPath(namespace, "deprecations.json")).split("\\").join("/");
143
160
  }
144
161
  /** Unlike readContract, a missing file is normal (most projects never deprecate anything) - returns [] rather than throwing. */
145
- function readDeprecations() {
146
- if (!(0, fs_1.existsSync)(DEPRECATIONS_PATH))
162
+ function readDeprecations(namespace = null) {
163
+ const deprecationsPath = targetPath(namespace, "deprecations.json");
164
+ if (!(0, fs_1.existsSync)(deprecationsPath))
147
165
  return [];
148
- const raw = (0, fs_1.readFileSync)(DEPRECATIONS_PATH, "utf-8");
166
+ const raw = (0, fs_1.readFileSync)(deprecationsPath, "utf-8");
149
167
  try {
150
168
  return JSON.parse(raw);
151
169
  }
152
170
  catch (error) {
153
171
  const reason = error instanceof Error ? error.message : String(error);
154
- throw new Error(`cliguard: "${getDeprecationsDisplayPath()}" is not valid JSON (${reason}). ` +
172
+ throw new Error(`cliguard: "${getDeprecationsDisplayPath(namespace)}" is not valid JSON (${reason}). ` +
155
173
  "If this file was hand-edited or came out of a bad merge, fix it or delete it " +
156
174
  "and re-run `cliguard deprecate` for whatever was in it.");
157
175
  }
158
176
  }
159
- function writeDeprecations(deprecations) {
160
- (0, fs_1.mkdirSync)((0, path_1.dirname)(DEPRECATIONS_PATH), { recursive: true });
161
- (0, fs_1.writeFileSync)(DEPRECATIONS_PATH, JSON.stringify(deprecations, null, 2) + "\n", "utf-8");
177
+ function writeDeprecations(deprecations, namespace = null) {
178
+ const deprecationsPath = targetPath(namespace, "deprecations.json");
179
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(deprecationsPath), { recursive: true });
180
+ (0, fs_1.writeFileSync)(deprecationsPath, JSON.stringify(deprecations, null, 2) + "\n", "utf-8");
162
181
  }
163
182
  /** CI workflow path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
164
183
  function getCiWorkflowDisplayPath() {
@@ -197,6 +216,10 @@ function getHookDisplayPath(hookName) {
197
216
  function hookExists(hookName) {
198
217
  return (0, fs_1.existsSync)((0, path_1.join)(resolveHooksDir(), hookName));
199
218
  }
219
+ /** Reads an arbitrary user-supplied text file (not one of cliguard's own fixed `.cliguard/*` paths) - `null` when it doesn't exist, since "no committed docs yet" is a normal, expected state for `cliguard docs --check`, not an error. */
220
+ function readTextFileIfExists(path) {
221
+ return (0, fs_1.existsSync)(path) ? (0, fs_1.readFileSync)(path, "utf-8") : null;
222
+ }
200
223
  /** Never called when hookExists() is true - install-hook checks first so a hand-edited hook is never clobbered. */
201
224
  function writeHook(hookName, content) {
202
225
  const hookPath = (0, path_1.join)(resolveHooksDir(), hookName);
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type CompareOptions, type DiffResult } from "./core/diff.engine";
2
2
  import type { Contract } from "./core/types";
3
3
  export type { CliAdapter } from "./adapters/adapter.interface";
4
4
  export { CacAdapter } from "./adapters/cac.adapter";
5
+ export { ClickAdapter } from "./adapters/click.adapter";
5
6
  export { CommanderAdapter } from "./adapters/commander.adapter";
6
7
  export { YargsAdapter } from "./adapters/yargs.adapter";
7
8
  export { adapters, resolveAdapter } from "./adapters/registry";
@@ -9,6 +10,7 @@ export { applyConfig, configExists, loadConfig } from "./core/config";
9
10
  export type { CliguardConfig, SeverityOverride } from "./core/config";
10
11
  export { DiffEngine } from "./core/diff.engine";
11
12
  export type { CompareOptions, DiffResult } from "./core/diff.engine";
13
+ export { renderMarkdownDocs } from "./core/docs";
12
14
  export { toGitLabCodeQuality, toJUnitXml, toRdjsonl } from "./core/report-formats";
13
15
  export type { ReportChange } from "./core/report-formats";
14
16
  export { ChangeType, type AcceptedBreak, type ArgumentContract, type CommandContract, type Contract, type Deprecation, type OptionContract, type OptionValueType, } from "./core/types";
@@ -27,5 +29,5 @@ export declare function extractContract(entryPath: string, adapterName?: string)
27
29
  * or a git ref by the caller's own code.
28
30
  */
29
31
  export declare function compareContracts(oldContract: Contract, newContract: Contract, options?: CompareOptions): DiffResult[];
30
- /** Every adapter name `extractContract`/the CLI's `--adapter` flag will accept, e.g. ["commander", "cac", "yargs"]. */
32
+ /** Every adapter name `extractContract`/the CLI's `--adapter` flag will accept, e.g. ["commander", "cac", "yargs", "click"]. */
31
33
  export declare function listAdapters(): string[];
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ChangeType = exports.toRdjsonl = exports.toJUnitXml = exports.toGitLabCodeQuality = exports.DiffEngine = exports.loadConfig = exports.configExists = exports.applyConfig = exports.resolveAdapter = exports.adapters = exports.YargsAdapter = exports.CommanderAdapter = exports.CacAdapter = void 0;
3
+ exports.ChangeType = exports.toRdjsonl = exports.toJUnitXml = exports.toGitLabCodeQuality = exports.renderMarkdownDocs = exports.DiffEngine = exports.loadConfig = exports.configExists = exports.applyConfig = exports.resolveAdapter = exports.adapters = exports.YargsAdapter = exports.CommanderAdapter = exports.ClickAdapter = exports.CacAdapter = void 0;
4
4
  exports.extractContract = extractContract;
5
5
  exports.compareContracts = compareContracts;
6
6
  exports.listAdapters = listAdapters;
@@ -16,6 +16,8 @@ const registry_1 = require("./adapters/registry");
16
16
  const diff_engine_1 = require("./core/diff.engine");
17
17
  var cac_adapter_1 = require("./adapters/cac.adapter");
18
18
  Object.defineProperty(exports, "CacAdapter", { enumerable: true, get: function () { return cac_adapter_1.CacAdapter; } });
19
+ var click_adapter_1 = require("./adapters/click.adapter");
20
+ Object.defineProperty(exports, "ClickAdapter", { enumerable: true, get: function () { return click_adapter_1.ClickAdapter; } });
19
21
  var commander_adapter_1 = require("./adapters/commander.adapter");
20
22
  Object.defineProperty(exports, "CommanderAdapter", { enumerable: true, get: function () { return commander_adapter_1.CommanderAdapter; } });
21
23
  var yargs_adapter_1 = require("./adapters/yargs.adapter");
@@ -29,6 +31,8 @@ Object.defineProperty(exports, "configExists", { enumerable: true, get: function
29
31
  Object.defineProperty(exports, "loadConfig", { enumerable: true, get: function () { return config_1.loadConfig; } });
30
32
  var diff_engine_2 = require("./core/diff.engine");
31
33
  Object.defineProperty(exports, "DiffEngine", { enumerable: true, get: function () { return diff_engine_2.DiffEngine; } });
34
+ var docs_1 = require("./core/docs");
35
+ Object.defineProperty(exports, "renderMarkdownDocs", { enumerable: true, get: function () { return docs_1.renderMarkdownDocs; } });
32
36
  var report_formats_1 = require("./core/report-formats");
33
37
  Object.defineProperty(exports, "toGitLabCodeQuality", { enumerable: true, get: function () { return report_formats_1.toGitLabCodeQuality; } });
34
38
  Object.defineProperty(exports, "toJUnitXml", { enumerable: true, get: function () { return report_formats_1.toJUnitXml; } });
@@ -60,7 +64,7 @@ async function extractContract(entryPath, adapterName = "commander") {
60
64
  function compareContracts(oldContract, newContract, options) {
61
65
  return diffEngine.compare(oldContract, newContract, options);
62
66
  }
63
- /** Every adapter name `extractContract`/the CLI's `--adapter` flag will accept, e.g. ["commander", "cac", "yargs"]. */
67
+ /** Every adapter name `extractContract`/the CLI's `--adapter` flag will accept, e.g. ["commander", "cac", "yargs", "click"]. */
64
68
  function listAdapters() {
65
69
  return Object.keys(registry_1.adapters);
66
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cliguard",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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",
@@ -8,6 +8,7 @@
8
8
  "snapshot-testing",
9
9
  "commander",
10
10
  "yargs",
11
+ "click",
11
12
  "ci"
12
13
  ],
13
14
  "author": "Bryandero98",