Skip to content

Commit bb91ae8

Browse files
feat(spec): a report declares its period (dateRange) and the export of its table (exportOptions) (#26628)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBMfEB4BKutj59fyVsomGnz7)_ Fixes #26548 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up owed — skills/objectstack-ui/evals/analytics-inline-vs-dataset.json ## 维护者速读 - **改了什么:** 报表元数据新增两个可选键。`dateRange` 是报表覆盖的时间段,预设表、默认值和改名提示与仪表盘逐字相同,作用于数据集里一个 `type: 'date'` 的维度;`exportOptions` 是报表表格的下载(csv / xlsx,默认开启,`formats: []` 关闭)。另加一条 lint 规则(判定维度是否存在、是否日期、省略时是否唯一)、表单「时间段与导出」分区、文档和三种语言的翻译。 - **为什么改:** 裁决 B(6408567749,#25873)要求报表有时间范围和导出,并复用现有的导出权限,不做订阅。这是规格的一半;Console 报表页的读取在 #25873。 - **风险与回滚:** 只加可选键,不声明这两个键的报表解析和渲染都不变。渲染器落地前两个键没有读取方,作者写了会收到 planned 警告,说明暂时不生效。回滚就是 revert 本 PR。 - **先看:** `packages/spec/src/ui/report.zod.ts`(两个新键和 joined 报表的拒绝),`packages/lint/src/validate-report-date-range.ts`(四种判定,也接在报表发布门上),`packages/spec/src/ui/list-view-export-options.ts`(`ReportExportOptionsSchema`,只留 csv / xlsx)。 - **两分钟验证:** `pnpm --filter @objectstack/spec exec vitest run src/ui/report-date-range-export.test.ts`,预期 48 个用例全部通过,其中一条断言报表的 `defaultRange` / `allowCustomRange` 与仪表盘的声明逐字相等。 - **需要你做的:** 本 PR 改到 `skills/**`,属受管面:席位合同评审通过后由你合并。 ## What changes **`ReportSchema` gains two optional top-level keys** (`packages/spec/src/ui/report.zod.ts`). A report that declares neither parses exactly as before, with neither key in its output. - **`dateRange: { dimension?, defaultRange, allowCustomRange }`**, a closed block. `defaultRange` and `allowCustomRange` are the dashboard's declaration: the same `DATE_RANGE_DEFAULT_RANGES` import (no second preset table), the same defaults (`this_month`, `true`), the same describes and the same rename hints (`preset` / `range` / `default`, `allowCustom` / `custom`). `report-date-range-export.test.ts` holds the two declarations equal through `strictObjectDeclarations()` and their JSON Schema, so `dashboard.zod.ts` is untouched. `dimension` names a dataset **dimension** that declares `type: 'date'` (ADR-0021: a report selects by dimension name), with the dashboard `compareTo` hints `field` / `dateField` / `fieldName` / `timeDimension` renamed onto it. The TSDoc states the renderer contract: the window rides `selection.timeDimensions: [{ dimension, dateRange }]` with no `granularity`, a filter window the analytics service already accepts. Top-level aliases `dateFilter` / `timeRange` → `dateRange` (the dashboard's). - **`exportOptions: { formats?, includeHeaders?, fileNamePrefix? }`**, declared as `ReportExportOptionsSchema` beside its list sibling in `list-view-export-options.ts`. `includeHeaders` and `fileNamePrefix` are the list block's members by identity; `formats` is `ListViewExportFormatSchema.exclude(['json'])`, so `csv` / `xlsx`. `maxRecords` / `streaming` are answered as list-only. No permission key: the TSDoc and describe name `ObjectPermission.allowExport` on the dataset base object, the predicate the list toolbar uses. Default on (both formats), `formats: []` off. - **Joined container.** A `joined` report refuses both keys, in the superRefine arm that already refuses the container `chart`, with the same no-pointer style. `JoinedReportBlockSchema` gets `guidance` for both keys. - **Guidance** on `ReportSchema` for `allowExport` (points at the permission-set axis and `exportOptions: { formats: [] }`), `subscription` and `schedule` (no scheduled delivery of a report). - **Lint rule `validateReportDateRange`** (`packages/lint/src/validate-report-date-range.ts`), in the reference-integrity suite beside `validateChartBindings`, so it runs on `os validate` / `os lint` / `os build` and at the `report` publish door (`runtimeTypes: ['flow', 'report']`). Four `error` verdicts, each with an `os explain` entry: `report-date-range-dimension-unknown`, `-not-date`, `-ambiguous` (omitted, two or more date dimensions) and `-none` (omitted, none). The omitted-dimension rule is the dashboard executor's `compareTo` rule: exactly one candidate is used. - **Liveness.** `liveness/report.json` rows for both keys, `planned`, `authorWarn` with an author hint, notes naming the renderer read point in `DatasetReportRenderer.tsx` and #25873 as carrier. Both containers are drilled (`children`, six child rows) so the gate's undeclared-inheritance check is satisfied. `lint-liveness-properties` now walks `reports`, so the warning reaches authors. - **Form.** `report.form.ts` gets a collapsed "Period & export" section, `visibleWhen: "data.type != 'joined'"`, with both composites. Translated in `@objectstack/platform-objects` for zh-CN, ja-JP and es-ES. - **Docs.** `content/docs/ui/reports.mdx` § "Period and export" (an `os:check` example, the dimension rule, the window lowering, the export permission axis, a "declared, not drawn yet" callout, what is not offered) and a paragraph under "Permissions and protection". `skills/objectstack-ui/rules/dashboards.md` § Report Configuration gets the `dateRange` example. - **Generated:** `content/docs/references/ui/report.mdx`, `authorable-surface/ui.json`, the strictness-ledger counts and the liveness state counts. - **Changeset:** `@objectstack/spec` minor, `@objectstack/lint` minor, `@objectstack/platform-objects` patch, with both declaration lines. **Skills readings** (`skills/**` touched): | Reading | Before | After | | --- | --- | --- | | `skills/objectstack-ui/rules/dashboards.md` lines | 464 | 463 | | `skills/objectstack-ui/rules/dashboards.md` tokens (ratchet, ceiling 6239) | 6239 | 6236 | | `skills/objectstack-ui/**` lines | 2260 | 2259 | | all `SKILL.md` lines | 4600 | 4600 | | bundle total tokens | 213407 | 213404 | The three-line `order` comment becomes a trailing comment on `order`, and the freed lines carry a one-line period comment plus the `dateRange` example, so the file still shrinks by one line and 3 tokens. Before is `origin/main` at the round-3 merge. The eval is the owed half (filed as #26633): both `skills/objectstack-ui/evals/*.json` files sit at their token ceiling (headroom 0), so a report-period prompt cannot be added here without deleting another prompt. ## Tests - `packages/spec/src/ui/report-date-range-export.test.ts` (new, 48 cases): untouched reports, `dateRange: {}` defaults, every preset accepted, `last_6_months` refused, every rename hint, unknown member, `dateFilter` / `timeRange`, joined refusal on container and block, declaration parity with the dashboard, `exportOptions` accepts and refusals (`json`, `pdf`, `docx`, `maxRecords`, `streaming`, bare array, joined, block), `allowExport` / `subscription` / `schedule` guidance, `defineReport` and `getMetadataTypeSchema('report')`. - `packages/lint/src/validate-report-date-range.test.ts` (new): all four verdicts on the `app-todo` task-dataset shape, nearest-name suggestion among date dimensions only, silence on unbound / joined / malformed input, suite integration, verdict length and `os explain` per id. - `runtime-gate.inert-type-writes.test.ts`: a new block proving the `report` publish door judges a date range against the dataset it binds (four cases and a control); the door's member pin is now two members. - `lint-liveness-properties.test.ts`: both keys warn with the authored hint; a report without them is silent. - Pins moved by the new form section: `report-form-echo-decisions.test.ts` (population 44 → 50), the catalog-wide positive control in `object-lifecycle-panel-echo-decisions.test.ts` (683 → 686), `validate-predicate-path-refs.test.ts` predicate census (88 → 89, literal comparisons 63 → 64). Each carries a dated comment. - `packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts`: the served `/meta/types` census pins `report` at 23 top-level properties (was 21), with a JSDoc line saying why. The round-3 merge of `main` kept main's lowered counts for the other types (#26599). ## Acceptance notes Read against the card's Acceptance list. Deviations, each a choice made here: 1. **`exportOptions` is also refused on a `joined` container.** The card decides the joined case for `dateRange` only. A joined report draws one table per block, so "the table the report draws" has no single answer. Widening later is non-breaking. Raised as an open question in the report. 2. **`json` and `pdf` get report-side messages.** The card says `pdf` keeps the list's retirement prescription, but that text sends the author to list behaviour that is false for a report. The report message for `pdf` points at a page with `print`. 3. **The `subscription` / `schedule` guidance cites no ruling.** Runtime strings carry no tracker numbers in this repo, so the text says what is true (no scheduled delivery) without the ruling reference. 4. **The lint rule sits on the `report` publish door** as well as `flow` (the chart-bindings precedent), so publishing a report with a bad `dimension` is refused, as the docs say. 5. **The skills example uses `this_year`, not `this_quarter`.** The example's matrix buckets `close_date` by quarter, so a quarter window would draw one column. 6. **Translations.** Only `@objectstack/platform-objects` bundles metadata forms, so the six new leaves landed there (three locales, authored, not filled); `pnpm check:i18n` reports all ten packages in sync. 7. **No new public export.** `ReportExportOptionsSchema` lives in `list-view-export-options.ts`, which, like its list sibling, is outside the `ui` barrel; `check:api-surface` is unchanged. Out of scope, untouched: `ui/` (the renderer is #25873), any server export route, `dashboard.zod.ts`. ## Verification All readings local, final head `be4a0204cdad`. It merges `origin/main` at `86ae47e13` (no rebase); `main` moved 6 commits during the test run, none touching a file this PR changes, and `git merge-tree` against `8222fa805` is clean. CI check-runs are not cited here: they report after this push. - **Package suites (round 1, at `0c548c59a8ca`):** `@objectstack/spec` 729 files / 22095 tests passed (1 skipped, 1 todo); `@objectstack/lint` 167 files / 7411 passed; `@objectstack/platform-objects` 96 files / 1361 passed. Typecheck of all three: exit 0. `eslint` on the 15 changed source files: exit 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands` named 113 families at `4dca57f78`; all were run on a full workspace build (118 turbo tasks) with each exit code recorded before any pipe. One was red: `check:empty-state` (exit 1), because the `dateRange` TSDoc says an absent window shows every period and nothing classified it. Fixed in `8c03abecb`: the sentence now sits beside the key and `dateRange` is registered as a `scope` empty state (it widens the period, never the access). That commit derived one more family (`check:pnpm-flag-spellings`). Re-run after it: `check:empty-state`, `check:pnpm-flag-spellings`, `check:spec-docblock-symbol-anchors`, `check:liveness`, `check:docs`, `check:api-surface`, `check:nul-bytes`, all exit 0. `dispatch-gates --ran`: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. - **Round 3, on the merged tree (`be4a0204cdad`):** the merge conflicted only in `CARD_PROPERTY_COUNTS` (`protocol.meta-types-degenerate-derivation.test.ts`), resolved to main's lowered counts with `report: 23` and both comment blocks. `pnpm install`, then `check:generated --fix` regenerated `authorable-surface/ui.json` (main's file plus `ui/Report:dateRange` and `ui/Report:exportOptions`), then 0 stale. `check:empty-state` and `check:liveness` exit 0. Suites, each exit 0: `@objectstack/spec` 732 files / 21956 passed (1 todo); `@objectstack/lint` 167 / 7411; `@objectstack/platform-objects` 96 / 1361; `@objectstack/metadata-protocol` 269 files (3 skipped) / 21580 passed (19 skipped), census file 18 / 18; `@objectstack/plugin-security` 230 / 4991 passed (45 skipped). - **Local-only families:** `check:dev-prereqs` exit 0, `check:skill-frame-freshness` exit 0. <details> <summary>check:liveness (report rows)</summary> ``` report 27 classified (live 21, planned 6) ✓ every governed-type property, at every depth the ledger drills, is classified, … every container inheritance is declared, … total across the shards: 1061 live · 1 experimental · 1 live-elsewhere · 113 dead · 29 planned = 1205 classified. ``` </details> <details> <summary>check:i18n and the translation ledgers</summary> ``` check-i18n-bundles: OK (10 package(s) — all bundles in sync, no undeclared authoring keys). platform-objects src/apps/translations: Test Files 25 passed (25), Tests 438 passed (438) ``` </details> <details> <summary>Skills token ratchet</summary> ``` ✓ check-skills-token-ratchet: 81 authored bundle file(s) within their ceilings; 10 generator-owned file(s) measured, not ratcheted. skills/objectstack-ui/rules/dashboards.md is 6236 tokens (ceiling 6239; headroom 3) ``` </details> <details> <summary>Focused tests</summary> ``` packages/spec src/ui/report-date-range-export.test.ts Tests 48 passed (48) packages/spec report.test.ts, report-date-range-export, alias-integrity, metadata-form-zod-reconciliation Test Files 4 passed, Tests 216 passed packages/lint validate-report-date-range, runtime-gate.inert-type-writes, lint-liveness-properties, reference-integrity-suite, rule-explanations Tests 191 passed (191) ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzhMJrMdCkQS33DYtmu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent af1e1e0 commit bb91ae8

30 files changed

Lines changed: 1266 additions & 24 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
feat(spec): a report declares the period it covers (`dateRange`) and the export of its table (`exportOptions`)
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: added
11+
12+
`ReportSchema` gains two optional top-level keys. A report that declares neither parses and renders exactly as before.
13+
14+
- **`dateRange: { dimension?, defaultRange, allowCustomRange }`.** A date window over one dimension of the report's dataset. `defaultRange` and `allowCustomRange` are the dashboard's own declaration, verbatim: the same preset table (`DATE_RANGE_DEFAULT_RANGES`), the same defaults (`this_month`, `true`) and the same rename hints. `dimension` names a dataset **dimension** that declares `type: 'date'`, not a raw object field; omitted, the dataset's one `type: 'date'` dimension is used. The window is a filter, not a bucket. The renderer sends it as `selection.timeDimensions: [{ dimension, dateRange }]` with no `granularity`, which the analytics service already accepts. `dateFilter` / `timeRange` are renamed onto `dateRange`, the dashboard's own spellings.
15+
- **`exportOptions: { formats?, includeHeaders?, fileNamePrefix? }`.** Three members of the list export block, under its key name. `formats` is `csv` / `xlsx` only (`json` and `pdf` are refused with a report-side message). Export is on by default (both formats) and `formats: []` turns it off. `maxRecords` and `streaming` are list-only and are refused with that reason. There is no report permission key: the control is gated on the dataset base object's `ObjectPermission.allowExport`, the axis the list toolbar uses. `allowExport` written on a report is answered with that pointer.
16+
- **Refused on a `joined` report**, on the container and on a block. The blocks may bind different datasets, and the report draws one table per block.
17+
- **No subscription.** `subscription` / `schedule` on a report are answered: there is no scheduled delivery of a report.
18+
19+
`@objectstack/lint` adds the dataset-aware half as a reference-integrity rule, `validateReportDateRange`. It runs on `os validate`, `os lint` and `os build`, and at the report publish door. Four `error` verdicts, each with an `os explain` entry: `report-date-range-dimension-unknown`, `report-date-range-dimension-not-date`, `report-date-range-dimension-ambiguous` (dimension omitted, two or more date dimensions) and `report-date-range-dimension-none` (dimension omitted, no date dimension). The liveness author-warning lint now walks `reports`, and both keys warn (`liveness-planned-property`) until the Console report page reads them. The report editor form gains a collapsed "Period & export" section, translated in `@objectstack/platform-objects` for zh-CN, ja-JP and es-ES.
20+
21+
Nothing renders either key yet. The Console report page's period chip and export control are the renderer's half, and until it lands an author declaring either key gets a planned-property warning that says so.

‎content/docs/references/ui/report.mdx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ const result = JoinedReportBlockSchema.parse(data);
6767
| **order** | `{ by: string; direction?: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first |
6868
| **drilldown** | `boolean` | optional (default: `true`) | Click-through to underlying records |
6969
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record<string, string>; subtitle?: string \| Record<string, string>; description?: string \| Record<string, string>; … }` | optional | Embedded chart configuration (refused on a joined report, which draws tables only) |
70+
| **dateRange** | `{ dimension?: string; defaultRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange?: boolean }` | optional | Report date range: a window over one dataset date dimension (refused on a joined report) |
71+
| **exportOptions** | `{ formats?: Enum<'csv' \| 'xlsx'>[]; includeHeaders?: boolean; fileNamePrefix?: string }` | optional | Report export control: `{ formats?, includeHeaders?, fileNamePrefix? }`, csv / xlsx, on by default; who may export is the base object's export permission (refused on a joined report) |
7072
| **blocks** | `{ name: string; label?: string \| Record<string, string>; description?: string \| Record<string, string>; type?: Enum<'tabular' \| 'summary' \| 'matrix'>; … }[]` | optional | Sub-reports for type=joined |
7173
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this report. |
7274
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
@@ -103,6 +105,22 @@ const result = JoinedReportBlockSchema.parse(data);
103105
| **interaction** | `{ tooltips?: boolean; brush?: boolean }` | optional | Interaction toggles: `{ tooltips?, brush? }` |
104106
| **aria** | `never` | optional | [REMOVED] `ChartConfig.aria` — authored as `dashboard.widgets[].chartConfig.aria`, `report.chart.aria` and `report.blocks[].chart.aria` — was removed in @objectstack/spec 17 (ADR-0049 D2). No chart renderer ever applied it: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react `<ObjectChart>` block never published it, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied on this same chart config is its sibling `description`, which the chart renderer lowers onto the chart graphic as `role="img"` plus `aria-label`. The shared `AriaProps` shape is NOT gone — `ariaLabel` / `ariaDescribedBy` / `role` stay live in the `aria` block on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
105107

108+
### Nested Shape: `Report.dateRange`
109+
110+
| Property | Type | Required | Description |
111+
| :--- | :--- | :--- | :--- |
112+
| **dimension** | `string` | optional | Dataset dimension (declared `type: 'date'`) the window applies to; omitted = the dataset's one date dimension |
113+
| **defaultRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>` | optional (default: `"this_month"`) | Default date range preset |
114+
| **allowCustomRange** | `boolean` | optional (default: `true`) | Allow users to pick a custom date range |
115+
116+
### Nested Shape: `Report.exportOptions`
117+
118+
| Property | Type | Required | Description |
119+
| :--- | :--- | :--- | :--- |
120+
| **formats** | `Enum<'csv' \| 'xlsx'>[]` | optional | Formats the report's export control offers (default: ['csv', 'xlsx']); an empty list turns the control off |
121+
| **includeHeaders** | `boolean` | optional | Include column headers in the exported file (default true) |
122+
| **fileNamePrefix** | `string` | optional | Download file name prefix — replaces the report label in the generated file name |
123+
106124
### Nested Shape: `Report.blocks[number]`
107125

108126
| Property | Type | Required | Description |

‎content/docs/ui/reports.mdx‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,67 @@ export const HoursByStatusChartReport = defineReport({
210210
});
211211
```
212212

213+
## Period and export
214+
215+
A non-`joined` report can declare the period it covers and how its table is downloaded.
216+
Both keys are optional: a report that declares neither shows every period, as it always
217+
has.
218+
219+
{/* os:check */}
220+
```typescript
221+
import { defineReport } from '@objectstack/spec/ui';
222+
223+
export const PipelineByQuarterReport = defineReport({
224+
name: 'pipeline_by_quarter',
225+
label: 'Pipeline by Quarter',
226+
type: 'summary',
227+
dataset: 'opportunity_metrics',
228+
rows: ['stage'],
229+
values: ['amount_sum'],
230+
dateRange: { dimension: 'close_date', defaultRange: 'this_quarter' },
231+
exportOptions: { formats: ['csv', 'xlsx'], fileNamePrefix: 'pipeline' },
232+
});
233+
```
234+
235+
**`dateRange`** is a window over one **date dimension of the dataset**. `defaultRange` and
236+
`allowCustomRange` are the dashboard's own: the same preset names (`this_month` by default,
237+
`this_quarter`, `last_30_days`, …, and `custom` to open the picker with no preset) and the
238+
same custom-range switch, on by default.
239+
240+
- **`dimension`** names a dataset dimension that declares `type: 'date'` — a dimension, not a
241+
raw object field. A dimension carries its own relationship path, so a date on a related
242+
record works the same way.
243+
- **Leave `dimension` out** only when the dataset declares exactly one `type: 'date'`
244+
dimension. A dataset with none, or with two or more (a task dataset with `due_date` and
245+
`completed_date`, say), is an error that names what it found. So is a `dimension` the
246+
dataset does not declare, or one that is not `type: 'date'`. `os validate` and `os build`
247+
check this against the dataset, and so does publishing the report.
248+
- **The window is a filter, not a bucket.** It is applied with the report's `runtimeFilter`
249+
and the dataset's own filter. How rows are grouped by date stays with the dimension's
250+
`dateGranularity` and the report's `rows` / `columns`.
251+
252+
**`exportOptions`** uses the list export's key and member names: `formats`,
253+
`includeHeaders`, `fileNamePrefix`. A report table downloads as `csv` or `xlsx`. Export is on
254+
by default (both formats), and `formats: []` turns it off. The download is the table as drawn,
255+
within the active period and filter, and the file is named for the report label (or
256+
`fileNamePrefix`) and the period. `maxRecords` and `streaming` are list-only, because a report's
257+
rows are already aggregated.
258+
259+
**Who may export is not a report key.** The export control is offered to a viewer whose
260+
permissions allow **export of the dataset's base object**. That is the permission-set
261+
`allowExport` axis the list toolbar uses (see [Permissions and protection](#permissions-and-protection)).
262+
263+
<Callout type="warn">
264+
**Declared, not drawn yet.** The Console report page does not read either key yet: there is
265+
no period chip and no export control on it today. Until the renderer ships, `os validate`
266+
warns on both keys so the gap is visible. The declarations are the contract the renderer
267+
implements.
268+
</Callout>
269+
270+
What is deliberately **not** offered: a `joined` report refuses both keys, because its blocks
271+
may bind different datasets and it draws one table per block. A report has no subscription or
272+
scheduled delivery, and no `pdf` or `json` export.
273+
213274
## Making a report reachable
214275

215276
A report is not reachable because it exists. Give it a navigation entry on an app:
@@ -256,6 +317,10 @@ never on the report — and **reachability** comes from the app navigation entry
256317
permissions on the app that carries it. A report is a presentation over a dataset, so it
257318
inherits the dataset's enforcement rather than restating it.
258319

320+
Export follows the same principle. A report declares *what* its export offers
321+
(`exportOptions`), never *who* may use it: that is the permission-set `allowExport` grant on
322+
the dataset's base object, which also governs exporting that object's list views.
323+
259324
## Related
260325

261326
- **Schema reference:** [Report](/docs/references/ui/report) — the full generated property

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 216 | 205 | 4 | 0 | 7 |
24+
| `ui/` | 217 | 206 | 4 | 0 | 7 |
2525

2626
## `ui/` — sites
2727

@@ -41,28 +41,28 @@ classify and is not listed (it becomes reportable the day it grows its first sit
4141
| `dataset.zod.ts` | 5 |
4242
| `i18n.zod.ts` | 1 |
4343
| `page.zod.ts` | 12 |
44-
| `report.zod.ts` | 3 |
44+
| `report.zod.ts` | 4 |
4545
| `responsive.zod.ts` | 1 |
4646
| `sharing.zod.ts` | 1 |
4747
| `view.zod.ts` | 59 |
4848
| `widget.zod.ts` | 1 |
49-
| **total** | **216** |
49+
| **total** | **217** |
5050

5151
## `ui/` — open
5252

5353
Per file, how many of its sites still silently discard unknown keys. The `Class`
5454
column that decides the bucket split is hand-written in the ledger; the arithmetic
5555
over it is here.
5656

57-
**7 strip of 216**, in 4 file(s).
57+
**7 strip of 217**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
6363
| `view.zod.ts` | 4 | 59 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **7** | **216** |
65+
| **total** | **7** | **217** |
6666

6767
| Bucket | Sites |
6868
|---|---|

‎packages/lint/src/index.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,18 @@ export {
646646
} from './validate-chart-bindings.js';
647647
export type { ChartBindingFinding, ChartBindingSeverity } from './validate-chart-bindings.js';
648648

649+
// [#26548] A report's `dateRange` resolved against its dataset: the one
650+
// `type: 'date'` dimension the period applies to. Beside the chart rule
651+
// because it resolves the same report against the same `stack.datasets`.
652+
export {
653+
validateReportDateRange,
654+
REPORT_DATE_RANGE_DIMENSION_UNKNOWN,
655+
REPORT_DATE_RANGE_DIMENSION_NOT_DATE,
656+
REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS,
657+
REPORT_DATE_RANGE_DIMENSION_NONE,
658+
} from './validate-report-date-range.js';
659+
export type { ReportDateRangeFinding } from './validate-report-date-range.js';
660+
649661
// [#14105] The layer BELOW the chart/widget binding rules above: a dataset's
650662
// own `include[]`, `dimensions[].field`, `measures[].field` and filter KEYS,
651663
// resolved against the object graph. Its base-object half is

‎packages/lint/src/lint-liveness-properties.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2287,3 +2287,30 @@ describe('#26146 — ledger rows below the child level are graded and counted',
22872287
expect(shipped.has('synth-depth-three')).toBe(false);
22882288
});
22892289
});
2290+
2291+
// ── #26548: a report's period and export are declared before the renderer reads them ──
2292+
//
2293+
// `report` joined TYPE_COLLECTIONS with its ledger's first warned rows:
2294+
// `dateRange` and `exportOptions` are `planned` + `authorWarn` until the
2295+
// Console report page reads them. Read off the SHIPPED ledger, so the flip to
2296+
// `live` (which drops `authorWarn`) turns the warning half of this block red
2297+
// on purpose — rewrite it as a silence pin in that same edit.
2298+
describe('report period and export (#26548 — planned until the report renderer reads them)', () => {
2299+
const report = (extra: Record<string, unknown> = {}) => ({
2300+
reports: [{ name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], ...extra }],
2301+
});
2302+
2303+
it('warns on each key, at the report, with the row\'s author hint', () => {
2304+
const findings = lintLivenessProperties(report({ dateRange: { dimension: 'close_date' }, exportOptions: { formats: ['csv'] } }));
2305+
const planned = findings.filter((f) => f.rule === 'liveness-planned-property');
2306+
expect(planned.map((f) => f.message.match(/`(dateRange|exportOptions)`/)?.[1]).sort()).toEqual(['dateRange', 'exportOptions']);
2307+
for (const f of planned) {
2308+
expect(f.where).toContain("report 'pipeline'");
2309+
expect(f.hint).toMatch(/^Keep it, but nothing reads it yet: the Console report page draws no /);
2310+
}
2311+
});
2312+
2313+
it('CONTROL — a report declaring neither key draws no warning', () => {
2314+
expect(lintLivenessProperties(report())).toEqual([]);
2315+
});
2316+
});

