Gate authored icon names against lucide's runtime icons record - #5932
Merged
Conversation
…s record lucide retires a spelling by dropping it from the runtime `icons` record while keeping it as a deprecated named export. A retired name therefore still imports, still type-checks and still renders wherever it is used as a COMPONENT (`Edit === SquarePen`, `Filter === Funnel`) — and resolves to nothing wherever it is used as a STRING, because the string lookups read that record. Nothing goes red in either direction, which is why the class had to be repaired twice, in two packages, by two cards (objectui#5586, objectui#5622), each leaving a LOCAL pin behind. `scripts/check-lucide-icon-record-names.mjs` replaces those pins with one predicate over the population: - its judgement is the runtime record itself, never a list of retired spellings, and it derives a replacement by object identity rather than remembering one; - it RE-DISCOVERS the resolver population from source on every run — the first pass found four record-reading resolvers the card's hand-built table did not know about, plus two more dynamic-surface ones; - it separates the record vocabulary (1767 keys) from `dynamic.mjs`'s (2025), because the dynamic list still carries every retired name this class is about; - an `icon` on a node whose `type` is not a censused record-reading renderer is DECLINED rather than flagged, so the gate cannot become one that gets suppressed. Retires the two local pins it fully subsumes and the membership half of a third, keeping the render assertions and the `ui:icon` registration pin (no first-party consumer of a registration's `icon` exists here, so the gate has no measured basis to judge it). Three live spellings the gate found are repaired in place, each a one-line correction to the same glyph: `filter` -> `funnel` and `more-horizontal` -> `ellipsis` in schema-catalog fixtures, `Filter` -> `Funnel` in the docs playground. Part of #5633 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
`scripts/__tests__/ci-cd-pipeline-doc.test.ts` compares every first-party command a ci.yml job runs against that job's "What it runs" cell, in order. A gate nobody wrote down is a build failure contributors meet without knowing what produced it (objectui#3653). Part of #5633 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
This was referenced Aug 24, 2026
os-zhuang
marked this pull request as ready for review
August 24, 2026 02:50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5633
lucide retires a spelling by dropping it from the runtime
iconsrecord while keeping it as a deprecated named export. A retired name therefore still imports, still type-checks and still renders wherever it is used as a component — and resolves to nothing wherever it is used as a string, because the string lookups read that record. Nothing goes red in either direction. That class had been repaired twice, in two packages, by two cards, each leaving a local pin behind. This adds one gate over the population instead of a fifth local pin.New gate:
scripts/check-lucide-icon-record-names.mjs, wired aspnpm check:icon-record-namesinci.yml'stype-checkjob.The population, measured
node scripts/check-lucide-icon-record-names.mjs --reporton69c86a310:The card's table of four record-reading resolvers was short by four.
form/button.tsx,plugin-list/ListView.tsx,plugin-detail/RelatedList.tsxandpreviews/ActionPreview.tsxeach resolve an authored string through the same record, each with its own hand-copied normalisation — and the eight do not agree: three different tokenisers (split('-'),split(/[-_\s]/),split(/[-_\s]+/)) and only five carrying theHometoHousealias. That divergence is the argument for measuring the population every run rather than maintaining a list; the gate takes the widest tokeniser plus the alias map, so it can never invent a violation none of the eight would produce.The dynamic surface was likewise short by two. It is censused but not judged:
lucide-react/dynamic.mjsstill carriesedit,smile,filter,alert-triangle. A gate pointed at that vocabulary would bless every name this class is about — worse than no gate.Controls on the enumeration
A sweep is only readable if a site known to be in the population and one known to be out are classified by the same pass.
packages/components/src/renderers/action/resolve-icon.ts, the canonical resolver both prior cards repaired againstpackages/plugin-chatbot/src/elements/tool.tsx, which builds its ownconst iconsmap of ReactNodes and indexes it by tool stateThe negative control is asserted by the gate itself, not just by this table: if that file is ever classified as a resolver, discovery is matching the name rather than the import and every other verdict is void.
What the gate declines to judge, and why that is the point
254
icon-keyed string literals are seen and not judged. Aniconon a node whosetypeis not a censused record-reading renderer is declined, because nothing here knows which vocabulary (if any) it reaches. That is what keeps the gate from being the kind that gets suppressed on day one — and the declined set is exactly the false-positive families #5633 catalogued. Re-measured here:iconfield (AppNavCanvas.tsx,block-types.ts,field-types.ts,flow-canvas-parts.tsx,AgentPreview.tsx).not-a-real-icon) — test paths are outside the scan, declared in the gate's header.typeof their own. Read the four renderers:button-group.tsx,breadcrumb.tsxandcommand.tsxnever readiconat all, anddropdown-menu.tsxrendersitem.iconas raw text ({item.icon && <span>{item.icon}</span>}). None reaches a record-reading resolver, so declining them is the correct answer rather than a gap. Both observations are filed separately, below.The gate's judgement
The runtime
iconsrecord itself, loaded from the lucide the resolvers load — resolved throughpackages/components/package.json, becauselucide-reactis not resolvable from the repo root. No list of retired spellings anywhere in the gate: a hand-kept vocabulary is the same defect one level up, ageing the moment lucide retires the next name and ageing silently. When the gate names a replacement it derives one by object identity — the retired export and its live key are the same object, so the live key is looked up in the record.Red/green demonstration
The discriminating pin, its two controls, and the whole suite are in
scripts/__tests__/check-lucide-icon-record-names.test.ts(25 cases, all over throwaway trees except the repo-wide assertions).{ type: 'button', icon: 'filter' }— retired, still exported,Filter === Funnelis truepackages/app/src/toolbar.ts:4, the spelling, andwrite \funnel``icon: 'funnel'authoredJudged === 1so green is a judgement, not a skipped fileicon: 'no-such-lucide-icon'is not a lucide icon at all, and explicitly not the deprecated-alias sentenceReverse verification
Four legs, each mutating a committed tree, confirming the mutation on disk by grepping the injected and the removed text (never an editor exit code), restoring under an
EXIT INT TERMtrap, and re-checkinggit diff HEAD --stat. No build is involved and none can hide a leg: the suite imports the gate by relative source path and the CLI isnode scripts/…, so nothing resolves through anexportsmap into adist/. Baseline and final were both 25/25.isLiveKeyalways trueicon: "Filter"in the playgroundis green. This is what proves the repairs below were real.errorsis empty.HometoHousealiasis greenstayed greenLeg D is the named non-discriminating leg for the repository-wide assertion: no censused site names
home, so that assertion cannot see the alias being dropped. It is discriminating only for the alias case, which is why the alias case exists.Local pins retired
packages/plugin-list/src/__tests__/ViewSwitcher.iconNames.test.tsVIEW_ICONS, now an anchored map with a min-entry preconditionpackages/plugin-detail/src/__tests__/DetailView.systemActionIconNames.test.tsitems.push({ icon }), now an anchored mappackages/plugin-view/src/__tests__/ViewSwitcher.test.tsxpackages/components/src/__tests__/icon-renderer-declared-default.test.tsiconmeta — measured,getMeta(...).iconis read nowhere underpackages/**orapps/**— so the gate has no measured basis to judge it. It also asserts the palette glyph and the dropped default stay the same name, which no membership check canA test asserts that the two deleted populations are present in the gate's anchor list, so they cannot be dropped rather than moved.
Spellings the gate found
Three, each a one-line correction to the same glyph object (verified at runtime:
MoreHorizontal === EllipsisandFilter === Funnelare bothtrue, and only the replacement is a record key):examples/schema-catalog/.../plugin-grid/product-inventory-grid.json—"icon": "filter"on atype: buttonnode rendered no icon. Nowfunnel.examples/schema-catalog/.../components-overlay-dropdown-menu/with-icons.json—"icon": "more-horizontal"on thetype: buttontrigger. Nowellipsis.apps/site/app/playground/page.tsx—icon: "Filter"on atype: "button"node. NowFunnel, keeping the file's PascalCase convention.Out of scope, filed separately
ui:dropdown-menurenders an item's authorediconas raw TEXT — the catalog fixture named with-icons.json draws the words "edit", "copy", "trash" beside its labels #5930 —dropdown-menu.tsxrendersitem.iconas raw text instead of resolving it, so an authored icon name shows up as a literal word.iconkeys that button-group, breadcrumb and command never read — two of them are named with-icons.json and render none #5931 —button-group,breadcrumbandcommandcarry authorediconkeys in catalog fixtures that no renderer reads.Divergence from the triage note, for the reviewer
Triage sequenced a four-resolver consolidation ahead of the gate. The dispatch fenced this round to the gate and explicitly excluded touching resolver behaviour, so no consolidation was attempted. The measurement strengthens the consolidation case rather than weakening it — eight resolvers, three tokenisers, two alias policies — and the gate now makes any new one visible on the run it appears. Recorded here for the PM rather than decided.
Verification
Every command below was run on
69c86a310(this branch's head), heavy legs serialised through the shared verify lock; exit codes captured before any pipe.node scripts/check-lucide-icon-record-names.mjs—OK lucide icon names: 64 authored/declared names reaching 8 record-reading resolvers are live \icons` keys`node scripts/check-changeset-presence.mjs—✅ 6 source file(s) of 4 released package(s) changed, and this change declares 1 changeset(s)(empty frontmatter: comment-onlysrc/edits and test changes publish nothing)node scripts/check-changeset-no-major.mjs—✅ No changeset declares a \major` bump.`node scripts/check-control-bytes.mjs—✅ check-control-bytes: OK (scanned 4922 tracked text file(s); skipped 85 binary).node scripts/check-phantom-dependencies.mjs—✅ Every in-scope import is declared by the package that publishes it.node scripts/check-doc-links.mjs—Links are valid across 13 scan roots.pnpm type-check:scripts— exit 0pnpm exec vitest run --maxWorkers=2 scripts/ examples/ packages/auth/ packages/plugin-view/ packages/components/src/__tests__/icon-renderer-declared-default.test.ts—Test Files 121 passed (121) / Tests 3771 passed (3771)pnpm exec vitest run --maxWorkers=2 packages/plugin-list/ packages/plugin-detail/ scripts/__tests__/check-lucide-icon-record-names.test.ts—Test Files 142 passed (142) / Tests 1600 passed (1600)Declared lint narrowing. ESLint was run on the six changed
.ts/.tsxfiles rather than repo-wide, with the three pieces of evidence that make the narrowing a measurement:eslint.config.jsitself, not guessed: the onlyfilesglobs are**/*.{ts,tsx}..mjs,.json,.ymland.mdmatch no glob, so the new gate script, the two catalog documents,ci.yml,package.json, the changeset and the pipeline page are outside the linted population entirely — not skipped by me.--format json: 6 results, cross-checked againstgit diff --name-only --diff-filter=d origin/main...HEAD | grep -E '\.(ts|tsx)$'— also 6, the same paths.0 errors, 6 warnings, every warning pre-existing on a line this branch does not touch (no-explicit-any,react-hooks/set-state-in-effect); ESLint exits 0 and this repo sets no--max-warnings.eslint.config.jssets noparserOptions.projectand noprojectService, so type-aware linting is not enabled and no rule reads another file's types. A change confined to these six files cannot move the verdict on any file the branch does not touch.The repo-wide
eslint .run is CI's, and it runs the farm regardless.Generated by Claude Code