Uh oh!
There was an error while loading. Please reload this page.
feat(devx): ratchet what packages/client's built exports resolve to - #12115
Merged
Conversation
#8140 bound 51 erased return types across `packages/client/src/index.ts` by hand. That buys a snapshot, not a property — nothing stopped the 52nd. Adds `scripts/check-exported-any-returns.mts`, a root gate with a thin per-package alias (the `check:test-typecheck` delegation shape), wired as a step in lint.yml's dist-reading lane. It reads the BUILT `.d.ts` because the largest erasure class carries no return annotation at all and so is invisible to every grep: 17 sites take their type from `unwrapResponse<any>` and 43 from a bare `res.json()`, which lib.dom declares `Promise<any>`. A caller-supplied `<T = any>` is not erasure and is not flagged — the signature is read uninstantiated, so the type parameter never resolves to `any`. Both directions are pinned in `--self-test`. Baseline, not zero: 65 sites are carried as named, shrink-only ledger entries with a written reason each, because four keep `any` deliberately (no contract exists to bind). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
`check:type-check-debt` measures the ROOT tsc program, and a new `.mts` under scripts/ lands inside it. Without asking for node's types the file contributed 35 raw errors to the `@objectstack/spec-monorepo` entry — a shrink-only ratchet, so the remedy is to make the file typecheck, never to raise the entry. It has to be `/// <reference types="node" />` rather than explicit imports, measured: with the reference removed and every builtin imported by name the file still carried 12 errors and the `node:*` specifiers themselves did not resolve. Because @types/node declares globals, asking for it here also supplies them to the rest of the program, which lowered the entry from 80 to 26. Closed with the gate's own `--lower`, which writes the measured number and records `compositionAt` so the stale note is not read as current. Adds `scripts/invoked-as.d.mts` so the entry-point predicate is typed rather than `any` — the partial-mirror shape `check-regen-pending.d.mts` already uses, and automatically covered by check:declaration-mirrors' discovered corpus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
Contributor
📓 Docs Drift Check
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): |
…ient-exported-any-gate
`check-ratchet-remedy-authority` sweeps `scripts/*.{mjs,mts}` and audits its
control corpus for SET EQUALITY both ways, so a new gate that hands out a
ratchet-expanding remedy is UNCLASSIFIED until its shape is recorded. CI was the
first thing to say so — no derivation surfaces this obligation.
Recorded as `marked`, which is the sweep's own verdict (`--list` puts it under
`marked`, anchor `shrink`) and not merely the author's intent. Marking rather
than refusal is the honest shape: four of the 65 ledgered sites keep `any`
because no published contract exists to bind (#11924), so adding a site is a
real act with a real owner rather than one to deny.
The control is a declaration registry, not a ratchet — adding a row is the
prescribed fix and weakens nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6yinlianghui
marked this pull request as ready for review
August 25, 2026 10:21
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#11927
#8140 bound 51 erased return-type sites across
packages/client/src/index.tsby hand. That buys asnapshot, not a property — nothing stopped the 52nd. This adds the ratchet.
All numbers below were re-measured at head
16debac25(withorigin/mainmerged in), against abuilt
packages/client/dist.Shape
A root
scripts/module with a thin per-package alias — thecheck:test-typecheckdelegationshape this package already uses, which keeps the whole landing in devx territory.
⛔
packages/spec/**is untouched; the card's "generalisepackages/spec/scripts/check-exported-any.ts" suggestion crosses a seat boundary, so that file wasread and modelled on, never edited.
scripts/check-exported-any-returns.mts--self-testpackages/client/exported-any-returns.jsonpackages/client/package.jsoncheck:exported-any-returnsalias.github/workflows/lint.ymlscripts/invoked-as.d.mtsscripts/check-type-check-coverage.mjsscripts/check-ratchet-remedy-authority.mjsWhy it reads a built
.d.ts, and the baseline that fell outThe largest erasure class carries no return annotation at all, so no grep can see it. Asking
the checker what each export resolves to found 65 sites, which classify cleanly:
Promise< any >, deliberate — no contract exists to bindreturn this.unwrapResponse< any >(res)return res.json()— lib.dom types itPromise< any >organizations.invite⭐ Two numbers here came out against the dispatch brief's reading, and both are load-bearing:
unwrapResponse< …any… >#11925's 38. The gate flags 17 of them. The other21 are typed
{ package: any }/{ project: any }— return types that CONTAINanyratherthan BEING
any, which is out of scope by the same linepackages/spec's gate draws. They arenot silently absorbed and not silently excluded: the ledger's
$commentstates the exclusion andpoints at A fifth client-SDK erasure spelling no grep in #8140's census counted: 38 methods with NO return annotation, typed from
unwrapResponse< …any… >#11925.res.json()sites are a sixth spelling that neither client SDK drops the precise spec types at its boundary: 32 methods returnPromise< any >on a package that already depends on@objectstack/spec#8140's census nor A fifth client-SDK erasure spelling no grep in #8140's census counted: 38 methods with NO return annotation, typed fromunwrapResponse< …any… >#11925 names —larger than the other five combined, and invisible to every grep either used. Filed as A sixth client-SDK erasure spelling, larger than the other five combined: 43 exported methods
return res.json()directly, whose lib.dom type isPromise< any >#12104.Baseline, not zero: all 65 are named ledger entries with a written reason each, so the file
reads as a worklist rather than a silencer. There is deliberately no
--updateflag — a reasona tool wrote is not a reason.
A caller-supplied generic is not erasure
data.*andactions.*take< T = any >by design. This needs no heuristic, because the typesystem already draws the line: the signature is read uninstantiated, so
The
= anydefault never enters the answer — a default is what an absent type argument resolvesto at a call site, and no call site is read. Measured: 51 caller-supplied generics reached, none
flagged. Both directions are pinned in
--self-test.Evidence
Every leg below was run on this branch; ablations state their rebuild and how the mutation was
confirmed on disk.
Detector, both directions —
--self-testcompiles a fixture mirroring the real emitted shape(a class with nested type-literal namespaces). Anti-vacuity floor is enumerated, not estimated: 12
callables, 3 generic. It pins that
Promise< any >and bareanyARE flagged through nestednamespaces, and that caller-supplied generics,
any-CONTAINING types and named data properties areNOT.
The real 52nd site, end to end. Injected an unannotated
res.json()method intopackages/client/src/index.ts— the exact shape of the 43 — confirmed on disk by grepping for theinjected marker (count 1), rebuilt the package (
VERDICT command-exit 0), confirmed the markerreached
dist/index.d.ts(count 1), then read the gate:The restore leg rebuilt too, and confirmed the marker absent from
dist(count 0). Both legsrun under a
trap … EXIT INT TERM, so a timeout cannot leave a mutated tree behind.Ratchet, both directions, against the real package — deleting a ledger entry goes red
(unledgered); adding one for
data.get(a caller-supplied generic, never erased) goes red asstale, naming the reason on file. Missing
--packagerefuses rather than guessing.Built-dist refusal, both causes — dist moved away → "holds no .d.ts declarations — the package
is not built";
srctouched newer → "holds .d.ts declarations OLDER than packages/client/src".Control: the restored tree is green again. The freshness rule is imported from
scripts/check-regen-pending.mjsso no second notion of "is this dist current" is created; therefusal text is local because
packages/spec/scripts/lib/dist-freshness.tslives inside anotherpackage and a root gate depending on it would invert the layering.
Gates, re-derived and re-run at
16debac25— 38 of 38 exit 0, list derived bynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(it reads the change set fromgit itself), plus
check:declaration-mirrors,check:ratchet-remedy-authority --self-testandcheck:nul-bytes.check:type-check-debtgreen separately (re-measure: "none above its recordednumber").
pnpm --filter @objectstack/client typecheckgreen ("0 file(s) / 0 error(s) held intest-typecheck-debt.json"); tests 25 files / 332 passing.
Repo-wide eslint — the full farm, not a narrowed run:
pnpm lint(
eslint . --no-inline-config) at16debac25, exit 0, 77s.Patch round (CI at
3ee515c7ffound two things no derivation could)check-ratchet-remedy-authority— UNCLASSIFIED. That gate sweepsscripts/*.{mjs,mts}andaudits its control corpus for set equality both ways, so a new gate handing out a
ratchet-expanding remedy is a failure until its shape is recorded. Recorded as
marked—which is the sweep's own verdict (
--listputs this file undermarked, anchorshrink),not merely the author's intent; the corpus exists precisely so those two have to agree. Marking
rather than refusal is the honest shape: four of the 65 sites keep
anybecause no publishedcontract exists to bind (Four client SDK routes answer a shape no published contract declares —
automation.create/automation.update/search/data.clone#11924), so adding a site is a real act with a real owner ratherthan one to deny. The control is a declaration registry, not a ratchet, so the row weakens
nothing.
Check Changeset. The label mechanism, not the derivation —skip-changesethad beenapplied minutes after the PR opened, and the check now reports
skipped. Read back: labels aresize/l+skip-changeset, so the whole-set write did not drop the size label.origin/mainwas merged (not rebased) before both fixes; it was clean.edits
scripts/check-type-check-coverage.mjsas this PR does, had not landed as of399ecad58— so
mainwill want merging again once it does.Root tsc program — the +35 that became −54
check:type-check-debtmeasures the ROOT tsc program, and a new.mtsunderscripts/landsinside it. Unannounced, this file added 35 raw errors to the
@objectstack/spec-monorepoentry.That entry is shrink-only, so the remedy is to make the file typecheck — never to raise it.
The fix is a triple-slash
referencedirective asking for node's types (spelled literally in thefile, not here — it is the exact tag shape this body's sanitizer eats). It has to be that and not
explicit imports, measured rather than assumed: with the reference removed and every builtin
imported by name the file still carried 12 errors and the
node:specifiers themselves did notresolve.
Because @types/node declares globals, asking for it here also supplies them to the rest of the
program, which took the entry from 80 → 26. Closed with the gate's own
pnpm check:type-check-debt --lower, which writes the measured number and recordscompositionAt: 80so the now-stale tier note is not read as current. ⭐ Keeping the lowering isthe reviewer's ruling on the card, against the alternative of leaving the diff narrower.
--loweralso banked an unrelated-1on@objectstack/plugin-approvals' test ledger; that wasreverted by hand as pre-existing drift this card did not cause — which, per the review, also
avoided a collision with PR #12110, in the merge queue lowering that exact entry 348 → 347.
scripts/invoked-as.d.mtsis the partial-mirror shapecheck-regen-pending.d.mtsalready uses, andis automatically covered by
check:declaration-mirrors' discovered corpus (green above).Changeset:
skip-changeset, derivedApplied the label rather than writing a changeset. Basis, measured with
npm pack --dry-run: thepublished tarball's only entry this diff touches is
package.json, and the change is ascriptskey an installer never executes.
dist/is byte-identical (no source change), README and CHANGELOGare untouched, and the ledger JSON is not published (
filesisdist+ README + CHANGELOG).There is nothing true and useful to write in a consumer CHANGELOG.
The
.changeset/cli-readme-drop-os-studio.mdprecedent — apatchfor pure prose — cuts the otherway on its own facts:
README.mdships and a consumer reads it, and the row it removed was afalse statement a consumer would act on. A
scriptskey has no reader. The direction that wouldflip this: had any
src/return type moved, the publisheddist/*.d.tswould change and that IS aconsumer-visible narrowing — that is #11924 / #11925 / #12104's work, not this card's.
Two things deliberately not done here
packages/client-react— named in the card as the obvious second consumer, and out of scopeby the dispatch. Generalising is cheap: the gate already takes
--packageand resolves the entry.d.tsfrom that package's ownexportsmap, so onboarding is one alias plus a seeded ledger.Making this gate visible to the dispatch derivation.
resolveCheckToFilesmatches only.mjs|.cjs|.js|.sh, so all 23 TypeScript-authored gates —check:exported-anyandcheck:api-surfaceincluded — resolve to zero gate files and contribute zero watch hints. Filedas
resolveCheckToFilesmatches only.mjs|.cjs|.js|.sh, so all 23 TypeScript-authored gates resolve to ZERO gate files and contribute ZERO watch hints #12107. Writing this file in JavaScript to satisfy a regex would be consumer-side tolerance fora producer-side defect, and widening the regex re-attributes 23 families' matched lists
fleet-wide.
Three consequences worth stating, since all are silences rather than signals. The bare-root
verdict was established from the sweep's own predicates run directly against this source
(
populationSpans→ one span,PKG_DIR;bareRootLiterals→ none), not frombare-root-worklist --self-testpassing — that pass could not see the file.check:entry-guardscans the same three extensions, so its green here is vacuous too; the import-safety convention is
held by hand instead, and verified by importing the module and looking. That probe caught a
real defect a reading would not have: the
isEntrypointguard was in place and the module stillcrashed on import, because an argv-reading top-level
constthrew during evaluation. Targetresolution is lazy now, and the exports are genuinely inert. The third is the
check-ratchet-remedy-authorityclassification above — but see the note onresolveCheckToFilesmatches only.mjs|.cjs|.js|.sh, so all 23 TypeScript-authored gates resolve to ZERO gate files and contribute ZERO watch hints #12107: that one has adifferent root cause and is already recorded elsewhere.
Generated by Claude Code