Skip to content

feat(devx): ratchet what packages/client's built exports resolve to - #12115

Merged
yinlianghui merged 4 commits into
mainfrom
claude/issue-11927-client-exported-any-gate
Aug 25, 2026
Merged

feat(devx): ratchet what packages/client's built exports resolve to#12115
yinlianghui merged 4 commits into
mainfrom
claude/issue-11927-client-exported-any-gate

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11927

#8140 bound 51 erased return-type sites across packages/client/src/index.ts by hand. That buys a
snapshot, not a property — nothing stopped the 52nd. This adds the ratchet.

All numbers below were re-measured at head 16debac25 (with origin/main merged in), against a
built packages/client/dist.

ⓘ Angle brackets are spaced throughout (Promise< any >) on purpose — GitHub's body sanitizer
strips tag-shaped spans, backticks included. First write of this body lost every one of them; the
spaced spelling is what survived the read-back.

Shape

A root scripts/ module with a thin per-package alias — the check:test-typecheck delegation
shape this package already uses, which keeps the whole landing in devx territory.
packages/spec/** is untouched; the card's "generalise
packages/spec/scripts/check-exported-any.ts" suggestion crosses a seat boundary, so that file was
read and modelled on, never edited.

filewhat
scripts/check-exported-any-returns.mtsthe gate + its --self-test
packages/client/exported-any-returns.jsonthe shrink-only ledger, 65 named entries
packages/client/package.jsoncheck:exported-any-returns alias
.github/workflows/lint.ymla step in the existing dist-reading lane
scripts/invoked-as.d.mtstypes for the entry-point predicate (see Root tsc program below)
scripts/check-type-check-coverage.mjsone ledger number, lowered (see below)
scripts/check-ratchet-remedy-authority.mjsone control-corpus row, classifying this gate

Why it reads a built .d.ts, and the baseline that fell out

The 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:

mechanismncard
declared Promise< any >, deliberate — no contract exists to bind4#11924
no annotation, return this.unwrapResponse< any >(res)17#11925
no annotation, return res.json() — lib.dom types it Promise< any >43#12104, filed from this card
no annotation, delegates to an already-erased sibling1closes with organizations.invite

Two numbers here came out against the dispatch brief's reading, and both are load-bearing:

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 --update flag — a reason
a tool wrote is not a reason.

A caller-supplied generic is not erasure

data.* and actions.* take < T = any > by design. This needs no heuristic, because the type
system already draws the line: the signature is read uninstantiated, so

invoke: < T = any >(…) => Promise< T > awaited type is `T`, a TypeParameter — NOT `any`
clone: (…) => Promise< any > awaited type IS `any`

The = any default never enters the answer — a default is what an absent type argument resolves
to 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-test compiles 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 bare any ARE flagged through nested
namespaces, and that caller-supplied generics, any-CONTAINING types and named data properties are
NOT.

The real 52nd site, end to end. Injected an unannotated res.json() method into
packages/client/src/index.ts — the exact shape of the 43 — confirmed on disk by grepping for the
injected marker (count 1), rebuilt the package (VERDICT command-exit 0), confirmed the marker
reached dist/index.d.ts (count 1), then read the gate:

❌ 1 exported callable(s) of @objectstack/client resolve to `any` and are not ledgered:
• `ObjectStackClient.data.theFiftySecondSite` resolves to `Promise< any >`

The restore leg rebuilt too, and confirmed the marker absent from dist (count 0). Both legs
run 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 as
stale, naming the reason on file. Missing --package refuses rather than guessing.

Built-dist refusal, both causes — dist moved away → "holds no .d.ts declarations — the package
is not built"
; src touched 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.mjs so no second notion of "is this dist current" is created; the
refusal text is local because packages/spec/scripts/lib/dist-freshness.ts lives inside another
package and a root gate depending on it would invert the layering.

Gates, re-derived and re-run at 16debac2538 of 38 exit 0, list derived by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (it reads the change set from
git itself), plus check:declaration-mirrors, check:ratchet-remedy-authority --self-test and
check:nul-bytes. check:type-check-debt green separately (re-measure: "none above its recorded
number"
). pnpm --filter @objectstack/client typecheck green ("0 file(s) / 0 error(s) held in
test-typecheck-debt.json"
); tests 25 files / 332 passing.

Repo-wide eslint — the full farm, not a narrowed run: pnpm lint
(eslint . --no-inline-config) at 16debac25, exit 0, 77s.

Patch round (CI at 3ee515c7f found two things no derivation could)

  1. check-ratchet-remedy-authority — UNCLASSIFIED. That gate sweeps scripts/*.{mjs,mts} and
    audits 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 (--list puts this file under marked, anchor shrink),
    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 any because no published
    contract 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 rather
    than one to deny. The control is a declaration registry, not a ratchet, so the row weakens
    nothing.
  2. Check Changeset. The label mechanism, not the derivation — skip-changeset had been
    applied minutes after the PR opened, and the check now reports skipped. Read back: labels are
    size/l + skip-changeset, so the whole-set write did not drop the size label.

origin/main was merged (not rebased) before both fixes; it was clean. ⚠️ PR #12110, which
edits scripts/check-type-check-coverage.mjs as this PR does, had not landed as of 399ecad58
— so main will want merging again once it does.

Root tsc program — the +35 that became −54

check:type-check-debt measures the ROOT tsc program, and a new .mts under scripts/ lands
inside it. Unannounced, this file added 35 raw errors to the @objectstack/spec-monorepo entry.
That entry is shrink-only, so the remedy is to make the file typecheck — never to raise it.

The fix is a triple-slash reference directive asking for node's types (spelled literally in the
file, 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 not
resolve.

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 records
compositionAt: 80 so the now-stale tier note is not read as current. ⭐ Keeping the lowering is
the reviewer's ruling on the card
, against the alternative of leaving the diff narrower.
--lower also banked an unrelated -1 on @objectstack/plugin-approvals' test ledger; that was
reverted 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.mts is the partial-mirror shape check-regen-pending.d.mts already uses, and
is automatically covered by check:declaration-mirrors' discovered corpus (green above).

Changeset: skip-changeset, derived

Applied the label rather than writing a changeset. Basis, measured with npm pack --dry-run: the
published tarball's only entry this diff touches is package.json, and the change is a scripts
key an installer never executes. dist/ is byte-identical (no source change), README and CHANGELOG
are untouched, and the ledger JSON is not published (files is dist + README + CHANGELOG).
There is nothing true and useful to write in a consumer CHANGELOG.

The .changeset/cli-readme-drop-os-studio.md precedent — a patch for pure prose — cuts the other
way on its own facts: README.md ships and a consumer reads it, and the row it removed was a
false statement a consumer would act on. A scripts key has no reader. The direction that would
flip this: had any src/ return type moved, the published dist/*.d.ts would change and that IS a
consumer-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 scope
    by the dispatch. Generalising is cheap: the gate already takes --package and resolves the entry
    .d.ts from that package's own exports map, so onboarding is one alias plus a seeded ledger.

  • Making this gate visible to the dispatch derivation.resolveCheckToFiles matches only
    .mjs|.cjs|.js|.sh, so all 23 TypeScript-authored gates — check:exported-any and
    check:api-surface included — resolve to zero gate files and contribute zero watch hints. Filed
    as resolveCheckToFiles matches 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 for
    a 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 from
    bare-root-worklist --self-test passing — that pass could not see the file. check:entry-guard
    scans 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 isEntrypoint guard was in place and the module still
    crashed on import, because an argv-reading top-level const threw during evaluation. Target
    resolution is lazy now, and the exports are genuinely inert. The third is the
    check-ratchet-remedy-authority classification above — but see the note on resolveCheckToFiles matches 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 a
    different root cause and is already recorded elsewhere.


Generated by Claude Code

#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
@github-actions

github-actionsBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e7480a80676dc70c1a21555d6a11a86c8f518209packageMentionDocs.

`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_01UjM2ia8Av1v5NqfqQEQmC6
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] packages/client has no check:exported-any equivalent — #8140 fixed 51 sites and nothing stops the 52nd

2 participants

@yinlianghui@claude