Skip to content

Commit 46d2b65

Browse files
fix(lint)!: a report whose runtimeFilter names a field its dataset's object lacks is refused at publish (report-filter-field-unknown) (#30003)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBQ8wXxPd6sXS6s1vP1qX6L8)_ Fixes #29900 Clause-②: yes (narrowing) — the report publish door, `os validate` and `os build` refuse a report `runtimeFilter` key they accepted before; `@objectstack/lint` also exports `validateReportRuntimeFilters`, `REPORT_FILTER_FIELD_UNKNOWN` and the `ReportRuntimeFilterFinding` type. Metadata-protocol: narrowed Skills-docs: none — `skills/**` and `content/docs/**` teach `runtimeFilter` keys that are fields of the dataset's object, which this rule accepts; no page teaches a key the object lacks. The reasoning ships as the `os explain report-filter-field-unknown` entry. ## 维护者速读 - 改了什么:报表的 `runtimeFilter` 里写了数据集对象上不存在的字段时,发布(以及 `os validate` / `os build`)直接 422 拒收,报出 `reports[N].runtimeFilter.KEY`;和仪表盘组件 `filter` 早已有的拦截是同一套判定。 - 为什么改:以前这种报表发布返回 200,之后每个打开报表的人看到的都是 "Dataset query failed" 报错面板,而同样的键写在仪表盘上会被拒;报表是唯一把坏过滤键放进线上的出口。 - 风险与回滚:这是对作者输入的收窄(BREAKING)。示例应用的 9 个报表(5 个带 `runtimeFilter`)测得 0 条误报;接受范围和仪表盘孪生规则一致,另外多接受数据集维度名。回滚就是 revert 本 PR。 - 先看:`packages/lint/src/validate-report-runtime-filter.ts`(新规则)、`packages/lint/src/validate-widget-bindings.ts` 的 `judgeDatasetFilterKeys`(从孪生规则抽出的共用判定)、`packages/lint/src/reference-integrity-suite.ts` 新增的套件成员。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.dashboard-dataset-publish-gate.test.ts -t 29900`,预期 6 个用例全过(坏键 422、字段 / 维度 / 系统列放行、仪表盘对照不变)。 - 需要你做的:无,席位落地。 ## What changes - **New rule `report-filter-field-unknown`** (`error`, `packages/lint/src/validate-report-runtime-filter.ts`). Each key of a report's `runtimeFilter` is judged on the bound dataset's object graph. On a `joined` report, each block's own filter is judged against the block's dataset, and the report-level `runtimeFilter` against every block's dataset, because the renderer merges it into every block's query (`DatasetReportRenderer`: `mergeFilters(outerFilter, block.runtimeFilter)`). - **One judgement, shared with the dashboard twin.** The twin's inline key judgement in `validateWidgetBindings` moved into `judgeDatasetFilterKeys` (existence, then the `include` clause), and its dataset index into `indexResolvableDatasets`. The widget arm now calls both, with its rule ids, messages, hints and paths unchanged (the twin's suite is green unchanged). The report arm reads only the existence verdict. Neither helper is exported from the package entry. - **Accepted keys.** Everything the twin accepts: object fields, registry-injected system columns, dotted relationship paths, all `$` operators and combinators, and the three skips (an object the stack does not define, one with no readable field map, an injected column). One more: a bare key naming a **dimension** of the bound dataset, because the dataset door binds a bare `where` key through the cube's members first (`dataset-filter-field-binding.ts`: «a key naming only a dimension still resolves through that dimension»). - **Where it runs.** A reference-integrity suite member with `runtimeTypes: ['flow', 'report']`, beside `validateReportDateRange`. So it runs on `os validate`, `os build` and `os lint`, and at the runtime report publish door (draft promotion, active save, package draft publish). `defineStack` does not run lint rules, so it does not reach `defineStack` (the twin does not either). - **`os explain report-filter-field-unknown`**: a new `RULE_EXPLANATIONS` entry. - **Changeset:** `@objectstack/lint` minor, BREAKING, ADR-0087 `not-required (no-migration-prescription)`. ## Why The card's reproduction: a summary report over `orch_ds2` with `runtimeFilter: { no_such_field: 'x' }` published 200, and the Console showed every reader "Dataset query failed: 400 Bad Request — Filter member no_such_field …". The same key on a dashboard widget's `filter` was refused 422 (`widget-filter-field-unknown`), so the report was the one consumer of the same dataset query that let it through. ## Tests - `packages/lint/src/validate-report-runtime-filter.test.ts` (new, 12): the card's repro at `reports[0].runtimeFilter.no_such_field` with the twin's verdict and "Did you mean". It also covers a field key, a dimension key, a system column under `$or` / `$not`, dotted paths, a joined report (block filter, plus the container filter per block), the skips, and that the include clause is not this rule's. A **parity pin** runs six keys through both rules on one dataset and gets the same verdict from each. The `validate` and `build` commands are pinned too. - `packages/lint/src/runtime-gate.inert-type-writes.test.ts`: at the door's own snapshot shape, the card's repro is refused at its path, a joined block's own key and a container key are refused, and field, dimension, system-column and `$` keys publish clean. The report member pin moves from two members to three. - `packages/lint/src/reference-integrity-suite.test.ts`: order pin gains the member after `validateReportDateRange`. - `packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts`, a new `#29900` block on the existing harness (no new engine double, so no ledger moves). These pins run through the real `saveMetaItem` draft then `publishMetaItem`: the card's repro is refused `422 INVALID_METADATA` at `reports[0].runtimeFilter.no_such_field`, and the active save is refused the same way. A field key, a dimension key and a system column under `$and` each publish. The dashboard control on the same dataset is still refused `widget-filter-field-unknown` at `dashboards[0].widgets[0].filter.no_such_field`. ## Acceptance notes - **Ruling premise measured false, intent kept.** The ruling lists "dataset dimensions" among keys the twin already accepts. Measured: the twin **refuses** a bare dimension name that differs from its field. A probe of a dimension `state` over field `status` with `filter: { state: 1 }` answered `widget-filter-field-unknown` at `dashboards[0].widgets[0].filter.state`. The report rule accepts it, as the ruling asks; that refuses less than the twin, never more. Whether the twin should accept it too is reported to the seat, not changed here. - **Cross-package case (measured, not changed).** At the door, with `packageScope: { packageId: 'P', dependencies: [] }` and the dataset's object carrying `_packageId: 'showcase'`, both `widget-filter-field-unknown` and `report-filter-field-unknown` are silent. With `dependencies: ['showcase']`, or with no scope, both refuse. The mechanism is `buildRuntimeWriteSnapshotSet`, which narrows `objects` to the written package's closure (#9612) but not `datasets`. A dataset outside the closure therefore still resolves, while its object is absent, and skip 1 fires. This is a gap and not a stated exemption, since no comment rules that an out-of-closure dataset's keys go unjudged. Reported to the seat to file. - **Not this rule's:** a dotted key that resolves but whose prefix the dataset's `include` omits (the twin's `widget-filter-field-not-included`). The report arm ignores that verdict, and a pin holds it silent. - **Declarations, measured against the twin.** `widget-filter-field-unknown` landed in `fa1eca3` as a minor "narrows the accept set" change, before Clause-② existed. This PR declares the narrowing and also exports a new validator, rule id and type from the `@objectstack/lint` entry, so the line is `yes (narrowing)`; the claim's provisional line was `no (narrowing)`. The changeset follows `ae7f49e` (a refusal of input that never worked): BREAKING, Before/Now, ADR-0087 `not-required (no-migration-prescription)`. A literal `FROM → TO.` label is refused by `check-adr-0087-registration` as contradicting `no-migration-prescription`. A refused key has no rewrite that keeps the author's meaning, so the body says what the author does instead. - **Shipped corpus:** the rule over `examples/app-showcase` (4 reports, 1 with `runtimeFilter`), `app-todo` (5, 4 with it), `app-crm` (0) and `app-multi-package` (0) gives **0 findings**. A positive control on a showcase report with `no_such_field_probe` fires at `reports[0].runtimeFilter.no_such_field_probe`. ## Verification Every reading below is at head `29690d3da`. **Fix-up after contract review 6620744276 (head `c238f6f96`).** `e88d432c9` changes the changeset's `Skills-docs:` value to `none — …`, matching this body. `c238f6f96` merges `origin/main` at `0b99c2b16` (#29992) with `scripts/pm/os-regen-merge.sh`; the merge is clean, and the branch diff against main is still the same 10 files. On the merged head, after a full `turbo run build` (120/120 tasks): - `pnpm --filter @objectstack/lint test`: 193 files and 7969 tests pass. - The `#29900` route pins: 13/13 pass. - The three dogfood files main moved, `signup-organization-census`, `signup-organization-census-walled` and `batch-row-hook-aborted`: 3 files and 29 tests pass. That clears the two signup reds from the battery below. - Derived gates: 58 derived, 58 run, all exit 0 (`--ran`: 58 run, 0 NOT-MEASURED). <details><summary>Ablation (one, of the rule)</summary> Mutation: `if (verdict.kind !== 'unknown') continue;` → `if (verdict.kind !== 'unknown' || 'ABLATE29900'.length > 0) continue;` in `validate-report-runtime-filter.ts`, via `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `3b984fd59a88` → `c25f0755128f`). Then `pnpm --filter @objectstack/lint build` ran, and `ablation-dist-preflight.mjs @objectstack/lint ABLATE29900` exited 0 (the marker is in `dist/`). - Red: lint `validate-report-runtime-filter.test.ts` + `runtime-gate.inert-type-writes.test.ts` gave 10 failed / 52 passed, including the card's repro, the door LIT pins and the `validate` / `build` pins. metadata-protocol `-t 29900` gave 2 failed / 4 passed: the draft→publish 422 and the active-save 422. The controls stayed green. - Restore: blob back to `3b984fd59a88` (== HEAD), `git diff HEAD` empty, rebuilt, `--absent` preflight exit 0; 62/62 and 6/6 green. </details> <details><summary>Package tests and typecheck</summary> - `pnpm --filter @objectstack/lint test`: Test Files 193 passed, Tests 7969 passed; `typecheck` (tsc + test-typecheck) exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 313 passed | 4 skipped, Tests 22345 passed | 26 skipped; `typecheck` exit 0. - `eslint --no-inline-config` over the 9 changed source/test files: exit 0. - `pnpm lint` (repo-wide `eslint . --no-inline-config`): exit 0. </details> <details><summary>Derived gates (dispatch-gates --commands)</summary> `node scripts/pm/dispatch-gates.mjs --commands` on head `29690d3da` derives 58 commands; all 58 ran after the full build and exited 0. `--ran` reconciliation: "58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED". - In the first pass, three commands exited 3 for environment reasons, and reran green: `check-plugin-teardown-shape --self-test` (shallow clone; fixture commit fetched, 48 cases), `check:dual-build-cjs-loads` (needed the full build), and `check-adr-0087-registration` (exit 1, a `FROM → TO.` label beside `no-migration-prescription`, fixed in the changeset). - `check:engine-double-contract` and `check:objectql-double-limit` were red on a copied stub engine in a new test file. The route pins moved into the existing harness, so no ledger moves, and both are green. </details> <details><summary>Dogfood battery (narrowing arm)</summary> Head `29690d3da`, one locked command: `pnpm exec turbo run build --concurrency=2 && pnpm --filter @objectstack/dogfood test` (no shard, no path). `os-verify-lock: VERDICT command-exit 1 · held the lock 2841s`. Test Files 1 failed | 414 passed | 3 skipped (418), Tests 2 failed | 3431 passed | 28 skipped (3461). - The 2 failures are both in `test/signup-organization-census.dogfood.test.ts`: "guard: the boot is single, and the sign-up reached all three writers" ("the verification mail persisted, in the identity-mail log: expected 0 to be greater than 0") and "sys_identity_email carries no organization column, and the sign-up's mail row names none". - **Not this change.** The file alone on this head gives 2 failed / 1 passed. As a control, the file alone with `packages/lint/src` checked out at `origin/main` and lint rebuilt (the dist carries 0 `report-filter-field-unknown`) gives the same 2 failed / 1 passed. Restored afterwards: tree clean, lint rebuilt (2 hits). The test exercises self-registration mail, which no lint rule reads. Reported to the seat as out of scope. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [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: n/a. - [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 the repo's required Chinese summary). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzhd49bgGPqyqFfVu83 <!-- signed: domain:server#6 · os-support-ai · session_01WGufzhd49bgGPqyqFfVu83 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f19fbb8 commit 46d2b65

