Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENHANCEMENT] minimum versions or minimum version in relation to #649

Open
sdavids opened this issue Nov 4, 2023 · 12 comments
Open

[ENHANCEMENT] minimum versions or minimum version in relation to #649

sdavids opened this issue Nov 4, 2023 · 12 comments

Comments

@sdavids
Copy link

sdavids commented Nov 4, 2023

Right now, we have the minVersion function:

minVersion('14.0.0 || 16.1.0 || 18.0.0') // => '14.0.0'

and the minSatisfying function:

minSatisfying([parse('16.1.0')],'14.0.0 || 16.1.0 || 18.0.0')
// SemVer {
//   options: {},
//   loose: false,
//   includePrerelease: false,
//   raw: '16.1.0',
//   major: 16,
//   minor: 1,
//   patch: 0,
//   prerelease: [],
//   build: [],
//   version: '16.1.0'
// }
minSatisfying([parse('16.0.0')],'14.0.0 || 16.1.0 || 18.0.0')
// null

I suggest the addition of one or two functions:

to_be_named(version, range, options)

to_be_named('13.0.0', '14.0.0 || 16.1.0 || 18.0.0') // => '14.0.0'
to_be_named('14.0.0', '14.0.0 || 16.1.0 || 18.0.0') // => '14.0.0'
to_be_named('14.0.1', '14.0.0 || 16.1.0 || 18.0.0') // => '14.0.1'
to_be_named('15.0.0', '14.0.0 || 16.1.0 || 18.0.0') // => '16.1.0'
to_be_named('16.0.0', '14.0.0 || 16.1.0 || 18.0.0') // => '16.1.0'
to_be_named('16.1.0', '14.0.0 || 16.1.0 || 18.0.0') // => '16.1.0'
to_be_named('16.1.1', '14.0.0 || 16.1.0 || 18.0.0') // => '16.1.1'
to_be_named('18.0.0', '14.0.0 || 16.1.0 || 18.0.0') // => '18.0.0'
to_be_named('18.0.1', '14.0.0 || 16.1.0 || 18.0.0') // => '18.0.1'
to_be_named('19.0.0', '14.0.0 || 16.1.0 || 18.0.0') // => '19.0.0'

or:

to_be_named2(range, options)

to_be_named2('a || b || c') // => [ minVersion(a), minVersion(b), minVersion(c) ]

to_be_named2('8.0.0', '10.0.1', '>12.0.0 || <14.0.0 || ~18.0.0' || ^20.0.0) 
// =>       ['8.0.0', '10.0.1', '12.0.1',   '0.0.0',   '18.0.0',   '20.0.0' ]

My use case is the following script:

minimum-engines.mjs

#!/usr/bin/env node

/*
 Copyright (c) 2023, Sebastian Davids

 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at

      http://www.apache.org/licenses/LICENSE-2.0

 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */

// Prerequisite:
//   npm i --save-dev semver

// Usage:
//   node minimum-engines.mjs node_modules

import { compare, minVersion, satisfies, validRange } from 'semver';
import { readFile, readdir } from 'node:fs/promises';
import { resolve } from 'node:path';

if (process.argv.length < 3) {
  console.error('You need to supply the path to the node_modules folder');
  process.exit(1);
}

const nodeModulesPath = process.argv[2];

const getPackageJsonFiles = async function* (path) {
  const dirEntries = await readdir(path, { withFileTypes: true });
  for (const entry of dirEntries) {
    const file = resolve(path, entry.name);
    if (entry.isDirectory()) {
      yield* getPackageJsonFiles(file);
    } else if (entry.name === 'package.json') {
      yield file;
    }
  }
};

const getEngines = async (path) => {
  const json = await readFile(path, 'utf8');
  try {
    const { engines = {} } = JSON.parse(json);
    return engines;
  } catch {
    return {};
  }
};

