@homeflare/alchemy 0.27.3 → 0.28.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 (58) hide show
  1. package/dist/cloudflare/mesh-node-token.d.ts +1 -1
  2. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
  3. package/dist/forgejo/branch-protection.d.ts +4 -4
  4. package/dist/forgejo/org-actions-secrets.d.ts +4 -4
  5. package/dist/forgejo/org-label.d.ts +4 -4
  6. package/dist/forgejo/org-team.d.ts +4 -4
  7. package/dist/forgejo/repo-webhook.d.ts +4 -4
  8. package/dist/forgejo/repository.d.ts +4 -4
  9. package/dist/forgejo/team-member.d.ts +4 -4
  10. package/dist/github/index.d.ts +1 -1
  11. package/dist/github/index.d.ts.map +1 -1
  12. package/dist/github/index.js +99 -3
  13. package/dist/github/index.js.map +8 -7
  14. package/dist/github/repository-ruleset-errors.d.ts +34 -1
  15. package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
  16. package/dist/github/repository-ruleset-guards.d.ts +12 -0
  17. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  18. package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
  19. package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
  20. package/dist/github/repository-ruleset-props.d.ts +88 -0
  21. package/dist/github/repository-ruleset-props.d.ts.map +1 -0
  22. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
  23. package/dist/github/repository-ruleset.d.ts +3 -31
  24. package/dist/github/repository-ruleset.d.ts.map +1 -1
  25. package/dist/netbox/fake-netbox.d.ts +43 -0
  26. package/dist/netbox/fake-netbox.d.ts.map +1 -0
  27. package/dist/netbox/index.d.ts +1 -2
  28. package/dist/netbox/index.d.ts.map +1 -1
  29. package/dist/netbox/index.js +71 -119
  30. package/dist/netbox/index.js.map +5 -6
  31. package/dist/netbox/prefix.d.ts +33 -2
  32. package/dist/netbox/prefix.d.ts.map +1 -1
  33. package/dist/netbox/resource.d.ts +73 -48
  34. package/dist/netbox/resource.d.ts.map +1 -1
  35. package/docs/credentials.md +10 -0
  36. package/docs/peers.md +11 -0
  37. package/docs/upstream-conformance.md +17 -2
  38. package/package.json +7 -2
  39. package/src/github/index.ts +2 -0
  40. package/src/github/repository-ruleset-errors.ts +45 -0
  41. package/src/github/repository-ruleset-exact-adopt.test.ts +179 -0
  42. package/src/github/repository-ruleset-guards.ts +8 -2
  43. package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
  44. package/src/github/repository-ruleset-narrowing.test.ts +210 -0
  45. package/src/github/repository-ruleset-props.ts +92 -0
  46. package/src/github/repository-ruleset-reconcile.ts +35 -0
  47. package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
  48. package/src/github/repository-ruleset.ts +27 -41
  49. package/src/netbox/fake-netbox.ts +56 -0
  50. package/src/netbox/index.ts +8 -2
  51. package/src/netbox/prefix.test.ts +223 -0
  52. package/src/netbox/prefix.ts +68 -32
  53. package/src/netbox/resource.test.ts +67 -0
  54. package/src/netbox/resource.ts +133 -112
  55. package/dist/netbox/client.d.ts +0 -52
  56. package/dist/netbox/client.d.ts.map +0 -1
  57. package/src/netbox/client.test.ts +0 -67
  58. package/src/netbox/client.ts +0 -146
@@ -43,6 +43,49 @@ export class BypassActorWidened extends Data.TaggedError('BypassActorWidened')<{
43
43
  }
44
44
  }
45
45
 
