diff --git a/README.md b/README.md index 05bc620b..2447354d 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,15 @@ Verifies the following fields: It also enforces all urls to be `https`, even the repository url. While `git` is technically valid most users cannot clone the repository anonomously. Shortcuts are not permitted either because it saves basically nothing, makes tooling more difficult to write and wont work for smaller hosting services. + +## Unsupported node versions + +Requires `engines.node` to be up-to-date and only supporting LTS and active versions. + +**Why?** Newer versions contains more builtin functions and features replacing the need for polyfills and many one-liner packages. + +As an example `mkdirp` can be replaced with `fs.mkdir(p, { recursive: true })` starting with Node 10. + +While stable Linux distributions (e.g. Debian stable) and enterprise environment might not use the most recent versions they often try to stay away from EOL versions. +Users stuck at older versions will not be able to update to the latest set of node packages but if you are using an environment with unsupported versions you are unlikely to want to update node packages. +It is also very likely that the package doesn't actually run on such old version anyway because of a missing feature or a dependency requiring a later version. diff --git a/package-lock.json b/package-lock.json index 446df860..79bca523 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1672,6 +1672,12 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "dev": true }, + "@types/semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-+nVsLKlcUCeMzD2ufHEYuJ9a2ovstb6Dp52A5VsoKxDXgvE051XgHI/33I1EymwkRGQkwnA0LkhnUzituGs4EQ==", + "dev": true + }, "@types/stack-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz", @@ -11570,8 +11576,7 @@ "semver": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", - "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", - "dev": true + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==" }, "semver-compare": { "version": "1.0.0", diff --git a/package.json b/package.json index 2a9eb077..a6fdf3ad 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@html-validate/stylish": "^1.0.0", "argparse": "^2.0.1", "find-up": "^5.0.0", + "semver": "^7.3.2", "tar": "^6.0.5", "tmp": "^0.2.1" }, @@ -75,6 +76,7 @@ "@types/glob": "7.1.3", "@types/jest": "26.0.15", "@types/node": "11.15.37", + "@types/semver": "^7.3.4", "@types/tar": "4.0.4", "@types/tmp": "0.2.0", "@typescript-eslint/eslint-plugin": "4.8.1", diff --git a/src/__snapshots__/package-json.spec.ts.snap b/src/__snapshots__/package-json.spec.ts.snap index 7c0bbf77..d05f7fe6 100644 --- a/src/__snapshots__/package-json.spec.ts.snap +++ b/src/__snapshots__/package-json.spec.ts.snap @@ -240,6 +240,18 @@ Array [ ] `; +exports[`should return engines.node supports eol version 1`] = ` +Array [ + Object { + "column": 1, + "line": 1, + "message": "engines.node is satisfied by Node 8 (EOL since 2019-12-31)", + "ruleId": "outdated-engines", + "severity": 2, + }, +] +`; + exports[`should return error if dependency is disallowed 1`] = ` Array [ Object { diff --git a/src/package-json.spec.ts b/src/package-json.spec.ts index 3a51ba21..39e1af1c 100644 --- a/src/package-json.spec.ts +++ b/src/package-json.spec.ts @@ -14,6 +14,9 @@ beforeEach(() => { license: "UNLICENSED", author: "Fred Flintstone ", repository: "https://git.example.net/test-case.git", + engines: { + node: ">= 10", + }, }; }); @@ -50,6 +53,15 @@ it("should not return error if dependency is allowed", async () => { expect(results).toEqual([]); }); +it("should return engines.node supports eol version", async () => { + expect.assertions(3); + pkg.engines.node = ">= 8"; + const results = await verifyPackageJson(pkg, "package.json"); + expect(results).toHaveLength(1); + expect(results[0].filePath).toEqual("package.json"); + expect(results[0].messages).toMatchSnapshot(); +}); + describe("@types", () => { beforeEach(() => { pkg.dependencies = { diff --git a/src/package-json.ts b/src/package-json.ts index 932d03e5..aaf1e38d 100644 --- a/src/package-json.ts +++ b/src/package-json.ts @@ -3,6 +3,7 @@ import { Message } from "./message"; import { Result } from "./result"; import { nonempty, present, typeArray, typeString, validUrl } from "./validators"; import { isDisallowedDependency } from "./rules/disallowed-dependency"; +import { outdatedEngines } from "./rules/outdated-engines"; export interface VerifyPackageJsonOptions { allowTypesDependencies?: boolean; @@ -75,7 +76,11 @@ export async function verifyPackageJson( filePath: string, options: VerifyPackageJsonOptions = {} ): Promise { - const messages: Message[] = [...verifyFields(pkg, options), ...verifyDependencies(pkg, options)]; + const messages: Message[] = [ + ...verifyFields(pkg, options), + ...verifyDependencies(pkg, options), + ...outdatedEngines(pkg), + ]; if (messages.length === 0) { return []; diff --git a/src/rules/outdated-engines.spec.ts b/src/rules/outdated-engines.spec.ts new file mode 100644 index 00000000..c760b078 --- /dev/null +++ b/src/rules/outdated-engines.spec.ts @@ -0,0 +1,95 @@ +import { Severity } from "@html-validate/stylish/dist/severity"; +import PackageJson from "../types/package-json"; +import { outdatedEngines } from "./outdated-engines"; + +let pkg: PackageJson; +const ruleId = "outdated-engines"; +const severity = Severity.ERROR; + +beforeEach(() => { + pkg = { + name: "mock-package", + version: "1.2.3", + engines: {}, + }; +}); + +describe("should return error when unsupported version satisfies engines.node", () => { + it.each` + range | description | date + ${">= 0.10.x"} | ${"Node 0.10"} | ${"2016-10-31"} + ${">= 0.12.x"} | ${"Node 0.12"} | ${"2016-12-31"} + ${">= 4.x"} | ${"Node 4"} | ${"2018-04-30"} + ${">= 5.x"} | ${"Node 5"} | ${"2016-06-30"} + ${">= 6.x"} | ${"Node 6"} | ${"2019-04-30"} + ${">= 7.x"} | ${"Node 7"} | ${"2017-06-30"} + ${">= 8.x"} | ${"Node 8"} | ${"2019-12-31"} + ${">= 9.x"} | ${"Node 9"} | ${"2018-06-30"} + `("$description", ({ range, description, date }) => { + expect.assertions(1); + pkg.engines.node = range; + expect(Array.from(outdatedEngines(pkg))).toEqual([ + { + ruleId: "outdated-engines", + severity: Severity.ERROR, + message: `engines.node is satisfied by ${description} (EOL since ${date})`, + line: 1, + column: 1, + }, + ]); + }); +}); + +it("should return error engines.node is not a valid semver range", () => { + expect.assertions(1); + pkg.engines.node = "foobar"; + expect(Array.from(outdatedEngines(pkg))).toMatchInlineSnapshot(` + Array [ + Object { + "column": 1, + "line": 1, + "message": "engines.node \\"foobar\\" is not a valid semver range", + "ruleId": "outdated-engines", + "severity": 2, + }, + ] + `); +}); + +it("should return error engines.node is missing", () => { + expect.assertions(1); + delete pkg.engines.node; + expect(Array.from(outdatedEngines(pkg))).toMatchInlineSnapshot(` + Array [ + Object { + "column": 1, + "line": 1, + "message": "Missing engines.node field", + "ruleId": "outdated-engines", + "severity": 2, + }, + ] + `); +}); + +it("should return error engines is missing", () => { + expect.assertions(1); + delete pkg.engines; + expect(Array.from(outdatedEngines(pkg))).toMatchInlineSnapshot(` + Array [ + Object { + "column": 1, + "line": 1, + "message": "Missing engines.node field", + "ruleId": "outdated-engines", + "severity": 2, + }, + ] + `); +}); + +it("should not return error when engines.node only supports active versions", () => { + expect.assertions(1); + pkg.engines.node = ">= 10"; + expect(Array.from(outdatedEngines(pkg))).toMatchInlineSnapshot(`Array []`); +}); diff --git a/src/rules/outdated-engines.ts b/src/rules/outdated-engines.ts new file mode 100644 index 00000000..b70774fe --- /dev/null +++ b/src/rules/outdated-engines.ts @@ -0,0 +1,63 @@ +import semver from "semver"; +import { Severity } from "@html-validate/stylish/dist/severity"; +import { Message } from "../message"; +import PackageJson from "../types/package-json"; + +const ruleId = "outdated-engines"; +const severity = Severity.ERROR; + +interface EOLDescriptor { + date: string; +} + +const EOL: [string, EOLDescriptor][] = [ + ["0.10.99", { date: "2016-10-31" }], + ["0.12.99", { date: "2016-12-31" }], + ["4.99.99", { date: "2018-04-30" }], + ["5.99.99", { date: "2016-06-30" }], + ["6.99.99", { date: "2019-04-30" }], + ["7.99.99", { date: "2017-06-30" }], + ["8.99.99", { date: "2019-12-31" }], + ["9.99.99", { date: "2018-06-30" }], +]; + +export function* outdatedEngines(pkg: PackageJson): Generator { + if (!pkg.engines || !pkg.engines.node) { + yield { + ruleId, + severity, + message: "Missing engines.node field", + line: 1, + column: 1, + }; + return; + } + + const range = pkg.engines.node; + if (!semver.validRange(range)) { + yield { + ruleId, + severity, + message: `engines.node "${range}" is not a valid semver range`, + line: 1, + column: 1, + }; + return; + } + + for (const [version, descriptor] of EOL) { + const parsed = semver.parse(version); + if (semver.satisfies(version, range)) { + yield { + ruleId, + severity, + message: `engines.node is satisfied by Node ${ + parsed.major || `0.${parsed.minor}` + } (EOL since ${descriptor.date})`, + line: 1, + column: 1, + }; + return; + } + } +} diff --git a/src/types/package-json.ts b/src/types/package-json.ts index 7291a71d..d09063f3 100644 --- a/src/types/package-json.ts +++ b/src/types/package-json.ts @@ -27,6 +27,7 @@ export default interface PackageJson { peerDependencies?: Record; bundledDependencies?: Record; optionalDependencies?: Record; + engines?: Record; [key: string]: | string diff --git a/tests/fixtures/disallowed-files/package.json b/tests/fixtures/disallowed-files/package.json index 27671b5f..2f42b263 100644 --- a/tests/fixtures/disallowed-files/package.json +++ b/tests/fixtures/disallowed-files/package.json @@ -18,5 +18,8 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, + "engines": { + "node": ">= 10" + }, "license": "ISC" } diff --git a/tests/fixtures/empty-fields/package.json b/tests/fixtures/empty-fields/package.json index d7b64002..fff7f78e 100644 --- a/tests/fixtures/empty-fields/package.json +++ b/tests/fixtures/empty-fields/package.json @@ -9,5 +9,8 @@ "author": "", "repository": {}, "main": "", - "license": "" + "license": "", + "engines": { + "node": ">= 10" + } } diff --git a/tests/fixtures/missing-binary/package.json b/tests/fixtures/missing-binary/package.json index a8fb1721..c93591b1 100644 --- a/tests/fixtures/missing-binary/package.json +++ b/tests/fixtures/missing-binary/package.json @@ -19,5 +19,8 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, + "engines": { + "node": ">= 10" + }, "license": "ISC" } diff --git a/tests/fixtures/missing-fields/package.json b/tests/fixtures/missing-fields/package.json index af677fb0..6de96d50 100644 --- a/tests/fixtures/missing-fields/package.json +++ b/tests/fixtures/missing-fields/package.json @@ -1,5 +1,8 @@ { "name": "missing-fields", "version": "1.0.0", - "files": [] + "files": [], + "engines": { + "node": ">= 10" + } } diff --git a/tests/fixtures/missing-main/package.json b/tests/fixtures/missing-main/package.json index 853e519d..f13f54cc 100644 --- a/tests/fixtures/missing-main/package.json +++ b/tests/fixtures/missing-main/package.json @@ -16,5 +16,8 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, + "engines": { + "node": ">= 10" + }, "license": "ISC" }