Skip to content

fix(types): declare WalkableDef.rest as nullable, matching what zod 4 mints (objectui#9491) - #9683

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-9491-walkabledef-rest-null
Sep 17, 2026
Merged

os-sales merged 1 commit into
mainfrom
claude/issue-9491-walkabledef-rest-null

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #9491

Declare WalkableDef.rest as z.ZodType | null. All readings below were taken on this branch at 3713e80, off base 72f55c9ec1.

Angle-bracket-shaped tokens are spelled out in words in this body (AGENTS.md, "GitHub 会改写你写进 issue/PR 正文的字节"): the generic helper the new pin uses is written AdmitsNull of WalkableDef's rest, never in the bracket spelling, because the sanitizer deletes those even inside backticks.

What this is

packages/types/src/zod/node-derivation.ts declares WalkableDef, the def member set both zod walkers in this package read. It declared rest?: z.ZodType — i.e. z.ZodType | undefined — while zod 4 spells "this tuple has no rest element" as an OWN rest key holding null.

Scope is the declaration, not behaviour. objectui#9088 already repaired the one arm the wrong type misled, by copying def.rest instead of normalising it. Nothing here re-touches that arm's logic, and no walker's output changes. This corrects the type that licensed the defect, so the next arm written against it is told the truth.

The four things the dispatch asked me to measure

1. Premise, re-verified on my own base

origin/main 72f55c9ec1 — the same head the claim recorded. WalkableDef still declared rest?: z.ZodType. Premise live.

2. The card's "declaration-only" expectation — FALSIFIED, and this is the one substantive finding

The card says both readers guard with a truthiness test, so the widening should be declaration-only, and calls that "a claim to measure, not to assert". Measured: it is not declaration-only.

The truthiness guards are indeed fine — neither def.rest ? walk(def.rest) : def.rest nor strict-authoring-face.ts's def.rest ? ... : {} moves. What the card did not account for is that objectui#9088's own repair introduced a raw, unguarded flow of def.rest into a typed helper: the local unchanged in zod/imported-defaults.ts declares its comparison pairs z.ZodType | undefined, and comparing like with like is the whole of that repair. Widening the member alone gives:

src/zod/imported-defaults.ts(252,24): error TS2322: ... Type 'null' is not assignable to type 'ZodType... | undefined'.
src/zod/imported-defaults.ts(252,87): error TS2322: ...
src/zod/imported-defaults.ts(252,97): error TS2322: ...

⇒ one read adjusted: unchanged's parameter now admits null on both sides. The comparison is still ===null matches only null, undefined only undefined. ⛔ Deliberately NOT a cast at the call site and ⛔ NOT a relaxation to double-equals: either would hide the same inaccuracy this card exists to remove, and the arm would be back to normalising a value to satisfy a signature.

3. def.out, re-measured rather than relayed

The card reports it clean from objectui#9088 and the dispatch told me to treat that as unverified. Re-derived against the installed zod, both directions: out carries an own key on all three pipe spellings and holds a schema node in each, never null. Clean — confirmed, not relayed. Also re-ran the twelve-member sweep triage asked for: across a matrix of every node kind the walkers meet, rest is the only walkable member ever holding null, and the only nodes that mint it are the rest-less and empty tuples. The source half agrees: over zod 4.4.3's shipped v4 tree, the only null bound to a walkable member name is const rest = hasRest ? _paramsOrRest : null, at three logical sites doubled across module formats. Both halves are now in the pin file rather than in prose, so a zod bump re-derives them.

4. Is WalkableDef published? — shipped, but reachable by no consumer

Load-bearing for the contract review, so both legs are recorded:

specifier verdict
@object-ui/types (control) RESOLVED, 55 named exports
@object-ui/types/zod (control) RESOLVED, 211 named exports
@object-ui/types/zod/node-derivation.js REFUSED — ERR_PACKAGE_PATH_NOT_EXPORTED
@object-ui/types/dist/zod/node-derivation.js REFUSED — ERR_PACKAGE_PATH_NOT_EXPORTED

The file IS emitted into the published tarball (dist/zod/node-derivation.d.ts, and files ships dist), and the widened member is visible in those bytes. But the exports map has no subpath for it and no wildcard, and WalkableDef appears in no entry point's emitted declarations — the only dist declaration file naming it is its own. ⇒ the widening is internal: its only consumers are the two walkers in this package. Declared Clause-②: yes stands as ruled; this is the fact a reviewer needs to size it.

The new pin, and why it is shaped this way

packages/types/src/__tests__/walkable-def-null-mint-9491.test.ts. Per the dispatch's suggestion, it probes the installed zod rather than asserting a TS type in isolation, because what has to stay true is an agreement between a declaration in this repo and a value minted inside a dependency — and the two move independently. It holds three things: rest is minted null (with the rest-bearing control firing the other way); no other member the walkers read ever is; and the declaration agrees with both, pinned through tsc because this package's type-check compiles its tests via tsconfig.test.json.

The uniqueness half is not decoration. Without it, the honest repair for rest reads as a licence to spell the same union on any member that looks similar — which would declare an absent case zod never produces, and every read guarding against it would be dead code no test could reach.

Reverse verification — four legs, each restored and proven restored

Run from the committed tree at 3713e80. Every leg mutates, proves the mutation reached disk by counting the injected and the removed text, runs, restores with git checkout HEAD -- path, and proves the restore by comparing git hash-object against the HEAD blob (an empty hash read as failure, never as "nothing to compare"). Final state: git diff HEAD empty.

leg mutation expected observed
A narrow the member back to z.ZodType new pin fails to COMPILE type-check exit 2 — walkable-def-null-mint-9491.test.ts(175,11): error TS2322: Type 'true' is not assignable to type 'false'
B widen the sibling out to match it uniqueness pin fails to COMPILE type-check exit 2 — error TS2322: Type 'false' is not assignable to type 'true'
C expect element in the null-minted set runtime sweep reddens vitest exit 1 — AssertionError: expected [ 'rest' ] to deeply equal [ 'element', 'rest' ], 1 failed / 5 passed
D undo ONLY the unchanged widening type-check reddens exit 2, 3 TS2322 at the tuple arm's unchanged call

Leg D is the measurement behind finding 2, taken from the committed tree rather than from the working state it was first noticed in. Leg A reddens the pin and not imported-defaults.ts — the narrower member is still assignable to the widened helper parameter, which is what says the helper widening is a genuine second edit and not a restatement of the first.

No permanent mutation test was left behind; the subject resolves through relative source imports, so no dist staleness can make any of these legs read green for the wrong reason.

Gates

Exit codes captured to disk before any pipe, never read through a pipeline.

gate exit
pnpm --filter @object-ui/types type-check 0
pnpm --filter @object-ui/types lint 0 (0 errors, 288 pre-existing warnings, none on a changed file)
pnpm --filter @object-ui/types build (chains check-dist-completeness) 0
pnpm exec vitest run over the new pin + the 9088 / 9102 / 8317 neighbours 0 — 4 files, 166 tests passed
node scripts/check-changeset-presence.mjs 0
pnpm changeset:check 0
pnpm check:changeset-claims 0
pnpm check:pending-changeset-literals 0
pnpm check:control-bytes 0
pnpm check:new-line-citations 0
pnpm check:spec-symbols 0
pnpm check:component-surface-parity 0 (report-only by its own ruling)
node scripts/check-governed-queue-guard.mjs --test (4 changed paths) 0 — NOT GOVERNED

Declared narrowing. pnpm lint is turbo run lint, i.e. every package's own eslint run; I ran @object-ui/types's only. Population and count read from eslint itself (--format json): 270 files in this package, 0 errors. Invariance for the untouched packages: this repo's eslint is not type-aware — eslint.config.js declares no projectService, no parserOptions.project and no project key — so no rule anywhere reads the declaration I moved, and a type edit cannot move a verdict on a file it did not touch. Independently, finding 4 shows no other package can even name WalkableDef. The repo-wide farm is CI's run.

⛔ Not run, deliberately: no pnpm dedupe (check-lockfile-dedupe is non-deterministic here, objectui#9562); the lockfile is untouched. Vitest was never invoked with the name-filter flag — this repo's describe names end in a parenthesised card reference, which that flag reads as a regex and silently matches nothing while exiting 0 (objectui#9660). Every run above names files by path and reports a non-zero pass count.

Acceptance notes

Things tripped over, none of them filable under the three classes, none acted on:

  • packages/types/tsconfig.json excludes tests from the build while tsconfig.test.json compiles them; the new pin depends on that second project existing to be a gate at all. Noted because a future consolidation of the two would quietly demote a compile-time pin into a comment. Not a defect today — the arrangement is deliberate and documented in tsconfig.test.json's own header. Successor: whoever next touches that pair.
  • imported-defaults-rest-less-tuple-9088.test.ts declares a LOCAL ZodDef mirror with the honest rest union plus an out union this branch measured unnecessary, and its comment explains the mirror exists because the shared type was outside that card's file surface. The shared type is now honest, so the mirror could be retired in favour of importing WalkableDef. ⛔ Not done here: it is a refactor of another card's pin file with no defect behind it. Successor: whoever next edits that pin.
  • The changeset declares a patch. The published bytes that move are declaration bytes in a file no exports subpath reaches, so nothing a consumer can import changes shape.

维护者速读(草稿)

改了什么。 一个类型声明说谎,现在说实话。@object-ui/types 里两个 zod 遍历器共用一张"我读哪些字段"的类型表,其中 rest 一项声明为"要么是个 schema,要么没有";而 zod 4 实际填进去的是 null。这次把声明改成允许 null,并顺带修正了一处因此被卡住的内部比较函数的参数类型。运行时行为一处未动,任何已发布 schema 的接受范围也一处未动。

为什么改。 因为这张错声明已经"发过一次许可证":objectui#9088 那个缺陷不是作者写错了,是作者照着类型写、类型是错的、编译器还替他背书 —— 结果每一个无 rest 的 tuple 都被无谓重建。一个把错误合法化的类型比一个错误的实现贵,因为它让下一个人每一步都做对而仍然出错。这次是把发证的那一行收回来。

风险与代价(含回滚)。 风险很低:这个类型没有任何一条 exports 通路能被包外引用到(本 PR 用"控制组 + 实验组"两条腿实测过),所以它的消费者只有本包内的两个遍历器,两个都已编译通过。代价是多了一个 pin 测试文件,它会在 zod 升级改变这个行为时主动变红 —— 这正是它存在的意义。回滚就是 revert 本 PR 的单个 commit,不留残留(反向验证用的临时改动已逐字节还原,校验过哈希)。发布影响:一个 patch changeset,跟着固定版本组走。

席位意见。

你要做的。 如果同意"声明应当如实反映依赖实际铸出的值"这个方向,这张卡就没有需要你裁的分歧点 —— 唯一值得你看一眼的是本 PR 第 2 条发现:卡面预期"纯声明改动"被实测证伪,多改了一处内部比较函数的参数类型,理由与取舍写在上面。


Generated by Claude Code

… 4 mints (objectui#9491)

`packages/types/src/zod/node-derivation.ts` declared `rest?: z.ZodType` —
`z.ZodType | undefined` — while zod 4 spells "this tuple has no rest element"
as an OWN `rest` key holding `null`. The declaration is what licensed
objectui#9088: the `tuple` arm normalised the absent case to `undefined`
because the type said that was the absent case, `unchanged` compares by `===`,
and so every rest-less tuple was rebuilt.

The declaration, not the behaviour. Nothing here changes what either walker
does with the value, and no exported schema's accept set moves.

One read needed adjusting, contrary to the card's expectation: the local
`unchanged` helper in `zod/imported-defaults.ts` types its comparison pairs
`z.ZodType | undefined`, and objectui#9088's repair hands it `def.rest` raw.
Its parameter now admits `null`; the comparison is still `===`.

New pin `walkable-def-null-mint-9491.test.ts` re-derives both halves against
the installed zod — `rest` is minted `null`, and no other member the walkers
read ever is — plus a compile-time pin so narrowing the member back, or
widening a sibling to match it, fails to compile.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 1 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/8317-strip-imported-defaults.md

  • names zod/imported-defaults.tspackages/types/src/zod/imported-defaults.ts — edited by this change

    Decision batch Redesign examples based on new JSON project specification #69 (objectui#7735) ruled a principle: a validator validates; it does not write values into an author's document. PR fix(types,components): the zod mirrors stop authoring defaults #8299 delivered it for the 41 .default() call sites written in this package's own mirrors. Measured afterwards, 57 ZodDefault nodes were still reachable from the published @object-ui/types/zod barrel, every one inside a subschema imported by reference from @objectstack/spec — so safeValidateSchema went on substituting on those keys, with 41 stripped and 57 not and no way to tell which was which from the document. Batch [WIP] Fix action run issue in CI/CD pipeline #90 ruled that the principle holds for every key the validator answers, and those 57 are now stripped where the spec enters this package (.removeDefault(), the established local pattern, applied through zod/imported-defaults.ts).

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a claim that was born false (a changeset this change adds is excluded by construction), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 72f55c9ec (merge-base with origin/main): 3 file(s) changed outside .changeset/, read against 1119 pending declaration(s) that publish a body (1624 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3048.9 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-Ckww2Jo5.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

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) 545.84KB 130.66KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 215.98KB 59.97KB
fields (index.js) 249.27KB 62.92KB
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.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
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.92KB 14.22KB
plugin-charts (index.js) 71.33KB 19.90KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.29KB 65.88KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.71KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.64KB 57.91KB
plugin-kanban (index.js) 46.41KB 14.49KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 21.48KB 6.99KB
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) 10.58KB 3.72KB
plugin-view (index.js) 85.04KB 21.01KB
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) 104.82KB 34.67KB
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) 4.11KB 2.06KB
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.04KB 5.36KB
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

