diff --git a/.README/rules/imports-as-dependencies.md b/.README/rules/imports-as-dependencies.md new file mode 100644 index 000000000..96850fa24 --- /dev/null +++ b/.README/rules/imports-as-dependencies.md @@ -0,0 +1,14 @@ +### `imports-as-dependencies` + +This rule will report an issue if JSDoc `import()` statements point to a package +which is not listed in `dependencies` or `devDependencies`. + +||| +|---|---| +|Context|everywhere| +|Tags|``| +|Recommended|false| +|Settings|| +|Options|| + + diff --git a/docs/rules/imports-as-dependencies.md b/docs/rules/imports-as-dependencies.md new file mode 100644 index 000000000..d59f92286 --- /dev/null +++ b/docs/rules/imports-as-dependencies.md @@ -0,0 +1,16 @@ + + +### imports-as-dependencies + +This rule will report an issue if JSDoc `import()` statements point to a package +which is not listed in `dependencies` or `devDependencies`. + +||| +|---|---| +|Context|everywhere| +|Tags|``| +|Recommended|false| +|Settings|| +|Options|| + + diff --git a/src/index.js b/src/index.js index 83d420796..1895b03c5 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import checkTypes from './rules/checkTypes'; import checkValues from './rules/checkValues'; import emptyTags from './rules/emptyTags'; import implementsOnClasses from './rules/implementsOnClasses'; +import importsAsDependencies from './rules/importsAsDependencies'; import informativeDocs from './rules/informativeDocs'; import matchDescription from './rules/matchDescription'; import matchName from './rules/matchName'; @@ -70,6 +71,7 @@ const index = { 'check-values': checkValues, 'empty-tags': emptyTags, 'implements-on-classes': implementsOnClasses, + 'imports-as-dependencies': importsAsDependencies, 'informative-docs': informativeDocs, 'match-description': matchDescription, 'match-name': matchName, @@ -135,6 +137,7 @@ const createRecommendedRuleset = (warnOrError) => { 'jsdoc/check-values': warnOrError, 'jsdoc/empty-tags': warnOrError, 'jsdoc/implements-on-classes': warnOrError, + 'jsdoc/imports-as-dependencies': 'off', 'jsdoc/informative-docs': 'off', 'jsdoc/match-description': 'off', 'jsdoc/match-name': 'off', diff --git a/src/rules/importsAsDependencies.js b/src/rules/importsAsDependencies.js new file mode 100644 index 000000000..a740a9511 --- /dev/null +++ b/src/rules/importsAsDependencies.js @@ -0,0 +1,82 @@ +import iterateJsdoc from '../iterateJsdoc'; +import { + parse, + traverse, + tryParse, +} from '@es-joy/jsdoccomment'; +import { + readFileSync, +} from 'fs'; +import { + join, +} from 'path'; + +/** + * @type {Set} + */ +let deps; +try { + const pkg = JSON.parse( + // @ts-expect-error It's ok + readFileSync(join(process.cwd(), './package.json')), + ); + deps = new Set([ + ...(pkg.dependencies ? + Object.keys(pkg.dependencies) : + // istanbul ignore next + []), + ...(pkg.devDependencies ? + Object.keys(pkg.devDependencies) : + // istanbul ignore next + []), + ]); +} catch (error) { + /* eslint-disable no-console -- Inform user */ + // istanbul ignore next + console.log(error); + /* eslint-enable no-console -- Inform user */ +} + +export default iterateJsdoc(({ + jsdoc, + settings, + utils, +}) => { + // istanbul ignore if + if (!deps) { + return; + } + + const { + mode, + } = settings; + + for (const tag of jsdoc.tags) { + let typeAst; + try { + typeAst = mode === 'permissive' ? tryParse(tag.type) : parse(tag.type, mode); + } catch { + continue; + } + + traverse(typeAst, (nde) => { + if (nde.type === 'JsdocTypeImport' && !deps.has(nde.element.value.replace( + /(@[^/]+\/[^/]+|[^/]+).*$/u, '$1', + ))) { + utils.reportJSDoc( + 'import points to package which is not found in dependencies', + tag, + ); + } + }); + } +}, { + iterateAllJsdocs: true, + meta: { + docs: { + description: 'Reports if JSDoc `import()` statements point to a package which is not listed in `dependencies` or `devDependencies`', + url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/imports-as-dependencies.md#repos-sticky-header', + }, + type: 'suggestion', + }, +}); diff --git a/test/rules/assertions/checkExamples.js b/test/rules/assertions/checkExamples.js index 1c319a9a0..efc829d9c 100644 --- a/test/rules/assertions/checkExamples.js +++ b/test/rules/assertions/checkExamples.js @@ -1,6 +1,3 @@ -// Change `process.cwd()` when testing `checkEslintrc: true` -process.chdir('test/rules/data'); - export default { invalid: [ { diff --git a/test/rules/assertions/importsAsDependencies.js b/test/rules/assertions/importsAsDependencies.js new file mode 100644 index 000000000..321a6c96b --- /dev/null +++ b/test/rules/assertions/importsAsDependencies.js @@ -0,0 +1,91 @@ +export default { + invalid: [ + { + code: ` + /** + * @type {null|import('sth').SomeApi} + */ + `, + errors: [ + { + line: 3, + message: 'import points to package which is not found in dependencies', + }, + ], + }, + { + code: ` + /** + * @type {null|import('sth').SomeApi} + */ + `, + errors: [ + { + line: 3, + message: 'import points to package which is not found in dependencies', + }, + ], + settings: { + jsdoc: { + mode: 'permissive', + }, + }, + }, + { + code: ` + /** + * @type {null|import('missingpackage/subpackage').SomeApi} + */ + `, + errors: [ + { + line: 3, + message: 'import points to package which is not found in dependencies', + }, + ], + }, + { + code: ` + /** + * @type {null|import('@sth/pkg').SomeApi} + */ + `, + errors: [ + { + line: 3, + message: 'import points to package which is not found in dependencies', + }, + ], + }, + ], + valid: [ + { + code: ` + /** + * @type {null|import('eslint').ESLint} + */ + `, + }, + { + code: ` + /** + * @type {null|import('eslint/use-at-your-own-risk').ESLint} + */ + `, + }, + { + code: ` + /** + * @type {null|import('@es-joy/jsdoccomment').InlineTag} + */ + `, + }, + { + code: ` + /** + * @type {null|import(} + */ + `, + }, + ], +}; diff --git a/test/rules/index.js b/test/rules/index.js index b181d7dc5..07475543b 100644 --- a/test/rules/index.js +++ b/test/rules/index.js @@ -25,6 +25,7 @@ const { FlatRuleTester, } = pkg; +// eslint-disable-next-line complexity -- Temporary const main = async () => { const ruleNames = JSON.parse(readFileSync(join(__dirname, './ruleNames.json'), 'utf8')); @@ -148,7 +149,17 @@ const main = async () => { } } + const cwd = process.cwd(); + if (ruleName === 'check-examples') { + // Change `process.cwd()` when testing `checkEslintrc: true` + process.chdir('test/rules/data'); + } + ruleTester.run(ruleName, rule, assertions); + + if (ruleName === 'check-examples') { + process.chdir(cwd); + } } if (!process.env.npm_config_rule) { diff --git a/test/rules/ruleNames.json b/test/rules/ruleNames.json index 7cff81eab..24038af51 100644 --- a/test/rules/ruleNames.json +++ b/test/rules/ruleNames.json @@ -12,6 +12,7 @@ "check-values", "empty-tags", "implements-on-classes", + "imports-as-dependencies", "informative-docs", "match-description", "match-name", diff --git a/tsconfig.json b/tsconfig.json index 98579a525..49dffe62a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "declarationMap": true, "allowSyntheticDefaultImports": true, "strict": true, - "target": "es6", + "target": "es2017", "outDir": "dist" }, "include": [