Repository: paritytech/foundry-compilers-polkadot
Pull request #21: [issues/106] - Added output/ hash to compiler cache file
Author: login: filip-parity
Created: 2025-05-08T13:02:48Z | Merged: 2025-05-09T11:23:08Z

### TL;DR

This pull request addresses the **[caching issue](https://github.com/paritytech/foundry-polkadot/issues/106)** in existing Foundry’s compiler workflow by introducing an output directory hash to the compiler cache file. This ensures that changes to the output directory (such as those caused by switching compilers) are detected, and a full recompilation is triggered when necessary.

---

### Problem Statement

Previously, when running a sequence of builds with different compilers but the same output directory, Foundry’s cache would not detect that the output directory had been overwritten by a different compiler. This led to situations where a build was incorrectly skipped, even though the artifacts in the output directory were no longer valid for the current compiler.

For example, the test [must_rebuild_when_used_the_same_out](https://github.com/paritytech/foundry-polkadot/pull/105/files#diff-f62fc99e8363d8e17855f93dcbe4a42328420f669f11d5bdb4789cb905dbb7b5R15-R51) failed because:
- The first build with `solc` wrote artifacts to `resolc-out/` and updated the `solidity-files-cache.json`.
- The second build with `resolc` overwrote `resolc-out/` and updated a separate `resolc-solidity-files-cache.json`.
- The third build with `solc` again checked only the source file hashes and timestamps in `solidity-files-cache.json`, ignoring the fact that the output directory had been changed by `resolc`, and thus skipped recompilation.

This behavior, while consistent with the current design (and with forks like ZKSync), can lead to stale or incorrect artifacts when compilers are switched but the output directory is shared.

---

### Solution

- **Output Directory Hashing:**  
  - The cache now includes an `output_hash` field, which is a hash of all files in the output directory.
  - On each build, the current hash is compared to the cached hash. If they differ, the cache is invalidated and a full recompilation is triggered.
- **Implementation Details:**  
  - Added a method to recursively hash the contents of the output directory using MD5 and `walkdir`.
  - Updated the `CompilerCache` struct and logic to store and check this hash.
  - Updated dependencies to include `walkdir`.
- **Cache Invalidation:**  
  - If the output directory contents change (e.g., due to a different compiler run), the cache is cleared and a full rebuild is performed.

---

### Command Execution Breakdown

- **Command 1:** `forge build --out=resolc-out` (solc)  
  - Artifacts written to `resolc-out/`, cache updated.
- **Command 2:** `forge build --resolc` (resolc)  
  - Artifacts in `resolc-out/` overwritten, separate cache updated.
- **Command 3:** `forge build --out=resolc-out` (solc)  
  - **Previously**: Build skipped due to unchanged source files, even though output directory was changed.
  - **Now: Output directory hash mismatch triggers a full rebuild.**

---

### Analysis & Rationale

- This change ensures that the cache is always in sync with the actual build artifacts, preventing stale or incorrect outputs when compilers are switched or the output directory is externally modified.
- The solution is minimally invasive: it adds a single field to the cache and a lightweight hash computation, with no breaking changes to the public API.
- The approach is consistent with the current cache design and can be extended or adapted for other compilers or output directory scenarios.

---

### Additional Notes

- The new field is optional and will be populated on the next cache write, ensuring backward compatibility.
- This change does not affect the default behavior for users who do not switch compilers or share output directories.
- Dependency update: `walkdir` is now included in the workspace.


Diff:
diff --git a/crates/compilers/src/buildinfo.rs b/crates/compilers/src/buildinfo.rs
index 0d6a82f59..d71363111 100644
--- a/crates/compilers/src/buildinfo.rs
+++ b/crates/compilers/src/buildinfo.rs
@@ -107,6 +107,7 @@ impl<L: Language> RawBuildInfo<L> {
 
         let input = serde_json::to_value(input)?;
         hasher.update(&serde_json::to_string(&input)?);
+        hasher.update(&serde_json::to_string(&output)?);
 
         // create the hash for `{_format,solcVersion,solcLongVersion,input}`
         // N.B. this is not exactly the same as hashing the json representation of these values but
diff --git a/crates/compilers/src/cache.rs b/crates/compilers/src/cache.rs
index e22c5386d..8b5696de2 100644
--- a/crates/compilers/src/cache.rs
+++ b/crates/compilers/src/cache.rs
@@ -177,6 +177,20 @@ impl<S: CompilerSettings> CompilerCache<S> {
             let path = self.paths.build_infos.join(build_id).with_extension("json");
             let _ = std::fs::remove_file(path);
         }
+
+        if let Ok(dir) = std::fs::read_dir(&self.paths.build_infos) {
+            for build_id in dir
+                .filter_map(|x| x.ok())
+                .filter(|f| f.path().extension().is_some_and(|f| f == "json") && f.path().is_file())
+            {
+                if !self
+                    .builds
+                    .contains(build_id.file_name().to_string_lossy().trim_end_matches(".json"))
+                {
+                    let _ = std::fs::remove_file(build_id.path());
+                }
+            }
+        }
     }
 
     /// Sets the `CacheEntry`'s file paths to `root` adjoined to `self.file`.
diff --git a/crates/compilers/tests/project.rs b/crates/compilers/tests/project.rs
index c0e553b06..7756fe8f4 100644
--- a/crates/compilers/tests/project.rs
+++ b/crates/compilers/tests/project.rs
@@ -4526,3 +4526,146 @@ fn can_compile_with_right_output() {
         .to_string()
         .starts_with("0x50564d"));
 }
+
+#[test]
+fn test_output_hash_cache_invalidation() {
+    // Set up test project using dapp-sample test data.
+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-data/dapp-sample");
+    let paths = ProjectPathsConfig::builder().sources(root.join("src")).lib(root.join("lib"));
+    let mut project = TempProject::<MultiCompiler, ConfigurableArtifacts>::new(paths).unwrap();
+    project.project_mut().build_info = true;
+
+    // First compilation - should compile everything since cache is empty.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(!compiled.is_unchanged(), "First compilation should not be cached");
+
+    // Second compilation - should use cache since nothing changed.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(compiled.is_unchanged(), "Second compilation should use cache");
+
+    // Adding a file to output directory should NOT invalidate cache
+    let artifacts_path = project.project().artifacts_path();
+    let new_file = artifacts_path.join("test.json");
+    fs::write(&new_file, "{}").unwrap();
+
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(
+        compiled.is_unchanged(),
+        "Cache should remain valid when only output directory changes"
+    );
+
+    // Modify source to trigger new build info
+    let new_contract_path = project.sources_path().join("NewContract.sol");
+    fs::write(
+        &new_contract_path,
+        r#"
+        pragma solidity ^0.8.10;
+        contract NewContract {}
+    "#,
+    )
+    .unwrap();
+
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(!compiled.is_unchanged(), "Cache should be invalidated when build info changes");
+
+    // Clean up test files
+    fs::remove_file(new_file).unwrap();
+    fs::remove_file(new_contract_path).unwrap();
+}
+
+#[test]
+fn test_output_hash_concurrent_modifications() {
+    // Set up test project.
+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-data/dapp-sample");
+    let paths = ProjectPathsConfig::builder().sources(root.join("src")).lib(root.join("lib"));
+    let mut project = TempProject::<MultiCompiler, ConfigurableArtifacts>::new(paths).unwrap();
+    project.project_mut().build_info = true;
+
+    // Initial compilation to establish baseline.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+
+    // Set up background task that continuously modifies a file in the output directory.
+    // This simulates concurrent access to the output directory during compilation.
+    let artifacts_path = project.project().artifacts_path();
+    let temporary_file_path = artifacts_path.join("changing.json");
+    let temporary_file_path_clone = temporary_file_path.clone();
+
+    let handle = std::thread::spawn(move || {
+        for index in 0..5 {
+            fs::write(&temporary_file_path_clone, format!("content_{index}")).unwrap();
+            std::thread::sleep(std::time::Duration::from_millis(10));
+        }
+    });
+
+    // Attempt compilation while directory is being modified.
+    // This tests that the cache system properly handles concurrent modifications.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(compiled.is_unchanged(), "Cache should remain valid during output directory changes");
+
+    // Wait for background task to complete and clean up.
+    handle.join().unwrap();
+    if temporary_file_path.exists() {
+        fs::remove_file(temporary_file_path).unwrap();
+    }
+}
+
+#[test]
+fn test_output_hash_nested_directories() {
+    // Set up test project.
+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-data/dapp-sample");
+    let paths = ProjectPathsConfig::builder().sources(root.join("src")).lib(root.join("lib"));
+    let project = TempProject::<MultiCompiler, ConfigurableArtifacts>::new(paths).unwrap();
+
+    let artifacts_path = project.project().artifacts_path();
+
+    // Create nested directory structure.
+    let nested_directory = artifacts_path.join("a/b/c");
+    fs::create_dir_all(&nested_directory).unwrap();
+
+    // Add files at different levels.
+    fs::write(artifacts_path.join("root.json"), "root").unwrap();
+    fs::write(artifacts_path.join("a/level1.json"), "level1").unwrap();
+    fs::write(artifacts_path.join("a/b/level2.json"), "level2").unwrap();
+    fs::write(nested_directory.join("level3.json"), "level3").unwrap();
+
+    // Compile and verify cache behavior.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(!compiled.is_unchanged(), "Should detect nested directory changes");
+
+    // Clean up.
+    fs::remove_dir_all(artifacts_path.join("a")).unwrap();
+    fs::remove_file(artifacts_path.join("root.json")).unwrap();
+}
+
+#[test]
+fn test_output_hash_special_filenames() {
+    // Set up test project.
+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-data/dapp-sample");
+    let paths = ProjectPathsConfig::builder().sources(root.join("src")).lib(root.join("lib"));
+    let project = TempProject::<MultiCompiler, ConfigurableArtifacts>::new(paths).unwrap();
+
+    let artifacts_path = project.project().artifacts_path();
+    fs::create_dir_all(artifacts_path).unwrap();
+
+    // Create files with special characters in names.
+    fs::write(artifacts_path.join("space file.json"), "space").unwrap();
+    fs::write(artifacts_path.join("underscore_file.json"), "underscore").unwrap();
+    fs::write(artifacts_path.join("dash-file.json"), "dash").unwrap();
+
+    // Compile and verify cache behavior.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(!compiled.is_unchanged(), "Should handle special filenames");
+
+    // Clean up.
+    fs::remove_file(artifacts_path.join("space file.json")).unwrap();
+    fs::remove_file(artifacts_path.join("underscore_file.json")).unwrap();
+    fs::remove_file(artifacts_path.join("dash-file.json")).unwrap();
+}


Reviews:
user: login: smiasojed
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T07:36:32Z
user: login: filip-parity
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-09T08:24:52Z
user: login: filip-parity
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-09T08:24:59Z
user: login: filip-parity
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-09T08:25:05Z
user: login: pkhry
state: APPROVED
author_association: NONE
submitted_at: 2025-05-09T10:51:45Z
user: login: smiasojed
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-09T11:20:34Z

Review comments:
diff_hunk: @@ -1055,6 +1062,27 @@ impl<'a, T: ArtifactOutput<CompilerContract = C::CompilerContract>, C: Compiler>
             // read the cache file if it already exists
             let mut cache = get_cache(project, invalidate_cache, preprocessed);
 
+            // Compare output directory hash with cached hash to detect changes.
path: crates/compilers/src/cache.rs
user: login: smiasojed
body: Maybe this logic could be in `get_cache` function and if case of mismatch we would return `CompilerCache::new(Default::default(), paths, preprocessed)` WDYT?
created_at: 2025-05-09T07:26:50Z
author_association: COLLABORATOR
diff_hunk: @@ -1243,6 +1271,9 @@ impl<'a, T: ArtifactOutput<CompilerContract = C::CompilerContract>, C: Compiler>
             cache
                 .strip_entries_prefix(project.root())
                 .strip_artifact_files_prefixes(project.artifacts_path());
+
+            cache.output_hash = Self::hash_directory(project.artifacts_path()).ok();
path: crates/compilers/src/cache.rs
user: login: smiasojed
body: why we set output hash only if `write_to_disk` is set?
created_at: 2025-05-09T07:33:31Z
author_association: COLLABORATOR
diff_hunk: @@ -1257,4 +1288,30 @@ impl<'a, T: ArtifactOutput<CompilerContract = C::CompilerContract>, C: Compiler>
             }
         }
     }
