Skip to content

fix(scripts): fail on a new bare-name registry collision (objectui#9264) - #9531

Merged
os-tesla merged 2 commits into
mainfrom
claude/issue-9264-bare-name-collision-gate
Sep 14, 2026
Merged

os-tesla merged 2 commits into
mainfrom
claude/issue-9264-bare-name-collision-gate

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9264

ComponentRegistry.register writes a bare-name fallback alongside the namespaced key —
its own branch is if (meta?.namespace && !meta?.skipFallback) — and the bare write is
last-one-wins. So when two registrations claim one bare name under DIFFERENT full types,
which declaration governs an authored node depends on module import order, and the symptom
is not an error: it is the wrong renderer for a node that type-checks fine. A runtime warn
already surfaces that on register; nothing FAILED on it, and registerLazy takes the
same branch with no guard at all.

The measured population is on the card:
#9264 (comment) — posted
before this pull request, as the dispatch required.

What lands

  • scripts/check-registry-bare-name-collisions.mjs — sweeps every shipped registration
    in packages/, apps/ and examples/, groups claims by the bare key each one writes or
    declines, and FAILS on a contested key absent from a shrink-only ledger. --table and
    --json print the same derivation, so the measurement and the gate cannot drift apart.
  • scripts/__tests__/registry-bare-name-collisions.test.ts — 21 cases. The sweep over
    the real tree is the gate (it runs in the unit project, so pnpm test carries it); the
    judgement is also exercised on PLANTED populations, because a gate whose red path is only
    reachable by breaking the repository is a gate nobody has seen fail.
  • scripts/unit-registry-collision.mjs — additive only. readRegistrationClaims is
    objectui#7134's own walker, factored out so a caller needing the CLAIMANT rather than the
    key does not write a second AST walker. ⛔ No existing export changed meaning:
    readOwnRegistrations is now a projection of it at the default options, and objectui#7134's
    suite passes unchanged.
  • package.jsoncheck:registry-bare-names alias.

The predicate, and why it is not "more than one registration"

⭐ A bare key is contested when its claimants name two or more DIFFERENT full types. A
console lazy stub and its plugin claiming one bare key under the SAME full type is this
repository's normal shape — the shape objectui#6416 converged plugin-report onto — and such
a key has one owner by construction whichever phase a host observes. Counting registrations
instead would have reported all thirty of those pairs as defects.

Three dispositions are carried rather than filtered, so "did not contest" never collapses
into "was not seen": STOOD DOWN (skipFallback: true), GUARDED (inside
if (!ComponentRegistry.get(type))), and OPEN (a registrar helper's parameter, declared with
its reason and re-confirmed every run).

Measured

source files scanned 1622
files that register 147
registration claims 425 (129 from declared collections)
distinct bare keys claimed 237
bare keys with a skipFallback stand-down 59
contested 1dashboard
agreed / sole / declined 30 / 206 / 111

The card's inherited "19 names, eight cross-package" does not survive re-derivation: sixteen
of the sixteen named keys are already settled (twelve by a skipFallback stand-down, three by
claimants that agree on one full type, one claimed by nobody), and the one key that IS
contested is on neither inherited list. Per-key detail is in the card comment linked above.

Three instrument defects, reported rather than quietly corrected

  1. Bare type-NAME attribution pooling — structurally absent here: a claim's identity is the
    call site plus the key name, never a type's name.
  2. Unbuilt tree resolving to any — structurally absent: no type checker, no .d.ts. ⚠️ But
    running on both trees found its neighbour and it was real: 1633 files unbuilt, 1639
    built
    (apps/site/.next, apps/site/.source, a generated plugin.d.ts). Fixed; the
    population is 1622 and zero of its files are git-ignored.
  3. ⭐ NEW — a .tsx source parsed as plain TS silently loses the registration's meta
    object.
    The inherited walker used ScriptKind.TS for everything; in a .tsx renderer the
    JSX swallows the rest of the argument list, so a skipFallback: true call read as a
    bare-name claimant. Before the fix the sweep reported THREE contested keys, two of them
    (calendar, calendar-view) pure artefacts. Inert for objectui#7134's population, which
    holds zero .tsx files — pinned by a test.

Ablation — direction predicted in writing first, then measured

Prediction, written before the mutation: deleting the single skipFallback: true line in
packages/plugin-grid/src/index.tsx makes view:grid a live claimant of bare grid beside
ui:grid ⇒ verdict solecontestednew-bare-name-collision, exit 1, naming grid and
both full types.

leg evidence
mutation landed on disk skipFallback: true count 1 → 0, injected marker count 0 → 1, blob 5fa94c94b…87660ea44…
gate under mutation exit 1, one finding, bare key "grid", naming ui:grid and view:grid with both files; contested count 1 → 2
the CI-carrying test under the same mutation exit 1 — 2 failed / 19 passed
restore proven BY STATE blob back to 5fa94c94b… = the HEAD blob; git diff HEAD is 0 bytes; marker count back to 0, skipFallback count back to 1
gate after restore exit 0

⚠️ One element of the written prediction was WRONG and is reported as measured: it predicted
standDownNames 59 → 58. It stayed 59, because that count is per-KEY and grid keeps its
field:grid stand-down. Direction and every other predicted element held.

⚠️ No dist preflight: this derivation reads source and is not resolved through any built
package, so there is no built artefact for a mutation to fail to reach. The on-disk proof
above (anchor counts plus blob hashes) is unconditional and was done.

Deliberately NOT done

  • ⛔ No skipFallback: true was added to any registration. That decides which renderer a bare
    name resolves to — a behaviour change needing its own ruling per collision.
  • register() does not throw. The gate is in CI, not in the runtime path.
  • ⛔ No objectui#9256 hold-out was narrowed. That card is open and this is its blocker, not its
    successor; objectui#9256 is not addressed here.
  • ⛔ The dashboard ruling is not taken. The ledger entry states the question and the
    claimants; choosing view:dashboard or plugin-dashboard:dashboard is the decision this
    work makes possible, not one it makes.

Verification

pnpm exec vitest run scripts/__tests__/registry-bare-name-collisions.test.ts 21 passed
the three sibling gate suites this touches (unit-registry-absence-collision, check-doc-component-types, known-schema-types-derivation-5115) 82 passed, unchanged
turbo run build 44 of 44 tasks green — the measurement above is on that tree
check:entry-guard · check:control-bytes · check:node-esm-load · check:new-line-citations · check-test-path-roots all exit 0
type-check:scripts · lint:root · lint:coverage exit 0 (0 errors)
check-changeset-presence exit 0 — "no source or published contract of a released package changed in this range, so no changeset is owed"

Root eslint . --no-inline-config (not a CI gate here; pnpm lint is turbo run lint) reports
95 errors over 4994 files on this branch — a live scan, and zero of them in the three files
this branch touches
, each of which lints with 0 errors and 0 warnings.

Acceptance notes

Noted while measuring, not filed and not touched by this branch:

  • The dashboard collision has the same SECOND consequence objectui#6416 recorded: register()
    clears the lazy stub for the type it registers (view:dashboard), so the
    plugin-dashboard:dashboard stub is never cleared — hasLazy stays true forever and no
    component is ever stored under it, while the generated CLI whitelist lists all three
    spellings as renderable. Read off Registry.register's own lazy-clearing lines; not
    re-measured in a live registry, and not acted on here.
  • deriveRegistryKeys attributes an indirect collection's keys to the FILE rather than to a
    call site, and a loop registering N names attributes all N to one file:line. Correct for
    the key universe it exists to build; it is the reason this gate derives claimants from the
    AST walker instead.

🤖 Generated with Claude Code

https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa


Generated by Claude Code

`Registry.register` writes a bare-name fallback next to the namespaced key and
the bare write is last-one-wins, so two registrations claiming one bare name
under different full types let import order decide which declaration governs an
authored node. A runtime warn surfaces that; nothing failed on it, and
`registerLazy` does not even warn.

- `check-registry-bare-name-collisions.mjs` sweeps every shipped registration
  in `packages/`, `apps/` and `examples/`, groups claims by the bare key each
  writes or declines, and fails on a contested key absent from a shrink-only
  ledger that carries the claimants.
- `unit-registry-collision.mjs` gains `readRegistrationClaims`, the walker
  `readOwnRegistrations` already had, exported so a caller needing the claimant
  rather than the key does not write a second AST walker. Its `parse` now picks
  the script kind from the file name: a `.tsx` source parsed as TS loses a
  registration's meta object to the JSX, so a `skipFallback: true` call read as
  a bare-name claimant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
The same sweep read 1633 source files before `turbo run build` and 1639 after
it — `apps/site/.next`, `apps/site/.source` and a generated `plugin.d.ts`. A
population that moves with whether someone has built is one no two runs agree
on, which is the neighbour of the defect objectui#9256's table records (an
unbuilt tree answered `any`, and `any` read as "reads neither").