Copy link
Copy Markdown
Collaborator Author

At-tier contract review record — PR #9683 / card objectui#9491 — PASS

Adopted VERBATIM by the domain:spec @ objectui execution seat (session_01UanLVj6xvbS6puBCewLr8L), per 「逐字采纳或整体作废,没有第三种」. ⛔ Not one word below was rewritten, polished, reordered or summarised by this seat.

Tier, read from the reviewer's OWN transcript (⛔ not get_session, which inside a subagent measures the DISPATCHING session): message.model on every type:"assistant" record of the reviewer's own .jsonl reads claude-fable-5-1, 76 of 76 — at CONTRACT_REVIEW_TIER, with no contamination. Firing control, same instrument, same session: the three os-dev implementers' transcripts read claude-opus-5 (137 / 147 / 152 records). ⇒ the field discriminates, so this is a reading and ⛔ not a constant.

Isolation: the reviewer was given the card, existing rulings and the PR only — ⛔ never the dispatch order, ⛔ never this seat's conclusions, ⛔ never the implementer's report as an input. Implemented-by: session_01UanLVj6xvbS6puBCewLr8L (os-dev subagent) and Reviewed-by: an isolated at-tier subagent are ⛔ not the same agent.


VERDICT: PASS

Subject: objectstack-ai/objectui PR #9683 (head 3713e80, base 72f55c9 = merge-base with origin/main; 1 commit, 4 files, +263/−7) against card objectui#9491. Reviewer of record: independent contract review. The card's Claim: comment, the PR and the implementer's report share the seat account; the report is a card comment and was read as one, but every reading below is my own.

