diff --git a/.gitignore b/.gitignore index 003e63fe7..16d96ebe0 100644 --- a/.gitignore +++ b/.gitignore @@ -244,6 +244,19 @@ yarn.lock !.pdd/ !.pdd/meta/ .pdd/meta/*.json +# Transient interprocess lock files (e.g. pdd_created_tests.json.lock, issue +# #1903 round 9). `os.open(O_CREAT)` leaves them behind and they are NOT +# unlinked on release (advisory flock is tied to the fd, so unlinking would +# break mutual exclusion). They must never be staged: `git add -A` would stage +# a `.lock` that the durable-sync allowlist then rejects (not the ownership +# manifest, not a module-prefixed `.json`), failing an otherwise-successful +# greenfield module (Codex review, PR #1998). +.pdd/meta/*.lock +# PDD greenfield-ownership manifest (issue #1903 §B.4): TRACKED so a fresh +# checkout / durable worktree keeps ownership provenance for co-located tests +# PDD created, else they are misread as human-adopted and can reach the churn +# never-block. Not a fingerprint, but shares the tracked .pdd/meta home. +!.pdd/meta/pdd_created_tests.json !.pdd/meta/agentic_bug_orchestrator_python.json !.pdd/meta/agentic_bug_python.json !.pdd/meta/agentic_checkup_python.json diff --git a/.pdd/meta/agentic_architecture_orchestrator_python.json b/.pdd/meta/agentic_architecture_orchestrator_python.json index 492082f7d..3e51cf92f 100644 --- a/.pdd/meta/agentic_architecture_orchestrator_python.json +++ b/.pdd/meta/agentic_architecture_orchestrator_python.json @@ -1,17 +1,20 @@ { - "pdd_version": "0.0.230", - "timestamp": "2026-05-07T22:56:39.108172+00:00", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:13:57.111014+00:00", "command": "fix", - "prompt_hash": "f00e7085128ae23be67d1969542748cee0b0035552a1bf9ee3601a7e4497a3d1", - "code_hash": "a782f86dda46bdb22d9dc90a48049428b07e38f791dc55c1ac10b47e78156eab", + "prompt_hash": "74e0ae662852beeca32f5280c2de6a5d0603bc75470d7397cb84f66fc5d0fad3", + "code_hash": "8456061ca2cd18073f9f58533dec5715909e02dcf24ed7c5feb7661f5b285e66", "example_hash": "aad78130a96907c5d1bd197a7bca53d6d1bdcf5775776860993f9e5c68635867", "test_hash": "30049fa3b4706221c111095cbbb9992f3335bd9ec6d51d6068bcd0cc2f9cf9f5", "test_files": { "test_agentic_architecture_orchestrator.py": "30049fa3b4706221c111095cbbb9992f3335bd9ec6d51d6068bcd0cc2f9cf9f5" }, "include_deps": { + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", "context/load_prompt_template_example.py": "a1cd6619182c6c951f5856dda4070e202875a5884bbfab9cc191d24de2f4951f", - "context/python_preamble.prompt": "57a3e51f529024ec0cb9658cd6ac61a7c8051ba0c8e887b31cf00b2e78a07d83", - "context/render_mermaid_example.py": "193a57bc4191595f252881c016ba7168c27c27c01ff192128d98cf76ce5f842b" + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/render_mermaid_example.py": "193a57bc4191595f252881c016ba7168c27c27c01ff192128d98cf76ce5f842b", + "pdd/architecture_registry.py": "5722225846b9cc8189b410a78e5714b4f2916d5748271d27f6f738692429c309", + "pdd/preprocess.py": "d04a6cfc261c1e88076398a34d0bf96c4285061668866c5ad305e4b605ffdf56" } } diff --git a/.pdd/meta/agentic_bug_orchestrator_python.json b/.pdd/meta/agentic_bug_orchestrator_python.json index a28ea032b..10511ee3a 100644 --- a/.pdd/meta/agentic_bug_orchestrator_python.json +++ b/.pdd/meta/agentic_bug_orchestrator_python.json @@ -1,19 +1,19 @@ { - "pdd_version": "0.0.236", - "timestamp": "2026-05-26T00:00:00.000000+00:00", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:13:57.201619+00:00", "command": "fix", - "prompt_hash": "d33d79202c9e395e9a2a43a67741d6578ca002d07dec20cdeec77f9af0849fd1", - "code_hash": "03f458276287612267b7a96b5cb2042df81fb6a15d57f531634094f5e921aef9", + "prompt_hash": "229ada9ea9220e2000593b81097761fb52a3eac7e86a5b565fe421d7e730d030", + "code_hash": "4e0125d16acf07a40abb64ac653ce5658560ba1a244c39646cf5f6ece387c1a7", "example_hash": "23b545fc0f892e14ee9660902d3f7e04893f0b793246c1ea9d559656fd39ffe6", - "test_hash": "781fe2b15c71d3098e06d8845e8e03255d547093f65cb81a7d0b769d6c1a14e7", + "test_hash": "b0b0448434f1a87c1beb56ed4411462091b96096f3d662eca706b336ac43d3bf", "test_files": { - "test_agentic_bug_orchestrator.py": "781fe2b15c71d3098e06d8845e8e03255d547093f65cb81a7d0b769d6c1a14e7", + "test_agentic_bug_orchestrator.py": "b0b0448434f1a87c1beb56ed4411462091b96096f3d662eca706b336ac43d3bf", "test_agentic_bug_orchestrator_1.py": "8085e2041355e53aef1709eeba3591fa0f7c512a637038b8c48d633e4cd07aa2", - "test_agentic_bug_orchestrator_step_comments.py": "693e1c1b571a1daee1013fb48bc8390f263a6544c163e3f55bcb60dafa1bb679" + "test_agentic_bug_orchestrator_step_comments.py": "d8b948bbe7f2ee02202cf54c28812a2e6eb85f398b0c50bd0b8e9d9387c3a5c8" }, "include_deps": { "context/agentic_bug_example.py": "96e35df4e8425a9173ff6a30281892c2bd8b81361cff72b732e488172ddf52f5", - "context/agentic_common_example.py": "113b5829cae3a9e38c362b74d5115ba09cab06c7be5831527d9829f97f7b3b7d", + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", "context/load_prompt_template_example.py": "a1cd6619182c6c951f5856dda4070e202875a5884bbfab9cc191d24de2f4951f", "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254" } diff --git a/.pdd/meta/agentic_change_orchestrator_python.json b/.pdd/meta/agentic_change_orchestrator_python.json index c1a7ee6db..1ef4175bd 100644 --- a/.pdd/meta/agentic_change_orchestrator_python.json +++ b/.pdd/meta/agentic_change_orchestrator_python.json @@ -1,21 +1,22 @@ { - "pdd_version": "0.0.233", - "timestamp": "2026-05-26T00:00:00.000000+00:00", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:13:57.302195+00:00", "command": "fix", - "prompt_hash": "147aebab37557437b25ade6f4a78b72c77350aed19d5f1d6d900feeaf4eadca4", - "code_hash": "19d8f4174475e05699edd0de801f238b3df6edc799b1ee44c5367cbad978adce", + "prompt_hash": "f9bcc13a9d27d6f7816aadd0b12efffd01dec12d25acc8a395e637ab93221dad", + "code_hash": "33dbdda241eb53e9c3c56448b0e74e9dd230025a3eb3cd842fa13ec5a04e2874", "example_hash": "fceaa9e9326bc78d10c74e519d756a09235b9606aba7b6e513fd3e2ebc56b4a2", - "test_hash": "10833f31e7de88490aa42d807f609f8481bb567c55aea5172b91429e398a91c4", + "test_hash": "2143164ebab405ee47b64dc45ce6c52eb025a54340233bb749b7333aa40cfbdd", "test_files": { - "test_agentic_change_orchestrator.py": "10833f31e7de88490aa42d807f609f8481bb567c55aea5172b91429e398a91c4" + "test_agentic_change_orchestrator.py": "2143164ebab405ee47b64dc45ce6c52eb025a54340233bb749b7333aa40cfbdd" }, "include_deps": { - "context/construct_paths_example.py": "9fb88745a2a0e2834f9da30c2128a122fef4d5a74212ceacc5f9c3d9ddb9a8ca", + "context/construct_paths_example.py": "412cb260650bcbd628fba647db917a8d32f8191df2e1c32cc22f22c0c11b1740", "context/get_extension_example.py": "def9c34c1416259a0a031a996b96aeff5e73a7012adff6af1cc557e74ba97cc8", "context/load_prompt_template_example.py": "a1cd6619182c6c951f5856dda4070e202875a5884bbfab9cc191d24de2f4951f", - "pdd/agentic_common.py": "82e30c361de4d7b9a62928fe21d8f55df96691c461c73750b30595e3a04446f2", - "pdd/architecture_sync.py": "fb55dc87aed5eca7eb62918291a247f4f3cc6b67555cdc8c02e22ce90a980d70", - "pdd/preprocess.py": "f95eedec5bcb4ca505d8682d4a38ea5d4b3eb07ac1d52a38ebffa9f89941a92e", - "pdd/sync_order.py": "118e49a22b0be2ae3017fdfeb1e05715a878efb89fab7750868be2388f2eaad9" + "pdd/agentic_common.py": "940260fc0f600000557345373cd7c0997b0672193bc9fd2c63e07ee3451c2357", + "pdd/architecture_sync.py": "0e0ff74978f49d629f92365723ebbea0e7c70e6d5234ccfdafb1c8e09c110c11", + "pdd/pre_checkup_gate.py": "9ff0bbaa18c82cf4da836126861c40936a4555b8ff972f4bceee370492f37ace", + "pdd/preprocess.py": "d04a6cfc261c1e88076398a34d0bf96c4285061668866c5ad305e4b605ffdf56", + "pdd/sync_order.py": "6cdaed3b56d9cc12f38ee5ac93b8623899372ea2d177c4f131ec258edb94d0dd" } } diff --git a/.pdd/meta/agentic_change_orchestrator_python_run.json b/.pdd/meta/agentic_change_orchestrator_python_run.json index a5ebc2dfa..fc4b0df6d 100644 --- a/.pdd/meta/agentic_change_orchestrator_python_run.json +++ b/.pdd/meta/agentic_change_orchestrator_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-05-14T23:49:36.973003+00:00", + "timestamp": "2026-07-10T18:19:42.514644+00:00", "exit_code": 0, - "tests_passed": 148, + "tests_passed": 262, "tests_failed": 0, - "coverage": 100.0, - "test_hash": "10833f31e7de88490aa42d807f609f8481bb567c55aea5172b91429e398a91c4", + "coverage": 82.0, + "test_hash": "2143164ebab405ee47b64dc45ce6c52eb025a54340233bb749b7333aa40cfbdd", "test_files": { - "test_agentic_change_orchestrator.py": "10833f31e7de88490aa42d807f609f8481bb567c55aea5172b91429e398a91c4" + "test_agentic_change_orchestrator.py": "2143164ebab405ee47b64dc45ce6c52eb025a54340233bb749b7333aa40cfbdd" } -} +} \ No newline at end of file diff --git a/.pdd/meta/agentic_checkup_orchestrator_python.json b/.pdd/meta/agentic_checkup_orchestrator_python.json index 2322ad2d0..9bcc8bd8c 100644 --- a/.pdd/meta/agentic_checkup_orchestrator_python.json +++ b/.pdd/meta/agentic_checkup_orchestrator_python.json @@ -1,20 +1,20 @@ { - "pdd_version": "0.0.273", - "timestamp": "2026-06-13T18:03:20.500524+00:00", - "command": "example", - "prompt_hash": "74563384c8358e0cfb994bd2b5e37a93a478a64c3e83a7cd1a63fa44246258d5", - "code_hash": "2a29489d565b0ebf5ef1d353f0070e806f9f990cd1eedc1c547abb983ddf93c0", - "example_hash": "9290bba1f27a21c2a8603065864fd059de6fde22151d6f0062e813a48b91061a", - "test_hash": "40ab1539859560e07acc3b4f5ac70ddeaa30e37333a04658c43c40593b18d55c", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:13:57.391512+00:00", + "command": "fix", + "prompt_hash": "15f7cb6d0efa3c1939e33461922d7f4bf9024cc48966143937ddb4a96fe28bd6", + "code_hash": "98452041d2f217c4283494496e32d7cf669b40a2bd59aa95232116cc7347a9de", + "example_hash": "295a9ae294e22224d3c447f13c462854bfe4e526cebc89bcbba417ea514c7ef2", + "test_hash": "7bfdf5931599356de13b4129f62ad5ed7ae928e10ae391c88a2e16f62ebf8d5c", "test_files": { - "test_agentic_checkup_orchestrator.py": "40ab1539859560e07acc3b4f5ac70ddeaa30e37333a04658c43c40593b18d55c" + "test_agentic_checkup_orchestrator.py": "7bfdf5931599356de13b4129f62ad5ed7ae928e10ae391c88a2e16f62ebf8d5c" }, "include_deps": { "context/agentic_bug_orchestrator_example.py": "23b545fc0f892e14ee9660902d3f7e04893f0b793246c1ea9d559656fd39ffe6", - "context/agentic_common_example.py": "f9cdb30a932973d341b795faf510056b95573610af5ac48e3849cf42c205998e", - "context/agentic_e2e_fix_orchestrator_example.py": "182e33cbf3aa78dc177caead0bf7f9e827cc17f0f9ba7cc32c0dd027063bde64", + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", + "context/agentic_e2e_fix_orchestrator_example.py": "180cc2c4316b531586040d61fc5f82f1f605d5c5736fd23bad63a889ac0b7d1e", "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", "context/load_prompt_template_example.py": "a1cd6619182c6c951f5856dda4070e202875a5884bbfab9cc191d24de2f4951f", "context/preprocess_example.py": "e7082a4a9ef830048ff32ed774a3e74da1cf4662a22115e6bd5543e1b9cdbf43" } -} \ No newline at end of file +} diff --git a/.pdd/meta/agentic_checkup_python.json b/.pdd/meta/agentic_checkup_python.json index c5d85cd57..cbe377a59 100644 --- a/.pdd/meta/agentic_checkup_python.json +++ b/.pdd/meta/agentic_checkup_python.json @@ -1,22 +1,23 @@ { - "pdd_version": "0.0.288.dev0", - "timestamp": "2026-06-26T22:58:40.678699+00:00", + "pdd_version": "0.0.300.dev0", + "timestamp": "2026-07-10T18:41:46.155892+00:00", "command": "test", - "prompt_hash": "86d6766af5e844962a3160de65f69177aab4146060461afeb674ad489ab71e26", - "code_hash": "30e37254c3d5721d0b7a43c023a677b670219b072fd0fa7a64990ce0c13fd175", + "prompt_hash": "2ae33917e1bbaed1d158254a8ac195aa2d9990b44fb8fa473c290ab025ee147b", + "code_hash": "d5b79fc009c5f935fd7fe70b2b2517f4f1f5c2c02a1231bf1d87ddbc4baba042", "example_hash": "57c5e8ae852946da79151ab49e44400f2c093bf6f8add0bf25a01c9872a13101", "test_hash": "4d7a9c10ba2f9925f7b567f885b6dd67dd48aa3e8757e347ddad9e6c7b6f3e02", "test_files": { "test_agentic_checkup.py": "4d7a9c10ba2f9925f7b567f885b6dd67dd48aa3e8757e347ddad9e6c7b6f3e02", - "test_agentic_checkup_orchestrator.py": "e37ccd4a0ffaa25a52f1374c570bb4fa9b4a69bd95a9a766f4e60d97a4dfb5fe" + "test_agentic_checkup_orchestrator.py": "7bfdf5931599356de13b4129f62ad5ed7ae928e10ae391c88a2e16f62ebf8d5c", + "test_agentic_checkup_step5_pass_evidence.py": "a9d5ac08e1cbf1326ab352cffdfbb3bc84c280327b70413ef6f982860bf89e44" }, "include_deps": { "context/agentic_change_example.py": "a8308b2af708c804d626a4a96012c5a67f5bc6469e33857cafc07627213828d7", "context/agentic_checkup_orchestrator_example.py": "295a9ae294e22224d3c447f13c462854bfe4e526cebc89bcbba417ea514c7ef2", "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", - "context/agentic_sync_example.py": "9bcc2f199a1463e8c424d8210ea6d5f34ad6fb5fcb1022aa879a9ecba0f8d5d6", - "context/checkup_review_loop_example.py": "b392eaa48f9bd84660d4a2aaec04c5237a7791059c6ffd64288b34eadce686bb", + "context/agentic_sync_example.py": "aa9683f1a26841f3cae854e72cce38636f0859d8dd2f7672fa14e412afdef884", + "context/checkup_review_loop_example.py": "9d94a966f8a8bf617bb80bf1613e77dfdaba702270a61980f31ac3e95df525ba", "context/ci_validation_example.py": "06b910b216f68af58862e76712ccf82bae1bdf87f3fa95b043408fb079f45ce0", "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254" } -} +} \ No newline at end of file diff --git a/.pdd/meta/agentic_checkup_python_run.json b/.pdd/meta/agentic_checkup_python_run.json index 8f8623bae..e20e27b8c 100644 --- a/.pdd/meta/agentic_checkup_python_run.json +++ b/.pdd/meta/agentic_checkup_python_run.json @@ -1,12 +1,13 @@ { - "timestamp": "2026-05-12T20:37:07.056697+00:00", + "timestamp": "2026-07-10T19:02:50.723629+00:00", "exit_code": 0, - "tests_passed": 114, + "tests_passed": 320, "tests_failed": 0, - "coverage": 100.0, - "test_hash": "5b29a9c32adc97b9e4e2f6f3e22144897fa60e7fb81f0bde530f8a8286b07360", + "coverage": 55.0, + "test_hash": "4d7a9c10ba2f9925f7b567f885b6dd67dd48aa3e8757e347ddad9e6c7b6f3e02", "test_files": { - "test_agentic_checkup.py": "5b29a9c32adc97b9e4e2f6f3e22144897fa60e7fb81f0bde530f8a8286b07360", - "test_agentic_checkup_orchestrator.py": "1c1ccb805904a89177c37b51c405776b3549286aed20d0adc540b66fbc2e66c5" + "test_agentic_checkup.py": "4d7a9c10ba2f9925f7b567f885b6dd67dd48aa3e8757e347ddad9e6c7b6f3e02", + "test_agentic_checkup_orchestrator.py": "7bfdf5931599356de13b4129f62ad5ed7ae928e10ae391c88a2e16f62ebf8d5c", + "test_agentic_checkup_step5_pass_evidence.py": "a9d5ac08e1cbf1326ab352cffdfbb3bc84c280327b70413ef6f982860bf89e44" } -} +} \ No newline at end of file diff --git a/.pdd/meta/agentic_common_python.json b/.pdd/meta/agentic_common_python.json new file mode 100644 index 000000000..2f1323254 --- /dev/null +++ b/.pdd/meta/agentic_common_python.json @@ -0,0 +1,22 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:25:13.152104+00:00", + "command": "fix", + "prompt_hash": "54803fa4abf1f1994c7a97f82d7fe78896d9a642822fd3d8353aa0deea34b82f", + "code_hash": "f44c5a078380d246d1b060f942b36f9cce4f80b488a5605ef9c124a8486e9801", + "example_hash": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", + "test_hash": "a7880c45a6c77af83612a4128bd4ba36950d03bc12c602e44df46d8998a9e7bc", + "test_files": { + "test_agentic_common.py": "a7880c45a6c77af83612a4128bd4ba36950d03bc12c602e44df46d8998a9e7bc", + "test_agentic_common_issue_813_anthropic_api_key_oauth_shadow.py": "4207732c6fb0e88b5be1fe632acd087f0b992e1ac7498dff036a0b9b9ea72138", + "test_agentic_common_worktree.py": "16fb52387d635f1bbccbac20d08ef5b5d878021b70fc1ad79e5d36664df2b9a9" + }, + "include_deps": { + "context/_keyring_timeout_example.py": "41f83d5ef117caba86b788f69b519f7e88f3d7dc2a641a017d74a7cee9be6ed1", + "context/api_key_scanner_example.py": "4b9f880a30445f25edffc394857ae0c218097726c41ab996a03fcbc4350b5482", + "context/auth_service_example.py": "4b6d2c8c9e13d1edc0d9805ee0c07657494cbf31dffe2bb0029b72975674a8cf", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "pdd/agentic_common.py": "f44c5a078380d246d1b060f942b36f9cce4f80b488a5605ef9c124a8486e9801", + "pdd/llm_invoke.py": "2c423dbe12dd22d1acfca6c658bfef36eaf3b016626ae4a2845576500bfcb54b" + } +} diff --git a/.pdd/meta/agentic_common_python_run.json b/.pdd/meta/agentic_common_python_run.json index d5b510799..f5e0c4033 100644 --- a/.pdd/meta/agentic_common_python_run.json +++ b/.pdd/meta/agentic_common_python_run.json @@ -1,12 +1,12 @@ { - "timestamp": "2026-06-19T02:32:59.720540+00:00", + "timestamp": "2026-07-10T18:20:53.975487+00:00", "exit_code": 0, - "tests_passed": 613, + "tests_passed": 639, "tests_failed": 0, - "coverage": 75.0, - "test_hash": "849be60c2e722da754f5d9d86ff6821c3f731a05543d2d8d3f4a748b6e92a6c6", + "coverage": 78.0, + "test_hash": "a50d44bbb9e6202ca53cef2f3e60feb90b38b35f46dd4ac22211b1bd67cde659", "test_files": { - "test_agentic_common.py": "849be60c2e722da754f5d9d86ff6821c3f731a05543d2d8d3f4a748b6e92a6c6", + "test_agentic_common.py": "a50d44bbb9e6202ca53cef2f3e60feb90b38b35f46dd4ac22211b1bd67cde659", "test_agentic_common_issue_813_anthropic_api_key_oauth_shadow.py": "4207732c6fb0e88b5be1fe632acd087f0b992e1ac7498dff036a0b9b9ea72138", "test_agentic_common_worktree.py": "16fb52387d635f1bbccbac20d08ef5b5d878021b70fc1ad79e5d36664df2b9a9" } diff --git a/.pdd/meta/agentic_e2e_fix_orchestrator_python.json b/.pdd/meta/agentic_e2e_fix_orchestrator_python.json index 3714c38a3..70e063285 100644 --- a/.pdd/meta/agentic_e2e_fix_orchestrator_python.json +++ b/.pdd/meta/agentic_e2e_fix_orchestrator_python.json @@ -1,23 +1,24 @@ { - "pdd_version": "0.0.257", - "timestamp": "2026-06-02T05:35:45.135425+00:00", - "command": "test", - "prompt_hash": "3db920baa54e4b2a67fee0dc24de08e26087b7f60ce9687b2fd7261b14f1f621", - "code_hash": "605779bd8be274f89d045972413cd2ce394cf4e6c808e24bfd7f4674fb10b17d", - "example_hash": "8cc5f11b0430113ad52c918cdbabafe254cf01667eb66e1ac8837f5cea965024", - "test_hash": "f15b0ccd990d7ab178700b28211c4e46f29f991500272c73583149309572607c", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:25:13.227985+00:00", + "command": "fix", + "prompt_hash": "2b18ad860c2a89ca33c9ae2d22390c1e3c26a311c68d58585282136964df1801", + "code_hash": "d073b7ee1b875a4a689ebba035b1d39bc972d1aa3c927a8d9d4e47c1d195aa6b", + "example_hash": "180cc2c4316b531586040d61fc5f82f1f605d5c5736fd23bad63a889ac0b7d1e", + "test_hash": "ba44cbe6ff3b2a0ccf3e827ad5a88d2355a757259017a0c2f39f9aa92de5f5d5", "test_files": { - "test_agentic_e2e_fix_orchestrator.py": "f15b0ccd990d7ab178700b28211c4e46f29f991500272c73583149309572607c", + "test_agentic_e2e_fix_orchestrator.py": "ba44cbe6ff3b2a0ccf3e827ad5a88d2355a757259017a0c2f39f9aa92de5f5d5", "test_agentic_e2e_fix_orchestrator_resume_prompt.py": "8e29ea7eb6330f75fa53b2699663f417b43cb063b8ac5613f34cbf0a6ceaa917" }, "include_deps": { "context/agentic_bug_orchestrator_example.py": "23b545fc0f892e14ee9660902d3f7e04893f0b793246c1ea9d559656fd39ffe6", - "context/agentic_common_example.py": "113b5829cae3a9e38c362b74d5115ba09cab06c7be5831527d9829f97f7b3b7d", - "context/agentic_e2e_fix_orchestrator_example.py": "8cc5f11b0430113ad52c918cdbabafe254cf01667eb66e1ac8837f5cea965024", + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", + "context/agentic_e2e_fix_orchestrator_example.py": "180cc2c4316b531586040d61fc5f82f1f605d5c5736fd23bad63a889ac0b7d1e", "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", "context/agentic_test_orchestrator_example.py": "473901f6f62844c81a047fa69387c4116d91a522c62d5c6ad30188e2e99dfaa5", "context/change/16/fix_error_loop.py": "01872cfcc6c3f5095551acd109e06b35ece96c15ca75dcf40ac395c3ed867e40", "context/ci_validation_example.py": "06b910b216f68af58862e76712ccf82bae1bdf87f3fa95b043408fb079f45ce0", - "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254" + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "pdd/mock_contract_validation.py": "635d6bc4cccdf9cdb2dd638dd60379c756e51adbe870b72f65923d5cee2e07f0" } -} \ No newline at end of file +} diff --git a/.pdd/meta/agentic_e2e_fix_orchestrator_python_run.json b/.pdd/meta/agentic_e2e_fix_orchestrator_python_run.json index 10d3b6996..633e54074 100644 --- a/.pdd/meta/agentic_e2e_fix_orchestrator_python_run.json +++ b/.pdd/meta/agentic_e2e_fix_orchestrator_python_run.json @@ -1,12 +1,12 @@ { - "timestamp": "2026-06-02T05:34:03.811429+00:00", + "timestamp": "2026-07-11T02:08:25.325077+00:00", "exit_code": 0, - "tests_passed": 420, + "tests_passed": 422, "tests_failed": 0, - "coverage": 87.0, - "test_hash": "f15b0ccd990d7ab178700b28211c4e46f29f991500272c73583149309572607c", + "coverage": 85.0, + "test_hash": "c1880aa902e7e4650c5ca4900b9ab6adec5362c0706fbf4a393016626f14644f", "test_files": { - "test_agentic_e2e_fix_orchestrator.py": "f15b0ccd990d7ab178700b28211c4e46f29f991500272c73583149309572607c", + "test_agentic_e2e_fix_orchestrator.py": "c1880aa902e7e4650c5ca4900b9ab6adec5362c0706fbf4a393016626f14644f", "test_agentic_e2e_fix_orchestrator_resume_prompt.py": "8e29ea7eb6330f75fa53b2699663f417b43cb063b8ac5613f34cbf0a6ceaa917" } } \ No newline at end of file diff --git a/.pdd/meta/agentic_split_orchestrator_python.json b/.pdd/meta/agentic_split_orchestrator_python.json index 703edbae6..562c49728 100644 --- a/.pdd/meta/agentic_split_orchestrator_python.json +++ b/.pdd/meta/agentic_split_orchestrator_python.json @@ -1,16 +1,22 @@ { - "pdd_version": "0.0.234", - "timestamp": "2026-05-12T02:53:56.081699+00:00", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:13:57.666297+00:00", "command": "fix", - "prompt_hash": "a9590c82b2b9cbbc9c39e64580662b9e1d1b779225cdbb0e798fd81d92257b6d", - "code_hash": "daaa0487f9e9538f2536f8e3ed061efb0068b83cda8bcae9aa993a0d1955baf4", + "prompt_hash": "50dbefef6462f3aa22989be9576294defd3a73d5a58ac3b4eba87ded70c47860", + "code_hash": "6f3d9338f4ec1065da56cfca0bda453bfef18eb85046f8fb0c9c81bf8922110a", "example_hash": "3f521ffaf006c3ab7fad663edc7d4bcc89f4bafe81ba43e760e9fe99ef7ea46c", "test_hash": "be3d9b082577fc7fac8bae292c109ab2d9df7b376f775825168c69941c10bbb7", "test_files": { "test_agentic_split_orchestrator.py": "be3d9b082577fc7fac8bae292c109ab2d9df7b376f775825168c69941c10bbb7" }, "include_deps": { + "context/agentic_test_orchestrator_example.py": "473901f6f62844c81a047fa69387c4116d91a522c62d5c6ad30188e2e99dfaa5", "context/load_prompt_template_example.py": "a1cd6619182c6c951f5856dda4070e202875a5884bbfab9cc191d24de2f4951f", - "context/python_preamble.prompt": "57a3e51f529024ec0cb9658cd6ac61a7c8051ba0c8e887b31cf00b2e78a07d83" + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "pdd/agentic_common.py": "940260fc0f600000557345373cd7c0997b0672193bc9fd2c63e07ee3451c2357", + "pdd/agentic_common_worktree.py": "9d54b21c1801d1f708340917eeb265435d968698392e501c2d8d214da6f4ee3f", + "pdd/architecture_sync.py": "0e0ff74978f49d629f92365723ebbea0e7c70e6d5234ccfdafb1c8e09c110c11", + "pdd/preprocess.py": "d04a6cfc261c1e88076398a34d0bf96c4285061668866c5ad305e4b605ffdf56", + "pdd/split_validation.py": "e51a9512fc4cedbe362f25da3c97c23a89057901bff10c6b04eeefe77b79dafe" } -} \ No newline at end of file +} diff --git a/.pdd/meta/agentic_split_orchestrator_python_run.json b/.pdd/meta/agentic_split_orchestrator_python_run.json index 9f7b2cd82..5f46fac13 100644 --- a/.pdd/meta/agentic_split_orchestrator_python_run.json +++ b/.pdd/meta/agentic_split_orchestrator_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-05-12T02:53:56.082141+00:00", + "timestamp": "2026-07-10T18:23:20.453310+00:00", "exit_code": 0, - "tests_passed": 1, + "tests_passed": 108, "tests_failed": 0, - "coverage": 100.0, + "coverage": 70.0, "test_hash": "be3d9b082577fc7fac8bae292c109ab2d9df7b376f775825168c69941c10bbb7", "test_files": { "test_agentic_split_orchestrator.py": "be3d9b082577fc7fac8bae292c109ab2d9df7b376f775825168c69941c10bbb7" } -} +} \ No newline at end of file diff --git a/.pdd/meta/agentic_sync_python.json b/.pdd/meta/agentic_sync_python.json new file mode 100644 index 000000000..1c61b6abb --- /dev/null +++ b/.pdd/meta/agentic_sync_python.json @@ -0,0 +1,25 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:13:57.750934+00:00", + "command": "fix", + "prompt_hash": "4e91aeb36c66e1c9b088c00a67490eb4ffd74ffbf38867604b50ec639df7ac0a", + "code_hash": "6e20893bc88c09d03e7bc15f958b78b329ba4cf06d5a82d8a6cb168f7d028890", + "example_hash": "aa9683f1a26841f3cae854e72cce38636f0859d8dd2f7672fa14e412afdef884", + "test_hash": "5365a19305e0fc78b8fb0f21b6b7a16c4ed405bd75e32c3e6ddcd1e814b6a15b", + "test_files": { + "test_agentic_sync.py": "5365a19305e0fc78b8fb0f21b6b7a16c4ed405bd75e32c3e6ddcd1e814b6a15b", + "test_agentic_sync_fallback_realgit.py": "cad09f7c0d18a9983a106fbb688db018ff52a2ba46e5cbd3dfd86389ab1b32de", + "test_agentic_sync_mocked_e2e.py": "a8adc991a2796b06a4f8ef59c531eab632350784ca7961d1282d4fa8424b38fe", + "test_agentic_sync_nearest_config.py": "afbed1e4021978e9b7480f4bb80bdba737c4a0adffb7768b3191b14bbce6ca19", + "test_agentic_sync_runner.py": "daf57f3bb02e467053caf2fa5ac5b8d11cdad4db74016ee521d6e6f64c283621" + }, + "include_deps": { + "context/agentic_change_example.py": "a8308b2af708c804d626a4a96012c5a67f5bc6469e33857cafc07627213828d7", + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", + "context/agentic_sync_runner_example.py": "fcb36209abb5ab882ad551754fb574d62509c968f8c564e18163be7f2b7ef35a", + "context/architecture_sync_example.py": "b6158dbc7ca3fd32665528562225f6739ec521a071d845acab0f7caaea221ae0", + "context/auto_deps_main_example.py": "9b9c36c18d39494b6e31b46f6e4758c25135b6fa3d1672fbb7365844f7d2de04", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/sync_order_example.py": "b4eb1e167e7604c7dbac703bdcba7287188fa9d2d5ef6d490216d003cdc542fa" + } +} diff --git a/.pdd/meta/agentic_sync_runner_python.json b/.pdd/meta/agentic_sync_runner_python.json index 9a568d668..6dfcb9a8d 100644 --- a/.pdd/meta/agentic_sync_runner_python.json +++ b/.pdd/meta/agentic_sync_runner_python.json @@ -1,17 +1,17 @@ { - "pdd_version": "0.0.275.dev3", - "timestamp": "2026-06-16T01:31:18.161464+00:00", + "pdd_version": "0.0.285.dev18", + "timestamp": "2026-07-10T18:00:01.932912+00:00", "command": "fix", - "prompt_hash": "0784d6ff7f749b74e6938bfdd945a1cf97aed961ebea4bf3849fe512bf7163f9", - "code_hash": "587d51aaed8470de39bbefbb0882f481f269431cca8b50e45b22e90dbd2eb695", + "prompt_hash": "4571774c0bb35dffd36224f9c276f93c8771e2b0704dbdd3158c1cd266e1209d", + "code_hash": "c5e20362cb70875c77380e3d796faddab6dc78c5cbcaeb8dcbe8f50c32101add", "example_hash": "fcb36209abb5ab882ad551754fb574d62509c968f8c564e18163be7f2b7ef35a", - "test_hash": "781deb083113a3c96b990501f24cdf41e0742190be5cfbe85553639c25e62e22", + "test_hash": "814f438b8e8018aca64239ee0dfea179221fe4d50494b5f62b3c0ef3718d352c", "test_files": { - "test_agentic_sync_runner.py": "781deb083113a3c96b990501f24cdf41e0742190be5cfbe85553639c25e62e22" + "test_agentic_sync_runner.py": "814f438b8e8018aca64239ee0dfea179221fe4d50494b5f62b3c0ef3718d352c" }, "include_deps": { "context/agentic_change_example.py": "a8308b2af708c804d626a4a96012c5a67f5bc6469e33857cafc07627213828d7", - "context/agentic_common_example.py": "f9cdb30a932973d341b795faf510056b95573610af5ac48e3849cf42c205998e", + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", "context/agentic_test_orchestrator_example.py": "473901f6f62844c81a047fa69387c4116d91a522c62d5c6ad30188e2e99dfaa5", "context/architecture_sync_example.py": "b6158dbc7ca3fd32665528562225f6739ec521a071d845acab0f7caaea221ae0", diff --git a/.pdd/meta/agentic_sync_runner_python_run.json b/.pdd/meta/agentic_sync_runner_python_run.json index 7ebd1c453..163788cb1 100644 --- a/.pdd/meta/agentic_sync_runner_python_run.json +++ b/.pdd/meta/agentic_sync_runner_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-06-16T01:31:18.161464+00:00", + "timestamp": "2026-07-10T18:01:00.847074+00:00", "exit_code": 0, - "tests_passed": 180, + "tests_passed": 199, "tests_failed": 0, - "coverage": 81.25, - "test_hash": "781deb083113a3c96b990501f24cdf41e0742190be5cfbe85553639c25e62e22", + "coverage": 83.0, + "test_hash": "daf57f3bb02e467053caf2fa5ac5b8d11cdad4db74016ee521d6e6f64c283621", "test_files": { - "test_agentic_sync_runner.py": "781deb083113a3c96b990501f24cdf41e0742190be5cfbe85553639c25e62e22" + "test_agentic_sync_runner.py": "daf57f3bb02e467053caf2fa5ac5b8d11cdad4db74016ee521d6e6f64c283621" } -} +} \ No newline at end of file diff --git a/.pdd/meta/agentic_test_orchestrator_python.json b/.pdd/meta/agentic_test_orchestrator_python.json new file mode 100644 index 000000000..4b0e47a72 --- /dev/null +++ b/.pdd/meta/agentic_test_orchestrator_python.json @@ -0,0 +1,18 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:13:57.830320+00:00", + "command": "fix", + "prompt_hash": "09e046865f97222108bc23c241167cf70007d0e6e63052c62aa726880a76ae40", + "code_hash": "e479886cb01ee68643b64b2a941a03de761d54333114b3ff2ae756b0759ee94a", + "example_hash": "473901f6f62844c81a047fa69387c4116d91a522c62d5c6ad30188e2e99dfaa5", + "test_hash": "b352fb8c22880f5c95e74f3b2b7a89251fd13e6563ff2ec550f8e4f5e34807dd", + "test_files": { + "test_agentic_test_orchestrator.py": "b352fb8c22880f5c95e74f3b2b7a89251fd13e6563ff2ec550f8e4f5e34807dd" + }, + "include_deps": { + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", + "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", + "context/load_prompt_template_example.py": "a1cd6619182c6c951f5856dda4070e202875a5884bbfab9cc191d24de2f4951f", + "context/pytest_output_example.py": "6f0cd68e3c4440081267c716651caec4fbdd7d9c4516f967517f02ae2a853920" + } +} diff --git a/.pdd/meta/auto_deps_main_python.json b/.pdd/meta/auto_deps_main_python.json new file mode 100644 index 000000000..7a196dc14 --- /dev/null +++ b/.pdd/meta/auto_deps_main_python.json @@ -0,0 +1,23 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T02:48:06.368654+00:00", + "command": "stack-merge-reconcile", + "prompt_hash": "d0e0d9afcbc60a200d19103e6b924282289db9df7b2c5615115b97fa62869b4b", + "code_hash": "88bb0e9f944a490d2229a90d5a67953b64c5557655edaa73e46de8a04dcf7957", + "example_hash": "9b9c36c18d39494b6e31b46f6e4758c25135b6fa3d1672fbb7365844f7d2de04", + "test_hash": "9474088539b5aaa4e71bbf5f917a570e79f67f38e65c4d3a5a2c66a7fa3c3261", + "test_files": { + "test_auto_deps_main.py": "9474088539b5aaa4e71bbf5f917a570e79f67f38e65c4d3a5a2c66a7fa3c3261" + }, + "include_deps": { + "README.md": "f77c5f3e52e4d1accdb56d8194f6de3b17b2f9a977ef3433aa19ab01938acb0b", + "context/ctx_obj_params.prompt": "829e2ab327e37b75afcd0dcf3893dcaec9012a89a1f6e5198ec7cfc0395d2782", + "context/construct_paths_example.py": "412cb260650bcbd628fba647db917a8d32f8191df2e1c32cc22f22c0c11b1740", + "context/insert_includes_example.py": "c304bf4656f75fa26192c92b3eb0422a903d8a1bc82dda3158dc9f184b337873", + "context/operation_log_example.py": "2660def7012d9b79bad3db33cf399ac603cc1c35dff0b064cbdd2638a013de1d", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "pdd/auto_deps_architecture.py": "d20e3525f49abb1c4d4d923ed619e9f0b26cb29cbca12df2f22479b8d3926a44", + "pdd/operation_log.py": "98c26af6e32c82e1bd26e1abd03feb7a3e229178bf4c66ce01bd003d2bcaf566", + "pdd/validate_prompt_includes.py": "fa97b72a58534e255768634f928f4cfdd2130a7a4d8b296da3eefdf77d47855b" + } +} diff --git a/.pdd/meta/auto_deps_main_python_run.json b/.pdd/meta/auto_deps_main_python_run.json new file mode 100644 index 000000000..283d0e3b3 --- /dev/null +++ b/.pdd/meta/auto_deps_main_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-11T01:46:35.610773+00:00", + "exit_code": 0, + "tests_passed": 332, + "tests_failed": 0, + "coverage": 72.72727272727273, + "test_hash": "9474088539b5aaa4e71bbf5f917a570e79f67f38e65c4d3a5a2c66a7fa3c3261", + "test_files": { + "test_auto_deps_main.py": "9474088539b5aaa4e71bbf5f917a570e79f67f38e65c4d3a5a2c66a7fa3c3261" + } +} \ No newline at end of file diff --git a/.pdd/meta/checkup_review_loop_python.json b/.pdd/meta/checkup_review_loop_python.json index 1710fae58..ee53b4a8a 100644 --- a/.pdd/meta/checkup_review_loop_python.json +++ b/.pdd/meta/checkup_review_loop_python.json @@ -1,13 +1,13 @@ { - "pdd_version": "0.0.288.dev0", - "timestamp": "2026-06-26T23:09:14.954776+00:00", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:20:49.551201+00:00", "command": "fix", - "prompt_hash": "05eafdbdedc8b12e80c5ac70d860902292ba364ff2dcf5da19959e9862ace5ef", - "code_hash": "a49524b05e78c8969518f44a654f9f90e869225fe94f7ad5d7ed3c675c1eead7", - "example_hash": "b392eaa48f9bd84660d4a2aaec04c5237a7791059c6ffd64288b34eadce686bb", - "test_hash": "e388afeff3508ef3e87be8261fe8400d7cdec20eabcb4b31717e755019bc41fa", + "prompt_hash": "605057d29fec2c02773baa1b2855e12cfc624291d44de76b9ce53a8569130ad9", + "code_hash": "90e1cc5a6ff9846b602e501f6321a9540e83ce639eed3925f90f277cc1714fd0", + "example_hash": "9d94a966f8a8bf617bb80bf1613e77dfdaba702270a61980f31ac3e95df525ba", + "test_hash": "c6f12b95b8125bedadfd0d677c9f7fb27e32d90e03d215cbc72e8c21460b789f", "test_files": { - "test_checkup_review_loop.py": "e388afeff3508ef3e87be8261fe8400d7cdec20eabcb4b31717e755019bc41fa" + "test_checkup_review_loop.py": "c6f12b95b8125bedadfd0d677c9f7fb27e32d90e03d215cbc72e8c21460b789f" }, "include_deps": { "context/agentic_change_example.py": "a8308b2af708c804d626a4a96012c5a67f5bc6469e33857cafc07627213828d7", diff --git a/.pdd/meta/checkup_review_loop_python_run.json b/.pdd/meta/checkup_review_loop_python_run.json index fa8e859d4..629eed315 100644 --- a/.pdd/meta/checkup_review_loop_python_run.json +++ b/.pdd/meta/checkup_review_loop_python_run.json @@ -1,12 +1,11 @@ { - "timestamp": "2026-05-20T16:52:45.063714+00:00", + "timestamp": "2026-07-10T19:05:10.622653+00:00", "exit_code": 0, - "tests_passed": 198, + "tests_passed": 316, "tests_failed": 0, - "tests_skipped": 1, - "coverage": 100.0, - "test_hash": "c122222d4a13be38d2c7e3ab0c4ffe8205a176fb4f7ff88cb6437d6eb3ad2ea5", + "coverage": 86.0, + "test_hash": "56b13078800ce514732dfd30c2fe161fc47b103507bc33f5e1d5a02a4cd2445b", "test_files": { - "test_checkup_review_loop.py": "c122222d4a13be38d2c7e3ab0c4ffe8205a176fb4f7ff88cb6437d6eb3ad2ea5" + "test_checkup_review_loop.py": "56b13078800ce514732dfd30c2fe161fc47b103507bc33f5e1d5a02a4cd2445b" } -} +} \ No newline at end of file diff --git a/.pdd/meta/ci_drift_heal_python.json b/.pdd/meta/ci_drift_heal_python.json index 40ec37cd0..8940bcfd4 100644 --- a/.pdd/meta/ci_drift_heal_python.json +++ b/.pdd/meta/ci_drift_heal_python.json @@ -1,9 +1,9 @@ { - "pdd_version": "0.0.296.dev0", - "timestamp": "2026-07-10T19:43:29.185846+00:00", - "command": "fix", - "prompt_hash": "26d9fef09114b1da971d431c3f7876b923c4086199f9d55522aedfa33b9392be", - "code_hash": "680193331b1f6e73d1cfe40b88f06989db27ca1d4324faa2f9e781db3b08fc36", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T01:46:35.668985+00:00", + "command": "test", + "prompt_hash": "d4b589ef626757b2374bd4975d10090798d5561ce7e39f1f223b38daa55fd2bc", + "code_hash": "a589d330590eca8bd0a1da64a78e124dc12a7b373e5b340d385a19d85ba69a56", "example_hash": "550e750fb041fb73dc92462d4f266bc999102ddd8e4efd3b40dbab0bb1d0d879", "test_hash": "03ea52b7e66a6a3ec720417bc19e05ff577d76dc8a381e9f8f83552f6d8ce6f2", "test_files": { @@ -21,4 +21,4 @@ "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", "context/sync_determine_operation_example.py": "225d20ea0210bbf52650a9f424879ff495e574dd2411eb4b86592a6d06cf59a5" } -} \ No newline at end of file +} diff --git a/.pdd/meta/ci_drift_heal_python_run.json b/.pdd/meta/ci_drift_heal_python_run.json index 844d8196a..f24b56dfd 100644 --- a/.pdd/meta/ci_drift_heal_python_run.json +++ b/.pdd/meta/ci_drift_heal_python_run.json @@ -1,12 +1,12 @@ { - "timestamp": "2026-07-09T11:33:33.405832+00:00", + "timestamp": "2026-07-11T01:46:35.671629+00:00", "exit_code": 0, - "tests_passed": 195, + "tests_passed": 249, "tests_failed": 0, - "coverage": 83.0, - "test_hash": "04bbe525d057462cb9e669037e210629f850dce9a42b5c08032cbbbb642abdf1", + "coverage": 74.82078853046595, + "test_hash": "03ea52b7e66a6a3ec720417bc19e05ff577d76dc8a381e9f8f83552f6d8ce6f2", "test_files": { - "test_ci_drift_heal.py": "04bbe525d057462cb9e669037e210629f850dce9a42b5c08032cbbbb642abdf1", + "test_ci_drift_heal.py": "03ea52b7e66a6a3ec720417bc19e05ff577d76dc8a381e9f8f83552f6d8ce6f2", "test_ci_drift_heal_e2e.py": "dfcd225cc5a6442ba11e9cd41b79568a57d97b7804ee6ea66f4191aa068478e3" } } \ No newline at end of file diff --git a/.pdd/meta/ci_validation_python.json b/.pdd/meta/ci_validation_python.json new file mode 100644 index 000000000..58e389242 --- /dev/null +++ b/.pdd/meta/ci_validation_python.json @@ -0,0 +1,17 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:13:57.997262+00:00", + "command": "fix", + "prompt_hash": "0b601b113a5c7cd2f1ba3f8363679c82da717fab1fcb842ec305bc61826f531e", + "code_hash": "2569b59da2c30dfa7108c05a878823dc33d2555c58b3e0ed601432dc39a7156d", + "example_hash": "06b910b216f68af58862e76712ccf82bae1bdf87f3fa95b043408fb079f45ce0", + "test_hash": "8677080ce6f68da4e90d98c41d940205e5e8db2f875c6f0fe28ebbc20c6f6479", + "test_files": { + "test_ci_validation.py": "8677080ce6f68da4e90d98c41d940205e5e8db2f875c6f0fe28ebbc20c6f6479" + }, + "include_deps": { + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", + "context/ci_validation_example.py": "06b910b216f68af58862e76712ccf82bae1bdf87f3fa95b043408fb079f45ce0", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254" + } +} diff --git a/.pdd/meta/cmd_test_main_python.json b/.pdd/meta/cmd_test_main_python.json new file mode 100644 index 000000000..b6b72b72d --- /dev/null +++ b/.pdd/meta/cmd_test_main_python.json @@ -0,0 +1,22 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T03:12:36.366278+00:00", + "command": "test", + "prompt_hash": "6e2483dda625c705e428017535968996872c5f5848f3147d2b855b21e4d921b5", + "code_hash": "01c0427c2e3999e4f4a7ddd6ded99606dba20f89055646212580d918a30841b7", + "example_hash": "0774984ee29f70407429f1eff4a1b76287715b7ddd6adf2bc149e874c9b844bb", + "test_hash": "d940dca46c808464348228dba68d1f88a884ff9ea0d827ce9c4e8ef27e78d643", + "test_files": { + "test_cmd_test_main.py": "d940dca46c808464348228dba68d1f88a884ff9ea0d827ce9c4e8ef27e78d643" + }, + "include_deps": { + "README.md": "3222002a883ccb289fa3c6b402e79621ff18bf697de9fcc401fe5363af984b40", + "context/ctx_obj_params.prompt": "829e2ab327e37b75afcd0dcf3893dcaec9012a89a1f6e5198ec7cfc0395d2782", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/cloud_function_call.py": "62ac7724ce2e3eddbba2b743bd7aa5790dbed34eae01e77e0252c4cc6358c95e", + "context/construct_paths_example.py": "412cb260650bcbd628fba647db917a8d32f8191df2e1c32cc22f22c0c11b1740", + "context/generate_test_example.py": "f73533a9cf63f2e2f4818ee923a8500e612d155e230017dbdb35aec945e41998", + "context/get_jwt_token_example.py": "76a87c8257cb1f4cfdc2edff6225c752432afb3a5e352fcb016d08d8eae16e01", + "context/increase_tests_example.py": "f53be429e9a9e6a523c770ed6d0d4498e2201b22d5a8e4ef28ac64e8ff172c91" + } +} diff --git a/.pdd/meta/cmd_test_main_python_run.json b/.pdd/meta/cmd_test_main_python_run.json new file mode 100644 index 000000000..1a7aad9f0 --- /dev/null +++ b/.pdd/meta/cmd_test_main_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-11T03:12:36.367261+00:00", + "exit_code": 0, + "tests_passed": 56, + "tests_failed": 0, + "coverage": 81.67, + "test_hash": "d940dca46c808464348228dba68d1f88a884ff9ea0d827ce9c4e8ef27e78d643", + "test_files": { + "test_cmd_test_main.py": "d940dca46c808464348228dba68d1f88a884ff9ea0d827ce9c4e8ef27e78d643" + } +} \ No newline at end of file diff --git a/.pdd/meta/code_generator_main_python.json b/.pdd/meta/code_generator_main_python.json index 0f74e423f..469bc1676 100644 --- a/.pdd/meta/code_generator_main_python.json +++ b/.pdd/meta/code_generator_main_python.json @@ -1,29 +1,29 @@ { - "pdd_version": "0.0.268", - "timestamp": "2026-06-09T23:30:32.073262+00:00", - "command": "example", - "prompt_hash": "9c646810b3e55fa6ba5c0bb1d8210e459446ffa6826c565d7012ea6bd55fb6d5", - "code_hash": "1293a25723fdcb7870711b4759248fe4681d5107cf2106feff61df57cb56ee96", - "example_hash": "25bb0d4b5fe4f31d0d4d3795d24e8e9bb2cd346c74e4fc916d0a9ba8590141be", - "test_hash": "3cc05a05384f982254fdb38d8a156fc7507a1dfec200fc91705d01fbbfad134d", + "pdd_version": "0.0.285.dev18", + "timestamp": "2026-07-10T18:53:23.229996+00:00", + "command": "fix", + "prompt_hash": "5f1519e4f1293c084a93c324260dd076d83ff86f5a36bef8aa74af474d59b848", + "code_hash": "bfaaac97b5df128ed94c13ba494b8c789bb35e8351c4f435da8b8c60924d2e10", + "example_hash": "4b66f24a1e2ac4195255738566b0cccdac0971523a245d3a8b78ad45d9e1a872", + "test_hash": "9d49863b11ac694a43fea4682db8bd95daeec581157fba91e5a8d7e949f49af6", "test_files": { - "test_code_generator_main.py": "3cc05a05384f982254fdb38d8a156fc7507a1dfec200fc91705d01fbbfad134d" + "test_code_generator_main.py": "9d49863b11ac694a43fea4682db8bd95daeec581157fba91e5a8d7e949f49af6" }, "include_deps": { "context/__init__example.py": "84208a445d03a336e465709ec0687eb969c8d865e6739bf8b79f2c8a8aad351a", "context/_keyring_timeout_example.py": "41f83d5ef117caba86b788f69b519f7e88f3d7dc2a641a017d74a7cee9be6ed1", "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", "context/api_key_scanner_example.py": "4b9f880a30445f25edffc394857ae0c218097726c41ab996a03fcbc4350b5482", - "context/architecture_sync_example.py": "6e1f65de35fcf1dbd0def81710da6f378495b37187eb01cd01ef43f662d9f1ba", + "context/architecture_sync_example.py": "b6158dbc7ca3fd32665528562225f6739ec521a071d845acab0f7caaea221ae0", "context/auth_service_example.py": "4b6d2c8c9e13d1edc0d9805ee0c07657494cbf31dffe2bb0029b72975674a8cf", "context/auto_include_example.py": "0c278ed716298149777bc7082827eb8aa6d80dac264c66b6cded6871232f2df7", "pdd/code_generator.py": "33235364b431edbb0fb1c4d8eb03fa21a3b776e07ea585530c19212da20c486d", - "pdd/code_generator_main.py": "1293a25723fdcb7870711b4759248fe4681d5107cf2106feff61df57cb56ee96", - "pdd/construct_paths.py": "832d478849bfe83c12d4a473c270902d0af0be577ae3276b07ef8a48c148d35f", + "pdd/code_generator_main.py": "bfaaac97b5df128ed94c13ba494b8c789bb35e8351c4f435da8b8c60924d2e10", + "pdd/construct_paths.py": "7b42a002e4ecc27be4d517cbc07f1fc9425e795d4918714f382d2ed5ddd00eab", "pdd/core/cloud.py": "0487c0b989996af144df3af1c818a6eeafe52fd209710e5a54eb43990c89ae97", - "pdd/incremental_code_generator.py": "266a4bed6ea41519a1b1c3c33057cb33cffecd2a3075ee0c2f790798bfff4834", - "pdd/preprocess.py": "a6c86dd348d0028a0638114338013e4633fb66fa51f756bc30458f4dce833703", + "pdd/incremental_code_generator.py": "8dd5dd7f3016b8fae86056484fdc49b824405cdcd6911e8b8657d3e043b3b06d", + "pdd/preprocess.py": "d04a6cfc261c1e88076398a34d0bf96c4285061668866c5ad305e4b605ffdf56", "pdd/python_env_detector.py": "cbe4044a83cd88a683f36d6ecfca245fef6a03ea2abc7f5c407636fccc051f35", - "prompts/context_snapshot_python.prompt": "d8e129b55b27814d16e7566f07dfdaae0cc6950352f351fd78cfeeab125a7cbf" + "prompts/context_snapshot_python.prompt": "05f46fa9adba7ed3c9c6088d150ad821c723e9d0a3efed9db2fe8a461386da9d" } -} \ No newline at end of file +} diff --git a/.pdd/meta/code_generator_main_python_run.json b/.pdd/meta/code_generator_main_python_run.json new file mode 100644 index 000000000..f039c7a3c --- /dev/null +++ b/.pdd/meta/code_generator_main_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-10T18:02:11.167991+00:00", + "exit_code": 0, + "tests_passed": 371, + "tests_failed": 0, + "coverage": 71.0, + "test_hash": "eadc171b2a83407c9c3503355389d86bd9730d1590338c776731b9ae14e4b74e", + "test_files": { + "test_code_generator_main.py": "eadc171b2a83407c9c3503355389d86bd9730d1590338c776731b9ae14e4b74e" + } +} diff --git a/.pdd/meta/content_selector_python.json b/.pdd/meta/content_selector_python.json new file mode 100644 index 000000000..3ea9cc9ac --- /dev/null +++ b/.pdd/meta/content_selector_python.json @@ -0,0 +1,17 @@ +{ + "pdd_version": "0.0.300.dev0", + "timestamp": "2026-07-10T18:41:46.408610+00:00", + "command": "fix", + "prompt_hash": "d8a2e973a96f18f2f21f3c38279dafe1c189f4c79d3b723cd36a7e115c2a94b4", + "code_hash": "4f08611362cb5b1223ebe3af10c12fb4842341c19095e6777996e7fb3c2ce827", + "example_hash": "1b71d5d614f81c6543cc3a437ed8fcaa810372b06dc9bf23bde3cd09d9d715e6", + "test_hash": "acfbcbea92e5b943890af0983083d9dca84c7b4c6dd8d0980ec303815f45dbc6", + "test_files": { + "test_content_selector.py": "acfbcbea92e5b943890af0983083d9dca84c7b4c6dd8d0980ec303815f45dbc6" + }, + "include_deps": { + "context/core/errors_example.py": "b50dbf08aec028bd57a8fe74762bf92aee9a1fe953fb3d7db50cc686c72bfee3", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "pdd/pytest_slicer.py": "c02a5488fe6f7248ed387636d5824cb2f1876544d3991e43ef253648a36e49a2" + } +} diff --git a/.pdd/meta/content_selector_python_run.json b/.pdd/meta/content_selector_python_run.json new file mode 100644 index 000000000..9a1cad48c --- /dev/null +++ b/.pdd/meta/content_selector_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-10T19:08:31.296834+00:00", + "exit_code": 0, + "tests_passed": 96, + "tests_failed": 0, + "coverage": 51.0, + "test_hash": "acfbcbea92e5b943890af0983083d9dca84c7b4c6dd8d0980ec303815f45dbc6", + "test_files": { + "test_content_selector.py": "acfbcbea92e5b943890af0983083d9dca84c7b4c6dd8d0980ec303815f45dbc6" + } +} \ No newline at end of file diff --git a/.pdd/meta/core_cli_python.json b/.pdd/meta/core_cli_python.json new file mode 100644 index 000000000..986c1fc35 --- /dev/null +++ b/.pdd/meta/core_cli_python.json @@ -0,0 +1,16 @@ +{ + "pdd_version": "0.0.302.dev0", + "timestamp": "2026-07-11T21:34:57.998075+00:00", + "command": "fix", + "prompt_hash": "041ab8f4feba77b02ce03ce3e6063becb71d17ef1b32d4906eeca29e555aefc8", + "code_hash": "bd12418a3af2519ad7a365ae4f27588489d78e56af22845df697fbd758319160", + "example_hash": null, + "test_hash": null, + "test_files": {}, + "include_deps": { + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", + "context/auto_update_example.py": "4fc8ab0da96c0e8abe793d195dbc64b6b9771631be818eecde059769e5914fc9", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/track_cost_example.py": "bd60efaa7d6274b0e4d1743ac05785461af65ec785b254730288d08774f72ed6" + } +} diff --git a/.pdd/meta/durable_sync_runner_python.json b/.pdd/meta/durable_sync_runner_python.json index cc5f8fafc..f87245cb5 100644 --- a/.pdd/meta/durable_sync_runner_python.json +++ b/.pdd/meta/durable_sync_runner_python.json @@ -2,15 +2,15 @@ "pdd_version": "0.0.275.dev3", "timestamp": "2026-06-16T02:09:40.279393+00:00", "command": "fix", - "prompt_hash": "607af0a8fd816de522eed6823ffa1865e52fd97893ea7a1d2c62381f3aaa1681", - "code_hash": "b95c3f7c268f7e91650068af248a25ad89ac9c133673c550cace9e22c4079265", + "prompt_hash": "649119a07eeb8843d23ad45004c8a0b61d2f923fac049343f14af99b1d5634ed", + "code_hash": "a67a6e933c9e9e294dfaa00448d544eb73d4978bc82e2d89a33061d49d06008d", "example_hash": null, - "test_hash": "088d55daf37c57130e44c01d4e13fd2f259e2a83dba229f693aa2653abcd15dd", + "test_hash": "433712c739e1a1a50d46314f520f6bac10ce302a17fa290284626d3491f0bcb2", "test_files": { - "test_durable_sync_runner.py": "088d55daf37c57130e44c01d4e13fd2f259e2a83dba229f693aa2653abcd15dd" + "test_durable_sync_runner.py": "433712c739e1a1a50d46314f520f6bac10ce302a17fa290284626d3491f0bcb2" }, "include_deps": { - "context/agentic_sync_example.py": "9bcc2f199a1463e8c424d8210ea6d5f34ad6fb5fcb1022aa879a9ecba0f8d5d6", + "context/agentic_sync_example.py": "aa9683f1a26841f3cae854e72cce38636f0859d8dd2f7672fa14e412afdef884", "context/agentic_sync_runner_example.py": "fcb36209abb5ab882ad551754fb574d62509c968f8c564e18163be7f2b7ef35a", "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", "context/sync_order_example.py": "b4eb1e167e7604c7dbac703bdcba7287188fa9d2d5ef6d490216d003cdc542fa" diff --git a/.pdd/meta/evidence_manifest_python.json b/.pdd/meta/evidence_manifest_python.json new file mode 100644 index 000000000..3206d8ee3 --- /dev/null +++ b/.pdd/meta/evidence_manifest_python.json @@ -0,0 +1,15 @@ +{ + "pdd_version": "0.0.300.dev0", + "timestamp": "2026-07-10T19:17:29.817095+00:00", + "command": "test", + "prompt_hash": "bcc43cb101d0e5d9fd31b625861f1b7b918e2e5ee5b77e491f262f72f9507dfc", + "code_hash": "f5c96199f48ef4cf1d2864e527d27febbd634b617192df0a8ae07e4f76d29174", + "example_hash": "9b2ad456d2c3a523fed9077b07f021897fa1176d8ea2bdf12624a1e54942804b", + "test_hash": "8e933218408181768f0fdd7cd8a6309c01f3ee9d4f98241d5f92b6b4e9ae70d7", + "test_files": { + "test_evidence_manifest.py": "8e933218408181768f0fdd7cd8a6309c01f3ee9d4f98241d5f92b6b4e9ae70d7" + }, + "include_deps": { + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254" + } +} \ No newline at end of file diff --git a/.pdd/meta/evidence_manifest_python_run.json b/.pdd/meta/evidence_manifest_python_run.json new file mode 100644 index 000000000..cfa7717e6 --- /dev/null +++ b/.pdd/meta/evidence_manifest_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-10T19:08:51.327506+00:00", + "exit_code": 0, + "tests_passed": 20, + "tests_failed": 0, + "coverage": 74.0, + "test_hash": "8e933218408181768f0fdd7cd8a6309c01f3ee9d4f98241d5f92b6b4e9ae70d7", + "test_files": { + "test_evidence_manifest.py": "8e933218408181768f0fdd7cd8a6309c01f3ee9d4f98241d5f92b6b4e9ae70d7" + } +} \ No newline at end of file diff --git a/.pdd/meta/fingerprint_transaction_python.json b/.pdd/meta/fingerprint_transaction_python.json new file mode 100644 index 000000000..7b8dc2eec --- /dev/null +++ b/.pdd/meta/fingerprint_transaction_python.json @@ -0,0 +1,19 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T02:02:14.872840+00:00", + "command": "test", + "prompt_hash": "5f7ff44feb08e58920f6799ba5b7b84ae85f4db3804efb52d662509ebc1395fb", + "code_hash": "e268904612b5ef15567878153db131176b5dbe5889830db1d313170c1ccce9ea", + "example_hash": "1c8d97703bf0d55809b9508bc2be174dfa40b1f55465aec2567b95109ef38398", + "test_hash": "7d7c640c05c9eb5d9bba0d2654df887f586b7944b2767ee751ba155a2324d4c0", + "test_files": { + "test_fingerprint_transaction.py": "7d7c640c05c9eb5d9bba0d2654df887f586b7944b2767ee751ba155a2324d4c0" + }, + "include_deps": { + "context/fingerprint_transaction_example.py": "1c8d97703bf0d55809b9508bc2be174dfa40b1f55465aec2567b95109ef38398", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "pdd/json_atomic.py": "1e464b0e63e8f7a48727a0b2c0cd5e91ae766bd8182c205bf069aa8f6ad81351", + "pdd/operation_log.py": "98c26af6e32c82e1bd26e1abd03feb7a3e229178bf4c66ce01bd003d2bcaf566", + "pdd/sync_determine_operation.py": "960eae34071f0c76de617793e6ea41ed7dc651e68db64260239fc1ae59b001c8" + } +} diff --git a/.pdd/meta/fingerprint_transaction_python_run.json b/.pdd/meta/fingerprint_transaction_python_run.json new file mode 100644 index 000000000..91ec66243 --- /dev/null +++ b/.pdd/meta/fingerprint_transaction_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-11T01:46:35.732618+00:00", + "exit_code": 0, + "tests_passed": 332, + "tests_failed": 0, + "coverage": 75.0, + "test_hash": "7d7c640c05c9eb5d9bba0d2654df887f586b7944b2767ee751ba155a2324d4c0", + "test_files": { + "test_fingerprint_transaction.py": "7d7c640c05c9eb5d9bba0d2654df887f586b7944b2767ee751ba155a2324d4c0" + } +} \ No newline at end of file diff --git a/.pdd/meta/fix_main_python.json b/.pdd/meta/fix_main_python.json new file mode 100644 index 000000000..e08b051b2 --- /dev/null +++ b/.pdd/meta/fix_main_python.json @@ -0,0 +1,24 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T02:08:25.259032+00:00", + "command": "test", + "prompt_hash": "bb1631e04f3dd8bcae7b748acf8d4e9bd479d7741813157ae19c8d4db5eac731", + "code_hash": "e82c40831b59e97f35234d765e43756f978ed924baa43d7b616306982bcf5aa7", + "example_hash": "29f88d8bfcc2256ebebe6efcb611f9487eb5ca6d8781286a6dd98689f92bca7a", + "test_hash": "6a19d93c9375efa0972129a4642f5e84663dc939822bffe1543cfeda87741789", + "test_files": { + "test_fix_main.py": "6a19d93c9375efa0972129a4642f5e84663dc939822bffe1543cfeda87741789", + "test_fix_main_issue_232.py": "f33bb063df3cb570b5b46e3361cf3339be65de7fdabe249d8ba0f15893ba2207" + }, + "include_deps": { + "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", + "context/auth_service_example.py": "4b6d2c8c9e13d1edc0d9805ee0c07657494cbf31dffe2bb0029b72975674a8cf", + "context/change/10/initial_fix_errors_from_unit_tests.py": "7a3eb969bcaa76dc85d59c4013a41a7da5bb7472daf97df2b2cad0475642d2c7", + "context/change/15/initial_cli.py": "b607c3e67702ad3bc5e61cc40ce85571e4ff09015de96016c8d7d56eb78b8c74", + "context/change/16/fix_error_loop.py": "01872cfcc6c3f5095551acd109e06b35ece96c15ca75dcf40ac395c3ed867e40", + "context/ctx_obj_params.prompt": "829e2ab327e37b75afcd0dcf3893dcaec9012a89a1f6e5198ec7cfc0395d2782", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "pdd/fix_focus.py": "e341e6bda871ddc5ba92cf05e9d554d49bc5e4afebf5b4f1482971edf557063d", + "pdd/mock_contract_validation.py": "635d6bc4cccdf9cdb2dd638dd60379c756e51adbe870b72f65923d5cee2e07f0" + } +} diff --git a/.pdd/meta/fix_main_python_run.json b/.pdd/meta/fix_main_python_run.json new file mode 100644 index 000000000..db0009918 --- /dev/null +++ b/.pdd/meta/fix_main_python_run.json @@ -0,0 +1,12 @@ +{ + "timestamp": "2026-07-11T02:08:25.256094+00:00", + "exit_code": 0, + "tests_passed": 51, + "tests_failed": 0, + "coverage": 72.0, + "test_hash": "6a19d93c9375efa0972129a4642f5e84663dc939822bffe1543cfeda87741789", + "test_files": { + "test_fix_main.py": "6a19d93c9375efa0972129a4642f5e84663dc939822bffe1543cfeda87741789", + "test_fix_main_issue_232.py": "f33bb063df3cb570b5b46e3361cf3339be65de7fdabe249d8ba0f15893ba2207" + } +} \ No newline at end of file diff --git a/.pdd/meta/metadata_sync_python.json b/.pdd/meta/metadata_sync_python.json index bba992e11..67dcc8427 100644 --- a/.pdd/meta/metadata_sync_python.json +++ b/.pdd/meta/metadata_sync_python.json @@ -1,19 +1,19 @@ { - "pdd_version": "0.0.237", - "timestamp": "2026-05-16T20:50:00.397700+00:00", - "command": "fix", - "prompt_hash": "18e8e073748217aa9c224bea7c7e8425ea36de28f769b30684cd21033018bfab", - "code_hash": "739ace489ae1bf00a9aa696332dabce993b934047228fe0d10a1ef9145130fb2", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T02:02:24.456787+00:00", + "command": "test", + "prompt_hash": "916a825a5182a72f0a2b30ce61669cbbd293f4d1416d0897bad2e9efe788f3da", + "code_hash": "09378299e0839951715866e3250d25a40a28c997d512bbb3c37b13cff7b2ec5a", "example_hash": "704453b3386724eef225b61c48f2fe922e1a973ac211c494e1d955dd44bcca11", - "test_hash": "a1bf7b5a8ce6c671aee77fca08edd29eea86e93cce2d02871d0bd25b31591dc3", + "test_hash": "84d99aee5dd190a0558e8195717e3a6ca3d0c5038474e5fb1a1beff80e4b89b7", "test_files": { - "test_metadata_sync.py": "a1bf7b5a8ce6c671aee77fca08edd29eea86e93cce2d02871d0bd25b31591dc3" + "test_metadata_sync.py": "84d99aee5dd190a0558e8195717e3a6ca3d0c5038474e5fb1a1beff80e4b89b7" }, "include_deps": { "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", - "pdd/architecture_registry.py": "f822c4ef9d66da1dc6e6104ad3ca99d822c277423e71bb807966e4fc80f779ed", - "pdd/architecture_sync.py": "89329e32dd4d13e7b095f1b96843e383936bbc5f329e50daf006dbb12af0d54b", - "pdd/operation_log.py": "78b05aadd7656dbabb7cdd113fa84d39d32c64e4cb4322aa2ac2570d390d94ef", - "pdd/sync_determine_operation.py": "dff5e2b0628122c49305106d255e91d57446d7c768d4ae78fa4a09a619383e23" + "pdd/architecture_registry.py": "5722225846b9cc8189b410a78e5714b4f2916d5748271d27f6f738692429c309", + "pdd/architecture_sync.py": "0e0ff74978f49d629f92365723ebbea0e7c70e6d5234ccfdafb1c8e09c110c11", + "pdd/operation_log.py": "98c26af6e32c82e1bd26e1abd03feb7a3e229178bf4c66ce01bd003d2bcaf566", + "pdd/sync_determine_operation.py": "960eae34071f0c76de617793e6ea41ed7dc651e68db64260239fc1ae59b001c8" } } diff --git a/.pdd/meta/metadata_sync_python_run.json b/.pdd/meta/metadata_sync_python_run.json index d7d4e6e49..6809e5526 100644 --- a/.pdd/meta/metadata_sync_python_run.json +++ b/.pdd/meta/metadata_sync_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-05-15T18:09:53.425787+00:00", + "timestamp": "2026-07-11T01:46:35.793487+00:00", "exit_code": 0, - "tests_passed": 45, + "tests_passed": 249, "tests_failed": 0, - "coverage": 100.0, - "test_hash": "a1bf7b5a8ce6c671aee77fca08edd29eea86e93cce2d02871d0bd25b31591dc3", + "coverage": 73.64864864864865, + "test_hash": "84d99aee5dd190a0558e8195717e3a6ca3d0c5038474e5fb1a1beff80e4b89b7", "test_files": { - "test_metadata_sync.py": "a1bf7b5a8ce6c671aee77fca08edd29eea86e93cce2d02871d0bd25b31591dc3" + "test_metadata_sync.py": "84d99aee5dd190a0558e8195717e3a6ca3d0c5038474e5fb1a1beff80e4b89b7" } -} +} \ No newline at end of file diff --git a/.pdd/meta/mock_contract_validation_python.json b/.pdd/meta/mock_contract_validation_python.json new file mode 100644 index 000000000..112e437c7 --- /dev/null +++ b/.pdd/meta/mock_contract_validation_python.json @@ -0,0 +1,16 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T02:08:25.193857+00:00", + "command": "test", + "prompt_hash": "185171a20454f0f20a26a56be8da89353524d100fc1a06c301f11fb45047bb8c", + "code_hash": "635d6bc4cccdf9cdb2dd638dd60379c756e51adbe870b72f65923d5cee2e07f0", + "example_hash": "8a972981f506196a0a6061e859c84e65644d9a069fd380a5cc2659f7c669cc7f", + "test_hash": "9b9f5260c88a3645cd163b0fef0e1071e83bdec61cd2618aed334a973521eb72", + "test_files": { + "test_mock_contract_validation.py": "9b9f5260c88a3645cd163b0fef0e1071e83bdec61cd2618aed334a973521eb72" + }, + "include_deps": { + "context/mock_contract_validation_example.py": "8a972981f506196a0a6061e859c84e65644d9a069fd380a5cc2659f7c669cc7f", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254" + } +} diff --git a/.pdd/meta/mock_contract_validation_python_run.json b/.pdd/meta/mock_contract_validation_python_run.json new file mode 100644 index 000000000..d3a7585dc --- /dev/null +++ b/.pdd/meta/mock_contract_validation_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-11T02:08:25.192501+00:00", + "exit_code": 0, + "tests_passed": 22, + "tests_failed": 0, + "coverage": 92.0, + "test_hash": "9b9f5260c88a3645cd163b0fef0e1071e83bdec61cd2618aed334a973521eb72", + "test_files": { + "test_mock_contract_validation.py": "9b9f5260c88a3645cd163b0fef0e1071e83bdec61cd2618aed334a973521eb72" + } +} \ No newline at end of file diff --git a/.pdd/meta/one_session_sync_python.json b/.pdd/meta/one_session_sync_python.json new file mode 100644 index 000000000..6f1ec1a59 --- /dev/null +++ b/.pdd/meta/one_session_sync_python.json @@ -0,0 +1,20 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:25:13.346414+00:00", + "command": "fix", + "prompt_hash": "1bee0c54e16e34c4efb060f1c76af882fae81b4190704eec7e8fc9d2a9bc3c55", + "code_hash": "9c7b1683ee0fbc88385d8f5bd35fa5e333ba19471d625cad3d7cadc982a81620", + "example_hash": "f2260e5e7b558e0cf1b0cb26acb7c95072c5ffd17d19231a2455979e7d8b9556", + "test_hash": "c323996f3c39ca5e90f45f9c4542b9134ec4a52183838f9956250fa581da2c6d", + "test_files": { + "test_one_session_sync.py": "c323996f3c39ca5e90f45f9c4542b9134ec4a52183838f9956250fa581da2c6d" + }, + "include_deps": { + "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", + "context/agentic_sync_runner_example.py": "fcb36209abb5ab882ad551754fb574d62509c968f8c564e18163be7f2b7ef35a", + "context/load_prompt_template_example.py": "a1cd6619182c6c951f5856dda4070e202875a5884bbfab9cc191d24de2f4951f", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "pdd/agentic_common.py": "f44c5a078380d246d1b060f942b36f9cce4f80b488a5605ef9c124a8486e9801", + "pdd/preprocess.py": "d04a6cfc261c1e88076398a34d0bf96c4285061668866c5ad305e4b605ffdf56" + } +} diff --git a/.pdd/meta/operation_log_python.json b/.pdd/meta/operation_log_python.json new file mode 100644 index 000000000..b43ae9f14 --- /dev/null +++ b/.pdd/meta/operation_log_python.json @@ -0,0 +1,17 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:13:58.175081+00:00", + "command": "test", + "prompt_hash": "ccf6113b6c44c5b30eeade9b22423b0bdde3fda6099cd80c15d83d7832551469", + "code_hash": "3e5964ca1e4012c2e05f2ff3533197dd8951f46ec9f1162e2d371d370a1079dd", + "example_hash": "2660def7012d9b79bad3db33cf399ac603cc1c35dff0b064cbdd2638a013de1d", + "test_hash": "71bbf0e76b5e16f51cb2b7b268310848d83e2abe450cd09b4203a4a349418d30", + "test_files": { + "test_operation_log.py": "71bbf0e76b5e16f51cb2b7b268310848d83e2abe450cd09b4203a4a349418d30", + "test_operation_logging_e2e.py": "1fc74b2e4db145dfe21150f751d4b71a81ab0aaf1cc72e44bbe8b037f671556e" + }, + "include_deps": { + "context/sync_determine_operation_example.py": "225d20ea0210bbf52650a9f424879ff495e574dd2411eb4b86592a6d06cf59a5", + "pdd/fingerprint_transaction.py": "e268904612b5ef15567878153db131176b5dbe5889830db1d313170c1ccce9ea" + } +} diff --git a/.pdd/meta/operation_log_python_run.json b/.pdd/meta/operation_log_python_run.json new file mode 100644 index 000000000..ccb54b178 --- /dev/null +++ b/.pdd/meta/operation_log_python_run.json @@ -0,0 +1,12 @@ +{ + "timestamp": "2026-07-11T01:46:35.850318+00:00", + "exit_code": 0, + "tests_passed": 332, + "tests_failed": 0, + "coverage": 77.16763005780346, + "test_hash": "3e4082cca2d615880133be25d42b315b621fafc6aa725edc8b98a78cea9f1fe6", + "test_files": { + "test_operation_log.py": "3e4082cca2d615880133be25d42b315b621fafc6aa725edc8b98a78cea9f1fe6", + "test_operation_logging_e2e.py": "1fc74b2e4db145dfe21150f751d4b71a81ab0aaf1cc72e44bbe8b037f671556e" + } +} \ No newline at end of file diff --git a/.pdd/meta/pin_example_hack_python.json b/.pdd/meta/pin_example_hack_python.json index 8f159bc13..2f83d78ed 100644 --- a/.pdd/meta/pin_example_hack_python.json +++ b/.pdd/meta/pin_example_hack_python.json @@ -1,9 +1,9 @@ { - "pdd_version": "0.0.234", - "timestamp": "2026-05-12T02:53:56.323298+00:00", - "command": "fix", - "prompt_hash": "77e8c17f1eb7ed4e1347312a702129bcfd8af95a985ee1ccd718291f83c93fae", - "code_hash": "c90df80316bc8339fcf9d4690ac29e4dc8ae3391e25c26c82e496b87dcd4a788", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T01:46:35.953593+00:00", + "command": "test", + "prompt_hash": "7d06995837a81ff1e5031322f86c3ed3d4d7e3800bb0dd9f2702f8d01df3a603", + "code_hash": "b08f64e14e726e69082d8811df1b8f22b1c4973bf40ad8957cc6c6f0daac85a3", "example_hash": "34e285dedea6ad0d3f3a5539401cc7c0bf5b2181d954915963fd66cd86e54e3c", "test_hash": "7fbffec72f9941a52b4ba7efd6468330dd2a3235dc53d0bb81168ea6eb6c3a72", "test_files": { @@ -12,13 +12,14 @@ "include_deps": { "context/auto_deps_main_example.py": "9b9c36c18d39494b6e31b46f6e4758c25135b6fa3d1672fbb7365844f7d2de04", "context/cmd_test_main_example.py": "0774984ee29f70407429f1eff4a1b76287715b7ddd6adf2bc149e874c9b844bb", - "context/code_generator_main_example.py": "679590f0913600886d99469d8ece23fbbfe6bea2e626b0d5d2ab5fdcdc079b80", + "context/code_generator_main_example.py": "4b66f24a1e2ac4195255738566b0cccdac0971523a245d3a8b78ad45d9e1a872", "context/context_generator_main_example.py": "77c3de786f31392540ec609d4b3942e98e996ae14c4014f5018cf2b05a560df4", "context/crash_main_example.py": "5c9b909107f74f773ccf7013d556a5812643d2a3b086e79fe07da7ea7877458c", + "context/fingerprint_transaction_example.py": "1c8d97703bf0d55809b9508bc2be174dfa40b1f55465aec2567b95109ef38398", "context/fix_main_example.py": "29f88d8bfcc2256ebebe6efcb611f9487eb5ca6d8781286a6dd98689f92bca7a", "context/fix_verification_main_example.py": "6490f398aca6df47b1a95e46a4ecfc0aabe299b5022de5c31ebc436e1bc3e459", "context/get_test_command_example.py": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "context/sync_determine_operation_example.py": "94a333b37d9875bcc9b39e22d4d00de1b196ad2f7e0fa997e6f638e153dbdfad", - "context/update_main_example.py": "b318834c648f25e6141d81f3ecac1d9da592a6bb4740500983f2b07b8a10d902" + "context/sync_determine_operation_example.py": "225d20ea0210bbf52650a9f424879ff495e574dd2411eb4b86592a6d06cf59a5", + "context/update_main_example.py": "9fe01fd19196c2604c91d101e49db849e0e1c9fabafeb2ad831a9c91b2f26416" } -} \ No newline at end of file +} diff --git a/.pdd/meta/pin_example_hack_python_run.json b/.pdd/meta/pin_example_hack_python_run.json index 7234a78c5..0230c7496 100644 --- a/.pdd/meta/pin_example_hack_python_run.json +++ b/.pdd/meta/pin_example_hack_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-05-12T02:53:56.323642+00:00", + "timestamp": "2026-07-11T01:46:35.956188+00:00", "exit_code": 0, - "tests_passed": 1, + "tests_passed": 249, "tests_failed": 0, - "coverage": 100.0, + "coverage": 4.609929078014185, "test_hash": "7fbffec72f9941a52b4ba7efd6468330dd2a3235dc53d0bb81168ea6eb6c3a72", "test_files": { "test_pin_example_hack.py": "7fbffec72f9941a52b4ba7efd6468330dd2a3235dc53d0bb81168ea6eb6c3a72" } -} +} \ No newline at end of file diff --git a/.pdd/meta/preprocess_python.json b/.pdd/meta/preprocess_python.json index c42350e3c..bf7e25700 100644 --- a/.pdd/meta/preprocess_python.json +++ b/.pdd/meta/preprocess_python.json @@ -1,9 +1,9 @@ { - "pdd_version": "0.0.270.dev0", - "timestamp": "2026-06-10T22:02:58.234695+00:00", + "pdd_version": "0.0.300.dev0", + "timestamp": "2026-07-10T18:41:46.623032+00:00", "command": "fix", - "prompt_hash": "5ae17e5a4d901ef324d56e2c7c221863cf1ad68b6081b1e7b8d0225df3f9015c", - "code_hash": "edb9bc460d68b854c6b832ddd1c3ce42cc60e71526dade63aea1ae6a132d2259", + "prompt_hash": "26794b056dcad2d491a8ed86496894ea381aba84f4c1899edda173d33b0024bf", + "code_hash": "d04a6cfc261c1e88076398a34d0bf96c4285061668866c5ad305e4b605ffdf56", "example_hash": "e7082a4a9ef830048ff32ed774a3e74da1cf4662a22115e6bd5543e1b9cdbf43", "test_hash": "ec9a69c157443c17d8cdece1e5102252f31b470d9107efea66d89cfa977d9d13", "test_files": { @@ -16,7 +16,7 @@ "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", "context/auto_include_example.py": "0c278ed716298149777bc7082827eb8aa6d80dac264c66b6cded6871232f2df7", "context/path_resolution_example.py": "15ba84fd11c61af8fa12dcb19d63d42f0cb09b747542bf20e69650b8ff4ac137", - "pdd/content_selector.py": "0e4fe56b42ea98d8cbf3c59f0f25849adbad5068db2f5835e5b2e7c4c21e46d2", - "pdd/include_query_extractor.py": "c2bea7ea18bb8d743394485938240089f980ca3df94b3c62f29c606ff76da453" + "pdd/content_selector.py": "727207a44f44c851f3668868f497d348b388b77211bb8ec1e41a5e0b6b79946f", + "pdd/include_query_extractor.py": "fa95489a0b7b8f3ad6e887844fe14f38350e56b3d2fc2e4add08318334bcd191" } -} +} \ No newline at end of file diff --git a/.pdd/meta/preprocess_python_run.json b/.pdd/meta/preprocess_python_run.json new file mode 100644 index 000000000..ec63877ed --- /dev/null +++ b/.pdd/meta/preprocess_python_run.json @@ -0,0 +1,13 @@ +{ + "timestamp": "2026-07-10T19:09:27.903563+00:00", + "exit_code": 0, + "tests_passed": 181, + "tests_failed": 0, + "coverage": 79.0, + "test_hash": "ec9a69c157443c17d8cdece1e5102252f31b470d9107efea66d89cfa977d9d13", + "test_files": { + "test_preprocess.py": "ec9a69c157443c17d8cdece1e5102252f31b470d9107efea66d89cfa977d9d13", + "test_preprocess_main.py": "0fa3887c7a4e2c3f051ce719117b8f07444da419b35363b9dff4a2b5ae0f17e9", + "test_preprocess_main_pdd_tags.py": "abddfe41c460aa7dcd4186028134deb4eef0a85419a7f6f59869a274c25f7ee8" + } +} \ No newline at end of file diff --git a/.pdd/meta/sync_determine_operation_python.json b/.pdd/meta/sync_determine_operation_python.json index 7445a7bb1..bc434741e 100644 --- a/.pdd/meta/sync_determine_operation_python.json +++ b/.pdd/meta/sync_determine_operation_python.json @@ -1,13 +1,13 @@ { - "pdd_version": "0.0.296.dev0", - "timestamp": "2026-07-09T09:54:54.515165+00:00", - "command": "fix", - "prompt_hash": "67066038dc835de0304bdf437277088994f8293b858343af8a8c1ba00b7c86a2", - "code_hash": "e616ac746674a9f1e8be84bf3093d5e1f45ff0175bc3767b758f2d17b2c54132", + "pdd_version": "0.0.300.dev0", + "timestamp": "2026-07-10T19:17:29.880386+00:00", + "command": "test", + "prompt_hash": "360c976a0e8d9084b05640baea42c2580ae54cd2d942259ced9c48b8fe5cb7d9", + "code_hash": "960eae34071f0c76de617793e6ea41ed7dc651e68db64260239fc1ae59b001c8", "example_hash": "225d20ea0210bbf52650a9f424879ff495e574dd2411eb4b86592a6d06cf59a5", - "test_hash": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d", + "test_hash": "08635633b7cce2eb24fd5f4ec5923c07d1179153d8313d157f80b72c60aa7013", "test_files": { - "test_sync_determine_operation.py": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d" + "test_sync_determine_operation.py": "08635633b7cce2eb24fd5f4ec5923c07d1179153d8313d157f80b72c60aa7013" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", diff --git a/.pdd/meta/sync_determine_operation_python_run.json b/.pdd/meta/sync_determine_operation_python_run.json index bb380c3d8..9145bd382 100644 --- a/.pdd/meta/sync_determine_operation_python_run.json +++ b/.pdd/meta/sync_determine_operation_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-07-09T09:54:54.515165+00:00", + "timestamp": "2026-07-10T19:16:43.078852+00:00", "exit_code": 0, - "tests_passed": 1, + "tests_passed": 184, "tests_failed": 0, - "coverage": 100.0, - "test_hash": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d", + "coverage": 80.0, + "test_hash": "08635633b7cce2eb24fd5f4ec5923c07d1179153d8313d157f80b72c60aa7013", "test_files": { - "test_sync_determine_operation.py": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d" + "test_sync_determine_operation.py": "08635633b7cce2eb24fd5f4ec5923c07d1179153d8313d157f80b72c60aa7013" } -} +} \ No newline at end of file diff --git a/.pdd/meta/sync_main_python.json b/.pdd/meta/sync_main_python.json new file mode 100644 index 000000000..1ccd9a89e --- /dev/null +++ b/.pdd/meta/sync_main_python.json @@ -0,0 +1,24 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T02:48:06.368654+00:00", + "command": "stack-merge-reconcile", + "prompt_hash": "e7448ade7fc37162916c8cd4f5c78e84f3116efb193182121a7f11d49fa8965c", + "code_hash": "cca0497f63c1f43f3e8c9359f6a6fa7d0e8b3e9a5082aa911294907b7515b602", + "example_hash": "9a7961a4044a54c46ce6c43c8adbbb83da46f233d9c6a9eaa28302440c0052c0", + "test_hash": "30f5651c0868812dacc7141629dc7ba11afb6a4417e695e98c2f74c1dfa4d1d1", + "test_files": { + "test_sync_main.py": "30f5651c0868812dacc7141629dc7ba11afb6a4417e695e98c2f74c1dfa4d1d1" + }, + "include_deps": { + "README.md": "f77c5f3e52e4d1accdb56d8194f6de3b17b2f9a977ef3433aa19ab01938acb0b", + "context/auth_service_example.py": "4b6d2c8c9e13d1edc0d9805ee0c07657494cbf31dffe2bb0029b72975674a8cf", + "context/construct_paths_example.py": "412cb260650bcbd628fba647db917a8d32f8191df2e1c32cc22f22c0c11b1740", + "context/ctx_obj_params.prompt": "829e2ab327e37b75afcd0dcf3893dcaec9012a89a1f6e5198ec7cfc0395d2782", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/sync_main_example.py": "9a7961a4044a54c46ce6c43c8adbbb83da46f233d9c6a9eaa28302440c0052c0", + "context/sync_orchestration_example.py": "cc1313a948946026d418c85a07e5bf0eedeb5915c44d630440e489979755e1f7", + "pdd/agentic_sync_runner.py": "11d3c060ea0361ec1a3bedd637a2d04e6ffd6030cca884486be8232e38bbe896", + "pdd/code_generator_main.py": "cc76ccc6038ba1ec2bab76a7a587370ba4a37faef70dddf7f6eb2bc8a45b347e", + "pdd/compressed_sync_context.py": "661fde0fcc42ce39c1004e47a774a8fd799b789065519cb5688949c35fed4859" + } +} diff --git a/.pdd/meta/sync_main_python_run.json b/.pdd/meta/sync_main_python_run.json new file mode 100644 index 000000000..ddc08a944 --- /dev/null +++ b/.pdd/meta/sync_main_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-11T01:46:36.023342+00:00", + "exit_code": 0, + "tests_passed": 332, + "tests_failed": 0, + "coverage": 66.77631578947368, + "test_hash": "9f617d403a3ba62d0f46451bf63d81485f6fb7c4ec0a56a9b342b4c244a8d330", + "test_files": { + "test_sync_main.py": "9f617d403a3ba62d0f46451bf63d81485f6fb7c4ec0a56a9b342b4c244a8d330" + } +} \ No newline at end of file diff --git a/.pdd/meta/sync_orchestration_python.json b/.pdd/meta/sync_orchestration_python.json index 5b52c7dd6..0b4428684 100644 --- a/.pdd/meta/sync_orchestration_python.json +++ b/.pdd/meta/sync_orchestration_python.json @@ -1,31 +1,32 @@ { - "pdd_version": "0.0.234", - "timestamp": "2026-05-12T02:53:56.362536+00:00", - "command": "fix", - "prompt_hash": "b9b900ce1b0cf9e50b73c4aad750e2231f632beda3c286c0469f21c0dc3a8475", - "code_hash": "7ef19b3e7eafd7abd0f54c9de74b7c4d8be5559d84b2673f1c60cb9313d4842e", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T02:48:06.368654+00:00", + "command": "stack-merge-reconcile", + "prompt_hash": "913643b1679b839351e7c8078af857a71387f6fed0595a4b4d6ba12d59372e70", + "code_hash": "6d6e5fbb83131161621ff79d6acf41215fcd23a347c1040f2d6cec6ed0058a39", "example_hash": "cc1313a948946026d418c85a07e5bf0eedeb5915c44d630440e489979755e1f7", - "test_hash": "3fce96bc02b851cc3e2d00001b0de3a2436f687c65a53d5e423212ac5c6d2517", + "test_hash": "04372c6ac095144c4b58def206e9037fa67e67d7eb5890a363a9e51b8bc7e8e4", "test_files": { - "test_sync_orchestration.py": "3fce96bc02b851cc3e2d00001b0de3a2436f687c65a53d5e423212ac5c6d2517" + "test_sync_orchestration.py": "04372c6ac095144c4b58def206e9037fa67e67d7eb5890a363a9e51b8bc7e8e4" }, "include_deps": { - "docs/whitepaper.md": "25b87d4af53ba35e7fa80b12f6bf09d12e16b8b952341df94cf8b47160886adf", - "README.md": "b572bb7bf64f3d9473845a45b8c942fc66b527bc516c960059bc538e574ae4bc", + "docs/whitepaper.md": "3347db03fa35376024b59d8546efdf0858655746490561b88b710fa6b85017cc", + "README.md": "f77c5f3e52e4d1accdb56d8194f6de3b17b2f9a977ef3433aa19ab01938acb0b", "context/auto_deps_main_example.py": "9b9c36c18d39494b6e31b46f6e4758c25135b6fa3d1672fbb7365844f7d2de04", "context/cmd_test_main_example.py": "0774984ee29f70407429f1eff4a1b76287715b7ddd6adf2bc149e874c9b844bb", - "context/code_generator_main_example.py": "679590f0913600886d99469d8ece23fbbfe6bea2e626b0d5d2ab5fdcdc079b80", + "context/code_generator_main_example.py": "4b66f24a1e2ac4195255738566b0cccdac0971523a245d3a8b78ad45d9e1a872", "context/context_generator_main_example.py": "77c3de786f31392540ec609d4b3942e98e996ae14c4014f5018cf2b05a560df4", "context/crash_main_example.py": "5c9b909107f74f773ccf7013d556a5812643d2a3b086e79fe07da7ea7877458c", "context/fix_main_example.py": "29f88d8bfcc2256ebebe6efcb611f9487eb5ca6d8781286a6dd98689f92bca7a", "context/fix_verification_main_example.py": "6490f398aca6df47b1a95e46a4ecfc0aabe299b5022de5c31ebc436e1bc3e459", "context/get_extension_example.py": "def9c34c1416259a0a031a996b96aeff5e73a7012adff6af1cc557e74ba97cc8", - "context/get_run_command_example.py": "4d187e844adb255c2738274556f4828728d0d73d84ebf8bda885d51d4be9414f", "context/get_test_command_example.py": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "context/operation_log_example.py": "6cd7962ab255d6080aeb97897175250f987ce936cf7333e7601f10834405a940", - "context/pytest_output_example.py": "6f0cd68e3c4440081267c716651caec4fbdd7d9c4516f967517f02ae2a853920", + "context/operation_log_example.py": "2660def7012d9b79bad3db33cf399ac603cc1c35dff0b064cbdd2638a013de1d", "context/python_env_detector_example.py": "65619b3e8bad0509f1ccf54d81b5c3760f423b6fc1925c932fc7968da78a172e", - "context/sync_determine_operation_example.py": "94a333b37d9875bcc9b39e22d4d00de1b196ad2f7e0fa997e6f638e153dbdfad", - "context/update_main_example.py": "b318834c648f25e6141d81f3ecac1d9da592a6bb4740500983f2b07b8a10d902" + "context/sync_determine_operation_example.py": "225d20ea0210bbf52650a9f424879ff495e574dd2411eb4b86592a6d06cf59a5", + "context/update_main_example.py": "9fe01fd19196c2604c91d101e49db849e0e1c9fabafeb2ad831a9c91b2f26416", + "pdd/compressed_sync_context.py": "661fde0fcc42ce39c1004e47a774a8fd799b789065519cb5688949c35fed4859", + "pdd/get_run_command.py": "5eec778a234abc4ace4166d71a68f6e49f1b81491962867de81f61be882a5319", + "pdd/pytest_output.py": "652a27dd1e08769611abe3cb6a83f372c450279d6bf1b676741b9b7186665975" } -} \ No newline at end of file +} diff --git a/.pdd/meta/sync_orchestration_python_run.json b/.pdd/meta/sync_orchestration_python_run.json index 21a1df2d3..9c216adb0 100644 --- a/.pdd/meta/sync_orchestration_python_run.json +++ b/.pdd/meta/sync_orchestration_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-05-12T02:53:56.363281+00:00", + "timestamp": "2026-07-11T01:46:36.090120+00:00", "exit_code": 0, - "tests_passed": 1, + "tests_passed": 249, "tests_failed": 0, - "coverage": 100.0, - "test_hash": "3fce96bc02b851cc3e2d00001b0de3a2436f687c65a53d5e423212ac5c6d2517", + "coverage": 21.48711943793911, + "test_hash": "7a4a23ed7c00408887d4ca9b604cb9715e0ad6e0a9c3e9e1b4df78603b396f41", "test_files": { - "test_sync_orchestration.py": "3fce96bc02b851cc3e2d00001b0de3a2436f687c65a53d5e423212ac5c6d2517" + "test_sync_orchestration.py": "7a4a23ed7c00408887d4ca9b604cb9715e0ad6e0a9c3e9e1b4df78603b396f41" } -} +} \ No newline at end of file diff --git a/.pdd/meta/sync_tui_python.json b/.pdd/meta/sync_tui_python.json index 6eecff6cd..02399cb34 100644 --- a/.pdd/meta/sync_tui_python.json +++ b/.pdd/meta/sync_tui_python.json @@ -1,15 +1,15 @@ { - "pdd_version": "0.0.257.dev0", - "timestamp": "2026-06-01T18:19:03.939345+00:00", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T20:13:58.262449+00:00", "command": "update", - "prompt_hash": "5eb3bb0151fa49a4f51f87c06051545bfa07a3bdf27718f23616a6a301deefa2", - "code_hash": "941cbbd6026742784188c66b97d64479daa733ffe1ff2d3d11f464e8a0e10992", + "prompt_hash": "71acdbd9477906391ac7bb10277e5fc13096d218f00c4c810c84bc9be1f05997", + "code_hash": "577b74ecf4d1c22ab36230d079831c9eef8afe573dfa9f31b657d4ca1ec0ff6a", "example_hash": "1d25354296c98967b94a5290f3db52d112bf6d160110056e4cdc88ae7db2297a", - "test_hash": "3d787dd8211c3ed4361df33c382b5ae2ef366aa9b0757d53a1fc103142b160fe", + "test_hash": "15c804e4985a31f7152a35434840d7b1caa46c45594d8005749f0bad06339876", "test_files": { - "test_sync_tui.py": "3d787dd8211c3ed4361df33c382b5ae2ef366aa9b0757d53a1fc103142b160fe" + "test_sync_tui.py": "15c804e4985a31f7152a35434840d7b1caa46c45594d8005749f0bad06339876" }, "include_deps": { "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254" } -} \ No newline at end of file +} diff --git a/.pdd/meta/update_main_python.json b/.pdd/meta/update_main_python.json new file mode 100644 index 000000000..2f95df7f0 --- /dev/null +++ b/.pdd/meta/update_main_python.json @@ -0,0 +1,28 @@ +{ + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-11T01:46:36.149986+00:00", + "command": "test", + "prompt_hash": "130e89c4b1b1b4823c9268a0ea053de06920ff87cfb49f68480d98ae385b2585", + "code_hash": "8e1f202971c3221e22f5f4725cd97ce8ea2479de08b8e34b45b6627fb1388d4e", + "example_hash": "9fe01fd19196c2604c91d101e49db849e0e1c9fabafeb2ad831a9c91b2f26416", + "test_hash": "c47028caf251b26e57c68eb54060f1ca4b89f52542b71ff11c86b438f645a907", + "test_files": { + "test_update_main.py": "c47028caf251b26e57c68eb54060f1ca4b89f52542b71ff11c86b438f645a907" + }, + "include_deps": { + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", + "context/agentic_update_example.py": "967254cd1c636ac5ba09afa0446312ffc1f28d3041ed453a4c6588c931f722c4", + "context/api_key_scanner_example.py": "4b9f880a30445f25edffc394857ae0c218097726c41ab996a03fcbc4350b5482", + "context/architecture_sync_example.py": "b6158dbc7ca3fd32665528562225f6739ec521a071d845acab0f7caaea221ae0", + "context/auto_include_example.py": "0c278ed716298149777bc7082827eb8aa6d80dac264c66b6cded6871232f2df7", + "context/construct_paths_example.py": "412cb260650bcbd628fba647db917a8d32f8191df2e1c32cc22f22c0c11b1740", + "context/ctx_obj_params.prompt": "829e2ab327e37b75afcd0dcf3893dcaec9012a89a1f6e5198ec7cfc0395d2782", + "context/git_update_example.py": "811304cee2cb890879ee68a816aed49dff2e7b50cfdca3125e16e0ae99819574", + "context/metadata_sync_example.py": "704453b3386724eef225b61c48f2fe922e1a973ac211c494e1d955dd44bcca11", + "context/operation_log_example.py": "2660def7012d9b79bad3db33cf399ac603cc1c35dff0b064cbdd2638a013de1d", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/sync_determine_operation_example.py": "225d20ea0210bbf52650a9f424879ff495e574dd2411eb4b86592a6d06cf59a5", + "context/update_prompt_example.py": "56b9d8c7aa823c2996a3dc5741a47844e223bfd088366d74e48b6a099b5a11c4", + "pdd/validate_prompt_includes.py": "fa97b72a58534e255768634f928f4cfdd2130a7a4d8b296da3eefdf77d47855b" + } +} diff --git a/.pdd/meta/update_main_python_run.json b/.pdd/meta/update_main_python_run.json new file mode 100644 index 000000000..1d2ba4bb1 --- /dev/null +++ b/.pdd/meta/update_main_python_run.json @@ -0,0 +1,11 @@ +{ + "timestamp": "2026-07-11T01:46:36.153204+00:00", + "exit_code": 0, + "tests_passed": 332, + "tests_failed": 0, + "coverage": 76.87927107061503, + "test_hash": "c47028caf251b26e57c68eb54060f1ca4b89f52542b71ff11c86b438f645a907", + "test_files": { + "test_update_main.py": "c47028caf251b26e57c68eb54060f1ca4b89f52542b71ff11c86b438f645a907" + } +} \ No newline at end of file diff --git a/README.md b/README.md index 11344fc39..bc3fcdd6c 100644 --- a/README.md +++ b/README.md @@ -1040,6 +1040,7 @@ Options: - `--skip-tests`: Skip unit test generation and fixing - `--target-coverage FLOAT`: Desired code coverage percentage (default is 90.0) - `--compress`: Use AST-based compression for Python few-shot examples (strips docstrings and logic-external comments). Helps fit more context into limited LLM windows without losing executable logic. +- `--fresh`: Disable the default surgical/edit-shaped regeneration of a mature module. By default, when a module already has non-empty code and its prompt changed, `pdd sync` edits the existing code in place (feeding the current code plus the prompt delta to the generator) so declared public symbols are preserved rather than dropped by a from-scratch rewrite. With `--fresh`, sync uses standard generation, which regenerates the module from scratch when the prompt change is large — use it when you intend a large rewrite rather than an in-place edit. New/empty modules are always generated fresh, and the public-surface / declared-interface gate still guards either path. `--fresh` acts on the standard multi-step single-module sync; in one-session/agentic sync the code is regenerated by the agent session, so `--fresh` only affects from-scratch (re)generation there. Single-module sync only: passing `--fresh` to project-wide (no-argument) or GitHub-issue agentic sync raises a `UsageError`. - `--dry-run`: Display real-time sync analysis instead of running sync operations. For no-argument project-wide sync, this prints the dependency-ordered module list and estimated cost without executing any module syncs, plus a single compact roll-up of modules outside the Tier 1 (`generate` / `auto-deps`) scope — bucketed by reason (e.g. `Out of Tier 1 scope: 42 example, 31 test, 18 verify, 12 update, 74 no-prompt fixture`) instead of one warning line per skipped entry. When zero modules are stale, the `0 stale module(s)` fragment is rendered in green so the success signal is visually unambiguous. Actionable architecture-graph warnings (ambiguous or unresolved cross-arch dependencies) are still printed individually in yellow. For single-module sync, it performs the same state analysis as a normal sync run but without acquiring exclusive locks or executing operations. Passing the top-level `pdd --verbose` flag (see above) restores the legacy per-module enumeration after the compact roll-up — one yellow warning line per module outside the Tier 1 scope — for debugging. - `--snapshot-context`: Capture the fully expanded prompt context used for generation, including nondeterministic ``, ``, and `` outputs. The run manifest is `.pdd/evidence/runs/.json`; snapshot artifacts are in `.pdd/evidence/runs//`. Replay can later reconstruct the same prompt/context from the recorded run artifact. - `--compressed-context / --no-compressed-context`: Enable or disable compressed sync context for generation and repair phases. This option is tri-state internally: omitting it lets `.pddrc` `defaults.compressed_context` apply, `--compressed-context` forces it on, and `--no-compressed-context` forces it off. When enabled, sync builds bounded phase packages from the prompt, existing tests, examples when present, contract sections, and recent repair evidence, then passes those packages to generate, verify, test, and fix attempts. The sync result records whether compression was used and whether any agentic fallback was needed. @@ -1104,6 +1105,7 @@ If multiple development language prompt files exist for the same basename, sync - **Architecture-Aware Outputs**: When `architecture.json` provides an explicit `filepath` for a prompt entry, sync honors it according to whether that `filepath` includes a directory component: - If `filepath` includes a directory (e.g. `backend/api/widget.py`), that explicit directory structure wins and is preserved as-is — `.pddrc` output paths are not applied to it. - If `filepath` is a bare filename at the project root (e.g. `widget.py`), the filename is preserved but its parent directory is taken from `.pddrc` `generate_output_path`. This makes the code path resolve consistently with `example_output_path` and `test_output_path`, which are always sourced from `.pddrc` defaults (Issue #1201). When no `generate_output_path` is configured, the bare filename resolves at the project root as before. +- **Runner-aware test paths (Issue #1903)**: When PDD would otherwise write a module's test to its *derived default* location and a single existing co-located test already exists (a jest/vitest/Next.js `__test__/{name}.test.tsx`-style sibling, or a Python `test_{name}.py` sibling), `pdd test`/`change`/`sync` **adopt that existing test** as the canonical path instead of maintaining a separate runner-blind `tests/` shadow — so PDD updates and verifies the test your runner actually collects. Adoption never overrides an explicit pin (CLI `--output`, `PDD_TEST_OUTPUT_PATH`, or `.pddrc` `test_output_path`/`outputs.test.path`), and never fires when more than one co-located test exists. **Greenfield (Issue #1903 §A):** when *no* co-located test exists yet but the project configures a jest/vitest runner, PDD writes the FIRST test to the location the runner will actually collect instead of a runner-blind `tests/` shadow. The write path honors JSON-readable config — `testMatch`/`testRegex` pick the `.test`/`.spec` + `__test__`/`__tests__` convention, and `roots`/`rootDir`/`testPathIgnorePatterns` are enforced so a custom layout never yields an *uncollected* test. For a **centralized** layout (tests only under a configured `roots`/`testMatch` directory) PDD derives a collected path *under that directory*, mirroring the module's relative sub-path so two same-stem modules never collapse onto one file (never fork/overwrite), rather than falling back to a runner-blind shadow. Jest `testMatch` is evaluated with ordered include/exclude semantics (a leading-`!` negation removes matches). Both the jest and **vitest** dialects are covered. A JS-only config (`jest.config.js`/`vitest.config.ts`, unparseable in Python) is handled by whole-word text-inspection: it uses the default convention ONLY when the config is a plain literal that customizes nothing discovery-related; if it customizes discovery, or *composes/delegates* it in a way a static scan can't follow (`require`/`import`/spread/`preset`/`extends`/function config), or a parseable config uses `projects`/`include`/`exclude` we can't fully resolve, PDD conservatively **refuses** (falls back to the derived path) rather than guess. It also only co-locates for an extension the default discovery collects — `.mjs`/`.cjs` are version-aware (vitest and **jest 30+** collect them; jest ≤29 / unknown versions do not, so they're refused) — and evaluates `testMatch` with jest's ordered include/exclude semantics (a negated character class `[!x]` means "not x"; an explicit-empty or both-`testMatch`-and-`testRegex` config matches nothing → refuse). Repo-controlled runner patterns are matched under a strict per-match timeout plus an aggregate pattern-count cap (ReDoS/DoS-safe, fail-closed). Python keeps its pytest-idiomatic `tests/` default. - Context-specific settings include output paths, default language, model parameters, coverage targets, and budgets **Workflow Logic**: @@ -1116,8 +1118,8 @@ The sync command automatically detects what files exist and executes the appropr - **Python naming convention**: Python exports should be snake_case, so a camelCase export (e.g. `processData`) fails the gate — UNLESS its exact name is a declared interface symbol (declared in `architecture.json` `module.functions` **or** the prompt's own ``), in which case it is treated as intentional public API (e.g. Firebase Cloud Function exports like `generateCode`) and allowed. Honoring the prompt — the source of truth — means a name you declare there is accepted even before `architecture.json` is regenerated to match. Only undeclared/accidental camelCase is rejected. - For interface entries that declare a paren-list `signature` (`module`, `cli`, and `command` types), each declared parameter name must appear in the matching function/method signature (dotted names like `ContentSelector.select` are resolved through the class body; variadic `*args`/`**kwargs` do not satisfy a declared named parameter). - **Signature drift** is checked per parameter: annotation drift fires only when both sides specify and differ (conservative — gradually-typed code does not churn), while default drift fires whenever the prompt declares a default and the generated code drops or changes it (strict — a missing default is a runtime contract break for callers omitting the optional kwarg). - - **Public-surface regression gate**: when a module already has a generated code file in the working tree (i.e., not a first-time generation), the gate ALSO snapshots the pre-generation public surface and rejects a generation that removes, renames, or changes the callable signature of any public symbol. For Python the snapshot covers top-level functions, classes, `class.method` symbols (including nested classes), module-level constants (`PUBLIC_FLAG = ...`, including bound `AnnAssign` like `PUBLIC_FLAG: bool = True`), and re-exported imports (`import git` exposes `git`; `from .helpers import load` exposes `load`). `from __future__ import …` directives and bare type-only annotations are not part of the surface. Intentional removals/signature changes must be scoped, e.g. `BREAKING-CHANGE: remove calculate_sha256` or `BREAKING-CHANGE: change signature calculate`; listing a top-level class (`BREAKING-CHANGE: remove Service`) implicitly authorizes removing every `Service.method` / `Service.Inner.method` descendant captured in the snapshot. A bare `BREAKING-CHANGE:` does not disable the gate. First-time generation (no prior code file) is exempt. Set `PDD_SKIP_PUBLIC_SURFACE_GATE=1` to disable only this gate, or `PDD_SKIP_CONFORMANCE=1` to skip all conformance gates. - - **Test-churn gate**: if `pdd sync` is about to overwrite an existing test file through the code-generation writer, `cmd_test_main`, or one-session agentic sync, and the unified-diff churn ratio between the pre-sync and proposed test file exceeds `PDD_TEST_CHURN_THRESHOLD` (default `0.40`, i.e., 40%), the gate fails fast with `TestChurnError` so a small prompt change cannot land a thousand-line test rewrite that drops broad existing coverage. Pure additive test growth is allowed, first-time test generation is exempt, and intentional rewrites require an explicit marker such as `BREAKING-CHANGE: rewrite tests`. Set `PDD_SKIP_TEST_CHURN_GATE=1` to disable only this gate. **One-session auto-recovery:** when the one-session sync retry loop exhausts on test churn, instead of hard-failing it accepts the rewrite IFF it is coverage-preserving — every pre-existing test file keeps at least as many test cases AND assertions (with at least one real assertion), deletes nothing, and is in a measurable language (Python via AST, TS/JS via a comment/string/regex-aware scanner); otherwise the strict gate still hard-fails. This lets a legitimate large rewrite driven by a real prompt change complete instead of forcing manual intervention, while still blocking silent coverage loss. An accepted rewrite prints a `PDD_TEST_CHURN_ACCEPTED` marker; set `PDD_DISABLE_TEST_CHURN_AUTOACCEPT=1` to force the strict gate. + - **Public-surface regression gate**: when a module already has a generated code file in the working tree (i.e., not a first-time generation), the gate ALSO snapshots the pre-generation public surface and rejects a generation that removes, renames, or changes the callable signature of any public symbol. For Python the snapshot covers top-level functions, classes, `class.method` symbols (including nested classes), module-level constants (`PUBLIC_FLAG = ...`, including bound `AnnAssign` like `PUBLIC_FLAG: bool = True`), and re-exported imports (`import git` exposes `git`; `from .helpers import load` exposes `load`). `from __future__ import …` directives and bare type-only annotations are not part of the surface. Intentional removals/signature changes must be scoped, e.g. `BREAKING-CHANGE: remove calculate_sha256` or `BREAKING-CHANGE: change signature calculate`; listing a top-level class (`BREAKING-CHANGE: remove Service`) implicitly authorizes removing every `Service.method` / `Service.Inner.method` descendant captured in the snapshot. A bare `BREAKING-CHANGE:` does not disable the gate. **Prompt-declared interface as the contract (#1900):** when the prompt's `` declares a `type: module` interface, each declared top-level function is validated against its DECLARED signature — a stable contract — instead of against the previous generation, so an intended interface change is authorized simply by editing the declaration (reviewable in the prompt diff) and the standard `pdd change → pdd sync` flow no longer needs a `BREAKING-CHANGE:` prose permit for declared symbols. Undeclared symbols keep the previous-generation baseline above (and its `BREAKING-CHANGE:` opt-out), so protection for helpers/re-exports is unchanged. Any declared symbol with a parseable paren signature — a top-level function, a dotted method (`Class.method`), or a constructor (`Class.__init__`), including declared `_`-prefixed helpers — is validated against its DECLARED signature (methods/constructors are receiver-stripped to match the snapshot: a leading `self`/`cls` is dropped, and `Class.__init__` compares against the class's constructor ABI), so editing the declaration authorizes an intended function/method/constructor change too. Binding-kind/async — which the declaration cannot express — stay anchored to the previous generation, so a `@staticmethod`→instance flip or an async↔sync change is still caught, with `BREAKING-CHANGE: change signature` relaxing only those un-declarable facets (never the declared parameters). A declared symbol WITHOUT a parseable paren signature (a description-only entry, or a class declared as `class Service`) is presence-only and falls back to the previous-generation baseline (an existing symbol's ABI drift is still caught there). On a declared-surface violation the failure lists the full declared-expected-vs-actual signature. First-time generation (no prior code file) is exempt. Set `PDD_SKIP_PUBLIC_SURFACE_GATE=1` to disable only this gate, or `PDD_SKIP_CONFORMANCE=1` to skip all conformance gates. + - **Test-churn gate**: if `pdd sync` is about to overwrite an existing test file through the code-generation writer, `cmd_test_main`, or one-session agentic sync, and the unified-diff churn ratio between the pre-sync and proposed test file exceeds `PDD_TEST_CHURN_THRESHOLD` (default `0.40`, i.e., 40%), the gate fails fast with `TestChurnError` so a small prompt change cannot land a thousand-line test rewrite that drops broad existing coverage. Pure additive test growth is allowed, first-time test generation is exempt, and intentional rewrites require an explicit marker such as `BREAKING-CHANGE: rewrite tests`. Set `PDD_SKIP_TEST_CHURN_GATE=1` to disable only this gate. **One-session auto-recovery:** when the one-session sync retry loop exhausts on test churn, instead of hard-failing it accepts the rewrite IFF it is coverage-preserving — every pre-existing test file keeps at least as many test cases AND assertions (with at least one real assertion), deletes nothing, and is in a measurable language (Python via AST, TS/JS via a comment/string/regex-aware scanner); otherwise the strict gate still hard-fails. This lets a legitimate large rewrite driven by a real prompt change complete instead of forcing manual intervention, while still blocking silent coverage loss. An accepted rewrite prints a `PDD_TEST_CHURN_ACCEPTED` marker; set `PDD_DISABLE_TEST_CHURN_AUTOACCEPT=1` to force the strict gate. **Issue-driven never-block (issue #1903 §B.4):** when the coverage-preserving auto-accept refuses (a genuinely coverage-*losing* rewrite) inside the **agentic issue-driven sync** (a GitHub issue URL, which opens a PR) AND the churned test is an **adopted co-located human test** (a jest/vitest `.test.`/`.spec.` file, a file under `__test__`/`__tests__`, or a Python sibling `test_.py` / `_test.py` outside the top-level `tests/` shadow — classified by `_is_adopted_collocated_test_path`), the workflow does NOT hand work back to the user by failing the command. The human-authored test is kept unchanged, a `PDD_TEST_CHURN_NEEDS_REVIEW` marker is emitted, the module is reported as synced, and the PR is opened with that test flagged **needs review** in the progress comment / PR body (`ModuleState.needs_review`, persisted across durable resumes). THREE independent guards keep this from ever masking coverage loss, and ALL must hold: (1) the runner is issue-driven — `self.issue_url` is set only for a GitHub issue → PR sync; a project-wide `pdd sync` builds the runner with `issue_url=None`, opens no PR, and keeps the strict hard-fail (there is no PR to flag against); (2) structured **adoption provenance** — the child sync stamps `adopted: true` on the churn block only when the test was adopted from an existing *human* co-located test, *unpinned*, decided at path resolution before generation (a pinned path, a greenfield test PDD created, or an older child with no marker reads false); and (3) the churned path is an in-repo co-located *shape* — not a PDD-owned `tests/` shadow, traversal, or out-of-root path. Standalone `pdd test` / `pdd sync ` never run through the issue-driven runner at all, so they always keep the strict hard-fail above. - **Prose/empty-output classification gate**: when the extractor returns empty or whitespace-only content for a code generation request — for example, because the provider returned a planning sentence or reasoning trace instead of a code block — `pdd sync` raises `ProseOutputError` *before* reaching the architecture conformance gate. This prevents an empty extraction from being misdiagnosed as a missing-symbol architecture failure. The repair directive on retry instructs the model to "return the complete source file only, inside a single code block; do not include planning text, prose explanation, or partial snippets outside the code block." Prose retries are limited to 1 additional attempt; a repeated prose response triggers a structured `=== generation output extraction failure ===` hard-failure block naming the provider/model, prompt, output path, extractor result, raw-output excerpt, and directing the user to check provider configuration. The target file is never overwritten. Set `PDD_ALLOW_EMPTY_GENERATION=1` to bypass. Providers that tend to return planning-style responses (e.g., local `lm_studio/*`, `ollama/*`, or ChatGPT/Codex interactive providers flagged `interactive_only` in `llm_model.csv`) are most likely to trigger this path. - **Empty-generation guard**: when the LLM provider returns an empty (or whitespace-only) body and the output path already has non-empty existing content, the writer refuses to truncate the file. Python public modules / test files trip `PublicSurfaceRegressionError` / `TestChurnError` through the normal gates; non-Python artifacts (JSON, YAML, prompts, etc.) raise a `click.UsageError("Refusing to overwrite ...")` instead. Set `PDD_ALLOW_EMPTY_GENERATION=1` for the rare case where empty output is intentional. - On failure, sync retries the generation step up to `MAX_CONFORMANCE_ATTEMPTS` with a `PDD_REPAIR_DIRECTIVE` that names the function to fix and the parameters/annotations/defaults to add or restore. Prose/empty-output failures (`ProseOutputError`) use a separate output-shape retry limited to 1 additional attempt. Public-surface and test-churn failures use the same repair loop **only on the generate and one-session paths**; surface regressions detected after a crash/fix/verify write are hard failures (no retry) because each of those operations already runs its own internal fix loop and a second outer retry would compound retries (`N × M`) without converging. `.pddrc` context/strength are pinned across the entire retry sequence so a retry never silently switches model or context. The retry stops early when the missing-symbol/signature set repeats across attempts, and the final failure is surfaced as a structured `=== generation output extraction failure ===`, `=== architecture conformance failure ===`, `=== public surface regression ===`, or `=== test churn threshold exceeded ===` block listing the offending symbols / churn ratio / provider context plus a `Reproduce locally: pdd sync ` line. @@ -1157,13 +1159,13 @@ pdd sync --no-one-session https://github.com/myorg/myrepo/issues/100 - **Smart Lock Management**: Prevents concurrent sync operations with automatic stale lock cleanup - Detects which files already exist and are up-to-date - Skips unnecessary steps (e.g., won't regenerate code if prompt hasn't changed) -- Uses git integration to detect changes and determine incremental vs full regeneration +- Uses git integration to detect changes; regenerates mature modules surgically (edit-shaped) by default so declared symbols are preserved, falling back to full regeneration for new modules or when `--fresh` is passed - Accumulates tests over time rather than replacing them (in a single test file per target) - Automatically handles dependencies between steps - **Compressed Context Telemetry**: Sync results and logs record whether compressed context was requested, the effective value after CLI and `.pddrc` resolution, whether it was actually applied for each phase, the source inputs used to build it, and whether the run fell back to agentic repair. This makes replay and benchmark comparisons distinguish normal sync from compressed-context sync. **Robust State Management**: -- **Fingerprint Files**: Maintains `.pdd/meta/{basename}_{language}.json` with operation history +- **Fingerprint Files**: Maintains `.pdd/meta/{basename}_{language}.json` with operation history. All fingerprint writes across every mutating command (sync, generate, example, update, fix, auto-deps, ci-heal) route through a single `FingerprintTransaction` context manager; writes are atomic (temp-file + `os.replace`) and enforced — a finalization failure is a command failure, not a silent warning. - **Run Reports**: Tracks test results, coverage, and execution status - **Lock Management**: Prevents race conditions with file-descriptor based locking - **Git Integration**: Leverages version control for change detection and rollback safety @@ -1909,7 +1911,7 @@ Where used: - Dependency references: Examples serve as lightweight (token efficient) interface references for other prompts and can be included as dependencies of a generate target. - Sanity checks: The example program is typically used as the runnable program for `crash` and `verify`, providing a quick end-to-end sanity check that the generated code runs and behaves as intended. - Auto-deps integration: The `auto-deps` command can scan example files (e.g., `examples/**/*.py`) and insert relevant references into prompts. Based on each example’s content (imports, API usage, filenames), it identifies useful development units to include as dependencies. -- Metadata finalization: A successful `pdd example` updates the affected module's fingerprint and clears its stale `.pdd/meta/__run.json` runtime-verification report, so a regenerated example never leaves runtime state describing the pre-mutation output. +- Metadata finalization: A successful `pdd example` updates the affected module's fingerprint and clears its stale `.pdd/meta/__run.json` runtime-verification report, so a regenerated example never leaves runtime state describing the pre-mutation output. The fingerprint write is atomic (temp-file + rename via `FingerprintTransaction`); a finalization failure exits non-zero rather than being surfaced as a warning. **When to use**: Choose this command when creating reusable references that other prompts can efficiently import. This produces token-efficient examples that are easier to reuse across multiple prompts compared to including full implementations. @@ -2310,6 +2312,8 @@ pdd [GLOBAL OPTIONS] fix --manual [OPTIONS] PROMPT_FILE CODE_FILE UNIT_TEST_FILE - `--quiet`: Suppress all output except errors. - `--protect-tests/--no-protect-tests`: When enabled, prevents the LLM from modifying test files. The LLM will treat tests as read-only specifications and only fix the code. This is especially useful when tests created by `pdd bug` are known to be correct. Default: `--no-protect-tests`. +Passing tests are also checked against repository-backed data contracts. When a fix introduces a literal query field or a generated mock fabricates a field for an existing query, `pdd fix` compares that shape with the exact resource section in schema Markdown/JSON and independent production readers/writers. A real contradiction (for example, querying `user_waitlist.userId` when the `user_waitlist` schema has no `userId` field while the test mocks one) is a hard non-zero failure before manual outputs are written or agentic changes are committed. If no exact contract exists, the result is surfaced as inconclusive instead of guessing from the field name. + #### Agentic E2E Fix Options - `--timeout-adder FLOAT`: Additional seconds to add to each step's timeout (default: 0.0). - `--max-cycles INT`: Maximum number of outer loop cycles before giving up (default: 5). @@ -2439,9 +2443,9 @@ The workflow analyzes the GitHub issue to extract test information, then iterati 6. **Create Unit Tests**: For code bugs, create or append unit tests for the affected dev units 7. **Verify Tests**: Run new unit tests to confirm they detect the bugs and will pass once fixed 8. **Run PDD Fix**: Execute `pdd fix` sequentially on failing unit tests for each dev unit -9. **Verify All**: Final verification that all tests pass locally. The orchestrator independently re-runs the discovered test files (not the agent's possibly-targeted selection); if that independent verification rejects an agent-reported pass, PDD posts a trusted `## Step 9/11:` rejection comment on the issue with the exact verifier command and bounded output, and the cycle is not recorded as a success +9. **Verify All**: Final verification that all tests pass locally. The orchestrator independently re-runs the discovered test files (not the agent's possibly-targeted selection); if that independent verification rejects an agent-reported pass, PDD posts a trusted `## Step 9/11:` rejection comment on the issue with the exact verifier command and bounded output, and the cycle is not recorded as a success. Before terminal success, the shared mock-contract gate checks every workflow-owned Python query/mock change against real repository schema and sibling evidence; a divergence blocks commit/push even when all tests pass 10. **CI Validation**: First poll external CI, retrieve logs on failure, and run an LLM fix loop to remediate any CI-specific issues (lint, artifacts, build). Once CI is green, run a real pre-checkup gate (the fix path's first drift-sync step) that **blocks** on any mechanical failure (build/smoke — compile, import, caller-compat, targeted tests) or unhealable update-drift. (Architecture-sync residuals and example-only drift are advisory in default mode — not hard blocks — because they are frequently spurious on a clean tree; strict mode promotes them.) It first drift-syncs code ↔ prompt ↔ `architecture.json` ↔ `.pdd/meta` in the worktree (the prompt/code sync is committed to the PR (an example regenerated as a side effect of an update-heal is committed and validated too; example-*only* drift is advisory — the gate does not auto-heal it, to avoid the #1243 null-hash heal loop); `.pdd/meta` fingerprint finalization is intentionally left to the post-merge sync — see #1317 — so the PR tree itself may still show fingerprint drift until then), then executes its own deterministic build/smoke checks — compile touched files, import changed modules, probe changed router/app modules for route/router objects (a non-blocking note, not a hard block — best-effort app-wiring smoke that stays with checkup), lint, caller-compatibility sweep, and run targeted unit tests by git-diff (Python tests are executed; a changed JS/TS test is reported, not run). It runs these checks itself rather than relying solely on GitHub required checks (which are often absent or vacuous) -11. **Code Cleanup**: Review all changes from the workflow and clean up code quality issues (debug statements, unused imports, duplicated code); revert if tests fail +11. **Code Cleanup**: Review all changes from the workflow and clean up code quality issues (debug statements, unused imports, duplicated code); revert if tests or the mock-contract gate fail After Step 11 the workflow clears state and returns. `pdd fix` does **not** run `pdd checkup` (no Layer-1 PR-mode checkup, no Layer-2 review-loop) as a final gate — verification is the workflow's own Step 7/9 plus the deterministic pre-checkup build/smoke gate in Step 10. `pdd checkup` remains a separate command you run on its own (the semantic ship-verdict is available there via `--final-gate`). Removing the agentic gate stops a transient checkup verdict (an empty/garbled review output, or a provider rate-limit) from failing an already-committed, correct fix. Note that CI is treated as best-effort: a pending / manually-triggered / `ACTION_REQUIRED` / timed-out required check the bot cannot run is **inconclusive**, not fatal — so a successful `pdd fix` run may mean external CI was inconclusive (still pending or waiting for manual action), not confirmed green. Failed required checks remain fail-closed by default and can still drive the CI-fix loop; repos that intentionally want pure external setup/auth failures such as missing GitHub Actions/Firebase credentials to be treated as inconclusive must opt in with `.pddrc` `ci.external_setup_fail_open: true`. @@ -2756,7 +2760,7 @@ Options: - `--git`: Use git history to find the original code file, eliminating the need for the `INPUT_CODE_FILE` argument. - `--extensions EXTENSIONS`: In repository-wide mode, filter the update to only include files with the specified comma-separated extensions (e.g., `py,js,ts`). - `--simple`: Use the legacy 2-stage LLM update process instead of the default agentic mode. Useful when agentic CLIs are not available or for faster updates. -- `--sync-metadata`: After the prompt update, run the shared metadata-sync orchestrator so prompt PDD tags, `architecture.json` entries, run reports, and fingerprint state are reconciled in one step. Works in single-file, regeneration, and repo modes. **Fingerprint note:** default single-file/regeneration `pdd update ` already finalizes the per-target fingerprint (`.pdd/meta/_.json`) on success, and logs a skip reason when finalization is intentionally bypassed; `--sync-metadata` does not gate that behavior. Before writing the single-file/regeneration fingerprint, the command clears the affected module's stale `.pdd/meta/__run.json` runtime-verification report and re-checks it is gone; if the stale report survives the clear attempt (for example, silent `os.remove` failure on permissions/locks), the fingerprint write is skipped with a yellow warning so a fresh fingerprint cannot coexist with stale runtime state — best-effort, the successful `(prompt, cost, model)` update tuple is preserved either way (closing issue [#1106](https://github.com/promptdriven/pdd/issues/1106)). The stale-report warning intentionally surfaces even under `--quiet`, because it describes a real metadata-consistency problem the operator should learn about regardless of other log suppression. Default repo-mode `pdd update --repo` likewise finalizes per-pair fingerprints and, before writing each fingerprint, clears the affected module's stale `.pdd/meta/__run.json` runtime-verification report so metadata and runtime state stay in lock-step (clear failures are surfaced as non-fatal warnings, and if the stale `_run.json` still exists after the clear attempt the fingerprint write is skipped so a fresh fingerprint cannot coexist with stale runtime state — closing issue [#1057](https://github.com/promptdriven/pdd/issues/1057)). Without this flag, the broader prompt-tag/architecture/run-report orchestrator is not run and those layers must be reconciled with separate commands. **Scope note:** the `tags` stage currently *preserves* existing PDD tags and only *seeds* tags from the matching `architecture.json` entry when a prompt has none — LLM-first **refresh** of stale-but-present tags is tracked at issue [#870](https://github.com/promptdriven/pdd/issues/870) and is not invoked by this orchestrator. When a prompt has zero PDD tags AND no architecture entry, the `tags` stage reports `skipped` (never `ok`) so operators see honest status. On any stage `failed`, `pdd update --sync-metadata` exits non-zero so CI auto-heal does not treat a half-finalized update as healed. +- `--sync-metadata`: After the prompt update, run the shared metadata-sync orchestrator so prompt PDD tags, `architecture.json` entries, run reports, and fingerprint state are reconciled in one step. Works in single-file, regeneration, and repo modes. **Fingerprint note:** without this flag, every successful single-file/regeneration update and every successful `--repo` pair finalizes through the shared `FingerprintTransaction` path. The command first resolves the complete unit path set, clears the affected stale `_run.json`, verifies it is gone, and atomically writes the new fingerprint. Identity, cleanup, hashing, or persistence failure is a hard non-zero command failure; the update cannot return a false-green success tuple after mutating an artifact. With `--sync-metadata`, the orchestrator owns that fingerprint stage, so the default finalizer intentionally skips instead of double-writing. The stale-report warning still surfaces under `--quiet` because it describes a real consistency failure. Without this flag, the broader prompt-tag/architecture stages are not run and must be reconciled separately. **Scope note:** the `tags` stage currently *preserves* existing PDD tags and only *seeds* tags from the matching `architecture.json` entry when a prompt has none — LLM-first **refresh** of stale-but-present tags is tracked at issue [#870](https://github.com/promptdriven/pdd/issues/870) and is not invoked by this orchestrator. When a prompt has zero PDD tags AND no architecture entry, the `tags` stage reports `skipped` (never `ok`) so operators see honest status. On any stage `failed`, `pdd update --sync-metadata` exits non-zero so CI auto-heal does not treat a half-finalized update as healed. Example (Metadata Sync): ```bash @@ -3010,13 +3014,13 @@ The command uses a two-stage retrieval pipeline when candidates exceed 50: After inserting `` directives, the command performs a **deduplication pass** that identifies and removes inline content in the prompt that semantically duplicates what the included documents already provide. -**Metadata finalization (on success):** After a successful `auto-deps` run, the command writes/updates a fingerprint in `.pdd/meta/` for the file that was actually written (with `operation="auto-deps"` and the current include-dependency hashes) and clears any stale per-module `_run.json` report keyed by the same identity, so downstream commands see a consistent view. The fingerprint write and the include-dependency metadata update are committed together (atomic from the caller's perspective) and use hash-based, language-agnostic helpers from `pdd.operation_log`. Finalization errors are surfaced as warnings and do not mask a successful `auto-deps` result. Invalid `` tags stripped by the prompt sanitizer and architecture-merge failures (e.g. `architecture.json` could not be patched in place) are also surfaced as yellow warnings before the success summary. +**Metadata finalization (on success):** After a successful `auto-deps` run, the command writes/updates a fingerprint in `.pdd/meta/` for the file that was actually written (with `operation="auto-deps"` and the current include-dependency hashes) and clears any stale per-module `_run.json` report keyed by the same identity, so downstream commands see a consistent view. The fingerprint write is atomic (temp-file + `os.replace` via `FingerprintTransaction`) and enforced — a finalization failure exits non-zero. Invalid `` tags stripped by the prompt sanitizer and architecture-merge failures (e.g. `architecture.json` could not be patched in place) are surfaced as yellow warnings before the success summary. Identity is derived from the **output path**: - **In-place mode** (`--output `, or `pdd sync`'s post-move flow): the output path is the canonical prompt, so the fingerprint lands at `.pdd/meta/_.json`. - **Default mode** (output is the separate `__with_deps.prompt` derivative): the fingerprint lands at `.pdd/meta/__with_deps.json`. Canonical metadata is intentionally untouched in this case — the derivative fingerprint records that an `auto-deps` run produced that file without overwriting the canonical module's record. -**Note on `pdd sync`:** `pdd sync` invokes `auto-deps` with a `*_with_deps.prompt` temp output and then moves it onto the canonical prompt. To avoid leaving orphan `.pdd/meta/*_with_deps.json` files for the moved temp prompt (and to avoid clearing the wrong run report), sync passes the internal `_skip_finalization=True` flag to `auto_deps_main` and owns canonical metadata itself: sync writes the canonical fingerprint via its atomic state machinery (`_save_fingerprint_atomic`) and clears the canonical `_run.json` via `clear_run_report` immediately after the move. +**Note on `pdd sync`:** `pdd sync` invokes `auto-deps` with a `*_with_deps.prompt` temp output and then moves it onto the canonical prompt. To avoid leaving orphan `.pdd/meta/*_with_deps.json` files for the moved temp prompt (and to avoid clearing the wrong run report), sync passes the internal `_skip_finalization=True` flag to `auto_deps_main` and owns canonical metadata itself: sync writes the canonical fingerprint via `FingerprintTransaction` (atomic temp-file + rename) and clears the canonical `_run.json` via `clear_run_report` immediately after the move. The command maintains a CSV file with the following columns: - `full_path`: The full path to the dependency file @@ -3129,7 +3133,7 @@ Options: - `--max-review-minutes FLOAT`: Maximum review-loop wall-clock minutes (default: 90.0). - `--blocking-severities LIST`: Comma-separated severity names used for review-loop reporting and prompt guidance (default: `blocker,critical,medium`). The fixer still receives every valid reviewer finding. - `--continue-on-reviewer-limit`: Report provider, rate, context-window, timeout, auth, network, sandbox, permission, and non-zero-exit reviewer failures as `degraded` instead of `failed`. Degraded reviewers are still not clean unless a configured fallback reviewer completes successfully and takes over as the active reviewer. -- `--fallback-reviewer-on-failure`: When the primary reviewer ends in `failed` or `missing` (not `degraded`), run a second review pass using the fixer's identity as a fallback reviewer. On a clean fallback the rendered `reviewer-status:` line shows the primary as `clean` so the cloud verdict adapter's real-reviewer-clean rule can fire, while the primary's original failure detail is preserved in the `### Reviewer Diagnostics` subsection of the final report with a `superseded_by_fallback` marker. Off by default to preserve existing CI expectations. +- `--fallback-reviewer-on-failure`: When the primary reviewer ends in `failed` or `missing` (not `degraded`), run a second review pass using the fixer's identity as a fallback reviewer. On a clean fallback the rendered `reviewer-status:` line shows the primary as `clean` so the cloud verdict adapter's real-reviewer-clean rule can fire, while the primary's original failure detail is preserved in the `### Reviewer Diagnostics` subsection of the final report with a `superseded_by_fallback` marker. If this fallback reviewer instead reports findings, the loop **auto-degrades** rather than dead-ending (issue #1941): because the fallback reviewer is the fixer's own identity (promoted only because the primary family is down), it runs a fresh same-family fixer session on those findings and re-verifies, so a one-provider-family outage self-heals instead of terminating with "findings remain, no fix attempted". The weaker guarantee is disclosed honestly — the report renders `same-role-review-fix: true` and `role-independence: degraded ( unavailable)`, and `final-state.json` carries a `role_independence` field. Off by default to preserve existing CI expectations. - `--no-gates`: Disable the deterministic local gates (`git diff --check` against the PR range, `doc-contract`, `prettier --check`, `npx --no-install tsc --noEmit`, a non-mutating Python syntax check via the builtin `compile()`, `ruff check`/`ruff format --check`/`black --check`/`mypy`) that otherwise run before any `clean` verdict in `--review-loop`. **`ruff format --check`** is opt-in: it fires only when **either** `[tool.ruff.format]` is declared in `pyproject.toml`, **or** a literal `ruff format` / `ruff-format` reference appears in `.pre-commit-config.yaml`, `cloudbuild-*-ci.yaml`, or `.github/workflows/*.{yml,yaml}` (canonical pre-commit hook id matches the hyphenated form). Touched CI files are excluded from the signal scan to defend against PR-poisoned opt-in. The Python syntax gate routes through `compile()` rather than `python -m py_compile` so it never writes `__pycache__/*.pyc` into the worktree; the TypeScript gate uses `--no-install` so it never hits the npm registry. Gate subprocesses resolve `git` and tool binaries through a sanitized PATH so a PR cannot provide worktree-local shims via `PATH=.:$PATH`. Default: gates are enabled. The flag exists only as a diagnostic/emergency escape hatch — under normal operation a failing gate must block the PR from being declared clean even when the LLM reviewer says clean. Issue #1092. - `doc-contract` repo config: non-PDD projects can declare documentation contracts in `.pdd/doc_contract.json` or `.pdd/doc-contract.json` with `version: 1` and `rules` containing `source_globs`, `added_regex`, and `docs`/`doc_paths`. Each captured added surface must be documented in the configured docs/sections; optional `doc_regex` can tie named captures together, such as requiring an operation and its new skip condition to appear in the same documented contract. In PR mode the base branch’s config is authoritative, so a PR cannot bypass the gate by weakening its own config. - `--gate-timeout FLOAT`: Per-gate timeout in seconds (default: 60). Applies to every discovered gate; the runner kills the subprocess and surfaces the gate as a `runner-error` blocker finding when the timeout fires. diff --git a/architecture.json b/architecture.json index 0466fbced..88e8f3416 100644 --- a/architecture.json +++ b/architecture.json @@ -1589,7 +1589,8 @@ "insert_includes_python.prompt", "validate_prompt_includes_python.prompt", "operation_log_python.prompt", - "auto_deps_architecture_python.prompt" + "auto_deps_architecture_python.prompt", + "fingerprint_transaction_python.prompt" ], "priority": 35, "filename": "auto_deps_main_python.prompt", @@ -1604,7 +1605,7 @@ "functions": [ { "name": "auto_deps_main", - "signature": "(ctx: click.Context, prompt_file: str, directory_path: str, auto_deps_csv_path: Optional[str], output: Optional[str], force_scan: Optional[bool] = False, progress_callback: Optional[Callable[[int, int], None]] = None, include_docs: bool = False, no_dedup: bool = False, concurrency: int = 1, _skip_finalization: bool = False)", + "signature": "(ctx: click.Context, prompt_file: str, directory_path: str, auto_deps_csv_path: Optional[str], output: Optional[str], force_scan: Optional[bool] = False, progress_callback: Optional[Callable[[int, int], None]] = None, include_docs: bool = False, no_dedup: bool = False, concurrency: int = 1, compress: bool = False, _skip_finalization: bool = False)", "returns": "Tuple[str, float, str]" } ] @@ -2280,7 +2281,7 @@ }, { "reason": "Primary orchestration engine for transitioning .prompt files to source code, handling path resolution, incremental generation, and architectural conformance.", - "description": "Command-line interface for code generator. Parses arguments, orchestrates the workflow, and formats output. Raises structured ArchitectureConformanceError failures with output/expected/found/missing fields plus failed-attempt total_cost/model_name (constructor accepts an optional repair_directive override for the signature check whose source of truth is the prompt, not architecture.json), and injects a non-empty PDD_REPAIR_DIRECTIVE into the generation prompt inside an block so sync retry attempts receive concrete missing-export instructions. Architecture conformance now also enforces the prompt's signature across module/cli/command interface types in three categories: (1) missing function/method — declared callables (including dotted methods like ContentSelector.select) absent from the generated code surface as bare names in missing_symbols; (2) missing parameter — declared parameter names absent from a matching function/method signature surface as dotted funcname.paramname entries; (3) signature drift — annotation drift is conservative (raises only when both sides specify and differ) while default drift is strict (raises when the prompt declares a default and the generated code drops or changes it, since callers omitting an optional kwarg would otherwise break with TypeError). Each category emits a distinct error sentence so the agentic_sync_runner repair loop can build a targeted directive (add missing function/method, add missing parameter, or update parameter to match the prompt's annotation/default). Issue #1012 adds two additional repair-loop gates: (a) PublicSurfaceRegressionError — for mature modules (pre-existing non-empty code file), snapshots top-level functions, classes, recursively-walked nested classes/methods (so a method on `Outer.Inner` is captured as `Outer.Inner.method`), and module-level public `ast.Assign` / bound `ast.AnnAssign` (only when `node.value is not None`; bare annotations like `PUBLIC_NAME: int` don't bind a runtime attribute and are ignored) targets plus `ast.Import`/`ast.ImportFrom` aliases (so removing a re-export like `import git` or a public constant like `PUBLIC_SETTING = ...` surfaces as a regression). When the module declares `__all__` as a clean list/tuple of string constants, that list is AUTHORITATIVE per Python semantics: a name is public iff it appears in `__all__`, even if underscore-prefixed; names NOT in `__all__` are NOT public. Classes listed in `__all__` ALSO contribute their recursively-walked members (methods, nested classes, methods on nested classes at every depth) regardless of underscore prefix — so `__all__ = [\"Service\"]` protects `Service.run`, `Service.Inner`, and `Service.Inner.method`. Computed `__all__` (`sorted(...)`, `X + Y`) or AugAssign extension (`__all__ += [...]`) falls back to the heuristic. Without `__all__`, underscore-prefixed names (including dunders) are excluded at every level; `from X import *` contributes no fixed name and is ignored. The same `__all__` precedence rule (including the class-member recursion) is applied to the parallel `_snapshot_public_signatures` so signature-drift detection sees the same public set. `_snapshot_public_signatures` entries carry a leading kind prefix (`[function]`/`[async_function]`/`[class]` at top level; `[instance]`/`[staticmethod]`/`[classmethod]`/`[property:]` inside classes; `[assignment]` for module-level rebindings; and `[import]`/`[import:]`/`[import:from ]`/`[import:from :]` for re-exports) so a binding-kind flip (e.g. a re-exported `pathlib.Path` replaced by a same-named `def`) diffs even when the parameter list happens to match. For `@dataclass` classes without an explicit `__init__`, `_snapshot_public_signatures` synthesises the constructor signature from class-body `AnnAssign` fields in source order: `@dataclass(kw_only=True)` emits a leading `*` so every field is kw-only; an in-body `_: KW_ONLY` sentinel (`KW_ONLY` Name or `dataclasses.KW_ONLY` Attribute) splits positional vs kw-only fields and is recognised before the underscore-prefix skip; when both decorator and sentinel are present only ONE `*` is emitted (decorator wins); `InitVar[...]` fields ARE included (they are runtime constructor params even though not stored as instance attributes); `ClassVar[...]` fields and `field(init=False, ...)` defaults are excluded; an explicit `__init__` still wins over synthesis. Inherited dataclass fields are collected in REVERSE-MRO order (matching Python's `@dataclass`: `__dataclass_fields__` is populated by walking bases right-to-left so later bases overwrite earlier ones in dict-insertion order) — `class C(A, B)` synthesises `(b, a, c)`. A base decorated with `@dataclass(init=False)` STILL contributes its annotated fields to a derived `@dataclass`'s synth; `init=False` only suppresses the base's OWN `__init__` synthesis. Cross-module bases that resolve to imports rather than same-module `ClassDef`s are marked `[inherited_unresolved]`. When a derived class redeclares a base's field name, the derived annotation/default wins but the field's POSITION matches the base's original slot (insertion-order dict semantics). The gate raises when public symbols are removed/renamed or their callable signature changes unless the prompt body contains an anchored `BREAKING-CHANGE:` directive (regex `^\\s*BREAKING-CHANGE:\\s*` with `re.MULTILINE`) whose tail starts with a `remove`/`delete`/`drop`/`rename` action verb (followed by a comma-separated symbol list) for removals or a `change signature`/`change api`/`change contract` verb pair for signature drift. Buried mid-line marker mentions and bare `BREAKING-CHANGE:` markers do NOT disable the gate. First-time generation is exempt. (b) TestChurnError — when overwriting an existing non-empty test file through code_generator_main or cmd_test_main, computes the stdlib `difflib.unified_diff` churn ratio between pre and post test bodies and raises when the ratio exceeds `PDD_TEST_CHURN_THRESHOLD` (default 0.40, accepts both decimal `0.40` and percent `40%` strings; defensive fallback to default on unparseable values) without an anchored `BREAKING-CHANGE:` directive that pairs an opt-out verb (`rewrite`/`replace`/`regenerate`/`overwrite`/`churn`/`remove`/`drop`) with `tests` as the verb's direct object. PublicSurfaceRegressionError and TestChurnError share the existing PDD_REPAIR_DIRECTIVE plumbing so sync_main, sync_orchestration, and agentic_sync_runner route them through the same repair loop. Issue #1649 adds ProseOutputError before the architecture/public-surface/test-churn gates: when extracted generated content is empty or whitespace-only for a non-prompt target, code_generator_main raises a typed generation-output extraction failure containing provider/model, prompt, output path, extractor result, and raw-output excerpt, so sync retries once with an output-shape directive instead of misdiagnosing empty extraction as missing exports/churn. `PDD_ALLOW_EMPTY_GENERATION=1` is the explicit escape hatch for the rare intentional empty-output case.", + "description": "Command-line interface for code generator. Parses arguments, orchestrates the workflow, and formats output. Raises structured ArchitectureConformanceError failures with output/expected/found/missing fields plus failed-attempt total_cost/model_name (constructor accepts an optional repair_directive override for the signature check whose source of truth is the prompt, not architecture.json), and injects a non-empty PDD_REPAIR_DIRECTIVE into the generation prompt inside an block so sync retry attempts receive concrete missing-export instructions. Architecture conformance now also enforces the prompt's signature across module/cli/command interface types in three categories: (1) missing function/method — declared callables (including dotted methods like ContentSelector.select) absent from the generated code surface as bare names in missing_symbols; (2) missing parameter — declared parameter names absent from a matching function/method signature surface as dotted funcname.paramname entries; (3) signature drift — annotation drift is conservative (raises only when both sides specify and differ) while default drift is strict (raises when the prompt declares a default and the generated code drops or changes it, since callers omitting an optional kwarg would otherwise break with TypeError). Each category emits a distinct error sentence so the agentic_sync_runner repair loop can build a targeted directive (add missing function/method, add missing parameter, or update parameter to match the prompt's annotation/default). Issue #1012 adds two additional repair-loop gates: (a) PublicSurfaceRegressionError — for mature modules (pre-existing non-empty code file), snapshots top-level functions, classes, recursively-walked nested classes/methods (so a method on `Outer.Inner` is captured as `Outer.Inner.method`), and module-level public `ast.Assign` / bound `ast.AnnAssign` (only when `node.value is not None`; bare annotations like `PUBLIC_NAME: int` don't bind a runtime attribute and are ignored) targets plus `ast.Import`/`ast.ImportFrom` aliases (so removing a re-export like `import git` or a public constant like `PUBLIC_SETTING = ...` surfaces as a regression). When the module declares `__all__` as a clean list/tuple of string constants, that list is AUTHORITATIVE per Python semantics: a name is public iff it appears in `__all__`, even if underscore-prefixed; names NOT in `__all__` are NOT public. Classes listed in `__all__` ALSO contribute their recursively-walked members (methods, nested classes, methods on nested classes at every depth) regardless of underscore prefix — so `__all__ = [\"Service\"]` protects `Service.run`, `Service.Inner`, and `Service.Inner.method`. Computed `__all__` (`sorted(...)`, `X + Y`) or AugAssign extension (`__all__ += [...]`) falls back to the heuristic. Without `__all__`, underscore-prefixed names (including dunders) are excluded at every level; `from X import *` contributes no fixed name and is ignored. The same `__all__` precedence rule (including the class-member recursion) is applied to the parallel `_snapshot_public_signatures` so signature-drift detection sees the same public set. `_snapshot_public_signatures` entries carry a leading kind prefix (`[function]`/`[async_function]`/`[class]` at top level; `[instance]`/`[staticmethod]`/`[classmethod]`/`[property:]` inside classes; `[assignment]` for module-level rebindings; and `[import]`/`[import:]`/`[import:from ]`/`[import:from :]` for re-exports) so a binding-kind flip (e.g. a re-exported `pathlib.Path` replaced by a same-named `def`) diffs even when the parameter list happens to match. For `@dataclass` classes without an explicit `__init__`, `_snapshot_public_signatures` synthesises the constructor signature from class-body `AnnAssign` fields in source order: `@dataclass(kw_only=True)` emits a leading `*` so every field is kw-only; an in-body `_: KW_ONLY` sentinel (`KW_ONLY` Name or `dataclasses.KW_ONLY` Attribute) splits positional vs kw-only fields and is recognised before the underscore-prefix skip; when both decorator and sentinel are present only ONE `*` is emitted (decorator wins); `InitVar[...]` fields ARE included (they are runtime constructor params even though not stored as instance attributes); `ClassVar[...]` fields and `field(init=False, ...)` defaults are excluded; an explicit `__init__` still wins over synthesis. Inherited dataclass fields are collected in REVERSE-MRO order (matching Python's `@dataclass`: `__dataclass_fields__` is populated by walking bases right-to-left so later bases overwrite earlier ones in dict-insertion order) — `class C(A, B)` synthesises `(b, a, c)`. A base decorated with `@dataclass(init=False)` STILL contributes its annotated fields to a derived `@dataclass`'s synth; `init=False` only suppresses the base's OWN `__init__` synthesis. Cross-module bases that resolve to imports rather than same-module `ClassDef`s are marked `[inherited_unresolved]`. When a derived class redeclares a base's field name, the derived annotation/default wins but the field's POSITION matches the base's original slot (insertion-order dict semantics). The gate raises when public symbols are removed/renamed or their callable signature changes unless the prompt body contains an anchored `BREAKING-CHANGE:` directive (regex `^\\s*BREAKING-CHANGE:\\s*` with `re.MULTILINE`) whose tail starts with a `remove`/`delete`/`drop`/`rename` action verb (followed by a comma-separated symbol list) for removals or a `change signature`/`change api`/`change contract` verb pair for signature drift. Buried mid-line marker mentions and bare `BREAKING-CHANGE:` markers do NOT disable the gate. First-time generation is exempt. (b) TestChurnError — when overwriting an existing non-empty test file through code_generator_main or cmd_test_main, computes the stdlib `difflib.unified_diff` churn ratio between pre and post test bodies and raises when the ratio exceeds `PDD_TEST_CHURN_THRESHOLD` (default 0.40, accepts both decimal `0.40` and percent `40%` strings; defensive fallback to default on unparseable values) without an anchored `BREAKING-CHANGE:` directive that pairs an opt-out verb (`rewrite`/`replace`/`regenerate`/`overwrite`/`churn`/`remove`/`drop`) with `tests` as the verb's direct object. PublicSurfaceRegressionError and TestChurnError share the existing PDD_REPAIR_DIRECTIVE plumbing so sync_main, sync_orchestration, and agentic_sync_runner route them through the same repair loop. Issue #1649 adds ProseOutputError before the architecture/public-surface/test-churn gates: when extracted generated content is empty or whitespace-only for a non-prompt target, code_generator_main raises a typed generation-output extraction failure containing provider/model, prompt, output path, extractor result, and raw-output excerpt, so sync retries once with an output-shape directive instead of misdiagnosing empty extraction as missing exports/churn. `PDD_ALLOW_EMPTY_GENERATION=1` is the explicit escape hatch for the rare intentional empty-output case. Issue #1900 makes the prompt's `` (`type: module`) the stable surface contract for DECLARED symbols: declared top-level functions, dotted methods, and constructors (including underscore-prefixed helpers/classes, resolved via patch-target signature capture) are validated against the DECLARED signature — receiver-stripped to match the snapshot — instead of the previous generation, so editing the declaration authorizes an intended change with no `BREAKING-CHANGE:` permit, while a declared callable that becomes non-callable is still rejected even under `BREAKING-CHANGE: change signature` (which relaxes only binding-kind/async, never declared parameters). Violations carry JSON-encoded `signature_detail:` lines (declared expected-vs-actual) plus declaration-aware repair advice (edit the `` declaration, not `BREAKING-CHANGE`). Undeclared symbols keep the previous-generation baseline and its `BREAKING-CHANGE:` opt-out described above.", "dependencies": [ "construct_paths_python.prompt", "preprocess_python.prompt", @@ -2338,7 +2339,7 @@ }, { "name": "PublicSurfaceRegressionError", - "signature": "(prompt_name: str, output_path: str, removed_symbols: List[str], pre_surface_size: int, post_surface_size: int, changed_signatures: Optional[List[str]] = None, total_cost: float = 0.0, model_name: str = \"unknown\", repair_directive: Optional[str] = None)", + "signature": "(prompt_name: str, output_path: str, removed_symbols: List[str], pre_surface_size: int, post_surface_size: int, changed_signatures: Optional[List[str]] = None, total_cost: float = 0.0, model_name: str = \"unknown\", repair_directive: Optional[str] = None, signature_details: Optional[List[Tuple[str, str, str, str]]] = None)", "returns": "PublicSurfaceRegressionError" }, { @@ -2579,7 +2580,8 @@ "agentic_common_python.prompt", "agentic_update_python.prompt", "sync_determine_operation_python.prompt", - "operation_log_python.prompt" + "operation_log_python.prompt", + "fingerprint_transaction_python.prompt" ], "priority": 62, "filename": "update_main_python.prompt", @@ -2951,14 +2953,15 @@ }, { "reason": "CLI wrapper for unit-test repair with loop mode, cloud/local execution, and compressed repair context.", - "description": "Command-line interface for fix. Parses arguments, orchestrates loop and single-pass fix workflows, formats output, and preserves Issue #888 focused repair outside loop mode. In single-pass cloud and local modes, large code/test inputs are reduced through fix_focus.prepare_focused_inputs before the LLM call; focused calls force effective test protection, reconstruct full code with fix_focus.reconstruct_code before validation/writes, validate against the full original test file, and never write returned sliced test content. The post-success auto-submit branch skips when CloudConfig.is_running_in_cloud() is true, calls CloudConfig.ensure_default_env() before lower-level cached JWT lookup, and wraps the asyncio JWT request in asyncio.wait_for (PDD_AUTO_SUBMIT_AUTH_TIMEOUT_S, default 300s / 5 min, sized for interactive Device Flow auth; defensive parse falls back to 300s on malformed values).", + "description": "Command-line interface for fix. Parses arguments, orchestrates loop and single-pass fix workflows, formats output, and preserves Issue #888 focused repair outside loop mode. In single-pass cloud and local modes, large code/test inputs are reduced through fix_focus.prepare_focused_inputs before the LLM call; focused calls force effective test protection, reconstruct full code with fix_focus.reconstruct_code before validation/writes, validate against the full original test file, and never write returned sliced test content. Before persisting a passing fix, the Issue #1939 mock-contract gate compares prospective query/mock shapes with repository schema and sibling evidence and hard-fails real divergences. The post-success auto-submit branch skips when CloudConfig.is_running_in_cloud() is true, calls CloudConfig.ensure_default_env() before lower-level cached JWT lookup, and wraps the asyncio JWT request in asyncio.wait_for (PDD_AUTO_SUBMIT_AUTH_TIMEOUT_S, default 300s / 5 min, sized for interactive Device Flow auth; defensive parse falls back to 300s on malformed values).", "dependencies": [ "fix_error_loop_python.prompt", "fix_focus_python.prompt", "agentic_langtest_python.prompt", "auth_service_python.prompt", "core/cloud_python.prompt", - "compressed_sync_context_python.prompt" + "compressed_sync_context_python.prompt", + "mock_contract_validation_python.prompt" ], "priority": 66, "filename": "fix_main_python.prompt", @@ -2987,7 +2990,7 @@ }, { "reason": "Determines the next sync operation, including isolated replay/repair decisions.", - "description": "Analyzes prompt state, file timestamps, and previous results to decide which workflow steps are needed.", + "description": "Analyzes prompt state, file timestamps, and previous results to decide which workflow steps are needed. Its issue-1903 path wrapper adopts one runner-collected co-located test only when the test-output location is not explicitly pinned; before replacing the generated-test write target, both adopted and derived paths pass through content_selector's component-sanitizing, symlink-aware project-root containment barrier, so caller-influenced paths never flow directly into filesystem resolution.", "dependencies": [ "agentic_langtest_python.prompt" ], @@ -3116,7 +3119,8 @@ "dependencies": [ "code_generator_main_python.prompt", "agentic_sync_runner_python.prompt", - "compressed_sync_context_python.prompt" + "compressed_sync_context_python.prompt", + "fingerprint_transaction_python.prompt" ], "priority": 70, "filename": "sync_orchestration_python.prompt", @@ -3132,7 +3136,7 @@ "functions": [ { "name": "sync_orchestration", - "signature": "(basename: str, target_coverage: float = 90.0, language: str = \"python\", prompts_dir: str = \"prompts\", code_dir: str = \"src\", examples_dir: str = \"examples\", tests_dir: str = \"tests\", max_attempts: int = 3, budget: float = 10.0, skip_verify: bool = False, skip_tests: bool = False, dry_run: bool = False, force: bool = False, strength: float = DEFAULT_STRENGTH, temperature: float = 0.0, time_param: float = 0.25, verbose: bool = False, quiet: bool = False, output_cost: Optional[str] = None, review_examples: bool = False, local: bool = False, context_config: Optional[Dict[str, str]] = None, context_override: Optional[str] = None, confirm_callback: Optional[Callable[[str, str], bool]] = None, no_steer: bool = False, steer_timeout: float = DEFAULT_STEER_TIMEOUT_S, agentic_mode: bool = False, snapshot_context: bool = False, compressed_context: bool = False) -> Dict[str, Any]", + "signature": "(basename: str, target_coverage: float = 90.0, language: str = \"python\", prompts_dir: str = \"prompts\", code_dir: str = \"src\", examples_dir: str = \"examples\", tests_dir: str = \"tests\", max_attempts: int = 3, budget: float = 10.0, skip_verify: bool = False, skip_tests: bool = False, dry_run: bool = False, force: bool = False, strength: float = DEFAULT_STRENGTH, temperature: float = 0.0, time_param: float = 0.25, verbose: bool = False, quiet: bool = False, output_cost: Optional[str] = None, review_examples: bool = False, local: bool = False, context_config: Optional[Dict[str, str]] = None, context_override: Optional[str] = None, confirm_callback: Optional[Callable[[str, str], bool]] = None, no_steer: bool = False, steer_timeout: float = DEFAULT_STEER_TIMEOUT_S, agentic_mode: bool = False, one_session: bool = False, compress: bool = False, fresh: bool = False, evidence: bool = False, snapshot_context: bool = False, compressed_context: bool = False) -> Dict[str, Any]", "returns": "Dict[str, Any]" } ] @@ -5489,7 +5493,8 @@ "core/cloud_python.prompt", "code_generator_main_python.prompt", "agentic_sync_runner_python.prompt", - "compressed_sync_context_python.prompt" + "compressed_sync_context_python.prompt", + "operation_log_python.prompt" ], "priority": 148, "filename": "sync_main_python.prompt", @@ -5505,7 +5510,7 @@ "commands": [ { "name": "sync_main", - "signature": "(ctx: click.Context, basename: str, max_attempts: Optional[int], budget: Optional[float], skip_verify: bool, skip_tests: bool, target_coverage: float, dry_run: bool, no_steer: bool = False, steer_timeout: Optional[float] = None, agentic_mode: bool = False, one_session: bool = False, compress: bool = False, evidence: bool = False, snapshot_context: bool = False, compressed_context: Optional[bool] = None) -> Tuple[Dict[str, Any], float, str]", + "signature": "(ctx: click.Context, basename: str, max_attempts: Optional[int], budget: Optional[float], skip_verify: bool, skip_tests: bool, target_coverage: float, dry_run: bool, no_steer: bool = False, steer_timeout: Optional[float] = None, agentic_mode: bool = False, one_session: bool = False, compress: bool = False, fresh: bool = False, evidence: bool = False, snapshot_context: bool = False, compressed_context: Optional[bool] = None) -> Tuple[Dict[str, Any], float, str]", "returns": "Tuple[Dict[str, Any], float, str]" } ] @@ -6521,11 +6526,12 @@ }, { "reason": "Orchestrates the 11-step agentic E2E fix workflow including post-push CI validation and code cleanup.", - "description": "Coordinates running unit tests, E2E tests, root cause analysis, E2E test fixing, dev unit identification, unit test creation, test verification, pdd fix execution, CI validation, and a deterministic pre-checkup drift-sync gate.", + "description": "Coordinates running unit tests, E2E tests, root cause analysis, E2E test fixing, dev unit identification, unit test creation, test verification, pdd fix execution, repository-backed mock-contract validation, CI validation, and a deterministic pre-checkup drift-sync gate.", "dependencies": [ "agentic_bug_orchestrator_python.prompt", "ci_validation_python.prompt", - "pre_checkup_gate_python.prompt" + "pre_checkup_gate_python.prompt", + "mock_contract_validation_python.prompt" ], "priority": 183, "filename": "agentic_e2e_fix_orchestrator_python.prompt", @@ -7416,7 +7422,9 @@ { "reason": "Provides sync operation logging, fingerprints, run reports, and compression metadata storage.", "description": "Decorator-based operation logging that tracks PDD command executions. Manages fingerprints, run reports, and event logs. Works with Click commands to extract module identity from prompt_file parameter.", - "dependencies": [], + "dependencies": [ + "fingerprint_transaction_python.prompt" + ], "priority": 210, "filename": "operation_log_python.prompt", "filepath": "pdd/operation_log.py", @@ -7431,7 +7439,7 @@ "functions": [ { "name": "log_operation", - "signature": "(updates_fingerprint: bool, updates_run_report: bool, clears_run_report: bool) -> Callable", + "signature": "(operation: str, updates_fingerprint: bool = False, updates_run_report: bool = False, clears_run_report: bool = False) -> Callable", "returns": "Callable" }, { @@ -7441,8 +7449,13 @@ }, { "name": "log_event", - "signature": "(basename: str, language: str, event: str, details: Mapping[str, Any] | None = None, project_root=None, paths=None) -> None", + "signature": "(basename: str, language: str, event: str, details: Mapping[str, Any] | None = None, project_root=None, paths=None, compression=None, agentic_fallback=None) -> None", "returns": "None" + }, + { + "name": "resolve_fingerprint_paths", + "signature": "(basename: str, language: str, prompt_file: Union[str, Path], *, paths: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]", + "returns": "Dict[str, Any]" } ] } @@ -7737,8 +7750,8 @@ } }, { - "reason": "Parallel dependency-aware sync runner that forwards compressed-context sync settings.", - "description": "Manages pdd sync subprocesses using a ThreadPoolExecutor whose worker count is read by _read_sync_max_workers() from the PDD_SYNC_MAX_WORKERS env var at construction (default 4, non-integer falls back to 4, clamped to 1-4, forced to 1 when total_budget is set; MAX_WORKERS stays a backwards-compatible module constant), preserves architecture subdirectory basenames, strips architecture filename/target language suffixes via PDD's known-language catalog so compound languages such as TypeScriptReact and JavaScriptReact resolve correctly, respects dependency ordering from architecture.json, enforces total-budget sequential execution when requested, shrinks each child/retry budget by persisted module costs plus current-module in-flight retry cost, posts live progress to GitHub issue comments with a total body cap below GitHub's comment limit, keeps unrelated ready modules running after a module failure while blocking transitive failed-dependency dependents, and stops all new scheduling when total budget is exhausted. Detects four repairable generation failures from per-module sync subprocesses - architecture-conformance ('Architecture conformance error for ' line prefix, parsed by _parse_conformance_failure), public-surface regression ('Public surface regression for ' line prefix, parsed by _parse_public_surface_failure, issue #1012), test-churn ('Test churn threshold exceeded for ' line prefix, parsed by _parse_test_churn_failure, issue #1012), and prose-output ('Generation output extraction failure for ' line prefix, parsed by _parse_prose_output_failure, issue #1649, checked first and limited to 1 additional attempt) - and retries conformance/surface/churn failures up to MAX_CONFORMANCE_ATTEMPTS=3 with a PDD_REPAIR_DIRECTIVE env var that names the exact offending symbols / churn ratio and forbids editing architecture.json or rewriting unrelated tests. Every child/retry keeps the per-module resolved .pddrc context, --strength, and --temperature stable so a flaky regeneration cannot silently switch model or context between attempts. Stops retrying before launching another repair child if the in-flight repair cost exhausts total_budget, short-circuits when the failure signature (sorted missing/removed symbols, ('prose',), or (rounded churn_ratio, pre_line_count)) repeats, and on hard failure records the matching structured block - '=== architecture conformance failure ===', '=== public surface regression ===', '=== test churn threshold exceeded ===', or '=== generation output extraction failure ===' with extractor_result/raw_output_excerpt fields (built by build_conformance_hard_failure_from_error / build_public_surface_hard_failure_from_error / build_test_churn_hard_failure_from_error / build_prose_output_hard_failure_from_error, all importable from pdd.agentic_sync_runner so sync_main and sync_orchestration reuse them verbatim) - plus a 'Reproduce locally:' line and an '--- env ---' fingerprint (pdd.__file__, pdd --version, git SHA, dirty flag, repo-vs-site-packages) so the GitHub App surface is actionable without exceeding GitHub comment size limits. Bounds per-attempt child stdout/stderr capture with a _BoundedTextCapture tail (capped by STDOUT_CAPTURE_LINE_LIMIT=5000 lines and STDOUT_CAPTURE_BYTE_LIMIT=1 MiB per stream, allocated fresh each attempt) so verbose repair retries cannot grow memory unbounded and trigger SIGKILL on Cloud Run; reads child pipes as bytes and decodes UTF-8 incrementally; tracks dropped line/byte counts surfaced via _log_dropped_output() and appended to failure/timeout summaries without injecting diagnostics into captured child output. The 'Overall status: ... Failed' verdict is recorded as the stdout line streams in (sticky), so a failure marker evicted from the bounded tail by trailing output still yields a failed verdict and a correct failure reason.", + "reason": "Parallel dependency-aware sync runner that forwards compressed-context sync settings and resolves each child's --context against its own module cwd's .pddrc.", + "description": "Manages pdd sync subprocesses using a ThreadPoolExecutor whose worker count is read by _read_sync_max_workers() from the PDD_SYNC_MAX_WORKERS env var at construction (default 4, non-integer falls back to 4, clamped to 1-4, forced to 1 when total_budget is set; MAX_WORKERS stays a backwards-compatible module constant), preserves architecture subdirectory basenames, strips architecture filename/target language suffixes via PDD's known-language catalog so compound languages such as TypeScriptReact and JavaScriptReact resolve correctly, respects dependency ordering from architecture.json, enforces total-budget sequential execution when requested, shrinks each child/retry budget by persisted module costs plus current-module in-flight retry cost, posts live progress to GitHub issue comments with a total body cap below GitHub's comment limit, keeps unrelated ready modules running after a module failure while blocking transitive failed-dependency dependents, and stops all new scheduling when total budget is exhausted. Detects four repairable generation failures from per-module sync subprocesses - architecture-conformance ('Architecture conformance error for ' line prefix, parsed by _parse_conformance_failure), public-surface regression ('Public surface regression for ' line prefix, parsed by _parse_public_surface_failure, issue #1012), test-churn ('Test churn threshold exceeded for ' line prefix, parsed by _parse_test_churn_failure, issue #1012), and prose-output ('Generation output extraction failure for ' line prefix, parsed by _parse_prose_output_failure, issue #1649, checked first and limited to 1 additional attempt) - and retries conformance/surface/churn failures up to MAX_CONFORMANCE_ATTEMPTS=3 with a PDD_REPAIR_DIRECTIVE env var that names the exact offending symbols / churn ratio and forbids editing architecture.json or rewriting unrelated tests. Every child/retry keeps the per-module resolved .pddrc context, --strength, and --temperature stable so a flaky regeneration cannot silently switch model or context between attempts. Stops retrying before launching another repair child if the in-flight repair cost exhausts total_budget, short-circuits when the failure signature (sorted missing/removed symbols, ('prose',), or (rounded churn_ratio, pre_line_count)) repeats, and on hard failure records the matching structured block - '=== architecture conformance failure ===', '=== public surface regression ===', '=== test churn threshold exceeded ===', or '=== generation output extraction failure ===' with extractor_result/raw_output_excerpt fields (built by build_conformance_hard_failure_from_error / build_public_surface_hard_failure_from_error / build_test_churn_hard_failure_from_error / build_prose_output_hard_failure_from_error, all importable from pdd.agentic_sync_runner so sync_main and sync_orchestration reuse them verbatim) - plus a 'Reproduce locally:' line and an '--- env ---' fingerprint (pdd.__file__, pdd --version, git SHA, dirty flag, repo-vs-site-packages) so the GitHub App surface is actionable without exceeding GitHub comment size limits. Bounds per-attempt child stdout/stderr capture with a _BoundedTextCapture tail (capped by STDOUT_CAPTURE_LINE_LIMIT=5000 lines and STDOUT_CAPTURE_BYTE_LIMIT=1 MiB per stream, allocated fresh each attempt) so verbose repair retries cannot grow memory unbounded and trigger SIGKILL on Cloud Run; reads child pipes as bytes and decodes UTF-8 incrementally; tracks dropped line/byte counts surfaced via _log_dropped_output() and appended to failure/timeout summaries without injecting diagnostics into captured child output. The 'Overall status: ... Failed' verdict is recorded as the stdout line streams in (sticky), so a failure marker evicted from the bounded tail by trailing output still yields a failed verdict and a correct failure reason. Issue #1900: `_parse_public_surface_failure` also recovers JSON-encoded `signature_detail:` lines (via `_parse_signature_detail_lines`) and, for prompt-``-declared violations, builds declaration-aware repair guidance (update the declaration / restore the declared signature) instead of the `BREAKING-CHANGE:` advice used for undeclared/removed symbols. Issue #1903 §B.4 test-churn never-block: on test-churn exhaustion the runner does NOT hard-fail when ALL THREE guards hold - the run is issue-driven (self.issue_url set; a project-wide sync with issue_url=None keeps the strict hard-fail), the child stamped structured adoption provenance adopted:true (adopted from an existing human co-located test, unpinned, decided at path resolution before generation; a pinned/greenfield/older-child test reads false), AND the churned path is an in-repo co-located shape (_is_adopted_collocated_test_path: a .test./.spec. file, under __test__/__tests__, or a Python sibling test_.py / _test.py outside the top-level tests/ shadow, in-root after canonical containment; a PDD-owned tests/ shadow, traversal, or out-of-root path keeps the hard-fail). When all hold it keeps the already-restored human test, emits a PDD_TEST_CHURN_NEEDS_REVIEW marker, sets ModuleState.needs_review (persisted across resume in _save_state/_load_state and rendered as 'Success (needs review)' plus a '### Needs review' section in the progress comment), and reports the module synced so the PR still opens with the test flagged for review.", "dependencies": [ "architecture_sync_python.prompt", "agentic_langtest_python.prompt", @@ -7855,6 +7868,7 @@ "dependencies": [ "agentic_common_python.prompt", "checkup_review_loop_python.prompt", + "ci_validation_python.prompt", "prompt_repair_python.prompt" ], "priority": 217, @@ -7978,7 +7992,8 @@ "Issue #1088 SHA-backed verification trust boundary: captures the post-push HEAD SHA via _git_rev_parse_head (never inferred from fixer prose), populates FixResult.push_status (pushed | push_failed | not_attempted), FixResult.local_fixer_commit_sha, and FixResult.pushed_head_sha after every fix turn, and rewrites the per-round fix findings.json artifact so the on-disk audit trail carries those fields. The verifier only runs when push_status == pushed AND a non-empty pushed_head_sha was observed; otherwise the round is marked skipped/unverified and the loop stops without claiming the findings as fixed. A clean verify pass pins state.verified_head_sha to that pushed SHA", "Issue #1088 final stale-head re-fetch: _finalize calls _fetch_pr_metadata exactly once at render to read the live remote head_sha whenever there is anything to verify — state.verified_head_sha is set (a verifier pinned a SHA), state.fresh_final_status == clean (a reviewer cleared the actual worktree HEAD without a subsequent fixer push), any fix was pushed, or any finding is marked fixed. The comparison target is state.verified_head_sha when a verifier ran clean, otherwise the most recent FixResult.pushed_head_sha for partial verifier acceptance, otherwise state.reviewed_head_sha captured from git rev-parse HEAD in the worktree the reviewer inspected (never from later PR metadata). If the remote head differs from the comparison target, the comparison target was never observed, or the re-fetch returned no head_sha, downgrades fresh_final_status to missing, downgrades every verification_status_by_round entry from verified to stale, and reverts every findings_by_key entry from fixed to open so final-state.json cannot present a stale verdict. state.final_refetch_attempted is set so the render layer can distinguish a failed re-fetch (remote-pr-head-sha: unknown) from no re-fetch at all (remote-pr-head-sha: none)", "Qualifies all unverified fixer rationale prose in the final report as fixer= fixer_disposition= / fixer_rationale= and appends verification=unverified, so bare fixer claims such as 'claude: fixed - ...' never appear as verifier evidence", - "Writes per-round prompt/output/normalized-findings/dedup-state artifacts and a final-state.json under .pdd/checkup-review-loop/issue-{N}-pr-{M}/. final-state.json includes same_role_review_fix, mode, verified_head_sha, remote_pr_head_sha, reviewed_head_sha, source_of_truth, gates, and verification_status_by_round so downstream consumers can re-verify the trust boundary and distinguish independent reviewer/fixer runs from explicit single-role review/fix runs", + "Writes per-round prompt/output/normalized-findings/dedup-state artifacts and a final-state.json under .pdd/checkup-review-loop/issue-{N}-pr-{M}/. final-state.json includes same_role_review_fix, mode, role_independence, verified_head_sha, remote_pr_head_sha, reviewed_head_sha, source_of_truth, gates, and verification_status_by_round so downstream consumers can re-verify the trust boundary and distinguish independent reviewer/fixer runs from explicit single-role review/fix runs", + "Issue #1941 auto-degrade: when fallback_reviewer_on_failure promotes the fixer's own identity to a fallback reviewer (because the primary reviewer's family is unavailable) and that fallback reviewer reports actionable findings, the loop does NOT dead-end with zero fix attempts. It sets same_role_review_fix=true and role_independence='degraded ( unavailable)', promotes the fallback to active reviewer, and hands the findings to the fixer as the next round's pending findings so a fresh same-family fix + required fresh-verify runs. The superseded primary still renders '(optional, superseded by )' so the cloud verdict adapter can ship on a degraded-but-clean result. If the same-family fixer also fails, the fix-failure stop_reason names the vacancy via a trailing '[role-independence ]'. role_independence stays 'independent' when both families are alive and for deliberate config-time allow_same_reviewer_fixer runs", "Posts the final report to the source issue and PR only when use_github_state=True (writes-only suppression flag)", "Issue #1092: at every clean-exit site (round-start LLM clean, review-only clean, post-verify clean, pending-findings-empty early exit, and fallback-reviewer clean), invokes _enforce_gates_before_clean to run pdd.checkup_gates over the loop-owned worktree; any failing deterministic gate (PR-range git diff --check, prettier --check, a non-mutating Python syntax check via builtin compile(), optional ruff/black/mypy/`npx --no-install tsc --noEmit`) is converted to a synthetic blocker ReviewFinding (reviewer prefix `gate:`) that the loop feeds back into the fixer rather than declaring clean", "Issue #1092 base-ref refresh: when config.enable_gates is True the loop calls pdd.agentic_checkup_orchestrator._refresh_pr_base_ref after _fetch_pr_metadata, fetching the PR's base branch into the dedicated tracking ref refs/remotes/pdd-checkup/pr-{N}/base (NEVER refs/remotes/origin/, which would mutate the operator's tracking refs when their origin is a fork) and populating pr_metadata['base_local_ref']. The fetch is bounded by a 60s subprocess timeout so a stalled transport cannot hold the review loop forever. The refresh helper resolves a trusted absolute git binary through pdd.checkup_gates and uses the sanitized gate env for its git-root, remote, and fetch subprocesses, so PATH=.:$PATH cannot execute a PR-shipped ./git before gate discovery. The review-loop short-circuit that skipped _fetch_pr_metadata in review-only mode is suppressed when gates are enabled so review-only on non-main-base PRs still gets the PR-range git-diff-check guarantee. discover_gates resolves base_ref from pr_metadata['base_local_ref'] first and falls back to the raw base_ref name when the refresh helper succeeded but did not land the ref", @@ -8586,7 +8601,8 @@ "description": "Contains maintenance and setup commands for the PDD CLI, including single-module sync, no-argument project-wide sync, GitHub issue sync dispatch, prompt-to-architecture sync, auto-deps, and setup.", "dependencies": [ "auto_deps_main_python.prompt", - "architecture_sync_python.prompt" + "architecture_sync_python.prompt", + "fingerprint_transaction_python.prompt" ], "priority": 70, "filename": "commands/maintenance_python.prompt", @@ -10519,6 +10535,11 @@ "name": "resolve_generate_output_paths", "signature": "(prompt_file: str | Path, *, output: Optional[str] = None, context_override: Optional[str] = None, force: bool = True, quiet: bool = True) -> list[str]", "returns": "list[str]" + }, + { + "name": "resolve_test_output_paths", + "signature": "(prompt_file: str | Path, code_file: str | Path, *, output: Optional[str] = None, language: Optional[str] = None, context_override: Optional[str] = None, force: bool = True, quiet: bool = True) -> list[str]", + "returns": "list[str]" } ] } @@ -10585,8 +10606,9 @@ }, { "reason": "Provides precise extraction of file content based on criteria like lines, AST, and regex for selective includes.", - "description": "Deterministic content extraction from files based on line ranges, Python AST structures, Markdown sections, regex patterns, and JSON/YAML path traversal.", + "description": "Deterministic content extraction from files based on line ranges, Python AST structures, Markdown sections, regex patterns, and JSON/YAML path traversal. It also implements issue #1903 runner-aware test placement: it detects and adopts a single existing runner-collected co-located test, and for a greenfield JS/TS project (jest/vitest configured, no test yet) derives where the runner actually collects the first test via find_runner_collected_test_path — honoring JSON-readable testMatch/testRegex/roots/rootDir/testPathIgnorePatterns (deriving a path under a centralized roots/testMatch directory when the layout is not co-located, and conservatively refusing when an unparseable JS config customizes discovery) — so PDD never writes a runner-blind tests/ shadow or an uncollected test. Caller-influenced module/candidate paths are reduced to the trusted working-tree root and sanitized component-by-component with os.path.basename before any filesystem operation, then canonicalized and checked for symlink escape; traversal, outside absolute paths, and malformed paths safely produce no adoption.", "dependencies": [ + "context/python_preamble.prompt", "pytest_slicer_python.prompt", "api_contract_slicer_python.prompt" ], @@ -10603,7 +10625,7 @@ "functions": [ { "name": "ContentSelector.select", - "signature": "(content: str, selectors: list[str] | str, file_path: str | None = None, mode: str = \"full\")", + "signature": "(content: str, selectors: list[str] | str, file_path: str | None = None, mode: str = \"full\", expand_dependencies: bool = False)", "returns": "str" } ] @@ -11304,6 +11326,52 @@ } } }, + { + "reason": "Single enforced code path for complete, atomic PDD fingerprint finalization with commit-or-fail semantics.", + "description": "Context manager that eagerly resolves the fingerprint destination, builds and validates the canonical payload, and either writes through the shared atomic JSON primitive or buffers the identical payload in sync's outer atomic state. Used by every PDD mutating command (sync, generate, example, update, fix, auto-deps, CI heal).", + "dependencies": [ + "sync_determine_operation_python.prompt" + ], + "priority": 5, + "filename": "fingerprint_transaction_python.prompt", + "filepath": "pdd/fingerprint_transaction.py", + "tags": [ + "module", + "python" + ], + "interface": { + "type": "module", + "module": { + "functions": [ + { + "name": "FingerprintTransaction.__init__", + "signature": "(basename: str, language: str, operation: str, paths: Optional[Dict[str, Path]] = None, cost: float = 0.0, model: str = '', *, atomic_state: Optional[Any] = None) -> None", + "returns": "None" + }, + { + "name": "FingerprintTransaction.__enter__", + "signature": "() -> FingerprintTransaction", + "returns": "FingerprintTransaction" + }, + { + "name": "FingerprintTransaction.__exit__", + "signature": "(exc_type, exc_val, exc_tb) -> bool", + "returns": "bool" + }, + { + "name": "FingerprintTransaction.skip", + "signature": "(reason: str) -> None", + "returns": "None" + }, + { + "name": "FingerprintTransaction.set_include_deps_override", + "signature": "(deps: Dict[str, str]) -> None", + "returns": "None" + } + ] + } + } + }, { "reason": "Deterministic, marker-traceable pytest oracle linking user stories to the regression tests that claim them.", "description": "Collection-only (pytest --collect-only) story<->test traceability: builds a bidirectional story_id<->nodeid map from @pytest.mark.story markers and exposes tests_for_story, story_for_test, has_regression_test, and the shared story_id helper. Never runs an LLM or executes test bodies.", @@ -11623,5 +11691,61 @@ ] } } + }, + { + "reason": "Rejects green fixes whose generated mocks contradict repository-backed schema and interface evidence.", + "description": "Diff-aware AST and schema gate that extracts Python query fields and mock payload shapes, checks exact Markdown/JSON schema sections plus independent production readers/writers, and returns clean, inconclusive, or evidence-linked divergence results for manual and agentic fix workflows.", + "dependencies": [], + "priority": 5, + "filename": "mock_contract_validation_python.prompt", + "filepath": "pdd/mock_contract_validation.py", + "tags": [ + "fix", + "gate", + "module", + "python", + "testing" + ], + "interface": { + "type": "module", + "module": { + "functions": [ + { + "name": "extract_query_fields", + "signature": "(source: str, source_path: str = '') -> tuple[QueryFieldUse, ...]", + "returns": "tuple[QueryFieldUse, ...]" + }, + { + "name": "extract_mock_fields", + "signature": "(source: str, source_path: str = '') -> tuple[MockFieldUse, ...]", + "returns": "tuple[MockFieldUse, ...]" + }, + { + "name": "validate_mock_contracts", + "signature": "(*, project_root: Path, production_sources: Mapping[str | Path, str], test_sources: Mapping[str | Path, str], baseline_production_sources: Optional[Mapping[str | Path, str]] = None, baseline_test_sources: Optional[Mapping[str | Path, str]] = None) -> MockContractReport", + "returns": "MockContractReport" + }, + { + "name": "validate_changed_files", + "signature": "(*, project_root: Path, changed_files: Sequence[str], baseline_ref: Optional[str] = None) -> MockContractReport", + "returns": "MockContractReport" + }, + { + "name": "format_mock_contract_report", + "signature": "(report: MockContractReport) -> str", + "returns": "str" + }, + { + "name": "enforce_mock_contracts", + "signature": "(report: MockContractReport) -> None", + "returns": "None" + } + ] + } + }, + "position": { + "x": 11600, + "y": 2200 + } } ] diff --git a/context/checkup_review_loop_example.py b/context/checkup_review_loop_example.py index fee036bab..b36c2de0e 100644 --- a/context/checkup_review_loop_example.py +++ b/context/checkup_review_loop_example.py @@ -229,9 +229,14 @@ class ReviewLoopState: final_refetch_attempted: bool = False gate_runs: List[Dict[str, Any]] = field(default_factory=list) source_of_truth: Optional[Dict[str, Any]] = None - # True only for explicit ``allow_same_reviewer_fixer`` runs where the - # resolved reviewer and fixer are the same role. + # True when the resolved reviewer and fixer are the same role — either an + # explicit ``allow_same_reviewer_fixer`` run OR a runtime auto-degrade + # (issue #1941) when a provider family was unavailable. same_role_review_fix: bool = False + # ``"independent"`` for the normal cross-family loop and for a deliberate + # config-time same-role run; ``"degraded ( unavailable)"`` only when + # role independence was relaxed at runtime because a family was down. + role_independence: str = "independent" # --------------------------------------------------------------------------- @@ -393,6 +398,9 @@ def clear_final_state(cwd: Path, issue_number: int, pr_number: int) -> None: "active_reviewer": "codex", "same_role_review_fix": False, "mode": "independent-reviewer-fixer", + # Issue #1941: ``"independent"`` here; ``"degraded ( unavailable)"`` + # when the loop auto-degraded to a same-family review/fix session. + "role_independence": "independent", # Always present in ``final-state.json``. Empty on the happy path; # populated for any reviewer that ended in failed/degraded/missing # (see ``EXAMPLE_REVIEWER_STATUS_DETAILS`` above for the shape, @@ -456,6 +464,7 @@ def clear_final_state(cwd: Path, issue_number: int, pr_number: int) -> None: # issue_aligned: true|false # active-reviewer: # same-role-review-fix: true|false +# role-independence: independent|degraded ( unavailable) # reviewer-status: = ... fresh-final= # fresh-final-review: clean|findings|failed|degraded|missing # verified-head-sha: |none @@ -511,6 +520,7 @@ def clear_final_state(cwd: Path, issue_number: int, pr_number: int) -> None: "issue_aligned: true\n" "active-reviewer: codex\n" "same-role-review-fix: false\n" + "role-independence: independent\n" "reviewer-status: codex=clean claude=fixer fresh-final=clean\n" "fresh-final-review: clean\n" "verified-head-sha: 0123456789abcdef0123456789abcdef01234567\n" diff --git a/context/evidence_manifest_example.py b/context/evidence_manifest_example.py new file mode 100644 index 000000000..f4aad5263 --- /dev/null +++ b/context/evidence_manifest_example.py @@ -0,0 +1,55 @@ +"""Minimal executable example for :mod:`pdd.evidence_manifest`. + +The example writes a real schema-v2 receipt in an isolated temporary project, +then returns the decoded payload so callers can inspect the result without +leaving repository artifacts behind. +""" + +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from pdd.evidence_manifest import SCHEMA_VERSION, write_evidence_manifest # noqa: E402 + + +def main() -> dict[str, Any]: + """Create one isolated evidence receipt and return its decoded payload.""" + with tempfile.TemporaryDirectory(prefix="evidence_manifest_example_") as tmp: + project = Path(tmp) + prompt = project / "prompts" / "greeting_python.prompt" + output = project / "pdd" / "greeting.py" + prompt.parent.mkdir(parents=True) + output.parent.mkdir(parents=True) + prompt.write_text("Write a greeting function.\n", encoding="utf-8") + output.write_text( + "def greeting(name: str) -> str:\n" + " return f'Hello, {name}!'\n", + encoding="utf-8", + ) + + manifest_path = write_evidence_manifest( + command="pdd generate", + prompt_file=prompt, + output_files=[output], + model="example-model", + cost_usd=0.0, + project_root=project, + ) + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + + assert payload["schema_version"] == SCHEMA_VERSION + assert payload["outputs"][0]["path"] == "pdd/greeting.py" + print(f"Evidence manifest example completed (schema v{SCHEMA_VERSION}).") + return payload + + +if __name__ == "__main__": + main() diff --git a/context/fingerprint_transaction_example.py b/context/fingerprint_transaction_example.py new file mode 100644 index 000000000..1df8fa7ab --- /dev/null +++ b/context/fingerprint_transaction_example.py @@ -0,0 +1,59 @@ +"""Examples of the shared fingerprint finalization boundary.""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from pdd.fingerprint_transaction import FingerprintTransaction + + +def finalize_generated_unit( + prompt_path: Path, + code_path: Path, + *, + dry_run: bool = False, +) -> Path: + """Persist a complete fingerprint, or explicitly skip a dry run.""" + transaction = FingerprintTransaction( + "sample", + "python", + "generate", + paths={"prompt": prompt_path, "code": code_path}, + cost=0.01, + model="example-model", + ) + with transaction: + if dry_run: + transaction.skip("dry-run") + return transaction.fingerprint_path + + +class FingerprintBuffer: + """Minimal duck-typed buffer used by an outer atomic-state context.""" + + def __init__(self) -> None: + self.pending: tuple[dict[str, Any], Path, str | None] | None = None + + def set_fingerprint( + self, + payload: dict[str, Any], + path: Path, + *, + operation: str | None = None, + ) -> None: + self.pending = (payload, path, operation) + + +def buffer_sync_fingerprint( + paths: dict[str, Path], + buffer: FingerprintBuffer, +) -> None: + """Build the canonical payload while deferring persistence to sync.""" + with FingerprintTransaction( + "sample", + "python", + "sync", + paths=paths, + atomic_state=buffer, + ): + pass diff --git a/context/mock_contract_validation_example.py b/context/mock_contract_validation_example.py new file mode 100644 index 000000000..15998ca9d --- /dev/null +++ b/context/mock_contract_validation_example.py @@ -0,0 +1,50 @@ +"""Runnable example for repository-backed mock-contract validation.""" +from pathlib import Path +from tempfile import TemporaryDirectory + +from pdd.mock_contract_validation import ( + format_mock_contract_report, + validate_mock_contracts, +) + + +def main() -> None: + """Show a passing test rejected because its mock invents a schema field.""" + with TemporaryDirectory() as directory: + root = Path(directory) + schema = root / "context" / "database-schema.md" + schema.parent.mkdir(parents=True) + schema.write_text( + "```\n" + "user_waitlist/\n" + " {uid}/\n" + " email: string\n" + " status: string\n" + "```\n", + encoding="utf-8", + ) + + fixed_code = """ +def load_waitlist(ids): + return query_collection( + "user_waitlist", + filters=[("userId", "in", ids)], + ) +""" + generated_test = """ +def test_load_waitlist(mock_query): + mock_query.return_value = [{"userId": "uid-1"}] + assert load_waitlist(["uid-1"]) +""" + report = validate_mock_contracts( + project_root=root, + production_sources={"backend/reader.py": fixed_code}, + test_sources={"backend/tests/test_reader.py": generated_test}, + baseline_production_sources={"backend/reader.py": ""}, + baseline_test_sources={"backend/tests/test_reader.py": ""}, + ) + print(format_mock_contract_report(report)) + + +if __name__ == "__main__": + main() diff --git a/pdd/agentic_architecture_orchestrator.py b/pdd/agentic_architecture_orchestrator.py index 063935205..13520858a 100644 --- a/pdd/agentic_architecture_orchestrator.py +++ b/pdd/agentic_architecture_orchestrator.py @@ -33,6 +33,7 @@ from rich.markup import escape from pdd.agentic_common import ( + provider_failure_workflow, run_agentic_task, load_workflow_state, save_workflow_state, @@ -449,6 +450,7 @@ def _validate_generated_test_syntax(cwd: Path) -> List[str]: return errors +@provider_failure_workflow def run_agentic_architecture_orchestrator( issue_url: str, issue_content: str, diff --git a/pdd/agentic_bug_orchestrator.py b/pdd/agentic_bug_orchestrator.py index b5c16a4df..90415d3fd 100644 --- a/pdd/agentic_bug_orchestrator.py +++ b/pdd/agentic_bug_orchestrator.py @@ -15,6 +15,7 @@ from rich.markup import escape from .agentic_common import ( + provider_failure_workflow, branch_checked_out_worktree, clean_restart_fallback_branch, current_worktree_branch, @@ -1902,6 +1903,7 @@ def _cleanup_backups_with_regression_guard( return restored +@provider_failure_workflow def run_agentic_bug_orchestrator( issue_url: str, issue_content: str, diff --git a/pdd/agentic_change_orchestrator.py b/pdd/agentic_change_orchestrator.py index 6519637d0..3565c0a7d 100644 --- a/pdd/agentic_change_orchestrator.py +++ b/pdd/agentic_change_orchestrator.py @@ -19,6 +19,7 @@ from rich.markup import escape from pdd.agentic_common import ( + provider_failure_workflow, branch_checked_out_worktree, clean_restart_fallback_branch, current_worktree_branch, @@ -1751,8 +1752,11 @@ def _preflight_drift_heal( This runs ``sync_determine_operation`` on every prompt in the worktree (hash-only, no LLM) to find modules with ``operation == "update"`` (prompt - stale vs code), and runs ``pdd update `` on each one *in the - worktree* to realign the prompt before Step 9 rewrites it. + stale vs code), and runs + ``pdd update --git `` on each authoritative pair + *in the worktree* to realign the prompt before Step 9 rewrites it. Passing + both paths is intentional: code-only update is regeneration mode and would + re-derive prompt identity from a weak leaf basename such as ``page``. Args: worktree_path: Path to the isolated git worktree where the change will @@ -1885,17 +1889,63 @@ def _preflight_drift_heal( failed: List[str] = [] healed_prompts: List[str] = [] for drift in prompt_drifts: - if not drift.code_path: + prompt_path = getattr(drift, "prompt_path", None) + code_path = getattr(drift, "code_path", None) + # Issue #2004: detect_drift already resolved the canonical pair. Never + # discard its prompt identity and fall back to code-only regeneration; + # common leaves (page.tsx, layout.tsx, config.py) can otherwise overwrite + # a different existing prompt in the same context. + if not prompt_path or not code_path: failed.append(drift.basename) continue + # Fail closed against a `.pddrc` / architecture.json that resolves a + # drift path OUTSIDE the isolated worktree — via `..` traversal, an + # absolute path elsewhere, or a symlink escape. Step 8.5 promises heals + # are "contained to the worktree and can't touch the user's main tree"; + # `pdd update --git ` would otherwise honor an escaping path and + # write beyond it. The paths originate from repo-controlled config that + # is issue-influenced in the agentic flow, so this is a trust boundary + # (CWE-022; Codex review, PR #1998). + from pdd.content_selector import _validated_project_path + safe_prompt_path = _validated_project_path(prompt_path, root=worktree_path) + safe_code_path = _validated_project_path(code_path, root=worktree_path) + if safe_prompt_path is None or safe_code_path is None: + failed.append(drift.basename) + continue + # Pass the CANONICAL paths (symlinks already collapsed by + # _validated_project_path) to the subprocess, NOT the original spellings, + # so the child opens the resolved target directly and a symlink component + # cannot be re-pointed out of the worktree between validation and use + # (CWE-022 / CWE-367 check/use gap; Codex review, PR #1998). Expressed + # relative to the worktree root the child runs in, which equals the + # original spelling for ordinary (non-symlinked) paths. + worktree_root_resolved = worktree_path.resolve() + heal_prompt_arg = str(safe_prompt_path.relative_to(worktree_root_resolved)) + heal_code_arg = str(safe_code_path.relative_to(worktree_root_resolved)) try: # Use sys.executable + -m pdd so the heal subprocess uses the # same Python venv as the orchestrator. A bare ["pdd", ...] # would pick up whatever pdd binary is on PATH, which can be # a different version when devs have a global install plus a # project-local one. + # + # Issue #2004: pass BOTH the authoritative prompt and code paths via + # the two-argument ``--git`` form. Drift detection already proved code + # is newer; ``--git`` selects true update mode while preserving the + # exact prompt identity ``DriftInfo`` resolved. Code-only update is + # regeneration mode and re-derives identity from a weak leaf basename, + # which can overwrite an unrelated existing prompt in the same context. result = subprocess.run( - [sys.executable, "-m", "pdd", "update", "--sync-metadata", drift.code_path], + [ + sys.executable, + "-m", + "pdd", + "update", + "--sync-metadata", + "--git", + heal_prompt_arg, + heal_code_arg, + ], cwd=str(worktree_path), capture_output=True, text=True, @@ -1904,8 +1954,7 @@ def _preflight_drift_heal( ) if result.returncode == 0: healed.append(drift.basename) - if drift.prompt_path: - healed_prompts.append(drift.prompt_path) + healed_prompts.append(str(prompt_path)) if not quiet: console.print(f" [green]✓[/green] healed {drift.basename}") else: @@ -2312,6 +2361,7 @@ def _existing_pr_matches_remote_head( return bool(pr_oid) and pr_oid.lower() == remote_oid.lower() +@provider_failure_workflow def run_agentic_change_orchestrator( issue_url: str, issue_content: str, diff --git a/pdd/agentic_checkup_orchestrator.py b/pdd/agentic_checkup_orchestrator.py index 7a30c6573..22b86ccf1 100644 --- a/pdd/agentic_checkup_orchestrator.py +++ b/pdd/agentic_checkup_orchestrator.py @@ -29,6 +29,7 @@ from rich.console import Console from .agentic_common import ( + provider_failure_workflow, DEFAULT_MAX_RETRIES, _sanitize_comment_body, clear_workflow_state, @@ -6105,6 +6106,7 @@ def _ingest_row(status_label: str, value: str) -> None: ) +@provider_failure_workflow def run_agentic_checkup_orchestrator( issue_url: str, issue_content: str, diff --git a/pdd/agentic_common.py b/pdd/agentic_common.py index b7fd43ae4..3d72862a6 100644 --- a/pdd/agentic_common.py +++ b/pdd/agentic_common.py @@ -14,6 +14,9 @@ import shutil import subprocess import tempfile +import threading +from contextlib import contextmanager +from contextvars import ContextVar import time import uuid import re @@ -21,7 +24,18 @@ from datetime import datetime, timedelta, timezone, tzinfo from pathlib import Path from zoneinfo import ZoneInfo -from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Union +from typing import ( + Any, + Callable, + Dict, + Iterator, + List, + Literal, + Optional, + Set, + Tuple, + Union, +) from dataclasses import dataclass from enum import Enum @@ -1791,6 +1805,135 @@ def get_agent_provider_preference() -> List[str]: return list(_DEFAULT_PROVIDER_PREFERENCE) +# --------------------------------------------------------------------------- +# Issue #1936: run-scoped permanent-provider-failure registry +# --------------------------------------------------------------------------- +# When a provider fails with a *permanent* error (auth / quota / billing / +# credential-limit etc.), retrying it later in the same run is futile — the +# credential will not heal before the process exits. `run_agentic_task` already +# falls through to the next provider *within a single call*, but each call +# rebuilds its candidate list from scratch, so: +# * a multi-step workflow (e.g. the 12-step change orchestrator) re-attempted a +# dead provider on every step, paying its auth-failure timeout each time; and +# * a call whose only feasible candidate was the dead provider (e.g. cloud +# one-session sync where staged Codex auth is present but expired) had +# nowhere to fall through to and failed the whole task. +# This registry records permanently-failed providers for the remainder of the +# run so every later candidate list drops them. It is mirrored into the +# `PDD_AGENTIC_DISABLED_PROVIDERS` env var (comma-separated `provider:class` +# tokens) so re-entrant `pdd` subprocesses spawned after the failure — the +# one-session sync agent shells out to `pdd generate` / `pdd test` — inherit the +# same skip-list. `PDD_AGENTIC_PROVIDER` stays the hard preference filter; this +# registry only ever *removes* a provider that already proved dead this run, it +# never adds one, so a staged-auth hint (`_has_codex_auth_file` / +# `PDD_CODEX_AUTH_AVAILABLE`) cannot re-pin a provider once its auth is known bad. +PDD_AGENTIC_DISABLED_PROVIDERS_ENV: str = "PDD_AGENTIC_DISABLED_PROVIDERS" +_disabled_providers: ContextVar[Optional[Dict[str, str]]] = ContextVar( + "pdd_agentic_disabled_providers", + default=None, +) +_disabled_provider_scope_depth: ContextVar[int] = ContextVar( + "pdd_agentic_disabled_provider_scope_depth", + default=0, +) + + +def _parse_disabled_providers_env() -> Dict[str, str]: + """Parse `PDD_AGENTIC_DISABLED_PROVIDERS` into {provider: classification}.""" + parsed: Dict[str, str] = {} + raw = os.environ.get(PDD_AGENTIC_DISABLED_PROVIDERS_ENV, "") or "" + for token in raw.split(","): + token = token.strip() + if not token: + continue + provider, _, classification = token.partition(":") + provider = provider.strip() + if provider: + parsed.setdefault(provider, classification.strip() or "permanent") + return parsed + + +def get_disabled_providers() -> Dict[str, str]: + """Return providers disabled for this run as {provider: classification}. + + A workflow scope starts from the inherited + `PDD_AGENTIC_DISABLED_PROVIDERS` env var, then keeps later failures in a + context-local registry. This prevents independent workflows in a + long-lived or concurrent host from poisoning each other. + """ + current = _disabled_providers.get() + if current is None: + return _parse_disabled_providers_env() + return dict(current) + + +def mark_provider_permanently_failed( + provider: str, classification: Optional[str] +) -> None: + """Record *provider* as permanently failed for the remainder of the run. + + Idempotent: the first classification recorded for a provider wins. The + registry is copied into each provider CLI subprocess environment by + :func:`_run_with_provider`, so re-entrant ``pdd`` commands inherit it + without mutating process-global environment state. + """ + if not provider: + return + token = (classification or "permanent").strip() or "permanent" + current = get_disabled_providers() + current.setdefault(provider, token) + _disabled_providers.set(current) + + +def reset_disabled_providers() -> None: + """Clear the run-scoped permanent-failure registry (env var + memory). + + Provided for test isolation and for callers that deliberately start a fresh + provider-health epoch within one process. + """ + _disabled_providers.set({}) + os.environ.pop(PDD_AGENTIC_DISABLED_PROVIDERS_ENV, None) + + +@contextmanager +def provider_failure_scope( + initial_disabled: Optional[Dict[str, str]] = None, +) -> Iterator[None]: + """Create one isolated provider-health epoch for a logical workflow. + + Nested scopes share the outer workflow's failures. A new outer scope + starts only with failures explicitly inherited through the environment, + and restores the caller's context on exit. Context variables keep + concurrent threads/tasks isolated without holding a workflow-long lock. + """ + depth = _disabled_provider_scope_depth.get() + depth_token = _disabled_provider_scope_depth.set(depth + 1) + registry_token = None + if depth == 0: + initial = _parse_disabled_providers_env() + if initial_disabled: + initial.update(initial_disabled) + registry_token = _disabled_providers.set(initial) + try: + yield + finally: + _disabled_provider_scope_depth.reset(depth_token) + if registry_token is not None: + _disabled_providers.reset(registry_token) + + +def provider_failure_workflow(func: Callable) -> Callable: + """Run a direct agentic call in a fresh scope unless a workflow owns one.""" + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if _disabled_provider_scope_depth.get() > 0: + return func(*args, **kwargs) + with provider_failure_scope(): + return func(*args, **kwargs) + + return wrapper + + TASK_CLASS_SINGLE_FILE: str = "single_file" TASK_CLASS_MULTI_FILE: str = "multi_file" TASK_CLASS_REPO_SCALE: str = "repo_scale" @@ -4876,6 +5019,7 @@ def build_agentic_task_instruction( ) +@provider_failure_workflow def run_agentic_task( instruction: str, cwd: Path, @@ -4975,6 +5119,10 @@ def run_agentic_task( elif task_class is not None: candidates = select_harness_for_task(task_class, candidates) + # Select the routing record before filtering providers so every routed + # request has an auditable outcome, including the fail-fast case where all + # feasible providers were disabled by earlier permanent failures. Applying + # the selected config remains deferred until after feasibility filtering. if routing_policy is not None: routing_config, routing_record = select_config( routing_policy, @@ -4982,6 +5130,45 @@ def run_agentic_task( budget_remaining=None, deadline=deadline, ) + + # Drop providers that failed permanently before routing selects/pins a + # harness. If the policy's preferred harness is disabled, the normal + # selected-harness-unavailable path keeps the remaining healthy candidates + # available instead of collapsing the run to a known-dead provider. + disabled_providers = get_disabled_providers() + if disabled_providers: + healthy = [p for p in candidates if p not in disabled_providers] + if healthy != candidates: + dropped = [p for p in candidates if p in disabled_providers] + if not healthy: + if routing_record is not None: + routing_record.fallback_reason = "all_feasible_providers_disabled" + detail = "; ".join( + f"{p}: {disabled_providers[p]}" for p in dropped + ) + msg = ( + "All agent providers failed permanently earlier this run: " + f"{detail}" + ) + if not quiet: + console.print(f"[bold red]{msg}[/bold red]") + _emit_routing_outcome( + routing_record, + cwd=cwd, + success=False, + cost_usd=0.0, + latency_seconds=0.0, + ) + _restore_routing_model_env(routing_model_env_originals) + return AgenticTaskResult(False, msg, 0.0, "", None) + if verbose: + console.print( + "[dim]Skipping providers that failed permanently earlier " + f"this run: {', '.join(dropped)}[/dim]" + ) + candidates = healthy + + if routing_policy is not None: if routing_config is not None: if routing_config.harness in candidates: candidates = [routing_config.harness] @@ -5387,6 +5574,13 @@ def run_agentic_task( _classify_permanent_error(output) if not success else None ) if permanent_class is not None: + # Issue #1936: remember this provider as permanently dead for + # the remainder of the run so later agentic calls (and + # re-entrant `pdd` subprocesses) skip it instead of re-paying + # its auth-failure timeout on every step. Within THIS call the + # provider cascade below still falls through to the next + # candidate; the registry only changes future candidate lists. + mark_provider_permanently_failed(provider, permanent_class) # Issue #814: emit a default-mode (non-verbose) diagnostic so # the user sees which provider was skipped and why, instead # of the workflow silently advancing to the next provider. @@ -7150,6 +7344,14 @@ def _run_with_provider( # Prepare Environment env = os.environ.copy() + disabled_providers = get_disabled_providers() + if disabled_providers: + env[PDD_AGENTIC_DISABLED_PROVIDERS_ENV] = ",".join( + f"{name}:{classification}" + for name, classification in disabled_providers.items() + ) + else: + env.pop(PDD_AGENTIC_DISABLED_PROVIDERS_ENV, None) env["TERM"] = "dumb" env["NO_COLOR"] = "1" env["CI"] = "1" diff --git a/pdd/agentic_e2e_fix_orchestrator.py b/pdd/agentic_e2e_fix_orchestrator.py index c9228cc2c..2d06db49b 100644 --- a/pdd/agentic_e2e_fix_orchestrator.py +++ b/pdd/agentic_e2e_fix_orchestrator.py @@ -11,13 +11,14 @@ import json from datetime import datetime from pathlib import Path -from typing import List, Tuple, Dict, Any, Optional, Set, NamedTuple +from typing import List, Tuple, Dict, Any, Optional, Sequence, Set, NamedTuple from rich.console import Console import re as _re from .agentic_common import ( + provider_failure_workflow, run_agentic_task, load_workflow_state, save_workflow_state, @@ -47,6 +48,11 @@ run_ci_validation_loop, ) from .pre_checkup_gate import run_pre_checkup_gate +from .mock_contract_validation import ( + MockContractReport, + format_mock_contract_report, + validate_changed_files, +) # Constants STEP_NAMES = { @@ -303,7 +309,58 @@ def _handle_verification_failure( return failure_type, import_error_retries, False -def _resolve_step9_loop_token(step_output: str, console: Console) -> Optional[str]: +_MOCK_CONTRACT_VERIFIED_PATTERN = _re.compile( + r"^\s*\*\*Mock contract audit:\*\*\s*MOCK_CONTRACTS_VERIFIED\s*$", + _re.IGNORECASE | _re.MULTILINE, +) +_MOCK_CONTRACT_MISMATCH_PATTERN = _re.compile( + r"^\s*\*\*Mock contract audit:\*\*\s*MOCK_CONTRACT_MISMATCH\s*$", + _re.IGNORECASE | _re.MULTILINE, +) + + +def _enforce_mock_contract_audit_gate( + step_output: str, + resolved_token: Optional[str], + *, + mock_contract_audit_required: bool, + output_console: Console, +) -> Optional[str]: + """Prevent a tests-pass token from bypassing a required mock audit. + + The semantic comparison is performed by the Step 9 agent against real + schemas, types, and production readers. This deterministic boundary only + enforces the agent's explicit result marker; it never guesses whether a + field name or mocked value is valid. + """ + if _MOCK_CONTRACT_MISMATCH_PATTERN.search(step_output): + output_console.print( + "[yellow]Step 9 found a mock/interface contract mismatch; " + "starting another fix cycle.[/yellow]" + ) + return "CONTINUE_CYCLE" + + if ( + mock_contract_audit_required + and resolved_token in ("ALL_TESTS_PASS", "LOCAL_TESTS_PASS") + and not _MOCK_CONTRACT_VERIFIED_PATTERN.search(step_output) + ): + output_console.print( + "[yellow]Step 9 claimed tests pass without the required " + "MOCK_CONTRACTS_VERIFIED audit marker; starting another fix " + "cycle.[/yellow]" + ) + return "CONTINUE_CYCLE" + + return resolved_token + + +def _resolve_step9_loop_token( + step_output: str, + console: Console, + *, + mock_contract_audit_required: bool = False, +) -> Optional[str]: """Resolve Step 9 loop-control token (tiers 1–3 + tier-4 fallback). Maps ALL_TESTS_PASS at step 9 to LOCAL_TESTS_PASS for downstream handling. @@ -311,7 +368,12 @@ def _resolve_step9_loop_token(step_output: str, console: Console) -> Optional[st """ tok = _classify_step_output(step_output, step_num=9) if tok: - return tok + return _enforce_mock_contract_audit_gate( + step_output, + tok, + mock_contract_audit_required=mock_contract_audit_required, + output_console=console, + ) tier4 = classify_step_output( step_output, ["ALL_TESTS_PASS", "CONTINUE_CYCLE", "MAX_CYCLES_REACHED"] ) @@ -319,14 +381,27 @@ def _resolve_step9_loop_token(step_output: str, console: Console) -> Optional[st console.print( "[yellow]Loop control token detected via LLM classification (tier 4)[/yellow]" ) - return "LOCAL_TESTS_PASS" if tier4.token == "ALL_TESTS_PASS" else tier4.token + resolved = ( + "LOCAL_TESTS_PASS" if tier4.token == "ALL_TESTS_PASS" else tier4.token + ) + return _enforce_mock_contract_audit_gate( + step_output, + resolved, + mock_contract_audit_required=mock_contract_audit_required, + output_console=console, + ) if tier4 and tier4.token == "CLASSIFICATION_ERROR": console.print( "[yellow]Step 9 classification unavailable (tier 4 error); " "starting next cycle.[/yellow]" ) return "CONTINUE_CYCLE" - return None + return _enforce_mock_contract_audit_gate( + step_output, + None, + mock_contract_audit_required=mock_contract_audit_required, + output_console=console, + ) def _resolve_cached_step9_output(step_outputs: Dict[str, str]) -> str: @@ -354,6 +429,8 @@ def _post_step9_resume_action( current_cycle: int, max_cycles: int, console: Console, + *, + mock_contract_audit_required: bool = False, ) -> str: """Decide what resume should do when last_completed_step >= 9 (Issue #1001). @@ -379,8 +456,19 @@ def _post_step9_resume_action( # (that's Step 3), but if the cached output surfaces it, treat as # success — Step 3 would already have determined no bug exists. if "NOT_A_BUG" in step9_output: - return "SUCCESS_FALL_THROUGH" - tok = _resolve_step9_loop_token(step9_output, console) + not_a_bug_gate = _enforce_mock_contract_audit_gate( + step9_output, + "LOCAL_TESTS_PASS", + mock_contract_audit_required=mock_contract_audit_required, + output_console=console, + ) + if not_a_bug_gate != "CONTINUE_CYCLE": + return "SUCCESS_FALL_THROUGH" + tok = _resolve_step9_loop_token( + step9_output, + console, + mock_contract_audit_required=mock_contract_audit_required, + ) if tok in ("ALL_TESTS_PASS", "LOCAL_TESTS_PASS"): return "SUCCESS_FALL_THROUGH" # Explicit terminal token from Step 9 (tier-1–3 detect_control_token @@ -682,6 +770,25 @@ def _is_intermediate_file(filepath: str) -> bool: # TypeScript/JavaScript test file extensions _TS_TEST_EXTENSIONS = (".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx") +# These patterns identify use of test-double APIs only. They intentionally do +# not inspect mocked field names or values; semantic validity is established by +# the Step 9 real-contract audit (Issue #1939). +_MOCK_CONTRACT_API_PATTERNS = ( + _re.compile(r"\b(?:unittest\.)?mock\.(?:patch|Mock|MagicMock|AsyncMock)\b"), + _re.compile(r"\b(?:Mock|MagicMock|AsyncMock)\s*\("), + _re.compile(r"(? bool: """Check if a filename matches test file conventions for any supported language. @@ -702,6 +809,39 @@ def _is_test_file(filename: str) -> bool: return False +def _find_mock_contract_test_files(test_files: List[str], cwd: Path) -> List[str]: + """Return in-worktree test files that use a recognized mocking API. + + This is a routing detector, not a schema validator. It decides whether the + Step 9 agent must perform the real interface/schema comparison and is + deliberately independent of field-name heuristics. + """ + root = cwd.resolve() + audit_files: List[str] = [] + seen: Set[str] = set() + + for test_file in test_files: + candidate = (cwd / test_file).resolve() + try: + candidate.relative_to(root) + except ValueError: + continue + if not candidate.is_file(): + continue + try: + source = candidate.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + if not any(pattern.search(source) for pattern in _MOCK_CONTRACT_API_PATTERNS): + continue + normalized = str(candidate.relative_to(root)) + if normalized not in seen: + seen.add(normalized) + audit_files.append(normalized) + + return audit_files + + def _extract_marker_files(text: str) -> List[str]: """Extract test file paths from E2E_FILES_CREATED/FILES_CREATED markers in text. @@ -983,6 +1123,37 @@ def _verify_tests_independently(test_files: List[str], cwd: Path) -> Tuple[bool, return all_passed, "\n".join(all_outputs) +def _validate_changed_mock_contracts( + *, + cwd: Path, + changed_files: Sequence[str], + baseline_ref: Optional[str], +) -> MockContractReport: + """Run the deterministic schema/mock gate over workflow-owned changes.""" + return validate_changed_files( + project_root=cwd, + changed_files=changed_files, + baseline_ref=baseline_ref, + ) + + +def _changed_mock_contract_error( + *, + cwd: Path, + changed_files: Sequence[str], + baseline_ref: Optional[str], +) -> Optional[str]: + """Return a rendered divergence error, or ``None`` when the gate permits.""" + report = _validate_changed_mock_contracts( + cwd=cwd, + changed_files=changed_files, + baseline_ref=baseline_ref, + ) + if report.diverged: + return format_mock_contract_report(report) + return None + + def _update_dev_unit_states(output: str, current_states: Dict[str, Any], identified_units_str: str) -> Dict[str, Any]: """Updates dev unit states based on Step 8 output.""" identified_units = [u.strip() for u in identified_units_str.split(",") if u.strip()] @@ -1649,7 +1820,7 @@ def _commit_and_push( # Commit with message referencing issue commit_msg = f"fix: {issue_title}\n\nFixes #{issue_number}" commit_result = subprocess.run( - ["git", "commit", "-m", commit_msg], + ["git", "commit", "--only", "-m", commit_msg, "--", *files_to_commit], cwd=cwd, capture_output=True, text=True @@ -1696,6 +1867,7 @@ def _run_pre_checkup_gate_with_remediation( timeout: float, initial_file_hashes: Dict[str, str], quiet: bool, + initial_sha: str = "", ) -> Tuple[bool, str, float, List[str]]: """Run the local pre-checkup gate and remediate actionable gate failures.""" total_cost = 0.0 @@ -1779,11 +1951,29 @@ def _run_pre_checkup_gate_with_remediation( latest_changed_files, ) + remediation_changed_files = ( + _detect_changed_files(cwd, initial_file_hashes) or latest_changed_files + ) + remediation_contract_error = _changed_mock_contract_error( + cwd=cwd, + changed_files=remediation_changed_files, + baseline_ref=initial_sha, + ) + if remediation_contract_error: + return ( + False, + "pre_checkup_gate fix failed mock-contract validation: " + f"{remediation_contract_error}", + total_cost, + remediation_changed_files, + ) + commit_success, commit_message = _commit_ci_fix( cwd=cwd, repo_owner=repo_owner, repo_name=repo_name, issue_number=issue_number, + allowed_files=remediation_changed_files, ) if not commit_success: return False, commit_message, total_cost, latest_changed_files @@ -1920,6 +2110,26 @@ def _run_step11_code_cleanup( verified, verify_output = _verify_tests_independently(test_files, cwd) if _fallback_scan_was_capped and verified: verified = False + if verified: + cleanup_candidate_files = _detect_changed_files(cwd, initial_file_hashes) + cleanup_contract_report = _validate_changed_mock_contracts( + cwd=cwd, + changed_files=cleanup_candidate_files, + baseline_ref=initial_sha, + ) + if cleanup_contract_report.diverged: + verified = False + verify_output = format_mock_contract_report(cleanup_contract_report) + if not quiet: + console.print( + "[yellow]Step 11: Mock-contract validation failed after " + "cleanup; reverting cleanup changes.[/yellow]" + ) + elif cleanup_contract_report.status == "inconclusive" and not quiet: + console.print( + "[yellow]Step 11: " + f"{format_mock_contract_report(cleanup_contract_report)}[/yellow]" + ) if verified: # Commit cleanup as a separate commit cleanup_changed = _detect_changed_files(cwd, initial_file_hashes) @@ -2034,6 +2244,7 @@ def _build_post_loop_state( } +@provider_failure_workflow def run_agentic_e2e_fix_orchestrator( issue_url: str, issue_content: str, @@ -2088,6 +2299,7 @@ def run_agentic_e2e_fix_orchestrator( # after `success`/`final_message` are initialized below. Default: no-op. _resume_deferred_action: Optional[str] = None _cached_step9_resume_action: Optional[str] = None + resume_mock_contract_audit_required = False # On resume, restore the current cycle's start snapshot so the resume-time # cycle-waste-breaker can prove no in-cycle progress before authorizing # terminal success. None when the saved state is legacy (no snapshot) or @@ -2191,8 +2403,23 @@ def run_agentic_e2e_fix_orchestrator( ) if last_completed_step >= 9 and not has_cached_failed_output: step9_cached = _resolve_cached_step9_output(step_outputs) + resume_test_files = _extract_test_files( + issue_content, + changed_files, + cwd, + resumed_initial_file_hashes, + ) + resume_mock_contract_audit_required = bool( + _find_mock_contract_test_files(resume_test_files, cwd) + ) _cached_step9_resume_action = _post_step9_resume_action( - step9_cached, current_cycle, max_cycles, console + step9_cached, + current_cycle, + max_cycles, + console, + mock_contract_audit_required=( + resume_mock_contract_audit_required + ), ) cached_step3 = step_outputs.get("3") @@ -2244,7 +2471,13 @@ def run_agentic_e2e_fix_orchestrator( else: step9_cached = _resolve_cached_step9_output(step_outputs) resume_action = _post_step9_resume_action( - step9_cached, current_cycle, max_cycles, console + step9_cached, + current_cycle, + max_cycles, + console, + mock_contract_audit_required=( + resume_mock_contract_audit_required + ), ) if resume_action == "ADVANCE_CYCLE": current_cycle += 1 @@ -2794,6 +3027,7 @@ def _post_verifier_rejection_comment( raise ValueError(f"Could not load prompt template: {template_name}") # 2. Prepare Context + step9_mock_contract_files: List[str] = [] context = { "issue_url": issue_url, "repo_owner": repo_owner, @@ -2826,6 +3060,25 @@ def _post_verifier_rejection_comment( if step_num == 9: context["next_cycle"] = current_cycle + 1 + step9_test_files = _extract_test_files( + issue_content, + changed_files, + cwd, + initial_file_hashes, + ) + step9_mock_contract_files = _find_mock_contract_test_files( + step9_test_files, cwd + ) + context["mock_contract_audit_required"] = ( + "true" if step9_mock_contract_files else "false" + ) + context["mock_contract_test_files"] = ( + "\n".join( + f"- `{path}`" for path in step9_mock_contract_files + ) + if step9_mock_contract_files + else "- (none detected)" + ) # Preprocess to escape curly braces in included content exclude = list(context.keys()) @@ -3107,7 +3360,13 @@ def _post_verifier_rejection_comment( # Check Loop Control (Step 9) if step_num == 9: - _step9_token = _resolve_step9_loop_token(step_output, console) + _step9_token = _resolve_step9_loop_token( + step_output, + console, + mock_contract_audit_required=bool( + step9_mock_contract_files + ), + ) def _merge_step9_apply(r: _Step9TokenApplyResult) -> None: nonlocal import_error_retries, success, final_message @@ -3233,7 +3492,13 @@ def _merge_step9_apply(r: _Step9TokenApplyResult) -> None: exc_info=True, ) - retry_token = _resolve_step9_loop_token(retry_output, console) + retry_token = _resolve_step9_loop_token( + retry_output, + console, + mock_contract_audit_required=bool( + step9_mock_contract_files + ), + ) if retry_token is None and not retry_success: console.print( "[yellow]Step 9 retry failed with no parseable loop-control token; " @@ -3329,6 +3594,25 @@ def _merge_step9_apply(r: _Step9TokenApplyResult) -> None: if actual_changed_files: changed_files = actual_changed_files + # Independent test execution cannot detect a mock that fabricates + # the same nonexistent field the production fix now queries. Gate + # the terminal success on repository schema/sibling evidence before + # committing or pushing anything. This applies to every success + # route (normal Step 9, retries, cached resume, and early exits). + mock_contract_report = _validate_changed_mock_contracts( + cwd=cwd, + changed_files=changed_files, + baseline_ref=initial_sha, + ) + if mock_contract_report.diverged: + final_message = format_mock_contract_report(mock_contract_report) + console.print(f"\n[bold red]{final_message}[/bold red]") + return False, final_message, total_cost, model_used, changed_files + if mock_contract_report.status == "inconclusive": + contract_warning = format_mock_contract_report(mock_contract_report) + console.print(f"\n[bold yellow]{contract_warning}[/bold yellow]") + final_message = f"{final_message} {contract_warning}".strip() + console.print("\n[bold green]E2E fix complete[/bold green]") console.print(f" Total cost: ${total_cost:.4f}") console.print(f" Cycles used: {current_cycle if current_cycle <= max_cycles else max_cycles}/{max_cycles}") @@ -3465,6 +3749,15 @@ def _merge_step9_apply(r: _Step9TokenApplyResult) -> None: run_agentic_task_fn=run_agentic_task, timeout=E2E_FIX_STEP_TIMEOUTS[10] + timeout_adder, quiet=quiet, + pre_commit_check=lambda files: _changed_mock_contract_error( + cwd=cwd, + changed_files=files, + baseline_ref=initial_sha, + ), + commit_files=lambda: ( + _detect_changed_files(cwd, initial_file_hashes) + or changed_files + ), ) total_cost += ci_cost changed_files = _detect_changed_files(cwd, initial_file_hashes) or changed_files @@ -3548,6 +3841,7 @@ def _merge_step9_apply(r: _Step9TokenApplyResult) -> None: ci_retries=ci_retries, timeout=E2E_FIX_STEP_TIMEOUTS[10] + timeout_adder, initial_file_hashes=initial_file_hashes, + initial_sha=initial_sha, quiet=quiet, ) ) @@ -3566,6 +3860,24 @@ def _merge_step9_apply(r: _Step9TokenApplyResult) -> None: # fingerprint finalization stays with the post-merge sync, per the # PR plan). It is a safe no-op (returns success) when the gate # healed nothing. + final_contract_error = _changed_mock_contract_error( + cwd=cwd, + changed_files=( + _detect_changed_files(cwd, initial_file_hashes) + or changed_files + ), + baseline_ref=initial_sha, + ) + if final_contract_error: + return ( + False, + "pre_checkup_gate drift-sync failed mock-contract validation: " + f"{final_contract_error}", + total_cost, + model_used, + changed_files, + ) + gate_sync_ok, gate_sync_message = _commit_and_push( cwd=cwd, issue_number=issue_number, diff --git a/pdd/agentic_split_orchestrator.py b/pdd/agentic_split_orchestrator.py index ab9c3d74c..9b7d67dba 100644 --- a/pdd/agentic_split_orchestrator.py +++ b/pdd/agentic_split_orchestrator.py @@ -15,6 +15,7 @@ from rich.table import Table from .agentic_common import ( + provider_failure_workflow, DEFAULT_MAX_RETRIES, clear_workflow_state, load_workflow_state, @@ -1167,6 +1168,7 @@ def _normalize_intent(raw: Optional[str]) -> Optional[str]: # Main orchestrator # --------------------------------------------------------------------------- +@provider_failure_workflow def run_agentic_split_orchestrator( target_file: str, *, diff --git a/pdd/agentic_sync.py b/pdd/agentic_sync.py index f1aee8f65..e663ee8cf 100644 --- a/pdd/agentic_sync.py +++ b/pdd/agentic_sync.py @@ -21,7 +21,11 @@ from rich.console import Console from .agentic_change import _check_gh_cli, _escape_format_braces, _parse_issue_url, _run_gh_command -from .agentic_common import build_agentic_task_instruction, run_agentic_task +from .agentic_common import ( + build_agentic_task_instruction, + provider_failure_workflow, + run_agentic_task, +) from .agentic_sync_runner import ( AsyncSyncRunner, _architecture_entry_aliases, @@ -2621,6 +2625,7 @@ def _truncate_head_tail(text: str, max_len: int) -> str: return result +@provider_failure_workflow def run_agentic_sync( issue_url: str, *, diff --git a/pdd/agentic_sync_runner.py b/pdd/agentic_sync_runner.py index 2a4e9ba8f..320ac4717 100644 --- a/pdd/agentic_sync_runner.py +++ b/pdd/agentic_sync_runner.py @@ -13,6 +13,7 @@ import json import os import re +import secrets import shutil import signal import subprocess @@ -225,6 +226,11 @@ class ModuleState: error: Optional[str] = None current_phase: Optional[str] = None completed_phases: List[str] = field(default_factory=list) + # Issue #1903 §B.4: set when an unreconcilable adopted co-located test was + # kept unchanged and flagged for review instead of hard-failing the + # issue-driven workflow. The module still counts as ``success`` (the PR is + # opened); this note surfaces in the progress comment / PR thread. + needs_review: Optional[str] = None class DepGraphFromArchitectureResult(NamedTuple): @@ -306,6 +312,216 @@ def _format_duration(start: Optional[float], end: Optional[float]) -> str: ) _PUBLIC_SURFACE_PREFIX = "Public surface regression for " _TEST_CHURN_PREFIX = "Test churn threshold exceeded for " +# Issue #1903 §B.4: emitted (stdout) when an unreconcilable adopted co-located +# test is kept and flagged for review instead of hard-failing the issue workflow. +_TEST_CHURN_NEEDS_REVIEW_MARKER = "PDD_TEST_CHURN_NEEDS_REVIEW" +# Env var naming the pipe FD the child reads the churn-provenance nonce from +# (issue #1903 §B.4 review round 8). MUST match code_generator_main._CHURN_NONCE_ENV. +_CHURN_NONCE_ENV = "PDD_CHURN_NONCE_FD" + + +_TEST_CHURN_HEADERS = ( + "=== test churn threshold exceeded ===", + "Test churn threshold exceeded for ", +) + + +def _test_churn_block_regions(stdout: str, stderr: str) -> list[str]: + """Every test-churn block region in the captured output. + + Each region runs from a churn header to the next header (or end). Field + extraction is scoped to these regions so an UNRELATED ``output:``/``adopted:`` + diagnostic line OUTSIDE any churn block is ignored (round 3). But it scans + ALL blocks — not just the last — so a forged churn block injected by + untrusted test output cannot silently OVERRIDE the real one: the extractors + require the fields to be UNANIMOUS across every block and otherwise fail + closed, so an attacker who prints a conflicting block only causes a strict + hard-fail, never a flipped never-block (issue #1903 review round 6, forgeable + provenance). A fully in-band signal is not attacker-proof; this raises the + bar and defaults to the safe outcome. + """ + combined = (stdout or "") + "\n" + (stderr or "") + starts = sorted( + idx + for header in _TEST_CHURN_HEADERS + for idx in _all_indices(combined, header) + ) + regions: list[str] = [] + for i, start in enumerate(starts): + end = starts[i + 1] if i + 1 < len(starts) else len(combined) + regions.append(combined[start:end]) + return regions + + +def _all_indices(text: str, sub: str) -> list[int]: + out, start = [], 0 + while True: + idx = text.find(sub, start) + if idx < 0: + break + out.append(idx) + start = idx + 1 + return out + + +def _region_has_valid_nonce(region: str, expected_nonce: Optional[str]) -> bool: + """True when *region* carries a ``nonce:`` matching *expected_nonce* (round 8). + + The parent hands each child a secret nonce over a non-inherited pipe FD; a + genuine PDD churn block echoes it, an untrusted project-test forgery cannot. + Constant-time compare. When no nonce is expected (``None``/empty), no region + can be authenticated so this is False. + """ + if not expected_nonce: + return False + m = re.search(r"^nonce:\s*([0-9a-f]{8,128})\s*$", region, re.MULTILINE) + return bool(m) and secrets.compare_digest(m.group(1), expected_nonce) + + +def _test_churn_block_regions_trusted( + stdout: str, stderr: str, expected_nonce: Optional[str] +) -> list[str]: + """Churn regions used for provenance. When *expected_nonce* is provided + (the issue-driven never-block), ONLY regions bearing the matching nonce are + trusted — a forged in-band block is dropped, not merely out-voted. When it is + ``None`` (standalone/tests), all regions are considered (legacy behavior).""" + regions = _test_churn_block_regions(stdout, stderr) + if expected_nonce is None: + return regions + return [r for r in regions if _region_has_valid_nonce(r, expected_nonce)] + + +def _extract_test_churn_output_path( + stdout: str, stderr: str, expected_nonce: Optional[str] = None +) -> Optional[str]: + """The churned ``output: `` — UNANIMOUS across all TRUSTED churn + blocks. + + Fails CLOSED to ``None`` when absent or when any two churn blocks disagree, + so a forged/injected block cannot substitute a different path. With + *expected_nonce* set, only nonce-authenticated blocks are consulted. + + Round 10: validated PER BLOCK, then unanimous across blocks — a trusted block + MISSING the field (or carrying conflicting values WITHIN it) fails closed, + honoring the "ANY conflict OR absence fails closed" contract rather than + letting one complete block cover for an incomplete sibling. + """ + regions = _test_churn_block_regions_trusted(stdout, stderr, expected_nonce) + if not regions: + return None + per_block: set[str] = set() + for region in regions: + vals = { + m.group(1).strip() + for m in re.finditer(r"^output:\s*(.+)$", region, re.MULTILINE) + if m.group(1).strip() + } + if len(vals) != 1: + return None # a trusted block missing/conflicting output -> fail closed + per_block.add(next(iter(vals))) + return next(iter(per_block)) if len(per_block) == 1 else None + + +def _extract_test_churn_adopted( + stdout: str, stderr: str, expected_nonce: Optional[str] = None +) -> bool: + """The ``adopted:`` provenance — True ONLY when UNANIMOUSLY ``true`` across + every TRUSTED churn block. A missing marker, any ``false``, or a conflict → + False (keep the strict hard-fail), so injecting an ``adopted: true`` cannot + flip a real ``adopted: false`` churn (issue #1903 review round 6). With + *expected_nonce* set (round 8), a block that does not carry the parent's + secret nonce is not trusted at all — so a hostile project test that merely + PRINTS ``adopted: true`` is ignored and the module keeps the strict hard-fail. + Round 10: validated PER BLOCK — a trusted block MISSING the ``adopted:`` + marker (or carrying both values) fails closed, so an incomplete authenticated + block cannot be covered for by a complete one. + """ + regions = _test_churn_block_regions_trusted(stdout, stderr, expected_nonce) + if not regions: + return False + per_block: set[str] = set() + for region in regions: + vals = { + m.group(1).lower() + for m in re.finditer( + r"^adopted:\s*(true|false)\s*$", region, re.MULTILINE | re.IGNORECASE + ) + } + if len(vals) != 1: + return False # a trusted block missing/conflicting adopted -> fail closed + per_block.add(next(iter(vals))) + return per_block == {"true"} + + +def _is_adopted_collocated_test_path( + test_path: Optional[str], *, project_root: Optional[Path] = None +) -> bool: + """True only when *test_path* is an IN-REPO, co-located (adopted) test. + + The never-block relief exists to keep a co-located test PDD adopted — NOT to + silently swallow coverage loss on a PDD-owned test or to act on a path + outside the project. This is a NECESSARY (not sufficient) gate: pathname + shape alone cannot prove human authorship, so it composes with the + ``self.issue_url`` guard and the upstream coverage-preserving auto-accept. + + Production churn paths are typically ABSOLUTE (``construct_paths`` / + ``resolve_test_output_path`` emit a canonical absolute path). Such a path is + accepted only after canonical containment in *project_root* (the worktree + root); it is then reduced to its repo-relative form for the shape checks. An + absolute path with no root to validate against, an out-of-root path, or any + ``..`` traversal (CWE-022) is rejected. PDD's derived shadow root (a + top-level ``tests/`` directory — ``tests/test_foo.py`` OR ``tests/foo.test.ts``) + is never "adopted". Accepts a runner co-location convention: a file under a + ``__test__``/``__tests__`` directory, a basename containing ``.test.``/ + ``.spec.`` (JS/TS), or a Python sibling basename ``test_.py`` / + ``_test.py`` OUTSIDE that top-level ``tests/`` shadow (issue #1903 + supports adopting an existing co-located Python sibling, whose churn must + reach the same never-block — review round 8). Never raises. + """ + if not test_path: + return False + normalized = test_path.replace("\\", "/").strip() + if not normalized: + return False + is_absolute = normalized[0] in "/~\\" or bool(re.match(r"^[A-Za-z]:", normalized)) + # Canonicalize BOTH absolute and relative paths against the worktree root and + # require containment (issue #1903 review round 6): a relative path whose + # segment is a symlink escaping the root (`src/link/foo.test.ts`) must be + # rejected, not merely lexically checked for ``..``. No trusted root -> fail + # closed for absolute paths; a relative path with no root falls back to a + # lexical-only check (best effort, still rejecting ``..``). + if is_absolute or project_root is not None: + if project_root is None: + return False + try: + root_resolved = Path(project_root).resolve() + raw = Path(test_path).expanduser() + resolved = (raw if raw.is_absolute() else (root_resolved / normalized)).resolve() + rel = resolved.relative_to(root_resolved) # raises if outside root (incl. symlink escape) + except (ValueError, OSError, RuntimeError): + return False + segments = [seg for seg in rel.as_posix().split("/") if seg] + else: + segments = [seg for seg in normalized.split("/") if seg] + if any(seg == ".." for seg in segments): + return False # traversal escape + if not segments: + return False + # PDD's derived shadow root (top-level ``tests/``) is never "adopted". + if segments[0] == "tests": + return False + if "__test__" in segments or "__tests__" in segments: + return True + name = segments[-1].lower() + if ".test." in name or ".spec." in name: + return True + # Python co-located sibling conventions (round 8): ``test_.py`` or + # ``_test.py`` outside the top-level ``tests/`` shadow (already + # excluded above). #1903 adopts an existing Python sibling test, whose churn + # must reach the same never-block as JS ``.test.``/``.spec.`` siblings. + if name.endswith(".py") and (name.startswith("test_") or name.endswith("_test.py")): + return True + return False def _parse_prose_output_failure( @@ -645,9 +861,59 @@ def build_conformance_hard_failure_from_error( return "\n".join(block_lines) +def _parse_signature_detail_lines(combined: str) -> List[Tuple[str, str, str, str]]: + """Parse ``signature_detail:`` lines into ``(symbol, expected, actual, source)``. + + A ``signature_detail:`` line carries the full expected-vs-actual contract for + one declared signature mismatch (issue #1900). The subprocess path rebuilds + the repair directive from stdout, so it must recover these lines to keep the + DECLARED expected signature — the stable repair target — in the directive and + the hard-failure block. Emitted by ``PublicSurfaceRegressionError`` and + ``_build_public_surface_hard_failure`` as a JSON object: + ``signature_detail: {"symbol": ..., "expected": ..., "actual": ..., "source": ...}`` + + JSON is bulletproof against signatures/defaults that contain the old ` | ` / + ``| actual: `` / ``| source: `` field delimiters (PEP-604 unions, string + defaults) — a class of corruption that recurred across several review passes + (codex round-8 finding 2). A line whose payload is not a well-formed JSON + object with the four string fields is SKIPPED (never raises). De-duplicated, + preserving first-seen order. + """ + details: List[Tuple[str, str, str, str]] = [] + seen: set = set() + for raw_line in combined.splitlines(): + line = raw_line.strip() + if not line.startswith("signature_detail:"): + continue + payload = line[len("signature_detail:"):].strip() + try: + obj = json.loads(payload) + detail = ( + str(obj["symbol"]), + str(obj["expected"]), + str(obj["actual"]), + str(obj["source"]), + ) + except (ValueError, TypeError, KeyError): + # Not a well-formed JSON detail object -> malformed line, skip it. + continue + if detail in seen: + continue + seen.add(detail) + details.append(detail) + return details + + def _parse_public_surface_failure_fields( stdout: str, stderr: str -) -> Optional[Tuple[str, Tuple[str, ...], Tuple[str, ...]]]: +) -> Optional[ + Tuple[ + str, + Tuple[str, ...], + Tuple[str, ...], + Tuple[Tuple[str, str, str, str], ...], + ] +]: """Detect a public-surface regression and keep removals/signatures separate.""" combined = (stdout or "") + "\n" + (stderr or "") if _PUBLIC_SURFACE_PREFIX not in combined: @@ -681,6 +947,9 @@ def _parse_public_surface_failure_fields( } ) ) + # Preserve declaration source order (de-duped) so the directive lists the + # declared repair targets deterministically. + details_tuple = tuple(_parse_signature_detail_lines(combined)) if not removed and not changed: return None lines = ["Public surface regression repair required."] @@ -688,16 +957,55 @@ def _parse_public_surface_failure_fields( lines.append("Restore these public symbols from the existing module:") for sym in removed: lines.append(f"- {sym}") - if changed: + # Prefer the DECLARED signature as the repair target: it is a stable target, + # unlike "restore compatible signatures" (compatible with the code being + # regenerated), which dead-ended the change->sync loop (issue #1900). + declared_details = [d for d in details_tuple if d[3] == "pdd-interface"] + if declared_details: + # Inject the DECLARED signature as a VERBATIM hard constraint, not just a + # description of the violation (issue #1968): annotation-level drift + # (declared `object`, regenerated `Any`; or a broadened param union) + # converges only when the retry is told to reproduce the declared + # annotation text token-for-token instead of a "compatible" spelling. + lines.append( + "Restore these public symbols to their declared " + " signatures — emit each declared signature " + "VERBATIM. Reproduce the declared annotation text token-for-token; " + "do not substitute an equivalent-but-differently-spelled type (keep " + "`object` as `object`; never emit `Any` where the declaration says " + "`object`) and do not broaden a declared parameter's type with `|` " + "union members the declaration omits:" + ) + for symbol, expected_entry, actual_entry, _ in declared_details: + lines.append( + f"- Restore `{symbol}` to its declared signature " + f"`{expected_entry}` (found `{actual_entry}`). Emit exactly " + f"`{expected_entry}` — the prior attempt emitted " + f"`{actual_entry}`, which differs only in annotation spelling " + f"and was rejected." + ) + lines.append( + "If a declared parameter change is intended, edit the prompt's " + " declaration to the intended signature (the " + "declaration is the contract for declared symbols)." + ) + declared_changed = {d[0] for d in declared_details} + remaining_changed = [sym for sym in changed if sym not in declared_changed] + if remaining_changed: lines.append("Restore compatible signatures for these public symbols:") - for sym in changed: + for sym in remaining_changed: lines.append(f"- {sym}") - lines.append( - "Preserve backward-compatible public helpers unless the prompt lists " - "the intended changes with scoped BREAKING-CHANGE: remove " - "or BREAKING-CHANGE: change signature markers." - ) - return "\n".join(lines), removed, changed + # Keep the BREAKING-CHANGE guidance only for UNDECLARED / removed violations: + # for a declared symbol it relaxes only binding-kind/async, not the declared + # params, so advising it on a pure declared-param violation loops the user + # back into the dead-end #1900 removes (codex round-7 finding 3). + if removed or remaining_changed or not declared_details: + lines.append( + "Preserve backward-compatible public helpers unless the prompt lists " + "the intended changes with scoped BREAKING-CHANGE: remove " + "or BREAKING-CHANGE: change signature markers." + ) + return "\n".join(lines), removed, changed, details_tuple def _parse_public_surface_failure( @@ -707,7 +1015,7 @@ def _parse_public_surface_failure( parsed = _parse_public_surface_failure_fields(stdout, stderr) if parsed is None: return None - directive, removed, changed = parsed + directive, removed, changed, _details = parsed signature = tuple( [f"removed:{symbol}" for symbol in removed] + [f"signature_changed:{symbol}" for symbol in changed] @@ -753,6 +1061,36 @@ def _parse_test_churn_failure( return directive, signature +def _public_surface_repair_advice( + has_declared: bool, + has_non_declared: bool, +) -> List[str]: + """Repair-advice lines for a public-surface hard-failure block. + + A declared ```` violation is fixed by editing the declaration — + ``BREAKING-CHANGE: change signature`` relaxes only the un-declarable + binding-kind/async for declared symbols, NOT their parameters, so advising the + marker for a declared-param mismatch loops the user back into the dead-end + #1900 removes (codex round-7 finding 3). Undeclared / removed violations keep + the BREAKING-CHANGE guidance. + """ + lines: List[str] = [] + if has_declared: + lines += [ + "For a declared `` symbol, update the prompt's", + "`` declaration to the intended signature (the", + "declaration is the contract), or restore the declared signature", + "shown above.", + ] + if has_non_declared or not has_declared: + lines += [ + "To allow this surface change, add a `BREAKING-CHANGE:` directive to", + "the prompt body. Example: `BREAKING-CHANGE: remove ` (or", + "`rename`, `change signature`).", + ] + return lines + + def build_public_surface_hard_failure_from_error( exc: Any, basename: str, @@ -760,6 +1098,10 @@ def build_public_surface_hard_failure_from_error( """Format a structured public-surface hard-failure block.""" removed = list(getattr(exc, "removed_symbols", []) or []) changed = list(getattr(exc, "changed_signatures", []) or []) + details = list(getattr(exc, "signature_details", []) or []) + declared_changed = {d[0] for d in details if len(d) >= 4 and d[3] == "pdd-interface"} + has_declared = bool(declared_changed) + has_non_declared = bool(removed) or bool(set(changed) - declared_changed) block_lines = [ str(exc), "", @@ -771,9 +1113,7 @@ def build_public_surface_hard_failure_from_error( f"pre surface size: {getattr(exc, 'pre_surface_size', '')}", f"post surface size: {getattr(exc, 'post_surface_size', '')}", "", - "To allow this surface change, add a `BREAKING-CHANGE:` directive to", - "the prompt body. Example: `BREAKING-CHANGE: remove ` (or", - "`rename`, `change signature`).", + *_public_surface_repair_advice(has_declared, has_non_declared), "", f"Reproduce locally: pdd sync {basename}", "", @@ -797,6 +1137,11 @@ def build_test_churn_hard_failure_from_error( f"threshold: {getattr(exc, 'threshold', '')}", f"pre lines: {getattr(exc, 'pre_line_count', '')}", f"post lines: {getattr(exc, 'post_line_count', '')}", + # Issue #1903 §B.4 provenance — whether this test was ADOPTED from an + # existing human co-located test (unpinned). The issue-driven never-block + # requires it to be true (in addition to the in-repo co-located + # classifier + issue scope). + f"adopted: {str(bool(getattr(exc, 'adopted_human', False))).lower()}", "", "To allow this rewrite, add a `BREAKING-CHANGE: rewrite tests`", "directive to the prompt body.", @@ -1197,6 +1542,12 @@ def __init__( self.quiet = quiet self.verbose = verbose self.issue_url = issue_url + # Secret per-run nonce for the trusted churn-provenance channel (issue + # #1903 §B.4 review round 8): handed to each child sync over a + # non-inherited pipe FD and required back in any churn block the + # never-block trusts, so untrusted project-test stdout cannot forge the + # adoption provenance / output path that downgrades a hard-fail. + self._churn_nonce: str = secrets.token_hex(16) self.project_root: Path = Path.cwd() self.module_cwds: Dict[str, Any] = dict(module_cwds or {}) self.module_targets: Dict[str, str] = dict(module_targets or {}) @@ -1246,6 +1597,11 @@ def __init__( self.budget_exhausted: bool = False self.comment_id: Optional[int] = None self.lock = threading.Lock() + # Serializes the ENTIRE snapshot->serialize->os.replace of _save_state + # (round 9) so a stale save cannot finish AFTER a newer one and overwrite + # it (which could resurrect a module as pending or drop its needs-review + # note on resume). Distinct from self.lock, which only guards the snapshot. + self._save_lock = threading.Lock() # Track child process groups for cleanup on interrupt self._child_pgids: set = set() @@ -1294,6 +1650,11 @@ def _load_state(self) -> None: state.completed_phases = list(phases) state.start_time = info.get("start_time") state.end_time = info.get("end_time") + # Issue #1903 §B.4: restore the needs-review flag so a resumed + # run still surfaces it in the progress comment / final summary. + review_note = info.get("needs_review") + if isinstance(review_note, str) and review_note: + state.needs_review = review_note if basename not in self._resumed_modules: self._resumed_modules.append(basename) @@ -1305,7 +1666,14 @@ def _load_state(self) -> None: pass def _save_state(self) -> None: - """Atomically persist current state to disk.""" + """Atomically persist current state to disk. + + The whole snapshot->write->replace runs under ``self._save_lock`` (round + 9) so concurrent saves are fully serialized: the save that acquires the + lock LAST both snapshots and writes last, so an earlier stale save can + never land on top of a newer one (which could drop a needs-review note or + resurrect a module as pending on resume). + """ if not self.issue_url: return @@ -1315,6 +1683,10 @@ def _save_state(self) -> None: except OSError: return + with self._save_lock: + self._write_state_locked(state_path) + + def _write_state_locked(self, state_path: Path) -> None: modules_data: Dict[str, Dict[str, Any]] = {} with self.lock: for basename in self.basenames: @@ -1327,6 +1699,10 @@ def _save_state(self) -> None: "start_time": state.start_time, "end_time": state.end_time, "error": state.error, + # Issue #1903 §B.4: persist so a durable resume does not drop + # the "needs review" flag for an already-synced module (the + # PR shipped with an adopted test left for review). + "needs_review": state.needs_review, } data = { @@ -1804,6 +2180,7 @@ def _build_comment_body(self, issue_number: int) -> str: error=s.error, current_phase=s.current_phase, completed_phases=list(s.completed_phases), + needs_review=s.needs_review, ) for b, s in self.module_states.items() } @@ -1813,7 +2190,7 @@ def _build_comment_body(self, issue_number: int) -> str: total_cost += state.cost if state.status == "success": - status_str = "Success" + status_str = "Success (needs review)" if state.needs_review else "Success" duration = _format_duration(state.start_time, state.end_time) cost_str = f"${state.cost:.2f}" n = len(state.completed_phases) @@ -1849,6 +2226,19 @@ def _build_comment_body(self, issue_number: int) -> str: lines.append("") lines.append(f"**Total cost:** ${total_cost:.2f}") + # Issue #1903 §B.4: surface any adopted tests kept and flagged for + # review (the workflow shipped the PR instead of hard-failing). + needs_review_notes = [ + states_snapshot[b].needs_review + for b in self.basenames + if states_snapshot[b].needs_review + ] + if needs_review_notes: + lines.append("") + lines.append("### ⚠️ Needs review") + for review_note in needs_review_notes: + lines.append(f"- {review_note}") + # Status footer failed_modules = [ b for b in self.basenames if states_snapshot[b].status == "failed" @@ -2146,11 +2536,19 @@ def _run_inner(self) -> Tuple[bool, str, float]: return False, msg, total_cost self._delete_state() - return ( - True, - f"All {len(succeeded)} modules synced successfully", - total_cost, - ) + # Issue #1903 §B.4: modules whose adopted test could not be reconciled + # still succeeded (PR ships), but name them so the caller/report can + # relay the "needs review" flag rather than claiming a clean sync. + needs_review = [ + b for b in succeeded if self.module_states[b].needs_review + ] + summary = f"All {len(succeeded)} modules synced successfully" + if needs_review: + summary += ( + f" (needs review: {needs_review} — an adopted test was kept " + "unchanged and flagged; see PR body)" + ) + return (True, summary, total_cost) # ------------------------------------------------------------------ # Subprocess execution @@ -2498,6 +2896,60 @@ def _sync_one_module(self, basename: str) -> Tuple[bool, float, str]: basename, last_error, last_stdout, last_stderr ) elif last_failure_kind == "test_churn": + # Issue #1903 §B.4 — never block the issue-driven workflow on an + # unreconcilable ADOPTED co-located test. Reaching here means: the + # child sync already RESTORED the pre-existing test to disk before + # re-raising, AND one-session's #2208 coverage-preserving auto-accept + # already REFUSED (so this is the coverage-losing case the strict gate + # would otherwise hard-fail). Per #1903 the command must still open the + # working PR and flag that one test "needs review" rather than hand + # work back to the user — but ONLY when the churned test is a + # human-authored co-located test PDD adopted. A PDD-owned ``tests/`` + # shadow that is NOT proven adopted keeps the strict hard-fail so + # coverage loss there is never silently swallowed. (Standalone + # `pdd sync ` / `pdd test`, which do not run through this + # issue-driven runner, always keep the strict hard-fail.) + # Round 8: gate BOTH the path and the adoption provenance on the + # per-run secret nonce, so only a genuinely PDD-emitted churn block + # (which echoed the nonce it read over the private FD) can drive the + # never-block. A block a hostile project test printed to stdout lacks + # the nonce and is ignored -> strict hard-fail. + churned_test_path = _extract_test_churn_output_path( + last_stdout, last_stderr, expected_nonce=self._churn_nonce + ) + churn_was_adopted = _extract_test_churn_adopted( + last_stdout, last_stderr, expected_nonce=self._churn_nonce + ) + # THREE guards, ALL required, so the relief never escapes its scope: + # (1) issue-driven workflow only — ``self.issue_url`` is set only + # when this runner backs a GitHub issue → PR sync (agentic_sync + # / durable_sync_runner). Project-wide ``pdd sync`` builds this + # runner with ``issue_url=None`` and opens NO PR, so it must + # keep the strict hard-fail (there is no PR to flag "needs + # review" against — relaxing it there would silently bypass the + # gate). + # (2) structured adoption provenance — the child stamped + # ``adopted: true`` because the churned test was ADOPTED from an + # existing human co-located test (unpinned), decided at path + # resolution BEFORE generation (``_extract_test_churn_adopted``). + # A user-pinned path, a greenfield test PDD created, or an older + # child (no marker) reads False -> strict hard-fail. + # (3) in-repo co-located shape — the path is a co-located test + # inside the worktree, not a ``tests/`` shadow or traversal + # (see ``_is_adopted_collocated_test_path``). + if ( + self.issue_url + and churn_was_adopted + and _is_adopted_collocated_test_path( + churned_test_path, project_root=self.project_root + ) + ): + note = self._register_test_churn_needs_review( + basename, churned_test_path + ) + if not self.quiet: + console.print(f"[yellow]{note}[/yellow]") + return True, total_cost, "" hard_block = self._build_test_churn_hard_failure( basename, last_error, last_stdout, last_stderr ) @@ -2507,6 +2959,32 @@ def _sync_one_module(self, basename: str) -> Tuple[bool, float, str]: ) return False, total_cost, hard_block + def _register_test_churn_needs_review( + self, basename: str, test_path: Optional[str] + ) -> str: + """Flag an unreconcilable adopted test for review instead of failing (#1903). + + The child sync already restored the human-authored test to disk before + re-raising the churn error; the issue workflow opens the PR with the test + flagged for review. ``test_path`` is the adopted co-located test resolved + by the caller (already classified via ``_is_adopted_collocated_test_path``). + Records the note on the module state (surfaced in the progress comment / + PR thread), emits a machine-readable marker to stdout, and returns the + operator-facing note. + """ + kept = test_path or "the adopted test" + note = ( + f"`{basename}`: test churn could not be reconciled after bounded " + f"repair — kept the existing test (`{kept}`) unchanged and " + f"flagged it for review (issue #1903); the PR still ships." + ) + print(f"{_TEST_CHURN_NEEDS_REVIEW_MARKER}: {kept}", flush=True) + with self.lock: + state = self.module_states.get(basename) + if state is not None: + state.needs_review = note + return note + def _build_prose_output_hard_failure( self, basename: str, @@ -2623,6 +3101,7 @@ def _build_public_surface_hard_failure( parsed = _parse_public_surface_failure_fields(stdout, stderr) removed = parsed[1] if parsed else tuple() changed = parsed[2] if parsed else tuple() + details = parsed[3] if parsed else tuple() combined = (stdout or "") + "\n" + (stderr or "") prompt_field = "" @@ -2660,28 +3139,52 @@ def _extract_field(label: str, default: str = "") -> str: return default return match.group(1).strip().rstrip(".").strip() or default - return "\n".join( + block_lines = [ + failure_summary or "Public surface regression", + "", + "=== public surface regression ===", + f"prompt: {prompt_field}", + f"output: {_extract_field('Output')}", + "removed: " + (", ".join(removed) if removed else ""), + "signature_changed: " + + (", ".join(changed) if changed else ""), + f"pre surface size: {_extract_field('Pre surface size')}", + f"post surface size: {_extract_field('Post surface size')}", + ] + # Carry the full expected-vs-actual contract for each declared signature + # mismatch so criterion 4 (no truncation) holds on the agentic path too + # (issue #1900). Byte-identical JSON to the ``signature_detail:`` message + # line emitted by ``PublicSurfaceRegressionError`` (codex round-8 #2). + for symbol, expected_entry, actual_entry, source in details: + block_lines.append( + "signature_detail: " + + json.dumps( + { + "symbol": symbol, + "expected": expected_entry, + "actual": actual_entry, + "source": source, + } + ) + ) + declared_changed = { + d[0] for d in details if len(d) >= 4 and d[3] == "pdd-interface" + } + has_declared = bool(declared_changed) + has_non_declared = bool(removed) or bool(set(changed) - declared_changed) + block_lines.append("") + block_lines.extend( + _public_surface_repair_advice(has_declared, has_non_declared) + ) + block_lines.extend( [ - failure_summary or "Public surface regression", - "", - "=== public surface regression ===", - f"prompt: {prompt_field}", - f"output: {_extract_field('Output')}", - "removed: " + (", ".join(removed) if removed else ""), - "signature_changed: " - + (", ".join(changed) if changed else ""), - f"pre surface size: {_extract_field('Pre surface size')}", - f"post surface size: {_extract_field('Post surface size')}", - "", - "To allow this surface change, add a `BREAKING-CHANGE:` directive to", - "the prompt body. Example: `BREAKING-CHANGE: remove ` (or", - "`rename`, `change signature`).", "", f"Reproduce locally: {self._reproduce_command(basename)}", "", _env_fingerprint(), ] ) + return "\n".join(block_lines) def _build_test_churn_hard_failure( self, @@ -2749,6 +3252,11 @@ def _extract_field(label: str, default: str = "") -> str: f"threshold: {threshold}", f"pre lines: {_extract_field('Pre lines')}", f"post lines: {_extract_field('Post lines')}", + # Issue #1903 §B.4 provenance — preserve the child's adoption + # stamp in the FINAL recorded block too (round-5 lockstep), so a + # hard-failure recorded when the never-block does NOT apply still + # carries whether the churned test was an adopted human test. + f"adopted: {str(_extract_test_churn_adopted(stdout, stderr, expected_nonce=self._churn_nonce)).lower()}", "", "To allow this rewrite, add a `BREAKING-CHANGE: rewrite tests`", "directive to the prompt body.", @@ -2912,6 +3420,19 @@ def _read_stream( cwd_str = str(self._module_cwd_path(basename)) + # Trusted churn-provenance channel (issue #1903 §B.4 review round 8): write + # the secret nonce into a pipe and hand the child ONLY the read end via + # ``pass_fds``. The child inherits this FD; the grandchild test processes it + # later spawns do NOT (subprocess ``close_fds`` default), so a hostile + # project test can read the ``PDD_CHURN_NONCE_FD`` env var but not the FD it + # names — it cannot learn the nonce and thus cannot forge a trusted block. + nonce_read_fd, nonce_write_fd = os.pipe() + try: + os.write(nonce_write_fd, self._churn_nonce.encode("ascii")) + finally: + os.close(nonce_write_fd) + env[_CHURN_NONCE_ENV] = str(nonce_read_fd) + try: process = subprocess.Popen( cmd, @@ -2923,6 +3444,7 @@ def _read_stream( text=False, bufsize=0, start_new_session=True, + pass_fds=(nonce_read_fd,), ) try: self._child_pgids.add(process.pid) @@ -2938,6 +3460,13 @@ def _read_stream( except OSError: pass return False, cost, str(exc), "", "" + finally: + # The child holds its own inherited copy; drop the parent's so the + # write side's EOF reaches the child and no FD leaks per attempt. + try: + os.close(nonce_read_fd) + except OSError: + pass t_out = threading.Thread( target=_read_stream, diff --git a/pdd/agentic_test_orchestrator.py b/pdd/agentic_test_orchestrator.py index 00e9af6f4..184a8d200 100644 --- a/pdd/agentic_test_orchestrator.py +++ b/pdd/agentic_test_orchestrator.py @@ -16,6 +16,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple, Union from .agentic_common import ( + provider_failure_workflow, branch_checked_out_worktree, clean_restart_fallback_branch, current_worktree_branch, @@ -388,6 +389,7 @@ def _poll_parallel_results(results_path: Path, timeout: float) -> Optional[str]: return None +@provider_failure_workflow def run_agentic_test_orchestrator( issue_url: str, issue_content: str, diff --git a/pdd/auto_deps_main.py b/pdd/auto_deps_main.py index 101697c4c..b764b119e 100644 --- a/pdd/auto_deps_main.py +++ b/pdd/auto_deps_main.py @@ -13,10 +13,14 @@ from .validate_prompt_includes import sanitize_prompt_output from .auto_deps_architecture import merge_auto_deps_includes_from_cwd from .operation_log import ( + _clear_run_report_before_fingerprint, + get_fingerprint_path, infer_module_identity, + resolve_fingerprint_paths, save_fingerprint, - clear_run_report, - get_run_report_path, +) +from .fingerprint_transaction import ( + FingerprintFinalizeError, ) @@ -188,8 +192,8 @@ def auto_deps_main( # written (``output_path``); in in-place mode this resolves to the # canonical ``_`` module, in the default CLI # flow it resolves to the ``__with_deps`` - # derivative. Errors are surfaced as warnings and never mask a - # successful auto-deps result. + # derivative. Finalization is part of command success: a real + # mutation may not return green without a persisted fingerprint. # # ``_skip_finalization=True`` is set by ``pdd sync`` because it # invokes auto-deps with a temp ``__with_deps`` @@ -201,76 +205,55 @@ def auto_deps_main( # run-report clear on its side). if _skip_finalization: return cleaned_prompt, total_cost, model_name - try: - basename, language = infer_module_identity(Path(output_path)) - if basename is None or language is None: - if not quiet: - console.print( - f"[yellow]Warning: Could not infer module identity for " - f"{output_path}; skipping fingerprint finalization.[/yellow]" - ) - else: - # Issue #1211: route clear/verify/save through the same - # `paths` hint (the prompt path we just wrote) so all - # three target the subproject's .pdd/meta — not a parent - # CWD orphan — when auto-deps is run from above the - # subproject root. - _autodeps_paths = {"prompt": Path(output_path)} - # Clear stale run report; do not let its failure block fingerprint save - try: - clear_run_report(basename, language, paths=_autodeps_paths) - except Exception as cr_exc: - if not quiet: - console.print( - f"[yellow]Warning: Failed to clear run report for " - f"{basename}_{language}: {cr_exc}[/yellow]" - ) - # Defensive: clear_run_report() in pdd.operation_log silently swallows - # OSError on the actual unlink, so verify the report is really gone. - try: - _stale_report_path = get_run_report_path( - basename, language, paths=_autodeps_paths - ) - if _stale_report_path.exists(): - if not quiet: - console.print( - f"[yellow]Warning: clear_run_report did not remove " - f"{_stale_report_path}; downstream sync may still see " - f"stale results.[/yellow]" - ) - except Exception as _vrf_exc: - if not quiet: - console.print( - f"[yellow]Warning: could not verify run-report removal: " - f"{_vrf_exc}[/yellow]" - ) - try: - save_fingerprint( - basename=basename, - language=language, - operation="auto-deps", - paths=_autodeps_paths, - cost=total_cost, - model=model_name, - ) - except Exception as fp_exc: - if not quiet: - console.print( - f"[yellow]Warning: Failed to save fingerprint for " - f"{basename}_{language}: {fp_exc}[/yellow]" - ) - except Exception as meta_exc: - # Never mask a successful auto-deps result on metadata errors + basename, language = infer_module_identity(Path(output_path)) + if basename is None or language is None: if not quiet: console.print( - f"[yellow]Warning: Metadata finalization encountered an error: {meta_exc}[/yellow]" + "[yellow]Skipping fingerprint finalization for " + f"unmanaged output {output_path}: could not infer " + "module identity.[/yellow]" ) + return cleaned_prompt, total_cost, model_name + + # Issue #1211: route clear and commit through the same known path + # so both target the subproject's .pdd/meta directory. + _autodeps_paths = resolve_fingerprint_paths( + basename, + language, + output_path, + paths={"prompt": Path(output_path)}, + ) + if not _clear_run_report_before_fingerprint( + basename, + language, + paths=_autodeps_paths, + ): + raise FingerprintFinalizeError( + "auto-deps", + get_fingerprint_path( + basename, + language, + paths=_autodeps_paths, + ), + "run report not cleared", + ) + save_fingerprint( + basename=basename, + language=language, + operation="auto-deps", + paths=_autodeps_paths, + cost=total_cost, + model=model_name, + ) return cleaned_prompt, total_cost, model_name except click.Abort: # Re-raise to allow orchestrators (e.g. pdd sync) to stop the loop raise + except FingerprintFinalizeError: + # Commit-or-fail: the Click boundary must see a non-zero failure. + raise except Exception as exc: if not quiet: console.print(f"[red]Error in auto-deps: {exc}[/red]") diff --git a/pdd/checkup_review_loop.py b/pdd/checkup_review_loop.py index e04d39318..b70893825 100644 --- a/pdd/checkup_review_loop.py +++ b/pdd/checkup_review_loop.py @@ -48,7 +48,11 @@ _refresh_pr_base_ref, _setup_pr_worktree, ) -from .agentic_common import DEFAULT_MAX_RETRIES, run_agentic_task +from .agentic_common import ( + DEFAULT_MAX_RETRIES, + provider_failure_workflow, + run_agentic_task, +) from .agentic_e2e_fix_orchestrator import push_with_retry from .architecture_registry import extract_modules @@ -329,6 +333,7 @@ def _defang_severity_tags(text: str) -> str: # - ``_ISSUE_ALIGNED_RE`` (``issue_aligned: true|false``) → ``_defang_issue_aligned`` # - ``_REVIEWER_STATUS_INLINE_RE`` (``reviewer-status:``) → ``_defang_reviewer_status_inline`` # - ``_FRESH_FINAL_INLINE_RE`` (``fresh-final-review:``) → ``_defang_fresh_final_inline`` +# - ``_DEFANG_ROLE_INDEPENDENCE_INLINE_RE`` (``role-independence:``) → ``_defang_role_independence_inline`` # - ``_BUDGET_EXHAUSTED_RE`` (``max-*-reached: true``) → ``_defang_budget_reached`` # - ``_REVIEWER_SECTION_RE`` / ``_FRESH_FINAL_SECTION_RE`` (markdown # heading scanners) → ``_defang_section_headings`` @@ -379,6 +384,15 @@ def _defang_severity_tags(text: str) -> str: r"\b(fresh[-_ ]?final(?:[-_ ]?review)?)(\s*[:=])", re.IGNORECASE, ) +# Issue #1941: the cloud verdict adapter reads ``role-independence:`` to route +# a degraded run to ``ship_degraded``. A reviewer stderr tail echoing +# ``role-independence: independent`` would otherwise let untrusted diagnostics +# override the authoritative header and downgrade a degraded disclosure back to +# a plain ship (or vice-versa). +_DEFANG_ROLE_INDEPENDENCE_INLINE_RE: re.Pattern[str] = re.compile( + r"\b(role[-_ ]?independence)(\s*[:=])", + re.IGNORECASE, +) # ``max-rounds-reached: true`` / ``max-cost-reached: true`` / # ``max-duration-reached: true`` / ``max-minutes-reached: true`` — @@ -484,6 +498,13 @@ def _defang_fresh_final_inline(text: str) -> str: return _DEFANG_FRESH_FINAL_INLINE_RE.sub(r"\1*\2", text) +def _defang_role_independence_inline(text: str) -> str: + """Neutralize inline ``role-independence:`` markers (issue #1941).""" + if not text: + return text + return _DEFANG_ROLE_INDEPENDENCE_INLINE_RE.sub(r"\1*\2", text) + + def _defang_budget_reached(text: str) -> str: """Neutralize ``max-*-reached: true`` budget markers.""" if not text: @@ -546,6 +567,7 @@ def _defang_adapter_trip_wires(text: str) -> str: text = _defang_issue_aligned(text) text = _defang_reviewer_status_inline(text) text = _defang_fresh_final_inline(text) + text = _defang_role_independence_inline(text) text = _defang_budget_reached(text) text = _defang_section_headings(text) text = _defang_pipe_table_lines(text) @@ -919,12 +941,70 @@ class ReviewLoopState: # final-state/report consumers from inferring the legacy independent # reviewer/fixer loop when one role intentionally handled both steps. same_role_review_fix: bool = False + # Issue #1941: role-independence disclosure. ``"independent"`` for the + # normal cross-family reviewer/fixer loop (and also for a deliberate + # config-time ``allow_same_reviewer_fixer`` run — that intent is disclosed + # separately by ``same_role_review_fix``; this field tracks only *runtime* + # degradation). Set to + # ``"degraded ( unavailable)"`` when the loop had to relax + # reviewer/fixer independence at runtime — running one family as both + # reviewer and fixer because the other family was unavailable and a + # guaranteed deadlock ("findings remain, no fix attempted") was the only + # alternative. Rendered verbatim in the final report and + # final-state.json so downstream verdict consumers and humans see the + # weaker guarantee. Kept in the appended field block so positional + # construction stays stable. + role_independence: str = "independent" @property def findings(self) -> List[ReviewFinding]: return list(self.findings_by_key.values()) +def _degraded_role_independence_note(unavailable_reviewer: Optional[str]) -> str: + """Render the ``role-independence`` degradation note for issue #1941. + + Produced when the loop auto-degrades to a same-family review/fix session + because the ``unavailable_reviewer``'s provider family could not run. The + note is surfaced verbatim in the final report and ``final-state.json`` so + downstream verdict consumers know reviewer/fixer independence was relaxed + and which family was missing. + """ + role = (unavailable_reviewer or "").strip() or "primary reviewer" + return f"degraded ({role} unavailable)" + + +def _has_eligible_independent_fixer_fallback( + state: "ReviewLoopState", + config: "ReviewLoopConfig", + primary_fixer: str, + reviewer: str, +) -> bool: + """True when a configured ``fixer_fallback`` resolves to an INDEPENDENT fixer. + + "Independent" mirrors the eligibility ``_maybe_run_fallback_fixer`` enforces: + a normalizable ``fixer_fallback`` role distinct from the primary fixer, the + current/active reviewer, and the originally configured reviewer. The #1941 + auto-degrade consults this so it prefers a genuine cross-family fallback + fixer over collapsing to a same-family session — it degrades ONLY when no + such independent fixer remains. Never raises. + """ + fallback_role = getattr(config, "fixer_fallback", None) + if not fallback_role: + return False + normalized_fallback = _normalize_reviewers([fallback_role]) + if not normalized_fallback: + return False + canonical = normalized_fallback[0] + excluded: set = set() + for role in (primary_fixer, reviewer, state.active_reviewer, state.original_reviewer): + if role: + norm = _normalize_reviewers([role]) + excluded.add(norm[0] if norm else role) + return canonical not in excluded + + +@provider_failure_workflow def run_checkup_review_loop( *, context: ReviewLoopContext, @@ -1275,9 +1355,49 @@ def run_checkup_review_loop( f"{fallback_review.status}." ) break + # Capture the just-failed primary reviewer BEFORE the + # promotion below rebinds ``reviewer`` so the #1941 + # degradation note names the family that actually went down. + failed_primary_reviewer = reviewer review = fallback_review reviewer = fallback state.active_reviewer = fallback + # Issue #1941: AUTO-DEGRADE after the EXPLICIT + # ``reviewer_fallback`` promotion too — not just inside the + # opt-in ``_maybe_run_fallback_reviewer`` else-arm below. + # This is the accepted/live deadlock shape (reviewer=codex, + # fixer=codex, reviewer_fallback=claude): the primary + # reviewer's family (codex) hard-failed, the configured + # fixer IS that same dead family, and the fallback reviewer + # (claude) just produced actionable findings. Falling + # straight through to the fix round would target the dead + # configured fixer and re-deadlock with "findings remain" + # Promote the surviving fallback family to a fresh SAME-family + # fixer session, stamp the weaker guarantee, and let the normal + # fix + fresh-verify path run. Automatic + disclosed for every + # consumer — no ``--fallback-reviewer-on-failure`` opt-in + # required. Narrow trigger: only when the configured fixer IS + # the identity that just demonstrably failed AND no eligible + # INDEPENDENT ``fixer_fallback`` remains — so a genuine + # cross-family fallback fixer is preferred over a same-family + # session (``_maybe_run_fallback_fixer`` will run it after the + # dead configured fixer fails), and role independence is only + # relaxed when there is truly no independent fixer left. + if ( + not config.review_only + and fixer + and fixer == failed_primary_reviewer + and fallback != fixer + and _actionable_findings(state, fallback_review.findings) + and not _has_eligible_independent_fixer_fallback( + state, config, primary_fixer=fixer, reviewer=fallback + ) + ): + state.active_fixer = fallback + state.same_role_review_fix = True + state.role_independence = _degraded_role_independence_note( + failed_primary_reviewer + ) else: fallback_result = None if not fallback_used: @@ -1347,6 +1467,33 @@ def run_checkup_review_loop( ) break if fallback_result.status == "findings": + # Issue #1941: AUTO-DEGRADE instead of dead-locking. + # The fallback reviewer IS the fixer's own role, + # promoted here only because the primary reviewer's + # family is unavailable. Terminating now would strand + # concrete, actionable findings with no fix attempt + # even though a fresh same-family fixer session can + # execute them and be re-reviewed — strictly better + # than a guaranteed deadlock. Hand the findings to + # the fixer on the next iteration (mirrors the + # fallback-clean gate-findings handoff just above), + # stamp the weaker guarantee, and let the normal + # fix + fresh-verify path run. The superseded primary + # already renders ``(optional, superseded by + # )`` because ``state.active_reviewer`` + # points at the fallback, so the cloud verdict + # adapter drops it from the required-reviewer set. + degraded_findings = _actionable_findings( + state, fallback_result.findings + ) + if degraded_findings: + state.same_role_review_fix = True + state.role_independence = ( + _degraded_role_independence_note(reviewer) + ) + reviewer = fallback_result.reviewer + pending_findings = degraded_findings + continue state.stop_reason = ( f"Primary reviewer {reviewer} unavailable " f"({review.status}); secondary reviewer {fixer} " @@ -1567,13 +1714,26 @@ def run_checkup_review_loop( # is the fallback once it has taken over from a prior # round, so a later-round failure should attribute # to it instead of the long-exhausted original. + # + # Issue #1941: when role-independence was auto-degraded + # (the fixer is running same-family because the other + # family was down), name that vacancy explicitly so the + # terminal reason is never a bare "could not address" that + # hides why no independent fixer was available. + degrade_note = ( + f" [role-independence {state.role_independence}]" + if state.role_independence != "independent" + else "" + ) state.stop_reason = ( f"Fixer {active_fixer} could not address {reviewer}'s findings" + ( - f" (fallback fixer {config.fixer_fallback} also failed)." + f" (fallback fixer {config.fixer_fallback} also failed)" if fallback_fix is not None - else "." + else "" ) + + degrade_note + + "." ) break # Fallback succeeded — it now drives the rest of this round. @@ -6159,6 +6319,100 @@ def _git_changed_files(worktree: Path) -> List[str]: return list(iter_changed_paths(parse_porcelain_z(result.stdout))) +def _pddrc_default_prompts_dir(worktree: Path) -> Optional[Path]: + """Return the ``.pddrc`` default-context ``prompts_dir`` for ``worktree``. + + Reuses the SAME ``.pddrc`` discovery/parse helpers the generate/sync paths + use (``construct_paths._find_pddrc_file`` / ``_load_pddrc_config``) so there + is a single source of truth for prompt-root config — checkup never forks its + own ``.pddrc`` reader. Returns a path relative to ``worktree`` (or absolute + made relative when it resolves inside the tree), or ``None`` when the key is + unset / the file is absent / anything fails to parse. Fail-soft by design: + the caller falls back to the layout defaults so a temporarily-broken + ``.pddrc`` never blocks a checkup round. + """ + try: + from .construct_paths import _find_pddrc_file, _load_pddrc_config + except Exception: # noqa: BLE001 — never let an import hiccup block checkup + return None + try: + pddrc_path = _find_pddrc_file(worktree) + if pddrc_path is None: + return None + config = _load_pddrc_config(pddrc_path) + except Exception: # noqa: BLE001 — fail-soft to layout defaults + return None + if not isinstance(config, dict): + return None + contexts = config.get("contexts") + if not isinstance(contexts, dict): + return None + default_ctx = contexts.get("default") + if not isinstance(default_ctx, dict): + return None + defaults = default_ctx.get("defaults") + if not isinstance(defaults, dict): + return None + raw = defaults.get("prompts_dir") + if not isinstance(raw, str) or not raw.strip(): + return None + prompts_dir = Path(raw.strip()) + if prompts_dir.is_absolute(): + try: + return prompts_dir.resolve().relative_to(worktree.resolve()) + except (OSError, ValueError): + return None + return prompts_dir + + +def _resolve_target_prompts_root(worktree: Path) -> Path: + """Resolve the repo-under-repair's base prompts root, relative to ``worktree``. + + ``architecture.json`` stores each module's prompt ``filename`` relative to + the repo's top-level prompts root. pdd is self-hosted, so in pdd's OWN + checkout that root is ``pdd/prompts`` (with a ``prompts -> pdd/prompts`` + convenience symlink) — but on every OTHER repo it is the ``.pddrc``- + configured ``prompts_dir`` (the same one generate/sync resolve), which + defaults to ``prompts``. Hardcoding ``pdd/prompts`` made the repair loop + probe a nonexistent path on external repos and wrongly conclude the owning + prompt was deleted, refusing to repair on any repo that is not pdd itself + (issue #1957). + + Resolution order — first existing directory wins, with symlinks resolved to + the real, git-tracked path so pdd's ``prompts -> pdd/prompts`` symlink maps + to ``pdd/prompts`` (the path git actually reports for co-edit detection): + + 1. the ``.pddrc`` default-context ``prompts_dir`` (explicit config), + 2. ``prompts`` — the universal PDD default / external-repo layout, + 3. ``pdd/prompts`` — pdd self-hosted / vendored-pdd layout. + + Falls back to ``prompts`` when none exist so a genuinely-missing prompt is + reported against the target repo's canonical root, never pdd's. + """ + candidates: List[Path] = [] + configured = _pddrc_default_prompts_dir(worktree) + if configured is not None: + candidates.append(configured) + for default in (Path("prompts"), Path("pdd") / "prompts"): + if default not in candidates: + candidates.append(default) + + try: + worktree_resolved = worktree.resolve() + except OSError: + worktree_resolved = worktree + + for candidate in candidates: + abs_candidate = worktree / candidate + if not abs_candidate.is_dir(): + continue + try: + return abs_candidate.resolve().relative_to(worktree_resolved) + except (OSError, ValueError): + return candidate + return Path("prompts") + + def _load_prompt_source_map( worktree: Path, head_ref: str = "HEAD" ) -> Optional[Dict[str, str]]: @@ -6218,6 +6472,7 @@ def _load_prompt_source_map( ) return None + prompts_root = _resolve_target_prompts_root(worktree) mapping: Dict[str, str] = {} for entry in extract_modules(data): filepath = entry.get("filepath") @@ -6227,7 +6482,7 @@ def _load_prompt_source_map( if not filepath or not filename: continue mapping[Path(filepath).as_posix()] = ( - Path("pdd") / "prompts" / filename + prompts_root / filename ).as_posix() if not mapping: @@ -6702,7 +6957,9 @@ def _source_of_truth_stop_reason( ) -def _extract_arch_pairs(data: Any) -> Set[Tuple[str, str]]: +def _extract_arch_pairs( + data: Any, prompts_root: Path +) -> Set[Tuple[str, str]]: """Build the canonical ``(code_path, prompt_path)`` pair set from an ``architecture.json`` payload. @@ -6710,6 +6967,14 @@ def _extract_arch_pairs(data: Any) -> Set[Tuple[str, str]]: pre-change (HEAD) and post-change (worktree) registry shapes. Paths are normalized to POSIX so comparisons match the changed-file form returned by ``_git_changed_files``. + + ``prompts_root`` is the repo-under-repair's resolved base prompts root + (``_resolve_target_prompts_root``). The guard checks each prompt's presence + on disk, so this must reflect the TARGET repo's layout — not pdd's + self-hosted ``pdd/prompts`` tree — or every external-repo prompt reads as + missing and the guard fires spuriously (issue #1957). The caller resolves it + once and threads the same root into the HEAD and worktree extractions so the + two pair sets remain comparable. """ pairs: Set[Tuple[str, str]] = set() for entry in extract_modules(data): @@ -6722,7 +6987,7 @@ def _extract_arch_pairs(data: Any) -> Set[Tuple[str, str]]: pairs.add( ( Path(filepath).as_posix(), - (Path("pdd") / "prompts" / filename).as_posix(), + (prompts_root / filename).as_posix(), ) ) return pairs @@ -6873,7 +7138,8 @@ def _check_architecture_registry_edit_guard( ) return None - head_pairs = _extract_arch_pairs(head_data) + prompts_root = _resolve_target_prompts_root(worktree) + head_pairs = _extract_arch_pairs(head_data, prompts_root) if not head_pairs: logger.warning( "architecture-registry guard: architecture.json at HEAD in " @@ -6927,7 +7193,7 @@ def _check_architecture_registry_edit_guard( exc, ) else: - worktree_pairs = _extract_arch_pairs(worktree_data) + worktree_pairs = _extract_arch_pairs(worktree_data, prompts_root) added = worktree_pairs - head_pairs removed = head_pairs - worktree_pairs @@ -7399,6 +7665,10 @@ def _write_final_state( if state.same_role_review_fix else "independent-reviewer-fixer" ), + # Issue #1941: role-independence disclosure. ``"independent"`` unless + # the loop auto-degraded to a same-family review/fix session because + # the other provider family was unavailable. + "role_independence": state.role_independence, "source_of_truth": state.source_of_truth, # SHA-backed verification trust boundary (issue #1088). Always # present so downstream consumers can rely on the schema rather @@ -7894,6 +8164,7 @@ def _render_final_report( f"issue_aligned: {issue_aligned}", f"active-reviewer: {state.active_reviewer or 'unknown'}", f"same-role-review-fix: {str(state.same_role_review_fix).lower()}", + f"role-independence: {state.role_independence}", f"reviewer-status: {status_pairs}", f"fresh-final-review: {state.fresh_final_status}", f"verified-head-sha: {verified_sha_line}", diff --git a/pdd/ci_drift_heal.py b/pdd/ci_drift_heal.py index a87dd695d..d707e27ad 100644 --- a/pdd/ci_drift_heal.py +++ b/pdd/ci_drift_heal.py @@ -792,21 +792,28 @@ def _run_metadata_sync_safe( ok = bool(getattr(result, "ok", False)) if ok: try: - from pdd.operation_log import infer_module_identity, save_fingerprint + from pdd.operation_log import ( + infer_module_identity, + resolve_fingerprint_paths, + save_fingerprint, + ) from pdd.sync_determine_operation import read_fingerprint basename, language = infer_module_identity(str(p)) if basename is None or language is None: raise ValueError("could not determine module identity from prompt path") - # Issue #1211: build paths directly from the known subproject - # paths rather than calling get_pdd_file_paths (which resolves - # from CWD and would return parent-repo paths in parent-CWD mode). - # Example/test-file hash validation is skipped here since those - # keys are not present in this minimal paths dict; that gap is - # tracked at issue #870 alongside LLM-first tag refresh. + # Issue #1211: seed resolution with the known subproject paths. + # The shared resolver anchors discovery at the real prompt, keeps + # these explicit paths authoritative, and adds existing + # example/test files so their hashes are not erased (#1926). if code_p is None: raise ValueError("authoritative prompt/code paths unavailable: code_path not provided") - paths: Dict[str, Any] = {"prompt": p, "code": code_p} + paths: Dict[str, Any] = resolve_fingerprint_paths( + basename, + language, + p, + paths={"prompt": p, "code": code_p}, + ) # Preserve the previous user-facing command so the released # `sync_determine_operation._is_workflow_complete` (which only # accepts verify/test/fix/update as complete) keeps recognizing @@ -833,9 +840,12 @@ def _run_metadata_sync_safe( or not fingerprint.code_hash ): raise ValueError("fingerprint missing prompt/code hashes") - except Exception: + except Exception as exc: basename = str(prompt_path) - print(f"metadata finalization failed: fingerprint refresh failed for {basename}") + print( + "metadata finalization failed: fingerprint refresh failed for " + f"{basename}: {exc}" + ) return False meta_rel = f".pdd/meta/{_safe_basename(basename)}_{language}.json" print(f"metadata finalized for {basename}: {meta_rel}") diff --git a/pdd/ci_validation.py b/pdd/ci_validation.py index acd990442..dce28a8f1 100644 --- a/pdd/ci_validation.py +++ b/pdd/ci_validation.py @@ -6,7 +6,7 @@ import time import zipfile from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple from rich.console import Console @@ -1255,8 +1255,9 @@ def _commit_ci_fix( repo_owner: str, repo_name: str, issue_number: int, + allowed_files: Optional[Sequence[str]] = None, ) -> Tuple[bool, str]: - """Stage non-artifact changes, commit them, and push to the PR branch.""" + """Commit and push CI edits, optionally limited to workflow-owned files.""" from .agentic_e2e_fix_orchestrator import ( _get_modified_and_untracked, _has_unpushed_commits, @@ -1264,31 +1265,45 @@ def _commit_ci_fix( _push_with_retry, ) - files_to_commit = [ + dirty_files = [ path for path in sorted(_get_modified_and_untracked(cwd)) if not _is_intermediate_file(path) ] + if allowed_files is None: + files_to_commit = dirty_files + else: + allowed = set(allowed_files) + files_to_commit = [path for path in dirty_files if path in allowed] if not files_to_commit: - if _has_unpushed_commits(cwd): + if allowed_files is None and _has_unpushed_commits(cwd): push_ok, push_err = _push_with_retry(cwd, repo_owner, repo_name) if push_ok: return True, "Pushed existing CI fix commits" return False, f"Push failed: {push_err}" - return False, "CI fix reported changes, but no committable files were found" + return ( + False, + "CI fix reported changes, but no workflow-owned committable files were found", + ) for path in files_to_commit: stage = _run_command(["git", "add", "--", path], cwd) if stage.returncode != 0: return False, f"Failed to stage {path}: {stage.stderr.strip()}" - commit = _run_command( - ["git", "commit", "-m", f"ci: fix CI failures for #{issue_number}"], - cwd, - ) + commit_cmd = ["git", "commit"] + if allowed_files is not None: + # ``--only`` prevents unrelated pre-staged changes from entering this + # commit. Plain ``git commit`` would commit the entire index even though + # the validator and staging loop touched only workflow-owned files. + commit_cmd.append("--only") + commit_cmd.extend(["-m", f"ci: fix CI failures for #{issue_number}"]) + if allowed_files is not None: + commit_cmd.extend(["--", *files_to_commit]) + commit = _run_command(commit_cmd, cwd) if commit.returncode != 0: - if _has_unpushed_commits(cwd): + if allowed_files is None and _has_unpushed_commits(cwd): push_ok, push_err = _push_with_retry(cwd, repo_owner, repo_name) if push_ok: return True, "Pushed existing CI fix commits" @@ -1345,6 +1360,8 @@ def run_ci_validation_loop( timeout: float, quiet: bool, expected_head_sha_override: Optional[str] = None, + pre_commit_check: Optional[Callable[[List[str]], Optional[str]]] = None, + commit_files: Optional[Callable[[], Sequence[str]]] = None, ) -> Tuple[bool, str, float]: """Poll required PR checks and iterate on CI-only failures until they pass. @@ -1355,6 +1372,11 @@ def run_ci_validation_loop( SHA via this override makes the poll wait for the correct head and prevents the timeout from burning while ``cwd``'s stale HEAD is compared to the advanced remote. + + ``commit_files`` limits the CI commit to caller-owned paths. + ``pre_commit_check`` receives that exact path list after the repair agent + edits the worktree but before commit/push, and returns ``None`` when clean + or an actionable failure message when the candidate must be rejected. """ total_cost = 0.0 max_retries = max(0, int(max_retries)) @@ -1689,12 +1711,40 @@ def run_ci_validation_loop( ) return False, "CI fix task did not apply an actionable fix", total_cost - commit_success, commit_message = _commit_ci_fix( + allowed_files = list(commit_files()) if commit_files is not None else None + if pre_commit_check is not None: + check_files = allowed_files + if check_files is None: + from .agentic_e2e_fix_orchestrator import ( + _get_modified_and_untracked, + _is_intermediate_file, + ) + + check_files = [ + path + for path in sorted(_get_modified_and_untracked(cwd)) + if not _is_intermediate_file(path) + ] + try: + pre_commit_error = pre_commit_check(check_files) + except Exception as exc: # pylint: disable=broad-except + pre_commit_error = f"pre-commit validation raised: {exc}" + if pre_commit_error: + return ( + False, + f"CI fix failed pre-commit validation: {pre_commit_error}", + total_cost, + ) + + commit_kwargs: Dict[str, Any] = dict( cwd=cwd, repo_owner=repo_owner, repo_name=repo_name, issue_number=issue_number, ) + if allowed_files is not None: + commit_kwargs["allowed_files"] = allowed_files + commit_success, commit_message = _commit_ci_fix(**commit_kwargs) if not commit_success: return False, commit_message, total_cost diff --git a/pdd/cmd_test_main.py b/pdd/cmd_test_main.py index 29b2ac17c..db0367525 100644 --- a/pdd/cmd_test_main.py +++ b/pdd/cmd_test_main.py @@ -15,6 +15,13 @@ from .config_resolution import resolve_effective_config from .construct_paths import construct_paths +from .content_selector import ( + configured_test_output_pinned, + find_collocated_test, + record_pdd_created_test, + resolve_test_output_path, + was_test_adopted, +) from .core.cloud import CloudConfig, get_cloud_timeout, get_cloud_request_timeout from .generate_test import generate_test from .increase_tests import increase_tests @@ -124,6 +131,85 @@ def cmd_test_main( console.print(f"[bold red]Error constructing paths: {e}[/bold red]") return TestResult("", 0.0, f"Error: {e}", None, str(e)) + # 2b. Issue #1903: when PDD used its derived default test path, adopt an + # existing, runner-collected co-located test as the canonical output so both + # the agentic and native branches target the real test CI runs instead of a + # runner-blind `tests/` shadow. The test location is treated as user-pinned + # (never overridden) when the user set it explicitly — via CLI `--output`, the + # `PDD_TEST_OUTPUT_PATH` env var, or an explicit `.pddrc test_output_path` for + # this module's context. The pin is read from the RAW `.pddrc` defaults (via + # configured_test_output_pinned), never from construct_paths' resolved_config, + # which injects a generated-default test_output_path and would always read as + # pinned. The context is resolved the way construct_paths resolves it: an + # explicit `--context` wins, else it is detected from the PROMPT file (whose + # `prompts_dir` selects the context — the code file's path may not match the + # context's `paths`), anchoring the .pddrc lookup on the prompt side. Mutating + # output_file_paths['output'] keeps a single source of truth for downstream + # write/churn steps. + pin_target = prompt_file or code_file + user_pinned_test_path = ( + output is not None + or configured_test_output_pinned( + pin_target, + context_override=ctx.obj.get("context"), + search_from=Path(pin_target).parent if pin_target else None, + ) + ) + derived_output = output_file_paths.get("output") + # Issue #1903 §B.4 provenance: capture whether this test is an ADOPTED human + # co-located test (unpinned) at RESOLUTION time — before generation + # overwrites the file and makes greenfield-created vs human-adopted + # indistinguishable. Threaded into the churn gate so the never-block only + # relieves a genuine adopted-human test. + test_was_adopted_human = False + if derived_output: + adopted_output = str( + resolve_test_output_path( + code_file, derived_output, user_pinned=user_pinned_test_path + ) + ) + test_was_adopted_human = was_test_adopted( + code_file, adopted_output, derived_output, + user_pinned=user_pinned_test_path, + ) + # Issue #1903 §B.4 ownership: when PDD is GREENFIELD-creating this test + # (no pre-existing co-located sibling, resolved to a runner-collected + # non-derived path that does not yet exist), record PDD ownership so a + # LATER run never mistakes this PDD-owned file for a human-adopted one. + if ( + not user_pinned_test_path + and adopted_output != derived_output + and find_collocated_test(code_file) is None + and not Path(adopted_output).exists() + ): + record_pdd_created_test(adopted_output) + output_file_paths["output"] = adopted_output + # Issue #1903: the write/churn steps read `output` (adopted above), but the + # native/cloud generation reads its destination from the separate + # `output_file` key (the `test_file_path` derivation below). For `test` + # commands generate_output_paths returns only `output`, so `output_file` + # is absent and would fall back to a bare `test_output.py` — prompting the + # LLM with the wrong destination (broken relative imports) while the write + # lands at the adopted/real path. This also covers sync, which passes the + # already-adopted path in as an explicit `output` (so no retarget happens + # here). Default `output_file` to the actual write target; `setdefault` + # leaves any explicitly-provided `output_file` untouched. + output_file_paths.setdefault("output_file", output_file_paths["output"]) + + # 2c. Issue #1903: sync regenerates a test by MERGING into an already-existing + # test, passing it as an explicit `output` with merge=True. With force=False, + # construct_paths renames that existing path to a numbered sibling (e.g. + # `page.test_1.tsx`), so the agentic/native write would create a NEW shadow + # beside the real test while sync keeps verifying the original (now stale) one — + # re-introducing the #1903 false-green for the core jest/TSX case. When merging + # into a known existing test, the write target IS that test: pin `output` / + # `output_file` to it so generation and the write both land on the real file + # (churn-guarded), never a numbered shadow. + if merge and existing_tests: + merge_target = existing_tests[0] + output_file_paths["output"] = merge_target + output_file_paths["output_file"] = merge_target + # 3. Resolve effective configuration (strength, temperature, time) # Priority: Function Arg > CLI Context > Config File > Default eff_config = resolve_effective_config( @@ -203,6 +289,7 @@ def cmd_test_main( prompt_name=Path(prompt_file).name, output_path=str(output_test_path), prompt_content=prompt_content_for_churn, + adopted_human=test_was_adopted_human, ) except TestChurnError as churn_err: churn_err.total_cost = float(total_cost or 0.0) @@ -269,6 +356,7 @@ def cmd_test_main( "- Add new tests for the prompt change without removing " "accumulated regression tests." ), + adopted_human=test_was_adopted_human, ) if not agentic_success and existing_test_content: # Agent itself failed — restore the pre-existing test file @@ -596,6 +684,7 @@ def cmd_test_main( prompt_name=Path(prompt_file).name, output_path=str(final_output_path), prompt_content=input_strings.get("prompt_file", ""), + adopted_human=test_was_adopted_human, ) with open(str(final_output_path), write_mode, encoding="utf-8") as f: diff --git a/pdd/code_generator_main.py b/pdd/code_generator_main.py index f4e22ac2d..3a392673c 100644 --- a/pdd/code_generator_main.py +++ b/pdd/code_generator_main.py @@ -49,6 +49,7 @@ annotations_compatible, build_module_default_symbols, compare_default_sources, + parse_callable_contract, signature_entries_compatible, ) @@ -176,6 +177,7 @@ def __init__( total_cost: float = 0.0, model_name: str = "unknown", repair_directive: Optional[str] = None, + signature_details: Optional[List[Tuple[str, str, str, str]]] = None, ) -> None: self.prompt_name = prompt_name self.output_path = output_path or "" @@ -185,16 +187,42 @@ def __init__( self.post_surface_size = int(post_surface_size) self.total_cost = float(total_cost or 0.0) self.model_name = model_name or "unknown" + # Structured per-symbol detail for signature mismatches (issue #1900): + # ``(symbol, expected_entry, actual_entry, source)`` where ``source`` is + # ``"pdd-interface"`` when the expected signature came from the prompt's + # declaration. Purely additive — the ``removed:`` / ``signature_changed:`` + # message lines below stay byte-for-byte identical (the cloud parser and + # ~50 tests key on them). + self.signature_details = list(signature_details or []) self._repair_directive_override = repair_directive output_display = self.output_path or "" - super().__init__( - f"Public surface regression for {prompt_name}:\n" - f"removed: {', '.join(self.removed_symbols) if self.removed_symbols else ''}\n" - f"signature_changed: {', '.join(self.changed_signatures) if self.changed_signatures else ''}\n" - f"output: {output_display}\n" - f"pre_surface_size: {self.pre_surface_size}\n" - f"post_surface_size: {self.post_surface_size}" - ) + message_lines = [ + f"Public surface regression for {prompt_name}:", + f"removed: {', '.join(self.removed_symbols) if self.removed_symbols else ''}", + f"signature_changed: {', '.join(self.changed_signatures) if self.changed_signatures else ''}", + f"output: {output_display}", + f"pre_surface_size: {self.pre_surface_size}", + f"post_surface_size: {self.post_surface_size}", + ] + # Append (never alter the lines above) one structured detail line per + # signature mismatch so the full expected-vs-actual contract is carried + # in the message the local + cloud repair loops read. JSON-encoded so a + # signature/default containing the old ` | ` field delimiters can't corrupt + # parsing (codex round-8 finding 2); parsed by + # ``agentic_sync_runner._parse_signature_detail_lines``. + for symbol, expected_entry, actual_entry, source in self.signature_details: + message_lines.append( + "signature_detail: " + + json.dumps( + { + "symbol": symbol, + "expected": expected_entry, + "actual": actual_entry, + "source": source, + } + ) + ) + super().__init__("\n".join(message_lines)) @property def repair_directive(self) -> str: @@ -205,9 +233,54 @@ def repair_directive(self) -> str: lines.append("Restore these public symbols from the existing module:") for sym in self.removed_symbols: lines.append(f"- {sym}") - if self.changed_signatures: + # Prefer the DECLARED signature as the repair target when the prompt's + # is the source of truth: it is a stable target, unlike + # "restore compatible signatures" (compatible with the very code being + # regenerated) which dead-ended the change->sync loop (issue #1900). + declared_details = [ + detail for detail in self.signature_details if detail[3] == "pdd-interface" + ] + if declared_details: + # Inject the DECLARED signature as a VERBATIM hard constraint, not + # just a description of the violation (issue #1968): an annotation- + # level drift (declared `object`, regenerated `Any`; or a broadened + # param union) converges only when the retry is told to reproduce the + # declared annotation text token-for-token instead of a semantically + # "equivalent" spelling it keeps re-emitting. + lines.append( + "Restore these public symbols to their declared " + " signatures — emit each declared signature " + "VERBATIM. Reproduce the declared annotation text token-for-" + "token; do not substitute an equivalent-but-differently-spelled " + "type (keep `object` as `object`; never emit `Any` where the " + "declaration says `object`) and do not broaden a declared " + "parameter's type with `|` union members the declaration omits:" + ) + for symbol, expected_entry, actual_entry, _ in declared_details: + lines.append( + f"- Restore `{symbol}` to its declared signature " + f"`{expected_entry}` (found `{actual_entry}`). Emit exactly " + f"`{expected_entry}` — the prior attempt emitted " + f"`{actual_entry}`, which differs only in annotation " + f"spelling and was rejected." + ) + # Declaration-aware guidance (codex round-7 finding 3): a declared + # PARAM change is authorized by EDITING the declaration, not by a + # BREAKING-CHANGE marker (which only relaxes the un-declarable + # binding-kind/async for declared symbols) — advising the marker here + # would loop the user back into the dead-end #1900 removes. + lines.append( + "If a declared parameter change is intended, edit the prompt's " + " declaration to the intended signature (the " + "declaration is the contract for declared symbols)." + ) + declared_changed = {detail[0] for detail in declared_details} + remaining_changed = [ + sym for sym in self.changed_signatures if sym not in declared_changed + ] + if remaining_changed: lines.append("Restore compatible signatures for these public symbols:") - for sym in self.changed_signatures: + for sym in remaining_changed: lines.append(f"- {sym}") lines.append( "Preserve backward-compatible public helpers unless the prompt lists " @@ -216,6 +289,49 @@ def repair_directive(self) -> str: return "\n".join(lines) +_CHURN_NONCE_ENV = "PDD_CHURN_NONCE_FD" +_CHURN_NONCE_CACHE: Optional[str] = None +_CHURN_NONCE_READ = False + + +def _read_churn_nonce() -> str: + """Read the one-time provenance nonce the PARENT sync runner handed this child + over a NON-inherited pipe FD (issue #1903 §B.4 review round 8). + + The parent passes the read-end FD number via ``PDD_CHURN_NONCE_FD`` and keeps + the FD out of any grandchild test subprocess (``close_fds`` default), so only + THIS trusted child process can read the nonce. Stamping it into the churn + block lets the parent distinguish a genuine PDD-emitted block from one a + hostile project test merely printed to stdout (which cannot know the nonce). + Read once (the pipe yields EOF afterwards) and cached. Returns ``""`` when no + channel is present (standalone ``pdd test``/``sync`` — which never + never-blocks — or an older parent). Total: any error yields ``""``. + """ + global _CHURN_NONCE_CACHE, _CHURN_NONCE_READ # pylint: disable=global-statement + if _CHURN_NONCE_READ: + return _CHURN_NONCE_CACHE or "" + _CHURN_NONCE_READ = True + fd_s = os.environ.get(_CHURN_NONCE_ENV) + if not fd_s: + _CHURN_NONCE_CACHE = "" + return "" + try: + fd = int(fd_s) + chunks = [] + while len(b"".join(chunks)) < 256: + data = os.read(fd, 256) + if not data: + break + chunks.append(data) + token = b"".join(chunks).decode("ascii", "ignore").strip() + # Accept only a plausible hex nonce so a coincidental FD-number collision + # in some process cannot inject arbitrary bytes as a "valid" nonce. + _CHURN_NONCE_CACHE = token if re.fullmatch(r"[0-9a-f]{8,128}", token) else "" + except (OSError, ValueError): + _CHURN_NONCE_CACHE = "" + return _CHURN_NONCE_CACHE or "" + + class TestChurnError(click.UsageError): """Raised when generation rewrites too much of an existing test file.""" @@ -230,6 +346,7 @@ def __init__( total_cost: float = 0.0, model_name: str = "unknown", repair_directive: Optional[str] = None, + adopted_human: bool = False, ) -> None: self.prompt_name = prompt_name self.output_path = output_path or "" @@ -239,15 +356,28 @@ def __init__( self.post_line_count = int(post_line_count) self.total_cost = float(total_cost or 0.0) self.model_name = model_name or "unknown" + # Issue #1903 §B.4 provenance: True only when this test was ADOPTED from + # an existing HUMAN co-located test (unpinned), determined at path + # resolution BEFORE generation. The issue-driven never-block requires it; + # a False value keeps the strict hard-fail. Serialized into the block so + # the (subprocess-boundary) parent runner can read it. + self.adopted_human = bool(adopted_human) self._repair_directive_override = repair_directive output_display = self.output_path or "" + # Stamp the parent-issued nonce (round 8) so the parent can authenticate + # THIS block as genuinely PDD-emitted; a forged block printed by a hostile + # project test cannot carry the correct (secret) nonce and is refused. + nonce = _read_churn_nonce() + nonce_line = f"\nnonce: {nonce}" if nonce else "" super().__init__( f"Test churn threshold exceeded for {prompt_name}:\n" f"ratio: {self.churn_ratio:.2f}\n" f"threshold: {self.threshold:.2f}\n" f"output: {output_display}\n" f"pre_line_count: {self.pre_line_count}\n" - f"post_line_count: {self.post_line_count}" + f"post_line_count: {self.post_line_count}\n" + f"adopted: {str(self.adopted_human).lower()}" + f"{nonce_line}" ) @property @@ -580,10 +710,14 @@ def _is_test_output_path(output_path: Optional[str]) -> bool: ".test.tsx", ".test.js", ".test.jsx", + ".test.mjs", + ".test.cjs", ".spec.ts", ".spec.tsx", ".spec.js", ".spec.jsx", + ".spec.mjs", + ".spec.cjs", ) # `_test.` / `_spec.` patterns for files that # live next to production code rather than under `tests/`. Go's @@ -631,7 +765,7 @@ def _is_test_output_path(output_path: Optional[str]) -> bool: name.startswith("test_") or lower_name.endswith(js_like_test_suffixes) or name.endswith(pascal_test_suffixes) - or any(part in {"tests", "__tests__"} for part in path.parts) + or any(part in {"tests", "__tests__", "__test__"} for part in path.parts) ) @@ -916,27 +1050,30 @@ def _symbol_exists_in_module(tree: ast.Module, symbol: str) -> bool: if node.value is not None and _assign_target_matches(node.target, symbol): return True return False - cls_name, remainder = symbol.split(".", 1) - cls_node = next( - (node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == cls_name), - None, - ) - if cls_node is None: - return False - if "." not in remainder: - return any( - isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name == remainder - for child in cls_node.body + # Dotted: walk the leading segments as nested ``ClassDef`` containers, then + # match the final segment as a method OR a (nested) class. Resolving the final + # segment as a class too — not method-only — means a declared NESTED class + # constructor whose path includes an underscore (``_Outer.Inner`` / + # ``Outer._Inner``) is recognized as defined and captured via the patch-target + # path, mirroring how the public recursion already keys nested classes as + # ``Outer.Inner`` (codex round-9). This only ever recognizes MORE names as + # defined, so no previously-matched name stops matching. + parts = symbol.split(".") + body: List[ast.stmt] = list(tree.body) + for part in parts[:-1]: + cls_node = next( + (node for node in body if isinstance(node, ast.ClassDef) and node.name == part), + None, ) - inner_cls, method_name = remainder.split(".", 1) - for child in cls_node.body: - if isinstance(child, ast.ClassDef) and child.name == inner_cls: - return any( - isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)) - and method.name == method_name - for method in child.body - ) - return False + if cls_node is None: + return False + body = list(cls_node.body) + last = parts[-1] + return any( + isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and child.name == last + for child in body + ) def _effective_patch_targets( @@ -1793,6 +1930,67 @@ def _synthesize_dataclass_init_signature( return f"({', '.join(parts)})" +def _resolve_class_node( + tree: ast.Module, symbol: str +) -> Optional[ast.ClassDef]: + """Resolve a (possibly dotted / nested) class path to its ``ClassDef`` node. + + Walks each dotted segment through nested class bodies (``Outer.Inner`` -> + the ``Inner`` node inside ``Outer``). Returns ``None`` when any segment is not + a class — so a dotted method path (``Outer.method``) resolves to ``None`` and + the caller falls back to method-signature capture. Used to give a patch-target + nested class its ``[class]`` constructor-ABI entry (codex round-9). + """ + body: List[ast.stmt] = list(tree.body) + node: Optional[ast.ClassDef] = None + for part in symbol.split("."): + node = next( + (child for child in body if isinstance(child, ast.ClassDef) and child.name == part), + None, + ) + if node is None: + return None + body = list(node.body) + return node + + +def _class_constructor_signature( + class_node: ast.ClassDef, + class_defs: Dict[str, ast.ClassDef], + imported_names: Set[str], +) -> str: + """Return a class's constructor-ABI signature string (no ``[class]`` prefix). + + Mirrors an explicit receiver-stripped ``__init__``, a synthesised stdlib + ``@dataclass`` init, or the bare ``()`` fallback — the exact logic + :func:`_snapshot_public_signatures` uses for the ``[class]`` entry, extracted + so a patch-target class (e.g. a declared underscore class whose constructor is + the contract) can reuse it (codex round-8 finding 3). + """ + explicit_init: Optional[ast.AST] = None + for child in class_node.body: + if ( + isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) + and child.name == "__init__" + ): + explicit_init = child + break + if explicit_init is not None: + return _format_python_signature(explicit_init, skip_first=True) + dataclass_decorators = [ + dec for dec in class_node.decorator_list if _is_dataclass_decorator(dec) + ] + is_dataclass = bool(dataclass_decorators) + init_synthesized = all( + _dataclass_decorator_synthesizes_init(dec) for dec in dataclass_decorators + ) + if is_dataclass and init_synthesized: + return _synthesize_dataclass_init_signature( + class_node, class_defs=class_defs, imported_names=imported_names + ) + return "()" + + def _patch_target_signature_entry( tree: ast.Module, symbol: str, @@ -1932,54 +2130,13 @@ def _walk_class( # function/async-function/class kind tagging so a replacement # that swaps the class for a function with a matching constructor # signature is still flagged. - explicit_init: Optional[ast.AST] = None - for child in class_node.body: - if ( - isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) - and child.name == "__init__" - ): - explicit_init = child - break - if explicit_init is not None: - class_signature = _format_python_signature( - explicit_init, skip_first=True - ) - else: - # No explicit ``__init__`` — but if the class is a stdlib - # ``@dataclass``, the synthesised init mirrors the field - # annotations. Build it from the ``AnnAssign`` field list so - # that adding/removing/reordering required fields shows up as - # a snapshot diff (reviewer reproducer for PR #1015). Falls - # back to the previous bare ``()`` for non-dataclass classes - # and for unsupported decorator forms (``@attr.s``, - # ``@pydantic.dataclasses.dataclass``, etc). - dataclass_decorators = [ - dec - for dec in class_node.decorator_list - if _is_dataclass_decorator(dec) - ] - is_dataclass = bool(dataclass_decorators) - # ``@dataclass(init=False)`` keeps the class's natural - # ``__init__`` (typically ``object.__init__`` — no fields - # injected). When ANY dataclass decorator opts out, do NOT - # synthesise from the class body: adding fields under - # ``init=False`` does not change the runtime constructor and - # must not falsely trip the gate; flipping ``init=False`` → - # default DOES change the constructor and SHOULD trip it - # (the ``()`` snapshot here vs the synth signature on the - # default branch). - init_synthesized = all( - _dataclass_decorator_synthesizes_init(dec) - for dec in dataclass_decorators - ) - if is_dataclass and init_synthesized: - class_signature = _synthesize_dataclass_init_signature( - class_node, - class_defs=class_defs, - imported_names=imported_names, - ) - else: - class_signature = "()" + # Record the class with its constructor-ABI signature (explicit + # receiver-stripped ``__init__`` / synthesised stdlib ``@dataclass`` init / + # bare ``()``). Shared with the patch-target class path via + # :func:`_class_constructor_signature`. + class_signature = _class_constructor_signature( + class_node, class_defs, imported_names + ) signatures[qualname] = f"[class] {class_signature}" # First pass: accumulate property accessor roles per name so a @@ -2218,6 +2375,20 @@ def _record_assignment_target(target: ast.AST) -> None: for symbol in sorted(patch_targets): if symbol in signatures: continue + # A patch-target that is a CLASS — top-level OR a nested + # ``Outer.Inner`` (incl. underscore paths) — gets its ``[class]`` + # constructor-ABI entry, so a declared (possibly underscore) class or + # ``Class.__init__`` / ``Outer.Inner.__init__`` is validated like a + # public class rather than skipped for lack of a signature entry (codex + # round-8 finding 3 + round-9). ``_patch_target_signature_entry`` only + # builds function/method entries. + class_node = _resolve_class_node(tree, symbol) + if class_node is not None: + class_signature = _class_constructor_signature( + class_node, class_defs, imported_names + ) + signatures[symbol] = f"[class] {class_signature}" + continue entry = _patch_target_signature_entry(tree, symbol, class_defs) if entry is not None: signatures[symbol] = entry @@ -2234,6 +2405,182 @@ def _prompt_allows_breaking_change(prompt_content: Optional[str]) -> bool: return _prompt_has_breaking_change_marker(prompt_content) +def _collect_declared_surface( + prompt_content: Optional[str], + prompt_name: str, +) -> Dict[str, Optional[str]]: + """Collect declared public symbols and raw signatures from ````. + + Returns ``{name -> raw_signature_or_None}`` for every function the prompt's + ``type: "module"`` ```` declares (``module.functions``). + Unlike :func:`_extract_pdd_interface_signatures`, a missing or non-paren + signature is KEPT (mapped to ``None``) so the surface gate can enforce + presence-only for description-only declarations (issue #1900). + + Scoped to ``type: "module"`` ONLY. ``type: "cli"`` / ``type: "command"`` + interfaces declare COMMAND strings (e.g. ``"sync-architecture"``, + ``"pdd extracts prune"`` — hyphens/spaces, not valid Python identifiers), not + module symbols; feeding those to the surface gate produced phantom + ``removed:`` diffs on valid generated code (codex review finding 1). CLI/ + command signature conformance stays owned by the conformance gate + (:func:`_extract_pdd_interface_signatures` still covers them). + + Returns ``{}`` when there is no prompt, no ``type: "module"`` + ```` block, or the JSON is malformed. The conformance gate owns + the parse-error warning, so this stays silent to avoid a duplicate warning. + """ + declared: Dict[str, Optional[str]] = {} + if not prompt_content: + return declared + tags = parse_prompt_tags(prompt_content) + if tags.get("interface_parse_error"): + return declared + interface = tags.get("interface") + if not isinstance(interface, dict) or interface.get("type") != "module": + return declared + + module_spec = interface.get("module") or {} + for item in module_spec.get("functions") or []: + if not isinstance(item, dict): + continue + name = item.get("name") + if not name or not isinstance(name, str): + continue + sig = item.get("signature") + declared[name] = sig if isinstance(sig, str) else None + return declared + + +def _declared_signature_to_entry( + raw_signature: Optional[str], + binding_kind: str, + *, + is_async: bool = False, + strip_receiver: bool = False, +) -> Optional[str]: + """Normalize a declared ```` signature into a snapshot entry. + + Produces an entry shaped like :func:`_snapshot_public_signatures` values + (``[] (params) -> ret``) so :func:`signature_entries_compatible` can + compare the DECLARED contract against the generated one. The binding kind and + async marker are copied from the GENERATED symbol (``binding_kind`` / + ``is_async``) because a ```` signature cannot express + ``self`` / property / ``async``; matching them by construction means only the + parameter list and return annotation are actually compared, and no + binding-kind drift is ever invented from the declaration. + + ``strip_receiver=True`` (used for receiver-bound methods and constructors: + ``instance`` / ``classmethod`` / ``class`` kinds) drops a leading ``self`` / + ``cls`` positional so the declared ``(self, x)`` matches the snapshot's + receiver-stripped ``(x)``. An already-stripped ``(x)`` is left unchanged. + + Returns ``None`` (presence-only: the symbol must exist but its signature is + not checked) when the declared signature is not a parseable paren-list — a + missing signature, ``None``, a class header (``class Foo(Base)``), + ``dataclass ...``, ``...`` — or when the composed entry does not parse as a + callable contract. + """ + if not raw_signature or not isinstance(raw_signature, str): + return None + sig = raw_signature.strip() + # Strip a leading ``async`` and/or ``def `` prefix so a declaration + # written as ``async def foo(x)`` or ``def foo(x)`` reduces to the bare + # parameter list. The entry's async-ness is taken from the generated symbol, + # not the declaration, so any declared ``async`` is dropped here. + if sig.startswith("async "): + sig = sig[len("async "):].strip() + def_match = re.match(r"^def\s+[A-Za-z_]\w*\s*(.*)$", sig, re.DOTALL) + if def_match: + sig = def_match.group(1).strip() + if sig.startswith("async "): + sig = sig[len("async "):].strip() + if not sig.startswith("("): + return None + if strip_receiver: + # Mirror the snapshot's receiver stripping: parse the declared paren + # signature and drop a leading ``self``/``cls`` positional so a declared + # ``(self, x)`` compares against the snapshot's ``(x)``. A signature that + # does not parse or whose first positional is not a receiver is left + # unchanged (an already receiver-free ``(x)`` stays ``(x)``). + try: + parsed = ast.parse(f"def _pdd{sig}: pass").body[0] + except SyntaxError: + return None + if isinstance(parsed, (ast.FunctionDef, ast.AsyncFunctionDef)): + positional = list(parsed.args.posonlyargs) + list(parsed.args.args) + if positional and positional[0].arg in {"self", "cls"}: + sig = _format_python_signature(parsed, skip_first=True) + async_prefix = "async " if is_async else "" + entry = f"[{binding_kind}] {async_prefix}{sig}" + if parse_callable_contract(entry) is None: + return None + return entry + + +def _entry_binding_context(entry: Optional[str]) -> Optional[Tuple[str, bool]]: + """Return ``(binding_kind, is_async)`` parsed off a snapshot entry. + + Reads the ``[]`` prefix and a leading ``async `` from a + :func:`_snapshot_public_signatures` value (e.g. ``[async_function] async + (x)`` -> ``("async_function", True)``). Returns ``None`` when the entry has + no binding-kind prefix. + """ + if not entry: + return None + match = re.match(r"^\[([^\]]+)\]\s*(.*)$", entry.strip()) + if match is None: + return None + return match.group(1), match.group(2).lstrip().startswith("async ") + + +def _declared_presence_name(name: str) -> str: + """Map a declared symbol to the surface name whose presence satisfies it. + + A constructor's ABI is keyed on the CLASS symbol (``Foo``), never + ``Foo.__init__`` (see :func:`_snapshot_public_surface`), so a prompt that + declares ``Foo.__init__`` is present as long as ``Foo`` is — flagging it as + a removed symbol on valid code was a false positive (codex review finding 2). + Every other declared name maps to itself. + """ + if name.endswith(".__init__"): + return name[: -len(".__init__")] + return name + + +def _declared_patch_targets( + code: Optional[str], + declared_names: Set[str], + language: Optional[str], +) -> Set[str]: + """Return the declared symbols DEFINED in *code*, as snapshot surface keys. + + The prompt's ```` declaration is authoritative (like ``__all__``), + so a declared ``_``-prefixed helper (real: ``_extract_step_report``) must be + captured in the public-surface / signature snapshots even though the default + heuristic filters underscore names out (codex round-7 finding 1). These are + fed as ``patch_targets`` (which force a defined name into the snapshot), so only + names actually DEFINED in *code* are returned — a genuinely removed declared + symbol stays out of the ``after`` snapshot and is still reported as removed. + + Each declared name is mapped through :func:`_declared_presence_name` (so a + declared ``Class.__init__`` contributes the CLASS key, matching where the + snapshot puts the constructor ABI, and never injects a phantom ``Class.__init__`` + signature entry that would bypass the declared-vs-old-code routing). + """ + if not declared_names or (language or "").lower() not in {"python", "py"}: + return set() + try: + tree = ast.parse(code or "") + except SyntaxError: + return set() + targets: Set[str] = set() + for name in declared_names: + presence = _declared_presence_name(name) + if _symbol_exists_in_module(tree, presence): + targets.add(presence) + return targets + + def _verify_public_surface_regression( existing_code: Optional[str], generated_code: str, @@ -2253,7 +2600,21 @@ def _verify_public_surface_regression( ): return - patch_targets = _effective_patch_targets(existing_code, language or "python", output_path) + # The prompt's ```` declaration is the stable surface contract + # (issue #1900), and it is authoritative like ``__all__``. Collect it BEFORE + # the snapshots so declared symbols — including ``_``-prefixed helpers the + # default heuristic would filter out (codex round-7 finding 1) — are forced + # into the surface/signature snapshots via ``patch_targets`` (only when + # actually DEFINED in the respective code, so a genuinely removed declared + # symbol still diffs as removed). Empty when there is no parseable + # ```` (also on a JSON parse error — the conformance gate owns + # that warning), so undeclared modules behave exactly as before. + declared = _collect_declared_surface(prompt_content, prompt_name) + declared_names = set(declared) + + patch_targets = _effective_patch_targets( + existing_code, language or "python", output_path + ) | _declared_patch_targets(existing_code, declared_names, language or "python") before = _snapshot_public_surface( existing_code, language or "python", @@ -2291,7 +2652,7 @@ def _verify_public_surface_regression( ) from syntax_err after_patch_targets = _effective_patch_targets( generated_code, language or "python", output_path - ) + ) | _declared_patch_targets(generated_code, declared_names, language or "python") after = _snapshot_public_surface( generated_code, language or "python", @@ -2312,11 +2673,35 @@ def _verify_public_surface_regression( for sym in before: if sym.startswith(prefix): expanded_allowed.add(sym) - removed = [ + # Per-symbol hybrid (issue #1900): DECLARED symbols are validated against the + # declaration (a stable target) so a legit ``pdd change`` that also drifts an + # unrelated declared signature no longer dead-ends the change->sync loop; + # UNDECLARED symbols keep the old-code baseline. ``declared`` was collected + # above (it also seeds ``patch_targets``). + + # Removal, per-symbol. UNDECLARED: a public name dropped between before and + # after regresses unless BREAKING-CHANGE opts it out (today's behavior). + # DECLARED: a still-declared symbol absent from the generated surface + # regresses regardless — the declaration is authoritative, so a + # BREAKING-CHANGE: remove does NOT excuse it, and its absence counts even if + # it was never in ``before``. + undeclared_removed = [ symbol for symbol in _diff_public_surface(before, after) - if symbol not in expanded_allowed + if symbol not in declared_names and symbol not in expanded_allowed + ] + # ``Foo.__init__`` is present when the ``Foo`` class symbol is (the + # constructor ABI is keyed on the class, not ``Class.__init__``), so map each + # declared name through its presence-name before the surface membership test + # (codex review finding 2). The presence-name is also what gets reported when + # a declared symbol is genuinely missing. + declared_missing = [ + presence + for symbol in declared_names + for presence in (_declared_presence_name(symbol),) + if presence not in after ] + removed = sorted(set(undeclared_removed) | set(declared_missing)) before_signatures = _snapshot_public_signatures( existing_code, language or "python", @@ -2329,17 +2714,162 @@ def _verify_public_surface_regression( ) # Per-side module default-symbol tables (issue #1558): a parameter default # written as a same-module constant (``max_chars=_LIMIT`` where - # ``_LIMIT = 25000``) resolves to the literal it stands for. Each side is - # resolved against its OWN module — the existing code for the ``before`` - # signature and the generated code for the ``after`` — so a literal <-> - # same-module-constant refactor of a default is not flagged as a regression, - # while the same constant name resolving to a different value across the two - # versions still is. + # ``_LIMIT = 25000``) resolves to the literal it stands for. For the + # UNDECLARED old-vs-new comparison each side is resolved against its OWN + # module — the existing code for the ``before`` signature and the generated + # code for the ``after`` — so a literal <-> same-module-constant refactor of a + # default is not flagged as a regression, while the same constant name + # resolving to a different value across the two versions still is. before_default_symbols = build_module_default_symbols(existing_code) after_default_symbols = build_module_default_symbols(generated_code) allowed_signature_changes = _prompt_breaking_change_signature_symbols(prompt_content) changed_set: Set[str] = set() + signature_details: List[Tuple[str, str, str, str]] = [] + # Declared symbols that were ACTUALLY validated against the declaration below + # (top-level functions with a parseable declared signature). Only these are + # excluded from the undeclared old-code loops, so the declaration can + # authorize a change old code would flag (the #2971 case). A declared symbol + # that is PRESENCE-ONLY here — a dotted method, or a non-paren declared + # signature where ``_declared_signature_to_entry`` returns None — is NOT + # added, so it falls back to the exact old-code baseline (added-required-param + # / binding-kind flip / ctor ABI drift stay caught; codex over-skip fix). + declared_validated: Set[str] = set() + + # DECLARED symbols: validate the generated signature against the DECLARED + # PARAM/return contract (a stable target), NEVER re-comparing params against + # the old code. Top-level functions AND dotted methods/constructors are all + # first-class declared-contract citizens here — the declaration is the permit + # for their params/return, while the un-declarable binding-kind/async are + # anchored to old code (below). Defaults on BOTH sides resolve in the + # GENERATED module namespace — the prompt describes the generated module + # (issue #1558's declared-vs-generated resolution). + for symbol in sorted(declared_names): + # A constructor's ABI is keyed on the CLASS symbol as a ``[class]`` entry, + # so a declared ``Class.__init__`` validates against the class entry; + # every other declared name (top-level function or ``Class.method``) keys + # on itself. + if symbol.endswith(".__init__"): + snapshot_key = symbol[: -len(".__init__")] + else: + snapshot_key = symbol + actual_entry = after_signatures.get(snapshot_key) + if actual_entry is None: + # Absent from the generated signature table (missing symbol or a + # non-callable form). Presence is enforced by the removal check + # above; not validated -> left out of ``declared_validated`` so it + # falls back to the old-code baseline. + continue + actual_ctx = _entry_binding_context(actual_entry) + if actual_ctx is None: + continue + # Binding kind + async are un-declarable (```` cannot + # express ``self`` / property / ``async`` / function-vs-class), so their + # baseline is the PRIOR generation: an async->sync or function->class drift + # on a declared symbol is a real regression (codex round-2 finding 1a), + # anchored to the OLD entry when the symbol already existed and was + # callable there. A ``BREAKING-CHANGE: change signature `` opt-out + # relaxes ONLY these un-declarable facets — it uses the GENERATED kind/ + # async — but the declared PARAM/return contract is STILL enforced below, + # so prose cannot wave through an added-required-param that violates the + # declaration (codex FM2). A newly-added declared symbol (or one whose + # prior form was a non-callable assignment/import) has no prior callable + # contract, so it also falls back to the generated kind/async. + if symbol in allowed_signature_changes: + expected_kind, expected_async = actual_ctx + else: + before_entry = before_signatures.get(snapshot_key) + before_ctx = ( + _entry_binding_context(before_entry) + if before_entry is not None + and parse_callable_contract(before_entry) is not None + else None + ) + expected_kind, expected_async = ( + before_ctx if before_ctx is not None else actual_ctx + ) + # The snapshot receiver-strips ``self``/``cls`` for receiver-bound methods + # and constructors, so strip a leading receiver from the declared signature + # for those kinds before comparing (a declared ``(self, x)`` must match the + # snapshot's ``(x)``; a plain function/staticmethod is left as declared). + strip_receiver = expected_kind in {"instance", "classmethod", "class"} + expected_entry = _declared_signature_to_entry( + declared[symbol], + expected_kind, + is_async=expected_async, + strip_receiver=strip_receiver, + ) + if expected_entry is None: + # Declared signature is not a parseable paren-list -> presence-only: + # the symbol must exist (enforced above) but its signature is NOT + # validated against the declaration here. It is intentionally left out + # of ``declared_validated`` so the undeclared old-code loops below + # still protect its signature (codex over-skip fix, e.g. a declared + # class with a non-paren ``"class Service"`` signature). + continue + compatible = signature_entries_compatible( + expected_entry, + actual_entry, + old_symbols=after_default_symbols, + new_symbols=after_default_symbols, + ) + if compatible is None: + # The GENERATED symbol is not a callable contract — a declared + # callable regenerated as a non-callable (``def f`` -> ``f = 1`` / + # ``from pkg import f as f``). If the OLD form WAS a callable, the + # declared callable became non-callable: flag it DIRECTLY here — that + # break is not authorized by ``BREAKING-CHANGE: change signature`` + # (which relaxes params, not de-callable-ing; de-callable-ing is a + # ``BREAKING-CHANGE: remove``), and the old-code loop would otherwise + # skip the symbol under such an opt-out (codex round-8 finding 1). + before_entry = before_signatures.get(snapshot_key) + before_ctx = ( + _entry_binding_context(before_entry) + if before_entry is not None + and parse_callable_contract(before_entry) is not None + else None + ) + if before_ctx is not None: + strip = before_ctx[0] in {"instance", "classmethod", "class"} + callable_expected = ( + _declared_signature_to_entry( + declared[symbol], + before_ctx[0], + is_async=before_ctx[1], + strip_receiver=strip, + ) + or before_entry + ) + changed_set.add(symbol) + signature_details.append( + (symbol, callable_expected, actual_entry, "pdd-interface") + ) + # else: the OLD form was ALSO non-callable (e.g. ``f = lambda: x`` that + # stayed an ``[assignment]``). Not validated -> falls through to the + # OLD-CODE baseline, which compares equal old-vs-new -> no false + # positive. Not added to ``declared_validated`` either way. + continue + # A real callable-vs-callable decision was made, so the declaration owns + # this symbol: exclude its SNAPSHOT KEY from the old-code loops below (the + # class entry for a validated ``__init__``, the ``X.method`` entry for a + # validated method). + declared_validated.add(snapshot_key) + if compatible is False: + # Report the ORIGINAL declared name (``Foo.method`` / + # ``Service.__init__``), not the snapshot key. + changed_set.add(symbol) + signature_details.append( + (symbol, expected_entry, actual_entry, "pdd-interface") + ) + # ``compatible is True`` -> compatible (nothing to flag). + + # UNDECLARED (and presence-only DECLARED) symbols: keep the historical + # old-vs-new comparison EXACTLY. Only symbols VALIDATED against the + # declaration above are skipped here — a presence-only declared symbol (dotted + # method / non-paren class) falls through to this old-code baseline so its + # signature drift is still caught (codex over-skip fix). for symbol, signature in before_signatures.items(): + if symbol in declared_validated: + continue if symbol not in after_signatures or symbol in allowed_signature_changes: continue after_signature = after_signatures[symbol] @@ -2364,6 +2894,8 @@ def _verify_public_surface_regression( continue changed_set.add(symbol) for symbol in before_signatures: + if symbol in declared_validated: + continue if symbol in after_signatures or symbol in changed_set: continue if "." in symbol: @@ -2379,9 +2911,276 @@ def _verify_public_surface_regression( changed_signatures=changed_signatures, pre_surface_size=len(before), post_surface_size=len(after), + signature_details=signature_details, ) +def _index_function_defs(tree: ast.Module) -> Dict[str, ast.AST]: + """Map dotted qualnames (``foo``, ``Class.method``) to their AST nodes. + + Classes contribute both their own key (so a declared class is locatable) and + the recursively-qualified keys of their nested functions/classes, matching the + dotted symbol names :func:`_snapshot_public_signatures` produces. First + definition wins on a name clash. + """ + index: Dict[str, ast.AST] = {} + + def _walk(prefix: str, body: List[ast.stmt]) -> None: + for node in body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + index.setdefault(f"{prefix}{node.name}", node) + elif isinstance(node, ast.ClassDef): + qual = f"{prefix}{node.name}" + index.setdefault(qual, node) + _walk(f"{qual}.", node.body) + + _walk("", tree.body) + return index + + +def _parse_declared_def(raw_signature: Optional[str]) -> Optional[ast.FunctionDef]: + """Parse a declared ```` signature into a ``FunctionDef`` node. + + Mirrors :func:`_declared_signature_to_entry`'s normalization: strip a leading + ``async`` / ``def `` prefix down to the bare parameter list, then parse + ``def _pdd: pass``. Returns ``None`` when the declaration is not a + parseable paren-list (a presence-only declaration is never reconciled). + """ + if not raw_signature or not isinstance(raw_signature, str): + return None + sig = raw_signature.strip() + if sig.startswith("async "): + sig = sig[len("async "):].strip() + def_match = re.match(r"^def\s+[A-Za-z_]\w*\s*(.*)$", sig, re.DOTALL) + if def_match: + sig = def_match.group(1).strip() + if sig.startswith("async "): + sig = sig[len("async "):].strip() + if not sig.startswith("("): + return None + try: + parsed = ast.parse(f"def _pdd{sig}: pass").body[0] + except SyntaxError: + return None + if not isinstance(parsed, ast.FunctionDef): + return None + return parsed + + +def _signature_slots( + func: ast.AST, +) -> List[Tuple[str, str, bool, Optional[str], Optional[ast.AST]]]: + """Ordered ``(name, kind, has_default, default_text, annotation_node)`` slots. + + Mirrors :func:`pdd.interface_semantics._params_from_arguments` so the + structural comparison in :func:`_annotation_only_edits` matches exactly what + the public-surface gate compares (posonly / positional / vararg / + keyword_only / kwarg, defaults normalized by :func:`ast.unparse`). + """ + args = func.args + slots: List[Tuple[str, str, bool, Optional[str], Optional[ast.AST]]] = [] + + def _add(arg: ast.arg, kind: str, default: Optional[ast.AST]) -> None: + has_default = default is not None + default_text = ast.unparse(default).strip() if default is not None else None + slots.append((arg.arg, kind, has_default, default_text, arg.annotation)) + + positional = list(args.posonlyargs) + list(args.args) + defaults = [None] * (len(positional) - len(args.defaults)) + list(args.defaults) + for arg, default in zip(args.posonlyargs, defaults[: len(args.posonlyargs)]): + _add(arg, "posonly", default) + for arg, default in zip(args.args, defaults[len(args.posonlyargs):]): + _add(arg, "positional", default) + if args.vararg is not None: + _add(args.vararg, "vararg", None) + for arg, default in zip(args.kwonlyargs, args.kw_defaults): + _add(arg, "keyword_only", default) + if args.kwarg is not None: + _add(args.kwarg, "kwarg", None) + return slots + + +def _line_start_byte_offsets(source: str) -> List[int]: + """UTF-8 byte offset at which each 1-indexed source line begins.""" + data = source.encode("utf-8") + offsets = [0] + for index, byte in enumerate(data): + if byte == 0x0A: # newline + offsets.append(index + 1) + return offsets + + +def _node_byte_span( + node: ast.AST, line_offsets: List[int] +) -> Optional[Tuple[int, int]]: + """Absolute UTF-8 byte ``(start, end)`` of a node, or ``None`` if unlocatable. + + ``ast`` column offsets are UTF-8 byte offsets into their line, so the caller + splices the encoded bytes (see :func:`_apply_byte_edits`). + """ + lineno = getattr(node, "lineno", None) + end_lineno = getattr(node, "end_lineno", None) + col = getattr(node, "col_offset", None) + end_col = getattr(node, "end_col_offset", None) + if lineno is None or end_lineno is None or col is None or end_col is None: + return None + if lineno > len(line_offsets) or end_lineno > len(line_offsets): + return None + return line_offsets[lineno - 1] + col, line_offsets[end_lineno - 1] + end_col + + +def _apply_byte_edits(source: str, edits: List[Tuple[int, int, str]]) -> str: + """Apply non-overlapping ``(start, end, replacement)`` byte edits to *source*.""" + data = bytearray(source.encode("utf-8")) + # Apply right-to-left so earlier offsets stay valid; skip any overlap defensively. + ordered = sorted(edits, key=lambda edit: edit[0], reverse=True) + last_start: Optional[int] = None + for start, end, replacement in ordered: + if start < 0 or end > len(data) or start > end: + continue + if last_start is not None and end > last_start: + continue + data[start:end] = replacement.encode("utf-8") + last_start = start + return data.decode("utf-8") + + +def _annotation_only_edits( + declared_def: ast.FunctionDef, + gen_node: ast.AST, + line_offsets: List[int], +) -> List[Tuple[int, int, str]]: + """Byte edits rewriting ``gen_node``'s annotations to the declared text. + + Returns an empty list unless the ONLY drift between the declaration and the + generated signature is annotation spelling on one or more parameters or the + return type (identical names, order, kinds and defaults). Only annotations the + public-surface gate considers INCOMPATIBLE are rewritten, so a compatible + alias (``Dict`` vs ``dict``) is never churned. Any structural difference + disqualifies the whole symbol (real drift the gate must still fire on). + """ + declared_slots = _signature_slots(declared_def) + gen_slots = _signature_slots(gen_node) + # A ```` signature cannot express ``self`` / ``cls``; the gate + # strips a leading receiver from the generated method before comparing, so + # drop it here too when the declaration does not carry one. + if ( + gen_slots + and gen_slots[0][0] in {"self", "cls"} + and (not declared_slots or declared_slots[0][0] not in {"self", "cls"}) + ): + gen_slots = gen_slots[1:] + if len(declared_slots) != len(gen_slots): + return [] + edits: List[Tuple[int, int, str]] = [] + for declared_slot, gen_slot in zip(declared_slots, gen_slots): + d_name, d_kind, d_has_def, d_def, d_ann = declared_slot + g_name, g_kind, g_has_def, g_def, g_ann = gen_slot + if ( + d_name != g_name + or d_kind != g_kind + or d_has_def != g_has_def + or d_def != g_def + ): + return [] + if d_ann is None or g_ann is None: + continue + d_text = ast.unparse(d_ann).strip() + g_text = ast.unparse(g_ann).strip() + if d_text == g_text or annotations_compatible(d_text, g_text): + continue + span = _node_byte_span(g_ann, line_offsets) + if span is None: + return [] + edits.append((span[0], span[1], d_text)) + d_ret = declared_def.returns + g_ret = getattr(gen_node, "returns", None) + if d_ret is not None and g_ret is not None: + d_ret_text = ast.unparse(d_ret).strip() + g_ret_text = ast.unparse(g_ret).strip() + if d_ret_text != g_ret_text and not annotations_compatible( + d_ret_text, g_ret_text + ): + span = _node_byte_span(g_ret, line_offsets) + if span is None: + return [] + edits.append((span[0], span[1], d_ret_text)) + return edits + + +def _reconcile_declared_annotation_drift( + existing_code: Optional[str], + generated_code: str, + prompt_name: str, + output_path: Optional[str], + language: Optional[str], + prompt_content: Optional[str], +) -> Optional[str]: + """Rewrite annotation-only drift on a declared symbol back to the declared text. + + Issue #1968: when a regeneration drifts a declared ```` + signature ONLY in annotation spelling — the declaration says ``object`` but + the model emitted ``Any``, or it broadened a parameter's declared type with + extra ``|`` union members — the public-surface gate correctly rejects it, but + the LLM repair retry keeps re-emitting the same "equivalent" spelling and + never converges. This deterministic pass runs BEFORE the gate: for every + declared symbol whose generated signature differs from the declaration ONLY + in annotation text (identical parameter names, order, kinds and defaults), it + rewrites the offending annotation(s) in the generated SOURCE to the declared + spelling, so the gate passes on the reconciled code with no further + generation attempt. + + Returns the rewritten ``generated_code`` when at least one annotation was + reconciled, or ``None`` when nothing safe to rewrite was found. It is + fail-safe: only annotations the gate considers INCOMPATIBLE are rewritten, + structural drift is left untouched, and the whole rewrite is discarded if the + result no longer parses. Bypass with ``PDD_SKIP_ANNOTATION_RECONCILE=1``. + """ + if ( + not existing_code + or not existing_code.strip() + or _env_flag_enabled("PDD_SKIP_ANNOTATION_RECONCILE") + or _env_flag_enabled("PDD_SKIP_PUBLIC_SURFACE_GATE") + or _env_flag_enabled("PDD_SKIP_CONFORMANCE") + or _is_test_output_path(output_path) + or not _is_python_generation(language, output_path) + ): + return None + declared = _collect_declared_surface(prompt_content, prompt_name) + if not declared: + return None + try: + tree = ast.parse(generated_code) + except SyntaxError: + return None + func_index = _index_function_defs(tree) + line_offsets = _line_start_byte_offsets(generated_code) + edits: List[Tuple[int, int, str]] = [] + try: + for name, raw_sig in declared.items(): + if not raw_sig: + continue + node = func_index.get(name) + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + declared_def = _parse_declared_def(raw_sig) + if declared_def is None: + continue + edits.extend(_annotation_only_edits(declared_def, node, line_offsets)) + except (ValueError, TypeError, AttributeError): + return None + if not edits: + return None + reconciled = _apply_byte_edits(generated_code, edits) + if reconciled == generated_code: + return None + try: + ast.parse(reconciled) + except SyntaxError: + return None + return reconciled + + def _get_test_churn_threshold() -> float: """Return the PDD_TEST_CHURN_THRESHOLD as a clamped 0..1 ratio. @@ -2445,8 +3244,15 @@ def _verify_test_churn( prompt_name: str, output_path: Optional[str], prompt_content: Optional[str], + adopted_human: bool = False, ) -> None: - """Fail when rewriting an existing test file exceeds the churn threshold.""" + """Fail when rewriting an existing test file exceeds the churn threshold. + + *adopted_human* records whether this test was adopted from an existing HUMAN + co-located test (unpinned) — provenance the issue #1903 §B.4 never-block + requires, computed by the caller at path-resolution time. It only annotates + the raised error; the gate decision here is unchanged. + """ if ( not existing_code or not existing_code.strip() @@ -2467,6 +3273,7 @@ def _verify_test_churn( threshold=threshold, pre_line_count=len(existing_code.splitlines()), post_line_count=len(generated_code.splitlines()), + adopted_human=adopted_human, ) def _should_wire_generated_exports(output_path: str) -> bool: @@ -4044,14 +4851,30 @@ def _match_one(patterns_list: List[str]) -> List[str]: elif verbose: console.print(f"[yellow]Git (log) failed for {prompt_file_rel_to_root_str} or no history found: {log_stderr}[/yellow]") + # Last resort: resolve the "original" prompt from the base branch. + # In the cloud sync-after-change flow the prompt edit is already + # committed on this branch (on-disk == HEAD) and no distinct prior + # version is found in local history, so without this we silently + # fall back to full regeneration and re-drop declared symbols + # (#1938/#1940, test_repo#4232). Configurable via PDD_SYNC_BASE_REF + # (defaults to origin/main). Only used when it differs from the + # current prompt; otherwise leave can_attempt_incremental False so an + # honest full regeneration runs (surfaced loudly below). if not found_different_prior: - original_prompt_content_for_incremental = new_prompt_candidate - can_attempt_incremental = True - if verbose: + base_ref = os.environ.get("PDD_SYNC_BASE_REF", "origin/main") + base_content = get_git_content_at_ref(prompt_file, git_ref=base_ref) + if base_content is not None and base_content.strip() != prompt_content.strip(): + original_prompt_content_for_incremental = base_content + can_attempt_incremental = True + found_different_prior = True + if verbose: + console.print(f"[cyan]Using base-branch ({base_ref}) prompt as incremental original.[/cyan]") + elif verbose: console.print( f"[yellow]Warning: Could not find a prior *different* version of {prompt_file} " - f"within the last {MAX_COMMITS_TO_SEARCH if git_root_path_obj else 'N/A'} relevant commits. " - f"Using current HEAD version as original (prompts will be identical).[/yellow]" + f"within the last {MAX_COMMITS_TO_SEARCH if git_root_path_obj else 'N/A'} relevant commits, " + f"and base-branch ({base_ref}) did not provide a distinct original. " + f"Performing full regeneration.[/yellow]" ) else: # File not in HEAD, cannot determine git-based original prompt. @@ -4093,6 +4916,22 @@ def _match_one(patterns_list: List[str]) -> List[str]: ) can_attempt_incremental = False + # Loud (non-verbose) degradation notice: a mature module (existing non-empty + # code on disk) is about to be fully regenerated because no "original" prompt + # could be resolved for surgical/incremental generation. This is the silent + # failure mode that re-drops declared symbols on sync (#1938/#1940, + # test_repo#4232) — surface it once so operators aren't blind. New/empty + # modules doing a legitimate first generation and repair-directive full + # regenerations are intentionally excluded. + if (llm_enabled + and existing_code_content is not None + and not can_attempt_incremental + and not repair_directive_active): + console.print( + "[yellow]Surgical/incremental generation unavailable (no original prompt " + "resolved) — performing full regeneration.[/yellow]" + ) + try: # Resolve post-process script from env/CLI override, then front matter, then sensible default per template post_process_script: Optional[str] = None @@ -4789,6 +5628,24 @@ def _subst_arg(arg: str) -> str: # existing file to 0 bytes — silent erasure of mature # public APIs and test coverage (external review iter-13 # follow-up; reproduced with mocked local generation). + # + # Deterministic annotation reconciliation (issue #1968): when a + # regeneration drifts a declared signature ONLY in annotation + # spelling (declared `object` vs emitted `Any`, or a broadened + # union), rewrite the emitted annotation back to the declared text + # BEFORE the gate re-checks so the sync converges without another + # generation attempt. The pass is a no-op unless it finds such + # drift, so it never alters an otherwise-passing generation. + reconciled_code = _reconcile_declared_annotation_drift( + existing_code=existing_code_content, + generated_code=generated_code_content, + prompt_name=prompt_name, + output_path=output_path, + language=language, + prompt_content=prompt_content, + ) + if reconciled_code is not None: + generated_code_content = reconciled_code try: _verify_public_surface_regression( existing_code=existing_code_content, diff --git a/pdd/commands/drift.py b/pdd/commands/drift.py index 202b6e7f3..582aa50ea 100644 --- a/pdd/commands/drift.py +++ b/pdd/commands/drift.py @@ -1,4 +1,5 @@ """``pdd checkup drift`` subcommand implementation.""" + from __future__ import annotations import json @@ -7,12 +8,18 @@ import click -from ..drift_main import DEFAULT_MAX_COST_USD, run_drift +from ..drift_main import DEFAULT_MAX_COST_USD, DriftInputError, run_drift @click.command("drift") @click.argument("devunit") -@click.option("--runs", default=1, show_default=True, type=int, help="Number of regeneration runs.") +@click.option( + "--runs", + default=1, + show_default=True, + type=int, + help="Number of regeneration runs.", +) @click.option("--model", default=None, help="Model override for regeneration runs.") @click.option( "--from-evidence", @@ -32,7 +39,9 @@ default=False, help="Do not regenerate; compare current artifact stability only.", ) -@click.option("--json", "as_json", is_flag=True, default=False, help="Emit machine-readable JSON.") +@click.option( + "--json", "as_json", is_flag=True, default=False, help="Emit machine-readable JSON." +) @click.option( "--max-cost", "max_cost_usd", @@ -79,7 +88,34 @@ def drift_cmd( dry_run=dry_run, max_cost_usd=effective_max_cost, ) + except DriftInputError as exc: + if as_json: + click.echo( + json.dumps( + { + "status": "error", + "error": {"code": exc.code, "message": str(exc)}, + }, + indent=2, + ) + ) + raise click.exceptions.Exit(1) from exc + raise click.ClickException(str(exc)) from exc except (FileNotFoundError, RuntimeError) as exc: + if as_json: + click.echo( + json.dumps( + { + "status": "error", + "error": { + "code": "drift_runtime_error", + "message": str(exc), + }, + }, + indent=2, + ) + ) + raise click.exceptions.Exit(1) from exc raise click.ClickException(str(exc)) from exc if as_json: @@ -93,7 +129,9 @@ def drift_cmd( click.echo(f"Verify: {payload['verify']}") click.echo(f"Policy: {payload['policy']}") if payload.get("policy_check_unavailable"): - click.echo("Policy note: gate command unavailable; policy check failed closed.") + click.echo( + "Policy note: gate command unavailable; policy check failed closed." + ) if payload.get("max_cost_usd") is not None: click.echo(f"Max cost: ${payload['max_cost_usd']:.4f}") click.echo(f"Total cost: ${payload['total_cost_usd']:.4f}") diff --git a/pdd/commands/fix.py b/pdd/commands/fix.py index a49dbd765..5fe5a47ad 100644 --- a/pdd/commands/fix.py +++ b/pdd/commands/fix.py @@ -122,7 +122,7 @@ def _is_user_story_file(value: str) -> bool: help="Behavior when context compression fails (default: full).", ) @click.pass_context -@log_operation(operation="fix", clears_run_report=True) +@log_operation(operation="fix", clears_run_report=True, updates_fingerprint=True) @track_cost def fix( ctx: click.Context, diff --git a/pdd/commands/maintenance.py b/pdd/commands/maintenance.py index b8ca34378..e2da0b23e 100644 --- a/pdd/commands/maintenance.py +++ b/pdd/commands/maintenance.py @@ -14,6 +14,7 @@ from ..track_cost import track_cost from ..core.errors import handle_error from ..sync_determine_operation import AmbiguousModuleError +from ..fingerprint_transaction import FingerprintFinalizeError from ..core.utils import _run_setup_utility, echo_model_line from ..evidence_manifest import ( collect_sync_evidence_paths, @@ -211,6 +212,19 @@ def _write_sync_evidence_manifest( "(auto-deps tags and generate preprocess expansion)." ), ) +@click.option( + "--fresh", + is_flag=True, + default=False, + help=( + "Disable the default surgical (edit-shaped) regeneration for a mature " + "module. By default `pdd sync` edits existing module code in place so " + "declared symbols are preserved; with --fresh, sync uses standard " + "generation, which regenerates the module from scratch when the prompt " + "change is large. Use it when you intend a large rewrite rather than an " + "in-place edit. Single-module sync only." + ), +) @click.option( "--compressed-context/--no-compressed-context", default=None, @@ -279,6 +293,7 @@ def sync( evidence: bool, snapshot_context: bool, compress: bool, + fresh: bool, compressed_context: Optional[bool], model: Optional[str] = None, compress_examples: Optional[bool] = None, @@ -367,6 +382,8 @@ def _restore_model_default(_prev=_prev_model_default): if basename is None: if snapshot_context: raise click.UsageError("--snapshot-context is only supported for single-module sync.") + if fresh: + raise click.UsageError("--fresh is only supported for single-module sync.") if durable or durable_branch or no_resume or durable_max_parallel is not None: raise click.UsageError("Durable sync options require a GitHub issue URL.") effective_one_session = one_session if one_session is not None else False @@ -411,6 +428,8 @@ def _restore_model_default(_prev=_prev_model_default): if _is_github_issue_url(basename): if snapshot_context: raise click.UsageError("--snapshot-context is only supported for single-module sync.") + if fresh: + raise click.UsageError("--fresh is only supported for single-module sync.") if not durable and ( durable_branch is not None or no_resume or durable_max_parallel is not None ): @@ -491,6 +510,7 @@ def _restore_model_default(_prev=_prev_model_default): one_session=effective_one_session, snapshot_context=snapshot_context, compress=compress, + fresh=fresh, compressed_context=effective_compressed_context, ) if evidence: @@ -992,6 +1012,8 @@ def auto_deps( return result, total_cost, model_name except click.Abort: raise + except FingerprintFinalizeError as exception: + raise click.ClickException(str(exception)) from exception except Exception as exception: handle_error(exception, "auto-deps", ctx.obj.get("quiet", False)) return None diff --git a/pdd/commands/modify.py b/pdd/commands/modify.py index 45e3f5dac..25f6a979a 100644 --- a/pdd/commands/modify.py +++ b/pdd/commands/modify.py @@ -625,6 +625,18 @@ def update( ) if ret is None: + # In single-file mode `update_main` returns None ONLY on failure + # (input error, git-history/provider failure, invalid output). A bare + # `return None` here makes Click exit 0, so a subprocess caller that + # only sees the exit code — e.g. the change orchestrator's Step 8.5 + # preflight drift-heal running `pdd update --sync-metadata --git ...` + # — would record an unchanged, still-stale prompt as "healed" and + # reason Step 9 from stale intent. Surface a non-zero exit so that + # boundary fails closed. Repo-wide mode keeps returning None (exit 0) + # because there None is also the legitimate "everything in sync" + # no-op, not a failure (Codex review, PR #1998). + if not is_repo_mode: + raise click.exceptions.Exit(1) return None return ret diff --git a/pdd/content_selector.py b/pdd/content_selector.py index b7dfc9363..fc953d888 100644 --- a/pdd/content_selector.py +++ b/pdd/content_selector.py @@ -13,9 +13,11 @@ import os import re import textwrap +import time +from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import Optional +from typing import Any, Mapping, Optional from ._selector_parse import parse_selectors_string from .api_contract_slicer import ApiContractSlicer, ContractSlicerError @@ -479,6 +481,1604 @@ def _sibling_test_paths(module_path: Path) -> list[Path]: return [path for path in candidates if path.is_file()] +# JS/TS jest/vitest/Next.js co-located test conventions (issue #1903). +_JS_TS_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs") +_JS_TS_TEST_SUFFIXES = (".test", ".spec") +_JS_TS_TEST_SUBDIRS = ("__test__", "__tests__", "tests") +# Python modules whose sibling tests `_sibling_test_paths` (always `.py`) can +# legitimately match (issue #1903). Any other suffix is unsupported and yields no +# candidates — a safe no-op — so e.g. `src/foo.go` never adopts a same-stem +# `tests/test_foo.py` and retargets Go generation into a Python file. +_PYTHON_EXTENSIONS = (".py", ".pyi") + + +def _collocated_js_ts_candidates(module_path: Path) -> list[Path]: + """Candidate co-located test files for a JS/TS module (issue #1903). + + Covers ``{stem}.test.`` / ``{stem}.spec.`` beside the module and + under ``__test__/``, ``__tests__/`` and ``tests/`` — the conventions jest, + vitest and Next.js collect. Existence is filtered by the caller. + """ + stem = module_path.stem + parent = module_path.parent + candidates: list[Path] = [] + for ext in _JS_TS_EXTENSIONS: + for suffix in _JS_TS_TEST_SUFFIXES: + filename = f"{stem}{suffix}{ext}" + candidates.append(parent / filename) + for subdir in _JS_TS_TEST_SUBDIRS: + candidates.append(parent / subdir / filename) + return candidates + + +def _contained_in_root(candidate_resolved: Path, root_resolved: Path) -> bool: + """CWE-022 containment barrier: *candidate_resolved* is *root* or inside it. + + Both arguments MUST already be ``.resolve()``d (symlinks and ``..`` + normalized). Adoption candidates are derived from a caller-supplied + code-file path — issue-influenced in the hosted/agentic flow — and must + never escape the working tree: a traversal like ``../../victim`` would + otherwise become a generated-test WRITE target (py/path-injection, + PR #1914 CodeQL alerts #339-#342). + """ + candidate_s, root_s = str(candidate_resolved), str(root_resolved) + return candidate_s == root_s or candidate_s.startswith( + root_s.rstrip(os.sep) + os.sep + ) + + +def _validated_project_path( + untrusted_path: str | Path, + *, + root: Path | None = None, +) -> Optional[Path]: + """Return a canonical in-project path built from sanitized components. + + ``code_file`` can originate in an issue-driven/cloud request, so it must + not flow directly into any filesystem operation. First reduce an absolute + path to a path relative to the trusted working-tree root (or reject it), + then sanitize every component with :func:`os.path.basename` before joining + it back to that root. Only after that lexical barrier do we resolve + symlinks and enforce canonical containment. This ordering is important: + checking containment *after* calling ``resolve`` on the raw caller value is + too late for CWE-022 and is reported by CodeQL's ``py/path-injection`` + query. + + The helper is total. Traversal, an outside absolute path, a symlink escape, + or any malformed value returns ``None``. + """ + try: + root_resolved = (root or Path.cwd()).resolve() + raw = Path(untrusted_path) + if raw.is_absolute(): + try: + relative = raw.relative_to(root_resolved) + except ValueError: + return None + else: + relative = raw + + if not relative.parts: + return None + + safe_parts: list[str] = [] + for part in relative.parts: + # basename is the path-component sanitization barrier. Equality + # ensures separators/traversal were not silently stripped. + safe_part = os.path.basename(part) + if safe_part != part or safe_part in {'', os.curdir, os.pardir}: + return None + safe_parts.append(safe_part) + + candidate = root_resolved.joinpath(*safe_parts) + resolved = candidate.resolve() + if not _contained_in_root(resolved, root_resolved): + return None + return resolved + except (OSError, RuntimeError, TypeError, ValueError): + return None + + +def find_collocated_test(code_file: str | Path) -> Optional[Path]: + """Return the single existing co-located test for *code_file*, else ``None``. + + Pure runner-awareness detector (issue #1903). Covers Python sibling + conventions (reusing :func:`_sibling_test_paths`) and JS/TS jest/vitest/ + Next.js conventions (``{stem}.test.`` / ``{stem}.spec.`` beside + the module and under ``__test__/``, ``__tests__/``, ``tests/``). Any other + language is unsupported and returns ``None`` (no candidates) so a non-Python, + non-JS/TS module is never retargeted onto a same-stem ``.py`` sibling. + + Only EXISTING files are considered, the module itself is excluded, and + candidates are de-duped by resolved path. Candidates outside the current + working tree are rejected (containment, CWE-022): adoption only ever + targets tests inside the project the CLI is operating on, so a + traversal-shaped *code_file* degrades to no-adoption rather than escaping + the repo. The match is returned only when exactly one distinct test file + exists (0 matches OR >1 match -> ``None``) so an ambiguous project is + never silently retargeted. Never raises: any error yields ``None``. + """ + matches = _collocated_test_matches(code_file) + return matches[0] if len(matches) == 1 else None + + +def _collocated_test_matches(code_file: str | Path) -> list[Path]: + """All distinct EXISTING co-located tests for *code_file* (validated, in-root). + + The shared match set behind :func:`find_collocated_test`. Cardinality + matters to callers: ZERO matches is greenfield-eligible, but an AMBIGUOUS + (>1) project must NOT be treated as greenfield (that would fork a third + test) — see :func:`resolve_test_output_path`. Never raises: any error yields + ``[]``. + """ + try: + root_resolved = Path.cwd().resolve() + module_path = _validated_project_path(code_file, root=root_resolved) + if module_path is None: + return [] + suffix = module_path.suffix.lower() + if suffix in _JS_TS_EXTENSIONS: + raw_candidates = _collocated_js_ts_candidates(module_path) + elif suffix in _PYTHON_EXTENSIONS: + raw_candidates = _sibling_test_paths(module_path) + else: + return [] + + seen: set[Path] = set() + matches: list[Path] = [] + for candidate in raw_candidates: + resolved = _validated_project_path(candidate, root=root_resolved) + if resolved is None or not resolved.is_file(): + continue + if resolved == module_path or resolved in seen: + continue + seen.add(resolved) + matches.append(resolved) + return matches + except Exception: # pylint: disable=broad-except + return [] + + +# JS/TS test-runner config markers for greenfield discovery (issue #1903 §A). +# A project that configures jest/vitest collects tests co-located with the +# module: jest's default ``testMatch`` matches any ``*.test.`` / +# ``*.spec.`` regardless of directory, so an ``__test__/{stem}.test.tsx`` +# beside the module is collected. When PDD would otherwise write the FIRST test +# for a brand-new module to a runner-blind ``tests/`` shadow, detect that the +# project uses such a runner and write the first test where the runner will +# actually collect it — never a root ``tests/`` orphan. +_JS_TS_RUNNER_CONFIG_STEMS = ("jest.config", "vitest.config") +_JS_TS_RUNNER_CONFIG_EXTS = ("", ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".json") + + +def _package_json_declares_js_runner(package_json: Path) -> bool: + """Return True when *package_json* references jest/vitest. + + Checks an inline ``"jest"``/``"vitest"`` config block, a declared + dependency on jest/vitest, or a ``test`` script that invokes one. Total — + any read/parse error yields ``False``. + """ + try: + data = json.loads(package_json.read_text(encoding="utf-8", errors="ignore")) + except (OSError, ValueError): + return False + if not isinstance(data, Mapping): + return False + # Inline runner config block (``"jest": {...}`` / ``"vitest": {...}``). + for key in ("jest", "vitest"): + if data.get(key) is not None: + return True + # Declared dependency on a runner (incl. scoped ``@scope/jest`` wrappers). + for dep_key in ( + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ): + deps = data.get(dep_key) + if isinstance(deps, Mapping): + for name in deps: + if isinstance(name, str) and ( + name in ("jest", "vitest") + or name.endswith("/jest") + or name.endswith("/vitest") + ): + return True + # A ``test`` / ``test:*`` script that INVOKES a runner as a command token — + # only these script keys, and only a real ``jest``/``vitest`` command token + # (not any substring, so ``"build": "echo no-jest"`` does not false-positive; + # issue #1903 review round 5). + scripts = data.get("scripts") + if isinstance(scripts, Mapping): + for key, cmd in scripts.items(): + if not (isinstance(key, str) and (key == "test" or key.startswith("test:"))): + continue + if not isinstance(cmd, str): + continue + if re.search(r"(?:^|[\s;&|/])(?:jest|vitest)(?:$|[\s;&|@'\"])", cmd): + return True + return False + + +def _project_uses_js_test_runner(module_path: Path, root_resolved: Path) -> bool: + """True when a jest/vitest runner is configured at or above *module_path*. + + Walks from the module's directory up to (and including) *root_resolved*, + looking for a jest/vitest config file or a ``package.json`` that declares + one. Never walks above the trusted root. Total — any error yields ``False``. + """ + try: + current = module_path.parent.resolve() + except (OSError, RuntimeError): + return False + root_s = str(root_resolved) + while True: + for stem in _JS_TS_RUNNER_CONFIG_STEMS: + for ext in _JS_TS_RUNNER_CONFIG_EXTS: + if (current / f"{stem}{ext}").is_file(): + return True + pkg = current / "package.json" + if pkg.is_file() and _package_json_declares_js_runner(pkg): + return True + if str(current) == root_s or not _contained_in_root(current, root_resolved): + break + parent = current.parent + if parent == current: + break + current = parent + return False + + +def _as_str_list(value: Any) -> list[str]: + """Coerce a jest config value (str | list | None) to a list of strings.""" + if isinstance(value, str): + return [value] + if isinstance(value, (list, tuple)): + return [v for v in value if isinstance(v, str)] + return [] + + +def _jest_config_from_package_json(package_json: Path) -> Optional[Mapping[str, Any]]: + """Return the inline ``jest``/``vitest`` config block from a package.json.""" + try: + data = json.loads(package_json.read_text(encoding="utf-8", errors="ignore")) + except (OSError, ValueError): + return None + if not isinstance(data, Mapping): + return None + for key in ("jest", "vitest"): + block = data.get(key) + if isinstance(block, Mapping): + return block + return None + + +# Test-discovery keys whose presence in an unparseable JS/TS config means we +# cannot assume default discovery (issue #1903 §A). Covers BOTH the jest dialect +# (testMatch/testRegex/testPathIgnorePatterns/roots/rootDir) and the vitest +# dialect (test.include/exclude, projects, workspace) — matched as whole words +# so generic tokens (e.g. ``__dirname``, ``rootReducer``) do not false-trigger. +_JS_RUNNER_DISCOVERY_KEYS = ( + "testMatch", + "testRegex", + "testPathIgnorePatterns", + "roots", + "rootDir", + "root", + "dir", + "include", + "exclude", + "projects", + "workspace", +) +_JS_RUNNER_DISCOVERY_RE = re.compile( + r"\b(?:" + "|".join(_JS_RUNNER_DISCOVERY_KEYS) + r")\b" +) +# Composition/delegation a static text scan cannot resolve: a config that +# imports/requires/spreads/extends a base, applies a preset, is a function, or +# whose export is a CALL expression (``module.exports = buildConfig()``, +# ``export default defineConfig(...)``, ``createJestConfig(...)``) could set +# discovery elsewhere. Any of these makes the config unresolvable -> refuse +# (issue #1903 review rounds 2-3, delegating/wrapper jest.config.js). +_JS_RUNNER_UNRESOLVABLE_RE = re.compile( + r"\brequire\s*\(|\bimport\b|\bpreset\b|\bextends\b|\.\.\." + r"|=>|\bfunction\b" + r"|=\s*[A-Za-z_$][\w$.]*\s*\(" # export is a call: = buildConfig( + r"|\.\w+\s*\(" # ANY method call: ['a','b'].join( + r"|\[[^\]\n]*\]\s*:" # computed property key: {[..]: ..} + r"|`" # template literal (dynamic strings) + r"|module\.exports\s*=\s*[A-Za-z_$]" # export is an identifier: = config + r"|export\s+default\s+[A-Za-z_$]" # export default ident / defineConfig +) +_JS_RUNNER_CONFIG_FILE_STEMS = ("jest.config", "vitest.config", "vite.config") + + +def _strip_js_comments_and_strings(text: str) -> str: + """Best-effort: blank out JS comments and string/template literals. + + So a discovery keyword or extra ``;`` inside a comment or string cannot + change the structural (whitelist) analysis. Replaces the CONTENTS of each + ``//``, ``/* */``, ``'…'``, ``"…"``, `` `…` `` with spaces (preserving + length/newlines is unnecessary). A single pass; never raises. + """ + out = [] + i, n = 0, len(text) + while i < n: + c = text[i] + two = text[i : i + 2] + if two == "//": + j = text.find("\n", i) + i = n if j < 0 else j + continue + if two == "/*": + j = text.find("*/", i + 2) + i = n if j < 0 else j + 2 + continue + if c in "'\"`": + quote = c + i += 1 + while i < n: + if text[i] == "\\": + i += 2 + continue + if text[i] == quote: + i += 1 + break + i += 1 + out.append('""') # placeholder token for a string value + continue + out.append(c) + i += 1 + return "".join(out) + + +def _js_config_is_trivial_default_literal(text: str) -> bool: + """True ONLY when *text* is provably a SINGLE plain-object-literal export. + + Whitelist (not blacklist): the config must be exactly ``module.exports = {…}`` + or ``export default {…}`` — one statement, balanced braces, nothing else + before or after (only optional ``;``/whitespace) — and the object body must + reference no discovery key and no dynamic/composition construct. This refuses + evasions a blacklist misses, e.g. a second statement mutating the export + (``module.exports = {}; module.exports['test'+'Match'] = […]``). Total. + """ + code = _strip_js_comments_and_strings(text).strip() + for prefix in ("module.exports", "export default"): + if not code.startswith(prefix): + continue + rest = code[len(prefix):].lstrip() + if prefix == "module.exports": + if not rest.startswith("="): + return False + rest = rest[1:].lstrip() + if not rest.startswith("{"): + return False + # Match the object literal via brace counting. + depth, end = 0, -1 + for idx, ch in enumerate(rest): + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + end = idx + break + if end < 0: + return False # unbalanced + body = rest[: end + 1] + trailing = rest[end + 1 :].strip().strip(";").strip() + if trailing: + return False # extra statements/assignments after the literal + # The object body must be a plain literal with no discovery/dynamic bits. + if _JS_RUNNER_DISCOVERY_RE.search(body) or _JS_RUNNER_UNRESOLVABLE_RE.search(body): + return False + return True + return False + + +def _js_config_text_has_custom_discovery(config_file: Path) -> bool: + """True when a JS/TS config CANNOT be proven a trivial default literal. + + We cannot execute a ``jest.config.js`` / ``vitest.config.ts`` in Python, so + (issue #1903 review round 7) we use a POSITIVE whitelist: trust DEFAULT + discovery ONLY when the config text is a single plain-object-literal export + with no discovery keys and no dynamic/composition constructs + (:func:`_js_config_is_trivial_default_literal`). ANY other shape — extra + statements, dynamic key construction, imports/require/spread/preset/function, + or an unreadable file — returns True so the caller conservatively refuses to + claim a co-located path is collected rather than risk a false-green. + """ + try: + text = config_file.read_text(encoding="utf-8", errors="ignore") + except (OSError, ValueError): + return True # unreadable opaque config -> cannot prove default -> refuse + # Check discovery keys against the ORIGINAL text (round 8): string-stripping + # would erase a QUOTED property key like ``"testMatch"``, so a discovery key + # anywhere in the raw text — quoted or not — forces refusal (a discovery word + # inside a string VALUE only over-refuses, which is safe). + if _JS_RUNNER_DISCOVERY_RE.search(text): + return True + return not _js_config_is_trivial_default_literal(text) + + +def _collect_js_runner_config( + module_path: Path, root_resolved: Path +) -> tuple[Mapping[str, Any], Optional[Path], bool]: + """Return the nearest jest/vitest config, its directory, and an opaque flag. + + Walks from the module's directory up to (and including) *root_resolved*, + reading ``jest.config.json`` / ``.jestrc`` / ``.jestrc.json`` and the + ``package.json`` ``"jest"``/``"vitest"`` block (nearest wins). A JS/TS config + file (``jest.config.js``/``.ts``, ``vitest.config.*``) is NOT parseable in + Python; when the nearest config is such a file the third return value + (``opaque_custom``) is ``True`` IFF the file's text references a + test-discovery key (``testMatch``/``roots``/``rootDir``/...), signalling the + caller to conservatively refuse to claim a co-located path is collected. A + JS/TS config that customizes nothing discovery-related leaves + ``opaque_custom=False`` so the default convention (which jest collects) is + used. Returns ``({}, None, False)`` when no config is found. Total. + """ + try: + current = module_path.parent.resolve() + except (OSError, RuntimeError): + return {}, None, False + root_s = str(root_resolved) + while True: + # Gather EVERY runner-config source AT THIS LEVEL. When more than one + # exists (e.g. an inline package.json jest block AND a vitest.config.ts), + # the ACTIVE runner is ambiguous — a different source could set different + # discovery — so fail closed (opaque) rather than let one hide another + # (issue #1903 review round 7). + json_config: Optional[Mapping[str, Any]] = None + for fname in ("jest.config.json", ".jestrc", ".jestrc.json"): + candidate = current / fname + if candidate.is_file(): + try: + data = json.loads( + candidate.read_text(encoding="utf-8", errors="ignore") + ) + if isinstance(data, Mapping): + json_config = data + break + except (OSError, ValueError): + pass + pkg = current / "package.json" + pkg_block = _jest_config_from_package_json(pkg) if pkg.is_file() else None + # Collect EVERY JS/TS config file at this level (round 8) — e.g. both a + # ``jest.config.js`` AND a ``vitest.config.ts`` — so two distinct runner + # config files count as two sources rather than the loop silently keeping + # only the first and missing the ambiguity. + js_config_files: list[Path] = [] + for stem in _JS_RUNNER_CONFIG_FILE_STEMS: + for ext in (".js", ".ts", ".mjs", ".cjs", ".mts", ".cts"): + jsfile = current / f"{stem}{ext}" + if jsfile.is_file(): + js_config_files.append(jsfile) + + source_count = ( + (1 if json_config is not None else 0) + + (1 if pkg_block is not None else 0) + + len(js_config_files) + ) + if source_count > 1: + return {}, current, True # ambiguous active runner -> refuse + if json_config is not None: + return json_config, current, False + if pkg_block is not None: + return pkg_block, current, False + if js_config_files: + return {}, current, _js_config_text_has_custom_discovery(js_config_files[0]) + + if str(current) == root_s or not _contained_in_root(current, root_resolved): + break + parent = current.parent + if parent == current: + break + current = parent + return {}, None, False + + +def _micromatch_to_regex(glob: str) -> Optional[str]: + """Translate a jest/micromatch ``testMatch`` glob to an anchored regex. + + Single pass. Handles the constructs jest's default and typical custom + ``testMatch`` use: ``**``/``**/`` globstar, ``*``, ``?``, extglobs + ``?(...)`` ``*(...)`` ``+(...)`` ``@(...)`` (with their quantifiers applied + to the group), brace alternation ``{a,b}``, ``|`` alternation inside groups, + and character classes ``[...]``. Returns ``None`` for negation ``!(...)`` (we + refuse to guess). ```` must already be substituted by the caller. + """ + out = ["^"] + # Parallel stacks per open group: the trailing regex quantifier ("", "?", + # "*", "+") and the group KIND. Kind matters because alternation differs by + # construct: BRACES ``{a,b}`` alternate on COMMA (pipe is literal), while + # EXTGLOBS ``@(a|b)`` / plain parens alternate on PIPE (comma is literal) — + # issue #1903 review round 5. + group_quant: list[str] = [] + group_kind: list[str] = [] # "brace" | "extglob" | "paren" + i, n = 0, len(glob) + while i < n: + c = glob[i] + if c == "!" and glob[i + 1 : i + 2] == "(": + return None # negation — refuse to guess + if c in "?*+@" and glob[i + 1 : i + 2] == "(": + group_quant.append({"?": "?", "*": "*", "+": "+", "@": ""}[c]) + group_kind.append("extglob") + out.append("(?:") + i += 2 + continue + if c == "*": + # ``**`` is a GLOBSTAR (crosses ``/``) ONLY when it is a whole path + # segment — preceded by ``/`` or start AND followed by ``/`` or end + # (round 9). micromatch treats ``**`` embedded in a segment (``qa**/``, + # ``**bar``, ``foo**``) as a plain single-segment ``*``, so translating + # every ``**`` to ``.*`` over-matches paths jest rejects (an uncollected + # green). ``[^/]*`` keeps embedded stars segment-local. + is_double = glob[i : i + 2] == "**" + at_seg_start = i == 0 or glob[i - 1] == "/" + after_double = glob[i + 2 : i + 3] + if is_double and at_seg_start and after_double == "/": + out.append("(?:.*/)?") + i += 3 + elif is_double and at_seg_start and after_double == "": + out.append(".*") # trailing whole-segment ``**`` -> match subtree + i += 2 + elif is_double: + out.append("[^/]*") # embedded ``**`` == single-segment ``*`` + i += 2 + else: + out.append("[^/]*") + i += 1 + continue + if c == "?": + out.append("[^/]") + i += 1 + continue + if c == "(": + group_quant.append("") + group_kind.append("paren") + out.append("(?:") + i += 1 + continue + if c == ")": + out.append(")") + out.append(group_quant.pop() if group_quant else "") + if group_kind: + group_kind.pop() + i += 1 + continue + if c == "|": + # Alternation only inside an extglob/paren group; elsewhere literal. + inside = group_kind and group_kind[-1] in ("extglob", "paren") + out.append("|" if inside else r"\|") + i += 1 + continue + if c == "{": + group_quant.append("") + group_kind.append("brace") + out.append("(?:") + i += 1 + continue + if c == "}": + out.append(")") + out.append(group_quant.pop() if group_quant else "") + if group_kind: + group_kind.pop() + i += 1 + continue + if c == ",": + # Alternation only inside a brace group; elsewhere literal. + inside_brace = group_kind and group_kind[-1] == "brace" + out.append("|" if inside_brace else ",") + i += 1 + continue + if c == "[": + # micromatch/bash character class. A leading ``!`` (or ``^``) means + # NEGATION — translate to Python's ``[^...]`` (``[!_]`` = "not _", NOT + # "``!`` or ``_``"). ``]`` as the FIRST class member is a literal. + start = i + 1 + neg = start < n and glob[start] in "!^" + scan = start + 1 if neg else start + if scan < n and glob[scan] == "]": # literal ] as first member + scan += 1 + j = glob.find("]", scan) + if j == -1: # malformed / unterminated -> literal '[' + out.append(r"\[") + i += 1 + continue + inner = glob[(start + 1 if neg else start):j] + out.append("[^" + inner + "]" if neg else "[" + inner + "]") + i = j + 1 + continue + out.append(re.escape(c)) + i += 1 + out.append("$") + return "".join(out) + + +try: # `regex` (already a pdd dependency) supports a per-match wall-clock timeout + import regex as _redos_regex # type: ignore + _HAVE_REDOS_REGEX = True +except ImportError: # pragma: no cover - stdlib fallback (no timeout, caps only) + _redos_regex = re # type: ignore + _HAVE_REDOS_REGEX = False + +# Repo-controlled ``testMatch``/``testRegex``/``testPathIgnorePatterns`` are +# hosted/issue-influenced. Matching them with backtracking regex is a ReDoS +# vector (issue #1903 review round 4), so every such match runs with a strict +# wall-clock timeout and generous length caps, and FAILS CLOSED (``None``). +_REGEX_MATCH_TIMEOUT_S = 0.05 +_REGEX_MAX_PATTERN_LEN = 4096 +_REGEX_MAX_TEXT_LEN = 2048 +# Aggregate-work caps for greenfield discovery (issue #1903 review rounds 5-6): a +# hostile config with very many patterns/roots/candidates must not stall sync. +# Bounded by BOTH element caps AND a single wall-clock budget for the whole +# discovery call (worst case ~ caps * per-match timeout is further capped here). +_MAX_RUNNER_PATTERNS = 32 +_MAX_GREENFIELD_CANDIDATES = 24 +_DISCOVERY_TOTAL_BUDGET_S = 1.0 + + +def _safe_regex_search(pattern: Optional[str], text: str) -> Optional[bool]: + """Timeout-bounded, fail-closed ``search`` for a repo-controlled pattern. + + Returns ``True``/``False`` on a decisive match, or ``None`` (fail closed) on + a timeout (catastrophic backtracking), a compile error, or over-long input — + so a malicious/pathological runner pattern can never stall sync. + """ + if pattern is None or text is None: + return None + if len(pattern) > _REGEX_MAX_PATTERN_LEN or len(text) > _REGEX_MAX_TEXT_LEN: + return None + if not _HAVE_REDOS_REGEX: + # No wall-clock-timeout engine available. A SHORT catastrophic pattern + # (e.g. ``(a+)+$``) well under the length caps can still backtrack + # exponentially in text length, so the caps alone do NOT bound stdlib + # ``re``. Repo-controlled patterns are hosted/issue-influenced, so fail + # closed rather than evaluate an untrusted regex without a timeout + # (ReDoS; Codex review, PR #1998). ``regex`` is declared directly in + # pyproject so this fallback is not reached in a normal install. + return None + try: + return _redos_regex.search( + pattern, text, timeout=_REGEX_MATCH_TIMEOUT_S + ) is not None + except (re.error, ValueError, RecursionError, TimeoutError): + return None + except Exception: # pylint: disable=broad-except (regex.TimeoutError etc.) + return None + + +def _glob_matches(glob: str, posix_path: str) -> Optional[bool]: + """Best-effort: does *posix_path* match jest ``testMatch`` *glob*? + + Returns ``True``/``False`` when the glob is translatable and matched within + the ReDoS timeout, or ``None`` when it is not (negation, compile failure, or + a timeout) so the caller stays conservative rather than guessing. + """ + # Reject an oversized RAW glob BEFORE translation (round 8): a multi-megabyte + # repo-controlled pattern would otherwise be fully scanned/expanded by + # ``_micromatch_to_regex`` outside the regex timeout and aggregate deadline. + if glob is None or len(glob) > _REGEX_MAX_PATTERN_LEN: + return None + return _safe_regex_search(_micromatch_to_regex(glob), posix_path) + + +def _sub_root_dir(value: str, root_dir: Path) -> str: + """Substitute jest's ```` token with the effective root directory.""" + return value.replace("", str(root_dir)) + + +def _resolve_config_roots( + config: Mapping[str, Any], config_dir: Path +) -> tuple[list[Path], Path]: + """Resolve jest's effective ``rootDir`` and ``roots`` (absolute paths). + + *config_dir* is the ```` base. Returns ``(roots, root_dir)``; + ``roots`` defaults to ``[root_dir]`` when none are configured. + """ + root_dir = config_dir + root_dir_value = config.get("rootDir") + if isinstance(root_dir_value, str) and root_dir_value: + try: + root_dir = (config_dir / root_dir_value).resolve() + except (OSError, RuntimeError, ValueError): + root_dir = config_dir + roots: list[Path] = [] + for r in _as_str_list(config.get("roots")): + try: + # jest resolves a non-absolute ``roots`` entry against the EFFECTIVE + # rootDir (path.resolve(rootDir, entry)), NOT the config directory + # (issue #1903 review round 5). ```` substitution yields an + # absolute path that is used as-is. + sub = _sub_root_dir(r, root_dir) + p = Path(sub) + roots.append((p if p.is_absolute() else (root_dir / sub)).resolve()) + except (OSError, RuntimeError, ValueError): + continue + return (roots or [root_dir]), root_dir + + +_GLOB_WILDCARD_CHARS = frozenset("*?[]{}()+@!") + + +def _testmatch_literal_dir(glob: str, root_dir: Path) -> Optional[Path]: + """Literal directory prefix of a ``testMatch`` glob before its first wildcard. + + ``/test/**/*.test.ts`` -> ``/test``. Returns ``None`` when the + pattern has no fixed directory anchor. Used to derive a collected write + location for a centralized (non-co-located) test layout (issue #1903 §A). + """ + if glob is None or len(glob) > _REGEX_MAX_PATTERN_LEN: + return None # round 8: bound preprocessing of an oversized raw pattern + sub = _sub_root_dir(glob, root_dir) + idx = next((i for i, c in enumerate(sub) if c in _GLOB_WILDCARD_CHARS), len(sub)) + slash = sub.rfind("/", 0, idx) + if slash <= 0: + return None + dir_str = sub[:slash] + try: + directory = Path(dir_str) + if not directory.is_absolute(): + directory = root_dir / dir_str + return directory.resolve() + except (OSError, RuntimeError, ValueError): + return None + + +def _module_rel_dir( + module_path: Path, root_dir: Path, config_dir: Path +) -> Optional[Path]: + """The module's directory relative to the effective rootDir (or config dir). + + Preserved under a centralized test anchor so a same-stem module in a + different source directory does not collide onto one shared test path. + Returns ``None`` when the module sits directly at the base (no relative + component) or cannot be related to either base. + """ + parent = module_path.parent + for base in (root_dir, config_dir): + try: + rel = parent.relative_to(base) + except (ValueError, TypeError): + continue + return None if str(rel) in ("", ".") else rel + return None + + +def _greenfield_candidate_paths( + module_path: Path, config: Mapping[str, Any], config_dir: Optional[Path] +) -> list[Path]: + """Ordered first-test candidates, biased by config conventions. + + Co-located placements beside the module come first (the preferred layout). + When the config declares ``roots``/``rootDir`` or a ``testMatch`` with a + fixed directory prefix (a CENTRALIZED layout), also derive candidates UNDER + those collected directories so a project whose runner does not collect + co-located tests still gets a runner-collected path instead of a runner-blind + ``tests/`` shadow (issue #1903 §A). ``_candidate_is_collected`` decides which + survive; ordering only sets preference. + """ + stem = module_path.stem + ext = module_path.suffix + # Bound the hint scan (round 8): only a fixed number of patterns, each a + # bounded prefix, so a hostile multi-megabyte ``testMatch`` cannot turn this + # substring probe into unbounded preprocessing work. + _hint_pats = ( + _as_str_list(config.get("testMatch")) + _as_str_list(config.get("testRegex")) + )[:_MAX_RUNNER_PATTERNS] + hint = " ".join(p[:_REGEX_MAX_PATTERN_LEN] for p in _hint_pats).lower() + + if "spec" in hint and "test" not in hint: + infixes = [".spec", ".test"] + else: + infixes = [".test", ".spec"] + + if "__tests__" in hint and "__test__" not in hint: + subdirs = ["__tests__", "__test__", ""] + else: + subdirs = ["__test__", "__tests__", ""] + + def _variants(base: Path) -> list[Path]: + out: list[Path] = [] + for infix in infixes: + for sub in subdirs: + anchored = base / sub if sub else base + out.append(anchored / f"{stem}{infix}{ext}") + return out + + candidates: list[Path] = list(_variants(module_path.parent)) + + if config_dir is not None: + roots, root_dir = _resolve_config_roots(config, config_dir) + # Mirror the module's directory (relative to the effective rootDir) under + # each centralized anchor so DISTINCT modules that share a stem + # (``src/a/util.ts`` vs ``src/b/util.ts``) map to DISTINCT collected test + # paths — never onto one shared file that a second sync would overwrite + # (issue #1903 "never fork, never overwrite"). + rel_dir = _module_rel_dir(module_path, root_dir, config_dir) + anchor_dirs: list[Path] = [] + for glob in _as_str_list(config.get("testMatch")): + literal = _testmatch_literal_dir(glob, root_dir) + if literal is not None: + anchor_dirs.append(literal) + if config.get("roots"): + anchor_dirs.extend(roots) + # An explicit rootDir centralizes collection under it — a module OUTSIDE + # rootDir has no collected co-located path, so derive one under rootDir. + if isinstance(config.get("rootDir"), str) and config.get("rootDir"): + anchor_dirs.append(root_dir) + seen = {str(c) for c in candidates} + for anchor in anchor_dirs: + base = anchor / rel_dir if rel_dir is not None else anchor + for cand in _variants(base): + key = str(cand) + if key not in seen: + candidates.append(cand) + seen.add(key) + return candidates + + +def _candidate_is_collected( + candidate: Path, + config: Mapping[str, Any], + config_dir: Optional[Path], + deadline: Optional[float] = None, +) -> Optional[bool]: + """Would the runner collect a test written at *candidate*? + + Enforces the config knobs issue #1903 §A names — ``roots``/``rootDir`` + (containment), ``testPathIgnorePatterns`` (exclusion), and + ``testMatch``/``testRegex`` (collection patterns). Returns ``True`` when + collected, ``False`` when provably NOT collected (so the caller falls back to + the derived path rather than emit an uncollected test), or ``None`` when the + config gives no verdict (no relevant keys, or unparseable patterns) so the + caller keeps the default-convention behavior. When *deadline* (a + ``time.monotonic`` value) is passed and already exceeded, fails CLOSED + (``False``) so aggregate regex work stays bounded (issue #1903 review r6). + """ + if config_dir is None: + return None + if deadline is not None and time.monotonic() > deadline: + return False + posix = candidate.as_posix() + resolved_roots, root_dir = _resolve_config_roots(config, config_dir) + + # roots / rootDir containment. + if config.get("roots") or ( + isinstance(config.get("rootDir"), str) and config.get("rootDir") + ): + if not any(_contained_in_root(candidate, root) for root in resolved_roots): + return False + + # testPathIgnorePatterns exclusion (repo-controlled regex over the path, + # ReDoS-bounded). A match excludes; an UNEVALUABLE ignore pattern could also + # exclude, so fail closed to "not collected" either way. + for pat in _as_str_list(config.get("testPathIgnorePatterns")): + if _safe_regex_search(_sub_root_dir(pat, root_dir), posix) is not False: + return False + + # testMatch / testRegex collection patterns. + match_globs = _as_str_list(config.get("testMatch")) + test_regexes = _as_str_list(config.get("testRegex")) + has_explicit = ( + config.get("testMatch") is not None or config.get("testRegex") is not None + ) + if not has_explicit: + # DEFAULT discovery: no explicit collection constraint -> no verdict; the + # caller applies the dialect-aware default-extension gate and default + # convention. + return None + # jest rejects configuring BOTH testMatch and testRegex -> fail closed. + if match_globs and test_regexes: + return False + # An EXPLICIT but empty collection set (``testMatch: []`` / ``testRegex: []``) + # matches NOTHING in jest -> not collected here (issue #1903 review round 5). + if not match_globs and not test_regexes: + return False + + # EXPLICIT patterns are present (default discovery already returned above). + # From here NEVER return ``None`` (which would mark the candidate a + # default-convention fallback): an explicit-rule miss or an unevaluable rule + # must FAIL CLOSED to ``False`` so the caller refuses rather than emit an + # uncollected path (issue #1903 review round 3). + # + # jest/micromatch apply ``testMatch`` SEQUENTIALLY: a positive glob includes, + # a leading-``!`` glob EXCLUDES, and a LATER match overrides an earlier one + # (negative-then-positive re-includes, positive-then-negative excludes). + included: Optional[bool] = None + for glob in match_globs: + if deadline is not None and time.monotonic() > deadline: + return False # aggregate budget exhausted mid-candidate -> fail closed + g = _sub_root_dir(glob, root_dir) + negated = g.startswith("!") + matched = _glob_matches(g[1:] if negated else g, posix) + if matched is None: + return False # cannot evaluate a rule whose position matters -> closed + if matched: + included = not negated + for rgx in test_regexes: + if deadline is not None and time.monotonic() > deadline: + return False + res = _safe_regex_search(_sub_root_dir(rgx, root_dir), posix) + if res is None: + return False # unevaluable / timed-out regex -> fail closed + if res: + included = True + return bool(included) # True = collected; explicit miss / excluded -> False + + +def _project_uses_vitest(module_path: Path, root_resolved: Path) -> bool: + """True when the detected runner is vitest (vs jest). + + Walks module dir -> root looking for a ``vitest.config.*``/``vite.config.*`` + file or a ``package.json`` that declares a ``vitest`` dependency / inline + ``vitest`` config block. Used only to widen the DEFAULT-discovery extension + set (vitest's default ``include`` collects ``.mjs``/``.cjs``; jest's does + not). Total — any error yields ``False`` (treat as jest, the stricter set). + """ + try: + current = module_path.parent.resolve() + except (OSError, RuntimeError): + return False + root_s = str(root_resolved) + while True: + for stem in ("vitest.config", "vite.config"): + for ext in (".js", ".ts", ".mjs", ".cjs", ".mts", ".cts"): + if (current / f"{stem}{ext}").is_file(): + return True + pkg = current / "package.json" + if pkg.is_file(): + try: + data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore")) + except (OSError, ValueError): + data = None + if isinstance(data, Mapping): + if isinstance(data.get("vitest"), Mapping): + return True + for dep_key in ("dependencies", "devDependencies", + "peerDependencies", "optionalDependencies"): + deps = data.get(dep_key) + if isinstance(deps, Mapping) and "vitest" in deps: + return True + if str(current) == root_s or not _contained_in_root(current, root_resolved): + break + parent = current.parent + if parent == current: + break + current = parent + return False + + +def _semver_clause_min_major(clause: str) -> Optional[int]: + """The guaranteed-minimum major of ONE AND-clause (space-separated + comparators), or ``None`` when the clause has NO lower bound (only ``<``/ + ``<=``/``*``). Round 10: conservative — an upper-bound-only or wildcard clause + admits arbitrarily low majors.""" + tokens = clause.split() + if "-" in tokens: # hyphen range "A - B": the lower bound is A + idx = tokens.index("-") + if idx > 0: + m = re.search(r"(\d+)", tokens[idx - 1]) + return int(m.group(1)) if m else None + return None + lower: Optional[int] = None + for tok in tokens: + t = tok.strip() + if not t or t in ("*", "x", "X", "latest"): + continue + if t.startswith("<"): + continue # upper bound, not a lower bound + mm = re.match(r"^(?:\^|~|>=|>|=|v)?\s*(\d+)", t) + if mm: + maj = int(mm.group(1)) + lower = maj if lower is None else max(lower, maj) + return lower + + +def _semver_range_min_major(range_str: str) -> int: + """The guaranteed-minimum MAJOR across a full npm range (``||`` union), i.e. + the lowest major any satisfying install could have (round 10). ``0`` when the + range cannot guarantee a floor (``"<30"``, ``"*"``, ``"latest"``, ``""``, or a + ``||`` alternative with no lower bound), so the caller enables version-gated + defaults ONLY when the WHOLE range is provably >= the required major.""" + if not range_str or not range_str.strip(): + return 0 + mins: list[int] = [] + for alt in range_str.split("||"): + cm = _semver_clause_min_major(alt) + if cm is None: + return 0 # an alternative admits arbitrarily low majors -> no floor + mins.append(cm) + return min(mins) if mins else 0 + + +def _detected_jest_major(module_path: Path, root_resolved: Path) -> Optional[int]: + """The GUARANTEED-MINIMUM declared jest major from the nearest package.json, + or ``None`` when no jest dependency is declared. Parses the full npm range + conservatively (round 10): ``"<30"`` -> 0, ``"^30 || ^29"`` -> 29, ``"^30"`` + -> 30 — so a caller enabling jest-30-only discovery never certifies an + uncollected test on a range that could install jest <=29. Total.""" + try: + current = module_path.parent.resolve() + except (OSError, RuntimeError): + return None + root_s = str(root_resolved) + while True: + pkg = current / "package.json" + if pkg.is_file(): + try: + data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore")) + except (OSError, ValueError): + data = None + if isinstance(data, Mapping): + for dep_key in ("dependencies", "devDependencies", + "peerDependencies", "optionalDependencies"): + deps = data.get(dep_key) + if isinstance(deps, Mapping) and isinstance(deps.get("jest"), str): + return _semver_range_min_major(deps["jest"]) + if str(current) == root_s or not _contained_in_root(current, root_resolved): + break + parent = current.parent + if parent == current: + break + current = parent + return None + + +_DEFAULT_RUNNER_IGNORED_SEGMENTS = frozenset({"node_modules"}) + + +def _has_default_ignored_segment(candidate: Path, root_resolved: Path) -> bool: + """True when *candidate* lies under a directory BOTH jest and vitest exclude + from default discovery (``node_modules``) — round 10. Checked on the path + RELATIVE to the project root so a root that legitimately sits under such a + name does not force-refuse everything.""" + try: + rel = candidate.resolve().relative_to(root_resolved) + except (ValueError, OSError, RuntimeError): + return False + return any(seg in _DEFAULT_RUNNER_IGNORED_SEGMENTS for seg in rel.parts) + + +def find_runner_collected_test_path(code_file: str | Path) -> Optional[Path]: + """Greenfield runner-aware first-test path for a NEW JS/TS module (#1903 §A). + + When no co-located test yet exists (:func:`find_collocated_test` returns + ``None``) but the project configures a jest/vitest runner, return the + co-located path the runner will actually collect so the FIRST generated test + lands where ``npm test`` looks instead of a runner-blind root ``tests/`` + shadow (the primary false-green in issue #1903). The write location honors + JSON-readable config: ``testMatch``/``testRegex`` pick the ``.test``/``.spec`` + + ``__test__``/``__tests__`` convention, and ``roots``/``rootDir``/ + ``testPathIgnorePatterns`` are enforced so a custom layout never yields an + UNCOLLECTED test — if no candidate would be collected, return ``None`` (fall + back to the derived path). For a CENTRALIZED layout (``roots``/``rootDir`` or + a ``testMatch`` with a fixed directory prefix) a collected path UNDER the + configured directory is derived instead of a co-located one, so the test + still lands where the runner looks. When the runner is configured only by a + JS file (``jest.config.js``, unparseable in Python): if that file's text + references NO discovery key the jest-default convention + ``__test__/{stem}.test.{ext}`` is used (jest's default ``testMatch`` collects + it); if it DOES customize discovery (``testMatch``/``roots``/... we cannot + parse) we conservatively return ``None`` rather than claim an unproven path is + collected. Python keeps its pytest-idiomatic ``tests/`` default (unchanged). + Any other language, or no detectable runner, yields ``None``. + + The module path is caller/issue-influenced, so it flows through + :func:`_validated_project_path` (CWE-022) before any filesystem use and the + returned write-target is re-asserted in-root. Never raises: any error path + returns ``None`` (no greenfield adoption — safe fallback to the derived path). + """ + try: + root_resolved = Path.cwd().resolve() + module_path = _validated_project_path(code_file, root=root_resolved) + if module_path is None: + return None + if module_path.suffix.lower() not in _JS_TS_EXTENSIONS: + return None + if not _project_uses_js_test_runner(module_path, root_resolved): + return None + + config, config_dir, opaque_custom = _collect_js_runner_config( + module_path, root_resolved + ) + # An unparseable JS/TS config that customizes discovery: we cannot prove + # where tests are collected, so refuse to write a possibly-uncollected + # test (fall back to the derived path) rather than guess the default. + if opaque_custom: + return None + # A parseable config that composes discovery in ways we do not resolve + # (jest ``projects`` multi-project, or a vitest ``test``/``include``/ + # ``exclude`` block) — refuse rather than risk certifying an uncollected + # path. + if isinstance(config, Mapping) and any( + config.get(k) is not None + for k in ("projects", "workspace", "include", "exclude") + ): + return None + # Bound AGGREGATE discovery work (issue #1903 review round 5): candidates + # x repo-controlled patterns, each with a ReDoS timeout, is O(P^2); a + # hostile config with a huge pattern/root count could still stall an + # issue-driven sync for minutes. Refuse when the declared pattern/root + # count is implausibly large rather than spend the budget. + if isinstance(config, Mapping): + declared_patterns = sum( + len(_as_str_list(config.get(k))) + for k in ("testMatch", "testRegex", "testPathIgnorePatterns", "roots") + ) + if declared_patterns > _MAX_RUNNER_PATTERNS: + return None + # DEFAULT-convention safety: when the config declares no explicit + # ``testMatch``/``testRegex`` (so placement relies on the runner's + # default discovery), only a module whose extension the DEFAULT collects + # is eligible. jest's default collects js/jsx/ts/tsx; vitest's default + # ``include`` ALSO collects mjs/cjs — so widen the set for a vitest + # project (issue #1903 review round 3). + has_explicit_match = isinstance(config, Mapping) and ( + config.get("testMatch") is not None or config.get("testRegex") is not None + ) + default_exts = (".js", ".jsx", ".ts", ".tsx") + # vitest's default ``include`` collects mjs/cjs; jest's default collects + # them only from v30+ (v30 added `[mc]` to the default testMatch). Widen + # the default set accordingly; an UNKNOWN jest version stays strict + # (refuse mjs/cjs) since jest <=29 would not collect them (#1903 review). + # Widen to mjs/cjs ONLY when the ACTIVE runner unambiguously collects + # them: jest>=30 (any project), OR vitest with NO jest present. A project + # with jest<=29 AND a vitest dependency is AMBIGUOUS (a jest run would not + # collect .mjs) -> fail closed, refuse (issue #1903 review round 6). + jest_major = _detected_jest_major(module_path, root_resolved) + allow_mc = (jest_major is not None and jest_major >= 30) or ( + jest_major is None and _project_uses_vitest(module_path, root_resolved) + ) + if allow_mc: + default_exts = default_exts + (".mjs", ".cjs") + if not has_explicit_match and module_path.suffix.lower() not in default_exts: + return None + candidates = _greenfield_candidate_paths(module_path, config, config_dir)[ + :_MAX_GREENFIELD_CANDIDATES + ] + + # Single wall-clock budget for the whole discovery call so an adversarial + # config of many near-timeout patterns cannot accumulate minutes of work + # (issue #1903 review round 6). On exhaustion, fail closed. + deadline = time.monotonic() + _DISCOVERY_TOTAL_BUDGET_S + fallback_default: Optional[Path] = None + for candidate in candidates: + resolved = _validated_project_path(candidate, root=root_resolved) + if resolved is None or not _contained_in_root(resolved, root_resolved): + continue + if resolved == module_path: + continue + # Default runner ignore (round 10): both jest (default + # ``testPathIgnorePatterns: ["/node_modules/"]``) and vitest (default + # ``exclude`` includes ``**/node_modules/**``) never collect a test + # under ``node_modules`` — so a co-located candidate there would be a + # false-green even when the filename convention matches. + if _has_default_ignored_segment(resolved, root_resolved): + continue + if time.monotonic() > deadline: + return fallback_default # budget exhausted -> stop, best-so-far + verdict = _candidate_is_collected(resolved, config, config_dir, deadline) + if verdict is True: + return resolved + if verdict is False: + continue + # verdict is None (no config verdict): remember the first valid + # default-convention candidate but keep looking for a proven match. + if fallback_default is None: + fallback_default = resolved + return fallback_default + except Exception: # pylint: disable=broad-except + return None + + +def _existing_collocated_is_collected( + module_path: Path, sibling: Path, root_resolved: Path +) -> Optional[bool]: + """Would the configured runner COLLECT an EXISTING co-located *sibling*? + + Adoption (issue #1903 §B) must not blindly retarget onto a co-located file the + project's runner does not actually collect — e.g. a stale + ``src/__test__/page.test.ts`` under a Jest ``testMatch: ["qa/**/*.spec.ts"]`` + — which would just preserve the false-green on an EXCLUDED file (review round + 9). Returns ``True`` (collected), ``False`` (a configured runner PROVABLY + excludes it), or ``None`` (no evaluable JS/TS runner config — Python, no + runner, or an opaque/composed config we cannot resolve) in which case the + caller keeps the default adopt-by-convention behavior. Never raises. + """ + try: + if module_path.suffix.lower() not in _JS_TS_EXTENSIONS: + return None + if not _project_uses_js_test_runner(module_path, root_resolved): + return None + config, config_dir, opaque_custom = _collect_js_runner_config( + module_path, root_resolved + ) + if opaque_custom or not isinstance(config, Mapping): + return None # cannot parse -> cannot prove exclusion -> adopt + if any( + config.get(k) is not None + for k in ("projects", "workspace", "include", "exclude") + ): + return None # composed discovery we do not resolve -> adopt + deadline = time.monotonic() + _DISCOVERY_TOTAL_BUDGET_S + return _candidate_is_collected(sibling, config, config_dir, deadline) + except Exception: # pylint: disable=broad-except + return None + + +def resolve_test_output_path( + code_file: str | Path, + derived_test_path: str | Path, + *, + user_pinned: bool, +) -> Path: + """Adopt an existing co-located test as the canonical test path (issue #1903). + + PDD derives its test-output path from ``.pddrc`` / defaults, blind to the + project's real test runner, so on a jest/Next.js project it maintains a + ``tests/`` shadow while the co-located test the runner collects goes stale + (a false-green). When a single unambiguous co-located test exists, return + it so generate/change/sync target the real file instead. When NO co-located + test exists yet but the project configures a jest/vitest runner (greenfield, + issue #1903 §A), return the co-located path the runner will collect + (``{module_dir}/__test__/{stem}.test.{ext}``) so the FIRST test lands where + the runner looks — never a root ``tests/`` orphan. + + Args: + code_file: The module under test. + derived_test_path: The test path PDD derived from config/defaults. + user_pinned: ``True`` when the user explicitly pinned the path (CLI + ``--output`` or an explicit ``.pddrc test_output_path``); such + paths are returned unchanged. + + Returns: + The adopted co-located test when one is found and differs from the + derived path, otherwise *derived_test_path* unchanged. Never raises. + """ + derived = Path(derived_test_path) + if user_pinned: + return derived + try: + matches = _collocated_test_matches(code_file) + if len(matches) != 1: + # ZERO existing co-located tests -> greenfield-eligible. AMBIGUOUS + # (>1) is NOT greenfield: writing a first test there would fork a + # THIRD file next to the existing ones. Only true greenfield (zero) + # consults runner discovery; ambiguous falls back to the derived path + # (issue #1903 review round 3 — adoption never fires when >1 exists). + if matches: # >1 existing co-located tests -> do not fork + return derived + # Greenfield (issue #1903 §A): no existing co-located test. If the + # project configures a JS/TS runner, write the FIRST test where the + # runner actually collects it rather than the runner-blind derived + # ``tests/`` shadow. No runner detected (or Python) -> derived. + greenfield = find_runner_collected_test_path(code_file) + if greenfield is None: + return derived + root_resolved = Path.cwd().resolve() + derived_resolved = _validated_project_path(derived, root=root_resolved) + if derived_resolved is not None and greenfield == derived_resolved: + return derived + return greenfield + # Exactly one existing co-located test -> adopt it. + sibling = matches[0] + root_resolved = Path.cwd().resolve() + sibling_resolved = _validated_project_path(sibling, root=root_resolved) + if sibling_resolved is None: + return derived + # Defense in depth (CWE-022): find_collocated_test already rejects + # candidates outside the working tree, but re-assert containment at + # the adoption sink — the returned path becomes a generated-test + # WRITE target downstream. + if not _contained_in_root(sibling_resolved, root_resolved): + return derived + derived_resolved = _validated_project_path(derived, root=root_resolved) + if derived_resolved is not None and sibling_resolved == derived_resolved: + return derived + # Round 9: only adopt a sibling the runner ACTUALLY collects. When a + # configured JS/TS runner PROVABLY excludes it (custom testMatch/roots the + # sibling does not match), adopting it would perpetuate the false-green on + # an excluded file. Redirect to the runner-collected location instead + # (greenfield discovery); if none can be proven, fall back to the derived + # default rather than certify the excluded file. + module_path = _validated_project_path(code_file, root=root_resolved) + if ( + module_path is not None + and _existing_collocated_is_collected( + module_path, sibling_resolved, root_resolved + ) + is False + ): + greenfield = find_runner_collected_test_path(code_file) + if greenfield is not None and greenfield != sibling_resolved: + if derived_resolved is not None and greenfield == derived_resolved: + return derived + return greenfield + return derived + return sibling_resolved + except Exception: # pylint: disable=broad-except + return derived + + +def was_test_adopted( + code_file: str | Path, + resolved_test_path: str | Path, + derived_test_path: str | Path, + *, + user_pinned: bool, +) -> bool: + """True when *resolved_test_path* is an EXISTING co-located test PDD ADOPTED. + + This is the structured provenance the issue #1903 §B.4 never-block requires: + it must fire ONLY for a co-located test PDD adopted from a HUMAN's existing + file — NOT a user-pinned path and NOT a greenfield test PDD is creating for + the first time. It is True only when ALL hold: the location was NOT + ``user_pinned``; a single existing co-located sibling was found at resolution + time (``find_collocated_test`` is not ``None`` — greenfield finds none yet); + and the resolved path actually differs from the derived default (adoption + genuinely happened, not a fall-through to the runner-blind shadow). + + MUST be evaluated at PATH-RESOLUTION time (before generation overwrites the + file), because after generation the greenfield-created and human-adopted + cases both exist on disk and become indistinguishable by presence. Total — + any error yields ``False`` (conservative: no never-block). + """ + try: + if user_pinned: + return False + sibling = find_collocated_test(code_file) + if sibling is None: + return False + if Path(resolved_test_path) == Path(derived_test_path): + return False + # Round 9: adoption means the resolved path IS that existing human + # sibling — NOT a greenfield/runner-collected redirect AWAY from a sibling + # the runner excludes (``resolve_test_output_path`` now redirects such + # excluded siblings). A redirect target is a fresh PDD-created file, not a + # human test, so it must never reach the never-block. + root_resolved = Path.cwd().resolve() + resolved_here = _validated_project_path(resolved_test_path, root=root_resolved) + sibling_here = _validated_project_path(sibling, root=root_resolved) + if ( + resolved_here is None + or sibling_here is None + or resolved_here != sibling_here + ): + return False + # Ownership provenance (issue #1903 review round 7): a test PDD itself + # GREENFIELD-created on a prior run must NOT be reclassified as + # human-adopted just because it now exists as a single co-located sibling. + if is_pdd_created_test(resolved_test_path): + return False + return True + except Exception: # pylint: disable=broad-except + return False + + +# Manifest of tests PDD GREENFIELD-created itself (issue #1903 review round 7), +# so a later run never mistakes a PDD-owned co-located test for a human-adopted +# one when deciding the churn never-block. Repo-relative POSIX paths. Lives under +# ``.pdd/meta/`` — PDD's TRACKED sync-metadata directory (committed alongside the +# per-module fingerprints) — NOT the top-level ``.pdd/`` which projects routinely +# gitignore. This durability matters: a fresh checkout or the durable runner's +# fresh module worktree must still see PDD's ownership, else a PDD-created test +# is re-read as human-adopted and can improperly reach the never-block (round 8). +_PDD_CREATED_TESTS_MANIFEST = Path(".pdd") / "meta" / "pdd_created_tests.json" +# Legacy location (round 7) read as a fallback so ownership recorded before the +# move is not lost on the first post-upgrade run. +_PDD_CREATED_TESTS_MANIFEST_LEGACY = Path(".pdd") / "pdd_created_tests.json" + + +@contextmanager +def _interprocess_lock(lock_path: Path): + """Best-effort EXCLUSIVE interprocess lock over *lock_path* (round 9). + + Uses ``fcntl.flock`` where available (POSIX) so concurrent ``pdd sync`` + children serialize their manifest read-modify-write. On a platform without + ``fcntl`` (e.g. Windows) it degrades to a no-op — the atomic ``os.replace`` + still prevents a torn file, only losing strict last-writer-wins ordering. + """ + fd = None + try: + try: + import fcntl # POSIX only + except ImportError: + fcntl = None # type: ignore[assignment] + if fcntl is not None: + fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644) + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + if fd is not None: + try: + import fcntl # noqa: F811 + + fcntl.flock(fd, fcntl.LOCK_UN) + except Exception: # pylint: disable=broad-except + pass + try: + os.close(fd) + except OSError: + pass + + +def _pdd_created_tests_manifest_path() -> Path: + return Path.cwd() / _PDD_CREATED_TESTS_MANIFEST + + +def _load_pdd_created_tests() -> set: + result: set = set() + for rel in (_PDD_CREATED_TESTS_MANIFEST, _PDD_CREATED_TESTS_MANIFEST_LEGACY): + try: + data = json.loads((Path.cwd() / rel).read_text(encoding="utf-8")) + if isinstance(data, list): + result.update(str(p) for p in data if isinstance(p, str)) + except (OSError, ValueError): + continue + return result + + +def _test_repo_relative(test_path: str | Path) -> Optional[str]: + """Repo-relative POSIX form of *test_path* (in-root), or ``None``.""" + try: + root = Path.cwd().resolve() + p = Path(test_path) + resolved = (p if p.is_absolute() else (root / p)).resolve() + return resolved.relative_to(root).as_posix() + except (OSError, RuntimeError, ValueError): + return None + + +def record_pdd_created_test(test_path: str | Path) -> None: + """Record that PDD GREENFIELD-created the test at *test_path* (#1903 §A/§B.4). + + Callers invoke this ONLY when PDD writes a brand-new greenfield test (no + pre-existing human file), so :func:`was_test_adopted` can later exclude it + from the human-adopted never-block. + + Parallel agentic-sync CHILD PROCESSES can record concurrently, so the whole + read-modify-write runs under an interprocess EXCLUSIVE file lock and commits + via a temp file + atomic ``os.replace`` (round 9): an unlocked RMW would let + two children read the same set and clobber each other's additions, dropping an + ownership record so a later run misreads a PDD-owned test as human-adopted. + Total — any error is swallowed. + """ + rel = _test_repo_relative(test_path) + if rel is None: + return + manifest = _pdd_created_tests_manifest_path() + try: + manifest.parent.mkdir(parents=True, exist_ok=True) + except OSError: + return + lock_path = manifest.with_suffix(manifest.suffix + ".lock") + try: + with _interprocess_lock(lock_path): + existing = _load_pdd_created_tests() # merges legacy under the lock + if rel in existing: + return + existing.add(rel) + payload = json.dumps(sorted(existing), indent=2) + "\n" + tmp = manifest.with_suffix(manifest.suffix + f".tmp.{os.getpid()}") + try: + tmp.write_text(payload, encoding="utf-8") + os.replace(tmp, manifest) # atomic within the same directory + finally: + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass + except (OSError, ValueError): + pass + + +def is_pdd_created_test(test_path: str | Path) -> bool: + """True when *test_path* is recorded as a PDD greenfield-created test. Total.""" + rel = _test_repo_relative(test_path) + return rel is not None and rel in _load_pdd_created_tests() + + +def _pins_test_output_location(config: Mapping[str, Any]) -> bool: + """Return True when *config* explicitly pins the test-output location (#1903). + + Recognizes both the flat ``test_output_path`` key and the Issue #237 + template form ``outputs.test.path``. It MUST be fed a RAW ``.pddrc`` context + ``defaults`` block (where these keys appear only when explicitly configured), + NEVER the ``resolved_config`` that ``construct_paths`` returns: that config + has a generated-default ``test_output_path`` injected back into it, so it is + ALWAYS present and would read as pinned even for a default derivation. Use + :func:`configured_test_output_pinned` to obtain the right raw defaults for a + file. Never raises. + """ + try: + # Presence (not truthiness): an explicitly configured `test_output_path: ""` + # (root-level test output) or `outputs.test.path: ""` is still an explicit + # user pin. Only a genuinely ABSENT key (or an explicit null) means + # "default derivation" and is eligible for co-located adoption (#1903). + if config.get("test_output_path") is not None: + return True + outputs = config.get("outputs") + if isinstance(outputs, Mapping): + test_cfg = outputs.get("test") + if isinstance(test_cfg, Mapping) and test_cfg.get("path") is not None: + return True + return False + except Exception: # pylint: disable=broad-except + return False + + +def configured_test_output_pinned( + target_file: str | Path, + *, + context_override: Optional[str] = None, + search_from: Optional[str | Path] = None, +) -> bool: + """True when the test-output location is user-pinned for *target_file* (#1903). + + Reads the explicit-vs-default provenance from the SAME authoritative source + the path derivation is configured by — the RAW ``.pddrc`` context ``defaults`` + (``test_output_path`` / ``outputs.test.path``) and the ``PDD_TEST_OUTPUT_PATH`` + env var — and NEVER from ``construct_paths``' ``resolved_config`` (which injects + a generated-default ``test_output_path`` and so always reads as pinned). + + The context is resolved the way ``construct_paths`` resolves it: an explicit + *context_override* wins; otherwise the context that owns *target_file* is + detected from its path (matching ``.pddrc`` ``prompts_dir`` / ``paths``), and + when nothing matches the ``default`` context's defaults apply. This keeps the + pin signal aligned with the derived path even when the owning context is + selected by a ``prompts_dir`` prefix rather than the bare basename/CWD. + + Args: + target_file: The file whose owning context is inspected — the prompt file + for sync/change derivation, or the code file for ``pdd test``. + context_override: An explicit context name, when the caller already + resolved one (bypasses path-based detection). + search_from: Directory to locate the nearest ``.pddrc`` from (defaults to + *target_file*'s parent). Sync passes its ``prompts_root`` anchor so a + nested subproject finds its own ``.pddrc``. + + Returns: + True when the location is explicitly pinned, else False. Never raises. + """ + try: + # Presence (not truthiness): an explicitly exported `PDD_TEST_OUTPUT_PATH=` + # (even empty — a root-level pin) is an explicit user choice, consistent with + # the `.pddrc test_output_path: ""` handling in `_pins_test_output_location`. + # Only a genuinely UNSET env var (None) is default-eligible (#1903). + if os.environ.get("PDD_TEST_OUTPUT_PATH") is not None: + return True + # Lazy import: mirrors the repo's other construct_paths lookups from here + # and avoids any import cycle at module load. + from .construct_paths import ( # pylint: disable=import-outside-toplevel + _find_pddrc_file, + _load_pddrc_config, + detect_context_for_file, + ) + start = Path(search_from) if search_from else Path(target_file).parent + pddrc_path = _find_pddrc_file(start) + if not pddrc_path: + return False + config = _load_pddrc_config(pddrc_path) + contexts = config.get("contexts", {}) + context_name = context_override + if not context_name: + context_name, _ = detect_context_for_file( + str(target_file), repo_root=str(pddrc_path.parent) + ) + # `.pddrc`'s `default` context is the fallback when no context matches + # (construct_paths applies it too), so a project that keeps all config in + # `default` is still recognized as pinned. + if not context_name and "default" in contexts: + context_name = "default" + defaults = contexts.get(context_name or "", {}).get("defaults", {}) + return _pins_test_output_location(defaults) + except Exception: # pylint: disable=broad-except + return False + + def discover_sibling_patch_targets(file_path: str | Path) -> set[str]: """Names patched on this module in sibling tests (e.g. ``fetch_data``).""" path = Path(file_path) diff --git a/pdd/core/cli.py b/pdd/core/cli.py index b387b684f..5d8d8dfc0 100644 --- a/pdd/core/cli.py +++ b/pdd/core/cli.py @@ -698,6 +698,16 @@ def cli( # Reset per-run error buffer and store core_dump flag clear_core_dump_errors() + # One CLI invocation is one provider-health epoch. Permanent provider + # failures remain visible to every agentic step (and its child processes) + # in this command, but cannot leak into a later CliRunner/server-style + # invocation hosted by the same Python process. + from ..agentic_common import provider_failure_scope + + provider_scope = provider_failure_scope() + provider_scope.__enter__() + ctx.call_on_close(lambda: provider_scope.__exit__(None, None, None)) + ctx.ensure_object(dict) ctx.obj["force"] = force if force: @@ -870,7 +880,7 @@ def _restore_estimate_env(_snapshot=_estimate_env_snapshot): pass # Warn users who have not completed interactive setup unless they are running it now - if not estimate_mode and _should_show_onboarding_reminder(ctx): + if not estimate_mode and not json_mode and _should_show_onboarding_reminder(ctx): console.print( "[warning]Complete onboarding with `pdd setup` to install tab completion and configure API keys.[/warning]" ) diff --git a/pdd/drift_main.py b/pdd/drift_main.py index af8dc2d09..14b5ea64e 100644 --- a/pdd/drift_main.py +++ b/pdd/drift_main.py @@ -1,4 +1,5 @@ """Regeneration stability checks for PDD dev units (``pdd checkup drift``).""" + from __future__ import annotations import ast @@ -13,7 +14,7 @@ from pathlib import Path from typing import Any, Optional -from .evidence_store import ManifestView, resolve_prompt_path +from .evidence_store import ManifestView try: from .evidence_manifest import resolve_test_output_paths @@ -24,7 +25,10 @@ from .gate_main import run_gate_policy as _run_gate_policy_impl _GATE_POLICY_AVAILABLE = True -except (ModuleNotFoundError, ImportError): # pragma: no cover - gate optional on this branch +except ( + ModuleNotFoundError, + ImportError, +): # pragma: no cover - gate optional on this branch _GATE_POLICY_AVAILABLE = False def _run_gate_policy_impl(*_args, **_kwargs): @@ -33,6 +37,7 @@ class _Result: return _Result() + DEFAULT_MAX_COST_USD = 20.0 _COST_RE = re.compile(r"(?:Total\s+)?Cost:\s*\$([0-9]+(?:\.[0-9]+)?)", re.IGNORECASE) _POLICY_VALIDATION_KEYS = ("policy", "gate", "checkup_gate", "policy_gate") @@ -42,6 +47,14 @@ class _Result: ) +class DriftInputError(FileNotFoundError): + """A stable, user-actionable failure while resolving drift inputs.""" + + def __init__(self, code: str, message: str): + self.code = code + super().__init__(message) + + @dataclass class RunSnapshot: run_index: int @@ -141,8 +154,29 @@ def _public_api(path: Path) -> list[str]: return sorted(names) -def _resolve_code_path(prompt_path: Path, project_root: Path) -> Path: - stem = prompt_path.stem.replace("_python", "").replace("_typescript", "") +def _prompt_identity(prompt_path: Path) -> tuple[str, str]: + """Return ``(basename, language)`` from a PDD prompt filename.""" + if prompt_path.suffix.lower() != ".prompt": + raise DriftInputError( + "drift_input_invalid", + f"Prompt path must end in .prompt: {prompt_path}", + ) + basename, separator, language = prompt_path.stem.rpartition("_") + if not separator or not basename or not language: + raise DriftInputError( + "drift_input_invalid", + f"Prompt filename must use _.prompt: {prompt_path.name}", + ) + return basename, language.lower() + + +def _resolve_code_path( + prompt_path: Path, + project_root: Path, + *, + basename: Optional[str] = None, +) -> Path: + stem = basename or _prompt_identity(prompt_path)[0] candidates = [ project_root / "pdd" / f"{stem}.py", project_root / "src" / f"{stem}.py", @@ -156,6 +190,242 @@ def _resolve_code_path(prompt_path: Path, project_root: Path) -> Path: ) +def _resolve_project_file( + raw_path: str | Path, + project_root: Path, + *, + label: str, +) -> Path: + """Resolve an existing file without allowing symlink or ``..`` escape.""" + root = project_root.resolve() + path = Path(raw_path) + candidate = path if path.is_absolute() else root / path + try: + resolved = candidate.resolve() + resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise DriftInputError( + "drift_input_outside_project", + f"{label} must resolve inside project root {root}: {raw_path}", + ) from exc + if not resolved.is_file(): + raise DriftInputError( + "drift_input_not_found", + f"{label} not found: {raw_path}", + ) + return resolved + + +def _active_prompt_roots(project_root: Path) -> list[Path]: + """Return contained prompt roots declared by the active project config.""" + from .construct_paths import _find_pddrc_file, _load_pddrc_config + + root = project_root.resolve() + candidates = [root / "prompts"] + pddrc_path = _find_pddrc_file(root) + if pddrc_path: + try: + config = _load_pddrc_config(pddrc_path) + except ValueError as exc: + raise DriftInputError( + "drift_input_invalid", + f"Could not load active .pddrc: {exc}", + ) from exc + contexts = config.get("contexts", {}) + if isinstance(contexts, dict): + for context in contexts.values(): + if not isinstance(context, dict): + continue + defaults = context.get("defaults", {}) + if not isinstance(defaults, dict): + continue + configured = defaults.get("prompts_dir") + if isinstance(configured, str) and configured.strip(): + candidates.append(pddrc_path.parent / configured) + + contained: list[tuple[Path, Path]] = [] + for candidate in candidates: + try: + resolved = candidate.resolve() + resolved.relative_to(root) + except (OSError, RuntimeError, ValueError): + continue + if not resolved.is_dir(): + continue + contained.append((candidate, resolved)) + + # Scan each physical tree once. A canonical ``prompts/`` root commonly + # contains several context-specific roots such as ``prompts/services``. + roots: list[Path] = [] + resolved_roots: list[Path] = [] + for candidate, resolved in sorted(contained, key=lambda item: len(item[1].parts)): + if any(resolved.is_relative_to(parent) for parent in resolved_roots): + continue + resolved_roots.append(resolved) + roots.append(candidate) + return roots + + +def _path_shaped_devunit(devunit: str) -> bool: + return ( + Path(devunit).is_absolute() + or "/" in devunit + or "\\" in devunit + or devunit.lower().endswith(".prompt") + ) + + +def _normalized_devunit_parts(devunit: str) -> tuple[str, ...]: + if devunit != devunit.strip() or "\\" in devunit: + raise DriftInputError( + "drift_input_invalid", + f"Invalid dev unit name: {devunit!r}", + ) + parts = tuple(devunit.split("/")) + if not parts or any(part in {"", ".", ".."} for part in parts): + raise DriftInputError( + "drift_input_invalid", + f"Invalid dev unit name: {devunit!r}", + ) + return parts + + +def _candidate_matches_devunit( + candidate: Path, + prompt_root: Path, + devunit_parts: tuple[str, ...], +) -> bool: + try: + relative = candidate.relative_to(prompt_root) + except ValueError: + return False + basename, _language = _prompt_identity(candidate) + identity = (*relative.parent.parts, basename) + if len(devunit_parts) == 1: + return basename.casefold() == devunit_parts[0].casefold() + if len(identity) < len(devunit_parts): + return False + return tuple(part.casefold() for part in identity[-len(devunit_parts) :]) == tuple( + part.casefold() for part in devunit_parts + ) + + +def _owning_prompt_root(prompt_path: Path, roots: list[Path]) -> Path: + owners: list[Path] = [] + for root in roots: + try: + prompt_path.relative_to(root.resolve()) + except ValueError: + continue + owners.append(root) + if not owners: + return prompt_path.parent + return min(owners, key=lambda path: len(path.resolve().parts)) + + +def _resolve_prompt_input( + devunit: str, + project_root: Path, +) -> tuple[Path, Path]: + """Resolve explicit prompt paths or unambiguous basenames in active roots.""" + roots = _active_prompt_roots(project_root) + if _path_shaped_devunit(devunit) and devunit.lower().endswith(".prompt"): + prompt = _resolve_project_file(devunit, project_root, label="Prompt file") + _prompt_identity(prompt) + return prompt, _owning_prompt_root(prompt, roots) + if _path_shaped_devunit(devunit) and Path(devunit).is_absolute(): + _resolve_project_file(devunit, project_root, label="Prompt file") + raise DriftInputError( + "drift_input_invalid", + f"Prompt path must end in .prompt: {devunit}", + ) + + devunit_parts = _normalized_devunit_parts(devunit) + matches: dict[Path, tuple[Path, Path]] = {} + project_root_resolved = project_root.resolve() + for prompt_root in roots: + for candidate in prompt_root.rglob("*.prompt"): + if not candidate.is_file(): + continue + try: + resolved = candidate.resolve() + resolved.relative_to(project_root_resolved) + if not _candidate_matches_devunit( + candidate, prompt_root, devunit_parts + ): + continue + except (DriftInputError, OSError, RuntimeError, ValueError): + continue + matches.setdefault(resolved, (candidate, prompt_root)) + + if not matches: + raise DriftInputError( + "drift_input_not_found", + f"Could not resolve prompt for dev unit {devunit!r}", + ) + if len(matches) > 1: + choices = sorted( + candidate.relative_to(project_root).as_posix() + for candidate, _prompt_root in matches.values() + ) + rendered = ", ".join(choices) + raise DriftInputError( + "drift_input_ambiguous", + f"Ambiguous prompt for dev unit {devunit!r}; use an explicit path: {rendered}", + ) + resolved, (_candidate, prompt_root) = next(iter(matches.items())) + return resolved, prompt_root + + +def _derive_code_from_prompt( + prompt_path: Path, + prompt_root: Path, + project_root: Path, +) -> Path: + """Use the shared PDD path resolver, then retain the flat legacy fallback.""" + basename, language = _prompt_identity(prompt_path) + try: + relative_prompt = prompt_path.relative_to(prompt_root.resolve()) + except ValueError as exc: + raise DriftInputError( + "drift_input_outside_project", + f"Prompt file is not contained by its active prompt root: {prompt_path}", + ) from exc + qualified_basename = (relative_prompt.parent / basename).as_posix() + try: + from .sync_determine_operation import AmbiguousModuleError, get_pdd_file_paths + + paths = get_pdd_file_paths( + qualified_basename, + language, + prompts_dir=str(prompt_root), + ) + resolved_prompt = paths.get("prompt") + if ( + resolved_prompt is not None + and resolved_prompt.resolve() != prompt_path.resolve() + ): + raise DriftInputError( + "drift_input_ambiguous", + "Resolved code belongs to a different prompt; pass --code-file: " + f"{resolved_prompt}", + ) + resolved = paths.get("code") + if resolved is not None and resolved.is_file(): + try: + return _resolve_project_file(resolved, project_root, label="Code file") + except DriftInputError: + pass + except AmbiguousModuleError as exc: + raise DriftInputError("drift_input_ambiguous", str(exc)) from exc + if qualified_basename != basename: + raise DriftInputError( + "drift_input_not_found", + f"Could not locate generated code for {prompt_path.name}; pass --code-file", + ) + return _resolve_code_path(prompt_path, project_root, basename=basename) + + def _path_score(path: Path, *, expected_stem: str, devunit: str) -> int: score = 0 if path.is_file(): @@ -196,36 +466,64 @@ def _select_manifest_output( return None -def _load_manifest_paths( +def _resolve_drift_inputs( devunit: str, project_root: Path, from_evidence: Optional[Path], + code_file: Optional[Path], ) -> tuple[Path, Path, Optional[ManifestView]]: - if from_evidence is None: - latest = project_root / ".pdd" / "evidence" / "devunits" / f"{devunit}.latest.json" + """Resolve the authoritative prompt/code pair for one drift invocation.""" + root = project_root.resolve() + if from_evidence is None and not _path_shaped_devunit(devunit): + latest = ( + project_root / ".pdd" / "evidence" / "devunits" / f"{devunit}.latest.json" + ) if latest.is_file(): from_evidence = latest - if from_evidence is None or not from_evidence.is_file(): - prompt = resolve_prompt_path(project_root, devunit) - if prompt is None: - raise FileNotFoundError(f"Could not resolve prompt for dev unit {devunit!r}") - return prompt, _resolve_code_path(prompt, project_root), None - - manifest = ManifestView.from_file(from_evidence.resolve(), project_root) - prompt = manifest.prompt_path or resolve_prompt_path(project_root, devunit, manifest.raw) - if prompt is None: - raise FileNotFoundError(f"Evidence manifest missing prompt path: {from_evidence}") - - expected_stem = prompt.stem.replace("_python", "").replace("_typescript", "") - picked = _select_manifest_output( - manifest, - project_root=project_root, - expected_stem=expected_stem, - devunit=devunit, - ) - if picked is not None: - return prompt, picked, manifest - return prompt, _resolve_code_path(prompt, project_root), manifest + manifest = None + if from_evidence is not None: + if not from_evidence.is_file(): + raise DriftInputError( + "drift_input_not_found", + f"Evidence manifest not found: {from_evidence}", + ) + try: + manifest = ManifestView.from_file(from_evidence.resolve(), root) + except (OSError, TypeError, ValueError) as exc: + raise DriftInputError( + "drift_input_invalid", + f"Could not load evidence manifest {from_evidence}: {exc}", + ) from exc + + roots = _active_prompt_roots(root) + if manifest is not None and manifest.prompt_path is not None: + prompt = _resolve_project_file( + manifest.prompt_path, + root, + label="Evidence prompt file", + ) + prompt_root = _owning_prompt_root(prompt, roots) + else: + prompt, prompt_root = _resolve_prompt_input(devunit, root) + + # An explicit code path is authoritative. Do not derive or validate a + # manifest/default output first: that ordering was the core of issue #2001. + if code_file is not None: + code = _resolve_project_file(code_file, root, label="Code file") + return prompt, code, manifest + + if manifest is not None: + basename, _language = _prompt_identity(prompt) + picked = _select_manifest_output( + manifest, + project_root=root, + expected_stem=basename, + devunit=devunit, + ) + if picked is not None: + code = _resolve_project_file(picked, root, label="Evidence code file") + return prompt, code, manifest + return prompt, _derive_code_from_prompt(prompt, prompt_root, root), manifest def _parse_cost_usd(stdout: str, stderr: str) -> float: @@ -458,7 +756,9 @@ def _run_pytest_for_candidate( return completed.returncode == 0 -def _validation_key_configured(manifest: Optional[ManifestView], keys: tuple[str, ...]) -> bool: +def _validation_key_configured( + manifest: Optional[ManifestView], keys: tuple[str, ...] +) -> bool: if manifest is None: return False for key in keys: @@ -641,12 +941,12 @@ def run_drift( dry_run: bool = False, max_cost_usd: Optional[float] = None, ) -> DriftReport: - prompt_path, resolved_code, manifest = _load_manifest_paths( + prompt_path, code_path, manifest = _resolve_drift_inputs( devunit, project_root, from_evidence, + code_file, ) - code_path = code_file.resolve() if code_file else resolved_code if not code_path.is_file(): raise FileNotFoundError(f"Code file not found: {code_path}") @@ -729,9 +1029,10 @@ def run_drift( public_api_unchanged = bool(candidate_apis) and all( api == baseline_api for api in candidate_apis ) - implementation_changed = any(h != baseline_hash for h in candidate_hashes) or len( - set(candidate_hashes) - ) > 1 + implementation_changed = ( + any(h != baseline_hash for h in candidate_hashes) + or len(set(candidate_hashes)) > 1 + ) behavior_unchanged = all( snap.tests_passed and snap.stories_passed diff --git a/pdd/durable_sync_runner.py b/pdd/durable_sync_runner.py index 4cfa8dc1b..c8ed209d8 100644 --- a/pdd/durable_sync_runner.py +++ b/pdd/durable_sync_runner.py @@ -8,6 +8,8 @@ # pylint: disable=too-few-public-methods from __future__ import annotations +import base64 +import binascii import os import re import shutil @@ -28,6 +30,10 @@ CHECKPOINT_TRAILER = "PDD-Sync-Checkpoint-V1" CHECKPOINT_AUTHOR_NAME = "PDD Durable Sync" CHECKPOINT_AUTHOR_EMAIL = "pdd-durable-sync@example.invalid" +# Shared (non-module-prefixed) greenfield-ownership manifest under .pdd/meta +# (issue #1903 §B.4 round 10). Kept in lockstep with +# content_selector._PDD_CREATED_TESTS_MANIFEST. +_OWNERSHIP_MANIFEST_NAME = "pdd_created_tests.json" class DurableSyncRunner(AsyncSyncRunner): @@ -140,6 +146,11 @@ def _prepare_durable_branch(self) -> Tuple[bool, str]: for basename in self.basenames: if basename in completed: self.module_states[basename].status = "success" + # Restore the adopted-test "needs review" note (round 8) so a + # resumed run still surfaces it in the PR comment and summary. + review = completed[basename] + if review: + self.module_states[basename].needs_review = review self._resumed_modules.append(basename) self._prepared = True @@ -426,6 +437,14 @@ def _force_add_module_metadata(self, basename: str, module_worktree: Path) -> No for meta_dir in meta_dirs: if meta_dir.exists(): paths.extend(sorted(meta_dir.glob(f"{safe}_*.json"))) + # Issue #1903 §B.4 (round 10): the SHARED greenfield-ownership + # manifest lives in .pdd/meta and is tracked, but it is NOT + # module-prefixed — force-add it too so a PDD-created co-located + # test's ownership travels on the durable branch and survives a + # fresh-worktree resume (else it is misread as human-adopted). + manifest = meta_dir / _OWNERSHIP_MANIFEST_NAME + if manifest.is_file(): + paths.append(manifest) if not paths: return rel_paths = [str(path.relative_to(module_worktree)) for path in paths] @@ -453,7 +472,11 @@ def _unsafe_staged_paths(self, basename: str, paths: List[str]) -> List[str]: ) if matching_meta_prefix: meta_name = Path(normalized).name - if not meta_name.startswith(f"{safe}_") or not meta_name.endswith(".json"): + # The shared ownership manifest is a legitimate tracked + # non-module-prefixed .pdd/meta file (round 10) — allow it. + if meta_name == _OWNERSHIP_MANIFEST_NAME: + pass + elif not meta_name.startswith(f"{safe}_") or not meta_name.endswith(".json"): unsafe.append(path) else: unsafe.append(path) @@ -629,23 +652,38 @@ def _push_unpushed_local_checkpoints(self) -> Tuple[bool, str]: return False, message return True, "" - def _read_checkpointed_modules(self) -> Set[str]: + def _read_checkpointed_modules(self) -> Dict[str, Optional[str]]: + """Map each checkpointed module (for this issue) to its ``needs_review`` + note (or ``None``). The newest trailer per module wins so a re-checkpoint + that cleared/added the flag is honored on resume (round 8).""" log = self._git(["log", "--format=%B"], cwd=self.durable_worktree_path) if log.returncode != 0: - return set() + return {} - completed: Set[str] = set() - for line in log.stdout.splitlines(): + completed: Dict[str, Optional[str]] = {} + for line in log.stdout.splitlines(): # git log is newest-first parsed = _parse_checkpoint_trailer(line) if not parsed: continue - issue, module = parsed - if issue == self.issue_number: - completed.add(module) + issue, module, review = parsed + if issue == self.issue_number and module not in completed: + completed[module] = review return completed def _checkpoint_trailer(self, basename: str) -> str: - return f"{CHECKPOINT_TRAILER}: issue={self.issue_number} module={basename}" + trailer = f"{CHECKPOINT_TRAILER}: issue={self.issue_number} module={basename}" + # Carry an issue #1903 §B.4 "needs review" note into the durable + # checkpoint (round 8): durable resume rebuilds module state from these + # trailers only, so without this a resumed module loses its adopted-test + # review warning and coverage loss would be silently swallowed. The note + # is prose with spaces; trailer values are whitespace-free, so base64url + # encode it (no padding — restored on parse). + state = self.module_states.get(basename) + review = getattr(state, "needs_review", None) if state is not None else None + if review: + enc = base64.urlsafe_b64encode(review.encode("utf-8")).decode("ascii").rstrip("=") + trailer += f" review={enc}" + return trailer def _ensure_clean_durable_worktree(self) -> Tuple[bool, str]: status = self._git(["status", "--porcelain"], cwd=self.durable_worktree_path) @@ -787,7 +825,7 @@ def _git( ) -def _parse_checkpoint_trailer(line: str) -> Optional[Tuple[int, str]]: +def _parse_checkpoint_trailer(line: str) -> Optional[Tuple[int, str, Optional[str]]]: prefix = f"{CHECKPOINT_TRAILER}:" stripped = line.strip() if not stripped.startswith(prefix): @@ -801,7 +839,15 @@ def _parse_checkpoint_trailer(line: str) -> Optional[Tuple[int, str]]: module = fields.get("module") if not module: return None - return issue, module + review: Optional[str] = None + enc = fields.get("review") + if enc: + try: + pad = "=" * (-len(enc) % 4) + review = base64.urlsafe_b64decode(enc + pad).decode("utf-8") + except (binascii.Error, ValueError, UnicodeDecodeError): + review = None + return issue, module, review def _slugify_basename(basename: str) -> str: diff --git a/pdd/evidence_manifest.py b/pdd/evidence_manifest.py index 629ddf26d..45e0d88ba 100644 --- a/pdd/evidence_manifest.py +++ b/pdd/evidence_manifest.py @@ -466,7 +466,27 @@ def resolve_test_output_paths( # pylint: disable=too-many-arguments context_override=context_override, ) resolved = output_file_paths.get("output") - return [str(resolved)] if resolved else [] + if not resolved: + return [] + # Issue #1903: mirror cmd_test_main's co-located-test adoption so the evidence + # manifest records the path PDD actually writes/runs (the adopted co-located + # test), not the runner-blind derived shadow. Pin detection matches + # cmd_test_main: explicit `output`, or a raw-`.pddrc`/env pin for the module's + # context (prompt-side, honoring `context_override`). Lazy import avoids a cycle. + from .content_selector import ( # pylint: disable=import-outside-toplevel + configured_test_output_pinned, + resolve_test_output_path, + ) + pin_target = str(prompt_file) if prompt_file else str(code_file) + user_pinned = output is not None or configured_test_output_pinned( + pin_target, + context_override=context_override, + search_from=Path(pin_target).parent if pin_target else None, + ) + resolved = str( + resolve_test_output_path(code_file, resolved, user_pinned=user_pinned) + ) + return [resolved] def _safe_slug(value: str) -> str: diff --git a/pdd/fingerprint_transaction.py b/pdd/fingerprint_transaction.py new file mode 100644 index 000000000..51463477e --- /dev/null +++ b/pdd/fingerprint_transaction.py @@ -0,0 +1,246 @@ +"""Transactional fingerprint finalization. + +Every successful fingerprint write is prepared and validated here. Callers may +either commit immediately or provide the sync orchestrator's atomic-state buffer; +both paths use the same payload construction and null-hash checks. + +``os.replace`` is atomic on POSIX when source and destination share a filesystem. +Windows has weaker replacement guarantees when the destination already exists. +""" +from __future__ import annotations + +import logging +from dataclasses import asdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Mapping, Optional + +from . import __version__ +from .json_atomic import atomic_write_json +from .operation_log import get_fingerprint_path +from .sync_determine_operation import ( + Fingerprint, + calculate_current_hashes, + get_pdd_file_paths, + read_fingerprint, +) + +logger = logging.getLogger(__name__) + + +class FingerprintFinalizeError(RuntimeError): + """Raised when a fingerprint cannot be finalized safely.""" + + def __init__(self, operation: str, fingerprint_path: Path, cause: object): + self.operation = operation + self.fingerprint_path = Path(fingerprint_path) + self.cause = cause + super().__init__( + f"[{operation}] fingerprint finalization failed for " + f"{self.fingerprint_path}: {cause}" + ) + + +def _coerce_paths(paths: Mapping[str, Any]) -> Dict[str, Any]: + """Normalize path hints without changing their caller-selected scope.""" + normalized: Dict[str, Any] = {} + for key, value in paths.items(): + if key == "test_files": + if value is None: + normalized[key] = [] + else: + normalized[key] = [ + item if isinstance(item, Path) else Path(item) + for item in value + ] + elif value is None or isinstance(value, Path): + normalized[key] = value + else: + normalized[key] = Path(value) + return normalized + + +class FingerprintTransaction: + """Commit a complete fingerprint on clean context-manager exit. + + ``atomic_state`` is the sync orchestrator's optional metadata buffer. It is + deliberately duck-typed so this leaf module never imports the orchestrator. + """ + + def __init__( + self, + basename: str, + language: str, + operation: str, + paths: Optional[Dict[str, Path]] = None, + cost: float = 0.0, + model: str = "", + *, + atomic_state: Optional[Any] = None, + ) -> None: + self._basename = basename + self._language = language.lower() + self._operation = operation + self._cost = float(cost or 0.0) + self._model = model + self._atomic_state = atomic_state + self._skipped = False + self._skip_reason: Optional[str] = None + self._include_deps_override: Optional[Dict[str, str]] = None + + fallback_path = ( + Path(".pdd") + / "meta" + / f"{basename.replace('/', '_')}_{self._language}.json" + ) + try: + resolved_paths: Mapping[str, Any] + if paths: + # Preserve issue #983: explicit paths are authoritative and must + # not be replaced by CWD-based discovery. + resolved_paths = paths + else: + resolved_paths = get_pdd_file_paths(basename, self._language) + self._paths = _coerce_paths(resolved_paths) + self._fingerprint_path = get_fingerprint_path( + basename, + self._language, + paths=self._paths, + ) + except Exception as exc: + raise FingerprintFinalizeError( + operation, + fallback_path, + f"path resolution failed: {exc}", + ) from exc + + @property + def fingerprint_path(self) -> Path: + """Return the eagerly resolved destination path.""" + return self._fingerprint_path + + def __enter__(self) -> "FingerprintTransaction": + return self + + def skip(self, reason: str) -> None: + """Suppress finalization for an intentional non-mutating path.""" + self._skipped = True + self._skip_reason = str(reason) + logger.info( + "FingerprintTransaction.skip for %s/%s (%s): %s", + self._basename, + self._language, + self._operation, + self._skip_reason, + ) + + def set_include_deps_override(self, deps: Dict[str, str]) -> None: + """Use a pre-mutation include graph for hashing and persistence.""" + self._include_deps_override = dict(deps) + + def _validate_hashes(self, current_hashes: Mapping[str, Any]) -> None: + """Reject fingerprints that cannot represent their existing inputs.""" + if current_hashes.get("prompt_hash") is None: + raise FingerprintFinalizeError( + self._operation, + self._fingerprint_path, + "prompt_hash is null", + ) + + for key in ("code", "example", "test"): + value = self._paths.get(key) + if isinstance(value, Path) and value.exists(): + hash_field = f"{key}_hash" + if current_hashes.get(hash_field) is None: + raise FingerprintFinalizeError( + self._operation, + self._fingerprint_path, + f"{hash_field} is null for existing path {value}", + ) + + expected_tests = self._paths.get("test_files") or [] + actual_tests = current_hashes.get("test_files") or {} + for test_path in expected_tests: + if isinstance(test_path, Path) and test_path.exists(): + if actual_tests.get(test_path.name) is None: + raise FingerprintFinalizeError( + self._operation, + self._fingerprint_path, + f"test_files hash is null for existing path {test_path}", + ) + + def _build_payload(self) -> Dict[str, Any]: + previous = read_fingerprint( + self._basename, + self._language, + paths=self._paths, + ) + stored_deps = ( + self._include_deps_override + if self._include_deps_override is not None + else (previous.include_deps if previous else None) + ) + current_hashes = calculate_current_hashes( + self._paths, + stored_include_deps=stored_deps, + ) + self._validate_hashes(current_hashes) + + include_deps = ( + self._include_deps_override + if self._include_deps_override is not None + else current_hashes.get("include_deps") + ) + fingerprint = Fingerprint( + pdd_version=__version__, + timestamp=datetime.now(timezone.utc).isoformat(), + command=self._operation, + prompt_hash=current_hashes.get("prompt_hash"), + code_hash=current_hashes.get("code_hash"), + example_hash=current_hashes.get("example_hash"), + test_hash=current_hashes.get("test_hash"), + test_files=current_hashes.get("test_files"), + include_deps=include_deps, + ) + return asdict(fingerprint) + + def _commit(self) -> None: + try: + payload = self._build_payload() + if self._atomic_state is not None: + setter = getattr(self._atomic_state, "set_fingerprint", None) + if not callable(setter): + raise TypeError("atomic_state does not provide set_fingerprint()") + setter( + payload, + self._fingerprint_path, + operation=self._operation, + ) + else: + atomic_write_json(self._fingerprint_path, payload) + except FingerprintFinalizeError: + raise + except Exception as exc: + raise FingerprintFinalizeError( + self._operation, + self._fingerprint_path, + exc, + ) from exc + + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + if exc_type is not None: + return False + if self._skipped: + logger.debug( + "Fingerprint commit suppressed for %s/%s (%s): %s", + self._basename, + self._language, + self._operation, + self._skip_reason, + ) + return False + self._commit() + return False + + +__all__ = ["FingerprintFinalizeError", "FingerprintTransaction"] diff --git a/pdd/fix_main.py b/pdd/fix_main.py index 2e53615f5..c9d1b66bd 100644 --- a/pdd/fix_main.py +++ b/pdd/fix_main.py @@ -24,6 +24,12 @@ from .get_jwt_token import get_jwt_token from .get_language import get_language from .core.cloud import CloudConfig, get_cloud_timeout, get_cloud_request_timeout +from .mock_contract_validation import ( + MockContractDivergenceError, + enforce_mock_contracts, + format_mock_contract_report, + validate_mock_contracts, +) # Import DEFAULT_STRENGTH from the package from . import DEFAULT_STRENGTH @@ -525,6 +531,44 @@ def fix_main( # No changes suggested by LLM success = False + # A passing test run is necessary but not sufficient when the fix also + # changed a mock. Compare prospective code/test contents with the + # repository's real schema before either file is persisted. Baseline + # contents make the check diff-aware, so unrelated legacy queries do + # not become new failures merely because this command touched the file. + if success: + final_test_content = ( + input_strings["unit_test_file"] + if protect_tests or _local_focused_slices or _fix_focused_slices + else (fixed_unit_test or input_strings["unit_test_file"]) + ) + final_code_content = fixed_code or input_strings["code_file"] + mock_contract_report = validate_mock_contracts( + # Use the cwd string so tests that patch this module's ``Path`` + # for output-file assertions cannot replace the validator root. + project_root=os.getcwd(), + production_sources={ + output_file_paths["output_code"]: final_code_content, + }, + test_sources={ + output_file_paths["output_test"]: final_test_content, + }, + baseline_production_sources={ + code_file: input_strings["code_file"], + }, + baseline_test_sources={ + unit_test_file: input_strings["unit_test_file"], + }, + ) + if mock_contract_report.status == "inconclusive" and not ctx.obj.get( + "quiet", False + ): + rprint( + "[bold yellow]Mock-contract validation inconclusive:[/bold yellow] " + + format_mock_contract_report(mock_contract_report) + ) + enforce_mock_contracts(mock_contract_report) + # Save fixed files # Skip test write when focused repair was used: the LLM only saw a # slice of the test file, so writing fixed_unit_test would truncate @@ -739,6 +783,28 @@ def fix_main( except click.UsageError: # Re-raise UsageError for proper CLI handling (e.g., cloud auth failures, insufficient credits) raise + except MockContractDivergenceError as divergence_error: + # A green test suite backed by a schema-divergent mock is a hard fix + # failure. Let the Click boundary produce a non-zero exit and prevent + # the operation decorator from writing a success fingerprint. Loop mode + # writes each candidate to the source paths while testing it, so restore + # the exact pre-fix contents before surfacing the contract failure. + if loop: + rollback_errors = [] + for input_path, input_key in ( + (code_file, "code_file"), + (unit_test_file, "unit_test_file"), + ): + try: + with open(input_path, "w", encoding="utf-8") as stream: + stream.write(input_strings[input_key]) + except (OSError, KeyError) as rollback_error: + rollback_errors.append(f"{input_path}: {rollback_error}") + if rollback_errors: + divergence_error.add_note( + "Could not fully restore pre-fix files: " + "; ".join(rollback_errors) + ) + raise except Exception as e: if not ctx.obj.get('quiet', False): # Safely handle and print MarkupError diff --git a/pdd/metadata_sync.py b/pdd/metadata_sync.py index 00c92e99d..96242fc60 100644 --- a/pdd/metadata_sync.py +++ b/pdd/metadata_sync.py @@ -17,7 +17,12 @@ update_architecture_from_prompt, ) from .architecture_registry import find_architecture_for_project -from .operation_log import clear_run_report, infer_module_identity, save_fingerprint +from .operation_log import ( + clear_run_report, + infer_module_identity, + resolve_fingerprint_paths, + save_fingerprint, +) _THEME = Theme( @@ -482,12 +487,18 @@ def run_metadata_sync( basename, language = infer_module_identity(prompt_path) if not basename or not language: reason = "could not infer (basename, language) for fingerprint" - result.stages["fingerprint"] = StageStatus(status="skipped", reason=reason) - _stage_log_exit("fingerprint", "skipped", reason) + result.stages["fingerprint"] = StageStatus(status="failed", reason=reason) + _stage_log_exit("fingerprint", "failed", reason) else: paths: Dict[str, Path] = {"prompt": prompt_path} if code_path is not None: paths["code"] = code_path + paths = resolve_fingerprint_paths( + basename, + language, + prompt_path, + paths=paths, + ) detail = f"basename={basename} language={language}" if dry_run: result.stages["fingerprint"] = StageStatus( @@ -534,4 +545,4 @@ def run_metadata_sync( result.stages["fingerprint"] = StageStatus(status="failed", reason=reason) _stage_log_exit("fingerprint", "failed", reason) - return result \ No newline at end of file + return result diff --git a/pdd/mock_contract_validation.py b/pdd/mock_contract_validation.py new file mode 100644 index 000000000..15ebb3059 --- /dev/null +++ b/pdd/mock_contract_validation.py @@ -0,0 +1,913 @@ +"""Validate generated mock data against repository-backed interface contracts. + +The fix workflows already re-run generated tests independently. That proves +the tests execute, but it cannot prove that a mock describes the production +interface correctly. This module closes that gap for Python data lookups by +comparing newly introduced query fields and mock payload fields with explicit +schema documents and corroborating production usages in the repository. + +The validator is deliberately conservative: absence is a failure only when an +authoritative schema section for the exact resource exists. Missing or +conflicting evidence is surfaced as ``inconclusive`` rather than guessed from a +field name. +""" +from __future__ import annotations + +import ast +import json +import re +import subprocess +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping, Optional, Sequence + + +_QUERY_CALLS = {"query_collection", "count_collection"} +_WRITE_CALLS = { + "add_document", + "create_document", + "set_document", + "update_document", +} +_MOCK_FACTORIES = {"AsyncMock", "MagicMock", "Mock", "patch"} +_SCHEMA_SUFFIXES = {".json", ".md", ".markdown"} +_SKIP_DIRS = { + ".git", + ".pdd", + ".tox", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "research", + "staging", + "venv", +} + + +@dataclass(frozen=True) +class QueryFieldUse: + """A concrete field lookup against a named external resource.""" + + resource: str + field_name: str + source_path: str + line: int + callee: str + + +@dataclass(frozen=True) +class MockFieldUse: + """A literal field supplied by a test mock payload.""" + + field_name: str + source_path: str + line: int + target: str + + +@dataclass(frozen=True) +class ContractEvidence: + """Allowed fields proven by a repository contract source.""" + + resource: str + fields: frozenset[str] + source_path: str + line: int + kind: str + + +@dataclass(frozen=True) +class MockContractFinding: + """One query/mock shape that contradicts authoritative evidence.""" + + resource: str + field_name: str + code_path: str + code_line: int + mock_paths: tuple[str, ...] + contract_paths: tuple[str, ...] + message: str + + +@dataclass(frozen=True) +class MockContractReport: + """Structured result consumed by manual and agentic fix workflows.""" + + status: str + findings: tuple[MockContractFinding, ...] = () + warnings: tuple[str, ...] = () + queries: tuple[QueryFieldUse, ...] = () + mock_fields: tuple[MockFieldUse, ...] = () + contracts: tuple[ContractEvidence, ...] = () + + @property + def diverged(self) -> bool: + """Return whether a real contract contradiction was found.""" + return self.status == "diverged" + + +class MockContractDivergenceError(RuntimeError): + """Raised when passing tests rely on a schema-divergent mock.""" + + def __init__(self, report: MockContractReport): + self.report = report + super().__init__(format_mock_contract_report(report)) + + +def _literal_string(node: Optional[ast.AST]) -> Optional[str]: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def _call_name(node: ast.AST) -> str: + parts: list[str] = [] + current = node + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if isinstance(current, ast.Name): + parts.append(current.id) + return ".".join(reversed(parts)) + + +def _keyword(call: ast.Call, name: str) -> Optional[ast.AST]: + for item in call.keywords: + if item.arg == name: + return item.value + return None + + +def _resource_from_collection_chain(node: ast.AST) -> Optional[str]: + """Find ``collection()`` inside a chained Firestore call.""" + for child in ast.walk(node): + if not isinstance(child, ast.Call): + continue + if _call_name(child.func).rsplit(".", maxsplit=1)[-1] != "collection": + continue + resource_node = child.args[0] if child.args else _keyword(child, "collection_name") + resource = _literal_string(resource_node) + if resource: + return resource + return None + + +def _filter_fields(node: Optional[ast.AST]) -> list[tuple[str, int]]: + """Extract literal field names from tuple filters or ``FieldFilter`` calls.""" + if node is None: + return [] + found: list[tuple[str, int]] = [] + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + direct = _literal_string(node.elts[0]) if node.elts else None + if direct and len(node.elts) >= 2: + found.append((direct, getattr(node.elts[0], "lineno", node.lineno))) + else: + for child in node.elts: + found.extend(_filter_fields(child)) + elif isinstance(node, ast.Call): + tail = _call_name(node.func).rsplit(".", maxsplit=1)[-1] + if tail in {"FieldFilter", "where"}: + field_node = node.args[0] if node.args else _keyword(node, "field_path") + value = _literal_string(field_node) + if value: + found.append((value, getattr(field_node, "lineno", node.lineno))) + for child in node.args: + found.extend(_filter_fields(child)) + for item in node.keywords: + found.extend(_filter_fields(item.value)) + return found + + +def extract_query_fields( # pylint: disable=too-many-locals + source: str, + source_path: str = "", +) -> tuple[QueryFieldUse, ...]: + """Return literal external-resource query fields from Python source.""" + try: + tree = ast.parse(source, filename=source_path) + except SyntaxError: + return () + + uses: list[QueryFieldUse] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + callee = _call_name(node.func) + tail = callee.rsplit(".", maxsplit=1)[-1] + resource: Optional[str] = None + fields: list[tuple[str, int]] = [] + + if tail in _QUERY_CALLS: + resource_node = node.args[0] if node.args else _keyword(node, "collection_name") + resource = _literal_string(resource_node) + filters_node = ( + node.args[1] + if len(node.args) > 1 + else _keyword(node, "filters") + ) + fields = _filter_fields(filters_node) + elif tail == "where": + resource = _resource_from_collection_chain(node.func) + field_node = node.args[0] if node.args else _keyword(node, "field_path") + value = _literal_string(field_node) + if value: + fields = [(value, getattr(field_node, "lineno", node.lineno))] + else: + fields = _filter_fields(_keyword(node, "filter")) + elif tail == "filter": + resource = _resource_from_collection_chain(node.func) + filter_node = node.args[0] if node.args else _keyword(node, "filter") + fields = _filter_fields(filter_node) + + if not resource: + continue + for field_name, line in fields: + uses.append( + QueryFieldUse( + resource=resource, + field_name=field_name, + source_path=source_path, + line=line, + callee=callee, + ) + ) + return tuple(uses) + + +def _dict_fields(node: ast.AST) -> list[tuple[str, int]]: + found: list[tuple[str, int]] = [] + for child in ast.walk(node): + if not isinstance(child, ast.Dict): + continue + for key in child.keys: + value = _literal_string(key) + if value: + found.append((value, getattr(key, "lineno", child.lineno))) + return found + + +def _assignment_target(node: ast.AST) -> str: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + prefix = _assignment_target(node.value) + return f"{prefix}.{node.attr}" if prefix else node.attr + return "" + + +def extract_mock_fields( # pylint: disable=too-many-branches,too-many-locals + source: str, + source_path: str = "", +) -> tuple[MockFieldUse, ...]: + """Return literal fields that are actually supplied through mock payloads.""" + try: + tree = ast.parse(source, filename=source_path) + except SyntaxError: + return () + + functions = { + node.name: node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + relevant_functions: set[str] = { + name for name in functions if name.lower().startswith(("fake", "mock")) + } + payload_nodes: list[tuple[ast.AST, str]] = [] + + for node in ast.walk(tree): + if isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + value = node.value + for target_node in targets: + target = _assignment_target(target_node) + lower = target.lower() + if lower.endswith((".return_value", ".side_effect")) or lower.startswith( + ("mock", "fake") + ): + payload_nodes.append((value, target)) + if lower.endswith(".side_effect") and isinstance(value, ast.Name): + relevant_functions.add(value.id) + elif isinstance(node, ast.Call): + callee = _call_name(node.func) + tail = callee.rsplit(".", maxsplit=1)[-1] + if tail not in _MOCK_FACTORIES and not callee.endswith("patch.object"): + continue + for item in node.keywords: + if item.arg in {"return_value", "side_effect"}: + payload_nodes.append((item.value, f"{tail}.{item.arg}")) + if item.arg == "side_effect" and isinstance(item.value, ast.Name): + relevant_functions.add(item.value.id) + + for name in relevant_functions: + function = functions.get(name) + if function is not None: + for node in ast.walk(function): + if isinstance(node, ast.Return) and node.value is not None: + payload_nodes.append((node.value, name)) + + uses: list[MockFieldUse] = [] + seen: set[tuple[str, int, str]] = set() + for payload, target in payload_nodes: + for field_name, line in _dict_fields(payload): + key = (field_name, line, target) + if key in seen: + continue + seen.add(key) + uses.append( + MockFieldUse( + field_name=field_name, + source_path=source_path, + line=line, + target=target, + ) + ) + return tuple(uses) + + +def _markdown_contracts( # pylint: disable=too-many-branches,too-many-locals + path: Path, + text: str, + resource: str, +) -> list[ContractEvidence]: + """Parse indentation-defined fields under an exact ``resource/`` block.""" + lines = text.splitlines() + evidence: list[ContractEvidence] = [] + resource_re = re.compile(rf"^(?P\s*){re.escape(resource)}/\s*$") + field_re = re.compile(r"^(?P\s*)(?P[A-Za-z_][\w-]*)\s*:") + + for index, line in enumerate(lines): + match = resource_re.match(line) + if not match: + continue + root_indent = len(match.group("indent").expandtabs(4)) + candidates: list[tuple[int, str]] = [] + for following in lines[index + 1 :]: + stripped = following.strip() + if stripped.startswith("```"): + break + leading = following[: len(following) - len(following.lstrip(" \t"))] + indent = len(leading.expandtabs(4)) + if stripped and indent <= root_indent: + break + field_match = field_re.match(following) + if field_match: + candidates.append((indent, field_match.group("field"))) + if not candidates: + continue + field_indent = min(indent for indent, _ in candidates) + field_paths: set[str] = set() + parents: list[tuple[int, str]] = [] + for indent, name in candidates: + while parents and indent <= parents[-1][0]: + parents.pop() + if indent == field_indent: + field_path = name + elif parents: + field_path = f"{parents[-1][1]}.{name}" + else: + # A malformed indentation jump cannot establish a safe field + # path, so do not accidentally authorize the nested bare name. + continue + field_paths.add(field_path) + parents.append((indent, field_path)) + fields = frozenset(field_paths) + if fields: + evidence.append( + ContractEvidence( + resource=resource, + fields=fields, + source_path=str(path), + line=index + 1, + kind="schema", + ) + ) + return evidence + + +def _json_property_paths(properties: Mapping[str, Any]) -> frozenset[str]: + """Return top-level and dotted nested field paths from JSON schema fields.""" + found: set[str] = set() + for raw_name, definition in properties.items(): + name = str(raw_name) + found.add(name) + if not isinstance(definition, dict): + continue + nested = definition.get("properties") or definition.get("fields") + if not isinstance(nested, dict): + items = definition.get("items") + if isinstance(items, dict): + nested = items.get("properties") or items.get("fields") + if isinstance(nested, dict): + found.update( + f"{name}.{child}" for child in _json_property_paths(nested) + ) + return frozenset(found) + + +def _json_field_sets(value: Any, resource: str) -> list[frozenset[str]]: + found: list[frozenset[str]] = [] + if isinstance(value, dict): + properties = value.get("properties") + fields = value.get("fields") + identity = str(value.get("title") or value.get("name") or "") + if identity == resource and isinstance(properties, dict): + found.append(_json_property_paths(properties)) + if identity == resource and isinstance(fields, dict): + found.append(_json_property_paths(fields)) + for key, child in value.items(): + if key == resource and isinstance(child, dict): + child_properties = child.get("properties") or child.get("fields") + if isinstance(child_properties, dict): + found.append(_json_property_paths(child_properties)) + found.extend(_json_field_sets(child, resource)) + elif isinstance(value, list): + for child in value: + found.extend(_json_field_sets(child, resource)) + return found + + +def _schema_files(project_root: Path) -> Iterable[Path]: + roots = [project_root / "context", project_root / "docs", project_root / "schemas"] + seen: set[Path] = set() + for path in project_root.glob("*schema*"): + if path.is_file(): + roots.append(path) + for root in roots: + candidates = [root] if root.is_file() else (root.rglob("*") if root.is_dir() else []) + for path in candidates: + if not path.is_file() or path.suffix.lower() not in _SCHEMA_SUFFIXES: + continue + if "schema" not in path.name.lower() and path.parent.name.lower() != "schemas": + continue + if any(part in _SKIP_DIRS for part in path.parts): + continue + resolved = path.resolve() + if resolved in seen: + continue + seen.add(resolved) + try: + if path.stat().st_size > 2_000_000: + continue + except OSError: + continue + yield path + + +def _load_schema_contracts(project_root: Path, resources: set[str]) -> list[ContractEvidence]: + contracts: list[ContractEvidence] = [] + for path in _schema_files(project_root): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + continue + if path.suffix.lower() in {".md", ".markdown"}: + for resource in resources: + if resource in text: + contracts.extend(_markdown_contracts(path, text, resource)) + continue + try: + payload = json.loads(text) + except (json.JSONDecodeError, ValueError): + continue + for resource in resources: + for fields in _json_field_sets(payload, resource): + if fields: + contracts.append( + ContractEvidence( + resource=resource, + fields=fields, + source_path=str(path), + line=1, + kind="schema", + ) + ) + return contracts + + +def _writer_fields(source: str, source_path: str) -> list[tuple[str, str, int]]: + try: + tree = ast.parse(source, filename=source_path) + except SyntaxError: + return [] + found: list[tuple[str, str, int]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + tail = _call_name(node.func).rsplit(".", maxsplit=1)[-1] + if tail not in _WRITE_CALLS: + continue + resource_node = node.args[0] if node.args else _keyword(node, "collection_name") + resource = _literal_string(resource_node) + payload = node.args[2] if len(node.args) > 2 else _keyword(node, "data") + if not resource or payload is None: + continue + for field_name, line in _dict_fields(payload): + found.append((resource, field_name, line)) + return found + + +def _is_test_path(path: Path) -> bool: + name = path.name.lower() + return name.startswith("test_") or ".test." in name or ".spec." in name or any( + part.lower() in {"test", "tests", "__test__", "__tests__"} + for part in path.parts + ) + + +def _source_files(project_root: Path) -> Iterable[Path]: + for path in project_root.rglob("*.py"): + if any(part in _SKIP_DIRS for part in path.parts): + continue + if _is_test_path(path): + continue + try: + if path.stat().st_size > 1_000_000: + continue + except OSError: + continue + yield path + + +def _repository_evidence( # pylint: disable=too-many-locals + project_root: Path, + resources: set[str], + excluded_paths: set[Path], +) -> list[ContractEvidence]: + """Collect corroborating fields from independent production readers/writers.""" + by_resource: dict[str, set[str]] = {resource: set() for resource in resources} + first_source: dict[str, tuple[str, int]] = {} + for path in _source_files(project_root): + try: + resolved = path.resolve() + except OSError: + continue + if resolved in excluded_paths: + continue + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + continue + relevant = {resource for resource in resources if resource in source} + if not relevant: + continue + rel = str(path.relative_to(project_root)) + for use in extract_query_fields(source, rel): + if use.resource in relevant: + by_resource[use.resource].add(use.field_name) + first_source.setdefault(use.resource, (rel, use.line)) + for resource, field_name, line in _writer_fields(source, rel): + if resource in relevant: + by_resource[resource].add(field_name) + first_source.setdefault(resource, (rel, line)) + + evidence: list[ContractEvidence] = [] + for resource, fields in by_resource.items(): + if not fields: + continue + source_path, line = first_source[resource] + evidence.append( + ContractEvidence( + resource=resource, + fields=frozenset(fields), + source_path=source_path, + line=line, + kind="sibling", + ) + ) + return evidence + + +def _normalize_sources(sources: Mapping[str | Path, str]) -> dict[str, str]: + return {str(path): content for path, content in sources.items()} + + +def validate_mock_contracts( # pylint: disable=too-many-locals,too-many-statements + *, + project_root: Path, + production_sources: Mapping[str | Path, str], + test_sources: Mapping[str | Path, str], + baseline_production_sources: Optional[Mapping[str | Path, str]] = None, + baseline_test_sources: Optional[Mapping[str | Path, str]] = None, +) -> MockContractReport: + """Compare new query/mock shapes with repository-backed contracts. + + Baseline maps make the gate diff-aware: pre-existing query/mock pairs are + ignored, while a newly introduced query or newly fabricated mock field is + checked. When baselines are unavailable all supplied pairs are treated as + candidates, which is conservative for resumed workflows. + """ + root = Path(project_root).resolve() + production = _normalize_sources(production_sources) + tests = _normalize_sources(test_sources) + baseline_production = _normalize_sources(baseline_production_sources or {}) + baseline_tests = _normalize_sources(baseline_test_sources or {}) + + queries = tuple( + use + for path, source in production.items() + for use in extract_query_fields(source, path) + ) + mock_fields = tuple( + use + for path, source in tests.items() + for use in extract_mock_fields(source, path) + ) + baseline_query_counts = Counter( + (use.resource, use.field_name) + for path, source in baseline_production.items() + for use in extract_query_fields(source, path) + ) + current_query_counts: Counter[tuple[str, str]] = Counter() + baseline_mock_counts = Counter( + use.field_name + for path, source in baseline_tests.items() + for use in extract_mock_fields(source, path) + ) + current_mock_counts = Counter(use.field_name for use in mock_fields) + new_mock_names = { + name + for name, count in current_mock_counts.items() + if count > baseline_mock_counts[name] + } + test_text = "\n".join(tests.values()) + + candidates: list[QueryFieldUse] = [] + for use in queries: + pair = (use.resource, use.field_name) + current_query_counts[pair] += 1 + new_query = current_query_counts[pair] > baseline_query_counts[pair] + mock_introduced = ( + use.field_name in new_mock_names and use.resource in test_text + ) + if new_query or mock_introduced: + candidates.append(use) + + if not candidates: + return MockContractReport( + status="not_applicable", + queries=queries, + mock_fields=mock_fields, + ) + + resources = {use.resource for use in candidates} + contracts = _load_schema_contracts(root, resources) + excluded: set[Path] = set() + # Exclude both prospective-output and original input paths. Iterative + # ``pdd fix`` tests candidates in place at the original path before this + # gate runs; allowing that file into sibling evidence would let the bad + # candidate certify itself under a second mapping key. + for source_path in (*production, *baseline_production): + path = Path(source_path) + candidate = path if path.is_absolute() else root / path + try: + excluded.add(candidate.resolve()) + except OSError: + pass + # Repository-wide source scanning is the expensive portion of this gate. + # Skip it on the common valid-schema path and only seek sibling evidence + # for resources whose candidate field is not already declared. + schema_fields: dict[str, set[str]] = {resource: set() for resource in resources} + for contract in contracts: + if contract.kind == "schema": + schema_fields[contract.resource].update(contract.fields) + sibling_resources = { + use.resource + for use in candidates + if use.field_name not in schema_fields.get(use.resource, set()) + } + if sibling_resources: + contracts.extend(_repository_evidence(root, sibling_resources, excluded)) + + findings: list[MockContractFinding] = [] + warnings: list[str] = [] + checked: set[tuple[str, str, str, int]] = set() + for use in candidates: + identity = (use.resource, use.field_name, use.source_path, use.line) + if identity in checked: + continue + checked.add(identity) + resource_contracts = [item for item in contracts if item.resource == use.resource] + schema_contracts = [item for item in resource_contracts if item.kind == "schema"] + sibling_contracts = [item for item in resource_contracts if item.kind == "sibling"] + schema_allows = any(use.field_name in item.fields for item in schema_contracts) + sibling_allows = any(use.field_name in item.fields for item in sibling_contracts) + if schema_allows or sibling_allows: + continue + if not schema_contracts: + warnings.append( + f"{use.source_path}:{use.line}: could not verify " + f"{use.resource}.{use.field_name}; no authoritative schema " + "section for that exact resource was found" + ) + continue + + mock_paths = tuple( + sorted( + { + f"{item.source_path}:{item.line}" + for item in mock_fields + if item.field_name == use.field_name + } + ) + ) + contract_paths = tuple( + sorted({f"{item.source_path}:{item.line}" for item in schema_contracts}) + ) + mock_clause = ( + " The passing test fabricates the same field in a mock payload at " + + ", ".join(mock_paths) + + "." + if mock_paths + else "" + ) + findings.append( + MockContractFinding( + resource=use.resource, + field_name=use.field_name, + code_path=use.source_path, + code_line=use.line, + mock_paths=mock_paths, + contract_paths=contract_paths, + message=( + f"Query targets field {use.resource}.{use.field_name}, but " + "that field is absent from the exact repository schema " + f"section.{mock_clause}" + ), + ) + ) + + status = "diverged" if findings else ("inconclusive" if warnings else "clean") + return MockContractReport( + status=status, + findings=tuple(findings), + warnings=tuple(warnings), + queries=queries, + mock_fields=mock_fields, + contracts=tuple(contracts), + ) + + +def _git_source(project_root: Path, ref: str, relative_path: str) -> Optional[str]: + try: + result = subprocess.run( + ["git", "show", f"{ref}:{relative_path}"], + cwd=project_root, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + return result.stdout if result.returncode == 0 else None + + +def validate_changed_files( # pylint: disable=too-many-branches,too-many-locals,too-many-statements + *, + project_root: Path, + changed_files: Sequence[str], + baseline_ref: Optional[str] = None, +) -> MockContractReport: + """Load changed Python code/tests and validate their new mock contracts.""" + root = Path(project_root).resolve() + production: dict[str, str] = {} + tests: dict[str, str] = {} + baseline_production: dict[str, str] = {} + baseline_tests: dict[str, str] = {} + for raw in changed_files: + raw_path = Path(raw) + if raw_path.is_absolute(): + try: + relative = raw_path.resolve().relative_to(root).as_posix() + except (OSError, ValueError): + continue + else: + relative = raw_path.as_posix() + path = root / relative + if path.suffix.lower() != ".py" or not path.is_file(): + continue + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + continue + target = tests if _is_test_path(Path(relative)) else production + target[relative] = source + if baseline_ref: + prior = _git_source(root, baseline_ref, relative) + if prior is not None: + baseline_target = ( + baseline_tests if _is_test_path(Path(relative)) else baseline_production + ) + baseline_target[relative] = prior + + # A generated test can add a fabricated payload for an unchanged production + # query. Such a test-only edit still has to be checked: locate exact query + # uses that match a newly added mock field and are tied to a resource named + # by the changed test. Treat the unchanged reader as its own baseline so + # candidate selection is driven by the new mock, not by legacy code. + current_mock_counts = Counter( + use.field_name + for source_path, source in tests.items() + for use in extract_mock_fields(source, source_path) + ) + baseline_mock_counts = Counter( + use.field_name + for source_path, source in baseline_tests.items() + for use in extract_mock_fields(source, source_path) + ) + new_mock_names = { + name + for name, count in current_mock_counts.items() + if count > baseline_mock_counts[name] + } + if new_mock_names and tests: + test_text = "\n".join(tests.values()) + supplied_paths = { + (root / path).resolve() if not Path(path).is_absolute() else Path(path).resolve() + for path in production + } + for path in _source_files(root): + try: + resolved = path.resolve() + except OSError: + continue + if resolved in supplied_paths: + continue + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + continue + if not any(name in source for name in new_mock_names): + continue + relative = path.relative_to(root).as_posix() + matching_queries = [ + use + for use in extract_query_fields(source, relative) + if use.field_name in new_mock_names and use.resource in test_text + ] + if not matching_queries: + continue + production[relative] = source + baseline_production[relative] = source + + return validate_mock_contracts( + project_root=root, + production_sources=production, + test_sources=tests, + baseline_production_sources=baseline_production, + baseline_test_sources=baseline_tests, + ) + + +def format_mock_contract_report(report: MockContractReport) -> str: + """Render a bounded, evidence-linked workflow diagnostic.""" + if report.status == "not_applicable": + return "Mock-contract validation: not applicable (no new query/mock contract pair)." + if report.status == "clean": + return ( + "Mock-contract validation: clean; queried fields are backed by " + "repository contract evidence." + ) + if report.status == "inconclusive": + details = "\n".join(f"- {warning}" for warning in report.warnings) + return f"Mock-contract validation: inconclusive.\n{details}" + + lines = [ + "MOCK_CONTRACT_DIVERGENCE: passing tests contradict the real interface/schema contract." + ] + for finding in report.findings: + lines.append( + f"- {finding.code_path}:{finding.code_line} " + f"[{finding.resource}.{finding.field_name}] {finding.message}" + ) + if finding.contract_paths: + lines.append(f" Contract evidence: {', '.join(finding.contract_paths)}") + if finding.mock_paths: + lines.append(f" Divergent mock evidence: {', '.join(finding.mock_paths)}") + return "\n".join(lines) + + +def enforce_mock_contracts(report: MockContractReport) -> None: + """Raise a typed hard failure when a schema/mock contradiction exists.""" + if report.diverged: + raise MockContractDivergenceError(report) + + +__all__ = [ + "ContractEvidence", + "MockContractDivergenceError", + "MockContractFinding", + "MockContractReport", + "MockFieldUse", + "QueryFieldUse", + "enforce_mock_contracts", + "extract_mock_fields", + "extract_query_fields", + "format_mock_contract_report", + "validate_changed_files", + "validate_mock_contracts", +] diff --git a/pdd/one_session_sync.py b/pdd/one_session_sync.py index 47e7396cf..164132d50 100644 --- a/pdd/one_session_sync.py +++ b/pdd/one_session_sync.py @@ -16,7 +16,7 @@ from rich.console import Console from rich import print as rprint -from .agentic_common import run_agentic_task +from .agentic_common import provider_failure_workflow, run_agentic_task from .agentic_test_generate import ( _get_file_mtimes, _snapshot_pre_test_contents, @@ -543,6 +543,7 @@ def _escape_braces(s: str) -> str: return prompt +@provider_failure_workflow def run_one_session_sync( basename: str, language: str, @@ -589,6 +590,57 @@ def run_one_session_sync( except OSError: existing_test_content = None + # Issue #1903 §B.4 provenance: is the canonical test an ADOPTED human + # co-located test (unpinned)? Computed NOW — before the agentic session + # overwrites it — because after generation a greenfield-created and a + # human-adopted test are indistinguishable by presence. Threaded into the + # churn gate so the issue-driven never-block only relieves a genuine + # adopted-human test. Total; any error -> False (keep the strict gate). + test_was_adopted_human = False + try: + if test_path is not None: + from .content_selector import ( + configured_test_output_pinned, + find_collocated_test, + is_pdd_created_test, + record_pdd_created_test, + ) + _pin_target = str(prompt_path or code_path) + _pinned = ( + os.environ.get("PDD_TEST_OUTPUT_PATH") is not None + or configured_test_output_pinned(_pin_target) + ) + _sibling = find_collocated_test(code_path) + test_was_adopted_human = bool( + not _pinned + and _sibling is not None + and Path(test_path).resolve() == Path(_sibling).resolve() + and not is_pdd_created_test(test_path) # PDD-owned greenfield != adopted + ) + # Ownership provenance (issue #1903 §B.4 round 7): if PDD is + # GREENFIELD-creating this co-located test (no pre-existing file / + # sibling, unpinned, a co-located shape), record PDD ownership so a + # later run never treats it as human-adopted. + _pp = Path(test_path) + _segs = [s for s in _pp.as_posix().split("/") if s] + _collocated_shape = ( + "__test__" in _segs + or "__tests__" in _segs + or ".test." in _pp.name.lower() + or ".spec." in _pp.name.lower() + ) + if ( + not _pinned + and _sibling is None + and not _pp.exists() + and _segs + and _segs[0] != "tests" + and _collocated_shape + ): + record_pdd_created_test(test_path) + except Exception: # pylint: disable=broad-except + test_was_adopted_human = False + # Snapshot the pre-session code content (#1012, P1.A) so the # public-surface regression gate can run after the agentic session # rewrites the code file. Without this gate, `pdd sync --one-session` @@ -1106,6 +1158,7 @@ def _heartbeat() -> None: "- Add new tests for the prompt change without removing " "the pre-existing ones." ), + adopted_human=test_was_adopted_human, ) generated_test_content = test_path.read_text(encoding="utf-8") _verify_test_churn( @@ -1114,6 +1167,7 @@ def _heartbeat() -> None: prompt_name=f"{basename}_test_{language}.prompt", output_path=str(test_path), prompt_content=prompt_content, + adopted_human=test_was_adopted_human, ) # Gate passed — accept this attempt. churn_gate_passed = True diff --git a/pdd/operation_log.py b/pdd/operation_log.py index 2bcf9f841..80ba64b7a 100644 --- a/pdd/operation_log.py +++ b/pdd/operation_log.py @@ -391,6 +391,68 @@ def _prompts_root_for_fingerprint(prompt_file: Union[str, Path]) -> Path: return prompt_path.parent +def resolve_fingerprint_paths( + basename: str, + language: str, + prompt_file: Union[str, Path], + *, + paths: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + """Resolve a complete unit path set without losing explicit caller paths. + + Mutating commands often know only the prompt and the artifact they wrote. + Persisting that partial set would replace previously valid example/test + hashes with ``null`` and make the next sync report those untouched files as + changed. Resolve the remaining paths from the prompt's own project, then + overlay every explicit path so caller-selected locations stay authoritative + (issues #983, #1211/#1290, and #1305). + + Resolution is best-effort because derivative/new prompt outputs may not yet + be registered in project configuration. The explicit touched paths remain + available for hard hash validation even when optional sibling discovery + cannot complete. + """ + explicit: Dict[str, Any] = dict(paths or {}) + explicit_prompt = Path(prompt_file) + explicit["prompt"] = explicit_prompt + try: + from .sync_determine_operation import get_pdd_file_paths + + discovered: Dict[str, Any] = get_pdd_file_paths( + basename, + language, + prompts_dir=str(_prompts_root_for_fingerprint(prompt_file)), + ) + except (ImportError, OSError, ValueError) as exc: + logger.warning( + "Could not resolve complete fingerprint paths for %s/%s: %s", + basename, + language, + exc, + ) + return explicit + + resolved_prompt = discovered.get("prompt") + if not isinstance(resolved_prompt, Path) or not resolved_prompt.exists(): + logger.warning( + "Resolved fingerprint prompt is missing for %s/%s: %s", + basename, + language, + resolved_prompt, + ) + return explicit + + # A CLI may supply a relative prompt spelling while path discovery returns + # the real absolute file. Keep the discovered prompt when that spelling + # does not exist from the current process CWD; otherwise an otherwise good + # resolution would be overwritten with a null-hash path (issue #1305). + overlay = dict(explicit) + if not explicit_prompt.exists(): + overlay.pop("prompt", None) + discovered.update(overlay) + return discovered + + def load_operation_log( basename: str, language: str, @@ -578,56 +640,22 @@ def save_fingerprint( cost: float = 0.0, model: str = "unknown" ) -> None: - """ - Save the current fingerprint/state to the state file. + """Finalize the current fingerprint through the shared transaction. - 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. + Fingerprint persistence is part of command success. Any path, hash, or + atomic-write failure therefore propagates as ``FingerprintFinalizeError``. """ - from dataclasses import asdict - from datetime import timezone - from .sync_determine_operation import calculate_current_hashes, Fingerprint, read_fingerprint - from . import __version__ - - # Issue #983: when the caller provides `paths`, use them directly — do - # NOT call get_pdd_file_paths. Issue #1211: at the same time, use those - # caller-supplied paths to detect the subproject root so the meta dir - # anchors at the .pddrc rather than the run CWD. - if not paths: - from .sync_determine_operation import get_pdd_file_paths - try: - paths = get_pdd_file_paths(basename, language) - except (ImportError, OSError, ValueError) as e: - logger.warning("Could not resolve paths for %s/%s: %s", basename, language, e) - paths = {} - - path = get_fingerprint_path(basename, language, paths=paths) - - # Issue #522: Pass stored include deps for prompt hash calculation - prev_fp = read_fingerprint(basename, language, paths=paths) - 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( - pdd_version=__version__, - timestamp=datetime.now(timezone.utc).isoformat(), - command=operation, - prompt_hash=current_hashes.get('prompt_hash'), - code_hash=current_hashes.get('code_hash'), - 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 - ) + from .fingerprint_transaction import FingerprintTransaction - try: - with open(path, 'w', encoding='utf-8') as f: - json.dump(asdict(fingerprint), f, indent=2) - except Exception as e: - console = Console() - console.print(f"[yellow]Warning: Failed to save fingerprint to {path}: {e}[/yellow]") + with FingerprintTransaction( + basename=basename, + language=language, + operation=operation, + paths=paths, + cost=cost, + model=model, + ): + pass def save_run_report( @@ -777,81 +805,110 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: cost = _extract_cost_from_result(operation, result) model = _extract_model_from_result(operation, result) - update_log_entry(entry, success=success, cost=cost, model=model, duration=duration, error=error_msg) if _estimate_mode_active(): pass elif basename and language: - append_log_entry(basename, language, entry, paths=log_paths) - if success: - fingerprint_allowed = True - # Clear the stale run report only after the command - # succeeds, so a failed run cannot erase existing - # runtime verification state that still describes the - # current code. The clear must happen before - # save_fingerprint so a fresh fingerprint never - # coexists with a stale per-module run report - # (issue #1057). - if clears_run_report: - fingerprint_allowed = _clear_run_report_before_fingerprint( - basename, language, paths=log_paths + try: + no_op_fix = ( + operation == "fix" + and cost == 0.0 + and str(model).strip().lower() + in {"", "none", "unknown", "n/a"} + ) + if success and no_op_fix: + logger.info( + "Skipping fix metadata finalization for %s/%s: " + "no LLM invocation", + basename, + language, ) - if updates_fingerprint and fingerprint_allowed: - # Issue #1305 + #1211: save_fingerprint hashes only - # Path values, so a bare {"prompt": } hint - # yields all-null hashes (the prompt string is - # skipped and code/example/test keys are absent) and - # the fingerprint never converges (CI auto-heal - # loops). Resolve the authoritative, complete Path - # set here. get_pdd_file_paths re-resolves the - # prompts root from the run CWD, so anchor it at the - # prompt file's subproject via an absolute - # prompts_dir — otherwise a command run from a PARENT - # CWD for a nested subproject resolves to the parent - # (null hashes again) and writes the fingerprint - # outside the subproject, splitting it from the log / - # run report that log_paths anchors. Fall back to a - # Path-coerced prompt hint (never a raw string) if - # resolution fails, so anchoring still works. The - # #983 contract is preserved: the caller resolves the - # paths, so save_fingerprint does not. - from .sync_determine_operation import get_pdd_file_paths - try: - prompts_root = _prompts_root_for_fingerprint( - prompt_file + elif success: + fingerprint_allowed = True + # Clear the stale run report only after the command + # succeeds, so a failed run cannot erase existing + # runtime verification state that still describes the + # current code. The clear must happen before + # save_fingerprint so a fresh fingerprint never + # coexists with a stale per-module run report + # (issue #1057). + if clears_run_report: + fingerprint_allowed = _clear_run_report_before_fingerprint( + basename, language, paths=log_paths ) - fingerprint_paths: Dict[str, Any] = get_pdd_file_paths( - basename, language, prompts_dir=str(prompts_root) + if updates_fingerprint and not fingerprint_allowed: + from .fingerprint_transaction import ( + FingerprintFinalizeError, ) - # Existence-gate the resolution: if it silently - # resolved the prompt to a path that is not on - # disk (a mis-resolution that did not raise), the - # other paths are wrong too, so the fingerprint - # would be null and mis-anchored. Fall back to the - # real prompt path so prompt_hash is real and the - # write still anchors at the subproject. - resolved_prompt = fingerprint_paths.get("prompt") - if not ( - isinstance(resolved_prompt, Path) - and resolved_prompt.exists() - ): - fingerprint_paths = {"prompt": Path(prompt_file)} - except (ImportError, OSError, ValueError) as e: - logger.warning( - "Could not resolve paths for %s/%s: %s", + + raise FingerprintFinalizeError( + operation, + get_fingerprint_path( + basename, + language, + paths=log_paths, + ), + "run report not cleared", + ) + if updates_fingerprint and fingerprint_allowed: + # Issue #1305 + #1211: save_fingerprint hashes only + # Path values, so a bare {"prompt": } hint + # yields all-null hashes (the prompt string is + # skipped and code/example/test keys are absent) and + # the fingerprint never converges (CI auto-heal + # loops). Resolve the authoritative, complete Path + # set here. get_pdd_file_paths re-resolves the + # prompts root from the run CWD, so anchor it at the + # prompt file's subproject via an absolute + # prompts_dir — otherwise a command run from a PARENT + # CWD for a nested subproject resolves to the parent + # (null hashes again) and writes the fingerprint + # outside the subproject, splitting it from the log / + # run report that log_paths anchors. Fall back to a + # Path-coerced prompt hint (never a raw string) if + # resolution fails, so anchoring still works. The + # #983 contract is preserved: the caller resolves the + # paths, so save_fingerprint does not. + fingerprint_paths = resolve_fingerprint_paths( basename, language, - e, + prompt_file, ) - fingerprint_paths = {"prompt": Path(prompt_file)} - save_fingerprint( - basename, - language, - operation=operation, - paths=fingerprint_paths, - cost=cost, - model=model, - ) - if updates_run_report and isinstance(result, dict): - save_run_report(basename, language, result, paths=log_paths) + save_fingerprint( + basename, + language, + operation=operation, + paths=fingerprint_paths, + cost=cost, + model=model, + ) + if updates_run_report and isinstance(result, dict): + save_run_report(basename, language, result, paths=log_paths) + except Exception as finalization_error: + # Metadata persistence is part of command success. If it + # fails after the wrapped command returned, persist a + # failed audit entry before propagating the fail-closed + # exception to the CLI. + success = False + error_msg = str(finalization_error) + update_log_entry( + entry, + success=False, + cost=cost, + model=model, + duration=duration, + error=error_msg, + ) + append_log_entry(basename, language, entry, paths=log_paths) + raise + + update_log_entry( + entry, + success=success, + cost=cost, + model=model, + duration=duration, + error=error_msg, + ) + append_log_entry(basename, language, entry, paths=log_paths) return wrapper return decorator diff --git a/pdd/pin_example_hack.py b/pdd/pin_example_hack.py index 15b4889cf..f599086e8 100644 --- a/pdd/pin_example_hack.py +++ b/pdd/pin_example_hack.py @@ -14,7 +14,6 @@ from pathlib import Path from typing import Dict, Any, Optional, List, Callable from dataclasses import asdict, dataclass, field -import tempfile import sys import click @@ -52,6 +51,7 @@ from .get_run_command import get_run_command_for_file from .pytest_output import extract_failing_files_from_output, _find_project_root from . import DEFAULT_STRENGTH +from .json_atomic import atomic_write_json # --- Helper Functions --- @@ -67,6 +67,7 @@ class PendingStateUpdate: fingerprint: Optional[Dict[str, Any]] = None run_report_path: Optional[Path] = None fingerprint_path: Optional[Path] = None + fingerprint_operation: Optional[str] = None class AtomicStateUpdate: @@ -105,39 +106,39 @@ def set_run_report(self, report: Dict[str, Any], path: Path): self.pending.run_report = report self.pending.run_report_path = path - def set_fingerprint(self, fingerprint: Dict[str, Any], path: Path): + def set_fingerprint( + self, + fingerprint: Dict[str, Any], + path: Path, + *, + operation: Optional[str] = None, + ): """Buffer a fingerprint for atomic write.""" self.pending.fingerprint = fingerprint self.pending.fingerprint_path = path + self.pending.fingerprint_operation = operation def _atomic_write(self, data: Dict[str, Any], target_path: Path) -> None: """Write data to file atomically using temp file + rename pattern.""" - target_path.parent.mkdir(parents=True, exist_ok=True) - - # Write to temp file in same directory (required for atomic rename) - fd, temp_path = tempfile.mkstemp( - dir=target_path.parent, - prefix=f".{target_path.stem}_", - suffix=".tmp" - ) - self._temp_files.append(temp_path) - - try: - with os.fdopen(fd, 'w') as f: - json.dump(data, f, indent=2, default=str) - - # Atomic rename - guaranteed atomic on POSIX systems - os.replace(temp_path, target_path) - self._temp_files.remove(temp_path) # Successfully moved, stop tracking - except Exception: - # Leave temp file for rollback to clean up - raise + atomic_write_json(target_path, data) def _commit(self): """Commit all pending state updates atomically.""" # Write fingerprint first (checkpoint), then run_report if self.pending.fingerprint and self.pending.fingerprint_path: - self._atomic_write(self.pending.fingerprint, self.pending.fingerprint_path) + try: + self._atomic_write( + self.pending.fingerprint, + self.pending.fingerprint_path, + ) + except Exception as exc: + from .fingerprint_transaction import FingerprintFinalizeError + + raise FingerprintFinalizeError( + self.pending.fingerprint_operation or "unknown", + self.pending.fingerprint_path, + exc, + ) from exc if self.pending.run_report and self.pending.run_report_path: self._atomic_write(self.pending.run_report, self.pending.run_report_path) @@ -246,31 +247,18 @@ def _save_operation_fingerprint(basename: str, language: str, operation: str, model: The model used. atomic_state: Optional AtomicStateUpdate for atomic writes (Issue #159 fix). """ - from datetime import datetime, timezone - from .sync_determine_operation import calculate_current_hashes, Fingerprint - from . import __version__ - - current_hashes = calculate_current_hashes(paths) - fingerprint = Fingerprint( - pdd_version=__version__, - timestamp=datetime.now(timezone.utc).isoformat(), - command=operation, - prompt_hash=current_hashes.get('prompt_hash'), - code_hash=current_hashes.get('code_hash'), - example_hash=current_hashes.get('example_hash'), - test_hash=current_hashes.get('test_hash'), - test_files=current_hashes.get('test_files'), # Bug #156 - ) - - fingerprint_file = META_DIR / f"{_safe_basename(basename)}_{language.lower()}.json" - if atomic_state: - # Buffer for atomic write - atomic_state.set_fingerprint(asdict(fingerprint), fingerprint_file) - else: - # Legacy direct write - META_DIR.mkdir(parents=True, exist_ok=True) - with open(fingerprint_file, 'w') as f: - json.dump(asdict(fingerprint), f, indent=2, default=str) + from .fingerprint_transaction import FingerprintTransaction + + with FingerprintTransaction( + basename=basename, + language=language, + operation=operation, + paths=paths, + cost=cost, + model=model, + atomic_state=atomic_state, + ): + pass def _python_cov_target_for_code_file(code_file: Path) -> str: """Return a `pytest-cov` `--cov` target for a Python code file. @@ -1761,4 +1749,4 @@ def __init__(self, rc, out, err): PDD_DIR.mkdir(exist_ok=True) META_DIR.mkdir(exist_ok=True) result = sync_orchestration(basename="my_calculator", language="python", quiet=True) - print(json.dumps(result, indent=2)) \ No newline at end of file + print(json.dumps(result, indent=2)) diff --git a/pdd/prompts/agentic_architecture_orchestrator_python.prompt b/pdd/prompts/agentic_architecture_orchestrator_python.prompt index d355b310a..3a5f432dd 100644 --- a/pdd/prompts/agentic_architecture_orchestrator_python.prompt +++ b/pdd/prompts/agentic_architecture_orchestrator_python.prompt @@ -15,6 +15,8 @@ Orchestrator for the agentic architecture workflow. Runs each step as a separate agentic task, accumulates context between steps, tracks overall progress and cost, and supports resuming from saved state. Includes gate steps (1b complexity, 2b codebase scan, 5b completeness, 7b self-review, 9b consistency) and per-step validation with in-place fixing (steps 10-12). Post-processes successful output via render_mermaid.py. % Requirements + +Decorate `run_agentic_architecture_orchestrator` with `provider_failure_workflow` so direct library calls own one provider-health epoch across all steps, while nested CLI calls share the existing outer epoch. 1. Function: `run_agentic_architecture_orchestrator(...) -> Tuple[bool, str, float, str, List[str]]` 2. Return 5-tuple: (success, final_message, total_cost, model_used, output_files) 3. Accumulate step outputs as context for subsequent steps; track total cost. @@ -97,4 +99,4 @@ ARCH_STEP_TIMEOUTS: Dict[Union[int, float], float] = { % Deliverables -- Code: `pdd/agentic_architecture_orchestrator.py` \ No newline at end of file +- Code: `pdd/agentic_architecture_orchestrator.py` diff --git a/pdd/prompts/agentic_bug_orchestrator_python.prompt b/pdd/prompts/agentic_bug_orchestrator_python.prompt index a5315a992..64a04efda 100644 --- a/pdd/prompts/agentic_bug_orchestrator_python.prompt +++ b/pdd/prompts/agentic_bug_orchestrator_python.prompt @@ -48,6 +48,8 @@ BUG_STEP_TIMEOUTS: Dict[int, float] = { ``` % Requirements + +Decorate `run_agentic_bug_orchestrator` with `provider_failure_workflow` so direct library calls own one provider-health epoch across all steps, while nested CLI calls share the existing outer epoch. 1. Function: `run_agentic_bug_orchestrator(issue_url: str, issue_content: str, repo_owner: str, repo_name: str, issue_number: int, issue_author: str, issue_title: str, *, cwd: Path, verbose: bool = False, quiet: bool = False, timeout_adder: float = 0.0, use_github_state: bool = True, reasoning_time: Optional[float] = None, clean_restart: bool = False) -> Tuple[bool, str, float, str, List[str]]` 2. Return 5-tuple: (success, final_message, total_cost, model_used, changed_files) 3. Run 12 steps sequentially, each as a separate `run_agentic_task()` call diff --git a/pdd/prompts/agentic_change_orchestrator_python.prompt b/pdd/prompts/agentic_change_orchestrator_python.prompt index 880e05a2a..91ed8f430 100644 --- a/pdd/prompts/agentic_change_orchestrator_python.prompt +++ b/pdd/prompts/agentic_change_orchestrator_python.prompt @@ -40,6 +40,8 @@ Orchestrator for the 13-step agentic change workflow. Runs each step as a separate agentic task, accumulates context between steps, tracks overall progress and cost, and supports resuming from saved state. Includes a review loop (steps 11-12) that iterates until no issues are found. % Requirements + +Decorate `run_agentic_change_orchestrator` with `provider_failure_workflow` so direct library calls own one provider-health epoch across all steps, while nested CLI calls share the existing outer epoch. 1. Function: `run_agentic_change_orchestrator(issue_url: str, issue_content: str, repo_owner: str, repo_name: str, issue_number: int, issue_author: str, issue_title: str, issue_updated_at: str = "", *, cwd: Path, verbose: bool = False, quiet: bool = False, timeout_adder: float = 0.0, use_github_state: bool = True, reasoning_time: Optional[float] = None, clean_restart: bool = False) -> Tuple[bool, str, float, str, List[str]]` 2. Return 5-tuple: (success, final_message, total_cost, model_used, changed_files) 3. Run 13 steps, with steps 11-12 in a review loop (max 5 iterations) @@ -436,8 +438,8 @@ Between Step 8 (Analyze Prompt Changes) and Step 9 (Implement), the orchestrator - When `prompt_drifts` is empty: print `[Step 8.5/13] Pre-flight drift check: all prompts in sync with code.` (unless quiet) and return `([], [], [])` - Cap fan-out: when more than `max_modules` (default `_PREFLIGHT_MAX_HEAL_MODULES = 10`) prompt drifts are detected, only the alphabetically-first `max_modules` are healed; the rest are logged as deferred to the CI auto-heal pipeline. With `timeout_per_module=300s`, an unbounded fan-out costs `N × 5min` worst case - Build a headless heal env that mirrors `ci_drift_heal._build_ci_env`: `PDD_FORCE=1`, `CI=1`, `NO_COLOR=1`, `PDD_FORCE_LOCAL=1`, `PDD_SKIP_LOCAL_MODELS=1`, `PDD_RESTORE_PROTECTED_PATHS_ON_FAILURE=1`. The last two are required to avoid LM-Studio hangs on a Cloud Run executor and to keep `.pdd/meta` in a consistent state when `pdd update` times out mid-mutation - - For each prompt drift with a resolved `code_path`: run `subprocess.run([sys.executable, "-m", "pdd", "update", "--sync-metadata", code_path], cwd=str(worktree_path), capture_output=True, text=True, timeout=timeout_per_module, env=heal_env)`. `--sync-metadata` is required so prompt tags, architecture, run-report cleanup, and fingerprint finalization are completed before preflight considers the heal successful. `sys.executable + -m pdd` is required so the heal subprocess uses the orchestrator's venv; a bare `["pdd", ...]` picks up whatever pdd is on `$PATH` (potentially a different version) - - Classify each attempt: returncode 0 → append basename to `healed` AND append `drift.prompt_path` (when set) to `healed_prompts`. Non-zero exit, timeout (`TimeoutExpired`), missing `code_path`, or any other exception → append basename to `failed`. Always restore the original CWD in `finally` + - For each prompt drift, require both its authoritative `prompt_path` and `code_path`; if either is missing, mark the module failed without launching a subprocess. MUST also fail closed when either path escapes the worktree — a `..` traversal, an absolute path elsewhere, or a symlink out of the tree (the paths come from repo-controlled `.pddrc`/architecture.json config that is issue-influenced in the agentic flow; CWE-022). Validate both against `worktree_path` (e.g. via `content_selector._validated_project_path`) and mark the module failed without launching a subprocess if either does not resolve inside it, so `pdd update --git` can never write beyond the isolated worktree. Run `subprocess.run([sys.executable, "-m", "pdd", "update", "--sync-metadata", "--git", prompt_path, code_path], cwd=str(worktree_path), capture_output=True, text=True, timeout=timeout_per_module, env=heal_env)`. The two-path `--git` form is required even though drift detection already established that code is newer: it selects true update mode while preserving the exact prompt identity already resolved by `DriftInfo`. Never downgrade this to code-only regeneration, which re-derives identity from common leaf basenames such as `page`, `layout`, or `config` and can overwrite an unrelated existing prompt (issue #2004). `--sync-metadata` is required so prompt tags, architecture, run-report cleanup, and fingerprint finalization are completed before preflight considers the heal successful. `sys.executable + -m pdd` is required so the heal subprocess uses the orchestrator's venv; a bare `["pdd", ...]` picks up whatever pdd is on `$PATH` (potentially a different version) + - Classify each attempt: returncode 0 → append basename to `healed` AND append the authoritative `prompt_path` to `healed_prompts`. Non-zero exit, timeout (`TimeoutExpired`), missing prompt/code identity, or any other exception → append basename to `failed`. Always restore the original CWD in `finally` - Return `(healed, failed, healed_prompts)` — three lists. The third element is the list of prompt-file paths the heal subprocesses rewrote; Step 10's discovery sweep merges these so docs reachable from a just-healed prompt's `` graph still pass through the doc-sync contract 2. Call site in orchestrator loop: diff --git a/pdd/prompts/agentic_checkup_orchestrator_python.prompt b/pdd/prompts/agentic_checkup_orchestrator_python.prompt index 55b078e67..fd5ba05fd 100644 --- a/pdd/prompts/agentic_checkup_orchestrator_python.prompt +++ b/pdd/prompts/agentic_checkup_orchestrator_python.prompt @@ -16,6 +16,7 @@ 1) This module acts as the multi-step agentic orchestrator for the `pdd checkup` command, responsible for managing an 8-step workflow that analyzes, fixes, and verifies software issues. It coordinates between LLM-based agents, local environment execution, and git state management, ensuring that changes are isolated in worktrees and that the workflow can be resumed gracefully after interruptions. 2) Requirements: + 0. Decorate `run_agentic_checkup_orchestrator` with `provider_failure_workflow` so direct library calls own one provider-health epoch across every step. 1. Implement an 8-step workflow: 1. Discover, 2. Deps, 3. Build, 4. Interfaces, 5. Test, 6. Fix (split into 6.1 Fix, 6.2 Regression, 6.3 E2E), 7. Verify, and 8. Create PR. 2. Manage an iterative "Fix-Verify" loop for steps 3-7. Step 7 exits the loop when `_step7_passed(...)` returns true OR when the legacy text sentinel "All Issues Fixed" is present. Before saving/posting a passing Step 7 result, call `_ensure_step7_success_artifacts(...)` so the final report contains legacy-compatible downstream evidence: "All Issues Fixed", `**Failed:** 0`, `**New failures:** 0`, an all-green CI line for PR mode, the verified PR head SHA when known, and a final fenced JSON verdict with `success: true` (and `issue_aligned: true` for PR mode with a source issue). If Step 7 emits a clearly green hosted markdown report but forgets the JSON object, `_step7_passed(...)` may accept it only through a narrow `_step7_human_success_report_passed(...)` fallback. The fallback MUST require a Step 7 final-report heading (`## Step 7/8: ...` or hosted `## Step 7: ...`), either `Checkup complete` or an explicit `### Overall Status` section, zero-failure evidence (`**Failed:** 0`, `0 failed`, or `0 failures`) with no positive failure counts, "all clear" plus one of "no remaining issues", "no issues remaining", or equivalent zero-issues wording, all prior findings fixed, and explicit issue-alignment language in PR+issue mode; vague no-JSON summaries still fail closed. Loop back to Step 3 up to `MAX_FIX_VERIFY_ITERATIONS` (default 3) only when neither the structured gate nor the legacy sentinel proves success. If the maximum is reached without verified success, return failure and do not push fixes or create/skip through a successful PR gate. **Issue #1212 — fixer-skip, scope, causal, clean-run, and Step 5 visibility guards (PR mode only)**: diff --git a/pdd/prompts/agentic_common_python.prompt b/pdd/prompts/agentic_common_python.prompt index d57e9390f..6268a88c8 100644 --- a/pdd/prompts/agentic_common_python.prompt +++ b/pdd/prompts/agentic_common_python.prompt @@ -20,6 +20,11 @@ "functions": [ {"name": "get_agent_provider_preference", "signature": "() -> List[str]", "returns": "List[str]"}, {"name": "get_available_agents", "signature": "() -> List[str]", "returns": "List[str]"}, + {"name": "get_disabled_providers", "signature": "() -> Dict[str, str]", "returns": "Dict[str, str]"}, + {"name": "mark_provider_permanently_failed", "signature": "(provider: str, classification: Optional[str]) -> None", "returns": "None"}, + {"name": "provider_failure_scope", "signature": "(initial_disabled: Optional[Dict[str, str]] = None) -> Iterator[None]", "returns": "Iterator[None]"}, + {"name": "provider_failure_workflow", "signature": "(func: Callable) -> Callable", "returns": "Callable"}, + {"name": "reset_disabled_providers", "signature": "() -> None", "returns": "None"}, {"name": "get_agentic_capabilities", "signature": "() -> Dict[str, Any]", "returns": "Dict[str, Any]"}, {"name": "validate_claude_policy", "signature": "(policy: Any, *, interactive: bool = False) -> ClaudePolicy", "returns": "ClaudePolicy"}, {"name": "build_agentic_task_instruction", "signature": "(instruction: str, *, user_feedback: Optional[str] = None, steers: Optional[List[SteerEntry]] = None) -> str", "returns": "str"}, @@ -131,6 +136,7 @@ Shared infrastructure for agentic CLI invocations (Claude Code, Gemini/Antigravi - **Marker safety / false positives (Issue #1541)**: `_provider_limit_marker(...)` emits ONLY fixed enum provider/status/reason/source fields plus the normalized UTC timestamp; out-of-range fields are coerced to conservative defaults so the marker can never carry raw provider stderr, tokens, API keys, authorization headers, user prompt content, or other untrusted text. Prose containing `hit your limit` MUST NOT emit the marker or classify as `credential-limit` unless `_classify_permanent_error` already classified it (proximity + time-token guarded). Focused tests cover exact reset timestamp/date strings, time-only resets, unparseable resets, generic 429 without reset, false-positive prose, backward-compatible `credential-limit`, secret-safety, and emission to stdout under `quiet=True`. - This prevents wasting 3 retries on errors that won't resolve (e.g., temperature=1 rejection on Vertex AI). - Codex stale ChatGPT login failures are permanent and must be normalized before surfacing to users. If OpenAI/Codex exits nonzero and combined stderr/stdout includes Codex websocket/backend auth text such as `access token could not be refreshed`, `please sign in again`, `chatgpt.com/backend-api/codex`, `codex/responses`, plus `401`/`Unauthorized`/sign-in language, return a concise message telling the user to run `codex login` (or `codex login --device-auth`; `codex login --with-api-key` for API-key auth). Do not expose raw websocket URLs/log noise as the main failure. The recovery message MUST also tell the user how to skip Codex for the run: setting `PDD_AGENTIC_PROVIDER=anthropic,google` is the primary disable. Mention secondary options (unset `PDD_CODEX_AUTH_AVAILABLE` if set in env, or `codex logout` / remove `~/.codex/auth.json` since `_has_codex_auth_file` now picks up the file directly per Issue #813 round-6) so file-only Codex users have an actionable path — telling them to "unset `PDD_CODEX_AUTH_AVAILABLE`" alone is a no-op when their auth came from `~/.codex/auth.json` and that env var was never set. + - **Run-scoped permanent-failure registry (Issue #1936)**: A permanent classification is not just a per-attempt signal — it is a fact about the credential that holds for the remainder of the run. When `_classify_permanent_error` fires for a provider, record it via `mark_provider_permanently_failed(provider, classification)` so every LATER agentic call in the same run drops that provider from its candidate list. Without this, a multi-step workflow (e.g. the 12-step change orchestrator) re-attempted a dead provider on every step, paying its auth-failure timeout each time. The registry is a context-local `{provider: classification}` map exposed via `get_disabled_providers()` and cleared via `reset_disabled_providers()`. `provider_failure_scope(initial_disabled=None)` creates a workflow-health epoch: nested scopes share the outer map, a new outer scope starts from inherited env plus the optional seed (used to cross a deliberate worker-thread boundary), concurrent contexts remain isolated, and exit restores the caller context. Decorate every public multi-step agentic orchestrator with `provider_failure_workflow`; the top-level CLI also owns one scope for its whole invocation, while a direct `run_agentic_task` call creates a fresh scope only when no workflow scope exists. Do NOT mutate `os.environ` when marking a failure. Instead, `_run_with_provider` copies the current map into the provider CLI subprocess environment, so re-entrant `pdd` subprocesses inherit the skip-list without cross-workflow process-global leakage. `mark_provider_permanently_failed` is idempotent (first classification wins). This registry is subordinate to `PDD_AGENTIC_PROVIDER`: it only REMOVES a provider that already proved dead this run, never adds one. 15. **Aggregate Per-Step Timeout**: For local runs without `PDD_JOB_DEADLINE`, default to `2 * effective_timeout` as the step deadline. This caps worst-case to 2x the step timeout instead of 9x (3 providers x 3 retries). 16. **Progress Tracking** (Ctrl+C UX): - `set_agentic_progress(workflow, current_step, total_steps, step_name, completed_steps)`: Record current progress so KeyboardInterrupt can report how far the workflow got. @@ -229,6 +235,7 @@ This matrix records per-harness model **controllability** for the deliverable; i - If `before_attempt` is provided, call it as `before_attempt(provider, attempt)` immediately before each provider attempt, including internal retries. Catch and warn on callback exceptions so legacy agent execution is not broken. This hook is for callers that own per-attempt sidecar artifacts and must clear them before every retry; it must not affect provider selection, cost accounting, retry backoff, or result tuple compatibility. - Routing policy escalation and feasible-provider fallback must propagate `before_attempt` into their nested `run_agentic_task(..., routing_policy=None, ...)` calls. Artifact-producing callers rely on the hook during escalated attempts just as much as during the initial provider attempt. - If `single_provider_attempt=True`, run only the first feasible provider exactly once: clamp `max_retries` to 1, do not fall back to later providers, and disable routing escalation. Use this only for side-effecting tasks where repeating the prompt can duplicate external mutations. +- After applying the `PDD_AGENTIC_PROVIDER` preference filter and task-class ordering, select the routing config/record when a routing policy is present, but defer applying or pinning that config. Then, before config application and `single_provider_attempt` truncation, drop providers already recorded in the run-scoped permanent-failure registry (`get_disabled_providers()`; see Requirement 14). This ordering preserves an audit record even when every feasible provider is disabled, while still letting a policy whose preferred harness is disabled follow its existing `selected_harness_unavailable` fallback path to a healthy provider instead of pinning the dead harness. If every feasible candidate has been disabled, set the routing record fallback reason to `all_feasible_providers_disabled`, emit a failed zero-cost/zero-latency routing outcome, and return `AgenticTaskResult(False, msg, 0.0, "", None)` immediately where `msg` is `"All agent providers failed permanently earlier this run: : ; ..."`. - `use_playwright` enables constrained tools (see section). - Uses exponential backoff with jitter: `retry_delay * (2 ** (attempt - 1)) + random.uniform(0, retry_delay)`. - Classifies errors as permanent/transient via `_is_permanent_error()` before retrying. diff --git a/pdd/prompts/agentic_e2e_fix_orchestrator_python.prompt b/pdd/prompts/agentic_e2e_fix_orchestrator_python.prompt index e8c827e65..19a133133 100644 --- a/pdd/prompts/agentic_e2e_fix_orchestrator_python.prompt +++ b/pdd/prompts/agentic_e2e_fix_orchestrator_python.prompt @@ -16,10 +16,13 @@ agentic_bug_orchestrator_python.prompt ci_validation_python.prompt pre_checkup_gate_python.prompt +mock_contract_validation_python.prompt -context/agentic_e2e_fix_orchestrator_example.py +context/agentic_e2e_fix_orchestrator_example.py + +Decorate `run_agentic_e2e_fix_orchestrator` with `provider_failure_workflow` so direct library calls own one provider-health epoch across all fix, cleanup, CI, and pre-checkup steps. context/ci_validation_example.py @@ -38,6 +41,9 @@ context/change/16/fix_error_loop.py + + pdd/mock_contract_validation.py + % Goal Design `agentic_e2e_fix_orchestrator` for the 11-step E2E-fix workflow: @@ -73,6 +79,17 @@ NOT_A_BUG is a first-class loop-control token alongside ALL_TESTS_PASS / CONTINU - `_extract_test_files` fallback directory scan MUST cap results at `MAX_FALLBACK_TEST_FILES = 20` (sorted by mtime descending). When the cap triggers, set module-level `_fallback_scan_was_capped = True` so callers downgrade verified passes to failures (a partial run cannot be authoritative). - `_get_modified_and_untracked` MUST iterate merge-base candidates `("main", "master", "origin/main", "origin/master")` so shallow clones still resolve. All `subprocess.run` git calls in it MUST set `timeout=30` and catch `subprocess.TimeoutExpired`. +% Mock-Contract Success Gates (Issue #1939) +- Independent test execution is necessary but cannot prove that generated mocks match a real data/interface contract. Before the terminal success branch commits or pushes any file, run `validate_changed_files(project_root=cwd, changed_files=actual_changed_files, baseline_ref=initial_sha)`. +- A `diverged` report is a hard workflow failure. Return `False` with the evidence-linked `MOCK_CONTRACT_DIVERGENCE` diagnostic; do not commit/push and do not let a Step 2/Step 9/resume pass token bypass the gate. +- An `inconclusive` report is printed and appended to the final message as a first-class warning. Do not guess validity from field-name strings when the repository exposes no exact contract. +- The single terminal gate MUST cover normal Step 9, retry, early-exit, and cached-resume success routes. Step 11 cleanup re-runs the same gate after its test pass and reverts cleanup when it introduces a divergence. +- The deterministic Python/schema gate above is authoritative for its supported query shapes. The Step 9 audit below is complementary defense in depth for broader test-double APIs, languages, types, and interfaces; its marker never overrides a deterministic divergence. +- Before formatting Step 9, use `_extract_test_files` and `_find_mock_contract_test_files` to identify changed issue-related tests that actually call recognized mocking APIs. Detection is only a routing decision and MUST NOT judge validity from field-name strings. +- Inject `mock_contract_audit_required` and the exact `mock_contract_test_files` list into the Step 9 prompt. When required, the Step 9 agent compares every changed mock/test double with real schema docs, interfaces/types, production readers/writers, and sibling production call sites, including query field/key paths and return shapes. +- `_resolve_step9_loop_token` MUST pass every resolved token through `_enforce_mock_contract_audit_gate`. `**Mock contract audit:** MOCK_CONTRACT_MISMATCH` always resolves to `CONTINUE_CYCLE`. A required audit cannot resolve `ALL_TESTS_PASS`/`LOCAL_TESTS_PASS` unless the output contains the exact `**Mock contract audit:** MOCK_CONTRACTS_VERIFIED` line; missing proof fails closed to `CONTINUE_CYCLE`. +- Apply the same gate to the one-shot Step 9 retry and cached Step 9 resume resolution. Recompute the audit requirement from the discovered test files on resume so an interrupted pre-gate pass cannot fall through to cleanup/CI. + % Verification-Failure Classification & Retry On VERIFICATION_FAILED, classify via `_classify_verification_failure` (deterministic regex on pytest `^E ` traceback lines): - `import_error` → ImportError/ModuleNotFoundError/NameError/AttributeError/SyntaxError, JS "Cannot find module" / ReferenceError, or "no test runner available". @@ -106,7 +123,7 @@ If a prior cycle applied a direct edit but the current cycle makes no new meanin % Step-Specific Retries - Step 1: on a single timeout failure, retry once with timeout multiplied by 1.5 before falling through. -- Step 9: if the run succeeded but emitted no recognizable loop-control token, retry Step 9 once with the same prompt (and persist retry cost/output to state). Tier-4 `CLASSIFICATION_ERROR` is treated as CONTINUE_CYCLE (transient classifier). +- Step 9: if the run succeeded but emitted no recognizable loop-control token, retry Step 9 once with the same prompt (and persist retry cost/output to state). Tier-4 `CLASSIFICATION_ERROR` is treated as CONTINUE_CYCLE (transient classifier). Both the primary and retry token paths enforce the generated-mock contract gate above. - Provider failures: track `consecutive_provider_failures`; abort the entire workflow after 3 in a row (`"All agent providers failed"` substring). Reset on any successful step. % Step 8 Timeout (Issue #1366) @@ -147,7 +164,7 @@ The orchestrator owns user-visible per-step `## Step N/11:` comments on the GitH - **Relationship to legacy `post_final_comment`**: workflow-level final messages (NOT_A_BUG, MAX_CYCLES_REACHED, missing loop-control tokens) continue to flow through `post_final_comment` unchanged. `post_step_comment_once` is strictly additive for the success-step case. % Commit / Push (push_with_retry) -After a successful inner loop, `_commit_and_push` stages only files whose hashes differ from `initial_file_hashes` (filtered through `_is_intermediate_file` to drop `*_fixed.*`, `*.bak`, `error_output*.txt`, `.pdd/**` except `e2e-fix-state/`, `step*_output.md`, `test_issue_*_reproduction.py`, `.gh-wrapper/**` executor wrapper artifacts, etc.), commits as `"fix: {issue_title}\n\nFixes #{issue_number}"`, and pushes via `push_with_retry`. The `.gh-wrapper/**` filter (Issue #1001) MUST be applied in both the hash-diff staging path and any fallback `_get_modified_and_untracked` staging path so the executor's `gh`/`git` shim binaries (e.g. `.gh-wrapper/gh`, `.gh-wrapper/git`) never leak into the fix commit; matching MUST be platform-neutral (normalize `\\` → `/`) and anchored on the `.gh-wrapper/` directory boundary so legitimate paths containing the substring `gh-wrapper` (e.g. `gh-wrapper-docs.md`, `tools/gh_wrapper.py`) are not over-filtered. `.gh-wrapper/` is already in the project `.gitignore` but staging code must not rely on that alone (`git add ` bypasses gitignore). +After a successful inner loop, `_commit_and_push` stages only files whose hashes differ from `initial_file_hashes` (filtered through `_is_intermediate_file` to drop `*_fixed.*`, `*.bak`, `error_output*.txt`, `.pdd/**` except `e2e-fix-state/`, `step*_output.md`, `test_issue_*_reproduction.py`, `.gh-wrapper/**` executor wrapper artifacts, etc.), commits exactly that allowlist with `git commit --only ... -- ` so unrelated pre-staged index entries remain untouched, uses the message `"fix: {issue_title}\n\nFixes #{issue_number}"`, and pushes via `push_with_retry`. The `.gh-wrapper/**` filter (Issue #1001) MUST be applied in both the hash-diff staging path and any fallback `_get_modified_and_untracked` staging path so the executor's `gh`/`git` shim binaries (e.g. `.gh-wrapper/gh`, `.gh-wrapper/git`) never leak into the fix commit; matching MUST be platform-neutral (normalize `\\` → `/`) and anchored on the `.gh-wrapper/` directory boundary so legitimate paths containing the substring `gh-wrapper` (e.g. `gh-wrapper-docs.md`, `tools/gh_wrapper.py`) are not over-filtered. `.gh-wrapper/` is already in the project `.gitignore` but staging code must not rely on that alone (`git add ` bypasses gitignore). `push_with_retry` MUST: - Accept `force_with_lease_on_non_fast_forward: bool = True`. When true, retry non-fast-forward rejections (markers `"non-fast-forward"`, `"tip of your current branch is behind"`) with `--force-with-lease`. When false, return the non-fast-forward error to the caller so shared-branch workflows can fetch/rebase/retry instead. Other `[rejected]` reasons (protected branches, hooks) MUST NOT force-push. @@ -160,7 +177,7 @@ After a successful inner loop, `_commit_and_push` stages only files whose hashes - Skipped entirely when `skip_cleanup` is True. % Step 10: CI Validation and Pre-Checkup Gate -After Step 11, unless `skip_ci`, call `run_ci_validation_loop` with the Step 10 prompt template, `ci_retries`, and the per-step timeout. Update `final_message` only when CI returns a substantive message (not the no-PR / no-checks placeholders). When CI succeeds, run the **Pre-Checkup Gate** (below) via `_run_pre_checkup_gate_with_remediation`; if the gate fails, fail the workflow. On gate pass, commit and push the gate's drift-sync heals via `_commit_and_push` (fail closed if that push fails), then clear workflow state and return success. There is NO final agentic checkup / `run_agentic_checkup` / Layer-1 / Layer-2 / review-loop / head-provenance / post-checkup CI re-validation step. +After Step 11, unless `skip_ci`, call `run_ci_validation_loop` with the Step 10 prompt template, `ci_retries`, the per-step timeout, `commit_files` derived from `_detect_changed_files(cwd, initial_file_hashes)`, and a `pre_commit_check(files)` that validates that exact allowlist against `initial_sha`. Pre-existing dirty files must neither be treated as workflow output nor swept into a remediation commit. The local pre-checkup remediation helper applies the same exact-file rule and calls `_commit_ci_fix(..., allowed_files=remediation_changed_files)`. On gate pass, re-run mock-contract validation after drift sync and before `_commit_and_push`; fail closed on divergence or push failure, then clear workflow state and return success. An unresolved/pending/manual-action required check no longer fails the fix: `run_ci_validation_loop` treats a `"timeout"` status (required checks still pending/unreported/unavailable within the poll window — e.g. a manually-triggered or external check the bot cannot run) and a pure `"action_required"` status (a settled check requiring an external trigger/comment) as INCONCLUSIVE and FAILS OPEN, returning success with a best-effort note instead of a hard failure. A genuine check FAILURE still returns "failed" and is unaffected. diff --git a/pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt b/pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt index f2094943a..b6489c938 100644 --- a/pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +++ b/pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt @@ -4,7 +4,7 @@ {{{{ "type": "config", "config": {{{{ - "keys": ["issue_url", "repo_owner", "repo_name", "issue_number", "cycle_number", "max_cycles", "issue_content", "step1_output", "step2_output", "step8_output"] + "keys": ["issue_url", "repo_owner", "repo_name", "issue_number", "cycle_number", "max_cycles", "issue_content", "step1_output", "step2_output", "step8_output", "mock_contract_audit_required", "mock_contract_test_files"] }}}} }}}} @@ -60,6 +60,12 @@ You are working on step 9 of 11 in an agentic e2e fix workflow. This step verifi {step8_output} +% Mock Contract Audit Scope + +- Audit required: {mock_contract_audit_required} +- Changed issue-related test files that use mocking APIs: +{mock_contract_test_files} + % Your Task 1. **Run issue-related tests** @@ -73,7 +79,16 @@ You are working on step 9 of 11 in an agentic e2e fix workflow. This step verifi - Are the original bugs fixed? - Are there new failures? -3. **Determine next action** +3. **Audit generated/modified test doubles against real contracts when required** + - When `Audit required` is `true`, inspect every mock, stub, fake, monkeypatch, or spy introduced or modified in the listed test files. + - For each test double, identify the production boundary it replaces and derive the authoritative contract from real evidence: schema documentation, declared interfaces/types, production readers and writers, and sibling production call sites. Prefer multiple corroborating sources when available. + - Compare mocked keys, document identity/key paths, nested shapes, query fields/operators, argument types, and return types with that evidence. Do not treat the generated test, its fixture, or a field-name guess as authoritative. + - Specifically verify every new query or lookup targets a field/key path that exists in the real schema or interface. A fallback path does not make an impossible primary query valid. + - If a mock fabricates a field/shape or otherwise diverges, fix the code/test when safe and rerun the relevant tests. If any divergence remains, emit `**Mock contract audit:** MOCK_CONTRACT_MISMATCH` and do not report `ALL_TESTS_PASS`. + - Only after every listed test double matches its real contract may you emit `**Mock contract audit:** MOCK_CONTRACTS_VERIFIED`. + - When `Audit required` is `false`, emit `**Mock contract audit:** NOT_REQUIRED` and do not invent an audit finding. + +4. **Determine next action** - If all tests pass: Workflow complete - If tests still fail and cycle < max_cycles: Another cycle needed - If tests still fail and cycle >= max_cycles: Max cycles reached, report status @@ -92,6 +107,8 @@ gh issue comment {issue_number} --repo {repo_owner}/{repo_name} --body "..." **Status:** ALL_TESTS_PASS +**Mock contract audit:** {{MOCK_CONTRACTS_VERIFIED or NOT_REQUIRED}} + ### Test Results - Unit tests: {{N}}/{{M}} pass - E2E tests: {{N}}/{{M}} pass @@ -112,6 +129,8 @@ All bugs fixed successfully in {cycle_number} cycle(s). Handing off to CI valida **Status:** CONTINUE_CYCLE +**Mock contract audit:** {{MOCK_CONTRACT_MISMATCH, MOCK_CONTRACTS_VERIFIED, or NOT_REQUIRED}} + ### Test Results - Unit tests: {{N}}/{{M}} pass ({{K}} still failing) - E2E tests: {{N}}/{{M}} pass ({{K}} still failing) @@ -134,6 +153,8 @@ All bugs fixed successfully in {cycle_number} cycle(s). Handing off to CI valida **Status:** MAX_CYCLES_REACHED +**Mock contract audit:** {{MOCK_CONTRACT_MISMATCH, MOCK_CONTRACTS_VERIFIED, or NOT_REQUIRED}} + ### Test Results - Unit tests: {{N}}/{{M}} pass ({{K}} still failing) - E2E tests: {{N}}/{{M}} pass ({{K}} still failing) @@ -154,6 +175,7 @@ All bugs fixed successfully in {cycle_number} cycle(s). Handing off to CI valida - **You MUST include exactly one of the following control tokens in your response: ALL_TESTS_PASS, CONTINUE_CYCLE, or MAX_CYCLES_REACHED.** The orchestrator parses your output for these exact strings to decide whether to continue, stop, or start another cycle. If none is present, the workflow stops with an error. - Always emit the token inside a `**Status:**` line (e.g., `**Status:** ALL_TESTS_PASS`) as shown in the templates above. +- Always emit exactly one `**Mock contract audit:**` line. When the audit is required, `ALL_TESTS_PASS` is invalid unless that line is exactly `**Mock contract audit:** MOCK_CONTRACTS_VERIFIED`; the orchestrator fails closed to another cycle if the marker is missing. - Be accurate in test counts - don't round or estimate - Include specific failure information for debugging - Always post your findings as a GitHub comment before completing diff --git a/pdd/prompts/agentic_split_orchestrator_python.prompt b/pdd/prompts/agentic_split_orchestrator_python.prompt index c12a2e2eb..d47a5f592 100644 --- a/pdd/prompts/agentic_split_orchestrator_python.prompt +++ b/pdd/prompts/agentic_split_orchestrator_python.prompt @@ -24,6 +24,8 @@ Write the `pdd/agentic_split_orchestrator.py` module. Orchestrator for the agentic split workflow. Diagnoses whether a PDD dev unit has an architectural problem; if so, splits the full dev unit (prompt + code + example + tests) into smaller PDD-native dev units. Runs LLM steps as separate agentic tasks, executes deterministic gates (validation, tests, lint, regen, arch sync) in Python, accumulates context between steps, tracks cost, and supports resuming from saved state. After the main pipeline, optionally runs ONE focused refinement pass on a single flagged child. % Requirements + +Decorate `run_agentic_split_orchestrator` with `provider_failure_workflow` so direct library calls own one provider-health epoch across all steps. 1. Function signature: `run_agentic_split_orchestrator(target_file: str, *, cwd: Path, verbose: bool = False, quiet: bool = False, timeout_adder: float = 0.0, use_github_state: bool = True, diagnose_only: bool = False, propose_only: bool = False, delete_dead: bool = False, force_split: bool = False, no_verify: bool = False, skip_regen_gate: bool = False, experimental_language: bool = False, intent: Optional[str] = None, no_phase_extraction: bool = False, max_cost: Optional[float] = None) -> Tuple[bool, str, float, str, List[str]]` 2. Return 5-tuple: `(success, final_message, total_cost, model_used, changed_files)` 3. Language tier gate: `SUPPORTED_LANGUAGES = ["python"]`. Non-Python aborts with `"Language not supported: {lang}. Use --experimental-language."` unless `experimental_language=True`. diff --git a/pdd/prompts/agentic_sync_python.prompt b/pdd/prompts/agentic_sync_python.prompt index 9523a46b4..56c3a60fa 100644 --- a/pdd/prompts/agentic_sync_python.prompt +++ b/pdd/prompts/agentic_sync_python.prompt @@ -31,6 +31,8 @@ Entry point for sync orchestration. Supports two public workflows: 2. `run_global_sync(...)`: Tier 1 no-argument global sync. Triggered by the CLI when `pdd sync` is invoked with no BASENAME. Do not use PRD files, PRD fingerprinting, architecture schema migration, or agentic PRD analysis in v1. % Requirements + +Decorate `run_agentic_sync` with `provider_failure_workflow` so direct library calls own one provider-health epoch across all sync steps. 1. Function: `run_agentic_sync(issue_url: str, *, verbose: bool = False, quiet: bool = False, budget: Optional[float] = None, skip_verify: bool = False, skip_tests: bool = False, dry_run: bool = False, agentic_mode: bool = True, no_steer: bool = True, max_attempts: Optional[int] = None, timeout_adder: float = 0.0, use_github_state: bool = True, one_session: bool = False, reasoning_time: Optional[float] = None, durable: bool = False, durable_branch: Optional[str] = None, no_resume: bool = False, durable_max_parallel: Optional[int] = None, strength: Optional[float] = None, temperature: Optional[float] = None, context_override: Optional[str] = None, compressed_context: bool = False, local: bool = False) -> Tuple[bool, str, float, str]` 2. Return 4-tuple: (success, message, total_cost, model_used) 3. Parse GitHub issue URL to extract: owner, repo, issue_number (reuse `_parse_issue_url` from `agentic_change.py`) diff --git a/pdd/prompts/agentic_sync_runner_python.prompt b/pdd/prompts/agentic_sync_runner_python.prompt index 2f0fa5b9c..010b025d0 100644 --- a/pdd/prompts/agentic_sync_runner_python.prompt +++ b/pdd/prompts/agentic_sync_runner_python.prompt @@ -63,7 +63,8 @@ Parallel sync engine that runs `pdd sync` for multiple modules concurrently usin 9b. Architecture-conformance / public-surface / test-churn / prose-output retry/repair (issues #865, #1012, #1649): when a per-module `pdd sync` subprocess fails AND the captured stdout/stderr contains a repairable generation failure — detected via any of the line prefixes `Generation output extraction failure for `, `Architecture conformance error for `, `Public surface regression for `, or `Test churn threshold exceeded for ` — the runner MUST treat it as a repairable generation failure. Prose/empty-output failures (`Generation output extraction failure for `, detected by `_parse_prose_output_failure`) MUST be checked FIRST (before conformance/surface/churn), use `failure_kind = "prose_output"`, and are limited to 1 additional attempt (2 total). Conformance, public-surface, and test-churn failures retry up to `MAX_CONFORMANCE_ATTEMPTS = 3` total attempts (initial attempt + up to 2 repair attempts). Each repair attempt MUST set the `PDD_REPAIR_DIRECTIVE` environment variable in the subprocess env to a directive built by the appropriate parser — `_parse_prose_output_failure(stdout, stderr)`, `_parse_conformance_failure(stdout, stderr)`, `_parse_public_surface_failure(stdout, stderr)`, or `_parse_test_churn_failure(stdout, stderr)` — that names the exact issue and instructs the model on the correct output shape or missing symbols. The directive MUST NOT be set on the first attempt. Retries MUST NOT advance the per-module `current_phase` to a later phase; these failures restart the same generate-then-verify cycle. Repair retries do NOT count against any per-module test/fix attempt budget — they are a separate, prompt-level repair loop. When `total_budget` is set, every retry command MUST receive the remaining total budget after subtracting both persisted module costs and the current module's in-flight retry costs; if the in-flight repair cost exhausts the remaining total budget, stop retrying before launching another child process and record a hard failure. The runner MUST also keep the per-module resolved `.pddrc` context (Requirement 11a) and `--strength` (when present in `sync_options`) stable across EVERY retry attempt so a flaky regeneration cannot silently switch model or context between attempts. 9b.1. Repair retries MUST use the same compressed-context setting as the initial attempt. Parse child output/result metadata when available to surface compression and agentic-fallback status in progress summaries without printing raw compressed context content. 9c. Short-circuit on stuck failure signature: `_parse_prose_output_failure`, `_parse_conformance_failure`, `_parse_public_surface_failure`, and `_parse_test_churn_failure` MUST each return a normalized, sortable failure-signature tuple — `("prose",)` for prose-output (a fixed sentinel so a second consecutive prose response always triggers the short-circuit), sorted missing symbols for conformance, sorted removed symbols for public-surface, and `(round(churn_ratio, 2), pre_line_count)` for test-churn. If the failure signature on attempt N is identical to attempt N-1 (the LLM produced the same omission/regression/rewrite/prose-response twice in a row), the runner MUST stop retrying immediately and record the module as failed — additional retries with identical input are wasted budget. The recorded `error` MUST include the structured failure block (prose-output, conformance, public-surface, or test-churn) so callers (including the GitHub App) can surface it. -9d. Hard-failure diagnostic: when a module fails after all repair retries are exhausted (or short-circuits per 9c), the recorded `error` MUST include, in order: (1) the original failure reason from 9a, (2) a structured block whose header depends on the failure class — `=== generation output extraction failure ===` listing `prompt`, `output`, `language`, `model`, `extractor_result`, `raw_output_excerpt`, plus a provider-configuration note directing the user to check provider configuration (built by `build_prose_output_hard_failure_from_error`); `=== architecture conformance failure ===` listing `prompt`, `output`, `expected`, `found`, `missing` (built by `build_conformance_hard_failure_from_error`); `=== public surface regression ===` listing `prompt`, `output`, `removed`, `pre_surface_size`, `post_surface_size`, plus the `BREAKING-CHANGE:` opt-in note (built by `build_public_surface_hard_failure_from_error`); or `=== test churn threshold exceeded ===` listing `prompt`, `output`, `ratio`, `threshold`, `pre_line_count`, `post_line_count`, plus the `BREAKING-CHANGE:` opt-in note (built by `build_test_churn_hard_failure_from_error`) — one line each, (3) a local repro line `Reproduce locally: ` where `` is `_reproduce_command(basename)` (Requirement 11a) — `cd && pdd --context sync `, omitting the `cd` prefix when the module cwd is the project root and `--context` when none resolves (the standalone `build_*_hard_failure_from_error` helpers below have no runner cwd and keep the plain `Reproduce locally: pdd sync ` form), and (4) an `--- env ---` fingerprint block emitted via helper `_env_fingerprint()` containing `pdd.__file__`, `pdd --version` output, current git SHA, working-tree dirty status (`clean` or `dirty`), and whether the active `pdd` resolves inside the checked-out repo or `site-packages`. The fingerprint helper MUST be best-effort (each probe wrapped in try/except) so a missing git binary or an importable-but-uninstalled `pdd` never raises out of the failure path. All four `build_*_hard_failure_from_error` helpers MUST be importable from `pdd.agentic_sync_runner` so `sync_main` and `sync_orchestration` can reuse them verbatim. +9d. Hard-failure diagnostic: when a module fails after all repair retries are exhausted (or short-circuits per 9c), the recorded `error` MUST include, in order: (1) the original failure reason from 9a, (2) a structured block whose header depends on the failure class — `=== generation output extraction failure ===` listing `prompt`, `output`, `language`, `model`, `extractor_result`, `raw_output_excerpt`, plus a provider-configuration note directing the user to check provider configuration (built by `build_prose_output_hard_failure_from_error`); `=== architecture conformance failure ===` listing `prompt`, `output`, `expected`, `found`, `missing` (built by `build_conformance_hard_failure_from_error`); `=== public surface regression ===` listing `prompt`, `output`, `removed`, `pre_surface_size`, `post_surface_size`, plus the `BREAKING-CHANGE:` opt-in note (built by `build_public_surface_hard_failure_from_error`); or `=== test churn threshold exceeded ===` listing `prompt`, `output`, `ratio`, `threshold`, `pre_line_count`, `post_line_count`, `adopted` (issue #1903 §B.4 provenance from `exc.adopted_human`), plus the `BREAKING-CHANGE:` opt-in note (built by `build_test_churn_hard_failure_from_error`) — one line each, (3) a local repro line `Reproduce locally: ` where `` is `_reproduce_command(basename)` (Requirement 11a) — `cd && pdd --context sync `, omitting the `cd` prefix when the module cwd is the project root and `--context` when none resolves (the standalone `build_*_hard_failure_from_error` helpers below have no runner cwd and keep the plain `Reproduce locally: pdd sync ` form), and (4) an `--- env ---` fingerprint block emitted via helper `_env_fingerprint()` containing `pdd.__file__`, `pdd --version` output, current git SHA, working-tree dirty status (`clean` or `dirty`), and whether the active `pdd` resolves inside the checked-out repo or `site-packages`. The fingerprint helper MUST be best-effort (each probe wrapped in try/except) so a missing git binary or an importable-but-uninstalled `pdd` never raises out of the failure path. All four `build_*_hard_failure_from_error` helpers MUST be importable from `pdd.agentic_sync_runner` so `sync_main` and `sync_orchestration` can reuse them verbatim. +9e. Test-churn never-block for an ADOPTED co-located test (issue #1903 §B.4): the ONE conditional exception to 9d's hard-failure path is a `test_churn` failure whose churned test is a HUMAN-authored co-located test PDD ADOPTED, reached inside the issue-driven PR workflow. By the time churn exhausts here the child has already restored the pre-existing test and the coverage-preserving auto-accept (#2208) has refused — so this is the coverage-LOSING case. Issue #1903 requires the workflow to still open the working PR and flag that one test "needs review" rather than hand work back to the user. Relax the hard-fail ONLY when ALL THREE hold: (i) the run is issue-driven — it backs a GitHub issue → PR; a project-wide sync that opens NO PR keeps the strict hard-fail (there is nothing to flag "needs review" against, and relaxing it there would silently bypass coverage protection); (ii) the churn carries provenance that the test was ADOPTED from an existing human co-located test (unpinned, determined at path resolution BEFORE generation) — and that provenance MUST be UNFORGEABLE by untrusted project-test output: a churn block a hostile test merely PRINTS to the child's captured output must not be trusted, and the provenance parse MUST FAIL CLOSED on any absent, conflicting, or unauthenticated field (a pinned path, a PDD-created greenfield test, or missing/forged provenance all read as NOT adopted → strict hard-fail); (iii) the churned test path is an IN-REPO co-located shape — never a PDD-owned `tests/` shadow, a traversal, or an out-of-root path. When ALL hold, the runner MUST NOT record a hard failure — it flags the module for review with a persisted, operator-facing note naming the module and the kept test, emits an observable needs-review marker on stdout, and reports the module SUCCEEDED so the PR opens. When any guard fails, the module keeps the strict 9d hard-fail (coverage loss on a PDD-owned/greenfield test, or outside the issue-driven PR flow, is never silently swallowed). The needs-review state MUST survive a durable resume and surface both in the progress comment (rendered as `Success (needs review)` with a dedicated review section listing every note) and in the final run summary (which names the needs-review modules rather than claiming a wholly clean sync). This never-block applies ONLY to the issue-driven runner; standalone `pdd sync ` / `pdd test` always keep the strict test-churn hard-fail, and the coverage-preserving auto-accept remains the first line of recovery. 10. Per-module timeout: `MODULE_TIMEOUT = int(os.environ.get('PDD_MODULE_TIMEOUT_SECONDS', '2700'))` seconds (default 45 minutes); effective cap per module is `MODULE_TIMEOUT + max(0.0, float(sync_options.get('timeout_adder') or 0.0))` so `--timeout-adder` extends without ever shrinking it. Timeout-error message reports the effective seconds. 11. Child command construction must forward resolved global model/config knobs before the `sync` subcommand: `--strength` and `--temperature` when present in `sync_options`, plus `--local` when requested. This keeps repair retries pinned to the same model settings instead of letting each child re-resolve them differently. The `--context` flag is NOT forwarded raw — it is resolved per module (Requirement 11a), because each child runs in its own module cwd whose `.pddrc` may not define the global context. 11a. Per-module `--context` resolution (issue #1675): a child `pdd sync` runs in that module's own cwd (`module_cwds[basename]`), whose nearest `.pddrc` may not define the global `sync_options["context"]`. Forwarding a context invalid for the child cwd makes the child die with `Unknown context`; forcing a nested context back to repo root breaks the opposite case. Resolve the effective context per module via a helper `_compute_module_context(basename)` and emit `--context ` only when a value results. The decision MUST be: @@ -95,6 +96,7 @@ Parallel sync engine that runs `pdd sync` for multiple modules concurrently usin - `error: Optional[str]` - `current_phase: Optional[str]` — current phase name or None - `completed_phases: List[str]` — list of completed phase names (excludes skip: phases) +- `needs_review: Optional[str] = None` — set (issue #1903 §B.4) when an unreconcilable adopted co-located test was kept unchanged and flagged for review instead of hard-failing the issue-driven workflow. The module still counts as `success` (the PR is opened); this note is surfaced in the progress comment / PR thread. % GitHub Comment Updates - Create comment on first update (`gh api POST`), store `comment_id` @@ -158,11 +160,14 @@ Parallel sync engine that runs `pdd sync` for multiple modules concurrently usin All five shapes MUST route into the same repair-retry loop. The parser MUST stop the missing-symbol capture before `Output:`, `Expected:`, `Found:`, or the next `the prompt's ` sentence so dotted symbols such as `Class.method` are preserved while diagnostics are not treated as symbols. For shape (e), the per-entry split MUST be parenthesis-aware (track depth and split on top-level commas) so `(annotation: declared \`bool\`, found \`str\`)` survives intact; strip the parenthesised diagnostic when contributing to `missing_symbols` so the stuck-symbol-set short-circuit compares canonical dotted symbols. The `repair_directive` MUST be tailored to which shape produced each symbol: legacy export-missing entries (shapes (a) and (b)) go under `Required missing exports:` with the `Add these exact exports. Do not modify architecture.json. Do not remove existing valid exports.` footer; pdd-interface entries go under a separate section emitting `- Add the following missing function(s)/method(s) declared in the prompt: \`[, ...]\`.` for shape (c), `- On \`\`, add the following missing parameter(s) to the signature and corresponding code paths: \`[, ...]\`.` for shape (d) (regrouped via `rpartition('.')` so `ContentSelector.select.mode` attaches to `ContentSelector.select`, not `ContentSelector`), and `- Update the generated code so parameter (: declared \`\`, found \`\`) matches the prompt.` for shape (e). The pdd-interface section MUST end with the line `Do not remove the declared parameters from the prompt's . The prompt is the source of truth — update the generated code to match it.` Return `None` when no conformance failure or no concrete missing/offending symbol is detected so callers fall through to the normal error path instead of retrying with an empty directive. - `_env_fingerprint() -> str`: return a multi-line `--- env ---` block describing the active PDD installation (best-effort, never raises). Include: `pdd.__file__` (or ``), `pdd --version` stdout (first line; `` on error), current git SHA from `git rev-parse HEAD` (or ``), `git status --porcelain` -> `clean` or `dirty` (or ``), and a `source: repo|site-packages|unknown` line derived from whether `pdd.__file__` lives under the current working directory tree. -- `_parse_public_surface_failure(stdout: str, stderr: str) -> Optional[Tuple[str, Tuple[str, ...]]]` (#1012): scan combined stdout+stderr for the line prefix `Public surface regression for ` and, when matched, return `(repair_directive, signature)` where `signature` preserves distinct `removed:` and `signature_changed:` entries from the structured `removed:` and `signature_changed:` lines. The `repair_directive` MUST instruct the LLM to restore missing public symbols and compatible signatures in separate sections. Return `None` when no public-surface failure is detected so callers fall through to the normal error path. +- `_parse_public_surface_failure(stdout: str, stderr: str) -> Optional[Tuple[str, Tuple[str, ...]]]` (#1012): scan combined stdout+stderr for the line prefix `Public surface regression for ` and, when matched, return `(repair_directive, signature)` where `signature` preserves distinct `removed:` and `signature_changed:` entries from the structured `removed:` and `signature_changed:` lines. The `repair_directive` MUST instruct the LLM to restore missing public symbols and compatible signatures in separate sections. (#1900) The parser MUST ALSO recover the JSON-encoded `signature_detail:` lines the gate emits — each line is `signature_detail: ` where `` is a JSON object with `symbol`, `expected`, `actual`, and `source` keys (parsed via a `_parse_signature_detail_lines` helper using `json.loads` per line, delimiter-safe, malformed lines skipped) — and when a detail's `source` is `pdd-interface` (a prompt-declared-`` violation) the rebuilt `repair_directive` MUST carry the declared expected-vs-actual signature as the stable repair target and instruct the LLM to update the prompt's `` declaration to the intended signature (or restore the declared signature), NOT to add a `BREAKING-CHANGE:` directive — for declared symbols `BREAKING-CHANGE: change signature` relaxes only binding-kind/async, never the declared parameters. (#1968) For a `pdd-interface` detail the directive MUST inject the declared signature as a VERBATIM hard constraint — tell the LLM to emit each declared signature exactly, reproduce the declared annotation text token-for-token, and never substitute an equivalent-but-differently-spelled type (keep `object` as `object`, never `Any`) or broaden a declared parameter's type with `|` union members the declaration omits — so an annotation-level drift (declared `object` vs regenerated `Any`, or a broadened union) converges instead of the retry re-emitting the identical spelling. The `BREAKING-CHANGE:` restore/opt-in guidance stays for undeclared / removed-symbol violations. Return `None` when no public-surface failure is detected so callers fall through to the normal error path. - `_parse_test_churn_failure(stdout: str, stderr: str) -> Optional[Tuple[str, Tuple[float, int]]]` (#1012): scan combined stdout+stderr for the line prefix `Test churn threshold exceeded for ` and, when matched, return `(repair_directive, signature)` where `signature` is `(round(ratio, 2), pre_line_count)` extracted from the `ratio:` and `pre_line_count:` lines. The `repair_directive` MUST instruct the LLM to extend the existing test file rather than rewrite it, preserving existing test function names and unrelated coverage, and MUST include the footer `Add new tests for the prompt change without deleting accumulated regression tests. If a wholesale rewrite is intentional, add a line beginning with \`BREAKING-CHANGE:\` to the prompt body.` Return `None` when no test-churn failure is detected. - `build_prose_output_hard_failure_from_error(exc: ProseOutputError, basename: str) -> str` (#1649): render the `=== generation output extraction failure ===` block described in 9d, listing `prompt: {exc.prompt_name}`, `output: {exc.output_path}`, `language: {exc.language}`, `model: {exc.model_name}`, `extractor_result: {exc.extractor_result}`, and `raw_output_excerpt: {exc.raw_output_excerpt}`, plus a provider-configuration note (e.g., `"The model returned no extractable code. Check that the provider/model is configured to return complete source files, not planning text or agentic responses."`), ending with `Reproduce locally: pdd sync ` and the `_env_fingerprint()` output. Importable from `pdd.agentic_sync_runner` so `sync_main` and `sync_orchestration` can reuse it verbatim. - `build_public_surface_hard_failure_from_error(exc: PublicSurfaceRegressionError, basename: str) -> str` (#1012): render the `=== public surface regression ===` block described in 9d, ending with `Reproduce locally: pdd sync ` and the `_env_fingerprint()` output. Importable from `pdd.agentic_sync_runner` so `sync_main` and `sync_orchestration` can reuse it verbatim. -- `build_test_churn_hard_failure_from_error(exc: TestChurnError, basename: str) -> str` (#1012): render the `=== test churn threshold exceeded ===` block described in 9d, ending with `Reproduce locally: pdd sync ` and the `_env_fingerprint()` output. Importable from `pdd.agentic_sync_runner`. +- `build_test_churn_hard_failure_from_error(exc: TestChurnError, basename: str) -> str` (#1012): render the `=== test churn threshold exceeded ===` block described in 9d — including the `adopted: ` line from `getattr(exc, "adopted_human", False)` (#1903 §B.4) — ending with `Reproduce locally: pdd sync ` and the `_env_fingerprint()` output. Importable from `pdd.agentic_sync_runner`. +- The churned-test `output:` PATH and the `adopted:` PROVENANCE are read from the captured child output across EVERY test-churn block, trusting ONLY blocks the child AUTHENTICATED with the run's secret provenance token (handed to the child over a channel the grandchild test processes it later launches cannot access), and requiring each trusted block to carry the field and all trusted blocks to AGREE — so neither an unrelated diagnostic line NOR a block a hostile project test printed (which cannot carry the secret token) can leak in, and any absent/conflicting/unauthenticated provenance fails closed to the strict hard-fail. The path extraction FAILS CLOSED (yields no path → hard-fail) when absent or when the blocks carry CONFLICTING `output:` values; the adoption read yields True ONLY when at least one `adopted:` marker is present and EVERY marker across all blocks is `true` (a missing marker, any `false`, or conflicting values → False). Module-level constant `_TEST_CHURN_NEEDS_REVIEW_MARKER = "PDD_TEST_CHURN_NEEDS_REVIEW"` is the stdout marker emitted alongside the kept test path. +- `_is_adopted_collocated_test_path(test_path: Optional[str], *, project_root: Optional[Path] = None) -> bool` (#1903 §B.4): True ONLY when `test_path` is an IN-REPO, co-located test SHAPE. A NECESSARY (not sufficient) gate — pathname shape cannot prove human authorship, so it composes with the `issue_url` guard, the structured `adopted:` provenance, and the upstream coverage-preserving auto-accept. Production churn paths are typically ABSOLUTE (`construct_paths`/`resolve_test_output_path` emit a canonical absolute path); accept an absolute (or `~`) path ONLY after canonical containment inside `project_root` (the worktree root) — resolve it, require it to be under the root, then reduce to its repo-RELATIVE form for the shape checks; an absolute path with no `project_root` to validate against, or one outside the root, is REJECTED. REJECT a falsy/blank path, any `..` traversal component (CWE-022), and PDD's derived shadow root (a top-level `tests/` directory — covering both `tests/test_*.py` and `tests/*.test.ts`). ACCEPT a runner co-location convention: a file under a `__test__`/`__tests__` segment, a basename containing `.test.`/`.spec.` (JS/TS), OR a Python co-located sibling basename `test_.py` / `_test.py` OUTSIDE that top-level `tests/` shadow (issue #1903 supports adopting an existing co-located Python sibling, whose churn must reach the same never-block as JS siblings — review round 8). Never raises. +- `_register_test_churn_needs_review(self, basename: str, test_path: Optional[str]) -> str` (#1903 §B.4): implements 9e steps (a)–(b) for an already-classified adopted `test_path` — build the operator-facing note, emit the `PDD_TEST_CHURN_NEEDS_REVIEW: ` marker to stdout, set `ModuleState.needs_review` under `self.lock`, and return the note. Never raises. % State Management (Private Methods) - `_state_file_path() -> Path`: return `.pdd/agentic_sync_state.json` path diff --git a/pdd/prompts/agentic_test_orchestrator_python.prompt b/pdd/prompts/agentic_test_orchestrator_python.prompt index f99954802..e8997a360 100644 --- a/pdd/prompts/agentic_test_orchestrator_python.prompt +++ b/pdd/prompts/agentic_test_orchestrator_python.prompt @@ -7,6 +7,8 @@ The module is an **Agentic Test Orchestrator** for an 18-step automated test generation workflow. Its responsibility is to manage the lifecycle of an "issue-to-test" conversion process by executing sequential and conditional agentic tasks. It maintains state, handles workspace isolation via git worktrees, manages complex context passing between steps (including special handling for web/Playwright tests), and supports resume-from-failure functionality using both local and remote (GitHub) state persistence. # Requirements + +Decorate `run_agentic_test_orchestrator` with `provider_failure_workflow` so direct library calls own one provider-health epoch across all steps. 1. **Workflow Execution**: Implement a linear sequence of 18 steps (including fractional steps like 5.5) defined by specific prompt templates. 2. **State Management**: - Support persistence and resumption using `load_workflow_state` and `save_workflow_state`. diff --git a/pdd/prompts/auto_deps_main_python.prompt b/pdd/prompts/auto_deps_main_python.prompt index 422b48080..f57cbda3b 100644 --- a/pdd/prompts/auto_deps_main_python.prompt +++ b/pdd/prompts/auto_deps_main_python.prompt @@ -16,6 +16,7 @@ validate_prompt_includes_python.prompt operation_log_python.prompt auto_deps_architecture_python.prompt +fingerprint_transaction_python.prompt % You are an expert Python engineer. Your goal is to write the `auto_deps_main` function that will be part of the `pdd` command-line program. This function will handle the core logic for the `auto-deps` command. @@ -83,23 +84,23 @@ Use a `.lock` suffix file (e.g., `{csv_path}.lock`) for the lock. - Error handling: • On `click.Abort` exception: re-raise to allow the orchestrator to stop the sync loop. + • On `FingerprintFinalizeError`: re-raise so a mutated prompt cannot return a false success. • On any other exception: print a red error message (unless `quiet`) and return an error tuple `("", 0.0, f"Error: {exc}")` instead of calling `sys.exit(1)`. This allows the orchestrator to handle failures gracefully. - Metadata finalization (on successful runs only — skip on the error path above): • After the modified prompt and any CSV output have been written successfully, finalize fingerprint and include-dependency state for the file that was actually written (`output_file_paths["output"]`) so downstream commands see a consistent view. This must run for *every* successful auto-deps mutation — both the default CLI flow (where the output is `__with_deps.prompt`) and the in-place flow (where the output equals the input `prompt_file`). Issue #989 requires this for every mutated prompt file; do not gate the finalizer on `output_path == prompt_file`. - • Use the existing helpers from `pdd.operation_log` (`infer_module_identity`, `save_fingerprint`, `clear_run_report`, `get_run_report_path`). Behavior must be language-agnostic (hash-based). + • Use the shared `pdd.operation_log` helpers (`infer_module_identity`, `resolve_fingerprint_paths`, `_clear_run_report_before_fingerprint`, `get_fingerprint_path`, and `save_fingerprint`). The public save wrapper is the only persistence route and delegates to `FingerprintTransaction`; behavior remains language-agnostic and hash-based. • Derive identity from the *output* path, not the input prompt: - - Resolve `(basename, language)` via `infer_module_identity(Path(output_path))`. The output path is the file whose hash and dependency state the fingerprint records; tying the identity to that file keeps `.pdd/meta` self-consistent even when the canonical prompt is untouched (default mode) or when the file is the canonical prompt itself (in-place mode). `infer_module_identity` returns `(None, None)` for unrecognized prompt names — check `basename is None or language is None` explicitly and skip finalization (logged as a yellow warning) rather than guessing. - - Clear any stale per-module run report via `clear_run_report(basename, language, paths={"prompt": Path(output_path)})` because the prompt content changed. If `clear_run_report` raises, log a warning and continue — it must not abort the subsequent fingerprint save. - - After invoking `clear_run_report`, verify the report path is actually gone via `get_run_report_path(basename, language, paths={"prompt": Path(output_path)})`; if it still exists, log a yellow warning (unless `quiet`). This is defensive because the helper silently swallows unlink failures. - - Write/update the fingerprint via `save_fingerprint(...)` with `operation="auto-deps"`, `paths={"prompt": Path(output_path)}`, `cost=total_cost`, and `model=model_name`, recording the new prompt hash and the current include-dependency hashes so the fingerprint write and the include-dependency metadata update are committed together (atomic from the caller's perspective). + - Resolve `(basename, language)` via `infer_module_identity(Path(output_path))`. The output path is the file whose hash and dependency state the fingerprint records. If either value is missing, the destination is an unmanaged/redirected output rather than a PDD unit: emit a yellow skip message unless quiet and return the successful artifact tuple without module metadata. + - Build the complete path set with `resolve_fingerprint_paths(basename, language, output_path, paths={"prompt": Path(output_path)})`. This preserves the written prompt while retaining code/example/test hashes for registered units; unregistered derivative outputs retain the explicit prompt path. + - Call `_clear_run_report_before_fingerprint(..., paths=resolved_paths)`. If it returns false, raise `FingerprintFinalizeError` using `get_fingerprint_path(..., paths=resolved_paths)` and cause `"run report not cleared"`. + - Commit with `save_fingerprint(..., operation="auto-deps", paths=resolved_paths, cost=total_cost, model=model_name)`. Re-raise `FingerprintFinalizeError` before the broad error-tuple handler so finalization is a non-zero command failure. • Architecture file updates (architecture.json) are merged by `merge_auto_deps_includes_from_cwd` and are not finalized here — that file is not a `(basename, language)`-keyed module and has its own atomicity guarantees inside the merge helper. - • Surface non-exception failures from the architecture merge (return shape `{"updated": bool, "messages": list[str], …}`): if the helper returned `updated=False`, emit each `messages` entry as a yellow `Warning: architecture.json not updated: …` line so the success message does not mask a silent no-op. + • Surface non-exception failures from the architecture merge (return shape `{"updated": bool, "messages": list[str], "added_dependencies": list[str], …}`): when `updated=False` AND `added_dependencies` is non-empty, emit each message as a yellow `Warning: architecture.json not updated: …` line. `updated=False` with no requested additions is a legitimate no-op. • Surface invalid `` tags stripped by `sanitize_prompt_output` (it returns `(cleaned_prompt, invalid_includes: list[str])`): if `invalid_includes` is non-empty, emit a yellow warning naming the count, the output path, and the stripped entries so the user knows generated dependencies were dropped. - • Finalization errors must not mask a successful `auto-deps` result: log a yellow warning (unless `quiet`) and continue; do not raise. - • NOTE: `pdd sync` invokes `auto_deps_main` with a temp `__with_deps.prompt` output and then `shutil.move`s it onto the canonical prompt. To avoid leaving an orphan `.pdd/meta/*_with_deps.json` for that temp identity after the move (and to avoid clearing the wrong run report), sync passes the internal flag `_skip_finalization=True` and owns canonical metadata itself: it writes the canonical fingerprint via `_save_fingerprint_atomic` (`pdd/sync_orchestration.py` around line 2719) and clears the canonical `_run.json` via `clear_run_report` immediately after the move. When `_skip_finalization=True`, this finalizer must return immediately after the success console output without writing a fingerprint or clearing any run report. + • NOTE: `pdd sync` invokes `auto_deps_main` with a temp `__with_deps.prompt` output and then moves it onto the canonical prompt. To avoid orphan derivative metadata, sync passes `_skip_finalization=True`; after the artifact/console work, return the successful tuple before identity lookup or metadata writes. Sync owns canonical fingerprint finalization through `_save_fingerprint_atomic` and clears the canonical run report after the move. -pdd/operation_log.py +pdd/operation_log.py diff --git a/pdd/prompts/checkup_review_loop_python.prompt b/pdd/prompts/checkup_review_loop_python.prompt index bcfd010ca..65bd09cc9 100644 --- a/pdd/prompts/checkup_review_loop_python.prompt +++ b/pdd/prompts/checkup_review_loop_python.prompt @@ -9,9 +9,97 @@ "type": "module", "module": { "functions": [ - {"name": "run_checkup_review_loop", "signature": "(*, context: ReviewLoopContext, config: ReviewLoopConfig, cwd: Path, verbose: bool, quiet: bool, use_github_state: bool) -> Tuple[bool, str, float, str]", "returns": "Tuple[bool, str, float, str]"}, - {"name": "parse_reviewers", "signature": "(value: str | Sequence[str] | None) -> Tuple[str, ...]", "returns": "Tuple[str, ...]"} - ] + { + "name": "run_checkup_review_loop", + "signature": "(*, context: ReviewLoopContext, config: ReviewLoopConfig, cwd: Path, verbose: bool, quiet: bool, use_github_state: bool) -> Tuple[bool, str, float, str]", + "returns": "Tuple[bool, str, float, str]", + "sideEffects": [ + "Creates one PR worktree for the entire loop session via _setup_pr_worktree", + "Runs primary-reviewer/fixer/verifier CLI agents against that worktree", + "Can invoke one normalized reviewer_fallback if the primary reviewer cannot complete; the fallback becomes active for subsequent review/fix/verify gating", + "Requires distinct reviewer/fixer roles by default; when ReviewLoopConfig.allow_same_reviewer_fixer is true and the resolved roles are equal, runs explicit single-role review/fix mode without writing the legacy fixer sentinel into reviewer_status. The final report renders same-role-review-fix: true and final-state.json records same_role_review_fix=true with mode=single-role-review-fix", + "Can invoke one alias-normalized fixer_fallback when the primary fixer reports success=False (typical: credential-limit). Before the fallback runs, captures the pre-fix HEAD SHA and resets the worktree (git reset --hard <sha> + git clean -fd) so failed primary edits do not leak into the next commit. The fallback is skipped when it resolves to the primary fixer, the active reviewer, or the originally configured reviewer (ReviewLoopState.original_reviewer) to preserve reviewer/fixer role independence. On success the fallback becomes ReviewLoopState.active_fixer and drives every subsequent round; the fallback is one-shot for the entire loop. Both the failed primary FixResult and the fallback FixResult (success or failure) are appended to state.fixes for the audit trail; failed-fixer summaries are routed through _defang_adapter_trip_wires before rendering so trip-wire tokens cannot downgrade an otherwise-verified fallback verdict", + "In review-only mode, runs only the primary reviewer and never fetches push metadata, commits, or pushes", + "Commits with PDD Bot identity and pushes fixes to the PR head ref via the shared push_with_retry helper with force_with_lease_on_non_fast_forward disabled (refuses to leave tokenized URL in git config)", + "If the PR head advances remotely during the fixer turn, fetches refs/heads/<head_ref> from the updated head repo with token auth fallback, runs a plain git rebase --onto FETCH_HEAD <upstream> HEAD where <upstream> is local_base_sha when supplied (typically pre_fix_sha, per Issue #1433 Bug #3 + codex pass-2 F3 to replay the FULL multi-commit local range from agentic autoheal) and falls back to HEAD~1 for the legacy single-commit case, then retries the push instead of force-pushing over remote work; rebase conflicts abort before verifier", + "When a budget cap is crossed during a successful fixer turn, still commits and pushes the completed fixes before stopping prior to verifier execution", + "Feeds fixer rejections back to the primary reviewer and keeps reviewer-rejected findings open until fixed or max rounds", + "Issue #1088 SHA-backed verification trust boundary: captures the post-push HEAD SHA via _git_rev_parse_head (never inferred from fixer prose), populates FixResult.push_status (pushed | push_failed | not_attempted), FixResult.local_fixer_commit_sha, and FixResult.pushed_head_sha after every fix turn, and rewrites the per-round fix findings.json artifact so the on-disk audit trail carries those fields. The verifier only runs when push_status == pushed AND a non-empty pushed_head_sha was observed; otherwise the round is marked skipped/unverified and the loop stops without claiming the findings as fixed. A clean verify pass pins state.verified_head_sha to that pushed SHA", + "Issue #1088 final stale-head re-fetch: _finalize calls _fetch_pr_metadata exactly once at render to read the live remote head_sha whenever there is anything to verify — state.verified_head_sha is set (a verifier pinned a SHA), state.fresh_final_status == clean (a reviewer cleared the actual worktree HEAD without a subsequent fixer push), any fix was pushed, or any finding is marked fixed. The comparison target is state.verified_head_sha when a verifier ran clean, otherwise the most recent FixResult.pushed_head_sha for partial verifier acceptance, otherwise state.reviewed_head_sha captured from git rev-parse HEAD in the worktree the reviewer inspected (never from later PR metadata). If the remote head differs from the comparison target, the comparison target was never observed, or the re-fetch returned no head_sha, downgrades fresh_final_status to missing, downgrades every verification_status_by_round entry from verified to stale, and reverts every findings_by_key entry from fixed to open so final-state.json cannot present a stale verdict. state.final_refetch_attempted is set so the render layer can distinguish a failed re-fetch (remote-pr-head-sha: unknown) from no re-fetch at all (remote-pr-head-sha: none)", + "Qualifies all unverified fixer rationale prose in the final report as fixer=<role> fixer_disposition=<value> / fixer_rationale=<value> and appends verification=unverified, so bare fixer claims such as 'claude: fixed - ...' never appear as verifier evidence", + "Writes per-round prompt/output/normalized-findings/dedup-state artifacts and a final-state.json under .pdd/checkup-review-loop/issue-{N}-pr-{M}/. final-state.json includes same_role_review_fix, mode, role_independence, verified_head_sha, remote_pr_head_sha, reviewed_head_sha, source_of_truth, gates, and verification_status_by_round so downstream consumers can re-verify the trust boundary and distinguish independent reviewer/fixer runs from explicit single-role review/fix runs", + "Issue #1941 auto-degrade: the loop does NOT dead-end with zero fix attempts when the only surviving reviewer is the same family that must also fix. This fires AUTOMATICALLY (no opt-in flag) from EITHER fallback mechanism: (a) the explicit reviewer_fallback promotes a surviving cross-family reviewer AND the configured fixer IS the just-failed primary's (now-unavailable) identity/family, OR (b) the opt-in fallback_reviewer_on_failure promotes the fixer's own identity to a fallback reviewer because the primary reviewer's family is unavailable — and in either case that fallback reviewer reports actionable findings. On degrade it discloses same_role_review_fix=true and role_independence='degraded (<primary role> unavailable)', has the surviving fallback family (not the unavailable configured fixer) perform the fix, and runs a fresh same-family fix + required fresh-verify. The superseded primary still renders '(optional, superseded by <active_reviewer>)' so the cloud verdict adapter can ship on a degraded-but-clean result. If the same-family fixer also fails, the fix-failure stop_reason names the vacancy via a trailing '[role-independence <value>]'. role_independence stays 'independent' when both families are alive and for deliberate config-time allow_same_reviewer_fixer runs", + "Posts the final report to the source issue and PR only when use_github_state=True (writes-only suppression flag)", + "Issue #1092: at every clean-exit site (round-start LLM clean, review-only clean, post-verify clean, pending-findings-empty early exit, and fallback-reviewer clean), invokes _enforce_gates_before_clean to run pdd.checkup_gates over the loop-owned worktree; any failing deterministic gate (PR-range git diff --check, prettier --check, a non-mutating Python syntax check via builtin compile(), optional ruff/black/mypy/`npx --no-install tsc --noEmit`) is converted to a synthetic blocker ReviewFinding (reviewer prefix `gate:`) that the loop feeds back into the fixer rather than declaring clean", + "Issue #1092 base-ref refresh: when config.enable_gates is True the loop calls pdd.agentic_checkup_orchestrator._refresh_pr_base_ref after _fetch_pr_metadata, fetching the PR's base branch into the dedicated tracking ref refs/remotes/pdd-checkup/pr-{N}/base (NEVER refs/remotes/origin/<base>, which would mutate the operator's tracking refs when their origin is a fork) and populating pr_metadata['base_local_ref']. The fetch is bounded by a 60s subprocess timeout so a stalled transport cannot hold the review loop forever. The refresh helper resolves a trusted absolute git binary through pdd.checkup_gates and uses the sanitized gate env for its git-root, remote, and fetch subprocesses, so PATH=.:$PATH cannot execute a PR-shipped ./git before gate discovery. The review-loop short-circuit that skipped _fetch_pr_metadata in review-only mode is suppressed when gates are enabled so review-only on non-main-base PRs still gets the PR-range git-diff-check guarantee. discover_gates resolves base_ref from pr_metadata['base_local_ref'] first and falls back to the raw base_ref name when the refresh helper succeeded but did not land the ref", + "Issue #1092 fail-closed on refresh error: when _refresh_pr_base_ref populates pr_metadata['base_ref_fetch_error'] (subprocess CalledProcessError, TimeoutExpired, or other failure), _enforce_gates_before_clean appends a crash-row to state.gate_runs (phase='base-ref-refresh') and returns a single synthetic gate:base-ref blocker ReviewFinding. The loop refuses the clean verdict the same way it does for a crashed gate runner — never silently falls back to origin/main or worktree-only git diff --check, both of which would let an LLM clean verdict ride over a check we cannot prove ran against the right base", + "Issue #1092 fail-closed on metadata fetch: when _fetch_pr_metadata returns no usable base_ref (gh API outage, auth error, invalid JSON), the loop sets pr_metadata['base_ref_fetch_error'] BEFORE the refresh-call site so the same gate:base-ref fail-closed path engages — the pre-fix guard (if pr_metadata.get('base_ref')) silently skipped the refresh and let gates run with base_ref=None", + "Issue #1092 fail-closed on changed-files fallback: _pr_changed_files_all walks the base-candidate list using a trusted absolute git binary plus the sanitized gate env, then falls back to HEAD~1...HEAD only when every authoritative base diff failed. When the caller passed a real pr_metadata (base_ref or base_local_ref set) and the scanner had to fall back, the scanner records pr_metadata['changed_files_fallback']. _enforce_gates_before_clean then emits a single gate:changed-files blocker (phase='changed-files-resolution') so iter-30 node_modules and iter-27/iter-32 config-touched skips cannot be silently bypassed by a truncated inventory missing earlier-commit poisoning (iter-34 Finding 1). The same trusted git path is used by the sibling _pr_changed_python_files scanner so static drift detection cannot execute a PR-shipped ./git either. The same sentinel is also set — and same blocker emitted — when _pr_changed_files_all RAISES; the previous 'swallow into []' path proceeded with an empty inventory and let the safety skips no-op (iter-35 Finding 1). Exception text is scrubbed before storage. The fallback path stays valid when no base_ref was expected (unit-test paths)", + "Issue #1433 Bug #1 (pre-flight base-merge probe): immediately after _setup_pr_worktree and _refresh_pr_base_ref but BEFORE the first reviewer round, calls _detect_pr_base_merge_conflict(worktree, pr_metadata['base_local_ref']). The helper verifies the base ref with git rev-parse --verify, then runs `git merge-tree --write-tree --no-messages <base_local_ref> HEAD` (requires git >= 2.38; exit 0 = clean, exit 1 = conflict, other = cannot decide). Fail-OPEN on cannot-decide so non-PR worktrees, old git, or unresolvable refs do not become a new spurious block. When a real conflict is detected the loop creates a synthetic blocker ReviewFinding (reviewer='preflight:base-merge', area='pr-merge-conflict', round_number=0), persists preflight-base-merge-conflict.txt under artifacts_dir, sets reviewer_status[reviewer]='findings' + stop_reason, renders the final report via _finalize, posts via _post_review_loop_report, and returns with zero LLM spend. Codex review pass-2 finding F5: pre-flight only probes against base_local_ref freshly fetched by _refresh_pr_base_ref — a stale origin/<base> fallback would risk false positives on fork-origin operators. When base_ref_fetch_error is set or base_local_ref is unavailable the pre-flight is skipped and the existing gate:base-ref blocker handles the base problem", + "Issue #1433 Bug #3 (push fixer-subprocess commits): _commit_and_push_if_changed accepts a pre_fix_sha kwarg the loop captures via git rev-parse HEAD before invoking the fixer. When the fixer subprocess (typically codex autoheal) already committed inside the worktree, _git_changed_files returns empty (working tree is clean) but HEAD has moved past pre_fix_sha. The pre-fix function returned 'No changes to push' and the autoheal commit silently rotted. The helper now treats current_head != pre_fix_sha as has_unpushed_local_commits INDEPENDENTLY of the dirty flag (codex pass-2 F2 — the previous `not changed and ...` gate failed when the worktree had only filtered .pdd/checkup-context / .pdd/meta artifacts) and pushes the existing commits AS-IS, preserving the original author/message (e.g., 'Codex Local Autoheal') without creating a redundant PDD-Bot commit on top", + "Issue #1433 Bug #3 + codex pass-2 F3 (rebase full local range): _rebase_onto_updated_pr_head accepts a local_base_sha kwarg (typically pre_fix_sha). When supplied, the rebase upstream is `<local_base_sha>` instead of the hard-coded `HEAD~1`, so a multi-commit local range (typical for agentic autoheal that lands two or more commits per round) replays in full onto FETCH_HEAD. The pre-F3 single-commit HEAD~1..HEAD form is preserved when local_base_sha is None or equal to fixer_sha so legacy/test callers see no behaviour change", + "Issue #1433 Bug #3 (extended guard attention): the loop body unions _git_changed_files(worktree) with _files_changed_since(worktree, pre_fix_sha) before invoking _check_architecture_registry_edit_guard and _check_prompt_source_guard. _files_changed_since parses `git diff --name-status -z --find-renames <pre_fix_sha>..HEAD` and surfaces BOTH old and new paths for R records (matching the _git_changed_files contract via git_porcelain) and the destination only for C records (codex pass-2 F4 — the previous --name-only form let a committed rename of a prompt-owned code file out of pdd/ slip past the guard). The union ensures the #1063 prompt-source and #1081 registry-edit guards still fire on changes the fixer subprocess COMMITTED rather than leaving in the working tree", + "Issue #1433 Bug #3 + codex pass-2 F1 (guard baseline pinned to pre_fix_sha): _check_prompt_source_guard, _check_architecture_registry_edit_guard, _load_prompt_source_map, and _path_exists_at_head all accept a head_ref kwarg (default 'HEAD' preserves backward compat for non-loop callers including the reviewer-prompt companion-files collector). The loop pins head_ref=pre_fix_sha when computing the guard baseline so the comparison is against the pre-fixer snapshot. Without this, a fixer subprocess that COMMITTED a registry repoint/removal would compare the post-fix registry against itself — zero diff — and bypass both guards entirely (defeating the protections #1063 and #1081 originally added). _path_exists_at_head's 'did this path exist at baseline?' check also threads head_ref so the unregistered-new-code scan judges additions against the pre-fix tree", + "Issue #1433 Bug #4 (reviewer companion source-of-truth attention): _run_review computes _collect_companion_source_of_truth_files(worktree, pr_metadata) and threads it into _review_prompt via the companion_source_of_truth kwarg. _format_companion_source_of_truth renders a `## Companion Source-Of-Truth Files To Inspect` section listing {code_path, prompt_path, prompt_in_diff, architecture_in_diff} for every code file in the PR diff. Codex pass-6 finding 1: the skip condition is `prompt_in_diff AND arch_entry_in_diff` where arch_entry_in_diff is a PER-ENTRY signal computed via _arch_entries_changed_set (canonical sorted-keys JSON comparison of base vs HEAD architecture.json entries per code_path); pass-5's prompt-only skip rule missed the case where module A's prompt was co-edited but A's architecture entry was unchanged (an arch.json edit for unrelated module B set the global signal True and hid A's at-risk arch surface). Reuses _load_prompt_source_map (which reads `git show HEAD:architecture.json` to derive the canonical code <-> prompt mapping) and _pr_changed_files_all. Always fail-open: any git/IO/JSON error returns [] so the section just disappears from the prompt rather than breaking the round. Codex pass-4 finding 2 + pass-5 finding 2 + pass-6 finding 2: when the eligible set exceeds max_entries (default 200) the collector appends a synthetic marker dict {`__truncated__`: True, `total_eligible`, `shown`, `omitted_code_paths` bounded at `max_entries // 2`, `omitted_paths_remaining`}; _run_review additionally persists the FULL eligible list as a sidecar JSON artifact (`round-{N}-{mode}-companion-source-of-truth.json` under artifacts_dir) and passes the artifact path to the formatter so the truncation note instructs the reviewer to either confirm artifact-based coverage or REPORT a `severity=blocker` finding (reviewer='companion-scope', area='workflow'). The bounded prompt section + sidecar artifact together keep the prompt context budget predictable while giving the reviewer (and post-hoc audits) the complete coverage record" + ] + }, + { + "name": "parse_reviewers", + "signature": "(value: str | Sequence[str] | None) -> Tuple[str, ...]", + "returns": "Tuple[str, ...]", + "sideEffects": [ + "None" + ] + } + ], + "dataclasses": [ + "ReviewLoopContext", + "ReviewLoopConfig", + "ReviewLoopState", + "ReviewFinding", + "ReviewResult", + "FixResult" + ], + "config_knobs": [ + "reviewers", + "reviewer", + "fixer", + "reviewer_fallback", + "fixer_fallback", + "review_only", + "max_rounds", + "max_cost", + "max_minutes", + "require_all_reviewers_clean", + "continue_on_reviewer_limit", + "require_final_fresh_review", + "blocking_severities", + "clean_reviewer_states", + "fallback_reviewer_on_failure", + "timeout_adder", + "reasoning_time", + "enable_gates", + "gate_timeout", + "gate_allow", + "enable_source_of_truth_repair", + "allow_same_reviewer_fixer" + ], + "artifacts_layout": { + "directory": ".pdd/checkup-review-loop/issue-{issue_number}-pr-{pr_number}/", + "per_round": [ + "round-{N}-{review|verify|fallback}-{role}.prompt.txt", + "round-{N}-{review|verify|fallback}-{role}.output.txt", + "round-{N}-{review|verify|fallback}-{role}.findings.json", + "round-{N}-fix-{fixer}-for-{reviewer}.prompt.txt", + "round-{N}-fix-{fixer}-for-{reviewer}.output.txt", + "round-{N}-fix-{fixer}-for-{reviewer}.findings.json", + "dedup-state-round-{N}.json" + ], + "final": [ + "final-report.md", + "final-state.json" + ] + } } } @@ -25,6 +113,7 @@ % Goal Add a PR-mode primary-reviewer/fixer review loop for `pdd checkup`. +Decorate `run_checkup_review_loop` with `provider_failure_workflow` so direct library calls own one provider-health epoch across reviewer, fixer, and verifier rounds. % Public API 1. `parse_reviewers(value: str | Sequence[str] | None) -> Tuple[str, ...]`. @@ -67,7 +156,7 @@ Add a PR-mode primary-reviewer/fixer review loop for `pdd checkup`. - Delegate the push itself to the shared retry helper, passing the head repo's `clone_url`, `HEAD:{head_ref}` as the refspec, and `force_with_lease_on_non_fast_forward=False`. - If a push fails because the PR branch advanced remotely (`fetch first`, `non-fast-forward`, remote contains work, or branch-behind wording), MUST NOT force-push over the remote commit. Instead fetch the exact PR branch ref as `refs/heads/{head_ref}` into `FETCH_HEAD` (retrying fetch with the same token sources as push auth if needed), then rebase the FULL local commit range onto the fetched head using plain `git rebase --onto FETCH_HEAD HEAD`, invoked with the same explicit `-c user.name=PDD Bot -c user.email=pdd-bot@users.noreply.github.com` overrides the commit step uses because the worktree's git config cannot be trusted to carry a usable committer identity (under pytest's HOME isolation and on cloud-batch runs Linux git refuses the auto-detected `root@.(none)` identity and aborts the rebase). The `` is `local_base_sha` when the caller supplied it (typically `pre_fix_sha` — see 1433-B rebase) so a multi-commit local range (agentic-autoheal landing two or more commits per round) replays in full; otherwise fall back to `HEAD~1` for backward compat with the legacy single-commit case. This prevents a force-pushed PR head from resurrecting old commits that the remote intentionally dropped. Retry the same non-force push after rebase, allowing a small bounded number of fetch/rebase/retry cycles in case another checkup attempt or bot advances the branch again between the fetch and retry. If fetch or rebase fails, abort before verifier because the PR still does not contain the local fix. Cap the retry budget at up to three push attempts (two intermediate rebases) and surface the final error redacted via `_redact_secret` against `_github_token_from_env()` so tokenized URLs that git may echo back from the push helper never leak into the user-visible message. If `git rebase --abort` itself fails during cleanup, append its error to the rebase-failure message instead of swallowing it. The retry loop MUST capture the fixer commit's SHA via `git rev-parse HEAD` immediately after the bot commit succeeds (or after detecting an already-committed fixer commit per 1433-B), and BEFORE each rebase retry MUST `git reset --hard ` so the worktree's HEAD always points at the original fixer commit before the rebase runs. This is required because git can fast-forward or drop the fixer commit as empty when the fetched PR head already contains the patch, leaving HEAD on a remote commit; without the reset, a subsequent retry's `..HEAD` range would describe that remote commit and could resurrect work a maintainer force-pushed away. 10a. Prompt-source guard. After the fixer (primary or fallback) reports success and BEFORE `_commit_and_push_if_changed`, the loop MUST run a deterministic guard `_check_prompt_source_guard(worktree, changed_files)` over the worktree's current change set. The guard exists because PDD's source-of-truth contract requires that any edit to a prompt-owned module is accompanied by the matching prompt edit; otherwise the bot is silently producing prompt/code drift and the next `pdd sync` overwrites the bot's edits (issue #1063, reproduction commit `bf57242d`). The policy MUST live at the policy layer (the main loop), NOT inside `_commit_and_push_if_changed` — the push helper does staging/commit/push only. - - Mapping rule: read `architecture.json` from `git show HEAD:architecture.json` executed in the worktree (NOT the worktree filesystem, NOT `project_root`, NOT `cwd`, NOT the user's primary checkout). Reading from the worktree filesystem would let the fixer remove its own registry entry in the same change set and slip past the guard (review pass #3 Finding 2). The pre-fixer `HEAD` is the canonical registry; the guard MUST NOT fall back to the worktree filesystem when the HEAD read fails (that re-opens the evasion hole). Parse the bare-list shape via `pdd.architecture_registry.extract_modules`. For each module with both `filename` (e.g. `"agentic_update_python.prompt"`) and `filepath` (e.g. `"pdd/agentic_update.py"`), record the mapping `Path(filepath).as_posix() -> "pdd/prompts/{filename}"`. Normalize all paths to POSIX form before comparing against `_git_changed_files(worktree)`. `_git_changed_files` MUST delegate to `pdd.git_porcelain.parse_porcelain_z` + `pdd.git_porcelain.iter_changed_paths` on raw bytes from `git status --porcelain=v1 -z` so renames surface BOTH the new and old paths as discrete entries while copies surface ONLY the new (destination) path; the copy source is referenced by git but is not itself modified and must NOT appear as a changed path (if it did, the prompt-source guard would falsely refuse a fix that merely copied an existing prompt-owned module). Collapsing a rename into the literal `"old -> new"` string (the `--porcelain` non-`-z` shape) lets a registered code file move out from under the registry without tripping the guard (rename-handling gap, codex review pass #2 Finding A). + - Mapping rule: read `architecture.json` from `git show HEAD:architecture.json` executed in the worktree (NOT the worktree filesystem, NOT `project_root`, NOT `cwd`, NOT the user's primary checkout). Reading from the worktree filesystem would let the fixer remove its own registry entry in the same change set and slip past the guard (review pass #3 Finding 2). The pre-fixer `HEAD` is the canonical registry; the guard MUST NOT fall back to the worktree filesystem when the HEAD read fails (that re-opens the evasion hole). Parse the bare-list shape via `pdd.architecture_registry.extract_modules`. For each module with both `filename` (e.g. `"agentic_update_python.prompt"`) and `filepath` (e.g. `"pdd/agentic_update.py"`), record the mapping `Path(filepath).as_posix() -> "{prompts_root}/{filename}"` where `prompts_root` is the repo-under-repair's resolved base prompts root from `_resolve_target_prompts_root(worktree)` (see helper spec below). `filename` in `architecture.json` is stored relative to the target repo's TOP-LEVEL prompts root, so the owning prompt MUST be reconstructed against THAT root — NOT a hardcoded `pdd/prompts/` prefix. pdd is self-hosted (its prompts live under `pdd/prompts` with a `prompts -> pdd/prompts` symlink), so a literal `pdd/prompts/` prefix only ever resolved on pdd's own checkout; on every external repo it probed a nonexistent path and wrongly classified the owning prompt as deleted, refusing to repair on any repo that is not pdd itself (issue #1957). Resolve `prompts_root` ONCE per call and reuse it for every entry. Normalize all paths to POSIX form before comparing against `_git_changed_files(worktree)`. `_git_changed_files` MUST delegate to `pdd.git_porcelain.parse_porcelain_z` + `pdd.git_porcelain.iter_changed_paths` on raw bytes from `git status --porcelain=v1 -z` so renames surface BOTH the new and old paths as discrete entries while copies surface ONLY the new (destination) path; the copy source is referenced by git but is not itself modified and must NOT appear as a changed path (if it did, the prompt-source guard would falsely refuse a fix that merely copied an existing prompt-owned module). Collapsing a rename into the literal `"old -> new"` string (the `--porcelain` non-`-z` shape) lets a registered code file move out from under the registry without tripping the guard (rename-handling gap, codex review pass #2 Finding A). - Fail-closed behavior: for every changed file whose POSIX path is a registered code-path key, if the corresponding prompt path is NOT also in the changed set, that pair is an offender. Collect ALL offending pairs (do not short-circuit), then set `state.stop_reason` to a single line that enumerates every pair in the form `"generated-code-only fix refused: is generated from ; is generated from . Update the prompt source or run the proper PDD sync path before re-running the review loop."`. Do NOT call `_commit_and_push_if_changed`, do NOT reset the worktree (artifacts remain useful for debugging), do NOT mark affected findings as fixed, persist a refusal artifact at `round-{N}-prompt-source-guard-refusal.txt` alongside the other round artifacts, and `break` out of the loop the same way other terminal `stop_reason` paths do. - Ship-gate contract: the guard `break` MUST leave `state.reviewer_status[reviewer]` at `findings` (already set before the fixer ran), `state.fresh_final_status` at `missing`, and the affected findings at `status="open"`. Those three signals — surfaced in the rendered report as `reviewer-status: =findings`, `fresh-final-review: missing`, and the open-status row in the `### Findings` table — are what `checkup_verdict_adapter` parses to decide ship/non-ship. The loop's own `success` return flag is `True` on this path per the existing `run_checkup_review_loop` "loop completed with a trustworthy report" contract (the same contract the failed-push refusal path follows); the report markers are the ship gate. Do NOT switch this path to `success=False` — that would break the documented contract and the existing failed-push test, and the ship gate is not located at the boolean anyway. - Graceful degradation (allow + WARNING-level log via `logger.warning`, do NOT block): (a) `git show HEAD:architecture.json` exits non-zero (no git repo, no commits, or path absent from HEAD); (b) the HEAD blob is unparseable (`json.JSONDecodeError`); (c) `extract_modules` returns an empty list (object shape without `modules`, etc.). Failing closed on any of these would brick auto-heal on a temporarily-broken registry, which is the inverse of the bug being fixed. Use targeted exception classes only — never a bare `except Exception:`. NOTE: graceful degradation does NOT extend to "registered prompt missing on disk while code persists" — that shape is the disk-state attack from review pass #3 Finding 1 and the check order above blocks it. @@ -76,13 +165,14 @@ Add a PR-mode primary-reviewer/fixer review loop for `pdd checkup`. - This module (`pdd/checkup_review_loop.py`) is itself registered in `architecture.json`, so a future review-loop fix that changes only the Python file would trip its own new guard. Every meaningful edit to the review loop MUST update both this prompt and the Python module. 10b. Architecture-registry edit guard. As a sibling of 10a, after the fixer reports success and BEFORE `_commit_and_push_if_changed`, the loop MUST also run a deterministic guard `_check_architecture_registry_edit_guard(worktree, changed_files)` over the worktree's current change set. The guard exists because 10a is per-registered-entry against the pre-fixer HEAD registry, which leaves a coordinated 3-step bypass open (issue #1081): the fixer renames a registered code file off-disk, deletes the owning prompt, and rewrites `architecture.json` to point at the new code path — none of HEAD's registry entries trip, the renamed code lands with no prompt source registered, and the next `pdd sync` has nothing to regenerate from. 10b closes that hole by detecting registry mutations themselves, not by widening 10a's per-entry filesystem check (which would block legitimate helper-module refactors). - Trigger condition: run the guard when EITHER `architecture.json` is in `_git_changed_files(worktree)` OR any HEAD-registered pair has its code or prompt path missing from the worktree (implicit retirement — the no-arch-edit variant of the #1081 bypass: codex review pass #3 finding 1). The implicit-retirement branch closes the bypass where the fixer deletes both members of a HEAD-registered pair, adds a new unregistered code file, and leaves `architecture.json` untouched — 10a's check (3) allows the old pair as a "legitimate retirement" because code and prompt are both gone, but the registry is now stale and the new code landed unregistered. When NEITHER condition fires (no registry edit AND every HEAD pair is still on disk), the guard short-circuits to `None` (allow). 10a still covers code-only edits to registered modules that are present on disk. - - Mapping rule: read the pre-change registry from `git show HEAD:architecture.json` executed in the worktree (same source-of-truth as 10a — NOT the worktree filesystem, which the fixer has already mutated), and read the post-change registry from `(worktree / "architecture.json").read_text()`. Parse both via `pdd.architecture_registry.extract_modules`. For each parsed module with both `filename` and `filepath`, record the canonical pair `(Path(filepath).as_posix(), "pdd/prompts/" + filename)`. Build two sets `head_pairs` and `worktree_pairs`, then compute the symmetric deltas: `added = worktree_pairs - head_pairs`, `removed = head_pairs - worktree_pairs`, `repointed = pairs whose filepath appears in both sets but maps to a different prompt filename, or whose prompt filename appears in both sets but maps to a different filepath`. Repointed entries are reported separately so the refusal message can name them clearly; they MUST NOT be elided by treating the registry edit as a pure add+remove. + - Mapping rule: read the pre-change registry from `git show HEAD:architecture.json` executed in the worktree (same source-of-truth as 10a — NOT the worktree filesystem, which the fixer has already mutated), and read the post-change registry from `(worktree / "architecture.json").read_text()`. Parse both via `pdd.architecture_registry.extract_modules`. For each parsed module with both `filename` and `filepath`, record the canonical pair `(Path(filepath).as_posix(), (prompts_root / filename).as_posix())` where `prompts_root` is the repo-under-repair's resolved base prompts root from `_resolve_target_prompts_root(worktree)` (same helper as 10a — the guard checks each prompt's presence on disk, so a hardcoded `pdd/prompts/` prefix makes every external-repo prompt read as missing and fires the implicit-retirement trigger spuriously, issue #1957). Resolve `prompts_root` ONCE and thread the SAME root into both the HEAD and worktree extractions so the two pair sets stay comparable. Build two sets `head_pairs` and `worktree_pairs`, then compute the symmetric deltas: `added = worktree_pairs - head_pairs`, `removed = head_pairs - worktree_pairs`, `repointed = pairs whose filepath appears in both sets but maps to a different prompt filename, or whose prompt filename appears in both sets but maps to a different filepath`. Repointed entries are reported separately so the refusal message can name them clearly; they MUST NOT be elided by treating the registry edit as a pure add+remove. - Fail-closed behavior: for every added pair, BLOCK unless the new prompt path is present on disk as a REAL file (require `is_file() and not is_symlink()` — a symlink to any existing file would otherwise satisfy `is_file()` and forge the prompt source, codex review pass #3 finding 3) AND that prompt path is in the changed set (the fixer must actually create the prompt source, not just point the registry at a name) AND the registered prompt path ends in `.prompt` (codex review pass #14 — the disguised-prompt bypass, sibling of pass #13: a fixer can rewrite `architecture.json` to register `(pdd/foo_v2.py, pdd/prompts/foo_v2.py)` — pointing the "prompt" at a `.py` file. The added-pair check then sees both paths on disk and in the change set, ALLOWS the add, the unregistered-new-code scan skips `pdd/prompts/foo_v2.py` because it is now in `worktree_registered_paths`, and `pdd.prompts.foo_v2` lands as importable unregistered Python. Defence: a registered prompt path MUST end in `.prompt` — the canonical PDD prompt-source suffix — anything else (`.py`, `.pyc`, `.pyw`, `.pyo`, `.so`, `.pyd`, …) is the bypass shape. The check is case-insensitive on the path side to mirror the round-9/round-12 suffix/prefix normalization for case-insensitive filesystems). Apply the same `.prompt` suffix validation to repointed pairs (both `repointed_by_code` — checking the NEW prompt path — and `repointed_by_prompt` — defence-in-depth on the HEAD-side prompt key): a repoint that points at a non-`.prompt` is the same disguised-prompt shape dressed as a repoint instead of an added pair. For every removed pair, BLOCK unless the old code path is gone from disk AND (the old prompt is gone from disk OR is in the changed set) — i.e. legitimate retirement, the same shape 10a's check (3) allows. For every repointed pair, BLOCK unconditionally: a registry repoint without retiring the old entry and adding the new one as separate changes is the #1081 bypass shape. When ANY HEAD-registered pair is removed via registry edit (full wipe is the boundary case of partial wipe) OR implicitly retired on disk without an `architecture.json` edit (codex review pass #3 finding 1: the no-arch-edit variant — any HEAD pair whose code or prompt is missing from the worktree), also BLOCK if any changed path still exists on disk and is in the change set, is neither `architecture.json` nor a `.prompt` file (codex review pass #13: the original `pdd/prompts/` directory blanket was too broad — `.py`/`.pyc`/`.pyw`/`.pyo`/`.so`/`.pyd` under `pdd/prompts/` is still importable as `pdd.prompts.`, so the exclusion narrowed to the canonical `.prompt` suffix; a `.prompt` file anywhere is skipped because the suffix marks it as a canonical prompt source and isn't importable Python), is registered in NEITHER the HEAD registry nor the post-change worktree registry, is present on disk as EITHER a real file OR a symlink (a symlink dropped here is itself an attack shape — opposite polarity from the added-pair check above where a symlink would forge allowance), AND did NOT exist at HEAD (codex review pass #3 finding 2: skip modifications of existing helpers/tests/docs to avoid false positives on legitimate retirements that also touch an unregistered helper — check via `git cat-file -e HEAD:`), AND looks like generated prompt-driven code (path is under `pdd/` and either ends in an importable suffix (`.py`, `.pyw`, `.pyc`, `.pyo`, `.so`, `.pyd` — every shape `importlib.machinery.all_suffixes()` recognizes) OR is a symlink — codex review pass #6 finding 2: 10b's scope boundary is registry mutations, which cover prompt-owned modules under `pdd/`; tests under `tests/`, docs under `docs/`, and scripts under `scripts/` are out of scope, but `__init__.py` is INTENTIONALLY kept in scope so the round-6 finding 1 untracked-directory bypass at `pdd/foo_v2/__init__.py` is still caught; codex review pass #7: a symlink under `pdd/` outside `pdd/prompts/` is ALSO kept in scope regardless of suffix because `git status --untracked-files=all` lists a directory-symlink as the bare link path with no trailing slash and no `.py` extension — `pdd/foo_v2 -> ../tests/evil_pkg` would otherwise slip past the `.py` filter while making `pdd.foo_v2` an importable package backed by unregistered code; codex review pass #8: the suffix filter must cover every shape Python can import as `pdd.` — a sourceless `.pyc` (loaded via `importlib.machinery.SourcelessFileLoader`), a native extension `.so`/`.pyd`, or legacy optimized `.pyo` bytecode is importable with no `.py` source on disk, so dropping `pdd/foo_v2.pyc` (compiled from source then source removed) would otherwise slip past a `.py`-only check and the new code would land unregistered; the same review pass #8 follow-up also covers `.pyw` Windows-only Python source — on Windows `importlib.machinery.SOURCE_SUFFIXES` includes `.pyw` so `pdd/foo_v2.pyw` is importable as `pdd.foo_v2` via `SourceFileLoader` exactly like `.py`, and a `.py`-only filter would miss it). Report the registry-edit variant as `removed registered pair while new unregistered code path was added`, and the no-arch-edit variant as `retired registered pair on disk while new unregistered code path was added; architecture.json not updated`. This preserves legitimate retirement when no new live path remains AND legitimate retire-old + add-new module rewrites that re-register the new path. Collect ALL offending entries (do not short-circuit), then set `state.stop_reason` to a single line that enumerates every offender in the form `"architecture.json registry edit refused: removed registered pair while new unregistered code path was added; added without prompt source on disk; added where the registered prompt path is not a .prompt file (importable code disguised as a prompt); removed with code still present; repointed from to . Update the prompt source or run the proper PDD sync path before re-running the review loop."`. Do NOT call `_commit_and_push_if_changed`, do NOT reset the worktree, do NOT mark affected findings as fixed, persist a refusal artifact at `round-{N}-architecture-registry-guard-refusal.txt` alongside the other round artifacts, and `break` out of the loop the same way 10a does. - Ship-gate contract: identical to 10a. The `break` leaves `state.reviewer_status[reviewer]` at `findings`, `state.fresh_final_status` at `missing`, and the affected findings at `status="open"`. The loop's `success` return flag remains `True` per the existing trustworthy-report contract; the ship gate lives in the report markers, not the boolean. - Graceful degradation (allow + WARNING-level log via `logger.warning`, do NOT block): (a) `git show HEAD:architecture.json` exits non-zero — same graceful path as 10a; (b) the HEAD-side blob is unparseable (`json.JSONDecodeError` raised while parsing the HEAD blob); (c) `extract_modules` returns an empty list on the HEAD side (legitimate registry-absent-at-HEAD — there is no prior registry to defend). Worktree-side failures degrade gracefully ONLY when HEAD is also empty/unavailable: (d) the worktree's `architecture.json` cannot be read (`OSError`, including `FileNotFoundError`) and HEAD has no entries; (e) the worktree-side blob is unparseable (`json.JSONDecodeError` raised while parsing the worktree blob) and HEAD has no entries. Use targeted exception classes only — never a bare `except Exception:`. NOTE: this graceful path does NOT extend to the "added pair with missing prompt on disk", "repointed pair", or any "HEAD has entries but worktree side is empty/unavailable" shape — those are the attack vectors. The worktree-side empty/unavailable shape has THREE forms, all equivalent for enforcement purposes: (i) worktree blob parses to an empty pair list (the fixer wiped `architecture.json` to `[]`/`{}` or an unrecognized shape), (ii) worktree blob raises `OSError`/`FileNotFoundError` (the fixer deleted `architecture.json` outright), (iii) worktree blob raises `json.JSONDecodeError` (the fixer wrote garbage to `architecture.json`). All three, combined with 10a's check (3) `code gone + prompt gone` allow branch, re-open the rename-off-disk + delete-prompt drift and MUST block. When HEAD is non-empty and the worktree side is empty/unavailable via any of (i)/(ii)/(iii), treat `worktree_pairs` as the empty set so `removed = head_pairs`, `added = ∅`, `repointed = ∅`, and run the normal removed-pair enforcement (every prior registered pair must satisfy the retirement shape: code gone from disk AND (prompt gone from disk OR prompt in changeset)); offenders block via the standard refusal. - Check order: (1) read+parse HEAD registry with graceful-degradation on `git show` non-zero exit, `json.JSONDecodeError`, or `extract_modules` returning empty (allow + WARNING). (2) compute `implicit_retirement = any(not (worktree / code).is_file() or not (worktree / prompt).is_file() for code, prompt in head_pairs)`; if `architecture.json` is NOT in `_git_changed_files` AND `implicit_retirement` is False, return `None`. (3) read+parse the worktree registry; on `OSError`/`FileNotFoundError`, `json.JSONDecodeError`, or `extract_modules` returning empty, set `worktree_pairs = ∅` — then if `head_pairs` is also empty allow + WARNING (legitimate registry-absent-on-both-sides), otherwise proceed with `worktree_pairs = ∅` (this is the #1081 wipe/delete/garbage attack and MUST flow into removed-pair enforcement, NOT a graceful allow). When `architecture.json` is NOT in the change set but `implicit_retirement` is True, `worktree_pairs` equals `head_pairs` so `added = removed = repointed = ∅` and steps (5)/(6)/(7) are no-ops — the enforcement runs through step (8) only. (4) compute `added`, `removed`, `repointed` from the canonical-pair sets (with empty `worktree_pairs` this collapses to `added = ∅`, `repointed = ∅`, `removed = head_pairs`). (5) for each added pair: check prompt-on-disk-as-real-file (require `(worktree / prompt).is_file() and not (worktree / prompt).is_symlink()` — a symlink-to-existing-file can satisfy `is_file()` and bypass the prompt-source check, codex review pass #3 finding 3) AND prompt-in-changeset; block on miss. (6) for each removed pair: check code-gone-from-disk AND (prompt-gone-from-disk OR prompt-in-changeset); block on miss. (7) for each repointed pair: block unconditionally. (8) if `removed_only` is non-empty (registry-edit partial/full wipe case, which subsumes the full-wipe boundary) OR `implicit_retirement` is True (no-arch-edit variant, codex review pass #3 finding 1), scan the changed set for any still-existing path that is not `architecture.json`, does not end in `.prompt` (codex review pass #13 — the prompts-subdir importable-file bypass: the original `pdd/prompts/` directory blanket was too broad; its intent was to skip canonical prompt sources, but `.py`/`.pyc`/`.pyw`/`.pyo`/`.so`/`.pyd` files under `pdd/prompts/` are still importable as `pdd.prompts.` and a fixer can land `pdd/prompts/foo_v2.py` alongside a wipe + retire to slip past the scan; replace the dir-blanket with a `.prompt` suffix exclusion — `.prompt` files anywhere are skipped because the suffix marks them as canonical prompt sources and isn't importable Python, and everything else under `pdd/prompts/` falls through to the importable-suffix filter), IS under `pdd/` AND (ends in an importable suffix (`.py`, `.pyw`, `.pyc`, `.pyo`, `.so`, `.pyd`) OR is a symlink — codex review pass #6 finding 2: restrict the scan to generated prompt-driven code; tests/`tests/`, docs/`docs/`, scripts/`scripts/`, and other top-level paths are out of scope — but keep `__init__.py` in scope so the untracked-directory bypass at `pdd/foo_v2/__init__.py` from codex review pass #6 finding 1 is still caught; codex review pass #7: keep any symlink under `pdd/` outside `pdd/prompts/` in scope regardless of suffix — a directory-symlink like `pdd/foo_v2 -> ../tests/evil_pkg` is listed by `git status --untracked-files=all` as the bare link path with no trailing slash and no `.py` extension, and `Path.is_symlink()` does NOT follow the link so the check is safe even if the target is broken or outside the worktree; codex review pass #8: the suffix filter must cover every importable shape `importlib.machinery.all_suffixes()` recognizes — sourceless `.pyc` bytecode (via `importlib.machinery.SourcelessFileLoader`), native `.so`/`.pyd` extensions, and legacy `.pyo` optimized bytecode are all importable as `pdd.` without a `.py` source, so a `.py`-only filter would let `pdd/foo_v2.pyc` slip through; the same review pass #8 follow-up also adds `.pyw` to the suffix list — `.pyw` is Windows-only Python source (on Windows `importlib.machinery.SOURCE_SUFFIXES` includes `.pyw`) loaded via `SourceFileLoader` exactly like `.py`, so `pdd/foo_v2.pyw` is importable as `pdd.foo_v2` and must trip the scan), registered in NEITHER the HEAD registry nor the post-change worktree registry, is present on disk as EITHER a real file OR a symlink (treat symlinks as presence here — a symlink dropped at the new code path is itself a #1081 attack shape, the OPPOSITE polarity from the added-pair prompt-presence check where a symlink would forge allowance), AND did NOT exist at HEAD (check via `git cat-file -e HEAD:` — codex review pass #3 finding 2: paths that exist at HEAD are modifications of pre-existing unregistered helpers/tests/docs, NOT additions, and falsely flagging them blocks legitimate retirements that also modify a helper); block each such path as an unregistered-new-code offender. The refusal text differs by trigger: `removed registered pair while new unregistered code path was added` when the registry was edited, vs `retired registered pair on disk while new unregistered code path was added; architecture.json not updated` when no registry edit occurred — so the operator can tell the two #1081 variants apart. (9) if any offender was recorded, render the refusal line, persist the artifact, set `stop_reason`, and `break`. - Scope boundary: the guard ONLY enforces on registry mutations introduced in the current loop iteration via the current worktree change set. Pre-existing PR-head registry drift remains #1062's territory. The guard does NOT inspect every `architecture.json` field — only the `(filepath, filename)` canonical pairs surfaced by `extract_modules`. Other registry edits (reason text, interface metadata, dependency lists) are out of scope so they do not block legitimate metadata-only updates. The unregistered-new-code scan in step (8) is FURTHER scoped to generated prompt-driven code (paths under `pdd/` either ending in an importable suffix (`.py`, `.pyw`, `.pyc`, `.pyo`, `.so`, `.pyd` — every shape `importlib.machinery.all_suffixes()` recognizes) OR being a symlink); tests/`tests/`, docs/`docs/`, scripts/`scripts/`, and other top-level paths are out of scope so a legitimate retirement that also adds an unrelated test or doc does not falsely trip the scan (codex review pass #6 finding 2). `__init__.py` IS in scope so a new untracked package directory does not bypass the scan (codex review pass #6 finding 1). A symlink under `pdd/` outside `pdd/prompts/` IS in scope regardless of suffix because it can resolve to importable Python code (a package directory or a `.py` module) without carrying a `.py` suffix on the link path itself (codex review pass #7 — the symlinked-package bypass: `pdd/foo_v2 -> ../tests/evil_pkg` is listed by git as the bare link path and would otherwise slip past the `.py` filter). Sourceless bytecode (`.pyc`), native extension modules (`.so`/`.pyd`), and legacy optimized bytecode (`.pyo`) are ALL in scope because each is importable as `pdd.` with no `.py` source — `importlib.machinery.SourcelessFileLoader` handles `.pyc` standalone, and dropping `pdd/foo_v2.pyc` after compiling from a removed source would otherwise slip past a `.py`-only filter (codex review pass #8 — the sourceless-bytecode bypass). `.pyw` is Windows-only Python source (on Windows `importlib.machinery.SOURCE_SUFFIXES` includes `.pyw`) loaded via `SourceFileLoader` exactly like `.py`, so a `pdd/foo_v2.pyw` dropped at the new code path is importable as `pdd.foo_v2` on Windows and is therefore in scope as well (codex review pass #8 follow-up). The suffix comparison MUST be case-insensitive — Python's `importlib.machinery` suffix matching is case-insensitive on case-insensitive filesystems (Windows; macOS HFS+/APFS in default case-insensitive mode), so `pdd/foo_v2.PY`, `pdd/foo_v2.PYC`, or `pdd/foo_v2.So` is importable as `pdd.foo_v2` exactly like its lowercase counterpart; a case-sensitive `str.endswith` against the lowercase `_IMPORTABLE_SUFFIXES` tuple would let an uppercase/mixed-case suffix slip past the scan, so lowercase the path side of the comparison before `endswith` (codex review pass #9 — the case-insensitive suffix bypass). Codex review pass #12 — the case-insensitive prefix bypass, sibling of pass #9: the directory-prefix checks themselves (`path.startswith("pdd/")` to scope into the scan, and `path.startswith("pdd/prompts/")` to exclude prompts) must ALSO be case-insensitive, because on the same case-insensitive filesystems an uppercase or mixed-case prefix like `PDD/foo_v2.py` or `Pdd/foo_v2.py` aliases to `pdd/foo_v2.py` on disk and is importable as `pdd.foo_v2`; a case-sensitive `str.startswith("pdd/")` would skip the path as out-of-scope and the bypass would land. Lowercase the path side of both prefix comparisons (`pdd/` and `pdd/prompts/`) before `startswith`, mirroring the round-9 suffix fix. Codex review pass #13 — the prompts-subdir importable-file bypass, sibling of pass #12: the `pdd/prompts/` directory blanket was itself too broad. Its intent was to skip canonical prompt sources (`.prompt` files), but a `.py`/`.pyc`/`.pyw`/`.pyo`/`.so`/`.pyd` file under `pdd/prompts/` is still importable Python (`pdd.prompts.`), and a fixer can wipe the registry, retire the registered pair, and drop `pdd/prompts/foo_v2.py` to slip past the unregistered-new-code scan. Replace the dir-blanket with a `.prompt` suffix exclusion on the path: a `.prompt` file ANYWHERE in the tree is skipped (the canonical PDD prompt-source suffix and not importable Python), and any other file under `pdd/prompts/` falls through to the standard importable-suffix filter. Apply the same `.prompt` suffix replacement to the round-11 submodule check below so the two scans share the identical exclusion rule. Apply the same case-insensitive prefix normalization to the round-11 submodule check below so an uppercase `PDD/foo_v2` gitlink is caught consistently. Codex review pass #11 — the git-submodule bypass: a fixer can run `git submodule add pdd/foo_v2`; the gitlink shows as the bare directory path in `git status --untracked-files=all` (the submodule's checked-out files come from its own HEAD, not from `--untracked-files=all`), so `pdd/foo_v2` carries no importable suffix on the link path and is not a symlink — the suffix-or-symlink scan above silently allows it while `pdd.foo_v2` becomes importable from the submodule's `__init__.py`. The signal we DO see is `.gitmodules` appearing in the change set. After the unregistered-new-code scan and BEFORE the no-offender early return, also scan for any path under `pdd/` (whose path does not end in `.prompt` — codex review pass #13: mirror the `.prompt` suffix exclusion from the unregistered-new-code scan above so the two scans share the identical exclusion rule; a submodule path is unlikely to end in `.prompt` in practice, but keep them in lockstep) that is registered in NEITHER the HEAD registry nor the post-change worktree registry, is a real directory on disk (`candidate.is_dir() and not candidate.is_symlink()` — symlinks are caught by the symlink branch of the 10b scan, gitlinks materialize as real directories), did NOT exist at HEAD, AND `.gitmodules` is in the change set AND the retirement-context trigger (`removed_only or implicit_retirement`) is True. Block each such path as `new git submodule introduced via .gitmodules edit while a registered prompt-owned pair was retired`. Legitimate refactors that add a submodule inside `pdd/` are unheard of in this codebase; an LLM fixer adding a submodule alongside a retirement/wipe is unambiguously the bypass shape. - Ordering with 10a: run 10b BEFORE 10a in the post-fixer pre-push block. 10b's registry-mutation check is strictly cheaper than 10a's per-entry filesystem walk on the common no-registry-edit path (it short-circuits on the trigger condition), and when 10b fires it produces the more precise diagnostic for the #1081 attack shape; running it first means the operator sees the registry-mutation refusal rather than a downstream 10a refusal that fires on a partial symptom. + - Target-repo prompts-root resolution (issue #1957). Both guards (and the reviewer-prompt companion-files collector) MUST resolve owning-prompt paths against the REPO-UNDER-REPAIR's prompt layout, never pdd's self-hosted one. `_resolve_target_prompts_root(worktree) -> Path` returns the target repo's base prompts root RELATIVE to `worktree` (the same layout `filename` entries in `architecture.json` are stored relative to). Resolution order, first EXISTING directory wins, with symlinks resolved to the real git-tracked path (so pdd's `prompts -> pdd/prompts` symlink maps to `pdd/prompts`, the path git actually reports for co-edit detection): (1) the `.pddrc` default-context `prompts_dir` via `_pddrc_default_prompts_dir(worktree)`; (2) `prompts` — the universal PDD default / external-repo layout; (3) `pdd/prompts` — pdd self-hosted / vendored-pdd layout. Fall back to `prompts` when none exist so a genuinely-missing prompt is reported against the target repo's canonical root, never pdd's. `_pddrc_default_prompts_dir(worktree) -> Optional[Path]` reuses the SAME `.pddrc` discovery/parse helpers generate/sync use — `construct_paths._find_pddrc_file` and `construct_paths._load_pddrc_config` (single source of truth for prompt-root config; checkup MUST NOT fork its own `.pddrc` reader) — reads `contexts.default.defaults.prompts_dir`, and is fully fail-soft: any missing key / absent file / parse error returns `None` so the caller falls back to the layout defaults and a temporarily-broken `.pddrc` never blocks a checkup round. An absolute configured `prompts_dir` is returned relative to `worktree` when it resolves inside the tree, else `None`. % Configurable reviewer status gates 11. `ReviewLoopConfig` exposes the gate knobs (also surfaced through CLI flags): @@ -90,12 +180,12 @@ Add a PR-mode primary-reviewer/fixer review loop for `pdd checkup`. - `clean_reviewer_states: Tuple[str, ...]` — compatibility parser for downstream reviewer-status gates. Default `("clean",)`. `degraded`, `failed`, and `missing` are hard not-clean states and must be dropped even if a caller lists them. - `require_all_reviewers_clean: bool` — retained for backward-compatible CLI/API shape, but the primary reviewer is the only ship gate. - `continue_on_reviewer_limit: bool` — when true, provider/rate/quota/context-window/timeout reviewer failures are reported as `degraded` instead of `failed`; this never marks the active reviewer clean and never continues into fixer/push mutation without a completed review. A degraded primary may be superseded only by a successful configured `reviewer_fallback`. The degraded marker set also covers transient infra-class failure strings: auth/unauthorized/login, network/connection, non-zero exit code/status, permission denied, sandbox. - - `reviewer_fallback: Optional[str]` — optional secondary reviewer invoked at most once across the entire loop when the primary reviewer's status is in `{"failed", "degraded", "missing"}`. The fallback is normalized with the same role aliases as `reviewer` and `fixer`, and is ignored if it resolves to the primary reviewer or the fixer. The primary reviewer's preserved status remains in `state.reviewer_status` under its own key; the fallback's status is recorded under the fallback's role key. `ReviewLoopState.active_reviewer` tracks the reviewer whose clean result currently gates `issue_aligned`; a superseded primary infra failure must remain visible in `reviewer_status` without forcing `issue_aligned: unknown` after the fallback completes cleanly. If the fallback returns a usable review (`clean` or `findings`), the fallback role takes over as the primary reviewer for ALL subsequent rounds, including the current round's fix step and verify step and every later review/fix/verify cycle until the loop terminates — the original primary that failed once is assumed likely to fail again and is not retried. If the fallback also lands in a hard not-clean state, the loop breaks with both statuses preserved. When (and only when) a fallback takeover actually occurs (`state.active_reviewer` is not the originally resolved primary), the superseded primary's row in the `### Per-Reviewer Status` table MUST be annotated with `(optional, superseded by )` after its hard-not-clean status token — this is the contract the pdd_cloud `checkup_verdict_adapter` parser keys off (any reviewer row whose text contains the literal `optional` is dropped from the required set), and is what lets the verdict adapter resolve to `ship_degraded` (rather than `unknown`) when the fallback's review is clean. If the fallback also fails (no takeover), no row is annotated `optional` so the verdict adapter still treats both as required and short-circuits to `unknown`. - - `fallback_reviewer_on_failure: bool` — opt-in (default `False`). When the primary reviewer ends in `failed` or `missing` (NOT `degraded` — that means reduced quality and must not silently lose signal), run a second review pass using the configured fixer's identity as a fallback reviewer. The fallback's result is recorded as a real reviewer row so the cloud verdict adapter sees a clean real-reviewer entry rather than the legacy `fixer` sentinel; on a clean fallback the rendered `reviewer-status:` line shows the primary as `clean` so the adapter's real-reviewer-clean rule can fire. The primary's original failure is preserved in `ReviewLoopState.reviewer_status_details` with `superseded_by_fallback="true"` and `fallback_reviewer=`, and rendered in the `### Reviewer Diagnostics` subsection. Off by default to preserve existing CI expectations. + - `reviewer_fallback: Optional[str]` — optional secondary reviewer invoked at most once across the entire loop when the primary reviewer's status is in `{"failed", "degraded", "missing"}`. The fallback is normalized with the same role aliases as `reviewer` and `fixer`, and is ignored if it resolves to the primary reviewer or the fixer. The primary reviewer's preserved status remains in `state.reviewer_status` under its own key; the fallback's status is recorded under the fallback's role key. `ReviewLoopState.active_reviewer` tracks the reviewer whose clean result currently gates `issue_aligned`; a superseded primary infra failure must remain visible in `reviewer_status` without forcing `issue_aligned: unknown` after the fallback completes cleanly. If the fallback returns a usable review (`clean` or `findings`), the fallback role takes over as the primary reviewer for ALL subsequent rounds, including the current round's fix step and verify step and every later review/fix/verify cycle until the loop terminates — the original primary that failed once is assumed likely to fail again and is not retried. If the fallback also lands in a hard not-clean state, the loop breaks with both statuses preserved. When (and only when) a fallback takeover actually occurs (`state.active_reviewer` is not the originally resolved primary), the superseded primary's row in the `### Per-Reviewer Status` table MUST be annotated with `(optional, superseded by )` after its hard-not-clean status token — this is the contract the pdd_cloud `checkup_verdict_adapter` parser keys off (any reviewer row whose text contains the literal `optional` is dropped from the required set), and is what lets the verdict adapter resolve to `ship_degraded` (rather than `unknown`) when the fallback's review is clean. If the fallback also fails (no takeover), no row is annotated `optional` so the verdict adapter still treats both as required and short-circuits to `unknown`. **Auto-degrade on explicit-fallback findings (issue #1941):** in NON-review-only mode (review-only runs no fixer, so a fix-degrade must never fire there), when this explicit `reviewer_fallback` takes over because the primary reviewer's family is unavailable, the surviving fallback reviewer reports actionable findings, AND the configured fixer belongs to that same just-failed (unavailable) family, AND no eligible INDEPENDENT `fixer_fallback` remains (a configured cross-family fallback fixer — one distinct from the primary fixer, the active reviewer, and the originally configured reviewer — is PREFERRED and, when present, runs instead of degrading) — so no independent fixer can run and the fix round would otherwise dead-end with the findings unaddressed — the loop MUST NOT terminate with "findings remain". It auto-degrades: the surviving fallback family — not the unavailable configured fixer — performs a fresh fix session followed by the required fresh verification, exactly as in the normal cross-family flow. The run is disclosed honestly as a single-role review/fix (`same_role_review_fix` true) with `role_independence` reported as `degraded ( unavailable)`, and the superseded primary still renders `(optional, superseded by )`. This degradation is AUTOMATIC for every consumer and requires NO `fallback_reviewer_on_failure` opt-in (that flag governs the separate fixer-as-reviewer path). It is deliberately NARROW — a genuinely independent, available cross-family fixer is still preferred and is never replaced by a same-family session. If the degraded same-family fixer then also fails, the terminal `stop_reason` names the provider vacancy (the `[role-independence ]` disclosure defined under `fixer_fallback`), never a bare "findings remain". + - `fallback_reviewer_on_failure: bool` — opt-in (default `False`). When the primary reviewer ends in `failed` or `missing` (NOT `degraded` — that means reduced quality and must not silently lose signal), run a second review pass using the configured fixer's identity as a fallback reviewer. The fallback's result is recorded as a real reviewer row so the cloud verdict adapter sees a clean real-reviewer entry rather than the legacy `fixer` sentinel; on a clean fallback the rendered `reviewer-status:` line shows the primary as `clean` so the adapter's real-reviewer-clean rule can fire. The primary's original failure is preserved in `ReviewLoopState.reviewer_status_details` with `superseded_by_fallback="true"` and `fallback_reviewer=`, and rendered in the `### Reviewer Diagnostics` subsection. Off by default to preserve existing CI expectations. **Auto-degrade on fallback findings (issue #1941):** when this fallback reviewer returns `findings` (rather than clean), the loop MUST NOT dead-end with a "secondary reviewer reported findings (fallback)" stop reason and zero fix attempts. Because the fallback reviewer IS the fixer's own identity — promoted only because the primary reviewer's family is unavailable — a guaranteed deadlock is the only alternative to relaxing role independence, and a fresh same-family fixer session can still execute the concrete, named findings and be re-reviewed. So when the fallback reviewer reports actionable findings (per `_actionable_findings`), the loop MUST auto-degrade: set `ReviewLoopState.same_role_review_fix = True`, set `ReviewLoopState.role_independence = "degraded ( unavailable)"` (capture the unavailable primary role BEFORE reassigning the active reviewer), promote the fallback to the active reviewer, and hand those findings to the fixer as the next round's pending findings (identical mechanism to the fallback-reviewer-clean gate-findings handoff) so the normal fix + required fresh-verify path runs a same-family session. The superseded primary still renders `(optional, superseded by )`, so the cloud verdict adapter drops it from the required set and can ship on a degraded-but-clean result. If there are no actionable fallback findings, keep the legacy behavior (record the fallback `findings` status and stop). If the same-family fixer then also fails, the fix-failure `stop_reason` MUST name the vacancy (append `" [role-independence ]"` when `state.role_independence != "independent"`) rather than a bare "could not address". Behavior is unchanged when both provider families are alive (the primary reviewer never fails, so this path never fires). - `enable_gates: bool` — Issue #1092 deterministic-gate enforcement (default `True`). When True, every clean-exit site in the loop routes through `_enforce_gates_before_clean` (see section "Deterministic gate enforcement" below). Set to `False` (CLI flag `--no-gates`) to fall back to LLM-only verdicts. - `gate_timeout: float` — per-gate wall-clock cap in seconds (default `60.0`). A gate exceeding this cap is recorded as a runner-side failure (`exit_code=None`) and converted to a blocker finding; the cap is bounded so a stuck local tool cannot wedge the loop. - `gate_allow: Tuple[str, ...]` — forward-compatibility hook plumbed from the repeatable `--gate-allow` CLI flag. Discovery remains allowlist-only; this argument is threaded through so the CLI surface and discovery surface can co-evolve without breaking signature stability. - - `fixer_fallback: Optional[str]` — optional secondary fixer invoked at most once across the entire loop when the primary fixer reports `success=False`. The fallback fixer is the analog of `reviewer_fallback` for the fix step: the primary CLI subprocess can dead-stop on credential exhaustion (e.g. Claude Code subscription-tier `"You've hit your limit · resets …"` permanent classification `credential-limit`) and without a fallback fixer the loop sets `stop_reason = "Fixer {fixer} could not address {reviewer}'s findings."` and breaks. With `fixer_fallback` set, the loop calls `_maybe_run_fallback_fixer` BEFORE that break. The fallback is normalized with the same role aliases as `reviewer_fallback`, so `--fixer claude --fixer-fallback anthropic` resolves to the same role and is skipped as a no-op retry on identical credentials. The fallback is ignored when it equals the primary fixer (a no-op retry on the same role that just failed) or the active reviewer (`state.active_reviewer`) or the originally configured reviewer (promoting the reviewer to fix its own findings — or to fix findings from a reviewer it just superseded as a fallback — collapses the reviewer/fixer role independence the review loop exists to enforce). If the budget is already exhausted when the primary fails, the fallback is NOT attempted: the budget-exhausted flag is set on `state` and `stop_reason` is rewritten to `"Fixer {primary} could not address {reviewer}'s findings; budget exhausted before fallback fixer {fallback} could run."` so the operator-facing report attributes the gate correctly. `ReviewLoopState.active_fixer` tracks the fixer that currently drives `_run_fix`; if the fallback returns a usable `FixResult` (`success=True`), the fallback role takes over as `state.active_fixer` and drives every subsequent round's fix step until the loop terminates — the original primary that failed once is assumed likely to fail again (subscription-tier credential-limit reset windows are hours-to-days) and is not retried, and `_maybe_run_fallback_fixer` early-returns on later rounds because the one-shot fallback has already been consumed. When the fallback succeeds, both `FixResult`s are appended to `state.fixes` (primary failure + fallback success) so the audit trail records the credential rotation; the fallback's `FixResult` then drives `_commit_and_push_if_changed`, the verify pass, and the rest of the current round, and the takeover persists across all later rounds. When the fallback also fails the loop breaks with `stop_reason` carrying both names: `"Fixer {primary} could not address {reviewer}'s findings (fallback fixer {fallback} also failed)."`. + - `fixer_fallback: Optional[str]` — optional secondary fixer invoked at most once across the entire loop when the primary fixer reports `success=False`. The fallback fixer is the analog of `reviewer_fallback` for the fix step: the primary CLI subprocess can dead-stop on credential exhaustion (e.g. Claude Code subscription-tier `"You've hit your limit · resets …"` permanent classification `credential-limit`) and without a fallback fixer the loop sets `stop_reason = "Fixer {fixer} could not address {reviewer}'s findings."` and breaks. With `fixer_fallback` set, the loop calls `_maybe_run_fallback_fixer` BEFORE that break. The fallback is normalized with the same role aliases as `reviewer_fallback`, so `--fixer claude --fixer-fallback anthropic` resolves to the same role and is skipped as a no-op retry on identical credentials. The fallback is ignored when it equals the primary fixer (a no-op retry on the same role that just failed) or the active reviewer (`state.active_reviewer`) or the originally configured reviewer (promoting the reviewer to fix its own findings — or to fix findings from a reviewer it just superseded as a fallback — collapses the reviewer/fixer role independence the review loop exists to enforce). If the budget is already exhausted when the primary fails, the fallback is NOT attempted: the budget-exhausted flag is set on `state` and `stop_reason` is rewritten to `"Fixer {primary} could not address {reviewer}'s findings; budget exhausted before fallback fixer {fallback} could run."` so the operator-facing report attributes the gate correctly. `ReviewLoopState.active_fixer` tracks the fixer that currently drives `_run_fix`; if the fallback returns a usable `FixResult` (`success=True`), the fallback role takes over as `state.active_fixer` and drives every subsequent round's fix step until the loop terminates — the original primary that failed once is assumed likely to fail again (subscription-tier credential-limit reset windows are hours-to-days) and is not retried, and `_maybe_run_fallback_fixer` early-returns on later rounds because the one-shot fallback has already been consumed. When the fallback succeeds, both `FixResult`s are appended to `state.fixes` (primary failure + fallback success) so the audit trail records the credential rotation; the fallback's `FixResult` then drives `_commit_and_push_if_changed`, the verify pass, and the rest of the current round, and the takeover persists across all later rounds. When the fallback also fails the loop breaks with `stop_reason` carrying both names: `"Fixer {primary} could not address {reviewer}'s findings (fallback fixer {fallback} also failed)."`. When role independence was auto-degraded at runtime (`state.role_independence != "independent"`, issue #1941), this fix-failure `stop_reason` additionally appends `" [role-independence ]"` before the trailing period so the terminal reason names the provider vacancy explicitly rather than a bare "could not address"; the string is byte-identical to the above in the non-degraded and config-time same-role cases. - `allow_same_reviewer_fixer: bool` — explicit opt-in (default `False`) for single-role review/fix mode. When false, `reviewer == fixer` in non-review-only mode is rejected. When true and the resolved reviewer/fixer are equal, the loop may run that one role for both review and fix, but `reviewer_status[role]` represents reviewer outcome only and MUST NOT be overwritten with the legacy `fixer` sentinel. 12. `_required_findings` must read from `config.blocking_severities`. Never hard-code the priority set. The list is a priority label only: every valid, in-scope reviewer finding remains actionable until fixed or explicitly accepted by the reviewer as resolved. 12a. The reviewer prompt and the fixer prompt MUST also inject `config.blocking_severities` into their text (e.g. `"Highest-priority severities: blocker, critical"`). Hard-coding "blocker, critical, and medium" in prompt prose lets the LLM keep flagging severities the user has narrowed off, producing prompt/code drift exactly like the one this PR fixes for `pdd checkup`. @@ -216,7 +306,7 @@ The loop MUST NOT mark a single `FixResult.pushed_head_sha` as verified more tha 23. After every reviewer or fixer step completes, write `dedup-state-round-{N}.json` containing the cumulative findings (one per dedup key) with their current `status`, `reviewer`, `round_number`, and full payload. Overwriting at each step is fine; we only need the latest cumulative snapshot per round. 24. At end of loop write: - `final-report.md` — exact bytes returned to the caller. - - `final-state.json` — `{reviewer_status, active_reviewer, reviewer_status_details, fresh_final_status, stop_reason, total_cost, last_model, max_rounds_reached, max_cost_reached, max_duration_reached, fix_attempts_by_key, dispute_notes_by_key, reviewer_feedback_by_key, same_role_review_fix, mode, source_of_truth, verified_head_sha, remote_pr_head_sha, reviewed_head_sha, verification_status_by_round, gates, findings: [...]}` — the canonical machine-readable verdict. `same_role_review_fix` is true only for explicit `--allow-same-reviewer-fixer` runs where the resolved reviewer and fixer are the same role; `mode` is `single-role-review-fix` for that case and `independent-reviewer-fixer` otherwise. `source_of_truth` carries the structured source-of-truth repair/blocker audit dict verbatim when present and `null` otherwise. The verification-boundary fields (`verified_head_sha`, `remote_pr_head_sha`, `reviewed_head_sha`, `verification_status_by_round`) are always present where applicable (values may be `null`/`{}`) so downstream consumers can rely on the schema rather than feature-detecting. `reviewer_status_details` is keyed by reviewer role and carries per-reviewer diagnostic detail (`status`, `classification`, `exit_code`, `reason`) for any reviewer that ended in failed/degraded/missing; when `--fallback-reviewer-on-failure` promotes a clean fallback, the primary's entry retains its original failure detail plus a `superseded_by_fallback="true"` marker and `fallback_reviewer` name. + - `final-state.json` — `{reviewer_status, active_reviewer, reviewer_status_details, fresh_final_status, stop_reason, total_cost, last_model, max_rounds_reached, max_cost_reached, max_duration_reached, fix_attempts_by_key, dispute_notes_by_key, reviewer_feedback_by_key, same_role_review_fix, mode, role_independence, source_of_truth, verified_head_sha, remote_pr_head_sha, reviewed_head_sha, verification_status_by_round, gates, findings: [...]}` — the canonical machine-readable verdict. `same_role_review_fix` is true when the resolved reviewer and fixer are the same role — either for an explicit `--allow-same-reviewer-fixer` run OR when the loop auto-degraded to a same-family review/fix session at runtime (issue #1941); `mode` is `single-role-review-fix` for that case and `independent-reviewer-fixer` otherwise. `role_independence` is always present and distinguishes the two: `"independent"` for the normal cross-family loop AND for a deliberate config-time `--allow-same-reviewer-fixer` run (that intent is disclosed by `same_role_review_fix`), versus `"degraded ( unavailable)"` only when role independence was relaxed at runtime because a provider family was unavailable. `source_of_truth` carries the structured source-of-truth repair/blocker audit dict verbatim when present and `null` otherwise. The verification-boundary fields (`verified_head_sha`, `remote_pr_head_sha`, `reviewed_head_sha`, `verification_status_by_round`) are always present where applicable (values may be `null`/`{}`) so downstream consumers can rely on the schema rather than feature-detecting. `reviewer_status_details` is keyed by reviewer role and carries per-reviewer diagnostic detail (`status`, `classification`, `exit_code`, `reason`) for any reviewer that ended in failed/degraded/missing; when `--fallback-reviewer-on-failure` promotes a clean fallback, the primary's entry retains its original failure detail plus a `superseded_by_fallback="true"` marker and `fallback_reviewer` name. 25. `_write_artifact(path, content)` MUST `mkdir(parents=True, exist_ok=True)` and write UTF-8. 25a. Public verdict-IO helpers (consumed by the canonical final gate, issue #1406): `load_final_state(cwd, issue_number, pr_number) -> Optional[Dict[str, Any]]` reads the run's `final-state.json` from `_artifacts_dir(cwd, issue_number, pr_number)` and returns the parsed mapping, or `None` when the artifact is absent or unparsable (`OSError`/`json.JSONDecodeError`) — callers MUST treat `None` as fail-closed, never as a clean result. `clear_final_state(cwd, issue_number, pr_number) -> None` deletes any stale `final-state.json` before a fresh run so a later `load_final_state` cannot mistake a prior run's verdict for the current one (`FileNotFoundError` and other `OSError`s are swallowed). A role-error or worktree-setup-error path that returns before `_finalize` writes no new file, so the post-clear absence is correctly read as fail-closed. These two helpers own the `final-state.json` path logic so consumers (`agentic_checkup._review_loop_ship_verdict`) do not duplicate it. @@ -227,6 +317,7 @@ The loop MUST NOT mark a single `FixResult.pushed_head_sha` as verified more tha - `issue_aligned: true|false` - `active-reviewer: codex|claude|gemini|...` - `same-role-review-fix: true|false` + - `role-independence: independent|degraded ( unavailable)` — current value of `state.role_independence`. Always present. Renders `independent` for the normal cross-family loop and for a deliberate `--allow-same-reviewer-fixer` run; renders `degraded ( unavailable)` only when the loop auto-degraded role independence at runtime (see the `fallback_reviewer_on_failure` degrade rule). - `reviewer-status: codex=... claude=fixer fresh-final=...` (primary/superseded reviewer status, fixer role marker, any fallback reviewer status, plus `fresh-final`; in single-role mode do not emit `=fixer` for the reviewer/fixer role) - `fresh-final-review: clean|findings|failed|degraded|missing` - `verified-head-sha: |none` — current value of `state.verified_head_sha` (the full SHA, not a short SHA, so the line is unambiguous). Always present. Renders `none` when no verifier pass has cleared a pushed head this loop. @@ -236,7 +327,7 @@ The loop MUST NOT mark a single `FixResult.pushed_head_sha` as verified more tha - `max-duration-reached: true|false` 27. Sections, in order: `### Summary`, `### Per-Reviewer Status` (markdown table including primary/superseded reviewer, fallback reviewer when used, fixer role marker only when distinct from the reviewer, and `fresh-final`), optional `### Reviewer Diagnostics` (only when `state.reviewer_status_details` is non-empty; include `classification`, `exit_code`, and a fenced reason tail; for `--fallback-reviewer-on-failure` overrides, include `status overridden by fallback (original=, fallback=)`), `### Findings` (markdown table; only unfixed findings with columns `Severity | Status | Location | Finding | Required fix | Reviewer`; placeholder row when empty), `### Fixer Rationale` (one bullet per remaining finding with latest fixer disposition/rationale, or `- none`), `### Fixes Attempted` (one bullet per `FixResult`). `### Fixer Rationale` renders only unverified fixer claims for still-open findings, so every bullet MUST qualify the prose with explicit labels such as `fixer= fixer_disposition= fixer_rationale=` or `fixer_summary=` and MUST append `verification=unverified`; it MUST NEVER render a bare line like `claude: fixed - ...` or otherwise present the fixer disposition as the verifier verdict. Each `### Fixes Attempted` bullet MUST be rendered in the fixed-field form `- round= fixer= fixer_result= push_status= local_sha= pushed_sha= verification= [summary=]`. The fixer's free-text summary is rendered only as the trailing `summary=` field (single-line, escaped) and MUST NOT be the leading status token. Bare `success`/`fixed`/`done` tokens from fixer output MUST NEVER appear as the gating status (R-V7). SHAs in the bullet MAY be rendered as short SHAs (`git rev-parse --short` style, 7+ chars); the full SHAs live in the header `verified-head-sha:`/`remote-pr-head-sha:` lines and in `final-state.json`. 27a. The `verification=` field on each `### Fixes Attempted` bullet is gated as follows. `verification=verified` requires ALL of: (a) `FixResult.push_status == "pushed"`, (b) the verifier ran on `FixResult.pushed_head_sha` and returned `clean`, (c) `state.verified_head_sha == FixResult.pushed_head_sha`, (d) the final-report re-fetch (R-V5) observed `state.remote_pr_head_sha == state.verified_head_sha`, (e) no hard-not-clean active reviewer state, and (f) no safety cap stopped the loop after the fixer pushed but before verification cleared. Any other combination — including findings remaining open, verifier missing/failed/degraded, push failed, budget/cost/time cap crossed after fixer push but before verifier clean, remote head observed advanced past the verified SHA, or final-report re-fetch failure — MUST render `verification=unverified`. When R-V5 detects a remote-head mismatch, the bullet for the affected `FixResult` MUST render `verification=unverified` even when the verifier itself returned `clean` for the earlier SHA; the SHA mismatch supersedes the in-loop verifier verdict. Fixer summaries are not authoritative until the verifier/verification path is clean on the actual pushed SHA. -27b. The `### Reviewer Diagnostics` reason tail renders untrusted reviewer stderr and MUST be passed through `_defang_adapter_trip_wires` before render. The cloud `checkup_verdict_adapter` scans the entire report (including code fences) for `[SEV]` tokens, line-leading `error:`, `_ERROR_MARKERS` substrings (`checkup failed`, `checkup timed out`, `error running checkup`), `issue_aligned: true|false`, inline `reviewer-status:` / `fresh-final-review:` markers, `max-*-reached: true` budget flags, `### Per-Reviewer Status` / `### Fresh Final Review` headings, AND any markdown pipe-table row that the adapter's `_extract_findings` parses as a real finding. The pipe-table scanner peels markdown wrappers (`**bold**`, `*italic*`, `` `code` ``) and skips empty leading cells before its severity check, so `| **critical** | … |`, `| `critical` | … |`, `| *critical* | … |`, and `| | critical | … |` all inject synthetic critical findings if leaked from reviewer stderr. The defang must therefore neutralize EVERY pipe-prefixed line in the diagnostics reason (not only severity-shaped rows). Any trip-wire leaking from reviewer stderr would either inject synthetic findings/status or downgrade the verdict; defang at the render boundary keeps `state.reviewer_status_details` and `final-state.json` truthful while the rendered text can no longer hijack the adapter. If the adapter grows a new full-report scanner, add a matching defang here. +27b. The `### Reviewer Diagnostics` reason tail renders untrusted reviewer stderr and MUST be passed through `_defang_adapter_trip_wires` before render. The cloud `checkup_verdict_adapter` scans the entire report (including code fences) for `[SEV]` tokens, line-leading `error:`, `_ERROR_MARKERS` substrings (`checkup failed`, `checkup timed out`, `error running checkup`), `issue_aligned: true|false`, inline `reviewer-status:` / `fresh-final-review:` / `role-independence:` (issue #1941) markers, `max-*-reached: true` budget flags, `### Per-Reviewer Status` / `### Fresh Final Review` headings, AND any markdown pipe-table row that the adapter's `_extract_findings` parses as a real finding. The pipe-table scanner peels markdown wrappers (`**bold**`, `*italic*`, `` `code` ``) and skips empty leading cells before its severity check, so `| **critical** | … |`, `| `critical` | … |`, `| *critical* | … |`, and `| | critical | … |` all inject synthetic critical findings if leaked from reviewer stderr. The defang must therefore neutralize EVERY pipe-prefixed line in the diagnostics reason (not only severity-shaped rows). Any trip-wire leaking from reviewer stderr would either inject synthetic findings/status or downgrade the verdict; defang at the render boundary keeps `state.reviewer_status_details` and `final-state.json` truthful while the rendered text can no longer hijack the adapter. If the adapter grows a new full-report scanner, add a matching defang here. 28. Pipe characters and newlines inside finding fields must be escaped via a helper before being inserted into table rows. % GitHub posting @@ -279,7 +370,7 @@ The #1063 prompt-source guard (`_check_prompt_source_guard`) deterministically r 2047-C. **Guards remain the trust boundary.** After a repair attempt, the loop RE-COMPUTES `guard_changed_files` and RE-RUNS both `_check_architecture_registry_edit_guard` and `_check_prompt_source_guard`. It pushes only when both now pass; any residual refusal sets `state.source_of_truth["blocked"] = True`, writes the refusal + structured details artifact, sets `state.stop_reason` to `_source_of_truth_stop_reason(residual, details)` (which names the unrepairable modules), and breaks — never push on a repair that did not actually satisfy the deterministic guards. -2047-D. **Structured state + machine verdict.** `ReviewLoopState` MUST carry `source_of_truth: Optional[Dict[str, Any]] = None` with shape `{"blocked", "repair_attempted", "repaired", "unrepairable":[{"code_path","prompt_path","kind","reason"}], "offenders":[...]}`. `source_of_truth` remains in the appended field block for positional-construction stability, followed by `same_role_review_fix` as the final appended state field for explicit single-role review/fix runs. `_write_final_state` MUST persist `source_of_truth` verbatim. `_render_machine_verdict_block` MUST emit two additional `pdd.checkup.final_gate.v1` fields: `source_of_truth` (the state dict verbatim) and `failure_category` (via `_review_loop_failure_category(state, passed, remaining_findings)` returning one of the stable `FINAL_GATE_CATEGORY_*` strings — a source-of-truth blocker maps to `"source_of_truth_repair_needed"`. A source-of-truth blocker is EITHER guard's refusal (the prompt-source guard's `generated-code-only fix refused` OR the architecture-registry guard's `architecture.json registry edit refused`, both in `state.stop_reason`), a recorded `state.source_of_truth["blocked"]`, OR an OPEN reviewer finding about the prompt/architecture source of truth (by `area` or text — the #1519 new-modules-need-contracts shape the guard never sees). Budget exhaustion maps to `"budget_exhausted"`, otherwise `"review_findings_remain"`; a passing verdict is `"passed"`). These let pdd_cloud classify the outcome deterministically (issue promptdriven/pdd_cloud#2047) instead of substring-matching free text. +2047-D. **Structured state + machine verdict.** `ReviewLoopState` MUST carry `source_of_truth: Optional[Dict[str, Any]] = None` with shape `{"blocked", "repair_attempted", "repaired", "unrepairable":[{"code_path","prompt_path","kind","reason"}], "offenders":[...]}`. `source_of_truth` remains in the appended field block for positional-construction stability, followed by `same_role_review_fix` (single-role review/fix runs, config-time OR runtime auto-degrade) and then `role_independence: str = "independent"` as the final appended state field (issue #1941 runtime role-independence disclosure). `_write_final_state` MUST persist `source_of_truth` verbatim. `_render_machine_verdict_block` MUST emit two additional `pdd.checkup.final_gate.v1` fields: `source_of_truth` (the state dict verbatim) and `failure_category` (via `_review_loop_failure_category(state, passed, remaining_findings)` returning one of the stable `FINAL_GATE_CATEGORY_*` strings — a source-of-truth blocker maps to `"source_of_truth_repair_needed"`. A source-of-truth blocker is EITHER guard's refusal (the prompt-source guard's `generated-code-only fix refused` OR the architecture-registry guard's `architecture.json registry edit refused`, both in `state.stop_reason`), a recorded `state.source_of_truth["blocked"]`, OR an OPEN reviewer finding about the prompt/architecture source of truth (by `area` or text — the #1519 new-modules-need-contracts shape the guard never sees). Budget exhaustion maps to `"budget_exhausted"`, otherwise `"review_findings_remain"`; a passing verdict is `"passed"`). These let pdd_cloud classify the outcome deterministically (issue promptdriven/pdd_cloud#2047) instead of substring-matching free text. % Dependencies context/agentic_common_example.py diff --git a/pdd/prompts/ci_drift_heal_python.prompt b/pdd/prompts/ci_drift_heal_python.prompt index 6cfdee3c3..d2d6bacec 100644 --- a/pdd/prompts/ci_drift_heal_python.prompt +++ b/pdd/prompts/ci_drift_heal_python.prompt @@ -52,7 +52,7 @@ A standalone CI script (`pdd/ci_drift_heal.py`) that orchestrates drift detectio 5. **Scope control:** `--modules` limits detection to specific basenames. Without it, scan all discovered modules. Accept both space-separated and comma-separated module lists. 6. **Three-way heal dispatch:** Dispatch by `drift.operation`: - - `update`: run `pdd --force --strength 0.5 update ` then invoke the shared `run_metadata_sync` orchestrator (from `pdd.metadata_sync`) for the (prompt, code) pair before the follow-up `pdd --force --strength 0.5 example ` step. The orchestrator finalizes prompt tags, architecture entries, run-report cleanup, and fingerprint state in fixed order; after a successful orchestrator result, refresh the operation-log fingerprint using the authoritative resolved module paths (not only the prompt/code pair handed to the orchestrator) so existing example/test hashes are preserved in the final fingerprint. Pass the `paths` dict from `get_pdd_file_paths` to every `read_fingerprint` and `save_fingerprint` call so reads and writes all anchor at the same subproject `.pdd/meta` directory (issue #1211). If code_path is unresolved, fail closed (no repo-wide fallback). After update, run churn gate and structural invariants gate before the orchestrator call. **Metadata finalization is mandatory and never silent**: `_run_metadata_sync_safe` returning False MUST be treated as a hard heal failure in every invocation mode (PR auto-heal, push-to-main auto-heal, and preflight drift-heal). The module's `heal_module` call MUST return False, the module MUST be recorded as failed (not merely skipped), and `main` MUST return a non-zero exit code with an explicit log line `metadata finalization failed for : ` so the workflow step fails loudly rather than committing a half-synced state. This finalization-failure escalation overrides the push-to-main "advisory" clause in Requirement 15. **Fingerprint refresh paths (issue #1211)**: after a successful `run_metadata_sync` result, build `paths = {"prompt": p, "code": code_p}` directly from the known subproject paths — do NOT call `get_pdd_file_paths(basename, language)`, which resolves from CWD and returns parent-repo paths in parent-CWD mode. Pass this dict to every `read_fingerprint` and `save_fingerprint` call. If `code_p` is None when `result.ok` is True, raise `ValueError("authoritative prompt/code paths unavailable: code_path not provided")`. Example/test-file hash validation (checking `example_hash`/`test_files` in the fingerprint against paths keys `"example"`/`"test_files"`) is skipped since those keys are absent from this minimal dict; that gap is tracked at issue #870. **Module-scoped snapshot/restore (issue #1211)**: implement `_subproject_root_for(drift)` — walk up from `drift.prompt_path` then `drift.code_path` looking for `.pddrc`, fall back to `_repo_root()`. Use it inside `_snapshot_metadata_state_for(drift)` (replacing `_repo_root()`) so `.pdd/meta` paths resolve under the subproject root. Change `_restore_metadata_state_for(snapshot, root: Path)` to accept an explicit `root` parameter; at both call sites pass `_subproject_root_for(drift)`. Before calling `_run_metadata_sync_safe`, capture a per-module snapshot of `architecture.json`, the operation-log fingerprint path `.pdd/meta/_.json`, and the operation-log run-report path `.pdd/meta/__run.json` via `_snapshot_metadata_state_for(drift)`. The safe basename must match `pdd.operation_log` path semantics, so subdirectory basenames such as `commands/foo` snapshot `.pdd/meta/commands_foo_python.json` and `.pdd/meta/commands_foo_python_run.json`, not `.pdd/meta/commands/foo_python.json`. On any orchestrator-stage failure OR on follow-up `pdd example` failure, call `_revert_prompt_file(drift)` AND `_restore_metadata_state_for(snapshot, _subproject_root_for(drift))` to revert this module's writes only. Do NOT use a repo-scoped `git restore .pdd` / `git restore architecture.json` here — that would wipe earlier successful modules' state from the same run on a multi-module push-to-main heal. The legacy `_cleanup_metadata_artifacts` name is preserved as a no-op shim solely for backward import compatibility; new code uses the snapshot/restore primitives. **Preflight drift-heal uses the same orchestrator call** so CLI auto-heal and preflight share one metadata-finalization path, and the same finalization-failure escalation applies there. + - `update`: run `pdd --force --strength 0.5 update ` then invoke the shared `run_metadata_sync` orchestrator (from `pdd.metadata_sync`) for the (prompt, code) pair before the follow-up `pdd --force --strength 0.5 example ` step. The orchestrator finalizes prompt tags, architecture entries, run-report cleanup, and fingerprint state in fixed order; after a successful orchestrator result, refresh the operation-log fingerprint once more using the shared `resolve_fingerprint_paths` + `save_fingerprint` boundary. Seed resolution with the known subproject `{"prompt": p, "code": code_p}` paths so explicit locations stay authoritative, while the prompt-anchored resolver adds existing example/test paths rather than wiping their hashes. Use the same complete mapping for the preceding `read_fingerprint`, preserve a completed user-facing command (`verify`/`test`/`fix`/`update`, falling back to `fix`), and verify the resulting fingerprint has non-null prompt/code hashes. If `code_p` is missing, identity cannot be inferred, path resolution/persistence fails, or the written fingerprint is incomplete, return False with an explicit finalization diagnostic. After update, run churn gate and structural invariants gate before the orchestrator call. **Metadata finalization is mandatory and never silent**: `_run_metadata_sync_safe` returning False MUST be treated as a hard heal failure in every invocation mode (PR auto-heal, push-to-main auto-heal, and preflight drift-heal). The module's `heal_module` call MUST return False, the module MUST be recorded as failed (not merely skipped), and `main` MUST return a non-zero exit code with an explicit log line `metadata finalization failed for : ` so the workflow step fails loudly rather than committing a half-synced state. This finalization-failure escalation overrides the push-to-main "advisory" clause in Requirement 15. **Module-scoped snapshot/restore (issue #1211)**: implement `_subproject_root_for(drift)` — walk up from `drift.prompt_path` then `drift.code_path` looking for `.pddrc`, fall back to `_repo_root()`. Use it inside `_snapshot_metadata_state_for(drift)` (replacing `_repo_root()`) so `.pdd/meta` paths resolve under the subproject root. Change `_restore_metadata_state_for(snapshot, root: Path)` to accept an explicit `root` parameter; at both call sites pass `_subproject_root_for(drift)`. Before calling `_run_metadata_sync_safe`, capture a per-module snapshot of `architecture.json`, the operation-log fingerprint path `.pdd/meta/_.json`, and the operation-log run-report path `.pdd/meta/__run.json` via `_snapshot_metadata_state_for(drift)`. The safe basename must match `pdd.operation_log` path semantics, so subdirectory basenames such as `commands/foo` snapshot `.pdd/meta/commands_foo_python.json` and `.pdd/meta/commands_foo_python_run.json`, not `.pdd/meta/commands/foo_python.json`. On any orchestrator-stage failure OR on follow-up `pdd example` failure, call `_revert_prompt_file(drift)` AND `_restore_metadata_state_for(snapshot, _subproject_root_for(drift))` to revert this module's writes only. Do NOT use a repo-scoped `git restore .pdd` / `git restore architecture.json` here — that would wipe earlier successful modules' state from the same run on a multi-module push-to-main heal. The legacy `_cleanup_metadata_artifacts` name is preserved as a no-op shim solely for backward import compatibility; new code uses the snapshot/restore primitives. **Preflight drift-heal uses the same orchestrator call** so CLI auto-heal and preflight share one metadata-finalization path, and the same finalization-failure escalation applies there. - `example`: run `pdd --force --strength 0.5 example ` directly. Require both paths resolved; fail closed otherwise. In CI, when `PDD_HEAL_SKIP_REVIEW_ONLY_EXAMPLE_DRIFT=1`, the drift reason is git-reclassified as already-covered PR review work (`Code and prompt changed together...` or `Prompt changed without code changes...`), and the resolved example file already exists, skip the example rewrite and leave that reviewed artifact to the PR author; missing examples still run the heal. When `PDD_HEAL_SKIP_EXISTING_EXAMPLE_DRIFT=1` and any resolved example file already exists, skip the example rewrite as a broader fallback. - `auto-deps`: run `pdd --force --strength 0.5 auto-deps --output --csv project_dependencies.csv` directly. Do not route this through full `pdd sync`, because sync can continue into generate/crash flows after dependency insertion. - `verify`/`generate`/`test`/`crash`: run `pdd --force --strength 0.5 sync ` to let sync_determine_operation dispatch correctly. In PR auto-heal mode (no `--skip-ci`), the subprocess env MUST include `PDD_DISABLE_TEST_EXTEND=1` so this nested sync cannot escalate a narrow heal into coverage-driven `test_extend`. Do NOT use `pdd example` for these — it would overwrite user edits (verify), skip code regeneration (generate), or save a stale fingerprint. The only exceptions are the git-based clean-CI reclassifications above, where a reported `auto-deps`/`generate` operation is converted to `example` or skipped based on actual code/prompt changes in the PR. diff --git a/pdd/prompts/ci_validation_python.prompt b/pdd/prompts/ci_validation_python.prompt index f1e1835e3..183342fd7 100644 --- a/pdd/prompts/ci_validation_python.prompt +++ b/pdd/prompts/ci_validation_python.prompt @@ -5,7 +5,7 @@ "type": "module", "module": { "functions": [ - {"name": "run_ci_validation_loop", "signature": "(cwd, repo_owner, repo_name, issue_number, max_retries, step_template, run_agentic_task_fn, timeout, quiet, expected_head_sha_override=None)", "returns": "Tuple[bool, str, float]", "sideEffects": ["without expected_head_sha_override, polls the PR's live head SHA instead of cwd's local HEAD so stale cloud worktrees do not burn the timeout", "expected_head_sha_override forces the poll to wait for a specific verified PR head SHA; if GitHub never reports that SHA as live, the loop fails with an explicit stale-head diagnostic", "poll timeouts with observed required checks are treated as inconclusive and best-effort instead of driving the fix loop", "required checks whose only terminal non-success state is ACTION_REQUIRED are treated as manual-action inconclusive instead of code failures", "when .pddrc ci.manual_trigger_comment or ci.manual_triggers is configured for ACTION_REQUIRED checks, posts each matching trigger comment once and repolls before falling back to inconclusive, ignoring passed checks for trigger matching", "missing or unknown CI check buckets are treated as failed/unknown rather than passing from a successful gh exit code, except ACTION_REQUIRED state remains manual-action even with a missing bucket", "required check failures whose logs clearly show external CI setup/auth issues such as missing GitHub Actions Google/Firebase secrets are treated as inconclusive instead of driving the CI-fix loop only when .pddrc ci.external_setup_fail_open is exactly true; by default failed checks remain fail-closed/repairable"]}, + {"name": "run_ci_validation_loop", "signature": "(cwd, repo_owner, repo_name, issue_number, max_retries, step_template, run_agentic_task_fn, timeout, quiet, expected_head_sha_override=None, pre_commit_check=None, commit_files=None)", "returns": "Tuple[bool, str, float]", "sideEffects": ["without expected_head_sha_override, polls the PR live head SHA", "commit_files returns the exact workflow-owned path set that may be committed and pre_commit_check receives the same set before commit/push", "poll timeouts with observed required checks are treated as inconclusive", "ACTION_REQUIRED checks are treated as manual-action inconclusive", "failed checks remain fail-closed/repairable by default"]}, {"name": "run_github_checks_gate", "signature": "(cwd, repo_owner, repo_name, pr_number, quiet, expected_head_sha=None, required_only=False)", "returns": "Tuple[bool, str, str]"}, {"name": "post_ci_failure_comment", "signature": "(repo_owner, repo_name, pr_number, failures, attempts, cwd)", "returns": "bool"}, {"name": "detect_ci_system", "signature": "(cwd)", "returns": "str"} @@ -32,14 +32,14 @@ Write the `pdd/ci_validation.py` module. Module providing CI polling, log retrieval, and fix-iteration loop infrastructure for post-push CI validation. This module bridges the gap between external CI systems (GitHub Actions, Cloud Build) and the agentic fix workflow by detecting failures, retrieving logs, and invoking an LLM to apply fixes. % Requirements -1. Function: `run_ci_validation_loop(cwd: Path, repo_owner: str, repo_name: str, issue_number: int, max_retries: int, step_template: str, run_agentic_task_fn: Callable, timeout: float, quiet: bool, expected_head_sha_override: Optional[str] = None) -> Tuple[bool, str, float]` — returns (ci_passed, message, cost). When `expected_head_sha_override` is not provided, read the PR's live head SHA with `_get_pr_head_sha(...)` and use that as the expected poll head, falling back to `_get_head_sha(cwd)` only if GitHub cannot report the live head. This prevents stale cloud worktree HEADs from forcing the poll to wait for an obsolete SHA. When `expected_head_sha_override` is provided, the inner poll uses it as the expected PR head SHA. This is required for the post-checkup re-validation gate, where the checkup pushes from its own worktree and `cwd`'s local HEAD is stale relative to the PR remote. If GitHub never reports the override as the live PR head, fail with an explicit stale-head diagnostic naming the expected SHA, live PR SHA, PR number, and last known checks instead of returning a generic timeout. +1. Function: `run_ci_validation_loop(cwd: Path, repo_owner: str, repo_name: str, issue_number: int, max_retries: int, step_template: str, run_agentic_task_fn: Callable, timeout: float, quiet: bool, expected_head_sha_override: Optional[str] = None, pre_commit_check: Optional[Callable[[List[str]], Optional[str]]] = None, commit_files: Optional[Callable[[], Sequence[str]]] = None) -> Tuple[bool, str, float]` — returns (ci_passed, message, cost). Preserve the existing live-head polling behavior. After an agent edit, `commit_files` returns the exact workflow-owned paths; pass that same list to `pre_commit_check` and `_commit_ci_fix(..., allowed_files=...)`. `_commit_ci_fix` intersects dirty files with the allowlist and uses `git commit --only -- `, so unrelated pre-existing or pre-staged files cannot enter the remediation commit. With an active allowlist, never fall back to pushing arbitrary existing unpushed commits. A validation error or exception fails closed. 2. Discover the current branch's open PR and capture `pr_number` plus current head SHA; if no PR is found, return success with an informational message. 3. Poll CI status via `gh pr checks --required --json name,state,bucket,link` with structured output. Handle exit codes: 0=pass, 1=fail, 8=pending. Use the `bucket` field plus each check `state` to distinguish pass/fail/pending/manual-action states and re-query checks after each new push/SHA so the loop does not act on stale results from the previous head. A non-empty check set with a missing/unknown bucket is not pass evidence even when `gh` exits 0; fail closed instead of relying on `all([])` or exit-code fallback, except `state=ACTION_REQUIRED` remains manual-action even when its bucket is missing or malformed. If the live PR head differs from the expected head, still read the live PR checks before sleeping; terminal non-success states such as `failure`, `cancelled`, `timed_out`, or `startup_failure` must return a settled failure immediately rather than hiding behind a stale-head wait loop. `ACTION_REQUIRED` is terminal but non-repairable by code: when every non-success/non-skipped check is `ACTION_REQUIRED` and there are no pending checks, classify it as `"action_required"` instead of `"failed"` so the CI-fix LLM loop is not invoked for a manual trigger. If any pending check is present alongside `ACTION_REQUIRED`, keep polling/pending; if any real failed/cancelled/timed-out/startup-failure check is present alongside `ACTION_REQUIRED`, the real failure wins and the status is `"failed"` even if its bucket is missing or malformed. 4. Handle startup delay: wait 30s initial delay after push, then poll every 30s up to 10 minutes for checks to appear/complete. A poll timeout SPLITS into two cases by whether any required checks were actually read for the expected head: (a) INCONCLUSIVE → FAIL OPEN — when the poll OBSERVED the required checks and they are merely pending / not-yet-completed (a NON-empty check set; commonly a manually-triggered or external check the bot structurally cannot run, e.g. a `/gcbrun`-gated build), return `(True, , total_cost)`, posting a neutral best-effort PR comment (NOT a CI-failure comment) and NOT driving the fix loop. This is safe because a genuine check FAILURE returns `"failed"` from the classifier and never reaches the timeout branch, so failing open cannot mask a real failure; the fix is already committed and a pending check the bot cannot run must not retroactively hard-fail an otherwise-correct fix. For a pure settled `"action_required"` status, first look for optional root `.pddrc` CI config: `ci.manual_triggers` maps case-insensitive ACTION_REQUIRED check-name substrings to comments and wins over `ci.manual_trigger_comment` for those checks only; passed checks must not consume or suppress manual triggers. `ci.manual_trigger_comment` is the global fallback for ACTION_REQUIRED checks without a specific match. If trigger comments are configured for multiple observed checks, post each matching trigger body at most once, waiting and repolling after each post before falling back. If no trigger remains configured, posting fails, or all configured triggers have already been posted and the checks still require action, treat the status as INCONCLUSIVE → FAIL OPEN with a neutral manual-action note, because it requires an external trigger/comment and is not an actionable code failure. (b) FAIL CLOSED — when NO required checks were ever read for the expected head (an EMPTY check set: the PR head never matched the expected SHA, or the rollup was unreadable for the whole window), return `(False, , total_cost)` and post a CI-failure comment; with no observed checks we cannot prove the checks are merely pending rather than failing, so we must not green-light a head whose real status was never seen. 5. On CI failure: retrieve failure logs via tiered fallback — `gh run view --log-failed` → `gh api` zip download → fallback to check link URL. 6. Before invoking the CI-fix LLM loop, failed checks stay fail-closed by default. Only when the root `.pddrc` has `ci.external_setup_fail_open: true`, classify clear external/manual CI setup failures from the check summary plus logs. If the logs show missing or empty GitHub Actions credentials/secrets that code changes cannot repair — for example `google-github-actions/auth` failing because neither `credentials_json` nor `workload_identity_provider` is configured, Firebase service-account secrets missing/unavailable, secrets unavailable for the pull-request workflow event, or GitHub integration permission failures — post a neutral PR note, return success with an inconclusive/best-effort message, and do NOT call `run_agentic_task_fn` or post the structured CI-failure comment, when failed check names are external/setup-shaped or generic CI/build names. Do not classify ordinary lint, import, test, or compilation failures this way; when external setup text and actionable test/lint/import/compile/build signals appear either in the logs (including common frontend build text such as `Module not found` / `Can't resolve`) or in another failed check name, the actionable failure wins and the CI-fix loop remains available. 7. On actionable failure with logs: format context and call `run_agentic_task_fn` with the step 10 LLM template to interpret and fix. -8. After fix: Only commit/push when the LLM output contains `CI_FIX_APPLIED`. Stage changed files and commit with message `"ci: fix CI failures for #{issue_number}"`, push, and re-poll. +8. After fix: Only commit/push when the LLM output contains `CI_FIX_APPLIED`. Resolve `commit_files`, run `pre_commit_check(exact_files)`, stage only those paths, and commit with `git commit --only -m "ci: fix CI failures for #{issue_number}" -- `. Leave unrelated dirty/staged files untouched, then push and re-poll. 9. Artifact Exclusion: When staging changes, explicitly exclude the same debug/intermediate artifact patterns filtered by the main orchestrator (`_fixed.py`, `.bak`, `.tmp`, `error_output*.txt`, `.pdd/` except state). 10. On max retries exhausted: return `(False, summary_of_remaining_failures, total_cost)`. 11. Function: `post_ci_failure_comment(repo_owner: str, repo_name: str, pr_number: int, failures: List[str], attempts: int, cwd: Path) -> bool` — posts a structured PR comment describing unresolved CI failures and what was attempted using `post_pr_comment`. diff --git a/pdd/prompts/cmd_test_main_python.prompt b/pdd/prompts/cmd_test_main_python.prompt index 09d99d50d..5a57d0d5f 100644 --- a/pdd/prompts/cmd_test_main_python.prompt +++ b/pdd/prompts/cmd_test_main_python.prompt @@ -50,6 +50,13 @@ % along with ctx.obj['force'], ctx.obj['quiet'], and command="test". % When calling `construct_paths`, include `context_override=ctx.obj.get('context')` so a global `--context` is honored. % Also pass `confirm_callback=ctx.obj.get('confirm_callback')` for TUI confirmation support. +% - **Runner-collected co-located test adoption (#1903):** Immediately AFTER `construct_paths` returns (and before resolving the effective config / write), when the resolved test path is PDD's DERIVED DEFAULT (not user-pinned), adopt an existing runner-collected co-located test as the canonical output so BOTH the agentic and native branches target the real test CI runs instead of a runner-blind `tests/` shadow (on a jest/Next.js project PDD otherwise verifies its own `tests/` shadow while the co-located `__test__/*.test.tsx` goes stale — a false-green): +% * Treat the location as user-pinned (NEVER overridden) when the caller passed an explicit `output` (CLI `--output`) OR `configured_test_output_pinned(pin_target, context_override=ctx.obj.get('context'), search_from=Path(pin_target).parent)` reports a pin — i.e. the `PDD_TEST_OUTPUT_PATH` env var, or an explicit `.pddrc` `test_output_path`/`outputs.test.path` for this module's context. Read the pin from the RAW `.pddrc` defaults via `configured_test_output_pinned` (imported from `content_selector`), NEVER from `construct_paths`' `resolved_config` (which injects a generated-default and would always read as pinned). Resolve the context the way `construct_paths` does: an explicit `--context` (`ctx.obj.get('context')`) wins, else DETECT it from the PROMPT file — set `pin_target = prompt_file or code_file`, because the prompt's `prompts_dir` selects the context and the code file's path may not match the context's `paths` — anchoring the `.pddrc` lookup on the prompt side. +% * When NOT pinned, set `output_file_paths['output'] = str(resolve_test_output_path(code_file, output_file_paths['output'], user_pinned=))` (from `content_selector`). Mutating `output_file_paths['output']` keeps a single source of truth for the downstream write/churn steps. +% * **Adoption provenance (#1903 §B.4):** at this SAME resolution point (BEFORE generation overwrites any file), capture `test_was_adopted_human = was_test_adopted(code_file, , , user_pinned=)` (from `content_selector`). Thread this value as `adopted_human=test_was_adopted_human` into EVERY `_verify_test_churn(...)` call and every directly-constructed `TestChurnError(...)` in this command, so the raised churn error/block records whether the churned test was adopted from an existing human co-located test — the provenance the issue-driven never-block requires. **Greenfield ownership:** when PDD is GREENFIELD-creating the test (unpinned, `find_collocated_test(code_file)` is None, the resolved output differs from the derived default, and the resolved path does not yet exist), also call `record_pdd_created_test()` so a LATER run does not misclassify this PDD-owned test as human-adopted. +% * Keep the native/cloud generation destination in sync with the adopted write target: the write/churn steps read `output` (adopted above), but native/cloud generation reads its destination from the SEPARATE `output_file` key. For `test` commands `output_file` is absent and would fall back to a bare `test_output.py`, prompting the LLM with the wrong destination (broken relative imports) while the write lands at the adopted/real path. `output_file_paths.setdefault('output_file', output_file_paths['output'])` — default `output_file` to the adopted target, leaving any explicitly-provided `output_file` untouched. (Sync passes the already-adopted path in as an explicit `output`, so no retarget happens for it here.) +% * **Merge-into-existing pin (#1903):** when `merge` is True AND `existing_tests` is provided, sync is regenerating a test by MERGING into an already-existing test (passed as an explicit `output` with `merge=True`). With `force=False`, `construct_paths` renames that existing path to a numbered sibling (e.g. `page.test_1.tsx`), so the agentic/native write would create a NEW shadow beside the real test while sync keeps verifying the original (now stale) one — re-introducing the false-green for the core jest/TSX case. When merging into a known existing test the write target IS that test: pin BOTH `output_file_paths['output']` AND `output_file_paths['output_file']` to `existing_tests[0]` so generation and the write both land on the real file (churn-guarded), never a numbered shadow. +% * Explicit `--output` is still honored; churn protections are unchanged. % - When `existing_tests` is provided, read all files and concatenate their content into `input_strings["existing_tests"]`. % - If 'coverage_report' is NOT provided: use Cloud vs Local Execution Strategy (see below) to generate tests. % Detect if the code_file is an example file by checking if the filename stem ends with '_example'. diff --git a/pdd/prompts/code_generator_main_python.prompt b/pdd/prompts/code_generator_main_python.prompt index 566867d03..ca3029c38 100644 --- a/pdd/prompts/code_generator_main_python.prompt +++ b/pdd/prompts/code_generator_main_python.prompt @@ -11,8 +11,8 @@ {"name": "git_add_files", "signature": "(file_paths: List[str], verbose: bool = False) -> bool", "returns": "bool"}, {"name": "code_generator_main", "signature": "(ctx: click.Context, prompt_file: str, output: Optional[str], original_prompt_file_path: Optional[str], force_incremental_flag: bool, env_vars: Optional[Dict[str, str]] = None, unit_test_file: Optional[str] = None, exclude_tests: bool = False, language: Optional[str] = None, output_from_config: bool = False, compress: bool = False, snapshot_context: bool = False, compressed_context: Optional[Mapping[str, Any]] = None) -> Tuple[str, bool, float, str]", "returns": "Tuple[str, bool, float, str]"}, {"name": "ArchitectureConformanceError", "signature": "(prompt_name: str, output_path: str, architecture_entry: Dict[str, Any], expected_symbols: List[str], found_symbols: List[str], missing_symbols: List[str], message: Optional[str] = None, total_cost: float = 0.0, model_name: str = \"unknown\", repair_directive: Optional[str] = None)", "returns": "ArchitectureConformanceError"}, - {"name": "PublicSurfaceRegressionError", "signature": "(prompt_name: str, output_path: str, removed_symbols: List[str], pre_surface_size: int, post_surface_size: int, changed_signatures: Optional[List[str]] = None, total_cost: float = 0.0, model_name: str = \"unknown\", repair_directive: Optional[str] = None)", "returns": "PublicSurfaceRegressionError"}, - {"name": "TestChurnError", "signature": "(prompt_name: str, output_path: str, churn_ratio: float, threshold: float, pre_line_count: int, post_line_count: int, total_cost: float = 0.0, model_name: str = \"unknown\", repair_directive: Optional[str] = None)", "returns": "TestChurnError"}, + {"name": "PublicSurfaceRegressionError", "signature": "(prompt_name: str, output_path: str, removed_symbols: List[str], pre_surface_size: int, post_surface_size: int, changed_signatures: Optional[List[str]] = None, total_cost: float = 0.0, model_name: str = \"unknown\", repair_directive: Optional[str] = None, signature_details: Optional[List[Tuple[str, str, str, str]]] = None)", "returns": "PublicSurfaceRegressionError"}, + {"name": "TestChurnError", "signature": "(prompt_name: str, output_path: str, churn_ratio: float, threshold: float, pre_line_count: int, post_line_count: int, total_cost: float = 0.0, model_name: str = \"unknown\", repair_directive: Optional[str] = None, adopted_human: bool = False)", "returns": "TestChurnError"}, {"name": "ProseOutputError", "signature": "(prompt_name: str, output_path: str, language: str, model_name: str = \"unknown\", total_cost: float = 0.0, raw_output: Optional[str] = None, extractor_result: str = \"empty\")", "returns": "ProseOutputError"} ] } @@ -107,7 +107,7 @@ You are an expert Python Backend Engineer responsible for the `code_generator_ma - Implement helper `_prompt_breaking_change_removed_symbols(prompt_content: str) -> Set[str]`: parse only **anchored** `BREAKING-CHANGE:` directive lines (regex `^\s*BREAKING-CHANGE:\s*` with `re.MULTILINE`) — buried mid-line marker mentions like `See the BREAKING-CHANGE: marker doc for details` must NOT register as real directives. After the action verb (`remove`/`removes`/`removed`/`removing`, `delete`/`deletes`/`deleted`/`deleting`, `drop`/`drops`/`dropped`/`dropping`, or `rename`/`renames`/`renamed`/`renaming`) the remainder MUST be a comma-separated list of identifier tokens — bare, backticked (`` `old_helper` ``), single-quoted (`'old_helper'`), or double-quoted (`"Class.method"`). Wrappers MUST match on both sides (no `"old_helper'`); mismatched wrappers are rejected. Prose tokens (anything containing embedded whitespace) are rejected: a directive like `BREAKING-CHANGE: remove old_helper to opt out` whitelists only `old_helper`, never `to`/`opt`/`out`. For multi-symbol opt-outs, omit trailing prose entirely — use `BREAKING-CHANGE: remove old_helper, retired_method`. A bare `BREAKING-CHANGE:` marker MUST NOT disable the gate globally. Descendant expansion: when a top-level class name appears in the removal allow-list (e.g. `BREAKING-CHANGE: remove Service`), the public-surface gate MUST implicitly authorize removing every `Class.method` / `Class.Inner.method` descendant the pre-sync snapshot captured for that class; callers should not have to enumerate every member by hand. This expansion applies ONLY to removal verbs (`remove`/`delete`/`drop`/`rename`) — signature-change directives stay strict per-symbol because "change signature Service" is ambiguous (constructor? every method?) and silent broadening would mask real regressions. - Implement helper `_prompt_breaking_change_signature_symbols(prompt_content: str) -> Set[str]`: same anchored-directive parsing as `_prompt_breaking_change_removed_symbols`. The directive must start with a `change` verb (`change`/`changes`/`changed`/`changing`) followed by `signature`/`signatures`/`api`/`contract`; after the verb pair the remainder must be a comma-separated symbol list using the same identifier grammar (bare or wrapped in matching backticks / single-quotes / double-quotes). - **Semantic callable-signature comparison (#1012 / #1558)**: decide whether "a public callable signature changed" by comparing each shared symbol's pre/post `_snapshot_public_signatures` entry with `signature_entries_compatible` (from `pdd.interface_semantics`), NOT raw string equality, so a backward-compatible widening or a default reformat is not a false regression. Build per-side module default-symbol tables `build_module_default_symbols(existing_code)` and `build_module_default_symbols(generated_code)` and pass them as `old_symbols` / `new_symbols` so a parameter default written as a same-module constant resolves to its literal — each side resolved against its OWN module version. A literal ↔ same-module-immutable-constant refactor of a default (`max_chars=25000` ↔ `_LIMIT = 25000; max_chars=_LIMIT`) is therefore NOT a regression, while the SAME constant name resolving to a different value across the two versions (`_LIMIT = 25000` → `_LIMIT = 5000`, identical signature text) IS a provable break and still raises. For callable entries do NOT short-circuit on equal signature text before this semantic comparison, or the same-named-constant value-change case is missed; only when `signature_entries_compatible` returns `None` (a non-callable entry such as an `[assignment]` or an import re-export) fall back to exact-string equality. Unresolvable defaults (calls, imported names) stay `UNKNOWN` and are flagged only when the source text also differs. - - When an unlisted removal is non-empty OR a public callable signature changed AND a pre-existing code file was loaded, raise a new typed exception `PublicSurfaceRegressionError(click.UsageError)` colocated with `ArchitectureConformanceError`. The constructor parameter order is `prompt_name: str`, `output_path: str`, `removed_symbols: List[str]`, `pre_surface_size: int`, `post_surface_size: int`, `changed_signatures: Optional[List[str]] = None`, `total_cost: float = 0.0`, `model_name: str = "unknown"`, `repair_directive: Optional[str] = None`. `changed_signatures` carries a default and is typically passed by keyword from callers, but the positional order MUST match this list so call sites that pre-date the signature gate (which used to pass only removals plus sizes) keep working. The exception also exposes a `repair_directive: str` property of the form: + - When an unlisted removal is non-empty OR a public callable signature changed AND a pre-existing code file was loaded, raise a new typed exception `PublicSurfaceRegressionError(click.UsageError)` colocated with `ArchitectureConformanceError`. The constructor parameter order is `prompt_name: str`, `output_path: str`, `removed_symbols: List[str]`, `pre_surface_size: int`, `post_surface_size: int`, `changed_signatures: Optional[List[str]] = None`, `total_cost: float = 0.0`, `model_name: str = "unknown"`, `repair_directive: Optional[str] = None`, `signature_details: Optional[List[Tuple[str, str, str, str]]] = None`. `changed_signatures` carries a default and is typically passed by keyword from callers, but the positional order MUST match this list so call sites that pre-date the signature gate (which used to pass only removals plus sizes) keep working. `signature_details` (added last, keyword, #1900) carries `(symbol, expected_entry, actual_entry, source)` tuples for prompt-declared-interface mismatches; the constructor APPENDS one `signature_detail: ` line per tuple to the message AFTER the existing fields (which stay byte-identical), where `` is a compact one-line `json.dumps({"symbol": ..., "expected": ..., "actual": ..., "source": ...})`. JSON encoding keeps the full declared-expected-vs-actual contract intact even when a signature/default contains delimiter-like substrings (PEP-604 `|` unions, quoted defaults), which corrupted the earlier `|`-separated format; the parser is `agentic_sync_runner._parse_signature_detail_lines` (`json.loads` per line, malformed lines skipped). The exception also exposes a `repair_directive: str` property of the form: ``` Public-surface regression detected for {prompt_name}. The following previously-exported public symbols are missing from the regenerated code: @@ -120,11 +120,18 @@ You are an expert Python Backend Engineer responsible for the `code_generator_ma - First-time generation is exempt: when no pre-existing code file is loaded, skip the gate silently (a brand-new module has no surface to preserve). The snapshot MUST be taken from the file on disk before the new generation overwrites it. - `PDD_SKIP_PUBLIC_SURFACE_GATE=1` disables only this gate. `PDD_SKIP_CONFORMANCE=1` continues to disable all conformance gates. - The gate runs AFTER architecture conformance and pdd-interface signature checks pass, so a single retry loop can carry both repair directives via `PDD_REPAIR_DIRECTIVE`. + - **Prompt-declared interface as the surface contract (#1900)**: when the prompt supplies a `type: "module"` ``, treat its declared `module.functions` as a stable public-surface contract for those symbols instead of diffing them against the previous generation (the old-vs-new baseline had no stable repair target and dead-ended `pdd change → pdd sync` whenever a declared interface legitimately evolved). Implement `_collect_declared_surface(prompt_content: str, prompt_name: str) -> Dict[str, Optional[str]]` returning `{declared_name -> raw_signature_or_None}` for a `type: "module"` interface ONLY (mirror the `module.functions` scope; `cli`/`command` names are NOT Python surface symbols and MUST be excluded; a `None` signature is a description-only declaration). The gate is then PER-SYMBOL: + - **Undeclared symbols** keep the previous-generation baseline above (removal diff + `signature_entries_compatible` vs the OLD code, plus the `BREAKING-CHANGE` opt-out) — unchanged, so helpers / re-exports stay protected. + - **Declared top-level (non-dotted) functions**: a declared name absent from the generated surface is a removal (BREAKING-CHANGE does NOT excuse a still-declared symbol); otherwise compare the generated signature entry against the DECLARED signature with `signature_entries_compatible`, with BOTH `old_symbols` and `new_symbols` resolved in the GENERATED module namespace (`build_module_default_symbols(generated_code)`), because the declaration describes the generated module (#1558) — do NOT use old-code symbols for declared expectations. Normalize the declared signature to a snapshot-style entry with `_declared_signature_to_entry(raw_sig, binding_kind, is_async=...)` (strip a leading `async`/`def ` prefix; a non-paren signature — `None`, a class header, `...` — is presence-only). Because `` cannot express `self`/property/`async`, the entry's binding-kind + async are taken from the PRE-generation entry when the symbol was a callable there (via `_entry_binding_context(before_signatures[name])`), else from the generated entry — so params/return are governed by the declaration while an async↔sync or function↔class flip is still caught against the old code. + - **Declared dotted methods** (`Class.method`) and **constructors** (`Class.__init__`) with a parseable paren signature are validated against the declaration exactly like top-level functions, but RECEIVER-STRIPPED to match the snapshot: resolve the snapshot key (`Class.__init__` → the class `[class]` entry, since a constructor's ABI is keyed on the class; a method → its own `Class.method` entry), take the expected binding-kind/async from the pre-generation entry for that key when it is callable (else from the generated entry), and drop a leading `self`/`cls` from the declared signature when the expected kind is `instance` / `classmethod` / `class` (`_declared_signature_to_entry(..., strip_receiver=True)`), so `(self, x)` compares as `(x)` while an already-stripped `(x)` is unchanged. These symbols join `declared_validated` (keyed on the snapshot key, so a validated `__init__` excludes the class entry and a validated method excludes `Class.method`) and are excluded from the old-code baseline — editing the declaration authorizes an intended method/constructor change, and `BREAKING-CHANGE: change signature` relaxes only the un-declarable binding-kind/async, never the declared parameters. ONLY a declaration without a parseable paren signature (a description-only entry, `class Service`, `None`, `...`) stays presence-only and is NOT added to `declared_validated`, so it falls back to the previous-generation (old-vs-new) baseline (an existing symbol's ABI drift is still caught there, honoring `BREAKING-CHANGE` + #1558). Presence of a declared dotted name is enforced via `_declared_presence_name(name)` (mapping `Foo.__init__` → the `Foo` class symbol). Only `declared_validated` — not every declared name — is excluded from the old-code baseline loops, so no pre-#1900 protection is lost. + - `BREAKING-CHANGE: change signature ` for a DECLARED symbol relaxes ONLY the un-declarable binding-kind/async (take them from the generated entry), NEVER the declared params/return — an added-required-param that violates the declared signature is not bypassable by prose. + A declared-surface violation still raises `PublicSurfaceRegressionError` (declared removals join `removed:`, declared signature drifts join `signature_changed:` — byte-identical fields), additionally carrying `signature_details` so the `signature_detail:` lines named above reach the in-process and subprocess (`agentic_sync_runner`) repair loops with the exact declared target. The repair directive built from these `pdd-interface` details (both the in-process `PublicSurfaceRegressionError.repair_directive` property and the `agentic_sync_runner` subprocess reconstruction) MUST inject the declared signature as a VERBATIM hard constraint — instruct the model to emit each declared signature exactly, reproduce the declared annotation text token-for-token, and NEVER substitute an equivalent-but-differently-spelled type (keep `object` as `object`, never `Any`) or broaden a declared parameter's type with `|` union members the declaration omits — so an annotation-level drift (declared `object` vs regenerated `Any`, or a broadened union) converges instead of the retry re-emitting the identical spelling (#1968). Modules whose prompt declares no `type: "module"` `` behave exactly as before this change. + - **Deterministic annotation reconciliation (#1968)**: immediately BEFORE the public-surface gate runs, `code_generator_main` calls `_reconcile_declared_annotation_drift(existing_code, generated_code, prompt_name, output_path, language, prompt_content) -> Optional[str]` and, when it returns a non-`None` string, uses that as the generated content the gate checks and the writer persists. For every declared `type: "module"` symbol whose generated signature differs from the declaration ONLY in annotation spelling on matching parameters/return (identical parameter names, order, kinds and defaults; only annotations the gate deems INCOMPATIBLE via `annotations_compatible`), it rewrites the offending annotation(s) in the generated SOURCE to the declared text (a UTF-8 byte-offset splice on the emitted annotation node), so the gate then passes on the reconciled code with no further generation attempt. It is fail-safe: any structural drift disqualifies that symbol (left for the gate and repair loop), a compatible alias (`Dict` vs `dict`) is never churned, and the whole rewrite is discarded if the reconciled source no longer parses. It returns `None` (a no-op that never alters an otherwise-passing generation) unless it reconciles at least one annotation; `PDD_SKIP_ANNOTATION_RECONCILE=1` bypasses it. This is the deterministic backstop that guarantees convergence on annotation-level drift even if the VERBATIM directive above does not. - **Empty-generation gating**: the conformance, public-surface, and test-churn gate-call sites in `code_generator_main` MUST gate on `generated_code_content is not None` (NOT a truthy check). A truthy check skips every gate when the provider returns `""`, and the writer below then truncates the existing file to 0 bytes — silent erasure of mature public APIs / test coverage. With `is not None` the gates run against an empty string: `_snapshot_public_surface("")` returns `set()` so every prior symbol surfaces as removed (PublicSurfaceRegressionError), and `_compute_test_churn_ratio(existing, "")` returns 1.0 so any non-empty test file trips TestChurnError. The compat-gate restore branch (re-write `existing_code_content` to `output_path` on raise) MUST cover this case too so the repair loop sees the canonical pre-sync content. For file types neither gate can inspect (non-Python source, JSON, YAML, prompts, etc.), `code_generator_main` MUST additionally enforce a safety guard immediately before `p_output.write_text(...)`: when `existing_code_content` is non-empty AND `generated_code_content` is `None` / empty / whitespace-only AND `PDD_ALLOW_EMPTY_GENERATION` is NOT enabled, raise `click.UsageError("Refusing to overwrite {output_path} with empty generated content (existing file has {len} chars). ... Set PDD_ALLOW_EMPTY_GENERATION=1 to bypass.")` BEFORE the write. The escape hatch exists for the rare intentional empty-output case. -5c. **Test-Churn Gate (#1012)**: When the generated artifact is a test file (output path under the project's tests directory — `tests/` or `__tests__/` — OR filename starts with `test_` OR the filename ends with a language-specific test suffix; two families: (i) **lowercase `_test.` / `_spec.` sibling-file suffixes** for every extension in `_LANGUAGE_TEST_FILE_EXTS` (a module-level tuple seeded with `.py`, `.go`, `.rb`, `.rs`, `.exs`, `.ex`, `.dart`, `.clj`, `.cljc`, `.lua`, `.php` — append new entries here when adding a language to `language_format.csv` that uses the `_test.` convention, so the gate auto-covers it without another fix round); (ii) **PascalCase JVM/.NET/Swift suffixes** — `Test.java`/`Tests.java`/`TestCase.java`/`IT.java`, `Test.kt`/`Tests.kt`/`Spec.kt`, `Test.scala`/`Tests.scala`/`Spec.scala`, `Test.groovy`/`Tests.groovy`/`Spec.groovy`, `Tests.swift`, `Test.cs`/`Tests.cs` — matched case-sensitive so `latest.kt`/`manifest.java`/`request.scala`/`latest.groovy` do not false-positive; the JS/TS `.test.` / `.spec.` (`.ts`/`.tsx`/`.js`/`.jsx`) family stays case-insensitive) AND the test file already exists with non-empty content AND the prompt diff body does NOT contain an explicit, **anchored** test rewrite marker (a real `^\s*BREAKING-CHANGE:\s*` directive line whose tail pairs an opt-out verb — `rewrite`/`rewriting`/`replace`/`replacing`/`regenerate`/`regenerating`/`overwrite`/`overwriting`/`overwritten`/`churn`/`remove`/`removing`/`drop`/`dropping` — with `test`/`tests` AS THE VERB'S DIRECT OBJECT; the parser MUST accept both imperative and gerund wording such as `BREAKING-CHANGE: rewriting tests`, and MUST reject directives where the verb governs a different noun phrase from `tests` such as `BREAKING-CHANGE: rewrite docs and update tests` (here `rewrite`'s object is `docs`, and `update` is not in the opt-out verb list — a comma, semicolon, or conjunction `and`/`but`/`then`/`or`/`plus`/`also` between the verb and `tests?` breaks the phrase). Form: scan each opt-out verb match; require `tests?` to appear after the verb with no separator between them. So `BREAKING-CHANGE: drop foo and rewrite tests` opts out (second verb `rewrite` directly governs `tests`), but `BREAKING-CHANGE: rewrite docs and update tests` does not), compute the unified-diff churn ratio between pre-sync and proposed test contents. Buried mid-line mentions of the marker (instructional prose referring to it by example) MUST NOT opt out the gate. If `churn_ratio > PDD_TEST_CHURN_THRESHOLD` (env var, default `0.40`), raise `TestChurnError(click.UsageError)` BEFORE writing the file. +5c. **Test-Churn Gate (#1012)**: When the generated artifact is a test file (output path under the project's tests directory — `tests/`, `__tests__/`, or the SINGULAR `__test__/` (the jest/vitest/Next.js co-located test dir; #1903) — OR filename starts with `test_` OR the filename ends with a language-specific test suffix; two families: (i) **lowercase `_test.` / `_spec.` sibling-file suffixes** for every extension in `_LANGUAGE_TEST_FILE_EXTS` (a module-level tuple seeded with `.py`, `.go`, `.rb`, `.rs`, `.exs`, `.ex`, `.dart`, `.clj`, `.cljc`, `.lua`, `.php` — append new entries here when adding a language to `language_format.csv` that uses the `_test.` convention, so the gate auto-covers it without another fix round); (ii) **PascalCase JVM/.NET/Swift suffixes** — `Test.java`/`Tests.java`/`TestCase.java`/`IT.java`, `Test.kt`/`Tests.kt`/`Spec.kt`, `Test.scala`/`Tests.scala`/`Spec.scala`, `Test.groovy`/`Tests.groovy`/`Spec.groovy`, `Tests.swift`, `Test.cs`/`Tests.cs` — matched case-sensitive so `latest.kt`/`manifest.java`/`request.scala`/`latest.groovy` do not false-positive; the JS/TS `.test.` / `.spec.` (`.ts`/`.tsx`/`.js`/`.jsx`/`.mjs`/`.cjs` — the ESM/CJS `.mjs`/`.cjs` variants added for adopted co-located tests, #1903) family stays case-insensitive) AND the test file already exists with non-empty content AND the prompt diff body does NOT contain an explicit, **anchored** test rewrite marker (a real `^\s*BREAKING-CHANGE:\s*` directive line whose tail pairs an opt-out verb — `rewrite`/`rewriting`/`replace`/`replacing`/`regenerate`/`regenerating`/`overwrite`/`overwriting`/`overwritten`/`churn`/`remove`/`removing`/`drop`/`dropping` — with `test`/`tests` AS THE VERB'S DIRECT OBJECT; the parser MUST accept both imperative and gerund wording such as `BREAKING-CHANGE: rewriting tests`, and MUST reject directives where the verb governs a different noun phrase from `tests` such as `BREAKING-CHANGE: rewrite docs and update tests` (here `rewrite`'s object is `docs`, and `update` is not in the opt-out verb list — a comma, semicolon, or conjunction `and`/`but`/`then`/`or`/`plus`/`also` between the verb and `tests?` breaks the phrase). Form: scan each opt-out verb match; require `tests?` to appear after the verb with no separator between them. So `BREAKING-CHANGE: drop foo and rewrite tests` opts out (second verb `rewrite` directly governs `tests`), but `BREAKING-CHANGE: rewrite docs and update tests` does not), compute the unified-diff churn ratio between pre-sync and proposed test contents. Buried mid-line mentions of the marker (instructional prose referring to it by example) MUST NOT opt out the gate. If `churn_ratio > PDD_TEST_CHURN_THRESHOLD` (env var, default `0.40`), raise `TestChurnError(click.UsageError)` BEFORE writing the file. - The same churn policy must also be enforced by the `cmd_test_main` test-generation write path and one-session agentic sync: when agentic or native test generation is about to overwrite the canonical test output, snapshot the existing file first, reject high-churn rewrites with `TestChurnError`, and restore the pre-existing file if an agentic test generator already wrote the candidate before the gate can reject it. Append/merge mode and pure additive growth are exempt because they preserve the existing file body. - Implement helper `_compute_test_churn_ratio(pre_text: str, post_text: str) -> float` using stdlib `difflib.unified_diff`: count added (`+`) and removed (`-`) lines (excluding the `+++`/`---` headers); if no lines were removed, return `0.0`; otherwise divide `max(added, removed)` by `max(len(pre_lines), 1)`, and cap at `1.0`. Empty pre file → ratio `0.0` (first generation exempt). - - `TestChurnError` attributes: `prompt_name: str`, `output_path: str`, `churn_ratio: float`, `threshold: float`, `pre_line_count: int`, `post_line_count: int`, `total_cost: float`, `model_name: str`, `repair_directive: str` of the form: + - `TestChurnError` attributes: `prompt_name: str`, `output_path: str`, `churn_ratio: float`, `threshold: float`, `pre_line_count: int`, `post_line_count: int`, `total_cost: float`, `model_name: str`, `repair_directive: str`, and `adopted_human: bool` (issue #1903 §B.4 provenance — True only when this test was adopted from an existing HUMAN co-located test, UNPINNED, as determined by the caller at path resolution before generation; serialized into the message as an `adopted: ` line so the subprocess-boundary agentic runner can read whether the issue-driven never-block may apply. `_verify_test_churn` accepts a matching `adopted_human: bool = False` parameter and forwards it. Default False keeps the strict hard-fail). To let the parent AUTHENTICATE a churn block as genuinely PDD-emitted rather than one a hostile project test merely printed to stdout (issue #1903 §B.4 review round 8), `TestChurnError` ALSO appends a `nonce: ` line when the parent handed this child a secret provenance nonce over a NON-inherited pipe FD named by the `PDD_CHURN_NONCE_FD` env var. The child reads that FD ONCE (cached; grandchild test subprocesses do not inherit the FD under the default `close_fds`, so untrusted test code cannot learn the nonce even though it can read the env var), accepts only a plausible hex token, and omits the line when no channel is present (standalone `pdd test`/`sync`, which never never-block). The parent trusts a block's adoption/path provenance only when its nonce matches. The `repair_directive` is of the form: ``` Test churn for {prompt_name} exceeds threshold (ratio={churn_ratio:.2f}, threshold={threshold:.2f}). Regenerate by extending the existing test file rather than rewriting it. Preserve existing test function names and coverage for unrelated behavior. Add new tests for the prompt change without deleting accumulated regression tests. @@ -157,7 +164,7 @@ You are an expert Python Backend Engineer responsible for the `code_generator_ma pdd/core/cloud.py - pdd/code_generator_main.py + pdd/code_generator_main.py pdd/python_env_detector.py @@ -197,7 +204,7 @@ You are an expert Python Backend Engineer responsible for the `code_generator_ma A single Python file containing: 1. The primary `code_generator_main` function returning `(generated_code, was_incremental, total_cost, model_name)`. 2. Private helper functions for Git operations (`_run_git_command`, `is_git_repository`). -3. Private helpers for architecture and wiring (`_verify_architecture_conformance`, the `_verify_architecture_json_conformance` symbol-existence helper it delegates to, the `pdd-interface` signature helpers `_verify_pdd_interface_signatures` / `_extract_pdd_interface_signatures` / `_find_target_function`, the parameter introspection helpers `_parse_declared_param_names` / `_collect_actual_param_names` (name-only) and `_parse_declared_param_specs` / `_collect_actual_param_specs` / `_ast_args_to_specs` (name + annotation + default, used for the drift check), the public-surface helpers `_snapshot_public_surface` / `_snapshot_public_signatures` / `_diff_public_surface` / `_collect_patch_targets` / `_prompt_breaking_change_removed_symbols` / `_prompt_breaking_change_signature_symbols`, the test-churn helper `_compute_test_churn_ratio`, `_should_wire_generated_exports`, `_wire_to_parent_init`). +3. Private helpers for architecture and wiring (`_verify_architecture_conformance`, the `_verify_architecture_json_conformance` symbol-existence helper it delegates to, the `pdd-interface` signature helpers `_verify_pdd_interface_signatures` / `_extract_pdd_interface_signatures` / `_find_target_function`, the parameter introspection helpers `_parse_declared_param_names` / `_collect_actual_param_names` (name-only) and `_parse_declared_param_specs` / `_collect_actual_param_specs` / `_ast_args_to_specs` (name + annotation + default, used for the drift check), the public-surface helpers `_snapshot_public_surface` / `_snapshot_public_signatures` / `_diff_public_surface` / `_collect_patch_targets` / `_prompt_breaking_change_removed_symbols` / `_prompt_breaking_change_signature_symbols`, the deterministic annotation-reconcile helpers `_reconcile_declared_annotation_drift` (and its supporting `_index_function_defs` / `_parse_declared_def` / `_signature_slots` / `_annotation_only_edits` / `_line_start_byte_offsets` / `_node_byte_span` / `_apply_byte_edits`), the test-churn helper `_compute_test_churn_ratio`, `_should_wire_generated_exports`, `_wire_to_parent_init`). 4. Logic for handling Cloud API requests, including JWT authentication and error code handling. 5. Public exception class `ArchitectureConformanceError(click.UsageError)` colocated with `_verify_architecture_conformance`, exposing the structured attributes listed in Requirement 5 plus failed-attempt `total_cost` and `model_name`, and a `repair_directive` property suitable for injection into a regeneration prompt. The constructor MUST accept an optional `repair_directive: Optional[str] = None` keyword argument: when provided, the property returns that explicit directive (used by the `pdd-interface` signature check where the prompt — not `architecture.json` — is the source of truth); when omitted, the property returns the default `architecture.json`-oriented directive enumerating missing exports. The class MUST be importable as `from pdd.code_generator_main import ArchitectureConformanceError` so `agentic_sync_runner` can catch it without a string-match. 6. `PDD_REPAIR_DIRECTIVE` prompt-content injection, wrapped in `` tags, so retrying sync callers can force regeneration toward missing architecture exports without changing `architecture.json`. diff --git a/pdd/prompts/commands/fix_python.prompt b/pdd/prompts/commands/fix_python.prompt index e33b7d755..49277f698 100644 --- a/pdd/prompts/commands/fix_python.prompt +++ b/pdd/prompts/commands/fix_python.prompt @@ -44,7 +44,7 @@ - Manual: `--output-test`, `--output-code`, `--output-results`, `--loop`, `--verification-program`, `--max-attempts` (3), `--budget` (5.0), `--auto-submit`, `--agentic-fallback/--no-agentic-fallback` (T), `--compress-test-context`, `--context-compression` (choice), `--compression-fallback` (choice). - Both: `--protect-tests/--no-protect-tests` (F). - **Technical Details**: - - Decorate with `@log_operation(operation="fix", clears_run_report=True)` and `@track_cost`. + - Decorate with `@log_operation(operation="fix", clears_run_report=True, updates_fingerprint=True)` and `@track_cost` so a successful manual fix cannot return without refreshing the shared fingerprint transaction. Agentic issue mode has no local prompt identity and therefore leaves finalization to its workflow. - Use deferred imports inside the command for mode-specific functions. - Return `Optional[Tuple[result_dict, cost, model]]`. - Re-raise Click exceptions; use `handle_error(e, "fix", quiet)` for others. diff --git a/pdd/prompts/commands/maintenance_python.prompt b/pdd/prompts/commands/maintenance_python.prompt index 6326e5a8b..3d533d35e 100644 --- a/pdd/prompts/commands/maintenance_python.prompt +++ b/pdd/prompts/commands/maintenance_python.prompt @@ -16,6 +16,7 @@ auto_deps_main_python.prompt architecture_sync_python.prompt +fingerprint_transaction_python.prompt % You are an expert Python engineer. Your goal is to write `pdd/commands/maintenance.py`. @@ -43,6 +44,7 @@ - `--skip-tests` (`is_flag=True`, `default=False`): Skip unit test generation/fixing. - `--target-coverage` (`type=float`, `default=None`): Desired coverage percentage. Help mentions "Default: 90.0 or .pddrc value." - `--dry-run` (`is_flag=True`, `default=False`): Analyze sync state without executing operations. + - `--fresh` (`is_flag=True`, `default=False`): Disable the default surgical/edit-shaped regeneration of a mature module (#1938 Pillar A). For single-module sync, forward to `sync_main()` as `fresh`; the single-module sync path drives `code_generator_main` with `force_incremental_flag=(not fresh)`, so mature modules are edited in place by default and, when `--fresh` is passed, standard generation is used instead (which regenerates the module from scratch when the prompt change is large). `--fresh` is single-module only: when BASENAME is omitted (global sync) or is a GitHub issue URL (agentic sync), raise `click.UsageError("--fresh is only supported for single-module sync.")` BEFORE dispatch — exactly like `--snapshot-context` — rather than silently dropping the flag. Help: "Disable the default surgical (edit-shaped) regeneration for a mature module; with --fresh, sync uses standard generation, which regenerates the module from scratch when the prompt change is large. Use it when you intend a large rewrite rather than an in-place edit. Single-module sync only." - `--snapshot-context` (`is_flag=True`, `default=False`): Capture replayable expanded prompt context for generation-time prompt expansion. For single-module sync, forward to `sync_main`. For project-wide sync, forward only if the downstream global sync path supports it; otherwise raise `click.UsageError`. For GitHub issue sync, either forward explicitly through `run_agentic_sync` when supported or raise `click.UsageError` so unsupported snapshot requests are not silently ignored. - `--compressed-context/--no-compressed-context` (Click flag/no-flag syntax, `default=None`): TRI-STATE. `None` means "no CLI value supplied"; `True` and `False` are explicit user choices. Enable phase-aware compressed context for sync generation and repair phases. This is distinct from `--snapshot-context`: snapshots record replayable expanded prompt context, while compressed context supplies bounded prompt/test/example/contract evidence to generate, verify, test, and fix phases. Resolve the effective value as `compressed_context if compressed_context is not None else .pddrc defaults.compressed_context if present else False`. Forward the resolved bool to `sync_main`, `_run_global_sync_dispatch`, and `_run_agentic_sync_dispatch` when the downstream path supports it; if the user explicitly requests compressed context on a path that cannot honor it, raise a clear `click.UsageError` instead of silently ignoring it. - `--log` (`is_flag=True`, `default=False`, `hidden=True`): Deprecated. When set, emit `click.echo(click.style("Warning: --log is deprecated, use --dry-run instead.", fg="yellow"), err=True)` and then set `dry_run = True` before any dispatch. diff --git a/pdd/prompts/commands/modify_python.prompt b/pdd/prompts/commands/modify_python.prompt index 8a5855df9..2cfad627a 100644 --- a/pdd/prompts/commands/modify_python.prompt +++ b/pdd/prompts/commands/modify_python.prompt @@ -71,7 +71,7 @@ This module provides Click commands (`split`, `change`, `update`) for prompt man - 2 args: `input_prompt_file=files[0]`, `modified_code_file=files[1]` (git-based). - 3 args: `input_prompt_file=files[0]`, `modified_code_file=files[1]`, `input_code_file=files[2]`. - **`--sync-metadata`** (opt-in): When set, `update_main` runs the shared `run_metadata_sync` orchestrator after the prompt update — preserves or seeds prompt PDD tags from the architecture entry, reconciles the architecture.json entry, clears stale run reports, and finalizes the fingerprint last. Compatible with all modes (single-file, regeneration, repo). `failed` in any stage causes the command to exit non-zero (raises `click.exceptions.Exit(1)`); `skipped` is acceptable and passes through (no `architecture.json`, unregistered modules, LLM-first tag refresh pending #870 — not implemented here). Help text: "After update, run the shared metadata-sync orchestrator (preserve/seed PDD tags, reconcile architecture.json entry, clear stale run reports, finalize fingerprint last). On any stage failed, exits non-zero. Stages may report skipped for legitimate cases (no architecture.json, unregistered modules). LLM-first refresh of stale-but-present tags is tracked at #870 and is NOT invoked here." - - Calls `update_main(ctx, input_prompt_file, modified_code_file, input_code_file, output, use_git=git, repo, extensions, directory, simple, base_branch, budget, dry_run, sync_metadata=sync_metadata)` and returns its result (or `None` if it returns `None`). + - Calls `update_main(ctx, input_prompt_file, modified_code_file, input_code_file, output, use_git=git, repo, extensions, directory, simple, base_branch, budget, dry_run, sync_metadata=sync_metadata)`. Handling of a `None` return depends on the mode: in **repo-wide** mode `None` is the legitimate "everything in sync" no-op and passes through unchanged (exit 0); in **single-file** mode `update_main` returns `None` ONLY on failure (input error, git-history/provider failure, invalid output), so raise `click.exceptions.Exit(1)` rather than returning it — a bare `return None` there exits 0 and lets the Step 8.5 preflight drift-heal subprocess (which only inspects `returncode`) record a still-stale prompt as "healed". Otherwise return the result. % Error Handling - `split` and `change`: re-raise `click.Abort`, `click.UsageError`, `click.exceptions.Exit`. diff --git a/pdd/prompts/content_selector_python.prompt b/pdd/prompts/content_selector_python.prompt index 337ed1e0e..dd2c4d37a 100644 --- a/pdd/prompts/content_selector_python.prompt +++ b/pdd/prompts/content_selector_python.prompt @@ -114,6 +114,31 @@ Deterministic content extraction from files based on line ranges, Python AST str - Sibling Test Preservation: When the file path indicates a test file (starts with `test_` or ends with `_test.py`), MUST preserve the original test structure exactly (to keep the "mold walls" stable) but still strip docstrings. - If `mode="compressed"` is combined with selectors, apply compression only to the resulting extracted spans. +% Co-located Test Detection & Adoption (Runner Awareness — issue #1903) +These are standalone, PURE module-level helper functions (NOT methods of `ContentSelector`). Otherwise PDD derives a module's test-output path from `.pddrc`/defaults (`tests/test_{name}{ext}`), blind to the project's real test runner, so on a jest/vitest/Next.js project it maintains a runner-blind `tests/` shadow while the co-located test the runner actually collects (`__test__/{name}.test.tsx`) goes stale — a false-green. These helpers let `generate`/`change`/`sync` adopt a single existing co-located test as the canonical test path. Every helper below is TOTAL — it MUST NEVER raise; any error yields the safe / no-adoption result. +- `_sibling_test_paths(module_path: Path) -> list[Path]` (existing, unchanged): the Python sibling-test convention (existing same-stem `test_{stem}.py` / `{stem}_test.py`-style `.py` siblings) reused by both patch-target discovery and `find_collocated_test`. +- `discover_sibling_patch_targets(file_path) -> set[str]` (existing, unchanged): patch-target names scanned from sibling tests. Leave its behavior and the `_sibling_test_paths` / patch-target logic AS-IS. +- `_validated_project_path(untrusted_path: str | Path, *, root: Path | None = None) -> Optional[Path]`: the CWE-022 barrier used BEFORE any filesystem operation on an issue/caller-influenced path. Anchor at the trusted resolved `root` (default CWD); reduce an absolute input to a lexical path relative to that root or reject it; reject empty/`.`/`..` components; sanitize EACH remaining component through `os.path.basename` and require equality (so sanitization never silently changes the requested path); only then join to the trusted root, resolve symlinks, and require canonical containment. Return the resolved in-root path or `None`; never raise. Do not call `resolve()`/`is_file()` on the raw caller path and then check containment afterward — that is too late for CWE-022 and CodeQL `py/path-injection`. +- `find_collocated_test(code_file: str | Path) -> Optional[Path]`: first pass `code_file` through `_validated_project_path`; an outside/traversal/symlink-escaping module returns `None` before candidate discovery. Return the SINGLE existing co-located test for the validated module, else `None`. Gate on the module's OWN suffix (case-insensitive): + - JS/TS suffixes `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`: candidates are `{stem}.test.` and `{stem}.spec.` — for EVERY one of those 6 extensions and both `.test`/`.spec` infixes — BESIDE the module and under sibling subdirs `__test__/`, `__tests__/`, and `tests/` (the conventions jest, vitest and Next.js collect). + - Python suffixes `.py`, `.pyi`: reuse `_sibling_test_paths(module_path)` as the candidate list. + - Any OTHER suffix: return `None` (no candidates) — a safe no-op so a non-Python, non-JS/TS module (e.g. `src/foo.go`) is never retargeted onto a same-stem `.py`/`.test.*` sibling and its generation redirected into the wrong-language file. + Pass every candidate through `_validated_project_path` BEFORE `is_file()`. Consider ONLY existing validated files; EXCLUDE the module file itself; DE-DUP by canonical path. Return the match ONLY when EXACTLY ONE distinct test file exists — 0 matches OR >1 match both yield `None`, so an ambiguous project is never silently retargeted. A bad candidate (including a symlink loop/escape or malformed path) is skipped while the rest are evaluated. Never raises: any unexpected error yields `None`. +- `find_runner_collected_test_path(code_file: str | Path) -> Optional[Path]` — greenfield runner-aware FIRST-test placement (issue #1903 §A). The ONLY public greenfield entry; the discovery/config-parsing/matching internals are implementation detail (the model's choice, pinned by accumulated tests — not prescribed here). Observable contract: + - Fires ONLY for an in-project JS/TS module (validated through `_validated_project_path`, CWE-022) that has NO existing co-located test yet and HAS a jest/vitest runner configured at or above it. A Python or non-JS/TS module, or no detectable runner, yields `None` (keep the derived default — do NOT redirect Python's pytest-idiomatic `tests/`). + - MUST NEVER return an UNCOLLECTED path (that would just relocate the false-green). It returns a path ONLY when the runner PROVABLY collects it; when collection cannot be proven it returns `None` (fall back to the derived path). In particular it REFUSES for: an UNPARSEABLE JS config unless its text is provably a SINGLE plain-object-literal export with no discovery keys and no dynamic/composition constructs (a positive WHITELIST — not a token blacklist — so a second-statement mutation like `module.exports = {}; module.exports['test'+'Match'] = […]` is refused); MULTIPLE runner-config sources at the same directory level (e.g. an inline `package.json` jest block AND a `vitest.config.ts` — the active runner is ambiguous, so fail closed); a discovery shape it does not resolve (jest `projects`, vitest `include`/`exclude`/`workspace`); or a module extension the DEFAULT discovery does not collect. For `.mjs`/`.cjs` this is VERSION-aware: vitest's default `include` and jest **30+**'s default `testMatch` collect them, but jest **≤29** (and an UNKNOWN/undeclared jest version) do not — so an mjs/cjs module is co-located only for vitest or a package.json-declared jest≥30, and refused otherwise. When explicit `testMatch`/`testRegex` are present, ordered include/exclude decides instead: an explicit-empty set or a both-configured (testMatch AND testRegex) config matches nothing → refuse; bare `roots` resolve against the effective `rootDir`; and every repo-controlled pattern is matched under a strict per-match timeout AND an aggregate pattern-count cap so a hostile/catastrophic config fails closed rather than stalling sync. + - When the collection rules ARE resolvable it honors them: `roots`/`rootDir` containment, `testPathIgnorePatterns` exclusion, and `testMatch`/`testRegex` with jest's ORDERED include/exclude semantics (later patterns override earlier ones; a leading-`!` glob excludes; a negated character class `[!x]` means "not x"). For a CENTRALIZED layout (collection only under a configured directory) it derives a path UNDER that directory, preserving the module's relative sub-path so DISTINCT same-stem modules NEVER collapse onto one file (never fork, never overwrite). + - CO-LOCATED returns use a convention `find_collocated_test` recognizes, so the next sync re-detects and ADOPTS the file. A CENTRALIZED return (under a configured `roots`/`testMatch` anchor) is NOT re-detected by `find_collocated_test` (which searches only beside the module and its sibling test dirs); it is instead RE-DERIVED deterministically to the same path on the next sync (idempotent placement) and its PDD ownership is tracked via the greenfield-ownership manifest, so it is never re-created or misclassified as a human-adopted test. TOTAL — any error yields `None`. +- `resolve_test_output_path(code_file: str | Path, derived_test_path: str | Path, *, user_pinned: bool) -> Path` — the adopter. Returns the derived path UNCHANGED when the location is `user_pinned`. Otherwise, keyed on the CARDINALITY of existing co-located tests: EXACTLY ONE existing sibling → adopt it (return it) — BUT only when the project's runner actually COLLECTS that sibling: when a PARSEABLE JS/TS runner config PROVABLY EXCLUDES it (a custom `testMatch`/`roots` the sibling does not match), do NOT adopt the excluded file (that would just relocate the false-green); instead redirect to `find_runner_collected_test_path` (the runner-collected location) or, when none can be proven, the derived default. An OPAQUE/unparseable config cannot prove exclusion, so the sibling is adopted (conservative status quo). MORE THAN ONE (ambiguous) → return the derived path (never fork a third test — adoption never fires when >1 exists, and an ambiguous project is NOT greenfield); ZERO → greenfield: consult `find_runner_collected_test_path` and return its runner-collected path when one is offered, else the derived path — so the FIRST test lands where the runner collects it instead of a runner-blind `tests/` shadow. All caller/issue-influenced paths pass the `_validated_project_path` containment barrier before use. Never raises (on any error return the derived path). +- `was_test_adopted(code_file, resolved_test_path, derived_test_path, *, user_pinned: bool) -> bool` — the structured ADOPTION-provenance predicate the issue #1903 §B.4 never-block requires. True ONLY when ALL hold: NOT `user_pinned`; a single existing co-located sibling was present at resolution time (so a greenfield first-test, which finds none, is False); the resolved path differs from the derived default (adoption genuinely happened); the resolved path IS that existing sibling (NOT a greenfield/runner-collected redirect away from a sibling the runner excludes — a redirect target is a fresh PDD-created file, not a human test); AND the resolved path is NOT recorded as a PDD greenfield-created test (`is_pdd_created_test` — a test PDD created itself on a prior run is PDD-owned, never "human-adopted"). MUST be evaluated at path-resolution time (before generation overwrites the file, after which greenfield-created and human-adopted tests are indistinguishable by presence). The test-generation callers pass this into the churn gate so it stamps `adopted:` on any `TestChurnError`. Total — any error yields False (conservative). +- `record_pdd_created_test(test_path)` / `is_pdd_created_test(test_path) -> bool` — persist and query PDD's greenfield ownership in a project manifest. The CANONICAL manifest is `.pdd/meta/pdd_created_tests.json` — PDD's TRACKED sync-metadata directory (committed with the per-module fingerprints) so ownership survives a fresh checkout or the durable runner's fresh module worktree; the legacy top-level `.pdd/pdd_created_tests.json` (routinely gitignored) is read ONLY as a fallback and merged forward on write. Repo-relative POSIX paths. Because parallel agentic-sync CHILD PROCESSES can record concurrently, `record_pdd_created_test` performs its read-modify-write under an INTERPROCESS EXCLUSIVE file lock and commits via a temp file + atomic `os.replace` (an unlocked RMW would drop a record when two children clobber each other). Test-generation callers call it ONLY when PDD GREENFIELD-creates a brand-new co-located test (no pre-existing human file, unpinned, a co-located shape), so a later run never mistakes that PDD-owned file for a human-adopted one. Both total; any error swallowed. +- `_pins_test_output_location(config: Mapping[str, Any]) -> bool`: presence-based (NOT truthiness) explicit-vs-default provenance. Return True when `config.get("test_output_path") is not None`, OR when the Issue #237 template form `config["outputs"]["test"]["path"] is not None` (guard each level with `isinstance(..., Mapping)`). An explicitly configured `""` (root-level test output) STILL counts as a pin; only a genuinely ABSENT key (or an explicit null) means "default derivation" and is eligible for adoption. It MUST be fed a RAW `.pddrc` context `defaults` block — NEVER `construct_paths`' `resolved_config`, which has a generated-default `test_output_path` injected back into it and would therefore ALWAYS read as pinned. Never raises. +- `configured_test_output_pinned(target_file: str | Path, *, context_override: Optional[str] = None, search_from: Optional[str | Path] = None) -> bool`: True when the test-output location is user-pinned for `target_file`. Return True immediately when the `PDD_TEST_OUTPUT_PATH` env var is PRESENT — `os.environ.get("PDD_TEST_OUTPUT_PATH") is not None`, even if empty (presence NOT truthiness, consistent with the `.pddrc test_output_path: ""` root-level pin; only a genuinely UNSET env var is default-eligible). Otherwise resolve the owning `.pddrc` context the way `construct_paths` does and delegate to `_pins_test_output_location` on that context's RAW `defaults`: + - Locate the nearest `.pddrc` starting from `search_from` (default: `target_file`'s parent) via `construct_paths._find_pddrc_file`; if none found, return False. + - Load it via `construct_paths._load_pddrc_config`. Choose the context: an explicit `context_override` wins; else path-detect the context that OWNS `target_file` via `construct_paths.detect_context_for_file(str(target_file), repo_root=<.pddrc's dir>)`; else fall back to the `default` context when one exists in `contexts`. + - Read that context's `defaults` block and return `_pins_test_output_location(defaults)`. + Import the `construct_paths` helpers (`_find_pddrc_file`, `_load_pddrc_config`, `detect_context_for_file`) LAZILY inside the function to avoid an import cycle at module load. `search_from` lets a caller (e.g. `sync` passing its `prompts_root`) anchor the `.pddrc` lookup so a nested subproject finds its OWN `.pddrc`. `target_file` is the file whose owning context is inspected — the PROMPT file for sync/change derivation (its `prompts_dir` selects the context), the CODE file for `pdd test`. Never raises (on any error return False). + % Error Reporting - Obtain the module-level Rich console from the central PDD color system via `from .cli_theme import get_console` and `console = get_console()`, rather than constructing a `Console` with an inline theme. This keeps error styling (`info`, `warning`, `error`, `success`, `path`, `selector`) consistent with the rest of the CLI. Print error context (including file path when available) before raising `SelectorError`. diff --git a/pdd/prompts/core/cli_python.prompt b/pdd/prompts/core/cli_python.prompt index 1f88ef7a4..e26efae22 100644 --- a/pdd/prompts/core/cli_python.prompt +++ b/pdd/prompts/core/cli_python.prompt @@ -48,7 +48,7 @@ 3. **Main Entry (`cli`)**: - Global Options: `--force`, `--strength`, `--temperature`, `--time` (default `DEFAULT_TIME`), `--verbose`, `--quiet`, `--color/--no-color`, `--output-cost`, `--estimate`, `--dry-run-cost` (alias for `--estimate`), `--estimate-json`, `--review-examples`, `--local`, `--context` (override), `--list-contexts`, `--core-dump` (default True), `--keep-core-dumps` (default 10), `--compress-examples` (bool), `--compress-test-context` (bool), `--context-compression` (choice: off, test, examples, contracts, all), `--compression-fallback` (choice: full, error). `--estimate-json` implies estimate mode and machine-readable output. - - Initialization: Call `get_local_pdd_path()`, `clear_core_dump_errors()`, and `garbage_collect_core_dumps(keep=keep_core_dumps)`. + - Initialization: Call `get_local_pdd_path()`, `clear_core_dump_errors()`, and `garbage_collect_core_dumps(keep=keep_core_dumps)`. Enter `agentic_common.provider_failure_scope()` once for the whole Click invocation and register its exit with `ctx.call_on_close`, so all agentic steps in this logical run share permanent-provider failures while repeated in-process CLI invocations start fresh health epochs. - Propagate flags to `ctx.obj` and env vars (`PDD_FORCE`, `PDD_FORCE_LOCAL`, `PDD_QUIET`, `PDD_ESTIMATE`, `PDD_ESTIMATE_JSON`, `PDD_COMPRESS_EXAMPLES`, `PDD_COMPRESS_TEST_CONTEXT`, `PDD_CONTEXT_COMPRESSION`, `PDD_COMPRESSION_FALLBACK`). Store the tri-state color preference in `ctx.obj["color"]`, apply it immediately with `apply_color_preference(color)`, and register the returned restore callback with `ctx.call_on_close` so `--color/--no-color` applies during the run while preserving auto-detection when omitted. `ctx.obj["estimate"]` is true when either `--estimate`/`--dry-run-cost`, `--estimate-json`, or `PDD_ESTIMATE=1` is supplied; `ctx.obj["estimate_json"]` is true only for `--estimate-json`; initialize `ctx.obj["estimate_results"] = []` and alias `ctx.obj["estimate_records"]` to that list for downstream `llm_invoke` accumulation. Estimate mode forces local execution (`ctx.obj["local"] = True`, `PDD_FORCE_LOCAL=1`) so cloud/auth/provider setup is not contacted. `ctx.obj["time"]` is always populated (the `DEFAULT_TIME` fallback), so additionally set `ctx.obj["time_explicit"] = (time is not None)` so callers that want to forward an explicit per-call reasoning override can gate on it (otherwise plain `pdd bug ...` would behave as if `--time DEFAULT_TIME` was passed and force-set provider effort). When `--time` is explicitly provided, also export `PDD_REASONING_EFFORT={low,medium,high}` using `reasoning.time_to_effort_level` so agentic provider subprocesses (Codex/Claude/Gemini/Antigravity via `agentic_common._run_with_provider`) honor reasoning effort. When `--time` is omitted, leave `PDD_REASONING_EFFORT` untouched so values pre-set by a worker `env.yaml` (e.g. `CODEX_REASONING_EFFORT=xhigh` for GPT-5.4 Codex routing) keep applying. - Estimate Mode Cost Suppression: When estimate mode is active, suppress actual-run cost CSV writes by setting `ctx.obj["output_cost"] = None` and removing/ignoring `PDD_OUTPUT_COST_PATH` for this process even if `--output-cost` or the environment variable is present. Estimate mode reports preview data only; it must not append cost rows. - Estimate Scope: This first version supports estimate mode for the standard `generate` subcommand only. If `--estimate`, `--dry-run-cost`, `--estimate-json`, or `PDD_ESTIMATE=1` is used with any other subcommand, raise `click.UsageError("Estimate mode currently supports `generate` only.")` before dispatch. @@ -56,7 +56,7 @@ - When `--review-examples` is supplied, set `ctx.obj["review_examples"] = True` AND initialize `ctx.obj["grounding_review_decisions"] = []` so downstream grounding consumers can append per-module review decisions (`{"module": str, "decision": "accept"|"reject", "reason": Optional[str]}`) for the evidence-manifest writer to record under `generation.grounding.reviewed`. When `--review-examples` is not supplied, do NOT pre-create the key — the manifest writer treats its absence as "no review observed" and records `reviewed: false`. - Only set `strength`/`temperature` in `ctx.obj` if explicitly provided. - Early Actions: Handle `--list-contexts` and `--context` validation (against `list_available_contexts()`). Run `auto_update()` unless `PDD_AUTO_UPDATE=false`. - - Show onboarding reminder via `_should_show_onboarding_reminder`. + - Show onboarding reminder via `_should_show_onboarding_reminder` for human-output invocations only; suppress it for machine JSON mode so stdout remains one parseable JSON document. - Output Capture: If `--core-dump` enabled, wrap `sys.stdout/stderr` with `OutputCapture` and store in `ctx.obj`. 4. **Result Callback (`process_commands`)**: diff --git a/pdd/prompts/durable_sync_runner_python.prompt b/pdd/prompts/durable_sync_runner_python.prompt index 1a9534d53..06164869a 100644 --- a/pdd/prompts/durable_sync_runner_python.prompt +++ b/pdd/prompts/durable_sync_runner_python.prompt @@ -24,7 +24,7 @@ Durable execution engine for `pdd sync --durable`. It must pr 2. Prepare a durable branch named `sync/issue-` by default, unless a safe `durable_branch` override is supplied. 3. Reject unsafe branches: `main`, `master`, the repository default branch, missing/broken `origin`, non-git directories, and durable branches checked out in another worktree. 4. Use `.pdd/worktrees/durable-issue-` as the main durable worktree and `.pdd/worktrees/sync-issue--` for per-module worktrees. -5. Resume by scanning pushed checkpoint commits on the durable branch for trailers formatted as `PDD-Sync-Checkpoint-V1: issue= module=`. Ignore trailers for other issues. +5. Resume by scanning pushed checkpoint commits on the durable branch for trailers formatted as `PDD-Sync-Checkpoint-V1: issue= module=`. Ignore trailers for other issues. A module that was flagged "needs review" (issue #1903 §B.4 adopted-test never-block) MUST carry that note through the checkpoint trailer (whitespace-free encoded, e.g. base64url) and be RESTORED onto the resumed module state — since durable resume rebuilds state from trailers alone, a dropped note would let a resumed run report a clean success and silently swallow the coverage loss the flag exists to surface. A malformed/garbage review token degrades to no-note, never a crash. The newest trailer per module wins. 6. Do not rely on `.pdd/agentic_sync_state.json` for durable resume. Corrupt or missing local state must not prevent resuming from remote checkpoint trailers. 7. For each successful module, create a checkpoint commit containing only safe, relevant project files and allowed `.pdd/meta/_*.json` metadata. Push the checkpoint before printing `PDD_CHECKPOINT:`. 8. If a module succeeds with no file diff, create an empty checkpoint commit so resume can still skip it later. diff --git a/pdd/prompts/evidence_manifest_python.prompt b/pdd/prompts/evidence_manifest_python.prompt index ed18143e6..3eb764223 100644 --- a/pdd/prompts/evidence_manifest_python.prompt +++ b/pdd/prompts/evidence_manifest_python.prompt @@ -7,7 +7,8 @@ "functions": [ {"name": "write_evidence_manifest", "signature": "(*, command: str, prompt_file: Optional[str | Path] = None, output_files: Iterable[str | Path] = (), model: str = \"\", cost_usd: float = 0.0, temperature: Optional[float] = None, validation: Optional[Mapping[str, str]] = None, logs: Optional[Mapping[str, Optional[str]]] = None, basename: Optional[str] = None, project_root: Optional[str | Path] = None, context_snapshot: Optional[Mapping[str, Any]] = None, grounding: Optional[Dict[str, Any]] = None, reviewed: bool = False, compression: Optional[Mapping[str, Any]] = None, agentic_fallback: Optional[Mapping[str, Any]] = None, compress: bool = False) -> Path", "returns": "Path"}, {"name": "validation_from_sync", "signature": "(sync_result, *, skip_tests, skip_verify, dry_run=False)", "returns": "Mapping[str, str]"}, - {"name": "resolve_generate_output_paths", "signature": "(prompt_file, *, output=None, ...) -> list[str]", "returns": "list[str]"} + {"name": "resolve_generate_output_paths", "signature": "(prompt_file, *, output=None, ...) -> list[str]", "returns": "list[str]"}, + {"name": "resolve_test_output_paths", "signature": "(prompt_file, code_file, *, output=None, ...) -> list[str]", "returns": "list[str]"} ] } } @@ -41,6 +42,8 @@ - Accept optional `compression` and `agentic_fallback` kwargs. Emit backward-compatible `generation.compression` and `generation.agentic_fallback` objects when supplied. `generation.compression` should include enabled/requested/used, phases, source counts, source hashes, compressed hash, budget/size estimates, unavailable reason, and mode. `generation.agentic_fallback` should include attempted/used, phases, reason, and provider/tool metadata when available. Sanitize these metadata blocks before writing by dropping raw prompt/context payload keys such as `content`, `context`, `prompt`, `rendered`, `raw_context`, `compressed_context`, and `compressed_content`; never persist raw compressed context content in the manifest. - Bump the manifest `schema_version` metadata from `1` to `2` to reflect the additive `generation.grounding` object; update `pdd/schemas/evidence_manifest.schema.json` so the new object is documented and required at schema v2, while still tolerating the legacy `grounding_examples` field. Update the schema and `docs/evidence_manifest.md` to document optional `generation.compression` and `generation.agentic_fallback` metadata alongside grounding. +- Provide `resolve_generate_output_paths(...)` and `resolve_test_output_paths(prompt_file, code_file, *, output=None, language=None, context_override=None, force=True, quiet=True) -> list[str]` that resolve a run's output paths via `construct_paths` for the evidence manifest. `resolve_test_output_paths` MUST resolve the test path the SAME way `cmd_test_main` does — including the Issue #1903 co-located-test adoption: after `construct_paths`, when the path is PDD's derived default (NOT user-pinned via explicit `output`, `PDD_TEST_OUTPUT_PATH`, or an explicit `.pddrc test_output_path`/`outputs.test.path` for the module's prompt-side context, honoring `context_override`), adopt the single existing co-located test via `content_selector.resolve_test_output_path` (pin detection via `content_selector.configured_test_output_pinned(prompt_file or code_file, context_override=context_override, search_from=.parent)`, lazily imported). This records the file PDD actually writes/runs — not the runner-blind derived shadow — so evidence/replay audits point at the right artifact. An explicit pin is always honored (no adoption). + % Deliverables - Code: `pdd/evidence_manifest.py` - Schema: `pdd/schemas/evidence_manifest.schema.json` diff --git a/pdd/prompts/fingerprint_transaction_python.prompt b/pdd/prompts/fingerprint_transaction_python.prompt new file mode 100644 index 000000000..efba6d6d9 --- /dev/null +++ b/pdd/prompts/fingerprint_transaction_python.prompt @@ -0,0 +1,93 @@ +context/python_preamble.prompt + +Single enforced code path for complete, atomic PDD fingerprint finalization with commit-or-fail semantics. + + +{ + "type": "module", + "module": { + "functions": [ + {"name": "FingerprintTransaction.__init__", "signature": "(basename: str, language: str, operation: str, paths: Optional[Dict[str, Path]] = None, cost: float = 0.0, model: str = '', *, atomic_state: Optional[Any] = None) -> None", "returns": "None"}, + {"name": "FingerprintTransaction.__enter__", "signature": "() -> FingerprintTransaction", "returns": "FingerprintTransaction"}, + {"name": "FingerprintTransaction.__exit__", "signature": "(exc_type, exc_val, exc_tb) -> bool", "returns": "bool"}, + {"name": "FingerprintTransaction.skip", "signature": "(reason: str) -> None", "returns": "None"}, + {"name": "FingerprintTransaction.set_include_deps_override", "signature": "(deps: Dict[str, str]) -> None", "returns": "None"} + ] + } +} + + +sync_determine_operation_python.prompt + +% Goal +Write `pdd/fingerprint_transaction.py`, the authoritative builder and commit boundary for every PDD fingerprint write. All mutating workflows delegate here, and none may report success after finalization fails. + +% Role & Scope +This module resolves the fingerprint destination once, computes the canonical payload, rejects incomplete hashes, and either writes the JSON with the shared crash-safe atomic writer or buffers the identical payload in sync's outer `AtomicStateUpdate`. It does not invoke LLMs, mutate artifacts, clear run reports, or decide workflow operations. + + +- Immediate commit: write the validated payload with `json_atomic.atomic_write_json`, whose same-directory temp file, flush, `fsync`, and `os.replace` prevent a partial JSON destination. +- Buffered commit: pass the validated payload and eagerly resolved destination to an outer atomic-state object's `set_fingerprint`; the outer context performs the write before its command can return success. +- Intentional skip: `skip(reason)` suppresses finalization for a known non-mutating path such as dry-run or redirected output. +- Complete existing input: prompt is always required; code, example, primary test, and each `test_files` entry require a non-null hash whenever the supplied path exists. + + + +R1 (MUST): On a clean body exit without `skip()`, build and validate exactly one fingerprint payload, then commit or buffer it. Any failure raises `FingerprintFinalizeError` and propagates. +R2 (MUST): On a body exception, return `False` without building or writing a fingerprint so the original exception propagates. +R3 (MUST): Reject a null `prompt_hash`. Also reject a null code/example/test hash for every corresponding supplied path that exists, and reject a missing entry for every existing supplied `test_files` path. +R4 (MUST): Resolve `fingerprint_path` eagerly in `__init__` from the same normalized path mapping later used for reading and hashing. Explicit paths are authoritative; call `get_pdd_file_paths` only when no non-empty `paths` mapping was supplied (issue #983). +R5 (MUST): Immediate commits use `atomic_write_json`; do not duplicate temp-file JSON persistence in this module or any caller. +R6 (MUST): If `atomic_state` is supplied, require a callable `set_fingerprint(payload, path, operation=...)` and buffer the same payload an immediate commit would write. Do not write the destination early. +R7 (MUST): `skip(reason)` is idempotent, logs the reason at INFO, and makes clean exit a no-op. A skipped transaction never validates hashes or raises a finalization error. +R8 (MUST): `FingerprintFinalizeError` includes the operation, resolved fingerprint path, and underlying cause. Path-discovery errors use the deterministic fallback path `.pdd/meta/_.json` in diagnostics. +R9 (MUST): An include-dependency override controls both hash calculation (`stored_include_deps`) and the `include_deps` value persisted. This preserves the pre-mutation include graph used by auto-deps/sync. + + +% Requirements + +1. Define `FingerprintFinalizeError(RuntimeError)` with public attributes `operation`, `fingerprint_path`, and `cause`. Format its message as `"[{operation}] fingerprint finalization failed for {fingerprint_path}: {cause}"`. + +2. In `FingerprintTransaction.__init__`: + - Lowercase `language`; store operation, numeric cost, model, optional duck-typed `atomic_state`, skip state, and include override state. + - When `paths` is non-empty, normalize its values without changing scope: coerce ordinary values to `Path`, preserve `None`, and normalize `test_files` to a list of `Path` values. Never replace explicit paths with CWD discovery. + - Otherwise resolve paths via `get_pdd_file_paths(basename, language)` and normalize the result. + - Resolve and store `get_fingerprint_path(basename, language, paths=normalized_paths)` immediately. Wrap discovery/destination errors as `FingerprintFinalizeError` with `path resolution failed: ...` as the cause. + - Expose the resolved destination through a read-only `fingerprint_path` property. + +3. `__enter__` returns `self`. `skip(reason)` records/logs an intentional skip. `set_include_deps_override(deps)` copies the mapping so callers cannot mutate transaction state afterward. + +4. Payload construction on clean exit: + - Read the previous fingerprint with the normalized paths. + - Select `stored_deps`: the explicit override when set, otherwise the previous fingerprint's `include_deps`, otherwise `None`. + - Call `calculate_current_hashes(paths, stored_include_deps=stored_deps)` exactly once. + - Apply R3 validation before constructing or writing anything. + - Build the existing `Fingerprint` dataclass with `pdd_version=__version__`, a UTC ISO timestamp, `command=operation`, all current artifact hashes/test-files hashes, and include deps from the override when present or the calculated result otherwise. Serialize with `dataclasses.asdict`. + +5. Commit the payload: + - With `atomic_state`, look up `set_fingerprint`; a missing/non-callable setter is a typed finalization failure. Call it with `(payload, fingerprint_path, operation=operation)`. + - Without `atomic_state`, call `atomic_write_json(fingerprint_path, payload)`. + - Re-raise an existing `FingerprintFinalizeError` unchanged. Wrap every other build, buffer, or atomic-write exception as `FingerprintFinalizeError` using exception chaining. + +6. `__exit__` always returns `False`. It first propagates body exceptions, then honors skip, and only then commits. Log skipped exits at DEBUG. + +7. Document that `os.replace` is atomic on POSIX for same-filesystem paths and has weaker replacement guarantees on Windows. The shared writer creates its temp file in the destination directory. + +% Dependencies + + pdd/json_atomic.py + + + pdd/operation_log.py + + + pdd/sync_determine_operation.py + + + + context/fingerprint_transaction_example.py + + +% Deliverables +- Code: `pdd/fingerprint_transaction.py` +- Public API: `FingerprintTransaction`, `FingerprintFinalizeError` diff --git a/pdd/prompts/fix_main_python.prompt b/pdd/prompts/fix_main_python.prompt index d09b2943b..ce8bac849 100644 --- a/pdd/prompts/fix_main_python.prompt +++ b/pdd/prompts/fix_main_python.prompt @@ -17,6 +17,7 @@ auth_service_python.prompt core/cloud_python.prompt compressed_sync_context_python.prompt +mock_contract_validation_python.prompt % You are an expert Python engineer. Write `fix_main`, the CLI wrapper for the PDD `fix` command. It orchestrates single-pass fixes via `fix_errors_from_unit_tests`, iterative fixes via `fix_error_loop`, optional cloud execution, and optional auto-submit of successful fixes. @@ -42,6 +43,9 @@ context/change/15/initial_cli.py + + pdd/mock_contract_validation.py + % Inputs - `ctx` (`click.Context`) @@ -114,7 +118,13 @@ Return `Tuple[bool, str, str, int, float, str]`: - Do not trust `updateUnitTest` / `updateCode` alone; if either file changed, validate with `run_pytest_on_file` using temp files. If nothing changed, `success=False`. - Focused-repair path (large dev units): if `is_large(code_content, unit_test_content)`, call `prepare_focused_inputs` to obtain sliced failing-test and code subsets. Pass the slices as `unit_test` / `code` to the LLM call. After the call, reconstruct the full code with `reconstruct_code(code_content, fixed_code, focused_inputs.slices)`. Never use the returned sliced test content as the final test file; validate the fixed code against the full original test file with `run_pytest_on_file`. Always pass `protect_tests=True` (or equivalent `effective_protect_tests = protect_tests or bool(focused_inputs)`) to the LLM when focused slices are in use, so the model cannot return a test-only fix that would be silently discarded. -7. Saving and reporting: +7. Mock-contract success gate (Issue #1939): + - After candidate tests pass but before writing either output, call `validate_mock_contracts` with the prospective fixed code/test and the original code/test as baselines. + - The comparison MUST use repository schema/sibling evidence; never reject a field from its spelling alone. + - Call `enforce_mock_contracts`. A divergence raises `MockContractDivergenceError`, is re-raised unchanged by `fix_main`, prevents output persistence, and makes the Click command exit non-zero. Because `fix_error_loop` tests candidates in place, loop mode MUST restore the exact original code and test contents before re-raising a divergence. + - Surface an `inconclusive` report as a visible yellow warning without converting absent evidence into a false positive. + +8. Saving and reporting: - Write only non-empty outputs. - Never write the fixed test file when `protect_tests=True`; optionally log that skip in verbose mode. - In focused-repair mode, the LLM only sees a test slice, so the returned test content is always a partial subset; never write it back as the final test file — always use the original full test content for the saved test output. @@ -123,7 +133,7 @@ Return `Tuple[bool, str, str, int, float, str]`: - Only print the test/code output path when that file was actually produced. - In verbose mode, print only a short analysis preview and escape Rich markup safely when preview text is invalid. -8. Auto-submit: +9. Auto-submit: - Run only when `success` is true, `auto_submit` is true, `PDD_FORCE_LOCAL` is not enabled, and `CloudConfig.is_running_in_cloud()` is False. When skipped for the cloud reason, emit a yellow `Skipping example submission: cloud executor has no interactive auth path.` (subject to `quiet`). - Before calling the lower-level async `get_jwt_token(...)`, call `CloudConfig.ensure_default_env()` so `PDD_CLOUD_URL` infers staging/prod/local before cached JWT audience validation. This prevents a prod cached JWT from being reused against a staging `submitExample` URL (#859 follow-up). - Get a JWT with `asyncio.run(asyncio.wait_for(get_jwt_token(...), timeout=auth_timeout_s))` where `auth_timeout_s` is `float(os.environ.get('PDD_AUTO_SUBMIT_AUTH_TIMEOUT_S', '300'))` parsed defensively (`try/except (TypeError, ValueError)` falls back to `300.0`). On `asyncio.TimeoutError` / `TimeoutError`, emit a yellow warning (subject to `quiet`) and skip submission; do not propagate as a fix failure. @@ -133,11 +143,11 @@ Return `Tuple[bool, str, str, int, float, str]`: - Metadata should include a title/description, language from `get_language(os.path.splitext(code_file)[1])`, empty framework, tags `["auto-fix", "example"]`, `isPublic=True`, and `price=0.0`. - Submission failures should be reported but must not change the main fix result. -8a. Compression and fallback metadata: +9a. Compression and fallback metadata: - Expose whether agentic fallback was invoked or used so sync/evidence can record it. - Preserve `protect_tests`, focused repair, cloud-only fallback, and direct CLI behavior when no compressed context is supplied. -9. Error handling: - - Re-raise `click.Abort` and `click.UsageError`. +10. Error handling: + - Re-raise `click.Abort`, `click.UsageError`, and `MockContractDivergenceError`. - For all other exceptions, print Rich-safe error messages; handle `MarkupError` explicitly by escaping its message. - Return `(False, "", "", 0, 0.0, f"Error: {e}")` instead of exiting the process. diff --git a/pdd/prompts/metadata_sync_python.prompt b/pdd/prompts/metadata_sync_python.prompt index 69ca4b216..be0f478c6 100644 --- a/pdd/prompts/metadata_sync_python.prompt +++ b/pdd/prompts/metadata_sync_python.prompt @@ -34,7 +34,7 @@ This module provides the shared `run_metadata_sync` entry point used by `pdd upd 2. `tags`: **preserve existing PDD metadata tags, or seed tags from the architecture entry when the prompt has none.** Uses `pdd.architecture_sync.generate_tags_from_architecture` (multi-language compatible, no language-specific AST dependency) to render the seed block. Writes the seeded prompt back to disk unless `dry_run=True`. When the prompt has zero PDD tags AND no architecture entry exists to seed from, report `skipped` with a reason naming #870 — never `ok` (claiming `ok with 0 tag(s)` would mislead operators). **LLM-first tag refresh (rewriting existing-but-stale tags) is out of scope for this orchestrator and tracked at #870**; downstream callers that need refresh should invoke that primitive directly. 3. `architecture`: call `update_architecture_from_prompt(prompt_filename, prompts_dir, architecture_path, dry_run, prompt_content_override=)` so architecture.json picks up the freshly-generated tags without a disk race. `prompt_filename` MUST be the prompts-dir-relative path (e.g. `commands/foo_python.prompt`), not just the basename — `update_architecture_from_prompt` matches architecture entries by their `filename` field which is path-aware per Issue #617. Fall back to the basename only when the prompt sits outside any `prompts/` ancestor. When the architecture lookup returns "No architecture entry found for: …", treat that as `skipped` (unregistered module is a normal state for tools/scripts), NOT as `failed`, so the fingerprint stage is not gated off. **Gated symmetrically with `run_report` and `fingerprint`**: if `prompt` or `tags` reported `failed`, mark `architecture` as `skipped` with the failing stage as the reason and do NOT invoke `update_architecture_from_prompt` — that call writes architecture.json on success, and running it after an earlier hard failure means the architecture entry can be modified on disk before the overall result reports `failed`, which on the push-to-main auto-heal path can land via the heal-commit's scoped `git add -u` even though downstream stages correctly stop. 4. `run_report`: clear or refresh stale run reports tied to this prompt/code pair (only the entries this sync invalidates — do not wipe unrelated entries). Build a `paths` hint from the known file paths — `{"prompt": prompt_path}` plus `"code": code_path` when `code_path` is set — and pass it as `paths=` to `clear_run_report` so that `.pdd/meta` is anchored at the subproject root rather than the run CWD (supports parent-directory workflows, issue #1211). **Gated symmetrically with `fingerprint`**: if `prompt`, `tags`, or `architecture` reported `failed`, mark `run_report` as `skipped` with the failing stage as the reason and do NOT invoke `clear_run_report` — the deletion is an on-disk side effect that, left to run after an earlier hard failure, would create partial `.pdd/meta` state that auto-heal's scoped staging (`git add -u` plus per-module fingerprint pathspecs) could still pick up on the push-to-main path. - 5. `fingerprint`: finalize fingerprint state LAST via the fingerprint primitives from `pdd.sync_determine_operation` / `pdd.operation_log`. Pass the same `paths` hint (built from `prompt_path`/`code_path`) to `read_fingerprint` and `save_fingerprint` so fingerprint reads and writes anchor at the subproject `.pdd/meta` directory (issue #1211). Gated on no prior stage reporting `failed` — `skipped` and `dry_run` are acceptable upstream statuses (matches `MetadataSyncResult.ok`). If any earlier stage failed, mark fingerprint as `skipped` with the failing stage as the reason. This prevents storing a fingerprint that records "in sync" when an earlier metadata layer is broken. + 5. `fingerprint`: finalize fingerprint state LAST through `operation_log.save_fingerprint`, the thin shared-transaction wrapper. Start with the known prompt/code hint, then call `resolve_fingerprint_paths` so untouched existing example/test hashes are retained while explicit paths remain authoritative. Use this complete set for both `read_fingerprint` and the save, anchoring all metadata at the same subproject `.pdd/meta` (issue #1211). Preserve a previous user-facing command in `verify`/`test`/`fix`/`update`; use `fix` when no completed command exists so the internal metadata stage does not make the workflow appear incomplete. Gate on no prior stage reporting `failed`; `skipped` and `dry_run` remain acceptable upstream statuses. Any finalization exception is caught by the per-stage handler and reported as `failed`, which makes `MetadataSyncResult.ok` false. 3. **Result type** `MetadataSyncResult` — a Pydantic v2 model with: - `prompt_path: Path` - `code_path: Optional[Path]` @@ -69,7 +69,7 @@ This module provides the shared `run_metadata_sync` entry point used by `pdd upd pdd/sync_determine_operation.py - pdd/operation_log.py + pdd/operation_log.py diff --git a/pdd/prompts/mock_contract_validation_python.prompt b/pdd/prompts/mock_contract_validation_python.prompt new file mode 100644 index 000000000..074d61c5a --- /dev/null +++ b/pdd/prompts/mock_contract_validation_python.prompt @@ -0,0 +1,65 @@ +context/python_preamble.prompt + +Rejects green fixes whose generated mocks contradict repository-backed schema and interface evidence. + + +{ + "type": "module", + "module": { + "functions": [ + {"name": "extract_query_fields", "signature": "(source: str, source_path: str = '') -> tuple[QueryFieldUse, ...]", "returns": "tuple[QueryFieldUse, ...]"}, + {"name": "extract_mock_fields", "signature": "(source: str, source_path: str = '') -> tuple[MockFieldUse, ...]", "returns": "tuple[MockFieldUse, ...]"}, + {"name": "validate_mock_contracts", "signature": "(*, project_root: Path, production_sources: Mapping[str | Path, str], test_sources: Mapping[str | Path, str], baseline_production_sources: Optional[Mapping[str | Path, str]] = None, baseline_test_sources: Optional[Mapping[str | Path, str]] = None) -> MockContractReport", "returns": "MockContractReport"}, + {"name": "validate_changed_files", "signature": "(*, project_root: Path, changed_files: Sequence[str], baseline_ref: Optional[str] = None) -> MockContractReport", "returns": "MockContractReport"}, + {"name": "format_mock_contract_report", "signature": "(report: MockContractReport) -> str", "returns": "str"}, + {"name": "enforce_mock_contracts", "signature": "(report: MockContractReport) -> None", "returns": "None"} + ] + } +} + + +% Goal +Write `pdd/mock_contract_validation.py`, a deterministic success gate for issue #1939. A generated test may execute and pass while fabricating the exact field that makes a production lookup wrong. Detect that contradiction from real repository evidence before a fix can report success. + + +- Query field: a literal field path used by supported Python data lookups (`query_collection`, `count_collection`, Firestore `where`, or `filter(FieldFilter(...))`). +- Mock field: a literal dictionary key supplied through `Mock`/`MagicMock`/`AsyncMock`/`patch` return values, side effects, or fake/mock helper returns. +- Authoritative schema: an exact resource block in a schema Markdown/JSON file that enumerates allowed fields. +- Sibling evidence: the same exact resource/field pair used by an independent production reader or writer outside the changed file. +- Diverged: an introduced query/mock pair names a field absent from an exact authoritative schema and lacks corroborating sibling evidence. +- Inconclusive: a candidate exists but no exact authoritative schema is available; never guess from a suspicious-looking field name. + + + +R1 (MUST): The #1939 regression shape — code queries `user_waitlist.userId`, a passing test mocks `userId`, and the exact `user_waitlist` schema omits it — returns `status="diverged"` with code, mock, and schema locations. +R2 (MUST): A field explicitly present in the exact schema, or corroborated by an independent production reader/writer, never produces a divergence. +R3 (MUST): Missing evidence returns `inconclusive`, not a heuristic failure. Field spelling alone is never evidence. +R4 (MUST): Baseline sources make validation diff-aware. Check newly introduced resource/field queries and newly introduced mock fields tied to an existing query; do not relitigate unchanged legacy pairs. +R5 (MUST): Schema parsing preserves field paths: a nested object field does not authorize querying that nested name as a top-level field, while the correctly qualified dotted path remains valid. +R6 (MUST): `MockContractDivergenceError` retains the structured report and formats an explicit `MOCK_CONTRACT_DIVERGENCE` diagnostic. + + +% Requirements + +1. Parse Python with `ast`; syntax errors yield no extracted uses rather than crashing the fix workflow. Support literal tuple/list filters and `FieldFilter` calls. Do not infer dynamic resource or field names. + +2. Extract mock payload keys only from actual mock/fake structures: return values, side effects, mock constructors, and fake/mock helper returns. Do not classify every dictionary in a test as mock evidence. + +3. Discover bounded schema files under the project root (`context`, `docs`, `schemas`, and root schema files), skipping generated/vendor/research trees and oversized files. For Markdown, require an exact `resource/` block and derive top-level plus indentation-qualified dotted field paths below its document-id row. For JSON, require an exact resource identity/key and explicit `properties`/`fields`, including nested object/array properties as dotted paths. + +4. Scan independent Python production files for exact resource query fields and literal writer payload fields. Exclude tests, prospective output paths, and every original/baseline production path (resolved to collapse relative, absolute, and symlink aliases), so an iterative fix candidate already written in place cannot certify itself. + +5. `validate_mock_contracts` returns immutable structured evidence. Candidate selection is diff-aware using optional baseline maps. Status is `not_applicable`, `clean`, `inconclusive`, or `diverged`. + +6. `validate_changed_files` loads changed Python code/tests from disk and optional baselines with `git show :`, then delegates to `validate_mock_contracts`. For a test-only edit that adds a mock field, locate an unchanged exact production query tied to the resource named by that test and validate that pair too. Git/read failures degrade to conservative current-source validation. + +7. Diagnostics name the exact `resource.field`, production line, schema source, and divergent mock source. `enforce_mock_contracts` raises only for `diverged`; inconclusive evidence remains a visible workflow warning. + + + context/mock_contract_validation_example.py + + +% Deliverables +- Code: `pdd/mock_contract_validation.py` +- Tests: `tests/test_mock_contract_validation.py` +- Example: `context/mock_contract_validation_example.py` diff --git a/pdd/prompts/one_session_sync_python.prompt b/pdd/prompts/one_session_sync_python.prompt index 36d8252dd..6d42213d1 100644 --- a/pdd/prompts/one_session_sync_python.prompt +++ b/pdd/prompts/one_session_sync_python.prompt @@ -23,7 +23,7 @@ Runs example generation, crash-fix, verify, test generation, and test-fix steps 6. Escapes `{`/`}` in resolved prompt content and code content before `.format()` to prevent code like `{uid}` from being treated as template placeholders 7. Sanitizes basename for progress file path: replace `/` with `_` to handle subdirectory modules (e.g., `core/cloud` → `core_cloud`) 8. Substitutes all placeholders: `{basename}`, `{language}`, `{prompt_path}`, `{code_path}`, `{example_path}`, `{test_path}`, `{project_root}`, `{resolved_prompt_content}`, `{code_content}`, `{target_coverage}`, `{progress_file}`, `{import_base}`, `{verify_step_num}`, `{test_step_num}` -9. Function: `run_one_session_sync(basename, language, pdd_files, project_root, *, target_coverage=90.0, budget=None, verbose=False, quiet=False, timeout=None) -> Dict[str, Any]` +9. Function: `run_one_session_sync(basename, language, pdd_files, project_root, *, target_coverage=90.0, budget=None, verbose=False, quiet=False, timeout=None) -> Dict[str, Any]`. Decorate it with `provider_failure_workflow` so direct server/library calls share permanent provider failures across every conformance-repair attempt and reset the health epoch when the workflow returns. 10. Validates code file exists; raises `FileNotFoundError` if not 11. Forwards `verbose` and `quiet` to `run_agentic_task(instruction=prompt, cwd=project_root, verbose=verbose, quiet=quiet, label="one_session_sync:{basename}", timeout=timeout or 1200, max_retries=2)`. Passes `max_retries=2` so the single-provider false-positive retry path in `run_agentic_task` actually fires — cloud one-session sync runs with anthropic-only (`len(candidates) == 1`), and the default `max_retries=1` means a single transient empty-result response from Claude Code fails the entire sync. 12. Returns dict with keys: `success`, `total_cost`, `model_name`, `operations_completed`, `errors`, `summary` @@ -54,6 +54,7 @@ Runs example generation, crash-fix, verify, test generation, and test-fix steps - Raise `FileNotFoundError` if code file missing: "Code file not found: {path}\nRun `pdd generate {basename}` first." - Raise `FileNotFoundError` if prompt template missing - On agentic task failure, return `errors` list with first 500 chars of output text; set `summary` to "Step {step} failed" identifying the last uncompleted step based on progress file markers. +- **Adoption provenance (#1903 §B.4)**: BEFORE the agentic session overwrites the canonical test (i.e. at the same point the pre-existing content is snapshotted), compute `test_was_adopted_human` — True only when the location is NOT pinned (`os.environ.get("PDD_TEST_OUTPUT_PATH") is None` AND `configured_test_output_pinned(prompt_or_code)` is False) AND `find_collocated_test(code_path)` returns a single existing sibling whose resolved path EQUALS the canonical `test_path` (so a greenfield/derived path is False). Total; any error → False. Also exclude a PDD greenfield-created test from adoption: AND `not is_pdd_created_test(test_path)`. Thread `adopted_human=test_was_adopted_human` into the canonical `_verify_test_churn(...)` call and the canonical-test `TestChurnError(...)` raises, so the emitted churn block stamps the provenance the issue-driven never-block requires. Alt-path (non-canonical) churn raises keep the default False (strict hard-fail). **Greenfield ownership:** when PDD is GREENFIELD-creating this test (unpinned, no pre-existing sibling, the file does not yet exist, and it is a co-located shape — under `__test__`/`__tests__` or a `.test.`/`.spec.` name, not a top-level `tests/` shadow), call `record_pdd_created_test(test_path)` so a later run never treats this PDD-owned file as human-adopted. - **Test-Churn Repair Loop (#1015 Codex iter-2)**: When the agentic session succeeds AND there was a pre-existing test file, snapshot the pre-existing content BEFORE invoking the session and then run `_verify_test_churn` against the generated file after the session completes. If `_verify_test_churn` raises `TestChurnError`, restore the pre-existing file and route through the same `PDD_REPAIR_DIRECTIVE` retry contract used by `sync_orchestration._run_test_op_with_churn_retry`: - Wrap the agentic-task call + churn-check in a bounded loop capped at `min(MAX_CONFORMANCE_ATTEMPTS, 2)` iterations (test-churn retries rarely converge — one retry only). - On each `TestChurnError`, restore the pre-existing test file, accumulate `attempt_cost` onto `accumulated_cost`, set `PDD_REPAIR_DIRECTIVE = exc.repair_directive` for the next attempt, and rebuild the heartbeat thread + clean the progress file so the retry can report fresh step-level updates. diff --git a/pdd/prompts/operation_log_python.prompt b/pdd/prompts/operation_log_python.prompt index 585fa146a..bee5fed64 100644 --- a/pdd/prompts/operation_log_python.prompt +++ b/pdd/prompts/operation_log_python.prompt @@ -1,13 +1,16 @@ Provides sync operation logging, fingerprints, run reports, and compression metadata storage. +fingerprint_transaction_python.prompt + { "type": "module", "module": { "functions": [ - {"name": "log_operation", "signature": "(updates_fingerprint: bool, updates_run_report: bool, clears_run_report: bool) -> Callable", "returns": "Callable"}, + {"name": "log_operation", "signature": "(operation: str, updates_fingerprint: bool = False, updates_run_report: bool = False, clears_run_report: bool = False) -> Callable", "returns": "Callable"}, {"name": "append_log_entry", "signature": "(basename: str, language: str, entry: Mapping[str, Any], project_root=None, paths=None) -> None", "returns": "None"}, - {"name": "log_event", "signature": "(basename: str, language: str, event: str, details: Mapping[str, Any] | None = None, project_root=None, paths=None, compression=None, agentic_fallback=None) -> None", "returns": "None"} + {"name": "log_event", "signature": "(basename: str, language: str, event: str, details: Mapping[str, Any] | None = None, project_root=None, paths=None, compression=None, agentic_fallback=None) -> None", "returns": "None"}, + {"name": "resolve_fingerprint_paths", "signature": "(basename: str, language: str, prompt_file: Union[str, Path], *, paths: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]", "returns": "Dict[str, Any]"} ] } } @@ -34,9 +37,10 @@ A shared logging infrastructure module for tracking all PDD operations. Provides 3. **Module Identity Inference** - Extract basename and language from prompt file paths. Reconstructs directory context relative to the configured prompts root, so `prompts/frontend/settings/page_typescriptreact.prompt` yields basename `frontend/settings/page` and language `typescriptreact`. Paths without a `prompts/` ancestor fall back to filename-only. Language is always normalized to lowercase. **Issue #1211**: when the nearest `.pddrc` defines a non-default `prompts_dir` (e.g. `"prompts/backend"`), `infer_module_identity` MUST anchor the subdir extraction on that configured root — not the literal `"prompts"` segment. Otherwise the decorator would emit basenames like `backend/services/foo` while `construct_paths`/`sync` emit `services/foo`, splitting fingerprint filenames into two (`backend_services_foo_*.json` vs `services_foo_*.json`) and silently hiding metadata from sync. Use a helper `_prompts_dir_for_prompt(prompt_path)` that finds the nearest `.pddrc` via `construct_paths._find_nearest_pddrc_for_file`, resolves the context (file-based first, then cwd-based), and returns its `prompts_dir`. Fall back to the literal `prompts/` anchor when no `.pddrc` or no `prompts_dir` is set. -4. **State File Management** - Save fingerprints (`.json`), run reports (`_run.json`), and sync logs (`_sync.log`) to `.pdd/meta/`. All paths use sanitized basenames and `language.lower()`. Stale run reports are cleared only after the invalidating operation succeeds, and must be cleared before `save_fingerprint` so a fresh fingerprint never coexists with a stale per-module run report (issue #1057). The decorator must verify that a pre-existing run report is actually gone before saving a fingerprint; if deletion cannot be verified, warn and skip `save_fingerprint`. A failed command must never erase existing runtime verification state. +4. **State File Management** - Save fingerprints (`.json`), run reports (`_run.json`), and sync logs (`_sync.log`) to `.pdd/meta/`. All paths use sanitized basenames and `language.lower()`. Stale run reports are cleared only after the invalidating operation succeeds, and must be cleared before `save_fingerprint` so a fresh fingerprint never coexists with a stale per-module run report (issue #1057). The decorator must verify that a pre-existing run report is actually gone before saving a fingerprint; if deletion cannot be verified, raise `FingerprintFinalizeError` and fail the command. A failed command must never erase existing runtime verification state. + - `save_fingerprint` is a thin public wrapper that constructs a `FingerprintTransaction` and commits it. **`FingerprintFinalizeError` propagates to callers — fingerprint write failures are command failures, not warnings.** Intentional non-mutating modes skip before calling this wrapper; callers that need a scoped transaction may use `FingerprintTransaction.skip(reason=...)`. - `save_fingerprint` must write the full `Fingerprint` dataclass format (from `sync_determine_operation`) using `calculate_current_hashes` so that `read_fingerprint` can parse it. This ensures manual commands (generate, example) don't break sync's fingerprint tracking. Pass stored `include_deps` from previous fingerprint for prompt hash calculation. - - Metadata path resolution (issue #1211): `ensure_meta_dir`, `get_log_path`, `get_fingerprint_path`, and `get_run_report_path` all accept an optional `project_root` argument that anchors `.pdd/meta` under that root, plus an optional `paths` dict (the same `paths` flowing through `save_fingerprint`). Resolution order when `project_root` is omitted: (1) walk up from each file in `paths` until a `.pddrc` is found — this is what handles the case where the subproject .pddrc lives BELOW the run CWD; (2) walk up from CWD; (3) fall back to CWD. `save_fingerprint`, `save_run_report`, `clear_run_report`, `_clear_run_report_before_fingerprint`, and `append_log_entry` all accept `paths` and thread it through. The `@log_operation` decorator captures the inferred `prompt_file` path as an anchoring hint and passes a `paths` dict to every metadata write it makes (append_log_entry, save_fingerprint, save_run_report, _clear_run_report_before_fingerprint) so a decorated command run from a parent CWD still anchors its sync log, fingerprint, and run report at the subproject's `.pdd/meta`. **Issue #1305 / #1211**: for the `save_fingerprint` call specifically, the decorator MUST pass the authoritative, complete file-path set — `Path` objects keyed by `prompt`/`code`/`example`/`test` — and NOT a bare `{"prompt": prompt_file}` hint. `calculate_current_hashes` only hashes values that are `Path` instances, so a raw string prompt value plus absent code/example/test keys yields all-null `prompt_hash/code_hash/example_hash/test_hash`; that null fingerprint never matches the files on disk, so it never converges and CI auto-heal re-runs `pdd example`/`generate` on every pass, rewriting the generated example and overwriting maintainer cleanup. Resolve that set via `get_pdd_file_paths`, but anchor it at the prompt file's subproject by passing an absolute `prompts_dir` pointing at the prompts-root directory that contains this `prompt_file`: `get_pdd_file_paths(basename, language, prompts_dir=)`. This anchoring is REQUIRED because `get_pdd_file_paths` otherwise re-resolves the prompts root from the run CWD (`_find_pddrc_file()` walks up from CWD): a command invoked from a PARENT CWD for a nested subproject (the #1211 case — subproject `.pddrc` BELOW the run CWD) would then resolve to the parent, fail to find the prompt, point code/example/test at non-existent parent files (all-null hashes again), AND write the fingerprint to the parent `.pdd/meta` — splitting it from the sync log/run report that the prompt-path hint still anchors at the subproject. The absolute prompts root must be the BASE prompts directory (e.g. `/prompts`) — the `prompts` component nearest the prompt file, the same root `get_pdd_file_paths` uses by default — NOT a deeper, context-configured `prompts_dir` such as `prompts/commands`. `get_pdd_file_paths` searches for the prompt recursively under this root and computes `architecture.json` lookup keys *relative to it*, so a deeper root changes that key (`checkup_python.prompt` instead of `commands/checkup_python.prompt`) and silently breaks filepath resolution for subdir-context modules (e.g. `pdd/commands/checkup.py`). Compute it by taking the path up to and including the `prompts` component nearest the prompt file (resolved absolute), falling back to the prompt file's own directory when the path has no `prompts` component. Once the prompt resolves to its real subproject path, `construct_paths` detects the subproject `.pddrc` lives below the CWD and anchors code/example/test inside the subproject too, so all four hashes are real and the fingerprint lands in the subproject's `.pdd/meta`. Passing the full, subproject-anchored `Path` dict produces real, non-null hashes for both the `example` and `generate` operations and lets a second auto-heal pass on an unchanged tree detect no drift. Existence-gate the result: if resolution silently produced a `prompt` path that is not on disk (a mis-resolution that did not raise), the other paths are wrong too, so fall back to `{"prompt": Path(prompt_file)}` so `prompt_hash` is still real and the write anchors at the subproject. Likewise, if prompts-root computation or `get_pdd_file_paths` resolution raises, fall back to `{"prompt": Path(prompt_file)}` (a coerced `Path`, never a raw string) so anchoring still works. The lighter prompt-path hint may still be used for the non-fingerprint writes (append_log_entry/save_run_report/clear) purely for anchoring. Issue #983 contract preserved: when the caller passes a non-empty `paths`, `save_fingerprint` never calls `get_pdd_file_paths` — the decorator resolves the full path set itself before the call. Expose `_detect_project_root` and `_detect_project_root_from_paths` helpers for cross-module reuse by `sync_determine_operation.get_meta_dir` / `read_fingerprint` / `read_run_report`. + - Metadata path resolution (issue #1211): `ensure_meta_dir`, `get_log_path`, `get_fingerprint_path`, and `get_run_report_path` all accept an optional `project_root` argument that anchors `.pdd/meta` under that root, plus an optional `paths` dict. Resolution order when `project_root` is omitted: (1) walk up from each file in `paths` until a `.pddrc` is found; (2) walk up from CWD; (3) fall back to CWD. `save_fingerprint`, `save_run_report`, `clear_run_report`, `_clear_run_report_before_fingerprint`, and `append_log_entry` all thread the path hint through. `resolve_fingerprint_paths` is the shared caller-side resolver for mutating commands that know only some artifacts: anchor `get_pdd_file_paths` at the absolute base `prompts` directory containing the real prompt, verify its resolved prompt exists, then overlay every explicit caller path so those locations remain authoritative. If a relative CLI spelling does not exist from process CWD, keep the valid discovered prompt instead of overwriting it. Derivative/new prompts may not yet be registered; on discovery failure or a missing discovered prompt, return the explicit touched paths so their hashes still undergo hard validation. This prevents normal partial update/CI refreshes from replacing untouched example/test hashes with null while preserving issue #983: `save_fingerprint` itself never re-discovers a non-empty explicit mapping. The decorator uses this helper for the complete fingerprint path set but retains the lighter prompt hint for log/run-report anchoring. Expose `_detect_project_root` and `_detect_project_root_from_paths` for `sync_determine_operation` reuse. 5. **Event Logging** - Log special events (lock_acquired, budget_warning, etc.) to the sync log. **Issue #1211**: `log_event` accepts an optional `paths` argument and forwards it to `append_log_entry` so lifecycle events (sync_start, lock_acquired, lock_released, budget_warning/exceeded, cycle_detected, steering_override, test_extend_skipped/limit, import_validation_failed, auto_fix_attempted/success, etc.) land in the same subproject `.pdd/meta` as the fingerprints/run reports. Without this, lifecycle logs split across two trees when invoked from a parent CWD. `load_operation_log` accepts the same `paths` hint so dry-run / log-display reads find the subproject log. - Sync callers may attach per-phase compression status and agentic-fallback status to log entries/events. Store only metadata such as requested, used, phase, hashes, source counts, estimates, reason, and fallback booleans. @@ -44,21 +48,25 @@ A shared logging infrastructure module for tracking all PDD operations. Provides % The @log_operation Decorator A decorator for CLI commands that automatically logs operations and manages state files. Parameters control which state files are updated: -- `updates_fingerprint` - Save fingerprint on success +- `updates_fingerprint` - Save fingerprint on success via `FingerprintTransaction`; `FingerprintFinalizeError` propagates to Click boundary (non-zero exit) - `updates_run_report` - Save run report on success (if result is a dict) -- `clears_run_report` - Clear stale run report only after the wrapped command succeeds, before `save_fingerprint`. Verify a pre-existing run report was removed; if it remains or cannot be inspected, warn and skip fingerprint saving. A failed command must not clear the existing run report (issue #1057). +- `clears_run_report` - Clear stale run report only after the wrapped command succeeds, before fingerprint finalization. Verify a pre-existing run report was removed; if it remains, raise `FingerprintFinalizeError` so the command cannot return a false success. A failed wrapped command must not clear the existing run report (issue #1057). The decorator must: - Work with Click commands - Extract module identity from the `prompt_file` parameter, falling back to the first `.prompt`-suffixed value in an `args` tuple (for `nargs=-1` commands) - Handle cases where module identity cannot be inferred (skip logging to disk) - Extract cost/model from result tuples via helpers in `sync_orchestration` +- Treat metadata finalization as part of command success: do not append a `success: true` log entry until run-report clearing, fingerprint persistence, and run-report persistence finish. If one of those post-command operations raises, update the entry to `success: false` with the finalization error, append that failed entry, then re-raise so the CLI remains fail-closed. % Dependencies context/sync_determine_operation_example.py + + pdd/fingerprint_transaction.py + % Deliverables diff --git a/pdd/prompts/pin_example_hack_python.prompt b/pdd/prompts/pin_example_hack_python.prompt index 7e29dd7e6..7c978745a 100644 --- a/pdd/prompts/pin_example_hack_python.prompt +++ b/pdd/prompts/pin_example_hack_python.prompt @@ -12,6 +12,7 @@ Create a Python module that orchestrates the full PDD sync workflow for one base context/fix_main_example.py context/update_main_example.py context/get_test_command_example.py +context/fingerprint_transaction_example.py ## Public API @@ -66,7 +67,7 @@ At function start, coerce `None` for `target_coverage`, `budget`, `max_attempts` Implement: - `load_sync_log`, `create_sync_log_entry`, `update_sync_log_entry`, `append_sync_log`, `log_sync_event` - `save_run_report(report, basename, language, atomic_state=None)` writing `META_DIR / "{safe_basename}_{language.lower()}_run.json"` -- `_save_operation_fingerprint(..., atomic_state=None)` using `calculate_current_hashes` + `Fingerprint`, writing `META_DIR / "{safe_basename}_{language.lower()}.json"` +- `_save_operation_fingerprint(..., atomic_state=None)` entering `FingerprintTransaction(basename, language, operation, paths=paths, cost=cost, model=model, atomic_state=atomic_state)` as a context manager (`with FingerprintTransaction(...): pass`). On clean exit the transaction builds the canonical fingerprint payload and finalizes it exactly once: with no `atomic_state` it writes atomically to `transaction.fingerprint_path`; with an outer `atomic_state` (an `AtomicStateUpdate`) it routes that payload through `atomic_state.set_fingerprint(payload, fingerprint_path, operation=...)` so only the outer atomic state owns the final write. Do not invent a manual prepare/skip handshake — `skip(reason)` exists only to suppress finalization on an intentional non-mutating path (e.g. a dry run). See the `fingerprint_transaction_example` include for both shapes. - `_python_cov_target_for_code_file`, `_python_cov_target_for_test_and_code` - `_parse_test_output` for Python, JS/TS, Go, Rust, plus fallback - `_detect_example_errors` (traceback + ERROR-log patterns only) diff --git a/pdd/prompts/sync_determine_operation_python.prompt b/pdd/prompts/sync_determine_operation_python.prompt index 4e4d10de7..fd805afff 100644 --- a/pdd/prompts/sync_determine_operation_python.prompt +++ b/pdd/prompts/sync_determine_operation_python.prompt @@ -49,6 +49,11 @@ You are an expert Python developer. Your task is to implement the core decision- 10. **Missing File Recovery**: `validate_expected_files` checks fingerprint expectations against disk. `_handle_missing_expected_files` returns appropriate recovery operation with priority: code → example → test. 11. **Cost Estimation**: `estimate_operation_cost` returns dollar estimates per operation for budget tracking. 12. **Stale Run Report Handling**: Run reports without a fingerprint are ignored (orphaned). After `auto-deps`, existing run reports are stale and ignored (the early auto-deps check fires before run_report processing). +13. **Runner-Collected Co-located Test Adoption (Issue #1903)**: PDD derives a module's test path from `.pddrc`/defaults (`tests/test_{name}{ext}`), blind to the project's real test runner, so on a jest/Next.js project sync keeps verifying a runner-blind `tests/` shadow while the co-located test CI actually runs (`__test__/{name}.test.tsx`) goes stale — a false-green (a `.tsx` module also wrongly derived a `.ts` extension). `get_pdd_file_paths` now adopts a single existing co-located test as the canonical `test`/`test_files` so sync/change target the real test: + - **Delegate/wrapper split.** All existing derivation logic (architecture.json, template-based, legacy, prompt-missing, fallback, and the `AmbiguousModuleError` re-raise) moves into a raw-derivation delegate `_get_pdd_file_paths_uncollocated(basename, language, prompts_dir="prompts", context_override=None) -> Tuple[Dict[str, Path], bool]`; EVERY one of its return branches now returns `(result, test_output_pinned)`. The public `get_pdd_file_paths(basename, language, prompts_dir="prompts", context_override=None) -> Dict[str, Path]` is a thin wrapper whose signature and `{prompt, code, example, test, test_files}` result shape are UNCHANGED: it calls the delegate, then adopts IN PLACE via `_adopt_collocated_test(result, *, user_pinned=test_output_pinned)` and returns the adopted dict. + - **One authoritative provenance signal.** `test_output_pinned` is the single explicit-vs-default signal for THIS module, read from the SAME config each branch derived paths from: the RAW `.pddrc` context `defaults` (`test_output_path` / `outputs.test.path`, via `content_selector._pins_test_output_location`) PLUS the `PDD_TEST_OUTPUT_PATH` env var — NEVER `construct_paths`' `resolved_config` (which injects a generated-default `test_output_path` and so ALWAYS reads as pinned). Seed it from `os.environ.get("PDD_TEST_OUTPUT_PATH") is not None` (presence NOT truthiness — an explicit empty `PDD_TEST_OUTPUT_PATH=` is still a pin; and so it is defined even if an exception fires before the prompt resolves), then refine it ONCE, before the branch dispatch, via `content_selector.configured_test_output_pinned(prompt_path, context_override=context_override or resolved_context_name, search_from=prompts_root_anchor)`. + - **Architecture.json branch caveat.** When no `context_override`/`resolved_context_name` is known, the architecture.json branch DETECTS its context from the arch CODE path (not the prompt side), so the early prompt-side signal can miss a `test_output_path` pinned on the code-path-matched context and let adoption silently override it. In that branch recompute a branch-local `arch_test_output_pinned` (seed from the env var, then OR with `_pins_test_output_location(defaults)` of that branch's OWN resolved context defaults) and return IT from the architecture.json return. + - **Adopter and write-sink containment.** `_adopt_collocated_test(result: Dict[str, Path], *, user_pinned: bool) -> Dict[str, Path]`: when `result['code']` and `result['test']` are both present and `user_pinned` is False, call `content_selector.resolve_test_output_path(result['code'], result['test'], user_pinned=user_pinned)`, then pass BOTH the adopted and derived paths separately through `content_selector._validated_project_path` using the trusted resolved CWD. Never call `Path.resolve()` directly on either caller-influenced value. Reject a missing/outside adopted result; if the two validated paths are equal, keep the original; otherwise set `result['test']` and `result['test_files']` to the canonical validated adopted path. An explicit pin is ALWAYS honored. Never raises — on any error return `result` unchanged. The standard Python `src/foo.py -> tests/test_foo.py` flow is a no-op here because the co-located sibling equals the derived path. ### Dependencies: @@ -90,7 +95,7 @@ You are an expert Python developer. Your task is to implement the core decision- A standalone Python module `sync_determine_operation.py` containing: - Dataclass definitions for state tracking. - `SyncLock` class. -- `get_pdd_file_paths`, `_generate_paths_from_templates`, and hashing utilities. +- `get_pdd_file_paths` (thin adopting wrapper), its raw-derivation delegate `_get_pdd_file_paths_uncollocated`, the co-located-test adopter `_adopt_collocated_test`, `_generate_paths_from_templates`, and hashing utilities. - `sync_determine_operation` (primary entry point) and `_perform_sync_analysis`. - `_check_example_success_history`, `validate_expected_files`, `_handle_missing_expected_files`. - `analyze_conflict_with_llm` (LLM fallback). diff --git a/pdd/prompts/sync_main_python.prompt b/pdd/prompts/sync_main_python.prompt index 7343df20b..401912ee1 100644 --- a/pdd/prompts/sync_main_python.prompt +++ b/pdd/prompts/sync_main_python.prompt @@ -5,7 +5,7 @@ "type": "cli", "cli": { "commands": [ - {"name": "sync_main", "signature": "(ctx: click.Context, basename: str, max_attempts: Optional[int], budget: Optional[float], skip_verify: bool, skip_tests: bool, target_coverage: float, dry_run: bool, no_steer: bool = False, steer_timeout: Optional[float] = None, agentic_mode: bool = False, one_session: bool = False, compress: bool = False, evidence: bool = False, snapshot_context: bool = False, compressed_context: Optional[bool] = None) -> Tuple[Dict[str, Any], float, str]", "returns": "Tuple[Dict[str, Any], float, str]"} + {"name": "sync_main", "signature": "(ctx: click.Context, basename: str, max_attempts: Optional[int], budget: Optional[float], skip_verify: bool, skip_tests: bool, target_coverage: float, dry_run: bool, no_steer: bool = False, steer_timeout: Optional[float] = None, agentic_mode: bool = False, one_session: bool = False, compress: bool = False, fresh: bool = False, evidence: bool = False, snapshot_context: bool = False, compressed_context: Optional[bool] = None) -> Tuple[Dict[str, Any], float, str]", "returns": "Tuple[Dict[str, Any], float, str]"} ] } } @@ -15,6 +15,7 @@ code_generator_main_python.prompt agentic_sync_runner_python.prompt compressed_sync_context_python.prompt +operation_log_python.prompt # sync_main_python.prompt % You are an expert Python engineer. Your goal is to write a Python function, 'sync_main', that will be the CLI wrapper for the sync command. This function handles interface logic (environment variables, parameter validation, path construction) and calls sync_orchestration to perform the actual sync workflow. @@ -41,6 +42,7 @@ - `steer_timeout` (Optional[float]): Steering choice timeout in seconds. None = default timeout. - `agentic_mode` (bool): When True, Python behaves like non-Python languages (uses agentic path for operations) - `one_session` (bool): When True, run example/crash/verify/test/fix in a single agentic session instead of separate steps + - `fresh` (bool): Issue #1938 Pillar A. When False (default), regenerate mature modules surgically (edit-shaped) by passing `force_incremental_flag=True` to `code_generator_main` so declared public symbols are preserved instead of dropped by a full "rebirth" regeneration. When True (`pdd sync --fresh`), pass `force_incremental_flag=False` to restore standard generation. Forward `fresh` to every `sync_orchestration()` call and use `force_incremental_flag=(not fresh)` in the one-session generation loop. Note: in one-session mode the module is otherwise (re)generated by the agent session (`run_one_session_sync`), so the one-session `code_generator_main` generate — and therefore `fresh` — only takes effect when the code file is missing or `--force` triggers a from-scratch generation; for an already-existing mature module in one-session mode the code edit is agent-driven and `fresh` does not change it. The effective path for `fresh` is the standard multi-step single-module sync. Default False. - `snapshot_context` (bool): When True, capture replayable expanded prompt context for generation in `code_generator_main` and `sync_orchestration`. Default False. - `compressed_context` (Optional[bool]): Tri-state request flag for phase-aware compressed context. `None` means no explicit CLI value was supplied; resolve the effective setting from CLI value when not None, then `.pddrc` default context `defaults.compressed_context`, then `False`. Display the effective setting with other sync settings and never print raw compressed prompt/test/example content. - `compress` (bool): Use AST-based compression (`mode="compressed"`) for Python includes when no explicit include mode is set. Default False. @@ -111,7 +113,7 @@ - Mutually exclusive with `skip_tests` and `skip_verify` (raise UsageError if combined) - Use `get_pdd_file_paths()` from `sync_determine_operation` to resolve module file paths - **Fingerprint check**: If not force mode, call `sync_determine_operation()` to check if module is already synced; skip if operation is "nothing" - - **Phase 1 (Generation)**: If code file doesn't exist or `force`, run `code_generator_main()` to generate the code file; track the generation cost as `pre_cost`. Pass `output_from_config=True` — the resolved code path comes from `get_pdd_file_paths()` (which uses `.pddrc` `generate_output_path`), not an explicit user `--output` flag, so front-matter `output:` should still take precedence over the .pddrc default. Forward `snapshot_context` into every generation attempt. When the effective compressed-context request flag is true, build a phase-specific compressed context package immediately before each LLM-backed generation attempt and pass that mapping to `code_generator_main(compressed_context=package)`. Never pass the boolean request flag itself to `code_generator_main`. If package construction fails, pass `None`, continue with full-context behavior, and record `used=false` compression metadata with the failure reason. + - **Phase 1 (Generation)**: If code file doesn't exist or `force`, run `code_generator_main()` to generate the code file; track the generation cost as `pre_cost`. Pass `output_from_config=True` — the resolved code path comes from `get_pdd_file_paths()` (which uses `.pddrc` `generate_output_path`), not an explicit user `--output` flag, so front-matter `output:` should still take precedence over the .pddrc default. Pass `force_incremental_flag=(not fresh)` so a mature module (existing non-empty code file) is regenerated surgically/edit-shaped by default and only fully regenerated when the operator passes `--fresh` (#1938 Pillar A); `code_generator_main` still falls back to full generation for a new/empty file or when the original prompt cannot be determined. Forward `snapshot_context` into every generation attempt. When the effective compressed-context request flag is true, build a phase-specific compressed context package immediately before each LLM-backed generation attempt and pass that mapping to `code_generator_main(compressed_context=package)`. Never pass the boolean request flag itself to `code_generator_main`. If package construction fails, pass `None`, continue with full-context behavior, and record `used=false` compression metadata with the failure reason. - **Ambiguous module names (issue #1677)**: catch `AmbiguousModuleError` (imported from `sync_determine_operation`; a `ValueError` subclass raised by `get_pdd_file_paths` when a bare basename maps to ≥2 distinct architecture outputs) around module/path resolution and the per-language loop, and fail the command hard with the conflicting-target list (shown even under `--quiet`, exit non-zero). Never fall through to first-match-wins generation for an ambiguous bare basename. - **Architecture-conformance / public-surface / test-churn retry (#866, #1012)**: Wrap the `code_generator_main()` call in a retry loop bounded by `MAX_CONFORMANCE_ATTEMPTS` (imported from `pdd.agentic_sync_runner`). The loop catches three typed exceptions raised from `code_generator_main`: `ArchitectureConformanceError`, `PublicSurfaceRegressionError`, and `TestChurnError`. For each, add `exc.total_cost` to `pre_cost`, remember `exc.model_name`, set `PDD_REPAIR_DIRECTIVE` to `exc.repair_directive`, and retry only if budget remains. - Track the failure signature across attempts: sorted `missing_symbols` for conformance, sorted `removed_symbols` for public-surface, `(round(churn_ratio, 2), pre_line_count)` for test-churn. If the next attempt fails with the **identical** signature, abort the loop (no progress) instead of looping forever. Test-churn additionally caps at ONE repair attempt — the additive-only directive either lands the first try or stays stuck. @@ -124,7 +126,7 @@ - Merge costs: add `pre_cost` (including failed conformance attempts and the final successful generate attempt) to the one-session result's `total_cost`; pass only `remaining_budget - pre_cost` into the one-session phase. - Include snapshot status and artifact paths from generation in the one-session summary/result when available, without displaying sensitive artifact contents or forbidden detail strings. - If code generation fails (code file still missing), return failure without proceeding to one-session - - **Post-sync**: Save fingerprint via `save_fingerprint()` so next sync sees files as up-to-date + - **Post-sync**: Save the complete `pdd_files` mapping through `operation_log.save_fingerprint`, the shared `FingerprintTransaction` wrapper, so the next sync sees files as up to date. Do not catch its `FingerprintFinalizeError`; one-session sync must exit non-zero when finalization fails. - **Auto-submit**: On success and not local mode, call `_auto_submit_example()` to submit the example to PDD Cloud 9. **Auto-Submit Example** (`_auto_submit_example` helper): @@ -145,7 +147,7 @@ - Track remaining_budget across languages (reduce after each language's cost) - Skip subsequent languages if budget exhausted - Aggregate results and costs from all language syncs - - Forward steerability controls (`no_steer`, resolved `steer_timeout`), `compress`, `evidence`, and #877 compression flags (`compress_examples`, `compress_test_context`, `context_compression`, `compression_fallback`) to each sync_orchestration call + - Forward steerability controls (`no_steer`, resolved `steer_timeout`), `compress`, `fresh`, `evidence`, and #877 compression flags (`compress_examples`, `compress_test_context`, `context_compression`, `compression_fallback`) to each sync_orchestration call - **Auto-submit on success**: After sync_orchestration returns success for a language (and not local mode), call `_auto_submit_example()` to submit the prompt+code pair to PDD Cloud, same as one-session mode. Use `get_pdd_file_paths()` to resolve the module file paths needed for submission. Wrap in try/except and log warnings on failure (do not abort sync). 11. User Feedback (unless quiet=True): diff --git a/pdd/prompts/sync_orchestration_python.prompt b/pdd/prompts/sync_orchestration_python.prompt index 638faa76a..f29f7538c 100644 --- a/pdd/prompts/sync_orchestration_python.prompt +++ b/pdd/prompts/sync_orchestration_python.prompt @@ -5,7 +5,7 @@ "type": "module", "module": { "functions": [ - {"name": "sync_orchestration", "signature": "(basename: str, target_coverage: float = 90.0, language: str = \"python\", prompts_dir: str = \"prompts\", code_dir: str = \"src\", examples_dir: str = \"examples\", tests_dir: str = \"tests\", max_attempts: int = 3, budget: float = 10.0, skip_verify: bool = False, skip_tests: bool = False, dry_run: bool = False, force: bool = False, strength: float = DEFAULT_STRENGTH, temperature: float = 0.0, time_param: float = 0.25, verbose: bool = False, quiet: bool = False, output_cost: Optional[str] = None, review_examples: bool = False, local: bool = False, context_config: Optional[Dict[str, str]] = None, context_override: Optional[str] = None, confirm_callback: Optional[Callable[[str, str], bool]] = None, no_steer: bool = False, steer_timeout: float = DEFAULT_STEER_TIMEOUT_S, agentic_mode: bool = False, one_session: bool = False, compress: bool = False, evidence: bool = False, snapshot_context: bool = False, compressed_context: bool = False) -> Dict[str, Any]", "returns": "Dict[str, Any]"} + {"name": "sync_orchestration", "signature": "(basename: str, target_coverage: float = 90.0, language: str = \"python\", prompts_dir: str = \"prompts\", code_dir: str = \"src\", examples_dir: str = \"examples\", tests_dir: str = \"tests\", max_attempts: int = 3, budget: float = 10.0, skip_verify: bool = False, skip_tests: bool = False, dry_run: bool = False, force: bool = False, strength: float = DEFAULT_STRENGTH, temperature: float = 0.0, time_param: float = 0.25, verbose: bool = False, quiet: bool = False, output_cost: Optional[str] = None, review_examples: bool = False, local: bool = False, context_config: Optional[Dict[str, str]] = None, context_override: Optional[str] = None, confirm_callback: Optional[Callable[[str, str], bool]] = None, no_steer: bool = False, steer_timeout: float = DEFAULT_STEER_TIMEOUT_S, agentic_mode: bool = False, one_session: bool = False, compress: bool = False, fresh: bool = False, evidence: bool = False, snapshot_context: bool = False, compressed_context: bool = False) -> Dict[str, Any]", "returns": "Dict[str, Any]"} ] } } @@ -14,6 +14,7 @@ code_generator_main_python.prompt agentic_sync_runner_python.prompt compressed_sync_context_python.prompt +fingerprint_transaction_python.prompt # sync_orchestration_python.prompt @@ -73,6 +74,7 @@ def sync_orchestration( steer_timeout: float = DEFAULT_STEER_TIMEOUT_S, agentic_mode: bool = False, compress: bool = False, + fresh: bool = False, evidence: bool = False, snapshot_context: bool = False, compressed_context: bool = False, @@ -179,10 +181,10 @@ from .operation_log import ( update_log_entry, append_log_entry, log_event, - save_fingerprint, save_run_report, clear_run_report, ) +from .fingerprint_transaction import FingerprintTransaction, FingerprintFinalizeError from .sync_determine_operation import ( sync_determine_operation, get_pdd_file_paths, @@ -289,9 +291,11 @@ Detect headless environments (no TTY, CI environment variable, or `quiet=True`) Use `AtomicStateUpdate` context manager for consistent state writes: - Ensures run_report and fingerprint are both written or neither is written - Uses temp file + atomic rename pattern for crash safety -- After each successful operation, save a `Fingerprint` to disk with current file hashes +- After each successful operation, save a `Fingerprint` to disk with current file hashes via `FingerprintTransaction`. `FingerprintFinalizeError` propagates through the operation dispatch to Click; it MUST NOT be caught by per-operation `except` handlers. +- **No-op fix handling**: a zero-cost fix with an empty/none/unknown/n/a model performed no mutation. Detect it before finalization and do not call `_save_fingerprint_atomic`; this intentional non-mutating path must not claim `fix` completed. +- The fingerprint-write leg of `AtomicStateUpdate` and `_save_fingerprint_atomic` MUST route through `FingerprintTransaction`. `_save_fingerprint_atomic` becomes a thin wrapper that constructs and enters a `FingerprintTransaction`. - **Case-insensitive language in paths**: All metadata file paths (fingerprint `.json`, run report `_run.json`, sync log `_sync.log`) must use `language.lower()` for consistent paths on case-sensitive filesystems. This applies to any direct path construction in this module (e.g., `f"{_safe_basename(basename)}_{language.lower()}_run.json"`). -- **Subproject anchoring (issue #1211)**: never compose `META_DIR / ` directly for runtime writes — the module-level `META_DIR` is CWD-resolved at import time and won't follow a subproject `.pddrc` that lives below the run CWD. Instead, route fingerprint/run-report/log paths through `get_fingerprint_path(...)`, `get_run_report_path(...)`, and `get_log_path(...)` (from `operation_log`) and pass `paths=pdd_files` so the helper resolves the meta dir via upward `.pddrc` detection from those paths. `_save_run_report_atomic` and `_save_fingerprint_atomic` both accept `paths` and forward them into the atomic-state file path AND into `read_fingerprint` for stored-deps lookup. Every `read_run_report` / `clear_run_report` / `log_event` / `append_log_entry` call inside `sync_orchestration` must pass `paths=pdd_files` once `pdd_files` is in scope — including the post-`pdd_files` branches in coverage retry, crash-detection, generate logic, lifecycle events (sync_start, lock_acquired, lock_released, budget_warning/exceeded, cycle_detected, steering_override, etc.), and per-operation log_entry appends. The dry-run / `_display_sync_log` branch executes BEFORE `pdd_files` is set, so it does a best-effort `get_pdd_file_paths(...)` lookup (wrapped in try/except) and forwards the result via `paths=` so log display still finds the subproject log when invoked from a parent CWD. Mismatched paths leave stale state behind and let stale parent metadata leak into decision logic. The only acceptable `META_DIR` reference outside of these helpers is the `if __name__ == '__main__':` demo block at the end of the file. +- **Subproject anchoring (issue #1211)**: never compose `META_DIR / ` directly for runtime writes — the module-level `META_DIR` is CWD-resolved at import time and won't follow a subproject `.pddrc` that lives below the run CWD. Instead, route fingerprint/run-report/log paths through `get_fingerprint_path(...)`, `get_run_report_path(...)`, and `get_log_path(...)` (from `operation_log`) and pass `paths=pdd_files` so the helper resolves the meta dir via upward `.pddrc` detection from those paths. `_save_run_report_atomic` both accepts `paths` and forwards it into the atomic-state file path AND into `read_fingerprint` for stored-deps lookup. Every `read_run_report` / `clear_run_report` / `log_event` / `append_log_entry` call inside `sync_orchestration` must pass `paths=pdd_files` once `pdd_files` is in scope — including the post-`pdd_files` branches in coverage retry, crash-detection, generate logic, lifecycle events (sync_start, lock_acquired, lock_released, budget_warning/exceeded, cycle_detected, steering_override, etc.), and per-operation log_entry appends. The dry-run / `_display_sync_log` branch executes BEFORE `pdd_files` is set, so it does a best-effort `get_pdd_file_paths(...)` lookup (wrapped in try/except) and forwards the result via `paths=` so log display still finds the subproject log when invoked from a parent CWD. Mismatched paths leave stale state behind and let stale parent metadata leak into decision logic. The only acceptable `META_DIR` reference outside of these helpers is the `if __name__ == '__main__':` demo block at the end of the file. ### Skip Handling @@ -305,6 +309,7 @@ When `skip_verify` or `skip_tests` is set: - For the **auto-deps** operation, invoke `auto_deps_main()` with the resolved prompt path, directory path, CSV path, and `_skip_finalization=True`. Pass `compress=compress`. - For the **generate** operation, invoke `code_generator_main()` with the resolved `pdd_files['prompt']` and `pdd_files['code']` (both `.resolve()`'d to absolute paths). Pass `output_from_config=True` — the resolved code path comes from `get_pdd_file_paths()` (which uses `.pddrc generate_output_path`), not an explicit user `--output`, so front-matter `output:` should still take precedence over the .pddrc default. Forward `compress` and `snapshot_context` during normal generation and during conformance retry attempts. When compressed context is requested, build a fresh `phase="generate"` package for each initial attempt and retry, then pass it as `compressed_context=` to `code_generator_main`; never pass the boolean request flag. The one-session sync path in `sync_main` follows the same convention. + - **Surgical regeneration for mature modules (#1938 Pillar A)**: pass `force_incremental_flag=(not fresh)` to `code_generator_main` — i.e. by DEFAULT (`fresh=False`) mature modules are regenerated surgically (edit-shaped): `code_generator_main` feeds the existing on-disk code plus the prompt delta to the incremental generator so declared public symbols are preserved in place rather than dropped by a full "rebirth" regeneration. When the operator passes `pdd sync --fresh` (`fresh=True`), forward `force_incremental_flag=False` to restore full regeneration (for intended large rewrites). This applies to the initial generate attempt AND the conformance retry attempts; `code_generator_main` itself keeps the safety fallbacks — it performs full generation for a new/empty module or when the original prompt cannot be determined, and it disables incremental generation whenever a `PDD_REPAIR_DIRECTIVE` is active so repair retries still reach the full generator (#1724). The public-surface / declared-interface gate remains the guarantee; surgical generation only lowers drift frequency at the source. The one-session sync path in `sync_main` passes the same `force_incremental_flag=(not fresh)`. - **Ambiguous module names (issue #1677)**: `get_pdd_file_paths` raises `AmbiguousModuleError` (a `ValueError` subclass listing the conflicting targets) when a bare basename maps to ≥2 distinct architecture outputs. `sync_orchestration` imports it from `sync_determine_operation` and must let it propagate in BOTH the dry-run/log-display path and the main path — do NOT swallow it in the broad per-operation `except` — so an ambiguous bare basename fails the sync fast instead of generating to a guessed default path. - **Architecture-conformance / public-surface / test-churn retry (#866, #1012)**: Wrap the **generate** invocation of `code_generator_main()` in a repair loop bounded by `MAX_CONFORMANCE_ATTEMPTS` (imported from `pdd.agentic_sync_runner`). The loop catches three typed exceptions from `code_generator_main` — `ArchitectureConformanceError`, `PublicSurfaceRegressionError`, and `TestChurnError` — and routes each through the same `PDD_REPAIR_DIRECTIVE` plumbing: - On `ArchitectureConformanceError`: accumulate `exc.total_cost`/`exc.model_name`, set `PDD_REPAIR_DIRECTIVE` to `exc.repair_directive`, and retry only while budget remains; abort early (without further attempts) when the next failure carries the **identical** sorted missing-symbol set as the previous one (no progress is being made). On exhaustion call `build_conformance_hard_failure_from_error(last_exc, basename)` and `print()` the structured block to **stderr** (it contains `prompt:`, `output:`, `expected:`, `found:`, `missing:`, `Reproduce locally: pdd sync `, and the `--- env ---` fingerprint) before re-raising `last_exc`. @@ -371,7 +376,7 @@ Use these functions from `operation_log` module (already imported above): - `update_log_entry(entry, success, cost, model, duration, error)` - Add execution results to log entry - `append_log_entry(basename, language, entry, paths=None)` - Append entry to log file; pass `paths=pdd_files` so the log anchors at the subproject `.pdd/meta` (issue #1211) - `log_event(basename, language, event_type, details, invocation_mode, paths=None)` - Log system events (lock_acquired, budget_warning, etc.); pass `paths=pdd_files` for subproject anchoring -- `save_fingerprint(basename, language, operation, paths=None, cost, model)` - Save fingerprint after success; pass `paths=pdd_files` for subproject anchoring +- `_save_fingerprint_atomic(...)` - Thin sync adapter around `FingerprintTransaction`. Pass `paths=pdd_files`, the outer `AtomicStateUpdate`, and any include-deps override; `FingerprintFinalizeError` propagates. - `save_run_report(basename, language, report_data, paths=None)` - Save RunReport dict; pass `paths=pdd_files` for subproject anchoring (issue #1211) - `clear_run_report(basename, language, paths=None)` - Remove stale run report; pass `paths=pdd_files` for subproject anchoring @@ -441,7 +446,7 @@ Handle command results in multiple formats: - For test operation failure: if `result[4]` (error_message) is non-empty, append it to the errors list so it reaches the sync log - For crash/fix operations returning tuples: extract error message from `result[1]` on failure and append to errors list - For `ArchitectureConformanceError` exception paths, extract `total_cost` and `model_name` from the exception into a result-shaped dict before logging so failed conformance attempts do not report `$0.00`. -- Save fingerprint after successful operations regardless of format, **except** for no-op fix operations. A no-op fix is detected when `operation == 'fix'` and `actual_cost == 0.0` and `model_name` is empty/`'none'`/`'unknown'`/`'n/a'`. This check **must run before** `_save_fingerprint_atomic` so that a logical failure (consecutive no-op breaker) does not persist stale "fix completed" state. Do not call `_save_fingerprint_atomic` for no-op fixes. +- After parsing a successful mutating result, call `_save_fingerprint_atomic`, which constructs `FingerprintTransaction` with the outer atomic-state buffer. **For no-op fix operations**, omit the adapter call because no artifact mutation occurred. A no-op fix is detected when `operation == 'fix'`, `actual_cost == 0.0`, and `model_name` is empty/`'none'`/`'unknown'`/`'n/a'`. This check must run before finalization so the consecutive-no-op breaker cannot persist stale "fix completed" state. ### Non-Python Language Handling and Agentic Mode diff --git a/pdd/prompts/sync_tui_python.prompt b/pdd/prompts/sync_tui_python.prompt index 08a8a4e1d..0e2f0b714 100644 --- a/pdd/prompts/sync_tui_python.prompt +++ b/pdd/prompts/sync_tui_python.prompt @@ -20,6 +20,7 @@ providing animated feedback, log output, and modal dialogs for user interaction. 2. **Worker Thread Execution**: - Run worker_func in @work(thread=True) decorated method + - Snapshot `get_disabled_providers()` in `SyncApp.__init__`, then seed `provider_failure_scope(initial_disabled)` inside the worker thread before calling `worker_func`; ContextVars do not propagate to Textual worker threads automatically - Redirect stdout/stderr to RichLog via ThreadSafeRedirector - Redirect stdin to TUI input modals via TUIStdinRedirector - Set FORCE_COLOR=1, TERM=xterm-256color, COLUMNS= during execution diff --git a/pdd/prompts/update_main_python.prompt b/pdd/prompts/update_main_python.prompt index 7e53ef339..590c3a9d8 100644 --- a/pdd/prompts/update_main_python.prompt +++ b/pdd/prompts/update_main_python.prompt @@ -38,6 +38,7 @@ agentic_update_python.prompt sync_determine_operation_python.prompt operation_log_python.prompt +fingerprint_transaction_python.prompt % Goal Write `pdd/update_main.py` — the CLI wrapper for updating prompts based on modified code, featuring repository-wide scanning, agentic routing, and post-update synchronization. @@ -66,15 +67,13 @@ Supports three modes: true update, regeneration, and repository-wide updates. Ro - **Failure reporting**: When any stage in `MetadataSyncResult` is not `ok`, print a Rich error line per failed stage with the stage name and reason. The `metadata` column makes it obvious which layer is incomplete. - **Backward compatibility**: When `sync_metadata=False` (default), behavior is unchanged for repo mode — it keeps its current post-update fingerprint and architecture-sync calls. Single-file and regeneration modes still run the default fingerprint finalization defined in Requirement 15 (this is the only metadata write they perform by default). 15. Default fingerprint finalization for single-file and regeneration modes: - - After a successful single-file or regeneration update, after the prompt has been written and before returning a non-None `(prompt, cost, model)` tuple, write a current fingerprint for the affected `(prompt, code)` pair. - - Reuse `infer_module_identity(prompt_path)` and `save_fingerprint(...)` from `pdd.operation_log`; do not introduce a parallel fingerprint implementation. Pass `paths={"prompt": Path(prompt_path), "code": Path(code_path)}` (omitting `"code"` when code path is not available) to `save_fingerprint` so `.pdd/meta` anchors at the subproject root when running from a parent directory (issue #1211). - - Skip this default finalization in repo mode because repo mode already performs post-update fingerprinting. - - If `sync_metadata=True`, do not double-write and print `[info][metadata] Skipping fingerprint finalization: orchestrator owns fingerprint stage[/info]` unless `quiet`. - - If `dry_run=True`, do not finalize metadata and print `[info][metadata] Skipping fingerprint finalization: dry-run mode[/info]` unless `quiet`. - - If the written prompt path differs from the canonical source prompt (i.e. `--output` redirected the write away from the input prompt in true-update mode), do not finalize metadata and print `[info][metadata] Skipping fingerprint finalization: output redirected[/info]` unless `quiet`. This guard applies in addition to (and independently of) the `sync_metadata=True` skip — when both are set, the orchestrator call itself is also skipped per Requirement 14 so neither layer records a fingerprint against the redirected path. - - If `infer_module_identity(prompt_path)` returns `(None, None)`, do not finalize metadata and print `[info][metadata] Skipping fingerprint finalization: unable to infer module identity for [/info]` unless `quiet`. - - If `save_fingerprint` raises, keep the successful return tuple and print `[warning][metadata] Fingerprint save failed: [/warning]` unless `quiet`. - - Stale `_run.json` cleanup MUST reuse `pdd.operation_log._clear_run_report_before_fingerprint(basename, language, paths={"prompt": Path(prompt_path), "code": Path(code_path)})` (omitting `"code"` when unavailable), which clears the run report then re-checks existence and returns `False` (with a yellow console warning) when a silent `os.remove` left it on disk. When the helper returns `False`, skip `save_fingerprint` so a fresh fingerprint never coexists with stale runtime state (issue #1106; mirrors repo-mode and the `log_operation` decorator). The helper's warning surfaces unconditionally — it does NOT honour the caller's `quiet` flag, intentionally, because it describes a real metadata problem. Wrap the helper call in `try/except`: on an unexpected exception, emit `[warning][metadata] Run report clear failed: [/warning]` unless `quiet` and skip `save_fingerprint` (best-effort cleanup must never break the successful update tuple). The function-local `from .operation_log import (_clear_run_report_before_fingerprint, infer_module_identity, save_fingerprint)` MUST also be wrapped in `try/except ImportError`: on ImportError, emit `[warning][metadata] Could not import finalization helpers: [/warning]` unless `quiet` and return without calling `save_fingerprint`. This is required because `_clear_run_report_before_fingerprint` is a private underscore-prefixed symbol and therefore more fragile to internal `operation_log` renames than the public names alongside it — without the import guard, an ImportError would propagate to `update_main`'s outer `except Exception: return None` and silently convert a successful `(prompt, cost, model)` tuple to `None`, violating the "best-effort metadata cleanup must never break the successful update tuple" contract. + - After a successful single-file or regeneration update, after the prompt has been written and before returning a non-None `(prompt, cost, model)` tuple, call one `_finalize_single_file_fingerprint` helper. It delegates persistence to `operation_log.save_fingerprint`, the shared `FingerprintTransaction` wrapper. + - Resolve identity with `infer_module_identity(prompt_path)`, then build a complete unit mapping with `resolve_fingerprint_paths(basename, language, prompt_path, paths={"prompt": Path(prompt_path), "code": Path(code_path)})`. The explicit written paths remain authoritative while existing example/test hashes are preserved, and the prompt path anchors `.pdd/meta` at a nested subproject (issues #1211/#1290). + - Repo mode invokes the same helper once per successful pair from its post-update loop; individual pair workers do not finalize a second time. + - Intentional non-mutating conditions return before finalization: `sync_metadata=True` (orchestrator owns the stage), `dry_run=True`, or redirected output. Print the existing informational skip line unless `quiet`. + - Inability to infer identity is NOT an intentional skip after a real write: raise `FingerprintFinalizeError("update", prompt_path, "unable to infer module identity")`. + - Stale `_run.json` cleanup uses `_clear_run_report_before_fingerprint(..., paths=complete_paths)`. An exception or a false return becomes `FingerprintFinalizeError`; never warn-and-return after the artifact mutated. The helper's own warning about a surviving report remains visible even under quiet mode. + - Import failures for finalization helpers are wrapped as `FingerprintFinalizeError`. `save_fingerprint` errors propagate. At the command boundary, print the explicit diagnostic and raise `click.exceptions.Exit(1)` so every single-file and repo-mode finalization failure is non-zero. % Modes 1. **True update**: `input_prompt_file` + `modified_code_file` + (use_git OR `input_code_file`). @@ -113,7 +112,7 @@ Supports three modes: true update, regeneration, and repository-wide updates. Ro - After resolving pairs, call `ensure_pddrc_for_scan` from `pddrc_initializer` to auto-create `.pddrc` if needed. - **Change detection**: Use `get_git_changed_files` + `is_code_changed` to filter pairs. Also include pairs with empty (0-byte) prompt files regardless of code changes. Include dependency hashes from fingerprints are checked so that changes to shared include files (preambles, examples) trigger updates. - Progress bar: Rich Progress with spinner, bar, percentage, time remaining, and running total cost. -- **Post-update fingerprinting** (legacy, `sync_metadata=False` only): After each successful pair update, infer module identity via `infer_module_identity`, then call `clear_run_report(basename, language, paths={"prompt": Path(prompt_path), "code": Path(code_path)})` BEFORE `save_fingerprint` so stale `.pdd/meta/__run.json` runtime-verification state from before the mutation is invalidated rather than left to coexist with the freshly written fingerprint. Pass the same `paths` hint to `get_run_report_path` and `save_fingerprint` so all metadata writes anchor at the subproject root (issue #1211). Wrap `clear_run_report` in its own try/except and surface failures as a non-fatal warning (mirroring the single-file finalize path in `_finalize_target_fingerprint`); after the clear attempt, re-check `get_run_report_path(basename, language, paths=...).exists()` and **skip `save_fingerprint` for that pair (with a yellow warning) when the stale `_run.json` still exists** — this is the safer behavior required by issue #1057 so a fresh fingerprint can never coexist with stale runtime-verification state. When `sync_metadata=True`, the per-pair `run_metadata_sync` call replaces this — do NOT also call `clear_run_report` or `save_fingerprint` here, or the fingerprint will be written before the orchestrator's gates run and a stale fingerprint can record "in sync" even when an earlier metadata stage failed. +- **Post-update fingerprinting** (legacy, `sync_metadata=False` only): After every successful pair, call the same `_finalize_single_file_fingerprint` helper used by single-file modes. It resolves the complete unit paths, clears and verifies the stale run report, and delegates to the shared save wrapper. Any `FingerprintFinalizeError` prints an explicit diagnostic and raises `click.exceptions.Exit(1)`; repo mode may not keep processing after a pair mutated without a durable fingerprint. When `sync_metadata=True`, the per-pair orchestrator replaces this path, so do not also finalize early. - **Post-update architecture sync** (legacy, `sync_metadata=False` only): Use `find_architecture_for_project` to locate architecture files; for each successfully updated prompt, call `update_architecture_from_prompt` if architecture file exists. When `sync_metadata=True`, this is handled inside `run_metadata_sync` (which also gates the write on prior-stage success), so do NOT call it again from `update_main`. - **Post-update PRD sync** (both branches): If architecture entries were updated, find PRD file and use `run_agentic_task` to review/update PRD content. Unpack the agent result as `(llm_success, llm_output, llm_cost, _llm_model)`, add `llm_cost` to the repository total when present, and treat `llm_success=False` as an error status without writing the PRD. Parse `...` tags from `llm_output` only when the call succeeded; otherwise leave the PRD unchanged. PRD sync stays in `update_main` even under `sync_metadata=True` — the orchestrator deliberately does NOT call it (see Requirement 11). Key off the count of pairs whose `MetadataSyncResult.stages['architecture']` reported `updated`/`ok` (or, in the legacy branch, off the count returned by the inline arch loop). - Summary table: `prompt file`, `status`, `cost`, `model`, `error` columns. When `sync_metadata=True`, append a `metadata` column (one of `synced`, `partial:`, `failed:`, `skipped`, `dry-run`) so partial heals surface visibly. Show architecture/PRD status if relevant. diff --git a/pdd/sync_determine_operation.py b/pdd/sync_determine_operation.py index 99e0e35a2..4c5a5d358 100644 --- a/pdd/sync_determine_operation.py +++ b/pdd/sync_determine_operation.py @@ -43,6 +43,13 @@ _load_pddrc_config, construct_paths, ) +from pdd.content_selector import ( + _contained_in_root, + _pins_test_output_location, + _validated_project_path, + configured_test_output_pinned, + resolve_test_output_path, +) from pdd.load_prompt_template import load_prompt_template from pdd.llm_invoke import llm_invoke from pdd.get_language import get_language @@ -1112,8 +1119,20 @@ def _generate_paths_from_templates( return result -def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts", context_override: Optional[str] = None) -> Dict[str, Path]: - """Returns a dictionary mapping file types to their expected Path objects. +def _get_pdd_file_paths_uncollocated(basename: str, language: str, prompts_dir: str = "prompts", context_override: Optional[str] = None) -> Tuple[Dict[str, Path], bool]: + """Derive PDD file paths and the explicit-vs-default test-output signal. + + Raw path derivation delegate for :func:`get_pdd_file_paths`. Returns + ``(result, test_output_pinned)`` where *test_output_pinned* is True when the + user explicitly pinned the test-output location for THIS module — read from + the SAME authoritative config each branch derives paths from (``.pddrc`` + context ``defaults.test_output_path`` / ``defaults.outputs.test.path``, or + the ``PDD_TEST_OUTPUT_PATH`` env var). The public wrapper uses it so the + runner-collected co-located test adoption (issue #1903) never overrides an + explicit test path. Computing it here (rather than re-resolving the context + from the bare basename/CWD) keeps a single source of truth and avoids a + context divergence when a ``prompts_dir``-anchored context differs from the + CWD default. Issue #225: Now checks architecture.json for filepath field before falling back to .pddrc configuration. This allows complex directory structures @@ -1129,6 +1148,15 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts logger = logging.getLogger(__name__) logger.info(f"get_pdd_file_paths called: basename={basename}, language={language}, prompts_dir={prompts_dir}") + # Issue #1903: whether the test-output location is user-pinned. Seeded from + # the (global) env var so it is defined even if an exception fires before the + # prompt is resolved; refined ONCE below from the RAW .pddrc defaults of the + # module's context (never construct_paths' resolved_config, which injects a + # generated-default test_output_path and would always read as pinned). + # Presence (not truthiness): an explicit `PDD_TEST_OUTPUT_PATH=` (even empty) is a + # user pin, matching `_pins_test_output_location`'s empty-`.pddrc` handling (#1903). + test_output_pinned = os.environ.get("PDD_TEST_OUTPUT_PATH") is not None + try: # Use construct_paths to get configuration-aware paths prompts_root = _resolve_prompts_root(prompts_dir) @@ -1208,6 +1236,18 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts logger.info(f"Checking prompt_path={prompt_path}, exists={Path(prompt_path).exists()}") + # Issue #1903: resolve the explicit-vs-default test-output provenance ONCE, + # from the RAW .pddrc defaults of the context that owns this module's prompt + # (context_override/resolved_context_name when known, else path-detected the + # way construct_paths detects it, else the `default` context). Every branch + # below returns this single value so they all agree, and none re-reads the + # polluted resolved_config that construct_paths returns. + test_output_pinned = configured_test_output_pinned( + prompt_path, + context_override=context_override or resolved_context_name, + search_from=prompts_root_anchor, + ) + # If architecture.json has a filepath, use it for code/test/example paths arch_filepath = None if arch_path: @@ -1237,6 +1277,18 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts example_dir = "examples/" test_dir = "tests/" generate_dir = "" + # Issue #1903 finding 1: the co-located-test adoption pin for THIS + # branch must be read from the SAME context the branch derives + # `test_dir` from. When no context_override/resolved_context_name is + # known, that context is DETECTED FROM THE ARCH CODE PATH just below + # (not the prompt side), so the early prompt-side `test_output_pinned` + # can miss an explicit `test_output_path` configured on the + # code-path-matched context and let adoption silently override it. + # Recompute a branch-local pin from that context's raw defaults, + # seeded from the env override. + arch_test_output_pinned = ( + os.environ.get("PDD_TEST_OUTPUT_PATH") is not None + ) if pddrc_path: try: config = _load_pddrc_config(pddrc_path) @@ -1249,6 +1301,10 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts ) context_config = config.get('contexts', {}).get(context_name or '', {}) defaults = context_config.get('defaults', {}) + arch_test_output_pinned = ( + arch_test_output_pinned + or _pins_test_output_location(defaults) + ) example_dir = defaults.get('example_output_path', 'examples/') test_dir = defaults.get('test_output_path', 'tests/') generate_dir = defaults.get('generate_output_path', '') @@ -1326,7 +1382,7 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts 'test_files': matching_test_files or [test_path] } logger.info(f"get_pdd_file_paths returning (from architecture.json): {result}") - return result + return result, arch_test_output_pinned # Check if prompt file exists - if not, we still need configuration-aware paths if not Path(prompt_path).exists(): @@ -1372,7 +1428,7 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts context_name=context_name, ) logger.debug(f"get_pdd_file_paths returning (template-based): {result}") - return result + return result, test_output_pinned # Legacy path construction (backwards compatibility) # Extract directory configuration from resolved_config @@ -1467,7 +1523,7 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts 'test_files': matching_test_files or [test_path] # Bug #156 } logger.debug(f"get_pdd_file_paths returning (prompt missing): test={test_path}") - return result + return result, test_output_pinned except Exception as e: # If construct_paths fails, fall back to current directory paths # This maintains backward compatibility @@ -1487,7 +1543,7 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts 'example': Path(f"{dir_prefix}{name_part}_example{_dot(extension)}"), 'test': fallback_test_path, 'test_files': fallback_matching or [fallback_test_path] # Bug #156 - } + }, test_output_pinned input_file_paths = { "prompt_file": prompt_path @@ -1528,7 +1584,7 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts context_name=context_name, ) logger.debug(f"get_pdd_file_paths returning (template-based, prompt exists): {result}") - return result + return result, test_output_pinned # For sync command, output_file_paths contains the configured paths # Extract the code path from output_file_paths @@ -1677,7 +1733,7 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts 'example': example_path, 'test': test_path, 'test_files': matching_test_files or [test_path] # Bug #156: All matching test files - } + }, test_output_pinned except AmbiguousModuleError: # Issue #1677: ambiguity is a hard, actionable error — never let the broad @@ -1710,7 +1766,70 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts 'example': Path(f"{dir_prefix}{name_part}_example{_dot(extension)}"), 'test': test_path, 'test_files': matching_test_files or [test_path] # Bug #156: All matching test files - } + }, test_output_pinned + + +def _adopt_collocated_test(result: Dict[str, Path], *, user_pinned: bool) -> Dict[str, Path]: + """Retarget *result* onto a single existing co-located test (issue #1903). + + PDD derives its test path from ``.pddrc`` / defaults, blind to the project's + real test runner, so on a jest/Next.js project it maintains a ``tests/`` + shadow while the co-located test the runner collects goes stale (a + false-green). When the module has exactly one unambiguous co-located test, + replace the derived ``test`` shadow with it and make it the sole + ``test_files`` entry. + + *user_pinned* carries the real explicit-vs-default provenance computed by + :func:`_get_pdd_file_paths_uncollocated` from the SAME resolved context/config + that produced ``result['test']``: an explicitly configured test-output + location is never overridden. Never raises; on any error the original result + is returned. + """ + try: + code_path = result.get('code') + derived_test = result.get('test') + if code_path is None or derived_test is None: + return result + adopted = resolve_test_output_path( + code_path, derived_test, user_pinned=user_pinned + ) + root_resolved = Path.cwd().resolve() + adopted_resolved = _validated_project_path(adopted, root=root_resolved) + if adopted_resolved is None: + return result + # Defense in depth (CWE-022, PR #1914 CodeQL): the adopted path becomes + # the generated-test WRITE target (result['test']/['test_files']); only + # accept it when it stays inside the working tree. + if not _contained_in_root(adopted_resolved, root_resolved): + return result + derived_resolved = _validated_project_path( + derived_test, + root=root_resolved, + ) + if derived_resolved is not None and adopted_resolved == derived_resolved: + return result + result['test'] = adopted_resolved + result['test_files'] = [adopted_resolved] + return result + except Exception: # pylint: disable=broad-except + return result + + +def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts", context_override: Optional[str] = None) -> Dict[str, Path]: + """Return a dict mapping file types to their expected Path objects. + + Derives the raw paths via :func:`_get_pdd_file_paths_uncollocated` (which also + reports whether the test-output location is explicitly pinned), then adopts a + runner-collected co-located test as the canonical ``test`` path (issue #1903) + so sync/change target the real test CI runs instead of a runner-blind + ``tests/`` shadow — unless the test-output location was explicitly configured, + which is always honored. See the delegate for the full derivation contract and + priority order. + """ + result, test_output_pinned = _get_pdd_file_paths_uncollocated( + basename, language, prompts_dir, context_override=context_override + ) + return _adopt_collocated_test(result, user_pinned=test_output_pinned) def calculate_sha256(file_path: Path) -> Optional[str]: diff --git a/pdd/sync_main.py b/pdd/sync_main.py index 8ca29b5a1..c8f72cdff 100644 --- a/pdd/sync_main.py +++ b/pdd/sync_main.py @@ -610,6 +610,7 @@ def sync_main( agentic_mode: bool = False, one_session: bool = False, compress: bool = False, + fresh: bool = False, evidence: bool = False, snapshot_context: bool = False, compressed_context: bool = False, @@ -1064,7 +1065,14 @@ def _one_session_compressed_context( prompt_file=str(pdd_files["prompt"].resolve()), output=str(pdd_files["code"].resolve()), original_prompt_file_path=None, - force_incremental_flag=False, + # Surgical (edit-shaped) regeneration by + # default for mature modules; --fresh + # (fresh=True) restores full regeneration + # (#1938 Pillar A). code_generator_main + # still falls back to full generation for + # new/empty modules or when the original + # prompt can't be determined. + force_incremental_flag=not fresh, language=resolved_language, # output is .pddrc-derived (get_pdd_file_paths uses # context config), so let front-matter override it. @@ -1272,6 +1280,7 @@ def _one_session_compressed_context( agentic_mode=agentic_mode, snapshot_context=snapshot_context, compressed_context=compressed_context, + fresh=fresh, ) # Post-sync: auto-submit example to cloud on success (multi-step path) diff --git a/pdd/sync_orchestration.py b/pdd/sync_orchestration.py index 4e7e70c14..6047c6a20 100644 --- a/pdd/sync_orchestration.py +++ b/pdd/sync_orchestration.py @@ -38,13 +38,13 @@ update_log_entry, append_log_entry, log_event, - save_fingerprint, save_run_report, clear_run_report, get_log_path, get_run_report_path, get_fingerprint_path, ) +from .json_atomic import atomic_write_json from .sync_determine_operation import ( sync_determine_operation, get_pdd_file_paths, @@ -454,6 +454,7 @@ class PendingStateUpdate: fingerprint: Optional[Dict[str, Any]] = None run_report_path: Optional[Path] = None fingerprint_path: Optional[Path] = None + fingerprint_operation: Optional[str] = None @dataclass @@ -500,41 +501,75 @@ def set_run_report(self, report: Dict[str, Any], path: Path): self.pending.run_report = report self.pending.run_report_path = path - def set_fingerprint(self, fingerprint: Dict[str, Any], path: Path): + def set_fingerprint( + self, + fingerprint: Dict[str, Any], + path: Path, + *, + operation: Optional[str] = None, + ): """Buffer a fingerprint for atomic write.""" self.pending.fingerprint = fingerprint self.pending.fingerprint_path = path + self.pending.fingerprint_operation = operation def _atomic_write(self, data: Dict[str, Any], target_path: Path) -> None: """Write data to file atomically using temp file + rename pattern.""" - target_path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(target_path, data) - # Write to temp file in same directory (required for atomic rename) - fd, temp_path = tempfile.mkstemp( - dir=target_path.parent, - prefix=f".{target_path.stem}_", - suffix=".tmp" - ) - self._temp_files.append(temp_path) + def _commit(self): + """Commit all pending state updates atomically.""" + snapshots: List[FileRollbackSnapshot] = [] + for path in ( + self.pending.fingerprint_path, + self.pending.run_report_path, + ): + if path is None: + continue + if path.exists(): + snapshots.append( + FileRollbackSnapshot(path=path, existed=True, content=path.read_bytes()) + ) + else: + snapshots.append(FileRollbackSnapshot(path=path, existed=False)) try: - with os.fdopen(fd, 'w') as f: - json.dump(data, f, indent=2, default=str) + # Write fingerprint first (checkpoint), then run_report. If either + # leg fails, restore both destinations from the snapshots above so + # the outer state context cannot leave a half-committed pair. + if self.pending.fingerprint and self.pending.fingerprint_path: + self._atomic_write( + self.pending.fingerprint, + self.pending.fingerprint_path, + ) + if self.pending.run_report and self.pending.run_report_path: + self._atomic_write( + self.pending.run_report, + self.pending.run_report_path, + ) + except Exception as exc: + for snapshot in reversed(snapshots): + try: + if snapshot.existed: + _atomic_write_bytes(snapshot.path, snapshot.content or b"") + elif snapshot.path.exists(): + snapshot.path.unlink() + except OSError as rollback_exc: + logger.error( + "Failed to roll back atomic metadata path %s: %s", + snapshot.path, + rollback_exc, + ) - # Atomic rename - guaranteed atomic on POSIX systems - os.replace(temp_path, target_path) - self._temp_files.remove(temp_path) # Successfully moved, stop tracking - except Exception: - # Leave temp file for rollback to clean up - raise + if self.pending.fingerprint_path is not None: + from .fingerprint_transaction import FingerprintFinalizeError - def _commit(self): - """Commit all pending state updates atomically.""" - # Write fingerprint first (checkpoint), then run_report - if self.pending.fingerprint and self.pending.fingerprint_path: - self._atomic_write(self.pending.fingerprint, self.pending.fingerprint_path) - if self.pending.run_report and self.pending.run_report_path: - self._atomic_write(self.pending.run_report, self.pending.run_report_path) + raise FingerprintFinalizeError( + self.pending.fingerprint_operation or "unknown", + self.pending.fingerprint_path, + f"atomic state commit failed: {exc}", + ) from exc + raise def _rollback(self): """Clean up any temp files without committing changes.""" @@ -682,43 +717,21 @@ def _save_fingerprint_atomic(basename: str, language: str, operation: str, 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, read_fingerprint - from . import __version__ - - # 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, paths=paths) - 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(), - command=operation, - prompt_hash=current_hashes.get('prompt_hash'), - code_hash=current_hashes.get('code_hash'), - 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 - ) - - # Issue #1211: route the atomic fingerprint file through the - # paths-aware helper so subprojects whose .pddrc is below run CWD - # get the file under /.pdd/meta, not parent CWD. - fingerprint_file = get_fingerprint_path(basename, language, paths=paths) - atomic_state.set_fingerprint(asdict(fingerprint), fingerprint_file) - else: - # Direct write using operation_log - save_fingerprint(basename, language, operation, paths, cost, model) + from .fingerprint_transaction import FingerprintTransaction + + transaction = FingerprintTransaction( + basename=basename, + language=language, + operation=operation, + paths=paths, + cost=cost, + model=model, + atomic_state=atomic_state, + ) + if include_deps_override is not None: + transaction.set_include_deps_override(include_deps_override) + with transaction: + pass def _python_cov_target_for_code_file(code_file: Path) -> str: """Return a `pytest-cov` `--cov` target for a Python code file. @@ -2003,12 +2016,22 @@ def sync_orchestration( steer_timeout: float = DEFAULT_STEER_TIMEOUT_S, agentic_mode: bool = False, compress: bool = False, + fresh: bool = False, evidence: bool = False, snapshot_context: bool = False, compressed_context: bool = False, ) -> Dict[str, Any]: """ Orchestrates the complete PDD sync workflow with parallel animation. + + ``fresh`` (issue #1938 Pillar A): when False (the default), the generate + operation regenerates mature modules surgically (edit-shaped) by driving + ``code_generator_main`` with ``force_incremental_flag=True`` so declared + public symbols are preserved instead of being dropped by a full "rebirth" + regeneration. Pass ``fresh=True`` (``pdd sync --fresh``) to restore full + regeneration. ``code_generator_main`` still falls back to full generation + for new/empty modules or when the original prompt cannot be determined, and + conformance repair retries still force full regeneration. """ # Handle None values from CLI (Issue #194) - defense in depth if target_coverage is None: @@ -2746,7 +2769,7 @@ def sync_worker_logic(): for _conform_attempt in range(MAX_CONFORMANCE_ATTEMPTS): try: # Use absolute paths to avoid path_resolution_mode mismatch between sync (cwd) and generate (config_base) - result = code_generator_main(ctx, prompt_file=str(pdd_files['prompt'].resolve()), output=str(pdd_files['code'].resolve()), original_prompt_file_path=None, force_incremental_flag=False, output_from_config=True, compress=compress, snapshot_context=snapshot_context, compressed_context=_phase_compressed_context('generate', os.environ.get("PDD_REPAIR_DIRECTIVE"))) + result = code_generator_main(ctx, prompt_file=str(pdd_files['prompt'].resolve()), output=str(pdd_files['code'].resolve()), original_prompt_file_path=None, force_incremental_flag=not fresh, output_from_config=True, compress=compress, snapshot_context=snapshot_context, compressed_context=_phase_compressed_context('generate', os.environ.get("PDD_REPAIR_DIRECTIVE"))) last_conform_exc = None break except ( diff --git a/pdd/sync_tui.py b/pdd/sync_tui.py index 4a1c1918c..f09af1d13 100644 --- a/pdd/sync_tui.py +++ b/pdd/sync_tui.py @@ -22,6 +22,7 @@ # Reuse existing animation logic from .sync_animation import AnimationState, _render_animation_frame, DEEP_NAVY, ELECTRIC_CYAN +from .agentic_common import get_disabled_providers, provider_failure_scope from . import logo_animation from rich.style import Style @@ -1163,6 +1164,10 @@ def __init__( self.basename = basename self.budget = budget self.worker_func = worker_func + # ContextVars do not propagate into Textual's worker thread. Snapshot + # the parent workflow state now and seed a thread-owned scope when the + # worker starts so every agentic step shares the same health epoch. + self._initial_disabled_providers = get_disabled_providers() # Shared state refs self.function_name_ref = function_name_ref @@ -1341,6 +1346,18 @@ def on_resize(self, event) -> None: @work(thread=True) def run_worker_task(self) -> None: """Runs the sync logic in a separate thread, capturing stdout/stderr/stdin.""" + self._run_worker_body() + + def _run_worker_body(self) -> None: + """Execute the sync worker logic. + + Kept separate from the ``@work(thread=True)`` wrapper so the real worker + body can run synchronously in tests / non-interactive contexts. Newer + Textual releases require a running event loop to schedule a thread + worker, so invoking the decorated ``run_worker_task`` directly (without a + live app) raises ``RuntimeError: no running event loop``. The body itself + already guards every app-dependent branch on ``self.is_running``. + """ # Set app reference for stdin redirector self._app_ref[0] = self @@ -1385,7 +1402,8 @@ def run_worker_task(self) -> None: self._stdin_redirector = None try: - self.worker_result = self.worker_func() + with provider_failure_scope(self._initial_disabled_providers): + self.worker_result = self.worker_func() except EOFError as e: # Handle EOF from stdin redirector - input was needed but cancelled/failed self.worker_exception = e diff --git a/pdd/update_main.py b/pdd/update_main.py index 0543cbb2f..c06aef22a 100644 --- a/pdd/update_main.py +++ b/pdd/update_main.py @@ -34,6 +34,9 @@ from .agentic_update import run_agentic_update from .sync_determine_operation import calculate_sha256, extract_include_deps, read_fingerprint from .validate_prompt_includes import sanitize_prompt_output +from .fingerprint_transaction import ( + FingerprintFinalizeError, +) from . import DEFAULT_TIME # Issue #1714: bound the PRD-sync agentic step below the 600s @@ -1080,16 +1083,15 @@ def _finalize_single_file_fingerprint( ) -> None: """Default fingerprint finalization for single-file/regeneration update modes. - Writes a current `(prompt, code)` fingerprint via the existing - `pdd.operation_log` helpers so a successful `pdd update ` is not - re-detected as changed on the next run (issue #1007 / PR #1009 - Requirement 15). Skips with an `[info]` log line — unless `quiet` — for - the intentional skip cases (sync_metadata orchestrator owns the stage, - dry-run mode, `--output` redirected the write away from the canonical - source prompt, or `infer_module_identity` cannot derive - basename/language). All failures are best-effort: a `save_fingerprint` - exception surfaces as a `[warning]` line but never breaks the caller's - success tuple. + Writes a complete unit fingerprint through the shared operation-log + transaction so a successful `pdd update ` is not re-detected as + changed on the next run (issue #1007 / PR #1009 Requirement 15). Only + genuinely non-mutating paths skip: the metadata orchestrator owns the + stage, dry-run mode, or `--output` redirected the write away from the + canonical source prompt. Identity, run-report cleanup, path resolution, + and persistence failures are hard ``FingerprintFinalizeError`` failures; + a real artifact mutation may not retain the caller's success tuple unless + its fingerprint commits. ``source_prompt_path`` is the canonical input prompt for the module. When callers pass a redirected output path via ``--output``, the written file @@ -1121,37 +1123,27 @@ def _finalize_single_file_fingerprint( ) return - # Wrap the import itself so the user's successful update tuple is never - # broken by an import-time failure (e.g. `_clear_run_report_before_fingerprint` - # gets renamed in a future operation_log refactor — it's a private - # underscore-prefixed name and therefore more fragile than the public - # `clear_run_report` / `infer_module_identity` / `save_fingerprint` - # alongside it). An ImportError raised here would propagate up to - # `update_main`'s outer `except Exception: return None`, converting a - # successful `(prompt, cost, model)` tuple to None — which violates the - # issue #1106 acceptance criterion: best-effort metadata cleanup must - # never fail the successful update tuple. try: from .operation_log import ( _clear_run_report_before_fingerprint, + get_fingerprint_path, infer_module_identity, + resolve_fingerprint_paths, save_fingerprint, ) except ImportError as exc: - if not quiet: - rprint( - f"[warning][metadata] Could not import finalization helpers: " - f"{exc}[/warning]" - ) - return + raise FingerprintFinalizeError( + "update", + prompt_path, + f"could not import finalization helpers: {exc}", + ) from exc basename, language = infer_module_identity(prompt_path) if not (basename and language): - if not quiet: - rprint( - "[info][metadata] Skipping fingerprint finalization: " - f"unable to infer module identity for {prompt_path}[/info]" - ) - return + raise FingerprintFinalizeError( + "update", + prompt_path, + "unable to infer module identity", + ) # Reuse the shared helper so the single-file finalize path enforces the # same invariant the `log_operation` decorator and repo-mode block already @@ -1171,24 +1163,30 @@ def _finalize_single_file_fingerprint( # nearest .pddrc), not a parent CWD orphan. Without this we cleared # parent metadata while writing the fresh fingerprint to the subproject, # leaving stale subproject _run.json beside it. - update_paths = {"prompt": Path(prompt_path), "code": Path(code_path)} + update_paths = resolve_fingerprint_paths( + basename, + language, + prompt_path, + paths={"prompt": Path(prompt_path), "code": Path(code_path)}, + ) try: fingerprint_allowed = _clear_run_report_before_fingerprint( - basename, language, paths=update_paths + basename, + language, + paths=update_paths, ) except Exception as exc: - # Defensive: surrounding pattern in this function treats metadata - # cleanup as best-effort; an unexpected raise must not break the - # successful update tuple. Warn and skip the save, matching the - # `save_fingerprint` except-arm below. - if not quiet: - rprint( - f"[warning][metadata] Run report clear failed: {exc}[/warning]" - ) - return + raise FingerprintFinalizeError( + "update", + get_fingerprint_path(basename, language, paths=update_paths), + f"run report clear failed: {exc}", + ) from exc if not fingerprint_allowed: - return - + raise FingerprintFinalizeError( + "update", + get_fingerprint_path(basename, language, paths=update_paths), + "run report not cleared", + ) try: save_fingerprint( basename, @@ -1198,9 +1196,14 @@ def _finalize_single_file_fingerprint( cost=cost, model=model, ) + except FingerprintFinalizeError: + raise except Exception as exc: - if not quiet: - rprint(f"[warning][metadata] Fingerprint save failed: {exc}[/warning]") + raise FingerprintFinalizeError( + "update", + get_fingerprint_path(basename, language, paths=update_paths), + exc, + ) from exc def update_main( @@ -1385,85 +1388,24 @@ def update_main( if not sync_metadata: # Save fingerprint so the file isn't detected as changed next run if "Success" in result.get("status", ""): - from .operation_log import ( - clear_run_report, - get_run_report_path, - infer_module_identity, - save_fingerprint, - ) - basename, language = infer_module_identity(prompt_path) - if basename and language: - # Issue #1211: route all three metadata calls - # (get_run_report_path / clear_run_report / - # save_fingerprint) through the same `paths` hint - # so they hit the subproject .pdd/meta — not a - # parent CWD orphan — when the user invokes - # update from above the subproject root. - _update_paths = { - "prompt": Path(prompt_path), - "code": Path(code_path), - } - # Clear stale run report first so it can't outlive - # the prompt/code pair it described. Best-effort: - # never fail the update because of metadata I/O, - # but surface failures as a non-fatal warning so - # the user knows runtime verification state may - # still describe the pre-mutation files. - try: - _stale_report_path = get_run_report_path( - basename, language, paths=_update_paths - ) - except Exception: - _stale_report_path = None - _pre_existed = bool( - _stale_report_path is not None - and _stale_report_path.exists() + try: + _finalize_single_file_fingerprint( + Path(prompt_path), + Path(code_path), + sync_metadata=False, + dry_run=False, + quiet=quiet, + cost=result.get("cost", 0.0), + model=result.get("model", "unknown"), + source_prompt_path=Path(prompt_path), ) - try: - clear_run_report(basename, language, paths=_update_paths) - except Exception as exc: - if not quiet: - rprint( - f"[warning][metadata] Run report clear failed for " - f"{basename} ({language}): {exc}[/warning]" - ) - # Defensive: clear_run_report() in pdd.operation_log - # silently swallows OSError on the actual unlink - # (see pdd/operation_log.py:317-320), so if the - # report file existed before the call but still - # exists afterwards, the deletion failed silently. - # Surface that as a non-fatal warning so the user - # knows runtime verification state may still - # describe the pre-mutation files. - _stale_remains = False - if _pre_existed and _stale_report_path is not None: - try: - _still_there = _stale_report_path.exists() - except Exception: - _still_there = False - if _still_there: - _stale_remains = True - if not quiet: - rprint( - f"[warning][metadata] Run report clear failed for " - f"{basename} ({language}): " - f"still exists after clear_run_report: " - f"{_stale_report_path}; skipping fingerprint update so a " - f"fresh fingerprint does not coexist with a stale " - f"run report (issue #1057)." - f"[/warning]" - ) - if not _stale_remains: - try: - save_fingerprint( - basename, language, - operation="update", - paths=_update_paths, - cost=result.get("cost", 0.0), - model=result.get("model", "unknown"), - ) - except Exception: - pass # Best-effort; don't fail the update + except FingerprintFinalizeError as exc: + if not quiet: + rprint( + "[bold red]Fingerprint finalization failed:" + f"[/bold red] {exc}" + ) + raise click.exceptions.Exit(1) from exc else: if "Success" in result.get("status", ""): try: @@ -2038,6 +1980,10 @@ def _metadata_column_value(prompt_file_key: str) -> str: # (#871). Letting the bare `except Exception` below swallow this would # silently convert it to exit 0. raise + except FingerprintFinalizeError as e: + if not quiet: + rprint(f"[bold red]Fingerprint finalization failed:[/bold red] {e}") + raise click.exceptions.Exit(1) from e except Exception as e: if not quiet: rprint(f"[bold red]Error:[/bold red] {str(e)}") diff --git a/pyproject.toml b/pyproject.toml index 6a527281d..0cef15caa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dependencies = [ "litellm[caching]>=1.84.0,<1.85", "lxml>=5.0.0", "rich==15.0.0", + "regex>=2024.0.0", "semver==3.0.4", "setuptools", "starlette>=0.37.2", diff --git a/tests/commands/test_drift_cli.py b/tests/commands/test_drift_cli.py index 429ee4e67..01326613e 100644 --- a/tests/commands/test_drift_cli.py +++ b/tests/commands/test_drift_cli.py @@ -1,4 +1,5 @@ """CLI smoke tests for ``pdd checkup drift`` (PR #1261 manual test plan).""" + from __future__ import annotations import hashlib @@ -49,6 +50,35 @@ def _write_smoke_project(project: Path) -> None: ) +def _write_nested_project( + project: Path, + *, + prompt_root: str = "prompts/src", + prompt_name: str = "widget_Python.prompt", + code_path: str = "generated/widget_impl.py", +) -> tuple[Path, Path]: + prompt = project / prompt_root / prompt_name + prompt.parent.mkdir(parents=True, exist_ok=True) + prompt.write_text("\nNested widget module.\n\n", encoding="utf-8") + code = project / code_path + code.parent.mkdir(parents=True, exist_ok=True) + code.write_text("def widget() -> str:\n return 'ok'\n", encoding="utf-8") + (project / ".pddrc").write_text( + "version: '1.0'\n" + "contexts:\n" + " nested:\n" + " paths:\n" + f" - '{prompt_root}/**'\n" + f" - '{Path(code_path).parent.as_posix()}/**'\n" + " defaults:\n" + f" prompts_dir: '{prompt_root}'\n" + f" generate_output_path: '{Path(code_path).parent.as_posix()}'\n" + " default_language: python\n", + encoding="utf-8", + ) + return prompt, code + + @pytest.fixture def runner() -> CliRunner: return CliRunner() @@ -62,7 +92,9 @@ def test_checkup_drift_help(runner: CliRunner) -> None: assert "--json" in result.output -def test_checkup_drift_dry_run_json(runner: CliRunner, tmp_path: Path, monkeypatch) -> None: +def test_checkup_drift_dry_run_json( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: _write_smoke_project(tmp_path) monkeypatch.chdir(tmp_path) result = runner.invoke( @@ -76,10 +108,14 @@ def test_checkup_drift_dry_run_json(runner: CliRunner, tmp_path: Path, monkeypat assert payload["dry_run"] is True -def test_checkup_drift_from_evidence_json(runner: CliRunner, tmp_path: Path, monkeypatch) -> None: +def test_checkup_drift_from_evidence_json( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: _write_smoke_project(tmp_path) monkeypatch.chdir(tmp_path) - manifest = tmp_path / ".pdd" / "evidence" / "devunits" / "refund_payment.latest.json" + manifest = ( + tmp_path / ".pdd" / "evidence" / "devunits" / "refund_payment.latest.json" + ) result = runner.invoke( checkup, [ @@ -153,7 +189,7 @@ def _fake_drift_main(args, **kwargs): return 0 with patch("pdd.commands.checkup.drift_cmd.main", side_effect=_fake_drift_main): - result = runner.invoke( + runner.invoke( checkup, ["drift", "refund_payment", "--preview"], catch_exceptions=False, @@ -179,3 +215,352 @@ def test_explicit_dry_run_still_forwarded_to_drift( assert result.exit_code == 0, result.output payload = json.loads(result.output) assert payload["dry_run"] is True + + +def test_checkup_drift_accepts_explicit_nested_prompt_and_authoritative_code_file( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + prompt, code = _write_nested_project(tmp_path) + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + checkup, + [ + "drift", + prompt.relative_to(tmp_path).as_posix(), + "--code-file", + code.relative_to(tmp_path).as_posix(), + "--dry-run", + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert Path(payload["prompt_path"]) == prompt.resolve() + assert Path(payload["code_path"]) == code.resolve() + assert payload["status"] == "stable" + + +def test_checkup_drift_resolves_nested_basename_from_active_pddrc( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + prompt, code = _write_nested_project( + tmp_path, + prompt_root="specs/backend", + prompt_name="widget_Python.prompt", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + checkup, + [ + "drift", + "widget", + "--code-file", + code.relative_to(tmp_path).as_posix(), + "--dry-run", + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert Path(payload["prompt_path"]) == prompt.resolve() + assert Path(payload["code_path"]) == code.resolve() + + +def test_checkup_drift_explicit_nested_prompt_derives_own_sibling_context_code( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + """A prompt path must retain its context when ``--code-file`` is omitted.""" + for context in ("backend", "frontend"): + prompt = tmp_path / "prompts" / context / "widget_Python.prompt" + prompt.parent.mkdir(parents=True) + prompt.write_text(f"{context} widget\n", encoding="utf-8") + code = tmp_path / "src" / context / "widget.py" + code.parent.mkdir(parents=True) + code.write_text( + f"def widget() -> str:\n return '{context}'\n", + encoding="utf-8", + ) + (tmp_path / ".pddrc").write_text( + "version: '1.0'\n" + "contexts:\n" + " backend:\n" + " paths:\n" + " - 'backend/**'\n" + " - 'src/backend/**'\n" + " - 'prompts/backend/**'\n" + " defaults:\n" + " prompts_dir: prompts/backend\n" + " generate_output_path: src/backend\n" + " default_language: python\n" + " frontend:\n" + " paths:\n" + " - 'frontend/**'\n" + " - 'src/frontend/**'\n" + " - 'prompts/frontend/**'\n" + " defaults:\n" + " prompts_dir: prompts/frontend\n" + " generate_output_path: src/frontend\n" + " default_language: python\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + checkup, + [ + "drift", + "prompts/frontend/widget_Python.prompt", + "--dry-run", + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert ( + Path(payload["prompt_path"]) + == (tmp_path / "prompts/frontend/widget_Python.prompt").resolve() + ) + assert Path(payload["code_path"]) == (tmp_path / "src/frontend/widget.py").resolve() + + +def test_checkup_drift_nested_prompt_does_not_fall_back_to_unrelated_flat_code( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + prompt = tmp_path / "prompts/frontend/widget_Python.prompt" + prompt.parent.mkdir(parents=True) + prompt.write_text("frontend widget\n", encoding="utf-8") + unrelated = tmp_path / "src/widget.py" + unrelated.parent.mkdir() + unrelated.write_text("def widget() -> str:\n return 'root'\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + "version: '1.0'\n" + "contexts:\n" + " frontend:\n" + " paths:\n" + " - 'frontend/**'\n" + " - 'prompts/frontend/**'\n" + " defaults:\n" + " prompts_dir: prompts/frontend\n" + " generate_output_path: src/frontend\n" + " default_language: python\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + checkup, + [ + "drift", + "prompts/frontend/widget_Python.prompt", + "--dry-run", + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["error"]["code"] == "drift_input_not_found" + assert "pass --code-file" in payload["error"]["message"] + + +def test_checkup_drift_matches_language_suffix_case_insensitively( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + prompt, code = _write_nested_project( + tmp_path, + prompt_name="widget_PyThOn.prompt", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + checkup, + [ + "drift", + "widget", + "--code-file", + code.relative_to(tmp_path).as_posix(), + "--dry-run", + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert Path(payload["prompt_path"]) == prompt.resolve() + + +def test_checkup_drift_json_resolution_failure_is_structured( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + checkup, + ["drift", "missing_widget", "--dry-run", "--json"], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload == { + "status": "error", + "error": { + "code": "drift_input_not_found", + "message": "Could not resolve prompt for dev unit 'missing_widget'", + }, + } + + +def test_checkup_drift_rejects_explicit_paths_outside_project_root( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + project = tmp_path / "project" + project.mkdir() + outside_prompt = tmp_path / "outside_Python.prompt" + outside_prompt.write_text("outside\n", encoding="utf-8") + code = project / "src" / "widget.py" + code.parent.mkdir() + code.write_text("def widget() -> None:\n pass\n", encoding="utf-8") + monkeypatch.chdir(project) + + result = runner.invoke( + checkup, + [ + "drift", + str(outside_prompt), + "--code-file", + str(code), + "--dry-run", + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["status"] == "error" + assert payload["error"]["code"] == "drift_input_outside_project" + + +def test_checkup_drift_rejects_explicit_code_outside_project_root( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + project = tmp_path / "project" + prompt = project / "prompts" / "widget_python.prompt" + prompt.parent.mkdir(parents=True) + prompt.write_text("widget\n", encoding="utf-8") + outside_code = tmp_path / "widget.py" + outside_code.write_text("def widget() -> None:\n pass\n", encoding="utf-8") + monkeypatch.chdir(project) + + result = runner.invoke( + checkup, + [ + "drift", + "prompts/widget_python.prompt", + "--code-file", + str(outside_code), + "--dry-run", + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["error"]["code"] == "drift_input_outside_project" + + +def test_checkup_drift_rejects_ambiguous_nested_basename( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + code = tmp_path / "src" / "widget.py" + code.parent.mkdir(parents=True) + code.write_text("def widget() -> None:\n pass\n", encoding="utf-8") + for prompt_dir in ("specs/backend", "specs/frontend"): + prompt = tmp_path / prompt_dir / "widget_python.prompt" + prompt.parent.mkdir(parents=True) + prompt.write_text("widget\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + "version: '1.0'\n" + "contexts:\n" + " backend:\n" + " defaults:\n" + " prompts_dir: specs/backend\n" + " default_language: python\n" + " frontend:\n" + " defaults:\n" + " prompts_dir: specs/frontend\n" + " default_language: python\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + checkup, + [ + "drift", + "widget", + "--code-file", + "src/widget.py", + "--dry-run", + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["error"]["code"] == "drift_input_ambiguous" + assert "specs/backend/widget_python.prompt" in payload["error"]["message"] + assert "specs/frontend/widget_python.prompt" in payload["error"]["message"] + + +def test_checkup_drift_flat_prompt_control_without_manifest( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + prompt = tmp_path / "prompts" / "widget_python.prompt" + prompt.parent.mkdir() + prompt.write_text("widget\n", encoding="utf-8") + code = tmp_path / "src" / "widget.py" + code.parent.mkdir() + code.write_text("def widget() -> str:\n return 'ok'\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + checkup, + ["drift", "widget", "--code-file", "src/widget.py", "--dry-run", "--json"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert Path(payload["prompt_path"]) == prompt.resolve() + assert Path(payload["code_path"]) == code.resolve() + + +def test_root_cli_preserves_structured_drift_json_failure( + runner: CliRunner, tmp_path: Path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + cli, + ["checkup", "drift", "missing_widget", "--dry-run", "--json"], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["status"] == "error" + assert payload["error"]["code"] == "drift_input_not_found" diff --git a/tests/commands/test_maintenance.py b/tests/commands/test_maintenance.py index c26180762..62f56675f 100644 --- a/tests/commands/test_maintenance.py +++ b/tests/commands/test_maintenance.py @@ -100,6 +100,51 @@ def test_sync_dry_run(self, runner, base_ctx_obj): assert result.exit_code == 0 assert mock_sm.call_args.kwargs["dry_run"] is True + def test_sync_fresh_flag_forwarded(self, runner, base_ctx_obj): + """`pdd sync --fresh` forwards fresh=True to sync_main; default is + fresh=False so mature modules regenerate surgically (#1938 Pillar A).""" + mock_result = ({"success": True}, 0.0, "none") + cli = _make_cli(sync, base_ctx_obj) + + with patch("pdd.commands.maintenance.sync_main", return_value=mock_result) as mock_sm: + result = runner.invoke(cli, ["sync", "calc", "--fresh"], catch_exceptions=False) + assert result.exit_code == 0 + assert mock_sm.call_args.kwargs["fresh"] is True + + with patch("pdd.commands.maintenance.sync_main", return_value=mock_result) as mock_sm: + result = runner.invoke(cli, ["sync", "calc"], catch_exceptions=False) + assert result.exit_code == 0 + assert mock_sm.call_args.kwargs["fresh"] is False + + def test_sync_fresh_rejected_on_global_sync(self, runner, base_ctx_obj): + """--fresh is single-module only: reject on no-argument global sync + instead of silently dropping it (#1938).""" + cli = _make_cli(sync, base_ctx_obj) + + with patch("pdd.commands.maintenance._run_global_sync_dispatch") as mock_global, \ + patch("pdd.commands.maintenance.run_agentic_sync") as mock_agentic: + result = runner.invoke(cli, ["sync", "--fresh"]) + + assert result.exit_code != 0 + assert "single-module sync" in result.output + mock_global.assert_not_called() + mock_agentic.assert_not_called() + + def test_sync_fresh_rejected_on_agentic_url_sync(self, runner, base_ctx_obj): + """--fresh is single-module only: reject on GitHub-issue agentic sync + instead of silently dropping it (#1938).""" + cli = _make_cli(sync, base_ctx_obj) + + with patch("pdd.commands.maintenance._is_github_issue_url", return_value=True), \ + patch("pdd.commands.maintenance.run_agentic_sync") as mock_agentic: + result = runner.invoke(cli, [ + "sync", "https://github.com/org/repo/issues/7", "--fresh", + ]) + + assert result.exit_code != 0 + assert "single-module sync" in result.output + mock_agentic.assert_not_called() + def test_sync_without_basename_dispatches_global_sync_not_durable( self, runner, diff --git a/tests/conftest.py b/tests/conftest.py index b108bb94e..b548f99eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -283,11 +283,21 @@ def _isolate_provider_env(monkeypatch): """ # Lazy import: avoid pulling litellm into conftest's import graph. # See module docstring for the rationale. - from pdd.agentic_common import _opencode_provider_env_keys + from pdd.agentic_common import ( + _opencode_provider_env_keys, + reset_disabled_providers, + ) for key in _opencode_provider_env_keys(): monkeypatch.delenv(key, raising=False) + # Issue #1936: the run-scoped permanent-provider-failure registry lives in a + # module global AND the PDD_AGENTIC_DISABLED_PROVIDERS env var. A test that + # induces a permanent provider error would otherwise leak a disabled provider + # into later tests in the same session. Clear both before every test. + monkeypatch.delenv("PDD_AGENTIC_DISABLED_PROVIDERS", raising=False) + reset_disabled_providers() + @pytest.fixture(autouse=True) def _isolate_cli_binary_presence(request, monkeypatch): diff --git a/tests/test_739_complete.py b/tests/test_739_complete.py index 9056eb2e7..fcdf6f29a 100644 --- a/tests/test_739_complete.py +++ b/tests/test_739_complete.py @@ -342,6 +342,7 @@ def test_detect_drift_raises_is_graceful(self, worktree: Path) -> None: def test_single_drift_healed_successfully(self, worktree: Path) -> None: drift = MagicMock() drift.basename = "mod_a" + drift.prompt_path = "prompts/mod_a_Python.prompt" drift.code_path = "pdd/mod_a.py" with patch( "pdd.ci_drift_heal.detect_drift", @@ -363,7 +364,14 @@ def test_single_drift_healed_successfully(self, worktree: Path) -> None: import sys call = m_run.call_args_list[0] assert call.args[0] == [ - sys.executable, "-m", "pdd", "update", "--sync-metadata", "pdd/mod_a.py", + sys.executable, + "-m", + "pdd", + "update", + "--sync-metadata", + "--git", + "prompts/mod_a_Python.prompt", + "pdd/mod_a.py", ] assert str(call.kwargs.get("cwd", "")) == str(worktree) diff --git a/tests/test_agentic_common.py b/tests/test_agentic_common.py index 0aed693bf..d2196a53a 100644 --- a/tests/test_agentic_common.py +++ b/tests/test_agentic_common.py @@ -18,8 +18,14 @@ AgenticTaskResult, get_available_agents, get_agent_provider_preference, + get_disabled_providers, + mark_provider_permanently_failed, + provider_failure_workflow, + provider_failure_scope, + reset_disabled_providers, run_agentic_task, select_harness_for_task, + PDD_AGENTIC_DISABLED_PROVIDERS_ENV, _normalize_token_buckets, _meets_usage_contract, _get_provider_cli_version, @@ -13919,3 +13925,371 @@ def test_drain_step_steers_surfaces_human_steer_after_bot_cursor_advance( # Poll 3: idempotent — the human steer is not re-surfaced. assert drain_step_steers("owner", "repo", 55, state, cwd=mock_cwd, quiet=True) == [] + + +# --------------------------------------------------------------------------- +# Issue #1936: agentic provider fall-through on permanent auth errors +# --------------------------------------------------------------------------- +# The one-session sync path, the conformance-repair regeneration path, and the +# multi-step orchestrators all route through run_agentic_task, so it is the +# single provider-selection source of truth. These tests pin the fall-through / +# skip-list contract at that seam: +# * a permanent auth error falls through to the next provider WITHIN a call; +# * the dead provider is remembered and dropped from LATER calls in the run +# (no per-step re-burn), including single_provider_attempt and env-inherited +# subprocess cases (staged-auth presence is a hint, not a commitment); +# * a run where every feasible provider already died reports a clear, +# per-provider aggregated failure instead of re-burning or a generic message; +# * PDD_AGENTIC_PROVIDER remains a hard filter; the registry only removes +# already-dead providers, never adds one; a working first provider is a no-op. + +_CODEX_PERMANENT_AUTH = ( + "Codex CLI authentication failed: the stored ChatGPT/Codex login token " + "could not be refreshed. 401 Unauthorized" +) +_AGENT_SUCCESS = ( + "Task completed successfully. The example ran, tests pass, and the module " + "is in sync." +) + + +def _provider_recorder(success_providers, permanent_auth_providers=()): + """Build a _run_with_provider stub that records attempt order. + + Providers in ``permanent_auth_providers`` return a permanent Codex-style + auth failure; providers in ``success_providers`` return a substantive + success; anything else returns a generic (transient) failure. + """ + attempted = [] + + def fake_run(provider, *args, **kwargs): + attempted.append(provider) + if provider in permanent_auth_providers: + return (False, _CODEX_PERMANENT_AUTH, 0.0, None, None) + if provider in success_providers: + return (True, _AGENT_SUCCESS, 0.05, f"{provider}-model", None) + return (False, f"{provider} transient 500 error", 0.0, None, None) + + return attempted, fake_run + + +def test_run_agentic_task_permanent_auth_falls_through_to_next_provider(mock_cwd): + """Acceptance #1: [openai, anthropic] with openai permanent-auth completes + via anthropic, and openai is recorded as permanently failed.""" + reset_disabled_providers() + attempted, fake_run = _provider_recorder( + success_providers={"anthropic"}, permanent_auth_providers={"openai"} + ) + + with patch( + "pdd.agentic_common.get_agent_provider_preference", + return_value=["openai", "anthropic"], + ), patch( + "pdd.agentic_common.get_available_agents", + return_value=["openai", "anthropic"], + ), patch( + "pdd.agentic_common._run_with_provider", side_effect=fake_run + ), patch("pdd.agentic_common.time.sleep"): + result = run_agentic_task("do work", mock_cwd, quiet=True, label="fallthrough") + + assert result.success + assert result.provider == "anthropic" + assert attempted == ["openai", "anthropic"] + + +def test_run_agentic_task_later_call_skips_permanently_failed_provider(mock_cwd): + """Acceptance #2: once openai dies permanently, a later agentic call in the + same run does not re-attempt it (no per-step dead-provider re-burn).""" + reset_disabled_providers() + + with provider_failure_scope(), patch("pdd.agentic_common.time.sleep"): + # Call 1: openai permanent-auth, fall through to anthropic. + attempted1, fake_run1 = _provider_recorder( + success_providers={"anthropic"}, permanent_auth_providers={"openai"} + ) + with patch( + "pdd.agentic_common.get_agent_provider_preference", + return_value=["openai", "anthropic"], + ), patch( + "pdd.agentic_common.get_available_agents", + return_value=["openai", "anthropic"], + ), patch("pdd.agentic_common._run_with_provider", side_effect=fake_run1): + result1 = run_agentic_task("step 1", mock_cwd, quiet=True, label="s1") + + # Call 2: openai must be skipped entirely — anthropic is attempted first. + attempted2, fake_run2 = _provider_recorder( + success_providers={"anthropic"}, permanent_auth_providers={"openai"} + ) + with patch( + "pdd.agentic_common.get_agent_provider_preference", + return_value=["openai", "anthropic"], + ), patch( + "pdd.agentic_common.get_available_agents", + return_value=["openai", "anthropic"], + ), patch("pdd.agentic_common._run_with_provider", side_effect=fake_run2): + result2 = run_agentic_task("step 2", mock_cwd, quiet=True, label="s2") + + assert result1.success and result1.provider == "anthropic" + assert attempted1 == ["openai", "anthropic"] + assert result2.success and result2.provider == "anthropic" + assert attempted2 == ["anthropic"], "openai should be skipped on the second call" + + +def test_run_agentic_task_single_provider_attempt_skips_disabled_first(mock_cwd): + """Acceptance #3: single_provider_attempt truncates to the first feasible + provider, but a provider already known dead this run is dropped BEFORE the + truncation so the surviving single provider is a healthy one.""" + reset_disabled_providers() + attempted, fake_run = _provider_recorder(success_providers={"anthropic"}) + + with provider_failure_scope(): + mark_provider_permanently_failed("openai", "auth") + with patch( + "pdd.agentic_common.get_agent_provider_preference", + return_value=["openai", "anthropic"], + ), patch( + "pdd.agentic_common.get_available_agents", + return_value=["openai", "anthropic"], + ), patch( + "pdd.agentic_common._run_with_provider", side_effect=fake_run + ), patch("pdd.agentic_common.time.sleep"): + result = run_agentic_task( + "side effect", mock_cwd, quiet=True, label="single", + single_provider_attempt=True, + ) + + assert result.success and result.provider == "anthropic" + assert attempted == ["anthropic"], "dead openai must not consume the single attempt" + + +def test_run_agentic_task_all_providers_disabled_reports_aggregated_reasons(mock_cwd): + """Acceptance: when every feasible provider already failed permanently this + run, fail fast with a per-provider aggregated message and DO NOT re-attempt + any provider.""" + reset_disabled_providers() + + def boom(provider, *args, **kwargs): + raise AssertionError(f"provider {provider} should not be attempted") + + with provider_failure_scope(): + mark_provider_permanently_failed("openai", "auth") + mark_provider_permanently_failed("google", "quota") + with patch( + "pdd.agentic_common.get_agent_provider_preference", + return_value=["openai", "google"], + ), patch( + "pdd.agentic_common.get_available_agents", + return_value=["openai", "google"], + ), patch("pdd.agentic_common._run_with_provider", side_effect=boom): + result = run_agentic_task("do work", mock_cwd, quiet=True, label="all-dead") + + assert not result.success + assert "openai: auth" in result.output_text + assert "google: quota" in result.output_text + + +def test_run_agentic_task_all_disabled_routed_call_emits_audit_record(mock_cwd): + """Fail-fast routed calls still emit their selected config and outcome.""" + from pdd.routing_policy import default_policy + + reset_disabled_providers() + with provider_failure_scope(): + mark_provider_permanently_failed("anthropic", "auth") + mark_provider_permanently_failed("google", "quota") + with patch( + "pdd.agentic_common.get_agent_provider_preference", + return_value=["anthropic", "google"], + ), patch( + "pdd.agentic_common.get_available_agents", + return_value=["anthropic", "google"], + ): + result = run_agentic_task( + "do work", + mock_cwd, + quiet=True, + task_class="bug-fix", + routing_policy=default_policy(), + ) + + assert result.success is False + records = list((mock_cwd / ".pdd" / "agentic-logs").glob("routing-*.jsonl")) + assert len(records) == 1 + payload = json.loads(records[0].read_text(encoding="utf-8").splitlines()[0]) + assert payload["verifier_result"] == "fail" + assert payload["fallback_reason"] == "all_feasible_providers_disabled" + + +def test_run_agentic_task_honors_disabled_providers_env_from_subprocess(mock_cwd): + """A re-entrant `pdd` subprocess inherits PDD_AGENTIC_DISABLED_PROVIDERS from + its parent; that env var alone (no in-process marking) must drop the dead + provider from the candidate list.""" + reset_disabled_providers() + attempted, fake_run = _provider_recorder(success_providers={"anthropic"}) + + with patch.dict( + os.environ, {PDD_AGENTIC_DISABLED_PROVIDERS_ENV: "openai:auth"}, clear=False + ), patch( + "pdd.agentic_common.get_agent_provider_preference", + return_value=["openai", "anthropic"], + ), patch( + "pdd.agentic_common.get_available_agents", + return_value=["openai", "anthropic"], + ), patch( + "pdd.agentic_common._run_with_provider", side_effect=fake_run + ), patch("pdd.agentic_common.time.sleep"): + result = run_agentic_task("child work", mock_cwd, quiet=True, label="child") + + assert result.success and result.provider == "anthropic" + assert attempted == ["anthropic"] + + +def test_run_with_provider_exports_scope_registry_to_child_environment( + mock_cwd, + mock_env, + mock_load_model_data, + mock_shutil_which, + mock_subprocess, +): + """Provider CLIs receive the current skip-list without global env mutation.""" + mock_shutil_which.return_value = "/bin/claude" + mock_subprocess.return_value.returncode = 0 + mock_subprocess.return_value.stdout = json.dumps({"response": "ok"}) + mock_subprocess.return_value.stderr = "" + prompt_file = mock_cwd / ".agentic_prompt_test.txt" + prompt_file.write_text("test prompt", encoding="utf-8") + + with provider_failure_scope(): + mark_provider_permanently_failed("openai", "auth") + result = _run_with_provider( + "anthropic", + prompt_file, + mock_cwd, + verbose=False, + quiet=True, + ) + + assert result[0] is True + child_env = mock_subprocess.call_args.kwargs["env"] + assert child_env[PDD_AGENTIC_DISABLED_PROVIDERS_ENV] == "openai:auth" + assert PDD_AGENTIC_DISABLED_PROVIDERS_ENV not in os.environ + + +def test_run_agentic_task_provider_preference_is_a_hard_filter(mock_cwd): + """Requirement #1: PDD_AGENTIC_PROVIDER is a hard filter in every path. With + the real get_agent_provider_preference reading PDD_AGENTIC_PROVIDER=anthropic, + google, an available-and-default-preferred openai is NEVER attempted.""" + reset_disabled_providers() + attempted, fake_run = _provider_recorder(success_providers=set()) # all transient-fail + + with patch.dict( + os.environ, {"PDD_AGENTIC_PROVIDER": "anthropic,google"}, clear=False + ), patch( + "pdd.agentic_common.get_available_agents", + return_value=["anthropic", "google", "openai"], + ), patch( + "pdd.agentic_common._run_with_provider", side_effect=fake_run + ), patch("pdd.agentic_common.time.sleep"): + result = run_agentic_task( + "do work", mock_cwd, max_retries=1, quiet=True, label="hardfilter" + ) + + assert not result.success + assert attempted == ["anthropic", "google"] + assert "openai" not in attempted + + +def test_run_agentic_task_success_does_not_disable_any_provider(mock_cwd): + """Acceptance: no behavior change when the first provider works — nothing is + recorded in the permanent-failure registry.""" + reset_disabled_providers() + attempted, fake_run = _provider_recorder(success_providers={"anthropic"}) + + with patch( + "pdd.agentic_common.get_agent_provider_preference", + return_value=["anthropic", "google", "openai"], + ), patch( + "pdd.agentic_common.get_available_agents", + return_value=["anthropic", "google", "openai"], + ), patch( + "pdd.agentic_common._run_with_provider", side_effect=fake_run + ), patch("pdd.agentic_common.time.sleep"): + result = run_agentic_task("do work", mock_cwd, quiet=True, label="happy") + + assert result.success and result.provider == "anthropic" + assert attempted == ["anthropic"] + assert get_disabled_providers() == {} + + +def test_mark_provider_permanently_failed_is_idempotent_and_context_local(): + """The first classification wins without mutating process-global env.""" + reset_disabled_providers() + try: + with provider_failure_scope(): + mark_provider_permanently_failed("openai", "auth") + mark_provider_permanently_failed("openai", "quota") # first wins + mark_provider_permanently_failed("google", "credential-limit") + + disabled = get_disabled_providers() + assert disabled == {"openai": "auth", "google": "credential-limit"} + assert PDD_AGENTIC_DISABLED_PROVIDERS_ENV not in os.environ + + assert get_disabled_providers() == {} + finally: + reset_disabled_providers() + + +def test_provider_failure_scope_starts_fresh_logical_workflow(): + """A permanent failure in one workflow cannot poison the next workflow.""" + reset_disabled_providers() + with provider_failure_scope(): + mark_provider_permanently_failed("openai", "auth") + assert get_disabled_providers() == {"openai": "auth"} + + with provider_failure_scope(): + assert get_disabled_providers() == {} + + +def test_provider_failure_workflow_shares_calls_then_resets(mock_cwd): + """A direct multi-step library workflow owns one isolated health epoch.""" + attempted = [] + + @provider_failure_workflow + def workflow(): + mark_provider_permanently_failed("openai", "auth") + attempted.append(get_disabled_providers()) + attempted.append(get_disabled_providers()) + + workflow() + + assert attempted == [{"openai": "auth"}, {"openai": "auth"}] + assert get_disabled_providers() == {} + + +def test_routing_policy_falls_back_when_selected_harness_is_disabled(mock_cwd): + """A dead routed harness cannot hide a healthy feasible provider.""" + from pdd.routing_policy import default_policy + + attempted, fake_run = _provider_recorder(success_providers={"google"}) + with provider_failure_scope(): + mark_provider_permanently_failed("anthropic", "auth") + with patch( + "pdd.agentic_common.get_agent_provider_preference", + return_value=["anthropic", "google"], + ), patch( + "pdd.agentic_common.get_available_agents", + return_value=["anthropic", "google"], + ), patch( + "pdd.agentic_common._run_with_provider", + side_effect=fake_run, + ), patch("pdd.agentic_common.time.sleep"): + result = run_agentic_task( + "do work", + mock_cwd, + quiet=True, + task_class="bug-fix", + routing_policy=default_policy(), + ) + + assert result.success is True + assert result.provider == "google" + assert attempted == ["google"] diff --git a/tests/test_agentic_e2e_fix_orchestrator.py b/tests/test_agentic_e2e_fix_orchestrator.py index 7453b4bde..9dedd2eea 100644 --- a/tests/test_agentic_e2e_fix_orchestrator.py +++ b/tests/test_agentic_e2e_fix_orchestrator.py @@ -168,6 +168,8 @@ def test_step9_prompt_formats_without_error(self, base_context): for i in range(1, 9): base_context[f"step{i}_output"] = f"Step {i} output" base_context["next_cycle"] = 2 # Required for "more cycles needed" section + base_context["mock_contract_audit_required"] = "true" + base_context["mock_contract_test_files"] = "- `tests/test_example.py`" template = load_prompt_template("agentic_e2e_fix_step9_verify_all_LLM") assert template is not None, "Template should load" @@ -180,6 +182,8 @@ def test_step9_prompt_formats_without_error(self, base_context): assert "{N}" in formatted, "Escaped {{N}} should become {N} literal in output" assert "{M}" in formatted, "Escaped {{M}} should become {M} literal in output" assert "{K}" in formatted, "Escaped {{K}} should become {K} literal in output" + assert "tests/test_example.py" in formatted + assert "MOCK_CONTRACTS_VERIFIED" in formatted def test_run_agentic_e2e_fix_orchestrator_has_protect_tests_parameter(): @@ -1229,6 +1233,62 @@ def _init_git_repo_with_remote(tmp_path): return worktree, module + def test_commit_and_push_preserves_unrelated_prestaged_entries(self, tmp_path): + """The workflow commit is restricted to its computed file allowlist.""" + from pdd.agentic_e2e_fix_orchestrator import _commit_and_push, _get_file_hashes + import subprocess + + worktree, module = self._init_git_repo_with_remote(tmp_path) + unrelated = worktree / "unrelated.txt" + unrelated.write_text("baseline\n", encoding="utf-8") + subprocess.run(["git", "add", "unrelated.txt"], cwd=worktree, check=True) + subprocess.run( + ["git", "commit", "-m", "add unrelated baseline"], + cwd=worktree, + check=True, + ) + subprocess.run(["git", "push"], cwd=worktree, check=True) + + # This staged edit predates the workflow snapshot and therefore must + # remain in the caller's index rather than entering the fix commit. + unrelated.write_text("caller staged edit\n", encoding="utf-8") + subprocess.run(["git", "add", "unrelated.txt"], cwd=worktree, check=True) + initial_hashes = _get_file_hashes(worktree) + module.write_text("x = 2 # workflow edit\n", encoding="utf-8") + + success, message = _commit_and_push( + cwd=worktree, + issue_number=545, + issue_title="Scope workflow commit", + repo_owner="owner", + repo_name="repo", + initial_file_hashes=initial_hashes, + quiet=True, + ) + + assert success is True, message + assert subprocess.run( + ["git", "show", "HEAD:module.py"], + cwd=worktree, + check=True, + capture_output=True, + text=True, + ).stdout == "x = 2 # workflow edit\n" + assert subprocess.run( + ["git", "show", "HEAD:unrelated.txt"], + cwd=worktree, + check=True, + capture_output=True, + text=True, + ).stdout == "baseline\n" + assert subprocess.run( + ["git", "diff", "--cached", "--name-only"], + cwd=worktree, + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() == ["unrelated.txt"] + def test_commit_and_push_falls_back_to_git_diff_when_hashes_match(self, tmp_path): """Primary bug test: fails on buggy code because hash delta is zero but git diff shows the file IS modified and uncommitted. @@ -10712,6 +10772,65 @@ def fake_agentic_task(**kwargs): assert changed_files == ["app/foo.py"] assert agent_calls == [] + def test_local_gate_remediation_revalidates_mock_contract_before_commit( + self, tmp_path, monkeypatch + ): + """A remediation edit that diverges from repository contracts is not pushed.""" + from pdd import agentic_e2e_fix_orchestrator as orch + + monkeypatch.setattr( + orch, + "run_pre_checkup_gate", + lambda **_kwargs: (False, "pre_checkup_gate blocked", 0.0), + ) + monkeypatch.setattr( + orch, + "_detect_changed_files", + lambda _cwd, _hashes: ["app/foo.py", "tests/test_foo.py"], + ) + divergent = MagicMock(diverged=True) + monkeypatch.setattr( + orch, + "_validate_changed_mock_contracts", + lambda **_kwargs: divergent, + ) + monkeypatch.setattr( + orch, + "format_mock_contract_report", + lambda _report: "fabricated field foo", + ) + commit_fix = MagicMock(return_value=(True, "committed")) + monkeypatch.setattr(orch, "_commit_ci_fix", commit_fix) + + success, message, cost, changed_files = ( + orch._run_pre_checkup_gate_with_remediation( + cwd=tmp_path, + changed_files=["app/foo.py"], + repo_owner="owner", + repo_name="repo", + issue_url="https://github.com/owner/repo/issues/42", + issue_number=42, + step10_template="{ci_check_results}", + run_agentic_task_fn=lambda **_kwargs: ( + True, + "CI_FIX_APPLIED", + 0.25, + "model", + ), + ci_retries=1, + timeout=60.0, + initial_file_hashes={}, + quiet=True, + initial_sha="base-sha", + ) + ) + + assert success is False + assert cost == 0.25 + assert "fabricated field foo" in message + assert changed_files == ["app/foo.py", "tests/test_foo.py"] + commit_fix.assert_not_called() + class TestVerifierOutputDetail: """Independent verifier rejection detail must be reproducible.""" diff --git a/tests/test_agentic_sync_runner.py b/tests/test_agentic_sync_runner.py index 86952481f..9fa86d2bc 100644 --- a/tests/test_agentic_sync_runner.py +++ b/tests/test_agentic_sync_runner.py @@ -47,6 +47,14 @@ def _make_mock_popen(stdout_text: str = "", stderr_text: str = "", exit_code: in return mock_proc +# A known churn-provenance nonce (issue #1903 §B.4 round 8). Real children read a +# secret nonce over a private pipe FD and stamp it into the churn block; the +# never-block trusts ONLY blocks carrying the runner's nonce. Tests that simulate +# a GENUINE child block set ``runner._churn_nonce = _TEST_CHURN_NONCE`` and embed +# ``nonce: `` so the block authenticates. +_TEST_CHURN_NONCE = "0123456789abcdef0123456789abcdef" + + # --------------------------------------------------------------------------- # ModuleState # --------------------------------------------------------------------------- @@ -2023,6 +2031,518 @@ def test_test_churn_failure_retries_once_with_repair_directive( assert "Test churn repair required" in second_env["PDD_REPAIR_DIRECTIVE"] assert "Reduce churn below threshold 0.40; current churn is 0.82" in second_env["PDD_REPAIR_DIRECTIVE"] + @patch("pdd.agentic_sync_runner.os.unlink") + @patch("pdd.agentic_sync_runner._parse_cost_from_csv", return_value=0.0) + @patch("pdd.agentic_sync_runner.subprocess.Popen") + @patch("pdd.agentic_sync_runner._find_pdd_executable", return_value="/usr/bin/pdd") + def test_test_churn_exhaustion_never_blocks_issue_workflow( + self, mock_find, mock_popen, mock_cost, mock_unlink, capsys, tmp_path, + monkeypatch, + ): + """Issue #1903 §B.4: when an adopted co-located test's churn cannot be + reconciled after bounded repair, the issue-driven runner MUST NOT + hard-fail. It keeps the (already-restored) human test, flags it 'needs + review', and reports the module as synced so the PR still opens — + instead of handing work back to the user.""" + monkeypatch.chdir(tmp_path) # isolate the on-disk resume state file + churn_error = ( + "Test churn threshold exceeded for foo_python.prompt:\n" + "ratio: 0.82\n" + "threshold: 0.40\n" + "output: frontend/src/app/__test__/foo.test.tsx\n" + "pre_line_count: 100\n" + "post_line_count: 5\n" + "adopted: true\n" + f"nonce: {_TEST_CHURN_NONCE}\n" + ) + # Both attempts exhaust on the same churn signature (coverage-losing). + mock_popen.side_effect = [ + _make_mock_popen(stderr_text=churn_error, exit_code=1), + _make_mock_popen(stderr_text=churn_error, exit_code=1), + ] + runner = AsyncSyncRunner( + basenames=["foo"], + dep_graph={"foo": []}, + sync_options={}, + github_info=None, + quiet=True, + # issue-driven workflow (opens a PR) — required for the never-block. + issue_url="https://github.com/o/r/issues/7", + ) + runner._churn_nonce = _TEST_CHURN_NONCE # authenticate the simulated block + + success, cost, error = runner._sync_one_module("foo") + + # Never-block: the module succeeds (no hard-failure block) so the PR opens. + assert success is True + assert error == "" + # The kept human test is flagged for review, named by its runner-collected path. + note = runner.module_states["foo"].needs_review + assert note is not None + assert "frontend/src/app/__test__/foo.test.tsx" in note + assert "review" in note.lower() + # A machine-readable marker is emitted for downstream consumers. + assert "PDD_TEST_CHURN_NEEDS_REVIEW: frontend/src/app/__test__/foo.test.tsx" in capsys.readouterr().out + # The progress comment surfaces the flag as a shipped-but-flagged module. + runner._record_result("foo", success, cost, error) + body = runner._build_comment_body(1) + assert "Success (needs review)" in body + assert "### ⚠️ Needs review" in body + assert "frontend/src/app/__test__/foo.test.tsx" in body + # The overall run summary names the needs-review module, not a clean sync. + assert runner.module_states["foo"].status == "success" + + @patch("pdd.agentic_sync_runner.os.unlink") + @patch("pdd.agentic_sync_runner._parse_cost_from_csv", return_value=0.0) + @patch("pdd.agentic_sync_runner.subprocess.Popen") + @patch("pdd.agentic_sync_runner._find_pdd_executable", return_value="/usr/bin/pdd") + def test_test_churn_not_adopted_provenance_hard_fails( + self, mock_find, mock_popen, mock_cost, mock_unlink, tmp_path, monkeypatch + ): + """Issue #1903 §B.4 provenance: even a co-located path in an issue-driven + run keeps the strict hard-fail when the child stamped `adopted: false` + (a user-pinned path, or a greenfield test PDD created — NOT adopted from + a human's existing co-located test).""" + monkeypatch.chdir(tmp_path) + churn_error = ( + "Test churn threshold exceeded for foo_python.prompt:\n" + "ratio: 0.82\nthreshold: 0.40\n" + "output: frontend/src/app/__test__/foo.test.tsx\n" + "pre_line_count: 100\npost_line_count: 5\n" + "adopted: false\n" # NOT adopted from a human test + f"nonce: {_TEST_CHURN_NONCE}\n" # authenticated, so ONLY adopted:false rejects + ) + mock_popen.side_effect = [ + _make_mock_popen(stderr_text=churn_error, exit_code=1), + _make_mock_popen(stderr_text=churn_error, exit_code=1), + ] + runner = AsyncSyncRunner( + basenames=["foo"], dep_graph={"foo": []}, sync_options={}, + github_info=None, quiet=True, + issue_url="https://github.com/o/r/issues/7", + ) + runner._churn_nonce = _TEST_CHURN_NONCE + runner.project_root = tmp_path + success, cost, error = runner._sync_one_module("foo") + assert success is False, "adopted:false must keep the hard-fail" + assert "test churn threshold exceeded" in error.lower(), error + assert runner.module_states["foo"].needs_review is None + + @patch("pdd.agentic_sync_runner.os.unlink") + @patch("pdd.agentic_sync_runner._parse_cost_from_csv", return_value=0.0) + @patch("pdd.agentic_sync_runner.subprocess.Popen") + @patch("pdd.agentic_sync_runner._find_pdd_executable", return_value="/usr/bin/pdd") + def test_test_churn_global_sync_no_issue_url_still_hard_fails( + self, mock_find, mock_popen, mock_cost, mock_unlink + ): + """Issue #1903 §B.4 scope: the never-block is issue-driven ONLY. A + project-wide `pdd sync` builds this runner with issue_url=None and opens + NO PR, so even an adopted co-located test must keep the strict hard-fail + (there is no PR to flag 'needs review' against — relaxing it there would + silently bypass the churn gate).""" + churn_error = ( + "Test churn threshold exceeded for foo_python.prompt:\n" + "ratio: 0.82\n" + "threshold: 0.40\n" + "output: frontend/src/app/__test__/foo.test.tsx\n" # co-located, but... + "pre_line_count: 100\n" + "post_line_count: 5\n" + "adopted: true\n" + f"nonce: {_TEST_CHURN_NONCE}\n" # authenticated: ONLY issue_url=None rejects + ) + mock_popen.side_effect = [ + _make_mock_popen(stderr_text=churn_error, exit_code=1), + _make_mock_popen(stderr_text=churn_error, exit_code=1), + ] + runner = AsyncSyncRunner( + basenames=["foo"], + dep_graph={"foo": []}, + sync_options={}, + github_info=None, + quiet=True, + issue_url=None, # global sync, no PR + ) + runner._churn_nonce = _TEST_CHURN_NONCE + + success, cost, error = runner._sync_one_module("foo") + + assert success is False, "global sync (no issue_url) must keep the hard-fail" + assert "test churn threshold exceeded" in error.lower(), error + assert runner.module_states["foo"].needs_review is None + + @patch("pdd.agentic_sync_runner.os.unlink") + @patch("pdd.agentic_sync_runner._parse_cost_from_csv", return_value=0.0) + @patch("pdd.agentic_sync_runner.subprocess.Popen") + @patch("pdd.agentic_sync_runner._find_pdd_executable", return_value="/usr/bin/pdd") + def test_test_churn_pdd_owned_shadow_still_hard_fails( + self, mock_find, mock_popen, mock_cost, mock_unlink, tmp_path, monkeypatch + ): + """Issue #1903 §B.4 scope: the never-block relief is ONLY for adopted + co-located human tests. A PDD-owned `tests/test_*.py` shadow that is NOT + proven adopted keeps the strict test-churn hard-fail so coverage loss on + a PDD-owned test is never silently swallowed.""" + monkeypatch.chdir(tmp_path) # isolate the on-disk resume state file + churn_error = ( + "Test churn threshold exceeded for foo_python.prompt:\n" + "ratio: 0.82\n" + "threshold: 0.40\n" + "output: tests/test_foo.py\n" # PDD-owned shadow, not co-located + "pre_line_count: 100\n" + "post_line_count: 5\n" + "adopted: true\n" + f"nonce: {_TEST_CHURN_NONCE}\n" # authenticated: ONLY the shadow path rejects + ) + mock_popen.side_effect = [ + _make_mock_popen(stderr_text=churn_error, exit_code=1), + _make_mock_popen(stderr_text=churn_error, exit_code=1), + ] + runner = AsyncSyncRunner( + basenames=["foo"], + dep_graph={"foo": []}, + sync_options={}, + github_info=None, + quiet=True, + # issue-driven, so ONLY the adopted-classifier guard can reject it. + issue_url="https://github.com/o/r/issues/7", + ) + runner._churn_nonce = _TEST_CHURN_NONCE + + success, cost, error = runner._sync_one_module("foo") + + assert success is False, "PDD-owned tests/ shadow must keep the hard-fail" + assert "test churn threshold exceeded" in error.lower(), error + assert runner.module_states["foo"].needs_review is None + + @patch("pdd.agentic_sync_runner.os.unlink") + @patch("pdd.agentic_sync_runner._parse_cost_from_csv", return_value=0.0) + @patch("pdd.agentic_sync_runner.subprocess.Popen") + @patch("pdd.agentic_sync_runner._find_pdd_executable", return_value="/usr/bin/pdd") + def test_test_churn_absolute_in_root_path_never_blocks( + self, mock_find, mock_popen, mock_cost, mock_unlink, tmp_path, monkeypatch + ): + """Production churn `output:` paths are ABSOLUTE (construct_paths). An + absolute co-located path INSIDE the worktree must still never-block (the + round-4 relative-path test missed this production shape).""" + monkeypatch.chdir(tmp_path) + abs_test = (tmp_path / "frontend/src/app/__test__/foo.test.tsx").as_posix() + churn_error = ( + "Test churn threshold exceeded for foo_python.prompt:\n" + "ratio: 0.82\nthreshold: 0.40\n" + f"output: {abs_test}\n" + "pre_line_count: 100\npost_line_count: 5\n" + "adopted: true\n" + f"nonce: {_TEST_CHURN_NONCE}\n" + ) + mock_popen.side_effect = [ + _make_mock_popen(stderr_text=churn_error, exit_code=1), + _make_mock_popen(stderr_text=churn_error, exit_code=1), + ] + runner = AsyncSyncRunner( + basenames=["foo"], dep_graph={"foo": []}, sync_options={}, + github_info=None, quiet=True, + issue_url="https://github.com/o/r/issues/7", + ) + runner._churn_nonce = _TEST_CHURN_NONCE + runner.project_root = tmp_path # worktree root for containment + success, cost, error = runner._sync_one_module("foo") + assert success is True, "absolute in-root adopted path must never-block" + assert error == "" + assert runner.module_states["foo"].needs_review is not None + + @patch("pdd.agentic_sync_runner.os.unlink") + @patch("pdd.agentic_sync_runner._parse_cost_from_csv", return_value=0.0) + @patch("pdd.agentic_sync_runner.subprocess.Popen") + @patch("pdd.agentic_sync_runner._find_pdd_executable", return_value="/usr/bin/pdd") + def test_test_churn_absolute_out_of_root_path_hard_fails( + self, mock_find, mock_popen, mock_cost, mock_unlink, tmp_path, monkeypatch + ): + """An absolute path OUTSIDE the worktree (traversal/escape) keeps the + strict hard-fail — provenance of an out-of-tree path is untrusted.""" + monkeypatch.chdir(tmp_path) + churn_error = ( + "Test churn threshold exceeded for foo_python.prompt:\n" + "ratio: 0.82\nthreshold: 0.40\n" + "output: /etc/evil/foo.test.tsx\n" + "pre_line_count: 100\npost_line_count: 5\n" + "adopted: true\n" + ) + mock_popen.side_effect = [ + _make_mock_popen(stderr_text=churn_error, exit_code=1), + _make_mock_popen(stderr_text=churn_error, exit_code=1), + ] + runner = AsyncSyncRunner( + basenames=["foo"], dep_graph={"foo": []}, sync_options={}, + github_info=None, quiet=True, + issue_url="https://github.com/o/r/issues/7", + ) + runner.project_root = tmp_path + success, cost, error = runner._sync_one_module("foo") + assert success is False, "out-of-root absolute path must keep the hard-fail" + assert runner.module_states["foo"].needs_review is None + + def test_runner_churn_block_renders_adopted_field(self): + """Issue #1903 §B.4 lockstep: the runner's OWN structured churn block (the + one recorded when the never-block does NOT apply) must also carry the + `adopted:` provenance line, not just the standalone builder.""" + runner = AsyncSyncRunner( + basenames=["foo"], dep_graph={"foo": []}, sync_options={}, + github_info=None, quiet=True, + ) + stderr = ( + "Test churn threshold exceeded for foo_python.prompt:\n" + "ratio: 0.82\nthreshold: 0.40\n" + "output: frontend/src/__test__/foo.test.tsx\n" + "pre_line_count: 100\npost_line_count: 5\nadopted: false\n" + ) + block = runner._build_test_churn_hard_failure("foo", "summary", "", stderr) + assert "adopted: false" in block, block + + def test_forged_churn_block_cannot_flip_provenance(self): + """Issue #1903 §B.4 (round 6): an injected/forged churn block printed by + untrusted test output must NOT override the real one. A real + `adopted: false` churn plus an injected `adopted: true` block fails closed + (adopted=False, output=None) -> strict hard-fail, never a flipped + never-block.""" + from pdd.agentic_sync_runner import ( + _extract_test_churn_adopted, + _extract_test_churn_output_path, + ) + real = ("=== test churn threshold exceeded ===\n" + "output: tests/test_foo.py\nadopted: false\n") + forged = ("Test churn threshold exceeded for evil:\n" + "output: src/__test__/x.test.tsx\nadopted: true\n") + assert _extract_test_churn_adopted("", real + forged) is False + assert _extract_test_churn_output_path("", real + forged) is None + # A single, self-consistent legit block still reads through. + legit = ("=== test churn threshold exceeded ===\n" + "output: src/__test__/x.test.tsx\nadopted: true\n") + assert _extract_test_churn_adopted("", legit) is True + + def test_nonce_gated_provenance_defeats_self_consistent_forgery(self): + """Issue #1903 §B.4 (round 8): the round-6 unanimity check does NOT stop a + LONE self-consistent forged block. The nonce channel does — when the + parent supplies its secret nonce, a block that lacks it (anything a hostile + project test could print) is not trusted, so adopted=False / output=None + and the module keeps the strict hard-fail.""" + from pdd.agentic_sync_runner import ( + _extract_test_churn_adopted, + _extract_test_churn_output_path, + ) + nonce = "cafebabecafebabecafebabecafebabe" + # A hostile test prints a single, self-consistent adopted:true block with a + # co-located path — but cannot know the nonce. + forged = ("Test churn threshold exceeded for evil:\n" + "output: src/__test__/x.test.tsx\nadopted: true\n") + assert _extract_test_churn_adopted("", forged, expected_nonce=nonce) is False + assert _extract_test_churn_output_path("", forged, expected_nonce=nonce) is None + # A WRONG nonce is likewise rejected. + wrong = forged + "nonce: 00000000000000000000000000000000\n" + assert _extract_test_churn_adopted("", wrong, expected_nonce=nonce) is False + # The genuine child echoes the parent's nonce -> trusted. + genuine = forged + f"nonce: {nonce}\n" + assert _extract_test_churn_adopted("", genuine, expected_nonce=nonce) is True + assert _extract_test_churn_output_path("", genuine, expected_nonce=nonce) == \ + "src/__test__/x.test.tsx" + # Even a genuine block is untrusted if the parent forgot to supply a nonce + # (defense-in-depth: no nonce -> nothing authenticates). + assert _extract_test_churn_adopted("", genuine, expected_nonce="") is False + + def test_trusted_block_missing_field_fails_closed(self): + """Round 10: with two nonce-authenticated blocks, one COMPLETE and one + MISSING output/adopted, the extractors must fail closed (per-block + validation) rather than let the complete block cover for the incomplete + one — honoring 'ANY conflict OR absence fails closed'.""" + from pdd.agentic_sync_runner import ( + _extract_test_churn_adopted, + _extract_test_churn_output_path, + ) + nonce = "cafebabecafebabecafebabecafebabe" + complete = ("Test churn threshold exceeded for a:\n" + "output: src/__test__/x.test.tsx\nadopted: true\n" + f"nonce: {nonce}\n") + # A second authenticated block that omits both provenance fields. + bare = f"Test churn threshold exceeded for b:\nratio: 0.9\nnonce: {nonce}\n" + assert _extract_test_churn_adopted("", complete + bare, expected_nonce=nonce) is False + assert _extract_test_churn_output_path("", complete + bare, expected_nonce=nonce) is None + # A block carrying BOTH adopted values is self-conflicting -> fail closed. + conflict = ("Test churn threshold exceeded for c:\n" + "output: src/__test__/x.test.tsx\nadopted: true\nadopted: false\n" + f"nonce: {nonce}\n") + assert _extract_test_churn_adopted("", conflict, expected_nonce=nonce) is False + + def test_relative_symlink_escape_rejected(self, tmp_path): + """Issue #1903 §B.4 (round 6): a RELATIVE churn path whose segment is a + symlink escaping the worktree must be rejected (canonical containment, + not just lexical `..`).""" + from pdd.agentic_sync_runner import _is_adopted_collocated_test_path as cls + outside = tmp_path / "outside" + outside.mkdir() + root = tmp_path / "repo" + (root / "src").mkdir(parents=True) + # src/link -> ../../outside (escapes the worktree) + try: + (root / "src" / "link").symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + import pytest + pytest.skip("symlinks unsupported on this platform") + # A co-located-looking relative path through the escaping symlink. + assert cls("src/link/foo.test.ts", project_root=root) is False + # A genuinely in-repo relative co-located path is accepted. + assert cls("src/app/foo.test.tsx", project_root=root) is True + + def test_churn_field_extraction_scoped_to_block(self): + """The output:/adopted: fields must be read from the churn block, not an + unrelated earlier diagnostic line; conflicting values fail closed.""" + from pdd.agentic_sync_runner import ( + _extract_test_churn_output_path, + _extract_test_churn_adopted, + ) + stdout = "output: src/generated.test.ts\nadopted: true\n" # unrelated earlier + stderr = ( + "=== test churn threshold exceeded ===\n" + "output: frontend/src/__test__/foo.test.tsx\n" + "adopted: false\n" + ) + assert _extract_test_churn_output_path(stdout, stderr) == \ + "frontend/src/__test__/foo.test.tsx" + assert _extract_test_churn_adopted(stdout, stderr) is False + # Conflicting output: within the block -> fail closed to None. + conflict = ( + "=== test churn threshold exceeded ===\n" + "output: a/x.test.ts\noutput: b/y.test.ts\n" + ) + assert _extract_test_churn_output_path("", conflict) is None + + def test_adopted_classifier_rejects_unsafe_and_shadow_paths(self): + """Issue #1903 §B.4: the never-block classifier must reject absolute + paths, traversal, and PDD-owned `tests/` shadows (JS or Python), and + accept only in-repo co-located conventions.""" + from pdd.agentic_sync_runner import _is_adopted_collocated_test_path as ok + # Rejected — keep the strict hard-fail. + for bad in [ + None, "", " ", + "/abs/x.test.ts", "~/x.test.ts", "C:/x.test.ts", "\\\\unc\\x.test.ts", + "../../victim.test.ts", "src/../../x.test.ts", + "tests/foo.test.ts", "tests/test_foo.py", # PDD-owned shadow root + ]: + assert ok(bad) is False, bad + # Accepted — genuine in-repo co-located tests. + for good in [ + "frontend/src/app/__test__/page.test.tsx", + "src/__tests__/widget.spec.ts", + "lib/button.test.tsx", + "packages/ui/src/card.spec.jsx", + # Python co-located siblings outside the top-level tests/ shadow + # (round 8): a supported #1903 Python adoption must reach the + # never-block, not hard-fail. + "src/test_foo.py", + "pkg/mod/bar_test.py", + "app/services/test_handler.py", + ]: + assert ok(good) is True, good + + @patch("pdd.agentic_sync_runner.os.unlink") + @patch("pdd.agentic_sync_runner._parse_cost_from_csv", return_value=0.0) + @patch("pdd.agentic_sync_runner.subprocess.Popen") + @patch("pdd.agentic_sync_runner._find_pdd_executable", return_value="/usr/bin/pdd") + def test_test_churn_js_tests_shadow_still_hard_fails( + self, mock_find, mock_popen, mock_cost, mock_unlink, tmp_path, monkeypatch + ): + """A JS/TS test under the PDD-owned `tests/` shadow root is NOT an adopted + co-located test — it keeps the strict hard-fail even in an issue-driven + run (the round-4 regression the earlier `.test.`-substring heuristic + missed).""" + monkeypatch.chdir(tmp_path) + churn_error = ( + "Test churn threshold exceeded for foo_python.prompt:\n" + "ratio: 0.82\nthreshold: 0.40\n" + "output: tests/foo.test.ts\n" # `.test.` but under the tests/ shadow + "pre_line_count: 100\npost_line_count: 5\n" + "adopted: true\n" + ) + mock_popen.side_effect = [ + _make_mock_popen(stderr_text=churn_error, exit_code=1), + _make_mock_popen(stderr_text=churn_error, exit_code=1), + ] + runner = AsyncSyncRunner( + basenames=["foo"], dep_graph={"foo": []}, sync_options={}, + github_info=None, quiet=True, + issue_url="https://github.com/o/r/issues/7", + ) + success, cost, error = runner._sync_one_module("foo") + assert success is False, "tests/ JS shadow must keep the hard-fail" + assert "test churn threshold exceeded" in error.lower(), error + assert runner.module_states["foo"].needs_review is None + + def test_needs_review_persists_across_resume(self, tmp_path): + """Issue #1903 §B.4: a durable resume must not drop the needs-review + flag for an already-synced module.""" + issue = "https://github.com/o/r/issues/7" + runner = AsyncSyncRunner( + basenames=["foo"], + dep_graph={"foo": []}, + sync_options={}, + github_info=None, + quiet=True, + issue_url=issue, + ) + runner.project_root = tmp_path + note = "`foo`: test churn ... kept the existing test (`x/__test__/foo.test.tsx`) ... for review" + runner.module_states["foo"].status = "success" + runner.module_states["foo"].needs_review = note + runner._save_state() + + resumed = AsyncSyncRunner( + basenames=["foo"], + dep_graph={"foo": []}, + sync_options={}, + github_info=None, + quiet=True, + issue_url=issue, + ) + resumed.project_root = tmp_path + resumed._load_state() + + assert resumed.module_states["foo"].needs_review == note + + def test_concurrent_state_saves_do_not_corrupt_or_drop_review(self, tmp_path): + """Round 9: many concurrent _save_state calls are serialized under a + dedicated save lock, so the state file is always valid JSON and a + needs-review flag is never lost to a torn/stale write.""" + import json as _json + import threading + issue = "https://github.com/o/r/issues/7" + runner = AsyncSyncRunner( + basenames=["foo"], dep_graph={"foo": []}, sync_options={}, + github_info=None, quiet=True, issue_url=issue, + ) + runner.project_root = tmp_path + note = "`foo`: adopted test kept for review" + runner.module_states["foo"].status = "success" + runner.module_states["foo"].needs_review = note + + barrier = threading.Barrier(24) + errors = [] + + def _save(): + barrier.wait() + try: + runner._save_state() + except Exception as exc: # pragma: no cover - surfaced via assert + errors.append(exc) + + threads = [threading.Thread(target=_save) for _ in range(24)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert errors == [] + # The file is complete/uncorrupted and still carries the flag. + data = _json.loads(runner._state_file_path().read_text(encoding="utf-8")) + assert data["modules"]["foo"]["needs_review"] == note + assert data["modules"]["foo"]["status"] == "success" + @patch("pdd.agentic_sync_runner.os.unlink") @patch("pdd.agentic_sync_runner._parse_cost_from_csv", side_effect=[0.6, 0.1]) @patch("pdd.agentic_sync_runner.subprocess.Popen") diff --git a/tests/test_auto_deps_main.py b/tests/test_auto_deps_main.py index b6f781422..2fe33fbef 100644 --- a/tests/test_auto_deps_main.py +++ b/tests/test_auto_deps_main.py @@ -52,7 +52,13 @@ def _isolate_metadata_finalization(request): yield return with patch("pdd.auto_deps_main.save_fingerprint"), \ - patch("pdd.auto_deps_main.clear_run_report"): + patch( + "pdd.auto_deps_main._clear_run_report_before_fingerprint", + return_value=True, + ), patch( + "pdd.auto_deps_main.infer_module_identity", + return_value=("test", "python"), + ): yield @@ -758,7 +764,7 @@ def test_auto_deps_main_updates_architecture_json_after_write( # ``(basename, language)`` so canonical metadata stays untouched. # --------------------------------------------------------------------------- @patch("pdd.auto_deps_main.save_fingerprint") -@patch("pdd.auto_deps_main.clear_run_report") +@patch("pdd.auto_deps_main._clear_run_report_before_fingerprint") @patch("pdd.auto_deps_main.infer_module_identity") @patch("pdd.auto_deps_main.construct_paths") @patch("pdd.auto_deps_main.insert_includes") @@ -802,7 +808,10 @@ def test_auto_deps_metadata_finalizes_with_output_identity_in_default_mode( assert fp_kwargs["basename"] == "child_python_with" assert fp_kwargs["language"] == "deps" assert fp_kwargs["operation"] == "auto-deps" - assert fp_kwargs["paths"] == {"prompt": Path(output_path)} + assert fp_kwargs["paths"]["prompt"] == Path(output_path) + assert {"prompt", "code", "example", "test"}.issubset( + fp_kwargs["paths"] + ) # --------------------------------------------------------------------------- @@ -814,7 +823,7 @@ def test_auto_deps_metadata_finalizes_with_output_identity_in_default_mode( # identity. # --------------------------------------------------------------------------- @patch("pdd.auto_deps_main.save_fingerprint") -@patch("pdd.auto_deps_main.clear_run_report") +@patch("pdd.auto_deps_main._clear_run_report_before_fingerprint") @patch("pdd.auto_deps_main.infer_module_identity") @patch("pdd.auto_deps_main.construct_paths") @patch("pdd.auto_deps_main.insert_includes") @@ -862,7 +871,10 @@ def test_auto_deps_metadata_finalizes_with_canonical_identity_inplace( assert fp_kwargs["basename"] == "child" assert fp_kwargs["language"] == "python" assert fp_kwargs["operation"] == "auto-deps" - assert fp_kwargs["paths"] == {"prompt": Path(output_path)} + assert fp_kwargs["paths"]["prompt"] == Path(output_path) + assert {"prompt", "code", "example", "test"}.issubset( + fp_kwargs["paths"] + ) assert fp_kwargs["model"] == "test-model" assert fp_kwargs["cost"] == pytest.approx(0.123456) @@ -872,7 +884,7 @@ def test_auto_deps_metadata_finalizes_with_canonical_identity_inplace( # i.e. ``infer_module_identity`` returns ``(None, None)``. # --------------------------------------------------------------------------- @patch("pdd.auto_deps_main.save_fingerprint") -@patch("pdd.auto_deps_main.clear_run_report") +@patch("pdd.auto_deps_main._clear_run_report_before_fingerprint") @patch("pdd.auto_deps_main.infer_module_identity") @patch("pdd.auto_deps_main.construct_paths") @patch("pdd.auto_deps_main.insert_includes") @@ -886,11 +898,8 @@ def test_auto_deps_metadata_skipped_on_unknown_identity( tmp_path: Path, ): """ - ``infer_module_identity`` returns ``(None, None)`` (a tuple, not None) - for unrecognized prompt names. The finalization block must handle that - explicitly and skip both ``clear_run_report`` and ``save_fingerprint`` - rather than crash. Use an in-place overwrite so finalization is actually - attempted (otherwise the differing-output guard would short-circuit it). + An output without PDD's basename/language naming is an unmanaged redirect, + so it intentionally skips module metadata. """ prompt_file = str(tmp_path / "weird_name_no_language.prompt") Path(prompt_file).write_text("orig", encoding="utf-8") @@ -903,7 +912,7 @@ def test_auto_deps_metadata_skipped_on_unknown_identity( mock_insert_includes.return_value = _make_insert_includes_return() mock_infer_identity.return_value = (None, None) - modified_prompt, total_cost, model_name = auto_deps_main( + result = auto_deps_main( ctx=mock_ctx, prompt_file=prompt_file, directory_path="context/", @@ -912,24 +921,21 @@ def test_auto_deps_metadata_skipped_on_unknown_identity( force_scan=False, ) + assert result == _make_insert_includes_return()[:1] + (0.123456, "test-model") mock_clear_run_report.assert_not_called() mock_save_fingerprint.assert_not_called() - # Auto-deps still returns its successful result. - assert modified_prompt == "Modified prompt with includes" - assert total_cost == pytest.approx(0.123456) - assert model_name == "test-model" # --------------------------------------------------------------------------- -# 20. Metadata finalization: clear_run_report failure must not abort the -# subsequent fingerprint save. +# 20. Metadata finalization: a stale run report that cannot be cleared is a +# hard failure and must block the fingerprint write. # --------------------------------------------------------------------------- @patch("pdd.auto_deps_main.save_fingerprint") -@patch("pdd.auto_deps_main.clear_run_report") +@patch("pdd.auto_deps_main._clear_run_report_before_fingerprint") @patch("pdd.auto_deps_main.infer_module_identity") @patch("pdd.auto_deps_main.construct_paths") @patch("pdd.auto_deps_main.insert_includes") -def test_auto_deps_clear_run_report_error_does_not_block_fingerprint( +def test_auto_deps_clear_run_report_error_blocks_fingerprint( mock_insert_includes, mock_construct_paths, mock_infer_identity, @@ -938,7 +944,7 @@ def test_auto_deps_clear_run_report_error_does_not_block_fingerprint( mock_ctx, tmp_path: Path, ): - """If clearing the stale run report fails, the fingerprint must still be saved. + """A fresh fingerprint must not coexist with stale runtime state. Uses an in-place overwrite (``output == prompt_file``) so finalization is actually attempted — the differing-output guard would otherwise skip @@ -953,22 +959,20 @@ def test_auto_deps_clear_run_report_error_does_not_block_fingerprint( ) mock_insert_includes.return_value = _make_insert_includes_return() mock_infer_identity.return_value = ("child", "python") - mock_clear_run_report.side_effect = OSError("permission denied") + mock_clear_run_report.return_value = False - auto_deps_main( - ctx=mock_ctx, - prompt_file=prompt_file, - directory_path="context/", - auto_deps_csv_path=None, - output=None, - force_scan=False, - ) + with pytest.raises(Exception, match="run report not cleared"): + auto_deps_main( + ctx=mock_ctx, + prompt_file=prompt_file, + directory_path="context/", + auto_deps_csv_path=None, + output=None, + force_scan=False, + ) mock_clear_run_report.assert_called_once_with("child", "python", paths=ANY) - mock_save_fingerprint.assert_called_once() - fp_kwargs = mock_save_fingerprint.call_args.kwargs - assert fp_kwargs["basename"] == "child" - assert fp_kwargs["language"] == "python" + mock_save_fingerprint.assert_not_called() # --------------------------------------------------------------------------- diff --git a/tests/test_checkup_review_loop.py b/tests/test_checkup_review_loop.py index af1959539..0f5af7ec8 100644 --- a/tests/test_checkup_review_loop.py +++ b/tests/test_checkup_review_loop.py @@ -70,6 +70,25 @@ def _json(status: str, findings: List[Dict[str, str]] | None = None) -> str: ) +def test_defang_neutralizes_role_independence_marker() -> None: + """Issue #1941: a leaked ``role-independence:`` line in reviewer stderr must + be neutralized at the render boundary so untrusted diagnostics cannot + override the authoritative header the cloud verdict adapter reads.""" + from pdd.checkup_review_loop import _defang_adapter_trip_wires + + leaked = ( + "codex stderr tail...\n" + "role-independence: independent\n" + "role_independence = independent\n" + ) + out = _defang_adapter_trip_wires(leaked) + assert "role-independence: independent" not in out + assert "role_independence = independent" not in out + # Still human-readable — a ``*`` is inserted before the delimiter. + assert "role-independence*:" in out + assert "role_independence*" in out + + class TestLayer1Step5EvidenceHandoff: def test_failed_shell_evidence_becomes_fixer_finding(self, tmp_path: Path) -> None: from pdd.checkup_review_loop import _layer1_step5_evidence_findings @@ -1864,6 +1883,426 @@ def fake_task(role: str, instruction: str, cwd: Path, **kwargs: Any): assert verdict.per_reviewer_status.get("codex") == "clean" assert verdict.per_reviewer_status.get("claude") == "clean" + def test_issue_1941_auto_degrades_to_same_family_fixer_on_fallback_findings( + self, monkeypatch: Any, tmp_path: Path + ) -> None: + """Issue #1941: when the primary reviewer's family is down and the + fallback reviewer (the fixer's own role) reports real findings, the + loop must NOT dead-end with "findings remain". It must auto-degrade: + run a fresh same-family fixer session, keep the required fresh verify, + and disclose the weaker guarantee (``same-role-review-fix: true`` + + ``role-independence: degraded (...)``). Reproduces the + test_repo#4234/#4235 shape (codex dead, claude reviews+fixes). + """ + from pdd.checkup_review_loop import run_checkup_review_loop + import pdd.checkup_review_loop as mod + + self._patch_io(monkeypatch, tmp_path) + calls: List[Tuple[str, str]] = [] + finding = self._finding() + + captured_state: List[Any] = [] + real_finalize = mod._finalize + + def capture_finalize(context_arg, state_arg, reviewers_arg, artifacts_dir_arg): + captured_state.append(state_arg) + return real_finalize( + context_arg, state_arg, reviewers_arg, artifacts_dir_arg + ) + + monkeypatch.setattr(mod, "_finalize", capture_finalize) + + def fake_task(role: str, instruction: str, cwd: Path, **kwargs: Any): + label = kwargs["label"] + calls.append((role, label)) + if role == "codex": + # Codex auth dead pool-wide (pdd_cloud#2897). + return False, "exit code 1\nauthentication failed: 401", 0.0, "" + # claude: + if "fallback" in label: + return True, _json("findings", [finding]), 0.2, role + if "fix-" in label: + return ( + True, + '{"summary":"addressed finding","changed_files":["tests/test_flow.py"]}', + 0.2, + role, + ) + # round-start re-review / verify passes are clean once fixed. + return True, _json("clean"), 0.1, role + + monkeypatch.setattr(mod, "_run_role_task", fake_task) + + success, report, _cost, _model = run_checkup_review_loop( + context=_ctx(tmp_path), + config=_config(fallback_reviewer_on_failure=True), + cwd=tmp_path, + quiet=True, + use_github_state=False, + ) + + assert success is True + # The fix round MUST run as a fresh same-family claude session. + assert any( + "fix-claude-for-claude" in label for _, label in calls + ), f"same-family fix round did not run; calls={calls!r}" + # A fresh verify (same-family re-review) MUST still run. + assert any( + "verify-claude" in label for _, label in calls + ), f"fresh verify did not run; calls={calls!r}" + # Honest disclosure in the rendered report. + assert "same-role-review-fix: true" in report, report + assert "role-independence: degraded (codex unavailable)" in report, report + assert "active-reviewer: claude" in report + # The superseded primary is dropped from the required set so the + # cloud verdict adapter ships on the degraded-but-clean result. + assert "| codex | failed (optional, superseded by claude) |" in report, report + assert "reviewer-status: codex=failed claude=clean fresh-final=clean" in report + # No bare "findings remain" deadlock stop reason. + assert "reported findings (fallback)" not in report, report + # Machine-readable final-state.json (consumed by pdd_cloud) carries + # the disclosure fields so downstream verdict consumers see the + # weaker guarantee, not just humans reading the markdown. + final_state = json.loads( + ( + tmp_path + / ".pdd" + / "checkup-review-loop" + / "issue-2-pr-1" + / "final-state.json" + ).read_text() + ) + assert final_state["role_independence"] == "degraded (codex unavailable)" + assert final_state["same_role_review_fix"] is True + assert final_state["mode"] == "single-role-review-fix" + # The whole point of the fix: the review-loop final gate must PASS + # (ship) instead of dead-locking. Assert the review-loop Machine + # Verdict passed rather than merely inspecting disclosure strings. + machine_verdict = report.split("### Machine Verdict", 1)[1] + assert '"stage": "review-loop"' in machine_verdict, machine_verdict + assert '"status": "passed"' in machine_verdict, machine_verdict + assert captured_state, "_finalize never called" + state = captured_state[-1] + assert state.same_role_review_fix is True + assert state.role_independence == "degraded (codex unavailable)" + + def test_issue_1941_auto_degrades_on_explicit_reviewer_fallback_findings( + self, monkeypatch: Any, tmp_path: Path + ) -> None: + """Issue #1941 (accepted/live config): reviewer=codex, fixer=codex, + reviewer_fallback=claude, codex family dead. + + The primary reviewer (codex) hard-fails, the EXPLICIT ``reviewer_fallback`` + (claude) reviews and reports real findings, and the configured fixer IS + the dead codex family. The loop MUST auto-degrade — automatically, with + NO ``fallback_reviewer_on_failure`` opt-in — and run a fresh same-family + claude fix (never target dead codex), keep the required fresh verify, and + disclose the weaker guarantee. Before the fix this deadlocked with + "Fixer codex could not address claude's findings". Reproduces + test_repo#4234/#4235 via the explicit-fallback path (the previous #1941 + regression only covered the opt-in ``_maybe_run_fallback_reviewer`` arm). + """ + from pdd.checkup_review_loop import run_checkup_review_loop + import pdd.checkup_review_loop as mod + + self._patch_io(monkeypatch, tmp_path) + calls: List[Tuple[str, str]] = [] + finding = self._finding() + + captured_state: List[Any] = [] + real_finalize = mod._finalize + + def capture_finalize(context_arg, state_arg, reviewers_arg, artifacts_dir_arg): + captured_state.append(state_arg) + return real_finalize( + context_arg, state_arg, reviewers_arg, artifacts_dir_arg + ) + + monkeypatch.setattr(mod, "_finalize", capture_finalize) + + def fake_task(role: str, instruction: str, cwd: Path, **kwargs: Any): + label = kwargs["label"] + calls.append((role, label)) + if role == "codex": + # Codex auth dead pool-wide (pdd_cloud#2897) — for BOTH the + # primary review AND any attempt to use it as the fixer. + return False, "exit code 1\nauthentication failed: 401", 0.0, "" + # claude: + if "fix-" in label: + return ( + True, + '{"summary":"addressed finding","changed_files":["tests/test_flow.py"]}', + 0.2, + role, + ) + if "verify" in label: + return True, _json("clean"), 0.1, role + # The explicit reviewer_fallback review runs with mode="review" + # (label ``...-review-claude-round1``) and reports real findings. + return True, _json("findings", [finding]), 0.2, role + + monkeypatch.setattr(mod, "_run_role_task", fake_task) + + success, report, _cost, _model = run_checkup_review_loop( + context=_ctx(tmp_path), + # The accepted/live shape: reviewer==fixer==codex (so the live + # ``--allow-same-reviewer-fixer`` is appended) with an explicit + # cross-family reviewer_fallback. NOTE: ``fallback_reviewer_on_failure`` + # is deliberately NOT set — the degrade must be automatic. + config=_config( + reviewer="codex", + fixer="codex", + reviewer_fallback="claude", + allow_same_reviewer_fixer=True, + ), + cwd=tmp_path, + quiet=True, + use_github_state=False, + ) + + assert success is True + # The fix round MUST run as a fresh same-family claude session and MUST + # never target the dead codex fixer. + assert any( + "fix-claude-for-claude" in label for _, label in calls + ), f"same-family fix round did not run; calls={calls!r}" + assert not any( + role == "codex" and "fix-" in label for role, label in calls + ), f"fix must never target dead codex; calls={calls!r}" + # A fresh verify (same-family re-review) MUST still run. + assert any( + "verify-claude" in label for _, label in calls + ), f"fresh verify did not run; calls={calls!r}" + # Honest disclosure in the rendered report. + assert "same-role-review-fix: true" in report, report + assert "role-independence: degraded (codex unavailable)" in report, report + assert "active-reviewer: claude" in report + assert "| codex | failed (optional, superseded by claude) |" in report, report + assert "reviewer-status: codex=failed claude=clean fresh-final=clean" in report + # No bare deadlock: the pre-fix behavior stopped with this exact reason. + assert "Fixer codex could not address" not in report, report + # Machine-readable final-state.json carries the disclosure fields. + final_state = json.loads( + ( + tmp_path + / ".pdd" + / "checkup-review-loop" + / "issue-2-pr-1" + / "final-state.json" + ).read_text() + ) + assert final_state["role_independence"] == "degraded (codex unavailable)" + assert final_state["same_role_review_fix"] is True + assert final_state["mode"] == "single-role-review-fix" + # The review-loop Machine Verdict must PASS (ship) rather than deadlock. + machine_verdict = report.split("### Machine Verdict", 1)[1] + assert '"stage": "review-loop"' in machine_verdict, machine_verdict + assert '"status": "passed"' in machine_verdict, machine_verdict + assert captured_state, "_finalize never called" + state = captured_state[-1] + assert state.same_role_review_fix is True + assert state.role_independence == "degraded (codex unavailable)" + + def test_issue_1941_independent_fixer_fallback_preferred_over_degrade( + self, monkeypatch: Any, tmp_path: Path + ) -> None: + """Issue #1941: when an eligible INDEPENDENT fixer_fallback exists, the + loop must prefer it over collapsing to a same-family session. Config: + reviewer=codex, fixer=codex, reviewer_fallback=claude, fixer_fallback=gemini, + codex down. Claude reviews+reports; the dead codex fixer is tried, then the + independent gemini fallback fixer runs — role independence is NOT degraded. + """ + from pdd.checkup_review_loop import run_checkup_review_loop + import pdd.checkup_review_loop as mod + + self._patch_io(monkeypatch, tmp_path) + calls: List[Tuple[str, str]] = [] + finding = self._finding() + captured_state: List[Any] = [] + real_finalize = mod._finalize + + def capture_finalize(context_arg, state_arg, reviewers_arg, artifacts_dir_arg): + captured_state.append(state_arg) + return real_finalize(context_arg, state_arg, reviewers_arg, artifacts_dir_arg) + + monkeypatch.setattr(mod, "_finalize", capture_finalize) + + def fake_task(role: str, instruction: str, cwd: Path, **kwargs: Any): + label = kwargs["label"] + calls.append((role, label)) + if role == "codex": + return False, "exit code 1\nauthentication failed: 401", 0.0, "" + if role == "gemini": + if "fix-" in label: + return ( + True, + '{"summary":"fixed","changed_files":["tests/test_flow.py"]}', + 0.2, + role, + ) + return True, _json("clean"), 0.1, role + # claude: explicit reviewer_fallback review reports findings; verify clean. + if "verify" in label: + return True, _json("clean"), 0.1, role + if "review" in label: + return True, _json("findings", [finding]), 0.2, role + return True, _json("clean"), 0.1, role + + monkeypatch.setattr(mod, "_run_role_task", fake_task) + + success, report, _cost, _model = run_checkup_review_loop( + context=_ctx(tmp_path), + config=_config( + reviewer="codex", + fixer="codex", + reviewer_fallback="claude", + fixer_fallback="gemini", + allow_same_reviewer_fixer=True, + ), + cwd=tmp_path, + quiet=True, + use_github_state=False, + ) + + assert success is True + # The dead configured fixer is tried, THEN the independent gemini fallback. + assert any("fix-codex-for-claude" in label for _, label in calls), calls + assert any("fix-gemini-for-claude" in label for _, label in calls), calls + # Never collapse to a same-family claude fixer when gemini is available. + assert not any( + role == "claude" and "fix-" in label for role, label in calls + ), calls + # Role independence is NOT degraded — an independent fixer ran. + assert "role-independence: independent" in report, report + assert "degraded (codex unavailable)" not in report, report + final_state = json.loads( + (tmp_path / ".pdd" / "checkup-review-loop" / "issue-2-pr-1" + / "final-state.json").read_text() + ) + assert final_state["role_independence"] == "independent" + assert captured_state and captured_state[-1].role_independence == "independent" + + def test_issue_1941_review_only_never_auto_degrades( + self, monkeypatch: Any, tmp_path: Path + ) -> None: + """Issue #1941: review-only mode runs NO fixer, so the explicit-fallback + auto-degrade must NOT fire (else the report falsely claims a same-role + fix that never happened). reviewer=fixer=codex, reviewer_fallback=claude, + review_only=True, codex down.""" + from pdd.checkup_review_loop import run_checkup_review_loop + import pdd.checkup_review_loop as mod + + self._patch_io(monkeypatch, tmp_path) + calls: List[Tuple[str, str]] = [] + finding = self._finding() + + def fake_task(role: str, instruction: str, cwd: Path, **kwargs: Any): + calls.append((role, kwargs["label"])) + if role == "codex": + return False, "exit code 1\nauthentication failed: 401", 0.0, "" + if "review" in kwargs["label"]: + return True, _json("findings", [finding]), 0.2, role + return True, _json("clean"), 0.1, role + + monkeypatch.setattr(mod, "_run_role_task", fake_task) + + success, report, _cost, _model = run_checkup_review_loop( + context=_ctx(tmp_path), + config=_config( + reviewer="codex", + fixer="codex", + reviewer_fallback="claude", + allow_same_reviewer_fixer=True, + review_only=True, + ), + cwd=tmp_path, + quiet=True, + use_github_state=False, + ) + + assert success is True + # No fixer is ever invoked in review-only mode. + assert not any("fix-" in label for _, label in calls), calls + # And the run must NOT be falsely disclosed as a degraded same-role fix. + assert "same-role-review-fix: true" not in report, report + assert "degraded (codex unavailable)" not in report, report + + def test_issue_1941_both_families_alive_stays_independent( + self, monkeypatch: Any, tmp_path: Path + ) -> None: + """Issue #1941 AC: when both families are alive, behavior is + unchanged — no degrade, no same-role stamp, and the fallback + reviewer's role is never promoted to fix its own review.""" + from pdd.checkup_review_loop import run_checkup_review_loop + import pdd.checkup_review_loop as mod + + self._patch_io(monkeypatch, tmp_path) + calls: List[Tuple[str, str]] = [] + + def fake_task(role: str, instruction: str, cwd: Path, **kwargs: Any): + calls.append((role, kwargs["label"])) + # codex reviews clean on the first pass; claude never needed. + return True, _json("clean"), 0.1, role + + monkeypatch.setattr(mod, "_run_role_task", fake_task) + + success, report, _cost, _model = run_checkup_review_loop( + context=_ctx(tmp_path), + config=_config(fallback_reviewer_on_failure=True), + cwd=tmp_path, + quiet=True, + use_github_state=False, + ) + + assert success is True + assert "role-independence: independent" in report, report + assert "same-role-review-fix: false" in report, report + assert not any(role == "claude" for role, _ in calls), calls + + def test_issue_1941_degraded_same_family_fixer_failure_names_vacancy( + self, monkeypatch: Any, tmp_path: Path + ) -> None: + """Issue #1941 AC: if the degraded same-family fixer ALSO fails, the + loop stops with a precise reason that names the vacancy — never a bare + "findings remain" that hides why no independent fixer ran.""" + from pdd.checkup_review_loop import run_checkup_review_loop + import pdd.checkup_review_loop as mod + + self._patch_io(monkeypatch, tmp_path) + calls: List[Tuple[str, str]] = [] + finding = self._finding() + + def fake_task(role: str, instruction: str, cwd: Path, **kwargs: Any): + label = kwargs["label"] + calls.append((role, label)) + if role == "codex": + return False, "exit code 1\nauthentication failed: 401", 0.0, "" + if "fallback" in label: + return True, _json("findings", [finding]), 0.2, role + if "fix-" in label: + # The degraded same-family fixer hits its own outage. + return False, "exit code 1\nrate limit exceeded 429", 0.0, role + return True, _json("clean"), 0.1, role + + monkeypatch.setattr(mod, "_run_role_task", fake_task) + + success, report, _cost, _model = run_checkup_review_loop( + context=_ctx(tmp_path), + config=_config(fallback_reviewer_on_failure=True), + cwd=tmp_path, + quiet=True, + use_github_state=False, + ) + + assert success is True + # The degrade was attempted (fix round ran) … + assert any( + "fix-claude-for-claude" in label for _, label in calls + ), calls + # … and the terminal reason names the vacancy explicitly. + assert "role-independence degraded (codex unavailable)" in report, report + assert "could not address" in report, report + def test_diagnostics_tail_scrubs_secrets( self, monkeypatch: Any, tmp_path: Path ) -> None: @@ -7066,7 +7505,12 @@ def test_code_present_with_prompt_missing_is_refused(self, tmp_path: Path) -> No tmp_path, [_prompt_module("agentic_update_python.prompt", "pdd/agentic_update.py")], ) - # Code present, prompt absent. + # Code present, prompt absent. pdd's own checkout always keeps a + # populated ``pdd/prompts/`` dir (only THIS module's prompt is gone), so + # seed it here for a realistic pdd layout — the #1957 resolver reads it + # to keep this repo's owning prompts under ``pdd/prompts`` rather than + # the external-repo default ``prompts``. + (tmp_path / "pdd" / "prompts").mkdir(parents=True, exist_ok=True) self._seed_code(tmp_path, "pdd/agentic_update.py") reason = _check_prompt_source_guard(tmp_path, ["pdd/agentic_update.py"]) @@ -7179,8 +7623,11 @@ def test_code_modified_with_prompt_deleted_only_is_refused( # Code persists on disk (modified, not deleted). (tmp_path / "pdd").mkdir(parents=True, exist_ok=True) (tmp_path / "pdd" / "foo.py").write_text("modified code\n", encoding="utf-8") - # Prompt was deleted in this change set - intentionally NOT - # creating it on disk. + # pdd's own checkout always keeps a populated ``pdd/prompts/`` dir, so + # seed it for a realistic pdd layout (the #1957 resolver reads it to keep + # owning prompts under ``pdd/prompts``). Only THIS module's prompt was + # deleted in this change set - intentionally NOT creating it on disk. + (tmp_path / "pdd" / "prompts").mkdir(parents=True, exist_ok=True) reason = _check_prompt_source_guard( tmp_path, @@ -13939,3 +14386,192 @@ def test_passed_short_circuits(self): _review_loop_failure_category(ReviewLoopState(), True, [sot]) == FINAL_GATE_CATEGORY_PASSED ) + + +class TestTargetPromptsRootResolution1957: + """Checkup repair must resolve owning prompts against the TARGET repo's + prompt layout, not pdd's self-hosted ``pdd/prompts`` tree (issue #1957). + + pdd is self-hosted, so before this fix the repair loop only ever worked on + pdd's own checkout: it string-joined a hardcoded ``pdd/prompts/`` prefix and + on every OTHER repo probed a nonexistent path, then declared the owning + prompt "deleted" and refused to repair. + """ + + @staticmethod + def _git_init(worktree: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=worktree, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=worktree, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], cwd=worktree, check=True + ) + + def _seed_external_repo( + self, + worktree: Path, + *, + prompts_dir: str = "prompts", + pddrc: bool = False, + code_path: str = "src/foo.py", + prompt_filename: str = "foo_python.prompt", + prompt_exists: bool = True, + ) -> None: + """Seed a NON-pdd repo whose prompts live under ``prompts_dir`` (not + ``pdd/prompts``), commit ``architecture.json`` to HEAD, and drop the + code (+ optional owning prompt) into the worktree.""" + self._git_init(worktree) + if pddrc: + (worktree / ".pddrc").write_text( + "version: '1.0'\n" + "contexts:\n" + " default:\n" + " paths: ['**']\n" + " defaults:\n" + f" prompts_dir: '{prompts_dir}'\n", + encoding="utf-8", + ) + (worktree / "architecture.json").write_text( + json.dumps( + [{"filename": prompt_filename, "filepath": code_path}], indent=2 + ), + encoding="utf-8", + ) + subprocess.run(["git", "add", "-A"], cwd=worktree, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "seed"], cwd=worktree, check=True + ) + code = worktree / code_path + code.parent.mkdir(parents=True, exist_ok=True) + code.write_text("# generated\n", encoding="utf-8") + if prompt_exists: + prompt = worktree / prompts_dir / prompt_filename + prompt.parent.mkdir(parents=True, exist_ok=True) + prompt.write_text("prompt body\n", encoding="utf-8") + + def test_resolve_external_prompts_layout(self, tmp_path: Path) -> None: + from pdd.checkup_review_loop import _resolve_target_prompts_root + + (tmp_path / "prompts").mkdir() + assert _resolve_target_prompts_root(tmp_path) == Path("prompts") + + def test_resolve_pdd_self_hosted_layout(self, tmp_path: Path) -> None: + from pdd.checkup_review_loop import _resolve_target_prompts_root + + # pdd's own checkout has no top-level ``prompts/`` dir (only the + # in-package one) — the resolver must still find it. + (tmp_path / "pdd" / "prompts").mkdir(parents=True) + assert _resolve_target_prompts_root(tmp_path) == Path("pdd/prompts") + + def test_resolve_follows_prompts_symlink_to_real_tracked_path( + self, tmp_path: Path + ) -> None: + from pdd.checkup_review_loop import _resolve_target_prompts_root + + # Real pdd keeps ``prompts -> pdd/prompts``; git tracks the real + # ``pdd/prompts/...`` paths, so the resolver must return the symlink + # TARGET (else co-edit detection misses the git-reported path). + (tmp_path / "pdd" / "prompts").mkdir(parents=True) + (tmp_path / "prompts").symlink_to(tmp_path / "pdd" / "prompts") + assert _resolve_target_prompts_root(tmp_path) == Path("pdd/prompts") + + def test_resolve_honours_pddrc_prompts_dir(self, tmp_path: Path) -> None: + from pdd.checkup_review_loop import _resolve_target_prompts_root + + (tmp_path / ".pddrc").write_text( + "version: '1.0'\n" + "contexts:\n" + " default:\n" + " paths: ['**']\n" + " defaults:\n" + " prompts_dir: 'contracts/prompts'\n", + encoding="utf-8", + ) + (tmp_path / "contracts" / "prompts").mkdir(parents=True) + assert _resolve_target_prompts_root(tmp_path) == Path("contracts/prompts") + + def test_load_prompt_source_map_uses_target_layout( + self, tmp_path: Path + ) -> None: + from pdd.checkup_review_loop import _load_prompt_source_map + + self._seed_external_repo(tmp_path) + mapping = _load_prompt_source_map(tmp_path) + # Must map to the TARGET repo's ``prompts/`` file — NOT ``pdd/prompts``. + assert mapping == {"src/foo.py": "prompts/foo_python.prompt"}, mapping + + def test_repair_engages_on_external_layout(self, tmp_path: Path) -> None: + """The #1957 reproduction: a code-only change to a registered module in + a non-pdd repo must classify as repairable ``drift`` (owning prompt + found under the target's ``prompts/``), NOT ``missing_prompt``.""" + from pdd.checkup_review_loop import _prompt_source_offenders + + self._seed_external_repo(tmp_path) + offenders = _prompt_source_offenders(tmp_path, ["src/foo.py"]) + assert offenders == [ + { + "code_path": "src/foo.py", + "prompt_path": "prompts/foo_python.prompt", + "kind": "drift", + } + ], offenders + + def test_repair_engages_on_external_layout_via_pddrc( + self, tmp_path: Path + ) -> None: + from pdd.checkup_review_loop import _prompt_source_offenders + + self._seed_external_repo( + tmp_path, prompts_dir="contracts/prompts", pddrc=True + ) + offenders = _prompt_source_offenders(tmp_path, ["src/foo.py"]) + assert [o["kind"] for o in offenders] == ["drift"], offenders + assert offenders[0]["prompt_path"] == "contracts/prompts/foo_python.prompt" + + def test_genuinely_deleted_prompt_still_detected_external_layout( + self, tmp_path: Path + ) -> None: + """A prompt that is genuinely absent from the target's resolved prompt + root is still reported ``missing_prompt`` — the fix narrows the + false-positive, it must not blind the guard to real deletions.""" + from pdd.checkup_review_loop import _prompt_source_offenders + + self._seed_external_repo(tmp_path, prompt_exists=False) + # Keep a real ``prompts/`` dir so resolution locks to the target root. + (tmp_path / "prompts").mkdir(exist_ok=True) + offenders = _prompt_source_offenders(tmp_path, ["src/foo.py"]) + assert [o["kind"] for o in offenders] == ["missing_prompt"], offenders + + def test_extract_arch_pairs_uses_target_layout(self, tmp_path: Path) -> None: + from pdd.checkup_review_loop import ( + _extract_arch_pairs, + _resolve_target_prompts_root, + ) + + (tmp_path / "prompts").mkdir() + data = [{"filename": "foo_python.prompt", "filepath": "src/foo.py"}] + pairs = _extract_arch_pairs(data, _resolve_target_prompts_root(tmp_path)) + assert pairs == {("src/foo.py", "prompts/foo_python.prompt")}, pairs + + def test_registry_guard_no_false_implicit_retirement_external_layout( + self, tmp_path: Path + ) -> None: + """The registry-edit guard's implicit-retirement trigger probes each + registered prompt on disk. Under the old hardcoded ``pdd/prompts`` + prefix every external-repo prompt looked missing, firing the guard + spuriously. With the target-layout fix, an untouched external repo + short-circuits to ``None``.""" + from pdd.checkup_review_loop import ( + _check_architecture_registry_edit_guard, + ) + + self._seed_external_repo(tmp_path) + # No architecture.json edit and every registered pair present on disk + # under the target layout → nothing to enforce. + assert ( + _check_architecture_registry_edit_guard(tmp_path, ["src/foo.py"]) + is None + ) diff --git a/tests/test_ci_validation.py b/tests/test_ci_validation.py index f4609f249..890a55169 100644 --- a/tests/test_ci_validation.py +++ b/tests/test_ci_validation.py @@ -10,6 +10,7 @@ import pytest from pdd.ci_validation import ( + _commit_ci_fix, _check_run_bucket, _classify_check_result, _classify_external_ci_failure, @@ -2883,3 +2884,71 @@ def test_loop_fails_closed_when_timeout_without_any_checks_read(tmp_path: Path) # the unread case posts a CI-failure comment instead. info_comment.assert_not_called() failure_comment.assert_called_once() + + +def test_ci_fix_runs_caller_pre_commit_check_before_push(tmp_path: Path) -> None: + """A caller invariant can reject CI-agent edits before commit/push.""" + failed_check = {"name": "tests", "state": "FAILURE", "bucket": "fail"} + with patch("pdd.ci_validation._find_open_pr_number", return_value=1998), \ + patch("pdd.ci_validation.detect_ci_system", return_value="github_actions"), \ + patch("pdd.ci_validation._get_pr_head_sha", return_value=LIVE_HEAD_SHA), \ + patch("pdd.ci_validation._poll_required_checks", return_value=("failed", [failed_check])), \ + patch("pdd.ci_validation._collect_failure_logs", return_value="failure"), \ + patch("pdd.ci_validation._commit_ci_fix") as commit_fix, \ + patch("pdd.ci_validation.time.sleep", return_value=None): + success, message, cost = run_ci_validation_loop( + cwd=tmp_path, + repo_owner="promptdriven", + repo_name="pdd", + issue_number=1939, + max_retries=1, + step_template="{ci_failure_logs}", + run_agentic_task_fn=lambda **_: ( + True, + "CI_FIX_APPLIED", + 0.25, + "model", + ), + timeout=60.0, + quiet=True, + pre_commit_check=lambda _files: "mock-contract divergence", + commit_files=lambda: ["pdd/owned.py"], + ) + + assert success is False + assert cost == 0.25 + assert "mock-contract divergence" in message + commit_fix.assert_not_called() + + +def test_commit_ci_fix_excludes_unowned_dirty_and_prestaged_files( + tmp_path: Path, +) -> None: + """The remediation commit pathspec is identical to its validated allowlist.""" + completed = subprocess.CompletedProcess([], 0, stdout="", stderr="") + with patch( + "pdd.agentic_e2e_fix_orchestrator._get_modified_and_untracked", + return_value={"pdd/owned.py", "pdd/preexisting.py"}, + ), patch( + "pdd.agentic_e2e_fix_orchestrator._push_with_retry", + return_value=(True, ""), + ), patch( + "pdd.ci_validation._run_command", + return_value=completed, + ) as run_command: + success, message = _commit_ci_fix( + cwd=tmp_path, + repo_owner="promptdriven", + repo_name="pdd", + issue_number=1939, + allowed_files=["pdd/owned.py"], + ) + + assert success is True + assert "1 CI fix file" in message + commands = [call.args[0] for call in run_command.call_args_list] + assert ["git", "add", "--", "pdd/owned.py"] in commands + commit_command = next(command for command in commands if command[:2] == ["git", "commit"]) + assert "--only" in commit_command + assert "pdd/owned.py" in commit_command + assert "pdd/preexisting.py" not in commit_command diff --git a/tests/test_code_generator_main.py b/tests/test_code_generator_main.py index 1cae36964..071511558 100644 --- a/tests/test_code_generator_main.py +++ b/tests/test_code_generator_main.py @@ -1023,7 +1023,195 @@ def git_command_side_effect(*args, **kwargs): add_called_for_prompt = True break assert add_called_for_prompt - mock_subprocess_run_fixture.side_effect = None + mock_subprocess_run_fixture.side_effect = None + + +# --- Tests for #1938/#1940 base-branch original fallback (test_repo#4232) --- +# In the cloud sync-after-change flow the prompt edit is already committed on the +# branch (on-disk == HEAD) and the pre-change version is not reliably found in local +# history, so surgical/incremental generation used to silently fall back to full +# regeneration and re-drop declared symbols. The base-branch fallback resolves the +# "original" prompt from PDD_SYNC_BASE_REF (default origin/main) as a last resort. + +def _fake_run_git_no_local_history(command, cwd=None): + """Force the local-history search to find nothing. + + Making `git rev-parse --show-toplevel` fail leaves git_root_path_obj None so + the prior-different-version search is skipped; every other git call is a + successful no-op (staging status/diff/add during incremental rollback setup). + """ + if command[:2] == ["git", "rev-parse"] and "--show-toplevel" in command: + return (128, "", "not a git repository") + return (0, "", "") + + +def test_incremental_base_ref_fallback_used_when_no_local_history( + mock_ctx, temp_dir_setup, mock_construct_paths_fixture, + mock_incremental_generator_fixture, mock_env_vars, monkeypatch +): + """on-disk == HEAD, no distinct prior version in history, but base-ref returns a + different prompt -> can_attempt_incremental becomes True using base content.""" + monkeypatch.delenv("PDD_SYNC_BASE_REF", raising=False) + on_disk_prompt = "New prompt content committed on this branch" + base_prompt = "Original prompt content on base branch" + + prompt_file_path = temp_dir_setup["prompts_dir"] / "base_ref_prompt.prompt" + create_file(prompt_file_path, on_disk_prompt) + output_file_path = temp_dir_setup["output_dir"] / "base_ref_output.py" + create_file(output_file_path, "Existing mature code") + + mock_construct_paths_fixture.return_value = ( + {}, + {"prompt_file": on_disk_prompt}, + {"output": str(output_file_path)}, + "python", + ) + mock_incremental_generator_fixture.return_value = ("Base-ref updated code", True, 0.004, "base_inc_model") + + def fake_get_content(file_path, git_ref="HEAD"): + if git_ref == "HEAD": + return on_disk_prompt # on-disk matches HEAD + if git_ref == "origin/main": + return base_prompt + return None + + with patch("pdd.code_generator_main.is_git_repository", return_value=True), \ + patch("pdd.code_generator_main.get_git_content_at_ref", side_effect=fake_get_content), \ + patch("pdd.code_generator_main._run_git_command", side_effect=_fake_run_git_no_local_history): + code, incremental, cost, model = code_generator_main( + mock_ctx, str(prompt_file_path), str(output_file_path), None, False + ) + + assert incremental + assert code == "Base-ref updated code" + call_kwargs = mock_incremental_generator_fixture.call_args.kwargs + assert call_kwargs["original_prompt"] == base_prompt + assert call_kwargs["new_prompt"] == on_disk_prompt + + +def test_incremental_base_ref_fallback_missing_ref_falls_back_to_full( + mock_ctx, temp_dir_setup, mock_construct_paths_fixture, mock_local_generator_fixture, + mock_incremental_generator_fixture, mock_env_vars, mock_rich_console_fixture, monkeypatch +): + """base-ref returns None (no such ref) -> no incremental attempt, honest full + generation, loud degradation notice, and no crash.""" + monkeypatch.delenv("PDD_SYNC_BASE_REF", raising=False) + mock_ctx.obj['local'] = True + on_disk_prompt = "New prompt content committed on this branch" + + prompt_file_path = temp_dir_setup["prompts_dir"] / "base_ref_missing.prompt" + create_file(prompt_file_path, on_disk_prompt) + output_file_path = temp_dir_setup["output_dir"] / "base_ref_missing_output.py" + create_file(output_file_path, "Existing mature code") + + mock_construct_paths_fixture.return_value = ( + {}, + {"prompt_file": on_disk_prompt}, + {"output": str(output_file_path)}, + "python", + ) + + def fake_get_content(file_path, git_ref="HEAD"): + if git_ref == "HEAD": + return on_disk_prompt + return None # base-ref does not exist + + with patch("pdd.code_generator_main.is_git_repository", return_value=True), \ + patch("pdd.code_generator_main.get_git_content_at_ref", side_effect=fake_get_content), \ + patch("pdd.code_generator_main._run_git_command", side_effect=_fake_run_git_no_local_history): + code, incremental, cost, model = code_generator_main( + mock_ctx, str(prompt_file_path), str(output_file_path), None, False + ) + + assert not incremental + mock_incremental_generator_fixture.assert_not_called() + mock_local_generator_fixture.assert_called_once() + printed = " ".join(str(c.args[0]) for c in mock_rich_console_fixture.call_args_list if c.args) + assert "Surgical/incremental generation unavailable" in printed + + +def test_incremental_base_ref_fallback_identical_to_current_not_used( + mock_ctx, temp_dir_setup, mock_construct_paths_fixture, mock_local_generator_fixture, + mock_incremental_generator_fixture, mock_env_vars, monkeypatch +): + """base-ref content equals the current prompt -> not used, no spurious + incremental; honest full generation runs instead.""" + monkeypatch.delenv("PDD_SYNC_BASE_REF", raising=False) + mock_ctx.obj['local'] = True + on_disk_prompt = "Identical prompt content on both branches" + + prompt_file_path = temp_dir_setup["prompts_dir"] / "base_ref_identical.prompt" + create_file(prompt_file_path, on_disk_prompt) + output_file_path = temp_dir_setup["output_dir"] / "base_ref_identical_output.py" + create_file(output_file_path, "Existing mature code") + + mock_construct_paths_fixture.return_value = ( + {}, + {"prompt_file": on_disk_prompt}, + {"output": str(output_file_path)}, + "python", + ) + + def fake_get_content(file_path, git_ref="HEAD"): + # Both HEAD and base-ref match the current on-disk prompt. + return on_disk_prompt + + with patch("pdd.code_generator_main.is_git_repository", return_value=True), \ + patch("pdd.code_generator_main.get_git_content_at_ref", side_effect=fake_get_content), \ + patch("pdd.code_generator_main._run_git_command", side_effect=_fake_run_git_no_local_history): + code, incremental, cost, model = code_generator_main( + mock_ctx, str(prompt_file_path), str(output_file_path), None, False + ) + + assert not incremental + mock_incremental_generator_fixture.assert_not_called() + mock_local_generator_fixture.assert_called_once() + + +def test_incremental_base_ref_env_override_is_honored( + mock_ctx, temp_dir_setup, mock_construct_paths_fixture, + mock_incremental_generator_fixture, mock_env_vars, monkeypatch +): + """PDD_SYNC_BASE_REF overrides the default origin/main base ref.""" + monkeypatch.setenv("PDD_SYNC_BASE_REF", "origin/release-1.x") + on_disk_prompt = "New prompt content committed on this branch" + base_prompt = "Original prompt content on release-1.x" + + prompt_file_path = temp_dir_setup["prompts_dir"] / "base_ref_override.prompt" + create_file(prompt_file_path, on_disk_prompt) + output_file_path = temp_dir_setup["output_dir"] / "base_ref_override_output.py" + create_file(output_file_path, "Existing mature code") + + mock_construct_paths_fixture.return_value = ( + {}, + {"prompt_file": on_disk_prompt}, + {"output": str(output_file_path)}, + "python", + ) + mock_incremental_generator_fixture.return_value = ("Override updated code", True, 0.004, "override_model") + + seen_refs = [] + + def fake_get_content(file_path, git_ref="HEAD"): + seen_refs.append(git_ref) + if git_ref == "HEAD": + return on_disk_prompt + if git_ref == "origin/release-1.x": + return base_prompt + return None # default origin/main must NOT be consulted + + with patch("pdd.code_generator_main.is_git_repository", return_value=True), \ + patch("pdd.code_generator_main.get_git_content_at_ref", side_effect=fake_get_content), \ + patch("pdd.code_generator_main._run_git_command", side_effect=_fake_run_git_no_local_history): + code, incremental, cost, model = code_generator_main( + mock_ctx, str(prompt_file_path), str(output_file_path), None, False + ) + + assert incremental + assert "origin/release-1.x" in seen_refs + assert "origin/main" not in seen_refs + call_kwargs = mock_incremental_generator_fixture.call_args.kwargs + assert call_kwargs["original_prompt"] == base_prompt def test_incremental_gen_fallback_to_full_on_generator_suggestion( @@ -7216,6 +7404,40 @@ def test_test_churn_gate_raises_above_threshold(self, monkeypatch): assert excinfo.value.churn_ratio > excinfo.value.threshold assert "Test churn threshold exceeded for update_main_test_Python.prompt:" in str(excinfo.value) + def test_churn_block_stamps_parent_nonce_from_fd(self, monkeypatch): + """Issue #1903 §B.4 (round 8): when the parent hands the child a nonce over + the private pipe FD, the child stamps it into the churn block so the parent + can authenticate the block. No FD -> no nonce line (standalone).""" + import os + import pdd.code_generator_main as cg + + # Reset the once-only module cache between simulated child processes. + def _fresh(): + cg._CHURN_NONCE_CACHE = None + cg._CHURN_NONCE_READ = False + + nonce = "0123456789abcdef0123456789abcdef" + r, w = os.pipe() + os.write(w, nonce.encode("ascii")) + os.close(w) + monkeypatch.setenv(cg._CHURN_NONCE_ENV, str(r)) + _fresh() + try: + err = cg.TestChurnError("m.prompt", "src/__test__/x.test.tsx", 0.9, 0.4, 100, 5) + finally: + try: + os.close(r) + except OSError: + pass + assert f"nonce: {nonce}" in str(err) + + # Standalone (no FD env): no nonce line is emitted. + monkeypatch.delenv(cg._CHURN_NONCE_ENV, raising=False) + _fresh() + err2 = cg.TestChurnError("m.prompt", "tests/test_x.py", 0.9, 0.4, 100, 5) + assert "nonce:" not in str(err2) + _fresh() + def test_test_churn_allows_pure_additive_growth(self, monkeypatch): from pdd.code_generator_main import _verify_test_churn diff --git a/tests/test_durable_sync_runner.py b/tests/test_durable_sync_runner.py index 419751c3c..a8ec799af 100644 --- a/tests/test_durable_sync_runner.py +++ b/tests/test_durable_sync_runner.py @@ -69,6 +69,18 @@ def _push_durable_head(self): return False, "simulated push outage" +_NEEDS_REVIEW_NOTE = "`foo`: adopted test kept unchanged — flagged for review (#1903)." + + +class NeedsReviewDurableRunner(DurableSyncRunner): + """Stubs the child sync as a success that flags the module needs-review — + mirrors the issue #1903 §B.4 never-block outcome (round 8).""" + + def _run_child_sync(self, basename: str): + self.module_states[basename].needs_review = _NEEDS_REVIEW_NOTE + return True, 0.0, "" + + class MultiModuleDurableRunner(DurableSyncRunner): def _run_child_sync(self, basename: str): cwd = self.module_cwds[basename] @@ -107,13 +119,29 @@ def _runner(repo: Path, runner_cls=EmptyDurableRunner, **kwargs) -> DurableSyncR def test_parse_checkpoint_trailer_requires_supported_version_and_fields(): assert _parse_checkpoint_trailer( "PDD-Sync-Checkpoint-V1: issue=1328 module=src/foo" - ) == (1328, "src/foo") + ) == (1328, "src/foo", None) assert _parse_checkpoint_trailer( "PDD-Sync-Checkpoint-V2: issue=1328 module=src/foo" ) is None assert _parse_checkpoint_trailer("PDD-Sync-Checkpoint-V1: module=src/foo") is None +def test_checkpoint_trailer_round_trips_needs_review_note(tmp_path: Path): + """Issue #1903 §B.4 (round 8): a durable checkpoint must carry the adopted-test + ``needs_review`` note so a resumed run does not silently drop it.""" + import base64 + from pdd.durable_sync_runner import CHECKPOINT_TRAILER + + note = "`foo`: test churn kept the existing test (`src/foo.test.tsx`) — review." + enc = base64.urlsafe_b64encode(note.encode("utf-8")).decode("ascii").rstrip("=") + line = f"{CHECKPOINT_TRAILER}: issue=1328 module=src/foo review={enc}" + assert _parse_checkpoint_trailer(line) == (1328, "src/foo", note) + # A whitespace-free garbage review token degrades to None, never raises. + bad = f"{CHECKPOINT_TRAILER}: issue=1328 module=src/foo review=!!!notb64!!!" + parsed = _parse_checkpoint_trailer(bad) + assert parsed is not None and parsed[0] == 1328 and parsed[1] == "src/foo" + + def test_slugify_basename_adds_digest_to_avoid_worktree_name_collisions(): assert _slugify_basename("foo/bar") != _slugify_basename("foo-bar") assert _slugify_basename("foo/bar").startswith("foo-bar-") @@ -201,6 +229,25 @@ def test_resume_skips_modules_with_matching_issue_trailer(tmp_path: Path): assert after == before +def test_durable_resume_restores_needs_review_flag(tmp_path: Path): + """Issue #1903 §B.4 (round 8): a durable resume rebuilds state from checkpoint + trailers only; the adopted-test needs-review note must survive that round-trip + rather than reappearing as a clean success.""" + repo = _init_repo_with_remote(tmp_path) + first = _runner(repo, runner_cls=NeedsReviewDurableRunner) + success, message, _ = first.run() + assert success is True, message + assert first.module_states["foo"].needs_review == _NEEDS_REVIEW_NOTE + + # Resume in a fresh runner: no local JSON state — only the git trailer. + second = _runner(repo, runner_cls=NeedsReviewDurableRunner) + # Restore happens in _prepare_durable_branch before any child sync re-runs. + ok, msg = second._prepare_durable_branch() + assert ok, msg + assert second._resumed_modules == ["foo"] + assert second.module_states["foo"].needs_review == _NEEDS_REVIEW_NOTE + + def test_no_resume_ignores_existing_trailer_and_appends_checkpoint(tmp_path: Path): repo = _init_repo_with_remote(tmp_path) first = _runner(repo) @@ -246,6 +293,36 @@ def test_module_metadata_is_force_added_even_when_pdd_dir_is_ignored(tmp_path: P assert readme == "updated\n" +class OwnershipManifestRunner(DurableSyncRunner): + """Writes module metadata AND the shared greenfield-ownership manifest, + mirroring a child that greenfield-creates a co-located test (round 10).""" + + def _run_child_sync(self, basename: str): + cwd = self.module_cwds[basename] + (cwd / "README.md").write_text("updated\n", encoding="utf-8") + meta_dir = cwd / ".pdd" / "meta" + meta_dir.mkdir(parents=True, exist_ok=True) + (meta_dir / "foo_python.json").write_text( + json.dumps({"module": basename}), encoding="utf-8") + (meta_dir / "pdd_created_tests.json").write_text( + json.dumps(["src/__test__/foo.test.tsx"]), encoding="utf-8") + return True, 0.0, "" + + +def test_ownership_manifest_is_checkpointed_even_when_pdd_dir_is_ignored(tmp_path: Path): + # Round 10: the shared, non-module-prefixed ownership manifest must be + # force-added to the checkpoint (not rejected as unsafe), so a fresh-worktree + # resume keeps PDD's greenfield ownership provenance. + repo = _init_repo_with_remote(tmp_path) + runner = _runner(repo, runner_cls=OwnershipManifestRunner) + success, message, _ = runner.run() + assert success is True, message + manifest = _git( + repo, "show", "sync/issue-1328:.pdd/meta/pdd_created_tests.json" + ).stdout + assert json.loads(manifest) == ["src/__test__/foo.test.tsx"] + + def test_nested_module_metadata_is_force_added_for_module_cwd(tmp_path: Path): repo = _init_repo_with_remote(tmp_path) module_dir = repo / "packages" / "app" diff --git a/tests/test_e2e_issue_357_step9_keyerror.py b/tests/test_e2e_issue_357_step9_keyerror.py index f02925282..66a2e73e0 100644 --- a/tests/test_e2e_issue_357_step9_keyerror.py +++ b/tests/test_e2e_issue_357_step9_keyerror.py @@ -83,6 +83,8 @@ def test_step9_orchestrator_formats_prompt_without_keyerror(self, mock_cwd): "protect_tests": "false", "protect_tests_flag": "", "next_cycle": 6, # cycle_number + 1 + "mock_contract_audit_required": "true", + "mock_contract_test_files": "- `tests/test_issue_357.py`", } # Add all previous step outputs (steps 1-8) @@ -157,6 +159,8 @@ def test_step9_all_template_sections_format_correctly(self, mock_cwd): "protect_tests": "false", "protect_tests_flag": "", "next_cycle": 4, + "mock_contract_audit_required": "false", + "mock_contract_test_files": "- (none detected)", } for i in range(1, 9): diff --git a/tests/test_evidence_manifest.py b/tests/test_evidence_manifest.py index acc477a0f..2872daf1b 100644 --- a/tests/test_evidence_manifest.py +++ b/tests/test_evidence_manifest.py @@ -4,6 +4,7 @@ import hashlib import json import os +import runpy from pathlib import Path import jsonschema @@ -488,3 +489,17 @@ def test_contract_waivers_validate_against_schema(tmp_path: Path) -> None: assert contracts["status"] == "available" assert contracts["waivers"][0]["id"] == "W1" assert contracts["rules"]["R3"]["waiver_status"] == "active" + + +def test_repository_example_is_importable_and_runs( + capsys: pytest.CaptureFixture[str], +) -> None: + """The checked-in evidence example must import and exercise the real API.""" + example_path = Path(__file__).parents[1] / "context" / "evidence_manifest_example.py" + namespace = runpy.run_path(str(example_path)) + + payload = namespace["main"]() + + assert payload["schema_version"] == SCHEMA_VERSION + assert payload["outputs"][0]["path"] == "pdd/greeting.py" + assert "Evidence manifest example completed" in capsys.readouterr().out diff --git a/tests/test_fingerprint_invariant.py b/tests/test_fingerprint_invariant.py new file mode 100644 index 000000000..60a47cf4d --- /dev/null +++ b/tests/test_fingerprint_invariant.py @@ -0,0 +1,340 @@ +"""Post-command freshness invariant for every mutating command (#1926). + +The command bodies are kept offline by replacing only their LLM-producing +work. Their production metadata boundaries remain real: decorators, +single/repo update finalization, auto-deps, sync, and CI heal all have to +persist a fingerprint which the real drift classifier accepts as fresh. +""" +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import click +import pytest + +from pdd.auto_deps_main import auto_deps_main +from pdd.ci_drift_heal import _run_metadata_sync_safe +from pdd.operation_log import log_operation +from pdd.sync_determine_operation import ( + calculate_current_hashes, + read_fingerprint, + sync_determine_operation, +) +from pdd.sync_orchestration import _save_fingerprint_atomic +from pdd.update_main import _finalize_single_file_fingerprint + + +MUTATING_COMMANDS = ( + ("sync", "sync"), + ("generate", "decorator"), + ("example", "decorator"), + ("update", "update"), + ("update --all", "update"), + ("auto-deps", "auto-deps"), + ("fix", "decorator"), + ("CI heal", "ci-heal"), +) + + +@pytest.fixture +def fingerprint_unit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> tuple[Path, dict[str, Path]]: + root = tmp_path / "project" + paths = { + "prompt": root / "prompts" / "sample_python.prompt", + "code": root / "src" / "sample.py", + "example": root / "examples" / "sample_example.py", + "test": root / "tests" / "test_sample.py", + } + paths["test_files"] = [paths["test"]] + for path in paths.values(): + if isinstance(path, list): + continue + path.parent.mkdir(parents=True, exist_ok=True) + + (root / ".pddrc").write_text( + 'version: "1.0"\n' + "contexts:\n" + " default:\n" + " defaults:\n" + ' generate_output_path: "src/"\n' + ' example_output_path: "examples/"\n' + ' test_output_path: "tests/"\n' + ' default_language: "python"\n', + encoding="utf-8", + ) + paths["prompt"].write_text("% Goal\nCreate sample.\n", encoding="utf-8") + paths["code"].write_text("def sample():\n return 1\n", encoding="utf-8") + paths["example"].write_text( + "from sample import sample\nprint(sample())\n", + encoding="utf-8", + ) + paths["test"].write_text( + "def test_sample():\n assert True\n", + encoding="utf-8", + ) + monkeypatch.chdir(root) + return root, paths + + +def _run_decorated_finalizer(operation: str, paths: dict[str, Path]) -> None: + @log_operation( + operation=operation, + clears_run_report=True, + updates_fingerprint=True, + ) + def mutation(*, prompt_file: str): + if operation == "generate": + return "written", False, 0.25, "invariant-model" + if operation == "fix": + return {}, "fixed", True, 1, 0.25, "invariant-model" + return "written", 0.25, "invariant-model" + + with patch( + "pdd.sync_determine_operation.get_pdd_file_paths", + return_value=paths, + ): + mutation(prompt_file=str(paths["prompt"])) + + +def _run_update_finalizer(paths: dict[str, Path]) -> None: + with patch( + "pdd.operation_log.infer_module_identity", + return_value=("sample", "python"), + ), patch( + "pdd.operation_log._clear_run_report_before_fingerprint", + return_value=True, + ): + _finalize_single_file_fingerprint( + prompt_path=paths["prompt"], + code_path=paths["code"], + sync_metadata=False, + dry_run=False, + quiet=True, + cost=0.25, + model="invariant-model", + ) + + +def _run_auto_deps_finalizer(root: Path, paths: dict[str, Path]) -> None: + original = paths["prompt"].read_text(encoding="utf-8") + csv_path = root / "project_dependencies.csv" + ctx = click.Context(click.Command("auto-deps")) + ctx.obj = { + "quiet": True, + "force": True, + "strength": 0.5, + "temperature": 0.0, + "time": 0.25, + } + with patch( + "pdd.auto_deps_main.construct_paths", + return_value=( + {}, + {"prompt_file": original}, + {"output": str(paths["prompt"]), "csv": str(csv_path)}, + "python", + ), + ), patch( + "pdd.auto_deps_main.insert_includes", + return_value=(original, "", 0.25, "invariant-model"), + ), patch( + "pdd.auto_deps_main.sanitize_prompt_output", + return_value=(original, []), + ), patch( + "pdd.auto_deps_main.merge_auto_deps_includes_from_cwd", + return_value={"updated": False, "added_dependencies": []}, + ), patch( + "pdd.auto_deps_main._clear_run_report_before_fingerprint", + return_value=True, + ): + result = auto_deps_main( + ctx=ctx, + prompt_file=str(paths["prompt"]), + directory_path=str(root), + auto_deps_csv_path=str(csv_path), + output=str(paths["prompt"]), + ) + + assert result[0] == original + fingerprint = read_fingerprint("sample", "python", paths=paths) + assert fingerprint is not None + assert fingerprint.command == "auto-deps" + + +def _run_ci_heal_finalizer(paths: dict[str, Path]) -> None: + with patch( + "pdd.metadata_sync.run_metadata_sync", + return_value=SimpleNamespace(ok=True), + ): + assert _run_metadata_sync_safe( + str(paths["prompt"]), + str(paths["code"]), + ) + + +def _run_command_finalizer( + command: str, + route: str, + root: Path, + paths: dict[str, Path], +) -> None: + if route == "sync": + _save_fingerprint_atomic( + "sample", + "python", + "sync", + paths, + 0.25, + "invariant-model", + ) + elif route == "decorator": + _run_decorated_finalizer(command, paths) + elif route == "update": + _run_update_finalizer(paths) + elif route == "auto-deps": + _run_auto_deps_finalizer(root, paths) + # Standalone auto-deps intentionally advances the workflow to + # generate; model that required continuation before asserting the + # terminal freshness invariant. + _run_decorated_finalizer("generate", paths) + elif route == "ci-heal": + _run_ci_heal_finalizer(paths) + else: # pragma: no cover - registry is closed above + raise AssertionError(f"unknown finalization route: {route}") + + +@pytest.mark.parametrize( + ("command", "route"), + MUTATING_COMMANDS, + ids=[command for command, _route in MUTATING_COMMANDS], +) +def test_mutating_command_leaves_unit_fresh( + command: str, + route: str, + fingerprint_unit: tuple[Path, dict[str, Path]], +) -> None: + """A green mutating command must leave a durable, fresh fingerprint.""" + root, paths = fingerprint_unit + + _run_command_finalizer(command, route, root, paths) + + fingerprint_path = root / ".pdd" / "meta" / "sample_python.json" + assert fingerprint_path.exists(), f"{command} returned without a fingerprint" + payload = json.loads(fingerprint_path.read_text(encoding="utf-8")) + assert payload["prompt_hash"], f"{command} wrote a null prompt hash" + + with patch( + "pdd.sync_determine_operation.get_pdd_file_paths", + return_value=paths, + ): + decision = sync_determine_operation( + "sample", + "python", + target_coverage=90.0, + log_mode=True, + skip_tests=True, + skip_verify=True, + ) + + assert decision.operation in {"nothing", "all_synced"}, ( + f"{command} reported success but the touched unit is still stale: " + f"{decision.operation} ({decision.reason})" + ) + + +def test_example_from_parent_cwd_has_no_null_hashes_or_wrong_root( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Combined regressions for #1305 and #1211/#1290.""" + parent = tmp_path / "parent" + subproject = parent / "extensions" / "app" + parent.mkdir() + (subproject / "prompts").mkdir(parents=True) + (subproject / "src").mkdir() + (subproject / "examples").mkdir() + (subproject / "tests").mkdir() + (subproject / ".pddrc").write_text( + 'version: "1.0"\ncontexts:\n default:\n defaults:\n' + ' generate_output_path: "src/"\n' + ' example_output_path: "examples/"\n' + ' test_output_path: "tests/"\n', + encoding="utf-8", + ) + paths = { + "prompt": subproject / "prompts" / "sample_python.prompt", + "code": subproject / "src" / "sample.py", + "example": subproject / "examples" / "sample_example.py", + "test": subproject / "tests" / "test_sample.py", + } + paths["test_files"] = [paths["test"]] + paths["prompt"].write_text("% Goal\nNested sample.\n", encoding="utf-8") + paths["code"].write_text("VALUE = 1\n", encoding="utf-8") + paths["example"].write_text("print(1)\n", encoding="utf-8") + paths["test"].write_text("def test_value(): assert True\n", encoding="utf-8") + monkeypatch.chdir(parent) + + _run_decorated_finalizer("example", paths) + + fingerprint_path = subproject / ".pdd" / "meta" / "sample_python.json" + assert fingerprint_path.exists() + assert not (parent / ".pdd" / "meta" / "sample_python.json").exists() + payload = json.loads(fingerprint_path.read_text(encoding="utf-8")) + expected = calculate_current_hashes(paths) + assert payload["prompt_hash"] == expected["prompt_hash"] + assert payload["code_hash"] == expected["code_hash"] + assert payload["example_hash"] == expected["example_hash"] + assert payload["test_hash"] == expected["test_hash"] + + fingerprint = read_fingerprint("sample", "python", paths=paths) + assert fingerprint is not None + with patch( + "pdd.sync_determine_operation.get_pdd_file_paths", + return_value=paths, + ): + decision = sync_determine_operation( + "sample", + "python", + 90.0, + log_mode=True, + skip_tests=True, + skip_verify=True, + ) + assert decision.operation in {"nothing", "all_synced"} + + +@pytest.mark.parametrize( + ("relative_path", "required_text"), + ( + ( + "pdd/commands/generate.py", + '@log_operation(operation="generate", clears_run_report=True, updates_fingerprint=True)', + ), + ( + "pdd/commands/generate.py", + '@log_operation(operation="example", clears_run_report=True, updates_fingerprint=True)', + ), + ( + "pdd/commands/fix.py", + '@log_operation(operation="fix", clears_run_report=True, updates_fingerprint=True)', + ), + ("pdd/update_main.py", "_finalize_single_file_fingerprint("), + ("pdd/auto_deps_main.py", "save_fingerprint("), + ("pdd/sync_orchestration.py", "FingerprintTransaction("), + ("pdd/ci_drift_heal.py", "save_fingerprint("), + ), +) +def test_mutating_command_is_registered_with_shared_finalization_route( + relative_path: str, + required_text: str, +) -> None: + """Keep the executable command registry aligned with the property harness.""" + repo_root = Path(__file__).parents[1] + source = (repo_root / relative_path).read_text(encoding="utf-8") + assert required_text in source diff --git a/tests/test_fingerprint_transaction.py b/tests/test_fingerprint_transaction.py new file mode 100644 index 000000000..b912824ee --- /dev/null +++ b/tests/test_fingerprint_transaction.py @@ -0,0 +1,399 @@ +"""Regression coverage for transactional fingerprint finalization (#1926).""" +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from pdd.fingerprint_transaction import ( + FingerprintFinalizeError, + FingerprintTransaction, +) +from pdd.operation_log import save_fingerprint +from pdd.sync_determine_operation import calculate_current_hashes + + +def _unit(tmp_path: Path) -> tuple[dict[str, Path], Path]: + root = tmp_path / "project" + (root / "prompts").mkdir(parents=True) + (root / "pdd").mkdir() + (root / "tests").mkdir() + (root / ".pdd" / "meta").mkdir(parents=True) + (root / ".pddrc").write_text("contexts: {}\n", encoding="utf-8") + paths = { + "prompt": root / "prompts" / "sample_python.prompt", + "code": root / "pdd" / "sample.py", + "test": root / "tests" / "test_sample.py", + } + paths["prompt"].write_text("% Goal\nCreate sample.\n", encoding="utf-8") + paths["code"].write_text("def sample():\n return 1\n", encoding="utf-8") + paths["test"].write_text("def test_sample():\n assert True\n", encoding="utf-8") + return paths, root + + +def test_clean_exit_writes_complete_fingerprint(tmp_path: Path) -> None: + paths, root = _unit(tmp_path) + + with FingerprintTransaction("sample", "python", "generate", paths): + pass + + fingerprint_path = root / ".pdd" / "meta" / "sample_python.json" + data = json.loads(fingerprint_path.read_text(encoding="utf-8")) + expected = calculate_current_hashes(paths) + assert data["command"] == "generate" + assert data["prompt_hash"] == expected["prompt_hash"] + assert data["code_hash"] == expected["code_hash"] + assert data["test_hash"] == expected["test_hash"] + assert data["prompt_hash"] is not None + + +def test_body_exception_preserves_existing_fingerprint(tmp_path: Path) -> None: + paths, root = _unit(tmp_path) + fingerprint_path = root / ".pdd" / "meta" / "sample_python.json" + fingerprint_path.write_text('{"sentinel": true}\n', encoding="utf-8") + + with pytest.raises(ValueError, match="body failed"): + with FingerprintTransaction("sample", "python", "generate", paths): + raise ValueError("body failed") + + assert json.loads(fingerprint_path.read_text(encoding="utf-8")) == { + "sentinel": True + } + + +def test_skip_is_idempotent_and_does_not_write(tmp_path: Path) -> None: + paths, root = _unit(tmp_path) + transaction = FingerprintTransaction("sample", "python", "update", paths) + + with transaction: + transaction.skip("dry-run") + transaction.skip("dry-run") + + assert not (root / ".pdd" / "meta" / "sample_python.json").exists() + + +def test_null_prompt_hash_is_hard_failure_and_preserves_previous_file( + tmp_path: Path, +) -> None: + paths, root = _unit(tmp_path) + paths["prompt"].unlink() + fingerprint_path = root / ".pdd" / "meta" / "sample_python.json" + fingerprint_path.write_text('{"old": "state"}\n', encoding="utf-8") + + with pytest.raises(FingerprintFinalizeError) as raised: + with FingerprintTransaction("sample", "python", "fix", paths): + pass + + message = str(raised.value) + assert "[fix]" in message + assert str(fingerprint_path) in message + assert "prompt_hash is null" in message + assert json.loads(fingerprint_path.read_text(encoding="utf-8")) == { + "old": "state" + } + + +def test_existing_non_prompt_artifact_cannot_have_null_hash( + tmp_path: Path, +) -> None: + paths, _root = _unit(tmp_path) + hashes = calculate_current_hashes(paths) + hashes["code_hash"] = None + + with patch( + "pdd.fingerprint_transaction.calculate_current_hashes", + return_value=hashes, + ): + with pytest.raises(FingerprintFinalizeError, match="code_hash is null"): + with FingerprintTransaction("sample", "python", "generate", paths): + pass + + +def test_atomic_write_failure_has_context_and_leaves_destination_intact( + tmp_path: Path, +) -> None: + paths, root = _unit(tmp_path) + fingerprint_path = root / ".pdd" / "meta" / "sample_python.json" + fingerprint_path.write_text('{"old": true}\n', encoding="utf-8") + + with patch( + "pdd.fingerprint_transaction.atomic_write_json", + side_effect=OSError("disk full"), + ): + with pytest.raises(FingerprintFinalizeError) as raised: + with FingerprintTransaction("sample", "python", "example", paths): + pass + + assert "[example]" in str(raised.value) + assert str(fingerprint_path) in str(raised.value) + assert "disk full" in str(raised.value) + assert json.loads(fingerprint_path.read_text(encoding="utf-8")) == { + "old": True + } + + +def test_explicit_paths_eagerly_anchor_nested_subproject( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + parent = tmp_path / "parent" + nested = parent / "nested" + (nested / "prompts").mkdir(parents=True) + (nested / "src").mkdir() + (nested / ".pddrc").write_text("contexts: {}\n", encoding="utf-8") + prompt = nested / "prompts" / "sample_python.prompt" + code = nested / "src" / "sample.py" + prompt.write_text("% Goal\nNested.\n", encoding="utf-8") + code.write_text("VALUE = 1\n", encoding="utf-8") + monkeypatch.chdir(parent) + + transaction = FingerprintTransaction( + "sample", + "python", + "update", + {"prompt": prompt, "code": code}, + ) + assert transaction.fingerprint_path == ( + nested / ".pdd" / "meta" / "sample_python.json" + ) + with transaction: + pass + + assert transaction.fingerprint_path.exists() + assert not (parent / ".pdd" / "meta" / "sample_python.json").exists() + + +def test_include_override_controls_hash_and_persisted_graph(tmp_path: Path) -> None: + paths, root = _unit(tmp_path) + dependency = root / "schema.md" + dependency.write_text("contract-v1\n", encoding="utf-8") + override = {str(dependency): "pre-captured-hash"} + expected = calculate_current_hashes(paths, stored_include_deps=override) + + transaction = FingerprintTransaction( + "sample", + "python", + "auto-deps", + paths, + ) + transaction.set_include_deps_override(override) + with transaction: + pass + + data = json.loads( + (root / ".pdd" / "meta" / "sample_python.json").read_text( + encoding="utf-8" + ) + ) + assert data["prompt_hash"] == expected["prompt_hash"] + assert data["include_deps"] == override + + +def test_atomic_state_uses_same_payload_builder_without_early_write( + tmp_path: Path, +) -> None: + paths, root = _unit(tmp_path) + + class Buffer: + payload = None + path = None + operation = None + + def set_fingerprint(self, payload, path, *, operation=None): + self.payload = payload + self.path = path + self.operation = operation + + buffer = Buffer() + with FingerprintTransaction( + "sample", + "python", + "test", + paths, + atomic_state=buffer, + ): + pass + + destination = root / ".pdd" / "meta" / "sample_python.json" + assert not destination.exists() + assert buffer.path == destination + assert buffer.operation == "test" + assert buffer.payload["prompt_hash"] is not None + + +def test_outer_atomic_state_rolls_back_fingerprint_if_run_report_commit_fails( + tmp_path: Path, +) -> None: + """The buffered sync path must not expose half of its state pair.""" + from pdd.sync_orchestration import AtomicStateUpdate + + meta = tmp_path / ".pdd" / "meta" + meta.mkdir(parents=True) + fingerprint_path = meta / "sample_python.json" + run_report_path = meta / "sample_python_run.json" + old_fingerprint = b'{"old": "fingerprint"}\n' + old_run_report = b'{"old": "run-report"}\n' + fingerprint_path.write_bytes(old_fingerprint) + run_report_path.write_bytes(old_run_report) + + state = AtomicStateUpdate("sample", "python") + real_atomic_write = state._atomic_write + write_count = 0 + + def fail_second_write(data, target_path): + nonlocal write_count + write_count += 1 + if write_count == 2: + raise OSError("run-report disk failure") + real_atomic_write(data, target_path) + + state._atomic_write = fail_second_write + with pytest.raises(FingerprintFinalizeError, match="atomic state commit failed"): + with state: + state.set_fingerprint( + {"prompt_hash": "new"}, + fingerprint_path, + operation="test", + ) + state.set_run_report({"exit_code": 0}, run_report_path) + + assert fingerprint_path.read_bytes() == old_fingerprint + assert run_report_path.read_bytes() == old_run_report + + +def test_public_save_wrapper_propagates_transaction_failure(tmp_path: Path) -> None: + paths, _root = _unit(tmp_path) + paths["prompt"].unlink() + + with pytest.raises(FingerprintFinalizeError, match="prompt_hash is null"): + save_fingerprint("sample", "python", "generate", paths=paths) + + +def test_path_normalization_handles_strings_and_null_test_list( + tmp_path: Path, +) -> None: + paths, root = _unit(tmp_path) + + with FingerprintTransaction( + "sample", + "python", + "generate", + paths={"prompt": str(paths["prompt"]), "test_files": None}, + ): + pass + + payload = json.loads( + (root / ".pdd" / "meta" / "sample_python.json").read_text( + encoding="utf-8" + ) + ) + assert payload["prompt_hash"] is not None + assert payload["test_files"] == {} + + +def test_implicit_path_discovery_and_failure_are_typed(tmp_path: Path) -> None: + paths, _root = _unit(tmp_path) + with patch( + "pdd.fingerprint_transaction.get_pdd_file_paths", + return_value=paths, + ) as discover: + with FingerprintTransaction("sample", "python", "generate"): + pass + discover.assert_called_once_with("sample", "python") + + with patch( + "pdd.fingerprint_transaction.get_pdd_file_paths", + side_effect=OSError("cannot discover project"), + ): + with pytest.raises( + FingerprintFinalizeError, + match="path resolution failed: cannot discover project", + ) as raised: + FingerprintTransaction("nested/sample", "python", "generate") + assert raised.value.fingerprint_path == Path( + ".pdd/meta/nested_sample_python.json" + ) + + +def test_existing_test_file_requires_test_files_hash(tmp_path: Path) -> None: + paths, _root = _unit(tmp_path) + paths["test_files"] = [paths["test"]] + hashes = calculate_current_hashes(paths) + hashes["test_files"] = {} + + with patch( + "pdd.fingerprint_transaction.calculate_current_hashes", + return_value=hashes, + ): + with pytest.raises( + FingerprintFinalizeError, + match="test_files hash is null", + ): + with FingerprintTransaction("sample", "python", "test", paths): + pass + + +def test_atomic_state_requires_fingerprint_setter(tmp_path: Path) -> None: + paths, _root = _unit(tmp_path) + + with pytest.raises( + FingerprintFinalizeError, + match="atomic_state does not provide set_fingerprint", + ): + with FingerprintTransaction( + "sample", + "python", + "sync", + paths, + atomic_state=object(), + ): + pass + + +def test_fingerprint_payload_has_one_authoritative_constructor() -> None: + """Future mutating paths must delegate instead of reimplementing writes.""" + package_root = Path(__file__).parents[1] / "pdd" + constructor_owners = [] + serialized_write_owners = [] + for path in package_root.rglob("*.py"): + source = path.read_text(encoding="utf-8") + if "Fingerprint(" in source: + constructor_owners.append(path.relative_to(package_root).as_posix()) + if "json.dump(asdict(fingerprint)" in source: + serialized_write_owners.append(path.relative_to(package_root).as_posix()) + + # read_fingerprint reconstructs the persisted dataclass; only the + # transaction is allowed to construct a new write payload. + assert sorted(constructor_owners) == [ + "fingerprint_transaction.py", + "sync_determine_operation.py", + ] + assert serialized_write_owners == [] + + +def test_transaction_public_api_has_no_prepare_handshake() -> None: + """The atomic-state deferral is constructor-driven, not a manual handshake. + + Regression for a prompt/code divergence (Codex review, PR #1998): the + ``pin_example_hack`` prompt once instructed generated code to call a + non-existent ``transaction.prepare()`` and a manual + ``skip("deferred to AtomicStateUpdate")`` dance. The real contract passes + ``atomic_state`` to the constructor and finalizes on clean context exit. + Green hand-written unit tests do not catch a stale prompt; regeneration + from it would materialize an ``AttributeError``. + """ + # The class exposes skip() (suppress) but never a prepare() handshake. + assert hasattr(FingerprintTransaction, "skip") + assert not hasattr(FingerprintTransaction, "prepare") + + # No source-of-truth prompt may instruct the fabricated prepare() protocol. + prompts_dir = Path(__file__).parents[1] / "pdd" / "prompts" + offenders = [ + p.relative_to(prompts_dir).as_posix() + for p in prompts_dir.rglob("*.prompt") + if "transaction.prepare(" in p.read_text(encoding="utf-8") + or "FingerprintTransaction.prepare" in p.read_text(encoding="utf-8") + ] + assert offenders == [], f"prompts reference non-existent prepare(): {offenders}" diff --git a/tests/test_issue_1714_sync_stall.py b/tests/test_issue_1714_sync_stall.py index 305665d4b..5380608c0 100644 --- a/tests/test_issue_1714_sync_stall.py +++ b/tests/test_issue_1714_sync_stall.py @@ -1523,7 +1523,7 @@ def test_prd_sync_run_agentic_task_must_pass_explicit_timeout( "model": "anthropic", "error": "", }), \ - patch("pdd.operation_log.infer_module_identity", return_value=(None, None)), \ + patch("pdd.update_main._finalize_single_file_fingerprint"), \ patch("pdd.architecture_registry.find_architecture_for_project", return_value=[arch_file]), \ patch("pdd.architecture_sync.update_architecture_from_prompt", @@ -1787,4 +1787,3 @@ def test_e2e_test_modules_importable(self): ] for mod_name in e2e_modules: importlib.import_module(mod_name) - diff --git a/tests/test_issue_1900_surface_contract.py b/tests/test_issue_1900_surface_contract.py new file mode 100644 index 000000000..a9ece6987 --- /dev/null +++ b/tests/test_issue_1900_surface_contract.py @@ -0,0 +1,969 @@ +"""Issue #1900: the prompt-declared ```` is the surface contract. + +The public-surface regression gate +(:func:`pdd.code_generator_main._verify_public_surface_regression`) used to +compare the OLD generated code against the NEW generated code for EVERY public +symbol. That dead-ends the ``pdd change -> pdd sync`` loop whenever a legitimate +change adds a symbol AND regeneration also drifts an unrelated *declared* public +function's signature (live case pdd_cloud#2971: ``list_secret_metadata``): the +gate hard-fails with a repair directive whose target is "compatible with the +very code being regenerated", i.e. no stable target. + +The fix makes the gate declaration-aware, per-symbol: + +* DECLARED symbols (named in the prompt's ````) are validated + against the prompt declaration — a stable target — not against the old code. +* UNDECLARED symbols keep today's old-code baseline (+ ``BREAKING-CHANGE`` + opt-out), so backward-compat protection for helpers/re-exports is unchanged. + +These are gate-level acceptance tests: they call +``_verify_public_surface_regression(before, after, prompt_name, out, "python", +prompt_content)`` directly and assert on ``PublicSurfaceRegressionError``'s +``.removed_symbols`` / ``.changed_signatures`` / ``str(exc)`` / repair directive, +plus one test that the ``signature_detail:`` lines propagate through +``pdd.agentic_sync_runner``'s subprocess parser. +""" + +import json + +import pytest + +from pdd.code_generator_main import ( + PublicSurfaceRegressionError, + _verify_public_surface_regression, +) + +PROMPT = "demo_Python.prompt" +OUT = "pdd/demo.py" + + +def _iface_prompt(functions, body="% engineer\n"): + """Build a minimal prompt carrying a ```` module decl. + + ``functions`` is an iterable of ``(name, signature)`` pairs. A ``signature`` + of ``None`` is emitted as a description-only declaration (no ``signature`` + key) so the presence-only path can be exercised. + """ + decls = [] + for name, signature in functions: + entry = {"name": name} + if signature is not None: + entry["signature"] = signature + decls.append(entry) + spec = {"type": "module", "module": {"functions": decls}} + return f"{json.dumps(spec)}\n{body}" + + +def _detail_line(symbol, expected, actual, source="pdd-interface"): + """Build a ``signature_detail:`` line in the JSON wire format (round-8 #2).""" + return "signature_detail: " + json.dumps( + {"symbol": symbol, "expected": expected, "actual": actual, "source": source} + ) + + +class TestIssue1900SurfaceContract: + def test_2971_shape_flags_declared_drift_not_added_symbol(self): + """The pdd_cloud#2971 shape: a legit change ADDS + ``get_secret_with_enabled_fallback`` while regeneration DRIFTS the + unrelated declared ``list_secret_metadata`` (adds a required param). + + The declared drift must be flagged against the DECLARED signature (a + stable target); the correctly-added declared symbol must NOT be flagged; + a stable declared symbol must NOT be flagged.""" + before = ( + "def list_secret_metadata(project_id, prefix=''):\n" + " return []\n" + "def get_secret(project_id, name):\n" + " return name\n" + ) + after = ( + "def list_secret_metadata(project_id, prefix='', *, region):\n" + " return []\n" + "def get_secret(project_id, name):\n" + " return name\n" + "def get_secret_with_enabled_fallback(project_id, name):\n" + " return name\n" + ) + prompt = _iface_prompt( + [ + ("list_secret_metadata", "(project_id, prefix='')"), + ("get_secret", "(project_id, name)"), + ("get_secret_with_enabled_fallback", "(project_id, name)"), + ] + ) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "list_secret_metadata" in exc.value.changed_signatures + assert "get_secret_with_enabled_fallback" not in exc.value.changed_signatures + assert "get_secret" not in exc.value.changed_signatures + assert "get_secret_with_enabled_fallback" not in exc.value.removed_symbols + # The directive/message must name the DECLARED expected signature, the + # stable target the old code-vs-code comparison never had. + message = str(exc.value) + "\n" + exc.value.repair_directive + assert "(project_id, prefix='')" in message + + def test_declaration_authorizes_incompatible_change(self): + """A declaration is a permit: dropping a required param that the + declaration also drops must pass, even though old-vs-new alone would + flag the removed required ``b``.""" + before = "def f(a, b):\n return a\n" + after = "def f(a):\n return a\n" + prompt = _iface_prompt([("f", "(a)")]) + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + + def test_additive_only_declared_symbol_passes(self): + """A newly declared symbol added in ``after`` matching its declared + signature, with no other drift, must pass with no error.""" + before = "def f(a):\n return a\n" + after = ( + "def f(a):\n return a\n" + "def g(a, b):\n return b\n" + ) + prompt = _iface_prompt([("f", "(a)"), ("g", "(a, b)")]) + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + + def test_no_declaration_falls_back_to_old_code(self): + """The same drift as the #2971 case but with NO ```` must + still raise against the old code (the fallback baseline is preserved).""" + before = ( + "def list_secret_metadata(project_id, prefix=''):\n" + " return []\n" + ) + after = ( + "def list_secret_metadata(project_id, prefix='', *, region):\n" + " return []\n" + ) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", "Just prose, no interface." + ) + assert "list_secret_metadata" in exc.value.changed_signatures + + def test_declared_symbol_missing_from_generated_is_removed(self): + """A declared symbol absent from generated code is a violation, listed in + ``removed_symbols`` — regardless of whether it was in ``before`` (the + declaration is authoritative for presence).""" + before = "def f(a):\n return a\n" + after = "def f(a):\n return a\n" + # ``g`` is declared but never generated (never in before or after). + prompt = _iface_prompt([("f", "(a)"), ("g", "(a)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "g" in exc.value.removed_symbols + + def test_declaration_authoritative_over_breaking_change(self): + """A ``BREAKING-CHANGE: remove`` marker does NOT excuse a symbol that is + still declared in ````: the declaration wins.""" + before = "def f(a):\n return a\ndef g(a):\n return a\n" + after = "def f(a):\n return a\n" + prompt = _iface_prompt( + [("f", "(a)"), ("g", "(a)")], + body="% engineer\nBREAKING-CHANGE: remove g\n", + ) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "g" in exc.value.removed_symbols + + def test_undeclared_symbol_still_protected(self): + """Per-symbol hybrid: an UNDECLARED public helper dropped by + regeneration must still raise (old-code protection intact), even though + another symbol IS declared.""" + before = ( + "def f(a):\n return a\n" + "def helper():\n return 1\n" + ) + after = "def f(a):\n return a\n" + prompt = _iface_prompt([("f", "(a)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "helper" in exc.value.removed_symbols + + def test_output_carries_full_expected_and_actual_signatures(self): + """Criterion 4: the error output carries the FULL declared-expected AND + the actual generated signature text (no truncation).""" + before = "def f(a, b='x'):\n return a\n" + after = "def f(a, b='x', *, c):\n return a\n" + prompt = _iface_prompt([("f", "(a, b='x')")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + text = str(exc.value) + "\n" + exc.value.repair_directive + assert "(a, b='x')" in text # declared expected + assert "*, c" in text # actual generated drift + + def test_presence_only_declared_signature_falls_back_to_old_code(self): + """A declared signature that is not a parseable paren-list (here: omitted) + is presence-only for the DECLARED check, but it FALLS BACK to the old-code + baseline (its exact pre-PR protection) rather than being skipped from every + check. So a backward-compatible change passes, an incompatible change + raises, and its ABSENCE is a violation.""" + prompt = _iface_prompt([("f", None)]) + # Present with a backward-compatible change (added optional) -> no raise. + _verify_public_surface_regression( + "def f(a):\n return a\n", + "def f(a, b=None):\n return a\n", + PROMPT, + OUT, + "python", + prompt, + ) + # Present with an incompatible change (added required) -> the old-code + # baseline still catches it (no longer silently unchecked). + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + "def f(a):\n return a\n", + "def f(a, b):\n return a\n", + PROMPT, + OUT, + "python", + prompt, + ) + assert "f" in exc.value.changed_signatures + # Absent from generated code -> violation. + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + "def f(a):\n return a\n", + "def other():\n return 1\n", + PROMPT, + OUT, + "python", + prompt, + ) + assert "f" in exc.value.removed_symbols + + def test_default_resolution_uses_generated_namespace(self): + """Issue #1558: for the declared-vs-generated comparison BOTH sides + resolve in the GENERATED module namespace. A declared default written as + a bare constant name that the generated module binds must resolve to its + literal and NOT be flagged (it would fail closed if the declared side + were resolved against the OLD/empty namespace).""" + before = "def f(max_chars=999):\n return max_chars\n" + after = ( + "_LIMIT = 25000\n" + "def f(max_chars=_LIMIT):\n return max_chars\n" + ) + prompt = _iface_prompt([("f", "(max_chars=_LIMIT)")]) + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + + def test_cli_and_command_names_are_not_surface_symbols(self): + """Codex finding 1: ``type: cli`` / ``type: command`` interfaces declare + COMMAND strings (``sync-architecture``, ``pdd extracts prune`` — hyphens + and spaces, not valid Python identifiers), NOT module symbols. They must + never drive ``removed:`` diffs; only ``type: module`` declares Python + symbols the surface gate can own. CLI/command signature conformance stays + with the conformance gate.""" + before = "def run():\n return 1\n" + after = "def run():\n return 1\n" + cli_spec = { + "type": "cli", + "cli": {"commands": [{"name": "sync-architecture", "signature": "(--flag)"}]}, + } + cli_prompt = ( + f"{json.dumps(cli_spec)}\n% engineer\n" + ) + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", cli_prompt + ) + command_spec = { + "type": "command", + "command": {"name": "pdd extracts prune", "signature": "(target)"}, + } + command_prompt = ( + f"{json.dumps(command_spec)}\n% engineer\n" + ) + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", command_prompt + ) + + def test_dotted_init_presence_satisfied_by_class_symbol(self): + """Codex finding 2: a constructor's ABI is keyed on the CLASS symbol + (``Foo``), never ``Foo.__init__``. A real ``type: module`` prompt may + declare ``Foo.__init__``; its presence must be satisfied by the class + symbol and its signature left to the conformance gate — so valid code + must NOT raise. The class going missing is still a violation.""" + prompt = _iface_prompt([("Foo.__init__", "(self, value)")]) + before = ( + "class Foo:\n" + " def __init__(self, value):\n" + " self.value = value\n" + ) + # Constructor signature drift (added optional param) is owned by the + # conformance gate; the surface gate must not raise here. + after = ( + "class Foo:\n" + " def __init__(self, value, extra=None):\n" + " self.value = value\n" + ) + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + # The class itself absent -> still a violation naming the class. + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, + "def other():\n return 1\n", + PROMPT, + OUT, + "python", + prompt, + ) + assert "Foo" in exc.value.removed_symbols + + def test_declared_async_or_kind_drift_uses_old_code_baseline(self): + """Codex round-2 finding 1a: the ```` cannot express + ``async`` or a function/class binding kind, so those un-declarable + structural facets are anchored to the PRIOR generation for a declared + symbol. An ``async``->sync (and sync->``async``) drift on a declared + function must therefore still raise, even though the declaration's + parameter list is unchanged.""" + prompt = _iface_prompt([("f", "(x)")]) + # async -> sync. + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + "async def f(x):\n return x\n", + "def f(x):\n return x\n", + PROMPT, + OUT, + "python", + prompt, + ) + assert "f" in exc.value.changed_signatures + # sync -> async. + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + "def f(x):\n return x\n", + "async def f(x):\n return x\n", + PROMPT, + OUT, + "python", + prompt, + ) + assert "f" in exc.value.changed_signatures + + def test_declared_async_regenerated_async_passes(self): + """The anchor must not create a false positive: a declared async function + regenerated async — even with an added OPTIONAL parameter — is + backward-compatible and must NOT raise.""" + prompt = _iface_prompt([("f", "(x)")]) + _verify_public_surface_regression( + "async def f(x):\n return x\n", + "async def f(x, y=None):\n return x\n", + PROMPT, + OUT, + "python", + prompt, + ) + + def test_breaking_change_opts_out_declared_kind_change(self): + """A rare INTENDED async/kind change on a declared symbol still has an + escape hatch: ``BREAKING-CHANGE: change signature f`` opts the declared + symbol out of the declared-signature check (mirroring the undeclared + path), because the declaration cannot express the change.""" + prompt = _iface_prompt( + [("f", "(x)")], + body="% engineer\nBREAKING-CHANGE: change signature f\n", + ) + _verify_public_surface_regression( + "async def f(x):\n return x\n", + "def f(x):\n return x\n", + PROMPT, + OUT, + "python", + prompt, + ) + + def test_declared_dotted_method_receiver_stripped(self): + """Codex round-5: declared dotted methods are first-class declared-contract + citizens, validated against the DECLARED signature with the leading + ``self``/``cls`` receiver stripped to match the snapshot. So an UNCHANGED + method (declared with ``(self, x)`` OR ``def method(self, x)``) must NOT + raise (receiver-strip correctness), while a method whose params drift + incompatibly from the declaration MUST raise.""" + code = ( + "class Foo:\n" + " def method(self, x):\n" + " return x\n" + ) + for sig in ("(self, x)", "def method(self, x)"): + prompt = _iface_prompt([("Foo.method", sig)]) + _verify_public_surface_regression( + code, code, PROMPT, OUT, "python", prompt + ) + # Incompatible drift from the declared method signature -> raises. + prompt = _iface_prompt([("Foo.method", "(self, x)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + code, + "class Foo:\n def method(self, x, y):\n return x\n", + PROMPT, + OUT, + "python", + prompt, + ) + assert "Foo.method" in exc.value.changed_signatures + + def test_declared_dotted_method_dropped_still_raises(self): + """Presence of a declared dotted method is still enforced: dropping + ``Foo.method`` from the generated code raises it as removed.""" + before = ( + "class Foo:\n" + " def method(self, x):\n" + " return x\n" + ) + after = "class Foo:\n pass\n" + prompt = _iface_prompt([("Foo.method", "(self, x)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "Foo.method" in exc.value.removed_symbols + + def test_breaking_change_does_not_bypass_declared_params(self): + """Codex FM2: ``BREAKING-CHANGE: change signature`` relaxes ONLY the + un-declarable binding-kind/async, NOT the declared param/return contract. + An added-required param that violates the declared signature must STILL be + flagged even with the opt-out (the declaration is the source of truth for + params).""" + prompt = _iface_prompt( + [("f", "(a)")], + body="% engineer\nBREAKING-CHANGE: change signature f\n", + ) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + "def f(a):\n return a\n", + "def f(a, b):\n return a\n", + PROMPT, + OUT, + "python", + prompt, + ) + assert "f" in exc.value.changed_signatures + + def test_presence_only_declared_method_added_required_param_raises(self): + """Codex F1: a presence-only DECLARED dotted method must still fall back + to the OLD-CODE baseline for its signature (not be skipped from every + check). Adding a required parameter to ``Foo.method`` breaks callers and + must raise.""" + prompt = _iface_prompt([("Foo.method", "(self, x)")]) + before = "class Foo:\n def method(self, x):\n return x\n" + after = "class Foo:\n def method(self, x, required):\n return x\n" + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "Foo.method" in exc.value.changed_signatures + + def test_presence_only_declared_method_binding_kind_flip_raises(self): + """Codex F2: a ``@staticmethod`` -> instance binding-kind flip on a + declared method must be caught via the old-code baseline (receiver- + stripped snapshot entries on both sides, so no self false positive).""" + prompt = _iface_prompt([("Factory.build", "(path)")]) + before = ( + "class Factory:\n" + " @staticmethod\n" + " def build(path):\n" + " return path\n" + ) + after = ( + "class Factory:\n" + " def build(self, path):\n" + " return path\n" + ) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "Factory.build" in exc.value.changed_signatures + + def test_presence_only_declared_class_ctor_drift_raises(self): + """Codex F3: a declared class whose signature is a non-paren string + (``class Service``) is presence-only for the declared check (its + ``_declared_signature_to_entry`` is None) and must still fall back to the + old-code baseline, catching an added required ``__init__`` parameter.""" + prompt = _iface_prompt([("Service", "class Service")]) + before = ( + "class Service:\n" + " def __init__(self, config):\n" + " self.config = config\n" + ) + after = ( + "class Service:\n" + " def __init__(self, config, region):\n" + " self.config = config\n" + ) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "Service" in exc.value.changed_signatures + + def test_breaking_change_on_declared_method_relaxes_only_kind(self): + """Codex round-5: now that declared methods are validated against the + declaration, ``BREAKING-CHANGE: change signature `` must relax ONLY + the un-declarable binding-kind/async — a binding-kind flip opted out passes, + but an added-required PARAM that violates the declaration STILL raises + (consistent with FM2; the declaration is authoritative for params).""" + # (a) binding-kind flip (staticmethod -> instance) opted out -> passes. + flip_prompt = _iface_prompt( + [("Factory.build", "(path)")], + body="% engineer\nBREAKING-CHANGE: change signature Factory.build\n", + ) + _verify_public_surface_regression( + "class Factory:\n @staticmethod\n def build(path):\n return path\n", + "class Factory:\n def build(self, path):\n return path\n", + PROMPT, + OUT, + "python", + flip_prompt, + ) + # (b) added-required param still raises despite the opt-out. + param_prompt = _iface_prompt( + [("Foo.method", "(self, x)")], + body="% engineer\nBREAKING-CHANGE: change signature Foo.method\n", + ) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + "class Foo:\n def method(self, x):\n return x\n", + "class Foo:\n def method(self, x, required):\n return x\n", + PROMPT, + OUT, + "python", + param_prompt, + ) + assert "Foo.method" in exc.value.changed_signatures + + def test_new_declared_method_added_required_param_raises(self): + """Codex round-5: a NEWLY-added declared method (no old-code baseline) is + still validated against its DECLARED signature, so generating it with an + extra required parameter beyond the declaration raises.""" + before = "def existing():\n return 1\n" + after = ( + "def existing():\n return 1\n" + "class Foo:\n def method(self, x, required):\n return x\n" + ) + prompt = _iface_prompt([("Foo.method", "(self, x)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "Foo.method" in exc.value.changed_signatures + + def test_new_declared_constructor_added_required_param_raises(self): + """Codex round-5: a NEWLY-added declared constructor (``Class.__init__``, + keyed on the class ``[class]`` entry) is validated against its DECLARED + signature — an extra required ctor param beyond the declaration raises.""" + before = "def existing():\n return 1\n" + after = ( + "def existing():\n return 1\n" + "class Service:\n" + " def __init__(self, config, region):\n" + " self.config = config\n" + ) + prompt = _iface_prompt([("Service.__init__", "(self, config)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "Service.__init__" in exc.value.changed_signatures + + def test_declared_method_change_authorized_by_declaration(self): + """Codex round-5 F3: the declaration is the permit for METHOD changes too. + A declared method whose signature was edited (dropping a param) and whose + regeneration follows the new declaration must PASS — the declared name is + excluded from the old-code baseline, so the dropped param is not flagged.""" + before = "class Foo:\n def method(self, a, b):\n return a\n" + after = "class Foo:\n def method(self, a):\n return a\n" + prompt = _iface_prompt([("Foo.method", "(self, a)")]) + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + + def test_declared_underscore_symbol_unchanged_no_false_positive(self): + """Codex round-7 finding 1: a prompt may declare ``_``-prefixed helpers in + its ```` (real: ``_extract_step_report``). The declaration is + authoritative (like ``__all__``), so a declared underscore symbol that + EXISTS must NOT be reported as removed on unchanged code, even though the + public-surface snapshot normally filters underscore names out.""" + code = ( + "def _extract_step_report(x):\n return x\n" + "def pub():\n return 1\n" + ) + prompt = _iface_prompt([("_extract_step_report", "(x)")]) + _verify_public_surface_regression( + code, code, PROMPT, OUT, "python", prompt + ) + + def test_declared_underscore_symbol_removed_raises(self): + """A genuinely removed declared underscore symbol is still a violation.""" + before = ( + "def _helper(x):\n return x\n" + "def pub():\n return 1\n" + ) + after = "def pub():\n return 1\n" + prompt = _iface_prompt([("_helper", "(x)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "_helper" in exc.value.removed_symbols + + def test_declared_underscore_symbol_signature_drift_raises(self): + """A declared underscore symbol's signature is now validated too: an + added required param beyond the declaration raises.""" + before = "def _helper(x):\n return x\n" + after = "def _helper(x, y):\n return x\n" + prompt = _iface_prompt([("_helper", "(x)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "_helper" in exc.value.changed_signatures + + def test_declared_callable_regenerated_as_assignment_raises(self): + """Codex round-7 finding 2: a declared callable regenerated as a + non-callable (``def f()`` -> ``f = 1``) is a break. The declared path can't + decide (the actual isn't a callable contract), so the symbol falls back to + the old-code baseline, which catches the ``[function]`` -> ``[assignment]`` + kind flip.""" + before = "def f():\n return 1\n" + after = "f = 1\n" + prompt = _iface_prompt([("f", "()")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "f" in exc.value.changed_signatures + + def test_declared_callable_regenerated_as_import_raises(self): + """Same as above but the callable becomes a re-exported import + (``[function]`` -> ``[import]``).""" + before = "def f():\n return 1\n" + after = "from pkg import f as f\n" + prompt = _iface_prompt([("f", "()")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "f" in exc.value.changed_signatures + + def test_unchanged_callable_assignment_declared_passes(self): + """Guard: an UNCHANGED callable-assignment declared as ``f()`` must NOT + false-positive — the old-code baseline compares equal old-vs-new.""" + code = "f = lambda: x\n" + prompt = _iface_prompt([("f", "()")]) + _verify_public_surface_regression( + code, code, PROMPT, OUT, "python", prompt + ) + + def test_callable_to_noncallable_raises_even_with_breaking_change(self): + """Codex round-8 finding 1: ``BREAKING-CHANGE: change signature`` authorizes + a PARAMETER change, not de-callable-ing a declared callable (that would be + ``BREAKING-CHANGE: remove``). A declared callable regenerated as a + non-callable must raise even under the opt-out (which would otherwise make + the old-code fallback skip the symbol).""" + prompt = _iface_prompt( + [("f", "()")], + body="% engineer\nBREAKING-CHANGE: change signature f\n", + ) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + "def f():\n return 1\n", + "f = 1\n", + PROMPT, + OUT, + "python", + prompt, + ) + assert "f" in exc.value.changed_signatures + + def test_unchanged_callable_assignment_passes_with_breaking_change(self): + """Guard for finding 1: an UNCHANGED callable-assignment (its OLD form is + already a non-callable) must NOT raise, even with the opt-out present — + only a callable that BECAME non-callable is flagged.""" + code = "f = lambda: x\n" + prompt = _iface_prompt( + [("f", "()")], + body="% engineer\nBREAKING-CHANGE: change signature f\n", + ) + _verify_public_surface_regression( + code, code, PROMPT, OUT, "python", prompt + ) + + def test_declared_underscore_class_ctor_drift_raises(self): + """Codex round-8 finding 3: a declared UNDERSCORE class constructor is a + first-class declared contract. Its ``[class]`` constructor-ABI entry must be + captured (via the patch-target path) so an added required ctor param + raises.""" + before = ( + "class _Service:\n" + " def __init__(self, config):\n" + " self.config = config\n" + ) + after = ( + "class _Service:\n" + " def __init__(self, config, region):\n" + " self.config = config\n" + ) + prompt = _iface_prompt([("_Service.__init__", "(self, config)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "_Service.__init__" in exc.value.changed_signatures + + def test_declared_underscore_class_ctor_unchanged_passes(self): + """A declared underscore class constructor that is unchanged must NOT + raise (receiver-stripped ctor ABI matches on both sides).""" + code = ( + "class _Service:\n" + " def __init__(self, config):\n" + " self.config = config\n" + ) + prompt = _iface_prompt([("_Service.__init__", "(self, config)")]) + _verify_public_surface_regression( + code, code, PROMPT, OUT, "python", prompt + ) + + def test_declared_underscore_method_drift_raises(self): + """A declared underscore method's signature is validated too: an added + required param beyond the declaration raises.""" + before = "class _Cls:\n def _m(self, x):\n return x\n" + after = "class _Cls:\n def _m(self, x, y):\n return x\n" + prompt = _iface_prompt([("_Cls._m", "(self, x)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + before, after, PROMPT, OUT, "python", prompt + ) + assert "_Cls._m" in exc.value.changed_signatures + + @staticmethod + def _nested_ctor_module(outer, inner, extra=""): + return ( + f"class {outer}:\n" + f" class {inner}:\n" + f" def __init__(self, config{extra}):\n" + f" self.config = config\n" + ) + + def test_declared_nested_ctor_underscore_outer_drift_raises(self): + """Codex round-9 case A: a declared NESTED constructor whose class path has + an underscore OUTER class (``_Outer.Inner.__init__``) must raise a signature + drift (``changed`` + a ``pdd-interface`` detail) on an added ctor param, + exactly like the public control — not pass silently.""" + prompt = _iface_prompt([("_Outer.Inner.__init__", "(self, config)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + self._nested_ctor_module("_Outer", "Inner"), + self._nested_ctor_module("_Outer", "Inner", ", region"), + PROMPT, + OUT, + "python", + prompt, + ) + assert "_Outer.Inner.__init__" in exc.value.changed_signatures + + def test_declared_nested_ctor_underscore_inner_drift_raises(self): + """Codex round-9 case B: underscore INNER class (``Outer._Inner.__init__``) + drift must raise as a ``changed`` signature with a detail — NOT be + mischaracterized as a bare ``removed`` of the nested class.""" + prompt = _iface_prompt([("Outer._Inner.__init__", "(self, config)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + self._nested_ctor_module("Outer", "_Inner"), + self._nested_ctor_module("Outer", "_Inner", ", region"), + PROMPT, + OUT, + "python", + prompt, + ) + assert "Outer._Inner.__init__" in exc.value.changed_signatures + assert "Outer._Inner" not in exc.value.removed_symbols + + def test_declared_nested_ctor_underscore_unchanged_passes(self): + """An unchanged declared nested constructor (underscore outer OR inner) + must NOT raise (captured and validated, no phantom removal).""" + for outer, inner in [("_Outer", "Inner"), ("Outer", "_Inner")]: + code = self._nested_ctor_module(outer, inner) + prompt = _iface_prompt([(f"{outer}.{inner}.__init__", "(self, config)")]) + _verify_public_surface_regression( + code, code, PROMPT, OUT, "python", prompt + ) + + def test_declared_nested_ctor_public_control_raises(self): + """Codex round-9 control C (must stay working): an all-public declared + nested constructor drift raises ``changed=['Outer.Inner.__init__']``.""" + prompt = _iface_prompt([("Outer.Inner.__init__", "(self, config)")]) + with pytest.raises(PublicSurfaceRegressionError) as exc: + _verify_public_surface_regression( + self._nested_ctor_module("Outer", "Inner"), + self._nested_ctor_module("Outer", "Inner", ", region"), + PROMPT, + OUT, + "python", + prompt, + ) + assert "Outer.Inner.__init__" in exc.value.changed_signatures + + +class TestIssue1900AgenticPropagation: + def test_signature_detail_lines_reach_agentic_directive(self): + """The subprocess/agentic path rebuilds the repair directive from stdout, + so the new ``signature_detail:`` lines must be parsed and carried into + the rebuilt directive (criterion 4 on the agentic path).""" + from pdd.agentic_sync_runner import _parse_public_surface_failure + + stderr = ( + "Public surface regression for demo_Python.prompt:\n" + "removed: \n" + "signature_changed: list_secret_metadata\n" + "output: pdd/demo.py\n" + "pre_surface_size: 2\n" + "post_surface_size: 3\n" + + _detail_line( + "list_secret_metadata", + "[function] (project_id, prefix='')", + "[function] (project_id, prefix='', *, region)", + ) + + "\n" + ) + parsed = _parse_public_surface_failure("", stderr) + assert parsed is not None + directive, signature = parsed + # The declared expected signature (the stable target) must ride along in + # the rebuilt directive. + assert "(project_id, prefix='')" in directive + assert "list_secret_metadata" in directive + # Existing signature tuple contract is unchanged (removed + changed). + assert signature == ("signature_changed:list_secret_metadata",) + + def test_signature_detail_union_types_survive_parsing(self): + """A ``signature_detail:`` line whose expected AND actual entries contain + PEP-604 ` | ` union types must round-trip through the JSON parser with the + FULL signatures intact.""" + from pdd.agentic_sync_runner import _parse_public_surface_failure + + expected_sig = "[function] (x: int | str) -> bool | None" + actual_sig = "[function] (x: int | str, y: int) -> bool | None" + stderr = ( + "Public surface regression for demo_Python.prompt:\n" + "removed: \n" + "signature_changed: f\n" + "output: pdd/demo.py\n" + "pre_surface_size: 1\n" + "post_surface_size: 1\n" + + _detail_line("f", expected_sig, actual_sig) + "\n" + ) + parsed = _parse_public_surface_failure("", stderr) + assert parsed is not None + directive, _signature = parsed + assert expected_sig in directive + assert actual_sig in directive + + def test_malformed_signature_detail_line_is_skipped_not_crash(self): + """A malformed ``signature_detail:`` line (invalid JSON) must be SKIPPED, + never raise. A well-formed JSON line in the same payload must still + parse.""" + from pdd.agentic_sync_runner import _parse_public_surface_failure + + stderr = ( + "Public surface regression for demo_Python.prompt:\n" + "removed: \n" + "signature_changed: good, bad\n" + "output: pdd/demo.py\n" + "pre_surface_size: 2\n" + "post_surface_size: 2\n" + # Well-formed JSON. + + _detail_line("good", "[function] (a)", "[function] (a, b)") + "\n" + # Malformed: not valid JSON -> must be skipped, not crash. + + "signature_detail: bad | this is not json\n" + ) + parsed = _parse_public_surface_failure("", stderr) + assert parsed is not None + directive, _signature = parsed + # The well-formed detail survives with its declared target line. + assert "Restore `good` to its declared signature `[function] (a)`" in directive + # The malformed line contributed no declared-target line. + assert "Restore `bad`" not in directive + + def test_signature_detail_delimiter_substring_in_signature_parses(self): + """Codex round-8 finding 2: JSON encoding makes a signature/default that + contains the ` | actual: ` AND ` | source: ` delimiter substrings survive + intact — the recurring right-split corruption is gone.""" + from pdd.agentic_sync_runner import _parse_public_surface_failure + + expected_sig = "[function] (mode=' | source: X | actual: Y')" + actual_sig = "[function] (mode='ok')" + stderr = ( + "Public surface regression for demo_Python.prompt:\n" + "removed: \n" + "signature_changed: f\n" + "output: pdd/demo.py\n" + "pre_surface_size: 1\n" + "post_surface_size: 1\n" + + _detail_line("f", expected_sig, actual_sig) + "\n" + ) + parsed = _parse_public_surface_failure("", stderr) + assert parsed is not None + directive, _signature = parsed + assert expected_sig in directive + assert actual_sig in directive + + def test_declared_violation_advice_points_at_declaration(self): + """Codex round-7 finding 3: since ``BREAKING-CHANGE: change signature`` no + longer bypasses a declared PARAM contract, the repair advice for a declared + violation (across the typed error, the agentic hard-failure block, and the + rebuilt directive) must point at editing the ```` declaration + and must NOT tell the user to add a ``change signature`` marker (which would + loop back into the dead-end #1900 removes).""" + from unittest.mock import patch + from pdd.code_generator_main import PublicSurfaceRegressionError + from pdd.agentic_sync_runner import ( + build_public_surface_hard_failure_from_error, + _parse_public_surface_failure, + ) + + exc = PublicSurfaceRegressionError( + prompt_name="demo_Python.prompt", + output_path="pdd/demo.py", + removed_symbols=[], + changed_signatures=["f"], + pre_surface_size=1, + post_surface_size=1, + signature_details=[ + ("f", "[function] (a)", "[function] (a, b)", "pdd-interface") + ], + ) + # Typed error's repair directive. + directive = exc.repair_directive + assert "" in directive + assert "change signature" not in directive + + # Agentic hard-failure block built from the typed error. + with patch( + "pdd.agentic_sync_runner._env_fingerprint", return_value="--- env ---" + ): + block = build_public_surface_hard_failure_from_error(exc, "demo") + assert "" in block + assert "change signature" not in block + + # Agentic rebuilt directive parsed from the failure stdout. + rebuilt = _parse_public_surface_failure("", str(exc) + "\n") + assert rebuilt is not None + rebuilt_directive, _sig = rebuilt + assert "" in rebuilt_directive + assert "change signature" not in rebuilt_directive diff --git a/tests/test_issue_1903_adopt_collocated_test.py b/tests/test_issue_1903_adopt_collocated_test.py new file mode 100644 index 000000000..82e02857f --- /dev/null +++ b/tests/test_issue_1903_adopt_collocated_test.py @@ -0,0 +1,1683 @@ +"""Regression tests for issue #1903 — adopt the runner-collected co-located test. + +PDD derives its canonical test-output path purely from ``.pddrc`` / +default conventions (``tests/test_{basename}{ext}``), blind to the project's +real test runner. On a jest/Next.js project the test the runner actually +collects is co-located (e.g. ``.../__test__/page.test.tsx``) while PDD writes +and verifies a root ``tests/`` shadow the runner never sees — a false-green. + +The fix adds a pure detector (:func:`find_collocated_test`) and an adopter +(:func:`resolve_test_output_path`) and wires them into the two write-decision +points: :func:`cmd_test_main` (direct ``pdd test``) and +:func:`get_pdd_file_paths` (sync / change). These tests pin that behavior and +guard the regression traps (Python default unchanged, honor explicit pins, +never adopt on ambiguity, never raise). + +The integration tests drive the REAL ``construct_paths`` (never a mock of it): +``construct_paths`` injects a generated-default ``test_output_path`` back into +the ``resolved_config`` it returns, so a mocked ``construct_paths`` would hide +the very pollution this fix routes around (the provenance is read from the raw +``.pddrc`` defaults instead). Mocking path construction here would produce a +false green — see the round-4 blocker. +""" +import json +import re +import sys +from pathlib import Path + +# Prioritize the local package (mirrors sibling test modules). +project_root = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(project_root)) + +import pytest + +from pdd.content_selector import ( + _micromatch_to_regex, + _pins_test_output_location, + _validated_project_path, + configured_test_output_pinned, + find_collocated_test, + find_runner_collected_test_path, + resolve_test_output_path, + was_test_adopted, +) +from pdd.sync_determine_operation import ( + get_pdd_file_paths, + _adopt_collocated_test, + _get_pdd_file_paths_uncollocated, +) +from pdd.cmd_test_main import cmd_test_main +from pdd.code_generator_main import _is_test_output_path + + +def _write(path: Path, content: str = "// placeholder\n") -> Path: + """Create *path* (and parents) with *content*; return it.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# 1) find_collocated_test — the pure detector +# --------------------------------------------------------------------------- + +class TestFindCollocatedTest: + def test_finds_test_under_dunder_test_dir(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + code = _write(tmp_path / "frontend/src/app/contributions/page.tsx") + real = _write(code.parent / "__test__" / "page.test.tsx") + assert find_collocated_test(code).resolve() == real.resolve() + + def test_finds_spec_file(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + code = _write(tmp_path / "src/widget.ts") + spec = _write(code.parent / "widget.spec.ts") + assert find_collocated_test(code).resolve() == spec.resolve() + + def test_finds_beside_module_test(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + code = _write(tmp_path / "src/button.tsx") + test = _write(code.parent / "button.test.tsx") + assert find_collocated_test(code).resolve() == test.resolve() + + def test_finds_python_sibling(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + code = _write(tmp_path / "src/foo.py", "def foo():\n return 1\n") + test = _write(tmp_path / "tests" / "test_foo.py", + "def test_foo():\n assert True\n") + assert find_collocated_test(code).resolve() == test.resolve() + + def test_module_self_never_counts(self, tmp_path): + # A lone module with no sibling test -> None (the module is excluded). + code = _write(tmp_path / "src/page.tsx") + assert find_collocated_test(code) is None + + def test_zero_matches_returns_none(self, tmp_path): + code = _write(tmp_path / "src/thing.ts") + # A same-stem non-test neighbor must not be mistaken for a test. + _write(code.parent / "thing.helper.ts") + assert find_collocated_test(code) is None + + def test_multiple_matches_returns_none(self, tmp_path): + code = _write(tmp_path / "src/page.tsx") + _write(code.parent / "page.test.tsx") + _write(code.parent / "__tests__" / "page.test.tsx") + assert find_collocated_test(code) is None + + def test_bad_path_returns_none_without_raising(self): + assert find_collocated_test("/no/such/dir/page.tsx") is None + assert find_collocated_test("") is None + + def test_unsupported_language_never_matches_python_sibling(self, tmp_path): + # Go module with a same-stem Python test nearby must NOT be adopted + # (cross-language mis-adoption regression, #1903 finding 1). + code = _write(tmp_path / "src/foo.go", "package main\n") + _write(tmp_path / "tests" / "test_foo.py", "def test_foo():\n assert True\n") + assert find_collocated_test(code) is None + + def test_unsupported_suffix_returns_none(self, tmp_path): + code = _write(tmp_path / "src/foo.rs", "fn main() {}\n") + _write(tmp_path / "tests" / "test_foo.py", "def test_foo():\n assert True\n") + assert find_collocated_test(code) is None + + +# --------------------------------------------------------------------------- +# 2) resolve_test_output_path — the adopter +# --------------------------------------------------------------------------- + +class TestResolveTestOutputPath: + def test_adopts_collocated_over_shadow(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + code = _write(tmp_path / "frontend/src/app/contributions/page.tsx") + real = _write(code.parent / "__test__" / "page.test.tsx") + shadow = tmp_path / "tests" / "test_page.tsx" # runner-blind, not on disk + got = resolve_test_output_path(code, shadow, user_pinned=False) + assert Path(got).resolve() == real.resolve() + + def test_user_pinned_returns_derived_unchanged(self, tmp_path): + code = _write(tmp_path / "frontend/src/app/contributions/page.tsx") + _write(code.parent / "__test__" / "page.test.tsx") + shadow = tmp_path / "tests" / "test_page.tsx" + got = resolve_test_output_path(code, shadow, user_pinned=True) + assert Path(got) == shadow + + def test_returns_derived_when_sibling_equals_derived(self, tmp_path): + code = _write(tmp_path / "src/foo.py", "def foo():\n return 1\n") + derived = _write(tmp_path / "tests" / "test_foo.py", + "def test_foo():\n assert True\n") + got = resolve_test_output_path(code, derived, user_pinned=False) + assert Path(got).resolve() == derived.resolve() + + def test_returns_derived_when_no_sibling(self, tmp_path): + code = _write(tmp_path / "src/page.tsx") + shadow = tmp_path / "tests" / "test_page.tsx" + got = resolve_test_output_path(code, shadow, user_pinned=False) + assert Path(got) == shadow + + def test_go_module_keeps_derived_go_path(self, tmp_path): + # Go generation must not be retargeted into a co-located `.py` test. + code = _write(tmp_path / "src/foo.go", "package main\n") + _write(tmp_path / "tests" / "test_foo.py", "def test_foo():\n assert True\n") + derived = tmp_path / "tests" / "test_foo.go" + got = resolve_test_output_path(code, derived, user_pinned=False) + assert Path(got) == derived + + def test_excluded_sibling_is_not_adopted(self, tmp_path, monkeypatch): + # Round 9: an existing co-located sibling the runner PROVABLY does not + # collect (a PARSEABLE custom testMatch that excludes it) must NOT be + # adopted — that would preserve the false-green on an excluded file. + # Redirect away from the stale excluded sibling. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", '{"devDependencies": {"jest": "^30"}}\n') + _write(tmp_path / "jest.config.json", + json.dumps({"testMatch": ["/qa/**/*.spec.ts"]})) + code = _write(tmp_path / "src/page.ts") + stale = _write(tmp_path / "src/__test__/page.test.ts") # NOT under qa/ + derived = tmp_path / "tests" / "test_page.ts" + got = resolve_test_output_path(code, derived, user_pinned=False) + assert Path(got).resolve() != stale.resolve(), \ + "must not adopt a runner-excluded sibling" + # And was_test_adopted must NOT claim adoption for the redirect target. + assert was_test_adopted(code, str(got), str(derived), user_pinned=False) is False + + def test_collected_sibling_still_adopted(self, tmp_path, monkeypatch): + # Control: when the runner (default jest) DOES collect the co-located + # sibling, adoption still fires as before. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", '{"devDependencies": {"jest": "^30"}}\n') + code = _write(tmp_path / "src/page.tsx") + real = _write(tmp_path / "src/__test__/page.test.tsx") + derived = tmp_path / "tests" / "test_page.tsx" + got = resolve_test_output_path(code, derived, user_pinned=False) + assert Path(got).resolve() == real.resolve() + assert was_test_adopted(code, str(got), str(derived), user_pinned=False) is True + + +# --------------------------------------------------------------------------- +# 2b) Greenfield runner discovery — issue #1903 §A (write the FIRST test where +# the runner collects it, not a runner-blind tests/ shadow). +# --------------------------------------------------------------------------- + +class TestGreenfieldRunnerDiscovery: + def test_jest_config_js_greenfield_co_locates(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", "module.exports = {};\n") + code = _write(tmp_path / "src/app/page.tsx", + "export default function P(){return null;}\n") + got = find_runner_collected_test_path(code) + assert got is not None + assert Path(got).resolve() == ( + tmp_path / "src/app/__test__/page.test.tsx" + ).resolve() + + def test_package_json_devdep_jest_detected(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", '{"devDependencies": {"jest": "^29"}}\n') + code = _write(tmp_path / "src/widget.ts") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == ( + tmp_path / "src/__test__/widget.test.ts" + ).resolve() + + def test_package_json_inline_jest_block_detected(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", '{"jest": {"testEnvironment": "jsdom"}}\n') + code = _write(tmp_path / "src/button.jsx") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == ( + tmp_path / "src/__test__/button.test.jsx" + ).resolve() + + def test_vitest_config_detected(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "vitest.config.ts", "export default {};\n") + code = _write(tmp_path / "lib/util.ts") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == ( + tmp_path / "lib/__test__/util.test.ts" + ).resolve() + + def test_config_found_by_walking_up_to_root(self, tmp_path, monkeypatch): + # jest.config sits at the project root; the module is nested deep. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "frontend/jest.config.js", "module.exports = {};\n") + code = _write(tmp_path / "frontend/src/app/contributions/page.tsx") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == ( + tmp_path / "frontend/src/app/contributions/__test__/page.test.tsx" + ).resolve() + + def test_no_runner_config_returns_none(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + code = _write(tmp_path / "src/page.tsx") + assert find_runner_collected_test_path(code) is None + + def test_package_json_without_runner_ignored(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", '{"dependencies": {"react": "^18"}}\n') + code = _write(tmp_path / "src/page.tsx") + assert find_runner_collected_test_path(code) is None + + def test_python_module_keeps_pytest_default(self, tmp_path, monkeypatch): + # A JS runner nearby must NOT redirect Python (pytest tests/ is correct). + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", '{"devDependencies": {"jest": "^29"}}\n') + code = _write(tmp_path / "src/foo.py", "def foo():\n return 1\n") + assert find_runner_collected_test_path(code) is None + + def test_malformed_package_json_returns_none(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", "{ this is not json\n") + code = _write(tmp_path / "src/page.tsx") + assert find_runner_collected_test_path(code) is None + + def test_resolve_greenfield_co_locates_over_shadow(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "frontend/package.json", + '{"devDependencies":{"jest":"^29"}}\n') + code = _write(tmp_path / "frontend/src/app/contributions/page.tsx") + shadow = tmp_path / "tests" / "test_page.tsx" # runner-blind, not on disk + got = resolve_test_output_path(code, shadow, user_pinned=False) + assert Path(got).resolve() == ( + code.parent / "__test__" / "page.test.tsx" + ).resolve() + + def test_resolve_greenfield_honors_user_pin(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", "module.exports = {};\n") + code = _write(tmp_path / "src/page.tsx") + shadow = tmp_path / "tests" / "test_page.tsx" + got = resolve_test_output_path(code, shadow, user_pinned=True) + assert Path(got) == shadow + + # --- config-aware placement (issue #1903 §A: honor testMatch/roots/rootDir) --- + + def test_custom_testmatch_spec_co_locates_spec(self, tmp_path, monkeypatch): + # testMatch requires `.spec` -> write `.spec`, not `.test`. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"testMatch": ["**/*.spec.tsx"]}})) + code = _write(tmp_path / "src/page.tsx") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == (tmp_path / "src/__test__/page.spec.tsx").resolve() + + def test_custom_testmatch_requires_dunder_tests_dir(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"testMatch": ["**/__tests__/**/*.tsx"]}})) + code = _write(tmp_path / "src/page.tsx") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == (tmp_path / "src/__tests__/page.test.tsx").resolve() + + def test_centralized_testmatch_derives_collected_path(self, tmp_path, monkeypatch): + # A centralized layout (tests only under top-level test/) does NOT collect + # a co-located test -> DERIVE a path UNDER the collected directory (never + # the runner-blind tests/ shadow, and never an uncollected co-located + # orphan). The derived path must satisfy the configured testMatch. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"testMatch": ["/test/**/*.test.ts"]}})) + code = _write(tmp_path / "src/util.ts") + got = find_runner_collected_test_path(code) + assert got is not None + rel = Path(got).resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel.startswith("test/") and rel.endswith("util.test.ts"), rel + + def test_roots_excluding_module_derives_under_root(self, tmp_path, monkeypatch): + # roots restricts collection to test/; a module under src/ cannot be + # co-located within a collected root -> derive a path UNDER test/. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"roots": ["/test"]}})) + code = _write(tmp_path / "src/app/page.tsx") + got = find_runner_collected_test_path(code) + assert got is not None + rel = Path(got).resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel.startswith("test/") and rel.endswith("page.test.tsx"), rel + + def test_js_config_plain_uses_default_convention(self, tmp_path, monkeypatch): + # A jest.config.js with NO discovery keys uses jest defaults, which + # collect the co-located __test__/*.test.tsx we write. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", "module.exports = {};\n") + code = _write(tmp_path / "src/page.tsx") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == (tmp_path / "src/__test__/page.test.tsx").resolve() + + def test_js_config_custom_discovery_refuses_conservatively(self, tmp_path, monkeypatch): + # A jest.config.js that customizes testMatch (unparseable in Python) -> + # we cannot prove where tests are collected -> refuse (fall back to + # derived) rather than emit a possibly-uncollected co-located test. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", + "module.exports = { testMatch: ['/qa/**/*.test.ts'] };\n") + code = _write(tmp_path / "src/page.tsx") + assert find_runner_collected_test_path(code) is None + shadow = tmp_path / "tests" / "test_page.tsx" + assert resolve_test_output_path(code, shadow, user_pinned=False) == shadow + + def test_roots_including_module_co_locates(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"roots": ["/src"]}})) + code = _write(tmp_path / "src/app/page.tsx") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == ( + tmp_path / "src/app/__test__/page.test.tsx" + ).resolve() + + def test_testpathignore_routes_to_alternate_dir(self, tmp_path, monkeypatch): + # testPathIgnorePatterns excludes __test__/ -> use __tests__ (default + # testMatch still collects it). + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"testPathIgnorePatterns": ["__test__/"]}})) + code = _write(tmp_path / "src/page.tsx") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == (tmp_path / "src/__tests__/page.test.tsx").resolve() + + def test_jest_config_json_is_parsed(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.json", + json.dumps({"testMatch": ["**/*.spec.ts"]})) + code = _write(tmp_path / "lib/util.ts") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == (tmp_path / "lib/__test__/util.spec.ts").resolve() + + def test_centralized_same_stem_modules_do_not_collide(self, tmp_path, monkeypatch): + # Two different modules that share a stem MUST map to DISTINCT centralized + # test paths — else syncing the second overwrites the first (never fork, + # never overwrite). + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"testMatch": ["/test/**/*.test.ts"]}})) + a = _write(tmp_path / "src/a/util.ts") + b = _write(tmp_path / "src/b/util.ts") + got_a = find_runner_collected_test_path(a) + got_b = find_runner_collected_test_path(b) + assert got_a is not None and got_b is not None + assert Path(got_a).resolve() != Path(got_b).resolve(), (got_a, got_b) + # Each preserves its module's relative directory under the test root. + assert "a" in Path(got_a).resolve().relative_to(tmp_path.resolve()).parts + assert "b" in Path(got_b).resolve().relative_to(tmp_path.resolve()).parts + + def test_vitest_custom_include_refuses(self, tmp_path, monkeypatch): + # A vitest.config.ts with a custom test.include (unparseable) must NOT be + # assumed default -> refuse (fall back to derived), never a false-green. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "vitest.config.ts", + "export default { test: { include: ['custom/**/*.spec.ts'] } }\n") + code = _write(tmp_path / "src/page.tsx") + assert find_runner_collected_test_path(code) is None + + def test_vitest_plain_config_co_locates(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write(tmp_path / "vitest.config.ts", "export default { test: {} }\n") + _write(tmp_path / "package.json", '{"devDependencies": {"vitest": "^1"}}\n') + code = _write(tmp_path / "src/page.tsx") + got = find_runner_collected_test_path(code) + assert Path(got).resolve() == (tmp_path / "src/__test__/page.test.tsx").resolve() + + def test_jest_testmatch_negation_excludes(self, tmp_path, monkeypatch): + # An ordered negation (`!**/src/**`) removes matches; a module under src + # is NOT collected co-located -> refuse (no false-green). + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", json.dumps( + {"jest": {"testMatch": ["**/*.test.tsx", "!**/src/**"]}})) + excluded = _write(tmp_path / "src/page.tsx") + included = _write(tmp_path / "lib/widget.tsx") + assert find_runner_collected_test_path(excluded) is None + got = find_runner_collected_test_path(included) + assert Path(got).resolve() == (tmp_path / "lib/__test__/widget.test.tsx").resolve() + + def test_jest_projects_config_refuses(self, tmp_path, monkeypatch): + # jest `projects` restructures discovery in ways we do not resolve -> refuse. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"projects": ["/a", "/b"]}})) + code = _write(tmp_path / "src/page.tsx") + assert find_runner_collected_test_path(code) is None + + def test_delegating_js_config_refuses(self, tmp_path, monkeypatch): + # A jest.config.js that require()s a base config could set discovery in a + # file we can't read -> refuse rather than assume defaults. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", "module.exports = require('./config/jest.base.js')\n") + code = _write(tmp_path / "src/page.tsx") + assert find_runner_collected_test_path(code) is None + + def test_preset_and_function_js_config_refuse(self, tmp_path, monkeypatch): + # preset / function configs can change discovery in ways we can't resolve. + monkeypatch.chdir(tmp_path) + for content in ( + "module.exports = { preset: 'ts-jest' }\n", + "module.exports = () => ({})\n", + "import base from './base'\nexport default { ...base }\n", + ): + (tmp_path / "jest.config.js").write_text(content, encoding="utf-8") + code = _write(tmp_path / f"src/m{abs(hash(content))}.tsx") + assert find_runner_collected_test_path(code) is None, content + + def test_rootdir_only_derives_under_rootdir(self, tmp_path, monkeypatch): + # rootDir centralizes collection; a module outside it derives under rootDir. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", json.dumps({"jest": {"rootDir": "test"}})) + code = _write(tmp_path / "src/page.tsx") + got = find_runner_collected_test_path(code) + assert got is not None + rel = Path(got).resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel.startswith("test/") and rel.endswith("page.test.tsx"), rel + + def test_mjs_module_default_config_refuses(self, tmp_path, monkeypatch): + # jest's DEFAULT testMatch collects js/jsx/ts/tsx, NOT mjs/cjs. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", "module.exports = {}\n") + code = _write(tmp_path / "src/util.mjs") + assert find_runner_collected_test_path(code) is None + + def test_testmatch_negation_then_positive_reincludes(self, tmp_path, monkeypatch): + # Ordered semantics: a later positive re-includes a path an earlier + # negation excluded (module under __tests__/, re-included by the 2nd glob). + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", json.dumps({"jest": {"testMatch": [ + "!**/__fixtures__/**", "**/__tests__/**/*.tsx"]}})) + code = _write(tmp_path / "src/__tests__/page.tsx") + got = find_runner_collected_test_path(code) + assert got is not None and Path(got).name == "page.test.tsx" + + def test_char_class_negation_matcher(self): + from pdd.content_selector import _glob_matches + # `[!_]` = "not underscore" (bash/micromatch), NOT "! or _". + assert _glob_matches("**/[!_]*.test.ts", "src/page.test.ts") is True + assert _glob_matches("**/[!_]*.test.ts", "src/_hidden.test.ts") is False + + def test_ambiguous_existing_tests_not_greenfield(self, tmp_path, monkeypatch): + # TWO existing co-located tests -> ambiguous -> resolve must NOT greenfield + # (would fork a third file). Falls back to the derived path. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", "module.exports = {}\n") + code = _write(tmp_path / "src/page.tsx") + _write(tmp_path / "src/page.test.tsx") + _write(tmp_path / "src/__tests__/page.test.tsx") + shadow = tmp_path / "tests" / "test_page.tsx" + assert resolve_test_output_path(code, shadow, user_pinned=False) == shadow + + def test_explicit_testmatch_no_match_refuses(self, tmp_path, monkeypatch): + # An explicit testMatch that matches NO co-located candidate must fail + # closed (refuse), not fall through to an uncollected default path. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"testMatch": ["/qa/**/*-test.ts"]}})) + code = _write(tmp_path / "src/page.ts") + assert find_runner_collected_test_path(code) is None + + def test_vitest_dir_and_call_config_refuse(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + for content in ( + "export default { test: { dir: './tests' } }\n", # vitest dir + "export default { test: { root: './src' } }\n", # vitest root + "module.exports = buildConfig()\n", # call-expr export + ): + (tmp_path / "vitest.config.ts").write_text(content, encoding="utf-8") + code = _write(tmp_path / f"src/m{abs(hash(content))}.tsx") + assert find_runner_collected_test_path(code) is None, content + + def test_vitest_default_collects_mjs(self, tmp_path, monkeypatch): + # vitest's default include collects .mjs -> a plain vitest project + # co-locates an .mjs module (jest would not; dialect-aware). + monkeypatch.chdir(tmp_path) + _write(tmp_path / "vitest.config.ts", "export default { test: {} }\n") + _write(tmp_path / "package.json", '{"devDependencies": {"vitest": "^1"}}\n') + code = _write(tmp_path / "src/util.mjs") + got = find_runner_collected_test_path(code) + assert got is not None and Path(got).name == "util.test.mjs" + + def test_jest30_collects_mjs_but_jest29_does_not(self, tmp_path, monkeypatch): + # Jest 30's default testMatch collects .mjs/.cjs; Jest 29 does not. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"devDependencies": {"jest": "^30.0.0"}})) + got = find_runner_collected_test_path(_write(tmp_path / "src/util.mjs")) + assert got is not None and Path(got).name == "util.test.mjs" + # Downgrade to jest 29 -> refuse the .mjs (would be uncollected). + (tmp_path / "package.json").write_text( + json.dumps({"devDependencies": {"jest": "^29.7.0"}}), encoding="utf-8") + assert find_runner_collected_test_path(_write(tmp_path / "lib/util.mjs")) is None + + def test_computed_key_and_identifier_configs_refuse(self, tmp_path, monkeypatch): + # A computed testMatch key or a non-literal (identifier) export cannot be + # proven default -> refuse. + monkeypatch.chdir(tmp_path) + for content in ( + "const c = {['test' + 'Match']: ['**/qa/**/*.test.ts']}; module.exports = c\n", + "const config = {}; module.exports = config\n", + ): + (tmp_path / "jest.config.js").write_text(content, encoding="utf-8") + code = _write(tmp_path / f"src/m{abs(hash(content))}.tsx") + assert find_runner_collected_test_path(code) is None, content + + def test_bare_roots_resolved_against_rootdir(self, tmp_path, monkeypatch): + # roots:["src"] with rootDir:"frontend" -> collected root is + # frontend/src (jest resolves roots against effective rootDir). A module + # under frontend/src co-locates; the wrong base would exclude it. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"rootDir": "frontend", "roots": ["src"]}})) + code = _write(tmp_path / "frontend/src/a/page.tsx") + got = find_runner_collected_test_path(code) + assert got is not None + rel = Path(got).resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel.startswith("frontend/src/") and rel.endswith("page.test.tsx"), rel + + def test_explicit_empty_and_both_configs_refuse(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + # testMatch: [] matches nothing in jest -> refuse. + _write(tmp_path / "package.json", json.dumps({"jest": {"testMatch": []}})) + assert find_runner_collected_test_path(_write(tmp_path / "src/a.tsx")) is None + # Both testMatch AND testRegex configured -> jest rejects -> refuse. + _write(tmp_path / "package.json", + json.dumps({"jest": {"testMatch": ["**/*.test.tsx"], "testRegex": ".*"}})) + assert find_runner_collected_test_path(_write(tmp_path / "src/b.tsx")) is None + + def test_script_substring_is_not_a_runner(self, tmp_path): + from pdd.content_selector import _package_json_declares_js_runner + _write(tmp_path / "p1.json", json.dumps({"scripts": {"build": "echo no-jest"}})) + assert _package_json_declares_js_runner(tmp_path / "p1.json") is False + _write(tmp_path / "p2.json", json.dumps({"scripts": {"test": "jest --ci"}})) + assert _package_json_declares_js_runner(tmp_path / "p2.json") is True + _write(tmp_path / "p3.json", json.dumps({"scripts": {"test:unit": "vitest run"}})) + assert _package_json_declares_js_runner(tmp_path / "p3.json") is True + + def test_extglob_comma_is_literal_brace_comma_alternates(self): + from pdd.content_selector import _glob_matches + # extglob @(foo,bar): "foo,bar" is a LITERAL alternative (pipe alternates). + assert _glob_matches("**/@(foo,bar).test.ts", "x/foo.test.ts") is False + assert _glob_matches("**/@(foo|bar).test.ts", "x/foo.test.ts") is True + # brace {foo,bar}: comma alternates. + assert _glob_matches("**/{foo,bar}.test.ts", "x/foo.test.ts") is True + + def test_excessive_pattern_count_refuses(self, tmp_path, monkeypatch): + # A hostile config with a huge pattern count fails closed (DoS bound). + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"jest": {"testMatch": ["**/*.test.tsx"] * 200}})) + code = _write(tmp_path / "src/page.tsx") + assert find_runner_collected_test_path(code) is None + + def test_second_statement_mutation_config_refuses(self, tmp_path, monkeypatch): + # A whitelist (single plain-literal export) refuses a config that mutates + # the export in a SECOND statement (blacklist-evading, round 7). + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", + "module.exports = {}; module.exports['testMatch'] = ['qa/**/*.test.ts']\n") + assert find_runner_collected_test_path(_write(tmp_path / "src/p.tsx")) is None + # A plain literal with only a NON-discovery key is still default. + _write(tmp_path / "jest.config.js", "module.exports = { testEnvironment: 'jsdom' }\n") + got = find_runner_collected_test_path(_write(tmp_path / "src/q.tsx")) + assert got is not None and Path(got).name == "q.test.tsx" + + def test_ambiguous_config_sources_refuse(self, tmp_path, monkeypatch): + # An inline package.json jest block AND a vitest.config.ts at the same + # level -> ambiguous active runner -> refuse (round 7). + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", json.dumps({"jest": {}})) + _write(tmp_path / "vitest.config.ts", "export default { test: {} }\n") + assert find_runner_collected_test_path(_write(tmp_path / "src/p.tsx")) is None + + def test_greenfield_ownership_not_reclassified_as_adopted(self, tmp_path, monkeypatch): + # Two-run provenance: PDD greenfield-creates a co-located test, records + # ownership; a LATER resolution must NOT call it human-adopted. + from pdd.content_selector import ( + was_test_adopted, record_pdd_created_test, is_pdd_created_test, + ) + monkeypatch.chdir(tmp_path) + code = _write(tmp_path / "src/foo.tsx") + gf = tmp_path / "src/__test__/foo.test.tsx" + derived = tmp_path / "tests/test_foo.tsx" + # Simulate run 1: PDD creates the greenfield test and records ownership. + _write(gf, "test('x', () => {})\n") + record_pdd_created_test(str(gf)) + assert is_pdd_created_test(str(gf)) is True + # Run 2: the file now exists as a single co-located sibling, but ownership + # provenance keeps it OUT of the human-adopted never-block. + assert was_test_adopted(code, str(gf), str(derived), user_pinned=False) is False + + def test_concurrent_ownership_records_are_not_lost(self, tmp_path, monkeypatch): + # Round 9: parallel children recording greenfield ownership must not + # clobber each other (locked, atomic RMW). Every recorded path survives. + import threading + from pdd.content_selector import record_pdd_created_test, is_pdd_created_test + monkeypatch.chdir(tmp_path) + paths = [f"src/mod{i}/__test__/mod{i}.test.tsx" for i in range(40)] + barrier = threading.Barrier(len(paths)) + + def _rec(p): + barrier.wait() # maximize contention on the shared manifest + record_pdd_created_test(p) + + threads = [threading.Thread(target=_rec, args=(p,)) for p in paths] + for t in threads: + t.start() + for t in threads: + t.join() + missing = [p for p in paths if not is_pdd_created_test(p)] + assert missing == [], f"lost ownership records under contention: {missing}" + + def test_dynamic_config_construction_refuses(self, tmp_path, monkeypatch): + # A config that DYNAMICALLY builds discovery keys (join/computed) cannot + # be proven a plain literal -> refuse. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", + "const k = ['test','Match'].join(''); module.exports = {[k]: ['**/qa/**/*.test.ts']}\n") + assert find_runner_collected_test_path(_write(tmp_path / "src/p.tsx")) is None + + def test_quoted_discovery_key_config_refuses(self, tmp_path, monkeypatch): + # A QUOTED discovery property key (``"testMatch"``) must refuse just like + # an unquoted one — string-stripping must not erase it (round 8). + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", + 'module.exports = { "testMatch": ["/qa/**/*.test.ts"] }\n') + assert find_runner_collected_test_path(_write(tmp_path / "src/p.tsx")) is None + + def test_two_js_config_files_are_ambiguous(self, tmp_path, monkeypatch): + # Two distinct runner config FILES at the same level (round 8): the loop + # must count both, not keep only the first -> ambiguous -> refuse. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "jest.config.js", "module.exports = {}\n") + _write(tmp_path / "vitest.config.ts", + "export default { test: { include: ['x/**/*.spec.ts'] } }\n") + assert find_runner_collected_test_path(_write(tmp_path / "src/p.tsx")) is None + + def test_jest_semver_range_min_major_is_conservative(self): + # Round 10: enable jest-30-only defaults ONLY when the WHOLE range + # guarantees major >= 30 — a first-integer parse would misread `<30`/ + # `^30 || ^29` and certify an uncollected .mjs test. + from pdd.content_selector import _semver_range_min_major as mm + assert mm("<30") == 0 + assert mm("^30 || ^29") == 29 + assert mm("^30") == 30 + assert mm(">=30") == 30 + assert mm("~30.1") == 30 + assert mm("*") == 0 and mm("latest") == 0 and mm("") == 0 + assert mm(">=29 <31") == 29 + + def test_jest_below_30_range_refuses_mjs(self, tmp_path, monkeypatch): + # A `<30` jest range must NOT enable mjs default discovery (jest <=29 + # does not collect .mjs) -> greenfield refuses for a .mjs module. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", json.dumps({"devDependencies": {"jest": "<30"}})) + assert find_runner_collected_test_path(_write(tmp_path / "src/u.mjs")) is None + # A jest `^30` range DOES collect .mjs -> co-locates. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", json.dumps({"devDependencies": {"jest": "^30"}})) + assert find_runner_collected_test_path(_write(tmp_path / "src/v.mjs")) is not None + + def test_node_modules_candidate_is_not_collected(self, tmp_path, monkeypatch): + # Round 10: default jest/vitest discovery excludes node_modules, so a + # co-located candidate there is a false-green -> refuse. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", '{"devDependencies": {"jest": "^30"}}\n') + code = _write(tmp_path / "node_modules/pkg/src/thing.tsx") + assert find_runner_collected_test_path(code) is None + + def test_globstar_only_crosses_separators_at_segment_boundary(self): + # Round 9: `**` is a globstar (crosses `/`) ONLY as a whole segment. + # Embedded `**` (`qa**/`, `**bar`) is a single-segment `*` in micromatch; + # translating it to `.*` would certify paths jest rejects. + def matches(glob, path): + rx = _micromatch_to_regex(glob) + return bool(rx) and bool(re.match(rx + r"$", path)) + assert matches("**/qa/x.test.ts", "qa/x.test.ts") + assert matches("**/a/b.ts", "x/y/a/b.ts") + assert matches("qa**/page.test.ts", "qafoo/page.test.ts") # single-segment + assert not matches("qa**/page.test.ts", "qa/x/page.test.ts") # must NOT cross / + assert not matches("**bar/x.ts", "a/bar/x.ts") # embedded, no cross + + def test_oversized_raw_pattern_is_bounded(self, tmp_path, monkeypatch): + # A multi-megabyte raw testMatch glob must be rejected BEFORE translation + # (round 8) — it must not hang and must not certify a co-located path. + import time + monkeypatch.chdir(tmp_path) + huge = "a" * 200_000 + _write(tmp_path / "package.json", json.dumps( + {"jest": {"testMatch": [f"/{huge}/**/*.test.ts"]}})) + code = _write(tmp_path / "src/p.tsx") + start = time.time() + find_runner_collected_test_path(code) # must not hang on the giant glob + assert time.time() - start < 3.0 + + def test_jest29_with_vitest_dep_is_ambiguous_mjs_refused(self, tmp_path, monkeypatch): + # jest<=29 AND a vitest dependency is ambiguous for .mjs (a jest run would + # not collect it) -> fail closed. + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", + json.dumps({"devDependencies": {"jest": "^29.0.0", "vitest": "^1"}})) + assert find_runner_collected_test_path(_write(tmp_path / "src/u.mjs")) is None + # A .tsx module is unaffected (collected by both). + got = find_runner_collected_test_path(_write(tmp_path / "src/w.tsx")) + assert got is not None + + def test_discovery_has_total_time_budget(self, tmp_path, monkeypatch): + # Even at the pattern cap, the whole discovery call is time-bounded. + import time + monkeypatch.chdir(tmp_path) + _write(tmp_path / "package.json", json.dumps( + {"jest": {"testMatch": ["**/+(a|aa)b.test.ts"] * 30}})) + code = _write(tmp_path / f"src/{'a' * 44}.tsx") + start = time.time() + find_runner_collected_test_path(code) # must not hang + assert time.time() - start < 5.0, "discovery must be time-bounded" + + def test_runner_pattern_matching_is_redos_bounded(self): + # A catastrophic-backtracking testMatch pattern must not stall: the + # bounded matcher fails closed (None) rather than hanging. + import time + from pdd.content_selector import _safe_regex_search, _micromatch_to_regex + pat = _micromatch_to_regex("**/+(a|aa)b.test.ts") + # all 'a', no trailing 'b' -> exponential backtracking on a naive engine. + text = "src/" + ("a" * 48) + ".test.ts" + start = time.time() + result = _safe_regex_search(pat, text) + elapsed = time.time() - start + assert result is None, "catastrophic pattern must fail closed" + assert elapsed < 1.0, f"matcher must be time-bounded, took {elapsed:.2f}s" + + +# --------------------------------------------------------------------------- +# 3) get_pdd_file_paths integration — mirror the investigator repro using the +# REAL construct_paths (a mock would inject nothing and mask the round-4 +# resolved_config-pollution blocker this fix routes around — see #1903). +# --------------------------------------------------------------------------- + +def _build_jest_project(tmp_path: Path, *, test_output_path: str | None) -> tuple[Path, Path]: + """Realistic Next.js/jest project on disk with a co-located test. + + Writes a single-context ``.pddrc`` (generate path + language + example path, + plus an explicit ``test_output_path`` only when *test_output_path* is given), + a real prompt, the module, and the co-located ``__test__/page.test.tsx`` the + runner collects. Returns ``(code, real_test)``. No ``construct_paths`` mock — + the whole derivation runs for real. + """ + pddrc = ( + 'version: "1.0"\n' + "contexts:\n" + " default:\n" + " defaults:\n" + ' generate_output_path: "frontend/src/app/contributions/"\n' + ' default_language: "typescriptreact"\n' + ' example_output_path: "examples/"\n' + ) + if test_output_path is not None: + pddrc += f' test_output_path: "{test_output_path}"\n' + _write(tmp_path / ".pddrc", pddrc) + code = _write( + tmp_path / "frontend/src/app/contributions/page.tsx", + "export default function Page() { return null; }\n", + ) + real = _write(code.parent / "__test__" / "page.test.tsx", "test('x', () => {});\n") + _write(tmp_path / "prompts/page_typescriptreact.prompt", "Generate a page.\n") + return code, real + + +def test_get_pdd_file_paths_adopts_collocated_jest_test(tmp_path, monkeypatch): + """Default naming (.pddrc omits test_output_path) adopts the co-located test the + jest runner collects, not the runner-blind shadow. Uses the REAL construct_paths, + whose resolved_config injects a generated-default test_output_path — the exact + pollution the round-4 blocker exposed (#1903).""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _, real = _build_jest_project(tmp_path, test_output_path=None) + + # Pre-fix behavior: the raw derivation returns the runner-blind shadow and the + # provenance signal is NOT pinned (the .pddrc omits the test location). This is + # the assertion the round-4 blocker would have failed with a mocked construct_paths. + raw, raw_pinned = _get_pdd_file_paths_uncollocated("page", "typescriptreact", "prompts") + assert Path(raw["test"]).name == "test_page.tsx" + assert Path(raw["test"]).resolve() != real.resolve() + assert raw_pinned is False # no explicit test config -> adoption allowed + + # Post-fix behavior: the public entry point adopts the co-located test. + paths = get_pdd_file_paths("page", "typescriptreact", "prompts") + assert Path(paths["test"]).resolve() == real.resolve() + assert [Path(p).resolve() for p in paths["test_files"]] == [real.resolve()] + # No stray tests/ shadow is selected. + shadow = (tmp_path / "tests" / "test_page.tsx").resolve() + assert all(Path(p).resolve() != shadow for p in paths["test_files"]) + + +def _build_greenfield_jest_project(tmp_path: Path) -> tuple[Path, Path]: + """Fresh Next.js/jest project with a runner config but NO test yet (#1903 §A). + + Writes a single-context ``.pddrc`` (no ``test_output_path`` override), a + ``jest.config.js`` and a ``package.json`` declaring jest under ``frontend/``, + a real prompt, and the module — but deliberately NO co-located test. Returns + ``(code, expected_runner_path)`` where ``expected_runner_path`` is the + co-located ``__test__/page.test.tsx`` the jest runner collects (not on disk + yet). Mirrors :func:`_build_jest_project` sans the pre-existing test. + """ + pddrc = ( + 'version: "1.0"\n' + "contexts:\n" + " default:\n" + " defaults:\n" + ' generate_output_path: "frontend/src/app/contributions/"\n' + ' default_language: "typescriptreact"\n' + ' example_output_path: "examples/"\n' + ) + _write(tmp_path / ".pddrc", pddrc) + # A plain jest.config.js (no discovery keys) uses jest defaults, which + # collect the co-located __test__/*.test.tsx. + _write(tmp_path / "frontend/jest.config.js", "module.exports = {};\n") + _write(tmp_path / "frontend/package.json", '{"devDependencies": {"jest": "^29"}}\n') + code = _write( + tmp_path / "frontend/src/app/contributions/page.tsx", + "export default function Page() { return null; }\n", + ) + _write(tmp_path / "prompts/page_typescriptreact.prompt", "Generate a page.\n") + expected = tmp_path / "frontend/src/app/contributions/__test__/page.test.tsx" + return code, expected + + +def test_get_pdd_file_paths_greenfield_writes_runner_collected_path(tmp_path, monkeypatch): + """Issue #1903 §A acceptance: a fresh Next.js/jest project (runner config, + NO existing test, NO ``.pddrc`` ``test_output_path`` override) targets the + FIRST test at the runner-collected co-located ``__test__/page.test.tsx`` — + never a root ``tests/`` orphan. Uses the REAL construct_paths.""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _code, expected = _build_greenfield_jest_project(tmp_path) + assert not expected.exists() # greenfield: the runner test does not exist yet + + # Pre-fix behavior: the raw derivation is the runner-blind shadow, unpinned. + raw, raw_pinned = _get_pdd_file_paths_uncollocated("page", "typescriptreact", "prompts") + assert Path(raw["test"]).name == "test_page.tsx" + assert raw_pinned is False # no explicit test config -> greenfield allowed + + # Post-fix behavior: the public entry point targets the runner-collected path. + paths = get_pdd_file_paths("page", "typescriptreact", "prompts") + assert Path(paths["test"]).resolve() == expected.resolve() + # No root tests/ shadow orphan is selected. + shadow = (tmp_path / "tests" / "test_page.tsx").resolve() + assert Path(paths["test"]).resolve() != shadow + assert all(Path(p).resolve() != shadow for p in paths["test_files"]) + + +def test_get_pdd_file_paths_no_adopt_when_explicit_non_default(tmp_path, monkeypatch): + """Explicit `.pddrc test_output_path: contract-tests/` is never overridden + (real construct_paths; the pin is read from the raw .pddrc defaults).""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _, real = _build_jest_project(tmp_path, test_output_path="contract-tests/") + + _, pinned = _get_pdd_file_paths_uncollocated("page", "typescriptreact", "prompts") + assert pinned is True + paths = get_pdd_file_paths("page", "typescriptreact", "prompts") + assert "contract-tests" in Path(paths["test"]).parts + assert Path(paths["test"]).resolve() != real.resolve() + + +def test_pins_predicate_explicit_empty_is_pinned(): + """Round-8 finding: an explicit empty ``test_output_path``/``outputs.test.path`` + (root-level output) is a real user pin — presence, not truthiness. A genuinely + absent or explicitly-null key is not a pin.""" + assert _pins_test_output_location({"test_output_path": ""}) is True + assert _pins_test_output_location({"outputs": {"test": {"path": ""}}}) is True + assert _pins_test_output_location({}) is False + assert _pins_test_output_location({"test_output_path": None}) is False + + +def test_get_pdd_file_paths_no_adopt_when_explicit_empty_test_output(tmp_path, monkeypatch): + """Round-8 finding: `.pddrc test_output_path: ""` (explicit root-level output) is a + user pin and must NOT be adopted away to the co-located test (real construct_paths). + Fails before the presence-based `_pins_test_output_location`.""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _, real = _build_jest_project(tmp_path, test_output_path="") + + _, pinned = _get_pdd_file_paths_uncollocated("page", "typescriptreact", "prompts") + assert pinned is True + paths = get_pdd_file_paths("page", "typescriptreact", "prompts") + assert Path(paths["test"]).resolve() != real.resolve() + + +def test_get_pdd_file_paths_no_adopt_when_explicit_tests_dir(tmp_path, monkeypatch): + """Explicit `.pddrc test_output_path: tests/` (equal to the default value) is + still an explicit pin -> NO adoption (real construct_paths; finding-2 + regression).""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _, real = _build_jest_project(tmp_path, test_output_path="tests/") + + _, pinned = _get_pdd_file_paths_uncollocated("page", "typescriptreact", "prompts") + assert pinned is True + paths = get_pdd_file_paths("page", "typescriptreact", "prompts") + assert Path(paths["test"]).parts[-2:] == ("tests", "test_page.tsx") + assert Path(paths["test"]).resolve() != real.resolve() + + +def test_get_pdd_file_paths_env_var_pins(tmp_path, monkeypatch): + """PDD_TEST_OUTPUT_PATH pins the location -> NO adoption (real construct_paths).""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PDD_TEST_OUTPUT_PATH", "contract-tests/") + _, real = _build_jest_project(tmp_path, test_output_path=None) + + _, pinned = _get_pdd_file_paths_uncollocated("page", "typescriptreact", "prompts") + assert pinned is True + paths = get_pdd_file_paths("page", "typescriptreact", "prompts") + assert Path(paths["test"]).resolve() != real.resolve() + + +def test_get_pdd_file_paths_no_adopt_with_prompts_dir_anchored_context( + tmp_path, monkeypatch +): + """Finding-2 regression: an explicit test_output_path lives in a context + selected by a prompts_dir prefix (`prompts/frontend`), NOT by the bare + basename/CWD. The authoritative resolution must see the pin and NOT adopt — + a bare-basename context lookup would miss it and wrongly retarget. Uses the + REAL construct_paths (no mock) so the prompts_dir anchoring actually runs.""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _write( + tmp_path / ".pddrc", + 'version: "1.0"\n' + "contexts:\n" + " frontend:\n" + " defaults:\n" + ' prompts_dir: "prompts/frontend"\n' + ' generate_output_path: "frontend/src/app/contributions/"\n' + ' test_output_path: "contract-tests/"\n' + ' default_language: "typescriptreact"\n' + " default:\n" + " defaults:\n" + ' default_language: "python"\n', + ) + code = _write( + tmp_path / "frontend/src/app/contributions/page.tsx", + "export default function Page() { return null; }\n", + ) + real = _write( + code.parent / "__test__" / "page.test.tsx", "test('x', () => {});\n" + ) + _write( + tmp_path / "prompts/frontend/page_typescriptreact.prompt", + "Generate a contributions page.\n", + ) + + # The authoritative (prompts_dir-anchored) resolution sees the explicit pin... + raw, pinned = _get_pdd_file_paths_uncollocated( + "page", "typescriptreact", "prompts/frontend" + ) + assert pinned is True + # ...and the co-located test IS otherwise detectable, so only the pin (not a + # missing sibling) prevents adoption — proving the regression is real. + would_adopt = _adopt_collocated_test(dict(raw), user_pinned=False) + assert Path(would_adopt["test"]).resolve() == real.resolve() + + # Public entry point: explicit pin honored, co-located test NOT adopted. + paths = get_pdd_file_paths("page", "typescriptreact", "prompts/frontend") + assert "contract-tests" in Path(paths["test"]).parts + assert Path(paths["test"]).resolve() != real.resolve() + + +def test_get_pdd_file_paths_python_default_unchanged(tmp_path, monkeypatch): + """Standard Python layout (src/foo.py + tests/test_foo.py) is a no-op: the + derived shadow equals the real co-located test, so nothing is retargeted + (real construct_paths).""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _write( + tmp_path / ".pddrc", + 'version: "1.0"\n' + "contexts:\n" + " default:\n" + " defaults:\n" + ' generate_output_path: "src/"\n' + ' test_output_path: "tests/"\n' + ' example_output_path: "examples/"\n' + ' default_language: "python"\n', + ) + _write(tmp_path / "src/foo.py", "def foo():\n return 1\n") + real = _write(tmp_path / "tests/test_foo.py", "def test_foo():\n assert True\n") + _write(tmp_path / "prompts/foo_python.prompt", "Generate foo\n") + + paths = get_pdd_file_paths("foo", "python", "prompts") + assert Path(paths["test"]).resolve() == real.resolve() + + +# --------------------------------------------------------------------------- +# 3b) Provenance signal — explicit-vs-default detection +# --------------------------------------------------------------------------- + +class TestExplicitConfigProvenance: + def test_pins_predicate_flat_key(self): + assert _pins_test_output_location({"test_output_path": "tests/"}) is True + + def test_pins_predicate_template_form(self): + assert _pins_test_output_location( + {"outputs": {"test": {"path": "tests/test_{name}{ext}"}}} + ) is True + + def test_pins_predicate_absent(self): + assert _pins_test_output_location({"generate_output_path": "src/"}) is False + assert _pins_test_output_location({}) is False + + def test_configured_pin_reads_raw_default_context(self, tmp_path, monkeypatch): + """`configured_test_output_pinned` reads the raw `.pddrc` default context — + NOT construct_paths' resolved_config — so a `default`-context test_output_path + is recognized while an omitted one is not.""" + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _write( + tmp_path / ".pddrc", + 'version: "1.0"\ncontexts:\n default:\n defaults:\n' + ' test_output_path: "contract-tests/"\n', + ) + code = _write(tmp_path / "src/foo.py", "def foo():\n return 1\n") + assert configured_test_output_pinned(code) is True + + # A sibling project with no test_output_path -> not pinned. + other = tmp_path.parent / (tmp_path.name + "_other") + _write( + other / ".pddrc", + 'version: "1.0"\ncontexts:\n default:\n defaults:\n' + ' generate_output_path: "src/"\n', + ) + other_code = _write(other / "src/foo.py", "def foo():\n return 1\n") + assert configured_test_output_pinned(other_code) is False + + def test_env_var_pins_via_configured(self, tmp_path, monkeypatch): + """PDD_TEST_OUTPUT_PATH alone makes the location pinned (no .pddrc needed).""" + monkeypatch.setenv("PDD_TEST_OUTPUT_PATH", "contract-tests/") + code = _write(tmp_path / "src/foo.py", "def foo():\n return 1\n") + assert configured_test_output_pinned(code) is True + + +# --------------------------------------------------------------------------- +# 4) cmd_test_main — direct `pdd test` (REAL construct_paths; only generation +# is mocked so path construction and adoption run end-to-end). +# --------------------------------------------------------------------------- + +@pytest.fixture +def local_ctx(): + """Click context forcing local execution (no cloud round-trip).""" + import click + ctx = click.Context(click.Command("test")) + ctx.obj = { + "verbose": False, + "strength": 0.5, + "temperature": 0.0, + "force": False, + "quiet": True, + "local": True, + } + return ctx + + +def _cmd_project(tmp_path: Path, *, test_output_path: str | None) -> tuple[Path, Path, Path]: + """A minimal Python project for `pdd test`: `.pddrc` + module + co-located + (empty) sibling test + prompt. Returns ``(code, prompt, collocated)``.""" + pddrc = ( + 'version: "1.0"\n' + "contexts:\n" + " default:\n" + " defaults:\n" + ' generate_output_path: "src/"\n' + ' default_language: "python"\n' + ) + if test_output_path is not None: + pddrc += f' test_output_path: "{test_output_path}"\n' + _write(tmp_path / ".pddrc", pddrc) + code = _write(tmp_path / "src/foo.py", "def foo():\n return 1\n") + prompt = _write(tmp_path / "prompts/foo_python.prompt", "Generate foo\n") + # Empty co-located sibling -> first-time generation (no churn). + collocated = _write(code.parent / "test_foo.py", "") + return code, prompt, collocated + + +def test_cmd_test_main_retargets_to_collocated_when_output_absent( + tmp_path, local_ctx, monkeypatch +): + """With no --output and no explicit .pddrc pin, direct `pdd test` writes to the + co-located sibling instead of the runner-blind root shadow (real construct_paths).""" + from unittest.mock import patch + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + code, prompt, collocated = _cmd_project(tmp_path, test_output_path=None) + shadow = tmp_path / "test_foo.py" # derived root shadow + generated = "def test_foo():\n assert foo() == 1\n" + + with patch("pdd.cmd_test_main.generate_test") as mock_gen: + mock_gen.return_value = (generated, 0.01, "model") + cmd_test_main( + ctx=local_ctx, + prompt_file=str(prompt), + code_file=str(code), + output=None, + language="python", + ) + + assert collocated.read_text(encoding="utf-8") == generated + assert not shadow.exists() + + +def test_cmd_test_main_honors_explicit_output(tmp_path, local_ctx, monkeypatch): + """An explicit --output is never replaced by a co-located sibling (real + construct_paths).""" + from unittest.mock import patch + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + code, prompt, collocated = _cmd_project(tmp_path, test_output_path=None) + pinned = tmp_path / "custom" / "my_test.py" + generated = "def test_foo():\n assert foo() == 1\n" + + with patch("pdd.cmd_test_main.generate_test") as mock_gen: + mock_gen.return_value = (generated, 0.01, "model") + cmd_test_main( + ctx=local_ctx, + prompt_file=str(prompt), + code_file=str(code), + output=str(pinned), + language="python", + ) + + assert pinned.read_text(encoding="utf-8") == generated + assert collocated.read_text(encoding="utf-8") == "" # untouched + + +def test_cmd_test_main_no_adopt_when_pddrc_pins_test_output( + tmp_path, local_ctx, monkeypatch +): + """No --output, but `.pddrc` explicitly pins test_output_path for the code file's + context: the co-located sibling must NOT override it (finding 1). The pin is read + from the RAW `.pddrc` (not construct_paths' resolved_config), so a real `.pddrc` + is on disk and construct_paths runs for real.""" + from unittest.mock import patch + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + code, prompt, collocated = _cmd_project(tmp_path, test_output_path="contract-tests/") + pinned = tmp_path / "contract-tests" / "test_foo.py" # explicit .pddrc dir + generated = "def test_foo():\n assert foo() == 1\n" + + with patch("pdd.cmd_test_main.generate_test") as mock_gen: + mock_gen.return_value = (generated, 0.01, "model") + cmd_test_main( + ctx=local_ctx, + prompt_file=str(prompt), + code_file=str(code), + output=None, + language="python", + ) + + assert pinned.read_text(encoding="utf-8") == generated + assert collocated.read_text(encoding="utf-8") == "" # untouched + + +def test_cmd_test_main_no_adopt_with_prompts_dir_context_pin( + tmp_path, local_ctx, monkeypatch +): + """Round-4 finding: the explicit ``test_output_path`` lives in a context selected + by ``prompts_dir`` (``prompts/backend``), so reading the pin from the CODE file + alone resolves to ``default``/unpinned and would wrongly adopt. Direct ``pdd test`` + must read the pin from the PROMPT side (the way ``construct_paths`` selects the + context) and NOT adopt the co-located sibling. Real ``construct_paths``; only + generation is mocked.""" + from unittest.mock import patch + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _write( + tmp_path / ".pddrc", + 'version: "1.0"\n' + "contexts:\n" + " backend:\n" + " defaults:\n" + ' prompts_dir: "prompts/backend"\n' + ' generate_output_path: "services/api/"\n' + ' test_output_path: "contract-tests/"\n' + ' default_language: "python"\n' + " default:\n" + " defaults:\n" + ' generate_output_path: "src/"\n' + ' default_language: "python"\n', + ) + code = _write(tmp_path / "services/api/foo.py", "def foo():\n return 1\n") + prompt = _write(tmp_path / "prompts/backend/foo_python.prompt", "Generate foo\n") + collocated = _write(code.parent / "test_foo.py", "") # empty co-located sibling + pinned = tmp_path / "contract-tests" / "test_foo.py" # prompts_dir-context pin + generated = "def test_foo():\n assert foo() == 1\n" + + with patch("pdd.cmd_test_main.generate_test") as mock_gen: + mock_gen.return_value = (generated, 0.01, "model") + cmd_test_main( + ctx=local_ctx, + prompt_file=str(prompt), + code_file=str(code), + output=None, + language="python", + ) + + # Pin (from the prompts_dir-selected context) honored: written to contract-tests/, + # the co-located sibling left untouched. + assert pinned.read_text(encoding="utf-8") == generated + assert collocated.read_text(encoding="utf-8") == "" + + +# --------------------------------------------------------------------------- +# 5) architecture.json branch — the pin must be read from the context the arch +# branch derives `test_dir` from (issue #1903 finding 1). REAL construct_paths +# and a REAL architecture.json on disk. +# --------------------------------------------------------------------------- + +def test_arch_json_branch_honors_pin_from_code_path_context(tmp_path, monkeypatch): + """Finding 1: with architecture.json driving path derivation, the explicit + `test_output_path` lives in a context selected by the arch CODE path + (`frontend/**`), NOT by the prompt (which sits at `prompts/` and resolves to + `default`). The arch branch must read its pin from THAT code-path context and + NOT adopt the co-located test — a prompt-side-only pin would miss it and wrongly + retarget the explicit `contract-tests/` path onto the co-located sibling.""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _write( + tmp_path / ".pddrc", + json.dumps({ + "version": "1.0", + "contexts": { + "frontend": { + "paths": ["frontend/**"], + "defaults": { + "generate_output_path": "frontend/src/app/", + "test_output_path": "contract-tests/", + "default_language": "typescriptreact", + }, + }, + "default": {"defaults": {"default_language": "python"}}, + }, + }), + ) + (tmp_path / "architecture.json").write_text( + json.dumps([ + {"filename": "page_typescriptreact.prompt", + "filepath": "frontend/src/app/page.tsx"}, + ]), + encoding="utf-8", + ) + code = _write( + tmp_path / "frontend/src/app/page.tsx", + "export default function Page() { return null; }\n", + ) + real = _write(code.parent / "__test__" / "page.test.tsx", "test('x', () => {});\n") + # Prompt at the top level -> matches `default`, NOT the `frontend` context. + _write(tmp_path / "prompts/page_typescriptreact.prompt", "Generate a page.\n") + + # The arch branch resolves the pin from the code-path (`frontend`) context... + raw, pinned = _get_pdd_file_paths_uncollocated("page", "typescriptreact", "prompts") + assert pinned is True + assert "contract-tests" in Path(raw["test"]).parts + # ...and the co-located test IS otherwise detectable, so ONLY the pin (not a + # missing sibling) prevents adoption — proving the regression is real. + would_adopt = _adopt_collocated_test(dict(raw), user_pinned=False) + assert Path(would_adopt["test"]).resolve() == real.resolve() + + # Public entry point: explicit pin honored, co-located test NOT adopted. + paths = get_pdd_file_paths("page", "typescriptreact", "prompts") + assert "contract-tests" in Path(paths["test"]).parts + assert Path(paths["test"]).resolve() != real.resolve() + + +# --------------------------------------------------------------------------- +# 6) churn guard coverage — `_is_test_output_path` must recognize the adopted +# `.mjs/.cjs` test files and the singular `__test__` dir (issue #1903 +# finding 2), so an adopted human test is never wholesale-rewritten outside +# the TestChurnError guard. +# --------------------------------------------------------------------------- + +class TestIsTestOutputPathChurnCoverage: + def test_recognizes_mjs_cjs_suffixes(self): + assert _is_test_output_path("src/__test__/page.test.mjs") is True + assert _is_test_output_path("foo.spec.cjs") is True + assert _is_test_output_path("src/page.test.cjs") is True + assert _is_test_output_path("src/page.spec.mjs") is True + + def test_recognizes_singular_dunder_test_dir(self): + assert _is_test_output_path("pkg/__test__/widget.test.tsx") is True + assert _is_test_output_path("frontend/src/app/__test__/page.tsx") is True + + def test_plain_module_still_not_a_test(self): + assert _is_test_output_path("src/page.mjs") is False + assert _is_test_output_path("src/page.cjs") is False + + +# --------------------------------------------------------------------------- +# 7) cmd_test_main generation destination — native generation must be told the +# ADOPTED test path, not the stale pre-adoption default (issue #1903 +# finding 3). REAL construct_paths; only generation is mocked. +# --------------------------------------------------------------------------- + +def test_cmd_test_main_generation_uses_adopted_path(tmp_path, local_ctx, monkeypatch): + """Finding 3: after adoption retargets the write to the co-located sibling, the + native generation must be prompted with THAT destination (so relative imports + are correct) — not the stale `output_file` default (`test_output.py`).""" + from unittest.mock import patch + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + code, prompt, collocated = _cmd_project(tmp_path, test_output_path=None) + generated = "def test_foo():\n assert foo() == 1\n" + + with patch("pdd.cmd_test_main.generate_test") as mock_gen: + mock_gen.return_value = (generated, 0.01, "model") + cmd_test_main( + ctx=local_ctx, + prompt_file=str(prompt), + code_file=str(code), + output=None, + language="python", + ) + + # Generation was told the adopted co-located destination, not `test_output.py`. + passed_test_path = Path(mock_gen.call_args.kwargs["test_file_path"]) + assert passed_test_path.resolve() == collocated.resolve() + assert passed_test_path.name != "test_output.py" + # And the write landed there. + assert collocated.read_text(encoding="utf-8") == generated + + +def test_cmd_test_main_generation_uses_explicit_output(tmp_path, local_ctx, monkeypatch): + """Round-6 finding: when sync passes the ALREADY-adopted path in as an explicit + ``output`` (so cmd_test_main itself does NOT retarget), native generation must + still be prompted with that real destination — not the bare ``test_output.py`` + fallback (``output_file`` is absent for ``test`` commands). Mirrors + ``sync_orchestration`` calling ``cmd_test_main(output=str(pdd_files['test']))``. + Fails before the ``setdefault('output_file', output)`` fix (test_file_path would + be ``test_output.py``).""" + from unittest.mock import patch + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + code, prompt, collocated = _cmd_project(tmp_path, test_output_path=None) + generated = "def test_foo():\n assert foo() == 1\n" + + with patch("pdd.cmd_test_main.generate_test") as mock_gen: + mock_gen.return_value = (generated, 0.01, "model") + cmd_test_main( + ctx=local_ctx, + prompt_file=str(prompt), + code_file=str(code), + output=str(collocated), # sync passes the already-adopted path explicitly + language="python", + ) + + # Generation is prompted with the real write destination's directory (correct + # relative imports), never the bare `test_output.py` fallback. (The exact leaf + # may differ from `collocated` under construct_paths' force=False dedup, which + # real sync avoids via merge=True; the invariant we lock is: not the fallback, + # and co-located with the module.) + passed_test_path = Path(mock_gen.call_args.kwargs["test_file_path"]) + assert passed_test_path.name != "test_output.py" + assert passed_test_path.parent.resolve() == collocated.parent.resolve() + + +def test_cmd_test_main_sync_merge_writes_real_collocated_test_no_shadow( + tmp_path, local_ctx, monkeypatch +): + """Round-7 finding (core jest case): sync regenerates by MERGING into the + EXISTING adopted co-located TSX test — `cmd_test_main(output=str(real_test), + existing_tests=[real_test], merge=True, language='typescriptreact')`. With + force=False, construct_paths would number the existing path to + `page.test_1.tsx` and the agentic branch would write that NEW shadow, leaving + the real test stale (the #1903 false-green persists). Assert the agentic write + targets the REAL co-located test and no numbered shadow is created. Fails + before the merge-target pin.""" + from unittest.mock import patch + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + _write( + tmp_path / ".pddrc", + 'version: "1.0"\n' + "contexts:\n" + " default:\n" + " defaults:\n" + ' generate_output_path: "frontend/src/app/contributions/"\n' + ' default_language: "typescriptreact"\n', + ) + code = _write( + tmp_path / "frontend/src/app/contributions/page.tsx", + "export default function Page(){return null;}\n", + ) + generated = "test('renders', () => { expect(true).toBe(true); });\n" + # Existing content == generated so the churn guard is a no-op; the invariant + # under test is the WRITE PATH, not churn. + real_test = _write(code.parent / "__test__" / "page.test.tsx", generated) + prompt = _write(tmp_path / "prompts/page_typescriptreact.prompt", "Generate a page.\n") + + captured = {} + + def fake_agentic(*args, **kwargs): + out = kwargs.get("output_test_file") + captured["output_test_file"] = out + Path(out).write_text(generated, encoding="utf-8") + return (generated, 0.01, "model", True, None) + + with patch( + "pdd.agentic_test_generate.run_agentic_test_generate", side_effect=fake_agentic + ): + cmd_test_main( + ctx=local_ctx, + prompt_file=str(prompt), + code_file=str(code), + output=str(real_test), # sync passes the adopted existing test + existing_tests=[str(real_test)], + merge=True, + language="typescriptreact", + ) + + assert Path(captured["output_test_file"]).resolve() == real_test.resolve() + assert not (real_test.parent / "page.test_1.tsx").exists() + assert real_test.read_text(encoding="utf-8") == generated + + +def test_env_var_present_but_empty_is_pinned(tmp_path, monkeypatch): + """An explicitly exported `PDD_TEST_OUTPUT_PATH=` (present but EMPTY) is a + root-level pin — presence, not truthiness — consistent with `.pddrc + test_output_path: ""`; a co-located test must NOT be adopted over it. Fails + before the presence-based env check (empty env would read as unpinned).""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PDD_TEST_OUTPUT_PATH", "") # present but empty + code, real = _build_jest_project(tmp_path, test_output_path=None) + + # The predicate short-circuits to pinned on a present (even empty) env var. + assert configured_test_output_pinned(str(code)) is True + _, pinned = _get_pdd_file_paths_uncollocated("page", "typescriptreact", "prompts") + assert pinned is True + paths = get_pdd_file_paths("page", "typescriptreact", "prompts") + assert Path(paths["test"]).resolve() != real.resolve() + + +def test_evidence_manifest_resolve_test_output_uses_adopted_path(tmp_path, monkeypatch): + """`evidence_manifest.resolve_test_output_paths` must record the ADOPTED co-located + test (the file PDD actually writes/runs), not the runner-blind derived shadow — + mirroring cmd_test_main so `pdd test --evidence` audit/replay points at the right + artifact (#1903). Fails before the evidence-side adoption fix.""" + from pdd.evidence_manifest import resolve_test_output_paths + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PDD_TEST_OUTPUT_PATH", raising=False) + code, real = _build_jest_project(tmp_path, test_output_path=None) + prompt = tmp_path / "prompts" / "page_typescriptreact.prompt" + + got = resolve_test_output_paths(str(prompt), str(code)) + assert [Path(p).resolve() for p in got] == [real.resolve()] + + # An explicit pin is still respected (no adoption). + monkeypatch.setenv("PDD_TEST_OUTPUT_PATH", "contract-tests/") + got_pinned = resolve_test_output_paths(str(prompt), str(code)) + assert Path(got_pinned[0]).resolve() != real.resolve() + + +# --------------------------------------------------------------------------- +# CWE-022 containment (PR #1914 CodeQL alerts #339-#342): adoption candidates +# derived from a caller-supplied code-file path must never escape the working +# tree — the adopted path becomes a generated-test WRITE target. +# --------------------------------------------------------------------------- + + +def test_find_collocated_test_rejects_candidates_outside_cwd(tmp_path, monkeypatch): + """A code file outside the working tree (traversal shape) is never adopted: + its real sibling test exists but lies outside cwd -> no match.""" + outside = tmp_path / "outside" + module = _write(outside / "widget.ts", "export const x = 1;\n") + _write(outside / "widget.test.ts", "test('x', () => {});\n") + + workdir = tmp_path / "repo" + workdir.mkdir() + monkeypatch.chdir(workdir) + + # Reachable via traversal from cwd, but resolves outside the working tree. + traversal = Path("..") / "outside" / "widget.ts" + assert find_collocated_test(traversal) is None + assert find_collocated_test(module) is None # absolute form, same escape + + +def test_resolve_test_output_path_traversal_returns_derived(tmp_path, monkeypatch): + outside = tmp_path / "outside" + _write(outside / "widget.ts", "export const x = 1;\n") + _write(outside / "widget.test.ts", "test('x', () => {});\n") + + workdir = tmp_path / "repo" + workdir.mkdir() + monkeypatch.chdir(workdir) + + derived = Path("tests") / "test_widget.ts" + got = resolve_test_output_path( + Path("..") / "outside" / "widget.ts", derived, user_pinned=False + ) + assert got == derived # no adoption, safe degradation + + +def test_adopt_collocated_test_keeps_result_when_adoption_escapes_root( + tmp_path, monkeypatch +): + """Sink-side defense in depth: even if a containing-escape path reached + _adopt_collocated_test, the derived result is kept unchanged.""" + outside = tmp_path / "outside" + module = _write(outside / "widget.ts", "export const x = 1;\n") + _write(outside / "widget.test.ts", "test('x', () => {});\n") + + workdir = tmp_path / "repo" + workdir.mkdir() + monkeypatch.chdir(workdir) + + derived = { + "code": module, + "test": Path("tests") / "test_widget.ts", + "test_files": [Path("tests") / "test_widget.ts"], + } + got = _adopt_collocated_test(dict(derived), user_pinned=False) + assert got["test"] == derived["test"] + assert got["test_files"] == derived["test_files"] + + +def test_in_repo_adoption_still_works_after_containment(tmp_path, monkeypatch): + """Containment must not break the normal in-repo adoption path.""" + monkeypatch.chdir(tmp_path) + code, real = _build_jest_project(tmp_path, test_output_path=None) + got = resolve_test_output_path( + code, Path("tests") / "test_page.tsx", user_pinned=False + ) + assert Path(got).resolve() == real.resolve() + + +def test_validated_project_path_sanitizes_before_filesystem_use(tmp_path, monkeypatch): + """Only component-sanitized paths under the trusted root are canonicalized.""" + repo = tmp_path / "repo" + repo.mkdir() + module = _write(repo / "src" / "widget.ts", "export const x = 1;\n") + outside = _write(tmp_path / "outside.ts", "export const y = 2;\n") + monkeypatch.chdir(repo) + + assert _validated_project_path("src/widget.ts") == module.resolve() + assert _validated_project_path(module) == module.resolve() + assert _validated_project_path(Path("..") / "outside.ts") is None + assert _validated_project_path(outside) is None + + +def test_find_collocated_test_rejects_symlink_escape(tmp_path, monkeypatch): + """A lexical in-repo candidate whose symlink target escapes is rejected.""" + repo = tmp_path / "repo" + code = _write(repo / "src" / "widget.ts", "export const x = 1;\n") + outside_test = _write(tmp_path / "widget.test.ts", "test('x', () => {});\n") + (code.parent / "widget.test.ts").symlink_to(outside_test) + monkeypatch.chdir(repo) + + assert find_collocated_test(code) is None + + +def test_pdd_created_tests_lock_is_gitignored_but_manifest_tracked(): + """The transient ownership-manifest lock must never be stageable. + + Codex review (PR #1998): `record_pdd_created_test` opens + `.pdd/meta/pdd_created_tests.json.lock` (O_CREAT) and does NOT unlink it + (advisory flock is fd-bound; unlinking would break mutual exclusion). If it + is stageable, durable sync's `git add -A` stages a `.lock` the allowlist then + rejects (not the ownership manifest, not a module-prefixed `.json`), failing + an otherwise-successful greenfield module. The lock must be gitignored while + the manifest itself stays tracked. + """ + import subprocess + from pathlib import Path + + repo_root = Path(__file__).resolve().parents[1] + + def _ignored(rel: str) -> bool: + return ( + subprocess.run( + ["git", "check-ignore", "-q", rel], + cwd=repo_root, + ).returncode + == 0 + ) + + assert _ignored(".pdd/meta/pdd_created_tests.json.lock"), ( + "the ownership-manifest lock file must be gitignored" + ) + assert not _ignored(".pdd/meta/pdd_created_tests.json"), ( + "the ownership manifest itself must remain tracked" + ) + + +def test_safe_regex_search_fails_closed_without_timeout_engine(monkeypatch): + """Without the wall-clock-timeout regex engine, untrusted patterns must not run. + + Codex review (PR #1998): repo-controlled testRegex/testMatch are ReDoS + vectors. The length caps do not bound a SHORT catastrophic pattern, so when + the timeout-capable `regex` package is unavailable the fallback must fail + closed (return None) rather than evaluate the pattern with unbounded stdlib + `re`. + """ + import pdd.content_selector as cs + + monkeypatch.setattr(cs, "_HAVE_REDOS_REGEX", False) + # Even a trivially-matching pattern is not evaluated on the fallback path. + assert cs._safe_regex_search("a", "a") is None + + +def test_safe_regex_search_times_out_on_catastrophic_pattern(): + """A catastrophic short pattern fails closed (None) quickly, never hangs.""" + import time + import pdd.content_selector as cs + + if not cs._HAVE_REDOS_REGEX: + import pytest as _pytest + _pytest.skip("timeout-capable regex engine not installed") + + evil = "(a+)+$" + text = "a" * 2000 + "!" # forces catastrophic backtracking, non-matching tail + start = time.monotonic() + result = cs._safe_regex_search(evil, text) + elapsed = time.monotonic() - start + + assert result is None # fail closed on timeout + assert elapsed < 2.0, f"ReDoS not bounded: took {elapsed:.2f}s" diff --git a/tests/test_issue_1939_mock_contract_validation.py b/tests/test_issue_1939_mock_contract_validation.py new file mode 100644 index 000000000..d0ffcd106 --- /dev/null +++ b/tests/test_issue_1939_mock_contract_validation.py @@ -0,0 +1,204 @@ +"""Regression coverage for generated mock-vs-real-contract validation (#1939).""" + +from __future__ import annotations + +import io +from pathlib import Path + +from rich.console import Console + +from pdd.agentic_e2e_fix_orchestrator import ( + _find_mock_contract_test_files, + _post_step9_resume_action, + _resolve_step9_loop_token, +) + + +def _console() -> Console: + return Console(file=io.StringIO(), force_terminal=False) + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def test_mock_audit_routing_uses_mock_apis_not_field_name_heuristics( + tmp_path: Path, +) -> None: + """Route on actual mocking APIs, never on suspicious-looking field names.""" + plain_test = tmp_path / "tests" / "test_plain_contract.py" + mocked_test = tmp_path / "tests" / "test_mocked_contract.py" + pytest_mock_test = tmp_path / "tests" / "test_pytest_mock_contract.py" + _write( + plain_test, + "def test_payload():\n" + " payload = {'userId': 'uid-1'}\n" + " assert payload['userId'] == 'uid-1'\n", + ) + _write( + mocked_test, + "from unittest.mock import patch\n\n" + "def test_reader():\n" + " with patch('service.read_user') as reader:\n" + " reader.return_value = {'uid': 'uid-1'}\n" + " assert reader()['uid'] == 'uid-1'\n", + ) + _write( + pytest_mock_test, + "def test_reader(mocker):\n" + " reader = mocker.patch('service.read_user')\n" + " reader.return_value = {'uid': 'uid-1'}\n" + " assert reader()['uid'] == 'uid-1'\n", + ) + + result = _find_mock_contract_test_files( + [ + "tests/test_plain_contract.py", + "tests/test_mocked_contract.py", + "tests/test_pytest_mock_contract.py", + ], + tmp_path, + ) + + assert result == [ + "tests/test_mocked_contract.py", + "tests/test_pytest_mock_contract.py", + ] + + +def test_4235_impossible_query_mock_cannot_be_certified_by_green_tests( + tmp_path: Path, +) -> None: + """The live #4235 shape is routed to audit and mismatch overrides green.""" + _write( + tmp_path / "context" / "database-schema.md", + "user_waitlist is keyed by the user UID document id; documents do not " + "contain a userId field.\n", + ) + _write( + tmp_path / "service.py", + "def list_waitlist(query_collection, uids):\n" + " return query_collection('user_waitlist', " + "filters=[('userId', 'in', uids)])\n", + ) + test_path = tmp_path / "tests" / "test_service.py" + _write( + test_path, + "from unittest.mock import MagicMock\n" + "from service import list_waitlist\n\n" + "def test_batch_lookup():\n" + " query_collection = MagicMock(return_value=[{'userId': 'uid-1'}])\n" + " assert list_waitlist(query_collection, ['uid-1'])\n", + ) + + audit_files = _find_mock_contract_test_files( + ["tests/test_service.py"], tmp_path + ) + output = """All generated tests pass. +The schema and sibling document readers prove that user_waitlist uses UID +document ids and has no userId query field. +**Mock contract audit:** MOCK_CONTRACT_MISMATCH +**Status:** ALL_TESTS_PASS +""" + + assert audit_files == ["tests/test_service.py"] + assert ( + _resolve_step9_loop_token( + output, + _console(), + mock_contract_audit_required=bool(audit_files), + ) + == "CONTINUE_CYCLE" + ) + + +def test_real_contract_mock_keeps_the_existing_pass_path() -> None: + """A verified test double preserves the normal independently-tested pass path.""" + output = """The mock matches the declared UserRecord type and both production readers. +**Mock contract audit:** MOCK_CONTRACTS_VERIFIED +**Status:** ALL_TESTS_PASS +""" + + assert ( + _resolve_step9_loop_token( + output, + _console(), + mock_contract_audit_required=True, + ) + == "LOCAL_TESTS_PASS" + ) + + +def test_required_audit_missing_exact_verification_line_fails_closed() -> None: + """A prose mention cannot impersonate the exact audit result line.""" + output = """All tests pass. I considered MOCK_CONTRACTS_VERIFIED in prose. +**Status:** ALL_TESTS_PASS +""" + + assert ( + _resolve_step9_loop_token( + output, + _console(), + mock_contract_audit_required=True, + ) + == "CONTINUE_CYCLE" + ) + + +def test_tests_without_mocks_do_not_require_a_new_marker() -> None: + """Tests without doubles retain the pre-#1939 Step 9 behavior.""" + assert ( + _resolve_step9_loop_token( + "**Status:** ALL_TESTS_PASS", + _console(), + mock_contract_audit_required=False, + ) + == "LOCAL_TESTS_PASS" + ) + + +def test_cached_step9_pass_reapplies_mock_contract_gate_on_resume() -> None: + """Resume must not trust a pre-gate cached success token.""" + unverified = _post_step9_resume_action( + "**Status:** ALL_TESTS_PASS", + current_cycle=1, + max_cycles=3, + console=_console(), + mock_contract_audit_required=True, + ) + verified = _post_step9_resume_action( + "**Mock contract audit:** MOCK_CONTRACTS_VERIFIED\n" + "**Status:** ALL_TESTS_PASS", + current_cycle=1, + max_cycles=3, + console=_console(), + mock_contract_audit_required=True, + ) + + assert unverified == "ADVANCE_CYCLE" + assert verified == "SUCCESS_FALL_THROUGH" + + +def test_step9_prompt_requires_real_contract_evidence_and_exact_markers() -> None: + """The audit prompt anchors decisions in real contracts and exact markers.""" + prompt = ( + Path(__file__).resolve().parents[1] + / "pdd" + / "prompts" + / "agentic_e2e_fix_step9_verify_all_LLM.prompt" + ).read_text(encoding="utf-8") + + for expected in ( + "{mock_contract_audit_required}", + "{mock_contract_test_files}", + "schema documentation", + "declared interfaces/types", + "production readers and writers", + "sibling production call sites", + "query fields/operators", + "MOCK_CONTRACT_MISMATCH", + "MOCK_CONTRACTS_VERIFIED", + "Do not treat the generated test", + ): + assert expected in prompt diff --git a/tests/test_issue_1968_annotation_convergence.py b/tests/test_issue_1968_annotation_convergence.py new file mode 100644 index 000000000..a404a9696 --- /dev/null +++ b/tests/test_issue_1968_annotation_convergence.py @@ -0,0 +1,250 @@ +"""Issue #1968: the sync surface-contract repair must converge on annotation-level +drift (``object`` vs ``Any``, a broadened parameter union). + +Before this fix the public-surface gate correctly flagged an annotation-level +regression on a declared ```` signature, but the repair loop did +not converge: the retry regenerated the identical drift and failed again. Two +complementary fixes are exercised here. + +* Requirement 1 (LLM directive) — the repair directive built for a + ``signature_changed`` / ``source: pdd-interface`` violation must inject the + DECLARED signature as a VERBATIM hard constraint ("emit exactly this + signature ...", plus an explicit anti-substitution rule), not merely describe + the violation. Both builders are covered: the typed + :class:`PublicSurfaceRegressionError.repair_directive` (in-process path) and + ``agentic_sync_runner._parse_public_surface_failure`` (subprocess/cloud path). + +* Requirement 2 (deterministic pass) — + :func:`pdd.code_generator_main._reconcile_declared_annotation_drift` rewrites + an emitted signature back to the declared text when the ONLY drift from the + declaration is annotation spelling on a declared symbol, so the gate passes on + the reconciled code WITHOUT another generation attempt. Non-annotation + (structural) violations are left untouched so the gate still fires. +""" + +import json + +import pytest + +from pdd.code_generator_main import ( + PublicSurfaceRegressionError, + _reconcile_declared_annotation_drift, + _verify_public_surface_regression, +) + +PROMPT = "secrets_client_Python.prompt" +OUT = "src/clients/secrets_client.py" + + +def _iface_prompt(functions, body="% engineer\n"): + """Build a minimal prompt carrying a ```` module decl.""" + decls = [] + for name, signature in functions: + entry = {"name": name} + if signature is not None: + entry["signature"] = signature + decls.append(entry) + spec = {"type": "module", "module": {"functions": decls}} + return f"{json.dumps(spec)}\n{body}" + + +class TestRepairDirectiveVerbatim: + """Requirement 1: the declared signature rides along as a hard constraint.""" + + def test_property_directive_injects_verbatim_constraint(self): + exc = PublicSurfaceRegressionError( + prompt_name=PROMPT, + output_path=OUT, + removed_symbols=[], + changed_signatures=["list_secrets"], + pre_surface_size=5, + post_surface_size=5, + signature_details=[ + ( + "list_secrets", + "[function] (name: str) -> list[dict[str, object]]", + "[function] (name: str) -> list[dict[str, Any]]", + "pdd-interface", + ) + ], + ) + directive = exc.repair_directive + # Existing behavior: the declared signature is named as the stable target. + assert ( + "Restore `list_secrets` to its declared signature " + "`[function] (name: str) -> list[dict[str, object]]`" in directive + ) + # NEW: a verbatim hard constraint, not just a description of the violation. + assert "VERBATIM" in directive + assert "Emit exactly `[function] (name: str) -> list[dict[str, object]]`" in directive + # NEW: explicit anti-substitution rule so the retry stops emitting `Any`. + assert "do not substitute" in directive.lower() + assert "never emit `Any` where the declaration says `object`" in directive + # A declared-param fix must not be advised via a BREAKING-CHANGE marker. + assert "change signature" not in directive + + def test_subprocess_directive_injects_verbatim_constraint(self): + from pdd.agentic_sync_runner import _parse_public_surface_failure + + detail = "signature_detail: " + json.dumps( + { + "symbol": "list_secrets", + "expected": "[function] (name: str) -> list[dict[str, object]]", + "actual": "[function] (name: str) -> list[dict[str, Any]]", + "source": "pdd-interface", + } + ) + stderr = ( + "Public surface regression for secrets_client_Python.prompt:\n" + "removed: \n" + "signature_changed: list_secrets\n" + "output: src/clients/secrets_client.py\n" + "pre_surface_size: 5\n" + "post_surface_size: 5\n" + detail + "\n" + ) + parsed = _parse_public_surface_failure("", stderr) + assert parsed is not None + directive, _signature = parsed + # Existing per-symbol line preserved (the cloud parser and tests key on it). + assert ( + "Restore `list_secrets` to its declared signature " + "`[function] (name: str) -> list[dict[str, object]]`" in directive + ) + # NEW verbatim hard-constraint framing on the subprocess path too. + assert "VERBATIM" in directive + assert "Emit exactly `[function] (name: str) -> list[dict[str, object]]`" in directive + assert "do not substitute" in directive.lower() + + +class TestDeterministicReconcile: + """Requirement 2: annotation-only drift is rewritten to the declared text.""" + + def test_object_vs_any_return_annotation_converges(self): + existing = ( + "def list_secrets(name: str) -> list[dict[str, object]]:\n" + " return []\n" + ) + generated = ( + "from typing import Any\n" + "def list_secrets(name: str) -> list[dict[str, Any]]:\n" + " return []\n" + ) + prompt = _iface_prompt( + [("list_secrets", "(name: str) -> list[dict[str, object]]")] + ) + # Baseline: the gate flags the annotation drift. + with pytest.raises(PublicSurfaceRegressionError): + _verify_public_surface_regression( + existing, generated, PROMPT, OUT, "python", prompt + ) + # Reconcile rewrites the emitted annotation back to the declared spelling. + reconciled = _reconcile_declared_annotation_drift( + existing, generated, PROMPT, OUT, "python", prompt + ) + assert reconciled is not None + assert "list[dict[str, object]]" in reconciled + assert "list[dict[str, Any]]" not in reconciled + # Deterministic convergence: the gate now passes on the reconciled code. + _verify_public_surface_regression( + existing, reconciled, PROMPT, OUT, "python", prompt + ) + + def test_broadened_param_union_converges(self): + existing = ( + "def load(req: dict[str, object] | None = None) -> None:\n" + " return None\n" + ) + generated = ( + "from typing import Any\n" + "def load(req: dict[str, Any] | list | None = None) -> None:\n" + " return None\n" + ) + prompt = _iface_prompt( + [("load", "(req: dict[str, object] | None = None) -> None")] + ) + with pytest.raises(PublicSurfaceRegressionError): + _verify_public_surface_regression( + existing, generated, PROMPT, OUT, "python", prompt + ) + reconciled = _reconcile_declared_annotation_drift( + existing, generated, PROMPT, OUT, "python", prompt + ) + assert reconciled is not None + def_line = next( + line for line in reconciled.splitlines() if line.startswith("def load") + ) + assert "dict[str, object] | None" in def_line + assert "Any" not in def_line + assert "list" not in def_line + _verify_public_surface_regression( + existing, reconciled, PROMPT, OUT, "python", prompt + ) + + def test_structural_added_param_is_not_auto_fixed(self): + # An added required param is a real regression, not annotation spelling — + # the reconciler must leave it for the gate/repair loop. + existing = "def f(a):\n return a\n" + generated = "def f(a, b):\n return a\n" + prompt = _iface_prompt([("f", "(a)")]) + reconciled = _reconcile_declared_annotation_drift( + existing, generated, PROMPT, OUT, "python", prompt + ) + assert reconciled is None + with pytest.raises(PublicSurfaceRegressionError): + _verify_public_surface_regression( + existing, generated, PROMPT, OUT, "python", prompt + ) + + def test_removed_symbol_is_not_touched(self): + existing = "def f(a):\n return a\ndef g(a):\n return a\n" + generated = "def f(a):\n return a\n" + prompt = _iface_prompt([("f", "(a)"), ("g", "(a)")]) + reconciled = _reconcile_declared_annotation_drift( + existing, generated, PROMPT, OUT, "python", prompt + ) + assert reconciled is None + + def test_compatible_annotation_alias_is_left_alone(self): + # `Dict[str, int]` and `dict[str, int]` are compatible — the gate never + # flags them, so the reconciler must not churn the emitted code. + existing = "def h(x: dict[str, int]) -> None:\n return None\n" + generated = ( + "from typing import Dict\n" + "def h(x: Dict[str, int]) -> None:\n return None\n" + ) + prompt = _iface_prompt([("h", "(x: dict[str, int]) -> None")]) + reconciled = _reconcile_declared_annotation_drift( + existing, generated, PROMPT, OUT, "python", prompt + ) + assert reconciled is None + + def test_method_annotation_drift_converges(self): + existing = ( + "class C:\n" + " def m(self, name: str) -> dict[str, object]:\n" + " return {}\n" + ) + generated = ( + "from typing import Any\n" + "class C:\n" + " def m(self, name: str) -> dict[str, Any]:\n" + " return {}\n" + ) + prompt = _iface_prompt([("C.m", "(self, name: str) -> dict[str, object]")]) + reconciled = _reconcile_declared_annotation_drift( + existing, generated, PROMPT, OUT, "python", prompt + ) + assert reconciled is not None + assert "dict[str, object]" in reconciled + assert "dict[str, Any]" not in reconciled + _verify_public_surface_regression( + existing, reconciled, PROMPT, OUT, "python", prompt + ) + + def test_no_declaration_is_a_noop(self): + existing = "def f(a: int) -> object:\n return a\n" + generated = "def f(a: int) -> object:\n return a\n" + reconciled = _reconcile_declared_annotation_drift( + existing, generated, PROMPT, OUT, "python", "Just prose, no interface." + ) + assert reconciled is None diff --git a/tests/test_issue_2004_authoritative_drift_pair.py b/tests/test_issue_2004_authoritative_drift_pair.py new file mode 100644 index 000000000..596bad86b --- /dev/null +++ b/tests/test_issue_2004_authoritative_drift_pair.py @@ -0,0 +1,208 @@ +"""Regression tests for authoritative Step 8.5 update pairs (issue #2004).""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from pdd.agentic_change_orchestrator import _preflight_drift_heal +from pdd.ci_drift_heal import DriftInfo + + +def _drift(*, prompt_path: str | None, code_path: str | None) -> DriftInfo: + return DriftInfo( + basename="hackathon_event_detail_page", + language="typescriptreact", + operation="update", + reason="code hash changed", + prompt_path=prompt_path, + code_path=code_path, + ) + + +def test_preflight_passes_the_exact_authoritative_pair_to_true_update(tmp_path: Path) -> None: + drift = _drift( + prompt_path="prompts/hackathon_event_detail_page_TypeScriptReact.prompt", + code_path="frontend/src/app/hackathon/[eventId]/page.tsx", + ) + completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + + with patch("pdd.ci_drift_heal.detect_drift", return_value=([drift], [])), patch( + "pdd.agentic_change_orchestrator.subprocess.run", return_value=completed + ) as run: + healed, failed, healed_prompts = _preflight_drift_heal(tmp_path, quiet=True) + + assert healed == ["hackathon_event_detail_page"] + assert failed == [] + assert healed_prompts == [ + "prompts/hackathon_event_detail_page_TypeScriptReact.prompt" + ] + assert run.call_args.args[0] == [ + sys.executable, + "-m", + "pdd", + "update", + "--sync-metadata", + "--git", + "prompts/hackathon_event_detail_page_TypeScriptReact.prompt", + "frontend/src/app/hackathon/[eventId]/page.tsx", + ] + assert run.call_args.kwargs["cwd"] == str(tmp_path) + + +def test_same_leaf_unrelated_prompt_remains_byte_identical(tmp_path: Path) -> None: + """Model both CLI modes so the old code-only call corrupts this fixture.""" + authoritative = tmp_path / "prompts/hackathon_event_detail_page_TypeScriptReact.prompt" + unrelated = tmp_path / "prompts/frontend/page_TypeScriptReact.prompt" + code = tmp_path / "frontend/src/app/hackathon/[eventId]/page.tsx" + for path in (authoritative, unrelated, code): + path.parent.mkdir(parents=True, exist_ok=True) + authoritative.write_text("stale event detail prompt\n", encoding="utf-8") + unrelated.write_text("PDD public homepage prompt\n", encoding="utf-8") + code.write_text("export default function EventPage() {}\n", encoding="utf-8") + unrelated_before = unrelated.read_bytes() + + drift = _drift( + prompt_path=str(authoritative.relative_to(tmp_path)), + code_path=str(code.relative_to(tmp_path)), + ) + + def model_update_cli(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess: + # True update owns argv[-2]. The former code-only regeneration call would + # weakly resolve page.tsx to the unrelated frontend/page prompt. + target = Path(argv[-2]) if "--git" in argv else unrelated.relative_to(tmp_path) + (Path(str(kwargs["cwd"])) / target).write_text( + "event detail prompt derived from code\n", encoding="utf-8" + ) + return subprocess.CompletedProcess(args=argv, returncode=0, stdout="", stderr="") + + with patch("pdd.ci_drift_heal.detect_drift", return_value=([drift], [])), patch( + "pdd.agentic_change_orchestrator.subprocess.run", side_effect=model_update_cli + ): + healed, failed, _ = _preflight_drift_heal(tmp_path, quiet=True) + + assert healed == ["hackathon_event_detail_page"] + assert failed == [] + assert authoritative.read_text(encoding="utf-8").startswith("event detail prompt") + assert unrelated.read_bytes() == unrelated_before + + +@pytest.mark.parametrize( + ("prompt_path", "code_path"), + [ + (None, "frontend/src/unique.ts"), + ("prompts/unique_TypeScript.prompt", None), + ], +) +def test_missing_authoritative_pair_fails_without_code_only_fallback( + tmp_path: Path, prompt_path: str | None, code_path: str | None +) -> None: + drift = _drift(prompt_path=prompt_path, code_path=code_path) + + with patch("pdd.ci_drift_heal.detect_drift", return_value=([drift], [])), patch( + "pdd.agentic_change_orchestrator.subprocess.run" + ) as run: + healed, failed, healed_prompts = _preflight_drift_heal(tmp_path, quiet=True) + + assert healed == [] + assert failed == ["hackathon_event_detail_page"] + assert healed_prompts == [] + run.assert_not_called() + + +def test_unique_module_uses_the_same_compatible_true_update_contract(tmp_path: Path) -> None: + drift = DriftInfo( + basename="unique", + language="typescript", + operation="update", + reason="code hash changed", + prompt_path="prompts/unique_TypeScript.prompt", + code_path="src/unique.ts", + ) + + with patch("pdd.ci_drift_heal.detect_drift", return_value=([drift], [])), patch( + "pdd.agentic_change_orchestrator.subprocess.run", + return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""), + ) as run: + healed, failed, _ = _preflight_drift_heal(tmp_path, quiet=True) + + assert healed == ["unique"] + assert failed == [] + assert run.call_args.args[0][-3:] == [ + "--git", + "prompts/unique_TypeScript.prompt", + "src/unique.ts", + ] + + +@pytest.mark.parametrize( + ("prompt_path", "code_path"), + [ + # code path escapes the worktree via traversal + ("prompts/unique_TypeScript.prompt", "../../../etc/passwd"), + # prompt path escapes the worktree via traversal + ("../../outside/unique_TypeScript.prompt", "src/unique.ts"), + # absolute path outside the worktree + ("prompts/unique_TypeScript.prompt", "/etc/hosts"), + ], +) +def test_preflight_fails_closed_on_path_escaping_worktree( + tmp_path: Path, prompt_path: str, code_path: str +) -> None: + """A repo `.pddrc`/architecture.json must not drive `pdd update` outside the + isolated worktree (issue #2004 follow-up / Codex review PR #1998, CWE-022). + + Step 8.5 promises heals are contained to the worktree; an escaping prompt or + code path must fail closed (marked failed, no subprocess launched) rather + than let `pdd update --git` write beyond the worktree. + """ + drift = _drift(prompt_path=prompt_path, code_path=code_path) + + with patch("pdd.ci_drift_heal.detect_drift", return_value=([drift], [])), patch( + "pdd.agentic_change_orchestrator.subprocess.run" + ) as run: + healed, failed, healed_prompts = _preflight_drift_heal(tmp_path, quiet=True) + + assert healed == [] + assert failed == ["hackathon_event_detail_page"] + assert healed_prompts == [] + run.assert_not_called() + + +def test_preflight_passes_canonical_paths_not_symlink_spelling(tmp_path: Path) -> None: + """The heal subprocess must receive the resolved target, not a symlink spelling. + + Codex review (PR #1998): passing the original spelling leaves a check/use gap + where a symlink component could be re-pointed out of the worktree between + validation and the child opening it. Passing the canonicalized path (symlink + collapsed) closes that gap; for an in-worktree symlink the child gets the real + target's path. + """ + real_prompt = tmp_path / "prompts" / "real_TypeScript.prompt" + real_code = tmp_path / "src" / "unique.ts" + for p in (real_prompt, real_code): + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("x\n", encoding="utf-8") + link_prompt = tmp_path / "prompts" / "link_TypeScript.prompt" + link_prompt.symlink_to(real_prompt) + + drift = _drift( + prompt_path="prompts/link_TypeScript.prompt", + code_path="src/unique.ts", + ) + + with patch("pdd.ci_drift_heal.detect_drift", return_value=([drift], [])), patch( + "pdd.agentic_change_orchestrator.subprocess.run", + return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""), + ) as run: + healed, failed, _ = _preflight_drift_heal(tmp_path, quiet=True) + + assert healed == ["hackathon_event_detail_page"] + assert failed == [] + argv = run.call_args.args[0] + assert argv[-2] == "prompts/real_TypeScript.prompt" + assert argv[-1] == "src/unique.ts" diff --git a/tests/test_metadata_sync.py b/tests/test_metadata_sync.py index b0521f40d..0f0238e13 100644 --- a/tests/test_metadata_sync.py +++ b/tests/test_metadata_sync.py @@ -579,7 +579,7 @@ def test_run_report_skipped_when_identity_cannot_be_inferred(tmp_path: Path) -> result = run_metadata_sync(ws["prompt_path"], dry_run=False) assert result.stages["run_report"].status == "skipped" mock_clear.assert_not_called() - assert result.stages["fingerprint"].status == "skipped" + assert result.stages["fingerprint"].status == "failed" mock_save.assert_not_called() diff --git a/tests/test_mock_contract_fix_wiring.py b/tests/test_mock_contract_fix_wiring.py new file mode 100644 index 000000000..ffc1663e7 --- /dev/null +++ b/tests/test_mock_contract_fix_wiring.py @@ -0,0 +1,225 @@ +"""Workflow wiring tests for the issue #1939 mock-contract gate.""" +from __future__ import annotations + +import inspect +from pathlib import Path +from unittest.mock import patch + +import click +import pytest + +from pdd.agentic_e2e_fix_orchestrator import ( + _validate_changed_mock_contracts, + run_agentic_e2e_fix_orchestrator, +) +from pdd.fix_main import fix_main +from pdd.mock_contract_validation import MockContractDivergenceError + + +BROKEN_CODE = """ +def load(ids): + return query_collection("user_waitlist", filters=[("userId", "in", ids)]) +""" + +BROKEN_TEST = """ +def test_load(mock_query): + mock_query.return_value = [{"userId": "u-1"}] + assert load(["u-1"]) +""" + + +def _schema(root: Path, field: str = "email") -> None: + path = root / "context" / "database-schema.md" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "```\nuser_waitlist/\n {uid}/\n" + f" {field}: string\n```\n", + encoding="utf-8", + ) + + +def _ctx() -> click.Context: + ctx = click.Context(click.Command("fix")) + ctx.obj = { + "confirm_callback": None, + "context": None, + "force": False, + "local": True, + "quiet": True, + "strength": 0.5, + "temperature": 0.0, + "time": 0.25, + "verbose": False, + } + return ctx + + +def _run_manual_fix(tmp_path: Path, schema_field: str) -> tuple: + _schema(tmp_path, schema_field) + error = tmp_path / "errors.log" + error.write_text("failing assertion", encoding="utf-8") + output_code = tmp_path / "src" / "reader.py" + output_test = tmp_path / "tests" / "test_reader.py" + constructed = ( + {}, + { + "prompt_file": "% Goal\nRead waitlist rows.\n", + "code_file": "def load(ids): return []\n", + "unit_test_file": "def test_old(): pass\n", + "error_file": "failing assertion", + }, + { + "output_code": str(output_code), + "output_test": str(output_test), + "output_results": str(tmp_path / "results.log"), + }, + None, + ) + generated = (True, True, BROKEN_TEST, BROKEN_CODE, "analysis", 0.1, "model") + effective = { + "strength": 0.5, + "temperature": 0.0, + "time": 0.25, + "compress_test_context": False, + "context_compression": "off", + "compression_fallback": "full", + } + with patch("pdd.fix_main.construct_paths", return_value=constructed), patch( + "pdd.fix_main.resolve_effective_config", return_value=effective + ), patch( + "pdd.fix_main.fix_errors_from_unit_tests", return_value=generated + ), patch( + "pdd.fix_main.run_pytest_on_file", return_value=(0, 0, 0, "passed") + ): + result = fix_main( + ctx=_ctx(), + prompt_file="prompts/reader_python.prompt", + code_file="src/reader.py", + unit_test_file="tests/test_reader.py", + error_file=str(error), + output_test=None, + output_code=None, + output_results=None, + loop=False, + verification_program=None, + max_attempts=1, + budget=1.0, + auto_submit=False, + ) + return result, output_code, output_test + + +def test_manual_fix_fails_before_writing_schema_divergent_mock( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + with pytest.raises(MockContractDivergenceError, match="user_waitlist.userId"): + _run_manual_fix(tmp_path, "email") + assert not (tmp_path / "src" / "reader.py").exists() + assert not (tmp_path / "tests" / "test_reader.py").exists() + + +def test_manual_fix_with_real_mock_field_remains_successful( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + result, output_code, output_test = _run_manual_fix(tmp_path, "userId") + assert result[0] is True + assert output_code.read_text(encoding="utf-8") == BROKEN_CODE + assert output_test.read_text(encoding="utf-8") == BROKEN_TEST + + +def test_loop_fix_restores_inputs_when_contract_gate_rejects( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The iterative fixer writes candidates in place; rejection must roll back.""" + monkeypatch.chdir(tmp_path) + _schema(tmp_path, "email") + prompt = tmp_path / "prompts" / "reader_python.prompt" + code = tmp_path / "src" / "reader.py" + test = tmp_path / "tests" / "test_reader.py" + verifier = tmp_path / "verify.py" + prompt.parent.mkdir(parents=True) + code.parent.mkdir(parents=True) + test.parent.mkdir(parents=True) + prompt.write_text("% Goal\nRead waitlist rows.\n", encoding="utf-8") + original_code = "def load(ids): return []\n" + original_test = "def test_old(): pass\n" + code.write_text(original_code, encoding="utf-8") + test.write_text(original_test, encoding="utf-8") + verifier.write_text("print('ok')\n", encoding="utf-8") + effective = { + "strength": 0.5, + "temperature": 0.0, + "time": 0.25, + "compress_test_context": False, + "context_compression": "off", + "compression_fallback": "full", + } + + def _write_bad_candidate(**_kwargs): + code.write_text(BROKEN_CODE, encoding="utf-8") + test.write_text(BROKEN_TEST, encoding="utf-8") + return True, BROKEN_TEST, BROKEN_CODE, 1, 0.1, "model" + + with patch( + "pdd.fix_main.resolve_effective_config", return_value=effective + ), patch("pdd.fix_main.fix_error_loop", side_effect=_write_bad_candidate): + with pytest.raises(MockContractDivergenceError, match="user_waitlist.userId"): + fix_main( + ctx=_ctx(), + prompt_file=str(prompt), + code_file=str(code), + unit_test_file=str(test), + error_file="", + output_test=None, + output_code=None, + output_results=None, + loop=True, + verification_program=str(verifier), + max_attempts=1, + budget=1.0, + auto_submit=False, + ) + + assert code.read_text(encoding="utf-8") == original_code + assert test.read_text(encoding="utf-8") == original_test + + +def test_agentic_gate_reads_changed_files_and_real_schema(tmp_path: Path) -> None: + _schema(tmp_path, "email") + code = tmp_path / "backend" / "reader.py" + test = tmp_path / "backend" / "tests" / "test_reader.py" + code.parent.mkdir(parents=True) + test.parent.mkdir(parents=True) + code.write_text(BROKEN_CODE, encoding="utf-8") + test.write_text(BROKEN_TEST, encoding="utf-8") + + report = _validate_changed_mock_contracts( + cwd=tmp_path, + changed_files=["backend/reader.py", "backend/tests/test_reader.py"], + baseline_ref=None, + ) + assert report.diverged + + +def test_agentic_success_gate_runs_before_commit_and_push() -> None: + source = inspect.getsource(run_agentic_e2e_fix_orchestrator) + success_branch = source.index("if success:") + gate = source.index("_validate_changed_mock_contracts(", success_branch) + commit = source.index("_commit_and_push(", gate) + assert success_branch < gate < commit + + +def test_post_validation_repair_paths_recheck_before_push() -> None: + """CI and pre-checkup mutations cannot bypass the terminal contract gate.""" + source = inspect.getsource(run_agentic_e2e_fix_orchestrator) + ci_hook = source.index("pre_commit_check=") + pre_checkup = source.index("_run_pre_checkup_gate_with_remediation(", ci_hook) + final_gate = source.index("final_contract_error =", pre_checkup) + gate_sync_push = source.index("_commit_and_push(", final_gate) + + assert ci_hook < pre_checkup < final_gate < gate_sync_push diff --git a/tests/test_mock_contract_validation.py b/tests/test_mock_contract_validation.py new file mode 100644 index 000000000..3ba10d4d7 --- /dev/null +++ b/tests/test_mock_contract_validation.py @@ -0,0 +1,523 @@ +"""Regression and contract tests for issue #1939.""" +from __future__ import annotations + +import json +from pathlib import Path +import subprocess + +import pytest + +from pdd.mock_contract_validation import ( + MockContractDivergenceError, + enforce_mock_contracts, + extract_mock_fields, + extract_query_fields, + format_mock_contract_report, + validate_changed_files, + validate_mock_contracts, +) + + +def _write_waitlist_schema(root: Path, *fields: str) -> Path: + path = root / "context" / "database-schema.md" + path.parent.mkdir(parents=True, exist_ok=True) + body = "\n".join(f" {field}: string" for field in fields) + path.write_text( + "# Database Schema\n\n" + "### user_waitlist\n" + "```\n" + "user_waitlist/\n" + " {uid}/\n" + f"{body}\n" + "```\n", + encoding="utf-8", + ) + return path + + +_BROKEN_CODE = """ +def load_waitlist(user_ids): + return query_collection( + "user_waitlist", + filters=[("userId", "in", user_ids)], + ) +""" + +_BROKEN_TEST = """ +def test_batch_lookup(mock_query): + mock_query.return_value = [ + {"userId": "uid-1", "email": "person@example.com"}, + ] + assert load_waitlist(["uid-1"])[0]["userId"] == "uid-1" +""" + + +def test_issue_1939_mocked_nonexistent_field_is_a_hard_finding(tmp_path: Path) -> None: + """The exact #4235 shape may not be certified by a green mock.""" + schema = _write_waitlist_schema(tmp_path, "email", "status") + + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"backend/admin_migrations.py": _BROKEN_CODE}, + test_sources={"backend/tests/test_admin_migrations.py": _BROKEN_TEST}, + baseline_production_sources={"backend/admin_migrations.py": "def load_waitlist(ids): return []\n"}, + baseline_test_sources={"backend/tests/test_admin_migrations.py": "def test_old(): pass\n"}, + ) + + assert report.status == "diverged" + assert report.diverged + assert len(report.findings) == 1 + finding = report.findings[0] + assert finding.resource == "user_waitlist" + assert finding.field_name == "userId" + assert str(schema) in finding.contract_paths[0] + assert finding.mock_paths == ("backend/tests/test_admin_migrations.py:4",) + rendered = format_mock_contract_report(report) + assert "MOCK_CONTRACT_DIVERGENCE" in rendered + assert "user_waitlist.userId" in rendered + with pytest.raises(MockContractDivergenceError, match="user_waitlist.userId"): + enforce_mock_contracts(report) + + +def test_mock_using_real_schema_field_is_unaffected(tmp_path: Path) -> None: + _write_waitlist_schema(tmp_path, "email", "status") + code = _BROKEN_CODE.replace("userId", "email") + test = _BROKEN_TEST.replace("userId", "email") + + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"backend/reader.py": code}, + test_sources={"backend/tests/test_reader.py": test}, + baseline_production_sources={"backend/reader.py": ""}, + baseline_test_sources={"backend/tests/test_reader.py": ""}, + ) + + assert report.status == "clean" + assert not report.findings + enforce_mock_contracts(report) + + +def test_preexisting_query_and_mock_do_not_become_new_failure(tmp_path: Path) -> None: + _write_waitlist_schema(tmp_path, "email", "status") + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": _BROKEN_CODE}, + test_sources={"tests/test_reader.py": _BROKEN_TEST}, + baseline_production_sources={"reader.py": _BROKEN_CODE}, + baseline_test_sources={"tests/test_reader.py": _BROKEN_TEST}, + ) + assert report.status == "not_applicable" + + +def test_new_mock_exposes_preexisting_invalid_query(tmp_path: Path) -> None: + _write_waitlist_schema(tmp_path, "email", "status") + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": _BROKEN_CODE}, + test_sources={"tests/test_reader.py": _BROKEN_TEST + "\nRESOURCE = 'user_waitlist'\n"}, + baseline_production_sources={"reader.py": _BROKEN_CODE}, + baseline_test_sources={"tests/test_reader.py": "def test_old(): pass\n"}, + ) + assert report.status == "diverged" + assert report.findings[0].field_name == "userId" + + +def test_additional_occurrence_of_existing_mock_field_is_still_new(tmp_path: Path) -> None: + """A same-named mock elsewhere in the baseline must not hide a new payload.""" + _write_waitlist_schema(tmp_path, "email", "status") + baseline_test = "mock_other.return_value = [{'userId': 'unrelated'}]\n" + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": _BROKEN_CODE}, + test_sources={ + "tests/test_reader.py": baseline_test + + "\nRESOURCE = 'user_waitlist'\n" + + _BROKEN_TEST + }, + baseline_production_sources={"reader.py": _BROKEN_CODE}, + baseline_test_sources={"tests/test_reader.py": baseline_test}, + ) + + assert report.status == "diverged" + + +def test_missing_contract_is_inconclusive_not_a_name_heuristic(tmp_path: Path) -> None: + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": _BROKEN_CODE}, + test_sources={"tests/test_reader.py": _BROKEN_TEST}, + ) + assert report.status == "inconclusive" + assert not report.findings + assert "no authoritative schema" in report.warnings[0] + + +def test_independent_sibling_usage_is_corroborating_contract_evidence(tmp_path: Path) -> None: + _write_waitlist_schema(tmp_path, "email", "status") + sibling = tmp_path / "backend" / "existing_reader.py" + sibling.parent.mkdir(parents=True) + sibling.write_text( + "def read(values):\n" + " return query_collection('user_waitlist', " + "filters=[('legacyId', 'in', values)])\n", + encoding="utf-8", + ) + code = _BROKEN_CODE.replace("userId", "legacyId") + test = _BROKEN_TEST.replace("userId", "legacyId") + + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"backend/new_reader.py": code}, + test_sources={"backend/tests/test_new_reader.py": test}, + ) + assert report.status == "clean" + assert any(item.kind == "sibling" for item in report.contracts) + + +def test_nested_schema_fields_do_not_count_as_top_level_fields(tmp_path: Path) -> None: + schema = tmp_path / "context" / "database-schema.md" + schema.parent.mkdir(parents=True) + schema.write_text( + "```\n" + "user_waitlist/\n" + " {uid}/\n" + " email: string\n" + " emailsSent: array<{\n" + " templateId: number\n" + " }>\n" + "```\n", + encoding="utf-8", + ) + code = _BROKEN_CODE.replace("userId", "templateId") + test = _BROKEN_TEST.replace("userId", "templateId") + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": code}, + test_sources={"tests/test_reader.py": test}, + ) + assert report.status == "diverged" + + +def test_qualified_nested_markdown_schema_field_is_real(tmp_path: Path) -> None: + schema = tmp_path / "context" / "database-schema.md" + schema.parent.mkdir(parents=True) + schema.write_text( + "```\n" + "user_waitlist/\n" + " {uid}/\n" + " profile: map\n" + " userId: string\n" + "```\n", + encoding="utf-8", + ) + code = _BROKEN_CODE.replace("userId", "profile.userId") + test = _BROKEN_TEST.replace("userId", "profile.userId") + + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": code}, + test_sources={"tests/test_reader.py": test}, + ) + + assert report.status == "clean" + assert "profile.userId" in report.contracts[0].fields + + +def test_json_schema_contract_is_supported(tmp_path: Path) -> None: + schema = tmp_path / "schemas" / "collections.schema.json" + schema.parent.mkdir(parents=True) + schema.write_text( + json.dumps( + { + "$defs": { + "user_waitlist": { + "title": "user_waitlist", + "type": "object", + "properties": {"userId": {"type": "string"}}, + } + } + } + ), + encoding="utf-8", + ) + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": _BROKEN_CODE}, + test_sources={"tests/test_reader.py": _BROKEN_TEST}, + ) + assert report.status == "clean" + + +def test_qualified_nested_json_schema_field_is_real(tmp_path: Path) -> None: + schema = tmp_path / "schemas" / "collections.schema.json" + schema.parent.mkdir(parents=True) + schema.write_text( + json.dumps( + { + "user_waitlist": { + "properties": { + "profile": { + "type": "object", + "properties": {"userId": {"type": "string"}}, + } + } + } + } + ), + encoding="utf-8", + ) + code = _BROKEN_CODE.replace("userId", "profile.userId") + test = _BROKEN_TEST.replace("userId", "profile.userId") + + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": code}, + test_sources={"tests/test_reader.py": test}, + ) + + assert report.status == "clean" + assert "profile.userId" in report.contracts[0].fields + + +def test_extractors_only_use_query_and_mock_payload_structure() -> None: + queries = extract_query_fields(_BROKEN_CODE, "reader.py") + mocks = extract_mock_fields(_BROKEN_TEST, "test_reader.py") + assert [(item.resource, item.field_name) for item in queries] == [ + ("user_waitlist", "userId") + ] + assert {item.field_name for item in mocks} == {"userId", "email"} + + +def test_fake_side_effect_function_payload_is_detected() -> None: + source = """ +def fake_query(collection, filters): + return [{"userId": "uid-1"}] + +def test_reader(mock_query): + mock_query.side_effect = fake_query +""" + fields = extract_mock_fields(source, "test_reader.py") + assert [(item.field_name, item.target) for item in fields] == [ + ("userId", "fake_query") + ] + + +def test_query_extractor_supports_firestore_chains_and_keyword_filters() -> None: + source = """ +client.collection(collection_name="users").where( + field_path="email", op_string="==", value="person@example.com" +) +client.collection("users").where( + filter=FieldFilter(field_path="status", op_string="==", value="active") +) +client.collection("users").filter(FieldFilter("age", ">=", 18)) +count_collection(collection_name="users", filters=[("enabled", "==", True)]) +query_collection(dynamic_collection, filters=[("ignored", "==", True)]) +""" + + fields = extract_query_fields(source, "reader.py") + + assert {(item.resource, item.field_name) for item in fields} == { + ("users", "age"), + ("users", "email"), + ("users", "enabled"), + ("users", "status"), + } + assert all(item.source_path == "reader.py" for item in fields) + + +def test_extractors_ignore_invalid_or_irrelevant_python() -> None: + assert extract_query_fields("def broken(", "broken.py") == () + assert extract_mock_fields("def broken(", "test_broken.py") == () + assert extract_query_fields("query_collection(name, filters=None)") == () + assert extract_mock_fields("payload = {'ordinary': 'data'}") == () + + +def test_mock_extractor_supports_annotations_factories_and_patch_object() -> None: + source = """ +def fake_lookup(): + return [{"sideEffectId": "one"}] + +mock_lookup: object = MagicMock(return_value=[{"annotatedId": "two"}]) +direct = AsyncMock(side_effect=lambda: [{"asyncId": "three"}]) +context = patch.object(service, "lookup", side_effect=fake_lookup) +ordinary = {"ignored": "four"} +""" + + fields = extract_mock_fields(source, "tests/test_reader.py") + + assert {item.field_name for item in fields} == { + "annotatedId", + "asyncId", + "sideEffectId", + } + assert "ignored" not in {item.field_name for item in fields} + + +def test_root_json_schema_fields_and_list_shape_are_supported(tmp_path: Path) -> None: + (tmp_path / "api-schema.json").write_text( + json.dumps( + [ + { + "name": "user_waitlist", + "fields": {"userId": {"type": "string"}}, + } + ] + ), + encoding="utf-8", + ) + context = tmp_path / "context" + context.mkdir() + (context / "broken-schema.json").write_text("{invalid", encoding="utf-8") + + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": _BROKEN_CODE}, + test_sources={"tests/test_reader.py": _BROKEN_TEST}, + ) + + assert report.status == "clean" + assert report.contracts[0].fields == frozenset({"userId"}) + + +def test_independent_writer_is_corroborating_contract_evidence(tmp_path: Path) -> None: + _write_waitlist_schema(tmp_path, "email", "status") + sibling = tmp_path / "backend" / "existing_writer.py" + sibling.parent.mkdir(parents=True) + sibling.write_text( + "set_document(collection_name='user_waitlist', document_id='uid-1', " + "data={'legacyId': 'uid-1'})\n", + encoding="utf-8", + ) + code = _BROKEN_CODE.replace("userId", "legacyId") + test = _BROKEN_TEST.replace("userId", "legacyId") + + report = validate_mock_contracts( + project_root=tmp_path, + production_sources={"backend/new_reader.py": code}, + test_sources={"backend/tests/test_new_reader.py": test}, + ) + + assert report.status == "clean" + sibling_contract = next(item for item in report.contracts if item.kind == "sibling") + assert sibling_contract.fields == frozenset({"legacyId"}) + + +def test_validate_changed_files_uses_git_baseline(tmp_path: Path) -> None: + _write_waitlist_schema(tmp_path, "email", "status") + code = tmp_path / "backend" / "reader.py" + test = tmp_path / "backend" / "tests" / "test_reader.py" + code.parent.mkdir(parents=True) + test.parent.mkdir(parents=True) + code.write_text("def load(ids): return []\n", encoding="utf-8") + test.write_text("def test_old(): pass\n", encoding="utf-8") + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "add", "backend"], cwd=tmp_path, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=PDD Test", + "-c", + "user.email=pdd-test@example.com", + "commit", + "-qm", + "baseline", + ], + cwd=tmp_path, + check=True, + ) + code.write_text(_BROKEN_CODE, encoding="utf-8") + test.write_text(_BROKEN_TEST, encoding="utf-8") + + report = validate_changed_files( + project_root=tmp_path, + changed_files=[str(code), "backend/tests/test_reader.py", "README.md", "missing.py"], + baseline_ref="HEAD", + ) + + assert report.status == "diverged" + assert report.findings[0].field_name == "userId" + + +def test_changed_file_loader_checks_new_mock_for_unchanged_query(tmp_path: Path) -> None: + """Agentic test-only edits cannot bypass an unchanged invalid reader.""" + _write_waitlist_schema(tmp_path, "email", "status") + code = tmp_path / "backend" / "reader.py" + test = tmp_path / "backend" / "tests" / "test_reader.py" + code.parent.mkdir(parents=True) + test.parent.mkdir(parents=True) + code.write_text(_BROKEN_CODE, encoding="utf-8") + test.write_text( + "RESOURCE = 'user_waitlist'\ndef test_old(): pass\n", encoding="utf-8" + ) + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "add", "backend"], cwd=tmp_path, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=PDD Test", + "-c", + "user.email=pdd-test@example.com", + "commit", + "-qm", + "baseline", + ], + cwd=tmp_path, + check=True, + ) + test.write_text( + "RESOURCE = 'user_waitlist'\n" + _BROKEN_TEST, encoding="utf-8" + ) + + report = validate_changed_files( + project_root=tmp_path, + changed_files=["backend/tests/test_reader.py"], + baseline_ref="HEAD", + ) + + assert report.status == "diverged" + assert report.findings[0].code_path == "backend/reader.py" + + +def test_changed_file_loader_ignores_paths_outside_project(tmp_path: Path) -> None: + outside = tmp_path.parent / f"{tmp_path.name}-outside.py" + outside.write_text(_BROKEN_CODE, encoding="utf-8") + try: + report = validate_changed_files( + project_root=tmp_path, + changed_files=[str(outside), "not-created.py"], + ) + finally: + outside.unlink() + + assert report.status == "not_applicable" + assert format_mock_contract_report(report).startswith("Mock-contract validation: not applicable") + + +def test_report_formatter_covers_clean_and_inconclusive_results(tmp_path: Path) -> None: + not_applicable = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": ""}, + test_sources={"tests/test_reader.py": ""}, + ) + assert "not applicable" in format_mock_contract_report(not_applicable) + + _write_waitlist_schema(tmp_path, "userId") + clean = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": _BROKEN_CODE}, + test_sources={"tests/test_reader.py": _BROKEN_TEST}, + ) + assert "clean" in format_mock_contract_report(clean) + + (tmp_path / "context" / "database-schema.md").unlink() + + inconclusive = validate_mock_contracts( + project_root=tmp_path, + production_sources={"reader.py": _BROKEN_CODE}, + test_sources={"tests/test_reader.py": _BROKEN_TEST}, + ) + assert "inconclusive" in format_mock_contract_report(inconclusive) + assert "no authoritative schema" in format_mock_contract_report(inconclusive) diff --git a/tests/test_one_session_sync.py b/tests/test_one_session_sync.py index ced7af16f..23c013b3c 100644 --- a/tests/test_one_session_sync.py +++ b/tests/test_one_session_sync.py @@ -492,6 +492,55 @@ def test_success_returns_correct_dict(self, mock_build, mock_task, tmp_path): for op in ("example", "crash_fix", "verify", "test"): assert op in summary + @patch("pdd.one_session_sync.run_agentic_task") + @patch("pdd.one_session_sync.build_one_session_prompt", return_value="mega prompt") + def test_direct_workflow_shares_provider_failures_across_repair_attempts( + self, mock_build, mock_task, tmp_path, monkeypatch + ): + from pdd.agentic_common import ( + get_disabled_providers, + mark_provider_permanently_failed, + ) + + monkeypatch.setenv("PDD_TEST_CHURN_THRESHOLD", "0.40") + pdd_files = _make_pdd_files(tmp_path) + original_tests = ( + "def test_a(): pass\n" + "def test_b(): pass\n" + "def test_c(): pass\n" + ) + rewritten_tests = ( + "def test_new_a(): pass\n" + "def test_new_b(): pass\n" + "def test_new_c(): pass\n" + ) + pdd_files["test"].write_text(original_tests, encoding="utf-8") + observed = [] + + def fake_task(*args, **kwargs): + observed.append(get_disabled_providers()) + if mock_task.call_count == 1: + mark_provider_permanently_failed("openai", "auth") + pdd_files["test"].write_text(rewritten_tests, encoding="utf-8") + else: + pdd_files["test"].write_text( + original_tests + "def test_d(): pass\n", encoding="utf-8" + ) + return True, "done", 0.5, "claude-code" + + mock_task.side_effect = fake_task + + result = run_one_session_sync( + basename="my_module", + language="python", + pdd_files=pdd_files, + project_root=tmp_path, + ) + + assert result["success"] is True + assert observed == [{}, {"openai": "auth"}] + assert get_disabled_providers() == {} + @patch("pdd.one_session_sync.run_agentic_task") @patch("pdd.one_session_sync.build_one_session_prompt", return_value="mega prompt") def test_failure_returns_errors(self, mock_build, mock_task, tmp_path): diff --git a/tests/test_operation_log.py b/tests/test_operation_log.py index 2dc9fa794..973303f13 100644 --- a/tests/test_operation_log.py +++ b/tests/test_operation_log.py @@ -9,6 +9,7 @@ # Import the module under test from pdd import operation_log +from pdd.fingerprint_transaction import FingerprintFinalizeError # -------------------------------------------------------------------------------- # TEST PLAN @@ -235,7 +236,10 @@ def test_load_operation_log_compatibility(temp_pdd_env): def test_save_fingerprint(temp_pdd_env): """Test saving fingerprint state in Fingerprint dataclass format.""" basename, lang = "state", "go" - paths = {"prompt": Path("prompts/state_go.prompt")} + prompt_path = Path(temp_pdd_env).parents[1] / "prompts" / "state_go.prompt" + prompt_path.parent.mkdir() + prompt_path.write_text("% State\n", encoding="utf-8") + paths = {"prompt": prompt_path} operation_log.save_fingerprint(basename, lang, "op1", paths, 0.5, "gpt-4") @@ -299,7 +303,9 @@ def test_log_operation_decorator_success(temp_pdd_env): def my_command(prompt_file: str): return {"status": "ok"}, 0.15, "gpt-3.5" - prompt_path = "prompts/feat_logic_python.prompt" + prompt_path = Path(temp_pdd_env).parents[1] / "prompts" / "feat_logic_python.prompt" + prompt_path.parent.mkdir() + prompt_path.write_text("% Feature logic\n", encoding="utf-8") # Run result = my_command(prompt_file=prompt_path) @@ -398,7 +404,9 @@ def test_log_operation_decorator_failure_preserves_run_report(temp_pdd_env): def failing_example(prompt_file: str): raise RuntimeError("generation failed") - prompt_path = f"prompts/{basename}_{lang}.prompt" + prompt_path = Path(temp_pdd_env).parents[1] / "prompts" / f"{basename}_{lang}.prompt" + prompt_path.parent.mkdir() + prompt_path.write_text("% Example\n", encoding="utf-8") with pytest.raises(RuntimeError, match="generation failed"): failing_example(prompt_file=prompt_path) @@ -471,8 +479,10 @@ def test_log_operation_decorator_success_clears_run_report(temp_pdd_env): def ok_example(prompt_file: str): return "ok", 0.0, "mock" - prompt_path = f"prompts/{basename}_{lang}.prompt" - ok_example(prompt_file=prompt_path) + prompt_path = Path(temp_pdd_env).parents[1] / "prompts" / f"{basename}_{lang}.prompt" + prompt_path.parent.mkdir() + prompt_path.write_text("% Example\n", encoding="utf-8") + ok_example(prompt_file=str(prompt_path)) assert not rr_path.exists(), ( "clears_run_report must remove the stale run report on success" @@ -600,13 +610,16 @@ def test_fingerprint_path_extension_consistency(tmp_path): # Patch module to use temp directory with patch("pdd.operation_log.META_DIR", str(meta_dir)): + prompt_path = tmp_path / "prompts" / "test.prompt" + prompt_path.parent.mkdir() + prompt_path.write_text("% Test\n", encoding="utf-8") # Write a fingerprint using operation_log save_fingerprint( basename=basename, language=language, operation="test_operation", - paths={"prompt": Path("prompts/test.prompt")}, + paths={"prompt": prompt_path}, cost=0.123, model="test-model" ) @@ -643,11 +656,15 @@ def test_fingerprint_format_compatibility(tmp_path): with patch("pdd.operation_log.META_DIR", str(meta_dir)), \ patch("pdd.sync_determine_operation.get_meta_dir", return_value=meta_dir): + prompt_path = tmp_path / "prompts" / f"{basename}_{language}.prompt" + prompt_path.parent.mkdir() + prompt_path.write_text("% Format\n", encoding="utf-8") + save_fingerprint( basename=basename, language=language, operation="test_op", - paths={}, + paths={"prompt": prompt_path}, cost=0.1, model="test" ) @@ -848,7 +865,7 @@ def test_save_fingerprint_resolves_paths_when_none_issue_983(temp_pdd_env, tmp_p mock_paths = {"prompt": prompt_file, "code": code_file} with patch( - "pdd.sync_determine_operation.get_pdd_file_paths", return_value=mock_paths + "pdd.fingerprint_transaction.get_pdd_file_paths", return_value=mock_paths ), patch( "pdd.sync_determine_operation.get_meta_dir", return_value=Path(temp_pdd_env), @@ -890,7 +907,7 @@ def test_save_fingerprint_skips_resolution_when_paths_provided_issue_983(temp_pd explicit_paths = {"prompt": prompt_file} with patch( - "pdd.sync_determine_operation.get_pdd_file_paths" + "pdd.fingerprint_transaction.get_pdd_file_paths" ) as mock_get_paths: operation_log.save_fingerprint( "mymod", "python", operation="generate", paths=explicit_paths @@ -899,26 +916,23 @@ def test_save_fingerprint_skips_resolution_when_paths_provided_issue_983(temp_pd mock_get_paths.assert_not_called() -def test_save_fingerprint_warns_on_path_resolution_failure_issue_983(temp_pdd_env): +def test_save_fingerprint_fails_on_path_resolution_failure_issue_983(temp_pdd_env): """ - Issue #983: If get_pdd_file_paths raises a recoverable error during - path resolution, save_fingerprint should warn and produce null hashes - (graceful degradation) rather than crashing. + Issue #1926: path-resolution failure must not produce a null-hash + fingerprint or report command success. """ with patch( - "pdd.sync_determine_operation.get_pdd_file_paths", + "pdd.fingerprint_transaction.get_pdd_file_paths", side_effect=OSError("prompts dir not found"), - ), patch("pdd.operation_log.logger") as mock_logger: - # Should NOT raise - operation_log.save_fingerprint("badmod", "python", operation="generate") + ): + with pytest.raises(FingerprintFinalizeError) as raised: + operation_log.save_fingerprint("badmod", "python", operation="generate") - mock_logger.warning.assert_called_once() - warning_args = str(mock_logger.warning.call_args) - assert "badmod" in warning_args - assert "python" in warning_args + assert "path resolution failed" in str(raised.value) + assert "badmod_python.json" in str(raised.value) -def test_log_operation_decorator_skips_fingerprint_when_clear_silently_fails(temp_pdd_env): +def test_log_operation_decorator_fails_when_clear_silently_fails(temp_pdd_env): """ Regression for issue #1057: if a stale run report survives clear_run_report(), the decorator must not write a fresh fingerprint next to stale runtime state. @@ -935,13 +949,25 @@ def test_log_operation_decorator_skips_fingerprint_when_clear_silently_fails(tem def successful_command(prompt_file): return "ok", False, 0.0, "model" + prompt_path = Path(temp_pdd_env).parents[1] / "prompts" / "demo_python.prompt" + prompt_path.parent.mkdir() + prompt_path.write_text("% Demo\n", encoding="utf-8") + with patch("pdd.operation_log.os.remove", lambda _path: None), patch( "pdd.operation_log.save_fingerprint" ) as mock_save_fingerprint: - successful_command(prompt_file="prompts/demo_python.prompt") + with pytest.raises(FingerprintFinalizeError, match="run report not cleared"): + successful_command(prompt_file=str(prompt_path)) assert run_report_path.exists() mock_save_fingerprint.assert_not_called() + entries = operation_log.load_operation_log( + "demo", + "python", + paths={"prompt": str(prompt_path)}, + ) + assert entries[-1]["success"] is False + assert "run report not cleared" in entries[-1]["error"] # -------------------------------------------------------------------------------- @@ -1149,14 +1175,7 @@ def run_example(prompt_file): def test_log_operation_fallback_coerces_prompt_to_path_issue_1305(tmp_path): - """Issue #1305 (Test 5): when get_pdd_file_paths resolution fails, the - decorator must fall back to {"prompt": Path(prompt_file)} — a COERCED Path, - never a raw string — so prompt_hash is still real and the command does not - crash. - - On buggy code (or a naive raw-string fallback) the prompt value stays a str, - calculate_current_hashes skips it, and prompt_hash is null -> assertion fails. - """ + """A derivative/unregistered prompt still receives a real prompt hash.""" basename, language = "fallbackmod", "python" meta_dir, paths = _setup_pdd_module_files(tmp_path, basename, language) real_prompt_path = paths["prompt"] # absolute path to the real prompt file on disk @@ -1171,23 +1190,16 @@ def run_example(prompt_file): "pdd.sync_determine_operation.get_pdd_file_paths", side_effect=OSError("prompts dir not found"), ): - # Must not raise even though path resolution failed. result = run_example(prompt_file=str(real_prompt_path)) - assert result == ({"status": "ok"}, 0.1, "gpt-4"), "decorator must still return the result" + assert result == ({"status": "ok"}, 0.1, "gpt-4") fp_path = operation_log.get_fingerprint_path(basename, language, project_root=tmp_path) - assert fp_path.exists(), "fallback path must still write a fingerprint file" + assert fp_path.exists() with open(fp_path) as f: fp_data = json.load(f) - - # Fallback coerces the prompt string to a Path, so prompt_hash is real. - assert fp_data["prompt_hash"] is not None, ( - "fallback must pass Path(prompt_file) (not a raw str) so prompt_hash is non-null" - ) - assert _is_hex_sha256(fp_data["prompt_hash"]), ( - f"prompt_hash should be a 64-char hex SHA-256, got: {fp_data['prompt_hash']!r}" - ) + assert fp_data["prompt_hash"] is not None + assert _is_hex_sha256(fp_data["prompt_hash"]) def test_log_operation_example_writes_test_hash_issue_1305(tmp_path): diff --git a/tests/test_sync_determine_operation.py b/tests/test_sync_determine_operation.py index 58d243f10..61ee62a95 100644 --- a/tests/test_sync_determine_operation.py +++ b/tests/test_sync_determine_operation.py @@ -10,12 +10,7 @@ from datetime import datetime, timezone from unittest.mock import patch, MagicMock, mock_open -# Add the 'pdd' directory to the Python path to allow imports. -# This is necessary because the test file is in 'tests/' and the code is in 'pdd/'. -pdd_path = Path(__file__).parent.parent / 'pdd' -sys.path.insert(0, str(pdd_path)) - -from sync_determine_operation import ( +from pdd.sync_determine_operation import ( sync_determine_operation, analyze_conflict_with_llm, SyncLock, @@ -115,7 +110,7 @@ def pdd_test_environment(tmp_path): Path("prompts").mkdir(exist_ok=True) # Now update the constants after changing directory - pdd_module = sys.modules['sync_determine_operation'] + pdd_module = sys.modules['pdd.sync_determine_operation'] pdd_module.PDD_DIR = pdd_module.get_pdd_dir() pdd_module.META_DIR = pdd_module.get_meta_dir() pdd_module.LOCKS_DIR = pdd_module.get_locks_dir() @@ -284,13 +279,13 @@ def test_read_run_report_success(self, pdd_test_environment): # --- Part 2: `sync_determine_operation` Decision Logic --- -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_log_mode_skips_lock(mock_construct, pdd_test_environment): - with patch('sync_determine_operation.SyncLock') as mock_lock: + with patch('pdd.sync_determine_operation.SyncLock') as mock_lock: sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, log_mode=True) mock_lock.assert_not_called() - with patch('sync_determine_operation.SyncLock') as mock_lock: + with patch('pdd.sync_determine_operation.SyncLock') as mock_lock: sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, log_mode=False) mock_lock.assert_called_once_with(BASENAME, LANGUAGE) @@ -360,7 +355,7 @@ def test_context_aware_fix_over_crash_logic(pdd_test_environment): assert decision.details['example_success_history'] == True -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_crash_on_exit_code_nonzero(mock_construct, pdd_test_environment): # Create fingerprint (required for run_report to be processed) fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" @@ -376,7 +371,7 @@ def test_decision_crash_on_exit_code_nonzero(mock_construct, pdd_test_environmen assert decision.operation == 'crash' assert "Runtime error detected" in decision.reason -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_verify_after_crash_fix(mock_construct, pdd_test_environment): # Last command was 'crash' fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" @@ -394,7 +389,7 @@ def test_decision_verify_after_crash_fix(mock_construct, pdd_test_environment): assert "Previous crash operation completed" in decision.reason -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_crash_retry_when_exit_code_nonzero(mock_construct, pdd_test_environment): """When crash operation failed (exit_code != 0), should retry crash, not proceed to verify.""" # Last command was 'crash' @@ -412,7 +407,7 @@ def test_decision_crash_retry_when_exit_code_nonzero(mock_construct, pdd_test_en assert decision.operation == 'crash', f"Expected 'crash' when exit_code=1, got '{decision.operation}'" assert "retry crash fix" in decision.reason -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_fix_on_test_failures(mock_construct, pdd_test_environment): # Create prompt file so get_pdd_file_paths can work properly prompts_dir = pdd_test_environment / "prompts" @@ -446,8 +441,8 @@ def test_decision_fix_on_test_failures(mock_construct, pdd_test_environment): assert decision.operation == 'fix' assert "Test failures detected" in decision.reason -@patch('sync_determine_operation.construct_paths') -@patch('sync_determine_operation.get_pdd_file_paths') +@patch('pdd.sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.get_pdd_file_paths') def test_decision_test_on_low_coverage(mock_get_pdd_paths, mock_construct, pdd_test_environment): tmp_path = pdd_test_environment @@ -483,8 +478,8 @@ def test_decision_test_on_low_coverage(mock_get_pdd_paths, mock_construct, pdd_t assert f"coverage 75.0% below target {TARGET_COVERAGE:.1f}%" in decision.reason.lower() -@patch('sync_determine_operation.construct_paths') -@patch('sync_determine_operation.get_pdd_file_paths') +@patch('pdd.sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.get_pdd_file_paths') def test_decision_pr_scope_guard_suppresses_python_low_coverage_test_extend( mock_get_pdd_paths, mock_construct, @@ -523,8 +518,8 @@ def test_decision_pr_scope_guard_suppresses_python_low_coverage_test_extend( assert decision.details['current_coverage'] == 0.0 -@patch('sync_determine_operation.construct_paths') -@patch('sync_determine_operation.get_pdd_file_paths') +@patch('pdd.sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.get_pdd_file_paths') def test_decision_test_extend_default_still_runs_without_pr_scope_guard( mock_get_pdd_paths, mock_construct, @@ -583,28 +578,28 @@ def test_test_extend_disabled_unset_is_false(monkeypatch): assert is_test_extend_disabled() is False # --- No Fingerprint Tests --- -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_generate_for_new_prompt(mock_construct, pdd_test_environment): create_file(pdd_test_environment / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt", "A simple prompt.") decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(pdd_test_environment / "prompts")) assert decision.operation == 'generate' assert "New prompt ready" in decision.reason -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_autodeps_for_new_prompt_with_deps(mock_construct, pdd_test_environment): create_file(pdd_test_environment / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt", "A prompt that needs to another file.") decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(pdd_test_environment / "prompts")) assert decision.operation == 'auto-deps' assert "New prompt with dependencies detected" in decision.reason -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_nothing_for_new_unit_no_prompt(mock_construct, pdd_test_environment): decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE) assert decision.operation == 'nothing' assert "No prompt file and no history" in decision.reason # --- State Change Tests --- -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_nothing_when_synced(mock_construct, pdd_test_environment): prompts_dir = pdd_test_environment / "prompts" p_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt") @@ -932,7 +927,7 @@ def test_real_hashes_with_context_aware_fix_over_crash(pdd_test_environment): assert "prefer fix over crash" in decision.reason.lower() assert decision.details['example_success_history'] == True -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_example_when_missing(mock_construct, pdd_test_environment): prompts_dir = pdd_test_environment / "prompts" p_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt") @@ -958,7 +953,7 @@ def test_decision_example_when_missing(mock_construct, pdd_test_environment): assert decision.operation == 'example' assert "Code exists but example missing" in decision.reason -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_update_on_code_change(mock_construct, pdd_test_environment): prompts_dir = pdd_test_environment / "prompts" p_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt") @@ -984,7 +979,7 @@ def test_decision_update_on_code_change(mock_construct, pdd_test_environment): assert decision.operation == 'update' assert "Code changed" in decision.reason -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_decision_analyze_conflict_on_multiple_changes(mock_construct, pdd_test_environment): """When prompt and derived files changed, sync must return an explicit conflict.""" prompts_dir = pdd_test_environment / "prompts" @@ -1015,7 +1010,7 @@ def test_decision_analyze_conflict_on_multiple_changes(mock_construct, pdd_test_ assert fp_path.exists(), "Conflict classification must not delete metadata" -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_log_mode_conflict_analysis_keeps_metadata(mock_construct, pdd_test_environment): """Read-only analysis must not delete metadata for prompt+derived conflicts.""" prompts_dir = pdd_test_environment / "prompts" @@ -1056,7 +1051,7 @@ def test_log_mode_conflict_analysis_keeps_metadata(mock_construct, pdd_test_envi assert rr_path.exists() -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_conflict_preserves_fingerprint_and_run_report(mock_construct, pdd_test_environment): """Prompt+derived co-edits must not delete metadata or pick a winner.""" prompts_dir = pdd_test_environment / "prompts" @@ -1125,10 +1120,10 @@ def test_prompt_code_coedit_conflict_with_real_paths_preserves_metadata(pdd_test # --- Part 3: `analyze_conflict_with_llm` --- -@patch('sync_determine_operation.get_git_diff', return_value="fake diff") -@patch('sync_determine_operation.load_prompt_template', return_value="prompt: {prompt_diff}") -@patch('sync_determine_operation.llm_invoke') -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.get_git_diff', return_value="fake diff") +@patch('pdd.sync_determine_operation.load_prompt_template', return_value="prompt: {prompt_diff}") +@patch('pdd.sync_determine_operation.llm_invoke') +@patch('pdd.sync_determine_operation.construct_paths') def test_analyze_conflict_success(mock_construct, mock_llm_invoke, mock_load_template, mock_git_diff, pdd_test_environment): mock_llm_invoke.return_value = { 'result': json.dumps({ @@ -1150,10 +1145,10 @@ def test_analyze_conflict_success(mock_construct, mock_llm_invoke, mock_load_tem assert decision.estimated_cost == 0.05 mock_load_template.assert_called_with("sync_analysis_LLM") -@patch('sync_determine_operation.get_git_diff') -@patch('sync_determine_operation.load_prompt_template') -@patch('sync_determine_operation.llm_invoke') -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.get_git_diff') +@patch('pdd.sync_determine_operation.load_prompt_template') +@patch('pdd.sync_determine_operation.llm_invoke') +@patch('pdd.sync_determine_operation.construct_paths') def test_analyze_conflict_llm_invalid_json(mock_construct, mock_llm_invoke, mock_load_template, mock_git_diff, pdd_test_environment): mock_load_template.return_value = "template" mock_llm_invoke.return_value = {'result': 'this is not json', 'cost': 0.01} @@ -1165,10 +1160,10 @@ def test_analyze_conflict_llm_invalid_json(mock_construct, mock_llm_invoke, mock assert "Invalid LLM response" in decision.reason assert decision.confidence == 0.0 -@patch('sync_determine_operation.get_git_diff') -@patch('sync_determine_operation.load_prompt_template') -@patch('sync_determine_operation.llm_invoke') -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.get_git_diff') +@patch('pdd.sync_determine_operation.load_prompt_template') +@patch('pdd.sync_determine_operation.llm_invoke') +@patch('pdd.sync_determine_operation.construct_paths') def test_analyze_conflict_llm_low_confidence(mock_construct, mock_llm_invoke, mock_load_template, mock_git_diff, pdd_test_environment): mock_load_template.return_value = "template" mock_llm_invoke.return_value = { @@ -1183,8 +1178,8 @@ def test_analyze_conflict_llm_low_confidence(mock_construct, mock_llm_invoke, mo assert "LLM confidence too low" in decision.reason assert decision.confidence == 0.5 -@patch('sync_determine_operation.load_prompt_template', return_value=None) -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.load_prompt_template', return_value=None) +@patch('pdd.sync_determine_operation.construct_paths') def test_analyze_conflict_llm_template_missing(mock_construct, mock_load_template, pdd_test_environment): fingerprint = Fingerprint("1.0", "t", "generate", "p", "c", None, None) decision = analyze_conflict_with_llm(BASENAME, LANGUAGE, fingerprint, ['prompt']) @@ -1194,7 +1189,7 @@ def test_analyze_conflict_llm_template_missing(mock_construct, mock_load_templat # --- Part 4: Skip Flag Tests --- -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_skip_tests_prevents_test_operation_on_low_coverage(mock_construct, pdd_test_environment): """Test that test operation is not returned when skip_tests=True even with low coverage.""" # Create fingerprint (required for run_report to be processed) @@ -1212,7 +1207,7 @@ def test_skip_tests_prevents_test_operation_on_low_coverage(mock_construct, pdd_ assert decision.operation == 'all_synced' assert "tests skipped" in decision.reason.lower() -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_skip_tests_workflow_completion(mock_construct, pdd_test_environment): """Test workflow completion when skip_tests=True and test files are missing.""" prompts_dir = pdd_test_environment / "prompts" @@ -1249,7 +1244,7 @@ def test_skip_tests_workflow_completion(mock_construct, pdd_test_environment): # Check for skip_tests in reason or that it's an all_synced with tests skipped assert "skip_tests=True" in decision.reason or "tests skipped" in decision.reason.lower() -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_skip_flags_parameter_propagation(mock_construct, pdd_test_environment): """Test that skip flags are correctly used in decision logic.""" # Test with both flags enabled @@ -1257,7 +1252,7 @@ def test_skip_flags_parameter_propagation(mock_construct, pdd_test_environment): # Should not crash and should handle skip flags properly assert isinstance(decision, SyncDecision) -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_sync_determine_operation_respects_skip_flags_before_run_report(mock_construct, pdd_test_environment): """Test that skip flags prevent crash/fix recommendations based on cached failing run reports.""" # Create prompt file so get_pdd_file_paths can work properly @@ -1706,7 +1701,7 @@ def test_auto_deps_first_time_with_dependencies(self, pdd_test_environment): assert decision.details['has_dependencies'] == True assert decision.details['fingerprint_found'] == False - @patch('sync_determine_operation.construct_paths') + @patch('pdd.sync_determine_operation.construct_paths') def test_auto_deps_regenerates_when_code_exists_from_previous_run(self, mock_construct, pdd_test_environment): """Test that after auto-deps completes, generate runs even when code file exists from previous run. @@ -1776,7 +1771,7 @@ def test_auto_deps_regenerates_when_code_exists_from_previous_run(self, mock_con assert decision.details.get('regenerate_after_autodeps') == True assert decision.details.get('code_exists') == True # Confirms code existed but we still regenerate - @patch('sync_determine_operation.construct_paths') + @patch('pdd.sync_determine_operation.construct_paths') def test_no_fingerprint_with_stale_run_report_should_generate(self, mock_construct, pdd_test_environment): """Test that when fingerprint is deleted but run_report exists, sync treats it as fresh start. @@ -1833,7 +1828,7 @@ def test_no_fingerprint_with_stale_run_report_should_generate(self, mock_constru f"Expected 'auto-deps', got '{decision.operation}'. " \ f"Bug: stale run_report should be ignored when fingerprint is missing." - @patch('sync_determine_operation.construct_paths') + @patch('pdd.sync_determine_operation.construct_paths') def test_auto_deps_ignores_stale_run_report_with_low_coverage(self, mock_construct, pdd_test_environment): """Test that after auto-deps completes, stale run_report with low coverage is ignored. @@ -2331,7 +2326,7 @@ def mock_construct_paths( "python" ) - monkeypatch.setattr('sync_determine_operation.construct_paths', mock_construct_paths) + monkeypatch.setattr('pdd.sync_determine_operation.construct_paths', mock_construct_paths) # Test when prompt file doesn't exist - this is the regression scenario basename = "test_unit" @@ -2434,7 +2429,7 @@ def test_get_pdd_file_paths_fallback_without_construct_paths(self, tmp_path, mon finally: os.chdir(original_cwd) - @patch('sync_determine_operation.construct_paths') + @patch('pdd.sync_determine_operation.construct_paths') def test_sync_operation_with_missing_prompt_respects_test_path(self, mock_construct, tmp_path): """Test that sync_determine_operation doesn't fail when test file is in configured directory. @@ -2510,7 +2505,7 @@ def test_file_path_lookup_regression(self, tmp_path, monkeypatch): original_cwd = os.getcwd() # Store original module constants to restore them later - pdd_module = sys.modules['sync_determine_operation'] + pdd_module = sys.modules['pdd.sync_determine_operation'] original_pdd_dir = pdd_module.PDD_DIR original_meta_dir = pdd_module.META_DIR original_locks_dir = pdd_module.LOCKS_DIR @@ -2867,7 +2862,7 @@ def all_files_env(self, tmp_path): d.mkdir(parents=True, exist_ok=True) # Update module-level path constants BEFORE calling get_pdd_file_paths - pdd_module = sys.modules['sync_determine_operation'] + pdd_module = sys.modules['pdd.sync_determine_operation'] pdd_module.PDD_DIR = pdd_module.get_pdd_dir() pdd_module.META_DIR = pdd_module.get_meta_dir() pdd_module.LOCKS_DIR = pdd_module.get_locks_dir() @@ -3005,7 +3000,7 @@ def test_complete_workflow_returns_nothing(self, all_files_env): # --- Part 6: PDD Doctrine - Derived Artifacts Tests --- -@patch('sync_determine_operation.construct_paths') +@patch('pdd.sync_determine_operation.construct_paths') def test_no_conflict_when_only_derived_artifacts_change(mock_construct, pdd_test_environment): """ Test that when only derived artifacts (code + example) change but prompt is UNCHANGED, @@ -3142,8 +3137,8 @@ def test_stale_run_report_detected_when_test_hash_differs(self, pdd_test_environ 'test': test_path, } - with patch('sync_determine_operation.construct_paths') as mock_construct, \ - patch('sync_determine_operation.get_pdd_file_paths') as mock_get_paths: + with patch('pdd.sync_determine_operation.construct_paths') as mock_construct, \ + patch('pdd.sync_determine_operation.get_pdd_file_paths') as mock_get_paths: mock_construct.return_value = ( {'prompt_file': str(prompt_path)}, {'output': str(code_path)}, @@ -3277,8 +3272,8 @@ def test_run_report_tests_failed_triggers_fix(self, pdd_test_environment): 'test': test_path, } - with patch('sync_determine_operation.construct_paths') as mock_construct, \ - patch('sync_determine_operation.get_pdd_file_paths') as mock_get_paths: + with patch('pdd.sync_determine_operation.construct_paths') as mock_construct, \ + patch('pdd.sync_determine_operation.get_pdd_file_paths') as mock_get_paths: mock_construct.return_value = ( {'prompt_file': str(prompt_path)}, {'output': str(code_path)}, @@ -3443,8 +3438,8 @@ def test_sync_returns_test_operation_when_tests_not_run(self, pdd_test_environme 'test': test_path, } - with patch('sync_determine_operation.construct_paths') as mock_construct, \ - patch('sync_determine_operation.get_pdd_file_paths') as mock_get_paths: + with patch('pdd.sync_determine_operation.construct_paths') as mock_construct, \ + patch('pdd.sync_determine_operation.get_pdd_file_paths') as mock_get_paths: mock_construct.return_value = ( {'prompt_file': str(prompt_path)}, {'output': str(code_path)}, @@ -4524,7 +4519,10 @@ def test_check_example_success_history_with_bracket_basename(self, tmp_path): Bug: _safe_basename('frontend/[id]') -> 'frontend_[id]', then meta_dir.glob('frontend_[id]_python_run*.json') interprets [id] as char class. """ - from sync_determine_operation import _check_example_success_history, _safe_basename + from pdd.sync_determine_operation import ( + _check_example_success_history, + _safe_basename, + ) assert _safe_basename("frontend/[id]") == "frontend_[id]" @@ -4534,9 +4532,9 @@ def test_check_example_success_history_with_bracket_basename(self, tmp_path): report_file = meta_dir / "frontend_[id]_python_run_001.json" report_file.write_text('{"exit_code": 0}') - with patch("sync_determine_operation.get_meta_dir", return_value=meta_dir), \ - patch("sync_determine_operation.read_fingerprint", return_value=None), \ - patch("sync_determine_operation.read_run_report", return_value=None): + with patch("pdd.sync_determine_operation.get_meta_dir", return_value=meta_dir), \ + patch("pdd.sync_determine_operation.read_fingerprint", return_value=None), \ + patch("pdd.sync_determine_operation.read_run_report", return_value=None): result = _check_example_success_history("frontend/[id]", "python") @@ -4566,7 +4564,7 @@ def test_get_pdd_file_paths_primary_test_glob_with_brackets(self, tmp_path, monk test_file_2 = tests_dir / "test_[id]_extra.py" test_file_2.write_text("def test_2(): pass") - with patch("sync_determine_operation.construct_paths") as mock_cp: + with patch("pdd.sync_determine_operation.construct_paths") as mock_cp: def side_effect(*args, **kwargs): cmd = kwargs.get("command", "sync") if cmd == "test": @@ -4615,7 +4613,7 @@ def test_get_pdd_file_paths_fallback_glob_with_brackets(self, tmp_path, monkeypa test_file_2 = tmp_path / "test_[id]_extra.py" test_file_2.write_text("def test_2(): pass") - with patch("sync_determine_operation.construct_paths", side_effect=Exception("force fallback")): + with patch("pdd.sync_determine_operation.construct_paths", side_effect=Exception("force fallback")): result = get_pdd_file_paths("[id]", "python", prompts_dir=str(tmp_path)) test_files = result.get("test_files", []) @@ -4640,7 +4638,7 @@ def test_get_pdd_file_paths_prompt_missing_glob_with_brackets(self, tmp_path, mo test_file_2 = tests_dir / "test_[id]_extra.py" test_file_2.write_text("def test_2(): pass") - with patch("sync_determine_operation.construct_paths") as mock_cp: + with patch("pdd.sync_determine_operation.construct_paths") as mock_cp: mock_cp.return_value = ( {"prompts_dir": str(tmp_path / "prompts"), "tests_dir": str(tests_dir)}, {"prompt_file": "content"}, diff --git a/tests/test_sync_main.py b/tests/test_sync_main.py index 3b9dc9d77..1d0f3ea59 100644 --- a/tests/test_sync_main.py +++ b/tests/test_sync_main.py @@ -262,6 +262,59 @@ def test_sync_main_forwards_compress_to_orchestration( assert mock_sync_orchestration.call_args.kwargs["compress"] is True +def test_sync_main_defaults_to_surgical_fresh_false( + mock_project_dir, mock_construct_paths, mock_sync_orchestration +): + """Default sync forwards fresh=False so mature modules regenerate + surgically (edit-shaped) rather than being rebirthed (#1938 Pillar A).""" + (mock_project_dir / "prompts" / "demo_python.prompt").touch() + mock_sync_orchestration.return_value = { + "success": True, + "total_cost": 0.0, + "model_name": "test", + "summary": "ok", + } + ctx = create_mock_context({"quiet": True}) + sync_main( + ctx, + "demo", + max_attempts=1, + budget=5.0, + skip_verify=True, + skip_tests=True, + target_coverage=90.0, + dry_run=False, + ) + assert mock_sync_orchestration.call_args.kwargs["fresh"] is False + + +def test_sync_main_forwards_fresh_true_to_orchestration( + mock_project_dir, mock_construct_paths, mock_sync_orchestration +): + """`pdd sync --fresh` threads fresh=True into sync_orchestration so the + operator opts back into full ('fresh') regeneration (#1938 Pillar A).""" + (mock_project_dir / "prompts" / "demo_python.prompt").touch() + mock_sync_orchestration.return_value = { + "success": True, + "total_cost": 0.0, + "model_name": "test", + "summary": "ok", + } + ctx = create_mock_context({"quiet": True}) + sync_main( + ctx, + "demo", + max_attempts=1, + budget=5.0, + skip_verify=True, + skip_tests=True, + target_coverage=90.0, + dry_run=False, + fresh=True, + ) + assert mock_sync_orchestration.call_args.kwargs["fresh"] is True + + def test_sync_success_multiple_languages(mock_project_dir, mock_construct_paths, mock_sync_orchestration): """Tests a successful sync for multiple languages, checking budget reduction and result aggregation.""" (mock_project_dir / "prompts" / "my_lib_python.prompt").touch() diff --git a/tests/test_sync_orchestration.py b/tests/test_sync_orchestration.py index 16d9c8664..3a6f29d75 100644 --- a/tests/test_sync_orchestration.py +++ b/tests/test_sync_orchestration.py @@ -9500,3 +9500,58 @@ def test_sync_orchestration_skip_handler_for_fix(orchestration_fixture): orchestration_fixture['_save_fingerprint_atomic'].assert_any_call( "calculator", "python", "skip:fix", ANY, 0.0, "skipped" ) + + +# --------------------------------------------------------------------------- +# Issue #1938 (Pillar A): surgical regeneration for mature modules. +# +# For a mature module (existing non-empty code) with a small prompt delta, +# `pdd sync` regeneration must be edit-shaped, not rebirth-shaped, so declared +# symbols are preserved instead of dropped by a full "big change" regen. The +# generate operation therefore drives `code_generator_main` with +# `force_incremental_flag=True` by DEFAULT (surgical/edit-shaped), and only with +# `force_incremental_flag=False` (today's full-regeneration behavior) when the +# operator opts in via `pdd sync --fresh`. The `code_generator_main` layer keeps +# the existing safety nets: a new/empty module or an undeterminable original +# prompt still falls back to full generation, and a repair directive still +# forces full regeneration. +# --------------------------------------------------------------------------- +def _capture_force_incremental(orchestration_fixture): + """Run a single generate op and return the force_incremental_flag kwarg + passed to code_generator_main.""" + mock_determine = orchestration_fixture['sync_determine_operation'] + mock_determine.side_effect = [ + SyncDecision(operation='generate', reason='Prompt changed'), + SyncDecision(operation='all_synced', reason='All artifacts are up to date'), + ] + code_path = orchestration_fixture['get_pdd_file_paths'].return_value['code'] + captured = {} + + def fake_codegen(*_args, **_kwargs): + captured['force_incremental_flag'] = _kwargs.get('force_incremental_flag') + code_path.parent.mkdir(parents=True, exist_ok=True) + code_path.write_text("class Calculator:\n pass\n") + return ("class Calculator:\n pass\n", True, 0.10, "model") + + orchestration_fixture['code_generator_main'].side_effect = fake_codegen + return captured + + +def test_generate_defaults_to_surgical_force_incremental(orchestration_fixture): + """Default sync must request edit-shaped (surgical) generation: + code_generator_main receives force_incremental_flag=True.""" + captured = _capture_force_incremental(orchestration_fixture) + + sync_orchestration(basename="calculator", language="python", budget=1.0) + + assert captured['force_incremental_flag'] is True + + +def test_generate_fresh_flag_forces_full_regeneration(orchestration_fixture): + """`pdd sync --fresh` (fresh=True) must restore full-regeneration behavior: + code_generator_main receives force_incremental_flag=False.""" + captured = _capture_force_incremental(orchestration_fixture) + + sync_orchestration(basename="calculator", language="python", budget=1.0, fresh=True) + + assert captured['force_incremental_flag'] is False diff --git a/tests/test_sync_tui.py b/tests/test_sync_tui.py index 76d8c5d6d..a84bfedc4 100644 --- a/tests/test_sync_tui.py +++ b/tests/test_sync_tui.py @@ -25,6 +25,12 @@ from textual.widgets import RichLog, ProgressBar, Static from textual.app import App +from pdd.agentic_common import ( + get_disabled_providers, + mark_provider_permanently_failed, + provider_failure_scope, + reset_disabled_providers, +) # --- Event loop fixture for sync tests --- @@ -205,6 +211,37 @@ def mock_worker(): assert os.environ.get("TERM") == original_term assert os.environ.get("COLUMNS") == original_columns + +def test_sync_worker_seeds_parent_provider_failure_scope(): + """Textual worker execution preserves the parent workflow skip-list.""" + observed = [] + + def mock_worker(): + observed.append(get_disabled_providers()) + return {"success": True} + + reset_disabled_providers() + refs = [[""]] * 10 + with provider_failure_scope(): + mark_provider_permanently_failed("openai", "auth") + app = SyncApp( + "test", + 1.0, + mock_worker, + *refs, + stop_event=threading.Event(), + ) + + app._app_ref = [app] + # Invoke the worker body directly: the @work(thread=True) wrapper requires a + # running Textual event loop to schedule the thread on newer Textual releases, + # which is not present in this synchronous unit test. The body is the real + # production logic and guards app-dependent branches on self.is_running. + app._run_worker_body() + + assert observed == [{"openai": "auth"}] + assert get_disabled_providers() == {} + def test_progress_callback_thread_safety(): """Verify _update_progress schedules a UI update.""" # Setup shared refs (10 positional ref arguments) diff --git a/tests/test_update_main.py b/tests/test_update_main.py index 99d8ceea6..791916354 100644 --- a/tests/test_update_main.py +++ b/tests/test_update_main.py @@ -8,6 +8,7 @@ import git from pdd import DEFAULT_STRENGTH +from pdd.fingerprint_transaction import FingerprintFinalizeError from pdd.update_main import ( _finalize_single_file_fingerprint, _included_docs_for_drift_report, @@ -71,7 +72,8 @@ def mock_open_file(): """ Patches the built-in open function so no real file I/O happens. """ - with patch("builtins.open", mock_open()) as mock_file: + with patch("builtins.open", mock_open()) as mock_file, \ + patch("pdd.operation_log.save_fingerprint"): yield mock_file @pytest.fixture @@ -725,8 +727,10 @@ def mock_update_logic( ctx = click.Context(click.Command('update')) ctx.obj = {"strength": 0.5, "temperature": 0.1, "verbose": False, "time": 0.25, "quiet": False} - # Run update_main in repo mode - result = update_main(ctx=ctx, input_prompt_file=None, modified_code_file=None, input_code_file=None, output=None, use_git=False, repo=True) + # The update primitive is mocked and does not actually write its prompt; + # isolate this orchestration test from the real finalizer. + with patch("pdd.operation_log.save_fingerprint"): + result = update_main(ctx=ctx, input_prompt_file=None, modified_code_file=None, input_code_file=None, output=None, use_git=False, repo=True) # Assert that the update function was called for each pair (all 3 marked as changed) assert mock_update_file_pair.call_count == 3 @@ -775,16 +779,17 @@ def mock_update_logic( ctx = click.Context(click.Command('update')) ctx.obj = {"strength": 0.5, "temperature": 0.1, "verbose": False, "time": 0.25, "quiet": False} - result = update_main( - ctx=ctx, - input_prompt_file=None, - modified_code_file=None, - input_code_file=None, - output=None, - use_git=False, - repo=True, - budget=1.0, - ) + with patch("pdd.operation_log.save_fingerprint"): + result = update_main( + ctx=ctx, + input_prompt_file=None, + modified_code_file=None, + input_code_file=None, + output=None, + use_git=False, + repo=True, + budget=1.0, + ) # First two updates run (0.6 + 0.6), then cap is reached and third is skipped. assert mock_update_file_pair.call_count == 2 @@ -2285,6 +2290,7 @@ def test_explicit_params_override_ctx(self): patch("pdd.update_main.resolve_prompt_code_pair", return_value=("/tmp/test.prompt", "/tmp/test.py")), \ patch("pdd.update_main._resolve_update_runtime_config", side_effect=self._fake_resolve_runtime_config), \ + patch("pdd.update_main._finalize_single_file_fingerprint"), \ patch("builtins.open", mock_open(read_data="def foo(): pass\n")): update_main( ctx=ctx, @@ -2324,6 +2330,7 @@ def test_ctx_values_used_when_params_none(self): patch("pdd.update_main.resolve_prompt_code_pair", return_value=("/tmp/test.prompt", "/tmp/test.py")), \ patch("pdd.update_main._resolve_update_runtime_config", side_effect=self._fake_resolve_runtime_config), \ + patch("pdd.update_main._finalize_single_file_fingerprint"), \ patch("builtins.open", mock_open(read_data="def foo(): pass\n")): update_main( ctx=ctx, @@ -3076,10 +3083,9 @@ def test_default_single_file_update_writes_fresh_fingerprint( kwargs = mock_save_fp.call_args.kwargs assert mock_save_fp.call_args.args == ("modified_code", "python") assert kwargs["operation"] == "update" - assert kwargs["paths"] == { - "prompt": Path(str(derived_prompt)), - "code": Path(str(code_file)), - } + assert kwargs["paths"]["prompt"] == Path(str(derived_prompt)) + assert kwargs["paths"]["code"] == Path(str(code_file)) + assert {"prompt", "code", "example", "test"}.issubset(kwargs["paths"]) @patch("pdd.update_main.resolve_prompt_code_pair") @@ -3218,7 +3224,7 @@ def test_default_single_file_update_clears_stale_run_report( assert (meta_dir / "foo_python.json").exists() -def test_finalize_single_file_fingerprint_skips_save_when_run_report_survives_clear( +def test_finalize_single_file_fingerprint_fails_when_run_report_survives_clear( tmp_path, monkeypatch, capsys, @@ -3254,15 +3260,16 @@ def test_finalize_single_file_fingerprint_skips_save_when_run_report_survives_cl import pdd.operation_log as ol monkeypatch.setattr(ol.os, "remove", lambda *a, **kw: None) - _finalize_single_file_fingerprint( - prompt_path=prompt_path, - code_path=code_path, - sync_metadata=False, - dry_run=False, - quiet=False, - cost=0.0, - model="test-model", - ) + with pytest.raises(FingerprintFinalizeError, match="run report not cleared"): + _finalize_single_file_fingerprint( + prompt_path=prompt_path, + code_path=code_path, + sync_metadata=False, + dry_run=False, + quiet=False, + cost=0.0, + model="test-model", + ) # Acceptance criteria: # - The stale run report still exists (because os.remove was nulled). @@ -3320,15 +3327,16 @@ def test_finalize_single_file_fingerprint_warns_about_stale_run_report_even_when import pdd.operation_log as ol monkeypatch.setattr(ol.os, "remove", lambda *a, **kw: None) - _finalize_single_file_fingerprint( - prompt_path=prompt_path, - code_path=code_path, - sync_metadata=False, - dry_run=False, - quiet=True, # explicit: warning must surface anyway - cost=0.0, - model="test-model", - ) + with pytest.raises(FingerprintFinalizeError, match="run report not cleared"): + _finalize_single_file_fingerprint( + prompt_path=prompt_path, + code_path=code_path, + sync_metadata=False, + dry_run=False, + quiet=True, # explicit: warning must surface anyway + cost=0.0, + model="test-model", + ) assert not (meta_dir / "foo_python.json").exists() # Rich's Console wraps long lines on narrow terminals; normalize @@ -3342,7 +3350,7 @@ def test_finalize_single_file_fingerprint_warns_about_stale_run_report_even_when ) -def test_finalize_single_file_fingerprint_swallows_import_error_for_helpers( +def test_finalize_single_file_fingerprint_wraps_import_error_for_helpers( tmp_path, monkeypatch, capsys, @@ -3384,7 +3392,10 @@ def test_finalize_single_file_fingerprint_swallows_import_error_for_helpers( # statement; the import machinery resolves that via `pdd.operation_log` # in sys.modules. Our stand-in lacks the needed names, so the # `from ... import (a, b, c)` raises ImportError at the `a` lookup. - try: + with pytest.raises( + FingerprintFinalizeError, + match="could not import finalization helpers", + ): _finalize_single_file_fingerprint( prompt_path=prompt_path, code_path=code_path, @@ -3394,12 +3405,6 @@ def test_finalize_single_file_fingerprint_swallows_import_error_for_helpers( cost=0.0, model="test-model", ) - except ImportError as exc: - pytest.fail( - f"_finalize_single_file_fingerprint must NOT propagate an " - f"ImportError out — best-effort metadata cleanup may not " - f"break the caller's successful update tuple. Got: {exc!r}" - ) finally: if real_module is not None: sys.modules["pdd.operation_log"] = real_module @@ -3408,16 +3413,10 @@ def test_finalize_single_file_fingerprint_swallows_import_error_for_helpers( # helpers, so writing a fingerprint without first clearing the stale # run report would defeat the issue #1106 invariant. save_fingerprint_mock.assert_not_called() - # A user-facing warning must surface so the operator knows finalization - # was skipped (it is informational, not status fluff). - captured = " ".join(capsys.readouterr().out.split()) - assert "Could not import finalization helpers" in captured, ( - f"Expected a 'Could not import finalization helpers' warning " - f"when the import wrapping fires; got stdout: {captured!r}" - ) + # The caller receives a typed failure and cannot report the update green. -def test_default_single_file_update_skips_fingerprint_when_identity_unknown( +def test_default_single_file_update_fails_when_identity_unknown( mock_ctx, minimal_input_files, mock_construct_paths, @@ -3431,20 +3430,21 @@ def test_default_single_file_update_skips_fingerprint_when_identity_unknown( with patch("pdd.update_main.get_available_agents", return_value=[]), \ patch("pdd.operation_log.infer_module_identity", return_value=(None, None)), \ patch("pdd.operation_log.save_fingerprint") as mock_save_fp: - result = update_main( - ctx=mock_ctx, - input_prompt_file="updated_prompt.prompt", - modified_code_file=minimal_input_files["modified_code_file"], - input_code_file=minimal_input_files["input_code_file"], - output="updated_prompt.prompt", - use_git=False, - ) + with pytest.raises(click.exceptions.Exit) as raised: + update_main( + ctx=mock_ctx, + input_prompt_file="updated_prompt.prompt", + modified_code_file=minimal_input_files["modified_code_file"], + input_code_file=minimal_input_files["input_code_file"], + output="updated_prompt.prompt", + use_git=False, + ) - assert result is not None + assert raised.value.exit_code == 1 mock_save_fp.assert_not_called() -def test_default_single_file_update_swallows_fingerprint_save_failure( +def test_default_single_file_update_fails_on_fingerprint_save_failure( mock_ctx, minimal_input_files, mock_construct_paths, @@ -3458,16 +3458,17 @@ def test_default_single_file_update_swallows_fingerprint_save_failure( with patch("pdd.update_main.get_available_agents", return_value=[]), \ patch("pdd.operation_log.infer_module_identity", return_value=("mod", "python")), \ patch("pdd.operation_log.save_fingerprint", side_effect=OSError("disk full")): - result = update_main( - ctx=mock_ctx, - input_prompt_file="updated_prompt.prompt", - modified_code_file=minimal_input_files["modified_code_file"], - input_code_file=minimal_input_files["input_code_file"], - output="updated_prompt.prompt", - use_git=False, - ) + with pytest.raises(click.exceptions.Exit) as raised: + update_main( + ctx=mock_ctx, + input_prompt_file="updated_prompt.prompt", + modified_code_file=minimal_input_files["modified_code_file"], + input_code_file=minimal_input_files["input_code_file"], + output="updated_prompt.prompt", + use_git=False, + ) - assert result == ("updated prompt text", 0.123456, "test-model") + assert raised.value.exit_code == 1 def test_default_single_file_update_skips_fingerprint_when_output_redirected( @@ -3727,6 +3728,65 @@ def _sync(prompt_path, code_path, dry_run): ) +def test_cli_single_file_update_failure_exits_non_zero(tmp_path): + """A single-file `pdd update` whose update_main returns None must exit != 0. + + Codex review (PR #1998): update_main returns None on failure (input/provider/ + invalid-output). If the CLI converts that to exit 0, the change orchestrator's + Step 8.5 preflight heal — which runs `pdd update --git ...` as a subprocess and + only inspects the exit code — records an unchanged, still-stale prompt as + "healed" and reasons Step 9 from stale intent. The single-file boundary must + fail closed. + """ + from click.testing import CliRunner + from pdd.commands.modify import update + + prompt_file = tmp_path / "mod_Python.prompt" + prompt_file.write_text("stale prompt\n", encoding="utf-8") + code_file = tmp_path / "mod.py" + code_file.write_text("def foo(): return 1\n", encoding="utf-8") + + runner = CliRunner() + with patch("pdd.commands.modify.update_main", return_value=None): + result = runner.invoke( + update, + ["--git", str(prompt_file), str(code_file)], + obj={"quiet": True, "force": True, "verbose": False, "time": 1.0, + "strength": 0.5, "temperature": 0.0, "context": None}, + standalone_mode=True, + ) + + assert result.exit_code != 0, ( + f"single-file update failure must yield non-zero exit; got " + f"{result.exit_code}. stdout={result.stdout!r}" + ) + + +def test_cli_repo_mode_update_no_op_none_exits_zero(tmp_path): + """Repo-wide mode returns None for a legitimate 'everything in sync' no-op. + + That no-op must remain exit 0 — only single-file None (a failure) fails closed + (Codex review, PR #1998). + """ + from click.testing import CliRunner + from pdd.commands.modify import update + + runner = CliRunner() + with patch("pdd.commands.modify.update_main", return_value=None): + result = runner.invoke( + update, + ["--all"], + obj={"quiet": True, "force": True, "verbose": False, "time": 1.0, + "strength": 0.5, "temperature": 0.0, "context": None}, + standalone_mode=True, + ) + + assert result.exit_code == 0, ( + f"repo-mode no-op (None) must stay exit 0; got {result.exit_code}. " + f"stdout={result.stdout!r}" + ) + + # --- Repo-mode sync_metadata integration ---------------------------------- @@ -3846,7 +3906,7 @@ def _update(prompt_file, code_file, ctx, repo, simple=False, strength=None, temp @patch("pdd.update_main.get_git_changed_files", return_value=set()) @patch("pdd.update_main.update_file_pair") @patch("pdd.pddrc_initializer.ensure_pddrc_for_scan") -def test_repo_mode_clear_run_report_failure_warns_and_continues( +def test_repo_mode_clear_run_report_failure_is_hard_failure( mock_pddrc, mock_update_file_pair, mock_git_changed, @@ -3882,27 +3942,24 @@ def _update(prompt_file, code_file, ctx, repo, simple=False, strength=None, temp # quiet=False so the warning is emitted ctx.obj = {"strength": 0.5, "temperature": 0.1, "verbose": False, "time": 0.25, "quiet": False} - result = update_main( - ctx=ctx, - input_prompt_file=None, - modified_code_file=None, - input_code_file=None, - output=None, - use_git=False, - repo=True, - sync_metadata=False, - ) + with pytest.raises(click.exceptions.Exit) as raised: + update_main( + ctx=ctx, + input_prompt_file=None, + modified_code_file=None, + input_code_file=None, + output=None, + use_git=False, + repo=True, + sync_metadata=False, + ) - assert result is not None + assert raised.value.exit_code == 1 assert mock_sync.call_count == 0 - # clear_run_report attempted for each successful pair - assert mock_clear_rr.call_count == mock_update_file_pair.call_count - # save_fingerprint still called per pair despite clear failure - assert mock_save_fp.call_count == mock_update_file_pair.call_count - assert mock_save_fp.call_count >= 1 - # Warning surfaced to the user + assert mock_clear_rr.call_count == 1 + assert mock_save_fp.call_count == 0 out = capsys.readouterr().out - assert "Run report clear failed" in out + assert "Fingerprint finalization failed" in out assert "disk full" in out @@ -3911,7 +3968,7 @@ def _update(prompt_file, code_file, ctx, repo, simple=False, strength=None, temp @patch("pdd.update_main.get_git_changed_files", return_value=set()) @patch("pdd.update_main.update_file_pair") @patch("pdd.pddrc_initializer.ensure_pddrc_for_scan") -def test_repo_mode_clear_run_report_silent_unlink_failure_warns( +def test_repo_mode_clear_run_report_silent_unlink_failure_is_hard_failure( mock_pddrc, mock_update_file_pair, mock_git_changed, @@ -3958,22 +4015,23 @@ def _update(prompt_file, code_file, ctx, repo, simple=False, strength=None, temp # quiet=False so the defensive warning is emitted ctx.obj = {"strength": 0.5, "temperature": 0.1, "verbose": False, "time": 0.25, "quiet": False} - result = update_main( - ctx=ctx, - input_prompt_file=None, - modified_code_file=None, - input_code_file=None, - output=None, - use_git=False, - repo=True, - sync_metadata=False, - ) + with pytest.raises(click.exceptions.Exit) as raised: + update_main( + ctx=ctx, + input_prompt_file=None, + modified_code_file=None, + input_code_file=None, + output=None, + use_git=False, + repo=True, + sync_metadata=False, + ) - assert result is not None + assert raised.value.exit_code == 1 assert mock_sync.call_count == 0 # clear_run_report attempted for each successful pair, but it silently # did nothing (no exception raised, no file removed). - assert mock_clear_rr.call_count == mock_update_file_pair.call_count + assert mock_clear_rr.call_count == 1 # save_fingerprint must be SKIPPED when the stale run report remains, # so we don't claim finalized metadata while runtime verification still # describes the pre-update files (issue #1057). @@ -3981,8 +4039,9 @@ def _update(prompt_file, code_file, ctx, repo, simple=False, strength=None, temp # Defensive warning surfaced to the user because the report file still # exists after clear_run_report returned. out = capsys.readouterr().out - assert "Run report clear failed" in out - assert "still exists after" in out + normalized_out = " ".join(out.split()) + assert "still exists after clear" in normalized_out + assert "Fingerprint finalization failed" in normalized_out # The file is indeed still on disk (the whole point of this regression). assert stale_report.exists() @@ -4421,6 +4480,7 @@ def _run_prd_sync_update(repo_root, ctx, sync_metadata=False): patch("pdd.update_main.git.Repo") as mock_repo, patch("pdd.update_main.os.getcwd", return_value=str(repo_root)), patch("pdd.pddrc_initializer.ensure_pddrc_for_scan"), + patch("pdd.operation_log.save_fingerprint"), ): mock_repo.return_value.working_tree_dir = str(repo_root) return update_main(