UNPKG

sb-mig

Version:

CLI to rule the world. (and handle stuff related to Storyblok CMS)

392 lines (391 loc) 15.1 kB
import { existsSync, readdirSync } from "fs"; import path from "path"; import { getFileContentWithRequire } from "../../utils/files.js"; import Logger from "../../utils/logger.js"; export class MigrationValidationFailedError extends Error { migrationConfig; validatorId; validatorName; issueCount; issues; constructor({ migrationConfig, validatorId, validatorName, issueCount, issues, }) { super(`Validation failed for migration '${migrationConfig}' via '${validatorId}' (${issueCount} issue(s)).`); this.name = "MigrationValidationFailedError"; this.migrationConfig = migrationConfig; this.validatorId = validatorId; this.validatorName = validatorName; this.issueCount = issueCount; this.issues = issues; } } const maxFormatSamplesPerComponent = 3; const validationFileRegex = /\.validation\.(cjs|js|mjs)$/; const dedupe = (items) => Array.from(new Set(items)); const debugLog = (isDebug, message) => { if (isDebug) { Logger.warning(`[VALIDATION] ${message}`); } }; const getObservation = (observations, key) => { const existing = observations.get(key); if (existing) return existing; const created = { fields: new Set(), legacyItemBasis: [], legacyEmbedWidth: [], legacyEmbedHeight: [], }; observations.set(key, created); return created; }; const getKeySet = (map, key) => { const existing = map.get(key); if (existing) return existing; const created = new Set(); map.set(key, created); return created; }; const formatPath = (pathParts) => { return pathParts .map((segment, index) => { if (typeof segment === "number") return `[${segment}]`; if (index === 0) return segment; return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(segment) ? `.${segment}` : `["${segment}"]`; }) .join(""); }; const formatRelativePath = (pathParts) => formatPath(pathParts); const pathKey = (pathParts) => { return pathParts .map((segment) => typeof segment === "number" ? `n:${segment}` : `s:${segment}`) .join("|"); }; const isLegacySizeValue = (value) => { if (value === null) return false; if (typeof value === "string") return true; if (typeof value === "number") return true; if (typeof value === "boolean") return true; if (typeof value !== "object") return false; const maybeSize = value; return !(typeof maybeSize.value === "string" && typeof maybeSize.unit === "string"); }; const traverse = (value, visitor, pathParts = []) => { if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { const childPath = [...pathParts, i]; visitor(value[i], i, childPath); traverse(value[i], visitor, childPath); } return; } if (value !== null && typeof value === "object") { for (const key of Object.keys(value)) { const childPath = [...pathParts, key]; const childValue = value[key]; visitor(childValue, key, childPath); traverse(childValue, visitor, childPath); } } }; const isRuleSetConfig = (value) => { if (!value || typeof value !== "object") return false; const maybe = value; return (typeof maybe.ruleSetName === "string" && Boolean(maybe.rules) && typeof maybe.rules === "object" && typeof maybe.noIssuesMessage === "string"); }; const toPreparedValidator = (moduleValue, sourcePath) => { if (!moduleValue || typeof moduleValue !== "object") { return null; } const maybe = moduleValue; if (typeof maybe.id !== "string" || typeof maybe.name !== "string") { return null; } const ruleSet = isRuleSetConfig(maybe.ruleSet) ? maybe.ruleSet : undefined; const validateData = typeof maybe.validateData === "function" ? maybe.validateData : undefined; if (!ruleSet && !validateData) { return null; } return { id: maybe.id, name: maybe.name, ruleSet, validateData, sourcePath, }; }; const resolveValidatorPathCandidates = ({ migrationConfigName, migrationConfigPath, }) => { const directReplaced = migrationConfigPath.replace(/\.sb\.migration\.(cjs|js|mjs)$/, ".validation.$1"); const migrationDir = path.dirname(migrationConfigPath); const byNameInSameDir = [ path.join(migrationDir, `${migrationConfigName}.validation.cjs`), path.join(migrationDir, `${migrationConfigName}.validation.js`), path.join(migrationDir, `${migrationConfigName}.validation.mjs`), ]; const discoveredInDir = readdirSync(migrationDir) .filter((fileName) => validationFileRegex.test(fileName)) .map((fileName) => path.join(migrationDir, fileName)); return dedupe([ directReplaced, ...byNameInSameDir, ...discoveredInDir, ]).filter((candidatePath) => existsSync(candidatePath)); }; export const discoverMigrationValidatorForMigrationFile = ({ migrationConfigName, migrationConfigPath, }) => { const candidates = resolveValidatorPathCandidates({ migrationConfigName, migrationConfigPath, }); for (const candidatePath of candidates) { try { const loaded = getFileContentWithRequire({ file: candidatePath }); const prepared = toPreparedValidator(loaded, candidatePath); if (prepared) { return prepared; } } catch { // ignore candidate and continue } } return null; }; export const validateRuleSetData = ({ data, ruleSet, isDebug = false, }) => { const trackedFields = new Set(Object.values(ruleSet.rules).flatMap((rule) => [ ...(rule.forbiddenFields ?? []), ...(rule.requiredFields ?? []), ])); const hasItemBasisChecks = Object.values(ruleSet.rules).some((rule) => rule.checkItemBasis); const hasEmbedSizeChecks = Object.values(ruleSet.rules).some((rule) => rule.checkEmbedSizes); const targetComponents = new Set(Object.keys(ruleSet.rules)); const normalizedWrappers = ruleSet.wrapperNormalization?.wrapperToBase ?? {}; const trackedWrapperComponents = new Set(Object.keys(normalizedWrappers)); const componentByPathKey = new Map(); const pathByKey = new Map(); const uidByPathKey = new Map(); const observations = new Map(); const objectKeysByPathKey = new Map(); traverse(data, (value, key, pathParts) => { if (typeof key === "string") { const parentPath = pathParts.slice(0, -1); const parentKey = pathKey(parentPath); const keySet = getKeySet(objectKeysByPathKey, parentKey); keySet.add(key); pathByKey.set(parentKey, parentPath); } if (key === "component" && typeof value === "string") { const componentPath = pathParts.slice(0, -1); const keyStr = pathKey(componentPath); componentByPathKey.set(keyStr, value); pathByKey.set(keyStr, componentPath); if (targetComponents.has(value) || trackedWrapperComponents.has(value)) { debugLog(isDebug, `found target component ${value} at ${formatPath(componentPath) || "(root)"}`); } } if (key === "_uid" && typeof value === "string") { const componentPath = pathParts.slice(0, -1); const keyStr = pathKey(componentPath); uidByPathKey.set(keyStr, value); pathByKey.set(keyStr, componentPath); } const designIndex = pathParts.indexOf("design"); if (designIndex === -1) return; if (pathParts[designIndex + 1] !== "fields") return; const fieldName = pathParts[designIndex + 2]; if (typeof fieldName !== "string") return; const componentPath = pathParts.slice(0, designIndex); const keyStr = pathKey(componentPath); pathByKey.set(keyStr, componentPath); const observation = getObservation(observations, keyStr); if (pathParts.length === designIndex + 3) { observation.fields.add(fieldName); } if (trackedFields.has(fieldName) && pathParts.length === designIndex + 3) { debugLog(isDebug, `saw design.fields.${fieldName} at ${formatPath(pathParts.slice(0, designIndex + 3))}`); } if (pathParts[designIndex + 3] !== "values") return; if (pathParts.length !== designIndex + 5) return; const valuePath = formatRelativePath(pathParts.slice(designIndex + 3)); if (hasItemBasisChecks && fieldName === "item_basis" && isLegacySizeValue(value)) { if (observation.legacyItemBasis.length < maxFormatSamplesPerComponent) { observation.legacyItemBasis.push(valuePath); } } if (hasEmbedSizeChecks && fieldName === "embed_width" && isLegacySizeValue(value)) { if (observation.legacyEmbedWidth.length < maxFormatSamplesPerComponent) { observation.legacyEmbedWidth.push(valuePath); } } if (hasEmbedSizeChecks && fieldName === "embed_height" && isLegacySizeValue(value)) { if (observation.legacyEmbedHeight.length < maxFormatSamplesPerComponent) { observation.legacyEmbedHeight.push(valuePath); } } }); const issues = []; for (const [keyStr, component] of componentByPathKey.entries()) { const componentPath = pathByKey.get(keyStr) ?? []; const componentPathStr = formatPath(componentPath) || "(root)"; const uid = uidByPathKey.get(keyStr) ?? null; const topLevelKeys = objectKeysByPathKey.get(keyStr) ?? new Set(); const observation = observations.get(keyStr) ?? { fields: new Set(), legacyItemBasis: [], legacyEmbedWidth: [], legacyEmbedHeight: [], }; if (!ruleSet.rules[component] && !normalizedWrappers[component]) { continue; } const rule = ruleSet.rules[component]; if (rule) { for (const field of rule.forbiddenFields ?? []) { if (observation.fields.has(field)) { issues.push({ componentPath: componentPathStr, component, uid, message: `Forbidden field still present: design.fields.${field}`, }); } } for (const field of rule.requiredFields ?? []) { if (!observation.fields.has(field)) { issues.push({ componentPath: componentPathStr, component, uid, message: `Required field missing: design.fields.${field}`, }); } } for (const topLevelKey of rule.forbiddenTopLevelKeys ?? []) { if (topLevelKeys.has(topLevelKey)) { issues.push({ componentPath: componentPathStr, component, uid, message: `Forbidden top-level key still present: ${topLevelKey}`, }); } } for (const topLevelKey of rule.requiredTopLevelKeys ?? []) { if (!topLevelKeys.has(topLevelKey)) { issues.push({ componentPath: componentPathStr, component, uid, message: `Required top-level key missing: ${topLevelKey}`, }); } } if (rule.checkItemBasis && observation.legacyItemBasis.length > 0) { issues.push({ componentPath: componentPathStr, component, uid, message: `item_basis values still in V3 format (${observation.legacyItemBasis.join(", ")})`, }); } if (rule.checkEmbedSizes && observation.legacyEmbedWidth.length > 0) { issues.push({ componentPath: componentPathStr, component, uid, message: `embed_width values still in V3 format (${observation.legacyEmbedWidth.join(", ")})`, }); } if (rule.checkEmbedSizes && observation.legacyEmbedHeight.length > 0) { issues.push({ componentPath: componentPathStr, component, uid, message: `embed_height values still in V3 format (${observation.legacyEmbedHeight.join(", ")})`, }); } } const expectedBaseComponent = normalizedWrappers[component]; if (expectedBaseComponent) { issues.push({ componentPath: componentPathStr, component, uid, message: `Wrapper component still present: component=${component} (expected ${expectedBaseComponent})`, }); } } return { ok: issues.length === 0, issueCount: issues.length, issues, }; }; const isMigrationValidationReport = (value) => { if (!value || typeof value !== "object") { return false; } const maybe = value; return (typeof maybe.ok === "boolean" && typeof maybe.issueCount === "number" && Array.isArray(maybe.issues)); }; export const runPreparedMigrationValidator = ({ validator, data, isDebug = false, }) => { if (validator.validateData) { const result = validator.validateData({ data, isDebug, }); if (result && typeof result.then === "function") { throw new Error(`Validator '${validator.id}' returned a Promise from validateData. sb-mig requires synchronous validateData for in-memory pipeline execution.`); } if (!isMigrationValidationReport(result)) { throw new Error(`Validator '${validator.id}' returned invalid report shape. Expected { ok, issueCount, issues[] }`); } return result; } if (validator.ruleSet) { return validateRuleSetData({ data, ruleSet: validator.ruleSet, isDebug, }); } throw new Error(`Validator '${validator.id}' has neither ruleSet nor validateData.`); };