Skip to content

Commit 724a08c

Browse files
nbafrankclaude
andcommitted
v0.3.5 bundle review: fix three cross-cutting issues before tag
The post-bundle holistic review caught three integration issues each commit's individual review didn't surface: 1. **#85 wipe-confirm unreachable when calling R differs from pin (sync.rs).** The #70 calling-R bail fired before #85's interactive prompt, so users in the most common pinned-R-mismatch scenario got the opaque "Refusing to install" error instead of the wipe-confirm #85 was meant to deliver. Restructure the two guards into a single decision tree: - wipe needed + calling mismatched → combined warn + confirm; on accept, still bail with the calling-R explanation (project library left intact, accepted wipe runs on next sync from the matching R) - wipe needed only → existing #85 confirm path - calling mismatched only (no wipe) → existing #70 bail The user always sees ONE clear story instead of two competing guards. 2. **`uvr scan` missed roxygen2 @import / @importFrom (scan.rs).** Package source trees often declare deps exclusively via these tags, never calling library() in their own .R files. Without this regex, `uvr scan` reported "no references found" for those packages — actively misleading. Add a third regex; new test covers @importFrom dplyr filter mutate (extracts dplyr, NOT filter/mutate) and @import ggplot2. 3. **`.Rprofile` r-version warn collided with library messages (init.rs).** When .r-version != active R, the snippet emitted both "R 4.5 active but pin is 4.6" and "Run uvr::sync() to install" — users would chase the second (running sync builds packages for the wrong R, making the situation worse). Now the library-status messages are gated on version_ok; mismatched sessions see only the version warning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 05bbe34 commit 724a08c

3 files changed

Lines changed: 136 additions & 53 deletions

File tree

crates/uvr/src/commands/init.rs

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,14 @@ local({
135135
# that warning and could be running scripts under the wrong R for
136136
# ages. Surface it at session startup so the mismatch is visible
137137
# immediately.
138+
#
139+
# Post-bundle review: when the version is mismatched, suppress the
140+
# library-status messages below — otherwise the user sees both
141+
# "R 4.5 active but pin is 4.6" and "Run uvr::sync() to install"
142+
# and chases the wrong fix (running sync would build packages for
143+
# the wrong R). Show the version warning alone; library messages
144+
# become useful again once they restart R against the pin.
145+
version_ok <- TRUE
138146
if (file.exists(rver_file)) {
139147
pinned <- tryCatch(trimws(readLines(rver_file, warn = FALSE)[1]),
140148
error = function(e) "")
@@ -145,34 +153,37 @@ local({
145153
if (pinned_minor != active_minor) {
146154
message("uvr: R ", active, " active but .r-version pins ", pinned,
147155
". Restart R against the pinned version, or run uvr::r_pin() to change the pin.")
156+
version_ok <- FALSE
148157
}
149158
}
150159
}
151-
if (dir.exists(lib)) {
152-
# #17: `.libPaths(lib)` with a single new path causes R to drop the
153-
# user's site library (e.g. ~/R/x86_64-pc-linux-gnu-library/4.4) —
154-
# only the new path and the system library survive. Prepending via
155-
# `unique(c(lib, .libPaths()))` keeps the project lib first (so it
156-
# wins resolution) while preserving anything the user already had.
157-
.libPaths(unique(c(lib, .libPaths())))
158-
n_locked <- count_locked(lock)
159-
installed <- list.dirs(lib, recursive = FALSE, full.names = FALSE)
160-
n_installed <- length(setdiff(installed, "uvr"))
161-
if (n_locked > 0 && n_installed < n_locked) {
162-
message("uvr: ", n_locked - n_installed, " of ", n_locked,
163-
" package(s) not installed. Run uvr::sync() to install.")
164-
} else if (n_locked > 0) {
165-
message("uvr: library linked (", n_installed, " packages)")
160+
if (version_ok) {
161+
if (dir.exists(lib)) {
162+
# #17: `.libPaths(lib)` with a single new path causes R to drop the
163+
# user's site library (e.g. ~/R/x86_64-pc-linux-gnu-library/4.4) —
164+
# only the new path and the system library survive. Prepending via
165+
# `unique(c(lib, .libPaths()))` keeps the project lib first (so it
166+
# wins resolution) while preserving anything the user already had.
167+
.libPaths(unique(c(lib, .libPaths())))
168+
n_locked <- count_locked(lock)
169+
installed <- list.dirs(lib, recursive = FALSE, full.names = FALSE)
170+
n_installed <- length(setdiff(installed, "uvr"))
171+
if (n_locked > 0 && n_installed < n_locked) {
172+
message("uvr: ", n_locked - n_installed, " of ", n_locked,
173+
" package(s) not installed. Run uvr::sync() to install.")
174+
} else if (n_locked > 0) {
175+
message("uvr: library linked (", n_installed, " packages)")
176+
} else if (file.exists(lock)) {
177+
message("uvr: library active, but uvr.lock is empty. Run uvr::lock() to populate it.")
178+
} else {
179+
message("uvr: library active, but no uvr.lock found. Run uvr::lock() to create one.")
180+
}
166181
} else if (file.exists(lock)) {
167-
message("uvr: library active, but uvr.lock is empty. Run uvr::lock() to populate it.")
168-
} else {
169-
message("uvr: library active, but no uvr.lock found. Run uvr::lock() to create one.")
182+
# #59: .uvr/library/ doesn't exist yet but the lockfile does — fresh
183+
# checkout, never synced. Tell the user.
184+
n_locked <- count_locked(lock)
185+
message("uvr: 0 of ", n_locked, " package(s) installed. Run uvr::sync() to install.")
170186
}
171-
} else if (file.exists(lock)) {
172-
# #59: .uvr/library/ doesn't exist yet but the lockfile does — fresh
173-
# checkout, never synced. Tell the user.
174-
n_locked <- count_locked(lock)
175-
message("uvr: 0 of ", n_locked, " package(s) installed. Run uvr::sync() to install.")
176187
}
177188
})
178189
# <<< uvr <<<

crates/uvr/src/commands/scan.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ fn has_scannable_extension(path: &Path) -> bool {
151151
)
152152
}
153153

154-
/// Compiled regexes for the four R-package name patterns.
154+
/// Compiled regexes for the R-package name patterns we recognise.
155155
///
156156
/// Patterns are intentionally conservative — we want few false positives
157157
/// even at the cost of a few false negatives. Comment lines that contain
@@ -161,6 +161,7 @@ fn has_scannable_extension(path: &Path) -> bool {
161161
struct PackageDetector {
162162
library_or_require: Regex,
163163
namespace_op: Regex,
164+
roxygen_import: Regex,
164165
}
165166

166167
impl PackageDetector {
@@ -179,9 +180,18 @@ impl PackageDetector {
179180
let namespace_op =
180181
Regex::new(r"\b([A-Za-z][A-Za-z0-9._]*):{2,3}[A-Za-z._]").expect(":: regex compiles");
181182

183+
// roxygen2 `#' @import pkg` and `#' @importFrom pkg fn1 fn2`. Real
184+
// package source trees often declare deps exclusively via these
185+
// tags — without this, `uvr scan` returns "no references found"
186+
// for any package that doesn't `library()` its own deps (post-
187+
// bundle review).
188+
let roxygen_import = Regex::new(r"#'\s*@import(?:From)?\s+([A-Za-z][A-Za-z0-9._]*)")
189+
.expect("roxygen import regex compiles");
190+
182191
Self {
183192
library_or_require,
184193
namespace_op,
194+
roxygen_import,
185195
}
186196
}
187197

@@ -197,6 +207,11 @@ impl PackageDetector {
197207
found.insert(name.as_str().to_string());
198208
}
199209
}
210+
for cap in self.roxygen_import.captures_iter(content) {
211+
if let Some(name) = cap.get(1) {
212+
found.insert(name.as_str().to_string());
213+
}
214+
}
200215
found
201216
}
202217
}
@@ -250,6 +265,31 @@ mixed <- dplyr::filter(df) |> tidyr::pivot_longer()
250265
assert!(found.is_empty(), "got {found:?}");
251266
}
252267

