@homeflare/alchemy 0.28.0 → 0.29.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 (210) 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 +63 -23
  42. package/dist/github/index.js.map +13 -12
  43. package/dist/github/repo-baseline-data.d.ts +10 -1
  44. package/dist/github/repo-baseline-data.d.ts.map +1 -1
  45. package/dist/github/repo-policy.d.ts +1 -1
  46. package/dist/github/repo-policy.d.ts.map +1 -1
  47. package/dist/github/repository-ruleset-errors.d.ts +3 -2
  48. package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
  49. package/dist/github/repository-ruleset-form.d.ts.map +1 -1
  50. package/dist/github/repository-ruleset-guards.d.ts +3 -2
  51. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  52. package/dist/github/repository-ruleset-octokit.d.ts +49 -0
  53. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
  54. package/dist/github/repository-ruleset-probe.d.ts +2 -1
  55. package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
  56. package/dist/github/repository-ruleset-props.d.ts +7 -4
  57. package/dist/github/repository-ruleset-props.d.ts.map +1 -1
  58. package/dist/github/repository-ruleset-providers.d.ts +42 -0
  59. package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
  60. package/dist/github/repository-ruleset.d.ts +26 -4
  61. package/dist/github/repository-ruleset.d.ts.map +1 -1
  62. package/dist/litellm/fake-litellm.d.ts +27 -16
  63. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  64. package/dist/litellm/fake-stack.d.ts +9 -4
  65. package/dist/litellm/fake-stack.d.ts.map +1 -1
  66. package/dist/litellm/index.d.ts +7 -5
  67. package/dist/litellm/index.d.ts.map +1 -1
  68. package/dist/litellm/index.js +53 -123
  69. package/dist/litellm/index.js.map +8 -8
  70. package/dist/litellm/operations.d.ts +60 -0
  71. package/dist/litellm/operations.d.ts.map +1 -0
  72. package/dist/litellm/pass-through-endpoint.d.ts +10 -28
  73. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  74. package/dist/litellm/pass-through-errors.d.ts +37 -0
  75. package/dist/litellm/pass-through-errors.d.ts.map +1 -0
  76. package/dist/litellm/pass-through-form.d.ts +11 -4
  77. package/dist/litellm/pass-through-form.d.ts.map +1 -1
  78. package/dist/litellm/providers.d.ts +7 -5
  79. package/dist/litellm/providers.d.ts.map +1 -1
  80. package/dist/netbox/prefix.d.ts +4 -4
  81. package/dist/paperless/custom-field.d.ts +21 -21
  82. package/dist/paperless/custom-field.d.ts.map +1 -1
  83. package/dist/paperless/document-type.d.ts +34 -4
  84. package/dist/paperless/document-type.d.ts.map +1 -1
  85. package/dist/paperless/fake-paperless.d.ts +36 -19
  86. package/dist/paperless/fake-paperless.d.ts.map +1 -1
  87. package/dist/paperless/index.d.ts +7 -2
  88. package/dist/paperless/index.d.ts.map +1 -1
  89. package/dist/paperless/index.js +164 -228
  90. package/dist/paperless/index.js.map +10 -13
  91. package/dist/paperless/matching-locate.d.ts +42 -20
  92. package/dist/paperless/matching-locate.d.ts.map +1 -1
  93. package/dist/paperless/matching-types.d.ts +47 -18
  94. package/dist/paperless/matching-types.d.ts.map +1 -1
  95. package/dist/paperless/matching.d.ts +30 -27
  96. package/dist/paperless/matching.d.ts.map +1 -1
  97. package/dist/paperless/providers.d.ts +13 -7
  98. package/dist/paperless/providers.d.ts.map +1 -1
  99. package/dist/paperless/storage-path.d.ts +34 -4
  100. package/dist/paperless/storage-path.d.ts.map +1 -1
  101. package/dist/paperless/tag.d.ts +43 -8
  102. package/dist/paperless/tag.d.ts.map +1 -1
  103. package/dist/proxmox/acl.d.ts +30 -23
  104. package/dist/proxmox/acl.d.ts.map +1 -1
  105. package/dist/proxmox/distilled-pve.d.ts +89 -0
  106. package/dist/proxmox/distilled-pve.d.ts.map +1 -0
  107. package/dist/proxmox/index.js +663 -609
  108. package/dist/proxmox/index.js.map +12 -11
  109. package/dist/proxmox/members.d.ts +29 -10
  110. package/dist/proxmox/members.d.ts.map +1 -1
  111. package/docs/caddy-admin.md +1 -1
  112. package/docs/caddy.md +3 -2
  113. package/docs/credentials.md +16 -0
  114. package/docs/litellm.md +21 -13
  115. package/docs/paperless.md +12 -0
  116. package/docs/peers.md +5 -0
  117. package/docs/repo-policy.md +16 -1
  118. package/docs/repository-ruleset.md +53 -13
  119. package/docs/upstream-conformance.md +126 -10
  120. package/package.json +5 -2
  121. package/src/caddy/admin-address.ts +71 -0
  122. package/src/caddy/admin-calls.ts +48 -100
  123. package/src/caddy/admin.ts +37 -41
  124. package/src/caddy/adopt-scope.test.ts +1 -1
  125. package/src/caddy/caddy-http-client.ts +229 -0
  126. package/src/caddy/config-adopt-provider.test.ts +139 -0
  127. package/src/caddy/config-adopt.test.ts +60 -126
  128. package/src/caddy/config-errors.ts +47 -0
  129. package/src/caddy/config-lifecycle.test.ts +55 -42
  130. package/src/caddy/config-lifecycle.ts +72 -162
  131. package/src/caddy/config-reconcile.ts +138 -0
  132. package/src/caddy/config.ts +36 -42
  133. package/src/caddy/fake-caddy.ts +20 -2
  134. package/src/caddy/index.ts +3 -8
  135. package/src/caddy/local-admin.test.ts +35 -21
  136. package/src/caddy/local-admin.ts +60 -167
  137. package/src/caddy/providers.test.ts +14 -6
  138. package/src/caddy/providers.ts +20 -4
  139. package/src/github/declare-repo-baseline.ts +6 -6
  140. package/src/github/index.ts +8 -4
  141. package/src/github/repo-baseline-data.test.ts +13 -0
  142. package/src/github/repo-baseline-data.ts +11 -2
  143. package/src/github/repo-policy-stack.test.ts +59 -0
  144. package/src/github/repo-policy.ts +3 -2
  145. package/src/github/repository-ruleset-errors.ts +6 -4
  146. package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
  147. package/src/github/repository-ruleset-exact-adopt.test.ts +14 -9
  148. package/src/github/repository-ruleset-form.ts +7 -3
  149. package/src/github/repository-ruleset-guards.ts +3 -2
  150. package/src/github/repository-ruleset-octokit.test.ts +141 -0
  151. package/src/github/repository-ruleset-octokit.ts +121 -30
  152. package/src/github/repository-ruleset-probe.ts +2 -1
  153. package/src/github/repository-ruleset-props.ts +7 -4
  154. package/src/github/repository-ruleset-providers.ts +46 -0
  155. package/src/github/repository-ruleset-wire.test.ts +38 -27
  156. package/src/github/repository-ruleset.ts +27 -3
  157. package/src/litellm/fake-litellm.ts +83 -78
  158. package/src/litellm/fake-stack.ts +13 -5
  159. package/src/litellm/index.ts +7 -21
  160. package/src/litellm/operations.ts +149 -0
  161. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
  162. package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
  163. package/src/litellm/pass-through-endpoint.test.ts +8 -8
  164. package/src/litellm/pass-through-endpoint.ts +21 -31
  165. package/src/litellm/pass-through-errors.ts +37 -0
  166. package/src/litellm/pass-through-form.ts +20 -10
  167. package/src/litellm/providers.ts +8 -7
  168. package/src/paperless/custom-field.test.ts +49 -40
  169. package/src/paperless/custom-field.ts +57 -33
  170. package/src/paperless/document-type.test.ts +48 -0
  171. package/src/paperless/document-type.ts +51 -22
  172. package/src/paperless/fake-paperless.ts +63 -72
  173. package/src/paperless/index.ts +7 -13
  174. package/src/paperless/matching-locate.test.ts +102 -0
  175. package/src/paperless/matching-locate.ts +80 -86
  176. package/src/paperless/matching-types.ts +49 -16
  177. package/src/paperless/matching.ts +77 -90
  178. package/src/paperless/providers.ts +14 -10
  179. package/src/paperless/storage-path.test.ts +56 -0
  180. package/src/paperless/storage-path.ts +53 -24
  181. package/src/paperless/tag-identity.test.ts +127 -0
  182. package/src/paperless/tag.test.ts +100 -0
  183. package/src/paperless/tag.ts +62 -32
  184. package/src/proxmox/acl.test.ts +145 -0
  185. package/src/proxmox/acl.ts +128 -150
  186. package/src/proxmox/distilled-pve.test.ts +231 -0
  187. package/src/proxmox/distilled-pve.ts +147 -0
  188. package/src/proxmox/members-timeout.test.ts +161 -0
  189. package/src/proxmox/members.test.ts +9 -0
  190. package/src/proxmox/members.ts +36 -2
  191. package/dist/litellm/client.d.ts +0 -76
  192. package/dist/litellm/client.d.ts.map +0 -1
  193. package/dist/litellm/credentials.d.ts +0 -48
  194. package/dist/litellm/credentials.d.ts.map +0 -1
  195. package/dist/litellm/generated/pass-through.d.ts +0 -71
  196. package/dist/litellm/generated/pass-through.d.ts.map +0 -1
  197. package/dist/paperless/client.d.ts +0 -37
  198. package/dist/paperless/client.d.ts.map +0 -1
  199. package/dist/paperless/credentials.d.ts +0 -40
  200. package/dist/paperless/credentials.d.ts.map +0 -1
  201. package/dist/paperless/errors.d.ts +0 -49
  202. package/dist/paperless/errors.d.ts.map +0 -1
  203. package/src/litellm/client.ts +0 -195
  204. package/src/litellm/credentials.ts +0 -73
  205. package/src/litellm/generated/pass-through.ts +0 -73
  206. package/src/paperless/client.ts +0 -88
  207. package/src/paperless/credentials.ts +0 -69
  208. package/src/paperless/errors.ts +0 -62
  209. package/src/paperless/matching-identity.test.ts +0 -194
  210. 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)));
