Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 33 additions & 1 deletion crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>` → `(type, coordinate, version)`. The
Expand Down Expand Up @@ -259,7 +262,7 @@ pub(super) async fn run_redirect(
let mut rush_warnings: Vec<serde_json::Value> = Vec::new();
let mut rush_lock_keys: Vec<String> = 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());
Expand Down Expand Up @@ -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"
);
}
}
}
116 changes: 116 additions & 0 deletions crates/socket-patch-core/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
6 changes: 3 additions & 3 deletions crates/socket-patch-core/src/crawlers/pkg_managers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
30 changes: 28 additions & 2 deletions crates/socket-patch-core/src/package_json/find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-core/src/vendor/lock_inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ async fn inventory_rush_pnpm_locks(project_root: &Path) -> Vec<LockfileEntry> {
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);
}
Expand Down
19 changes: 18 additions & 1 deletion crates/socket-patch-core/src/vendor/npm_flavor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading