diff --git a/README.md b/README.md index f5545fb1..8e8becbc 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ optional arguments: --ignore-missing-fields ignore errors for missing fields (but still checks for empty and valid) + --ignore-node-version [MAJOR] + ignore error for outdated node version (restricted to MAJOR version if given) ``` Use `--tarball` and `--pkgfile` to specify custom locations. @@ -257,6 +259,8 @@ While stable Linux distributions (e.g. Debian stable) and enterprise environment 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. +This rule can be ignored with `--ignore-node-version`. + ## Verify engine constraints Requires `engines.node` to be satisfied by all transitive dependencies. diff --git a/src/index.ts b/src/index.ts index 35ef220c..c95d2934 100644 --- a/src/index.ts +++ b/src/index.ts @@ -114,6 +114,14 @@ async function run(): Promise { action: "store_true", help: "ignore errors for missing fields (but still checks for empty and valid)", }); + parser.add_argument("--ignore-node-version", { + nargs: "?", + metavar: "MAJOR", + type: "int", + default: false, + const: true, + help: "ignore error for outdated node version (restricted to MAJOR version if given)", + }); const args = parser.parse_args() as ParsedArgs; const allowedDependencies = new Set(args.allow_dependency.map((it) => it.split(",")).flat()); diff --git a/src/node-versions.ts b/src/node-versions.ts index c57aac3b..34929a97 100644 --- a/src/node-versions.ts +++ b/src/node-versions.ts @@ -3,6 +3,7 @@ interface NodeVersionDescriptor { eol?: string; } +/* https://github.com/nodejs/Release/blob/main/schedule.json */ export const nodeVersions: Array<[string, NodeVersionDescriptor]> = [ ["0.10.x", { eol: "2016-10-31" }], ["0.12.x", { eol: "2016-12-31" }], @@ -20,4 +21,9 @@ export const nodeVersions: Array<[string, NodeVersionDescriptor]> = [ ["15.x.x", { eol: "2021-06-01" }], ["16.x.x", { eol: "2023-09-11" }], ["17.x.x", { eol: "2022-06-01" }], + ["18.x.x", {}], + ["19.x.x", { eol: "2023-06-01" }], + ["20.x.x", {}], + ["21.x.x", {}], + ["22.x.x", {}], ]; diff --git a/src/package-json.spec.ts b/src/package-json.spec.ts index 14641f8b..a6b0d634 100644 --- a/src/package-json.spec.ts +++ b/src/package-json.spec.ts @@ -120,6 +120,7 @@ it("should not return error if explicitly allowed by user", async () => { }; const results = await verifyPackageJson(pkg, "package.json", { allowedDependencies: new Set(["eslint"]), + ignoreNodeVersion: false, }); expect(results).toHaveLength(0); }); @@ -151,6 +152,7 @@ describe("@types", () => { const results = await verifyPackageJson(pkg, "package.json", { allowedDependencies: new Set(), allowTypesDependencies: true, + ignoreNodeVersion: false, }); expect(results).toHaveLength(0); }); @@ -170,6 +172,7 @@ describe("present", () => { const results = await verifyPackageJson(pkg, "package.json", { allowedDependencies: new Set(), ignoreMissingFields: true, + ignoreNodeVersion: false, }); expect(results).toHaveLength(0); }); diff --git a/src/package-json.ts b/src/package-json.ts index b6772fe1..6aa69069 100644 --- a/src/package-json.ts +++ b/src/package-json.ts @@ -22,6 +22,7 @@ export interface VerifyPackageJsonOptions { allowedDependencies: Set; allowTypesDependencies?: boolean; ignoreMissingFields?: boolean; + ignoreNodeVersion: boolean | number; } type validator = (key: string, value: unknown) => void; @@ -129,15 +130,16 @@ function verifyDependencies(pkg: PackageJson, options: VerifyPackageJsonOptions) export async function verifyPackageJson( pkg: PackageJson, filePath: string, - options: VerifyPackageJsonOptions = { allowedDependencies: new Set() }, + options: VerifyPackageJsonOptions = { allowedDependencies: new Set(), ignoreNodeVersion: false }, ): Promise { + const { ignoreNodeVersion } = options; const messages: Message[] = [ ...(await deprecatedDependency(pkg)), ...(await verifyEngineConstraint(pkg)), ...exportsTypesOrder(pkg), ...verifyFields(pkg, options), ...verifyDependencies(pkg, options), - ...outdatedEngines(pkg), + ...outdatedEngines(pkg, ignoreNodeVersion), ...typesNodeMatchingEngine(pkg), ]; diff --git a/src/rules/outdated-engines.spec.ts b/src/rules/outdated-engines.spec.ts index d09e4e0d..c45494ee 100644 --- a/src/rules/outdated-engines.spec.ts +++ b/src/rules/outdated-engines.spec.ts @@ -29,6 +29,8 @@ describe("should return error when unsupported version satisfies engines.node", ${">= 13.x"} | ${"Node 13"} ${">= 14.x"} | ${"Node 14"} ${">= 15.x"} | ${"Node 15"} + ${">= 16.x"} | ${"Node 16"} + ${">= 17.x"} | ${"Node 17"} `("$description", ({ range, description }) => { expect.assertions(1); pkg.engines = { @@ -38,7 +40,7 @@ describe("should return error when unsupported version satisfies engines.node", const message = new RegExp( String.raw`engines\.node is satisfied by ${description} \(EOL since \d{4}-.*\)`, ); - expect(Array.from(outdatedEngines(pkg))).toEqual([ + expect(Array.from(outdatedEngines(pkg, false))).toEqual([ { ruleId: "outdated-engines", severity: Severity.ERROR, @@ -50,12 +52,29 @@ describe("should return error when unsupported version satisfies engines.node", }); }); +describe("should allow supported version (including odd versions in-between)", () => { + it.each` + range | description + ${">= 18.x"} | ${"Node 18"} + ${">= 19.x"} | ${"Node 19"} + ${">= 20.x"} | ${"Node 20"} + ${">= 21.x"} | ${"Node 21"} + ${">= 22.x"} | ${"Node 22"} + `("$description", ({ range }) => { + expect.assertions(1); + pkg.engines = { + node: range, + }; + expect(Array.from(outdatedEngines(pkg, false))).toEqual([]); + }); +}); + 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(` + expect(Array.from(outdatedEngines(pkg, false))).toMatchInlineSnapshot(` [ { "column": 1, @@ -71,7 +90,7 @@ it("should return error engines.node is not a valid semver range", () => { it("should return error engines.node is missing", () => { expect.assertions(1); pkg.engines = {}; - expect(Array.from(outdatedEngines(pkg))).toMatchInlineSnapshot(` + expect(Array.from(outdatedEngines(pkg, false))).toMatchInlineSnapshot(` [ { "column": 1, @@ -87,7 +106,7 @@ it("should return error engines.node is missing", () => { it("should return error engines is missing", () => { expect.assertions(1); delete pkg.engines; - expect(Array.from(outdatedEngines(pkg))).toMatchInlineSnapshot(` + expect(Array.from(outdatedEngines(pkg, false))).toMatchInlineSnapshot(` [ { "column": 1, @@ -105,5 +124,37 @@ it("should not return error when engines.node only supports active versions", () pkg.engines = { node: ">= 18", }; - expect(Array.from(outdatedEngines(pkg))).toMatchInlineSnapshot(`[]`); + expect(Array.from(outdatedEngines(pkg, false))).toMatchInlineSnapshot(`[]`); +}); + +it("should ignore outdated node version when ignoreNodeVersion is true", () => { + expect.assertions(1); + pkg.engines = { + node: ">= 16", + }; + expect(Array.from(outdatedEngines(pkg, true))).toEqual([]); +}); + +it("should ignore outdated node version when ignoreNodeVersion is specific major", () => { + expect.assertions(1); + pkg.engines = { + node: ">= 16", + }; + expect(Array.from(outdatedEngines(pkg, 16))).toEqual([]); +}); + +it("should yield error when ignoreNodeVersion does not match declared engines.node range", () => { + expect.assertions(1); + pkg.engines = { + node: ">= 18", + }; + expect(Array.from(outdatedEngines(pkg, 16))).toEqual([ + { + ruleId: "outdated-engines", + severity: 2, + message: '--ignore-node-version=16 used but engines.node=">= 18" does not match v16.x', + line: 1, + column: 1, + }, + ]); }); diff --git a/src/rules/outdated-engines.ts b/src/rules/outdated-engines.ts index 508c3fc1..f068979a 100644 --- a/src/rules/outdated-engines.ts +++ b/src/rules/outdated-engines.ts @@ -7,7 +7,10 @@ import { type PackageJson } from "../types"; const ruleId = "outdated-engines"; const severity = Severity.ERROR; -export function* outdatedEngines(pkg: PackageJson): Generator { +export function* outdatedEngines( + pkg: PackageJson, + ignoreNodeVersion: boolean | number, +): Generator { if (!pkg.engines?.node) { yield { ruleId, @@ -31,17 +34,32 @@ export function* outdatedEngines(pkg: PackageJson): Generator { return; } + if (ignoreNodeVersion === true) { + return; + } + for (const [version, descriptor] of nodeVersions) { + /* assume the list of versions are sorted: when a version not EOL is found + * we stop processing the list, e.g. `>= 18` is OK while Node 18 is not EOL + * even if Node 19 is EOL. */ if (!descriptor.eol) { - continue; + break; } const expanded = version.replace(/[xX*]/g, "999"); - const parsed = semver.parse(expanded); if (!semver.satisfies(expanded, range)) { continue; } - const nodeRelease = (parsed?.major ?? 0) || `0.${String(parsed?.minor ?? "")}`; + + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- parsing hardcoded values and covered by unit tests */ + const parsed = semver.parse(expanded)!; + + const { major, minor } = parsed; + if (ignoreNodeVersion === major) { + return; + } + + const nodeRelease = major > 0 ? major : `0.${String(minor)}`; const message = `engines.node is satisfied by Node ${String(nodeRelease)} (EOL since ${ descriptor.eol })`; @@ -54,4 +72,19 @@ export function* outdatedEngines(pkg: PackageJson): Generator { }; return; } + + /* if we reached this far there was no error silenced by ignoreNodeVersion so + * we yield a new error informing that the ignore is no longer needed */ + if (typeof ignoreNodeVersion === "number") { + const option = String(ignoreNodeVersion); + const version = `v${String(ignoreNodeVersion)}.x`; + const message = `--ignore-node-version=${option} used but engines.node="${range}" does not match ${version}`; + yield { + ruleId, + severity, + message, + line: 1, + column: 1, + }; + } } diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts index 52204387..acdfbe7f 100644 --- a/tests/integration.spec.ts +++ b/tests/integration.spec.ts @@ -25,6 +25,9 @@ it.each(fixtures)("%s", async (fixture) => { const pkgPath = path.relative(ROOT_DIRECTORY, path.join(dir, "package.json")); const pkg: PackageJson = JSON.parse(await fs.readFile(pkgPath, "utf-8")); const tarball = { filePath: await npmPack(pkg, fixture) }; - const result = await verify(pkg, pkgPath, tarball, { allowedDependencies: new Set() }); + const result = await verify(pkg, pkgPath, tarball, { + allowedDependencies: new Set(), + ignoreNodeVersion: false, + }); expect(result).toMatchSnapshot(); });