Repository navigation
conversions:page-component 改写只走 regions[].components[] —— slots.* 与容器嵌套(lint 的 walkPageComponents 会下钻)全部漏改,page-header-subtitle-alias 因此覆盖不到 spec-valid 的 header 节点 #6775
Description
Activity
Triage (routing only —
pm:queuewas already set by the filer): +domain:spec.- Anchor verified on
origin/main@e1e7629:mapPageComponentsatpackages/spec/src/conversions/walk.ts:193walks onlyregions[].components[]; the lint-side walker inpackages/lint/src/page-walk.tsrecurses intoslots.*and containerproperties— the coverage asymmetry the card measures. - Lane rationale: the fix widens the conversion walker's reach in
packages/spec/src/conversions/— conversion rewrite behavior is the semantic face of the contract (what stored metadata gets normalized), not describe/JSDoc text ⇒domain:spec, notspec-surface. The alternative route named in the body (tombstoningdescriptiononPageHeaderProps) would change the acceptance surface, which is alsodomain:specterritory — either direction lands in the same lane. - Dedup: referenced conversions:
page-component-visibility-to-visibleWhen就地展开了 region→components 的 copy-on-write 走查,与新的mapPageComponents是两份同形代码 #5511 (themapPageComponentsconvergence card) is CLOSED completed; [移交自 objectui] 登记 ADR-0087 D2 conversion 条目page-header-subtitle-alias(description→subtitle) #4827/feat(spec): 登记 ADR-0087 D2 conversionpage-header-subtitle-alias(description→subtitle) #5509 are the registration lineage, not shadows. objectui#3789 is the blocked downstream retirement, correctly stopped at needs_decision on its side — this card is that blocker's dispatch entry, single entry confirmed. - No
target:v17: the user-visible failure (silent subtitle loss) only materializes if objectui#3226's??fallback is deleted, and objectui#3789 is holding exactly that until this lands.
本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
- Anchor verified on
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 inpackages/spec/src/conversions/(walkslots.*and nested containers like lint'swalkPageComponentsdoes), affected conversion fixtures (page-header-subtitle-aliasand 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 touchpackages/spec/src/conversions|migrationsregistries 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:cloudsingle container per the sizing rule.
Generated by Claude Code
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
ACCEPT → PR #7034 (Fixes #6775). Verified against GitHub: 5-file surface exactly as scoped (walker + registry FIXTURES + two test files + changeset;
CONVERSIONS_BY_MAJORbyte-identical — the no-registry-entries boundary held); CI 26 checks — 24 success + 2 skips, ESLint and TypeScript Type Check bothsuccess.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 inpackages/lint(the only package that can see both walkers) is the durable part of this fix.Downstream: objectui#3789 (PageHeader
subtitle ?? descriptionretirement, 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
发现于 objectui#3789 的测量阶段(退役 objectui
PageHeader的subtitle ?? description回退)。该单的门是「证明每一条 page-header 元数据路径都经过执行改写的 loader」。门没过,原因在上游:conversion 层的 page-component 走查面比同仓 lint 层的走查面窄一大截,page-header-subtitle-alias因此改不到一批 spec-valid 的 header 节点。两个走查器,覆盖面不同
packages/spec/src/conversions/walk.ts:193mapPageComponentspages[].regions[].components[],一层,到此为止packages/lint/src/page-walk.tswalkPageComponentsregions[].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):page-header-subtitle-alias改写regions[].components[].properties.descriptionslots.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于是一个节点都不访问。复现片段(节点用
propertiesbag,因为PageComponentSchema是 strict,内联键会报unrecognized_keys):影响
page-header-subtitle-alias的退役承诺兑现不了。 该条目的 doc 写「objectui#3226 deletes its??once this ships」,但 slots / 容器嵌套两处它改不到。objectui 侧删掉subtitle ?? description会让这些页面静默丢副标题(标题照渲染,第二行消失,不报错)—— 正是该条目选 conversion 而非 deletion 所要避免的那个失败形状。objectui#3789 据此停在 needs_decision。mapPageComponents上的 page-component conversion 都有同一个洞。其它几条靠 tombstone 兜底(嵌套授权点tsc会红),page-header-subtitle-alias没有 tombstone 可靠 ——PageHeaderProps里没有description,而properties不按type校验,所以properties.description在任何位置都 parse 通过。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 里就不规范化」是位置决定语义,这条规则没人记得住。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 的测量阶段发现,未在该单改任何代码。