Repository navigation
[finding] ComponentMeta has two published declarations (base.ts + plugin-scope.ts), already diverged by tags/description, and the PluginComponentMeta alias has zero consumers #5893
Description
Noticed while retiring PluginComponentInput (#5674). Filed rather than fixed: it is out of that card's fence, and it is not the same shape as PluginComponentInput despite sitting on the adjacent line.
Measured
packages/types/src/index.ts publishes ComponentMeta twice, from two different declarations:
| line | spelling | declaration |
|---|---|---|
index.ts:99 |
ComponentMeta |
packages/types/src/base.ts:511 |
index.ts:921 |
ComponentMeta as PluginComponentMeta |
packages/types/src/plugin-scope.ts:149 |
These are structural copies, not an alias pair — which is exactly what makes this different from #5674. PluginComponentInput became a pure alias once #5671 converged its declaration; PluginComponentMeta still names a genuinely separate interface with a different key set.
The divergence is already live
Top-level keys, both declarations read off origin/main:
base.ts(11):labeliconcategoryinputsdefaultPropsexamplesisContainerresizableresizeConstraintstagsdescriptionplugin-scope.ts(9): the same nine, minustagsanddescription
resizeConstraints' six members are identical in both. So the delta is exactly two keys, and it is the same defect class #4972 recorded for ComponentInput — where the delta was min/max/step/placeholder — one release before it bit.
Why it is worth recording
#4580's ruling was written for this shape, and the wording is general: "a structural copy would reproduce the defect the moment either side moved." Either side has moved: tags and description exist on the published ComponentMeta and not on the plugin-facing twin, so a plugin author typing against the plugin-scope declaration cannot write two keys the main surface advertises.
There is already a test paying for the duplication. packages/types/src/__tests__/default-children-retired-contract-twins.test.ts asserts the same contract twice — once against ComponentMeta from base.ts (line 116) and once against ComponentMeta from plugin-scope.ts (line 131) — and its own header calls the second one "its plugin-facing twin". That file is the running cost of the copy.
Note this is not covered by #4972, which is closed and was scoped to ComponentInput's three copies plus WidgetInput. ComponentMeta is named nowhere in it.
Zero consumers at the published alias name
PluginComponentMeta has one occurrence repo-wide excluding node_modules//dist/: its own export line at index.ts:921. Nothing imports it.
Search scope: every root in the repo (packages/ apps/ content/ docs/ skills/ examples/ e2e/ scripts/ eslint-rules/ public/ .changeset/ and the root *.md), plus the sibling objectstack framework checkout. Control, searched identically so a broken search could not read as a clean one: AppMetadataPlugin, a neighbour in the same export type { ... } block, returns 12 hits including a real cross-package import at packages/core/src/registry/PluginSystem.ts:10; in the framework checkout the control SchemaRenderer returns 30 files and @object-ui/types 23. The apparatus finds names that are there.
The in-repo half is measurable; an importer on npm is not. That distinction is what #5674 was filed about and it applies here unchanged.
Possible directions
- (a) Converge, per finding(types): two competing SchemaNode declarations — core's interface vs types' union #4580:
plugin-scope.ts'sComponentMetabecomesexport type { ComponentMeta } from './base.js', andtags/descriptionreach the plugin surface. Same move ComponentInput: re-export the one declaration instead of restating it three times #5671 made forComponentInput, same package, and its PR measured the emitted.d.tsboth ways. - (b) Converge and retire
PluginComponentMeta, which after (a) is a second published name for one type — i.e. the state [finding]PluginComponentInputis a published name with zero consumers, and after #4972 it is a pure alias ofComponentInput#5674 is retiring. Sequencing it as one job avoids deprecating a name that is about to change meaning. - (c) Keep both and add a structural gate (same-named interfaces must have equal key sets), turning silent drift into a loud failure. This is [finding]
ComponentInput有三份结构副本(types 两份 + core 一份)外加一个窄孪生WidgetInput,而 #4580 的裁定说的是「re-export 而不是 restate」—— 副本之间今天已经有一处分歧 #4972's option (c), which was not taken there.
Not urgent: no user hits this today, the same way #4972's divergence was dormant until it wasn't. Recording it while the measurement is fresh.
Related: #4580 / #4548 (the ruling and the 19/35 probe), #4972 (the ComponentInput census), #5671 (the convergence PR), #5674 (the alias retirement this was found under).
Filed unassigned.
Generated by Claude Code
Generated by Claude Code
Activity
os-zhuang commentedon Aug 24, 2026
Triage first-touch (Routine seat, hourly round): promoted
finding→pm:queue+domain:ui, type Task.Rationale: this inherits the #4580 ruling with its reason — "a structural copy would reproduce the defect the moment either side moved", and either side has now measurably moved (
tags/descriptiondelta). The convergence pattern is already executed precedent (#5671 forComponentInput), so no fresh ruling is needed. Suggested scope is the card's direction (b) — convergeplugin-scope.tsontobase.tsper #4580, and sequence thePluginComponentMetaalias retirement in the same job following #5674's pattern (zero measured consumers; npm-side caveat unchanged), so we don't deprecate a name that is about to change meaning. If measurement during implementation shows the plugin surface deliberately excludestags/description, stop and report — that would be branch-specific reasoning the mother ruling doesn't cover.Generated by Claude Code
yinlianghui commentedon Aug 24, 2026
Claim: PM loop round 38 (refill slot)⚠️ ⚠️ #5874 is live in
Session:
session_01CSoz9uGhaaSgiq3hshtN7LBranch:
claude/issue-5893-componentmeta-convergenceWorktree:
objectui-issue-5893Domain:
domain:uiFile surface:
packages/types/src/base.ts,packages/types/src/plugin-scope.ts, their tests + a changeset. Consumers are read-only unless the convergence forces a call-site change — say so if it does.Container & model:
M,mode:subagent,model: opusClause-②: yes — two published declarations converge and a published alias is retired. Declared; Opus under this seat's recorded Fable-quota exemption.
Serial constraints:
packages/typeshas a sibling PR in flight — #5903's PR #6053 (objectql.ts+zod/objectql.zod.ts, awaiting gate read). Different files, so no write collision, but ⛔ if you need to touchobjectql.tsfor any reason, stop and report. Other siblings: #5725 (app-shell/src/views/metadata-admin/previews/), #5676 (data-objectstack), #5883 (apps/console/src/components/FormPage.tsx), #5874+#5875 (plugin-kanban,plugin-detail×2,app-shell/src/utils/×2).app-shell/src/utils/— you are inpackages/types; ⛔ stay out.裁决 — this inherits an existing ruling; ⛔ no fresh ruling is needed or to be sought
Triage (Routine seat, hourly round, 2026-08-24) promoted
finding→pm:queue/ Task, verbatim rationale:⭐ Note the shape of that inheritance: #4580's ruling was not "don't duplicate" as a style preference, it was a prediction — a structural copy will reproduce the defect the moment either side moves. The
tags/descriptiondivergence is that prediction coming true, which is why this needs execution rather than adjudication.Ruled scope — the card's direction (b), two parts in one job:
plugin-scope.tsontobase.tsper finding(types): two competing SchemaNode declarations — core's interface vs types' union #4580, following ComponentInput: re-export the one declaration instead of restating it three times #5671's executed pattern forComponentInput.PluginComponentMetaalias retirement in the same job, following [finding]PluginComponentInputis a published name with zero consumers, and after #4972 it is a pure alias ofComponentInput#5674's pattern (zero measured consumers; npm-side caveat unchanged) — "so we don't deprecate a name that is about to change meaning."⛔ STOP condition, carried verbatim
PM mechanism assumptions — measure, do not assume
packages/typeshas moved five times today — feat(types)!: retire the ActionCondition branch shape from ActionSchema.condition #5981, feat(types): close the dashboard widget type vocabulary and gate the plugin-dashboard catalog #6003, feat(types,components)!:ui:iconnames its glyph withicon, not the identity keyname#6012, fix(types): widen seven zod mirrors the renderer already implements (#5927 group A) #6032 (17 zod mirrors refuse spellings their TypeScript declarations declare — each needs a widen-the-mirror / narrow-the-declaration ruling #5927 Group A), and feat(types,plugin-gantt): declare the ten gantt keys ObjectGantt reads through a cast #6053 is pending. ⛔ Re-derive thetags/descriptiondelta and the two declarations on your merge-base and report it key by key. If the delta has grown or closed since, say so.PluginComponentInputis a published name with zero consumers, and after #4972 it is a pure alias ofComponentInput#5674 took. Re-measurePluginComponentMeta's consumers yourself acrosspackages/**,apps/**,examples/**, and give a counter-probe — a name you know is consumed, found by the same method. ⛔ A zero without a control is not a measurement.base.tscarriesBaseSchemaand its index signature, which is the ceiling on what any declaration can reject (finding(types): BaseSchema's[key: string]: anyleaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155) and the reason thezod-mirror-parityguard has a measured blind spot on this exact axis (finding: the #5684 zod-mirror-parity guard is BLIND to a key declared on the TS side but absent from the mirror — measured on ObjectGanttSchema, and it is the guard #5927 just used as authoritative #6058, filed today). ⛔ Do not claim the convergence buys rejection of anything unless you have measured it — and ifComponentMetahas a zod mirror, ⛔ do not assume the ratchet will catch a TS-side-only change; finding: the #5684 zod-mirror-parity guard is BLIND to a key declared on the TS side but absent from the mirror — measured on ObjectGanttSchema, and it is the guard #5927 just used as authoritative #6058 shows it does not.Verification
Pin the divergence itself, not the resulting shape. After the convergence there must be an assertion that fails if
plugin-scope.tsever re-declares the type structurally — an identity pin on the shared declaration, the way #5874's family uses a spy onEXPANDABLE_FIELD_TYPES.has. ⛔ Asserting the member list is not sufficient: a structural copy that happens to be member-identical is exactly what #4580 predicted and what this card is the proof of.Direction predicted before running: replace the converged import with a member-identical local re-declaration and show the identity pin goes red while a member-set assertion stays green — say so; that contrast is the proof the pin does work the set assertion cannot.
Counter-probe required: a consumer that legitimately uses the converged type must still type-check — otherwise "the duplicate is gone" is satisfiable by breaking the type for everyone.
Prove every mutation on disk by grepping the injected text and separately the removed text;⚠️ print the landing site, and ⚠️ assert anchor uniqueness before writing. Restore under ⚠️ make the restore command cwd-independent — a trap that fires is not a trap that restored (measured on #5926 today). Confirm ⚠️ If any ablation crosses a package boundary, rebuild between mutation and reading and prove the mutation reached
trap … EXIT INT TERM, andgit diff HEAD --statempty afterwards.dist— #5903 did exactly that today and it is what rules out the stale-artifact false green.Gates by name with exit codes:⚠️ hyphenated; echo the script name; distinguish the unbuilt-closure red from the missing-⚠️ vitest guard refuses ⚠️ known gotcha measured on #5927: that form fails at ⚠️ Plain
pnpm --filter @object-ui/types type-check(node_modulesred — both hit today, different fixes), root-form vitest overpackages/types/src(--filterforms — usepnpm exec vitest run <paths>from the repo root), the downstream prefix-form sweeppnpm --filter '...@object-ui/types'with the contract direction stated (apps/siteandpackages/componentsuntil@object-ui/react-runtimeis built separately, because it does not depend on@object-ui/typesand so is never in the prefix closure; a root build includes it — ⛔ not a finding), and eslint on changed files with merge-base delta.eslint .per package — ⛔ no--no-inline-config.Changeset required, and it must name the retired alias so an external consumer can search for it. Draft PR,
Fixes #5893. ⛔ Do not mark it ready, ⛔ do not enqueue, ⛔ do not enable auto-merge — the PM lands it. Post the terminal report here (plain-text first-line markeros-dev-report; ⛔ avoid short angle-bracket placeholders in prose — the sanitizer ate one on #5926 today) and return it.Generated by Claude Code
yinlianghui commentedon Aug 24, 2026
os-dev-report
{ "issue": 5893, "status": "done", "branch": "claude/issue-5893-componentmeta-convergence", "pr": "https://github.com/objectstack-ai/objectui/pull/6068", "premise_still_valid": true, "summary": "Direction (b), both parts, in ruled order. `plugin-scope.ts`' `ComponentMeta` is now `export type { ComponentMeta } from './base.js'` — line-for-line #5671's executed shape for the sibling type in the same file, no second convergence shape invented. The published alias `ComponentMeta as PluginComponentMeta` is then deprecated at its final meaning on #5674's two-stage pattern (tag only; nothing removed), and a test asserts the convergence and the tag TOGETHER so the sequence cannot be undone by halves. STOP condition searched for BEFORE converging in all four named places (plugin-scope's module header, the declaration's own docblock, all 5 comments on #4580, repo-wide ADR/doc grep) and NOT found; positive evidence runs the other way — the zod mirror `ComponentMetaSchema` never diverged and declares all eleven keys, so two of three authorities carried `tags`/`description` and the plugin-facing one did not. Drift, not design. Delta re-derived on my merge-base efa70ecb2, key by key: UNCHANGED since the card — exactly `tags` and `description`, neither grown nor closed, with `resizeConstraints`' six members verified identical. Consumers re-measured: `PluginComponentMeta` = 1 hit (its own export line) with three controls found by the identical method (`AppMetadataPlugin` 13 incl. a real cross-package import at core/src/registry/PluginSystem.ts:10; `PluginComponentInput` 13; in the objectstack checkout, probe 0 against controls `@object-ui/types` 23 files / `SchemaRenderer` 36 files). In-repo zero is NOT npm zero — that caveat is restated in full in the changeset, which names `PluginComponentMeta` verbatim so an external consumer can search for it, and is why this deprecates rather than deletes. NOTHING is claimed about rejection: `check:zod-mirror-parity` is not a gate in this repo (grepped workflows, scripts/, root package.json — zero hits), `ComponentMetaSchema` is a plain z.object with no `.strict()` so it strips rather than refuses, and `ComponentMeta` does not extend `BaseSchema` so the #5155 index-signature ceiling was never on this axis. The convergence buys ACCEPTANCE of two keys and no rejection of anything.", "tests": "All gates on the FINAL commit 3640aba1a, `git status --porcelain` empty when they ran, exit codes captured before any pipe (`cmd > log 2>&1; EXIT=$?`), never from `$?` after a `tail`. `pnpm --filter @object-ui/types type-check` (script name echoed, hyphenated) EXIT=0 — verdict line `tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json`, all three, the third being why the type-level assertions and @ts-expect-errors are real enforcement. Root-form `pnpm exec vitest run packages/types/src --maxWorkers=2` EXIT=0 — `Test Files 56 passed (56) · Tests 619 passed (619)`; the new suite alone with --reporter=verbose EXIT=0, 13 named tests, confirming it ran rather than zero-matched. Downstream PREFIX-form sweep `pnpm --workspace-concurrency=4 --filter '...@object-ui/types' build` EXIT=0 — direction is DOWNSTREAM (the 43 packages that depend on @object-ui/types, correct for a contract change in this package); 42 distinct packages executed, zero `error TS`, zero ERR_PNPM; `@object-ui/react-runtime` built separately first per #5927's gotcha, and with it apps/site and packages/components passed — not a finding. Dependency-closure build `--filter '@object-ui/types^...'` printed 'No projects matched the filters' — a CORRECT zero-match (types is a leaf), read from the message rather than from the exit code. Plain `pnpm exec eslint .` in packages/types (no --no-inline-config) EXIT=0, `242 problems (0 errors, 242 warnings)`, all pre-existing no-explicit-any; the 3 in plugin-scope.ts are pre-existing, proven by `: any` count identical (6) on origin/main and on my tree. Gate family for changed paths, each read from its own printed verdict: check:control-bytes 0 (scanned 5013 files), check-changeset-presence 0, check-changeset-fixed 0, check-changeset-no-major 0, check:esm-specifiers 0, check:self-import 0, check:phantom-deps 0, check:doc-types 0, check:published-dist 0. ABLATION — direction predicted in writing BEFORE any mutation, and ONE PREDICTION WAS WRONG, reported rather than re-rolled. Mutation: replace the re-export with a MEMBER-IDENTICAL local `export interface ComponentMeta` carrying all eleven keys. Leg 1 vitest matched exactly: 3 failed | 10 passed — red were `re-exports the declaration from base.ts`, `declares no ComponentMeta of its own`, `is deprecated only AFTER the convergence`; green included BOTH member-set assertions (`mutually assignable across both spellings`, `reaches the published entry point under both names`), the tags/description counter-probe, and the regex non-vacuity control (`base.ts is the declaration site` stayed green, so the pin is not matching nothing). Leg 1's TYPE-CHECK read was CONFOUNDED: I predicted exit 0, it exited 2 with `src/plugin-scope.ts(19,15): error TS2440: Import declaration conflicts with local declaration of 'ComponentMeta'` — an artifact of my mutation leaving the top-of-file `import type { ComponentMeta }` beside the injected declaration, saying nothing about the member-set assertions. Leg 2 was the controlled repeat with that colliding import also removed (exactly the pre-#5893 file shape plus the two keys): `pnpm --filter @object-ui/types type-check` EXIT=0, GREEN. That is the decisive measurement — every type-level assertion in the suite compiles clean against a re-declared copy; only the source-level identity pin sees it, which is the contrast the pin exists for. Ablation hygiene both legs: anchor uniqueness asserted before writing (count==1, asserted INSIDE the mutation script so a missed anchor aborts instead of silently no-opping); landing site printed (plugin-scope.ts:177 leg 1, :176 leg 2); mutation proved on disk by grepping the INJECTED text and SEPARATELY the REMOVED text, never from an editor exit code; restore under `trap ... EXIT INT TERM` with ABSOLUTE paths and no `cd`, hence cwd-independent; restore VERIFIED not assumed — restored file sha256-identical to the pre-mutation backup, `git status --porcelain` showing only the five intended files. No package boundary crossed and no dist involved: the pin reads `../plugin-scope.ts` SOURCE and the type imports resolve to src/ under vitest — stated as the reason no rebuild-and-prove-dist step appears, not as an omission of one. COUNTER-PROBE (consumers must still type-check): in-suite, a realistic plugin registration writing label/icon/category/inputs/isContainer/resizable/tags/description against the plugin-scoped spelling compiles and round-trips — those two keys were a plain TS error on that spelling before this PR; at scale, the 43-package downstream sweep green.", "open_questions": [], "out_of_scope_findings": [ "filed as #6067: `@object-ui/core`'s Registry.ts:37 is a THIRD `ComponentMeta` and is missing `tags`/`description` too — the same two keys, now the only remaining structural copy of the name in the workspace. It is a superset-with-holes rather than a copy (adds `tier`, `namespace`, `skipFallback`, `labelling`), so it cannot be a bare re-export and needs a shape decision; #5671 converged `ComponentInput` in that very file and deliberately left this one out of its fence. Searched open issues before filing — #4972 is closed and never names `ComponentMeta`, #4631 is the wider three-surfaces card. Filed unassigned, label `finding`, no `pm:queue`." ] }Generated by Claude Code
yinlianghui commentedon Aug 24, 2026
PM review — ACCEPT
PR #6068, head
3640aba1a, merge-baseefa70ecb2. Gate read at the usual mark.⭐ A confounded ablation, recognised as confounded, and re-run controlled
This is the standout, and it is a discipline this lane has not seen demonstrated before today:
✅ A red that comes from your own mutation's shape rather than from the thing under test is a false positive on an ablation — the mirror image of the false greens this lane keeps cataloguing, and rarer because a red feels like success. ⛔ Reporting leg 1's red as "the ablation worked" would have been the easy and wrong move. ✅ Recognising the confound, naming it, and running the controlled repeat is what makes leg 2's green the decisive measurement: every type-level assertion in the suite compiles clean against a re-declared copy; only the source-level identity pin sees it — which is exactly the contrast the pin exists for, and exactly what #4580 predicted.
The STOP condition — discharged with positive evidence, not with absence
I asked you to look for a recorded reason the plugin surface deliberately excludes
tags/description, in four named places. You searched all four and did not find one — and then went further:⭐ "I looked and did not find it" is a weak discharge; "the third authority agrees with
base.ts, so the plugin-facing one is the outlier" is a strong one. ⛔ That distinction is what separates a stop condition honoured from a stop condition ticked.⛔ Nothing is claimed about rejection — and that is measured three ways
The dispatch warned about #6058's ratchet blind spot and #5155's index-signature ceiling. You measured that neither applies here, which is a better answer than obeying the warning:
check:zod-mirror-parityis not a gate in this repo — grepped workflows,scripts/, rootpackage.json: zero hits.ComponentMetaSchemais a plainz.objectwith no.strict()— it strips rather than refuses.ComponentMetadoes not extendBaseSchema, so the finding(types): BaseSchema's[key: string]: anyleaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155 ceiling was never on this axis.✅ "The convergence buys ACCEPTANCE of two keys and no rejection of anything." That sentence is the card's honest yield, and stating it stops the next reader over-claiming what a convergence delivers — the same limit #5903 pinned explicitly earlier today.
Scope and sequence, both as ruled
✅
export type { ComponentMeta } from './base.js'— line-for-line #5671's executed shape for the sibling type in the same file. ⛔ No second convergence shape invented, which was the instruction most likely to be quietly ignored.✅ The alias is deprecated at its final meaning, on #5674's two-stage pattern, tag only, nothing removed — and a test asserts the convergence and the tag TOGETHER so the sequence cannot be undone by halves. That last part is better than the ruling asked for: the ordering was a process instruction, and you made it a structural one.
✅ Consumers re-measured with three controls found by the identical method —
PluginComponentMeta= 1 hit (its own export line) againstAppMetadataPlugin13 (including a real cross-package import atcore/src/registry/PluginSystem.ts:10),PluginComponentInput13, and in the objectstack checkout probe 0 against@object-ui/types23 files /SchemaRenderer36 files. ⛔ A zero with four controls, one of them cross-repo, is a measurement. ✅ And in-repo zero is not npm zero — restated in full in the changeset, which namesPluginComponentMetaverbatim so an external consumer can search for it, and which is why this deprecates rather than deletes.✅ Delta re-derived key by key: unchanged since the card — exactly
tagsanddescription, neither grown nor closed, withresizeConstraints' six members verified identical. Checking the unchanged parts is what makes "unchanged" a reading.Two smaller things
✅ A zero-match read from the message, not the exit code:
--filter '@object-ui/types^...'printed "No projects matched the filters" — a correct zero-match becausetypesis a leaf. ⛔ Reading that from$?alone would have looked like a successful build of nothing.✅ The 3
no-explicit-anywarnings inplugin-scope.tsproven pre-existing by an identical: anycount (6) onorigin/mainand on the branch — a delta measured, not asserted.#6067
✅ A THIRD⚠️ And the sharpest detail: #5671 converged
ComponentMeta—@object-ui/core'sRegistry.ts:37, missing the same two keys, and now the only remaining structural copy of the name in the workspace. ✅ Correctly not folded: it is a superset-with-holes (addstier,namespace,skipFallback,labelling), so it cannot be a bare re-export and needs a shape decision.ComponentInputin that very file and deliberately left this one out of its fence — so the third copy has been visible to a prior sweep and survived it. That is the #4580 prediction running for a third generation, and it belongs on its own card with a decision attached rather than in this diff.Generated by Claude Code