@@ -8,12 +8,12 @@
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)
@@ -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,
@@ -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,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
+ });
@@ -7,8 +7,9 @@
7
7
  * checks: ['ci', 'secret scan'],
8
8
  * });
9
9
  *
10
- * Provide `GitHub.providers()` AND `RepositoryRulesetProvider()`; this subpath adds no GitHub
11
- * provider of its own.
10
+ * Provide `repoPolicyProviders()` (repository-ruleset-providers.ts) — the composed
11
+ * `GitHub.providers()` + `RepositoryRulesetProvider()` layer; this subpath adds no GitHub
12
+ * provider of its own beyond the ruleset bridge.
12
13
  *
13
14
  * ★ REWIRED 2026-09-23 onto `GitHub.RepositoryRuleset`, not upstream `GitHub.Ruleset` — the
14
15
  * ADOPT-SAFE bridge documented in repository-ruleset.ts. Only `builds` calls this, and its
@@ -123,8 +123,9 @@ export class RequiredChecksOmitted extends Data.TaggedError('RequiredChecksOmitt
123
123
  }
124
124
  }
125
125
 
126
- /** A context being ADDED to `requiredStatusChecks` has never reported success on the default
127
- * branch. Requiring it would leave every future pull request pending forever. */
126
+ /** A context being ADDED to `requiredStatusChecks` has never reported success anywhere this
127
+ * family looks (the default branch's tip, then recent merged PR heads — K2, bounded, not full
128
+ * history). Requiring it would leave every future pull request pending forever. */
128
129
  export class NeverReportedContext extends Data.TaggedError('NeverReportedContext')<{
129
130
  readonly owner: string;
130
131
  readonly repository: string;
@@ -133,8 +134,9 @@ export class NeverReportedContext extends Data.TaggedError('NeverReportedContext
133
134
  override get message(): string {
134
135
  return (
135
136
  `GitHub.RepositoryRuleset: "${this.context}" has never reported success on ` +
136
- `${this.owner}/${this.repository}'s default branch. Requiring it now would leave every ` +
137
- 'pull request waiting on a check that has never once reported — refused before any write.'
137
+ `${this.owner}/${this.repository}'s default branch tip or any of its recent merged pull ` +
138
+ 'requests. Requiring it now would leave every pull request waiting on a check that has ' +
139
+ 'never once reported — refused before any write.'
138
140
  );
139
141
  }
140
142
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * K1 (2026-09-23): `extraApprovalForUnattributedChanges` now declares `true`, not only `false`
3
+ * — split out of repository-ruleset-exact-adopt.test.ts purely to keep that file under the
4
+ * 250-line cap, not because the case is unrelated: this is the exact gap that file's own header
5
+ * named as blocking a true zero-write adopt for aop/magictransit/loggarr/
6
+ * doesthishelp-workeropen (all four carry `require_extra_approval_for_unattributed_changes:
7
+ * true` live).
8
+ *
9
+ * `gh api repos/taslabs-net/aop/rulesets/14572279`, re-read 2026-09-23 for this PR — the FULL
10
+ * record (not H15's own caution about paraphrasing was heeded the hard way: an earlier revision
11
+ * of this fixture hand-typed only the fields the design doc had quoted, which omitted
12
+ * `dismiss_stale_reviews_on_push: true` and `required_reviewers: []` — both of which
13
+ * `buildPullRequestRule` always sends, so a fixture missing them can never truly noop; the "zero
14
+ * writes" assertion below caught it): `bypass_actors: [{actor_id: 5, actor_type:
15
+ * "RepositoryRole", bypass_mode: "always"}]`, `rules: [deletion, non_fast_forward, pull_request]`,
16
+ * and the full `pull_request` parameters are `required_approving_review_count: 0`,
17
+ * `dismiss_stale_reviews_on_push: true`, `required_reviewers: []`, `require_code_owner_review:
18
+ * false`, `require_last_push_approval: false`, `required_review_thread_resolution: false`,
19
+ * `require_extra_approval_for_unattributed_changes: true`,
20
+ * `allowed_merge_methods: [merge, squash, rebase]`.
21
+ */
22
+ import { describe, expect, test } from 'bun:test';
23
+ import { makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
24
+ import {
25
+ BASE_PULL_REQUEST,
26
+ props,
27
+ reconcileWithOctokit,
28
+ run,
29
+ } from './repository-ruleset-test-helpers.ts';
30
+
31
+ const AOP_LIVE = {
32
+ id: 14572279,
33
+ name: 'main',
34
+ target: 'branch',
35
+ enforcement: 'active',
36
+ bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
37
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
38
+ rules: [
39
+ { type: 'deletion' },
40
+ { type: 'non_fast_forward' },
41
+ {
42
+ type: 'pull_request',
43
+ parameters: {
44
+ required_approving_review_count: 0,
45
+ dismiss_stale_reviews_on_push: true,
46
+ required_reviewers: [],
47
+ require_code_owner_review: false,
48
+ require_last_push_approval: false,
49
+ required_review_thread_resolution: false,
50
+ require_extra_approval_for_unattributed_changes: true,
51
+ allowed_merge_methods: ['merge', 'squash', 'rebase'],
52
+ },
53
+ },
54
+ ],
55
+ };
56
+
57
+ describe('K1: extraApprovalForUnattributedChanges completes exact-adopt', () => {
58
+ // Unlike repository-ruleset-exact-adopt.test.ts's bypass-only fixture (which deliberately
59
+ // keeps `BASE_PULL_REQUEST`'s `false`/`[squash]` to prove bypass round-trips on its own), this
60
+ // declares aop's FULL live `pull_request` shape — methods AND the extra-approval flag — so
61
+ // the whole ruleset, not just `bypassActors`, is now a genuine zero-write adopt.
62
+ test('aop: full live shape, extra-approval flag included, makes zero writes', async () => {
63
+ const fake = makeRecordingFake({ aop: AOP_LIVE });
64
+ const declaration = props({
65
+ repository: 'aop',
66
+ bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
67
+ rules: {
68
+ deletion: true,
69
+ nonFastForward: true,
70
+ pullRequest: {
71
+ ...BASE_PULL_REQUEST,
72
+ dismissStaleReviewsOnPush: true,
73
+ allowedMergeMethods: ['merge', 'squash', 'rebase'],
74
+ extraApprovalForUnattributedChanges: true,
75
+ },
76
+ },
77
+ });
78
+ await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 14572279 }));
79
+ expect(fake.writes).toHaveLength(0);
80
+ });
81
+
82
+ // The counterpart failure mode: declaring `false` against a live `true` is real drift (not a
83
+ // narrowing/widening question — that guard family is about `bypassActors` and rule presence,
84
+ // not this field's value) and must still produce exactly one `update`, never a silent noop.
85
+ test('aop: declaring false against a live true is drift, not a noop', async () => {
86
+ const fake = makeRecordingFake({ aop: AOP_LIVE });
87
+ const declaration = props({
88
+ repository: 'aop',
89
+ bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
90
+ rules: {
91
+ deletion: true,
92
+ nonFastForward: true,
93
+ pullRequest: {
94
+ ...BASE_PULL_REQUEST,
95
+ dismissStaleReviewsOnPush: true,
96
+ allowedMergeMethods: ['merge', 'squash', 'rebase'],
97
+ extraApprovalForUnattributedChanges: false,
98
+ },
99
+ },
100
+ });
101
+ await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 14572279 }));
102
+ expect(fake.writes).toHaveLength(1);
103
+ const pr = fake.writes[0]?.body?.rules?.find((r) => r.type === 'pull_request');
104
+ expect(
105
+ (pr as { parameters: Record<string, unknown> } | undefined)?.parameters
106
+ .require_extra_approval_for_unattributed_changes,
107
+ ).toBe(false);
108
+ });
109
+ });
@@ -14,15 +14,20 @@
14
14
  * Integration 29110 is `always`, not the reverse) — see repository-ruleset-narrowing-guards.ts
15
15
  * for the guard these fixtures exercise.
16
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.
17
+ * ★ K1 LANDED 2026-09-23: `extraApprovalForUnattributedChanges` now declares `true`, not only
18
+ * `false` — the gap this file's earlier revision named as blocking a true zero-write adopt for
19
+ * aop/magictransit/loggarr/doesthishelp-workeropen (all four carry
20
+ * `require_extra_approval_for_unattributed_changes: true` live). The 4 fixtures below still
21
+ * prove the narrower claim: that `bypassActors` round-trips an arbitrary
22
+ * `actor_id`/`actor_type`/`bypass_mode` combination — single-actor by role, single-actor by
23
+ * integration, and multi-actor with mixed modes — onto the wire exactly as declared, with
24
+ * neither `BypassActorWidened` nor `BypassActorNarrowed` firing on a match; their declarations
25
+ * still use `BASE_PULL_REQUEST` (`extraApprovalForUnattributedChanges: false`), so each is
26
+ * still a genuine `update`, not a noop — that keeps this file's original point (bypass alone
27
+ * round-trips) legible on its own. The case K1 unblocks — `aop`'s FULL live shape, extra-
28
+ * approval flag included, as a true zero-write adopt — is proven in
29
+ * repository-ruleset-exact-adopt-k1.test.ts, split out purely to keep this file under the
30
+ * 250-line cap.
26
31
  */
27
32
  import { describe, expect, test } from 'bun:test';
28
33
  import { makeGetOnlyFake, makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
@@ -50,11 +50,15 @@ function buildPullRequestRule(rule: RepositoryRulesetPullRequestRule): WireRule
50
50
  minimum_approvals: r.minimumApprovals,
51
51
  reviewer: { id: r.reviewer.actorId, type: r.reviewer.actorType },
52
52
  })),
53
- // ⛔ Only ever sent as `false` (H15) — see repository-ruleset.ts for why this exists at
54
- // all and repository-ruleset-wire.test.ts for whether Octokit forwards it untyped.
53
+ // Sent as declared, `true` or `false` (K1, 2026-09-23) — see repository-ruleset.ts's H15
54
+ // for why this field exists here at all (Octokit, not distilled) and
55
+ // repository-ruleset-wire.test.ts for whether Octokit forwards it untyped for both values.
55
56
  ...(rule.extraApprovalForUnattributedChanges === undefined
56
57
  ? {}
57
- : { require_extra_approval_for_unattributed_changes: false }),
58
+ : {
59
+ require_extra_approval_for_unattributed_changes:
60
+ rule.extraApprovalForUnattributedChanges,
61
+ }),
58
62
  },
59
63
  // The extension field above has no home in Octokit's own rule union (H15) — the cast is
60
64
  // the only place that gap is bridged.
@@ -182,8 +182,9 @@ export const newlyRequiredContexts = (
182
182
  .filter((c) => !liveContexts.has(c));
183
183
  };
184
184
 
185
- /** Fails on the first newly-required context that has never reported success on the default
186
- * branch — checked once here (reconcile's observe step) and again by the standalone checker. */
185
+ /** Fails on the first newly-required context that has never reported success anywhere this
186
+ * family looks (the default branch's tip, then recent merged PR heads — K2) — checked once here
187
+ * (reconcile's observe step) and again by the standalone checker. */
187
188
  export const refuseUnreportedContexts = <R = never>(
188
189
  octokit: RulesetOctokit<R>,
189
190
  owner: string,
@@ -0,0 +1,141 @@
1
+ /**
2
+ * K2 (2026-09-24): MEASURES `contextReportedSuccess` (repository-ruleset-octokit.ts) against a
3
+ * real `@octokit/rest` instance with a fetch shim (no network) — the same pattern
4
+ * repository-ruleset-wire.test.ts uses for H15, applied here to the "which ref did we check"
5
+ * question instead of "did the wire field survive". The shim routes by URL path, not by call
6
+ * order, so these tests exercise the SAME sequencing the real implementation performs: default
7
+ * branch tip first, then (only if the tip has nothing) recent merged PR heads, bounded.
8
+ *
9
+ * Both sides the guard exists to hold: accept a context that reports only on a recent merged PR
10
+ * head (the case K2 fixes — CI runs on `pull_request` only, never the tip), and still refuse one
11
+ * that reports nowhere, tip or any recent merged head, INCLUDING one whose only success is
12
+ * older than `RECENT_MERGED_PR_LIMIT` merges ago (the bound is real, not decorative).
13
+ */
14
+ import { describe, expect, test } from 'bun:test';
15
+ import { Octokit } from '@octokit/rest';
16
+ import * as Effect from 'effect/Effect';
17
+ import { RECENT_MERGED_PR_LIMIT, contextReportedSuccess } from './repository-ruleset-octokit.ts';
18
+
19
+ interface CheckRun {
20
+ readonly name: string;
21
+ readonly conclusion: string | null;
22
+ }
23
+ interface Status {
24
+ readonly context: string;
25
+ readonly state: string;
26
+ }
27
+ interface MergedPull {
28
+ readonly merged_at: string | null;
29
+ readonly head: { readonly sha: string };
30
+ }
31
+
32
+ interface Fixture {
33
+ readonly defaultBranch: string;
34
+ readonly checkRuns?: Readonly<Record<string, readonly CheckRun[]>>;
35
+ readonly statuses?: Readonly<Record<string, readonly Status[]>>;
36
+ /** Already in the order GitHub's `sort: updated, direction: desc` would return — most
37
+ * recently updated first. */
38
+ readonly closedPulls?: readonly MergedPull[];
39
+ }
40
+
41
+ /** Routes by URL path, not call order — GET .../pulls, GET .../commits/{ref}/check-runs, GET
42
+ * .../commits/{ref}/statuses, else the bare repo GET (`default_branch`). `captured` records
43
+ * every URL so a test can assert a ref was — or, for boundedness, was NEVER — looked up. */
44
+ function makeServingFetch(fx: Fixture, captured: string[]): typeof fetch {
45
+ const respond = (body: unknown) =>
46
+ new Response(JSON.stringify(body), {
47
+ status: 200,
48
+ headers: { 'content-type': 'application/json' },
49
+ });
50
+ return (async (input: Parameters<typeof fetch>[0]) => {
51
+ const url = String(input);
52
+ captured.push(url);
53
+ if (/\/pulls(\?|$)/.test(url)) return respond(fx.closedPulls ?? []);
54
+ const commits = /\/commits\/([^/?]+)\/(check-runs|statuses)/.exec(url);
55
+ if (commits) {
56
+ const [, ref, kind] = commits;
57
+ if (kind === 'check-runs') {
58
+ const runs = fx.checkRuns?.[ref as string] ?? [];
59
+ return respond({ total_count: runs.length, check_runs: runs });
60
+ }
61
+ return respond(fx.statuses?.[ref as string] ?? []);
62
+ }
63
+ return respond({ default_branch: fx.defaultBranch });
64
+ }) as typeof fetch;
65
+ }
66
+
67
+ const octokitOver = (fx: Fixture, captured: string[] = []) =>
68
+ new Octokit({
69
+ auth: 'fake-token-not-a-real-credential',
70
+ request: { fetch: makeServingFetch(fx, captured) },
71
+ });
72
+
73
+ const run = (eff: Effect.Effect<boolean, Error>): Promise<boolean> => Effect.runPromise(eff);
74
+
75
+ describe('contextReportedSuccess', () => {
76
+ test('accepts a context that reports only on a recent merged PR head, not the tip', async () => {
77
+ const octokit = octokitOver({
78
+ defaultBranch: 'main',
79
+ checkRuns: { sha2: [{ name: 'ci', conclusion: 'success' }] },
80
+ closedPulls: [
81
+ { merged_at: '2026-09-20T00:00:00Z', head: { sha: 'sha3' } },
82
+ { merged_at: '2026-09-19T00:00:00Z', head: { sha: 'sha2' } },
83
+ { merged_at: '2026-09-18T00:00:00Z', head: { sha: 'sha1' } },
84
+ ],
85
+ });
86
+ const reported = await run(
87
+ contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-builds', 'ci'),
88
+ );
89
+ expect(reported).toBe(true);
90
+ });
91
+
92
+ test('refuses a context that reports nowhere — tip or any recent merged head', async () => {
93
+ const octokit = octokitOver({
94
+ defaultBranch: 'main',
95
+ closedPulls: [
96
+ { merged_at: '2026-09-20T00:00:00Z', head: { sha: 'sha3' } },
97
+ { merged_at: '2026-09-19T00:00:00Z', head: { sha: 'sha2' } },
98
+ ],
99
+ });
100
+ const reported = await run(
101
+ contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-builds', 'ci'),
102
+ );
103
+ expect(reported).toBe(false);
104
+ });
105
+
106
+ test('a success older than RECENT_MERGED_PR_LIMIT merges ago is still refused — the bound is real', async () => {
107
+ // RECENT_MERGED_PR_LIMIT (10) merged PRs report nothing; only the 11th (oldest returned,
108
+ // outside the bound) reports `ci` — proves the function stops at the limit rather than
109
+ // scanning everything the list call happened to return.
110
+ const closedPulls: MergedPull[] = Array.from({ length: RECENT_MERGED_PR_LIMIT }, (_, i) => ({
111
+ merged_at: `2026-09-${String(20 - i)}T00:00:00Z`,
112
+ head: { sha: `within-bound-${String(i)}` },
113
+ }));
114
+ closedPulls.push({ merged_at: '2026-08-01T00:00:00Z', head: { sha: 'beyond-bound' } });
115
+ const captured: string[] = [];
116
+ const octokit = octokitOver(
117
+ {
118
+ defaultBranch: 'main',
119
+ checkRuns: { 'beyond-bound': [{ name: 'ci', conclusion: 'success' }] },
120
+ closedPulls,
121
+ },
122
+ captured,
123
+ );
124
+ const reported = await run(
125
+ contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-builds', 'ci'),
126
+ );
127
+ expect(reported).toBe(false);
128
+ expect(captured.some((url) => url.includes('/commits/beyond-bound/'))).toBe(false);
129
+ });
130
+
131
+ test('the default branch tip alone is still enough — no fallback needed', async () => {
132
+ const octokit = octokitOver({
133
+ defaultBranch: 'main',
134
+ statuses: { main: [{ context: 'secret scan', state: 'success' }] },
135
+ });
136
+ const reported = await run(
137
+ contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-kit', 'secret scan'),
138
+ );
139
+ expect(reported).toBe(true);
140
+ });
141
+ });