Skip to content

Commit d0bbbbe

Browse files
authored
Add lint rule to validate type descriptions in README (#1396)
1 parent 1e8bd10 commit d0bbbbe

48 files changed

Lines changed: 425 additions & 124 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

lint-rules/readme-jsdoc-sync.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// @ts-check
2+
/// <reference types="node" />
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import ts from 'typescript';
6+
7+
/** @type {import('@eslint/markdown').MarkdownRuleDefinition} */
8+
export const readmeJSDocSyncRule = {
9+
meta: {
10+
type: 'suggestion',
11+
language: 'markdown/commonmark',
12+
docs: {
13+
description: 'Enforces that type descriptions in the README exactly match the first line of their source JSDoc.',
14+
},
15+
fixable: 'code',
16+
messages: {
17+
mismatch: 'Type description does not match the source JSDoc.\n\nExpected: {{expected}}\n\nFound: {{actual}}',
18+
missingTypeOrJSDoc: 'Type `{{typeName}}` in `{{filePath}}` either does not exist or lacks JSDoc documentation.',
19+
fileNotFound: 'Linked file `{{filePath}}` not found.',
20+
},
21+
schema: [],
22+
},
23+
create(context) {
24+
if (path.basename(context.filename) !== 'readme.md') {
25+
return {};
26+
}
27+
28+
return {
29+
listItem(node) {
30+
const paragraph = node.children.find(child => child.type === 'paragraph');
31+
if (!paragraph) {
32+
return;
33+
}
34+
35+
const linkNode = paragraph.children[0];
36+
if (linkNode?.type !== 'link' || linkNode.url.startsWith('http') || !linkNode.url.endsWith('.d.ts')) {
37+
return;
38+
}
39+
40+
const inlineCodeNode = linkNode.children[0];
41+
if (inlineCodeNode?.type !== 'inlineCode') {
42+
return;
43+
}
44+
45+
const typeName = inlineCodeNode.value;
46+
const typeDescription = context.sourceCode.getText(paragraph).split(' - ').slice(1).join(' - ');
47+
48+
const absolutePath = path.resolve(path.dirname(context.filename), linkNode.url);
49+
50+
let sourceContent;
51+
try {
52+
sourceContent = fs.readFileSync(absolutePath, 'utf8');
53+
} catch {
54+
return context.report({
55+
node: linkNode,
56+
messageId: 'fileNotFound',
57+
data: {
58+
filePath: linkNode.url,
59+
},
60+
});
61+
}
62+
63+
const sourceFile = ts.createSourceFile(linkNode.url, sourceContent, ts.ScriptTarget.Latest, true);
64+
let jsdocDescription = ts.forEachChild(sourceFile, node => {
65+
if ((ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) && node.name.text === typeName) {
66+
const jsdocs = ts.getJSDocCommentsAndTags(node);
67+
return jsdocs[0]?.getText().split('\n')[1];
68+
}
69+
70+
return undefined;
71+
});
72+
73+
if (!jsdocDescription) {
74+
return context.report({
75+
node: linkNode,
76+
messageId: 'missingTypeOrJSDoc',
77+
data: {
78+
typeName,
79+
filePath: linkNode.url,
80+
},
81+
});
82+
}
83+
84+
const tagRegex = /\{@link\s+([^\}]+)\}/gv;
85+
// This simply replaces "{@link SymbolName}" with "`SymbolName`".
86+
// It doesn't handle captions or external links, for example, "{@link SymbolName | some caption}" simply becomes "`SymbolName | some caption`".
87+
// For external links, markdown syntax should be used, like "[type-fest](https://github.com/sindresorhus/type-fest)".
88+
// And for symbols, if just "`SymbolName`" isn't sufficient, then for those specific cases this rule should be disabled.
89+
jsdocDescription = jsdocDescription.replaceAll(tagRegex, (_, content) => `\`${content}\``);
90+
91+
if (typeDescription !== jsdocDescription) {
92+
context.report({
93+
node,
94+
messageId: 'mismatch',
95+
data: {
96+
expected: jsdocDescription,
97+
actual: typeDescription,
98+
},
99+
fix(fixer) {
100+
return fixer.replaceText(
101+
paragraph,
102+
`${context.sourceCode.getText(linkNode)} - ${jsdocDescription}`,
103+
);
104+
},
105+
});
106+
}
107+
},
108+
};
109+
},
110+
};
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import markdown from '@eslint/markdown';
2+
import {createRuleTester, createFixtures, dedenter} from './test-utils.js';
3+
import {readmeJSDocSyncRule} from './readme-jsdoc-sync.js';
4+
5+
const {fixturePath} = createFixtures({
6+
'source/some-type-alias.d.ts': dedenter`
7+
/**
8+
Some description for \`MyAlias\` type.
9+
Note: This is a note.
10+
@example
11+
type MyAlias = string;
12+
*/
13+
export type MyAlias = string;
14+
`,
15+
'source/some-interface.d.ts': dedenter`
16+
/**
17+
Some description for \`MyInterface\` interface.
18+
This is second line.
19+
@category Test
20+
*/
21+
export interface MyInterface {
22+
prop: string;
23+
}
24+
`,
25+
'source/multiple-exports.d.ts': dedenter`
26+
/**
27+
First line for \`Multi\`.
28+
Second line for \`Multi\`.
29+
*/
30+
export type Multi = string;
31+
32+
/**
33+
Description for \`Other\`.
34+
*/
35+
export type Other = number;
36+
`,
37+
'source/hyphen.d.ts': dedenter`
38+
/**
39+
Contains a - inside.
40+
*/
41+
export type Hyphen = string;
42+
`,
43+
'source/complex-format.d.ts': dedenter`
44+
/**
45+
Description with [link to \`type-fest\`](https://github.com/sindresorhus/type-fest) and some \`code\`. And another sentence.
46+
@category Test
47+
*/
48+
export type ComplexFormat = string;
49+
`,
50+
'source/link-tag.d.ts': dedenter`
51+
/**
52+
Similar to {@link Exclude<T, U>} type.
53+
*/
54+
export type LinkTag = string;
55+
`,
56+
'source/noDoc.d.ts': dedenter`
57+
export type NoDoc = string;
58+
`,
59+
});
60+
61+
const ruleTester = createRuleTester({
62+
plugins: {markdown},
63+
});
64+
65+
const testCase = test => ({
66+
filename: fixturePath('readme.md'),
67+
language: 'markdown/commonmark',
68+
...test,
69+
});
70+
71+
ruleTester.run('readme-jsdoc-sync', readmeJSDocSyncRule, {
72+
valid: [
73+
// Type alias
74+
testCase({
75+
code: '- [`MyAlias`](source/some-type-alias.d.ts) - Some description for `MyAlias` type.',
76+
}),
77+
// Interface
78+
testCase({
79+
code: '- [`MyInterface`](source/some-interface.d.ts) - Some description for `MyInterface` interface.',
80+
}),
81+
// Multiple exports
82+
testCase({
83+
code: '- [`Multi`](source/multiple-exports.d.ts) - First line for `Multi`.',
84+
}),
85+
testCase({
86+
code: '- [`Other`](source/multiple-exports.d.ts) - Description for `Other`.',
87+
}),
88+
// Description containing a hyphen
89+
testCase({
90+
code: '- [`Hyphen`](source/hyphen.d.ts) - Contains a - inside.',
91+
}),
92+
// Description with links, inline code, and multiple sentences
93+
testCase({
94+
code: '- [`ComplexFormat`](source/complex-format.d.ts) - Description with [link to `type-fest`](https://github.com/sindresorhus/type-fest) and some `code`. And another sentence.',
95+
}),
96+
// Description with JSDoc link tag
97+
testCase({
98+
code: '- [`LinkTag`](source/link-tag.d.ts) - Similar to `Exclude<T, U>` type.',
99+
}),
100+
// Normal list item without a link
101+
testCase({
102+
code: '- Some normal list item.',
103+
}),
104+
// Non `.d.ts` link
105+
testCase({
106+
code: '- [`Partial<T>`](https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype) - Make all properties in `T` optional.',
107+
}),
108+
// `.d.ts` link with HTTP url
109+
testCase({
110+
code: '- [`Linter.Config`](https://github.com/eslint/eslint/blob/main/lib/types/index.d.ts) - Some description.',
111+
}),
112+
// Link is not the first element
113+
testCase({
114+
code: '- `Prettify`- See [`Simplify`](source/simplify.d.ts)',
115+
}),
116+
// Multiple list items
117+
testCase({
118+
code: dedenter`
119+
Some introduction paragraph.
120+
121+
122+
## Types
123+
124+
### Some group
125+
- [\`MyAlias\`](source/some-type-alias.d.ts) - Some description for \`MyAlias\` type.
126+
- [\`MyInterface\`](source/some-interface.d.ts) - Some description for \`MyInterface\` interface.
127+
128+
### Another group
129+
- [\`Multi\`](source/multiple-exports.d.ts) - First line for \`Multi\`.
130+
- [\`Other\`](source/multiple-exports.d.ts) - Description for \`Other\`.
131+
- [\`Hyphen\`](source/hyphen.d.ts) - Contains a - inside.
132+
133+
134+
## Alternatives
135+
- \`Prettify\`- See [\`Simplify\`](source/simplify.d.ts)
136+
`,
137+
}),
138+
],
139+
invalid: [
140+
testCase({
141+
// Mismatch between README description and source JSDoc
142+
code: dedenter`
143+
- [\`MyAlias\`](source/some-type-alias.d.ts) - Some description for MyAlias type.
144+
- [\`ComplexFormat\`](source/complex-format.d.ts) - Wrong description.
145+
- [\`LinkTag\`](source/link-tag.d.ts) - Similar to Exclude type.
146+
`,
147+
errors: [{messageId: 'mismatch'}, {messageId: 'mismatch'}, {messageId: 'mismatch'}],
148+
output: dedenter`
149+
- [\`MyAlias\`](source/some-type-alias.d.ts) - Some description for \`MyAlias\` type.
150+
- [\`ComplexFormat\`](source/complex-format.d.ts) - Description with [link to \`type-fest\`](https://github.com/sindresorhus/type-fest) and some \`code\`. And another sentence.
151+
- [\`LinkTag\`](source/link-tag.d.ts) - Similar to \`Exclude<T, U>\` type.
152+
`,
153+
}),
154+
// Linked `.d.ts` file does not exist
155+
testCase({
156+
code: '- [`Missing`](source/does-not-exist.d.ts) - Some description.',
157+
errors: [{messageId: 'fileNotFound'}],
158+
}),
159+
// Linked type has no JSDoc description
160+
testCase({
161+
code: '- [`NoDoc`](source/noDoc.d.ts) - Some description.',
162+
errors: [{messageId: 'missingTypeOrJSDoc'}],
163+
}),
164+
// Linked type does not exist
165+
testCase({
166+
code: '- [`Foo`](source/some-type-alias.d.ts) - Some description for `MyAlias` type.',
167+
errors: [{messageId: 'missingTypeOrJSDoc'}],
168+
}),
169+
],
170+
});

