sf-decomposer
Version:
Split large Salesforce metadata files into version-control-friendly pieces and rebuild deployment-ready files.
139 lines • 6.9 kB
JavaScript
;
import { mkdtemp, rm, readFile, writeFile, cp, stat, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve } from 'node:path';
import { getRepoRoot } from '../service/core/getRepoRoot.js';
import { diffDirectories } from '../service/verify/diffDirectories.js';
import { SFDX_PROJECT_FILE_NAME } from '../helpers/constants.js';
import { decomposeMetadataTypes } from './decomposeMetadataTypes.js';
import { recomposeMetadataTypes } from './recomposeMetadataTypes.js';
/**
* Run a non-destructive round-trip check: copy the user's package directories into a scratch
* directory under the OS temp folder, decompose + recompose them there, and diff the rebuilt
* parents against the originals.
*
* The temp directory is always removed before this function returns, and the user's working tree
* is never modified. The returned `drift` array is empty when every parent XML survived the round
* trip byte-identically; otherwise each entry names the offending file (relative to its package
* directory) and a short reason.
*/
export async function verifyMetadataTypes(options) {
const { metadataTypes, format, ignoreDirs, strategy, decomposeNestedPerms, manifest, overrides, log } = options;
const { repoRoot, dxConfigFilePath } = (await getRepoRoot());
const sfdxProjectRaw = await readFile(dxConfigFilePath, 'utf-8');
const sfdxProject = JSON.parse(sfdxProjectRaw);
const packageDirRelPaths = sfdxProject.packageDirectories.map((p) => p.path);
const tempProjectDir = await mkdtemp(join(tmpdir(), 'sf-decomposer-verify-'));
const originalCwd = process.cwd();
try {
await Promise.all(packageDirRelPaths.map(async (rel) => {
const src = resolve(repoRoot, rel);
const dst = resolve(tempProjectDir, rel);
/* istanbul ignore next -- @preserve: declared package dirs typically exist; defensive only */
if (!(await pathExists(src))) {
/* istanbul ignore next -- @preserve: declared package dirs typically exist; defensive only */
return;
}
await cp(src, dst, { recursive: true });
}));
await writeFile(join(tempProjectDir, SFDX_PROJECT_FILE_NAME), sfdxProjectRaw);
// Manifests are validated by oclif's `Flags.file({ exists: true })`, so when one is supplied
// it always points to a real file under the user's repo. Mirror it into the temp project at
// the same relative path so parseManifest finds it via the tempProjectDir repoRoot.
let tempManifest;
if (manifest) {
const absManifest = resolve(originalCwd, manifest);
const relManifest = relative(repoRoot, absManifest);
const tempManifestAbs = resolve(tempProjectDir, relManifest);
await mkdir(dirname(tempManifestAbs), { recursive: true });
await cp(absManifest, tempManifestAbs);
tempManifest = tempManifestAbs;
}
// Strip any user-supplied prePurge/postPurge from the overrides for verify only. Verify needs
// the parent XML to survive the decompose phase so that manifest-driven recompose can
// re-resolve it (parseManifest only returns parent XML paths that exist on disk). Letting the
// user's overrides drive post-purge here would silently break manifest filtering.
const verifyOverrides = overrides?.map((override) => {
const { prePurge, postPurge, ...rest } = override;
// Reference the stripped fields so the linter understands they are intentionally discarded.
void prePurge;
void postPurge;
return rest;
});
const decomposed = await decomposeMetadataTypes({
metadataTypes,
// Wipe any pre-existing decomposed children so we always start from a clean fixture, but
// keep the parent XML intact for the recompose phase (see comment above).
prepurge: true,
postpurge: false,
format,
ignoreDirs,
strategy,
decomposeNestedPerms,
manifest: tempManifest,
overrides: verifyOverrides,
log,
repoRoot: tempProjectDir,
});
if (decomposed.metadata.length > 0) {
await recomposeMetadataTypes({
metadataTypes: decomposed.metadata,
// Postpurge here removes the decomposed children we generated above, leaving the rebuilt
// parent XML as the only artifact to diff against the original.
postpurge: true,
ignoreDirs,
manifest: tempManifest,
log,
repoRoot: tempProjectDir,
});
}
const drift = [];
const reordered = [];
const diffTasks = packageDirRelPaths.map(async (rel) => {
const original = resolve(repoRoot, rel);
const reconstructed = resolve(tempProjectDir, rel);
/* istanbul ignore if -- @preserve: we just `cp`'d into this directory, so it always exists */
if (!(await pathExists(reconstructed))) {
return { drift: [], reordered: [] };
}
return diffDirectories(original, reconstructed);
});
for (const result of await Promise.all(diffTasks)) {
drift.push(...result.drift);
reordered.push(...result.reordered);
}
if (drift.length === 0) {
log(`Round-trip verified for ${decomposed.metadata.length} metadata type(s); no drift detected.`);
}
else {
log(`Round-trip drift detected in ${drift.length} file(s):`);
for (const entry of drift) {
log(` - ${entry.path}: ${entry.reason}`);
}
}
if (reordered.length > 0) {
// Informational only — semantic content matches, just sibling/attribute order changed.
// Salesforce treats metadata as order-agnostic, so this is safe to commit.
log(`Note: ${reordered.length} file(s) round-tripped semantically but with sibling/attribute reordering:`);
for (const path of reordered) {
log(` - ${path}`);
}
}
return { metadata: decomposed.metadata, drift, reordered };
}
finally {
await rm(tempProjectDir, { recursive: true, force: true });
}
}
async function pathExists(path) {
try {
await stat(path);
return true;
}
catch {
/* istanbul ignore next -- @preserve: package directories declared in sfdx-project.json always
exist on disk in the supported flow; this catch is defensive for partially-broken projects. */
return false;
}
}
//# sourceMappingURL=verifyMetadataTypes.js.map