Skip to content

feat(lint): refuse literal empty combinators at authoring time (#5330) - #6703

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-5330-empty-combinator-lint
Aug 8, 2026
Merged

feat(lint): refuse literal empty combinators at authoring time (#5330)#6703
os-project-manager merged 2 commits into
mainfrom
claude/issue-5330-empty-combinator-lint

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

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
(reduceFilterVerdict in @objectstack/spec/data, proven against
FILTER_LOGIC_CASES, consumed by every backend). This PR adds the half the
ruling 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 in
AUTHORING_RULES as gating, so it runs on os validate / os build /
os lint at once — and on the runtime publish gate for flow writes, the door a
Studio tenant, a REST /meta client and an MCP/AI author all use. Two ids:

id shape
filter-empty-combinator a literal $and: [], $or: [] or $not: {}
filter-empty-node a literal {} as the whole filter, or as a branch of $and / $or

It consumes reduceFilterVerdict and writes no fourth reduction (#5659's
whole argument). Note that filterCarriesNoCondition (=== 'true') could not
have served: {$or: []} reduces to FALSE, so the TRUE-only helper does not see
it. 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, fix
it" teaches the wrong fix half the time, so each shape names its own:

And, when zero rows really is the intent, every hint names the declared spelling
that says so instead of implying it: an empty $in list, which
packages/objectql/src/filter-comparand-shape.ts documents as "a legitimate,
declared predicate — matches nothing" and deliberately does not refuse.

The row-set wording is derived from reduceFilterVerdict, never retyped, and
a test drives the four #5322 identity cases straight out of FILTER_LOGIC_CASES
and 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-strategy building {$not: {}}, a client-built query — never enters
that 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 defineStack time and
produces {$or: []} has produced authored metadata and is judged as such, which
is the direction that helps — that author can still see it before publishing.

The docblock history note the card asked for

filter-normalizer.ts once refused these shapes with the wording "An empty
combinator 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

Also in this PR (internal)

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 — the same argument
page-walk.ts (#3583) and view-walk.ts (#6381) make about a walk with N
copies. 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 and
its test file is untouched.

Relationship to flow-multi-write-unfiltered (#5482)

That rule WARNS (deliberately, not a gate) when a multi: true CRUD node is
bounded 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: []} + multi both fire, and
they are sequential rather than duplicate: delete the $and as this rule
prescribes 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 test64 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.ts3 files, 86 passed (the runtime identity matrix is untouched).
  • Corpus survey before gating: zero authored empty combinators or empty filter nodes anywhere in 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 $or branch. That
asymmetry 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

claude added 2 commits August 8, 2026 12:58
#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
@vercel

vercel Bot commented Aug 8, 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 8, 2026 1:09pm

Request Review

@os-project-manager
os-project-manager marked this pull request as ready for review August 8, 2026 13:09
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

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/permissions/authorization.mdx (via @objectstack/lint)
  • content/docs/releases/v17.mdx (via @objectstack/lint)

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.

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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

publish/lint 面对字面量空组合子响亮拒收 —— #5322 拍板单位元后的可选编写期信号(PD #12 形状)

2 participants