feat(eslint): add no-primitive-value-properties rule to enforce value objects

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
This commit is contained in:
Willem van den Ende 2026-06-14 12:39:54 +01:00
parent dea77d463f
commit 0540e5ff5b
2 changed files with 156 additions and 0 deletions

View File

@ -1,6 +1,7 @@
import js from '@eslint/js'; import js from '@eslint/js';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier'; import prettier from 'eslint-config-prettier';
import noPrimitiveValue from './eslint/no-primitive-value-properties.js';
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'coverage', 'allium-main'] }, { ignores: ['dist', 'node_modules', 'coverage', 'allium-main'] },
@ -17,9 +18,17 @@ export default tseslint.config(
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
}, },
}, },
plugins: {
rpg: {
rules: {
'no-primitive-value-properties': noPrimitiveValue,
},
},
},
rules: { rules: {
'@typescript-eslint/switch-exhaustiveness-check': 'warn', '@typescript-eslint/switch-exhaustiveness-check': 'warn',
'@typescript-eslint/no-unnecessary-condition': 'warn', '@typescript-eslint/no-unnecessary-condition': 'warn',
'rpg/no-primitive-value-properties': 'warn',
}, },
}, },

View File

@ -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');
}
},
};
},
};