UNPKG

legal-markdown-js

Version:

Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version

403 lines 16.4 kB
/** * @fileoverview Frontmatter Merger for Import Processing * * This module provides the core functionality for merging YAML frontmatter from * imported files using the "source always wins" strategy with flattened merging. * It enables granular merging at the property level while maintaining predictable * conflict resolution. * * Features: * - Flattened merging for granular property-level control * - "Source always wins" conflict resolution strategy * - Type conflict detection and validation * - Reserved field filtering integration * - Comprehensive merge statistics and reporting * * @example * ```typescript * import { mergeFlattened, validateMergeCompatibility } from './frontmatter-merger.js'; * * const current = { * title: "Main Document", * config: { server: "prod", port: 8080 } * }; * * const imported = { * config: { level: "high", server: "dev" }, // server conflict - current wins * client: "Acme Corp" // new field - added * }; * * const result = mergeFlattened(current, imported); * // { * // title: "Main Document", * // config: { server: "prod", port: 8080, level: "high" }, * // client: "Acme Corp" * // } * ``` */ import { flattenObject, unflattenObject, NUMERIC_TYPED_ARRAY_TYPES, isBuffer, isBigIntTypedArray, } from './object-flattener.js'; import { ProcessingError } from '../../errors/index.js'; import { logger } from '../../utils/logger.js'; import { filterReservedFields } from './reserved-fields-filter.js'; /** * Error thrown when merge validation fails */ export class MergeValidationError extends Error { field; currentType; importedType; constructor(message, field, currentType, importedType) { super(message); this.field = field; this.currentType = currentType; this.importedType = importedType; this.name = 'MergeValidationError'; } } export function mergeFlattened(current, imported, options = {}) { const { filterReserved = true, validateTypes = true, logOperations = false, conflictStrategy: _conflictStrategy = 'source-wins', includeStats = false, timeoutMs = 10000, } = options; const startTime = Date.now(); // Initialize statistics tracking const stats = { currentProperties: 0, importedProperties: 0, propertiesAdded: 0, conflictsResolved: 0, reservedFieldsFiltered: 0, addedFields: [], conflictedFields: [], filteredFields: [], }; // Handle null/undefined inputs if (!current || typeof current !== 'object') { current = {}; } if (!imported || typeof imported !== 'object') { imported = {}; } // Filter reserved fields from imported metadata let filteredImported = imported; if (filterReserved) { const originalKeys = Object.keys(imported); filteredImported = filterReservedFields(imported, { logFiltered: logOperations }); const filteredKeys = Object.keys(filteredImported); stats.reservedFieldsFiltered = originalKeys.length - filteredKeys.length; stats.filteredFields = originalKeys.filter(key => !filteredKeys.includes(key)); } // Flatten both objects for granular comparison with timeout protection // Both inputs are Record<string, YamlValue>; cast from unknown for downstream type safety const currentFlat = flattenObject(current, '', new WeakSet(), startTime, timeoutMs); const importedFlat = flattenObject(filteredImported, '', new WeakSet(), Date.now(), timeoutMs); stats.currentProperties = Object.keys(currentFlat).length; stats.importedProperties = Object.keys(importedFlat).length; if (logOperations) { logger.debug(`Merging frontmatter: ${stats.currentProperties} current + ${stats.importedProperties} imported properties`); } // Merge using "source always wins" strategy const mergedFlat = { ...currentFlat }; for (const [key, importedValue] of Object.entries(importedFlat)) { // Timeout safety check during merge loop if (Date.now() - startTime > timeoutMs) { throw new ProcessingError(`Frontmatter merge timed out after ${timeoutMs}ms. ` + 'This may indicate complex nested structures or circular references.'); } if (Object.prototype.hasOwnProperty.call(currentFlat, key)) { // Direct conflict: current wins const currentValue = currentFlat[key]; // Validate type compatibility if requested if (validateTypes) { try { validateMergeCompatibility(currentValue, importedValue, key); } catch (error) { if (logOperations) { logger.warn(`Type conflict for '${key}': ${error instanceof Error ? error.message : String(error)}`); } // Skip this field due to type conflict - current value remains unchanged stats.conflictsResolved++; stats.conflictedFields.push(key); continue; } } stats.conflictsResolved++; stats.conflictedFields.push(key); if (logOperations) { logger.debug(`Conflict for '${key}': current value kept`); } } else { // Check for nested conflicts in both directions const nestedConflictResult = validateTypes ? checkNestedConflicts(key, importedValue, currentFlat, logOperations) : { hasConflict: false, conflictedField: '' }; if (nestedConflictResult.hasConflict) { stats.conflictsResolved++; stats.conflictedFields.push(nestedConflictResult.conflictedField); } if (!nestedConflictResult.hasConflict) { // No conflict: add imported value mergedFlat[key] = importedValue; stats.propertiesAdded++; stats.addedFields.push(key); if (logOperations) { logger.debug(`Added '${key}' from import`); } } } } // Unflatten back to hierarchical structure; cast from unknown since input was YamlValue const mergedMetadata = unflattenObject(mergedFlat); if (includeStats) { return { metadata: mergedMetadata, stats, }; } return mergedMetadata; } /** * Validates that two values are compatible for merging * * Checks if the current and imported values have compatible types. * Throws MergeValidationError if types are incompatible. * * @param current - Current value * @param imported - Imported value to merge * @param key - Property key for error reporting * @throws MergeValidationError when types are incompatible * * @example * ```typescript * // Compatible types - no error * validateMergeCompatibility("string", "another string", "title"); * validateMergeCompatibility(42, 100, "count"); * * // Incompatible types - throws error * try { * validateMergeCompatibility("string", { object: true }, "config"); * } catch (error) { * console.log(error.message); // "Type conflict for 'config': current=string, imported=object" * } * ``` */ export function validateMergeCompatibility(current, imported, key) { const currentType = getValueType(current); const importedType = getValueType(imported); // Allow null/undefined to be compatible with anything if (current === null || current === undefined || imported === null || imported === undefined) { return; } // Check for incompatible type combinations if (currentType !== importedType) { // Special cases that are allowed const compatibleCombinations = [ ['string', 'number'], // Can convert numbers to strings ['number', 'string'], // Can parse strings as numbers ['boolean', 'string'], // Can convert boolean to string ['string', 'boolean'], // Can parse strings as boolean ['date', 'string'], // YAML can serialize Date as string ['string', 'date'], // String can represent a date ]; const isCompatible = compatibleCombinations.some(([type1, type2]) => (currentType === type1 && importedType === type2) || (currentType === type2 && importedType === type1)); if (!isCompatible) { throw new MergeValidationError(`Type conflict for '${key}': current=${currentType}, imported=${importedType}`, key, currentType, importedType); } } } /** * Gets a normalized type string for a value * * Returns a consistent type identifier for merge compatibility checking. * Distinguishes between plain objects and special object types (Date, RegExp, etc.) * to enable proper type conflict detection. * * @param value - Value to get type for * @returns Normalized type string * * @example * ```typescript * getValueType(null); // 'null' * getValueType([1, 2, 3]); // 'array' * getValueType(new Date()); // 'date' * getValueType(/regex/); // 'regexp' * getValueType({ a: 1 }); // 'object' * ``` */ function getValueType(value) { if (value === null) return 'null'; if (value === undefined) return 'undefined'; if (Array.isArray(value)) return 'array'; // Special object types if (value instanceof Date) return 'date'; if (value instanceof RegExp) return 'regexp'; if (value instanceof Error) return 'error'; // Node.js Buffer (shared check from object-flattener) if (isBuffer(value)) return 'buffer'; // Collections if (value instanceof Set || value instanceof WeakSet) return 'set'; if (value instanceof Map || value instanceof WeakMap) return 'map'; // Typed arrays - return generic 'typedarray' type (numeric, shared from object-flattener) if (NUMERIC_TYPED_ARRAY_TYPES.some(Type => value instanceof Type)) return 'typedarray'; // BigInt typed arrays (shared check from object-flattener) if (isBigIntTypedArray(value)) return 'typedarray'; // Default to typeof for primitives and plain objects return typeof value; } /** * Performs a dry run merge to preview results * * Simulates a merge operation without actually performing it. * Useful for validation and preview purposes. * * @param current - Current metadata * @param imported - Imported metadata * @param options - Merge options * @returns Preview of merge results * * @example * ```typescript * const preview = previewMerge(current, imported, { filterReserved: true }); * console.log(`Would add ${preview.stats.propertiesAdded} properties`); * console.log(`Would resolve ${preview.stats.conflictsResolved} conflicts`); * console.log(`Would filter ${preview.stats.reservedFieldsFiltered} reserved fields`); * ``` */ export function previewMerge(current, imported, options = {}) { const mergeOpts = { ...options, includeStats: true }; return mergeFlattened(current, imported, mergeOpts); } /** * Merges multiple imported metadata objects sequentially * * Applies the merge operation sequentially across multiple imports, * where each result becomes the new "current" for the next merge. * * @param initial - Initial metadata (usually from main document) * @param imports - Array of imported metadata objects to merge * @param options - Merge options applied to all operations * @returns Final merged metadata with cumulative statistics * * @example * ```typescript * const initial = { title: "Main Doc" }; * const imports = [ * { config: { level: "high" } }, * { config: { debug: true }, client: "Acme" }, * { metadata: { version: "1.0" } } * ]; * * const result = mergeSequentially(initial, imports, { includeStats: true }); * // result.metadata contains all merged properties * // result.stats contains cumulative statistics * ``` */ export function mergeSequentially(initial, imports, options = {}) { const startTime = Date.now(); const timeoutMs = options.timeoutMs || 15000; // Longer timeout for sequential operations let currentMetadata = initial; const cumulativeStats = { currentProperties: 0, importedProperties: 0, propertiesAdded: 0, conflictsResolved: 0, reservedFieldsFiltered: 0, addedFields: [], conflictedFields: [], filteredFields: [], }; for (let i = 0; i < imports.length; i++) { // Timeout safety check during sequential merge if (Date.now() - startTime > timeoutMs) { const message = `Sequential merge timed out after ${timeoutMs}ms ` + `while processing import ${i + 1}/${imports.length}.`; throw new ProcessingError(message); } const importedData = imports[i]; const remainingTime = Math.max(1000, timeoutMs - (Date.now() - startTime)); const mergeOpts = { ...options, includeStats: true, timeoutMs: remainingTime, }; const result = mergeFlattened(currentMetadata, importedData, mergeOpts); currentMetadata = result.metadata; // Accumulate statistics if (result.stats) { cumulativeStats.importedProperties += result.stats.importedProperties; cumulativeStats.propertiesAdded += result.stats.propertiesAdded; cumulativeStats.conflictsResolved += result.stats.conflictsResolved; cumulativeStats.reservedFieldsFiltered += result.stats.reservedFieldsFiltered; cumulativeStats.addedFields.push(...result.stats.addedFields); cumulativeStats.conflictedFields.push(...result.stats.conflictedFields); cumulativeStats.filteredFields.push(...result.stats.filteredFields); } if (options.logOperations) { logger.debug(`Merged import ${i + 1}/${imports.length}: +${result.stats?.propertiesAdded} properties`); } } // Set final current properties count cumulativeStats.currentProperties = Object.keys(flattenObject(currentMetadata)).length; return { metadata: currentMetadata, stats: cumulativeStats, }; } /** * Helper function to check for nested conflicts between current and imported fields * * @param key - The imported field key to check * @param importedValue - The value being imported * @param currentFlat - The flattened current metadata * @param logOperations - Whether to log conflict details * @returns Object indicating if there's a conflict and which field caused it */ function checkNestedConflicts(key, importedValue, currentFlat, logOperations) { // Case 1: current has 'config.debug' but import has 'config' // Import would overwrite nested structure with primitive for (const currentKey of Object.keys(currentFlat)) { if (currentKey.startsWith(key + '.')) { try { validateMergeCompatibility({}, importedValue, key); } catch (error) { if (logOperations) { logger.warn(`Type conflict for '${key}': ${error instanceof Error ? error.message : String(error)}`); } return { hasConflict: true, conflictedField: key }; } } } // Case 2: current has 'enabled' but import has 'enabled.not' // Import would overwrite primitive with nested structure const baseKey = key.split('.')[0]; const hasBaseKey = Object.prototype.hasOwnProperty.call(currentFlat, baseKey); if (hasBaseKey && baseKey !== key) { const currentValue = currentFlat[baseKey]; try { validateMergeCompatibility(currentValue, {}, baseKey); } catch (error) { if (logOperations) { const message = error instanceof Error ? error.message : String(error); logger.warn(`Type conflict for '${baseKey}': ${message}`); } return { hasConflict: true, conflictedField: baseKey }; } } return { hasConflict: false, conflictedField: '' }; } // Exported for testing - not part of public API export { checkNestedConflicts as _checkNestedConflicts }; //# sourceMappingURL=frontmatter-merger.js.map