import { type BaseIssue, boolean, type GenericSchema, finite, literal, null_, number, picklist, pipe, safeParse, strictObject, string, summarize, union, variant, } from 'valibot'; import { MalformedRuleError, UnknownNodeError } from './errors'; import { CHART_RULE_CONTRACT_VERSION, type ParsedRule, type Rule, type RuleNode, type Scalar } from './types'; import { EQUALITY_COMPARISON_KINDS, NUMERIC_COMPARISON_KINDS, RULE_CHANNELS, RULE_NODE_KINDS, } from './vocabulary'; const ruleNodeKinds: ReadonlySet = new Set(RULE_NODE_KINDS); const placeholderKeys: ReadonlySet = new Set(['$rule', 'channel']); const ruleFieldKeys = ['$rule', 'channel', 'kind', 'value', 'left', 'right', 'cond', 'then', 'else'] as const; const scalarSchema: GenericSchema = union([ pipe(number(), finite()), string(), boolean(), null_(), ]); const parsedRules = new WeakSet(); /** @deprecated Use `CHART_RULE_CONTRACT_VERSION` to identify the executable contract. */ export const CHART_RULE_SCHEMA_ID = `https://rippling.com/schemas/${CHART_RULE_CONTRACT_VERSION}.json`; function hasOwn(value: object, key: PropertyKey): boolean { return Object.prototype.hasOwnProperty.call(value, key); } const refNodeSchema = strictObject({ kind: literal('value') }); const literalNodeSchema = strictObject({ kind: literal('literal'), value: scalarSchema }); const numericLiteralNodeSchema = strictObject({ kind: literal('literal'), value: pipe(number(), finite()), }); const requiredColorLiteralNodeSchema = strictObject({ kind: literal('literal'), value: string(), }); const optionalColorLiteralNodeSchema = strictObject({ kind: literal('literal'), value: union([string(), null_()]), }); const comparisonOperandSchema = variant('kind', [refNodeSchema, literalNodeSchema]); const numericOperandSchema = variant('kind', [refNodeSchema, numericLiteralNodeSchema]); const equalityComparisonNodeSchema = strictObject({ kind: picklist(EQUALITY_COMPARISON_KINDS), left: comparisonOperandSchema, right: comparisonOperandSchema, }); const numericComparisonNodeSchema = strictObject({ kind: picklist(NUMERIC_COMPARISON_KINDS), left: numericOperandSchema, right: numericOperandSchema, }); const comparisonNodeSchema = variant('kind', [equalityComparisonNodeSchema, numericComparisonNodeSchema]); const ifElseNodeSchema = strictObject({ kind: literal('ifElse'), cond: comparisonNodeSchema, then: requiredColorLiteralNodeSchema, else: optionalColorLiteralNodeSchema, }); /** * Executable runtime schema for a chart-rule node. * * v1 is intentionally non-recursive. A comparison contains only `value` or * literal operands, and a conditional contains one comparison plus two colour * literals. Contract tests prove that every public builder output parses. */ const ruleNodeSchema: GenericSchema> = variant('kind', [ refNodeSchema, literalNodeSchema, equalityComparisonNodeSchema, numericComparisonNodeSchema, ifElseNodeSchema, ]); /** Runtime schema for the `{$rule, channel}` value embedded in a chart option. */ const rulePlaceholderSchema = strictObject({ $rule: ifElseNodeSchema, channel: picklist(RULE_CHANNELS), }); /** * Runtime schema for either a bare rule node or a chart-option placeholder. * Persisted and agent-authored JSON crosses this boundary as `unknown`. */ export const chartRuleSchema: GenericSchema> = union([ rulePlaceholderSchema, ruleNodeSchema, ]); /** Parse untrusted chart-rule JSON and return an immutable validated clone. */ export function parseChartRule(input: unknown): ParsedRule { if (isParsedRule(input)) { return input; } preflight(input); const result = safeParse(chartRuleSchema, input); if (result.success) { return markParsedRule(result.output); } throw new MalformedRuleError(summarize(result.issues)); } /** * Valibot's object schemas use `key in input`, which also reads inherited * properties. Reject inherited rule fields before parsing so prototype data * cannot supply required operands or bypass the flat v1 contract. */ function preflight(input: unknown): void { assertNoInheritedRuleFields(input); const root = unwrapPlaceholder(input); assertNoInheritedRuleFields(root); assertKnownNode(root); if (!isRecord(root) || !hasOwn(root, 'kind')) { return; } if (root['kind'] === 'ifElse') { inspectChildren(root, ['cond', 'then', 'else']); const condition = root['cond']; inspectComparison(condition); } else if (typeof root['kind'] === 'string' && isComparisonKind(root['kind'])) { inspectComparison(root); } } function inspectComparison(value: unknown): void { assertNoInheritedRuleFields(value); assertKnownNode(value); if (isRecord(value)) { inspectChildren(value, ['left', 'right']); } } function inspectChildren(parent: Record, keys: readonly string[]): void { for (const key of keys) { if (hasOwn(parent, key)) { const child = parent[key]; assertNoInheritedRuleFields(child); assertKnownNode(child); } } } function assertNoInheritedRuleFields(value: unknown): void { if (!isRecord(value)) { return; } for (const key of ruleFieldKeys) { if (key in value && !hasOwn(value, key)) { throw new MalformedRuleError(`a chart rule must not inherit the ${key} property`); } } } function assertKnownNode(value: unknown): void { if ( isRecord(value) && hasOwn(value, 'kind') && typeof value['kind'] === 'string' && !ruleNodeKinds.has(value['kind']) ) { throw new UnknownNodeError(value['kind']); } } function isComparisonKind(kind: string): boolean { return kind !== 'literal' && kind !== 'value' && kind !== 'ifElse' && ruleNodeKinds.has(kind); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function unwrapPlaceholder(input: unknown): unknown { if (input === null || typeof input !== 'object' || Array.isArray(input) || !hasOwn(input, '$rule')) { return input; } const unexpectedKey = Object.keys(input).find((key) => !placeholderKeys.has(key)); if (unexpectedKey !== undefined) { throw new MalformedRuleError(`a rule placeholder has an unexpected property: ${unexpectedKey}`); } return (input as { $rule?: unknown }).$rule; } function isParsedRule(input: unknown): input is ParsedRule { return input !== null && typeof input === 'object' && !Array.isArray(input) && parsedRules.has(input); } /** * Mark the validated clone so `evaluate(parseChartRule(input), ctx)` does not * reparse the full value for every datum. Freezing prevents a caller from * mutating a trusted value after validation. */ function markParsedRule(rule: Rule): ParsedRule { const stack: object[] = [rule]; const seen = new WeakSet(); while (stack.length > 0) { const current = stack.pop(); if (!current || seen.has(current)) { continue; } seen.add(current); for (const value of Object.values(current)) { if (value !== null && typeof value === 'object') { stack.push(value); } } Object.freeze(current); } parsedRules.add(rule); return rule as ParsedRule; }