// SPEC: pikasTech/unidesk#1579 YAML-first composition. // Responsibility: materialize lightweight YAML templates before domain parsers validate shapes. export interface YamlCompositionResult { readonly value: T; readonly traces: readonly YamlCompositionTrace[]; } export interface YamlCompositionTrace { readonly path: string; readonly extends: readonly string[]; readonly overrideKeys: readonly string[]; readonly variables: Record; } interface CompositionOptions { readonly label: string; readonly stripTemplateKeys?: boolean; } const COMPOSITION_KEYS = new Set(["extends", "overrides", "variables"]); const TEMPLATE_ROOT_KEYS = new Set(["templates", "baselines"]); export function materializeYamlComposition(input: T, options: CompositionOptions): YamlCompositionResult { const traces: YamlCompositionTrace[] = []; const root = input as unknown; const value = materializeValue(root, root, options.label, {}, traces, []); if (options.stripTemplateKeys === true && isRecord(value)) { const stripped = { ...value }; for (const key of TEMPLATE_ROOT_KEYS) delete stripped[key]; return { value: stripped as T, traces }; } return { value: value as T, traces }; } export function renderYamlTemplateStrings(input: T, variables: Record): T { return renderValue(input as unknown, deriveVariables(variables)) as T; } function materializeValue( value: unknown, root: unknown, path: string, inheritedVariables: Record, traces: YamlCompositionTrace[], stack: readonly string[], ): unknown { if (Array.isArray(value)) { return value.map((item, index) => materializeValue(item, root, `${path}[${index}]`, inheritedVariables, traces, stack)); } if (!isRecord(value)) return renderValue(value, inheritedVariables); const localVariables = deriveVariables({ ...inheritedVariables, ...stringRecord(value.variables) }); const refs = extendsRefs(value.extends); let merged: Record = {}; if (refs.length > 0) { for (const ref of refs) { if (stack.includes(ref)) throw new Error(`${path}.extends has cyclic template reference ${ref}`); const template = valueAtPath(root, ref); if (!isRecord(template)) throw new Error(`${path}.extends references missing object ${ref}`); const renderedTemplate = materializeValue(template, root, ref, localVariables, traces, [...stack, ref]); merged = deepMerge(merged, asRecord(renderedTemplate, ref)); } } const own = stripCompositionFields(value); const overrides = value.overrides === undefined ? {} : asRecord(value.overrides, `${path}.overrides`); merged = deepMerge(merged, own); merged = deepMerge(merged, overrides); const rendered = renderObjectScalars(merged, deriveVariables({ ...variablesFromRecord(merged), ...localVariables })); const result = Object.fromEntries( Object.entries(asRecord(rendered, path)).map(([key, item]) => [ key, materializeValue(item, root, `${path}.${key}`, deriveVariables({ ...localVariables, ...variablesFromRecord(rendered) }), traces, stack), ]), ); if (refs.length > 0) { traces.push({ path, extends: refs, overrideKeys: Object.keys(overrides).sort(), variables: deriveVariables({ ...localVariables, ...variablesFromRecord(result) }), }); } return result; } function stripCompositionFields(value: Record): Record { return Object.fromEntries(Object.entries(value).filter(([key]) => !COMPOSITION_KEYS.has(key))); } function extendsRefs(value: unknown): string[] { if (value === undefined) return []; if (typeof value === "string" && value.trim().length > 0) return [value.trim()]; if (Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim().length > 0)) { return value.map((item) => item.trim()); } throw new Error("extends must be a non-empty string or string array"); } function renderValue(value: unknown, variables: Record): unknown { if (typeof value === "string") { return value.replace(/\$\{([A-Za-z][A-Za-z0-9_]*)\}/gu, (match, name: string) => variables[name] ?? match); } if (Array.isArray(value)) return value.map((item) => renderValue(item, variables)); if (!isRecord(value)) return value; return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderValue(item, variables)])); } function renderObjectScalars(value: Record, variables: Record): Record { return Object.fromEntries(Object.entries(value).map(([key, item]) => [ key, typeof item === "string" ? renderValue(item, variables) : item, ])); } function variablesFromRecord(value: unknown): Record { if (!isRecord(value)) return {}; const result: Record = {}; if (typeof value.node === "string" && value.node.length > 0) result.NODE = value.node; if (typeof value.lane === "string" && value.lane.length > 0) result.LANE = value.lane; if (typeof value.id === "string" && value.id.length > 0) result.targetId = value.id; return deriveVariables(result); } function deriveVariables(input: Record): Record { const result = { ...input }; if (result.NODE !== undefined) result.nodeLower = result.NODE.toLowerCase(); if (result.LANE !== undefined) result.lane = result.LANE; if (result.lane !== undefined && result.LANE === undefined) result.LANE = result.lane; return result; } function stringRecord(value: unknown): Record { if (value === undefined) return {}; const raw = asRecord(value, "variables"); return Object.fromEntries(Object.entries(raw).map(([key, item]) => { if (typeof item !== "string" || item.length === 0) throw new Error(`variables.${key} must be a non-empty string`); return [key, item]; })); } function deepMerge(base: Record, override: Record): Record { const result: Record = { ...base }; for (const [key, value] of Object.entries(override)) { const existing = result[key]; result[key] = isRecord(existing) && isRecord(value) ? deepMerge(existing, value) : value; } return result; } function valueAtPath(root: unknown, ref: string): unknown { let current: unknown = root; for (const segment of ref.split(".")) { if (!/^[A-Za-z0-9_-]+$/u.test(segment) || !isRecord(current)) return undefined; current = current[segment]; } return current; } function asRecord(value: unknown, path: string): Record { if (!isRecord(value)) throw new Error(`${path} must be an object`); return value; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }