Skip to content

fix(spec): judge react-block registry inputs against the full node contract - #13202

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-13192-node-contract-parity
Aug 29, 2026
Merged

fix(spec): judge react-block registry inputs against the full node contract#13202
os-trump merged 2 commits into
mainfrom
claude/issue-13192-node-contract-parity

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes #13192

check:react-blocks-declaration-parity judged a registry-declared input against
ComponentPropsMap[type] alone — the per-block half of what a page node may carry.
The other half is declared once, on PageComponentSchema, and applies to every
component whatever its block. So a node-level key read to this gate as an invented
input, and the complaint was false rather than merely inconvenient.

This is the inverse of the usual gate defect: not "should be red and isn't" but
"shouldn't be red and is about to be". objectui PR #6767 has landed — 17 renderers
across 12 packages now declare dataSource at the ElementDataSourceGate wrapping
seam — so the next read of objectui's manifest would have reported a false parity
failure on every one of them.

Re-measured on this ref, before editing

The card's two readings reproduce verbatim:

=== PageComponentSchema.safeParse({ type: 'list-view', dataSource: {…} }) ===
success: true
keeps dataSource: true
data: {"type":"list-view","properties":{},"dataSource":{"object":"account"}}

=== ComponentPropsMap["object-grid"] key set ===
count: 37
has dataSource: false

The implementer's .pipe() note also holds, and it matters: PageComponentSchema ends
in .transform(normalizeVisibleWhen), so it is a ZodPipe and z.toJSONSchema of the
pipe itself yields no properties at all. The authorable key shape is the pipe's
input side, _def.in.

The fix

The node-level half of the accepted set is now derived from PageComponentSchema's
own key shape, never listed. The accepted set per block is
node-level keys ∪ this block's props ∪ overlay.

The literal this replaced — ['aria', 'type', 'id', 'className', 'style'] — named
five of the twelve keys the shape actually accepts, which is the substance of the
sweep below.