46
+ /** A declared `bypassActors` drops an actor the live ruleset has, with no
47
+ * `acknowledgeBypassNarrowing` on the declaration. Unlike widening, narrowing is not always
48
+ * wrong — an exact-adopt declaration that matches live exactly narrows nothing — so this is
49
+ * refused only when live genuinely has more than the declaration keeps, and it is escapable
50
+ * (`BypassActorWidened` is not): see `bypassNarrowingRefusal` in
51
+ * repository-ruleset-narrowing-guards.ts. */
52
+ export class BypassActorNarrowed extends Data.TaggedError('BypassActorNarrowed')<{
53
+ readonly owner: string;
54
+ readonly repository: string;
55
+ readonly name: string;
56
+ readonly narrowed: readonly string[];
57
+ }> {
58
+ override get message(): string {
59
+ return (
60
+ `GitHub.RepositoryRuleset "${this.name}" on ${this.owner}/${this.repository}: the ` +
61
+ `declaration drops a bypass actor the live ruleset has (${this.narrowed.join(', ')}), and ` +
62
+ 'the declaration carries no `acknowledgeBypassNarrowing`. Add one with a reason once the ' +
63
+ 'removal is deliberate, or restore the actor to match live.'
64
+ );
65
+ }
66
+ }
67
+
68
+ /** A rule type the declaration explicitly marks absent (`false`) while the live ruleset still
69
+ * has it, with no `acknowledgeRuleNarrowing` on the declaration — see `ruleNarrowingRefusal` in
70
+ * repository-ruleset-narrowing-guards.ts. Distinct from `UndeclaredLiveRule`: that one fires on
71
+ * SILENCE (the declaration says nothing); this one fires on an EXPLICIT, but unacknowledged,
72
+ * removal. */
73
+ export class RuleNarrowed extends Data.TaggedError('RuleNarrowed')<{
74
+ readonly owner: string;
75
+ readonly repository: string;
76
+ readonly name: string;
77
+ readonly ruleType: string;
78
+ }> {
79
+ override get message(): string {
80
+ return (
81
+ `GitHub.RepositoryRuleset "${this.name}" on ${this.owner}/${this.repository}: the ` +
82
+ `declaration drops a live "${this.ruleType}" rule, and carries no ` +
83
+ '`acknowledgeRuleNarrowing`. Add one with a reason once the removal is deliberate, or ' +
84
+ 'declare the rule to match live.'
85
+ );
86
+ }
87
+ }
88
+
46
89
  /** A live rule's `type` is neither modeled nor explicitly refused by RULE_TYPE_COVERAGE, or is
47
90
  * refused and present live anyway. The PUT replaces `rules` wholesale, so silently omitting a
48
91
  * live rule type would drop it — this fails the plan by name instead. */
