diff --git a/docs/rules/require-license.md b/docs/rules/require-license.md new file mode 100644 index 00000000..ef040a72 --- /dev/null +++ b/docs/rules/require-license.md @@ -0,0 +1,75 @@ +# require-license + +💼 This rule is enabled in the ✅ `recommended` config. + + + +This rule applies two validations to the `"licence"` property: + +- It must be a string rather than any other data type +- It's value should match one of the values provided in the options + +Example of **incorrect** code for this rule: + +When the rule is configured with + +```ts +{ + "require-license": ["error", "GPL"] +} +``` +```json +{ + "license": "MIT" +} +``` + +When the rule is configured with + +```ts +{ + "require-license": ["error", ["MIT", "GPL"]] +} +``` + +```json +{ + "license": "Apache" +} +``` + +Example of **correct** code for this rule: + +When the rule is configured with + +```ts +{ + "require-license": ["error", "GPL"] +} +``` + +```json +{ + "license": "GPL" +} +``` + +When the rule is configured with + +```ts +{ + "require-license": ["error", ["Apache", "MIT"]] +} +``` + +```json +{ + "license": "Apache" +} +``` +```json +{ + "license": "MIT" +} +``` + diff --git a/src/plugin.ts b/src/plugin.ts index a09d3077..ed98b196 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -6,6 +6,7 @@ import { rule as noEmptyFields } from "./rules/no-empty-fields.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 requiresLicense } from "./rules/requires-license.js"; import { rule as sortCollections } from "./rules/sort-collections.js"; import { rule as uniqueDependencies } from "./rules/unique-dependencies.js"; import { rule as validLocalDependency } from "./rules/valid-local-dependency.js"; @@ -17,48 +18,49 @@ import { rule as validVersion } from "./rules/valid-version.js"; const require = createRequire(import.meta.url || __filename); const { name, version } = require("../package.json") as { - name: string; - version: string; + name: string; + version: string; }; const rules: Record = { - "no-empty-fields": noEmptyFields, - "no-redundant-files": noRedundantFiles, - "order-properties": orderProperties, - "repository-shorthand": preferRepositoryShorthand, - "sort-collections": sortCollections, - "unique-dependencies": uniqueDependencies, - "valid-local-dependency": validLocalDependency, - "valid-name": validName, - "valid-package-definition": validPackageDefinition, - "valid-repository-directory": validRepositoryDirectory, - "valid-version": validVersion, + "no-empty-fields": noEmptyFields, + "no-redundant-files": noRedundantFiles, + "order-properties": orderProperties, + "repository-shorthand": preferRepositoryShorthand, + "requires-license": requiresLicense, + "sort-collections": sortCollections, + "unique-dependencies": uniqueDependencies, + "valid-local-dependency": validLocalDependency, + "valid-name": validName, + "valid-package-definition": validPackageDefinition, + "valid-repository-directory": validRepositoryDirectory, + "valid-version": validVersion, - /** @deprecated use 'valid-package-definition' instead */ - "valid-package-def": { - ...validPackageDefinition, - meta: { - ...validPackageDefinition.meta, - deprecated: true, - docs: { - ...validPackageDefinition.meta.docs, - recommended: false, - }, - replacedBy: ["valid-package-definition"], - }, - }, + /** @deprecated use 'valid-package-definition' instead */ + "valid-package-def": { + ...validPackageDefinition, + meta: { + ...validPackageDefinition.meta, + deprecated: true, + docs: { + ...validPackageDefinition.meta.docs, + recommended: false, + }, + replacedBy: ["valid-package-definition"], + }, + }, }; export const plugin = { - meta: { - name, - version, - }, - rules, + 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]), + Object.entries(rules) + .filter(([, rule]) => rule.meta.docs?.recommended) + .map(([name]) => ["package-json/" + name, "error" as const]), ); diff --git a/src/rules/requires-license.ts b/src/rules/requires-license.ts new file mode 100644 index 00000000..5704b858 --- /dev/null +++ b/src/rules/requires-license.ts @@ -0,0 +1,85 @@ +import type { AST as JsonAST } from "jsonc-eslint-parser"; + +import * as ESTree from "estree"; + +import { createRule } from "../createRule.js"; + +export type AllowedValues = string | string[]; +export type Options = [AllowedValues]; + +export function convertAllowedValuesToString(allowed: string[]): string { + return allowed.map((v) => `'${v}'`).join(","); +} + +export function multipleDescriptor(allowed: string[]) { + return allowed.length > 1 ? 'one of' : '' +} + +export const rule = createRule({ + create(context) { + const ruleOptions = context.options[0]; + const allowedValues = Array.isArray(ruleOptions) ? ruleOptions : [ruleOptions]; + return { + "Program > JSONExpressionStatement > JSONObjectExpression > JSONProperty[key.value=license]"( + node: JsonAST.JSONProperty, + ) { + if ( + node.value.type !== "JSONLiteral" || + typeof node.value.value !== "string" + ) { + context.report({ + messageId: "nonString", + node: node.value as unknown as ESTree.Node, + }); + return; + } + + if (!allowedValues.includes(node.value.value)) { + context.report({ + data: { + allowed: + convertAllowedValuesToString(allowedValues), + multiple: multipleDescriptor(allowedValues), + }, + loc: node.loc, + messageId: "invalidValue", + }); + } + }, + }; + }, + + meta: { + docs: { + category: "Best Practices", + description: + "Enforce the 'license' field to be a specific value/values", + recommended: false, + }, + messages: { + invalidValue: '"license" must be{{multiple}} {{allowed}}"', + nonString: '"license" must be a string"', + }, + schema: { + items: [ + { + oneOf: [ + { + items: { + type: "string", + }, + type: "array", + }, + { + type: "string" + } + ] + } + + ], + type: "array" + + }, + type: "problem", + }, +}); diff --git a/src/tests/rules/requires-license.test.ts b/src/tests/rules/requires-license.test.ts new file mode 100644 index 00000000..8e16fcec --- /dev/null +++ b/src/tests/rules/requires-license.test.ts @@ -0,0 +1,61 @@ +import type { RuleTester } from "eslint"; + +import type { AllowedValues, Options } from "../../rules/requires-license.js"; + +import { + convertAllowedValuesToString, + multipleDescriptor, + rule, +} from "../../rules/requires-license.js"; +import { ruleTester } from "./ruleTester.js"; + +function createInvalidCase( + licenseValue: unknown, + allowedValues: AllowedValues, + expectedMessageId = "invalidValue", + expectedAllowed: AllowedValues = allowedValues, +): RuleTester.InvalidTestCase { + const base = createValidCase(licenseValue, allowedValues); + + const expectedAllowedArr = Array.isArray(expectedAllowed) + ? expectedAllowed + : [expectedAllowed]; + + return { + ...base, + errors: [ + { + data: { + allowed: convertAllowedValuesToString(expectedAllowedArr), + multiple: multipleDescriptor(expectedAllowedArr), + }, + messageId: expectedMessageId, + }, + ], + }; +} + +function createValidCase( + licenseValue: unknown, + allowedValues: AllowedValues, +): RuleTester.ValidTestCase { + return { + code: JSON.stringify({ + license: licenseValue, + name: "some-test-package", + }), + options: [allowedValues], + }; +} + +ruleTester.run("requires-license", rule, { + invalid: [ + createInvalidCase("CC BY-SA", "GPL"), + createInvalidCase("Apache", ["MIT", "GPL"]), + createInvalidCase(1234, ["GPL"], "nonString"), + ], + valid: [ + createValidCase("Apache", "Apache"), + createValidCase("GPL", ["MIT", "GPL"]), + ], +});