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.
@@ -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
- compareCommands(oldCmd, newCmd, path) {
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, "&amp;")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/"/g, "&quot;")
14
+ .replace(/'/g, "&apos;");
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
+ }
@@ -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;
@@ -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
+ }
@@ -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. */
@@ -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
+ }