test(client): pin the environments.* any-CONTAINING family by membership - #19422
os-project-manager merged 2 commits into
Conversation
A prose docblock was the only thing holding the 14 `any`-carrying methods on `ObjectStackClient.environments.*`. `check:exported-any-returns` asks whether an awaited return type IS `any` and never whether it CONTAINS one -- a deliberate, documented scope that buys the gate zero false positives -- and the sites carry no return annotation at all, so no text search can see them either. A 15th such method landed silently green. Pinned by membership rather than by a CONTAINS-any detector, because a census over the built surface shows CONTAINS-any has no canonical boundary here: the population is a function of the walk's hop budget (24 at 3 hops, 43 at 4, 57 at 5 and 6), an unbounded walk does not terminate, and a package-wide rule flags the caller-shaped `any` the ratchet's ledger protects by name. The membership reading is stable across every bound measured: 21 callables on the namespace, 0 annotated, 14 carrying `any` in their own envelope. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
The diff's only published byte change is one TSDoc paragraph on `ObjectStackClient.environments`, measured in all four emitted artifacts (index.js, index.mjs, index.d.ts, index.d.mts). The pin file itself ships nothing -- zero hits for its symbols under the package's `files[]`. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 54112f827e4711b7e70901bc0669613f33c9138f && git checkout 54112f827e4711b7e70901bc0669613f33c9138f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f20fe298a18e23d99791cfe34b52aaf229d039d3 98784aa33f48b99fce82fcec978e1b04e738b87b && git checkout -B drift-repro f20fe298a18e23d99791cfe34b52aaf229d039d3 && git merge --no-ff 98784aa33f48b99fce82fcec978e1b04e738b87b
node scripts/docs-audit/affected-docs.mjs --json f20fe298a18e23d99791cfe34b52aaf229d039d3
|
ACCEPT — and the order's sketch was the wrong instrument, measuredHead judged: The census, re-measured independently at
|
Fixes #19383
Clause-②: no
A prose docblock was the only thing holding the 14
any-CONTAINING methods onObjectStackClient.environments.*. This PR enumerates the family by name, in a pin that fails when a 15th arrives.The census — instrument first, because a grep cannot answer this
ts.createProgram+TypeChecker, asking the two halves separately, over the same walkscripts/check-exported-any-returns.mtsuses so the keys line up with its ledger:.d.tsbecause tsup always emits one, and this run measures exactly that: 292/331 annotated in source, 318/318 in dist.checker.getAwaitedTypeCONTAINany— a type property. Invisible to text, because these methods carry no annotation at all, soany/Promise/unwrapResponsenever appear on a signature line.Taken in
objectstack-ai/objectstackat8ddefbc977da(the branch point), both programs built from that tree.Control, before any reading was believed: 17/17 fixture cases land in their pre-declared bucket — including
Record<string, any>, the mapped type whosetypeArgumentsis empty and which put the #11925 instrument's first version in the clean bucket. The descent reaches it throughgetIndexInfosOfType.What it found
anyanyObjectStackClient.environments.*callablesanyThe card's count of 14 reproduces exactly, and so does its membership:
list,get,create,update,activate,rotateCredential,updateHostname,retryProvisioning, andpackages.list,packages.install,packages.get,packages.enable,packages.disable,packages.upgrade.The clean bucket was read, not assumed. The 7
environments.*callables that are NOT in the family —delete,listRevisions,listBranches,renameBranch,deleteBranch,listDrivers,packages.uninstall— are unannotated too and concrete anyway ({ environmentId, deleted, archived, purgeDeferred, retention… },{ drivers: Array of name+driverId, total },{ id, success }). They are what proves this is a real discriminator and not "the whole namespace".The gate's own run on the same tree reports
318 callables reached (52 caller-supplied generics), 2 ledgered site(s) still open— the dist half of this census reached 318 with 52 generics, so the instrument and the landed ratchet agree on the population they share.The decision: a guard, but NOT the one the card sketched
The card suggested an allow-list over a CONTAINS-any detector. The census says that instrument is the wrong one, for three measured reasons.
1. CONTAINS-any is not a predicate, it is a predicate plus a hop budget. Package-wide population by how far the walk may descend: 24 at 3 hops, 43 at 4, 57 at 5, 57 at 6. An unbounded walk does not terminate in practice — it forces instantiation of the whole
@objectstack/spectype graph and OOMs at 8 GB, measured twice. And 144 callables are still depth-truncated at 6 hops, so the clean bucket is never proved clean. A gate built on this would have to declare an arbitrary constant, and its green would mean "noanywithin N hops".2. Its false positives are exactly the class the ledger protects by name. Of the 16 unannotated any-containing sites, the 2 outside this namespace are
organizations.list(better-auth organisationmetadata) andoauth.applications.list(Record<string, any>[], the opaque OAuth client row) — the ledger's own two examples. Widen to the annotated surface and it gets worse: at 4 hops, 29 of 43 hits areResponse.json()(lib.dom),AsyncIterable'sTReturn, or theFilterConditionoperator bag. False-positive rate 12.5% on the unannotated slice, 67.4% at 4 hops, 75.4% at 6.3. The two vantage points disagree, and the gate's vantage point is the wrong one here.
check:exported-any-returnsreads the built.d.ts, and must, for half (b) of the unannotated methods. But tsup emitsprivate query;with no type, so it resolves toany— and 13 callables are CONTAINS-any in dist and clean in source on that account alone (QueryBuilder's 11 chainable methods,createQuery, andObjectStackClient.environment). Half (a) is unreadable there anyway.What landed instead
A membership pin —
packages/client/src/environments-any-family.pin.test.ts— which asks the bounded question the census can actually settle, and never asks CONTAINS-any at build time.any; zero if it is bound to a concrete contract. Binding one of the 14 is the shrink-only direction: remove the name.any: 0/21 measured. The namespace has no caller-supplied generic at all, which is precisely why the scope stops at the namespace.Four assertions, all compiled by
tsconfig.test.json(whichpackage.json'stypecheckscript names, so none is the phantom class AGENTS.md warns about): the exact key set ofenvironments(15) and ofenvironments.packages(7), and the exactany-carrying subset of each (8 + 6 = 14). A runtimeObject.keyshalf catches a 15th method even when it is fully typed, because #12036's blanket licence would cover that one too.The
index.tsdocblock now points at it and says the licence is bounded — the half that makes the two findable from each other.Ablation — every leg mutated on disk, measured, and restored byte-identically
All three through
scripts/ablation-replace.mjs, from the committed state, each restore verified asblob == HEADwithgit diff HEADempty. No dist rebuild was needed: the pin imports./indexrelatively, so bothtsc -p tsconfig.test.jsonand vitest read source.ablationFifteenthreturning{ environment: any }intoenvironmentstscred atEnvironmentsKeysArePinnedandEnvironmentsAnyFamilyIsPinned(TS2344,Type 'false' does not satisfy the constraint 'true')expected [ 'ablationFifteenth', …(15) ] to deeply equal [ 'activate', 'create', …(13) ]IsAnycollapsed to always-trueIsAnycollapsed to always-falseThe two collapse legs are why the control battery is not decoration: a predicate stuck on either verdict dies in the file, and the expected direction was declared before each run.
Tests
At
98784aa(this branch's head):pnpm --filter @objectstack/client test— 49 files, 568 tests, all pass.pnpm --filter @objectstack/client typecheck— pass, includingcheck:test-typecheck(0 file(s) / 0 error(s)held intest-typecheck-debt.json).pnpm --filter @objectstack/client check:exported-any-returns— self-test pass, thenno NEW exported callable resolves to any: 318 callables reached (52 caller-supplied generics), 2 ledgered site(s) still open. Unchanged, as intended: this PR adds nothing to that ledger and removes nothing from it.pnpm --filter '@objectstack/client...' build— pass.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackwere run; results in the report on the card.Changeset —
patch, and the class, measured.changeset/19383-environments-any-family-membership-pin.md,@objectstack/client: patch.skip-changesetwas tested and refused by measurement, not by judgement. The package'sfiles[]is["dist","README.md","CHANGELOG.md"]; after a rebuild, the pin file's symbols get 0 hits anywhere underdist/, but the docblock paragraph gets 4 —index.js,index.mjs,index.d.ts,index.d.mts, because tsup keeps the JSDoc in the JS output too. So the diff publishes.The class is documentation-only change to published bytes: no export added, removed or renamed; no signature, envelope key, accept set or runtime behaviour moves; nothing an author can write changes. That is
patch— notminor(nothing new is authorable) and not breaking (nothing narrows).Clause-②: no, so no ADR-0087 marker applies.Acceptance notes
Noted here rather than filed — neither fits a filing class (no reproducible defect, no violated declared contract, no metadata-authoring trap):
packages/client/exported-any-returns.json's$commentcarries a count that no longer reproduces. It states "21 of A fifth client-SDK erasure spelling no grep in #8140's census counted: 38 methods with NO return annotation, typed fromunwrapResponse< …any… >#11925's 38 unannotated methods are absent here: they areany-CONTAINING". Measured on this tree: 39 unannotated, of which 2 IS-any(both ledgered), 16 CONTAINS-any, 21 clean — and [finding] theenvironments.*any-CONTAINING family is held by a prose docblock alone —check:exported-any-returnsasks IS-any, never CONTAINS-any, and the sites carry no annotation for text to see #19383's own body records A fifth client-SDK erasure spelling no grep in #8140's census counted: 38 methods with NO return annotation, typed fromunwrapResponse< …any… >#11925's instrument reading 18 after its false-negative fix, a third number. The$commentcites no repository or commit, so it is prose rather than a reading. Whoever next edits that ledger — the file is touched on every ledger or graduation — is the one who will read it; left alone here because this card is explicitly not about the ratchet.tsuperases private field types in the emitted.d.ts, and a dist-readinganygate would inherit 13 phantom hits.private query: Partial<QueryAST>emits asprivate query;, which resolves toany, soQueryBuilder's 11 chainable methods,createQueryandObjectStackClient.environmentread as CONTAINS-any in dist and clean in source. Nothing today is wrong — the existing ratchet asks IS-anyand never descends — but it is load-bearing for any successor that widens it, which is this card's own successor.Generated by Claude Code