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

Issue:
🚀 Feature: Bring in a valid-bin 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 `valid-bin` rule. It should enforce that, _if_ the field exists in the `package.json` file, it's set to a valid value. It should do nothing if the field doesn't exist.

### Additional Info

Per discussion in #51, this won't be enabled by default at first. For this issue, we should:

1. Send a PR to create the rule in this repo (resolving this issue)
2. File an issue on https://github.com/JoshuaKGoldberg/package-json-validator to create a standalone API that performs the same validation
3. Once a version of `package-json-validator` is released with that feature, file an issue on this repo to switch the rule to use that API from `package-json-validator`

If you want to take on just step 1, that's ok - no pressure to do all three. 🙂 

Once _all_ `valid-` rules exist in this plugin, we can then file an issue to deprecate the `valid-package-def` rule in favor of them.

💖

Pull request description:
feat(valid-bin): add new rule for validating bin

## PR Checklist

- [x] Addresses an existing open issue: fixes #818 
- [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 `valid-bin` rule that uses `validateBin` from `package-json-validator` to identify errors.

This will need to wait to merge after #1077, so that we're not double-reporting validation errors on `bin` with `valid-package-definition`.

Lastly, this is the first new rule that employs our new approach of using the more granular validation functions from `package-json-validator` and can serve as a model for the rest of the `valid-*` rules.


Patch:
diff --git a/README.md b/README.md
index d7bf7aa..b46a125 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,7 @@ The default settings don't conflict, and Prettier plugins can quickly fix up ord
 | [restrict-dependency-ranges](docs/rules/restrict-dependency-ranges.md) | Restricts the range of dependencies to allow or disallow specific types of ranges.                |      |    | 💡 |    |
 | [sort-collections](docs/rules/sort-collections.md)                     | Dependencies, scripts, and configuration values must be declared in alphabetical order.           | ✔️ ✅ | 🔧 |    |    |
 | [unique-dependencies](docs/rules/unique-dependencies.md)               | Checks a dependency isn't specified more than once (i.e. in `dependencies` and `devDependencies`) | ✔️ ✅ |    | 💡 |    |
+| [valid-bin](docs/rules/valid-bin.md)                                   | Enforce that the `bin` property is valid.                                                         | ✔️ ✅ |    |    |    |
 | [valid-local-dependency](docs/rules/valid-local-dependency.md)         | Checks existence of local dependencies in the package.json                                        | ✔️ ✅ |    |    |    |
 | [valid-name](docs/rules/valid-name.md)                                 | Enforce that package names are valid npm package names                                            | ✔️ ✅ |    |    |    |
 | [valid-package-def](docs/rules/valid-package-def.md)                   | Enforce that package.json has all properties required by the npm spec                             |      |    |    | ❌  |
diff --git a/docs/rules/valid-bin.md b/docs/rules/valid-bin.md
new file mode 100644
index 0000000..6598981
--- /dev/null
+++ b/docs/rules/valid-bin.md
@@ -0,0 +1,36 @@
+# valid-bin
+
+💼 This rule is enabled in the following configs: ✔️ `legacy-recommended`, ✅ `recommended`.
+
+<!-- end auto-generated rule header -->
+
+This rule does the following checks on value of the `bin` property:
+
+- It must be either a non-empty string or an object.
+- If the value is an object, it should only consist of non-empty string values
+
+Example of **incorrect** code for this rule:
+
+```json
+{
+	"bin": {
+		"invalid-bin": 123
+	}
+}
+```
+
+Example of **correct** code for this rule:
+
+```json
+{
+	"bin": "./bin/cli.mjs"
+}
+```
+
+```json
+{
+	"bin": {
+		"my-cli": "./bin/cli.mjs"
+	}
+}
+```
diff --git a/docs/rules/valid-name.md b/docs/rules/valid-name.md
index 244671f..d695960 100644
--- a/docs/rules/valid-name.md
+++ b/docs/rules/valid-name.md
@@ -4,7 +4,7 @@
 
 <!-- end auto-generated rule header -->
 
-This rule applies two validations to the `"name"` property:
+This rule applies two validations to the `name` property:
 
 - It must be a string rather than any other data type
 - It should pass [`validate-npm-package-name`](https://www.npmjs.com/package/validate-npm-package-name) validation
diff --git a/docs/rules/valid-version.md b/docs/rules/valid-version.md
index e012c75..9807567 100644
--- a/docs/rules/valid-version.md
+++ b/docs/rules/valid-version.md
@@ -4,7 +4,7 @@
 
 <!-- end auto-generated rule header -->
 
-This rule applies two validations to the `"name"` property:
+This rule applies two validations to the `version` property:
 
 - It must be a string rather than any other data type
 - It should pass [`semver`](https://www.npmjs.com/package/semver) validation
diff --git a/src/plugin.ts b/src/plugin.ts
index 96b6802..f12f21b 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -12,6 +12,7 @@ import { rules as requireRules } from "./rules/require-properties.js";
 import { rule as restrictDependencyRanges } from "./rules/restrict-dependency-ranges.js";
 import { rule as sortCollections } from "./rules/sort-collections.js";
 import { rule as uniqueDependencies } from "./rules/unique-dependencies.js";
+import { rule as validBin } from "./rules/valid-bin.js";
 import { rule as validLocalDependency } from "./rules/valid-local-dependency.js";
 import { rule as validName } from "./rules/valid-name.js";
 import { rule as validPackageDefinition } from "./rules/valid-package-definition.js";
@@ -34,6 +35,7 @@ const rules: Record<string, PackageJsonRuleModule> = {
 	"restrict-dependency-ranges": restrictDependencyRanges,
 	"sort-collections": sortCollections,
 	"unique-dependencies": uniqueDependencies,
+	"valid-bin": validBin,
 	"valid-local-dependency": validLocalDependency,
 	"valid-name": validName,
 	"valid-package-definition": validPackageDefinition,
diff --git a/src/rules/valid-bin.ts b/src/rules/valid-bin.ts
new file mode 100644
index 0000000..d399e11
--- /dev/null
+++ b/src/rules/valid-bin.ts
@@ -0,0 +1,49 @@
+import * as ESTree from "estree";
+import { AST as JsonAST } from "jsonc-eslint-parser";
+import { validateBin } from "package-json-validator";
+
+import { createRule } from "../createRule.js";
+
+export const rule = createRule({
+	create(context) {
+		return {
+			"Program > JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=bin]"(
+				node: JsonAST.JSONProperty,
+			) {
+				const binValueNode = node.value as unknown as ESTree.Node;
+				const binValue: unknown = JSON.parse(
+					context.sourceCode.getText(binValueNode),
+				);
+
+				const errors = validateBin(binValue);
+				if (!errors.length) {
+					return;
+				}
+
+				context.report({
+					data: {
+						errors:
+							errors.length > 1
+								? ["", ...errors].join("\n - ")
+								: errors[0],
+					},
+					messageId: "validationError",
+					node: binValueNode,
+				});
+			},
+		};
+	},
+
+	meta: {
+		docs: {
+			category: "Best Practices",
+			description: "Enforce that the `bin` property is valid.",
+			recommended: true,
+		},
+		messages: {
+			validationError: "Invalid bin: {{ errors }}",
+		},
+		schema: [],
+		type: "problem",
+	},
+});


Test patch:
diff --git a/src/tests/rules/valid-bin.test.ts b/src/tests/rules/valid-bin.test.ts
new file mode 100644
index 0000000..f559427
--- /dev/null
+++ b/src/tests/rules/valid-bin.test.ts
@@ -0,0 +1,115 @@
+import { rule } from "../../rules/valid-bin.js";
+import { ruleTester } from "./ruleTester.js";
+
+ruleTester.run("valid-bin", rule, {
+	invalid: [
+		{
+			code: `{
+	"bin": null
+}
+`,
+			errors: [
+				{
+					data: {
+						errors: "bin field is `null`, but should be a `string` or an `object`",
+					},
+					messageId: "validationError",
+				},
+			],
+		},
+		{
+			code: `{
+	"bin": 123
+}
+`,
+			errors: [
+				{
+					data: {
+						errors: 'Type for field "bin" should be `string` or `object`, not `number`',
+					},
+					messageId: "validationError",
+				},
+			],
+		},
+		{
+			code: `{
+	"bin": ""
+}
+`,
+			errors: [
+				{
+					data: {
+						errors: "bin field is empty, but should be a relative path",
+					},
+					messageId: "validationError",
+				},
+			],
+		},
+		{
+			code: `{
+	"bin": { "invalid-bin": 123 }
+}
+`,
+			errors: [
+				{
+					data: {
+						errors: 'bin field "invalid-bin" should be a string',
+					},
+					messageId: "validationError",
+				},
+			],
+		},
+		{
+			code: `{
+	"bin": { "invalid-bin": "" }
+}
+`,
+			errors: [
+				{
+					data: {
+						errors: 'bin field "invalid-bin" is empty, but should be a relative path',
+					},
+					messageId: "validationError",
+				},
+			],
+		},
+		{
+			code: `{
+	"bin": { "": "invalid-key" }
+}
+`,
+			errors: [
+				{
+					data: {
+						errors: "bin field 0 has an empty key, but should be a valid command name",
+					},
+					messageId: "validationError",
+				},
+			],
+		},
+		{
+			code: `{
+	"bin": { "": "invalid-key", "   ": "invalid-key" }
+}
+`,
+			errors: [
+				{
+					data: {
+						errors: `
+ - bin field 0 has an empty key, but should be a valid command name
+ - bin field 1 has an empty key, but should be a valid command name`,
+					},
+					messageId: "validationError",
+				},
+			],
+		},
+	],
+	valid: [
+		"{}",
+		`{ "bin": "./silver-mt-zion.js" }`,
+		`{ "bin": "silver-mt-zion.js" }`,
+		`{ "bin": { "silver-mt-zion": "./silver-mt-zion.js" } }`,
+		`{ "bin": { "silver-mt-zion": "silver-mt-zion.js" } }`,
+		`{ "bin": { "silver-mt-zion": "silver-mt-zion.js", "nin": "./nin.js" } }`,
+	],
+});