feat(lint): refuse literal empty combinators at authoring time (#5330) - #6703
Merged
Merged
Conversation
#5322 settled the RUNTIME meaning of the four empty shapes as the boolean identity reduction, and #5659/PR #6528 made that reduction one implementation (`reduceFilterVerdict` in `@objectstack/spec/data`). This adds the half the ruling left open: the literal SPELLINGS are refused where an author writes them, with a per-shape prescription. `validateEmptyCombinators` is a new gating registry rule (all three CLI commands, plus the runtime publish gate for `flow` writes). Two ids: `filter-empty-combinator` for `$and: []` / `$or: []` / `$not: {}`, and `filter-empty-node` for a literal `{}` as the whole filter or as a branch. The row-set wording in every message is DERIVED from `reduceFilterVerdict` rather than retyped, so `{$and: []}` / `{}` are described as match-ALL and `{$or: []}` / `{$not: {}}` as match-NONE — the asymmetry a generic message gets wrong half the time (#5388 holds a live instance one package over). A test drives the four #5322 identity cases out of `FILTER_LOGIC_CASES` and asserts the message agrees with the rows the table says the filter selects. No translate or evaluation path is touched. The literal-vs-programmatic boundary is structural: this rule sees only values that reached the metadata graph, so a producer that assembles zero disjuncts while serving a request keeps the runtime identity. Also: the filter-subtree traversal `validate-filter-tokens.ts` grew for #3574 moved to a shared `filter-walk.ts` now that it has a second consumer, with each rule keeping its own surface list. `validate-filter-tokens` behaviour unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
…ty-combinator-lint
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
os-project-manager
marked this pull request as ready for review
August 8, 2026 13:09
Contributor
📓 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:
|
os-project-manager
enabled auto-merge
August 8, 2026 13:09
This was referenced Aug 8, 2026
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5330
What this is
#5322 settled what an empty combinator MEANS at run time — the boolean identity
reduction — and #5659 / PR #6528 made that reduction one implementation
(
reduceFilterVerdictin@objectstack/spec/data, proven againstFILTER_LOGIC_CASES, consumed by every backend). This PR adds the half theruling deliberately left open and filed as this card: the literal spellings
are refused where an author writes them.
No translate or evaluation path is touched. The conformance matrix is
untouched, and a stack that ignores the finding runs exactly as it ran before.
The rule
packages/lint/src/validate-empty-combinators.ts, registered inAUTHORING_RULESasgating, so it runs onos validate/os build/os lintat once — and on the runtime publish gate forflowwrites, the door aStudio tenant, a REST
/metaclient and an MCP/AI author all use. Two ids:filter-empty-combinator$and: [],$or: []or$not: {}filter-empty-node{}as the whole filter, or as a branch of$and/$orIt consumes
reduceFilterVerdictand writes no fourth reduction (#5659'swhole argument). Note that
filterCarriesNoCondition(=== 'true') could nothave served:
{$or: []}reduces to FALSE, so the TRUE-only helper does not seeit. The rule uses the verdict's full range.
The prescriptions are per shape, because the identities disagree
{$and: []}and{}reduce to TRUE (match every row);{$or: []}and{$not: {}}reduce to FALSE (match no row). A generic "empty combinator, fixit" teaches the wrong fix half the time, so each shape names its own:
$and: []— "matches EVERY row … it constrains nothing". Fix: delete thekey (an absent
filterand a TRUE-reducing one run identically, and only theabsent key says so), or fill the array.
$or: []— "matches NO row: this surface renders permanently empty, and ona read scope it hides every row (fail-closed by design — SqlDriver.applyFilterCondition 丢弃编译成空的 $and/$or 子过滤器,而不是套用布尔单位元 —— 与同仓 matchesFilterCondition / driver-memory 相反 #5134)". Fix: this is
the opposite of "no filter" — emptying the array does not relax the filter,
it closes it.
$not: {}— "an empty node is TRUE and NOT TRUE is FALSE, so it matches NOrow — the opposite of the 'no filter' an empty operand looks like". Fix: put
the negated condition inside
$not.{}as the whole filter — "matches EVERY row, exactly as if the key wereabsent — so a filter is declared and enforces nothing". Fix: delete the key.
{}as a$orbranch — "one TRUE disjunct ABSORBS the whole disjunction,so every branch you wrote beside it is dead". The hint records why the runtime
absorbs rather than filters (dropping the branch would silently NARROW the
scope — read-scope-sql 的
$not有两处与 SQL 驱动分叉:非 NULL-safe(#5146 后的最后一个异类),且{ $not: {} }编译成空 → RLS 整表放行 #5297).And, when zero rows really is the intent, every hint names the declared spelling
that says so instead of implying it: an empty
$inlist, whichpackages/objectql/src/filter-comparand-shape.tsdocuments as "a legitimate,declared predicate — matches nothing" and deliberately does not refuse.
The row-set wording is derived from
reduceFilterVerdict, never retyped, anda test drives the four #5322 identity cases straight out of
FILTER_LOGIC_CASESand asserts the message agrees with the rows the table says the filter selects.
That is the pin that would have caught #5388's defect (
{$or: []}labelled"match-all") if it had existed one package over. #5388 itself is untouched — a
different package and a different lane — but this rule's vocabulary is written to
be quoted by it.
The literal-vs-programmatic boundary
The ruling requires it, because #5322 kept the runtime identity precisely so a
programmatic producer can fail closed. The boundary here is structural, not
heuristic: the rule reads the authored metadata graph, so a value a producer
constructs while serving a request — an RLS lowering,
cel-to-filter,objectql-strategybuilding{$not: {}}, a client-built query — never entersthat graph and never reaches this rule. There is no predicate to get wrong.
The line is therefore "did this value reach the metadata graph", not "did a
human type it": a TS config that computes a filter at
defineStacktime andproduces
{$or: []}has produced authored metadata and is judged as such, whichis the direction that helps — that author can still see it before publishing.
The docblock history note the card asked for
filter-normalizer.tsonce refused these shapes with the wording "An emptycombinator has no defensible reading — dropping it widens the query, and treating
it as 'match nothing' silently empties a chart". That wording is gone from the
runtime (#5322 overruled it, PR #5365 replaced the throw with the identity).
The new module's header records the relationship in full, so the next reader who
sees that sentence quoted does not conclude the runtime still throws.
Deliberately not judged
{ field: {} }—{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240's shape, ruled and gated elsewhere. The walk neverdescends into a field key's value for that reason.
$and/$or, a non-object$notoperand — refused by name atthe schema and at every driver; a second complaint would describe a run that
never happens.
filter: [...],filter: []) — that isFilterArray, lowered byparseFilterAST, and not one of the four shapes空组合子在同仓有两个对立答案:五个后端归约成布尔单位元,service-analytics 的两个编译器 fail-closed 抛错 —— #5239 的一致性表四条因此进不了表 #5322 ruled on. Out of scope rather than guessed at.
Also in this PR (internal)
The filter-subtree traversal
validate-filter-tokens.tsgrew for #3574 moved toa shared
filter-walk.ts, now that it has a second consumer — the same argumentpage-walk.ts(#3583) andview-walk.ts(#6381) make about a walk with Ncopies. Each rule keeps its own surface list, so one rule's widening cannot
land silently in the other;
validate-filter-tokens's behaviour is unchanged andits test file is untouched.
Relationship to
flow-multi-write-unfiltered(#5482)That rule WARNS (deliberately, not a gate) when a
multi: trueCRUD node isbounded by a TRUE-reducing filter, because the engine grants a whole-object write
on purpose. No tension: the canonical spelling of that legitimate intent —
omitting the key — is untouched here. On
{$and: []}+multiboth fire, andthey are sequential rather than duplicate: delete the
$andas this ruleprescribes and the write is still unbounded, so #5482's warning is still the
right next thing to read.
Verification
Post-merge with
origin/main(b127c8b22), dependency closure rebuilt first:pnpm --filter @objectstack/lint typecheck— clean.pnpm --filter @objectstack/lint test— 64 files, 1629 passed, 4 skipped.pnpm --filter @objectstack/spec exec vitest run src/data/filter-verdict.test.ts src/data/filter-array-declaration.test.ts src/data/context-tokens.test.ts— 3 files, 86 passed (the runtime identity matrix is untouched).examples/,apps/,core/,content/or the platform packages, so this turns nothing red that works today.Reverse verification, direction predicted before running it: replacing the
derived verdict with the "all empty combinators are match-all" reading (#5388's
defect) turned 5 of 21 cases red and left 16 green — red on
$or: []and$not: {}only, green on$and: [],{}and the absorbed$orbranch. Thatasymmetry is the point: a test that merely counted findings would have stayed
fully green through the ablation. Restored and re-run green.
Generated by Claude Code