@addon24/eslint-config
Version:
ESLint configuration rules for WorldOfTextcraft projects - Centralized configuration for all project types
689 lines (596 loc) • 26.5 kB
JavaScript
/**
* 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,
},
};