Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
94a6c2e
Plan one lock model for every lockfile reader
mikolalysenko Nov 4, 2026
a08a113
Keep the origin classifier out of formats/
mikolalysenko Nov 4, 2026
0006f14
Make one set of behaviors the goal, hosted semantics first
mikolalysenko Nov 4, 2026
950f9fe
Catalog where readers and modes disagree
mikolalysenko Nov 4, 2026
a396590
Record user-patch and repo-root decisions
mikolalysenko Nov 4, 2026
134d40e
Move the Socket origin classifier into one module
mikolalysenko Nov 4, 2026
b696235
Read every pnpm lock document, the env lockfile as its own tree
mikolalysenko Nov 4, 2026
182e3b7
Ask the origin classifier whether a pnpm dep is vendored
mikolalysenko Nov 4, 2026
38d5a3c
Read Rush locks always and follow lockfile=false in the inventory
mikolalysenko Nov 4, 2026
f63ee08
Guard pnpm entry reads with an architecture test
mikolalysenko Nov 4, 2026
09fa5a9
Mark N4, N5 (pnpm), N12 and N14 resolved; rebudget
mikolalysenko Nov 4, 2026
613ca3a
Address code review of the pnpm reader PR
mikolalysenko Nov 4, 2026
ad40d1d
Plan pnpm rewires with one core for both modes
mikolalysenko Nov 5, 2026
4a84f2d
Mark N5, N15 and N16 for the pnpm planner merge; rebudget
mikolalysenko Nov 5, 2026
56392e1
Shadow a shrinkwrap.yaml beside a pnpm-lock.yaml (N11)
mikolalysenko Nov 5, 2026
cc3ad0c
Address the code review of the pnpm planner merge
mikolalysenko Nov 6, 2026
e7df820
Refuse vendoring a pnpm 1 / 2 project beside its shrinkwrap.yaml
mikolalysenko Nov 6, 2026
0d68393
Fix the loc-budget counter's cfg(test) and module bugs
mikolalysenko Nov 6, 2026
75d575e
Record the 2026-11-05 owner decisions; link the fix PRs
mikolalysenko Nov 6, 2026
4242ebc
Merge lock-model/0-design into lock-model/1-origin
claude Nov 6, 2026
1d27a20
Merge lock-model/1-origin into lock-model/2a-pnpm-readers
claude Nov 6, 2026
cf29118
Merge lock-model/2a-pnpm-readers into lock-model/2b-pnpm-planner
claude Nov 6, 2026
4b1fc91
Keep cfg(test) crate-visible fields and const generics from swallowin…
claude Nov 6, 2026
d6ccde3
Name real symbols and paths in the lock-model design doc
claude Nov 6, 2026
88df7f9
Merge lock-model/0-design into lock-model/1-origin
mikolalysenko Nov 6, 2026
fbe644d
Address the review of the origin move
mikolalysenko Nov 6, 2026
097a538
Merge lock-model/1-origin into lock-model/2a-pnpm-readers
mikolalysenko Nov 6, 2026
1fec27a
Merge lock-model/2a-pnpm-readers into lock-model/2b-pnpm-planner
mikolalysenko Nov 6, 2026
a322027
Merge remote-tracking branch 'origin/main' into pr2002
claude Nov 7, 2026
ad1abb4
Merge lock-model/1-origin into lock-model/2a-pnpm-readers
claude Nov 7, 2026
e79b9f1
Merge remote-tracking branch 'origin/lock-model/2a-pnpm-readers' into…
claude Nov 7, 2026
64dfe54
Test the pnpm takeover gate with a mixed-ending lock
claude Nov 7, 2026
ff603e2
Gate the inventory's Rush check on is_rush, as discovery does
claude Nov 7, 2026
6978424
Merge remote-tracking branch 'origin/lock-model/2a-pnpm-readers' into…
claude Nov 7, 2026
2b4475e
Read package.json beside a root pnpm lock pair in hosted mode
claude Nov 7, 2026
e69a284
Merge main into lock-model/1-origin
claude Nov 9, 2026
3d9a72e
Merge commit 'e69a284' into lock-model/2a-pnpm-readers
claude Nov 9, 2026
fc5fdf6
Merge remote-tracking branch 'origin/main' into lock-model/2a-pnpm-re…
claude Nov 9, 2026
f5215a4
Carry main's new pnpm marks onto per-tree UnwiredCopy scopes
claude Nov 9, 2026
97bee85
Merge lock-model/2a-pnpm-readers into lock-model/2b-pnpm-planner
claude Nov 9, 2026
622e9c9
Drop a pnpm env twin when its project ref is withheld
claude Nov 9, 2026
3824614
Merge remote-tracking branch 'origin/main' into lock-model/2a-pnpm-re…
mikolalysenko Nov 9, 2026
868d135
Withhold a pnpm env ref the project document resolves elsewhere
mikolalysenko Nov 9, 2026
a857945
Note the env-ref withholding in the N4 divergence row
mikolalysenko Nov 9, 2026
2731ef0
Merge remote-tracking branch 'origin/lock-model/2a-pnpm-readers' into…
mikolalysenko Nov 9, 2026
a673e1b
Name a project copy that withholds a pnpm config-dependency pin
mikolalysenko Nov 9, 2026
e61df3f
Merge remote-tracking branch 'origin/lock-model/2a-pnpm-readers' into…
mikolalysenko Nov 9, 2026
a303dd6
Address the second code review of the pnpm reader PR
mikolalysenko Nov 9, 2026
e426d50
Merge remote-tracking branch 'origin/lock-model/2a-pnpm-readers' into…
mikolalysenko Nov 9, 2026
ac8e9e0
Pin the shadowed early shrinkwrap in pnpm_hosted
mikolalysenko Nov 9, 2026
8b59a5a
Address the second code review of the pnpm planner PR
mikolalysenko Nov 9, 2026
9786923
Merge remote-tracking branch 'origin/main' into lock-model/2a-pnpm-re…
mikolalysenko Nov 9, 2026
ff4c8db
Simplify the folded pnpm reader and planner
mikolalysenko Nov 9, 2026
aa9f5c9
Address the code review of the simplify pass
mikolalysenko Nov 9, 2026
ec7e230
Merge remote-tracking branch 'origin/main' into lock-model/2a-pnpm-re…
mikolalysenko Nov 9, 2026
9bbb4b7
Merge main: scope #2018's user-patched marks per install tree
mikolalysenko Nov 9, 2026
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
4 changes: 2 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc

**pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `pnpm_config_git_branch_lockfile` (pnpm 11 and newer) or `npm_config_git_branch_lockfile` (pnpm 10 and older; either when the major is unknown, read as for the `lockfile` setting above) set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock.<branch>.yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock.<branch>.yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. `vex` omits every pin of those pnpm locks (`vex_pnpm_git_branch_lockfile`) whenever such a branch lock exists, whatever the setting says (fail-closed; read from the project's files only, not the environment); see **Unattested references**.

`redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers.
`redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers, when no `pnpm-lock.yaml` sits beside them. pnpm >= 3 never reads a `shrinkwrap.yaml` it has a `pnpm-lock.yaml` for, so that one is shadowed (unless the project pins pnpm 1 or 2 in `node_modules/.modules.yaml` or package.json's `packageManager`: those read only `shrinkwrap.yaml`, so there the stray `pnpm-lock.yaml` is the shadowed one): hosted mode never rewrites it and leaves it out of the all-or-nothing gate, so an entry it cannot repoint (or pnpm 1's early shrinkwrap head) refuses nothing, and `vex` only sweeps it for stale Socket identities. Vendored mode never writes it either, and refuses a project that pins pnpm 1 or 2 beside a `shrinkwrap.yaml` (`vendor_lockfile_version_unsupported`: it does not write the shrinkwrap grammar, and the stray `pnpm-lock.yaml` would patch nothing pnpm installs).

**vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`, or `redirect_vlt_custom_registry_skipped` alone). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifacts**: the scan never downloads the hosted artifacts; the patch server sends them unencoded (`Cache-Control: no-transform`), and vlt verifies slot [2] against the bytes it receives at install time. **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the version document of the registry the node resolves against (slot [3] is its `dist.tarball`, as vlt writes it; `upstream_registry_fallback` when that registry can't be read), following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`.

Expand Down Expand Up @@ -562,7 +562,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma
| Ecosystem | Files read | Hosted reference | Vendored reference | Hosted pin (`integrity_required`) |
|---|---|---|---|---|
| npm | `package-lock.json` and `npm-shrinkwrap.json` (both when both exist) | `resolved` on the patch host (`packages` in v2/v3; `dependencies` only in v1; `link` entries skipped, and so are `inBundle` / `bundled` entries a dependency bundles (a copy the root project's own `bundleDependencies` covers, and the dependencies npm installs beneath it, has no parent tarball and is rewired like any entry, #1618; a copy beneath a root-bundled package that declares its own bundle stays skipped, since the lock cannot tell which bundle carries it), and so is any entry npm installs from a git, URL or `file:` spec or from a dependency's own `npm-shrinkwrap.json` (beneath a `hasShrinkwrap: true` package, which npm 7–11 install from that shrinkwrap; `redirect_npm_shrinkwrapped_instance_skipped` / `vendor_shrinkwrapped_instance_skipped`), together with every ref for the same `name@version`) | `resolved: file:.socket/vendor/npm/<uuid>/<name>-<ver>.tgz` | `integrity`, required |
| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required. With pnpm's `lockfile` setting off (#1074, outside Rush) pnpm reads no lock: every hosted reference in the root and member locks keeps no pin, and one `patched_ref_unattributable` per lock names them; vendored references are unaffected. |
| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required. With pnpm's `lockfile` setting off (#1074, outside Rush) pnpm reads no lock: every hosted reference in the root and member locks keeps no pin, and one `patched_ref_unattributable` per lock names them; vendored references are unaffected. pnpm 11+'s env lockfile document (config dependencies, the pinned package manager) is read as its own install tree: its references neither contest nor are contested by another lock, and its entries never contest the project document's references. One of its references is never attested while the project document of the same lock resolves the same `name@version` from elsewhere, because a VEX statement covers the release, not one install tree: a registry entry or a `file:` copy withholds it with `patched_ref_unattributable` (re-running `scan` rewires a registry entry), and a bundled copy omits it like any pnpm bundled copy (`vex_pnpm_bundled_copy`). |
| yarn | `yarn.lock` (classic and berry) | classic `resolved`; berry entry keyed and resolved `<name>@<url>` + root `package.json` `resolutions` `"<name>@npm:<range>": "<url>"` (older releases: `resolution: …::__archiveUrl=<url>`) | classic `resolved "file:./.socket/vendor/npm/…#<sha1>"`; berry `file:` entry **plus** a root `package.json` `resolutions` mapping onto the same artifact (without it the entry is orphaned: diagnosed, no ref) | classic `integrity` / `#sha1`, berry `checksum`, required |
| bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf. A user URL or `file:` tarball tuple / record whose `<name>-<ver>.tgz` leaf names the same `name@version` is installed from its own spec, so every ref for that `name@version` is dropped | `.socket/vendor/npm/<uuid>/<name>-<ver>.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. |
| vlt | `vlt-lock.json` (lockfileVersion absent, `0` or `1`; a BOM-prefixed, unparseable, non-object or other-version lock is not read: diagnosed, no ref). `vlt.json` (read only for its `modifiers`) and `node_modules/.vlt-lock.json` are never wiring. | a registry node (any segment) whose slot [3] is a `/patch/npm/…` URL on the patch host with the leaf `<bare>-<ver>.tgz` of its DepID's `name@version`, whose embedded `<name>/<ver>` path (when present) is that `name@version`, and slot [1] == name; version from the DepID | a `file` node `.socket/vendor/npm/<uuid>/<name>-<ver>/node_modules/<name>` (or a user-installed `<name>-<ver>.tgz`) with slot [1] == name; version from the path. A same-`name@version` registry node, or a diagnosed Socket-shaped one, beside it is diagnosed, no ref. | slot [2] `sha512-…`, required (a hosted node without one is no reference). A same-`name@version` node on another registry, or a diagnosed Socket-shaped one, keeps the reference but withholds the lockfile basis: only an installed tree whose every store copy verifies attests. So does a lock some vlt release discards, the conditions of `redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored` and `redirect_vlt_scalar_registry_ignored` (which in-run `--vex` withholds too): every hosted reference in it keeps no pin, and one `patched_ref_unattributable` names them. |
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/src/commands/scan/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,7 @@ mod tests {
resolved: None,
integrity: LockIntegrity::None,
source_kind: SourceKind::Unspecified,
pnpm_env: false,
}
}

Expand Down
46 changes: 46 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6133,6 +6133,52 @@ async fn pnpm_rollback_keeps_tarball_under_workspace_include_tarball_url() {
assert_eq!(pnpm_pin_and_rollback(tmp.path(), &server), pristine);
}

/// pnpm 11+ records a config dependency bare in the env lockfile document
/// even under `lockfileIncludeTarballUrl`, so a rollback of a pin in both
/// documents gives the project entry its `tarball:` back and leaves the env
/// entry bare.
#[tokio::test]
#[serial]
async fn pnpm_rollback_keeps_the_env_document_bare_under_include_tarball_url() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;
mock_view(&server).await;
mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await;
let tarball = format!(
"{}/npm-registry/{NAME}/-/{NAME}-{VERSION}.tgz",
server.uri()
);

let tmp = tempfile::tempdir().unwrap();
let project = write_pnpm_tarball_project(tmp.path(), &tarball);
let pristine = format!(
"---\nlockfileVersion: '9.0'\n\nimporters:\n\n .:\n configDependencies:\n \
{NAME}:\n specifier: {VERSION}\n version: {VERSION}\n\npackages:\n\n \
{NAME}@{VERSION}:\n resolution: {{integrity: sha512-UPSTREAMupstream==}}\n\n\
snapshots:\n\n {NAME}@{VERSION}: {{}}\n\n---\n{project}"
);
std::fs::write(tmp.path().join("pnpm-lock.yaml"), &pristine).unwrap();
std::fs::write(
tmp.path().join("pnpm-workspace.yaml"),
"packages:\n - '.'\nlockfileIncludeTarballUrl: true\n",
)
.unwrap();
let pinned_twice = |root: &Path| {
let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap();
lock.matches("patch.test").count()
};
let env = run_redirect_subprocess(tmp.path(), &server.uri());
assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}");
assert_eq!(pinned_twice(tmp.path()), 2, "both documents are pinned");
let (code, env) = rollback_json(tmp.path(), &server);
assert_eq!(code, Some(0), "rollback: {env:#}");
assert_eq!(
std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(),
pristine
);
}

/// #557, the other case pnpm records `tarball:` in: the registry's
/// `dist.tarball` is not the conventional URL pnpm would derive, so pnpm
/// keeps it even without the setting.
Expand Down
77 changes: 77 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3012,6 +3012,83 @@ async fn hosted_pnpm_file_tarball_copy_is_named() {
.await;
}

/// #2009: the registry release is a pnpm config dependency (the env
/// lockfile document) and the project document installs only a `file:`
/// copy of the same `name@version`. The scan pins the config dependency,
/// and names the project's copy with `redirect_copy_unpatched`, since a
/// VEX statement covers the release, not one install tree.
#[tokio::test]
#[serial]
async fn hosted_pnpm_config_dependency_names_the_projects_file_copy() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;

let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let copy_key = format!("file:forks/{NAME}");
write_file_copy_project(
root,
&copy_key,
&format!(" resolution: {{directory: forks/{NAME}, type: directory}}\n"),
&format!("file:../../forks/{NAME}"),
);
std::fs::remove_dir_all(root.join("packages").join("a")).unwrap();
std::fs::write(
root.join("pnpm-lock.yaml"),
format!(
"---
lockfileVersion: '9.0'

importers:

.:
configDependencies:
{NAME}:
specifier: {VERSION}
version: {VERSION}

packages:

{NAME}@{VERSION}:
resolution: {{integrity: {UPSTREAM_SHA512}}}

---
lockfileVersion: '9.0'

importers:
.: {{}}
packages/b:
dependencies:
{NAME}:
specifier: file:../../forks/{NAME}
version: {copy_key}

packages:
{NAME}@{copy_key}:
resolution: {{directory: forks/{NAME}, type: directory}}

snapshots:
{NAME}@{copy_key}: {{}}
"
),
)
.unwrap();

let (code, doc) = run_hosted_json(root, &server.uri());
assert_eq!(code, Some(0), "{doc}");
let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap();
assert!(
lock.contains(&format!("tarball: {HOSTED_URL}")),
"the config dependency is pinned: {lock}"
);
let text = doc.to_string();
assert!(
text.contains("redirect_copy_unpatched") && text.contains(&format!("`{NAME}@{copy_key}`")),
"the project's copy is named: {doc:#}"
);
}

/// The #935 workspace: member `a` depends on the registry release, member
/// `b` on the `file:` copy `copy_key` (spelled `spec` in its manifest).
fn write_file_copy_project(root: &Path, copy_key: &str, copy_entry: &str, spec: &str) {
Expand Down
Loading
Loading