|
| 1 | +//! Which lockfile governs a project's installs, per ecosystem: the ONE |
| 2 | +//! precedence table every mode reads. |
| 3 | +//! |
| 4 | +//! Before this table the npm-family order lived in the vendored router, its |
| 5 | +//! in-memory copy, the inventory's migration-leftover fallback, the hosted |
| 6 | +//! vlt `SIBLING_LOCKS` list, the hosted vlt preflight inputs, the hosted |
| 7 | +//! npm rewriter's "another lock owns it" check and the hosted governing-root |
| 8 | +//! lock-root and workspace-root lists; the PyPI order lived in the |
| 9 | +//! vendored router, the hosted `pdm_drives` gate and the agent-mode PDM |
| 10 | +//! crawler. Each was a hand copy, and a precedence change had to land in all |
| 11 | +//! of them. |
| 12 | +//! |
| 13 | +//! The table is presence-only and PURE: callers stat the files (on disk, in |
| 14 | +//! a snapshot or in a memory project) and pass a predicate. Content |
| 15 | +//! decisions that refine a family (pnpm v9 vs legacy, yarn classic vs berry, |
| 16 | +//! a vlt lock's version) stay with the router that reads the bytes. |
| 17 | +//! |
| 18 | +//! What each mode DOES with the answer is deliberately different, and stays |
| 19 | +//! with the mode: vendored wires the governing lock only and warns about |
| 20 | +//! every other present lock ([`npm_locks_outside`], |
| 21 | +//! [`pypi_locks_outside`]); hosted rewrites every present lock and asks the |
| 22 | +//! table only to decide which lock confirms a pin or may veto its siblings |
| 23 | +//! ([`pypi_tool_lock_governs`], and vlt's `vlt_drives`). |
| 24 | +
|
| 25 | +use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, VLT_LOCK}; |
| 26 | + |
| 27 | +/// One npm-family wiring family: the locks one package manager installs |
| 28 | +/// from. The family that governs wires (or supersedes) every file in it. |
| 29 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 30 | +pub enum NpmLockFamily { |
| 31 | + /// `vlt-lock.json`. |
| 32 | + Vlt, |
| 33 | + /// `bun.lock` and the binary `bun.lockb` (Bun reads the text lock when |
| 34 | + /// both exist). |
| 35 | + Bun, |
| 36 | + /// The root `pnpm-lock.yaml`. |
| 37 | + Pnpm, |
| 38 | + /// `yarn.lock`, classic or berry. |
| 39 | + Yarn, |
| 40 | + /// `npm-shrinkwrap.json` and `package-lock.json` (npm prefers the |
| 41 | + /// shrinkwrap; npm 12 installs from the package-lock beside it). |
| 42 | + Npm, |
| 43 | +} |
| 44 | + |
| 45 | +impl NpmLockFamily { |
| 46 | + /// The root-relative lock files of this family, in its own preference |
| 47 | + /// order. |
| 48 | + pub const fn files(self) -> &'static [&'static str] { |
| 49 | + match self { |
| 50 | + NpmLockFamily::Vlt => &[VLT_LOCK], |
| 51 | + NpmLockFamily::Bun => &[BUN_LOCK, BUN_LOCKB], |
| 52 | + NpmLockFamily::Pnpm => &[PNPM_LOCK], |
| 53 | + NpmLockFamily::Yarn => &["yarn.lock"], |
| 54 | + NpmLockFamily::Npm => &NPM_LOCKS, |
| 55 | + } |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +/// The npm-family precedence, first present family wins. A Plug'n'Play |
| 60 | +/// loader is checked before any of these (it refuses or reclassifies the |
| 61 | +/// project whatever locks are present), and Rush's common lock only after |
| 62 | +/// none matched; both stay with the router. |
| 63 | +/// |
| 64 | +/// vlt first: a committed `vlt-lock.json` is only ever written by vlt. Bun |
| 65 | +/// before pnpm: Bun's isolated linker leaves a `node_modules/.bun/` store |
| 66 | +/// that looks like pnpm's, so the lock name decides. |
| 67 | +pub const NPM_PRECEDENCE: [NpmLockFamily; 5] = [ |
| 68 | + NpmLockFamily::Vlt, |
| 69 | + NpmLockFamily::Bun, |
| 70 | + NpmLockFamily::Pnpm, |
| 71 | + NpmLockFamily::Yarn, |
| 72 | + NpmLockFamily::Npm, |
| 73 | +]; |
| 74 | + |
| 75 | +/// Every root npm-family lock file, in precedence order. |
| 76 | +pub fn npm_lock_files() -> impl Iterator<Item = &'static str> { |
| 77 | + NPM_PRECEDENCE |
| 78 | + .into_iter() |
| 79 | + .flat_map(|family| family.files().iter().copied()) |
| 80 | +} |
| 81 | + |
| 82 | +/// The family whose lock governs installs: the first in |
| 83 | +/// [`NPM_PRECEDENCE`] with any file present, skipping `skip` (the inventory |
| 84 | +/// asks what a version-refused pnpm lock was shadowing). |
| 85 | +pub fn npm_governing_family( |
| 86 | + present: impl Fn(&str) -> bool, |
| 87 | + skip: Option<NpmLockFamily>, |
| 88 | +) -> Option<NpmLockFamily> { |
| 89 | + NPM_PRECEDENCE |
| 90 | + .into_iter() |
| 91 | + .filter(|family| Some(*family) != skip) |
| 92 | + .find(|family| family.files().iter().any(|file| present(file))) |
| 93 | +} |
| 94 | + |
| 95 | +/// The present lock files OUTSIDE `family`, in precedence order: the locks |
| 96 | +/// a single-lock wiring leaves untouched. |
| 97 | +pub fn npm_locks_outside( |
| 98 | + family: NpmLockFamily, |
| 99 | + present: impl Fn(&str) -> bool, |
| 100 | +) -> Vec<&'static str> { |
| 101 | + NPM_PRECEDENCE |
| 102 | + .into_iter() |
| 103 | + .filter(|other| *other != family) |
| 104 | + .flat_map(|other| other.files().iter().copied()) |
| 105 | + .filter(|file| present(file)) |
| 106 | + .collect() |
| 107 | +} |
| 108 | + |
| 109 | +/// The PyPI tool lockfiles, in precedence order (migration direction and |
| 110 | +/// ecosystem currency: uv > Poetry > PDM > Pipenv). Standalone PEP 751 / |
| 111 | +/// PEP 723 locks rank between uv and Poetry, but only when they pin the |
| 112 | +/// package being wired, so the vendored router decides them with the |
| 113 | +/// content in hand. `requirements.txt` and Hatch rank below every tool |
| 114 | +/// lock. |
| 115 | +pub const PYPI_TOOL_LOCKS: [&str; 4] = ["uv.lock", "poetry.lock", "pdm.lock", "Pipfile.lock"]; |
| 116 | + |
| 117 | +/// The PyPI requirements file the vendored router falls back to. |
| 118 | +pub const PYPI_REQUIREMENTS: &str = "requirements.txt"; |
| 119 | + |
| 120 | +/// The governing tool lock: the first of [`PYPI_TOOL_LOCKS`] present. |
| 121 | +pub fn pypi_governing_tool_lock(present: impl Fn(&str) -> bool) -> Option<&'static str> { |
| 122 | + PYPI_TOOL_LOCKS.into_iter().find(|lock| present(lock)) |
| 123 | +} |
| 124 | + |
| 125 | +/// Whether a tool lock of higher precedence than `lock` (one of |
| 126 | +/// [`PYPI_TOOL_LOCKS`]) is present: `lock`'s tool does not drive installs, |
| 127 | +/// whether or not `lock` itself exists yet. |
| 128 | +pub fn pypi_tool_lock_shadowed(lock: &str, present: impl Fn(&str) -> bool) -> bool { |
| 129 | + PYPI_TOOL_LOCKS |
| 130 | + .into_iter() |
| 131 | + .take_while(|higher| *higher != lock) |
| 132 | + .any(present) |
| 133 | +} |
| 134 | + |
| 135 | +/// Whether `lock` (one of [`PYPI_TOOL_LOCKS`]) is present and no tool lock |
| 136 | +/// of higher precedence is: a leftover lower-ranked lock neither drives |
| 137 | +/// installs nor may veto the governing one. |
| 138 | +pub fn pypi_tool_lock_governs(lock: &str, present: impl Fn(&str) -> bool) -> bool { |
| 139 | + present(lock) && !pypi_tool_lock_shadowed(lock, present) |
| 140 | +} |
| 141 | + |
| 142 | +/// The present tool locks other than the governing one, in precedence |
| 143 | +/// order. |
| 144 | +pub fn pypi_locks_outside(governing: &str, present: impl Fn(&str) -> bool) -> Vec<&'static str> { |
| 145 | + PYPI_TOOL_LOCKS |
| 146 | + .into_iter() |
| 147 | + .filter(|lock| *lock != governing && present(lock)) |
| 148 | + .collect() |
| 149 | +} |
| 150 | + |
| 151 | +#[cfg(test)] |
| 152 | +mod tests { |
| 153 | + use super::*; |
| 154 | + |
| 155 | + fn set<'a>(files: &'a [&'a str]) -> impl Fn(&str) -> bool + 'a { |
| 156 | + move |name| files.contains(&name) |
| 157 | + } |
| 158 | + |
| 159 | + #[test] |
| 160 | + fn npm_precedence_is_vlt_bun_pnpm_yarn_npm() { |
| 161 | + let all = [ |
| 162 | + "package-lock.json", |
| 163 | + "npm-shrinkwrap.json", |
| 164 | + "yarn.lock", |
| 165 | + "pnpm-lock.yaml", |
| 166 | + "bun.lockb", |
| 167 | + "bun.lock", |
| 168 | + "vlt-lock.json", |
| 169 | + ]; |
| 170 | + let expected = [ |
| 171 | + (NpmLockFamily::Vlt, 6), |
| 172 | + (NpmLockFamily::Bun, 4), |
| 173 | + (NpmLockFamily::Pnpm, 3), |
| 174 | + (NpmLockFamily::Yarn, 2), |
| 175 | + (NpmLockFamily::Npm, 0), |
| 176 | + ]; |
| 177 | + for (family, from) in expected { |
| 178 | + assert_eq!( |
| 179 | + npm_governing_family(set(&all[..=from]), None), |
| 180 | + Some(family), |
| 181 | + "{:?}", |
| 182 | + &all[..=from] |
| 183 | + ); |
| 184 | + } |
| 185 | + assert_eq!(npm_governing_family(set(&[]), None), None); |
| 186 | + assert_eq!( |
| 187 | + npm_governing_family(set(&["bun.lockb"]), None), |
| 188 | + Some(NpmLockFamily::Bun) |
| 189 | + ); |
| 190 | + assert_eq!( |
| 191 | + npm_governing_family(set(&["package-lock.json"]), None), |
| 192 | + Some(NpmLockFamily::Npm) |
| 193 | + ); |
| 194 | + } |
| 195 | + |
| 196 | + #[test] |
| 197 | + fn skip_reports_what_a_family_shadows() { |
| 198 | + let files = ["pnpm-lock.yaml", "yarn.lock", "package-lock.json"]; |
| 199 | + assert_eq!( |
| 200 | + npm_governing_family(set(&files), Some(NpmLockFamily::Pnpm)), |
| 201 | + Some(NpmLockFamily::Yarn) |
| 202 | + ); |
| 203 | + assert_eq!( |
| 204 | + npm_governing_family(set(&["pnpm-lock.yaml"]), Some(NpmLockFamily::Pnpm)), |
| 205 | + None |
| 206 | + ); |
| 207 | + } |
| 208 | + |
| 209 | + #[test] |
| 210 | + fn locks_outside_a_family_never_include_its_own_files() { |
| 211 | + let files = [ |
| 212 | + "vlt-lock.json", |
| 213 | + "bun.lock", |
| 214 | + "bun.lockb", |
| 215 | + "pnpm-lock.yaml", |
| 216 | + "yarn.lock", |
| 217 | + "npm-shrinkwrap.json", |
| 218 | + "package-lock.json", |
| 219 | + ]; |
| 220 | + assert_eq!(npm_lock_files().collect::<Vec<_>>(), files); |
| 221 | + assert_eq!( |
| 222 | + npm_locks_outside(NpmLockFamily::Vlt, set(&files)), |
| 223 | + files[1..] |
| 224 | + ); |
| 225 | + assert_eq!( |
| 226 | + npm_locks_outside(NpmLockFamily::Npm, set(&files)), |
| 227 | + files[..5] |
| 228 | + ); |
| 229 | + assert!(npm_locks_outside(NpmLockFamily::Bun, set(&["bun.lock", "bun.lockb"])).is_empty()); |
| 230 | + } |
| 231 | + |
| 232 | + #[test] |
| 233 | + fn pypi_tool_lock_precedence() { |
| 234 | + assert_eq!( |
| 235 | + pypi_governing_tool_lock(set(&["Pipfile.lock", "pdm.lock", "uv.lock"])), |
| 236 | + Some("uv.lock") |
| 237 | + ); |
| 238 | + assert!(pypi_tool_lock_governs( |
| 239 | + "pdm.lock", |
| 240 | + set(&["pdm.lock", "Pipfile.lock"]) |
| 241 | + )); |
| 242 | + assert!(!pypi_tool_lock_governs( |
| 243 | + "pdm.lock", |
| 244 | + set(&["pdm.lock", "poetry.lock"]) |
| 245 | + )); |
| 246 | + assert!(!pypi_tool_lock_governs( |
| 247 | + "pdm.lock", |
| 248 | + set(&["pdm.lock", "uv.lock"]) |
| 249 | + )); |
| 250 | + assert!(!pypi_tool_lock_governs("pdm.lock", set(&[]))); |
| 251 | + assert!(pypi_tool_lock_shadowed("pdm.lock", set(&["poetry.lock"]))); |
| 252 | + assert!(!pypi_tool_lock_shadowed("pdm.lock", set(&["Pipfile.lock"]))); |
| 253 | + assert!(!pypi_tool_lock_shadowed( |
| 254 | + "uv.lock", |
| 255 | + set(&["uv.lock", "poetry.lock"]) |
| 256 | + )); |
| 257 | + assert_eq!( |
| 258 | + pypi_locks_outside( |
| 259 | + "poetry.lock", |
| 260 | + set(&["uv.lock", "poetry.lock", "Pipfile.lock"]) |
| 261 | + ), |
| 262 | + ["uv.lock", "Pipfile.lock"] |
| 263 | + ); |
| 264 | + } |
| 265 | +} |
0 commit comments