cliguard 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -0
- package/dist/adapters/adapter.interface.d.ts +10 -0
- package/dist/adapters/cac.adapter.d.ts +1 -0
- package/dist/adapters/cac.adapter.js +5 -0
- package/dist/adapters/commander.adapter.d.ts +2 -0
- package/dist/adapters/commander.adapter.js +2 -0
- package/dist/adapters/registry.d.ts +3 -0
- package/dist/adapters/registry.js +26 -0
- package/dist/adapters/yargs.adapter.d.ts +1 -0
- package/dist/adapters/yargs.adapter.js +3 -0
- package/dist/bin.js +284 -35
- package/dist/core/config.d.ts +24 -0
- package/dist/core/config.js +106 -0
- package/dist/core/diff.engine.d.ts +56 -1
- package/dist/core/diff.engine.js +123 -8
- package/dist/core/report-formats.d.ts +44 -0
- package/dist/core/report-formats.js +103 -0
- package/dist/core/storage.d.ts +25 -1
- package/dist/core/storage.js +121 -0
- package/dist/core/types.d.ts +19 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +66 -0
- package/package.json +10 -1
package/dist/bin.js
CHANGED
|
@@ -2,28 +2,13 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const commander_1 = require("commander");
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const registry_1 = require("./adapters/registry");
|
|
7
|
+
const config_1 = require("./core/config");
|
|
8
8
|
const diff_engine_1 = require("./core/diff.engine");
|
|
9
|
+
const report_formats_1 = require("./core/report-formats");
|
|
9
10
|
const storage_1 = require("./core/storage");
|
|
10
11
|
const types_1 = require("./core/types");
|
|
11
|
-
// Constructing an adapter here is cheap (no eager require of its
|
|
12
|
-
// framework - CacAdapter only loads `cac` lazily, inside extract()), so
|
|
13
|
-
// every adapter is always registered regardless of which one a given
|
|
14
|
-
// invocation actually uses.
|
|
15
|
-
const adapters = {
|
|
16
|
-
commander: new commander_adapter_1.CommanderAdapter(),
|
|
17
|
-
cac: new cac_adapter_1.CacAdapter(),
|
|
18
|
-
yargs: new yargs_adapter_1.YargsAdapter(),
|
|
19
|
-
};
|
|
20
|
-
function resolveAdapter(name) {
|
|
21
|
-
const adapter = adapters[name];
|
|
22
|
-
if (!adapter) {
|
|
23
|
-
throw new Error(`cliguard: unknown adapter "${name}". Available: ${Object.keys(adapters).join(", ")}.`);
|
|
24
|
-
}
|
|
25
|
-
return adapter;
|
|
26
|
-
}
|
|
27
12
|
const diffEngine = new diff_engine_1.DiffEngine();
|
|
28
13
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- package.json has no type declarations to import against; require() is the simplest correct read here
|
|
29
14
|
const packageJson = require("../package.json");
|
|
@@ -37,20 +22,52 @@ const adapterOption = [
|
|
|
37
22
|
"CLI framework adapter to use",
|
|
38
23
|
"commander",
|
|
39
24
|
];
|
|
25
|
+
const REPORT_FORMATS = ["text", "json", "junit", "gitlab-codequality", "rdjsonl"];
|
|
26
|
+
/** `--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
|
+
function resolveFormat(explicit, jsonFlag) {
|
|
28
|
+
const format = explicit ?? (jsonFlag ? "json" : "text");
|
|
29
|
+
return REPORT_FORMATS.includes(format) ? format : null;
|
|
30
|
+
}
|
|
31
|
+
/** Renders every format except "text" (which each command still prints itself, since its "nothing changed" message differs between `check` and `diff`). */
|
|
32
|
+
function formatReport(diff, acceptedPaths, format, contractPath) {
|
|
33
|
+
if (format === "json") {
|
|
34
|
+
return JSON.stringify(toJsonResult(diff, acceptedPaths), null, 2);
|
|
35
|
+
}
|
|
36
|
+
const annotated = annotateChanges(diff, acceptedPaths);
|
|
37
|
+
switch (format) {
|
|
38
|
+
case "junit":
|
|
39
|
+
return (0, report_formats_1.toJUnitXml)(annotated);
|
|
40
|
+
case "gitlab-codequality":
|
|
41
|
+
return (0, report_formats_1.toGitLabCodeQuality)(annotated, contractPath);
|
|
42
|
+
case "rdjsonl":
|
|
43
|
+
return (0, report_formats_1.toRdjsonl)(annotated, contractPath);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
40
46
|
program
|
|
41
47
|
.command("init")
|
|
42
48
|
.description("Capture the current CLI surface as the committed contract")
|
|
43
49
|
.argument("<entry>", "path to the target CLI's entry file")
|
|
44
50
|
.option(...adapterOption)
|
|
51
|
+
.option("--with-ci", "also scaffold a GitHub Actions workflow that runs cliguard on every pull request", false)
|
|
45
52
|
.action(async (entry, options) => {
|
|
46
53
|
if ((0, storage_1.contractExists)()) {
|
|
47
54
|
console.warn(`A contract already exists. Run "cliguard update" to overwrite it.`);
|
|
48
55
|
process.exit(1);
|
|
49
56
|
}
|
|
50
57
|
const exitCode = await withSuppressedExit(async () => {
|
|
51
|
-
const contract = await resolveAdapter(options.adapter).extract(entry);
|
|
58
|
+
const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
|
|
52
59
|
(0, storage_1.writeContract)(contract);
|
|
53
60
|
console.log(`✅ CLI contract initialized successfully at ${(0, storage_1.getContractDisplayPath)()}.`);
|
|
61
|
+
if (options.withCi) {
|
|
62
|
+
if ((0, storage_1.ciWorkflowExists)()) {
|
|
63
|
+
console.log(`ℹ️ ${(0, storage_1.getCiWorkflowDisplayPath)()} already exists - left it untouched.`);
|
|
64
|
+
}
|
|
65
|
+
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));
|
|
68
|
+
console.log(`✅ GitHub Actions workflow scaffolded at ${(0, storage_1.getCiWorkflowDisplayPath)()}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
54
71
|
return 0;
|
|
55
72
|
});
|
|
56
73
|
process.exit(exitCode);
|
|
@@ -60,16 +77,24 @@ program
|
|
|
60
77
|
.description("Compare the current CLI surface against the committed contract")
|
|
61
78
|
.argument("<entry>", "path to the target CLI's entry file")
|
|
62
79
|
.option(...adapterOption)
|
|
63
|
-
.option("--json", "print a machine-readable JSON result instead of text", false)
|
|
80
|
+
.option("--json", "print a machine-readable JSON result instead of text (shorthand for --format json)", false)
|
|
81
|
+
.option("--format <format>", `output format: ${REPORT_FORMATS.join(", ")}`)
|
|
82
|
+
.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
|
+
.option("--strict", "enable extra rules for currently-silent risky changes (e.g. a positional argument reorder)", false)
|
|
64
84
|
.action(async (entry, options) => {
|
|
65
85
|
const exitCode = await withSuppressedExit(async () => {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
const format = resolveFormat(options.format, options.json);
|
|
87
|
+
if (!format) {
|
|
88
|
+
console.error(`cliguard: unknown --format "${options.format}". Use ${REPORT_FORMATS.join(", ")}.`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
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)()));
|
|
69
94
|
const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)());
|
|
70
95
|
const hasBreaking = diff.some((change) => change.type === types_1.ChangeType.BREAKING && !acceptedPaths.has(change.path));
|
|
71
|
-
if (
|
|
72
|
-
console.log(
|
|
96
|
+
if (format !== "text") {
|
|
97
|
+
console.log(formatReport(diff, acceptedPaths, format, (0, storage_1.getContractDisplayPath)()));
|
|
73
98
|
return hasBreaking ? 1 : 0;
|
|
74
99
|
}
|
|
75
100
|
if (diff.length === 0) {
|
|
@@ -96,8 +121,8 @@ program
|
|
|
96
121
|
return 1;
|
|
97
122
|
}
|
|
98
123
|
const oldContract = (0, storage_1.readContract)();
|
|
99
|
-
const newContract = await resolveAdapter(options.adapter).extract(entry);
|
|
100
|
-
const diff = diffEngine.compare(oldContract, newContract);
|
|
124
|
+
const newContract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
|
|
125
|
+
const diff = applyDeprecations(diffEngine.applyUnstableMarkers((0, config_1.applyConfig)(diffEngine.compare(oldContract, newContract), (0, config_1.loadConfig)()), oldContract, newContract), indexDeprecations((0, storage_1.readDeprecations)()));
|
|
101
126
|
const match = diff.find((change) => change.type === types_1.ChangeType.BREAKING && change.path === changePath);
|
|
102
127
|
if (!match) {
|
|
103
128
|
const breaking = diff.filter((change) => change.type === types_1.ChangeType.BREAKING);
|
|
@@ -123,6 +148,44 @@ program
|
|
|
123
148
|
});
|
|
124
149
|
process.exit(exitCode);
|
|
125
150
|
});
|
|
151
|
+
program
|
|
152
|
+
.command("deprecate")
|
|
153
|
+
.description("Schedule a command/option/argument for removal ahead of time, so that removal counts as PATCH instead of BREAKING")
|
|
154
|
+
.argument("<entry>", "path to the target CLI's entry file")
|
|
155
|
+
.argument("<path>", 'the exact Contract path to deprecate, e.g. "root -> build -> option[--target]" - still present today, not yet removed')
|
|
156
|
+
.requiredOption("--remove-by <versionOrDate>", 'when this is expected to actually go away (e.g. "2.0.0" or "2026-12-01") - informational, never enforced by cliguard itself')
|
|
157
|
+
.option("-r, --reason <text>", "why this is being deprecated - shown once it's removed")
|
|
158
|
+
.option(...adapterOption)
|
|
159
|
+
.action(async (entry, path, options) => {
|
|
160
|
+
const exitCode = await withSuppressedExit(async () => {
|
|
161
|
+
const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
|
|
162
|
+
const validPaths = diffEngine.collectPaths(contract);
|
|
163
|
+
if (!validPaths.has(path)) {
|
|
164
|
+
console.error(`cliguard: no such path "${path}" in the current contract - it may already be ` +
|
|
165
|
+
"removed, or never existed. Run `cliguard preview <entry>` to see the current contract.");
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
// Replaces any earlier deprecation at the same path rather than
|
|
169
|
+
// accumulating duplicates - re-running `deprecate` updates the
|
|
170
|
+
// remove-by/reason, same as `accept` does for its own reason.
|
|
171
|
+
const remaining = (0, storage_1.readDeprecations)().filter((existing) => existing.path !== path);
|
|
172
|
+
const deprecation = {
|
|
173
|
+
path,
|
|
174
|
+
removeBy: options.removeBy,
|
|
175
|
+
reason: options.reason,
|
|
176
|
+
deprecatedAt: new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
(0, storage_1.writeDeprecations)([...remaining, deprecation]);
|
|
179
|
+
console.log(`✅ Deprecated: [${path}]`);
|
|
180
|
+
console.log(` Remove by: ${options.removeBy}`);
|
|
181
|
+
if (options.reason)
|
|
182
|
+
console.log(` Reason: ${options.reason}`);
|
|
183
|
+
console.log(` Recorded in ${(0, storage_1.getDeprecationsDisplayPath)()} - commit this file. ` +
|
|
184
|
+
"Its eventual removal will count as PATCH, not BREAKING.");
|
|
185
|
+
return 0;
|
|
186
|
+
});
|
|
187
|
+
process.exit(exitCode);
|
|
188
|
+
});
|
|
126
189
|
program
|
|
127
190
|
.command("update")
|
|
128
191
|
.description("Overwrite the committed contract with the CLI's current surface")
|
|
@@ -130,31 +193,108 @@ program
|
|
|
130
193
|
.option(...adapterOption)
|
|
131
194
|
.action(async (entry, options) => {
|
|
132
195
|
const exitCode = await withSuppressedExit(async () => {
|
|
133
|
-
const contract = await resolveAdapter(options.adapter).extract(entry);
|
|
196
|
+
const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
|
|
134
197
|
(0, storage_1.writeContract)(contract);
|
|
135
198
|
console.log("🔄 CLI contract updated successfully.");
|
|
136
199
|
return 0;
|
|
137
200
|
});
|
|
138
201
|
process.exit(exitCode);
|
|
139
202
|
});
|
|
203
|
+
program
|
|
204
|
+
.command("preview")
|
|
205
|
+
.description("Extract the current CLI's contract and print it, without writing .cliguard/contract.json")
|
|
206
|
+
.argument("<entry>", "path to the target CLI's entry file")
|
|
207
|
+
.option(...adapterOption)
|
|
208
|
+
.action(async (entry, options) => {
|
|
209
|
+
const exitCode = await withSuppressedExit(async () => {
|
|
210
|
+
const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
|
|
211
|
+
console.log(JSON.stringify(contract, null, 2));
|
|
212
|
+
return 0;
|
|
213
|
+
});
|
|
214
|
+
process.exit(exitCode);
|
|
215
|
+
});
|
|
216
|
+
program
|
|
217
|
+
.command("doctor")
|
|
218
|
+
.description("Show every adapter's known limitations, or sanity-check one against a real entry file")
|
|
219
|
+
.argument("[entry]", "optional: path to a target CLI's entry file to actually test extraction")
|
|
220
|
+
.option(...adapterOption)
|
|
221
|
+
.action(async (entry, options) => {
|
|
222
|
+
if (!entry) {
|
|
223
|
+
printAdapterLimitations();
|
|
224
|
+
process.exit(0);
|
|
225
|
+
}
|
|
226
|
+
const exitCode = await withSuppressedExit(async () => {
|
|
227
|
+
const adapter = (0, registry_1.resolveAdapter)(options.adapter);
|
|
228
|
+
console.log(`Adapter: ${adapter.id}`);
|
|
229
|
+
if (adapter.limitations.length === 0) {
|
|
230
|
+
console.log(" No known limitations.");
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
for (const limitation of adapter.limitations) {
|
|
234
|
+
console.log(` ⚠️ ${limitation}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
console.log("");
|
|
238
|
+
try {
|
|
239
|
+
const contract = await adapter.extract(entry);
|
|
240
|
+
const summary = summarizeCommand(contract.root);
|
|
241
|
+
console.log(`✅ Extraction succeeded: ${summary.commands} command(s), ` +
|
|
242
|
+
`${summary.options} option(s), ${summary.arguments} argument(s).`);
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
console.error(`❌ Extraction failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
process.exit(exitCode);
|
|
251
|
+
});
|
|
252
|
+
const HOOK_NAMES = ["pre-commit", "pre-push"];
|
|
253
|
+
program
|
|
254
|
+
.command("install-hook")
|
|
255
|
+
.description("Install a git hook that runs `cliguard check` automatically, catching a breaking change before it ever reaches CI")
|
|
256
|
+
.argument("<entry>", "path to the target CLI's entry file")
|
|
257
|
+
.option(...adapterOption)
|
|
258
|
+
.option("--hook <name>", `which git hook to install: ${HOOK_NAMES.join(" or ")}`, "pre-push")
|
|
259
|
+
.action((entry, options) => {
|
|
260
|
+
if (!isHookName(options.hook)) {
|
|
261
|
+
console.error(`cliguard: --hook must be one of ${HOOK_NAMES.join(", ")} - got "${options.hook}".`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
if ((0, storage_1.hookExists)(options.hook)) {
|
|
265
|
+
console.log(`ℹ️ ${(0, storage_1.getHookDisplayPath)(options.hook)} already exists - left it untouched.`);
|
|
266
|
+
process.exit(0);
|
|
267
|
+
}
|
|
268
|
+
const entryPath = (0, path_1.relative)(process.cwd(), (0, path_1.resolve)(entry)).split("\\").join("/");
|
|
269
|
+
(0, storage_1.writeHook)(options.hook, buildHookScript(entryPath, options.adapter));
|
|
270
|
+
console.log(`✅ Git hook installed at ${(0, storage_1.getHookDisplayPath)(options.hook)}.`);
|
|
271
|
+
process.exit(0);
|
|
272
|
+
});
|
|
140
273
|
program
|
|
141
274
|
.command("diff")
|
|
142
275
|
.description("Compare two contract files directly, without running any CLI")
|
|
143
276
|
.argument("<oldContract>", "path to the older contract JSON file")
|
|
144
277
|
.argument("<newContract>", "path to the newer contract JSON file")
|
|
145
|
-
.option("--json", "print a machine-readable JSON result instead of text", false)
|
|
278
|
+
.option("--json", "print a machine-readable JSON result instead of text (shorthand for --format json)", false)
|
|
279
|
+
.option("--format <format>", `output format: ${REPORT_FORMATS.join(", ")}`)
|
|
280
|
+
.option("--strict", "enable extra rules for currently-silent risky changes (e.g. a positional argument reorder)", false)
|
|
146
281
|
.action((oldPath, newPath, options) => {
|
|
147
282
|
// No adapter, no target CLI ever loaded here - just two files off
|
|
148
283
|
// disk - so none of withSuppressedExit's process.exit-race concerns
|
|
149
284
|
// apply. A thrown Error (bad path, corrupt JSON) still surfaces via
|
|
150
285
|
// this program's own top-level parseAsync().catch() below.
|
|
286
|
+
const format = resolveFormat(options.format, options.json);
|
|
287
|
+
if (!format) {
|
|
288
|
+
console.error(`cliguard: unknown --format "${options.format}". Use ${REPORT_FORMATS.join(", ")}.`);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
151
291
|
const oldContract = (0, storage_1.readContractFile)(oldPath);
|
|
152
292
|
const newContract = (0, storage_1.readContractFile)(newPath);
|
|
153
|
-
const diff = diffEngine.compare(oldContract, newContract);
|
|
293
|
+
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)()));
|
|
154
294
|
const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)());
|
|
155
295
|
const hasBreaking = diff.some((change) => change.type === types_1.ChangeType.BREAKING && !acceptedPaths.has(change.path));
|
|
156
|
-
if (
|
|
157
|
-
console.log(
|
|
296
|
+
if (format !== "text") {
|
|
297
|
+
console.log(formatReport(diff, acceptedPaths, format, newPath));
|
|
158
298
|
process.exit(hasBreaking ? 1 : 0);
|
|
159
299
|
}
|
|
160
300
|
if (diff.length === 0) {
|
|
@@ -164,6 +304,81 @@ program
|
|
|
164
304
|
printDiff(diff, acceptedPaths);
|
|
165
305
|
process.exit(hasBreaking ? 1 : 0);
|
|
166
306
|
});
|
|
307
|
+
/**
|
|
308
|
+
* The same workflow the README's "CI integration" section documents by
|
|
309
|
+
* hand - generated here so `init --with-ci` never drifts from it. `entry`
|
|
310
|
+
* is expected already normalized (forward slashes, relative to cwd); the
|
|
311
|
+
* adapter line is only emitted for a non-default adapter, matching the
|
|
312
|
+
* Action's own `adapter` input default of "commander".
|
|
313
|
+
*/
|
|
314
|
+
function buildCiWorkflowYaml(entryPath, adapter) {
|
|
315
|
+
const lines = [
|
|
316
|
+
"# Generated by `cliguard init --with-ci` - edit freely, cliguard won't touch this file again.",
|
|
317
|
+
"name: CLI contract",
|
|
318
|
+
"on: [pull_request]",
|
|
319
|
+
"permissions:",
|
|
320
|
+
" pull-requests: write # needed for the PR comment",
|
|
321
|
+
"jobs:",
|
|
322
|
+
" check:",
|
|
323
|
+
" runs-on: ubuntu-latest",
|
|
324
|
+
" steps:",
|
|
325
|
+
" - uses: actions/checkout@v4",
|
|
326
|
+
" - uses: actions/setup-node@v4",
|
|
327
|
+
" with: { node-version: 22.x }",
|
|
328
|
+
" - run: npm ci",
|
|
329
|
+
" - uses: Bryandero98/cliguard@v1",
|
|
330
|
+
" with:",
|
|
331
|
+
` entry: ./${entryPath}`,
|
|
332
|
+
];
|
|
333
|
+
if (adapter !== "commander") {
|
|
334
|
+
lines.push(` adapter: ${adapter}`);
|
|
335
|
+
}
|
|
336
|
+
return lines.join("\n") + "\n";
|
|
337
|
+
}
|
|
338
|
+
function printAdapterLimitations() {
|
|
339
|
+
console.log("Registered adapters and their known limitations:\n");
|
|
340
|
+
for (const adapter of Object.values(registry_1.adapters)) {
|
|
341
|
+
console.log(adapter.id + ":");
|
|
342
|
+
if (adapter.limitations.length === 0) {
|
|
343
|
+
console.log(" No known limitations.");
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
for (const limitation of adapter.limitations) {
|
|
347
|
+
console.log(` ⚠️ ${limitation}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
console.log("");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function summarizeCommand(cmd) {
|
|
354
|
+
let commands = 1;
|
|
355
|
+
let options = cmd.options.length;
|
|
356
|
+
let args = cmd.arguments.length;
|
|
357
|
+
for (const sub of cmd.subcommands) {
|
|
358
|
+
const subSummary = summarizeCommand(sub);
|
|
359
|
+
commands += subSummary.commands;
|
|
360
|
+
options += subSummary.options;
|
|
361
|
+
args += subSummary.arguments;
|
|
362
|
+
}
|
|
363
|
+
return { commands, options, arguments: args };
|
|
364
|
+
}
|
|
365
|
+
function isHookName(value) {
|
|
366
|
+
return HOOK_NAMES.includes(value);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* A minimal POSIX-sh script - Git for Windows runs hooks through its own
|
|
370
|
+
* bundled sh.exe via the shebang, same as any other platform, so one
|
|
371
|
+
* script body works everywhere without a separate Windows path.
|
|
372
|
+
*/
|
|
373
|
+
function buildHookScript(entryPath, adapter) {
|
|
374
|
+
const adapterFlag = adapter === "commander" ? "" : ` --adapter ${adapter}`;
|
|
375
|
+
return [
|
|
376
|
+
"#!/bin/sh",
|
|
377
|
+
"# Installed by `cliguard install-hook` - edit freely, cliguard won't touch this file again.",
|
|
378
|
+
`npx cliguard check "${entryPath}"${adapterFlag}`,
|
|
379
|
+
"",
|
|
380
|
+
].join("\n");
|
|
381
|
+
}
|
|
167
382
|
/**
|
|
168
383
|
* Runs `action` with `process.exit` neutralized, restoring the real one
|
|
169
384
|
* the instant `action` settles - then the caller calls the *real*
|
|
@@ -198,13 +413,47 @@ async function withSuppressedExit(action) {
|
|
|
198
413
|
function indexAcceptedBreaks(accepted) {
|
|
199
414
|
return new Map(accepted.map((entry) => [entry.path, entry]));
|
|
200
415
|
}
|
|
201
|
-
function
|
|
202
|
-
|
|
416
|
+
function indexDeprecations(deprecations) {
|
|
417
|
+
return new Map(deprecations.map((entry) => [entry.path, entry]));
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Reclassifies a BREAKING removal at a deprecated path as PATCH, folding
|
|
421
|
+
* the deprecation's own record into the message - a scheduled, announced
|
|
422
|
+
* removal is no longer a surprise to whoever's relying on the flag, so it
|
|
423
|
+
* shouldn't fail the build the way an unannounced one still does. Only
|
|
424
|
+
* ever touches entries with `removal: true` (see DiffResult) - a
|
|
425
|
+
* *different* kind of BREAKING change at the same path (e.g. a default
|
|
426
|
+
* value change on an option that also happens to be deprecated) is left
|
|
427
|
+
* alone, since deprecating a removal says nothing about other changes.
|
|
428
|
+
*/
|
|
429
|
+
function applyDeprecations(diff, deprecations) {
|
|
430
|
+
return diff.map((entry) => {
|
|
431
|
+
if (entry.type !== types_1.ChangeType.BREAKING || !entry.removal)
|
|
432
|
+
return entry;
|
|
433
|
+
const deprecation = deprecations.get(entry.path);
|
|
434
|
+
if (!deprecation)
|
|
435
|
+
return entry;
|
|
436
|
+
return {
|
|
437
|
+
type: types_1.ChangeType.PATCH,
|
|
438
|
+
path: entry.path,
|
|
439
|
+
message: `${entry.message} Deprecated ${deprecation.deprecatedAt.slice(0, 10)}, ` +
|
|
440
|
+
`scheduled removal by ${deprecation.removeBy}` +
|
|
441
|
+
(deprecation.reason ? ` (${deprecation.reason})` : "") +
|
|
442
|
+
" - this removal was expected.",
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/** A BREAKING entry matched by `cliguard accept` gains `acknowledged: true` and its recorded `reason`; every other entry passes through unchanged. Shared by --json, --format junit, and --format gitlab-codequality so all three agree on what "acknowledged" means. */
|
|
447
|
+
function annotateChanges(diff, acceptedPaths) {
|
|
448
|
+
return diff.map((entry) => {
|
|
203
449
|
if (entry.type !== types_1.ChangeType.BREAKING)
|
|
204
450
|
return entry;
|
|
205
451
|
const accepted = acceptedPaths.get(entry.path);
|
|
206
452
|
return accepted ? { ...entry, acknowledged: true, reason: accepted.reason } : entry;
|
|
207
453
|
});
|
|
454
|
+
}
|
|
455
|
+
function toJsonResult(diff, acceptedPaths) {
|
|
456
|
+
const changes = annotateChanges(diff, acceptedPaths);
|
|
208
457
|
const summary = {
|
|
209
458
|
breaking: changes.filter((change) => change.type === types_1.ChangeType.BREAKING && !change.acknowledged).length,
|
|
210
459
|
acknowledgedBreaking: changes.filter((change) => change.type === types_1.ChangeType.BREAKING && change.acknowledged).length,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DiffResult } from "./diff.engine";
|
|
2
|
+
import { ChangeType } from "./types";
|
|
3
|
+
export interface SeverityOverride {
|
|
4
|
+
readonly pattern: string | RegExp;
|
|
5
|
+
readonly severity: ChangeType;
|
|
6
|
+
}
|
|
7
|
+
export interface CliguardConfig {
|
|
8
|
+
/** A change whose DiffResult.path matches any of these is dropped from the report entirely - never shown, never counted, never fails the build. */
|
|
9
|
+
readonly ignore?: readonly (string | RegExp)[];
|
|
10
|
+
/** A change whose DiffResult.path matches `pattern` gets reclassified to `severity` - the first matching entry wins. */
|
|
11
|
+
readonly severityOverrides?: readonly SeverityOverride[];
|
|
12
|
+
}
|
|
13
|
+
/** 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
|
+
export declare function getConfigDisplayPath(): string;
|
|
15
|
+
export declare function configExists(): boolean;
|
|
16
|
+
/** Returns `{}` (no policy applied) when no config file exists - a project with no `cliguard.config.js` behaves exactly as it always has. */
|
|
17
|
+
export declare function loadConfig(): CliguardConfig;
|
|
18
|
+
/**
|
|
19
|
+
* Applies project-wide policy before any per-instance override (`cliguard
|
|
20
|
+
* accept`/`deprecate`) gets a chance to run - an ignored or downgraded
|
|
21
|
+
* change simply isn't BREAKING by the time either of those look at it, so
|
|
22
|
+
* there's nothing left to accept or deprecate for it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function applyConfig(diff: readonly DiffResult[], config: CliguardConfig): DiffResult[];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getConfigDisplayPath = getConfigDisplayPath;
|
|
4
|
+
exports.configExists = configExists;
|
|
5
|
+
exports.loadConfig = loadConfig;
|
|
6
|
+
exports.applyConfig = applyConfig;
|
|
7
|
+
const fs_1 = require("fs");
|
|
8
|
+
const path_1 = require("path");
|
|
9
|
+
const types_1 = require("./types");
|
|
10
|
+
const CANDIDATE_NAMES = ["cliguard.config.js", "cliguard.config.cjs"];
|
|
11
|
+
function resolveConfigPath() {
|
|
12
|
+
for (const name of CANDIDATE_NAMES) {
|
|
13
|
+
const path = (0, path_1.join)(process.cwd(), name);
|
|
14
|
+
if ((0, fs_1.existsSync)(path))
|
|
15
|
+
return path;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
/** 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()`. */
|
|
20
|
+
function getConfigDisplayPath() {
|
|
21
|
+
const path = resolveConfigPath();
|
|
22
|
+
return path ? (0, path_1.relative)(process.cwd(), path).split("\\").join("/") : CANDIDATE_NAMES[0];
|
|
23
|
+
}
|
|
24
|
+
function configExists() {
|
|
25
|
+
return resolveConfigPath() !== null;
|
|
26
|
+
}
|
|
27
|
+
const VALID_SEVERITIES = [
|
|
28
|
+
types_1.ChangeType.BREAKING,
|
|
29
|
+
types_1.ChangeType.ADDITIVE,
|
|
30
|
+
types_1.ChangeType.PATCH,
|
|
31
|
+
];
|
|
32
|
+
function validateConfig(raw, displayPath) {
|
|
33
|
+
if (raw === null || typeof raw !== "object") {
|
|
34
|
+
throw new Error(`cliguard: ${displayPath} must export an object (module.exports = {...}).`);
|
|
35
|
+
}
|
|
36
|
+
const config = raw;
|
|
37
|
+
if (config.ignore !== undefined) {
|
|
38
|
+
if (!Array.isArray(config.ignore) ||
|
|
39
|
+
!config.ignore.every((entry) => typeof entry === "string" || entry instanceof RegExp)) {
|
|
40
|
+
throw new Error(`cliguard: ${displayPath}'s "ignore" must be an array of strings/RegExp.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (config.severityOverrides !== undefined) {
|
|
44
|
+
if (!Array.isArray(config.severityOverrides)) {
|
|
45
|
+
throw new Error(`cliguard: ${displayPath}'s "severityOverrides" must be an array.`);
|
|
46
|
+
}
|
|
47
|
+
for (const entry of config.severityOverrides) {
|
|
48
|
+
const override = entry;
|
|
49
|
+
const patternOk = typeof override.pattern === "string" || override.pattern instanceof RegExp;
|
|
50
|
+
const severityOk = typeof override.severity === "string" && VALID_SEVERITIES.includes(override.severity);
|
|
51
|
+
if (!patternOk || !severityOk) {
|
|
52
|
+
throw new Error(`cliguard: ${displayPath}'s "severityOverrides" entries must look like ` +
|
|
53
|
+
`{ pattern: string | RegExp, severity: "BREAKING" | "ADDITIVE" | "PATCH" } - got ${JSON.stringify(entry)}.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return config;
|
|
58
|
+
}
|
|
59
|
+
/** Returns `{}` (no policy applied) when no config file exists - a project with no `cliguard.config.js` behaves exactly as it always has. */
|
|
60
|
+
function loadConfig() {
|
|
61
|
+
const path = resolveConfigPath();
|
|
62
|
+
if (!path)
|
|
63
|
+
return {};
|
|
64
|
+
const displayPath = getConfigDisplayPath();
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- a config file is plain CommonJS by design, same convention as eslint.config.js/jest.config.js
|
|
68
|
+
raw = require(path);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw new Error(`cliguard: failed to load ${displayPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
72
|
+
}
|
|
73
|
+
return validateConfig(raw, displayPath);
|
|
74
|
+
}
|
|
75
|
+
/** Glob-lite: `*` matches any run of characters, everything else is matched literally - just enough to write "root -> * -> option[--debug]" without a full glob dependency for one wildcard character. */
|
|
76
|
+
function matchesGlob(pattern, path) {
|
|
77
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
78
|
+
return new RegExp(`^${escaped}$`).test(path);
|
|
79
|
+
}
|
|
80
|
+
function matches(pattern, path) {
|
|
81
|
+
return pattern instanceof RegExp ? pattern.test(path) : matchesGlob(pattern, path);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Applies project-wide policy before any per-instance override (`cliguard
|
|
85
|
+
* accept`/`deprecate`) gets a chance to run - an ignored or downgraded
|
|
86
|
+
* change simply isn't BREAKING by the time either of those look at it, so
|
|
87
|
+
* there's nothing left to accept or deprecate for it.
|
|
88
|
+
*/
|
|
89
|
+
function applyConfig(diff, config) {
|
|
90
|
+
const ignore = config.ignore ?? [];
|
|
91
|
+
const overrides = config.severityOverrides ?? [];
|
|
92
|
+
if (ignore.length === 0 && overrides.length === 0)
|
|
93
|
+
return diff;
|
|
94
|
+
return diff
|
|
95
|
+
.filter((entry) => !ignore.some((pattern) => matches(pattern, entry.path)))
|
|
96
|
+
.map((entry) => {
|
|
97
|
+
const override = overrides.find((candidate) => matches(candidate.pattern, entry.path));
|
|
98
|
+
if (!override || override.severity === entry.type)
|
|
99
|
+
return entry;
|
|
100
|
+
return {
|
|
101
|
+
...entry,
|
|
102
|
+
type: override.severity,
|
|
103
|
+
message: `${entry.message} [config: severity overridden to ${override.severity}]`,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -5,6 +5,20 @@ export interface DiffResult {
|
|
|
5
5
|
readonly path: string;
|
|
6
6
|
/** Human-readable description of exactly what changed. */
|
|
7
7
|
readonly message: string;
|
|
8
|
+
/** True only for "a command/option/argument was removed" - lets `cliguard deprecate` reclassify exactly this kind of BREAKING change, never any other kind at the same path. */
|
|
9
|
+
readonly removal?: true;
|
|
10
|
+
}
|
|
11
|
+
export interface CompareOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Enables extra rules for changes that are currently silent (no diff
|
|
14
|
+
* entry at all) but can still break an existing caller - today, only a
|
|
15
|
+
* pure reorder of a command's positional arguments (same names, same
|
|
16
|
+
* required/variadic shape, different sequence), which the default,
|
|
17
|
+
* name-indexed comparison can't see since it never looks at position.
|
|
18
|
+
* Off by default so existing callers/CI configs keep today's behavior
|
|
19
|
+
* exactly - this is opt-in stricter enforcement, not a bug fix.
|
|
20
|
+
*/
|
|
21
|
+
readonly strict?: boolean;
|
|
8
22
|
}
|
|
9
23
|
/**
|
|
10
24
|
* Compares two Contracts and returns every difference between them,
|
|
@@ -14,7 +28,38 @@ export interface DiffResult {
|
|
|
14
28
|
* either contract. `capturedAt` is intentionally never read.
|
|
15
29
|
*/
|
|
16
30
|
export declare class DiffEngine {
|
|
17
|
-
compare(oldContract: Contract, newContract: Contract): DiffResult[];
|
|
31
|
+
compare(oldContract: Contract, newContract: Contract, options?: CompareOptions): DiffResult[];
|
|
32
|
+
/**
|
|
33
|
+
* Every valid DiffResult.path reachable from a Contract's root - the
|
|
34
|
+
* same path shapes `compareCommands`/`compareOptions`/`compareArguments`
|
|
35
|
+
* build ("root", "root -> build", "root -> build -> option[--target]",
|
|
36
|
+
* "root -> build -> argument[<file>]"). Used by `cliguard deprecate` to
|
|
37
|
+
* validate a path actually exists in the current contract before
|
|
38
|
+
* recording it, without duplicating the path-building logic here.
|
|
39
|
+
*/
|
|
40
|
+
collectPaths(contract: Contract): Set<string>;
|
|
41
|
+
private walkCommand;
|
|
42
|
+
/**
|
|
43
|
+
* Every DiffResult.path whose own description contains `marker` - a
|
|
44
|
+
* command/option/argument the target CLI's own author has marked this
|
|
45
|
+
* way is signaling "not stable yet" right at the point where it's
|
|
46
|
+
* declared (inspired by `buf`'s `ignore_unstable_packages`), rather
|
|
47
|
+
* than through a separately maintained ignore list
|
|
48
|
+
* (`cliguard.config.js`) that can silently drift out of sync as flags
|
|
49
|
+
* get renamed, removed, or re-added under a different name.
|
|
50
|
+
*/
|
|
51
|
+
collectUnstablePaths(contract: Contract, marker: string): Set<string>;
|
|
52
|
+
private walkUnstable;
|
|
53
|
+
/**
|
|
54
|
+
* Reclassifies a BREAKING change as PATCH when either side of the diff
|
|
55
|
+
* marks that path unstable - checking both the old *and* new contract
|
|
56
|
+
* matters because a removal only exists in the old one (the entry is
|
|
57
|
+
* simply gone from the new tree) while an added-then-changed entry only
|
|
58
|
+
* exists in the new one. `marker` defaults to `[unstable]`; every other
|
|
59
|
+
* kind of change (ADDITIVE, PATCH) is already non-blocking and left
|
|
60
|
+
* untouched either way.
|
|
61
|
+
*/
|
|
62
|
+
applyUnstableMarkers(diff: readonly DiffResult[], oldContract: Contract, newContract: Contract, marker?: string): DiffResult[];
|
|
18
63
|
private compareCommands;
|
|
19
64
|
private compareSubcommands;
|
|
20
65
|
/** CAC's default command (declared with no leading name, e.g. `cli.command("[...files]", ...)`) has name === "" - a blank path segment reads as a typo, not a real command. */
|
|
@@ -22,6 +67,16 @@ export declare class DiffEngine {
|
|
|
22
67
|
private compareOptions;
|
|
23
68
|
private compareOption;
|
|
24
69
|
private compareArguments;
|
|
70
|
+
/**
|
|
71
|
+
* `--strict`-only: positional arguments are matched by name everywhere
|
|
72
|
+
* above, so a pure reorder (same names, same shape, different sequence)
|
|
73
|
+
* produces no diff at all under the default rules - but position is
|
|
74
|
+
* exactly what a caller passing values positionally relies on, so it's
|
|
75
|
+
* a real, silent break. Only fires when the two argument lists are the
|
|
76
|
+
* same *set* of names (any actual add/remove is already reported by the
|
|
77
|
+
* per-name loop above; this would just be redundant noise on top of it).
|
|
78
|
+
*/
|
|
79
|
+
private compareArgumentOrder;
|
|
25
80
|
private compareArgument;
|
|
26
81
|
/** Shared by command aliases and option aliases - the rules are identical for both. */
|
|
27
82
|
private compareAliases;
|