Skip to content
Merged
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
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: saber
Type: Package
Title: Code Analysis and Project Context for R
Version: 0.4.0
Version: 0.5.0
Authors@R: person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai",
comment = c(ORCID = "0009-0005-4248-604X"))
Expand Down
55 changes: 34 additions & 21 deletions R/agent_context.R
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,18 @@
#' saber::agent_context(agent = "claude", include_memory = TRUE)
#' }
#' @export
agent_context <- function(agent = NULL,
project_dir = getwd(),
agent_context <- function(agent = NULL, project_dir = getwd(),
workspace_dir = NULL,
memory_base = file.path(path.expand("~"),
".claude", "projects"),
claude_global_path = file.path(path.expand("~"),
".claude", "CLAUDE.md"),
include_memory = NULL,
include_project = NULL,
include_global = NULL,
include_soul = NULL,
memory_base = file.path(path.expand("~"), ".claude", "projects"),
claude_global_path = file.path(path.expand("~"), ".claude", "CLAUDE.md"),
include_memory = NULL, include_project = NULL,
include_global = NULL, include_soul = NULL,
max_memory_lines = 100L) {
agent_key <- if (is.null(agent)) NA_character_ else as.character(agent)[1L]
if (is.null(agent)) {
agent_key <- NA_character_
} else {
agent_key <- as.character(agent)[1L]
}

defaults <- agent_context_defaults(agent_key)
incl_mem <- include_memory %||% defaults$memory
Expand Down Expand Up @@ -119,18 +118,15 @@ agent_context <- function(agent = NULL,
#' @noRd
agent_context_defaults <- function(agent) {
if (is.na(agent)) {
return(list(memory = TRUE, project = TRUE,
global = TRUE, soul = TRUE))
return(list(memory = TRUE, project = TRUE, global = TRUE, soul = TRUE))
}
switch(agent,
claude = list(memory = FALSE, project = TRUE,
global = TRUE, soul = TRUE),
codex = list(memory = TRUE, project = TRUE,
global = TRUE, soul = TRUE),
codex = list(memory = TRUE, project = TRUE, global = TRUE, soul = TRUE),
llamar = list(memory = TRUE, project = TRUE,
global = TRUE, soul = TRUE),
list(memory = TRUE, project = TRUE,
global = TRUE, soul = TRUE)
list(memory = TRUE, project = TRUE, global = TRUE, soul = TRUE)
)
}

Expand Down Expand Up @@ -190,7 +186,11 @@ agent_context_project <- function(project_dir, agent, forced = FALSE) {
file_to_load <- NULL
if (forced || is.na(agent)) {
# User overrode the default, or unknown agent: prefer CLAUDE.md
file_to_load <- if (claude_exists) claude_path else agents_path
if (claude_exists) {
file_to_load <- claude_path
} else {
file_to_load <- agents_path
}
} else if (identical(agent, "claude")) {
# claude autoloads CLAUDE.md; only load AGENTS.md if it exists
# and is a distinct file
Expand All @@ -205,7 +205,11 @@ agent_context_project <- function(project_dir, agent, forced = FALSE) {
}
} else {
# llamar / unknown: prefer CLAUDE.md, fall back to AGENTS.md
file_to_load <- if (claude_exists) claude_path else agents_path
if (claude_exists) {
file_to_load <- claude_path
} else {
file_to_load <- agents_path
}
}

if (is.null(file_to_load)) {
Expand Down Expand Up @@ -242,15 +246,23 @@ agent_context_global <- function(workspace_dir, agent, claude_global,

file_to_load <- NULL
if (forced || is.na(agent)) {
file_to_load <- if (claude_exists) claude_global else user_path
if (claude_exists) {
file_to_load <- claude_global
} else {
file_to_load <- user_path
}
} else if (identical(agent, "claude")) {
# claude autoloads ~/.claude/CLAUDE.md; only load USER.md
if (user_exists && !same_file(claude_global, user_path)) {
file_to_load <- user_path
}
} else {
# codex / llamar / unknown: prefer claude global, fall back to USER.md
file_to_load <- if (claude_exists) claude_global else user_path
if (claude_exists) {
file_to_load <- claude_global
} else {
file_to_load <- user_path
}
}

if (is.null(file_to_load)) {
Expand Down Expand Up @@ -307,3 +319,4 @@ same_file <- function(a, b) {
#' Null-coalescing operator
#' @noRd
`%||%` <- function(a, b) if (is.null(a)) b else a

94 changes: 69 additions & 25 deletions R/blast.R
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,23 @@
#' project and all callers in downstream projects (projects whose DESCRIPTION
#' lists this one in Depends, Imports, or LinkingTo).
#'
#' With \code{include = c("r", "examples", "vignettes")} the search can be
#' extended to references in the target project's roxygen \verb{@examples}
#' blocks and vignette code chunks (Rmd, qmd, Rnw). Documentation scanning is
#' target-project only; it does not walk downstream projects' docs.
#'
#' @param fn Character. Function name to search for.
#' @param project Character. Project name (or path to project directory).
#' @param include Character vector. Any of \code{"r"} (R source, default),
#' \code{"examples"} (roxygen \verb{@examples} blocks in the target
#' project), and \code{"vignettes"} (code chunks in the target project's
#' vignettes).
#' @param scan_dir Directory to scan for downstream projects.
#' @param cache_dir Directory for symbol cache files.
#' @param exclude Character vector of directory basenames to skip when
#' scanning for downstream projects.
#' @return A data.frame with columns: caller, project, file, line.
#' @return A data.frame with columns: caller, project, file, line, source.
#' \code{source} is one of \code{"r"}, \code{"example"}, \code{"vignette"}.
#' @examples
#' # Create a minimal project
#' d <- file.path(tempdir(), "blastpkg")
Expand All @@ -24,10 +34,22 @@
#' # Find all callers of helper()
#' blast_radius("helper", project = d, scan_dir = tempdir(),
#' cache_dir = tempdir())
#'
#' # Include roxygen @examples and vignettes from the target project
#' blast_radius("helper", project = d, include = c("r", "examples", "vignettes"),
#' scan_dir = tempdir(), cache_dir = tempdir())
#' @export
blast_radius <- function(fn, project = NULL, scan_dir = path.expand("~"),
blast_radius <- function(fn, project = NULL, include = "r",
scan_dir = path.expand("~"),
cache_dir = file.path(tools::R_user_dir("saber", "cache"), "symbols"),
exclude = default_exclude()) {
allowed <- c("r", "examples", "vignettes")
bad <- setdiff(include, allowed)
if (length(bad) > 0L) {
stop("invalid 'include' value(s): ", paste(bad, collapse = ", "),
". Allowed: ", paste(allowed, collapse = ", "))
}

if (is.null(project)) {
project <- basename(getwd())
}
Expand All @@ -39,44 +61,66 @@ blast_radius <- function(fn, project = NULL, scan_dir = path.expand("~"),
}
project_name <- basename(normalizePath(project_dir, mustWork = FALSE))

results <- data.frame(caller = character(), project = character(),
file = character(), line = integer(),
stringsAsFactors = FALSE)
results <- empty_blast_results()

# 1. Internal callers from this project's symbol cache
if (dir.exists(project_dir)) {
if ("r" %in% include && dir.exists(project_dir)) {
syms <- symbols(project_dir, cache_dir = cache_dir)
internal <- syms$calls[syms$calls$callee == fn,, drop = FALSE]
if (nrow(internal) > 0L) {
results <- rbind(results,
data.frame(caller = internal$caller, project = project_name,
file = internal$file, line = internal$line,
data.frame(caller = internal$caller,
project = project_name,
file = internal$file,
line = internal$line,
source = "r",
stringsAsFactors = FALSE))
}
}

# 2. Find downstream projects via DESCRIPTION files
downstream <- find_downstream(project_name, scan_dir, exclude)
# 2. Downstream projects via DESCRIPTION files (R source only)
if ("r" %in% include) {
downstream <- find_downstream(project_name, scan_dir, exclude)
for (ds_name in downstream) {
ds_dir <- file.path(scan_dir, ds_name)
if (!dir.exists(file.path(ds_dir, "R"))) {
next
}

for (ds_name in downstream) {
ds_dir <- file.path(scan_dir, ds_name)
if (!dir.exists(file.path(ds_dir, "R"))) {
next
ds_syms <- symbols(ds_dir, cache_dir = cache_dir)
qualified <- paste0(project_name, "::", fn)
ds_callers <- ds_syms$calls[ds_syms$calls$callee == qualified |
ds_syms$calls$callee == fn,, drop = FALSE]
if (nrow(ds_callers) > 0L) {
results <- rbind(results,
data.frame(caller = ds_callers$caller,
project = ds_name,
file = ds_callers$file,
line = ds_callers$line,
source = "r",
stringsAsFactors = FALSE))
}
}
}

ds_syms <- symbols(ds_dir, cache_dir = cache_dir)
# Look for pkg::fn calls and bare fn calls
qualified <- paste0(project_name, "::", fn)
ds_callers <- ds_syms$calls[ds_syms$calls$callee == qualified |
ds_syms$calls$callee == fn,, drop = FALSE]
if (nrow(ds_callers) > 0L) {
results <- rbind(results,
data.frame(caller = ds_callers$caller, project = ds_name,
file = ds_callers$file, line = ds_callers$line,
stringsAsFactors = FALSE))
}
# 3. Target-project roxygen @examples
if ("examples" %in% include && dir.exists(project_dir)) {
results <- rbind(results, scan_examples(project_dir, fn))
}

# 4. Target-project vignettes
if ("vignettes" %in% include && dir.exists(project_dir)) {
results <- rbind(results, scan_vignettes(project_dir, fn))
}

results
}

#' Empty result frame shared by blast_radius scanners
#' @noRd
empty_blast_results <- function() {
data.frame(caller = character(), project = character(),
file = character(), line = integer(),
source = character(), stringsAsFactors = FALSE)
}

Loading