|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#19383] The `environments.*` any-CONTAINING family, pinned by MEMBERSHIP. |
| 5 | + * |
| 6 | + * ## What was holding this family before this file |
| 7 | + * |
| 8 | + * A prose docblock, and nothing else. `index.ts` says, above `environments`, |
| 9 | + * that *every* unannotated method in this namespace and in the nested |
| 10 | + * `packages` block keeps its erased `any` deliberately (#12036, hardened when |
| 11 | + * ruling B on #16325 moved the control-plane contracts out of this repo — |
| 12 | + * `packages/spec/src/cloud` is absent here today). That sentence is a blanket |
| 13 | + * licence over an UNBOUNDED future population: a 15th such method inherits it |
| 14 | + * on arrival, with nothing for a reviewer to point at. |
| 15 | + * |
| 16 | + * Two mechanisms that look like they would hold it, and cannot: |
| 17 | + * |
| 18 | + * - `check:exported-any-returns` (#11927) asks whether an awaited return type |
| 19 | + * **IS** `any`, never whether it **CONTAINS** one. That scope is deliberate, |
| 20 | + * documented in its ledger `$comment`, and is what buys the gate its |
| 21 | + * zero-false-positive property. All 14 of these sites are `any`-CONTAINING, |
| 22 | + * so the gate is silent about them BY DESIGN and correctly so. |
| 23 | + * - any text search. These 21 callables carry NO return annotation at all, so |
| 24 | + * `any`, `Promise` and `unwrapResponse` need never appear on a signature |
| 25 | + * line. That is #11925's own thesis, and it is why the census behind this |
| 26 | + * file used `ts.createProgram` + `TypeChecker` rather than a grep. |
| 27 | + * |
| 28 | + * ## The reading this file pins |
| 29 | + * |
| 30 | + * Census over `packages/client/src/index.ts` in `objectstack-ai/objectstack` |
| 31 | + * at `8ddefbc977da`, asking the two halves separately — (a) does the SOURCE |
| 32 | + * declaration node write an explicit return type (an AST property, invisible in |
| 33 | + * a built `.d.ts` because tsup always emits one: 318/318 annotated there against |
| 34 | + * 292/331 in source), and (b) does `checker.getAwaitedType` CONTAIN `any` (a |
| 35 | + * type property, invisible to text): |
| 36 | + * |
| 37 | + * ObjectStackClient.environments.* 21 callables, 0 annotated |
| 38 | + * 14 CONTAINS-any, 7 clean |
| 39 | + * package-wide, unannotated 39 callables |
| 40 | + * 2 IS-any (both already ledgered) |
| 41 | + * 16 CONTAINS-any |
| 42 | + * |
| 43 | + * The 2 unannotated any-CONTAINING sites outside this namespace are |
| 44 | + * `organizations.list` (better-auth organisation `metadata`) and |
| 45 | + * `oauth.applications.list` (`Record<string, any>[]`, the opaque OAuth client |
| 46 | + * row) — i.e. exactly the caller-shaped class the ratchet's ledger protects by |
| 47 | + * name. That is why this pin is scoped to the NAMESPACE and does not become a |
| 48 | + * package-wide CONTAINS-any rule: measured against the same census, a |
| 49 | + * package-wide rule flags 43 sites at a 4-hop bound and 57 at 6, and all but |
| 50 | + * these 14 are caller-shaped, lib-shaped (`Response.json()`, `AsyncIterable`'s |
| 51 | + * `TReturn`) or the `FilterCondition` operator bag. |
| 52 | + * |
| 53 | + * ## Why MEMBERSHIP and not a CONTAINS-any detector |
| 54 | + * |
| 55 | + * "Contains `any`" has no canonical boundary over this surface: the population |
| 56 | + * is a function of how many hops the walk is allowed (24 at 3, 43 at 4, 57 at |
| 57 | + * 5 and 6), an unbounded walk does not terminate in practice, and 144 callables |
| 58 | + * are still unexplored at 6 hops — so a CONTAINS-any gate's green would mean |
| 59 | + * "no `any` within N hops", never "no `any`". This file asks a bounded question |
| 60 | + * instead: WHICH KEYS are on the namespace, and which of their envelopes carry |
| 61 | + * `any` in their own top two levels. Both are stable across every bound |
| 62 | + * measured (3, 4, 5, 6). |
| 63 | + * |
| 64 | + * ## What goes red, and what it costs |
| 65 | + * |
| 66 | + * A 15th method on `environments` or `environments.packages` fails BOTH the |
| 67 | + * runtime key pin and — if its envelope carries `any` — the type pin. Cost to |
| 68 | + * land one: add its name to the union below, in a diff someone reads. Binding |
| 69 | + * one of the 14 to a real contract also goes red, in the shrink-only direction: |
| 70 | + * remove the name. Neither is a refusal; both are a sentence someone has to |
| 71 | + * write. |
| 72 | + */ |
| 73 | + |
| 74 | +import { describe, it, expect } from 'vitest'; |
| 75 | +import { ObjectStackClient } from './index'; |
| 76 | + |
| 77 | +type EnvironmentsNamespace = ObjectStackClient['environments']; |
| 78 | +type EnvironmentPackagesNamespace = EnvironmentsNamespace['packages']; |
| 79 | + |
| 80 | +// ── The predicate ─────────────────────────────────────────────────────────── |
| 81 | + |
| 82 | +/** |
| 83 | + * `any` absorbs every intersection, so `1 & T` is `any` exactly when `T` is, |
| 84 | + * and only `any` makes `0 extends …` true. A caller-supplied `<T = any>` is |
| 85 | + * NOT `any` here for the same reason the #11927 ratchet gives: the default is |
| 86 | + * what an absent type ARGUMENT resolves to at a call site, and no call site is |
| 87 | + * read. |
| 88 | + */ |
| 89 | +type IsAny<T> = 0 extends 1 & T ? true : false; |
| 90 | + |
| 91 | +/** The element of an array type; anything else unchanged. */ |
| 92 | +type Unwrap<T> = T extends readonly (infer E)[] ? E : T; |
| 93 | + |
| 94 | +/** Assertion carrier: a `false` here is a compile error, which is the point. */ |
| 95 | +type Assert<T extends true> = T; |
| 96 | + |
| 97 | +/** Both directions, so a wider OR narrower union is equally red. */ |
| 98 | +type Exact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false; |
| 99 | + |
| 100 | +/** |
| 101 | + * Does this envelope carry `any` in its own top two levels — the envelope |
| 102 | + * itself, or one of its members, or the element of a member array? |
| 103 | + * |
| 104 | + * The 2-hop bound is DECLARED, not accidental: the control battery below pins |
| 105 | + * that a 3-hop `any` reads `false`. Every one of the 14 sites carries its `any` |
| 106 | + * at hop 1 or 2, written directly into the method's own `unwrapResponse<…>` |
| 107 | + * type argument, which is what makes the bound safe here and is exactly the |
| 108 | + * property that separates them from the deep caller-shaped hits. |
| 109 | + */ |
| 110 | +type EnvelopeCarriesAny<T> = IsAny<T> extends true |
| 111 | + ? true |
| 112 | + : T extends object |
| 113 | + ? true extends { [K in keyof T]-?: IsAny<Unwrap<NonNullable<T[K]>>> }[keyof T] |
| 114 | + ? true |
| 115 | + : false |
| 116 | + : false; |
| 117 | + |
| 118 | +/** The keys of a namespace whose awaited envelope carries `any`. */ |
| 119 | +type AnyCarryingKeys<N> = { |
| 120 | + [K in keyof N]-?: N[K] extends (...args: never[]) => unknown |
| 121 | + ? EnvelopeCarriesAny<Awaited<ReturnType<N[K]>>> extends true |
| 122 | + ? K |
| 123 | + : never |
| 124 | + : never; |
| 125 | +}[keyof N]; |
| 126 | + |
| 127 | +// ── CONTROL ───────────────────────────────────────────────────────────────── |
| 128 | +// |
| 129 | +// A pin that only inspects its own hits cannot find its own false negatives, |
| 130 | +// and a predicate that collapsed to a constant would hold every assertion below |
| 131 | +// it green forever. These are compiled by `tsconfig.test.json` (which `package |
| 132 | +// .json`'s `typecheck` script names), so they are real checks rather than the |
| 133 | +// phantom class AGENTS.md warns about. Both verdicts are exercised. |
| 134 | + |
| 135 | +/** TRUE side — including the mapped-type shape a `typeArguments`-only walk misses. */ |
| 136 | +export type ControlDirectAny = Assert<Exact<EnvelopeCarriesAny<any>, true>>; |
| 137 | +export type ControlMemberAny = Assert<Exact<EnvelopeCarriesAny<{ environment: any }>, true>>; |
| 138 | +export type ControlMemberAnyArray = Assert<Exact<EnvelopeCarriesAny<{ environments: any[]; total: number }>, true>>; |
| 139 | +export type ControlOptionalMemberAny = Assert<Exact<EnvelopeCarriesAny<{ a: string; b?: any }>, true>>; |
| 140 | +export type ControlIndexSignatureAny = Assert<Exact<EnvelopeCarriesAny<Record<string, any>>, true>>; |
| 141 | + |
| 142 | +/** FALSE side — a predicate stuck on `true` dies here. */ |
| 143 | +export type ControlConcrete = Assert<Exact<EnvelopeCarriesAny<{ id: string; total: number }>, false>>; |
| 144 | +export type ControlConcreteArray = Assert<Exact<EnvelopeCarriesAny<{ items: { id: string }[] }>, false>>; |
| 145 | +export type ControlUnknown = Assert<Exact<EnvelopeCarriesAny<{ payload: unknown }>, false>>; |
| 146 | +export type ControlGenericParam = Assert<Exact<EnvelopeCarriesAny<{ rows: unknown[] }>, false>>; |
| 147 | +/** The declared 2-hop bound: `any` three levels down reads FALSE, on purpose. */ |
| 148 | +export type ControlBeyondTheBound = Assert<Exact<EnvelopeCarriesAny<{ a: { b: any } }>, false>>; |
| 149 | + |
| 150 | +// ── The pins ──────────────────────────────────────────────────────────────── |
| 151 | + |
| 152 | +/** |
| 153 | + * Every key on `client.environments`. `packages` is the nested namespace |
| 154 | + * object, not a method; the other 14 are the callables. |
| 155 | + */ |
| 156 | +export type EnvironmentsKeysArePinned = Assert< |
| 157 | + Exact< |
| 158 | + keyof EnvironmentsNamespace, |
| 159 | + | 'list' |
| 160 | + | 'get' |
| 161 | + | 'create' |
| 162 | + | 'update' |
| 163 | + | 'delete' |
| 164 | + | 'activate' |
| 165 | + | 'rotateCredential' |
| 166 | + | 'updateHostname' |
| 167 | + | 'listRevisions' |
| 168 | + | 'listBranches' |
| 169 | + | 'renameBranch' |
| 170 | + | 'deleteBranch' |
| 171 | + | 'retryProvisioning' |
| 172 | + | 'listDrivers' |
| 173 | + | 'packages' |
| 174 | + > |
| 175 | +>; |
| 176 | + |
| 177 | +/** Every key on the environment-scoped `client.environments.packages`. */ |
| 178 | +export type EnvironmentPackagesKeysArePinned = Assert< |
| 179 | + Exact< |
| 180 | + keyof EnvironmentPackagesNamespace, |
| 181 | + 'list' | 'install' | 'get' | 'enable' | 'disable' | 'uninstall' | 'upgrade' |
| 182 | + > |
| 183 | +>; |
| 184 | + |
| 185 | +/** |
| 186 | + * The 8 `environments.*` methods whose envelope carries `any`. The 6 absentees |
| 187 | + * — `delete`, `listRevisions`, `listBranches`, `renameBranch`, `deleteBranch`, |
| 188 | + * `listDrivers` — are unannotated too, and are concrete anyway: they are what |
| 189 | + * proves this pin is not simply "the whole namespace". |
| 190 | + */ |
| 191 | +export type EnvironmentsAnyFamilyIsPinned = Assert< |
| 192 | + Exact< |
| 193 | + AnyCarryingKeys<EnvironmentsNamespace>, |
| 194 | + | 'list' |
| 195 | + | 'get' |
| 196 | + | 'create' |
| 197 | + | 'update' |
| 198 | + | 'activate' |
| 199 | + | 'rotateCredential' |
| 200 | + | 'updateHostname' |
| 201 | + | 'retryProvisioning' |
| 202 | + > |
| 203 | +>; |
| 204 | + |
| 205 | +/** |
| 206 | + * The 6 `environments.packages.*` methods whose envelope carries `any`. |
| 207 | + * `uninstall` is the absentee: it answers `{ id, success }`. |
| 208 | + */ |
| 209 | +export type EnvironmentPackagesAnyFamilyIsPinned = Assert< |
| 210 | + Exact< |
| 211 | + AnyCarryingKeys<EnvironmentPackagesNamespace>, |
| 212 | + 'list' | 'install' | 'get' | 'enable' | 'disable' | 'upgrade' |
| 213 | + > |
| 214 | +>; |
| 215 | + |
| 216 | +describe('[#19383] the environments.* any-CONTAINING family is pinned by membership', () => { |
| 217 | + /** |
| 218 | + * The runtime half. It catches what the type half deliberately does not: a |
| 219 | + * 15th method that is fully bound to a concrete contract still changes this |
| 220 | + * namespace, and this repo's reason for reading the namespace as one family |
| 221 | + * is #12036's blanket licence, which such a method would also inherit. |
| 222 | + * |
| 223 | + * `environments` is a class property holding an object literal, so |
| 224 | + * `Object.keys` on an instance is exactly the literal's own keys — no |
| 225 | + * prototype walk, no inherited members. |
| 226 | + */ |
| 227 | + it('exposes exactly the 15 environments keys and the 7 packages keys', () => { |
| 228 | + const client = new ObjectStackClient({ baseUrl: 'http://pin.invalid' }); |
| 229 | + |
| 230 | + expect(Object.keys(client.environments).sort()).toEqual( |
| 231 | + [ |
| 232 | + 'activate', |
| 233 | + 'create', |
| 234 | + 'delete', |
| 235 | + 'deleteBranch', |
| 236 | + 'get', |
| 237 | + 'list', |
| 238 | + 'listBranches', |
| 239 | + 'listDrivers', |
| 240 | + 'listRevisions', |
| 241 | + 'packages', |
| 242 | + 'renameBranch', |
| 243 | + 'retryProvisioning', |
| 244 | + 'rotateCredential', |
| 245 | + 'update', |
| 246 | + 'updateHostname', |
| 247 | + ], |
| 248 | + ); |
| 249 | + expect(Object.keys(client.environments.packages).sort()).toEqual( |
| 250 | + ['disable', 'enable', 'get', 'install', 'list', 'uninstall', 'upgrade'], |
| 251 | + ); |
| 252 | + }); |
| 253 | + |
| 254 | + /** |
| 255 | + * Anti-vacuity for the half tsc owns: name the assertion aliases so a |
| 256 | + * reader can see the file really carries them, and state the count this |
| 257 | + * round measured. A `describe` block with no reference to them would leave |
| 258 | + * the type pins looking like commentary. |
| 259 | + */ |
| 260 | + it('carries 14 pinned any-carrying sites across the two namespaces', () => { |
| 261 | + const pinned: Record<string, readonly string[]> = { |
| 262 | + environments: [ |
| 263 | + 'list', |
| 264 | + 'get', |
| 265 | + 'create', |
| 266 | + 'update', |
| 267 | + 'activate', |
| 268 | + 'rotateCredential', |
| 269 | + 'updateHostname', |
| 270 | + 'retryProvisioning', |
| 271 | + ], |
| 272 | + 'environments.packages': ['list', 'install', 'get', 'enable', 'disable', 'upgrade'], |
| 273 | + }; |
| 274 | + expect(pinned.environments.length + pinned['environments.packages'].length).toBe(14); |
| 275 | + |
| 276 | + // Every pinned name is a real callable on the namespace it names — so a |
| 277 | + // rename cannot leave the prose list above pointing at nothing. |
| 278 | + const client = new ObjectStackClient({ baseUrl: 'http://pin.invalid' }); |
| 279 | + for (const key of pinned.environments) { |
| 280 | + expect(typeof (client.environments as Record<string, unknown>)[key]).toBe('function'); |
| 281 | + } |
| 282 | + for (const key of pinned['environments.packages']) { |
| 283 | + expect(typeof (client.environments.packages as Record<string, unknown>)[key]).toBe('function'); |
| 284 | + } |
| 285 | + }); |
| 286 | +}); |
0 commit comments