‎packages/lint/src/lint-liveness-properties.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,11 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
585585
// lookup, and it means a future regression that re-deadens a widget key warns
586586
// on its own instead of waiting for someone to notice this list again.
587587
{ type: 'dashboard', key: 'dashboards' },
588+
// #26548 — `report` joins the day its ledger first warns: `dateRange` and
589+
// `exportOptions` are `planned` + `authorWarn` until the report renderer
590+
// reads them. A flat collection whose top-level keys are the ledger's props,
591+
// so the flat walk below is the right shape for it.
592+
{ type: 'report', key: 'reports' },
588593
];
589594

590595
/**

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ describe('reference-integrity suite — membership', () => {
4545
// Placed beside the member that resolves the same page's field names.
4646
'validatePageVisualizationBindings',
4747
'validateChartBindings',
48+
// [#26548] The same report's `dateRange.dimension`, resolved against the
49+
// same dataset's date dimensions — placed beside the chart member that
50+
// resolves the rest of that report's bindings.
51+
'validateReportDateRange',
4852
// [#14105] The dataset's OWN references, one level below the two binding
4953
// rules above it — the conscious edit this written-out list exists to
5054
// 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
@@ -109,6 +109,7 @@ import { validateActionDispatchContract } from './validate-action-dispatch-contr
109109
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
110110
import { validatePageVisualizationBindings } from './validate-page-visualization-bindings.js';
111111
import { validateChartBindings } from './validate-chart-bindings.js';
112+
import { validateReportDateRange } from './validate-report-date-range.js';
112113
import { validateDatasetReferences } from './validate-dataset-references.js';
113114
import { validateMappingTargetFields } from './validate-mapping-target-fields.js';
114115
import { validateNavAccess } from './validate-nav-access.js';
@@ -438,6 +439,23 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
438439
// crossing: 9 reports (showcase 4, todo 5), 8 of them binding a real dataset
439440
// with `rows`/`values` — 0 findings, with a lit synthetic probe refused.
440441
{ name: 'validateChartBindings', runtimeTypes: ['flow', 'report'], run: validateChartBindings },
442+
// [#26548] The same report, one key on: `dateRange.dimension` resolved
443+
// against the bound dataset's `type: 'date'` dimensions (named, or the ONE
444+
// candidate when omitted). Directly after its sibling because the two read
445+
// the same `stack.reports` against the same `stack.datasets`, and this one
446+
// skips a dataset that resolves to nothing — the sibling's
447+
// `chart-dataset-unknown` reports that typo once.
448+
//
449+
// `report` joins the `['flow']` default for the sibling's reason, unchanged:
450+
// `stack.datasets` is the one collection it resolves into and the per-write
451+
// report snapshot carries it, so there is no missing-collection
452+
// false-positive channel. The publish door is the only door a Studio or
453+
// MCP/AI author has, and a window the renderer cannot apply should be
454+
// refused there, not stored. A refusal widening on a NEW key only: MEASURED
455+
// over the shipped report corpus (showcase 4, todo 5) before crossing — no
456+
// report declares `dateRange`, so 0 findings by construction, and the door
457+
// test's lit probes are refused (`runtime-gate.inert-type-writes.test.ts`).
458+
{ name: 'validateReportDateRange', runtimeTypes: ['flow', 'report'], run: validateReportDateRange },
441459
// [#14105] One level BELOW the two members above it. `validateChartBindings`
442460
// and `validateWidgetBindings` resolve a presentation's binding against the
443461
// dataset (#7529/#8902); this one resolves the DATASET's own references —

0 commit comments

Comments
 (0)