diff --git a/pdd/operation_log.py b/pdd/operation_log.py index 34ef9a5a..246dd677 100644 --- a/pdd/operation_log.py +++ b/pdd/operation_log.py @@ -211,7 +211,8 @@ def save_fingerprint( operation: str, paths: Optional[Dict[str, Path]] = None, cost: float = 0.0, - model: str = "unknown" + model: str = "unknown", + test_prompt_hash: Optional[str] = None ) -> None: """ Save the current fingerprint/state to the state file. @@ -219,10 +220,17 @@ def save_fingerprint( Writes the full Fingerprint dataclass format compatible with read_fingerprint() in sync_determine_operation.py. This ensures manual commands (generate, example) don't break sync's fingerprint tracking. + + Args: + test_prompt_hash: Issue #203 - Hash of prompt when tests were generated. + If None, automatically determined based on operation: + - generate: None (tests now stale) + - test: current prompt hash (tests updated) + - other: preserved from existing 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) @@ -230,6 +238,20 @@ def save_fingerprint( # Calculate file hashes from paths (if provided) current_hashes = calculate_current_hashes(paths) if paths else {} + # Issue #203: Determine test_prompt_hash based on operation type + # This mirrors the logic in sync_orchestration._save_fingerprint_atomic + if test_prompt_hash is None: + if operation == 'generate': + # Code regenerated, tests are now stale + test_prompt_hash = None + elif operation == 'test': + # Tests regenerated, link to current prompt + test_prompt_hash = current_hashes.get('prompt_hash') + else: + # Other operations: preserve existing value + existing_fp = read_fingerprint(basename, language) + test_prompt_hash = existing_fp.test_prompt_hash if existing_fp else None + # Create Fingerprint with same format as _save_fingerprint_atomic fingerprint = Fingerprint( pdd_version=__version__, @@ -240,6 +262,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'), + test_prompt_hash=test_prompt_hash, # Issue #203 ) try: diff --git a/pdd/sync_determine_operation.py b/pdd/sync_determine_operation.py index 60a1d67e..379acdbd 100644 --- a/pdd/sync_determine_operation.py +++ b/pdd/sync_determine_operation.py @@ -109,6 +109,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", ...} + test_prompt_hash: Optional[str] = None # Issue #203: Hash of prompt when tests were generated @dataclass @@ -782,7 +783,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 + test_prompt_hash=data.get('test_prompt_hash') # Issue #203 ) except (json.JSONDecodeError, KeyError, IOError): return None @@ -1557,6 +1559,26 @@ def _perform_sync_analysis(basename: str, language: str, target_coverage: float, if not changes: # No Changes (Hashes Match Fingerprint) - Progress workflow with skip awareness + + # Issue #203: Check if tests are stale (generated from old prompt version) + # Even if workflow appears complete, tests may need regeneration if prompt changed + if (not skip_tests and fingerprint and paths['test'].exists() and + fingerprint.test_prompt_hash is not None and + fingerprint.test_prompt_hash != current_hashes.get('prompt_hash')): + return SyncDecision( + operation='test', + reason='Tests outdated - generated from old prompt version, need regeneration', + confidence=0.90, + estimated_cost=estimate_operation_cost('test'), + details={ + 'decision_type': 'heuristic', + 'test_prompt_hash': fingerprint.test_prompt_hash, + 'current_prompt_hash': current_hashes.get('prompt_hash'), + 'tests_stale': True, + 'workflow_stage': 'test_regeneration_for_prompt_change' + } + ) + if _is_workflow_complete(paths, skip_tests, skip_verify, basename, language): return SyncDecision( operation='nothing', diff --git a/pdd/sync_orchestration.py b/pdd/sync_orchestration.py index af2674d8..9a6c9f50 100644 --- a/pdd/sync_orchestration.py +++ b/pdd/sync_orchestration.py @@ -196,6 +196,9 @@ def _save_fingerprint_atomic(basename: str, language: str, operation: str, model: The model used. atomic_state: Optional AtomicStateUpdate for atomic writes (Issue #159 fix). """ + # Issue #203: Import read_fingerprint once for both branches + from .sync_determine_operation import read_fingerprint + if atomic_state: # Buffer for atomic write from datetime import datetime, timezone @@ -203,6 +206,22 @@ def _save_fingerprint_atomic(basename: str, language: str, operation: str, from . import __version__ current_hashes = calculate_current_hashes(paths) + + # Issue #203: Determine test_prompt_hash based on operation + # - 'generate': Reset to None (tests become stale since code changed) + # - 'test': Set to current prompt_hash (tests are now up-to-date with prompt) + # - Other operations: Preserve existing test_prompt_hash + existing_fingerprint = read_fingerprint(basename, language) + if operation == 'generate': + # Code regenerated - tests are now stale + test_prompt_hash = None + elif operation == 'test': + # Tests regenerated - link them to current prompt version + test_prompt_hash = current_hashes.get('prompt_hash') + else: + # Preserve existing test_prompt_hash for other operations + test_prompt_hash = existing_fingerprint.test_prompt_hash if existing_fingerprint else None + fingerprint = Fingerprint( pdd_version=__version__, timestamp=datetime.now(timezone.utc).isoformat(), @@ -212,13 +231,18 @@ 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 + test_prompt_hash=test_prompt_hash, # Issue #203 ) fingerprint_file = META_DIR / f"{_safe_basename(basename)}_{language}.json" atomic_state.set_fingerprint(asdict(fingerprint), fingerprint_file) else: # Direct write using operation_log - save_fingerprint(basename, language, operation, paths, cost, model) + # Issue #203: Preserve test_prompt_hash from existing fingerprint for skip operations + existing_fp = read_fingerprint(basename, language) + existing_test_prompt_hash = existing_fp.test_prompt_hash if existing_fp else None + save_fingerprint(basename, language, operation, paths, cost, model, + test_prompt_hash=existing_test_prompt_hash) def _python_cov_target_for_code_file(code_file: Path) -> str: """Return a `pytest-cov` `--cov` target for a Python code file. diff --git a/tests/test_operation_log.py b/tests/test_operation_log.py index 22fddf76..cad3701b 100644 --- a/tests/test_operation_log.py +++ b/tests/test_operation_log.py @@ -623,4 +623,236 @@ def test_fingerprint_hash_compatibility_with_sync(tmp_path): assert result.command == "generate" # Verify pdd_version is set - assert result.pdd_version is not None, "pdd_version should be set" \ No newline at end of file + assert result.pdd_version is not None, "pdd_version should be set" + + +# -------------------------------------------------------------------------------- +# ISSUE #203: test_prompt_hash auto-management in save_fingerprint +# -------------------------------------------------------------------------------- + +class TestIssue203TestPromptHashManagement: + """Test that save_fingerprint automatically manages test_prompt_hash based on operation type.""" + + def test_generate_operation_sets_test_prompt_hash_to_none(self, tmp_path): + """ + Issue #203: When operation='generate', test_prompt_hash should be None + because code was regenerated and tests are now stale. + """ + from pdd.operation_log import save_fingerprint + from pdd.sync_determine_operation import read_fingerprint + + basename = "gen_test" + language = "python" + + meta_dir = tmp_path / ".pdd" / "meta" + meta_dir.mkdir(parents=True) + + # Create existing fingerprint with test_prompt_hash set + existing_fp = meta_dir / f"{basename}_{language}.json" + existing_fp.write_text(json.dumps({ + "pdd_version": "0.0.1", + "timestamp": "2024-01-01T00:00:00", + "command": "test", + "prompt_hash": "old_prompt_hash", + "code_hash": None, + "example_hash": None, + "test_hash": None, + "test_files": None, + "test_prompt_hash": "existing_test_prompt_hash" + })) + + with patch("pdd.operation_log.META_DIR", str(meta_dir)), \ + patch("pdd.sync_determine_operation.get_meta_dir", return_value=meta_dir): + + # Call save_fingerprint with operation='generate' (no explicit test_prompt_hash) + save_fingerprint( + basename=basename, + language=language, + operation="generate", + paths={}, + cost=0.1, + model="test" + ) + + # Read back and verify test_prompt_hash is None + result = read_fingerprint(basename, language) + assert result is not None + assert result.test_prompt_hash is None, ( + "generate operation should set test_prompt_hash to None (tests now stale)" + ) + + def test_test_operation_sets_test_prompt_hash_to_current(self, tmp_path): + """ + Issue #203: When operation='test', test_prompt_hash should be set to + the current prompt hash (tests regenerated, linked to current prompt). + """ + from pdd.operation_log import save_fingerprint + from pdd.sync_determine_operation import read_fingerprint + + basename = "test_op_test" + language = "python" + + meta_dir = tmp_path / ".pdd" / "meta" + prompts_dir = tmp_path / "prompts" + meta_dir.mkdir(parents=True) + prompts_dir.mkdir(parents=True) + + # Create a prompt file with known content + prompt_file = prompts_dir / f"{basename}_{language}.prompt" + prompt_file.write_text("% Test prompt content\n") + + paths = {"prompt": prompt_file} + + with patch("pdd.operation_log.META_DIR", str(meta_dir)), \ + patch("pdd.sync_determine_operation.get_meta_dir", return_value=meta_dir): + + # Call save_fingerprint with operation='test' + save_fingerprint( + basename=basename, + language=language, + operation="test", + paths=paths, + cost=0.1, + model="test" + ) + + # Read back and verify test_prompt_hash equals prompt_hash + result = read_fingerprint(basename, language) + assert result is not None + assert result.prompt_hash is not None, "prompt_hash should be calculated" + assert result.test_prompt_hash == result.prompt_hash, ( + "test operation should set test_prompt_hash to current prompt_hash" + ) + + def test_example_operation_preserves_test_prompt_hash(self, tmp_path): + """ + Issue #203: When operation is not 'generate' or 'test', the existing + test_prompt_hash should be preserved. + """ + from pdd.operation_log import save_fingerprint + from pdd.sync_determine_operation import read_fingerprint + + basename = "example_test" + language = "python" + + meta_dir = tmp_path / ".pdd" / "meta" + meta_dir.mkdir(parents=True) + + existing_test_prompt_hash = "preserved_hash_value" + + # Create existing fingerprint with test_prompt_hash set + existing_fp = meta_dir / f"{basename}_{language}.json" + existing_fp.write_text(json.dumps({ + "pdd_version": "0.0.1", + "timestamp": "2024-01-01T00:00:00", + "command": "test", + "prompt_hash": "some_hash", + "code_hash": None, + "example_hash": None, + "test_hash": None, + "test_files": None, + "test_prompt_hash": existing_test_prompt_hash + })) + + with patch("pdd.operation_log.META_DIR", str(meta_dir)), \ + patch("pdd.sync_determine_operation.get_meta_dir", return_value=meta_dir): + + # Call save_fingerprint with operation='example' + save_fingerprint( + basename=basename, + language=language, + operation="example", + paths={}, + cost=0.1, + model="test" + ) + + # Read back and verify test_prompt_hash is preserved + result = read_fingerprint(basename, language) + assert result is not None + assert result.test_prompt_hash == existing_test_prompt_hash, ( + "example operation should preserve existing test_prompt_hash" + ) + + def test_fix_operation_preserves_test_prompt_hash(self, tmp_path): + """ + Issue #203: Fix operation should also preserve existing test_prompt_hash. + """ + from pdd.operation_log import save_fingerprint + from pdd.sync_determine_operation import read_fingerprint + + basename = "fix_test" + language = "python" + + meta_dir = tmp_path / ".pdd" / "meta" + meta_dir.mkdir(parents=True) + + existing_test_prompt_hash = "fix_preserved_hash" + + # Create existing fingerprint + existing_fp = meta_dir / f"{basename}_{language}.json" + existing_fp.write_text(json.dumps({ + "pdd_version": "0.0.1", + "timestamp": "2024-01-01T00:00:00", + "command": "test", + "prompt_hash": "some_hash", + "code_hash": None, + "example_hash": None, + "test_hash": None, + "test_files": None, + "test_prompt_hash": existing_test_prompt_hash + })) + + with patch("pdd.operation_log.META_DIR", str(meta_dir)), \ + patch("pdd.sync_determine_operation.get_meta_dir", return_value=meta_dir): + + save_fingerprint( + basename=basename, + language=language, + operation="fix", + paths={}, + cost=0.1, + model="test" + ) + + result = read_fingerprint(basename, language) + assert result is not None + assert result.test_prompt_hash == existing_test_prompt_hash, ( + "fix operation should preserve existing test_prompt_hash" + ) + + def test_explicit_test_prompt_hash_overrides_auto_logic(self, tmp_path): + """ + Issue #203: When test_prompt_hash is explicitly passed, it should override + the automatic logic. + """ + from pdd.operation_log import save_fingerprint + from pdd.sync_determine_operation import read_fingerprint + + basename = "explicit_test" + language = "python" + + meta_dir = tmp_path / ".pdd" / "meta" + meta_dir.mkdir(parents=True) + + explicit_hash = "explicitly_passed_hash" + + with patch("pdd.operation_log.META_DIR", str(meta_dir)), \ + patch("pdd.sync_determine_operation.get_meta_dir", return_value=meta_dir): + + # Even for 'generate' operation, explicit test_prompt_hash should be used + save_fingerprint( + basename=basename, + language=language, + operation="generate", + paths={}, + cost=0.1, + model="test", + test_prompt_hash=explicit_hash + ) + + result = read_fingerprint(basename, language) + assert result is not None + assert result.test_prompt_hash == explicit_hash, ( + "Explicit test_prompt_hash should override automatic logic" + ) \ No newline at end of file diff --git a/tests/test_sync_determine_operation.py b/tests/test_sync_determine_operation.py index 3d846959..cd0d2643 100644 --- a/tests/test_sync_determine_operation.py +++ b/tests/test_sync_determine_operation.py @@ -3043,6 +3043,257 @@ def test_prompt_change_detected_even_after_crash_workflow(pdd_test_environment): f"Reason should mention prompt change: {decision.reason}" +# --- Issue #203: Auto-update tests based on prompt changes --- + +class TestIssue203FingerprintTestPromptHash: + """Tests for the test_prompt_hash field in Fingerprint dataclass (Issue #203).""" + + def test_fingerprint_has_test_prompt_hash_field(self): + """Fingerprint dataclass should have test_prompt_hash field.""" + fp = Fingerprint( + pdd_version="1.0.0", + timestamp="2024-01-01T00:00:00Z", + command="test", + prompt_hash="prompt_hash_123", + code_hash="code_hash_456", + example_hash="example_hash_789", + test_hash="test_hash_abc", + test_files=None, + test_prompt_hash="prompt_hash_123", + ) + assert hasattr(fp, 'test_prompt_hash') + assert fp.test_prompt_hash == "prompt_hash_123" + + def test_fingerprint_test_prompt_hash_defaults_to_none(self): + """test_prompt_hash should default to None for backward compatibility.""" + fp = Fingerprint( + pdd_version="1.0.0", + timestamp="2024-01-01T00:00:00Z", + command="generate", + prompt_hash="hash1", + code_hash="hash2", + example_hash="hash3", + test_hash="hash4", + ) + assert fp.test_prompt_hash is None + + def test_fingerprint_serialization_includes_test_prompt_hash(self): + """asdict should include test_prompt_hash in serialized output.""" + from dataclasses import asdict + fp = Fingerprint( + pdd_version="1.0.0", + timestamp="2024-01-01T00:00:00Z", + command="test", + prompt_hash="p1", + code_hash="c1", + example_hash="e1", + test_hash="t1", + test_files=None, + test_prompt_hash="p1", + ) + data = asdict(fp) + assert 'test_prompt_hash' in data + assert data['test_prompt_hash'] == "p1" + + +class TestIssue203ReadFingerprintTestPromptHash: + """Tests for reading test_prompt_hash from fingerprint files (Issue #203).""" + + def test_read_fingerprint_with_test_prompt_hash(self, pdd_test_environment): + """read_fingerprint should correctly read test_prompt_hash field.""" + fingerprint_data = { + "pdd_version": "1.0.0", + "timestamp": "2024-01-01T00:00:00Z", + "command": "test", + "prompt_hash": "prompt_abc", + "code_hash": "code_def", + "example_hash": "example_ghi", + "test_hash": "test_jkl", + "test_files": None, + "test_prompt_hash": "prompt_abc", + } + fp_file = get_meta_dir() / "issue203_python.json" + create_fingerprint_file(fp_file, fingerprint_data) + + fp = read_fingerprint("issue203", "python") + + assert fp is not None + assert fp.test_prompt_hash == "prompt_abc" + + def test_read_fingerprint_backward_compat_without_test_prompt_hash(self, pdd_test_environment): + """read_fingerprint should handle old fingerprints without test_prompt_hash.""" + old_fingerprint_data = { + "pdd_version": "0.99.0", + "timestamp": "2024-01-01T00:00:00Z", + "command": "generate", + "prompt_hash": "old_prompt", + "code_hash": "old_code", + "example_hash": "old_example", + "test_hash": "old_test", + "test_files": None, + # No test_prompt_hash field - simulating old format + } + fp_file = get_meta_dir() / "oldmod203_python.json" + create_fingerprint_file(fp_file, old_fingerprint_data) + + fp = read_fingerprint("oldmod203", "python") + + assert fp is not None + assert fp.test_prompt_hash is None + + +class TestIssue203StaleTestDetection: + """Tests for sync_determine_operation detecting stale tests (Issue #203).""" + + @patch('sync_determine_operation.construct_paths') + def test_detects_stale_tests_when_test_prompt_hash_differs(self, mock_construct, pdd_test_environment): + """Should return 'test' operation when test_prompt_hash doesn't match current prompt.""" + prompts_dir = pdd_test_environment / "prompts" + + # Create all required files + p_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", "NEW prompt content for 203") + c_hash = create_file(pdd_test_environment / f"{BASENAME}.py", "# regenerated code") + e_hash = create_file(pdd_test_environment / f"{BASENAME}_example.py", "# example") + t_hash = create_file(pdd_test_environment / f"test_{BASENAME}.py", "# old tests") + + mock_construct.return_value = ( + {}, {}, + { + 'code_file': str(pdd_test_environment / f"{BASENAME}.py"), + 'example_file': str(pdd_test_environment / f"{BASENAME}_example.py"), + 'test_file': str(pdd_test_environment / f"test_{BASENAME}.py") + }, + LANGUAGE + ) + + # Create fingerprint with OLD test_prompt_hash (different from current prompt) + old_prompt_hash = "old_prompt_hash_before_change_203" + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, { + "pdd_version": "1.0", + "timestamp": "t", + "command": "test", + "prompt_hash": p_hash, + "code_hash": c_hash, + "example_hash": e_hash, + "test_hash": t_hash, + "test_files": None, + "test_prompt_hash": old_prompt_hash, # OLD - different from current! + }) + + # Create run_report (coverage above TARGET_COVERAGE=90.0 to avoid test_extend) + rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + create_run_report_file(rr_path, { + "timestamp": "t", + "exit_code": 0, + "tests_passed": 5, + "tests_failed": 0, + "coverage": 95.0, + "test_hash": t_hash, + }) + + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + assert decision.operation == 'test' + assert 'outdated' in decision.reason.lower() + assert decision.details.get('tests_stale') is True + + @patch('sync_determine_operation.construct_paths') + def test_no_stale_test_detection_when_test_prompt_hash_matches(self, mock_construct, pdd_test_environment): + """Should return 'nothing' when test_prompt_hash matches current prompt.""" + prompts_dir = pdd_test_environment / "prompts" + + p_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", "synced prompt 203") + c_hash = create_file(pdd_test_environment / f"{BASENAME}.py", "# synced code") + e_hash = create_file(pdd_test_environment / f"{BASENAME}_example.py", "# example") + t_hash = create_file(pdd_test_environment / f"test_{BASENAME}.py", "# synced tests") + + mock_construct.return_value = ( + {}, {}, + { + 'code_file': str(pdd_test_environment / f"{BASENAME}.py"), + 'example_file': str(pdd_test_environment / f"{BASENAME}_example.py"), + 'test_file': str(pdd_test_environment / f"test_{BASENAME}.py") + }, + LANGUAGE + ) + + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, { + "pdd_version": "1.0", + "timestamp": "t", + "command": "test", + "prompt_hash": p_hash, + "code_hash": c_hash, + "example_hash": e_hash, + "test_hash": t_hash, + "test_files": None, + "test_prompt_hash": p_hash, # MATCHES current prompt hash + }) + + rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + create_run_report_file(rr_path, { + "timestamp": "t", + "exit_code": 0, + "tests_passed": 10, + "tests_failed": 0, + "coverage": 95.0, # Above TARGET_COVERAGE=90.0 + "test_hash": t_hash, + }) + + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + assert decision.operation == 'nothing' + + @patch('sync_determine_operation.construct_paths') + def test_no_stale_test_detection_when_test_prompt_hash_is_none(self, mock_construct, pdd_test_environment): + """Should NOT trigger stale test detection when test_prompt_hash is None (backward compat).""" + prompts_dir = pdd_test_environment / "prompts" + + p_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", "legacy prompt 203") + c_hash = create_file(pdd_test_environment / f"{BASENAME}.py", "# code") + e_hash = create_file(pdd_test_environment / f"{BASENAME}_example.py", "# example") + t_hash = create_file(pdd_test_environment / f"test_{BASENAME}.py", "# tests") + + mock_construct.return_value = ( + {}, {}, + { + 'code_file': str(pdd_test_environment / f"{BASENAME}.py"), + 'example_file': str(pdd_test_environment / f"{BASENAME}_example.py"), + 'test_file': str(pdd_test_environment / f"test_{BASENAME}.py") + }, + LANGUAGE + ) + + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, { + "pdd_version": "0.99", + "timestamp": "t", + "command": "test", + "prompt_hash": p_hash, + "code_hash": c_hash, + "example_hash": e_hash, + "test_hash": t_hash, + # No test_prompt_hash - legacy fingerprint + }) + + rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + create_run_report_file(rr_path, { + "timestamp": "t", + "exit_code": 0, + "tests_passed": 5, + "tests_failed": 0, + "coverage": 95.0, # Above TARGET_COVERAGE=90.0 + "test_hash": t_hash, + }) + + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + # Should NOT trigger stale test detection for legacy fingerprints + assert decision.operation == 'nothing' + assert decision.details.get('tests_stale') is not True + + # --- GitHub Issue #349: Infinite Loop Bug Tests --- class TestInfiniteLoopBugIssue349: diff --git a/tests/test_sync_orchestration.py b/tests/test_sync_orchestration.py index 456b436d..97b532aa 100644 --- a/tests/test_sync_orchestration.py +++ b/tests/test_sync_orchestration.py @@ -4596,3 +4596,228 @@ def capture_subprocess_run(cmd, **kwargs): f"This verifies the fix: sync_orchestration.py:1266 uses " f"pdd_files['code'].resolve().parent instead of hardcoded Path.cwd() / 'src'" ) + + +# --- Issue #203: Auto-update tests based on prompt changes --- + +class TestIssue203SaveOperationFingerprintTestPromptHash: + """Tests for _save_fingerprint_atomic setting test_prompt_hash correctly (Issue #203).""" + + def test_generate_operation_sets_test_prompt_hash_to_none(self, tmp_path, monkeypatch): + """After 'generate' operation, test_prompt_hash should be None (tests are stale).""" + from unittest.mock import MagicMock + from pdd.sync_orchestration import _save_fingerprint_atomic, META_DIR + + # Setup + meta_dir = tmp_path / ".pdd" / "meta" + meta_dir.mkdir(parents=True) + monkeypatch.setattr('pdd.sync_orchestration.META_DIR', meta_dir) + + # Create test files + prompt_file = tmp_path / "test203.prompt" + prompt_file.write_text("test prompt content for issue 203") + code_file = tmp_path / "test203.py" + code_file.write_text("# test code for issue 203") + + paths = { + 'prompt': prompt_file, + 'code': code_file, + 'example': tmp_path / "test203_example.py", + 'test': tmp_path / "test_test203.py", + } + + mock_atomic = MagicMock() + _save_fingerprint_atomic("test203", "python", "generate", paths, 0.0, "model", atomic_state=mock_atomic) + + # Verify set_fingerprint was called with correct data + mock_atomic.set_fingerprint.assert_called_once() + fingerprint_data = mock_atomic.set_fingerprint.call_args[0][0] + assert fingerprint_data['test_prompt_hash'] is None # Should be None after generate + + def test_test_operation_sets_test_prompt_hash_to_current(self, tmp_path, monkeypatch): + """After 'test' operation, test_prompt_hash should match current prompt_hash.""" + from unittest.mock import MagicMock + from pdd.sync_orchestration import _save_fingerprint_atomic, META_DIR + from pdd.sync_determine_operation import calculate_sha256 + + # Setup + meta_dir = tmp_path / ".pdd" / "meta" + meta_dir.mkdir(parents=True) + monkeypatch.setattr('pdd.sync_orchestration.META_DIR', meta_dir) + + prompt_file = tmp_path / "test203b.prompt" + prompt_file.write_text("test prompt content v2 for issue 203") + code_file = tmp_path / "test203b.py" + code_file.write_text("# test code v2 for issue 203") + test_file = tmp_path / "test_test203b.py" + test_file.write_text("# test file for issue 203") + + paths = { + 'prompt': prompt_file, + 'code': code_file, + 'example': tmp_path / "test203b_example.py", + 'test': test_file, + } + + expected_prompt_hash = calculate_sha256(prompt_file) + + mock_atomic = MagicMock() + _save_fingerprint_atomic("test203b", "python", "test", paths, 0.0, "model", atomic_state=mock_atomic) + + # Verify set_fingerprint was called with correct data + mock_atomic.set_fingerprint.assert_called_once() + fingerprint_data = mock_atomic.set_fingerprint.call_args[0][0] + assert fingerprint_data['test_prompt_hash'] == expected_prompt_hash + + def test_fix_operation_preserves_test_prompt_hash(self, tmp_path, monkeypatch): + """After 'fix' operation, test_prompt_hash should be preserved from previous fingerprint.""" + import json + from unittest.mock import MagicMock + from pdd.sync_orchestration import _save_fingerprint_atomic, META_DIR + from pdd.sync_determine_operation import get_meta_dir + + # Setup + meta_dir = tmp_path / ".pdd" / "meta" + meta_dir.mkdir(parents=True) + monkeypatch.setattr('pdd.sync_orchestration.META_DIR', meta_dir) + monkeypatch.setattr('pdd.sync_determine_operation.get_meta_dir', lambda: meta_dir) + + # Create initial fingerprint with test_prompt_hash set + initial_fp = { + "pdd_version": "1.0.0", + "timestamp": "2024-01-01T00:00:00Z", + "command": "test", + "prompt_hash": "initial_prompt_hash_203", + "code_hash": "initial_code_hash_203", + "example_hash": None, + "test_hash": "initial_test_hash_203", + "test_files": None, + "test_prompt_hash": "initial_prompt_hash_203", + } + fp_file = meta_dir / "mymod203_python.json" + with open(fp_file, 'w') as f: + json.dump(initial_fp, f) + + prompt_file = tmp_path / "mymod203.prompt" + prompt_file.write_text("test prompt content for issue 203") + code_file = tmp_path / "mymod203.py" + code_file.write_text("# fixed code for issue 203") + + paths = { + 'prompt': prompt_file, + 'code': code_file, + 'example': tmp_path / "mymod203_example.py", + 'test': tmp_path / "test_mymod203.py", + } + + mock_atomic = MagicMock() + _save_fingerprint_atomic("mymod203", "python", "fix", paths, 0.0, "model", atomic_state=mock_atomic) + + # Verify set_fingerprint was called with preserved test_prompt_hash + mock_atomic.set_fingerprint.assert_called_once() + fingerprint_data = mock_atomic.set_fingerprint.call_args[0][0] + assert fingerprint_data['test_prompt_hash'] == "initial_prompt_hash_203" + + def test_generate_then_test_workflow(self, tmp_path, monkeypatch): + """ + End-to-end workflow: generate clears test_prompt_hash, test sets it. + Verifies the fingerprint data passed to atomic_state.set_fingerprint(). + """ + from unittest.mock import MagicMock + from pdd.sync_orchestration import _save_fingerprint_atomic, META_DIR + from pdd.sync_determine_operation import calculate_sha256 + + # Setup + meta_dir = tmp_path / ".pdd" / "meta" + meta_dir.mkdir(parents=True) + monkeypatch.setattr('pdd.sync_orchestration.META_DIR', meta_dir) + + prompt_file = tmp_path / "workflow203.prompt" + prompt_file.write_text("Original prompt for workflow test") + code_file = tmp_path / "workflow203.py" + code_file.write_text("# original code") + test_file = tmp_path / "test_workflow203.py" + test_file.write_text("# original tests") + + paths = { + 'prompt': prompt_file, + 'code': code_file, + 'example': tmp_path / "workflow203_example.py", + 'test': test_file, + } + + # Step 1: Initial test operation sets test_prompt_hash + original_prompt_hash = calculate_sha256(prompt_file) + mock_atomic1 = MagicMock() + _save_fingerprint_atomic("workflow203", "python", "test", paths, 0.0, "model", atomic_state=mock_atomic1) + fp_data1 = mock_atomic1.set_fingerprint.call_args[0][0] + assert fp_data1['test_prompt_hash'] == original_prompt_hash + + # Step 2: Prompt changes, generate operation clears test_prompt_hash + prompt_file.write_text("UPDATED prompt with new requirements") + new_prompt_hash = calculate_sha256(prompt_file) + code_file.write_text("# regenerated code") + mock_atomic2 = MagicMock() + _save_fingerprint_atomic("workflow203", "python", "generate", paths, 0.0, "model", atomic_state=mock_atomic2) + fp_data2 = mock_atomic2.set_fingerprint.call_args[0][0] + assert fp_data2['test_prompt_hash'] is None # Cleared by generate + assert fp_data2['prompt_hash'] == new_prompt_hash + + # Step 3: Test operation re-links to new prompt + test_file.write_text("# new tests for updated requirements") + mock_atomic3 = MagicMock() + _save_fingerprint_atomic("workflow203", "python", "test", paths, 0.0, "model", atomic_state=mock_atomic3) + fp_data3 = mock_atomic3.set_fingerprint.call_args[0][0] + assert fp_data3['test_prompt_hash'] == new_prompt_hash # Now linked to new prompt + + def test_skip_prefixed_operation_preserves_test_prompt_hash_without_atomic_state(self, tmp_path, monkeypatch): + """ + Issue #203 edge case: Skip-prefixed operations (like skip:test, without atomic_state) + should preserve existing test_prompt_hash instead of losing it. + """ + import json + from pdd.sync_orchestration import _save_fingerprint_atomic, META_DIR + from pdd.sync_determine_operation import read_fingerprint, get_meta_dir + + # Setup + meta_dir = tmp_path / ".pdd" / "meta" + meta_dir.mkdir(parents=True) + monkeypatch.setattr('pdd.sync_orchestration.META_DIR', meta_dir) + monkeypatch.setattr('pdd.sync_determine_operation.get_meta_dir', lambda: meta_dir) + monkeypatch.setattr('pdd.operation_log.META_DIR', str(meta_dir)) + + # Create initial fingerprint with test_prompt_hash set + initial_fp = { + "pdd_version": "1.0.0", + "timestamp": "2024-01-01T00:00:00Z", + "command": "test", + "prompt_hash": "prompt_hash_skip_test", + "code_hash": "code_hash_skip_test", + "example_hash": None, + "test_hash": "test_hash_skip_test", + "test_files": None, + "test_prompt_hash": "preserved_test_prompt_hash_203", + } + fp_file = meta_dir / "skipmod_python.json" + with open(fp_file, 'w') as f: + json.dump(initial_fp, f) + + prompt_file = tmp_path / "skipmod.prompt" + prompt_file.write_text("test prompt") + code_file = tmp_path / "skipmod.py" + code_file.write_text("# code") + + paths = { + 'prompt': prompt_file, + 'code': code_file, + 'example': tmp_path / "skipmod_example.py", + 'test': tmp_path / "test_skipmod.py", + } + + # Call WITHOUT atomic_state (simulates skip operation path) + _save_fingerprint_atomic("skipmod", "python", "skip:test", paths, 0.0, "skipped", atomic_state=None) + + # Verify test_prompt_hash is preserved + fp = read_fingerprint("skipmod", "python") + assert fp is not None + assert fp.test_prompt_hash == "preserved_test_prompt_hash_203" # Should be preserved!