Skip to content

fix(core): cloneAsOverride returns a mutable type, so an override draft type-checks - #5320

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-5257-clone-as-override-mutable
Aug 19, 2026
Merged

fix(core): cloneAsOverride returns a mutable type, so an override draft type-checks#5320
os-support-ai merged 1 commit into
mainfrom
claude/issue-5257-clone-as-override-mutable

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#5257

Per the maintainer's 2026-08-19 ruling (verbatim 「全部接受」), option A: cloneAsOverride returns a type that tells the truth about the value it produces.

The defect

cloneAsOverride< T >(view: T): T handed the input type straight back. Cloning a SystemView< S > — which is DeepReadonly< S > plus the marker symbol — returned something still typed deep-readonly, even though the implementation has always produced a plain mutable object (structuredClone, or a JSON round-trip fallback) and deliberately drops the marker. The declaration was simply wrong about its own value.

The acceptance criterion, measured

The criterion was concrete: the documented override flow in packages/core/README.md must type-check, measured by the snippet compile gate from #5138.

That document sits in the gate's UNGATED_DOCS ledger, so a plain pnpm check:doc-snippets run does not compile it. The measurement therefore re-runs the gate's own exported harness (analyze / compileSnippets — same compiler options, same dist-resolution control, same planted sentinel) with an ungated map covering every document except core's README. Nothing about the measurement is re-implemented.

Controls green on every run: resolution landed on packages/types/dist/index.d.ts (a built artifact, not source), 0 source-file leaks, the planted sentinel produced its TS2305 (so the program is not resolving everything to any), positive control clean.

beforeafter
README.md:124draft.columns.push(...)TS2339gone
README.md:119userListView.columns.push(...)TS2339TS2339 — correct, see below
total semantic diagnostics on the page76

The surviving TS2339 is required. Line 119 is the userListView.columns.push({ name: 'name' }) // ❌ TypeError (strict mode) demonstration — the System-View immutability example, where a readonly rejection is the documentation working as written. Relaxing the clone must not relax the source, and three type-level cases pin that it does not.

Note on line numbers: the card cites README.md:121 and :116. Those shifted to :124 and :119 between filing and now; the substance is identical and the drift is only the numbering.

The change

Adds DeepMutable< T >, the inverse of the existing DeepReadonly< T >, and returns it.

Naming — a deliberate deviation from the ruling's literal text, flagged for review. The ruling writes the signature as Mutable< T > while also calling for "a Mutable/DeepMutable inverse". This ships one type, named DeepMutable, for the same reason the existing type is not called Readonly: TS' built-in Readonly< T > is shallow, so a bare Mutable would understate its reach by exactly the depth gap that made cloneAsOverride wrong in the first place. Shipping a shallow-sounding name on a deep type would repeat this card's own bug one level up. Two exported names for one type was also rejected — it gives an author two spellings for one thing.

Two limits, stated rather than left to be discovered. DeepMutable is symmetric with DeepReadonly arm for arm and inherits its tuple behaviour: a tuple widens to an array, exactly as DeepReadonly widens it in the other direction (an inverse that preserved tuples would not be an inverse). And it does not strip the SYSTEM_VIEW_MARKER key — the clone never carries the symbol at runtime, but the key is declared optional, so carrying it states "may be absent", which is true. Excluding it needs Exclude< keyof T, typeof SYSTEM_VIEW_MARKER >, a non-homomorphic mapped type that drops the ? modifier from every other property and turns optional keys required: a strictly worse type traded for removing a key that already reads as optional. One type-level case pins that optionality survives, so that regression cannot land quietly.

Consumer sweep — the answer is zero, and it is counter-probed

cloneAsOverride has no call site anywhere in the repo outside packages/core/README.md. DeepReadonly and SystemView have no consumer outside packages/core either. The sibling objectstack checkout has zero hits.

A zero is only worth as much as the probe that produced it, so the same grep was run over neighbouring core exports: normalizeListView and expandFields return hits across plugin-detail, plugin-view, plugin-list, app-shell and types. The sweep spans packages; the zero is real.

So the ruling's load-bearing premise — the return type relaxes toward runtime, existing callers keep compiling — holds trivially here, and is additionally pinned rather than assumed: two type-level cases assert a draft is still assignable to the SystemView< S > and the DeepReadonly< S > it came from. Nobody loses a permission; a caller gains one. Hence patch.

Reverse verification — predicted before running, then observed

Reverted freeze-schema.ts alone, keeping the new tests and the ledger edit.

