Skip to content

Commit 315888d

Browse files
feat(spec,lint): one derived list of page-component slot positions, read by all three page walks (#20940) (#20961)
Closes #20940 Clause-②: yes (widening) > The declaration above is the seat's: ruling `5921583377` took the dev's recommendation, and the claim line was corrected in place to match (`scripts/pm/clause2-line.mjs:90`: two new exports expand the public surface). The reasoning is in "Clause ② re-read" below. ## What this does Three page walks descend into a component's `properties` bag, and each kept its own list of positions: - the ADR-0087 conversion walker: a module-private `COMPONENT_CHILD_KEYS` plus `items[].children`; - `@objectstack/lint`'s `walkPageComponents`: `items[].children`, `children`, `body`, `footer`; - the exported `walkAddressedPageComponents`: `children`, `items[].children`. So a node in a `page:card` footer was judged by `os lint` and skipped by every consumer of the exported walk: `translatePage`, the CLI extractor and objectui's validator. Now `packages/spec` owns **one** list, derived from the component rows. There is no fourth list. - **The rows declare the fact.** `componentSlot()` / `retiredComponentSlot()` are module-private markers in `ui/component.zod.ts`. Each registers the exact schema instance a row's shape holds, and returns it unchanged, so the parse and the JSON Schema are the unmarked schema's. Five keys are marked `componentSlot`: `PageContainerProps.children`, `PageCardProps.children`, `PageCardProps.footer`, and `children` on the `page:tabs` and `page:accordion` panel items. `PageCardProps.body`, a `retiredKey` tombstone, is marked `retiredComponentSlot`. - **`pageComponentSlotPositions()` derives the list** from `ComponentPropsMap`. It is exported with its type `PageComponentSlotPosition` from `@objectstack/spec/ui`. It returns `children`, `body` (retired), `footer`, and the panel position `items[].children`. Order is the visit order: direct slots in row order, then panels, so `children` is still walked before `items[].children`, the order the exported walk's nested-id arbitration has used since #16772. The list is derived on first call and memoized, never at import. The first call reads every row's shape, which constructs the `lazySchema` rows: measured 17.6 ms, once per process. Two rows that contradict each other on one position throw. - **All three walks read it.** The conversion walker reads every entry. `walkAddressedPageComponents` and lint's `walkPageComponents` read the authorable entries, so `page:card.footer` is now descended by all three. - **`body` is handled per the standing ruling.** #5775 (maintainer ruling 2026-08-06, direction A, recorded in `docs/protocol-upgrade-guide.md` and in the `PageCardProps.body` tombstone and the `PageContainerProps` guidance) made `children` the one composition key. It kept the renderers' `body` read as a back-compat fallback for stored documents, not as an authorable spelling. So: - The exported walk still does not descend `body`. - **Lint's descent of `body` was re-read against that ruling, as the claim asked, and removed.** An author who writes `body` is refused by the tombstone's own rename prescription. Judging the sub-tree under a refused key as if it were authored is what the ruling rules out. After the rename the sub-tree is judged under `children`. - The conversion walker keeps descending `body`. Stored documents still carry it, and a conversion ordered before `page-card-body-to-children` (for example `page-header-subtitle-alias`) meets the sub-tree there. `page-component-walk.test.ts` pins that reach, and removing it would be a reach regression the claim did not order. - **Recursive typing of `children` is declined.** The component rows are not reached by any load-path parse: `PageComponentSchema.properties` is an open bag. So a recursive `children` type would take effect only in lint's props gate, which already judges every nested node through the walk. It would also narrow a published accept set (`children` legally holds bare id strings and `null`). And it would not replace any consumer's walk, as the card itself notes. ## Clause ② re-read — why the declaration is `yes (widening)` - The claim's original `no` covered the walk: the exported walk descends a slot the rows already declared. But the design the claim orders ("`packages/spec` owns one list … lint's `walkPageComponents` reads it") needs `@objectstack/lint`, a separate package, to import the list. So `@objectstack/spec` grows two exports, and `api-surface/ui.json` gains two rows in this diff. - `scripts/pm/clause2-line.mjs` states the question the declaration answers: 「本卡放宽接受集或扩大公开面吗」. The public surface grows, so the answer is yes. - The level rule the gate mechanizes (`pr-automation.yml`, "WHICH LEVEL", ruling 2026-09-04) says: "a new exported symbol on an `index`" is a widening that takes at least `minor`. - `scripts/pm/check-widening-tells.mjs` names "T3: a new row in a published entry point's export listing (`packages/spec/api-surface/*.json`)" as a widening tell. Under a `no` it refuses the enqueue. - The changeset grades `@objectstack/spec` `minor`, so `check-changeset-no-major`'s level axis passes under either value. The seat took this in ruling `5921583377`. ## Changeset levels - **`@objectstack/spec`: `minor`.** It adds two exports: `pageComponentSlotPositions`, `PageComponentSlotPosition`. The exported walk's signature and return shape are unchanged. The one earlier widening of this walk (#16772) was banner-BREAKING only for its return-shape change, so none is declared here. - **`@objectstack/lint`: `patch`.** No export is added, and `walkPageComponents`'s signature is unchanged. Its reach drops the retired `body` per the ruling, and the tombstone still refuses the key itself. - **`@objectstack/cli`: `patch`.** There is no code change, only corrected comments. The changeset tells an upgrader that `os i18n extract` / `os i18n check` now offer footer component keys. - No ADR-0087 marker: nothing an author writes moves, no stored shape changes and no conversion id is touched. `check-adr-0087-registration` reads the changeset as non-breaking (exit 0). ## Verification — at `d9d0d38cfc` (this PR's head when opened) - The cut round at `5dc435cb07` re-ran the spec and lint suites and typechecks, `check:generated`, and the derived gates (report `5922116038` on #20940): all green, 91 of 91 derived gates exit 0. - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: **584 files, 17196 passed, 1 todo**, exit 0. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: **117 files, 5440 passed**, exit 0. - `pnpm --filter @objectstack/spec typecheck && pnpm --filter @objectstack/lint typecheck`: exit 0. `check:test-typecheck` held for both, with the debt ledgers unchanged. - `pnpm --filter @objectstack/spec check:generated`: exit 1 at first, on `api-surface/` and `export-origins/` (the two new exports). `--fix` regenerated exactly those two, adding +2 rows each (commit `d9d0d38cfc`). Then "All 15 generated artifacts are up to date", exit 0. `check:authorable-surface` and `check:docs` were green throughout, which is the marker's no-schema-change claim, measured. - These gates exited 0: `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main`, `check-closing-keyword-parity`, `check-issue-citations`, `check-spec-docblock-symbol-anchors`, `check:nul-bytes`, `check:doc-authoring`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check-undeclared-dep-imports`, `check:issue-citations`. They ran at `3dee2204c9`; the commit since only adds four generated JSON lines. - The changed-file suites were run at each commit: `component.test.ts`, `i18n-resolver.test.ts`, `page-component-walk.test.ts` (671 passed), and lint `page-walk.test.ts` plus `page-walk-conversion-parity.test.ts` (21 passed). ## Ablations — each run from the committed head, restored and re-greened All three used `scripts/ablation-replace.mjs`, whose anchor hit 1 → 0 with the blob changed. Each was restored with `git checkout HEAD`, and the blob equals HEAD with `git diff HEAD` empty. 1. **The exported walk stops descending `footer`**, by filtering `footer` out of its positions. - The spec source suite went red, 3 of 300: the footer translate case, the walk trace, and "hands a malformed node in a `page:card` footer to the visitor". - Spec was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - Lint's three-walk parity case, which reads spec's `dist/`, went red, 1 of 5. - Restore leg: spec rebuilt, the preflight `--absent` was clean with a clean tree, and both suites were green again (300/300, 5/5). 2. **Lint's walk takes every list entry**, the retired `body` included, which is its pre-change reach. Lint went red, 4 of 21: the walk case, both #6775 parity cases, and the three-walk parity case. Restored: 21/21. 3. **The row stops marking `footer`**: `componentSlot(` was removed from `PageCardProps.footer`. Spec went red, 6 of 671: the derived-list pin, the three exported-walk footer cases, the conversion walker's footer case, and its list-completeness case. This shows the list is derived from the row marker, not written down anywhere else. Restored: 671/671. ## Not measured when this PR was opened - `packages/cli` unit tier, `test/platform-page-i18n-parity.test.ts`. It imports built `@objectstack/cloud-connection` / `mcp` / `platform-objects`, whose dependency closure this fresh worktree had not built (a first attempt died with the container). The expectation is edited: `card_footer_child` is offered and applied on both sides. The run and its reading go in the os-dev-report on #20940. - The remaining families from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff. It derives 89 commands: 12 were run directly (listed above), and 5 more are covered inside `check:generated` (`check:api-surface`, `check:authorable-surface`, `check:docs`, `check:liveness`, `check:strictness-ledger`). The rest go in the os-dev-report; CI runs the full farm. ## Acceptance notes - **Consumers.** objectui needs no change here. Its pin (e) goes red at the spec bump, and objectui deletes that row, as the card and triage say. - **Visit order.** Within one component, lint and the conversion walker now visit direct slots before panels. Previously they visited `items[].children` first. This is observable only as finding or notice order, for a component carrying both a direct slot and panels. - **New import in `conversions/walk.ts`.** It now imports `ui/component.zod.ts`. `component.zod.ts`'s import closure (49 files) was measured and reaches no `conversions/` module, so there is no cycle. The derived list is read only inside functions. - **Prose this change made stale is corrected in this PR** (`5dc435cb07`; the review adoption `5921754490` named this PR the carrier): `packages/spec/src/automation/region-slots.ts:35-37`, `packages/lint/src/page-envelope-audit.ts:59`, and `packages/lint/src/validate-visibility-predicates.ts:1236` with its test comment at `:497`. - **`packages/cli/src/utils/i18n-extract.ts` comments.** Only comments changed there, where they stated the old positions (`body`/`footer` undescended; lint's walk "wider"). The claim scopes that file to "where the widened walk moves an assertion". These are prose assertions the widening made false. --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d329f0 commit 315888d

18 files changed

Lines changed: 716 additions & 209 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': patch
4+
'@objectstack/cli': patch
5+
---
6+
7+
feat(spec): one list of page-component slot positions, derived from the component rows and read by every page walk — `page:card`'s `footer` is now walked by all three (#20940)
8+
9+
The platform has three walks that descend into a page component's `properties` bag, and each kept its own list of where child components hang: the ADR-0087 conversion walker (`children`, `body`, `footer`, `items[].children`), `@objectstack/lint`'s `walkPageComponents` (the same four) and the exported `walkAddressedPageComponents` (`children`, `items[].children`). So a node in a card's `footer` — a declared, rendered slot ("Card footer components (slot)") — was judged by `os lint` and skipped by every consumer of the exported walk: `translatePage` left its copy untranslated, `os i18n extract` offered no key for it, and objectui's validator passed it unjudged.
10+
11+
**`@objectstack/spec` — new exports `pageComponentSlotPositions()` and `PageComponentSlotPosition` (`@objectstack/spec/ui`).** The component rows now mark each composition slot at its declaration, and `pageComponentSlotPositions()` derives the one list from `ComponentPropsMap`: `children`, `footer` and the panel position `items[].children`, plus the tombstoned `body` flagged `retired: true`. The marker changes nothing about the schema it marks — the parse, the JSON Schema and the authorable surface are unchanged. The list is derived on first call and memoized, never at import. `minor` because the package's public surface grows by these two exports.
12+
13+
**`walkAddressedPageComponents` descends `properties.footer`.** It reads the list's authorable entries, in the list's order (`children`, `footer`, then `items[].children`); signature and return shape are unchanged. What follows from it:
14+
15+
- `translatePage` translates the copy of a component in a card footer through `pages.<name>.components.<id>`, like any other nested component.
16+
- `os i18n extract` offers those keys, and `os i18n check` counts them, for a stack whose card footers hold components with an `id` and copy.
17+
- objectui's validator, which judges the nodes this walk visits, now judges a card footer's nodes.
18+
19+
`page:card.body` stays undescended, as #5775 ruled: it is not an authorable spelling.
20+
21+
**The conversion walker reads every entry, the retired one included.** Its reach does not change: it descends `children`, `body`, `footer` and `items[].children`, as before. Stored documents still carry `body`, the renderers still draw it, and a conversion that runs before `page-card-body-to-children` meets the sub-tree there. Within one component the visit order is now `children`, `body`, `footer`, then the panels. That order is observable only as the order of the notices for a component that carries both a direct slot and panels.
22+
23+
**`@objectstack/lint` — `walkPageComponents` reads the list's authorable entries.** It walks `footer` as before, and it stops walking the retired `body` spelling. The walk matches by shape, so this drops a `body` array on any component, not only on `page:card`. #5775 (maintainer ruling 2026-08-06, direction A) made `children` the one composition key. The renderers keep reading `body` only as a back-compat fallback for stored documents. On `page:card` the tombstone's rename prescription still refuses `body`, and so does the thin containers' guidance; the sub-tree is judged once it sits under `children`. So the rules built on this walk no longer report findings about nodes under any component's `body` array. The conversion walker keeps reaching them for stored documents.
24+
25+
**`@objectstack/cli`:** no code change. `os i18n extract` and `os i18n check` pick up the `footer` component keys through the shared walk. The extractor's object-section pass stops reading `record:details` sections under a retired `body`, through lint's walk.
26+
27+
**Why no ADR-0087 ledger entry.** Nothing an author writes moves: no spec key is retired or renamed, no stored `sys_metadata` shape changes, and no conversion or migration id is touched. `objectstack migrate meta` has nothing to act on.

‎packages/cli/src/utils/i18n-extract.ts‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -932,9 +932,10 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void {
932932
// private copy. That walk exists precisely because duplicating it produced a
933933
// dead rule once already (#3583): components hang off `regions[].components`
934934
// AND `slots.<slot>` (which may be a bare component, not an array), sub-trees
935-
// live inside the untyped `properties` bag (`page:tabs` →
936-
// `properties.items[].children`, `page:card` → `properties.body`/`.footer`),
937-
// and source-authored pages (`kind: 'html' | 'react' | 'jsx'`) hold only a
935+
// live inside the untyped `properties` bag at the positions spec's
936+
// `pageComponentSlotPositions()` derives (`page:tabs` →
937+
// `properties.items[].children`, `page:card` → `properties.footer`, every
938+
// container → `properties.children`), and source-authored pages (`kind: 'html' | 'react' | 'jsx'`) hold only a
938939
// DERIVED region cache that the author never wrote — scaffolding translation
939940
// keys off that cache would invent an authoring surface.
940941
//
@@ -1099,8 +1100,10 @@ export function authorWarnedTranslationGroups(): ReadonlySet<string> {
10991100
* WALK — which components carry those keys — is `walkAddressedPageComponents`,
11001101
* the same traversal `translatePage` itself runs (commit c45d8e6b4, completing the key
11011102
* list's precedent). The walk owns the roots (`regions[].components[]` AND
1102-
* `slots.<slot>`), the descent (`properties.children` AND a panel's
1103-
* `properties.items[].children`, depth-capped, cycle-guarded) and the ruled
1103+
* `slots.<slot>`), the descent (the authorable slot positions spec derives
1104+
* from the component rows — `properties.children`, a card's
1105+
* `properties.footer`, a panel's `properties.items[].children` — depth-capped,
1106+
* cycle-guarded) and the ruled
11041107
* collision arbitration (commit 901355c3b: root level wins outright; among nested
11051108
* components, document-order first sighting) — this function used to
11061109
* hand-mirror all five and now owns none of them. What it still owns:
@@ -1113,15 +1116,14 @@ export function authorWarnedTranslationGroups(): ReadonlySet<string> {
11131116
* - the `label` either/or: `label` may be authored on the component itself
11141117
* or in its props — the same either/or `translatePage` resolves back onto.
11151118
*
1116-
* ⛔ Deliberately NOT `@objectstack/lint`'s `walkPageComponents`, which is
1117-
* WIDER than the resolver in two ways (`properties.body`, `properties.footer`
1118-
* — `page:card`'s slots, which the resolver leaves undescended as a renderer
1119-
* back-compat fallback rather than an authorable spelling; `slots.<slot>`
1120-
* roots and `properties.items[].children` were the other two until #16772
1121-
* brought both into the shared walk) and NARROWER in one (it skips
1122-
* `kind: 'html' | 'react' | 'jsx'` pages, which `translatePage` walks) —
1123-
* either direction of that mismatch is one half of the failure pair
1124-
* `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names.
1119+
* ⛔ Deliberately NOT `@objectstack/lint`'s `walkPageComponents`. Its descent
1120+
* is no longer WIDER than the resolver's — since #20940 both read spec's one
1121+
* derived slot list, and `page:card`'s `properties.body` / `.footer` were the
1122+
* last positions lint walked and the resolver did not (`slots.<slot>` roots
1123+
* and `properties.items[].children` closed at #16772) — but it is still
1124+
* NARROWER in one way (it skips `kind: 'html' | 'react' | 'jsx'` pages, which
1125+
* `translatePage` walks), and a mismatch in either direction is one half of
1126+
* the failure pair `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names.
11251127
*/
11261128
function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): void {
11271129
walkAddressedPageComponents(page, (component, { id, nested, addressed }) => {

‎packages/cli/test/platform-page-i18n-parity.test.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,13 @@ const walkParityPage = (): Record<string, any> => ({
288288
'bare-component-id-string',
289289
null,
290290
],
291-
// NOT descended by `translatePage`: `body`/`footer` are a
292-
// renderer-side back-compat fallback, not an authorable
293-
// composition spelling.
291+
// NOT descended by `translatePage`: `body` is the retired
292+
// spelling (#5775) — a renderer-side back-compat fallback, not an
293+
// authorable composition spelling.
294294
body: [{ id: 'card_body_child', type: 'object-metric', properties: { title: 'Body child' } }],
295+
// DESCENDED since #20940 — `footer` is a declared, rendered slot,
296+
// one of the positions spec's `pageComponentSlotPositions()`
297+
// derives from the component rows.
295298
footer: [{ id: 'card_footer_child', type: 'object-metric', properties: { title: 'Footer child' } }],
296299
// DESCENDED since #16772 — a `page:tabs` / `page:accordion`
297300
// panel's `items[].children`, one level below the container.
@@ -375,19 +378,21 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
375378
it('pins the two sets by name, so a shape that stops being reachable is visible', () => {
376379
const page = walkParityPage();
377380
expect([...idsExtractorOffers(page)].sort()).toEqual([
378-
'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
379-
'slot_child', 'slot_list_child', 'tab_child',
381+
'card', 'card_footer_child', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header',
382+
'region_metric', 'slot_child', 'slot_list_child', 'tab_child',
380383
]);
381-
// `card_body_child` and `card_footer_child` are absent from BOTH sides —
382-
// the shapes `translatePage` does not descend. `tab_child`, `slot_child`
383-
// and `slot_list_child` are present on BOTH sides since #16772 widened
384-
// the shared walk to `items[].children` and to the `slots.<slot>` roots.
384+
// `card_body_child` is absent from BOTH sides — the retired spelling
385+
// `translatePage` does not descend. `card_footer_child` is present on BOTH
386+
// sides since #20940 moved the shared walk onto the rows' one derived slot
387+
// list, which names `footer`. `tab_child`, `slot_child` and
388+
// `slot_list_child` are present on BOTH sides since #16772 widened the
389+
// shared walk to `items[].children` and to the `slots.<slot>` roots.
385390
// `hdr` — the region-level `page:header` — is absent from BOTH sides since
386391
// the ruling. `nested_header` stays: a `page:header` inside a container is
387392
// reached by the id route only, so the id key is the only key it has.
388393
expect([...idsResolverApplies(page)].sort()).toEqual([
389-
'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
390-
'slot_child', 'slot_list_child', 'tab_child',
394+
'card', 'card_footer_child', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header',
395+
'region_metric', 'slot_child', 'slot_list_child', 'tab_child',
391396
]);
392397
});
393398

‎packages/lint/src/page-envelope-audit.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
* | door | parses | reaches | blind to |
5757
* |:--|:--|:--|:--|
5858
* | 1 `PageSchema` | the whole page | every schema-typed position (component `visibleWhen`, and any expression key nested in a typed sub-schema) | anything inside `properties` |
59-
* | 2 `PageComponentSchema` | each walked component | components nested INSIDE `properties` (`page:tabs` → `items[].children[]`, `page:card` → `body`/`footer`) | the `properties` bag itself |
59+
* | 2 `PageComponentSchema` | each walked component | components nested INSIDE `properties` (`page:tabs` → `items[].children[]`, `page:card` → `footer`) | the `properties` bag itself |
6060
* | 3 `ComponentPropsMap[type]` | each component's `properties` | expression keys the per-type props schema declares (`record:alert.properties.visible`) | types absent from the map |
6161
*
6262
* Door 2 exists because `PageComponentSchema.properties` is

0 commit comments

Comments
 (0)