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}'." + )