Skip to content

Commit 207103e

Browse files
authored
chore: add check-variants rule to ESLint plugin (#5678)
* chore: add check-variants rule to ESLint plugin * chore: remove rule and extend existing rule
1 parent dadc50d commit 207103e

File tree

3 files changed

+153
-45
lines changed

3 files changed

+153
-45
lines changed

validator/README.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,19 @@ 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. Includes additional checks on variant tag use. |
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. |
2121
| `jsdoc-endpoint-check` | Validates JSDoc on endpoints in the specification. Ensuring consistent formatting. Some errors can be fixed with `--fix`. |
2222

2323
## Usage

validator/rules/no-variants-on-responses.js

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,51 +16,124 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19-
import { ESLintUtils } from '@typescript-eslint/utils';
19+
import { ESLintUtils } from '@typescript-eslint/utils'
2020

21-
const createRule = ESLintUtils.RuleCreator(name => `https://example.com/rule/${name}`)
21+
const createRule = ESLintUtils.RuleCreator(
22+
(name) => `https://example.com/rule/${name}`
23+
)
2224

2325
export default createRule({
2426
name: 'no-variants-on-responses',
2527
create(context) {
28+
const sourceCode = context.sourceCode || context.getSourceCode()
29+
30+
const getJsDocTags = (node) => {
31+
const targetNode =
32+
node.parent?.type === 'ExportNamedDeclaration' ? node.parent : node
33+
const comments = sourceCode.getCommentsBefore(targetNode)
34+
35+
const jsDocComment = comments
36+
?.filter(
37+
(comment) => comment.type === 'Block' && comment.value.startsWith('*')
38+
)
39+
.pop()
40+
41+
if (!jsDocComment) return []
42+
43+
return jsDocComment.value
44+
.split('\n')
45+
.map((line) => line.trim().match(/^\*?\s*@(\w+)(?:\s+(.*))?$/))
46+
.filter(Boolean)
47+
.map(([, tag, value]) => ({ tag, value: value?.trim() || '' }))
48+
}
49+
2650
return {
27-
ClassDeclaration(node) {
28-
const className = node.id?.name;
29-
if (className !== 'Response' && className !== 'Request') {
30-
return;
51+
'TSInterfaceDeclaration, ClassDeclaration'(node) {
52+
const className = node.id?.name
53+
if (className === 'Response' || className === 'Request') {
54+
const fullText = sourceCode.text
55+
56+
const nodeStart = node.range[0]
57+
const textBefore = fullText.substring(
58+
Math.max(0, nodeStart - 200),
59+
nodeStart
60+
)
61+
62+
const hasVariantsTag =
63+
/@variants\s+(container|internal|external|untagged)/.test(
64+
textBefore
65+
)
66+
67+
if (hasVariantsTag) {
68+
context.report({
69+
node,
70+
messageId: 'noVariantsOnResponses',
71+
data: {
72+
className,
73+
suggestion:
74+
'Move @variants to a separate body class and use value_body pattern with @codegen_name. See SearchResponse for an example.'
75+
}
76+
})
77+
}
78+
return
3179
}
32-
33-
const sourceCode = context.sourceCode || context.getSourceCode();
34-
const fullText = sourceCode.text;
35-
36-
const nodeStart = node.range[0];
37-
const textBefore = fullText.substring(Math.max(0, nodeStart - 200), nodeStart);
38-
39-
const hasVariantsTag = /@variants\s+(container|internal|external|untagged)/.test(textBefore);
40-
41-
if (hasVariantsTag) {
80+
81+
const jsDocTags = getJsDocTags(node)
82+
83+
const nonContainerVariant = jsDocTags.find(
84+
({ tag, value }) => tag === 'variants' && value !== 'container'
85+
)
86+
if (nonContainerVariant) {
4287
context.report({
4388
node,
44-
messageId: 'noVariantsOnResponses',
89+
messageId: 'interfaceWithNonContainerVariants',
4590
data: {
46-
className,
47-
suggestion: 'Move @variants to a separate body class and use value_body pattern with @codegen_name. See SearchResponse for an example.'
91+
interfaceName: node.id.name,
92+
variantValue: nonContainerVariant.value
4893
}
49-
});
94+
})
95+
return
5096
}
5197
},
98+
TSTypeAliasDeclaration(node) {
99+
const jsDocTags = getJsDocTags(node)
100+
const allowedVariants = ['internal', 'typed_keys_quirk', 'untagged']
101+
102+
const invalidVariant = jsDocTags.find(
103+
({ tag, value }) =>
104+
tag === 'variants' &&
105+
!allowedVariants.some((allowed) => value.startsWith(allowed))
106+
)
107+
108+
if (invalidVariant) {
109+
context.report({
110+
node,
111+
messageId: 'invalidVariantsTag',
112+
data: {
113+
typeName: node.id.name,
114+
variantValue: invalidVariant.value,
115+
allowedValues: allowedVariants.join(', ')
116+
}
117+
})
118+
}
119+
}
52120
}
53121
},
54122
meta: {
55123
docs: {
56-
description: '@variants is only supported on Interface types, not on Request or Response classes. Use value_body pattern instead.',
124+
description:
125+
'@variants is only supported on Interface types, not on Request or Response classes. Use value_body pattern instead.'
57126
},
58127
messages: {
59-
noVariantsOnResponses: '@variants on {{className}} is not supported in metamodel. {{suggestion}}'
128+
noVariantsOnResponses:
129+
'@variants on {{className}} is not supported in metamodel. {{suggestion}}',
130+
interfaceWithNonContainerVariants:
131+
"Interface '{{ interfaceName }}' has '@variants {{ variantValue }}' but only 'container' is allowed for interfaces.",
132+
invalidVariantsTag:
133+
"Type alias '{{ typeName }}' has invalid '@variants {{ variantValue }}'. Must start with: {{ allowedValues }}."
60134
},
61135
type: 'problem',
62136
schema: []
63137
},
64138
defaultOptions: []
65139
})
66-

validator/test/no-variants-on-responses.test.js

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ const ruleTester = new RuleTester({
2323
languageOptions: {
2424
parserOptions: {
2525
projectService: {
26-
allowDefaultProject: ['*.ts*'],
26+
allowDefaultProject: ['*.ts*']
2727
},
28-
tsconfigRootDir: import.meta.dirname,
29-
},
30-
},
28+
tsconfigRootDir: import.meta.dirname
29+
}
30+
}
3131
})
3232

3333
ruleTester.run('no-variants-on-responses', rule, {
@@ -42,7 +42,7 @@ ruleTester.run('no-variants-on-responses', rule, {
4242
classification?: ClassificationSummary
4343
regression?: RegressionSummary
4444
}`,
45-
45+
4646
`export class Request {
4747
path_parts: {}
4848
query_parameters: {}
@@ -51,19 +51,31 @@ ruleTester.run('no-variants-on-responses', rule, {
5151
5252
/** @variants internal tag='type' */
5353
export type RequestBody = TypeA | TypeB`,
54-
54+
5555
`/** @variants container */
5656
export interface MyContainer {
5757
option_a?: OptionA
5858
option_b?: OptionB
5959
}`,
60-
60+
6161
`export class Response {
6262
body: {
6363
count: integer
6464
results: string[]
6565
}
6666
}`,
67+
{
68+
name: 'not Request or Response',
69+
code: `/** @variants container */
70+
export class MyClass {
71+
body: MyContainer
72+
}`
73+
},
74+
{
75+
name: 'internal tag on type alias',
76+
code: `/** @variants internal tag='type' */
77+
export type MyType = string | number`
78+
}
6779
],
6880
invalid: [
6981
{
@@ -92,6 +104,29 @@ ruleTester.run('no-variants-on-responses', rule, {
92104
}`,
93105
errors: [{ messageId: 'noVariantsOnResponses' }]
94106
},
95-
],
107+
{
108+
name: 'Request has variants tag',
109+
code: `/** @variants container */
110+
export class Request {}`,
111+
errors: [{ messageId: 'noVariantsOnResponses' }]
112+
},
113+
{
114+
name: 'Response has variants tag',
115+
code: `/** @variants container */
116+
export class Response {}`,
117+
errors: [{ messageId: 'noVariantsOnResponses' }]
118+
},
119+
{
120+
name: 'Interface has non-container variants tag',
121+
code: `/** @variants internal */
122+
export class RankContainer {}`,
123+
errors: [{ messageId: 'interfaceWithNonContainerVariants' }]
124+
},
125+
{
126+
name: 'Type alias has invalid variants tag',
127+
code: `/** @variants invalid */
128+
export type MyType = string | number`,
129+
errors: [{ messageId: 'invalidVariantsTag' }]
130+
}
131+
]
96132
})
97-

0 commit comments

Comments
 (0)