From 0540e5ff5b16f977451e96c0dd2f841f8a51b899 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Sun, 14 Jun 2026 12:39:54 +0100 Subject: [PATCH] feat(eslint): add no-primitive-value-properties rule to enforce value objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add custom ESLint rule that warns when class properties or constructor parameters use primitive types (number, string, boolean) where a value object exists in src/value-objects/. The rule auto-discovers value objects from the directory and maps property names (e.g. 'health' → 'Health') to suggest the correct type. Found 4 warnings on the codebase: - MagicalObject.ts: #health: number property - MagicalObject.ts: health: number constructor param - MagicalWeapon.ts: health: number constructor param - HealingObject.ts: health: number constructor param --- eslint.config.js | 9 ++ eslint/no-primitive-value-properties.js | 147 ++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 eslint/no-primitive-value-properties.js diff --git a/eslint.config.js b/eslint.config.js index 7af7fb1..a255bf5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,7 @@ import js from '@eslint/js'; import tseslint from 'typescript-eslint'; import prettier from 'eslint-config-prettier'; +import noPrimitiveValue from './eslint/no-primitive-value-properties.js'; export default tseslint.config( { ignores: ['dist', 'node_modules', 'coverage', 'allium-main'] }, @@ -17,9 +18,17 @@ export default tseslint.config( tsconfigRootDir: import.meta.dirname, }, }, + plugins: { + rpg: { + rules: { + 'no-primitive-value-properties': noPrimitiveValue, + }, + }, + }, rules: { '@typescript-eslint/switch-exhaustiveness-check': 'warn', '@typescript-eslint/no-unnecessary-condition': 'warn', + 'rpg/no-primitive-value-properties': 'warn', }, }, diff --git a/eslint/no-primitive-value-properties.js b/eslint/no-primitive-value-properties.js new file mode 100644 index 0000000..9a42f1c --- /dev/null +++ b/eslint/no-primitive-value-properties.js @@ -0,0 +1,147 @@ +import { readdirSync } from 'node:fs'; +import { join, parse } from 'node:path'; + +const VALUE_OBJECTS_DIR = join(import.meta.dirname, '..', 'src', 'value-objects'); + +function discoverValueObjects() { + const files = readdirSync(VALUE_OBJECTS_DIR).filter( + (f) => f.endsWith('.ts') && f !== 'index.ts', + ); + return files.map((f) => parse(f).name); +} + +const VALUE_OBJECTS = discoverValueObjects(); + +function buildPropertyMap() { + const map = {}; + for (const name of VALUE_OBJECTS) { + const propertyName = name.charAt(0).toLowerCase() + name.slice(1); + map[propertyName] = name; + } + return map; +} + +const PROPERTY_MAP = buildPropertyMap(); + +/** Map ESLint AST type names to the primitive keyword string. */ +const PRIMITIVE_TYPE_MAP = { + TSNumberKeyword: 'number', + TSStringKeyword: 'string', + TSBooleanKeyword: 'boolean', +}; + +function isPrimitiveType(typeNode) { + if (!typeNode) return false; + const keyword = PRIMITIVE_TYPE_MAP[typeNode.type]; + return keyword !== undefined; +} + +function getPrimitiveKeyword(typeNode) { + if (!typeNode) return null; + return PRIMITIVE_TYPE_MAP[typeNode.type] || null; +} + +function getParamName(param) { + if (param.type === 'TSParameterProperty') { + return getParamName(param.parameter); + } + if (param.type === 'Identifier') { + return param.name; + } + return null; +} + +export default { + meta: { + type: 'suggestion', + docs: { + description: + 'Warn when primitive types are used in class properties or constructor parameters that should be value objects', + recommended: false, + }, + schema: [ + { + type: 'object', + properties: { + includeConstructorParams: { type: 'boolean', default: true }, + includeProperties: { type: 'boolean', default: true }, + }, + additionalProperties: false, + }, + ], + messages: { + primitiveProperty: + 'Use value object "{{valueObject}}" instead of primitive "{{primitive}}" for property "{{propertyName}}". Available: {{available}}', + primitiveParam: + 'Use value object "{{valueObject}}" instead of primitive "{{primitive}}" for parameter "{{paramName}}". Available: {{available}}', + }, + }, + + create(context) { + const options = context.options[0] || {}; + const includeConstructorParams = options.includeConstructorParams !== false; + const includeProperties = options.includeProperties !== false; + + function reportPrimitive(node, propName, primitiveType, messageId) { + const valueObject = PROPERTY_MAP[propName]; + if (valueObject) { + context.report({ + node, + messageId, + data: { + valueObject, + primitive: primitiveType, + propertyName: propName, + paramName: propName, + available: VALUE_OBJECTS.join(', '), + }, + }); + } + } + + return { + ClassProperty(node) { + if (!includeProperties) return; + if (!node.typeAnnotation) return; + + const typeNode = node.typeAnnotation.typeAnnotation; + if (!isPrimitiveType(typeNode)) return; + + reportPrimitive(node, node.key.name, getPrimitiveKeyword(typeNode), 'primitiveProperty'); + }, + + PropertyDefinition(node) { + if (!includeProperties) return; + if (!node.typeAnnotation) return; + + const typeNode = node.typeAnnotation.typeAnnotation; + if (!isPrimitiveType(typeNode)) return; + + reportPrimitive(node, node.key.name, getPrimitiveKeyword(typeNode), 'primitiveProperty'); + }, + + MethodDefinition(node) { + if (!includeConstructorParams) return; + if (node.key.name !== 'constructor') return; + if (!node.value.params) return; + + for (const param of node.value.params) { + let paramName = null; + let typeNode = null; + + if (param.type === 'TSParameterProperty') { + paramName = getParamName(param.parameter); + typeNode = param.parameter.typeAnnotation?.typeAnnotation; + } else if (param.type === 'Identifier') { + paramName = param.name; + typeNode = param.typeAnnotation?.typeAnnotation; + } + + if (!paramName || !typeNode || !isPrimitiveType(typeNode)) continue; + + reportPrimitive(param, paramName, getPrimitiveKeyword(typeNode), 'primitiveParam'); + } + }, + }; + }, +};