mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-05 14:07:11 +08:00
fix: bound parent memory discovery
Generated with https://github.com/Yeachan-Heo/gajae-code Co-authored-by: Gajae Code <dev@gajae-code.com>
This commit is contained in:
@@ -290,12 +290,7 @@ fn discover_instruction_files(
|
||||
cwd: &Path,
|
||||
rules_import: &RulesImportConfig,
|
||||
) -> std::io::Result<Vec<ContextFile>> {
|
||||
let mut directories = Vec::new();
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
directories.push(dir.to_path_buf());
|
||||
cursor = dir.parent();
|
||||
}
|
||||
let mut directories = instruction_discovery_dirs(cwd);
|
||||
directories.reverse();
|
||||
|
||||
let mut files = Vec::new();
|
||||
@@ -318,6 +313,32 @@ fn discover_instruction_files(
|
||||
Ok(dedupe_instruction_files(files))
|
||||
}
|
||||
|
||||
fn instruction_discovery_dirs(cwd: &Path) -> Vec<PathBuf> {
|
||||
let boundary = nearest_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
|
||||
let mut directories = Vec::new();
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
directories.push(dir.to_path_buf());
|
||||
if dir == boundary {
|
||||
break;
|
||||
}
|
||||
cursor = dir.parent();
|
||||
}
|
||||
directories
|
||||
}
|
||||
|
||||
fn nearest_git_root(cwd: &Path) -> Option<PathBuf> {
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
let git_marker = dir.join(".git");
|
||||
if git_marker.is_dir() || git_marker.is_file() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
cursor = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
||||
if path.is_dir() {
|
||||
return Ok(());
|
||||
@@ -812,6 +833,7 @@ mod tests {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("apps").join("api");
|
||||
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
|
||||
fs::create_dir(root.join(".git")).expect("git boundary");
|
||||
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
|
||||
fs::write(root.join("CLAUDE.local.md"), "local instructions")
|
||||
.expect("write local instructions");
|
||||
@@ -926,6 +948,7 @@ mod tests {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("apps").join("api");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
fs::create_dir(root.join(".git")).expect("git boundary");
|
||||
fs::write(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
|
||||
fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
|
||||
|
||||
@@ -938,6 +961,50 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_stops_at_git_root_boundary_439() {
|
||||
let root = temp_dir();
|
||||
let repo = root.join("repo");
|
||||
let nested = repo.join("subproj").join("deep").join("nest");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
fs::create_dir(repo.join(".git")).expect("git boundary");
|
||||
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
|
||||
fs::write(repo.join("CLAUDE.md"), "REPO_CLAUDE").expect("write repo");
|
||||
fs::write(repo.join("subproj").join("CLAUDE.md"), "CHILD_CLAUDE").expect("write child");
|
||||
fs::write(
|
||||
repo.join("subproj").join("deep").join("CLAUDE.md"),
|
||||
"DEEP_CLAUDE",
|
||||
)
|
||||
.expect("write deep");
|
||||
|
||||
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(!rendered.contains("PARENT_CLAUDE"));
|
||||
assert!(rendered.contains("REPO_CLAUDE"));
|
||||
assert!(rendered.contains("CHILD_CLAUDE"));
|
||||
assert!(rendered.contains("DEEP_CLAUDE"));
|
||||
assert_eq!(context.instruction_files.len(), 3);
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_without_git_root_stays_cwd_local_439() {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("scratch");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
|
||||
fs::write(nested.join("CLAUDE.md"), "SCRATCH_CLAUDE").expect("write scratch");
|
||||
|
||||
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(!rendered.contains("PARENT_CLAUDE"));
|
||||
assert!(rendered.contains("SCRATCH_CLAUDE"));
|
||||
assert_eq!(context.instruction_files.len(), 1);
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_large_instruction_content_for_rendering() {
|
||||
let rendered = render_instruction_content(&"x".repeat(4500));
|
||||
|
||||
@@ -3493,6 +3493,11 @@ fn render_doctor_report(
|
||||
config.as_ref().ok(),
|
||||
config.as_ref().err().map(ToString::to_string).as_deref(),
|
||||
);
|
||||
let memory_files = memory_file_summaries_for(
|
||||
&cwd,
|
||||
project_root.as_deref(),
|
||||
&project_context.instruction_files,
|
||||
);
|
||||
let context = StatusContext {
|
||||
cwd: cwd.clone(),
|
||||
session_path: None,
|
||||
@@ -3502,10 +3507,11 @@ fn render_doctor_report(
|
||||
.map_or(0, |runtime_config| runtime_config.loaded_entries().len()),
|
||||
discovered_config_files: discovered_config.len(),
|
||||
memory_file_count: project_context.instruction_files.len(),
|
||||
memory_files: memory_file_summaries(&project_context.instruction_files),
|
||||
memory_files: memory_files.clone(),
|
||||
unloaded_memory_files: unloaded_memory_candidates(
|
||||
&cwd,
|
||||
&memory_file_summaries(&project_context.instruction_files),
|
||||
project_root.as_deref(),
|
||||
&memory_files,
|
||||
),
|
||||
project_root,
|
||||
git_branch,
|
||||
@@ -4048,6 +4054,7 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
|
||||
|
||||
fn check_memory_health(context: &StatusContext) -> DiagnosticCheck {
|
||||
let has_unloaded = !context.unloaded_memory_files.is_empty();
|
||||
let has_outside_project = context.memory_files.iter().any(|file| file.outside_project);
|
||||
let mut details = vec![format!("Loaded files {}", context.memory_file_count)];
|
||||
details.extend(context.memory_files.iter().map(|file| {
|
||||
format!(
|
||||
@@ -4064,18 +4071,22 @@ fn check_memory_health(context: &StatusContext) -> DiagnosticCheck {
|
||||
|
||||
DiagnosticCheck::new(
|
||||
"Memory",
|
||||
if has_unloaded {
|
||||
if has_unloaded || has_outside_project {
|
||||
DiagnosticLevel::Warn
|
||||
} else {
|
||||
DiagnosticLevel::Ok
|
||||
},
|
||||
if has_unloaded {
|
||||
if has_outside_project {
|
||||
"memory files outside the current git project are loaded".to_string()
|
||||
} else if has_unloaded {
|
||||
"some workspace memory files exist but were not loaded".to_string()
|
||||
} else {
|
||||
format!("{} workspace memory files loaded", context.memory_file_count)
|
||||
},
|
||||
)
|
||||
.with_hint(if has_unloaded {
|
||||
.with_hint(if has_outside_project {
|
||||
"Inspect workspace.memory_files in `claw status --output-format json`; move unintended ancestor instructions inside the git project or run from the intended workspace root."
|
||||
} else if has_unloaded {
|
||||
"Move instructions into CLAUDE.md, CLAW.md, or AGENTS.md within the current workspace ancestry, or inspect workspace.memory_files in `claw status --output-format json`."
|
||||
} else {
|
||||
""
|
||||
@@ -4499,7 +4510,13 @@ fn print_system_prompt(
|
||||
"unknown",
|
||||
model_family_identity_for(model),
|
||||
)?;
|
||||
let memory_files = memory_file_summaries(&project_context.instruction_files);
|
||||
let (project_root, _) =
|
||||
parse_git_status_metadata_for(&project_context.cwd, project_context.git_status.as_deref());
|
||||
let memory_files = memory_file_summaries_for(
|
||||
&project_context.cwd,
|
||||
project_root.as_deref(),
|
||||
&project_context.instruction_files,
|
||||
);
|
||||
let message = sections.join(
|
||||
"
|
||||
|
||||
@@ -4759,6 +4776,9 @@ struct MemoryFileSummary {
|
||||
path: String,
|
||||
source: String,
|
||||
chars: usize,
|
||||
origin: String,
|
||||
scope_path: String,
|
||||
outside_project: bool,
|
||||
contributes: bool,
|
||||
}
|
||||
|
||||
@@ -4768,34 +4788,103 @@ impl MemoryFileSummary {
|
||||
"path": self.path,
|
||||
"source": self.source,
|
||||
"chars": self.chars,
|
||||
"origin": self.origin,
|
||||
"scope_path": self.scope_path,
|
||||
"outside_project": self.outside_project,
|
||||
"contributes": self.contributes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_file_summaries(files: &[ContextFile]) -> Vec<MemoryFileSummary> {
|
||||
fn memory_file_summaries_for(
|
||||
cwd: &Path,
|
||||
project_root: Option<&Path>,
|
||||
files: &[ContextFile],
|
||||
) -> Vec<MemoryFileSummary> {
|
||||
let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
|
||||
let project_root =
|
||||
project_root.map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()));
|
||||
files
|
||||
.iter()
|
||||
.map(|file| MemoryFileSummary {
|
||||
path: file.path.display().to_string(),
|
||||
source: file.source().to_string(),
|
||||
chars: file.char_count(),
|
||||
contributes: true,
|
||||
.map(|file| {
|
||||
let path = file
|
||||
.path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| file.path.clone());
|
||||
let scope_path = memory_scope_path(&path);
|
||||
let origin = memory_origin(&cwd, project_root.as_deref(), &scope_path);
|
||||
let outside_project = project_root
|
||||
.as_ref()
|
||||
.is_some_and(|root| !path.starts_with(root));
|
||||
MemoryFileSummary {
|
||||
path: file.path.display().to_string(),
|
||||
source: file.source().to_string(),
|
||||
origin: origin.to_string(),
|
||||
scope_path: scope_path.display().to_string(),
|
||||
chars: file.char_count(),
|
||||
outside_project,
|
||||
contributes: true,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn memory_scope_path(path: &Path) -> PathBuf {
|
||||
let Some(parent) = path.parent() else {
|
||||
return PathBuf::from(".");
|
||||
};
|
||||
let parent_name = parent.file_name().and_then(|name| name.to_str());
|
||||
if matches!(parent_name, Some(".claw" | ".claude")) {
|
||||
return parent.parent().unwrap_or(parent).to_path_buf();
|
||||
}
|
||||
if matches!(parent_name, Some("rules" | "rules.local")) {
|
||||
if let Some(grandparent) = parent.parent() {
|
||||
if grandparent.file_name().and_then(|name| name.to_str()) == Some(".claw") {
|
||||
return grandparent.parent().unwrap_or(grandparent).to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
parent.to_path_buf()
|
||||
}
|
||||
|
||||
fn memory_origin(cwd: &Path, project_root: Option<&Path>, scope_path: &Path) -> &'static str {
|
||||
if scope_path == cwd {
|
||||
return "workspace";
|
||||
}
|
||||
if project_root.is_some_and(|root| !scope_path.starts_with(root)) {
|
||||
return "outside_project";
|
||||
}
|
||||
if let Some(home) = env::var_os("HOME").map(PathBuf::from) {
|
||||
let home = home.canonicalize().unwrap_or(home);
|
||||
if scope_path == home {
|
||||
return "home";
|
||||
}
|
||||
}
|
||||
if cwd.parent().is_some_and(|parent| parent == scope_path) {
|
||||
return "parent_dir";
|
||||
}
|
||||
if cwd.starts_with(scope_path) {
|
||||
return "ancestor";
|
||||
}
|
||||
"workspace"
|
||||
}
|
||||
|
||||
fn memory_files_json(files: &[MemoryFileSummary]) -> Vec<serde_json::Value> {
|
||||
files.iter().map(MemoryFileSummary::json_value).collect()
|
||||
}
|
||||
|
||||
fn unloaded_memory_candidates(cwd: &Path, files: &[MemoryFileSummary]) -> Vec<String> {
|
||||
fn unloaded_memory_candidates(
|
||||
cwd: &Path,
|
||||
project_root: Option<&Path>,
|
||||
files: &[MemoryFileSummary],
|
||||
) -> Vec<String> {
|
||||
let mut loaded = files
|
||||
.iter()
|
||||
.map(|file| PathBuf::from(&file.path))
|
||||
.collect::<Vec<_>>();
|
||||
loaded.sort();
|
||||
|
||||
let boundary = project_root.unwrap_or(cwd);
|
||||
let mut missing = Vec::new();
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
@@ -4805,6 +4894,9 @@ fn unloaded_memory_candidates(cwd: &Path, files: &[MemoryFileSummary]) -> Vec<St
|
||||
missing.push(candidate.display().to_string());
|
||||
}
|
||||
}
|
||||
if dir == boundary {
|
||||
break;
|
||||
}
|
||||
cursor = dir.parent();
|
||||
}
|
||||
missing.sort();
|
||||
@@ -8888,16 +8980,22 @@ fn status_context(
|
||||
runtime_config.as_ref().ok(),
|
||||
config_load_error.as_deref(),
|
||||
);
|
||||
let memory_files = memory_file_summaries_for(
|
||||
&cwd,
|
||||
project_root.as_deref(),
|
||||
&project_context.instruction_files,
|
||||
);
|
||||
Ok(StatusContext {
|
||||
cwd: cwd.clone(),
|
||||
session_path: session_path.map(Path::to_path_buf),
|
||||
loaded_config_files,
|
||||
discovered_config_files,
|
||||
memory_file_count: project_context.instruction_files.len(),
|
||||
memory_files: memory_file_summaries(&project_context.instruction_files),
|
||||
memory_files: memory_files.clone(),
|
||||
unloaded_memory_files: unloaded_memory_candidates(
|
||||
&cwd,
|
||||
&memory_file_summaries(&project_context.instruction_files),
|
||||
project_root.as_deref(),
|
||||
&memory_files,
|
||||
),
|
||||
project_root,
|
||||
git_branch,
|
||||
@@ -16447,6 +16545,9 @@ mod tests {
|
||||
memory_files: vec![super::MemoryFileSummary {
|
||||
path: "/tmp/project/CLAUDE.md".to_string(),
|
||||
source: "claude_md".to_string(),
|
||||
origin: "workspace".to_string(),
|
||||
scope_path: "/tmp/project".to_string(),
|
||||
outside_project: false,
|
||||
chars: 42,
|
||||
contributes: true,
|
||||
}],
|
||||
@@ -16649,6 +16750,9 @@ mod tests {
|
||||
memory_files: vec![super::MemoryFileSummary {
|
||||
path: "/tmp/project/CLAUDE.md".to_string(),
|
||||
source: "claude_md".to_string(),
|
||||
origin: "workspace".to_string(),
|
||||
scope_path: "/tmp/project".to_string(),
|
||||
outside_project: false,
|
||||
chars: 12,
|
||||
contributes: true,
|
||||
}],
|
||||
|
||||
@@ -1309,6 +1309,15 @@ fn memory_files_load_claude_claw_agents_and_surface_json_438() {
|
||||
assert!(memory_files
|
||||
.iter()
|
||||
.all(|file| file["contributes"].as_bool() == Some(true)));
|
||||
assert!(memory_files
|
||||
.iter()
|
||||
.all(|file| file["origin"].as_str() == Some("workspace")));
|
||||
assert!(memory_files
|
||||
.iter()
|
||||
.all(|file| file["scope_path"].as_str().is_some()));
|
||||
assert!(memory_files
|
||||
.iter()
|
||||
.all(|file| file["outside_project"].as_bool() == Some(false)));
|
||||
|
||||
let prompt =
|
||||
assert_json_command_with_env(&root, &["--output-format", "json", "system-prompt"], &envs);
|
||||
@@ -1335,6 +1344,65 @@ fn memory_files_load_claude_claw_agents_and_surface_json_438() {
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_discovery_stops_at_git_root_and_reports_origins_439() {
|
||||
let root = unique_temp_dir("memory-boundary-439");
|
||||
let repo = root.join("repo");
|
||||
let nested = repo.join("subproj").join("deep").join("nest");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&nested).expect("nested dir should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.current_dir(&repo)
|
||||
.output()
|
||||
.expect("git init should launch");
|
||||
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
|
||||
fs::write(repo.join("CLAUDE.md"), "REPO_CLAUDE").expect("write repo");
|
||||
fs::write(repo.join("subproj").join("CLAUDE.md"), "CHILD_CLAUDE").expect("write child");
|
||||
fs::write(
|
||||
repo.join("subproj").join("deep").join("CLAUDE.md"),
|
||||
"DEEP_CLAUDE",
|
||||
)
|
||||
.expect("write deep");
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
];
|
||||
|
||||
let status =
|
||||
assert_json_command_with_env(&nested, &["--output-format", "json", "status"], &envs);
|
||||
assert_eq!(status["workspace"]["memory_file_count"], 3);
|
||||
let memory_files = status["workspace"]["memory_files"]
|
||||
.as_array()
|
||||
.expect("memory files");
|
||||
let origins = memory_files
|
||||
.iter()
|
||||
.map(|file| file["origin"].as_str().expect("origin"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(origins, vec!["ancestor", "ancestor", "parent_dir"]);
|
||||
let serialized = serde_json::to_string(memory_files).expect("memory files serialize");
|
||||
assert!(!serialized.contains("PARENT_CLAUDE"));
|
||||
assert!(!serialized.contains(root.join("CLAUDE.md").to_str().expect("parent path")));
|
||||
|
||||
let prompt = assert_json_command_with_env(
|
||||
&nested,
|
||||
&["--output-format", "json", "system-prompt"],
|
||||
&envs,
|
||||
);
|
||||
let message = prompt["message"].as_str().expect("prompt message");
|
||||
assert!(!message.contains("PARENT_CLAUDE"));
|
||||
assert!(message.contains("REPO_CLAUDE"));
|
||||
assert!(message.contains("CHILD_CLAUDE"));
|
||||
assert!(message.contains("DEEP_CLAUDE"));
|
||||
assert_eq!(prompt["memory_files"][0]["origin"], "ancestor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dump_manifests_and_init_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("manifest-init-json");
|
||||
|
||||
Reference in New Issue
Block a user