diff --git a/src/npm/npm_parse.ml b/src/npm/npm_parse.ml index 0b13390..2a1391d 100644 --- a/src/npm/npm_parse.ml +++ b/src/npm/npm_parse.ml @@ -12,11 +12,12 @@ - The dependency-spec classification below. npm accepts git, file, link, workspace and tag specs that no registry lookup can resolve; those rows are dropped and counted rather than guessed at. - - The engines encoding. A semver constant is compared inside the - calculus by the platform valuation's ordering, which is plain string - order, so numeric components are zero-padded here to make string - order agree with version order. Prerelease tags in an engines range - are dropped. + - The os/cpu/libc encoding. Each list becomes a platform gate over + the valuation's variable of that name; npm applies the same test at + reify time (EBADPLATFORM). "engines" is deliberately *not* a gate: + npm never consults it when choosing versions, and --engine-strict + only promotes the install-time warning to an error, so gating on it + would make our instance strictly smaller than npm's. - The optionalDependencies reading. Such a row is an ordinary dependency that npm abandons in exactly one situation: its manifest @@ -188,28 +189,12 @@ let peer_of (meta : (string * Yojson.Safe.t) list) (key, spec) : peer option = (* ---- platform gates ---- *) -(* the calculus compares a gate's constant with the valuation's ordering, - which is plain string order, so a version constant is padded until - string order and version order agree *) -let pad (v : string) : string = - let p = Npm_version.parse v in - Printf.sprintf "%05d.%05d.%05d" p.Npm_version.major p.Npm_version.minor - p.Npm_version.patch - -let gate_of_comparator (x : string) (ct : Npm_version.comparator) : gate = - match ct with - | Npm_version.Any -> GTrue - | Npm_version.Cmp (o, c) -> GCmp (o, x, pad c) - let conj = List.fold_left (fun a b -> GAnd (a, b)) GTrue let disj = function | [] -> GFalse | g :: gs -> List.fold_left (fun a b -> GOr (a, b)) g gs -let gate_of_range (x : string) (rg : Npm_version.range) : gate = - disj (List.map (fun cs -> conj (List.map (gate_of_comparator x) cs)) rg) - (* os and cpu are lists whose positives are alternatives and whose !-prefixed entries are exclusions *) let gate_of_list (x : string) (l : string list) : gate option = @@ -231,26 +216,12 @@ let gate_of_list (x : string) (l : string list) : gate option = in match pos @ neg with [] -> None | g :: gs -> Some (conj (g :: gs)) +(* "engines" is not read: see the header. os/cpu/libc are, because npm + does refuse a package whose platform its host fails. *) let gates_of (j : Yojson.Safe.t) : gate list = - (* npm enforces only node and npm from engines; iojs, yarn, pnpm, bun, - vscode and friends are informational, and treating an unknown key as - a veto would cut every version of a package that carries one. *) - let eng = - List.filter_map - (fun (k, v) -> - match v with - | `String rg when List.mem k [ "node"; "npm" ] && not (unresolvable rg) - -> - Some (gate_of_range k (Npm_version.parse_range rg)) - | _ -> None) - (assoc_of (member "engines" j)) - in - let plat = - List.filter_map - (fun (x, l) -> gate_of_list x (str_list (member l j))) - [ ("os", "os"); ("cpu", "cpu"); ("libc", "libc") ] - in - eng @ plat + List.filter_map + (fun (x, l) -> gate_of_list x (str_list (member l j))) + [ ("os", "os"); ("cpu", "cpu"); ("libc", "libc") ] (* ---- manifests ---- *) diff --git a/src/npm/npm_solve.ml b/src/npm/npm_solve.ml index b08231a..7baf7a4 100644 --- a/src/npm/npm_solve.ml +++ b/src/npm/npm_solve.ml @@ -44,20 +44,17 @@ module T = Np.T by the theory, and each is a place where this driver may disagree with npm. *) -(* The platform valuation. engines/os/cpu are gates against a fixed - environment; an unset variable fails its gate, which is npm's - engine-strict reading. A real frontend would read these from the host - and from .npmrc. *) -let host_node = "22.0.0" -let host_npm = "10.0.0" +(* The platform valuation. os/cpu/libc are gates against a fixed + environment; an unset variable fails its gate. engines is not gated + at all -- npm does not consult it when selecting versions -- so the + parser mints no gate over "node" or "npm" and this has no entry for + them. A real frontend would read these from the host. *) let host_os = "linux" let host_cpu = "x64" let host_libc = "glibc" let rho (x : string) : string option = match x with - | "node" -> Some (P.pad host_node) - | "npm" -> Some (P.pad host_npm) | "os" -> Some host_os | "cpu" -> Some host_cpu | "libc" -> Some host_libc @@ -572,6 +569,60 @@ end module PG = Pubgrub.Make (PName) (PVersion) +let greatest = function + | [] -> invalid_arg "greatest" + | c :: cs -> + List.fold_left (fun a b -> if 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. *) +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 + +(* A directory no dependency row of p names: only a peer asks for it, so + childCands offers every published version of the target and nothing + narrows the slot but the peer ranges. *) +let peer_only st p (a : string) = + not + (List.exists (fun (d : Np.coq_DepRow) -> d.Np.d_dir = a) (active_rows st p)) + +(* Is the candidate's own granular node already carried? It holds + exactly one version, so any assignment to it is that version. *) +let carried ~assigned (m : string * string) (c : PVersion.t) = + match c with + | Np.Vs.Gran _ -> false + | Np.Vs.Orig u -> ( + match assigned (Np.Nm.Granular (m, u)) with + | PG.Unselected -> false + | PG.Decided w -> PVersion.compare w c = 0 + | PG.Entailed r -> PG.Ranges.contains c r) + +(* npm fills a peer slot from the tree before resolving it, so a version + the partial solution already carries outranks both the dist-tag and + the newest; resolving afresh is what brings in a second tree of a + package the answer already holds. Preference only, as in + deb_solve's alt_carried: the filter falls back to the whole candidate + list, so nothing that was satisfiable stops being so. *) +let choose st ~assigned (nm : PName.t) (cands : PVersion.t list) : PVersion.t = + match nm with + | Np.Nm.Granular _ -> greatest cands + | Np.Nm.Intermediate (k, v, m) -> ( + let cands = + if peer_only st (snd k, v) (fst m) then + match List.filter (carried ~assigned m) cands with + | [] -> cands + | reused -> reused + else cands + in + match tagged st (snd m) cands with Some c -> c | None -> greatest cands) + type result = { installs : ((string * string) * string) list; tree : (((string * string) * string) * ((string * string) * string)) list; @@ -602,7 +653,7 @@ let solve ?(debug = false) ar (root : string * string) = r in match - PG.solve ~vers:versions ~deps:dependencies + PG.solve ~choose:(choose st) ~vers:versions ~deps:dependencies [ (root_nm, PG.Ranges.of_list [ Np.Vs.Orig (snd root) ]) ] with | Error inc ->