canary-test-cli 5.15.0 → 6.0.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.
Files changed (79) hide show
  1. package/agent/frameworks/registry.json +655 -0
  2. package/bin/canary.js +20 -15
  3. package/dist/doctor-manifest.d.ts +94 -0
  4. package/dist/doctor.d.ts +67 -0
  5. package/dist/engine/analysis/cli.js +270 -0
  6. package/dist/engine/analysis/engine.js +146 -0
  7. package/dist/engine/analysis/reports.js +0 -0
  8. package/dist/engine/analysis/rows.js +9 -0
  9. package/dist/engine/cli-commands.js +618 -0
  10. package/dist/engine/cli-common.js +60 -0
  11. package/dist/engine/cli.core.js +208 -0
  12. package/dist/engine/cli.js +31 -0
  13. package/dist/engine/company-knowledge-cli.js +201 -0
  14. package/dist/engine/core/ci-env.js +33 -0
  15. package/dist/engine/core/classifier.js +192 -0
  16. package/dist/engine/core/company-knowledge.js +765 -0
  17. package/dist/engine/core/config-validation.js +74 -0
  18. package/dist/engine/core/detection.js +48 -0
  19. package/dist/engine/core/domain-scanner.js +212 -0
  20. package/dist/engine/core/environment-detect.js +410 -0
  21. package/dist/engine/core/executor.js +181 -0
  22. package/dist/engine/core/feedback.js +93 -0
  23. package/dist/engine/core/fixture-scanner.js +173 -0
  24. package/dist/engine/core/framework-registry.js +123 -0
  25. package/dist/engine/core/mcp-validator.js +218 -0
  26. package/dist/engine/core/metadata-scanner.js +147 -0
  27. package/dist/engine/core/migrator.js +1112 -0
  28. package/dist/engine/core/overlays.js +176 -0
  29. package/dist/engine/core/pattern-healer.js +147 -0
  30. package/dist/engine/core/pattern-matcher.js +255 -0
  31. package/dist/engine/core/quality-scorer.js +213 -0
  32. package/dist/engine/core/recommender.js +152 -0
  33. package/dist/engine/core/reporter.js +211 -0
  34. package/dist/engine/core/scaffolder.js +236 -0
  35. package/dist/engine/core/skill-registry.js +522 -0
  36. package/dist/engine/core/static-linter.js +237 -0
  37. package/dist/engine/core/ticket-updater.js +639 -0
  38. package/dist/engine/core/workflow-discovery.js +693 -0
  39. package/dist/engine/guardian/agent-tier.js +338 -0
  40. package/dist/engine/guardian/analysis-emit.js +201 -0
  41. package/dist/engine/guardian/cli.js +787 -0
  42. package/dist/engine/guardian/coverage.js +1055 -0
  43. package/dist/engine/guardian/delta-emitter.js +46 -0
  44. package/dist/engine/guardian/diff-extractor.js +257 -0
  45. package/dist/engine/guardian/hard-gate.js +373 -0
  46. package/dist/engine/guardian/impact-mapper.js +121 -0
  47. package/dist/engine/guardian/pr-check.js +975 -0
  48. package/dist/engine/guardian/pr-comment.js +200 -0
  49. package/dist/engine/guardian/summary-emitter.js +94 -0
  50. package/dist/engine/guardian/tier.js +58 -0
  51. package/dist/engine/history/cli.js +303 -0
  52. package/dist/engine/history/detector.js +68 -0
  53. package/dist/engine/history/ndjson-store.js +177 -0
  54. package/dist/engine/history/record.js +14 -0
  55. package/dist/engine/history/schema.js +59 -0
  56. package/dist/engine/history/store.js +47 -0
  57. package/dist/engine/history/supabase-store.js +113 -0
  58. package/dist/engine/main-deps.js +105 -0
  59. package/dist/engine/mcp-server.js +647 -0
  60. package/dist/engine/package.json +4 -0
  61. package/dist/engine/skills-cli.js +181 -0
  62. package/dist/engine/ui/banner.js +50 -0
  63. package/dist/engine/util/coalesce.js +12 -0
  64. package/dist/engine/util/round.js +43 -0
  65. package/dist/engine/workflow-cli.js +242 -0
  66. package/dist/engine-checks.d.ts +49 -0
  67. package/dist/overlay-commands.d.ts +81 -0
  68. package/dist/overlay-conflicts.d.ts +33 -0
  69. package/dist/overlay-lint.d.ts +19 -0
  70. package/dist/overlays-registry.d.ts +74 -0
  71. package/dist/reporters/testtracker.d.ts +89 -0
  72. package/dist/reporters/testtracker.js +195 -0
  73. package/dist/router.d.ts +12 -0
  74. package/dist/router.js +4 -4
  75. package/dist/skill-requirements.d.ts +57 -0
  76. package/dist/source-spec.d.ts +20 -0
  77. package/package.json +30 -6
  78. package/bin/canary +0 -0
  79. package/scripts/install.js +0 -104
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Emit the machine-readable api-delta.json v1 artifact.
3
+ *
4
+ * Faithful TypeScript port of `agent/guardian/delta_emitter.py`. Serializes an
5
+ * `ApiDiff` into the frozen contract (docs/specs/api-delta-contract.md) that
6
+ * downstream tooling consumes to trigger library-stub regeneration. Generic and
7
+ * company-neutral — the shape carries only HTTP method/path/change categories.
8
+ *
9
+ * The builder is pure (the caller supplies `generated`) so it stays testable.
10
+ */
11
+ import { writeFileSync } from 'node:fs';
12
+ import { classifyChanges } from './diff-extractor.js';
13
+ /** Python: `build_api_delta`. Build the api-delta.json v1 object from a diff. */
14
+ export function buildApiDelta(diff, sha, suite, generated) {
15
+ return {
16
+ schema_version: 1,
17
+ sut: { sha, suite },
18
+ generated,
19
+ summary: {
20
+ added: diff.added.length,
21
+ removed: diff.removed.length,
22
+ changed: diff.changed.length,
23
+ total: diff.added.length + diff.removed.length + diff.changed.length,
24
+ },
25
+ endpoints: {
26
+ added: diff.added.map((ec) => ({
27
+ method: ec.method.toUpperCase(),
28
+ path: ec.path,
29
+ })),
30
+ removed: diff.removed.map((ec) => ({
31
+ method: ec.method.toUpperCase(),
32
+ path: ec.path,
33
+ })),
34
+ changed: diff.changed.map((ec) => ({
35
+ method: ec.method.toUpperCase(),
36
+ path: ec.path,
37
+ changes: classifyChanges(ec.before, ec.after),
38
+ })),
39
+ },
40
+ };
41
+ }
42
+ /** Python: `write_api_delta`. Write the delta to `path` as indented JSON. */
43
+ export function writeApiDelta(delta, path) {
44
+ writeFileSync(path, `${JSON.stringify(delta, null, 2)}\n`, 'utf-8');
45
+ }
46
+ //# sourceMappingURL=delta-emitter.js.map
@@ -0,0 +1,257 @@
1
+ /**
2
+ * OpenAPI spec diff extractor.
3
+ *
4
+ * Faithful TypeScript port of `agent/guardian/diff_extractor.py`. Compares two
5
+ * OpenAPI specs (before/after a commit) and produces a structured list of
6
+ * added, removed, and changed endpoints.
7
+ *
8
+ * Input: parsed objects (from JSON.parse or a YAML loader).
9
+ * Output: an `ApiDiff` with three lists of `EndpointChange`.
10
+ */
11
+ /** Python: `ChangeType(str, Enum)`. */
12
+ export var ChangeType;
13
+ (function (ChangeType) {
14
+ ChangeType["ADDED"] = "added";
15
+ ChangeType["REMOVED"] = "removed";
16
+ ChangeType["CHANGED"] = "changed";
17
+ })(ChangeType || (ChangeType = {}));
18
+ /**
19
+ * Python: `EndpointChange` dataclass. A class (rather than a bare interface) so
20
+ * the constructor supplies the same field defaults the dataclass does
21
+ * (`operation_id`/`summary` -> "", `before`/`after` -> {}).
22
+ */
23
+ export class EndpointChange {
24
+ path;
25
+ method;
26
+ change_type;
27
+ // `string | null`: mirrors Python `dict.get("operationId", "")` — a MISSING
28
+ // key defaults to "", but a present-null value passes through as `null`.
29
+ operation_id;
30
+ summary;
31
+ before;
32
+ after;
33
+ constructor(init) {
34
+ this.path = init.path;
35
+ this.method = init.method;
36
+ this.change_type = init.change_type;
37
+ // Default only on undefined (missing key), never on a present null.
38
+ this.operation_id =
39
+ init.operation_id === undefined ? '' : init.operation_id;
40
+ this.summary = init.summary === undefined ? '' : init.summary;
41
+ this.before = init.before === undefined ? {} : init.before;
42
+ this.after = init.after === undefined ? {} : init.after;
43
+ }
44
+ }
45
+ /** Python: `ApiDiff` dataclass. */
46
+ export class ApiDiff {
47
+ added;
48
+ removed;
49
+ changed;
50
+ constructor(added, removed, changed) {
51
+ this.added = added;
52
+ this.removed = removed;
53
+ this.changed = changed;
54
+ }
55
+ /** Python: `is_empty` property. */
56
+ get isEmpty() {
57
+ return !(this.added.length || this.removed.length || this.changed.length);
58
+ }
59
+ }
60
+ const HTTP_METHODS = [
61
+ 'get',
62
+ 'post',
63
+ 'put',
64
+ 'patch',
65
+ 'delete',
66
+ 'head',
67
+ 'options',
68
+ ];
69
+ // Frozen vocabulary for a changed endpoint (see
70
+ // docs/specs/api-delta-contract.md).
71
+ export const VALID_CHANGES = [
72
+ 'params',
73
+ 'request-body',
74
+ 'response',
75
+ 'auth',
76
+ 'status-codes',
77
+ ];
78
+ /** Collision-free composite key for a (path, method) pair (Python tuple key). */
79
+ function opKey(path, method) {
80
+ return JSON.stringify([path, method]);
81
+ }
82
+ /**
83
+ * Python-truthiness for JSON-shaped values: `None`/`undefined`, `false`, `0`,
84
+ * `""`, empty array, and empty object are all falsy (mirrors `if op:`).
85
+ */
86
+ function pyTruthy(value) {
87
+ if (value === null || value === undefined || value === false)
88
+ return false;
89
+ if (value === 0 || value === '')
90
+ return false;
91
+ if (Array.isArray(value))
92
+ return value.length > 0;
93
+ if (typeof value === 'object')
94
+ return Object.keys(value).length > 0;
95
+ return Boolean(value);
96
+ }
97
+ /** Python `a or b`: the fallback wins only when `a` is falsy. */
98
+ function pyOr(value, fallback) {
99
+ return pyTruthy(value) ? value : fallback;
100
+ }
101
+ /** Python `dict.get(key, default)`: default only on a missing key. */
102
+ function pyGet(obj, key, fallback) {
103
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
104
+ }
105
+ /**
106
+ * Deep equality mirroring Python `==` on JSON-shaped data: arrays compare
107
+ * order-sensitively, objects compare by key set (order-insensitive), and
108
+ * `None`/`undefined` are interchangeable.
109
+ */
110
+ function pyEqual(a, b) {
111
+ if (a === b)
112
+ return true;
113
+ if (a === null || a === undefined)
114
+ return b === null || b === undefined;
115
+ if (b === null || b === undefined)
116
+ return false;
117
+ const aArr = Array.isArray(a);
118
+ const bArr = Array.isArray(b);
119
+ if (aArr || bArr) {
120
+ if (!aArr || !bArr || a.length !== b.length)
121
+ return false;
122
+ for (let i = 0; i < a.length; i++) {
123
+ if (!pyEqual(a[i], b[i]))
124
+ return false;
125
+ }
126
+ return true;
127
+ }
128
+ if (typeof a === 'object' && typeof b === 'object') {
129
+ const ao = a;
130
+ const bo = b;
131
+ const aKeys = Object.keys(ao);
132
+ const bKeys = Object.keys(bo);
133
+ if (aKeys.length !== bKeys.length)
134
+ return false;
135
+ for (const key of aKeys) {
136
+ if (!Object.prototype.hasOwnProperty.call(bo, key))
137
+ return false;
138
+ if (!pyEqual(ao[key], bo[key]))
139
+ return false;
140
+ }
141
+ return true;
142
+ }
143
+ return false;
144
+ }
145
+ function setEqual(a, b) {
146
+ if (a.size !== b.size)
147
+ return false;
148
+ for (const x of a) {
149
+ if (!b.has(x))
150
+ return false;
151
+ }
152
+ return true;
153
+ }
154
+ /**
155
+ * Python: `classify_changes`. Classify a changed OpenAPI operation into the
156
+ * frozen change vocabulary. Returns every applicable category ordered by
157
+ * `VALID_CHANGES`; a change confined to non-contract fields returns [].
158
+ */
159
+ export function classifyChanges(before, after) {
160
+ const found = new Set();
161
+ if (!pyEqual(pyOr(before['parameters'], []), pyOr(after['parameters'], []))) {
162
+ found.add('params');
163
+ }
164
+ if (!pyEqual(before['requestBody'], after['requestBody'])) {
165
+ found.add('request-body');
166
+ }
167
+ if (!pyEqual(before['security'], after['security'])) {
168
+ found.add('auth');
169
+ }
170
+ const beforeResp = pyOr(before['responses'], {});
171
+ const afterResp = pyOr(after['responses'], {});
172
+ const beforeKeys = new Set(Object.keys(beforeResp));
173
+ const afterKeys = new Set(Object.keys(afterResp));
174
+ if (!setEqual(beforeKeys, afterKeys)) {
175
+ found.add('status-codes');
176
+ }
177
+ for (const code of beforeKeys) {
178
+ if (afterKeys.has(code) && !pyEqual(beforeResp[code], afterResp[code])) {
179
+ found.add('response');
180
+ break;
181
+ }
182
+ }
183
+ return VALID_CHANGES.filter((c) => found.has(c));
184
+ }
185
+ /**
186
+ * Python: `_iter_operations`. Insertion-ordered to match Python's dict
187
+ * iteration order.
188
+ */
189
+ function iterOperations(spec) {
190
+ const result = new Map();
191
+ const paths = pyOr(spec['paths'], {});
192
+ for (const [path, pathItemRaw] of Object.entries(paths)) {
193
+ const pathItem = pathItemRaw;
194
+ for (const method of HTTP_METHODS) {
195
+ const op = pathItem[method];
196
+ if (pyTruthy(op)) {
197
+ result.set(opKey(path, method), {
198
+ path,
199
+ method,
200
+ op: op,
201
+ });
202
+ }
203
+ }
204
+ }
205
+ return result;
206
+ }
207
+ /**
208
+ * Python: `extract_api_diff`. Compare two OpenAPI spec objects and return the
209
+ * diff.
210
+ */
211
+ export function extractApiDiff(before, after) {
212
+ const beforeOps = iterOperations(before);
213
+ const afterOps = iterOperations(after);
214
+ const added = [];
215
+ const removed = [];
216
+ const changed = [];
217
+ for (const [key, { path, method, op }] of afterOps) {
218
+ if (!beforeOps.has(key)) {
219
+ added.push(new EndpointChange({
220
+ path,
221
+ method,
222
+ change_type: ChangeType.ADDED,
223
+ operation_id: pyGet(op, 'operationId', ''),
224
+ summary: pyGet(op, 'summary', ''),
225
+ after: op,
226
+ }));
227
+ }
228
+ else {
229
+ const beforeOp = beforeOps.get(key).op;
230
+ if (!pyEqual(op, beforeOp)) {
231
+ changed.push(new EndpointChange({
232
+ path,
233
+ method,
234
+ change_type: ChangeType.CHANGED,
235
+ operation_id: pyGet(op, 'operationId', pyGet(beforeOp, 'operationId', '')),
236
+ summary: pyGet(op, 'summary', ''),
237
+ before: beforeOp,
238
+ after: op,
239
+ }));
240
+ }
241
+ }
242
+ }
243
+ for (const [key, { path, method, op }] of beforeOps) {
244
+ if (!afterOps.has(key)) {
245
+ removed.push(new EndpointChange({
246
+ path,
247
+ method,
248
+ change_type: ChangeType.REMOVED,
249
+ operation_id: pyGet(op, 'operationId', ''),
250
+ summary: pyGet(op, 'summary', ''),
251
+ before: op,
252
+ }));
253
+ }
254
+ }
255
+ return new ApiDiff(added, removed, changed);
256
+ }
257
+ //# sourceMappingURL=diff-extractor.js.map
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Promote the guardian gate from soft to hard by registering its status check
3
+ * as a **required** check in branch protection — the one admin step the
4
+ * operator guide says the guardian can't do for you.
5
+ *
6
+ * Faithful TypeScript port of `agent/guardian/hard_gate.py`.
7
+ *
8
+ * Structure mirrors {@link ./pr-comment}: a {@link BranchProtection} interface,
9
+ * an in-memory fake for network-free tests, and a thin `fetch` REST client. The
10
+ * decision logic ({@link planHardGate}) is pure. Any barrier — no admin scope, a
11
+ * plan without branch protection, a bad token, a network error — surfaces as
12
+ * {@link HardGateBlocked} carrying a manual playbook, never a silent no-op and
13
+ * never a crash (the #294/#295 fail-loud contract).
14
+ *
15
+ * Two hazards this module is built to avoid:
16
+ *
17
+ * - **Clobbering existing protection.** `GET .../required_status_checks` returns
18
+ * 404 both for an unprotected branch AND a protected branch that simply has no
19
+ * required-checks section. Treating the latter as "unprotected" and PUT-ing a
20
+ * fresh ruleset would wipe review/admin/restriction protections. We
21
+ * disambiguate via the parent `.../protection` endpoint and only ever
22
+ * PUT-create when the branch is *genuinely* unprotected; otherwise we PATCH
23
+ * the checks sub-resource, which leaves every other protection untouched.
24
+ * - **Registering a phantom check.** A required context that no workflow reports
25
+ * stays "Expected" forever and blocks *every* merge — worse than not
26
+ * promoting. So before registering we verify the context against the checks a
27
+ * recent commit actually reported, and refuse (listing the real ones) unless
28
+ * `force`.
29
+ *
30
+ * Python→TS nuances:
31
+ * - **async**: the seam is `Promise`-returning (Node `fetch` is async), so
32
+ * {@link applyHardGate} is async and the fakes are `async`. The pure
33
+ * {@link planHardGate} and {@link renderPlaybook} stay synchronous.
34
+ * - **error mapping**: `fetch` resolves on a non-2xx status. The real client
35
+ * maps 401/403→{@link GitHubPermissionError} off `resp.status`, and throws a
36
+ * status-bearing {@link HttpError} for other codes so the 404 disambiguation
37
+ * can inspect the status (the analog of urllib's `HTTPError.code`). Python's
38
+ * `OSError` (URLError / timeout / non-403 HTTPError) maps to any non-permission
39
+ * error caught in {@link applyHardGate}.
40
+ */
41
+ import { GitHubPermissionError } from './pr-comment.js';
42
+ /**
43
+ * A barrier stopped the required-check registration.
44
+ *
45
+ * Carries a human-readable `reason` and a `playbook` of manual steps, so a
46
+ * permission/plan/network barrier degrades to actionable guidance rather than a
47
+ * crash or a silent skip.
48
+ */
49
+ export class HardGateBlocked extends Error {
50
+ reason;
51
+ playbook;
52
+ constructor(reason, playbook) {
53
+ super(reason);
54
+ this.name = 'HardGateBlocked';
55
+ this.reason = reason;
56
+ this.playbook = playbook;
57
+ }
58
+ }
59
+ /**
60
+ * Decide the change without touching the network (PURE).
61
+ *
62
+ * `existingContexts` is the branch's current required-check contexts, an empty
63
+ * list if the branch is protected but requires no checks yet, or `null`
64
+ * **only** if the branch has no protection at all. That three-way distinction
65
+ * is load-bearing: `null` is the only value that permits the
66
+ * protection-creating path.
67
+ */
68
+ export function planHardGate(existingContexts, checkContext, branch) {
69
+ const creates = existingContexts === null;
70
+ const current = [...(existingContexts ?? [])];
71
+ const already = current.includes(checkContext);
72
+ const resulting = already ? current : [...current, checkContext];
73
+ return {
74
+ check_context: checkContext,
75
+ branch,
76
+ already_required: already,
77
+ creates_protection: creates,
78
+ resulting_contexts: [...new Set(resulting)].sort(),
79
+ };
80
+ }
81
+ /**
82
+ * In-memory {@link BranchProtection} for tests — no network.
83
+ *
84
+ * State model: `contexts` is the required-check list; `null` means no checks
85
+ * section. `protected` says whether the branch has protection at all (implied
86
+ * true when `contexts` is a list). So `contexts=null, protected=true` models the
87
+ * dangerous protected-without-checks state, and `contexts=null,
88
+ * protected=false` a genuinely unprotected branch. `admin=false` →
89
+ * reads/writes reject (no scope); `plan_supported=false` → writes reject.
90
+ * `read_error` injects an arbitrary read failure.
91
+ */
92
+ export class FakeBranchProtectionClient {
93
+ contexts;
94
+ protected;
95
+ admin;
96
+ plan_supported;
97
+ observed;
98
+ read_error;
99
+ write_count;
100
+ last_create;
101
+ store;
102
+ constructor(init = {}) {
103
+ this.contexts = init.contexts ?? null;
104
+ this.protected = init.protected ?? false;
105
+ this.admin = init.admin ?? true;
106
+ this.plan_supported = init.plan_supported ?? true;
107
+ this.observed = init.observed ?? [];
108
+ this.read_error = init.read_error ?? null;
109
+ this.write_count = 0;
110
+ this.last_create = null;
111
+ this.store = new Map();
112
+ // Mirrors the Python dataclass `__post_init__`: a seeded contexts list
113
+ // implies the branch is protected, and is always stored under "main".
114
+ if (this.contexts !== null) {
115
+ this.store.set('main', [...this.contexts]);
116
+ this.protected = true;
117
+ }
118
+ }
119
+ contextsFor(branch) {
120
+ return this.store.get(branch) ?? null;
121
+ }
122
+ async requiredCheckContexts(branch) {
123
+ if (this.read_error !== null) {
124
+ throw this.read_error;
125
+ }
126
+ if (!this.admin) {
127
+ throw new GitHubPermissionError('token lacks admin scope for branch protection');
128
+ }
129
+ const stored = this.store.get(branch);
130
+ if (stored !== undefined) {
131
+ return stored;
132
+ }
133
+ return this.protected ? [] : null;
134
+ }
135
+ async observedCheckContexts(branch) {
136
+ void branch;
137
+ return [...this.observed];
138
+ }
139
+ async setRequiredChecks(branch, contexts, create) {
140
+ if (!this.admin) {
141
+ throw new GitHubPermissionError('token lacks admin scope');
142
+ }
143
+ if (!this.plan_supported) {
144
+ throw new GitHubPermissionError('Upgrade your plan to use branch protection on this repository');
145
+ }
146
+ this.write_count += 1;
147
+ this.last_create = create;
148
+ this.store.set(branch, [...contexts]);
149
+ }
150
+ }
151
+ /**
152
+ * Manual steps to require the guardian check — the fallback when canary can't
153
+ * do it (no admin, unsupported plan, bad token, network error).
154
+ */
155
+ export function renderPlaybook(repo, branch, checkContext) {
156
+ return (`Manual steps to require the '${checkContext}' check on ` +
157
+ `${repo}@${branch}:\n` +
158
+ ` 1. Open https://github.com/${repo}/settings/branches\n` +
159
+ ` 2. Add or edit a branch protection rule for '${branch}'.\n` +
160
+ ` 3. Enable 'Require status checks to pass before merging' and add ` +
161
+ `'${checkContext}' to the required checks.\n` +
162
+ `Or, with an admin token, run:\n` +
163
+ ` gh api -X PATCH ` +
164
+ `repos/${repo}/branches/${branch}/protection/required_status_checks ` +
165
+ `-f 'checks[][context]=${checkContext}'\n` +
166
+ `Confirm '${checkContext}' is the exact context a recent PR reported ` +
167
+ `(a wrong name registers a gate that never fires — and can block every ` +
168
+ `merge).`);
169
+ }
170
+ /**
171
+ * Register `checkContext` as required on `branch`.
172
+ *
173
+ * Verifies the context is one a recent commit actually reported (unless
174
+ * `force`) so we never register a phantom check that would block every merge.
175
+ * Returns the plan on success (including the idempotent already-required
176
+ * no-op). Rejects with {@link HardGateBlocked} carrying a playbook on any
177
+ * barrier.
178
+ */
179
+ export async function applyHardGate(client, repo, branch, checkContext, force = false) {
180
+ const playbook = renderPlaybook(repo, branch, checkContext);
181
+ const blocked = (reason) => new HardGateBlocked(reason, playbook);
182
+ // --- verify the context is real, before we touch protection ---
183
+ if (!force) {
184
+ let observed;
185
+ try {
186
+ observed = await client.observedCheckContexts(branch);
187
+ }
188
+ catch {
189
+ // Python catches (GitHubPermissionError, OSError) → best-effort empty.
190
+ observed = [];
191
+ }
192
+ if (observed.length === 0) {
193
+ throw blocked(`could not confirm any check has reported on ${repo}@${branch}, ` +
194
+ `so cannot verify '${checkContext}' is a real context; ` +
195
+ 'requiring an unreported check would block every merge. ' +
196
+ 'Open a PR so the guardian check runs at least once, or pass ' +
197
+ '--force to register it anyway.');
198
+ }
199
+ if (!observed.includes(checkContext)) {
200
+ const sorted = [...observed].sort();
201
+ throw blocked(`'${checkContext}' is not among the checks recently reported on ` +
202
+ `${repo}@${branch}: ${reprList(sorted)}. Requiring it would block ` +
203
+ 'every merge. Re-run --check with one of those, or --force to ' +
204
+ 'override.');
205
+ }
206
+ }
207
+ // --- read state (disambiguating unprotected vs protected-without-checks) ---
208
+ let existing;
209
+ try {
210
+ existing = await client.requiredCheckContexts(branch);
211
+ }
212
+ catch (err) {
213
+ if (err instanceof GitHubPermissionError) {
214
+ throw blocked(`cannot read branch protection for ${repo}@${branch}: ${err.message}`);
215
+ }
216
+ // OSError analog: URLError / timeout / non-403 HTTPError.
217
+ throw blocked(`failed reading branch protection for ${repo}@${branch}: ${errText(err)}`);
218
+ }
219
+ const plan = planHardGate(existing, checkContext, branch);
220
+ if (plan.already_required) {
221
+ return plan;
222
+ }
223
+ try {
224
+ await client.setRequiredChecks(branch, plan.resulting_contexts, plan.creates_protection);
225
+ }
226
+ catch (err) {
227
+ if (err instanceof GitHubPermissionError) {
228
+ throw blocked(`cannot update branch protection for ${repo}@${branch}: ${err.message}`);
229
+ }
230
+ throw blocked(`failed updating branch protection for ${repo}@${branch}: ${errText(err)}`);
231
+ }
232
+ return plan;
233
+ }
234
+ /** Render a string list the way Python's `repr(list)` reads: `['a', 'b']`. */
235
+ function reprList(items) {
236
+ return `[${items.map((s) => `'${s}'`).join(', ')}]`;
237
+ }
238
+ function errText(err) {
239
+ return err instanceof Error ? err.message : String(err);
240
+ }
241
+ /**
242
+ * A GitHub REST HTTP error carrying its status, the analog of urllib's
243
+ * `HTTPError.code`. Thrown by {@link RestBranchProtectionClient} for non-2xx
244
+ * responses other than 401/403 so the 404 disambiguation can inspect the code.
245
+ */
246
+ export class HttpError extends Error {
247
+ status;
248
+ constructor(status, message) {
249
+ super(message);
250
+ this.name = 'HttpError';
251
+ this.status = status;
252
+ }
253
+ }
254
+ /** Thin real {@link BranchProtection} over the GitHub REST API (`fetch`). */
255
+ export class RestBranchProtectionClient {
256
+ repo;
257
+ token;
258
+ static API = 'https://api.github.com';
259
+ constructor(repo, token) {
260
+ this.repo = repo;
261
+ this.token = token;
262
+ }
263
+ async request(method, path, body) {
264
+ const url = `${RestBranchProtectionClient.API}/repos/${this.repo}/${path}`;
265
+ const headers = {
266
+ Authorization: `Bearer ${this.token}`,
267
+ Accept: 'application/vnd.github+json',
268
+ };
269
+ const init = { method, headers };
270
+ if (body !== undefined) {
271
+ headers['Content-Type'] = 'application/json';
272
+ init.body = JSON.stringify(body);
273
+ }
274
+ const resp = await fetch(url, init);
275
+ if (!resp.ok) {
276
+ // 401 (bad/expired token) and 403 (no admin scope / rate limit) both mean
277
+ // "we can't proceed here" → permission error → playbook.
278
+ if (resp.status === 401 || resp.status === 403) {
279
+ throw new GitHubPermissionError(`HTTP ${resp.status}: ${resp.statusText || 'forbidden'}`);
280
+ }
281
+ // Other codes propagate as a status-bearing error → fail-loud, and the
282
+ // 404 disambiguation reads `.status`.
283
+ throw new HttpError(resp.status, `HTTP ${resp.status}: ${resp.statusText || ''}`);
284
+ }
285
+ const raw = (await resp.text()) || '{}';
286
+ return JSON.parse(raw);
287
+ }
288
+ async branchIsUnprotected(branch) {
289
+ // True only if the branch has NO protection object at all (parent 404). A
290
+ // protected branch without a checks section returns 200 here.
291
+ try {
292
+ await this.request('GET', `branches/${branch}/protection`);
293
+ return false;
294
+ }
295
+ catch (err) {
296
+ if (err instanceof HttpError && err.status === 404) {
297
+ return true;
298
+ }
299
+ throw err;
300
+ }
301
+ }
302
+ async requiredCheckContexts(branch) {
303
+ let data;
304
+ try {
305
+ data = await this.request('GET', `branches/${branch}/protection/required_status_checks`);
306
+ }
307
+ catch (err) {
308
+ if (err instanceof HttpError && err.status === 404) {
309
+ // Ambiguous 404: genuinely unprotected, OR protected without a checks
310
+ // section. Only the former may take the create path.
311
+ return (await this.branchIsUnprotected(branch)) ? null : [];
312
+ }
313
+ throw err;
314
+ }
315
+ const checks = data['checks'];
316
+ if (Array.isArray(checks)) {
317
+ return checks
318
+ .map((c) => (isRecord(c) ? String(c['context'] ?? '') : ''))
319
+ .filter((context) => context !== '');
320
+ }
321
+ const contexts = data['contexts'];
322
+ return Array.isArray(contexts) ? contexts : []; // legacy shape
323
+ }
324
+ async observedCheckContexts(branch) {
325
+ let data;
326
+ try {
327
+ data = await this.request('GET', `commits/${branch}/check-runs`);
328
+ }
329
+ catch (err) {
330
+ // URLError / GitHubPermissionError → best-effort empty. (An HttpError for
331
+ // another status also degrades to empty here, matching the Python
332
+ // best-effort intent for observed checks.)
333
+ void err;
334
+ return [];
335
+ }
336
+ const runs = data['check_runs'];
337
+ if (!Array.isArray(runs)) {
338
+ return [];
339
+ }
340
+ const names = new Set();
341
+ for (const r of runs) {
342
+ if (isRecord(r)) {
343
+ const name = String(r['name'] ?? '');
344
+ if (name !== '')
345
+ names.add(name);
346
+ }
347
+ }
348
+ return [...names].sort();
349
+ }
350
+ async setRequiredChecks(branch, contexts, create) {
351
+ const checks = contexts.map((c) => ({ context: c }));
352
+ if (create) {
353
+ // Genuinely unprotected — PUT a minimal ruleset requiring only the checks;
354
+ // other protections stay unset.
355
+ await this.request('PUT', `branches/${branch}/protection`, {
356
+ required_status_checks: { strict: false, checks },
357
+ enforce_admins: null,
358
+ required_pull_request_reviews: null,
359
+ restrictions: null,
360
+ });
361
+ }
362
+ else {
363
+ // Protected already — PATCH the sub-resource (a partial update that
364
+ // touches ONLY required status checks, preserving reviews / restrictions /
365
+ // enforce_admins / the existing `strict` flag).
366
+ await this.request('PATCH', `branches/${branch}/protection/required_status_checks`, { checks });
367
+ }
368
+ }
369
+ }
370
+ function isRecord(value) {
371
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
372
+ }
373
+ //# sourceMappingURL=hard-gate.js.map