Skip to content

conversions:page-component 改写只走 regions[].components[] —— slots.* 与容器嵌套(lint 的 walkPageComponents 会下钻)全部漏改,page-header-subtitle-alias 因此覆盖不到 spec-valid 的 header 节点 #6775

Description

@yinlianghui

发现于 objectui#3789 的测量阶段(退役 objectui PageHeader 的 subtitle ?? description 回退)。该单的门是「证明每一条 page-header 元数据路径都经过执行改写的 loader」。门没过,原因在上游:conversion 层的 page-component 走查面比同仓 lint 层的走查面窄一大截,page-header-subtitle-alias 因此改不到一批 spec-valid 的 header 节点。

两个走查器,覆盖面不同

位置 覆盖
conversion 层 packages/spec/src/conversions/walk.ts:193 mapPageComponents 只有 pages[].regions[].components[],一层,到此为止
lint 层 packages/lint/src/page-walk.ts walkPageComponents regions[].components[] 加 page.slots.*、properties.children[]、properties.items[].children[](tabs/accordion)、properties.body[] / properties.footer[](card),深度递归

page-walk.ts 的模块注释把这件事说得很清楚:PageComponentSchema 是 .strict(),组件自身没有 children 键,子树都在无类型的 properties 包里,所以递归必须手写。lint 手写了;conversion 没有。walk.ts:222-236 把这个边界写成了刻意选择(「Region level is the whole surface a page-component conversion can reach」),但它成立的前提是「嵌套处的键会被 tombstone 在 parse 期挡掉」——description 不在 PageHeaderProps 里被 tombstone,而 properties 是自由 bag,所以什么都没挡。

实测(objectui 实装的 @objectstack/spec@17.0.0-rc.5)

applyConversionsToStoredItem('pages', row) + PageSchema.safeParse(row):

形状 spec-valid 被 page-header-subtitle-alias 改写
regions[].components[].properties.description 是 是(基线,正常)
slots.header.properties.description(regions: []) 是 否
regions[].components[] 里 page:card 的 properties.children[].properties.description 是 否

