Skip to content

Commit 623f310

Browse files
committed
chore: add check-variants rule to ESLint plugin
1 parent cccee92 commit 623f310

File tree

5 files changed

+215
-15
lines changed

5 files changed

+215
-15
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/check-variants': 'error'
99100
}
100101
})

validator/README.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@ It is configured [in the specification directory](../specification/eslint.config
55

66
## Rules
77

8-
| Name | Description |
9-
|---------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
10-
| `single-key-dictionary-key-is-string` | `SingleKeyDictionary` keys must be strings. |
11-
| `dictionary-key-is-string` | `Dictionary` keys must be strings. |
12-
| `no-native-types` | TypeScript native utility types (`Record`, `Partial`, etc.) and collection types (`Map`, `Set`, etc.) are not allowed. Use spec-defined aliases like `Dictionary` instead. |
13-
| `invalid-node-types` | The spec uses a subset of TypeScript, so some types, clauses and expressions are not allowed. |
14-
| `no-generic-number` | Generic `number` type is not allowed outside of `_types/Numeric.ts`. Use concrete numeric types like `integer`, `long`, `float`, `double`, etc. |
15-
| `request-must-have-urls` | All Request interfaces extending `RequestBase` must have a `urls` property defining their endpoint paths and HTTP methods. |
16-
| `no-variants-on-responses` | `@variants` is only supported on Interface types, not on Request or Response classes. Use value_body pattern with `@codegen_name` instead. |
17-
| `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. |
18-
| `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. |
19-
| `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. | |
8+
| Name | Description |
9+
|---------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
10+
| `single-key-dictionary-key-is-string` | `SingleKeyDictionary` keys must be strings. |
11+
| `dictionary-key-is-string` | `Dictionary` keys must be strings. |
12+
| `no-native-types` | TypeScript native utility types (`Record`, `Partial`, etc.) and collection types (`Map`, `Set`, etc.) are not allowed. Use spec-defined aliases like `Dictionary` instead. |
13+
| `invalid-node-types` | The spec uses a subset of TypeScript, so some types, clauses and expressions are not allowed. |
14+
| `no-generic-number` | Generic `number` type is not allowed outside of `_types/Numeric.ts`. Use concrete numeric types like `integer`, `long`, `float`, `double`, etc. |
15+
| `request-must-have-urls` | All Request interfaces extending `RequestBase` must have a `urls` property defining their endpoint paths and HTTP methods. |
16+
| `no-variants-on-responses` | `@variants` is only supported on Interface types, not on Request or Response classes. Use value_body pattern with `@codegen_name` instead. |
17+
| `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. |
18+
| `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. |
19+
| `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. |
21+
| `check-variants` | Checks that `@variants` are valid for specific types. `Request` and `Response`, no variants allowed; Type Aliases, `internal`, `typed_keys_quirk` or `untagged`; Interface classes, `container` |
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 checkVariants from './rules/check-variants.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+
'check-variants': checkVariants
4446
}
4547
}

validator/rules/check-variants.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
import { ESLintUtils } from '@typescript-eslint/utils'
20+
21+
export const checkVariants = ESLintUtils.RuleCreator.withoutDocs({
22+
name: 'check-variants',
23+
meta: {
24+
type: 'problem',
25+
docs: {
26+
description: 'Checks that variants are allowed on a given type.',
27+
recommended: 'error'
28+
},
29+
messages: {
30+
variantsOnRequestOrResponse:
31+
'Variants are not allowed on {{ className }} classes.',
32+
interfaceWithNonContainerVariants:
33+
"Interface '{{ interfaceName }}' has '@variants {{ variantValue }}' but only 'container' is allowed for interfaces.",
34+
invalidVariantsTag:
35+
"Type alias '{{ typeName }}' has invalid '@variants {{ variantValue }}'. Must start with: {{ allowedValues }}."
36+
}
37+
},
38+
defaultOptions: [],
39+
create(context) {
40+
const sourceCode = context.sourceCode || context.getSourceCode()
41+
42+
const getJsDocTags = (node) => {
43+
const targetNode =
44+
node.parent?.type === 'ExportNamedDeclaration' ? node.parent : node
45+
const comments = sourceCode.getCommentsBefore(targetNode)
46+
47+
const jsDocComment = comments
48+
?.filter(
49+
(comment) => comment.type === 'Block' && comment.value.startsWith('*')
50+
)
51+
.pop()
52+
53+
if (!jsDocComment) return []
54+
55+
return jsDocComment.value
56+
.split('\n')
57+
.map((line) => line.trim().match(/^\*?\s*@(\w+)(?:\s+(.*))?$/))
58+
.filter(Boolean)
59+
.map(([, tag, value]) => ({ tag, value: value?.trim() || '' }))
60+
}
61+
62+
const isRequestOrResponse = (name) =>
63+
name === 'Request' || name === 'Response'
64+
65+
const hasVariantsTag = (tags) => tags.some(({ tag }) => tag === 'variants')
66+
67+
return {
68+
'TSInterfaceDeclaration, ClassDeclaration'(node) {
69+
const jsDocTags = getJsDocTags(node)
70+
if (isRequestOrResponse(node.id.name)) {
71+
if (hasVariantsTag(jsDocTags)) {
72+
context.report({
73+
node,
74+
messageId: 'variantsOnRequestOrResponse',
75+
data: { className: node.id.name }
76+
})
77+
}
78+
return
79+
}
80+
81+
const nonContainerVariant = jsDocTags.find(
82+
({ tag, value }) => tag === 'variants' && value !== 'container'
83+
)
84+
if (nonContainerVariant) {
85+
context.report({
86+
node,
87+
messageId: 'interfaceWithNonContainerVariants',
88+
data: {
89+
interfaceName: node.id.name,
90+
variantValue: nonContainerVariant.value
91+
}
92+
})
93+
return
94+
}
95+
},
96+
TSTypeAliasDeclaration(node) {
97+
const jsDocTags = getJsDocTags(node)
98+
const allowedVariants = ['internal', 'typed_keys_quirk', 'untagged']
99+
100+
const invalidVariant = jsDocTags.find(
101+
({ tag, value }) =>
102+
tag === 'variants' &&
103+
!allowedVariants.some((allowed) => value.startsWith(allowed))
104+
)
105+
106+
if (invalidVariant) {
107+
context.report({
108+
node,
109+
messageId: 'invalidVariantsTag',
110+
data: {
111+
typeName: node.id.name,
112+
variantValue: invalidVariant.value,
113+
allowedValues: allowedVariants.join(', ')
114+
}
115+
})
116+
}
117+
}
118+
}
119+
}
120+
})
121+
122+
export default checkVariants
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
import { RuleTester } from '@typescript-eslint/rule-tester'
20+
import rule from '../rules/check-variants.js'
21+
22+
const ruleTester = new RuleTester({
23+
languageOptions: {
24+
parserOptions: {
25+
projectService: {
26+
allowDefaultProject: ['*.ts*']
27+
},
28+
tsconfigRootDir: import.meta.dirname
29+
}
30+
}
31+
})
32+
33+
ruleTester.run('check-variants', rule, {
34+
valid: [
35+
{
36+
name: 'not Request or Response',
37+
code: `/** @variants container */
38+
export class MyClass {
39+
body: MyContainer
40+
}`
41+
},
42+
{
43+
name: 'internal tag on type alias',
44+
code: `/** @variants internal tag='type' */
45+
export type MyType = string | number`
46+
}
47+
],
48+
invalid: [
49+
{
50+
name: 'Request has variants tag',
51+
code: `/** @variants container */
52+
export class Request {}`,
53+
errors: [{ messageId: 'variantsOnRequestOrResponse' }]
54+
},
55+
{
56+
name: 'Response has variants tag',
57+
code: `/** @variants container */
58+
export class Response {}`,
59+
errors: [{ messageId: 'variantsOnRequestOrResponse' }]
60+
},
61+
{
62+
name: 'Interface has non-container variants tag',
63+
code: `/** @variants internal */
64+
export class RankContainer {}`,
65+
errors: [{ messageId: 'interfaceWithNonContainerVariants' }]
66+
},
67+
{
68+
name: 'Type alias has invalid variants tag',
69+
code: `/** @variants invalid */
70+
export type MyType = string | number`,
71+
errors: [{ messageId: 'invalidVariantsTag' }]
72+
}
73+
]
74+
})

0 commit comments

Comments
 (0)