Skip to content

Commit 216a39e

Browse files
fix(lint)!: the view and object publish doors refuse a list-view chart whose dataset or measure does not resolve (#26906)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBPTDEzru2zMrxajEdXBRvAB)_ Fixes #26847 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: updated here — `content/docs/data-modeling/analytics.mdx` gains the rule and its remedy; no `skills/**` file teaches list-view chart bindings, so no skill changes. ## 维护者速读 - 改了什么:列表视图的图表如果引用了不存在的数据集,或 `values` 写了数据集里没有的度量(维度同理),通过视图或对象发布时直接拒绝(422),给出与 `os validate` 相同的结论;草稿保存不拦。 - 为什么改:Studio 或 MCP 作者拼错数据集名、或把原始字段名当度量写,以前发布返回 200,保存下来的图表是空的或坏的,没有任何提示,而 CLI 对同一份文件会报错。 - 风险与回滚:没有改任何 schema,只是让已有的 lint 规则在视图/对象发布门也运行;仓内 examples 与 platform-objects 没有视图或对象被拒(实测 2 个图表列表视图,0 条结论)。回滚即 revert 本 PR。 - 先看:`packages/lint/src/validate-chart-bindings.ts`(列表视图遍历新增的两种独立视图形态)、`packages/lint/src/reference-integrity-suite.ts`(`validateChartBindings` 一行的 `runtimeTypes` 与上方注释)、`packages/rest/src/meta-view-chart-binding.test.ts` 末尾的 `#26847` 段。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/validate-chart-bindings.test.ts -t 26847`,应见 24 条全绿(三种视图形态 × 未知数据集 / 未知度量 / 对照,加对象发布门与运行时门)。 - 需要你做的:无,席位落地(窄化需先过契约档复核)。 ## What changes `validateChartBindings` (`packages/lint`) already gave `chart-dataset-unknown`, `chart-dimension-unknown` and `chart-measure-unknown` for a list-view chart under `os validate`, but the runtime publish door never ran it for a view or an object. Two things kept it out: 1. **The suite row.** `reference-integrity-suite.ts` declared `runtimeTypes: ['flow', 'report']`. It is now `['flow', 'report', 'view', 'object']`, and the comment above the row is rewritten: a list-view chart resolves against `stack.datasets` alone, which every per-write snapshot carries, so crossing it opens no missing-collection false-positive channel. The rule's report, page and dashboard surfaces read collections a view or object snapshot does not carry, so on those writes they find nothing. 2. **The walk.** The list-view walk read `views[].list`, `views[].listViews.KEY` and `objects[].listViews.KEY`, but not the two standalone shapes `PUT /api/v1/meta/view` hands the suite: the flattened list overlay (`viewKind: 'list'`, chart at the top level) and the ViewItem record (`viewKind: 'list'`, chart inside `config`). Both rungs are added, recognised the way `validateListViewFieldRefs` recognises them. Without them, adding `view` to `runtimeTypes` would have been a no-op for those two shapes. One guard moves with it: the rule's early return (`no datasets, no reports, no views, no pages`) now also looks at `objects`, so an object list view naming a dataset in a stack that declares none is reported instead of skipped. That is what makes the `object` door judge an object written into an environment with no datasets. The verdicts are unchanged: `chart-dataset-unknown` at `chart.dataset`, `chart-measure-unknown` at `chart.values[i]`, `chart-dimension-unknown` at `chart.dimensions[i]`, all `error`, so the publish answers `422 INVALID_METADATA`. A draft save is not gated. ## Why A Studio or MCP author who mistypes a dataset name, or names a measure the dataset lacks, gets a saved view whose chart renders empty or broken. Nothing says why, and the CLI would have refused the same file. ## Tests - `packages/lint/src/validate-chart-bindings.test.ts`, new `[#26847]` block (24 cases): each of the three view shapes (ViewItem `config`, flattened overlay, container `listViews` member) gives `chart-dataset-unknown` and `chart-measure-unknown` at the right path, with a clean control; a ViewItem record is walked at `config` only; an object list view in a stack with no datasets is reported. At the runtime door (`runRuntimeAuthoringRules`, the function `metadata-protocol` calls): a lit probe per verdict per view shape and on an object write, each with a clean control, and a pin that the row declares `view` and `object`. - `packages/rest/src/meta-view-chart-binding.test.ts`, new `#26847` block (9 cases) on the real composition (real `RestServer` route, real `saveMetaItem`, real `ObjectQL` + SQLite `sys_metadata`): per shape, `no_such_dataset` → 422 `INVALID_METADATA` with `chart-dataset-unknown` at `….chart.dataset` and nothing stored; `ghost_measure` → 422 with `chart-measure-unknown` at `….chart.values[0]` and nothing stored; the control → 200 and stored. The file's `boot()` now publishes the `lead_metrics` dataset its fixtures bind, through `PUT /api/v1/meta/dataset`: the existing #22491 control bound that dataset without declaring it, and is refused now that the door resolves it. - Re-judged pins: the `view` and `object` member-surface rosters (`runtime-gate.view-writes.test.ts`, `runtime-gate.object-writes.test.ts`) gain `validateChartBindings` with the argument written beside them. The #26719 save-door probes in `validate-list-view-field-refs.test.ts` charted `status` / `task_count` against the person dataset, which declares neither; they now select names their dataset declares, so the foreign object stays the only thing wrong with them. ## Acceptance notes - **Measured before the change, at the real door.** On `main` `20457f6f`, with the same harness as the new REST block and a declared `lead_metrics` dataset, all six bad PUTs answered `200` (three shapes × `no_such_dataset` / `ghost_measure`), and the three controls `200`. On this branch the six answer `422` at `views[0].config.chart.dataset` / `views[0].chart.dataset` / `views[0].listViews.pipeline.chart.dataset` (and `….values[0]` for the measure), the controls still `200`. The lint dist was rebuilt from each tree before its run. - **Shipped corpus, measured at the door's snapshot shape before crossing**, on `21e8217b`: every view sent through `runRuntimeAuthoringRules({ type: 'view' })` and every object with `listViews` through `{ type: 'object' }`, each with its own stack's objects and datasets as context. `examples/app-crm` 3 views / 6 objects, `examples/app-todo` 1 / 1, `examples/app-showcase` 6 views / 24 objects / 4 datasets, `examples/app-multi-package` none, `packages/platform-objects` 53 objects (29 with `listViews`). Two list views chart (showcase `task` `chart`, `project` `budget_chart`): 0 chart findings at either door. A lit probe (the showcase chart rebound to an unknown dataset) is refused. `apps/` holds only the docs site; a repo-wide search for `type: 'chart'` outside tests finds no other list-view chart. - **Not changed:** `dashboard` and `page` writes still do not run this member; their surfaces are owned by their sibling rules. ## Verification All local, on `35d40a9e`, through `scripts/pm/os-verify-lock.sh` (every VERDICT line `command-exit 0`): - `pnpm --filter @objectstack/lint test`: Test Files 169 passed (169), Tests 7490 passed (7490). `pnpm --filter @objectstack/lint typecheck`: exit 0. - `pnpm --filter @objectstack/rest test`: Test Files 328 passed (328), Tests 6522 passed, 326 skipped. - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 270 passed, 4 skipped (274), Tests 21666 passed, 26 skipped. - `node scripts/pm/dispatch-gates.mjs --commands` (merge base `20457f6f`, 6 paths): 88 derived families, 86 exit 0. `check:skill-examples` and `check:dual-build-cjs-loads` exit 3 (prerequisite: they read a full build); they re-run after `pnpm build`, and that reading, the full dogfood battery on the final head and the `--ran` reconciliation are in the os-dev report on #26847. - Before/after at the real REST door: see the first acceptance note. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `eslint --no-inline-config` on the seven changed source and test files exits 0 (the repo-wide `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. (Not touched.) - [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. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufziFPBpGc76tAFHbB8v --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fe9d25 commit 216a39e

9 files changed

Lines changed: 331 additions & 23 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: the view and object publish doors refuse a list-view chart whose dataset, dimension or measure does not resolve
6+
7+
Clause-②: no (narrowing) — a list view whose `chart.dataset` names no declared dataset, or whose `chart.dimensions` / `chart.values` names a dimension or measure its dataset does not declare, is refused at publish (`PUT /api/v1/meta/view`, `PUT /api/v1/meta/object`); it published `200` before. `os validate` / `os build` already refused it on the shapes they walked, and still do; they now also report it where they skipped it: an `objects[].listViews` chart in a stack that declares no datasets, reports, views or pages, and a `views[]` entry in the flattened list overlay or ViewItem `config` shape. Nothing is widened.
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: an existing author-time rule now also runs at the view and object publish doors. There is no mechanical rewrite to prescribe, because only the author knows which declared dataset or measure the chart meant. -->
11+
12+
**BREAKING** accept-set narrowing at the runtime publish door: `validateChartBindings` (`@objectstack/lint`) joins the reference-integrity suite's `view` and `object` writes.
13+
14+
- **Why.** A Studio or MCP author who mistyped a dataset name, or named a measure the dataset lacks, got a saved view whose chart rendered empty or broken, with nothing saying why, while the CLI refused the same file.
15+
- **Before.** Measured on the real `PUT /api/v1/meta/view` route over a real `sys_metadata` store: a chart list view naming `no_such_dataset`, or a `values` entry naming no measure of its dataset, answered `200` and was stored, as a view item (`config`), as a flattened list overlay and as a container `listViews` member alike. `os validate` / `os build` skipped the same two cases the door now reports: an object's `listViews` chart in a stack with no datasets, reports, views or pages, and the flattened overlay and ViewItem `config` shapes.
16+
- **Now.** Each is refused `422 INVALID_METADATA` with the verdict `os validate` gives: `chart-dataset-unknown` at `chart.dataset`, `chart-measure-unknown` at `chart.values[i]`, and `chart-dimension-unknown` at `chart.dimensions[i]`. The rule's list-view walk now opens the two standalone view shapes the door carries (the flattened list overlay and the ViewItem `config`), and an object's own `listViews` is judged on an object write. A draft save is not gated, as for every author-time rule.
17+
- **Who moves.** No view or object in `examples/**` or `packages/platform-objects` is refused: measured at the door's own snapshot shape, the two chart list views in the showcase app bind declared datasets and measures.
18+
19+
**The remedy.** Bind a dataset the stack or package declares, and name its declared dimensions and measures (a measure name such as `amount_sum`, never the base field `amount`), or declare the missing dimension or measure on the dataset.

‎content/docs/data-modeling/analytics.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,13 @@ and list-view charts to it **by name**. Presentations reference a dataset by its
285285
`name` string — the shipped presentation schemas carry no inline/anonymous
286286
dataset shape, so even a one-off single-object KPI is a small named dataset.
287287

288+
A list-view chart's binding must resolve. A `dataset` no package declares, or a
289+
`dimensions` or `values` entry the dataset does not declare, is refused at
290+
publish, for a view and for an object's own `listViews` alike, and fails
291+
`os validate` (`chart-dataset-unknown`, `chart-dimension-unknown`,
292+
`chart-measure-unknown`). A `values` entry names a dataset **measure**, never
293+
the base field it aggregates.
294+
288295
A list-view chart draws the rows of its dataset's base `object`, never the
289296
list's own records: the list's filter does not reach the dataset query. So a
290297
list view's chart must bind a dataset whose `object` is the object the list

‎packages/lint/src/reference-integrity-suite.ts‎

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -422,23 +422,36 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
422422
// every page-door binding as unresolvable. Its page-typed sibling above
423423
// takes the default for the same reason.
424424
{ name: 'validatePageVisualizationBindings', run: validatePageVisualizationBindings },
425-
// [#19542] `runtimeTypes` gains `report` under the ADR-0049 ruling, and ONLY
426-
// `report`. On a report write this member is the subject-reader: it opens
427-
// with `recordsOf(stack.reports)` and resolves that report's own `dataset` /
428-
// `rows` / `columns` / `values` and its chart's axes against `stack.datasets`
429-
// — the one collection this resolution needs and the one the per-write
430-
// snapshot already carries (#7529), so it has no missing-collection
431-
// false-positive channel. ⛔ NOT `dashboard` or `view`: on those writes the
432-
// report/board surfaces the rule walks are the collections the snapshot does
433-
// NOT carry, which is the `dataset` door's own DARK pin one file over.
425+
// [#19542] `runtimeTypes` gains `report` under the ADR-0049 ruling. On a
426+
// report write this member is the subject-reader: it opens with
427+
// `recordsOf(stack.reports)` and resolves that report's own `dataset` /
428+
// `rows` / `columns` / `values` and its chart's axes against
429+
// `stack.datasets` — the one collection this resolution needs and the one
430+
// the per-write snapshot already carries (#7529), so it has no
431+
// missing-collection false-positive channel.
434432
//
435-
// ⚠️ A REFUSAL widening, like #15254's and #19143's: a report republished
436-
// with a dangling dataset binding or a dimension the dataset does not
437-
// declare is now refused (422) rather than stored silently. MEASURED over
438-
// the shipped report corpus at the door's own snapshot shape before
439-
// crossing: 9 reports (showcase 4, todo 5), 8 of them binding a real dataset
440-
// with `rows`/`values` — 0 findings, with a lit synthetic probe refused.
441-
{ name: 'validateChartBindings', runtimeTypes: ['flow', 'report'], run: validateChartBindings },
433+
// [#26847] `view` and `object` join for the same reason. A list-view chart
434+
// (`views[].list` / `listViews`, the flattened overlay, the ViewItem
435+
// `config`, and an object's own `listViews`) binds `dataset` + `dimensions`
436+
// + `values` and resolves against `stack.datasets` ALONE — the collection
437+
// the snapshot carries — so the door refuses an unknown dataset
438+
// (`chart-dataset-unknown`), dimension or measure (`chart-dimension-unknown`
439+
// / `chart-measure-unknown`) the way `os validate` already does. The walk's
440+
// report, page and dashboard surfaces read collections a view or object
441+
// snapshot does not carry, so on those writes they find nothing: they are
442+
// empty there, never dangling. ⛔ Still NOT `dashboard` or `page`: on those
443+
// writes the surfaces the rule walks are the subject, and their sibling
444+
// rules own them.
445+
//
446+
// ⚠️ REFUSAL widenings, like #15254's and #19143's: a report, view or object
447+
// published with a dangling chart binding is now refused (422) rather than
448+
// stored silently. Each was MEASURED over the shipped corpus at the door's
449+
// own snapshot shape before crossing — `report`: 9 reports (showcase 4,
450+
// todo 5), 0 findings; `view` / `object`: every view and every object with
451+
// `listViews` in the four example apps and platform-objects, of which two
452+
// list views chart (showcase `task` and `project`), 0 findings. A lit
453+
// synthetic probe is refused at each door.
454+
{ name: 'validateChartBindings', runtimeTypes: ['flow', 'report', 'view', 'object'], run: validateChartBindings },
442455
// [#26548] The same report, one key on: `dateRange.dimension` resolved
443456
// against the bound dataset's `type: 'date'` dimensions (named, or the ONE
444457
// candidate when omitted). Directly after its sibling because the two read

‎packages/lint/src/runtime-gate.object-writes.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,10 +260,16 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
260260
// Each was measured over the shipped object corpus before crossing (116
261261
// objects, 0 findings, precision 1.0); the comments on the members carry
262262
// the populations, and the four controls below are the non-vacuity half.
263+
// [#26847] `validateChartBindings` is the fourth, for an object's built-in
264+
// `listViews` charts: resolved against `stack.datasets`, a collection every
265+
// per-write snapshot carries. Measured over the shipped object corpus (no
266+
// object list view charts, 0 findings); its door pins live in
267+
// `validate-chart-bindings.test.ts`.
263268
expect(crossed).toEqual([
264269
'validateSearchableFields',
265270
'validateListViewFieldRefs',
266271
'validateObjectFieldRefs',
272+
'validateChartBindings',
267273
]);
268274
// `validateSortableFields` measured equally clean but was NOT crossed:
269275
// that is its own adjudication, and this pin is where it has to be made.

‎packages/lint/src/runtime-gate.view-writes.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
244244
// #9313 failure inverted: the standalone list view a Studio tenant or an
245245
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
246246
// a build-time-only rule never reaches the author who made the typo.
247+
// [#26847] The fifth crossing, a different collection on purpose: a list
248+
// view's chart binding (`dataset` / `dimensions` / `values`), resolved
249+
// against `stack.datasets`, which every per-write snapshot also carries.
250+
// The rule's list-view walk opens the flattened overlay and the ViewItem
251+
// `config` for it; its report and page surfaces find nothing on a view
252+
// snapshot. Measured over the shipped view corpus before crossing (two
253+
// chart list views, 0 findings); the door pins live in
254+
// `validate-chart-bindings.test.ts`.
247255
expect(crossed).toEqual([
248256
'validateSearchableFields',
249257
'validateSortableFields',
250258
'validateListViewFieldRefs',
259+
'validateChartBindings',
251260
]);
252261
// And every member still judges flow snapshots — the #4463 P1 surface is
253262
// not narrowed by the member axis existing.

‎packages/lint/src/validate-chart-bindings.test.ts‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
CHART_AXIS_NOT_SELECTED,
1010
} from './validate-chart-bindings.js';
1111
import { explainRule } from './rule-explanations.js';
12+
import { runRuntimeAuthoringRules } from './runtime-gate.js';
13+
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
1214

