Skip to content

非记忆化 z.lazy getter 让 zodShapeOf 每次解出**新实例**:桥项进得去、永远匹配不上(#6098 测量轮的副产品) #6221

Closed as not planned

Description

@os-zhuang

观察类发现,来自 #6098 的 #5056 测量轮实测。今天不产生任何错误答案,记录在案,免得下一轮把它当成新 bug 重新发现一次。

与 #5828 不是同一件事:那条说的是「门测量 testkit」里 cloneOverlap 比值的假可达面(src/ui/door-reachability.testkit.test.ts);这条说的是 scripts/build-schemas.ts 里 computeSurfaceReachability 的 bridged 表,机制是实例身份而不是重合度。同属 #5056 家族,不同仪器。

事实

zodChildSchemas 与 zodShapeOf 都会调用 z.lazy 节点的 def.getter(),而 Zod 的 lazy getter 不做记忆化——实测 getter() !== getter()。如果 getter 的函数体里现构造 schema(.extend(...)、.strict() 等),那么两次调用拿到的是两套不同实例的子图。

仓内的现成标本是 NavigationItemSchema(packages/spec/src/ui/app.zod.ts:570):

export const NavigationItemSchema = z.lazy(() =>
  z.discriminatedUnion('type', [
    ObjectNavItemSchema.extend({ children: z.array(NavigationItemSchema).optional()... }).strict()...,
    ...
  ])
);

后果,#6098 实测:

  • BFS 用 zodChildSchemas 走进去,入队的是第一次 getter 调用产出的实例;
  • 建 bridged 表时 zodShapeOf 又调一次 getter,拿到的 children 是全新实例——于是桥表里多出一个 (children, 从未被 BFS 访问过的实例) 对;
  • 该桥项谁也匹配不上:任何后续 zodShapeOf 调用都会再 mint 一个新实例,身份永远对不上。实测 1610 个 def 中 0 个命中它。

为什么今天不产生错误答案

误差方向朝假不可达(fail-open)一侧,而两个可能踩中的位置今天都被别的机制挡住了:

  • ui/NavigationItem 自己在 visited 里,reachableVia 先于 shape 判定返回 root-graph;
  • data/FilterArray(另一个 emitted 的 lazy def,visited=false)根本解不出 shape,落在 !shape 的 fail-closed 兜底上。

触发真实错误需要:一个 emitted def 是非记忆化 lazy、不在 BFS 闭包里、且解得出 shape——三条同时成立。今天没有这样的 def。

如果哪天要修

修法在生产侧或仪器侧,不是在消费端加容忍:

  • A. 记忆化 getter(lazySchema() 层面缓存一次),让「同一个 lazy 解出同一套实例」成为契约。最干净,但要确认 OS_EAGER_SCHEMAS 两种模式下都成立,且不改变 Zod 自身的解析语义。
  • B. zodChildSchemas / zodShapeOf 共享一次 getter 结果(walk 期间的 WeakMap 缓存),把不稳定性关在仪器内部。范围小,但只治这两个 walker。

两条都需要自己的测量轮(会动 bridged 表),所以不该顺手塞进任何别的 PR。

参考:#6098 · #5056 · #5828

Activity

  1. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    Findings sweep (maintainer-authorized one-off, 2026-08-07 — registered on #6015): hold confirmed. Fail-closed today (unresolvable shapes get a tombstone demand, never a false pass), so the instance-identity gap costs nothing yet. Restart: the first reachability verdict traced to lazy-getter instance identity, or the next zodShapeOf-family instrumentation card (pairs with #6232's cells).


    Generated by Claude Code

  2. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Findings round — hold re-confirmed (finding + domain:spec-tooling unchanged). Stale-premise check on origin/main @ 424316b:

    • The specimen is still live: NavigationItemSchema is still a bare (non-memoized) z.lazy(() => ...) at packages/spec/src/ui/app.zod.ts:656, and zod-graph.ts still calls the getter independently in the two walkers.
    • The error direction is still fail-open-but-shielded: the two candidate victims remain covered (ui/NavigationItem answers root-graph via visited; unresolvable shapes fall to the !shape fail-closed branch demanding tombstones), so the instance-identity gap still produces zero wrong answers.
    • The triple trigger condition (emitted def that is non-memoized lazy, outside the BFS closure, AND shape-resolvable) still has no instance in the repo.

    Restart unchanged: the first reachability verdict traced to lazy-getter instance identity, or the next zodShapeOf-family instrumentation card (pairs with #6232).

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Findings-triage re-check (quota round): HOLD maintained (finding stands, no label change).

    Premise re-verified on origin/main @ 2c28df9: the specimen anchor drifted (NavigationItemSchema = z.lazy now at packages/spec/src/ui/app.zod.ts:701, was :570 — recording the relocation for the next pass) and the bridged instance-identity table is live in packages/spec/scripts/build-schemas.ts (computeSurfaceReachability :980, bridged map :1000–:1009). Zod's lazy getter remains non-memoized, so the bridge-in/never-match mechanism stands; still produces no wrong answers today. Held.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  4. claude commented on Aug 10, 2026

    @claude
    Contributor

    Findings round (quota re-verification): HOLD maintained (finding + domain:spec-tooling unchanged). Re-checked @ origin/main a76a3af: the specimen is live at packages/spec/src/ui/app.zod.ts:701 — note the declaration gained an explicit type annotation (NavigationItemSchema: z.ZodType<NavigationItem, NavigationItemInput> = z.lazy(…)), which defeats an exact-form grep; it is still a bare non-memoized z.lazy. The bridged instance-identity map is live in build-schemas.ts (computeSurfaceReachability :980, bridged :1000). Mechanism stands, error direction still fail-open-but-shielded, zero wrong answers today. Restart unfired.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  5. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Findings cadence re-check @ origin/main 1530870: HOLD — finding stands.

    Premise live: packages/spec/scripts/lib/zod-graph.ts still has no memoization around the z.lazy getter (zero WeakMap/cache hits in the file; the :28 docblock still describes the getter/check-clone identity behavior). Held for the same reason as filed: measurement-tooling accuracy, no shipped behavior at stake; bundles naturally with #6232 (same file, same walker family) if promoted.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  6. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Findings cadence re-check @ origin/main 4ed4160: HOLD — finding stands, premise live (one anchor drifted, mechanism unchanged).

    NavigationItemSchema is still a non-memoized z.lazy — now at packages/spec/src/ui/app.zod.ts:701 (the card's :570 anchor has drifted with file growth; the declaration is otherwise the same discriminated-union-inside-getter shape). No memoization (WeakMap/cache) has appeared in scripts/lib/zod-graph.ts, and the file is untouched since filing. The three-conditions-simultaneously trigger (emitted + non-memoized-lazy + outside BFS closure + shape-resolvable) still has zero instances, so the error direction remains theoretical. Held: both fix routes (A memoized getter / B shared walk-scope cache) move the bridged table and need their own #5056 measurement round — same reason as sibling #6232; a promotion should probably take the two cards as one instrumented pass.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  7. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Findings triage round, 2026-08-11 (maintainer-directed). Graded: hold — instance-identity miss in the bridged table produces no wrong answers today (fail direction is conservative). Restart: same as its sibling #6232 — the next walker-family card takes both cells and this memoization fact in one instrument revision.


    Generated by Claude Code

  8. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Findings-round disposition: hold carried (premise re-verified: zero memoization in zod-graph.ts against a live getter() control; NavigationItemSchema still a bare z.lazy, now with an explicit z.ZodType<...> annotation that defeats exact-form greps).

    Pack recorded — PACK-zodgraph = {#6221, #6232}: same file (packages/spec/scripts/lib/zod-graph.ts), same walker family, one shared acceptance criterion: one instrumented pass over zod-graph.ts (memoize the lazy getter; align the depth cap and the array/record descent) with a gen:schema before/after diff explaining every moved verdict and every new bridge entry in both directions. #5828 explicitly does NOT join (different file, different mechanism — overlap ratio vs instance identity).

    Restart condition: any commit touches packages/spec/scripts/lib/zod-graph.ts — promote the pack then, as one sweep card, one measured pass.

    本评论来自分诊座位(scheduled session session_01DLzE5XVRASxFotEU1h9cjH),不构成认领。


    Generated by Claude Code

  9. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Finding-grading round: closed, not planned (pack-closed with #6232 — see the close-out there for the carried acceptance criterion).

    Trigger requires three conditions simultaneously (emitted + non-memoized lazy + outside the BFS closure + shape-resolvable) — zero instances across 1610 defs, error direction fail-open-but-shielded, no pull across 8 triage passes. The one sentence worth keeping (non-memoized z.lazy getters yield fresh instances per resolution, so identity-keyed bridging can never match) belongs in zod-graph.ts's docblock via the rider named on #6232.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions