Skip to content

Gate the entry-nameability invariant across every public spec entry - #12418

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-11986-entry-nameability-gate
Aug 26, 2026
Merged

Gate the entry-nameability invariant across every public spec entry#12418
huangyiirene merged 1 commit into
mainfrom
claude/issue-11986-entry-nameability-gate

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11986

Generalizes the root-entry declaration-emit probe into a per-entry check: gate, so the entry-nameability invariant is held closed for every public entry of packages/spec instead of repaired one name at a time.

The invariant, maintainer ruling 2026-08-23 recorded on #11350 (quoted verbatim, untranslated):

a type that appears structurally in an entry's public declarations must be nameable from that same entry.

Chartered by the 2026-08-25 ruling on #11709 — 「11709 11742 同意」 — which accepted A′ (the two root re-exports, landed as #12327) plus C, this gate.

What it does

For each of the 17 module entries in the exports map, the gate compiles a one-file consumer program with declaration: true, resolved the way a real consumer resolves it (a node_modules/@objectstack/spec symlink, so tsc walks the package's own exports map), and asserts the declaration emitter reports nothing it cannot name. 422 call probes in total, 31–47s wall clock.

Design decisions, each settled by measurement

Two probe shapes were measured; the call shape was chosen because the other one is blind to the defect.

  • referenceexport const r = E.NAME;, one per value export. Re-prints each export's declared type. Measured with A′ reverted and the dist rebuilt: the root entry's leak set does not move. defineStack's declared return type is ObjectStackDefinition, which is nameable from the root entry, so the printer names it instead of expanding it. The leak exists only at a call.
  • callexport const c = E.FN(null as never, …); for every export whose type has a call signature, with as many never arguments as the signature has required parameters. never is assignable to every parameter type, so this needs no per-factory fixture — and the emitted declaration is byte-identical to the pin's real defineStack({ objects: [] }) call (verified by diffing the two .d.ts outputs). This is the shape that moves under the ablation.

The entries are never batched into one program, and that is load-bearing rather than an optimisation left on the table. The dispatch suggested batching with per-entry namespaces if the cost were CI-material. Measured, batching would silently empty the gate: a program file that imports a subpath entry makes that entry's names nameable program-wide (#11350's control). A single program importing all 17 entries can name every type any of them declares and reports zero leaks no matter how many exist — full cost, no measurement. The --self-test now measures this in both directions permanently, so the claim cannot rot into prose.

Enumeration is type-level, via the checker, not typeof x === 'function'. Zod v4 schema objects are runtime functions — 375 of /api's 389 runtime functions are zod schemas with no call signature — so a runtime predicate generates hundreds of bogus TS2349: This expression is not callable diagnostics per entry and the checker never reaches declaration emit for the real probes.

