Skip to content

Commit 20457f6

Browse files
fix(lint)!: refuse a list-view chart bound to a dataset over another object (#26842)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBBvzHnt6XJAsoWun9t3Y1WU)_ Fixes #26719 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. ## 维护者速读 - 改了什么:列表视图的图表如果绑定的数据集建在别的对象上(比如项目列表下画任务数据),发布时直接拒绝(422),`os validate` / `os build` 也报错;草稿保存不拦,与其他作者期规则一致。 - 为什么改:这种图表画的是别的对象的数据,却挂在本对象列表下,用户会把任务统计当成项目统计看,而以前保存和发布都返回 200、没有任何提示。 - 风险与回滚:只新增一条 lint 规则,不改任何 schema;仓内 examples 与 platform-objects 没有视图被它拒绝。回滚即 revert 本 PR。 - 先看:`packages/lint/src/validate-list-view-field-refs.ts`(`checkChartDataset` 与新增文档段)、`packages/lint/src/validate-list-view-field-refs.test.ts` 末尾的 `[#26719]` 段、`.changeset/26719-list-view-chart-foreign-dataset.md`。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/validate-list-view-field-refs.test.ts -t 26719`,应见 16 条全绿(拒绝、同对象对照、关系可达也拒绝、四种视图形态、运行时保存门)。 - 需要你做的:无,席位落地(窄化需先过契约档复核)。 ## What changes A list view's `chart` binds an ADR-0021 dataset, and the chart draws the rows of that dataset's base `object`: the list hands `queryDataset` the dataset name, dimensions and measures and nothing else, so neither the list's object nor its filter reaches the query. A chart under one object's list bound to a dataset over another object plotted the other object's records under the first object's name, and saved and published `200`. - **New rule** `list-view-chart-dataset-foreign-object` (`error`) in `validateListViewFieldRefs`, the list-view lint family that already judges columns and the #24563 closure binding. One finding per list view, at `chart.dataset`; `where` names the view, the message names the dataset and both objects, the fix line names the remedy. - **Why this family and not `validateChartBindings`.** The claim and the issue name `packages/spec`, but the list-view lint family lives in `packages/lint` (the spec holds no business logic). `validateListViewFieldRefs` already resolves the list view's bound object on every shape the save door carries (a view item record, a flattened list overlay, a container's `list` / `listViews`, an object's built-in `listViews`, with a list view's own `data.object` winning) and already runs at the `view` and `object` doors. `validateChartBindings` runs at the `flow` and `report` doors only, and does not walk the view-item or flattened shapes, so landing the check there would not have reached the door the QA measured. - **A dataset that reaches the view's object through a declared relation is refused, not accepted.** `include` lists to-one relationships FROM the dataset's base object, so a dataset over `crm_opportunity` that includes `account` still counts and sums opportunities: the relation adds columns to group by, never a second row set. Nothing maps the list's object onto the dataset either (that mapping was refused as option B of ruling 5825582592). Equality of the two object names is the one test that says the chart plots the rows the list lists. Pinned both ways (an `owner`-including task dataset is refused under the person list and admitted under the task list). - **Not judged here, each with an owner:** a dataset that resolves to nothing (`chart-dataset-unknown`), a dataset with no `object`, a list view whose binding does not resolve (`list-view-object-outside-closure` / `list-view-object-unknown` / skip 1). The dataset's own package is not judged: when its object is the view's own, that object is already inside the written package's closure. - `os explain list-view-chart-dataset-foreign-object` carries the reasoning; `LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT` is exported from `@objectstack/lint`; `content/docs/data-modeling/analytics.mdx` states the rule. - Changeset: `@objectstack/lint` minor, BREAKING accept-set narrowing, ADR-0087 `not-required (no-migration-prescription)`; the body states the remedy and carries no FROM → TO rewrite, since that disposition refuses a prescription. ## Why Studio QA S5 (studio-qa-2026-10-27): authors got a chart of unrelated data under an object's list without any warning. ## Tests - New `[#26719]` block at the end of `packages/lint/src/validate-list-view-field-refs.test.ts`, 16 cases: the refusal (rule, `error`, path `views[0].list.chart.dataset`, `where` names the view, message names the dataset and both objects, one-line fix ≤ 200 chars); the same-object control (silent); the relation case both ways; the four other list-view shapes (`objects[].listViews`, `views[].listViews`, a flattened overlay, a ViewItem record) each at its own path; a list view's own `data.object` winning over its container's; silence where another verdict owns the case (unknown dataset, dataset with no `object`, unresolved binding); an object with no readable field map still judged; `validate` and `build` both fail; at the runtime save door (`runRuntimeAuthoringRules`, type `view`, under a package closure) a view item bound to another package's object's dataset is refused and the same view on its own object's dataset is admitted; `os explain` carries the relationship answer. - `pnpm --filter @objectstack/lint test` on the merged tree (merge commit `3ccc765c`; the later commits touch only the changeset and one docs page): Test Files 168/168, Tests 7446/7446. `pnpm --filter @objectstack/lint typecheck` exit 0 (same lock, `VERDICT command-exit 0`). - Ablation (source, `scripts/ablation-replace.mjs`, anchor hit 1 → 0, blob `eeb9791a` → `d078fc8e`): removing the `checkChartDataset(...)` call turns 11 of the block's cases red; the 5 controls stay green, as expected. Restored: anchor back to 1, `git diff HEAD` empty. ## Acceptance notes - **Landing site.** The claim's file surface says `packages/spec`; the list-view lint family it names (cf. #24563) lives in `packages/lint`, and the change is there. `packages/spec` is untouched. - **Draft saves are not gated**, as for every author-time rule: the QA's `?mode=draft` PUT still answers 200, and its promotion (`POST /api/v1/meta/view/NAME/publish`) now answers 422. - **Security reading (asked by the card): the dataset read enforces the caller's permissions on the foreign object. Not a finding.** Measured live on this branch's base (`c8583f80`, showcase boot, a non-builder `member`): a dataset over `chartgap_item` (member has no read) → member `POST /api/v1/analytics/dataset/query` `403 PERMISSION_DENIED` ("reading chartgap_item is not permitted"), the same with an inline dataset body; a dataset over `showcase_contact` (member `GET /data` 403) → dataset query 403; a dataset over `showcase_inquiry` (member reads 0 of 3 rows by `GET /data`) → dataset `row_count` 0 for the member, 3 for admin. Object-level admission and the row scope both hold at the dataset door. - **Out of scope, reported to the seat (class a, not fixed here):** `validateChartBindings` runs at the `flow` and `report` doors only and does not walk the view-item or flattened list shapes, so at `PUT /api/v1/meta/view` a chart list view whose dataset resolves to nothing, or whose `values` names no measure of its dataset, publishes `200` (measured on `c8583f80`: `no_such_dataset` and `ghost_measure`, both as a view item and as a container's `listViews`). Crossing that member onto `view` is its own refusal widening with its own corpus measurement. - **Corpus.** `examples/**`, `apps/**`, `packages/platform-objects`, `packages/plugins` and `packages/services` declare two chart list views (showcase `task.view.ts` → `showcase_task_metrics` over `showcase_task`, `project.view.ts` → `showcase_project_metrics` over `showcase_project`); both are their own object's. Nothing to fix. `packages/rest/src/meta-view-chart-binding.test.ts` binds `lead_metrics`, which its harness does not register, so the rule is silent there. ## Verification - Live, on this branch (lint rebuilt, showcase `pnpm dev -- --fresh`, package `com.qa.chartgap` with object `chartgap_item`): a chart list view bound to `showcase_task_metrics` (over `showcase_task`) → publish `422 INVALID_METADATA` with `list-view-chart-dataset-foreign-object` at `views[0].config.chart.dataset` (view item) and `views[0].listViews.lv_foreign2_publish.chart.dataset` (container); draft `200`; the draft's promotion `422`. Bound to `chartgap_item_metrics` (over `chartgap_item`) → `200` on every path, promotion `200`. - Before, on `c8583f80`: the same four writes bound to `showcase_task_metrics` all answered `200`. - Derived gates at `9634b5a3`: `dispatch-gates --commands` → 87 families, 87 run, all exit 0 (`check-adr-0087-registration` first exit 1 on a FROM/TO label in the changeset, fixed in `9634b5a3`, re-run exit 0); `dispatch-gates --ran` → 87 derived, 87 run, 0 NOT-MEASURED, 0 UNRUN. - Full dogfood (owed for a narrowing): at `9634b5a3`, `pnpm --filter @objectstack/dogfood test` under the verify lock (`VERDICT command-exit 0`, 16m43s): Test Files 314 passed, 3 skipped (317); Tests 2609 passed, 20 skipped (2629); no failure. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `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: 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 (the maintainer brief is Chinese by rule). --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzhALodSKE3ejCJ1EXk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96e7a43 commit 20457f6

6 files changed

Lines changed: 323 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: a list view's chart bound to a dataset over another object is refused (`list-view-chart-dataset-foreign-object`)
6+
7+
Clause-②: no (narrowing) — a list view whose `chart.dataset` names a dataset declared over another object than the one the list view lists is refused at publish and fails `os validate` / `os build`; it saved and published before. 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: a new author-time rule refuses one combination of two existing references. There is no mechanical rewrite to prescribe, because only the author knows whether the chart belongs on the dataset's object, on a dashboard, or needs a dataset over the list's own object. -->
11+
12+
**BREAKING** accept-set narrowing in `validateListViewFieldRefs` (`@objectstack/lint`), served by the runtime save door (`PUT /api/v1/meta/view`, `PUT /api/v1/meta/object`) and by `os validate` / `os build`.
13+
14+
- **Why.** A list view's chart draws the rows of its dataset's base `object`: the list hands the dataset query the dataset name, its dimensions and its measures and nothing else, so neither the list's object nor its filter reaches the query. A chart under one object's list bound to a dataset over another object plotted that other object's records, labelled as a view of the first.
15+
- **Before.** Such a list view saved and published `200` with no advisory: measured with a view item and a view container written into one package and bound to a dataset over another package's object. No rule compared the two objects on any list-view shape.
16+
- **Now.** It is reported once per list view, at `chart.dataset`, as `list-view-chart-dataset-foreign-object` (`error`). The verdict names the view, the dataset and both objects. The publish is refused `422 INVALID_METADATA`; a draft save is not gated, as for every author-time rule.
17+
- **A dataset that reaches the list's object through a relationship is refused too.** `include` lists to-one relationships from the dataset's base object, so it adds columns to group by and never a second set of rows: a dataset over opportunities that includes their account still counts opportunities.
18+
- **Not judged.** A dataset that resolves to nothing (`chart-dataset-unknown`), a dataset that declares no `object`, and a list view whose own binding does not resolve.
19+
- **Who moves.** No list view in `examples/**` or `packages/platform-objects` is refused: the two chart list views in the showcase app bind datasets over their own objects.
20+
21+
**The remedy.** Bind a dataset declared over the list view's own object, or move the chart to a list view of the dataset's object or to a dashboard widget naming the dataset. Which one is the author's call, so nothing rewrites it for you.

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,12 @@ Author new analytics directly in dataset form: define the metric once as a
284284
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.
287+
288+
A list-view chart draws the rows of its dataset's base `object`, never the
289+
list's own records: the list's filter does not reach the dataset query. So a
290+
list view's chart must bind a dataset whose `object` is the object the list
291+
lists. A dataset over another object is refused at publish and fails
292+
`os validate` (`list-view-chart-dataset-foreign-object`), even when its
293+
`include` reaches the list's object through a relationship, because `include`
294+
adds columns to group by, not rows. Put a chart of another object's data on
295+
that object's list view or on a dashboard.

‎packages/lint/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,11 +532,14 @@ export type {
532532
// name with the tombstone's prescription (`os lint` never parses).
533533
// [#25786] Its SIXTH: a grouping level's `orderBy` naming a field the group
534534
// header query cannot order by (`min` / `max` refused for its type).
535+
// [#26719] Its SEVENTH: a list view whose `chart` binds a dataset declared
536+
// over another object than the one the list view lists.
535537
export {
536538
validateListViewFieldRefs,
537539
LIST_VIEW_FIELD_UNKNOWN,
538540
LIST_VIEW_FIELD_DOTTED,
539541
LIST_VIEW_GROUP_ORDER_UNORDERABLE,
542+
LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT,
540543
LIST_VIEW_OBJECT_OUTSIDE_CLOSURE,
541544
LIST_VIEW_OBJECT_UNKNOWN,
542545
LIST_VIEW_KEY_RETIRED,

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3440,6 +3440,27 @@ const LIST_VIEW_GROUP_ORDER_UNORDERABLE_EXPLANATION: RuleExplanation = {
34403440
],
34413441
};
34423442

3443+
const LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT_EXPLANATION: RuleExplanation = {
3444+
rule: 'list-view-chart-dataset-foreign-object',
3445+
covers: 'why a list view\'s chart must chart the object the list lists',
3446+
paragraphs: [
3447+
'A list view\'s `chart` names a dataset, and the dataset declares its own base `object`. The chart ' +
3448+
'draws that object\'s records: the list view hands the dataset query the dataset name, its ' +
3449+
'dimensions and its measures and nothing else, so neither the list\'s object nor its filter reaches ' +
3450+
'the query. A chart bound to a dataset over another object therefore plots that other object\'s ' +
3451+
'records under this object\'s list, and nothing on the screen says so.',
3452+
'A dataset whose `include` reaches the list\'s object through a relationship is refused too. ' +
3453+
'`include` lists to-one relationships from the dataset\'s base object, so it adds columns to group ' +
3454+
'by and never a second set of rows: a dataset over opportunities that includes their account still ' +
3455+
'counts opportunities. The two object names must be equal.',
3456+
'An error, so the publish is refused (`422`) and `os validate` and `os build` fail. Not judged: a ' +
3457+
'draft save, a dataset that resolves to nothing (`chart-dataset-unknown`), a dataset that declares no ' +
3458+
'`object`, and a list view whose own binding does not resolve.',
3459+
'To fix it, bind a dataset declared over the list\'s object, or move the chart to a list view of the ' +
3460+
'dataset\'s object, or to a dashboard, where a widget names its dataset without a list object.',
3461+
],
3462+
};
3463+
34433464
/** Which keys a list view's binding is read from, and what stays unjudged (both binding ids write it out). */
34443465
const LIST_VIEW_BINDING_SCOPE =
34453466
'One finding per list view, at the key the binding was read from: the view\'s `object`, or a list ' +
@@ -5201,6 +5222,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
52015222
[LIST_VIEW_FIELD_UNKNOWN_EXPLANATION.rule]: LIST_VIEW_FIELD_UNKNOWN_EXPLANATION,
52025223
[LIST_VIEW_FIELD_DOTTED_EXPLANATION.rule]: LIST_VIEW_FIELD_DOTTED_EXPLANATION,
52035224
[LIST_VIEW_GROUP_ORDER_UNORDERABLE_EXPLANATION.rule]: LIST_VIEW_GROUP_ORDER_UNORDERABLE_EXPLANATION,
5225+
[LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT_EXPLANATION.rule]: LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT_EXPLANATION,
52045226
[LIST_VIEW_OBJECT_OUTSIDE_CLOSURE_EXPLANATION.rule]: LIST_VIEW_OBJECT_OUTSIDE_CLOSURE_EXPLANATION,
52055227
[LIST_VIEW_OBJECT_UNKNOWN_EXPLANATION.rule]: LIST_VIEW_OBJECT_UNKNOWN_EXPLANATION,
52065228
[TRANSLATION_TARGET_UNKNOWN_EXPLANATION.rule]: TRANSLATION_TARGET_UNKNOWN_EXPLANATION,

‎packages/lint/src/validate-list-view-field-refs.test.ts‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
import { describe, expect, it } from 'vitest';
1313
import { runAuthoringRules, splitBySeverity } from './authoring-rules.js';
1414
import { validateReferenceIntegrity } from './reference-integrity-suite.js';
15+
import { runRuntimeAuthoringRules } from './runtime-gate.js';
1516
import {
1617
validateListViewFieldRefs as validateListViewFieldRefsUnrecorded,
1718
LIST_VIEW_FIELD_UNKNOWN,
1819
LIST_VIEW_FIELD_DOTTED,
1920
LIST_VIEW_GROUP_ORDER_UNORDERABLE,
21+
LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT,
2022
LIST_VIEW_OBJECT_OUTSIDE_CLOSURE,
2123
LIST_VIEW_OBJECT_UNKNOWN,
2224
listViewWalkedPositions,
@@ -1295,3 +1297,190 @@ describe('[#22161] one-line fixes — list-view-field-unknown, list-view-field-d
12951297
}
12961298
});
12971299
});
1300+
1301+
// [#26719] A list view's chart bound to a dataset declared over ANOTHER object
1302+
// than the one the list view lists. Measured at the save door before the rule
1303+
// landed: such a view saved and published 200 with no advisory.
1304+
describe('[#26719] a list-view chart bound to a dataset over another object', () => {
1305+
const DATASETS = [
1306+
// Over the list's own object.
1307+
{
1308+
name: 'duly_task_metrics',
1309+
object: 'duly_task',
1310+
dimensions: [{ name: 'status', field: 'status' }],
1311+
measures: [{ name: 'task_count', aggregate: 'count' }],
1312+
},
1313+
// Over another object.
1314+
{
1315+
name: 'duly_person_metrics',
1316+
object: 'duly_person',
1317+
dimensions: [{ name: 'name', field: 'name' }],
1318+
measures: [{ name: 'person_count', aggregate: 'count' }],
1319+
},
1320+
// Over `duly_task`, reaching `duly_person` through the declared `owner`
1321+
// relationship.
1322+
{
1323+
name: 'duly_task_by_owner',
1324+
object: 'duly_task',
1325+
include: ['owner'],
1326+
dimensions: [{ name: 'owner_name', field: 'owner.name' }],
1327+
measures: [{ name: 'task_count', aggregate: 'count' }],
1328+
},
1329+
];
1330+
const chartOn = (dataset: string, extra: AnyRec = {}): AnyRec => ({
1331+
name: 'by_status',
1332+
type: 'chart',
1333+
data: { provider: 'object', object: 'duly_task' },
1334+
columns: ['title'],
1335+
chart: { chartType: 'bar', dataset, dimensions: ['status'], values: ['task_count'] },
1336+
...extra,
1337+
});
1338+
const withDatasets = (stack: AnyRec): AnyRec => ({ ...stack, datasets: DATASETS });
1339+
const rulesOf = (fs: Array<{ rule: string }>) => fs.map((f) => f.rule);
1340+
1341+
it('REFUSES a dataset over another object — `error`, at `chart.dataset`, naming the view, the dataset and both objects', () => {
1342+
const findings = validateListViewFieldRefs(withDatasets(stackWith(chartOn('duly_person_metrics'))));
1343+
expect(findings).toHaveLength(1);
1344+
const [f] = findings;
1345+
expect(f).toMatchObject({
1346+
severity: 'error',
1347+
rule: LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT,
1348+
path: 'views[0].list.chart.dataset',
1349+
});
1350+
expect(f.where).toContain('duly_task');
1351+
for (const name of ['"duly_person_metrics"', '"duly_person"', '"duly_task"']) {
1352+
expect(f.message, name).toContain(name);
1353+
}
1354+
expect(f.hint).not.toContain('\n');
1355+
expect(f.hint.length, f.hint).toBeLessThanOrEqual(200);
1356+
});
1357+
1358+
it('CONTROL — a chart over a dataset on the list\'s own object reports nothing', () => {
1359+
expect(validateListViewFieldRefs(withDatasets(stackWith(chartOn('duly_task_metrics'))))).toEqual([]);
1360+
});
1361+
1362+
it('REFUSES a dataset that reaches the list\'s object only through a declared relationship', () => {
1363+
// A `duly_person` list charting a dataset over `duly_task` that includes
1364+
// `owner`: the chart still counts tasks.
1365+
const listView = chartOn('duly_task_by_owner', { data: { provider: 'object', object: 'duly_person' }, columns: ['name'] });
1366+
const findings = validateListViewFieldRefs(withDatasets(stackWith(listView)));
1367+
expect(rulesOf(findings)).toEqual([LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT]);
1368+
expect(findings[0].message).toContain('"duly_task_by_owner" over object "duly_task"');
1369+
});
1370+
1371+
it('…while the same dataset under the `duly_task` list is that object\'s own', () => {
1372+
expect(validateListViewFieldRefs(withDatasets(stackWith(chartOn('duly_task_by_owner'))))).toEqual([]);
1373+
});
1374+
1375+
it.each([
1376+
['objects[].listViews.<key>', { objects: [{ ...OBJECTS[0], listViews: { by_status: chartOn('duly_person_metrics', { data: undefined }) } }, OBJECTS[1]] }, 'objects[0].listViews.by_status.chart.dataset'],
1377+
['views[].listViews.<key>', { objects: OBJECTS, views: [{ name: 'duly_task', object: 'duly_task', listViews: { by_status: chartOn('duly_person_metrics') } }] }, 'views[0].listViews.by_status.chart.dataset'],
1378+
['a flattened list overlay', { objects: OBJECTS, views: [{ ...chartOn('duly_person_metrics'), name: 'duly_task.by_status', object: 'duly_task', viewKind: 'list' }] }, 'views[0].chart.dataset'],
1379+
['a ViewItem record', { objects: OBJECTS, views: [{ name: 'duly_task.by_status', object: 'duly_task', viewKind: 'list', config: chartOn('duly_person_metrics') }] }, 'views[0].config.chart.dataset'],
1380+
])('walks %s', (_label, stack, path) => {
1381+
const findings = validateListViewFieldRefs(withDatasets(stack as AnyRec));
1382+
expect(findings.map((f) => [f.rule, f.path])).toEqual([[LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT, path]]);
1383+
});
1384+
1385+
it('judges the list view\'s own `data.object` binding, which wins over its container\'s', () => {
1386+
// A built-in list view of `duly_task` retargeted to `duly_person`: the
1387+
// person dataset is its own, the task dataset is foreign.
1388+
const retargeted = (dataset: string) => ({
1389+
objects: [{ ...OBJECTS[0], listViews: { people: chartOn(dataset, { data: { provider: 'object', object: 'duly_person' }, columns: ['name'] }) } }, OBJECTS[1]],
1390+
});
1391+
expect(validateListViewFieldRefs(withDatasets(retargeted('duly_person_metrics')))).toEqual([]);
1392+
expect(rulesOf(validateListViewFieldRefs(withDatasets(retargeted('duly_task_metrics'))))).toEqual([
1393+
LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT,
1394+
]);
1395+
});
1396+
1397+
it('is silent where another verdict owns the case: an unknown dataset, a dataset with no `object`, an unresolved binding', () => {
1398+
expect(validateListViewFieldRefs(withDatasets(stackWith(chartOn('no_such_dataset'))))).toEqual([]);
1399+
const objectless = { ...stackWith(chartOn('loose_metrics')), datasets: [{ name: 'loose_metrics', measures: [] }] };
1400+
expect(validateListViewFieldRefs(objectless)).toEqual([]);
1401+
const unbound = stackWith(chartOn('duly_person_metrics', { data: { provider: 'object', object: 'sys_elsewhere' } }));
1402+
expect(validateListViewFieldRefs(withDatasets(unbound))).toEqual([]);
1403+
});
1404+
1405+
it('judges an object that declares no readable field map — the verdict reads object NAMES only', () => {
1406+
const external = [{ name: 'duly_task', label: 'Task', external: true }, OBJECTS[1]];
1407+
const findings = validateListViewFieldRefs(withDatasets(stackWith(chartOn('duly_person_metrics'), external)));
1408+
expect(rulesOf(findings)).toEqual([LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT]);
1409+
});
1410+
1411+
for (const command of ['validate', 'build'] as const) {
1412+
it(`fails \`${command}\``, () => {
1413+
const { errors } = splitBySeverity(
1414+
runAuthoringRules(command, { normalized: withDatasets(stackWith(chartOn('duly_person_metrics'))) }),
1415+
);
1416+
expect(errors.map((f) => f.rule)).toContain(LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT);
1417+
});
1418+
}
1419+
1420+
describe('at the runtime save door', () => {
1421+
const SCOPE = { packageId: 'com.qa.gap', dependencies: [] as string[] };
1422+
const owned = (o: AnyRec, pkg: string) => ({ ...o, _packageId: pkg });
1423+
const objects = [owned(OBJECTS[0], 'com.qa.gap'), owned(OBJECTS[1], 'com.other.app')];
1424+
const datasets = [owned(DATASETS[0], 'com.qa.gap'), owned(DATASETS[1], 'com.other.app')];
1425+
const item = (dataset: string): AnyRec => ({
1426+
name: 'duly_task.by_status',
1427+
object: 'duly_task',
1428+
viewKind: 'list',
1429+
label: 'By status',
1430+
config: chartOn(dataset),
1431+
});
1432+
const write = (dataset: string) =>
1433+
runRuntimeAuthoringRules({ type: 'view', item: item(dataset), context: { objects, datasets }, packageScope: SCOPE });
1434+
1435+
it('REFUSES a view written into one package bound to a dataset over another package\'s object', () => {
1436+
const result = write('duly_person_metrics');
1437+
expect(rulesOf(result.errors)).toEqual([LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT]);
1438+
expect(result.errors[0].path).toMatch(/chart\.dataset$/);
1439+
});
1440+
1441+
it('CONTROL — the same view bound to a dataset over its own object is admitted', () => {
1442+
const result = write('duly_task_metrics');
1443+
expect(result.errors).toEqual([]);
1444+
});
1445+
1446+
// The `object` door: an object's built-in list views ride the object write.
1447+
// A minimal object the object door's own rules admit, so the control's
1448+
// empty `errors` speaks for this rule alone.
1449+
const doorTask = owned({
1450+
name: 'duly_task',
1451+
label: 'Task',
1452+
sharingModel: 'private',
1453+
fields: [
1454+
{ name: 'title', type: 'text', label: 'Title' },
1455+
{ name: 'status', type: 'text', label: 'Status' },
1456+
],
1457+
}, 'com.qa.gap');
1458+
const writeObject = (dataset: string) =>
1459+
runRuntimeAuthoringRules({
1460+
type: 'object',
1461+
item: { ...doorTask, listViews: { by_status: chartOn(dataset, { data: undefined }) } },
1462+
context: { objects: [doorTask, objects[1]], datasets },
1463+
packageScope: SCOPE,
1464+
});
1465+
1466+
it('REFUSES an object write whose built-in list view charts a dataset over another package\'s object', () => {
1467+
const result = writeObject('duly_person_metrics');
1468+
expect(rulesOf(result.errors)).toEqual([LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT]);
1469+
expect(result.errors[0].path).toMatch(/listViews\.by_status\.chart\.dataset$/);
1470+
});
1471+
1472+
it('CONTROL — the same object write charting a dataset over itself is admitted', () => {
1473+
const result = writeObject('duly_task_metrics');
1474+
expect(result.errors).toEqual([]);
1475+
});
1476+
});
1477+
1478+
it('`os explain` carries the reasoning, the relationship answer included', () => {
1479+
const entry = explainRule(LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT);
1480+
expect(entry, 'no `os explain` entry').toBeDefined();
1481+
const text = entry!.paragraphs.join('\n');
1482+
for (const fact of ['base `object`', '`include`', 'to-one relationships', 'a draft save', '`chart-dataset-unknown`', 'dashboard']) {
1483+
expect(text, fact).toContain(fact);
1484+
}
1485+
});
1486+
});

0 commit comments

Comments
 (0)