Skip to content

Commit b3e4e66

Browse files
committed
chore: add new JSDoc endpoint check rule
1 parent cccee92 commit b3e4e66

File tree

8 files changed

+745
-25
lines changed

8 files changed

+745
-25
lines changed

specification/eslint.config.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,19 @@ 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/jsdoc-endpoint-check': [
100+
'error',
101+
{
102+
markdownlint: {
103+
default: true,
104+
'MD041': false, // first-line-heading
105+
'MD013': false, // line-length
106+
'MD033': false, // no-inline-html
107+
'MD034': false, // no-bare-urls
108+
'MD047': false // single-trailing-newline
109+
}
110+
}
111+
]
99112
}
100113
})

validator/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ It is configured [in the specification directory](../specification/eslint.config
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. |
2020
| `no-all-string-literal-unions | Unions consisting entirely of string literals (e.g., `"green" \| "yellow" \| "red"`) are not allowed, use enums instead. | |
21+
| `jsdoc-endpoint-check` | Validates JSDoc on endpoints in the specification. Ensuring consistent formatting. Some errors can be fixed with `--fix`. |
2122

2223
## Usage
2324

validator/eslint-plugin-es-spec.js

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,32 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19-
import singleKeyDict from './rules/single-key-dictionary-key-is-string.js'
20-
import dict from './rules/dictionary-key-is-string.js'
21-
import noNativeTypes from './rules/no-native-types.js'
22-
import invalidNodeTypes from './rules/invalid-node-types.js'
23-
import noGenericNumber from './rules/no-generic-number.js'
24-
import requestMustHaveUrls from './rules/request-must-have-urls.js'
25-
import noVariantsOnResponses from './rules/no-variants-on-responses.js'
26-
import noInlineUnions from './rules/no-inline-unions.js'
27-
import preferTaggedVariants from './rules/prefer-tagged-variants.js'
28-
import noDuplicateTypeNames from './rules/no-duplicate-type-names.js'
29-
import noAllStringLiteralUnions from './rules/no-all-string-literal-unions.js'
19+
import singleKeyDict from "./rules/single-key-dictionary-key-is-string.js";
20+
import dict from "./rules/dictionary-key-is-string.js";
21+
import noNativeTypes from "./rules/no-native-types.js";
22+
import invalidNodeTypes from "./rules/invalid-node-types.js";
23+
import noGenericNumber from "./rules/no-generic-number.js";
24+
import requestMustHaveUrls from "./rules/request-must-have-urls.js";
25+
import noVariantsOnResponses from "./rules/no-variants-on-responses.js";
26+
import noInlineUnions from "./rules/no-inline-unions.js";
27+
import preferTaggedVariants from "./rules/prefer-tagged-variants.js";
28+
import noDuplicateTypeNames from "./rules/no-duplicate-type-names.js";
29+
import noAllStringLiteralUnions from "./rules/no-all-string-literal-unions.js";
30+
import jsdocEndpointCheck from "./rules/jsdoc-endpoint-check.js";
3031

3132
export default {
3233
rules: {
33-
'single-key-dictionary-key-is-string': singleKeyDict,
34-
'dictionary-key-is-string': dict,
35-
'no-native-types': noNativeTypes,
36-
'invalid-node-types': invalidNodeTypes,
37-
'no-generic-number': noGenericNumber,
38-
'request-must-have-urls': requestMustHaveUrls,
39-
'no-variants-on-responses': noVariantsOnResponses,
40-
'no-inline-unions': noInlineUnions,
41-
'prefer-tagged-variants': preferTaggedVariants,
42-
'no-duplicate-type-names': noDuplicateTypeNames,
43-
'no-all-string-literal-unions': noAllStringLiteralUnions
44-
}
45-
}
34+
"single-key-dictionary-key-is-string": singleKeyDict,
35+
"dictionary-key-is-string": dict,
36+
"no-native-types": noNativeTypes,
37+
"invalid-node-types": invalidNodeTypes,
38+
"no-generic-number": noGenericNumber,
39+
"request-must-have-urls": requestMustHaveUrls,
40+
"no-variants-on-responses": noVariantsOnResponses,
41+
"no-inline-unions": noInlineUnions,
42+
"prefer-tagged-variants": preferTaggedVariants,
43+
"no-duplicate-type-names": noDuplicateTypeNames,
44+
"no-all-string-literal-unions": noAllStringLiteralUnions,
45+
"jsdoc-endpoint-check": jsdocEndpointCheck,
46+
},
47+
};

validator/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"license": "Apache-2.0",
1515
"dependencies": {
1616
"@typescript-eslint/utils": "^8.32.1",
17+
"markdownlint": "^0.39.0",
1718
"typescript": "^5.8.3"
1819
},
1920
"devDependencies": {
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
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+
import { lint as markdownlintSync } from 'markdownlint/sync'
21+
22+
const isLineEmpty = line => !line || line.trim() === ''
23+
24+
const parseJSDoc = (jsdoc) => {
25+
const lines = jsdoc.value
26+
.split('\n')
27+
.map(line => line.trim().replace(/^\*\s?/, ''))
28+
29+
return {
30+
lines,
31+
summaryIndex: lines.findIndex(line => !isLineEmpty(line)),
32+
lastNonEmptyIndex: lines.findLastIndex(line => !isLineEmpty(line))
33+
}
34+
}
35+
36+
const reconstructJSDoc = (lines) => {
37+
let trimmedLines = [...lines]
38+
39+
while (trimmedLines.length > 0 && isLineEmpty(trimmedLines[trimmedLines.length - 1])) {
40+
trimmedLines.pop()
41+
}
42+
43+
if (trimmedLines.length > 0 && isLineEmpty(trimmedLines[0])) {
44+
trimmedLines = trimmedLines.slice(1)
45+
}
46+
47+
const lineContent = trimmedLines.map(line => line ? ` * ${line}` : ' *').join('\n')
48+
return `/**\n${lineContent}\n */`
49+
}
50+
51+
const fixers = {
52+
firstLineShouldBeEmpty: (lines) => {
53+
if (!isLineEmpty(lines[0])) {
54+
return ['', lines[0], ...lines.slice(1)]
55+
}
56+
return lines
57+
},
58+
59+
summaryMissingPeriod: (lines, { summaryIndex }) => {
60+
if (summaryIndex !== -1) {
61+
const fixed = [...lines]
62+
fixed[summaryIndex] = lines[summaryIndex].trimEnd() + '.'
63+
return fixed
64+
}
65+
return lines
66+
},
67+
68+
lineAfterSummaryShouldBeEmpty: (lines, { summaryIndex }) => {
69+
if (summaryIndex !== -1) {
70+
const fixed = [...lines]
71+
fixed.splice(summaryIndex + 1, 0, '')
72+
return fixed
73+
}
74+
return lines
75+
}
76+
}
77+
78+
const createJSDocFixer = (jsdoc, messageId) => {
79+
const parsed = parseJSDoc(jsdoc)
80+
const fixer = fixers[messageId]
81+
82+
if (!fixer) return null
83+
84+
const fixedLines = fixer(parsed.lines, parsed)
85+
return (fix) => fix.replaceTextRange([jsdoc.range[0], jsdoc.range[1]], reconstructJSDoc(fixedLines))
86+
}
87+
88+
const validateMarkdown = (lines, summaryIndex, startLine, column, markdownlintConfig) => {
89+
const errors = []
90+
91+
// Extract description content
92+
const descriptionStartIndex = summaryIndex + 2
93+
if (descriptionStartIndex >= lines.length) {
94+
return errors
95+
}
96+
97+
const descriptionLines = lines.slice(descriptionStartIndex)
98+
const description = descriptionLines.join('\n').trim()
99+
100+
if (!description) {
101+
return errors
102+
}
103+
104+
const result = markdownlintSync({
105+
strings: {
106+
'description': description
107+
},
108+
config: markdownlintConfig
109+
})
110+
111+
// Convert markdownlint errors to ESLint errors
112+
const markdownErrors = result.description || []
113+
markdownErrors.forEach(error => {
114+
const lineOffset = descriptionStartIndex + error.lineNumber - 1
115+
errors.push({
116+
messageId: 'markdownLintError',
117+
line: startLine + lineOffset,
118+
column,
119+
canFix: false,
120+
data: {
121+
rule: error.ruleNames.join('/'),
122+
detail: error.ruleDescription + (error.errorDetail ? `: ${error.errorDetail}` : '')
123+
}
124+
})
125+
})
126+
127+
return errors
128+
}
129+
130+
const validateJSDoc = (jsdoc, markdownlintConfig) => {
131+
const { lines, summaryIndex, lastNonEmptyIndex } = parseJSDoc(jsdoc)
132+
const { line: startLine, column } = jsdoc.loc.start
133+
134+
const createError = (messageId, lineOffset, data, canFix = false) => ({
135+
messageId,
136+
line: startLine + lineOffset,
137+
column,
138+
canFix,
139+
...(data && { data })
140+
})
141+
142+
const errors = []
143+
144+
if (!isLineEmpty(lines[0])) {
145+
errors.push(createError('firstLineShouldBeEmpty', 0, null, true))
146+
}
147+
148+
if (summaryIndex === -1 || (isLineEmpty(lines[0]) && summaryIndex !== 1)) {
149+
errors.push(createError('missingSummary', 1))
150+
return errors
151+
}
152+
153+
const summary = lines[summaryIndex]
154+
155+
if (/[*`\[\]#]/.test(summary)) {
156+
errors.push(createError('summaryHasMarkup', summaryIndex))
157+
}
158+
159+
if (!summary.trim().endsWith('.')) {
160+
errors.push(createError('summaryMissingPeriod', summaryIndex, null, true))
161+
}
162+
163+
const lineAfterSummary = summaryIndex + 1
164+
if (lineAfterSummary < lines.length && !isLineEmpty(lines[lineAfterSummary])) {
165+
errors.push(createError('lineAfterSummaryShouldBeEmpty', lineAfterSummary, null, true))
166+
}
167+
168+
// Validate markdown in description
169+
errors.push(...validateMarkdown(lines, summaryIndex, startLine, column, markdownlintConfig))
170+
171+
return errors
172+
}
173+
174+
export const jsdocEndpointCheck = ESLintUtils.RuleCreator.withoutDocs(
175+
{
176+
name: 'jsdoc-endpoint-check',
177+
meta: {
178+
type: 'layout',
179+
docs: {
180+
description: 'Checks that the JSDoc for an endpoint has the correct format',
181+
recommended: 'error'
182+
},
183+
fixable: 'code',
184+
hasSuggestions: true,
185+
messages: {
186+
firstLineShouldBeEmpty: 'JSDoc first line should be empty',
187+
summaryHasMarkup: 'JSDoc summary should not contain markup',
188+
summaryMissingPeriod: 'JSDoc summary should end with a period',
189+
lineAfterSummaryShouldBeEmpty: 'Line after summary should be empty',
190+
endpointJSDocMissing: 'The JSDoc for an endpoint is missing.',
191+
markdownLintError: 'Markdown error ({{rule}}): {{detail}}'
192+
},
193+
schema: [
194+
{
195+
type: 'object',
196+
properties: {
197+
markdownlint: {
198+
type: 'object',
199+
description: 'Configuration object for markdownlint rules',
200+
additionalProperties: true
201+
}
202+
},
203+
additionalProperties: false
204+
}
205+
]
206+
},
207+
defaultOptions: [
208+
{
209+
markdownlint: {
210+
default: true,
211+
// Disable rules that don't make sense for JSDoc descriptions
212+
'MD041': false,
213+
'MD013': false,
214+
'MD033': false,
215+
'MD034': false,
216+
'MD047': false
217+
}
218+
}
219+
],
220+
create(context) {
221+
const sourceCode = context.sourceCode || context.getSourceCode()
222+
const options = context.options[0] || {}
223+
const markdownlintConfig = options.markdownlint || context.options[0]?.markdownlint || {
224+
default: true,
225+
'MD041': false,
226+
'MD013': false,
227+
'MD033': false,
228+
'MD034': false,
229+
'MD047': false
230+
}
231+
232+
return {
233+
'TSInterfaceDeclaration, ClassDeclaration'(node) {
234+
if (node.id.name !== 'Request') return
235+
236+
const nodeToGetCommentsFrom =
237+
node.parent?.type === 'ExportNamedDeclaration' ? node.parent : node
238+
239+
const comments = sourceCode.getCommentsBefore(nodeToGetCommentsFrom)
240+
const jsdoc = comments
241+
?.filter(comment => comment.type === 'Block' && comment.value.startsWith('*'))
242+
.pop()
243+
244+
if (!jsdoc) {
245+
context.report({ node, messageId: 'endpointJSDocMissing' })
246+
return
247+
}
248+
249+
const validationErrors = validateJSDoc(jsdoc, markdownlintConfig)
250+
validationErrors.forEach(({ messageId, data, line, column, canFix }) => {
251+
context.report({
252+
node,
253+
messageId,
254+
...(data && { data }),
255+
loc: { start: { line, column } },
256+
...(canFix && { fix: createJSDocFixer(jsdoc, messageId) }),
257+
...(canFix && { suggestions: [createJSDocFixer(jsdoc, messageId)] })
258+
})
259+
})
260+
}
261+
}
262+
}
263+
})
264+
265+
export default jsdocEndpointCheck

0 commit comments

Comments
 (0)