@homeflare/alchemy 0.28.0 → 0.29.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 (221) hide show
  1. package/README.md +2 -0
  2. package/dist/caddy/admin-address.d.ts +27 -0
  3. package/dist/caddy/admin-address.d.ts.map +1 -0
  4. package/dist/caddy/admin-calls.d.ts +25 -28
  5. package/dist/caddy/admin-calls.d.ts.map +1 -1
  6. package/dist/caddy/admin.d.ts +28 -38
  7. package/dist/caddy/admin.d.ts.map +1 -1
  8. package/dist/caddy/caddy-http-client.d.ts +25 -0
  9. package/dist/caddy/caddy-http-client.d.ts.map +1 -0
  10. package/dist/caddy/config-errors.d.ts +24 -0
  11. package/dist/caddy/config-errors.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +20 -49
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  14. package/dist/caddy/config-reconcile.d.ts +45 -0
  15. package/dist/caddy/config-reconcile.d.ts.map +1 -0
  16. package/dist/caddy/config.d.ts +4 -3
  17. package/dist/caddy/config.d.ts.map +1 -1
  18. package/dist/caddy/fake-caddy.d.ts +10 -2
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  20. package/dist/caddy/index.d.ts +3 -3
  21. package/dist/caddy/index.d.ts.map +1 -1
  22. package/dist/caddy/index.js +487 -444
  23. package/dist/caddy/index.js.map +16 -12
  24. package/dist/caddy/local-admin.d.ts +20 -34
  25. package/dist/caddy/local-admin.d.ts.map +1 -1
  26. package/dist/caddy/providers.d.ts +19 -3
  27. package/dist/caddy/providers.d.ts.map +1 -1
  28. package/dist/cloudflare/mesh-node-token.d.ts +1 -1
  29. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
  30. package/dist/forgejo/branch-protection.d.ts +4 -4
  31. package/dist/forgejo/org-actions-secrets.d.ts +4 -4
  32. package/dist/forgejo/org-label.d.ts +4 -4
  33. package/dist/forgejo/org-team.d.ts +4 -4
  34. package/dist/forgejo/repo-webhook.d.ts +4 -4
  35. package/dist/forgejo/repository.d.ts +4 -4
  36. package/dist/forgejo/team-member.d.ts +4 -4
  37. package/dist/github/declare-repo-baseline.d.ts +1 -1
  38. package/dist/github/declare-repo-baseline.d.ts.map +1 -1
  39. package/dist/github/index.d.ts +8 -4
  40. package/dist/github/index.d.ts.map +1 -1
  41. package/dist/github/index.js +83 -24
  42. package/dist/github/index.js.map +14 -12
  43. package/dist/github/repo-apply-order-fake-providers.d.ts +48 -0
  44. package/dist/github/repo-apply-order-fake-providers.d.ts.map +1 -0
  45. package/dist/github/repo-apply-order-fake-stack.d.ts +13 -0
  46. package/dist/github/repo-apply-order-fake-stack.d.ts.map +1 -0
  47. package/dist/github/repo-auto-merge-gate.d.ts +54 -0
  48. package/dist/github/repo-auto-merge-gate.d.ts.map +1 -0
  49. package/dist/github/repo-baseline-data.d.ts +10 -1
  50. package/dist/github/repo-baseline-data.d.ts.map +1 -1
  51. package/dist/github/repo-policy.d.ts +1 -1
  52. package/dist/github/repo-policy.d.ts.map +1 -1
  53. package/dist/github/repository-ruleset-errors.d.ts +3 -2
  54. package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
  55. package/dist/github/repository-ruleset-form.d.ts.map +1 -1
  56. package/dist/github/repository-ruleset-guards.d.ts +3 -2
  57. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  58. package/dist/github/repository-ruleset-octokit.d.ts +49 -0
  59. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
  60. package/dist/github/repository-ruleset-probe.d.ts +2 -1
  61. package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
  62. package/dist/github/repository-ruleset-props.d.ts +7 -4
  63. package/dist/github/repository-ruleset-props.d.ts.map +1 -1
  64. package/dist/github/repository-ruleset-providers.d.ts +42 -0
  65. package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
  66. package/dist/github/repository-ruleset.d.ts +26 -4
  67. package/dist/github/repository-ruleset.d.ts.map +1 -1
  68. package/dist/litellm/fake-litellm.d.ts +27 -16
  69. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  70. package/dist/litellm/fake-stack.d.ts +9 -4
  71. package/dist/litellm/fake-stack.d.ts.map +1 -1
  72. package/dist/litellm/index.d.ts +7 -5
  73. package/dist/litellm/index.d.ts.map +1 -1
  74. package/dist/litellm/index.js +53 -123
  75. package/dist/litellm/index.js.map +8 -8
  76. package/dist/litellm/operations.d.ts +60 -0
  77. package/dist/litellm/operations.d.ts.map +1 -0
  78. package/dist/litellm/pass-through-endpoint.d.ts +10 -28
  79. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  80. package/dist/litellm/pass-through-errors.d.ts +37 -0
  81. package/dist/litellm/pass-through-errors.d.ts.map +1 -0
  82. package/dist/litellm/pass-through-form.d.ts +11 -4
  83. package/dist/litellm/pass-through-form.d.ts.map +1 -1
  84. package/dist/litellm/providers.d.ts +7 -5
  85. package/dist/litellm/providers.d.ts.map +1 -1
  86. package/dist/netbox/prefix.d.ts +4 -4
  87. package/dist/paperless/custom-field.d.ts +21 -21
  88. package/dist/paperless/custom-field.d.ts.map +1 -1
  89. package/dist/paperless/document-type.d.ts +34 -4
  90. package/dist/paperless/document-type.d.ts.map +1 -1
  91. package/dist/paperless/fake-paperless.d.ts +36 -19
  92. package/dist/paperless/fake-paperless.d.ts.map +1 -1
  93. package/dist/paperless/index.d.ts +7 -2
  94. package/dist/paperless/index.d.ts.map +1 -1
  95. package/dist/paperless/index.js +164 -228
  96. package/dist/paperless/index.js.map +10 -13
  97. package/dist/paperless/matching-locate.d.ts +42 -20
  98. package/dist/paperless/matching-locate.d.ts.map +1 -1
  99. package/dist/paperless/matching-types.d.ts +47 -18
  100. package/dist/paperless/matching-types.d.ts.map +1 -1
  101. package/dist/paperless/matching.d.ts +30 -27
  102. package/dist/paperless/matching.d.ts.map +1 -1
  103. package/dist/paperless/providers.d.ts +13 -7
  104. package/dist/paperless/providers.d.ts.map +1 -1
  105. package/dist/paperless/storage-path.d.ts +34 -4
  106. package/dist/paperless/storage-path.d.ts.map +1 -1
  107. package/dist/paperless/tag.d.ts +43 -8
  108. package/dist/paperless/tag.d.ts.map +1 -1
  109. package/dist/proxmox/acl.d.ts +30 -23
  110. package/dist/proxmox/acl.d.ts.map +1 -1
  111. package/dist/proxmox/distilled-pve.d.ts +89 -0
  112. package/dist/proxmox/distilled-pve.d.ts.map +1 -0
  113. package/dist/proxmox/index.js +663 -609
  114. package/dist/proxmox/index.js.map +12 -11
  115. package/dist/proxmox/members.d.ts +29 -10
  116. package/dist/proxmox/members.d.ts.map +1 -1
  117. package/docs/caddy-admin.md +1 -1
  118. package/docs/caddy.md +3 -2
  119. package/docs/credentials.md +16 -0
  120. package/docs/litellm.md +21 -13
  121. package/docs/paperless.md +12 -0
  122. package/docs/peers.md +5 -0
  123. package/docs/repo-policy.md +16 -1
  124. package/docs/repository-ruleset.md +53 -13
  125. package/docs/upstream-conformance.md +126 -10
  126. package/package.json +5 -2
  127. package/src/caddy/admin-address.ts +71 -0
  128. package/src/caddy/admin-calls.ts +48 -100
  129. package/src/caddy/admin.ts +37 -41
  130. package/src/caddy/adopt-scope.test.ts +1 -1
  131. package/src/caddy/caddy-http-client.ts +229 -0
  132. package/src/caddy/config-adopt-provider.test.ts +139 -0
  133. package/src/caddy/config-adopt.test.ts +60 -126
  134. package/src/caddy/config-errors.ts +47 -0
  135. package/src/caddy/config-lifecycle.test.ts +55 -42
  136. package/src/caddy/config-lifecycle.ts +72 -162
  137. package/src/caddy/config-reconcile.ts +138 -0
  138. package/src/caddy/config.ts +36 -42
  139. package/src/caddy/fake-caddy.ts +20 -2
  140. package/src/caddy/index.ts +3 -8
  141. package/src/caddy/local-admin.test.ts +35 -21
  142. package/src/caddy/local-admin.ts +60 -167
  143. package/src/caddy/providers.test.ts +14 -6
  144. package/src/caddy/providers.ts +20 -4
  145. package/src/github/declare-repo-baseline-apply-order.test.ts +86 -0
  146. package/src/github/declare-repo-baseline.ts +40 -7
  147. package/src/github/index.ts +8 -4
  148. package/src/github/repo-apply-order-fake-providers.ts +119 -0
  149. package/src/github/repo-apply-order-fake-stack.ts +74 -0
  150. package/src/github/repo-auto-merge-gate.ts +57 -0
  151. package/src/github/repo-baseline-data.test.ts +13 -0
  152. package/src/github/repo-baseline-data.ts +11 -2
  153. package/src/github/repo-policy-apply-order.test.ts +72 -0
  154. package/src/github/repo-policy-stack.test.ts +59 -0
  155. package/src/github/repo-policy.ts +31 -3
  156. package/src/github/repository-ruleset-errors.ts +6 -4
  157. package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
  158. package/src/github/repository-ruleset-exact-adopt.test.ts +14 -9
  159. package/src/github/repository-ruleset-form.ts +7 -3
  160. package/src/github/repository-ruleset-guards.ts +3 -2
  161. package/src/github/repository-ruleset-octokit.test.ts +141 -0
  162. package/src/github/repository-ruleset-octokit.ts +121 -30
  163. package/src/github/repository-ruleset-probe.ts +2 -1
  164. package/src/github/repository-ruleset-props.ts +7 -4
  165. package/src/github/repository-ruleset-providers.ts +46 -0
  166. package/src/github/repository-ruleset-wire.test.ts +38 -27
  167. package/src/github/repository-ruleset.ts +27 -3
  168. package/src/litellm/fake-litellm.ts +83 -78
  169. package/src/litellm/fake-stack.ts +13 -5
  170. package/src/litellm/index.ts +7 -21
  171. package/src/litellm/operations.ts +149 -0
  172. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
  173. package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
  174. package/src/litellm/pass-through-endpoint.test.ts +8 -8
  175. package/src/litellm/pass-through-endpoint.ts +21 -31
  176. package/src/litellm/pass-through-errors.ts +37 -0
  177. package/src/litellm/pass-through-form.ts +20 -10
  178. package/src/litellm/providers.ts +8 -7
  179. package/src/paperless/custom-field.test.ts +49 -40
  180. package/src/paperless/custom-field.ts +57 -33
  181. package/src/paperless/document-type.test.ts +48 -0
  182. package/src/paperless/document-type.ts +51 -22
  183. package/src/paperless/fake-paperless.ts +63 -72
  184. package/src/paperless/index.ts +7 -13
  185. package/src/paperless/matching-locate.test.ts +102 -0
  186. package/src/paperless/matching-locate.ts +80 -86
  187. package/src/paperless/matching-types.ts +49 -16
  188. package/src/paperless/matching.ts +77 -90
  189. package/src/paperless/providers.ts +14 -10
  190. package/src/paperless/storage-path.test.ts +56 -0
  191. package/src/paperless/storage-path.ts +53 -24
  192. package/src/paperless/tag-identity.test.ts +127 -0
  193. package/src/paperless/tag.test.ts +100 -0
  194. package/src/paperless/tag.ts +62 -32
  195. package/src/proxmox/acl.test.ts +145 -0
  196. package/src/proxmox/acl.ts +128 -150
  197. package/src/proxmox/distilled-pve.test.ts +231 -0
  198. package/src/proxmox/distilled-pve.ts +147 -0
  199. package/src/proxmox/members-timeout.test.ts +161 -0
  200. package/src/proxmox/members.test.ts +9 -0
  201. package/src/proxmox/members.ts +36 -2
  202. package/dist/litellm/client.d.ts +0 -76
  203. package/dist/litellm/client.d.ts.map +0 -1
  204. package/dist/litellm/credentials.d.ts +0 -48
  205. package/dist/litellm/credentials.d.ts.map +0 -1
  206. package/dist/litellm/generated/pass-through.d.ts +0 -71
  207. package/dist/litellm/generated/pass-through.d.ts.map +0 -1
  208. package/dist/paperless/client.d.ts +0 -37
  209. package/dist/paperless/client.d.ts.map +0 -1
  210. package/dist/paperless/credentials.d.ts +0 -40
  211. package/dist/paperless/credentials.d.ts.map +0 -1
  212. package/dist/paperless/errors.d.ts +0 -49
  213. package/dist/paperless/errors.d.ts.map +0 -1
  214. package/src/litellm/client.ts +0 -195
  215. package/src/litellm/credentials.ts +0 -73
  216. package/src/litellm/generated/pass-through.ts +0 -73
  217. package/src/paperless/client.ts +0 -88
  218. package/src/paperless/credentials.ts +0 -69
  219. package/src/paperless/errors.ts +0 -62
  220. package/src/paperless/matching-identity.test.ts +0 -194
  221. package/src/paperless/matching.test.ts +0 -128