lint-rules/test-utils.js

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ const defaultTypeAwareTsconfig = {
4848
],
4949
};
5050

51-
export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => {
52-
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'type-fest-type-aware-'));
51+
export const createFixtures = fixtureFiles => {
52+
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'type-fest-fixtures-'));
5353

5454
const writeFixture = (relativePath, content) => {
5555
const absolutePath = path.join(fixtureRoot, relativePath);
@@ -61,6 +61,14 @@ export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => {
6161
writeFixture(relativePath, content);
6262
}
6363

64+
const fixturePath = relativePath => path.join(fixtureRoot, relativePath);
65+
66+
return {fixtureRoot, fixturePath, writeFixture};
67+
};
68+
69+
export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => {
70+
const fixture = createFixtures(fixtureFiles);
71+
6472
const hasRuleTesterOption = Object.hasOwn(options, 'ruleTester');
6573
const hasTsconfigOption = Object.hasOwn(options, 'tsconfig');
6674
const ruleTesterOverrides = hasRuleTesterOption || hasTsconfigOption ? options.ruleTester ?? {} : options;
@@ -69,7 +77,7 @@ export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => {
6977
: defaultTypeAwareTsconfig;
7078

7179
if (!('tsconfig.json' in fixtureFiles)) {
72-
writeFixture('tsconfig.json', `${JSON.stringify(tsconfig, null, '\t')}\n`);
80+
fixture.writeFixture('tsconfig.json', `${JSON.stringify(tsconfig, null, '\t')}\n`);
7381
}
7482

7583
const overrideLanguageOptions = ruleTesterOverrides.languageOptions ?? {};
@@ -85,19 +93,12 @@ export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => {
8593
allowDefaultProject: ['*.ts*'],
8694
...overrideProjectService,
8795
},
88-
tsconfigRootDir: fixtureRoot,
96+
tsconfigRootDir: fixture.fixtureRoot,
8997
},
9098
},
9199
});
92100

