Skip to content

Commit 1a1294b

Browse files
Fix lockless nested npm projects in root scans (#1554) (#1591)
* Start fix for #1554 Assisted-by: Claude Code:claude-opus-5-5 * Skip lockless nested npm projects in root scans A root `scan --mode vendored` over a repo that holds an npm project installed without a lockfile (`package-lock=false` in its .npmrc, or `npm install --no-package-lock`) handed that project's packages to the root's lockfile. The root lock never lists them, so every run failed with vendor_lock_entry_not_found, exited 1 and dropped the in-run VEX, and the suggested `npm install` could not clear it. Such a directory, with its own package.json and node_modules and not listed by any workspace, is now treated like a nested project with its own lock: its packages get the nested_project_skipped warning naming the PATH to scan, and the root run succeeds. A member of a lockless inner workspace counts as part of that workspace's project. Fixes #1554 Assisted-by: Claude Code:claude-opus-5-5 * Give lockless nested projects a working remedy The nested_project_skipped warning for a project installed without a lockfile said it had its own lockfile and told the user to run a hosted or vendored scan of it, which is refused there for lack of a lock. It now says the project has no lockfile and offers two remedies that work: create the lock with `npm install --package-lock` and scan it, or patch its node_modules in place with agent mode. The lockless check also stops at the scan root, so a workspace file above the directory being scanned no longer decides what counts as nested below it; a pnpm workspace root no longer claims a directory it does not list; and Rush projects stay the Rush root's. Assisted-by: Claude Code:claude-opus-5-5 * Read brace globs when finding lockless projects A pnpm-workspace.yaml whose packages: globs use braces or classes (`{apps,libs}/*`), or that could not be read, made every lockless project below it count as a workspace member, so a root vendored scan still failed on it. Such globs are now matched with the shared workspace glob grammar, and an unreadable file lists nothing, since pnpm cannot install from it either. Assisted-by: Claude Code:claude-opus-5-5 * Honour pnpm exclusions in unmodeled globs The brace-glob fallback for pnpm-workspace.yaml matched a directory if any package manager's reading did, so a `!` exclusion, a `test` or `tests` directory, or a dot directory could still count as a member, and a lockless project there failed the root vendored scan. The fallback now reads each glob pnpm's way. Assisted-by: Claude Code:claude-opus-5-5 * Strip one `!` from a pnpm exclusion glob The brace-glob fallback stripped every leading `!`, so `!!apps/x` excluded apps/x, while pnpm's own matcher strips one `!` and treats the rest as a literal that matches nothing. It now strips one. Assisted-by: Claude Code:claude-opus-5-5 * Pin setup-uv to 0.13.1 for the Pipenv legs The nightly e2e_vex_build pipenv legs (ubuntu and macOS) went red on 2026-10-20: `uv pip install -r pylock.toml` refuses the pylock Pipenv 2026.8.0 writes with "The target environment is not supported by the lockfile". Pipenv emits `environments = []`, and uv 0.13.2 (released 2026-10-19) started treating an empty list as "no environment supported". setup-uv installs the latest uv when no version is given, so the oracle drifted under an unchanged main. Pin uv to 0.13.1, the last version these legs passed on, in ci.yml and pipenv-compatibility.yml, as the other workflows already pin their uv. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4ww7ytRoApPcBHiJN4bm2 (cherry picked from commit a7c9207) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ff86f0 commit 1a1294b

4 files changed

Lines changed: 358 additions & 14 deletions

File tree

‎crates/socket-patch-cli/src/commands/scan/discovery.rs‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,8 @@ pub(crate) async fn nested_project_skips(
666666
let shell_dir = std::env::current_dir()
667667
.ok()
668668
.map(|d| std::fs::canonicalize(&d).unwrap_or(d));
669-
let mut by_project: BTreeMap<String, Vec<String>> = BTreeMap::new();
669+
// Project -> (whether it has a lockfile of its own, its purls).
670+
let mut by_project: BTreeMap<String, (bool, Vec<String>)> = BTreeMap::new();
670671
let mut skipped = HashSet::new();
671672
for (purl, project) in candidates {
672673
let project = std::fs::canonicalize(&project).unwrap_or(project);
@@ -678,7 +679,12 @@ pub(crate) async fn nested_project_skips(
678679
.to_string_lossy()
679680
.replace('\\', "/");
680681
skipped.insert(PurlKey::new(&purl));
681-
by_project.entry(shown).or_default().push(purl);
682+
let locked = socket_patch_core::hosted::governing_root::has_own_npm_family_lock(&project);
683+
by_project
684+
.entry(shown)
685+
.or_insert_with(|| (locked, Vec::new()))
686+
.1
687+
.push(purl);
682688
}
683689
// Spelled out per mode so the remedy-command check can parse each.
684690
let rerun = |dir: &str| {
@@ -690,22 +696,35 @@ pub(crate) async fn nested_project_skips(
690696
};
691697
let warnings = by_project
692698
.into_iter()
693-
.map(|(dir, purls)| {
699+
.map(|(dir, (locked, purls))| {
694700
let (verb, left) = if purls.len() == 1 {
695701
("is", "it is")
696702
} else {
697703
("are", "they are")
698704
};
699-
(
700-
NESTED_PROJECT_SKIPPED.to_string(),
705+
let word = shell_word(&dir);
706+
// #1554: a project installed without a lockfile has none for a
707+
// hosted or vendored run of its own to rewire either.
708+
let detail = if locked {
701709
format!(
702710
"{} {verb} installed only by {dir}, a separate npm project with its own \
703711
lockfile; this run rewires only this directory's lockfile, so {left} left \
704712
alone; run {} to patch that project",
705713
purls.join(", "),
706-
rerun(&shell_word(&dir)),
707-
),
708-
)
714+
rerun(&word),
715+
)
716+
} else {
717+
format!(
718+
"{} {verb} installed only by {dir}, a separate npm project installed \
719+
without a lockfile; this run rewires only this directory's lockfile, so \
720+
{left} left alone; to patch that project, create its lockfile \
721+
(`npm install --package-lock` in {dir}) and run {}, or patch its \
722+
node_modules in place with `socket-patch scan --mode agent {word}`",
723+
purls.join(", "),
724+
rerun(&word),
725+
)
726+
};
727+
(NESTED_PROJECT_SKIPPED.to_string(), detail)
709728
})
710729
.collect();
711730
(skipped, warnings)

‎crates/socket-patch-cli/tests/scan/scan_nested_project_e2e.rs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,65 @@ async fn warning_names_the_path_from_the_shells_directory() {
425425
"{detail}"
426426
);
427427
}
428+
429+
/// #1554: [`write_repo`], but `tools/x` was installed without a lock
430+
/// (`package-lock=false` in its `.npmrc`, or `npm install
431+
/// --no-package-lock`).
432+
fn write_lockless_repo(root: &Path, npmrc: bool) {
433+
write_repo(root);
434+
std::fs::remove_file(root.join("tools/x/package-lock.json")).unwrap();
435+
if npmrc {
436+
std::fs::write(root.join("tools/x/.npmrc"), "package-lock=false\n").unwrap();
437+
}
438+
}
439+
440+
/// #1554: a lockless nested project is as independent as a locked one.
441+
/// Every run ended `partialFailure`, exit 1, with
442+
/// `vendor_lock_entry_not_found`, and the in-run VEX was dropped.
443+
#[tokio::test]
444+
async fn root_vendored_scan_skips_a_lockless_nested_project() {
445+
for npmrc in [true, false] {
446+
let tmp = tempfile::tempdir().unwrap();
447+
let root = tmp.path();
448+
write_lockless_repo(root, npmrc);
449+
let mock = MockServer::start().await;
450+
mount_api(&mock).await;
451+
452+
for run in 1..=2 {
453+
let (code, stdout, stderr) = run_scan(root, &mock.uri(), "vendored", &[]);
454+
let v = parse_json_envelope(&stdout);
455+
assert_eq!(
456+
code, 0,
457+
"npmrc={npmrc} run {run}: exit 0\nstdout={stdout}\nstderr={stderr}"
458+
);
459+
assert_eq!(v["status"], "success", "npmrc={npmrc} run {run}: {v:#}");
460+
assert_nested_skipped(&v);
461+
let detail = warning(&v, WARNING).unwrap()["detail"].as_str().unwrap();
462+
assert!(detail.contains("without a lockfile"), "{detail}");
463+
assert!(
464+
detail.contains("npm install --package-lock")
465+
&& detail.contains("scan --mode agent tools/x"),
466+
"names a remedy that works without a lockfile: {detail}"
467+
);
468+
}
469+
let root_lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap();
470+
assert!(root_lock.contains(".socket/vendor"), "{root_lock}");
471+
assert!(!root.join("tools/x/package-lock.json").exists());
472+
473+
let out = root.join("out.vex.json");
474+
let (code, stdout, stderr) = run_scan(
475+
root,
476+
&mock.uri(),
477+
"vendored",
478+
&[
479+
"--vex",
480+
out.to_str().unwrap(),
481+
"--vex-product",
482+
"pkg:npm/root@1.0.0",
483+
],
484+
);
485+
assert_eq!(code, 0, "npmrc={npmrc}: stdout={stdout}\nstderr={stderr}");
486+
let doc = std::fs::read_to_string(&out).expect("the in-run VEX is written");
487+
assert!(!doc.contains("left-pad"), "{doc}");
488+
}
489+
}

0 commit comments

Comments
 (0)