Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/lint-literal-empty-combinator-rejection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/lint": minor
---

feat(lint): literal empty combinators are refused at authoring time, with a per-shape prescription (#5330)

#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 change adds the other half
the ruling deliberately left open: the literal SPELLINGS are now refused where an
author writes them, which is Prime Directive #12's standard shape (reject at the
producer, do not tolerate at the consumer) and #5240's same-direction precedent
one shape over.

`validateEmptyCombinators` is a new gating rule in `AUTHORING_RULES`, 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 rule ids:

- `filter-empty-combinator` — a literal `$and: []`, `$or: []` or `$not: {}`.
- `filter-empty-node` — a literal `{}` standing as the whole filter, or as a
branch of `$and` / `$or`.

**The prescription is 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" message teaches the
wrong fix half the time, so each shape names its own: delete the key to mean "no
filter"; fill the array to mean a constraint; put the negated condition inside
`$not`; and, when zero rows really is the intent, `{ <field>: { $in: [] } }` is
the declared spelling that says so instead of implying it. The row-set wording in
every message is DERIVED from `reduceFilterVerdict` rather than 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.

**Nothing at run time changed.** No translate or evaluation path is touched, the
conformance matrix is untouched, and a stack that ignores the finding runs exactly
as before. The literal-vs-programmatic boundary the ruling requires is structural,
not heuristic: this rule sees only values that reached the metadata graph, so a
producer that assembles zero disjuncts while serving a request — an RLS lowering,
a CEL `!expr`, a client-built query — never reaches it and keeps the runtime
identity, which is what makes `{$or: []}` = zero rows fail-closed (#5134).

Also 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. Each rule
still declares its OWN surface list, so one rule's widening cannot land silently
in the other; `validate-filter-tokens`'s behaviour is unchanged.
26 changes: 26 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import { validateViewContainers } from './validate-view-containers.js';
import { validateWidgetBindings } from './validate-widget-bindings.js';
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
import { validateFilterTokens } from './validate-filter-tokens.js';
import { validateEmptyCombinators } from './validate-empty-combinators.js';
import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import { validateComponentProps } from './validate-component-props.js';
import { validateResponsiveStyles } from './validate-responsive-styles.js';
Expand Down Expand Up @@ -486,6 +487,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
run: (stack) => validateFilterTokens(stack),
},
// #5330 — the LITERAL empty combinators (`$and: []`, `$or: []`, `$not: {}`,
// `{}`). #5322 ruled their RUNTIME meaning to be the boolean identity, and
// this rule does not touch it: it refuses the literal SPELLINGS at authoring
// time with a per-shape prescription, which is Prime Directive #12's standard
// shape (reject at the producer, never tolerate at the consumer) and #5240's
// same-direction precedent one shape over.
{
name: 'validateEmptyCombinators',
tier: 'gating',
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/validate-empty-combinators.ts',
// The one type #4463's P1 slice opened, and the one this rule most needs:
// a flow CRUD node's `config.filter` is where an empty combinator has the
// largest blast radius, and the write path is the only door an AI author
// uses. This rule needs NO resolution context at all — it judges the filter
// literal in isolation — so RUNTIME_NEEDS_FULL_SNAPSHOT does not apply to
// it, and widening to the other filter-carrying types (`object`, `view`,
// `page`, `dashboard`) is a one-line `runtimeTypes` edit once #4463 P2
// opens them at the gate. Making that call here would widen the gate's
// dispatch surface on this rule's authority, which is P2's decision.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow'],
run: (stack) => validateEmptyCombinators(stack),
},
// The reference-integrity suite (#3583 §5 D5) — itself a registry, of the
// rules that answer "does this name resolve to anything?". It reached all
// three commands before this file existed; it is an entry here so the two
Expand Down
154 changes: 154 additions & 0 deletions packages/lint/src/filter-walk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Shared traversal: where the AUTHORED filters are in a metadata stack.
*
* Two rules in this package need the same answer to the same question — "which
* values in this stack were authored as a filter?" — and they need it for
* different reasons: `validate-filter-tokens.ts` classifies the STRINGS inside
* those subtrees (#3574), `validate-empty-combinators.ts` classifies their
* SHAPE (#5330). The subtree-finding half is identical for both, and it is the
* half with the interesting failure mode: #3574 happened because a resolver
* enumerated known surfaces and the dashboard was simply never added to the
* list. `page-walk.ts` (#3583/#5405) and `view-walk.ts` (#6381) are the same
* argument on two other traversals — with N copies the next author fixes one of
* N and the survivors keep the old verdict — and this file is written from
* theirs.
*
* ## What is shared, and what deliberately is NOT
*
* The MECHANISM is shared: descend a stack item, recognise a filter KEY, hand
* the subtree to a visitor. The SURFACE LIST is a parameter, not a constant,
* because the two callers genuinely differ: the token rule scans the seven
* presentation collections it has always scanned, and adding an eighth to a
* shared constant would silently widen a live gating rule. A caller declares
* its own {@link FilterSurface} list and owns that decision.
*
* ## Scanning for KEYS rather than enumerating surfaces
*
* Widget filters, list-view filters, dataset and measure filters, report
* runtime filters, flow CRUD node filters and SDUI component filters all spell
* the key the same way, so a new surface that follows the convention is covered
* the day it ships. That is the property #3574 lacked.
*
* Navigation `recordId` / `params` are NOT filter keys and are never visited:
* they resolve an additional vocabulary (`AppContextSelector` ids such as
* `{active_package}`) that is meaningless in a filter, and restricting the walk
* is what holds false positives at zero.
*/

/** Any plain metadata record. */
type AnyRec = Record<string, unknown>;

/** Keys whose subtree is a filter. The one place a filter is authored. */
export const FILTER_KEYS: ReadonlySet<string> = new Set(['filter', 'filters', 'runtimeFilter']);

/** One stack collection a caller wants walked. */
export interface FilterSurface {
/** Stack collection key — `dashboards`, `objects`, `flows`, … */
key: string;
/** Singular noun used in the `where` label — `dashboard`, `object`, `flow`, … */
kind: string;
}

/** One authored filter subtree, with everything a finding needs to name it. */
export interface AuthoredFilter {
/** The value found under the filter key, exactly as authored. */
value: unknown;
/** Config path, e.g. `dashboards[0].widgets[2].filter`. */
path: string;
/** Human-readable location, e.g. `dashboard "sales" · widget "my_deals"`. */
where: string;
}

/**
* Coerce a collection (array or name-keyed map) to an array of records,
* injecting `name` from the map key — so a rule works on both the parsed
* (array) and normalized (map) stack shapes.
*/
function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') {
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
}
return [];
}

function label(v: unknown, fallback: string): string {
return typeof v === 'string' && v.length > 0 ? v : fallback;
}

/**
* Find filter subtrees anywhere beneath `node` and hand each to `visit`.
*
* Exported for a caller that already has a single item in hand (the runtime
* publish gate's per-write snapshot arrives that way) rather than a whole stack.
*/
export function scanForFilters(
node: unknown,
path: string,
where: string,
visit: (filter: AuthoredFilter) => void,
seen: Set<unknown> = new Set(),
): void {
if (!node || typeof node !== 'object') return;
// Metadata graphs can be cyclic once normalized; guard the walk.
if (seen.has(node)) return;
seen.add(node);

if (Array.isArray(node)) {
node.forEach((v, i) => scanForFilters(v, `${path}[${i}]`, where, visit, seen));
return;
}

for (const [k, v] of Object.entries(node as AnyRec)) {
const childPath = `${path}.${k}`;
if (FILTER_KEYS.has(k)) {
visit({ value: v, path: childPath, where });
continue;
}
scanForFilters(v, childPath, where, visit, seen);
}
}

/**
* Walk every authored filter in `stack` across the caller's surfaces.
*
* Pure traversal: it holds no judgement and emits no findings. Dashboards get
* a per-widget `where` because that is the surface #3574 was filed against and
* naming the widget is what lets an author jump straight to it; every other
* surface is named by its collection kind and its own `name` / `id`.
*/
export function walkAuthoredFilters(
stack: unknown,
surfaces: readonly FilterSurface[],
visit: (filter: AuthoredFilter) => void,
): void {
if (!stack || typeof stack !== 'object') return;

for (const { key, kind } of surfaces) {
const items = asArray((stack as AnyRec)[key]);
items.forEach((item, i) => {
const name = label(item.name ?? item.id, `#${i}`);
if (kind === 'dashboard') {
const widgets = Array.isArray(item.widgets) ? (item.widgets as AnyRec[]) : [];
widgets.forEach((w, wi) => {
const wName = label(w.id ?? w.title, `#${wi}`);
scanForFilters(
w,
`${key}[${i}].widgets[${wi}]`,
`dashboard "${name}" · widget "${wName}"`,
visit,
new Set(),
);
});
// ...and everything else on the dashboard (globalFilters, header, etc.)
// minus the widgets already covered above.
const { widgets: _skip, ...rest } = item;
scanForFilters(rest, `${key}[${i}]`, `dashboard "${name}"`, visit, new Set());
return;
}
scanForFilters(item, `${key}[${i}]`, `${kind} "${name}"`, visit, new Set());
});
}
}
16 changes: 16 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,22 @@ export type {
export { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens.js';
export type { FilterTokenFinding, FilterTokenSeverity } from './validate-filter-tokens.js';

// #5330 — the same subtree, judged for SHAPE rather than for its strings. The
// runtime meaning of an empty combinator is settled (#5322: boolean identity,
// one implementation in `@objectstack/spec`'s `reduceFilterVerdict`); this
// refuses the literal spellings at authoring time, with a prescription that is
// per shape because the identities disagree — `{$and: []}` / `{}` are match-ALL
// and `{$or: []}` / `{$not: {}}` are match-NONE.
export {
validateEmptyCombinators,
FILTER_EMPTY_COMBINATOR,
FILTER_EMPTY_NODE,
} from './validate-empty-combinators.js';
export type {
EmptyCombinatorFinding,
EmptyCombinatorSeverity,
} from './validate-empty-combinators.js';

export {
validateObjectReferences,
OBJECT_REFERENCE_UNKNOWN,
Expand Down
Loading
Loading