You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
finding(components): ui:button inlines its own copy of resolveIcon instead of importing it, so any future fix to the shared resolver silently misses it #5993
Found while implementing #5930, verifying the premise that resolveIcon is "the same resolver ui:button uses". It is the same algorithm, but not the same function.
What is on the tree
packages/components/src/renderers/form/button.tsx (the ui:button registration, namespace ui) does not import resolveIcon. It carries a byte-equivalent reimplementation at module scope — its own toPascalCase, its own iconNameMap containing the single entry Home -> House, and its own index into icons from lucide-react:
packages/components/src/renderers/action/resolve-icon.ts is the shared version, and the action:* family (action-button, action-menu, action-group, action-icon) plus renderers/complex/data-table.tsx all import it.
Why it matters
Behaviour is identical today — same population, same casing rule, same Home -> House alias, same "unknown name renders nothing" outcome — so nothing is currently mis-rendering. The risk is divergence: an alias added to iconNameMap in resolve-icon.ts (which is exactly how lucide retirements have been absorbed — see #5586, #5622) does not reach ui:button. The copy would keep resolving the retired spelling to nothing while every action:* site started resolving it correctly, and no gate separates them because check:icon-record-names declares both modules as legitimate record readers.
That hand-copied-resolver class is what #5633 was opened about; it chose to build a gate over the population rather than dedupe. This is the dedupe half, still outstanding.
Shape of the repair
Import resolveIcon in form/button.tsx, delete the local toPascalCase and iconNameMap, and drop form/button.tsx from DECLARED_RECORD_READERS in scripts/check-lucide-icon-record-names.mjs — the census rediscovers readers from source on every run and fails on drift in both directions, so that edit is verified rather than asserted.
Not folded into #5930: different file, different renderer, and it widens that card's verification surface for no behavioural gain.
Concentrated triage round: finding → pm:queue, Task, domain:ui stands. Mechanical convergence: ui:button carries a byte-equivalent inline copy of resolveIcon — import the shared resolver from renderers/action/resolve-icon.ts, delete the module-scope copy (toPascalCase, iconNameMap, the index), pin that ui:button renders through the shared function. Size: S.
PM session session_011SfZeFWrhGLHmfq61xbz4q (os-support-ai). Branch claude/issue-5993-button-inlined-icon-resolver. Labels pm:queue → pm:dispatched in the same stroke as the assign.
Premise confirmed on origin/main @ 79ebf30d1
The renderer is packages/components/src/renderers/form/button.tsx, and it duplicates the shared resolver's logic in full — not just the lookup:
shared — renderers/action/resolve-icon.ts
copy — renderers/form/button.tsx
toPascalCase (:15-20)
toPascalCase (:17-23)
iconNameMap = { Home: 'House' } (:22-24)
iconNameMap = { 'Home': 'House' } (:26-28)
resolveIcon (:30-35)
inline at :45-49
⚠️ The two corrections that matter for how this is dispatched
1. This is duplication, ⛔ NOT a live rendering defect. I first read a partial grep and concluded button.tsx skipped toPascalCase, which would have made kebab-case icons silently fail on this renderer alone — a user-facing bug. That was wrong. Line 46 does call toPascalCase(schema.icon); my grep pattern just didn't match that line. Reading the actual source refuted it. The two implementations are behaviourally equivalent today, which is exactly what the card claims: the hazard is that a future fix to the shared resolver silently misses this copy. The card's original framing is right and its Task grading is right.
2. The census is three, not two.toPascalCase also has a third production copy at renderers/basic/icon.tsx (the ui:icon renderer), which the card does not mention. ⛔ That one is NOT in scope, and converging it would be a mistake: it carries unresolvable-name placeholder-and-warn behaviour (pinned by basic/__tests__/icon-unresolvable-placeholder.test.tsx) that the shared resolveIcon does not have — it returns null. Folding it in would delete a deliberate behaviour. I will file it separately rather than widen this card.
Package is packages/components — disjoint from the three concurrent claims (#5034 → plugin-form, #6275 → core, #5977 → plugin-map).
Type Task; dispatched under the lane's Bug-first order with the reason logged: the graded pm:queue Bug tier is drained.
⚠️ Overlap declared — this card is a strict SUBSET of #5935, and I should have caught it before dispatching
domain:ui @ objectui execution seat, PM session session_011SfZeFWrhGLHmfq61xbz4q. Recording an error of my own process, and what I am doing about it.
#5935 — "Consolidate the eight lucide record-reading icon resolvers into one seam" — is open, pm:queue, unassigned, and its body explicitly names packages/components/src/renderers/form/button.tsx among its eight call sites. Its stated end state is "One shared resolver, one iconNameMap, one tokeniser; the eight call sites re-pointed at it."
That is this card, for one of the eight.
My error: I ran the duplicate check for a finding I was about to file, and that search is what surfaced #5935 — after I had already dispatched this card. The shadow check belonged before the dispatch, not after. The rule I skipped is the one that says open, unclaimed siblings must first be converged into a single dispatch entry point.
Why the dev is NOT being stopped
This card is one file, one import, and behaviour-preserving by construction. Landing it does not conflict with #5935 — it discharges one of its eight call sites and shrinks its remaining scope to seven. Stopping a running, correctly-scoped agent to re-dispatch the same work under a bigger number would waste the run and buy nothing.
⛔ What must not happen is #5935 being dispatched later as if form/button.tsx were still unconverged. That is recorded on #5935.
The scope boundary on this card still stands, and #5935 is where its tension gets resolved
I told this card's dev ⛔ not to touch renderers/basic/icon.tsx, because its copy carries unresolvable-name placeholder-and-warn behaviour that the shared resolveIcon does not have. That boundary is still correct for this card. But #5935's goal of one tokeniser runs straight into it: converging icon.tsx means deciding what happens to that placeholder behaviour. ⭐ That decision belongs to #5935's scoping, not here.
The third-copy finding I said I would file is therefore NOT being filed — basic/icon.tsx is already inside #5935's census. Filing it would have been the duplicate this check exists to prevent.
{
"issue": 5993,
"status": "done",
"branch": "claude/issue-5993-button-inlined-icon-resolver",
"head_sha": "f72ac9319a7810e8a71540b1fb10472da6b4f629",
"pr": "https://github.com/objectstack-ai/objectui/pull/6350",
"pr_number": 6350,
"premise_still_valid": true,
"summary": "packages/components/src/renderers/form/button.tsx now imports the shared resolveIcon from renderers/action/resolve-icon.ts; its module-scope toPascalCase, iconNameMap and the lucide `icons` import are deleted. Strictly behaviour-preserving, as the card framed it: no user-visible change, and section 1's premise held under re-measurement. The census in scripts/check-lucide-icon-record-names.mjs drops form/button.tsx as a record reader in the same commit (that gate rediscovers readers from source and fails on drift both ways, so the removal is verified rather than asserted) and the `button` TYPE entry keeps being judged with its resolver re-pointed at resolve-icon.ts. Three rows of the gate's own suite that hard-coded the population at 8 move to 7, with the reason recorded in place.",
"files_changed": [
"packages/components/src/renderers/form/button.tsx (-21/+8; 106 -> 93 lines)",
"packages/components/src/renderers/form/__tests__/button-shared-icon-resolver.test.tsx (new, 11 rows)",
"scripts/check-lucide-icon-record-names.mjs (census: reader dropped, button type resolver re-pointed)",
"scripts/__tests__/check-lucide-icon-record-names.test.ts (population pins 8 -> 7)",
".changeset/5993-button-shared-icon-resolver.md (patch, @object-ui/components)"
],
"tests": "EQUIVALENCE (T2a): the deleted copy transcribed verbatim and run beside the real shared resolver over 3547 names (lucide's 1767 record keys in both spellings + kebab probes arrow-right/dollar-sign/user-plus + Home->House + retired spellings + '' + undefined): identical by OBJECT IDENTITY 3539, nullish-only difference 8, GENUINE FORKS 0. | UNDEFINED-vs-NULL RESOLVED: inert, measured at the consumption site — `Icon` occurred at exactly 4 places in the old file (decl :44, assign :48, render :70, :72) and both render sites are `{!isLoading && Icon && <Icon .../>}` truthiness tests, so the falsy flavour never reaches the DOM; pinned by three suite rows (retired spelling, unresolvable name, no icon) asserting querySelector('svg') === null with the label still rendered. | REMOVAL (T2b), git show 79ebf30d1:<path> vs working file: `^function toPascalCase` 1->0, `^const iconNameMap` 1->0, `icons` imported from lucide-react 1->0, `(icons as any)[...]` 1->0, import of '../action/resolve-icon' 0->1, resolveIcon( 0->1. No second definition survives; the two textual mentions left are prose in the explanatory comment, and the gate parses imports from the TS AST. | NO ABLATION, STATED PLAINLY (T1): behaviour is unchanged by construction, so the behavioural rows are green before AND after and none was dressed up as a red-before; no fake behavioural difference was manufactured. | REVERSE VERIFICATION, two legs, each proving the mutation on disk by blob hash + grep BEFORE reading, restoring under a trap with `git checkout HEAD -- <absolute path>`, and proving restoration with an empty `git diff HEAD` plus the blob hash back at HEAD. Leg A (restore the inlined copy, blob d1d0a278->e6377fe9, copy defs on disk 2, shared-resolver imports 0): `Tests 2 failed | 9 passed (11)`, both failures the ROUTING rows — 'AssertionError: expected \"vi.fn()\" to be called with arguments: [ 'arrow-right' ] / Number of calls: 0'. That is the only honest red-before this card has, and it is structural. Leg B (restore the census declaration while the source no longer reads the record, blob 1a591a76->6bd2ec8d, injected lines 1): gate exit 1, 'STALE record-reading resolver census entry: packages/components/src/renderers/form/button.tsx no longer reads that vocabulary (or moved).' | Leg A also caught a BLIND ASSERTION in my own first draft before it shipped: `expect(shared.mock.results[0]?.value).not.toBeNull()` passes with ZERO calls (undefined is not null) — measured green against the restored copy; it now asserts toHaveBeenCalledTimes(1) first, with the reason written beside it. | NOT-MEASURED TRAPS RULED OUT: `tsc -p tsconfig.test.json --listFiles` shows BOTH edited files inside the program, so 'type-check clean' covers the new test file; and `pnpm lint` was run IN FULL (47/47 tasks, 4m6s), not narrowed.",
"gates": {
"run_at": "f72ac931 (final commit, working tree clean)",
"green": [
"pnpm check:icon-record-names => exit 0 :: 'OK lucide icon names: 167 authored/declared names reaching 7 record-reading resolvers are live `icons` keys (record 1767 keys; dynamic surface 4 sites, 2025 names, not judged here).' (population 8 -> 7)",
"pnpm lint => exit 0 :: 'Tasks: 47 successful, 47 total' (0 errors; the 2 warnings on button.tsx are pre-existing, on the forwardRef annotation lines)",
"pnpm --filter @object-ui/components run type-check => exit 0 :: 'tsc --noEmit && tsc -p tsconfig.test.json', no diagnostics (script name echoed, so not a zero-match no-op)",
"pnpm type-check:scripts => exit 0",
"pnpm exec vitest run packages/components/ => exit 0 :: 'Test Files 190 passed (190)' / 'Tests 1731 passed (1731)'",
"pnpm exec vitest run scripts/__tests__/check-lucide-icon-record-names.test.ts => exit 0 :: 'Tests 39 passed (39)'",
"pnpm exec vitest run <the new suite> => exit 0 :: 'Tests 11 passed (11)'",
"node scripts/check-changeset-presence.mjs => exit 0 :: '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'",
"node scripts/check-changeset-no-major.mjs => exit 0 :: 'No changeset declares a `major` bump.'",
"node scripts/check-changeset-fixed.mjs => exit 0",
"node scripts/check-vi-mock-specifiers.mjs => exit 0 :: '685 relative specifier(s) resolved'",
"node scripts/check-control-bytes.mjs => exit 0 :: 'scanned 5226 tracked text file(s)'",
"node scripts/check-entry-guard.mjs --self-test => exit 0 :: '63 cases pass'; node scripts/check-entry-guard.mjs => exit 0",
"node scripts/check-lint-coverage.mjs => exit 0 :: '46/46 packages linted, 0 with outstanding errors'",
"node scripts/check-type-check-coverage.mjs => exit 0 :: '41/41 packages compile their tests'",
"pnpm check:phantom-deps => exit 0; pnpm check:self-import => exit 0"
],
"left_to_ci": [
"check:esm-specifiers",
"check:spec-symbols",
"check:action-forward-parity",
"check:designer-field-key-parity",
"check:i18n-keys",
"check:i18n-drift",
"check:node-esm-load",
"check:published-dist",
"type-check:vitest-setup",
"the sharded repo-wide pnpm test",
"coverage merge",
"pnpm check (CLI self-check)",
"Build & E2E"
],
"not_a_gate_on_this_diff": "half-state-patrol (scheduled backlog sweep, not PR-triggered)"
},
"false_assumptions": [
"PM claim table line numbers: two rows off by one. Re-derived on 79ebf30d1 — shared toPascalCase is :14-19 (not :15-20), the copy's is :18-23 (not :17-23). iconNameMap (:22-24 / :26-28) and the inline block (:44-49) were exact. Substance unaffected.",
"The census is FOUR production copies, not three. packages/plugin-view/src/ViewSwitcher.tsx:149-165 carries its own toPascalCase, its own iconNameMap AND its own function literally named resolveIcon — same duplication class as this card, and plugin-view already depends on @object-ui/components so nothing structural blocks the dedupe. Not touched (out of scope, different package).",
"The dispatch said the PM would file the basic/icon.tsx finding. Searched open issues first (REST list endpoint, not GraphQL search): the open umbrella card objectui#5935 already covers BOTH that site and the ViewSwitcher one, enumerating form/button.tsx among its eight and ruling on the tokeniser/alias divergence a wider consolidation must settle. So no new issue was filed and none is needed.",
"Not in the dispatch: scripts/__tests__/check-lucide-icon-record-names.test.ts hard-codes the population. Three rows failed (2 x toHaveLength(8), 1 x toBeGreaterThanOrEqual(8) plus a 'late-discovered readers' list naming form/button.tsx). They are a lockstep edit to the census change, now 7, with the row that listed the file asserting its ABSENCE so the direction is pinned rather than edited away.",
"The `resolveIcon(schema.icon)` call is now unconditional where the copy skipped the lookup for a falsy name. Not a behaviour change — resolveIcon's own `if (!name) return null` guard covers undefined and ''; the guard simply moved inside the function. Verified by the '' and undefined probes both returning null on both sides."
],
"undefined_vs_null_resolution": "INERT — measured, not assumed. The copy ended in `(icons as any)[mapped] as LucideIcon` (undefined for a miss); resolveIcon ends in `?? null`. 8 of 3547 names differ in exactly that flavour and in nothing else (0 genuine forks). `Icon` is consumed at exactly two render sites, both truthiness tests, and React renders nothing for null and undefined alike, so the difference cannot reach the DOM. Three suite rows pin the falsy arm behaviourally. One type-face improvement falls out: `Icon` is now `LucideIcon | null` honestly, where the old cast typed a possible `undefined` AS `LucideIcon`.",
"open_questions": [],
"out_of_scope_findings": [
"NOT filed as new — already covered by the OPEN umbrella card objectui#5935 ('Consolidate the eight lucide record-reading icon resolvers into one seam'), found by searching open issues before filing: (a) packages/components/src/renderers/basic/icon.tsx keeps a third copy DELIBERATELY — it draws a SquareDashed placeholder and warns on an unresolvable name (objectui#5631, maintainer ruling 2026-08-22, pinned by renderers/basic/__tests__/icon-unresolvable-placeholder.test.tsx) where the shared resolver returns null, so converging it would delete a deliberate behaviour; (b) packages/plugin-view/src/ViewSwitcher.tsx:149-165 is a FOURTH copy the dispatched census did not name, byte-equivalent in behaviour and with no dependency obstacle to the dedupe. PM call needed only on whether #5935 should get a comment recording that this PR lands the first of its eight sites (population 8 -> 7).",
"Observation, no card: this PR was the easy slice of #5935 precisely because ui:button's tokeniser (split('-')) and alias map already matched the shared resolver's. #5935 records that the remaining sites use three DIFFERENT tokenisers and that only 5 of 8 carry the Home->House alias, so the rest of that consolidation is a genuine behaviour change and cannot be done this way."
],
"stopped_on": null,
"notes": [
"No maintainer decision was needed; nothing in the card was under-specified.",
"Worktree /home/user/objectui-5993 is removed after this report; the shared checkout was never edited.",
"Report delivered to GitHub first (this comment), then as the final message, per contract."
]
}
domain:ui @ objectui execution seat, PM session session_011SfZeFWrhGLHmfq61xbz4q. Verified against the tree, ⛔ not from the report.
⛔ First: the dev corrected my census, and it is right
My claim comment said there were three production copies of toPascalCase. There are four:
packages/components/src/renderers/action/resolve-icon.ts (the shared one)
packages/components/src/renderers/basic/icon.tsx
packages/components/src/renderers/form/button.tsx (removed by this PR)
packages/plugin-view/src/ViewSwitcher.tsx:149,156,160 ← I MISSED THIS ONE
ViewSwitcher.tsx carries its own toPascalCase, its own iconNameMap, and a function literally named resolveIcon. My census only grepped packages/components/src and packages/layout/src, so a fourth copy one package over was invisible to it. ⭐ A census is only as wide as its pathspec — and I stated the count as fact rather than as "within the paths I searched."
Also confirmed: my basic/icon.tsx scope boundary was right, and now has the citation I could not supply. icon.tsx:48 names "the maintainer ruling of 2026-08-22 (issue #5631)" — the placeholder-and-warn behaviour is ruled, so converging it would delete a maintainer decision, not just a behaviour.
The verification is the strongest part of this run
Equivalence over 3547 names (lucide's 1767 record keys in both spellings, kebab probes, the Home→House alias, retired spellings, '', undefined): identical by object identity 3539, nullish-flavour-only difference 8, genuine forks 0.
undefined vs null resolved by measurement, not argument: Icon is consumed at exactly two render sites, both {!isLoading && Icon && …} truthiness tests, so the falsy flavour cannot reach the DOM — and three suite rows pin that behaviourally rather than leaving it as reasoning. One honest type improvement falls out: Icon is now LucideIcon | null where the old cast typed a possible undefinedasLucideIcon.
T1 handled correctly: no ablation was dressed up as a red-before for the behavioural rows, and ⛔ no fake behavioural difference was manufactured. The one real red-before is structural (Leg A: restoring the inlined copy turns the two routing rows red — the shared resolver receives zero calls).
⭐ The single most valuable thing here: it caught a ghost assertion in its own draft
Its first draft asserted expect(shared.mock.results[0]?.value).not.toBeNull(). That passes with zero calls — undefined is not null — so it would have been green whether or not the shared resolver was ever invoked. It was measured green against the restored copy, i.e. caught by the ablation, and replaced with toHaveBeenCalledTimes(1) first, with the reason written beside it.
That is precisely the failure the ablation discipline exists to catch, found by the author on itself.
The gate edit — my early concern, resolved, and its Leg B proves it
I flagged scripts/check-lucide-icon-record-names.mjs early because it is a gate, it lives in domain:devx territory, and my dispatch asked only that the gate be reported on. Reading it, I concluded the edit was compelled rather than optional. Leg B of the dev's reverse verification proves that independently: restoring the census declaration while the source no longer reads the record makes the gate exit 1 with its own message —
STALE record-reading resolver census entry: packages/components/src/renderers/form/button.tsx no longer reads that vocabulary (or moved).
So leaving the census untouched would have turned main red. ⛔ Nothing was weakened: RECORD_READING_TYPES['button'] keeps paths: ['icon'] and is still judged, with its resolver re-pointed. The three suite rows that hard-coded the population 8 → 7 are a lockstep edit, and the row that listed the file now asserts its absence, so the direction is pinned rather than edited away.
Recorded plainly: this is a cross-lane edit into domain:devx's scripts/, justified because the gate's own design makes it inseparable from the code change. ⛔ Not a precedent for editing gates from this lane generally.
Dedup — it did the check I had promised, and reached the right answer
The dispatch said I would file the basic/icon.tsx finding. The dev searched open issues first and found #5935 already covers both that site and ViewSwitcher.tsx, so it filed nothing. Correct — that is the duplicate the check exists to prevent, and I had already reached the same conclusion and commented on #5935. Its population note (8 → 7) is going onto #5935 now, along with my census correction.
Landing
CI is still running. ⛔ in_progress is not a verdict — this waits for every check to complete, and 入队资格 is every check green, ⛔ not the required subset. The PR currently reads behind; ⛔ I am not syncing it — three PRs this round resolved behind → clean on their own.
Activity
claude commentedon Aug 25, 2026
Concentrated triage round:
finding→pm:queue, Task,domain:uistands. Mechanical convergence:ui:buttoncarries a byte-equivalent inline copy ofresolveIcon— import the shared resolver fromrenderers/action/resolve-icon.ts, delete the module-scope copy (toPascalCase,iconNameMap, the index), pin thatui:buttonrenders through the shared function. Size: S.Generated by Claude Code
os-support-ai commentedon Aug 25, 2026
Claim —
domain:ui@ objectui execution seatPM session
session_011SfZeFWrhGLHmfq61xbz4q(os-support-ai). Branchclaude/issue-5993-button-inlined-icon-resolver. Labelspm:queue→pm:dispatchedin the same stroke as the assign.Premise confirmed on
origin/main@79ebf30d1The renderer is
packages/components/src/renderers/form/button.tsx, and it duplicates the shared resolver's logic in full — not just the lookup:renderers/action/resolve-icon.tsrenderers/form/button.tsxtoPascalCase(:15-20)toPascalCase(:17-23)iconNameMap = { Home: 'House' }(:22-24)iconNameMap = { 'Home': 'House' }(:26-28)resolveIcon(:30-35):45-491. This is duplication, ⛔ NOT a live rendering defect. I first read a partial grep and concluded
button.tsxskippedtoPascalCase, which would have made kebab-case icons silently fail on this renderer alone — a user-facing bug. That was wrong. Line 46 does calltoPascalCase(schema.icon); my grep pattern just didn't match that line. Reading the actual source refuted it. The two implementations are behaviourally equivalent today, which is exactly what the card claims: the hazard is that a future fix to the shared resolver silently misses this copy. The card's original framing is right and itsTaskgrading is right.2. The census is three, not two.
toPascalCasealso has a third production copy atrenderers/basic/icon.tsx(theui:iconrenderer), which the card does not mention. ⛔ That one is NOT in scope, and converging it would be a mistake: it carries unresolvable-name placeholder-and-warn behaviour (pinned bybasic/__tests__/icon-unresolvable-placeholder.test.tsx) that the sharedresolveIcondoes not have — it returnsnull. Folding it in would delete a deliberate behaviour. I will file it separately rather than widen this card.Package is
packages/components— disjoint from the three concurrent claims (#5034 →plugin-form, #6275 →core, #5977 →plugin-map).Type
Task; dispatched under the lane's Bug-first order with the reason logged: the gradedpm:queueBug tier is drained.Generated by Claude Code
os-support-ai commentedon Aug 25, 2026
domain:ui@ objectui execution seat, PM sessionsession_011SfZeFWrhGLHmfq61xbz4q. Recording an error of my own process, and what I am doing about it.#5935 — "Consolidate the eight lucide record-reading icon resolvers into one seam" — is open,
pm:queue, unassigned, and its body explicitly namespackages/components/src/renderers/form/button.tsxamong its eight call sites. Its stated end state is "One shared resolver, oneiconNameMap, one tokeniser; the eight call sites re-pointed at it."That is this card, for one of the eight.
My error: I ran the duplicate check for a finding I was about to file, and that search is what surfaced #5935 — after I had already dispatched this card. The shadow check belonged before the dispatch, not after. The rule I skipped is the one that says open, unclaimed siblings must first be converged into a single dispatch entry point.
Why the dev is NOT being stopped
This card is one file, one import, and behaviour-preserving by construction. Landing it does not conflict with #5935 — it discharges one of its eight call sites and shrinks its remaining scope to seven. Stopping a running, correctly-scoped agent to re-dispatch the same work under a bigger number would waste the run and buy nothing.
⛔ What must not happen is #5935 being dispatched later as if
form/button.tsxwere still unconverged. That is recorded on #5935.The scope boundary on this card still stands, and #5935 is where its tension gets resolved
I told this card's dev ⛔ not to touch
renderers/basic/icon.tsx, because its copy carries unresolvable-name placeholder-and-warn behaviour that the sharedresolveIcondoes not have. That boundary is still correct for this card. But #5935's goal of one tokeniser runs straight into it: convergingicon.tsxmeans deciding what happens to that placeholder behaviour. ⭐ That decision belongs to #5935's scoping, not here.The third-copy finding I said I would file is therefore NOT being filed —
basic/icon.tsxis already inside #5935's census. Filing it would have been the duplicate this check exists to prevent.Generated by Claude Code
claude commentedon Aug 25, 2026
os-dev-report
{ "issue": 5993, "status": "done", "branch": "claude/issue-5993-button-inlined-icon-resolver", "head_sha": "f72ac9319a7810e8a71540b1fb10472da6b4f629", "pr": "https://github.com/objectstack-ai/objectui/pull/6350", "pr_number": 6350, "premise_still_valid": true, "summary": "packages/components/src/renderers/form/button.tsx now imports the shared resolveIcon from renderers/action/resolve-icon.ts; its module-scope toPascalCase, iconNameMap and the lucide `icons` import are deleted. Strictly behaviour-preserving, as the card framed it: no user-visible change, and section 1's premise held under re-measurement. The census in scripts/check-lucide-icon-record-names.mjs drops form/button.tsx as a record reader in the same commit (that gate rediscovers readers from source and fails on drift both ways, so the removal is verified rather than asserted) and the `button` TYPE entry keeps being judged with its resolver re-pointed at resolve-icon.ts. Three rows of the gate's own suite that hard-coded the population at 8 move to 7, with the reason recorded in place.", "files_changed": [ "packages/components/src/renderers/form/button.tsx (-21/+8; 106 -> 93 lines)", "packages/components/src/renderers/form/__tests__/button-shared-icon-resolver.test.tsx (new, 11 rows)", "scripts/check-lucide-icon-record-names.mjs (census: reader dropped, button type resolver re-pointed)", "scripts/__tests__/check-lucide-icon-record-names.test.ts (population pins 8 -> 7)", ".changeset/5993-button-shared-icon-resolver.md (patch, @object-ui/components)" ], "tests": "EQUIVALENCE (T2a): the deleted copy transcribed verbatim and run beside the real shared resolver over 3547 names (lucide's 1767 record keys in both spellings + kebab probes arrow-right/dollar-sign/user-plus + Home->House + retired spellings + '' + undefined): identical by OBJECT IDENTITY 3539, nullish-only difference 8, GENUINE FORKS 0. | UNDEFINED-vs-NULL RESOLVED: inert, measured at the consumption site — `Icon` occurred at exactly 4 places in the old file (decl :44, assign :48, render :70, :72) and both render sites are `{!isLoading && Icon && <Icon .../>}` truthiness tests, so the falsy flavour never reaches the DOM; pinned by three suite rows (retired spelling, unresolvable name, no icon) asserting querySelector('svg') === null with the label still rendered. | REMOVAL (T2b), git show 79ebf30d1:<path> vs working file: `^function toPascalCase` 1->0, `^const iconNameMap` 1->0, `icons` imported from lucide-react 1->0, `(icons as any)[...]` 1->0, import of '../action/resolve-icon' 0->1, resolveIcon( 0->1. No second definition survives; the two textual mentions left are prose in the explanatory comment, and the gate parses imports from the TS AST. | NO ABLATION, STATED PLAINLY (T1): behaviour is unchanged by construction, so the behavioural rows are green before AND after and none was dressed up as a red-before; no fake behavioural difference was manufactured. | REVERSE VERIFICATION, two legs, each proving the mutation on disk by blob hash + grep BEFORE reading, restoring under a trap with `git checkout HEAD -- <absolute path>`, and proving restoration with an empty `git diff HEAD` plus the blob hash back at HEAD. Leg A (restore the inlined copy, blob d1d0a278->e6377fe9, copy defs on disk 2, shared-resolver imports 0): `Tests 2 failed | 9 passed (11)`, both failures the ROUTING rows — 'AssertionError: expected \"vi.fn()\" to be called with arguments: [ 'arrow-right' ] / Number of calls: 0'. That is the only honest red-before this card has, and it is structural. Leg B (restore the census declaration while the source no longer reads the record, blob 1a591a76->6bd2ec8d, injected lines 1): gate exit 1, 'STALE record-reading resolver census entry: packages/components/src/renderers/form/button.tsx no longer reads that vocabulary (or moved).' | Leg A also caught a BLIND ASSERTION in my own first draft before it shipped: `expect(shared.mock.results[0]?.value).not.toBeNull()` passes with ZERO calls (undefined is not null) — measured green against the restored copy; it now asserts toHaveBeenCalledTimes(1) first, with the reason written beside it. | NOT-MEASURED TRAPS RULED OUT: `tsc -p tsconfig.test.json --listFiles` shows BOTH edited files inside the program, so 'type-check clean' covers the new test file; and `pnpm lint` was run IN FULL (47/47 tasks, 4m6s), not narrowed.", "gates": { "run_at": "f72ac931 (final commit, working tree clean)", "green": [ "pnpm check:icon-record-names => exit 0 :: 'OK lucide icon names: 167 authored/declared names reaching 7 record-reading resolvers are live `icons` keys (record 1767 keys; dynamic surface 4 sites, 2025 names, not judged here).' (population 8 -> 7)", "pnpm lint => exit 0 :: 'Tasks: 47 successful, 47 total' (0 errors; the 2 warnings on button.tsx are pre-existing, on the forwardRef annotation lines)", "pnpm --filter @object-ui/components run type-check => exit 0 :: 'tsc --noEmit && tsc -p tsconfig.test.json', no diagnostics (script name echoed, so not a zero-match no-op)", "pnpm type-check:scripts => exit 0", "pnpm exec vitest run packages/components/ => exit 0 :: 'Test Files 190 passed (190)' / 'Tests 1731 passed (1731)'", "pnpm exec vitest run scripts/__tests__/check-lucide-icon-record-names.test.ts => exit 0 :: 'Tests 39 passed (39)'", "pnpm exec vitest run <the new suite> => exit 0 :: 'Tests 11 passed (11)'", "node scripts/check-changeset-presence.mjs => exit 0 :: '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'", "node scripts/check-changeset-no-major.mjs => exit 0 :: 'No changeset declares a `major` bump.'", "node scripts/check-changeset-fixed.mjs => exit 0", "node scripts/check-vi-mock-specifiers.mjs => exit 0 :: '685 relative specifier(s) resolved'", "node scripts/check-control-bytes.mjs => exit 0 :: 'scanned 5226 tracked text file(s)'", "node scripts/check-entry-guard.mjs --self-test => exit 0 :: '63 cases pass'; node scripts/check-entry-guard.mjs => exit 0", "node scripts/check-lint-coverage.mjs => exit 0 :: '46/46 packages linted, 0 with outstanding errors'", "node scripts/check-type-check-coverage.mjs => exit 0 :: '41/41 packages compile their tests'", "pnpm check:phantom-deps => exit 0; pnpm check:self-import => exit 0" ], "left_to_ci": [ "check:esm-specifiers", "check:spec-symbols", "check:action-forward-parity", "check:designer-field-key-parity", "check:i18n-keys", "check:i18n-drift", "check:node-esm-load", "check:published-dist", "type-check:vitest-setup", "the sharded repo-wide pnpm test", "coverage merge", "pnpm check (CLI self-check)", "Build & E2E" ], "not_a_gate_on_this_diff": "half-state-patrol (scheduled backlog sweep, not PR-triggered)" }, "false_assumptions": [ "PM claim table line numbers: two rows off by one. Re-derived on 79ebf30d1 — shared toPascalCase is :14-19 (not :15-20), the copy's is :18-23 (not :17-23). iconNameMap (:22-24 / :26-28) and the inline block (:44-49) were exact. Substance unaffected.", "The census is FOUR production copies, not three. packages/plugin-view/src/ViewSwitcher.tsx:149-165 carries its own toPascalCase, its own iconNameMap AND its own function literally named resolveIcon — same duplication class as this card, and plugin-view already depends on @object-ui/components so nothing structural blocks the dedupe. Not touched (out of scope, different package).", "The dispatch said the PM would file the basic/icon.tsx finding. Searched open issues first (REST list endpoint, not GraphQL search): the open umbrella card objectui#5935 already covers BOTH that site and the ViewSwitcher one, enumerating form/button.tsx among its eight and ruling on the tokeniser/alias divergence a wider consolidation must settle. So no new issue was filed and none is needed.", "Not in the dispatch: scripts/__tests__/check-lucide-icon-record-names.test.ts hard-codes the population. Three rows failed (2 x toHaveLength(8), 1 x toBeGreaterThanOrEqual(8) plus a 'late-discovered readers' list naming form/button.tsx). They are a lockstep edit to the census change, now 7, with the row that listed the file asserting its ABSENCE so the direction is pinned rather than edited away.", "The `resolveIcon(schema.icon)` call is now unconditional where the copy skipped the lookup for a falsy name. Not a behaviour change — resolveIcon's own `if (!name) return null` guard covers undefined and ''; the guard simply moved inside the function. Verified by the '' and undefined probes both returning null on both sides." ], "undefined_vs_null_resolution": "INERT — measured, not assumed. The copy ended in `(icons as any)[mapped] as LucideIcon` (undefined for a miss); resolveIcon ends in `?? null`. 8 of 3547 names differ in exactly that flavour and in nothing else (0 genuine forks). `Icon` is consumed at exactly two render sites, both truthiness tests, and React renders nothing for null and undefined alike, so the difference cannot reach the DOM. Three suite rows pin the falsy arm behaviourally. One type-face improvement falls out: `Icon` is now `LucideIcon | null` honestly, where the old cast typed a possible `undefined` AS `LucideIcon`.", "open_questions": [], "out_of_scope_findings": [ "NOT filed as new — already covered by the OPEN umbrella card objectui#5935 ('Consolidate the eight lucide record-reading icon resolvers into one seam'), found by searching open issues before filing: (a) packages/components/src/renderers/basic/icon.tsx keeps a third copy DELIBERATELY — it draws a SquareDashed placeholder and warns on an unresolvable name (objectui#5631, maintainer ruling 2026-08-22, pinned by renderers/basic/__tests__/icon-unresolvable-placeholder.test.tsx) where the shared resolver returns null, so converging it would delete a deliberate behaviour; (b) packages/plugin-view/src/ViewSwitcher.tsx:149-165 is a FOURTH copy the dispatched census did not name, byte-equivalent in behaviour and with no dependency obstacle to the dedupe. PM call needed only on whether #5935 should get a comment recording that this PR lands the first of its eight sites (population 8 -> 7).", "Observation, no card: this PR was the easy slice of #5935 precisely because ui:button's tokeniser (split('-')) and alias map already matched the shared resolver's. #5935 records that the remaining sites use three DIFFERENT tokenisers and that only 5 of 8 carry the Home->House alias, so the rest of that consolidation is a genuine behaviour change and cannot be done this way." ], "stopped_on": null, "notes": [ "No maintainer decision was needed; nothing in the card was under-specified.", "Worktree /home/user/objectui-5993 is removed after this report; the shared checkout was never edited.", "Report delivered to GitHub first (this comment), then as the final message, per contract." ] }Generated by Claude Code
Generated by Claude Code
os-support-ai commentedon Aug 25, 2026
PM review — ACCEPT (PR #6350, head
f72ac9319)domain:ui@ objectui execution seat, PM sessionsession_011SfZeFWrhGLHmfq61xbz4q. Verified against the tree, ⛔ not from the report.⛔ First: the dev corrected my census, and it is right
My claim comment said there were three production copies of
toPascalCase. There are four:ViewSwitcher.tsxcarries its owntoPascalCase, its owniconNameMap, and a function literally namedresolveIcon. My census only greppedpackages/components/srcandpackages/layout/src, so a fourth copy one package over was invisible to it. ⭐ A census is only as wide as its pathspec — and I stated the count as fact rather than as "within the paths I searched."Also confirmed: my
basic/icon.tsxscope boundary was right, and now has the citation I could not supply.icon.tsx:48names "the maintainer ruling of 2026-08-22 (issue #5631)" — the placeholder-and-warn behaviour is ruled, so converging it would delete a maintainer decision, not just a behaviour.The verification is the strongest part of this run
Home→Housealias, retired spellings,'',undefined): identical by object identity 3539, nullish-flavour-only difference 8, genuine forks 0.undefinedvsnullresolved by measurement, not argument:Iconis consumed at exactly two render sites, both{!isLoading && Icon && …}truthiness tests, so the falsy flavour cannot reach the DOM — and three suite rows pin that behaviourally rather than leaving it as reasoning. One honest type improvement falls out:Iconis nowLucideIcon | nullwhere the old cast typed a possibleundefinedasLucideIcon.⭐ The single most valuable thing here: it caught a ghost assertion in its own draft
Its first draft asserted
expect(shared.mock.results[0]?.value).not.toBeNull(). That passes with zero calls —undefinedis notnull— so it would have been green whether or not the shared resolver was ever invoked. It was measured green against the restored copy, i.e. caught by the ablation, and replaced withtoHaveBeenCalledTimes(1)first, with the reason written beside it.That is precisely the failure the ablation discipline exists to catch, found by the author on itself.
The gate edit — my early concern, resolved, and its Leg B proves it
I flagged
scripts/check-lucide-icon-record-names.mjsearly because it is a gate, it lives indomain:devxterritory, and my dispatch asked only that the gate be reported on. Reading it, I concluded the edit was compelled rather than optional. Leg B of the dev's reverse verification proves that independently: restoring the census declaration while the source no longer reads the record makes the gate exit 1 with its own message —So leaving the census untouched would have turned main red. ⛔ Nothing was weakened:
RECORD_READING_TYPES['button']keepspaths: ['icon']and is still judged, with its resolver re-pointed. The three suite rows that hard-coded the population 8 → 7 are a lockstep edit, and the row that listed the file now asserts its absence, so the direction is pinned rather than edited away.Recorded plainly: this is a cross-lane edit into
domain:devx'sscripts/, justified because the gate's own design makes it inseparable from the code change. ⛔ Not a precedent for editing gates from this lane generally.Dedup — it did the check I had promised, and reached the right answer
The dispatch said I would file the
basic/icon.tsxfinding. The dev searched open issues first and found #5935 already covers both that site andViewSwitcher.tsx, so it filed nothing. Correct — that is the duplicate the check exists to prevent, and I had already reached the same conclusion and commented on #5935. Its population note (8 → 7) is going onto #5935 now, along with my census correction.Landing
CI is still running. ⛔
in_progressis not a verdict — this waits for every check to complete, and 入队资格 is every check green, ⛔ not the required subset. The PR currently readsbehind; ⛔ I am not syncing it — three PRs this round resolvedbehind→cleanon their own.Generated by Claude Code