Skip to content

Commit 68af4bb

Browse files
committed
chore: add eslint rule no-property-overload to disallow property shadowing in interfaces/classes
1 parent b164877 commit 68af4bb

File tree

5 files changed

+602
-3
lines changed

5 files changed

+602
-3
lines changed

specification/eslint.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export default defineConfig({
9595
}
9696
}
9797
],
98-
'es-spec-validator/no-all-string-literal-unions': 'error'
98+
'es-spec-validator/no-all-string-literal-unions': 'error',
99+
'es-spec-validator/no-property-overload': 'error'
99100
}
100101
})

validator/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ It is configured [in the specification directory](../specification/eslint.config
1717
| `no-inline-unions` | Inline union types (e.g., `field: A \| B`) are not allowed in properties/fields. Define a named type alias instead to improve code generation for statically-typed languages. |
1818
| `prefer-tagged-variants` | Union of class types should use tagged variants (`@variants internal` or `@variants container`) instead of inline unions for better deserialization support in statically-typed languages. |
1919
| `no-duplicate-type-names` | All types must be unique across class and enum definitions. |
20-
| `no-all-string-literal-unions | Unions consisting entirely of string literals (e.g., `"green" \| "yellow" \| "red"`) are not allowed, use enums instead. | |
20+
| `no-all-string-literal-unions` | Unions consisting entirely of string literals (e.g., `"green" \| "yellow" \| "red"`) are not allowed, use enums instead. |
21+
| `no-property-overload` | Properties that shadow or overload properties from a base interface or class are not allowed. All property names must be unique across the inheritance chain. | |
2122

2223
## Usage
2324

validator/eslint-plugin-es-spec.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import noInlineUnions from './rules/no-inline-unions.js'
2727
import preferTaggedVariants from './rules/prefer-tagged-variants.js'
2828
import noDuplicateTypeNames from './rules/no-duplicate-type-names.js'
2929
import noAllStringLiteralUnions from './rules/no-all-string-literal-unions.js'
30+
import noPropertyOverload from './rules/no-property-overload.js'
3031

3132
export default {
3233
rules: {
@@ -40,6 +41,7 @@ export default {
4041
'no-inline-unions': noInlineUnions,
4142
'prefer-tagged-variants': preferTaggedVariants,
4243
'no-duplicate-type-names': noDuplicateTypeNames,
43-
'no-all-string-literal-unions': noAllStringLiteralUnions
44+
'no-all-string-literal-unions': noAllStringLiteralUnions,
45+
'no-property-overload': noPropertyOverload
4446
}
4547
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'
21+
22+
const IMPLEMENTS_KEYWORD = 119;
23+
const EXTENDS_KEYWORD = 96;
24+
25+
export const noPropertyOverload = ESLintUtils.RuleCreator.withoutDocs({
26+
name: "no-property-overload",
27+
meta: {
28+
type: "problem",
29+
docs: {
30+
description: "Disallow properties that shadow or overload properties from a base interface or class.",
31+
requiresTypeChecking: true,
32+
},
33+
messages: {
34+
propertyOverload: "Property '{{propertyName}}' on {{typeName}} '{{declarationName}}' overloads a property from a base type."
35+
},
36+
schema: [],
37+
},
38+
defaultOptions: [],
39+
40+
create(context) {
41+
const services = ESLintUtils.getParserServices(context);
42+
if (!services.program) {
43+
return {};
44+
}
45+
46+
const typeChecker = services.program.getTypeChecker();
47+
const esTreeNodeToTSNodeMap = services.esTreeNodeToTSNodeMap;
48+
49+
const getPropertyName = (node) => node.name ?? node.value ?? null;
50+
51+
function collectAllProperties(type, inheritedPropertyNames, visited = new Set()) {
52+
if (visited.has(type)) return;
53+
visited.add(type);
54+
55+
type.getProperties().forEach(prop => inheritedPropertyNames.add(prop.name));
56+
57+
type.symbol?.declarations?.forEach(declaration => {
58+
declaration.heritageClauses?.forEach(clause => {
59+
if (clause.token === IMPLEMENTS_KEYWORD || clause.token === EXTENDS_KEYWORD) {
60+
clause.types.forEach(typeNode => {
61+
const relatedType = typeChecker.getTypeAtLocation(typeNode);
62+
collectAllProperties(relatedType, inheritedPropertyNames, visited);
63+
});
64+
}
65+
});
66+
});
67+
68+
(type.getBaseTypes() || []).forEach(baseType => {
69+
collectAllProperties(baseType, inheritedPropertyNames, visited);
70+
});
71+
}
72+
73+
return {
74+
'TSInterfaceDeclaration, ClassDeclaration'(node) {
75+
const isInterface = node.type === 'TSInterfaceDeclaration';
76+
const hasInheritance = isInterface
77+
? node.extends?.length > 0
78+
: node.superClass || node.implements?.length > 0;
79+
80+
if (!hasInheritance) return;
81+
82+
const tsNode = esTreeNodeToTSNodeMap.get(node);
83+
const currentType = typeChecker.getTypeAtLocation(tsNode);
84+
const baseTypes = currentType.getBaseTypes() || [];
85+
86+
const inheritedPropertyNames = new Set();
87+
baseTypes.forEach(baseType => {
88+
collectAllProperties(baseType, inheritedPropertyNames, new Set());
89+
});
90+
91+
const typeName = isInterface ? 'interface' : 'class';
92+
93+
node.body.body.forEach(propNode => {
94+
if (propNode.type !== 'TSPropertySignature' && propNode.type !== 'PropertyDefinition') {
95+
return;
96+
}
97+
98+
const localPropName = getPropertyName(propNode.key);
99+
if (!localPropName || !inheritedPropertyNames.has(localPropName)) return;
100+
101+
context.report({
102+
node: propNode.key,
103+
messageId: "propertyOverload",
104+
data: {
105+
propertyName: localPropName,
106+
typeName,
107+
declarationName: node.id.name,
108+
},
109+
});
110+
});
111+
}
112+
};
113+
}
114+
});
115+
116+
export default noPropertyOverload;

0 commit comments

Comments
 (0)