const mergeEngine = (engines, engine, value) => {
  const range = value.trim();
  if (range === '' || validRange(range) === null) {
    return;
  }
  const current = engines[engine] ?? '0.0.0';
  if (satisfies(current, range)) {
    return;
  }
  // TODO does not handle the following case:
  // range: 14.0.0 || 16.1.0 || 18.0.0
  // current: 16.0.0
  const version = minVersion(range).raw;
  const newCurrent = compare(version, current) === -1 ? current : version;
  // 16.0.0 instead of 16.1.0
  engines[engine] = newCurrent;
  //
};

const result = {};

try {
  for await (const file of getPackageJsonFiles(nodeModulesPath)) {
    const engines = await getEngines(file);
    for (const engine of Object.keys(engines)) {
      mergeEngine(result, engine, engines[engine]);
    }
  }
  console.log(JSON.stringify(result));
} catch (e) {
  console.error(e);
}

It will crawl the given node_modules folder and return an object one can use for the engines in your project.

Example output

{"node":"18.12.0","npm":"1.0.0","iojs":"1.0.0"}

See the TODO for the case the script cannot handle right now without one of the suggested function additions.

@sdavids
Copy link
Author

sdavids commented Nov 5, 2023

I have updated my script to handle the case but I still think there is value in adding either method to semver.

minimum-engines.mjs

#!/usr/bin/env node

/*
 Copyright (c) 2023, Sebastian Davids

 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at

      http://www.apache.org/licenses/LICENSE-2.0

 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */

// Prerequisite:
//   npm i --save-dev semver

// Usage:
//   node minimum-engines.mjs node_modules

import { compare, ltr, minVersion, parse, satisfies, validRange } from 'semver';
import { readFile, readdir } from 'node:fs/promises';
import { resolve } from 'node:path';

if (process.argv.length < 3) {
  console.error('You need to supply the path to the node_modules folder');
  process.exit(1);
}

const nodeModulesPath = process.argv[2];

const getPackageJsonFiles = async function* (path) {
  const dirEntries = await readdir(path, { withFileTypes: true });
  for (const entry of dirEntries) {
    const file = resolve(path, entry.name);
    if (entry.isDirectory()) {
      yield* getPackageJsonFiles(file);
    } else if (entry.name === 'package.json') {
      yield file;
    }
  }
};

const getEngines = async (path) => {
  const json = await readFile(path, 'utf8');
  try {
    const { engines = {} } = JSON.parse(json);
    return engines;
  } catch {
    return {};
  }
};

const mergeEngine = (engines, engine, value) => {
  const range = value.trim();
  if (range === '' || validRange(range) === null) {
    return;
  }
  const current = engines[engine] ?? '0.0.0';
  if (satisfies(current, range)) {
    return;
  }
  const currentVersion = parse(current);
  const newCurrent = minVersion(
    range
      .split('||')
      .map((r) => minVersion(r).raw)
      .filter((r) => ltr(currentVersion, r))
      .map((r) => (ltr(currentVersion, r) ? r : current))
      .join(' || '),
  ).raw;
  engines[engine] = compare(newCurrent, current) === -1 ? current : newCurrent;
};

const result = {};

try {
  for await (const file of getPackageJsonFiles(nodeModulesPath)) {
    const engines = await getEngines(file);
    for (const engine of Object.keys(engines)) {
      mergeEngine(result, engine, engines[engine]);
    }
  }
  console.log(JSON.stringify(result));
} catch (e) {
  console.error(e);
}

@ljharb
Copy link

ljharb commented Nov 5, 2023

I'm really confused what this is asking for. if you have a range that doesn't include v13, then v13 has no relationship to it, it's just incompatible.

@sdavids
Copy link
Author

sdavids commented Nov 5, 2023

The question for to_be_named is:

Give me the version included in the range equal or higher to the given version; if the given version is higher than any included version than return it.

Examples:

Range: >14.0.0 || >16.0.0 || > 18.0.0
Given version: 16.0.0
Result: 16.0.0 -- version included in range so return it

Range: >14.0.0 || >16.1.0 || > 18.0.0
Given version: 16.0.0
Result: 16.1.0 -- version not included in range so select the minimum version higher than the given version

Range: >14.0.0 || >16.1.0 || > 18.0.0
Given version: 20.0.0
Result: 20.0.0 -- version higher than all versions in range so return it

more examples above.

The question for to_be_named2 is:

Give me the minimum version for each part of a composite range.

@ljharb
Copy link

ljharb commented Nov 5, 2023

What's the use case tho?

@sdavids
Copy link
Author

sdavids commented Nov 5, 2023

See the script above for one —- it determines the minimum engines for a given project.

@ljharb
Copy link

ljharb commented Nov 5, 2023

That's already what minVersion does, when given the engines.node range.

@sdavids
Copy link
Author

sdavids commented Nov 5, 2023

range 1

minVersion('>14.0.0 || >16.1.0 || >18.0.0') // 14.0.0

range 2

minVersion('>16.0.0 || >18.0.0 || >20.0.0') // 16.0.0

16.0.0 will not satisfy the first range but 16.1.0 will satisfy both

A third possible function would be

to_be_named3(ranges, options)

to_be_named3(
  [
    '>14.0.0 || >16.0.0 || >18.0.0'
    '>16.0.0 || >18.0.0 || >20.0.0',
    '>14.0.0 || >16.1.0 || >18.0.0',
  ]) // => 16.1.0

@ljharb
Copy link

ljharb commented Nov 5, 2023

ok so you want the minimum version that satisfies both ranges?

I agree there's not a simple way to do that. I have npx ls-engines if you want to know what, based on your dep graph, your package.json's engines field should be. Does that address your use case?

@sdavids
Copy link
Author

sdavids commented Nov 5, 2023

The results of my script above and nix ls-engines --mode=actual are different.

My script: {"node":"16.14.0"}
ls-engines: {"node": ">= 14.13"}

I ran it on: https://github.com/terser/html-minifier-terser

It has a vite@^4.3.9 dependency with a range ^14.18.0 || >=16.0.0 and a eslint-config-standard@^17.1.0 dependency which has the transitive dependency [email protected] with a range of >=16.0.0.

So I am not sure about ls-engine's suggestion of adding

"engines": {
  "node": ">= 14.13"
}

Running it just on the non-dev dependencies I get:

My script: {"node":"14.0.0"}
ls-engines: {"node": ">= 14.13"}

I guess ls-engines also includes the range of the project's package.json (^14.13.1 || >=16.0.0) and not just the ranges in the node_modules (--mode=actual)?

@ljharb
Copy link

ljharb commented Nov 5, 2023

la-engines only considers prod deps by default; run it with --dev if you want those included.

ofc, on a published package engines should only consider prod deps anyways.

@sdavids
Copy link
Author

sdavids commented Nov 5, 2023

$ npx ls-engines --mode=actual --dev

gives: {"node": ">= 16.14"} -- the same result as my script above.

With explicit engines field:

$ npx ls-engines --mode=actual

┌───────────────────────────────────┬───────────────────────────┐
│ package engines:                  │ dependency graph engines: │
├───────────────────────────────────┼───────────────────────────┤
│ "engines": {                      │ "engines": {              │
│   "node": "^14.13.1 || >= 16.0.0""node": ">= 14.13"      │
│ }                                 │ }                         │
└───────────────────────────────────┴───────────────────────────┘

Your “engines” field allows fewer node versions than your dependency graph does.

If you want to widen your support, you can run `ls-engines --save`, or manually add the following to your `package.json`:
"engines": {
  "node": ">= 14.13"
}

Without explicit engines field:

$ npx ls-engines --mode=actual

┌──────────────────┬───────────────────────────┐
│ package engines: │ dependency graph engines: │
├──────────────────┼───────────────────────────┤
│ "engines": {     │ "engines": {              │
│   "node": "*""node": ">= 14"         │
│ }                │ }                         │
└──────────────────┴───────────────────────────┘

Your “engines” field is missing! Prefer explicitly setting a supported engine range.

You can fix this by running `ls-engines --save`, or by manually adding the following to your `package.json`:
"engines": {
  "node": ">= 14"
}

Should dependency graph engines not be {"node": ">= 14"} in both case?

@ljharb
Copy link

ljharb commented Nov 5, 2023

Feel free to file a bug on the repo if its output is confusing.

@npm npm deleted a comment from Shadowyfigurings87 Nov 28, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants