Repository: JoshuaKGoldberg/eslint-plugin-package-json (ts)

Issue:
🚀 Feature: Bring in a require-engines rule
### Bug Report Checklist

- [x] I have tried restarting my IDE and the issue persists.
- [x] I have pulled the latest `main` branch of the repository.
- [x] I have [searched for related issues](https://github.com/JoshuaKGoldberg/eslint-plugin-package-json/issues?q=is%3Aissue) and found none that matched my issue.

### Overview

Following #42 -> #51: we've resolved to create a standalone `require-engines` rule. It should enforce that the field exists in the `package.json` file.


### Additional Info

💖 

Pull request description:
feat(require-engines): add new rule
<!-- 👋 Hi, thanks for sending a PR to eslint-plugin-package-json! 💖.
Please fill out all fields below and make sure each item is true and [x] checked.
Otherwise we may not be able to review your PR. -->

## PR Checklist

-   [x] Addresses an existing open issue: fixes #868 
-   [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/eslint-plugin-package-json/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
-   [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/eslint-plugin-package-json/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

This change adds a new `require-engines` rule.


Patch:
diff --git a/.eslint-doc-generatorrc.js b/.eslint-doc-generatorrc.js
new file mode 100644
index 0000000..cfe70d1
--- /dev/null
+++ b/.eslint-doc-generatorrc.js
@@ -0,0 +1,6 @@
+/** @type {import('eslint-doc-generator').GenerateOptions} */
+const config = {
+	ignoreConfig: ["legacy-recommended"],
+};
+
+module.exports = config;
diff --git a/.github/workflows/lint-eslint-docs.yml b/.github/workflows/lint-eslint-docs.yml
index e9c23e7..eddf79a 100644
--- a/.github/workflows/lint-eslint-docs.yml
+++ b/.github/workflows/lint-eslint-docs.yml
@@ -4,7 +4,6 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - uses: ./.github/actions/prepare
-      - run: npm run build
       - run: npm run lint:eslint-docs
 
 name: Lint ESLint Docs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41839ae..aeebb84 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Changelog
 
+# [0.27.0](https://github.com/JoshuaKGoldberg/eslint-plugin-package-json/compare/v0.26.4...v0.27.0) (2025-03-17)
+
+### Features
+
+-   empty feature commit to trigger new minor version ([b6b58d4](https://github.com/JoshuaKGoldberg/eslint-plugin-package-json/commit/b6b58d46da07dd5f1a51e5b2c71a10803863b113))
+
+## [0.26.4](https://github.com/JoshuaKGoldberg/eslint-plugin-package-json/compare/v0.26.3...v0.26.4) (2025-03-17)
+
 ## [0.26.3](https://github.com/JoshuaKGoldberg/eslint-plugin-package-json/compare/v0.26.2...v0.26.3) (2025-03-07)
 
 ### Bug Fixes
diff --git a/README.md b/README.md
index 6a866d8..5f51539 100644
--- a/README.md
+++ b/README.md
@@ -34,25 +34,24 @@ This plugin's recommended configuration enables its rules on `**/package.json` f
 In your ESLint configuration file:
 
 ```ts
-import packageJson from "eslint-plugin-package-json/configs/recommended";
+import packageJson from "eslint-plugin-package-json";
 
 export default [
 	// your other ESLint configurations
-	packageJson,
+	packageJson.configs.recommended,
 ];
 ```
 
 If you want to override the recommended rules:
 
 ```ts
-import packageJson from "eslint-plugin-package-json/configs/recommended";
+import packageJson from "eslint-plugin-package-json";
 
 export default [
 	// your other ESLint configurations
+	packageJson.configs.recommended,
 	{
-		...packageJson,
 		rules: {
-			...packageJson.rules,
 			"package-json/valid-package-definition": "off",
 		},
 	},
@@ -75,10 +74,9 @@ Add an override to your ESLint configuration file that specifies `jsonc-eslint-p
 module.exports = {
 	overrides: [
 		{
-			extends: ["plugin:package-json/recommended"],
+			extends: ["plugin:package-json/legacy-recommended"],
 			files: ["package.json"],
 			parser: "jsonc-eslint-parser",
-			plugins: ["package-json"],
 		},
 	],
 };
@@ -91,10 +89,9 @@ See [ESLint's _Configure Rules_ guide](https://eslint.org/docs/latest/use/config
 module.exports = {
 	overrides: [
 		{
-			extends: ["plugin:package-json/recommended"],
+			extends: ["plugin:package-json/legacy-recommended"],
 			files: ["package.json"],
 			parser: "jsonc-eslint-parser",
-			plugins: ["package-json"],
 			rules: {
 				"package-json/valid-package-definition": "error",
 			},
@@ -127,6 +124,7 @@ The default settings don't conflict, and Prettier plugins can quickly fix up ord
 | [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.                                    | ✅  | 🔧 |    |    |
 | [require-author](docs/rules/require-author.md)                         | Requires the `author` property to be present.                                                     |    |    |    |    |
+| [require-engines](docs/rules/require-engines.md)                       | Requires the `engines` property to be present.                                                    |    |    |    |    |
 | [require-files](docs/rules/require-files.md)                           | Requires the `files` property to be present.                                                      |    |    |    |    |
 | [require-keywords](docs/rules/require-keywords.md)                     | Requires the `keywords` property to be present.                                                   |    |    |    |    |
 | [require-name](docs/rules/require-name.md)                             | Requires the `name` property to be present.                                                       | ✅  |    |    |    |
diff --git a/cspell.json b/cspell.json
index ef2592a..22a4d37 100644
--- a/cspell.json
+++ b/cspell.json
@@ -4,6 +4,7 @@
 		".github",
 		"CHANGELOG.md",
 		"coverage",
+		"eslint.config.js",
 		"lib",
 		"node_modules",
 		"pnpm-lock.yaml"
diff --git a/docs/rules/require-engines.md b/docs/rules/require-engines.md
new file mode 100644
index 0000000..9fbf90f
--- /dev/null
+++ b/docs/rules/require-engines.md
@@ -0,0 +1,27 @@
+# require-engines
+
+<!-- end auto-generated rule header -->
+
+This rule checks for the existence of the `"engines"` property in a package.json,
+and reports a violation if it doesn't exist.
+
+Example of **incorrect** code for this rule:
+
+```json
+{
+	"name": "thee-silver-mt-zion",
+	"version": "13.0.0"
+}
+```
+
+Example of **correct** code for this rule:
+
+```json
+{
+	"name": "thee-silver-mt-zion",
+	"version": "13.0.0",
+	"engines": {
+		"node": ">=18"
+	}
+}
+```
diff --git a/eslint.config.js b/eslint.config.js
index 664a6ec..60e34d8 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -44,6 +44,7 @@ module.exports = tseslint.config(
 				projectService: {
 					allowDefaultProject: [
 						"*.config.*s",
+						".eslint-doc-generatorrc.js",
 						"bin/*.js",
 						"src/tests/*.js",
 					],
diff --git a/package.json b/package.json
index 2fe0a7f..91571a6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "eslint-plugin-package-json",
-	"version": "0.26.3",
+	"version": "0.27.0",
 	"description": "Rules for consistent, readable, and valid package.json files. 🗂️",
 	"homepage": "https://github.com/JoshuaKGoldberg/eslint-plugin-package-json#readme",
 	"bugs": {
@@ -21,14 +21,6 @@
 			},
 			"import": "./lib/index.mjs",
 			"require": "./lib/index.js"
-		},
-		"./configs/recommended": {
-			"types": {
-				"import": "./lib/configs/recommended.d.mts",
-				"require": "./lib/configs/recommended.d.ts"
-			},
-			"import": "./lib/configs/recommended.mjs",
-			"require": "./lib/configs/recommended.js"
 		}
 	},
 	"main": "./lib/index.js",
@@ -48,7 +40,7 @@
 		"should-semantic-release": "should-semantic-release --verbose",
 		"test": "vitest",
 		"tsc": "tsc",
-		"update:eslint-docs": "eslint-doc-generator --rule-doc-title-format name"
+		"update:eslint-docs": "pnpm build && eslint-doc-generator --rule-doc-title-format name"
 	},
 	"lint-staged": {
 		"*": "prettier --ignore-unknown --write"
@@ -78,7 +70,7 @@
 		"console-fail-test": "^0.5.0",
 		"cspell": "^8.3.2",
 		"eslint": "^9.18.0",
-		"eslint-doc-generator": "^2.0.2",
+		"eslint-doc-generator": "^2.1.2",
 		"eslint-plugin-eslint-plugin": "^6.4.0",
 		"eslint-plugin-jsdoc": "^50.6.1",
 		"eslint-plugin-jsonc": "^2.18.2",
@@ -107,7 +99,7 @@
 		"eslint": ">=8.0.0",
 		"jsonc-eslint-parser": "^2.0.0"
 	},
-	"packageManager": "pnpm@10.6.2",
+	"packageManager": "pnpm@10.6.3",
 	"engines": {
 		"node": ">=18"
 	},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 523c882..964986a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -76,8 +76,8 @@ importers:
         specifier: ^9.18.0
         version: 9.22.0(jiti@2.4.2)
       eslint-doc-generator:
-        specifier: ^2.0.2
-        version: 2.1.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
+        specifier: ^2.1.2
+        version: 2.1.2(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
       eslint-plugin-eslint-plugin:
         specifier: ^6.4.0
         version: 6.4.0(eslint@9.22.0(jiti@2.4.2))
@@ -110,10 +110,10 @@ importers:
         version: 2.4.0
       knip:
         specifier: ^5.0.0
-        version: 5.45.0(@types/node@22.13.10)(typescript@5.8.2)
+        version: 5.46.0(@types/node@22.13.10)(typescript@5.8.2)
       lint-staged:
         specifier: ^15.3.0
-        version: 15.4.3
+        version: 15.5.0
       markdownlint:
         specifier: ^0.37.4
         version: 0.37.4
@@ -1695,8 +1695,8 @@ packages:
     peerDependencies:
       eslint: '>=6.0.0'
 
-  eslint-doc-generator@2.1.0:
-    resolution: {integrity: sha512-DrHarT83g05wrTJghZ9MWRrbPAheWi1ZhBjtwc4cQzj1nFAZ2KTbOVopct2CQJw2vXe65vrj0gR8oMfckgQQMg==}
+  eslint-doc-generator@2.1.2:
+    resolution: {integrity: sha512-4CzDcFiVaNDxaUYF01bnebGkxKvowp6dyX/USED+4GbKCNTcoopbXZ0Jtb0m4/xYS2ImOajDmgb8HA3njgVMPg==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=22.0.0}
     hasBin: true
     peerDependencies:
@@ -2355,8 +2355,8 @@ packages:
   keyv@4.5.4:
     resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
 
-  knip@5.45.0:
-    resolution: {integrity: sha512-OUyO9FUEVCM6/j0gl+PP/LDeJEs4hIdE8n4vK4xrtjN1g3Qu4Ws1oexbWTCJ+8xt8Tgse4Yvhx96OqF/UVl3Ug==}
+  knip@5.46.0:
+    resolution: {integrity: sha512-WedHSK5xNBWYgm64Rt5B9b0CVXL2kRBcyCeet3NHgdv9en3QE4AWSDPEiX48NoPUBW3h//9S0VwLF5MG/MPi3g==}
     engines: {node: '>=18.18.0'}
     hasBin: true
     peerDependencies:
@@ -2385,8 +2385,8 @@ packages:
   linkify-it@5.0.0:
     resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
 
-  lint-staged@15.4.3:
-    resolution: {integrity: sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==}
+  lint-staged@15.5.0:
+    resolution: {integrity: sha512-WyCzSbfYGhK7cU+UuDDkzUiytbfbi0ZdPy2orwtM75P3WTtQBzmG40cCxIa8Ii2+XjfxzLH6Be46tUfWS85Xfg==}
     engines: {node: '>=18.12.0'}
     hasBin: true
 
@@ -5099,7 +5099,7 @@ snapshots:
       eslint: 9.22.0(jiti@2.4.2)
       semver: 7.7.1
 
-  eslint-doc-generator@2.1.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2):
+  eslint-doc-generator@2.1.2(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2):
     dependencies:
       '@typescript-eslint/utils': 8.26.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
       ajv: 8.17.1
@@ -5799,7 +5799,7 @@ snapshots:
     dependencies:
       json-buffer: 3.0.1
 
-  knip@5.45.0(@types/node@22.13.10)(typescript@5.8.2):
+  knip@5.46.0(@types/node@22.13.10)(typescript@5.8.2):
     dependencies:
       '@nodelib/fs.walk': 3.0.1
       '@snyk/github-codeowners': 1.1.0
@@ -5839,7 +5839,7 @@ snapshots:
     dependencies:
       uc.micro: 2.1.0
 
-  lint-staged@15.4.3:
+  lint-staged@15.5.0:
     dependencies:
       chalk: 5.4.1
       commander: 13.1.0
diff --git a/src/configs/recommended.ts b/src/configs/recommended.ts
deleted file mode 100644
index 81598e4..0000000
--- a/src/configs/recommended.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as parserJsonc from "jsonc-eslint-parser";
-
-import { plugin, recommendedRuleSettings } from "../plugin.js";
-
-const recommended = {
-	files: ["**/package.json"],
-	languageOptions: {
-		parser: parserJsonc,
-	},
-	name: "package-json/recommended",
-	plugins: {
-		get "package-json"() {
-			return plugin;
-		},
-	},
-	rules: recommendedRuleSettings,
-};
-
-export default recommended;
diff --git a/src/index.ts b/src/index.ts
index 0133423..7a61081 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,12 +1,6 @@
-import { plugin, recommendedRuleSettings } from "./plugin.js";
+import { plugin } from "./plugin.js";
 
 export const rules = plugin.rules;
-
-export const configs = {
-	recommended: {
-		plugins: ["package-json"],
-		rules: recommendedRuleSettings,
-	},
-};
+export const configs = plugin.configs;
 
 export default plugin;
diff --git a/src/plugin.ts b/src/plugin.ts
index 082027a..db84261 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -1,3 +1,4 @@
+import * as parserJsonc from "jsonc-eslint-parser";
 import { createRequire } from "node:module";
 
 import type { PackageJsonRuleModule } from "./createRule.js";
@@ -51,16 +52,35 @@ const rules: Record<string, PackageJsonRuleModule> = {
 	},
 };
 
+const recommendedRules = Object.fromEntries(
+	Object.entries(rules)
+		.filter(([, rule]) => rule.meta.docs?.recommended)
+		.map(([name]) => ["package-json/" + name, "error" as const]),
+);
+
 export const plugin = {
+	configs: {
+		"legacy-recommended": {
+			plugins: ["package-json"],
+			rules: recommendedRules,
+		},
+		recommended: {
+			files: ["**/package.json"],
+			languageOptions: {
+				parser: parserJsonc,
+			},
+			name: "package-json/recommended",
+			plugins: {
+				get "package-json"() {
+					return plugin;
+				},
+			},
+			rules: recommendedRules,
+		},
+	},
 	meta: {
 		name,
 		version,
 	},
 	rules,
 };
-
-export const recommendedRuleSettings = Object.fromEntries(
-	Object.entries(rules)
-		.filter(([, rule]) => rule.meta.docs?.recommended)
-		.map(([name]) => ["package-json/" + name, "error" as const]),
-);
diff --git a/src/rules/require-properties.ts b/src/rules/require-properties.ts
index 94a8ec1..55688fe 100644
--- a/src/rules/require-properties.ts
+++ b/src/rules/require-properties.ts
@@ -6,6 +6,7 @@ import { createRequirePropertyRule } from "../utils/createRequirePropertyRule.js
 // in the format [propertyName, isRecommended]
 const properties = [
 	["author", false],
+	["engines", false],
 	["files", false],
 	["keywords", false],
 	["name", true],


Test patch:
diff --git a/src/configs/recommended.test.ts b/src/configs/recommended.test.ts
deleted file mode 100644
index fc3d8c9..0000000
--- a/src/configs/recommended.test.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { plugin } from "../plugin.js";
-import recommended from "./recommended.js";
-
-describe("recommended", () => {
-	it("includes the plugin", () => {
-		expect(recommended.plugins["package-json"]).toBe(plugin);
-	});
-});
diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts
index 7afc148..2673162 100644
--- a/src/tests/index.test.ts
+++ b/src/tests/index.test.ts
@@ -3,18 +3,16 @@ import { readFile } from "node:fs/promises";
 import { resolve } from "node:path";
 import { describe, expect, it } from "vitest";
 
-import recommended from "../configs/recommended.js";
-
-const filename = new URL(import.meta.url).pathname;
+import plugin from "../index.js";
 
 describe("configs", () => {
 	it("configs should work properly", async () => {
 		const eslint = new ESLint({
-			baseConfig: recommended as Linter.Config,
+			baseConfig: plugin.configs.recommended as Linter.Config,
 			fix: true,
 		});
 		const code = await readFile(
-			resolve(filename, "../../../package.json"),
+			resolve(import.meta.filename, "../../../package.json"),
 			"utf8",
 		);
 		const result = await eslint.lintText(code, {
diff --git a/src/tests/rules/require-engines.test.ts b/src/tests/rules/require-engines.test.ts
new file mode 100644
index 0000000..d3e3aa1
--- /dev/null
+++ b/src/tests/rules/require-engines.test.ts
@@ -0,0 +1,54 @@
+import { rules } from "../../rules/require-properties.js";
+import { ruleTester } from "./ruleTester.js";
+
+ruleTester.run("require-engines", rules["require-engines"], {
+	invalid: [
+		{
+			code: "{}",
+			errors: [
+				{
+					data: { property: "engines" },
+					line: 1,
+					messageId: "missing",
+				},
+			],
+		},
+		{
+			code: `{
+            "name": "foo",
+            "version": "1.0.0"
+            }
+            `,
+			errors: [
+				{
+					data: { property: "engines" },
+					line: 1,
+					messageId: "missing",
+				},
+			],
+		},
+		{
+			code: `{
+            "name": "foo",
+            "version": "1.0.0",
+            "bin": {
+                "engines": "./index.js"
+            }
+            }
+            `,
+			errors: [
+				{
+					data: { property: "engines" },
+					line: 1,
+					messageId: "missing",
+				},
+			],
+		},
+	],
+	valid: [
+		`{ "main": "./index.js", "engines": { "node": ">=20" } }`,
+		`{ "engines": { "node": ">=20" } }`,
+		`{ "engines": 123 }`,
+		`{ "engines": "node123" }`,
+	],
+});