diff --git a/eval/npm/cmp.sh b/eval/npm/cmp.sh index 863d36c..3ee33f9 100755 --- a/eval/npm/cmp.sh +++ b/eval/npm/cmp.sh @@ -10,7 +10,12 @@ # # Baselines are kept in lock-.json and reused; they were taken with # the npm in npm-version, whose arborist differs across majors, so -# regenerate them all or none. EXTRA pins names on *both* sides at once +# regenerate them all or none. npm-version is also the host our side is +# given: npm-pick-manifest ranks engines against the running node and +# npm, so leaving ours unset would have the two sides sorting candidates +# by different rules -- the mistake eval/cargo made with --rust-version. +# +# EXTRA pins names on *both* sides at once # -- "name@version ..." appended to the wrapper's dependencies -- so a # divergence can be re-asked with npm's pick forced: if the two then # coincide, npm's answer was one our instance already admitted and only @@ -42,7 +47,12 @@ else lock="$S/lock-$slug.json" fi -"$exe" npm --offline --cache "$RUN/cache" --tree "$root" > "$out/$slug.ours" 2>&1 +npmv=$(sed -n 1p "$S/npm-version") +nodev=$(sed -n 2p "$S/npm-version") + +"$exe" npm --offline --cache "$RUN/cache" --tree \ + ${nodev:+--node-version "$nodev"} ${npmv:+--npm-version "$npmv"} \ + "$root" > "$out/$slug.ours" 2>&1 if [ ! -s "$lock" ]; then ( cd "$W" && rm -f package-lock.json && \ diff --git a/eval/npm/valid.sh b/eval/npm/valid.sh index e16c6ab..3fa1806 100755 --- a/eval/npm/valid.sh +++ b/eval/npm/valid.sh @@ -67,7 +67,14 @@ mkdir -p "$W" root=$(python3 "$S/mkroot.py" "$RUN/cache" "$goal" "$W" "$pin" | cut -d' ' -f1) cd "$S/../.." -"$exe" npm --offline --cache "$RUN/cache" --tree "$root" > "$out/$slug.ours" 2>&1 +# the same host cmp.sh gives, so the answer validated here is the answer +# compared there rather than a differently ranked sibling of it +npmv=$(sed -n 1p "$S/npm-version") +nodev=$(sed -n 2p "$S/npm-version") + +"$exe" npm --offline --cache "$RUN/cache" --tree \ + ${nodev:+--node-version "$nodev"} ${npmv:+--npm-version "$npmv"} \ + "$root" > "$out/$slug.ours" 2>&1 if ! grep -q '^node_modules' "$out/$slug.ours"; then printf '%-24s NO SOLUTION (see %s)\n' "$goal" "$out/$slug.ours" exit 1 diff --git a/eval/npm/verdict.py b/eval/npm/verdict.py index b5515dd..89571b2 100644 --- a/eval/npm/verdict.py +++ b/eval/npm/verdict.py @@ -12,21 +12,27 @@ edge (R wants directory d, npm resolved it to V) that is two tests: the requirer's row differs from npm's -- an alias or an override read differently, or the edge attributed to the wrong requirer -- and is not a preference at all. - gate does V's manifest pass the gates our frontend applies at its - hardcoded host (node 22.0.0, npm 10.0.0, linux/x64/glibc)? - A failure here would be our instance being strictly smaller - than npm's. + gate does V's manifest pass the gates our frontend applies at the + run host (linux/x64/glibc)? A failure here would be our + instance being strictly smaller than npm's. range and gate both pass -> preference gap gate fails -> instance gap (gate) range fails -> instance gap (row read differently) -The gate test is now a tripwire rather than a classification: our -frontend applies no engines gate (dropped in 68d24de) and no -os/cpu/libc gate (dropped when npm's resolution became -platform-independent), so it reads the manifests and not our instance. -A "gate" verdict today means either a regression or a version the -snapshot simply lacks. +The gate test is a tripwire rather than a classification: our frontend +applies no os/cpu/libc gate, npm's resolution being platform-independent, +so it reads the manifests and not our instance. A "gate" verdict means +either a regression or a version the snapshot simply lacks. + +engines is deliberately not a gate on either side. npm-pick-manifest +sorts on it (lib/index.js:165-179) and installs an unrunnable version +when nothing else matches, and so do we, so npm picking a version this +host cannot run is an ordinary preference gap. It is named separately +because engines is the one preference key whose input is a host +constant: both sides have to be given the same node and npm versions, or +they rank by different rules and every such edge diverges. The host +here is npm-version, which is what cmp.sh passes our side. usage: verdict.py """ @@ -81,9 +87,21 @@ def resolve_semver(): return "semver" +def resolve_host(): + """npm-version, the file setup.sh writes: npm's version then node's""" + host = {"os": "linux", "cpu": "x64", "libc": "glibc"} + try: + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), + "npm-version")) as f: + npm, node = [l.strip().lstrip("v=") for l in f][:2] + host["npm"], host["node"] = npm, node + except (OSError, ValueError): + host["npm"], host["node"] = "", "" + return host + + SEMVER = resolve_semver() -HOST = {"node": "22.0.0", "npm": "10.0.0", "os": "linux", "cpu": "x64", - "libc": "glibc"} +HOST = resolve_host() def escape(name): @@ -159,7 +177,7 @@ def main(): rg = declared(rm, d) if rm else None eng = (vm or {}).get("engines", {}) eng = {k: v for k, v in eng.items() - if k in ("node", "npm") and isinstance(v, str)} + if k in ("node", "npm") and isinstance(v, str) and HOST[k]} rows.append((rn, rv, d, vn, vv, rg, vm, eng)) if rg is not None: pairs.append([vv, rg]) @@ -172,12 +190,12 @@ def main(): why = "instance gap (gate): version absent from snapshot" elif not all(listed(vm, f, HOST[f]) for f in ("os", "cpu", "libc")): why = "instance gap (gate): os/cpu/libc" - elif not all(sat[(HOST[k], v)] for k, v in eng.items()): - why = "instance gap (gate): engines" elif rg is None: why = "unclassified: requirer's row not found" elif sat[(vv, rg)] is not True: why = "instance gap: row read differently" + elif not all(sat[(HOST[k], v)] for k, v in eng.items()): + why = "preference gap (engines)" else: why = "preference gap" counts[why] = counts.get(why, 0) + 1 diff --git a/src/main.ml b/src/main.ml index 002e6db..1390ddf 100644 --- a/src/main.ml +++ b/src/main.ml @@ -316,12 +316,12 @@ let alpine_cmd = (Cmd.info "alpine" ~doc:"Solve against an Alpine APKINDEX.") Term.(const alpine_run $ debug_arg $ path $ goals) -let npm_run debug cache offline tree omit goal wanted = +let npm_run debug cache offline tree omit nodev npmv goal wanted = let t0 = Unix.gettimeofday () in let ar = Npm_solve.empty_archive ~optional:(not (List.mem "optional" omit)) - ~cache ~offline () + ~node:nodev ~npm:npmv ~cache ~offline () in let vs = List.map @@ -407,6 +407,26 @@ let npm_cmd = "Omit a dependency class; $(b,optional) is the class \ distinguished here.") in + (* npm-pick-manifest's engines preference, which needs a host to rank + against -- npm reads its own and node's, this asks for them *) + let nodev = + Arg.( + value + & opt (some string) None + & info [ "node-version" ] ~docv:"VERSION" + ~doc: + "Host node version to prefer engines-compatible package \ + versions for; unset leaves engines.node untested.") + in + let npmv = + Arg.( + value + & opt (some string) None + & info [ "npm-version" ] ~docv:"VERSION" + ~doc: + "Host npm version, the other sub-key checkEngine reads; unset \ + leaves engines.npm untested.") + in let goal = Arg.( required @@ -423,7 +443,8 @@ let npm_cmd = Cmd.v (Cmd.info "npm" ~doc:"Solve against the npm registry.") Term.( - const npm_run $ debug_arg $ cache $ offline $ tree $ omit $ goal $ wanted) + const npm_run $ debug_arg $ cache $ offline $ tree $ omit $ nodev $ npmv + $ goal $ wanted) let () = let doc = "Solve dependencies through the verified package calculus." in diff --git a/src/npm/npm_parse.ml b/src/npm/npm_parse.ml index 195cf79..c353a37 100644 --- a/src/npm/npm_parse.ml +++ b/src/npm/npm_parse.ml @@ -12,13 +12,19 @@ - The dependency-spec classification below. npm accepts git, file, link, workspace and tag specs that no registry lookup can resolve; those dependencies are dropped and counted rather than guessed at. - Deliberately *not* read: "engines" and "os"/"cpu"/"libc". npm - consults none of them when choosing versions -- a package-lock.json - records every platform's variant of an optional dependency whatever - host wrote it, and the filtering happens at install time - (EBADPLATFORM, and --engine-strict promoting a warning to an error). - Reading them here would make our instance strictly smaller than - npm's. + Deliberately *not* read: "os", "cpu" and "libc". npm consults none + of them when choosing a version -- npm-pick-manifest has no platform + key at all -- so a package-lock.json records every platform's variant + of an optional dependency whatever host wrote it, and the filtering + happens at install time (EBADPLATFORM). Reading them here would make + our instance strictly smaller than npm's. + + "engines" is a different case and is read, because npm-pick-manifest + sorts on it. It is not a gate: a version the host cannot run is + ranked below one it can and is still installable when nothing else + matches, which is why --engine-strict exists to promote the + install-time warning to an error. Only the "node" and "npm" sub-keys + are live, because checkEngine tests those two and nothing else. - The optionalDependencies reading. Such an entry is an ordinary dependency that npm abandons in exactly one situation: its manifest @@ -32,7 +38,7 @@ the same name, which is what the dependency assembly below does. Not modelled, and counted where it matters: bundledDependencies - (placement) and "deprecated" (npm warns and installs anyway). *) + (placement). *) type dep = { d_dir : string; (* the directory key, i.e. the manifest key *) @@ -53,6 +59,11 @@ type ver = { v_peers : peer list; v_ovr : (string * Npm_version.range) list; v_deprecated : bool; + (* engines.node and engines.npm, the two sub-keys checkEngine tests; + absent means the version imposes no requirement, which is what makes + it rank above one whose requirement the host fails *) + v_eng_node : Npm_version.range option; + v_eng_npm : Npm_version.range option; } type packument = { @@ -191,6 +202,13 @@ let overrides_of (j : Yojson.Safe.t) : (string * Npm_version.range) list = let is_deprecated = function `Null -> false | `Bool b -> b | _ -> true +(* checkEngine reads eng.node and eng.npm and ignores everything else in + the object, so a sub-key such as "yarn" is not a requirement at all *) +let engine_of (j : Yojson.Safe.t) (k : string) : Npm_version.range option = + match member k (member "engines" j) with + | `String rg -> Some (Npm_version.parse_range rg) + | _ -> None + let ver_of ~(root : bool) (vers : string) (j : Yojson.Safe.t) : ver option = match j with | `Assoc _ -> @@ -224,6 +242,8 @@ let ver_of ~(root : bool) (vers : string) (j : Yojson.Safe.t) : ver option = v_peers = peers; v_ovr = (if root then overrides_of j else []); v_deprecated = dep; + v_eng_node = engine_of j "node"; + v_eng_npm = engine_of j "npm"; } | _ -> reject (); diff --git a/src/npm/npm_solve.ml b/src/npm/npm_solve.ml index 4eb9dfa..3cd584c 100644 --- a/src/npm/npm_solve.ml +++ b/src/npm/npm_solve.ml @@ -46,8 +46,27 @@ module T = Np.T npm. *) (* No platform valuation: resolution here is platform-independent, as - npm's is. engines, os, cpu and libc are all install-time tests, so - the parser reads none of them and nothing cuts the repository. *) + npm's is. os, cpu and libc are install-time tests alone -- they + appear nowhere in npm-pick-manifest -- so the parser reads none of + them and nothing cuts the repository. *) + +(* The host npm-pick-manifest ranks engines against. npm always has one, + arborist passing process.version as nodeVersion and the CLI its own + version as npmVersion, but nothing here can read a node that need not + be installed, so an unset half leaves that sub-key untested exactly as + checkEngine does for a null version: every candidate then passes and + the engine keys tie, leaving deprecated and semver to decide. A + correspondence harness has to supply both, as eval/cargo does for + --rust-version, or the two sides rank by different rules. *) +let node_version : string option = None +let npm_version : string option = None + +(* semver takes a leading v or = on the version it tests, which is the + shape `node --version` prints; our comparator parses digits only. *) +let host_version (s : string) : string = + if s <> "" && (s.[0] = 'v' || s.[0] = 'V' || s.[0] = '=') then + String.sub s 1 (String.length s - 1) + else s (* devDependencies participate only from the root package: that is depActive's rule in the theory, not a choice made here, but only the @@ -65,6 +84,8 @@ type archive = { (* false under --omit=optional: an optional dependency is then dropped outright rather than only when the registry cannot satisfy it *) optional : bool; + node : string option; + npm : string option; pkgs : (string, P.ver list) Hashtbl.t; latest : (string, string) Hashtbl.t; entry : (string * string, P.ver) Hashtbl.t; @@ -80,11 +101,14 @@ type archive = { mutable n_opt_dropped : int; } -let empty_archive ?(optional = true) ~cache ~offline () = +let empty_archive ?(optional = true) ?(node = node_version) + ?(npm = npm_version) ~cache ~offline () = { cache; offline; optional; + node = Option.map host_version node; + npm = Option.map host_version npm; pkgs = Hashtbl.create 1024; latest = Hashtbl.create 1024; entry = Hashtbl.create 16384; @@ -514,17 +538,83 @@ let greatest = function | c :: cs -> List.fold_left (fun a b -> if PVersion.compare b a > 0 then b else a) c cs +(* checkEngine, npm-install-checks/lib/index.js: engines.node and + engines.npm are the two sub-keys it tests, and a null host version + passes its own sub-key rather than failing it, so a package declaring + a requirement we have no host for is ranked as though it declared + none. *) +let engine_ok st (p : string * string) : bool = + match meta st.ar p with + | None -> true + | Some v -> + let ok host rg = + match (host, rg) with + | Some h, Some rg -> Npm_version.holds_pre h rg + | _ -> true + in + ok st.ar.node v.P.v_eng_node && ok st.ar.npm v.P.v_eng_npm + +let deprecated st (p : string * string) : bool = + match meta st.ar p with None -> false | Some v -> v.P.v_deprecated + +(* npm-pick-manifest's sort keys above semver, lib/index.js:167-181: + + ((notdeprb && engineb) - (notdepra && enginea)) || + (engineb - enginea) || + (notdeprb - notdepra) || + semver.rcompare(vera, verb, sortSemverOpt) + + deprecated and engines are one preference because they are one sort + function, and the middle key is what orders them against each other: a + deprecated version the host can run outranks a current one it cannot. + Neither drops a candidate, so neither can make anything unsatisfiable + -- a package whose every version is deprecated resolves to its newest, + and a pinned version the host cannot run still installs, which is what + --engine-strict exists to refuse at install time. + + The three keys npm sorts above these have no counterpart here. avoid + is npm audit fix's, passed by nothing that writes an ordinary + lockfile; policyRestrictions and stagedVersions appear in no public + packument, and the parser reads neither, so restricted and staged are + uniformly false. A Gran version stands for a granularity class rather + than a release, so it carries neither key. *) +let pick_keys st (n : string) (c : PVersion.t) : bool * bool * bool = + match c with + | Np.Vs.Gran _ -> (true, true, true) + | Np.Vs.Orig v -> + let p = (n, v) in + let nd = not (deprecated st p) and eng = engine_ok st p in + (nd && eng, eng, nd) + +let best st (n : string) (cands : PVersion.t list) : PVersion.t = + match cands with + | [] -> invalid_arg "best" + | c :: cs -> + List.fold_left + (fun a b -> + let d = compare (pick_keys st n b) (pick_keys st n a) in + if d > 0 || (d = 0 && PVersion.compare b a > 0) then b else a) + c cs + (* npm-pick-manifest offers dist-tags.latest before the highest version the range admits, and takes it whenever the range admits it; only the ordering differs from ours, so a package published ahead of its own latest tag no longer drags its newest release in. Preference only: - the tag is consulted inside the candidates, never outside them. *) + the tag is consulted inside the candidates, never outside them. + + The fast path is guarded by the same two keys as the sort + (index.js:119-132, [engineOk(mani, ..) && !mani.deprecated]), so a + deprecated or unrunnable latest is not a shortcut past them. It is + not merely redundant with the sort: the tag may name a version the + sort would rank below a newer one. *) let tagged st (n : string) (cands : PVersion.t list) : PVersion.t option = match Hashtbl.find_opt st.ar.latest n with | None -> None | Some l -> - let l = Np.Vs.Orig l in - List.find_opt (fun c -> PVersion.compare c l = 0) cands + if deprecated st (n, l) || not (engine_ok st (n, l)) then None + else + let l = Np.Vs.Orig l in + List.find_opt (fun c -> PVersion.compare c l = 0) cands (* A directory no dependency of p names: only a peer asks for it, so childCands offers every published version of the target and nothing @@ -563,7 +653,9 @@ let choose st ~assigned (n : PName.t) (cands : PVersion.t list) : PVersion.t = | reused -> reused else cands in - match tagged st (snd m) cands with Some c -> c | None -> greatest cands) + match tagged st (snd m) cands with + | Some c -> c + | None -> best st (snd m) cands) type result = { installs : ((string * string) * string) list; diff --git a/src/npm/npm_version.ml b/src/npm/npm_version.ml index a3580a2..fabd4ec 100644 --- a/src/npm/npm_version.ml +++ b/src/npm/npm_version.ml @@ -346,6 +346,15 @@ let cs_holds cs v = let holds (v : string) (rg : range) : bool = List.exists (fun cs -> cs_holds cs v) rg +(* semver's includePrerelease, which checkEngine passes and a dependency + range never does: cs_admits is dropped, so a prerelease version is + ordered by an ordinary comparator rather than refused by one that + names no prerelease. It matters only for a prerelease host -- an + engines range is matched against the running node or npm, not against + a published version. *) +let holds_pre (v : string) (rg : range) : bool = + List.exists (fun cs -> List.for_all (fun ct -> comp_match ct v) cs) rg + let string_of_op = function | Ge -> ">=" | Gt -> ">" diff --git a/test/frontends/npm.t/depr-all.json b/test/frontends/npm.t/depr-all.json new file mode 100644 index 0000000..01df1ae --- /dev/null +++ b/test/frontends/npm.t/depr-all.json @@ -0,0 +1,4 @@ +{"name":"depr-all","dist-tags":{"latest":"2.0.0"}, + "versions":{ + "1.0.0":{"name":"depr-all","version":"1.0.0","deprecated":"unmaintained"}, + "2.0.0":{"name":"depr-all","version":"2.0.0","deprecated":"unmaintained"}}} diff --git a/test/frontends/npm.t/depr-app.json b/test/frontends/npm.t/depr-app.json new file mode 100644 index 0000000..51da467 --- /dev/null +++ b/test/frontends/npm.t/depr-app.json @@ -0,0 +1,4 @@ +{"name":"depr-app","dist-tags":{"latest":"1.0.0"}, + "versions":{ + "1.0.0":{"name":"depr-app","version":"1.0.0", + "dependencies":{"depr":"*","depr-old":"^1","depr-all":"*"}}}} diff --git a/test/frontends/npm.t/depr-old.json b/test/frontends/npm.t/depr-old.json new file mode 100644 index 0000000..73cfbca --- /dev/null +++ b/test/frontends/npm.t/depr-old.json @@ -0,0 +1,5 @@ +{"name":"depr-old","dist-tags":{"latest":"3.0.0"}, + "versions":{ + "1.0.0":{"name":"depr-old","version":"1.0.0"}, + "1.1.0":{"name":"depr-old","version":"1.1.0","deprecated":"unmaintained"}, + "3.0.0":{"name":"depr-old","version":"3.0.0"}}} diff --git a/test/frontends/npm.t/depr.json b/test/frontends/npm.t/depr.json new file mode 100644 index 0000000..142b568 --- /dev/null +++ b/test/frontends/npm.t/depr.json @@ -0,0 +1,4 @@ +{"name":"depr","dist-tags":{"latest":"2.0.0"}, + "versions":{ + "1.0.0":{"name":"depr","version":"1.0.0"}, + "2.0.0":{"name":"depr","version":"2.0.0","deprecated":"unmaintained"}}} diff --git a/test/frontends/npm.t/engine-app.json b/test/frontends/npm.t/engine-app.json new file mode 100644 index 0000000..b082c4b --- /dev/null +++ b/test/frontends/npm.t/engine-app.json @@ -0,0 +1,4 @@ +{"name":"engine-app","dist-tags":{"latest":"1.0.0"}, + "versions":{ + "1.0.0":{"name":"engine-app","version":"1.0.0", + "dependencies":{"engine":"*","engine-npm":"*","engine-depr":"*"}}}} diff --git a/test/frontends/npm.t/engine-depr.json b/test/frontends/npm.t/engine-depr.json new file mode 100644 index 0000000..cc2fad9 --- /dev/null +++ b/test/frontends/npm.t/engine-depr.json @@ -0,0 +1,4 @@ +{"name":"engine-depr","dist-tags":{"latest":"2.0.0"}, + "versions":{ + "1.0.0":{"name":"engine-depr","version":"1.0.0","deprecated":"unmaintained"}, + "2.0.0":{"name":"engine-depr","version":"2.0.0","engines":{"node":">=99"}}}} diff --git a/test/frontends/npm.t/engine-npm.json b/test/frontends/npm.t/engine-npm.json new file mode 100644 index 0000000..e49f18e --- /dev/null +++ b/test/frontends/npm.t/engine-npm.json @@ -0,0 +1,4 @@ +{"name":"engine-npm","dist-tags":{"latest":"2.0.0"}, + "versions":{ + "1.0.0":{"name":"engine-npm","version":"1.0.0"}, + "2.0.0":{"name":"engine-npm","version":"2.0.0","engines":{"npm":">=99"}}}} diff --git a/test/frontends/npm.t/engine.json b/test/frontends/npm.t/engine.json new file mode 100644 index 0000000..4fcb90a --- /dev/null +++ b/test/frontends/npm.t/engine.json @@ -0,0 +1,4 @@ +{"name":"engine","dist-tags":{"latest":"2.0.0"}, + "versions":{ + "1.0.0":{"name":"engine","version":"1.0.0"}, + "2.0.0":{"name":"engine","version":"2.0.0","engines":{"node":">=99"}}}} diff --git a/test/frontends/npm.t/run.t b/test/frontends/npm.t/run.t index 7f0d0d6..56aee4e 100644 --- a/test/frontends/npm.t/run.t +++ b/test/frontends/npm.t/run.t @@ -208,6 +208,86 @@ peer dependency's range exactly as it replaces a dependency's. cone: 3 packages, 4 versions, 0 packuments fetched encoded solution: 5 core nodes (11 lookups) +deprecated is a resolution preference, not a warning printed over a pick +already made: npm-pick-manifest ranks a non-deprecated version above a +deprecated one and above semver order, and the dist-tags.latest fast path +is taken only when the tagged version is not deprecated. depr publishes +1.0.0 clean and 2.0.0 deprecated and tags 2.0.0 latest, so the fast path +is refused and 1.0.0 is picked. depr-old is the same shape one major +down, where the tag never enters into it: latest is 3.0.0, which ^1 +refuses, and the sort alone demotes the deprecated 1.1.0 to leave 1.0.0. +depr-all is the guard that this is a preference: every version in range +is deprecated, the key ties, and the newest is picked exactly as before. + + $ ../../../src/main.exe npm --offline --cache . --tree depr-app | sed -E '/^(parse|solve) [0-9.]+s$/d' + root depr-app 1.0.0 + packages (4): + depr 1.0.0 + depr-all 2.0.0 + depr-app 1.0.0 + depr-old 1.0.0 + node_modules (3 edges): + depr-app 1.0.0 <- depr 1.0.0 + depr-app 1.0.0 <- depr-all 2.0.0 + depr-app 1.0.0 <- depr-old 1.0.0 + cone: 4 packages, 8 versions, 0 packuments fetched + encoded solution: 7 core nodes (17 lookups) + +engines is the other half of the same sort, so it needs a host to rank +against and there is none unless one is given. Unset, every candidate +passes the engine test, the key ties, and only deprecated and semver +decide: engine and engine-npm take their newest, and engine-depr takes +the non-deprecated 2.0.0. + + $ ../../../src/main.exe npm --offline --cache . --tree engine-app | sed -E '/^(parse|solve) [0-9.]+s$/d' + root engine-app 1.0.0 + packages (4): + engine 2.0.0 + engine-app 1.0.0 + engine-depr 2.0.0 + engine-npm 2.0.0 + node_modules (3 edges): + engine-app 1.0.0 <- engine 2.0.0 + engine-app 1.0.0 <- engine-depr 2.0.0 + engine-app 1.0.0 <- engine-npm 2.0.0 + cone: 4 packages, 7 versions, 0 packuments fetched + encoded solution: 7 core nodes (17 lookups) + +Given a host, the preference acts, and engines.npm is as live a sub-key as +engines.node: engine's 2.0.0 wants node >=99 and engine-npm's wants npm +>=99, and both fall back to 1.0.0. engine-depr fixes the order of the two +keys against each other. Its 1.0.0 is deprecated and buildable, its 2.0.0 +is current and unbuildable, and npm sorts on (not deprecated and engine +ok) before engine ok before not deprecated -- the first key ties at false, +so the engine key decides and the deprecated 1.0.0 wins. + + $ ../../../src/main.exe npm --offline --cache . --tree --node-version v24.19.0 --npm-version 11.17.0 engine-app | sed -E '/^(parse|solve) [0-9.]+s$/d' + root engine-app 1.0.0 + packages (4): + engine 1.0.0 + engine-app 1.0.0 + engine-depr 1.0.0 + engine-npm 1.0.0 + node_modules (3 edges): + engine-app 1.0.0 <- engine 1.0.0 + engine-app 1.0.0 <- engine-depr 1.0.0 + engine-app 1.0.0 <- engine-npm 1.0.0 + cone: 4 packages, 7 versions, 0 packuments fetched + encoded solution: 7 core nodes (17 lookups) + +Neither key is a gate, so pinning past the preference still resolves: the +root asked for * and engine 2.0.0 is a version the host cannot run, yet +naming it directly installs it, exactly as npm records an EBADENGINE +package in a lockfile and complains at install time. + + $ ../../../src/main.exe npm --offline --cache . --node-version v24.19.0 engine 2.0.0 | sed -E '/^(parse|solve) [0-9.]+s$/d' + root engine 2.0.0 + packages (1): + engine 2.0.0 + node_modules edges: 0 + cone: 1 packages, 2 versions, 0 packuments fetched + encoded solution: 1 core nodes (2 lookups) + A package with no packument in the cache cannot be fetched when offline. $ ../../../src/main.exe npm --offline --cache . missing