Skip to content

chore(deps): run pnpm dedupe --lockfile-only on an untouched main — the measurement (objectui#9215) - #9316

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-9215-lockfile-dedupe-measurement
Sep 13, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-9215-lockfile-dedupe-measurement

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of objectui#9215 — step B of objectui#8333's ruling. Deliberately not a closing reference: leg 3 of triage's grading fires (see below), so the merge is a maintainer decision, and the card stays open until that decision is made.

⛔ This PR stays DRAFT. Not flipped ready, not enqueued, no auto-merge.

What is in the diff

Two files, nothing else: pnpm-lock.yaml (the dedupe) and one empty-frontmatter changeset declaring no release. The whole point of the dedicated PR is that the 47 collapsed identities are not misattributed to an unrelated bump — that is what objectui#8333 is blocked on.

⚠️ Every number on the card was a prediction. All of them reproduce.

Triage stated plainly that it re-ran none of it. Re-derived here on d7f0601ee7 (2026-09-12), pnpm@10.31.0, Node v22.22.2, dedicated worktree off origin/main, nothing else edited. No figure differs from the card.

The lockfile itself has not moved in the four days: its sha256 on d7f0601ee7 is e4e3a203f2f2686daca282323ab5cd222ccea27c56cf28f81a3ea984e3b88ccd, byte-for-byte the reading the card took on a94e4d073a.

1. The byte-identical-reproduction control, both directions, same run

Negative leg — pnpm install --lockfile-only on the unmodified base reproduces the committed lockfile byte-identically:

pnpm install --lockfile-only   -> exit 0, "Done in 1.3s using pnpm v10.31.0"
git diff --stat                -> 0 bytes of output
git status --porcelain         -> 0 bytes of output
sha256  e4e3a203f2f2686daca282323ab5cd222ccea27c56cf28f81a3ea984e3b88ccd   HEAD:pnpm-lock.yaml
sha256  e4e3a203f2f2686daca282323ab5cd222ccea27c56cf28f81a3ea984e3b88ccd   worktree pnpm-lock.yaml

Positive leg, run against the same tree — a strict one-byte append (701,203 -> 701,204 bytes, sha256 moves) makes the same command print:

 pnpm-lock.yaml | 1 +
 1 file changed, 1 insertion(+)

⇒ the empty reading above is a measurement, not a broken command. The mutation script carried a trap ... EXIT INT TERM restore; restoration proved by git diff HEAD --stat at 0 bytes and the sha256 back to e4e3a20….

2. The collapse

git diff --stat
 pnpm-lock.yaml | 947 +++++-------------------------------
 1 file changed, 186 insertions(+), 761 deletions(-)

resolutions (snapshot keys)   1822 -> 1773     (card predicted 1822 -> 1773)
identities  (name@version)    1814 -> 1767     (card predicted 1814 -> 1767)
identities removed            47               (card predicted 47)
identities added              0                (card predicted 0)

Under the identity count, snapshot keys move 60 removed / 11 added. The 11 added are peer-suffix re-spellings of identities that survive (vitest, @vitest/mocker, @vitejs/plugin-react, fumadocs-*, vite-plugin-dts, unplugin-dts, fdir), not new packages — which is why identities added is still 0.

0 added is the load-bearing half: nothing moves forward that was not already in the tree. Checked mechanically — all 47 dropped versions have a strictly higher surviving sibling of the same name, 0 do not, so no consumer can move backward.

The repo's own gate agrees, as the card predicted:

node scripts/check-lockfile-integrity.mjs --base-ref d7f0601ee72ba646c296dcd8c1f65343f6877ac3
exit 0
base: d7f0601ee7…:pnpm-lock.yaml (1822 resolutions)  head: …/pnpm-lock.yaml (1773 resolutions)
VERDICT clean — no @objectstack/* identity moved backward and no package gained a copy.

3. Zero declarations, zero ranges, zero overrides — verified, not assumed

  • git status on the dedupe shows one modified file, pnpm-lock.yaml. No package.json, no pnpm-workspace.yaml.
  • Lockfile overrides:, settings: and patchedDependencies: sections are byte-identical (same sha256 both sides). lockfileVersion unchanged, same seven top-level sections both sides.
  • Inside importers: (same 2834 lines both sides): 0 specifier: lines change, 114 version: lines change, 0 other lines change.

⇒ every declared range stays exactly where it was; only resolutions move.

4. ⛔ Leg 3 FIRES — all eight identity-sensitive packages are hit

Triage named react-router / react-router-dom, the five @radix-ui/react-{slot,primitive,id,compose-refs,use-layout-effect} and motion as the cases where two copies collapsing into one can change behaviour, not just bytes. Every one of them collapses here. Naming them explicitly, with who held the copy that disappears:

package copy that disappears collapses onto who held the disappearing copy on base
react-router 7.18.0 7.18.2 react-router-dom@7.18.0 only
react-router-dom 7.18.0 7.18.2 reached from the packages/plugin-designer importer
@radix-ui/react-slot 1.3.0 1.3.3 @radix-ui/react-primitive@2.1.6
@radix-ui/react-primitive 2.1.6 2.1.10 cmdk@1.1.1
@radix-ui/react-id 1.1.2 1.1.4 cmdk@1.1.1
@radix-ui/react-compose-refs 1.1.3 1.1.5 @radix-ui/react-slot@1.3.0, cmdk@1.1.1
@radix-ui/react-use-layout-effect 1.1.2 1.1.4 @radix-ui/react-id@1.1.2
motion 13.1.1 13.2.0 fumadocs-ui@16.15.4 (the docs site)

Two readings that bear on the decision:

  1. This is not two copies of one version merging — it is the older of two versions being dropped and its consumers moving up to the newer copy that is already in the tree. Bytes and behaviour both move for those consumers, within their declared ranges.
  2. Nothing in the workspace asked for the old copies. Every workspace declaration already names the surviving side or a range that accepts it: react-router-dom is declared ^7.18.2 by six manifests (and ^6.0.0 || ^7.0.0 as a peer by four), @radix-ui/react-slot ^1.3.3, motion ^13.2.0. The disappearing copies come from third-party subtrees — cmdk@1.1.1 for the five radix primitives, fumadocs-ui for motion, and the plugin-designer peer range for react-router-dom@7.18.0, which does not satisfy the repo's own ^7.18.2.

⇒ answering the card's question 1: accretion, not intent. No declaration, range or override selects any dropped copy; all 47 are strictly older siblings left behind by partial re-resolves. ⛔ Ruling the merge is not this seat's to make — that is the maintainer's, which is why this PR is draft.

5. Bundle Analysis delta, in isolation

Both sides measured on this box, identical procedure, the only difference being the lockfile: clean install, pnpm turbo run build --filter='./packages/*' --concurrency=2 (--force on the second run, 0 cached), pnpm --filter @object-ui/console build, then node scripts/check-eager-closure-budget.mjs.

Entry chunk (the 350 KB gzip line):

base    index-Dnp7foTL.js   raw 577,724   gzip 147,820 bytes  (144.4 KB)
dedupe  index-D052_d15.js   raw 577,659   gzip 147,804 bytes  (144.4 KB)
delta                       raw     -65   gzip      -16 bytes

Eager closure:

base    3116.3 KB gzipped across 52 of 528 chunks (budget 3134.8 KB, headroom 18.5 KB)   exit 0
dedupe  3105.3 KB gzipped across 52 of 528 chunks (budget 3134.8 KB, headroom 29.5 KB)   exit 2
delta    -11.0 KB gzipped

Per eagerly-loaded chunk, gzipped:

vendor-objectstack   1211.2 -> 1211.2 KB    0.0
ui-components         386.1 ->  387.8 KB   +1.7   <-- grew
vendor-markdown       162.0 ->  159.9 KB   -2.1
vendor-react           80.2 ->   70.9 KB   -9.3   <-- react-router collapsing
vendor-radix           59.0 ->   58.0 KB   -1.0
plugin-chatbot / src / vendor-charts / data-adapter / plugins-views / i18n   unchanged

⚠️ The gate goes RED, and not for a reason this PR may fix

check:eager-closure exits 2 on the deduped tree. The aggregate closure is better and every per-chunk ceiling still passes; what reds is the ceiling-sensitivity leg on ui-components:

base    ui-components  386.1 KB / 389.6 KB ceiling   headroom 3.5 KB (0.04x)   pass
dedupe  ui-components  387.8 KB / 389.6 KB ceiling   headroom 1.9 KB (0.02x)   EXHAUSTED
                       (declared allowance 4,289 bytes; the row reds below 3,378 bytes of headroom)

That row was already declared exhausted before this change — its pinned allowance exists precisely because it sits under the 0.10x floor — and the checker's own verdict text says the two edits that would turn it green are both forbidden: ⛔ never raise the ceiling, ⛔ never raise the allowance. Neither is done here. Paying the row down is the open decision the checker points at, objectui#7848 (open, pm:blocked, domain:devx); it is taken there and not folded into this diff, per the checker's own instruction.

On attribution, stated honestly in both directions: the checker warns that this row drifts under unrelated traffic and that the named amount is very likely not the author's. Here the two builds differ only by the lockfile, so the +1.7 KB is this change's. The likely mechanism is not a shipped dependency — the ui-components chunk rule matches packages/(components|fields) workspace source only, and that source is byte-identical across the two builds. What did move is the bundler that emitted those dists: vite 8.2.1 -> 8.2.2, rolldown 1.2.3 -> 1.2.7 and its 13 platform bindings all collapse in this dedupe, so the workspace dists are re-emitted by a different codegen. ⚠️ That is an inference from the chunk rule plus the input set, not something this PR pins byte-by-byte.

6. The full list — 45 names whose copy count collapsed

@babel/parser: 2 -> 1 | dropped 7.29.7 | kept 7.29.8
@babel/types: 2 -> 1 | dropped 7.29.7 | kept 7.29.8
@oxc-project/types: 2 -> 1 | dropped 0.143.0 | kept 0.148.0
@radix-ui/react-compose-refs: 2 -> 1 | dropped 1.1.3 | kept 1.1.5
@radix-ui/react-id: 2 -> 1 | dropped 1.1.2 | kept 1.1.4
@radix-ui/react-primitive: 2 -> 1 | dropped 2.1.6 | kept 2.1.10
@radix-ui/react-slot: 2 -> 1 | dropped 1.3.0 | kept 1.3.3            [workspace-declared]
@radix-ui/react-use-layout-effect: 2 -> 1 | dropped 1.1.2 | kept 1.1.4
@rolldown/binding-android-arm64: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-darwin-arm64: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-darwin-x64: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-freebsd-x64: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-linux-arm-gnueabihf: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-linux-arm64-gnu: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-linux-arm64-musl: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-linux-ppc64-gnu: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-linux-s390x-gnu: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-linux-x64-gnu: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-linux-x64-musl: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-openharmony-arm64: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-win32-arm64-msvc: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@rolldown/binding-win32-x64-msvc: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
@types/d3-shape: 2 -> 1 | dropped 3.1.8 | kept 3.2.0
@types/hast: 2 -> 1 | dropped 3.0.4 | kept 3.0.5
@ungap/structured-clone: 3 -> 1 | dropped 1.3.2, 1.3.3 | kept 1.4.0
baseline-browser-mapping: 2 -> 1 | dropped 2.10.43 | kept 2.11.21
browserslist: 2 -> 1 | dropped 4.28.6 | kept 4.28.8
caniuse-lite: 2 -> 1 | dropped 1.0.30001806 | kept 1.0.30001810
dayjs: 2 -> 1 | dropped 1.11.21 | kept 1.11.23
electron-to-chromium: 2 -> 1 | dropped 1.5.393 | kept 1.5.408
es-toolkit: 2 -> 1 | dropped 1.50.0 | kept 1.52.0
flatted: 2 -> 1 | dropped 3.4.2 | kept 3.4.4
lucide-react: 2 -> 1 | dropped 1.31.0 | kept 1.35.0                  [workspace-declared]
markdown-it: 2 -> 1 | dropped 14.2.0 | kept 14.3.0
mermaid: 2 -> 1 | dropped 11.16.0 | kept 11.17.2                     [workspace-declared]
minimatch: 5 -> 4 | dropped 10.2.5 | kept 10.2.3, 10.2.6, 3.1.5, 5.1.9
motion: 2 -> 1 | dropped 13.1.1 | kept 13.2.0                        [workspace-declared] [identity-sensitive]
node-releases: 2 -> 1 | dropped 2.0.51 | kept 2.0.53
picomatch: 3 -> 2 | dropped 4.0.5 | kept 2.3.2, 4.0.7
react-router: 2 -> 1 | dropped 7.18.0 | kept 7.18.2                  [identity-sensitive]
react-router-dom: 2 -> 1 | dropped 7.18.0 | kept 7.18.2              [workspace-declared] [identity-sensitive]
rolldown: 2 -> 1 | dropped 1.2.3 | kept 1.2.7
tinyexec: 4 -> 2 | dropped 1.2.4, 1.3.0 | kept 0.3.2, 1.3.1
update-browserslist-db: 2 -> 1 | dropped 1.2.3 | kept 1.3.1
vite: 2 -> 1 | dropped 8.2.1 | kept 8.2.2                            [workspace-declared]

The five @radix-ui/* rows above are also identity-sensitive; they are marked in the table in section 4 rather than repeated here.

Workspace importers whose direct resolution moves: vite 8.2.1 -> 8.2.2 (23 importers, all devDependencies except packages/cli), lucide-react 1.31.0 -> 1.35.0 (18 importers), react-router-dom 7.18.0 -> 7.18.2 (packages/plugin-designer). Everything else is transitive.

Scope

⛔ No gate or report that tracks the duplicate count over time is built here — triage ruled that a different card, to be filed once this lands if it still matters. The existing check-lockfile-integrity gate is correct as designed (objectui#8326 watches for copies being gained) and is untouched.

Gates run locally

node scripts/check-lockfile-integrity.mjs --base-ref d7f0601ee7…   exit 0   VERDICT clean
node scripts/check-changeset-presence.mjs                          exit 0   1 changeset added, none owed
node scripts/check-changeset-no-major.mjs                          exit 0
node scripts/check-changeset-overwrite.mjs                         exit 0
node scripts/check-changeset-claims.mjs                            exit 0
node scripts/check-changeset-fixed.mjs                             exit 0
node scripts/check-control-bytes.mjs                               exit 0
grep -naP control-byte scan of both changed files                  no hits
node scripts/check-eager-closure-budget.mjs                         exit 2   see section 5

Exit codes captured before any pipe.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr


Generated by Claude Code

`pnpm dedupe --lockfile-only` on `d7f0601ee7` with nothing else edited.
Pure collapse of pre-existing duplicate resolutions: 1822 -> 1773 snapshot
keys, 1814 -> 1767 identities, 47 removed and 0 added. Every dropped copy
has a strictly higher surviving sibling of the same name, so no consumer
moves backward.

Zero declarations, zero ranges and zero overrides move: the lockfile's
`overrides`, `settings` and `patchedDependencies` sections are byte-identical,
and 0 of the `importers:` `specifier:` lines change (114 `version:` lines do).
No `package.json` and no `pnpm-workspace.yaml` is touched.

The measurement, the identity-sensitive collapses and the `Bundle Analysis`
delta are reported on the pull request. Lockfile only; empty-frontmatter
changeset declares no release.

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

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

ACCEPT the measurement — and ⛔ LEG 3 FIRED, so the merge is the maintainer's ruling, not this seat's. PR stays DRAFT.

domain:devx @ objectui PM seat (session_01FhBNJcLRZLe8M87VcUgpKr), R55, 2026-09-12T21:3xZ. Head ed8e1b1be4, base d7f0601ee7. Two files: pnpm-lock.yaml (+186 / −761) and one empty-frontmatter changeset. ⛔ Nothing else in the diff — which was the whole point of B-before-A.

Verified by this seat against the diff, ⛔ not taken from the report

claim re-derivation
dedicated PR, lockfile only ✅ 2 files; 0 package.json changed
identities 1814 → 1767 ✅ resolution: lines 1814 → 1767
⭐ the identity-sensitive copies really disappear ✅ react-router@7.18.0 2 → 0, react-router-dom@7.18.0 2 → 0, motion@13.1.1 2 → 0

⭐ Every figure the card predicted reproduced exactly on today's tip, with nothing to explain — and the committed lockfile's sha256 is still e4e3a203f2…, byte-identical to the card's reading four days ago, so the base itself never moved. ⚠️ That is a lucky outcome, not a reason to have skipped the re-derivation: triage said explicitly it had re-run none of it and that the registry had moved under objectui#8333's numbers in four days.

⭐ The control ran both directions in the same run: pnpm install --lockfile-only on the unmodified base reproduces the committed lockfile byte-identically (matching sha256, empty git diff --stat), and a strict one-byte append (701203 → 701204) makes the same command print 1 file changed. ⇒ the empty reading is a measurement, ⛔ not a broken command. Restore proven by sha256 and an empty diff, ⛔ never by an exit code.

⛔⛔ LEG 3 FIRES — all eight identity-sensitive packages are hit

react-router 7.18.0→7.18.2 · react-router-dom 7.18.0→7.18.2 · the five @radix-ui/react-{slot,primitive,id,compose-refs,use-layout-effect} · motion 13.1.1→13.2.0.

⚠️ And the round found a sharper point than triage's fence anticipated: the collapse is the OLDER of two versions being dropped and its consumers moving up — ⛔ not two copies of one version merging. ⇒ for those consumers, behaviour moves as well as bytes, within range.

⇒ per triage's ruling — 「⇒ that reading is what turns this into a maintainer decision, and it belongs on the PR with numbers attached, ⛔ not asked in advance without them」 — this seat ⛔ does not flip ready, ⛔ does not enqueue, ⛔ does not arm auto-merge, and ⛔ does not rule the merge. Routed to the maintainer with the numbers attached, which is exactly the shape triage asked for.

⭐ The card's question 1 is ANSWERED: accretion, not intent

⛔ No declaration, range or override selects any dropped copy — verified structurally: overrides:, settings: and patchedDependencies: are byte-identical by sha256; inside importers: (2834 lines both sides) 0 specifier: lines change, 114 version: lines change, 0 other lines change. The dropped copies come from third-party subtrees — cmdk@1.1.1 for the radix five, fumadocs-ui for motion, react-router-dom@7.18.0 for react-router — plus packages/plugin-designer's peer range, which does not even satisfy the repo's own ^7.18.2. And all 47 dropped versions have a strictly higher surviving sibling, so ⛔ nothing can move backward. check-lockfile-integrity agrees: VERDICT clean — no @objectstack/* identity moved backward and no package gained a copy.

⚠️ Bundle Analysis will be RED, and the red is NOT fixable in this PR

The aggregate improves: eager closure 3116.3 → 3105.3 KB (−11.0 KB), every per-chunk ceiling passes, entry chunk −65 raw / −16 gzip. Per chunk: vendor-react −9.3 KB (react-router collapsing), vendor-markdown −2.1, vendor-radix −1.0, ui-components +1.7.

What reds is the ceiling-sensitivity leg on ui-components: headroom 3.5 KB (0.04×) → 1.9 KB (0.02×, EXHAUSTED) against a declared 4289-byte allowance that reds below 3378. ⭐ The checker's own verdict text forbids both edits that would turn it green — ⛔ never raise the ceiling, ⛔ never raise the allowance — and says to take the row to its open card. ⇒ reported on objectui#7848 (open, pm:blocked, domain:devx) as a measured data point, ⛔ nothing raised, ⛔ nothing re-pinned, ⛔ no new card filed because that row already has an owner.

⚠️ The round's own inference, stated as an inference: the ui-components chunk rule matches packages/(components|fields) source only, that source is byte-identical across both builds, so what moved is the bundler emitting their dists (vite 8.2.1→8.2.2 and rolldown 1.2.3→1.2.7 both collapse here). ⛔ Not pinned byte-by-byte, and labelled as such.

⇒ Bundle Analysis is a required context, so this PR cannot land on this seat's authority regardless of leg 3. Both questions go up together.

⭐ Two things this round refused, both correctly

  1. ⛔ No gate tracking the duplicate count over time — triage fenced it as a different card, 「the count is not decidable as a threshold until this PR shows what the healthy number is」. ⭐ This run now shows it: 1773 resolutions / 1767 identities, with 45 names still carrying more than one copy after dedupe (minimatch ×4, picomatch ×2, tinyexec ×2 among them) ⇒ a future threshold card now has a baseline, which is precisely what the fence was waiting for.
  2. ⛔ No new card for the ui-components red — objectui#7848 already owns that number, named by the checker itself.

⇒ What this seat is doing, and what it is not

⛔ Not merging. ⛔ Not flipping ready. ⛔ Not arming. ⭐ Labelling needs-user-decision, requesting review, and handing the maintainer two rulings with numbers attached — the merge (leg 3), and how the Bundle Analysis red is handled at merge time. objectui#8333 stays pm:blocked on this card until the first is answered.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Console Performance Budget — gauge not trustworthy

The eager closure was measured, but one of the ceilings it is measured against no longer means what it names, so this run carries no pass/fail verdict for the performance budget.

This is not a budget violation. Nothing grew: the half marked below is a verdict about the gauge, and a ceiling that has stopped measuring anything can neither clear a bundle nor condemn one.

Step Outcome
Build packages success
Check console performance budget failure

Which half objected:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ✅ pass
Ceiling sensitivity (headroom) ⚠️ broken gauge
Ceiling freshness (checkout vs. base branch) ✅ pass

⚠️ A broken gauge half is a verdict about the ceiling, not about the bundle: that line has drifted out of range of the regression it exists to catch, or the report behind it cannot be trusted. It does not say anything grew. The Check console performance budget step log carries the ceiling and the number it was compared against.

Reason: The entry chunk measured 144.3 KB, but the eager-closure half of this gate returned no trustworthy VERDICT: the report could not be read, a ceiling has drifted out of range of the regression it must catch, or (objectui#6245) a ceiling was replaced on the base branch after this checkout was made. The step log says which. This is not a passing budget — and it is not a size regression either.

See the workflow run for details.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 502.04KB 115.16KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 211.58KB 58.68KB
fields (index.js) 247.88KB 62.51KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.21KB 2.26KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.02KB 13.93KB
plugin-charts (index.js) 71.51KB 19.97KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.22KB 34.59KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.48KB 65.86KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.77KB 34.17KB
plugin-gantt (index.js) 166.94KB 41.03KB
plugin-grid (index.js) 211.59KB 57.48KB
plugin-kanban (index.js) 45.99KB 14.29KB
plugin-list (index.js) 112.58KB 27.65KB
plugin-map (index.js) 20.43KB 6.81KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.79KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 94.03KB 31.02KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang marked this pull request as ready for review September 13, 2026 06:13
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 69aa9c0 Sep 13, 2026
38 of 39 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9215-lockfile-dedupe-measurement branch September 13, 2026 06:32
os-support-ai pushed a commit that referenced this pull request Sep 25, 2026
Brings the branch up to main at 21d34d5 (294 commits since the merge
base 078f2e4). One textual conflict.

packages/plugin-kanban/package.json
  Union. main deleted `@tanstack/react-virtual` from `dependencies` along
  with the module that used it (objectui#8932); this branch bumps
  `lucide-react`. Both kept.

pnpm-lock.yaml
  Regenerated by pnpm from main's lockfile, not hand-merged: main's file
  taken whole, then `pnpm install --lockfile-only` with the 22 manifests
  briefly at an exact `1.43.0` and again with the caret restored, which
  pnpm keeps. A plain re-lock follows `^1.43.0` to the newest 1.x (1.48.0
  today), outside what this branch measures. The delta against main is
  lucide-react alone: 22 importer specifiers `^1.31.0` to `^1.43.0`,
  22 resolutions and the fumadocs peer suffixes 1.35.0 to 1.43.0.

  Note main's resolution was already 1.35.0, not 1.31.0, since the
  lockfile dedupe of #9316; the declared range is what said 1.31.0.

  `node scripts/check-lockfile-integrity.mjs --base <main's lockfile>
  --head pnpm-lock.yaml` reads `VERDICT clean` (1773 resolutions on both
  sides).

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
os-support-ai pushed a commit that referenced this pull request Sep 25, 2026
…s tree

The entry was written before objectui#9251 landed and before main's
lockfile moved to 1.35.0. Three of its claims stopped being true: the
double lucide install it credited to this bump was already collapsed by
the #9316 dedupe; its class note called the per-alias classes purely
additive, when 1.43.0 also drops the key-derived class on component
imports; and it said nothing about string-resolved icons, which under
ruling C keep the seam's classes and do not get lucide's alias classes.

It now declares every released package whose `lucide-react` range moves
(20 in `dependencies`, the console in `devDependencies`) plus `cli` and
`types`, whose source changed, all `patch`. It names the invisible-glyph
repair (`__iconData`) as a user-visible fix, and dates the `Trash2` class
example in the pending objectui#9251 entry, without editing that entry.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
…urce` expression root (objectstack-ai#9378)

Fixes objectstack-ai#9370

⛔ **GOVERNED SURFACE** (`skills/**`, `GOVERNED_SURFACES`
`skills-catalog`). This PR parks as a **draft by design** and needs an
APPROVED review from an authorized approver (`os-zhuang`, `hotlong`). ⛔
Not flipped ready, not enqueued, no auto-merge. The precedent on this
exact surface is objectstack-ai#9352 / card objectstack-ai#9311, merged as `28be0786d`.

⚠️ **Merge after objectstack-ai#9369, and this PR states which world it measured in.**
Re-derived from my own tree at base `69aa9c017`, not assumed:
`SchemaRenderer.tsx` still carries the `data: dataSource` binding, and
`SchemaRendererContext.tsx` still reads `const dataSource =
context?.dataSource` inside `useDataScope`. **Both halves are still LIVE
on this base** — objectstack-ai#9369 is open, not merged. So these pages become true
the moment objectstack-ai#9369 lands and are ahead of the code until then. Every
evaluator measurement below is from that same pre-objectstack-ai#9369 tree, which is
the right place to take it: the evaluator itself is what objectstack-ai#9369 stops
feeding, and the evaluator's own behaviour does not move. Sequencing is
a maintainer decision, not something this PR can enforce.

## What moved

The maintainer ruled objectui#9308 option B on 2026-09-13:
`SchemaRenderer` stops publishing the injected `DataSource` adapter as
the expression root `data` (b1), and `useDataScope` — what a node's
`bind` resolves through — reads the ambient scope a host publishes via
`PredicateScopeProvider` instead of walking that adapter (b2). The
published skill package still taught both halves.

Three files, matching the shape objectstack-ai#9369 used for
`content/docs/guide/schema-rendering.md` and `packages/react/README.md`:
publish the host values under real names through
`PredicateScopeProvider`, read them by those names, and state that
`dataSource` is the **adapter** and not a root.

| file | what changed |
|---|---|
| `guides/schema-expressions.md` | the scope table, a new COMPILED
wiring example, the migration callout, the `bind` resolution prose, the
bound-list comment, debugging-checklist item 3 |
| `guides/data-integration.md` | the architecture diagram's channel
split, "Static data" rewritten onto the scope channel as a COMPILED
example, the `useDataScope` resolution sentence, the `data` root claim |
| `rules/protocol.md` | both measured tables' WIRING restated, the
`bind` comment and nested-path sentence, plus a callout carrying the
retirement and pointing at the verdict flip |

⛔ Untouched, as the card requires: the `object-*` reader-list paragraph
and the `data-table` `bind` pothole (objectui#6575 still rules
`data-table` does not read `bind`).

## ⭐ The part that is not a renaming

A migration note that only swaps provider names is wrong here, because
the **verdict moved**. Measured by me on the built evaluator
(`packages/core/dist`), with firing controls — ⛔ and the `true` on a
missing root belongs to the **predicate layer**, not to the evaluator:

| scope, expression | `evaluateExpression` | `evaluateCondition` |
|---|---|---|
| CONTROL `{ data: { status: 'draft' } }`, `${data.status}` | `"draft"`
| `true` |
| CONTROL same scope, `Status: ${data.status}` | `"Status: draft"` |
`true` |
| CONTROL same scope, `${data.nope}` — present root, absent member |
`undefined` | `false` |
| `{ data: {} }` — adapter-shaped, `${data.status == 'draft'}` | `false`
| `false` |
| `{ data: undefined }`, same predicate | `false` | `false` |
| `{}` — no `data` root, `${data.status}` | `"${data.status}"` | `true`
|
| `{}` — no `data` root, `Status: ${data.status}` | `"Status:
${data.status}"` | `true` |
| `{}` — no `data` root, `${data.status == 'draft'}` | `"${data.status
== 'draft'}"` | `true` |

The third control is the discriminating one: a root that is PRESENT with
an absent member yields `undefined`, while a root that is MISSING yields
the template's own source characters. So the two layers answer a missing
root differently, and each has its own reader-visible symptom:

- **predicate layer** — fails soft to `true`. A `"visible":
"${data.status == 'draft'}"` authored from these pages was **hidden on
every row** and is now **shown on every row**; spelled `"hidden"` it
flips the other way.
- **interpolation layer** — does not fail soft at all. A `content` built
from a missing root paints the literal characters `${data.status}` on
screen.

The callout carries one column per layer and tells the reader to read
both. It also states that re-publishing `data` restores the OLD
always-`false` verdict rather than fixing the gate, and points row gates
at `record` — the runtime-layer root under ADR-0089 D3.

## What I decided about the ~two dozen `${data.…}` examples

Measured, not estimated: `schema-expressions.md` carries **21** lines
spelling `${data.` (one of them is the census's own scope-table row).
Repo-wide under `skills/`: 21 here, 14 in `rules/protocol.md`, 5 in
`page-builder.md`, 4 in `testing.md`, 2 each in `data-integration.md`
and `auth-permissions.md`, 1 in `architecture.md`, plus 4 in
`evals/*.json`.

**Decision: keep every one of them verbatim and make them reachable,
rather than rewrite them to bare roots.** The page's new wiring example
publishes a root literally named `data`, and the scope table says in a
row that every `${data.*}` example on the page assumes exactly that.
Three reasons this is the right divergence from objectstack-ai#9369's `content/docs`
shape:

1. **A pin renders those exact spellings.**
`skill-guide-provider-envelope.test.tsx`, as re-derived in objectstack-ai#9369,
publishes `scope = { data: PROVIDER }` and renders `${data.customers}` /
`${data.label}`. Rewriting the guide spellings would move that pin's
subject from inside a second pull request — the "green alone, red
together" shape.
2. **ADR-0089 D3 does not forbid the name.** `CANONICAL_ROOT_BY_LAYER`
puts `data` at the metadata layer. What was retired is the renderer
AUTO-publishing the adapter there, not the name.
3. **Most of those examples are about something else** — which text keys
carry expressions, type preservation, the troubleshooting section. The
root name is scaffolding, and churning 20 lines of scaffolding on a
governed surface buys the reader nothing.

⛔ Nothing was silently rewritten: the only expression text this PR
changes is the two `dataSource = {…}` comments that named the retired
wiring.

## Re-measurement of the card's per-line census

The card measured on the objectui#9308 branch. Re-measured against
`origin/main` `69aa9c017` — **every cited line still lands on the cited
text**:

| cited | on `origin/main` `69aa9c017` | verdict |
|---|---|---|
| 112 | `| Top-level data fields | \`SchemaRendererProvider dataSource\`
| …` | exact |
| 113 | `| \`data\` | Alias for dataSource root | …` | exact |
| 302-303 | `dataSource = { customerNames: … }`,
`useDataScope("customerNames")` | exact |
| 305 | `**Nested paths work:** … resolves
\`dataSource.app.settings.users\`` | exact |
| 393 | `// ✅ Bound data, already node-shaped: dataSource = { rows: … }`
| exact |

`premise_still_valid: true`.

## Enumeration beyond the census (⛔ out of scope for this PR)

`--measure` judges every candidate fence, marked or not, so a page can
be wrong where no gate looks. Reading the prose as well turned up two
more:

- **`guides/auth-permissions.md`** carries the same retired claim in its
own words — a scope table row `| \`data\` | the \`dataSource\` passed to
\`SchemaRendererProvider\` |`, the sentence "Keys of the `dataSource`
object are reachable only under the `data.` root", and "With no host
scope mounted, `data` and `page` are all you get". ⛔ Out of scope here:
**PR objectstack-ai#9374 holds that file**, so an in-place edit would collide. Filed
as objectstack-ai#9379.
- **`guides/testing.md` Pattern 5** asserts `getByText('Secret')` for a
node gated on `${userRole !== "admin"}` — a BARE root that no channel
publishes, before or after the ruling. Copy the example and the
assertion fails. Different defect class from this card, and independent
of the ruling — it is wrong on `main` today. Filed as objectstack-ai#9380.

Noted, not filed: the `${data.*}` examples in `page-builder.md`,
`architecture.md`, `i18n.md`, `project-setup.md` and `evals/*.json` are
in the same reachable-only-if-the-host-publishes class; they are not
falsifiably wrong and the skills lane that takes the auth-permissions
card is their natural carrier.

## Verification

Reproduce-first, as required — the harness is trustworthy before any red
or green from it is read:

```text
$ node scripts/check-skill-examples.mjs --self-test     # BEFORE the build
EXIT=2 — PRECONDITION NOT MET: the self-test's type-check leg needs the workspace built

$ ...os-verify-lock.sh -c 'turbo run build $(--build-filter) --concurrency=2'
VERDICT command-exit 0 · held the lock 44s · waited 0s

$ node scripts/check-skill-examples.mjs --self-test     # AFTER the build
EXIT=0 — ✓ 59 cases pass
```

Gate, verbatim, before and after this diff:

```text
BEFORE (exit 0)
Scanned 20 guide(s) under skills, .claude/skills: 121 ts/tsx/typescript fence(s), 70 json/jsonc fence(s).
Marked: 14 ts fence(s) (floor 13), 70 json fence(s) (floor 70)
Semantic phase: 14 of 14 ts fence(s) judged, 0 failed.
JSON phase:     70 fence(s) parsed, 0 failed.
Every marked skill example holds up against the built types.

AFTER (exit 0)
Scanned 20 guide(s) under skills, .claude/skills: 122 ts/tsx/typescript fence(s), 70 json/jsonc fence(s).
Marked: 16 ts fence(s) (floor 13), 70 json fence(s) (floor 70)
Semantic phase: 16 of 16 ts fence(s) judged, 0 failed.
JSON phase:     70 fence(s) parsed, 0 failed.
Every marked skill example holds up against the built types.
```

⭐ The marked ts population moves **14 to 16** and the json population is
unmoved at 70 — both floors are SHRINK-ONLY and neither is breached. The
two new marked fences are the wiring examples, so the thing this card is
about is now COMPILED against the built `dist/*.d.ts` rather than only
read.

Derived by hand from `package.json` plus `.github/workflows/` (this repo
has no `dispatch-gates.mjs`); every one run on this tree:

| gate | exit |
|---|---|
| `check-skill-examples.mjs --self-test` | 0 |
| `check-skill-examples.mjs` | 0 |
| `check-skills-paths.mjs` | 0 — 88/89 stated paths resolve, 1
pre-existing baselined |
| `check-skill-eval-tokens.mjs --self-test` /
`check-skill-eval-tokens.mjs` | 0 / 0 |
| `check-control-bytes.mjs` | 0 — 7539 tracked text files |
| `check-shell-escape-residue.mjs` | 0 — 16/16 skills documents under a
declared root |
| `check-new-cross-file-line-citations.mjs` | 0 — 0 new citations |
| `check-changeset-presence.mjs` | 0 |
| `check-doc-links.mjs` | 0 |
| `check-governed-queue-guard.mjs --self-test` | 0 — 185 cases |

Package tests that READ these three documents (`markdown-test-inputs.mjs
--changed` names all three as test inputs, so CI runs the full shards):
reported below the fold once the shared verify lock grants a turn.

Line readings for the governed surface, as the contract requires:

| reading | before | after | delta |
|---|---|---|---|
| `guides/schema-expressions.md` | 569 | 635 | +66 |
| `guides/data-integration.md` | 485 | 513 | +28 |
| `rules/protocol.md` | 341 | 354 | +13 |
| whole published package `skills/**` | 5201 | 5308 | +107 (+2.1%) |

The added lines are the correction itself: one migration callout, two
now-compiled wiring examples, and the sentences that replace the retired
claims. No re-wrap was used to buy lines.

## Changeset

**None owed**, by measurement rather than by inheritance:

1. `node scripts/check-changeset-presence.mjs` — "Compared the working
tree with `69aa9c017` (merge-base with origin/main): 3 file(s) changed,
0 of them published source of a package the release covers, 0 of them a
manifest whose published contract moved … No source or published
contract of a released package changed in this range, so no changeset is
owed." (exit 0)
2. Independently: **0 of 43** workspace manifests mention `skills` in
`files` / `exports` / `main` / `module` / `types` / `bin`. Positive
control on the same scan: 38 manifests list `dist` in `files[]`. Nothing
under `skills/` is shipped by any npm package.
3. The merged precedent on this surface, `28be0786d` (objectstack-ai#9352), changed
one `skills/` file and carried no changeset.

⚠️ One inconsistency worth a maintainer's eye rather than my guess: open
PR objectstack-ai#9374, also a single `skills/` guide, DOES carry an empty-frontmatter
changeset declaring "no package is released by this change". Both forms
pass the gate. If the empty-frontmatter declaration is the house style
for this surface, say so and I will add one.

## Governed-queue-guard verdict on this diff (verbatim)

```text
$ node scripts/check-governed-queue-guard.mjs --test skills/objectui/guides/schema-expressions.md skills/objectui/guides/data-integration.md skills/objectui/rules/protocol.md
⛔ GOVERNED — 3 of 3 path(s) are on a governed surface:
   skills/** x3 — the published skills catalog
     - skills/objectui/guides/schema-expressions.md
     - skills/objectui/guides/data-integration.md
     - skills/objectui/rules/protocol.md

   One governed path governs the WHOLE pull request — proportion is not a question.
   ⛔ Do not flip it ready, enqueue it, or arm auto-merge. Park it as a DRAFT and leave the merge
      to the maintainer; a human merge IS the review record for a governed surface.
   The merge-queue run of "Governed Surface Queue Guard" refuses this diff unless an APPROVED review by an
   authorized approver (GOVERNED_APPROVERS: os-zhuang, hotlong) is on the pull request — on
   whichever commit it was left (maintainer ruling 2026-09-04).
exit 3
```

## Clause-② — contract review

`needs:contract-review` is hung on this PR in the same stroke as opening
it, mirroring the card. Published `skills/**` making falsifiable
contract-semantics claims about which roots a tier binds. ⛔ The
governed-surface human merge does **not** substitute for it; the two
stack, and neither limb is mine to clear.

## Inherited reds — ⛔ not from this PR

- `Bundle Analysis` is red on `main` (arrived with objectstack-ai#9316; a maintainer
decision).
- `Doc Snippet Type Check` is red on `main` until objectstack-ai#9369 lands. This diff
touches no document that gate scans (`content/docs` + package READMEs +
root `README.md`, explicitly not `skills/**`).

## Acceptance notes

- ⛔ No test was skipped, quarantined or weakened. No assertion was
loosened. The marked-fence floors both hold and the ts population grew.
- The card's "the pins are green while the prose beside them is wrong"
warning was taken literally: the six `packages/components` pins objectstack-ai#9369
re-derived were read for what they assert about these files before a
byte was changed, and every string they pin is intact — `Rule: Keys Live
on the Node`, the `hoists every key onto\s+the node` regex, the absence
of the two retired sentences, the `no md matches "instead of
\`props.\`"` class guard, and the `"bind": "customerNames"` list example
in each of the three guides.
- Noted, not filed: `rules/protocol.md`'s two measured tables still cite
`origin/main` `f1c27f037` as the commit they were measured on. This PR
restates their WIRING, not their outcomes, and says so inline; a
re-measurement on a current commit is a separate piece of work, and the
`skill-guide-provider-envelope` pin is what actually holds those
outcomes today. Carrier: whoever next re-derives that pin.

## 维护者速读(草稿)

### 改了什么
把发布中的 `skills/objectui` 三份指南从「`dataSource` 就是表达式根 `data`、`bind` 走
`dataSource`」改成 ruling 之后的真实通道:宿主用 `PredicateScopeProvider`
发布作用域,`dataSource` 只是取数适配器。另外给两个接线示例加上 `os:check` 标记,让它们第一次真的被编译校验。

### 为什么改
2026-09-13 的裁决(objectui#9308 option B)已经把这两件事退役,但发布给 AI
读的技能包还在教它们。更要紧的是:这不是改名。同一条 `data.*` 门,旧接线下恒判 `false`、新接线下因为根缺失走
fail-soft 恒判 `true` —— 一个 `visible` 门从「每行都藏」变成「每行都显」。只换 provider
名字的迁移说明会让读者踩正这一脚。

### 风险与代价(含回滚)
- **排序风险(主要):** objectstack-ai#9369 还没合。在它合进去之前,这三页描述的是尚未落地的行为。⚠️ 建议排在 objectstack-ai#9369 之后合。
- 保留了全部 `${data.*}` 示例原文,只让接线示例发布一个叫 `data` 的根 —— 因为
`skill-guide-provider-envelope` 这个 pin 正在渲染这些拼写,改写它们会在另一个 PR 的盲区里挪动 pin
的被测主体。
- 回滚代价:纯文档,`git revert` 一笔即可,不影响任何已发布包(本仓 43 个 manifest 无一发布 `skills/`)。

### 席位意见
(留空,待 `os-zhuang` / `hotlong` 定稿)

### 你要做的
1. 确认合并顺序:先 objectstack-ai#9369,再本 PR。
2. 作为受管面的人工合并方给出 APPROVED review(这一道是 `check-governed-queue-guard`
机械要求的)。
3. clause-② 的 `needs:contract-review` 需要一份书面复核记录后才清 —— ⛔
两道保障叠加,人工合并不替代它。
4. 顺带定一句话:本仓单文件 `skills/` 改动到底要不要写空 frontmatter changeset(objectstack-ai#9352 没写、objectstack-ai#9374
写了,门禁两边都放行)。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ)_


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: os-zhuang <jack@objectstack.ai>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
…ling repaired (objectstack-ai#8941)

> ⚠️ **GOVERNED — this PR stays a draft until an authorized approval is
on it.** `node scripts/check-governed-queue-guard.mjs --test` over this
PR's file list answers `GOVERNED — 1 of 42 path(s)`:
`skills/objectui/guides/plugin-development.md`, the published skills
catalog. One governed path governs the whole PR. ⛔ Not to be flipped
ready, enqueued or armed with auto-merge until `os-zhuang` or `hotlong`
leaves an APPROVED review; ⛔ no seat approves it. After that approval
the claiming seat lands it.

Moves `lucide-react` from `^1.31.0` to `^1.43.0` across the workspace,
repairs what the jump breaks, and executes the maintainer's **ruling C**
on this PR (comment `5750512894`, verbatim 「8941 C」): relax the two
pins, land the bump. The re-measured census comes first, then ruling C,
then the checks. The sections under **Historical** further down were
measured on older trees and are ⛔ not re-derived.

objectui#7058 (the Dependabot branch this PR superseded) is already
closed, unmerged, since 2026-09-13. This PR never named it with a
closing keyword; closing it was never this PR's.

## 1. This round's census — re-measured on 2026-09-25 against the
INSTALLED 1.43.0

Tree: this branch after merging `main` at `21d34d5e2` (merge commit
`423a1a59a`); the censuses read the committed tree through `git ls-tree`
plus `git cat-file`, so enumeration and reading come from the same
source. The installed `lucide-react` is 1.43.0, integrity-equal to the
registry tarball (`sha512-ubtnda1f…`).

⚠️ **Correction to the baseline.** `main` does not install 1.31.0. Its
manifests say `^1.31.0`, but since the `pnpm dedupe` of objectstack-ai#9316
(2026-09-13) its lockfile resolves **1.35.0**, one copy. So this PR
moves the declared range `^1.31.0` to `^1.43.0` and the installed
version 1.35.0 to 1.43.0. Every comparison below is taken against both
1.31.0 and 1.35.0.

### 1a. What lucide itself changed (read off the three installed
artifacts)

| Surface | 1.31.0 | 1.35.0 (main today) | 1.43.0 | Removed, 1.35.0 to
1.43.0 |
|---|---|---|---|---|
| runtime exports of `lucide-react` | 6068 | 6110 | 6224 | **0** |
| public names in `lucide-react.d.ts` | 6072 | 6114 | 6230 | **0** |
| `dynamicIconImports` keys (= `iconNames`) | 2025 | 2039 | 2077 | **0**
|
| keys of the runtime `icons` record | 1767 | 1781 | 1818 | **1 —
`Trash2`** |
| icon modules exporting `__iconNode` | 1767 | 1781 | **0** | — |
| icon modules exporting `__iconData` | 0 | 0 | 1818 | — |
| icon modules declaring `aliases` | 0 | 0 | 248 | — |

1.31.0 to 1.43.0 also removes 0 exports, 0 type names, 0 dynamic names
and the same single record key.

### 1b. Census 1 — every name this tree imports from `lucide-react`
resolves

TypeScript-AST scan of every tracked code file: import and export-from
declarations (named, default, namespace, `type`), `import()` /
`vi.mock()` / `require.resolve()` calls naming the package, `import
type` nodes, and static member reads off a namespace import or off
`icons`.

| Tree | importing files | sites | distinct value names | type names |
fail on 1.43.0 | fail on 1.35.0 | fail on 1.31.0 |
|---|---|---|---|---|---|---|---|
| this branch after the merge | 413 | 1980 | 329 | 2 (`LucideIcon`,
`LucideProps`) | **0** | 0 | 0 |
| `6f20cfb06` (the 2026-09-20 head) | 419 | 2018 | 331 | 2 | 0 | 0 | 0 |

Controls, in the same run: an injected `Trash`, `type LucideIcon` and
namespace read `L.Pencil` resolve; an injected `NotARealLucideIcon` and
`L.AlsoNotReal` are both reported as failures.

### 1c. Census 2 — every authored icon STRING resolves as a key of the
runtime `icons` record

Every string in an icon-named position (`icon`, `iconName`, `...Icon`
properties, JSX attributes, `resolveIcon(...)` arguments) across code,
JSON, YAML and Markdown, tokenised with the seam's own rule copied
verbatim (`split(/[-_\s]+/)`, capitalise, `Home` to `House`), judged
against each version's record.

| Reading | Result |
|---|---|
| sites / distinct names | 717 / 207 |
| live on 1.43.0 | 641 |
| live on 1.35.0 or 1.31.0 but **dead on 1.43.0** (what the bump breaks)
| **0** outside this PR's own changeset prose, which names `trash-2` as
the spelling it retired |
| dead on all three versions | 75 sites, 40 distinct — pre-existing and
untouched by the bump: the deliberate retired-spelling negatives
(`edit`, `smile`, `filter`, `not-a-real-icon`,
`definitely-not-a-lucide-icon`…) and strings that are not icon names at
all (colour classes, labels) |
| `pnpm check:icon-record-names` | `OK lucide icon names: 184
authored/declared names reaching 1 record-reading resolver are live
icons keys (record 1818 keys ...)` |

Controls: an injected `icon: 'trash'` reads live, injected `icon:
'trash-2'` in JSON and in TS both read as regressions. Independently, a
case-insensitive `git grep` for every spelling that tokenises to
`Trash2` finds only component imports of `Trash2` (still a runtime
export in 1.43.0), comments, and one `vi.mock` stub key: no authored
string. The same census over `main` finds exactly 6 string sites that
tokenise to `Trash2` (5 `trash-2`, 1 `Trash2`, one per file); they are
the ones the 2026-09-20 round repaired, and all 6 read `trash` here.

**The glyph is unchanged by that repair**, measured on icon nodes:

| Comparison | full node | node without `key` |
|---|---|---|
| 1.43.0 `trash` vs 1.31.0 `trash-2` | IDENTICAL | IDENTICAL |
| 1.43.0 `trash` vs 1.35.0 `trash-2` | IDENTICAL | IDENTICAL |
| control: 1.43.0 `pencil` vs 1.31.0 / 1.35.0 `pencil` | IDENTICAL |
IDENTICAL |
| control: 1.43.0 `trash` vs 1.35.0 `trash` | DIFFERENT | DIFFERENT |

⚠️ The last control row is also a user-visible fact: lucide moved the
two-line bin artwork under the `trash` name, so anything that already
authored `trash` (10 string sites on `main`) now draws what `trash-2`
drew. Upstream also redrew other glyphs. Comparing the artwork each name
draws on 1.35.0 against 1.43.0 (controls: `Trash2` reads unchanged,
`Trash` reads changed), the ones this tree uses are the components
`CheckCircle2` (29 importing files), `Table2` (8), `CircleDot` (5),
`CircleCheck` (2), `Asterisk` (1) and `BadgeCheck` (1), and the strings
`trash`, `shopping-cart`, `circle-check` / `CircleCheck` and `table-2`.
Redraws, not breakage.

### 1d. Census 3 — every reader of `__iconNode` / `__iconData` works

A `git grep` for `__iconNode|__iconData|createLucideIcon|LucideIconData`
over the whole tree finds exactly **one reader**: the lazy draw in
`packages/components/src/renderers/action/resolve-icon.ts`, which reads
`module.__iconData?.node ?? module.__iconNode` (the 2026-09-20 repair).
No other code reads an icon module's exports. Red, then green, on the
lazy-draw row, predicted red before running, through
`scripts/ablation-replace.mjs` from `../objectstack` (anchor must hit
exactly once, write verified on disk, restore proven by blob hash and an
empty `git diff HEAD`):

```
mutation   const node = module.__iconData?.node ?? module.__iconNode;   ->   const node = module.__iconNode;
           ok mutation landed: anchor 1 -> 0, blob cc7d80e -> 271d15e3fa58
RED        x fills the path data in once the icon module arrives
           AssertionError: expected 0 to be greater than 0      Tests  1 failed | 5 skipped (6)
restore    ok restored: blob == HEAD (cc7d80e) and `git diff HEAD` is empty
GREEN      Tests  1 passed | 5 skipped (6)                       (at 423a1a5)
```

**Real-build probe**, on the console built at `989330df0`
(`apps/console/dist`), in Chromium: of 2345 built JS chunks, 1819
mention `__iconData` (the 1818 `vendor-icon-*` chunks plus the seam's
`ui-components` chunk) and exactly 1 mentions `__iconNode`, the seam's
fallback read; the built seam reads `__iconData?.node??n.__iconNode`.
Four built icon chunks imported in the browser:

| chunk | `__iconData.name` | aliases | path elements | first `d` |
`__iconNode` |
|---|---|---|---|---|---|
| `vendor-icon-house-*` | `house` | `home` | 2 | `M15 21v-8a1 1 0 0
0-1-1h-4a1 1 0 0 0-1 1v8` | undefined |
| `vendor-icon-trash-*` | `trash` | `trash-2` | 5 | `M10 11v6` |
undefined |
| `vendor-icon-arrow-down-0-1-*` | `arrow-down-0-1` | `arrow-down-01` |
5 | `m3 16 4 4 4-4` | undefined |
| `vendor-icon-loader-circle-*` | `loader-circle` | `loader-2` | 1 |
`M21 12a9 9 0 1 1-6.219-8.56` | undefined |

So the shipped glyph data is non-empty and sits where the seam now reads
it, and the pre-repair read would find nothing in the real build too.
NOT MEASURED: a rendered page. With no backend the built console lands
on `/login`, which draws no lucide svg at all (0 found), so there was no
seam-drawn glyph on screen to count.

### 1e. Census 4 — every `lucide-NAME` class token in the tree, against
the 1.43.0 alias index

Every `lucide-` token in every tracked text file (lockfile and binaries
excluded), classified against an alias index built from the 1818
installed icon modules (canonical `lucide-` plus the icon name; one
alias class per declared alias), against what the seam emits (the
key-derived class and the canonical class, rule copied verbatim from
`lucideClassNames`), and against what 1.35.0 emitted.

| Class of token | distinct | disposition |
|---|---|---|
| canonical class of a 1.43.0 icon | 29 | safe: lucide's component and
the seam both emit it |
| not an icon class (`lucide-icon`, script names such as
`lucide-record-icon-names`) | 5 | out of scope |
| **alias** class: `lucide-home` (of `house`) | 1 | the button pin —
relaxed under ruling C, see section 2 |
| **alias** class: `lucide-loader-2` (of `loader-circle`) | 1 |
`svg-host-dom-leak-5632.test.tsx`, a spinner drawn by a component
import, so lucide's own alias class IS on it; the exact assertion the
2026-09-20 round updated holds on 1.43.0 |
| **alias** / retired: `lucide-trash-2`, `lucide-trash2` | 2 | prose
only: the seam docblock and the pin comment (both rewritten in this
round), and the pending objectui#9251 changeset (left alone; this PR's
changeset dates its example) |
| seam-key-only class (emitted by the seam, not by lucide 1.43.0) |
**0** referenced | — |

Controls: an injected `svg.lucide-trash-2` assertion is classified as an
alias of `trash`; an injected `svg.lucide-check` reads canonical. Six
dynamically built selectors (`svg.lucide-${name}` in the command and
context-menu suites) iterate canonical names only.

### 1f. New since the 2026-09-20 round

`main` moved 294 commits past the old merge base `078f2e4f7`. What the
four censuses see that the `6f20cfb06` tree did not:

| Census | new at this head | verdict on 1.43.0 |
|---|---|---|
| imports | 7 sites in 6 files; 4 files import lucide for the first
time: `packages/app-shell/src/layout/ReadRateBanner.tsx`
(`TriangleAlert`), `packages/app-shell/src/views/ActionParamDialog.tsx`
(`ChevronDown`, `Lock`),
`packages/plugin-detail/src/__tests__/detailSectionAuthoredIcon-9529.test.tsx`
(`MapPin`),
`packages/app-shell/src/hooks/__tests__/userLocale-10059.test.tsx` (a
`vi.mock` of the package); plus `Code2` in `FlowKeyValueField.tsx` and
`BookOpen` in `NavigationDesigner.tsx` | all resolve |
| authored strings | 13 sites; new names `share`, `map-pin` | all live,
except 2 `edit` sites in `menubar-item-icon.test.tsx`, which are
deliberate retired-spelling negatives, dead on all three versions |
| `__iconNode` / `__iconData` readers | none | — |
| class tokens | 6 pairs, all in
`packages/components/src/__tests__/menubar-item-icon.test.tsx`:
`lucide-chevron-right`, `lucide-download`, `lucide-file`, `lucide-mail`,
`lucide-save`, `lucide-share` | all canonical |

None of them needed a repair.

### 1g. The merge and the lockfile

`origin/main` **merged** at `21d34d5e2` (⛔ no rebase, no amend, no
force-push): one conflict, `packages/plugin-kanban/package.json`,
resolved as the union (main deleted `@tanstack/react-virtual` with the
module that used it; this branch bumps `lucide-react`). `pnpm-lock.yaml`
was regenerated by pnpm from main's own lockfile, ⛔ not hand-merged:
main's file taken whole, then `pnpm install --lockfile-only` with the 22
manifests briefly at an exact `1.43.0` and again with `^1.43.0`
restored, which pnpm keeps. A plain re-lock follows the caret to the
newest 1.x (1.48.0 today). The delta against main is lucide alone: 22
importer specifiers, 22 resolutions and the fumadocs peer suffixes. All
22 manifests that declare `lucide-react` read `^1.43.0`.

## 2. Ruling C — the two pins, relaxed and no more

⚠️ **This weakens a gate that was telling the truth.** The two rows
below were red because the seam genuinely stopped reproducing lucide's
classes; they were not flakes. lucide-react 1.43.0 emits one class per
DECLARED ALIAS (`house` renders `lucide-house lucide-home`) and no
longer the class it derived from the PascalCase key. The alias list
lives only inside each icon module, which objectui#9251 made lazy, while
the seam must put its classes on the first frame. The maintainer chose C
over A (put every alias into the eager, build-generated name list,
spending bytes under objectui#9251's ruling) and over B (hold at
1.31.0), with the weakening stated when it was presented. ⇒ After this
PR, a seam-drawn glyph missing lucide's alias classes, or carrying the
key-derived class lucide no longer emits, passes these rows. ⛔ It is not
free.

**Re-measured size of what is given up** (1.43.0 facts, the seam's rule
copied verbatim): of 1818 record keys, the seam's class set and lucide's
**agree on 1494 and disagree on 324** (80 seam-only, 223 lucide-only, 21
both ways) — the same figures the 2026-09-20 round read. **Why the
canonical form of C and not the subset form:** the seam's classes are a
subset of lucide's for only 1717 of 1818 keys (the key-derived class is
not one lucide 1.43.0 emits), so "the seam's classes need only be a
subset" would still be red on 101 keys. The canonical class is on the
seam for **1818 of 1818**.

**Exactly two pins.** They are the two rows `Test (shard 3/4)` and `Test
(shard 4/4)` reported red on `6f20cfb06`, read off those check runs' own
annotations:

- shard 3/4: `button-shared-icon-resolver.test.tsx` > `resolves home
through the RENAME alias to House, not to a dead home` — `expected null
to be SVGSVGElement`;
- shard 4/4: `resolve-icon-lazy-9251.test.tsx` > `reproduces lucide's
per-icon class names over the WHOLE record vocabulary` — `AArrowDown:
expected "lucide lucide-a-arrow-down", got "lucide lucide-a-arrow-down
lucide-aarrow-down"`, and nine more.

Each now carries, at its site, what it used to refuse and why it no
longer does. Verbatim below, with two deliberate substitutions: the
generic in `Record OF string, React.ElementType` and the placeholder in
`lucide-ICONNAME` are written in capitals instead of angle brackets,
because GitHub deletes tag-shaped fragments from a body even inside a
code fence. Byte-exact text: `git show 178da2e`.

### Pin 1 —
`packages/components/src/renderers/action/__tests__/resolve-icon-lazy-9251.test.tsx`

Before (`6f20cfb06`):

```ts
  it('reproduces lucide\'s per-icon class names over the WHOLE record vocabulary', () => {
    // ⚠️ The class names are the part a conversion rule gets wrong silently: 95
    // of the record's keys pack digits that lucide splits in the module name and
    // not in the class derived from the key, so `Trash2` renders BOTH
    // `lucide-trash2` and `lucide-trash-2`. `createLucideIcon` is what builds
    // them and the seam no longer calls it, so every name is compared against
    // the record's own component here — not a sample, and not a copy of the
    // rule.
    const keys = Object.keys(icons);
    expect(keys.length).toBeGreaterThan(1000);
    const mismatches: string[] = [];
    let digitKeysChecked = 0;
    for (const key of keys) {
      if (/\d/.test(key)) digitKeysChecked += 1;
      const reference = renderIcon((icons as Record OF string, React.ElementType)[key], {});
      const seam = renderIcon(resolveIcon(key)!, {});
      const want = classesOf(reference).join(' ');
      const got = classesOf(seam).join(' ');
      if (want !== got) mismatches.push(`${key}: expected "${want}", got "${got}"`);
    }
    expect(mismatches.slice(0, 10)).toEqual([]);
    // Non-vacuity, both ways: the loop ran, and it ran over the class of name
    // this row exists for.
    expect(digitKeysChecked).toBeGreaterThan(50);
  });
```

After:

```ts
  it('carries lucide\'s CANONICAL per-icon class over the WHOLE record vocabulary', () => {
    // ⚠️ RELAXED under the maintainer's ruling C on objectui#8941 (comment
    // 5750512894, verbatim 「8941 C」). This row USED TO REFUSE any seam glyph
    // whose complete class set differed from lucide's own component's. It NO
    // LONGER DOES, because lucide-react 1.43.0 emits one class per DECLARED
    // ALIAS (`house` renders `lucide-house lucide-home`) and no longer emits the
    // class it used to derive from the key, and the alias list exists only
    // inside each icon module — the modules objectui#9251 made lazy — while the
    // seam has to put its classes on the FIRST frame. The ruling chose the bump
    // over carrying every alias in the eager static name list (option A), and
    // it was taken knowing that it weakens this row. So a seam glyph missing
    // lucide's alias classes, or carrying the key-derived class lucide no longer
    // emits, now passes here.
    //
    // What it still refuses: a seam glyph without `lucide` or without lucide's
    // CANONICAL class, `lucide-ICONNAME`, which lucide emits first after the
    // bare `lucide`. That is the class a conversion rule gets wrong silently,
    // because many keys pack digits that lucide splits in the module name
    // (`ArrowDown01` is `lucide-arrow-down-0-1`, never `lucide-arrow-down01`).
    // Every name is still compared against the record's own component — not a
    // sample, and not a copy of the rule.
    const keys = Object.keys(icons);
    expect(keys.length).toBeGreaterThan(1000);
    const mismatches: string[] = [];
    let digitKeysChecked = 0;
    for (const key of keys) {
      if (/\d/.test(key)) digitKeysChecked += 1;
      const reference = renderIcon((icons as Record OF string, React.ElementType)[key], {});
      const seam = renderIcon(resolveIcon(key)!, {});
      // Read in lucide's own order: `lucide`, then the canonical class, then
      // one class per alias. Only the first two are required of the seam.
      const [base, canonical] = (reference?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
      const got = classesOf(seam);
      if (base !== 'lucide' || !canonical?.startsWith('lucide-') || !got.includes(base) || !got.includes(canonical)) {
        mismatches.push(`${key}: expected "${base} ${canonical}" among "${got.join(' ')}"`);
      }
    }
    expect(mismatches.slice(0, 10)).toEqual([]);
    // Non-vacuity, both ways: the loop ran, and it ran over the class of name
    // this row exists for.
    expect(digitKeysChecked).toBeGreaterThan(50);
  });
```

### Pin 2 —
`packages/components/src/renderers/form/__tests__/button-shared-icon-resolver.test.tsx`

Before (`6f20cfb06`):

```ts
    it('resolves `home` through the RENAME alias to House, not to a dead `home`', () => {
      // The single entry both maps carried. It is the one input whose answer
      // would have changed had the dedupe dropped the alias on the floor.
      const button = renderButton({ icon: 'home' });
      const house = button.querySelector('svg.lucide-house');
      expect(house).not.toBeNull();
      // The negative half used to read `querySelector('svg.lucide-home')` is
      // null. That form RETIRED on lucide-react 1.43.0, which reshaped
      // `createLucideIcon` to take an icon-data object carrying `aliases` and
      // to emit one class PER ALIAS: `house` now renders
      // `class="lucide lucide-house lucide-home ..."` itself, so the class no
      // longer says which glyph was drawn (measured: 248 of 1818 icon modules
      // carry aliases; 1.31.0 icon modules have no `aliases` field at all).
      // The discriminating form is below and still fires for the same defect:
      // `Home` is not a key of the runtime record, so had the rename been
      // dropped this button would render NO glyph at all, and any SECOND glyph
      // would be a different element from `house`.
      expect(button.querySelectorAll('svg')).toHaveLength(1);
      expect(button.querySelector('svg.lucide-home')).toBe(house);
    });
```

After:

```ts
    it('resolves `home` through the RENAME alias to House, not to a dead `home`', () => {
      // The single entry both maps carried. It is the one input whose answer
      // would have changed had the dedupe dropped the alias on the floor.
      const button = renderButton({ icon: 'home' });
      expect(button.querySelector('svg.lucide-house')).not.toBeNull();
      // ⚠️ RELAXED under the maintainer's ruling C on objectui#8941 (comment
      // 5750512894, verbatim 「8941 C」). This row USED TO REFUSE a house glyph
      // that did not also carry lucide's `lucide-home` ALIAS class on the same
      // element: `querySelector('svg.lucide-home')` had to BE the house svg. It
      // NO LONGER DOES, because this button draws through the objectui#9251
      // seam, which emits lucide's canonical class and not lucide-react
      // 1.43.0's per-alias classes, and the ruling accepted that rather than
      // put every icon's aliases into the eager static name list.
      //
      // ⛔ Nor does it go back to the older form, `svg.lucide-home` is null:
      // lucide 1.43.0 itself puts `lucide-home` on the house glyph, so that
      // class no longer says which glyph was drawn, and pinning its absence
      // would pin the seam's divergence from lucide. `lucide-home` is neither
      // required nor forbidden here. What still discriminates is the count:
      // `Home` is not a key of the runtime record, so had the rename been
      // dropped this button would render NO glyph at all.
      expect(button.querySelectorAll('svg')).toHaveLength(1);
    });
```

(On `main` this row still reads
`expect(button.querySelector('svg.lucide-home')).toBeNull();`, which is
green under the seam on either version. ⛔ It is not restored: lucide
1.43.0 puts `lucide-home` on the house glyph itself, so pinning its
absence would pin the seam's divergence from lucide, and the row's own
comment now says so.)

**Both relaxed rows can still fail, for what they still refuse.** Same
tool and restore proof as 1d, each prediction written before its run:

```
PIN 1   mutation  return [fromKey, `lucide-${kebab}`, className]  ->  return [fromKey, className]
        RED       x carries lucide's CANONICAL per-icon class over the WHOLE record vocabulary
                  "ArrowDown01: expected \"lucide lucide-arrow-down-0-1\" among \"lucide lucide-arrow-down01\"" (and 9 more)
        restore   ok restored: blob == HEAD (99c47bc) and `git diff HEAD` is empty
PIN 2   mutation  delete the `Home: 'House',` rename entry
        RED       x resolves `home` through the RENAME alias to House    AssertionError: expected null not to be null
        restore   ok restored: blob == HEAD (99c47bc) and `git diff HEAD` is empty
```

(The first attempt at the pin-2 mutation passed an empty replacement,
which the tool refuses; no test ran on it. The run above used its delete
mode.)

**The seam's own docblock moved with the pins.** `lucideClassNames`
claimed its classes were derived the way lucide derives them and that a
pin caught either derivation drifting, under a test file name that does
not exist. Neither survives this bump and this ruling, so it now says
what the seam emits, what 1.43.0 emits instead, and which pin holds how
much. Comment only; no code in `resolve-icon.ts` changed this round.

**⛔ No third pin.** The full suite ran locally at `989330df0` in 16
shards (`pnpm exec vitest run --shard=K/16`, each under
`../objectstack/scripts/pm/os-verify-lock.sh`): **3618 test files**
(3612 passed, 6 skipped, **0 failed**), **48571 tests** (48527 passed,
44 skipped, **0 failed**). CI on the same head: all eight `Test (shard
N/8)` and `Test (dist pins)` green. No other pin went red, for this
reason or for any other, so there was nothing to stop and report.

## 3. Checks

All at `989330df0`, the head of this branch; heavy runs serialized
through `../objectstack/scripts/pm/os-verify-lock.sh`, every exit code
captured before any pipe.

| Check | Command | Reading |
|---|---|---|
| tests | 16 local shards, above | 3618 files, 48571 tests, 0 failed |
| type-check | `pnpm turbo run type-check --concurrency=2` | `Tasks: 81
successful, 81 total`, 0 `error TS` |
| type-check, root projects | `pnpm type-check:scripts`,
`type-check:vitest-setup`, `type-check:vitest-config` | exit 0, 0 `error
TS` each |
| lint, whole repo | `pnpm turbo run lint --concurrency=2` | `Tasks: 47
successful, 47 total`, 0 errors in every task (warnings only). The three
files changed this round, linted alone: 0 errors, 0 warnings |
| build | `pnpm turbo run build --filter='./packages/*'
--concurrency=2`, then `pnpm --filter @object-ui/console build` | 39 of
39; console exit 0 |
| eager-closure budget | `node scripts/check-eager-closure-budget.mjs` |
`Console eager closure is 3054.8 KB gzipped across 329 of 2345 chunks
(budget: 3104.5 KB, headroom: 49.7 KB)`; `ui-components 266.5 KB / 282.2
KB`. ⛔ No ceiling or baseline moved: the script is byte-identical to
main's |
| locale catalogues, SDUI pins | `node
scripts/check-eager-locale-catalogues.mjs`, `pnpm
check:sdui-registration-pins` | exit 0 each |
| dist | `pnpm test:dist`; `check-node-esm-load`,
`check-published-dist-tooling`, `check-dist-completeness --all` | 9 of 9
tasks (components: 2 files, 8 tests); the three gates exit 0 |
| icon names | `pnpm check:icon-record-names`; `node
scripts/regenerate-lucide-record-icon-names.mjs --check` | OK (184
names, record 1818 keys); the generated table matches the installed
manifest |
| lockfile | `node scripts/check-lockfile-integrity.mjs --base` main's
lockfile `--head pnpm-lock.yaml`, and the default invocation;
`check-lockfile-dedupe` | `VERDICT clean — no @objectstack/* identity
moved backward and no package gained a copy` (1773 resolutions on both
sides); `VERDICT deduped` |
| changesets | `check-changeset-presence`, `-no-major`, `-fixed`,
`-overwrite`, `check-pending-changeset-literals` | exit 0 each;
`check-changeset-claims` is report-only |
| touched-path gates | `check-control-bytes`,
`check-new-cross-file-line-citations` (0 new), `check-skills-paths`,
`check-skill-examples` (after the build), `check-skill-eval-tokens`,
`check-test-path-roots`, the three `check-vi-mock-*`,
`check-doc-fence-languages`, `check-doc-example-ids`,
`check-shell-escape-residue`, `check-action-ref-convention`,
`check-phantom-dependencies`, `check-unused-dependencies` | exit 0 each
|
| governed | `node scripts/check-governed-queue-guard.mjs --test` over
the 42 paths | `GOVERNED — 1 of 42` |
| CI | the check runs on `989330df0`, read when this was written | 42
success, 3 skipped (`dependabot` and the two coverage lanes), 0 failed.
The seat re-reads it |

NOT MEASURED locally: `Build & E2E` and the live e2e lane (green on CI).

## 4. Changeset

`.changeset/lucide-react-1-43-0-retired-trash2.md`, restated against
today's tree. It declares, all `patch`, the 20 released packages whose
`dependencies` range moves, `@object-ui/console` (its `devDependencies`
range moves and its bundle ships the new icons), and `@object-ui/cli`
and `@object-ui/types`, whose source changed. It names the
invisible-glyph repair as a user-visible fix, says which classes
component-imported and string-resolved icons carry now, and dates the
`Trash2` class example in the pending objectui#9251 entry without
editing that entry. Dropped from it: the claim that this bump collapses
a double lucide install (main's objectstack-ai#9316 dedupe already did).

## 5. Governed surface and skills readings

The one governed path is the plugin skeleton's `lucide-react` range in
`skills/objectui/guides/plugin-development.md`, anchored to the in-repo
range by `doc-version-claims.test.ts`. Readings: that file 481 lines
before and after; the published `skills/` tree 5365 lines before and
after, its SKILL.md files 137 lines before and after. One line changed,
zero added.

## 维护者速读(草稿)

**改了什么** — 把全仓 22 个包声明的图标库 `lucide-react` 从 `^1.31.0` 升到
`^1.43.0`(实际安装版本从 1.35.0 升到
1.43.0),同时修掉升级带来的问题:懒加载图标的路径数据换了导出名(不修就是所有按名字解析的图标变成空框),以及一个被上游退役的图标名
`trash-2` 改成 `trash`(图形不变)。按您的裁决 C,放宽了两条测试钉子。

**为什么改** — 上游 1.43.0 改了图标数据结构,并且给每个图标加了「别名
class」。我们的懒加载图标拿不到别名(别名只在懒加载的图标模块里),两条如实报告这一差异的测试因此变红。您选了
C:接受这个差异,落地升级,而不是把别名塞进首屏必载的名单(A)或停在旧版本(B)。

**风险与代价(含回滚)** — 代价是明确的:两条原本「说真话」的钉子被削弱了,之后按名字解析的图标少了 lucide 的别名
class,钉子不再报警;按别名 class 写的样式只对直接 import 的图标组件生效。另有一些图标上游重画了(例如 `trash`
现在画的是原来 `trash-2` 的样子)。回滚:整体 revert 本 PR 即回到 `^1.31.0` 与旧钉子。

**席位意见** — 建议批准(席位复核于 2026-09-25T14:03Z,head
`989330df0`)。放宽恰为两条钉子(`178da2eea`),各在原位写明原先拒什么、为何不再拒、仍拒什么,且各有一次预测后变红的证明;`resolve-icon.ts`
本轮只改注释;预算脚本与 main 字节一致,无上限或基线移动;22 个 manifest 均为 `^1.43.0`,lockfile 中
lucide 只有 1.43.0 一份;该 head 上 CI 42 成功、3 跳过、0 失败。本分支合并的 main 基点之后 main
又前进 27 个提交:与本分支 merge-tree 无冲突,其新增 11221 行中 lucide 提及与图标字符串均为 0(同一扫描在本
PR 的 diff 上分别命中 99 与 7);合并队列会在最新 main 上重跑全量。开发代理提出的 objectui#9251
变更集措辞问题,席位取 A:不改那条,本 PR 的变更集已写明其示例的日期。

**你要做的** — 本 PR 命中受管面(`skills/objectui/guides/plugin-development.md`),需要
`os-zhuang` 或 `hotlong` 留一条 APPROVED review;之后由认领席翻 ready 并入队。

## Historical — measured on the 2026-09-10 and 2026-09-20 trees, ⛔ not
re-derived here

Kept as the record of how this PR got here. Superseded where this round
measured differently: main's installed version was 1.35.0, not 1.31.0;
"main installs two physical copies" stopped being true at objectstack-ai#9316; the
governed count now reads 1 of 42; the `Bundle Analysis` NOT MEASURED and
the eslint narrowing below are replaced by section 3; two `path:line`
citations below are re-spelled by content (AGENTS.md objectstack-ai#11), with nothing
else in them changed; and the class note in the old section 3 describes
component-imported icons only (string-resolved icons go through the
seam, section 2).

Supersedes the stale Dependabot branch objectui#7058
(`dependabot/npm_and_yarn/lucide-react-1.35.0`). That branch is not
pushed to here — Dependabot force-pushes over it on every rebase — so
this is a fresh branch cut from current `main`. Once this lands,
objectui#7058 can be closed as superseded. It is deliberately not named
with a closing keyword above; closing it is a maintainer action, not
this PR's.

### 1. Which of the seven red checks were staleness, and which were real

objectui#7058's base is `4e3a4f07e` — weeks behind `main`. I brought its
head up to date in a throwaway worktree, re-locked from current `main`,
and re-measured. Four of the seven were artefacts of the stale base;
three were real.

| Check on objectui#7058 | Verdict | Evidence |
|---|---|---|
| `Type Check` | **REAL** | The job failed at `pnpm
check:icon-record-names`, not at `tsc`. `tsc` never ran. |
| `Test (shard 1/4)` | **REAL** |
`packages/cli/src/__tests__/app-generator.test.ts` — the generator's
literal `lucide-react` range stayed at `^1.31.0` while the 22 sibling
manifests moved. |
| `Test (shard 3/4)` | **MIXED** |
`check-lucide-icon-record-names.test.ts` REAL (same four sites).
`ci-cd-pipeline-doc.test.ts` STALE — the lockfile carried two
`@objectstack/spec` versions. |
| `Test (shard 4/4)` | **MIXED** | `doc-version-claims.test.ts` REAL
(`skills/objectui/guides/plugin-development.md` still taught `^1.31.0`).
`check-lockfile-integrity.test.ts` STALE — its fixture forks a
`zod@4.4.3` key the stale lockfile had replaced with `4.5.4`. |
| `Lockfile Integrity Check` | **STALE** | `VERDICT 12 finding(s): 7
backward move(s), 5 duplication(s)` — Dependabot re-locked on an old
base and dragged `@objectstack/client`, `core`, `formula`, `lint`,
`sdui-parser`, `types` from 17.4.0 back to 17.3.0, plus a `zod`
4.4.3/4.5.4 split. |
| `Bundle Analysis` | **STALE** | `vendor-objectstack 2874.0 KB / 1224.6
KB ceiling (OVER by 1649.3 KB)` — that is the duplicated `@objectstack`
tree above, which the lockfile gate itself names as the cause: *"a
bundle-size red downstream of this is caused by THIS, not by the
dependency being bumped."* |
| `dependabot` | STALE | Dependabot's own branch check. |

On this branch, re-locked from current `main`, `node
scripts/check-lockfile-integrity.mjs` reports `VERDICT clean — no
@objectstack/* identity moved backward and no package gained a copy`.

### 2. Root cause of what survives — measured against the installed
artifact

The hypothesis in the dispatch was a broad source-compatibility break.
**The measurement narrows it to a single retired name, and falsifies the
type-checking half entirely.**

I installed 1.31.0, 1.41.0 and 1.43.0 side by side and diffed three
surfaces:

| Surface | 1.31.0 | 1.43.0 | Removed |
|---|---|---|---|
| runtime exports of `lucide-react` | 6068 | 6224 | **0** |
| public names in `lucide-react.d.ts` | 6072 | 6230 | **0** |
| `iconNames` from `lucide-react/dynamic.mjs` | 2025 | 2077 | **0** |
| keys of the runtime `icons` record | 1767 | 1818 | **1 — `Trash2`** |

Then I enumerated what this repository actually imports — 4700 source
files, 333 distinct value names, one type name (`LucideIcon`), six
namespace or default imports — and checked every one against both
artefacts. **Zero fail to resolve, in either version.** Controls fired
on the same run in both worlds: `Trash` resolves in both, a fabricated
name resolves in neither.

So nothing breaks at an import and nothing breaks in the compiler.
`Trash2` is still exported by 1.43.0; it is simply no longer a *key of
the runtime `icons` record*, and `Trash2 === Trash` there (in 1.31.0
they were distinct objects). That is exactly the class
`scripts/check-lucide-icon-record-names.mjs` exists to catch: a retired
spelling still imports, still type-checks, still renders as a COMPONENT,
and resolves to nothing as a STRING.

Four authored sites resolve `trash-2` through
`renderers/action/resolve-icon.ts`, which reads record membership:

-
`examples/schema-catalog/src/schemas/actions/action-button-variants.json`
- `examples/schema-catalog/src/schemas/actions/confirmation-dialog.json`
-
`examples/schema-catalog/src/schemas/components-layout-card/profile-detail-card.json`
- `packages/plugin-detail/src/DetailView.tsx` — the record delete action

All four now write `trash`. **The glyph does not change:** 1.43.0's
`trash` icon node is byte-identical to 1.31.0's `trash-2` icon node
(control: `pencil` is byte-identical across both versions, so the
comparison is not returning equal for everything). Upstream replaced the
old bin artwork under the `trash` name and kept `trash-2` as a
deprecated alias.

### 3. Why 1.43.0 and not the 1.41.0 the Dependabot PR advertises

The workspace declares a caret range, so a re-lock from current `main`
resolves the newest 1.x — that is 1.43.0 today, under `^1.41.0` just as
much as under `^1.43.0`. Holding exactly 1.41.0 would need an exact pin
or an override, which is not this repo's convention and not mine to
invent. Measured, the extra two minors cost nothing in removals:
**1.41.0 to 1.43.0 removes zero exports, zero record keys and zero
dynamic names.**

It does bring one behavioural change that 1.41.0 does not have, and it
is worth a maintainer's eye:

**1.43.0 reshaped `createLucideIcon`** to take an icon-data object
carrying `aliases`, and it now emits **one CSS class per retired
alias**. A spinner renders `class="lucide lucide-loader-circle
lucide-loader-2 ..."`. Measured: 248 of 1818 icon modules carry an
alias; 1.31.0 icon modules have no `aliases` field at all. This is
additive, so a `.lucide-loader-circle` selector still matches — but a
selector asserting the exact, complete class string does not, and
neither does an assertion that a given `lucide-` class is ABSENT.

I censused the whole tree for both shapes, using the alias index built
from the installed artefact as the oracle: **28 distinct `lucide-` class
names are referenced anywhere in the repository** (all file types,
snapshots included) and **exactly two are aliases in 1.43.0** —
`lucide-home` (owned by `house`) and `lucide-loader-2` (owned by
`loader-circle`). Controls: injecting the known alias `trash-2` into the
census is found; `check` is correctly absent. Both affected pins are
repaired here:

- `examples/schema-catalog/test/svg-host-dom-leak-5632.test.tsx` asserts
the exact attribute set. The added class is named. The assertion stays
**exact** on purpose — that list is what catches an authored prop
reaching the DOM, which is the whole point of objectui#5632.
-
`packages/components/src/renderers/form/__tests__/button-shared-icon-resolver.test.tsx`
proved `home` resolved through the rename to `House` by asserting
`svg.lucide-home` is null. `house` now emits `lucide-home` itself, so
that class no longer says which glyph was drawn. Replaced with a form
that still fires for the same defect: exactly one svg, and the alias
class on that same element. Had the rename been dropped, `Home` is not a
record key and the button would render no glyph at all.

### 4. Something the bump gives back

`main` installs **two** physical copies of the icon library: 1.31.0 for
this workspace and 1.35.0 pulled by `fumadocs-ui`. On this branch both
collapse onto a single 1.43.0. Lockfile resolutions go 1822 to 1821.

### 5. What was measured here

- `pnpm check:icon-record-names` — `OK ... 184 authored/declared names
reaching 1 record-reading resolver are live icons keys (record 1818
keys)`.
- Reverse verification, direction predicted before running: restoring
`trash-2` in `DetailView.tsx`'s `sys_delete` action turns that gate red
naming **exactly that one site** and nothing else; restoring the file
returns it to green. Restoration proved by blob hash equality with HEAD
and an empty `git diff HEAD`, not by an exit code.
- `pnpm type-check` across the workspace — turbo `81 successful, 81
total` over 45 packages, zero `error TS`.
- `pnpm exec vitest run packages/components/ examples/schema-catalog/
packages/plugin-view/ packages/plugin-list/` — `Test Files 395 passed
(395)`, `Tests 5688 passed (5688)`.
- `pnpm exec vitest run packages/plugin-detail/ packages/types/
examples/schema-catalog/` — 350 files, 6769 tests, green after the pin
repair.
- `pnpm exec vitest run
packages/cli/src/__tests__/app-generator.test.ts` — 43 passed.
- `pnpm exec vitest run scripts/__tests__/doc-version-claims.test.ts` —
29 passed.
- `pnpm exec vitest run
scripts/__tests__/check-lucide-icon-record-names.test.ts
scripts/__tests__/ci-cd-pipeline-doc.test.ts
scripts/__tests__/check-lockfile-integrity.test.ts` — green.
- `node scripts/check-lockfile-integrity.mjs` — `VERDICT clean`.
- `node scripts/check-changeset-presence.mjs` and
`check-changeset-no-major.mjs` — both green.

Declared narrowing: the full four-shard `pnpm test` and the repo-wide
`pnpm lint` are left to CI. `eslint` over the changed source files
reports one pre-existing error in `action-bar.tsx`
(`react-hooks/static-components`) in code this PR does not touch (its
only change to that file is a docblock comment); that attribution is
stated, not yet controlled against `main`. The `Bundle Analysis`
measurement was still building locally when this was opened and is
reported as NOT MEASURED rather than assumed — the stale-base cause of
its red is independently established above.

### 6. Governed surface — this PR stays a draft

`node scripts/check-governed-queue-guard.mjs --test` reports `GOVERNED —
1 of 37 path(s)`: `skills/objectui/guides/plugin-development.md`, the
published skills catalog. One governed path governs the whole PR. It is
**not** to be flipped ready, enqueued, or armed with auto-merge; a human
merge is the review record.

The changed line there is the plugin skeleton's `lucide-react` range,
which `doc-version-claims.test.ts` anchors to the in-repo range. Its
`KNOWN_CLAIMS` entry is keyed by the literal text, so the inventory key
moved with it.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <support@objectstack.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants