Repository: lmstudio-ai/venvstacks

Issue:
[Regression] 0.5.0: layer locks are invalidated if input files aren't checked in
The stricter layer lock invalidation in 0.5.0 is overly strict for use cases where only the locked requirements files and their associated metadata files are being checked in, not the temporary `*.in` requirements input files (which are intended to just be a working copy of the requirements in the TOML layer spec).

In the case where the input file doesn't exist, but the layer lock metadata file does exist, the lock input file should just be written out (without marking the layer lock as invalid) if the metadata input hash matches the hash calculated from the requirements in the TOML file.

Related issues to be investigated while working on this one:

* the lock hash update script also needs to handle the case where the requirements input files aren't being checked in
* the lock hash update script should handle the "other_inputs_hash" field (this may involve allowing the update script to depend on the main venvstacks library, since updating that field requires parsing the layer dependency tree)

Discussion:
@ncoghlan: Resolved in #178 

Solution patch:
diff --git a/docs/changelog.d/20250522_221607_ncoghlan_make_lock_input_files_a_pure_cache.rst b/docs/changelog.d/20250522_221607_ncoghlan_make_lock_input_files_a_pure_cache.rst
new file mode 100644
index 0000000..6771bf6
--- /dev/null
+++ b/docs/changelog.d/20250522_221607_ncoghlan_make_lock_input_files_a_pure_cache.rst
@@ -0,0 +1,5 @@
+Fixed
+-----
+
+- Layer locks are no longer incorrectly marked as invalid solely because the lock
+  input cache files are missing (reported in :issue:`175`).
diff --git a/src/venvstacks/stacks.py b/src/venvstacks/stacks.py
index 85cd212..09ee345 100755
--- a/src/venvstacks/stacks.py
+++ b/src/venvstacks/stacks.py
@@ -223,8 +223,8 @@ class EnvironmentLock:
     def __post_init__(self) -> None:
         req_path = self.locked_requirements_path
         self._lock_input_path = req_path.with_suffix(".in")
-        self._update_hashes()
         self._lock_metadata_path = Path(f"{req_path}.json")
+        self._update_hashes()
         self._last_locked = None
         self._lock_version = None
         self._update_from_valid_lock_info()
@@ -338,11 +338,12 @@ class EnvironmentLock:
         self._update_other_inputs_hash()
         self._other_inputs_hash = _hash_strings(self.other_inputs)
         self._lock_input_hash = input_hash = self._hash_reqs(self.declared_requirements)
-        if self._hash_req_file(self._lock_input_path) != input_hash:
-            # Declared requirements input file is out of date, so locked output is not valid
+        last_metadata = self._load_saved_metadata()
+        if last_metadata is None or last_metadata.get("lock_input_hash") != input_hash:
+            # Declared requirements hash has changed, so locked output is not valid
             req_hash = None
         else:
-            # Input file is consistent, so also check the locked output hash
+            # Declared requirements hash is consistent, so also check the locked output hash
             req_hash = self._hash_req_file(self.locked_requirements_path)
         self._requirements_hash = req_hash
 
@@ -427,10 +428,20 @@ class EnvironmentLock:
             return lock_metadata.get("lock_version", 1)
         return None
 
+    def _clear_lock_input_cache(self) -> bool:
+        # Lock input path should serve as a pure cache of info from the TOML stack spec
+        # Allow the test suite to ensure nothing depends on the input file existing
+        path_to_remove = self._lock_input_path
+        if path_to_remove.exists():
+            path_to_remove.unlink()
+            return True
+        return False
+
     def _purge_lock(self) -> bool:
         # Currently a test suite helper, but may become a public API if it proves
         # useful when implementing https://github.com/lmstudio-ai/venvstacks/issues/10
-        files_removed = False
+        # However, a public version may need to preserve the lock version info by default
+        files_removed = self._clear_lock_input_cache()
         for path_to_remove in (self.locked_requirements_path, self._lock_metadata_path):
             if path_to_remove.exists():
                 path_to_remove.unlink()


Test patch:
diff --git a/tests/test_minimal_project.py b/tests/test_minimal_project.py
index abe84f9..bd94175 100644
--- a/tests/test_minimal_project.py
+++ b/tests/test_minimal_project.py
@@ -529,6 +529,25 @@ class TestMinimalBuildConfigWithExistingLockFiles(unittest.TestCase):
         self.assertTrue(all(env.needs_lock() for env in envs_to_lock))
         self.assertTrue(build_env._needs_lock())
 
+    def test_lock_input_cache_is_optional(self):
+        build_env = self.build_env
+        stack_spec = build_env.stack_spec
+        # Lock input files should serve as a pure cache
+        for env in build_env.all_environments():
+            env.env_lock._clear_lock_input_cache()
+        # Create a new instance using the same input and build folders
+        new_env = stack_spec.define_build_environment(build_env.build_path)
+        del build_env  # Ensure all checks are run against the new instance
+        expected_names = [env.env_name for env in EXPECTED_ENVIRONMENTS]
+        all_names = [env.env_name for env in new_env.all_environments()]
+        self.assertEqual(all_names, expected_names)
+        # Lock files still exist, so no envs should need locking
+        unlocked = [
+            env.env_name for env in new_env.all_environments() if env.needs_lock()
+        ]
+        self.assertEqual(unlocked, [])
+        self.assertFalse(new_env._needs_lock())
+
 
 class TestMinimalBuild(DeploymentTestCase):
     # Test cases that actually create the build environment folders