Sweep: the other node-level keys (the filer's first NOT-MEASURED item)

The card checked className and left the rest of PageComponentSchema's node-level
keys unswept. Swept here. The shape carries 13 keys; 12 are accepted and 1
is a tombstone:

accepted: aria, className, dataSource, events, id, label, properties,
          responsiveStyles, style, type, visibility, visibleWhen
refused : responsive   (retiredKey → z.never(); z.toJSONSchema spells it {"not":{}})

So dataSource was not alone. Seven accepted node-level keys sat outside the old
literal — dataSource, events, label, properties, responsiveStyles,
visibility, visibleWhen — each one registry declaration away from the same false
failure. A fix handling only dataSource and className would have left the defect
standing for the next one.

responsive is the discrimination that keeps the widening honest. It is in the
shape but accepts nothing, so a registry still declaring it as an input is a real
finding and stays red. Sweeping the whole shape in without that test is exactly how a
widening turns a gate vacuous.

Reproduce-first

Same manifest (object-grid declaring objectName, filter, dataSource, className;
list-view declaring objectName, dataSource), same flags
(--baseline BASELINE_FILE --strict), the pre-fix script from origin/main vs this one.
The gate wraps a react-block tag in angle brackets; those are stripped in the quotes below
because this body's sanitizer eats short tag-shaped fragments, backticks included (#12886):

BEFORE  exit 1
  ⚠ NEW declaration divergence vs accepted baseline:
      - ListView: new registry-only input(s) not in baseline: dataSource
      - object-grid: new registry-only input(s) not in baseline: className, dataSource

AFTER   exit 0
  ✓ no new DECLARATION divergence vs accepted baseline (see the scope note above).

The acceptance is named in the report, not silently dropped — a widening nobody can
see in a CI log cannot be audited:

    registry declares, spec accepts at NODE level (not a per-block prop): dataSource

and every run now prints the derived set it used:

Node contract: the accepted set per block is node-level keys ∪ this block's props ∪ overlay.
       12 node-level keys, DERIVED from PageComponentSchema's own key shape (#13192):
       aria, className, dataSource, events, id, label, properties, responsiveStyles, style, type, visibility, visibleWhen
       Keys the shape RETIRES (`z.never()` tombstones) are excluded, so a registry input
       naming one is still reported — a widening that swept those in would be vacuous.

Calibration pins

Run against object-grid with dataSource accepted in the same run, so the accept
and the refusal cannot be confused for one lenient mode:

registry input verdict exit
dataSource accepted at node level 0
className accepted at node level 0
viewName new registry-only input(s) not in baseline: viewName 1
responsive new registry-only input(s) not in baseline: responsive 1
zzzInventedRegistryInput new registry-only input(s) not in baseline: zzzInventedRegistryInput 1

objectName gets no behavioural leg, and the reason is a measurement rather than an
omission: it is accepted on every block for a legitimate per-block reason — a
prop of all six object-* schemas and an overlay prop of all three react blocks. The
discrimination that can actually go wrong is it being swept in as node-level, and
that is pinned directly on the derived set (expect(derived).not.toContain('objectName')).

Ablation — the pins are load-bearing

Both legs mutated on disk, proven by anchor count and blob hash, and proven still
mutated at the end of each run; both restored and the restore proven by
git diff HEAD being empty. No dist/ is involved — the gate is executed from source
by tsx, so there is no build leg to prove.

  1. Remove && !NODE_CONTRACT_KEYS.has(p) from both registryOnly computations
    (anchors 2 → 0, blob 850d5a6e3700c81a): 6 of the 7 new tests fail. The
    survivor is the derived-set printing test, which pins a different thing.
  2. Remove the tombstone discrimination .filter((k) => !acceptsNothing(props[k]))
    (anchor 1 → 0, blob 850d5a6eefae4195): exactly 2 fail — the responsive
    pin and the derived-set print — while the other five stay green. That is the
    vacuous-widening shape, and it is caught.

An earlier attempt at leg 1 was a no-op: the trap … EXIT from the mutating shell
fired when that shell exited, so the following call measured the restored tree and read
25/25 green. Caught by the "still mutated?" assertion, not by the exit code. Both legs
above were re-run mutation-and-measurement inside one shell.

Fences

  • The gate is not weakened, exempted or disabled. It compares more than it did, and
    three named keys plus a retired one still go red.
  • No per-block exemption and no dataSource copied into any block's
    ComponentPropsMap.
  • skills/objectstack-ui/contracts/react-blocks.contract.json is untouched; the
    diff is two files, both under packages/spec/scripts/.
  • The committed react-declaration-parity.baseline.json is unchanged and needs no
    regeneration: the ratchet flags only new registry-only inputs, and this change can
    only ever produce fewer. Its three _acceptedReasons entries (ObjectForm.initialData,
    .mobile, .navigateOnSuccess) are not node-level keys, so none of them goes stale.

Verification

Union re-run after the final commit, at 9d3195219.

  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    (no hand-fed paths; change set 2 path(s) vs merge base 33184fd29, three-dot). Family
    identical before and after the merge.
  • 24 derived/implicated gates green, exit codes captured before any pipe — check:nul-bytes,
    check:published-files, check:cross-package-test-inputs, check:test-source-alias,
    check:page-declaration-shape, check:engine-double-contract, check:where-matcher,
    check:query-options-erasure, check:comment-mask-adoption, check:keyed-text-bounds,
    check:merge-driver, check:pm-governed-merges, check:undeclared-dep-imports,
    check:slot-lookup, check:type-source-resolution, check:objectql-double-limit,
    check:logger-receiver-detach, check:plugin-teardown-shape, check:ci-filter-parity,
    check:shard-attestation, and spec's check:empty-state / check:liveness /
    check:strictness-ledger / check:variant-docs.
  • The edited gate's own suite plus the two other suites that reference it
    (build-schemas-check-mode.test.ts, check-generated-ledger.test.ts):
    Test Files 3 passed (3) · Tests 96 passed (96) — measured at d7202dbf7, the commit
    before the merge. Declared narrowing: two attempts to re-run all three on the merged
    head hit os-verify-lock queue timeouts (exit 99, 540s each, holder
    issue-13135/batch8.sh then batch9.sh), ~18 min with no acquisition, so the merged-head
    re-run was narrowed to the 7 new calibration tests — Tests 7 passed | 18 skipped (25),
    exit 0, counts read from vitest's own output. What that narrowing excludes is bounded and
    checked, not assumed: the merge's entire delta inside packages/spec is five files
    under scripts/liveness/**, and none of the three suites references liveness (grep: 0
    hits), while check:liveness itself was re-run green on the merged head above. CI runs all
    three suites in full regardless.
  • pnpm --filter @objectstack/spec run typecheck — all three programs green, verdict line
    check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json. Coverage of the edit proven rather than assumed:
    tsc -p tsconfig.scripts.json --listFiles reaches both edited files (1 hit each),
    while tsconfig.test.json reaches neither — so the scripts program is what measured
    this diff.
  • pnpm --filter '@objectstack/spec^...' build matched no projects. That zero-match is
    real, not a mistyped filter: @objectstack/spec declares no workspace dependencies,
    so there is nothing to build ahead of its tests.

Two readings are NOT MEASURED, each on the gate's own say-so rather than mine:
check:test-completeness (exit 3, PREREQUISITE NOT MET — it grades a saved
turbo run test log and none exists locally; its own text names the dispatch-gates
family and says "the local reading for this gate is NOT MEASURED … it is not a red"),
and check:dev-prereqs (exit 1, "The workspace is not built — 1 unmet precondition, not
a list of problems", 67/67 packages). The same unbuilt-workspace precondition parks
check:dual-build-cjs-loads and check:type-check-debt --re-measure; CI builds first
and runs all four.

The one thing this PR could not verify is the objectui manifest itself: NOT MEASURED.
No sdui.manifest.json exists in this repo and none can be produced here — its only
producer drives a browser against a built objectui console (#4690's standing constraint,
which this gate's own header documents). The false failure is therefore reproduced and
cured against synthetic manifests carrying the same shape objectui #6767 emits, not
against objectui's real dump.

Changeset

None, deliberately. packages/spec's files array is
["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"]
scripts is absent, so neither edited file ships in the @objectstack/spec tarball
and this PR releases nothing. Precedent measured from git history rather than recalled:
of the last 400 commits on main, every commit whose entire diff sits under
packages/*/scripts/ carried zero .changeset/ files — #13183 and #13081 (both
2026-08-29), #12973, #12854, #12630 and #12549. The Check Changeset job has exactly two
exemptions and no path exemption, so skip-changeset is the mechanism; the label is
applied to this PR.


Generated by Claude Code

claude added 2 commits August 29, 2026 09:51
…ntract

`check:react-blocks-declaration-parity` compared a registry-declared input
against `ComponentPropsMap[type]` alone — the PER-BLOCK half of what a page
node may carry. The other half is declared once on `PageComponentSchema` and
applies to every component, so a node-level key read as an invented input and
the complaint was false rather than merely noisy.

The node-level half is now DERIVED from `PageComponentSchema`'s own key shape
(its `.transform()` makes it a ZodPipe, so the authorable shape is `_def.in`),
with `retiredKey()` tombstones excluded so the widening cannot go vacuous.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2d2e6f0d73d4e8432a348b550494916b7928da8bpackageMentionDocs.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33249548691 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test:  FAIL  test/serve-publishes-bound-port.e2e.test.ts > #13062 the non-zero half — nothing an ordinary boot publishes may move > publishes exactly the port it was asked for when th
      ↳ 失败原因: @objectstack/cli:test: Error: ENOENT: no such file or directory, open '/tmp/os-bound-port-home-o5KXvA/runtime.env_local.json'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 20 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants