Skip to content

Commit 6aa3d57

Browse files
committed
feat: add lint rule to validate type descriptions in README.md
1 parent 7761f91 commit 6aa3d57

File tree

6 files changed

+277
-15
lines changed

6 files changed

+277
-15
lines changed

lint-rules/readme-jsdoc-sync.js

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

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
@@ -53,11 +53,13 @@
5353
"tagged-tag": "^1.0.0"
5454
},
5555
"devDependencies": {
56+
"@eslint/markdown": "^7.5.1",
5657
"@sindresorhus/tsconfig": "^8.0.1",
58+
"@types/node": "^25.5.0",
5759
"@typescript-eslint/parser": "^8.44.0",
58-
"eslint": "^9.35.0",
5960
"@typescript/vfs": "^1.6.1",
6061
"dedent": "^1.7.0",
62+
"eslint": "^9.35.0",
6163
"expect-type": "^1.2.2",
6264
"npm-run-all2": "^8.0.4",
6365
"tsd": "^0.33.0",

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"noEmit": true,
55
"allowJs": true,
66
"noUnusedLocals": false, // Allow unused variables in test-d/*.ts files
7-
"types": [], // Ensures no @types/ are unintentionally included
7+
"types": ["node"],
88
"exactOptionalPropertyTypes": true,
99
"skipLibCheck": false, // Ensures .d.ts files are checked: https://github.com/sindresorhus/tsconfig/issues/15
1010
"erasableSyntaxOnly": false // We cannot do this as we need to be able to test enums.

xo.config.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
// @ts-check
2+
import markdown from '@eslint/markdown';
23
import tseslint from 'typescript-eslint';
34
import {importPathRule} from './lint-rules/import-path.js';
45
import {sourceFilesExtensionRule} from './lint-rules/source-files-extension.js';
56
import {requireExportedTypesRule} from './lint-rules/require-exported-types.js';
67
import {requireExportRule} from './lint-rules/require-export.js';
78
import {validateJSDocCodeblocksRule} from './lint-rules/validate-jsdoc-codeblocks.js';
9+
import {readmeJSDocSyncRule} from './lint-rules/readme-jsdoc-sync.js';
810
import {jsdocCodeblocksProcessor} from './lint-processors/jsdoc-codeblocks.js';
911

10-
/** @type {import('xo').FlatXoConfig} */
12+
/** @type {Array<import('xo').XoConfigItem | import('@eslint/core').ConfigObject>} */
1113
const xoConfig = [
1214
{
1315
rules: {
@@ -55,6 +57,7 @@ const xoConfig = [
5557
},
5658
},
5759
{
60+
files: ['**/*'],
5861
plugins: {
5962
'type-fest': {
6063
rules: {
@@ -63,6 +66,7 @@ const xoConfig = [
6366
'require-exported-types': requireExportedTypesRule,
6467
'require-export': requireExportRule,
6568
'validate-jsdoc-codeblocks': validateJSDocCodeblocksRule,
69+
'readme-jsdoc-sync': readmeJSDocSyncRule,
6670
},
6771
processors: {
6872
'jsdoc-codeblocks': jsdocCodeblocksProcessor,
@@ -137,6 +141,16 @@ const xoConfig = [
137141
],
138142
},
139143
},
144+
{
145+
files: ['readme.md'],
146+
language: 'markdown/commonmark',
147+
plugins: {
148+
markdown,
149+
},
150+
rules: {
151+
'type-fest/readme-jsdoc-sync': 'error',
152+
},
153+
},
140154
];
141155

142156
export default xoConfig;

0 commit comments

Comments
 (0)