Repository: qltysh/qlty
Pull request #2066: Fix formatter invocation directory when runs from target_directory
Author: login: marschattha
Created: 2025-05-22T00:43:39Z | Merged: 2025-05-22T20:08:52Z

Since the computation for invocation directory is done in planning phase before files are staged (in the executor phase). 
Advanced directive like runs_from target_directory was not functioning as intended.


Diff:
diff --git a/qlty-check/src/planner/driver.rs b/qlty-check/src/planner/driver.rs
index bfbb7dc2f..db90c2cbf 100644
--- a/qlty-check/src/planner/driver.rs
+++ b/qlty-check/src/planner/driver.rs
@@ -54,6 +54,7 @@ impl DriverPlanner {
             plugin: plugin_planner.plugin.clone(),
             tool: plugin_planner.tool.clone(),
             target_root: Self::compute_target_root(&driver, plugin_planner),
+            workspace_root: plugin_planner.workspace.root.clone(),
         };
 
         Self {
diff --git a/qlty-check/src/planner/invocation_directory.rs b/qlty-check/src/planner/invocation_directory.rs
index 85bcacb61..2202ce20a 100644
--- a/qlty-check/src/planner/invocation_directory.rs
+++ b/qlty-check/src/planner/invocation_directory.rs
@@ -10,6 +10,7 @@ pub struct InvocationDirectoryPlanner {
     pub plugin: PluginDef,
     pub tool: Box<dyn Tool>,
     pub target_root: PathBuf,
+    pub workspace_root: PathBuf,
 }
 
 impl InvocationDirectoryPlanner {
@@ -54,7 +55,7 @@ impl InvocationDirectoryPlanner {
     }
 
     fn target_directory(&self, target: &WorkspaceEntry) -> Result<PathBuf> {
-        let potential_dir = self.target_root.join(target.path.clone());
+        let potential_dir = self.workspace_root.join(target.path.clone());
 
         if potential_dir.exists() {
             let metadata = std::fs::metadata(potential_dir.clone()).with_context(|| {
@@ -64,10 +65,22 @@ impl InvocationDirectoryPlanner {
                 )
             })?;
 
-            if metadata.is_dir() {
-                Ok(potential_dir)
+            let potential_target_dir = if metadata.is_dir() {
+                potential_dir
             } else {
-                Ok(potential_dir.parent().unwrap().to_path_buf())
+                potential_dir.parent().unwrap().to_path_buf()
+            };
+
+            if self.target_root == self.workspace_root {
+                return Ok(potential_target_dir);
+            }
+
+            // Handle case for formatters where files haven't been staged yet
+            if let Ok(relative_path) = potential_target_dir.strip_prefix(&self.workspace_root) {
+                let target_dir = self.target_root.join(relative_path);
+                Ok(target_dir)
+            } else {
+                Ok(potential_target_dir)
             }
         } else {
             Ok(self.target_root.clone())
@@ -106,6 +119,7 @@ mod test {
     use qlty_config::config::InvocationDirectoryDef;
     use qlty_test_utilities::git::sample_repo;
     use std::{fs::File, time::SystemTime};
+    use tempfile::tempdir;
 
     fn target_files(path: &str) -> Target {
         Target {
@@ -141,6 +155,7 @@ mod test {
                 plugin: Default::default(),
             }),
             target_root: temp_dir.to_path_buf(),
+            workspace_root: temp_dir.to_path_buf(),
         }
     }
 
@@ -267,4 +282,60 @@ mod test {
             assert!(invocation_directory.ends_with(result));
         }
     }
+
+    #[test]
+    fn test_target_directory_when_different_target_root_from_workspace_root() {
+        let (temp_dir, _) = sample_repo();
+        let temp_dir_path = temp_dir.path();
+        let temp_dir_staging = tempdir().unwrap();
+        let temp_dir_staging_path = temp_dir_staging.path();
+
+        let invocation_directory_def = InvocationDirectoryDef {
+            kind: InvocationDirectoryType::TargetDirectory,
+            path: None,
+        };
+
+        let planner = InvocationDirectoryPlanner {
+            driver: DriverDef {
+                invocation_directory_def,
+                ..Default::default()
+            },
+            plugin: PluginDef {
+                config_files: vec!["config_file.json".into()],
+                ..Default::default()
+            },
+            tool: Box::new(NullTool {
+                parent_directory: temp_dir_path
+                    .to_path_buf()
+                    .join(".qlty")
+                    .join("cache")
+                    .join("tools")
+                    .join("null_tool"),
+                plugin_name: "mock_plugin".to_string(),
+                plugin: Default::default(),
+            }),
+            target_root: temp_dir_staging_path.to_path_buf(),
+            workspace_root: temp_dir_path.to_path_buf(),
+        };
+
+        let targets_results = vec![
+            (
+                target_files("lib/hello.rb"),
+                temp_dir_staging_path.join("lib"),
+            ),
+            (
+                target_files("greetings.rb"),
+                temp_dir_staging_path.to_path_buf(),
+            ),
+            (
+                target_files("lib/tasks/some.rb"),
+                temp_dir_staging_path.join("lib/tasks"),
+            ),
+        ];
+
+        for (target, result) in targets_results {
+            let invocation_directory = planner.compute(&target).unwrap();
+            assert_eq!(invocation_directory, result);
+        }
+    }
 }
diff --git a/qlty-check/src/planner/target_batcher.rs b/qlty-check/src/planner/target_batcher.rs
index 5bd801088..54aacd105 100644
--- a/qlty-check/src/planner/target_batcher.rs
+++ b/qlty-check/src/planner/target_batcher.rs
@@ -294,6 +294,7 @@ mod test {
                     ..Default::default()
                 }),
                 target_root: PathBuf::from("/User/test/project_root/"),
+                workspace_root: PathBuf::from("/User/test/project_root/"),
             },
             plugin_configs: vec![
                 plugin_config_file("/User/test/project_root/config1"),
@@ -525,6 +526,7 @@ mod test {
                     ..Default::default()
                 }),
                 target_root: temp_path.clone(),
+                workspace_root: temp_path.clone(),
             },
             plugin_configs: vec![],
             current_prefix: None,


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR ensures that formatter invocation directories use `workspace_root` during the planning phase, allowing advanced directives like `runs_from target_directory` to work correctly when staging files.