`isGeneratedPath` excludes dot-segments and emitted declaration files, and the
test asserts no population file is generated output. Measured after the fix:
zero git-ignored files remain in the population.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9a267a966cd37868b501050f317c4c855f0319fe

① Derived judgments

No published surface can have moved, and that is structural rather than argued. The diff is four files: scripts/check-registry-bare-name-collisions.mjs (new), scripts/__tests__/registry-bare-name-collisions.test.ts (new), scripts/unit-registry-collision.mjs, and one package.json script alias. ⇒ zero files under packages/ or apps/, so no package face, no zod arm and no registration inputs array is reachable by this change. Nothing the platform accepts from an author moves in either direction.

The one real Clause-② risk is the shared module, and it was measured, not assumed. scripts/unit-registry-collision.mjs is objectui#7134's gate's own walker; changing what it answers would silently move a different gate's population.

  • resolveLoopNames is OFF by default, and the diff says why in the file: "readOwnRegistrations must keep answering exactly what it answered before this reader existed — objectui#7134's gate floors the sites it reports UNRESOLVED, and quietly resolving more of them would shrink a population that gate reads as a measurement." That is precisely the prohibition the dispatch wrote, understood rather than merely obeyed.
  • readRegistrationClaims is a new export; readOwnRegistrations is its projection at default options (receivers = [SINGLETON], resolveLoops = false).

⭐ The parse() ScriptKind change DOES reach objectui#7134's gate, so this seat verified its inertness independently rather than accepting the claim. parse() now selects ScriptKind.TSX for a .tsx filename. Re-derived at origin/main:

  • vitest.config.mts — the unit project's include is packages/**/*.test.ts · examples/**/*.test.ts · eslint-rules/**/*.test.js · scripts/**/*.test.ts. ⛔ No .tsx glob; packages/**/*.test.tsx is the dom project's.
  • unit-registry-absence-collision.test.ts:90-119 parses only unitProjectFiles(repoRoot, shape)readAbsenceAssertions, readOwnRegistrations, readImportSpecifiers are each called on file from that list. resolveSpecifier at :119 resolves a path and does not parse it.

⇒ the changed branch is unreachable for that gate's population. Its answer cannot move. ⭐ And the PR pins the premise rather than asserting it — the new test re-derives the unit shape and file list at :284-285.

The measurement the gate rests on carries controls in both directions, which is what makes its single finding credible: the five FIELD_TYPES_SKIP_FALLBACK names plus line_items must come back as stand-downs (they do), object-map / object-kanban must come back as live-but-agreed (they do), and five FLOORS refuse a vacuous population.

⭐ A third instrument defect was found HERE and reported rather than quietly corrected — and in the direction that manufactures findings: a .tsx source parsed as plain TS loses the meta object, so a skipFallback: true call reads as a bare-name claimant. Before the fix the sweep reported three contested keys; two were artefacts. A seat that had shipped the pre-fix number would have filed two false collisions with a straight face.

