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/core/diff.engine.js
CHANGED
|
@@ -10,7 +10,7 @@ const types_1 = require("./types");
|
|
|
10
10
|
* either contract. `capturedAt` is intentionally never read.
|
|
11
11
|
*/
|
|
12
12
|
class DiffEngine {
|
|
13
|
-
compare(oldContract, newContract) {
|
|
13
|
+
compare(oldContract, newContract, options = {}) {
|
|
14
14
|
const results = [];
|
|
15
15
|
if (oldContract.adapter !== newContract.adapter) {
|
|
16
16
|
results.push({
|
|
@@ -26,10 +26,90 @@ class DiffEngine {
|
|
|
26
26
|
message: `Contract format version changed from ${oldContract.contractVersion} to ${newContract.contractVersion}.`,
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
-
results.push(...this.compareCommands(oldContract.root, newContract.root, "root"));
|
|
29
|
+
results.push(...this.compareCommands(oldContract.root, newContract.root, "root", options));
|
|
30
30
|
return results;
|
|
31
31
|
}
|
|
32
|
-
|
|
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) {
|
|
41
|
+
const paths = new Set();
|
|
42
|
+
this.walkCommand(contract.root, "root", paths);
|
|
43
|
+
return paths;
|
|
44
|
+
}
|
|
45
|
+
walkCommand(cmd, path, paths) {
|
|
46
|
+
paths.add(path);
|
|
47
|
+
for (const option of cmd.options) {
|
|
48
|
+
paths.add(`${path} -> option[--${option.name}]`);
|
|
49
|
+
}
|
|
50
|
+
for (const arg of cmd.arguments) {
|
|
51
|
+
paths.add(`${path} -> argument[<${arg.name}>]`);
|
|
52
|
+
}
|
|
53
|
+
for (const sub of cmd.subcommands) {
|
|
54
|
+
this.walkCommand(sub, `${path} -> ${this.commandLabel(sub.name)}`, paths);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Every DiffResult.path whose own description contains `marker` - a
|
|
59
|
+
* command/option/argument the target CLI's own author has marked this
|
|
60
|
+
* way is signaling "not stable yet" right at the point where it's
|
|
61
|
+
* declared (inspired by `buf`'s `ignore_unstable_packages`), rather
|
|
62
|
+
* than through a separately maintained ignore list
|
|
63
|
+
* (`cliguard.config.js`) that can silently drift out of sync as flags
|
|
64
|
+
* get renamed, removed, or re-added under a different name.
|
|
65
|
+
*/
|
|
66
|
+
collectUnstablePaths(contract, marker) {
|
|
67
|
+
const paths = new Set();
|
|
68
|
+
this.walkUnstable(contract.root, "root", marker, paths);
|
|
69
|
+
return paths;
|
|
70
|
+
}
|
|
71
|
+
walkUnstable(cmd, path, marker, paths) {
|
|
72
|
+
if (cmd.description.includes(marker))
|
|
73
|
+
paths.add(path);
|
|
74
|
+
for (const option of cmd.options) {
|
|
75
|
+
if (option.description.includes(marker))
|
|
76
|
+
paths.add(`${path} -> option[--${option.name}]`);
|
|
77
|
+
}
|
|
78
|
+
for (const arg of cmd.arguments) {
|
|
79
|
+
if (arg.description.includes(marker))
|
|
80
|
+
paths.add(`${path} -> argument[<${arg.name}>]`);
|
|
81
|
+
}
|
|
82
|
+
for (const sub of cmd.subcommands) {
|
|
83
|
+
this.walkUnstable(sub, `${path} -> ${this.commandLabel(sub.name)}`, marker, paths);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Reclassifies a BREAKING change as PATCH when either side of the diff
|
|
88
|
+
* marks that path unstable - checking both the old *and* new contract
|
|
89
|
+
* matters because a removal only exists in the old one (the entry is
|
|
90
|
+
* simply gone from the new tree) while an added-then-changed entry only
|
|
91
|
+
* exists in the new one. `marker` defaults to `[unstable]`; every other
|
|
92
|
+
* kind of change (ADDITIVE, PATCH) is already non-blocking and left
|
|
93
|
+
* untouched either way.
|
|
94
|
+
*/
|
|
95
|
+
applyUnstableMarkers(diff, oldContract, newContract, marker = "[unstable]") {
|
|
96
|
+
const unstable = new Set([
|
|
97
|
+
...this.collectUnstablePaths(oldContract, marker),
|
|
98
|
+
...this.collectUnstablePaths(newContract, marker),
|
|
99
|
+
]);
|
|
100
|
+
if (unstable.size === 0)
|
|
101
|
+
return diff;
|
|
102
|
+
return diff.map((entry) => {
|
|
103
|
+
if (entry.type !== types_1.ChangeType.BREAKING || !unstable.has(entry.path))
|
|
104
|
+
return entry;
|
|
105
|
+
return {
|
|
106
|
+
...entry,
|
|
107
|
+
type: types_1.ChangeType.PATCH,
|
|
108
|
+
message: `${entry.message} [unstable: exempt from breaking-change enforcement]`,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
compareCommands(oldCmd, newCmd, path, options) {
|
|
33
113
|
const results = [];
|
|
34
114
|
if (oldCmd.description !== newCmd.description) {
|
|
35
115
|
results.push({
|
|
@@ -40,11 +120,11 @@ class DiffEngine {
|
|
|
40
120
|
}
|
|
41
121
|
results.push(...this.compareAliases(oldCmd.aliases, newCmd.aliases, path, `command "${this.commandLabel(oldCmd.name)}"`));
|
|
42
122
|
results.push(...this.compareOptions(oldCmd.options, newCmd.options, path));
|
|
43
|
-
results.push(...this.compareArguments(oldCmd.arguments, newCmd.arguments, path));
|
|
44
|
-
results.push(...this.compareSubcommands(oldCmd.subcommands, newCmd.subcommands, path));
|
|
123
|
+
results.push(...this.compareArguments(oldCmd.arguments, newCmd.arguments, path, options));
|
|
124
|
+
results.push(...this.compareSubcommands(oldCmd.subcommands, newCmd.subcommands, path, options));
|
|
45
125
|
return results;
|
|
46
126
|
}
|
|
47
|
-
compareSubcommands(oldSubs, newSubs, path) {
|
|
127
|
+
compareSubcommands(oldSubs, newSubs, path, options) {
|
|
48
128
|
const results = [];
|
|
49
129
|
const oldByName = this.indexByName(oldSubs);
|
|
50
130
|
const newByName = this.indexByName(newSubs);
|
|
@@ -56,10 +136,11 @@ class DiffEngine {
|
|
|
56
136
|
type: types_1.ChangeType.BREAKING,
|
|
57
137
|
path: childPath,
|
|
58
138
|
message: `Command "${this.commandLabel(name)}" was removed.`,
|
|
139
|
+
removal: true,
|
|
59
140
|
});
|
|
60
141
|
continue;
|
|
61
142
|
}
|
|
62
|
-
results.push(...this.compareCommands(oldSub, newSub, childPath));
|
|
143
|
+
results.push(...this.compareCommands(oldSub, newSub, childPath, options));
|
|
63
144
|
}
|
|
64
145
|
for (const name of newByName.keys()) {
|
|
65
146
|
if (oldByName.has(name))
|
|
@@ -88,6 +169,7 @@ class DiffEngine {
|
|
|
88
169
|
type: types_1.ChangeType.BREAKING,
|
|
89
170
|
path: optionPath,
|
|
90
171
|
message: `Option "--${name}" was removed.`,
|
|
172
|
+
removal: true,
|
|
91
173
|
});
|
|
92
174
|
continue;
|
|
93
175
|
}
|
|
@@ -162,7 +244,7 @@ class DiffEngine {
|
|
|
162
244
|
}
|
|
163
245
|
return results;
|
|
164
246
|
}
|
|
165
|
-
compareArguments(oldArgs, newArgs, path) {
|
|
247
|
+
compareArguments(oldArgs, newArgs, path, options) {
|
|
166
248
|
const results = [];
|
|
167
249
|
const oldByName = this.indexByName(oldArgs);
|
|
168
250
|
const newByName = this.indexByName(newArgs);
|
|
@@ -174,6 +256,7 @@ class DiffEngine {
|
|
|
174
256
|
type: types_1.ChangeType.BREAKING,
|
|
175
257
|
path: argPath,
|
|
176
258
|
message: `Argument "<${name}>" was removed.`,
|
|
259
|
+
removal: true,
|
|
177
260
|
});
|
|
178
261
|
continue;
|
|
179
262
|
}
|
|
@@ -198,8 +281,40 @@ class DiffEngine {
|
|
|
198
281
|
});
|
|
199
282
|
}
|
|
200
283
|
}
|
|
284
|
+
if (options.strict) {
|
|
285
|
+
results.push(...this.compareArgumentOrder(oldArgs, newArgs, path));
|
|
286
|
+
}
|
|
201
287
|
return results;
|
|
202
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* `--strict`-only: positional arguments are matched by name everywhere
|
|
291
|
+
* above, so a pure reorder (same names, same shape, different sequence)
|
|
292
|
+
* produces no diff at all under the default rules - but position is
|
|
293
|
+
* exactly what a caller passing values positionally relies on, so it's
|
|
294
|
+
* a real, silent break. Only fires when the two argument lists are the
|
|
295
|
+
* same *set* of names (any actual add/remove is already reported by the
|
|
296
|
+
* per-name loop above; this would just be redundant noise on top of it).
|
|
297
|
+
*/
|
|
298
|
+
compareArgumentOrder(oldArgs, newArgs, path) {
|
|
299
|
+
if (oldArgs.length !== newArgs.length)
|
|
300
|
+
return [];
|
|
301
|
+
const oldNames = oldArgs.map((arg) => arg.name);
|
|
302
|
+
const newNames = newArgs.map((arg) => arg.name);
|
|
303
|
+
if (oldNames.join("") === newNames.join(""))
|
|
304
|
+
return [];
|
|
305
|
+
const sameSet = new Set(oldNames).size === new Set(newNames).size &&
|
|
306
|
+
oldNames.every((name) => newNames.includes(name));
|
|
307
|
+
if (!sameSet)
|
|
308
|
+
return [];
|
|
309
|
+
return [
|
|
310
|
+
{
|
|
311
|
+
type: types_1.ChangeType.BREAKING,
|
|
312
|
+
path,
|
|
313
|
+
message: `[strict] Argument order changed: was <${oldNames.join(">, <")}>, ` +
|
|
314
|
+
`now <${newNames.join(">, <")}>. Existing positional invocations may now bind values to the wrong argument.`,
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
}
|
|
203
318
|
compareArgument(oldArg, newArg, path) {
|
|
204
319
|
const results = [];
|
|
205
320
|
const label = `Argument "<${oldArg.name}>"`;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ChangeType } from "./types";
|
|
2
|
+
/** The same annotated shape `check --json`/`diff --json` already produce - a BREAKING entry matched by `cliguard accept` gains `acknowledged`/`reason`. */
|
|
3
|
+
export interface ReportChange {
|
|
4
|
+
readonly type: ChangeType;
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly message: string;
|
|
7
|
+
readonly acknowledged?: boolean;
|
|
8
|
+
readonly reason?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* One <testsuite> named "cliguard", one <testcase> per changed path - the
|
|
12
|
+
* same shape ESLint/ruff's own JUnit reporters use, understood natively by
|
|
13
|
+
* Jenkins, CircleCI, Azure DevOps, and GitLab's own "JUnit report" widget.
|
|
14
|
+
* Only an unacknowledged BREAKING entry gets a <failure> child; ADDITIVE,
|
|
15
|
+
* PATCH, and an acknowledged BREAKING all report as a passing testcase,
|
|
16
|
+
* matching exactly which entries fail `check`'s own exit code.
|
|
17
|
+
*
|
|
18
|
+
* DiffResult.path can itself contain `<`/`>` (an argument path looks like
|
|
19
|
+
* "root -> build -> argument[<file>]") - every value here goes through
|
|
20
|
+
* escapeXml, not just the message, or this would emit invalid XML on the
|
|
21
|
+
* very first CLI that has a positional argument.
|
|
22
|
+
*/
|
|
23
|
+
export declare function toJUnitXml(changes: readonly ReportChange[]): string;
|
|
24
|
+
/**
|
|
25
|
+
* GitLab's Code Quality report format (`artifacts: reports: codequality`),
|
|
26
|
+
* surfaced as inline annotations on a merge request. The format is
|
|
27
|
+
* inherently file+line shaped - a cliguard change has neither, so every
|
|
28
|
+
* entry points at the committed contract file (line 1) and puts the real
|
|
29
|
+
* location in the description instead. A best-effort mapping, not a
|
|
30
|
+
* perfect fit for what GitLab expects, but it gets cliguard's diff into
|
|
31
|
+
* GitLab's own MR widget with zero extra infra on GitLab's side.
|
|
32
|
+
*/
|
|
33
|
+
export declare function toGitLabCodeQuality(changes: readonly ReportChange[], contractPath: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* reviewdog's own Diagnostic Format, one JSON object per line (rdjsonl) -
|
|
36
|
+
* NOT a JSON array, reviewdog reads it as a stream. Piping this into
|
|
37
|
+
* `reviewdog -f=rdjsonl -reporter=<...>` hands off posting the diff to
|
|
38
|
+
* whichever platform reviewdog already has a reporter for (GitHub,
|
|
39
|
+
* GitLab, Bitbucket, a local checkstyle-style report), instead of
|
|
40
|
+
* cliguard maintaining a bespoke reporter per platform itself. Same
|
|
41
|
+
* file+line limitation and same fallback (the contract file, line 1) as
|
|
42
|
+
* `toGitLabCodeQuality` - reviewdog's format is just as file-shaped.
|
|
43
|
+
*/
|
|
44
|
+
export declare function toRdjsonl(changes: readonly ReportChange[], contractPath: string): string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toJUnitXml = toJUnitXml;
|
|
4
|
+
exports.toGitLabCodeQuality = toGitLabCodeQuality;
|
|
5
|
+
exports.toRdjsonl = toRdjsonl;
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
7
|
+
const types_1 = require("./types");
|
|
8
|
+
function escapeXml(value) {
|
|
9
|
+
return value
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """)
|
|
14
|
+
.replace(/'/g, "'");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* One <testsuite> named "cliguard", one <testcase> per changed path - the
|
|
18
|
+
* same shape ESLint/ruff's own JUnit reporters use, understood natively by
|
|
19
|
+
* Jenkins, CircleCI, Azure DevOps, and GitLab's own "JUnit report" widget.
|
|
20
|
+
* Only an unacknowledged BREAKING entry gets a <failure> child; ADDITIVE,
|
|
21
|
+
* PATCH, and an acknowledged BREAKING all report as a passing testcase,
|
|
22
|
+
* matching exactly which entries fail `check`'s own exit code.
|
|
23
|
+
*
|
|
24
|
+
* DiffResult.path can itself contain `<`/`>` (an argument path looks like
|
|
25
|
+
* "root -> build -> argument[<file>]") - every value here goes through
|
|
26
|
+
* escapeXml, not just the message, or this would emit invalid XML on the
|
|
27
|
+
* very first CLI that has a positional argument.
|
|
28
|
+
*/
|
|
29
|
+
function toJUnitXml(changes) {
|
|
30
|
+
const failures = changes.filter((change) => change.type === types_1.ChangeType.BREAKING && !change.acknowledged).length;
|
|
31
|
+
const testcases = changes.map((change) => {
|
|
32
|
+
const name = escapeXml(change.path);
|
|
33
|
+
const message = escapeXml(change.message);
|
|
34
|
+
const isFailure = change.type === types_1.ChangeType.BREAKING && !change.acknowledged;
|
|
35
|
+
if (!isFailure) {
|
|
36
|
+
return (` <testcase name="${name}" classname="cliguard">\n` +
|
|
37
|
+
` <system-out>${message}</system-out>\n` +
|
|
38
|
+
` </testcase>`);
|
|
39
|
+
}
|
|
40
|
+
return (` <testcase name="${name}" classname="cliguard">\n` +
|
|
41
|
+
` <failure message="${message}" type="BREAKING">${message}</failure>\n` +
|
|
42
|
+
` </testcase>`);
|
|
43
|
+
});
|
|
44
|
+
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
45
|
+
`<testsuites>\n` +
|
|
46
|
+
` <testsuite name="cliguard" tests="${changes.length}" failures="${failures}">\n` +
|
|
47
|
+
(testcases.length > 0 ? `${testcases.join("\n")}\n` : "") +
|
|
48
|
+
` </testsuite>\n` +
|
|
49
|
+
`</testsuites>\n`);
|
|
50
|
+
}
|
|
51
|
+
function severityFor(change) {
|
|
52
|
+
if (change.type === types_1.ChangeType.BREAKING)
|
|
53
|
+
return change.acknowledged ? "info" : "blocker";
|
|
54
|
+
if (change.type === types_1.ChangeType.PATCH)
|
|
55
|
+
return "minor";
|
|
56
|
+
return "info"; // ADDITIVE
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* GitLab's Code Quality report format (`artifacts: reports: codequality`),
|
|
60
|
+
* surfaced as inline annotations on a merge request. The format is
|
|
61
|
+
* inherently file+line shaped - a cliguard change has neither, so every
|
|
62
|
+
* entry points at the committed contract file (line 1) and puts the real
|
|
63
|
+
* location in the description instead. A best-effort mapping, not a
|
|
64
|
+
* perfect fit for what GitLab expects, but it gets cliguard's diff into
|
|
65
|
+
* GitLab's own MR widget with zero extra infra on GitLab's side.
|
|
66
|
+
*/
|
|
67
|
+
function toGitLabCodeQuality(changes, contractPath) {
|
|
68
|
+
const issues = changes.map((change) => ({
|
|
69
|
+
description: `[${change.type}] ${change.path}: ${change.message}`,
|
|
70
|
+
check_name: `cliguard/${change.type.toLowerCase()}`,
|
|
71
|
+
fingerprint: (0, crypto_1.createHash)("md5").update(`${change.path}|${change.message}`).digest("hex"),
|
|
72
|
+
severity: severityFor(change),
|
|
73
|
+
location: { path: contractPath, lines: { begin: 1 } },
|
|
74
|
+
}));
|
|
75
|
+
return JSON.stringify(issues, null, 2);
|
|
76
|
+
}
|
|
77
|
+
function rdjsonlSeverityFor(change) {
|
|
78
|
+
if (change.type === types_1.ChangeType.BREAKING)
|
|
79
|
+
return change.acknowledged ? "INFO" : "ERROR";
|
|
80
|
+
if (change.type === types_1.ChangeType.PATCH)
|
|
81
|
+
return "WARNING";
|
|
82
|
+
return "INFO"; // ADDITIVE
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* reviewdog's own Diagnostic Format, one JSON object per line (rdjsonl) -
|
|
86
|
+
* NOT a JSON array, reviewdog reads it as a stream. Piping this into
|
|
87
|
+
* `reviewdog -f=rdjsonl -reporter=<...>` hands off posting the diff to
|
|
88
|
+
* whichever platform reviewdog already has a reporter for (GitHub,
|
|
89
|
+
* GitLab, Bitbucket, a local checkstyle-style report), instead of
|
|
90
|
+
* cliguard maintaining a bespoke reporter per platform itself. Same
|
|
91
|
+
* file+line limitation and same fallback (the contract file, line 1) as
|
|
92
|
+
* `toGitLabCodeQuality` - reviewdog's format is just as file-shaped.
|
|
93
|
+
*/
|
|
94
|
+
function toRdjsonl(changes, contractPath) {
|
|
95
|
+
return changes
|
|
96
|
+
.map((change) => JSON.stringify({
|
|
97
|
+
message: `[${change.type}] ${change.message}`,
|
|
98
|
+
location: { path: contractPath, range: { start: { line: 1, column: 1 } } },
|
|
99
|
+
severity: rdjsonlSeverityFor(change),
|
|
100
|
+
code: { value: change.path },
|
|
101
|
+
}))
|
|
102
|
+
.join("\n");
|
|
103
|
+
}
|
package/dist/core/storage.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AcceptedBreak, Contract } from "./types";
|
|
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
3
|
export declare function getContractDisplayPath(): string;
|
|
4
4
|
/** Accepted-breaks path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
@@ -15,6 +15,30 @@ export declare function writeContract(contract: Contract): void;
|
|
|
15
15
|
* already about as displayable as it gets.
|
|
16
16
|
*/
|
|
17
17
|
export declare function readContractFile(path: string, displayPath?: string): Contract;
|
|
18
|
+
/**
|
|
19
|
+
* Reads the committed contract as it existed at a git ref (a branch, tag,
|
|
20
|
+
* or commit sha) instead of the working tree - `cliguard check <entry>
|
|
21
|
+
* --against origin/main` needs no local `.cliguard/contract.json` at all,
|
|
22
|
+
* closing the CI-friction gap `readContractFile`'s own doc comment above
|
|
23
|
+
* already names as the manual workaround (`git show <ref>:... > old.json`
|
|
24
|
+
* piped into `cliguard diff`).
|
|
25
|
+
*/
|
|
26
|
+
export declare function readContractAtRef(ref: string): Contract;
|
|
18
27
|
/** Unlike readContract, a missing file is normal (most projects never accept a break) - returns [] rather than throwing. */
|
|
19
28
|
export declare function readAcceptedBreaks(): AcceptedBreak[];
|
|
20
29
|
export declare function writeAcceptedBreaks(breaks: readonly AcceptedBreak[]): void;
|
|
30
|
+
/** Deprecations path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
31
|
+
export declare function getDeprecationsDisplayPath(): string;
|
|
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;
|
|
35
|
+
/** CI workflow path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
36
|
+
export declare function getCiWorkflowDisplayPath(): string;
|
|
37
|
+
export declare function ciWorkflowExists(): boolean;
|
|
38
|
+
/** Never called when ciWorkflowExists() is true - `init --with-ci` checks first so a hand-edited workflow is never clobbered. */
|
|
39
|
+
export declare function writeCiWorkflow(content: string): void;
|
|
40
|
+
/** Hook path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
41
|
+
export declare function getHookDisplayPath(hookName: string): string;
|
|
42
|
+
export declare function hookExists(hookName: string): boolean;
|
|
43
|
+
/** Never called when hookExists() is true - install-hook checks first so a hand-edited hook is never clobbered. */
|
|
44
|
+
export declare function writeHook(hookName: string, content: string): void;
|
package/dist/core/storage.js
CHANGED
|
@@ -6,12 +6,25 @@ exports.contractExists = contractExists;
|
|
|
6
6
|
exports.readContract = readContract;
|
|
7
7
|
exports.writeContract = writeContract;
|
|
8
8
|
exports.readContractFile = readContractFile;
|
|
9
|
+
exports.readContractAtRef = readContractAtRef;
|
|
9
10
|
exports.readAcceptedBreaks = readAcceptedBreaks;
|
|
10
11
|
exports.writeAcceptedBreaks = writeAcceptedBreaks;
|
|
12
|
+
exports.getDeprecationsDisplayPath = getDeprecationsDisplayPath;
|
|
13
|
+
exports.readDeprecations = readDeprecations;
|
|
14
|
+
exports.writeDeprecations = writeDeprecations;
|
|
15
|
+
exports.getCiWorkflowDisplayPath = getCiWorkflowDisplayPath;
|
|
16
|
+
exports.ciWorkflowExists = ciWorkflowExists;
|
|
17
|
+
exports.writeCiWorkflow = writeCiWorkflow;
|
|
18
|
+
exports.getHookDisplayPath = getHookDisplayPath;
|
|
19
|
+
exports.hookExists = hookExists;
|
|
20
|
+
exports.writeHook = writeHook;
|
|
21
|
+
const child_process_1 = require("child_process");
|
|
11
22
|
const fs_1 = require("fs");
|
|
12
23
|
const path_1 = require("path");
|
|
13
24
|
const CONTRACT_PATH = (0, path_1.join)(process.cwd(), ".cliguard", "contract.json");
|
|
14
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");
|
|
27
|
+
const CI_WORKFLOW_PATH = (0, path_1.join)(process.cwd(), ".github", "workflows", "cliguard.yml");
|
|
15
28
|
/** Contract path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
16
29
|
function getContractDisplayPath() {
|
|
17
30
|
return (0, path_1.relative)(process.cwd(), CONTRACT_PATH).split("\\").join("/");
|
|
@@ -69,6 +82,40 @@ function readContractFile(path, displayPath = path) {
|
|
|
69
82
|
throw new Error(`cliguard: "${displayPath}" is not valid JSON (${reason}).`);
|
|
70
83
|
}
|
|
71
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Reads the committed contract as it existed at a git ref (a branch, tag,
|
|
87
|
+
* or commit sha) instead of the working tree - `cliguard check <entry>
|
|
88
|
+
* --against origin/main` needs no local `.cliguard/contract.json` at all,
|
|
89
|
+
* closing the CI-friction gap `readContractFile`'s own doc comment above
|
|
90
|
+
* already names as the manual workaround (`git show <ref>:... > old.json`
|
|
91
|
+
* piped into `cliguard diff`).
|
|
92
|
+
*/
|
|
93
|
+
function readContractAtRef(ref) {
|
|
94
|
+
const contractGitPath = getContractDisplayPath();
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
raw = (0, child_process_1.execFileSync)("git", ["show", `${ref}:${contractGitPath}`], {
|
|
98
|
+
encoding: "utf-8",
|
|
99
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const stderr = error && typeof error === "object" && "stderr" in error
|
|
104
|
+
? String(error.stderr).trim()
|
|
105
|
+
: undefined;
|
|
106
|
+
throw new Error(`cliguard: couldn't read "${contractGitPath}" at ref "${ref}"` +
|
|
107
|
+
(stderr ? ` (${stderr})` : ".") +
|
|
108
|
+
` Make sure "${ref}" exists and has a contract committed at that path - ` +
|
|
109
|
+
`a shallow clone may need \`git fetch --deepen\` or \`git fetch origin ${ref}\` first.`);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(raw);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
116
|
+
throw new Error(`cliguard: "${contractGitPath}" at ref "${ref}" is not valid JSON (${reason}).`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
72
119
|
/** Unlike readContract, a missing file is normal (most projects never accept a break) - returns [] rather than throwing. */
|
|
73
120
|
function readAcceptedBreaks() {
|
|
74
121
|
if (!(0, fs_1.existsSync)(ACCEPTED_BREAKS_PATH))
|
|
@@ -90,3 +137,77 @@ function writeAcceptedBreaks(breaks) {
|
|
|
90
137
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(ACCEPTED_BREAKS_PATH), { recursive: true });
|
|
91
138
|
(0, fs_1.writeFileSync)(ACCEPTED_BREAKS_PATH, JSON.stringify(breaks, null, 2) + "\n", "utf-8");
|
|
92
139
|
}
|
|
140
|
+
/** 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("/");
|
|
143
|
+
}
|
|
144
|
+
/** 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))
|
|
147
|
+
return [];
|
|
148
|
+
const raw = (0, fs_1.readFileSync)(DEPRECATIONS_PATH, "utf-8");
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(raw);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
154
|
+
throw new Error(`cliguard: "${getDeprecationsDisplayPath()}" is not valid JSON (${reason}). ` +
|
|
155
|
+
"If this file was hand-edited or came out of a bad merge, fix it or delete it " +
|
|
156
|
+
"and re-run `cliguard deprecate` for whatever was in it.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
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");
|
|
162
|
+
}
|
|
163
|
+
/** CI workflow path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
164
|
+
function getCiWorkflowDisplayPath() {
|
|
165
|
+
return (0, path_1.relative)(process.cwd(), CI_WORKFLOW_PATH).split("\\").join("/");
|
|
166
|
+
}
|
|
167
|
+
function ciWorkflowExists() {
|
|
168
|
+
return (0, fs_1.existsSync)(CI_WORKFLOW_PATH);
|
|
169
|
+
}
|
|
170
|
+
/** Never called when ciWorkflowExists() is true - `init --with-ci` checks first so a hand-edited workflow is never clobbered. */
|
|
171
|
+
function writeCiWorkflow(content) {
|
|
172
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(CI_WORKFLOW_PATH), { recursive: true });
|
|
173
|
+
(0, fs_1.writeFileSync)(CI_WORKFLOW_PATH, content, "utf-8");
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* `git rev-parse --git-path hooks` rather than a hardcoded `.git/hooks` -
|
|
177
|
+
* correct even when a repo sets `core.hooksPath`, is a worktree (`.git` is
|
|
178
|
+
* a file, not a directory, pointing elsewhere), or is a submodule.
|
|
179
|
+
*/
|
|
180
|
+
function resolveHooksDir() {
|
|
181
|
+
let out;
|
|
182
|
+
try {
|
|
183
|
+
out = (0, child_process_1.execFileSync)("git", ["rev-parse", "--git-path", "hooks"], {
|
|
184
|
+
encoding: "utf-8",
|
|
185
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
186
|
+
}).trim();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
throw new Error("cliguard: not a git repository (or git isn't installed) - install-hook needs one.");
|
|
190
|
+
}
|
|
191
|
+
return (0, path_1.resolve)(process.cwd(), out);
|
|
192
|
+
}
|
|
193
|
+
/** Hook path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
194
|
+
function getHookDisplayPath(hookName) {
|
|
195
|
+
return (0, path_1.relative)(process.cwd(), (0, path_1.join)(resolveHooksDir(), hookName)).split("\\").join("/");
|
|
196
|
+
}
|
|
197
|
+
function hookExists(hookName) {
|
|
198
|
+
return (0, fs_1.existsSync)((0, path_1.join)(resolveHooksDir(), hookName));
|
|
199
|
+
}
|
|
200
|
+
/** Never called when hookExists() is true - install-hook checks first so a hand-edited hook is never clobbered. */
|
|
201
|
+
function writeHook(hookName, content) {
|
|
202
|
+
const hookPath = (0, path_1.join)(resolveHooksDir(), hookName);
|
|
203
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(hookPath), { recursive: true });
|
|
204
|
+
(0, fs_1.writeFileSync)(hookPath, content, "utf-8");
|
|
205
|
+
try {
|
|
206
|
+
(0, fs_1.chmodSync)(hookPath, 0o755);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Best-effort: Windows filesystems mostly ignore the Unix exec bit
|
|
210
|
+
// anyway, and Git for Windows runs hooks via its own shebang handling
|
|
211
|
+
// regardless - a failed chmod here shouldn't fail the whole command.
|
|
212
|
+
}
|
|
213
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -66,6 +66,25 @@ export interface AcceptedBreak {
|
|
|
66
66
|
/** ISO-8601 timestamp of when `cliguard accept` recorded this. */
|
|
67
67
|
readonly acceptedAt: string;
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* A command/option/argument the maintainer has scheduled for removal ahead
|
|
71
|
+
* of time - written by `cliguard deprecate` and committed to
|
|
72
|
+
* `.cliguard/deprecations.json`. Unlike `AcceptedBreak` (which forgives a
|
|
73
|
+
* break that already happened), this is recorded *before* the removal:
|
|
74
|
+
* `check`/`diff` reclassify a matching BREAKING removal as PATCH instead
|
|
75
|
+
* of failing the build, but only because the deprecation was announced in
|
|
76
|
+
* advance - removing something with no prior `deprecate` still fails.
|
|
77
|
+
*/
|
|
78
|
+
export interface Deprecation {
|
|
79
|
+
/** Must equal the DiffResult.path of the eventual removal, e.g. "root -> build -> option[--target]". */
|
|
80
|
+
readonly path: string;
|
|
81
|
+
/** When this is expected to actually go away - a version ("2.0.0") or a date. Informational, never enforced by cliguard itself. */
|
|
82
|
+
readonly removeBy: string;
|
|
83
|
+
/** Why this is being deprecated - optional, shown alongside the change once it's removed. */
|
|
84
|
+
readonly reason?: string;
|
|
85
|
+
/** ISO-8601 timestamp of when `cliguard deprecate` recorded this. */
|
|
86
|
+
readonly deprecatedAt: string;
|
|
87
|
+
}
|
|
69
88
|
/** Severity of a single detected difference between two contracts. */
|
|
70
89
|
export declare enum ChangeType {
|
|
71
90
|
/** Removes or narrows something a caller may already depend on. */
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type CompareOptions, type DiffResult } from "./core/diff.engine";
|
|
2
|
+
import type { Contract } from "./core/types";
|
|
3
|
+
export type { CliAdapter } from "./adapters/adapter.interface";
|
|
4
|
+
export { CacAdapter } from "./adapters/cac.adapter";
|
|
5
|
+
export { CommanderAdapter } from "./adapters/commander.adapter";
|
|
6
|
+
export { YargsAdapter } from "./adapters/yargs.adapter";
|
|
7
|
+
export { adapters, resolveAdapter } from "./adapters/registry";
|
|
8
|
+
export { applyConfig, configExists, loadConfig } from "./core/config";
|
|
9
|
+
export type { CliguardConfig, SeverityOverride } from "./core/config";
|
|
10
|
+
export { DiffEngine } from "./core/diff.engine";
|
|
11
|
+
export type { CompareOptions, DiffResult } from "./core/diff.engine";
|
|
12
|
+
export { toGitLabCodeQuality, toJUnitXml, toRdjsonl } from "./core/report-formats";
|
|
13
|
+
export type { ReportChange } from "./core/report-formats";
|
|
14
|
+
export { ChangeType, type AcceptedBreak, type ArgumentContract, type CommandContract, type Contract, type Deprecation, type OptionContract, type OptionValueType, } from "./core/types";
|
|
15
|
+
/**
|
|
16
|
+
* Loads `entryPath` and extracts its full command surface as a Contract -
|
|
17
|
+
* the same extraction `cliguard init`/`check`/`update` run, without going
|
|
18
|
+
* through a subprocess. `adapterName` defaults to "commander", matching
|
|
19
|
+
* the CLI's own default.
|
|
20
|
+
*/
|
|
21
|
+
export declare function extractContract(entryPath: string, adapterName?: string): Promise<Contract>;
|
|
22
|
+
/**
|
|
23
|
+
* Compares two Contracts and returns every difference, classified
|
|
24
|
+
* BREAKING/ADDITIVE/PATCH - the exact same comparison `cliguard check`/`diff`
|
|
25
|
+
* run. Framework-agnostic: neither Contract needs to have come from
|
|
26
|
+
* `extractContract`, so this also works against contracts read from disk
|
|
27
|
+
* or a git ref by the caller's own code.
|
|
28
|
+
*/
|
|
29
|
+
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"]. */
|
|
31
|
+
export declare function listAdapters(): string[];
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
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;
|
|
4
|
+
exports.extractContract = extractContract;
|
|
5
|
+
exports.compareContracts = compareContracts;
|
|
6
|
+
exports.listAdapters = listAdapters;
|
|
7
|
+
/**
|
|
8
|
+
* Programmatic entry point - `import { extractContract, compareContracts } from "cliguard"`.
|
|
9
|
+
* Everything here is the same code `bin.ts` itself calls; this file only
|
|
10
|
+
* adds the two convenience wrappers (`extractContract`/`compareContracts`)
|
|
11
|
+
* and re-exports the pieces a caller embedding cliguard in their own
|
|
12
|
+
* build script, monorepo tooling, or bot would need - never a second
|
|
13
|
+
* implementation of anything.
|
|
14
|
+
*/
|
|
15
|
+
const registry_1 = require("./adapters/registry");
|
|
16
|
+
const diff_engine_1 = require("./core/diff.engine");
|
|
17
|
+
var cac_adapter_1 = require("./adapters/cac.adapter");
|
|
18
|
+
Object.defineProperty(exports, "CacAdapter", { enumerable: true, get: function () { return cac_adapter_1.CacAdapter; } });
|
|
19
|
+
var commander_adapter_1 = require("./adapters/commander.adapter");
|
|
20
|
+
Object.defineProperty(exports, "CommanderAdapter", { enumerable: true, get: function () { return commander_adapter_1.CommanderAdapter; } });
|
|
21
|
+
var yargs_adapter_1 = require("./adapters/yargs.adapter");
|
|
22
|
+
Object.defineProperty(exports, "YargsAdapter", { enumerable: true, get: function () { return yargs_adapter_1.YargsAdapter; } });
|
|
23
|
+
var registry_2 = require("./adapters/registry");
|
|
24
|
+
Object.defineProperty(exports, "adapters", { enumerable: true, get: function () { return registry_2.adapters; } });
|
|
25
|
+
Object.defineProperty(exports, "resolveAdapter", { enumerable: true, get: function () { return registry_2.resolveAdapter; } });
|
|
26
|
+
var config_1 = require("./core/config");
|
|
27
|
+
Object.defineProperty(exports, "applyConfig", { enumerable: true, get: function () { return config_1.applyConfig; } });
|
|
28
|
+
Object.defineProperty(exports, "configExists", { enumerable: true, get: function () { return config_1.configExists; } });
|
|
29
|
+
Object.defineProperty(exports, "loadConfig", { enumerable: true, get: function () { return config_1.loadConfig; } });
|
|
30
|
+
var diff_engine_2 = require("./core/diff.engine");
|
|
31
|
+
Object.defineProperty(exports, "DiffEngine", { enumerable: true, get: function () { return diff_engine_2.DiffEngine; } });
|
|
32
|
+
var report_formats_1 = require("./core/report-formats");
|
|
33
|
+
Object.defineProperty(exports, "toGitLabCodeQuality", { enumerable: true, get: function () { return report_formats_1.toGitLabCodeQuality; } });
|
|
34
|
+
Object.defineProperty(exports, "toJUnitXml", { enumerable: true, get: function () { return report_formats_1.toJUnitXml; } });
|
|
35
|
+
Object.defineProperty(exports, "toRdjsonl", { enumerable: true, get: function () { return report_formats_1.toRdjsonl; } });
|
|
36
|
+
var types_1 = require("./core/types");
|
|
37
|
+
Object.defineProperty(exports, "ChangeType", { enumerable: true, get: function () { return types_1.ChangeType; } });
|
|
38
|
+
const diffEngine = new diff_engine_1.DiffEngine();
|
|
39
|
+
/**
|
|
40
|
+
* Loads `entryPath` and extracts its full command surface as a Contract -
|
|
41
|
+
* the same extraction `cliguard init`/`check`/`update` run, without going
|
|
42
|
+
* through a subprocess. `adapterName` defaults to "commander", matching
|
|
43
|
+
* the CLI's own default.
|
|
44
|
+
*/
|
|
45
|
+
async function extractContract(entryPath, adapterName = "commander") {
|
|
46
|
+
// Deliberately `async` rather than returning resolveAdapter(...).extract(...)
|
|
47
|
+
// directly - resolveAdapter throws synchronously on an unknown name, and
|
|
48
|
+
// without `async` that throw would escape as a synchronous exception
|
|
49
|
+
// instead of a rejected Promise, breaking the "this always returns a
|
|
50
|
+
// Promise" contract the function's own return type promises.
|
|
51
|
+
return (0, registry_1.resolveAdapter)(adapterName).extract(entryPath);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Compares two Contracts and returns every difference, classified
|
|
55
|
+
* BREAKING/ADDITIVE/PATCH - the exact same comparison `cliguard check`/`diff`
|
|
56
|
+
* run. Framework-agnostic: neither Contract needs to have come from
|
|
57
|
+
* `extractContract`, so this also works against contracts read from disk
|
|
58
|
+
* or a git ref by the caller's own code.
|
|
59
|
+
*/
|
|
60
|
+
function compareContracts(oldContract, newContract, options) {
|
|
61
|
+
return diffEngine.compare(oldContract, newContract, options);
|
|
62
|
+
}
|
|
63
|
+
/** Every adapter name `extractContract`/the CLI's `--adapter` flag will accept, e.g. ["commander", "cac", "yargs"]. */
|
|
64
|
+
function listAdapters() {
|
|
65
|
+
return Object.keys(registry_1.adapters);
|
|
66
|
+
}
|