- Introduce and propagate a new `workspace_root` field in planners.
- Update `target_directory` logic to compute paths relative to `workspace_root` before staging.
- Add tests covering scenarios where `target_root` differs from `workspace_root`.

### Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

| File                                          | Description                                                      |
|-----------------------------------------------|------------------------------------------------------------------|
| qlty-check/src/planner/target_batcher.rs      | Added `workspace_root` to test fixtures for `TargetBatcher`      |
| qlty-check/src/planner/invocation_directory.rs| Added `workspace_root`, refactored `target_directory`, and new tests |
| qlty-check/src/planner/driver.rs              | Set `workspace_root` when constructing `InvocationDirectoryPlanner` |


<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**qlty-check/src/planner/invocation_directory.rs:85**
* Add a test for the branch where `potential_dir.exists()` is false to verify that the fallback to `target_root` is correct.
```
Ok(self.target_root.clone())
```
**qlty-check/src/planner/driver.rs:57**
* [nitpick] Double-check that `plugin_planner.workspace.root` is the correct source for the planner’s `workspace_root`—misalignments here could lead to incorrect path calculations at runtime.
```
workspace_root: plugin_planner.workspace.root.clone(),
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T00:44:57Z
user: login: brynary
body: Thanks. A few questions...

How was this problem exposed?

Also is this a regression or something we believe never worked?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T13:38:59Z
user: login: brynary
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T18:12:18Z

Review comments:
diff_hunk: @@ -64,10 +65,22 @@ impl InvocationDirectoryPlanner {
                 )
             })?;
 
-            if metadata.is_dir() {
-                Ok(potential_dir)
+            let potential_target_dir = if metadata.is_dir() {
+                potential_dir
             } else {
-                Ok(potential_dir.parent().unwrap().to_path_buf())
+                potential_dir.parent().unwrap().to_path_buf()
path: qlty-check/src/planner/invocation_directory.rs
user: login: Copilot
body: Avoid using `unwrap()` on `parent()` which could panic if the path has no parent; consider handling the `None` case gracefully or using `expect()` with a clear error message.
```suggestion
                potential_dir
                    .parent()
                    .ok_or_else(|| anyhow::anyhow!("The potential directory has no parent: {}", path_to_native_string(&potential_dir)))?
                    .to_path_buf()
```
created_at: 2025-05-22T00:44:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,10 +65,22 @@ impl InvocationDirectoryPlanner {
                 )
             })?;
 
-            if metadata.is_dir() {
-                Ok(potential_dir)
+            let potential_target_dir = if metadata.is_dir() {
+                potential_dir
             } else {
-                Ok(potential_dir.parent().unwrap().to_path_buf())
+                potential_dir.parent().unwrap().to_path_buf()
+            };
+
+            if self.target_root == self.workspace_root {
+                return Ok(potential_target_dir);
+            }
+
+            // Handle case for formatters where files haven't been staged yet
path: qlty-check/src/planner/invocation_directory.rs
user: login: Copilot
body: [nitpick] Consider expanding this comment or adding a docstring to explain the new logic path and its intended use case when `target_root` and `workspace_root` differ.
```suggestion
            // Handle the case where `target_root` and `workspace_root` differ.
            // This logic ensures that formatters or tools can correctly resolve paths
            // even when files haven't been staged yet. If the `potential_target_dir` is
            // within the `workspace_root`, we compute its relative path and append it
            // to the `target_root`. This allows tools to operate in the correct directory
            // structure. If the `strip_prefix` operation fails (e.g., the directory is
            // not within the `workspace_root`), we fall back to using the original
            // `potential_target_dir`.
