fix(spec): the page-component conversion walk reaches nested containers, not only regions and slots (#6775) - #7034
Conversation
…rs, not only regions and slots (#6775) `mapPageComponents` visited `pages[].regions[].components[]` and `pages[].slots.<slot>` and stopped. A component nested inside another component's `properties` — a card's `children` / `body` / `footer`, a `page:tabs` / `page:accordion` panel's `items[].children` — was visited by nobody, so no page-component conversion rewrote it, while `walkPageComponents` in `packages/lint` has descended into those containers from the start. Every conversion therefore reached strictly less than the lint rule that judges its result. The walk now descends into the same containers lint does, to any depth, with the same path spelling, so a conversion notice and a lint finding name one site with one string. The mapper still runs on the container first and the descent reads the MAPPED component, so `page-card-body-to-children` — which moves `properties.body` to `properties.children` — walks its sub-tree exactly once, under the canonical key. Copy-on-write is unchanged: an untouched sub-tree keeps its reference, and a stack where nothing converts is returned by identity. The load-path cost this fixes belongs to `page-header-subtitle-alias`. Every other entry leans on a tombstone for the sites a conversion cannot reach; this one has none, because `description` stays a live declared prop on other components, so `properties.description` parses green at any position. A header authored in a card or on a `kind: 'slotted'` record page got no rewrite and no diagnostic from any layer. Fixtures for all seven walker-backed conversions now pin the slotted and nested positions beside the region-level one, and a cross-walker parity test in `packages/lint` — the only place that can see both walkers — asserts the two reachable sets agree, pinning the two deliberate differences: lint skips source-authored pages (the conversion must still normalize their derived cache) and the conversion walk keeps a 32-container depth ceiling for hand-built `defineStack` objects. No conversion registry entry added or removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd32yJ2omZsgUXxiAjeBcE
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #6775
Premise re-verified against
origin/main@0f539bd— half of it had already landedThe card measures two gaps. Measured before touching anything, with the built spec of this worktree (
applyConversions(..., { includeRetired: true })+PageSchema.safeParse):origin/mainregions[].components[](baseline)slots.headeron aregions: []slotted pagepage:card→properties.children[]page:tabs→properties.items[].children[]page:card→properties.body[]/footer[]slots.header→ nestedchildren[]So
premise_still_valid: true, but narrowed: theslots.*half was fixed by #6776 (the walker and its doc already carry it). What remained live is container nesting, and that half reproduced exactly as the card describes — spec-valid, unconverted, silent.What changed
mapPageComponentsnow descends into the containers a component nests its sub-tree in, matchingwalkPageComponents(packages/lint/src/page-walk.ts) key for key and path spelling for path spelling:properties.children[]— generic layout nestingproperties.items[].children[]—page:tabs/page:accordionpanelsproperties.body[]/properties.footer[]—page:cardRecognised by shape (an array), not by component
type— the same rule lint applies, becausepropertiesis an open bag that nothing validates per-type on the load path. Arecord:alert'sbody: 'Confirm the work.'string is not an array and is never mistaken for a slot.The mapper runs on the container first and the descent reads the mapped component. That is what keeps the walk single-visit under
page-card-body-to-children, which moves a container key (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 is…properties.children[0].properties.children, never…body[0]….Copy-on-write is unchanged and pinned: an untouched nested branch keeps its reference, and a stack where nothing converts is still returned by identity.
Why this was load-bearing rather than tidy
The standard answer for a site a conversion cannot reach is the tombstone — the key is typed
never, sotscrefuses it at the authoring site and the parse refuses it at load, wherever it sits. That answer does not hold for a key that stays live elsewhere on the surface.page-header-subtitle-aliasretiresdescriptionon page-header components whiledescriptionremains a declared prop on others (element:text_inputhelper text), so it cannot be tombstoned:properties.descriptionparses green at any position. A header inside a card, or on the slotted record page objectui's own guide prescribes, got no rewrite and no diagnostic from any of the three layers — conversion silent,PageSchemasatisfied, props gate advisory/CLI-only and running on already-converted metadata.Reachable-set difference vs. the lint walker (enumerated, as asked — not silently widened)
Two differences remain after this change, both deliberate, both pinned by the parity test:
kind: 'html' | 'react' | 'jsx') — lint skips them, the conversion still visits them. 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 feat(spec): 登记 ADR-0087 D2 conversionpage-header-subtitle-alias(description→subtitle) #5509, so it was not done.defineStackobjects, where a self-referencingchildrenis reachable; mirrorsMAX_REGION_DEPTHfor flow regions.Nothing else differs: the parity test walks a page carrying the probe at all eight positions lint yields and asserts the conversion's notice paths are the same set.
Fixture coverage — reverse verification, direction predicted first
Predicted before measuring: with the new fixtures/tests in place and the walker reverted to
origin/main, every fixture pair for a walker-backed conversion and every nested walker test goes RED, while region-level and slot-level assertions stay GREEN.Measured (
git checkout HEAD -- walk.ts, runsrc/conversions): 16 failures, 181 passing — exactly 7 fixture pairs + 9 nested walker tests; region- and slot-level assertions green throughout. Restoring the walker: 197/197 green.The 7 conversions whose fixtures now carry a
slots.*node and a container-nested node:page-header-subtitle-aliasrecord-picker-display-field-to-label-fieldrecord-picker-inert-keys-removedpage-card-body-to-childreninline-action-api-params-to-body-extrapage-tabs-type-to-tab-stylepage-component-visibility-to-visibleWhen(v15, same walker)A second, independent RED→GREEN data point: the new cross-walker parity test in
packages/lintresolves@objectstack/specfromdist, so it first ran against the old built walker and failed on precisely the four nested positions; after rebuilding spec it passes.New tests:
packages/spec/src/conversions/page-component-walk.test.ts— parity per container shape, three-deep recursion, nesting inside a named slot, single-visit under the moved container key, copy-on-write + reference sharing, shape gating (prosebody, tab records, non-dict children), cycle termination, and the depth ceiling (converts at 32, stops at 33, no throw).packages/lint/src/page-walk-conversion-parity.test.ts— the cross-walker guard described above.Verification
Dependency closure built first (#6371):
turbo run build --filter='./packages/*' --filter='./examples/*^...'— 66/66. Consumer sweep run in the'…@objectstack/spec'direction (#6218), i.e. from the spec walker outward to its consumers:metadata/database-loader,metadata-protocol(protocol,stored-migration,runtime-authoring-gate),objectql(plugin,rule-validator),service-datasource— all exercise the conversion entry points, none pins region-only reach; their suites are green below.Every
check:*gate in.github/workflows/lint.yml, enumerated one by one — 68 steps, all PASS, includingpnpm lint,pnpm --filter @objectstack/spec exec tsc --noEmit,check:generated --reconcile-only,check:spec-changes,check:upgrade-guide,check:authorable-surface,check:api-surface,check:docs, bothturbo buildlegs,turbo typecheck(121/121), examples + downstream-contract typecheck. Plusnode scripts/check-adr-0087-registration.mjs --base origin/main→ ✓ (no declared-breaking changeset).Test suites in full:
@objectstack/spec9127/9127 ·@objectstack/lint1771/1771 ·@objectstack/metadata-protocol820/820 ·@objectstack/cli1095/1095 ·@objectstack/metadata588/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 undercontent/docs/referenceschanged, so there was nothing togit add.Scope
Walker + fixtures + tests + changeset. No conversion registry entry added or removed (
CONVERSIONS_BY_MAJORis byte-identical) — sibling territory per #6815 / #5488. Doc comments on the entries that claimed "region level is the reach, deliberately" were corrected, since the change makes them false.Out of scope, noted not acted on:
packages/spec/src/system/i18n-resolver.ts:897documents its own region-level-only component visit forpage:header. That is a separate walker with its own contract; widening it is not this card's business.🤖 Generated with Claude Code
https://claude.ai/code/session_01Cd32yJ2omZsgUXxiAjeBcE
Generated by Claude Code