第二行是最严重的一条:那正是 objectui 文档里推荐的定制 record 页头的写法(objectui content/docs/guide/slotted-pages.md,slot 表第一行 header → page:header,示例显式写 regions: []「slotted pages don't author regions」)。slotted 页把 regions 留空,mapPageComponents 于是一个节点都不访问。

复现片段(节点用 properties bag,因为 PageComponentSchema 是 strict,内联键会报 unrecognized_keys):

const slotted = {
  name: 'account_detail_page', label: 'Account Detail',
  type: 'record', object: 'account', kind: 'slotted',
  regions: [],
  slots: { header: { type: 'page:header', properties: { title: '{name}', description: 'x' } } },
};
PageSchema.safeParse(slotted).success                     // => true
applyConversionsToStoredItem('pages', slotted)            // => properties.description 原样留存,零 notice

影响

  1. page-header-subtitle-alias 的退役承诺兑现不了。 该条目的 doc 写「objectui#3226 deletes its ?? once this ships」,但 slots / 容器嵌套两处它改不到。objectui 侧删掉 subtitle ?? description 会让这些页面静默丢副标题(标题照渲染,第二行消失,不报错)—— 正是该条目选 conversion 而非 deletion 所要避免的那个失败形状。objectui#3789 据此停在 needs_decision。
  2. 不止这一条。 任何建在 mapPageComponents 上的 page-component conversion 都有同一个洞。其它几条靠 tombstone 兜底(嵌套授权点 tsc 会红),page-header-subtitle-alias 没有 tombstone 可靠 —— PageHeaderProps 里没有 description,而 properties 不按 type 校验,所以 properties.description 在任何位置都 parse 通过。
  3. 授权期也没有信号。 validateComponentProps 是 advisory + CLI_ONLY,且跑在 normalized 上(authoring-rules.ts:538-575 注释里明说:靠 conversion 先改写,所以改写过的别名「is never reported as undeclared」)。改不到的那些位置,conversion 不报、lint 不报(它虽然下钻得到,但 advisory 且只在 CLI)、schema 不报。三层皆无。

建议方向(留给 triage 定档)

倾向把 conversion 的走查面对齐 lint 已经写好的那一份 —— 让 mapPageComponents 复用 / 收敛到 page-walk.ts 同形的下钻(slots.* + properties.children / items[].children / body / footer),而不是在消费端加容忍。理由:

  • 契约优先:声明的覆盖面应当等于渲染器实际服务的面。现在「同一个 description,放 region 一层就规范化、放 slot 里就不规范化」是位置决定语义,这条规则没人记得住。
  • 让 AI 写的元数据难写错:slotted 页与容器嵌套恰恰是模型生成 page 元数据最常用的两种形状(apps/console 的 preview-samples 注释自己也写了「these samples are copied — increasingly by models generating metadata」)。位置敏感的规范化是这类错误的藏身处。
  • 该条目本身已按 PAGE_HEADER_COMPONENT_TYPES 做了 type gate,所以下钻不会碰到 element:text_input 那类另有真 description 的组件 —— walk.ts 划这条边界时担心的跨组件误伤,在这一条上已被 type gate 挡住。

另一条可行路线是把 description 在 PageHeaderProps 上做成 tombstone,让嵌套授权点在 parse 期被点名拒绝(与 record-picker 三键同构),但那会让 properties 需要按 type 真正校验,是更大的一步(与 #4001 相邻)。

Refs

objectui#3789(被本单阻塞的退役单)、objectui#3226(原裁定)、#4827(本 conversion 的登记单)、#5509、#5511(mapPageComponents 收敛单)、#5068 / #4001(properties 无门的历史)、ADR-0087 D2。

—— 由 objectui#3789 的测量阶段发现,未在该单改任何代码。

Activity

  1. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Triage (routing only — pm:queue was already set by the filer): + domain:spec.

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


    Generated by Claude Code

  2. self-assigned this
    on Aug 9, 2026
  3. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 2 (maintainer-approved cloud dispatch, 2026-08-09 「同意」; this seat's standing v17-board recommendation — migration-machinery correctness, class ③)
    Session: session_01PiRUoQkTSBBmpyXBY3cVn2 (dispatching); implementation runs as an independent cloud session (own container)
    Branch: claude/issue-6775-conversion-walker-slots
    Worktree: cloud session workspace (solo container)
    Domain: domain:spec
    File surface: the page-component conversion walker in packages/spec/src/conversions/ (walk slots.* and nested containers like lint's walkPageComponents does), affected conversion fixtures (page-header-subtitle-alias and every page-component conversion the walker feeds), conversion tests. (Stop on breach; explain in the report.)
    Serial constraints cleared: in-flight siblings — ⚠️ #6815/#6704/#5488 also touch packages/spec/src/conversions|migrations registries at landing ⇒ rides the landing relay, ordered at collection; source files disjoint (walker vs registry entries).
    Container assessment: M-L (conversion-matrix replay + full regen radius), mode:cloud single container per the sizing rule.


    Generated by Claude Code

  4. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    OS-DEV-REPORT

    {
      "issue": 6775,
      "premise_still_valid": true,
      "premise_note": "Half the card had already landed. Re-measured on origin/main @ 0f539bd with the built spec: the `slots.*` gap was closed by #6776 (a header in `slots.header` on a `regions: []` slotted page IS converted today). The CONTAINER-NESTING gap is live and reproduced exactly as filed: `properties.children[]`, `properties.items[].children[]`, `properties.body[]`, `properties.footer[]` — all spec-valid, none converted, no diagnostic from any layer. That live half is what this PR fixes.",
      "status": "delivered",
      "branch": "claude/issue-6775-conversion-walker-slots",
      "head_sha": "95075dd1c2a657e0bc607ded920af4babf29b238",
      "pr": 7034,
      "pr_url": "https://github.com/objectstack-ai/objectstack/pull/7034",
      "pr_state": "draft",
      "registry_entries_changed": 0,
      "registry_note": "CONVERSIONS_BY_MAJOR is byte-identical — verified by diffing the table against origin/main. No entry added, removed or reordered. Diff is walker + fixtures + tests + changeset only, as scoped.",
    
      "change_surface": {
        "packages/spec/src/conversions/walk.ts": "mapPageComponents descends into the container keys walkPageComponents descends into (children / items[].children / body / footer), to any depth, same path spelling. New private helpers mapComponentTree + mapComponentList; MAX_COMPONENT_DEPTH = 32 mirroring MAX_REGION_DEPTH. Mapper runs on the container FIRST and the descent reads the MAPPED component.",
        "packages/spec/src/conversions/registry.ts": "Fixture `before`/`after` extended for all 7 walker-backed conversions with a slot-level and a container-nested node; doc comments that asserted 'region level is the reach, deliberately' corrected (they became false).",
        "packages/spec/src/conversions/page-component-walk.test.ts": "NEW — per-container parity, 3-deep recursion, nesting inside a named slot, single-visit under a moved container key, copy-on-write + reference sharing, shape gating, cycle termination, depth ceiling.",
        "packages/lint/src/page-walk-conversion-parity.test.ts": "NEW — cross-walker reachable-set guard. lint is the only package that can see both walkers (it depends on spec, not vice versa). Asserted behaviourally, not by comparing implementations.",
        ".changeset/conversion-walk-nested-page-components.md": "patch @objectstack/spec"
      },
    
      "reverse_verification": {
        "direction_predicted_before_measuring": true,
        "prediction": "With the new fixtures/tests in place and walk.ts reverted to origin/main: every fixture pair for a walker-backed conversion RED, every NESTED walker test RED, region-level and slot-level assertions GREEN.",
        "measured": "16 failed / 181 passed — exactly 7 fixture pairs + 9 nested walker tests. Region- and slot-level assertions green throughout. Walker restored: 197/197 green.",
        "match": "exact",
        "second_independent_datapoint": "The lint parity test resolves @objectstack/spec from dist, so its first run hit the OLD built walker and failed on precisely the four nested positions (and on the page-card body→children notice leaking in, which the final test filters by conversionId). After rebuilding spec: green."
      },
    
      "fixture_coverage": {
        "page-header-subtitle-alias": {"notices": "2 -> 7", "added": ["slots.header on kind:slotted", "card children[]", "card footer[]", "tabs items[].children[]", "two-deep card-in-tab"]},
        "record-picker-display-field-to-label-field": {"notices": "2 -> 4", "added": ["card children[]", "slots.details[] (array slot)"]},
        "record-picker-inert-keys-removed": {"notices": "2 -> 4", "added": ["tabs items[].children[]", "slots.details (single)"]},
        "page-card-body-to-children": {"notices": "1 -> 4", "added": ["card nested in card via the MOVED key", "slots.details"]},
        "inline-action-api-params-to-body-extra": {"notices": "1 -> 3", "added": ["submit button in card footer[]", "slots.actions[]"]},
        "page-tabs-type-to-tab-style": {"notices": "3 -> 5", "added": ["tabs in card children[]", "tabs in a tab panel"]},
        "page-component-visibility-to-visibleWhen": {"notices": "1 -> 3", "added": ["card children[]", "slots.highlights"], "note": "toMajor 15, not in the 17 chain, but rides the same walker — covered so its doc claim does not go stale."}
      },
    
      "walker_semantics": {
        "container_keys": ["properties.children[]", "properties.items[].children[]", "properties.body[]", "properties.footer[]"],
        "recognition": "by SHAPE (an array), not by component `type` — same rule lint applies, because `properties` is an open bag nothing validates per-type on the load path. A record:alert's prose `body` string is not an array and is never mistaken for a slot; non-dict elements pass through.",
        "ordering": "mapper first, then descend into the MAPPED component. This is what keeps the walk single-visit under page-card-body-to-children, which MOVES `properties.body` -> `properties.children`: the sub-tree is walked once under the canonical key, not once per spelling. Pinned by a test asserting the inner notice path reads `...properties.children[0].properties.children`.",
        "copy_on_write": "unchanged and pinned — untouched nested branch keeps its reference; a stack where nothing converts is still returned by identity."
      },
    
      "remaining_reachable_set_difference_vs_lint": [
        {
          "difference": "source-authored pages (kind: 'html' | 'react' | 'jsx')",
          "lint": "skips them",
          "conversion": "still visits them",
          "why_not_matched": "lint's skip prevents findings about a DERIVED region cache the author never wrote; a conversion still has to normalize that cache or a stored page rehydrates in a shape the runtime no longer serves. Matching lint here would REMOVE reach conversions have had since #5509 — a narrowing, not the widening this card asked for. Pinned as a deliberate difference by the parity test.",
          "enumerated_in_pr_body": true
        },
        {
          "difference": "MAX_COMPONENT_DEPTH = 32",
          "lint": "no ceiling (walks parsed JSON only)",
          "conversion": "ceiling, mirroring MAX_REGION_DEPTH",
          "why_not_matched": "this walker also runs on hand-built defineStack objects, where a self-referencing `children` is reachable on the load path.",
          "enumerated_in_pr_body": true
        }
      ],
    
      "verification": {
        "dependency_closure_built_first": "turbo run build --filter='./packages/*' --filter='./examples/*^...' — 66/66 (#6371)",
        "consumer_sweep": {
          "direction": "'...@objectstack/spec' — outward from the spec walker to its consumers (#6218)",
          "consumers_examined": ["metadata/loaders/database-loader", "metadata-protocol/{protocol,stored-migration,runtime-authoring-gate}", "objectql/{plugin,rule-validator}", "service-datasource/datasource-admin-plugin"],
          "finding": "all reach the conversion layer through applyConversions / applyConversionsToStoredItem; none pins region-only reach; all suites green."
        },
        "lint_workflow_gates": "all 68 run steps enumerated from .github/workflows/lint.yml one by one — every one PASS (pnpm lint; spec tsc --noEmit; check:generated --reconcile-only; check:spec-changes; check:upgrade-guide; check:authorable-surface; check:api-surface; check:exported-any; check:dual-source-exports; check:docs; check:skill-*; both turbo build legs; turbo typecheck 121/121; examples + downstream-contract typecheck; i18n trio; and the rest)",
        "adr_0087_registration": "node scripts/check-adr-0087-registration.mjs --base origin/main -> OK (no declared-breaking changeset)",
        "conversion_fixture_suite": "packages/spec src/conversions — 197/197",
        "full_suites": {
          "@objectstack/spec": "9127/9127",
          "@objectstack/lint": "1771/1771",
          "@objectstack/metadata-protocol": "820/820",
          "@objectstack/cli": "1095/1095",
          "@objectstack/metadata": "588/588"
        },
        "generated_trees": "produced by generators only (gen:schema, gen:docs, gen:spec-changes, gen:upgrade-guide, gen:skill-docs, gen:skill-refs, gen:api-surface) — NO drift; nothing under content/docs/references changed, so there was nothing to `git add`."
      },
    
      "ci": {
        "converged": true,
        "total_checks": 26,
        "success": 24,
        "skipped": 2,
        "failure": 0,
        "ESLint": "success",
        "TypeScript Type Check": "success",
        "other": "Build Core, Test Core (1/3, 2/3, 3/3) + aggregate, Dogfood Regression Gate (1/3, 2/3, 3/3) + aggregate, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Check Changeset, Check PR Size, Spec property liveness, ADR maintainer approval, Console Pin Freshness, Check Documentation Links, docs-drift flag, Auto Label — all success. Skipped: Build Docs, Console Pin Gate (not triggered by this diff)."
      },
    
      "downstream_unblocked": "objectui#3789 (the PageHeader `subtitle ?? description` retirement) was stopped at needs_decision on this blocker. With slots (#6776) + containers (this PR), page-header-subtitle-alias now reaches every spec-valid header position, so the consumer-side `??` can retire without pages silently losing their second line. objectui#3789 should re-run its measurement against a spec build carrying this commit before deleting the fallback.",
    
      "open_questions": [
        {
          "decided": "Covered page-component-visibility-to-visibleWhen (toMajor 15) even though the brief scoped 'the 17 chain'. It rides the same walker, so its behaviour changed either way and its doc asserted region-only reach — leaving that unfixtured and undocumented would have shipped a false comment. Ruled inside scope: no registry change, fixture + doc only."
        },
        {
          "decided": "Did NOT narrow the walker to skip source-authored pages for lint parity. Requirement said descend where lint descends and enumerate any remaining difference; skipping pages would be a REMOVAL of existing conversion reach, which is a behaviour change beyond this card. Enumerated in the PR body and pinned by test instead."
        },
        {
          "decided": "Container keys recognised by shape, not gated on component `type`. Type-gating would have needed a type->container-keys table that lint does not have, and would silently miss the layout containers (`type: 'flex'` grids in the showcase) that compose `children` without declaring it in any props schema."
        }
      ],
    
      "noted_not_acted_on": [
        "packages/spec/src/system/i18n-resolver.ts:897 documents its own region-level-ONLY component visit for page:header — a separate walker with its own contract. Same class of position-dependence, out of this card's scope; worth a card of its own if slotted/nested headers should be translatable.",
        "packages/spec/src/kernel/metadata-authoring-lint.ts:211 describes `page.regions[].components[]` roots — likewise a different walker, unexamined here."
      ],
    
      "landing": "Not merged, per the relay. Draft PR #7034 is green and ready for the PM's serialized landing with the sibling spec PRs (#6815 / #6704 / #5488). Source files are disjoint from theirs (walker + fixtures vs registry entries); if main moves, resolve by MERGE (never rebase/force-push) plus the four-step regen for merge=os-regen paths."
    }

    Generated by Claude Code

  5. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    ACCEPT → PR #7034 (Fixes #6775). Verified against GitHub: 5-file surface exactly as scoped (walker + registry FIXTURES + two test files + changeset; CONVERSIONS_BY_MAJOR byte-identical — the no-registry-entries boundary held); CI 26 checks — 24 success + 2 skips, ESLint and TypeScript Type Check both success.

    The premise verification is the model case: half the card was already dead — #6776 closed the slots.* gap in flight — and the dev measured that, said so, and fixed only the living half (container nesting), instead of either re-doing landed work or treating the whole card as stale. Reverse verification hit its predicted 16-red set exactly, with a second independent datapoint (the lint-parity test resolving the OLD dist). The two remaining reachable-set differences vs lint are enumerated AND pinned as deliberate, not silently left. The cross-package parity test living in packages/lint (the only package that can see both walkers) is the durable part of this fix.

    Downstream: objectui#3789 (PageHeader subtitle ?? description retirement, stopped at needs_decision on this blocker) is now unblocked — notified on its thread to re-measure against a spec build carrying this commit.

    Landing: queue, second of the three cloud PRs.


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions