Skip to content

Commit d06b3dc

Browse files
feat(lint): refuse literal empty combinators at authoring time (#5330) (#6703)
#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. Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cd584d5 commit d06b3dc

7 files changed

Lines changed: 968 additions & 100 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): literal empty combinators are refused at authoring time, with a per-shape prescription (#5330)
6+
7+
#5322 settled what an empty combinator MEANS at run time — the boolean identity
8+
reduction — and #5659/PR #6528 made that reduction one implementation
9+
(`reduceFilterVerdict` in `@objectstack/spec/data`, proven against
10+
`FILTER_LOGIC_CASES`, consumed by every backend). This change adds the other half
11+
the ruling deliberately left open: the literal SPELLINGS are now refused where an
12+
author writes them, which is Prime Directive #12's standard shape (reject at the
13+
producer, do not tolerate at the consumer) and #5240's same-direction precedent
14+
one shape over.
15+
16+
`validateEmptyCombinators` is a new gating rule in `AUTHORING_RULES`, so it runs
17+
on `os validate` / `os build` / `os lint` at once, and on the runtime publish
18+
gate for `flow` writes — the door a Studio tenant, a REST `/meta` client and an
19+
MCP/AI author all use. Two rule ids:
20+
21+
- `filter-empty-combinator` — a literal `$and: []`, `$or: []` or `$not: {}`.
22+
- `filter-empty-node` — a literal `{}` standing as the whole filter, or as a
23+
branch of `$and` / `$or`.
24+
25+
**The prescription is per shape, because the identities disagree.** `{$and: []}`
26+
and `{}` reduce to TRUE (match EVERY row); `{$or: []}` and `{$not: {}}` reduce to
27+
FALSE (match NO row). A generic "empty combinator, fix it" message teaches the
28+
wrong fix half the time, so each shape names its own: delete the key to mean "no
29+
filter"; fill the array to mean a constraint; put the negated condition inside
30+
`$not`; and, when zero rows really is the intent, `{ <field>: { $in: [] } }` is
31+
the declared spelling that says so instead of implying it. The row-set wording in
32+
every message is DERIVED from `reduceFilterVerdict` rather than retyped, and a
33+
test drives the four #5322 identity cases straight out of `FILTER_LOGIC_CASES` and
34+
asserts the message agrees with the rows the table says the filter selects.
35+
36+
**Nothing at run time changed.** No translate or evaluation path is touched, the
37+
conformance matrix is untouched, and a stack that ignores the finding runs exactly
38+
as before. The literal-vs-programmatic boundary the ruling requires is structural,
39+
not heuristic: this rule sees only values that reached the metadata graph, so a
40+
producer that assembles zero disjuncts while serving a request — an RLS lowering,
41+
a CEL `!expr`, a client-built query — never reaches it and keeps the runtime
42+
identity, which is what makes `{$or: []}` = zero rows fail-closed (#5134).
43+
44+
Also internal: the filter-subtree traversal `validate-filter-tokens.ts` grew for
45+
#3574 moved to a shared `filter-walk.ts` now that it has a second consumer — the
46+
same argument `page-walk.ts` (#3583) and `view-walk.ts` (#6381) make. Each rule
47+
still declares its OWN surface list, so one rule's widening cannot land silently
48+
in the other; `validate-filter-tokens`'s behaviour is unchanged.

packages/lint/src/authoring-rules.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import { validateViewContainers } from './validate-view-containers.js';
104104
import { validateWidgetBindings } from './validate-widget-bindings.js';
105105
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
106106
import { validateFilterTokens } from './validate-filter-tokens.js';
107+
import { validateEmptyCombinators } from './validate-empty-combinators.js';
107108
import { validateReferenceIntegrity } from './reference-integrity-suite.js';
108109
import { validateComponentProps } from './validate-component-props.js';
109110
import { validateResponsiveStyles } from './validate-responsive-styles.js';
@@ -486,6 +487,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
486487
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
487488
run: (stack) => validateFilterTokens(stack),
488489
},
490+
// #5330 — the LITERAL empty combinators (`$and: []`, `$or: []`, `$not: {}`,
491+
// `{}`). #5322 ruled their RUNTIME meaning to be the boolean identity, and
492+
// this rule does not touch it: it refuses the literal SPELLINGS at authoring
493+
// time with a per-shape prescription, which is Prime Directive #12's standard
494+
// shape (reject at the producer, never tolerate at the consumer) and #5240's
495+
// same-direction precedent one shape over.
496+
{
497+
name: 'validateEmptyCombinators',
498+
tier: 'gating',
499+
input: 'parsed',
500+
commands: ALL,
501+
source: 'packages/lint/src/validate-empty-combinators.ts',
502+
// The one type #4463's P1 slice opened, and the one this rule most needs:
503+
// a flow CRUD node's `config.filter` is where an empty combinator has the
504+
// largest blast radius, and the write path is the only door an AI author
505+
// uses. This rule needs NO resolution context at all — it judges the filter
506+
// literal in isolation — so RUNTIME_NEEDS_FULL_SNAPSHOT does not apply to
507+
// it, and widening to the other filter-carrying types (`object`, `view`,
508+
// `page`, `dashboard`) is a one-line `runtimeTypes` edit once #4463 P2
509+
// opens them at the gate. Making that call here would widen the gate's
510+
// dispatch surface on this rule's authority, which is P2's decision.
511+
surfaces: CLI_AND_RUNTIME,
512+
runtimeTypes: ['flow'],
513+
run: (stack) => validateEmptyCombinators(stack),
514+
},
489515
// The reference-integrity suite (#3583 §5 D5) — itself a registry, of the
490516
// rules that answer "does this name resolve to anything?". It reached all
491517
// three commands before this file existed; it is an entry here so the two

packages/lint/src/filter-walk.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Shared traversal: where the AUTHORED filters are in a metadata stack.
5+
*
6+
* Two rules in this package need the same answer to the same question — "which
7+
* values in this stack were authored as a filter?" — and they need it for
8+
* different reasons: `validate-filter-tokens.ts` classifies the STRINGS inside
9+
* those subtrees (#3574), `validate-empty-combinators.ts` classifies their
10+
* SHAPE (#5330). The subtree-finding half is identical for both, and it is the
11+
* half with the interesting failure mode: #3574 happened because a resolver
12+
* enumerated known surfaces and the dashboard was simply never added to the
13+
* list. `page-walk.ts` (#3583/#5405) and `view-walk.ts` (#6381) are the same
14+
* argument on two other traversals — with N copies the next author fixes one of
15+
* N and the survivors keep the old verdict — and this file is written from
16+
* theirs.
17+
*
18+
* ## What is shared, and what deliberately is NOT
19+
*
20+
* The MECHANISM is shared: descend a stack item, recognise a filter KEY, hand
21+
* the subtree to a visitor. The SURFACE LIST is a parameter, not a constant,
22+
* because the two callers genuinely differ: the token rule scans the seven
23+
* presentation collections it has always scanned, and adding an eighth to a
24+
* shared constant would silently widen a live gating rule. A caller declares
25+
* its own {@link FilterSurface} list and owns that decision.
26+
*
27+
* ## Scanning for KEYS rather than enumerating surfaces
28+
*
29+
* Widget filters, list-view filters, dataset and measure filters, report
30+
* runtime filters, flow CRUD node filters and SDUI component filters all spell
31+
* the key the same way, so a new surface that follows the convention is covered
32+
* the day it ships. That is the property #3574 lacked.
33+
*
34+
* Navigation `recordId` / `params` are NOT filter keys and are never visited:
35+
* they resolve an additional vocabulary (`AppContextSelector` ids such as
36+
* `{active_package}`) that is meaningless in a filter, and restricting the walk
37+
* is what holds false positives at zero.
38+
*/
39+
40+
/** Any plain metadata record. */
41+
type AnyRec = Record<string, unknown>;
42+
43+
/** Keys whose subtree is a filter. The one place a filter is authored. */
44+
export const FILTER_KEYS: ReadonlySet<string> = new Set(['filter', 'filters', 'runtimeFilter']);
45+
46+
/** One stack collection a caller wants walked. */
47+
export interface FilterSurface {
48+
/** Stack collection key — `dashboards`, `objects`, `flows`, … */
49+
key: string;
50+
/** Singular noun used in the `where` label — `dashboard`, `object`, `flow`, … */
51+
kind: string;
52+
}
53+
54+
/** One authored filter subtree, with everything a finding needs to name it. */
55+
export interface AuthoredFilter {
56+
/** The value found under the filter key, exactly as authored. */
57+
value: unknown;
58+
/** Config path, e.g. `dashboards[0].widgets[2].filter`. */
59+
path: string;
60+
/** Human-readable location, e.g. `dashboard "sales" · widget "my_deals"`. */
61+
where: string;
62+
}
63+
64+
/**
65+
* Coerce a collection (array or name-keyed map) to an array of records,
66+
* injecting `name` from the map key — so a rule works on both the parsed
67+
* (array) and normalized (map) stack shapes.
68+
*/
69+
function asArray(v: unknown): AnyRec[] {
70+
if (Array.isArray(v)) return v as AnyRec[];
71+
if (v && typeof v === 'object') {
72+
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
73+
}
74+
return [];
75+
}
76+
77+
function label(v: unknown, fallback: string): string {
78+
return typeof v === 'string' && v.length > 0 ? v : fallback;
79+
}
80+
81+
/**
82+
* Find filter subtrees anywhere beneath `node` and hand each to `visit`.
83+
*
84+
* Exported for a caller that already has a single item in hand (the runtime
85+
* publish gate's per-write snapshot arrives that way) rather than a whole stack.
86+
*/
87+
export function scanForFilters(
88+
node: unknown,
89+
path: string,
90+
where: string,
91+
visit: (filter: AuthoredFilter) => void,
92+
seen: Set<unknown> = new Set(),
93+
): void {
94+
if (!node || typeof node !== 'object') return;
95+
// Metadata graphs can be cyclic once normalized; guard the walk.
96+
if (seen.has(node)) return;
97+
seen.add(node);
98+
99+
if (Array.isArray(node)) {
100+
node.forEach((v, i) => scanForFilters(v, `${path}[${i}]`, where, visit, seen));
101+
return;
102+
}
103+
104+
for (const [k, v] of Object.entries(node as AnyRec)) {
105+
const childPath = `${path}.${k}`;
106+
if (FILTER_KEYS.has(k)) {
107+
visit({ value: v, path: childPath, where });
108+
continue;
109+
}
110+
scanForFilters(v, childPath, where, visit, seen);
111+
}
112+
}
113+
114+
/**
115+
* Walk every authored filter in `stack` across the caller's surfaces.
116+
*
117+
* Pure traversal: it holds no judgement and emits no findings. Dashboards get
118+
* a per-widget `where` because that is the surface #3574 was filed against and
119+
* naming the widget is what lets an author jump straight to it; every other
120+
* surface is named by its collection kind and its own `name` / `id`.
121+
*/
122+
export function walkAuthoredFilters(
123+
stack: unknown,
124+
surfaces: readonly FilterSurface[],
125+
visit: (filter: AuthoredFilter) => void,
126+
): void {
127+
if (!stack || typeof stack !== 'object') return;
128+
129+
for (const { key, kind } of surfaces) {
130+
const items = asArray((stack as AnyRec)[key]);
131+
items.forEach((item, i) => {
132+
const name = label(item.name ?? item.id, `#${i}`);
133+
if (kind === 'dashboard') {
134+
const widgets = Array.isArray(item.widgets) ? (item.widgets as AnyRec[]) : [];
135+
widgets.forEach((w, wi) => {
136+
const wName = label(w.id ?? w.title, `#${wi}`);
137+
scanForFilters(
138+
w,
139+
`${key}[${i}].widgets[${wi}]`,
140+
`dashboard "${name}" · widget "${wName}"`,
141+
visit,
142+
new Set(),
143+
);
144+
});
145+
// ...and everything else on the dashboard (globalFilters, header, etc.)
146+
// minus the widgets already covered above.
147+
const { widgets: _skip, ...rest } = item;
148+
scanForFilters(rest, `${key}[${i}]`, `dashboard "${name}"`, visit, new Set());
149+
return;
150+
}
151+
scanForFilters(item, `${key}[${i}]`, `${kind} "${name}"`, visit, new Set());
152+
});
153+
}
154+
}

packages/lint/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,22 @@ export type {
248248
export { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens.js';
249249
export type { FilterTokenFinding, FilterTokenSeverity } from './validate-filter-tokens.js';
250250

251+
// #5330 — the same subtree, judged for SHAPE rather than for its strings. The
252+
// runtime meaning of an empty combinator is settled (#5322: boolean identity,
253+
// one implementation in `@objectstack/spec`'s `reduceFilterVerdict`); this
254+
// refuses the literal spellings at authoring time, with a prescription that is
255+
// per shape because the identities disagree — `{$and: []}` / `{}` are match-ALL
256+
// and `{$or: []}` / `{$not: {}}` are match-NONE.
257+
export {
258+
validateEmptyCombinators,
259+
FILTER_EMPTY_COMBINATOR,
260+
FILTER_EMPTY_NODE,
261+
} from './validate-empty-combinators.js';
262+
export type {
263+
EmptyCombinatorFinding,
264+
EmptyCombinatorSeverity,
265+
} from './validate-empty-combinators.js';
266+
251267
export {
252268
validateObjectReferences,
253269
OBJECT_REFERENCE_UNKNOWN,

0 commit comments

Comments
 (0)