1315
// [#22161] Each finding of the two converted ids is one verdict sentence; the
1416
// reasoning it used to carry is the id's `os explain` entry. Every call below
@@ -851,6 +853,150 @@ describe('validateChartBindings — list-view charts', () => {
851853
});
852854
});
853855

856+
// [#26847] The two standalone `views[]` shapes `PUT /api/v1/meta/view` carries,
857+
// and the publish door itself. Measured on main before the change: a chart list
858+
// view naming no declared dataset, or a measure its dataset lacks, published
859+
// 200 through that door as a ViewItem record, a flattened overlay and a
860+
// container `listViews` member alike, while `os validate` refused the file.
861+
describe('[#26847] list-view charts at the view and object publish doors', () => {
862+
const chart = (over: Record<string, unknown> = {}) => ({
863+
chartType: 'bar',
864+
dataset: 'task_metrics',
865+
dimensions: ['status'],
866+
values: ['est_hours'],
867+
...over,
868+
});
869+
const listView = (over: Record<string, unknown> = {}) => ({
870+
type: 'chart',
871+
label: 'Hours by status',
872+
data: { provider: 'object', object: 'showcase_task' },
873+
columns: ['status'],
874+
chart: chart(over),
875+
});
876+
const SHAPES: Array<[string, (over?: Record<string, unknown>) => Record<string, unknown>, string]> = [
877+
[
878+
'a ViewItem record',
879+
(over) => ({ name: 'showcase_task.hours', object: 'showcase_task', viewKind: 'list', label: 'Hours', config: listView(over) }),
880+
'views[0].config.chart',
881+
],
882+
[
883+
'a flattened list overlay',
884+
(over) => ({ name: 'showcase_task.hours', object: 'showcase_task', viewKind: 'list', ...listView(over) }),
885+
'views[0].chart',
886+
],
887+
[
888+
'a container listViews member',
889+
(over) => ({ name: 'showcase_task', object: 'showcase_task', listViews: { hours: listView(over) } }),
890+
'views[0].listViews.hours.chart',
891+
],
892+
];
893+
894+
describe.each(SHAPES)('the rule walks %s', (_label, view, at) => {
895+
it('chart-dataset-unknown at `chart.dataset`', () => {
896+
const findings = validateChartBindings({ ...baseStack(), views: [view({ dataset: 'no_such_dataset' })] });
897+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([[CHART_DATASET_UNKNOWN, 'error', `${at}.dataset`]]);
898+
});
899+
900+
it('chart-measure-unknown at `chart.values[i]`', () => {
901+
const findings = validateChartBindings({ ...baseStack(), views: [view({ values: ['est_hours', 'ghost_measure'] })] });
902+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([[CHART_MEASURE_UNKNOWN, 'error', `${at}.values[1]`]]);
903+
});
904+
905+
it('CONTROL — a fully resolved chart reports nothing', () => {
906+
expect(validateChartBindings({ ...baseStack(), views: [view()] })).toEqual([]);
907+
});
908+
});
909+
910+
it('a ViewItem record is judged at `config` only — its envelope carries no chart to walk twice', () => {
911+
const item = { ...SHAPES[0][1]({ dataset: 'no_such_dataset' }), chart: chart({ dataset: 'no_such_dataset' }) };
912+
expect(validateChartBindings({ ...baseStack(), views: [item] }).map((f) => f.path)).toEqual([
913+
'views[0].config.chart.dataset',
914+
]);
915+
});
916+
917+
it('an object list view names a dataset in a stack that declares none: refused, not skipped', () => {
918+
const findings = validateChartBindings({
919+
objects: [{ name: 'showcase_task', fields: {}, listViews: { hours: listView() } }],
920+
});
921+
expect(findings.map((f) => [f.rule, f.path])).toEqual([
922+
[CHART_DATASET_UNKNOWN, 'objects[0].listViews.hours.chart.dataset'],
923+
]);
924+
});
925+
926+
describe('at the runtime publish door', () => {
927+
const { datasets } = baseStack();
928+
const objects = [
929+
{
930+
name: 'showcase_task',
931+
label: 'Task',
932+
sharingModel: 'private',
933+
fields: {
934+
status: { type: 'text', label: 'Status' },
935+
estimate_hours: { type: 'number', label: 'Estimate' },
936+
},
937+
},
938+
];
939+
const chartRules = (fs: Array<{ rule: string; path: string }>) =>
940+
fs.filter((f) => f.rule.startsWith('chart-')).map((f) => [f.rule, f.path]);
941+
942+
it('`validateChartBindings` reaches `view` and `object` writes', () => {
943+
for (const type of ['view', 'object']) {
944+
expect(
945+
REFERENCE_INTEGRITY_RULES.find((r) => r.name === 'validateChartBindings')?.runtimeTypes,
946+
type,
947+
).toContain(type);
948+
}
949+
});
950+
951+
describe.each(SHAPES)('a view written as %s', (_label, view, at) => {
952+
const write = (over?: Record<string, unknown>) =>
953+
runRuntimeAuthoringRules({ type: 'view', item: view(over), context: { objects, datasets } });
954+
955+
it('⭐ LIT — a chart naming no declared dataset is REFUSED at `chart.dataset`', () => {
956+
const result = write({ dataset: 'no_such_dataset' });
957+
expect(chartRules(result.errors)).toEqual([[CHART_DATASET_UNKNOWN, `${at}.dataset`]]);
958+
});
959+
960+
it('⭐ LIT — a chart whose `values` names no measure of its dataset is REFUSED at `chart.values[i]`', () => {
961+
const result = write({ values: ['ghost_measure'] });
962+
expect(chartRules(result.errors)).toEqual([[CHART_MEASURE_UNKNOWN, `${at}.values[0]`]]);
963+
});
964+
965+
it('⭐ CONTROL — the same view bound to a declared dataset and measure publishes clean', () => {
966+
const result = write();
967+
expect(result.rulesRun).toContain('validateReferenceIntegrity');
968+
expect(result.errors).toEqual([]);
969+
});
970+
});
971+
972+
describe('an object write carrying a built-in chart list view', () => {
973+
const write = (over?: Record<string, unknown>) =>
974+
runRuntimeAuthoringRules({
975+
type: 'object',
976+
item: { ...objects[0], listViews: { hours: { ...listView(over), data: undefined } } },
977+
context: { objects, datasets },
978+
});
979+
980+
it('⭐ LIT — an unknown dataset is REFUSED', () => {
981+
// The door names an object entry by name rather than position.
982+
expect(chartRules(write({ dataset: 'no_such_dataset' }).errors)).toEqual([
983+
[CHART_DATASET_UNKNOWN, 'objects.showcase_task.listViews.hours.chart.dataset'],
984+
]);
985+
});
986+
987+
it('⭐ LIT — an unknown measure is REFUSED', () => {
988+
expect(chartRules(write({ values: ['ghost_measure'] }).errors)).toEqual([
989+
[CHART_MEASURE_UNKNOWN, 'objects.showcase_task.listViews.hours.chart.values[0]'],
990+
]);
991+
});
992+
993+
it('⭐ CONTROL — a resolved chart publishes clean', () => {
994+
expect(write().errors).toEqual([]);
995+
});
996+
});
997+
});
998+
});
999+
8541000
describe('validateChartBindings — dataset-bound page chart components', () => {
8551001
it('checks dimensions/values and a ChartConfig-style yAxis', () => {
8561002
const findings = validateChartBindings({

‎packages/lint/src/validate-chart-bindings.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@
2727
* `rows` / `columns` / `values`) is still checked, below.
2828
* 2. **List-view charts** — `ListChartConfigSchema` (`dataset` +
2929
* `dimensions` + `values`), reachable through `views[].list`,
30-
* `views[].listViews.<key>`, and `objects[].listViews.<key>`.
30+
* `views[].listViews.<key>`, `objects[].listViews.<key>`, and the two
31+
* standalone `views[]` shapes `PUT /api/v1/meta/view` carries: the
32+
* flattened list overlay (`viewKind: 'list'`, the chart at the TOP
33+
* level) and the ViewItem record (`viewKind: 'list'`, one level down
34+
* inside `config`). Without those two rungs a view written through the
35+
* publish door is a shape this walk never opens, so crossing the rule
36+
* onto `view` writes would have been a no-op that reads as coverage.
3137
* 3. **Dataset-bound page chart components** — a `PageComponent` whose
3238
* `properties` carry a `dataset` (the `object-chart` component). Same
3339
* binding shape as a list chart, but it arrives through the untyped
@@ -363,7 +369,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
363369
if (!stack || typeof stack !== 'object') return findings;
364370

365371
const datasets = indexDatasets(stack);
366-
if (datasets.size === 0 && !stack.reports && !stack.views && !stack.pages) return findings;
372+
if (datasets.size === 0 && !stack.reports && !stack.views && !stack.objects && !stack.pages) return findings;
367373

368374
/**
369375
* Resolve one bound dataset NAME, reporting `chart-dataset-unknown` when it
@@ -624,6 +630,16 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
624630
const view = views[vi];
625631
if (!isRec(view)) continue;
626632
const viewName = strName(view.name) ?? strName(view.objectName) ?? `#${vi}`;
633+
// The two standalone shapes, recognised as `validateListViewFieldRefs`
634+
// recognises them: a flattened overlay carries its chart on the view
635+
// itself, a ViewItem record inside `config`.
636+
if (view.viewKind === 'list') {
637+
if (isRec(view.config)) {
638+
checkListChart(view.config, `view "${viewName}" (ViewItem record) · chart`, `views[${vi}].config`);
639+
} else {
640+
checkListChart(view, `view "${viewName}" (flattened list overlay) · chart`, `views[${vi}]`);
641+
}
642+
}
627643
checkListChart(view.list, `view "${viewName}" · list chart`, `views[${vi}].list`);
628644
if (isRec(view.listViews)) {
629645
for (const [key, lv] of Object.entries(view.listViews)) {

0 commit comments

Comments
 (0)