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 |
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