+
+    /// Hashes the contents of a directory.
+    pub fn hash_directory<P: AsRef<Path>>(directory: P) -> IoResult<String> {
path: crates/compilers/src/cache.rs
user: login: smiasojed
body: does this function need to be public?
created_at: 2025-05-09T07:36:05Z
author_association: COLLABORATOR
diff_hunk: @@ -1257,4 +1288,30 @@ impl<'a, T: ArtifactOutput<CompilerContract = C::CompilerContract>, C: Compiler>
             }
         }
     }
+
+    /// Hashes the contents of a directory.
+    pub fn hash_directory<P: AsRef<Path>>(directory: P) -> IoResult<String> {
path: crates/compilers/src/cache.rs
user: login: filip-parity
body: Hmm not really, I made it `public` just in case we would need to hash other directories as part of the compiler caching but I guess we can disregard this until (if) the need arise. **Addressing...**
created_at: 2025-05-09T08:24:51Z
author_association: NONE
diff_hunk: @@ -1243,6 +1271,9 @@ impl<'a, T: ArtifactOutput<CompilerContract = C::CompilerContract>, C: Compiler>
             cache
                 .strip_entries_prefix(project.root())
                 .strip_artifact_files_prefixes(project.artifacts_path());
+
+            cache.output_hash = Self::hash_directory(project.artifacts_path()).ok();
path: crates/compilers/src/cache.rs
user: login: filip-parity
body: Good catch, I set this to do some manual testing and I genuinely forgot to revert the change haha. **Addressing...**
created_at: 2025-05-09T08:24:59Z
author_association: NONE
diff_hunk: @@ -1055,6 +1062,27 @@ impl<'a, T: ArtifactOutput<CompilerContract = C::CompilerContract>, C: Compiler>
             // read the cache file if it already exists
             let mut cache = get_cache(project, invalidate_cache, preprocessed);
 
+            // Compare output directory hash with cached hash to detect changes.
path: crates/compilers/src/cache.rs
user: login: filip-parity
body: Yes, that's a good suggestion! Moving this logic to `get_cache` would make more sense because it's **more logical to handle all cache invalidation checks in one place** where we decide whether to use existing cache or create a new one and it actually **simplifies the flow** (if there's a hash mismatch, we just start with a fresh cache instead of clearing an existing one). **Addressing...**
created_at: 2025-05-09T08:25:05Z
author_association: NONE

PR comments:
user: login: filip-parity
created_at: 2025-05-08T18:10:56Z
body: Manual testing done _via_:
- Changes under `/paritytech/foundry-polkadot/Cargo.toml`:
```toml
foundry-compilers = { path = "../../foundry-compilers-polkadot/crates/compilers" }
```
and
```bash
cargo build # under `/paritytech/foundry-compilers-polkadot/`
```
- Running:
```bash
> rm -rf resolc-out/
> # rm -rf zkout/
/Users/filip/github/foundry-polkadot/target/debug/forge build --out=resolc-out
[⠰] Compiling...
Compiler run successful!
> # /Users/filip/github/foundry-polkadot/target/debug/forge build --out=zkout
> /Users/filip/github/foundry-polkadot/target/debug/forge build --resolc     # hardcoded to `resolc-out/`
[⠑] Compiling...
Compiler run successful with warnings:
Warning: Warning: Your code or one of its dependencies uses the 'extcodesize' instruction, which is
usually needed in the following cases:
  1. To detect whether an address belongs to a smart contract.
  2. To detect whether the deploy code execution has finished.
Polkadot comes with native account abstraction support (so smart contracts are just accounts
coverned by code), and you should avoid differentiating between contracts and non-contract
addresses.
--> lib/forge-std/src/StdCheats.sol
Warning: Warning: Your code or one of its dependencies uses the 'extcodesize' instruction, which is
usually needed in the following cases:
  1. To detect whether an address belongs to a smart contract.
  2. To detect whether the deploy code execution has finished.
Polkadot comes with native account abstraction support (so smart contracts are just accounts
coverned by code), and you should avoid differentiating between contracts and non-contract
addresses.
--> lib/forge-std/src/StdCheats.sol
Warning: Warning: Your code or one of its dependencies uses the 'extcodesize' instruction, which is
usually needed in the following cases:
  1. To detect whether an address belongs to a smart contract.
  2. To detect whether the deploy code execution has finished.
Polkadot comes with native account abstraction support (so smart contracts are just accounts
coverned by code), and you should avoid differentiating between contracts and non-contract
addresses.
--> lib/forge-std/src/StdUtils.sol
> # /Users/filip/github/foundry-polkadot/target/debug/forge build --zk-compile       # hardcoded to `zkout/`
> /Users/filip/github/foundry-polkadot/target/debug/forge build --out=resolc-out
[⠔] Compiling...
Compiler run successful!
> # /Users/filip/github/foundry-polkadot/target/debug/forge build --out=zkout
```
author_association: NONE
user: login: smiasojed
created_at: 2025-05-09T07:50:53Z
body: Thank you. Overall, it looks good.
author_association: COLLABORATOR
user: login: pkhry
created_at: 2025-05-09T08:12:33Z
body: dq: can you give steps to reproduce the line here? 

> The second build with resolc overwrote resolc-out/ and updated a separate resolc-solidity-files-cache.json.
 
when i recompile either with resolc or solc - only `solidity-files-cache.json` is being updated.  
author_association: NONE
user: login: filip-parity
created_at: 2025-05-09T08:30:17Z
body: > dq: can you give steps to reproduce the line here?
> 
> > The second build with resolc overwrote resolc-out/ and updated a separate resolc-solidity-files-cache.json.
> 
> when i recompile either with resolc or solc - only `solidity-files-cache.json` is being updated.

**FYI** I added a more comprehensive analysis as part of [this issue](https://github.com/paritytech/foundry-polkadot/issues/106) but TL;DR commands are:
```bash
$(FORGE) build --out=resolc-out
$(FORGE) build --resolc	# hardcoded to `resolc-out/`
$(FORGE) build --out=resolc-out
```

Also, you have the initial test within another pull request that reproduced this issue available [here](https://github.com/paritytech/foundry-polkadot/pull/105/files#diff-f62fc99e8363d8e17855f93dcbe4a42328420f669f11d5bdb4789cb905dbb7b5R15-R51).
author_association: NONE
user: login: pkhry
created_at: 2025-05-09T09:24:15Z
body: > > dq: can you give steps to reproduce the line here?
> > > The second build with resolc overwrote resolc-out/ and updated a separate resolc-solidity-files-cache.json.
> > 
> > 
> > when i recompile either with resolc or solc - only `solidity-files-cache.json` is being updated.
> 
> **FYI** I added a more comprehensive analysis as part of [this issue](https://github.com/paritytech/foundry-polkadot/issues/106) but TL;DR commands are:
> 
> ```shell
> $(FORGE) build --out=resolc-out
> $(FORGE) build --resolc	# hardcoded to `resolc-out/`
> $(FORGE) build --out=resolc-out
> ```
> 
> Also, you have the initial test within another pull request that reproduced this issue available [here](https://github.com/paritytech/foundry-polkadot/pull/105/files#diff-f62fc99e8363d8e17855f93dcbe4a42328420f669f11d5bdb4789cb905dbb7b5R15-R51).

oof, accidentaly used wrong flag for resolc

why not do something like this? it should also invalidate caches 
It will remove unrelated build-infos from the out folder

```git 
diff --git a/crates/compilers/src/buildinfo.rs b/crates/compilers/src/buildinfo.rs
index 0d6a82f..d713631 100644
--- a/crates/compilers/src/buildinfo.rs
+++ b/crates/compilers/src/buildinfo.rs
@@ -107,6 +107,7 @@ impl<L: Language> RawBuildInfo<L> {
 
         let input = serde_json::to_value(input)?;
         hasher.update(&serde_json::to_string(&input)?);
+        hasher.update(&serde_json::to_string(&output)?);
 
         // create the hash for `{_format,solcVersion,solcLongVersion,input}`
         // N.B. this is not exactly the same as hashing the json representation of these values but
diff --git a/crates/compilers/src/cache.rs b/crates/compilers/src/cache.rs
index e22c538..caafa56 100644
--- a/crates/compilers/src/cache.rs
+++ b/crates/compilers/src/cache.rs
@@ -177,6 +177,18 @@ impl<S: CompilerSettings> CompilerCache<S> {
             let path = self.paths.build_infos.join(build_id).with_extension("json");
             let _ = std::fs::remove_file(path);
         }
+        if let Ok(dir) = std::fs::read_dir(&self.paths.build_infos) {
+            for build_id in dir
+                .filter_map(|x| x.ok())
+                .filter(|f| f.path().extension().is_some_and(|f| f == "json") && f.path().is_file())
+            {
+                if !self.builds.contains(
+                    build_id.file_name().to_string_lossy().clone().trim_end_matches(".json"),
+                ) {
+                    let _ = std::fs::remove_file(build_id.path());
+                }
+            }
+        }
     }
 
     /// Sets the `CacheEntry`'s file paths to `root` adjoined to `self.file`.
diff --git a/crates/compilers/tests/project.rs b/crates/compilers/tests/project.rs
index c0e553b..29e6025 100644
--- a/crates/compilers/tests/project.rs
+++ b/crates/compilers/tests/project.rs
@@ -4526,3 +4526,132 @@ fn can_compile_with_right_output() {
         .to_string()
         .starts_with("0x50564d"));
 }
+
+#[test]
+fn test_output_hash_cache_invalidation() {
+    // Set up test project using dapp-sample test data.
+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-data/dapp-sample");
+    let paths = ProjectPathsConfig::builder().sources(root.join("src")).lib(root.join("lib"));
+    let project = TempProject::<MultiCompiler, ConfigurableArtifacts>::new(paths).unwrap();
+
+    // First compilation - should compile everything since cache is empty.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(!compiled.is_unchanged(), "First compilation should not be cached");
+
+    // Second compilation - should use cache since nothing changed.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(compiled.is_unchanged(), "Second compilation should use cache");
+
+    // Modify output directory by adding a new file.
+    // This should invalidate the cache since output hash will change.
+    let artifacts_path = project.project().artifacts_path();
+    let new_file = artifacts_path.join("test.json");
+    fs::write(&new_file, "{}").unwrap();
+
+    // Third compilation - should recompile since output directory changed.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(!compiled.is_unchanged(), "Cache should be invalidated after output dir changes");
+
+    // Clean up test file.
+    fs::remove_file(new_file).unwrap();
+}
+
+#[test]
+fn test_output_hash_concurrent_modifications() {
+    // Set up test project.
+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-data/dapp-sample");
+    let paths = ProjectPathsConfig::builder().sources(root.join("src")).lib(root.join("lib"));
+    let project = TempProject::<MultiCompiler, ConfigurableArtifacts>::new(paths).unwrap();
+
+    // Initial compilation to establish baseline.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+
+    // Set up background task that continuously modifies a file in the output directory.
+    // This simulates concurrent access to the output directory during compilation.
+    let artifacts_path = project.project().artifacts_path();
+    let temporary_file_path = artifacts_path.join("changing.json");
+    let temporary_file_path_clone = temporary_file_path.clone();
+
+    let handle = std::thread::spawn(move || {
+        for index in 0..5 {
+            fs::write(&temporary_file_path_clone, format!("content_{index}")).unwrap();
+            std::thread::sleep(std::time::Duration::from_millis(10));
+        }
+    });
+
+    // Attempt compilation while directory is being modified.
+    // This tests that the cache system properly handles concurrent modifications.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(!compiled.is_unchanged(), "Should detect changes in output directory");
+
+    // Wait for background task to complete and clean up.
+    handle.join().unwrap();
+    if temporary_file_path.exists() {
+        fs::remove_file(temporary_file_path).unwrap();
+    }
+}
+
+#[test]
+fn test_output_hash_nested_directories() {
+    // Set up test project.
+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-data/dapp-sample");
+    let paths = ProjectPathsConfig::builder().sources(root.join("src")).lib(root.join("lib"));
+    let project = TempProject::<MultiCompiler, ConfigurableArtifacts>::new(paths).unwrap();
+
+    let artifacts_path = project.project().artifacts_path();
+
+    // Create nested directory structure.
+    let nested_directory = artifacts_path.join("a/b/c");
+    fs::create_dir_all(&nested_directory).unwrap();
+
+    // Add files at different levels.
+    fs::write(artifacts_path.join("root.json"), "root").unwrap();
+    fs::write(artifacts_path.join("a/level1.json"), "level1").unwrap();
+    fs::write(artifacts_path.join("a/b/level2.json"), "level2").unwrap();
+    fs::write(nested_directory.join("level3.json"), "level3").unwrap();
+
+    // Compile and verify cache behavior.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(!compiled.is_unchanged(), "Should detect nested directory changes");
+
+    // Clean up.
+    fs::remove_dir_all(artifacts_path.join("a")).unwrap();
+    fs::remove_file(artifacts_path.join("root.json")).unwrap();
+
+    // Compile and verify cache behavior.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(compiled.is_unchanged(), "Should detect nested directory changes");
+}
+
+#[test]
+fn test_output_hash_special_filenames() {
+    // Set up test project.
+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-data/dapp-sample");
+    let paths = ProjectPathsConfig::builder().sources(root.join("src")).lib(root.join("lib"));
+    let project = TempProject::<MultiCompiler, ConfigurableArtifacts>::new(paths).unwrap();
+
+    let artifacts_path = project.project().artifacts_path();
+    fs::create_dir_all(artifacts_path).unwrap();
+
+    // Create files with special characters in names.
+    fs::write(artifacts_path.join("space file.json"), "space").unwrap();
+    fs::write(artifacts_path.join("underscore_file.json"), "underscore").unwrap();
+    fs::write(artifacts_path.join("dash-file.json"), "dash").unwrap();
+
+    // Compile and verify cache behavior.
+    let compiled = project.compile().unwrap();
+    compiled.assert_success();
+    assert!(!compiled.is_unchanged(), "Should handle special filenames");
+
+    // Clean up.
+    fs::remove_file(artifacts_path.join("space file.json")).unwrap();
+    fs::remove_file(artifacts_path.join("underscore_file.json")).unwrap();
+    fs::remove_file(artifacts_path.join("dash-file.json")).unwrap();
+}

```
author_association: NONE
user: login: filip-parity
created_at: 2025-05-09T09:34:33Z
body: > why not do something like this? it should also invalidate caches It will remove unrelated build-infos from the out folder
> 
> ```
> diff --git a/crates/compilers/src/buildinfo.rs b/crates/compilers/src/buildinfo.rs
> ...
> +}
> ```

Hmm interesting, **LGTM** (but my Rust knowledge is still in kindergarten). The proposed changes by @pkhry  have even less footprint on the existing codebase. **Any opinion here @smiasojed ?**
author_association: NONE
user: login: smiasojed
created_at: 2025-05-09T10:00:03Z
body: > 
> Hmm interesting, **LGTM** (but my Rust knowledge is still in kindergarten). The proposed changes by @pkhry have even less footprint on the existing codebase. **Any opinion here @smiasojed ?**

We need to find a clean solution if you thing that this is better, then please use it.

author_association: COLLABORATOR
user: login: filip-parity
created_at: 2025-05-09T10:34:09Z
body: > We need to find a clean solution if you thing that this is better, then please use it.

The build info approach recommended by @pkhry actually provides more information because:
- It captures both input and output state;
- It maintains direct linkage between source files and their artifacts;
- It includes compilation settings and dependencies;
- The cleanup of orphaned build files ensures consistency.

---

The new approach is actually more comprehensive and precise than just hashing the output directory. **I updated the current implementation.** Thank you @pkhry !
author_association: NONE