@@ -129,7 +172,9 @@ export class ReadbackMismatch extends Data.TaggedError('ReadbackMismatch')<{
129
172
  export type RepositoryRulesetError =
130
173
  | DuplicateRuleset
131
174
  | BypassActorWidened
175
+ | BypassActorNarrowed
132
176
  | UndeclaredLiveRule
177
+ | RuleNarrowed
133
178
  | RequiredChecksOmitted
134
179
  | NeverReportedContext
135
180
  | RulesetConstraintRefused
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Exact-adopt pass-through, proven against the 5 live rulesets the red team found this resource
3
+ * could not express before 2026-09-23 (design critique, "strongest_failure": `repoBaselineRuleset()`
4
+ * hardcodes `bypassActors: []` and an unconditional `pullRequest` rule; every one of these 5
5
+ * repos' live ruleset has a non-empty `bypass_actors`, and one — `taslabs-net/taslabs-net` — has
6
+ * no live `pull_request` rule at all). This file proves the underlying `RepositoryRuleset`
7
+ * resource (not the convenience `repoBaselineRuleset()`, which intentionally keeps its baseline
8
+ * defaults — see repo-baseline-data.ts) can express all 5 shapes, and that declaring one exactly
9
+ * is never treated as narrowing.
10
+ *
11
+ * ⚠️ RE-READ LIVE 2026-09-23 via `gh api repos/taslabs-net/<repo>/rulesets/<id>`, not copied
12
+ * from the design doc's own paraphrase — `bypass_mode` differs from the design critique's
13
+ * prose for `doesthishelp-workeropen` (measured: RepositoryRole 5 is `pull_request` mode and
14
+ * Integration 29110 is `always`, not the reverse) — see repository-ruleset-narrowing-guards.ts
15
+ * for the guard these fixtures exercise.
16
+ *
17
+ * `taslabs-net` is the only one of the 5 whose FULL ruleset (bypass AND rules) is provable as a
18
+ * true zero-write adopt today: its live `pull_request` rules (aop/magictransit/loggarr/
19
+ * doesthishelp-workeropen) all carry `require_extra_approval_for_unattributed_changes: true`,
20
+ * which this resource cannot yet declare (K1 — a separate, later PR; H15's comment on
21
+ * `extraApprovalForUnattributedChanges` explains why only `false` is modeled today). The other
22
+ * 4 fixtures below prove the narrower claim K1 does not gate: that this resource's `bypassActors`
23
+ * round-trips an arbitrary `actor_id`/`actor_type`/`bypass_mode` combination — single-actor by
24
+ * role, single-actor by integration, and multi-actor with mixed modes — onto the wire exactly as
25
+ * declared, with neither `BypassActorWidened` nor `BypassActorNarrowed` firing on a match.
26
+ */
27
+ import { describe, expect, test } from 'bun:test';
28
+ import { makeGetOnlyFake, makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
29
+ import {
30
+ BASE_PULL_REQUEST,
31
+ props,
32
+ reconcileWithOctokit,
33
+ run,
34
+ } from './repository-ruleset-test-helpers.ts';
35
+
36
+ describe('exact-adopt pass-through — live shapes measured 2026-09-23', () => {
37
+ // The critical case: a non-empty bypass_actors AND an absent pull_request rule, both at once.
38
+ // `gh api repos/taslabs-net/taslabs-net/rulesets/12206080`.
39
+ test('taslabs-net/taslabs-net: non-empty bypass, no pull_request rule — zero-write adopt', async () => {
40
+ const live = {
41
+ id: 12206080,
42
+ name: 'main',
43
+ target: 'branch',
44
+ enforcement: 'active',
45
+ bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
46
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
47
+ rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }],
48
+ };
49
+ const fake = makeGetOnlyFake({ 'taslabs-net': live });
50
+ const declaration = props({
51
+ repository: 'taslabs-net',
52
+ bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
53
+ rules: { deletion: true, nonFastForward: true },
54
+ });
55
+ const attrs = await run(reconcileWithOctokit(fake, declaration, { rulesetId: 12206080 }));
56
+ expect(attrs.rulesetId).toBe(12206080);
57
+ });
58
+
59
+ // `gh api repos/taslabs-net/aop/rulesets/14572279` — single RepositoryRole actor, mode
60
+ // "always". Live `pull_request` also carries the K1 flag, so the write below is an `update`,
61
+ // not a noop — the point here is that `bypassActors` alone sends back exactly what was
62
+ // declared, and neither narrowing nor widening guard fires on a matching bypass list.
63
+ test('aop: single RepositoryRole actor round-trips exactly', async () => {
64
+ const live = {
65
+ id: 14572279,
66
+ name: 'main',
67
+ target: 'branch',
68
+ enforcement: 'active',
69
+ bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
70
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
71
+ rules: [
72
+ { type: 'deletion' },
73
+ { type: 'non_fast_forward' },
74
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
75
+ ],
76
+ };
77
+ const fake = makeRecordingFake({ aop: live });
78
+ const declaration = props({
79
+ repository: 'aop',
80
+ bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
81
+ rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
82
+ });
83
+ await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 14572279 }));
84
+ expect(fake.writes[0]?.body?.bypass_actors).toEqual([
85
+ { actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' },
86
+ ]);
87
+ });
88
+
89
+ // `gh api repos/taslabs-net/loggarr/rulesets/12323114` — a single Integration (Dependabot)
90
+ // actor with `bypass_mode: "pull_request"`, not `"always"`.
91
+ test('loggarr: single Integration actor, pull_request bypass mode, round-trips exactly', async () => {
92
+ const live = {
93
+ id: 12323114,
94
+ name: 'main',
95
+ target: 'branch',
96
+ enforcement: 'active',
97
+ bypass_actors: [{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'pull_request' }],
98
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
99
+ rules: [
100
+ { type: 'deletion' },
101
+ { type: 'non_fast_forward' },
102
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
103
+ ],
104
+ };
105
+ const fake = makeRecordingFake({ loggarr: live });
106
+ const declaration = props({
107
+ repository: 'loggarr',
108
+ bypassActors: [{ actorType: 'Integration', actorId: 29110, bypassMode: 'pull_request' }],
109
+ rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
110
+ });
111
+ await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 12323114 }));
112
+ expect(fake.writes[0]?.body?.bypass_actors).toEqual([
113
+ { actor_id: 29110, actor_type: 'Integration', bypass_mode: 'pull_request' },
114
+ ]);
115
+ });
116
+
117
+ // `gh api repos/taslabs-net/doesthishelp-workeropen/rulesets/12504824` — TWO actors with
118
+ // DIFFERENT modes each (the design critique's own "(both)" shorthand for this repo).
119
+ test('doesthishelp-workeropen: two actors, mixed bypass modes, round-trip exactly', async () => {
120
+ const live = {
121
+ id: 12504824,
122
+ name: 'main',
123
+ target: 'branch',
124
+ enforcement: 'active',
125
+ bypass_actors: [
126
+ { actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'pull_request' },
127
+ { actor_id: 29110, actor_type: 'Integration', bypass_mode: 'always' },
128
+ ],
129
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
130
+ rules: [
131
+ { type: 'deletion' },
132
+ { type: 'non_fast_forward' },
133
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
134
+ ],
135
+ };
136
+ const fake = makeRecordingFake({ 'doesthishelp-workeropen': live });
137
+ const declaration = props({
138
+ repository: 'doesthishelp-workeropen',
139
+ bypassActors: [
140
+ { actorType: 'RepositoryRole', actorId: 5, bypassMode: 'pull_request' },
141
+ { actorType: 'Integration', actorId: 29110, bypassMode: 'always' },
142
+ ],
143
+ rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
144
+ });
145
+ await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 12504824 }));
146
+ expect(fake.writes[0]?.body?.bypass_actors).toEqual([
147
+ { actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'pull_request' },
148
+ { actor_id: 29110, actor_type: 'Integration', bypass_mode: 'always' },
149
+ ]);
150
+ });
151
+
152
+ // `gh api repos/taslabs-net/magictransit/rulesets/10470003` — same single-actor shape as
153
+ // `aop`, kept as its own case because it is one of the 5 the design's own census named.
154
+ test('magictransit: single RepositoryRole actor round-trips exactly', async () => {
155
+ const live = {
156
+ id: 10470003,
157
+ name: 'main',
158
+ target: 'branch',
159
+ enforcement: 'active',
160
+ bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
161
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
162
+ rules: [
163
+ { type: 'deletion' },
164
+ { type: 'non_fast_forward' },
165
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
166
+ ],
167
+ };
168
+ const fake = makeRecordingFake({ magictransit: live });
169
+ const declaration = props({
170
+ repository: 'magictransit',
171
+ bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
172
+ rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
173
+ });
174
+ await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 10470003 }));
175
+ expect(fake.writes[0]?.body?.bypass_actors).toEqual([
176
+ { actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' },
177
+ ]);
178
+ });
179
+ });
@@ -14,7 +14,10 @@ import {
14
14
  } from './repository-ruleset-errors.ts';
