import { JsonFieldNamingProfile, renderJsonFieldName, } from './object-location'; import type { CheckResult } from './i18n'; export interface WireFieldMetadata { readonly canonicalName: string; readonly wireName: string; readonly aliases: readonly string[]; } export interface WireEntityMetadata { readonly entityType: string; readonly profile: JsonFieldNamingProfile; readonly fields: Readonly>; } export interface NormalizedWireInput { /** Values keyed only by canonical KSML property names. */ readonly values: Readonly>; /** Exact submitted RFC 6901 pointer, retained only for accepted aliases. */ readonly sourceInstancePaths: Readonly>; } export class WireInputError extends Error { constructor( public readonly code: 'WIRE_UNKNOWN_FIELD' | 'WIRE_FIELD_COLLISION', public readonly instancePath: string, message: string, ) { super(message); this.name = 'WireInputError'; } } export function createWireEntityMetadata( entityType: string, canonicalFields: readonly string[], profile: JsonFieldNamingProfile = 'camelCase', aliases: Readonly> = {}, ): WireEntityMetadata { const fields: Record = {}; const spellings = new Map(); for (const canonicalName of canonicalFields) { const wireName = renderJsonFieldName(canonicalName, profile); const fieldAliases = [...(aliases[canonicalName] ?? [])]; for (const spelling of [wireName, ...fieldAliases]) { const existing = spellings.get(spelling); if (existing && existing !== canonicalName) { throw new Error( `Wire field spelling '${spelling}' maps to both '${existing}' and '${canonicalName}'`, ); } spellings.set(spelling, canonicalName); } fields[canonicalName] = Object.freeze({ canonicalName, wireName, aliases: Object.freeze(fieldAliases), }); } return Object.freeze({ entityType, profile, fields: Object.freeze(fields) }); } /** * Normalize one JSON object before checker or mutation execution. * * No serializer naming policy is consulted. The generated field map is the * complete authority, and two submitted spellings for one canonical field are * always rejected, even when their values happen to be equal. */ export function normalizeWireInput( input: Readonly>, metadata: WireEntityMetadata, parentPointer = '', ): NormalizedWireInput { const lookup = new Map(); for (const field of Object.values(metadata.fields)) { lookup.set(field.wireName, field); for (const alias of field.aliases) lookup.set(alias, field); } const values: Record = {}; const sourceInstancePaths: Record = {}; const submitted = new Map(); for (const [submittedName, value] of Object.entries(input)) { const pointer = `${parentPointer}/${escapeJsonPointer(submittedName)}`; const field = lookup.get(submittedName); if (!field) { throw new WireInputError( 'WIRE_UNKNOWN_FIELD', pointer, `Unknown ${metadata.entityType} field '${submittedName}'`, ); } const previous = submitted.get(field.canonicalName); if (previous !== undefined) { throw new WireInputError( 'WIRE_FIELD_COLLISION', pointer, `Fields '${previous}' and '${submittedName}' both map to canonical field '${field.canonicalName}'`, ); } submitted.set(field.canonicalName, submittedName); values[field.canonicalName] = value; if (submittedName !== field.wireName) { sourceInstancePaths[field.canonicalName] = pointer; } } return Object.freeze({ values: Object.freeze(values), sourceInstancePaths: Object.freeze(sourceInstancePaths), }); } /** Render canonical mutation values to the selected external profile. */ export function encodeWireOutput( values: Readonly>, metadata: WireEntityMetadata, ): Readonly> { const output: Record = {}; for (const [canonicalName, value] of Object.entries(values)) { const field = metadata.fields[canonicalName]; if (!field) { throw new Error(`Unknown canonical ${metadata.entityType} field '${canonicalName}'`); } output[field.wireName] = value; } return Object.freeze(output); } /** Add submitted alias provenance to checker results without changing location identity. */ export function retainSubmittedPaths( results: readonly CheckResult[], normalized: NormalizedWireInput, ): CheckResult[] { return results.map(result => { const firstProperty = result.location.segments.find(segment => segment.kind === 'property'); if (!firstProperty || firstProperty.kind !== 'property') return { ...result }; const sourceInstancePath = normalized.sourceInstancePaths[firstProperty.name]; return sourceInstancePath === undefined ? { ...result } : { ...result, sourceInstancePath }; }); } function escapeJsonPointer(value: string): string { return value.replace(/~/g, '~0').replace(/\//g, '~1'); }