sf-decomposer
Version:
Split large Salesforce metadata files into version-control-friendly pieces and rebuild deployment-ready files.
120 lines • 4.62 kB
JavaScript
;
import { readdir, readFile, stat } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { parseXml } from 'config-disassembler';
/**
* Recursively diff two directory trees. Files in the reference tree are compared against the
* mock tree; files that exist only in the mock tree are intentionally ignored, so the helper is
* safe to use against round-trip output that contains transient sidecars (e.g.
* `.config-disassembler.json`) which the original tree never had.
*
* For `.xml` files, comparison is **structural and order-insensitive**: sibling elements with the
* same tag name can appear in any order without registering as drift. Files that are byte-different
* but semantically equal are returned in `reordered` so the caller can surface the difference.
*/
export async function diffDirectories(referenceDir, mockDir, prefix = '') {
const out = { drift: [], reordered: [] };
let entries;
try {
entries = await readdir(referenceDir, { withFileTypes: true });
}
catch {
/* istanbul ignore next -- @preserve: caller already filters to existing directories */
return out;
}
for (const entry of entries) {
const refPath = join(referenceDir, entry.name);
const mockPath = join(mockDir, entry.name);
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
// eslint-disable-next-line no-await-in-loop
const nested = await diffDirectories(refPath, mockPath, relPath);
out.drift.push(...nested.drift);
out.reordered.push(...nested.reordered);
continue;
}
// eslint-disable-next-line no-await-in-loop
const mockExists = await fileExists(mockPath);
if (!mockExists) {
out.drift.push({ path: relPath, reason: 'missing in round-trip output' });
continue;
}
// eslint-disable-next-line no-await-in-loop
const [ref, mock] = await Promise.all([readFile(refPath, 'utf-8'), readFile(mockPath, 'utf-8')]);
if (ref === mock)
continue;
if (isXmlFile(entry.name)) {
// Byte-different but maybe semantically identical — e.g. siblings reordered on round trip.
if (xmlEquivalent(refPath, mockPath)) {
out.reordered.push(relPath);
}
else {
out.drift.push({ path: relPath, reason: 'content drift' });
}
}
else {
out.drift.push({ path: relPath, reason: 'content drift' });
}
}
return out;
}
async function fileExists(path) {
try {
await stat(path);
return true;
}
catch {
// Stryker disable next-line BlockStatement: defensive only
return false;
}
}
function isXmlFile(fileName) {
return extname(fileName).toLowerCase() === '.xml';
}
/**
* Compare two XML files for structural equality, ignoring sibling order and attribute order.
* Falls back to `false` if either side fails to parse, so genuinely malformed output still
* surfaces as drift through the caller.
*/
export function xmlEquivalent(refPath, mockPath) {
const parsedA = parseXml(refPath);
const parsedB = parseXml(mockPath);
if (parsedA === null || parsedB === null)
return false;
return canonicalJson(parsedA) === canonicalJson(parsedB);
}
/**
* Convert any JSON value into a stable string representation: object keys are sorted, and arrays
* are sorted by the canonical-JSON of each element. Two values produce the same canonical string
* iff they are deeply equal up to sibling order.
*/
export function canonicalJson(value) {
return JSON.stringify(canonicalize(value));
}
function canonicalize(value) {
if (value === null || typeof value !== 'object')
return value;
if (Array.isArray(value)) {
const normalized = value.map(canonicalize);
normalized.sort((left, right) => {
const ls = JSON.stringify(left);
const rs = JSON.stringify(right);
// Stryker disable next-line EqualityOperator
if (ls < rs)
return -1;
// Stryker disable next-line EqualityOperator
if (ls > rs)
return 1;
return 0;
});
return normalized;
}
const record = value;
const keys = Object.keys(record).sort();
const out = {};
for (const key of keys) {
out[key] = canonicalize(record[key]);
}
return out;
}
//# sourceMappingURL=diffDirectories.js.map