@homeflare/alchemy 0.27.3 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/cloudflare/mesh-node-token.d.ts +1 -1
  2. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
  3. package/dist/forgejo/branch-protection.d.ts +4 -4
  4. package/dist/forgejo/org-actions-secrets.d.ts +4 -4
  5. package/dist/forgejo/org-label.d.ts +4 -4
  6. package/dist/forgejo/org-team.d.ts +4 -4
  7. package/dist/forgejo/repo-webhook.d.ts +4 -4
  8. package/dist/forgejo/repository.d.ts +4 -4
  9. package/dist/forgejo/team-member.d.ts +4 -4
  10. package/dist/github/index.d.ts +1 -1
  11. package/dist/github/index.d.ts.map +1 -1
  12. package/dist/github/index.js +99 -3
  13. package/dist/github/index.js.map +8 -7
  14. package/dist/github/repository-ruleset-errors.d.ts +34 -1
  15. package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
  16. package/dist/github/repository-ruleset-guards.d.ts +12 -0
  17. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  18. package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
  19. package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
  20. package/dist/github/repository-ruleset-props.d.ts +88 -0
  21. package/dist/github/repository-ruleset-props.d.ts.map +1 -0
  22. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
  23. package/dist/github/repository-ruleset.d.ts +3 -31
  24. package/dist/github/repository-ruleset.d.ts.map +1 -1
  25. package/dist/netbox/fake-netbox.d.ts +43 -0
  26. package/dist/netbox/fake-netbox.d.ts.map +1 -0
  27. package/dist/netbox/index.d.ts +1 -2
  28. package/dist/netbox/index.d.ts.map +1 -1
  29. package/dist/netbox/index.js +71 -119
  30. package/dist/netbox/index.js.map +5 -6
  31. package/dist/netbox/prefix.d.ts +33 -2
  32. package/dist/netbox/prefix.d.ts.map +1 -1
  33. package/dist/netbox/resource.d.ts +73 -48
  34. package/dist/netbox/resource.d.ts.map +1 -1
  35. package/docs/credentials.md +10 -0
  36. package/docs/peers.md +11 -0
  37. package/docs/upstream-conformance.md +17 -2
  38. package/package.json +7 -2
  39. package/src/github/index.ts +2 -0
  40. package/src/github/repository-ruleset-errors.ts +45 -0
  41. package/src/github/repository-ruleset-exact-adopt.test.ts +179 -0
  42. package/src/github/repository-ruleset-guards.ts +8 -2
  43. package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
  44. package/src/github/repository-ruleset-narrowing.test.ts +210 -0
  45. package/src/github/repository-ruleset-props.ts +92 -0
  46. package/src/github/repository-ruleset-reconcile.ts +35 -0
  47. package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
  48. package/src/github/repository-ruleset.ts +27 -41
  49. package/src/netbox/fake-netbox.ts +56 -0
  50. package/src/netbox/index.ts +8 -2
  51. package/src/netbox/prefix.test.ts +223 -0
  52. package/src/netbox/prefix.ts +68 -32
  53. package/src/netbox/resource.test.ts +67 -0
  54. package/src/netbox/resource.ts +133 -112
  55. package/dist/netbox/client.d.ts +0 -52
  56. package/dist/netbox/client.d.ts.map +0 -1
  57. package/src/netbox/client.test.ts +0 -67
  58. package/src/netbox/client.ts +0 -146
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `GitHub.RepositoryRuleset`'s declared shape — split out of repository-ruleset.ts purely to
3
+ * keep that file (the provider wiring) under the 250-line cap; nothing here is provider logic.
4
+ */
5
+ import type { RulesetProps } from 'alchemy/GitHub';
6
+
7
+ type UpstreamRules = NonNullable<RulesetProps['rules']>;
8
+
9
+ export interface RepositoryRulesetReviewerRule {
10
+ readonly filePatterns: readonly string[];
11
+ readonly minimumApprovals: number;
12
+ readonly reviewer: { readonly actorId: number; readonly actorType: 'Team' };
13
+ }
14
+
15
+ export interface RepositoryRulesetPullRequestRule extends NonNullable<
16
+ UpstreamRules['pullRequest']
17
+ > {
18
+ /** @default every method GitHub allows — the house baseline always pins `['squash']`. */
19
+ readonly allowedMergeMethods?: readonly ('merge' | 'squash' | 'rebase')[];
20
+ readonly requiredReviewers?: readonly RepositoryRulesetReviewerRule[];
21
+ /** `require_extra_approval_for_unattributed_changes` — see repository-ruleset.ts's H15. Only
22
+ * `false` is modeled: sending `true` has no house use, and GitHub already defaults absent to
23
+ * `true`. */
24
+ readonly extraApprovalForUnattributedChanges?: false;
25
+ }
26
+
27
+ export interface RepositoryRulesetStatusChecksRule extends NonNullable<
28
+ UpstreamRules['requiredStatusChecks']
29
+ > {
30
+ readonly doNotEnforceOnCreate?: boolean;
31
+ }
32
+
33
+ export interface RepositoryRulesetRules extends Omit<
34
+ UpstreamRules,
35
+ 'pullRequest' | 'requiredStatusChecks'
36
+ > {
37
+ /** `undefined`: no opinion (refused if the live ruleset already has one and this is not an
38
+ * exact-adopt pass-through — see repository-ruleset-guards.ts's `undeclaredLiveRuleRefusal`).
39
+ * `false`: declare its ABSENCE — either "this repo never had one" (a create, or an adopt
40
+ * where live already has none, both no-ops) or a deliberate removal of a live rule, which
41
+ * `ruleNarrowingRefusal` gates behind `acknowledgeRuleNarrowing` (see below). Added 2026-09-23 for
42
+ * exact-adopt pass-through: `requiredStatusChecks` already had this `false` escape; `deletion`/
43
+ * `nonFastForward`/etc already have it implicitly (plain `boolean`, so `false` is already a
44
+ * value); `pullRequest` was the one modeled rule with NO way to express "not present at all" —
45
+ * a repo like `taslabs-net/taslabs-net` (measured live 2026-09-23: `rules = [deletion,
46
+ * non_fast_forward]`, no `pull_request`) could not be adopted through this resource without
47
+ * either refusing to omit the key (a type error) or silently declaring a brand-new rule. */
48
+ readonly pullRequest?: RepositoryRulesetPullRequestRule | false;
49
+ /** `undefined`: no opinion (refused if the live ruleset already has one — see
50
+ * repository-ruleset-guards.ts). `false`: declare its ABSENCE — removes a live one. */
51
+ readonly requiredStatusChecks?: RepositoryRulesetStatusChecksRule | false;
52
+ }
53
+
54
+ export interface RepositoryRulesetProps extends Omit<RulesetProps, 'rules'> {
55
+ readonly rules?: RepositoryRulesetRules;
56
+ /**
57
+ * An explicit, reasoned sign-off that THIS declaration deliberately drops a live bypass actor
58
+ * (`bypassActors` is a strict subset of what is live). Without this, `reconcile` refuses the
59
+ * write rather than silently loosen a ruleset that already protects something — see
60
+ * `bypassNarrowingRefusal` in repository-ruleset-narrowing-guards.ts. `reason` is not logged or
61
+ * sent to GitHub; it exists so the declaration's own diff carries WHY, for the next reader
62
+ * (S21: a refusal names the fact it checked — this is the human-authored counterpart for the
63
+ * one case a fact alone cannot settle: "is this narrowing intentional?").
64
+ *
65
+ * ⛔ SEPARATE FROM `acknowledgeRuleNarrowing` ON PURPOSE (2026-09-23 review finding: a single
66
+ * shared `acknowledgeNarrowing` let one reason worded for a rule drop silently also rubber-
67
+ * stamp an unrelated bypass-actor drop in the SAME declaration — a `bypassActors: []` left
68
+ * over from an unrelated edit slipped through under a reason that never mentioned bypass at
69
+ * all). Each prop authorizes only the narrowing its own guard checks; a declaration that
70
+ * narrows both needs both, each with its own reason.
71
+ *
72
+ * ⛔ ALSO A STANDING PROP, NOT A ONE-TIME TOKEN — remove it from the declaration once the
73
+ * narrowing it describes has landed. Left in place, it keeps disabling the bypass-narrowing
74
+ * guard for every future `reconcile` of this resource, not only the change it was written
75
+ * for — the same way `rules: { deletion: false }` stays a standing "this rule stays absent"
76
+ * fact rather than a one-time action, only here the standing fact is "bypass narrowing needs
77
+ * no review", which is a fact you want true for as short a window as possible. A reviewer
78
+ * reading a diff that adds a SECOND, unrelated bypass drop under an OLD
79
+ * `acknowledgeBypassNarrowing` left over from a prior change is the failure mode this note
80
+ * exists to head off.
81
+ */
82
+ readonly acknowledgeBypassNarrowing?: { readonly reason: string };
83
+ /**
84
+ * The `rules`-side counterpart to `acknowledgeBypassNarrowing` above — an explicit, reasoned
85
+ * sign-off that THIS declaration deliberately drops a live rule (a modeled rule type declared
86
+ * `false`/boolean-`false` while the live ruleset still has it). Without this, `reconcile`
87
+ * refuses the write — see `ruleNarrowingRefusal` in repository-ruleset-narrowing-guards.ts.
88
+ * Same standing-prop caution as `acknowledgeBypassNarrowing`: remove it once the rule drop it
89
+ * describes has landed, or it keeps excusing every later rule drop on this resource too.
90
+ */
91
+ readonly acknowledgeRuleNarrowing?: { readonly reason: string };
92
+ }
@@ -20,6 +20,12 @@ import {
20
20
  requiredChecksOmissionRefusal,
21
21
  undeclaredLiveRuleRefusal,
22
22
  } from './repository-ruleset-guards.ts';
