Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions lint-rules/readme-jsdoc-sync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// @ts-check
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';

/** @type {import('@eslint/markdown').MarkdownRuleDefinition} */
export const readmeJSDocSyncRule = {
meta: {
type: 'suggestion',
language: 'markdown/commonmark',
docs: {
description: 'Enforces that type descriptions in the README exactly match the first line of their source JSDoc.',
},
fixable: 'code',
messages: {
mismatch: 'Type description does not match the source JSDoc.\n\nExpected: {{expected}}\n\nFound: {{actual}}',
fileNotFound: 'Linked file `{{filePath}}` not found.',
},
schema: [],
},
create(context) {
if (path.basename(context.filename) !== 'readme.md') {
return {};
}

return {
listItem(node) {
const paragraph = node.children.find(child => child.type === 'paragraph');
if (!paragraph) {
return;
}

const linkNode = paragraph.children[0];
if (linkNode?.type !== 'link' || !linkNode?.url.endsWith('.d.ts')) {
return;
}

const inlineCodeNode = linkNode.children[0];
if (inlineCodeNode?.type !== 'inlineCode') {
return;
}

const typeName = inlineCodeNode.value;
const typeDescription = context.sourceCode.getText(paragraph).split(' - ').slice(1).join(' - ');

const absolutePath = path.resolve(path.dirname(context.filename), linkNode.url);

let sourceContent;
try {
sourceContent = fs.readFileSync(absolutePath, 'utf8');
} catch {
return context.report({
node: linkNode,
messageId: 'fileNotFound',
data: {
filePath: linkNode.url,
},
});
}

const sourceFile = ts.createSourceFile(linkNode.url, sourceContent, ts.ScriptTarget.Latest, true);
const jsDocFirstLine = ts.forEachChild(sourceFile, node => {
if ((ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) && node.name.text === typeName) {
const jsDocs = ts.getJSDocCommentsAndTags(node);
return ts.getTextOfJSDocComment(jsDocs[0]?.comment)?.split('\n')[0];
}

return undefined;
});

if (jsDocFirstLine && typeDescription !== jsDocFirstLine) {
context.report({
node,
messageId: 'mismatch',
data: {
expected: jsDocFirstLine,
actual: typeDescription,
},
fix(fixer) {
return fixer.replaceText(
paragraph,
`${context.sourceCode.getText(linkNode)} - ${jsDocFirstLine}`,
);
},
});
}
},
};
},
};

154 changes: 154 additions & 0 deletions lint-rules/readme-jsdoc-sync.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import markdown from '@eslint/markdown';
import {createRuleTester, createFixtures, dedenter} from './test-utils.js';
import {readmeJSDocSyncRule} from './readme-jsdoc-sync.js';

const {fixturePath} = createFixtures({
'source/some-test.d.ts': dedenter`
/**
Some description for \`Test\` type.
Note: This is a note.
@example
type Test = string;
*/
export type Test = string;
`,
'source/some-interface.d.ts': dedenter`
/**
Some description for \`MyInterface\` interface.
This is second line.
@category Test
*/
export interface MyInterface {
prop: string;
}
`,
'source/multiple-exports.d.ts': dedenter`
/**
First line for \`Multi\`.
Second line for \`Multi\`.
*/
export type Multi = string;

/**
Description for \`Other\`.
*/
export type Other = number;
`,
'source/noDoc.d.ts': dedenter`
export type NoDoc = string;
`,
'source/hyphen.d.ts': dedenter`
/**
Contains a - inside.
*/
export type Hyphen = string;
`,
'source/complex-format.d.ts': dedenter`
/**
Description with [link to \`type-fest\`](https://github.com/sindresorhus/type-fest) and some \`code\`. And another sentence.
@category Test
*/
export type ComplexFormat = string;
`,
'source/asterisk-prefix.d.ts': dedenter`
/**
* Some description.
*/
type Test = string;
export type AsteriskPrefix = string;
`,
});

const ruleTester = createRuleTester({
plugins: {markdown},
});

const testCase = test => ({
filename: fixturePath('readme.md'),
language: 'markdown/commonmark',
...test,
});

