All files / lib/ast utils.js

64.76% Statements 68/105
70.32% Branches 64/91
62.5% Functions 5/8
64.42% Lines 67/104

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275                          24x                     24x         24x 84x 84x     84x                   84x                   84x   20x   20x 3x 3x 3x 3x 3x   17x   64x   40x 40x   40x   1x 1x       39x   1x 1x       38x                 38x   24x   1x 1x             23x       24x                     18x   18x 63x 63x     63x         63x           63x 15x 15x       48x 30x   10x 9x   1x   20x   10x     10x 9x   1x     30x       18x   6x 12x   6x     6x       18x                 182x                         402x 134x     268x 173x     95x 95x 326x   95x                                                                                                                      
/**
 * AST Utility Functions
 *
 * Helper functions for diffing and merging AST objects
 */
 
/**
 * Deep diff two objects
 * @param {Object} obj1 - First object
 * @param {Object} obj2 - Second object to compare against
 * @returns {Object} Diff result with summary and detailed changes
 */
export function diffObjects(obj1, obj2) {
  const result = {
    summary: {
      added: 0,
      removed: 0,
      modified: 0,
      unchanged: 0
    },
    changes: {}
  };
 
  // Collect all keys from both objects
  const allKeys = new Set([
    ...Object.keys(obj1 || {}),
    ...Object.keys(obj2 || {})
  ]);
 
  for (const key of allKeys) {
    const val1 = obj1?.[key];
    const val2 = obj2?.[key];
 
    // Key only in obj2 - added
    Iif (!(key in obj1)) {
      result.summary.added++;
      result.changes[key] = {
        status: 'added',
        value: val2
      };
      continue;
    }
 
    // Key only in obj1 - removed
    Iif (!(key in obj2)) {
      result.summary.removed++;
      result.changes[key] = {
        status: 'removed',
        value: val1
      };
      continue;
    }
 
    // Both have the key - compare values
    if (isObject(val1) && isObject(val2)) {
      // Recursive diff for nested objects
      const nestedDiff = diffObjects(val1, val2);
 
      if (Object.keys(nestedDiff.changes).length > 0) {
        result.summary.modified += nestedDiff.summary.modified;
        result.summary.added += nestedDiff.summary.added;
        result.summary.removed += nestedDiff.summary.removed;
        result.summary.unchanged += nestedDiff.summary.unchanged;
        result.changes[key] = nestedDiff;
      } else {
        result.summary.unchanged++;
      }
    } else if (Array.isArray(val1) && Array.isArray(val2)) {
      // Array comparison - treat empty vs non-empty as added/removed
      const isEmpty1 = val1.length === 0;
      const isEmpty2 = val2.length === 0;
 
      if (isEmpty1 && !isEmpty2) {
        // Added: source has items, target empty
        result.summary.added++;
        result.changes[key] = {
          status: 'added',
          value: val2
        };
      } else if (!isEmpty1 && isEmpty2) {
        // Removed: target had items, source empty
        result.summary.removed++;
        result.changes[key] = {
          status: 'removed',
          value: val1
        };
      } else Iif (JSON.stringify(val1) !== JSON.stringify(val2)) {
        // Both non-empty and different -> modified
        result.summary.modified++;
        result.changes[key] = {
          status: 'modified',
          oldValue: val1,
          newValue: val2
        };
      } else {
        result.summary.unchanged++;
      }
    } else if (val1 !== val2) {
      // Primitive value difference
      result.summary.modified++;
      result.changes[key] = {
        status: 'modified',
        oldValue: val1,
        newValue: val2
      };
    } else {
      // No change
      result.summary.unchanged++;
    }
  }
 
  return result;
}
 
/**
 * Merge two objects with strategy
 * @param {Object} target - Target object (values preserved based on strategy)
 * @param {Object} source - Source object (values to merge in)
 * @param {string} strategy - Merge strategy: 'canonical', 'preserve', 'timestamp'
 * @returns {Object} Merged object
 */