23
+ import {
24
+ bypassNarrowingRefusal,
25
+ isBypassNarrowingAcknowledged,
26
+ isRuleNarrowingAcknowledged,
27
+ ruleNarrowingRefusal,
28
+ } from './repository-ruleset-narrowing-guards.ts';
23
29
  import {
24
30
  type RulesetOctokit,
25
31
  type RulesetRecord,
@@ -104,6 +110,12 @@ export const reconcileRuleset = <R = never>(input: {
104
110
  observed = yield* probeByName(octokit, { owner, repo, name: news.name, target });
105
111
  }
106
112
 
113
+ // Deliberately TWO separate reads, not one shared flag — each guard below is authorized
114
+ // only by the acknowledgement scoped to what it checks (see the file header on
115
+ // repository-ruleset-narrowing-guards.ts for the 2026-09-23 finding this fixes).
116
+ const bypassAcknowledged = isBypassNarrowingAcknowledged(news);
117
+ const ruleAcknowledged = isRuleNarrowingAcknowledged(news);
118
+
107
119
  const bypassRefusal =
108
120
  news.bypassActors === undefined
109
121
  ? undefined
@@ -116,6 +128,19 @@ export const reconcileRuleset = <R = never>(input: {
116
128
  });
117
129
  if (bypassRefusal !== undefined) return yield* Effect.fail(bypassRefusal);
118
130
 
131
+ const bypassNarrowRefusal =
132
+ news.bypassActors === undefined
133
+ ? undefined
134
+ : bypassNarrowingRefusal({
135
+ owner,
136
+ repository: repo,
137
+ name: news.name,
138
+ declared: news.bypassActors,
139
+ live: observed,
140
+ acknowledged: bypassAcknowledged,
141
+ });
142
+ if (bypassNarrowRefusal !== undefined) return yield* Effect.fail(bypassNarrowRefusal);
143
+
119
144
  const ruleRefusal = undeclaredLiveRuleRefusal({
120
145
  owner,
121
146
  repository: repo,
@@ -125,6 +150,16 @@ export const reconcileRuleset = <R = never>(input: {
125
150
  });
126
151
  if (ruleRefusal !== undefined) return yield* Effect.fail(ruleRefusal);
127
152
 
153
+ const ruleNarrowRefusal = ruleNarrowingRefusal({
154
+ owner,
155
+ repository: repo,
156
+ name: news.name,
157
+ declared: news.rules,
158
+ live: observed,
159
+ acknowledged: ruleAcknowledged,
160
+ });
161
+ if (ruleNarrowRefusal !== undefined) return yield* Effect.fail(ruleNarrowRefusal);
162
+
128
163
  const checksRefusal = requiredChecksOmissionRefusal({
129
164
  owner,
130
165
  repository: repo,
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * (ii) The write-recording fake, refusals: duplicate names, bypass widening, an undeclared live
3
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.
4
+ * readback mismatch fails even though the write itself "succeeded" (S10). Bypass/rule narrowing
5
+ * without the matching `acknowledge{Bypass,Rule}Narrowing` are their own file,
6
+ * repository-ruleset-narrowing.test.ts (split out purely for the 250-line cap). The happy paths
7
+ * (create, noop, update) are repository-ruleset-write.test.ts; exact-adopt pass-through's own
8
+ * dedicated fixtures (all 5 live non-empty-bypass shapes) are
9
+ * repository-ruleset-exact-adopt.test.ts.
6
10
  */
7
11
  import { describe, expect, test } from 'bun:test';
8
12
  import * as Effect from 'effect/Effect';
@@ -94,30 +98,6 @@ describe('refusals make zero writes', () => {
94
98
  expect(tagOf(error)).toBe('UndeclaredLiveRule');
95
99
  });
96
100
 
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
101
  test('required checks omitted while the live ruleset has them', async () => {
122
102
  const live = {
123
103
  id: 1,
@@ -42,7 +42,7 @@
42
42
  import { Resource } from 'alchemy';
43
43
  import { Unowned } from 'alchemy/AdoptPolicy';
44
44
  import { isResolved } from 'alchemy/Diff';
45
- import type { GitHubCredentials, RulesetProps } from 'alchemy/GitHub';
45
+ import type { GitHubCredentials } from 'alchemy/GitHub';
46
46
  import * as Provider from 'alchemy/Provider';
47
47
  import * as Effect from 'effect/Effect';
48
48
  import {
@@ -52,46 +52,16 @@ import {
52
52
  import { RulesetConstraintRefused } from './repository-ruleset-errors.ts';
53
53
  import { makeRulesetOctokit } from './repository-ruleset-octokit.ts';
54
54
  import { type RulesetRecord, probeByName } from './repository-ruleset-probe.ts';
55
+ import type { RepositoryRulesetProps } from './repository-ruleset-props.ts';
55
56
  import { reconcileRuleset } from './repository-ruleset-reconcile.ts';
56
57
 
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
- }
58
+ export type {
59
+ RepositoryRulesetProps,
60
+ RepositoryRulesetPullRequestRule,
61
+ RepositoryRulesetReviewerRule,
62
+ RepositoryRulesetRules,
63
+ RepositoryRulesetStatusChecksRule,
64
+ } from './repository-ruleset-props.ts';
95
65
 
96
66
  export interface RepositoryRulesetAttributes {
97
67
  readonly rulesetId: number;
@@ -124,7 +94,11 @@ export const attrsOf = (r: RulesetRecord): RepositoryRulesetAttributes => ({
124
94
  /** Plan-time-only refusals — no live data needed, so these run before any read (schema-codegen's
125
95
  * rule: check the form before the "no previous state" branch, never after it). */
126
96
  function constraintRefusal(props: RepositoryRulesetProps): RulesetConstraintRefused | undefined {
127
- const approvals = props.rules?.pullRequest?.requiredApprovingReviewCount;
97
+ // `=== false` narrows `pullRequest` out of the `| false` half added for exact-adopt
98
+ // pass-through (see `RepositoryRulesetRules` above) — a declared absence has no fields to
99
+ // constrain.
100
+ const pr = props.rules?.pullRequest === false ? undefined : props.rules?.pullRequest;
101
+ const approvals = pr?.requiredApprovingReviewCount;
128
102
  if (approvals !== undefined) {
129
103
  const reason = approvalCountRefusal(approvals);
130
104
  if (reason !== undefined) {
@@ -134,7 +108,7 @@ function constraintRefusal(props: RepositoryRulesetProps): RulesetConstraintRefu
134
108
  });
135
109
  }
136
110
  }
137
- const methods = props.rules?.pullRequest?.allowedMergeMethods;
111
+ const methods = pr?.allowedMergeMethods;
138
112
  if (methods !== undefined) {
139
113
  const reason = allowedMergeMethodsRefusal(methods);
140
114
  if (reason !== undefined) {
@@ -144,6 +118,18 @@ function constraintRefusal(props: RepositoryRulesetProps): RulesetConstraintRefu
144
118
  });
145
119
  }
146
120
  }
121
+ // ⚠️ NO SEPARATE PLAN-TIME CHECK FOR A BLANK `acknowledge{Bypass,Rule}Narrowing.reason` HERE.
122
+ // A blank reason is already indistinguishable from "not acknowledged at all" —
123
+ // `isBypassNarrowingAcknowledged`/`isRuleNarrowingAcknowledged`
124
+ // (repository-ruleset-narrowing-guards.ts) each require a non-empty, trimmed `reason` on
125
+ // their OWN prop, and the matching narrowing guard reads that, not presence alone. Adding a
126
+ // SECOND check here would refuse with `RulesetConstraintRefused` instead of the more
127
+ // specific `BypassActorNarrowed`/`RuleNarrowed` the caller actually needs to act on — and,
128
+ // unlike those two (exercised directly through `reconcileRuleset`, injected-octokit fakes
129
+ // included), this function is wired only through the real `RepositoryRuleset.Provider`
130
+ // (`makeRulesetOctokit` needs live `GitHubCredentials`), which this family's own test
131
+ // helpers do not exercise (see `repository-ruleset-test-helpers.ts`'s stub
132
+ // `constraintRefusal`) — a check that lives only here would ship untested.
147
133
  return undefined;
148
134
  }
149
135
 
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A minimal in-memory NetBox REST API for this family's tests — an in-memory `fetch` handed to
3
+ * Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring
4
+ * `../forgejo/fake-forgejo.ts` (itself mirroring `cloudflare/fake-mesh.ts`). Tests exercise
5
+ * distilled's REAL path assembly, JSON encode/decode and status→typed-error matching (`404` →
6
+ * `NotFound`, `403` → `Forbidden`, …) — nothing about that matching is re-implemented here, only
7
+ * the wire responses a scenario needs.
8
+ *
9
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
10
+ * `netbox.example.com`, and the token is a placeholder.
11
+ */
12
+ import { type Credentials, credentials } from '@distilled.cloud/netbox/Credentials';
13
+ import * as Layer from 'effect/Layer';
14
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
+
16
+ export const FAKE_BASE = 'https://netbox.example.com';
17
+ export const FAKE_TOKEN = 'placeholder-netbox-token';
18
+
19
+ export type Seen = { readonly method: string; readonly path: string };
20
+
21
+ /** NetBox's `{ detail }` failure envelope (403/404) — matching protocol.ts's `errorEnvelope`. */
22
+ export const fakeFailure = (status: number, detail: string) =>
23
+ Response.json({ detail }, { status });
24
+
25
+ /**
26
+ * `route` decides every response; this wrapper only records what was sent and adapts to
27
+ * `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
28
+ */
29
+ export const fakeNetbox = (route: (method: string, url: URL) => Response) => {
30
+ const seen: Seen[] = [];
31
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
32
+ const request =
33
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
34
+ const url = new URL(request.url);
35
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
36
+ return route(request.method, url);
37
+ }) as typeof globalThis.fetch;
38
+ return { fetch, seen };
39
+ };
40
+
41
+ /**
42
+ * distilled Credentials + the real FetchHttpClient over the fake — `NetboxOpContext`, what every
43
+ * resource file's exported `spec.fetchLive` needs.
44
+ *
45
+ * ⚠️ NOT PAIRED WITH `handlers` (each resource file's `netboxHandlers(spec)` export). `handlers`
46
+ * bakes in `CredentialsFromEnv`, which resolves `NETBOX_URL` / `NETBOX_TOKEN` through Effect's
47
+ * `Config` — whose default provider snapshots `process.env` once and never re-reads it (measured
48
+ * in the forgejo migration, kit PR 180: mutating `process.env` mid-test-run had no effect). So a
49
+ * test cannot point `handlers` at a fake server; it calls `spec.fetchLive`/`spec.attributes`
50
+ * directly instead, with this explicit layer, exercising exactly the same production code
51
+ * without that env seam.
52
+ */
53
+ export const fakeNetboxLayer = (
54
+ fetchFn: typeof globalThis.fetch,
55
+ creds: Layer.Layer<Credentials> = credentials({ baseUrl: FAKE_BASE, token: FAKE_TOKEN }),
56
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
@@ -8,7 +8,6 @@
8
8
  * ★ The constraint reader IS exported, because a consumer writing its own family needs the same
9
9
  * guard — and because a table nobody can reach is a table nobody uses.
10
10
  */
11
- export { NetboxError, apiBase } from './client.ts';
12
11
  export {
13
12
  NETBOX_CONSTRAINTS,
14
13
  bodyViolations,
@@ -31,4 +30,11 @@ export {
31
30
  type PrefixProps,
32
31
  type PrefixStatus,
33
32
  } from './prefix.ts';
34
- export { type NetboxRequirements, type NetboxSpec, netboxHandlers } from './resource.ts';
33
+ export {
34
+ LOCATE_PAGE,
35
+ locateOne,
36
+ type NetboxRequirements,
37
+ type NetboxSpec,
38
+ netboxHandlers,
39
+ soleMatch,
40
+ } from './resource.ts';
@@ -0,0 +1,223 @@
1
+ /**
2
+ * `Netbox.Prefix`'s `spec` against a fake NetBox, proving the real distilled wire path that
3
+ * replaced `client.ts`'s hand-rolled `HttpClient` calls — path assembly, JSON encode/decode and
4
+ * status→typed-error matching, exercised through `@distilled.cloud/netbox/ipam`'s real operations,
5
+ * never re-implemented here. Mirrors `../forgejo/org-label.test.ts` and `repository.test.ts`'s
6
+ * "call the exported production functions" shape (kit PR 180).
7
+ *
8
+ * ⛔ THE POINT OF THIS FILE. `resource.test.ts` proves `locateOne`/`soleMatch` in isolation with a
9
+ * fixture list; this file proves prefix.ts's own `spec.fetchLive`, `spec.attributes`,
10
+ * `spec.create`, `spec.update.call` and `spec.destroy` — the exact object `netboxHandlers(spec)`
11
+ * wraps into `NetboxPrefixProvider` — against a real HTTP round trip.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import type * as ipam from '@distilled.cloud/netbox/ipam';
15
+ import * as Effect from 'effect/Effect';
16
+ import { FAKE_BASE, fakeFailure, fakeNetbox, fakeNetboxLayer } from './fake-netbox.ts';
17
+ import type { PrefixProps } from './prefix.ts';
18
+ import { spec } from './prefix.ts';
19
+
20
+ const PREFIXES_PATH = '/api/ipam/prefixes/';
21
+ const PREFIX_ID_PATH = '/api/ipam/prefixes/7/';
22
+
23
+ /** A decodable `Prefix` — every field the SDK's schema requires present, per ipam.ts's interface. */
24
+ const livePrefix = (overrides: Record<string, unknown> = {}): ipam.Prefix =>
25
+ ({
26
+ _depth: 0,
27
+ children: 0,
28
+ comments: '',
29
+ created: '2026-01-01T00:00:00Z',
30
+ description: '',
31
+ display: '10.0.0.0/24',
32
+ display_url: `${FAKE_BASE}${PREFIX_ID_PATH}`,
33
+ family: { label: 'IPv4', value: 4 },
34
+ id: 7,
35
+ is_pool: false,
36
+ last_updated: '2026-01-01T00:00:00Z',
37
+ mark_utilized: false,
38
+ prefix: '10.0.0.0/24',
39
+ scope: null,
40
+ status: { label: 'Active', value: 'active' },
41
+ url: `${FAKE_BASE}${PREFIX_ID_PATH}`,
42
+ vrf: null,
43
+ ...overrides,
44
+ }) as unknown as ipam.Prefix;
45
+
46
+ /** `BriefVRF`/`BriefRIR`/`BriefVLAN` all require `id`, `url`, `display`, `name` — no `S.optional`. */
47
+ const briefVrf = (id: number, name: string) => ({
48
+ display: name,
49
+ id,
50
+ name,
51
+ url: `${FAKE_BASE}/api/ipam/vrfs/${String(id)}/`,
52
+ });
53
+ const briefTenant = (id: number, name: string) => ({
54
+ display: name,
55
+ id,
56
+ name,
57
+ slug: name,
58
+ url: `${FAKE_BASE}/api/tenancy/tenants/${String(id)}/`,
59
+ });
60
+ const briefVlan = (id: number, name: string, vid: number) => ({
61
+ display: name,
62
+ id,
63
+ name,
64
+ url: `${FAKE_BASE}/api/ipam/vlans/${String(id)}/`,
65
+ vid,
66
+ });
67
+
68
+ const readThrough = (fetchFn: typeof globalThis.fetch, props: PrefixProps) =>
69
+ Effect.runPromise(
70
+ spec.fetchLive(props).pipe(
71
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
72
+ Effect.provide(fakeNetboxLayer(fetchFn)),
73
+ ),
74
+ );
75
+
76
+ describe('Netbox.Prefix spec.fetchLive', () => {
77
+ test('one server-side match, no VRF declared, decodes into the typed row', async () => {
78
+ const fake = fakeNetbox((method, url) =>
79
+ method === 'GET' && url.pathname === PREFIXES_PATH
80
+ ? Response.json({ count: 1, next: null, previous: null, results: [livePrefix()] })
81
+ : fakeFailure(400, 'unexpected request'),
82
+ );
83
+ const live = await Effect.runPromise(
84
+ spec.fetchLive({ prefix: '10.0.0.0/24' }).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
85
+ );
86
+ expect(live?.id).toBe(7);
87
+ expect(fake.seen[0]?.method).toBe('GET');
88
+ // ⚠️ Not just the path — the server-side filter this resource relies on to narrow
89
+ // candidates before `identifies` runs. A future distilled bump that changed how an
90
+ // array param serializes would silently stop narrowing on the wire; asserting only
91
+ // `pathname` would never catch that.
92
+ const sent = new URL(`${FAKE_BASE}${fake.seen[0]?.path ?? ''}`);
93
+ expect(sent.searchParams.getAll('prefix')).toEqual(['10.0.0.0/24']);
94
+ });
95
+
96
+ /**
97
+ * 🔴 THE CASE `identifies` EXISTS FOR — the same VRF disambiguation prefix.ts's own docstring
98
+ * explains. `prefix` alone matched two rows (the same CIDR in two VRFs); the declared `vrf`
99
+ * picks the right one in this process, never by re-filtering server-side.
100
+ */
101
+ test('several rows for the same CIDR narrow to the declared VRF', async () => {
102
+ const rows = [
103
+ livePrefix({ id: 7, vrf: briefVrf(1, 'blue') }),
104
+ livePrefix({ id: 8, vrf: briefVrf(2, 'red') }),
105
+ ];
106
+ const fake = fakeNetbox((method, url) =>
107
+ method === 'GET' && url.pathname === PREFIXES_PATH
108
+ ? Response.json({ count: 2, next: null, previous: null, results: rows })
109
+ : fakeFailure(400, 'unexpected request'),
110
+ );
111
+ const live = await Effect.runPromise(
112
+ spec
113
+ .fetchLive({ prefix: '10.0.0.0/24', vrf: 2 })
114
+ .pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
115
+ );
116
+ expect(live?.id).toBe(8);
117
+ });
118
+
119
+ test('no candidate rows is absent, not an error', async () => {
120
+ const fake = fakeNetbox(() =>
121
+ Response.json({ count: 0, next: null, previous: null, results: [] }),
122
+ );
123
+ const live = await Effect.runPromise(
124
+ spec.fetchLive({ prefix: '10.20.30.0/24' }).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
125
+ );
126
+ expect(live).toBeUndefined();
127
+ });
128
+
129
+ test('a 400 on the list propagates — never folded to absent', async () => {
130
+ const fake = fakeNetbox(() => fakeFailure(400, 'prefix: invalid CIDR'));
131
+ const failure = await Effect.runPromise(
132
+ Effect.flip(
133
+ spec.fetchLive({ prefix: 'not-a-cidr' }).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
134
+ ),
135
+ );
136
+ expect(failure._tag).toBe('BadRequest');
137
+ });
138
+ });
139
+
140
+ describe('Netbox.Prefix spec.attributes — the actual exported production mapping', () => {
141
+ test('status, foreign keys and free text all decode the way client.ts used to read them', async () => {
142
+ const fake = fakeNetbox((method, url) =>
143
+ method === 'GET' && url.pathname === PREFIXES_PATH
144
+ ? Response.json({
145
+ count: 1,
146
+ next: null,
147
+ previous: null,
148
+ results: [
149
+ livePrefix({
150
+ comments: 'management range',
151
+ description: 'core VLAN',
152
+ is_pool: true,
153
+ mark_utilized: true,
154
+ status: { label: 'Deprecated', value: 'deprecated' },
155
+ tenant: briefTenant(3, 'ops'),
156
+ vlan: briefVlan(5, 'core', 10),
157
+ vrf: briefVrf(1, 'blue'),
158
+ }),
159
+ ],
160
+ })
161
+ : fakeFailure(400, 'unexpected request'),
162
+ );
163
+ expect(await readThrough(fake.fetch, { prefix: '10.0.0.0/24', vrf: 1 })).toEqual({
164
+ comments: 'management range',
165
+ description: 'core VLAN',
166
+ isPool: true,
167
+ markUtilized: true,
168
+ prefix: '10.0.0.0/24',
169
+ prefixId: 7,
170
+ status: 'deprecated',
171
+ tenant: 3,
172
+ vlan: 5,
173
+ vrf: 1,
174
+ });
175
+ });
176
+ });
177
+
178
+ describe('Netbox.Prefix spec.create / spec.update.call / spec.destroy — the real wire calls', () => {
179
+ const props: PrefixProps = { description: 'core VLAN', prefix: '10.0.0.0/24', status: 'active' };
180
+ const update = spec.update;
181
+ if (update === undefined) throw new Error('Netbox.Prefix must declare spec.update');
182
+
183
+ test('create POSTs the constraint-checked body to /api/ipam/prefixes/', async () => {
184
+ const fake = fakeNetbox((method, url) =>
185
+ method === 'POST' && url.pathname === PREFIXES_PATH
186
+ ? Response.json(livePrefix({ description: 'core VLAN' }), { status: 201 })
187
+ : fakeFailure(400, 'unexpected request'),
188
+ );
189
+ const body = spec.createBody(props);
190
+ const live = await Effect.runPromise(
191
+ spec.create(props, body).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
192
+ );
193
+ expect((live as ipam.Prefix).id).toBe(7);
194
+ expect(fake.seen).toEqual([{ method: 'POST', path: PREFIXES_PATH }]);
195
+ });
196
+
197
+ test('update PATCHes /api/ipam/prefixes/{id}/, addressed by the live row numeric id', async () => {
198
+ const fake = fakeNetbox((method, url) =>
199
+ method === 'PATCH' && url.pathname === PREFIX_ID_PATH
200
+ ? Response.json(livePrefix({ status: { label: 'Deprecated', value: 'deprecated' } }))
201
+ : fakeFailure(400, 'unexpected request'),
202
+ );
203
+ const declared = { ...props, status: 'deprecated' as const };
204
+ const body = update.body(declared);
205
+ const after = await Effect.runPromise(
206
+ update.call(declared, livePrefix(), body).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
207
+ );
208
+ expect((after as ipam.Prefix).status?.value).toBe('deprecated');
209
+ expect(fake.seen).toEqual([{ method: 'PATCH', path: PREFIX_ID_PATH }]);
210
+ });
211
+
212
+ test('destroy DELETEs /api/ipam/prefixes/{id}/', async () => {
213
+ const fake = fakeNetbox((method, url) =>
214
+ method === 'DELETE' && url.pathname === PREFIX_ID_PATH
215
+ ? new Response(null, { status: 204 })
216
+ : fakeFailure(400, 'unexpected request'),
217
+ );
218
+ await Effect.runPromise(
219
+ spec.destroy(props, livePrefix()).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
220
+ );
221
+ expect(fake.seen).toEqual([{ method: 'DELETE', path: PREFIX_ID_PATH }]);
222
+ });
223
+ });