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
|
@@ -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;
|
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,6 +1,44 @@
|
|
|
1
|
-
import type { 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
|
+
/** Accepted-breaks path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
5
|
+
export declare function getAcceptedBreaksDisplayPath(): string;
|
|
4
6
|
export declare function contractExists(): boolean;
|
|
5
7
|
export declare function readContract(): Contract;
|
|
6
8
|
export declare function writeContract(contract: Contract): void;
|
|
9
|
+
/**
|
|
10
|
+
* Reads a Contract from an arbitrary path, not the committed
|
|
11
|
+
* `.cliguard/contract.json` - for `cliguard diff <a> <b>`, comparing two
|
|
12
|
+
* contract files directly (e.g. two tags' committed contracts pulled via
|
|
13
|
+
* `git show`) without running any real CLI. `displayPath` is what error
|
|
14
|
+
* messages name; defaults to `path` itself since a caller-supplied path is
|
|
15
|
+
* already about as displayable as it gets.
|
|
16
|
+
*/
|
|
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;
|
|
27
|
+
/** Unlike readContract, a missing file is normal (most projects never accept a break) - returns [] rather than throwing. */
|
|
28
|
+
export declare function readAcceptedBreaks(): AcceptedBreak[];
|
|
29
|
+
export declare function writeAcceptedBreaks(breaks: readonly AcceptedBreak[]): void;
|
|
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
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getContractDisplayPath = getContractDisplayPath;
|
|
4
|
+
exports.getAcceptedBreaksDisplayPath = getAcceptedBreaksDisplayPath;
|
|
4
5
|
exports.contractExists = contractExists;
|
|
5
6
|
exports.readContract = readContract;
|
|
6
7
|
exports.writeContract = writeContract;
|
|
8
|
+
exports.readContractFile = readContractFile;
|
|
9
|
+
exports.readContractAtRef = readContractAtRef;
|
|
10
|
+
exports.readAcceptedBreaks = readAcceptedBreaks;
|
|
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");
|
|
7
22
|
const fs_1 = require("fs");
|
|
8
23
|
const path_1 = require("path");
|
|
9
24
|
const CONTRACT_PATH = (0, path_1.join)(process.cwd(), ".cliguard", "contract.json");
|
|
25
|
+
const ACCEPTED_BREAKS_PATH = (0, path_1.join)(process.cwd(), ".cliguard", "accepted-breaks.json");
|
|
26
|
+
const DEPRECATIONS_PATH = (0, path_1.join)(process.cwd(), ".cliguard", "deprecations.json");
|
|
27
|
+
const CI_WORKFLOW_PATH = (0, path_1.join)(process.cwd(), ".github", "workflows", "cliguard.yml");
|
|
10
28
|
/** Contract path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
11
29
|
function getContractDisplayPath() {
|
|
12
30
|
return (0, path_1.relative)(process.cwd(), CONTRACT_PATH).split("\\").join("/");
|
|
13
31
|
}
|
|
32
|
+
/** Accepted-breaks path relative to cwd, normalized to forward slashes - display only, never used for I/O. */
|
|
33
|
+
function getAcceptedBreaksDisplayPath() {
|
|
34
|
+
return (0, path_1.relative)(process.cwd(), ACCEPTED_BREAKS_PATH).split("\\").join("/");
|
|
35
|
+
}
|
|
14
36
|
function contractExists() {
|
|
15
37
|
return (0, fs_1.existsSync)(CONTRACT_PATH);
|
|
16
38
|
}
|
|
@@ -39,3 +61,153 @@ function writeContract(contract) {
|
|
|
39
61
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(CONTRACT_PATH), { recursive: true });
|
|
40
62
|
(0, fs_1.writeFileSync)(CONTRACT_PATH, JSON.stringify(contract, null, 2) + "\n", "utf-8");
|
|
41
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Reads a Contract from an arbitrary path, not the committed
|
|
66
|
+
* `.cliguard/contract.json` - for `cliguard diff <a> <b>`, comparing two
|
|
67
|
+
* contract files directly (e.g. two tags' committed contracts pulled via
|
|
68
|
+
* `git show`) without running any real CLI. `displayPath` is what error
|
|
69
|
+
* messages name; defaults to `path` itself since a caller-supplied path is
|
|
70
|
+
* already about as displayable as it gets.
|
|
71
|
+
*/
|
|
72
|
+
function readContractFile(path, displayPath = path) {
|
|
73
|
+
if (!(0, fs_1.existsSync)(path)) {
|
|
74
|
+
throw new Error(`cliguard: no such file: "${displayPath}".`);
|
|
75
|
+
}
|
|
76
|
+
const raw = (0, fs_1.readFileSync)(path, "utf-8");
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
82
|
+
throw new Error(`cliguard: "${displayPath}" is not valid JSON (${reason}).`);
|
|
83
|
+
}
|
|
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
|
+
}
|
|
119
|
+
/** Unlike readContract, a missing file is normal (most projects never accept a break) - returns [] rather than throwing. */
|
|
120
|
+
function readAcceptedBreaks() {
|
|
121
|
+
if (!(0, fs_1.existsSync)(ACCEPTED_BREAKS_PATH))
|
|
122
|
+
return [];
|
|
123
|
+
const raw = (0, fs_1.readFileSync)(ACCEPTED_BREAKS_PATH, "utf-8");
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(raw);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
// See readContract's identical-purpose catch for why naming the file
|
|
129
|
+
// and the fix matters here too.
|
|
130
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
131
|
+
throw new Error(`cliguard: "${getAcceptedBreaksDisplayPath()}" is not valid JSON (${reason}). ` +
|
|
132
|
+
"If this file was hand-edited or came out of a bad merge, fix it or delete it " +
|
|
133
|
+
"and re-run `cliguard accept` for whatever was in it.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function writeAcceptedBreaks(breaks) {
|
|
137
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(ACCEPTED_BREAKS_PATH), { recursive: true });
|
|
138
|
+
(0, fs_1.writeFileSync)(ACCEPTED_BREAKS_PATH, JSON.stringify(breaks, null, 2) + "\n", "utf-8");
|
|
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
|
+
}
|