Skip to content

Commit

Permalink
feat(no-redundant-files): add new rule
Browse files Browse the repository at this point in the history
This change adds a new rule for preventing inclusion of unnecessary or redundant files in a package.json's `files` list.  It checks for two primary types of errors:
1. Duplicate entries within the files array (the same file listed more than once)
1. Files that are automatically included by npm, and don't need to be explicitly included.

Of the second type, there are two flavors that npm includes automatically
1. Files that are always included, regardless of what else is present in the package.json (e.g. README.md)
1. Files that are included because they're declared in other places in the package (e.g. file declared as the `main` entry)
  • Loading branch information
michaelfaith committed Jan 17, 2025
1 parent 36ae418 commit 062e1e6
Show file tree
Hide file tree
Showing 6 changed files with 611 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ The default settings don't conflict, and Prettier plugins can quickly fix up ord

| Name                       | Description | 💼 | 🔧 | 💡 ||
| :--------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------ | :- | :- | :- | :- |
| [no-redundant-files](docs/rules/no-redundant-files.md) | Prevents adding unnecessary / redundant files. | | | 💡 | |
| [order-properties](docs/rules/order-properties.md) | Package properties must be declared in standard order || 🔧 | | |
| [repository-shorthand](docs/rules/repository-shorthand.md) | Enforce either object or shorthand declaration for repository. || 🔧 | | |
| [sort-collections](docs/rules/sort-collections.md) | Dependencies, scripts, and configuration values must be declared in alphabetical order. || 🔧 | | |
Expand Down
40 changes: 40 additions & 0 deletions docs/rules/no-redundant-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# no-redundant-files

💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).

<!-- end auto-generated rule header -->

