167 lines
6.7 KiB
TypeScript
167 lines
6.7 KiB
TypeScript
// SPEC: pikasTech/unidesk#1579 YAML-first composition.
|
|
// Responsibility: materialize lightweight YAML templates before domain parsers validate shapes.
|
|
|
|
export interface YamlCompositionResult<T> {
|
|
readonly value: T;
|
|
readonly traces: readonly YamlCompositionTrace[];
|
|
}
|
|
|
|
export interface YamlCompositionTrace {
|
|
readonly path: string;
|
|
readonly extends: readonly string[];
|
|
readonly overrideKeys: readonly string[];
|
|
readonly variables: Record<string, string>;
|
|
}
|
|
|
|
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<T>(input: T, options: CompositionOptions): YamlCompositionResult<T> {
|
|
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<T>(input: T, variables: Record<string, string>): T {
|
|
return renderValue(input as unknown, deriveVariables(variables)) as T;
|
|
}
|
|
|
|
function materializeValue(
|
|
value: unknown,
|
|
root: unknown,
|
|
path: string,
|
|
inheritedVariables: Record<string, string>,
|
|
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<string, unknown> = {};
|
|
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<string, unknown>): Record<string, unknown> {
|
|
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<string, string>): 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<string, unknown>, variables: Record<string, string>): Record<string, unknown> {
|
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
key,
|
|
typeof item === "string" ? renderValue(item, variables) : item,
|
|
]));
|
|
}
|
|
|
|
function variablesFromRecord(value: unknown): Record<string, string> {
|
|
if (!isRecord(value)) return {};
|
|
const result: Record<string, string> = {};
|
|
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<string, string>): Record<string, string> {
|
|
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<string, string> {
|
|
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<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
|
|
const result: Record<string, unknown> = { ...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<string, unknown> {
|
|
if (!isRecord(value)) throw new Error(`${path} must be an object`);
|
|
return value;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|