From 8c5ac5ff063183d78ea9d67ca553c53aa1476bd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:23:37 +0000 Subject: [PATCH 1/4] Start fix for #333 Assisted-by: Claude Code:claude-opus-5-5 From 66710e2c804b57b7c2ceb1f3891fcb6130f7bb91 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:34:52 +0000 Subject: [PATCH 2/4] Hosted scan: read the Pipfile beside Pipfile.lock A hosted scan never read the project's Pipfile, so every Pipenv project looked like it had an abandoned Pipfile.lock. When that lock pinned the package to the user's own wheel, another version or a VCS source, the scan still redirected a sibling requirements.txt, reported success, and warned that there was no Pipfile. Pipenv kept installing the unpatched package. The format registry now lists Pipfile as a hosted, presence-only file. Both the disk and the in-memory hosted engines read it, and the in-memory engine still counts it as present when the host has no usable content for it (a symlink or an oversize file). A conflict in a live Pipfile.lock now refuses the patch for the whole project. Fixes #333 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/hosted.rs | 1 + .../tests/in_process_redirect_pipenv.rs | 67 ++++++ .../socket-patch-core/src/formats/registry.rs | 5 + .../src/formats/registry.rs.orig | 223 ++++++++++++++++++ crates/socket-patch-core/src/hosted/engine.rs | 59 ++++- 5 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 crates/socket-patch-core/src/formats/registry.rs.orig diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index d6031e78..0b62d774 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -3531,6 +3531,7 @@ mod tests { "poetry.lock", "pdm.lock", "Pipfile.lock", + "Pipfile", "pyproject.toml", "hatch.toml", "Cargo.toml", diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index bf2e00fd..ea25f497 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -14,6 +14,9 @@ //! installer probe would otherwise need a real Pipenv 7–11 on PATH for; //! * a stale `Pipfile.lock` that does not pin the package does not veto //! the sibling `requirements.txt` redirect; +//! * a conflicting entry in a live `Pipfile.lock` (a `Pipfile` beside it) +//! vetoes the sibling `requirements.txt` redirect, while the same +//! conflict in an abandoned lock (no `Pipfile`) does not (#333); //! * a venv still holding the UPSTREAM release is reported stale and kept //! out of the same-run attestation. //! @@ -473,6 +476,70 @@ async fn stale_pipfile_lock_does_not_veto_the_requirements_redirect() { assert_eq!(read(&tmp.path().join("Pipfile.lock")), stale); } +/// The lock entry repointed at the user's own wheel: a `file` source that +/// is not Socket's, which the Pipenv planner refuses as a conflict. +fn lock_with_user_file_source() -> String { + LOCK.replace( + "\"version\": \"==1.26.18\"", + "\"file\": \"wheels/urllib3-1.26.18-py2.py3-none-any.whl\"", + ) +} + +/// #333: a conflicting entry in a LIVE Pipfile.lock (a Pipfile beside it) +/// means Pipenv never installs the patch, so the patch is refused for the +/// whole project. The hosted scan must therefore see the Pipfile: the +/// sibling requirements.txt stays untouched instead of being +/// half-redirected. +#[tokio::test] +#[serial] +async fn live_pipfile_lock_conflict_vetoes_the_requirements_redirect() { + let _major = MajorGuard::set("2026"); + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let lock = lock_with_user_file_source(); + std::fs::write(tmp.path().join("Pipfile.lock"), &lock).unwrap(); + const REQS: &str = "urllib3==1.26.18\nrequests==2.31.0\n"; + std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap(); + + run(hosted_args(tmp.path(), server.uri(), None)).await; + assert_eq!( + read(&tmp.path().join("requirements.txt")), + REQS, + "a live Pipfile.lock conflict must veto the sibling requirements.txt" + ); + assert_eq!(read(&tmp.path().join("Pipfile.lock")), lock); + assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE); +} + +/// The same conflict in an ABANDONED lock (no Pipfile beside it) says +/// nothing about the project's install files: the sibling requirements.txt +/// is still redirected. +#[tokio::test] +#[serial] +async fn abandoned_pipfile_lock_conflict_does_not_veto_the_requirements_redirect() { + let _major = MajorGuard::set("2026"); + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + std::fs::remove_file(tmp.path().join("Pipfile")).unwrap(); + let lock = lock_with_user_file_source(); + std::fs::write(tmp.path().join("Pipfile.lock"), &lock).unwrap(); + const REQS: &str = "urllib3==1.26.18\nrequests==2.31.0\n"; + std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap(); + + let code = run(hosted_args(tmp.path(), server.uri(), None)).await; + assert_eq!(code, 0); + let requirements = read(&tmp.path().join("requirements.txt")); + assert!( + requirements.contains(HOSTED_URL), + "an abandoned lock must not veto requirements.txt: {requirements}" + ); + assert_eq!(read(&tmp.path().join("Pipfile.lock")), lock); +} + #[tokio::test] #[serial] async fn warm_venv_with_the_upstream_release_is_not_attested() { diff --git a/crates/socket-patch-core/src/formats/registry.rs b/crates/socket-patch-core/src/formats/registry.rs index a0c703ee..1d27fc5c 100644 --- a/crates/socket-patch-core/src/formats/registry.rs +++ b/crates/socket-patch-core/src/formats/registry.rs @@ -101,6 +101,10 @@ const REGISTRY: &[FormatFile] = &[ row("poetry.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), row("pdm.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), row("Pipfile.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), + // Never edited — its presence tells the Pipenv planner a Pipfile.lock is + // live (a conflict there vetoes the sibling Python rewriters) rather + // than abandoned. + row("Pipfile", "pypi", HOSTED | PRESENCE_ONLY), row("pyproject.toml", "pypi", HOSTED | VENDORED | PROBE), row("hatch.toml", "pypi", HOSTED | PROBE), // ── cargo ── @@ -217,6 +221,7 @@ mod tests { assert_eq!(hosted_file_ecosystem(".cargo/config"), Some("cargo")); assert_eq!(hosted_file_ecosystem("checksums.sha256"), Some("maven")); assert_eq!(hosted_file_ecosystem("build.gradle"), None); + assert_eq!(hosted_file_ecosystem("Pipfile"), None); assert_eq!(hosted_file_ecosystem("package.json"), None); assert_eq!(hosted_file_ecosystem("NuGet.Config"), Some("nuget")); } diff --git a/crates/socket-patch-core/src/formats/registry.rs.orig b/crates/socket-patch-core/src/formats/registry.rs.orig new file mode 100644 index 00000000..a0c703ee --- /dev/null +++ b/crates/socket-patch-core/src/formats/registry.rs.orig @@ -0,0 +1,223 @@ +//! Which project files carry a lock or its wiring, per ecosystem, and in +//! which roles — the ONE table the hosted planners' candidate reads, the +//! vendored planners' wiring search, lockfile discovery's vendored-liveness +//! probe, the in-memory engine's root detection and the npm-family flavor +//! probes all filter. +//! +//! The roles intentionally diverge per file (a binary Bun lock has a native +//! reader and is never text-scanned for wiring; `pnpm-lock.yml` is only a +//! package-manager marker; Gradle scripts are read by the hosted Maven +//! planner for their presence only); each divergence is one flag on one +//! row. Paths are root-relative with `/` separators. Dynamic sets — PEP 751 +//! / PEP 723 Python locks, vlt importer manifests, requirements `-r` +//! includes, Rush's nested pnpm locks — are enumerated by their callers. + +/// Read by the hosted planners (`scan --mode hosted`, the in-memory +/// engine's candidate reads). +pub const HOSTED: u8 = 1 << 0; +/// Rewired by a vendored planner: the search space for +/// `.socket/vendor///` references when the vendor ledger +/// is gone (`repair`). +pub const VENDORED: u8 = 1 << 1; +/// A lock (or wiring config) a vendored artifact is consumed through — the +/// liveness probe of a ledger entry whose recorded wiring files are gone +/// (`vex::discover`), and the npm-family flavor probe's lock family. +pub const PROBE: u8 = 1 << 2; +/// Makes its directory a project root (the in-memory hosted engine). +pub const ROOT: u8 = 1 << 3; +/// Marks a pnpm project for package-manager detection. +pub const PNPM_MARKER: u8 = 1 << 4; +/// Read by the hosted planners for its presence (or as advisory input) +/// only: no hosted rewriter edits it, so it names no ecosystem for the +/// symlinked-read refusal. +pub const PRESENCE_ONLY: u8 = 1 << 5; + +/// One row of the [`registry`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FormatFile { + /// Root-relative path. + pub path: &'static str, + /// Vendor-ecosystem tag (`npm`, `pypi`, `cargo`, …). + pub ecosystem: &'static str, + /// The roles, a mask of [`HOSTED`], [`VENDORED`], [`PROBE`], [`ROOT`], + /// [`PNPM_MARKER`] and [`PRESENCE_ONLY`]. + pub roles: u8, +} + +impl FormatFile { + pub fn has(&self, role: u8) -> bool { + self.roles & role != 0 + } + + /// The path's last segment. + pub fn basename(&self) -> &'static str { + self.path.rsplit('/').next().unwrap_or(self.path) + } +} + +const fn row(path: &'static str, ecosystem: &'static str, roles: u8) -> FormatFile { + FormatFile { + path, + ecosystem, + roles, + } +} + +/// In the hosted planners' read order. +const REGISTRY: &[FormatFile] = &[ + // ── npm family ── + row("package-lock.json", "npm", HOSTED | VENDORED | PROBE | ROOT), + row("npm-shrinkwrap.json", "npm", HOSTED | VENDORED | PROBE | ROOT), + row( + "pnpm-lock.yaml", + "npm", + HOSTED | VENDORED | PROBE | ROOT | PNPM_MARKER, + ), + // Package-manager detection only: the vendor probe and the hosted + // planners have never accepted these spellings. + row("pnpm-lock.yml", "npm", PNPM_MARKER), + row("pnpm-workspace.yaml", "npm", PNPM_MARKER), + // pnpm <= 2 uses the same package identities under the old filename. + row("shrinkwrap.yaml", "npm", HOSTED), + row("node_modules/.modules.yaml", "npm", HOSTED), + row("yarn.lock", "npm", HOSTED | VENDORED | PROBE | ROOT), + // A berry lock's cache-config gate: read by the hosted planners only. + row(".yarnrc.yml", "npm", HOSTED), + row("bun.lock", "npm", HOSTED | VENDORED | PROBE | ROOT), + // Binary Bun locks are read and rewritten natively, never text-scanned + // for wiring. + row("bun.lockb", "npm", HOSTED | PROBE | ROOT), + row("vlt-lock.json", "npm", HOSTED | VENDORED | PROBE | ROOT), + // vlt's config: a read-only hosted input (the old-lockfile advisory). + row("vlt.json", "npm", HOSTED), + // The hidden lock is only stat'ed as the install-state sentinel. + row("node_modules/.vlt-lock.json", "npm", HOSTED), + // The vendored planners' override surface; manifests never make a root. + row("package.json", "npm", VENDORED), + row("rush.json", "npm", ROOT), + // ── pypi ── + row("requirements.txt", "pypi", HOSTED | VENDORED | PROBE | ROOT), + row("uv.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), + row("poetry.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), + row("pdm.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), + row("Pipfile.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), + row("pyproject.toml", "pypi", HOSTED | VENDORED | PROBE), + row("hatch.toml", "pypi", HOSTED | PROBE), + // ── cargo ── + row("Cargo.toml", "cargo", HOSTED | VENDORED | PROBE), + row("Cargo.lock", "cargo", HOSTED | VENDORED | ROOT), + row(".cargo/config.toml", "cargo", HOSTED | VENDORED | PROBE), + // The LEGACY extensionless spelling: cargo reads `.cargo/config` in + // preference to `config.toml` when both exist, so the hosted planner + // must see it (it wires the managed registry into whichever one is + // present); vendored wiring before v5 lived there too. + row(".cargo/config", "cargo", HOSTED | VENDORED | PROBE), + // ── composer ── + row("composer.json", "composer", VENDORED), + row("composer.lock", "composer", HOSTED | VENDORED | PROBE | ROOT), + // ── nuget ── + row("nuget.config", "nuget", HOSTED | PROBE), + row("NuGet.config", "nuget", HOSTED | PROBE), + row("NuGet.Config", "nuget", HOSTED | PROBE), + row("packages.lock.json", "nuget", HOSTED), + // ── gem ── + row("Gemfile", "gem", HOSTED | VENDORED), + row("Gemfile.lock", "gem", HOSTED | VENDORED | PROBE | ROOT), + // Bundler's modern manifest spelling — preferred over Gemfile when both + // exist (the gem planner picks the pair bundler reads and fails closed + // on diverging spellings). + row("gems.rb", "gem", HOSTED), + row("gems.locked", "gem", HOSTED | ROOT), + // ── golang ── + // The hosted planner edits the main module's go.mod (fork-style + // `replace`) and go.sum (the socket module's two h1: lines); go.sum may + // legitimately be absent — the planner creates it then. + row("go.mod", "golang", HOSTED | VENDORED | PROBE | ROOT), + row("go.sum", "golang", HOSTED | ROOT), + // ── maven ── + row("pom.xml", "maven", HOSTED | PROBE), + // Maven Trusted Checksums files the fail-closed maven planner merges + // into (read so an existing user config / checksum set is preserved). + row(".mvn/maven.config", "maven", HOSTED), + row(".mvn/checksums/checksums.sha256", "maven", HOSTED), + // Gradle build scripts are never edited — their presence only feeds the + // maven planner's paste-able `exclusiveContent` snippet warning. + row("settings.gradle", "maven", HOSTED | PRESENCE_ONLY), + row("settings.gradle.kts", "maven", HOSTED | PRESENCE_ONLY), + row("build.gradle", "maven", HOSTED | PRESENCE_ONLY), + row("build.gradle.kts", "maven", HOSTED | PRESENCE_ONLY), + // deno.lock is deliberately absent: deno is its own ecosystem + // (JSR-crawled) and no planner edits its integrity entries. +]; + +/// Every row, in the hosted planners' read order. +pub fn registry() -> &'static [FormatFile] { + REGISTRY +} + +/// The paths of every row carrying `role`, in registry order. +pub fn paths_with(role: u8) -> Vec<&'static str> { + REGISTRY + .iter() + .filter(|f| f.has(role)) + .map(|f| f.path) + .collect() +} + +/// The [`PROBE`] paths of `ecosystem`, in registry order. +pub fn probe_paths(ecosystem: &str) -> Vec<&'static str> { + REGISTRY + .iter() + .filter(|f| f.ecosystem == ecosystem && f.has(PROBE)) + .map(|f| f.path) + .collect() +} + +/// The ecosystem whose hosted planner edits a candidate file, by basename +/// (`rel` may be nested, e.g. a Rush lock or a workspace member's +/// `Cargo.toml`); `None` for files no hosted rewriter edits. +pub fn hosted_file_ecosystem(rel: &str) -> Option<&'static str> { + let base = rel.rsplit('/').next().unwrap_or(rel); + REGISTRY + .iter() + .find(|f| f.has(HOSTED) && !f.has(PRESENCE_ONLY) && f.basename() == base) + .map(|f| f.ecosystem) +} + +/// The [`ROOT`] row a basename names. +pub fn root_marker(base: &str) -> Option<&'static FormatFile> { + REGISTRY.iter().find(|f| f.has(ROOT) && f.path == base) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_are_unique_and_root_markers_are_root_level() { + let mut paths: Vec<&str> = REGISTRY.iter().map(|f| f.path).collect(); + paths.sort_unstable(); + let before = paths.len(); + paths.dedup(); + assert_eq!(before, paths.len(), "duplicate registry path"); + for f in REGISTRY.iter().filter(|f| f.has(ROOT)) { + assert!(!f.path.contains('/'), "{}: a root marker is a basename", f.path); + } + } + + #[test] + fn hosted_file_ecosystem_matches_basenames_of_edited_files_only() { + assert_eq!(hosted_file_ecosystem("package-lock.json"), Some("npm")); + assert_eq!( + hosted_file_ecosystem("common/config/rush/pnpm-lock.yaml"), + Some("npm") + ); + assert_eq!(hosted_file_ecosystem(".modules.yaml"), Some("npm")); + assert_eq!(hosted_file_ecosystem("crates/a/Cargo.toml"), Some("cargo")); + assert_eq!(hosted_file_ecosystem(".cargo/config"), Some("cargo")); + assert_eq!(hosted_file_ecosystem("checksums.sha256"), Some("maven")); + assert_eq!(hosted_file_ecosystem("build.gradle"), None); + assert_eq!(hosted_file_ecosystem("package.json"), None); + assert_eq!(hosted_file_ecosystem("NuGet.Config"), Some("nuget")); + } +} diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 2d9173b9..49efe2bd 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -377,6 +377,22 @@ fn rush_repo(view: &ProjectView<'_>) -> bool { } } +/// Whether `rel` is a [`PRESENCE_ONLY`](crate::formats::registry::PRESENCE_ONLY) +/// row the in-memory host lists without usable content (a symbolic link, +/// an oversize or presence-only entry). Its planners only ask whether it +/// exists — the Pipenv planner tells a live `Pipfile.lock` from an +/// abandoned one by the `Pipfile` beside it — so it is recorded as present +/// (empty) rather than dropped. Disk reads such a file through any link. +fn presence_only_present(view: &ProjectView<'_>, rel: &str) -> bool { + let ProjectView::Memory(project) = view else { + return false; + }; + project.contains(rel) + && crate::formats::registry::registry() + .iter() + .any(|f| f.path == rel && f.has(crate::formats::registry::PRESENCE_ONLY)) +} + /// Read the project's candidate files: [`REDIRECT_CANDIDATE_FILES`], the /// Cargo workspace members (when a cargo candidate meets a root /// `Cargo.toml`), the Python locks and their scripts, and the Rush locks. @@ -399,7 +415,9 @@ pub async fn read_candidate_files( } continue; } - out.read(view, unreadable, name).await; + if !out.read(view, unreadable, name).await && presence_only_present(view, name) { + out.files.insert((*name).to_string(), String::new()); + } } // Cargo workspace members (and in-root path dependencies) declare @@ -1556,6 +1574,44 @@ mod tests { assert!(read.unreadable_reads.is_empty()); } + /// #333: the Pipenv planner keys a live lock on the `Pipfile` beside + /// it, so the candidate reads must carry it — read from disk, and kept + /// as present in memory even when the host has no content for it. + #[tokio::test] + async fn the_pipfile_is_read_for_its_presence() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("Pipfile"), "[packages]\n").unwrap(); + std::fs::write(tmp.path().join("Pipfile.lock"), "{}").unwrap(); + let read = + read_candidate_files(&ProjectView::Disk(tmp.path()), &BTreeSet::new(), &[]).await; + assert_eq!( + read.files.get("Pipfile").map(String::as_str), + Some("[packages]\n") + ); + + for entry in [MemoryEntry::Symlink, MemoryEntry::Present] { + let mut p = MemoryProject::new(); + p.insert_text("Pipfile.lock", "{}"); + p.insert("Pipfile", entry.clone()); + let unreadable = match entry { + MemoryEntry::Present => BTreeSet::from(["Pipfile".to_string()]), + _ => BTreeSet::new(), + }; + let read = read_candidate_files(&ProjectView::Memory(&p), &unreadable, &[]).await; + assert_eq!( + read.files.get("Pipfile").map(String::as_str), + Some(""), + "{entry:?}" + ); + } + + // Absent stays absent: a lone Pipfile.lock is abandoned. + let mut p = MemoryProject::new(); + p.insert_text("Pipfile.lock", "{}"); + let read = read_candidate_files(&ProjectView::Memory(&p), &BTreeSet::new(), &[]).await; + assert!(!read.files.contains_key("Pipfile")); + } + #[test] fn file_ecosystems_cover_the_rewrite_targets() { assert_eq!(file_ecosystem("package-lock.json"), Some("npm")); @@ -1566,6 +1622,7 @@ mod tests { assert_eq!(file_ecosystem("tool.py.lock"), Some("pypi")); assert_eq!(file_ecosystem("crates/a/Cargo.toml"), Some("cargo")); assert_eq!(file_ecosystem("build.gradle"), None); + assert_eq!(file_ecosystem("Pipfile"), None); } } From 943974cd106fdbc039432e1683998d2dbce9062e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:35:04 +0000 Subject: [PATCH 3/4] Note the Pipenv hosted fix in the changelog Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a92c2ae..c0c4f38b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,9 @@ limits, and required install commands. - Python rewrites preserve supported markers, groups, extras, source metadata, and integrity pins. Relocks, out-of-tree environments, and lock-only VEX are handled consistently with each installer's supported behavior. +- Hosted Pipenv scans read the `Pipfile`, so a conflicting `Pipfile.lock` entry + refuses the patch project-wide instead of half-redirecting a sibling + `requirements.txt` (#333). - Vendoring reuses valid committed artifacts during service outages. Updates do not build from a previous patch's modified bytes. Verified service artifacts keep their identity; integrity failures do not fall through to a local rebuild. From 6565facf626543f532723e91355f2e4e4f4a070a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:50:36 +0000 Subject: [PATCH 4/4] Remove a stray patch backup file Assisted-by: Claude Code:claude-opus-5-5 --- .../src/formats/registry.rs.orig | 223 ------------------ 1 file changed, 223 deletions(-) delete mode 100644 crates/socket-patch-core/src/formats/registry.rs.orig diff --git a/crates/socket-patch-core/src/formats/registry.rs.orig b/crates/socket-patch-core/src/formats/registry.rs.orig deleted file mode 100644 index a0c703ee..00000000 --- a/crates/socket-patch-core/src/formats/registry.rs.orig +++ /dev/null @@ -1,223 +0,0 @@ -//! Which project files carry a lock or its wiring, per ecosystem, and in -//! which roles — the ONE table the hosted planners' candidate reads, the -//! vendored planners' wiring search, lockfile discovery's vendored-liveness -//! probe, the in-memory engine's root detection and the npm-family flavor -//! probes all filter. -//! -//! The roles intentionally diverge per file (a binary Bun lock has a native -//! reader and is never text-scanned for wiring; `pnpm-lock.yml` is only a -//! package-manager marker; Gradle scripts are read by the hosted Maven -//! planner for their presence only); each divergence is one flag on one -//! row. Paths are root-relative with `/` separators. Dynamic sets — PEP 751 -//! / PEP 723 Python locks, vlt importer manifests, requirements `-r` -//! includes, Rush's nested pnpm locks — are enumerated by their callers. - -/// Read by the hosted planners (`scan --mode hosted`, the in-memory -/// engine's candidate reads). -pub const HOSTED: u8 = 1 << 0; -/// Rewired by a vendored planner: the search space for -/// `.socket/vendor///` references when the vendor ledger -/// is gone (`repair`). -pub const VENDORED: u8 = 1 << 1; -/// A lock (or wiring config) a vendored artifact is consumed through — the -/// liveness probe of a ledger entry whose recorded wiring files are gone -/// (`vex::discover`), and the npm-family flavor probe's lock family. -pub const PROBE: u8 = 1 << 2; -/// Makes its directory a project root (the in-memory hosted engine). -pub const ROOT: u8 = 1 << 3; -/// Marks a pnpm project for package-manager detection. -pub const PNPM_MARKER: u8 = 1 << 4; -/// Read by the hosted planners for its presence (or as advisory input) -/// only: no hosted rewriter edits it, so it names no ecosystem for the -/// symlinked-read refusal. -pub const PRESENCE_ONLY: u8 = 1 << 5; - -/// One row of the [`registry`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct FormatFile { - /// Root-relative path. - pub path: &'static str, - /// Vendor-ecosystem tag (`npm`, `pypi`, `cargo`, …). - pub ecosystem: &'static str, - /// The roles, a mask of [`HOSTED`], [`VENDORED`], [`PROBE`], [`ROOT`], - /// [`PNPM_MARKER`] and [`PRESENCE_ONLY`]. - pub roles: u8, -} - -impl FormatFile { - pub fn has(&self, role: u8) -> bool { - self.roles & role != 0 - } - - /// The path's last segment. - pub fn basename(&self) -> &'static str { - self.path.rsplit('/').next().unwrap_or(self.path) - } -} - -const fn row(path: &'static str, ecosystem: &'static str, roles: u8) -> FormatFile { - FormatFile { - path, - ecosystem, - roles, - } -} - -/// In the hosted planners' read order. -const REGISTRY: &[FormatFile] = &[ - // ── npm family ── - row("package-lock.json", "npm", HOSTED | VENDORED | PROBE | ROOT), - row("npm-shrinkwrap.json", "npm", HOSTED | VENDORED | PROBE | ROOT), - row( - "pnpm-lock.yaml", - "npm", - HOSTED | VENDORED | PROBE | ROOT | PNPM_MARKER, - ), - // Package-manager detection only: the vendor probe and the hosted - // planners have never accepted these spellings. - row("pnpm-lock.yml", "npm", PNPM_MARKER), - row("pnpm-workspace.yaml", "npm", PNPM_MARKER), - // pnpm <= 2 uses the same package identities under the old filename. - row("shrinkwrap.yaml", "npm", HOSTED), - row("node_modules/.modules.yaml", "npm", HOSTED), - row("yarn.lock", "npm", HOSTED | VENDORED | PROBE | ROOT), - // A berry lock's cache-config gate: read by the hosted planners only. - row(".yarnrc.yml", "npm", HOSTED), - row("bun.lock", "npm", HOSTED | VENDORED | PROBE | ROOT), - // Binary Bun locks are read and rewritten natively, never text-scanned - // for wiring. - row("bun.lockb", "npm", HOSTED | PROBE | ROOT), - row("vlt-lock.json", "npm", HOSTED | VENDORED | PROBE | ROOT), - // vlt's config: a read-only hosted input (the old-lockfile advisory). - row("vlt.json", "npm", HOSTED), - // The hidden lock is only stat'ed as the install-state sentinel. - row("node_modules/.vlt-lock.json", "npm", HOSTED), - // The vendored planners' override surface; manifests never make a root. - row("package.json", "npm", VENDORED), - row("rush.json", "npm", ROOT), - // ── pypi ── - row("requirements.txt", "pypi", HOSTED | VENDORED | PROBE | ROOT), - row("uv.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), - row("poetry.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), - row("pdm.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), - row("Pipfile.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), - row("pyproject.toml", "pypi", HOSTED | VENDORED | PROBE), - row("hatch.toml", "pypi", HOSTED | PROBE), - // ── cargo ── - row("Cargo.toml", "cargo", HOSTED | VENDORED | PROBE), - row("Cargo.lock", "cargo", HOSTED | VENDORED | ROOT), - row(".cargo/config.toml", "cargo", HOSTED | VENDORED | PROBE), - // The LEGACY extensionless spelling: cargo reads `.cargo/config` in - // preference to `config.toml` when both exist, so the hosted planner - // must see it (it wires the managed registry into whichever one is - // present); vendored wiring before v5 lived there too. - row(".cargo/config", "cargo", HOSTED | VENDORED | PROBE), - // ── composer ── - row("composer.json", "composer", VENDORED), - row("composer.lock", "composer", HOSTED | VENDORED | PROBE | ROOT), - // ── nuget ── - row("nuget.config", "nuget", HOSTED | PROBE), - row("NuGet.config", "nuget", HOSTED | PROBE), - row("NuGet.Config", "nuget", HOSTED | PROBE), - row("packages.lock.json", "nuget", HOSTED), - // ── gem ── - row("Gemfile", "gem", HOSTED | VENDORED), - row("Gemfile.lock", "gem", HOSTED | VENDORED | PROBE | ROOT), - // Bundler's modern manifest spelling — preferred over Gemfile when both - // exist (the gem planner picks the pair bundler reads and fails closed - // on diverging spellings). - row("gems.rb", "gem", HOSTED), - row("gems.locked", "gem", HOSTED | ROOT), - // ── golang ── - // The hosted planner edits the main module's go.mod (fork-style - // `replace`) and go.sum (the socket module's two h1: lines); go.sum may - // legitimately be absent — the planner creates it then. - row("go.mod", "golang", HOSTED | VENDORED | PROBE | ROOT), - row("go.sum", "golang", HOSTED | ROOT), - // ── maven ── - row("pom.xml", "maven", HOSTED | PROBE), - // Maven Trusted Checksums files the fail-closed maven planner merges - // into (read so an existing user config / checksum set is preserved). - row(".mvn/maven.config", "maven", HOSTED), - row(".mvn/checksums/checksums.sha256", "maven", HOSTED), - // Gradle build scripts are never edited — their presence only feeds the - // maven planner's paste-able `exclusiveContent` snippet warning. - row("settings.gradle", "maven", HOSTED | PRESENCE_ONLY), - row("settings.gradle.kts", "maven", HOSTED | PRESENCE_ONLY), - row("build.gradle", "maven", HOSTED | PRESENCE_ONLY), - row("build.gradle.kts", "maven", HOSTED | PRESENCE_ONLY), - // deno.lock is deliberately absent: deno is its own ecosystem - // (JSR-crawled) and no planner edits its integrity entries. -]; - -/// Every row, in the hosted planners' read order. -pub fn registry() -> &'static [FormatFile] { - REGISTRY -} - -/// The paths of every row carrying `role`, in registry order. -pub fn paths_with(role: u8) -> Vec<&'static str> { - REGISTRY - .iter() - .filter(|f| f.has(role)) - .map(|f| f.path) - .collect() -} - -/// The [`PROBE`] paths of `ecosystem`, in registry order. -pub fn probe_paths(ecosystem: &str) -> Vec<&'static str> { - REGISTRY - .iter() - .filter(|f| f.ecosystem == ecosystem && f.has(PROBE)) - .map(|f| f.path) - .collect() -} - -/// The ecosystem whose hosted planner edits a candidate file, by basename -/// (`rel` may be nested, e.g. a Rush lock or a workspace member's -/// `Cargo.toml`); `None` for files no hosted rewriter edits. -pub fn hosted_file_ecosystem(rel: &str) -> Option<&'static str> { - let base = rel.rsplit('/').next().unwrap_or(rel); - REGISTRY - .iter() - .find(|f| f.has(HOSTED) && !f.has(PRESENCE_ONLY) && f.basename() == base) - .map(|f| f.ecosystem) -} - -/// The [`ROOT`] row a basename names. -pub fn root_marker(base: &str) -> Option<&'static FormatFile> { - REGISTRY.iter().find(|f| f.has(ROOT) && f.path == base) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn paths_are_unique_and_root_markers_are_root_level() { - let mut paths: Vec<&str> = REGISTRY.iter().map(|f| f.path).collect(); - paths.sort_unstable(); - let before = paths.len(); - paths.dedup(); - assert_eq!(before, paths.len(), "duplicate registry path"); - for f in REGISTRY.iter().filter(|f| f.has(ROOT)) { - assert!(!f.path.contains('/'), "{}: a root marker is a basename", f.path); - } - } - - #[test] - fn hosted_file_ecosystem_matches_basenames_of_edited_files_only() { - assert_eq!(hosted_file_ecosystem("package-lock.json"), Some("npm")); - assert_eq!( - hosted_file_ecosystem("common/config/rush/pnpm-lock.yaml"), - Some("npm") - ); - assert_eq!(hosted_file_ecosystem(".modules.yaml"), Some("npm")); - assert_eq!(hosted_file_ecosystem("crates/a/Cargo.toml"), Some("cargo")); - assert_eq!(hosted_file_ecosystem(".cargo/config"), Some("cargo")); - assert_eq!(hosted_file_ecosystem("checksums.sha256"), Some("maven")); - assert_eq!(hosted_file_ecosystem("build.gradle"), None); - assert_eq!(hosted_file_ecosystem("package.json"), None); - assert_eq!(hosted_file_ecosystem("NuGet.Config"), Some("nuget")); - } -}