feat(lint): publish-time resolution of metadata-form predicate paths (#7010) - #7214
Conversation
…7010) New `error`-level rule family `validate-predicate-path-refs`: a conditional-visibility predicate on a schema-bound metadata form must name paths the target schema actually declares. - `predicate-path-unresolved` — a `data.`-rooted path whose first unresolvable segment is not a key of the schema at that point. - `predicate-path-unrooted` — a bare identifier that IS a key of the scope, i.e. #6254's shape (right name, dropped root). Immune to the CEL type-name blind spot that made #6248's gate structurally unable to catch it. Scoped to the `data.*` layer: the metadata-type schema registry is a closed key set, while an ObjectQL object's addressable path set is not. Corpus-counted before enforcing: 0 findings over the shipped METADATA_FORM_REGISTRY (17 forms, 46 predicates); 16 once #6254's pre-fix `object.form.ts` spellings are restored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…dicate-path-publish-check
…helper #7186 landed `packages/lint/src/collection-entries.ts` on main after this branch was cut. The local 15-line duplicate documented as "folds into that helper when it lands" now does — same semantics, same array/name-keyed-map handling, same walk order, so no verdict or path changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
|
ACCEPT — devx PM seat. Both required gates green by name on the reworked head What made this delivery acceptable, in the order the dispatch demanded it:
Follow-ups already filed, not decided here: #7219 (the Closes #7010 on merge. Generated by Claude Code |
…me publish gate (objectstack-ai#7220) (objectstack-ai#7479) * feat(lint): move the views[] visibility-predicate family to the runtime publish gate (objectstack-ai#7220) The four rules that judge a `views[]` conditional-visibility predicate ran on the three CLI commands only. A `view` written through the runtime publish door — Studio's designer, REST `/meta`, MCP — was judged by NONE of them, and that door is the only one most tenants have and the one AI authors use. A predicate that cannot parse saved clean and then failed OPEN in the console: the element renders unconditionally, pixel-identical to one carrying no predicate (objectstack-ai#5149). Both registry entries move to `surfaces: CLI_AND_RUNTIME` with `runtimeTypes: ['view']`, in ONE edit, on the maintainer's 2026-08-10 ruling and sequenced after objectstack-ai#4717's `advisories` channel landed (PR objectstack-ai#7435). Six rule ids cross together: `visibility-predicate-syntax`, `visibility-predicate-over-budget`, `visibility-bare-identifier`, `visibility-root-mislayered`, `predicate-path-unresolved` and `predicate-path-unrooted`. They move together on purpose. objectstack-ai#7214's implementer wired the path rule alone, measured the result and reverted it: a `view` refused for an unresolvable predicate PATH while a predicate that does not parse at all walks through the same door is less predictable than refusing neither. A half-wired wall is worse than an unwired one, so the family property is now PINNED in `authoring-rule-wiring.test.ts` — every id on this surface is gated at the runtime door, or none is — rather than trusted to a prose `surfaceReason` that goes stale silently. `RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY`, the constant that recorded the CLI-only state as a decision, is deleted with its last user. Three things were measured rather than assumed: - The gate needed no dispatch-side change. `runtimeGatedTypes()` is derived from this registry, `TYPE_TO_STACK_KEY` already mapped `view -> views`, and `runtime-authoring-gate.ts` passes the written type straight through — its two `'flow'` literals belong to the gate-local objectstack-ai#6285 rule, not to a type allowlist. Declaring is sufficient; `runtimeGatedTypes()` now returns `['flow', 'view']`. - Neither rule needs a stack-wide snapshot. Both read `stack.views` and `stack.pages` and no other collection — never `objects` — so the previous `RUNTIME_NEEDS_FULL_SNAPSHOT` reason on `validateVisibilityPredicates` was describing nothing about this rule. It was the reason a rule got when nobody measured, which is the objectstack-ai#4409/objectstack-ai#4463 defect one layer in. - The door and `os build` agree. `runtime-gate.test.ts` drives the real dispatch path over an eight-input corpus and asserts the finding sets are IDENTICAL — id, severity and path — so the move changes WHERE the rules run, never WHAT they say. Set equality, not "both non-empty": a half-wired wall is precisely the state where both sides are non-empty and disagree. `visibility-root-mislayered` is `warning` on every surface, so it does not 422 — it reaches the author through the `advisories` channel on the 2xx save response. That channel is why this move was allowed to happen at all: moving the rules before findings could travel back would have run them and discarded the verdicts, the exact shape objectstack-ai#4463 exists to close. Behaviour change on a public door (a `view` publish carrying one of these defects is now refused), so it ships a changeset rather than skip-changeset. Drafts are still never gated and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration hatch. Fixes objectstack-ai#7220 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpSiahE9sb96neoWajbiQz * docs(deployment): the runtime publish gate now judges `view` writes too (objectstack-ai#7220) The surface table in `validating-metadata.mdx` was made wrong by the rule move in this PR, in the two places a reader would actually rely on: - it stated `flow` is the only metadata type any rule declares at the runtime publish door, and that "an object, view, page or dashboard save is checked by the schema parse and by nothing else". Both are now false for `view`; - the `views[]` visibility-predicate family had no row at all, so the table could not say what that door does or does not judge about a predicate. Adds the row with a `✓ᵛ` marker for `view` writes, corrects the footnote to name both gated types, and records WHY the family crossed together — the property the wiring guard now pins — since a reader who takes the table as a menu of independently-wirable rules would draw exactly the conclusion objectstack-ai#7220 was filed to prevent. The rest of the page was re-read and is unchanged: the "held to the CLI's verdicts" paragraph, the 422 envelope description and the objectstack-ai#6285 runtime-only exception all remain accurate. `content/docs/releases/v17.mdx` also references `@objectstack/lint` and is release-owned — read, left untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpSiahE9sb96neoWajbiQz --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #7010
The producer-side companion to #6936. That issue was ruled Option C — the objectui evaluator keeps failing OPEN with a warning — which settles the renderer's posture and deliberately leaves the producer side open. This is the check the #6936 filing itself named as the long-term answer: a predicate that references a path the target schema does not declare is refused at authoring time, not guessed at render time.
1. Predicate carriers, enumerated (step 1 of the deliverable)
Read off the schemas, not guessed. Every
*When-family predicate slot declared withExpressionInputSchema:visibleWhen,visibleOn(deprecated)packages/spec/src/ui/view.zod.ts:1659/:1661record(runtime) /data(metadata form)visibleWhen,visibleOn(deprecated)view.zod.ts:1760/:1762visibleWhen,visibility(deprecated)packages/spec/src/ui/page.zod.ts:150/:152record,current_user,page.< var >visibleWhen,readonlyWhen,requiredWhenpackages/spec/src/data/field.zod.ts:801-803recordvisibleWhenfield.zod.ts:162,packages/spec/src/ui/action.zod.ts:319record+current_uservisibleWhen,disabledWhenpackages/spec/src/data/object.zod.ts:1079/:1082recordvisibleWhenpackages/spec/src/ui/component.zod.ts:349visibleWhenpackages/spec/src/automation/builtin-node-config.zod.ts:404visible(not a*Whenspelling)app.zod.ts:319,action.zod.ts:379,bulk-action.zod.ts:209,settings-manifest.zod.ts:234/:491The metadata-admin conditional fields the card names are the
*.form.tsfamily: 17defineFormlayouts registered inMETADATA_FORM_REGISTRY(packages/spec/src/system/metadata-form-registry.ts), of which 6 carry predicates —object(20 sites incl. helpText refs),field(10),view(7),action(6),page(4),report(3). Post-parse the shipped population is 46 predicates, allvisibleWhen, alldata.-rooted (measured, not counted from source lines).Note for a later reader:
FormFieldBaseSchemacarries norequiredWhen/readonlyWhen— those live onFieldSchema(the data field), which is arecord.*surface. So on the form surface this card targets,visibleWhenis the only predicate key, and the card's "requiredWhen" belongs to therecord.*layer that is out of scope below.2. Landing point:
packages/lint, and why the two candidates converge thereThe card offered "the AUTHORING_RULES family in
metadata-protocol, orpackages/lint". They are the same place:AUTHORING_RULESlives inpackages/lint/src/authoring-rules.ts;metadata-protocolis one consumer of it through@objectstack/lint/runtime.Two hard constraints then decide it outright:
parseCelToAst— packages/lint 绕过 @objectstack/formula 直接 parse CEL —— 两个解析入口对「什么能解析」会给出不同答案 #4812 forbids a private parser).packages/specdepends onzodand nothing else (packages/spec/package.json), so a spec-side home would need a newspec → formuladependency, on top of Prime Directive ✨ Set up Copilot instructions #2.packages/lint/src/validate-visibility-predicates.ts, and share the traversal (view-walk.tsformViewSites) this rule needs.New file
packages/lint/src/validate-predicate-path-refs.ts, registered inAUTHORING_RULES(tier: 'gating', all three commands), exported from theindexbarrel.3. What the check does
Two rules, one question — "does this identifier name something the target schema declares?" — differing only in whether the author supplied the root:
predicate-path-unresolved— adata.-rooted path whose first unresolvable segment is not a key of the schema at that point (data.tpye == 'formula'). The message names the unresolvable path and the container; the hint carries a "Did you mean" from the declared key set.predicate-path-unrooted— a bare identifier that is a declared key of the scope (type == 'formula').object.form.ts的 20 处visibleWhen写的是裸标识符(type == 'formula'),同仓field.form.ts写的是data.type—— 同一面两种拼写,必有一种不生效 #6254's shape verbatim: right name, dropped root.Why the three existing gates all wave this through
validate-visibility-predicates.tsjudges a predicate's shape — does it parse (visibility-predicate-syntax, #6253), is it rooted at all (visibility-bare-identifier, #6128), is that root right for the layer (visibility-root-mislayered). None of them opens the target schema.data.tpye == 'formula'passes all three and then fails open in the console, pixel-identical to no predicate at all (#5149).And #6254 already measured the other half: its 16 bare
type == …predicates were structurally uncatchable by #6248's gate, becausetypeis an identifier CEL itself declares, so the strict checker reports a type overload rather than an unknown variable. This rule never asks CEL what resolves — it asks the schema, whose key set is closed and has no opinion about CEL's type vocabulary.Scope is
data.*only, as a decisionAn
error-level gate needs a closed oracle. The metadata-form layer has one: the row under edit is an instance of a metadata type, enumerable key by key throughgetMetadataTypeSchema. The runtimerecord.*layer does not — lookup traversal, system columns the authoredfieldsmap never lists, formula/rollup outputs are all legitimate paths, and anerrorgate over an open set manufactures false build errors. Recorded as an open question rather than guessed at.Repeater rows rebind
data, and the rule followsInside a
type: 'record'/repeater/compositesub-field list, objectui's metadata SchemaForm evaluates{ data: row }— soobject.form.ts'sdata.typemeansFieldSchema.type, not the non-existentObjectSchema.type(view.zod.ts:1647-1657states both halves). The rule descends with the same rebinding. Without it the shipped corpus would read 16 false positives instead of 0.4.⚠️ Corpus count — the load-bearing number
Run through the rule's production entry point over the shipped
METADATA_FORM_REGISTRY(17 forms, 46 predicates):origin/main@55da611e5predicate-path-unresolvedpredicate-path-unrootedNo STOP. Zero hits on the shipped tree, so the gate lands enforcing (
error).Two anti-vacuity guards ship with that number, because "0" is exactly what a gate that reads nothing also reports:
fieldsrepeater, so the walk stops before the 16 sub-field predicates and the count comes out below the corpus while looking like proof of reach.)Reverse verification — direction predicted before running: RED on restore
#6254 rewrote 16 predicates in
object.form.tsfromtype …todata.type …. Restoring the bare spelling on a deep copy of the shippedobjectform must turn the count from 0 to exactly 16predicate-path-unrooted. It does — and the same measurement was taken independently against the real pre-#6254 file (git show 643b7c76b^) during development, with the same 16 sites at the same paths.That measurement also caught a real defect in this PR before it shipped: with a
typeof s === 'object'guard indefOf, the callablelazySchemaproxy read as "not a schema", every key set came out empty, and the gate reported clean over the corpus and over the deliberately corrupted copy alike. The guard now accepts functions, and the comment on it says why.5. Tests
packages/lint/src/validate-predicate-path-refs.test.ts— 24 cases: resolvable paths, repeater rebinding, record-map keys, opaque scopes, comprehension variables, both rule limbs (severity + rule id + the named path + the suggestion), the deliberate boundaries (unparseable source, object-bound form, unknownschemaId, unresolvable row schema, a throwing resolver), traversal reach (form,formViews.<key>, name-keyedviewsmap, thevisibleOnalias value), registry wiring, and the corpus block above.6. Runtime-publish surface: deliberately not widened
surfaces: ['cli'], with a reason that is a decision, not a limitation (RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY). This rule needs only the written item — its oracle is a static registry, not the tenant's other metadata — so none of the three existing P2 reasons is true for it, andstackKeyForType('view')already exists. It is not wired because a half-wired wall is worse than an unwired one: a Studioviewwrite would be refused for an unresolvable path while a predicate that does not parse at all, and one with no root at all, walked through the same door. Theviews[]visibility family should move toruntime-publishtogether, as one measured edit.Verification
Honest negative:
packages/cliandpackages/metadata-protocolwere not run locally (their dependency chains are a full-repo build under a shared container). The registry change is additive and CLI-only, andauthoring-rule-wiring.test.ts/rule-id-barrel-exports.test.ts/authoring-rule-input-tier.test.tsall pass in the lint suite; CI covers the rest.Changeset
@objectstack/lint: minor, real (notskip-changeset) — this adds a functional,error-level authoring check to a published package, which can newly fail an author's build.zodjoins@objectstack/lint's devDependencies so the unit tests can pin the schema traversal against a shape they fully control rather than against whateverFieldSchemadeclares this month.Not in scope
The objectui evaluator change (#6936, ruled C) — its own card, another repo. No
docs/adr/**, nocontent/docs/releases/**, no gate or ratchet weakened.Generated by Claude Code