93-
const fixturePath = relativePath => path.join(fixtureRoot, relativePath);
94-
95-
return {
96-
ruleTester,
97-
fixtureRoot,
98-
fixturePath,
99-
writeFixture,
100-
};
101+
return {ruleTester, ...fixture};
101102
};
102103

103104
export const dedenter = dedent.withOptions({alignValues: true});

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"scripts": {
2828
"test:tsc": "node --max-old-space-size=6144 ./node_modules/.bin/tsc",
2929
"test:tsd": "node --max-old-space-size=6144 ./node_modules/.bin/tsd",
30-
"test:xo": "node --max-old-space-size=6144 ./node_modules/.bin/xo --ignores=lint-processors/fixtures/**/*.d.ts",
30+
"test:xo": "node --max-old-space-size=6144 ./node_modules/.bin/xo --ignores=lint-processors/fixtures/**/*.d.ts '**/*.{js,ts,md}'",
3131
"test:linter": "node --test",
3232
"test": "run-p test:*"
3333
},
@@ -53,7 +53,9 @@
5353
"tagged-tag": "^1.0.0"
5454
},
5555
"devDependencies": {
56+
"@eslint/markdown": "^8.0.1",
5657
"@sindresorhus/tsconfig": "^8.0.1",
58+
"@types/node": "^25.5.0",
5759
"@typescript-eslint/parser": "^8.44.0",
5860
"@typescript/vfs": "^1.6.1",
5961
"dedent": "^1.7.0",

0 commit comments

Comments
 (0)