Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/inherit-jsdoc-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-config-webpack": minor
---

Add the `webpack/inherit-jsdoc` rule and the `webpack/types` config.
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export default defineConfig([

### Webpack-specific configs

Two opt-in configs cover conventions only webpack's own repositories need. They
are not part of `recommended` — extend them explicitly:
Three opt-in configs cover conventions only webpack's own repositories need.
They are not part of `recommended` — extend them explicitly:

```js
import { defineConfig } from "eslint/config";
Expand All @@ -50,7 +50,12 @@ import configs from "eslint-config-webpack/configs.js";

export default defineConfig([
{
extends: [config, configs["webpack/special"], configs["webpack/schemas"]],
extends: [
config,
configs["webpack/special"],
configs["webpack/schemas"],
configs["webpack/types"],
],
},
]);
```
Expand All @@ -59,6 +64,7 @@ export default defineConfig([
| ----------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `webpack/special` | source files | `webpack/require-license-comment` — every file opens with the MIT license header. |
| `webpack/schemas` | `**/schemas/**/*.json` | `webpack/valid-schema` — the JSON schema conventions webpack's declaration and validator generators rely on. `webpack/format-schema` — key order, and definitions kept in sync with the base schema (autofixable). |
| `webpack/types` | `**/lib/**/*.{js,mjs,cjs}` | `webpack/inherit-jsdoc` — an overriding method carries the JSDoc of the method it overrides (autofixable). |

`webpack/valid-schema` accepts options for which keywords a schema may use.
`keywords` replaces the default set — webpack's own — outright, `allow` adds to
Expand All @@ -85,6 +91,30 @@ value, uses `instanceof` without `tsType`, `absolutePath` off a string or
`additionalProperties`, nests or mis-sizes `oneOf`/`anyOf`/`allOf`, or leaves a
property without a description starting in uppercase and ending in a single dot.

`webpack/inherit-jsdoc` needs type information, so `webpack/types` sets up
`@typescript-eslint/parser` with `projectService` — the project therefore needs
a `tsconfig.json` covering the linted files, and `typescript` installed. The
rule copies the base class method's JSDoc onto every override that is missing
it or has drifted from it, dropping `@abstract`. `stripTags` chooses which tags
are dropped:

```js
export default defineConfig([
{
extends: [configs["webpack/types"]],
rules: {
"webpack/inherit-jsdoc": ["error", { stripTags: ["abstract", "virtual"] }],
},
},
]);
```

Getters, setters, static methods and methods overriding a declaration file (so
`Object.toString` or `Set.add`) are left alone. Inheritance is resolved through
the whole chain, so restoring a JSDoc that several classes deep an override
inherits takes more than one `--fix` run — repeat it until the tree stops
changing.

[npm]: https://img.shields.io/npm/v/eslint-config-webpack.svg
[npm-url]: https://npmjs.com/package/eslint-config-webpack
[test]: https://github.com/webpack/eslint-config-webpack/actions/workflows/test.yml/badge.svg
Expand Down
4 changes: 4 additions & 0 deletions configs/webpack-special.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ const recommendedWebpackSpecialConfig = configs.recommended;
/** @type {import("eslint").Linter.Config} */
const webpackSchemasConfig = configs.schemas;

/** @type {import("eslint").Linter.Config} */
const webpackTypesConfig = configs.types;

export default {
"webpack/special": recommendedWebpackSpecialConfig,
"webpack/schemas": webpackSchemasConfig,
"webpack/types": webpackTypesConfig,
};
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export default defineConfig([
configs["node-recommended-commonjs"],
configs["webpack/special"],
configs["webpack/schemas"],
configs["webpack/types"],
],
},
{
Expand Down
27 changes: 26 additions & 1 deletion plugins/webpack/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRequire } from "node:module";
import * as parserJsonc from "jsonc-eslint-parser";
import { allExtensions } from "../../configs/utils/extensions.js";
import { rule as formatSchema } from "./rules/format-schema.js";
import { rule as inheritJsdoc } from "./rules/inherit-jsdoc.js";
import { rule as noDuplicateImportTag } from "./rules/no-duplicate-import-tag.js";
import { rule as noUnusedImportTag } from "./rules/no-unused-import-tag.js";
import { rule as preferImportTag } from "./rules/prefer-import-tag.js";
Expand All @@ -15,6 +16,7 @@ const { version } = require("../../package.json");

const rules = {
"format-schema": formatSchema,
"inherit-jsdoc": inheritJsdoc,
"no-duplicate-import-tag": noDuplicateImportTag,
"no-unused-import-tag": noUnusedImportTag,
"prefer-import-tag": preferImportTag,
Expand All @@ -31,7 +33,7 @@ const recommendedRules = {
),
};

/** @type {Record<"recommended" | "schemas", import("eslint").Linter.Config>} */
/** @type {Record<"recommended" | "schemas" | "types", import("eslint").Linter.Config>} */
const configs = {
recommended: {
name: "webpack/recommended",
Expand Down Expand Up @@ -61,6 +63,29 @@ const configs = {
"webpack/valid-schema": "error",
},
},
types: {
name: "webpack/types",
files: ["**/lib/**/*.{js,mjs,cjs}"],
languageOptions: {
// `typescript` is an optional peer dependency, so the parser that needs
// it is only resolved when this config is actually used.
get parser() {
return require("typescript-eslint").parser;
},
parserOptions: {
projectService: true,
},
},
plugins: {
get webpack() {
// eslint-disable-next-line no-use-before-define
return plugin;
},
},
rules: {
"webpack/inherit-jsdoc": "error",
},
},
};

const plugin = {
Expand Down
203 changes: 203 additions & 0 deletions plugins/webpack/rules/inherit-jsdoc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/** @typedef {import("eslint").Rule.RuleFixer} RuleFixer */
/** @typedef {import("eslint").Rule.Fix} Fix */
/** @typedef {import("estree").MethodDefinition} MethodDefinition */

// The TypeScript AST is reached through the parser services, which type them as
// `unknown` outside a TypeScript-aware parser.
// eslint-disable-next-line jsdoc/reject-any-type
/** @typedef {any} TSNode */

/**
* Reads the first JSDoc block attached to a TypeScript node.
* @param {TSNode} node the TypeScript node
* @returns {TSNode | undefined} the JSDoc node when the node carries one
*/
function getJSDoc(node) {
const { jsDoc } = node;

return jsDoc && jsDoc.length > 0 ? jsDoc[0] : undefined;
}

/**
* Drops the lines carrying one of the stripped tags and re-indents the block.
* @param {string} text the JSDoc text of the base class method
* @param {string} indent the indentation of the overriding method
* @param {string[]} stripTags tag names to drop, without the leading `@`
* @returns {string} the JSDoc text as it must appear on the overriding method
*/
function rewriteJSDoc(text, indent, stripTags) {
const stripped = new Set(
stripTags.map((tag) => new RegExp(`^\\s*\\*\\s*@${tag}\\b`)),
);
const lines = text.replaceAll(/\r\n?/g, "\n").split("\n");

return lines
.filter(
(line, index) =>
index === 0 || ![...stripped].some((regexp) => regexp.test(line)),
)
.map((line, index) =>
index === 0 ? line : `${indent} ${line.trimStart()}`,
)
.join("\n");
}

/**
* Finds the method of the same name on a base class of the given class.
* @param {TSNode} typeChecker the TypeScript type checker
* @param {TSNode} classNode the TypeScript class declaration or expression
* @param {string} memberName the name of the overriding method
* @param {number} memberKind the syntax kind a base declaration must share
* @returns {TSNode | undefined} the base class method declaration when found
*/
function findInBaseClass(typeChecker, classNode, memberName, memberKind) {
const classSymbol = classNode.name
? typeChecker.getSymbolAtLocation(classNode.name)
: classNode.symbol;

if (!classSymbol) {
return undefined;
}

const baseTypes =
typeChecker.getDeclaredTypeOfSymbol(classSymbol).getBaseTypes() || [];

for (const baseType of baseTypes) {
// `getProperty` walks the whole base chain, so an override three classes
// down from the documented method still resolves.
const property = baseType.getProperty(memberName);
const declaration =
property && property.declarations && property.declarations[0];

// A method declared in a `.d.ts` is `Object.toString` or `Set.add`, whose
// JSDoc documents the standard library rather than this class hierarchy.
if (
declaration &&
declaration.kind === memberKind &&
!declaration.getSourceFile().isDeclarationFile &&
getJSDoc(declaration)
) {
return declaration;
}
}

return undefined;
}

/**
* @type {import("eslint").Rule.RuleModule} rule rule
*/
export const rule = {
create(context) {
const { sourceCode } = context;
const services = sourceCode.parserServices;

if (!services || !services.program || !services.esTreeNodeToTSNodeMap) {
return {};
}

const { stripTags = ["abstract"] } = context.options[0] || {};
const typeChecker = services.program.getTypeChecker();

return {
MethodDefinition(node) {
// Getters and setters are left alone: their JSDoc documents the
// value, which an override is free to describe differently.
if (node.static || node.computed || node.kind !== "method") {
return;
}

const member = services.esTreeNodeToTSNodeMap.get(node);

if (!member || !member.parent) {
return;
}

const memberName = member.name.getText();
const baseMember = findInBaseClass(
typeChecker,
member.parent,
memberName,
member.kind,
);

if (!baseMember) {
return;
}

const start = member.getStart();
const [indent] = /** @type {RegExpMatchArray} */ (
sourceCode.lines[sourceCode.getLocFromIndex(start).line - 1].match(
/^\s*/,
)
);
const expected = rewriteJSDoc(
getJSDoc(baseMember).getText(),
indent,
stripTags,
);
const currentJSDoc = getJSDoc(member);

if (!currentJSDoc) {
context.report({
/** @type {(fixer: RuleFixer) => Fix} */
fix: (fixer) =>
fixer.insertTextBeforeRange(
[start, start],
`${expected}\n${indent}`,
),
messageId: "missing",
node: /** @type {MethodDefinition} */ (node).key,
});

return;
}

const current = currentJSDoc.getText().replaceAll(/\r\n?/g, "\n");

if (current === expected) {
return;
}

context.report({
/** @type {(fixer: RuleFixer) => Fix} */
fix: (fixer) =>
fixer.replaceTextRange(
[currentJSDoc.getStart(), currentJSDoc.getEnd()],
expected,
),
messageId: "outdated",
node: /** @type {MethodDefinition} */ (node).key,
});
},
};
},
meta: {
docs: {
category: "Best Practices",
description:
"Require a method to carry the JSDoc of the method it overrides",
recommended: false,
},
fixable: "code",
messages: {
missing:
"Expected the JSDoc of the base class method, this method overrides it.",
outdated:
"Expected this JSDoc to match the base class method it overrides.",
},
schema: [
{
additionalProperties: false,
properties: {
stripTags: {
items: { type: "string" },
type: "array",
},
},
type: "object",
},
],
type: "suggestion",
},
};
7 changes: 4 additions & 3 deletions plugins/webpack/rules/require-license-comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const rule = {
"Program:exit"(program) {
const comments = sourceCode.getAllComments();
const licenseComment =
/** @type {(Comment & { start: number, end: number }) | undefined} */
/** @type {(Comment & { range: [number, number] }) | undefined} */
(
comments.find(
(comment) =>
Expand All @@ -32,7 +32,8 @@ export const rule = {
return;
}

const afterComment = sourceCode.text[licenseComment.end];
const [, end] = licenseComment.range;
const afterComment = sourceCode.text[end];

if (afterComment !== "\n") {
context.report({
Expand All @@ -43,7 +44,7 @@ export const rule = {
return;
}

const afterAfterComment = sourceCode.text[licenseComment.end + 1];
const afterAfterComment = sourceCode.text[end + 1];

if (afterAfterComment !== "\n") {
context.report({
Expand Down
Loading