This rule checks that the `files` property of a `package.json` doesn't contain
any redundant or unnecessary file entries. By default, [npm will automatically](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#files)
include certain files, based on a number of circumstances.

It will always include the following files, if present:

- `package.json`
- `README`
<!-- cspell:disable-next-line -->
- `LICENSE` / `LICENCE`

Additionally, it will include any files that are declared in the `main` and `bin`
fields of the `package.json`.

This rule will check that the `files` don't contain any of the above. It will
also check for duplicate entries.

Example of **incorrect** code for this rule:

```json
{
"files": ["README.md", "CHANGELOG.md", "lib/index.js"],
"main": "lib/index.js"
}
```

Example of **correct** code for this rule:

```json
{
"files": ["CHANGELOG.md"],
"main": "lib/index.js"
}
```
10 changes: 10 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ module.exports = tseslint.config(
"no-useless-rename": "error",
"object-shorthand": "error",
"operator-assignment": "error",
"perfectionist/sort-objects": [
"error",
{
customGroups: {
programExit: "Program:exit",
},
groups: ["unknown", "programExit"],
type: "alphabetical",
},
],
},
settings: {
perfectionist: { partitionByComment: true, type: "natural" },
Expand Down
2 changes: 2 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createRequire } from "node:module";

import type { PackageJsonRuleModule } from "./createRule.js";

import { rule as noRedundantFiles } from "./rules/no-redundant-files.js";
import { rule as orderProperties } from "./rules/order-properties.js";
import { rule as preferRepositoryShorthand } from "./rules/repository-shorthand.js";
import { rule as sortCollections } from "./rules/sort-collections.js";
Expand All @@ -20,6 +21,7 @@ const { name, version } = require("../package.json") as {
};

const rules: Record<string, PackageJsonRuleModule> = {
"no-redundant-files": noRedundantFiles,
"order-properties": orderProperties,
"repository-shorthand": preferRepositoryShorthand,
"sort-collections": sortCollections,
Expand Down
229 changes: 229 additions & 0 deletions src/rules/no-redundant-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import type { AST as JsonAST } from "jsonc-eslint-parser";

import * as ESTree from "estree";

import { createRule } from "../createRule.js";
import { isJSONStringLiteral, isNotNullish } from "../utils/predicates.js";

const defaultFiles = [
/* cspell:disable-next-line */
"LICENCE",
/* cspell:disable-next-line */
"LICENCE.md",
"LICENSE",
"LICENSE.md",
"package.json",
"README.md",
] as const;

const cachedRegex = new Map<string, RegExp>();
const getCachedLocalFileRegex = (filename: string) => {
// Strip the leading `./`, if there is one, since we'll be incorporating
// it into the regex.
const baseFilename = filename.replace("./", "");
let regex = cachedRegex.get(baseFilename);
if (regex) {
return regex;
} else {
regex = new RegExp(`^(./)?${baseFilename}$`, "i");
cachedRegex.set(baseFilename, regex);
return regex;
}
};

export const rule = createRule({
create(context) {
// We need to cache these as we find them, since we need to know some of
// the other values to ensure that files doesn't contain duplicates.
const entryCache: {
bin: string[];
files: (JsonAST.JSONExpression | null)[];
main?: string;
} = { bin: [], files: [] };

/**
* Report rule violations
*/
const report = (
elements: (JsonAST.JSONExpression | null)[],
index: number,
messageId: string,
) => {
const element = elements[index];

if (isNotNullish(element) && isJSONStringLiteral(element)) {
context.report({
data: { file: element.value },
messageId,
node: element as unknown as ESTree.Node,
suggest: [
{
*fix(fixer) {
yield fixer.remove(
element as unknown as ESTree.Node,
);

// If this is not the last entry, then we need to remove the comma from this line.
const tokenFromCurrentLine =
context.sourceCode.getTokenAfter(
element as unknown as ESTree.Node,
);
if (tokenFromCurrentLine?.value === ",") {
yield fixer.remove(tokenFromCurrentLine);
}

// If this is the last line and it's not the only entry, then the line above this one
// will become the last line, and should not have a trailing comma.
if (
index > 0 &&
tokenFromCurrentLine?.value !== ","
) {
const tokenFromPreviousLine =
context.sourceCode.getTokenAfter(
elements[
index - 1
] as unknown as ESTree.Node,
);
if (tokenFromPreviousLine?.value === ",") {
yield fixer.remove(
tokenFromPreviousLine,
);
}
}
},
messageId: "remove",
},
],
});
}
};

return {
"Program > JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=bin]"(
node: JsonAST.JSONProperty,
) {
const binValue = node.value;

// "bin" can be either a simple string or a map of commands to files.
// If it's anything else, then this is malformed and we can't really
// do anything with it.
if (isJSONStringLiteral(binValue)) {
entryCache.bin.push(binValue.value);
} else if (binValue.type === "JSONObjectExpression") {
for (const prop of binValue.properties) {
if (isJSONStringLiteral(prop.value)) {
entryCache.bin.push(prop.value.value);
}
}
}
},
"Program > JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=files]"(
node: JsonAST.JSONProperty,
) {
// "files" should only ever be an array of strings.
if (node.value.type === "JSONArrayExpression") {
// We want to add it to the files cache, but also check for
// duplicates as we go.
const seen = new Set<string>();
const elements = node.value.elements;
entryCache.files = elements;
for (const [index, element] of elements.entries()) {
// We only care about JSONStringLiteral values
// That _should_ be all that's here, be in order to process
// the fix correctly we'll act on the full array of elements
if (
isNotNullish(element) &&
isJSONStringLiteral(element)
) {
if (seen.has(element.value)) {
report(elements, index, "duplicate");
} else {
seen.add(element.value);
}

// We can also go ahead and check if this matches one
// of the static default files
const regex = getCachedLocalFileRegex(
element.value,
);
for (const defaultFile of defaultFiles) {
if (regex.test(defaultFile)) {
report(
elements,
index,
"unnecessaryDefault",
);
}
}
}
}
}
},
"Program > JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=main]"(
node: JsonAST.JSONProperty,
) {
// "main" should only ever be a string.
if (isJSONStringLiteral(node.value)) {
entryCache.main = node.value.value;
}
},
"Program:exit"() {
// Now that we have all of the entries, we can check for unnecessary files.
const files = entryCache.files;

// Bail out early if there are no files.
if (files.length === 0) {
return;
}

const validations = [
// First check if the "main" entry is included in "files".
{
files: entryCache.main ? [entryCache.main] : [],
messageId: "unnecessaryMain",
},
// Next check if any "bin" entries are included in "files".
{
files: entryCache.bin,
messageId: "unnecessaryBin",
},
];
for (const validation of validations) {
for (const fileToCheck of validation.files) {
for (const [index, fileEntry] of files.entries()) {
if (
isNotNullish(fileEntry) &&
isJSONStringLiteral(fileEntry)
) {
const regex = getCachedLocalFileRegex(
fileEntry.value,
);
if (regex.test(fileToCheck)) {
report(files, index, validation.messageId);
}
}
}
}
}
},
};
},

meta: {
docs: {
category: "Best Practices",
description: "Prevents adding unnecessary / redundant files.",
recommended: false,
},
hasSuggestions: true,
messages: {
duplicate: 'Files has more than one entry for "{{file}}".',
remove: "Remove this redundant entry.",
unnecessaryBin: `Explicitly declaring "{{file}}" in "files" is unnecessary; it's included in "bin".`,
unnecessaryDefault: `Explicitly declaring "{{file}}" in "files" is unnecessary; it's included by default.`,
unnecessaryMain: `Explicitly declaring "{{file}}" in "files" is unnecessary; it's the "main" entry.`,
},
schema: [],
type: "suggestion",
},
});
Loading

0 comments on commit 062e1e6

Please sign in to comment.