You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 0368504
Browse filesBrowse the repository at this point in the historyBrowse files
feat(lint)!: refuse a widget filterBindings key that names no declared dashboard filter (#27527)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBUKsd8za5Xus285ev54wetJ)_Fixes#27445
Triage-ref: 6491434802
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — `skills/objectstack-ui/rules/dashboards.md` already
teaches that a `filterBindings` key is the declared filter's `name`
(defaulting to `field`); nothing there teaches an undeclared key, so no
`skills/**` edit. Docs updated here: `content/docs/ui/dashboards.mdx`,
`content/docs/deployment/validating-metadata.mdx`.
## 维护者速读
- 改了什么:新增 lint 规则 `dashboard-filter-binding-unknown`(error)。部件
`filterBindings` 的键若不是该仪表盘已声明的过滤器名,`os validate` / `os build` / `os
lint` 和元数据发布门(`422 INVALID_METADATA`)都会拒绝;草稿照常保存,`_diagnostics`
给出同样的结论。消息写明部件 id、错误的键、已声明的过滤器,并给出 Did you mean。
- 为什么改:运行时按已声明过滤器的名字去读绑定,未声明的键永远不会被读到。拼错的退出(`{ regoin: false
}`)会让部件仍被那个过滤器筛选,数字悄悄变了。
- 风险与回滚:BREAKING(收窄),ADR-0087 登记 D3 条目
`ui-dashboard-filter-binding-unknown-refused`,无 D2(盘点没有任何生产者写出未声明的键)。已存的
`sys_metadata` 仪表盘照常加载,下次保存/发布才会被拒。回滚即 revert。
- 先看:`packages/lint/src/validate-widget-bindings.ts` 的 (a-1) 段。
- 两分钟验证:`cd packages/lint && pnpm exec vitest run
src/validate-widget-bindings.test.ts -t
"dashboard-filter-binding-unknown"`,预期全部通过。
- 需要你做的:无,席位落地(需合同复核)。
## What changes
**One new error id in `validateWidgetBindings`.** For every widget, each
key of `filterBindings` must name a filter the dashboard declares. The
declared names are the ones the rule's existing `dashboardFilterDefs`
builds, the same set objectui's `resolveDashboardFilterDefs` builds at
render time: each `globalFilters[]` entry under its `name` (its `field`
when unnamed), and `dateRange` only when the dashboard declares
`dateRange`. A key outside that set is reported at
`dashboards[i].widgets[j].filterBindings.<key>`:
> widget "kpi_deals" binds `filterBindings.regoin`, but dashboard
"deal_board" declares no filter named `regoin` (declared: dateRange,
region), so the binding is never read
with the fix `Rename the key to a declared filter name, or delete the
entry. Did you mean "region"?` (a `dateRange` key on a dashboard with no
`dateRange` gets "Declare `dateRange` on the dashboard, or delete the
`dateRange` entry."). The check runs before the dataset limb because it
needs no dataset, and it is not suppressible.
**Why error, not the `chart-field-unknown` warning tier.** #15463
demoted `chart-field-unknown` because its three keys only re-dress the
same numbers. A `filterBindings` key decides which rows the widget
counts: a misspelled opt-out leaves the widget scoped by the filter the
author opted it out of, and a misspelled re-target leaves the filter on
its default field. Triage 6491434802 graded it a refusal at draft and
publish.
**Route.** The rule rides `validateWidgetBindings`, which is already on
the `dashboard` runtime-gate path with per-rule `surfaces` (the
2026-08-15 ruling's class), so the publish door refuses it with no new
wiring and a stored row is never judged on load. No spec superRefine:
`DashboardWidgetSchema` cannot see its dashboard's filters, and a parse
refusal would move the load path.
- `packages/lint`: the rule and `DASHBOARD_FILTER_BINDING_UNKNOWN`
(barrel-exported), its `os explain` entry in `rule-explanations.ts`, the
module docblock bullet.
- `packages/spec`: `DashboardWidgetSchema.filterBindings` TSDoc and
`.describe()` name the rule and the valid keys
(`content/docs/references/ui/dashboard.mdx` regenerated); ADR-0087 D3
entry `18.ui-dashboard-filter-binding-unknown-refused` (no conversion:
only the author knows which filter a misspelled key meant).
- Docs: a row in the "Where a Filter's `field` Resolves" table and a
paragraph under Per-Widget Filter Bindings (`dashboards.mdx`); a
paragraph in `validating-metadata.mdx`.
- Changeset `27445-dashboard-filter-binding-unknown.md`:
`@objectstack/lint` minor, `@objectstack/spec` minor, marker `registered
ui-dashboard-filter-binding-unknown-refused`.
## Census (Phase 1)
Measured on main `273137d2` and the public HotCRM clone at `47a0666`:
- Producers: 0 offenders. Showcase `showcase_revenue_pulse` (keys
`dateRange`, `region`; declares both) and `showcase_ops_dashboard`
(`task_status`; declared); platform `system_overview` (`created_at`; an
unnamed `globalFilters` entry whose name defaults to its field); HotCRM
`executive_dashboard`, `sales_dashboard`, `crm_overview_dashboard`,
`service_dashboard` (`dateRange`, `owner_id`, `lead_source`, `type`,
`priority`; all declared, the unnamed entries by field). `docs/**` and
`skills/**` snippets are widget-array fragments. So no D2.
- `dateRange` is valid only when declared: lint's `dashboardFilterDefs`,
objectui's `resolveDashboardFilterDefs` and `@objectstack/verify`'s
dashboard-window proof all build the `dateRange` def only when
`dash.dateRange` is set, so an undeclared one was inert everywhere.
- Studio's widget inspector writes `filterBindings` rows only for
declared filter defs. Renaming or removing a global filter does not
prune existing widget bindings; that dashboard's next save is now
refused naming the stale key, which the widget's generic `Filter
Bindings` form row can delete.
## Tests
- `packages/lint/src/validate-widget-bindings.test.ts` (new describe, 12
cases): the card's body (exact message, path and fix), re-target
spelling with one finding per key, declared keys including `dateRange`
pass, an unnamed `globalFilters` entry bound by `field`, a custom `name`
replaces the field as the key, `dateRange` only when declared, a
dashboard with no filters, judged with no dataset and not suppressible,
silent without `filterBindings`, gates `validate` and `build`, the `os
explain` entry.
- `packages/lint/src/runtime-gate.test.ts`: the door accept-set pin goes
from five ids to six, and a new case pins both halves at the door (a
declared binding publishes; `regoin` is refused at its path with the
declared list and the Did you mean).
-
`packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts`:
a seventh case on the real save/read/publish seams (the draft saves,
reads `valid: false` with the publish's words, the publish answers `422
INVALID_METADATA`); and a stored-row pair: a row written with the
authoring gate off loads with `{ regoin: false }` intact, and its next
save is refused 422 naming the key and `(declared: region)`. The stub
engine's matcher learned `$in`, which the active read's type clause
uses.
- Ablation: with the rule's check replaced by `continue`, 10 lint cases
go red; restored, green.
## Verification
Head `4759433d`, off main `273137d2`.
- Under `os-verify-lock`: build of the `@objectstack/lint...` and
`@objectstack/metadata-protocol...` closures exit 0; `@objectstack/spec`
`check:generated` exit 0 (after `gen:docs` for the describe);
`@objectstack/spec` `test` (750 files, 22313 tests) and `test:repo` (86
files, 1419 tests) exit 0; `@objectstack/lint` test (177 files, 7602
tests) and typecheck exit 0; `@objectstack/metadata-protocol` test (279
files passed, 4 skipped; 21777 tests) and typecheck exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands`: 107 derived families,
each run with its exit recorded. 103 exit 0 on the first pass; four exit
3 for prerequisites (`check-plugin-teardown-shape --self-test` needed
its pinned fixture commit fetched; `check:skill-examples`,
`check:dual-build-cjs-loads` and `check:lean-entry-closure` need a full
build). After `git fetch origin 621a487` and a full `turbo run build`
under the lock (11m38s on a shared box), all four exit 0.
`dispatch-gates --ran`: 107 derived, 107 run, 0 NOT-MEASURED, 0 UNRUN,
all exit 0.
- ESLint on the changed source and test files: 0 errors.
## Human Minimum
Each item is one rule from
[CONTRIBUTING.md › Human
Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum),
which links the AGENTS.md text that states it.
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. ESLint
on the changed files: 0 errors; `pnpm lint` is CI's run.)
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English. (The 维护者速读 block is in
Chinese by the repo's dispatch rule.)
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzeTeGTp7gp3ECrLPMf
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(lint)!: `dashboard-filter-binding-unknown` refuses a widget `filterBindings` key that names no filter the dashboard declares, naming the widget, the key and the declared filters
7
+
8
+
Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse a dashboard whose widget `filterBindings` carries a key that names no declared filter (`{ regoin: false }` beside a `region` filter, or a `dateRange` key on a dashboard that declares no `dateRange`), which they accepted before. A draft still saves, and its `_diagnostics` read reports the same verdict. No accept set is widened; one rule id and its exported constant `DASHBOARD_FILTER_BINDING_UNKNOWN` are added.
**BREAKING** accept-set narrowing at every authoring door for `dashboard`.
14
+
15
+
-**Before.**`PUT /api/v1/meta/dashboard/<name>?mode=draft` and the publish that followed both answered `200` for a widget binding `filterBindings: { no_such_filter: 'status' }`. The runtime reads a binding by looking up each declared filter's name, so the key was never read: a misspelled opt-out left the widget scoped by the filter it was meant to ignore, and a misspelled re-target left the filter on its default field.
16
+
-**Now.** The rule reports the key at `widgets[i].filterBindings.<key>` at `error`, naming the widget, the key and the declared filters, with the nearest declared name as a "Did you mean". A key is valid when it names a `globalFilters` entry (by its `name`, or its `field` when it has no `name`) or is `dateRange` on a dashboard that declares `dateRange`. It is not suppressible.
17
+
-**Who sees it.** A dashboard with a misspelled or stale binding key, for example one left behind after its filter was renamed or removed. No dashboard in this repository's examples or platform objects, and none in the public HotCRM app, binds an undeclared key.
18
+
-**Stored rows.** A `sys_metadata` dashboard written before this release with such a key still loads and renders as before: the rule judges the authoring path, not the load path. Its next save or publish is refused until the key is fixed.
19
+
20
+
**The remedy.** Rename the key to one of the declared filter names the finding lists, declare the filter on the dashboard, or delete the entry.
Copy file name to clipboardExpand all lines: content/docs/references/ui/dashboard.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -76,7 +76,7 @@ const result = DashboardSchema.parse(data);
76
76
|**values**|`string[]`| ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family and on the single-series pie/donut/funnel/treemap/sankey; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
77
77
|**layout**|`{ x: number; y: number; w: number; h: number }`| optional | Grid layout position (auto-flowed when omitted) |
|**filterBindings**|`Record<string, string \| false>`| optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
79
+
|**filterBindings**|`Record<string, string \| false>`| optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out. Each key must name a filter the dashboard declares (dateRange only when declared); lint rule dashboard-filter-binding-unknown refuses any other|
80
80
|**suppressWarnings**|`string[]`| optional | Build diagnostic rule ids suppressed on this widget |
81
81
82
82
### Nested Shape: `Dashboard.dateRange`
@@ -176,7 +176,7 @@ Dashboard header action
176
176
|**values**|`string[]`| ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family and on the single-series pie/donut/funnel/treemap/sankey; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
177
177
|**layout**|`{ x: number; y: number; w: number; h: number }`| optional | Grid layout position (auto-flowed when omitted) |
|**filterBindings**|`Record<string, string \| false>`| optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
179
+
|**filterBindings**|`Record<string, string \| false>`| optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out. Each key must name a filter the dashboard declares (dateRange only when declared); lint rule dashboard-filter-binding-unknown refuses any other|
180
180
|**suppressWarnings**|`string[]`| optional | Build diagnostic rule ids suppressed on this widget |
Copy file name to clipboardExpand all lines: content/docs/ui/dashboards.mdx
+7Lines changed: 7 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -466,6 +466,7 @@ They are two different namespaces:
466
466
| :--- | :--- | :--- |
467
467
|`globalFilters[].field` (after any `filterBindings` re-target) | The bound widget's `dataset.object` — a bare name against that object's own fields, a dotted `relationship.field` path hop by hop with its prefix declared in the dataset's `include`|`dashboard-filter-field-unknown`, severity **error**|
468
468
|`widgets[].dimensions[]`| The dataset's declared `dimensions`, by name |`widget-dimension-unknown`, severity **error**|
469
+
|`widgets[].filterBindings` keys | The dashboard's declared filters, by name: each `globalFilters[].name` (its `field` when unnamed), and `dateRange` only when the dashboard declares `dateRange`|`dashboard-filter-binding-unknown`, severity **error**|
469
470
470
471
Two consequences follow, and their asymmetry is the part that surprises authors:
471
472
@@ -547,6 +548,12 @@ Binding precedence: an explicit `filterBindings` entry (string override or
547
548
`false` opt-out) → the filter's legacy `targetWidgets` allow-list → the
548
549
filter's own `field`.
549
550
551
+
Each key must name a filter the dashboard declares. The dashboard looks a
552
+
binding up by the declared filter's name, so a misspelled key (`regoin` for
553
+
`region`) is never read and the widget keeps the filter it was meant to drop
554
+
or re-target. `os validate` and the publish door refuse such a key with
555
+
`dashboard-filter-binding-unknown`, naming the declared filters.
556
+
550
557
### In the Console
551
558
552
559
-**The filter bar.** The date range and each global filter render as controls above the
0 commit comments