diff --git a/pdd/operation_log.py b/pdd/operation_log.py
index 180af9351..8ae3b5716 100644
--- a/pdd/operation_log.py
+++ b/pdd/operation_log.py
@@ -231,13 +231,15 @@ def save_fingerprint(
"""
from dataclasses import asdict
from datetime import timezone
- from .sync_determine_operation import calculate_current_hashes, Fingerprint
+ from .sync_determine_operation import calculate_current_hashes, Fingerprint, read_fingerprint
from . import __version__
path = get_fingerprint_path(basename, language)
- # Calculate file hashes from paths (if provided)
- current_hashes = calculate_current_hashes(paths) if paths else {}
+ # Issue #522: Pass stored include deps for prompt hash calculation
+ prev_fp = read_fingerprint(basename, language)
+ stored_deps = prev_fp.include_deps if prev_fp else None
+ current_hashes = calculate_current_hashes(paths, stored_include_deps=stored_deps) if paths else {}
# Create Fingerprint with same format as _save_fingerprint_atomic
fingerprint = Fingerprint(
@@ -249,6 +251,7 @@ def save_fingerprint(
example_hash=current_hashes.get('example_hash'),
test_hash=current_hashes.get('test_hash'),
test_files=current_hashes.get('test_files'),
+ include_deps=current_hashes.get('include_deps'), # Issue #522
)
try:
diff --git a/pdd/sync_determine_operation.py b/pdd/sync_determine_operation.py
index 6b664bad6..8b622b4b1 100644
--- a/pdd/sync_determine_operation.py
+++ b/pdd/sync_determine_operation.py
@@ -7,6 +7,7 @@
"""
import os
+import re
import sys
import json
import hashlib
@@ -109,6 +110,7 @@ class Fingerprint:
example_hash: Optional[str]
test_hash: Optional[str] # Keep for backward compat (primary test file)
test_files: Optional[Dict[str, str]] = None # Bug #156: {"test_foo.py": "hash1", ...}
+ include_deps: Optional[Dict[str, str]] = None # Issue #522: {"path": "hash", ...}
@dataclass
@@ -836,6 +838,121 @@ def calculate_sha256(file_path: Path) -> Optional[str]:
return None
+_INCLUDE_PATTERN = re.compile(r'(.*?)')
+_BACKTICK_INCLUDE_PATTERN = re.compile(r'```<([^>]*?)>```')
+
+
+def _resolve_include_path(include_ref: str, prompt_dir: Path) -> Optional[Path]:
+ """Resolve an reference to an absolute Path."""
+ p = Path(include_ref)
+ if p.is_absolute() and p.exists():
+ return p
+ candidate = prompt_dir / include_ref
+ if candidate.exists():
+ return candidate
+ candidate = Path.cwd() / include_ref
+ if candidate.exists():
+ return candidate
+ return None
+
+
+def extract_include_deps(prompt_path: Path) -> Dict[str, str]:
+ """Extract include dependency paths and their hashes from a prompt file.
+
+ Returns a dict mapping resolved dependency paths to their SHA256 hashes.
+ Only includes dependencies that exist on disk.
+ """
+ if not prompt_path.exists():
+ return {}
+
+ try:
+ prompt_content = prompt_path.read_text(encoding='utf-8', errors='ignore')
+ except (IOError, OSError):
+ return {}
+
+ include_refs = _INCLUDE_PATTERN.findall(prompt_content)
+ include_refs += _BACKTICK_INCLUDE_PATTERN.findall(prompt_content)
+
+ if not include_refs:
+ return {}
+
+ deps = {}
+ prompt_dir = prompt_path.parent
+ for ref in sorted(set(r.strip() for r in include_refs)):
+ dep_path = _resolve_include_path(ref, prompt_dir)
+ if dep_path and dep_path.exists():
+ dep_hash = calculate_sha256(dep_path)
+ if dep_hash:
+ deps[str(dep_path)] = dep_hash
+
+ return deps
+
+
+def calculate_prompt_hash(prompt_path: Path, stored_deps: Optional[Dict[str, str]] = None) -> Optional[str]:
+ """Hash a prompt file including the content of all its dependencies.
+
+ If the prompt has tags, extracts and hashes those dependencies.
+ If no tags are found but stored_deps is provided (from a previous fingerprint),
+ uses those stored dependency paths to compute the hash. This handles the case
+ where the auto-deps step strips tags from the prompt file.
+
+ Args:
+ prompt_path: Path to the prompt file.
+ stored_deps: Previously stored dependency paths from fingerprint (issue #522).
+
+ Returns:
+ SHA256 hex digest of the prompt + dependency contents, or None.
+ """
+ if not prompt_path.exists():
+ return None
+
+ try:
+ prompt_content = prompt_path.read_text(encoding='utf-8', errors='ignore')
+ except (IOError, OSError):
+ return None
+
+ # Try to find include refs in current prompt content
+ include_refs = _INCLUDE_PATTERN.findall(prompt_content)
+ include_refs += _BACKTICK_INCLUDE_PATTERN.findall(prompt_content)
+
+ # Resolve to actual paths
+ prompt_dir = prompt_path.parent
+ dep_paths = []
+ if include_refs:
+ for ref in sorted(set(r.strip() for r in include_refs)):
+ dep_path = _resolve_include_path(ref, prompt_dir)
+ if dep_path and dep_path.exists():
+ dep_paths.append(dep_path)
+ elif stored_deps:
+ # No include tags in prompt — use stored dependency paths from fingerprint
+ for dep_path_str in sorted(stored_deps.keys()):
+ dep_path = Path(dep_path_str)
+ if dep_path.exists():
+ dep_paths.append(dep_path)
+
+ if not dep_paths:
+ return calculate_sha256(prompt_path)
+
+ # Build composite hash: prompt bytes + sorted dependency contents
+ hasher = hashlib.sha256()
+ try:
+ with open(prompt_path, 'rb') as f:
+ for chunk in iter(lambda: f.read(4096), b""):
+ hasher.update(chunk)
+ except (IOError, OSError):
+ return None
+
+ for dep_path in dep_paths:
+ try:
+ with open(dep_path, 'rb') as f:
+ for chunk in iter(lambda: f.read(4096), b""):
+ hasher.update(chunk)
+ except (IOError, OSError):
+ pass
+
+ return hasher.hexdigest()
+
+
def read_fingerprint(basename: str, language: str) -> Optional[Fingerprint]:
"""Reads and validates the JSON fingerprint file."""
meta_dir = get_meta_dir()
@@ -857,7 +974,8 @@ def read_fingerprint(basename: str, language: str) -> Optional[Fingerprint]:
code_hash=data.get('code_hash'),
example_hash=data.get('example_hash'),
test_hash=data.get('test_hash'),
- test_files=data.get('test_files') # Bug #156
+ test_files=data.get('test_files'), # Bug #156
+ include_deps=data.get('include_deps'), # Issue #522
)
except (json.JSONDecodeError, KeyError, IOError):
return None
@@ -889,9 +1007,14 @@ def read_run_report(basename: str, language: str) -> Optional[RunReport]:
return None
-def calculate_current_hashes(paths: Dict[str, Any]) -> Dict[str, Any]:
- """Computes the hashes for all current files on disk."""
- # Return hash keys that match what the fingerprint expects
+def calculate_current_hashes(paths: Dict[str, Any], stored_include_deps: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
+ """Computes the hashes for all current files on disk.
+
+ Args:
+ paths: Dictionary of PDD file paths.
+ stored_include_deps: Previously stored include dependency paths from fingerprint.
+ Used when the prompt no longer has tags (issue #522).
+ """
hashes = {}
for file_type, file_path in paths.items():
if file_type == 'test_files':
@@ -901,6 +1024,22 @@ def calculate_current_hashes(paths: Dict[str, Any]) -> Dict[str, Any]:
for f in file_path
if isinstance(f, Path) and f.exists()
}
+ elif file_type == 'prompt' and isinstance(file_path, Path):
+ # Issue #522: Hash prompt with dependencies
+ hashes['prompt_hash'] = calculate_prompt_hash(file_path, stored_deps=stored_include_deps)
+ # Also extract current include deps for persistence
+ hashes['include_deps'] = extract_include_deps(file_path)
+ # If no deps found in prompt but we have stored deps, preserve them
+ if not hashes['include_deps'] and stored_include_deps:
+ # Re-hash stored deps to check for changes
+ updated_deps = {}
+ for dep_path_str, old_hash in stored_include_deps.items():
+ dep_path = Path(dep_path_str)
+ if dep_path.exists():
+ new_hash = calculate_sha256(dep_path)
+ if new_hash:
+ updated_deps[dep_path_str] = new_hash
+ hashes['include_deps'] = updated_deps
elif isinstance(file_path, Path):
hashes[f"{file_type}_hash"] = calculate_sha256(file_path)
return hashes
@@ -1361,7 +1500,9 @@ def _perform_sync_analysis(basename: str, language: str, target_coverage: float,
# If the user modified the prompt, we need to regenerate regardless of runtime state
if fingerprint:
paths = get_pdd_file_paths(basename, language, prompts_dir, context_override=context_override)
- current_prompt_hash = calculate_sha256(paths['prompt'])
+ # Issue #522: Use stored include deps so changes to included files are detected
+ # even when auto-deps has stripped tags from the prompt
+ current_prompt_hash = calculate_prompt_hash(paths['prompt'], stored_deps=fingerprint.include_deps)
if current_prompt_hash and current_prompt_hash != fingerprint.prompt_hash:
prompt_content = paths['prompt'].read_text(encoding='utf-8', errors='ignore') if paths['prompt'].exists() else ""
has_deps = check_for_dependencies(prompt_content)
@@ -1610,7 +1751,9 @@ def _perform_sync_analysis(basename: str, language: str, target_coverage: float,
# 2. Analyze File State
paths = get_pdd_file_paths(basename, language, prompts_dir, context_override=context_override)
- current_hashes = calculate_current_hashes(paths)
+ # Issue #522: Pass stored include deps so prompt hash accounts for dependency changes
+ stored_deps = fingerprint.include_deps if fingerprint else None
+ current_hashes = calculate_current_hashes(paths, stored_include_deps=stored_deps)
# 3. Implement the Decision Tree
if not fingerprint:
diff --git a/pdd/sync_orchestration.py b/pdd/sync_orchestration.py
index ebeb334ed..f27b32016 100644
--- a/pdd/sync_orchestration.py
+++ b/pdd/sync_orchestration.py
@@ -196,7 +196,8 @@ def _save_run_report_atomic(report: Dict[str, Any], basename: str, language: str
def _save_fingerprint_atomic(basename: str, language: str, operation: str,
paths: Dict[str, Path], cost: float, model: str,
- atomic_state: Optional['AtomicStateUpdate'] = None):
+ atomic_state: Optional['AtomicStateUpdate'] = None,
+ include_deps_override: Optional[Dict[str, str]] = None):
"""Save fingerprint state after successful operation, supporting atomic updates.
Args:
@@ -207,14 +208,26 @@ def _save_fingerprint_atomic(basename: str, language: str, operation: str,
cost: The cost of the operation.
model: The model used.
atomic_state: Optional AtomicStateUpdate for atomic writes (Issue #159 fix).
+ include_deps_override: Pre-captured include deps (Issue #522). Used when
+ auto-deps may have stripped tags before fingerprint save.
"""
if atomic_state:
# Buffer for atomic write
from datetime import datetime, timezone
- from .sync_determine_operation import calculate_current_hashes, Fingerprint
+ from .sync_determine_operation import calculate_current_hashes, Fingerprint, read_fingerprint
from . import __version__
- current_hashes = calculate_current_hashes(paths)
+ # Issue #522: Use override deps if provided (captured before auto-deps),
+ # otherwise fall back to stored deps from previous fingerprint
+ if include_deps_override is not None:
+ stored_deps = include_deps_override
+ else:
+ prev_fp = read_fingerprint(basename, language)
+ stored_deps = prev_fp.include_deps if prev_fp else None
+ current_hashes = calculate_current_hashes(paths, stored_include_deps=stored_deps)
+ # If override provided and current extraction found nothing, use the override
+ if include_deps_override and not current_hashes.get('include_deps'):
+ current_hashes['include_deps'] = include_deps_override
fingerprint = Fingerprint(
pdd_version=__version__,
timestamp=datetime.now(timezone.utc).isoformat(),
@@ -224,6 +237,7 @@ def _save_fingerprint_atomic(basename: str, language: str, operation: str,
example_hash=current_hashes.get('example_hash'),
test_hash=current_hashes.get('test_hash'),
test_files=current_hashes.get('test_files'), # Bug #156
+ include_deps=current_hashes.get('include_deps'), # Issue #522
)
fingerprint_file = META_DIR / f"{_safe_basename(basename)}_{language.lower()}.json"
@@ -1431,6 +1445,7 @@ def sync_worker_logic():
result = {}
success = False
op_start_time = time.time()
+ include_deps_override = None # Issue #522: Captured before auto-deps strips tags
# Issue #159 fix: Use atomic state for consistent run_report + fingerprint writes
with AtomicStateUpdate(basename, language) as atomic_state:
@@ -1440,6 +1455,9 @@ def sync_worker_logic():
if operation == 'auto-deps':
temp_output = str(pdd_files['prompt']).replace('.prompt', '_with_deps.prompt')
original_content = pdd_files['prompt'].read_text(encoding='utf-8')
+ # Issue #522: Capture include deps BEFORE auto-deps may strip tags
+ from .sync_determine_operation import extract_include_deps
+ include_deps_override = extract_include_deps(pdd_files['prompt'])
result = auto_deps_main(
ctx,
prompt_file=str(pdd_files['prompt']),
@@ -1582,7 +1600,7 @@ def __init__(self, rc, out, err):
crash_log_content = f"Auto-fixed: {auto_fix_msg}"
# Fix for issue #430: Save fingerprint and track operation completion before continuing
operations_completed.append('crash')
- _save_fingerprint_atomic(basename, language, 'crash', pdd_files, 0.0, 'auto-fix', atomic_state=atomic_state)
+ _save_fingerprint_atomic(basename, language, 'crash', pdd_files, 0.0, 'auto-fix', atomic_state=atomic_state, include_deps_override=include_deps_override)
continue # Skip crash_main, move to next operation
else:
# Auto-fix didn't fully work, update error log and proceed
@@ -1890,7 +1908,7 @@ def __init__(self, rc, out, err):
model_name = result[2] if len(result) >= 3 and isinstance(result[2], str) else 'unknown'
last_model_name = str(model_name)
operations_completed.append(operation)
- _save_fingerprint_atomic(basename, language, operation, pdd_files, actual_cost, str(model_name), atomic_state=atomic_state)
+ _save_fingerprint_atomic(basename, language, operation, pdd_files, actual_cost, str(model_name), atomic_state=atomic_state, include_deps_override=include_deps_override)
update_log_entry(log_entry, success=success, cost=actual_cost, model=model_name, duration=duration, error=errors[-1] if errors and not success else None)
append_log_entry(basename, language, log_entry)
diff --git a/tests/test_e2e_issue_522_include_fingerprint.py b/tests/test_e2e_issue_522_include_fingerprint.py
new file mode 100644
index 000000000..11f94040a
--- /dev/null
+++ b/tests/test_e2e_issue_522_include_fingerprint.py
@@ -0,0 +1,211 @@
+"""
+E2E regression test for Issue #522: Sync fingerprint ignores dependencies.
+
+When an included file changes but the top-level .prompt file doesn't, sync should
+detect the change and regenerate code. The bug is that calculate_sha256 only hashes
+the raw .prompt file, so included file changes are invisible to the fingerprint system.
+
+Approach 2 fix: Store include dependency paths + hashes in the fingerprint JSON so
+they persist even after auto-deps strips tags from the prompt.
+"""
+
+import hashlib
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+from pdd.sync_determine_operation import (
+ sync_determine_operation,
+ calculate_prompt_hash,
+ extract_include_deps,
+ calculate_sha256,
+)
+
+
+def _sha256(path: Path) -> str:
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def _setup_sync_env(tmp_path, prompt_content, included_files):
+ """
+ Set up a realistic sync environment simulating a completed prior sync.
+ """
+ prompts_dir = tmp_path / "prompts"
+ prompts_dir.mkdir()
+ src_dir = tmp_path / "src"
+ src_dir.mkdir()
+ tests_dir = tmp_path / "tests"
+ tests_dir.mkdir()
+ pdd_dir = tmp_path / ".pdd"
+ meta_dir = pdd_dir / "meta"
+ locks_dir = pdd_dir / "locks"
+ meta_dir.mkdir(parents=True)
+ locks_dir.mkdir(parents=True)
+
+ prompt_file = prompts_dir / "helper_python.prompt"
+ prompt_file.write_text(prompt_content)
+
+ for name, content in included_files.items():
+ (tmp_path / name).write_text(content)
+
+ code_file = src_dir / "helper.py"
+ code_file.write_text("# generated\ndef helper(): pass\n")
+
+ example_file = src_dir / "helper_example.py"
+ example_file.write_text("# example\n")
+
+ test_file = tests_dir / "test_helper.py"
+ test_file.write_text("def test_helper(): assert True\n")
+
+ prompt_hash = calculate_prompt_hash(prompt_file)
+ include_deps = extract_include_deps(prompt_file)
+ code_hash = _sha256(code_file)
+ example_hash = _sha256(example_file)
+ test_hash = _sha256(test_file)
+
+ fp_file = meta_dir / "helper_python.json"
+ fp_file.write_text(json.dumps({
+ "pdd_version": "0.0.156",
+ "prompt_hash": prompt_hash,
+ "code_hash": code_hash,
+ "example_hash": example_hash,
+ "test_hash": test_hash,
+ "command": "test",
+ "timestamp": "2026-02-14T00:00:00+00:00",
+ "include_deps": include_deps,
+ }))
+
+ rr_file = meta_dir / "helper_python_run.json"
+ rr_file.write_text(json.dumps({
+ "timestamp": "2026-02-14T00:00:00+00:00",
+ "exit_code": 0,
+ "tests_passed": 5,
+ "tests_failed": 0,
+ "coverage": 95.0,
+ "test_hash": test_hash,
+ }))
+
+ paths = {
+ "prompt": prompt_file,
+ "code": code_file,
+ "example": example_file,
+ "test": test_file,
+ "test_files": [test_file],
+ }
+
+ return {
+ "paths": paths,
+ "prompts_dir": prompts_dir,
+ }
+
+
+class TestIssue522IncludeFingerprintE2E:
+ """
+ E2E: Full sync_determine_operation with real filesystem state to verify
+ that changes to d files trigger regeneration.
+ """
+
+ def test_included_file_change_triggers_regeneration(self, tmp_path, monkeypatch):
+ """
+ BUG: After a successful sync, modifying an d file without touching
+ the prompt should trigger regeneration.
+ """
+ monkeypatch.chdir(tmp_path)
+
+ prompt_content = (
+ "Create a helper function.\n"
+ "shared_types.py\n"
+ "Generate a function that creates a User object.\n"
+ )
+ env = _setup_sync_env(tmp_path, prompt_content, {
+ "shared_types.py": "class User:\n def __init__(self, name): self.name = name\n",
+ })
+
+ # Modify the included file — this is the bug trigger
+ (tmp_path / "shared_types.py").write_text(
+ "class User:\n def __init__(self, name, age, email): pass\n"
+ )
+
+ with patch("pdd.sync_determine_operation.get_pdd_file_paths", return_value=env["paths"]):
+ decision = sync_determine_operation(
+ basename="helper",
+ language="python",
+ target_coverage=90.0,
+ prompts_dir=str(env["prompts_dir"]),
+ )
+
+ assert decision.operation in ("generate", "auto-deps"), (
+ f"Expected 'generate' or 'auto-deps' because d file changed, "
+ f"but got '{decision.operation}'. "
+ f"Fingerprint must account for included file content."
+ )
+
+ def test_tags_stripped_dep_change_still_detected(self, tmp_path, monkeypatch):
+ """
+ Greg's exact scenario: auto-deps strips tags, then dep changes.
+ Stored include_deps in fingerprint must catch it.
+ """
+ monkeypatch.chdir(tmp_path)
+
+ # Step 1: First sync with include tags
+ prompt_content_with_tags = (
+ "Create a helper function.\n"
+ "shared_types.py\n"
+ )
+ env = _setup_sync_env(tmp_path, prompt_content_with_tags, {
+ "shared_types.py": "class User:\n def __init__(self, name): self.name = name\n",
+ })
+
+ # Step 2: Simulate auto-deps stripping tags (rewrites prompt)
+ prompt_file = env["paths"]["prompt"]
+ stripped_content = (
+ "Create a helper function.\n"
+ "class User:\n def __init__(self, name): self.name = name\n"
+ )
+ prompt_file.write_text(stripped_content)
+
+ # Step 3: Change the dependency file
+ (tmp_path / "shared_types.py").write_text(
+ "class User:\n def __init__(self, name, age): pass\n"
+ )
+
+ with patch("pdd.sync_determine_operation.get_pdd_file_paths", return_value=env["paths"]):
+ decision = sync_determine_operation(
+ basename="helper",
+ language="python",
+ target_coverage=90.0,
+ prompts_dir=str(env["prompts_dir"]),
+ )
+
+ assert decision.operation in ("generate", "auto-deps"), (
+ f"Expected regeneration because stored include dep changed, "
+ f"but got '{decision.operation}'. "
+ f"Stored include_deps must persist across tag stripping."
+ )
+
+ def test_no_change_returns_nothing(self, tmp_path, monkeypatch):
+ """
+ Baseline: When nothing changes, sync should not regenerate.
+ """
+ monkeypatch.chdir(tmp_path)
+
+ prompt_content = (
+ "Create a helper function.\n"
+ "shared_types.py\n"
+ )
+ env = _setup_sync_env(tmp_path, prompt_content, {
+ "shared_types.py": "class User:\n pass\n",
+ })
+
+ with patch("pdd.sync_determine_operation.get_pdd_file_paths", return_value=env["paths"]):
+ decision = sync_determine_operation(
+ basename="helper",
+ language="python",
+ target_coverage=90.0,
+ prompts_dir=str(env["prompts_dir"]),
+ )
+
+ assert decision.operation in ("nothing", "all_synced", "verify"), (
+ f"Expected 'nothing'/'all_synced'/'verify' when nothing changed, "
+ f"got '{decision.operation}'. Fix must not cause false positives."
+ )
diff --git a/tests/test_sync_determine_operation.py b/tests/test_sync_determine_operation.py
index 2ac7a186f..120f589c1 100644
--- a/tests/test_sync_determine_operation.py
+++ b/tests/test_sync_determine_operation.py
@@ -23,6 +23,8 @@
RunReport,
SyncDecision,
calculate_sha256,
+ calculate_prompt_hash,
+ extract_include_deps,
read_fingerprint,
read_run_report,
PDD_DIR,
@@ -3456,3 +3458,332 @@ def test_nonzero_exit_code_with_actual_failures_should_trigger_fix(self, pdd_tes
f"exit_code=1 with tests_failed=2 should trigger 'fix', got '{decision.operation}'\n"
f"Real test failures must still be handled."
)
+
+
+# --- GitHub Issue #522: Fingerprint ignores dependencies ---
+
+class TestFingerprintIncludeDependencies:
+ """
+ Regression tests for GitHub issue #522: sync fingerprint ignores
+ dependencies. When an included file changes but the top-level .prompt file
+ doesn't, sync should detect the change and regenerate.
+
+ Approach 2: Store include dependency paths + hashes in the fingerprint JSON
+ so changes are detected even after auto-deps strips tags.
+ """
+
+ def test_extract_include_deps_finds_xml_includes(self, pdd_test_environment):
+ """extract_include_deps should find tags and hash the files."""
+ prompts_dir = pdd_test_environment / "prompts"
+ dep_file = pdd_test_environment / "shared_types.py"
+ create_file(dep_file, "class User:\n name: str\n")
+
+ prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt"
+ create_file(prompt_path, "Create a helper.\nshared_types.py\n")
+
+ deps = extract_include_deps(prompt_path)
+ assert len(deps) == 1, f"Expected 1 include dep, got {len(deps)}"
+ assert str(dep_file) in deps or any("shared_types.py" in k for k in deps)
+
+ def test_extract_include_deps_finds_backtick_includes(self, pdd_test_environment):
+ """extract_include_deps should find `````` backtick includes."""
+ prompts_dir = pdd_test_environment / "prompts"
+ dep_file = pdd_test_environment / "utils.py"
+ create_file(dep_file, "def helper(): pass\n")
+
+ prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt"
+ create_file(prompt_path, "Build module.\n``````\n")
+
+ deps = extract_include_deps(prompt_path)
+ assert len(deps) == 1, f"Expected 1 backtick include dep, got {len(deps)}"
+
+ def test_extract_include_deps_empty_when_no_includes(self, pdd_test_environment):
+ """extract_include_deps should return empty dict for prompts without includes."""
+ prompts_dir = pdd_test_environment / "prompts"
+ prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt"
+ create_file(prompt_path, "Simple prompt with no includes.\n")
+
+ deps = extract_include_deps(prompt_path)
+ assert deps == {}, f"Expected empty dict, got {deps}"
+
+ def test_calculate_prompt_hash_with_stored_deps(self, pdd_test_environment):
+ """calculate_prompt_hash should use stored deps when prompt has no include tags."""
+ prompts_dir = pdd_test_environment / "prompts"
+ dep_file = pdd_test_environment / "shared_types.py"
+ create_file(dep_file, "class User:\n name: str\n")
+
+ # Prompt WITHOUT include tags (simulates post-auto-deps state)
+ prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt"
+ create_file(prompt_path, "Create a helper using User class.\n")
+
+ stored_deps = {str(dep_file): calculate_sha256(dep_file)}
+
+ # Hash with stored deps should differ from hash without
+ hash_without = calculate_prompt_hash(prompt_path)
+ hash_with = calculate_prompt_hash(prompt_path, stored_deps=stored_deps)
+
+ assert hash_without != hash_with, (
+ "Hash with stored deps should differ from hash without — "
+ "stored deps should contribute to the composite hash"
+ )
+
+ def test_calculate_prompt_hash_detects_dep_change_via_stored_deps(self, pdd_test_environment):
+ """When a stored dep file changes, the composite hash must change."""
+ prompts_dir = pdd_test_environment / "prompts"
+ dep_file = pdd_test_environment / "shared_types.py"
+ create_file(dep_file, "class User:\n name: str\n")
+
+ prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt"
+ create_file(prompt_path, "Create a helper using User class.\n")
+
+ stored_deps = {str(dep_file): calculate_sha256(dep_file)}
+ hash_before = calculate_prompt_hash(prompt_path, stored_deps=stored_deps)
+
+ # Change the dependency file
+ create_file(dep_file, "class User:\n name: str\n email: str\n")
+
+ hash_after = calculate_prompt_hash(prompt_path, stored_deps=stored_deps)
+
+ assert hash_before != hash_after, (
+ "Composite prompt hash must change when a stored dependency file changes, "
+ "even when the prompt itself has no tags"
+ )
+
+ def test_fingerprint_stores_include_deps(self, pdd_test_environment):
+ """Fingerprint dataclass should correctly store and serialize include_deps."""
+ fp = Fingerprint(
+ pdd_version="0.0.156",
+ timestamp="2026-02-23T00:00:00Z",
+ command="test",
+ prompt_hash="abc",
+ code_hash="def",
+ example_hash="ghi",
+ test_hash="jkl",
+ include_deps={"shared_types.py": "hash1", "utils.py": "hash2"},
+ )
+ from dataclasses import asdict
+ d = asdict(fp)
+ assert d["include_deps"] == {"shared_types.py": "hash1", "utils.py": "hash2"}
+
+ def test_fingerprint_include_deps_backward_compat(self, pdd_test_environment):
+ """Old fingerprint files without include_deps should load with include_deps=None."""
+ fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json"
+ create_fingerprint_file(fp_path, {
+ "pdd_version": "0.0.145",
+ "timestamp": "2026-01-01T00:00:00Z",
+ "command": "generate",
+ "prompt_hash": "abc",
+ "code_hash": "def",
+ "example_hash": "ghi",
+ "test_hash": "jkl",
+ })
+
+ fp = read_fingerprint(BASENAME, LANGUAGE)
+ assert fp is not None
+ assert fp.include_deps is None, "Old fingerprints should have include_deps=None"
+
+ def test_fingerprint_with_include_deps_loads_correctly(self, pdd_test_environment):
+ """Fingerprint with include_deps field should load correctly."""
+ fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json"
+ create_fingerprint_file(fp_path, {
+ "pdd_version": "0.0.156",
+ "timestamp": "2026-02-23T00:00:00Z",
+ "command": "generate",
+ "prompt_hash": "abc",
+ "code_hash": "def",
+ "example_hash": "ghi",
+ "test_hash": "jkl",
+ "include_deps": {"shared.py": "hash1"},
+ })
+
+ fp = read_fingerprint(BASENAME, LANGUAGE)
+ assert fp is not None
+ assert fp.include_deps == {"shared.py": "hash1"}
+
+ def test_included_file_change_triggers_regeneration(self, pdd_test_environment):
+ """
+ Primary bug reproduction (Greg's scenario): After auto-deps strips
+ tags, changing the included file should still trigger regeneration via stored deps.
+ """
+ prompts_dir = pdd_test_environment / "prompts"
+ prompts_dir.mkdir(exist_ok=True)
+
+ # Create dependency file
+ dep_file = pdd_test_environment / "shared_types.py"
+ create_file(dep_file, "class User:\n def __init__(self, name): self.name = name\n")
+
+ # Prompt WITH includes (first sync)
+ prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt"
+ prompt_content_with_tags = "Create a helper.\nshared_types.py\n"
+ create_file(prompt_path, prompt_content_with_tags)
+
+ # Calculate what hash the first sync would have saved
+ first_sync_hash = calculate_prompt_hash(prompt_path)
+ first_sync_deps = extract_include_deps(prompt_path)
+
+ # Simulate auto-deps stripping the include tags (rewrites .prompt)
+ prompt_content_stripped = "Create a helper.\nclass User:\n def __init__(self, name): self.name = name\n"
+ create_file(prompt_path, prompt_content_stripped)
+
+ # Create fingerprint from "first sync" with stored deps
+ fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json"
+ create_fingerprint_file(fp_path, {
+ "pdd_version": "0.0.156",
+ "timestamp": "2026-02-23T00:00:00Z",
+ "command": "test",
+ "prompt_hash": first_sync_hash,
+ "code_hash": None,
+ "example_hash": None,
+ "test_hash": None,
+ "include_deps": first_sync_deps,
+ })
+
+ # Create code/example/test files
+ paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts")
+ code_hash = create_file(paths['code'], "def helper(): pass")
+ example_hash = create_file(paths['example'], "helper()")
+ test_hash = create_file(paths['test'], "def test_helper(): pass")
+
+ # Update fingerprint with file hashes
+ create_fingerprint_file(fp_path, {
+ "pdd_version": "0.0.156",
+ "timestamp": "2026-02-23T00:00:00Z",
+ "command": "test",
+ "prompt_hash": first_sync_hash,
+ "code_hash": code_hash,
+ "example_hash": example_hash,
+ "test_hash": test_hash,
+ "include_deps": first_sync_deps,
+ })
+
+ # Create passing run report
+ rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json"
+ create_run_report_file(rr_path, {
+ "timestamp": "2026-02-23T00:00:00Z",
+ "exit_code": 0,
+ "tests_passed": 5,
+ "tests_failed": 0,
+ "coverage": 95.0,
+ })
+
+ # NOW change the included file (this is the bug trigger)
+ create_file(dep_file, "class User:\n def __init__(self, name, age, email): pass\n")
+
+ decision = sync_determine_operation(
+ BASENAME, LANGUAGE, TARGET_COVERAGE,
+ prompts_dir=str(prompts_dir)
+ )
+
+ assert decision.operation in ('generate', 'auto-deps'), (
+ f"Expected 'generate' or 'auto-deps' because included file changed "
+ f"(via stored deps), but got '{decision.operation}'. "
+ f"Stored include_deps in fingerprint must detect dependency changes "
+ f"even when auto-deps has stripped tags from the prompt."
+ )
+
+ def test_no_change_no_false_positive_with_stored_deps(self, pdd_test_environment):
+ """When nothing changes, stored deps must not cause false positive regeneration."""
+ prompts_dir = pdd_test_environment / "prompts"
+ prompts_dir.mkdir(exist_ok=True)
+
+ dep_file = pdd_test_environment / "shared_types.py"
+ create_file(dep_file, "class User:\n pass\n")
+
+ # Prompt WITH includes
+ prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt"
+ create_file(prompt_path, "Create a helper.\nshared_types.py\n")
+
+ prompt_hash = calculate_prompt_hash(prompt_path)
+ include_deps = extract_include_deps(prompt_path)
+
+ paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts")
+ code_hash = create_file(paths['code'], "def helper(): pass")
+ example_hash = create_file(paths['example'], "helper()")
+ test_hash = create_file(paths['test'], "def test_helper(): pass")
+
+ fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json"
+ create_fingerprint_file(fp_path, {
+ "pdd_version": "0.0.156",
+ "timestamp": "2026-02-23T00:00:00Z",
+ "command": "test",
+ "prompt_hash": prompt_hash,
+ "code_hash": code_hash,
+ "example_hash": example_hash,
+ "test_hash": test_hash,
+ "include_deps": include_deps,
+ })
+
+ rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json"
+ create_run_report_file(rr_path, {
+ "timestamp": "2026-02-23T00:00:00Z",
+ "exit_code": 0,
+ "tests_passed": 5,
+ "tests_failed": 0,
+ "coverage": 95.0,
+ })
+
+ # Nothing changed
+ decision = sync_determine_operation(
+ BASENAME, LANGUAGE, TARGET_COVERAGE,
+ prompts_dir=str(prompts_dir)
+ )
+
+ assert decision.operation not in ('generate', 'auto-deps'), (
+ f"Expected no regeneration when nothing changed, got '{decision.operation}'. "
+ f"Stored include_deps must not cause false positives."
+ )
+
+ def test_one_of_multiple_deps_changes(self, pdd_test_environment):
+ """When one of multiple stored deps changes, sync should detect it."""
+ prompts_dir = pdd_test_environment / "prompts"
+ prompts_dir.mkdir(exist_ok=True)
+
+ dep1 = pdd_test_environment / "types.py"
+ dep2 = pdd_test_environment / "utils.py"
+ create_file(dep1, "class Foo: pass")
+ create_file(dep2, "def bar(): pass")
+
+ prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt"
+ create_file(prompt_path, "Build module.\ntypes.py\nutils.py\n")
+
+ prompt_hash = calculate_prompt_hash(prompt_path)
+ include_deps = extract_include_deps(prompt_path)
+
+ paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts")
+ code_hash = create_file(paths['code'], "def module(): pass")
+ example_hash = create_file(paths['example'], "module()")
+ test_hash = create_file(paths['test'], "def test_module(): pass")
+
+ fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json"
+ create_fingerprint_file(fp_path, {
+ "pdd_version": "0.0.156",
+ "timestamp": "2026-02-23T00:00:00Z",
+ "command": "test",
+ "prompt_hash": prompt_hash,
+ "code_hash": code_hash,
+ "example_hash": example_hash,
+ "test_hash": test_hash,
+ "include_deps": include_deps,
+ })
+
+ rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json"
+ create_run_report_file(rr_path, {
+ "timestamp": "2026-02-23T00:00:00Z",
+ "exit_code": 0,
+ "tests_passed": 1,
+ "tests_failed": 0,
+ "coverage": 90.0,
+ })
+
+ # Change only dep2
+ create_file(dep2, "def bar(): return 42")
+
+ decision = sync_determine_operation(
+ BASENAME, LANGUAGE, TARGET_COVERAGE,
+ prompts_dir=str(prompts_dir)
+ )
+
+ assert decision.operation in ('generate', 'auto-deps'), (
+ f"Expected regeneration when one of multiple included files changed, "
+ f"got '{decision.operation}'."
+ )