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
25 changes: 25 additions & 0 deletions src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ pub struct Config {
pub debt: crate::engine::debt_tracker::DebtConfig,
/// Active quality profile (resolved from .cora.yaml).
pub profile: Option<crate::engine::profiles::Profile>,
/// Analysis configuration — dead-code detection, entry-point patterns.
#[serde(default, skip_serializing_if = "is_default")]
pub analysis: AnalysisConfig,
}

/// Provider configuration.
Expand Down Expand Up @@ -137,6 +140,7 @@ impl Default for Config {
bundling: BundlingConfig::default(),
debt: crate::engine::debt_tracker::DebtConfig::default(),
profile: None,
analysis: AnalysisConfig::default(),
}
}
}
Expand Down Expand Up @@ -300,6 +304,8 @@ pub struct CoraFile {
pub debt: Option<crate::engine::debt_tracker::DebtConfig>,
#[serde(default)]
pub profile: Option<crate::engine::profiles::ProfileRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub analysis: Option<AnalysisConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -398,6 +404,16 @@ pub struct StaticAnalysisConfig {
pub clippy_output_file: Option<String>,
}

/// Analysis configuration — controls dead-code detection and symbol search.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct AnalysisConfig {
/// Custom entry-point method patterns to exclude from dead-code detection.
/// Supports glob-like patterns: `prefix*`, `*suffix`, `*substring*`.
/// Example: `["*Handler", "resolve_*", "*Middleware"]`
#[serde(default, skip_serializing_if = "is_default")]
pub entry_point_patterns: Vec<String>,
}

fn is_default<T: Default + PartialEq>(val: &T) -> bool {
*val == T::default()
}
Expand Down Expand Up @@ -635,6 +651,15 @@ impl CoraFile {
}
}
}
// Merge analysis config
if let Some(analysis) = &self.analysis {
if !analysis.entry_point_patterns.is_empty() {
config
.analysis
.entry_point_patterns
.clone_from(&analysis.entry_point_patterns);
}
}
Ok(())
}
}
Expand Down
142 changes: 122 additions & 20 deletions src/engine/index_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ use crate::engine::rules::types::RuleFinding;
use crate::index::graph;