@@ -6,12 +6,28 @@
6
6
  * ★ launchdProviders() TOO when the stack uses caddyWithFile(): the file is a launchd-subpath
7
7
  * HostFile, written through that subpath's HostRunner.
8
8
  * ⚠️ The default is localCaddyAdmin(): `http://127.0.0.1:2019`, Caddy's default admin listener.
9
- * Pass another CaddyAdmin for a unix socket, a narrowed `admin { origins }`, or an SSH forward.
9
+ * Pass another CaddyTransport for a unix socket, a narrowed `admin { origins }`, or an SSH
10
+ * forward — `localCaddyAdmin({ address, hostHeader })` builds one; a consumer can provide any
11
+ * other `{ endpoint, listener, layer }` reaching `@distilled.cloud/caddy`'s own `Credentials`
12
+ * and `HttpClient.HttpClient`.
13
+ *
14
+ * ⚠️ `Layer.provideMerge`, NEVER PLAIN `Layer.provide`, for `caddyAdminLayer`. `CaddyConfigProvider()`'s
15
+ * `read`/`diff`/`reconcile` handlers each still need `CaddyAdminService` and
16
+ * `@distilled.cloud/caddy`'s `Credentials`/`HttpClient` — Alchemy's own `Provider.effect` types
17
+ * those as part of the LAYER's overall requirement (`ProviderService`'s per-handler `Req` type
18
+ * parameters, `alchemy/Provider.ts`), for exactly this reason. Plain `Layer.provide` SEALS a
19
+ * layer — it satisfies `CaddyConfigProvider()`'s requirement only for BUILDING the `Provider`
20
+ * value (the `admin` in its outer `Effect.gen`), then hides those services from anything that
21
+ * runs LATER, when the returned `read`/`diff`/`reconcile` FUNCTIONS actually execute. MEASURED
22
+ * 2026-09-23: with plain `Layer.provide`, `caddyProviders()`'s own `reconcile` handler dies with
23
+ * "Service not found: homeflare/caddy/CaddyAdmin" the moment the ENGINE calls it. `provideMerge`
24
+ * feeds `caddyAdminLayer`'s output into `CaddyConfigProvider()`'s requirement AND keeps that same
25
+ * output live in the result, so every later handler call still runs with it in context.
10
26
  */