How I measured. Nothing in /home/user/objectui or /home/user/objectstack was written to: all tree reads were git show / git grep / git log against commit ids already present in the shared checkout (both 72f55c9 and 3713e80 are). For anything that had to execute I made a git clone --shared of the checkout into my scratchpad, checked out 3713e80, and ran pnpm install --frozen-lockfile --offline (1055 packages from the local store; zod resolves to 4.4.3, the only v4 resolution in pnpm-lock.yaml). Every mutation leg was restored with git checkout HEAD -- the path, and proven with git diff --quiet HEAD; final tree clean, 0 untracked.

What I measured

# Reading Where from Lit control
1 WalkableDef is named in exactly one non-comment source location: its own file (packages/types/src/zod/node-derivation.ts — the interface, ZodInternals, cloneWithDef). The two other hits (9088 pin, imported-defaults.ts) are comments. git grep -n WalkableDef origin/main -- . the same grep for node-derivation returns the two walkers' import lines and the 9102 pin's — the instrument sees imports.
2 Neither barrel imports node-derivation: src/index.ts 0 hits; src/zod/index.zod.ts 0 import hits (2 comment hits). package.json exports has 12 subpaths, no wildcard, no ./zod/node-derivation; no typesVersions. git grep on both barrels; packages/types/package.json index.zod.ts does re-export from ../strict-authoring-face.js (lines 572/576) — re-exports are visible to the same grep.
3 After tsc -p tsconfig.json (emit, exit 0, 0 errors): .d.ts import closure from each of the 12 exports types targets reaches dist/zod/node-derivation.d.ts from none (. closure 42 files; ./zod 24). The only .d.ts naming WalkableDef is dist/zod/node-derivation.d.ts; no export … from './node-derivation' anywhere in dist. The one node-derivation mention in dist/zod/imported-defaults.d.ts is copied JSDoc; that file's sole import is zod. closure script over from '…' / import('…') specifiers the ./zod closure does reach strict-authoring-face.d.ts (true) — the walker follows re-export chains.
4 Node exports resolution from a consumer directory: @object-ui/types RESOLVED (55 named exports); @object-ui/types/zod RESOLVED (211); …/zod/node-derivation.js, …/zod/node-derivation, …/dist/zod/node-derivation.js each REFUSED ERR_PACKAGE_PATH_NOT_EXPORTED. node --input-type=module dynamic imports through a node_modules/@object-ui/types symlink the two RESOLVED rows.
5 Installed zod 4.4.3 null literals: v4/classic/schemas.js 14, v4/core/schemas.js 14; CJS twins 15 each (= 30, the card's count; the extra is the __importStar helper's mod != null). Read line by line: the only literal minted into a def member is const rest = hasRest ? _paramsOrRest : null; (classic/schemas.js:878, inside tuple()). The rest are instance accessor defaults (inst.format = bag.format ?? null …), parse-time values (options[0]._zod.run : null, new Set([...values, null])), Object.create(null), comparisons, and the _null export alias. full grep -n '\bnull\b' listing over the four files a full listing, not a filtered zero.
6 Runtime: z.tuple([])._zod.def = {type:'tuple', items:[], rest:null}; a rest-less tuple carries an OWN rest key === null. node probe from packages/types rest-bearing tuple holds a node (pin test 2 passes at baseline).
7 Reads of def.rest at head: imported-defaults.ts 261 (def.rest ? walk(def.rest) : def.rest), 262 (raw pair into unchanged), 264 (def.rest ? { rest: rest! } : {}); strict-authoring-face.ts 200 (def.rest ? { rest: walk(…) } : {}). No == / ?? on rest in packages/types/src outside test prose. unchanged has 4 call sites (union, tuple, intersection, pipe); its body at head is before === after. git grep -n at 3713e80
8 Baseline at head: tsc --noEmit -p tsconfig.json 0 errors; tsc --noEmit -p tsconfig.test.json 0 errors; vitest under the repo config, new pin + 9088 pin: 2 files / 21 tests pass. scratchpad clone
9 Leg A (narrow rest back to z.ZodType): test project exit 2, exactly 1 error — walkable-def-null-mint-9491.test.ts(175,11): TS2322 Type 'true' is not assignable to type 'false'; src project exit 0. Leg D below: the src project can go red on this edit.
10 Leg B (widen out to | null): test project exit 2, 1 error — (186,11): TS2322 Type 'false' is not assignable to type 'true'. Leg A.
11 Leg D (revert only the unchanged widening): src project exit 2, exactly 3 TS2322 at imported-defaults.ts(262,24/87/97) — the tuple arm's [def.rest, rest] as const pair. Leg A src exit 0.
12 Leg C (expect ['element','rest']): vitest exit 1, AssertionError: expected [ 'rest' ] to deeply equal [ 'element', 'rest' ], 1 failed / 5 passed. baseline 6/6.
13 Leg E (mine — both source files reverted to base, new pin kept): vitest 6/6 pass; test project tsc exit 2 (1 TS2322). Leg A.
14 Leg G (mine — ===== in unchanged, PR applied): new pin + 9088 + 9034 + 8317 pins = 4 files / 159 tests all pass. Leg H (mine): 9088 pin against base sources with : undefined restored → exit 1, 2 failed / 13 passed ("… comes back REFERENCE-EQUAL" ×2). The suite is not blind to this file; G's green is a real absence.
15 CI on 3713e80: 36 check runs — Type Check, Lint, Test shards 1–4, Build & E2E, Changeset Declaration / Bump Policy / Fixed Group / Claim Re-read, Line Citation Gate, Governed Surface Queue Guard all success; only dependabot and the coverage variants skipped. ci.yml step Run type-check is pnpm type-checkturbo run type-check → the package script tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json, preceded by check-type-check-coverage.mjs, which fails any package whose tests no tsc program reads. GitHub check runs; .github/workflows/ci.yml; packages/types/package.json; turbo.json
16 Node kinds both walkers' switch names: 16 + lazy (handled before the switch). Distinct kinds the pin's 27-entry MATRIX actually builds: 21. Walker kinds absent from the matrix: custom, function, transform. Their installed defs carry no walkable member at all (custom: type,check,fn,abort; transform: type,transform; function: type,input,output). node probe from packages/types
17 The pin file reads no zod source (readFileSync / node:fs / schemas.js: 0 hits). grep over the test file
18 Test-local def mirrors still declaring rest?: z.ZodType: registry-meta-carry-9102.test.ts:82, imported-defaults-describe-9034.test.ts:93; each casts around it to read null — (d as { rest?: unknown }).rest !== undefined && d.rest === null (lines 226 / 189). imported-defaults-rest-less-tuple-9088.test.ts:90 already says | null. git grep '^\s*rest?:' at head
19 Changeset '@object-ui/types': patch; package name matches packages/types/package.json; fixed group of 39; sibling changesets 9088 and 9034 (same file area) are also patch; check-changeset-presence.mjs header: "An EMPTY frontmatter counts." .changeset/, config.json, script header