/// Check if a file path matches any of the skip patterns.
/// Uses simple glob matching (* and **) against both the full path and the basename.
/// Pattern "src/main.ts" matches exactly, "*.config.ts" matches any filename ending in .config.ts.
/// Supports simple glob patterns:
/// - Exact: `"src/main.ts"` → full path match
/// - Suffix: `"*.config.ts"` → basename ends with `.config.ts`
/// - Prefix: `"vite.config.*"` → basename starts with `vite.config.`
/// - Any-dir name: `"**/something"` → basename or path suffix match
/// - Any-dir wildcard: `"**/phaser/**"` → any path component equals `phaser`
/// - Prefix-dir: `"src/engine/**"` → file under `src/engine/`
/// - Double wildcard ext: `"**/*.test.ts"` → basename ends with `.test.ts`
pub fn should_skip_file(file_path: &str, skip_patterns: &[String]) -> bool {
if skip_patterns.is_empty() {
return false;
Expand All @@ -34,27 +40,60 @@ pub fn should_skip_file(file_path: &str, skip_patterns: &[String]) -> bool {
if basename == *pattern {
return true;
}
// Simple glob: pattern contains *
if pattern.contains('*') {
// Convert simple glob to a basic match:
// "*.config.ts" → check if basename ends with ".config.ts"
// "vite.config.*" → check if basename starts with "vite.config."
if pattern.starts_with("*.") {
let suffix = &pattern[1..]; // ".config.ts"
if basename.ends_with(suffix) {
return true;
}
} else if pattern.ends_with(".*") {
let prefix = &pattern[..pattern.len() - 2]; // "vite.config"
if basename.starts_with(prefix) {
if !pattern.contains('*') {
continue;
}

// "**/*.ext" → suffix match on basename (any directory)
if let Some(rest) = pattern.strip_prefix("**/") {
if let Some(ext) = rest.strip_prefix("*.") {
if basename.ends_with(ext) {
return true;
}
}
// "**/something" → match basename
if let Some(rest) = pattern.strip_prefix("**/") {
if basename == rest || file_path.ends_with(&format!("/{}", rest)) {
return true;
}
}

// "*.ext" → suffix match on basename
if pattern.starts_with("*.") {
let suffix = &pattern[1..]; // ".config.ts"
if basename.ends_with(suffix) {
return true;
}
}

// "name.*" → prefix match on basename
if pattern.ends_with(".*") {
let prefix = &pattern[..pattern.len() - 2]; // "vite.config"
if basename.starts_with(prefix) {
return true;
}
}

// "**/name/**" → any path component equals "name"
if let Some(dir) = pattern
.strip_prefix("**/")
.and_then(|s| s.strip_suffix("/**"))
{
let components: Vec<&str> = file_path.split('/').collect();
if components.contains(&dir) {
return true;
}
}

// "dir/**" → file under dir/
if let Some(dir) = pattern.strip_suffix("/**") {
if file_path.starts_with(&format!("{dir}/")) || file_path == dir {
return true;
}
}

// "**/name" → match basename or path suffix
if let Some(rest) = pattern.strip_prefix("**/") {
if rest.contains('*') {
continue;
}
if basename == rest || file_path.ends_with(&format!("/{rest}")) {
return true;
}
}
}
Expand Down Expand Up @@ -614,4 +653,67 @@ mod tests {
assert!(!should_skip_file("src/utils.ts", &defaults));
assert!(!should_skip_file("src/components/Button.tsx", &defaults));
}

// --- New glob pattern tests (#453) ---

#[test]
fn skip_doublestar_dir_doublestar() {
// "**/phaser/**" → match any path containing "phaser" as a component
let patterns = vec!["**/phaser/**".into()];
assert!(should_skip_file(
"src/scenes/phaser/GameScene.ts",
&patterns
));
assert!(should_skip_file("phaser/GameScene.ts", &patterns));
assert!(should_skip_file("src/phaser/utils.ts", &patterns));
assert!(!should_skip_file("src/utils/phaserHelper.ts", &patterns));
assert!(!should_skip_file("src/app.ts", &patterns));
}

#[test]
fn skip_prefix_dir_doublestar() {
// "src/engine/**" → files under src/engine/
let patterns = vec!["src/engine/**".into()];
assert!(should_skip_file("src/engine/physics.rs", &patterns));
assert!(should_skip_file("src/engine/core/mod.rs", &patterns));
assert!(!should_skip_file("src/app/engine.rs", &patterns));
}

#[test]
fn skip_doublestar_wildcard_ext() {
// "**/*.test.ts" → any .test.ts file anywhere
let patterns = vec!["**/*.test.ts".into()];
assert!(should_skip_file("src/utils/helpers.test.ts", &patterns));
assert!(should_skip_file("tests/integration.test.ts", &patterns));
assert!(!should_skip_file("src/utils/helpers.ts", &patterns));
assert!(!should_skip_file("src/utils/helpers.test.js", &patterns));
}

#[test]
fn skip_nested_doublestar_dir() {
// "**/node_modules/**" → match nested node_modules
let patterns = vec!["**/node_modules/**".into()];
assert!(should_skip_file("node_modules/lodash/index.js", &patterns));
assert!(should_skip_file(
"packages/app/node_modules/lodash/index.js",
&patterns
));
assert!(!should_skip_file("src/node_modulesHelper.ts", &patterns));
}

#[test]
fn skip_multiple_patterns_any_match() {
let patterns = vec![
"**/phaser/**".into(),
"*.config.ts".into(),
"src/main.ts".into(),
];
assert!(should_skip_file(
"src/scenes/phaser/GameScene.ts",
&patterns
));
assert!(should_skip_file("vite.config.ts", &patterns));
assert!(should_skip_file("src/main.ts", &patterns));
assert!(!should_skip_file("src/lib.rs", &patterns));
}
}
94 changes: 93 additions & 1 deletion src/index/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,10 @@ pub struct DeadCodeOptions {
pub include_tests: bool,
/// If set, only report symbols whose span is at least this many lines long.
pub min_lines: Option<u32>,
/// Additional entry-point method name patterns to exclude from dead-code.
/// Each entry is a SQL LIKE pattern (e.g. `%Handler`, `%Listener`, `%Route`).
/// These are checked against symbol names in addition to WELL_KNOWN_NAMES.
pub entry_point_patterns: Vec<String>,
}

/// Well-known symbol names that should not be flagged as dead code even when
Expand Down Expand Up @@ -706,6 +710,56 @@ const WELL_KNOWN_NAMES: &[&str] = &[
"duration_since",
];

/// Framework lifecycle / entry-point method name prefixes.
///
/// Methods starting with these prefixes are typically called by frameworks
/// (Axum route handlers, SvelteKit page loads, Phaser scene lifecycle, etc.)
/// and may not have direct callers in the codebase.
const FRAMEWORK_ENTRY_PREFIXES: &[&str] = &[
// Axum / Actix / Tower handler patterns
"handle_",
// SvelteKit page/layout exports
"load",
// Phaser scene lifecycle
"create",
"preload",
"update",
// React / Svelte lifecycle
"render",
"component_did_mount",
"on_mount",
"on_destroy",
// General async patterns
"handle",
"process",
"execute",
"serve",
"start",
"stop",
"run",
// GraphQL resolvers
"resolve_",
];

/// Patterns that indicate a method is a framework callback (suffix-based).
const FRAMEWORK_ENTRY_SUFFIXES: &[&str] = &[
// Axum handler trait
"Handler",
// Event listeners
"Listener",
"Callback",
// Middleware
"Middleware",
// Route
"Route",
// Hook
"Hook",
// Plugin
"Plugin",
// Provider
"Provider",
];

/// Find dead code: symbols that have no recorded callers and are not
/// well-known names or (by default) test functions.
///
Expand Down Expand Up @@ -744,6 +798,22 @@ pub fn find_dead_code(
sql.push_str(" AND s.name NOT LIKE 'test_%' AND s.name NOT LIKE '%_test'");
}

// Exclude symbols with `// cora: keep` suppression marker in their signature.
sql.push_str(" AND (s.signature IS NULL OR s.signature NOT LIKE '%cora: keep%')");

// Exclude framework entry-point methods by name prefix.
for prefix in FRAMEWORK_ENTRY_PREFIXES {
sql.push_str(&format!(" AND LOWER(s.name) NOT LIKE '{prefix}%'"));
}

// Exclude framework entry-point methods by name suffix.
for suffix in FRAMEWORK_ENTRY_SUFFIXES {
sql.push_str(&format!(" AND s.name NOT LIKE '%{suffix}'"));
}

// Exclude user-configured entry-point patterns via post-filter below.
let _ = &opts.entry_point_patterns; // patterns applied in post-filter

// Optional min_lines filter — applied post-query below when span metadata
// becomes available. Kept for API compatibility.
if opts.min_lines.is_some() {
Expand Down Expand Up @@ -782,7 +852,28 @@ pub fn find_dead_code(
})
})?;

Ok(rows.filter_map(|r| r.ok()).collect())
// Post-filter by user-configured entry_point_patterns (safe LIKE matching).
let results: Vec<DeadCodeResult> = rows
.filter_map(|r| r.ok())
.filter(|r| {
// Skip if any user pattern matches
let name_lower = r.name.to_lowercase();
!opts.entry_point_patterns.iter().any(|p| {
// Simple glob-like matching: prefix*, *suffix, or exact
if p.starts_with('*') && p.ends_with('*') && p.len() > 2 {
name_lower.contains(&p[1..p.len() - 1].to_lowercase())
} else if let Some(suffix) = p.strip_prefix('*') {
name_lower.ends_with(&suffix.to_lowercase())
} else if let Some(prefix) = p.strip_suffix('*') {
name_lower.starts_with(&prefix.to_lowercase())
} else {
name_lower == p.to_lowercase()
}
})
})
.collect();

Ok(results)
}

/// Result of unused import analysis for a single file.
Expand Down Expand Up @@ -1249,6 +1340,7 @@ mod tests {
&DeadCodeOptions {
include_tests: true,
min_lines: None,
entry_point_patterns: vec![],
},
)
.unwrap();
Expand Down
Loading
Loading