11
27
  import * as Layer from 'effect/Layer';
12
- import { type CaddyAdmin, caddyAdminLayer } from './admin.ts';
28
+ import { type CaddyTransport, caddyAdminLayer } from './admin.ts';
13
29
  import { CaddyConfigProvider } from './config.ts';
14
30
  import { localCaddyAdmin } from './local-admin.ts';
15
31
 
16
- export const caddyProviders = (admin: CaddyAdmin = localCaddyAdmin()) =>
17
- CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(admin)));
32
+ export const caddyProviders = (transport: CaddyTransport = localCaddyAdmin()) =>
33
+ CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(transport)));
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `declareRepoBaseline` through Alchemy's own Plan+Apply (repo-apply-order-fake-stack.ts) — not
3
+ * the pure form functions — proving the K5 apply-ordering fix (repo-auto-merge-gate.ts) actually
4
+ * changes what the ENGINE schedules, the same reasoning `litellm/pass-through-endpoint-replace.
5
+ * test.ts`'s header gives for driving the engine instead of asserting a `diff` answer.
6
+ *
7
+ * ★ THE "TURNING ON" TESTS DEPLOY TWICE, ON PURPOSE. K5's own scenario is literally "a repo
8
+ * MOVES from checks:[] to checks:['x']" — an UPDATE to an already-live repo and ruleset, not a
9
+ * cold create. A first version of this file proved the ordering fix with a single deploy from
10
+ * empty state instead (an ADVERSARIAL REVIEW, 2026-09-24, caught this: that single-deploy
11
+ * shape is a cold create, and the fake ruleset provider back then had no notion that a real
12
+ * ruleset create 404s against a repository that does not exist yet — so the test could not
13
+ * have caught the ordering fix forcing exactly that impossible order on a genuine cold create).
14
+ * Deploying `checks: []` first, then `checks: ['ci']`, makes both resources already live by
15
+ * the time the gate kicks in — the actual K5 shape, and no longer a cold create.
16
+ */
17
+ import { beforeEach, describe, expect, test } from 'bun:test';
18
+ import { declareRepoBaseline } from './declare-repo-baseline.ts';
19
+ import { type World, fakeGithubProviders, makeWorld } from './repo-apply-order-fake-providers.ts';
20
+ import { fakeStack } from './repo-apply-order-fake-stack.ts';
21
+
22
+ let world: World;
23
+ let stack: ReturnType<typeof fakeStack>;
24
+ beforeEach(() => {
25
+ world = makeWorld();
26
+ stack = fakeStack(fakeGithubProviders(world));
27
+ });
28
+
29
+ const declare = (checks: readonly string[]) =>
30
+ declareRepoBaseline('acme', {
31
+ owner: 'taslabs-net',
32
+ repository: 'acme',
33
+ checks,
34
+ visibility: 'private',
35
+ });
36
+
37
+ describe('an existing repo moving from checks: [] to checks: ["ci"]', () => {
38
+ beforeEach(async () => {
39
+ // Establish both as already-live, exactly like a repo baselined with no checks yet.
40
+ await stack.deploy(declare([]));
41
+ world.calls.length = 0;
42
+ });
43
+
44
+ test('the update that turns auto-merge on applies the ruleset before the repository', async () => {
45
+ const planned = await stack.deploy(declare(['ci']));
46
+
47
+ expect(planned).toEqual({ acme: 'update', 'acme-ruleset': 'update' });
48
+ expect(world.calls).toEqual(['ruleset', 'repository']);
49
+ // The value sent is still exactly `true` — only the ordering changed.
50
+ expect(world.repositoryAllowAutoMerge).toBe(true);
51
+ });
52
+
53
+ test('a ruleset apply failure leaves allowAutoMerge untouched', async () => {
54
+ world.rulesetShouldFail = true;
55
+
56
+ await expect(stack.deploy(declare(['ci']))).rejects.toBeDefined();
57
+
58
+ // The repository's `reconcile` never ran at all — blocked on the ruleset's Output, which
59
+ // never resolved — so `allowAutoMerge` was never even sent, let alone turned on with no
60
+ // required check live. It is still `false` from the first deploy above.
61
+ expect(world.calls).toEqual(['ruleset']);
62
+ expect(world.repositoryAllowAutoMerge).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe('auto-merge already/staying off (checks: [])', () => {
67
+ test('declares both with no ordering edge forced between them', async () => {
68
+ const planned = await stack.deploy(declare([]));
69
+
70
+ expect(planned).toEqual({ acme: 'create', 'acme-ruleset': 'create' });
71
+ expect(world.calls.sort()).toEqual(['repository', 'ruleset']);
72
+ // `allowAutoMerge` was sent as a plain `false`, never gated on the ruleset.
73
+ expect(world.repositoryAllowAutoMerge).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe('a repo and ruleset created together for the first time, checks from day one', () => {
78
+ test('fails clearly instead of silently fail-open (known, documented limit — see file header)', async () => {
79
+ await expect(stack.deploy(declare(['ci']))).rejects.toThrow(/does not exist yet/);
80
+
81
+ // The ruleset's own create is what refuses — never the repository's, and never a state
82
+ // where allowAutoMerge went live with nothing to wait on.
83
+ expect(world.calls).toEqual(['ruleset']);
84
+ expect(world.repositoryAllowAutoMerge).toBeUndefined();
85
+ });
86
+ });
@@ -8,16 +8,34 @@
8
8
  * visibility: 'private',
9
9
  * });
10
10
  *
11
- * Provide `GitHub.providers()` AND `RepositoryRulesetProvider()` — this subpath ships no
12
- * provider of its own, the same as `declareRepoPolicy`. Both resources are `retain` and
13
- * `adopt(true)` ALWAYS, unlike `declareRepoPolicy`'s optional `adopt` — a baseline this widely
14
- * shared is meant for a checkout adopting the estate's existing repository and ruleset by name,
15
- * never for creating one out from under a caller who forgot the flag (H1: a name match is not
16
- * ours until adopted).
11
+ * Provide `repoPolicyProviders()` (repository-ruleset-providers.ts) — this subpath ships no
12
+ * provider of its own beyond the ruleset bridge, the same as `declareRepoPolicy`. Both resources
13
+ * are `retain` and `adopt(true)` ALWAYS, unlike `declareRepoPolicy`'s optional `adopt` — a
14
+ * baseline this widely shared is meant for a checkout adopting the estate's existing repository
15
+ * and ruleset by name, never for creating one out from under a caller who forgot the flag (H1: a
16
+ * name match is not ours until adopted).
17
17
  *
18
18
  * ⛔ NO IMPORT FROM `@homeflare/config`, either direction — see `renderRepoShape`'s own header
19
19
  * in `packages/config/src/repo-shape/render.ts`. A caller spreads `renderRepoShape(shape)
20
20
  * .policy` into these options itself; `checks` only needs to be `readonly string[]`.
21
+ *
22
+ * ⛔ APPLY ORDER: `allowAutoMerge` NEVER GOES LIVE BEFORE THE RULESET IT WAITS ON. When `checks`
23
+ * is non-empty, the ruleset is declared and applied FIRST, and the repository's `allowAutoMerge`
24
+ * is threaded through `gateAutoMergeOnRuleset` (repo-auto-merge-gate.ts) so Alchemy's own
25
+ * Output-in-props ordering — not declaration order — makes the engine wait for it. See that
26
+ * file's header for the full mechanism, the K5 hazard it closes, and why the reverse direction
27
+ * (checks removed) needs no matching edge.
28
+ * ⚠️ A REPOSITORY THAT DOES NOT YET EXIST: `adopt(true)` above does NOT refuse to create — with
29
+ * nothing live to adopt, Alchemy creates from scratch regardless (`AdoptPolicy`'s own contract:
30
+ * `undefined` observed state means create, adopt policy or not). The paragraph above says this
31
+ * baseline is "never for creating one out from under a caller who forgot the flag", but nothing
32
+ * enforces that; it is the caller's discipline, not this function's. If it IS ever called for a
33
+ * repo+ruleset that do not exist yet with `checks` non-empty from day one, the forced
34
+ * ruleset-first order above now makes the ruleset's create fail — GitHub's own ruleset API
35
+ * 404s on a repository that does not exist — instead of racing. That failure is explicit and
36
+ * tested (declare-repo-baseline-apply-order.test.ts's "created together" case), not a silent
37
+ * fail-open, but it is still a real usability regression for that one scenario: create the bare
38
+ * repository first (a prior deploy, or a separate `GitHub.Repository` call).
21
39
  */
22
40
  import { adopt } from 'alchemy/AdoptPolicy';
23
41
  import * as GitHub from 'alchemy/GitHub';
@@ -28,6 +46,7 @@ import {
28
46
  repoBaselineRuleset,
29
47
  repoBaselineSettings,
30
48
  } from './repo-baseline-data.ts';
49
+ import { gateAutoMergeOnRuleset } from './repo-auto-merge-gate.ts';
31
50
  import { RepositoryRuleset } from './repository-ruleset.ts';
32
51
 
33
52
  export interface DeclareRepoBaselineOptions extends RepoBaselineInput {}
@@ -36,7 +55,21 @@ export const declareRepoBaseline = (id: string, options: DeclareRepoBaselineOpti
36
55
  Effect.gen(function* () {
37
56
  const owned = <A, R>(resource: Effect.Effect<A, never, R>) =>
38
57
  resource.pipe(RemovalPolicy.retain(), adopt(true));
39
- const repository = yield* owned(GitHub.Repository(id, repoBaselineSettings(options)));
58
+ const settings = repoBaselineSettings(options);
59
+
60
+ // Auto-merge is turning on (or staying on): declare and apply the ruleset FIRST, so its
61
+ // rulesetId is available to gate the repository's allowAutoMerge — see the file header.
62
+ if (settings.allowAutoMerge) {
63
+ const ruleset = yield* owned(
64
+ RepositoryRuleset(`${id}-ruleset`, repoBaselineRuleset(options)),
65
+ );
66
+ const repository = yield* owned(
67
+ GitHub.Repository(id, gateAutoMergeOnRuleset(settings, ruleset)),
68
+ );
69
+ return { repository, ruleset };
70
+ }
71
+
72
+ const repository = yield* owned(GitHub.Repository(id, settings));
40
73
  const ruleset = yield* owned(RepositoryRuleset(`${id}-ruleset`, repoBaselineRuleset(options)));
41
74
  return { repository, ruleset };
42
75
  });
@@ -3,10 +3,13 @@
3
3
  * one bridge resource this house builds atop upstream GitHub: `GitHub.RepositoryRuleset`.
4
4
  *
5
5
  * ⚠️ `declareRepoPolicy`/`declareRepoBaseline` COMPOSE UPSTREAM `GitHub.Repository` PLUS THIS
6
- * SUBPATH'S OWN `RepositoryRulesetProvider()` — provide BOTH `GitHub.providers()` and
7
- * `RepositoryRulesetProvider()`. Before 2026-09-23 this subpath shipped no provider at all,
8
- * composing only vendor resources; `RepositoryRuleset` is the one exception, and
9
- * repository-ruleset.ts's header says why upstream's own `Ruleset` cannot fill the gap.
6
+ * SUBPATH'S OWN `RepositoryRulesetProvider()` — provide `repoPolicyProviders()`
7
+ * (repository-ruleset-providers.ts), the composed `GitHub.providers()` +
8
+ * `RepositoryRulesetProvider()` layer that actually typechecks (2026-09-24: writing the two
9
+ * side by side leaves `RepositoryRulesetProvider()`'s own `GitHubCredentials` requirement
10
+ * unfed). Before 2026-09-23 this subpath shipped no provider at all, composing only vendor
11
+ * resources; `RepositoryRuleset` is the one exception, and repository-ruleset.ts's header
12
+ * says why upstream's own `Ruleset` cannot fill the gap.
10
13
  * ★ WHY THE POLICY HELPERS EXIST. "Open a pull request, let auto-merge land it" is safe only
11
14
  * when five repository properties and four ruleset rules all agree, and every way of getting
12
15
  * one of them wrong is silent — an auto-merge that lands on red, a force push that rewrites
@@ -42,6 +45,7 @@ export {
42
45
  type RepositoryRulesetRules,
43
46
  type RepositoryRulesetStatusChecksRule,
44
47
  } from './repository-ruleset.ts';
48
+ export { repoPolicyProviders } from './repository-ruleset-providers.ts';
45
49
  export { RULE_TYPE_COVERAGE, type RepositoryRuleType } from './repository-ruleset-constraints.ts';
46
50
  export {
47
51
  BypassActorNarrowed,
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Fully in-memory `Provider.succeed(...)` fakes for `GitHub.Repository` and `RepositoryRuleset`
3
+ * — no octokit, no `GitHubCredentials`, no network. Unlike `repository-ruleset-fake-octokit.ts`
4
+ * (a fake HTTP client fed into the REAL provider's `reconcile`), these fakes replace the provider
5
+ * entirely, because what `declare-repo-baseline-apply-order.test.ts` and
6
+ * `repo-policy-apply-order.test.ts` measure is the ENGINE's own scheduling — which resource's
7
+ * `reconcile` runs first, and whether one's failure reaches the other — not GitHub's wire
8
+ * protocol. `Provider.succeed`'s runtime lookup (`tryFindProviderRegistrationByType`,
9
+ * `node_modules/alchemy/src/Provider.ts`) checks the INDIVIDUAL `Provider<R>` tag before falling
10
+ * back to a `GitHub.Providers` collection, so registering these two standalone providers is
11
+ * enough regardless of `GitHub.Repository`'s own declared `Providers` slot — the same shortcut
12
+ * `RepositoryRulesetProvider()` already relies on.
13
+ *
14
+ * ⚠️ NO LIVE NETWORK, EVER. Nothing here imports `octokit`/`GitHubCredentials`, so a test built
15
+ * on this file cannot make a real GitHub call even by accident — required by this PR's own
16
+ * "no live GitHub writes" rule.
17
+ *
18
+ * ⚠️ THE FAKE RULESET REFUSES A REPO THAT DOESN'T EXIST YET, ON PURPOSE — mirroring GitHub's own
19
+ * `POST /repos/{owner}/{repo}/rulesets`, which 404s on a repo that hasn't been created. A first
20
+ * ADVERSARIAL REVIEW pass (2026-09-24) on this PR found that the ordering fix forces the
21
+ * ruleset to apply before the repository whenever auto-merge turns on — safe for an UPDATE
22
+ * (the repo already exists), but a real hazard for a repo+ruleset created together for the
23
+ * FIRST time with checks set from day one, and the original version of this fake modeled no
24
+ * such constraint, so its own tests could not have caught that regression. It now can — see
25
+ * `declare-repo-baseline-apply-order.test.ts`'s "a repo and ruleset created together" test.
26
+ */
27
+ import * as GitHub from 'alchemy/GitHub';
28
+ import * as Provider from 'alchemy/Provider';
29
+ import * as Effect from 'effect/Effect';
30
+ import * as Layer from 'effect/Layer';
31
+ import { RepositoryRuleset, type RepositoryRulesetProps } from './repository-ruleset.ts';
32
+
33
+ /** One resource type's name, in the order its fake `reconcile` was actually invoked. */
34
+ export type CallLog = ('repository' | 'ruleset')[];
35
+
36
+ export interface World {
37
+ readonly calls: CallLog;
38
+ /** `owner/name` of every repository the fake repository provider has reconciled at least
39
+ * once — what the fake ruleset provider checks before allowing a create. */
40
+ readonly liveRepositories: Set<string>;
41
+ /** Set before `deploy` to make the ruleset's `reconcile` fail instead of succeeding, even
42
+ * against a repository already in `liveRepositories`. */
43
+ rulesetShouldFail: boolean;
44
+ /** `news.allowAutoMerge` as the repository's fake `reconcile` last observed it — `undefined`
45
+ * until (unless) the repository's `reconcile` actually runs. */
46
+ repositoryAllowAutoMerge: boolean | undefined;
47
+ }
48
+
49
+ export const makeWorld = (): World => ({
50
+ calls: [],
51
+ liveRepositories: new Set(),
52
+ rulesetShouldFail: false,
53
+ repositoryAllowAutoMerge: undefined,
54
+ });
55
+
56
+ let sequence = 0;
57
+ const nextId = () => {
58
+ sequence += 1;
59
+ return sequence;
60
+ };
61
+
62
+ const fakeRepositoryProvider = (world: World) =>
63
+ Provider.succeed(GitHub.Repository, {
64
+ list: () => Effect.succeed([]),
65
+ reconcile: ({ news }: { readonly news: GitHub.RepositoryProps }) =>
66
+ Effect.sync(() => {
67
+ world.calls.push('repository');
68
+ world.repositoryAllowAutoMerge = news.allowAutoMerge;
69
+ const id = nextId();
70
+ const fullName = `${news.owner}/${news.name}`;
71
+ world.liveRepositories.add(fullName);
72
+ return {
73
+ repoId: id,
74
+ nodeId: `repo-node-${id}`,
75
+ fullName,
76
+ htmlUrl: `https://github.com/${fullName}`,
77
+ gitUrl: `git://github.com/${fullName}.git`,
78
+ sshUrl: `git@github.com:${fullName}.git`,
79
+ cloneUrl: `https://github.com/${fullName}.git`,
80
+ defaultBranch: 'main',
81
+ createdAt: new Date(0).toISOString(),
82
+ updatedAt: new Date(0).toISOString(),
83
+ };
84
+ }),
85
+ delete: () => Effect.void,
86
+ });
87
+
88
+ const fakeRulesetProvider = (world: World) =>
89
+ Provider.succeed(RepositoryRuleset, {
90
+ list: () => Effect.succeed([]),
91
+ reconcile: ({ news }: { readonly news: RepositoryRulesetProps }) =>
92
+ Effect.gen(function* () {
93
+ world.calls.push('ruleset');
94
+ if (world.rulesetShouldFail) {
95
+ return yield* Effect.fail(new Error('fake ruleset apply failure'));
96
+ }
97
+ const fullName = `${news.owner}/${news.repository}`;
98
+ if (!world.liveRepositories.has(fullName)) {
99
+ // The real `POST /repos/{owner}/{repo}/rulesets` 404s here — the target repository
100
+ // does not exist yet. See this file's header.
101
+ return yield* Effect.fail(
102
+ new Error(`fake GitHub 404: repository '${fullName}' does not exist yet`),
103
+ );
104
+ }
105
+ const id = nextId();
106
+ return {
107
+ rulesetId: id,
108
+ nodeId: `ruleset-node-${id}`,
109
+ name: news.name,
110
+ createdAt: new Date(0).toISOString(),
111
+ updatedAt: new Date(0).toISOString(),
112
+ };
113
+ }),
114
+ delete: () => Effect.void,
115
+ });
116
+
117
+ /** Both fakes, merged — everything `declareRepoBaseline`/`declareRepoPolicy` need. */
118
+ export const fakeGithubProviders = (world: World) =>
119
+ Layer.mergeAll(fakeRepositoryProvider(world), fakeRulesetProvider(world));
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Alchemy's own `Plan.make`/`apply`, run in-process over an in-memory state store, for
3
+ * `declare-repo-baseline-apply-order.test.ts` and `repo-policy-apply-order.test.ts`.
4
+ *
5
+ * ★ SAME SHAPE AS `litellm/fake-stack.ts` AND `openbao/fake-stack.ts` — copied, not
6
+ * reinvented: those two files' own headers explain why the ENGINE has to run (a `diff`/`props`
7
+ * answer proves nothing about what Apply actually schedules) rather than calling provider
8
+ * handlers directly, which is exactly what this PR needs to prove about apply ORDER. The casts
9
+ * below sit at the same typed boundary theirs do — `Alchemy.Stack` types its body against
10
+ * every service a CLI run provides; this harness's body only ever needs the providers given.
11
+ */
12
+ import * as Alchemy from 'alchemy';
13
+ import { AdoptPolicy } from 'alchemy/AdoptPolicy';
14
+ import { provideFreshArtifactStore } from 'alchemy/Artifacts';
15
+ import * as Effect from 'effect/Effect';
16
+ import * as Layer from 'effect/Layer';
17
+
18
+ export type Planned = Readonly<Record<string, string>>;
19
+ export type StackBody = Effect.Effect<unknown, unknown, unknown>;
20
+ export type DeployOptions = { readonly adopt?: boolean };
21
+
22
+ export interface FakeStack {
23
+ /** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
24
+ readonly deploy: (body: StackBody, options?: DeployOptions) => Promise<Planned>;
25
+ }
26
+
27
+ type Node = { readonly action: string };
28
+ type PlanView = {
29
+ readonly resources: Readonly<Record<string, Node>>;
30
+ readonly deletions: Readonly<Record<string, Node | undefined>>;
31
+ };
32
+
33
+ const actionsOf = (plan: PlanView): Planned => {
34
+ const out: Record<string, string> = {};
35
+ for (const [fqn, node] of Object.entries(plan.resources)) out[fqn] = node.action;
36
+ for (const [fqn, node] of Object.entries(plan.deletions)) {
37
+ if (node !== undefined) out[fqn] = node.action;
38
+ }
39
+ return out;
40
+ };
41
+
42
+ export const fakeStack = <ROut, E, RIn>(
43
+ providers: Layer.Layer<ROut, E, RIn>,
44
+ name = 'RepoApplyOrderStack',
45
+ ): FakeStack => {
46
+ const state = Alchemy.inMemoryState();
47
+ const stack = Alchemy.Stack as unknown as (
48
+ stackName: string,
49
+ options: { providers: typeof providers; state: typeof state },
50
+ body: StackBody,
51
+ ) => Effect.Effect<{ readonly services: never }, unknown, never>;
52
+ const plan = Alchemy.Plan.make as unknown as (
53
+ compiled: unknown,
54
+ ) => Effect.Effect<PlanView, unknown, never>;
55
+ const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
56
+
57
+ const deploy = (body: StackBody, options: DeployOptions = {}) =>
58
+ Effect.gen(function* () {
59
+ const compiled = yield* stack(name, { providers, state }, body);
60
+ return yield* Effect.gen(function* () {
61
+ const planned = yield* plan(compiled);
62
+ yield* apply(planned);
63
+ return actionsOf(planned);
64
+ }).pipe(provideFreshArtifactStore, Effect.provide(Layer.succeedContext(compiled.services)));
65
+ }).pipe(
66
+ options.adopt === undefined ? (e) => e : Effect.provideService(AdoptPolicy, options.adopt),
67
+ Effect.provideService(Alchemy.Stage, 'test'),
68
+ Effect.scoped,
69
+ );
70
+
71
+ return {
72
+ deploy: (body, options) => Effect.runPromise(deploy(body, options) as Effect.Effect<Planned>),
73
+ };
74
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `gateAutoMergeOnRuleset` — the one place `declareRepoBaseline` and `declareRepoPolicy` thread
3
+ * a real apply-ordering edge from `GitHub.Repository`'s `allowAutoMerge` to the sibling
4
+ * `RepositoryRuleset`, so a deploy can never turn auto-merge on before the ruleset's required
5
+ * checks/approvals are actually live.
6
+ *
7
+ * ⛔ K5 (kit PR 216, 2026-09-24): both callers declare `GitHub.Repository` and `RepositoryRuleset`
8
+ * as two INDEPENDENT resources with no dependency edge between them. When a repo moves from
9
+ * `checks:[]` to `checks:['x']`, `allowAutoMerge` flips to `true` in the very same deploy that
10
+ * adds the required check. If the Repository apply lands before the Ruleset apply — or the
11
+ * Ruleset apply fails outright — the repo ends up with auto-merge on and nothing required: `gh
12
+ * pr merge --auto` merges a CLEAN pull request immediately (memory `gh-auto-merge-merges-now`).
13
+ * That fail-open state is exactly what K5 exists to prevent.
14
+ *
15
+ * ★ THE MECHANISM — ALCHEMY ORDERS BY OUTPUT REFERENCES IN PROPS, NOT DECLARATION ORDER.
16
+ * `Plan.ts` (~l.1031-1038): a resource's upstream deps are `Output.upstreamAny(resource.Props)`.
17
+ * `Apply.ts` (~l.760-762, ~l.909): `waitForDeps(allUpstreamFqns())` — built the same way from
18
+ * `Output.resolveUpstream(node.props)` — runs before `reconcile`. See the kit's own
19
+ * `alchemy-output-refs-order-resources` memory. `GitHub.Repository`'s upstream `RepositoryProps`
20
+ * has no spare field: `reconcile` (`node_modules/alchemy/src/GitHub/Repository.ts`) sends every
21
+ * declared prop straight to Octokit's create/update calls, so there is nothing to smuggle an
22
+ * unrelated Output into. `allowAutoMerge` itself is the vehicle instead: its value is still
23
+ * exactly `true` whenever this function is called with it already `true` — only now computed
24
+ * AFTER `ruleset.rulesetId` resolves via `Output.map`, instead of written as a literal. That
25
+ * single Output reference is enough for `Output.upstreamAny` to record the ruleset as this
26
+ * Repository's upstream dependency, so the engine applies the ruleset first.
27
+ *
28
+ * ★ WHY THIS NEEDS NO REVERSE-DIRECTION EDGE. The opposite transition — checks/approvals
29
+ * REMOVED, so `allowAutoMerge` turns off — cannot silently race the other way. Neither caller
30
+ * ever declares `rules.requiredStatusChecks: false` or omits `rules.pullRequest` on purpose;
31
+ * dropping `checks`/`requiredApprovals` from the options just OMITS the corresponding rule key.
32
+ * `requiredChecksOmissionRefusal` and `undeclaredLiveRuleRefusal` (repository-ruleset-guards.ts)
33
+ * both refuse — unconditionally, regardless of apply order — to drop a rule that is still LIVE
34
+ * by omission. So the worst reachable state, whichever resource applies first, is: Repository
35
+ * succeeds (auto-merge off, the correct direction anyway) and Ruleset refuses (the live rule
36
+ * stays required) — never fail-open. Proved by the ordering/failure tests beside this file.
37
+ */
38
+ import * as Output from 'alchemy/Output';
39
+
40
+ /** The one attribute this gate needs from a declared `RepositoryRuleset`. */
41
+ export interface AutoMergeGateSource {
42
+ readonly rulesetId: Output.Output<number, never>;
43
+ }
44
+
45
+ /**
46
+ * Returns `settings` unchanged when `allowAutoMerge` is falsy — nothing to gate, and every
47
+ * existing declaration with auto-merge off keeps its exact prior props (no diff, no replan).
48
+ * Otherwise returns `settings` with `allowAutoMerge` recomputed as an Output.map over
49
+ * `ruleset.rulesetId` — still resolves to `true`, now with a real dependency edge attached.
50
+ */
51
+ export function gateAutoMergeOnRuleset<P extends { readonly allowAutoMerge?: boolean }>(
52
+ settings: P,
53
+ ruleset: AutoMergeGateSource,
54
+ ): P | (Omit<P, 'allowAutoMerge'> & { readonly allowAutoMerge: Output.Output<boolean, never> }) {
55
+ if (!settings.allowAutoMerge) return settings;
56
+ return { ...settings, allowAutoMerge: Output.map(ruleset.rulesetId, () => true) };
57
+ }
@@ -36,6 +36,19 @@ describe('repoBaselineSettings', () => {
36
36
  visibility: undefined,
37
37
  });
38
38
  });
39
+
40
+ // K5 (2026-09-24): zero required checks means `gh pr merge --auto` has nothing to wait for —
41
+ // GitHub merges a CLEAN pull request immediately, with no review and no green run. Regression
42
+ // for repoBaselineSettings hardcoding `allowAutoMerge: true` regardless of `checks`.
43
+ test('zero checks turns auto-merge off — nothing would ever hold a merge back', () => {
44
+ const settings = repoBaselineSettings({ ...input, checks: [] });
45
+ expect(settings.allowAutoMerge).toBe(false);
46
+ });
47
+
48
+ test('at least one check keeps auto-merge on', () => {
49
+ const settings = repoBaselineSettings({ ...input, checks: ['ci'] });
50
+ expect(settings.allowAutoMerge).toBe(true);
51
+ });
39
52
  });
40
53
 
41
54
  describe('repoBaselineRuleset', () => {
@@ -21,7 +21,16 @@ export interface RepoBaselineInput {
21
21
 
22
22
  /** `GitHub.Repository` fields this baseline owns. Every merge-automation caller across the 17
23
23
  * tray checkouts uses `--squash` (measured 2026-09-23; the only non-squash mentions are
24
- * GitLab-era comments in cliff.toml), so squash-only is not a per-repo choice. */
24
+ * GitLab-era comments in cliff.toml), so squash-only is not a per-repo choice.
25
+ *
26
+ * ⛔ K5 (2026-09-24): `allowAutoMerge` FOLLOWS `checks.length`, NOT A HARDCODED `true`. `gh pr
27
+ * merge --auto` (and the ruleset's own auto-merge) is a queue only while something is
28
+ * outstanding — with zero required status checks there is nothing to wait for, so GitHub
29
+ * merges a CLEAN pull request on the spot, with no review and no green run. This is the same
30
+ * failure `repo-policy-guards.ts`'s `assertAutoMergeWaits` refuses on the sibling `repoPolicy`
31
+ * path (five doors to the same "nothing outstanding" state); this baseline has no `autoMerge`
32
+ * opt-out prop to refuse WITH, so the fix here is unconditional rather than a thrown refusal —
33
+ * a repo with an empty `checks` list simply never gets auto-merge turned on by this baseline. */
25
34
  export function repoBaselineSettings(input: RepoBaselineInput) {
26
35
  return {
27
36
  owner: input.owner,
@@ -31,7 +40,7 @@ export function repoBaselineSettings(input: RepoBaselineInput) {
31
40
  allowSquashMerge: true,
32
41
  allowMergeCommit: false,
33
42
  allowRebaseMerge: false,
34
- allowAutoMerge: true,
43
+ allowAutoMerge: input.checks.length > 0,
35
44
  deleteBranchOnMerge: true,
36
45
  hasWiki: false,
37
46
  };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `declareRepoPolicy` through Alchemy's own Plan+Apply (repo-apply-order-fake-stack.ts) — the
3
+ * same K5 apply-ordering proof as declare-repo-baseline-apply-order.test.ts, for the OTHER
4
+ * caller of `gateAutoMergeOnRuleset`. See that file's header for why the "turning on" tests
5
+ * deploy twice (K5's own scenario is an update to an already-live repo+ruleset, not a cold
6
+ * create) and for the cold-create limitation this suite also tests explicitly.
7
+ */
8
+ import { beforeEach, describe, expect, test } from 'bun:test';
9
+ import { type World, fakeGithubProviders, makeWorld } from './repo-apply-order-fake-providers.ts';
10
+ import { fakeStack } from './repo-apply-order-fake-stack.ts';
11
+ import { declareRepoPolicy } from './repo-policy.ts';
12
+
13
+ let world: World;
14
+ let stack: ReturnType<typeof fakeStack>;
15
+ beforeEach(() => {
16
+ world = makeWorld();
17
+ stack = fakeStack(fakeGithubProviders(world));
18
+ });
19
+
20
+ const declare = (checks: readonly string[]) =>
21
+ declareRepoPolicy('api', { owner: 'taslabs-net', repository: 'api', checks });
22
+
23
+ const declareOff = () =>
24
+ declareRepoPolicy('api', {
25
+ owner: 'taslabs-net',
26
+ repository: 'api',
27
+ checks: [],
28
+ autoMerge: false,
29
+ });
30
+
31
+ describe('an existing repo moving from checks: [] to checks: ["ci"]', () => {
32
+ beforeEach(async () => {
33
+ await stack.deploy(declareOff());
34
+ world.calls.length = 0;
35
+ });
36
+
37
+ test('the update that turns auto-merge on applies the ruleset before the repository', async () => {
38
+ const planned = await stack.deploy(declare(['ci']));
39
+
40
+ expect(planned).toEqual({ api: 'update', 'api-ruleset': 'update' });
41
+ expect(world.calls).toEqual(['ruleset', 'repository']);
42
+ expect(world.repositoryAllowAutoMerge).toBe(true);
43
+ });
44
+
45
+ test('a ruleset apply failure leaves allowAutoMerge untouched', async () => {
46
+ world.rulesetShouldFail = true;
47
+
48
+ await expect(stack.deploy(declare(['ci']))).rejects.toBeDefined();
49
+
50
+ expect(world.calls).toEqual(['ruleset']);
51
+ expect(world.repositoryAllowAutoMerge).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('auto-merge off (autoMerge: false, no checks)', () => {
56
+ test('declares both with no ordering edge forced between them', async () => {
57
+ const planned = await stack.deploy(declareOff());
58
+
59
+ expect(planned).toEqual({ api: 'create', 'api-ruleset': 'create' });
60
+ expect(world.calls.sort()).toEqual(['repository', 'ruleset']);
61
+ expect(world.repositoryAllowAutoMerge).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe('a repo and ruleset created together for the first time, checks from day one', () => {
66
+ test('fails clearly instead of silently fail-open (known, documented limit)', async () => {
67
+ await expect(stack.deploy(declare(['ci']))).rejects.toThrow(/does not exist yet/);
68
+
69
+ expect(world.calls).toEqual(['ruleset']);
70
+ expect(world.repositoryAllowAutoMerge).toBeUndefined();
71
+ });
72
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * TYPE-LEVEL test — a real `Alchemy.Stack(...)` whose body calls `declareRepoPolicy` AND
3
+ * `declareRepoBaseline`, wired with the documented `repoPolicyProviders()`. This is the test
4
+ * that would have caught 2026-09-24's bug (homeflare-builds bump PR 6, tsc TS2345):
5
+ * `RepositoryRuleset`'s `Resource<>` declaration put `GitHubCredentials` — not
6
+ * `Provider<RepositoryRuleset>` — in its 5th type parameter, so a stack BODY that only ever
7
+ * calls `declareRepoPolicy`/`declareRepoBaseline` failed `tsc`, and nothing in this repo's own
8
+ * `bun run check` exercised that path before now: every existing test in this family drives the
9
+ * resource's CRUD handlers directly (repository-ruleset-*.test.ts) or the pure form functions
10
+ * (repo-policy-form.test.ts), never a real `Alchemy.Stack(...)` body.
11
+ *
12
+ * ⚠️ BUILT, NEVER RUN. `Alchemy.Stack(...)` returns an unexecuted `Effect` — every combinator
13
+ * between here and a runtime (`.pipe`, `Effect.gen`, `Effect.flatMap`, `Object.assign`) is
14
+ * itself lazy (MEASURED by reading `alchemy/src/Stack.ts`'s own `Stack`/`make`: no
15
+ * `Effect.runSync`/`runPromise` anywhere in the construction path). Building the value is
16
+ * enough to make `tsc` check the body's inferred requirement type against `Stack`'s own
17
+ * `Req extends StackServices | ProviderServices` constraint — see repository-ruleset.ts's
18
+ * file header for what that constraint is and why `GitHubCredentials` used to fail it. This
19
+ * test never calls `Effect.runPromise`/`runSync`: no network, no `GitHubCredentials`
20
+ * resolved, no state written.
21
+ *
22
+ * ⚠️ `.pipe(Effect.orDie)` ON `declareRepoPolicy` IS UNRELATED TO WHAT THIS TEST CHECKS.
23
+ * `declareRepoPolicy` refuses a bad policy through `Effect.try`'s `catch`, so its error
24
+ * channel is `Error`, not `never` — a separate, pre-existing type surface (present since its
25
+ * original PR #98) that `Alchemy.Stack`'s own `eff: Effect.Effect<A, ConfigError, Req>` does
26
+ * not accept regardless of the `Req`/`Providers` bug this test targets. Dying on it here keeps
27
+ * that surface out of the way without papering over it — a real caller still has to decide how
28
+ * to handle a refused policy, which this test deliberately does not answer.
29
+ */
30
+ import { expect, test } from 'bun:test';
31
+ import * as Alchemy from 'alchemy';
32
+ import * as Effect from 'effect/Effect';
33
+ import { declareRepoBaseline } from './declare-repo-baseline.ts';
34
+ import { declareRepoPolicy } from './repo-policy.ts';
35
+ import { repoPolicyProviders } from './repository-ruleset-providers.ts';
36
+
37
+ test('declareRepoPolicy and declareRepoBaseline typecheck as one Alchemy.Stack body', () => {
38
+ const stack = Alchemy.Stack(
39
+ 'repo-policy-typecheck',
40
+ { providers: repoPolicyProviders(), state: Alchemy.inMemoryState() },
41
+ Effect.gen(function* () {
42
+ const policy = yield* declareRepoPolicy('api', {
43
+ owner: 'taslabs-net',
44
+ repository: 'api',
45
+ checks: ['ci', 'secret scan'],
46
+ }).pipe(Effect.orDie);
47
+ const baseline = yield* declareRepoBaseline('widgets', {
48
+ owner: 'taslabs-net',
49
+ repository: 'widgets',
50
+ checks: ['ci'],
51
+ visibility: 'private',
52
+ });
53
+ return { policy, baseline };
54
+ }),
55
+ );
56
+ // Not run — see file header. This only proves construction didn't throw synchronously; the
57
+ // real assertion is `tsc`'s, over the whole expression above.
58
+ expect(stack).toBeDefined();
59
+ });