15
15
  import type { RulesetOctokit, RulesetRecord } from './repository-ruleset-probe.ts';
16
16
 
17
- const bypassKey = (a: { actor_type: unknown; actor_id: unknown; bypass_mode?: unknown }) =>
17
+ /** Exported: repository-ruleset-narrowing-guards.ts reuses the identical key so a widened and a
18
+ * narrowed actor are never computed by two independently-drifting definitions of "the same
19
+ * actor". */
20
+ export const bypassKey = (a: { actor_type: unknown; actor_id: unknown; bypass_mode?: unknown }) =>
18
21
  `${String(a.actor_type)}:${String(a.actor_id ?? '')}:${String(a.bypass_mode ?? 'always')}`;
19
22
 
20
23
  /**
@@ -56,7 +59,10 @@ export function bypassWideningRefusal(input: {
56
59
  });
57
60
  }
58
61
 
59
- const liveRuleTypes = (live: RulesetRecord | undefined): Set<string> =>
62
+ /** Exported for the same reason as `bypassKey` — repository-ruleset-narrowing-guards.ts's
63
+ * `ruleNarrowingRefusal` needs the identical live-type set `undeclaredLiveRuleRefusal` computes,
64
+ * not a second reading of `live.rules` that could disagree with this one. */
65
+ export const liveRuleTypes = (live: RulesetRecord | undefined): Set<string> =>
60
66
  new Set((live?.rules ?? []).map((r) => String(r.type)));
61
67
 
