77"""
88
99import os
10+ import re
1011import sys
1112import json
1213import hashlib
@@ -109,6 +110,7 @@ class Fingerprint:
109110 example_hash : Optional [str ]
110111 test_hash : Optional [str ] # Keep for backward compat (primary test file)
111112 test_files : Optional [Dict [str , str ]] = None # Bug #156: {"test_foo.py": "hash1", ...}
113+ include_deps : Optional [Dict [str , str ]] = None # Issue #522: {"path": "hash", ...}
112114
113115
114116@dataclass
@@ -836,6 +838,121 @@ def calculate_sha256(file_path: Path) -> Optional[str]:
836838 return None
837839
838840
841+ _INCLUDE_PATTERN = re .compile (r'<include>(.*?)</include>' )
842+ _BACKTICK_INCLUDE_PATTERN = re .compile (r'```<([^>]*?)>```' )
843+
844+
845+ def _resolve_include_path (include_ref : str , prompt_dir : Path ) -> Optional [Path ]:
846+ """Resolve an <include> reference to an absolute Path."""
847+ p = Path (include_ref )
848+ if p .is_absolute () and p .exists ():
849+ return p
850+ candidate = prompt_dir / include_ref
851+ if candidate .exists ():
852+ return candidate
853+ candidate = Path .cwd () / include_ref
854+ if candidate .exists ():
855+ return candidate
856+ return None
857+
858+
859+ def extract_include_deps (prompt_path : Path ) -> Dict [str , str ]:
860+ """Extract include dependency paths and their hashes from a prompt file.
861+
862+ Returns a dict mapping resolved dependency paths to their SHA256 hashes.
863+ Only includes dependencies that exist on disk.
864+ """
865+ if not prompt_path .exists ():
866+ return {}
867+
868+ try :
869+ prompt_content = prompt_path .read_text (encoding = 'utf-8' , errors = 'ignore' )
870+ except (IOError , OSError ):
871+ return {}
872+
873+ include_refs = _INCLUDE_PATTERN .findall (prompt_content )
874+ include_refs += _BACKTICK_INCLUDE_PATTERN .findall (prompt_content )
875+
876+ if not include_refs :
877+ return {}
878+
879+ deps = {}
880+ prompt_dir = prompt_path .parent
881+ for ref in sorted (set (r .strip () for r in include_refs )):
882+ dep_path = _resolve_include_path (ref , prompt_dir )
883+ if dep_path and dep_path .exists ():
884+ dep_hash = calculate_sha256 (dep_path )
885+ if dep_hash :
886+ deps [str (dep_path )] = dep_hash
887+
888+ return deps
889+
890+
891+ def calculate_prompt_hash (prompt_path : Path , stored_deps : Optional [Dict [str , str ]] = None ) -> Optional [str ]:
892+ """Hash a prompt file including the content of all its <include> dependencies.
893+
894+ If the prompt has <include> tags, extracts and hashes those dependencies.
895+ If no tags are found but stored_deps is provided (from a previous fingerprint),
896+ uses those stored dependency paths to compute the hash. This handles the case
897+ where the auto-deps step strips <include> tags from the prompt file.
898+
899+ Args:
900+ prompt_path: Path to the prompt file.
901+ stored_deps: Previously stored dependency paths from fingerprint (issue #522).
902+
903+ Returns:
904+ SHA256 hex digest of the prompt + dependency contents, or None.
905+ """
906+ if not prompt_path .exists ():
907+ return None
908+
909+ try :
910+ prompt_content = prompt_path .read_text (encoding = 'utf-8' , errors = 'ignore' )
911+ except (IOError , OSError ):
912+ return None
913+
914+ # Try to find include refs in current prompt content
915+ include_refs = _INCLUDE_PATTERN .findall (prompt_content )
916+ include_refs += _BACKTICK_INCLUDE_PATTERN .findall (prompt_content )
917+
918+ # Resolve to actual paths
919+ prompt_dir = prompt_path .parent
920+ dep_paths = []
921+ if include_refs :
922+ for ref in sorted (set (r .strip () for r in include_refs )):
923+ dep_path = _resolve_include_path (ref , prompt_dir )
924+ if dep_path and dep_path .exists ():
925+ dep_paths .append (dep_path )
926+ elif stored_deps :
927+ # No include tags in prompt — use stored dependency paths from fingerprint
928+ for dep_path_str in sorted (stored_deps .keys ()):
929+ dep_path = Path (dep_path_str )
930+ if dep_path .exists ():
931+ dep_paths .append (dep_path )
932+
933+ if not dep_paths :
934+ return calculate_sha256 (prompt_path )
935+
936+ # Build composite hash: prompt bytes + sorted dependency contents
937+ hasher = hashlib .sha256 ()
938+ try :
939+ with open (prompt_path , 'rb' ) as f :
940+ for chunk in iter (lambda : f .read (4096 ), b"" ):
941+ hasher .update (chunk )
942+ except (IOError , OSError ):
943+ return None
944+
945+ for dep_path in dep_paths :
946+ try :
947+ with open (dep_path , 'rb' ) as f :
948+ for chunk in iter (lambda : f .read (4096 ), b"" ):
949+ hasher .update (chunk )
950+ except (IOError , OSError ):
951+ pass
952+
953+ return hasher .hexdigest ()
954+
955+
839956def read_fingerprint (basename : str , language : str ) -> Optional [Fingerprint ]:
840957 """Reads and validates the JSON fingerprint file."""
841958 meta_dir = get_meta_dir ()
@@ -857,7 +974,8 @@ def read_fingerprint(basename: str, language: str) -> Optional[Fingerprint]:
857974 code_hash = data .get ('code_hash' ),
858975 example_hash = data .get ('example_hash' ),
859976 test_hash = data .get ('test_hash' ),
860- test_files = data .get ('test_files' ) # Bug #156
977+ test_files = data .get ('test_files' ), # Bug #156
978+ include_deps = data .get ('include_deps' ), # Issue #522
861979 )
862980 except (json .JSONDecodeError , KeyError , IOError ):
863981 return None
@@ -889,9 +1007,14 @@ def read_run_report(basename: str, language: str) -> Optional[RunReport]:
8891007 return None
8901008
8911009
892- def calculate_current_hashes (paths : Dict [str , Any ]) -> Dict [str , Any ]:
893- """Computes the hashes for all current files on disk."""
894- # Return hash keys that match what the fingerprint expects
1010+ def calculate_current_hashes (paths : Dict [str , Any ], stored_include_deps : Optional [Dict [str , str ]] = None ) -> Dict [str , Any ]:
1011+ """Computes the hashes for all current files on disk.
1012+
1013+ Args:
1014+ paths: Dictionary of PDD file paths.
1015+ stored_include_deps: Previously stored include dependency paths from fingerprint.
1016+ Used when the prompt no longer has <include> tags (issue #522).
1017+ """
8951018 hashes = {}
8961019 for file_type , file_path in paths .items ():
8971020 if file_type == 'test_files' :
@@ -901,6 +1024,22 @@ def calculate_current_hashes(paths: Dict[str, Any]) -> Dict[str, Any]:
9011024 for f in file_path
9021025 if isinstance (f , Path ) and f .exists ()
9031026 }
1027+ elif file_type == 'prompt' and isinstance (file_path , Path ):
1028+ # Issue #522: Hash prompt with <include> dependencies
1029+ hashes ['prompt_hash' ] = calculate_prompt_hash (file_path , stored_deps = stored_include_deps )
1030+ # Also extract current include deps for persistence
1031+ hashes ['include_deps' ] = extract_include_deps (file_path )
1032+ # If no deps found in prompt but we have stored deps, preserve them
1033+ if not hashes ['include_deps' ] and stored_include_deps :
1034+ # Re-hash stored deps to check for changes
1035+ updated_deps = {}
1036+ for dep_path_str , old_hash in stored_include_deps .items ():
1037+ dep_path = Path (dep_path_str )
1038+ if dep_path .exists ():
1039+ new_hash = calculate_sha256 (dep_path )
1040+ if new_hash :
1041+ updated_deps [dep_path_str ] = new_hash
1042+ hashes ['include_deps' ] = updated_deps
9041043 elif isinstance (file_path , Path ):
9051044 hashes [f"{ file_type } _hash" ] = calculate_sha256 (file_path )
9061045 return hashes
@@ -1361,7 +1500,9 @@ def _perform_sync_analysis(basename: str, language: str, target_coverage: float,
13611500 # If the user modified the prompt, we need to regenerate regardless of runtime state
13621501 if fingerprint :
13631502 paths = get_pdd_file_paths (basename , language , prompts_dir , context_override = context_override )
1364- current_prompt_hash = calculate_sha256 (paths ['prompt' ])
1503+ # Issue #522: Use stored include deps so changes to included files are detected
1504+ # even when auto-deps has stripped <include> tags from the prompt
1505+ current_prompt_hash = calculate_prompt_hash (paths ['prompt' ], stored_deps = fingerprint .include_deps )
13651506 if current_prompt_hash and current_prompt_hash != fingerprint .prompt_hash :
13661507 prompt_content = paths ['prompt' ].read_text (encoding = 'utf-8' , errors = 'ignore' ) if paths ['prompt' ].exists () else ""
13671508 has_deps = check_for_dependencies (prompt_content )
@@ -1610,7 +1751,9 @@ def _perform_sync_analysis(basename: str, language: str, target_coverage: float,
16101751
16111752 # 2. Analyze File State
16121753 paths = get_pdd_file_paths (basename , language , prompts_dir , context_override = context_override )
1613- current_hashes = calculate_current_hashes (paths )
1754+ # Issue #522: Pass stored include deps so prompt hash accounts for dependency changes
1755+ stored_deps = fingerprint .include_deps if fingerprint else None
1756+ current_hashes = calculate_current_hashes (paths , stored_include_deps = stored_deps )
16141757
16151758 # 3. Implement the Decision Tree
16161759 if not fingerprint :
0 commit comments