diff --git a/crates/no-mistakes/src/codebase/dependencies/args_relationships.rs b/crates/no-mistakes/src/codebase/dependencies/args_relationships.rs index a60b52e23..5ab804fef 100644 --- a/crates/no-mistakes/src/codebase/dependencies/args_relationships.rs +++ b/crates/no-mistakes/src/codebase/dependencies/args_relationships.rs @@ -36,6 +36,11 @@ pub enum RelationshipArg { Dotnet, Swift, Terraform, + Python, + Go, + Rust, + Ruby, + Php, Resource, All, } @@ -81,6 +86,11 @@ impl RelationshipArg { Self::Dotnet => "dotnet", Self::Swift => "swift", Self::Terraform => "terraform", + Self::Python => "python", + Self::Go => "go", + Self::Rust => "rust", + Self::Ruby => "ruby", + Self::Php => "php", Self::Resource => "resource", Self::All => "all", Self::Workflow diff --git a/crates/no-mistakes/src/codebase/dependencies/args_relationships_filter.rs b/crates/no-mistakes/src/codebase/dependencies/args_relationships_filter.rs index d31ad05f0..6fd980176 100644 --- a/crates/no-mistakes/src/codebase/dependencies/args_relationships_filter.rs +++ b/crates/no-mistakes/src/codebase/dependencies/args_relationships_filter.rs @@ -77,6 +77,11 @@ fn non_workflow_relationship_edges(relationship: &RelationshipArg) -> &'static [ EdgeKind::TerraformModuleRef, EdgeKind::TerraformOutputRef, ], + RelationshipArg::Python => &[EdgeKind::PythonImport, EdgeKind::PythonReference], + RelationshipArg::Go => &[EdgeKind::GoImport, EdgeKind::GoReference], + RelationshipArg::Rust => &[EdgeKind::RustUse, EdgeKind::RustMod, EdgeKind::RustPackage], + RelationshipArg::Ruby => &[EdgeKind::RubyRequire, EdgeKind::RubyReference], + RelationshipArg::Php => &[EdgeKind::PhpUse, EdgeKind::PhpPackage], RelationshipArg::Resource => &[EdgeKind::Resource], RelationshipArg::Workflow | RelationshipArg::WorkflowJob @@ -136,6 +141,17 @@ fn standard_relationship_edges() -> std::collections::HashSet { EdgeKind::TerraformReference, EdgeKind::TerraformModuleRef, EdgeKind::TerraformOutputRef, + EdgeKind::PythonImport, + EdgeKind::PythonReference, + EdgeKind::GoImport, + EdgeKind::GoReference, + EdgeKind::RustUse, + EdgeKind::RustMod, + EdgeKind::RustPackage, + EdgeKind::RubyRequire, + EdgeKind::RubyReference, + EdgeKind::PhpUse, + EdgeKind::PhpPackage, EdgeKind::Resource, ] .into_iter() diff --git a/crates/no-mistakes/src/codebase/dependencies/args_test_globs.rs b/crates/no-mistakes/src/codebase/dependencies/args_test_globs.rs index 2f1c95fa8..459d9c0f5 100644 --- a/crates/no-mistakes/src/codebase/dependencies/args_test_globs.rs +++ b/crates/no-mistakes/src/codebase/dependencies/args_test_globs.rs @@ -46,6 +46,15 @@ pub(crate) fn test_globs(framework: &str) -> Vec { "cargo" => globs_to_strings(CARGO), "dotnet" => globs_to_strings(DOTNET), "swift" => globs_to_strings(SWIFT), + "python" => globs_to_strings(&[ + "**/test_*.py", + "**/*_test.py", + "**/tests.py", + "**/tests/**/*.py", + ]), + "go" => globs_to_strings(&["**/*_test.go"]), + "rails" => globs_to_strings(&["**/spec/**/*_spec.rb", "**/test/**/*_test.rb"]), + "php" => globs_to_strings(&["**/*Test.php", "**/tests/**/*.php"]), _ => vec![], } } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs index a2e34724a..2039b55c5 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs @@ -25,6 +25,7 @@ pub struct GraphBuildPlan { pub dotnet: bool, pub swift: bool, pub terraform: bool, + pub language_frontends: bool, } impl GraphBuildPlan { @@ -54,6 +55,7 @@ impl GraphBuildPlan { dotnet: true, swift: true, terraform: true, + language_frontends: true, } } @@ -125,6 +127,7 @@ impl GraphBuildPlan { terraform: allowed.contains(&EdgeKind::TerraformReference) || allowed.contains(&EdgeKind::TerraformModuleRef) || allowed.contains(&EdgeKind::TerraformOutputRef), + language_frontends: allowed_requests_language_frontends(allowed), } } @@ -150,6 +153,7 @@ impl GraphBuildPlan { self.dotnet |= other.dotnet; self.swift |= other.swift; self.terraform |= other.terraform; + self.language_frontends |= other.language_frontends; } pub fn with_symbols(mut self, symbols: bool) -> Self { @@ -175,38 +179,3 @@ impl GraphBuildPlan { } } } - -fn graph_plan_needs_config(plan: GraphBuildPlan) -> bool { - plan.ci - || plan.workflow_topology - || plan.routes - || plan.queues - || plan.http - || plan.tests - || plan.dotnet - || plan.swift - || plan.terraform -} - -fn effective_ts_fact_plan( - plan: GraphBuildPlan, - options: Option<&GraphConfigOptions>, -) -> TsFactPlan { - let mut fact_plan = plan.ts_fact_plan(); - let route_refs_configured = options.is_some_and(route_ref_facts_configured); - let route_backend_configured = options.is_some_and(route_backend_facts_configured); - let http_configured = options.is_some_and(http_facts_configured); - let queue_configured = options.is_some_and(queue_facts_configured); - - fact_plan.route_refs &= route_refs_configured; - fact_plan.backend_routes &= route_backend_configured || http_configured; - fact_plan.http_calls &= http_configured; - fact_plan.symbols = plan.symbols || (fact_plan.symbols && queue_configured); - fact_plan.queue_usage &= queue_configured; - fact_plan.queue_factory &= queue_configured; - fact_plan.queue_project &= queue_configured; - fact_plan.server_routes = options.is_some_and(|options| { - options.project_route_globset.is_some() && (plan.routes || plan.swift) - }); - fact_plan -} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/build_plan_facts.rs b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan_facts.rs new file mode 100644 index 000000000..bdf0c73f1 --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan_facts.rs @@ -0,0 +1,53 @@ +fn allowed_requests_language_frontends(allowed: &HashSet) -> bool { + [ + EdgeKind::PythonImport, + EdgeKind::PythonReference, + EdgeKind::GoImport, + EdgeKind::GoReference, + EdgeKind::RustUse, + EdgeKind::RustMod, + EdgeKind::RustPackage, + EdgeKind::RubyRequire, + EdgeKind::RubyReference, + EdgeKind::PhpUse, + EdgeKind::PhpPackage, + ] + .into_iter() + .any(|kind| allowed.contains(&kind)) +} + +fn graph_plan_needs_config(plan: GraphBuildPlan) -> bool { + plan.ci + || plan.workflow_topology + || plan.routes + || plan.queues + || plan.http + || plan.tests + || plan.dotnet + || plan.swift + || plan.terraform + || plan.language_frontends +} + +fn effective_ts_fact_plan( + plan: GraphBuildPlan, + options: Option<&GraphConfigOptions>, +) -> TsFactPlan { + let mut fact_plan = plan.ts_fact_plan(); + let route_refs_configured = options.is_some_and(route_ref_facts_configured); + let route_backend_configured = options.is_some_and(route_backend_facts_configured); + let http_configured = options.is_some_and(http_facts_configured); + let queue_configured = options.is_some_and(queue_facts_configured); + + fact_plan.route_refs &= route_refs_configured; + fact_plan.backend_routes &= route_backend_configured || http_configured; + fact_plan.http_calls &= http_configured; + fact_plan.symbols = plan.symbols || (fact_plan.symbols && queue_configured); + fact_plan.queue_usage &= queue_configured; + fact_plan.queue_factory &= queue_configured; + fact_plan.queue_project &= queue_configured; + fact_plan.server_routes = options.is_some_and(|options| { + options.project_route_globset.is_some() && (plan.routes || plan.swift) + }); + fact_plan +} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs b/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs index 4d6c708b5..453689b8e 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs @@ -94,6 +94,23 @@ fn collect_dotnet_edges_for_plan(inputs: &GraphEdgeBuildInputs<'_>) -> Vec ) } +fn merge_language_frontend_edges( + inputs: &GraphEdgeBuildInputs<'_>, + forward: &mut EdgeMap, + reverse: &mut EdgeMap, +) { + let edges = collect_language_frontend_edges( + inputs.root, + &inputs.graph_files.all, + inputs.config_options, + ); + for (from, to, _) in &edges { + forward.entry(from.clone()).or_default(); + forward.entry(to.clone()).or_default(); + } + merge_edges(forward, reverse, edges); +} + fn collect_terraform_edges_for_plan(inputs: &GraphEdgeBuildInputs<'_>) -> Vec { if !inputs.plan.terraform { return Vec::new(); diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/builder_remaining_edges.rs b/crates/no-mistakes/src/codebase/dependencies/graph/builder_remaining_edges.rs index deaff6197..2ac00bb59 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/builder_remaining_edges.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/builder_remaining_edges.rs @@ -117,5 +117,14 @@ fn collect_remaining_edges( Ok(()) })?; crate::invocation::check_timeout()?; + crate::perf_trace::trace("graph.language_frontends", || { + if edge_inputs.plan.language_frontends + || edge_inputs.plan.queues + || edge_inputs.plan.routes + { + merge_language_frontend_edges(edge_inputs, forward, reverse); + } + }); + crate::invocation::check_timeout()?; Ok(()) } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs new file mode 100644 index 000000000..64ed1ed13 --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -0,0 +1,190 @@ +fn emit_route_edges( + root: &Path, + facts: &LangFactMap, + options: &GraphConfigOptions, + edges: &mut Vec, +) { + for file in facts.files.values() { + if !route_file_allowed(root, &file.path, options) { + continue; + } + for (_, handler) in &file.route_handlers { + if let Some(targets) = facts.files_by_module.get(handler) { + let scoped: std::collections::BTreeSet<_> = targets + .iter() + .filter(|target| { + facts + .files + .get(*target) + .is_some_and(|other| same_lang_package(file, other)) + }) + .cloned() + .collect(); + push_file_edges(edges, &file.path, &scoped, EdgeKind::RouteRef); + } + for name in route_handler_names(handler) + .into_iter() + .flat_map(|name| aliased_route_names(file, name)) + { + if let Some(targets) = facts.declarations.get(&name) { + let scoped: std::collections::BTreeSet<_> = targets + .iter() + .filter(|target| { + facts.files.get(*target).is_some_and(|other| { + same_lang_package(file, other) + && handler_module_matches(handler, other) + }) + }) + .cloned() + .collect(); + push_file_edges(edges, &file.path, &scoped, EdgeKind::RouteRef); + } + } + } + } +} + +fn route_file_allowed(root: &Path, path: &Path, options: &GraphConfigOptions) -> bool { + let Some(globset) = options.project_route_globset.as_ref() else { + return true; + }; + let rel = path.strip_prefix(root).unwrap_or(path); + globset.is_match(rel.to_string_lossy().as_ref()) +} + +fn same_lang_package(file: &LangFileFacts, other: &LangFileFacts) -> bool { + file.package.is_none() || file.package == other.package +} + +fn handler_module_matches(handler: &str, file: &LangFileFacts) -> bool { + let view = remap_aliased_handler(file, &normalize_route_handler(handler)); + if !view.contains('.') || view.contains("::") || view.contains('#') || view.contains('/') { + return true; + } + let Some(module) = file.module.as_deref() else { + return true; + }; + if module == view || view.starts_with(&format!("{module}.")) { + return true; + } + view.rsplit_once('.') + .is_some_and(|(parent, _)| module == parent || module.ends_with(&format!(".{parent}"))) +} + +fn aliased_route_names(file: &LangFileFacts, name: String) -> Vec { + let mut names = vec![name.clone()]; + for import in &file.imports { + let Some((alias, target)) = import.split_once('=') else { + continue; + }; + if alias == name { + names.push(target.to_string()); + if let Some(short) = target.rsplit('.').next() { + names.push(short.to_string()); + } + } + } + names +} + +fn remap_aliased_handler(file: &LangFileFacts, view: &str) -> String { + let Some((prefix, rest)) = view.split_once('.') else { + return view.to_string(); + }; + file.imports + .iter() + .find_map(|import| { + let (alias, target) = import.split_once('=')?; + (alias == prefix).then(|| format!("{target}.{rest}")) + }) + .unwrap_or_else(|| view.to_string()) +} + +fn normalize_route_handler(handler: &str) -> String { + let trimmed = handler.replace(['\'', '"', ' '], ""); + trimmed + .strip_suffix("()") + .unwrap_or(trimmed.as_str()) + .strip_suffix(".as_view") + .unwrap_or(trimmed.as_str()) + .to_string() +} + +fn reference_target_allowed(file: &LangFileFacts, target: &LangFileFacts, reference: &str) -> bool { + if file.module.is_some() && file.module == target.module { + return true; + } + if reference.contains("::") && same_lang_package(file, target) { + return true; + } + if target.module.as_deref().is_some_and(|module| { + file.imports + .iter() + .any(|import| import_reaches_module(import, module, reference)) + }) { + return true; + } + if file.module.is_some() { + return false; + } + same_lang_package(file, target) +} + +fn import_reaches_module(import: &str, module: &str, reference: &str) -> bool { + import == module + || import == reference + || import == format!("{module}.{reference}") + || import == format!("{module}/{reference}") + || import.starts_with(&format!("{module}.")) + || Path::new(import).file_stem().and_then(|name| name.to_str()) + == Path::new(module).file_stem().and_then(|name| name.to_str()) +} + +fn route_handler_names(handler: &str) -> Vec { + let trimmed = handler.replace(['\'', '"', ' '], ""); + if let Some((controller, _)) = trimmed.split_once('#') { + return rails_controller_names(controller); + } + if let Some((class, _)) = trimmed.split_once("::") { + let short = class.rsplit('\\').next().unwrap_or(class).to_string(); + return vec![short, class.to_string()]; + } + let view = normalize_route_handler(&trimmed); + let mut names = vec![view.clone()]; + if let Some((_, last)) = view.rsplit_once('.') { + names.push(last.to_string()); + } + names +} + +fn rails_controller_names(controller: &str) -> Vec { + let parts: Vec<&str> = controller.split('/').filter(|part| !part.is_empty()).collect(); + let last = parts.last().copied().unwrap_or(controller); + let mut class = last.to_string(); + if !class.ends_with("Controller") { + class.push_str("Controller"); + } + let class = snake_to_pascal(&class); + if parts.len() <= 1 { + return vec![class]; + } + let namespace = parts[..parts.len() - 1] + .iter() + .map(|part| snake_to_pascal(part)) + .collect::>() + .join("::"); + vec![format!("{namespace}::{class}")] +} + +fn snake_to_pascal(name: &str) -> String { + name.split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + let first = chars.next().expect("non-empty"); + first.to_ascii_uppercase().to_string() + chars.as_str() + }) + .collect() +} + + diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs new file mode 100644 index 000000000..389704abe --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -0,0 +1,164 @@ +use crate::codebase::lang_frontends::{ + collect_all_lang_facts, scan_kafka_file, topic_identity, LangFactMap, LangFileFacts, + LangFrontendConfig, +}; + +fn collect_language_frontend_edges( + root: &Path, + all_files: &[PathBuf], + config_options: Option<&GraphConfigOptions>, +) -> Vec { + let Some(options) = config_options else { + return Vec::new(); + }; + let config = lang_config_from_options(options); + if config_is_empty(&config) + && options.queue_enqueues.is_empty() + && options.queue_workers.is_empty() + { + return Vec::new(); + } + let facts = collect_all_lang_facts(root, all_files, &config); + let mut edges = Vec::new(); + emit_lang_edges(&facts.python, EdgeKind::PythonImport, EdgeKind::PythonReference, &mut edges); + emit_lang_edges(&facts.go, EdgeKind::GoImport, EdgeKind::GoReference, &mut edges); + emit_lang_edges(&facts.rust, EdgeKind::RustUse, EdgeKind::RustUse, &mut edges); + emit_mod_edges(&facts.rust, EdgeKind::RustMod, &mut edges); + emit_package_edges(&facts.rust, EdgeKind::RustPackage, &mut edges); + emit_lang_edges(&facts.ruby, EdgeKind::RubyRequire, EdgeKind::RubyReference, &mut edges); + emit_lang_edges(&facts.php, EdgeKind::PhpUse, EdgeKind::PhpUse, &mut edges); + emit_package_edges(&facts.php, EdgeKind::PhpPackage, &mut edges); + emit_queue_edges(root, &facts.python, options, &mut edges); + emit_queue_edges(root, &facts.go, options, &mut edges); + emit_queue_edges(root, &facts.ruby, options, &mut edges); + emit_queue_edges(root, &facts.php, options, &mut edges); + emit_route_edges(root, &facts.python, options, &mut edges); + emit_route_edges(root, &facts.ruby, options, &mut edges); + emit_route_edges(root, &facts.php, options, &mut edges); + emit_kafka_edges(root, all_files, options, &mut edges); + edges +} + +fn lang_config_from_options(options: &GraphConfigOptions) -> LangFrontendConfig { + LangFrontendConfig { + python_packages: options.python_packages.clone(), + go_modules: options.go_modules.clone(), + rust_packages: options.rust_packages.clone(), + rails_apps: options.rails_apps.clone(), + php_apps: options.php_apps.clone(), + php_framework: options.php_framework.clone(), + } +} + +fn config_is_empty(config: &LangFrontendConfig) -> bool { + config.python_packages.is_empty() + && config.go_modules.is_empty() + && config.rust_packages.is_empty() + && config.rails_apps.is_empty() + && config.php_apps.is_empty() +} + +fn emit_lang_edges( + facts: &LangFactMap, + import_kind: EdgeKind, + ref_kind: EdgeKind, + edges: &mut Vec, +) { + for file in facts.files.values() { + for import in &file.imports { + if let Some(targets) = facts.files_by_module.get(import) { + let scoped: std::collections::BTreeSet<_> = targets + .iter() + .filter(|target| { + import_kind == EdgeKind::GoImport + || facts + .files + .get(*target) + .is_some_and(|other| same_lang_package(file, other)) + }) + .cloned() + .collect(); + push_file_edges(edges, &file.path, &scoped, import_kind); + } + } + for reference in &file.references { + if let Some(targets) = facts.declarations.get(reference) { + let scoped: std::collections::BTreeSet<_> = targets + .iter() + .filter(|target| { + facts + .files + .get(*target) + .is_some_and(|other| reference_target_allowed(file, other, reference)) + }) + .cloned() + .collect(); + push_file_edges(edges, &file.path, &scoped, ref_kind); + } + } + } +} + +fn emit_mod_edges(facts: &LangFactMap, kind: EdgeKind, edges: &mut Vec) { + for file in facts.files.values() { + for name in &file.mods { + let qualified = match file.module.as_deref() { + Some(parent) => format!("{parent}.{name}"), + None => name.clone(), + }; + let targets = facts + .files_by_module + .get(&qualified) + .or_else(|| facts.files_by_module.get(name)); + if let Some(targets) = targets { + let scoped: std::collections::BTreeSet<_> = targets + .iter() + .filter(|target| { + facts + .files + .get(*target) + .is_some_and(|other| same_lang_package(file, other)) + }) + .cloned() + .collect(); + push_file_edges(edges, &file.path, &scoped, kind); + } + } + } +} + +fn emit_package_edges(facts: &LangFactMap, kind: EdgeKind, edges: &mut Vec) { + for files in facts.files_by_package.values() { + let Some(root) = package_root_file(files) else { + continue; + }; + push_file_edges(edges, root, files, kind); + } +} + +fn package_root_file(files: &std::collections::BTreeSet) -> Option<&Path> { + let named = |want: &str| { + files + .iter() + .find(|path| path.file_name().and_then(|name| name.to_str()) == Some(want)) + }; + named("lib.rs") + .or_else(|| named("main.rs")) + .or_else(|| named("composer.json")) + .or_else(|| named("mod.rs")) + .or_else(|| files.iter().next()) + .map(PathBuf::as_path) +} + +fn push_file_edges( + edges: &mut Vec, + source: &Path, + targets: &std::collections::BTreeSet, + kind: EdgeKind, +) { + for target in targets { + if target != source { + edges.push((NodeId::file(source), NodeId::file(target), kind)); + } + } +} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_queues.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_queues.rs new file mode 100644 index 000000000..78cf0a612 --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_queues.rs @@ -0,0 +1,124 @@ +fn emit_queue_edges( + root: &Path, + facts: &LangFactMap, + options: &GraphConfigOptions, + edges: &mut Vec, +) { + let mut workers: std::collections::HashMap> = + std::collections::HashMap::new(); + for file in facts.files.values() { + let Some(cluster) = matching_queue_cluster(root, &file.path, &options.queue_workers, options) + else { + continue; + }; + for job in &file.queue_workers { + workers + .entry(topic_identity(cluster.as_deref(), job)) + .or_default() + .insert(file.path.clone()); + } + } + for file in facts.files.values() { + let Some(cluster) = + matching_queue_cluster(root, &file.path, &options.queue_enqueues, options) + else { + continue; + }; + for job in &file.queue_enqueues { + let identity = topic_identity(cluster.as_deref(), job); + let node = NodeId::queue_job(&file.path, identity.clone()); + edges.push(( + NodeId::file(&file.path), + node.clone(), + EdgeKind::QueueEnqueue, + )); + if let Some(targets) = workers.get(&identity) { + for worker in targets { + edges.push((node.clone(), NodeId::file(worker), EdgeKind::QueueWorker)); + } + } + } + } +} + +fn matching_queue_cluster( + root: &Path, + path: &Path, + globs: &[String], + options: &GraphConfigOptions, +) -> Option> { + if globs.is_empty() { + return None; + } + let rel = path.strip_prefix(root).unwrap_or(path); + let rel_s = rel.to_string_lossy(); + globs.iter().find_map(|glob| { + if !matches_any(&rel_s, std::slice::from_ref(glob)) { + return None; + } + Some(match options.queue_glob_clusters.get(glob) { + Some(cluster) => cluster.clone(), + None => options.queue_cluster.clone(), + }) + }) +} + +fn emit_kafka_edges( + root: &Path, + all_files: &[PathBuf], + options: &GraphConfigOptions, + edges: &mut Vec, +) { + let mut produces = Vec::new(); + let mut consumes = Vec::new(); + for path in all_files { + let enqueue = matching_queue_cluster(root, path, &options.queue_enqueues, options); + let worker = matching_queue_cluster(root, path, &options.queue_workers, options); + if enqueue.is_none() && worker.is_none() { + continue; + } + let Some((prod, cons)) = scan_kafka_file(path) else { + continue; + }; + if let Some(cluster) = enqueue { + produces.push((path.clone(), prod, cluster)); + } + if let Some(cluster) = worker { + consumes.push((path.clone(), cons, cluster)); + } + } + let mut workers: std::collections::HashMap> = + std::collections::HashMap::new(); + for (path, topics, cluster) in &consumes { + for topic in topics { + workers + .entry(topic_identity(cluster.as_deref(), topic)) + .or_default() + .insert(path.clone()); + } + } + for (path, topics, cluster) in produces { + for topic in topics { + let identity = topic_identity(cluster.as_deref(), &topic); + let node = NodeId::queue_job(&path, identity.clone()); + edges.push(( + NodeId::file(&path), + node.clone(), + EdgeKind::QueueEnqueue, + )); + if let Some(targets) = workers.get(&identity) { + for worker in targets { + edges.push((node.clone(), NodeId::file(worker), EdgeKind::QueueWorker)); + } + } + } + } +} + +fn matches_any(rel: &str, globs: &[String]) -> bool { + globs.iter().any(|glob| { + globset::Glob::new(glob) + .ok() + .is_some_and(|g| g.compile_matcher().is_match(rel)) + }) +} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/files_config.rs b/crates/no-mistakes/src/codebase/dependencies/graph/files_config.rs index ca484b92a..a035f166b 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/files_config.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/files_config.rs @@ -26,7 +26,7 @@ pub fn ts_fact_plan_and_context_for_plan_with_config( ) } -#[derive(Clone)] +#[derive(Clone, Default)] struct GraphConfigOptions { route: crate::codebase::config::RouteOptions, queue: crate::codebase::config::QueueOptions, @@ -38,6 +38,16 @@ struct GraphConfigOptions { queue_project_factory_names: Vec, dotnet_projects: Vec, swift_packages: Vec, + python_packages: Vec, + go_modules: Vec, + rust_packages: Vec, + rails_apps: Vec, + php_apps: Vec, + php_framework: Option, + queue_enqueues: Vec, + queue_workers: Vec, + queue_cluster: Option, + queue_glob_clusters: HashMap>, terraform: crate::config::v2::schema::TerraformConfig, ci: crate::config::v2::schema::CiConfig, } @@ -108,11 +118,74 @@ fn graph_config_options_from_loaded_with_test_filter( queue_project_factory_names: v2_config.queues.factories.clone(), dotnet_projects: crate::codebase::dotnet::configured_projects(root, &v2_config.tests.dotnet), swift_packages: v2_config.tests.swift.packages.clone(), + python_packages: v2_config.tests.python.packages.clone(), + go_modules: v2_config.tests.go.modules.clone(), + rust_packages: v2_config.tests.rust.packages.clone(), + rails_apps: v2_config.tests.rails.apps.clone(), + php_apps: v2_config.tests.php.apps.clone(), + php_framework: v2_config.tests.php.framework.clone(), + queue_enqueues: flatten_queue_globs(v2_config, prefixed_queue_globs_enqueues), + queue_workers: flatten_queue_globs(v2_config, prefixed_queue_globs_workers), + queue_cluster: v2_config + .projects + .values() + .find_map(|project| project.queues.cluster.clone()), + queue_glob_clusters: flatten_queue_glob_clusters(v2_config), terraform: v2_config.infra.terraform.clone(), ci: v2_config.ci.clone(), } } +fn prefixed_queue_globs_enqueues(project: &crate::config::v2::schema::Project) -> Vec { + prefix_project_globs(project.root.as_deref(), &project.queues.enqueues) +} + +fn prefixed_queue_globs_workers(project: &crate::config::v2::schema::Project) -> Vec { + prefix_project_globs(project.root.as_deref(), &project.queues.workers) +} + +fn flatten_queue_globs( + v2_config: &crate::config::v2::NoMistakesConfig, + select: fn(&crate::config::v2::schema::Project) -> Vec, +) -> Vec { + v2_config.projects.values().flat_map(select).collect() +} + +fn flatten_queue_glob_clusters( + v2_config: &crate::config::v2::NoMistakesConfig, +) -> HashMap> { + let mut clusters = HashMap::new(); + for project in v2_config.projects.values() { + let cluster = project.queues.cluster.clone(); + for glob in prefixed_queue_globs_enqueues(project) + .into_iter() + .chain(prefixed_queue_globs_workers(project)) + { + clusters.entry(glob).or_insert_with(|| cluster.clone()); + } + } + clusters +} + +fn prefix_project_globs(root: Option<&str>, globs: &[String]) -> Vec { + let prefix = root + .map(str::trim) + .filter(|root| !root.is_empty() && *root != "."); + globs + .iter() + .map(|glob| match prefix { + Some(root) if glob_has_root_prefix(glob, root) => glob.clone(), + Some(root) => format!("{}/{glob}", root.trim_end_matches('/')), + None => glob.clone(), + }) + .collect() +} + +fn glob_has_root_prefix(glob: &str, root: &str) -> bool { + let root = root.trim_end_matches('/'); + glob == root || glob.starts_with(&format!("{root}/")) +} + fn dedup_rewrites( mut rewrites: Vec, ) -> Vec { diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs b/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs index c72a5f082..686d5db9f 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs @@ -17,6 +17,7 @@ use crate::edge_index::{CanonicalEdge, EdgeIndex}; include!("types.rs"); include!("build_plan.rs"); +include!("build_plan_facts.rs"); include!("canonical.rs"); include!("graph_files.rs"); include!("files_config.rs"); @@ -37,6 +38,9 @@ include!("builder_remaining_edges.rs"); include!("builder_remaining_edges_independent.rs"); include!("builder_remaining_edges_fact_domain.rs"); include!("builder_helpers.rs"); +include!("edge_lang_frontends.rs"); +include!("edge_lang_domains.rs"); +include!("edge_lang_queues.rs"); include!("builder_entrypoints.rs"); include!("methods_lazy_vitest_setup.rs"); include!("methods_lazy.rs"); diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_edges.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_edges.rs index 293c8fe6d..eda0f69b2 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_edges.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_edges.rs @@ -331,173 +331,3 @@ fn route_and_http_fact_context_keep_separate_backend_matchers() { })); } -#[test] -fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { - let empty = crate::codebase::ts_resolver::normalize_path(&fixture("graph-empty-route-config")); - let empty_options = graph_config_options(&empty).unwrap(); - assert!(resolved_backend_prefixes(&empty_options).is_empty()); - assert!(route_backend_prefixes(&empty_options).is_empty()); - - let plan = GraphBuildPlan { - routes: true, - queues: true, - http: true, - ..GraphBuildPlan::default() - }; - let context = ts_fact_context_from_options(&empty, plan, Some(&empty_options)); - assert!(context.backend_route_extractors.is_empty()); - assert!(context.queue_factory_glob.is_none()); - assert!(context.http_prefixes.is_empty()); - let context_without_options = ts_fact_context_from_options(&empty, plan, None); - assert!(context_without_options.backend_route_extractors.is_empty()); - - let mut manual_context = TsFactContext::new(&empty); - add_backend_route_extractor( - &mut manual_context, - None, - Some("backend/**/*.mts".to_string()), - ); - add_backend_route_extractor(&mut manual_context, Some("app".to_string()), None); - add_backend_route_extractor( - &mut manual_context, - Some("app".to_string()), - Some("[".to_string()), - ); - assert!(manual_context.backend_route_extractors.is_empty()); - - assert!(compile_graph_glob("").is_none()); - assert!(compile_graph_glob("[").is_none()); - assert!(compile_graph_glob("backend/**/*.mts") - .expect("valid graph glob should compile") - .is_match(Path::new("backend/api/users.mts"))); - - let explicit = - crate::codebase::ts_resolver::normalize_path(&fixture("graph-default-route-config")); - let explicit_options = graph_config_options(&explicit).unwrap(); - let explicit_route_prefixes = vec![ - "/api/".to_string(), - "/prefix/".to_string(), - "/crawler/".to_string(), - ]; - assert_eq!( - resolved_backend_prefixes(&explicit_options), - vec!["/api/".to_string()] - ); - assert_eq!( - route_backend_prefixes(&explicit_options), - explicit_route_prefixes - ); - - let missing_register_options = GraphConfigOptions { - route: crate::codebase::config::RouteOptions::default(), - queue: crate::codebase::config::QueueOptions::default(), - http_route: crate::codebase::config::HttpRouteOptions { - backend_pattern: "backend/**/*.mts".to_string(), - register_object: String::new(), - }, - http_call: crate::codebase::config::HttpCallOptions { - backend_prefixes: vec!["/api/".to_string()], - }, - project_route_globset: None, - test_filter: None, - rewrites: vec![], - queue_project_factory_names: vec![], - dotnet_projects: vec![], - swift_packages: vec![], - terraform: Default::default(), - ci: crate::config::v2::schema::CiConfig::default(), - }; - let invalid_glob_options = GraphConfigOptions { - route: crate::codebase::config::RouteOptions::default(), - queue: crate::codebase::config::QueueOptions::default(), - http_route: crate::codebase::config::HttpRouteOptions { - backend_pattern: "[".to_string(), - register_object: "app".to_string(), - }, - http_call: crate::codebase::config::HttpCallOptions { - backend_prefixes: vec!["/api/".to_string()], - }, - project_route_globset: None, - test_filter: None, - rewrites: vec![], - queue_project_factory_names: vec![], - dotnet_projects: vec![], - swift_packages: vec![], - terraform: Default::default(), - ci: crate::config::v2::schema::CiConfig::default(), - }; - let tsconfig = - crate::codebase::ts_resolver::load_tsconfig(&explicit.join("tsconfig.json")).unwrap(); - let resolver = crate::codebase::ts_resolver::ImportResolver::new(&tsconfig); - assert!(collect_route_edges( - &explicit, - &tsconfig, - &resolver, - &[], - None, - Some(&explicit_options), - ) - .is_empty()); - assert!(collect_http_call_edges( - &explicit, - &tsconfig, - None, - &[], - &[], - &[], - Some(&explicit_options), - ) - .is_empty()); - - let queue_options = GraphConfigOptions { - route: crate::codebase::config::RouteOptions::default(), - queue: crate::codebase::config::QueueOptions { - queue_pattern: "src/**/*.ts".to_string(), - factory_specifier: "@app/queue".to_string(), - factory_function: "createQueue".to_string(), - }, - http_route: crate::codebase::config::HttpRouteOptions::default(), - http_call: crate::codebase::config::HttpCallOptions::default(), - project_route_globset: None, - test_filter: None, - rewrites: vec![], - queue_project_factory_names: vec![], - dotnet_projects: vec![], - swift_packages: vec![], - terraform: Default::default(), - ci: crate::config::v2::schema::CiConfig::default(), - }; - let mut forward = EdgeMap::new(); - let mut reverse = EdgeMap::new(); - test_support::add_queue_edges( - &explicit, - &resolver, - &[], - None, - Some(&queue_options), - &mut forward, - &mut reverse, - ); - assert!(forward.is_empty()); - - assert!(collect_http_call_edges( - &explicit, - &tsconfig, - None, - &[], - &[], - &[], - Some(&missing_register_options), - ) - .is_empty()); - assert!(collect_http_call_edges( - &explicit, - &tsconfig, - None, - &[], - &[], - &[], - Some(&invalid_glob_options), - ) - .is_empty()); -} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_edges_helpers.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_edges_helpers.rs new file mode 100644 index 000000000..14dd4ccd5 --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_edges_helpers.rs @@ -0,0 +1,202 @@ +use super::*; + +#[test] +fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { + let empty = crate::codebase::ts_resolver::normalize_path(&fixture("graph-empty-route-config")); + let empty_options = graph_config_options(&empty).unwrap(); + assert!(resolved_backend_prefixes(&empty_options).is_empty()); + assert!(route_backend_prefixes(&empty_options).is_empty()); + + let plan = GraphBuildPlan { + routes: true, + queues: true, + http: true, + ..GraphBuildPlan::default() + }; + let context = ts_fact_context_from_options(&empty, plan, Some(&empty_options)); + assert!(context.backend_route_extractors.is_empty()); + assert!(context.queue_factory_glob.is_none()); + assert!(context.http_prefixes.is_empty()); + let context_without_options = ts_fact_context_from_options(&empty, plan, None); + assert!(context_without_options.backend_route_extractors.is_empty()); + + let mut manual_context = TsFactContext::new(&empty); + add_backend_route_extractor( + &mut manual_context, + None, + Some("backend/**/*.mts".to_string()), + ); + add_backend_route_extractor(&mut manual_context, Some("app".to_string()), None); + add_backend_route_extractor( + &mut manual_context, + Some("app".to_string()), + Some("[".to_string()), + ); + assert!(manual_context.backend_route_extractors.is_empty()); + + assert!(compile_graph_glob("").is_none()); + assert!(compile_graph_glob("[").is_none()); + assert!(compile_graph_glob("backend/**/*.mts") + .expect("valid graph glob should compile") + .is_match(Path::new("backend/api/users.mts"))); + + let explicit = + crate::codebase::ts_resolver::normalize_path(&fixture("graph-default-route-config")); + let explicit_options = graph_config_options(&explicit).unwrap(); + let explicit_route_prefixes = vec![ + "/api/".to_string(), + "/prefix/".to_string(), + "/crawler/".to_string(), + ]; + assert_eq!( + resolved_backend_prefixes(&explicit_options), + vec!["/api/".to_string()] + ); + assert_eq!( + route_backend_prefixes(&explicit_options), + explicit_route_prefixes + ); + + let missing_register_options = GraphConfigOptions { + route: crate::codebase::config::RouteOptions::default(), + queue: crate::codebase::config::QueueOptions::default(), + http_route: crate::codebase::config::HttpRouteOptions { + backend_pattern: "backend/**/*.mts".to_string(), + register_object: String::new(), + }, + http_call: crate::codebase::config::HttpCallOptions { + backend_prefixes: vec!["/api/".to_string()], + }, + project_route_globset: None, + test_filter: None, + rewrites: vec![], + queue_project_factory_names: vec![], + dotnet_projects: vec![], + swift_packages: vec![], + python_packages: vec![], + go_modules: vec![], + rust_packages: vec![], + rails_apps: vec![], + php_apps: vec![], + php_framework: None, + queue_enqueues: vec![], + queue_workers: vec![], + queue_cluster: None, + queue_glob_clusters: HashMap::new(), + terraform: Default::default(), + ci: crate::config::v2::schema::CiConfig::default(), + }; + let invalid_glob_options = GraphConfigOptions { + route: crate::codebase::config::RouteOptions::default(), + queue: crate::codebase::config::QueueOptions::default(), + http_route: crate::codebase::config::HttpRouteOptions { + backend_pattern: "[".to_string(), + register_object: "app".to_string(), + }, + http_call: crate::codebase::config::HttpCallOptions { + backend_prefixes: vec!["/api/".to_string()], + }, + project_route_globset: None, + test_filter: None, + rewrites: vec![], + queue_project_factory_names: vec![], + dotnet_projects: vec![], + swift_packages: vec![], + python_packages: vec![], + go_modules: vec![], + rust_packages: vec![], + rails_apps: vec![], + php_apps: vec![], + php_framework: None, + queue_enqueues: vec![], + queue_workers: vec![], + queue_cluster: None, + queue_glob_clusters: HashMap::new(), + terraform: Default::default(), + ci: crate::config::v2::schema::CiConfig::default(), + }; + let tsconfig = + crate::codebase::ts_resolver::load_tsconfig(&explicit.join("tsconfig.json")).unwrap(); + let resolver = crate::codebase::ts_resolver::ImportResolver::new(&tsconfig); + assert!(collect_route_edges( + &explicit, + &tsconfig, + &resolver, + &[], + None, + Some(&explicit_options), + ) + .is_empty()); + assert!(collect_http_call_edges( + &explicit, + &tsconfig, + None, + &[], + &[], + &[], + Some(&explicit_options), + ) + .is_empty()); + + let queue_options = GraphConfigOptions { + route: crate::codebase::config::RouteOptions::default(), + queue: crate::codebase::config::QueueOptions { + queue_pattern: "src/**/*.ts".to_string(), + factory_specifier: "@app/queue".to_string(), + factory_function: "createQueue".to_string(), + }, + http_route: crate::codebase::config::HttpRouteOptions::default(), + http_call: crate::codebase::config::HttpCallOptions::default(), + project_route_globset: None, + test_filter: None, + rewrites: vec![], + queue_project_factory_names: vec![], + dotnet_projects: vec![], + swift_packages: vec![], + python_packages: vec![], + go_modules: vec![], + rust_packages: vec![], + rails_apps: vec![], + php_apps: vec![], + php_framework: None, + queue_enqueues: vec![], + queue_workers: vec![], + queue_cluster: None, + queue_glob_clusters: HashMap::new(), + terraform: Default::default(), + ci: crate::config::v2::schema::CiConfig::default(), + }; + let mut forward = EdgeMap::new(); + let mut reverse = EdgeMap::new(); + test_support::add_queue_edges( + &explicit, + &resolver, + &[], + None, + Some(&queue_options), + &mut forward, + &mut reverse, + ); + assert!(forward.is_empty()); + + assert!(collect_http_call_edges( + &explicit, + &tsconfig, + None, + &[], + &[], + &[], + Some(&missing_register_options), + ) + .is_empty()); + assert!(collect_http_call_edges( + &explicit, + &tsconfig, + None, + &[], + &[], + &[], + Some(&invalid_glob_options), + ) + .is_empty()); +} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_fact_plan.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_fact_plan.rs index 791735d3f..f07619a4b 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_fact_plan.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_fact_plan.rs @@ -40,6 +40,16 @@ fn effective_fact_plan_skips_config_dependent_domains_without_required_config() queue_project_factory_names: vec!["createQueue".to_string()], dotnet_projects: vec![], swift_packages: vec![], + python_packages: vec![], + go_modules: vec![], + rust_packages: vec![], + rails_apps: vec![], + php_apps: vec![], + php_framework: None, + queue_enqueues: vec![], + queue_workers: vec![], + queue_cluster: None, + queue_glob_clusters: HashMap::new(), terraform: Default::default(), ci: crate::config::v2::schema::CiConfig::default(), }; diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/core.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/core.rs index 8e23f57f1..b2ff396af 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/core.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/core.rs @@ -2,6 +2,7 @@ use super::test_support::*; use super::*; mod config_edges; +mod config_edges_helpers; mod config_fact_plan; mod config_path_full_graph; mod config_plan; diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/dotnet_edges.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/dotnet_edges.rs index 3837d6e53..f23cd6308 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/dotnet_edges.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/dotnet_edges.rs @@ -12,6 +12,16 @@ fn empty_options() -> GraphConfigOptions { queue_project_factory_names: Vec::new(), dotnet_projects: Vec::new(), swift_packages: Vec::new(), + python_packages: Vec::new(), + go_modules: Vec::new(), + rust_packages: Vec::new(), + rails_apps: Vec::new(), + php_apps: Vec::new(), + php_framework: None, + queue_enqueues: Vec::new(), + queue_workers: Vec::new(), + queue_cluster: None, + queue_glob_clusters: HashMap::new(), terraform: Default::default(), ci: crate::config::v2::schema::CiConfig::default(), } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/extra_symbol_defensive.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/extra_symbol_defensive.rs index 6fa9e891a..5a1b8ac57 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/extra_symbol_defensive.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/extra_symbol_defensive.rs @@ -233,6 +233,16 @@ fn symbol_edge_helpers_cover_defensive_symbol_branches() { queue_project_factory_names: vec![], dotnet_projects: vec![], swift_packages: vec![], + python_packages: vec![], + go_modules: vec![], + rust_packages: vec![], + rails_apps: vec![], + php_apps: vec![], + php_framework: None, + queue_enqueues: vec![], + queue_workers: vec![], + queue_cluster: None, + queue_glob_clusters: HashMap::new(), terraform: Default::default(), ci: crate::config::v2::schema::CiConfig::default(), }; diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs new file mode 100644 index 000000000..5f38b7ace --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs @@ -0,0 +1,199 @@ +fn lang_fixture(name: &str) -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/lang-frontends") + .join(name), + ) +} + +fn lang_files(root: &Path) -> Vec { + let repo = crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."), + ); + crate::codebase::ts_source::discover_visible_paths(&repo) + .into_iter() + .map(|path| { + let absolute = if path.is_absolute() { + path + } else { + repo.join(path) + }; + crate::codebase::ts_resolver::normalize_path(&absolute) + }) + .filter(|path| path.starts_with(root)) + .collect() +} + +fn lang_options() -> GraphConfigOptions { + GraphConfigOptions { + python_packages: vec!["app".into()], + go_modules: vec![".".into(), "worker".into()], + rust_packages: vec![".".into(), "src".into()], + rails_apps: vec![".".into()], + php_apps: vec![".".into()], + php_framework: Some("laravel".into()), + queue_enqueues: vec!["**/*".into()], + queue_workers: vec!["**/*".into()], + queue_cluster: Some("orders".into()), + ..GraphConfigOptions::default() + } +} + +#[test] +fn language_frontend_edges_cover_configured_extractors() { + let options = lang_options(); + let python = lang_fixture("python-celery-django"); + let python_edges = collect_language_frontend_edges(&python, &lang_files(&python), Some(&options)); + assert!(python_edges + .iter() + .any(|(_, _, kind)| *kind == EdgeKind::PythonImport)); + assert!(python_edges + .iter() + .any(|(_, _, kind)| *kind == EdgeKind::QueueEnqueue)); + assert!(python_edges.iter().any(|(from, to, kind)| { + *kind == EdgeKind::RouteRef + && from.as_file().is_some_and(|path| path.ends_with("urls.py")) + && to.as_file().is_some_and(|path| path.ends_with("views.py")) + })); + + let go = lang_fixture("go-asynq"); + let go_edges = collect_language_frontend_edges(&go, &lang_files(&go), Some(&options)); + assert!(go_edges + .iter() + .any(|(_, _, kind)| *kind == EdgeKind::QueueWorker)); + + let rust = lang_fixture("rust-mods"); + let rust_edges = collect_language_frontend_edges(&rust, &lang_files(&rust), Some(&options)); + assert!(rust_edges + .iter() + .any(|(_, _, kind)| *kind == EdgeKind::RustUse || *kind == EdgeKind::RustMod)); + assert!(rust_edges.iter().any(|(from, _, kind)| { + *kind == EdgeKind::RustPackage && from.as_file().is_some_and(|path| path.ends_with("lib.rs")) + })); + assert!(rust_edges.iter().all(|(from, _, kind)| { + *kind != EdgeKind::RustPackage + || from + .as_file() + .is_none_or(|path| !path.ends_with("aaa/mod.rs")) + })); + + let rails = lang_fixture("rails-jobs"); + let rails_edges = collect_language_frontend_edges(&rails, &lang_files(&rails), Some(&options)); + assert!(rails_edges + .iter() + .any(|(_, _, kind)| *kind == EdgeKind::RouteRef)); + + let php = lang_fixture("php-laravel"); + let php_edges = collect_language_frontend_edges(&php, &lang_files(&php), Some(&options)); + assert!(php_edges + .iter() + .any(|(_, _, kind)| *kind == EdgeKind::PhpUse || *kind == EdgeKind::PhpPackage)); + + let kafka = lang_fixture("kafka-topics"); + let kafka_edges = collect_language_frontend_edges(&kafka, &lang_files(&kafka), Some(&options)); + assert!(kafka_edges + .iter() + .any(|(_, _, kind)| *kind == EdgeKind::QueueEnqueue)); + assert!(collect_language_frontend_edges(&kafka, &lang_files(&kafka), None).is_empty()); +} + +#[test] +fn language_frontend_edges_skip_empty_config() { + let root = lang_fixture("python-celery-django"); + let files = lang_files(&root); + assert!(collect_language_frontend_edges(&root, &files, Some(&GraphConfigOptions::default())) + .is_empty()); +} + +#[test] +fn language_frontend_edges_cover_kafka_misses_and_empty_queue_globs() { + let kafka = lang_fixture("kafka-topics"); + let mut files = lang_files(&kafka); + files.push(kafka.join("missing-producer.ts")); + let mut options = lang_options(); + options.queue_enqueues = vec!["[".into(), "producer.ts".into()]; + options.queue_workers = vec!["consumer.ts".into()]; + let edges = collect_language_frontend_edges(&kafka, &files, Some(&options)); + assert!(edges + .iter() + .any(|(_, _, kind)| *kind == EdgeKind::QueueEnqueue)); + + let python = lang_fixture("python-celery-django"); + let no_queues = GraphConfigOptions { + python_packages: vec!["app".into()], + ..GraphConfigOptions::default() + }; + let python_edges = + collect_language_frontend_edges(&python, &lang_files(&python), Some(&no_queues)); + assert!(python_edges + .iter() + .all(|(_, _, kind)| *kind != EdgeKind::QueueEnqueue)); + + let rails = lang_fixture("rails-jobs"); + let rails_edges = collect_language_frontend_edges(&rails, &lang_files(&rails), Some(&lang_options())); + assert!(rails_edges.iter().any(|(from, to, kind)| { + *kind == EdgeKind::RouteRef + && from.as_file().is_some_and(|path| path.ends_with("routes.rb")) + && to + .as_file() + .is_some_and(|path| path.ends_with("admin/users_controller.rb")) + })); +} + +#[test] +fn language_frontend_config_keeps_already_prefixed_queue_globs() { + let root = lang_fixture("queue-prefix"); + let options = graph_config_options(&root).expect("queue-prefix config"); + assert!(options + .queue_enqueues + .iter() + .any(|glob| glob == "backend/app/**/*.py")); + assert!(options + .queue_enqueues + .iter() + .any(|glob| glob == "app/application/**/*.py")); + assert_eq!( + options.queue_glob_clusters.get("backend/app/**/*.py"), + Some(&Some("api".into())) + ); + assert_eq!( + options.queue_glob_clusters.get("app/application/**/*.py"), + Some(&Some("other".into())) + ); + assert_eq!(options.queue_glob_clusters.get("bare/*.py"), Some(&None)); +} + +#[test] +fn language_frontend_edges_scope_routes_and_go_packages() { + let python = lang_fixture("python-celery-django"); + let python_edges = + collect_language_frontend_edges(&python, &lang_files(&python), Some(&lang_options())); + assert!(python_edges.iter().any(|(from, to, kind)| { + *kind == EdgeKind::RouteRef + && from + .as_file() + .is_some_and(|path| path.ends_with("app/urls.py") && !path.ends_with("api/urls.py")) + && to.as_file().is_some_and(|path| path.ends_with("api/urls.py")) + })); + assert!(python_edges.iter().any(|(from, to, kind)| { + *kind == EdgeKind::RouteRef + && from + .as_file() + .is_some_and(|path| path.ends_with("app/urls.py") && !path.ends_with("api/urls.py")) + && to + .as_file() + .is_some_and(|path| path.ends_with("billing/views.py")) + })); + + let go = lang_fixture("go-asynq"); + let go_edges = collect_language_frontend_edges(&go, &lang_files(&go), Some(&lang_options())); + assert!(go_edges.iter().all(|(from, to, kind)| { + *kind != EdgeKind::GoReference + || !from + .as_file() + .is_some_and(|path| path.ends_with("pkg/ping.go")) + || !to + .as_file() + .is_some_and(|path| path.ends_with("mail/user.go")) + })); +} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends_more.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends_more.rs new file mode 100644 index 000000000..1f5230fba --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends_more.rs @@ -0,0 +1,14 @@ +#[test] +fn language_frontend_edges_keep_go_imports_across_modules() { + let go = lang_fixture("go-asynq"); + let mut options = lang_options(); + options.go_modules = vec!["worker".into(), "nested".into()]; + let go_edges = collect_language_frontend_edges(&go, &lang_files(&go), Some(&options)); + assert!(go_edges.iter().any(|(from, to, kind)| { + *kind == EdgeKind::GoImport + && from + .as_file() + .is_some_and(|path| path.ends_with("enqueue.go")) + && to.as_file().is_some_and(|path| path.ends_with("nested/mail.go")) + })); +} diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/mod.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/mod.rs index b6314c7d9..2316e29a1 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/mod.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/mod.rs @@ -63,6 +63,8 @@ include!("build_check_fact_adapters.rs"); include!("compatibility_adapters.rs"); include!("core_resolution.rs"); include!("core.rs"); +include!("language_frontends.rs"); +include!("language_frontends_more.rs"); include!("scoped_universe.rs"); include!("legacy_symbol_channel.rs"); include!("session_resolver_cache.rs"); diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/tests/route_import.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/route_import.rs index 3bca560ae..90227c2b2 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/route_import.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/route_import.rs @@ -96,6 +96,26 @@ fn graph_build_plan_from_allowed_covers_each_edge_family() { let workspace_type_only: HashSet<_> = [EdgeKind::WorkspaceTypeImport].into(); assert!(GraphBuildPlan::from_allowed(Some(&workspace_type_only)).workspace); + + for kind in [ + EdgeKind::PythonImport, + EdgeKind::PythonReference, + EdgeKind::GoImport, + EdgeKind::GoReference, + EdgeKind::RustUse, + EdgeKind::RustMod, + EdgeKind::RustPackage, + EdgeKind::RubyRequire, + EdgeKind::RubyReference, + EdgeKind::PhpUse, + EdgeKind::PhpPackage, + ] { + let allowed: HashSet<_> = [kind].into(); + assert!( + GraphBuildPlan::from_allowed(Some(&allowed)).language_frontends, + "{kind:?}" + ); + } } #[test] diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges.rs b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges.rs index 20b9541c2..b1e20b640 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges.rs @@ -69,6 +69,17 @@ pub enum EdgeKind { TerraformModuleRef, /// Terraform/OpenTofu output consumption reference. TerraformOutputRef, + PythonImport, + PythonReference, + GoImport, + GoReference, + RustUse, + RustMod, + RustPackage, + RubyRequire, + RubyReference, + PhpUse, + PhpPackage, /// Workflow file → virtual job node. WorkflowJob, /// Virtual workflow job → virtual workflow step node. @@ -156,6 +167,17 @@ impl EdgeKind { Self::TerraformReference => "terraform-ref", Self::TerraformModuleRef => "terraform-module", Self::TerraformOutputRef => "terraform-output", + Self::PythonImport => "python-import", + Self::PythonReference => "python-ref", + Self::GoImport => "go-import", + Self::GoReference => "go-ref", + Self::RustUse => "rust-use", + Self::RustMod => "rust-mod", + Self::RustPackage => "rust-package", + Self::RubyRequire => "ruby-require", + Self::RubyReference => "ruby-ref", + Self::PhpUse => "php-use", + Self::PhpPackage => "php-package", _ => unreachable!("core edge kinds are handled before domain rendering"), } } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort.rs b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort.rs index 91ade56e7..57ffbe274 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort.rs @@ -33,7 +33,18 @@ pub(super) const fn key(kind: EdgeKind) -> (u8, u8) { | EdgeKind::DotnetProjectDependency | EdgeKind::TerraformReference | EdgeKind::TerraformModuleRef - | EdgeKind::TerraformOutputRef => language(kind), + | EdgeKind::TerraformOutputRef + | EdgeKind::PythonImport + | EdgeKind::PythonReference + | EdgeKind::GoImport + | EdgeKind::GoReference + | EdgeKind::RustUse + | EdgeKind::RustMod + | EdgeKind::RustPackage + | EdgeKind::RubyRequire + | EdgeKind::RubyReference + | EdgeKind::PhpUse + | EdgeKind::PhpPackage => language(kind), EdgeKind::WorkflowJob | EdgeKind::WorkflowStep | EdgeKind::WorkflowNeeds @@ -90,6 +101,17 @@ const fn language(kind: EdgeKind) -> (u8, u8) { EdgeKind::TerraformReference => (27, 0), EdgeKind::TerraformModuleRef => (28, 0), EdgeKind::TerraformOutputRef => (29, 0), + EdgeKind::PythonImport => (39, 0), + EdgeKind::PythonReference => (40, 0), + EdgeKind::GoImport => (41, 0), + EdgeKind::GoReference => (42, 0), + EdgeKind::RustUse => (43, 0), + EdgeKind::RustMod => (44, 0), + EdgeKind::RustPackage => (45, 0), + EdgeKind::RubyRequire => (46, 0), + EdgeKind::RubyReference => (47, 0), + EdgeKind::PhpUse => (48, 0), + EdgeKind::PhpPackage => (49, 0), _ => panic!("language edge group is exhaustive"), } } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort/tests.rs b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort/tests.rs index 52854edb8..94c19b203 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort/tests.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/types_edges_sort/tests.rs @@ -13,6 +13,17 @@ fn public_sort_key_delegates_all_groups_and_non_vitest_detail_is_none() { EdgeKind::VitestSetup(VitestSetupField::GlobalSetup).sort_key(), (36, 1) ); + assert_eq!(EdgeKind::PythonImport.sort_key(), (39, 0)); + assert_eq!(EdgeKind::PythonReference.sort_key(), (40, 0)); + assert_eq!(EdgeKind::GoImport.sort_key(), (41, 0)); + assert_eq!(EdgeKind::GoReference.sort_key(), (42, 0)); + assert_eq!(EdgeKind::RustUse.sort_key(), (43, 0)); + assert_eq!(EdgeKind::RustMod.sort_key(), (44, 0)); + assert_eq!(EdgeKind::RustPackage.sort_key(), (45, 0)); + assert_eq!(EdgeKind::RubyRequire.sort_key(), (46, 0)); + assert_eq!(EdgeKind::RubyReference.sort_key(), (47, 0)); + assert_eq!(EdgeKind::PhpUse.sort_key(), (48, 0)); + assert_eq!(EdgeKind::PhpPackage.sort_key(), (49, 0)); } #[test] diff --git a/crates/no-mistakes/src/codebase/dependencies/output/tests/edge_kinds.rs b/crates/no-mistakes/src/codebase/dependencies/output/tests/edge_kinds.rs index c0bc0cbcc..a913b1134 100644 --- a/crates/no-mistakes/src/codebase/dependencies/output/tests/edge_kinds.rs +++ b/crates/no-mistakes/src/codebase/dependencies/output/tests/edge_kinds.rs @@ -81,6 +81,17 @@ fn serialized_edge_kinds_are_documented() { EdgeKind::TerraformReference, EdgeKind::TerraformModuleRef, EdgeKind::TerraformOutputRef, + EdgeKind::PythonImport, + EdgeKind::PythonReference, + EdgeKind::GoImport, + EdgeKind::GoReference, + EdgeKind::RustUse, + EdgeKind::RustMod, + EdgeKind::RustPackage, + EdgeKind::RubyRequire, + EdgeKind::RubyReference, + EdgeKind::PhpUse, + EdgeKind::PhpPackage, EdgeKind::WorkflowJob, EdgeKind::WorkflowStep, EdgeKind::WorkflowNeeds, @@ -122,6 +133,17 @@ fn serialized_edge_kinds_are_documented() { EdgeKind::TerraformReference => {} EdgeKind::TerraformModuleRef => {} EdgeKind::TerraformOutputRef => {} + EdgeKind::PythonImport => {} + EdgeKind::PythonReference => {} + EdgeKind::GoImport => {} + EdgeKind::GoReference => {} + EdgeKind::RustUse => {} + EdgeKind::RustMod => {} + EdgeKind::RustPackage => {} + EdgeKind::RubyRequire => {} + EdgeKind::RubyReference => {} + EdgeKind::PhpUse => {} + EdgeKind::PhpPackage => {} EdgeKind::WorkflowJob => {} EdgeKind::WorkflowStep => {} EdgeKind::WorkflowNeeds => {} diff --git a/crates/no-mistakes/src/codebase/dependencies/tests/args.rs b/crates/no-mistakes/src/codebase/dependencies/tests/args.rs index 75c0d6e90..8d69c376d 100644 --- a/crates/no-mistakes/src/codebase/dependencies/tests/args.rs +++ b/crates/no-mistakes/src/codebase/dependencies/tests/args.rs @@ -223,6 +223,43 @@ fn cargo_globs_include_tests_dir() { assert!(globs.iter().any(|g| g.contains("tests/**/*.rs"))); } +#[test] +fn python_relationship_enables_language_frontend_plan() { + let allowed = crate::codebase::dependencies::relationship_filter(&[ + crate::codebase::dependencies::RelationshipArg::Python, + ]) + .expect("python relationship"); + let plan = crate::codebase::dependencies::graph::GraphBuildPlan::from_allowed(Some(&allowed)); + assert!(plan.language_frontends); + assert!(!plan.imports); + for relationship in [ + crate::codebase::dependencies::RelationshipArg::Go, + crate::codebase::dependencies::RelationshipArg::Rust, + crate::codebase::dependencies::RelationshipArg::Ruby, + crate::codebase::dependencies::RelationshipArg::Php, + ] { + let allowed = crate::codebase::dependencies::relationship_filter(&[relationship]) + .expect("language relationship"); + let plan = + crate::codebase::dependencies::graph::GraphBuildPlan::from_allowed(Some(&allowed)); + assert!(plan.language_frontends, "{relationship:?}"); + assert_eq!(relationship.as_str(), format!("{relationship:?}").to_ascii_lowercase()); + } + assert_eq!( + crate::codebase::dependencies::RelationshipArg::Python.as_str(), + "python" + ); +} + +#[test] +fn language_frontend_globs_are_explicit() { + assert!(test_globs("python").iter().any(|glob| glob.contains("test_*.py"))); + assert!(test_globs("python").iter().any(|glob| glob.ends_with("tests.py"))); + assert!(test_globs("go").iter().any(|glob| glob.contains("*_test.go"))); + assert!(test_globs("rails").iter().any(|glob| glob.contains("_spec.rb"))); + assert!(test_globs("php").iter().any(|glob| glob.contains("Test.php"))); +} + #[test] fn unknown_framework_returns_empty() { let globs = test_globs("unknown"); diff --git a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs new file mode 100644 index 000000000..f57975e0d --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs @@ -0,0 +1,151 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Default)] +pub(crate) struct LangFileFacts { + pub path: PathBuf, + pub package: Option, + pub module: Option, + pub imports: Vec, + pub declarations: Vec, + pub references: Vec, + pub route_handlers: Vec<(String, String)>, + pub queue_enqueues: Vec, + pub queue_workers: Vec, + pub mods: Vec, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct LangFactMap { + pub files: BTreeMap, + pub declarations: HashMap>, + pub files_by_module: HashMap>, + pub files_by_package: HashMap>, +} + +impl LangFactMap { + pub(crate) fn index_file(&mut self, file: LangFileFacts) { + if let Some(package) = &file.package { + self.files_by_package + .entry(package.clone()) + .or_default() + .insert(file.path.clone()); + } + if let Some(module) = &file.module { + if !file + .path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with("_test.go")) + { + self.files_by_module + .entry(module.clone()) + .or_default() + .insert(file.path.clone()); + } + } + for declaration in &file.declarations { + self.declarations + .entry(declaration.clone()) + .or_default() + .insert(file.path.clone()); + if file.path.extension().and_then(|ext| ext.to_str()) == Some("php") { + self.files_by_module + .entry(declaration.clone()) + .or_default() + .insert(file.path.clone()); + } + } + self.files.insert(file.path.clone(), file); + } +} + +pub(crate) fn configured_roots(root: &Path, entries: &[String]) -> Vec { + entries + .iter() + .map(|entry| { + crate::codebase::ts_resolver::normalize_path(&root.join(entry.trim_end_matches('/'))) + }) + .collect() +} + +pub(crate) fn index_parsed_files(mut files: Vec) -> LangFactMap { + files.sort_by(|left, right| left.path.cmp(&right.path)); + let mut facts = LangFactMap::default(); + for file in files { + facts.index_file(file); + } + facts +} + +pub(crate) fn collect_files_parallel(files: Vec, parse: F) -> LangFactMap +where + F: Fn(&Path) -> Option + Sync, +{ + use rayon::prelude::*; + let parsed: Vec = files.par_iter().filter_map(|path| parse(path)).collect(); + index_parsed_files(parsed) +} + +pub(crate) fn files_under( + all_files: &[PathBuf], + roots: &[PathBuf], + extension: &str, +) -> Vec { + if roots.is_empty() { + return Vec::new(); + } + all_files + .iter() + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some(extension)) + .filter(|path| roots.iter().any(|root| path.starts_with(root))) + .cloned() + .collect() +} + +pub(crate) fn module_from_path(package_root: &Path, path: &Path) -> Option { + module_from_path_inner(package_root, path, false) +} + +pub(crate) fn rust_module_from_path(package_root: &Path, path: &Path) -> Option { + module_from_path_inner(package_root, path, true) +} + +fn module_from_path_inner(package_root: &Path, path: &Path, rust: bool) -> Option { + let rel = path.strip_prefix(package_root).ok()?; + let mut parts: Vec = rel + .iter() + .map(|part| part.to_string_lossy().into_owned()) + .collect(); + if let Some(last) = parts.last_mut() { + if let Some((stem, _)) = last.rsplit_once('.') { + *last = stem.to_string(); + } + } + if parts.last().is_some_and(|part| part == "__init__") { + parts.pop(); + } + if rust && parts.last().is_some_and(|part| part == "mod") { + parts.pop(); + } + if rust && parts.len() == 1 && matches!(parts[0].as_str(), "lib" | "main") { + return None; + } + if parts.is_empty() { + return None; + } + Some(parts.join(".")) +} + +pub(crate) fn owning_package<'a>( + path: &'a Path, + roots: &'a [PathBuf], + names: &'a [String], +) -> Option { + roots + .iter() + .zip(names.iter()) + .filter(|(root, _)| path.starts_with(root)) + .max_by_key(|(root, _)| root.components().count()) + .map(|(_, name)| name.trim_end_matches('/').to_string()) +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs new file mode 100644 index 000000000..f92b7be70 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -0,0 +1,200 @@ +use super::facts::{configured_roots, files_under, owning_package, LangFactMap, LangFileFacts}; +use super::strip::strip_comments_keep_strings; +use regex::Regex; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +pub(crate) fn collect_go_facts( + root: &Path, + all_files: &[PathBuf], + modules: &[String], +) -> LangFactMap { + let roots = configured_roots(root, modules); + let files = exclude_nested_go_modules(files_under(all_files, &roots, "go"), &roots, all_files); + let manifests: HashMap> = roots + .iter() + .map(|module_root| (module_root.clone(), read_go_module(module_root))) + .collect(); + super::facts::collect_files_parallel(files, |path| { + parse_go_file(path, &roots, modules, &manifests) + }) +} + +fn parse_go_file( + path: &Path, + roots: &[PathBuf], + modules: &[String], + manifests: &HashMap>, +) -> Option { + let source = std::fs::read_to_string(path).ok()?; + let text = strip_comments_keep_strings(&source); + let symbols = super::strip::mask_strings(&text); + let package = owning_package(path, roots, modules); + let module = go_import_path(path, roots, manifests); + Some(LangFileFacts { + path: path.to_path_buf(), + package, + module, + imports: extract_go_imports(&text), + declarations: extract_go_declarations(&symbols), + references: extract_named(&symbols, go_ref_re()), + route_handlers: Vec::new(), + queue_enqueues: extract_named(&text, asynq_task_re()), + queue_workers: extract_named(&text, asynq_handle_re()), + mods: Vec::new(), + }) +} + +fn exclude_nested_go_modules( + files: Vec, + roots: &[PathBuf], + all_files: &[PathBuf], +) -> Vec { + let go_mod_dirs: Vec = all_files + .iter() + .filter(|path| path.file_name().and_then(|name| name.to_str()) == Some("go.mod")) + .filter_map(|path| path.parent().map(Path::to_path_buf)) + .collect(); + files + .into_iter() + .filter(|path| { + let Some(owner) = roots + .iter() + .filter(|root| path.starts_with(root)) + .max_by_key(|root| root.components().count()) + else { + return false; + }; + !go_mod_dirs.iter().any(|dir| { + path.starts_with(dir) + && dir.starts_with(owner) + && dir != owner + && !roots.iter().any(|root| root == dir) + }) + }) + .collect() +} + +fn read_go_module(root: &Path) -> Option { + std::fs::read_to_string(root.join("go.mod")) + .ok() + .and_then(|source| { + source.lines().find_map(|line| { + line.strip_prefix("module ") + .map(|value| value.split("//").next().unwrap_or(value).trim().to_string()) + }) + }) +} + +fn go_import_path( + path: &Path, + roots: &[PathBuf], + manifests: &HashMap>, +) -> Option { + let root = roots + .iter() + .filter(|candidate| path.starts_with(candidate)) + .max_by_key(|candidate| candidate.components().count())?; + let module = manifests.get(root).cloned().flatten(); + let rel = path.parent()?.strip_prefix(root).ok()?; + let suffix = rel.to_string_lossy().replace('\\', "/"); + match (module, suffix.as_str()) { + (Some(module), "") => Some(module), + (Some(module), suffix) => Some(format!("{module}/{suffix}")), + (None, "") => root + .file_name() + .map(|name| name.to_string_lossy().into_owned()), + (None, suffix) => Some(suffix.to_string()), + } +} + +fn extract_go_imports(source: &str) -> Vec { + let mut imports = extract_named(source, go_single_import_re()); + for block in go_import_block_re().captures_iter(source) { + imports.extend(extract_named( + block.get(1).map(|m| m.as_str()).unwrap_or(""), + go_quoted_re(), + )); + } + imports.sort(); + imports.dedup(); + imports +} + +fn extract_go_declarations(source: &str) -> Vec { + let mut names = extract_named(source, go_decl_re()); + for cap in go_group_decl_re().captures_iter(source) { + names.extend(extract_named( + cap.get(1).map(|m| m.as_str()).unwrap_or(""), + go_exported_ident_re(), + )); + } + names.sort(); + names.dedup(); + names +} + +fn extract_named(source: &str, re: &Regex) -> Vec { + let mut values: Vec = re + .captures_iter(source) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) + .collect(); + values.sort(); + values.dedup(); + values +} + +fn go_single_import_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"(?m)^\s*import\s+(?:(?:[_A-Za-z][\w.]*|\.)\s+)?"([^"]+)""#).expect("import") + }) +} + +fn go_import_block_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?s)import\s*\((.*?)\)").expect("block")) +} + +fn go_quoted_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r#""([^"]+)""#).expect("quoted")) +} + +fn go_decl_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"(?m)^\s*(?:func\s+(?:\([^)]+\)\s+)?|type\s+|var\s+|const\s+)([A-Z][A-Za-z0-9_]*)", + ) + .expect("func") + }) +} + +fn go_group_decl_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*(?:const|var|type)\s*\((?s)(.*?)\)").expect("group decl") + }) +} + +fn go_exported_ident_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?m)^\s*([A-Z][A-Za-z0-9_]*)").expect("exported")) +} + +fn go_ref_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\b([A-Z][A-Za-z0-9_]*)\b").expect("ref")) +} + +fn asynq_task_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r#"asynq\.NewTask\(\s*"([^"]+)""#).expect("task")) +} + +fn asynq_handle_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r#"HandleFunc\(\s*"([^"]+)""#).expect("handle")) +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs new file mode 100644 index 000000000..1cdd5c1c8 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs @@ -0,0 +1,77 @@ +use regex::Regex; +use std::path::Path; +use std::sync::OnceLock; + +pub(crate) fn extract_kafka_topics(source: &str) -> (Vec, Vec) { + let source = super::strip::strip_comments_keep_strings(source); + let produces = extract_named(&source, kafka_produce_re()); + let consumes = extract_kafka_consumes(&source); + (produces, consumes) +} + +pub(crate) fn topic_identity(cluster: Option<&str>, topic: &str) -> String { + match cluster.filter(|value| !value.is_empty()) { + Some(cluster) => format!("{cluster}:{topic}"), + None => topic.to_string(), + } +} + +pub(crate) fn scan_file(path: &Path) -> Option<(Vec, Vec)> { + let source = std::fs::read_to_string(path).ok()?; + let text = super::strip::strip_comments_keep_strings(&source); + Some(extract_kafka_topics(&text)) +} + +fn extract_named(source: &str, re: &Regex) -> Vec { + let mut values: Vec = re + .captures_iter(source) + .filter_map(|cap| { + cap.iter() + .skip(1) + .flatten() + .map(|m| m.as_str().to_string()) + .next() + }) + .collect(); + values.sort(); + values.dedup(); + values +} + +fn extract_kafka_consumes(source: &str) -> Vec { + let mut values = extract_named(source, kafka_consume_re()); + for cap in kafka_consume_array_re().captures_iter(source) { + values.extend(extract_named( + cap.get(1).map(|m| m.as_str()).unwrap_or(""), + kafka_quoted_re(), + )); + } + values.sort(); + values.dedup(); + values +} + +fn kafka_produce_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"(?:\.|#)send\(\s*(?:\{[^}]*topic\s*:\s*["']([^"']+)["']|["']([^"']+)["'])"#) + .expect("produce") + }) +} + +fn kafka_consume_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"subscribe\(\s*\{[^}]*topic\s*:\s*["']([^"']+)["']"#).expect("consume") + }) +} + +fn kafka_consume_array_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r#"subscribe\(\s*\[([^\]]*)\]"#).expect("consume array")) +} + +fn kafka_quoted_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r#"["']([^"']+)["']"#).expect("quoted topic")) +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/mod.rs b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs new file mode 100644 index 000000000..7b601f533 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs @@ -0,0 +1,66 @@ +mod facts; +mod go; +mod kafka; +mod php; +mod python; +mod ruby; +mod rustlang; +mod strip; + +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_extra; +#[cfg(test)] +mod tests_more; +#[cfg(test)] +mod tests_p2; +#[cfg(test)] +mod tests_p3; + +use std::path::{Path, PathBuf}; + +pub(crate) use facts::{LangFactMap, LangFileFacts}; +pub(crate) use go::collect_go_facts; +pub(crate) use kafka::{scan_file as scan_kafka_file, topic_identity}; +pub(crate) use php::collect_php_facts; +pub(crate) use python::collect_python_facts; +pub(crate) use ruby::collect_ruby_facts; +pub(crate) use rustlang::collect_rust_facts; + +#[derive(Debug, Clone, Default)] +pub(crate) struct LangFrontendConfig { + pub python_packages: Vec, + pub go_modules: Vec, + pub rust_packages: Vec, + pub rails_apps: Vec, + pub php_apps: Vec, + pub php_framework: Option, +} + +pub(crate) struct CollectedLangFacts { + pub python: LangFactMap, + pub go: LangFactMap, + pub rust: LangFactMap, + pub ruby: LangFactMap, + pub php: LangFactMap, +} + +pub(crate) fn collect_all_lang_facts( + root: &Path, + all_files: &[PathBuf], + config: &LangFrontendConfig, +) -> CollectedLangFacts { + CollectedLangFacts { + python: collect_python_facts(root, all_files, &config.python_packages), + go: collect_go_facts(root, all_files, &config.go_modules), + rust: collect_rust_facts(root, all_files, &config.rust_packages), + ruby: collect_ruby_facts(root, all_files, &config.rails_apps), + php: collect_php_facts( + root, + all_files, + &config.php_apps, + config.php_framework.as_deref(), + ), + } +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs new file mode 100644 index 000000000..30b6f5be3 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -0,0 +1,196 @@ +use super::facts::{configured_roots, files_under, owning_package, LangFactMap, LangFileFacts}; +use super::strip::strip_comments_keep_strings; +#[path = "php_queue.rs"] +mod queue; +use regex::Regex; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +pub(crate) fn collect_php_facts( + root: &Path, + all_files: &[PathBuf], + apps: &[String], + framework: Option<&str>, +) -> LangFactMap { + let roots = configured_roots(root, apps); + let mut files = files_under(all_files, &roots, "php"); + for root in &roots { + let composer = root.join("composer.json"); + if all_files.iter().any(|path| path == &composer) { + files.push(composer); + } + } + let laravel = framework.is_some_and(|name| name.eq_ignore_ascii_case("laravel")); + super::facts::collect_files_parallel(files, |path| parse_php_file(path, &roots, apps, laravel)) +} + +fn parse_php_file( + path: &Path, + roots: &[PathBuf], + apps: &[String], + laravel: bool, +) -> Option { + let source = std::fs::read_to_string(path).ok()?; + let text = strip_comments_keep_strings(&source); + let classes = php_classes(&text); + let queue_workers = if laravel && queue::php_should_queue_re().is_match(&text) { + queue::laravel_queue_identities(&classes) + } else { + Vec::new() + }; + Some(LangFileFacts { + path: path.to_path_buf(), + package: owning_package(path, roots, apps), + module: classes.first().cloned().or_else(|| { + path.file_stem() + .map(|name| name.to_string_lossy().into_owned()) + }), + imports: { + let mut imports = extract_php_uses(&text); + imports.extend(queue::extract_php_requires(&text)); + imports + }, + declarations: classes, + references: extract_named(&text, php_use_re()), + route_handlers: if laravel { + extract_laravel_routes(&text) + } else { + Vec::new() + }, + queue_enqueues: if laravel { + queue::extract_laravel_dispatches(&text) + } else { + Vec::new() + }, + queue_workers, + mods: Vec::new(), + }) +} + +fn php_classes(source: &str) -> Vec { + let namespace = php_namespace_re() + .captures(source) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().replace('\\', ".")); + let mut names = extract_named(source, php_class_re()); + if let Some(namespace) = namespace { + names.extend( + names + .clone() + .into_iter() + .map(|name| format!("{namespace}.{name}")), + ); + } + names.sort(); + names.dedup(); + names +} + +pub(super) fn extract_named(source: &str, re: &Regex) -> Vec { + let mut values: Vec = re + .captures_iter(source) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().replace('\\', "."))) + .collect(); + values.sort(); + values.dedup(); + values +} + +fn extract_laravel_routes(source: &str) -> Vec<(String, String)> { + laravel_route_re() + .captures_iter(source) + .filter_map(|cap| { + let handler = cap.get(2).or_else(|| cap.get(3))?.as_str(); + Some(( + cap.get(1)?.as_str().into(), + handler.replace('\\', ".").trim_start_matches('.').into(), + )) + }) + .collect() +} + +fn php_namespace_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?m)^\s*namespace\s+([A-Za-z_\\][A-Za-z0-9_\\]*)").expect("ns")) +} + +pub(super) fn extract_php_uses(source: &str) -> Vec { + let mut imports = extract_named(source, php_use_re()); + for cap in php_alias_use_re().captures_iter(source) { + let path = cap + .get(1) + .map(|m| m.as_str().replace('\\', ".")) + .unwrap_or_default(); + if let Some(alias) = cap.get(2).map(|m| m.as_str()) { + imports.push(format!("{alias}={path}")); + } + } + for cap in php_group_use_re().captures_iter(source) { + let prefix = cap + .get(1) + .map(|m| m.as_str().replace('\\', ".")) + .unwrap_or_default(); + for member in cap.get(2).map(|m| m.as_str()).unwrap_or("").split(',') { + let ident = member + .split_whitespace() + .take_while(|token| !token.eq_ignore_ascii_case("as")) + .next() + .unwrap_or("") + .trim(); + if !ident.is_empty() { + let qualified = format!("{prefix}.{ident}"); + imports.push(qualified.clone()); + if let Some(alias) = member + .split_whitespace() + .skip_while(|token| !token.eq_ignore_ascii_case("as")) + .nth(1) + { + imports.push(format!("{alias}={qualified}")); + } + } + } + } + imports.sort(); + imports.dedup(); + imports +} + +fn php_group_use_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*use\s+([A-Za-z_\\][A-Za-z0-9_\\]*)\\\{([^}]+)\}").expect("group use") + }) +} + +fn php_alias_use_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*use\s+([A-Za-z_\\][A-Za-z0-9_\\]*)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)") + .expect("alias use") + }) +} + +fn php_use_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?m)^\s*use\s+([A-Za-z_\\][A-Za-z0-9_\\]*)").expect("use")) +} + +fn php_class_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"(?m)^\s*(?:final\s+|abstract\s+|readonly\s+)*(?:class|interface|trait|enum)\s+([A-Za-z_][A-Za-z0-9_]*)", + ) + .expect("class") + }) +} + +fn laravel_route_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r#"Route::(?:get|post|put|patch|delete)\(\s*['"]([^'"]+)['"]\s*,\s*(?:\[([^\]]+)\]|(\\?[A-Za-z_][A-Za-z0-9_\\]*)::class)"#, + ) + .expect("route") + }) +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php_queue.rs b/crates/no-mistakes/src/codebase/lang_frontends/php_queue.rs new file mode 100644 index 000000000..53e761fb6 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/php_queue.rs @@ -0,0 +1,91 @@ +use super::{extract_named, extract_php_uses, php_namespace_re}; +use regex::Regex; +use std::path::Path; +use std::sync::OnceLock; + +pub(super) fn extract_php_requires(source: &str) -> Vec { + extract_named(source, php_require_re()) + .into_iter() + .filter_map(|raw| { + Path::new(&raw) + .file_stem() + .map(|name| name.to_string_lossy().into_owned()) + }) + .collect() +} + +fn php_require_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r#"(?:require|include)(?:_once)?\s*(?:\(?\s*(?:__DIR__\s*\.\s*)?)['"]([^'"]+)['"]"#, + ) + .expect("php require") + }) +} + +pub(super) fn extract_laravel_dispatches(source: &str) -> Vec { + let uses = extract_php_uses(source); + let namespace = php_namespace_re() + .captures(source) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().replace('\\', ".")); + let mut names = Vec::new(); + for raw in extract_named(source, laravel_dispatch_re()) { + names.extend(resolve_php_queue_name(&raw, &uses, namespace.as_deref())); + } + names.sort(); + names.dedup(); + names +} + +fn resolve_php_queue_name(name: &str, uses: &[String], namespace: Option<&str>) -> Vec { + let mut names = vec![name.to_string()]; + if let Some((_, short)) = name.rsplit_once('.') { + names.push(short.to_string()); + } + for import in uses { + let target = import + .split_once('=') + .map_or(import.as_str(), |(_, target)| target); + let alias = import.split_once('=').map(|(alias, _)| alias); + if alias == Some(name) + || target == name + || target.ends_with(&format!(".{name}")) + || target.rsplit('.').next() == Some(name) + { + names.push(target.to_string()); + } + } + if !name.contains('.') { + if let Some(namespace) = namespace { + names.push(format!("{namespace}.{name}")); + } + } + names +} + +pub(super) fn laravel_queue_identities(classes: &[String]) -> Vec { + let qualified: Vec = classes + .iter() + .filter(|name| name.contains('.')) + .cloned() + .collect(); + if qualified.is_empty() { + classes.to_vec() + } else { + qualified + } +} + +fn laravel_dispatch_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"\b([A-Za-z_\\][A-Za-z0-9_\\]*)::dispatch\s*\(").expect("dispatch") + }) +} + +pub(super) fn php_should_queue_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\bimplements\s+[^{;]*\bShouldQueue\b").expect("shouldqueue")) +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python.rs b/crates/no-mistakes/src/codebase/lang_frontends/python.rs new file mode 100644 index 000000000..1bf03f48c --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -0,0 +1,157 @@ +use super::facts::{configured_roots, files_under, owning_package, LangFactMap, LangFileFacts}; +use super::strip::strip_comments_keep_strings; +#[path = "python_imports.rs"] +mod imports; +use imports::{extract_python_imports, python_module}; + +#[cfg(test)] +#[path = "python_imports_tests.rs"] +mod tests; +use regex::Regex; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +pub(crate) fn collect_python_facts( + root: &Path, + all_files: &[PathBuf], + packages: &[String], +) -> LangFactMap { + let roots = configured_roots(root, packages); + let files = files_under(all_files, &roots, "py"); + super::facts::collect_files_parallel(files, |path| { + parse_python_file(root, path, &roots, packages) + }) +} + +fn parse_python_file( + root: &Path, + path: &Path, + roots: &[PathBuf], + packages: &[String], +) -> Option { + let source = std::fs::read_to_string(path).ok()?; + let text = strip_comments_keep_strings(&source); + let symbols = super::strip::mask_strings(&text); + let package = owning_package(path, roots, packages); + let package_root = package + .as_ref() + .map(|name| crate::codebase::ts_resolver::normalize_path(&root.join(name))); + let module = python_module(package.as_deref(), package_root.as_deref(), path); + let imports = extract_python_imports(&text, path, package.as_deref(), package_root.as_deref()); + let queue_enqueues = extract_celery_enqueues(&text, &imports); + let queue_workers = extract_celery_workers(&text, module.as_deref()); + Some(LangFileFacts { + path: path.to_path_buf(), + package, + module, + imports, + declarations: extract_named(&symbols, python_decl_re()), + references: extract_named(&symbols, python_ref_re()), + route_handlers: extract_django_routes(&text), + queue_enqueues, + queue_workers, + mods: Vec::new(), + }) +} + +fn extract_celery_workers(source: &str, module: Option<&str>) -> Vec { + let mut names = extract_named(source, celery_named_task_re()); + for name in extract_named(source, celery_fn_task_re()) { + names.push(match module { + Some(module) => format!("{module}.{name}"), + None => name, + }); + } + names.sort(); + names.dedup(); + names +} + +fn extract_celery_enqueues(source: &str, imports: &[String]) -> Vec { + let mut names = extract_named(source, celery_enqueue_re()); + let extras: Vec = names + .iter() + .flat_map(|name| { + imports.iter().filter_map(move |import| { + let target = import + .split_once('=') + .map(|(_, target)| target) + .unwrap_or(import); + if target == name || target.ends_with(&format!(".{name}")) { + Some(target.to_string()) + } else { + None + } + }) + }) + .collect(); + names.extend(extras); + names.sort(); + names.dedup(); + names +} + +fn extract_named(source: &str, re: &Regex) -> Vec { + let mut values: Vec = re + .captures_iter(source) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) + .collect(); + values.sort(); + values.dedup(); + values +} + +fn extract_django_routes(source: &str) -> Vec<(String, String)> { + django_route_re() + .captures_iter(source) + .filter_map(|cap| { + Some(( + cap.get(1)?.as_str().to_string(), + cap.get(2).or_else(|| cap.get(3))?.as_str().to_string(), + )) + }) + .collect() +} + +fn python_decl_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*(?:async\s+)?(?:def|class)\s+([A-Za-z_]\w*)").expect("decl") + }) +} + +fn python_ref_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\b([A-Z][A-Za-z0-9_]+)\b").expect("ref")) +} + +fn django_route_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r#"\b(?:path|re_path)\(\s*["']([^"']*)["']\s*,\s*(?:include\(\s*["']([^"']+)["']|([A-Za-z_][\w.]*))"#, + ) + .expect("django") + }) +} + +fn celery_enqueue_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\b([A-Za-z_]\w*)\.(?:delay|apply_async)\s*\(").expect("enqueue")) +} + +fn celery_named_task_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"@(?:shared_task|[\w.]+\.task)\([^)]*name\s*=\s*["']([^"']+)["']"#) + .expect("named") + }) +} + +fn celery_fn_task_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?m)@(?:shared_task|[\w.]+\.task)(?:\([^)]*\))?\s*\n\s*(?:async\s+)?def\s+([A-Za-z_]\w*)") + .expect("fn task") + }) +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs new file mode 100644 index 000000000..8ba8f01eb --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs @@ -0,0 +1,158 @@ +use super::super::facts::module_from_path; +use regex::Regex; +use std::path::Path; +use std::sync::OnceLock; + +pub(super) fn python_module( + package: Option<&str>, + package_root: Option<&Path>, + path: &Path, +) -> Option { + let package = package_prefix(package?)?; + match package_root.and_then(|root| module_from_path(root, path)) { + Some(rel) => Some(match package { + Some(package) => format!("{package}.{rel}"), + None => rel, + }), + None => package.map(str::to_string), + } +} + +fn package_prefix(package: &str) -> Option> { + let trimmed = package.trim(); + if trimmed.is_empty() || trimmed == "." { + return Some(None); + } + Some(Some(trimmed)) +} + +pub(super) fn prefix_package(package: Option<&str>, module: String) -> String { + match package.and_then(|name| package_prefix(name).flatten()) { + Some(package) => format!("{package}.{module}"), + None => module, + } +} + +pub(super) fn extract_python_imports( + source: &str, + path: &Path, + package: Option<&str>, + package_root: Option<&Path>, +) -> Vec { + let source = super::super::strip::mask_strings(source); + let mut imports = Vec::new(); + for raw in extract_named(&source, python_import_re()) { + for part in raw.split(',') { + let mut tokens = part.split_whitespace(); + if let Some(name) = tokens.next() { + imports.push(name.to_string()); + if tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("as")) + { + if let Some(alias) = tokens.next() { + imports.push(format!("{alias}={name}")); + } + } + } + } + } + for cap in python_from_re().captures_iter(&source) { + let Some(module) = cap.get(1).map(|m| m.as_str()) else { + continue; + }; + let names = cap.get(2).map(|m| m.as_str()).unwrap_or(""); + if let Some(resolved) = resolve_relative(module, path, package_root) { + let resolved = prefix_package(package, resolved); + if module.chars().all(|ch| ch == '.') { + push_imported_members(&mut imports, &resolved, names); + } else { + imports.push(resolved.clone()); + push_imported_members(&mut imports, &resolved, names); + } + } else if !module.starts_with('.') { + imports.push(module.to_string()); + push_imported_members(&mut imports, module, names); + } + } + imports.sort(); + imports.dedup(); + imports +} + +fn push_imported_members(imports: &mut Vec, module: &str, names: &str) { + for (name, alias) in imported_bindings(names) { + let qualified = format!("{module}.{name}"); + imports.push(qualified.clone()); + if let Some(alias) = alias { + imports.push(format!("{alias}={qualified}")); + } + } +} + +fn imported_bindings(names: &str) -> Vec<(String, Option)> { + names + .trim() + .trim_start_matches('(') + .trim_end_matches(')') + .split(',') + .filter_map(|part| { + let mut tokens = part.split_whitespace(); + let ident = tokens.next()?; + if ident.is_empty() || ident.starts_with('(') || ident == "*" { + return None; + } + let alias = tokens + .next() + .filter(|token| token.eq_ignore_ascii_case("as")) + .and_then(|_| tokens.next()) + .map(str::to_string); + Some((ident.to_string(), alias)) + }) + .collect() +} + +fn resolve_relative(module: &str, path: &Path, package_root: Option<&Path>) -> Option { + let dots = module.chars().take_while(|ch| *ch == '.').count(); + if dots == 0 { + return None; + } + let rest = module[dots..].trim_matches('.'); + let mut dir = path.parent()?.to_path_buf(); + for _ in 1..dots { + dir = dir.parent()?.to_path_buf(); + } + let package_root = package_root?; + let target = if rest.is_empty() { + dir + } else { + dir.join(rest.replace('.', std::path::MAIN_SEPARATOR_STR)) + }; + module_from_path(package_root, &target.with_extension("py")) + .or_else(|| module_from_path(package_root, &target.join("__init__.py"))) +} + +fn extract_named(source: &str, re: &Regex) -> Vec { + let mut values: Vec = re + .captures_iter(source) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) + .collect(); + values.sort(); + values.dedup(); + values +} + +fn python_import_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?m)^\s*import\s+([^\n]+)").expect("python import")) +} + +fn python_from_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"(?ms)^\s*from\s+(\.+(?:[A-Za-z_][\w.]*)?|[A-Za-z_][\w.]*)\s+import\s+(\([^)]+\)|[^\n]+)", + ) + .expect("from") + }) +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python_imports_tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/python_imports_tests.rs new file mode 100644 index 000000000..17223763f --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports_tests.rs @@ -0,0 +1,40 @@ +use super::imports::{extract_python_imports, prefix_package, python_module}; +use std::path::Path; + +#[test] +fn extract_python_imports_covers_unprefixed_and_star_forms() { + let path = Path::new("/repo/app/users/views.py"); + let imports = extract_python_imports( + "import app.tasks, app.models\nfrom . import *\nfrom . import (\n helper,\n)\nfrom ...outside import nope\nfrom app.mod import helper", + path, + None, + None, + ); + assert!(imports.iter().any(|import| import == "app.tasks")); + assert!(imports.iter().any(|import| import == "app.mod.helper")); + let aliased = extract_python_imports("import app.tasks as celery_tasks", path, None, None); + assert!(aliased + .iter() + .any(|import| import == "celery_tasks=app.tasks")); + assert_eq!(python_module(None, None, path), None); + assert_eq!( + prefix_package(None, "users.models".to_string()), + "users.models" + ); + let pkg = Path::new("/repo"); + let paren = extract_python_imports( + "from . import (\n helper,\n)", + path, + Some("app"), + Some(&pkg.join("app")), + ); + assert!(paren.iter().any(|import| import.ends_with(".helper"))); + assert_eq!( + python_module(Some("."), Some(pkg), &pkg.join("app/users.py")).as_deref(), + Some("app.users") + ); + assert_eq!( + prefix_package(Some("."), "app.users".to_string()), + "app.users" + ); +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs new file mode 100644 index 000000000..bad0d7497 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -0,0 +1,163 @@ +use super::facts::{configured_roots, files_under, owning_package, LangFactMap, LangFileFacts}; +use super::strip::strip_comments_keep_strings; +use regex::Regex; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +pub(crate) fn collect_ruby_facts( + root: &Path, + all_files: &[PathBuf], + apps: &[String], +) -> LangFactMap { + let roots = configured_roots(root, apps); + let files = files_under(all_files, &roots, "rb"); + super::facts::collect_files_parallel(files, |path| parse_ruby_file(path, &roots, apps)) +} + +fn parse_ruby_file(path: &Path, roots: &[PathBuf], apps: &[String]) -> Option { + let source = std::fs::read_to_string(path).ok()?; + let text = strip_comments_keep_strings(&source); + Some(LangFileFacts { + path: path.to_path_buf(), + package: owning_package(path, roots, apps), + module: ruby_module_key(path, roots).or_else(|| { + path.file_stem() + .map(|name| name.to_string_lossy().into_owned()) + }), + imports: extract_requires(&text, path, roots), + declarations: extract_ruby_declarations(&text), + references: extract_named(&text, ruby_const_re()), + route_handlers: extract_pairs(&text, rails_route_re()), + queue_enqueues: extract_named(&text, active_job_re()), + queue_workers: extract_named(&text, ruby_job_class_re()), + mods: Vec::new(), + }) +} + +fn ruby_module_key(path: &Path, roots: &[PathBuf]) -> Option { + let root = roots + .iter() + .filter(|candidate| path.starts_with(candidate)) + .max_by_key(|candidate| candidate.components().count())?; + let rel = path.strip_prefix(root).ok()?; + let key = rel.to_string_lossy().replace('\\', "/"); + Some(key.trim_end_matches(".rb").to_string()) +} + +fn extract_requires(source: &str, path: &Path, roots: &[PathBuf]) -> Vec { + let mut imports = extract_named(source, ruby_require_re()); + for rel in extract_named(source, ruby_require_relative_re()) { + if let Some(parent) = path.parent() { + let resolved = crate::codebase::ts_resolver::normalize_path( + &parent.join(rel).with_extension("rb"), + ); + if let Some(key) = ruby_module_key(&resolved, roots) { + imports.push(key); + } + if let Some(stem) = resolved.file_stem() { + imports.push(stem.to_string_lossy().into_owned()); + } + } + } + imports.sort(); + imports.dedup(); + imports +} + +fn extract_named(source: &str, re: &Regex) -> Vec { + let mut values: Vec = re + .captures_iter(source) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) + .collect(); + values.sort(); + values.dedup(); + values +} + +fn extract_pairs(source: &str, re: &Regex) -> Vec<(String, String)> { + re.captures_iter(source) + .filter_map(|cap| { + Some(( + cap.get(1)?.as_str().to_string(), + cap.get(2)?.as_str().to_string(), + )) + }) + .collect() +} + +fn ruby_require_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r#"\brequire\s+["']([^"']+)["']"#).expect("require")) +} + +fn ruby_require_relative_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r#"\brequire_relative\s+["']([^"']+)["']"#).expect("rel")) +} + +fn extract_ruby_declarations(source: &str) -> Vec { + let mut stack: Vec<(usize, String)> = Vec::new(); + let mut names = Vec::new(); + for line in source.lines() { + let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); + while stack.last().is_some_and(|(depth, _)| *depth >= indent) { + stack.pop(); + } + if let Some(name) = ruby_decl_re() + .captures(line) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + { + let qualified = if stack.is_empty() || name.contains("::") { + name.clone() + } else { + format!( + "{}::{name}", + stack + .iter() + .map(|(_, part)| part.as_str()) + .collect::>() + .join("::") + ) + }; + names.push(qualified); + names.push(name.clone()); + stack.push((indent, name)); + } + } + names.sort(); + names.dedup(); + names +} + +fn ruby_decl_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?m)^\s*(?:class|module)\s+([A-Z][\w:]*)").expect("decl")) +} + +fn ruby_const_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"\b([A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)\b").expect("const") + }) +} + +fn rails_route_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"(?m)^\s*(?:get|post|put|patch|delete)\s+["']([^"']+)["']\s*,\s*to:\s*["']([^"']+)["']"#) + .expect("route") + }) +} + +fn active_job_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\b([A-Z][\w:]*)\.perform_later\b").expect("job")) +} + +fn ruby_job_class_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*class\s+([A-Z][\w:]*)\s*<\s*ApplicationJob").expect("job class") + }) +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs b/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs new file mode 100644 index 000000000..df8a23fb8 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs @@ -0,0 +1,81 @@ +pub(super) fn rust_path_prefixes(import: &str) -> Vec { + let parts: Vec<&str> = import.split('.').filter(|part| !part.is_empty()).collect(); + (1..parts.len()).map(|end| parts[..end].join(".")).collect() +} + +pub(super) fn qualify_rust_use(kind: &str, item: &str, module: Option<&str>) -> String { + match kind { + "self" => match module { + Some(module) => format!("{module}.{item}"), + None => item.to_string(), + }, + "super" => match module.and_then(|module| module.rsplit_once('.')) { + Some((parent, _)) => format!("{parent}.{item}"), + None => item.to_string(), + }, + _ => item.to_string(), + } +} + +pub(super) fn expand_rust_use(tree: &str) -> Vec { + let tree = tree.trim(); + let Some(start) = tree.find('{') else { + return vec![tree.to_string()]; + }; + let Some(end) = tree.rfind('}') else { + return vec![tree.to_string()]; + }; + let prefix = tree[..start].trim_end_matches(':'); + split_use_members(&tree[start + 1..end]) + .into_iter() + .flat_map(|member| { + let member = member + .split_whitespace() + .take_while(|token| !token.eq_ignore_ascii_case("as")) + .collect::>() + .join(" "); + let member = member.trim(); + if member.is_empty() || member == "self" { + return if prefix.is_empty() { + Vec::new() + } else { + vec![prefix.to_string()] + }; + } + let combined = if prefix.is_empty() { + member.to_string() + } else { + format!("{prefix}::{member}") + }; + expand_rust_use(&combined) + }) + .collect() +} + +fn split_use_members(inner: &str) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut depth = 0; + for ch in inner.chars() { + match ch { + '{' => { + depth += 1; + current.push(ch); + } + '}' => { + depth -= 1; + current.push(ch); + } + ',' if depth == 0 => { + if !current.trim().is_empty() { + parts.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + } + } + if !current.trim().is_empty() { + parts.push(current); + } + parts +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs new file mode 100644 index 000000000..7ad65c440 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs @@ -0,0 +1,122 @@ +use super::facts::{ + configured_roots, files_under, owning_package, rust_module_from_path, LangFactMap, + LangFileFacts, +}; +use super::strip::strip_comments_keep_strings; +#[path = "rust_use.rs"] +mod rust_use; +use regex::Regex; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +pub(crate) fn collect_rust_facts( + root: &Path, + all_files: &[PathBuf], + packages: &[String], +) -> LangFactMap { + let roots = configured_roots(root, packages); + let files = files_under(all_files, &roots, "rs"); + super::facts::collect_files_parallel(files, |path| { + parse_rust_file(root, path, &roots, packages) + }) +} + +fn parse_rust_file( + root: &Path, + path: &Path, + roots: &[PathBuf], + packages: &[String], +) -> Option { + let source = std::fs::read_to_string(path).ok()?; + let text = strip_comments_keep_strings(&source); + let package = owning_package(path, roots, packages); + let package_root = package + .as_ref() + .map(|name| crate::codebase::ts_resolver::normalize_path(&root.join(name))); + let src_root = package_root.as_ref().map(|pkg| { + let src = pkg.join("src"); + if src.is_dir() { + src + } else { + pkg.clone() + } + }); + let module = src_root + .as_ref() + .and_then(|pkg| rust_module_from_path(pkg, path)); + Some(LangFileFacts { + path: path.to_path_buf(), + package, + module: module.clone(), + imports: rust_imports(&text, module.as_deref()), + declarations: extract_named(&text, rust_decl_re()), + references: extract_named(&text, rust_ref_re()), + route_handlers: Vec::new(), + queue_enqueues: Vec::new(), + queue_workers: Vec::new(), + mods: extract_named(&text, rust_mod_re()), + }) +} + +fn rust_imports(source: &str, module: Option<&str>) -> Vec { + let mut imports = Vec::new(); + for cap in rust_use_re().captures_iter(source) { + let kind = cap.get(1).map(|m| m.as_str()).unwrap_or("crate"); + let tree = cap.get(2).map(|m| m.as_str()).unwrap_or(""); + for item in rust_use::expand_rust_use(tree) { + imports.push(rust_use::qualify_rust_use( + kind, + &item.replace("::", "."), + module, + )); + } + } + let prefixes: Vec = imports + .iter() + .flat_map(|import| rust_use::rust_path_prefixes(import)) + .collect(); + imports.extend(prefixes); + imports.sort(); + imports.dedup(); + imports +} + +fn extract_named(source: &str, re: &Regex) -> Vec { + let mut values: Vec = re + .captures_iter(source) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().replace("::", "."))) + .collect(); + values.sort(); + values.dedup(); + values +} + +fn rust_mod_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*(?:pub(?:\([^)]+\))?\s+)?mod\s+([A-Za-z_]\w*)\s*;").expect("mod") + }) +} + +fn rust_use_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*(?:pub(?:\([^)]+\))?\s+)?use\s+(crate|super|self)::(.+?)\s*;") + .expect("use") + }) +} + +fn rust_decl_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"(?m)^\s*pub(?:\([^)]+\))?\s+(?:fn|struct|enum|trait|type|mod)\s+([A-Za-z_]\w*)", + ) + .expect("decl") + }) +} + +fn rust_ref_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\b([A-Z][A-Za-z0-9_]*)\b").expect("ref")) +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/strip.rs b/crates/no-mistakes/src/codebase/lang_frontends/strip.rs new file mode 100644 index 000000000..8baf7835f --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/strip.rs @@ -0,0 +1,135 @@ +/// Strip comments while keeping string literals so route/queue names survive. +pub(crate) fn strip_comments_keep_strings(source: &str) -> String { + let mut out = String::with_capacity(source.len()); + let mut chars = source.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '#' { + skip_line(&mut chars); + out.push('\n'); + continue; + } + if ch == '/' && chars.peek() == Some(&'/') { + chars.next(); + skip_line(&mut chars); + out.push('\n'); + continue; + } + if ch == '/' && chars.peek() == Some(&'*') { + chars.next(); + skip_block(&mut chars, &mut out); + continue; + } + if ch == '"' || ch == '\'' { + out.push(ch); + copy_quoted(&mut chars, &mut out, ch); + continue; + } + out.push(ch); + } + out +} + +fn skip_line(chars: &mut std::iter::Peekable>) { + for ch in chars.by_ref() { + if ch == '\n' { + break; + } + } +} + +fn skip_block(chars: &mut std::iter::Peekable>, out: &mut String) { + let mut previous = '\0'; + for ch in chars.by_ref() { + if ch == '\n' { + out.push('\n'); + } + if previous == '*' && ch == '/' { + break; + } + previous = ch; + } +} + +fn copy_quoted( + chars: &mut std::iter::Peekable>, + out: &mut String, + quote: char, +) { + let mut escaped = false; + for ch in chars.by_ref() { + out.push(ch); + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == quote { + break; + } + } +} + +/// Replace string contents with spaces so docstring examples are not symbols. +pub(crate) fn mask_strings(source: &str) -> String { + let chars: Vec = source.chars().collect(); + let mut out = String::with_capacity(source.len()); + let mut i = 0; + while i < chars.len() { + let ch = chars[i]; + if ch == '"' || ch == '\'' || ch == '`' { + i = mask_quoted(&chars, &mut out, i, ch); + continue; + } + out.push(ch); + i += 1; + } + out +} + +fn mask_quoted(chars: &[char], out: &mut String, start: usize, quote: char) -> usize { + let triple = chars.get(start + 1) == Some("e) && chars.get(start + 2) == Some("e); + let width = if triple { 3 } else { 1 }; + for _ in 0..width { + out.push(quote); + } + let mut i = start + width; + let mut escaped = false; + while i < chars.len() { + if !triple && escaped { + push_masked(out, chars[i]); + escaped = false; + i += 1; + continue; + } + if !triple && chars[i] == '\\' { + out.push(' '); + escaped = true; + i += 1; + continue; + } + if triple && chars.get(i..i + 3) == Some(&[quote, quote, quote]) { + out.push(quote); + out.push(quote); + out.push(quote); + return i + 3; + } + if !triple && chars[i] == quote { + out.push(quote); + return i + 1; + } + push_masked(out, chars[i]); + i += 1; + } + i +} + +fn push_masked(out: &mut String, ch: char) { + if ch == '\n' { + out.push('\n'); + } else { + out.push(' '); + } +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs new file mode 100644 index 000000000..dc60da5cc --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -0,0 +1,200 @@ +use super::*; +use std::path::PathBuf; + +fn fixture(name: &str) -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/lang-frontends") + .join(name), + ) +} + +fn all_files(root: &std::path::Path) -> Vec { + let repo = crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."), + ); + crate::codebase::ts_source::discover_visible_paths(&repo) + .into_iter() + .map(|path| { + let absolute = if path.is_absolute() { + path + } else { + repo.join(path) + }; + crate::codebase::ts_resolver::normalize_path(&absolute) + }) + .filter(|path| path.starts_with(root)) + .collect() +} + +#[test] +fn python_collects_relative_import_celery_and_django_routes() { + let root = fixture("python-celery-django"); + let facts = collect_python_facts(&root, &all_files(&root), &["app".into()]); + let views = facts + .files + .keys() + .find(|path| path.ends_with("users/views.py")) + .cloned() + .expect("views"); + assert!(facts.files[&views] + .imports + .iter() + .any(|import| import == "app.users.models" || import.ends_with(".models"))); + let urls = facts + .files + .values() + .find(|file| file.path.ends_with("app/urls.py") && !file.path.ends_with("api/urls.py")) + .expect("urls"); + assert!(urls + .imports + .iter() + .any(|import| import == "app.users.views")); + assert_eq!( + facts.files[&views].module.as_deref(), + Some("app.users.views") + ); + let tasks = facts + .files + .values() + .find(|file| file.path.ends_with("tasks.py")) + .expect("tasks"); + assert!(tasks + .queue_workers + .iter() + .any(|job| job.contains("send_welcome"))); + let enqueue = facts + .files + .values() + .find(|file| file.path.ends_with("enqueue.py")) + .expect("enqueue"); + assert!(enqueue.imports.iter().any(|import| import == "app.tasks")); + assert!(enqueue + .imports + .iter() + .any(|import| import == "app.users.models")); + assert!(enqueue + .queue_enqueues + .iter() + .any(|job| job == "send_welcome")); + assert!(urls + .route_handlers + .iter() + .any(|(route, handler)| route == "api/users/" && handler.contains("user_list"))); + assert!(urls + .route_handlers + .iter() + .any(|(route, handler)| route == "users/" && handler.contains("UserView"))); +} + +#[test] +fn go_collects_asynq_task_and_handler() { + let root = fixture("go-asynq"); + let facts = collect_go_facts(&root, &all_files(&root), &["worker".into()]); + let enqueue = facts + .files + .values() + .find(|file| file.path.ends_with("enqueue.go")) + .expect("enqueue"); + assert!(enqueue.imports.iter().any(|import| import == "fmt")); + assert!(enqueue + .imports + .iter() + .any(|import| import == "github.com/hibiken/asynq")); + assert_eq!(enqueue.queue_enqueues, vec!["mail:welcome".to_string()]); + let tasks = facts + .files + .values() + .find(|file| file.path.ends_with("tasks.go")) + .expect("tasks"); + assert_eq!(tasks.queue_workers, vec!["mail:welcome".to_string()]); + assert!(tasks + .declarations + .iter() + .any(|name| name == "WelcomePayload" || name == "HandleWelcome")); + let nested = collect_go_facts(&root, &all_files(&root), &[".".into(), "nested".into()]); + let mail = nested + .files + .values() + .find(|file| file.path.ends_with("nested/mail.go")) + .expect("nested"); + assert_eq!(mail.module.as_deref(), Some("example.com/nested")); +} + +#[test] +fn rust_collects_use_and_declaration() { + let root = fixture("rust-mods"); + let facts = collect_rust_facts(&root, &all_files(&root), &[".".into()]); + let lib = facts + .files + .values() + .find(|file| file.path.ends_with("lib.rs")) + .expect("lib"); + assert!(lib.imports.iter().any(|import| import == "mail")); + assert!(lib.mods.iter().any(|name| name == "mail")); + assert!(facts + .files + .values() + .any(|file| file.module.as_deref() == Some("mail"))); + assert!(facts.declarations.contains_key("Welcome")); +} + +#[test] +fn rails_collects_route_and_active_job() { + let root = fixture("rails-jobs"); + let facts = collect_ruby_facts(&root, &all_files(&root), &[".".into()]); + let routes = facts + .files + .values() + .find(|file| file.path.ends_with("routes.rb")) + .expect("routes"); + assert!(routes + .route_handlers + .iter() + .any(|(route, handler)| route == "/api/users" && handler == "users#index")); + assert!(routes + .route_handlers + .iter() + .any(|(route, handler)| route == "/admin/users" && handler == "admin/users#index")); + assert!(facts.declarations.contains_key("Admin::UsersController")); + let controller = facts + .files + .values() + .find(|file| file.path.ends_with("controllers/users_controller.rb")) + .expect("controller"); + assert_eq!(controller.queue_enqueues, vec!["WelcomeJob".to_string()]); +} + +#[test] +fn php_collects_laravel_route_and_dispatch() { + let root = fixture("php-laravel"); + let facts = collect_php_facts(&root, &all_files(&root), &[".".into()], Some("laravel")); + let routes = facts + .files + .values() + .find(|file| file.path.ends_with("web.php")) + .expect("routes"); + assert!(routes + .route_handlers + .iter() + .any(|(route, _)| route == "/api/users")); + let job = facts + .files + .values() + .find(|file| file.path.ends_with("SomeJob.php")) + .expect("job"); + assert!(!job.queue_workers.is_empty()); + assert!(job + .declarations + .iter() + .any(|name| name == "App.Jobs.SomeJob" || name == "SomeJob")); + assert!(facts + .declarations + .keys() + .any(|name| name.contains("Mailer"))); + assert!(routes + .imports + .iter() + .any(|import| import == "App.Jobs.SomeJob")); + assert!(routes.imports.iter().all(|import| !import.contains(" as "))); +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs new file mode 100644 index 000000000..286bcd8ab --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs @@ -0,0 +1,123 @@ +use super::*; +use crate::codebase::lang_frontends::kafka::extract_kafka_topics; +use std::path::PathBuf; + +fn fixture(name: &str) -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/lang-frontends") + .join(name), + ) +} + +fn all_files(root: &std::path::Path) -> Vec { + let repo = crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."), + ); + crate::codebase::ts_source::discover_visible_paths(&repo) + .into_iter() + .map(|path| { + let absolute = if path.is_absolute() { + path + } else { + repo.join(path) + }; + crate::codebase::ts_resolver::normalize_path(&absolute) + }) + .filter(|path| path.starts_with(root)) + .collect() +} + +#[test] +fn kafka_extracts_static_topics_and_skips_dynamic() { + let (produces, consumes) = extract_kafka_topics( + r#" + producer.send({ topic: "mail.welcome" }); + consumer.subscribe({ topic: "mail.welcome" }); + producer.send({ topic: prefix + name }); + // producer.send({ topic: "mail.commented" }); + "#, + ); + assert_eq!(produces, vec!["mail.welcome".to_string()]); + assert_eq!(consumes, vec!["mail.welcome".to_string()]); + let (commented, _) = extract_kafka_topics("// producer.send({ topic: \"mail.commented\" });"); + assert!(commented.is_empty()); + assert_eq!( + topic_identity(Some("orders"), "mail.welcome"), + "orders:mail.welcome" + ); +} + +#[test] +fn empty_config_collects_nothing() { + let root = fixture("python-celery-django"); + let files = all_files(&root); + assert!(collect_python_facts(&root, &files, &[]).files.is_empty()); + assert!(collect_go_facts(&root, &files, &[]).files.is_empty()); + assert!(collect_rust_facts(&root, &files, &[]).files.is_empty()); + assert!(collect_ruby_facts(&root, &files, &[]).files.is_empty()); + assert!(collect_php_facts(&root, &files, &[], None).files.is_empty()); +} + +#[test] +fn php_without_framework_skips_laravel_extractors() { + let root = fixture("php-laravel"); + let facts = collect_php_facts(&root, &all_files(&root), &[".".into()], None); + let routes = facts.files.values().find(|f| f.path.ends_with("web.php")); + assert!(routes.is_some_and(|file| file.route_handlers.is_empty())); +} + +#[test] +fn strip_and_kafka_identity_cover_comment_and_empty_cluster_paths() { + let stripped = super::strip::strip_comments_keep_strings( + "# hash\n// line\n/* block\nstill */ \"keep // here\" 'ok' \"esc\\\"ape\" done", + ); + assert!(stripped.contains("keep // here")); + assert!(stripped.contains("done")); + assert!(!super::strip::mask_strings("const doc = `LegacyUser`").contains("LegacyUser")); + assert_eq!(topic_identity(None, "mail.welcome"), "mail.welcome"); + assert_eq!(topic_identity(Some(""), "mail.welcome"), "mail.welcome"); +} + +#[test] +fn go_and_rust_collectors_cover_missing_manifest_roots() { + let go = fixture("go-asynq"); + let go_facts = collect_go_facts(&go, &all_files(&go), &["worker".into()]); + assert!(!go_facts.files.is_empty()); + assert!(go_facts + .files + .values() + .any(|file| file.path.ends_with("pkg/ping.go") && file.module.as_deref() == Some("pkg"))); + let rust = fixture("rust-mods"); + let rust_facts = collect_rust_facts(&rust, &all_files(&rust), &["src".into()]); + assert!(rust_facts + .files + .values() + .any(|file| file.mods.is_empty() || file.module.is_some())); +} + +#[test] +fn rails_require_relative_and_python_init_module_keys() { + let rails = fixture("rails-jobs"); + let facts = collect_ruby_facts(&rails, &all_files(&rails), &[".".into()]); + let controller = facts + .files + .values() + .find(|file| file.path.ends_with("controllers/users_controller.rb")) + .expect("controller"); + assert!(controller + .imports + .iter() + .any(|import| import == "welcome_job" || import.ends_with("welcome_job"))); + let python = fixture("python-celery-django"); + let facts = collect_python_facts(&python, &all_files(&python), &["app".into()]); + assert!(facts + .files + .values() + .any(|file| file.module.as_deref() == Some("app"))); + let pkg = std::path::Path::new("/pkg"); + assert_eq!( + super::facts::module_from_path(pkg, &pkg.join("foo")).as_deref(), + Some("foo") + ); +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs new file mode 100644 index 000000000..d99b37084 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs @@ -0,0 +1,152 @@ +use super::*; +use crate::codebase::lang_frontends::kafka::extract_kafka_topics; +use crate::codebase::lang_frontends::strip::mask_strings; +use std::path::PathBuf; + +fn fixture(name: &str) -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/lang-frontends") + .join(name), + ) +} + +fn all_files(root: &std::path::Path) -> Vec { + let repo = crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."), + ); + crate::codebase::ts_source::discover_visible_paths(&repo) + .into_iter() + .map(|path| { + let absolute = if path.is_absolute() { + path + } else { + repo.join(path) + }; + crate::codebase::ts_resolver::normalize_path(&absolute) + }) + .filter(|path| path.starts_with(root)) + .collect() +} + +#[test] +fn python_masks_docstring_symbols_and_keeps_include_routes() { + let root = fixture("python-celery-django"); + let facts = collect_python_facts(&root, &all_files(&root), &["app".into()]); + let views = facts + .files + .values() + .find(|file| file.path.ends_with("users/views.py")) + .expect("views"); + assert!(!views.declarations.iter().any(|name| name == "LegacyUser")); + let urls = facts + .files + .values() + .find(|file| file.path.ends_with("app/urls.py") && !file.path.ends_with("api/urls.py")) + .expect("urls"); + assert!(urls + .route_handlers + .iter() + .any(|(route, handler)| route.is_empty() && handler.contains("user_list"))); + assert!(urls + .route_handlers + .iter() + .any(|(route, handler)| route == "api/" && handler == "app.api.urls")); + assert!(!mask_strings(r#"x = "class Hidden:" """class Doc:""" 'ok'"#).contains("Hidden")); +} + +#[test] +fn php_collects_invokable_routes_and_fq_should_queue() { + let root = fixture("php-laravel"); + let facts = collect_php_facts(&root, &all_files(&root), &[".".into()], Some("laravel")); + let routes = facts + .files + .values() + .find(|file| file.path.ends_with("web.php")) + .expect("routes"); + assert!(routes + .route_handlers + .iter() + .any(|(route, handler)| route == "/ping" && handler.contains("PingController"))); + let fq = facts + .files + .values() + .find(|file| file.path.ends_with("FqJob.php")) + .expect("fq job"); + assert!(!fq.queue_workers.is_empty()); +} + +#[test] +fn kafka_captures_every_subscription_array_topic() { + let (_, consumes) = extract_kafka_topics(r#"consumer.subscribe(["orders", "payments"]);"#); + assert!(consumes.iter().any(|topic| topic == "orders")); + assert!(consumes.iter().any(|topic| topic == "payments")); +} + +#[test] +fn go_skips_test_files_and_scopes_package_modules() { + let root = fixture("go-asynq"); + let facts = collect_go_facts(&root, &all_files(&root), &["worker".into()]); + let test = facts + .files + .values() + .find(|file| file.path.ends_with("ping_test.go")) + .expect("test"); + assert!(test.module.is_some()); + assert!(facts + .files_by_module + .get(test.module.as_ref().expect("test module")) + .is_none_or(|paths| paths.iter().all(|path| !path.ends_with("_test.go")))); + let dot = facts + .files + .values() + .find(|file| file.path.ends_with("pkg/dot.go")) + .expect("dot import"); + assert!(dot.imports.iter().any(|import| import == "mail")); + let pkg_user = facts + .files + .values() + .find(|file| file.path.ends_with("pkg/user.go")) + .expect("pkg user"); + assert_ne!( + pkg_user.module.as_deref(), + facts + .files + .values() + .find(|file| file.path.ends_with("mail/user.go")) + .and_then(|file| file.module.as_deref()) + ); + assert!(pkg_user + .declarations + .iter() + .any(|name| name == "DefaultTimeout")); +} + +#[test] +fn rust_expands_grouped_use_trees() { + let root = fixture("rust-mods"); + let facts = collect_rust_facts(&root, &all_files(&root), &[".".into()]); + let lib = facts + .files + .values() + .find(|file| file.path.ends_with("lib.rs")) + .expect("lib"); + assert!(lib.imports.iter().any(|import| import == "aaa")); + assert!(lib.imports.iter().any(|import| import == "mail")); +} + +#[test] +fn ruby_captures_qualified_constants() { + let root = fixture("rails-jobs"); + let facts = collect_ruby_facts(&root, &all_files(&root), &[".".into()]); + let controller = facts + .files + .values() + .find(|file| file.path.ends_with("controllers/users_controller.rb")) + .expect("controller"); + assert!(controller + .references + .iter() + .any(|name| name == "Admin::User")); + assert!(facts.declarations.contains_key("Admin::User")); +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests_p2.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_p2.rs new file mode 100644 index 000000000..d5e168861 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_p2.rs @@ -0,0 +1,143 @@ +use super::*; +use std::path::PathBuf; + +fn fixture(name: &str) -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/lang-frontends") + .join(name), + ) +} + +fn all_files(root: &std::path::Path) -> Vec { + let repo = crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."), + ); + crate::codebase::ts_source::discover_visible_paths(&repo) + .into_iter() + .map(|path| { + let absolute = if path.is_absolute() { + path + } else { + repo.join(path) + }; + crate::codebase::ts_resolver::normalize_path(&absolute) + }) + .filter(|path| path.starts_with(root)) + .collect() +} + +#[test] +fn go_skips_unconfigured_nested_modules() { + let root = fixture("go-asynq"); + let outer_only = collect_go_facts(&root, &all_files(&root), &[".".into()]); + assert!(outer_only + .files + .values() + .all(|file| !file.path.ends_with("nested/mail.go"))); + assert!(outer_only + .files + .values() + .any(|file| file.path.ends_with("enqueue.go"))); +} + +#[test] +fn ruby_require_relative_uses_normalized_module_key() { + let root = fixture("rails-jobs"); + let facts = collect_ruby_facts(&root, &all_files(&root), &[".".into()]); + let controller = facts + .files + .values() + .find(|file| file.path.ends_with("controllers/users_controller.rb")) + .expect("controller"); + let job = facts + .files + .values() + .find(|file| file.path.ends_with("jobs/welcome_job.rb")) + .expect("job"); + let key = job.module.as_deref().expect("job module"); + assert!( + controller.imports.iter().any(|import| import == key), + "require_relative should match normalized module {key}" + ); + assert!(!controller + .imports + .iter() + .any(|import| import.contains("/../"))); +} + +#[test] +fn php_queue_identities_are_namespace_qualified() { + let root = fixture("php-laravel"); + let facts = collect_php_facts(&root, &all_files(&root), &[".".into()], Some("laravel")); + let job = facts + .files + .values() + .find(|file| file.path.ends_with("SomeJob.php")) + .expect("job"); + assert!(job + .queue_workers + .iter() + .any(|name| name == "App.Jobs.SomeJob")); + assert!(job.queue_workers.iter().all(|name| name.contains('.'))); + let controller = facts + .files + .values() + .find(|file| file.path.ends_with("UserController.php")) + .expect("controller"); + assert!(controller + .queue_enqueues + .iter() + .any(|name| name == "App.Jobs.SomeJob")); +} + +#[test] +fn rust_keeps_intermediate_use_prefixes() { + let root = fixture("rust-mods"); + let facts = collect_rust_facts(&root, &all_files(&root), &[".".into()]); + let lib = facts + .files + .values() + .find(|file| file.path.ends_with("lib.rs")) + .expect("lib"); + assert!(lib.imports.iter().any(|import| import == "aaa.helper")); + assert!(lib.imports.iter().any(|import| import == "aaa")); +} + +#[test] +fn go_masks_raw_strings_and_strips_mod_comments() { + let root = fixture("go-asynq"); + let facts = collect_go_facts(&root, &all_files(&root), &["worker".into()]); + let ping = facts + .files + .values() + .find(|file| file.path.ends_with("pkg/ping.go")) + .expect("ping"); + assert!(!ping.references.iter().any(|name| name == "LegacyUser")); + let nested = collect_go_facts(&root, &all_files(&root), &[".".into(), "nested".into()]); + let mail = nested + .files + .values() + .find(|file| file.path.ends_with("nested/mail.go")) + .expect("nested"); + assert_eq!(mail.module.as_deref(), Some("example.com/nested")); +} + +#[test] +fn php_collects_static_require_stems() { + let root = fixture("php-laravel"); + let facts = collect_php_facts(&root, &all_files(&root), &[".".into()], Some("laravel")); + let routes = facts + .files + .values() + .find(|file| file.path.ends_with("web.php")) + .expect("routes"); + assert!(routes.imports.iter().any(|import| import == "helpers")); +} + +#[test] +fn ruby_tracks_lexical_module_namespaces() { + let root = fixture("rails-jobs"); + let facts = collect_ruby_facts(&root, &all_files(&root), &[".".into()]); + assert!(facts.declarations.contains_key("Admin::Ledger")); +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests_p3.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_p3.rs new file mode 100644 index 000000000..6cc6ba5ae --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_p3.rs @@ -0,0 +1,123 @@ +use super::*; +use crate::codebase::lang_frontends::kafka::extract_kafka_topics; +use std::path::PathBuf; + +fn fixture(name: &str) -> PathBuf { + crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/lang-frontends") + .join(name), + ) +} + +fn all_files(root: &std::path::Path) -> Vec { + let repo = crate::codebase::ts_resolver::normalize_path( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."), + ); + crate::codebase::ts_source::discover_visible_paths(&repo) + .into_iter() + .map(|path| { + let absolute = if path.is_absolute() { + path + } else { + repo.join(path) + }; + crate::codebase::ts_resolver::normalize_path(&absolute) + }) + .filter(|path| path.starts_with(root)) + .collect() +} + +#[test] +fn python_keeps_import_aliases_and_masks_docstring_imports() { + let root = fixture("python-celery-django"); + let facts = collect_python_facts(&root, &all_files(&root), &["app".into()]); + let enqueue = facts + .files + .values() + .find(|file| file.path.ends_with("enqueue.py")) + .expect("enqueue"); + assert!(enqueue + .imports + .iter() + .any(|import| import == "celery_tasks=app.tasks")); + let views = facts + .files + .values() + .find(|file| file.path.ends_with("users/views.py")) + .expect("views"); + assert!(!views + .imports + .iter() + .any(|import| import.contains("fake_docstring"))); +} + +#[test] +fn rust_skips_inline_mods_and_treats_crate_root_self_as_root() { + let root = fixture("rust-mods"); + let facts = collect_rust_facts(&root, &all_files(&root), &[".".into()]); + let lib = facts + .files + .values() + .find(|file| file.path.ends_with("lib.rs")) + .expect("lib"); + assert!(lib.module.is_none()); + assert!(lib.imports.iter().any(|import| import == "mail")); + assert!(!lib.imports.iter().any(|import| import.starts_with("lib."))); + let mail = facts + .files + .values() + .find(|file| file.path.ends_with("mail.rs")) + .expect("mail"); + assert!(!mail.mods.iter().any(|name| name == "unused")); +} + +#[test] +fn php_reads_readonly_classes_and_leading_route_separators() { + let root = fixture("php-laravel"); + let facts = collect_php_facts(&root, &all_files(&root), &[".".into()], Some("laravel")); + assert!(facts + .declarations + .keys() + .any(|name| name == "App.Dto.UserDto" || name == "UserDto")); + let routes = facts + .files + .values() + .find(|file| file.path.ends_with("web.php")) + .expect("routes"); + assert!(routes + .route_handlers + .iter() + .any(|(route, handler)| { route == "/fq-users" && handler.contains("UserController") })); +} + +#[test] +fn kafka_matches_python_send_and_reordered_subscribe() { + let (produces, consumes) = extract_kafka_topics( + r#" + producer.send("mail.welcome", value=payload) + consumer.subscribe({ fromBeginning: true, topic: "mail.welcome" }) + "#, + ); + assert_eq!(produces, vec!["mail.welcome".to_string()]); + assert_eq!(consumes, vec!["mail.welcome".to_string()]); +} + +#[test] +fn go_records_imports_of_configured_sibling_modules() { + let root = fixture("go-asynq"); + let facts = collect_go_facts( + &root, + &all_files(&root), + &["worker".into(), "nested".into()], + ); + let enqueue = facts + .files + .values() + .find(|file| file.path.ends_with("enqueue.go")) + .expect("enqueue"); + assert!(enqueue + .imports + .iter() + .any(|import| import == "example.com/nested")); +} diff --git a/crates/no-mistakes/src/codebase/mod.rs b/crates/no-mistakes/src/codebase/mod.rs index ceb3f6d70..063bd3dcf 100644 --- a/crates/no-mistakes/src/codebase/mod.rs +++ b/crates/no-mistakes/src/codebase/mod.rs @@ -9,6 +9,7 @@ pub mod dependencies; pub mod dotnet; pub(crate) mod glob_normalize; pub mod import_usages; +pub(crate) mod lang_frontends; pub mod lockfile; pub mod markdown_sections; pub mod md_links; diff --git a/crates/no-mistakes/src/config/v2/schema.rs b/crates/no-mistakes/src/config/v2/schema.rs index d8385435e..8b40cc577 100644 --- a/crates/no-mistakes/src/config/v2/schema.rs +++ b/crates/no-mistakes/src/config/v2/schema.rs @@ -16,9 +16,10 @@ mod tests_config; pub use ci_checks::{CheckCommandDef, CheckFileArgs, ChecksConfig, CiConfig}; pub use infra_config::{InfraConfig, TerraformConfig, TerraformTestConvention}; pub use tests_config::{ - DotnetConfig, DotnetProjectConfig, ImpactConfig, JestConfig, PlaywrightAppBinding, - PlaywrightSelectorWrapper, PlaywrightSelectors, PlaywrightTestConfig, StorybookConfig, - SwiftConfig, TestProjectPolicy, Tests, VitestConfig, + DotnetConfig, DotnetProjectConfig, GoConfig, ImpactConfig, JestConfig, PhpConfig, + PlaywrightAppBinding, PlaywrightSelectorWrapper, PlaywrightSelectors, PlaywrightTestConfig, + PythonConfig, RailsConfig, RustLangConfig, StorybookConfig, SwiftConfig, TestProjectPolicy, + Tests, VitestConfig, }; #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] @@ -118,6 +119,7 @@ pub enum ProjectType { pub struct QueueConfig { pub enqueues: Vec, pub workers: Vec, + pub cluster: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] diff --git a/crates/no-mistakes/src/config/v2/schema/tests_config.rs b/crates/no-mistakes/src/config/v2/schema/tests_config.rs index c0804bb45..505ee14ec 100644 --- a/crates/no-mistakes/src/config/v2/schema/tests_config.rs +++ b/crates/no-mistakes/src/config/v2/schema/tests_config.rs @@ -10,6 +10,11 @@ pub struct Tests { pub vitest: VitestConfig, pub dotnet: DotnetConfig, pub swift: SwiftConfig, + pub python: PythonConfig, + pub go: GoConfig, + pub rust: RustLangConfig, + pub rails: RailsConfig, + pub php: PhpConfig, pub jest: JestConfig, pub storybook: StorybookConfig, pub impact: ImpactConfig, @@ -112,6 +117,37 @@ pub struct SwiftConfig { pub projects: BTreeMap, } +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct PythonConfig { + pub packages: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct GoConfig { + pub modules: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct RustLangConfig { + pub packages: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct RailsConfig { + pub apps: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct PhpConfig { + pub framework: Option, + pub apps: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] #[serde(rename_all = "camelCase", default)] pub struct DotnetConfig { diff --git a/crates/no-mistakes/src/tests/config_invalidation/semantics.rs b/crates/no-mistakes/src/tests/config_invalidation/semantics.rs index 233177ca8..edb4cf3aa 100644 --- a/crates/no-mistakes/src/tests/config_invalidation/semantics.rs +++ b/crates/no-mistakes/src/tests/config_invalidation/semantics.rs @@ -99,6 +99,7 @@ struct GlobalGraphSemantics { // Only these rule options are read by graph configuration preparation. rules: Vec, dynamic_runner_tests: DynamicRunnerTests, + language_frontends: serde_json::Value, } #[derive(PartialEq)] @@ -160,6 +161,13 @@ fn global_graph_semantics(config: &NoMistakesConfig) -> GlobalGraphSemantics { projects, rules: graph_rules(config), dynamic_runner_tests: dynamic_runner_tests(config), + language_frontends: serde_json::json!({ + "python": &config.tests.python, + "go": &config.tests.go, + "rust": &config.tests.rust, + "rails": &config.tests.rails, + "php": &config.tests.php, + }), } } diff --git a/crates/no-mistakes/src/tests/plan_bfs.rs b/crates/no-mistakes/src/tests/plan_bfs.rs index 228091a8b..ec3600dae 100644 --- a/crates/no-mistakes/src/tests/plan_bfs.rs +++ b/crates/no-mistakes/src/tests/plan_bfs.rs @@ -195,6 +195,11 @@ pub(crate) fn impact_reason_label(edge: EdgeKind) -> &'static str { EdgeKind::TerraformReference => "terraform-ref", EdgeKind::TerraformModuleRef => "terraform-module", EdgeKind::TerraformOutputRef => "terraform-output", + EdgeKind::PythonImport | EdgeKind::PythonReference => "python", + EdgeKind::GoImport | EdgeKind::GoReference => "go", + EdgeKind::RustUse | EdgeKind::RustMod | EdgeKind::RustPackage => "rust", + EdgeKind::RubyRequire | EdgeKind::RubyReference => "ruby", + EdgeKind::PhpUse | EdgeKind::PhpPackage => "php", EdgeKind::WorkflowJob => "workflow-job", EdgeKind::WorkflowStep => "workflow-step", EdgeKind::WorkflowNeeds => "workflow-needs", diff --git a/crates/no-mistakes/tests/docs_coverage.rs b/crates/no-mistakes/tests/docs_coverage.rs index d88163874..8d08988dd 100644 --- a/crates/no-mistakes/tests/docs_coverage.rs +++ b/crates/no-mistakes/tests/docs_coverage.rs @@ -310,6 +310,11 @@ fn graph_edge_kinds_are_documented() { "dotnet-using", "dotnet-ref", "dotnet-project", + "python-import", + "go-import", + "rust-use", + "ruby-require", + "php-use", ]; for edge_kind in edge_kinds { assert!( diff --git a/docs/cli/queues.md b/docs/cli/queues.md index 208233730..92847aee3 100644 --- a/docs/cli/queues.md +++ b/docs/cli/queues.md @@ -1,6 +1,12 @@ # `no-mistakes queues` -Analyze queue producer/worker relationships for BullMQ and glide-mq patterns. +Analyze queue producer/worker relationships for BullMQ and glide-mq. + +Configured Celery, Asynq, Kafka, Active Job, and Laravel sites emit the same +canonical `queue-enqueue` / `queue-worker` graph edges. Query those through +`dependents --relationship queue` (or the language filter). The dedicated +`queues` command still reports the TypeScript queue pipeline; wiring those +language edges into `queues edges|related|check` is later work. | Leaf command | Purpose | | --- | --- | diff --git a/docs/configuration/tests.md b/docs/configuration/tests.md index 3efc5a794..171fd6691 100644 --- a/docs/configuration/tests.md +++ b/docs/configuration/tests.md @@ -29,6 +29,23 @@ tests: Selector settings feed Playwright coverage, route impact, and graph edges. +Language frontends are explicit. Empty lists disable analysis: + +```yaml +tests: + python: + packages: [backend] + go: + modules: [services/worker] + rust: + packages: [crates/api] + rails: + apps: [apps/web] + php: + framework: laravel + apps: [services/api] +``` + When `tests.playwright.configs` and `--playwright-config` are both omitted, `no-mistakes` automatically discovers Git-visible `playwright*.config.*` files directly under `--root`. Outside a Git checkout, `.gitignore` and `.ignore` diff --git a/docs/feature-parity.md b/docs/feature-parity.md index 4b2f2740e..56aa7c519 100644 --- a/docs/feature-parity.md +++ b/docs/feature-parity.md @@ -13,8 +13,8 @@ v1 is the Swift/.NET bar plus the named key feature for each stack: a module graph, `tests plan`, and either HTTP routes or queues. Playwright, React, Next.js fetches, call-sites, dead-exports, ecosystem lockfile diffs, and dedicated `no-mistakes python|go|rust|rails|php` CLIs are later work. Agents -use `dependents --relationship ` and `tests plan ` once those -edges and planners ship. +use `dependents --relationship ` now; `tests plan python|go|cargo|rails|php`, +ecosystem lockfiles, and dedicated language CLIs are not started. ## Current Status @@ -23,24 +23,21 @@ edges and planners ship. | TypeScript / JavaScript | yes | Vitest, Playwright | Express, Hono, Koa, Next.js | BullMQ, glide-mq | shipped | | Swift | `swift-import`, `swift-ref`, `swift-package` | `tests plan swift` | no (client `http` edges only) | no | shipped, narrower | | .NET / C# | `dotnet-using`, `dotnet-ref`, `dotnet-project` | `tests plan dotnet` | no | no | shipped, narrower | -| Rust | no | `--test cargo` globs only | no | no | partial: project type, check rules, CI Cargo edges | -| Python, Django, Celery | no | no | no | no | not started | -| Go, Asynq | no | no | no | no | not started | -| Kafka | n/a | n/a | n/a | no | not started | -| Ruby on Rails | no | no | no | no | not started | -| PHP | no | no | no | no | not started | +| Python, Django, Celery | `python-import`, `python-ref` | `--test python` globs | Django `path(` → handler | Celery `.delay(` / `@shared_task` | shipped (v1 extractors) | +| Go, Asynq | `go-import`, `go-ref` | `--test go` globs | no | Asynq `NewTask` / `HandleFunc` | shipped (v1 extractors) | +| Kafka | n/a | n/a | n/a | static topic produce/consume | shipped (v1 extractors) | +| Rust | `rust-use`, `rust-mod` | `--test cargo` globs | no | no | shipped (v1 extractors) | +| Ruby on Rails | `ruby-require`, `ruby-ref` | `--test rails` globs | `routes.rb` `to:` | Active Job `perform_later` | shipped (v1 extractors) | +| PHP | `php-use`, `php-package` | `--test php` globs | Laravel `Route::` | `::dispatch` / `ShouldQueue` | shipped (v1 extractors) | CI workflows and Terraform/OpenTofu are adjacent graph domains, not language frontends. They stay available to every language once files are tracked. -Rust today is not a language frontend. `projects.*.type: rust` exists, -[`rust-max-lines-per-file`](rules/rust-max-lines-per-file.md), -[`rust-no-inline-allows`](rules/rust-no-inline-allows.md), and -[`rust-no-inline-tests`](rules/rust-no-inline-tests.md) run as filesystem -checks, `--test cargo` filters `**/tests/**/*.rs` and `src/**/*_test.rs`, and -`ci` edges connect GitHub Actions workflows to Rust binaries invoked by -supported Cargo commands. There is no `use`/`mod` graph, no `tests plan cargo`, -and no Rust CLI. +Rust v1 is a language frontend for configured `tests.rust.packages`: `use +crate/super/self` and `pub` declarations emit `rust-use` / `rust-mod` edges. +The existing `rust-*` filesystem rules, `--test cargo` globs, and `ci` Cargo +binary edges remain. There is still no `tests plan cargo` and no `no-mistakes +rust` CLI. ## Canonical Feature Set @@ -64,12 +61,18 @@ declarations and references into facts and project them as `*-ref` edges. graph exists. Full-suite fallback remains explicit opt-in. **HTTP routes.** `server routes`, `server edges`, `server related`, and -`server contracts` list configured route definitions and static client calls. -Do not invent a second route graph. +`server contracts` list configured TS/JS route definitions and static client +calls. Language v1 extractors emit `route` edges into `DepGraph` for Django, +Rails, and Laravel; query those with `dependents --relationship route`. +`server routes` does not consume language `RouteRef` facts yet. Do not invent +a second route graph. **Queues.** `queues edges`, `queues related`, and `queues check` connect -producers to virtual job nodes to workers. Celery, Asynq, and Kafka extend this -domain. They do not get private graph shapes. +TS/JS producers to virtual job nodes to workers. Celery, Asynq, Kafka, Active +Job, and Laravel emit the same `queue-enqueue` / `queue-worker` edges into +`DepGraph`; query those with `dependents --relationship queue`. The dedicated +`queues` commands still report the TypeScript pipeline. They do not get +private graph shapes. **HTTP clients.** Static client calls produce `http` edges to matching route files, the same way TS `fetch` and Swift `Endpoint` literals do. @@ -345,9 +348,8 @@ opt-in. ## Agent Fallback Until a row in the status table is `shipped`, agents should keep using `rg` -for that language. The shipped `no-mistakes` skill already says Go and Rust -sources have no import-graph domain. That remains correct until the graph -edges and test planner land. +for that language. v1 extractors cover the module graph plus named routes or +queues; `tests plan `, lockfiles, and dedicated CLIs remain not started. See [Architecture](architecture.md) for the one-pass session rules, [Graph edges](graph-edges.md) for the current edge kinds, and diff --git a/docs/graph-edges.md b/docs/graph-edges.md index 4450f4a38..28ef20110 100644 --- a/docs/graph-edges.md +++ b/docs/graph-edges.md @@ -58,6 +58,21 @@ intentionally not added to unfiltered `dependencies --relationship all` output. | `terraform-ref` | `TerraformReference` | `terraform` | Terraform file referencing `.` -> file declaring that resource/data source | [`terraform-basic`](../test-cases/codebase-analysis/terraform-basic) | | `terraform-module` | `TerraformModuleRef` | `terraform` | Terraform file with a `module` block -> files in the module's local source directory | [`terraform-basic`](../test-cases/codebase-analysis/terraform-basic) | | `terraform-output` | `TerraformOutputRef` | `terraform` | Terraform file referencing `module..` -> file declaring that output | [`terraform-basic`](../test-cases/codebase-analysis/terraform-basic) | +| `python-import` | `PythonImport` | `python` | Python file -> module file of a static import | [`python-celery-django`](../fixtures/lang-frontends/python-celery-django) | +| `python-ref` | `PythonReference` | `python` | Python file -> file declaring a referenced name | [`python-celery-django`](../fixtures/lang-frontends/python-celery-django) | +| `go-import` | `GoImport` | `go` | Go file -> local files in an imported configured package | [`go-asynq`](../fixtures/lang-frontends/go-asynq) | +| `go-ref` | `GoReference` | `go` | Go file -> file declaring an exported name | [`go-asynq`](../fixtures/lang-frontends/go-asynq) | +| `rust-use` | `RustUse` | `rust` | Rust file -> file reached through `use crate/super/self` | [`rust-mods`](../fixtures/lang-frontends/rust-mods) | +| `rust-mod` | `RustMod` | `rust` | Rust file -> sibling `mod` file | [`rust-mods`](../fixtures/lang-frontends/rust-mods) | +| `rust-package` | `RustPackage` | `rust` | Rust crate -> files in a configured package | [`rust-mods`](../fixtures/lang-frontends/rust-mods) | +| `ruby-require` | `RubyRequire` | `ruby` | Ruby file -> required local file | [`rails-jobs`](../fixtures/lang-frontends/rails-jobs) | +| `ruby-ref` | `RubyReference` | `ruby` | Ruby file -> file declaring a referenced constant | [`rails-jobs`](../fixtures/lang-frontends/rails-jobs) | +| `php-use` | `PhpUse` | `php` | PHP file -> file declaring a used class | [`php-laravel`](../fixtures/lang-frontends/php-laravel) | +| `php-package` | `PhpPackage` | `php` | PHP file -> files in a configured Composer app | [`php-laravel`](../fixtures/lang-frontends/php-laravel) | + +Python, Go, Rust, Ruby, and PHP edges require matching `tests.` package, +module, or app lists. Empty lists disable that frontend. See +[Tests and selectors](configuration/tests.md). For `server edges` and `server related`, filters limit client-call source files; they do not narrow the prepared resolver/fact universe used to follow a static @@ -98,6 +113,11 @@ their configured roots, mounts, test exclusions, and any explicit filter. | `dotnet` | `dotnet-using`, `dotnet-ref`, `dotnet-project` | | `swift` | `swift-import`, `swift-ref`, `swift-package` | | `terraform` | `terraform-ref`, `terraform-module`, `terraform-output` | +| `python` | `python-import`, `python-ref` | +| `go` | `go-import`, `go-ref` | +| `rust` | `rust-use`, `rust-mod`, `rust-package` | +| `ruby` | `ruby-require`, `ruby-ref` | +| `php` | `php-use`, `php-package` | | `all` | all standard edge kinds, including `workflow`; excludes the opt-in `route-import` alternate view | Workflow virtual-node IDs are stable and project-relative: diff --git a/fixtures/lang-frontends/go-asynq/.no-mistakes.yml b/fixtures/lang-frontends/go-asynq/.no-mistakes.yml new file mode 100644 index 000000000..e8ec801b2 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/.no-mistakes.yml @@ -0,0 +1,4 @@ +tests: + go: + modules: + - worker diff --git a/fixtures/lang-frontends/go-asynq/go.mod b/fixtures/lang-frontends/go-asynq/go.mod new file mode 100644 index 000000000..8296230de --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/go.mod @@ -0,0 +1,5 @@ +module example.com/worker + +go 1.22 + +require github.com/hibiken/asynq v0.25.1 diff --git a/fixtures/lang-frontends/go-asynq/nested/go.mod b/fixtures/lang-frontends/go-asynq/nested/go.mod new file mode 100644 index 000000000..ac2715a69 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/nested/go.mod @@ -0,0 +1,3 @@ +module example.com/nested // isolated nested module + +go 1.22 diff --git a/fixtures/lang-frontends/go-asynq/nested/mail.go b/fixtures/lang-frontends/go-asynq/nested/mail.go new file mode 100644 index 000000000..9ca19e846 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/nested/mail.go @@ -0,0 +1 @@ +package mail diff --git a/fixtures/lang-frontends/go-asynq/worker/enqueue.go b/fixtures/lang-frontends/go-asynq/worker/enqueue.go new file mode 100644 index 000000000..b1910bfbc --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/enqueue.go @@ -0,0 +1,16 @@ +package worker + +import fmtlib "fmt" + +import ( + "example.com/nested" + "github.com/hibiken/asynq" +) + +func EnqueueWelcome(client *asynq.Client) error { + _, err := client.Enqueue(asynq.NewTask("mail:welcome", nil)) + if err != nil { + return fmtlib.Errorf("enqueue: %w", err) + } + return nil +} diff --git a/fixtures/lang-frontends/go-asynq/worker/mail/user.go b/fixtures/lang-frontends/go-asynq/worker/mail/user.go new file mode 100644 index 000000000..8afdf8f1c --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/mail/user.go @@ -0,0 +1,3 @@ +package mail + +type User struct{} diff --git a/fixtures/lang-frontends/go-asynq/worker/pkg/dot.go b/fixtures/lang-frontends/go-asynq/worker/pkg/dot.go new file mode 100644 index 000000000..5462f19f6 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/pkg/dot.go @@ -0,0 +1,3 @@ +package pkg + +import . "mail" diff --git a/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go b/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go new file mode 100644 index 000000000..1e9613166 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go @@ -0,0 +1,7 @@ +package pkg + +const doc = `LegacyUser` + +func Ping() User { + return User{} +} diff --git a/fixtures/lang-frontends/go-asynq/worker/pkg/ping_test.go b/fixtures/lang-frontends/go-asynq/worker/pkg/ping_test.go new file mode 100644 index 000000000..d6139abf1 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/pkg/ping_test.go @@ -0,0 +1,3 @@ +package pkg + +func TestPing(t *testing.T) {} diff --git a/fixtures/lang-frontends/go-asynq/worker/pkg/user.go b/fixtures/lang-frontends/go-asynq/worker/pkg/user.go new file mode 100644 index 000000000..41863ac80 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/pkg/user.go @@ -0,0 +1,7 @@ +package pkg + +type User struct{} + +const ( + DefaultTimeout = 30 +) diff --git a/fixtures/lang-frontends/go-asynq/worker/tasks.go b/fixtures/lang-frontends/go-asynq/worker/tasks.go new file mode 100644 index 000000000..803aebd7f --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/tasks.go @@ -0,0 +1,17 @@ +package worker + +import ( + "context" + + "github.com/hibiken/asynq" +) + +type WelcomePayload struct{} + +func HandleWelcome(ctx context.Context, task *asynq.Task) error { + return nil +} + +func Register(mux *asynq.ServeMux) { + mux.HandleFunc("mail:welcome", HandleWelcome) +} diff --git a/fixtures/lang-frontends/kafka-topics/.no-mistakes.yml b/fixtures/lang-frontends/kafka-topics/.no-mistakes.yml new file mode 100644 index 000000000..100b79a9a --- /dev/null +++ b/fixtures/lang-frontends/kafka-topics/.no-mistakes.yml @@ -0,0 +1,8 @@ +projects: + mail: + type: server + root: . + queues: + cluster: orders + enqueues: ["producer.ts"] + workers: ["consumer.ts"] diff --git a/fixtures/lang-frontends/kafka-topics/consumer.ts b/fixtures/lang-frontends/kafka-topics/consumer.ts new file mode 100644 index 000000000..d36fe9391 --- /dev/null +++ b/fixtures/lang-frontends/kafka-topics/consumer.ts @@ -0,0 +1,6 @@ +export function listen(consumer: { + subscribe: (input: { topic: string } | string[]) => void; +}) { + consumer.subscribe({ fromBeginning: true, topic: "mail.welcome" }); + consumer.subscribe(["orders", "payments"]); +} diff --git a/fixtures/lang-frontends/kafka-topics/producer.py b/fixtures/lang-frontends/kafka-topics/producer.py new file mode 100644 index 000000000..7676e5663 --- /dev/null +++ b/fixtures/lang-frontends/kafka-topics/producer.py @@ -0,0 +1,2 @@ +def send_welcome(producer): + producer.send("mail.welcome", value={"ok": True}) diff --git a/fixtures/lang-frontends/kafka-topics/producer.ts b/fixtures/lang-frontends/kafka-topics/producer.ts new file mode 100644 index 000000000..f4f6d6f5c --- /dev/null +++ b/fixtures/lang-frontends/kafka-topics/producer.ts @@ -0,0 +1,3 @@ +export async function sendWelcome(producer: { send: (input: { topic: string }) => Promise }) { + await producer.send({ topic: "mail.welcome" }); +} diff --git a/fixtures/lang-frontends/php-laravel/.no-mistakes.yml b/fixtures/lang-frontends/php-laravel/.no-mistakes.yml new file mode 100644 index 000000000..1f27a734a --- /dev/null +++ b/fixtures/lang-frontends/php-laravel/.no-mistakes.yml @@ -0,0 +1,5 @@ +tests: + php: + framework: laravel + apps: + - . diff --git a/fixtures/lang-frontends/php-laravel/app/Contracts/Mailer.php b/fixtures/lang-frontends/php-laravel/app/Contracts/Mailer.php new file mode 100644 index 000000000..a120bc9e7 --- /dev/null +++ b/fixtures/lang-frontends/php-laravel/app/Contracts/Mailer.php @@ -0,0 +1,7 @@ + None: + send_welcome.delay(user_id) diff --git a/fixtures/lang-frontends/python-celery-django/app/tasks.py b/fixtures/lang-frontends/python-celery-django/app/tasks.py new file mode 100644 index 000000000..1b0b6b9a5 --- /dev/null +++ b/fixtures/lang-frontends/python-celery-django/app/tasks.py @@ -0,0 +1,5 @@ +from celery import shared_task + +@shared_task(name="mail.send_welcome") +def send_welcome(user_id: int) -> None: + return None diff --git a/fixtures/lang-frontends/python-celery-django/app/urls.py b/fixtures/lang-frontends/python-celery-django/app/urls.py new file mode 100644 index 000000000..343e5e784 --- /dev/null +++ b/fixtures/lang-frontends/python-celery-django/app/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from app.billing import views as billing_views +from app.users import views + +urlpatterns = [ + path("", views.user_list), + path("api/", include("app.api.urls")), + path("api/users/", views.user_list), + path("users/", views.UserView.as_view()), + path("users-index/", views.index), + path("billing-index/", billing_views.index), +] diff --git a/fixtures/lang-frontends/python-celery-django/app/users/__init__.py b/fixtures/lang-frontends/python-celery-django/app/users/__init__.py new file mode 100644 index 000000000..5bb534f79 --- /dev/null +++ b/fixtures/lang-frontends/python-celery-django/app/users/__init__.py @@ -0,0 +1 @@ +# package diff --git a/fixtures/lang-frontends/python-celery-django/app/users/models.py b/fixtures/lang-frontends/python-celery-django/app/users/models.py new file mode 100644 index 000000000..24d95090e --- /dev/null +++ b/fixtures/lang-frontends/python-celery-django/app/users/models.py @@ -0,0 +1,2 @@ +class User: + name = "user" diff --git a/fixtures/lang-frontends/python-celery-django/app/users/views.py b/fixtures/lang-frontends/python-celery-django/app/users/views.py new file mode 100644 index 000000000..470a15f1b --- /dev/null +++ b/fixtures/lang-frontends/python-celery-django/app/users/views.py @@ -0,0 +1,18 @@ +from . import models +from .models import User +from app.tasks import * + +""" +import app.fake_docstring +class LegacyUser: + pass +""" + +def user_list(): + return User + +def index(): + return User + +class UserView: + pass diff --git a/fixtures/lang-frontends/queue-prefix/.no-mistakes.yml b/fixtures/lang-frontends/queue-prefix/.no-mistakes.yml new file mode 100644 index 000000000..6821e6368 --- /dev/null +++ b/fixtures/lang-frontends/queue-prefix/.no-mistakes.yml @@ -0,0 +1,28 @@ +projects: + api: + type: server + root: backend + queues: + cluster: api + enqueues: + - backend/app/**/*.py + - app/**/*.py + workers: + - app/**/*.py + other: + type: server + root: app + queues: + cluster: other + enqueues: + - application/**/*.py + workers: + - application/**/*.py + bare: + type: server + root: bare + queues: + enqueues: + - '*.py' + workers: + - '*.py' diff --git a/fixtures/lang-frontends/queue-prefix/backend/app/tasks.py b/fixtures/lang-frontends/queue-prefix/backend/app/tasks.py new file mode 100644 index 000000000..c4b457ac6 --- /dev/null +++ b/fixtures/lang-frontends/queue-prefix/backend/app/tasks.py @@ -0,0 +1,2 @@ +def ping(): + pass diff --git a/fixtures/lang-frontends/rails-jobs/.no-mistakes.yml b/fixtures/lang-frontends/rails-jobs/.no-mistakes.yml new file mode 100644 index 000000000..5cbd861cc --- /dev/null +++ b/fixtures/lang-frontends/rails-jobs/.no-mistakes.yml @@ -0,0 +1,4 @@ +tests: + rails: + apps: + - . diff --git a/fixtures/lang-frontends/rails-jobs/app/controllers/admin/users_controller.rb b/fixtures/lang-frontends/rails-jobs/app/controllers/admin/users_controller.rb new file mode 100644 index 000000000..56842c1d1 --- /dev/null +++ b/fixtures/lang-frontends/rails-jobs/app/controllers/admin/users_controller.rb @@ -0,0 +1,4 @@ +class Admin::UsersController + def index + end +end diff --git a/fixtures/lang-frontends/rails-jobs/app/controllers/users_controller.rb b/fixtures/lang-frontends/rails-jobs/app/controllers/users_controller.rb new file mode 100644 index 000000000..888180981 --- /dev/null +++ b/fixtures/lang-frontends/rails-jobs/app/controllers/users_controller.rb @@ -0,0 +1,10 @@ +require "app/jobs/welcome_job" +require_relative "../jobs/welcome_job" +require_relative "/missing/outside" + +class UsersController + def index + WelcomeJob.perform_later + Admin::User + end +end diff --git a/fixtures/lang-frontends/rails-jobs/app/jobs/welcome_job.rb b/fixtures/lang-frontends/rails-jobs/app/jobs/welcome_job.rb new file mode 100644 index 000000000..efd661ee0 --- /dev/null +++ b/fixtures/lang-frontends/rails-jobs/app/jobs/welcome_job.rb @@ -0,0 +1,4 @@ +class WelcomeJob < ApplicationJob + def perform + end +end diff --git a/fixtures/lang-frontends/rails-jobs/app/models/admin/user.rb b/fixtures/lang-frontends/rails-jobs/app/models/admin/user.rb new file mode 100644 index 000000000..5b8af4f6d --- /dev/null +++ b/fixtures/lang-frontends/rails-jobs/app/models/admin/user.rb @@ -0,0 +1,2 @@ +class Admin::User +end diff --git a/fixtures/lang-frontends/rails-jobs/app/services/admin.rb b/fixtures/lang-frontends/rails-jobs/app/services/admin.rb new file mode 100644 index 000000000..2679ab8fa --- /dev/null +++ b/fixtures/lang-frontends/rails-jobs/app/services/admin.rb @@ -0,0 +1,4 @@ +module Admin + class Ledger + end +end diff --git a/fixtures/lang-frontends/rails-jobs/config/routes.rb b/fixtures/lang-frontends/rails-jobs/config/routes.rb new file mode 100644 index 000000000..d1cb12dfc --- /dev/null +++ b/fixtures/lang-frontends/rails-jobs/config/routes.rb @@ -0,0 +1,4 @@ +Rails.application.routes.draw do + get "/api/users", to: "users#index" + get "/admin/users", to: "admin/users#index" +end diff --git a/fixtures/lang-frontends/rust-mods/.no-mistakes.yml b/fixtures/lang-frontends/rust-mods/.no-mistakes.yml new file mode 100644 index 000000000..de37b8e7c --- /dev/null +++ b/fixtures/lang-frontends/rust-mods/.no-mistakes.yml @@ -0,0 +1,4 @@ +tests: + rust: + packages: + - . diff --git a/fixtures/lang-frontends/rust-mods/src/aaa/helper.rs b/fixtures/lang-frontends/rust-mods/src/aaa/helper.rs new file mode 100644 index 000000000..19910d5b7 --- /dev/null +++ b/fixtures/lang-frontends/rust-mods/src/aaa/helper.rs @@ -0,0 +1 @@ +pub struct Item; diff --git a/fixtures/lang-frontends/rust-mods/src/aaa/mod.rs b/fixtures/lang-frontends/rust-mods/src/aaa/mod.rs new file mode 100644 index 000000000..ef8e1d39b --- /dev/null +++ b/fixtures/lang-frontends/rust-mods/src/aaa/mod.rs @@ -0,0 +1,3 @@ +pub mod helper; + +pub fn nested() {} diff --git a/fixtures/lang-frontends/rust-mods/src/lib.rs b/fixtures/lang-frontends/rust-mods/src/lib.rs new file mode 100644 index 000000000..d42a9d014 --- /dev/null +++ b/fixtures/lang-frontends/rust-mods/src/lib.rs @@ -0,0 +1,11 @@ +pub mod aaa; +pub mod mail; + +use crate::{aaa, mail as delivery}; +use crate::aaa::helper::Item; +pub use crate::mail::Welcome; +use self::mail::Welcome as LocalWelcome; + +pub fn send() { + Welcome::emit(); +} diff --git a/fixtures/lang-frontends/rust-mods/src/mail.rs b/fixtures/lang-frontends/rust-mods/src/mail.rs new file mode 100644 index 000000000..e13445ca3 --- /dev/null +++ b/fixtures/lang-frontends/rust-mods/src/mail.rs @@ -0,0 +1,9 @@ +pub struct Welcome; + +impl Welcome { + pub fn emit() {} +} + +mod unused { + pub fn skip() {} +} diff --git a/fixtures/lang-frontends/rust-mods/src/mod.rs b/fixtures/lang-frontends/rust-mods/src/mod.rs new file mode 100644 index 000000000..7c361e2a6 --- /dev/null +++ b/fixtures/lang-frontends/rust-mods/src/mod.rs @@ -0,0 +1 @@ +mod mail; diff --git a/fixtures/lang-frontends/rust-mods/src/unused.rs b/fixtures/lang-frontends/rust-mods/src/unused.rs new file mode 100644 index 000000000..3b4f01ee9 --- /dev/null +++ b/fixtures/lang-frontends/rust-mods/src/unused.rs @@ -0,0 +1 @@ +pub fn unused_file() {} diff --git a/packages/no-mistakes/traversal-types.d.ts b/packages/no-mistakes/traversal-types.d.ts index 2b72120e0..7509e2905 100644 --- a/packages/no-mistakes/traversal-types.d.ts +++ b/packages/no-mistakes/traversal-types.d.ts @@ -26,6 +26,11 @@ export type Relationship = | "dotnet" | "swift" | "terraform" + | "python" + | "go" + | "rust" + | "ruby" + | "php" | "resource" | "all"; diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md index 7aaea5705..f61c3466e 100644 --- a/skills/no-mistakes/SKILL.md +++ b/skills/no-mistakes/SKILL.md @@ -33,7 +33,7 @@ Dotnet/C# test-impact edges participate in the canonical graph via configured prose). For structural graph questions outside TS/JS, see Command Selection: `.yml` → `ci` · `.tf` → `infra` · `.swift` → `swift` · Rust binary CI impact → `--relationship ci` · CSS/JSON asset imports → -`--relationship asset`. Go source files have no graph domain — use `rg`. +`--relationship asset`. Go/Python/Rust/Rails/PHP graphs require explicit `tests.` config — see https://github.com/jonathanong/no-mistakes/blob/main/docs/feature-parity.md. For "what directly imports this one file?" in a single directory, `no-mistakes importers ` is faster than a full graph walk. @@ -248,8 +248,8 @@ with `includeSuppressed: true`. - `--depth ` to limit traversal depth. - `--filter ` to include only matching files; repeatable. - `--target-module ` to include only matching external module nodes (useful with `--relationship package`). -- `--test vitest|playwright|cargo|dotnet|swift` to filter to test files. -- `--relationship import|import-static|import-dynamic|import-type|import-require|route-import|workspace|package|test|route|queue|resource|md|ci|workflow|workflow-job|workflow-step|workflow-needs|workflow-uses|workflow-run|workflow-artifact|http|process|asset|react|dotnet|swift|terraform|all`. +- `--test vitest|playwright|cargo|dotnet|swift|python|go|rails|php` to filter to test files. +- `--relationship import|import-static|import-dynamic|import-type|import-require|route-import|workspace|package|test|route|queue|resource|md|ci|workflow|workflow-job|workflow-step|workflow-needs|workflow-uses|workflow-run|workflow-artifact|http|process|asset|react|dotnet|swift|terraform|python|go|rust|ruby|php|all`. - `--direction deps|dependents|both` for `queues related` and `server related`. - `--format json|md|yml|paths|human`, `--json`, root-global `--timings` / `--verbose-timings` (stderr), and `--jobs`. @@ -308,7 +308,7 @@ member usage. static when agent-readable analysis is required. - Selector text edges are approximate; exact configured test ID selector edges are stronger evidence. -- Non-TS/JS files are not walked for import edges; use `rg` for Go, Rust, CSS, JSON. +- Non-TS/JS files are not walked for import edges unless the matching language frontend is configured; use `rg` for CSS/JSON and unconfigured languages. - `tests plan` works without `testPlan` in `.no-mistakes.yml` (uses default direct + dependencies groups). Configure `testPlan` to add environments, custom limits, coverage groups (Playwright only), and global-config triggers.