268+
#[test]
269+
fn extract_roxygen_imports() {
270+
// Package source trees often declare deps purely via roxygen2
271+
// tags, never calling library() in their own .R files. Without
272+
// this regex, `uvr scan` reports nothing for those packages.
273+
let detector = PackageDetector::new();
274+
let src = "\
275+
#' @importFrom dplyr filter mutate
276+
#' @importFrom rlang .data
277+
#' @import ggplot2
278+
#' @importFrom stats predict
279+
my_fn <- function() NULL
280+
";
281+
let found = detector.extract(src);
282+
assert!(found.contains("dplyr"), "got {found:?}");
283+
assert!(found.contains("rlang"), "got {found:?}");
284+
assert!(found.contains("ggplot2"), "got {found:?}");
285+
assert!(found.contains("stats"), "got {found:?}");
286+
// The function names after `@importFrom <pkg>` must NOT be
287+
// captured as packages.
288+
assert!(!found.contains("filter"), "got {found:?}");
289+
assert!(!found.contains("mutate"), "got {found:?}");
290+
assert!(!found.contains("predict"), "got {found:?}");
291+
}
292+
253293
#[test]
254294
fn extension_matching() {
255295
assert!(has_scannable_extension(Path::new("script.R")));

crates/uvr/src/commands/sync.rs

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -141,40 +141,56 @@ pub async fn install_from_lockfile(
141141
let current_minor = r_minor(current_r);
142142
let locked_minor = r_minor(&lockfile.r.version);
143143
let sentinel_minor = read_library_r_sentinel(&library);
144-
145-
// #70 guard — must fire BEFORE the wipe check, not inside it. A library
146-
// already at `current_minor` (sentinel matches) skips the wipe but still
147-
// installs new packages under the resolved R. From a calling R session
148-
// on a different minor, those packages are unloadable in the live
149-
// session. Bail unconditionally when the calling R differs from the
150-
// R uvr would install for. Terminal-invoked uvr (R_HOME unset) returns
151-
// None from calling_r_minor() and proceeds normally.
152-
if let Some(calling_minor) = calling_r_minor() {
153-
if calling_minor != current_minor {
154-
anyhow::bail!(
155-
"Refusing to install: uvr is running inside R {calling} but the project pin/lockfile \
156-
resolves to R {target}. Packages built for R {target} would not load in this {calling} \
157-
session. Restart R against {target} (e.g. point your IDE at \
158-
~/.uvr/r-versions/{target_full}/bin/R), or update the pin to match this session, \
159-
then re-run `uvr sync`.",
160-
calling = calling_minor,
161-
target = current_minor,
162-
target_full = current_r,
163-
);
164-
}
165-
}
144+
let calling_minor_opt = calling_r_minor();
166145

167146
let lockfile_mismatch =
168147
looks_like_version(&lockfile.r.version) && current_minor != locked_minor;
169148
let sentinel_mismatch = sentinel_minor.as_ref().is_some_and(|m| m != &current_minor);
170-
171-
if lockfile_mismatch || sentinel_mismatch {
149+
let wipe_needed = lockfile_mismatch || sentinel_mismatch;
150+
let calling_mismatch = calling_minor_opt
151+
.as_ref()
152+
.is_some_and(|c| c != &current_minor);
153+
154+
// Two destructive risks to surface:
155+
// - sentinel/lockfile mismatch → about to wipe the project library
156+
// - calling R minor != install target → rebuilt library would not
157+
// load in the calling R session (#70).
158+
// Pre-#85, the calling-R bail fired before the wipe-confirm prompt,
159+
// making the prompt unreachable in the most common pinned-R-mismatch
160+
// case (post-bundle review). Combine both signals into a single
161+
// message + confirm path so the user sees one clear story.
162+
if wipe_needed && calling_mismatch {
163+
let from = sentinel_minor.clone().unwrap_or(locked_minor.clone());
164+
let calling = calling_minor_opt.as_deref().unwrap_or("?");
165+
ui::warn(format!(
166+
"R version changed ({} {} {}) — about to wipe project library and reinstall, \
167+
but uvr is running inside R {calling} so the rebuilt library would NOT load in this session",
168+
palette::dim(&from),
169+
palette::dim(ui::glyph::arrow()),
170+
palette::info(&current_minor),
171+
));
172+
if !confirm_library_wipe(&library)? {
173+
anyhow::bail!(
174+
"Aborted: not wiping project library. Restart R against the install target \
175+
(e.g. point your IDE at ~/.uvr/r-versions/{current_r}/bin/R) and re-run \
176+
`uvr sync`, or update the pin so it matches this {calling} session."
177+
);
178+
}
179+
// User explicitly accepted — proceed with wipe + reinstall, but
180+
// bail with the calling-R explanation before installing into a
181+
// mismatched session (the rebuilt library still won't load here).
182+
anyhow::bail!(
183+
"Refusing to install: uvr is running inside R {calling} but the lockfile resolves \
184+
to R {target}. Restart R against {target} (e.g. point your IDE at \
185+
~/.uvr/r-versions/{target_full}/bin/R), or update the pin to match this session, \
186+
then re-run `uvr sync`. (Project library left intact — accepted wipe will run \
187+
on the next sync from the matching R.)",
188+
calling = calling,
189+
target = current_minor,
190+
target_full = current_r,
191+
);
192+
} else if wipe_needed {
172193
let from = sentinel_minor.unwrap_or(locked_minor);
173-
// #85: clarify it's the *project* library being wiped (not the
174-
// global cache); confirm before destructive actions when stdin
175-
// is a TTY so a misdetected R version doesn't silently nuke
176-
// hand-built packages (B-Nilson's case where Matrix and s2 had
177-
// been custom-built per #52).
178194
ui::warn(format!(
179195
"R version changed ({} {} {}) — about to wipe project library and reinstall",
180196
palette::dim(&from),
@@ -193,6 +209,22 @@ pub async fn install_from_lockfile(
193209
std::fs::remove_dir_all(&library).context("Failed to wipe project library")?;
194210
}
195211
std::fs::create_dir_all(&library).context("Failed to recreate library directory")?;
212+
} else if calling_mismatch {
213+
// #70 guard — pure-bail path when no wipe is needed (library is
214+
// empty or sentinel matches current_minor). The bail is correct:
215+
// installing new packages under R `current` would still produce
216+
// artefacts unloadable in the calling `calling` session.
217+
let calling = calling_minor_opt.as_deref().unwrap_or("?");
218+
anyhow::bail!(
219+
"Refusing to install: uvr is running inside R {calling} but the project pin/lockfile \
220+
resolves to R {target}. Packages built for R {target} would not load in this {calling} \
221+
session. Restart R against {target} (e.g. point your IDE at \
222+
~/.uvr/r-versions/{target_full}/bin/R), or update the pin to match this session, \
223+
then re-run `uvr sync`.",
224+
calling = calling,
225+
target = current_minor,
226+
target_full = current_r,
227+
);
196228
}
197229
}
198230

0 commit comments

Comments
 (0)