diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 6506b62ad..b49310268 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1534,6 +1534,7 @@ command's integration tests check its output. | `pypi_pipenv_installer_unknown` | `skipped` (warning) | vendor (pipenv): no `pipenv` answered on PATH; the vendored references assume Pipenv 2018 or later (7–11 cannot consume them — use hosted mode there); `SOCKET_PIPENV_MAJOR` pins the release. | | `vendor_lock_entry_relocked` | revert `warnings[]` | vendor `--revert` / rollback (pipenv): a relock regenerated the wired entry to a registry reference, or removed it; the record is retired (artifact removed, ledger entry dropped) instead of drift-kept. | | `vendor_lock_entry_pruned_kept` | revert `warnings[]` | vendor `--revert` / rollback (yarn berry, #737): vendoring dropped a lock entry only the patched package's implicit `node-gyp` dependency reached (or one descriptor of a shared entry), and the lock has re-added or changed that entry since; it is left as it is (not drift: the artifact is still removed). Run `yarn install` if the lock is incomplete. | +| `vendor_lock_descriptor_unlocked` | revert `warnings[]` | vendor `--revert` / rollback / remove (yarn berry, #759): a descriptor of the vendored package was added while it was vendored, and the restored registry version does not satisfy it (or its range is one socket-patch cannot read, such as a dist-tag), so it has no `yarn.lock` entry after the revert. The revert still succeeds and the artifact is removed. Run `yarn install` and commit `yarn.lock`. | | `pypi_{poetry,pdm,pipenv}_no_lockfile` | `failed` | vendor (pypi): a lock-less tool marker with no `requirements.txt` fallback — run ` lock`. | | `pypi_poetry_integrity_unverified` | `skipped` (warning) | vendor (pypi / poetry): the lock was written by Poetry < 1.4 (0.12 `[metadata.hashes]`, lock 1.0/1.1, or a 2.0 lock without a `@generated by Poetry X.Y.Z` header — 1.3 wrote those). That installer does not verify local wheel hashes (the committed wheel bytes are the protection) and does not replace an already-installed package at the same version; recreate the virtualenv or `pip uninstall` the package before `poetry install`, or upgrade Poetry. | | `pypi_poetry_source_constraint` | `failed` / `skipped` (warning) | vendor (pypi / poetry): the pyproject pins the package to a named source (`source = "pypi"`), which Poetry records nothing for in the lock. Over a lock stamped by Poetry 1.4 – 2.2 (whose installer refuses that constraint on a file-sourced package, so every `poetry install` would fail) the package is refused before any write, `failed`; an already-wired lock is refused the same way instead of reading as in sync. Over any other lock the wiring stands and this is a warning naming the 1.4 – 2.2 range. The detail names the remedy: drop the `source` key or upgrade to Poetry >= 2.3, re-lock and re-run; when the lock is already wired it first says to run `socket-patch vendor --revert` (which reverts EVERY vendored package), since re-locking discards the wiring. | diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs index 771ed8a0e..f56f2e289 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs @@ -881,3 +881,178 @@ fn yarn_berry_vendor_refuses_a_gitignored_socket_dir() { ); assert!(!proj.join(format!(".socket/vendor/npm/{UUID}")).exists()); } + +// ── #759: revert after the lock moved on ────────────────────────────── + +/// What changes in the project while left-pad is vendored, before the +/// revert. +#[derive(Clone, Copy, Debug)] +enum LockChange { + /// Member `b` starts depending on `left-pad@1.3.0` beside the root's + /// `^1.3.0`. + MemberAddsDescriptor, + /// Member `b` stops depending on its `left-pad@1.3.0`; the root keeps + /// `^1.3.0`. + MemberRemovesDescriptor, + /// Vendored under yarn 4.0.2 (bare checksums), then installed with a + /// 4.1+ yarn, which respells every checksum `10c0/`. + YarnUpgrade, +} + +/// #759: `vendor --revert` after the lock changed since vendoring leaves a +/// lock a fresh checkout installs with `yarn install --immutable`. The +/// revert used to write the recorded pre-vendor entry back verbatim (its +/// old key, its old checksum spelling), and yarn rejected it (YN0028). +#[test] +fn issue_759_revert_after_a_member_adds_a_descriptor() { + run_revert_after_lock_change(LockChange::MemberAddsDescriptor); +} + +#[test] +fn issue_759_revert_after_a_member_removes_a_descriptor() { + run_revert_after_lock_change(LockChange::MemberRemovesDescriptor); +} + +#[test] +fn issue_759_revert_after_a_yarn_upgrade_respells_checksums() { + run_revert_after_lock_change(LockChange::YarnUpgrade); +} + +fn run_revert_after_lock_change(change: LockChange) { + // The upgrade case pins its own pair of releases: the last bare-hex + // writer, then the one under test (or the first prefixed writer when + // that is a bare-hex one itself). + let (vendor_yarn, after_yarn) = match change { + LockChange::YarnUpgrade => ( + "yarn@4.0.2", + match yarn_berry() { + "yarn@4.0.0" | "yarn@4.0.1" | "yarn@4.0.2" => "yarn@4.1.0", + current => current, + }, + ), + _ => (yarn_berry(), yarn_berry()), + }; + for pm in [vendor_yarn, after_yarn] { + if !has_corepack_pm(pm) { + skip!( + "SKIP e2e_vendor_yarn_berry_build (#759 {change:?}): `corepack {pm}` unavailable" + ); + return; + } + } + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + let global = tmp.path().join("yarn-global"); + let yarn = |pm: &str, dir: &Path, args: &[&str]| { + let out = corepack( + dir, + pm, + args, + &[("YARN_GLOBAL_FOLDER", global.to_str().unwrap())], + ); + (out.status.success(), yarn_berry_common::yarn_output(&out)) + }; + let member = |dep: Option<&str>| { + let deps = dep + .map(|r| format!(r#","dependencies":{{"{DEP}":"{r}"}}"#)) + .unwrap_or_default(); + std::fs::write( + proj.join("packages/b/package.json"), + format!(r#"{{"name":"b","version":"1.0.0"{deps}}}"#), + ) + .unwrap(); + }; + std::fs::create_dir_all(proj.join("packages/b")).unwrap(); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{"name":"root","version":"0.0.0","private":true,"workspaces":["packages/*"],"dependencies":{{"{DEP}":"^{DEP_VERSION}"}}}}"# + ), + ) + .unwrap(); + std::fs::write( + proj.join(".yarnrc.yml"), + "nodeLinker: node-modules\nenableGlobalCache: false\n", + ) + .unwrap(); + member(match change { + LockChange::MemberRemovesDescriptor => Some(DEP_VERSION), + _ => None, + }); + let (ok, out) = yarn(vendor_yarn, &proj, &["install"]); + if !ok { + skip!("SKIP e2e_vendor_yarn_berry_build (#759 {change:?}): fixture install failed:\n{out}"); + return; + } + + let orig = std::fs::read(proj.join("node_modules").join(DEP).join("index.js")).unwrap(); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + stage_patch( + &proj, + &format!("pkg:npm/{DEP}@{DEP_VERSION}"), + "package/index.js", + &orig, + &patched, + ); + let cwd = proj.to_str().unwrap(); + let (code, stdout, stderr) = + run_socket(&proj, &["vendor", "--json", "--offline", "--cwd", cwd]); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + match change { + LockChange::MemberAddsDescriptor => member(Some(DEP_VERSION)), + LockChange::MemberRemovesDescriptor => member(None), + LockChange::YarnUpgrade => {} + } + let (ok, out) = yarn(after_yarn, &proj, &["install"]); + assert!(ok, "{change:?}: install while vendored failed:\n{out}"); + let vendored = std::fs::read_to_string(proj.join("yarn.lock")).unwrap(); + if let LockChange::YarnUpgrade = change { + assert!( + vendored.contains("checksum: 10c0/"), + "{after_yarn} respells the vendored lock's checksums:\n{vendored}" + ); + } + + let (code, stdout, stderr) = run_socket(&proj, &["vendor", "--revert", "--json", "--cwd", cwd]); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let reverted = std::fs::read_to_string(proj.join("yarn.lock")).unwrap(); + + // Fresh checkout of the committable files, empty global folder. + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(fresh.join("packages/b")).unwrap(); + for file in [ + "package.json", + "yarn.lock", + ".yarnrc.yml", + "packages/b/package.json", + ] { + std::fs::copy(proj.join(file), fresh.join(file)).unwrap(); + } + let out = corepack( + &fresh, + after_yarn, + &["install", "--immutable"], + &[( + "YARN_GLOBAL_FOLDER", + tmp.path().join("fresh-global").to_str().unwrap(), + )], + ); + assert!( + out.status.success(), + "{change:?}: the reverted lock must pass `{after_yarn} install --immutable`:\n{}\n\ + reverted yarn.lock:\n{reverted}", + yarn_berry_common::yarn_output(&out) + ); + assert_eq!( + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(), + orig, + "the reverted project installs the registry bytes" + ); +} diff --git a/crates/socket-patch-core/src/formats/yarn/berry_prune.rs b/crates/socket-patch-core/src/formats/yarn/berry_prune.rs index 1a58ff50c..1cf524ccc 100644 --- a/crates/socket-patch-core/src/formats/yarn/berry_prune.rs +++ b/crates/socket-patch-core/src/formats/yarn/berry_prune.rs @@ -25,6 +25,47 @@ pub(crate) struct Pruned { pub after: Option, } +/// The descriptors a berry lock's `stanzas` still declare: every +/// entry's `dependencies:` map, plus the descriptor a `patch:` descriptor +/// wraps (`name@patch:name@npm%3A1.3.0#…` wraps `name@npm:1.3.0`), in +/// either a dependency map or a key. Yarn 4 spells a registry range +/// `npm:range`, yarn 2 and 3 a bare `range`; see +/// [`descriptor_declared`]. +pub(crate) fn declared_descriptors(stanzas: &[String]) -> BTreeSet { + let mut out = BTreeSet::new(); + for stanza in stanzas { + let keys = stanza_key(stanza) + .map(split_berry_key_patterns) + .unwrap_or_default(); + for descriptor in dependency_descriptors(stanza) { + if let Some(inner) = patch_inner(&descriptor) { + out.insert(inner); + } + out.insert(descriptor); + } + out.extend(keys.iter().filter_map(|k| patch_inner(k))); + } + out +} + +/// The descriptor a `name@patch:#` descriptor wraps, with +/// yarn's percent-encoding of the inner `:` undone. +fn patch_inner(descriptor: &str) -> Option { + let (_, rest) = descriptor.split_once("@patch:")?; + let inner = rest.split('#').next().unwrap_or(rest); + Some(inner.replace("%3A", ":")) +} + +/// Whether the `name@npm:range` `descriptor` is in `declared` +/// ([`declared_descriptors`]), in yarn 4's spelling or yarn 2/3's +/// bare `name@range`. +pub(crate) fn descriptor_declared(declared: &BTreeSet, descriptor: &str) -> bool { + declared.contains(descriptor) + || split_pattern(descriptor) + .and_then(|(name, range)| Some(format!("{name}@{}", range.strip_prefix("npm:")?))) + .is_some_and(|bare| declared.contains(&bare)) +} + /// The descriptors (`name@range`) an entry's `dependencies:` map names. pub(crate) fn dependency_descriptors(stanza: &str) -> Vec { let mut out = Vec::new(); diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 470415d1b..3114f7421 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -794,12 +794,16 @@ async fn restore_berry( // The selectors are all dropped below either way. A lock that // declares none of them (a spelling not read here) keeps every // selector's descriptor, as before. - let declared = declared.get_or_insert_with(|| berry_declared_descriptors(&blocks)); + let declared = declared.get_or_insert_with(|| { + crate::formats::yarn::berry_prune::declared_descriptors(&blocks) + }); if descriptors .iter() - .any(|d| berry_descriptor_declared(declared, d)) + .any(|d| crate::formats::yarn::berry_prune::descriptor_declared(declared, d)) { - descriptors.retain(|d| berry_descriptor_declared(declared, d)); + descriptors.retain(|d| { + crate::formats::yarn::berry_prune::descriptor_declared(declared, d) + }); } if descriptors.is_empty() { result.refuse( @@ -1027,52 +1031,6 @@ async fn restore_berry( view.write(rel, doc.render(&moved)); } -/// The descriptors a berry lock's `stanzas` still declare: every -/// entry's `dependencies:` map, plus the descriptor a `patch:` descriptor -/// wraps (`name@patch:name@npm%3A1.3.0#…` wraps `name@npm:1.3.0`), in -/// either a dependency map or a key. Yarn 4 spells a registry range -/// `npm:range`, yarn 2 and 3 a bare `range`; see -/// [`berry_descriptor_declared`]. -fn berry_declared_descriptors(stanzas: &[String]) -> BTreeSet { - use crate::formats::yarn::berry_prune::dependency_descriptors; - use crate::formats::yarn::patterns::split_berry_key_patterns; - use crate::formats::yarn::stanzas::stanza_key; - - let mut out = BTreeSet::new(); - for stanza in stanzas { - let keys = stanza_key(stanza) - .map(split_berry_key_patterns) - .unwrap_or_default(); - for descriptor in dependency_descriptors(stanza) { - if let Some(inner) = berry_patch_inner(&descriptor) { - out.insert(inner); - } - out.insert(descriptor); - } - out.extend(keys.iter().filter_map(|k| berry_patch_inner(k))); - } - out -} - -/// The descriptor a `name@patch:#` descriptor wraps, with -/// yarn's percent-encoding of the inner `:` undone. -fn berry_patch_inner(descriptor: &str) -> Option { - let (_, rest) = descriptor.split_once("@patch:")?; - let inner = rest.split('#').next().unwrap_or(rest); - Some(inner.replace("%3A", ":")) -} - -/// Whether the `name@npm:range` `descriptor` is in `declared` -/// ([`berry_declared_descriptors`]), in yarn 4's spelling or yarn 2/3's -/// bare `name@range`. -fn berry_descriptor_declared(declared: &BTreeSet, descriptor: &str) -> bool { - use crate::formats::yarn::patterns::split_pattern; - declared.contains(descriptor) - || split_pattern(descriptor) - .and_then(|(name, range)| Some(format!("{name}@{}", range.strip_prefix("npm:")?))) - .is_some_and(|bare| declared.contains(&bare)) -} - /// Retire the leftover hosted `resolutions` selectors of a yarn berry root /// `package.json` (#1203): `yarn remove` / `yarn up` deleted the lock entry /// a hosted pin keyed by its tarball URL but left its selector, which now @@ -2788,7 +2746,10 @@ mod tests { fn berry_descriptor_declared_reads_what_the_lock_still_uses() { let declared = |stanzas: &[&str], d: &str| { let stanzas: Vec = stanzas.iter().map(|s| s.to_string()).collect(); - super::berry_descriptor_declared(&super::berry_declared_descriptors(&stanzas), d) + crate::formats::yarn::berry_prune::descriptor_declared( + &crate::formats::yarn::berry_prune::declared_descriptors(&stanzas), + d, + ) }; let url_entry = "\"left-pad@http://patch.test/left-pad-1.3.0.tgz\":\n version: 1.3.0\n \ resolution: \"left-pad@http://patch.test/left-pad-1.3.0.tgz\""; diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 9115351dc..7a1a43021 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -38,6 +38,7 @@ //! (`vendor_yarn_berry_mixed_line_endings`, see //! [`refuse_mixed_line_endings`]); a revert never refuses on line endings. +use std::collections::BTreeSet; use std::path::Path; use serde_json::Value; @@ -46,15 +47,20 @@ use sha2::{Digest, Sha256, Sha512}; use crate::constants::SOCKET_DIR; use crate::formats::yarn::berry_entry::{render_pinned_entry, Pin}; use crate::formats::yarn::berry_gates::{self, BerryGate, Yarnrc, SUPPORTED_CACHE_KEY}; -use crate::formats::yarn::berry_prune::{cut_descriptors, prune_unreferenced}; -use crate::formats::yarn::blocks::{berry_field, block_eol, replace_block, scan_blocks, LockBlock}; +use crate::formats::yarn::berry_prune::{ + cut_descriptors, declared_descriptors, descriptor_declared, prune_unreferenced, +}; +use crate::formats::yarn::blocks::{ + berry_field, block_eol, replace_block, scan_blocks, with_body_field, LockBlock, +}; use crate::formats::yarn::patterns::{pattern_real_name, split_berry_key_patterns, split_pattern}; -use crate::formats::yarn::stanzas::{stanza_key, BerryStanzas}; +use crate::formats::yarn::stanzas::{stanza_key, stanza_lines, BerryStanzas}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::utils::fs::{ atomic_write_bytes_preserving_mode, read_regular_to_bytes, read_regular_to_string, }; +use crate::utils::line_endings::LineEndings; use crate::utils::socket_dir::remove_tree_and_prune; use crate::utils::uri::encode_uri_component; @@ -762,18 +768,34 @@ pub async fn revert_yarn_berry_opts( match read_regular_to_string(&lock_path).await { Ok(mut text) => { let mut changed = false; - let restored_entries: Vec = lock_recs - .iter() - .filter(|rec| rec.kind == KIND_LOCK_ENTRY || rec.kind == KIND_LOCK_ENTRY_PRUNED) - .filter_map(|rec| rec.original.as_ref()) - .filter_map(super::yarn_classic_lock::json_to_lines) - .map(|lines| lines.join("\n")) - .collect(); + // The checksum spelling the lock uses now, which a yarn + // upgrade since vendoring may have changed (#759). + let bare = lock_checksum_spelling(&text); + let user_routed = user_routed_packages(project_root, &pkg_recs, &entry.uuid).await; + let restored_of = |kind: &str| -> Vec { + lock_recs + .iter() + .filter(|rec| rec.kind == kind) + .filter_map(|rec| rec.original.as_ref()) + .filter_map(super::yarn_classic_lock::json_to_lines) + .map(|lines| lines.join("\n")) + .collect() + }; + let restored_main = restored_of(KIND_LOCK_ENTRY); + let mut restored_entries = restored_of(KIND_LOCK_ENTRY_PRUNED); + restored_entries.extend(restored_main.iter().cloned()); for rec in lock_recs { if rec.kind == KIND_LOCK_ENTRY_PRUNED { changed |= restore_pruned_entry(&mut text, rec, &mut outcome.warnings); continue; } + // ALREADY CONVERGED: an earlier pass (`--preserve-state`, + // or a revert whose package.json write failed) restored + // the entry, re-keyed or respelled as below, so it no + // longer equals the recorded original. Not drift. + if rec.kind == KIND_LOCK_ENTRY && restored_since(&text, rec) { + continue; + } changed |= revert_recorded_block( &mut text, rec, @@ -785,20 +807,46 @@ pub async fn revert_yarn_berry_opts( ); } if changed { - // The restored entries rejoin any registry entry added - // since with the same resolution (#1082). let mut doc = BerryStanzas::parse(&text); let restored: Vec = (0..doc.stanzas.len()) .filter(|&i| restored_entries.contains(&doc.stanzas[i])) .collect(); let mut moved = Vec::new(); - if !crate::formats::yarn::stanzas::merge_same_resolution( + // The recorded entries are what yarn wrote at vendor + // time; the lock has moved on since, so each follows + // what it declares and spells now (#759). A lock with + // mixed line endings is left to the byte splice above: + // the stanza view would render it in one style. + let declared = declared_descriptors(&doc.stanzas); + let rekey = Rekey { + declared: &declared, + user_routed: &user_routed, + }; + let mixed = LineEndings::of(&text) == LineEndings::Mixed; + let mut rebased = false; + for &i in restored.iter().filter(|_| !mixed) { + let main = restored_main.contains(&doc.stanzas[i]); + if let Some(stanza) = rebase_restored_entry( + &doc.stanzas, + i, + main.then_some(&rekey), + bare, + &mut moved, + &mut outcome.warnings, + ) { + doc.stanzas[i] = stanza; + rebased = true; + } + } + // The restored entries rejoin any registry entry added + // since with the same resolution (#1082). + let merged = !crate::formats::yarn::stanzas::merge_same_resolution( &mut doc.stanzas, &restored, &mut moved, ) - .is_empty() - { + .is_empty(); + if rebased || merged { text = doc.render(&moved); } forget_block_scans(); @@ -973,7 +1021,13 @@ fn restore_pruned_entry( .and_then(super::yarn_classic_lock::json_to_lines) .map(|lines| lines.join("\n")); let mut doc = BerryStanzas::parse(text); - if doc.stanzas.contains(&original) { + // Restored by an earlier pass, perhaps respelled (#759). + let neutral = checksum_spelling_neutral(&original); + if doc + .stanzas + .iter() + .any(|s| checksum_spelling_neutral(s) == neutral) + { return false; } let Some(key) = stanza_key(&original).map(str::to_string) else { @@ -1409,6 +1463,13 @@ fn scan_berry_target( /// every CI install of a yarn 4.0.x project fails. A lock with no checksum /// at all keeps the prefixed form (every yarn since 4.1). pub(crate) fn lock_spells_bare_checksums(lock_text: &str) -> bool { + lock_checksum_spelling(lock_text) == Some(true) +} + +/// How `lock_text` spells its checksums: `Some(true)` bare, `Some(false)` +/// prefixed (see [`lock_spells_bare_checksums`]), `None` when it holds no +/// checksum to tell by. +fn lock_checksum_spelling(lock_text: &str) -> Option { let mut saw_bare = false; for line in lock_text.lines() { let Some(value) = line.strip_prefix(" checksum:") else { @@ -1416,22 +1477,318 @@ pub(crate) fn lock_spells_bare_checksums(lock_text: &str) -> bool { }; let value = value.trim().trim_matches('"'); if value.contains('/') { - return false; + return Some(false); } saw_bare |= !value.is_empty(); } - saw_bare + saw_bare.then_some(true) } /// `checksum` (the recipe's `10c0/`) spelled the way `lock_text` /// spells its checksums (see [`lock_spells_bare_checksums`]). pub(crate) fn checksum_in_lock_spelling(lock_text: &str, checksum: &str) -> String { + spell_checksum(checksum, lock_spells_bare_checksums(lock_text)) +} + +/// `checksum`, in either spelling, as `` when `bare` and as +/// `10c0/` otherwise. +fn spell_checksum(checksum: &str, bare: bool) -> String { match checksum.split_once('/') { - Some((_, hex)) if lock_spells_bare_checksums(lock_text) => hex.to_string(), + Some((_, hex)) if bare => hex.to_string(), + None if !bare => format!("{SUPPORTED_CACHE_KEY}/{checksum}"), _ => checksum.to_string(), } } +/// `stanza` with its `checksum:` prefixed, so two spellings of one entry +/// compare equal. +fn checksum_spelling_neutral(stanza: &str) -> String { + let lines = stanza_lines(stanza); + berry_field(&lines, "checksum") + .and_then(|c| { + let line = format!(" checksum: {}", spell_checksum(c, false)); + with_body_field(&lines, "checksum", &line) + }) + .map_or_else(|| stanza.to_string(), |lines| lines.join("\n")) +} + +/// Whether an earlier revert pass already restored `rec`'s entry: the +/// vendored key is gone and an entry resolves where the recorded original +/// did. +fn restored_since(text: &str, rec: &WiringRecord) -> bool { + let Some(original) = rec + .original + .as_ref() + .and_then(super::yarn_classic_lock::json_to_lines) + else { + return false; + }; + let Some(resolution) = berry_field(&original, "resolution") else { + return false; + }; + let doc = BerryStanzas::parse(text); + let key_live = doc + .stanzas + .iter() + .any(|s| stanza_key(s).is_some() && stanza_key(s) == rec.key.as_deref()); + !key_live + && doc + .stanzas + .iter() + .any(|s| berry_field(&stanza_lines(s), "resolution") == Some(resolution)) +} + +/// The packages a user `resolutions` selector routes (see +/// [`Rekey::user_routed`]): a pin vendoring took over (its record keeps +/// the user's value), and any selector in the root `package.json` that is +/// not this entry's own vendored pin. An unreadable manifest names none. +async fn user_routed_packages( + project_root: &Path, + pkg_recs: &[&WiringRecord], + entry_uuid: &str, +) -> BTreeSet { + use crate::formats::yarn::patterns::resolution_selector_target; + let mut names: BTreeSet = pkg_recs + .iter() + .filter(|rec| rec.original.is_some()) + .filter_map(|rec| resolution_selector_target(rec.key.as_deref()?)) + .map(str::to_string) + .collect(); + let pkg = read_regular_to_bytes(&project_root.join(PACKAGE_JSON)) + .await + .ok() + .and_then(|bytes| parse_json_manifest(&bytes).ok()); + let table = pkg + .as_ref() + .and_then(|p| p.get("resolutions")) + .and_then(Value::as_object); + for (selector, value) in table.into_iter().flatten() { + let ours = value + .as_str() + .and_then(parse_vendor_path) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if let Some(target) = resolution_selector_target(selector).filter(|_| !ours) { + names.insert(target.to_string()); + } + } + names +} + +/// A pre-vendor entry the revert put back (`stanzas[idx]`), re-derived +/// from the live lock (#759), or `None` when it already matches it: +/// +/// - its `checksum:` in the lock's current spelling (`bare`, read before +/// the restore; `None` when the lock showed none): a yarn 4.0.x lock +/// upgraded to 4.1+ since vendoring respells every checksum +/// `10c0/`; +/// - with `rekey`, its key: the package's +/// registry descriptors the lock still declares, plus any added since +/// vendoring (the `resolutions` pin routed them to the vendored entry, +/// so none has an entry of its own) that its version satisfies. A +/// descriptor it does not satisfy needs an entry only `yarn install` +/// can resolve, which is warned about. Yarn keys an entry by exactly +/// the descriptors resolved to it, and `--immutable` rejects any other +/// key (YN0028). +fn rebase_restored_entry( + stanzas: &[String], + idx: usize, + rekey: Option<&Rekey<'_>>, + bare: Option, + moved: &mut Vec, + warnings: &mut Vec, +) -> Option { + let mut lines = stanza_lines(&stanzas[idx]); + let mut changed = false; + + if let (Some(checksum), Some(bare)) = (berry_field(&lines, "checksum"), bare) { + let spelled = spell_checksum(checksum, bare); + if spelled != checksum { + if let Some(respelled) = + with_body_field(&lines, "checksum", &format!(" checksum: {spelled}")) + { + lines = respelled; + changed = true; + } + } + } + + if let Some(rekey) = rekey { + if let Some(key) = rekeyed(stanzas, idx, &lines, rekey, warnings) { + lines[0] = format!("{key}:"); + moved.push(key); + changed = true; + } + } + + changed.then(|| lines.join("\n")) +} + +/// What [`rekeyed`] reads besides the lock. +struct Rekey<'a> { + /// [`declared_descriptors`] of the restored lock. + declared: &'a BTreeSet, + /// Packages a `resolutions` selector of the user's routes (one still + /// in `package.json`, or a pin vendoring took over and the revert puts + /// back): yarn keys their entry by the selector's range, which no + /// dependency map declares, so the recorded key stands. + user_routed: &'a BTreeSet, +} + +/// The key [`rebase_restored_entry`] gives a restored entry, or `None` to +/// keep the recorded one. +fn rekeyed( + stanzas: &[String], + idx: usize, + lines: &[String], + rekey: &Rekey<'_>, + warnings: &mut Vec, +) -> Option { + let declared = rekey.declared; + let recorded = stanza_key(&stanzas[idx])?; + let patterns = split_berry_key_patterns(recorded); + let version = berry_field(lines, "version")?; + let mut names = patterns + .iter() + .filter_map(|p| split_pattern(p).map(|(n, _)| n)); + let name = names.next()?; + if names.any(|n| n != name) || rekey.user_routed.contains(name) { + return None; + } + // The package's plain registry descriptors (`name@npm:range`) the lock + // declares, in yarn 4's key spelling whatever the dependency map's. + let live: BTreeSet = declared + .iter() + .filter_map(|d| { + let (n, range) = split_pattern(d)?; + let range = range.strip_prefix("npm:").unwrap_or(range); + (n == name && plain_npm_range(range)).then(|| format!("{name}@npm:{range}")) + }) + .collect(); + // A lock that declares none of them (a spelling not read here) keeps + // the recorded key, as before. + if live.is_empty() { + return None; + } + let registry = |p: &str| { + split_pattern(p).is_some_and(|(_, r)| r.strip_prefix("npm:").is_some_and(plain_npm_range)) + }; + let keyed_elsewhere: BTreeSet = stanzas + .iter() + .enumerate() + .filter(|&(i, _)| i != idx) + .filter_map(|(_, s)| stanza_key(s)) + .flat_map(split_berry_key_patterns) + .collect(); + let mut key: Vec = patterns + .iter() + .filter(|p| !registry(p) || descriptor_declared(declared, p)) + .cloned() + .collect(); + for descriptor in &live { + if key.contains(descriptor) || keyed_elsewhere.contains(descriptor) { + continue; + } + let range = &descriptor[name.len() + "@npm:".len()..]; + if npm_range_admits(range, version) == Some(true) { + key.push(descriptor.clone()); + } else { + warnings.push(VendorWarning::new( + "vendor_lock_descriptor_unlocked", + format!( + "`{descriptor}` was added while {name} was vendored, and the restored \ + {name}@{version} does not satisfy it (or its range could not be read), \ + so it has no {YARN_LOCK} entry; run `yarn install` and commit {YARN_LOCK}" + ), + )); + } + } + if !key.iter().any(|p| registry(p)) { + return None; + } + key.sort(); + key.dedup(); + (key != patterns).then(|| format!("\"{}\"", key.join(", "))) +} + +/// Whether `range` is a plain npm semver range (no protocol, alias, path, +/// URL or git spec), the kind yarn resolves against the registry. +fn plain_npm_range(range: &str) -> bool { + !range.contains([':', '@', '/', '#']) +} + +/// Whether the npm semver `range` admits `version`; `None` for a range +/// this reader does not decide (a dist-tag, a partial hyphen bound). +fn npm_range_admits(range: &str, version: &str) -> Option { + let version = semver::Version::parse(version).ok()?; + let mut admits = false; + for alternative in range.split("||") { + let comparators: Vec = match alternative.split_once(" - ") { + // `a - b` with full versions is `>=a <=b`. + Some((low, high)) => { + let full = |v: &str| { + let v = v.trim().trim_start_matches(['v', '=']); + semver::Version::parse(v).ok().map(|_| v.to_string()) + }; + vec![format!(">={}", full(low)?), format!("<={}", full(high)?)] + } + None => glue_operators(alternative) + .iter() + .map(|c| npm_comparator(c)) + .collect::>()?, + }; + let req = match comparators.is_empty() { + true => "*".to_string(), + false => comparators.join(", "), + }; + admits |= semver::VersionReq::parse(&req).ok()?.matches(&version); + } + Some(admits) +} + +/// The comparators of one npm range alternative, an operator npm allows +/// to stand apart (`>= 1.2.0`) joined to its version. +fn glue_operators(alternative: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut pending = String::new(); + for token in alternative.split_whitespace() { + if token + .chars() + .all(|c| matches!(c, '^' | '~' | '>' | '<' | '=')) + { + pending.push_str(token); + } else { + out.push(format!("{}{token}", std::mem::take(&mut pending))); + } + } + out +} + +/// One npm comparator in the `semver` crate's syntax, which differs for a +/// bare version: npm's `1.3.0` is exact and `1.3` means `~1.3`, where the +/// crate reads both as caret ranges. +fn npm_comparator(comparator: &str) -> Option { + let op_len = comparator + .find(|c: char| !matches!(c, '^' | '~' | '>' | '<' | '=')) + .unwrap_or(comparator.len()); + let (op, version) = comparator.split_at(op_len); + let version = version.strip_prefix('v').unwrap_or(version); + if !op.is_empty() { + return Some(format!("{op}{version}")); + } + // Prerelease and build tags ride on the patch part. + let core = version.split(['-', '+']).next().unwrap_or(version); + let parts: Vec<&str> = core.split('.').collect(); + let wildcard = |p: &&str| matches!(*p, "*" | "x" | "X"); + if parts.iter().any(wildcard) { + return Some(version.to_string()); + } + match parts.len() { + 3 => Some(format!("={version}")), + 1 | 2 => Some(format!("~{version}")), + _ => None, + } +} + /// The root workspace's name: the lock's single-pattern `@workspace:.` /// entry (the key + resolution of our file: entry embed it). fn root_workspace_name(blocks: &[LockBlock]) -> Option { @@ -2585,6 +2942,336 @@ __metadata: assert_eq!(tokio::fs::read(fx.pkg_path()).await.unwrap(), fx.pkg_bytes); } + // ── #759: the restored entry follows the live lock ──────────────────── + + const LP_CHECKSUM_HEX: &str = "3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955"; + + const WS_PKG: &str = r#"{ + "name": "root", + "private": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "left-pad": "^1.3.0" + } +} +"#; + + /// A two-workspace lock: the root declares `left-pad@^1.3.0`, member + /// `b` declares `b_dep` (nothing, or its own left-pad range), and the + /// left-pad entry is keyed by `key`. + fn ws_lock(key: &str, b_dep: Option<&str>) -> String { + let b_deps = b_dep + .map(|r| format!(" dependencies:\n left-pad: \"npm:{r}\"\n")) + .unwrap_or_default(); + format!( + "# This file is generated by running \"yarn install\" inside your project.\n\ + # Manual changes might be lost - proceed with caution!\n\n\ + __metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"b@workspace:packages/b\":\n version: 0.0.0-use.local\n \ + resolution: \"b@workspace:packages/b\"\n{b_deps} languageName: unknown\n \ + linkType: soft\n\n\ + \"{key}\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n \ + checksum: 10c0/{LP_CHECKSUM_HEX}\n languageName: node\n linkType: hard\n\n\ + \"root@workspace:.\":\n version: 0.0.0-use.local\n \ + resolution: \"root@workspace:.\"\n dependencies:\n \ + left-pad: \"npm:^1.3.0\"\n languageName: unknown\n linkType: soft\n" + ) + } + + /// Member `b`'s dependency edit, made by `yarn install` while vendored: + /// only the workspace stanza changes (the `resolutions` pin routes + /// every left-pad descriptor to the vendored entry). + async fn set_member_dep(fx: &Fixture, from: Option<&str>, to: Option<&str>) { + let deps = |r: Option<&str>| { + r.map(|r| format!(" dependencies:\n left-pad: \"npm:{r}\"\n")) + .unwrap_or_default() + }; + let stanza = |r: Option<&str>| { + format!( + "\"b@workspace:packages/b\":\n version: 0.0.0-use.local\n \ + resolution: \"b@workspace:packages/b\"\n{} languageName: unknown\n", + deps(r) + ) + }; + let text = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + let edited = text.replacen(&stanza(from), &stanza(to), 1); + assert_ne!(edited, text, "member stanza edited"); + tokio::fs::write(fx.lock_path(), edited).await.unwrap(); + } + + /// #759: vendored under yarn 4.0.x (bare checksums), then a yarn 4.1+ + /// install respells every checksum `10c0/`. The revert writes the + /// restored entry in the lock's current spelling, not the recorded one; + /// a bare line among prefixed ones fails every `--immutable` (YN0028). + #[tokio::test] + async fn issue_759_revert_follows_a_checksum_respelling_since_vendoring() { + let bare_before = B3_BEFORE_LOCK.replace("checksum: 10c0/", "checksum: "); + let fx = fixture_with(B3_BEFORE_PKG, &bare_before).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let vendored = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + assert!(lock_spells_bare_checksums(&vendored)); + // `yarn set version 4.18` + `yarn install`. + let upgraded: String = vendored + .lines() + .map(|l| match l.strip_prefix(" checksum: ") { + Some(hex) => format!(" checksum: 10c0/{hex}"), + None => l.to_string(), + }) + .collect::>() + .join("\n") + + "\n"; + tokio::fs::write(fx.lock_path(), &upgraded).await.unwrap(); + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + tokio::fs::read_to_string(fx.lock_path()).await.unwrap(), + B3_BEFORE_LOCK, + "the restored checksum is prefixed like every other entry" + ); + + // The reverse (a lock respelled bare since) follows the lock too. + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let vendored = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + tokio::fs::write( + fx.lock_path(), + vendored.replace("checksum: 10c0/", "checksum: "), + ) + .await + .unwrap(); + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(fx.lock_path()).await.unwrap(), + bare_before + ); + } + + /// #759: a workspace member adds a descriptor of the vendored package + /// while it is vendored. The `resolutions` pin routes it to the + /// vendored entry, so the lock gets no entry for it; the revert keys + /// the restored entry by it too, the way yarn writes it. + #[tokio::test] + async fn issue_759_revert_keys_a_descriptor_added_since_vendoring() { + let before = ws_lock("left-pad@npm:^1.3.0", None); + let fx = fixture_with(WS_PKG, &before).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + set_member_dep(&fx, None, Some("1.3.0")).await; + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + tokio::fs::read_to_string(fx.lock_path()).await.unwrap(), + ws_lock("left-pad@npm:1.3.0, left-pad@npm:^1.3.0", Some("1.3.0")), + ); + assert_eq!(tokio::fs::read(fx.pkg_path()).await.unwrap(), fx.pkg_bytes); + } + + /// #759: a workspace member removes its descriptor of the vendored + /// package while it is vendored (another keeps one). The revert drops + /// the removed descriptor from the restored key, as yarn would; the + /// vendored twin of the hosted #1435. + #[tokio::test] + async fn issue_759_revert_drops_a_descriptor_removed_since_vendoring() { + let before = ws_lock("left-pad@npm:1.3.0, left-pad@npm:^1.3.0", Some("1.3.0")); + let fx = fixture_with(WS_PKG, &before).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + set_member_dep(&fx, Some("1.3.0"), None).await; + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + tokio::fs::read_to_string(fx.lock_path()).await.unwrap(), + ws_lock("left-pad@npm:^1.3.0", None), + ); + } + + /// #759: a descriptor added since vendoring that the restored version + /// does not satisfy needs its own entry, which only `yarn install` can + /// resolve: the restored key stays as recorded, and the revert says so. + #[tokio::test] + async fn issue_759_revert_warns_about_a_descriptor_it_cannot_lock() { + let before = ws_lock("left-pad@npm:^1.3.0", None); + let fx = fixture_with(WS_PKG, &before).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + set_member_dep(&fx, None, Some("^2.0.0")).await; + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code).collect(); + assert_eq!( + codes, + ["vendor_lock_descriptor_unlocked"], + "{:?}", + outcome.warnings + ); + assert!( + outcome.warnings[0].detail.contains("left-pad@npm:^2.0.0"), + "{:?}", + outcome.warnings + ); + assert_eq!( + tokio::fs::read_to_string(fx.lock_path()).await.unwrap(), + ws_lock("left-pad@npm:^1.3.0", Some("^2.0.0")), + ); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); + } + + /// #759 review: a user `resolutions` pin vendoring took over keys the + /// entry by its own range (`left-pad@npm:1.3.0`), which no dependency + /// map declares (the root says `^1.3.0`). An unchanged lock reverts + /// byte-exactly; the key is not rebuilt from the dependency maps. + #[tokio::test] + async fn issue_759_taken_over_user_pin_keeps_its_key() { + let pkg = WS_PKG.replace( + " }\n}", + " },\n \"resolutions\": {\n \"left-pad\": \"1.3.0\"\n }\n}", + ); + let before = ws_lock("left-pad@npm:1.3.0", None); + let fx = fixture_with(&pkg, &before).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + tokio::fs::read_to_string(fx.lock_path()).await.unwrap(), + before + ); + assert_eq!(tokio::fs::read(fx.pkg_path()).await.unwrap(), fx.pkg_bytes); + } + + /// #759 review: a `--preserve-state` revert restores (and respells) the + /// entry but keeps the artifact; the next revert recognizes that + /// restore and converges silently instead of reporting the entry + /// removed. + #[tokio::test] + async fn issue_759_second_revert_pass_converges_on_a_rebased_entry() { + let bare_before = B3_BEFORE_LOCK.replace("checksum: 10c0/", "checksum: "); + let fx = fixture_with(B3_BEFORE_PKG, &bare_before).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let vendored = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + let upgraded = vendored.replace("checksum: ", "checksum: 10c0/"); + tokio::fs::write(fx.lock_path(), &upgraded).await.unwrap(); + + let keep = RevertOpts { + dry_run: false, + keep_artifact: true, + }; + let outcome = revert_yarn_berry_opts(&entry, fx.root(), keep).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(fx.lock_path()).await.unwrap(), + B3_BEFORE_LOCK + ); + assert!( + fx.tgz_path().exists(), + "--preserve-state keeps the artifact" + ); + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + tokio::fs::read_to_string(fx.lock_path()).await.unwrap(), + B3_BEFORE_LOCK + ); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); + } + + /// #759 review: a lock whose line endings were mixed since vendoring is + /// not rendered through the stanza view (which would flatten every + /// CRLF): the byte splice restores the entry and every other line keeps + /// its terminator. + #[tokio::test] + async fn issue_759_mixed_line_endings_are_not_flattened() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let vendored = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + let mixed = vendored.replace("checksum: 10c0/", "checksum: ").replacen( + "__metadata:\n", + "__metadata:\r\n", + 1, + ); + tokio::fs::write(fx.lock_path(), &mixed).await.unwrap(); + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let reverted = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + assert!(reverted.contains("__metadata:\r\n"), "{reverted}"); + assert!(reverted.contains("\"left-pad@npm:1.3.0\":\n"), "{reverted}"); + } + + #[test] + fn lock_checksum_spelling_tells_none_from_prefixed() { + let entry = |c: &str| format!("\"a@npm:1\":\n version: 1.0.0\n checksum: {c}\n"); + assert_eq!(lock_checksum_spelling(&entry("abcd")), Some(true)); + assert_eq!(lock_checksum_spelling(&entry("10c0/abcd")), Some(false)); + assert_eq!( + lock_checksum_spelling("\"a@npm:1\":\n version: 1.0.0\n"), + None + ); + assert_eq!(spell_checksum("abcd", false), "10c0/abcd"); + assert_eq!(spell_checksum("10c0/abcd", true), "abcd"); + assert_eq!(spell_checksum("abcd", true), "abcd"); + } + + #[test] + fn npm_range_admits_reads_npm_semantics() { + for (range, version, want) in [ + ("1.3.0", "1.3.0", Some(true)), + ("1.3.0", "1.3.1", Some(false)), + ("^1.3.0", "1.9.0", Some(true)), + ("^1.3.0", "2.0.0", Some(false)), + ("~1.3.0", "1.3.5", Some(true)), + ("~1.3.0", "1.4.0", Some(false)), + ("1.3", "1.3.9", Some(true)), + ("1.3", "1.4.0", Some(false)), + ("1", "1.9.0", Some(true)), + ("1.x", "1.9.0", Some(true)), + ("*", "4.0.0", Some(true)), + ("", "4.0.0", Some(true)), + (">=1.2.0 <1.4.0", "1.3.0", Some(true)), + (">=1.2.0 <1.3.0", "1.3.0", Some(false)), + ("^2.0.0 || ^1.3.0", "1.3.0", Some(true)), + ("1.0.0 - 2.0.0", "1.3.0", Some(true)), + ("1.0.0 - 1.2.0", "1.3.0", Some(false)), + ("1.0 - 2", "1.3.0", None), + (">= 1.2.0", "1.3.0", Some(true)), + ("< 1.3.0", "1.3.0", Some(false)), + ("v1.3.0", "1.3.0", Some(true)), + ("=1.3.0", "1.3.0", Some(true)), + ("1.3.0-beta.1", "1.3.0-beta.1", Some(true)), + ("1.3.0-beta.1", "1.3.0", Some(false)), + ("latest", "1.3.0", None), + ] { + assert_eq!( + npm_range_admits(range, version), + want, + "{range} vs {version}" + ); + } + } #[tokio::test] async fn revert_leaves_drifted_fragments_alone_with_warnings() { // Lock drift: the user re-resolved our entry back to the registry. diff --git a/docs/testing/yarn-berry-compatibility.md b/docs/testing/yarn-berry-compatibility.md index 2de3589d2..22cd188a6 100644 --- a/docs/testing/yarn-berry-compatibility.md +++ b/docs/testing/yarn-berry-compatibility.md @@ -119,7 +119,7 @@ What socket-patch does with those files: | uniformly LF or CRLF | rewritten in the file's own ending; no hosted ledger | lock entry spliced in the file's ending; `package.json` re-serialized in its own layout (BOM, indent, ending, trailing newline) | | leading BOM | kept | kept, both files | | mixed CRLF / LF, or a bare CR | refused untouched: `redirect_yarn_berry_mixed_line_endings` | refused before any write: `vendor_yarn_berry_mixed_line_endings` | -| revert (`rollback`, `remove`, takeovers) | upstream entries reconstructed from registry metadata; mixed endings refuse as drift | byte-exact; a lock mixed after vendoring gets the restored entry in the terminator of the entry it replaces | +| revert (`rollback`, `remove`, takeovers) | upstream entries reconstructed from registry metadata; mixed endings refuse as drift | byte-exact while the lock is unchanged since vendoring; a lock changed since gets the restored entry keyed by the descriptors it declares now and with its checksum in the lock's current spelling (#759); a lock mixed after vendoring gets the restored entry in the terminator of the entry it replaces | | mode takeover into this mode | the vendored revert is staged and the hosted rewrite planned against it; a purl the berry rewriter refuses (line endings, `cacheKey`, `compressionLevel`, no berry checksum) is retracted and stays vendored, byte-identical | the backend's project gates (both files' line endings, `cacheKey`, `compressionLevel`) run BEFORE the hosted redirect is reverted; a refused purl stays hosted, byte-identical | Every reader — manifest-less `vex`, the lockfile inventory, the npm flavor