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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions crates/socket-patch-cli/src/commands/scan/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,8 @@ pub(crate) async fn nested_project_skips(
let shell_dir = std::env::current_dir()
.ok()
.map(|d| std::fs::canonicalize(&d).unwrap_or(d));
let mut by_project: BTreeMap<String, Vec<String>> = BTreeMap::new();
// Project -> (whether it has a lockfile of its own, its purls).
let mut by_project: BTreeMap<String, (bool, Vec<String>)> = BTreeMap::new();
let mut skipped = HashSet::new();
for (purl, project) in candidates {
let project = std::fs::canonicalize(&project).unwrap_or(project);
Expand All @@ -678,7 +679,12 @@ pub(crate) async fn nested_project_skips(
.to_string_lossy()
.replace('\\', "/");
skipped.insert(PurlKey::new(&purl));
by_project.entry(shown).or_default().push(purl);
let locked = socket_patch_core::hosted::governing_root::has_own_npm_family_lock(&project);
by_project
.entry(shown)
.or_insert_with(|| (locked, Vec::new()))
.1
.push(purl);
}
// Spelled out per mode so the remedy-command check can parse each.
let rerun = |dir: &str| {
Expand All @@ -690,22 +696,35 @@ pub(crate) async fn nested_project_skips(
};
let warnings = by_project
.into_iter()
.map(|(dir, purls)| {
.map(|(dir, (locked, purls))| {
let (verb, left) = if purls.len() == 1 {
("is", "it is")
} else {
("are", "they are")
};
(
NESTED_PROJECT_SKIPPED.to_string(),
let word = shell_word(&dir);
// #1554: a project installed without a lockfile has none for a
// hosted or vendored run of its own to rewire either.
let detail = if locked {
format!(
"{} {verb} installed only by {dir}, a separate npm project with its own \
lockfile; this run rewires only this directory's lockfile, so {left} left \
alone; run {} to patch that project",
purls.join(", "),
rerun(&shell_word(&dir)),
),
)
rerun(&word),
)
} else {
format!(
"{} {verb} installed only by {dir}, a separate npm project installed \
without a lockfile; this run rewires only this directory's lockfile, so \
{left} left alone; to patch that project, create its lockfile \
(`npm install --package-lock` in {dir}) and run {}, or patch its \
node_modules in place with `socket-patch scan --mode agent {word}`",
purls.join(", "),
rerun(&word),
)
};
(NESTED_PROJECT_SKIPPED.to_string(), detail)
})
.collect();
(skipped, warnings)
Expand Down
62 changes: 62 additions & 0 deletions crates/socket-patch-cli/tests/scan/scan_nested_project_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,65 @@ async fn warning_names_the_path_from_the_shells_directory() {
"{detail}"
);
}

/// #1554: [`write_repo`], but `tools/x` was installed without a lock
/// (`package-lock=false` in its `.npmrc`, or `npm install
/// --no-package-lock`).
fn write_lockless_repo(root: &Path, npmrc: bool) {
write_repo(root);
std::fs::remove_file(root.join("tools/x/package-lock.json")).unwrap();
if npmrc {
std::fs::write(root.join("tools/x/.npmrc"), "package-lock=false\n").unwrap();
}
}

/// #1554: a lockless nested project is as independent as a locked one.
/// Every run ended `partialFailure`, exit 1, with
/// `vendor_lock_entry_not_found`, and the in-run VEX was dropped.
#[tokio::test]
async fn root_vendored_scan_skips_a_lockless_nested_project() {
for npmrc in [true, false] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_lockless_repo(root, npmrc);
let mock = MockServer::start().await;
mount_api(&mock).await;

for run in 1..=2 {
let (code, stdout, stderr) = run_scan(root, &mock.uri(), "vendored", &[]);
let v = parse_json_envelope(&stdout);
assert_eq!(
code, 0,
"npmrc={npmrc} run {run}: exit 0\nstdout={stdout}\nstderr={stderr}"
);
assert_eq!(v["status"], "success", "npmrc={npmrc} run {run}: {v:#}");
assert_nested_skipped(&v);
let detail = warning(&v, WARNING).unwrap()["detail"].as_str().unwrap();
assert!(detail.contains("without a lockfile"), "{detail}");
assert!(
detail.contains("npm install --package-lock")
&& detail.contains("scan --mode agent tools/x"),
"names a remedy that works without a lockfile: {detail}"
);
}
let root_lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap();
assert!(root_lock.contains(".socket/vendor"), "{root_lock}");
assert!(!root.join("tools/x/package-lock.json").exists());

let out = root.join("out.vex.json");
let (code, stdout, stderr) = run_scan(
root,
&mock.uri(),
"vendored",
&[
"--vex",
out.to_str().unwrap(),
"--vex-product",
"pkg:npm/root@1.0.0",
],
);
assert_eq!(code, 0, "npmrc={npmrc}: stdout={stdout}\nstderr={stderr}");
let doc = std::fs::read_to_string(&out).expect("the in-run VEX is written");
assert!(!doc.contains("left-pad"), "{doc}");
}
}
Loading
Loading