Skip to content

feat(lint): refuse a path-shaped ==/!= right-hand side at publish time (#7659) - #7691

Merged
os-help merged 1 commit into
mainfrom
claude/issue-7659-predicate-rhs-path-shape
Aug 11, 2026
Merged

feat(lint): refuse a path-shaped ==/!= right-hand side at publish time (#7659)#7691
os-help merged 1 commit into
mainfrom
claude/issue-7659-predicate-rhs-path-shape

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7659

The gap

objectui's metadata-admin predicate evaluator resolves paths on the LEFT of == / != only. The right side goes to parseLiteral, whose tail hands back anything it does not recognise as a literal verbatim — so data.a == data.b compares data.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-unresolved asks whether a path resolves; data.a == data.b answers yes twice and walks through clean. Measured at the runtime publish gate and pinned in runtime-gate.test.ts — the finding list for that predicate contains predicate-rhs-path-shaped and not predicate-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-shaped in packages/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 against objectstack-ai/objectui@37cd8e4 (packages/app-shell/src/views/metadata-admin/predicate.ts:193) rather than against the issue text:

/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/

It matches the issue body character for character.

The token comes from the canonical AST (an id node, 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 -3 fail the consumer's regex on their text and fail memberChain here — and costs exactly one case: an identifier containing $, which the grammar admits and CEL's identifier syntax does not, so data.a == $b never parses and is visibility-predicate-syntax's verdict (#6253). true / false / null / numbers / quoted strings arrive as value nodes, 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 ("every error rule mirrors a runtime enforcement point") is written on the validateSecurityPosture entry and scopes itself to the security-domain linter, not to this table. Read literally it would put this whole rule at warning, because the renderer does not refuse — it returns a wrong answer.

The applicable bar is the one this family writes for itself: error where "there is no reading of the metadata under which it was going to work" (visibility-bare-identifier), warning where the predicate is merely advisory-wrong (visibility-root-mislayered). The immediate file-sibling settles it — predicate-path-unresolved is error and its consequence is also a silent wrong verdict, not a runtime deny.

So the id carries two severities:

  • error on 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.
  • warning on 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 flip data.type == text, the unquoted-string spelling that works today by accident, into a fail-open true". It is outside the declared subset and dies when ROADMAP M9 swaps in @objectstack/formula, so it must be reported; refusing a view write 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 lintFlowPatterns has worked since #3760; the registry entry's gating tier 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 at error and 0 at warning. Reverse-verified — rewriting each == 'literal' into == data.__rhs__ reports all 45 comparisons, so the zero is a measurement and not a dead walk. The in array literals are deliberately left alone (objectui#4266).

Deliberately not done

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 schemaId resolves to no schema. A missed catch in the safe direction — changing that continue would 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 prescribes data.active. This rule's warning prescribes 'active'. Both are true about the token and they disagree about the fix; the pair is pinned in runtime-gate.test.ts rather 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):

Gate Result
pnpm --filter @objectstack/lint run test ✅ 70 files, 1901 passed, 4 skipped
pnpm --filter @objectstack/lint run typecheck ✅ clean
pnpm --filter @objectstack/cli run typecheck ✅ clean
pnpm --filter @objectstack/cli run test ✅ 109 files, 1182 passed
pnpm --filter @objectstack/metadata-protocol run test ✅ 72 files, 1062 passed

The last three cover both downstream @objectstack/lint consumers, including os validate end-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.

… 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
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 10:25am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint)
  • content/docs/deployment/validating-metadata.mdx (via packages/lint)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v17.mdx (via @objectstack/lint)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@os-help
os-help marked this pull request as ready for review August 11, 2026 10:48
@os-help
os-help added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 19bca8c Aug 11, 2026
27 checks passed
@os-help
os-help deleted the claude/issue-7659-predicate-rhs-path-shape branch August 11, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants