|
63 | 63 | * The `expect(…)` lines below are RUNTIME and are judged by vitest. Every |
64 | 64 | * relaxation therefore carries at least one assertion of each kind, so neither |
65 | 65 | * instrument going missing can make this file vacuous on its own. |
| 66 | + * |
| 67 | + * ## 3. The RETIREMENT this card's own ruling ordered (maintainer, 2026-08-30) |
| 68 | + * |
| 69 | + * The third section pins the opposite direction: `ReportComponentSchema.dataSource` |
| 70 | + * and `ReportBuilderSchema.dataSources` are RETIRED. Both were annotated with the |
| 71 | + * runtime `DataSource` ADAPTER — a shape no JSON document can author — and no read |
| 72 | + * site ever consumed either key. A retirement needs its own pin for the mirror |
| 73 | + * reason a widening does: `?: never` compiles for every existing caller (nobody |
| 74 | + * wrote the key), so nothing would fail if a later edit restored the adapter |
| 75 | + * annotation or relaxed the mirror back to `z.any()`. Both halves are pinned — |
| 76 | + * the `never` TypeScript twin AND the mirror's named refusal — because either one |
| 77 | + * alone leaves `declared !== enforced`, which is the defect ADR-0049 names. |
66 | 78 | */ |
67 | 79 |
|
68 | 80 | import { describe, it, expect } from 'vitest'; |
69 | 81 | import type { |
| 82 | + ReportBuilderSchema, |
70 | 83 | ReportComponentSchema, |
71 | 84 | ReportExportConfig, |
72 | 85 | ReportExportFormat, |
73 | 86 | } from '../reports.js'; |
74 | 87 | import type { ChartDataSeries } from '../data-display.js'; |
75 | 88 | import { ChartDataSeriesSchema } from '../zod/data-display.zod.js'; |
76 | | -import { ReportComponentSchema as ReportComponentZodSchema } from '../zod/reports.zod.js'; |
| 89 | +import { |
| 90 | + ReportBuilderSchema as ReportBuilderZodSchema, |
| 91 | + ReportComponentSchema as ReportComponentZodSchema, |
| 92 | +} from '../zod/reports.zod.js'; |
77 | 93 |
|
78 | 94 | /** `true` only when the two types are mutually assignable AND identical. */ |
79 | 95 | type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 |
@@ -162,3 +178,98 @@ describe('objectui#6121 — ChartDataSeries declares the per-series family overr |
162 | 178 | expect(ChartDataSeriesSchema.parse(plain).type).toBeUndefined(); |
163 | 179 | }); |
164 | 180 | }); |
| 181 | + |
| 182 | +describe('objectui#6121 — the two report data-source keys are retired on both faces', () => { |
| 183 | + // 3a. THE TYPE PIN. `?: never` resolves the member type to `undefined`, so |
| 184 | + // this line fails if either key is restored to `DataSource` / `DataSource[]` |
| 185 | + // — or to any other value type, including the `ViewData` binding whose key |
| 186 | + // name is still an open question on this card. |
| 187 | + type RetiredDataSource = ReportComponentSchema['dataSource']; |
| 188 | + type RetiredDataSources = ReportBuilderSchema['dataSources']; |
| 189 | + type _DataSourceStaysRetired = Assert<Eq<RetiredDataSource, undefined>>; |
| 190 | + type _DataSourcesStayRetired = Assert<Eq<RetiredDataSources, undefined>>; |
| 191 | + |
| 192 | + it('refuses an authored `dataSource` on both faces, by name', () => { |
| 193 | + const authored = { |
| 194 | + type: 'report' as const, |
| 195 | + title: 'Quarterly Sales Analysis', |
| 196 | + // The exact face `content/docs/core/report-schema.mdx` used to teach. |
| 197 | + dataSource: { provider: 'api', read: { url: '/api/sales', method: 'GET' } }, |
| 198 | + }; |
| 199 | + |
| 200 | + // @ts-expect-error `dataSource` is retired — `?: never` admits no value |
| 201 | + const typed: ReportComponentSchema = authored; |
| 202 | + expect(typed).toBeTruthy(); |
| 203 | + |
| 204 | + const result = ReportComponentZodSchema.safeParse(authored); |
| 205 | + expect(result.success).toBe(false); |
| 206 | + // The ENVELOPE, not the fact that something failed: one issue, at this |
| 207 | + // key's own path, reported as `invalid_type` (what `z.never()` emits) — |
| 208 | + // and carrying the tombstone's guidance rather than zod's generic text, |
| 209 | + // which is the half `retirementTombstone` exists for. |
| 210 | + const issues = result.success ? [] : result.error.issues; |
| 211 | + expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'dataSource']]); |
| 212 | + expect(issues[0]?.message).toContain('RETIRED (objectui#6121, ADR-0049)'); |
| 213 | + }); |
| 214 | + |
| 215 | + it('refuses an authored `dataSources` on the builder, by name', () => { |
| 216 | + const authored = { |
| 217 | + type: 'report-builder' as const, |
| 218 | + dataSources: [{ provider: 'api', read: { url: '/api/sales' } }], |
| 219 | + }; |
| 220 | + |
| 221 | + // @ts-expect-error `dataSources` is retired — `?: never` admits no value |
| 222 | + const typed: ReportBuilderSchema = authored; |
| 223 | + expect(typed).toBeTruthy(); |
| 224 | + |
| 225 | + const result = ReportBuilderZodSchema.safeParse(authored); |
| 226 | + expect(result.success).toBe(false); |
| 227 | + const issues = result.success ? [] : result.error.issues; |
| 228 | + expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'dataSources']]); |
| 229 | + expect(issues[0]?.message).toContain('RETIRED (objectui#6121, ADR-0049)'); |
| 230 | + }); |
| 231 | + |
| 232 | + // 3b. CONTROLS, in the same run. Two zeros above need two things that fire: |
| 233 | + // without these, a mirror that refused EVERYTHING would read as a pass, and |
| 234 | + // so would a `.safeParse` that had stopped being called at all. |
| 235 | + it('the same report without the retired key still parses, and the row array is untouched', () => { |
| 236 | + const report: ReportComponentSchema = { |
| 237 | + type: 'report', |
| 238 | + title: 'Quarterly Sales Analysis', |
| 239 | + // `data` is the report ROW array — a live key with a live read |
| 240 | + // (`LegacyReportRenderer` reads `data.length` / `data.map`). It is NOT |
| 241 | + // the retired binding, and this control is what keeps the retirement |
| 242 | + // above from reading as "reports refuse data". |
| 243 | + data: [{ region: 'EMEA', revenue: 1 }], |
| 244 | + }; |
| 245 | + const result = ReportComponentZodSchema.safeParse(report); |
| 246 | + expect(result.success).toBe(true); |
| 247 | + expect(result.success && result.data.data).toHaveLength(1); |
| 248 | + |
| 249 | + const builder: ReportBuilderSchema = { type: 'report-builder', showPreview: true }; |
| 250 | + expect(ReportBuilderZodSchema.safeParse(builder).success).toBe(true); |
| 251 | + }); |
| 252 | + |
| 253 | + // 3c. The guidance reaches the OTHER author-facing channel too — the |
| 254 | + // `.describe()` metadata that feeds generated JSON Schema and the docs |
| 255 | + // surface. One string, two channels, so they cannot drift apart. |
| 256 | + it('publishes the retirement guidance as schema metadata', () => { |
| 257 | + const shapeOf = (schema: { shape: Record<string, { description?: string }> }) => schema.shape; |
| 258 | + const componentDescribe = shapeOf( |
| 259 | + ReportComponentZodSchema as unknown as { shape: Record<string, { description?: string }> }, |
| 260 | + ).dataSource?.description; |
| 261 | + const builderDescribe = shapeOf( |
| 262 | + ReportBuilderZodSchema as unknown as { shape: Record<string, { description?: string }> }, |
| 263 | + ).dataSources?.description; |
| 264 | + |
| 265 | + expect(componentDescribe).toContain('RETIRED (objectui#6121, ADR-0049)'); |
| 266 | + expect(builderDescribe).toContain('RETIRED (objectui#6121, ADR-0049)'); |
| 267 | + // Control for the reader itself: a NON-retired member's description is |
| 268 | + // still its own noun, so the two hits above are not "every key says |
| 269 | + // RETIRED". |
| 270 | + expect( |
| 271 | + shapeOf(ReportComponentZodSchema as unknown as { shape: Record<string, { description?: string }> }) |
| 272 | + .title?.description, |
| 273 | + ).toBe('Report title'); |
| 274 | + }); |
| 275 | +}); |
0 commit comments