From 5936e1a442e2e3911acc42f094dbd49a5c9c8e94 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 05:08:59 -0700 Subject: [PATCH 01/26] feat: add language-frontend extractors for graph parity Add heuristic Python, Go, Rust, Rails, and PHP fact collectors and emit language, queue, and route edges from configured packages. Celery, Asynq, Kafka topics, Active Job, Django URLconfs, and Laravel routes use the canonical graph. --- .../dependencies/args_relationships.rs | 10 + .../dependencies/args_relationships_filter.rs | 16 ++ .../codebase/dependencies/args_test_globs.rs | 8 + .../dependencies/graph/builder_helpers.rs | 17 ++ .../graph/builder_remaining_edges.rs | 3 + .../dependencies/graph/edge_lang_frontends.rs | 175 ++++++++++++++++++ .../dependencies/graph/files_config.rs | 29 ++- .../src/codebase/dependencies/graph/mod.rs | 1 + .../dependencies/graph/tests/config_edges.rs | 24 +++ .../graph/tests/config_fact_plan.rs | 8 + .../dependencies/graph/tests/dotnet_edges.rs | 8 + .../graph/tests/extra_symbol_defensive.rs | 8 + .../dependencies/graph/types_edges.rs | 22 +++ .../dependencies/graph/types_edges_sort.rs | 24 ++- .../dependencies/output/tests/edge_kinds.rs | 22 +++ .../src/codebase/dependencies/tests/args.rs | 8 + .../src/codebase/lang_frontends/facts.rs | 105 +++++++++++ .../src/codebase/lang_frontends/go.rs | 104 +++++++++++ .../src/codebase/lang_frontends/kafka.rs | 55 ++++++ .../src/codebase/lang_frontends/mod.rs | 52 ++++++ .../src/codebase/lang_frontends/php.rs | 95 ++++++++++ .../src/codebase/lang_frontends/python.rs | 161 ++++++++++++++++ .../src/codebase/lang_frontends/ruby.rs | 105 +++++++++++ .../src/codebase/lang_frontends/rustlang.rs | 80 ++++++++ .../src/codebase/lang_frontends/strip.rs | 69 +++++++ .../src/codebase/lang_frontends/tests.rs | 160 ++++++++++++++++ crates/no-mistakes/src/codebase/mod.rs | 1 + crates/no-mistakes/src/config/v2/schema.rs | 8 +- .../src/config/v2/schema/tests_config.rs | 36 ++++ crates/no-mistakes/src/tests/plan_bfs.rs | 5 + crates/no-mistakes/tests/docs_coverage.rs | 5 + docs/cli/queues.md | 3 +- docs/configuration/tests.md | 17 ++ docs/feature-parity.md | 11 +- docs/graph-edges.md | 16 ++ .../lang-frontends/go-asynq/.no-mistakes.yml | 4 + .../lang-frontends/go-asynq/worker/enqueue.go | 8 + .../lang-frontends/go-asynq/worker/tasks.go | 11 ++ .../kafka-topics/.no-mistakes.yml | 8 + .../lang-frontends/kafka-topics/consumer.ts | 3 + .../lang-frontends/kafka-topics/producer.ts | 3 + .../php-laravel/.no-mistakes.yml | 5 + .../app/Http/Controllers/UserController.php | 13 ++ .../php-laravel/app/Jobs/SomeJob.php | 7 + .../lang-frontends/php-laravel/routes/web.php | 5 + .../python-celery-django/.no-mistakes.yml | 12 ++ .../python-celery-django/app/__init__.py | 1 + .../python-celery-django/app/enqueue.py | 4 + .../python-celery-django/app/tasks.py | 5 + .../python-celery-django/app/urls.py | 6 + .../app/users/__init__.py | 1 + .../python-celery-django/app/users/models.py | 2 + .../python-celery-django/app/users/views.py | 4 + .../rails-jobs/.no-mistakes.yml | 4 + .../app/controllers/users_controller.rb | 5 + .../rails-jobs/app/jobs/welcome_job.rb | 4 + .../rails-jobs/config/routes.rb | 3 + .../lang-frontends/rust-mods/.no-mistakes.yml | 4 + fixtures/lang-frontends/rust-mods/src/lib.rs | 7 + fixtures/lang-frontends/rust-mods/src/mail.rs | 5 + packages/no-mistakes/traversal-types.d.ts | 5 + skills/no-mistakes/SKILL.md | 8 +- 62 files changed, 1603 insertions(+), 15 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/facts.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/go.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/kafka.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/mod.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/php.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/python.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/ruby.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/strip.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/tests.rs create mode 100644 fixtures/lang-frontends/go-asynq/.no-mistakes.yml create mode 100644 fixtures/lang-frontends/go-asynq/worker/enqueue.go create mode 100644 fixtures/lang-frontends/go-asynq/worker/tasks.go create mode 100644 fixtures/lang-frontends/kafka-topics/.no-mistakes.yml create mode 100644 fixtures/lang-frontends/kafka-topics/consumer.ts create mode 100644 fixtures/lang-frontends/kafka-topics/producer.ts create mode 100644 fixtures/lang-frontends/php-laravel/.no-mistakes.yml create mode 100644 fixtures/lang-frontends/php-laravel/app/Http/Controllers/UserController.php create mode 100644 fixtures/lang-frontends/php-laravel/app/Jobs/SomeJob.php create mode 100644 fixtures/lang-frontends/php-laravel/routes/web.php create mode 100644 fixtures/lang-frontends/python-celery-django/.no-mistakes.yml create mode 100644 fixtures/lang-frontends/python-celery-django/app/__init__.py create mode 100644 fixtures/lang-frontends/python-celery-django/app/enqueue.py create mode 100644 fixtures/lang-frontends/python-celery-django/app/tasks.py create mode 100644 fixtures/lang-frontends/python-celery-django/app/urls.py create mode 100644 fixtures/lang-frontends/python-celery-django/app/users/__init__.py create mode 100644 fixtures/lang-frontends/python-celery-django/app/users/models.py create mode 100644 fixtures/lang-frontends/python-celery-django/app/users/views.py create mode 100644 fixtures/lang-frontends/rails-jobs/.no-mistakes.yml create mode 100644 fixtures/lang-frontends/rails-jobs/app/controllers/users_controller.rb create mode 100644 fixtures/lang-frontends/rails-jobs/app/jobs/welcome_job.rb create mode 100644 fixtures/lang-frontends/rails-jobs/config/routes.rb create mode 100644 fixtures/lang-frontends/rust-mods/.no-mistakes.yml create mode 100644 fixtures/lang-frontends/rust-mods/src/lib.rs create mode 100644 fixtures/lang-frontends/rust-mods/src/mail.rs diff --git a/crates/no-mistakes/src/codebase/dependencies/args_relationships.rs b/crates/no-mistakes/src/codebase/dependencies/args_relationships.rs index 9a671db9c..e1fae31c1 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..231ddacf5 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,14 @@ 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", + ]), + "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/builder_helpers.rs b/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs index ac581e549..a2e705043 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/builder_helpers.rs @@ -115,6 +115,23 @@ fn merge_dotnet_edges( merge_edges(forward, reverse, dotnet_edges); } +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 merge_terraform_edges( inputs: &GraphEdgeBuildInputs<'_>, forward: &mut EdgeMap, 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 d6e83765f..5988b298c 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 @@ -165,6 +165,9 @@ fn collect_remaining_edges( crate::perf_trace::trace("graph.terraform", || { merge_terraform_edges(edge_inputs, forward, reverse); }); + crate::perf_trace::trace("graph.language_frontends", || { + 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_frontends.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs new file mode 100644 index 000000000..875b04c0f --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -0,0 +1,175 @@ +use crate::codebase::lang_frontends::{ + collect_all_lang_facts, scan_kafka_file, topic_identity, LangFactMap, 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::RustMod, &mut edges); + emit_lang_edges(&facts.ruby, EdgeKind::RubyRequire, EdgeKind::RubyReference, &mut edges); + emit_lang_edges(&facts.php, EdgeKind::PhpUse, EdgeKind::PhpPackage, &mut edges); + emit_queue_edges(&facts.python, options.queue_cluster.as_deref(), &mut edges); + emit_queue_edges(&facts.go, options.queue_cluster.as_deref(), &mut edges); + emit_queue_edges(&facts.ruby, options.queue_cluster.as_deref(), &mut edges); + emit_queue_edges(&facts.php, options.queue_cluster.as_deref(), &mut edges); + emit_route_edges(&facts.python, &mut edges); + emit_route_edges(&facts.ruby, &mut edges); + emit_route_edges(&facts.php, &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(), + } +} + +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) { + push_file_edges(edges, &file.path, targets, import_kind); + } + } + for reference in &file.references { + if let Some(targets) = facts.declarations.get(reference) { + push_file_edges(edges, &file.path, targets, ref_kind); + } + } + } +} + +fn emit_queue_edges(facts: &LangFactMap, cluster: Option<&str>, edges: &mut Vec) { + let mut workers: std::collections::HashMap = std::collections::HashMap::new(); + for file in facts.files.values() { + for job in &file.queue_workers { + workers.insert(topic_identity(cluster, job), file.path.clone()); + } + } + for file in facts.files.values() { + for job in &file.queue_enqueues { + let identity = topic_identity(cluster, job); + let node = NodeId::QueueJob { + queue_file: file.path.clone(), + job: identity.clone(), + }; + edges.push((NodeId::File(file.path.clone()), node.clone(), EdgeKind::QueueEnqueue)); + if let Some(worker) = workers.get(&identity) { + edges.push((node, NodeId::File(worker.clone()), EdgeKind::QueueWorker)); + } + } + } +} + +fn emit_route_edges(facts: &LangFactMap, edges: &mut Vec) { + for file in facts.files.values() { + for (_, handler) in &file.route_handlers { + let name = handler.rsplit('.').next().unwrap_or(handler); + if let Some(targets) = facts.declarations.get(name) { + push_file_edges(edges, &file.path, targets, EdgeKind::RouteRef); + } + } + } +} + +fn emit_kafka_edges( + root: &Path, + all_files: &[PathBuf], + options: &GraphConfigOptions, + edges: &mut Vec, +) { + let cluster = options.queue_cluster.as_deref(); + let mut produces = Vec::new(); + let mut consumes = Vec::new(); + for path in all_files { + let Some((prod, cons)) = scan_kafka_file(path) else { + continue; + }; + let rel = path.strip_prefix(root).unwrap_or(path); + let rel_s = rel.to_string_lossy(); + if matches_any(&rel_s, &options.queue_enqueues) { + produces.push((path.clone(), prod)); + } + if matches_any(&rel_s, &options.queue_workers) { + consumes.push((path.clone(), cons)); + } + } + let mut workers = std::collections::HashMap::new(); + for (path, topics) in &consumes { + for topic in topics { + workers.insert(topic_identity(cluster, topic), path.clone()); + } + } + for (path, topics) in produces { + for topic in topics { + let identity = topic_identity(cluster, &topic); + let node = NodeId::QueueJob { + queue_file: path.clone(), + job: identity.clone(), + }; + edges.push((NodeId::File(path.clone()), node.clone(), EdgeKind::QueueEnqueue)); + if let Some(worker) = workers.get(&identity) { + edges.push((node, NodeId::File(worker.clone()), 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)) + }) +} + +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.to_path_buf()), + NodeId::File(target.clone()), + kind, + )); + } + } +} 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..70219b487 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,14 @@ 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, + queue_enqueues: Vec, + queue_workers: Vec, + queue_cluster: Option, terraform: crate::config::v2::schema::TerraformConfig, ci: crate::config::v2::schema::CiConfig, } @@ -108,6 +116,25 @@ 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(), + queue_enqueues: v2_config + .projects + .values() + .flat_map(|project| project.queues.enqueues.iter().cloned()) + .collect(), + queue_workers: v2_config + .projects + .values() + .flat_map(|project| project.queues.workers.iter().cloned()) + .collect(), + queue_cluster: v2_config + .projects + .values() + .find_map(|project| project.queues.cluster.clone()), terraform: v2_config.infra.terraform.clone(), ci: v2_config.ci.clone(), } diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs b/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs index e88c4c1d5..d3e515611 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs @@ -35,6 +35,7 @@ include!("builder_core.rs"); include!("builder_edges.rs"); include!("builder_remaining_edges.rs"); include!("builder_helpers.rs"); +include!("edge_lang_frontends.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..e763c7fa5 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 @@ -404,6 +404,14 @@ fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { 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![], + queue_enqueues: vec![], + queue_workers: vec![], + queue_cluster: None, terraform: Default::default(), ci: crate::config::v2::schema::CiConfig::default(), }; @@ -423,6 +431,14 @@ fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { 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![], + queue_enqueues: vec![], + queue_workers: vec![], + queue_cluster: None, terraform: Default::default(), ci: crate::config::v2::schema::CiConfig::default(), }; @@ -464,6 +480,14 @@ fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { 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![], + queue_enqueues: vec![], + queue_workers: vec![], + queue_cluster: None, terraform: Default::default(), ci: crate::config::v2::schema::CiConfig::default(), }; 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..81d6e5e88 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,14 @@ 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![], + queue_enqueues: vec![], + queue_workers: vec![], + queue_cluster: None, terraform: Default::default(), ci: crate::config::v2::schema::CiConfig::default(), }; 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 94800f162..9e5093181 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,14 @@ 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(), + queue_enqueues: Vec::new(), + queue_workers: Vec::new(), + queue_cluster: None, 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 31146b2ae..da2728161 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 @@ -236,6 +236,14 @@ 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![], + queue_enqueues: vec![], + queue_workers: vec![], + queue_cluster: None, terraform: Default::default(), ci: crate::config::v2::schema::CiConfig::default(), }; 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/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 b98514ea1..fb8959b11 100644 --- a/crates/no-mistakes/src/codebase/dependencies/tests/args.rs +++ b/crates/no-mistakes/src/codebase/dependencies/tests/args.rs @@ -223,6 +223,14 @@ fn cargo_globs_include_tests_dir() { assert!(globs.iter().any(|g| g.contains("tests/**/*.rs"))); } +#[test] +fn language_frontend_globs_are_explicit() { + assert!(test_globs("python").iter().any(|glob| glob.contains("test_*.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..e0d3c88ba --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs @@ -0,0 +1,105 @@ +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, +} + +#[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 { + 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()); + } + 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 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 { + 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__" || part == "mod") { + parts.pop(); + } + 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..b728461ca --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -0,0 +1,104 @@ +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_go_facts( + root: &Path, + all_files: &[PathBuf], + modules: &[String], +) -> LangFactMap { + let roots = configured_roots(root, modules); + let files = files_under(all_files, &roots, "go"); + let mut facts = LangFactMap::default(); + for path in files { + if let Some(file) = parse_go_file(&path, &roots, modules) { + facts.index_file(file); + } + } + facts +} + +fn parse_go_file(path: &Path, roots: &[PathBuf], modules: &[String]) -> Option { + let source = std::fs::read_to_string(path).ok()?; + let text = strip_comments_keep_strings(&source); + let package = owning_package(path, roots, modules); + let module = path + .parent() + .and_then(|dir| dir.file_name()) + .map(|name| name.to_string_lossy().into_owned()); + Some(LangFileFacts { + path: path.to_path_buf(), + package, + module, + imports: extract_go_imports(&text), + declarations: extract_named(&text, go_decl_re()), + references: extract_named(&text, go_ref_re()), + route_handlers: Vec::new(), + queue_enqueues: extract_named(&text, asynq_task_re()), + queue_workers: extract_named(&text, asynq_handle_re()), + }) +} + +fn extract_go_imports(source: &str) -> Vec { + let mut imports = extract_named(source, go_single_import_re()); + if let Some(block) = go_import_block_re().captures(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_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+"([^"]+)""#).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+)?([A-Z][A-Za-z0-9_]*)").expect("func") + }) +} + +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..0061b3b05 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs @@ -0,0 +1,55 @@ +use regex::Regex; +use std::path::Path; +use std::sync::OnceLock; + +pub(crate) fn extract_kafka_topics(source: &str) -> (Vec, Vec) { + let produces = extract_named(source, kafka_produce_re()); + let consumes = extract_named(source, kafka_consume_re()); + (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()?; + Some(extract_kafka_topics(&source)) +} + +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 kafka_produce_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"(?:topic\s*[:=]\s*["']([^"']+)["']|\.send\(\s*["']([^"']+)["'])"#) + .expect("produce") + }) +} + +fn kafka_consume_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r#"subscribe\(\s*(?:\{\s*topic\s*:\s*["']([^"']+)["']|\[\s*["']([^"']+)["'])"#, + ) + .expect("consume") + }) +} 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..15cfed8d1 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs @@ -0,0 +1,52 @@ +mod facts; +mod go; +mod kafka; +mod php; +mod python; +mod ruby; +mod rustlang; +mod strip; + +#[cfg(test)] +mod tests; + +use std::path::{Path, PathBuf}; + +pub(crate) use facts::LangFactMap; +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(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), + } +} 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..29fbca710 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -0,0 +1,95 @@ +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_php_facts( + root: &Path, + all_files: &[PathBuf], + apps: &[String], +) -> LangFactMap { + let roots = configured_roots(root, apps); + let files = files_under(all_files, &roots, "php"); + let mut facts = LangFactMap::default(); + for path in files { + if let Some(file) = parse_php_file(&path, &roots, apps) { + facts.index_file(file); + } + } + facts +} + +fn parse_php_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: extract_named(&text, php_class_re()).into_iter().next(), + imports: extract_named(&text, php_use_re()), + declarations: extract_named(&text, php_class_re()), + references: extract_named(&text, php_class_re()), + route_handlers: extract_pairs(&text, laravel_route_re()), + queue_enqueues: extract_named(&text, laravel_dispatch_re()), + queue_workers: if php_should_queue_re().is_match(&text) { + extract_named(&text, php_class_re()) + } else { + Vec::new() + }, + }) +} + +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_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().replace('\\', "."), + )) + }) + .collect() +} + +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+)?class\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*\[([^\]]+)\]"#) + .expect("route") + }) +} + +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")) +} + +fn php_should_queue_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\bimplements\s+ShouldQueue\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..9b56a880d --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -0,0 +1,161 @@ +use super::facts::{ + configured_roots, files_under, module_from_path, 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_python_facts( + root: &Path, + all_files: &[PathBuf], + packages: &[String], +) -> LangFactMap { + let roots = configured_roots(root, packages); + let files = files_under(all_files, &roots, "py"); + let mut facts = LangFactMap::default(); + for path in files { + if let Some(file) = parse_python_file(root, &path, &roots, packages) { + facts.index_file(file); + } + } + facts +} + +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 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 = package_root + .as_ref() + .and_then(|pkg| module_from_path(pkg, path)); + Some(LangFileFacts { + path: path.to_path_buf(), + package, + module, + imports: extract_python_imports(&text, path, package_root.as_deref()), + declarations: extract_named(&text, python_decl_re()), + references: extract_named(&text, python_ref_re()), + route_handlers: extract_pairs(&text, django_route_re()), + queue_enqueues: extract_named(&text, celery_enqueue_re()), + queue_workers: extract_celery_workers(&text), + }) +} + +fn extract_python_imports(source: &str, path: &Path, package_root: Option<&Path>) -> Vec { + let mut imports = extract_named(source, python_import_re()); + for cap in python_from_re().captures_iter(source) { + let Some(module) = cap.get(1).map(|m| m.as_str()) else { + continue; + }; + if let Some(resolved) = resolve_relative(module, path, package_root) { + imports.push(resolved); + } else if !module.starts_with('.') { + imports.push(module.to_string()); + } + } + imports.sort(); + imports.dedup(); + imports +} + +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_celery_workers(source: &str) -> Vec { + let mut names = extract_named(source, celery_named_task_re()); + names.extend(extract_named(source, celery_fn_task_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 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 python_import_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?m)^\s*import\s+([A-Za-z_][\w.]*)").expect("python import")) +} + +fn python_from_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?m)^\s*from\s+(\.*[A-Za-z_][\w.]*)\s+import").expect("from")) +} + +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*([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/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs new file mode 100644 index 000000000..66dde0aa8 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -0,0 +1,105 @@ +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"); + let mut facts = LangFactMap::default(); + for path in files { + if let Some(file) = parse_ruby_file(&path, &roots, apps) { + facts.index_file(file); + } + } + facts +} + +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: path.file_stem().map(|name| name.to_string_lossy().into_owned()), + imports: extract_requires(&text, path), + declarations: extract_named(&text, ruby_decl_re()), + 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()), + }) +} + +fn extract_requires(source: &str, path: &Path) -> 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() { + imports.push(parent.join(rel).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 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_]*)\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][A-Za-z0-9_]*)\.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][A-Za-z0-9_]*)\s*<\s*ApplicationJob").expect("job class")) +} 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..dcc5e5c2e --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs @@ -0,0 +1,80 @@ +use super::facts::{ + configured_roots, files_under, module_from_path, 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_rust_facts( + root: &Path, + all_files: &[PathBuf], + packages: &[String], +) -> LangFactMap { + let roots = configured_roots(root, packages); + let files = files_under(all_files, &roots, "rs"); + let mut facts = LangFactMap::default(); + for path in files { + if let Some(file) = parse_rust_file(root, &path, &roots, packages) { + facts.index_file(file); + } + } + facts +} + +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))); + Some(LangFileFacts { + path: path.to_path_buf(), + package, + module: package_root + .as_ref() + .and_then(|pkg| module_from_path(pkg, path)), + imports: extract_named(&text, rust_use_re()), + 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(), + }) +} + +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_use_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*use\s+(?:crate|super|self)::([\w:]+)").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..c3816f3e2 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/strip.rs @@ -0,0 +1,69 @@ +/// 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; + } + } +} 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..b473bca96 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -0,0 +1,160 @@ +use super::*; +use crate::codebase::lang_frontends::kafka::extract_kafka_topics; +use std::path::PathBuf; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/lang-frontends").join(name) +} + +fn all_files(root: &std::path::Path) -> Vec { + let mut files = Vec::new(); + walk(root, &mut files); + files.sort(); + files +} + +fn walk(dir: &std::path::Path, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, files); + } else { + files.push(crate::codebase::ts_resolver::normalize_path(&path)); + } + } +} + +#[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.contains("models"))); + 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.queue_enqueues.iter().any(|job| job == "send_welcome")); + let urls = facts + .files + .values() + .find(|file| file.path.ends_with("urls.py")) + .expect("urls"); + assert!(urls + .route_handlers + .iter() + .any(|(route, handler)| route == "api/users/" && handler.contains("user_list"))); +} + +#[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_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()]); +} + +#[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.contains("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")); + let controller = facts + .files + .values() + .find(|file| file.path.ends_with("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()]); + 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()); +} + +#[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 }); + "#, + ); + assert_eq!(produces, vec!["mail.welcome".to_string()]); + assert_eq!(consumes, vec!["mail.welcome".to_string()]); + 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, &[]).files.is_empty()); +} diff --git a/crates/no-mistakes/src/codebase/mod.rs b/crates/no-mistakes/src/codebase/mod.rs index ceb3f6d70..876eca30b 100644 --- a/crates/no-mistakes/src/codebase/mod.rs +++ b/crates/no-mistakes/src/codebase/mod.rs @@ -8,6 +8,7 @@ pub mod config; pub mod dependencies; pub mod dotnet; pub(crate) mod glob_normalize; +pub(crate) mod lang_frontends; pub mod import_usages; pub mod lockfile; pub mod markdown_sections; 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/plan_bfs.rs b/crates/no-mistakes/src/tests/plan_bfs.rs index aabba5d6b..45263c824 100644 --- a/crates/no-mistakes/src/tests/plan_bfs.rs +++ b/crates/no-mistakes/src/tests/plan_bfs.rs @@ -198,6 +198,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..39903832f 100644 --- a/docs/cli/queues.md +++ b/docs/cli/queues.md @@ -1,6 +1,7 @@ # `no-mistakes queues` -Analyze queue producer/worker relationships for BullMQ and glide-mq patterns. +Analyze queue producer/worker relationships for BullMQ, glide-mq, Celery, +Asynq, Kafka topics, Active Job, and Laravel `dispatch`. | 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..3c8a532ac 100644 --- a/docs/feature-parity.md +++ b/docs/feature-parity.md @@ -24,11 +24,12 @@ edges and planners ship. | 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. diff --git a/docs/graph-edges.md b/docs/graph-edges.md index 4450f4a38..0f96c7484 100644 --- a/docs/graph-edges.md +++ b/docs/graph-edges.md @@ -58,6 +58,17 @@ 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) | 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 +109,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/worker/enqueue.go b/fixtures/lang-frontends/go-asynq/worker/enqueue.go new file mode 100644 index 000000000..6f4b2eab1 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/enqueue.go @@ -0,0 +1,8 @@ +package worker + +import "github.com/hibiken/asynq" + +func EnqueueWelcome(client *asynq.Client) error { + _, err := client.Enqueue(asynq.NewTask("mail:welcome", nil)) + return err +} 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..d9f62a895 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/tasks.go @@ -0,0 +1,11 @@ +package worker + +import "github.com/hibiken/asynq" + +func HandleWelcome(ctx interface{}, 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..c0a5dfa6b --- /dev/null +++ b/fixtures/lang-frontends/kafka-topics/consumer.ts @@ -0,0 +1,3 @@ +export function listen(consumer: { subscribe: (input: { topic: string }) => void }) { + consumer.subscribe({ topic: "mail.welcome" }); +} 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/Http/Controllers/UserController.php b/fixtures/lang-frontends/php-laravel/app/Http/Controllers/UserController.php new file mode 100644 index 000000000..b4dd24414 --- /dev/null +++ b/fixtures/lang-frontends/php-laravel/app/Http/Controllers/UserController.php @@ -0,0 +1,13 @@ + 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..79b51cf6b --- /dev/null +++ b/fixtures/lang-frontends/python-celery-django/app/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from app.users import views + +urlpatterns = [ + path("api/users/", views.user_list), +] 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..90117e6d8 --- /dev/null +++ b/fixtures/lang-frontends/python-celery-django/app/users/views.py @@ -0,0 +1,4 @@ +from .models import User + +def user_list(): + return User 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/users_controller.rb b/fixtures/lang-frontends/rails-jobs/app/controllers/users_controller.rb new file mode 100644 index 000000000..7e370d70a --- /dev/null +++ b/fixtures/lang-frontends/rails-jobs/app/controllers/users_controller.rb @@ -0,0 +1,5 @@ +class UsersController + def index + WelcomeJob.perform_later + 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/config/routes.rb b/fixtures/lang-frontends/rails-jobs/config/routes.rb new file mode 100644 index 000000000..011dab26e --- /dev/null +++ b/fixtures/lang-frontends/rails-jobs/config/routes.rb @@ -0,0 +1,3 @@ +Rails.application.routes.draw do + get "/api/users", to: "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/lib.rs b/fixtures/lang-frontends/rust-mods/src/lib.rs new file mode 100644 index 000000000..0c99cfc05 --- /dev/null +++ b/fixtures/lang-frontends/rust-mods/src/lib.rs @@ -0,0 +1,7 @@ +pub mod mail; + +use crate::mail::Welcome; + +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..9bb5f7c60 --- /dev/null +++ b/fixtures/lang-frontends/rust-mods/src/mail.rs @@ -0,0 +1,5 @@ +pub struct Welcome; + +impl Welcome { + pub fn emit() {} +} 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..25b3230ad 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 `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. From e03b3b7aebf7840ab49105fbd0cd0b6b47875dcf Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 05:15:18 -0700 Subject: [PATCH 02/26] style: rustfmt language frontend modules CI lint:spell passed locally; rustfmt failed on the new extractors. --- .../src/codebase/lang_frontends/facts.rs | 5 +++- .../src/codebase/lang_frontends/go.rs | 4 +--- .../src/codebase/lang_frontends/kafka.rs | 6 ++--- .../src/codebase/lang_frontends/php.rs | 10 ++++---- .../src/codebase/lang_frontends/python.rs | 11 +++++++-- .../src/codebase/lang_frontends/ruby.rs | 19 ++++++++++----- .../src/codebase/lang_frontends/rustlang.rs | 10 ++++---- .../src/codebase/lang_frontends/strip.rs | 6 ++++- .../src/codebase/lang_frontends/tests.rs | 24 +++++++++++++++---- 9 files changed, 63 insertions(+), 32 deletions(-) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs index e0d3c88ba..cb026ccd9 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs @@ -82,7 +82,10 @@ pub(crate) fn module_from_path(package_root: &Path, path: &Path) -> Option &'static Regex { fn kafka_consume_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { - Regex::new( - r#"subscribe\(\s*(?:\{\s*topic\s*:\s*["']([^"']+)["']|\[\s*["']([^"']+)["'])"#, - ) - .expect("consume") + Regex::new(r#"subscribe\(\s*(?:\{\s*topic\s*:\s*["']([^"']+)["']|\[\s*["']([^"']+)["'])"#) + .expect("consume") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 29fbca710..7ac81aa99 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -1,6 +1,4 @@ -use super::facts::{ - configured_roots, files_under, owning_package, LangFactMap, LangFileFacts, -}; +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}; @@ -79,8 +77,10 @@ fn php_class_re() -> &'static Regex { 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*\[([^\]]+)\]"#) - .expect("route") + Regex::new( + r#"Route::(?:get|post|put|patch|delete)\(\s*['"]([^'"]+)['"]\s*,\s*\[([^\]]+)\]"#, + ) + .expect("route") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python.rs b/crates/no-mistakes/src/codebase/lang_frontends/python.rs index 9b56a880d..3e8bd61f0 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -107,7 +107,12 @@ fn extract_named(source: &str, re: &Regex) -> Vec { 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()))) + .filter_map(|cap| { + Some(( + cap.get(1)?.as_str().to_string(), + cap.get(2)?.as_str().to_string(), + )) + }) .collect() } @@ -123,7 +128,9 @@ fn python_from_re() -> &'static Regex { 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")) + 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 { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs index 66dde0aa8..3a87149fe 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -1,6 +1,4 @@ -use super::facts::{ - configured_roots, files_under, owning_package, LangFactMap, LangFileFacts, -}; +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}; @@ -28,7 +26,9 @@ fn parse_ruby_file(path: &Path, roots: &[PathBuf], apps: &[String]) -> Option Vec { 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()))) + .filter_map(|cap| { + Some(( + cap.get(1)?.as_str().to_string(), + cap.get(2)?.as_str().to_string(), + )) + }) .collect() } @@ -101,5 +106,7 @@ fn active_job_re() -> &'static Regex { 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][A-Za-z0-9_]*)\s*<\s*ApplicationJob").expect("job class")) + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*class\s+([A-Z][A-Za-z0-9_]*)\s*<\s*ApplicationJob").expect("job class") + }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs index dcc5e5c2e..eadca238d 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs @@ -61,16 +61,16 @@ fn extract_named(source: &str, re: &Regex) -> Vec { fn rust_use_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new(r"(?m)^\s*use\s+(?:crate|super|self)::([\w:]+)").expect("use") - }) + RE.get_or_init(|| Regex::new(r"(?m)^\s*use\s+(?:crate|super|self)::([\w:]+)").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") + Regex::new( + r"(?m)^\s*pub(?:\([^)]+\))?\s+(?:fn|struct|enum|trait|type|mod)\s+([A-Za-z_]\w*)", + ) + .expect("decl") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/strip.rs b/crates/no-mistakes/src/codebase/lang_frontends/strip.rs index c3816f3e2..0d73a012e 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/strip.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/strip.rs @@ -50,7 +50,11 @@ fn skip_block(chars: &mut std::iter::Peekable>, out: &mut St } } -fn copy_quoted(chars: &mut std::iter::Peekable>, out: &mut String, quote: char) { +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); diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs index b473bca96..1193a9114 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -3,7 +3,9 @@ use crate::codebase::lang_frontends::kafka::extract_kafka_topics; use std::path::PathBuf; fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/lang-frontends").join(name) + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/lang-frontends") + .join(name) } fn all_files(root: &std::path::Path) -> Vec { @@ -46,13 +48,19 @@ fn python_collects_relative_import_celery_and_django_routes() { .values() .find(|file| file.path.ends_with("tasks.py")) .expect("tasks"); - assert!(tasks.queue_workers.iter().any(|job| job.contains("send_welcome"))); + 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.queue_enqueues.iter().any(|job| job == "send_welcome")); + assert!(enqueue + .queue_enqueues + .iter() + .any(|job| job == "send_welcome")); let urls = facts .files .values() @@ -125,7 +133,10 @@ fn php_collects_laravel_route_and_dispatch() { .values() .find(|file| file.path.ends_with("web.php")) .expect("routes"); - assert!(routes.route_handlers.iter().any(|(route, _)| route == "/api/users")); + assert!(routes + .route_handlers + .iter() + .any(|(route, _)| route == "/api/users")); let job = facts .files .values() @@ -145,7 +156,10 @@ fn kafka_extracts_static_topics_and_skips_dynamic() { ); assert_eq!(produces, vec!["mail.welcome".to_string()]); assert_eq!(consumes, vec!["mail.welcome".to_string()]); - assert_eq!(topic_identity(Some("orders"), "mail.welcome"), "orders:mail.welcome"); + assert_eq!( + topic_identity(Some("orders"), "mail.welcome"), + "orders:mail.welcome" + ); } #[test] From e738c39003caa52c33062d17bfdc910fd37bbf6d Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 05:27:28 -0700 Subject: [PATCH 03/26] fix: wire language filters into the graph plan --relationship python|go|rust|ruby|php now requests a config-backed graph build. Import keys match Go module paths, Rust src modules, and PHP namespaces. Queue workers no longer clobber each other. --- .../codebase/dependencies/graph/build_plan.rs | 15 ++++ .../graph/builder_remaining_edges.rs | 7 +- .../dependencies/graph/edge_lang_frontends.rs | 70 ++++++++++++++++--- .../src/codebase/dependencies/tests/args.rs | 11 +++ .../src/codebase/lang_frontends/go.rs | 27 +++++-- .../src/codebase/lang_frontends/kafka.rs | 3 +- .../src/codebase/lang_frontends/php.rs | 31 +++++++- .../src/codebase/lang_frontends/python.rs | 5 +- .../src/codebase/lang_frontends/ruby.rs | 5 +- .../src/codebase/lang_frontends/rustlang.rs | 24 ++++++- .../src/codebase/lang_frontends/tests.rs | 51 ++++++++------ docs/cli/queues.md | 5 +- fixtures/lang-frontends/go-asynq/go.mod | 3 + 13 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 fixtures/lang-frontends/go-asynq/go.mod 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..1bf1487be 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,17 @@ impl GraphBuildPlan { terraform: allowed.contains(&EdgeKind::TerraformReference) || allowed.contains(&EdgeKind::TerraformModuleRef) || allowed.contains(&EdgeKind::TerraformOutputRef), + language_frontends: allowed.contains(&EdgeKind::PythonImport) + || allowed.contains(&EdgeKind::PythonReference) + || allowed.contains(&EdgeKind::GoImport) + || allowed.contains(&EdgeKind::GoReference) + || allowed.contains(&EdgeKind::RustUse) + || allowed.contains(&EdgeKind::RustMod) + || allowed.contains(&EdgeKind::RustPackage) + || allowed.contains(&EdgeKind::RubyRequire) + || allowed.contains(&EdgeKind::RubyReference) + || allowed.contains(&EdgeKind::PhpUse) + || allowed.contains(&EdgeKind::PhpPackage), } } @@ -150,6 +163,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 { @@ -186,6 +200,7 @@ fn graph_plan_needs_config(plan: GraphBuildPlan) -> bool { || plan.dotnet || plan.swift || plan.terraform + || plan.language_frontends } fn effective_ts_fact_plan( 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 5988b298c..f659f9f1b 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 @@ -166,7 +166,12 @@ fn collect_remaining_edges( merge_terraform_edges(edge_inputs, forward, reverse); }); crate::perf_trace::trace("graph.language_frontends", || { - merge_language_frontend_edges(edge_inputs, forward, reverse); + 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_frontends.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs index 875b04c0f..717c0a054 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -67,17 +67,30 @@ fn emit_lang_edges( } for reference in &file.references { if let Some(targets) = facts.declarations.get(reference) { - push_file_edges(edges, &file.path, targets, ref_kind); + let scoped: std::collections::BTreeSet<_> = targets + .iter() + .filter(|target| { + file.package.is_none() + || facts.files.get(*target).and_then(|other| other.package.as_ref()) + == file.package.as_ref() + }) + .cloned() + .collect(); + push_file_edges(edges, &file.path, &scoped, ref_kind); } } } } fn emit_queue_edges(facts: &LangFactMap, cluster: Option<&str>, edges: &mut Vec) { - let mut workers: std::collections::HashMap = std::collections::HashMap::new(); + let mut workers: std::collections::HashMap> = + std::collections::HashMap::new(); for file in facts.files.values() { for job in &file.queue_workers { - workers.insert(topic_identity(cluster, job), file.path.clone()); + workers + .entry(topic_identity(cluster, job)) + .or_default() + .insert(file.path.clone()); } } for file in facts.files.values() { @@ -87,9 +100,19 @@ fn emit_queue_edges(facts: &LangFactMap, cluster: Option<&str>, edges: &mut Vec< queue_file: file.path.clone(), job: identity.clone(), }; - edges.push((NodeId::File(file.path.clone()), node.clone(), EdgeKind::QueueEnqueue)); - if let Some(worker) = workers.get(&identity) { - edges.push((node, NodeId::File(worker.clone()), EdgeKind::QueueWorker)); + edges.push(( + NodeId::File(file.path.clone()), + node.clone(), + EdgeKind::QueueEnqueue, + )); + if let Some(targets) = workers.get(&identity) { + for worker in targets { + edges.push(( + node.clone(), + NodeId::File(worker.clone()), + EdgeKind::QueueWorker, + )); + } } } } @@ -98,14 +121,43 @@ fn emit_queue_edges(facts: &LangFactMap, cluster: Option<&str>, edges: &mut Vec< fn emit_route_edges(facts: &LangFactMap, edges: &mut Vec) { for file in facts.files.values() { for (_, handler) in &file.route_handlers { - let name = handler.rsplit('.').next().unwrap_or(handler); - if let Some(targets) = facts.declarations.get(name) { - push_file_edges(edges, &file.path, targets, EdgeKind::RouteRef); + for name in route_handler_names(handler) { + if let Some(targets) = facts.declarations.get(&name) { + push_file_edges(edges, &file.path, targets, EdgeKind::RouteRef); + } } } } } +fn route_handler_names(handler: &str) -> Vec { + let trimmed = handler.replace(['\'', '"', ' '], ""); + if let Some((controller, _)) = trimmed.split_once('#') { + let mut name = controller.replace('/', "_"); + if !name.ends_with("Controller") { + name.push_str("Controller"); + } + return vec![snake_to_pascal(&name), name]; + } + if let Some((class, _)) = trimmed.split_once("::") { + return vec![class.rsplit('\\').next().unwrap_or(class).to_string()]; + } + vec![trimmed.rsplit('.').next().unwrap_or(&trimmed).to_string()] +} + +fn snake_to_pascal(name: &str) -> String { + name.split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), + None => String::new(), + } + }) + .collect() +} + fn emit_kafka_edges( root: &Path, all_files: &[PathBuf], diff --git a/crates/no-mistakes/src/codebase/dependencies/tests/args.rs b/crates/no-mistakes/src/codebase/dependencies/tests/args.rs index fb8959b11..a49611ef7 100644 --- a/crates/no-mistakes/src/codebase/dependencies/tests/args.rs +++ b/crates/no-mistakes/src/codebase/dependencies/tests/args.rs @@ -223,6 +223,17 @@ 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); +} + #[test] fn language_frontend_globs_are_explicit() { assert!(test_globs("python").iter().any(|glob| glob.contains("test_*.py"))); diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index 825e1500e..553a148f0 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -24,10 +24,7 @@ fn parse_go_file(path: &Path, roots: &[PathBuf], modules: &[String]) -> Option Option Option { + let root = roots.iter().find(|candidate| path.starts_with(candidate))?; + let module = 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.trim().to_string()) + }) + }); + 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()); if let Some(block) = go_import_block_re().captures(source) { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs index 56462f07b..b8619b648 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs @@ -17,7 +17,8 @@ pub(crate) fn topic_identity(cluster: Option<&str>, topic: &str) -> String { pub(crate) fn scan_file(path: &Path) -> Option<(Vec, Vec)> { let source = std::fs::read_to_string(path).ok()?; - Some(extract_kafka_topics(&source)) + let text = super::strip::strip_comments_keep_strings(&source); + Some(extract_kafka_topics(&text)) } fn extract_named(source: &str, re: &Regex) -> Vec { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 7ac81aa99..a06045152 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -23,13 +23,14 @@ pub(crate) fn collect_php_facts( fn parse_php_file(path: &Path, roots: &[PathBuf], apps: &[String]) -> Option { let source = std::fs::read_to_string(path).ok()?; let text = strip_comments_keep_strings(&source); + let classes = php_classes(&text); Some(LangFileFacts { path: path.to_path_buf(), package: owning_package(path, roots, apps), - module: extract_named(&text, php_class_re()).into_iter().next(), + module: classes.first().cloned(), imports: extract_named(&text, php_use_re()), - declarations: extract_named(&text, php_class_re()), - references: extract_named(&text, php_class_re()), + declarations: classes, + references: extract_named(&text, php_use_re()), route_handlers: extract_pairs(&text, laravel_route_re()), queue_enqueues: extract_named(&text, laravel_dispatch_re()), queue_workers: if php_should_queue_re().is_match(&text) { @@ -40,6 +41,25 @@ fn parse_php_file(path: &Path, roots: &[PathBuf], apps: &[String]) -> Option 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 +} + fn extract_named(source: &str, re: &Regex) -> Vec { let mut values: Vec = re .captures_iter(source) @@ -61,6 +81,11 @@ fn extract_pairs(source: &str, re: &Regex) -> Vec<(String, String)> { .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")) +} + 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")) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python.rs b/crates/no-mistakes/src/codebase/lang_frontends/python.rs index 3e8bd61f0..a7ca95781 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -123,7 +123,10 @@ fn python_import_re() -> &'static Regex { fn python_from_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"(?m)^\s*from\s+(\.*[A-Za-z_][\w.]*)\s+import").expect("from")) + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*from\s+(\.+(?:[A-Za-z_][\w.]*)?|[A-Za-z_][\w.]*)\s+import") + .expect("from") + }) } fn python_decl_re() -> &'static Regex { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs index 3a87149fe..eff1ad5d9 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -42,7 +42,10 @@ fn extract_requires(source: &str, path: &Path) -> 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() { - imports.push(parent.join(rel).to_string_lossy().into_owned()); + let resolved = parent.join(rel); + if let Some(stem) = resolved.file_stem() { + imports.push(stem.to_string_lossy().into_owned()); + } } } imports.sort(); diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs index eadca238d..0dabf5cc7 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs @@ -34,13 +34,21 @@ fn parse_rust_file( 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() + } + }); Some(LangFileFacts { path: path.to_path_buf(), package, - module: package_root + module: src_root .as_ref() .and_then(|pkg| module_from_path(pkg, path)), - imports: extract_named(&text, rust_use_re()), + imports: rust_imports(&text), declarations: extract_named(&text, rust_decl_re()), references: extract_named(&text, rust_ref_re()), route_handlers: Vec::new(), @@ -49,6 +57,18 @@ fn parse_rust_file( }) } +fn rust_imports(source: &str) -> Vec { + let mut imports = extract_named(source, rust_use_re()); + let prefixes: Vec = imports + .iter() + .filter_map(|import| import.split('.').next().map(str::to_string)) + .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) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs index 1193a9114..cfca5606b 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -3,30 +3,29 @@ use crate::codebase::lang_frontends::kafka::extract_kafka_topics; use std::path::PathBuf; fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/lang-frontends") - .join(name) + 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 mut files = Vec::new(); - walk(root, &mut files); - files.sort(); - files -} - -fn walk(dir: &std::path::Path, files: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - walk(&path, files); - } else { - files.push(crate::codebase::ts_resolver::normalize_path(&path)); - } - } + 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] @@ -99,7 +98,11 @@ fn rust_collects_use_and_declaration() { .values() .find(|file| file.path.ends_with("lib.rs")) .expect("lib"); - assert!(lib.imports.iter().any(|import| import.contains("mail"))); + assert!(lib.imports.iter().any(|import| import == "mail")); + assert!(facts + .files + .values() + .any(|file| file.module.as_deref() == Some("mail"))); assert!(facts.declarations.contains_key("Welcome")); } @@ -143,6 +146,10 @@ fn php_collects_laravel_route_and_dispatch() { .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")); } #[test] diff --git a/docs/cli/queues.md b/docs/cli/queues.md index 39903832f..de84e5dda 100644 --- a/docs/cli/queues.md +++ b/docs/cli/queues.md @@ -1,7 +1,8 @@ # `no-mistakes queues` -Analyze queue producer/worker relationships for BullMQ, glide-mq, Celery, -Asynq, Kafka topics, Active Job, and Laravel `dispatch`. +Analyze queue producer/worker relationships for BullMQ and glide-mq, and for +configured Celery, Asynq, Kafka, Active Job, and Laravel enqueue/worker +sites that emit canonical `queue-enqueue` / `queue-worker` graph edges. | Leaf command | Purpose | | --- | --- | diff --git a/fixtures/lang-frontends/go-asynq/go.mod b/fixtures/lang-frontends/go-asynq/go.mod new file mode 100644 index 000000000..df35257c2 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/go.mod @@ -0,0 +1,3 @@ +module example.com/worker + +go 1.22 From b7e9ae018df76cb328f778ec4f934477d3387b10 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 05:32:36 -0700 Subject: [PATCH 04/26] fix: tighten Kafka produce matching and Rust status docs Restrict topic extraction to send() so subscribe objects are not treated as producers. Drop the stale Rust-not-a-frontend row. --- .../src/codebase/lang_frontends/kafka.rs | 2 +- docs/feature-parity.md | 14 +++++--------- .../php-laravel/app/Jobs/SomeJob.php | 2 ++ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs index b8619b648..2c8bab374 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs @@ -40,7 +40,7 @@ fn extract_named(source: &str, re: &Regex) -> Vec { fn kafka_produce_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { - Regex::new(r#"(?:topic\s*[:=]\s*["']([^"']+)["']|\.send\(\s*["']([^"']+)["'])"#) + Regex::new(r#"\.send\(\s*(?:\{[^}]*topic\s*:\s*["']([^"']+)["']|["']([^"']+)["'])"#) .expect("produce") }) } diff --git a/docs/feature-parity.md b/docs/feature-parity.md index 3c8a532ac..c4e7ad123 100644 --- a/docs/feature-parity.md +++ b/docs/feature-parity.md @@ -23,7 +23,6 @@ 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 | `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) | @@ -34,14 +33,11 @@ edges and planners ship. 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 diff --git a/fixtures/lang-frontends/php-laravel/app/Jobs/SomeJob.php b/fixtures/lang-frontends/php-laravel/app/Jobs/SomeJob.php index 4453372e3..e185494e2 100644 --- a/fixtures/lang-frontends/php-laravel/app/Jobs/SomeJob.php +++ b/fixtures/lang-frontends/php-laravel/app/Jobs/SomeJob.php @@ -2,6 +2,8 @@ namespace App\Jobs; +use Illuminate\Contracts\Queue\ShouldQueue; + class SomeJob implements ShouldQueue { } From a81584fadfb2da38181eafd231ded34434f92d97 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 05:38:47 -0700 Subject: [PATCH 05/26] fix: honor tests.php.framework for Laravel extractors Laravel route and dispatch facts run only when the configured framework is laravel. Graph options now carry that selector. --- .../dependencies/graph/edge_lang_frontends.rs | 1 + .../dependencies/graph/files_config.rs | 2 ++ .../dependencies/graph/tests/config_edges.rs | 3 +++ .../graph/tests/config_fact_plan.rs | 1 + .../dependencies/graph/tests/dotnet_edges.rs | 1 + .../graph/tests/extra_symbol_defensive.rs | 1 + .../src/codebase/lang_frontends/mod.rs | 8 +++++- .../src/codebase/lang_frontends/php.rs | 25 +++++++++++++++---- .../src/codebase/lang_frontends/tests.rs | 4 +-- crates/no-mistakes/src/codebase/mod.rs | 2 +- 10 files changed, 39 insertions(+), 9 deletions(-) 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 index 717c0a054..20d2d5e61 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -42,6 +42,7 @@ fn lang_config_from_options(options: &GraphConfigOptions) -> LangFrontendConfig rust_packages: options.rust_packages.clone(), rails_apps: options.rails_apps.clone(), php_apps: options.php_apps.clone(), + php_framework: options.php_framework.clone(), } } 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 70219b487..e4d2730e7 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/files_config.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/files_config.rs @@ -43,6 +43,7 @@ struct GraphConfigOptions { rust_packages: Vec, rails_apps: Vec, php_apps: Vec, + php_framework: Option, queue_enqueues: Vec, queue_workers: Vec, queue_cluster: Option, @@ -121,6 +122,7 @@ fn graph_config_options_from_loaded_with_test_filter( 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: v2_config .projects .values() 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 e763c7fa5..6e467a76a 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 @@ -409,6 +409,7 @@ fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { rust_packages: vec![], rails_apps: vec![], php_apps: vec![], + php_framework: None, queue_enqueues: vec![], queue_workers: vec![], queue_cluster: None, @@ -436,6 +437,7 @@ fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { rust_packages: vec![], rails_apps: vec![], php_apps: vec![], + php_framework: None, queue_enqueues: vec![], queue_workers: vec![], queue_cluster: None, @@ -485,6 +487,7 @@ fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { rust_packages: vec![], rails_apps: vec![], php_apps: vec![], + php_framework: None, queue_enqueues: vec![], queue_workers: vec![], queue_cluster: None, 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 81d6e5e88..d09a58f71 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 @@ -45,6 +45,7 @@ fn effective_fact_plan_skips_config_dependent_domains_without_required_config() rust_packages: vec![], rails_apps: vec![], php_apps: vec![], + php_framework: None, queue_enqueues: vec![], queue_workers: vec![], queue_cluster: None, 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 9e5093181..e61721a5f 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 @@ -17,6 +17,7 @@ fn empty_options() -> GraphConfigOptions { 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, 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 da2728161..523489b1b 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 @@ -241,6 +241,7 @@ fn symbol_edge_helpers_cover_defensive_symbol_branches() { rust_packages: vec![], rails_apps: vec![], php_apps: vec![], + php_framework: None, queue_enqueues: vec![], queue_workers: vec![], queue_cluster: None, diff --git a/crates/no-mistakes/src/codebase/lang_frontends/mod.rs b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs index 15cfed8d1..1bcde89a1 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/mod.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs @@ -27,6 +27,7 @@ pub(crate) struct LangFrontendConfig { pub rust_packages: Vec, pub rails_apps: Vec, pub php_apps: Vec, + pub php_framework: Option, } pub(crate) struct CollectedLangFacts { @@ -47,6 +48,11 @@ pub(crate) fn collect_all_lang_facts( 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), + 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 index a06045152..4e1bcc28b 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -8,19 +8,26 @@ pub(crate) fn collect_php_facts( root: &Path, all_files: &[PathBuf], apps: &[String], + framework: Option<&str>, ) -> LangFactMap { let roots = configured_roots(root, apps); let files = files_under(all_files, &roots, "php"); + let laravel = framework.is_none_or(|name| name.eq_ignore_ascii_case("laravel")); let mut facts = LangFactMap::default(); for path in files { - if let Some(file) = parse_php_file(&path, &roots, apps) { + if let Some(file) = parse_php_file(&path, &roots, apps, laravel) { facts.index_file(file); } } facts } -fn parse_php_file(path: &Path, roots: &[PathBuf], apps: &[String]) -> Option { +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); @@ -31,9 +38,17 @@ fn parse_php_file(path: &Path, roots: &[PathBuf], apps: &[String]) -> Option Date: Sun, 16 Aug 2026 05:57:04 -0700 Subject: [PATCH 06/26] fix: tighten language extractors and split oversized modules Address remaining review and CI failures on the language-frontend PR: parse every Go import block, resolve `from . import name`, strip Kafka comments, keep all Kafka consumers per topic, and parse configured language files in parallel. Split files that exceeded rust-max-lines-per-file and document later-work items as not started. --- .../codebase/dependencies/graph/build_plan.rs | 36 ---- .../dependencies/graph/build_plan_facts.rs | 35 +++ .../dependencies/graph/edge_lang_domains.rs | 104 +++++++++ .../dependencies/graph/edge_lang_frontends.rs | 91 -------- .../src/codebase/dependencies/graph/mod.rs | 2 + .../dependencies/graph/tests/config_edges.rs | 197 ----------------- .../graph/tests/config_edges_helpers.rs | 199 ++++++++++++++++++ .../codebase/dependencies/graph/tests/core.rs | 1 + .../src/codebase/lang_frontends/facts.rs | 18 ++ .../src/codebase/lang_frontends/go.rs | 10 +- .../src/codebase/lang_frontends/kafka.rs | 5 +- .../src/codebase/lang_frontends/php.rs | 8 +- .../src/codebase/lang_frontends/python.rs | 34 ++- .../src/codebase/lang_frontends/ruby.rs | 8 +- .../src/codebase/lang_frontends/rustlang.rs | 10 +- .../src/codebase/lang_frontends/tests.rs | 10 +- docs/feature-parity.md | 9 +- docs/graph-edges.md | 4 + .../lang-frontends/go-asynq/worker/enqueue.go | 13 +- .../lang-frontends/go-asynq/worker/tasks.go | 10 +- .../php-laravel/app/Jobs/SomeJob.php | 3 + .../python-celery-django/app/users/views.py | 1 + skills/no-mistakes/SKILL.md | 2 +- 23 files changed, 433 insertions(+), 377 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/dependencies/graph/build_plan_facts.rs create mode 100644 crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs create mode 100644 crates/no-mistakes/src/codebase/dependencies/graph/tests/config_edges_helpers.rs 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 1bf1487be..858565d15 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs @@ -189,39 +189,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 - || 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/build_plan_facts.rs b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan_facts.rs new file mode 100644 index 000000000..849ce3058 --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan_facts.rs @@ -0,0 +1,35 @@ +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/edge_lang_domains.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs new file mode 100644 index 000000000..253dc9ed7 --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -0,0 +1,104 @@ +fn emit_route_edges(facts: &LangFactMap, edges: &mut Vec) { + for file in facts.files.values() { + for (_, handler) in &file.route_handlers { + for name in route_handler_names(handler) { + if let Some(targets) = facts.declarations.get(&name) { + push_file_edges(edges, &file.path, targets, EdgeKind::RouteRef); + } + } + } + } +} + +fn route_handler_names(handler: &str) -> Vec { + let trimmed = handler.replace(['\'', '"', ' '], ""); + if let Some((controller, _)) = trimmed.split_once('#') { + let mut name = controller.replace('/', "_"); + if !name.ends_with("Controller") { + name.push_str("Controller"); + } + return vec![snake_to_pascal(&name), name]; + } + if let Some((class, _)) = trimmed.split_once("::") { + return vec![class.rsplit('\\').next().unwrap_or(class).to_string()]; + } + vec![trimmed.rsplit('.').next().unwrap_or(&trimmed).to_string()] +} + +fn snake_to_pascal(name: &str) -> String { + name.split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), + None => String::new(), + } + }) + .collect() +} + +fn emit_kafka_edges( + root: &Path, + all_files: &[PathBuf], + options: &GraphConfigOptions, + edges: &mut Vec, +) { + let cluster = options.queue_cluster.as_deref(); + let mut produces = Vec::new(); + let mut consumes = Vec::new(); + for path in all_files { + let Some((prod, cons)) = scan_kafka_file(path) else { + continue; + }; + let rel = path.strip_prefix(root).unwrap_or(path); + let rel_s = rel.to_string_lossy(); + if matches_any(&rel_s, &options.queue_enqueues) { + produces.push((path.clone(), prod)); + } + if matches_any(&rel_s, &options.queue_workers) { + consumes.push((path.clone(), cons)); + } + } + let mut workers: std::collections::HashMap> = + std::collections::HashMap::new(); + for (path, topics) in &consumes { + for topic in topics { + workers + .entry(topic_identity(cluster, topic)) + .or_default() + .insert(path.clone()); + } + } + for (path, topics) in produces { + for topic in topics { + let identity = topic_identity(cluster, &topic); + let node = NodeId::QueueJob { + queue_file: path.clone(), + job: identity.clone(), + }; + edges.push(( + NodeId::File(path.clone()), + node.clone(), + EdgeKind::QueueEnqueue, + )); + if let Some(targets) = workers.get(&identity) { + for worker in targets { + edges.push(( + node.clone(), + NodeId::File(worker.clone()), + 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/edge_lang_frontends.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs index 20d2d5e61..4c62ab228 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -119,97 +119,6 @@ fn emit_queue_edges(facts: &LangFactMap, cluster: Option<&str>, edges: &mut Vec< } } -fn emit_route_edges(facts: &LangFactMap, edges: &mut Vec) { - for file in facts.files.values() { - for (_, handler) in &file.route_handlers { - for name in route_handler_names(handler) { - if let Some(targets) = facts.declarations.get(&name) { - push_file_edges(edges, &file.path, targets, EdgeKind::RouteRef); - } - } - } - } -} - -fn route_handler_names(handler: &str) -> Vec { - let trimmed = handler.replace(['\'', '"', ' '], ""); - if let Some((controller, _)) = trimmed.split_once('#') { - let mut name = controller.replace('/', "_"); - if !name.ends_with("Controller") { - name.push_str("Controller"); - } - return vec![snake_to_pascal(&name), name]; - } - if let Some((class, _)) = trimmed.split_once("::") { - return vec![class.rsplit('\\').next().unwrap_or(class).to_string()]; - } - vec![trimmed.rsplit('.').next().unwrap_or(&trimmed).to_string()] -} - -fn snake_to_pascal(name: &str) -> String { - name.split('_') - .filter(|part| !part.is_empty()) - .map(|part| { - let mut chars = part.chars(); - match chars.next() { - Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), - None => String::new(), - } - }) - .collect() -} - -fn emit_kafka_edges( - root: &Path, - all_files: &[PathBuf], - options: &GraphConfigOptions, - edges: &mut Vec, -) { - let cluster = options.queue_cluster.as_deref(); - let mut produces = Vec::new(); - let mut consumes = Vec::new(); - for path in all_files { - let Some((prod, cons)) = scan_kafka_file(path) else { - continue; - }; - let rel = path.strip_prefix(root).unwrap_or(path); - let rel_s = rel.to_string_lossy(); - if matches_any(&rel_s, &options.queue_enqueues) { - produces.push((path.clone(), prod)); - } - if matches_any(&rel_s, &options.queue_workers) { - consumes.push((path.clone(), cons)); - } - } - let mut workers = std::collections::HashMap::new(); - for (path, topics) in &consumes { - for topic in topics { - workers.insert(topic_identity(cluster, topic), path.clone()); - } - } - for (path, topics) in produces { - for topic in topics { - let identity = topic_identity(cluster, &topic); - let node = NodeId::QueueJob { - queue_file: path.clone(), - job: identity.clone(), - }; - edges.push((NodeId::File(path.clone()), node.clone(), EdgeKind::QueueEnqueue)); - if let Some(worker) = workers.get(&identity) { - edges.push((node, NodeId::File(worker.clone()), 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)) - }) -} - fn push_file_edges( edges: &mut Vec, source: &Path, diff --git a/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs b/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs index d3e515611..e9577ffc7 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"); @@ -36,6 +37,7 @@ include!("builder_edges.rs"); include!("builder_remaining_edges.rs"); include!("builder_helpers.rs"); include!("edge_lang_frontends.rs"); +include!("edge_lang_domains.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 6e467a76a..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,200 +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![], - 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, - 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, - 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, - 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..951103cf6 --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_edges_helpers.rs @@ -0,0 +1,199 @@ +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, + 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, + 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, + 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/core.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/core.rs index 74cc0822a..ad9a38a24 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/lang_frontends/facts.rs b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs index cb026ccd9..faa95f4c8 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs @@ -55,6 +55,24 @@ pub(crate) fn configured_roots(root: &Path, entries: &[String]) -> Vec .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], diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index 553a148f0..692baecd2 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -11,13 +11,7 @@ pub(crate) fn collect_go_facts( ) -> LangFactMap { let roots = configured_roots(root, modules); let files = files_under(all_files, &roots, "go"); - let mut facts = LangFactMap::default(); - for path in files { - if let Some(file) = parse_go_file(&path, &roots, modules) { - facts.index_file(file); - } - } - facts + super::facts::collect_files_parallel(files, |path| parse_go_file(path, &roots, modules)) } fn parse_go_file(path: &Path, roots: &[PathBuf], modules: &[String]) -> Option { @@ -62,7 +56,7 @@ fn go_import_path(path: &Path, roots: &[PathBuf]) -> Option { fn extract_go_imports(source: &str) -> Vec { let mut imports = extract_named(source, go_single_import_re()); - if let Some(block) = go_import_block_re().captures(source) { + 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(), diff --git a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs index 2c8bab374..c41330d59 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs @@ -3,8 +3,9 @@ use std::path::Path; use std::sync::OnceLock; pub(crate) fn extract_kafka_topics(source: &str) -> (Vec, Vec) { - let produces = extract_named(source, kafka_produce_re()); - let consumes = extract_named(source, kafka_consume_re()); + let source = super::strip::strip_comments_keep_strings(source); + let produces = extract_named(&source, kafka_produce_re()); + let consumes = extract_named(&source, kafka_consume_re()); (produces, consumes) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 4e1bcc28b..23a6f5b6c 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -13,13 +13,7 @@ pub(crate) fn collect_php_facts( let roots = configured_roots(root, apps); let files = files_under(all_files, &roots, "php"); let laravel = framework.is_none_or(|name| name.eq_ignore_ascii_case("laravel")); - let mut facts = LangFactMap::default(); - for path in files { - if let Some(file) = parse_php_file(&path, &roots, apps, laravel) { - facts.index_file(file); - } - } - facts + super::facts::collect_files_parallel(files, |path| parse_php_file(path, &roots, apps, laravel)) } fn parse_php_file( diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python.rs b/crates/no-mistakes/src/codebase/lang_frontends/python.rs index a7ca95781..9b397534a 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -13,13 +13,9 @@ pub(crate) fn collect_python_facts( ) -> LangFactMap { let roots = configured_roots(root, packages); let files = files_under(all_files, &roots, "py"); - let mut facts = LangFactMap::default(); - for path in files { - if let Some(file) = parse_python_file(root, &path, &roots, packages) { - facts.index_file(file); - } - } - facts + super::facts::collect_files_parallel(files, |path| { + parse_python_file(root, path, &roots, packages) + }) } fn parse_python_file( @@ -56,8 +52,15 @@ fn extract_python_imports(source: &str, path: &Path, package_root: Option<&Path> 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) { - imports.push(resolved); + if module.chars().all(|ch| ch == '.') { + for name in imported_names(names) { + imports.push(format!("{resolved}.{name}")); + } + } else { + imports.push(resolved); + } } else if !module.starts_with('.') { imports.push(module.to_string()); } @@ -67,6 +70,19 @@ fn extract_python_imports(source: &str, path: &Path, package_root: Option<&Path> imports } +fn imported_names(names: &str) -> Vec { + names + .split(',') + .filter_map(|part| { + let ident = part.split_whitespace().next()?; + if ident.is_empty() || ident.starts_with('(') || ident == "*" { + return None; + } + Some(ident.to_string()) + }) + .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 { @@ -124,7 +140,7 @@ fn python_import_re() -> &'static Regex { fn python_from_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { - Regex::new(r"(?m)^\s*from\s+(\.+(?:[A-Za-z_][\w.]*)?|[A-Za-z_][\w.]*)\s+import") + Regex::new(r"(?m)^\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/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs index eff1ad5d9..4329cd33c 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -11,13 +11,7 @@ pub(crate) fn collect_ruby_facts( ) -> LangFactMap { let roots = configured_roots(root, apps); let files = files_under(all_files, &roots, "rb"); - let mut facts = LangFactMap::default(); - for path in files { - if let Some(file) = parse_ruby_file(&path, &roots, apps) { - facts.index_file(file); - } - } - facts + super::facts::collect_files_parallel(files, |path| parse_ruby_file(path, &roots, apps)) } fn parse_ruby_file(path: &Path, roots: &[PathBuf], apps: &[String]) -> Option { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs index 0dabf5cc7..a6262742c 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs @@ -13,13 +13,9 @@ pub(crate) fn collect_rust_facts( ) -> LangFactMap { let roots = configured_roots(root, packages); let files = files_under(all_files, &roots, "rs"); - let mut facts = LangFactMap::default(); - for path in files { - if let Some(file) = parse_rust_file(root, &path, &roots, packages) { - facts.index_file(file); - } - } - facts + super::facts::collect_files_parallel(files, |path| { + parse_rust_file(root, path, &roots, packages) + }) } fn parse_rust_file( diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs index 3ac4ab167..f52590279 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -41,7 +41,7 @@ fn python_collects_relative_import_celery_and_django_routes() { assert!(facts.files[&views] .imports .iter() - .any(|import| import.contains("models"))); + .any(|import| import.ends_with(".models") || import.contains("models"))); let tasks = facts .files .values() @@ -80,6 +80,11 @@ fn go_collects_asynq_task_and_handler() { .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 @@ -159,10 +164,13 @@ fn kafka_extracts_static_topics_and_skips_dynamic() { 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" diff --git a/docs/feature-parity.md b/docs/feature-parity.md index c4e7ad123..09ef5a075 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 @@ -342,9 +342,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 0f96c7484..28ef20110 100644 --- a/docs/graph-edges.md +++ b/docs/graph-edges.md @@ -70,6 +70,10 @@ intentionally not added to unfiltered `dependencies --relationship all` output. | `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 local or imported route helper. Server-route definitions remain constrained by diff --git a/fixtures/lang-frontends/go-asynq/worker/enqueue.go b/fixtures/lang-frontends/go-asynq/worker/enqueue.go index 6f4b2eab1..752647e45 100644 --- a/fixtures/lang-frontends/go-asynq/worker/enqueue.go +++ b/fixtures/lang-frontends/go-asynq/worker/enqueue.go @@ -1,8 +1,15 @@ package worker -import "github.com/hibiken/asynq" +import "fmt" + +import ( + "github.com/hibiken/asynq" +) func EnqueueWelcome(client *asynq.Client) error { - _, err := client.Enqueue(asynq.NewTask("mail:welcome", nil)) - return err + _, err := client.Enqueue(asynq.NewTask("mail:welcome", nil)) + if err != nil { + return fmt.Errorf("enqueue: %w", err) + } + return nil } diff --git a/fixtures/lang-frontends/go-asynq/worker/tasks.go b/fixtures/lang-frontends/go-asynq/worker/tasks.go index d9f62a895..e6ba470d0 100644 --- a/fixtures/lang-frontends/go-asynq/worker/tasks.go +++ b/fixtures/lang-frontends/go-asynq/worker/tasks.go @@ -1,9 +1,13 @@ package worker -import "github.com/hibiken/asynq" +import ( + "context" -func HandleWelcome(ctx interface{}, task *asynq.Task) error { - return nil + "github.com/hibiken/asynq" +) + +func HandleWelcome(ctx context.Context, task *asynq.Task) error { + return nil } func Register(mux *asynq.ServeMux) { diff --git a/fixtures/lang-frontends/php-laravel/app/Jobs/SomeJob.php b/fixtures/lang-frontends/php-laravel/app/Jobs/SomeJob.php index e185494e2..adb8794db 100644 --- a/fixtures/lang-frontends/php-laravel/app/Jobs/SomeJob.php +++ b/fixtures/lang-frontends/php-laravel/app/Jobs/SomeJob.php @@ -2,8 +2,11 @@ namespace App\Jobs; +use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; class SomeJob implements ShouldQueue { + use Dispatchable, Queueable; } diff --git a/fixtures/lang-frontends/python-celery-django/app/users/views.py b/fixtures/lang-frontends/python-celery-django/app/users/views.py index 90117e6d8..342dab7b2 100644 --- a/fixtures/lang-frontends/python-celery-django/app/users/views.py +++ b/fixtures/lang-frontends/python-celery-django/app/users/views.py @@ -1,3 +1,4 @@ +from . import models from .models import User def user_list(): diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md index 25b3230ad..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/Python/Rust/Rails/PHP graphs require explicit `tests.` config — see `docs/feature-parity.md`. +`--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. From 02f2afddf5f16dc5c7aaa97b7290f5b861fa8799 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 06:04:20 -0700 Subject: [PATCH 07/26] fix: match language graph keys and advertised edge kinds Index Python modules under the configured package prefix so `from app.users import views` resolves. Emit rust-package/php-package edges, scope route handlers to the owning app, index exported Go types, require an explicit Laravel framework, and honor queue globs for language enqueue/worker facts. --- .../dependencies/graph/edge_lang_domains.rs | 14 +++++- .../dependencies/graph/edge_lang_frontends.rs | 47 ++++++++++++++++--- .../src/codebase/lang_frontends/go.rs | 5 +- .../src/codebase/lang_frontends/php.rs | 2 +- .../src/codebase/lang_frontends/python.rs | 37 +++++++++++++-- .../src/codebase/lang_frontends/tests.rs | 32 ++++++++++--- .../lang-frontends/go-asynq/worker/tasks.go | 2 + 7 files changed, 119 insertions(+), 20 deletions(-) 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 index 253dc9ed7..79073947c 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -3,7 +3,19 @@ fn emit_route_edges(facts: &LangFactMap, edges: &mut Vec) { for (_, handler) in &file.route_handlers { for name in route_handler_names(handler) { if let Some(targets) = facts.declarations.get(&name) { - push_file_edges(edges, &file.path, targets, EdgeKind::RouteRef); + let scoped: std::collections::BTreeSet<_> = targets + .iter() + .filter(|target| { + file.package.is_none() + || facts + .files + .get(*target) + .and_then(|other| other.package.as_ref()) + == file.package.as_ref() + }) + .cloned() + .collect(); + push_file_edges(edges, &file.path, &scoped, EdgeKind::RouteRef); } } } 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 index 4c62ab228..c1b8b7039 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -22,12 +22,19 @@ fn collect_language_frontend_edges( 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::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::PhpPackage, &mut edges); - emit_queue_edges(&facts.python, options.queue_cluster.as_deref(), &mut edges); - emit_queue_edges(&facts.go, options.queue_cluster.as_deref(), &mut edges); - emit_queue_edges(&facts.ruby, options.queue_cluster.as_deref(), &mut edges); - emit_queue_edges(&facts.php, options.queue_cluster.as_deref(), &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(&facts.python, &mut edges); emit_route_edges(&facts.ruby, &mut edges); emit_route_edges(&facts.php, &mut edges); @@ -83,10 +90,27 @@ fn emit_lang_edges( } } -fn emit_queue_edges(facts: &LangFactMap, cluster: Option<&str>, edges: &mut Vec) { +fn emit_package_edges(facts: &LangFactMap, kind: EdgeKind, edges: &mut Vec) { + for files in facts.files_by_package.values() { + for source in files { + push_file_edges(edges, source, files, kind); + } + } +} + +fn emit_queue_edges( + root: &Path, + facts: &LangFactMap, + options: &GraphConfigOptions, + edges: &mut Vec, +) { + let cluster = options.queue_cluster.as_deref(); let mut workers: std::collections::HashMap> = std::collections::HashMap::new(); for file in facts.files.values() { + if !file_matches_globs(root, &file.path, &options.queue_workers) { + continue; + } for job in &file.queue_workers { workers .entry(topic_identity(cluster, job)) @@ -95,6 +119,9 @@ fn emit_queue_edges(facts: &LangFactMap, cluster: Option<&str>, edges: &mut Vec< } } for file in facts.files.values() { + if !file_matches_globs(root, &file.path, &options.queue_enqueues) { + continue; + } for job in &file.queue_enqueues { let identity = topic_identity(cluster, job); let node = NodeId::QueueJob { @@ -119,6 +146,14 @@ fn emit_queue_edges(facts: &LangFactMap, cluster: Option<&str>, edges: &mut Vec< } } +fn file_matches_globs(root: &Path, path: &Path, globs: &[String]) -> bool { + if globs.is_empty() { + return false; + } + let rel = path.strip_prefix(root).unwrap_or(path); + matches_any(&rel.to_string_lossy(), globs) +} + fn push_file_edges( edges: &mut Vec, source: &Path, diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index 692baecd2..3d56d9e00 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -95,7 +95,10 @@ fn go_quoted_re() -> &'static Regex { fn go_decl_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { - Regex::new(r"(?m)^\s*func\s+(?:\([^)]+\)\s+)?([A-Z][A-Za-z0-9_]*)").expect("func") + Regex::new( + r"(?m)^\s*(?:func\s+(?:\([^)]+\)\s+)?|type\s+|var\s+|const\s+)([A-Z][A-Za-z0-9_]*)", + ) + .expect("func") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 23a6f5b6c..d286162d6 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -12,7 +12,7 @@ pub(crate) fn collect_php_facts( ) -> LangFactMap { let roots = configured_roots(root, apps); let files = files_under(all_files, &roots, "php"); - let laravel = framework.is_none_or(|name| name.eq_ignore_ascii_case("laravel")); + 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)) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python.rs b/crates/no-mistakes/src/codebase/lang_frontends/python.rs index 9b397534a..ea0f36247 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -30,14 +30,13 @@ fn parse_python_file( let package_root = package .as_ref() .map(|name| crate::codebase::ts_resolver::normalize_path(&root.join(name))); - let module = package_root - .as_ref() - .and_then(|pkg| module_from_path(pkg, path)); + 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()); Some(LangFileFacts { path: path.to_path_buf(), package, module, - imports: extract_python_imports(&text, path, package_root.as_deref()), + imports, declarations: extract_named(&text, python_decl_re()), references: extract_named(&text, python_ref_re()), route_handlers: extract_pairs(&text, django_route_re()), @@ -46,7 +45,31 @@ fn parse_python_file( }) } -fn extract_python_imports(source: &str, path: &Path, package_root: Option<&Path>) -> Vec { +fn python_module( + package: Option<&str>, + package_root: Option<&Path>, + path: &Path, +) -> Option { + let package = package?; + match package_root.and_then(|root| module_from_path(root, path)) { + Some(rel) => Some(format!("{package}.{rel}")), + None => Some(package.to_string()), + } +} + +fn prefix_package(package: Option<&str>, module: String) -> String { + match package { + Some(package) => format!("{package}.{module}"), + None => module, + } +} + +fn extract_python_imports( + source: &str, + path: &Path, + package: Option<&str>, + package_root: Option<&Path>, +) -> Vec { let mut imports = extract_named(source, python_import_re()); for cap in python_from_re().captures_iter(source) { let Some(module) = cap.get(1).map(|m| m.as_str()) else { @@ -54,6 +77,7 @@ fn extract_python_imports(source: &str, path: &Path, package_root: Option<&Path> }; 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 == '.') { for name in imported_names(names) { imports.push(format!("{resolved}.{name}")); @@ -63,6 +87,9 @@ fn extract_python_imports(source: &str, path: &Path, package_root: Option<&Path> } } else if !module.starts_with('.') { imports.push(module.to_string()); + for name in imported_names(names) { + imports.push(format!("{module}.{name}")); + } } } imports.sort(); diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs index f52590279..5e69ef869 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -41,7 +41,20 @@ fn python_collects_relative_import_celery_and_django_routes() { assert!(facts.files[&views] .imports .iter() - .any(|import| import.ends_with(".models") || import.contains("models"))); + .any(|import| import == "app.users.models" || import.ends_with(".models"))); + let urls = facts + .files + .values() + .find(|file| file.path.ends_with("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() @@ -60,11 +73,6 @@ fn python_collects_relative_import_celery_and_django_routes() { .queue_enqueues .iter() .any(|job| job == "send_welcome")); - let urls = facts - .files - .values() - .find(|file| file.path.ends_with("urls.py")) - .expect("urls"); assert!(urls .route_handlers .iter() @@ -92,6 +100,10 @@ fn go_collects_asynq_task_and_handler() { .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")); } #[test] @@ -187,3 +199,11 @@ fn empty_config_collects_nothing() { 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())); +} diff --git a/fixtures/lang-frontends/go-asynq/worker/tasks.go b/fixtures/lang-frontends/go-asynq/worker/tasks.go index e6ba470d0..803aebd7f 100644 --- a/fixtures/lang-frontends/go-asynq/worker/tasks.go +++ b/fixtures/lang-frontends/go-asynq/worker/tasks.go @@ -6,6 +6,8 @@ import ( "github.com/hibiken/asynq" ) +type WelcomePayload struct{} + func HandleWelcome(ctx context.Context, task *asynq.Task) error { return nil } From 5dc8413d1348eca8798bb906db6a35f57ccdc56b Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 11:27:51 -0700 Subject: [PATCH 08/26] fix: drop package cliques and tighten language extractors Emit rust-package/php-package from a crate or app root only, emit RustMod from `mod` declarations, accept Go import aliases, parse comma-separated Python imports, skip Kafka reads outside queue globs, and include Django tests.py in --test python. Split oversized Python modules to stay under rust-max-lines-per-file. --- .../codebase/dependencies/args_test_globs.rs | 1 + .../dependencies/graph/edge_lang_domains.rs | 13 +- .../dependencies/graph/edge_lang_frontends.rs | 33 ++++- .../src/codebase/dependencies/tests/args.rs | 1 + .../src/codebase/lang_frontends/facts.rs | 17 ++- .../src/codebase/lang_frontends/go.rs | 5 +- .../src/codebase/lang_frontends/mod.rs | 2 + .../src/codebase/lang_frontends/php.rs | 1 + .../src/codebase/lang_frontends/python.rs | 106 +-------------- .../codebase/lang_frontends/python_imports.rs | 121 ++++++++++++++++++ .../src/codebase/lang_frontends/ruby.rs | 1 + .../src/codebase/lang_frontends/rustlang.rs | 13 +- .../src/codebase/lang_frontends/tests.rs | 46 +------ .../codebase/lang_frontends/tests_extra.rs | 68 ++++++++++ fixtures/lang-frontends/go-asynq/go.mod | 2 + .../lang-frontends/go-asynq/worker/enqueue.go | 4 +- .../python-celery-django/app/enqueue.py | 1 + 17 files changed, 277 insertions(+), 158 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs 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 231ddacf5..459d9c0f5 100644 --- a/crates/no-mistakes/src/codebase/dependencies/args_test_globs.rs +++ b/crates/no-mistakes/src/codebase/dependencies/args_test_globs.rs @@ -49,6 +49,7 @@ pub(crate) fn test_globs(framework: &str) -> Vec { "python" => globs_to_strings(&[ "**/test_*.py", "**/*_test.py", + "**/tests.py", "**/tests/**/*.py", ]), "go" => globs_to_strings(&["**/*_test.go"]), 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 index 79073947c..aebd0322c 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -60,15 +60,20 @@ fn emit_kafka_edges( let mut produces = Vec::new(); let mut consumes = Vec::new(); for path in all_files { + let rel = path.strip_prefix(root).unwrap_or(path); + let rel_s = rel.to_string_lossy(); + let enqueue = matches_any(&rel_s, &options.queue_enqueues); + let worker = matches_any(&rel_s, &options.queue_workers); + if !enqueue && !worker { + continue; + } let Some((prod, cons)) = scan_kafka_file(path) else { continue; }; - let rel = path.strip_prefix(root).unwrap_or(path); - let rel_s = rel.to_string_lossy(); - if matches_any(&rel_s, &options.queue_enqueues) { + if enqueue { produces.push((path.clone(), prod)); } - if matches_any(&rel_s, &options.queue_workers) { + if worker { consumes.push((path.clone(), cons)); } } 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 index c1b8b7039..403bc7423 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -21,7 +21,8 @@ fn collect_language_frontend_edges( 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::RustMod, &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); @@ -90,14 +91,38 @@ fn emit_lang_edges( } } +fn emit_mod_edges(facts: &LangFactMap, kind: EdgeKind, edges: &mut Vec) { + for file in facts.files.values() { + for name in &file.mods { + if let Some(targets) = facts.files_by_module.get(name) { + push_file_edges(edges, &file.path, targets, kind); + } + } + } +} + fn emit_package_edges(facts: &LangFactMap, kind: EdgeKind, edges: &mut Vec) { for files in facts.files_by_package.values() { - for source in files { - push_file_edges(edges, source, files, kind); - } + 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> { + files + .iter() + .find(|path| { + matches!( + path.file_name().and_then(|name| name.to_str()), + Some("lib.rs" | "main.rs" | "mod.rs" | "composer.json") + ) + }) + .or_else(|| files.iter().next()) + .map(PathBuf::as_path) +} + fn emit_queue_edges( root: &Path, facts: &LangFactMap, diff --git a/crates/no-mistakes/src/codebase/dependencies/tests/args.rs b/crates/no-mistakes/src/codebase/dependencies/tests/args.rs index a49611ef7..f8b26209f 100644 --- a/crates/no-mistakes/src/codebase/dependencies/tests/args.rs +++ b/crates/no-mistakes/src/codebase/dependencies/tests/args.rs @@ -237,6 +237,7 @@ fn python_relationship_enables_language_frontend_plan() { #[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"))); diff --git a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs index faa95f4c8..5c9db2134 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs @@ -12,6 +12,7 @@ pub(crate) struct LangFileFacts { pub route_handlers: Vec<(String, String)>, pub queue_enqueues: Vec, pub queue_workers: Vec, + pub mods: Vec, } #[derive(Debug, Clone, Default)] @@ -90,6 +91,14 @@ pub(crate) fn files_under( } 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() @@ -100,10 +109,10 @@ pub(crate) fn module_from_path(package_root: &Path, path: &Path) -> Option Option Vec { fn go_single_import_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r#"(?m)^\s*import\s+"([^"]+)""#).expect("import")) + 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 { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/mod.rs b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs index 1bcde89a1..1bc8b4af6 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/mod.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs @@ -9,6 +9,8 @@ mod strip; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_extra; use std::path::{Path, PathBuf}; diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index d286162d6..101a71254 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -47,6 +47,7 @@ fn parse_php_file( } else { Vec::new() }, + mods: Vec::new(), }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python.rs b/crates/no-mistakes/src/codebase/lang_frontends/python.rs index ea0f36247..044c00a17 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -1,7 +1,8 @@ -use super::facts::{ - configured_roots, files_under, module_from_path, owning_package, LangFactMap, LangFileFacts, -}; +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}; use regex::Regex; use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -42,94 +43,10 @@ fn parse_python_file( route_handlers: extract_pairs(&text, django_route_re()), queue_enqueues: extract_named(&text, celery_enqueue_re()), queue_workers: extract_celery_workers(&text), + mods: Vec::new(), }) } -fn python_module( - package: Option<&str>, - package_root: Option<&Path>, - path: &Path, -) -> Option { - let package = package?; - match package_root.and_then(|root| module_from_path(root, path)) { - Some(rel) => Some(format!("{package}.{rel}")), - None => Some(package.to_string()), - } -} - -fn prefix_package(package: Option<&str>, module: String) -> String { - match package { - Some(package) => format!("{package}.{module}"), - None => module, - } -} - -fn extract_python_imports( - source: &str, - path: &Path, - package: Option<&str>, - package_root: Option<&Path>, -) -> Vec { - let mut imports = extract_named(source, python_import_re()); - 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 == '.') { - for name in imported_names(names) { - imports.push(format!("{resolved}.{name}")); - } - } else { - imports.push(resolved); - } - } else if !module.starts_with('.') { - imports.push(module.to_string()); - for name in imported_names(names) { - imports.push(format!("{module}.{name}")); - } - } - } - imports.sort(); - imports.dedup(); - imports -} - -fn imported_names(names: &str) -> Vec { - names - .split(',') - .filter_map(|part| { - let ident = part.split_whitespace().next()?; - if ident.is_empty() || ident.starts_with('(') || ident == "*" { - return None; - } - Some(ident.to_string()) - }) - .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_celery_workers(source: &str) -> Vec { let mut names = extract_named(source, celery_named_task_re()); names.extend(extract_named(source, celery_fn_task_re())); @@ -159,19 +76,6 @@ fn extract_pairs(source: &str, re: &Regex) -> Vec<(String, String)> { .collect() } -fn python_import_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"(?m)^\s*import\s+([A-Za-z_][\w.]*)").expect("python import")) -} - -fn python_from_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new(r"(?m)^\s*from\s+(\.+(?:[A-Za-z_][\w.]*)?|[A-Za-z_][\w.]*)\s+import\s+([^\n]+)") - .expect("from") - }) -} - fn python_decl_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { 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..9edd1249d --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs @@ -0,0 +1,121 @@ +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?; + match package_root.and_then(|root| module_from_path(root, path)) { + Some(rel) => Some(format!("{package}.{rel}")), + None => Some(package.to_string()), + } +} + +fn prefix_package(package: Option<&str>, module: String) -> String { + match package { + 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 mut imports = Vec::new(); + for raw in extract_named(source, python_import_re()) { + for part in raw.split(',') { + if let Some(name) = part.split_whitespace().next() { + if !name.is_empty() { + imports.push(name.to_string()); + } + } + } + } + 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 == '.') { + for name in imported_names(names) { + imports.push(format!("{resolved}.{name}")); + } + } else { + imports.push(resolved); + } + } else if !module.starts_with('.') { + imports.push(module.to_string()); + for name in imported_names(names) { + imports.push(format!("{module}.{name}")); + } + } + } + imports.sort(); + imports.dedup(); + imports +} + +fn imported_names(names: &str) -> Vec { + names + .split(',') + .filter_map(|part| { + let ident = part.split_whitespace().next()?; + if ident.is_empty() || ident.starts_with('(') || ident == "*" { + return None; + } + Some(ident.to_string()) + }) + .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"(?m)^\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/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs index 4329cd33c..db46f918e 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -29,6 +29,7 @@ fn parse_ruby_file(path: &Path, roots: &[PathBuf], apps: &[String]) -> Option Vec { 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*)").expect("mod") + }) +} + fn rust_use_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| Regex::new(r"(?m)^\s*use\s+(?:crate|super|self)::([\w:]+)").expect("use")) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs index 5e69ef869..7432557e6 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -1,5 +1,4 @@ use super::*; -use crate::codebase::lang_frontends::kafka::extract_kafka_topics; use std::path::PathBuf; fn fixture(name: &str) -> PathBuf { @@ -69,6 +68,11 @@ fn python_collects_relative_import_celery_and_django_routes() { .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() @@ -116,6 +120,7 @@ fn rust_collects_use_and_declaration() { .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() @@ -168,42 +173,3 @@ fn php_collects_laravel_route_and_dispatch() { .iter() .any(|name| name == "App.Jobs.SomeJob" || name == "SomeJob")); } - -#[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())); -} 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..000126cbc --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs @@ -0,0 +1,68 @@ +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())); +} diff --git a/fixtures/lang-frontends/go-asynq/go.mod b/fixtures/lang-frontends/go-asynq/go.mod index df35257c2..8296230de 100644 --- a/fixtures/lang-frontends/go-asynq/go.mod +++ b/fixtures/lang-frontends/go-asynq/go.mod @@ -1,3 +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/worker/enqueue.go b/fixtures/lang-frontends/go-asynq/worker/enqueue.go index 752647e45..88376efc6 100644 --- a/fixtures/lang-frontends/go-asynq/worker/enqueue.go +++ b/fixtures/lang-frontends/go-asynq/worker/enqueue.go @@ -1,6 +1,6 @@ package worker -import "fmt" +import fmtlib "fmt" import ( "github.com/hibiken/asynq" @@ -9,7 +9,7 @@ import ( func EnqueueWelcome(client *asynq.Client) error { _, err := client.Enqueue(asynq.NewTask("mail:welcome", nil)) if err != nil { - return fmt.Errorf("enqueue: %w", err) + return fmtlib.Errorf("enqueue: %w", err) } return nil } diff --git a/fixtures/lang-frontends/python-celery-django/app/enqueue.py b/fixtures/lang-frontends/python-celery-django/app/enqueue.py index 7608de15c..f72490e9f 100644 --- a/fixtures/lang-frontends/python-celery-django/app/enqueue.py +++ b/fixtures/lang-frontends/python-celery-django/app/enqueue.py @@ -1,3 +1,4 @@ +import app.users.models, app.tasks from app.tasks import send_welcome def invite(user_id: int) -> None: From 0779195f2f5798227ccfbe1f07941d4f173cbbe0 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 11:31:26 -0700 Subject: [PATCH 09/26] fix: intern language queue and file nodes as Arc Match NodeId::file / queue_job after merging origin/main so language frontend edges compile against the interned graph node IDs. --- .../dependencies/graph/edge_lang_domains.rs | 13 +++---------- .../dependencies/graph/edge_lang_frontends.rs | 15 ++++----------- 2 files changed, 7 insertions(+), 21 deletions(-) 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 index aebd0322c..4645a1839 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -90,22 +90,15 @@ fn emit_kafka_edges( for (path, topics) in produces { for topic in topics { let identity = topic_identity(cluster, &topic); - let node = NodeId::QueueJob { - queue_file: path.clone(), - job: identity.clone(), - }; + let node = NodeId::queue_job(&path, identity.clone()); edges.push(( - NodeId::File(path.clone()), + 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.clone()), - EdgeKind::QueueWorker, - )); + edges.push((node.clone(), NodeId::file(worker), EdgeKind::QueueWorker)); } } } 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 index 403bc7423..b2523fa1a 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -149,12 +149,9 @@ fn emit_queue_edges( } for job in &file.queue_enqueues { let identity = topic_identity(cluster, job); - let node = NodeId::QueueJob { - queue_file: file.path.clone(), - job: identity.clone(), - }; + let node = NodeId::queue_job(&file.path, identity.clone()); edges.push(( - NodeId::File(file.path.clone()), + NodeId::file(&file.path), node.clone(), EdgeKind::QueueEnqueue, )); @@ -162,7 +159,7 @@ fn emit_queue_edges( for worker in targets { edges.push(( node.clone(), - NodeId::File(worker.clone()), + NodeId::file(worker), EdgeKind::QueueWorker, )); } @@ -187,11 +184,7 @@ fn push_file_edges( ) { for target in targets { if target != source { - edges.push(( - NodeId::File(source.to_path_buf()), - NodeId::File(target.clone()), - kind, - )); + edges.push((NodeId::file(source), NodeId::file(target), kind)); } } } From 8ef67f4d77bece64320921541da71d3e1547e7e2 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 11:44:35 -0700 Subject: [PATCH 10/26] test: cover language frontend graph edges and extractor branches Exercise collect_language_frontend_edges across the language fixtures so route, queue, package, and Kafka emitters stay above the coverage gate. Add comment-strip, require_relative, and missing-manifest cases. --- .../graph/tests/language_frontends.rs | 98 +++++++++++++++++++ .../codebase/dependencies/graph/tests/mod.rs | 1 + .../codebase/lang_frontends/tests_extra.rs | 45 +++++++++ .../app/controllers/users_controller.rb | 2 + 4 files changed, 146 insertions(+) create mode 100644 crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs 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..6b7f1ec59 --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs @@ -0,0 +1,98 @@ +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(|(_, _, kind)| *kind == EdgeKind::RouteRef)); + + 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(|(_, _, kind)| *kind == EdgeKind::RustPackage)); + + 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()); +} 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..2b478f2e7 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,7 @@ include!("build_check_fact_adapters.rs"); include!("compatibility_adapters.rs"); include!("core_resolution.rs"); include!("core.rs"); +include!("language_frontends.rs"); include!("scoped_universe.rs"); include!("legacy_symbol_channel.rs"); include!("session_resolver_cache.rs"); diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs index 000126cbc..022f18af4 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs @@ -66,3 +66,48 @@ fn php_without_framework_skips_laravel_extractors() { 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_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()); + 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("users_controller.rb")) + .expect("controller"); + assert!(controller + .imports + .iter() + .any(|import| import == "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"))); +} diff --git a/fixtures/lang-frontends/rails-jobs/app/controllers/users_controller.rb b/fixtures/lang-frontends/rails-jobs/app/controllers/users_controller.rb index 7e370d70a..bf506be52 100644 --- a/fixtures/lang-frontends/rails-jobs/app/controllers/users_controller.rb +++ b/fixtures/lang-frontends/rails-jobs/app/controllers/users_controller.rb @@ -1,3 +1,5 @@ +require_relative "../jobs/welcome_job" + class UsersController def index WelcomeJob.perform_later From 81e833975f06334f118faf37943eade57b20d399 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 11:50:47 -0700 Subject: [PATCH 11/26] fix: prefix project queue globs and expand PHP grouped uses Normalize enqueue/worker globs with the owning project root so `root: backend` matches repository-relative paths. Qualify nested Rust `mod` names against the declaring module, and expand `use App\\Jobs\\{A, B}` into qualified PHP imports. --- .../dependencies/graph/edge_lang_frontends.rs | 10 ++++++- .../dependencies/graph/files_config.rs | 25 +++++++++++++++-- .../src/codebase/lang_frontends/php.rs | 28 ++++++++++++++++++- .../lang-frontends/php-laravel/routes/web.php | 1 + 4 files changed, 60 insertions(+), 4 deletions(-) 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 index b2523fa1a..373e5674b 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -94,7 +94,15 @@ fn emit_lang_edges( fn emit_mod_edges(facts: &LangFactMap, kind: EdgeKind, edges: &mut Vec) { for file in facts.files.values() { for name in &file.mods { - if let Some(targets) = facts.files_by_module.get(name) { + 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 { push_file_edges(edges, &file.path, targets, kind); } } 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 e4d2730e7..433f23a34 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/files_config.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/files_config.rs @@ -126,12 +126,12 @@ fn graph_config_options_from_loaded_with_test_filter( queue_enqueues: v2_config .projects .values() - .flat_map(|project| project.queues.enqueues.iter().cloned()) + .flat_map(prefixed_queue_globs_enqueues) .collect(), queue_workers: v2_config .projects .values() - .flat_map(|project| project.queues.workers.iter().cloned()) + .flat_map(prefixed_queue_globs_workers) .collect(), queue_cluster: v2_config .projects @@ -142,6 +142,27 @@ fn graph_config_options_from_loaded_with_test_filter( } } +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 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.starts_with(root) => format!("{}/{glob}", root.trim_end_matches('/')), + _ => glob.clone(), + }) + .collect() +} + fn dedup_rewrites( mut rewrites: Vec, ) -> Vec { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 101a71254..0ff87989c 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -29,7 +29,7 @@ fn parse_php_file( path: path.to_path_buf(), package: owning_package(path, roots, apps), module: classes.first().cloned(), - imports: extract_named(&text, php_use_re()), + imports: extract_php_uses(&text), declarations: classes, references: extract_named(&text, php_use_re()), route_handlers: if laravel { @@ -96,6 +96,32 @@ fn php_namespace_re() -> &'static Regex { RE.get_or_init(|| Regex::new(r"(?m)^\s*namespace\s+([A-Za-z_\\][A-Za-z0-9_\\]*)").expect("ns")) } +fn extract_php_uses(source: &str) -> Vec { + let mut imports = extract_named(source, php_use_re()); + 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 member = member.trim(); + if !member.is_empty() { + imports.push(format!("{prefix}.{member}")); + } + } + } + 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_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")) diff --git a/fixtures/lang-frontends/php-laravel/routes/web.php b/fixtures/lang-frontends/php-laravel/routes/web.php index 72ce82113..5703d299c 100644 --- a/fixtures/lang-frontends/php-laravel/routes/web.php +++ b/fixtures/lang-frontends/php-laravel/routes/web.php @@ -1,5 +1,6 @@ Date: Sun, 16 Aug 2026 11:57:16 -0700 Subject: [PATCH 12/26] fix: index PHP composer.json and public Rust use items Include configured composer.json files in PHP package facts so php-package edges can root at the manifest. Accept pub/pub(crate) before Rust use so crate-root re-exports emit rust-use edges. --- crates/no-mistakes/src/codebase/lang_frontends/php.rs | 8 +++++++- .../no-mistakes/src/codebase/lang_frontends/rustlang.rs | 5 ++++- fixtures/lang-frontends/php-laravel/composer.json | 3 +++ fixtures/lang-frontends/rust-mods/src/lib.rs | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 fixtures/lang-frontends/php-laravel/composer.json diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 0ff87989c..03fb8fac2 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -11,7 +11,13 @@ pub(crate) fn collect_php_facts( framework: Option<&str>, ) -> LangFactMap { let roots = configured_roots(root, apps); - let files = files_under(all_files, &roots, "php"); + 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)) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs index 59b94fd2b..b2956e6e0 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs @@ -86,7 +86,10 @@ fn rust_mod_re() -> &'static Regex { fn rust_use_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"(?m)^\s*use\s+(?:crate|super|self)::([\w:]+)").expect("use")) + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*(?:pub(?:\([^)]+\))?\s+)?use\s+(?:crate|super|self)::([\w:]+)") + .expect("use") + }) } fn rust_decl_re() -> &'static Regex { diff --git a/fixtures/lang-frontends/php-laravel/composer.json b/fixtures/lang-frontends/php-laravel/composer.json new file mode 100644 index 000000000..3d136cabc --- /dev/null +++ b/fixtures/lang-frontends/php-laravel/composer.json @@ -0,0 +1,3 @@ +{ + "name": "fixture/php-laravel" +} diff --git a/fixtures/lang-frontends/rust-mods/src/lib.rs b/fixtures/lang-frontends/rust-mods/src/lib.rs index 0c99cfc05..8dbabc6c1 100644 --- a/fixtures/lang-frontends/rust-mods/src/lib.rs +++ b/fixtures/lang-frontends/rust-mods/src/lib.rs @@ -1,6 +1,6 @@ pub mod mail; -use crate::mail::Welcome; +pub use crate::mail::Welcome; pub fn send() { Welcome::emit(); From 43c99418d9231ebd4e70ac3116f2644428724b0b Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 12:05:36 -0700 Subject: [PATCH 13/26] fix: prefer crate roots, Django as_view, and deepest Go modules Choose lib.rs/main.rs over nested mod.rs for rust-package hubs. Strip .as_view so class-based Django routes resolve to the view class. Index overlapping Go modules from the deepest matching root. --- .../dependencies/graph/edge_lang_domains.rs | 7 ++++++- .../dependencies/graph/edge_lang_frontends.rs | 17 ++++++++-------- .../graph/tests/language_frontends.rs | 20 +++++++++++++------ .../src/codebase/lang_frontends/go.rs | 5 ++++- .../src/codebase/lang_frontends/tests.rs | 11 ++++++++++ .../lang-frontends/go-asynq/nested/go.mod | 3 +++ .../lang-frontends/go-asynq/nested/mail.go | 1 + .../python-celery-django/app/urls.py | 1 + .../python-celery-django/app/users/views.py | 3 +++ .../lang-frontends/rust-mods/src/aaa/mod.rs | 1 + fixtures/lang-frontends/rust-mods/src/lib.rs | 1 + 11 files changed, 54 insertions(+), 16 deletions(-) create mode 100644 fixtures/lang-frontends/go-asynq/nested/go.mod create mode 100644 fixtures/lang-frontends/go-asynq/nested/mail.go create mode 100644 fixtures/lang-frontends/rust-mods/src/aaa/mod.rs 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 index 4645a1839..fb82d3497 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -34,7 +34,12 @@ fn route_handler_names(handler: &str) -> Vec { if let Some((class, _)) = trimmed.split_once("::") { return vec![class.rsplit('\\').next().unwrap_or(class).to_string()]; } - vec![trimmed.rsplit('.').next().unwrap_or(&trimmed).to_string()] + let view = trimmed + .strip_suffix("()") + .unwrap_or(trimmed.as_str()) + .strip_suffix(".as_view") + .unwrap_or(trimmed.as_str()); + vec![view.rsplit('.').next().unwrap_or(view).to_string()] } fn snake_to_pascal(name: &str) -> String { 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 index 373e5674b..d2357c69b 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -119,14 +119,15 @@ fn emit_package_edges(facts: &LangFactMap, kind: EdgeKind, edges: &mut Vec } fn package_root_file(files: &std::collections::BTreeSet) -> Option<&Path> { - files - .iter() - .find(|path| { - matches!( - path.file_name().and_then(|name| name.to_str()), - Some("lib.rs" | "main.rs" | "mod.rs" | "composer.json") - ) - }) + 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) } 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 index 6b7f1ec59..54a4709ec 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs @@ -50,9 +50,11 @@ fn language_frontend_edges_cover_configured_extractors() { assert!(python_edges .iter() .any(|(_, _, kind)| *kind == EdgeKind::QueueEnqueue)); - assert!(python_edges - .iter() - .any(|(_, _, kind)| *kind == EdgeKind::RouteRef)); + 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)); @@ -65,9 +67,15 @@ fn language_frontend_edges_cover_configured_extractors() { assert!(rust_edges .iter() .any(|(_, _, kind)| *kind == EdgeKind::RustUse || *kind == EdgeKind::RustMod)); - assert!(rust_edges - .iter() - .any(|(_, _, kind)| *kind == EdgeKind::RustPackage)); + 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)); diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index 5f5a7c051..1a5947a03 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -34,7 +34,10 @@ fn parse_go_file(path: &Path, roots: &[PathBuf], modules: &[String]) -> Option Option { - let root = roots.iter().find(|candidate| path.starts_with(candidate))?; + let root = roots + .iter() + .filter(|candidate| path.starts_with(candidate)) + .max_by_key(|candidate| candidate.components().count())?; let module = std::fs::read_to_string(root.join("go.mod")) .ok() .and_then(|source| { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs index 7432557e6..89844b168 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -81,6 +81,10 @@ fn python_collects_relative_import_celery_and_django_routes() { .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] @@ -108,6 +112,13 @@ fn go_collects_asynq_task_and_handler() { .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] 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..7adda901a --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/nested/go.mod @@ -0,0 +1,3 @@ +module example.com/nested + +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/python-celery-django/app/urls.py b/fixtures/lang-frontends/python-celery-django/app/urls.py index 79b51cf6b..8300e7017 100644 --- a/fixtures/lang-frontends/python-celery-django/app/urls.py +++ b/fixtures/lang-frontends/python-celery-django/app/urls.py @@ -3,4 +3,5 @@ urlpatterns = [ path("api/users/", views.user_list), + path("users/", views.UserView.as_view()), ] diff --git a/fixtures/lang-frontends/python-celery-django/app/users/views.py b/fixtures/lang-frontends/python-celery-django/app/users/views.py index 342dab7b2..58f4b5532 100644 --- a/fixtures/lang-frontends/python-celery-django/app/users/views.py +++ b/fixtures/lang-frontends/python-celery-django/app/users/views.py @@ -3,3 +3,6 @@ def user_list(): return User + +class UserView: + pass 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..4828cd649 --- /dev/null +++ b/fixtures/lang-frontends/rust-mods/src/aaa/mod.rs @@ -0,0 +1 @@ +pub fn nested() {} diff --git a/fixtures/lang-frontends/rust-mods/src/lib.rs b/fixtures/lang-frontends/rust-mods/src/lib.rs index 8dbabc6c1..61349ca80 100644 --- a/fixtures/lang-frontends/rust-mods/src/lib.rs +++ b/fixtures/lang-frontends/rust-mods/src/lib.rs @@ -1,3 +1,4 @@ +pub mod aaa; pub mod mail; pub use crate::mail::Welcome; From 0f6f566ac77be2dbe8a68c144a549d553dcabf1d Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 12:13:49 -0700 Subject: [PATCH 14/26] fix: index PHP interfaces and Ruby require paths Treat interface/trait/enum declarations as PHP types so use targets resolve. Index Ruby files by app-relative require paths as well as stems so require "app/jobs/welcome_job" emits ruby-require edges. --- .../src/codebase/lang_frontends/php.rs | 6 +++-- .../src/codebase/lang_frontends/ruby.rs | 26 ++++++++++++++----- .../src/codebase/lang_frontends/tests.rs | 4 +++ .../codebase/lang_frontends/tests_extra.rs | 2 +- .../php-laravel/app/Contracts/Mailer.php | 7 +++++ .../lang-frontends/php-laravel/routes/web.php | 1 + .../app/controllers/users_controller.rb | 1 + 7 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 fixtures/lang-frontends/php-laravel/app/Contracts/Mailer.php diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 03fb8fac2..e48fd7d32 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -136,8 +136,10 @@ fn php_use_re() -> &'static Regex { fn php_class_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { - Regex::new(r"(?m)^\s*(?:final\s+|abstract\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)") - .expect("class") + Regex::new( + r"(?m)^\s*(?:final\s+|abstract\s+)?(?:class|interface|trait|enum)\s+([A-Za-z_][A-Za-z0-9_]*)", + ) + .expect("class") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs index db46f918e..ccd4a3bd0 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -20,10 +20,11 @@ fn parse_ruby_file(path: &Path, roots: &[PathBuf], apps: &[String]) -> Option Option Vec { +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 = parent.join(rel); + let resolved = 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()); } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs index 89844b168..cffd40bcb 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -183,4 +183,8 @@ fn php_collects_laravel_route_and_dispatch() { .declarations .iter() .any(|name| name == "App.Jobs.SomeJob" || name == "SomeJob")); + assert!(facts + .declarations + .keys() + .any(|name| name.contains("Mailer"))); } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs index 022f18af4..dc69455f9 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs @@ -103,7 +103,7 @@ fn rails_require_relative_and_python_init_module_keys() { assert!(controller .imports .iter() - .any(|import| import == "welcome_job")); + .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 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 @@ + Date: Sun, 16 Aug 2026 13:34:34 -0700 Subject: [PATCH 15/26] fix: keep Rails controller namespaces and PHP import aliases Map admin/users#index to Admin::UsersController instead of flattening the path. Drop `as` aliases from grouped PHP use members. Add fixture coverage for those forms plus queue-glob and Kafka miss branches. --- .../dependencies/graph/edge_lang_domains.rs | 31 ++++++++---- .../graph/tests/language_frontends.rs | 49 +++++++++++++++++++ .../src/codebase/lang_frontends/php.rs | 11 +++-- .../codebase/lang_frontends/python_imports.rs | 28 +++++++++-- .../src/codebase/lang_frontends/tests.rs | 12 ++++- .../codebase/lang_frontends/tests_extra.rs | 11 ++++- .../go-asynq/worker/pkg/ping.go | 1 + .../lang-frontends/php-laravel/routes/web.php | 1 + .../python-celery-django/app/users/views.py | 1 + .../queue-prefix/.no-mistakes.yml | 10 ++++ .../queue-prefix/backend/app/tasks.py | 2 + .../app/controllers/admin/users_controller.rb | 4 ++ .../app/controllers/users_controller.rb | 1 + .../rails-jobs/config/routes.rb | 1 + fixtures/lang-frontends/rust-mods/src/mod.rs | 1 + 15 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 fixtures/lang-frontends/go-asynq/worker/pkg/ping.go create mode 100644 fixtures/lang-frontends/queue-prefix/.no-mistakes.yml create mode 100644 fixtures/lang-frontends/queue-prefix/backend/app/tasks.py create mode 100644 fixtures/lang-frontends/rails-jobs/app/controllers/admin/users_controller.rb create mode 100644 fixtures/lang-frontends/rust-mods/src/mod.rs 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 index fb82d3497..b2cb2b026 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -25,11 +25,7 @@ fn emit_route_edges(facts: &LangFactMap, edges: &mut Vec) { fn route_handler_names(handler: &str) -> Vec { let trimmed = handler.replace(['\'', '"', ' '], ""); if let Some((controller, _)) = trimmed.split_once('#') { - let mut name = controller.replace('/', "_"); - if !name.ends_with("Controller") { - name.push_str("Controller"); - } - return vec![snake_to_pascal(&name), name]; + return rails_controller_names(controller); } if let Some((class, _)) = trimmed.split_once("::") { return vec![class.rsplit('\\').next().unwrap_or(class).to_string()]; @@ -42,15 +38,32 @@ fn route_handler_names(handler: &str) -> Vec { vec![view.rsplit('.').next().unwrap_or(view).to_string()] } +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}"), class] +} + fn snake_to_pascal(name: &str) -> String { name.split('_') .filter(|part| !part.is_empty()) .map(|part| { let mut chars = part.chars(); - match chars.next() { - Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), - None => String::new(), - } + 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/tests/language_frontends.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs index 54a4709ec..7051ffa5d 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs @@ -104,3 +104,52 @@ fn language_frontend_edges_skip_empty_config() { 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 == "backend/app/**/*.py" || glob.starts_with("backend/"))); +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index e48fd7d32..2d381b28b 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -110,9 +110,14 @@ fn extract_php_uses(source: &str) -> Vec { .map(|m| m.as_str().replace('\\', ".")) .unwrap_or_default(); for member in cap.get(2).map(|m| m.as_str()).unwrap_or("").split(',') { - let member = member.trim(); - if !member.is_empty() { - imports.push(format!("{prefix}.{member}")); + let ident = member + .split_whitespace() + .take_while(|token| !token.eq_ignore_ascii_case("as")) + .next() + .unwrap_or("") + .trim(); + if !ident.is_empty() { + imports.push(format!("{prefix}.{ident}")); } } } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs index 9edd1249d..4d3cb737b 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs @@ -32,9 +32,7 @@ pub(super) fn extract_python_imports( for raw in extract_named(source, python_import_re()) { for part in raw.split(',') { if let Some(name) = part.split_whitespace().next() { - if !name.is_empty() { - imports.push(name.to_string()); - } + imports.push(name.to_string()); } } } @@ -119,3 +117,27 @@ fn python_from_re() -> &'static Regex { .expect("from") }) } + +#[cfg(test)] +mod tests { + use super::*; + 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 ...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")); + assert_eq!(python_module(None, None, path), None); + assert_eq!( + prefix_package(None, "users.models".to_string()), + "users.models" + ); + } +} diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs index cffd40bcb..206893cee 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -152,10 +152,15 @@ fn rails_collects_route_and_active_job() { .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("users_controller.rb")) + .find(|file| file.path.ends_with("controllers/users_controller.rb")) .expect("controller"); assert_eq!(controller.queue_enqueues, vec!["WelcomeJob".to_string()]); } @@ -187,4 +192,9 @@ fn php_collects_laravel_route_and_dispatch() { .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 index dc69455f9..24181f9c8 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs @@ -83,6 +83,10 @@ 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 @@ -98,7 +102,7 @@ fn rails_require_relative_and_python_init_module_keys() { let controller = facts .files .values() - .find(|file| file.path.ends_with("users_controller.rb")) + .find(|file| file.path.ends_with("controllers/users_controller.rb")) .expect("controller"); assert!(controller .imports @@ -110,4 +114,9 @@ fn rails_require_relative_and_python_init_module_keys() { .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/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..c1caffeb1 --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go @@ -0,0 +1 @@ +package pkg diff --git a/fixtures/lang-frontends/php-laravel/routes/web.php b/fixtures/lang-frontends/php-laravel/routes/web.php index 9bf0accc6..c1345feca 100644 --- a/fixtures/lang-frontends/php-laravel/routes/web.php +++ b/fixtures/lang-frontends/php-laravel/routes/web.php @@ -1,6 +1,7 @@ Date: Sun, 16 Aug 2026 13:53:17 -0700 Subject: [PATCH 16/26] fix: tighten language extractors after review fix-thrash Mask Python string/docstring symbols, connect Django include() URLconfs, accept Laravel invokable controllers and FQCN ShouldQueue, expand Kafka subscribe arrays, keep per-project queue clusters, prefix globs on a path boundary, cache go.mod, skip _test.go import targets, expand grouped Rust use trees, and capture qualified Ruby constants. Scope references and Django handlers by module/import context. Document that dedicated queues and server CLIs still report the TS pipeline. --- .../dependencies/graph/edge_lang_domains.rs | 160 ++++++++++-------- .../dependencies/graph/edge_lang_frontends.rs | 76 ++------- .../dependencies/graph/edge_lang_queues.rs | 128 ++++++++++++++ .../dependencies/graph/files_config.rs | 47 +++-- .../src/codebase/dependencies/graph/mod.rs | 1 + .../graph/tests/config_edges_helpers.rs | 3 + .../graph/tests/config_fact_plan.rs | 1 + .../dependencies/graph/tests/dotnet_edges.rs | 1 + .../graph/tests/extra_symbol_defensive.rs | 1 + .../graph/tests/language_frontends.rs | 41 ++++- .../src/codebase/lang_frontends/go.rs | 50 ++++-- .../src/codebase/lang_frontends/kafka.rs | 28 ++- .../src/codebase/lang_frontends/mod.rs | 4 +- .../src/codebase/lang_frontends/php.rs | 18 +- .../src/codebase/lang_frontends/python.rs | 20 ++- .../codebase/lang_frontends/python_imports.rs | 28 ++- .../src/codebase/lang_frontends/ruby.rs | 4 +- .../src/codebase/lang_frontends/rust_use.rs | 57 +++++++ .../src/codebase/lang_frontends/rustlang.rs | 12 +- .../src/codebase/lang_frontends/strip.rs | 62 +++++++ .../src/codebase/lang_frontends/tests.rs | 2 +- .../src/codebase/lang_frontends/tests_more.rs | 137 +++++++++++++++ docs/cli/queues.md | 10 +- docs/feature-parity.md | 14 +- .../go-asynq/worker/mail/user.go | 3 + .../go-asynq/worker/pkg/ping.go | 4 + .../go-asynq/worker/pkg/ping_test.go | 3 + .../go-asynq/worker/pkg/user.go | 3 + .../lang-frontends/kafka-topics/consumer.ts | 5 +- .../app/Http/Controllers/PingController.php | 11 ++ .../php-laravel/app/Jobs/FqJob.php | 7 + .../lang-frontends/php-laravel/routes/web.php | 3 +- .../python-celery-django/app/api/__init__.py | 1 + .../python-celery-django/app/api/urls.py | 6 + .../app/billing/__init__.py | 1 + .../python-celery-django/app/billing/views.py | 6 + .../python-celery-django/app/urls.py | 6 +- .../python-celery-django/app/users/views.py | 8 + .../queue-prefix/.no-mistakes.yml | 10 ++ .../app/controllers/users_controller.rb | 1 + .../rails-jobs/app/models/admin/user.rb | 2 + fixtures/lang-frontends/rust-mods/src/lib.rs | 1 + 42 files changed, 787 insertions(+), 199 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_queues.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs create mode 100644 fixtures/lang-frontends/go-asynq/worker/mail/user.go create mode 100644 fixtures/lang-frontends/go-asynq/worker/pkg/ping_test.go create mode 100644 fixtures/lang-frontends/go-asynq/worker/pkg/user.go create mode 100644 fixtures/lang-frontends/php-laravel/app/Http/Controllers/PingController.php create mode 100644 fixtures/lang-frontends/php-laravel/app/Jobs/FqJob.php create mode 100644 fixtures/lang-frontends/python-celery-django/app/api/__init__.py create mode 100644 fixtures/lang-frontends/python-celery-django/app/api/urls.py create mode 100644 fixtures/lang-frontends/python-celery-django/app/billing/__init__.py create mode 100644 fixtures/lang-frontends/python-celery-django/app/billing/views.py create mode 100644 fixtures/lang-frontends/rails-jobs/app/models/admin/user.rb 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 index b2cb2b026..275c107da 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -1,17 +1,26 @@ -fn emit_route_edges(facts: &LangFactMap, edges: &mut Vec) { +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) { + push_file_edges(edges, &file.path, targets, EdgeKind::RouteRef); + } for name in route_handler_names(handler) { if let Some(targets) = facts.declarations.get(&name) { let scoped: std::collections::BTreeSet<_> = targets .iter() .filter(|target| { - file.package.is_none() - || facts - .files - .get(*target) - .and_then(|other| other.package.as_ref()) - == file.package.as_ref() + facts.files.get(*target).is_some_and(|other| { + same_lang_package(file, other) + && handler_module_matches(handler, other) + }) }) .cloned() .collect(); @@ -22,6 +31,70 @@ fn emit_route_edges(facts: &LangFactMap, edges: &mut Vec) { } } +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 = 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 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 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('#') { @@ -30,12 +103,12 @@ fn route_handler_names(handler: &str) -> Vec { if let Some((class, _)) = trimmed.split_once("::") { return vec![class.rsplit('\\').next().unwrap_or(class).to_string()]; } - let view = trimmed - .strip_suffix("()") - .unwrap_or(trimmed.as_str()) - .strip_suffix(".as_view") - .unwrap_or(trimmed.as_str()); - vec![view.rsplit('.').next().unwrap_or(view).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 { @@ -68,65 +141,4 @@ fn snake_to_pascal(name: &str) -> String { .collect() } -fn emit_kafka_edges( - root: &Path, - all_files: &[PathBuf], - options: &GraphConfigOptions, - edges: &mut Vec, -) { - let cluster = options.queue_cluster.as_deref(); - let mut produces = Vec::new(); - let mut consumes = Vec::new(); - for path in all_files { - let rel = path.strip_prefix(root).unwrap_or(path); - let rel_s = rel.to_string_lossy(); - let enqueue = matches_any(&rel_s, &options.queue_enqueues); - let worker = matches_any(&rel_s, &options.queue_workers); - if !enqueue && !worker { - continue; - } - let Some((prod, cons)) = scan_kafka_file(path) else { - continue; - }; - if enqueue { - produces.push((path.clone(), prod)); - } - if worker { - consumes.push((path.clone(), cons)); - } - } - let mut workers: std::collections::HashMap> = - std::collections::HashMap::new(); - for (path, topics) in &consumes { - for topic in topics { - workers - .entry(topic_identity(cluster, topic)) - .or_default() - .insert(path.clone()); - } - } - for (path, topics) in produces { - for topic in topics { - let identity = topic_identity(cluster, &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/edge_lang_frontends.rs b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs index d2357c69b..742df1d8e 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -1,5 +1,6 @@ use crate::codebase::lang_frontends::{ - collect_all_lang_facts, scan_kafka_file, topic_identity, LangFactMap, LangFrontendConfig, + collect_all_lang_facts, scan_kafka_file, topic_identity, LangFactMap, LangFileFacts, + LangFrontendConfig, }; fn collect_language_frontend_edges( @@ -27,18 +28,13 @@ fn collect_language_frontend_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.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(&facts.python, &mut edges); - emit_route_edges(&facts.ruby, &mut edges); - emit_route_edges(&facts.php, &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 } @@ -79,9 +75,10 @@ fn emit_lang_edges( let scoped: std::collections::BTreeSet<_> = targets .iter() .filter(|target| { - file.package.is_none() - || facts.files.get(*target).and_then(|other| other.package.as_ref()) - == file.package.as_ref() + facts + .files + .get(*target) + .is_some_and(|other| reference_target_allowed(file, other, reference)) }) .cloned() .collect(); @@ -132,59 +129,6 @@ fn package_root_file(files: &std::collections::BTreeSet) -> Option<&Pat .map(PathBuf::as_path) } -fn emit_queue_edges( - root: &Path, - facts: &LangFactMap, - options: &GraphConfigOptions, - edges: &mut Vec, -) { - let cluster = options.queue_cluster.as_deref(); - let mut workers: std::collections::HashMap> = - std::collections::HashMap::new(); - for file in facts.files.values() { - if !file_matches_globs(root, &file.path, &options.queue_workers) { - continue; - } - for job in &file.queue_workers { - workers - .entry(topic_identity(cluster, job)) - .or_default() - .insert(file.path.clone()); - } - } - for file in facts.files.values() { - if !file_matches_globs(root, &file.path, &options.queue_enqueues) { - continue; - } - for job in &file.queue_enqueues { - let identity = topic_identity(cluster, 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 file_matches_globs(root: &Path, path: &Path, globs: &[String]) -> bool { - if globs.is_empty() { - return false; - } - let rel = path.strip_prefix(root).unwrap_or(path); - matches_any(&rel.to_string_lossy(), globs) -} - fn push_file_edges( edges: &mut Vec, source: &Path, 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..b51f3068a --- /dev/null +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_queues.rs @@ -0,0 +1,128 @@ +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( + options + .queue_glob_clusters + .get(glob) + .cloned() + .flatten() + .or_else(|| 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 433f23a34..a035f166b 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/files_config.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/files_config.rs @@ -47,6 +47,7 @@ struct GraphConfigOptions { 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, } @@ -123,20 +124,13 @@ fn graph_config_options_from_loaded_with_test_filter( 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: v2_config - .projects - .values() - .flat_map(prefixed_queue_globs_enqueues) - .collect(), - queue_workers: v2_config - .projects - .values() - .flat_map(prefixed_queue_globs_workers) - .collect(), + 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(), } @@ -150,6 +144,29 @@ fn prefixed_queue_globs_workers(project: &crate::config::v2::schema::Project) -> 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) @@ -157,12 +174,18 @@ fn prefix_project_globs(root: Option<&str>, globs: &[String]) -> Vec { globs .iter() .map(|glob| match prefix { - Some(root) if !glob.starts_with(root) => format!("{}/{glob}", root.trim_end_matches('/')), - _ => glob.clone(), + 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 b30d08b42..686d5db9f 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/mod.rs @@ -40,6 +40,7 @@ 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_helpers.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_edges_helpers.rs index 951103cf6..14dd4ccd5 100644 --- 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 @@ -82,6 +82,7 @@ fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { queue_enqueues: vec![], queue_workers: vec![], queue_cluster: None, + queue_glob_clusters: HashMap::new(), terraform: Default::default(), ci: crate::config::v2::schema::CiConfig::default(), }; @@ -110,6 +111,7 @@ fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { queue_enqueues: vec![], queue_workers: vec![], queue_cluster: None, + queue_glob_clusters: HashMap::new(), terraform: Default::default(), ci: crate::config::v2::schema::CiConfig::default(), }; @@ -160,6 +162,7 @@ fn graph_config_helpers_require_explicit_prefixes_and_valid_globs() { 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/config_fact_plan.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/config_fact_plan.rs index d09a58f71..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 @@ -49,6 +49,7 @@ fn effective_fact_plan_skips_config_dependent_domains_without_required_config() 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/dotnet_edges.rs b/crates/no-mistakes/src/codebase/dependencies/graph/tests/dotnet_edges.rs index 472c631e3..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 @@ -21,6 +21,7 @@ fn empty_options() -> GraphConfigOptions { 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 eb3ae4080..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 @@ -242,6 +242,7 @@ fn symbol_edge_helpers_cover_defensive_symbol_branches() { 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 index 7051ffa5d..f66ad7c21 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs @@ -151,5 +151,44 @@ fn language_frontend_config_keeps_already_prefixed_queue_globs() { assert!(options .queue_enqueues .iter() - .any(|glob| glob == "backend/app/**/*.py" || glob.starts_with("backend/"))); + .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())) + ); +} + +#[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("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("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/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index 1a5947a03..b2f89098e 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -1,6 +1,7 @@ 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; @@ -11,14 +12,29 @@ pub(crate) fn collect_go_facts( ) -> LangFactMap { let roots = configured_roots(root, modules); let files = files_under(all_files, &roots, "go"); - super::facts::collect_files_parallel(files, |path| parse_go_file(path, &roots, modules)) + 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]) -> Option { +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 package = owning_package(path, roots, modules); - let module = go_import_path(path, roots); + let module = if is_go_test_file(path) { + None + } else { + go_import_path(path, roots, manifests) + }; Some(LangFileFacts { path: path.to_path_buf(), package, @@ -33,19 +49,33 @@ fn parse_go_file(path: &Path, roots: &[PathBuf], modules: &[String]) -> Option Option { - let root = roots - .iter() - .filter(|candidate| path.starts_with(candidate)) - .max_by_key(|candidate| candidate.components().count())?; - let module = std::fs::read_to_string(root.join("go.mod")) +fn is_go_test_file(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with("_test.go")) +} + +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.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()) { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs index c41330d59..b7b234c1b 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs @@ -5,7 +5,7 @@ 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_named(&source, kafka_consume_re()); + let consumes = extract_kafka_consumes(&source); (produces, consumes) } @@ -38,6 +38,19 @@ fn extract_named(source: &str, re: &Regex) -> Vec { 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(|| { @@ -49,7 +62,16 @@ fn kafka_produce_re() -> &'static Regex { fn kafka_consume_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { - Regex::new(r#"subscribe\(\s*(?:\{\s*topic\s*:\s*["']([^"']+)["']|\[\s*["']([^"']+)["'])"#) - .expect("consume") + Regex::new(r#"subscribe\(\s*\{\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 index 1bc8b4af6..66681f575 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/mod.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs @@ -11,10 +11,12 @@ mod strip; mod tests; #[cfg(test)] mod tests_extra; +#[cfg(test)] +mod tests_more; use std::path::{Path, PathBuf}; -pub(crate) use facts::LangFactMap; +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; diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 2d381b28b..ab4e4a306 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -39,7 +39,7 @@ fn parse_php_file( declarations: classes, references: extract_named(&text, php_use_re()), route_handlers: if laravel { - extract_pairs(&text, laravel_route_re()) + extract_laravel_routes(&text) } else { Vec::new() }, @@ -86,12 +86,16 @@ fn extract_named(source: &str, re: &Regex) -> Vec { values } -fn extract_pairs(source: &str, re: &Regex) -> Vec<(String, String)> { - re.captures_iter(source) +fn extract_laravel_routes(source: &str) -> Vec<(String, String)> { + laravel_route_re() + .captures_iter(source) .filter_map(|cap| { Some(( cap.get(1)?.as_str().to_string(), - cap.get(2)?.as_str().replace('\\', "."), + cap.get(2) + .or_else(|| cap.get(3))? + .as_str() + .replace('\\', "."), )) }) .collect() @@ -152,7 +156,7 @@ 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*\[([^\]]+)\]"#, + r#"Route::(?:get|post|put|patch|delete)\(\s*['"]([^'"]+)['"]\s*,\s*(?:\[([^\]]+)\]|([A-Za-z_\\][A-Za-z0-9_\\]*)::class)"#, ) .expect("route") }) @@ -165,5 +169,7 @@ fn laravel_dispatch_re() -> &'static Regex { fn php_should_queue_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"\bimplements\s+ShouldQueue\b").expect("shouldqueue")) + RE.get_or_init(|| { + Regex::new(r"\bimplements\s+\\?(?:[A-Za-z_\\]+\\)*ShouldQueue\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 index 044c00a17..d8fdada7a 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -27,6 +27,7 @@ fn parse_python_file( ) -> 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() @@ -38,9 +39,9 @@ fn parse_python_file( package, module, imports, - declarations: extract_named(&text, python_decl_re()), - references: extract_named(&text, python_ref_re()), - route_handlers: extract_pairs(&text, django_route_re()), + declarations: extract_named(&symbols, python_decl_re()), + references: extract_named(&symbols, python_ref_re()), + route_handlers: extract_django_routes(&text), queue_enqueues: extract_named(&text, celery_enqueue_re()), queue_workers: extract_celery_workers(&text), mods: Vec::new(), @@ -65,12 +66,13 @@ fn extract_named(source: &str, re: &Regex) -> Vec { values } -fn extract_pairs(source: &str, re: &Regex) -> Vec<(String, String)> { - re.captures_iter(source) +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)?.as_str().to_string(), + cap.get(2).or_else(|| cap.get(3))?.as_str().to_string(), )) }) .collect() @@ -91,8 +93,10 @@ fn python_ref_re() -> &'static Regex { 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*([A-Za-z_][\w.]*)"#) - .expect("django") + Regex::new( + r#"\b(?:path|re_path)\(\s*["']([^"']+)["']\s*,\s*(?:include\(\s*["']([^"']+)["']|([A-Za-z_][\w.]*))"#, + ) + .expect("django") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs index 4d3cb737b..fd941f73d 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs @@ -8,15 +8,26 @@ pub(super) fn python_module( package_root: Option<&Path>, path: &Path, ) -> Option { - let package = package?; + let package = package_prefix(package?)?; match package_root.and_then(|root| module_from_path(root, path)) { - Some(rel) => Some(format!("{package}.{rel}")), - None => Some(package.to_string()), + 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)) +} + fn prefix_package(package: Option<&str>, module: String) -> String { - match package { + match package.and_then(|name| package_prefix(name).flatten()) { Some(package) => format!("{package}.{module}"), None => module, } @@ -139,5 +150,14 @@ mod tests { prefix_package(None, "users.models".to_string()), "users.models" ); + let pkg = Path::new("/repo"); + 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 index ccd4a3bd0..5a3a4ca32 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -100,7 +100,9 @@ fn ruby_decl_re() -> &'static Regex { 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_]*)\b").expect("const")) + 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 { 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..33af9f038 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs @@ -0,0 +1,57 @@ +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.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 index b2956e6e0..5925f065a 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs @@ -3,6 +3,8 @@ use super::facts::{ 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; @@ -56,7 +58,13 @@ fn parse_rust_file( } fn rust_imports(source: &str) -> Vec { - let mut imports = extract_named(source, rust_use_re()); + let mut imports = Vec::new(); + for cap in rust_use_re().captures_iter(source) { + let tree = cap.get(1).map(|m| m.as_str()).unwrap_or(""); + for item in rust_use::expand_rust_use(tree) { + imports.push(item.replace("::", ".")); + } + } let prefixes: Vec = imports .iter() .filter_map(|import| import.split('.').next().map(str::to_string)) @@ -87,7 +95,7 @@ fn rust_mod_re() -> &'static Regex { 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)::([\w:]+)") + Regex::new(r"(?m)^\s*(?:pub(?:\([^)]+\))?\s+)?use\s+(?:crate|super|self)::(.+?)\s*;") .expect("use") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/strip.rs b/crates/no-mistakes/src/codebase/lang_frontends/strip.rs index 0d73a012e..9bd1d96be 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/strip.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/strip.rs @@ -71,3 +71,65 @@ fn copy_quoted( } } } + +/// 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 == '\'' { + 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 index 206893cee..dc60da5cc 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests.rs @@ -44,7 +44,7 @@ fn python_collects_relative_import_celery_and_django_routes() { let urls = facts .files .values() - .find(|file| file.path.ends_with("urls.py")) + .find(|file| file.path.ends_with("app/urls.py") && !file.path.ends_with("api/urls.py")) .expect("urls"); assert!(urls .imports 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..48e731aa2 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs @@ -0,0 +1,137 @@ +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 == "api/" && handler == "app.api.urls")); + assert_eq!( + mask_strings(r#"x = "class Hidden:" """class Doc:""" 'ok'"#).contains("Hidden"), + false + ); +} + +#[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_none()); + 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()) + ); +} + +#[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/docs/cli/queues.md b/docs/cli/queues.md index de84e5dda..92847aee3 100644 --- a/docs/cli/queues.md +++ b/docs/cli/queues.md @@ -1,8 +1,12 @@ # `no-mistakes queues` -Analyze queue producer/worker relationships for BullMQ and glide-mq, and for -configured Celery, Asynq, Kafka, Active Job, and Laravel enqueue/worker -sites that emit canonical `queue-enqueue` / `queue-worker` graph edges. +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/feature-parity.md b/docs/feature-parity.md index 09ef5a075..56aa7c519 100644 --- a/docs/feature-parity.md +++ b/docs/feature-parity.md @@ -61,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. 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/ping.go b/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go index c1caffeb1..b906ac8db 100644 --- a/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go +++ b/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go @@ -1 +1,5 @@ package pkg + +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..bea115dcb --- /dev/null +++ b/fixtures/lang-frontends/go-asynq/worker/pkg/user.go @@ -0,0 +1,3 @@ +package pkg + +type User struct{} diff --git a/fixtures/lang-frontends/kafka-topics/consumer.ts b/fixtures/lang-frontends/kafka-topics/consumer.ts index c0a5dfa6b..bc1ee8147 100644 --- a/fixtures/lang-frontends/kafka-topics/consumer.ts +++ b/fixtures/lang-frontends/kafka-topics/consumer.ts @@ -1,3 +1,6 @@ -export function listen(consumer: { subscribe: (input: { topic: string }) => void }) { +export function listen(consumer: { + subscribe: (input: { topic: string } | string[]) => void; +}) { consumer.subscribe({ topic: "mail.welcome" }); + consumer.subscribe(["orders", "payments"]); } diff --git a/fixtures/lang-frontends/php-laravel/app/Http/Controllers/PingController.php b/fixtures/lang-frontends/php-laravel/app/Http/Controllers/PingController.php new file mode 100644 index 000000000..98e121579 --- /dev/null +++ b/fixtures/lang-frontends/php-laravel/app/Http/Controllers/PingController.php @@ -0,0 +1,11 @@ + Date: Sun, 16 Aug 2026 14:00:27 -0700 Subject: [PATCH 17/26] fix: scope imports, namespaced Rails, and language graph invalidation Keep namespaced Rails handlers on the qualified controller only, accept Go dot-import aliases, filter language import targets by configured package, and include Python/Go/Rust/Rails/PHP test config in shared graph semantics. --- .../codebase/dependencies/graph/edge_lang_domains.rs | 2 +- .../dependencies/graph/edge_lang_frontends.rs | 12 +++++++++++- .../dependencies/graph/tests/language_frontends.rs | 8 ++++++-- crates/no-mistakes/src/codebase/lang_frontends/go.rs | 2 +- .../src/codebase/lang_frontends/tests_more.rs | 6 ++++++ .../src/tests/config_invalidation/semantics.rs | 8 ++++++++ fixtures/lang-frontends/go-asynq/worker/pkg/dot.go | 3 +++ 7 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 fixtures/lang-frontends/go-asynq/worker/pkg/dot.go 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 index 275c107da..dc7a5082c 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -127,7 +127,7 @@ fn rails_controller_names(controller: &str) -> Vec { .map(|part| snake_to_pascal(part)) .collect::>() .join("::"); - vec![format!("{namespace}::{class}"), class] + vec![format!("{namespace}::{class}")] } fn snake_to_pascal(name: &str) -> String { 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 index 742df1d8e..09ea1ca3a 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -67,7 +67,17 @@ fn emit_lang_edges( for file in facts.files.values() { for import in &file.imports { if let Some(targets) = facts.files_by_module.get(import) { - push_file_edges(edges, &file.path, targets, import_kind); + 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, import_kind); } } for reference in &file.references { 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 index f66ad7c21..a7a525b86 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs @@ -169,12 +169,16 @@ fn language_frontend_edges_scope_routes_and_go_packages() { 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("urls.py")) + && 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("urls.py")) + && 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")) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index b2f89098e..0b862ba85 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -114,7 +114,7 @@ fn extract_named(source: &str, re: &Regex) -> Vec { 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") + Regex::new(r#"(?m)^\s*import\s+(?:(?:[_A-Za-z][\w.]*|\.)\s+)?"([^"]+)""#).expect("import") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs index 48e731aa2..3b5581c28 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs @@ -92,6 +92,12 @@ fn go_skips_test_files_and_scopes_package_modules() { .find(|file| file.path.ends_with("ping_test.go")) .expect("test"); assert!(test.module.is_none()); + 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() 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/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" From de0821866d1fcacdfe21131dc459ba4fc0e258a2 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 14:09:42 -0700 Subject: [PATCH 18/26] fix: move Python import tests out of source and satisfy clippy rust-no-inline-tests rejected the cfg(test) module inside python_imports.rs. Clippy also rejected a bool assert_eq in tests_more. --- .../src/codebase/lang_frontends/python.rs | 4 +++ .../codebase/lang_frontends/python_imports.rs | 35 +------------------ .../lang_frontends/python_imports_tests.rs | 29 +++++++++++++++ .../src/codebase/lang_frontends/tests_more.rs | 5 +-- 4 files changed, 35 insertions(+), 38 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/python_imports_tests.rs diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python.rs b/crates/no-mistakes/src/codebase/lang_frontends/python.rs index d8fdada7a..a296f067b 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -3,6 +3,10 @@ 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; diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs index fd941f73d..e8c09d6de 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs @@ -26,7 +26,7 @@ fn package_prefix(package: &str) -> Option> { Some(Some(trimmed)) } -fn prefix_package(package: Option<&str>, module: String) -> String { +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, @@ -128,36 +128,3 @@ fn python_from_re() -> &'static Regex { .expect("from") }) } - -#[cfg(test)] -mod tests { - use super::*; - 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 ...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")); - assert_eq!(python_module(None, None, path), None); - assert_eq!( - prefix_package(None, "users.models".to_string()), - "users.models" - ); - let pkg = Path::new("/repo"); - 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/python_imports_tests.rs b/crates/no-mistakes/src/codebase/lang_frontends/python_imports_tests.rs new file mode 100644 index 000000000..8d1bd52b6 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports_tests.rs @@ -0,0 +1,29 @@ +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 ...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")); + assert_eq!(python_module(None, None, path), None); + assert_eq!( + prefix_package(None, "users.models".to_string()), + "users.models" + ); + let pkg = Path::new("/repo"); + 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/tests_more.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs index 3b5581c28..6fbcb376b 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs @@ -48,10 +48,7 @@ fn python_masks_docstring_symbols_and_keeps_include_routes() { .route_handlers .iter() .any(|(route, handler)| route == "api/" && handler == "app.api.urls")); - assert_eq!( - mask_strings(r#"x = "class Hidden:" """class Doc:""" 'ok'"#).contains("Hidden"), - false - ); + assert!(!mask_strings(r#"x = "class Hidden:" """class Doc:""" 'ok'"#).contains("Hidden")); } #[test] From 1406469c815a2a5c5ddda06e4d26a76c48ca5876 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 14:18:28 -0700 Subject: [PATCH 19/26] fix: more language extractor edge cases from review Accept empty Django paths, keep Go test-file package scope without indexing them as import targets, parse grouped Go const/var/type decls, strip Rust use aliases, match ShouldQueue anywhere in the interface list, expand parenthesized Python imports, qualify default Celery task names, resolve Rails :: constants in-app, and require Kafka .send({ topic }) object form. --- .../dependencies/graph/edge_lang_domains.rs | 3 ++ .../src/codebase/lang_frontends/facts.rs | 15 +++++-- .../src/codebase/lang_frontends/go.rs | 37 ++++++++++++------ .../src/codebase/lang_frontends/kafka.rs | 3 +- .../src/codebase/lang_frontends/php.rs | 4 +- .../src/codebase/lang_frontends/python.rs | 39 ++++++++++++++++--- .../codebase/lang_frontends/python_imports.rs | 9 ++++- .../lang_frontends/python_imports_tests.rs | 9 ++++- .../src/codebase/lang_frontends/rust_use.rs | 5 +++ .../src/codebase/lang_frontends/tests_more.rs | 14 ++++++- .../go-asynq/worker/pkg/user.go | 4 ++ .../php-laravel/app/Jobs/FqJob.php | 2 +- .../python-celery-django/app/urls.py | 1 + fixtures/lang-frontends/rust-mods/src/lib.rs | 2 +- 14 files changed, 115 insertions(+), 32 deletions(-) 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 index dc7a5082c..71f481cc9 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -72,6 +72,9 @@ fn reference_target_allowed(file: &LangFileFacts, target: &LangFileFacts, refere 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() diff --git a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs index 5c9db2134..b1c02472a 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs @@ -32,10 +32,17 @@ impl LangFactMap { .insert(file.path.clone()); } if let Some(module) = &file.module { - self.files_by_module - .entry(module.clone()) - .or_default() - .insert(file.path.clone()); + 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 diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index 0b862ba85..357abcb81 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -30,17 +30,13 @@ fn parse_go_file( let source = std::fs::read_to_string(path).ok()?; let text = strip_comments_keep_strings(&source); let package = owning_package(path, roots, modules); - let module = if is_go_test_file(path) { - None - } else { - go_import_path(path, roots, manifests) - }; + let module = go_import_path(path, roots, manifests); Some(LangFileFacts { path: path.to_path_buf(), package, module, imports: extract_go_imports(&text), - declarations: extract_named(&text, go_decl_re()), + declarations: extract_go_declarations(&text), references: extract_named(&text, go_ref_re()), route_handlers: Vec::new(), queue_enqueues: extract_named(&text, asynq_task_re()), @@ -49,12 +45,6 @@ fn parse_go_file( }) } -fn is_go_test_file(path: &Path) -> bool { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.ends_with("_test.go")) -} - fn read_go_module(root: &Path) -> Option { std::fs::read_to_string(root.join("go.mod")) .ok() @@ -101,6 +91,19 @@ fn extract_go_imports(source: &str) -> Vec { 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) @@ -138,6 +141,16 @@ fn go_decl_re() -> &'static Regex { }) } +fn go_group_decl_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?s)(?:const|var|type)\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")) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs index b7b234c1b..175e0d4b7 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs @@ -54,8 +54,7 @@ fn extract_kafka_consumes(source: &str) -> Vec { 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") + Regex::new(r#"\.send\(\s*\{[^}]*topic\s*:\s*["']([^"']+)["']"#).expect("produce") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index ab4e4a306..223d18e53 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -169,7 +169,5 @@ fn laravel_dispatch_re() -> &'static Regex { fn php_should_queue_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new(r"\bimplements\s+\\?(?:[A-Za-z_\\]+\\)*ShouldQueue\b").expect("shouldqueue") - }) + 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 index a296f067b..9e32a1c05 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -38,6 +38,8 @@ fn parse_python_file( .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, @@ -46,15 +48,42 @@ fn parse_python_file( declarations: extract_named(&symbols, python_decl_re()), references: extract_named(&symbols, python_ref_re()), route_handlers: extract_django_routes(&text), - queue_enqueues: extract_named(&text, celery_enqueue_re()), - queue_workers: extract_celery_workers(&text), + queue_enqueues, + queue_workers, mods: Vec::new(), }) } -fn extract_celery_workers(source: &str) -> Vec { +fn extract_celery_workers(source: &str, module: Option<&str>) -> Vec { let mut names = extract_named(source, celery_named_task_re()); - names.extend(extract_named(source, celery_fn_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| { + if import == name || import.ends_with(&format!(".{name}")) { + Some(import.clone()) + } else if !import.contains('.') && import == name { + None + } else { + Some(format!("{import}.{name}")) + } + }) + }) + .collect(); + names.extend(extras); names.sort(); names.dedup(); names @@ -98,7 +127,7 @@ 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.]*))"#, + r#"\b(?:path|re_path)\(\s*["']([^"']*)["']\s*,\s*(?:include\(\s*["']([^"']+)["']|([A-Za-z_][\w.]*))"#, ) .expect("django") }) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs index e8c09d6de..d1ee74bae 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs @@ -75,6 +75,9 @@ pub(super) fn extract_python_imports( fn imported_names(names: &str) -> Vec { names + .trim() + .trim_start_matches('(') + .trim_end_matches(')') .split(',') .filter_map(|part| { let ident = part.split_whitespace().next()?; @@ -124,7 +127,9 @@ fn python_import_re() -> &'static Regex { fn python_from_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { - Regex::new(r"(?m)^\s*from\s+(\.+(?:[A-Za-z_][\w.]*)?|[A-Za-z_][\w.]*)\s+import\s+([^\n]+)") - .expect("from") + 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 index 8d1bd52b6..1ba65e7df 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python_imports_tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports_tests.rs @@ -5,7 +5,7 @@ use std::path::Path; 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 ...outside import nope\nfrom app.mod import helper", + "import app.tasks, app.models\nfrom . import *\nfrom . import (\n helper,\n)\nfrom ...outside import nope\nfrom app.mod import helper", path, None, None, @@ -18,6 +18,13 @@ fn extract_python_imports_covers_unprefixed_and_star_forms() { "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") diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs b/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs index 33af9f038..07108b370 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs @@ -10,6 +10,11 @@ pub(super) fn expand_rust_use(tree: &str) -> Vec { 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() { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs index 6fbcb376b..d99b37084 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_more.rs @@ -44,6 +44,10 @@ fn python_masks_docstring_symbols_and_keeps_include_routes() { .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() @@ -88,7 +92,11 @@ fn go_skips_test_files_and_scopes_package_modules() { .values() .find(|file| file.path.ends_with("ping_test.go")) .expect("test"); - assert!(test.module.is_none()); + 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() @@ -108,6 +116,10 @@ fn go_skips_test_files_and_scopes_package_modules() { .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] diff --git a/fixtures/lang-frontends/go-asynq/worker/pkg/user.go b/fixtures/lang-frontends/go-asynq/worker/pkg/user.go index bea115dcb..41863ac80 100644 --- a/fixtures/lang-frontends/go-asynq/worker/pkg/user.go +++ b/fixtures/lang-frontends/go-asynq/worker/pkg/user.go @@ -1,3 +1,7 @@ package pkg type User struct{} + +const ( + DefaultTimeout = 30 +) diff --git a/fixtures/lang-frontends/php-laravel/app/Jobs/FqJob.php b/fixtures/lang-frontends/php-laravel/app/Jobs/FqJob.php index c1280fd67..9a48e778d 100644 --- a/fixtures/lang-frontends/php-laravel/app/Jobs/FqJob.php +++ b/fixtures/lang-frontends/php-laravel/app/Jobs/FqJob.php @@ -2,6 +2,6 @@ namespace App\Jobs; -class FqJob implements \Illuminate\Contracts\Queue\ShouldQueue +class FqJob implements ShouldBeUnique, \Illuminate\Contracts\Queue\ShouldQueue { } diff --git a/fixtures/lang-frontends/python-celery-django/app/urls.py b/fixtures/lang-frontends/python-celery-django/app/urls.py index 2aa622855..343e5e784 100644 --- a/fixtures/lang-frontends/python-celery-django/app/urls.py +++ b/fixtures/lang-frontends/python-celery-django/app/urls.py @@ -3,6 +3,7 @@ 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()), diff --git a/fixtures/lang-frontends/rust-mods/src/lib.rs b/fixtures/lang-frontends/rust-mods/src/lib.rs index 39bbb3c23..94648302f 100644 --- a/fixtures/lang-frontends/rust-mods/src/lib.rs +++ b/fixtures/lang-frontends/rust-mods/src/lib.rs @@ -1,7 +1,7 @@ pub mod aaa; pub mod mail; -use crate::{aaa, mail}; +use crate::{aaa, mail as delivery}; pub use crate::mail::Welcome; pub fn send() { From b460e8df202cc4484bd7238f2b15899e836ea391 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 14:26:09 -0700 Subject: [PATCH 20/26] fix: resolve relative Rust uses and remaining extractor nits Qualify self/super Rust uses from the current module, mask Go string literals for symbols, keep namespaced Active Job classes, attach relative Python imported names, index every PHP class as a module target, and accept private-field Kafka #send({ topic }) calls. --- .../src/codebase/lang_frontends/facts.rs | 6 +++++ .../src/codebase/lang_frontends/go.rs | 5 +++-- .../src/codebase/lang_frontends/kafka.rs | 2 +- .../codebase/lang_frontends/python_imports.rs | 5 ++++- .../src/codebase/lang_frontends/ruby.rs | 2 +- .../src/codebase/lang_frontends/rust_use.rs | 14 ++++++++++++ .../src/codebase/lang_frontends/rustlang.rs | 22 ++++++++++++------- 7 files changed, 43 insertions(+), 13 deletions(-) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs index b1c02472a..c177d2372 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs @@ -49,6 +49,12 @@ impl LangFactMap { .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); } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index 357abcb81..454adefe2 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -29,6 +29,7 @@ fn parse_go_file( ) -> 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 { @@ -36,8 +37,8 @@ fn parse_go_file( package, module, imports: extract_go_imports(&text), - declarations: extract_go_declarations(&text), - references: extract_named(&text, go_ref_re()), + 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()), diff --git a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs index 175e0d4b7..f92dca358 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs @@ -54,7 +54,7 @@ fn extract_kafka_consumes(source: &str) -> Vec { 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") + Regex::new(r#"(?:\.|#)send\(\s*\{[^}]*topic\s*:\s*["']([^"']+)["']"#).expect("produce") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs index d1ee74bae..f1fea4b84 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs @@ -59,7 +59,10 @@ pub(super) fn extract_python_imports( imports.push(format!("{resolved}.{name}")); } } else { - imports.push(resolved); + imports.push(resolved.clone()); + for name in imported_names(names) { + imports.push(format!("{resolved}.{name}")); + } } } else if !module.starts_with('.') { imports.push(module.to_string()); diff --git a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs index 5a3a4ca32..0395a7508 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -121,6 +121,6 @@ fn active_job_re() -> &'static Regex { 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][A-Za-z0-9_]*)\s*<\s*ApplicationJob").expect("job class") + 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 index 07108b370..603c77a04 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs @@ -1,3 +1,17 @@ +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 { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs index 5925f065a..0381c7e01 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs @@ -41,13 +41,14 @@ fn parse_rust_file( 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: src_root - .as_ref() - .and_then(|pkg| rust_module_from_path(pkg, path)), - imports: rust_imports(&text), + 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(), @@ -57,12 +58,17 @@ fn parse_rust_file( }) } -fn rust_imports(source: &str) -> Vec { +fn rust_imports(source: &str, module: Option<&str>) -> Vec { let mut imports = Vec::new(); for cap in rust_use_re().captures_iter(source) { - let tree = cap.get(1).map(|m| m.as_str()).unwrap_or(""); + 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(item.replace("::", ".")); + imports.push(rust_use::qualify_rust_use( + kind, + &item.replace("::", "."), + module, + )); } } let prefixes: Vec = imports @@ -95,7 +101,7 @@ fn rust_mod_re() -> &'static Regex { 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*;") + Regex::new(r"(?m)^\s*(?:pub(?:\([^)]+\))?\s+)?use\s+(crate|super|self)::(.+?)\s*;") .expect("use") }) } From 81409350f6dbd4e6cafa8459d2a965a429b55b0e Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 14:38:07 -0700 Subject: [PATCH 21/26] test: cover language relationship sort keys and build-plan flags Exercise every new language EdgeKind in sort_key, from_allowed, and RelationshipArg::as_str so CRAP stays under the CI threshold. --- .../dependencies/graph/tests/route_import.rs | 20 +++++++++++++++++++ .../graph/types_edges_sort/tests.rs | 11 ++++++++++ .../src/codebase/dependencies/tests/args.rs | 17 ++++++++++++++++ 3 files changed, 48 insertions(+) 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_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/tests/args.rs b/crates/no-mistakes/src/codebase/dependencies/tests/args.rs index 7f7629847..8d69c376d 100644 --- a/crates/no-mistakes/src/codebase/dependencies/tests/args.rs +++ b/crates/no-mistakes/src/codebase/dependencies/tests/args.rs @@ -232,6 +232,23 @@ fn python_relationship_enables_language_frontend_plan() { 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] From a5f2104f457bbac6c29884817bc62b9629eb30e6 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 14:50:22 -0700 Subject: [PATCH 22/26] fix: extract language-frontend build-plan flag to cut CRAP from_allowed's 11-way language OR chain was above the CRAP 30 gate. The flag now comes from a small any() helper exercised by existing from_allowed tests. --- .../codebase/dependencies/graph/build_plan.rs | 12 +----------- .../dependencies/graph/build_plan_facts.rs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 11 deletions(-) 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 858565d15..2039b55c5 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan.rs @@ -127,17 +127,7 @@ impl GraphBuildPlan { terraform: allowed.contains(&EdgeKind::TerraformReference) || allowed.contains(&EdgeKind::TerraformModuleRef) || allowed.contains(&EdgeKind::TerraformOutputRef), - language_frontends: allowed.contains(&EdgeKind::PythonImport) - || allowed.contains(&EdgeKind::PythonReference) - || allowed.contains(&EdgeKind::GoImport) - || allowed.contains(&EdgeKind::GoReference) - || allowed.contains(&EdgeKind::RustUse) - || allowed.contains(&EdgeKind::RustMod) - || allowed.contains(&EdgeKind::RustPackage) - || allowed.contains(&EdgeKind::RubyRequire) - || allowed.contains(&EdgeKind::RubyReference) - || allowed.contains(&EdgeKind::PhpUse) - || allowed.contains(&EdgeKind::PhpPackage), + language_frontends: allowed_requests_language_frontends(allowed), } } 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 index 849ce3058..bdf0c73f1 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/build_plan_facts.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/build_plan_facts.rs @@ -1,3 +1,21 @@ +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 From 43a8dd35bc2f6dc0bff72f51b9a3126b9fef1bc0 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 15:05:08 -0700 Subject: [PATCH 23/26] fix: resolve framework import aliases and tighten enqueue matching Map Python/PHP \`as\` aliases onto route handlers, keep namespaced Active Job enqueue identities, qualify Celery enqueues only from real imports, and parse only top-level Go const/var/type groups. --- .../dependencies/graph/edge_lang_domains.rs | 39 +++++++++++++++++-- .../src/codebase/lang_frontends/go.rs | 4 +- .../src/codebase/lang_frontends/php.rs | 27 ++++++++++++- .../src/codebase/lang_frontends/python.rs | 12 +++--- .../codebase/lang_frontends/python_imports.rs | 34 ++++++++++------ .../src/codebase/lang_frontends/ruby.rs | 2 +- 6 files changed, 95 insertions(+), 23 deletions(-) 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 index 71f481cc9..5db39b194 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -12,7 +12,10 @@ fn emit_route_edges( if let Some(targets) = facts.files_by_module.get(handler) { push_file_edges(edges, &file.path, targets, EdgeKind::RouteRef); } - for name in route_handler_names(handler) { + 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() @@ -44,7 +47,7 @@ fn same_lang_package(file: &LangFileFacts, other: &LangFileFacts) -> bool { } fn handler_module_matches(handler: &str, file: &LangFileFacts) -> bool { - let view = normalize_route_handler(handler); + let view = remap_aliased_handler(file, &normalize_route_handler(handler)); if !view.contains('.') || view.contains("::") || view.contains('#') || view.contains('/') { return true; } @@ -58,6 +61,35 @@ fn handler_module_matches(handler: &str, file: &LangFileFacts) -> bool { .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 @@ -104,7 +136,8 @@ fn route_handler_names(handler: &str) -> Vec { return rails_controller_names(controller); } if let Some((class, _)) = trimmed.split_once("::") { - return vec![class.rsplit('\\').next().unwrap_or(class).to_string()]; + 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()]; diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index 454adefe2..2885b130e 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -144,7 +144,9 @@ fn go_decl_re() -> &'static Regex { fn go_group_decl_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"(?s)(?:const|var|type)\s*\((.*?)\)").expect("group decl")) + RE.get_or_init(|| { + Regex::new(r"(?m)^\s*(?:const|var|type)\s*\((?s)(.*?)\)").expect("group decl") + }) } fn go_exported_ident_re() -> &'static Regex { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 223d18e53..881c4273e 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -108,6 +108,15 @@ fn php_namespace_re() -> &'static Regex { 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) @@ -121,7 +130,15 @@ fn extract_php_uses(source: &str) -> Vec { .unwrap_or("") .trim(); if !ident.is_empty() { - imports.push(format!("{prefix}.{ident}")); + 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}")); + } } } } @@ -137,6 +154,14 @@ fn php_group_use_re() -> &'static Regex { }) } +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")) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python.rs b/crates/no-mistakes/src/codebase/lang_frontends/python.rs index 9e32a1c05..1bf03f48c 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python.rs @@ -73,12 +73,14 @@ fn extract_celery_enqueues(source: &str, imports: &[String]) -> Vec { .iter() .flat_map(|name| { imports.iter().filter_map(move |import| { - if import == name || import.ends_with(&format!(".{name}")) { - Some(import.clone()) - } else if !import.contains('.') && import == name { - None + let target = import + .split_once('=') + .map(|(_, target)| target) + .unwrap_or(import); + if target == name || target.ends_with(&format!(".{name}")) { + Some(target.to_string()) } else { - Some(format!("{import}.{name}")) + None } }) }) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs index f1fea4b84..13d2bf3f7 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs @@ -55,20 +55,14 @@ pub(super) fn extract_python_imports( if let Some(resolved) = resolve_relative(module, path, package_root) { let resolved = prefix_package(package, resolved); if module.chars().all(|ch| ch == '.') { - for name in imported_names(names) { - imports.push(format!("{resolved}.{name}")); - } + push_imported_members(&mut imports, &resolved, names); } else { imports.push(resolved.clone()); - for name in imported_names(names) { - imports.push(format!("{resolved}.{name}")); - } + push_imported_members(&mut imports, &resolved, names); } } else if !module.starts_with('.') { imports.push(module.to_string()); - for name in imported_names(names) { - imports.push(format!("{module}.{name}")); - } + push_imported_members(&mut imports, module, names); } } imports.sort(); @@ -76,18 +70,34 @@ pub(super) fn extract_python_imports( imports } -fn imported_names(names: &str) -> Vec { +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 ident = part.split_whitespace().next()?; + let mut tokens = part.split_whitespace(); + let ident = tokens.next()?; if ident.is_empty() || ident.starts_with('(') || ident == "*" { return None; } - Some(ident.to_string()) + 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() } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs index 0395a7508..6c246d8a5 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -115,7 +115,7 @@ fn rails_route_re() -> &'static Regex { fn active_job_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"\b([A-Z][A-Za-z0-9_]*)\.perform_later\b").expect("job")) + RE.get_or_init(|| Regex::new(r"\b([A-Z][\w:]*)\.perform_later\b").expect("job")) } fn ruby_job_class_re() -> &'static Regex { From ce69ab88a3334b3445cc651ade64b7ddac6aecf7 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 15:25:23 -0700 Subject: [PATCH 24/26] fix: prune nested Go modules, normalize Ruby requires, qualify Laravel jobs Skip .go files under an unconfigured inner go.mod, normalize require_relative paths before module lookup, and use namespace-qualified Laravel ShouldQueue / ::dispatch identities. --- .../src/codebase/lang_frontends/go.rs | 32 ++++++- .../src/codebase/lang_frontends/mod.rs | 2 + .../src/codebase/lang_frontends/php.rs | 31 +++---- .../src/codebase/lang_frontends/php_queue.rs | 69 ++++++++++++++ .../src/codebase/lang_frontends/ruby.rs | 4 +- .../src/codebase/lang_frontends/tests_p2.rs | 92 +++++++++++++++++++ 6 files changed, 209 insertions(+), 21 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/php_queue.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/tests_p2.rs diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index 2885b130e..2bbd86dee 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -11,7 +11,7 @@ pub(crate) fn collect_go_facts( modules: &[String], ) -> LangFactMap { let roots = configured_roots(root, modules); - let files = files_under(all_files, &roots, "go"); + 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))) @@ -46,6 +46,36 @@ fn parse_go_file( }) } +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() diff --git a/crates/no-mistakes/src/codebase/lang_frontends/mod.rs b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs index 66681f575..32a551e65 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/mod.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs @@ -13,6 +13,8 @@ mod tests; mod tests_extra; #[cfg(test)] mod tests_more; +#[cfg(test)] +mod tests_p2; use std::path::{Path, PathBuf}; diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 881c4273e..41f48ba6c 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -1,5 +1,7 @@ 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; @@ -31,6 +33,11 @@ fn parse_php_file( 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), @@ -44,15 +51,11 @@ fn parse_php_file( Vec::new() }, queue_enqueues: if laravel { - extract_named(&text, laravel_dispatch_re()) - } else { - Vec::new() - }, - queue_workers: if laravel && php_should_queue_re().is_match(&text) { - extract_named(&text, php_class_re()) + queue::extract_laravel_dispatches(&text) } else { Vec::new() }, + queue_workers, mods: Vec::new(), }) } @@ -76,7 +79,7 @@ fn php_classes(source: &str) -> Vec { names } -fn extract_named(source: &str, re: &Regex) -> Vec { +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('\\', "."))) @@ -101,12 +104,12 @@ fn extract_laravel_routes(source: &str) -> Vec<(String, String)> { .collect() } -fn php_namespace_re() -> &'static Regex { +pub(super) 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")) } -fn extract_php_uses(source: &str) -> Vec { +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 @@ -186,13 +189,3 @@ fn laravel_route_re() -> &'static Regex { .expect("route") }) } - -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")) -} - -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/php_queue.rs b/crates/no-mistakes/src/codebase/lang_frontends/php_queue.rs new file mode 100644 index 000000000..d0dac665c --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/php_queue.rs @@ -0,0 +1,69 @@ +use super::{extract_named, extract_php_uses, php_namespace_re}; +use regex::Regex; +use std::sync::OnceLock; + +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/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs index 6c246d8a5..fe59dc43e 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -48,7 +48,9 @@ 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 = parent.join(rel).with_extension("rb"); + 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); } 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..b7c322b23 --- /dev/null +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_p2.rs @@ -0,0 +1,92 @@ +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")); +} From 66f2851430fa75ac407185edc13c58fc0a7202c1 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 16 Aug 2026 16:13:23 -0700 Subject: [PATCH 25/26] fix: scope language edges and close remaining extractor gaps Keep intermediate Rust use prefixes, filter Rust-mod and route targets by configured package, mask Go raw strings, strip go.mod comments, honor an explicit empty queue cluster, extract PHP require/include stems, and qualify nested Ruby module/class declarations. --- .../dependencies/graph/edge_lang_domains.rs | 12 ++++- .../dependencies/graph/edge_lang_frontends.rs | 12 ++++- .../dependencies/graph/edge_lang_queues.rs | 12 ++--- .../graph/tests/language_frontends.rs | 1 + .../src/codebase/lang_frontends/go.rs | 2 +- .../src/codebase/lang_frontends/php.rs | 13 +++-- .../src/codebase/lang_frontends/php_queue.rs | 22 ++++++++ .../src/codebase/lang_frontends/ruby.rs | 37 +++++++++++++- .../src/codebase/lang_frontends/rust_use.rs | 5 ++ .../src/codebase/lang_frontends/rustlang.rs | 2 +- .../src/codebase/lang_frontends/strip.rs | 2 +- .../codebase/lang_frontends/tests_extra.rs | 1 + .../src/codebase/lang_frontends/tests_p2.rs | 51 +++++++++++++++++++ .../lang-frontends/go-asynq/nested/go.mod | 2 +- .../go-asynq/worker/pkg/ping.go | 2 + .../php-laravel/app/helpers.php | 6 +++ .../lang-frontends/php-laravel/routes/web.php | 1 + .../queue-prefix/.no-mistakes.yml | 8 +++ .../rails-jobs/app/services/admin.rb | 4 ++ .../rust-mods/src/aaa/helper.rs | 1 + .../lang-frontends/rust-mods/src/aaa/mod.rs | 2 + fixtures/lang-frontends/rust-mods/src/lib.rs | 1 + 22 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 fixtures/lang-frontends/php-laravel/app/helpers.php create mode 100644 fixtures/lang-frontends/rails-jobs/app/services/admin.rb create mode 100644 fixtures/lang-frontends/rust-mods/src/aaa/helper.rs 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 index 5db39b194..64ed1ed13 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_domains.rs @@ -10,7 +10,17 @@ fn emit_route_edges( } for (_, handler) in &file.route_handlers { if let Some(targets) = facts.files_by_module.get(handler) { - push_file_edges(edges, &file.path, targets, EdgeKind::RouteRef); + 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() 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 index 09ea1ca3a..a466c997b 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -110,7 +110,17 @@ fn emit_mod_edges(facts: &LangFactMap, kind: EdgeKind, edges: &mut Vec) { .get(&qualified) .or_else(|| facts.files_by_module.get(name)); if let Some(targets) = targets { - push_file_edges(edges, &file.path, targets, kind); + 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); } } } 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 index b51f3068a..78cf0a612 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_queues.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_queues.rs @@ -56,14 +56,10 @@ fn matching_queue_cluster( if !matches_any(&rel_s, std::slice::from_ref(glob)) { return None; } - Some( - options - .queue_glob_clusters - .get(glob) - .cloned() - .flatten() - .or_else(|| options.queue_cluster.clone()), - ) + Some(match options.queue_glob_clusters.get(glob) { + Some(cluster) => cluster.clone(), + None => options.queue_cluster.clone(), + }) }) } 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 index a7a525b86..5f38b7ace 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends.rs @@ -160,6 +160,7 @@ fn language_frontend_config_keeps_already_prefixed_queue_globs() { options.queue_glob_clusters.get("app/application/**/*.py"), Some(&Some("other".into())) ); + assert_eq!(options.queue_glob_clusters.get("bare/*.py"), Some(&None)); } #[test] diff --git a/crates/no-mistakes/src/codebase/lang_frontends/go.rs b/crates/no-mistakes/src/codebase/lang_frontends/go.rs index 2bbd86dee..f92b7be70 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/go.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/go.rs @@ -82,7 +82,7 @@ fn read_go_module(root: &Path) -> Option { .and_then(|source| { source.lines().find_map(|line| { line.strip_prefix("module ") - .map(|value| value.trim().to_string()) + .map(|value| value.split("//").next().unwrap_or(value).trim().to_string()) }) }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 41f48ba6c..81aa0bacd 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -41,8 +41,15 @@ fn parse_php_file( Some(LangFileFacts { path: path.to_path_buf(), package: owning_package(path, roots, apps), - module: classes.first().cloned(), - imports: extract_php_uses(&text), + 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 { @@ -104,7 +111,7 @@ fn extract_laravel_routes(source: &str) -> Vec<(String, String)> { .collect() } -pub(super) fn php_namespace_re() -> &'static Regex { +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")) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php_queue.rs b/crates/no-mistakes/src/codebase/lang_frontends/php_queue.rs index d0dac665c..53e761fb6 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php_queue.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php_queue.rs @@ -1,7 +1,29 @@ 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() diff --git a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs index fe59dc43e..bad0d7497 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/ruby.rs @@ -25,7 +25,7 @@ fn parse_ruby_file(path: &Path, roots: &[PathBuf], apps: &[String]) -> Option &'static Regex { 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")) diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs b/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs index 603c77a04..df8a23fb8 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rust_use.rs @@ -1,3 +1,8 @@ +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 { diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs index 0381c7e01..ed48e2570 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs @@ -73,7 +73,7 @@ fn rust_imports(source: &str, module: Option<&str>) -> Vec { } let prefixes: Vec = imports .iter() - .filter_map(|import| import.split('.').next().map(str::to_string)) + .flat_map(|import| rust_use::rust_path_prefixes(import)) .collect(); imports.extend(prefixes); imports.sort(); diff --git a/crates/no-mistakes/src/codebase/lang_frontends/strip.rs b/crates/no-mistakes/src/codebase/lang_frontends/strip.rs index 9bd1d96be..8baf7835f 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/strip.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/strip.rs @@ -79,7 +79,7 @@ pub(crate) fn mask_strings(source: &str) -> String { let mut i = 0; while i < chars.len() { let ch = chars[i]; - if ch == '"' || ch == '\'' { + if ch == '"' || ch == '\'' || ch == '`' { i = mask_quoted(&chars, &mut out, i, ch); continue; } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs index 24181f9c8..286bcd8ab 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_extra.rs @@ -74,6 +74,7 @@ fn strip_and_kafka_identity_cover_comment_and_empty_cluster_paths() { ); 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"); } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/tests_p2.rs b/crates/no-mistakes/src/codebase/lang_frontends/tests_p2.rs index b7c322b23..d5e168861 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/tests_p2.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/tests_p2.rs @@ -90,3 +90,54 @@ fn php_queue_identities_are_namespace_qualified() { .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/fixtures/lang-frontends/go-asynq/nested/go.mod b/fixtures/lang-frontends/go-asynq/nested/go.mod index 7adda901a..ac2715a69 100644 --- a/fixtures/lang-frontends/go-asynq/nested/go.mod +++ b/fixtures/lang-frontends/go-asynq/nested/go.mod @@ -1,3 +1,3 @@ -module example.com/nested +module example.com/nested // isolated nested module go 1.22 diff --git a/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go b/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go index b906ac8db..1e9613166 100644 --- a/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go +++ b/fixtures/lang-frontends/go-asynq/worker/pkg/ping.go @@ -1,5 +1,7 @@ package pkg +const doc = `LegacyUser` + func Ping() User { return User{} } diff --git a/fixtures/lang-frontends/php-laravel/app/helpers.php b/fixtures/lang-frontends/php-laravel/app/helpers.php new file mode 100644 index 000000000..d31523c23 --- /dev/null +++ b/fixtures/lang-frontends/php-laravel/app/helpers.php @@ -0,0 +1,6 @@ + Date: Sun, 16 Aug 2026 16:22:17 -0700 Subject: [PATCH 26/26] fix: close remaining language extractor review gaps Keep crate-root self uses root-relative, skip inline Rust mods, emit Go imports across configured modules, recognize readonly PHP classes and leading Laravel route separators, and extract Python Kafka send plus import aliases without docstring false positives. --- .../dependencies/graph/edge_lang_frontends.rs | 9 +- .../graph/tests/language_frontends_more.rs | 14 ++ .../codebase/dependencies/graph/tests/mod.rs | 1 + .../src/codebase/lang_frontends/facts.rs | 3 + .../src/codebase/lang_frontends/kafka.rs | 5 +- .../src/codebase/lang_frontends/mod.rs | 2 + .../src/codebase/lang_frontends/php.rs | 12 +- .../codebase/lang_frontends/python_imports.rs | 16 ++- .../lang_frontends/python_imports_tests.rs | 4 + .../src/codebase/lang_frontends/rustlang.rs | 2 +- .../src/codebase/lang_frontends/tests_p3.rs | 123 ++++++++++++++++++ .../lang-frontends/go-asynq/worker/enqueue.go | 1 + .../lang-frontends/kafka-topics/consumer.ts | 2 +- .../lang-frontends/kafka-topics/producer.py | 2 + .../php-laravel/app/Dto/UserDto.php | 7 + .../lang-frontends/php-laravel/routes/web.php | 1 + .../python-celery-django/app/enqueue.py | 2 +- .../python-celery-django/app/users/views.py | 1 + fixtures/lang-frontends/rust-mods/src/lib.rs | 1 + fixtures/lang-frontends/rust-mods/src/mail.rs | 4 + .../lang-frontends/rust-mods/src/unused.rs | 1 + 21 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 crates/no-mistakes/src/codebase/dependencies/graph/tests/language_frontends_more.rs create mode 100644 crates/no-mistakes/src/codebase/lang_frontends/tests_p3.rs create mode 100644 fixtures/lang-frontends/kafka-topics/producer.py create mode 100644 fixtures/lang-frontends/php-laravel/app/Dto/UserDto.php create mode 100644 fixtures/lang-frontends/rust-mods/src/unused.rs 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 index a466c997b..389704abe 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/edge_lang_frontends.rs @@ -70,10 +70,11 @@ fn emit_lang_edges( let scoped: std::collections::BTreeSet<_> = targets .iter() .filter(|target| { - facts - .files - .get(*target) - .is_some_and(|other| same_lang_package(file, other)) + import_kind == EdgeKind::GoImport + || facts + .files + .get(*target) + .is_some_and(|other| same_lang_package(file, other)) }) .cloned() .collect(); 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 2b478f2e7..2316e29a1 100644 --- a/crates/no-mistakes/src/codebase/dependencies/graph/tests/mod.rs +++ b/crates/no-mistakes/src/codebase/dependencies/graph/tests/mod.rs @@ -64,6 +64,7 @@ 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/lang_frontends/facts.rs b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs index c177d2372..f57975e0d 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/facts.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/facts.rs @@ -128,6 +128,9 @@ fn module_from_path_inner(package_root: &Path, path: &Path, rust: bool) -> Optio 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; } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs index f92dca358..1cdd5c1c8 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/kafka.rs @@ -54,14 +54,15 @@ fn extract_kafka_consumes(source: &str) -> Vec { 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") + 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*\{\s*topic\s*:\s*["']([^"']+)["']"#).expect("consume") + Regex::new(r#"subscribe\(\s*\{[^}]*topic\s*:\s*["']([^"']+)["']"#).expect("consume") }) } diff --git a/crates/no-mistakes/src/codebase/lang_frontends/mod.rs b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs index 32a551e65..7b601f533 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/mod.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/mod.rs @@ -15,6 +15,8 @@ mod tests_extra; mod tests_more; #[cfg(test)] mod tests_p2; +#[cfg(test)] +mod tests_p3; use std::path::{Path, PathBuf}; diff --git a/crates/no-mistakes/src/codebase/lang_frontends/php.rs b/crates/no-mistakes/src/codebase/lang_frontends/php.rs index 81aa0bacd..30b6f5be3 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/php.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/php.rs @@ -100,12 +100,10 @@ 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().to_string(), - cap.get(2) - .or_else(|| cap.get(3))? - .as_str() - .replace('\\', "."), + cap.get(1)?.as_str().into(), + handler.replace('\\', ".").trim_start_matches('.').into(), )) }) .collect() @@ -181,7 +179,7 @@ fn php_class_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { Regex::new( - r"(?m)^\s*(?:final\s+|abstract\s+)?(?:class|interface|trait|enum)\s+([A-Za-z_][A-Za-z0-9_]*)", + r"(?m)^\s*(?:final\s+|abstract\s+|readonly\s+)*(?:class|interface|trait|enum)\s+([A-Za-z_][A-Za-z0-9_]*)", ) .expect("class") }) @@ -191,7 +189,7 @@ 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)"#, + 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/python_imports.rs b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs index 13d2bf3f7..8ba8f01eb 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports.rs @@ -39,15 +39,25 @@ pub(super) fn extract_python_imports( 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 raw in extract_named(&source, python_import_re()) { for part in raw.split(',') { - if let Some(name) = part.split_whitespace().next() { + 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) { + for cap in python_from_re().captures_iter(&source) { let Some(module) = cap.get(1).map(|m| m.as_str()) else { continue; }; 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 index 1ba65e7df..17223763f 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/python_imports_tests.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/python_imports_tests.rs @@ -12,6 +12,10 @@ fn extract_python_imports_covers_unprefixed_and_star_forms() { ); 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()), diff --git a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs index ed48e2570..7ad65c440 100644 --- a/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs +++ b/crates/no-mistakes/src/codebase/lang_frontends/rustlang.rs @@ -94,7 +94,7 @@ fn extract_named(source: &str, re: &Regex) -> Vec { 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*)").expect("mod") + Regex::new(r"(?m)^\s*(?:pub(?:\([^)]+\))?\s+)?mod\s+([A-Za-z_]\w*)\s*;").expect("mod") }) } 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/fixtures/lang-frontends/go-asynq/worker/enqueue.go b/fixtures/lang-frontends/go-asynq/worker/enqueue.go index 88376efc6..b1910bfbc 100644 --- a/fixtures/lang-frontends/go-asynq/worker/enqueue.go +++ b/fixtures/lang-frontends/go-asynq/worker/enqueue.go @@ -3,6 +3,7 @@ package worker import fmtlib "fmt" import ( + "example.com/nested" "github.com/hibiken/asynq" ) diff --git a/fixtures/lang-frontends/kafka-topics/consumer.ts b/fixtures/lang-frontends/kafka-topics/consumer.ts index bc1ee8147..d36fe9391 100644 --- a/fixtures/lang-frontends/kafka-topics/consumer.ts +++ b/fixtures/lang-frontends/kafka-topics/consumer.ts @@ -1,6 +1,6 @@ export function listen(consumer: { subscribe: (input: { topic: string } | string[]) => void; }) { - consumer.subscribe({ topic: "mail.welcome" }); + 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/php-laravel/app/Dto/UserDto.php b/fixtures/lang-frontends/php-laravel/app/Dto/UserDto.php new file mode 100644 index 000000000..f1c5dd039 --- /dev/null +++ b/fixtures/lang-frontends/php-laravel/app/Dto/UserDto.php @@ -0,0 +1,7 @@ + None: diff --git a/fixtures/lang-frontends/python-celery-django/app/users/views.py b/fixtures/lang-frontends/python-celery-django/app/users/views.py index cb81f5764..470a15f1b 100644 --- a/fixtures/lang-frontends/python-celery-django/app/users/views.py +++ b/fixtures/lang-frontends/python-celery-django/app/users/views.py @@ -3,6 +3,7 @@ from app.tasks import * """ +import app.fake_docstring class LegacyUser: pass """ diff --git a/fixtures/lang-frontends/rust-mods/src/lib.rs b/fixtures/lang-frontends/rust-mods/src/lib.rs index 17b36025f..d42a9d014 100644 --- a/fixtures/lang-frontends/rust-mods/src/lib.rs +++ b/fixtures/lang-frontends/rust-mods/src/lib.rs @@ -4,6 +4,7 @@ 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 index 9bb5f7c60..e13445ca3 100644 --- a/fixtures/lang-frontends/rust-mods/src/mail.rs +++ b/fixtures/lang-frontends/rust-mods/src/mail.rs @@ -3,3 +3,7 @@ pub struct Welcome; impl Welcome { pub fn emit() {} } + +mod unused { + pub fn skip() {} +} 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() {}