diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index c3df30d9..03816859 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -650,7 +650,10 @@ pub async fn run(args: ApplyArgs) -> i32 { // install cache by default. The CoW guard handles the // safety; this is informational only. } - _ => {} + // Exhaustive on purpose (no `_`): a new package-manager layout must + // make an explicit appearance here — silence is a decision, not a + // default. + NpmPkgManager::Npm | NpmPkgManager::YarnClassic | NpmPkgManager::Unknown => {} } match apply_patches_inner(&args, &manifest_path).await { diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 19dcdfe7..92af24fe 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -47,6 +47,9 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "settings.gradle.kts", "build.gradle", "build.gradle.kts", + // deno.lock is knowingly absent: deno is its own ecosystem and no + // redirect rewriter edits its integrity entries today — recording the + // decision here so the omission reads as deliberate, not forgotten. ]; /// `pkg:/@` → `(type, coordinate, version)`. The @@ -259,7 +262,7 @@ pub(super) async fn run_redirect( let mut rush_warnings: Vec = Vec::new(); let mut rush_lock_keys: Vec = Vec::new(); if args.common.cwd.join("rush.json").is_file() { - let common_lock = "common/config/rush/pnpm-lock.yaml"; + let common_lock = socket_patch_core::constants::npm_family::RUSH_COMMON_LOCK_REL; if let Ok(content) = std::fs::read_to_string(args.common.cwd.join(common_lock)) { files.insert(common_lock.to_string(), content); rush_lock_keys.push(common_lock.to_string()); @@ -553,3 +556,32 @@ pub(super) async fn run_redirect( } vex_code } + +#[cfg(test)] +mod tests { + use super::REDIRECT_CANDIDATE_FILES; + use socket_patch_core::constants::npm_family; + + #[test] + fn redirect_candidates_match_the_shared_npm_family_table() { + // Drift guard, both directions, without classifying the non-npm + // rows: every table row flagged redirect_candidate must be in the + // candidate list, and no npm-family row NOT so flagged may appear + // (bun.lockb's absence is deliberate — run_redirect auto-migrates + // it before rewriting). + for name in npm_family::names_with(|r| r.redirect_candidate) { + assert!( + REDIRECT_CANDIDATE_FILES.contains(&name), + "{name} is flagged redirect_candidate but missing from \ + REDIRECT_CANDIDATE_FILES" + ); + } + for name in npm_family::names_with(|r| !r.redirect_candidate) { + assert!( + !REDIRECT_CANDIDATE_FILES.contains(&name), + "{name} is deliberately NOT a redirect candidate (see the \ + npm_family table) but appears in REDIRECT_CANDIDATE_FILES" + ); + } + } +} diff --git a/crates/socket-patch-core/src/constants.rs b/crates/socket-patch-core/src/constants.rs index 7809be2b..ce198811 100644 --- a/crates/socket-patch-core/src/constants.rs +++ b/crates/socket-patch-core/src/constants.rs @@ -60,3 +60,119 @@ mod tests { assert_eq!(DEFAULT_PATCH_MANIFEST_PATH, ".socket/manifest.json"); } } + +/// The npm-family package managers' shared file-name knowledge. +/// +/// npm, pnpm, yarn (classic and berry) and bun spell their lockfiles and +/// layout markers across several subsystems — the vendor flavor probe +/// (`vendor::npm_flavor`), the hosted-redirect candidate list (the CLI's +/// `scan::hosted`), the crawler layout probe (`crawlers::pkg_managers`) and +/// setup's PM detection (`package_json::find`). Those sites accept +/// INTENTIONALLY divergent subsets: hosted redirect deliberately omits +/// `bun.lockb` (it auto-migrates it to `bun.lock` before rewriting), and the +/// `pnpm-lock.yml` spelling is accepted only by setup detection. This table +/// encodes each divergence once, visibly, instead of homogenizing them — +/// guard tests beside each consumer assert its list equals the rows flagged +/// for its role, so a new lockfile spelling added in one place fails the +/// other sites' tests instead of drifting silently. +pub mod npm_family { + /// One file-name row and the roles in which consumers accept it. + pub struct FileRow { + pub name: &'static str, + /// `vendor::npm_flavor`'s probe recognizes it (wiring family member). + pub vendor_probe: bool, + /// `scan::hosted` hands it to `rewrite_registry_redirect`. + pub redirect_candidate: bool, + /// `package_json::find::detect_package_manager` treats it as a pnpm + /// marker. + pub detects_pnpm: bool, + } + + pub const FILES: &[FileRow] = &[ + FileRow { + name: "package-lock.json", + vendor_probe: true, + redirect_candidate: true, + detects_pnpm: false, + }, + FileRow { + name: "npm-shrinkwrap.json", + vendor_probe: true, + redirect_candidate: true, + detects_pnpm: false, + }, + FileRow { + name: "pnpm-lock.yaml", + vendor_probe: true, + redirect_candidate: true, + detects_pnpm: true, + }, + // Setup-detection-only spellings: the vendor probe and redirect + // rewriters have never accepted these, and widening them there is a + // behavior change to make deliberately, not by table accident. + FileRow { + name: "pnpm-lock.yml", + vendor_probe: false, + redirect_candidate: false, + detects_pnpm: true, + }, + FileRow { + name: "pnpm-workspace.yaml", + vendor_probe: false, + redirect_candidate: false, + detects_pnpm: true, + }, + FileRow { + name: "yarn.lock", + vendor_probe: true, + redirect_candidate: true, + detects_pnpm: false, + }, + // Berry's cache-config gate: read by the redirect rewriters only. + FileRow { + name: ".yarnrc.yml", + vendor_probe: false, + redirect_candidate: true, + detects_pnpm: false, + }, + FileRow { + name: "bun.lock", + vendor_probe: true, + redirect_candidate: true, + detects_pnpm: false, + }, + // The legacy binary lock: the vendor probe knows it (to refuse with + // the migration hint); hosted redirect deliberately does NOT list it + // as a candidate — it auto-migrates to bun.lock first. + FileRow { + name: "bun.lockb", + vendor_probe: true, + redirect_candidate: false, + detects_pnpm: false, + }, + // deno.lock is deliberately absent: deno is its own ecosystem + // (JSR-crawled); no npm-family vendor/redirect/detection path treats + // deno.lock as an npm lock today. Adding it here is a feature + // decision, not a spelling fix. + ]; + + /// The names of every row `pick` flags — consumer guard tests compare + /// their local lists against this. + pub fn names_with(pick: impl Fn(&FileRow) -> bool) -> Vec<&'static str> { + FILES.iter().filter(|r| pick(r)).map(|r| r.name).collect() + } + + /// Yarn Plug'n'Play loader files — any one present means "packages are + /// not on disk" (crawler must refuse; vendor probe refuses). Yarn 3+ + /// emits `.pnp.cjs`, Yarn 2.x emitted `.pnp.js`, newer installs may add + /// the ESM `.pnp.loader.mjs`. + pub const PNP_MARKERS: [&str; 3] = [".pnp.cjs", ".pnp.js", ".pnp.loader.mjs"]; + + /// Rush monorepos keep the single pnpm source-of-truth lock here, + /// relative to the repo root (no root package.json/lock pair). + pub const RUSH_COMMON_LOCK_REL: &str = "common/config/rush/pnpm-lock.yaml"; + + /// The bun.lockb → bun.lock migration command, spliced into every + /// user-facing message that recommends it. + pub const BUN_MIGRATE_CMD: &str = "bun install --save-text-lockfile"; +} diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index bfa37043..896dea1c 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -79,9 +79,9 @@ pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager { // mean "packages aren't on disk" — refuse rather than silently // fall through to Unknown (a Yarn 2 PnP tree has no // `node_modules/`, so it would otherwise escape the refusal). - if project_root.join(".pnp.cjs").is_file() - || project_root.join(".pnp.js").is_file() - || project_root.join(".pnp.loader.mjs").is_file() + if crate::constants::npm_family::PNP_MARKERS + .iter() + .any(|m| project_root.join(m).is_file()) { return NpmPkgManager::YarnBerryPnP; } diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index 9d13e200..90a6df37 100644 --- a/crates/socket-patch-core/src/package_json/find.rs +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -5,9 +5,11 @@ use super::detect::{strip_bom, PackageManager}; use crate::utils::fs::{entry_file_type, is_dir, list_dir_entries}; /// Detect the package manager based on lockfiles in the project root. -/// Checks for pnpm-lock.yaml, pnpm-lock.yml, and pnpm-workspace.yaml. +/// The accepted pnpm marker spellings (including the `pnpm-lock.yml` +/// variant no other subsystem accepts) live in the shared +/// [`npm_family`](crate::constants::npm_family) table. pub async fn detect_package_manager(start_path: &Path) -> PackageManager { - for name in &["pnpm-lock.yaml", "pnpm-lock.yml", "pnpm-workspace.yaml"] { + for name in crate::constants::npm_family::names_with(|r| r.detects_pnpm) { if fs::metadata(start_path.join(name)).await.is_ok() { return PackageManager::Pnpm; } @@ -528,6 +530,30 @@ mod tests { ); } + #[tokio::test] + async fn detect_package_manager_accepts_every_table_flagged_pnpm_marker() { + // Behavioral pin on the shared npm_family table wiring: every row + // flagged detects_pnpm (including the `pnpm-lock.yml` spelling no + // other subsystem accepts) flips detection to Pnpm; an empty root + // stays Npm. + for name in crate::constants::npm_family::names_with(|r| r.detects_pnpm) { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(name), "").await.unwrap(); + assert!( + matches!( + detect_package_manager(dir.path()).await, + PackageManager::Pnpm + ), + "{name} must flip detection to pnpm" + ); + } + let dir = tempfile::tempdir().unwrap(); + assert!(matches!( + detect_package_manager(dir.path()).await, + PackageManager::Npm + )); + } + // ── Group 2: workspace detection + file discovery ──────────────── #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index fa1584b8..2c1d9b48 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -476,7 +476,7 @@ async fn inventory_rush_pnpm_locks(project_root: &Path) -> Vec { let mut out = Vec::new(); // The single source-of-truth lock. - let common_lock = project_root.join("common/config/rush/pnpm-lock.yaml"); + let common_lock = project_root.join(crate::constants::npm_family::RUSH_COMMON_LOCK_REL); if let Some(entries) = inventory_pnpm_lock_at(&common_lock).await { out.extend(entries); } diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index b813251e..bcc18bd1 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -57,7 +57,7 @@ impl NpmLockFlavor { /// Yarn berry Plug'n'Play loaders: packages live inside `.yarn/cache/` zips, /// so there is nothing on disk to stage and no lockfile entry to rewire. -const PNP_MARKERS: [&str; 3] = [".pnp.cjs", ".pnp.js", ".pnp.loader.mjs"]; +use crate::constants::npm_family::PNP_MARKERS; /// How many head lines the yarn content sniff reads (the v1 header sits in /// the leading comment block; berry's `__metadata:` is the first top-level @@ -397,6 +397,23 @@ pub async fn revert_npm_any( #[cfg(test)] mod tests { + #[test] + fn probe_lockfile_names_match_the_shared_npm_family_table() { + // Drift guard: the probe's wiring families and the shared + // constants::npm_family table must agree on which file names the + // vendor probe recognizes. A new lockfile spelling added in one + // place must show up in the other (and in every other consumer's + // guard test) instead of drifting silently. + let mut from_families: Vec<&str> = LOCKFILE_FAMILIES + .iter() + .flat_map(|(_, names)| names.iter().copied()) + .collect(); + from_families.sort_unstable(); + let mut from_table = crate::constants::npm_family::names_with(|r| r.vendor_probe); + from_table.sort_unstable(); + assert_eq!(from_families, from_table); + } + use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo;