UNPKG

sf-decomposer

Version:

Split large Salesforce metadata files into version-control-friendly pieces and rebuild deployment-ready files.

372 lines 17.7 kB
'use strict'; import { access, readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { getRepoRoot } from '../service/core/getRepoRoot.js'; import { DECOMPOSED_FILE_TYPES, DECOMPOSED_STRATEGIES, HOOK_CONFIG_JSON } from './constants.js'; /** * Resolve the absolute path of the default `.sfdecomposer.config.json`, located in the * repo root (the nearest ancestor directory that contains `sfdx-project.json`). Throws * a clear error when the repo root cannot be located or the config file does not exist. */ export async function resolveDefaultConfigPath() { const { repoRoot } = await getRepoRoot(); /* istanbul ignore next -- getRepoRoot throws when no sfdx-project.json ancestor exists, so repoRoot is always defined here. Stryker disable next-line all -- unreachable because getRepoRoot() throws before returning a falsy repoRoot. */ if (!repoRoot) { throw new Error(`Cannot locate ${HOOK_CONFIG_JSON}: repo root not found.`); } const configPath = resolve(repoRoot, HOOK_CONFIG_JSON); try { await access(configPath); } catch { throw new Error(`--config was provided but ${HOOK_CONFIG_JSON} was not found at ${configPath}. ` + 'Create the file in the repo root or omit --config.'); } return configPath; } // Run-scope keys that must never appear inside an override entry. Any other key is treated // as either a recognized override field (validated below) or as a forward-compatible unknown // key (silently ignored). const FORBIDDEN_OVERRIDE_KEYS = new Set(['manifest', 'metadataSuffixes', 'ignorePackageDirectories']); const SPLIT_TAGS_MODES = new Set(['split', 'group']); /** * Load and validate the `overrides` array from a `.sfdecomposer.config.json` file. * Returns an empty array if the file is missing, unreadable, or contains no overrides. */ export async function loadOverridesFromConfig(configPath) { let raw; try { // Stryker disable next-line StringLiteral: JSON.parse(Buffer) defaults to UTF-8 decoding raw = await readFile(configPath, 'utf-8'); } catch { return []; } let parsed; try { parsed = JSON.parse(raw); } catch (err) { /* istanbul ignore next -- @preserve: JSON.parse always throws SyntaxError instances. */ const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to parse ${configPath}: ${message}`); } const overrides = parsed.overrides; if (!overrides) return []; if (!Array.isArray(overrides)) { throw new Error(`"overrides" in ${configPath} must be an array.`); } validateOverrides(overrides); return overrides; } /** * Validate that the overrides array is well-formed. Throws on any structural problem. * Unknown override keys are tolerated (ignored), but forbidden run-scope keys throw. */ export function validateOverrides(overrides) { const seenTypes = new Set(); const seenComponents = new Set(); for (let i = 0; i < overrides.length; i++) { validateOverride(overrides[i], i, seenTypes, seenComponents); } } function validateOverride(override, i, seenTypes, seenComponents) { if (!override || typeof override !== 'object') { throw new Error(`Override at index ${i} must be an object.`); } if (override.metadataTypes !== undefined && !Array.isArray(override.metadataTypes)) { throw new Error(`Override at index ${i} has a non-array "metadataTypes".`); } if (override.components !== undefined && !Array.isArray(override.components)) { throw new Error(`Override at index ${i} has a non-array "components".`); } const hasMetadataTypes = Array.isArray(override.metadataTypes) && override.metadataTypes.length > 0; const hasComponents = Array.isArray(override.components) && override.components.length > 0; if (!hasMetadataTypes && !hasComponents) { throw new Error(`Override at index ${i} must include a non-empty "metadataTypes" or "components" array.`); } validateForbiddenKeys(override, i); validateOverrideValues(override, i); if (hasMetadataTypes) { validateMetadataTypeEntries(override.metadataTypes, i, seenTypes); } if (hasComponents) { validateComponentEntries(override.components, i, seenComponents); } } function validateForbiddenKeys(override, i) { for (const key of Object.keys(override)) { if (FORBIDDEN_OVERRIDE_KEYS.has(key)) { throw new Error(`Override at index ${i} contains "${key}", which is a run-scope option and cannot be set per metadata type.`); } } } function validateOverrideValues(override, i) { if (override.decomposedFormat !== undefined && !DECOMPOSED_FILE_TYPES.includes(override.decomposedFormat)) { throw new Error(`Override at index ${i} has invalid "decomposedFormat": "${override.decomposedFormat}". ` + `Allowed values: ${DECOMPOSED_FILE_TYPES.join(', ')}.`); } if (override.strategy !== undefined && !DECOMPOSED_STRATEGIES.includes(override.strategy)) { throw new Error(`Override at index ${i} has invalid "strategy": "${override.strategy}". ` + `Allowed values: ${DECOMPOSED_STRATEGIES.join(', ')}.`); } if (override.splitTags !== undefined) { validateSplitTagsSpec(override.splitTags, i); } if (override.multiLevel !== undefined) { validateMultiLevelSpec(override.multiLevel, i); } if (override.uniqueIdElements !== undefined) { validateUniqueIdElementsSpec(override.uniqueIdElements, i); } } /** * Validate the comma-separated `splitTags` spec at config-load time. Each rule must be of the * form `<tag>:<mode>:<field>` or `<tag>:<path>:<mode>:<field>`, with `mode` ∈ {split, group}. * Tags must be unique within the spec. Deeper validation (e.g. unknown XML tag names) is left * to the underlying disassembler crate at runtime. */ export function validateSplitTagsSpec(spec, i) { if (typeof spec !== 'string' || spec.trim() === '') { throw new Error(`Override at index ${i} has an empty "splitTags" string.`); } const rules = spec.split(',').map((rule) => rule.trim()); const seenTags = new Set(); for (const rule of rules) { if (rule === '') { throw new Error(`Override at index ${i} "splitTags" contains an empty rule.`); } const parts = rule.split(':').map((part) => part.trim()); let tag; let mode; let field; if (parts.length === 3) { [tag, mode, field] = parts; } else if (parts.length === 4) { // path defaults to tag in the 3-part form; we don't need to retain it for validation, // we just check the parts are non-empty and the mode/field are well-formed. [tag, , mode, field] = parts; } else { throw new Error(`Override at index ${i} "splitTags" rule "${rule}" must have 3 or 4 colon-separated parts ` + '("tag:mode:field" or "tag:path:mode:field").'); } if (!tag || !mode || !field || (parts.length === 4 && !parts[1])) { throw new Error(`Override at index ${i} "splitTags" rule "${rule}" has empty parts.`); } if (!SPLIT_TAGS_MODES.has(mode)) { throw new Error(`Override at index ${i} "splitTags" rule "${rule}" has invalid mode "${mode}". ` + `Allowed values: ${Array.from(SPLIT_TAGS_MODES).join(', ')}.`); } if (seenTags.has(tag)) { throw new Error(`Override at index ${i} "splitTags" contains duplicate tag "${tag}". Each tag may appear at most once.`); } seenTags.add(tag); } } /** * Validate the `multiLevel` spec at config-load time. Each rule must be of the form * `<file_pattern>:<root_to_strip>:<unique_id_elements>`, where `<unique_id_elements>` is * itself a comma-separated list. Three input shapes are supported: a single rule string * (legacy); a string[] of rules (preferred when targeting multiple nested sections); or a * single `;`-separated string of rules (compact form, also accepted by the crate). * * Rules are validated individually and the (file_pattern, root_to_strip) pair must be * unique across rules in the same scope. Deeper checks (whether a file pattern matches * anything, whether the unique-id elements actually exist on the inner XML) are left to * the runtime crate. */ export function validateMultiLevelSpec(spec, i) { const rules = normalizeMultiLevelRules(spec, i); const seenPairs = new Set(); for (const rule of rules) { const { filePattern, rootToStrip } = validateSingleMultiLevelRule(rule, i); const pairKey = `${filePattern}:${rootToStrip}`; if (seenPairs.has(pairKey)) { throw new Error(`Override at index ${i} "multiLevel" has duplicate (file_pattern, root_to_strip) pair "${pairKey}". ` + 'Each pair may appear at most once per scope.'); } seenPairs.add(pairKey); } } function normalizeMultiLevelRules(spec, i) { if (Array.isArray(spec)) { if (spec.length === 0) { throw new Error(`Override at index ${i} has an empty "multiLevel" array.`); } for (const entry of spec) { if (typeof entry !== 'string' || entry.trim() === '') { throw new Error(`Override at index ${i} "multiLevel" array contains an empty or non-string entry.`); } } return spec.map((rule) => rule.trim()); } if (typeof spec !== 'string' || spec.trim() === '') { throw new Error(`Override at index ${i} has an empty "multiLevel" string.`); } // A single `;`-separated string is treated as multiple rules to mirror the crate's parser. return spec .split(';') .map((rule) => rule.trim()) .filter((rule) => rule.length > 0); } function validateSingleMultiLevelRule(rule, i) { const parts = rule.split(':').map((part) => part.trim()); if (parts.length !== 3) { throw new Error(`Override at index ${i} "multiLevel" rule "${rule}" must have exactly 3 colon-separated parts ` + '("<file_pattern>:<root_to_strip>:<unique_id_elements>").'); } const [filePattern, rootToStrip, uniqueIdElements] = parts; if (!filePattern || !rootToStrip || !uniqueIdElements) { throw new Error(`Override at index ${i} "multiLevel" rule "${rule}" has empty parts.`); } const ids = uniqueIdElements.split(',').map((id) => id.trim()); const seenIds = new Set(); for (const id of ids) { if (id === '') { throw new Error(`Override at index ${i} "multiLevel" rule "${rule}" unique-id list contains an empty entry.`); } if (seenIds.has(id)) { throw new Error(`Override at index ${i} "multiLevel" rule "${rule}" unique-id list has duplicate entry "${id}".`); } seenIds.add(id); } return { filePattern, rootToStrip }; } /** * Validate the `uniqueIdElements` spec at config-load time. Must be a non-empty * comma-separated list of element names. Each entry may use `+` to join fields * into a compound key (e.g. `"actionName+pageOrSobjectType+formFactor"`). Deeper * validation (whether the named elements actually exist in the XML) is left to the * runtime crate. */ export function validateUniqueIdElementsSpec(spec, i) { if (typeof spec !== 'string' || spec.trim() === '') { throw new Error(`Override at index ${i} has an empty "uniqueIdElements" string.`); } const entries = spec.split(',').map((e) => e.trim()); for (const entry of entries) { if (entry === '') { throw new Error(`Override at index ${i} "uniqueIdElements" contains an empty entry.`); } } } function validateMetadataTypeEntries(metadataTypes, i, seenTypes) { for (const metadataType of metadataTypes) { if (typeof metadataType !== 'string' || metadataType.trim() === '') { throw new Error(`Override at index ${i} contains an empty or non-string metadata type.`); } if (seenTypes.has(metadataType)) { throw new Error(`Metadata type "${metadataType}" appears in more than one override. Each type may appear at most once.`); } seenTypes.add(metadataType); } } function validateComponentEntries(components, i, seenComponents) { for (const component of components) { if (typeof component !== 'string' || component.trim() === '') { throw new Error(`Override at index ${i} contains an empty or non-string component.`); } if (!parseComponentKey(component)) { throw new Error(`Override at index ${i} has invalid component key "${component}". ` + 'Expected format: "<metadataSuffix>:<fullName>" (e.g. "permissionset:HR_Admin").'); } if (seenComponents.has(component)) { throw new Error(`Component "${component}" appears in more than one override. Each component may appear at most once.`); } seenComponents.add(component); } } /** * Parse a component override key of the form `<metadataSuffix>:<fullName>`. Returns `undefined` * when the key is malformed. Only the first colon is treated as the delimiter so fullNames that * contain `/` (folder-typed metadata such as `report:MyFolder/MyReport`) are preserved verbatim. */ export function parseComponentKey(key) { const colonIdx = key.indexOf(':'); // Stryker disable next-line EqualityOperator, ConditionalExpression, ArithmeticOperator if (colonIdx <= 0 || colonIdx === key.length - 1) return undefined; const metadataType = key.slice(0, colonIdx).trim(); const fullName = key.slice(colonIdx + 1).trim(); if (!metadataType || !fullName) return undefined; return { metadataType, fullName }; } /** * Find the override (if any) that targets a specific metadata suffix. */ export function getOverrideForType(metadataType, overrides) { // Stryker disable next-line ConditionalExpression if (!overrides || overrides.length === 0) return undefined; return overrides.find((override) => override.metadataTypes?.includes(metadataType)); } /** * Find the override (if any) that targets a specific component, identified by its metadata * suffix and SDR fullName. */ export function getOverrideForComponent(metadataType, fullName, overrides) { // Stryker disable next-line ConditionalExpression if (!overrides || overrides.length === 0) return undefined; const key = `${metadataType}:${fullName}`; return overrides.find((override) => override.components?.includes(key)); } /** * Returns true when at least one override targets a component of the given metadata type. * Used by the decompose handler to decide whether per-component enumeration is required. */ export function hasComponentOverridesForType(metadataType, overrides) { // Stryker disable next-line ConditionalExpression if (!overrides || overrides.length === 0) return false; const prefix = `${metadataType}:`; return overrides.some((override) => override.components?.some((component) => component.startsWith(prefix))); } /** * Resolve the effective decompose options for a single metadata type. The base values are * the run-wide options (CLI flags or defaults); per-type override values, when present, win. */ export function resolveDecomposeOptionsForType(metadataType, base, overrides) { const override = getOverrideForType(metadataType, overrides); if (!override) return base; return { format: override.decomposedFormat ?? base.format, strategy: override.strategy ?? base.strategy, decomposeNestedPerms: override.decomposeNestedPermissions ?? base.decomposeNestedPerms, prepurge: override.prePurge ?? base.prepurge, postpurge: override.postPurge ?? base.postpurge, splitTags: override.splitTags ?? base.splitTags, multiLevel: override.multiLevel ?? base.multiLevel, uniqueIdElements: override.uniqueIdElements ?? base.uniqueIdElements, }; } /** * Resolve the effective decompose options for a single component. Precedence (highest first): * component-scoped override fields (via `components`), then type-scoped resolved values * (already applied by `resolveDecomposeOptionsForType`), then run-wide base values (passed * through as the typeResolved fallback). * * Hard plugin rules (e.g. labels and loyaltyProgramSetup forced to `unique-id`) are applied * separately by callers, not here, so this function stays pure. */ export function resolveDecomposeOptionsForComponent(metadataType, fullName, typeResolved, overrides) { const componentOverride = getOverrideForComponent(metadataType, fullName, overrides); if (!componentOverride) return typeResolved; return { format: componentOverride.decomposedFormat ?? typeResolved.format, strategy: componentOverride.strategy ?? typeResolved.strategy, decomposeNestedPerms: componentOverride.decomposeNestedPermissions ?? typeResolved.decomposeNestedPerms, prepurge: componentOverride.prePurge ?? typeResolved.prepurge, postpurge: componentOverride.postPurge ?? typeResolved.postpurge, splitTags: componentOverride.splitTags ?? typeResolved.splitTags, multiLevel: componentOverride.multiLevel ?? typeResolved.multiLevel, uniqueIdElements: componentOverride.uniqueIdElements ?? typeResolved.uniqueIdElements, }; } //# sourceMappingURL=configOverrides.js.map