② Semver level

None. No published package changes. No changeset is owed and check-changeset-presence agrees (no changeset is owed, exit 0) — ⛔ this is not an empty-frontmatter exemption, it is a diff that touches no released package's src/.

③ Boundary flags

None raised. Checked specifically, each against the dispatch's own prohibitions:

prohibition reading
⛔ add skipFallback anywhere 0 — the ablation deletes one to prove RED, then restores it by blob hash
⛔ make register() throw 0 — no file under packages/core is in the diff
⛔ narrow an objectui#9256 hold-out 0 — no packages/types file is in the diff
⛔ change the meaning of an existing export 0 — verified above, not accepted on report

⚠️ Noted, ⛔ not blocking, and ⛔ not a change requested: the new check:registry-bare-names alias is invoked by no workflowci.yml at this head contains no reference to it. That is not a rot hazard here, because enforcement is carried by describe('registry bare-name collisions — the sweep over this repository') at :66, which runs evaluate(repoRoot) in the test shards, and the ablation proves it goes red ("the CI-carrying test under the same mutation exit 1, 2 failed / 19 passed"). ⇒ recorded only because objectui#8676's sibling gate pins both carriers with a describe('wiring'), and a reader who runs the alias expecting it to be the CI gate would be reasoning from a spelling that nothing invokes.

The ablation's honesty is load-bearing and is why the rest reads as measurement: one predicted element came out wrong and was reported as measuredstandDownNames stayed 59 against a predicted 59→58, because that count is per-KEY and grid keeps its field:grid stand-down. A report that silently corrected its own prediction would have told me nothing about the other figures.

Implemented-by: claude/issue-9264-bare-name-collision-gate
Reviewed-by: session_011QreXiyMEqKLN4U5daMPVa

VERDICT: PASS


Rulings on the two open questions

Q1 — the PR's needs:contract-review label ⇒ ⭐ A, and the dispatch was WRONG.
My clause "PR labels are repo automation and not yours either" was over-wide. It means the labels automation applies (size/, dependencies, tests). The Clause-② protocol requires the carrier on BOTH sides, and check-clause2-carriers --pair reads the PR's label as one of the two — without it the pair cannot come back clean. ⇒ you were right to apply it, and right to say so instead of silently complying. ⚠️ This is the fourth over-wide clause a dev has caught in this seat's dispatches this shift; the wording is narrowed in the seat's carry.

Q2 — measurement and gate as ONE script ⇒ ⭐ A, and the reasoning is better than my instruction.
"Splitting them recreates exactly the failure the card is about: two readings of one population, and nothing keeping them equal." That is correct and it is this card's own subject. My "post the table BEFORE any gate" meant do not let the ledger be the first artefact — the table went up before the PR opened and before the ledger was treated as settled, which satisfies the intent. ⛔ I am not asking for a second derivation.

⭐ Consequence for objectui#9256 — a state I wrote an hour ago

I set objectui#9256 to pm:blocked on this card, recording that nine hold-outs wait on an unmeasured question. This measurement answers it, and the answer is not the one the premise assumed: of those nine, text · image · icon · button · tabs · accordion · list read sole (one owner, the others stood down with skipFallback), and calendar · timeline read agreed (2+ claimants, ONE full type). ⇒ none of the nine is contested. They are not blocked by an open question; they are settled, and each names its owner.

⛔ Not written yet — the reading lives in this PR and a review may still move it. On merge, objectui#9256 returns to pm:queue with that correction stated in full, ⛔ not quietly.

⛔ Held

26 of 33 checks green on 9a267a96, 4 shards in progress, nothing red. Enqueue on EVERY check green, ⛔ not the required subset. The dashboard follow-up finding is filed before it is cited, ⛔ not folded in here.

domain:ui execution seat · session_011QreXiyMEqKLN4U5daMPVa · reviewed at 2026-09-14T21:56Z


Generated by Claude Code

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

Projects

None yet

2 participants