diff --git a/pdd/update_main.py b/pdd/update_main.py index 9a45f6f3..11a26e3d 100644 --- a/pdd/update_main.py +++ b/pdd/update_main.py @@ -67,6 +67,7 @@ def resolve_prompt_code_pair(code_file_path: str, quiet: bool = False, output_di pass # Determine the base prompts directory + context_config = {} if output_dir: # Use the custom output directory (absolute path) base_prompts_dir = os.path.abspath(output_dir) diff --git a/tests/test_e2e_issue_493_update_output_subdir.py b/tests/test_e2e_issue_493_update_output_subdir.py new file mode 100644 index 00000000..d3169daa --- /dev/null +++ b/tests/test_e2e_issue_493_update_output_subdir.py @@ -0,0 +1,82 @@ +""" +E2E Test for Issue #493: pdd update --output crashes with NameError when +code file is in a subdirectory. + +This test exercises the full update_main() → resolve_prompt_code_pair() path +that a user triggers via `pdd update --output `. It mocks +only the LLM call (update_prompt) and agent availability at the system boundary, +but exercises all real path resolution logic including .pddrc context detection, +git repo discovery, and subdirectory structure preservation. + +Bug: resolve_prompt_code_pair() defines context_config only in the else branch +(when --output is NOT provided) but references it unconditionally when computing +code_root. When --output IS provided and the code file is in a subdirectory +(rel_dir != "."), this causes an UnboundLocalError. +""" + +import git +import click +from pathlib import Path +from unittest.mock import patch + +from pdd.update_main import update_main + + +def test_e2e_update_output_flag_with_subdirectory_code_file(tmp_path, monkeypatch): + """ + E2E regression test: `pdd update backend/src/module.py --output /tmp/output/` + should NOT crash with NameError when the code file is in a subdirectory. + + Exercises: update_main → resolve_prompt_code_pair → path resolution with --output. + Mocks: LLM call (update_prompt), agent availability (get_available_agents). + """ + # Setup: realistic repo structure matching the issue reproduction + repo_path = tmp_path / "pdd-test" + repo_path.mkdir() + sub_dir = repo_path / "backend" / "src" + sub_dir.mkdir(parents=True) + code_file = sub_dir / "module.py" + code_file.write_text("def hello(): return 'world'") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + # Initialize git repo (required for repo root detection) + repo = git.Repo.init(repo_path) + repo.index.add([str(code_file)]) + repo.index.commit("init") + + monkeypatch.chdir(repo_path) + + # Build a real Click context like the CLI would + ctx = click.Context(click.Command("update")) + ctx.obj = { + "strength": 0.5, + "temperature": 0.0, + "verbose": False, + "quiet": True, + } + + with patch("pdd.update_main.update_prompt") as mock_up, \ + patch("pdd.update_main.get_available_agents", return_value=[]), \ + patch("pdd.update_main.get_language", return_value="python"): + + mock_up.return_value = ("generated prompt content", 0.01, "mock-model") + + # Act: This is what `pdd update backend/src/module.py --output /tmp/output/` does + result = update_main( + ctx=ctx, + input_prompt_file=None, + modified_code_file=str(code_file), + input_code_file=None, + output=str(output_dir), + use_git=False, + ) + + # Assert: should succeed, not crash with NameError + assert result is not None, "update_main returned None, indicating a crash or error" + assert result[0] == "generated prompt content" + + # The prompt file should exist somewhere under the output directory + prompt_files = list(output_dir.rglob("*_python.prompt")) + assert len(prompt_files) >= 1, f"No prompt file created in {output_dir}" diff --git a/tests/test_update_main.py b/tests/test_update_main.py index 5ab235a6..fe4f6fb3 100644 --- a/tests/test_update_main.py +++ b/tests/test_update_main.py @@ -708,4 +708,97 @@ def agentic_side_effect(prompt_file, **kwargs): # Source must remain unchanged even after agentic failure assert source_prompt.read_text() == original_content, \ - "Source prompt was corrupted by failed agentic update" \ No newline at end of file + "Source prompt was corrupted by failed agentic update" + + +# --- Tests for issue #493: NameError when using --output with subdirectory code file --- + +from pdd.update_main import resolve_prompt_code_pair + + +def test_resolve_prompt_code_pair_output_dir_with_subdirectory_code_file(tmp_path, monkeypatch): + """ + Regression test for GitHub issue #493. + resolve_prompt_code_pair() crashes with UnboundLocalError when --output is provided + and the code file is in a subdirectory (rel_dir != "."), because context_config + is only defined in the else branch but used unconditionally when computing code_root. + """ + # Setup: git repo with code file in a subdirectory + repo_path = tmp_path / "test_repo" + repo_path.mkdir() + sub_dir = repo_path / "backend" / "src" + sub_dir.mkdir(parents=True) + code_file = sub_dir / "module.py" + code_file.write_text("def hello(): return 'world'") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + monkeypatch.chdir(repo_path) + git.Repo.init(repo_path) + + with patch("pdd.update_main.get_language") as mock_lang: + mock_lang.return_value = "python" + + # This should NOT raise NameError for context_config + prompt_path, code_path = resolve_prompt_code_pair( + str(code_file), quiet=True, output_dir=str(output_dir) + ) + + # The prompt file should be created under the output directory + assert os.path.exists(prompt_path) + assert Path(prompt_path).is_relative_to(output_dir) + assert prompt_path.endswith("module_python.prompt") + + +def test_resolve_prompt_code_pair_output_dir_with_root_level_code_file(tmp_path, monkeypatch): + """ + Edge case for issue #493: code file at repo root with --output should work + (this path doesn't hit the bug since rel_dir == "." skips the code_root logic). + """ + repo_path = tmp_path / "test_repo" + repo_path.mkdir() + code_file = repo_path / "module.py" + code_file.write_text("def hello(): return 'world'") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + monkeypatch.chdir(repo_path) + git.Repo.init(repo_path) + + with patch("pdd.update_main.get_language") as mock_lang: + mock_lang.return_value = "python" + + prompt_path, code_path = resolve_prompt_code_pair( + str(code_file), quiet=True, output_dir=str(output_dir) + ) + + assert os.path.exists(prompt_path) + assert prompt_path.endswith("module_python.prompt") + + +def test_resolve_prompt_code_pair_no_output_dir_subdirectory_still_works(tmp_path, monkeypatch): + """ + No-regression test for issue #493: without --output, subdirectory code files + should still work as before (context_config is defined in else branch). + """ + repo_path = tmp_path / "test_repo" + repo_path.mkdir() + sub_dir = repo_path / "backend" + sub_dir.mkdir() + code_file = sub_dir / "module.py" + code_file.write_text("def hello(): return 'world'") + + monkeypatch.chdir(repo_path) + git.Repo.init(repo_path) + + with patch("pdd.update_main.get_language") as mock_lang: + mock_lang.return_value = "python" + + prompt_path, code_path = resolve_prompt_code_pair( + str(code_file), quiet=True + ) + + assert os.path.exists(prompt_path) + assert prompt_path.endswith("module_python.prompt")