feat(lint): refuse a path-shaped ==/!= right-hand side at publish time (#7659) - #7691
Conversation
… time (#7659) The metadata-editing form renderer resolves paths on the LEFT of `==` / `!=` only; the right side goes to a literal parser whose tail returns anything it does not recognise verbatim. So `data.a == data.b` compares `data.a`'s value against the seven-character string "data.b" — false however equal the two sides are, and `data.a != data.b` correspondingly true. #7010's `predicate-path-unresolved` cannot see it: both paths resolve, so there is nothing for it to report. The renderer's own diagnostic (objectui#4049) is dev-mode only and fires after the metadata is stored, so an AI author or a CI pipeline publishing forms never sees it either. New sibling rule `predicate-rhs-path-shaped` in the same file, using the consumer's grammar verbatim (verified against objectui@37cd8e4, not against the issue text) — one grammar, two enforcement points. `error` on a dotted chain (no reading under which it worked); `warning` on a bare unquoted word, which compares as the literal text today and would be a false build error to refuse. The two path-resolution rules are untouched: a predicate that is both unresolvable and path-shaped on the right reports twice, because both statements are true and their fixes differ. `in`'s array parse (objectui#4266) is not folded in. Corpus over the shipped METADATA_FORM_REGISTRY: 0 at both severities, with reverse verification at 45 comparisons. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBKThMnWto2SromcUVAxJk
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 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:
|
Fixes #7659
The gap
objectui's metadata-admin predicate evaluator resolves paths on the LEFT of
==/!=only. The right side goes toparseLiteral, whose tail hands back anything it does not recognise as a literal verbatim — sodata.a == data.bcomparesdata.a's value against the seven-character string"data.b". The verdict does not depend on the right-hand path at all:==is false however equal the two sides are,!=correspondingly true.#7010 / PR #7214 cannot see this.
predicate-path-unresolvedasks whether a path resolves;data.a == data.banswers yes twice and walks through clean. Measured at the runtime publish gate and pinned inruntime-gate.test.ts— the finding list for that predicate containspredicate-rhs-path-shapedand notpredicate-path-unresolved. That silence is the entire justification for a new id rather than a widening of the existing check.The renderer's own diagnostic (objectui#4049 / objectui PR #4264) is dev-mode only and fires at render time, after the metadata is stored. An AI author or a CI pipeline publishing forms never sees it.
What changed
New sibling rule
predicate-rhs-path-shapedinpackages/lint/src/validate-predicate-path-refs.ts— same walk, same registry entry, a different question. It reports a==/!=right-hand side that is an unquoted identifier chain, and names both sanctioned spellings: quote the literal, or restructure so the path is on the left.One grammar, two enforcement points. The regex is quoted verbatim from the consumer's
PATH_SHAPED_LITERAL, verified againstobjectstack-ai/objectui@37cd8e4(packages/app-shell/src/views/metadata-admin/predicate.ts:193) rather than against the issue text:It matches the issue body character for character.
The token comes from the canonical AST (an
idnode, or a.-chain of them), not from re-splitting the source. That agrees with the consumer on every shape it can reach —foo(1),data.b[0]and-3fail the consumer's regex on their text and failmemberChainhere — and costs exactly one case: an identifier containing$, which the grammar admits and CEL's identifier syntax does not, sodata.a == $bnever parses and isvisibility-predicate-syntax's verdict (#6253).true/false/null/ numbers / quoted strings arrive asvaluenodes, so the negative controls are structural rather than a hand-maintained deny-list.Severity — argued, not inherited
The ADR-0090 D7 line in
authoring-rules.ts("everyerrorrule mirrors a runtime enforcement point") is written on thevalidateSecurityPostureentry and scopes itself to the security-domain linter, not to this table. Read literally it would put this whole rule atwarning, because the renderer does not refuse — it returns a wrong answer.The applicable bar is the one this family writes for itself:
errorwhere "there is no reading of the metadata under which it was going to work" (visibility-bare-identifier),warningwhere the predicate is merely advisory-wrong (visibility-root-mislayered). The immediate file-sibling settles it —predicate-path-unresolvediserrorand its consequence is also a silent wrong verdict, not a runtime deny.So the id carries two severities:
erroron a dotted chain (data.a == data.b, or the same with the sides swapped). Nobody writes a dotted identifier chain meaning the literal text of it; there is no reading under which this worked, and for==the element is hidden on every row.warningon a bare single word (status == active). This one works today, and objectui#4049's ruling says so in as many words — resolving the right side was rejected precisely because "it would flipdata.type == text, the unquoted-string spelling that works today by accident, into a fail-opentrue". It is outside the declared subset and dies when ROADMAP M9 swaps in@objectstack/formula, so it must be reported; refusing aviewwrite at the publish door over metadata that renders correctly is a false build error in the one direction a gate may not fail in.Per-finding severity is what gates, exactly as
lintFlowPatternshas worked since #3760; the registry entry'sgatingtier is unchanged because it already was gating.Corpus
Over the shipped
METADATA_FORM_REGISTRY(17 forms, 46 predicates), through the production entry point: 0 findings aterrorand 0 atwarning. Reverse-verified — rewriting each== 'literal'into== data.__rhs__reports all 45 comparisons, so the zero is a measurement and not a dead walk. Theinarray literals are deliberately left alone (objectui#4266).Deliberately not done
in's array parse (objectui#4266) is not folded in.objectuirepo — its half is already merged. Cloned read-only for grammar verification; zero edits.content/docs/releases/**edit; a changeset is included.One deliberate limitation, stated rather than inherited silently: the new rule needs no schema oracle but shares the walk with the two that do, so it does not reach a form whose
schemaIdresolves to no schema. A missed catch in the safe direction — changing thatcontinuewould mean editing the walk #7214 depends on.Known interaction, worth a reviewer's eye
A bare unquoted word is already refused at the publish door by
visibility-bare-identifier(error), which reads it as a dropped binding root and prescribesdata.active. This rule'swarningprescribes'active'. Both are true about the token and they disagree about the fix; the pair is pinned inruntime-gate.test.tsrather than papered over. Reconciling the two messages is a separate card, not a rider here.Gates
All run against a fully-built workspace (
pnpm build, exit 0):pnpm --filter @objectstack/lint run testpnpm --filter @objectstack/lint run typecheckpnpm --filter @objectstack/cli run typecheckpnpm --filter @objectstack/cli run testpnpm --filter @objectstack/metadata-protocol run testThe last three cover both downstream
@objectstack/lintconsumers, includingos validateend-to-end over the example stacks.Not verified: objectui's own test suite — its half (PR #4264) was read as source at
37cd8e4, not executed.