The rebuild question, per leg — and it mattered. The two legs resolve differently on purpose. The vitest pin file imports ../freeze-schema by relative path, so it reads source and needs no build. The snippet gate resolves @object-ui/core through package exports to dist/*.d.ts, so the edit only reaches it through a build. Measured: with the source reverted but dist stale, the gate reported 6 diagnostics — the fixed state. A false green. After rebuilding core it returned to 7, with TS2339 at both :119 and :124. Every leg here, mutation and restoration alike, was rebuilt and the marker's presence/absence in dist/utils/freeze-schema.d.ts verified before any result was read.

legpredictedobserved
vitest pin filered, ~8 failuresred, 6 failures
type-checkred — TS2305/TS2724 on a DeepMutable importred — TS2339, different cause
snippet gate (after rebuild)7 diagnostics, TS2339 at :119 and :124exactly that

Both misses are recorded rather than smoothed over:

  • 8 vs 6. Cases 0/1/2 flipped as predicted, and both discrimination tests failed exactly as predicted (flipped collapsed to [], and { i: 0, now: true, before: true }). The overcount was cases 8 and 9: an unresolved type name reports at the import line, not at the annotation site, where it degrades to any and yields no diagnostic. Only case 11 failed at its own line, via an implicit-any parameter under strict.
  • Wrong diagnostic on type-check.DeepMutable is named only inside virtual-module source strings in that file, never in a real import, so tsc never sees it. What went red instead was the runtime test's own draft.columns.push(...) line.

Green on both legs, stated plainly: the two runtime tests. They pass reverted and unreverted, because the runtime was never the defect — the type was. They are in the suite so a future edit cannot "fix" the mismatch by making the runtime readonly instead. Note the split they expose: vitest strips types, so those tests execute green on the reverted leg, while type-check reads their source and goes red. The two legs measure different things.

Declared file surface

  • packages/core/src/utils/freeze-schema.tsDeepMutable + the signature
  • packages/core/src/utils/__tests__/freeze-schema.types.test.ts — new; 12 type-level cases, a revert-proof discrimination control, 2 runtime companions
  • .changeset/clone-as-override-returns-mutable-5257.md — new, patch
  • scripts/check-doc-snippet-types.mjsbeyond the claimed surface, named here with evidence. One ledger string, no behaviour change. The entry for packages/core/README.md read TS2339x2 — candidate real defects, un-triaged; this PR is what makes that count false. It now reads TS2339x1, and the survivor is triaged rather than un-triaged: it is the deliberate immutability demonstration, not a defect. Leaving it would have left the debt list asserting a measurement this PR falsified. The gate's own self-test (20 cases) and a full gate run were both re-run because of this edit.

Verification

All at head c548048f3, run from the repository root (core owns a standalone vitest.config.ts, one of the 11 packages objectui#5313 measured the invocation guard as not covering).

  • vitest run packages/core scripts/__tests__/check-doc-snippet-types.test.ts92 files, 1937 tests, all pass
  • pnpm --filter @object-ui/core type-check — clean (tsc --noEmit && tsc -p tsconfig.test.json, both echoed)
  • pnpm --filter @object-ui/core lint — 0 errors
  • node scripts/check-doc-snippet-types.mjs — 68/68 blocks judged, 0 failed, all controls green
  • node scripts/check-control-bytes.mjs — 4716 files scanned, OK
  • check-changeset-presence / no-major / fixed, check-type-check-coverage, check-lint-coverage — all green

Generated by Claude Code

…ft types as mutable
`cloneAsOverride<T>(view: T): T` handed the input type straight back. Cloning a
`SystemView<S>` — `DeepReadonly<S>` plus the marker symbol — therefore returned
something still typed deep-readonly, while the implementation had always
produced a plain mutable object (`structuredClone`, or a JSON round-trip
fallback) and deliberately dropped the marker. The declaration was wrong about
its own value, and the documented override flow was what broke:
`packages/core/README.md`'s `draft.columns.push({ name: 'name' })` failed with
TS2339 against the built types.
Adds `DeepMutable<T>`, the inverse of the existing `DeepReadonly<T>`, and
returns it. Per the maintainer's 2026-08-19 ruling (option A), the two
alternatives were rejected by name: teaching a cast in the README is the
lenient-consumer pattern the contract rules out, and declaring the block a
documentation fragment hides a real signature defect behind the fragment marker.
The return type relaxes toward what the runtime already does, so no caller loses
a permission — a repo-wide sweep found no call site outside the README, and both
assignment directions are pinned as type-level cases.
Also refreshes the doc-snippet gate's ledger entry for `packages/core/README.md`,
which this change makes stale: TS2339x2 -> TS2339x1, and the survivor is now
triaged rather than un-triaged — it is the `❌ TypeError (strict mode)`
immutability demonstration, where a readonly rejection is the documentation
working as written.
Part of objectui#5257
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-7gc9fhTo.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.83KB3.70KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.33KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.79KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.71KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.81KB113.40KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.34KB
fields (index.js)237.07KB59.46KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.42KB1.39KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)29.43KB7.15KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)39.16KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)127.92KB32.80KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)124.19KB30.20KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)198.22KB53.28KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.66KB27.13KB
plugin-map (index.js)20.08KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.33KB20.61KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)36.10KB12.26KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-support-ai
os-support-ai marked this pull request as ready for review August 19, 2026 14:35
@os-support-ai
os-support-ai added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 8477be5Aug 19, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5257-clone-as-override-mutable branch August 19, 2026 14:35
Sign up for freeto 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.

core: cloneAsOverride keeps its input's deep-readonly type, so a Tenant/User override clone does not type-check as mutable

2 participants

@os-support-ai@claude