@homeflare/alchemy 0.26.0 → 0.27.1

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 (64) hide show
  1. package/dist/github/declare-repo-baseline.d.ts +11 -0
  2. package/dist/github/declare-repo-baseline.d.ts.map +1 -0
  3. package/dist/github/index.d.ts +20 -11
  4. package/dist/github/index.d.ts.map +1 -1
  5. package/dist/github/index.js +715 -4
  6. package/dist/github/index.js.map +14 -4
  7. package/dist/github/repo-baseline-data.d.ts +75 -0
  8. package/dist/github/repo-baseline-data.d.ts.map +1 -0
  9. package/dist/github/repo-policy.d.ts +4 -4
  10. package/dist/github/repo-policy.d.ts.map +1 -1
  11. package/dist/github/repository-ruleset-constraints.d.ts +105 -0
  12. package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
  13. package/dist/github/repository-ruleset-errors.d.ts +97 -0
  14. package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
  15. package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
  16. package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
  17. package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
  18. package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
  19. package/dist/github/repository-ruleset-form.d.ts +30 -0
  20. package/dist/github/repository-ruleset-form.d.ts.map +1 -0
  21. package/dist/github/repository-ruleset-guards.d.ts +70 -0
  22. package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
  23. package/dist/github/repository-ruleset-octokit.d.ts +5 -0
  24. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
  25. package/dist/github/repository-ruleset-probe.d.ts +87 -0
  26. package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
  27. package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
  28. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
  29. package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
  30. package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
  31. package/dist/github/repository-ruleset.d.ts +89 -0
  32. package/dist/github/repository-ruleset.d.ts.map +1 -0
  33. package/dist/launchd/index.js.map +2 -2
  34. package/dist/launchd/port-claims.d.ts +2 -2
  35. package/dist/launchd/port-claims.d.ts.map +1 -1
  36. package/docs/launchd-ports.md +14 -17
  37. package/docs/repo-policy-ruleset-hazards.md +8 -0
  38. package/docs/repo-policy.md +27 -12
  39. package/docs/repository-ruleset.md +114 -0
  40. package/docs/upstream-conformance.md +12 -1
  41. package/docs/upstream-pr-ruleset-fixes.md +77 -0
  42. package/package.json +1 -1
  43. package/src/github/declare-repo-baseline.ts +42 -0
  44. package/src/github/index.ts +43 -11
  45. package/src/github/repo-baseline-data.test.ts +76 -0
  46. package/src/github/repo-baseline-data.ts +86 -0
  47. package/src/github/repo-policy.ts +17 -17
  48. package/src/github/repository-ruleset-constraints.ts +136 -0
  49. package/src/github/repository-ruleset-coverage.test.ts +82 -0
  50. package/src/github/repository-ruleset-errors.ts +136 -0
  51. package/src/github/repository-ruleset-fake-octokit.ts +131 -0
  52. package/src/github/repository-ruleset-fixtures.ts +183 -0
  53. package/src/github/repository-ruleset-form.ts +212 -0
  54. package/src/github/repository-ruleset-guards.ts +194 -0
  55. package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
  56. package/src/github/repository-ruleset-octokit.ts +140 -0
  57. package/src/github/repository-ruleset-probe.ts +116 -0
  58. package/src/github/repository-ruleset-reconcile.ts +176 -0
  59. package/src/github/repository-ruleset-test-helpers.ts +102 -0
  60. package/src/github/repository-ruleset-wire.test.ts +66 -0
  61. package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
  62. package/src/github/repository-ruleset-write.test.ts +83 -0
  63. package/src/github/repository-ruleset.ts +219 -0
  64. package/src/launchd/port-claims.ts +8 -9
@@ -0,0 +1,199 @@
1
+ /**
2
+ * (ii) The write-recording fake, refusals: duplicate names, bypass widening, an undeclared live
3
+ * rule, required checks omitted, and a never-reported context each make ZERO writes; and a
4
+ * readback mismatch fails even though the write itself "succeeded" (S10). The happy paths
5
+ * (create, noop, update) are repository-ruleset-write.test.ts.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import { makeGetOnlyFake, makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
10
+ import type { RulesetOctokit } from './repository-ruleset-probe.ts';
11
+ import {
12
+ BASE_PULL_REQUEST,
13
+ fails,
14
+ props,
15
+ reconcileAgainst,
16
+ reconcileWithOctokit,
17
+ tagOf,
18
+ } from './repository-ruleset-test-helpers.ts';
19
+
20
+ describe('refusals make zero writes', () => {
21
+ test('duplicate ruleset names', async () => {
22
+ const fake: RulesetOctokit = {
23
+ list: () =>
24
+ Effect.succeed([
25
+ { id: 1, name: 'main', target: 'branch' },
26
+ { id: 2, name: 'main', target: 'branch' },
27
+ ]),
28
+ get: () => Effect.succeed(undefined),
29
+ create: () => Effect.fail(new Error('must not be called')),
30
+ update: () => Effect.fail(new Error('must not be called')),
31
+ delete: () => Effect.fail(new Error('must not be called')),
32
+ hasContextReportedSuccess: () => Effect.succeed(true),
33
+ };
34
+ const error = await fails(reconcileWithOctokit(fake, props()));
35
+ expect(tagOf(error)).toBe('DuplicateRuleset');
36
+ });
37
+
38
+ test('bypass widening', async () => {
39
+ const live = {
40
+ id: 1,
41
+ name: 'main',
42
+ enforcement: 'active',
43
+ bypass_actors: [],
44
+ conditions: {},
45
+ rules: [],
46
+ };
47
+ const fake = makeGetOnlyFake({ widgets: live });
48
+ const error = await fails(
49
+ reconcileWithOctokit(fake, props({ bypassActors: [{ actorType: 'OrganizationAdmin' }] }), {
50
+ rulesetId: 1,
51
+ }),
52
+ );
53
+ expect(tagOf(error)).toBe('BypassActorWidened');
54
+ });
55
+
56
+ test('a refused live rule type (e.g. workflows) blocks any write', async () => {
57
+ const live = {
58
+ id: 1,
59
+ name: 'main',
60
+ enforcement: 'active',
61
+ bypass_actors: [],
62
+ conditions: {},
63
+ rules: [{ type: 'workflows', parameters: {} }],
64
+ };
65
+ const fake = makeGetOnlyFake({ widgets: live });
66
+ const error = await fails(reconcileWithOctokit(fake, props(), { rulesetId: 1 }));
67
+ expect(tagOf(error)).toBe('UndeclaredLiveRule');
68
+ });
69
+
70
+ // Regression for a bug this family's own first cut shipped with: an omitted boolean rule
71
+ // (as opposed to one explicitly declared `false`) was treated as "fully owned, so absence
72
+ // is the removal" and never refused — meaning forgetting `deletion: true` in a declaration
73
+ // would silently drop branch-deletion protection from a live ruleset on the next update.
74
+ // Found by self-review, not by a failing test; see undeclaredLiveRuleRefusal's own comment.
75
+ test('an omitted boolean rule (not declared false) the live ruleset has blocks any write', async () => {
76
+ const live = {
77
+ id: 1,
78
+ name: 'main',
79
+ enforcement: 'active',
80
+ bypass_actors: [],
81
+ conditions: {},
82
+ rules: [
83
+ { type: 'deletion' },
84
+ { type: 'non_fast_forward' },
85
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
86
+ ],
87
+ };
88
+ const fake = makeGetOnlyFake({ widgets: live });
89
+ // Declares pullRequest but says nothing at all about `deletion` — not even `false`.
90
+ const declaration = props({
91
+ rules: { nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
92
+ });
93
+ const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
94
+ expect(tagOf(error)).toBe('UndeclaredLiveRule');
95
+ });
96
+
97
+ test('the SAME live ruleset with deletion explicitly declared false is a deliberate removal, not refused', async () => {
98
+ const live = {
99
+ id: 1,
100
+ name: 'main',
101
+ enforcement: 'active',
102
+ bypass_actors: [],
103
+ conditions: {},
104
+ rules: [
105
+ { type: 'deletion' },
106
+ { type: 'pull_request', parameters: { required_approving_review_count: 0 } },
107
+ ],
108
+ };
109
+ const fake = makeRecordingFake({ widgets: live });
110
+ fake.reportedContexts.add('ci');
111
+ const declaration = props({
112
+ rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
113
+ });
114
+ await reconcileAgainst(fake, declaration, { rulesetId: 1 }).pipe(Effect.runPromise);
115
+ expect(fake.writes).toHaveLength(1);
116
+ expect(fake.writes[0]?.op).toBe('update');
117
+ const sentTypes = fake.writes[0]?.body?.rules?.map((r) => r.type);
118
+ expect(sentTypes).not.toContain('deletion');
119
+ });
120
+
121
+ test('required checks omitted while the live ruleset has them', async () => {
122
+ const live = {
123
+ id: 1,
124
+ name: 'main',
125
+ enforcement: 'active',
126
+ bypass_actors: [],
127
+ conditions: {},
128
+ rules: [
129
+ {
130
+ type: 'required_status_checks',
131
+ parameters: { required_status_checks: [{ context: 'ci' }] },
132
+ },
133
+ ],
134
+ };
135
+ const fake = makeGetOnlyFake({ widgets: live });
136
+ const declaration = props({
137
+ rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
138
+ });
139
+ const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
140
+ expect(tagOf(error)).toBe('RequiredChecksOmitted');
141
+ });
142
+
143
+ test('a never-reported context refuses before any write', async () => {
144
+ const fake = makeRecordingFake({});
145
+ // `reportedContexts` starts empty — `ci` has never reported.
146
+ const error = await fails(reconcileAgainst(fake, props()));
147
+ expect(tagOf(error)).toBe('NeverReportedContext');
148
+ expect(fake.writes).toHaveLength(0);
149
+ });
150
+ });
151
+
152
+ describe('readback', () => {
153
+ test('fails when the independent re-read does not match what was sent', async () => {
154
+ // A hand-built fake, not `makeRecordingFake` — the point here is specifically that `get`
155
+ // (the INDEPENDENT re-read) can disagree with what `create` echoed back, simulating GitHub
156
+ // accepting the write but not persisting it as declared (S10: never trust the write's own
157
+ // report as proof).
158
+ const created = {
159
+ id: 1,
160
+ name: 'main',
161
+ target: 'branch',
162
+ enforcement: 'active',
163
+ bypass_actors: [],
164
+ conditions: {},
165
+ rules: [
166
+ {
167
+ type: 'pull_request',
168
+ parameters: { required_approving_review_count: 0, allowed_merge_methods: ['squash'] },
169
+ },
170
+ ],
171
+ };
172
+ const persisted = {
173
+ ...created,
174
+ rules: [
175
+ // GitHub silently kept every method instead of narrowing to squash.
176
+ {
177
+ type: 'pull_request',
178
+ parameters: {
179
+ required_approving_review_count: 0,
180
+ allowed_merge_methods: ['merge', 'squash', 'rebase'],
181
+ },
182
+ },
183
+ ],
184
+ };
185
+ const fake: RulesetOctokit = {
186
+ list: () => Effect.succeed([]),
187
+ get: () => Effect.succeed(persisted),
188
+ create: () => Effect.succeed(created),
189
+ update: () => Effect.fail(new Error('not exercised')),
190
+ delete: () => Effect.fail(new Error('not exercised')),
191
+ hasContextReportedSuccess: () => Effect.succeed(true),
192
+ };
193
+ const declaration = props({
194
+ rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
195
+ });
196
+ const error = await fails(reconcileWithOctokit(fake, declaration));
197
+ expect(tagOf(error)).toBe('ReadbackMismatch');
198
+ });
199
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * (ii) The write-recording fake, happy paths: a create sends the extension flag and
3
+ * `allowedMergeMethods`; a matching live ruleset makes zero writes (S10); one drifted field
4
+ * makes exactly one `update`, never a create. Refusals (zero writes) and the readback check
5
+ * are repository-ruleset-write-refusals.test.ts, split out to stay under the 250-line cap.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
9
+ import { desiredWireRuleset } from './repository-ruleset-form.ts';
10
+ import { props, reconcileAgainst, run } from './repository-ruleset-test-helpers.ts';
11
+
12
+ describe('create', () => {
13
+ test('sends allowedMergeMethods and the extension flag, and nothing else creates', async () => {
14
+ const fake = makeRecordingFake({});
15
+ fake.reportedContexts.add('ci');
16
+ const attrs = await run(reconcileAgainst(fake, props()));
17
+ expect(fake.writes).toHaveLength(1);
18
+ const [write] = fake.writes;
19
+ expect(write?.op).toBe('create');
20
+ expect(write?.repo).toBe('widgets');
21
+ const pr = write?.body?.rules?.find((r) => r.type === 'pull_request');
22
+ expect(
23
+ (pr as { parameters: Record<string, unknown> }).parameters.allowed_merge_methods,
24
+ ).toEqual(['squash']);
25
+ expect(
26
+ (pr as { parameters: Record<string, unknown> }).parameters
27
+ .require_extra_approval_for_unattributed_changes,
28
+ ).toBe(false);
29
+ expect(attrs.name).toBe('main');
30
+ });
31
+ });
32
+
33
+ describe('noop and update', () => {
34
+ test('a matching live ruleset makes zero writes', async () => {
35
+ const desired = desiredWireRuleset(props());
36
+ const fake = makeRecordingFake({
37
+ widgets: {
38
+ id: 1,
39
+ name: desired.name,
40
+ target: desired.target,
41
+ source_type: 'Repository',
42
+ enforcement: desired.enforcement,
43
+ bypass_actors: desired.bypass_actors as never,
44
+ conditions: desired.conditions,
45
+ rules: desired.rules as never,
46
+ },
47
+ });
48
+ fake.reportedContexts.add('ci');
49
+ await run(reconcileAgainst(fake, props(), { rulesetId: 1 }));
50
+ expect(fake.writes).toHaveLength(0);
51
+ });
52
+
53
+ test('one drifted field makes exactly one update, never a create', async () => {
54
+ const desired = desiredWireRuleset(props());
55
+ const fake = makeRecordingFake({
56
+ widgets: {
57
+ id: 1,
58
+ name: desired.name,
59
+ target: desired.target,
60
+ source_type: 'Repository',
61
+ enforcement: desired.enforcement,
62
+ bypass_actors: desired.bypass_actors as never,
63
+ conditions: desired.conditions,
64
+ rules: (desired.rules ?? []).map((r) =>
65
+ r.type === 'pull_request'
66
+ ? {
67
+ ...r,
68
+ parameters: {
69
+ ...r.parameters,
70
+ allowed_merge_methods: ['merge', 'squash', 'rebase'],
71
+ },
72
+ }
73
+ : r,
74
+ ) as never,
75
+ },
76
+ });
77
+ fake.reportedContexts.add('ci');
78
+ await run(reconcileAgainst(fake, props(), { rulesetId: 1 }));
79
+ expect(fake.writes).toHaveLength(1);
80
+ const [write] = fake.writes;
81
+ expect(write?.op).toBe('update');
82
+ });
83
+ });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * `GitHub.RepositoryRuleset` — a bridge resource for the one thing upstream `GitHub.Ruleset`
3
+ * cannot do: find a ruleset that already exists before creating a second one with the same
4
+ * name.
5
+ *
6
+ * ⛔ THE HAZARD THIS EXISTS TO CLOSE. Read out of `alchemy@2.0.0-beta.79`'s own source
7
+ * (`node_modules/alchemy/src/GitHub/Ruleset.ts`, `reconcile`): `let observed = output ===
8
+ * undefined ? undefined : yield* getRuleset(news, output.rulesetId)`. With no prior state,
9
+ * `observed` stays `undefined` and `createRepoRuleset` runs UNCONDITIONALLY — there is no
10
+ * probe by name. GitHub permits several rulesets with one name on a repository, so nothing
11
+ * errors; the repo quietly ends up with two, both enforcing. `declareRepoPolicy` in this same
12
+ * package (repo-policy.ts) documents the same hazard and tells a caller to check `gh api
13
+ * repos/<owner>/<repo>/rulesets` by hand before every first deploy. This resource makes that
14
+ * check the engine's own job: `read`/`reconcile` list-and-filter by name+target before ever
15
+ * creating (see repository-ruleset-probe.ts).
16
+ *
17
+ * ★ WHY A NEW TYPE STRING, NOT A PATCHED `GitHub.Ruleset`. Upstream's `Ruleset` cannot be
18
+ * edited from here — it ships inside the `alchemy` package. `GitHub.RepositoryRuleset` is a
19
+ * distinct `Provider` (its own `Context.Service` keyed by this type string — see
20
+ * `alchemy/src/Provider.ts#Provider`), so it is declared and merged into a stack's provider
21
+ * layer independently of `GitHub.providers()` — the same way `postgres/database.ts` merges
22
+ * `PostgresDatabaseProvider()` beside it. Nothing about upstream's `GitHub.Providers`
23
+ * collection needs touching for the two to coexist in one stack. Props are a strict superset
24
+ * of upstream's `RulesetProps` (H14: the type string sits inside upstream's own `GitHub`
25
+ * namespace, recorded as house difference H15 below for the client choice).
26
+ *
27
+ * ⛔ H15: CLIENT IS OCTOKIT, NOT DISTILLED — A NAMED DIVERGENCE FROM S23. The house's default is
28
+ * `@distilled.cloud/*` when a vendor SDK exists there, and `@distilled.cloud/github@1.0.0-rc.12`
29
+ * does ship `createRepoRuleset`/`updateRepoRuleset`. But it has NO field for
30
+ * `require_extra_approval_for_unattributed_changes` (measured 2026-09-22: grep of its
31
+ * generated types), and its request bodies are plain `effect/Schema` `S.Struct`s, which strip
32
+ * properties a schema does not declare on encode — REASONED, not measured against distilled
33
+ * itself; repository-ruleset-wire.test.ts measures the Octokit side of the same question
34
+ * instead, because Octokit is the client this file actually uses. That flag is the entire
35
+ * reason this resource exists for the 0-approval, solo-maintainer shape (see
36
+ * repo-policy-guards.ts's `resolveApprovals` for the same story on the upstream-only path):
37
+ * GitHub defaults it to `true` when absent, which blocks the very pull requests a `0` was
38
+ * meant to allow. `octokitFor`/`GitHubCredentials` are upstream's own EXPORTED building
39
+ * blocks (S23's second clause: reuse Alchemy's own client construction), so the client itself
40
+ * is not hand-rolled — only the choice of Octokit over distilled is a recorded divergence.
41
+ */
42
+ import { Resource } from 'alchemy';
43
+ import { Unowned } from 'alchemy/AdoptPolicy';
44
+ import { isResolved } from 'alchemy/Diff';
45
+ import type { GitHubCredentials, RulesetProps } from 'alchemy/GitHub';
46
+ import * as Provider from 'alchemy/Provider';
47
+ import * as Effect from 'effect/Effect';
48
+ import {
49
+ allowedMergeMethodsRefusal,
50
+ approvalCountRefusal,
51
+ } from './repository-ruleset-constraints.ts';
52
+ import { RulesetConstraintRefused } from './repository-ruleset-errors.ts';
53
+ import { makeRulesetOctokit } from './repository-ruleset-octokit.ts';
54
+ import { type RulesetRecord, probeByName } from './repository-ruleset-probe.ts';
55
+ import { reconcileRuleset } from './repository-ruleset-reconcile.ts';
56
+
57
+ type UpstreamRules = NonNullable<RulesetProps['rules']>;
58
+
59
+ export interface RepositoryRulesetReviewerRule {
60
+ readonly filePatterns: readonly string[];
61
+ readonly minimumApprovals: number;
62
+ readonly reviewer: { readonly actorId: number; readonly actorType: 'Team' };
63
+ }
64
+
65
+ export interface RepositoryRulesetPullRequestRule extends NonNullable<
66
+ UpstreamRules['pullRequest']
67
+ > {
68
+ /** @default every method GitHub allows — the house baseline always pins `['squash']`. */
69
+ readonly allowedMergeMethods?: readonly ('merge' | 'squash' | 'rebase')[];
70
+ readonly requiredReviewers?: readonly RepositoryRulesetReviewerRule[];
71
+ /** `require_extra_approval_for_unattributed_changes` — see H15 above. Only `false` is
72
+ * modeled: sending `true` has no house use, and GitHub already defaults absent to `true`. */
73
+ readonly extraApprovalForUnattributedChanges?: false;
74
+ }
75
+
76
+ export interface RepositoryRulesetStatusChecksRule extends NonNullable<
77
+ UpstreamRules['requiredStatusChecks']
78
+ > {
79
+ readonly doNotEnforceOnCreate?: boolean;
80
+ }
81
+
82
+ export interface RepositoryRulesetRules extends Omit<
83
+ UpstreamRules,
84
+ 'pullRequest' | 'requiredStatusChecks'
85
+ > {
86
+ readonly pullRequest?: RepositoryRulesetPullRequestRule;
87
+ /** `undefined`: no opinion (refused if the live ruleset already has one — see
88
+ * repository-ruleset-guards.ts). `false`: declare its ABSENCE — removes a live one. */
89
+ readonly requiredStatusChecks?: RepositoryRulesetStatusChecksRule | false;
90
+ }
91
+
92
+ export interface RepositoryRulesetProps extends Omit<RulesetProps, 'rules'> {
93
+ readonly rules?: RepositoryRulesetRules;
94
+ }
95
+
96
+ export interface RepositoryRulesetAttributes {
97
+ readonly rulesetId: number;
98
+ readonly nodeId: string | undefined;
99
+ readonly name: string;
100
+ readonly createdAt: string | undefined;
101
+ readonly updatedAt: string | undefined;
102
+ }
103
+
104
+ export interface RepositoryRuleset extends Resource<
105
+ 'GitHub.RepositoryRuleset',
106
+ RepositoryRulesetProps,
107
+ RepositoryRulesetAttributes,
108
+ never,
109
+ GitHubCredentials
110
+ > {}
111
+
112
+ export const RepositoryRuleset = Resource<RepositoryRuleset>('GitHub.RepositoryRuleset', {
113
+ defaultRemovalPolicy: 'retain',
114
+ });
115
+
116
+ export const attrsOf = (r: RulesetRecord): RepositoryRulesetAttributes => ({
117
+ rulesetId: r.id,
118
+ nodeId: r.node_id,
119
+ name: r.name,
120
+ createdAt: r.created_at,
121
+ updatedAt: r.updated_at,
122
+ });
123
+
124
+ /** Plan-time-only refusals — no live data needed, so these run before any read (schema-codegen's
125
+ * rule: check the form before the "no previous state" branch, never after it). */
126
+ function constraintRefusal(props: RepositoryRulesetProps): RulesetConstraintRefused | undefined {
127
+ const approvals = props.rules?.pullRequest?.requiredApprovingReviewCount;
128
+ if (approvals !== undefined) {
129
+ const reason = approvalCountRefusal(approvals);
130
+ if (reason !== undefined) {
131
+ return new RulesetConstraintRefused({
132
+ field: 'rules.pullRequest.requiredApprovingReviewCount',
133
+ reason,
134
+ });
135
+ }
136
+ }
137
+ const methods = props.rules?.pullRequest?.allowedMergeMethods;
138
+ if (methods !== undefined) {
139
+ const reason = allowedMergeMethodsRefusal(methods);
140
+ if (reason !== undefined) {
141
+ return new RulesetConstraintRefused({
142
+ field: 'rules.pullRequest.allowedMergeMethods',
143
+ reason,
144
+ });
145
+ }
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ export const repositoryRulesetHandlers = RepositoryRuleset.Provider.of({
151
+ // Account-wide, cross-repo enumeration duplicates upstream `GitHub.Ruleset`'s own `list` for
152
+ // no benefit here — every RepositoryRuleset is declared once, by name, in a checkout's own
153
+ // stack (S12 already defaults this; nuke should never touch a `retain` ruleset regardless).
154
+ list: () => Effect.succeed([]),
155
+ nuke: { skip: true },
156
+
157
+ read: ({ output, olds }) =>
158
+ Effect.gen(function* () {
159
+ const octokit = makeRulesetOctokit(olds.baseUrl);
160
+ if (output === undefined) {
161
+ const match = yield* probeByName(octokit, {
162
+ owner: olds.owner,
163
+ repo: olds.repository,
164
+ name: olds.name,
165
+ target: olds.target ?? 'branch',
166
+ });
167
+ // Identical is not ours (H1): a name match with no prior state needs adopt(true).
168
+ return match === undefined ? undefined : Unowned(attrsOf(match));
169
+ }
170
+ const byId = yield* octokit.get({
171
+ owner: olds.owner,
172
+ repo: olds.repository,
173
+ rulesetId: output.rulesetId,
174
+ });
175
+ if (byId !== undefined) return attrsOf(byId);
176
+ const byName = yield* probeByName(octokit, {
177
+ owner: olds.owner,
178
+ repo: olds.repository,
179
+ name: olds.name,
180
+ target: olds.target ?? 'branch',
181
+ });
182
+ return byName === undefined ? undefined : attrsOf(byName);
183
+ }),
184
+
185
+ // Plain function, not `Effect.fn(function* …)` — there is nothing here to `yield*`, and an
186
+ // empty generator is its own lint hazard (oxlint's `require-yield`) for a reason: it reads as
187
+ // an unfinished `Effect.gen` body.
188
+ diff: ({ news, olds }) =>
189
+ Effect.succeed(
190
+ isResolved(news) &&
191
+ olds !== undefined &&
192
+ (news.owner !== olds.owner || news.repository !== olds.repository)
193
+ ? ({ action: 'replace' } as const)
194
+ : undefined,
195
+ ),
196
+
197
+ reconcile: ({ news, output }) =>
198
+ reconcileRuleset({
199
+ octokit: makeRulesetOctokit(news.baseUrl),
200
+ news,
201
+ output,
202
+ constraintRefusal,
203
+ attrsOf,
204
+ }),
205
+
206
+ delete: ({ olds, output }) =>
207
+ Effect.gen(function* () {
208
+ if (output?.rulesetId === undefined) return;
209
+ const octokit = makeRulesetOctokit(olds.baseUrl);
210
+ yield* octokit.delete({
211
+ owner: olds.owner,
212
+ repo: olds.repository,
213
+ rulesetId: output.rulesetId,
214
+ });
215
+ }),
216
+ });
217
+
218
+ export const RepositoryRulesetProvider = () =>
219
+ Provider.succeed(RepositoryRuleset, repositoryRulesetHandlers);
@@ -3,13 +3,12 @@
3
3
  * runtime-portable (no `Bun.*`, no `node:*`), so a future Linux consumer (systemd units, under
4
4
  * `/linux`) can import it unchanged.
5
5
  *
6
- * ★ MIRRORS house/nix's REGISTRY, NOT A NEW RULE. `lib-ports.nix` (house @ 92cff7f) collides on
7
- * `lib.attrValues ports` alone — `dupes = lib.subtractLists (lib.unique vals) vals` — keying on
8
- * the PORT NUMBER by itself; address and protocol are never read. `portClaimProblems` below keys
9
- * the same way on purpose: it replaces that check for the jobs a kit stack declares, and a looser
10
- * or stricter rule would silently diverge from the registry it takes over from. What that trades
11
- * away — a wildcard bind beside a specific one on the same port both succeed, silently splitting
12
- * traffic — is measured in docs/launchd-ports.md, not assumed.
6
+ * ★ KEYS ON THE PORT NUMBER ALONE, NOT A NEW RULE. `portClaimProblems` below matches the estate's
7
+ * existing port-collision strictness — address and protocol never read — deliberately: a looser
8
+ * or stricter rule here would be a second, inconsistent source of truth for jobs migrating off a
9
+ * host's own port registry (still enforcing that same strictness until a job leaves it). What
10
+ * this trades away — a wildcard bind beside a specific one on the same port both succeed,
11
+ * silently splitting traffic — is measured in docs/launchd-ports.md, not assumed.
13
12
  * ★ IN THE STACK PROGRAM, NOT A PROVIDER — same shape as `catalogBinary()` (../release/catalog.ts).
14
13
  * Two reasons, both structural to Alchemy 2.0.0-beta.79: Alchemy never diffs a first `create`
15
14
  * whose props still hold an unresolved `Output`, and every mini job's `programArguments` carries
@@ -38,8 +37,8 @@ const describeAddress = (address: string | undefined): string => address ?? 'uns
38
37
  const describeOwner = (owner: string): string => (owner === '' ? '(empty owner)' : owner);
39
38
 
40
39
  /**
41
- * Everything wrong with a set of claims; empty when none collide. ★ KEYS ON `port` ALONE, exactly
42
- * like `lib-ports.nix`'s `assertNoCollision` — see the file header for why.
40
+ * Everything wrong with a set of claims; empty when none collide. ★ KEYS ON `port` ALONE — see the
41
+ * file header for why.
43
42
  */
44
43
  export const portClaimProblems = (claims: readonly PortClaim[]): string[] => {
45
44
  const found: string[] = [];