export function mergeObjects(target, source, strategy = 'canonical') {
  const result = { ...target };
 
  for (const key of Object.keys(source || {})) {
    const targetVal = target?.[key];
    const sourceVal = source[key];
 
    // Skip if source value is undefined/null
    Iif (sourceVal === undefined || sourceVal === null) {
      continue;
    }
 
    // Key not in target - add from source
    Iif (!(key in target) || targetVal === undefined || targetVal === null) {
      result[key] = deepClone(sourceVal);
      continue;
    }
 
    // Both values are objects - recurse
    if (isObject(targetVal) && isObject(sourceVal)) {
      result[key] = mergeObjects(targetVal, sourceVal, strategy);
      continue;
    }
 
    // Arrays - merge based on strategy
    if (Array.isArray(targetVal) && Array.isArray(sourceVal)) {
      if (strategy === 'preserve') {
        // Keep target unless target is empty, then adopt source
        if (targetVal.length === 0) {
          result[key] = deepClone(sourceVal);
        } else {
          result[key] = deepClone(targetVal);
        }
      } else if (strategy === 'timestamp') {
        // Use source (assumed newer)
        result[key] = deepClone(sourceVal);
      } else {
        // Canonical: prefer target, but if target is empty, use source
        if (targetVal.length === 0) {
          result[key] = deepClone(sourceVal);
        } else {
          result[key] = deepClone(targetVal);
        }
      }
      continue;
    }
 
    // Primitive values - use strategy
    if (strategy === 'preserve') {
      // Keep target value
      result[key] = targetVal;
    } else if (strategy === 'timestamp') {
      // Use source value (assumed newer)
      result[key] = sourceVal;
    } else {
      // Canonical: prefer target
      result[key] = targetVal;
    }
  }
 
  return result;
}
 
/**
 * Check if value is a plain object (not array, not null)
 * @param {*} value
 * @returns {boolean}
 */
function isObject(value) {
  return typeof value === 'object' &&
         value !== null &&
         !Array.isArray(value) &&
         !(value instanceof Date) &&
         !(value instanceof RegExp);
}
 
/**
 * Deep clone a value
 * @param {*} value
 * @returns {*}
 */
export function deepClone(value) {
  if (value === null || typeof value !== 'object') {
    return value;
  }
 
  if (Array.isArray(value)) {
    return value.map(item => deepClone(item));
  }
 
  const cloned = {};
  for (const key of Object.keys(value)) {
    cloned[key] = deepClone(value[key]);
  }
  return cloned;
}
 
/**
 * Format a diff result as a readable string
 * @param {Object} diff - Diff result from diffObjects
 * @param {string} indent - Indentation for nested output
 * @returns {string} Formatted diff string
 */
export function formatDiff(diff, indent = '') {
  const lines = [];
 
  // Summary
  const { summary } = diff;
  lines.push(`${indent}Summary:`);
  lines.push(`${indent}  Added: ${summary.added}`);
  lines.push(`${indent}  Removed: ${summary.removed}`);
  lines.push(`${indent}  Modified: ${summary.modified}`);
  lines.push(`${indent}  Unchanged: ${summary.unchanged}`);
 
  // Changes
  if (Object.keys(diff.changes).length > 0) {
    lines.push(`${indent}Changes:`);
    formatChanges(diff.changes, lines, indent + '  ');
  }
 
  return lines.join('\n');
}
 
/**
 * Format changes recursively
 * @private
 */
function formatChanges(changes, lines, indent) {
  for (const [key, change] of Object.entries(changes)) {
    if (change.status) {
      // Leaf change
      if (change.status === 'added') {
        lines.push(`${indent}+ ${key}: ${JSON.stringify(change.value)}`);
      } else if (change.status === 'removed') {
        lines.push(`${indent}- ${key}: ${JSON.stringify(change.value)}`);
      } else if (change.status === 'modified') {
        lines.push(`${indent}* ${key}:`);
        lines.push(`${indent}    - ${JSON.stringify(change.oldValue)}`);
        lines.push(`${indent}    + ${JSON.stringify(change.newValue)}`);
      }
    } else {
      // Nested diff
      const hasChanges = Object.values(change).some(
        c => c.status || (c.summary && (c.summary.added + c.summary.removed + c.summary.modified) > 0)
      );
 
      if (hasChanges) {
        lines.push(`${indent}${key}/`);
        formatChanges(change.changes || change, lines, indent + '  ');
      }
    }
  }
}