sb-mig
Version:
CLI to rule the world. (and handle stuff related to Storyblok CMS)
392 lines (391 loc) • 15.1 kB
JavaScript
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.`);
};