cliguard 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -3
- 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 +369 -34
- 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 +39 -1
- package/dist/core/storage.js +172 -0
- package/dist/core/types.d.ts +34 -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,26 +77,115 @@ 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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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)()));
|
|
94
|
+
const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)());
|
|
95
|
+
const hasBreaking = diff.some((change) => change.type === types_1.ChangeType.BREAKING && !acceptedPaths.has(change.path));
|
|
96
|
+
if (format !== "text") {
|
|
97
|
+
console.log(formatReport(diff, acceptedPaths, format, (0, storage_1.getContractDisplayPath)()));
|
|
72
98
|
return hasBreaking ? 1 : 0;
|
|
73
99
|
}
|
|
74
100
|
if (diff.length === 0) {
|
|
75
101
|
console.log("✅ CLI contract is intact.");
|
|
76
102
|
return 0;
|
|
77
103
|
}
|
|
78
|
-
printDiff(diff);
|
|
104
|
+
printDiff(diff, acceptedPaths);
|
|
79
105
|
return hasBreaking ? 1 : 0;
|
|
80
106
|
});
|
|
81
107
|
process.exit(exitCode);
|
|
82
108
|
});
|
|
109
|
+
program
|
|
110
|
+
.command("accept")
|
|
111
|
+
.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")
|
|
113
|
+
.argument("<changePath>", 'the exact DiffResult path to accept, e.g. "root -> build -> option[--target]"')
|
|
114
|
+
.requiredOption("-r, --reason <text>", "why this break is intentional - shown in check output")
|
|
115
|
+
.option(...adapterOption)
|
|
116
|
+
.action(async (entry, changePath, options) => {
|
|
117
|
+
const exitCode = await withSuppressedExit(async () => {
|
|
118
|
+
const reason = options.reason.trim();
|
|
119
|
+
if (!reason) {
|
|
120
|
+
console.error("cliguard: --reason can't be blank - it's the audit trail for why this break is OK.");
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
const oldContract = (0, storage_1.readContract)();
|
|
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)()));
|
|
126
|
+
const match = diff.find((change) => change.type === types_1.ChangeType.BREAKING && change.path === changePath);
|
|
127
|
+
if (!match) {
|
|
128
|
+
const breaking = diff.filter((change) => change.type === types_1.ChangeType.BREAKING);
|
|
129
|
+
console.error(`cliguard: no current BREAKING change at path "${changePath}".` +
|
|
130
|
+
(breaking.length === 0
|
|
131
|
+
? " There are no BREAKING changes right now - nothing to accept."
|
|
132
|
+
: ` Currently breaking:\n${breaking.map((change) => ` - ${change.path}`).join("\n")}`));
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
// Replaces any earlier acceptance at the same path rather than
|
|
136
|
+
// accumulating duplicates - re-running `accept` updates the reason.
|
|
137
|
+
const remaining = (0, storage_1.readAcceptedBreaks)().filter((accepted) => accepted.path !== changePath);
|
|
138
|
+
const accepted = {
|
|
139
|
+
path: changePath,
|
|
140
|
+
reason,
|
|
141
|
+
acceptedAt: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
(0, storage_1.writeAcceptedBreaks)([...remaining, accepted]);
|
|
144
|
+
console.log(`✅ Accepted: [${changePath}] ${match.message}`);
|
|
145
|
+
console.log(` Reason: ${reason}`);
|
|
146
|
+
console.log(` Recorded in ${(0, storage_1.getAcceptedBreaksDisplayPath)()} - commit this file.`);
|
|
147
|
+
return 0;
|
|
148
|
+
});
|
|
149
|
+
process.exit(exitCode);
|
|
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
|
+
});
|
|
83
189
|
program
|
|
84
190
|
.command("update")
|
|
85
191
|
.description("Overwrite the committed contract with the CLI's current surface")
|
|
@@ -87,13 +193,192 @@ program
|
|
|
87
193
|
.option(...adapterOption)
|
|
88
194
|
.action(async (entry, options) => {
|
|
89
195
|
const exitCode = await withSuppressedExit(async () => {
|
|
90
|
-
const contract = await resolveAdapter(options.adapter).extract(entry);
|
|
196
|
+
const contract = await (0, registry_1.resolveAdapter)(options.adapter).extract(entry);
|
|
91
197
|
(0, storage_1.writeContract)(contract);
|
|
92
198
|
console.log("🔄 CLI contract updated successfully.");
|
|
93
199
|
return 0;
|
|
94
200
|
});
|
|
95
201
|
process.exit(exitCode);
|
|
96
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
|
+
});
|
|
273
|
+
program
|
|
274
|
+
.command("diff")
|
|
275
|
+
.description("Compare two contract files directly, without running any CLI")
|
|
276
|
+
.argument("<oldContract>", "path to the older contract JSON file")
|
|
277
|
+
.argument("<newContract>", "path to the newer contract JSON file")
|
|
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)
|
|
281
|
+
.action((oldPath, newPath, options) => {
|
|
282
|
+
// No adapter, no target CLI ever loaded here - just two files off
|
|
283
|
+
// disk - so none of withSuppressedExit's process.exit-race concerns
|
|
284
|
+
// apply. A thrown Error (bad path, corrupt JSON) still surfaces via
|
|
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
|
+
}
|
|
291
|
+
const oldContract = (0, storage_1.readContractFile)(oldPath);
|
|
292
|
+
const newContract = (0, storage_1.readContractFile)(newPath);
|
|
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)()));
|
|
294
|
+
const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)());
|
|
295
|
+
const hasBreaking = diff.some((change) => change.type === types_1.ChangeType.BREAKING && !acceptedPaths.has(change.path));
|
|
296
|
+
if (format !== "text") {
|
|
297
|
+
console.log(formatReport(diff, acceptedPaths, format, newPath));
|
|
298
|
+
process.exit(hasBreaking ? 1 : 0);
|
|
299
|
+
}
|
|
300
|
+
if (diff.length === 0) {
|
|
301
|
+
console.log("✅ Contracts are identical.");
|
|
302
|
+
process.exit(0);
|
|
303
|
+
}
|
|
304
|
+
printDiff(diff, acceptedPaths);
|
|
305
|
+
process.exit(hasBreaking ? 1 : 0);
|
|
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
|
+
}
|
|
97
382
|
/**
|
|
98
383
|
* Runs `action` with `process.exit` neutralized, restoring the real one
|
|
99
384
|
* the instant `action` settles - then the caller calls the *real*
|
|
@@ -125,9 +410,53 @@ async function withSuppressedExit(action) {
|
|
|
125
410
|
process.exit = realExit;
|
|
126
411
|
}
|
|
127
412
|
}
|
|
128
|
-
function
|
|
413
|
+
function indexAcceptedBreaks(accepted) {
|
|
414
|
+
return new Map(accepted.map((entry) => [entry.path, entry]));
|
|
415
|
+
}
|
|
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) => {
|
|
449
|
+
if (entry.type !== types_1.ChangeType.BREAKING)
|
|
450
|
+
return entry;
|
|
451
|
+
const accepted = acceptedPaths.get(entry.path);
|
|
452
|
+
return accepted ? { ...entry, acknowledged: true, reason: accepted.reason } : entry;
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
function toJsonResult(diff, acceptedPaths) {
|
|
456
|
+
const changes = annotateChanges(diff, acceptedPaths);
|
|
129
457
|
const summary = {
|
|
130
|
-
breaking:
|
|
458
|
+
breaking: changes.filter((change) => change.type === types_1.ChangeType.BREAKING && !change.acknowledged).length,
|
|
459
|
+
acknowledgedBreaking: changes.filter((change) => change.type === types_1.ChangeType.BREAKING && change.acknowledged).length,
|
|
131
460
|
additive: diff.filter((entry) => entry.type === types_1.ChangeType.ADDITIVE).length,
|
|
132
461
|
patch: diff.filter((entry) => entry.type === types_1.ChangeType.PATCH).length,
|
|
133
462
|
};
|
|
@@ -138,11 +467,17 @@ function toJsonResult(diff) {
|
|
|
138
467
|
: summary.patch > 0
|
|
139
468
|
? "patch"
|
|
140
469
|
: null;
|
|
141
|
-
return { ok: summary.breaking === 0, changes
|
|
470
|
+
return { ok: summary.breaking === 0, changes, summary, suggestedBump };
|
|
142
471
|
}
|
|
143
|
-
function printDiff(diff) {
|
|
472
|
+
function printDiff(diff, acceptedPaths) {
|
|
144
473
|
for (const entry of diff) {
|
|
145
|
-
|
|
474
|
+
const accepted = entry.type === types_1.ChangeType.BREAKING ? acceptedPaths.get(entry.path) : undefined;
|
|
475
|
+
if (accepted) {
|
|
476
|
+
console.log(`🟣 [${entry.path}] ${entry.message} (acknowledged: ${accepted.reason})`);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
console.log(`${emojiFor(entry.type)} [${entry.path}] ${entry.message}`);
|
|
480
|
+
}
|
|
146
481
|
}
|
|
147
482
|
}
|
|
148
483
|
function emojiFor(type) {
|
|
@@ -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
|
+
}
|