10 files changed

Lines changed: 666 additions & 62 deletions
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 report whose `runtimeFilter` names a field its dataset's object lacks is refused at publish (`report-filter-field-unknown`), as the dashboard twin already is (#29900)
6+
7+
Clause-②: yes (narrowing) — the report publish door, `os validate` and `os build` refuse a report `runtimeFilter` key they accepted before; `@objectstack/lint` also exports `validateReportRuntimeFilters`, `REPORT_FILTER_FIELD_UNKNOWN` and the `ReportRuntimeFilterFinding` type.
8+
Metadata-protocol: narrowed
9+
Skills-docs: none — `skills/**` and `content/docs/**` teach `runtimeFilter` keys that are fields of the dataset's object, which this rule accepts; no page teaches a key the object lacks. The reasoning ships as the `os explain report-filter-field-unknown` entry.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: ReportSchema.runtimeFilter keeps its shape and meaning, and the build and the publish door now give its keys the verdict a dashboard widget filter key already gets. A refused key never served a report (every reader saw Dataset query failed), so there is no rewrite that keeps what the author meant; the author names a real field or dimension, and the refusal says which. -->
12+
13+
**BREAKING** accept-set narrowing at the report publish door (`PUT /api/v1/meta/report/NAME`, `POST /api/v1/meta/report/NAME/publish`, package draft publish; Studio, REST and MCP authors) and in `os validate` / `os build`.
14+
15+
- **Before.** A report with `runtimeFilter: { no_such_field: 'x' }` over a dataset whose object has no `no_such_field` published `200`, and every reader of the report saw a "Dataset query failed: 400 Bad Request — Filter member no_such_field …" panel instead of numbers. The same key on a dashboard widget's `filter` was already refused `422` (`widget-filter-field-unknown`).
16+
- **Now.** It is refused `422 INVALID_METADATA` with `report-filter-field-unknown` at `reports[0].runtimeFilter.no_such_field`, and the verdict names the object and the nearest field ("Did you mean …"). A `joined` report's block filter is judged against the block's dataset, and the report-level `runtimeFilter` (merged into every block's query) against each block's dataset.
17+
- **Accepted, as on a widget.** A field of the dataset's object, a registry-injected system column (`created_at`, `owner_id` where injected), a dotted relationship path, and every `$` operator and combinator. One more key is accepted on a report: a bare key naming a dimension of the bound dataset, which the dataset query binds through that dimension.
18+
- **Not judged.** A `dataset` that resolves to nothing (`chart-dataset-unknown` reports it once), an object the stack (or the publish snapshot) does not carry, an object with no readable field map, and a dotted key whose prefix the dataset's `include` does not declare.
19+
- **`os explain report-filter-field-unknown`** prints the reasoning; `RULE_EXPLANATIONS` gains the entry.
20+
21+
**What an author does.** Nothing for a report whose filter keys name real fields or dimensions. A refused key never served the report (every reader saw the error panel), so there is no rewrite that keeps what the author meant: name a field of the dataset's object, a dimension of the dataset, or a `relationship.field` path whose prefix the dataset's `include` declares. The refusal names the key and the nearest field.

‎packages/lint/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,14 @@ export {
721721
} from './validate-report-date-range.js';
722722
export type { ReportDateRangeFinding } from './validate-report-date-range.js';
723723

724+
// [#29900] A report's `runtimeFilter` keys resolved on its dataset's object
725+
// graph, by the dashboard twin's own judgement (`widget-filter-field-unknown`).
726+
export {
727+
validateReportRuntimeFilters,
728+
REPORT_FILTER_FIELD_UNKNOWN,
729+
} from './validate-report-runtime-filter.js';
730+
export type { ReportRuntimeFilterFinding } from './validate-report-runtime-filter.js';
731+
724732
// [#28473] ADR-0146 D3: a package namespace too long to have a paired
725733
// customization namespace, judged with `customizationNamespace` itself.
726734
export {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ describe('reference-integrity suite — membership', () => {
5353
// same dataset's date dimensions — placed beside the chart member that
5454
// resolves the rest of that report's bindings.
5555
'validateReportDateRange',
56+
// [#29900] The same report's `runtimeFilter` keys, resolved on the same
57+
// dataset's object graph by the dashboard twin's judgement.
58+
'validateReportRuntimeFilters',
5659
// [#14105] The dataset's OWN references, one level below the two binding
5760
// rules above it — the conscious edit this written-out list exists to
5861
// force, placed so the family reports top-down.

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ import { validatePageFieldBindings } from './validate-page-field-bindings.js';
111111
import { validatePageVisualizationBindings } from './validate-page-visualization-bindings.js';
112112
import { validateChartBindings } from './validate-chart-bindings.js';
113113
import { validateReportDateRange } from './validate-report-date-range.js';
114+
import { validateReportRuntimeFilters } from './validate-report-runtime-filter.js';
114115
import { validateDatasetReferences } from './validate-dataset-references.js';
115116
import { validateMappingTargetFields } from './validate-mapping-target-fields.js';
116117
import { validateNavAccess } from './validate-nav-access.js';
@@ -514,6 +515,23 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
514515
// report declares `dateRange`, so 0 findings by construction, and the door
515516
// test's lit probes are refused (`runtime-gate.inert-type-writes.test.ts`).
516517
{ name: 'validateReportDateRange', runtimeTypes: ['flow', 'report'], run: validateReportDateRange },
518+
// [#29900] The same report, its `runtimeFilter` KEYS: each resolved on the
519+
// bound dataset's object graph by `judgeDatasetFilterKeys`, the judgement the
520+
// dashboard twin `widget-filter-field-unknown` runs on a widget's own
521+
// `filter` (a dimension name of the dataset is accepted too: the dataset door
522+
// binds a bare key through the cube's members first). Without it a report
523+
// whose filter names a field the object lacks published 200 and every reader
524+
// got "Dataset query failed", while the identical key on a widget was refused.
525+
//
526+
// `report` joins the `['flow']` default for its siblings' reason: the two
527+
// collections it resolves into, `stack.datasets` and `stack.objects`, are both
528+
// carried by the per-write report snapshot (`RuntimeStackContext`), and its
529+
// skips (a dataset that resolves to nothing, an object this snapshot does not
530+
// carry, one with no readable field map, a registry-injected column) keep a
531+
// partial snapshot from manufacturing a refusal. A refusal NARROWING: MEASURED
532+
// over the shipped report corpus before crossing (see the PR), with lit
533+
// probes refused at the door (`runtime-gate.inert-type-writes.test.ts`).
534+
{ name: 'validateReportRuntimeFilters', runtimeTypes: ['flow', 'report'], run: validateReportRuntimeFilters },
517535
// [#14105] One level BELOW the two members above it. `validateChartBindings`
518536
// and `validateWidgetBindings` resolve a presentation's binding against the
519537
// dataset (#7529/#8902); this one resolves the DATASET's own references —

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5490,6 +5490,30 @@ const REPORT_DATE_RANGE_DIMENSION_NONE_EXPLANATION: RuleExplanation = {
54905490
paragraphs: REPORT_DATE_RANGE_SHARED_PARAGRAPHS,
54915491
};
54925492

5493+
// ── Report runtime filter (`validate-report-runtime-filter.ts`) ─────────────
5494+
5495+
const REPORT_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = {
5496+
rule: 'report-filter-field-unknown',
5497+
covers: 'what an unresolved report filter key does',
5498+
paragraphs: [
5499+
'A report\'s `runtimeFilter` is ANDed into its dataset query, exactly as a dashboard widget\'s own ' +
5500+
'`filter` is. The dataset query refuses a key it cannot bind, so a report whose filter names a ' +
5501+
'field its dataset\'s object lacks shows every reader a "Dataset query failed" panel instead of ' +
5502+
'numbers. On a `joined` report the report-level `runtimeFilter` is merged into every block\'s ' +
5503+
'query, so it is judged against each block\'s dataset, and each block\'s own filter against its ' +
5504+
'own.',
5505+
'Each key is judged as `widget-filter-field-unknown` judges a widget filter key — a dotted ' +
5506+
'`relationship.field` path hop by hop, a bare name against the authored and registry-injected ' +
5507+
'columns — and one more name is accepted: a bare key naming a dimension of the bound dataset, ' +
5508+
'which the dataset query binds through that dimension. Not reported: a `dataset` that resolves to ' +
5509+
'nothing (`chart-dataset-unknown` reports that once), an object this stack does not define, or ' +
5510+
'one with no readable field map.',
5511+
'To fix it, filter on a field of the dataset\'s object, on a dimension of the dataset, or on a ' +
5512+
'`relationship[.relationship].field` path whose prefix the dataset\'s `include` declares. The ' +
5513+
'verdict\'s "Did you mean" names the nearest field of the object the miss landed on.',
5514+
],
5515+
};
5516+
54935517
// The pairing rule lives in `@objectstack/spec/kernel`'s `customization-package.ts`
54945518
// (`customizationNamespace`, `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH`,
54955519
// `CUSTOMIZATION_NAMESPACE_SUFFIX`); `rule-explanations.test.ts` holds the
@@ -5762,6 +5786,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
57625786
[REPORT_DATE_RANGE_DIMENSION_NOT_DATE_EXPLANATION.rule]: REPORT_DATE_RANGE_DIMENSION_NOT_DATE_EXPLANATION,
57635787
[REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS_EXPLANATION.rule]: REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS_EXPLANATION,
57645788
[REPORT_DATE_RANGE_DIMENSION_NONE_EXPLANATION.rule]: REPORT_DATE_RANGE_DIMENSION_NONE_EXPLANATION,
5789+
[REPORT_FILTER_FIELD_UNKNOWN_EXPLANATION.rule]: REPORT_FILTER_FIELD_UNKNOWN_EXPLANATION,
57655790
[NAMESPACE_NO_CUSTOMIZATION_PAIR_EXPLANATION.rule]: NAMESPACE_NO_CUSTOMIZATION_PAIR_EXPLANATION,
57665791
[FLOW_REGION_SHAPE_INVALID_EXPLANATION.rule]: FLOW_REGION_SHAPE_INVALID_EXPLANATION,
57675792
});

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

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,73 @@ describe('#26548 — the `report` door judges a date range against the dataset i
527527
});
528528
});
529529

