UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

644 lines (643 loc) 30.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DataFormIssueType = void 0; const Utilities_1 = __importDefault(require("../core/Utilities")); const DataFormUtilities_1 = __importDefault(require("./DataFormUtilities")); const FieldUtilities_1 = __importDefault(require("./FieldUtilities")); const ICondition_1 = require("./ICondition"); const IField_1 = require("./IField"); /** * Cleans a field ID by removing the embedded enum syntax that appears in some form files. * For example: 'render_distance_type"<"fixed", "render"' becomes 'render_distance_type' * This syntax is used in forms to specify enum choices but should not appear in property names. */ function cleanFieldId(fieldId) { const quoteIndex = fieldId.indexOf('"<"'); if (quoteIndex !== -1) { return fieldId.substring(0, quoteIndex); } return fieldId; } /** * Maximum recursion depth for validation to prevent exponential time complexity * when validating deeply nested structures. Beyond this depth, validation will * skip recursive subForm validation. */ const MAX_VALIDATION_DEPTH = 10; var DataFormIssueType; (function (DataFormIssueType) { DataFormIssueType[DataFormIssueType["unexpectedStringUsedWhenObjectExpected"] = 101] = "unexpectedStringUsedWhenObjectExpected"; DataFormIssueType[DataFormIssueType["unexpectedBooleanUsedWhenObjectExpected"] = 102] = "unexpectedBooleanUsedWhenObjectExpected"; DataFormIssueType[DataFormIssueType["unexpectedNumberUsedWhenObjectExpected"] = 103] = "unexpectedNumberUsedWhenObjectExpected"; DataFormIssueType[DataFormIssueType["dataTypeMismatch"] = 110] = "dataTypeMismatch"; DataFormIssueType[DataFormIssueType["valueBelowMinimum"] = 111] = "valueBelowMinimum"; DataFormIssueType[DataFormIssueType["valueAboveMaximum"] = 112] = "valueAboveMaximum"; DataFormIssueType[DataFormIssueType["stringTooShort"] = 113] = "stringTooShort"; DataFormIssueType[DataFormIssueType["stringTooLong"] = 114] = "stringTooLong"; DataFormIssueType[DataFormIssueType["valueNotInChoices"] = 115] = "valueNotInChoices"; DataFormIssueType[DataFormIssueType["patternMismatch"] = 116] = "patternMismatch"; DataFormIssueType[DataFormIssueType["arrayLengthMismatch"] = 117] = "arrayLengthMismatch"; DataFormIssueType[DataFormIssueType["pointSizeMismatch"] = 118] = "pointSizeMismatch"; DataFormIssueType[DataFormIssueType["keyNotAllowed"] = 119] = "keyNotAllowed"; DataFormIssueType[DataFormIssueType["unexpectedProperty"] = 120] = "unexpectedProperty"; DataFormIssueType[DataFormIssueType["missingRequiredField"] = 121] = "missingRequiredField"; })(DataFormIssueType || (exports.DataFormIssueType = DataFormIssueType = {})); class DataFormValidator { static async validate(data, form, issues, path, context) { // Initialize context on first call (top-level validation) if (!context) { context = { depth: 0, subFormCache: new Map(), }; } // Check recursion depth limit - skip deep validation to prevent exponential complexity if (context.depth >= MAX_VALIDATION_DEPTH) { return issues || []; } if (path === undefined) { path = ""; } else { path = path + "."; } if (!issues) { issues = []; } if (typeof data === "string" || typeof data === "number" || typeof data === "boolean") { const scalarField = DataFormUtilities_1.default.getScalarField(form); if (!scalarField) { if (typeof data === "string") { issues.push(DataFormValidator.getValidationIssue(DataFormIssueType.unexpectedStringUsedWhenObjectExpected)); } else if (typeof data === "number") { issues.push(DataFormValidator.getValidationIssue(DataFormIssueType.unexpectedNumberUsedWhenObjectExpected)); } else if (typeof data === "boolean") { issues.push(DataFormValidator.getValidationIssue(DataFormIssueType.unexpectedBooleanUsedWhenObjectExpected)); } return issues; } await this.validateField(data, scalarField, issues, path + "<default value>", context); } const fields = form.fields; // Guard against forms with undefined or null fields array if (fields && Array.isArray(fields)) { for (const field of fields) { if (field.id) { // Clean the field ID to remove embedded enum syntax like 'render_distance_type"<"fixed", "render"' const cleanedFieldId = cleanFieldId(field.id); // Check if the cleaned field ID is a regex pattern (contains metacharacters like [, ], +, *, etc.) if (this.isPatternFieldId(cleanedFieldId) && typeof data === "object" && data !== null && !Array.isArray(data)) { // For pattern-based field IDs, find all matching keys in the data await this.validatePatternField(data, field, issues, path, context, cleanedFieldId); } else { // Use the cleaned field ID for data lookup const fieldData = data[cleanedFieldId]; await this.validateField(fieldData, field, issues, path + cleanedFieldId, context); } } } } // Check for unexpected properties not defined in the form if (typeof data === "object" && data !== null && !Array.isArray(data)) { await this.validateUnexpectedProperties(data, form, issues, path); } return issues; } /** * Validates that an object doesn't have properties not defined in the form. * This check only runs when form.strictAdditionalProperties is true, as most Minecraft * content formats allow additional properties not explicitly defined in the schema. */ static async validateUnexpectedProperties(data, form, issues, path) { // Skip if the form allows additional properties (default behavior for Minecraft content) if (!form.strictAdditionalProperties) { return; } // Skip if the form explicitly allows custom fields if (form.customField) { return; } // Skip if form.fields is not available if (!form.fields || !Array.isArray(form.fields)) { return; } const definedFieldIds = new Set(); for (const field of form.fields) { if (field.id) { definedFieldIds.add(field.id); } if (field.altId) { definedFieldIds.add(field.altId); } } const dataKeys = Object.keys(data); for (const key of dataKeys) { if (!definedFieldIds.has(key)) { issues.push({ message: `At ${path}${key}, unexpected property '${key}' is not defined in the schema.`, type: DataFormIssueType.unexpectedProperty, }); } } } static async validateField(data, field, issues, path, context) { const allFields = DataFormUtilities_1.default.getFieldAndAlternates(field); let dataMismatchErrors = ""; let hasOneMatch = false; let isRequired = false; let matchingField; for (const altField of allFields) { if (data !== undefined && data !== null) { const mismatchError = DataFormValidator.getDataMismatchError(data, altField.dataType); if (!mismatchError) { hasOneMatch = true; matchingField = altField; } else { dataMismatchErrors += mismatchError; } } if (altField.isRequired) { isRequired = true; } } if (data === undefined || data === null) { if (isRequired) { // Use cleaned field ID in error message const cleanedId = field.id ? cleanFieldId(field.id) : field.id; issues.push({ message: "At " + path + ", data is missing required field '" + cleanedId + "'.", type: DataFormIssueType.missingRequiredField, }); } return; } if (!hasOneMatch) { if (allFields.length > 1) { issues.push({ message: "At " + path + ", data does not match any one of the expected types: " + dataMismatchErrors, type: DataFormIssueType.dataTypeMismatch, }); } else { issues.push({ message: "At " + path + ", data does not match expected type. " + dataMismatchErrors, type: DataFormIssueType.dataTypeMismatch, }); } return; // Don't continue validation if type doesn't match } // Use the matching field for remaining validations const activeField = matchingField || field; // Validate numeric ranges if (typeof data === "number") { this.validateNumericRange(data, activeField, issues, path); } // Validate string length if (typeof data === "string") { this.validateStringLength(data, activeField, issues, path); } // Validate choices/enum values this.validateChoices(data, activeField, issues, path); // Validate patterns from validity conditions this.validatePatterns(data, activeField, issues, path); // Validate array lengths if (Array.isArray(data)) { this.validateArrayLength(data, activeField, issues, path); } // Validate point sizes (point2, point3, etc.) this.validatePointSize(data, activeField, issues, path); // Validate keyed collection keys if (typeof data === "object" && data !== null && !Array.isArray(data)) { await this.validateKeyedCollection(data, activeField, issues, path, context); } if (typeof data === "object" && data === null && field.id && Utilities_1.default.isUsableAsObjectKey(field.id)) { const fieldData = data[field.id]; const subForm = await this.getCachedSubForm(field, context); if (subForm && fieldData !== undefined && fieldData !== null) { await this.validate(fieldData, subForm, issues, path, { depth: context.depth + 1, subFormCache: context.subFormCache, }); } } } static getDataMismatchError(data, type) { if (Array.isArray(data)) { // Determine array element type by checking first element const elementType = data.length > 0 ? typeof data[0] : undefined; if (elementType === "string") { if (type === IField_1.FieldDataType.stringArray || type === IField_1.FieldDataType.checkboxListAsStringArray || type === IField_1.FieldDataType.longFormStringArray) { return undefined; } return ("Data '" + data + "' is of type string array, which does not match type '" + DataFormUtilities_1.default.getFieldTypeDescription(type) + "'."); } else if (elementType === "number") { // Number arrays can match numberArray, floatRange, intRange, percentRange, point2, point3, intPoint3, version if (type === IField_1.FieldDataType.numberArray || type === IField_1.FieldDataType.floatRange || type === IField_1.FieldDataType.intRange || type === IField_1.FieldDataType.percentRange || type === IField_1.FieldDataType.point2 || type === IField_1.FieldDataType.point3 || type === IField_1.FieldDataType.intPoint3 || type === IField_1.FieldDataType.version || type === IField_1.FieldDataType.location || type === IField_1.FieldDataType.locationOffset) { return undefined; } return ("Data '" + data + "' is of type number array, which does not match type '" + DataFormUtilities_1.default.getFieldTypeDescription(type) + "'."); } else if (elementType === "object") { // Array of objects - valid for objectArray, minecraftEventTriggerArray, etc. if (type === IField_1.FieldDataType.objectArray || type === IField_1.FieldDataType.minecraftEventTriggerArray || type === IField_1.FieldDataType.arrayOfKeyedStringCollection) { return undefined; } return ("Data is of type object array, which does not match type '" + DataFormUtilities_1.default.getFieldTypeDescription(type) + "'."); } else if (Array.isArray(data[0])) { // Two-dimensional array if (type === IField_1.FieldDataType.twoDStringArray) { return undefined; } return ("Data is of type 2D array, which does not match type '" + DataFormUtilities_1.default.getFieldTypeDescription(type) + "'."); } // Empty array - allow it for any array type if (data.length === 0) { return undefined; } } else if (typeof data === "string") { // Strings can match many types including enums, molang expressions, UUIDs, etc. if (type === IField_1.FieldDataType.localizableString || type === IField_1.FieldDataType.longFormString || type === IField_1.FieldDataType.version || type === IField_1.FieldDataType.stringLookup || type === IField_1.FieldDataType.string || type === IField_1.FieldDataType.stringEnum || type === IField_1.FieldDataType.molang || type === IField_1.FieldDataType.uuid || type === IField_1.FieldDataType.minecraftEventReference) { return undefined; } return ("Data '" + data + "' is of type string, which does not match type '" + DataFormUtilities_1.default.getFieldTypeDescription(type) + "'."); } else if (typeof data === "number") { // A single number can match many types including range types (collapsed ranges) // According to IField.ts docs, intRange/floatRange/percentRange can be a single number if (type === IField_1.FieldDataType.float || type === IField_1.FieldDataType.intBoolean || type === IField_1.FieldDataType.int || type === IField_1.FieldDataType.intEnum || type === IField_1.FieldDataType.intValueLookup || type === IField_1.FieldDataType.number || type === IField_1.FieldDataType.long || type === IField_1.FieldDataType.intRange || type === IField_1.FieldDataType.floatRange || type === IField_1.FieldDataType.percentRange) { return undefined; } return ("Data '" + data + "' is of type number, which does not match type '" + DataFormUtilities_1.default.getFieldTypeDescription(type) + "'."); } else if (typeof data === "boolean") { if (type === IField_1.FieldDataType.boolean) { return undefined; } return ("Data '" + data + "' is of type boolean, which does not match type '" + DataFormUtilities_1.default.getFieldTypeDescription(type) + "'."); } else if (typeof data === "object") { // Objects can match many types - keyed collections, range types (with min/max), filters, event triggers, etc. if (type === IField_1.FieldDataType.object || type === IField_1.FieldDataType.keyedBooleanCollection || type === IField_1.FieldDataType.keyedKeyedStringArrayCollection || type === IField_1.FieldDataType.keyedNumberArrayCollection || type === IField_1.FieldDataType.keyedNumberCollection || type === IField_1.FieldDataType.keyedObjectCollection || type === IField_1.FieldDataType.keyedStringArrayCollection || type === IField_1.FieldDataType.keyedStringCollection || // Range types can also be objects with min/max properties type === IField_1.FieldDataType.intRange || type === IField_1.FieldDataType.floatRange || type === IField_1.FieldDataType.percentRange || // Minecraft-specific object types type === IField_1.FieldDataType.minecraftFilter || type === IField_1.FieldDataType.minecraftEventTrigger || type === IField_1.FieldDataType.minecraftEventReference || // Point types can be objects with x/y/z properties type === IField_1.FieldDataType.point2 || type === IField_1.FieldDataType.point3 || type === IField_1.FieldDataType.intPoint3 || type === IField_1.FieldDataType.location || type === IField_1.FieldDataType.locationOffset) { return undefined; } let dataStr = JSON.stringify(data); if (dataStr.length > 60) { dataStr = dataStr.substring(0, 60) + "..."; } return ("Data '" + dataStr + "' is an object, which does not match type '" + DataFormUtilities_1.default.getFieldTypeDescription(type) + "'."); } return undefined; } /** * Validates that a numeric value is within the allowed range. */ static validateNumericRange(data, field, issues, path) { if (field.minValue !== undefined && data < field.minValue) { issues.push({ message: `At ${path}, value ${data} is below minimum value ${field.minValue}.`, type: DataFormIssueType.valueBelowMinimum, }); } if (field.maxValue !== undefined && data > field.maxValue) { issues.push({ message: `At ${path}, value ${data} is above maximum value ${field.maxValue}.`, type: DataFormIssueType.valueAboveMaximum, }); } } /** * Validates that a string value is within the allowed length. */ static validateStringLength(data, field, issues, path) { if (field.minLength !== undefined && data.length < field.minLength) { issues.push({ message: `At ${path}, string length ${data.length} is below minimum length ${field.minLength}.`, type: DataFormIssueType.stringTooShort, }); } if (field.maxLength !== undefined && data.length > field.maxLength) { issues.push({ message: `At ${path}, string length ${data.length} is above maximum length ${field.maxLength}.`, type: DataFormIssueType.stringTooLong, }); } } /** * Validates that a value matches one of the allowed choices. */ static validateChoices(data, field, issues, path) { // If no choices defined or mustMatchChoices is explicitly false, skip if (!field.choices || field.choices.length === 0) { return; } // If mustMatchChoices is explicitly false, skip validation if (field.mustMatchChoices === false) { return; } // For lookup fields without mustMatchChoices explicitly set, skip by default // since lookups may have dynamic values if (field.mustMatchChoices === undefined && (field.dataType === IField_1.FieldDataType.stringLookup || field.dataType === IField_1.FieldDataType.intValueLookup)) { return; } const dataValue = typeof data === "string" || typeof data === "number" ? data : undefined; if (dataValue === undefined) { return; } const validChoiceIds = field.choices.map((choice) => choice.id); const valueStr = String(dataValue); if (!validChoiceIds.includes(valueStr)) { const choiceList = validChoiceIds.slice(0, 5).join(", ") + (validChoiceIds.length > 5 ? ", ..." : ""); issues.push({ message: `At ${path}, value '${valueStr}' is not one of the allowed choices: ${choiceList}.`, type: DataFormIssueType.valueNotInChoices, }); } } /** * Validates that a value matches patterns defined in field.validity conditions. */ static validatePatterns(data, field, issues, path) { if (!field.validity || field.validity.length === 0) { return; } if (typeof data !== "string") { return; } for (const condition of field.validity) { if (condition.comparison === ICondition_1.ComparisonType.matchesPattern && condition.value !== undefined) { try { const pattern = new RegExp(String(condition.value)); if (!pattern.test(data)) { issues.push({ message: `At ${path}, value '${data}' does not match required pattern '${condition.value}'.`, type: DataFormIssueType.patternMismatch, }); } } catch (e) { // Invalid regex pattern - skip validation but don't crash } } } } /** * Validates that an array has the correct length if fixedLength is specified. */ static validateArrayLength(data, field, issues, path) { if (field.fixedLength !== undefined && data.length !== field.fixedLength) { issues.push({ message: `At ${path}, array has ${data.length} elements but expected exactly ${field.fixedLength}.`, type: DataFormIssueType.arrayLengthMismatch, }); } } /** * Validates that point arrays (point2, point3, etc.) have the correct number of elements. */ static validatePointSize(data, field, issues, path) { if (!Array.isArray(data)) { return; } let expectedSize; switch (field.dataType) { case IField_1.FieldDataType.point2: expectedSize = 2; break; case IField_1.FieldDataType.point3: expectedSize = 3; break; case IField_1.FieldDataType.floatRange: expectedSize = 2; break; case IField_1.FieldDataType.intRange: expectedSize = 2; break; } if (expectedSize !== undefined && data.length !== expectedSize) { const typeName = DataFormUtilities_1.default.getFieldTypeDescription(field.dataType); issues.push({ message: `At ${path}, ${typeName} has ${data.length} elements but expected exactly ${expectedSize}.`, type: DataFormIssueType.pointSizeMismatch, }); } } /** * Gets a subForm for a field, using the validation context cache to avoid repeated lookups. * This significantly improves performance when validating deeply nested structures. */ static async getCachedSubForm(field, context) { // If there's an inline subForm, return it directly (no caching needed) if (field.subForm) { return field.subForm; } // If there's no subFormId, nothing to look up if (!field.subFormId) { return null; } const cacheKey = field.subFormId; if (context.subFormCache.has(cacheKey)) { return context.subFormCache.get(cacheKey) || null; } const subForm = await FieldUtilities_1.default.getSubForm(field); context.subFormCache.set(cacheKey, subForm || null); return subForm || null; } /** * Validates that keys in a keyed collection are allowed if allowedKeys is specified. */ static async validateKeyedCollection(data, field, issues, path, context) { const isKeyedCollection = field.dataType === IField_1.FieldDataType.keyedObjectCollection || field.dataType === IField_1.FieldDataType.keyedStringCollection || field.dataType === IField_1.FieldDataType.keyedNumberCollection || field.dataType === IField_1.FieldDataType.keyedBooleanCollection || field.dataType === IField_1.FieldDataType.keyedStringArrayCollection || field.dataType === IField_1.FieldDataType.keyedNumberArrayCollection || field.dataType === IField_1.FieldDataType.keyedKeyedStringArrayCollection; if (!isKeyedCollection) { // For regular objects, validate subForm if available const subForm = await this.getCachedSubForm(field, context); if (subForm) { await this.validate(data, subForm, issues, path, { depth: context.depth + 1, subFormCache: context.subFormCache, }); } return; } // Validate allowedKeys if specified if (field.allowedKeys && field.allowedKeys.length > 0) { const dataKeys = Object.keys(data); for (const key of dataKeys) { if (!field.allowedKeys.includes(key)) { issues.push({ message: `At ${path}, key '${key}' is not in the allowed keys list.`, type: DataFormIssueType.keyNotAllowed, }); } } } // If there's a subForm, validate each value in the keyed collection if (field.dataType === IField_1.FieldDataType.keyedObjectCollection) { const subForm = await this.getCachedSubForm(field, context); if (subForm) { for (const key of Object.keys(data)) { const value = data[key]; if (value !== undefined && value !== null && typeof value === "object") { await this.validate(value, subForm, issues, `${path}.${key}`, { depth: context.depth + 1, subFormCache: context.subFormCache, }); } } } } } /** * Checks if a field ID contains regex metacharacters, indicating it's a pattern * that should be matched against data keys rather than used as a literal property name. * Common patterns in Minecraft forms: "geometry.[a-zA-Z0-9_.'-:]+" for legacy geometry. */ static isPatternFieldId(fieldId) { // Check for common regex metacharacters that indicate a pattern return /[[\]{}()*+?\\^$|]/.test(fieldId); } /** * Validates a pattern-based field ID against the data object. * For pattern fields, iterates through all data keys and validates those matching the pattern. * If the field is required, at least one matching key must exist. * @param cleanedFieldId - The field ID after cleaning (removing embedded enum syntax) */ static async validatePatternField(data, field, issues, path, context, cleanedFieldId) { const fieldIdToUse = cleanedFieldId || (field.id ? cleanFieldId(field.id) : undefined); if (!fieldIdToUse) { return; } let pattern; try { // Anchor the pattern to match the full key pattern = new RegExp("^" + fieldIdToUse + "$"); } catch (e) { // Invalid regex - treat as literal field ID (fallback) const fieldData = data[fieldIdToUse]; await this.validateField(fieldData, field, issues, path + fieldIdToUse, context); return; } const dataKeys = Object.keys(data); const matchingKeys = dataKeys.filter((key) => pattern.test(key)); if (matchingKeys.length === 0) { if (field.isRequired) { issues.push({ message: `At ${path}${fieldIdToUse}, data is missing required field matching pattern '${fieldIdToUse}'.`, type: DataFormIssueType.missingRequiredField, }); } return; } // Validate each matching key against the field's subForm or validation rules for (const matchingKey of matchingKeys) { const fieldData = data[matchingKey]; await this.validateField(fieldData, field, issues, path + matchingKey, context); } } static getValidationIssue(type) { return { message: Utilities_1.default.getTitleFromEnum(DataFormIssueType, type), type: type, }; } } exports.default = DataFormValidator;