diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c7ecc1..a11cf91b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Chart validation is now part of the core package. `validateChart(input, + backend)` returns `{ valid, warnings, errors, computedSize }` without + throwing, alongside `validateChartInput`, `validateSemanticTypes`, + `assembleForBackend`, and `stripPrivateKeys`, from `flint-chart` and the new + `flint-chart/validate` subpath. Hosts that let an agent author chart inputs + outside MCP get the same per-problem feedback the `validate_chart` tool + provides; `flint-chart-mcp` now consumes this implementation. Unregistered + `semantic_types` labels surface as `unknown_semantic_type` warnings, and + `isRegistered` / `getRegisteredTypes` are exported from `flint-chart/core` + ([#104](https://github.com/microsoft/flint-chart/issues/104)). + ### Fixed - Keyboard targeting now navigates and emits `focus-element` through the diff --git a/docs/api-reference.md b/docs/api-reference.md index 5c67e97a..52e7d9ad 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -292,6 +292,37 @@ Re-exported from `flint-chart` and `flint-chart/core`: Key types: `ChartAssemblyInput`, `ChartEncoding`, `ChartTemplateDef`, `AssembleOptions`, `ChartWarning`, `ChannelSemantics`. +## Validation + +Hosts that let an agent author a `ChartAssemblyInput` can validate it before +rendering. `validateChart` never throws; it reports every warning the assembler +emits plus a single `assembly_failed` error when the input cannot be compiled +(unknown chart type, unsupported channel, nonexistent field, canvas caps). +Re-exported from `flint-chart` and `flint-chart/validate`: + +```ts +import { validateChart } from 'flint-chart/validate'; + +const result = validateChart(input, 'vegalite'); +// { backend, chartType, valid, warnings, errors, computedSize? } +if (!result.valid) { + // feed result.errors back to the agent +} +``` + +| Symbol | Purpose | +|--------|---------| +| `validateChart(input, backend, options?)` | Validate and assemble; never throws | +| `validateChartInput(input, backend?, options?)` | Shape checks only; throws on the first problem | +| `validateSemanticTypes(semantic_types)` | `unknown_semantic_type` warnings for labels not in the type registry (also included by `validateChart`) | +| `assembleForBackend(backend, input, options?)` | Assemble and split out `_warnings` / `_width` / `_height` | +| `stripPrivateKeys(spec)` | Remove Flint's `_`-prefixed metadata from a spec | +| `VALIDATION_BACKENDS` | Runtime list of accepted backends (`vegalite`, `echarts`, `chartjs`, `plotly`) | + +`options.maxDataRows` (default 100,000) and `options.maxCanvasDim` (default +4000) cap input size. Inline `data.values` are required — resolve `data.url` +to rows before validating. + --- # §8 Overflow and warnings @@ -325,6 +356,7 @@ Inspect `_warnings` or `ChartWarning` arrays in integration code to surface trun | `flint-chart/vegalite` | VL templates and `assembleVegaLite` | | `flint-chart/echarts` | ECharts templates and `assembleECharts` | | `flint-chart/chartjs` | Chart.js templates and `assembleChartjs` | +| `flint-chart/validate` | `validateChart` and input validation helpers | | `flint-chart/test-data` | Gallery generators (`TEST_GENERATORS`) | --- diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md index ad2fb88d..3a967257 100644 --- a/docs/zh-CN/api-reference.md +++ b/docs/zh-CN/api-reference.md @@ -255,6 +255,35 @@ vlGetTemplateChannels('Scatter Plot'); 关键类型:`ChartAssemblyInput`、`ChartEncoding`、`ChartTemplateDef`、`AssembleOptions`、`ChartWarning`、`ChannelSemantics`。 +## 校验 + +让 Agent 编写 `ChartAssemblyInput` 的宿主可以在渲染前先校验输入。`validateChart` +不会抛出异常;它会返回 assembler 产生的全部警告,并在输入无法编译时(未知图表类型、 +不支持的通道、不存在的字段、画布上限)返回一条 `assembly_failed` 错误。 +从 `flint-chart` 与 `flint-chart/validate` 再导出: + +```ts +import { validateChart } from 'flint-chart/validate'; + +const result = validateChart(input, 'vegalite'); +// { backend, chartType, valid, warnings, errors, computedSize? } +if (!result.valid) { + // 将 result.errors 反馈给 Agent +} +``` + +| 符号 | 用途 | +|--------|---------| +| `validateChart(input, backend, options?)` | 校验并装配;不抛出异常 | +| `validateChartInput(input, backend?, options?)` | 仅做结构检查;遇到第一个问题即抛出 | +| `validateSemanticTypes(semantic_types)` | 对未在类型注册表中的标签返回 `unknown_semantic_type` 警告(`validateChart` 也会包含这些警告) | +| `assembleForBackend(backend, input, options?)` | 装配并拆出 `_warnings` / `_width` / `_height` | +| `stripPrivateKeys(spec)` | 从 spec 中移除 Flint 的 `_` 前缀元数据 | +| `VALIDATION_BACKENDS` | 运行时可用的 backend 列表(`vegalite`、`echarts`、`chartjs`、`plotly`) | + +`options.maxDataRows`(默认 100,000)与 `options.maxCanvasDim`(默认 4000)限制输入 +大小。要求内联的 `data.values` —— 请在校验前先把 `data.url` 解析为行数据。 + --- # §8 溢出与警告 @@ -288,6 +317,7 @@ vlGetTemplateChannels('Scatter Plot'); | `flint-chart/vegalite` | VL 模板与 `assembleVegaLite` | | `flint-chart/echarts` | ECharts 模板与 `assembleECharts` | | `flint-chart/chartjs` | Chart.js 模板与 `assembleChartjs` | +| `flint-chart/validate` | `validateChart` 与输入校验辅助函数 | | `flint-chart/test-data` | 图库生成器(`TEST_GENERATORS`) | --- diff --git a/packages/flint-js/README.md b/packages/flint-js/README.md index fd333b07..a7c19877 100644 --- a/packages/flint-js/README.md +++ b/packages/flint-js/README.md @@ -80,6 +80,7 @@ const xl = assembleExcel(input); // Native Excel chart artifact | `flint-chart/chartjs` | Chart.js backend | | `flint-chart/plotly` | Plotly backend | | `flint-chart/excel` | Native Excel / Office.js backend | +| `flint-chart/validate` | `validateChart` — per-problem input validation for agent loops | | `flint-chart/test-data` | Sample data generators used by the gallery and tests | | `flint-chart/gallery` | Curated example specs | diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index 28627964..a8aefdc6 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -70,6 +70,11 @@ "import": "./dist/image-charts/index.js", "require": "./dist/image-charts/index.cjs" }, + "./validate": { + "types": "./dist/validate/index.d.ts", + "import": "./dist/validate/index.js", + "require": "./dist/validate/index.cjs" + }, "./interactive": { "types": "./dist/interactive/index.d.ts", "import": "./dist/interactive/index.js", diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index da968a5e..f7585510 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -196,6 +196,7 @@ export { resolveStackable, resolveSortDirection, } from './field-semantics'; +export { isRegistered, getRegisteredTypes } from './type-registry'; // ThemeSpec: public visual-system vocabulary and chart-specific grounding export { diff --git a/packages/flint-js/src/index.ts b/packages/flint-js/src/index.ts index eeb753d1..1112f31d 100644 --- a/packages/flint-js/src/index.ts +++ b/packages/flint-js/src/index.ts @@ -26,6 +26,11 @@ * ecTemplateDefs / ecGetTemplateDef / ecGetTemplateChannels * cjsTemplateDefs / cjsGetTemplateDef / cjsGetTemplateChannels * + * Validation (also available from 'flint-chart/validate'): + * validateChart(input, backend) — never throws; { valid, warnings, errors, computedSize } + * validateChartInput(input, backend) — throws on the first problem + * validateSemanticTypes(types) — unregistered semantic_types labels + * * Usage: * ```ts * import { assembleVegaLite } from 'flint-chart'; @@ -60,3 +65,6 @@ export * from './excel'; // Image-Charts backend: assembleImageCharts + hosted-image-URL artifact type export * from './image-charts'; + +// Validation: validateChart, validateChartInput, assembleForBackend +export * from './validate'; diff --git a/packages/flint-js/src/validate/index.ts b/packages/flint-js/src/validate/index.ts new file mode 100644 index 00000000..f5c14910 --- /dev/null +++ b/packages/flint-js/src/validate/index.ts @@ -0,0 +1,356 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @module flint-chart/validate + * + * Backend-aware validation of {@link ChartAssemblyInput} for hosts that let an + * agent author chart inputs and need precise, per-problem feedback before + * anything renders. Pure JS — no file system or native dependencies. Inline + * `data.values` are required; hosts resolve `data.url` to rows themselves. + * + * ```ts + * import { validateChart } from 'flint-chart/validate'; + * + * const result = validateChart(input, 'vegalite'); + * if (!result.valid) console.log(result.errors); + * ``` + */ + +import type { + ChartAssemblyInput, + ChartEncoding, + ChartTemplateDef, + ChartWarning, +} from '../core/types'; +import { isRegistered } from '../core/type-registry'; +import { toTypeString } from '../core/field-semantics'; +import { assembleVegaLite } from '../vegalite/assemble'; +import { vlGetTemplateDef } from '../vegalite/templates'; +import { assembleECharts } from '../echarts/assemble'; +import { ecGetTemplateDef } from '../echarts/templates'; +import { assembleChartjs } from '../chartjs/assemble'; +import { cjsGetTemplateDef } from '../chartjs/templates'; +import { assemblePlotly } from '../plotly/assemble'; +import { plGetTemplateDef } from '../plotly/templates'; + +/** Backends whose inputs can be validated and assembled. */ +export type ValidationBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; + +export const VALIDATION_BACKENDS: readonly ValidationBackend[] = [ + 'vegalite', + 'echarts', + 'chartjs', + 'plotly', +]; + +/** Default cap on inline data rows. */ +export const DEFAULT_MAX_DATA_ROWS = 100_000; + +/** Default cap on `baseSize` / `canvasSize` dimensions in pixels. */ +export const DEFAULT_MAX_CANVAS_DIM = 4000; + +export interface ValidateChartOptions { + /** Maximum number of inline data rows accepted. Default: 100,000. */ + maxDataRows?: number; + /** Maximum `baseSize` / `canvasSize` dimension in pixels. Default: 4000. */ + maxCanvasDim?: number; +} + +export interface AssembleResult { + /** The backend-native spec (still carrying Flint's private `_`-keys). */ + spec: any; + /** Warnings emitted by the assembler. */ + warnings: ChartWarning[]; + /** Computed subplot width from the stretch model, if present. */ + width?: number; + /** Computed subplot height from the stretch model, if present. */ + height?: number; +} + +export interface ValidateResult { + backend: ValidationBackend; + chartType: string; + /** True when assembly succeeded with no error-severity warnings. */ + valid: boolean; + /** All warnings (info/warning/error) emitted during validation and assembly. */ + warnings: ChartWarning[]; + /** Error-severity warnings plus any thrown assembly failure. */ + errors: ChartWarning[]; + /** Computed layout size from Flint's stretch model, if available. */ + computedSize?: { width: number; height: number }; +} + +const ASSEMBLERS: Record any> = { + vegalite: assembleVegaLite, + echarts: assembleECharts, + chartjs: assembleChartjs, + plotly: assemblePlotly, +}; + +const TEMPLATE_LOOKUP: Record< + ValidationBackend, + (chartType: string) => ChartTemplateDef | undefined +> = { + vegalite: vlGetTemplateDef, + echarts: ecGetTemplateDef, + chartjs: cjsGetTemplateDef, + plotly: plGetTemplateDef, +}; + +/** + * Validate the shape of a {@link ChartAssemblyInput} before it reaches an + * assembler: data presence, row shape and caps, `chartType`, encodings against the + * backend's template (channel support, required channels, field existence), + * and canvas caps. Throws on the first problem found. When `backend` is given + * and the chart type is unknown to that backend, encoding checks are skipped + * so the assembler reports the unknown type. + */ +export function validateChartInput( + input: ChartAssemblyInput, + backend?: ValidationBackend, + options: ValidateChartOptions = {}, +): void { + if (!isRecord(input)) { + throw new Error('input must be a ChartAssemblyInput object'); + } + const rows = validateData(input.data, options.maxDataRows ?? DEFAULT_MAX_DATA_ROWS); + const chartSpec = input.chart_spec; + if (!isRecord(chartSpec) || typeof chartSpec.chartType !== 'string') { + throw new Error('input.chart_spec.chartType is required'); + } + validateEncodings(chartSpec.chartType, chartSpec.encodings, rows, backend); + validateCanvasCaps(chartSpec, options.maxCanvasDim ?? DEFAULT_MAX_CANVAS_DIM); +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validateData(data: unknown, maxDataRows: number): Record[] { + if (!isRecord(data)) { + throw new Error('input.data is required (provide { values: [...] })'); + } + const rows = data.values; + if (!Array.isArray(rows)) { + throw new Error('input.data must provide inline values'); + } + if (typeof data.url === 'string') { + throw new Error('input.data must provide either values or url, not both'); + } + if (rows.length > maxDataRows) { + throw new Error( + `input.data.values has ${rows.length} rows, exceeding the limit of ${maxDataRows}`, + ); + } + if (rows.length === 0) { + throw new Error('input.data.values must contain at least one row'); + } + for (const [index, row] of rows.entries()) { + if (!isRecord(row)) { + throw new Error(`data row ${index + 1} must be an object`); + } + } + return rows; +} + +function validateEncodings( + chartType: string, + encodings: unknown, + rows: Record[], + backend?: ValidationBackend, +): void { + if (!isRecord(encodings)) { + throw new Error('input.chart_spec.encodings must be a channel-to-encoding object'); + } + const entries = Object.entries(encodings); + if (entries.length === 0) { + throw new Error('input.chart_spec.encodings must bind at least one channel'); + } + + if (backend) { + const template = TEMPLATE_LOOKUP[backend]?.(chartType); + if (!template) return; + validateEncodingsAgainstTemplate(chartType, backend, template, encodings); + } + + const dataFields = new Set(rows.flatMap((row) => Object.keys(row))); + for (const [channel, encoding] of entries) { + for (const field of encodingFields(encoding)) { + if (!dataFields.has(field)) { + throw new Error( + `chart_spec.encodings.${channel}.field "${field}" does not exist in data.values`, + ); + } + } + } +} + +function validateEncodingsAgainstTemplate( + chartType: string, + backend: ValidationBackend, + template: ChartTemplateDef, + encodings: Record, +): void { + const allowed = new Set(template.channels ?? []); + for (const channel of Object.keys(encodings)) { + if (!allowed.has(channel)) { + throw new Error( + `chart_spec.encodings.${channel} is not supported by ${chartType} for ${backend}`, + ); + } + } + for (const channel of requiredChannels(template)) { + if (!hasEncodingBinding(encodings[channel])) { + throw new Error(`chart_spec.encodings.${channel} is required for ${chartType}`); + } + } +} + +function validateCanvasCaps(chartSpec: Record, maxCanvasDim: number): void { + for (const field of ['baseSize', 'canvasSize'] as const) { + const size = chartSpec[field]; + if (!isRecord(size)) continue; + const { width, height } = size; + if ( + (typeof width === 'number' && width > maxCanvasDim) || + (typeof height === 'number' && height > maxCanvasDim) + ) { + throw new Error( + `chart_spec.${field} exceeds the maximum dimension of ${maxCanvasDim}px`, + ); + } + } +} + +function requiredChannels(template: ChartTemplateDef): string[] { + const channels = template.channels ?? []; + if (channels.includes('x') && channels.includes('y')) return ['x', 'y']; + if (template.chart === 'KPI Card') return ['metric', 'value']; + return []; +} + +function hasEncodingBinding(value: unknown): boolean { + if (typeof value === 'string') return value.trim().length > 0; + if (Array.isArray(value)) return value.some(hasEncodingBinding); + if (value && typeof value === 'object') { + const encoding = value as ChartEncoding; + return ( + (typeof encoding.field === 'string' && encoding.field.trim().length > 0) || + encoding.aggregate === 'count' + ); + } + return false; +} + +function encodingFields(value: unknown): string[] { + if (typeof value === 'string') return value.trim() ? [value] : []; + if (Array.isArray(value)) return value.flatMap(encodingFields); + if (value && typeof value === 'object') { + const field = (value as ChartEncoding).field; + return typeof field === 'string' && field.trim() ? [field] : []; + } + return []; +} + +/** + * Report `semantic_types` labels that are not in Flint's type registry as + * `unknown_semantic_type` warnings. Unregistered labels are not an error — + * assembly falls back to inferring from the data — but a host that expects + * the registry to be honored can use this to catch typos and drift. + */ +export function validateSemanticTypes( + semanticTypes: ChartAssemblyInput['semantic_types'] | undefined, +): ChartWarning[] { + if (!isRecord(semanticTypes)) return []; + const warnings: ChartWarning[] = []; + for (const [field, annotation] of Object.entries(semanticTypes)) { + const semanticType = toTypeString(annotation); + if (!semanticType || !isRegistered(semanticType)) { + warnings.push({ + severity: 'warning', + code: 'unknown_semantic_type', + message: `semantic_types.${field} "${semanticType}" is not a registered semantic type; the field's type will be inferred from the data`, + field, + }); + } + } + return warnings; +} + +/** + * Validate and assemble a Flint spec for one backend, splitting out Flint's + * private metadata (`_warnings`, `_width`, `_height`). The returned `spec` is + * left untouched so callers can choose to expose or strip the private keys + * (see {@link stripPrivateKeys}). Throws on validation or assembly failure. + */ +export function assembleForBackend( + backend: ValidationBackend, + input: ChartAssemblyInput, + options: ValidateChartOptions = {}, +): AssembleResult { + const assemble = ASSEMBLERS[backend]; + if (!assemble) { + throw new Error(`unknown backend: ${backend}`); + } + validateChartInput(input, backend, options); + const spec = assemble(input); + const warnings: ChartWarning[] = Array.isArray(spec?._warnings) ? spec._warnings : []; + const width = typeof spec?._width === 'number' ? spec._width : undefined; + const height = typeof spec?._height === 'number' ? spec._height : undefined; + return { spec, warnings, width, height }; +} + +/** + * Validate a {@link ChartAssemblyInput} for a backend: report warnings/errors, + * applicability, and the computed layout size. Never throws — validation and + * assembly failures are surfaced as an error entry. Unregistered + * `semantic_types` labels are included as warnings (see + * {@link validateSemanticTypes}) and do not affect `valid`. + */ +export function validateChart( + input: ChartAssemblyInput, + backend: ValidationBackend, + options: ValidateChartOptions = {}, +): ValidateResult { + const chartType = typeof input?.chart_spec?.chartType === 'string' + ? input.chart_spec.chartType + : '(unknown)'; + const semanticTypeWarnings = validateSemanticTypes(input?.semantic_types); + try { + const { warnings, width, height } = assembleForBackend(backend, input, options); + const all = [...warnings, ...semanticTypeWarnings]; + const errors = all.filter((w) => w.severity === 'error'); + return { + backend, + chartType, + valid: errors.length === 0, + warnings: all, + errors, + computedSize: + width !== undefined && height !== undefined ? { width, height } : undefined, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + backend, + chartType, + valid: false, + warnings: semanticTypeWarnings, + errors: [{ severity: 'error', code: 'assembly_failed', message }], + }; + } +} + +/** + * Remove Flint's private `_`-prefixed annotation keys from a top-level spec + * object so it is render-ready and safe to surface to callers. + */ +export function stripPrivateKeys>(spec: T): T { + for (const key of Object.keys(spec)) { + if (key.startsWith('_')) { + delete (spec as Record)[key]; + } + } + return spec; +} diff --git a/packages/flint-js/tests/validate.test.ts b/packages/flint-js/tests/validate.test.ts new file mode 100644 index 00000000..eec3dcbd --- /dev/null +++ b/packages/flint-js/tests/validate.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { + VALIDATION_BACKENDS, + assembleForBackend, + stripPrivateKeys, + validateChart, + validateChartInput, + validateSemanticTypes, +} from '../src/validate'; +import type { ChartAssemblyInput } from '../src'; + +const barChart: ChartAssemblyInput = { + data: { + values: [ + { region: 'East', revenue: 120 }, + { region: 'West', revenue: 90 }, + { region: 'North', revenue: 150 }, + ], + }, + semantic_types: { region: 'Region', revenue: 'Amount' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + }, +}; + +function withSpec(chart_spec: Partial): ChartAssemblyInput { + return { ...barChart, chart_spec: { ...barChart.chart_spec, ...chart_spec } }; +} + +describe('validateChart', () => { + it.each(VALIDATION_BACKENDS)('accepts a valid bar chart for %s', (backend) => { + const result = validateChart(barChart, backend); + expect(result).toMatchObject({ backend, chartType: 'Bar Chart', valid: true, errors: [] }); + }); + + it('reports the computed layout size', () => { + const result = validateChart(barChart, 'vegalite'); + expect(result.computedSize?.width).toBeGreaterThan(0); + expect(result.computedSize?.height).toBeGreaterThan(0); + }); + + it('flags an unknown chart type as invalid without throwing', () => { + const result = validateChart(withSpec({ chartType: 'Not A Real Chart' }), 'vegalite'); + expect(result.valid).toBe(false); + expect(result.chartType).toBe('Not A Real Chart'); + expect(result.errors[0].code).toBe('assembly_failed'); + expect(result.errors[0].message).toMatch(/Unknown chart type/); + }); + + it('flags an unknown backend as invalid without throwing', () => { + const result = validateChart(barChart, 'excel' as any); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toMatch(/unknown backend: excel/); + }); + + it('flags a nonexistent field', () => { + const result = validateChart( + withSpec({ encodings: { x: { field: 'missing' }, y: { field: 'revenue' } } }), + 'vegalite', + ); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain('"missing" does not exist in data.values'); + }); + + it('flags an unsupported channel', () => { + const result = validateChart( + withSpec({ + encodings: { x: { field: 'region' }, y: { field: 'revenue' }, banana: { field: 'region' } }, + }), + 'echarts', + ); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain('encodings.banana is not supported by Bar Chart for echarts'); + }); + + it('flags a canvas that exceeds the default cap', () => { + const result = validateChart(withSpec({ canvasSize: { width: 5000, height: 300 } }), 'vegalite'); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain('maximum dimension of 4000px'); + }); + + it('honours a caller-supplied canvas cap', () => { + const result = validateChart(withSpec({ canvasSize: { width: 5000, height: 300 } }), 'vegalite', { + maxCanvasDim: 8000, + }); + expect(result.valid).toBe(true); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['a number', 42], + ['an empty object', {}], + ['data without values', { data: {} }], + ['data with a url only', { data: { url: 'x.csv' } }], + ['non-array values', { data: { values: 'nope' } }], + ])('rejects %s without throwing', (_label, input) => { + const result = validateChart(input as any, 'vegalite'); + expect(result).toMatchObject({ valid: false, chartType: '(unknown)' }); + expect(result.errors).toHaveLength(1); + }); + + it('surfaces unregistered semantic_types as warnings, not errors', () => { + const result = validateChart( + { ...barChart, semantic_types: { region: 'Region', revenue: 'Dollarz' } }, + 'vegalite', + ); + expect(result.valid).toBe(true); + expect(result.warnings).toContainEqual( + expect.objectContaining({ severity: 'warning', code: 'unknown_semantic_type', field: 'revenue' }), + ); + }); +}); + +describe('validateChartInput', () => { + it('rejects encodings that bind no channel', () => { + expect(() => validateChartInput(withSpec({ encodings: {} }), 'vegalite')).toThrow( + /must bind at least one channel/, + ); + }); + + it('rejects empty data', () => { + expect(() => validateChartInput({ ...barChart, data: { values: [] } }, 'vegalite')).toThrow( + /at least one row/, + ); + }); + + it('rejects rows that are not objects', () => { + expect(() => + validateChartInput({ ...barChart, data: { values: [{ region: 'a', revenue: 1 }, 2] } }, 'vegalite'), + ).toThrow(/data row 2 must be an object/); + }); + + it('rejects data that provides both values and url', () => { + expect(() => + validateChartInput({ ...barChart, data: { values: barChart.data.values, url: 'x.json' } } as any, 'vegalite'), + ).toThrow(/either values or url, not both/); + }); + + it('honours a caller-supplied row cap', () => { + expect(() => validateChartInput(barChart, 'vegalite', { maxDataRows: 2 })).toThrow( + /exceeding the limit of 2/, + ); + }); + + it('requires x and y for cartesian templates', () => { + expect(() => + validateChartInput(withSpec({ encodings: { x: { field: 'region' } } }), 'vegalite'), + ).toThrow(/encodings\.y is required for Bar Chart/); + }); + + it('checks field existence even without a backend', () => { + expect(() => validateChartInput(withSpec({ encodings: { x: 'nope', y: 'revenue' } }))).toThrow( + /"nope" does not exist/, + ); + expect(() => validateChartInput(barChart)).not.toThrow(); + }); + + it('skips template checks for a chart type the backend does not know', () => { + expect(() => + validateChartInput(withSpec({ chartType: 'Not A Real Chart' }), 'vegalite'), + ).not.toThrow(); + }); +}); + +describe('validateSemanticTypes', () => { + it('returns one warning per unregistered label', () => { + const warnings = validateSemanticTypes({ + a: 'Amount', + b: { semanticType: 'Percentage' }, + c: 'NotAType', + d: { semanticType: '' }, + }); + expect(warnings.map((w) => w.field)).toEqual(['c', 'd']); + expect(warnings.every((w) => w.code === 'unknown_semantic_type')).toBe(true); + }); + + it('returns nothing for missing semantic_types', () => { + expect(validateSemanticTypes(undefined)).toEqual([]); + }); +}); + +describe('assembleForBackend', () => { + it('splits Flint metadata out of the spec', () => { + const { spec, warnings, width, height } = assembleForBackend('echarts', barChart); + expect(Array.isArray(warnings)).toBe(true); + expect(typeof width).toBe('number'); + expect(typeof height).toBe('number'); + expect(Object.keys(spec).some((k) => k.startsWith('_'))).toBe(true); + }); + + it('rejects an unknown backend', () => { + expect(() => assembleForBackend('excel' as any, barChart)).toThrow(/unknown backend/); + }); +}); + +describe('stripPrivateKeys', () => { + it('removes only top-level underscore keys', () => { + const spec = stripPrivateKeys({ _warnings: [], width: 1, nested: { _keep: true } }); + expect(spec).toEqual({ width: 1, nested: { _keep: true } }); + }); +}); diff --git a/packages/flint-js/tsup.config.ts b/packages/flint-js/tsup.config.ts index 6b795ae6..96d76376 100644 --- a/packages/flint-js/tsup.config.ts +++ b/packages/flint-js/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'plotly/index': 'src/plotly/index.ts', 'excel/index': 'src/excel/index.ts', 'image-charts/index': 'src/image-charts/index.ts', + 'validate/index': 'src/validate/index.ts', 'interactive/index': 'src/interactive/index.ts', 'vegalite/interactive': 'src/vegalite/interactive.ts', 'echarts/interactive': 'src/echarts/interactive.ts', diff --git a/packages/flint-mcp/src/render/assemble.ts b/packages/flint-mcp/src/render/assemble.ts index 0ab4d868..df607286 100644 --- a/packages/flint-mcp/src/render/assemble.ts +++ b/packages/flint-mcp/src/render/assemble.ts @@ -2,16 +2,14 @@ // Licensed under the MIT License. import { - assembleVegaLite, - assembleECharts, - assembleChartjs, + assembleForBackend as coreAssembleForBackend, + validateChartInput, + stripPrivateKeys, + DEFAULT_MAX_CANVAS_DIM, + DEFAULT_MAX_DATA_ROWS, + type AssembleResult, type ChartAssemblyInput, - type ChartEncoding, - type ChartTemplateDef, - type ChartWarning, - vlGetTemplateDef, - ecGetTemplateDef, - cjsGetTemplateDef, + type ValidateChartOptions, } from 'flint-chart'; import { resolveDataSource, @@ -19,38 +17,19 @@ import { } from './data-source.js'; import type { RenderBackend } from './types.js'; +export { stripPrivateKeys, type AssembleResult }; + /** Maximum number of inline data rows accepted (DoS guard). */ -export const MAX_DATA_ROWS = 100_000; +export const MAX_DATA_ROWS = DEFAULT_MAX_DATA_ROWS; /** Maximum canvas dimension in pixels the host will honor (DoS guard). */ -export const MAX_CANVAS_DIM = 4000; - -const ASSEMBLERS: Record< - RenderBackend, - (input: ChartAssemblyInput) => any -> = { - vegalite: assembleVegaLite, - echarts: assembleECharts, - chartjs: assembleChartjs, -}; +export const MAX_CANVAS_DIM = DEFAULT_MAX_CANVAS_DIM; -const TEMPLATE_LOOKUP: Record ChartTemplateDef | undefined> = { - vegalite: vlGetTemplateDef, - echarts: ecGetTemplateDef, - chartjs: cjsGetTemplateDef, +export const INPUT_CAPS: ValidateChartOptions = { + maxDataRows: MAX_DATA_ROWS, + maxCanvasDim: MAX_CANVAS_DIM, }; -export interface AssembleResult { - /** The backend-native spec (still carrying Flint's private `_`-keys). */ - spec: any; - /** Warnings emitted by the assembler. */ - warnings: ChartWarning[]; - /** Computed subplot width from the stretch model, if present. */ - width?: number; - /** Computed subplot height from the stretch model, if present. */ - height?: number; -} - /** * Validate caller-supplied input before it reaches an assembler. Inline rows * pass through directly. Local `data.url` references are read unless @@ -60,164 +39,30 @@ export function validateInput( input: ChartAssemblyInput, options: DataSourceOptions = {}, ): void { - prepareInput(input, options); + validateChartInput(resolveInput(input, options), undefined, INPUT_CAPS); } -/** Resolve data references and validate caller-supplied input. */ -export function prepareInput( +/** Resolve `data.url` to inline rows so the core validator can see them. Throws on unreadable references. */ +export function resolveInput( input: ChartAssemblyInput, options: DataSourceOptions = {}, - backend?: RenderBackend, ): ChartAssemblyInput { if (input == null || typeof input !== 'object') { throw new Error('input must be a ChartAssemblyInput object'); } - const resolvedInput = resolveDataSource(input, { - ...options, - maxDataRows: MAX_DATA_ROWS, - }); - const resolvedData: any = (resolvedInput as any).data; - if (resolvedData == null || typeof resolvedData !== 'object') { - throw new Error('input.data is required (provide { values: [...] })'); - } - if (!Array.isArray(resolvedData.values)) { - throw new Error( - 'input.data must provide inline values or a readable local data.url', - ); - } - if (resolvedData.values.length > MAX_DATA_ROWS) { - throw new Error( - `input.data.values has ${resolvedData.values.length} rows, exceeding the limit of ${MAX_DATA_ROWS}`, - ); - } - const cs: any = (resolvedInput as any).chart_spec; - if (cs == null || typeof cs !== 'object' || typeof cs.chartType !== 'string') { - throw new Error('input.chart_spec.chartType is required'); - } - if (resolvedData.values.length === 0) { - throw new Error('input.data.values must contain at least one row'); - } - validateChartSpec(cs, resolvedData.values, backend); - for (const field of ['baseSize', 'canvasSize'] as const) { - const size = cs[field]; - if (size) { - if ( - (typeof size.width === 'number' && size.width > MAX_CANVAS_DIM) || - (typeof size.height === 'number' && size.height > MAX_CANVAS_DIM) - ) { - throw new Error( - `chart_spec.${field} exceeds the maximum dimension of ${MAX_CANVAS_DIM}px`, - ); - } - } - } - return resolvedInput; -} - -function validateChartSpec(cs: any, rows: Record[], backend?: RenderBackend): void { - const encodings = cs.encodings; - if (encodings == null || typeof encodings !== 'object' || Array.isArray(encodings)) { - throw new Error('input.chart_spec.encodings must be a channel-to-encoding object'); - } - - const entries = Object.entries(encodings); - if (entries.length === 0) { - throw new Error('input.chart_spec.encodings must bind at least one channel'); - } - - const template = backend ? TEMPLATE_LOOKUP[backend]?.(cs.chartType) : undefined; - if (backend && !template) return; - - if (template) { - const allowed = new Set(template.channels ?? []); - for (const [channel] of entries) { - if (!allowed.has(channel)) { - throw new Error( - `chart_spec.encodings.${channel} is not supported by ${cs.chartType} for ${backend}`, - ); - } - } - - for (const channel of requiredChannels(template)) { - if (!hasEncodingBinding(encodings[channel])) { - throw new Error(`chart_spec.encodings.${channel} is required for ${cs.chartType}`); - } - } - } - - const dataFields = new Set(rows.flatMap((row) => Object.keys(row))); - for (const [channel, encoding] of entries) { - for (const field of encodingFields(encoding)) { - if (!dataFields.has(field)) { - throw new Error(`chart_spec.encodings.${channel}.field "${field}" does not exist in data.values`); - } - } - } -} - -function requiredChannels(template: ChartTemplateDef): string[] { - const channels = template.channels ?? []; - if (channels.includes('x') && channels.includes('y')) return ['x', 'y']; - if (template.chart === 'KPI Card') return ['metric', 'value']; - return []; -} - -function hasEncodingBinding(value: unknown): boolean { - if (typeof value === 'string') return value.trim().length > 0; - if (Array.isArray(value)) return value.some(hasEncodingBinding); - if (value && typeof value === 'object') { - const encoding = value as ChartEncoding; - return ( - (typeof encoding.field === 'string' && encoding.field.trim().length > 0) || - encoding.aggregate === 'count' - ); - } - return false; -} - -function encodingFields(value: unknown): string[] { - if (typeof value === 'string') return value.trim() ? [value] : []; - if (Array.isArray(value)) return value.flatMap(encodingFields); - if (value && typeof value === 'object') { - const field = (value as ChartEncoding).field; - return typeof field === 'string' && field.trim() ? [field] : []; - } - return []; + return resolveDataSource(input, { ...options, maxDataRows: MAX_DATA_ROWS }); } /** - * Assemble a Flint spec for one backend and split out Flint's private metadata - * (`_warnings`, `_width`, `_height`). The returned `spec` is left untouched so - * callers can choose to expose or strip the private keys. + * Resolve `data.url`, then assemble a Flint spec for one backend and split out + * Flint's private metadata (`_warnings`, `_width`, `_height`). The returned + * `spec` is left untouched so callers can choose to expose or strip the + * private keys. */ export function assembleForBackend( backend: RenderBackend, input: ChartAssemblyInput, options: DataSourceOptions = {}, ): AssembleResult { - const assemble = ASSEMBLERS[backend]; - if (!assemble) { - throw new Error(`unknown backend: ${backend}`); - } - const resolvedInput = prepareInput(input, options, backend); - const spec = assemble(resolvedInput); - const warnings: ChartWarning[] = Array.isArray(spec?._warnings) - ? spec._warnings - : []; - const width = typeof spec?._width === 'number' ? spec._width : undefined; - const height = typeof spec?._height === 'number' ? spec._height : undefined; - return { spec, warnings, width, height }; -} - -/** - * Remove Flint's private `_`-prefixed annotation keys from a top-level spec - * object so it is render-ready and safe to surface to callers. - */ -export function stripPrivateKeys>(spec: T): T { - for (const key of Object.keys(spec)) { - if (key.startsWith('_')) { - delete (spec as Record)[key]; - } - } - return spec; + return coreAssembleForBackend(backend, resolveInput(input, options), INPUT_CAPS); } diff --git a/packages/flint-mcp/src/tools/validate.ts b/packages/flint-mcp/src/tools/validate.ts index 83479769..a475d4f3 100644 --- a/packages/flint-mcp/src/tools/validate.ts +++ b/packages/flint-mcp/src/tools/validate.ts @@ -1,57 +1,39 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ChartAssemblyInput, ChartWarning } from 'flint-chart'; -import { assembleForBackend } from '../render/assemble.js'; +import { + validateChart as coreValidateChart, + type ChartAssemblyInput, + type ValidateResult, +} from 'flint-chart'; +import { INPUT_CAPS, resolveInput } from '../render/assemble.js'; import type { DataSourceOptions } from '../render/data-source.js'; import type { RenderBackend } from '../render/types.js'; -export interface ValidateResult { - backend: RenderBackend; - chartType: string; - /** True when assembly succeeded with no error-severity warnings. */ - valid: boolean; - /** All warnings (info/warning/error) emitted during assembly. */ - warnings: ChartWarning[]; - /** Error-severity warnings plus any thrown assembly failure. */ - errors: ChartWarning[]; - /** Computed layout size from Flint's stretch model, if available. */ - computedSize?: { width: number; height: number }; -} +export type { ValidateResult }; /** - * Validate a {@link ChartAssemblyInput} for a backend: report warnings/errors, - * applicability, and the computed layout size. Never throws — assembly failures - * are surfaced as an error entry. Pure JS — no native dependencies. + * Resolve `data.url`, then validate a {@link ChartAssemblyInput} for a backend + * via `flint-chart`'s `validateChart`. Never throws — data-source and assembly + * failures are surfaced as an error entry. */ export function validateChart( input: ChartAssemblyInput, backend: RenderBackend, options: DataSourceOptions = {}, ): ValidateResult { - const chartType = input?.chart_spec?.chartType ?? '(unknown)'; + let resolvedInput: ChartAssemblyInput; try { - const { warnings, width, height } = assembleForBackend(backend, input, options); - const errors = warnings.filter((w) => w.severity === 'error'); - return { - backend, - chartType, - valid: errors.length === 0, - warnings, - errors, - computedSize: - typeof width === 'number' && typeof height === 'number' - ? { width, height } - : undefined, - }; + resolvedInput = resolveInput(input, options); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { backend, - chartType, + chartType: input?.chart_spec?.chartType ?? '(unknown)', valid: false, warnings: [], errors: [{ severity: 'error', code: 'assembly_failed', message }], }; } + return coreValidateChart(resolvedInput, backend, INPUT_CAPS); }