Repository navigation
[finding] ComponentConfig is the same two-published-declarations shape as ComponentMeta, one type down — @object-ui/core vs @object-ui/types #6298
Description
Measured while converging ComponentMeta at the registry (#6067, PR #6297). Filed unassigned, out of that card's fence: its file surface was packages/core/src/registry/**, and closing this one requires a decision about shape rather than a mechanical follow-through.
Measured
Read off origin/main at merge-base c456d91f4.
A repo-wide search for a type / interface / class declaration of any name ending in ComponentConfig returns four, two of which are the same name:
| declaration | form |
|---|---|
packages/types/src/base.ts:578 |
export interface ComponentConfig extends ComponentMeta { type: string; component: any; } |
packages/core/src/registry/Registry.ts:141 |
export type ComponentConfig<T = any> = ComponentMeta & { type: string; component: ComponentRenderer<T>; } |
packages/core/src/registry/Registry.ts:156 |
export type PublicComponentConfig<T = any> — a distinct name, not part of this |
packages/types/src/mobile.ts:283 |
export interface MobileComponentConfig — a distinct name, not part of this |
Control probe for the search, since a zero-hit is only a reading once a known-present neighbour has been probed: the same pattern run for ComponentMeta returns the two declarations #6067 is about, so the pattern matches both the export type and export interface spellings.
Both ComponentConfig declarations are published: packages/types/src/index.ts publishes its one, and @object-ui/core's src/index.ts carries export * from './registry/Registry.js' with package.json mapping "." to ./dist/index.d.ts.
What the delta actually is
They differ in two ways, and only one of them is closed by #6067:
- Which
ComponentMetathey extend. Before [finding]@object-ui/core'sRegistry.tsis a THIRDComponentMetadeclaration, and it is missingtags/descriptiontoo — the same delta #5893 just closed inpackages/types#6067 this was the whole nine-versus-thirteen divergence. PR ComponentMeta at the registry: derive it from the one declaration, extend it under a named type #6297 reduces it: core'sComponentMetais nowCanonicalComponentMeta & RegistryComponentMetaExtras, so the shared eleven members are single-sourced on both sides. What remains here is the four registry-only keys, which is a declared extension rather than drift. - Genericity and the
componentslot.@object-ui/types' is non-generic withcomponent: any; core's is<T = any>withcomponent: ComponentRenderer<T>(andComponentRenderer<T> = T). This half is untouched by [finding]@object-ui/core'sRegistry.tsis a THIRDComponentMetadeclaration, and it is missingtags/descriptiontoo — the same delta #5893 just closed inpackages/types#6067 and is the part that makes the two same-named exports genuinely different types.
So after #6297 lands this is no longer a drifting copy, but it is still two same-named published declarations, which is the shape objectui#4580 ruled about: a structural copy would reproduce the defect the moment either side moved.
Why it is worth recording rather than folding in
The ComponentMeta serial (#4972 → #5671 → #5893 → #6067) has now converged three declarations of two type names, each time by making the downstream one derive from the canonical rather than restate it. ComponentConfig is the next name down the same file and the same import edge, and it was left untouched by every one of those cards for the same reason each time: it was outside the fence. Recording it means the census of same-named published pairs in this neighbourhood is complete rather than trailing off.
Not urgent, and nothing a user hits is broken today. Both spellings accept every registration that compiles against either, for the same all-optional reason recorded in #6067: excess-property checking fires only on object literals, never on the type relation. This is the same dormancy #4972's divergence had until it wasn't.
Possible directions
- (a)
core'sComponentConfigbecomes@object-ui/types' extended with the generic renderer slot, mirroring exactly what ComponentMeta at the registry: derive it from the one declaration, extend it under a named type #6297 did forComponentMeta— one declaration for the shared members, a named extension for the rest. - (b)
@object-ui/types'ComponentConfiggains the type parameter (defaulted, so no consumer changes) andcorere-exports it outright, the way ComponentInput: re-export the one declaration instead of restating it three times #5671 handledComponentInput. Simplest resulting shape; needsComponentRendererto have a home that@object-ui/typescan reach. - (c) Decide that one of the two has no consumers and retire it. Not measured here.
Duplicate search — what was and was not possible
Searched locally: every declaration of the name across packages/** and apps/** (above); every in-repo mention of ComponentConfig in .changeset/, docs/, content/ and package __tests__/ alongside an issue reference — zero hits, so no in-repo artifact points at an existing card for it. Read directly: #4631, the "wider three-surfaces-disagree" card, which is about a component type's three disagreeing surfaces (TS schema type / registry inputs array / renderer prop reads) and names ComponentConfig nowhere. #4972 is closed and scoped to ComponentInput's copies plus WidgetInput; #5893 is scoped to the two declarations inside packages/types; #6067 is scoped to ComponentMeta.
disable_issues_react_ssr is on, so the embedded payload's preloadedQueries[0].result carries no rows) — while direct api.github.com returns 403 here. So the local evidence above is the whole dedup. If triage has search budget, one keyword pass over open issues for ComponentConfig would close the gap, and closing this as a duplicate is the cheap outcome if it finds one.
Related: #6067 (the ComponentMeta card this was measured under), PR #6297 (which reduces half of this delta), #4580 (the ruling and its reason), #4972, #5671, #5893, #4631.
Filed unassigned.
Activity
claude commentedon Aug 31, 2026
os-dev-report
{ "issue": 6298, "status": "done", "branch": "claude/issue-6298-componentconfig-one-authority", "pr": "https://github.com/objectstack-ai/objectui/pull/6937", "premise_still_valid": true, "summary": "The proviso was measured first and it SELECTS (b), but not for the reason triage expected. `ComponentRenderer` has exactly one declaration (core `Registry.ts:16`) and `@object-ui/types` cannot reach it - types depends on `@objectstack/spec` + `zod` only, core depends on types at `workspace:*`, so that edge is a cycle - so the proviso FAILS on its literal reading; but the alias is the IDENTITY, so `component: T` in types says exactly what `component: ComponentRenderer[T]` said in core with nothing to import, and (b) is reachable with no new edge. (a) was independently EXCLUDED: the gate counts a derived declaration as an authority, so it could not have delivered the mandated `KNOWN_COLLISIONS` deletion - proved on this tree, where `ComponentMeta` was converged the (a) way by PR #6297 and is STILL a baseline row one line below the row this PR removes. Landed: types' `ComponentConfig` gains the defaulted type parameter and owns the slot; core re-exports it (the #5671 `ComponentInput` form); the five registry-only keys move to a named extension `RegistryComponentConfig` that the registry getters return, type-identical to what they returned before, because `getNamespaceComponents` filters on `config.namespace`. Baseline row deleted (43 rows to 42), only that row; #6349's parallel-batch files untouched. PM note, NOT filed as a new card: `ComponentMeta` remains a KNOWN_COLLISIONS row for the same file pair and the same reason (core still declares it), and this PR demonstrates the route that would clear it - it is already tracked debt on the shrink-only baseline owned by the #6349 batch series, so filing would duplicate.", "tests": "ALL READINGS ON 6808939d9 (final commit; `git status` clean, so tree equals HEAD). Exit codes captured before any pipe.\nTYPE-CHECK (hyphenated script name echoed by pnpm): `pnpm --filter @object-ui/types type-check` gives exit 0 (`tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json`); `pnpm --filter @object-ui/core type-check` gives exit 0 (`tsc --noEmit && tsc -p tsconfig.test.json`). NOT-MEASURED check discharged: `tsc -p packages/core/tsconfig.test.json --listFiles` shows the new pin file IS in the program (1 hit) and that it reads `packages/types/dist/base.d.ts` (line 610: the emitted declaration WITH the new type parameter), so the type-level assertions are enforced rather than erased. types was rebuilt before core's type-check.\nROOT-FORM vitest (no --filter): `pnpm exec vitest run packages/types/ packages/core/ scripts/` gives BEFORE (merge-base 40c479af2, run in a throwaway comparison worktree since the tree cannot be in two states at once) `Test Files 269 passed (269)` / `Tests 5462 passed (5462)`, exit 0; AFTER `Test Files 270 passed (270)` / `Tests 5472 passed (5472)`, exit 0. Delta plus-1 file / plus-10 tests, exactly the new pin file. Comparison worktree removed. Ratchet family re-run at final HEAD: 9 files / 132 tests, exit 0.\nABLATION, direction PREDICTED IN WRITING BEFORE THE RUN ('red in the FRESH direction - a NEW colliding name - not the stale one'). OBSERVED exactly that: `ComponentConfig - a NEW colliding name / packages/core/src/registry/Registry.ts:236 - type declaration / packages/types/src/base.ts:642 - interface declaration`, exit 1 (11 tests became 1 failed | 10 passed). NO REBUILD IS INVOLVED, and that is a measured property rather than an omission: the gate is a pure SOURCE scan - `populationFiles` reads `git ls-files` and the matcher runs over `readFileSync` of the tracked `.ts` text - so no `dist/` sits between the mutation and the assertion and a stale build cannot make this ablation lie. MUTATION CONFIRMED ON DISK before the run: anchored line count 1 became 0, and `git hash-object` 542a4459 became df1e7537; both were asserted, with the script exiting 9 if either failed to move. RESTORE PROVEN BOTH WAYS after: hash back to 542a4459, equal to `git rev-parse HEAD:...`; anchored count back to 1; `git diff HEAD` empty; `git status` clean. Ran under `trap restore EXIT INT TERM` with an absolute `REPO_ROOT` from `git rev-parse --show-toplevel` and `git checkout HEAD -- ABSOLUTE_PATH`, never bare. The FACT was mutated, never the assertion.\nPUBLISHED SURFACE, before/after, read by an out-of-package consumer compiled against the EMITTED .d.ts of both packages: types' declaration accepts a type argument - `TS2315 not generic` became compiles-exit-0; keys-on-core minus keys-on-types - 'the five registry-only keys' became `never`; keys-on-types minus keys-on-core - `never` stayed `never`; both `component` slots `any` stayed `any`; mutual assignability `true` stayed `true` (THE GHOST - green on the diverged pair, which is why the new pin compares key sets and source identity and keeps assignability only as a labelled control).\nNO-CONSUMER-CHANGES PROVED, with the control the card's own discipline requires: zero import sites of `ComponentConfig` from `@object-ui/core` across packages/apps/examples/e2e (the only textual hits are prose in the doc comment this PR adds), and the CONTROL PROBE on the same module and same form returns ComponentRegistry 388 sites / Registry 7 / ComponentMeta 6, so the zero is a reading. Out-of-package consumer simulation against the emitted .d.ts (all three getters, all five registry keys, canonical keys, assignment from BOTH packages, bare-spelling object literal) gives tsc exit 0; NEGATIVE CONTROL (a key on neither declaration) gives `TS2339 Property 'thisKeyDoesNotExist' does not exist on type 'RegistryComponentConfig[any]'`, so the probe is live rather than vacuous. Downstream `type-check` of @object-ui/sdui-parser, @object-ui/components, @object-ui/react, @object-ui/app-shell gives exit 0.\nMANDATED GATES: `node scripts/check-phantom-dependencies.mjs` exit 0, verdict line 'Every in-scope import is declared by the package that publishes it' (40 packages, 18109 specifiers). `pnpm exec eslint .` PLAIN FORM (no --no-inline-config) exit 0, '11516 problems (0 errors, 11516 warnings)'; the warnings are the pre-existing repo baseline and this diff is net ZERO on them (one defaulted-any removed in core and one added; one added in types and one `component: any` removed) and the new test file draws none. `node scripts/check-changeset-presence.mjs` exit 0: '2 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'.\nDERIVED GATE FAMILY (objectui has no dispatch-gates.mjs; derived from the actual changed paths): check:control-bytes exit 0 (5784 tracked text files) plus a manual `grep -naP` control-byte scan of every touched file, no hits; check:doc-fences exit 0; check:doc-types exit 0; check:doc-snippets exit 0 after building its OWN declared `--build-filter` closure ('Semantic phase: 271 of 271 block(s) judged, 0 failed') - its first run exited 2 and SAID SO ('This is I-could-not-run, NOT I-ran-and-found-errors'), which was treated as PREREQUISITE NOT MET rather than as red; check:readme-exports exit 0 after the same build ('386 of 386 self-imports judged real, 0 fabricated') - likewise its first read was exit 1 with 364 of 364 findings in the single class 'run pnpm build first' and ZERO on either package I touched; check:self-import exit 0; check:spec-symbols exit 0; check:changeset-fixed / no-major / overwrite exit 0. Edited-gate rule discharged: the only other file naming `one-authority-per-exported-name-6273.test.ts` is `packages/app-shell/.../ActionParam.one-authority.test.ts`, which names it in PROSE and reads `packages/types/src/ui-action.ts` rather than the gate file; run anyway, 1 file / 9 tests, exit 0.\nDECLARED NARROWING: the vitest scope is packages/types/ plus packages/core/ plus scripts/ rather than the whole suite, and type-check covers the two edited packages plus four downstream consumers rather than the whole farm. CI runs both in full.", "mcp_calls": "0 - the whole run used REST (issue read, comments read, PR create, PR read-back) plus git; no MCP GitHub tool was called", "open_questions": [], "out_of_scope_findings": [ "NOT FILED, deliberately: `ComponentMeta` is still a KNOWN_COLLISIONS row for the same file pair (core/Registry.ts vs types/base.ts) because PR #6297 converged its SHAPE but left a declaration in core; this PR demonstrates the re-export route that would clear it. Not filed because it is already tracked debt on the shrink-only baseline the 2026-08-25 family ruling ordered cleaned, with the #6349 batch series as its owner mechanism, so a new card would duplicate an existing row. Raised here so PM can route it into a batch." ] }Generated by Claude Code
claude commentedon Aug 31, 2026
contract-review
Verdict: ACCEPT-WITH-CONDITIONS — PR #6937, head
6808939d9, clause ② review of theComponentConfignarrowingOne condition, and it is a two-line frontmatter edit plus one sentence; everything else was re-derived independently and reproduced exactly. Every reading below is my own measurement on fresh builds of base
40c479af2(the true merge-base; the stated baseb03ba3adis main's tip, one PR ahead) and head6808939d9, probes compiled against the emitted.d.tsof both packages only, exit codes captured before any pipe.1. Routing — clause ② was correctly applied; the PM is right
The narrowing is real and published: my cross-tree probe on the two emitted core surfaces reads the set difference of keys on base-core's
ComponentConfigminus head-core's as exactlytier | namespace | skipFallback | labelling | deprecated, and nothing in the other direction. A consumer hits it the ordinary way: on head,c.tieroffComponentConfigimported from@object-ui/coregives — the compiler's own line —error TS2339: Property 'tier' does not exist on type 'ComponentConfig[any]', and the literal form giveserror TS2353: Object literal may only specify known properties, and 'tier' does not exist in type 'ComponentConfig[any]'; both spellings compile green on base (hot control, same instrument, same pass). Out-of-repo consumers are unmeasurable, so review was owed. Routing correct; no lighter path.But the review's answer is that the narrowing should land, because it is constitutive of the fix, not collateral. The precise loser class: a consumer who (i) named
ComponentConfigfrom@object-ui/corerather than from@object-ui/types, AND (ii) read or wrote one of the five registry-only keys against that annotation. Getter-fed consumers lose nothing — all three getters returnRegistryComponentConfig, which I measured type-identical to the old core declaration (key set equal against the base tree's own emitted type, per-key member equality, parameterised slot equal). In-repo the loser class is empty: my own census, multi-line-import aware, finds 0 import clauses naming bareComponentConfigfrom@object-ui/coreacross packages/apps/examples/e2e, with the control hot in the same pass (ComponentRegistry388 import clauses,ComponentMeta5); 0 module augmentations of@object-ui/types. The break is compile-loud with a mechanical one-line fix (importRegistryComponentConfig, or the same name from@object-ui/types), and the runtime surface is untouched.2. No deprecation shim exists inside the repo's own ruling set — so none is owed
I checked every shape a shim could take. A derived local declaration in core (direction (a)) keeps a second authority — the gate's own doc: a declaration is an authority, and "⛔ A deliberate re-export is NOT an authority" — so the mandated
KNOWN_COLLISIONSdeletion is undeliverable that way. An aliasing re-export (RegistryComponentConfig as ComponentConfig) is also counted as an authority (gate doc and its own spelling-table test) and would additionally make the two published names genuinely different types again, which is the #4580 defect itself. A temporal window (keep the old declaration deprecated for one release) keeps the defect standing and the baseline row owed. The #5671ComponentInputprecedent — the same re-export disposition — shipped with no window. Landing as-is is right on all four axes; no maintainer ruling is needed, because the maintainer's standing policy already decides both the disposition (#4580 family ruling) and the grading (next section).3. The ghost is real, and the new pin bites — both verified by measurement
Ghost, measured on base: on the diverged pair read from the emitted
.d.ts, both directions of assignability typedtruein my probe while the same program read the key-set difference as the five keys anderror TS2315: Type 'ComponentConfig' is not generic(its own line) on the types side. Exit 0 on the pass-probe, so mutual assignability was green on two genuinely different types — an assignability pin would have been born green and stayed green. The dev's decision to build the pin on key sets + source identity and demote assignability to a labelled control is correct.Bite, by mutating the fact: in the head worktree I replaced the re-export line in
Registry.tswith the old local declaration — prediction written before the run: pin red on both source-identity assertions, gate red in the FRESH direction. Mutation proven on disk (anchored count 1 to 0,git hash-object944093f2bto98547925d), no rebuild involved (both instruments are source scans — a measured property of the gate, confirmed). Observed exactly the prediction:Tests 3 failed | 18 passed (21)—× re-exports the canonical declaration and names the extension,× declares no ComponentConfig of its own, and the gate'sComponentConfig — a NEW colliding name. Restore proven both ways under trap EXIT INT TERM with absolute paths: hash back to the HEAD blob, anchored count back to 1,git diff HEAD0 lines. Enforcement of the type-level pins is real, not vitest theater:tsc -p tsconfig.test.json --listFilesexit 0 shows the pin file in the program (1), readingpackages/types/dist/base.d.ts(1) and types src (0).4. Direction (a)'s exclusion holds on the tree
Registry.ts:260on head still carries the (a)-shapedComponentMetadeclaration PR #6297 landed, andKNOWN_COLLISIONSline 374 still carries its row — one line below the comment block where theComponentConfigrow was removed. The dev's counterexample is live and load-bearing, and it is why triage's fallback was never actually available for this card's mandated outcome.5. Re-export shape — correct, and the only legal one
export typeon the re-export is required, not stylistic:tsconfig.base.jsonsetsisolatedModules: true, under which a value-form re-export of a type-only name is a compile error, and the emittedRegistry.jswould otherwise carry a runtime re-export of a binding@object-ui/typesnever emits. What core publishes is proven empirically: my head probe importsComponentConfigfrom core's emitteddist/index.d.ts(through theexport *chain) and instantiates it — generic, identical to types' (mutual Exact true, key diffs never both ways, bare slot stillany,ComponentRendererstill the identity). And the rehoming was forced, not decorative:namespaceis in the extras-only key set, so a bare re-export as the entry type would have brokengetNamespaceComponents' filter in core's own build.6. THE CONDITION — regrade the changeset from patch/patch to minor
The grade contradicts the repo's written policy and every family precedent. AGENTS.md §版本号策略: "objectui 自身的破坏性变更也标
minor(在正文里写清 breaking 语义即可)", restated incheck-changeset-no-major.mjs's own header: "objectui's own breaking changes ship as minor with the break spelled out in the changeset body." Precedents: PR #5671 — the identical re-export disposition — graded minor on both packages; PR #6297 graded minor on core; PR #6936 (the sibling this review is consistency-paired with) graded minor on types. This PR removes five keys from a published type name on core (breaking for the class in §1) and adds a type parameter plus a new exported name on both — additive capability.patchis wrong on both entries by the repo's own convention; the fixed-group mechanics make the released version identical either way, but the changeset is the release-notes input and a narrowing filed as patch mis-signals there.Condition: in
.changeset/6298-componentconfig-one-authority.md, change bothpatchentries tominor, and add one sentence to the body naming the breaking class plainly, e.g.: "Breaking for consumers importingComponentConfigfrom@object-ui/coreand touching a registry-only key on it — switch that annotation toRegistryComponentConfig." The body's existing content is accurate and stays.Nothing else blocks. With the regrade in, this lands as-is: no shim, no deprecation window, no maintainer escalation.
Generated by Claude Code