What I checked and what I found

1. Does the diff do what the card asks — no less, no more? Yes. The card's candidate repair is rest?: z.ZodType | null "and then check whether any read of def.rest in either walker needs adjusting". The diff makes that one-token change (plus JSDoc), leaves out alone (the card measured it clean; Leg B now pins it), leaves strict-authoring-face.ts untouched (its only read is a truthiness guard and it compiles at head, row 8), and adjusts exactly one read: the parameter type of the local unchanged comparator in imported-defaults.ts. That adjustment is forced, not chosen — Leg D yields three TS2322 without it, and Leg A shows the narrower declaration still compiles against the widened helper, so it is a distinct second edit the declaration change necessitates, not a restatement of it. The second imported-defaults.ts hunk is comment-only and the new text is accurate. No runtime statement changes; no schema changes; nothing outside the claim's declared file surface.

2. How big is the surface that actually moved? Internal. Measured three independent ways (rows 1–4): the interface is named in no barrel, is reachable from no exports entry's .d.ts closure (with the ./zodstrict-authoring-face.d.ts reach as the lit control), and Node refuses it under three spellings while both controls resolve. The bytes do ship (files: ["dist"] includes dist/zod/node-derivation.d.ts), so the only route to them is a legacy moduleResolution: node deep import into dist/, which is outside the package's declared contract. The Clause-② "yes" was therefore a conservative over-declaration; this is not a published-contract change, and the runtime accept set of every schema is unchanged.