62
68
  /**
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Narrowing refusals — the counterpart to `bypassWideningRefusal`/`undeclaredLiveRuleRefusal` in
3
+ * repository-ruleset-guards.ts, split into its own file purely to keep both under the 250-line
4
+ * cap (same reason repository-ruleset-props.ts exists).
5
+ *
6
+ * ⛔ WIDENING STAYS UNCONDITIONAL; NARROWING GETS AN ESCAPE HATCH. Widening bypass (granting a
7
+ * NEW actor) has no legitimate reason to happen through a deploy — "grant it by hand and adopt
8
+ * the change" (repository-ruleset-errors.ts's `BypassActorWidened` message) already covers it,
9
+ * and this file does not touch that guard. NARROWING (removing a live bypass actor, or
10
+ * dropping a live rule) is different: it is exactly what an EXACT-ADOPT declaration of a repo
11
+ * whose live ruleset is narrower than the house baseline needs to express on its very first
12
+ * deploy — a caller declaring `taslabs-net/taslabs-net`'s live shape (measured live via `gh
13
+ * api repos/taslabs-net/taslabs-net/rulesets/12206080`, 2026-09-23: `bypass_actors:
14
+ * [{actor_id: 5, actor_type: "RepositoryRole", bypass_mode: "always"}]`, `rules: [deletion,
15
+ * non_fast_forward]`, no `pull_request` rule) is not narrowing anything by declaring exactly
16
+ * that shape — it is matching live exactly, and `reconcileRuleset`'s own noop check (comparing
17
+ * canonicalized wire forms) makes zero writes for it, same as any other exact match. What
18
+ * these guards refuse is a LATER declaration that removes something the CURRENT live state
19
+ * still has, without saying why. `acknowledgeBypassNarrowing`/`acknowledgeRuleNarrowing` are
20
+ * the "why": a reasoned, deliberate sign-off, not a rubber stamp — see their doc comments on
21
+ * `RepositoryRulesetProps` (repository-ruleset-props.ts).
22
+ *
23
+ * ⛔ THE TWO ACKNOWLEDGEMENTS ARE DELIBERATELY SEPARATE PROPS, NOT ONE SHARED FLAG (2026-09-23
24
+ * review finding). A single `acknowledgeNarrowing` computed once and handed to both guards let
25
+ * a reason written for one kind of narrowing silently also excuse the other — a declaration
26
+ * dropping BOTH a live rule and a live bypass actor at once needed only one acknowledgement,
27
+ * however narrowly worded, to pass both checks. Each guard below now reads its OWN prop.
28
+ */
29
+ import { bypassKey, liveRuleTypes } from './repository-ruleset-guards.ts';
30
+ import { BypassActorNarrowed, RuleNarrowed } from './repository-ruleset-errors.ts';
31
+ import type { RepositoryRulesetProps, RepositoryRulesetRules } from './repository-ruleset-props.ts';
32
+ import type { RulesetRecord } from './repository-ruleset-probe.ts';
33
+
34
+ /** `true` only for a non-empty, non-whitespace `reason` on `acknowledgeBypassNarrowing` — a bare
35
+ * `{}` or a blank string is not an acknowledgement (repository-ruleset.ts's `constraintRefusal`
36
+ * refuses the latter at plan time; this is the read `bypassNarrowingRefusal` shares). Deliberately
37
+ * ignorant of `acknowledgeRuleNarrowing` — see the file header. */
38
+ export const isBypassNarrowingAcknowledged = (props: RepositoryRulesetProps): boolean =>
39
+ props.acknowledgeBypassNarrowing !== undefined &&
40
+ props.acknowledgeBypassNarrowing.reason.trim().length > 0;
41
+
42
+ /** The `rules`-side counterpart to `isBypassNarrowingAcknowledged`, reading only
43
+ * `acknowledgeRuleNarrowing` — see the file header. */
44
+ export const isRuleNarrowingAcknowledged = (props: RepositoryRulesetProps): boolean =>
45
+ props.acknowledgeRuleNarrowing !== undefined &&
46
+ props.acknowledgeRuleNarrowing.reason.trim().length > 0;
47
+
48
+ /**
49
+ * A live bypass actor the declaration drops, unacknowledged. `undefined` `declared` never
50
+ * reaches here — same rule as `bypassWideningRefusal`: unmanaged means the live value is
51
+ * preserved unchanged (repository-ruleset-reconcile.ts), so there is nothing to narrow.
52
+ */
53
+ export function bypassNarrowingRefusal(input: {
54
+ readonly owner: string;
55
+ readonly repository: string;
56
+ readonly name: string;
57
+ readonly declared: RepositoryRulesetProps['bypassActors'];
58
+ readonly live: RulesetRecord | undefined;
59
+ readonly acknowledged: boolean;
60
+ }): BypassActorNarrowed | undefined {
61
+ if (input.acknowledged) return undefined;
62
+ if (input.live === undefined) return undefined;
63
+ const liveActors = (input.live.bypass_actors ?? []) as {
64
+ actor_type: unknown;
65
+ actor_id: unknown;
66
+ bypass_mode?: unknown;
67
+ }[];
68
+ const declaredKeys = new Set(
69
+ (input.declared ?? []).map((a) =>
70
+ bypassKey({
71
+ actor_type: a.actorType,
72
+ actor_id: a.actorId,
73
+ bypass_mode: a.bypassMode ?? 'always',
74
+ }),
75
+ ),
76
+ );
77
+ const narrowed = liveActors.map(bypassKey).filter((k) => !declaredKeys.has(k));
78
+ if (narrowed.length === 0) return undefined;
79
+ return new BypassActorNarrowed({
80
+ owner: input.owner,
81
+ repository: input.repository,
82
+ name: input.name,
83
+ narrowed,
84
+ });
85
+ }
86
+
87
+ /** The live rule `type` for the first boolean/`pullRequest` rule the declaration marks `false`
88
+ * while live still has it — `undefined` when none does. `requiredStatusChecks: false` is
89
+ * checked by the caller directly (its live-side read needs `required_status_checks`'s own
90
+ * parameters shape, not just presence, elsewhere in this family — kept out of this helper so it
91
+ * stays a plain type-to-type comparison). */
92
+ function droppedBooleanOrPullRequestType(
93
+ rules: RepositoryRulesetRules,
94
+ live: ReadonlySet<string>,
95
+ ): string | undefined {
96
+ const declaredFalse: readonly [boolean | undefined, string][] = [
97
+ [rules.creation, 'creation'],
98
+ [rules.update, 'update'],
99
+ [rules.deletion, 'deletion'],
100
+ [rules.requiredLinearHistory, 'required_linear_history'],
101
+ [rules.requiredSignatures, 'required_signatures'],
102
+ [rules.nonFastForward, 'non_fast_forward'],
103
+ ];
104
+ for (const [declared, type] of declaredFalse) {
105
+ if (declared === false && live.has(type)) return type;
106
+ }
107
+ if (rules.pullRequest === false && live.has('pull_request')) return 'pull_request';
108
+ return undefined;
109
+ }
110
+
111
+ /**
112
+ * A rule the declaration explicitly marks absent (a boolean rule or `pullRequest` declared
113
+ * `false`, or `requiredStatusChecks: false`) while the LIVE ruleset still has it, unacknowledged
114
+ * — the drop `undeclaredLiveRuleRefusal` (repository-ruleset-guards.ts) never sees, because an
115
+ * explicit `false` IS "declared" there (see its own `declaredRuleTypes`).
116
+ */
117
+ export function ruleNarrowingRefusal(input: {
118
+ readonly owner: string;
119
+ readonly repository: string;
120
+ readonly name: string;
121
+ readonly declared: RepositoryRulesetProps['rules'];
122
+ readonly live: RulesetRecord | undefined;
123
+ readonly acknowledged: boolean;
124
+ }): RuleNarrowed | undefined {
125
+ if (input.acknowledged) return undefined;
126
+ const rules = input.declared;
127
+ if (rules === undefined) return undefined;
128
+ const live = liveRuleTypes(input.live);
129
+ const ruleType =
130
+ droppedBooleanOrPullRequestType(rules, live) ??
131
+ (rules.requiredStatusChecks === false && live.has('required_status_checks')
132
+ ? 'required_status_checks'
133
+ : undefined);
134
+ if (ruleType === undefined) return undefined;
135
+ return new RuleNarrowed({
136
+ owner: input.owner,
137
+ repository: input.repository,
138
+ name: input.name,
139
+ ruleType,
140
+ });
141
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Narrowing refusals (repository-ruleset-narrowing-guards.ts), split out of
3
+ * repository-ruleset-write-refusals.test.ts purely to keep both files under the 250-line cap.
4
+ * `acknowledgeRuleNarrowing`/`acknowledgeBypassNarrowing` are the escape hatches: without the
5
+ * matching one, dropping a live rule or a live bypass actor is refused; with a non-empty reason
6
+ * on the RIGHT prop, it writes exactly what was declared. The two are intentionally separate
7
+ * props, not one shared flag — see the 'mixed narrowing' describe block below and the file
8
+ * header on repository-ruleset-narrowing-guards.ts for the 2026-09-23 finding this fixes. The
9
+ * dedicated exact-adopt fixtures (all 5 live non-empty-bypass shapes, none of which are
10
+ * "narrowing" — they match live exactly) are repository-ruleset-exact-adopt.test.ts.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+ import * as Effect from 'effect/Effect';
14
+ import { makeGetOnlyFake, makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
15
+ import {
16
+ BASE_PULL_REQUEST,
17
+ fails,
18
+ props,
19
+ reconcileAgainst,
20
+ reconcileWithOctokit,
21
+ tagOf,
22
+ } from './repository-ruleset-test-helpers.ts';
23
+
24
+ describe('rule narrowing', () => {
25
+ // Regression for the gap the exact-adopt pass-through work closed 2026-09-23: an explicit
26
+ // `false` alone used to be enough to drop a live rule, with no record of WHY. It is now also
27
+ // gated on `acknowledgeRuleNarrowing` — see repository-ruleset-narrowing-guards.ts.
28
+ test('dropping a live rule (deletion declared false) without acknowledgeRuleNarrowing is refused', async () => {
29
+ const live = {
30
+ id: 1,
31
+ name: 'main',
32
+ enforcement: 'active',
33
+ bypass_actors: [],
34
+ conditions: {},
35
+ rules: [
36
+ { type: 'deletion' },
37
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
38
+ ],
39
+ };
40
+ const fake = makeGetOnlyFake({ widgets: live });
41
+ const declaration = props({
42
+ rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
43
+ });
44
+ const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
45
+ expect(tagOf(error)).toBe('RuleNarrowed');
46
+ });
47
+
48
+ test('the SAME drop, acknowledged with a reason, is a deliberate removal and writes once', async () => {
49
+ const live = {
50
+ id: 1,
51
+ name: 'main',
52
+ enforcement: 'active',
53
+ bypass_actors: [],
54
+ conditions: {},
55
+ rules: [
56
+ { type: 'deletion' },
57
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
58
+ ],
59
+ };
60
+ const fake = makeRecordingFake({ widgets: live });
61
+ fake.reportedContexts.add('ci');
62
+ const declaration = props({
63
+ rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
64
+ acknowledgeRuleNarrowing: { reason: 'Tim approved dropping delete-protection 2026-09-23' },
65
+ });
66
+ await reconcileAgainst(fake, declaration, { rulesetId: 1 }).pipe(Effect.runPromise);
67
+ expect(fake.writes).toHaveLength(1);
68
+ expect(fake.writes[0]?.op).toBe('update');
69
+ const sentTypes = fake.writes[0]?.body?.rules?.map((r) => r.type);
70
+ expect(sentTypes).not.toContain('deletion');
71
+ });
72
+
73
+ // A blank (or whitespace-only) reason is not an acknowledgement —
74
+ // `isRuleNarrowingAcknowledged` (repository-ruleset-narrowing-guards.ts) requires a
75
+ // non-empty, TRIMMED reason, so this is indistinguishable from omitting
76
+ // `acknowledgeRuleNarrowing` entirely and gets the same refusal.
77
+ test('acknowledgeRuleNarrowing with a blank reason does not count as acknowledged', async () => {
78
+ const live = {
79
+ id: 1,
80
+ name: 'main',
81
+ enforcement: 'active',
82
+ bypass_actors: [],
83
+ conditions: {},
84
+ rules: [
85
+ { type: 'deletion' },
86
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
87
+ ],
88
+ };
89
+ const fake = makeGetOnlyFake({ widgets: live });
90
+ const declaration = props({
91
+ rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
92
+ acknowledgeRuleNarrowing: { reason: ' ' },
93
+ });
94
+ const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
95
+ expect(tagOf(error)).toBe('RuleNarrowed');
96
+ });
97
+
98
+ // Scoping regression (2026-09-23 review finding): an acknowledgement reasoned for the RULE
99
+ // drop must not also excuse a bypass-actor drop declared in the SAME declaration.
100
+ test('acknowledgeRuleNarrowing does not excuse an unrelated bypass-actor drop', async () => {
101
+ const live = {
102
+ id: 1,
103
+ name: 'main',
104
+ enforcement: 'active',
105
+ bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
106
+ conditions: {},
107
+ rules: [
108
+ { type: 'deletion' },
109
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
110
+ ],
111
+ };
112
+ const fake = makeGetOnlyFake({ widgets: live });
113
+ const declaration = props({
114
+ bypassActors: [],
115
+ rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
116
+ acknowledgeRuleNarrowing: { reason: 'Tim approved dropping delete-protection 2026-09-23' },
117
+ });
118
+ const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
119
+ expect(tagOf(error)).toBe('BypassActorNarrowed');
120
+ });
121
+ });
122
+
123
+ describe('bypass narrowing', () => {
124
+ test('dropping a live bypass actor without acknowledgeBypassNarrowing is refused', async () => {
125
+ const live = {
126
+ id: 1,
127
+ name: 'main',
128
+ enforcement: 'active',
129
+ bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
130
+ conditions: {},
131
+ rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }],
132
+ };
133
+ const fake = makeGetOnlyFake({ widgets: live });
134
+ const declaration = props({
135
+ bypassActors: [],
136
+ rules: { deletion: true, nonFastForward: true },
137
+ });
138
+ const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
139
+ expect(tagOf(error)).toBe('BypassActorNarrowed');
140
+ });
141
+
142
+ test('the SAME drop, acknowledged with a reason, writes the narrowed list', async () => {
143
+ const live = {
144
+ id: 1,
145
+ name: 'main',
146
+ enforcement: 'active',
147
+ bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
148
+ conditions: {},
149
+ rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }],
150
+ };
151
+ const fake = makeRecordingFake({ widgets: live });
152
+ const declaration = props({
153
+ bypassActors: [],
154
+ rules: { deletion: true, nonFastForward: true },
155
+ acknowledgeBypassNarrowing: { reason: 'Tim revoked admin bypass 2026-09-23' },
156
+ });
157
+ await reconcileAgainst(fake, declaration, { rulesetId: 1 }).pipe(Effect.runPromise);
158
+ expect(fake.writes).toHaveLength(1);
159
+ expect(fake.writes[0]?.body?.bypass_actors).toEqual([]);
160
+ });
161
+
162
+ // Widening stays refused even alongside an acknowledgement — `acknowledgeBypassNarrowing`
163
+ // gates ONLY narrowing (repository-ruleset-narrowing-guards.ts's file header explains why).
164
+ test('acknowledgeBypassNarrowing does not excuse widening', async () => {
165
+ const live = {
166
+ id: 1,
167
+ name: 'main',
168
+ enforcement: 'active',
169
+ bypass_actors: [],
170
+ conditions: {},
171
+ rules: [],
172
+ };
173
+ const fake = makeGetOnlyFake({ widgets: live });
174
+ const declaration = props({
175
+ bypassActors: [{ actorType: 'OrganizationAdmin' }],
176
+ acknowledgeBypassNarrowing: { reason: 'irrelevant to widening' },
177
+ });
178
+ const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
179
+ expect(tagOf(error)).toBe('BypassActorWidened');
180
+ });
181
+
182
+ // Scoping regression (2026-09-23 review finding), the mirror of the rule-narrowing case
183
+ // above: an acknowledgement reasoned for the BYPASS drop must not also excuse an unrelated
184
+ // rule drop declared in the SAME declaration.
185
+ test('acknowledgeBypassNarrowing does not excuse an unrelated rule drop', async () => {
186
+ const live = {
187
+ id: 1,
188
+ name: 'main',
189
+ enforcement: 'active',
190
+ bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
191
+ conditions: {},
192
+ rules: [
193
+ { type: 'deletion' },
194
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
195
+ ],
196
+ };
197
+ const fake = makeGetOnlyFake({ widgets: live });
198
+ const declaration = props({
199
+ bypassActors: [],
200
+ rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
201
+ acknowledgeBypassNarrowing: { reason: 'Tim revoked admin bypass 2026-09-23' },
202
+ });
203
+ // The bypass drop is acknowledged (so `bypassNarrowingRefusal` passes), but the rule drop
204
+ // is not — `reconcileRuleset` checks bypass narrowing before rule narrowing, so a REFUSED
205
+ // rule drop under a bypass-only acknowledgement surfaces as `RuleNarrowed`, proving the
206
+ // bypass acknowledgement did not also cover it.
207
+ const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
208
+ expect(tagOf(error)).toBe('RuleNarrowed');
209
+ });
210
+ });