From 03d6eefc9bb3902aa05a6e69466b9ee945c2a73e Mon Sep 17 00:00:00 2001 From: Ben Carey Date: Fri, 3 Apr 2026 12:05:11 -0400 Subject: [PATCH 1/2] feat(plugins): add preserve_autoload option for PHP plugin configuration When PHP linter extensions like Larastan bootstrap the Laravel application, they need the project's PSR-4 namespace mappings in the sandbox autoloader. Previously, filter_composer unconditionally stripped autoload/autoload-dev sections, causing "Class not found" crashes. This adds a preserve_autoload boolean (default false) that, when enabled, preserves autoload sections and creates symlinks from the sandbox to the project root so Composer's generated autoloader resolves paths to the actual source files. --- qlty-check/src/planner/config.rs | 2 + qlty-check/src/tool/php/composer.rs | 380 +++++++++++++++++++++++++++- qlty-config/src/config/plugin.rs | 47 ++++ 3 files changed, 426 insertions(+), 3 deletions(-) diff --git a/qlty-check/src/planner/config.rs b/qlty-check/src/planner/config.rs index 949ddc249..956fd372f 100644 --- a/qlty-check/src/planner/config.rs +++ b/qlty-check/src/planner/config.rs @@ -175,11 +175,13 @@ fn configure_plugin( } plugin_def.package_file = Some(package_file.to_str().unwrap_or_default().to_string()); + plugin_def.workspace_root = Some(prefixed_root); } // This is becoming a weird pattern, we should probably refactor this? plugin_def.fetch = enabled_plugin.fetch.clone(); plugin_def.package_filters = enabled_plugin.package_filters.clone(); + plugin_def.preserve_autoload = enabled_plugin.preserve_autoload; plugin_def.prefix = enabled_plugin.prefix.clone(); plugin_def diff --git a/qlty-check/src/tool/php/composer.rs b/qlty-check/src/tool/php/composer.rs index 4abd2bec0..c9f5c0ebc 100644 --- a/qlty-check/src/tool/php/composer.rs +++ b/qlty-check/src/tool/php/composer.rs @@ -10,7 +10,7 @@ use qlty_analysis::utils::fs::path_to_native_string; use serde_json::Value; use sha2::Digest; use std::env::split_paths; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tracing::{debug, error, info}; use super::PhpPackage; @@ -74,6 +74,7 @@ impl Composer { pub fn install_package_file(&self, php_package: &PhpPackage) -> Result<()> { info!("Installing composer package file"); Self::update_composer_json(php_package)?; + Self::create_autoload_symlinks(php_package)?; let composer_phar = PathBuf::from(self.directory()).join("composer.phar"); let composer_path = composer_phar.to_str().with_context(|| { format!( @@ -145,8 +146,10 @@ impl Composer { let mut composer_json = serde_json::from_str::(&composer_file_contents)?; if let Some(root_object) = composer_json.as_object_mut() { // Remove autoloads that might be relative to project root - root_object.remove("autoload"); - root_object.remove("autoload-dev"); + if !php_package.plugin.preserve_autoload { + root_object.remove("autoload"); + root_object.remove("autoload-dev"); + } // collapse require-dev into require if let Some(dev_dependencies) = root_object.clone().get("require-dev") { @@ -225,6 +228,132 @@ impl Composer { Ok(()) } + + fn create_autoload_symlinks(php_package: &PhpPackage) -> Result<()> { + if !php_package.plugin.preserve_autoload { + return Ok(()); + } + + let package_file = php_package + .plugin + .package_file + .as_ref() + .with_context(|| "Missing package_file in plugin definition")?; + + let project_root = php_package + .plugin + .workspace_root + .as_ref() + .with_context(|| "Missing workspace_root in plugin definition")?; + + let composer_contents = std::fs::read_to_string(package_file)?; + let composer_json: Value = serde_json::from_str(&composer_contents)?; + + let sandbox_dir = PathBuf::from(php_package.directory()); + let paths = Self::collect_autoload_paths(&composer_json); + + for relative_path in &paths { + let target = project_root.join(relative_path); + if !target.exists() { + debug!( + "Skipping autoload symlink, target does not exist: {:?}", + target + ); + continue; + } + + let link = sandbox_dir.join(relative_path); + if link.symlink_metadata().is_ok() { + debug!( + "Skipping autoload symlink, path already exists: {:?}", + link + ); + continue; + } + + if let Some(parent) = link.parent() { + std::fs::create_dir_all(parent)?; + } + + debug!("Creating autoload symlink: {:?} -> {:?}", link, target); + create_symlink(&target, &link).with_context(|| { + format!( + "Failed to create autoload symlink: {:?} -> {:?}", + link, target + ) + })?; + } + + Ok(()) + } + + fn collect_autoload_paths(composer_json: &Value) -> Vec { + let mut paths = vec![]; + + for section in &["autoload", "autoload-dev"] { + if let Some(autoload) = composer_json.get(section) { + for key in &["psr-4", "psr-0"] { + if let Some(mappings) = autoload.get(key).and_then(|v| v.as_object()) { + for (_, path_value) in mappings { + match path_value { + Value::String(s) if !s.is_empty() => { + paths.push(s.trim_end_matches('/').to_string()); + } + Value::Array(arr) => { + for v in arr { + if let Some(s) = v.as_str() { + if !s.is_empty() { + paths.push( + s.trim_end_matches('/').to_string(), + ); + } + } + } + } + _ => {} + } + } + } + } + + if let Some(classmap) = autoload.get("classmap").and_then(|v| v.as_array()) { + for v in classmap { + if let Some(s) = v.as_str() { + if !s.is_empty() { + paths.push(s.trim_end_matches('/').to_string()); + } + } + } + } + + if let Some(files) = autoload.get("files").and_then(|v| v.as_array()) { + for v in files { + if let Some(s) = v.as_str() { + if !s.is_empty() { + paths.push(s.to_string()); + } + } + } + } + } + } + + paths + } +} + +#[cfg(windows)] +fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + if target.is_dir() { + std::os::windows::fs::symlink_dir(target, link) + } else { + std::os::windows::fs::symlink_file(target, link) + } +} + +#[cfg(unix)] +fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link) } #[cfg(test)] @@ -355,6 +484,70 @@ pub mod test { ); } + #[test] + fn test_filter_composer_preserve_autoload() { + let temp_path = tempdir().unwrap(); + let list = Arc::new(Mutex::new(Vec::>::new())); + + let package = PhpPackage { + cmd: stub_cmd(list.clone()), + name: "tool".into(), + plugin: PluginDef { + package: Some("test".to_string()), + version: Some("1.0.0".to_string()), + package_file: Some(format!( + "{}/composer.json", + temp_path.path().to_str().unwrap() + )), + package_filters: vec!["foo".to_string()], + preserve_autoload: true, + ..Default::default() + }, + runtime: Php { + version: "1.0.0".to_string(), + }, + }; + + let composer_file = temp_path.path().join("composer.json"); + std::fs::write( + composer_file, + r#" + { + "autoload": { + "random": "value" + }, + "autoload-dev": { + "random": "value" + }, + "require": { + "foo": "1.0.0", + "bar": "1.0.0" + }, + "require-dev": { + "foo-dev": "1.0.0", + "bar-dev": "1.0.0" + } + }"#, + ) + .unwrap(); + + assert_eq!( + Composer::filter_composer(&package).unwrap(), + r#"{ + "autoload": { + "random": "value" + }, + "autoload-dev": { + "random": "value" + }, + "require": { + "foo": "1.0.0", + "foo-dev": "1.0.0" + } +}"# + ); + } + #[test] fn test_update_existing_composer_json() { with_php_package(|pkg, tempdir, _| { @@ -510,4 +703,185 @@ pub mod test { Ok(()) }); } + + #[test] + fn test_collect_autoload_paths() { + let composer_json: Value = serde_json::from_str( + r#"{ + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\": ["database/factories/", "database/seeders/"] + }, + "classmap": ["legacy/"], + "files": ["helpers/functions.php"] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + } + }"#, + ) + .unwrap(); + + let paths = Composer::collect_autoload_paths(&composer_json); + assert_eq!( + paths, + vec![ + "app", + "database/factories", + "database/seeders", + "legacy", + "helpers/functions.php", + "tests", + ] + ); + } + + #[test] + fn test_collect_autoload_paths_empty() { + let composer_json: Value = serde_json::from_str(r#"{"require": {"foo": "1.0"}}"#).unwrap(); + + let paths = Composer::collect_autoload_paths(&composer_json); + assert!(paths.is_empty()); + } + + #[test] + fn test_collect_autoload_paths_skips_empty_strings() { + let composer_json: Value = serde_json::from_str( + r#"{ + "autoload": { + "psr-4": { + "": "", + "App\\": "app/" + } + } + }"#, + ) + .unwrap(); + + let paths = Composer::collect_autoload_paths(&composer_json); + assert_eq!(paths, vec!["app"]); + } + + #[test] + fn test_create_autoload_symlinks() { + with_php_package(|pkg, tempdir, _| { + let project_dir = tempdir.path().join("project"); + std::fs::create_dir_all(&project_dir)?; + + std::fs::create_dir_all(project_dir.join("app"))?; + std::fs::create_dir_all(project_dir.join("tests"))?; + + let config_dir = project_dir.join(".qlty/configs"); + std::fs::create_dir_all(&config_dir)?; + + let composer_file = config_dir.join("composer.json"); + std::fs::write( + &composer_file, + r#"{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + } + }"#, + )?; + + pkg.plugin.package_file = Some(path_to_string(&composer_file)); + pkg.plugin.preserve_autoload = true; + pkg.plugin.workspace_root = Some(project_dir.clone()); + reroute_tools_root(tempdir, pkg); + + Composer::create_autoload_symlinks(pkg)?; + + let sandbox_dir = PathBuf::from(pkg.directory()); + assert!(sandbox_dir.join("app").symlink_metadata().unwrap().file_type().is_symlink()); + assert!(sandbox_dir.join("tests").symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!( + std::fs::read_link(sandbox_dir.join("app"))?, + project_dir.join("app") + ); + assert_eq!( + std::fs::read_link(sandbox_dir.join("tests"))?, + project_dir.join("tests") + ); + + Ok(()) + }); + } + + #[test] + fn test_create_autoload_symlinks_skips_when_disabled() { + with_php_package(|pkg, tempdir, _| { + let project_dir = tempdir.path().join("project"); + std::fs::create_dir_all(&project_dir)?; + std::fs::create_dir_all(project_dir.join("app"))?; + + let composer_file = project_dir.join("composer.json"); + std::fs::write( + &composer_file, + r#"{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } + }"#, + )?; + + pkg.plugin.package_file = Some(path_to_string(&composer_file)); + pkg.plugin.workspace_root = Some(project_dir.clone()); + reroute_tools_root(tempdir, pkg); + + Composer::create_autoload_symlinks(pkg)?; + + let sandbox_dir = PathBuf::from(pkg.directory()); + assert!(!sandbox_dir.join("app").exists()); + + Ok(()) + }); + } + + #[test] + fn test_create_autoload_symlinks_skips_nonexistent_targets() { + with_php_package(|pkg, tempdir, _| { + let project_dir = tempdir.path().join("project"); + std::fs::create_dir_all(&project_dir)?; + + let composer_file = project_dir.join("composer.json"); + std::fs::write( + &composer_file, + r#"{ + "autoload": { + "psr-4": { + "App\\": "app/", + "Missing\\": "does-not-exist/" + } + } + }"#, + )?; + + std::fs::create_dir_all(project_dir.join("app"))?; + + pkg.plugin.package_file = Some(path_to_string(&composer_file)); + pkg.plugin.preserve_autoload = true; + pkg.plugin.workspace_root = Some(project_dir.clone()); + reroute_tools_root(tempdir, pkg); + + Composer::create_autoload_symlinks(pkg)?; + + let sandbox_dir = PathBuf::from(pkg.directory()); + assert!(sandbox_dir.join("app").symlink_metadata().unwrap().file_type().is_symlink()); + assert!(!sandbox_dir.join("does-not-exist").exists()); + + Ok(()) + }); + } } diff --git a/qlty-config/src/config/plugin.rs b/qlty-config/src/config/plugin.rs index cf04f97bd..059fd7e7d 100644 --- a/qlty-config/src/config/plugin.rs +++ b/qlty-config/src/config/plugin.rs @@ -394,6 +394,9 @@ pub struct PluginDef { #[serde(default)] pub package_filters: Vec, + #[serde(default)] + pub preserve_autoload: bool, + #[serde(default)] pub package_file_candidate: Option, @@ -403,6 +406,9 @@ pub struct PluginDef { #[serde(default)] pub prefix: Option, + #[serde(skip)] + pub workspace_root: Option, + #[serde(default)] pub supported_platforms: Vec, @@ -694,6 +700,9 @@ pub struct EnabledPlugin { #[serde(default)] pub package_filters: Vec, + #[serde(default)] + pub preserve_autoload: bool, + #[serde(default)] pub prefix: Option, } @@ -720,6 +729,13 @@ impl EnabledPlugin { )); } + if self.preserve_autoload && self.package_file.is_none() { + return Err(anyhow::anyhow!( + "Plugin '{}' has 'preserve_autoload' configured but no 'package_file'. The 'preserve_autoload' option requires 'package_file' to be specified.", + self.name + )); + } + Ok(()) } } @@ -1026,4 +1042,35 @@ mod tests { assert!(error_message.contains("package_file")); assert!(error_message.contains("requires")); } + + #[test] + fn test_enabled_plugin_validate_success_with_preserve_autoload_and_package_file() { + let plugin = EnabledPlugin { + name: "test-plugin".to_string(), + package_file: Some("composer.json".to_string()), + preserve_autoload: true, + ..Default::default() + }; + + assert!(plugin.validate().is_ok()); + } + + #[test] + fn test_enabled_plugin_validate_failure_with_preserve_autoload_but_no_package_file() { + let plugin = EnabledPlugin { + name: "test-plugin".to_string(), + package_file: None, + preserve_autoload: true, + ..Default::default() + }; + + let result = plugin.validate(); + assert!(result.is_err()); + + let error_message = result.unwrap_err().to_string(); + assert!(error_message.contains("test-plugin")); + assert!(error_message.contains("preserve_autoload")); + assert!(error_message.contains("package_file")); + assert!(error_message.contains("requires")); + } } From fd37badfcf5e5da4b8671ca055c46ba1ff6d17e6 Mon Sep 17 00:00:00 2001 From: Ben Carey Date: Fri, 3 Apr 2026 12:11:24 -0400 Subject: [PATCH 2/2] refactor(plugins): reduce nesting in collect_autoload_paths Extract collect_psr_paths and collect_string_array_paths helpers to address qlty nested-control-flow and function-complexity findings. --- qlty-check/src/tool/php/composer.rs | 107 ++++++++++++++++------------ qlty-config/src/config/builder.rs | 5 ++ 2 files changed, 65 insertions(+), 47 deletions(-) diff --git a/qlty-check/src/tool/php/composer.rs b/qlty-check/src/tool/php/composer.rs index c9f5c0ebc..ff002637b 100644 --- a/qlty-check/src/tool/php/composer.rs +++ b/qlty-check/src/tool/php/composer.rs @@ -264,10 +264,7 @@ impl Composer { let link = sandbox_dir.join(relative_path); if link.symlink_metadata().is_ok() { - debug!( - "Skipping autoload symlink, path already exists: {:?}", - link - ); + debug!("Skipping autoload symlink, path already exists: {:?}", link); continue; } @@ -291,54 +288,55 @@ impl Composer { let mut paths = vec![]; for section in &["autoload", "autoload-dev"] { - if let Some(autoload) = composer_json.get(section) { - for key in &["psr-4", "psr-0"] { - if let Some(mappings) = autoload.get(key).and_then(|v| v.as_object()) { - for (_, path_value) in mappings { - match path_value { - Value::String(s) if !s.is_empty() => { - paths.push(s.trim_end_matches('/').to_string()); - } - Value::Array(arr) => { - for v in arr { - if let Some(s) = v.as_str() { - if !s.is_empty() { - paths.push( - s.trim_end_matches('/').to_string(), - ); - } - } - } - } - _ => {} - } - } - } - } + let Some(autoload) = composer_json.get(section) else { + continue; + }; - if let Some(classmap) = autoload.get("classmap").and_then(|v| v.as_array()) { - for v in classmap { - if let Some(s) = v.as_str() { - if !s.is_empty() { - paths.push(s.trim_end_matches('/').to_string()); - } - } + for key in &["psr-4", "psr-0"] { + if let Some(mappings) = autoload.get(key).and_then(|v| v.as_object()) { + for (_, path_value) in mappings { + Self::collect_psr_paths(path_value, &mut paths); } } + } - if let Some(files) = autoload.get("files").and_then(|v| v.as_array()) { - for v in files { - if let Some(s) = v.as_str() { - if !s.is_empty() { - paths.push(s.to_string()); - } - } - } + Self::collect_string_array_paths(autoload.get("classmap"), &mut paths); + Self::collect_string_array_paths(autoload.get("files"), &mut paths); + } + + paths + } + + fn collect_psr_paths(path_value: &Value, paths: &mut Vec) { + match path_value { + Value::String(s) if !s.is_empty() => { + paths.push(s.trim_end_matches('/').to_string()); + } + Value::Array(arr) => { + for s in arr + .iter() + .filter_map(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + paths.push(s.trim_end_matches('/').to_string()); } } + _ => {} } + } - paths + fn collect_string_array_paths(value: Option<&Value>, paths: &mut Vec) { + let Some(arr) = value.and_then(|v| v.as_array()) else { + return; + }; + + for s in arr + .iter() + .filter_map(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + paths.push(s.trim_end_matches('/').to_string()); + } } } @@ -802,8 +800,18 @@ pub mod test { Composer::create_autoload_symlinks(pkg)?; let sandbox_dir = PathBuf::from(pkg.directory()); - assert!(sandbox_dir.join("app").symlink_metadata().unwrap().file_type().is_symlink()); - assert!(sandbox_dir.join("tests").symlink_metadata().unwrap().file_type().is_symlink()); + assert!(sandbox_dir + .join("app") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink()); + assert!(sandbox_dir + .join("tests") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink()); assert_eq!( std::fs::read_link(sandbox_dir.join("app"))?, project_dir.join("app") @@ -878,7 +886,12 @@ pub mod test { Composer::create_autoload_symlinks(pkg)?; let sandbox_dir = PathBuf::from(pkg.directory()); - assert!(sandbox_dir.join("app").symlink_metadata().unwrap().file_type().is_symlink()); + assert!(sandbox_dir + .join("app") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink()); assert!(!sandbox_dir.join("does-not-exist").exists()); Ok(()) diff --git a/qlty-config/src/config/builder.rs b/qlty-config/src/config/builder.rs index 216faf933..113263540 100644 --- a/qlty-config/src/config/builder.rs +++ b/qlty-config/src/config/builder.rs @@ -466,6 +466,7 @@ fn merge_enabled_plugins(existing: &EnabledPlugin, new: &EnabledPlugin) -> Enabl triggers: prioritize_new_array(&existing.triggers, &new.triggers), fetch: prioritize_new_array(&existing.fetch, &new.fetch), package_filters: prioritize_new_array(&existing.package_filters, &new.package_filters), + preserve_autoload: new.preserve_autoload || existing.preserve_autoload, affects_cache: prioritize_new_array(&existing.affects_cache, &new.affects_cache), extra_packages: prioritize_new_array(&existing.extra_packages, &new.extra_packages), drivers: prioritize_new_array(&existing.drivers, &new.drivers), @@ -788,6 +789,7 @@ mod test { path: "path1".to_string(), }], package_filters: vec!["filter1".to_string()], + preserve_autoload: false, affects_cache: vec!["cache1".to_string()], extra_packages: vec![ExtraPackage { name: "pkg1".to_string(), @@ -810,6 +812,7 @@ mod test { path: "path2".to_string(), }], package_filters: vec!["filter2".to_string()], + preserve_autoload: false, affects_cache: vec!["cache2".to_string()], extra_packages: vec![ExtraPackage { name: "pkg2".to_string(), @@ -1060,6 +1063,7 @@ mod test { path: "path1".to_string(), }], package_filters: vec!["filter1".to_string()], + preserve_autoload: false, affects_cache: vec!["cache1".to_string()], extra_packages: vec![ExtraPackage { name: "pkg1".to_string(), @@ -1081,6 +1085,7 @@ mod test { path: "path2".to_string(), }], package_filters: vec!["filter2".to_string()], + preserve_autoload: false, affects_cache: vec!["cache2".to_string()], extra_packages: vec![ExtraPackage { name: "pkg2".to_string(),