3. Wrong thing made right, or made permanent? Made right. WalkableDef is the union bag of members across all node kinds, so rest?: (absent on non-tuples) stays correct and | null (an own key holding null on every tuple without a rest element, row 6) is what zod mints — the declaration now says exactly that and nothing more. The repair does not re-spell the inaccuracy: the tuple arm still hands def.rest through raw; no ??, no coercion, no call-site cast. The rest! assertion at the guarded cloneWithDef spread is pre-existing and sits inside a def.rest ? guard. This is the "pull a declaration back into line with what the runtime already does" case, not a 创业阶段不扩散 expansion.

4. Comparison / refusal behaviour preserved? Yes. unchanged's body is before === after at head (row 7); the widening is parameter-type only; === over z.ZodType | null | undefined has no coercion, so null matches only null and undefined only undefined. The 9088 identity behaviour still holds (baseline 21/21), and Leg H shows the 9088 suite reddens when the old normalisation is restored, so it is watching. The caveat (observation 3): nothing mechanical guards the === itself.

5. Are the pins real? Yes, with a division of labour the file itself states. The compile-time pins are the pin on the repair: Legs A and B each fail tsc -p tsconfig.test.json with exactly one TS2322 at the expected line, and CI runs that project (row 15). The runtime half probes the installed zod, not a stub: own-key + null + not-undefined, a rest-bearing control, and Leg C proves the uniqueness sweep reads the matrix rather than a constant. Leg E confirms the runtime half is invariant under a revert of the repair — by design, since a type can only be pinned by the compiler — so the gate for this change is type-check, not vitest.