530+
describe('#29900 — the `report` door resolves `runtimeFilter` keys as the dashboard twin does', () => {
531+
// The card's shape at the door's own snapshot: a summary report over a
532+
// dataset of `acme_invoice`, its dimension `state` named apart from the field
533+
// `status` it reads so that "a dimension key is accepted" is not the same
534+
// fact as "a field key is accepted".
535+
const FILTERED = {
536+
objects: OBJECTS,
537+
datasets: [
538+
...DATASETS,
539+
{
540+
name: 'acme_invoice_states',
541+
label: 'Invoice States',
542+
object: 'acme_invoice',
543+
dimensions: [{ name: 'state', label: 'State', field: 'status', type: 'string' }],
544+
measures: [{ name: 'total_sum', label: 'Total', aggregate: 'sum', field: 'total' }],
545+
},
546+
],
547+
};
548+
const report = (over: Record<string, unknown>) => ({
549+
name: 'acme_filtered_report',
550+
label: 'Open Invoices',
551+
type: 'summary',
552+
dataset: 'acme_invoice_states',
553+
rows: ['state'],
554+
values: ['total_sum'],
555+
...over,
556+
});
557+
558+
it('⭐ LIT — the card\'s repro: a key the object lacks is REFUSED at its path', () => {
559+
const result = runRuntimeAuthoringRules({
560+
type: 'report',
561+
item: report({ runtimeFilter: { no_such_field: 'x' } }),
562+
context: FILTERED,
563+
});
564+
expect(result.rulesRun).toContain('validateReferenceIntegrity');
565+
expect(ruleIds(result.errors), dump(result)).toEqual(['report-filter-field-unknown']);
566+
expect(result.errors[0].path).toBe('reports[0].runtimeFilter.no_such_field');
567+
});
568+
569+
it('⭐ LIT — a joined block\'s own key, and a container key a block\'s object lacks', () => {
570+
const joined = {
571+
name: 'acme_joined_report',
572+
label: 'Joined',
573+
type: 'joined',
574+
runtimeFilter: { nope: 1 },
575+
blocks: [{ name: 'b1', type: 'summary', dataset: 'acme_invoice_states', rows: ['state'], values: ['total_sum'], runtimeFilter: { also_nope: 1 } }],
576+
};
577+
const result = runRuntimeAuthoringRules({ type: 'report', item: joined, context: FILTERED });
578+
expect(result.errors.map((f) => [f.rule, f.path]), dump(result)).toEqual([
579+
['report-filter-field-unknown', 'reports[0].runtimeFilter.nope'],
580+
['report-filter-field-unknown', 'reports[0].blocks[0].runtimeFilter.also_nope'],
581+
]);
582+
});
583+
584+
it('⭐ CONTROL — a field key, a dimension key, a system column and `$` operators publish clean', () => {
585+
const result = runRuntimeAuthoringRules({
586+
type: 'report',
587+
item: report({
588+
runtimeFilter: { $and: [{ status: 'open' }, { state: { $ne: 'void' } }, { created_at: { $gte: '{today}' } }] },
589+
}),
590+
context: FILTERED,
591+
});
592+
expect(result.errors, dump(result)).toEqual([]);
593+
expect(result.advisories, dump(result)).toEqual([]);
594+
});
595+
});
596+
530597
// ─── Group A · `skill` — outside the door BY DESIGN, and these pins hold it ──
531598