ruleTester.run('readme-jsdoc-sync', readmeJSDocSyncRule, {
valid: [
// Type alias
testCase({
code: '- [`Test`](source/some-test.d.ts) - Some description for `Test` type.',
}),
// Interface
testCase({
code: '- [`MyInterface`](source/some-interface.d.ts) - Some description for `MyInterface` interface.',
}),
// Multiple exports
testCase({
code: '- [`Multi`](source/multiple-exports.d.ts) - First line for `Multi`.',
}),
testCase({
code: '- [`Other`](source/multiple-exports.d.ts) - Description for `Other`.',
}),
// Description containing a hyphen
testCase({
code: '- [`Hyphen`](source/hyphen.d.ts) - Contains a - inside.',
}),
// Description with links, inline code, and multiple sentences
testCase({
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.',
}),
// JSDoc with asterisk prefix style
testCase({
code: '- [`AsteriskPrefix`](source/asterisk-prefix.d.ts) - Some description.',
}),
// Normal list item without a link
testCase({
code: '- Some normal list item.',
}),
// Non `.d.ts` link
testCase({
code: '- [`Partial<T>`](https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype) - Make all properties in `T` optional.',
}),
// Link is not the first element
testCase({
code: '- `Prettify`- See [`Simplify`](source/simplify.d.ts)',
}),
// Multiple list items
testCase({
code: dedenter`
Some introduction paragraph.

## Types

### Some group
- [\`Test\`](source/some-test.d.ts) - Some description for \`Test\` type.
- [\`MyInterface\`](source/some-interface.d.ts) - Some description for \`MyInterface\` interface.

### Another group
- [\`Multi\`](source/multiple-exports.d.ts) - First line for \`Multi\`.
- [\`Other\`](source/multiple-exports.d.ts) - Description for \`Other\`.
- [\`Hyphen\`](source/hyphen.d.ts) - Contains a - inside.


## Alternatives
- \`Prettify\`- See [\`Simplify\`](source/simplify.d.ts)
`,
}),
],
invalid: [
testCase({
// Mismatch between README description and source JSDoc
code: dedenter`
- [\`Test\`](source/some-test.d.ts) - Some description for Test type.
- [\`ComplexFormat\`](source/complex-format.d.ts) - Wrong description.
`,
errors: [{messageId: 'mismatch'}, {messageId: 'mismatch'}],
output: dedenter`
- [\`Test\`](source/some-test.d.ts) - Some description for \`Test\` type.
- [\`ComplexFormat\`](source/complex-format.d.ts) - Description with [link to \`type-fest\`](https://github.com/sindresorhus/type-fest) and some \`code\`. And another sentence.
`,
}),
// Linked `.d.ts` file does not exist
testCase({
code: '- [`Missing`](source/does-not-exist.d.ts) - Some description.',
errors: [{messageId: 'fileNotFound'}],
}),
],
});
25 changes: 13 additions & 12 deletions lint-rules/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ const defaultTypeAwareTsconfig = {
],
};

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

const writeFixture = (relativePath, content) => {
const absolutePath = path.join(fixtureRoot, relativePath);
Expand All @@ -61,6 +61,14 @@ export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => {
writeFixture(relativePath, content);
}

const fixturePath = relativePath => path.join(fixtureRoot, relativePath);

return {fixtureRoot, fixturePath, writeFixture};
};

export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => {
const fixture = createFixtures(fixtureFiles);

const hasRuleTesterOption = Object.hasOwn(options, 'ruleTester');
const hasTsconfigOption = Object.hasOwn(options, 'tsconfig');
const ruleTesterOverrides = hasRuleTesterOption || hasTsconfigOption ? options.ruleTester ?? {} : options;
Expand All @@ -69,7 +77,7 @@ export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => {
: defaultTypeAwareTsconfig;

if (!('tsconfig.json' in fixtureFiles)) {
writeFixture('tsconfig.json', `${JSON.stringify(tsconfig, null, '\t')}\n`);
fixture.writeFixture('tsconfig.json', `${JSON.stringify(tsconfig, null, '\t')}\n`);
}

const overrideLanguageOptions = ruleTesterOverrides.languageOptions ?? {};
Expand All @@ -85,19 +93,12 @@ export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => {
allowDefaultProject: ['*.ts*'],
...overrideProjectService,
},
tsconfigRootDir: fixtureRoot,
tsconfigRootDir: fixture.fixtureRoot,
},
},
});

const fixturePath = relativePath => path.join(fixtureRoot, relativePath);

return {
ruleTester,
fixtureRoot,
fixturePath,
writeFixture,
};
return {ruleTester, ...fixture};
};

export const dedenter = dedent.withOptions({alignValues: true});
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
"tagged-tag": "^1.0.0"
},
"devDependencies": {
"@eslint/markdown": "^8.0.1",
"@sindresorhus/tsconfig": "^8.0.1",
"@types/node": "^25.5.0",
"@typescript-eslint/parser": "^8.44.0",
"@typescript/vfs": "^1.6.1",
"dedent": "^1.7.0",
Expand Down
Loading
Loading