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.
@@ -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;
@@ -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,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;
@@ -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
+ }