532599
describe('#19527 — `skill` stays outside the door by design: tool references are a whole-stack verdict', () => {
@@ -928,15 +995,19 @@ describe('#19542 — DARK: what the five crossings did NOT widen', () => {
928995
}
929996
});
930997

931-
it('a `report` write reaches exactly TWO suite members, and a `skill` write none', () => {
998+
it('a `report` write reaches exactly THREE suite members, and a `skill` write none', () => {
932999
// The entry-level `runtimeTypes` says which WRITES dispatch the suite; the
9331000
// per-member `runtimeTypes` says which MEMBERS judge that snapshot. Asked
9341001
// against the real table so a member widened without this file noticing
9351002
// fails here.
9361003
const membersFor = (type: string) =>
9371004
REFERENCE_INTEGRITY_RULES.filter((m) => (m.runtimeTypes ?? ['flow']).includes(type)).map((m) => m.name);
9381005

939-
expect(membersFor('report')).toEqual(['validateChartBindings', 'validateReportDateRange']);
1006+
expect(membersFor('report')).toEqual([
1007+
'validateChartBindings',
1008+
'validateReportDateRange',
1009+
'validateReportRuntimeFilters',
1010+
]);
9401011
// Outside the door by design (ruling `5791822697`) — the entry does not
9411012
// dispatch a skill write and no member claims one, so the two halves
9421013
// agree. See the `skill` block above.

0 commit comments

Comments
 (0)