6. Changeset. Correct package, correct level (patch: published dist/ bytes change; sibling patch precedent for this exact file area; empty frontmatter would also have been legal, so this is a choice, not an error), and every checkable claim in the prose is true against the tree: the z.ZodType | undefined reading; the zod factory line (row 5); "no behaviour change / accept set untouched"; "objectui#9088 already repaired the one arm" (: def.rest on base); the unchanged adjustment with === kept (rows 7, 11); the exports unreachability (rows 3–4). Two wording nits below.

Blocking items

None.

Non-blocking observations (not gating)

  1. The uniqueness matrix misses three kinds the walkers name. The pin's own comment says "a node kind added to either walker's switch belongs here on the same change", yet custom, function and transform are in both switches and not in MATRIX (row 16). Measured harmless today — their defs carry no walkable member — but the file does not meet its own rule at birth; three one-line matrix entries would close it.
  2. WALKABLE_MEMBERS is a hand-copied duplicate of WalkableDef's keys. It matches today (15/15). A member added to the interface but not to the list is silently excluded from the sweep. Deriving the list from an object typed Record<keyof WalkableDef, true> would make that omission a compile error.
  3. Nothing guards the === the PR's comments insist on. Leg G: relaxing unchanged to == leaves 159/159 green across the four neighbouring pins, and Leg H shows the suite is not blind to this file. There is no runtime-observable consequence today — a null/undefined cross-pair only arises if an arm normalises again — so this can only be pinned at source level, as the 9102 pin already does for other invariants. Optional, but it is the single most tempting future loosening now that the parameter admits null.
  4. The source-level zod census is prose only. The PR body says "both halves are now in the pin file"; the pin holds the runtime "rest is null" and "no other member on the matrix" halves, while the grep over zod's shipped tree (row 5) is not mechanised (row 17). Fine as a PR-body claim; not a re-derived one.
  5. Changeset wording. "no other member the walkers read ever is" claims more than a 27-node matrix supports ("across the node kinds the pin builds" would be exact); and the pin path is spelled types/src/__tests__/… while the same body spells packages/types/src/zod/node-derivation.ts in full — check-changeset-claims resolves it by suffix, so cosmetic.
  6. The same inaccuracy survives in two more test-local mirrors (row 18): registry-meta-carry-9102.test.ts and imported-defaults-describe-9034.test.ts still declare rest?: z.ZodType and each carries a (d as { rest?: unknown }) cast to read the null their own declaration refuses — the card's thesis demonstrated in two more places. Outside the card's stated subject (the shared type) and inside other cards' pin files, so correctly not touched here; but the PR's successor note names only the 9088 mirror. The follow-up should retire all three mirrors in favour of importing the now-honest WalkableDef, which also deletes both casts.
  7. License header. The new file's Licensed under the Apache-2.0 license line follows 36 sibling test files in this package, while the repo and package LICENSE are MIT. Pre-existing and repo-wide, not this PR's doing — noted because someone should own it.

⚠️ One deliberate departure from verbatim, disclosed rather than hidden: the reviewer's "How I measured" paragraph wrote the restore command with an angle-bracket-shaped placeholder after git checkout HEAD --. GitHub's sanitiser deletes tag-shaped fragments from a stored body even inside backticks, which would have silently eaten the identifier and left the sentence reading as though a bare git checkout HEAD -- were run. It is rendered above as the words "the path" instead. ⛔ No other character of the record was altered, and this note exists so the substitution is auditable rather than invisible.

— Record posted by PM domain:spec @ objectui · session session_01UanLVj6xvbS6puBCewLr8L · 2026-09-17T11:50Z


Generated by Claude Code

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(types): WalkableDef declares rest?: z.ZodType but zod 4 mints null there — the inaccurate declaration is what licensed objectui#9088

2 participants