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
52 changes: 46 additions & 6 deletions crates/codegraph-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,12 @@ enum Command {
Files {
#[arg(short, long)]
path: Option<PathBuf>,
#[arg(long)]
/// Filter to files under this directory (path prefix).
#[arg(long, value_name = "DIR")]
filter: Option<String>,
/// Filter to files of this language (matches `status` names, e.g. gdscript, godot_scene).
#[arg(long, value_name = "LANG")]
language: Option<String>,
#[arg(long)]
pattern: Option<String>,
#[arg(long, value_enum, default_value_t = FilesFormat::Tree)]
Expand Down Expand Up @@ -245,6 +249,7 @@ enum Command {
/// and reverse-dependency impact. Computed from the existing graph + disk
/// checks; adds no extraction and is separate from `check`.
Audit {
/// Project root (NOT a result filter; use --include/--exclude to narrow).
#[arg(short, long)]
path: Option<PathBuf>,
/// Report `.tres`/`.tscn` resources nothing references.
Expand All @@ -256,6 +261,12 @@ enum Command {
/// Report what references the given changed resource/script path.
#[arg(long, value_name = "PATH")]
impact: Option<String>,
/// Keep only results whose path is under this prefix (repeatable).
#[arg(long, value_name = "PREFIX")]
include: Vec<String>,
/// Drop results whose path is under this prefix, e.g. addons/ (repeatable).
#[arg(long, value_name = "PREFIX")]
exclude: Vec<String>,
#[arg(short = 'j', long = "json")]
json: bool,
},
Expand Down Expand Up @@ -422,11 +433,12 @@ fn run(cli: Cli) -> Result<()> {
Command::Files {
path,
filter,
language,
pattern,
format,
max_depth,
json,
} => cmd_files(path, filter, pattern, format, max_depth, json),
} => cmd_files(path, filter, language, pattern, format, max_depth, json),
Command::Serve {
path,
mcp,
Expand Down Expand Up @@ -463,8 +475,10 @@ fn run(cli: Cli) -> Result<()> {
orphans,
dangling,
impact,
include,
exclude,
json,
} => cmd_audit(path, orphans, dangling, impact, json),
} => cmd_audit(path, orphans, dangling, impact, include, exclude, json),
Command::Export {
path,
out,
Expand Down Expand Up @@ -1038,6 +1052,7 @@ fn cmd_query(
fn cmd_files(
path: Option<PathBuf>,
filter: Option<String>,
language: Option<String>,
pattern: Option<String>,
format: FilesFormat,
max_depth: Option<usize>,
Expand All @@ -1050,9 +1065,15 @@ fn cmd_files(
let alt = format!("./{filter}");
files.retain(|f| f.path.starts_with(&filter) || f.path.starts_with(&alt));
}
if let Some(language) = language {
files.retain(|f| f.language.as_str() == language);
}
if let Some(pattern) = pattern {
files.retain(|f| glob_matches(&pattern, &f.path));
}
for file in &mut files {
file.node_count = store.node_count_by_file_path(&file.path)?;
}
if json_output {
let output = files.iter().map(FileOutput::from).collect::<Vec<_>>();
print_json_pretty(&output)?;
Expand Down Expand Up @@ -1689,11 +1710,22 @@ fn cmd_check(path: Option<PathBuf>, json_output: bool) -> Result<()> {
Ok(())
}

fn audit_prefix_keep(path: &str, include: &[String], exclude: &[String]) -> bool {
let normalized = path.replace('\\', "/");
let under = |prefix: &String| normalized.starts_with(&prefix.replace('\\', "/"));
if !include.is_empty() && !include.iter().any(under) {
return false;
}
!exclude.iter().any(under)
}

fn cmd_audit(
path: Option<PathBuf>,
orphans: bool,
dangling: bool,
impact: Option<String>,
include: Vec<String>,
exclude: Vec<String>,
json_output: bool,
) -> Result<()> {
if !orphans && !dangling && impact.is_none() {
Expand All @@ -1703,18 +1735,26 @@ fn cmd_audit(
let store = open_store(&project)?;
let traverser = GraphTraverser::new(&store);

let orphan_list = if orphans {
let mut orphan_list = if orphans {
traverser.find_orphan_resources()?
} else {
Vec::new()
};
let dangling_list = if dangling {
orphan_list.retain(|o| audit_prefix_keep(&o.file_path, &include, &exclude));
let mut dangling_list = if dangling {
traverser.find_dangling_references(&project)?
} else {
Vec::new()
};
dangling_list.retain(|d| audit_prefix_keep(&d.from_file, &include, &exclude));
let impact_result = match &impact {
Some(changed) => Some(traverser.resource_impact(changed)?),
Some(changed) => {
let mut result = traverser.resource_impact(changed)?;
result
.affected
.retain(|a| audit_prefix_keep(&a.from_file, &include, &exclude));
Some(result)
}
None => None,
};

Expand Down
101 changes: 101 additions & 0 deletions crates/codegraph-cli/tests/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,107 @@ fn audit_dangling_does_not_report_a_scene_connection_method() {
);
}

#[test]
fn audit_orphans_exclude_drops_matching_prefix() {
// Given the fixture with an orphan resource planted under levels/ (an
// indexed subdir; addons/ and vendor/ are index-ignored so they cannot
// exercise the CLI-layer prefix filter),
let (_dir, project) = indexed_project("exclude-prefix");
let p = project.to_str().unwrap();
let levels = project.join("levels");
fs::create_dir_all(&levels).unwrap();
fs::copy(
project.join("orphan.tres"),
levels.join("level_orphan.tres"),
)
.unwrap();
let (_o, _e, ok) = cli(&["index", "--force", p]);
assert!(ok, "reindex failed");

// When audit --orphans runs without and with --exclude levels/,
let unfiltered: serde_json::Value = {
let (stdout, err, ok) = cli(&["audit", "--orphans", "--json", "-p", p]);
assert!(ok, "audit failed: stderr={err}");
serde_json::from_str(&stdout).expect("valid JSON")
};
let filtered: serde_json::Value = {
let (stdout, err, ok) = cli(&[
"audit",
"--orphans",
"--exclude",
"levels/",
"--json",
"-p",
p,
]);
assert!(ok, "audit failed: stderr={err}");
serde_json::from_str(&stdout).expect("valid JSON")
};

// Then the levels/ orphan is present unfiltered but dropped by --exclude.
let orphans = |v: &serde_json::Value| -> Vec<String> {
v["orphans"]
.as_array()
.expect("orphans array")
.iter()
.map(|o| o["filePath"].as_str().expect("filePath").to_string())
.collect()
};
assert!(
orphans(&unfiltered)
.iter()
.any(|f| f.starts_with("levels/")),
"levels orphan must appear unfiltered, got: {:?}",
orphans(&unfiltered)
);
assert!(
!orphans(&filtered).iter().any(|f| f.starts_with("levels/")),
"--exclude levels/ must drop levels orphans, got: {:?}",
orphans(&filtered)
);
}

#[test]
fn audit_dangling_include_keeps_only_matching_prefix() {
// Given the fixture with a Data/ resource whose ref target is missing,
let (_dir, project) = indexed_project("include-data");
let p = project.to_str().unwrap();
let data = project.join("Data");
fs::create_dir_all(&data).unwrap();
fs::write(
data.join("config.tres"),
"[gd_resource type=\"Resource\" format=3]\n\n[ext_resource type=\"Resource\" path=\"res://Data/gone.tres\" id=\"1\"]\n\n[resource]\nlinked = ExtResource(\"1\")\n",
)
.unwrap();
let (_o, _e, ok) = cli(&["index", "--force", p]);
assert!(ok, "reindex failed");

// When audit --dangling runs with --include Data/,
let (stdout, err, ok) = cli(&[
"audit",
"--dangling",
"--include",
"Data/",
"--json",
"-p",
p,
]);
assert!(ok, "audit failed: stderr={err}");
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");

// Then every reported dangling ref originates under Data/.
let from_files: Vec<&str> = value["dangling"]
.as_array()
.expect("dangling array")
.iter()
.map(|d| d["fromFile"].as_str().expect("fromFile"))
.collect();
assert!(
!from_files.is_empty() && from_files.iter().all(|f| f.starts_with("Data/")),
"--include Data/ must keep only Data/ refs, got: {from_files:?}"
);
}

#[test]
fn audit_requires_at_least_one_mode() {
// Given the indexed fixture,
Expand Down
Loading