```
created_at: 2025-05-22T00:44:57Z
author_association: CONTRIBUTOR

PR comments:
user: login: qltysh[bot]
created_at: 2025-05-22T00:45:57Z
body: <p><strong>Diff Coverage for macos-15:</strong> The code coverage on the diff in this pull request is <strong>98.5%</strong>.</p>
<p><strong>Total Coverage for macos-15:</strong> This PR will increase coverage by <strong>0.02%</strong>.</p>

  <details>
  <summary>File Coverage Changes</summary>

  | Path  | File Coverage Δ | Indirect |
  | ------------- | ------------- | ------------- |
 | qlty-check/src/planner/driver.rs | 0.1 |  |
 | qlty-check/src/planner/invocation_directory.rs | 0.2 |  |
 | qlty-check/src/planner/target_batcher.rs | 0.0 |  |
</details>

<details>
  <summary>🛟 Help</summary>
  <ul>
    <li>
      <p>
        <strong>Diff Coverage:</strong> Coverage for added or modified lines of code (excludes deleted files). <a href="https://qlty.sh/d/diff-coverage">
          Learn more.
          </a>
      </p>
    </li>
    <li>
      <p>
        <strong>Total Coverage:</strong> Coverage for the whole repository, calculated as the sum of all File Coverage.
        <a href="https://qlty.sh/d/total-coverage">
           Learn more.
        </a>
      </p>
    </li>
    <li>
      <p>
        <strong>File Coverage:</strong> Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)
      </p>
      <ul>
        <li>
        <p>
          <strong>Indirect Changes:</strong> Changes to File Coverage for files that were not modified in this PR. <a href="https://qlty.sh/d/indirect-coverage-changes">
            Learn more.
            </a>
        </p>
      </li>
      </ul>
    </li>
  </ul>
</details>
  

<a href="#coverage-summary-comment-macos-15"></a>
author_association: CONTRIBUTOR
user: login: qltysh[bot]
created_at: 2025-05-22T00:46:29Z
body: <p><strong>Diff Coverage for ubuntu-latest:</strong> The code coverage on the diff in this pull request is <strong>98.5%</strong>.</p>
<p><strong>Total Coverage for ubuntu-latest:</strong> This PR will increase coverage by <strong>0.02%</strong>.</p>

  <details>
  <summary>File Coverage Changes</summary>

  | Path  | File Coverage Δ | Indirect |
  | ------------- | ------------- | ------------- |
 | qlty-check/src/planner/driver.rs | 0.1 |  |
 | qlty-check/src/planner/invocation_directory.rs | 0.2 |  |
 | qlty-check/src/planner/target_batcher.rs | 0.0 |  |
</details>

<details>
  <summary>🛟 Help</summary>
  <ul>
    <li>
      <p>
        <strong>Diff Coverage:</strong> Coverage for added or modified lines of code (excludes deleted files). <a href="https://qlty.sh/d/diff-coverage">
          Learn more.
          </a>
      </p>
    </li>
    <li>
      <p>
        <strong>Total Coverage:</strong> Coverage for the whole repository, calculated as the sum of all File Coverage.
        <a href="https://qlty.sh/d/total-coverage">
           Learn more.
        </a>
      </p>
    </li>
    <li>
      <p>
        <strong>File Coverage:</strong> Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)
      </p>
      <ul>
        <li>
        <p>
          <strong>Indirect Changes:</strong> Changes to File Coverage for files that were not modified in this PR. <a href="https://qlty.sh/d/indirect-coverage-changes">
            Learn more.
            </a>
        </p>
      </li>
      </ul>
    </li>
  </ul>
</details>
  

<a href="#coverage-summary-comment-ubuntu-latest"></a>
author_association: CONTRIBUTOR
user: login: marschattha
created_at: 2025-05-22T15:16:00Z
body: It was exposed when I was trying to implement [terraform fmt](https://github.com/qltysh/qlty/pull/2067), noted that it wasn't showing results for nested files.

It is a fairly old regression but that went unnoticed since there aren't any formatters which need to be run at directory level, I remember we used to compute invocation directory in executor at some point and then moved it into planner for better control and integration with the target planning.
author_association: MEMBER