UNPKG

@addon24/eslint-config

Version:

ESLint configuration rules for WorldOfTextcraft projects - Centralized configuration for all project types

689 lines (596 loc) 26.5 kB
/** * ESLint-Regel: Stellt sicher, dass Entity-DTOs alle Properties ihrer zugehörigen Entities abbilden * Entity-DTOs (im dto/Entity Ordner) müssen alle Properties der Entity enthalten oder explizit ausschließen * Andere DTOs (Request, Response, Filter, Common) dürfen KEINE fromEntity-Methoden haben */ import fs from "fs"; import path from "path"; // Standard-Metadaten, die in DTOs ausgelassen werden können (jetzt konfigurierbar über schema) // Request-DTOs haben andere Regeln als Response-DTOs function isRequestDto(dtoName) { return dtoName.includes("Request") || dtoName.includes("Create") || dtoName.includes("Update"); } // Prüfe, ob DTO ein Response-DTO ist function isResponseDto(dtoName) { return dtoName.includes("Response") || dtoName.includes("Output") || dtoName.includes("Result"); } // Prüfe, ob DTO im Entity-Ordner liegt function isEntityDto(filename) { return filename.includes("/dto/Entity/") || filename.includes("test-fixtures"); } // Prüfe, ob DTO in anderen Ordnern liegt (Request, Response, Filter, Common) function isNonEntityDto(filename) { return filename.includes("/dto/") && (filename.includes("/dto/Request/") || filename.includes("/dto/Response/") || filename.includes("/dto/Filter/") || filename.includes("/dto/Common/")); } // Properties, die in Request-DTOs ausgelassen werden können const allowedRequestOmissions = new Set([ "id", "createdAt", "updatedAt", "isSystemMessage", "sender", "senderId", "recipient" // Werden serverseitig gesetzt ]); // Extra Properties, die in Request-DTOs erlaubt sind (aber nicht in Entity existieren) const allowedRequestExtras = new Set([ "recipientId" // Ersetzt recipient für einfachere API ]); // Extra Properties, die in Response-DTOs erlaubt sind (aber nicht in Entity existieren) const allowedResponseExtras = new Set([ "displayName", // Übersetzte/formatierte Namen "formattedValue", // Formatierte Werte "computed", // Berechnete Felder "metadata", // Zusätzliche Metadaten "status", // Zusätzliche Status-Informationen "links", // HATEOAS-Links "permissions", // Benutzer-spezifische Berechtigungen ]); /** @type {import('eslint').Rule.RuleModule} */ const dtoEntityMappingCompletenessRule = { meta: { type: "problem", docs: { description: "Entity-DTOs müssen alle Properties ihrer Entities abbilden; andere DTOs dürfen keine fromEntity-Methoden haben", category: "Architecture", recommended: true, }, hasSuggestions: true, schema: [ { type: "object", properties: { allowedOmissions: { type: "array", items: { type: "string" }, description: "Property names that are allowed to be omitted from DTOs", default: ["createdAt", "updatedAt", "id"] }, entityPath: { type: "string", description: "Path pattern for Entity files", default: "src/entity/" }, strictMapping: { type: "boolean", description: "Whether to enforce strict property mapping (no omissions allowed)", default: false }, checkOptionalityMismatch: { type: "boolean", description: "Whether to check for optionality mismatches between entity and DTO", default: true } }, additionalProperties: false } ], messages: { missingEntityProperty: "Entity-DTO '{{dtoName}}' fehlt Property '{{prop}}' der Entity '{{entityName}}' (Typ: {{entityType}})", extraDtoProperty: "Entity-DTO '{{dtoName}}' hat extra Property '{{prop}}' die nicht in Entity '{{entityName}}' existiert", typeMismatch: "Entity-DTO '{{dtoName}}' Property '{{prop}}' hat falschen Typ: DTO hat '{{dtoType}}', Entity hat '{{entityType}}'. Lösung: Interface in Entity exportieren und im DTO importieren", incorrectOptionality: "Entity-DTO '{{dtoName}}' Property '{{prop}}' darf nicht optional sein, wenn Entity-Property nicht optional ist: DTO hat '{{dtoType}}', Entity hat '{{entityType}}'", entityNotFound: "Entity-Datei für DTO '{{dtoName}}' nicht gefunden. Erwartet: {{expectedPath}}", forbiddenFromEntity: "{{dtoType}}-DTO '{{dtoName}}' darf keine fromEntity-Methode haben. Nur Entity-DTOs (im dto/Entity Ordner) dürfen fromEntity-Methoden haben.", missingFromEntity: "Entity-DTO '{{dtoName}}' muss eine fromEntity-Methode haben.", wrongFromEntityParameterType: "fromEntity-Methode Parameter muss Typ '{{expectedType}}' haben, nicht '{{actualType}}'", wrongFromEntityArrayParameterType: "fromEntityArray-Methode Parameter muss Typ '{{expectedType}}[]' haben, nicht '{{actualType}}'", }, }, create(context) { const options = context.options[0] || {}; const allowedOmissionsFromOptions = options.allowedOmissions || ["createdAt", "updatedAt", "id"]; const allowedOmissionsSet = new Set(allowedOmissionsFromOptions); const entityPath = options.entityPath || "src/entity/"; const strictMapping = options.strictMapping || false; const checkOptionalityMismatch = options.checkOptionalityMismatch !== false; function findEntityFile(dtoName) { // Konvertiere DTO-Namen zu Entity-Namen - EXAKTE Übereinstimmung erforderlich // AbilityEntityDto -> AbilityEntity (nicht AbilityEntityEntity) // StatClassBonusDtoEntityDto -> StatClassBonusEntity let entityName = dtoName; if (entityName.endsWith("DtoEntityDto")) { entityName = entityName.replace("DtoEntityDto", "Entity"); } else if (entityName.endsWith("EntityDto")) { entityName = entityName.replace("EntityDto", "Entity"); } else if (entityName.endsWith("Dto")) { entityName = entityName.replace("Dto", "Entity"); } const currentFile = context.getFilename(); // Für Test-Fixtures, suche in test-fixtures/entity/ Ordner if (currentFile.includes("test-fixtures")) { // Finde test-fixtures Root-Verzeichnis const testFixturesIndex = currentFile.indexOf("test-fixtures"); if (testFixturesIndex !== -1) { const testFixturesRoot = currentFile.substring(0, testFixturesIndex + "test-fixtures".length); const entityDir = path.join(testFixturesRoot, "entity"); const entityFile = path.join(entityDir, entityName + ".ts"); if (fs.existsSync(entityFile)) { return entityFile; } } return null; } // Für normale DTOs, suche in /app/backend/src/entity // Für /app/backend/src/dto/Entity/Game/FactionRaceRestrictionsMapDto.ts // soll der Pfad /app/backend/src/entity sein const srcIndex = currentFile.indexOf('/src/'); if (srcIndex === -1) { return null; // src/ nicht gefunden } const srcPath = currentFile.substring(0, srcIndex + 5); // /app/backend/src/ const entityDir = path.resolve(srcPath, "entity"); try { const entityFile = findFileRecursively(entityDir, entityName + ".ts"); if (entityFile) { return entityFile; } } catch { // Entity-Verzeichnis existiert nicht } return null; } function findFileRecursively(dir, filename) { if (!fs.existsSync(dir)) { return null; } const files = fs.readdirSync(dir); for (const file of files) { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { const result = findFileRecursively(filePath, filename); if (result) { return result; } } else if (file === filename) { return filePath; } } return null; } function extractNestedPropertiesFromJsonb(jsonbContent) { const nestedProperties = new Map(); // Spezielle Behandlung für TextureEntity settings if (jsonbContent.includes('settings') && jsonbContent.includes('intensity') && jsonbContent.includes('tiling')) { // Extrahiere nested Properties aus settings JSONB-Struktur if (jsonbContent.includes('intensity?: {')) { nestedProperties.set('intensity', 'object'); } if (jsonbContent.includes('tiling?: {')) { nestedProperties.set('tiling', 'object'); } } // Allgemeine Suche nach nested Properties in JSONB-Strukturen // Pattern: propertyName?: { ... } oder propertyName: { ... } const nestedPattern = /([a-zA-Z_][a-zA-Z0-9_]*)\s*\??\s*:\s*\{[^}]*\}/g; let match; while ((match = nestedPattern.exec(jsonbContent)) !== null) { const propertyName = match[1]; const propertyContent = match[0]; // Extrahiere den Typ der nested Property if (propertyContent.includes('number') || propertyContent.includes('string') || propertyContent.includes('boolean')) { nestedProperties.set(propertyName, 'object'); } } return nestedProperties; } function extractEntityProperties(entityContent) { const properties = new Map(); // TypeORM-Metadaten und Transformer-Properties, die ignoriert werden sollen const TYPEORM_METADATA = new Set([ 'nullable', 'unique', 'cascade', 'length', 'default', 'precision', 'scale', 'unsigned', 'zerofill', 'comment', 'charset', 'collation', 'generated', 'from', 'to', 'transformer', 'primary', 'select', 'insert', 'update', 'readonly', 'array', 'spatial', 'synchronize', 'onDelete', 'orphanedRowAction', 'createForeignKeyConstraints', 'lazy', // Parameter in ManyToOne Decorator 'eager', // Parameter in OneToMany/ManyToOne Decorator 'persistence', // TypeORM persistence option 'deferrable', // PostgreSQL constraint option 'onUpdate' // Foreign key constraint option ]); // Entferne Interface-Definitionen aus dem Content, um deren Properties zu ignorieren const contentWithoutInterfaces = entityContent.replace(/interface\s+[^{]+\{[^}]*\}/gs, ''); // Entferne JSON-Objekte aus dem Content, um deren innere Properties zu ignorieren const contentWithoutJsonObjects = contentWithoutInterfaces.replace(/:\s*\{[^}]*\}/gs, ': object'); // Robuste Regex-Lösung die auch Properties nach Decorators erkennt // Entferne mehrzeilige Decorators aus dem Content const cleanedContent = contentWithoutJsonObjects.replace(/@[A-Za-z]+\([^)]*\{[^}]*\}[^)]*\)/gs, ''); // Alternative Strategie: Suche nach Property-Namen und deren Typen const propertyMatches = []; // Suche nach Property-Definitionen mit verschiedenen Regex-Patterns const patterns = [ /^[ \t]*([a-zA-Z_][a-zA-Z0-9_]*)\s*([!?]?)\s*:\s*([^;]+);?\s*$/gm, /^[ \t]*([a-zA-Z_][a-zA-Z0-9_]*)\s*([!?]?)\s*:\s*([^;]+?);?\s*$/gm, /([a-zA-Z_][a-zA-Z0-9_]*)\s*([!?]?)\s*:\s*([^;]+);/g ]; // Spezielle Behandlung für Index-Signaturen const indexSignaturePattern = /\[key:\s*string\]\s*:\s*([^;]+);/g; let indexMatch; while ((indexMatch = indexSignaturePattern.exec(cleanedContent)) !== null) { propertyMatches.push({ name: "[key: string]", type: indexMatch[1].trim() }); } for (const pattern of patterns) { let match; while ((match = pattern.exec(cleanedContent)) !== null) { const propertyName = match[1].trim(); const optionality = match[2] || ""; const propertyType = match[3].trim(); // Ignoriere TypeORM-Metadaten und Relations if (!TYPEORM_METADATA.has(propertyName) && !propertyName.endsWith('Relations') && !propertyName.endsWith('Relation')) { // Füge Optionalität zum Typ hinzu let finalType = propertyType; if (optionality === "?" || propertyType.includes("| null") || propertyType.includes("| null")) { finalType += "?"; } propertyMatches.push({ name: propertyName, type: finalType }); } } } // Verarbeite die gefundenen Properties for (const match of propertyMatches) { const propertyName = match.name; const propertyType = match.type; // Behandle JSONB-Typen korrekt if (propertyType.includes('{') || propertyType.includes('}')) { if (propertyType.includes('Array<') || propertyType.includes('[]')) { properties.set(propertyName, 'array'); } else { properties.set(propertyName, 'object'); // Extrahiere nested Properties aus JSONB-Strukturen const nestedProps = extractNestedPropertiesFromJsonb(propertyType); for (const [nestedPropName, nestedPropType] of nestedProps) { properties.set(nestedPropName, nestedPropType); } } } else if (propertyType.includes('jsonb')) { // JSONB-Typen als object behandeln properties.set(propertyName, 'object'); } else { properties.set(propertyName, propertyType); } } return properties; } function extractDtoProperties(classNode) { const properties = new Map(); classNode.body.body.forEach(member => { // Normale Properties if (member.type === "PropertyDefinition" && member.key?.name) { const propName = member.key.name; let propType = "unknown"; let isOptional = false; // Prüfe auf Optionalität (Property mit ?) if (member.optional) { isOptional = true; } if (member.typeAnnotation?.typeAnnotation) { propType = getTypeString(member.typeAnnotation.typeAnnotation); } else if (member.value) { // Wenn keine explizite Typ-Annotation, versuche den Typ aus dem Initialwert abzuleiten propType = extractTypeFromValue(member.value); } // Füge Optionalität zum Typ hinzu if (isOptional) { propType += "?"; } properties.set(propName, propType); // Extrahiere nested Properties aus JSONB-Strukturen in DTOs if (propType.includes('{') || propType.includes('}')) { const nestedProps = extractNestedPropertiesFromJsonb(propType); for (const [nestedPropName, nestedPropType] of nestedProps) { properties.set(nestedPropName, nestedPropType); } } } // Index-Signaturen: [key: string]: unknown if (member.type === "TSIndexSignature") { const indexSignature = member; if (indexSignature.parameters && indexSignature.parameters.length > 0) { const param = indexSignature.parameters[0]; // Handle both TSParameterProperty and Identifier types let paramName, paramTypeAnnotation; if (param.type === "TSParameterProperty" && param.parameter) { paramName = param.parameter.name?.name; paramTypeAnnotation = param.parameter.typeAnnotation?.typeAnnotation; } else if (param.type === "Identifier") { paramName = param.name; paramTypeAnnotation = param.typeAnnotation?.typeAnnotation; } if (paramName === "key") { const keyType = getTypeString(paramTypeAnnotation); const valueType = getTypeString(indexSignature.typeAnnotation?.typeAnnotation); if (keyType === "string") { properties.set("[key: string]", valueType); } } } } }); return properties; } function getTypeString(typeNode) { switch (typeNode.type) { case "TSStringKeyword": return "string"; case "TSNumberKeyword": return "number"; case "TSBooleanKeyword": return "boolean"; case "TSNullKeyword": return "null"; case "TSUndefinedKeyword": return "undefined"; case "TSTypeReference": return typeNode.typeName?.name || "unknown"; case "TSUnionType": return typeNode.types.map(getTypeString).join(" | "); case "TSArrayType": return getTypeString(typeNode.elementType) + "[]"; case "TSTypeLiteral": return "object"; default: return "unknown"; } } function extractTypeFromValue(value) { if (value.type === "StringLiteral") return "string"; if (value.type === "NumericLiteral") return "number"; if (value.type === "BooleanLiteral") return "boolean"; if (value.type === "ArrayExpression") return "array"; if (value.type === "ObjectExpression") return "object"; if (value.type === "NewExpression") return "object"; if (value.type === "Identifier") { // Für bekannte Bezeichner wie "true", "false", etc. if (value.name === "true" || value.name === "false") return "boolean"; if (value.name === "null") return "null"; } return "unknown"; } // Verwende die extrahierte Typ-Matching-Regel // Die typesMatch Funktion wird direkt aus der dto-entity-type-matching Regel importiert function getDtoType(filename) { if (filename.includes("/dto/Request/")) return "Request"; if (filename.includes("/dto/Response/")) return "Response"; if (filename.includes("/dto/Filter/")) return "Filter"; if (filename.includes("/dto/Common/")) return "Common"; if (filename.includes("/dto/Entity/")) return "Entity"; return "Unknown"; } // Speichere DTO-Informationen für spätere Verwendung const dtoInfoMap = new Map(); return { ClassDeclaration(node) { // Sammle alle DTO-Klassen in DTO-Ordnern const filename = context.getFilename(); if (!filename.includes("/dto/")) return; const dtoName = node.id.name; const dtoType = getDtoType(filename); // Prüfe fromEntity-Methoden in der Klasse const hasFromEntity = node.body.body.some(member => member.type === "MethodDefinition" && member.static && member.key?.name === "fromEntity" ); // Für Entity-DTOs (nur im dto/Entity/ Ordner): Prüfe Entity-Mapping if (isEntityDto(filename)) { const isRequest = isRequestDto(dtoName); const entityFile = findEntityFile(dtoName); if (!entityFile) { // Entity-DTO ohne entsprechende Entity // Berechne erwarteten Entity-Namen let expectedEntityName = dtoName; if (expectedEntityName.endsWith("DtoEntityDto")) { // StatClassBonusDtoEntityDto -> StatClassBonusEntity expectedEntityName = expectedEntityName.replace("DtoEntityDto", "Entity"); } else if (expectedEntityName.endsWith("EntityDto")) { // AbilityEntityDto -> AbilityEntity expectedEntityName = expectedEntityName.replace("EntityDto", "Entity"); } else if (expectedEntityName.endsWith("Dto")) { // UserDto -> UserEntity expectedEntityName = expectedEntityName.replace("Dto", "Entity"); } context.report({ node, messageId: "entityNotFound", data: { dtoName, expectedPath: `${expectedEntityName}.ts`, }, }); return; } // Prüfe, ob fromEntity-Methode vorhanden ist if (!hasFromEntity) { context.report({ node, messageId: "missingFromEntity", data: { dtoName }, }); } // Parse Entity-Datei und speichere Informationen für NewExpression const entityContent = fs.readFileSync(entityFile, "utf8"); const entityProperties = extractEntityProperties(entityContent); const dtoProperties = extractDtoProperties(node); // Spezielle Behandlung für TextureEntity - füge nested Properties hinzu if (dtoName === "TextureEntityDto") { entityProperties.set("intensity", "object"); entityProperties.set("tiling", "object"); dtoProperties.set("intensity", "object"); dtoProperties.set("tiling", "object"); } const entityName = path.basename(entityFile, ".ts"); const currentAllowedOmissions = isRequest ? allowedRequestOmissions : (strictMapping ? new Set() : allowedOmissionsSet); const isResponse = isResponseDto(dtoName); // Prüfe Parameter-Typen der fromEntity und fromEntityArray Methoden node.body.body.forEach(member => { if (member.type === "MethodDefinition" && member.static) { const methodName = member.key?.name; if (methodName === "fromEntity") { // Prüfe fromEntity Parameter-Typ const params = member.value.params; if (params && params.length > 0) { const param = params[0]; if (param.typeAnnotation?.typeAnnotation) { const actualType = getTypeString(param.typeAnnotation.typeAnnotation); const expectedType = entityName; if (actualType !== expectedType && actualType !== "any") { context.report({ node: param, messageId: "wrongFromEntityParameterType", data: { expectedType, actualType, }, }); } } } } else if (methodName === "fromEntityArray") { // Prüfe fromEntityArray Parameter-Typ const params = member.value.params; if (params && params.length > 0) { const param = params[0]; if (param.typeAnnotation?.typeAnnotation) { const actualType = getTypeString(param.typeAnnotation.typeAnnotation); const expectedType = entityName; if (!actualType.includes(`${expectedType}[]`) && actualType !== "any[]" && actualType !== "any") { context.report({ node: param, messageId: "wrongFromEntityArrayParameterType", data: { expectedType, actualType, }, }); } } } } } }); dtoInfoMap.set(dtoName, { entityProperties, dtoProperties, entityName, currentAllowedOmissions, isRequest, isResponse, }); } // Für andere DTOs: Verbiete fromEntity-Methoden else if (isNonEntityDto(filename)) { if (hasFromEntity) { context.report({ node, messageId: "forbiddenFromEntity", data: { dtoName, dtoType, }, }); } } }, NewExpression(node) { // Prüfe nur bei Entity-DTO-Instanziierung if (node.callee.type !== "Identifier") return; const dtoName = node.callee.name; const dtoInfo = dtoInfoMap.get(dtoName); if (!dtoInfo) return; const { entityProperties, dtoProperties, entityName, currentAllowedOmissions, isRequest, isResponse } = dtoInfo; // Prüfe auf fehlende Entity-Properties im DTO for (const [entityProp, entityType] of entityProperties.entries()) { if (currentAllowedOmissions.has(entityProp)) { continue; } // Wenn die Entity ein JSONB-Objekt hat, prüfe nicht auf einzelne Properties if (entityType === "object" && entityProp === "textureMaps") { // textureMaps ist ein JSONB-Objekt, das als Interface in der DTO behandelt wird continue; } if (entityType === "object" && entityProp === "settings") { // settings ist ein JSONB-Objekt, das als Interface in der DTO behandelt wird continue; } // Spezielle Behandlung für Index-Signaturen if (entityProp === "[key: string]") { // Prüfe, ob die Index-Signatur in der DTO vorhanden ist const hasIndexSignature = dtoProperties.has("[key: string]") || Array.from(dtoProperties.keys()).some(key => key.includes("[key: string]")); if (!hasIndexSignature) { context.report({ node, messageId: "missingEntityProperty", data: { dtoName, entityName, prop: entityProp, entityType, }, }); } continue; } if (!dtoProperties.has(entityProp)) { context.report({ node, messageId: "missingEntityProperty", data: { dtoName, entityName, prop: entityProp, entityType, }, }); } // Typ-Übereinstimmung wird jetzt von der separaten dto-entity-type-consistency Regel geprüft } // Prüfe auf extra DTO-Properties (die nicht in der Entity existieren) for (const [dtoProp] of dtoProperties.entries()) { const isExtraAllowed = currentAllowedOmissions.has(dtoProp) || (isRequest && allowedRequestExtras.has(dtoProp)) || (isResponse && allowedResponseExtras.has(dtoProp)); if (!entityProperties.has(dtoProp) && !isExtraAllowed) { context.report({ node, messageId: "extraDtoProperty", data: { dtoName, entityName, prop: dtoProp, }, }); } } }, }; }, }; export default { rules: { "dto-entity-mapping-completeness": dtoEntityMappingCompletenessRule, }, };