Diagnostics land in three buckets, never two. The cannot be named family (matched on message substance, not a fixed code list, so a compiler renumbering cannot empty it) is the finding. TS7056 — the compiler refusing to serialize an inferred type at all — is reported as NOT MEASURED, because the printer gives up before name resolution and neither "clean" nor "leaking" is supportable. Anything else is a refusal naming the diagnostic: a probe program that fails to compile for an unrelated reason has measured nothing (#4690).

The premise the card carried is refuted, and the gate ships with a ledger because of it

The card expected the gate to be green at zero the moment A′ landed. It is not. The call-shape probe finds seven leaks across four entries on cdbd9204:

entryunnameable type(s)
@objectstack/specBook, FormField, NavigationItem
@objectstack/spec/kernelUnknownAuthoringKeyFinding
@objectstack/spec/aiFilterCondition, StateNodeConfig
@objectstack/spec/uiFilterCondition

That is not a different defect class — it is the card's own thesis arriving early. A′ repaired the defineStack instance and six siblings of it were live the whole time with nothing measuring them.

They are recorded in entry-nameability.baseline.json: hand-ratcheted, shrink-only in both directions (a new name fails; a recorded name that no longer leaks also fails, with an instruction to delete the row), and closed to new rows. Repairing them here would edit four entry barrels and regenerate api-surface/ — a different file surface and a different gate family from this card's, and two of those barrels were claimed by other in-flight work in this batch — so they are filed for triage as #12414 instead. The ledger is the ratchet, not the destination.

The 27 pairs the reference shape finds are a separate scope question, filed as #12416 with the measurement. ⚠️ They are deliberately not added to this ledger: a shrink-only ledger with no shrink path is a permitted accommodation surface, and the ledger's whole warrant is that every row it carries is repaired by one re-export line.

Acceptance criteria, each with its measured result

  1. With A′ reverted locally, red naming exactly BaseValidationRuleShape and FilterCondition — ✅ met exactly. Mutation proven on disk (git hash-object moved a2a790b1…6b8da6af…, both anchor greps 0), spec rebuilt on the mutated source, dist proven mutated (root-entry export statements naming the two types: 0 and 0), gate exit 1 reporting 2 NEW unnameable structural mention(s) across 1 public entry@objectstack/spec: BaseValidationRuleShape, FilterCondition, and nothing else. Restore leg: git checkout HEAD -- against an absolute path, git diff HEAD empty, hash back to a2a790b1…, rebuilt, dist export statements back to 1 and 1.
  2. Green on current main + A′ — ✅ 0 new unnameable structural mentions, exit 0, 422 probes, 17 entries.
  3. A synthetic leak added to any public entry turns it red — ✅ measured on a real subpath entry, not only the root: a factory returning { filter: FilterCondition } injected into src/security/index.ts (an entry that is clean today), rebuilt, marker proven present in dist/security/index.d.ts; gate exit 1 naming @objectstack/spec/securityFilterCondition. Restored, rebuilt, marker proven absent from dist. The --self-test's positive control mechanizes the same thing permanently on a hermetic fixture, with a repaired-fixture negative control beside it.

Anti-phantom controls

TS2883 is a declaration-emit diagnostic: drop declaration: true and every probe is green forever. Two independent controls, both required to pass on every run:

  • the canary, borrowed from the pin — a fixture whose only error is also declaration-emit-only (TS4094) and must be red under the same compiler profile;
  • a calibration refusal — zero callable exports across all entries is a refusal, not a pass. This is not hypothetical: the first draft of this gate read the namespace import's alias symbol instead of the aliased module symbol, found zero exports on all 17 entries, generated zero probes, and reported "0 unnameable structural mentions" at exit 0. The bug is fixed and the shape is now unreachable.

The self-test runs on every invocation rather than only under --self-test, so check:entry-nameability cannot be run without its own positive control.

Wiring

  • check:entry-nameability in packages/spec/package.json, beside the spec gate family.
  • Classified NO_GENERATOR in check-generated.ts (reconciliation green: 27 check: + 17 gen:, all classified). The classification is the safety property, not bookkeeping — a gen: that rewrote the ledger from whatever the tree currently leaks would admit the eighth leak by running a command, which is the whack-a-mole this gate was filed to end.
  • One step in lint.yml's typecheck-consumers lane, with check:api-surface / check:exported-any / check:dual-source-exports — the other gates that read the built dist a consumer's import resolves to. Adds no required context (a step in an existing lane, The ESLint required context carries ~54 check:* gates, so every one of their failures mis-routes its own diagnosis — and the rename needs a Settings change no agent seat can make #9325). The #11350 pin above it is untouched and keeps its own charter: it reproduces the real configs' program shape verbatim, fixture file included.

Gates run locally

All at 0eb9f84, the final commit, exit codes captured before any pipe:

check:entry-nameability (exit 0) · check:scripts-typecheck (exit 0) · check:generated --reconcile-only (exit 0) · check:merge-driver · check:published-files · check:nul-bytes · check:pnpm-filter-targets · check:workflow-status-functions · check-self-test-wired · check-self-test-workflow-commands · check-step-collectors · check-aggregator-roster · check-required-contexts · check-cross-package-test-inputs · check-ci-filter-parity — all exit 0. Families re-derived from the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack rather than from the dispatch list.

No changeset: the diff is tooling only — a gate script, its ledger, script wiring, a gate classification and one CI step. Nothing user-visible ships, so the PR carries skip-changeset.


Generated by Claude Code

Generalize the root-entry declaration-emit probe into a per-entry `check:`
gate: for each of the 17 module entries in `packages/spec`'s `exports` map,
compile a one-file consumer program that CALLS every callable export and
assert the declaration emitter reports nothing it cannot name.
Entries are deliberately never batched into one program — a program file
importing a subpath entry makes that entry's names nameable program-wide, so
a batched run reports zero leaks at full cost. The `--self-test` measures
that in both directions alongside a positive control, the repaired-fixture
negative control, and the declaration-emit canary.
Measured: 7 pre-existing leaks of the same class across 4 entries, recorded
in a hand-ratcheted, shrink-only ledger that is closed to new rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️1 changed file(s) yielded no anchor (packages/spec/entry-nameability.baseline.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/spec/entry-nameability.baseline.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 — 126 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 2e24115710640fba45938baf24a31fd9cc80694fpackageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: gate the entry-nameability invariant — fail any public entry whose declarations structurally mention a type not nameable from that entry

2 participants

@huangyiirene@claude