diff --git a/.pdd/meta/agentic_checkup_orchestrator_python.json b/.pdd/meta/agentic_checkup_orchestrator_python.json index 2322ad2d07..a45f7e77ef 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", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T01:11:41.662835+00:00", "command": "example", - "prompt_hash": "74563384c8358e0cfb994bd2b5e37a93a478a64c3e83a7cd1a63fa44246258d5", - "code_hash": "2a29489d565b0ebf5ef1d353f0070e806f9f990cd1eedc1c547abb983ddf93c0", - "example_hash": "9290bba1f27a21c2a8603065864fd059de6fde22151d6f0062e813a48b91061a", - "test_hash": "40ab1539859560e07acc3b4f5ac70ddeaa30e37333a04658c43c40593b18d55c", + "prompt_hash": "e37699eb75c15225902072fff779b454650befc9378a0be95bfeffc92c27ff79", + "code_hash": "743a47b8a779974079f0ae3bb77090e47b3bd87606ac73a58cbf71e8b645d52e", + "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_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", "context/load_prompt_template_example.py": "a1cd6619182c6c951f5856dda4070e202875a5884bbfab9cc191d24de2f4951f", - "context/preprocess_example.py": "e7082a4a9ef830048ff32ed774a3e74da1cf4662a22115e6bd5543e1b9cdbf43" + "context/preprocess_example.py": "e7082a4a9ef830048ff32ed774a3e74da1cf4662a22115e6bd5543e1b9cdbf43", + "context/agentic_bug_orchestrator_example.py": "23b545fc0f892e14ee9660902d3f7e04893f0b793246c1ea9d559656fd39ffe6", + "context/agentic_e2e_fix_orchestrator_example.py": "180cc2c4316b531586040d61fc5f82f1f605d5c5736fd23bad63a889ac0b7d1e", + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd" } } \ No newline at end of file diff --git a/.pdd/meta/agentic_e2e_fix_orchestrator_python.json b/.pdd/meta/agentic_e2e_fix_orchestrator_python.json index 3714c38a3a..bad3c0d228 100644 --- a/.pdd/meta/agentic_e2e_fix_orchestrator_python.json +++ b/.pdd/meta/agentic_e2e_fix_orchestrator_python.json @@ -1,23 +1,22 @@ { - "pdd_version": "0.0.257", - "timestamp": "2026-06-02T05:35:45.135425+00:00", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T01:11:41.923918+00:00", "command": "test", - "prompt_hash": "3db920baa54e4b2a67fee0dc24de08e26087b7f60ce9687b2fd7261b14f1f621", - "code_hash": "605779bd8be274f89d045972413cd2ce394cf4e6c808e24bfd7f4674fb10b17d", - "example_hash": "8cc5f11b0430113ad52c918cdbabafe254cf01667eb66e1ac8837f5cea965024", - "test_hash": "f15b0ccd990d7ab178700b28211c4e46f29f991500272c73583149309572607c", + "prompt_hash": "1f78ce54425088df0798f9117b4cd68c30fb2be29a10e498742a03f02fe3cb03", + "code_hash": "ad8df3444101a4ac3297db032f33b875c581c91fc615bc0f602a89b53113920e", + "example_hash": "180cc2c4316b531586040d61fc5f82f1f605d5c5736fd23bad63a889ac0b7d1e", + "test_hash": "b481eb0460268575f6816a13711049ca5df749871185df6759ece7d7bd5f6fcd", "test_files": { - "test_agentic_e2e_fix_orchestrator.py": "f15b0ccd990d7ab178700b28211c4e46f29f991500272c73583149309572607c", - "test_agentic_e2e_fix_orchestrator_resume_prompt.py": "8e29ea7eb6330f75fa53b2699663f417b43cb063b8ac5613f34cbf0a6ceaa917" + "test_agentic_e2e_fix_orchestrator.py": "b481eb0460268575f6816a13711049ca5df749871185df6759ece7d7bd5f6fcd" }, "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_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", - "context/agentic_test_orchestrator_example.py": "473901f6f62844c81a047fa69387c4116d91a522c62d5c6ad30188e2e99dfaa5", - "context/change/16/fix_error_loop.py": "01872cfcc6c3f5095551acd109e06b35ece96c15ca75dcf40ac395c3ed867e40", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/agentic_e2e_fix_orchestrator_example.py": "180cc2c4316b531586040d61fc5f82f1f605d5c5736fd23bad63a889ac0b7d1e", "context/ci_validation_example.py": "06b910b216f68af58862e76712ccf82bae1bdf87f3fa95b043408fb079f45ce0", - "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254" + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", + "context/agentic_test_orchestrator_example.py": "473901f6f62844c81a047fa69387c4116d91a522c62d5c6ad30188e2e99dfaa5", + "context/agentic_bug_orchestrator_example.py": "23b545fc0f892e14ee9660902d3f7e04893f0b793246c1ea9d559656fd39ffe6", + "context/change/16/fix_error_loop.py": "01872cfcc6c3f5095551acd109e06b35ece96c15ca75dcf40ac395c3ed867e40" } } \ 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 deleted file mode 100644 index 10d3b69964..0000000000 --- a/.pdd/meta/agentic_e2e_fix_orchestrator_python_run.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "timestamp": "2026-06-02T05:34:03.811429+00:00", - "exit_code": 0, - "tests_passed": 420, - "tests_failed": 0, - "coverage": 87.0, - "test_hash": "f15b0ccd990d7ab178700b28211c4e46f29f991500272c73583149309572607c", - "test_files": { - "test_agentic_e2e_fix_orchestrator.py": "f15b0ccd990d7ab178700b28211c4e46f29f991500272c73583149309572607c", - "test_agentic_e2e_fix_orchestrator_resume_prompt.py": "8e29ea7eb6330f75fa53b2699663f417b43cb063b8ac5613f34cbf0a6ceaa917" - } -} \ No newline at end of file diff --git a/.pdd/meta/agentic_sync_runner_python.json b/.pdd/meta/agentic_sync_runner_python.json index 9a568d6680..21a5adfb48 100644 --- a/.pdd/meta/agentic_sync_runner_python.json +++ b/.pdd/meta/agentic_sync_runner_python.json @@ -1,21 +1,21 @@ { - "pdd_version": "0.0.275.dev3", - "timestamp": "2026-06-16T01:31:18.161464+00:00", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T01:11:42.192743+00:00", "command": "fix", - "prompt_hash": "0784d6ff7f749b74e6938bfdd945a1cf97aed961ebea4bf3849fe512bf7163f9", - "code_hash": "587d51aaed8470de39bbefbb0882f481f269431cca8b50e45b22e90dbd2eb695", + "prompt_hash": "40f1328ebdf05c2606e0623817a8f07054832b68a3cdaa32c850fa8156174823", + "code_hash": "572f865d1bdbddf4827d5f308eb72030c3ed085c4f570284cbda8c961d01af2d", "example_hash": "fcb36209abb5ab882ad551754fb574d62509c968f8c564e18163be7f2b7ef35a", - "test_hash": "781deb083113a3c96b990501f24cdf41e0742190be5cfbe85553639c25e62e22", + "test_hash": "daf57f3bb02e467053caf2fa5ac5b8d11cdad4db74016ee521d6e6f64c283621", "test_files": { - "test_agentic_sync_runner.py": "781deb083113a3c96b990501f24cdf41e0742190be5cfbe85553639c25e62e22" + "test_agentic_sync_runner.py": "daf57f3bb02e467053caf2fa5ac5b8d11cdad4db74016ee521d6e6f64c283621" }, "include_deps": { + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", "context/agentic_change_example.py": "a8308b2af708c804d626a4a96012c5a67f5bc6469e33857cafc07627213828d7", - "context/agentic_common_example.py": "f9cdb30a932973d341b795faf510056b95573610af5ac48e3849cf42c205998e", - "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", - "context/agentic_test_orchestrator_example.py": "473901f6f62844c81a047fa69387c4116d91a522c62d5c6ad30188e2e99dfaa5", + "context/sync_order_example.py": "b4eb1e167e7604c7dbac703bdcba7287188fa9d2d5ef6d490216d003cdc542fa", + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", "context/architecture_sync_example.py": "b6158dbc7ca3fd32665528562225f6739ec521a071d845acab0f7caaea221ae0", - "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", - "context/sync_order_example.py": "b4eb1e167e7604c7dbac703bdcba7287188fa9d2d5ef6d490216d003cdc542fa" + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", + "context/agentic_test_orchestrator_example.py": "473901f6f62844c81a047fa69387c4116d91a522c62d5c6ad30188e2e99dfaa5" } -} +} \ No newline at end of file diff --git a/.pdd/meta/agentic_sync_runner_python_run.json b/.pdd/meta/agentic_sync_runner_python_run.json deleted file mode 100644 index 7ebd1c4535..0000000000 --- a/.pdd/meta/agentic_sync_runner_python_run.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "timestamp": "2026-06-16T01:31:18.161464+00:00", - "exit_code": 0, - "tests_passed": 180, - "tests_failed": 0, - "coverage": 81.25, - "test_hash": "781deb083113a3c96b990501f24cdf41e0742190be5cfbe85553639c25e62e22", - "test_files": { - "test_agentic_sync_runner.py": "781deb083113a3c96b990501f24cdf41e0742190be5cfbe85553639c25e62e22" - } -} diff --git a/.pdd/meta/ci_drift_heal_python.json b/.pdd/meta/ci_drift_heal_python.json index 40ec37cd07..e2662bbfb0 100644 --- a/.pdd/meta/ci_drift_heal_python.json +++ b/.pdd/meta/ci_drift_heal_python.json @@ -1,24 +1,23 @@ { - "pdd_version": "0.0.296.dev0", - "timestamp": "2026-07-10T19:43:29.185846+00:00", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T01:11:42.447069+00:00", "command": "fix", - "prompt_hash": "26d9fef09114b1da971d431c3f7876b923c4086199f9d55522aedfa33b9392be", - "code_hash": "680193331b1f6e73d1cfe40b88f06989db27ca1d4324faa2f9e781db3b08fc36", + "prompt_hash": "1509583fe60b71dcc6fae64b2d7775ec639f194dcaffabcc416a3d680c325d6f", + "code_hash": "4490d3a8abe483a57b0f498dc554df0a96a38f6c6ff25471934429649888b507", "example_hash": "550e750fb041fb73dc92462d4f266bc999102ddd8e4efd3b40dbab0bb1d0d879", - "test_hash": "03ea52b7e66a6a3ec720417bc19e05ff577d76dc8a381e9f8f83552f6d8ce6f2", + "test_hash": "4684c878ee273e205b65d4796a5d0657c5a0c8acda5bfd4d90b78c48b08d4c50", "test_files": { - "test_ci_drift_heal.py": "03ea52b7e66a6a3ec720417bc19e05ff577d76dc8a381e9f8f83552f6d8ce6f2", - "test_ci_drift_heal_e2e.py": "dfcd225cc5a6442ba11e9cd41b79568a57d97b7804ee6ea66f4191aa068478e3" + "test_ci_drift_heal.py": "4684c878ee273e205b65d4796a5d0657c5a0c8acda5bfd4d90b78c48b08d4c50" }, "include_deps": { - "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", - "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/sync_determine_operation_example.py": "225d20ea0210bbf52650a9f424879ff495e574dd2411eb4b86592a6d06cf59a5", + "context/operation_log_example.py": "2660def7012d9b79bad3db33cf399ac603cc1c35dff0b064cbdd2638a013de1d", "context/agentic_sync_runner_example.py": "fcb36209abb5ab882ad551754fb574d62509c968f8c564e18163be7f2b7ef35a", - "context/architecture_sync_example.py": "b6158dbc7ca3fd32665528562225f6739ec521a071d845acab0f7caaea221ae0", + "context/agentic_common_example.py": "c1538a115a3a33da228e7f6cc048f2a99023cd2c83a0efef382e49c477b24ad3", "context/auto_include_example.py": "0c278ed716298149777bc7082827eb8aa6d80dac264c66b6cded6871232f2df7", - "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/architecture_sync_example.py": "b6158dbc7ca3fd32665528562225f6739ec521a071d845acab0f7caaea221ae0", + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", + "context/metadata_sync_example.py": "704453b3386724eef225b61c48f2fe922e1a973ac211c494e1d955dd44bcca11" } } \ 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 deleted file mode 100644 index 844d8196a5..0000000000 --- a/.pdd/meta/ci_drift_heal_python_run.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "timestamp": "2026-07-09T11:33:33.405832+00:00", - "exit_code": 0, - "tests_passed": 195, - "tests_failed": 0, - "coverage": 83.0, - "test_hash": "04bbe525d057462cb9e669037e210629f850dce9a42b5c08032cbbbb642abdf1", - "test_files": { - "test_ci_drift_heal.py": "04bbe525d057462cb9e669037e210629f850dce9a42b5c08032cbbbb642abdf1", - "test_ci_drift_heal_e2e.py": "dfcd225cc5a6442ba11e9cd41b79568a57d97b7804ee6ea66f4191aa068478e3" - } -} \ 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 0f74e423fd..bfbeb214cf 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", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T01:11:42.700948+00:00", "command": "example", - "prompt_hash": "9c646810b3e55fa6ba5c0bb1d8210e459446ffa6826c565d7012ea6bd55fb6d5", - "code_hash": "1293a25723fdcb7870711b4759248fe4681d5107cf2106feff61df57cb56ee96", - "example_hash": "25bb0d4b5fe4f31d0d4d3795d24e8e9bb2cd346c74e4fc916d0a9ba8590141be", - "test_hash": "3cc05a05384f982254fdb38d8a156fc7507a1dfec200fc91705d01fbbfad134d", + "prompt_hash": "1d91c50006514a89625de866bfd7086b6772006678cf7292c664d30cfd772b2f", + "code_hash": "e83cc9bd8890d032fcf63001b83feb766d632ab08d00e20110e489e0386962b4", + "example_hash": "4b66f24a1e2ac4195255738566b0cccdac0971523a245d3a8b78ad45d9e1a872", + "test_hash": "5770dba6d2f38bfb7d8051e866bf622b43401b073ac8d97202fe25b4e805d231", "test_files": { - "test_code_generator_main.py": "3cc05a05384f982254fdb38d8a156fc7507a1dfec200fc91705d01fbbfad134d" + "test_code_generator_main.py": "5770dba6d2f38bfb7d8051e866bf622b43401b073ac8d97202fe25b4e805d231" }, "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/auth_service_example.py": "4b6d2c8c9e13d1edc0d9805ee0c07657494cbf31dffe2bb0029b72975674a8cf", - "context/auto_include_example.py": "0c278ed716298149777bc7082827eb8aa6d80dac264c66b6cded6871232f2df7", + "pdd/construct_paths.py": "8970fc0540a8fe14bef8572f73cd417feb013d682fa927948c6df0f6e5cf4f5f", + "pdd/preprocess.py": "3539ebbfbdae48f0c606451dd400879e010c539fa992aed52d9b30c3d4d18ac9", + "prompts/context_snapshot_python.prompt": "05f46fa9adba7ed3c9c6088d150ad821c723e9d0a3efed9db2fe8a461386da9d", "pdd/code_generator.py": "33235364b431edbb0fb1c4d8eb03fa21a3b776e07ea585530c19212da20c486d", - "pdd/code_generator_main.py": "1293a25723fdcb7870711b4759248fe4681d5107cf2106feff61df57cb56ee96", - "pdd/construct_paths.py": "832d478849bfe83c12d4a473c270902d0af0be577ae3276b07ef8a48c148d35f", + "pdd/incremental_code_generator.py": "8dd5dd7f3016b8fae86056484fdc49b824405cdcd6911e8b8657d3e043b3b06d", "pdd/core/cloud.py": "0487c0b989996af144df3af1c818a6eeafe52fd209710e5a54eb43990c89ae97", - "pdd/incremental_code_generator.py": "266a4bed6ea41519a1b1c3c33057cb33cffecd2a3075ee0c2f790798bfff4834", - "pdd/preprocess.py": "a6c86dd348d0028a0638114338013e4633fb66fa51f756bc30458f4dce833703", + "pdd/code_generator_main.py": "e83cc9bd8890d032fcf63001b83feb766d632ab08d00e20110e489e0386962b4", "pdd/python_env_detector.py": "cbe4044a83cd88a683f36d6ecfca245fef6a03ea2abc7f5c407636fccc051f35", - "prompts/context_snapshot_python.prompt": "d8e129b55b27814d16e7566f07dfdaae0cc6950352f351fd78cfeeab125a7cbf" + "context/architecture_sync_example.py": "b6158dbc7ca3fd32665528562225f6739ec521a071d845acab0f7caaea221ae0", + "context/auth_service_example.py": "4b6d2c8c9e13d1edc0d9805ee0c07657494cbf31dffe2bb0029b72975674a8cf", + "context/__init__example.py": "84208a445d03a336e465709ec0687eb969c8d865e6739bf8b79f2c8a8aad351a", + "context/auto_include_example.py": "0c278ed716298149777bc7082827eb8aa6d80dac264c66b6cded6871232f2df7", + "context/api_key_scanner_example.py": "4b9f880a30445f25edffc394857ae0c218097726c41ab996a03fcbc4350b5482", + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", + "context/_keyring_timeout_example.py": "41f83d5ef117caba86b788f69b519f7e88f3d7dc2a641a017d74a7cee9be6ed1" } } \ No newline at end of file diff --git a/.pdd/meta/fix_code_loop_python.json b/.pdd/meta/fix_code_loop_python.json index 9bac79604f..f9aade0153 100644 --- a/.pdd/meta/fix_code_loop_python.json +++ b/.pdd/meta/fix_code_loop_python.json @@ -1,19 +1,19 @@ { - "pdd_version": "0.0.228", - "timestamp": "2026-05-06T03:28:21.485952+00:00", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T01:11:42.959832+00:00", "command": "regenerate-public", - "prompt_hash": "947f0f0d054800b620cc2174bf96a8658fa4687190e9e4e50f23e39d1f91c1d0", + "prompt_hash": "97352e398ca0db96ec41f71b319b22b183ced0a8a293c7d050ae867f19e05ac3", "code_hash": "3bff0fb82111513ed5aa32c33bd80578c7178e871f818be1b01635b7a2b185b1", - "example_hash": null, + "example_hash": "c512f0f317474550a9ed74b5211ae0eab62c3f04115db14af67a88033aacfa87", "test_hash": "bc1f4f81af211623bcb2539cc4761f7e5ccb6926aebf86cc6aefa3b152557400", "test_files": { "test_fix_code_loop.py": "bc1f4f81af211623bcb2539cc4761f7e5ccb6926aebf86cc6aefa3b152557400" }, "include_deps": { "context/agentic_crash_example.py": "4a79b152db522063ccf099eca0419b419a656f168839d197f85781ae4fe1b5f6", - "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", - "context/fix_code_module_errors_example.py": "f35244d6e920711044fee92ade984f3e820a0fbfe766fd3d49718cbcfe793e60", + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", "context/get_language_example.py": "8b09af25b96fa49e2dcf5ff307bbeb0bc83e84b64618971d393397772a2fafab", - "context/python_preamble.prompt": "57a3e51f529024ec0cb9658cd6ac61a7c8051ba0c8e887b31cf00b2e78a07d83" + "context/fix_code_module_errors_example.py": "f35244d6e920711044fee92ade984f3e820a0fbfe766fd3d49718cbcfe793e60", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254" } } \ No newline at end of file diff --git a/.pdd/meta/fix_error_loop_python.json b/.pdd/meta/fix_error_loop_python.json index 1cd791ee0e..135e12e207 100644 --- a/.pdd/meta/fix_error_loop_python.json +++ b/.pdd/meta/fix_error_loop_python.json @@ -1,11 +1,25 @@ { - "pdd_version": "0.0.257", - "timestamp": "2026-06-02T05:32:50.786435+00:00", - "command": "example", - "prompt_hash": null, - "code_hash": null, - "example_hash": null, - "test_hash": null, - "test_files": null, - "include_deps": null + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T19:55:34.170582+00:00", + "command": "test", + "prompt_hash": "92a4afb36317d4ef367b810cd43c2a031c49ce0ac2ea15199e5fc052d16192ac", + "code_hash": "18a12faeed802501abd18328edbb8faff3b3f533e6a229b7f7f21161fbd634b5", + "example_hash": "a86435e82357bc671ac6dadee6df22c163d27a30cbdba102e9cbe3bc3121fc43", + "test_hash": "6190aeff914edf5b7929874866480d44a2805956a2917afaa1fd14975c5606ae", + "test_files": { + "test_fix_error_loop.py": "6190aeff914edf5b7929874866480d44a2805956a2917afaa1fd14975c5606ae" + }, + "include_deps": { + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "context/get_language_example.py": "8b09af25b96fa49e2dcf5ff307bbeb0bc83e84b64618971d393397772a2fafab", + "context/fix_errors_from_unit_tests_example.py": "df6c93b3ae585af13827cf3edb398e7c0da75648b34ff0477044b659df85a117", + "context/core/cloud_example.py": "8a14ae9e69dbccfbbfffd9bde1ea11192334a10aaf9d5b5e6dba5ac23ba0dd90", + "context/agentic_fix_example.py": "407a024fcba9402e00b401477725ae72b1de718ab59bea29b69509a0b15f621f", + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", + "context/pytest_example.py": "f13c02d911d386ac5bab8e3d4aae64a768e4a7e960803c80fcac14e9ba5975cb", + "context/python_env_detector_example.py": "65619b3e8bad0509f1ccf54d81b5c3760f423b6fc1925c932fc7968da78a172e", + "pdd/failure_classification.py": "0675857d23f52e447f15ace22ec37c2f003a61d3c810254c8547129c29d697ff", + "pdd/fix_focus.py": "e341e6bda871ddc5ba92cf05e9d554d49bc5e4afebf5b4f1482971edf557063d", + "pdd/test_context_packer.py": "27c7d14747d38df8edf24c8ef2ce1b7ca21d8f2eea38aec02ba06042ee144c0b" + } } \ No newline at end of file diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 3bab6eeb9f..8fc4434219 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,17 +1,19 @@ { - "pdd_version": "0.0.228", - "timestamp": "2026-05-06T03:28:21.593685+00:00", - "command": "regenerate-public", - "prompt_hash": "a8e7effb43b26e42006d3d2febfc38e0da7c1d53221ac4068d89b6d0c08ecfb5", - "code_hash": "a1e871896a289049c73df4071ec48fc47792ff6cab05332816ba702cd3bc1cb6", - "example_hash": null, - "test_hash": "5d759cb5c4491420c4ee01d6973f6d0493d02cfe1cdb0bfaf8cb540a868bc365", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T20:00:39.902385+00:00", + "command": "test", + "prompt_hash": "7a85c70f02ee81d2911d8868adb32d2f0f344102db5ec14d0e1cf286946259f2", + "code_hash": "b5e38e9fc9597df7b8cd7502603b58ea8b90821a25067688f0499014c6fa01ab", + "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", + "test_hash": "207d679a82b06b1b28b0c19cebd9dabe81a21ca58c007486d5d29448c587574f", "test_files": { - "test_get_test_command.py": "5d759cb5c4491420c4ee01d6973f6d0493d02cfe1cdb0bfaf8cb540a868bc365" + "test_get_test_command.py": "207d679a82b06b1b28b0c19cebd9dabe81a21ca58c007486d5d29448c587574f" }, "include_deps": { - "pdd/agentic_langtest.py": "7cb0be38d526d0a630500185427a9f76666d2b79385f7333a7608eb1fb33cfc9", - "pdd/data/language_format.csv": "db3542c10a4c5c2a4a9bbe02558b4bd02fbb357fb243e33de4831d337484373e", - "pdd/get_language.py": "65004add40c93365c1cbd705a6ef24d5bda6f7016c3a67749b25e61122c50ce7" + "pdd/agentic_langtest.py": "441f56ac5f74f56b67ee01d66f0f2cea38eaa0648461513ab45d6be872432372", + "pdd/get_language.py": "1490ea5281a086896be3a3f20cc5b216cd79c7974bcf8a6bbd13129656dd8080", + "pdd/data/language_format.csv": "691a4e14b76c176269e34dbbf03167c8fa17b76be9212e4546750b0a80315ff9", + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", + "context/get_run_command_example.py": "f377edf4ff0fe0e1f6a2777fb7730d0876c09de483a4c0d16a4ad064ddd585ff" } } \ 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 7445a7bb1d..f8684b4ace 100644 --- a/.pdd/meta/sync_determine_operation_python.json +++ b/.pdd/meta/sync_determine_operation_python.json @@ -1,21 +1,21 @@ { - "pdd_version": "0.0.296.dev0", - "timestamp": "2026-07-09T09:54:54.515165+00:00", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T01:11:43.728832+00:00", "command": "fix", - "prompt_hash": "67066038dc835de0304bdf437277088994f8293b858343af8a8c1ba00b7c86a2", - "code_hash": "e616ac746674a9f1e8be84bf3093d5e1f45ff0175bc3767b758f2d17b2c54132", + "prompt_hash": "6fe83dc2cfc25a26a0a1eccb963dc716b2e1b0f9d4389043ff0ed26ab3740ecd", + "code_hash": "e9dbfc88588fec6a64149c3b56baad118696e6066dff3931f0ade3b1ce8e1faf", "example_hash": "225d20ea0210bbf52650a9f424879ff495e574dd2411eb4b86592a6d06cf59a5", - "test_hash": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d", + "test_hash": "0dcb0a91e7d8b4bf7394ccd6307557e995c524bb930b207dede34f5bf392db79", "test_files": { - "test_sync_determine_operation.py": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d" + "test_sync_determine_operation.py": "0dcb0a91e7d8b4bf7394ccd6307557e995c524bb930b207dede34f5bf392db79" }, "include_deps": { - "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", "context/construct_paths_example.py": "412cb260650bcbd628fba647db917a8d32f8191df2e1c32cc22f22c0c11b1740", - "context/get_language_example.py": "8b09af25b96fa49e2dcf5ff307bbeb0bc83e84b64618971d393397772a2fafab", "context/load_prompt_template_example.py": "a1cd6619182c6c951f5856dda4070e202875a5884bbfab9cc191d24de2f4951f", "pdd/llm_invoke.py": "2c423dbe12dd22d1acfca6c658bfef36eaf3b016626ae4a2845576500bfcb54b", + "context/get_language_example.py": "8b09af25b96fa49e2dcf5ff307bbeb0bc83e84b64618971d393397772a2fafab", "pdd/template_expander.py": "bcaa670f334b9fa7726aa85906e70125ad2cd4b7e7b62a8e39f8bdd06c975edb", - "prompts/sync_analysis_LLM.prompt": "b54ff4325be64b9393db33026a576468bfa45f6c7aa736526b7bae2872f0520d" + "prompts/sync_analysis_LLM.prompt": "b54ff4325be64b9393db33026a576468bfa45f6c7aa736526b7bae2872f0520d", + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd" } } \ No newline at end of file diff --git a/.pdd/meta/sync_determine_operation_python_run.json b/.pdd/meta/sync_determine_operation_python_run.json deleted file mode 100644 index bb380c3d84..0000000000 --- a/.pdd/meta/sync_determine_operation_python_run.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "timestamp": "2026-07-09T09:54:54.515165+00:00", - "exit_code": 0, - "tests_passed": 1, - "tests_failed": 0, - "coverage": 100.0, - "test_hash": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d", - "test_files": { - "test_sync_determine_operation.py": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d" - } -} diff --git a/.pdd/meta/sync_orchestration_python.json b/.pdd/meta/sync_orchestration_python.json index 5b52c7dd61..72aa5e64d1 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", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T01:11:43.992108+00:00", "command": "fix", - "prompt_hash": "b9b900ce1b0cf9e50b73c4aad750e2231f632beda3c286c0469f21c0dc3a8475", - "code_hash": "7ef19b3e7eafd7abd0f54c9de74b7c4d8be5559d84b2673f1c60cb9313d4842e", + "prompt_hash": "d90125cb45f8f541419a3056b1e51292eba58afd151c0111f871884c70906f85", + "code_hash": "29be39a8cd919b0717d7a7afaa4e97b5a807e20d28b45a16870a0aa7f6c2b1ac", "example_hash": "cc1313a948946026d418c85a07e5bf0eedeb5915c44d630440e489979755e1f7", - "test_hash": "3fce96bc02b851cc3e2d00001b0de3a2436f687c65a53d5e423212ac5c6d2517", + "test_hash": "7ae879410fc4ee8eb32b4df3d49fdc1c6b9dc265f479b146709c81ec3275edb1", "test_files": { - "test_sync_orchestration.py": "3fce96bc02b851cc3e2d00001b0de3a2436f687c65a53d5e423212ac5c6d2517" + "test_sync_orchestration.py": "7ae879410fc4ee8eb32b4df3d49fdc1c6b9dc265f479b146709c81ec3275edb1" }, "include_deps": { - "docs/whitepaper.md": "25b87d4af53ba35e7fa80b12f6bf09d12e16b8b952341df94cf8b47160886adf", - "README.md": "b572bb7bf64f3d9473845a45b8c942fc66b527bc516c960059bc538e574ae4bc", + "README.md": "e3e643c358f518e85a567df61fe87fe9993218a5223a5e34526fa4eb755e267e", + "docs/whitepaper.md": "3347db03fa35376024b59d8546efdf0858655746490561b88b710fa6b85017cc", + "context/operation_log_example.py": "2660def7012d9b79bad3db33cf399ac603cc1c35dff0b064cbdd2638a013de1d", + "context/sync_determine_operation_example.py": "225d20ea0210bbf52650a9f424879ff495e574dd2411eb4b86592a6d06cf59a5", "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/cmd_test_main_example.py": "0774984ee29f70407429f1eff4a1b76287715b7ddd6adf2bc149e874c9b844bb", + "context/fix_main_example.py": "29f88d8bfcc2256ebebe6efcb611f9487eb5ca6d8781286a6dd98689f92bca7a", + "context/update_main_example.py": "9fe01fd19196c2604c91d101e49db849e0e1c9fabafeb2ad831a9c91b2f26416", "context/get_test_command_example.py": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "context/operation_log_example.py": "6cd7962ab255d6080aeb97897175250f987ce936cf7333e7601f10834405a940", - "context/pytest_output_example.py": "6f0cd68e3c4440081267c716651caec4fbdd7d9c4516f967517f02ae2a853920", + "context/get_extension_example.py": "def9c34c1416259a0a031a996b96aeff5e73a7012adff6af1cc557e74ba97cc8", "context/python_env_detector_example.py": "65619b3e8bad0509f1ccf54d81b5c3760f423b6fc1925c932fc7968da78a172e", - "context/sync_determine_operation_example.py": "94a333b37d9875bcc9b39e22d4d00de1b196ad2f7e0fa997e6f638e153dbdfad", - "context/update_main_example.py": "b318834c648f25e6141d81f3ecac1d9da592a6bb4740500983f2b07b8a10d902" + "pdd/get_run_command.py": "ad00dff52bdb959a8e0aece0b4e426539d79c6ffea5e69cfd1900574647550c5", + "pdd/pytest_output.py": "652a27dd1e08769611abe3cb6a83f372c450279d6bf1b676741b9b7186665975", + "pdd/compressed_sync_context.py": "661fde0fcc42ce39c1004e47a774a8fd799b789065519cb5688949c35fed4859" } } \ No newline at end of file diff --git a/.pdd/meta/sync_orchestration_python_run.json b/.pdd/meta/sync_orchestration_python_run.json deleted file mode 100644 index 21a1df2d31..0000000000 --- a/.pdd/meta/sync_orchestration_python_run.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "timestamp": "2026-05-12T02:53:56.363281+00:00", - "exit_code": 0, - "tests_passed": 1, - "tests_failed": 0, - "coverage": 100.0, - "test_hash": "3fce96bc02b851cc3e2d00001b0de3a2436f687c65a53d5e423212ac5c6d2517", - "test_files": { - "test_sync_orchestration.py": "3fce96bc02b851cc3e2d00001b0de3a2436f687c65a53d5e423212ac5c6d2517" - } -} diff --git a/.pdd/sync-ownership.json b/.pdd/sync-ownership.json index 8010ee6c36..a3ec40f892 100644 --- a/.pdd/sync-ownership.json +++ b/.pdd/sync-ownership.json @@ -13585,6 +13585,12 @@ "pattern": "user_stories/contracts/pdd_issue_routing_source_truth.contract.md", "role": "human-maintained" }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": "user_stories/contracts/pdd_nested_ts_runner_resolution.contract.md", + "role": "human-maintained" + }, { "inventory": "HUMAN_OWNED", "owner": "pdd-maintainers", @@ -13789,6 +13795,12 @@ "pattern": "user_stories/story__pdd_issue_routing_source_truth.md", "role": "human-maintained" }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": "user_stories/story__pdd_nested_ts_runner_resolution.md", + "role": "human-maintained" + }, { "inventory": "HUMAN_OWNED", "owner": "pdd-maintainers", diff --git a/.pdd/verification-profile-rotations.json b/.pdd/verification-profile-rotations.json index 2eb7e2f5dd..6b8341dd57 100644 --- a/.pdd/verification-profile-rotations.json +++ b/.pdd/verification-profile-rotations.json @@ -17,6 +17,132 @@ "policy_path": ".pdd/verification-profiles.json", "from_policy_sha256": "ffd867088a7c9a92840130ffd9db9eb8f279e611a02afe501d02855ebb03930f", "to_policy_sha256": "8a957dfa94fdc78ec9d1eb5ea6dfb0a08ff2452928a8b9f6a4dbd5368cb25f53" + }, + { + "prompt_path": "pdd/prompts/agentic_fix_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:04356924ac0c4dc03a76aedb1bc165d72d4bb85a040da93d5d1251ad215dbb11", + "to_requirement_id": "CONTRACT-SHA256:78e4d685c29cac5f8b6155c53495e2bfefb6ac066850d53c7ec865a61b752457", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "92a006d31417892681e032678e9885a10802db3825b967a66e4d34361cb25f89", + "to_policy_sha256": "9df613d88a1b89667b13781eee974a6f13f6fc31428898d473eb8f43d1aa945c" + }, + { + "prompt_path": "pdd/prompts/agentic_langtest_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:84ff51a86adeffa37ba3a860315933037e7edf9029303166a5fe3a76caaca252", + "to_requirement_id": "CONTRACT-SHA256:1ada3c0ba9faaa60875f4f311d8d120a6804b2bbca9a2591f6d4439712fb3b3c", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "9df613d88a1b89667b13781eee974a6f13f6fc31428898d473eb8f43d1aa945c", + "to_policy_sha256": "7fb62ab0fc9fa33dbbd67c2ec10680981307f325ce5f8f12b802239a39b1e1d0" + }, + { + "prompt_path": "pdd/prompts/fix_error_loop_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:afffd825b4495819b853fec9a86b0be7644f6fe0468d40548d8b9b2803d183ce", + "to_requirement_id": "CONTRACT-SHA256:d3df2e3b68eb9b322582ebaec7788a18aec53bf34a57e308c86b1e946fa73830", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "7fb62ab0fc9fa33dbbd67c2ec10680981307f325ce5f8f12b802239a39b1e1d0", + "to_policy_sha256": "e06977a4149af8c7043edc6e12cb1bf9a37fdd87b3d1c9cf114b03be2ac9f7a3" + }, + { + "prompt_path": "pdd/prompts/get_run_command_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:3f79d0e73ad298c71002614e953cd9b4749a72cbf28842c5dc90f988742b6ce9", + "to_requirement_id": "CONTRACT-SHA256:883fd53ab20b3c6d2daeb55390c37ea9e31437d5056c58565f5c25ccd51db274", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "e06977a4149af8c7043edc6e12cb1bf9a37fdd87b3d1c9cf114b03be2ac9f7a3", + "to_policy_sha256": "8d9fa821ba4500c597ecb5ff6e72269ada59176e5d438c00558cd63e3d9fae43" + }, + { + "prompt_path": "pdd/prompts/get_test_command_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:ef559f5558fb627aa53f078cba0eaae221a7af9a2c6bdadf580a4cb12bf217b7", + "to_requirement_id": "CONTRACT-SHA256:27e54b54a8f191560d725c84836f043244769d452484bbfbc4e837c53dcee5d2", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "8d9fa821ba4500c597ecb5ff6e72269ada59176e5d438c00558cd63e3d9fae43", + "to_policy_sha256": "432212888b12c24a6ee153b416a4a48e1ac561a2f1ef3dcc9b9726be153bb5a0" + }, + { + "prompt_path": "pdd/prompts/agentic_fix_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:78e4d685c29cac5f8b6155c53495e2bfefb6ac066850d53c7ec865a61b752457", + "to_requirement_id": "CONTRACT-SHA256:2258b83cdd6a0ca474b418e51a6c4dc9b7557b706dc88defaf69e3237df9fa84", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "432212888b12c24a6ee153b416a4a48e1ac561a2f1ef3dcc9b9726be153bb5a0", + "to_policy_sha256": "f56827cef3cde6a499e09ac102ff6efd8c8b13ec7018f4089376c471bdab7cf4" + }, + { + "prompt_path": "pdd/prompts/get_test_command_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:27e54b54a8f191560d725c84836f043244769d452484bbfbc4e837c53dcee5d2", + "to_requirement_id": "CONTRACT-SHA256:858ca20048acc4ef7013c220aeac8e8b2db0102056f477945f3cb06b8d7a8a88", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "f56827cef3cde6a499e09ac102ff6efd8c8b13ec7018f4089376c471bdab7cf4", + "to_policy_sha256": "249f75b3148954ba5cfacfefa0ae5b867ffffd3d1968dbc04ec9ca5544fd6bd4" + }, + { + "prompt_path": "pdd/prompts/agentic_langtest_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:1ada3c0ba9faaa60875f4f311d8d120a6804b2bbca9a2591f6d4439712fb3b3c", + "to_requirement_id": "CONTRACT-SHA256:4e4e83bfb6a6fd4911bd83a75423a869e111faf52ca340c30497904ba23e1049", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "249f75b3148954ba5cfacfefa0ae5b867ffffd3d1968dbc04ec9ca5544fd6bd4", + "to_policy_sha256": "c374508dd9d29b9424f079bac74ea1b8e51671158a9ba83539db48f63e111c4d" + }, + { + "prompt_path": "pdd/prompts/get_test_command_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:858ca20048acc4ef7013c220aeac8e8b2db0102056f477945f3cb06b8d7a8a88", + "to_requirement_id": "CONTRACT-SHA256:24fb1ab5a7a492f0d295d3b752ed4b4e045cf68081c9d1b4417b4b9554b3db9a", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "c374508dd9d29b9424f079bac74ea1b8e51671158a9ba83539db48f63e111c4d", + "to_policy_sha256": "d4bca92fd42d68d6c19be071332653051e7f0eaaa7f4861c756bfc119dd0a8e9" + }, + { + "prompt_path": "pdd/prompts/agentic_fix_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:2258b83cdd6a0ca474b418e51a6c4dc9b7557b706dc88defaf69e3237df9fa84", + "to_requirement_id": "CONTRACT-SHA256:b8bd677c8754f0922de48076cc42b06d4d57d5f78e5cdbd3ecdb008df899cc94", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "d4bca92fd42d68d6c19be071332653051e7f0eaaa7f4861c756bfc119dd0a8e9", + "to_policy_sha256": "9024f42d0bd49288b3096c1ba9dff7e7a8380bfc0bf162a3796079cd3a53e7fb" + }, + { + "prompt_path": "pdd/prompts/agentic_langtest_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:4e4e83bfb6a6fd4911bd83a75423a869e111faf52ca340c30497904ba23e1049", + "to_requirement_id": "CONTRACT-SHA256:663ed3b48635d512296716fc2722ab1ad21b465f4ac5d0c756ff5bba7a961210", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "9024f42d0bd49288b3096c1ba9dff7e7a8380bfc0bf162a3796079cd3a53e7fb", + "to_policy_sha256": "05cffe8f457df8cff7ab233fd69e2ccd6d9a3f96f91bdb718ae32d3f16c30197" + }, + { + "prompt_path": "pdd/prompts/fix_error_loop_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:d3df2e3b68eb9b322582ebaec7788a18aec53bf34a57e308c86b1e946fa73830", + "to_requirement_id": "CONTRACT-SHA256:0f19c51548cbbd0c226c0692251360abaab9c6f726d605660dd0b37617c68cb6", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "05cffe8f457df8cff7ab233fd69e2ccd6d9a3f96f91bdb718ae32d3f16c30197", + "to_policy_sha256": "99e8715f4d885bc352281cfc3af4862e7251eb49a4f0ca6793e10e4d4e6d72be" + }, + { + "prompt_path": "pdd/prompts/get_run_command_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:883fd53ab20b3c6d2daeb55390c37ea9e31437d5056c58565f5c25ccd51db274", + "to_requirement_id": "CONTRACT-SHA256:86024b1b169dfa721ba340f7113f78349bccdeb7286cae902ced4db8f48c988c", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "99e8715f4d885bc352281cfc3af4862e7251eb49a4f0ca6793e10e4d4e6d72be", + "to_policy_sha256": "228e4a82f42ab24a17bf1f223356946d3dea0e5a327a7435a242cdf77d46b765" + }, + { + "prompt_path": "pdd/prompts/get_test_command_python.prompt", + "language_id": "python", + "from_requirement_id": "CONTRACT-SHA256:24fb1ab5a7a492f0d295d3b752ed4b4e045cf68081c9d1b4417b4b9554b3db9a", + "to_requirement_id": "CONTRACT-SHA256:fb787e51f41432bb356b62dc4eb99212876c1491f912d7a6ea5bfc5b3bb82816", + "policy_path": ".pdd/verification-profiles.json", + "from_policy_sha256": "228e4a82f42ab24a17bf1f223356946d3dea0e5a327a7435a242cdf77d46b765", + "to_policy_sha256": "a28f24b53efe6d263a0c5d875d2c62e0705a5c70da4dbc83cdd47fb296cc0e92" } ] } diff --git a/.pdd/verification-profiles.json b/.pdd/verification-profiles.json index 0720f5bd37..08d432313b 100644 --- a/.pdd/verification-profiles.json +++ b/.pdd/verification-profiles.json @@ -1875,7 +1875,7 @@ "prompt_path": "pdd/prompts/agentic_fix_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:04356924ac0c4dc03a76aedb1bc165d72d4bb85a040da93d5d1251ad215dbb11" + "CONTRACT-SHA256:b8bd677c8754f0922de48076cc42b06d4d57d5f78e5cdbd3ecdb008df899cc94" ], "obligations": [ { @@ -1884,7 +1884,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:04356924ac0c4dc03a76aedb1bc165d72d4bb85a040da93d5d1251ad215dbb11" + "CONTRACT-SHA256:b8bd677c8754f0922de48076cc42b06d4d57d5f78e5cdbd3ecdb008df899cc94" ], "artifact_paths": [ "pdd/prompts/agentic_fix_python.prompt" @@ -1919,7 +1919,7 @@ "prompt_path": "pdd/prompts/agentic_langtest_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:84ff51a86adeffa37ba3a860315933037e7edf9029303166a5fe3a76caaca252" + "CONTRACT-SHA256:663ed3b48635d512296716fc2722ab1ad21b465f4ac5d0c756ff5bba7a961210" ], "obligations": [ { @@ -1928,7 +1928,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:84ff51a86adeffa37ba3a860315933037e7edf9029303166a5fe3a76caaca252" + "CONTRACT-SHA256:663ed3b48635d512296716fc2722ab1ad21b465f4ac5d0c756ff5bba7a961210" ], "artifact_paths": [ "pdd/prompts/agentic_langtest_python.prompt" @@ -5777,7 +5777,7 @@ "prompt_path": "pdd/prompts/fix_error_loop_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:afffd825b4495819b853fec9a86b0be7644f6fe0468d40548d8b9b2803d183ce" + "CONTRACT-SHA256:0f19c51548cbbd0c226c0692251360abaab9c6f726d605660dd0b37617c68cb6" ], "obligations": [ { @@ -5786,7 +5786,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:afffd825b4495819b853fec9a86b0be7644f6fe0468d40548d8b9b2803d183ce" + "CONTRACT-SHA256:0f19c51548cbbd0c226c0692251360abaab9c6f726d605660dd0b37617c68cb6" ], "artifact_paths": [ "pdd/prompts/fix_error_loop_python.prompt" @@ -7581,7 +7581,7 @@ "prompt_path": "pdd/prompts/get_run_command_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:3f79d0e73ad298c71002614e953cd9b4749a72cbf28842c5dc90f988742b6ce9" + "CONTRACT-SHA256:86024b1b169dfa721ba340f7113f78349bccdeb7286cae902ced4db8f48c988c" ], "obligations": [ { @@ -7590,7 +7590,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:3f79d0e73ad298c71002614e953cd9b4749a72cbf28842c5dc90f988742b6ce9" + "CONTRACT-SHA256:86024b1b169dfa721ba340f7113f78349bccdeb7286cae902ced4db8f48c988c" ], "artifact_paths": [ "pdd/prompts/get_run_command_python.prompt" @@ -7603,7 +7603,7 @@ "prompt_path": "pdd/prompts/get_test_command_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:ef559f5558fb627aa53f078cba0eaae221a7af9a2c6bdadf580a4cb12bf217b7" + "CONTRACT-SHA256:fb787e51f41432bb356b62dc4eb99212876c1491f912d7a6ea5bfc5b3bb82816" ], "obligations": [ { @@ -7612,7 +7612,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:ef559f5558fb627aa53f078cba0eaae221a7af9a2c6bdadf580a4cb12bf217b7" + "CONTRACT-SHA256:fb787e51f41432bb356b62dc4eb99212876c1491f912d7a6ea5bfc5b3bb82816" ], "artifact_paths": [ "pdd/prompts/get_test_command_python.prompt" diff --git a/architecture.json b/architecture.json index 01cce9d57b..b8232219c4 100644 --- a/architecture.json +++ b/architecture.json @@ -599,7 +599,9 @@ { "reason": "Generates and verifies language-specific tests.", "description": "Creates tests appropriate for the target language. Handles language-specific testing patterns.", - "dependencies": [], + "dependencies": [ + "get_run_command_python.prompt" + ], "priority": 10, "filename": "agentic_langtest_python.prompt", "filepath": "pdd/agentic_langtest.py", @@ -1673,7 +1675,9 @@ { "reason": "Resolves test runner command and working directory for a given test file across monorepos.", "description": "Provides the command to run tests in a given language with cwd detection for monorepos. Defaults to pytest for Python.", - "dependencies": [], + "dependencies": [ + "get_run_command_python.prompt" + ], "priority": 38, "filename": "get_test_command_python.prompt", "filepath": "pdd/get_test_command.py", diff --git a/context/agentic_langtest_example.py b/context/agentic_langtest_example.py index 942fa0424f..4d332e3814 100644 --- a/context/agentic_langtest_example.py +++ b/context/agentic_langtest_example.py @@ -1,10 +1,13 @@ # pdd/agentic_langtest.py from __future__ import annotations -import os +import csv +import shlex import shutil import sys from pathlib import Path +from pdd.get_run_command import shell_safe_substitute + def _which(cmd: str) -> bool: """Checks if a command-line tool exists in the system's PATH. @@ -45,55 +48,53 @@ def _find_project_root(start_path: str) -> Path: return Path(start_path).resolve().parent -def default_verify_cmd_for(lang: str, unit_test_file: str) -> str | None: - """Generates a default shell command to compile and run tests for a language. +def _run_test_command_for_language(lang: str) -> str: + """Return the `run_test_command` for `lang` from `language_format.csv`, or ''. - This function provides a conservative, best-effort command that should work - in common project setups. The command is designed to be run with `bash -lc`. + Keyed by the lowercase language *name* (e.g. "python", "javascript"). Returns + an empty string when the CSV is missing or the language has no command.""" + csv_path = Path(__file__).parent.parent / "pdd" / "data" / "language_format.csv" + if not csv_path.exists(): + return "" + with open(csv_path, newline="") as handle: + for row in csv.DictReader(handle): + if (row.get("language") or "").strip().lower() == lang: + return (row.get("run_test_command") or "").strip() + return "" - Args: - lang: The programming language (e.g., "python", "javascript", "java"). - unit_test_file: The absolute or relative path to the test file. - Returns: - A shell command string to execute the tests, or None if the language - is not supported or a build system cannot be detected. +def default_verify_cmd_for(lang: str, unit_test_file: str) -> str | None: + """Return a default shell command to run tests for `lang` and `unit_test_file`. + + Resolution order: + 1. `language_format.csv`'s `run_test_command` for the language, substituting the + (shell-quoted) test path for its `{file}` placeholder. + 2. Otherwise, for **Python**, a pytest command (interpreter and path both + shell-quoted). + 3. Otherwise `None`, so agentic mode handles test discovery/execution. + + The command is executed by callers with `bash -lc` / `shell=True`, so the test + path MUST be shell-quoted (`shlex.quote`) AND the `{file}` placeholder MUST be a + standalone bare word: `shlex.quote` wraps its value in single quotes, which only + neutralize metacharacters at a bare word — a CSV template that quotes the + placeholder (`mocha "{file}"`) would leave the single quotes literal and still + execute a `$(...)` in the path. `shell_safe_substitute` enforces this and returns + None for an unsafe template (fall through to the Python fallback / agentic mode). """ - test_rel = unit_test_file lang = lang.lower() - if lang == "python": - return f'{sys.executable} -m pytest "{test_rel}" -q' - - if lang == "javascript" or lang == "typescript": - example_dir = str(_find_project_root(unit_test_file)) - rel_test_path = os.path.relpath(unit_test_file, example_dir) - return ( - "set -e\n" - f'cd "{example_dir}" && ' - "command -v npm >/dev/null 2>&1 || { echo 'npm missing'; exit 127; } && " - "if [ -f package.json ]; then " - " npm install && npm test; " - "else " - f' echo "No package.json in {example_dir}; running test file directly"; ' - f' node -e "try {{ require(\'./{rel_test_path}\'); }} catch (e) {{ console.error(e); process.exit(1); }}"; ' - "fi" - ) - if lang == "java": - root_dir = str(_find_project_root(unit_test_file)) - # Prefer Maven if pom.xml is present - if "pom.xml" in os.listdir(root_dir): - return f"cd '{root_dir}' && mvn test" - # Gradle builds - elif "build.gradle" in os.listdir(root_dir) or "build.gradle.kts" in os.listdir(root_dir): - if "gradlew" in os.listdir(root_dir): - return f"cd '{root_dir}' && ./gradlew test" - else: - # Fixed incorrect quoting/backtick usage: use cd and run gradle - return f"cd '{root_dir}' && gradle test" - else: - return None + # 1. CSV lookup by language name. + csv_cmd = _run_test_command_for_language(lang) + if csv_cmd: + substituted = shell_safe_substitute(csv_cmd, {"{file}": unit_test_file}) + if substituted is not None: + return substituted + + # 2. Hardcoded Python fallback. + if lang == "python": + return f"{shlex.quote(sys.executable)} -m pytest {shlex.quote(unit_test_file)} -q" + # 3. No known command — agentic mode handles it. return None diff --git a/context/get_run_command_example.py b/context/get_run_command_example.py index 98977ca656..7342ef4999 100644 --- a/context/get_run_command_example.py +++ b/context/get_run_command_example.py @@ -7,14 +7,43 @@ The CSV file should have columns: - extension: The file extension (e.g., .py, .js) - - run_command: The command template with {file} placeholder (e.g., python {file}) + - run_command: A safe command template with a bare {file} placeholder + (e.g., python {file}) Prerequisites: - PDD_PATH environment variable must be set - language_format.csv must exist at $PDD_PATH/data/language_format.csv """ -from pdd.get_run_command import get_run_command, get_run_command_for_file +from pdd.get_run_command import ( + get_run_command, + get_run_command_for_file, + shell_safe_substitute, +) + + +def demonstrate_shell_safe_substitution() -> None: + """Show safe, single-pass path binding and fail-closed templates.""" + suspicious_path = "./output/report $(touch SHOULD_NOT_RUN).py" + safe_command = shell_safe_substitute( + "python {file}", {"{file}": suspicious_path} + ) + assert safe_command == "python './output/report $(touch SHOULD_NOT_RUN).py'" + + placeholder_in_value = "./output/{file}-literal.py" + single_pass = shell_safe_substitute( + "python {file}", {"{file}": placeholder_in_value} + ) + assert single_pass == "python './output/{file}-literal.py'" + + # A placeholder inside template quotes, or passed to a shell's -c code + # argument, is refused because a second shell parse could execute the value. + assert shell_safe_substitute( + 'python "{file}"', {"{file}": suspicious_path} + ) is None + assert shell_safe_substitute( + "bash -c {file}", {"{file}": suspicious_path} + ) is None def main(): @@ -60,8 +89,9 @@ def main(): # Example 3: Get complete run command for a specific file # ========================================================================== # Input: file_path (str) - Full or relative path to the file to run - # Output: str - Complete run command with {file} replaced by actual path - # Returns empty string if no run command available + # Output: str - Complete run command with the file path safely shell-quoted + # Returns empty string if no command is available or the configured + # template cannot bind the path safely print("Example 3: Get complete run command for a file") print("-" * 50) @@ -72,6 +102,8 @@ def main(): print(f"Complete run command: '{complete_command}'") # Expected output: "python ./output/my_script.py" or similar print() + + demonstrate_shell_safe_substitution() # ========================================================================== # Example 4: Handle unknown extension diff --git a/pdd/agentic_fix.py b/pdd/agentic_fix.py index 81b9f801d9..98bfddd869 100644 --- a/pdd/agentic_fix.py +++ b/pdd/agentic_fix.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shlex import shutil import subprocess import sys @@ -10,7 +11,7 @@ from rich.console import Console from .get_language import get_language -from .get_run_command import get_run_command_for_file +from .get_run_command import get_run_command_for_file, shell_safe_substitute from .llm_invoke import _load_model_data from .load_prompt_template import load_prompt_template from .agentic_langtest import default_verify_cmd_for @@ -116,18 +117,58 @@ def _run_testcmd(cmd: str, cwd: Path) -> bool: return proc.returncode == 0 -def _verify_and_log(unit_test_file: str, cwd: Path, *, verify_cmd: Optional[str], enabled: bool) -> bool: +def _substitute_verify_template(template: str, unit_test_file: str, + cwd: Path) -> Optional[str]: + """Substitute a verify-command TEMPLATE's ``{test}``/``{cwd}`` placeholders with + shell-quoted values via a shell-lexical-context-aware single pass (see + :func:`shell_safe_substitute`), or return ``None`` when the template is unsafe (a + placeholder inside quotes/backticks or not a standalone bare word — where + ``shlex.quote`` would not actually neutralize a ``$(...)`` in the path). + + Both ``{test}`` and ``{cwd}`` resolve to ABSOLUTE paths, anchored at the supplied + ``cwd`` (not the process CWD). A relative ``unit_test_file`` therefore targets the + same file ``run_agentic_fix`` resolved, and ``{cwd}`` is the same absolute + directory the caller runs the command in — so a template like + ``cd {cwd} && pytest {test}`` does not double-join a relative ``cwd`` onto the + already-relative process directory. Substitution is single-pass, so a value + containing a literal ``{cwd}``/``{test}`` is never rescanned as another placeholder. + """ + cwd_abs = Path(cwd).resolve() + test_abs = str((cwd_abs / unit_test_file).resolve()) + return shell_safe_substitute( + template, {"{test}": test_abs, "{cwd}": str(cwd_abs)}) + + +def _verify_and_log(unit_test_file: str, cwd: Path, *, verify_cmd: Optional[str], + enabled: bool, verify_cmd_is_template: bool = False) -> bool: """ Standard local verification gate: - If disabled, return True immediately (skip verification). - - If verify_cmd exists: format placeholders and run it via _run_testcmd. + - If verify_cmd exists: run it (see below). - Else: run the file directly using the appropriate interpreter for its language. Returns True iff the executed command exits 0. + + ``verify_cmd_is_template`` records the command's *provenance* explicitly (rather + than inferring it from ambient environment state): a template (from an explicit + caller arg or ``PDD_AGENTIC_VERIFY_CMD``) has intended ``{test}``/``{cwd}`` + placeholders and is substituted with SHELL-QUOTED values, whereas a *finalized* + command (e.g. from ``default_verify_cmd_for``) is run as-is — re-substituting it + would splice into its existing quoting and inject when the resolved path + contains a literal ``{test}``/``{cwd}`` plus shell metacharacters. """ if not enabled: return True if verify_cmd: - cmd = verify_cmd.replace("{test}", str(Path(unit_test_file).resolve())).replace("{cwd}", str(cwd)) + if verify_cmd_is_template: + cmd = _substitute_verify_template(verify_cmd, unit_test_file, cwd) + if cmd is None: + # Unsafe template (placeholder nested in quotes/token) → refuse to + # build an injectable command; the verification gate fails closed. + _info("Refusing verify-command template: {test}/{cwd} is not a " + "standalone shell word (quoting it would allow injection).") + return False + else: + cmd = verify_cmd return _run_testcmd(cmd, cwd) # Get language-appropriate run command from language_format.csv run_cmd = get_run_command_for_file(str(Path(unit_test_file).resolve())) @@ -290,9 +331,19 @@ def _is_useless_error_content(content: str) -> bool: if _is_useless_error_content(error_content): try: lang = get_language(os.path.splitext(code_path)[1]) - pre_cmd = os.getenv("PDD_AGENTIC_VERIFY_CMD") or default_verify_cmd_for(lang, unit_test_file) + env_tpl = os.getenv("PDD_AGENTIC_VERIFY_CMD") + # Only a user template's `{test}`/`{cwd}` placeholders are + # substituted (shell-quoted, and only when each is a standalone bare + # word — see _substitute_verify_template); an unsafe template is + # refused and we fall back to the finalized default command, which + # runs as-is so its own quoting is not corrupted (injection). + pre_cmd = None + if env_tpl: + pre_cmd = _substitute_verify_template( + env_tpl, unit_test_file, working_dir) + if pre_cmd is None: + pre_cmd = default_verify_cmd_for(lang, unit_test_file) if pre_cmd: - pre_cmd = pre_cmd.replace("{test}", str(Path(unit_test_file).resolve())).replace("{cwd}", str(working_dir)) pre = subprocess.run( ["bash", "-lc", pre_cmd], capture_output=True, text=True, check=False, @@ -328,10 +379,17 @@ def _is_useless_error_content(content: str) -> bool: env_verify = os.getenv("PDD_AGENTIC_VERIFY", None) verify_force = os.getenv("PDD_AGENTIC_VERIFY_FORCE", "0") == "1" + verify_cmd_is_template = False if is_python: + # Track command provenance explicitly: an explicit caller arg or the + # PDD_AGENTIC_VERIFY_CMD env var is a *template* (its {test}/{cwd} are + # substituted shell-quoted at execution); a default_verify_cmd_for + # result is *finalized* and run as-is. + verify_cmd_is_template = verify_cmd is not None if verify_cmd is None: verify_cmd = os.getenv("PDD_AGENTIC_VERIFY_CMD", None) + verify_cmd_is_template = verify_cmd is not None if verify_cmd is None: verify_cmd = default_verify_cmd_for(get_language(os.path.splitext(code_path)[1]), unit_test_file) @@ -420,7 +478,8 @@ def _is_useless_error_content(content: str) -> bool: if has_changes: if is_python: - ok = _verify_and_log(unit_test_file, working_dir, verify_cmd=verify_cmd, enabled=verify_enabled) + ok = _verify_and_log(unit_test_file, working_dir, verify_cmd=verify_cmd, + enabled=verify_enabled, verify_cmd_is_template=verify_cmd_is_template) else: # Non-Python: trust the agent's own result. # The agent already ran tests using language-appropriate tools internally. diff --git a/pdd/agentic_langtest.py b/pdd/agentic_langtest.py index 35a2936412..b5cde5493f 100644 --- a/pdd/agentic_langtest.py +++ b/pdd/agentic_langtest.py @@ -11,8 +11,11 @@ import csv import os +import shlex from pathlib import Path +from .get_run_command import shell_safe_substitute + def _load_language_format_by_name() -> dict: """Load language_format.csv into a dict keyed by lowercase language name.""" @@ -54,11 +57,23 @@ def default_verify_cmd_for(lang: str, unit_test_file: str) -> str | None: if lang in lang_formats: csv_cmd = lang_formats[lang].get('run_test_command', '').strip() if csv_cmd: - return csv_cmd.replace('{file}', unit_test_file) + # Shell-quote the substituted path via a shell-lexical-aware single pass: + # this command is executed with ``shell=True`` by pdd callers, so an + # unquoted path with metacharacters (``$()``/``;``) would be re-split or + # command-substituted. ``shlex.quote`` is only safe at a bare word, so a + # CSV template that quotes ``{file}`` is refused (None → fall through to + # the Python fallback / agentic mode) rather than made injectable. + substituted = shell_safe_substitute(csv_cmd, {'{file}': unit_test_file}) + if substituted is not None: + return substituted # 2. Hardcoded Python fallback if lang == "python": - return f'{os.sys.executable} -m pytest "{unit_test_file}" -q' + # ``shlex.quote`` (not bare double quotes): ``"$(...)"`` is still expanded + # by the shell inside double quotes, so double quotes do not stop injection. + # The interpreter path is quoted too, so a Python installed under a path + # with spaces (e.g. ``/opt/Python Env/bin/python``) does not re-split. + return f'{shlex.quote(os.sys.executable)} -m pytest {shlex.quote(unit_test_file)} -q' # 3. No command available — triggers agentic fallback return None diff --git a/pdd/fix_error_loop.py b/pdd/fix_error_loop.py index e6001168aa..6b712c5d57 100644 --- a/pdd/fix_error_loop.py +++ b/pdd/fix_error_loop.py @@ -2,7 +2,6 @@ import os import re -import shlex import shutil import subprocess import sys @@ -212,7 +211,14 @@ def _run_non_python_initial_verification(unit_test_file: str, code_file: str) -> else: command = str(test_command) cwd = None - command = command.replace("{file}", shlex.quote(unit_test_file)).replace("{test}", shlex.quote(unit_test_file)) + # NOTE: do NOT re-substitute ``{file}``/``{test}`` here. get_test_command_for_file + # already returns a fully-formed command with the (shell-quoted, absolute) test + # path embedded — CSV commands have their ``{file}`` placeholder resolved and TS + # runner commands embed the quoted path directly. Re-running .replace() would + # instead match those tokens if they appear *inside the resolved path* (e.g. a + # test file under a directory literally named ``{test}``), splicing an unbalanced + # quoted string into the middle of an already-quoted argument and breaking shell + # quoting — a command-injection vector via a maliciously named path. try: proc = subprocess.run( command, diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index 47bbf1b6a1..bdd1f51306 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -2,8 +2,210 @@ import os import csv +import re +import shlex +from typing import Dict, Optional from pdd.path_resolution import get_default_resolver +# Commands/forms that RE-EVALUATE an argument as code, undoing ``shlex.quote``'s single +# quoting: ``eval `` and a shell invoked with ``-c`` (``bash -c``/``sh -c``…). +_REEVAL_SHELLS = frozenset({"sh", "bash", "dash", "zsh", "ksh", "ash"}) + + +def _is_dash_c_option(tok: str) -> bool: + """True for a shell ``-c`` code option, INCLUDING a combined single-dash short-option + cluster that contains ``c`` (``-lc``, ``-xc``) — but not a long ``--`` option.""" + if tok.startswith("--"): + return False + return bool(re.match(r"^-[A-Za-z]*c[A-Za-z]*$", tok)) + + +def _feeds_value_into_reevaluation(template: str) -> bool: + """True when ``template`` would RE-EVALUATE an inserted value as code — so a + ``shlex.quote``-d ``$(...)`` in the value would still execute at the second parse. + The value is unsafe when a shell RE-EVALUATES it as code, whether it arrives as a + ``-c`` argument (``bash -c {file}``, ``bash -lc {file}``, ``timeout 5 bash -c`` — a + shell hidden behind an option-bearing wrapper) OR as STDIN piped into a shell + (``printf %s {file} | bash``) OR via a here-string / here-document redirect + (``bash <<< {file}``). Detection is conservative: + + * an ``eval`` token anywhere → unsafe; + * a clause containing both a shell name and a ``-c``-bearing option → unsafe; + * a re-evaluating shell name anywhere in the template together with a pipe ``|`` or an + input redirect / here-string (``<``) → unsafe (the value could flow into it). + + A bare ``sh {file}`` / ``bash {file}`` (run the *file* as a script — the shipped + Shell/Bash/Zsh templates) has no ``-c``, pipe, or ``<``, so it stays SAFE; a non-shell + ``-c`` option (``pytest -c cfg``) has no shell and stays safe. Comment parsing is + disabled so a mid-word ``#`` (``echo a#b``) cannot hide a later clause. An unparseable + template fails closed (True). Called only after ``$``/backtick/``()``/brace/glob/ + newline forms are already refused, so the tokenizer sees a simple command line.""" + try: + lex = shlex.shlex(template, posix=True, punctuation_chars=True) + lex.whitespace_split = True + lex.commenters = "" # Bash does not treat a mid-word '#' as a comment + clauses, clause = [], [] + for tok in lex: + if tok in (";", "&", "&&", "|", "||"): + clauses.append(clause) + clause = [] + else: + clause.append(tok) + clauses.append(clause) + except ValueError: + return True + all_bases = [t.split("/")[-1] for clause in clauses for t in clause] + if "eval" in all_bases: + return True + # ``env -S`` / ``env --split-string`` re-parses its argument(s) into a fresh command + # line, so a shell + ``-c`` can hide inside a quoted string beyond the top-level token + # scan (``env -S 'bash -c' {file}`` → ``bash -c ''`` second-parses the path). No + # run/verify template needs ``env -S`` — refuse it. + if "env" in all_bases: + for clause in clauses: + if any(t.startswith("-S") or t == "--split-string" + or t.startswith("--split-string=") for t in clause): + return True + has_shell = any(b in _REEVAL_SHELLS for b in all_bases) + # A shell that could RECEIVE the value as code via a pipe or an input + # redirect/here-string — the ``-c`` check would miss both. + if has_shell and ("|" in template or "<" in template): + return True + for toks in clauses: + bases = [t.split("/")[-1] for t in toks] + if any(b in _REEVAL_SHELLS for b in bases) \ + and any(_is_dash_c_option(t) for t in toks): + return True + # A shell command STRING hidden inside a single quoted token (a wrapper's + # re-parsed argument) — re-tokenize each multi-word token and re-check. + for t in toks: + try: + sub = shlex.split(t) + except ValueError: + continue + sub_bases = [x.split("/")[-1] for x in sub] + if len(sub) > 1 and ( + "eval" in sub_bases + or (any(b in _REEVAL_SHELLS for b in sub_bases) + and any(_is_dash_c_option(x) for x in sub))): + return True + return False + + +def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str]: + """Substitute ``{placeholder}`` tokens in a shell-command ``template`` with + shell-quoted values in a SINGLE left-to-right pass, or return ``None`` when the + template is unsafe. + + ``shlex.quote`` returns a *self-contained shell word* — a value with no + metacharacters is returned bare, otherwise it is single-quoted. Such a word is + safe to splice in, and safe to concatenate with ORDINARY LITERAL characters on + either side (so a suffix ``{file}.out`` or prefix ``./{file}`` stays a single + correctly-quoted argument). + + The value is only reinterpreted where the surrounding template creates a context + the single quoting cannot survive: a command-evaluation context (``$(...)``, + ``${...}``, arithmetic ``$((...))``, a ``$``-variable, backticks, or a + ``(...)``/process-substitution subshell), the template's own quotes, a shell + comment, or a here-document body. Rather than model all of those, this helper + ALLOWLISTS simple command lines: it refuses (returns ``None``) any template that + + * contains a newline (the only way to form a here-document body); + * contains ``$``, a backtick, or ``(``/``)`` anywhere (every command-substitution, + parameter/arithmetic expansion, and subshell/process-substitution form requires + one of these — real ``run``/verify templates like ``python {file}`` or + ``gfortran -o {file}.out {file}`` need none of them); + * contains a brace-expansion / pathname-expansion metacharacter (`{`, `}`, `*`, `?`, + `[`, `]`, `~`) OUTSIDE a placeholder token — an unquoted `{a,b}` or glob would + change the command's word count (and ``shlex.quote`` leaves a value's ``,`` + unquoted, so a value inside such a brace would re-split); + * places a placeholder inside its own single/double quotes, immediately after a + backslash, or inside a shell comment (a ``#`` that starts a comment — at a word + boundary or right after a ``;``/``&``/``|`` control operator — where a newline in + the value would break out onto a new command line); + * RE-EVALUATES the value as code — an ``eval`` command or a shell invoked with + ``-c`` (``bash -c {file}``) — where the second parse undoes the single quoting. + (A bare ``sh {file}`` / ``bash {file}`` that runs the *file* as a script is safe + and still substitutes; only the ``-c`` code argument is refused.) + + Substitution is single-pass, so a value that itself contains a ``{...}`` token is + never rescanned as a placeholder. An empty placeholder key is rejected up front (it + would match everywhere and never advance), and an ESCAPED placeholder (``\\{test}``) + is declined rather than emitted with the placeholder left unresolved. + """ + # Refuse constructs the single-pass allowlist cannot reason about: multiline + # (here-document bodies) and any command-evaluation context. `$` covers `$(`, + # `${`, `$((`, and `$var`; backtick covers command substitution; `(`/`)` cover + # subshells and `$(`/`<(`/`>(`. No legitimate run/verify template needs these. + if "\n" in template or "\r" in template: + return None + if any(ch in template for ch in "$`()"): + return None + # An empty placeholder key would match at every position (``startswith("", i)``) + # and never advance the cursor — reject rather than loop forever. + if any(not key for key in values): + return None + # Reject brace-expansion (`{a,b}`), pathname-expansion (`*`/`?`/`[…]`), and tilde + # metacharacters that appear OUTSIDE a placeholder token: an unquoted `{…,…}` or a + # glob would re-split/expand the command into a DIFFERENT word count under bash + # (e.g. `pre{X,tail}` → `preX pretail`), and a bare ``,`` from ``shlex.quote`` (which + # leaves ``,`` unquoted) inside such a brace would be reinterpreted. Placeholders + # legitimately contain `{`/`}`, so mask them out first; ordinary adjacency like + # ``./{file}`` and ``{file}.out`` has none of these and is unaffected. + masked = template + for key in values: + masked = masked.replace(key, "\x00") + if any(ch in masked for ch in "{}[]*?~"): + return None + # Refuse templates that re-evaluate an inserted value as code (``eval {file}``, + # ``bash -c {file}``) — the second parse would undo ``shlex.quote``'s single quoting. + if _feeds_value_into_reevaluation(template): + return None + out: list = [] + i, n = 0, len(template) + in_single = in_double = False + in_comment = False # after an unquoted comment-starting '#' to end of line + prev_significant = "" # last emitted template char (for comment-boundary/escape) + while i < n: + placeholder = next( + (k for k in values if template.startswith(k, i)), None) + if placeholder is not None: + if in_single or in_double or in_comment: + return None + out.append(shlex.quote(values[placeholder])) + i += len(placeholder) + prev_significant = "x" # placeholder resolves to a non-boundary word char + continue + char = template[i] + if char == "\\" and not in_single: + # An escaped placeholder (``\{test}``) cannot be filled meaningfully — its + # brace is now a literal — so decline rather than emit a command with the + # placeholder left unresolved. + if any(template.startswith(k, i + 1) for k in values): + return None + out.append(char) + nxt = template[i + 1] if i + 1 < n else "" + if nxt: + out.append(nxt) + i += 2 + else: + i += 1 + prev_significant = "x" # an escaped char is a word char, not a boundary + continue + if char == "'" and not in_double and not in_comment: + in_single = not in_single + elif char == '"' and not in_single and not in_comment: + in_double = not in_double + elif char == "#" and not in_single and not in_double and not in_comment \ + and (prev_significant in ("", " ", "\t", ";", "&", "|")): + # `#` begins a comment at a command-word boundary — start-of-line, after + # whitespace, or right after a control operator (`;`/`&`/`|`). + in_comment = True + out.append(char) + prev_significant = char + i += 1 + return "".join(out) + def get_run_command(extension: str) -> str: """ @@ -72,4 +274,11 @@ def get_run_command_for_file(file_path: str) -> str: if not run_command_template: return '' - return run_command_template.replace('{file}', file_path) + # Shell-quote the substituted path: callers run this command with `bash -lc` + # / `shell=True`, so an unquoted path with spaces or shell metacharacters + # (e.g. `/repo/$(touch PWN)/x.py`) would be re-split or executed via command + # substitution. But `shlex.quote` is only safe when `{file}` is a standalone bare + # word — a CSV template that quotes it (`printf %s "{file}"`) would let the value's + # `$(...)` still execute — so refuse such a template (return '' = no command). + substituted = shell_safe_substitute(run_command_template, {'{file}': file_path}) + return substituted if substituted is not None else '' diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 4680c62055..03609fea98 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -11,9 +11,308 @@ from pathlib import Path from typing import Optional, Tuple import csv +import json +import os +import re +import shlex +import stat from .agentic_langtest import default_verify_cmd_for from .get_language import get_language +from .get_run_command import shell_safe_substitute + + +# Upper bound on how many concrete patterns a single workspace glob may expand +# to via ``{a,b}`` brace alternation. Real workspace configs use a handful of +# alternatives; a manifest that expands past this bound is treated as +# pathological (untrusted brace-bomb) and membership is failed closed rather +# than materializing an exponential list. See ``_expand_braces``. +_MAX_BRACE_EXPANSION = 1024 + +# Upper bound on the number of ``/``-separated segments in a single workspace +# glob. Real globs have a handful; a manifest with thousands of segments (e.g. +# a wall of ``**`` components) is hostile and fails membership closed rather +# than driving the matcher into pathological cost. See +# ``_relative_matches_workspace_glob``. +_MAX_GLOB_SEGMENTS = 256 + +# Upper bound on the number of raw glob entries evaluated for a single package's +# membership. A declaration with more entries than this is treated as hostile +# and fails membership closed. Combined with ``_MAX_BRACE_EXPANSION`` (which is +# an *aggregate* budget shared across every glob in one membership check), this +# bounds total brace expansion regardless of how the manifest splits the work. +_MAX_RAW_GLOBS = 4096 + +# Upper bound on the size of a workspace-declaration file we will read/parse. A +# larger manifest is treated as hostile and contributes no globs. These caps are +# small on purpose: parsing a JSON/YAML array of N short entries materializes N +# Python objects *before* the ``_MAX_RAW_GLOBS`` count guard can run, so an +# under-cap-but-huge-cardinality manifest could otherwise peak at hundreds of MB +# and OOM a worker. Real manifests are tiny (a handful of workspace globs), so +# these bounds keep peak parse memory well under ~100 MB with generous headroom. +# ``pnpm-workspace.yaml`` gets the smaller cap because YAML parsing amplifies more +# per input byte and a real pnpm workspace file is only a few KB. +_MAX_MANIFEST_BYTES = 1 * 1024 * 1024 +_MAX_PNPM_YAML_BYTES = 256 * 1024 + +# Upper bound on the length (chars) of a single raw glob string. Real globs are +# a few dozen characters; a much longer one is hostile. This bounds not just the +# result *count* of brace expansion but its total *bytes*: brace expansion copies +# a glob's prefix/suffix per option, so without a length cap a long-prefix glob +# with a few brace groups (still under the count budget) could multiply into +# gigabytes of strings. Capping the raw glob length keeps expansion bounded in +# both count and size. +_MAX_GLOB_LENGTH = 4096 + +# Upper bound on the *aggregate* dynamic-program cells the glob matcher may fill +# across one membership check. Each glob costs ``(path_depth+1) * (segments+1)`` +# cells; the per-glob and per-declaration caps bound each factor, but their +# product across many globs (up to the brace budget) times a deep path could +# still be tens of millions of cells (seconds of CPU). This aggregate budget +# fails membership closed on such a crafted manifest. Real declarations use a +# handful of short globs against a shallow path — far under this bound. +_MAX_MATCH_CELLS = 2_000_000 + +# Upper bound on the *aggregate* characters the brace scanner may examine across +# one membership check. Brace expansion re-scans a pattern to locate the next +# alternation, and a deeply nested singleton (`{`×400 … `}`×400) makes each of +# up to ``_MAX_BRACE_EXPANSION`` worklist entries re-walk that long prefix — so a +# tiny (<1 KB) manifest glob can stay within the byte/count/segment budgets yet +# cost tens of seconds of pure scanning. Charging every scanned character against +# this shared budget bounds that work and fails membership closed. Real globs +# expand to a handful of short patterns — orders of magnitude under this bound. +_MAX_BRACE_SCAN_WORK = 8_000_000 + +# Extglob prefix characters: one of these immediately before ``(`` (``?(``/``*(``/ +# ``+(``/``@(``/``!(``) opens a minimatch extglob. minimatch expands these but the +# direct matcher does not, so a *complete* extglob group fails membership closed. +_EXTGLOB_PREFIXES = "?*+@!" + + +def _has_complete_extglob(pattern: str) -> bool: + """True when ``pattern`` contains a *complete* extglob group — an extglob prefix + (``?*+@!``) immediately followed by ``(`` and later a matching ``)``. + + An *incomplete* marker (``foo?(bar`` with no ``)``) is NOT flagged: minimatch + reads it as the supported ``?`` wildcard plus a literal ``(``, exactly as the + direct matcher does, so rejecting it would needlessly refuse a legitimate glob. + A single linear scan: once any ``X(`` marker is seen, the next ``)`` completes a + group.""" + seen_marker = False + prev = "" + for ch in pattern: + if ch == "(" and prev in _EXTGLOB_PREFIXES: + seen_marker = True + elif ch == ")" and seen_marker: + return True + prev = ch + return False + + +def _has_complete_bracket_class(raw: str) -> bool: + """True when ``raw`` contains a *closed, non-empty* ``[...]`` bracket class. Two + kinds are NOT flagged because minimatch treats them literally, so rejecting them + would needlessly refuse a legitimate dir name: + + * an *unmatched* ``[`` (no closing ``]``), e.g. ``foo[bar``; and + * an *empty* class ``[]``, ``[!]``, ``[^]`` (no member before the closing + ``]``), e.g. a dir literally named ``[]``. + + Per POSIX/minimatch, a ``]`` in the *first member position* (immediately after + ``[`` or its ``!``/``^`` negation marker) is a literal member, not the close — + so ``[]]``, ``[^]]``, ``[!]]`` ARE non-empty classes (their content is ``]``) + and ARE flagged. Only a class with ``[^…]`` negation or POSIX ``[[:…:]]`` + semantics, which diverge from a literal match, needs fail-closed handling. + + Runs in a single left-to-right pass (each ``find`` advances monotonically), so a + hostile glob of a million unmatched ``[`` cannot drive quadratic rescanning.""" + i, n = 0, len(raw) + while i < n: + if raw[i] != "[": + i += 1 + continue + j = i + 1 + if j < n and raw[j] in "!^": # a leading negation marker is not a member + j += 1 + # The character at ``j`` (even ``]``) is the first class member; a real + # class needs a *closing* ``]`` somewhere after it. A single forward search + # suffices — no ``]`` after ``j`` means none exists for any later ``[``. + if j < n and raw.find("]", j + 1) != -1: + return True + return False + return False + + +def _is_range_body(body: str) -> bool: + """True when ``body`` (the content of a ``{...}``) is a real minimatch brace + *range*: ``X..Y`` or ``X..Y..Z`` where the two endpoints are BOTH ASCII integers + (optional leading ``-`` — a leading ``+`` is NOT a range) or BOTH single ASCII + letters, and the optional step ``Z`` is an ASCII integer. + + Anything else is NOT a range — minimatch leaves it literal — so it is NOT + flagged: multi-character endpoints (``foo..bar``), non-integer numeric-looking + endpoints (``1.0..3.0``), plus-prefixed (``+1..+3``), non-ASCII/Unicode digits or + letters, and empty endpoints (``..``).""" + parts = body.split("..") + if len(parts) not in (2, 3): + return False + + def _is_ascii_int(text: str) -> bool: # optional leading '-' only, ASCII digits + digits = text[1:] if text[:1] == "-" else text + return bool(digits) and digits.isascii() and digits.isdigit() + + def _is_ascii_alpha(text: str) -> bool: # a single ASCII letter + return len(text) == 1 and text.isascii() and text.isalpha() + + start, end = parts[0], parts[1] + endpoints_ok = (_is_ascii_int(start) and _is_ascii_int(end)) or ( + _is_ascii_alpha(start) and _is_ascii_alpha(end)) + if not endpoints_ok: + return False + return len(parts) == 2 or _is_ascii_int(parts[2]) + + +def _has_brace_range(raw: str) -> bool: + """True when ``raw`` contains a real minimatch brace *range* — a ``{...}`` group + whose body matches range grammar (see ``_is_range_body``): ``{1..3}``, ``{a..c}``, + ``{01..03}``, ``{1..9..2}``. A comma-only expander emits these literally, so they + fail closed. + + Not flagged: a ``..`` outside any brace (dir ``foo..bar``); a comma-alternation + or multi-character body (``{foo..bar,baz}``, ``{foo..bar}``, ``{1.0..3.0}``); + an opaque ``${1..3}``; or an unbalanced ``{foo..bar``. Only *leaf* groups (no + nested ``{``) can be ranges — a range body holds no braces — so per-``}`` body + inspection is limited to leaves, keeping the whole scan linear. A range nested + inside an alternation (``{a,{1..3}}``) is still caught as the inner leaf.""" + # Each frame: [open_index, opaque, has_inner_brace]. A ``${...}`` (or any group + # inside one) is opaque; its body is literal, never a range. + stack: list = [] + prev = "" + for i, ch in enumerate(raw): + if ch == "{": + opaque = prev == "$" or (bool(stack) and stack[-1][1]) + if stack: + stack[-1][2] = True # parent now has a nested brace → not a leaf + stack.append([i, opaque, False]) + elif ch == "}" and stack: + open_i, opaque, has_inner = stack.pop() + if not opaque and not has_inner and _is_range_body(raw[open_i + 1:i]): + return True + prev = ch + return False # an unbalanced '{' left open never closes → literal, not a range + + +def _raw_glob_unsupported(raw: str) -> bool: + """Return True when the *raw* (unexpanded) glob uses a construct that must be + rejected *before* brace expansion, because expansion would mis-handle it: + + * ``\\`` — backslash escapes of brace metacharacters (the expander is not + escape-aware; ``{foo\\,bar}`` is two options, not three). + + Construct checks that depend on how the concrete pattern actually reads — bracket + classes and extglobs, which brace expansion can *create* (``{?,x}(foo)`` → + ``?(foo)``) or *destroy* (``{[,x}]`` → ``[]``, ``x]``) across alternatives — are + NOT done here; they run per expanded pattern in + ``_concrete_pattern_unsupported``. + """ + return "\\" in raw + + +_LEADING_PREFIX_RE = re.compile(r"^\.?/+") + + +def _strip_one_leading(pattern: str) -> str: + """Apply npm's leading normalization exactly once: strip an optional leading + ``.`` immediately followed by the entire leading run of ``/`` (the ``^\\.?/+`` + that ``@npmcli/map-workspaces`` uses). So ``./x``, ``.//x``, ``/x``, ``//x`` all + become ``x``, while a prefix left OVER is significant — ``/./x`` normalizes to + ``./x`` (needing a literal ``.`` segment) and ``././x`` keeps its second ``./`` — + so neither collapses to ``x`` and falsely matches a plain ``x`` package.""" + return _LEADING_PREFIX_RE.sub("", pattern, count=1) + + +def _effective_leading(pattern: str) -> str: + """Return ``pattern``'s effective leading form for classification (e.g. a ``#`` + comment), using the SAME one-pass normalization as matching. A residual leading + ``/`` or ``./`` after that one pass is an absolute/dot-slash remainder that the + matcher never matches, so it is reported as empty (not a comment).""" + rest = _strip_one_leading(pattern) + if rest.startswith("/") or rest.startswith("./"): + return "" + return rest + + +def _concrete_pattern_unsupported(pattern: str) -> bool: + """Return True when a fully brace-*expanded* (concrete) pattern uses a minimatch + construct this matcher does not implement with faithful parity, so the whole + membership check must fail closed: + + * a *closed, non-empty* ``[...]`` bracket class — ``[^a]`` negates in minimatch + but ``^`` is literal in ``fnmatch``; POSIX ``[[:alpha:]]`` is unsupported. An + unmatched literal ``[`` or an empty ``[]``/``[!]``/``[^]`` is fine (both treat + it literally) and is NOT rejected. + * a brace *range* — ``..`` inside a non-comma, non-``$`` brace group + (``{1..3}``, ``{a..c}``, ``{01..03}``, ``{1..9..2}``), which a comma-only + expander emits literally. A literal ``..`` outside braces, inside a comma + alternation, or inside an opaque ``${...}`` is fine and NOT rejected. + * a *complete* extglob group (``?(…)``/``*(…)``/``+(…)``/``@(…)``/``!(…)`` with + a matching ``)``), which minimatch expands but the direct matcher does not. + An *incomplete* marker (``foo?(bar`` with no ``)``) is minimatch's ``?`` + wildcard plus a literal ``(`` — the direct matcher agrees — so it is NOT + rejected. + + Bracket classes and extglobs cannot cross a ``/`` (a class/group is confined to + one path segment), so they are checked PER ``/``-delimited segment — otherwise a + ``[`` in one segment and a ``]`` in another (``foo[/bar]``, which minimatch reads + literally) would be misread as a class. Brace ranges are checked on the whole + pattern (a brace body may legitimately contain ``/``). + + (The astral ``?`` divergence is handled directly in ``_wildcard_segment_match``, + which matches over UTF-16 code units, so nothing about astral characters is + rejected here.) + + Failing closed only forgoes crossing a workspace boundary (the leaf still uses + its nearest ``package.json``); it never adopts a foreign config. Real workspace + globs use ``*``/``**``/``{,}`` — not these — so legitimate declarations are + unaffected. + """ + for segment in pattern.split("/"): + if _has_complete_bracket_class(segment) or _has_complete_extglob(segment): + return True + return _has_brace_range(pattern) + + +def _read_manifest_text(path: Path, max_bytes: int = _MAX_MANIFEST_BYTES) -> Optional[str]: + """Read ``path`` as UTF-8, or return ``None`` (so callers fail closed) if it is + missing, not a regular file, larger than ``max_bytes``, unreadable, or not + valid UTF-8. + + The file must resolve to a *regular* file: a manifest that is a symlink to a + character device or FIFO (e.g. ``/dev/zero``) reports ``st_size == 0`` yet + would stream forever, so a byte-capped read from a regular-file-only handle is + required — ``st_size`` alone is not trusted.""" + try: + st = path.stat() # follows symlinks; the *target* must be a regular file + if not stat.S_ISREG(st.st_mode): + return None + if st.st_size > max_bytes: + return None + with open(path, "rb") as handle: + data = handle.read(max_bytes + 1) + if len(data) > max_bytes: + return None # grew past the cap between stat and read + return data.decode("utf-8") + except (OSError, UnicodeError): + return None + + +class _PatternBudgetError(Exception): + """Raised when an untrusted workspace pattern exceeds a safety budget + (brace expansion or segment count), so membership can fail closed.""" + + +class _BraceBudgetError(_PatternBudgetError): + """Raised when a brace pattern would expand past ``_MAX_BRACE_EXPANSION``.""" @dataclass @@ -28,23 +327,1460 @@ class TestCommand: cwd: Optional[Path] = None +def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, + cell_budget: Optional[list] = None, + work_budget: Optional[list] = None, + pre_normalized: bool = False, + dot: bool = False) -> bool: + """Match a package's path segments against a single workspace glob pattern. + + Supports the segment semantics workspace tools use: ``*`` matches exactly one + path segment (with fnmatch inside the segment) and ``**`` matches zero or more + segments. A trailing ``/*`` therefore matches direct children only, while + ``**`` spans any depth. + + Matching is an iterative ``O(len(rel) * len(pattern))`` dynamic program (no + recursion and no list slicing), so a hostile pattern with many ``**`` + segments cannot drive it into exponential backtracking or ``RecursionError``. + The segment count is bounded *before* the pattern is split (a cheap + ``count('/')`` check), and a pattern past ``_MAX_GLOB_SEGMENTS`` raises + ``_PatternBudgetError`` so membership fails closed without materializing a + huge segment list. + + Dot semantics follow npm/minimatch's default (``dot: false``): a wildcard + segment (``*``/``**`` or an fnmatch pattern) does NOT match a path segment + that begins with ``.`` unless the *pattern* segment also begins with ``.``. + So ``packages/*`` does not match ``packages/.shadow``, but ``packages/.*`` + does. + """ + # Cheap guard before allocating the split list (a "slash wall" attack). + if pattern.count("/") > _MAX_GLOB_SEGMENTS: + raise _PatternBudgetError + # Apply npm's leading normalization exactly ONCE. A residual leading ``/`` or + # ``./`` (e.g. ``/./packages/*`` → ``./packages/*``) is an absolute/dot-slash + # remainder that does not match a relative package path — never collapse it to + # ``packages/*`` and falsely prove membership. When ``pre_normalized`` is set the + # caller has ALREADY applied the single leading strip to the RAW pattern before + # brace expansion (npm normalizes once, before minimatch expands braces), so a + # brace-GENERATED leading ``/`` (e.g. ``{/packages/*,other/*}`` → ``/packages/*``) + # is anchored and MUST NOT be stripped again — only the residual-significant check + # runs here. + rest = pattern if pre_normalized else _strip_one_leading(pattern) + if rest.startswith("/") or rest.startswith("./"): + return False + # Drop empty segments (from ``//`` or a trailing ``/``); every internal ``.`` + # segment is significant and kept. + pat_parts = [p for p in rest.split("/") if p != ""] + if len(pat_parts) > _MAX_GLOB_SEGMENTS: + raise _PatternBudgetError + rel = list(rel_parts) + n, m = len(rel), len(pat_parts) + # Fast reject: without ``**`` every pattern segment consumes exactly one path + # segment, so mismatched counts can never match — skip the DP (and its per-cell + # character work) entirely. This bounds a manifest of many wildcard-heavy globs + # against a deep path. + if "**" not in pat_parts and m != n: + return False + # Charge this match's DP-table size against a shared aggregate budget so that + # many long globs against a deep path cannot sum to tens of millions of cells. + if cell_budget is not None: + cell_budget[0] -= (n + 1) * (m + 1) + if cell_budget[0] < 0: + raise _PatternBudgetError + # Per-segment work budget (shared across the walk) charges the character-level + # matching so that many long wildcard segments cannot burn CPU under the cell cap. + work = work_budget if work_budget is not None else [_MAX_BRACE_SCAN_WORK] + return _segments_dp_match(rel, pat_parts, work, dot=dot) + + +def _segments_dp_match(rel: list, pat_parts: list, work: list, + dot: bool = False) -> bool: + """Iterative ``O(len(rel) * len(pat_parts))`` dynamic program matching path + segments ``rel`` against glob segments ``pat_parts`` (``*``/literal per segment, + ``**`` spanning any depth). UTF-16 units and dot-flags are computed ONCE per segment + (not per DP cell), and per-unit character work is charged against ``work`` — so a + deep path against many long wildcard segments cannot burn CPU. + + ``dot`` selects minimatch's dot policy. With ``dot=False`` (the default, used for + positive membership and pattern-vs-pattern preprocessing) a wildcard segment does + NOT match a leading-dot name unless the pattern segment also begins with ``.``. With + ``dot=True`` (used for npm's final IGNORE/negation matching, which npm applies via + ``glob``'s dot:true ignore set) a wildcard DOES match leading-dot names — so + ``packages/*`` excludes ``packages/.shadow``.""" + n, m = len(rel), len(pat_parts) + rel_units = [_utf16_units(r) for r in rel] + rel_is_dot = [r.startswith(".") for r in rel] + # A ``.`` or ``..`` path segment is special in minimatch: NO wildcard or globstar + # matches it — only the IDENTICAL literal segment does. (Relevant when a raw pattern + # STRING like ``packages/./x`` is matched as a path during npm's pruning.) + rel_is_dotdir = [r in (".", "..") for r in rel] + pat_units = [None if p == "**" else _utf16_units(p) for p in pat_parts] + pat_is_dot = [p.startswith(".") for p in pat_parts] + # dp[i][j] is True when rel[i:] matches pat_parts[j:]. + dp = [[False] * (m + 1) for _ in range(n + 1)] + dp[n][m] = True + # rel exhausted: only trailing ``**`` segments can still match (each empty). + for j in range(m - 1, -1, -1): + dp[n][j] = pat_parts[j] == "**" and dp[n][j + 1] + for i in range(n - 1, -1, -1): + for j in range(m - 1, -1, -1): + if rel_is_dotdir[i]: + # ``.``/``..`` matches ONLY an identical literal pattern segment. + dp[i][j] = (pat_parts[j] == rel[i]) and dp[i + 1][j + 1] + elif pat_parts[j] == "**": + # ``**`` matches zero segments (advance pattern) or one-or-more + # (consume rel[i], stay on the same ``**``). Under dot:false a + # leading-dot segment is not consumed by ``**``. + dp[i][j] = dp[i][j + 1] or ( + (dot or not rel_is_dot[i]) and dp[i + 1][j]) + elif (not dot) and rel_is_dot[i] and not pat_is_dot[j]: + # dot:false — a wildcard segment does not match a leading-dot name + # unless the pattern segment also begins with ``.``. + dp[i][j] = False + else: + dp[i][j] = (dp[i + 1][j + 1] + and _wildcard_units_match(rel_units[i], pat_units[j], work)) + return dp[0][0] + + +def _utf16_units(text: str) -> list: + """Return ``text`` as a list of UTF-16 code units (ints). A non-BMP / astral + character becomes its two-unit surrogate pair, matching how minimatch (a JS + regex) counts characters — so a single ``?`` matches one unit and an emoji needs + ``??``.""" + units: list = [] + for ch in text: + code = ord(ch) + if code > 0xFFFF: + code -= 0x10000 + units.append(0xD800 + (code >> 10)) + units.append(0xDC00 + (code & 0x3FF)) + else: + units.append(code) + return units + + +def _wildcard_units_match(name_u: list, pat_u: list, + work: Optional[list] = None) -> bool: + """Match one path segment against one glob segment, both given as UTF-16 code-unit + lists, in the matcher's *own* supported language — literal units plus ``*`` (any + run, including empty) and ``?`` (exactly one unit) — and NOTHING else. + + Working over UTF-16 units gives ``?`` exact minimatch parity even for astral + characters (``?`` matches one surrogate unit; an emoji needs ``??``). Every other + character, including ``[ ] ( ) { } ^ ! $`` and ``.``, is a literal — this + deliberately does NOT delegate to ``fnmatch``, whose ``[...]``/``[^…]`` + reinterpretation and OS case-folding diverge from minimatch on the literal + bracket forms this matcher intentionally permits (e.g. a dir named ``[^]``). + + Linear-space greedy two-pointer with single-star backtracking. Each unit examined + is charged against the ``work`` budget (shared across the discovery walk), so a + manifest of many long wildcard segments cannot burn CPU under the DP-cell cap.""" + if work is None: + work = [_MAX_BRACE_SCAN_WORK] + q, star = 0x3F, 0x2A # ord('?'), ord('*') — always wildcards in a glob + s = p = 0 + star_p = star_s = -1 + ns, npat = len(name_u), len(pat_u) + while s < ns: + work[0] -= 1 + if work[0] < 0: + raise _PatternBudgetError + if p < npat and pat_u[p] == star: + # A pattern ``*`` is ALWAYS a wildcard — test it BEFORE literal equality, or + # a literal ``*`` in the NAME (e.g. the pattern-string ``packages/**`` used as + # a "path" during npm's pattern-vs-pattern pruning) would be consumed as a + # literal match and ``**`` would fail to match the glob ``*``. + star_p, star_s = p, s + p += 1 + elif p < npat and (pat_u[p] == q or pat_u[p] == name_u[s]): + s += 1 + p += 1 + elif star_p != -1: + p = star_p + 1 + star_s += 1 + s = star_s + else: + return False + while p < npat and pat_u[p] == star: + work[0] -= 1 # charge trailing-star runs too (a long ``*``-wall is unbounded here) + if work[0] < 0: + raise _PatternBudgetError + p += 1 + return p == npat + + +def _wildcard_segment_match(name: str, pat: str) -> bool: + """Convenience wrapper: match segment strings by converting to UTF-16 units. See + :func:`_wildcard_units_match`.""" + return _wildcard_units_match(_utf16_units(name), _utf16_units(pat)) + + +def _workspace_source_is_pnpm(ancestor: Path) -> bool: + """True when ``ancestor`` declares its workspace via ``pnpm-workspace.yaml`` (even + a dangling symlink). pnpm's ``@pnpm/matcher`` evaluates include/exclude in + DECLARATION ORDER (last matching pattern wins — a later positive re-includes), + whereas npm/yarn's ``@npmcli/map-workspaces`` and lerna glob the positives and + remove the negatives as a set (an exclusion is terminal; a later broad positive + does NOT re-include). See ``_package_matches_workspace``'s ``ordered`` argument.""" + pnpm_path = ancestor / "pnpm-workspace.yaml" + return pnpm_path.exists() or pnpm_path.is_symlink() + + +def _workspace_globs_for(ancestor: Path, cache: Optional[dict] = None) -> list: + """Return the workspace package globs declared by ``ancestor`` (empty if none). + + Reads npm/yarn ``workspaces`` (list or ``{"packages": [...]}``), ``lerna.json`` + ``packages``, and ``pnpm-workspace.yaml`` ``packages``. + + ``pnpm-workspace.yaml``, when present, is *authoritative*: pnpm ignores the + ``workspaces`` field in ``package.json``, so a stale or attacker-controlled + ``workspaces`` list must not union in and broaden membership beyond what pnpm + itself would honor. pnpm requires a YAML parser; if it is unavailable or the + file cannot be parsed we conservatively return no globs so membership stays + unproven rather than falsely asserted. + + Every source is validated to be the expected JSON/YAML shape (a mapping); + a manifest whose top level is a non-object (e.g. ``[]``) contributes no + globs instead of raising. Each declared package glob must be a genuine + ``str`` — a list containing a non-string entry (e.g. ``[true]``, which would + otherwise coerce to the glob ``"True"``) is treated as malformed and + contributes no globs (fail closed). + + Results are memoized in ``cache`` (keyed by the canonical ancestor path) for + the duration of one discovery walk so an ancestor manifest is parsed at most + once even when the walk revisits it via ``_workspace_root_for``. + """ + if cache is not None: + try: + key = os.path.realpath(str(ancestor)) + except (OSError, RuntimeError): + key = str(ancestor) + if key in cache: + return cache[key] + result = _workspace_globs_uncached(ancestor) + cache[key] = result + return result + return _workspace_globs_uncached(ancestor) + + +_PNPM_YAML_LOADER_CACHE: dict = {} + + +def _pnpm_yaml_loader(yaml): + """Return (memoized) a ``yaml.SafeLoader`` subclass whose scalar resolution and + duplicate-key handling match pnpm's YAML 1.2 parser, so a ``pnpm-workspace.yaml`` + cannot prove/deny membership through a version discrepancy with PyYAML's default + YAML 1.1 rules. + + The inherited YAML 1.1 implicit-resolver table is REPLACED wholesale with the + YAML 1.2 *core schema* — layering selected rules onto the 1.1 table would still + misclassify the forms 1.1 resolves but 1.2 does not: `yes`/`no`/`on`/`off` (1.1 + bool → 1.2 str), `0b10` (1.1 binary → 1.2 str), `1:20` (1.1 sexagesimal → 1.2 + str), `2020-01-01` (1.1 timestamp → 1.2 str), and `1_000` (1.1 underscore int → + 1.2 str), which pnpm treats as valid string globs. Under the 1.2 core schema only + `true`/`false` (bool), `null`/`~`/empty (null), `[-+]?[0-9]+`/`0o…`/`0x…` (int), + and the exponent/`.inf`/`.nan` floats resolve as non-strings; everything else — + including ordinary globs and a quoted ``"0o12"`` — stays a string. A non-string + ``packages`` entry is then rejected by ``_string_globs`` exactly as pnpm rejects + a bare number/bool. + + pnpm also rejects duplicate mapping keys (PyYAML silently keeps the last); a + custom map constructor raises on a duplicate so the parse fails closed. + """ + cached = _PNPM_YAML_LOADER_CACHE.get("loader") + if cached is not None: + return cached + + class _Loader(yaml.SafeLoader): # pylint: disable=too-few-public-methods + pass + + _Loader.yaml_implicit_resolvers = {} # drop PyYAML's inherited YAML 1.1 table + _Loader.add_implicit_resolver( + "tag:yaml.org,2002:bool", + re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"), list("tTfF")) + # Null includes the EMPTY scalar (``- `` with nothing after it) — YAML 1.2 + # resolves it to null, a non-string that must fail closed. PyYAML looks up the + # empty-string first-char bucket, so register ``""`` and let the regex match "". + _Loader.add_implicit_resolver( + "tag:yaml.org,2002:null", + re.compile(r"^(?:~|null|Null|NULL|)$"), ["~", "n", "N", ""]) + _Loader.add_implicit_resolver( + "tag:yaml.org,2002:int", + re.compile(r"^(?:[-+]?[0-9]+|0o[0-7]+|0x[0-9a-fA-F]+)$"), + list("-+0123456789")) + _Loader.add_implicit_resolver( + "tag:yaml.org,2002:float", + re.compile( + r"^(?:[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?" + r"|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$"), + list("-+0123456789.")) + + def _construct_int_yaml_1_2(loader, node): + # YAML 1.2 constructs an ordinary digit run as BASE 10 (``012`` is 12, not + # octal 10 as YAML 1.1 does) and only ``0o``/``0x`` as bases 8/16. Getting + # this right is what makes duplicate keys such as ``012:`` and ``12:`` + # (both integer 12 in 1.2) compare equal below. + text = loader.construct_scalar(node) + sign = -1 if text[:1] == "-" else 1 + digits = text[1:] if text[:1] in "+-" else text + if digits[:2] == "0o": + return sign * int(digits[2:], 8) + if digits[:2] == "0x": + return sign * int(digits[2:], 16) + return sign * int(digits, 10) + + _Loader.add_constructor("tag:yaml.org,2002:int", _construct_int_yaml_1_2) + + def _construct_mapping_no_duplicates(loader, node, deep=False): + mapping = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + # Compare on (YAML tag, canonical value) so ``012``/``12`` (both int 12) + # collide while a string ``"12"`` and int ``12`` stay distinct. + marker = (key_node.tag, key) + if marker in mapping: + raise yaml.constructor.ConstructorError( + None, None, f"duplicate key {key!r}", key_node.start_mark) + mapping[marker] = loader.construct_object(value_node, deep=deep) + # Re-key by the plain constructed key so downstream ``data.get("packages")`` + # still works. + return {marker[1]: value for marker, value in mapping.items()} + + _Loader.add_constructor( + "tag:yaml.org,2002:map", _construct_mapping_no_duplicates) + _PNPM_YAML_LOADER_CACHE["loader"] = _Loader + return _Loader + + +def _workspace_globs_uncached(ancestor: Path) -> list: + """See :func:`_workspace_globs_for`; this performs the actual filesystem read.""" + # ``pnpm-workspace.yaml`` is authoritative when *present at all* — even as a + # dangling or unreadable symlink. ``Path.exists()`` returns False for a + # dangling symlink, so use lexical presence; a present-but-unreadable pnpm + # config yields no globs (fail closed) and must NOT fall through to a stale + # ``package.json`` ``workspaces`` field. + pnpm_path = ancestor / "pnpm-workspace.yaml" + if pnpm_path.exists() or pnpm_path.is_symlink(): + try: + import yaml # optional dependency + except ImportError: + return [] # cannot parse pnpm config → membership unproven (fail closed) + text = _read_manifest_text(pnpm_path, _MAX_PNPM_YAML_BYTES) # None if missing/dangling/oversized/bad-utf8 + if text is None: + return [] + try: + # Parse with pnpm-compatible (YAML 1.2 core + no-duplicate-key) rules so + # a version discrepancy cannot falsely prove membership. + data = yaml.load(text, Loader=_pnpm_yaml_loader(yaml)) + except (yaml.YAMLError, ValueError, TypeError, RecursionError, OverflowError): + # Any construction failure on untrusted YAML → membership unproven + # (fail closed). ``yaml.YAMLError`` does NOT cover errors raised by + # scalar constructors: e.g. a malformed timestamp such as + # ``2020-99-99`` makes PyYAML raise a bare ``ValueError`` + # ("month must be in 1..12"), and huge integers can raise + # ``OverflowError`` — none of which are ``yaml.YAMLError`` subclasses. + return [] + if isinstance(data, dict): + return _string_globs(data.get("packages")) + return [] + + globs: list = [] + manifest_path = ancestor / "package.json" + if manifest_path.exists(): + text = _read_manifest_text(manifest_path) + try: + manifest = json.loads( + text, parse_constant=_reject_json_constant) if text is not None else {} + except (ValueError, RecursionError): + # Malformed or deeply-nested (recursion-bomb) JSON → membership + # unproven (fail closed) rather than crashing discovery. + manifest = {} + if isinstance(manifest, dict): + ws = manifest.get("workspaces") + if isinstance(ws, dict): + ws = ws.get("packages") + globs.extend(_string_globs(ws)) + lerna_path = ancestor / "lerna.json" + if lerna_path.exists(): + text = _read_manifest_text(lerna_path) + try: + lerna = json.loads( + text, parse_constant=_reject_json_constant) if text is not None else None + except (ValueError, RecursionError): + lerna = None # parse failed → contribute nothing (fail closed) + if isinstance(lerna, dict): + if "packages" not in lerna: + # Only an *omitted* ``packages`` key gets the documented lerna + # default. A parse failure above sets ``lerna=None`` and skips + # this, and an explicit ``"packages": null`` is NOT an omission — + # it falls through to ``_string_globs(None)`` → no globs (fail + # closed), so a crafted null must not grant the default glob. + globs.append("packages/*") + else: + globs.extend(_string_globs(lerna["packages"])) + return globs + + +def _reject_json_constant(token: str): + """``parse_constant`` callback for ``json.loads``: raise on the non-standard + ``NaN``/``Infinity``/``-Infinity`` constants that Python accepts but strict JSON + parsers (npm's, Node's ``JSON.parse``) reject. A manifest using them is invalid + JSON and must fail closed, even when the token sits outside the workspace field — + a whole-document parse failure is what npm produces.""" + raise ValueError(f"invalid JSON constant {token!r}") + + +def _string_globs(value) -> list: + """Return ``value`` as a list of string globs, or ``[]`` if it is not a list + of strings. A single non-string entry makes the whole declaration malformed + (fail closed) so a JSON/YAML ``true``/number cannot be coerced into a glob. + + A declaration with more than ``_MAX_RAW_GLOBS`` entries is rejected up front + (before validating/copying the list) so a huge but under-byte-cap manifest + fails membership closed without a second full-list traversal/copy.""" + if not isinstance(value, list): + return [] + if len(value) > _MAX_RAW_GLOBS: + return [] + if not all(isinstance(item, str) for item in value): + return [] + return list(value) + + +def _split_top_level_commas(body: str, limit: int) -> list: + """Split a brace body on commas that are not inside a nested brace group. + + Raises ``_BraceBudgetError`` once more than ``limit`` top-level options would + be produced, so a brace body with millions of commas cannot materialize a + huge list before the caller's budget is consulted. + """ + parts, depth, current = [], 0, [] + for char in body: + if char == "{": + depth += 1 + current.append(char) + elif char == "}": + depth -= 1 + current.append(char) + elif char == "," and depth == 0: + parts.append("".join(current)) + current = [] + if len(parts) > limit: + raise _BraceBudgetError + else: + current.append(char) + parts.append("".join(current)) + if len(parts) > limit: + raise _BraceBudgetError + return parts + + +def _skip_balanced_braces(pattern: str, start: int, work: list) -> int: + """Return the index just past the ``}`` matching the ``{`` at ``start`` (nesting + included) when the group is *balanced*. When it is *unbalanced* (no matching + ``}``), return ``start + 1`` so the caller treats only that one ``{`` as literal + and keeps scanning — a later balanced alternation (e.g. the ``{a,b}`` in + ``${foo/{a,b}``) must still expand. Each scanned character is charged against + the ``work`` budget.""" + depth, i, n = 0, start, len(pattern) + while i < n: + work[0] -= 1 + if work[0] < 0: + raise _BraceBudgetError + if pattern[i] == "{": + depth += 1 + elif pattern[i] == "}": + depth -= 1 + if depth == 0: + return i + 1 + i += 1 + return start + 1 # unbalanced ${ → ``{`` is literal; resume after it + + +def _find_expandable_brace(pattern: str, limit: int, + work: Optional[list] = None) -> Optional[Tuple[int, int]]: + """Return ``(start, end)`` of the first *expandable* ``{...}`` group — one whose + body splits into two or more top-level options — or ``None`` when the pattern + holds no real alternation. + + The scan runs left-to-right and, on hitting a *single-option* brace (``{foo}``, + not a real alternation), does two things before giving up on it: it descends + into that brace's body to find a nested alternation (so ``{a{b,c}}`` still + expands its inner ``{b,c}``), and, failing that, it skips *past* the group and + keeps scanning the rest of the pattern (so a later ``{a,b}`` in + ``{foo}/{a,b}`` is still found). ``end`` indexes the matching ``}``. + + An *unmatched* opening ``{`` (no matching ``}``) is likewise literal and does + not short-circuit the scan: only that one brace is skipped, so a later balanced + alternation in ``{foo/{a,b}`` still expands. ``_split_top_level_commas`` may + raise ``_BraceBudgetError`` for a comma bomb. + + Every character examined is charged against the mutable ``work`` budget (shared + across the whole membership check). A deeply nested singleton makes this scan — + and the descent below — re-walk a long prefix repeatedly; the budget raises + ``_BraceBudgetError`` so such a pattern fails closed instead of burning CPU. + """ + if work is None: + work = [_MAX_BRACE_SCAN_WORK] + i, n = 0, len(pattern) + while i < n: + # Charge every examined position — including the non-brace prefix skipped + # below — so a long literal/``*`` run before the first brace (re-walked for + # each of up to _MAX_BRACE_EXPANSION worklist entries) is bounded, not free. + work[0] -= 1 + if work[0] < 0: + raise _BraceBudgetError + if pattern[i] != "{": + i += 1 + continue + depth, end = 0, -1 + for j in range(i, n): + work[0] -= 1 + if work[0] < 0: + raise _BraceBudgetError + if pattern[j] == "{": + depth += 1 + elif pattern[j] == "}": + depth -= 1 + if depth == 0: + end = j + break + if end == -1: + i += 1 # unmatched '{' is literal; keep scanning for a later group + continue + body = pattern[i + 1:end] + if len(_split_top_level_commas(body, limit)) >= 2: + return (i, end) + inner = _find_expandable_brace(body, limit, work) # descend into singleton + if inner is not None: + return (i + 1 + inner[0], i + 1 + inner[1]) + i = end + 1 # fully literal group → skip past and keep scanning + return None + + +_DOLLAR_MASK = "\x00" + + +def _mask_dollar_braces(pattern: str, work: list) -> Tuple[str, list]: + """Replace every *balanced* ``${...}`` span (a ``{`` immediately preceded by a + literal ``$``, nested braces included) with an inert ``\\x00N\\x00`` placeholder, + returning the masked string and the list of removed literal spans. + + This is done BEFORE brace expansion so the expander never sees a ``$`` adjacent + to a ``{``. Otherwise an option could generate that adjacency — expanding + ``{$,x}{a,b}`` produces ``${a,b}``, whose ``{a,b}`` is NOT a shell group and MUST + still expand — and a naive "``$`` precedes ``{``" opacity check on the worklist + string would wrongly freeze it. An *unbalanced* ``${`` is left as a literal + ``${`` (the later brace still expands). Each character is charged against + ``work``.""" + literals: list = [] + out: list = [] + i, n = 0, len(pattern) + while i < n: + work[0] -= 1 + if work[0] < 0: + raise _BraceBudgetError + if pattern[i] == "$" and i + 1 < n and pattern[i + 1] == "{": + end = _skip_balanced_braces(pattern, i + 1, work) + if end > i + 2: # a matching '}' was found → balanced ${...} + out.append(f"{_DOLLAR_MASK}{len(literals)}{_DOLLAR_MASK}") + literals.append(pattern[i:end]) + i = end + continue + out.append(pattern[i]) + i += 1 + return "".join(out), literals + + +def _restore_dollar_braces(pattern: str, literals: list) -> str: + """Substitute ``\\x00N\\x00`` placeholders back with their original ``${...}`` + literal spans.""" + if not literals: + return pattern + return re.sub( + rf"{_DOLLAR_MASK}(\d+){_DOLLAR_MASK}", + lambda mo: literals[int(mo.group(1))], + pattern, + ) + + +def _expand_braces(pattern: str, budget: Optional[list] = None, + work: Optional[list] = None) -> list: + """Expand ``{a,b}`` brace alternations (as npm/Yarn workspace globs use) into + concrete patterns. Unbalanced or single-option braces are left literal, and a + balanced ``${...}`` is opaque (masked out before expansion, restored after). + + Expansion is *iterative* (a worklist, not recursion) and bounded by a shared + ``budget`` — a single-element mutable list holding the number of finished + patterns still permitted. The caller passes one budget across every glob in a + membership check, so total expansion is bounded regardless of how a manifest + splits work across many globs; the transient worklist is bounded too. A + separate ``work`` budget bounds the *characters scanned* while locating the + next alternation (see ``_find_expandable_brace``), so a deeply nested singleton + cannot cost tens of seconds even while producing few finished patterns. Any + bound exceeded raises ``_BraceBudgetError`` so the caller fails membership + closed instead of materializing an exponential list — or overflowing the + recursion stack — from an untrusted manifest (a ``{a,b}`` brace bomb, + including one with thousands of nested groups or millions of comma options). + """ + if budget is None: + budget = [_MAX_BRACE_EXPANSION] + if work is None: + work = [_MAX_BRACE_SCAN_WORK] + if _DOLLAR_MASK in pattern: + # The mask sentinel cannot appear in a real path/glob; if it does the input + # cannot be masked safely → fail closed. + raise _PatternBudgetError + pattern, literals = _mask_dollar_braces(pattern, work) + + def _emit(value: str) -> None: + budget[0] -= 1 + if budget[0] < 0: + raise _BraceBudgetError + out.append(value) + + out: list = [] + worklist = [pattern] + while worklist: + if len(worklist) > budget[0] + 1: + raise _BraceBudgetError + pat = worklist.pop() + span = _find_expandable_brace(pat, budget[0] + 1, work) + if span is None: + _emit(pat) # no real alternation remains → terminal (literal braces) + continue + start, end = span + options = _split_top_level_commas(pat[start + 1:end], budget[0] + 1) + prefix, suffix = pat[:start], pat[end + 1:] + for option in options: + worklist.append(prefix + option + suffix) + return [_restore_dollar_braces(p, literals) for p in out] + + +def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, + cell_budget: Optional[list] = None, + work_budget: Optional[list] = None, + expand_budget: Optional[list] = None, + ordered: bool = True) -> bool: + """Return True when ``rel_parts`` matches the workspace globs' include/exclude + semantics. The algorithm depends on the declaring tool (``ordered``): + + * ``ordered=True`` (pnpm's ``@pnpm/matcher``): evaluate in DECLARATION ORDER, + last matching pattern wins PER PATH — a positive includes, a ``!`` exclusion + excludes, and a *later* positive RE-INCLUDES only the specific paths it matches + (``["packages/**", "!packages/legacy/**", "packages/legacy/app"]`` re-includes + ``packages/legacy/app`` but still excludes its sibling ``packages/legacy/other``). + This is the default for direct callers. + * ``ordered=False`` (npm/yarn's ``@npmcli/map-workspaces`` and lerna): npm's + ``appendNegatedPatterns`` preprocessing — a later POSITIVE pattern whose pattern + STRING is matched by an earlier ``!`` negation's glob REMOVES that negation + wholesale, so the same declaration re-includes ``packages/legacy/app`` AND (unlike + pnpm) its sibling ``packages/legacy/other``, because dropping ``!packages/legacy/**`` + un-excludes the entire subtree. A path is a member iff it matches a surviving + positive and no surviving negation. + + A path inside any ``node_modules/`` directory is never a member in either mode + (npm's built-in ``**/node_modules/**`` ignore; pnpm/yarn skip ``node_modules``). + + Brace alternations are expanded before matching (a raw glob matches when any of + its expansions does). The brace-expansion count (``expand_budget``), the + brace-scan work (``work_budget``), and the DP-cell count (``cell_budget``) are + each shared across the whole discovery walk when the caller supplies them, so + pathological globs at several ancestors cannot each spend a full budget's worth + of expansion, scanning, or matching. + """ + if len(globs) > _MAX_RAW_GLOBS: + return False # hostile declaration size → membership unproven (fail closed) + try: + # Brace-expansion count budget. Shared across the whole walk when supplied + # (so re-expanding globs at each ancestor cannot sum past the cap); + # otherwise fresh per call for direct/standalone callers. + budget = expand_budget if expand_budget is not None else [_MAX_BRACE_EXPANSION] + # Brace-scan budget (see _expand_braces). Shared across the whole walk when + # supplied; otherwise fresh per call for direct/standalone callers. + work = work_budget if work_budget is not None else [_MAX_BRACE_SCAN_WORK] + # Aggregate DP-cell budget for matching. When the caller supplies one it is + # shared across the *entire* discovery walk (so re-evaluating the same glob + # set at each package boundary cannot sum to tens of millions of cells); + # otherwise a fresh per-call budget is used (direct/standalone callers). + cells = cell_budget if cell_budget is not None else [_MAX_MATCH_CELLS] + # Universal built-in exclusion: a path *inside* a ``node_modules/`` directory + # is never a workspace member in any tool — npm appends ``**/node_modules/**`` + # to its ignore set, and pnpm/yarn ignore ``node_modules`` during package + # discovery. (Only the *contents* are excluded, matching ``**/node_modules/**``; + # a leaf directory literally named ``node_modules`` is not itself excluded.) + if any(part == "node_modules" for part in rel_parts[:-1]): + return False + + # Parse each declared glob once, IN ORDER, into (negated, concrete-patterns). + # Brace alternations are expanded here; a positive minimatch comment matches + # nothing (skipped); an unsupported construct fails membership closed. + parsed = [] # list[tuple[bool, list[str]]] in declaration order + for raw in globs: + # Do NOT strip surrounding whitespace: workspace tools treat it + # literally, so `" packages/* "` is a package literally named with + # spaces (a non-match), not a broader `packages/*`. An EMPTY entry is kept + # (not skipped): under npm's ``appendNegatedPatterns`` an empty positive + # pattern-string can still match and remove a prior negation + # (``["packages/**", "!**", ""]`` re-includes ``packages/**``), even though it + # never matches a non-empty leaf path. + raw = str(raw) + # Cheap length guard FIRST — before any O(len) syntax scan — so a hostile + # megabyte-long glob is rejected without a quadratic pre-scan. + if len(raw) > _MAX_GLOB_LENGTH: + raise _PatternBudgetError + if _raw_glob_unsupported(raw): + # A construct expansion itself would mishandle (backslash escapes). + raise _PatternBudgetError + negated = raw.startswith("!") + body = raw[1:] if negated else raw + if body.startswith("!"): + # Two or more leading ``!`` toggle negation in minimatch (``!!x`` is + # positive, ``!!!x`` negates again). This matcher does not track that + # parity, so a multi-bang glob fails membership closed rather than be + # mis-classified (e.g. treating ``!!!packages/foo`` as a literal and + # falsely proving membership). + raise _PatternBudgetError + # A pattern whose effective form begins with ``#`` is a minimatch comment + # under DEFAULT minimatch options — which is exactly what npm's + # ``appendNegatedPatterns`` preprocessing uses (``minimatch(pattern, + # negatedPattern)``), where the *pattern* arg is comment-parsed. npm's FINAL + # ``glob`` step, however, matches ``#`` LITERALLY (nocomment). Record the flag + # (classified after the SAME leading normalization the matcher applies, so + # ``/#*``/``.//#*`` count) and apply the split semantics per-source below. + is_comment = _effective_leading(body).startswith("#") + # npm applies leading normalization (``^\.?/+``) to each RAW pattern EXACTLY + # ONCE, BEFORE minimatch expands braces — so a slash GENERATED by brace + # expansion (``{/packages/*,other/*}`` → ``/packages/*``) stays anchored and + # is NOT re-normalized into ``packages/*``. Strip the body once here, expand + # the normalized body, and match the expansions as ``pre_normalized``. + norm_body = _strip_one_leading(body) + expanded = _expand_braces(norm_body, budget, work) + # Validate the CONCRETE (expanded) patterns, not the raw glob: brace + # expansion can create an unsupported construct out of separate + # alternatives (``{?,x}(foo)`` → ``?(foo)`` extglob) or dissolve an + # apparent one (``{[,x}]`` → ``[]``, ``x]`` literals). Checking each + # concrete pattern is faithful to what ``fnmatch`` will actually see. + for pat in expanded: + if _concrete_pattern_unsupported(pat): + raise _PatternBudgetError + parsed.append((negated, norm_body, expanded, is_comment)) + + def _matches_any(pattern_list, dot: bool = False) -> bool: + return any( + _relative_matches_workspace_glob( + rel_parts, p, cells, work, pre_normalized=True, dot=dot) + for p in pattern_list + ) + + if ordered: + # pnpm (@pnpm/matcher): evaluate in DECLARATION ORDER; the last pattern + # that matches this path decides — a later positive RE-INCLUDES a path an + # earlier ``!`` excluded, per-path. pnpm treats ``#`` LITERALLY (only a + # leading ``!`` is special), so a ``#app`` pattern matches a directory named + # ``#app`` and re-inclusion works — the npm-preprocessing comment rule does + # NOT apply here. + member = False + for negated, _body, expanded, _is_comment in parsed: + if _matches_any(expanded): + member = not negated + return member + + # npm/yarn/lerna (@npmcli/map-workspaces): faithful ``appendNegatedPatterns`` + # preprocessing. Walking in order, a later POSITIVE pattern whose pattern STRING + # is matched by an earlier negation's glob REMOVES that negation, and (a second, + # symmetric step) each SURVIVING negation prunes any positive PATTERN whose raw + # string it matches. The removal compares the RAW pattern *string* (braces literal; + # repeated/trailing ``/`` collapsed as minimatch does) via DEFAULT minimatch, so: + # * a ``#``-comment NEGATION matches NOTHING here — it can never be removed by a + # positive nor prune one (``["**","!#foo","#foo"]`` keeps ``#foo`` excluded); + # * braces expand only for the final concrete-path test + # (``["packages/**","!packages/a","packages/{a,b}"]`` excludes ``packages/a``, + # includes ``packages/b``); + # * a ``*`` in the negation glob matches a literal ``*`` segment of the positive + # string (``["packages/**","!packages/*"]`` prunes ``packages/**``). + # The FINAL ``glob`` step then matches ``#`` LITERALLY (nocomment) and applies the + # surviving negations as npm's dot:true IGNORE set — so ``packages/*`` excludes a + # leading-dot ``packages/.shadow`` — while positives use dot:false. A path is a + # member iff it matches a surviving positive (dot:false) and no surviving negation + # (dot:true). + def _raw_parts(norm_body): + # RAW pattern string as a literal path; drop empty segments so ``//``/trailing + # ``/`` collapse the way minimatch does for the pattern-vs-pattern comparison. + return tuple(p for p in norm_body.split("/") if p != "") + + def _string_matched_by(raw_parts, group): + return any( + _relative_matches_workspace_glob( + raw_parts, np, cells, work, pre_normalized=True) + for np in group) + + pos_groups = [] # (raw_pos_parts, expansions) for EVERY positive (incl. comments) + neg_groups = [] # (is_comment, expansions) still in force, declaration order + for negated, norm_body, expanded, is_comment in parsed: + raw_parts = _raw_parts(norm_body) + if negated: + neg_groups.append((is_comment, expanded)) + else: + # Reproduce upstream's EXACT mutation order: ``for (i=0; ...; ++i) { if + # match: splice(i,1) }`` — a ``splice(i)`` then ``++i`` SKIPS the negation + # shifted into slot ``i``, so an adjacent matching negation survives. A + # ``#``-comment negation matches nothing and is never removed. + i = 0 + while i < len(neg_groups): + n_comment, grp = neg_groups[i] + if (not n_comment) and _string_matched_by(raw_parts, grp): + del neg_groups[i] + i += 1 + pos_groups.append((raw_parts, expanded)) + # Second step: a surviving NON-comment negation prunes positives it matches. + positives = [] + for raw_parts, expanded in pos_groups: + pruned = any( + (not n_comment) and _string_matched_by(raw_parts, grp) + for n_comment, grp in neg_groups) + if not pruned: + positives.extend(expanded) + neg_flat = [np for _n_comment, grp in neg_groups for np in grp] + if not _matches_any(positives): # positives: dot:false + return False + return not _matches_any(neg_flat, dot=True) # ignores: dot:true + except (_PatternBudgetError, RecursionError): + # Pathological pattern from an untrusted manifest (brace bomb, ``**`` + # wall, or deep nesting) → cannot be evaluated safely, so membership is + # unproven (fail closed). + return False + + +def _workspace_root_for(package_dir: Path, cache: Optional[dict] = None, + cell_budget: Optional[list] = None, + work_budget: Optional[list] = None, + expand_budget: Optional[list] = None) -> Optional[Path]: + """Return the ancestor workspace *root* that ``package_dir`` is a proven member + of, or ``None`` when it is not a member of any ancestor workspace. + + Membership is proven, not assumed: an ancestor's declared workspace globs + (npm/yarn ``workspaces``, ``lerna.json`` ``packages``, ``pnpm-workspace.yaml`` + ``packages``) must actually match ``package_dir`` relative to that ancestor, + honoring ``!`` exclusions and brace expansion. An unrelated package (e.g. a + vendored ``vendor/tool``) or an explicitly excluded one (e.g. under a pnpm + ``!**/test/**``) beneath a workspace root is therefore not treated as a + member. The search never looks above the repository root (``.git``). + + Returning the *declaring root* (not just a boolean) lets the runner walk use + it as a traversal ceiling, so intermediate independent ``package.json`` + manifests sitting between the leaf and its workspace root do not prematurely + stop the walk (e.g. a member ``vendor/container/packages/app`` under a root + glob ``vendor/container/packages/*`` with an unrelated ``vendor/container`` + manifest in between). + """ + ancestor = package_dir.parent + for _ in range(80): + globs = _workspace_globs_for(ancestor, cache) + if globs: + # pnpm evaluates include/exclude in order (re-inclusion); npm/yarn/lerna + # treat an exclusion as terminal. Pick the algorithm by declaring source. + ordered = _workspace_source_is_pnpm(ancestor) + try: + rel_parts = tuple(package_dir.resolve().relative_to(ancestor.resolve()).parts) + except (ValueError, OSError, RuntimeError): + rel_parts = () + if rel_parts and _package_matches_workspace( + rel_parts, globs, cell_budget, work_budget, expand_budget, + ordered=ordered): + return ancestor + if (ancestor / ".git").exists(): + break + parent = ancestor.parent + if parent == ancestor: + break + ancestor = parent + return None + + +def _belongs_to_ancestor_workspace(package_dir: Path) -> bool: + """Return True if ``package_dir`` is a proven member of an ancestor JS + workspace (see :func:`_workspace_root_for`).""" + return _workspace_root_for(package_dir) is not None + + +def _is_within(child: Path, parent: Path) -> bool: + """True if ``child`` is ``parent`` or a descendant of it (after resolving).""" + try: + child.resolve().relative_to(parent.resolve()) + return True + except (ValueError, OSError, RuntimeError): + return False + + +def _is_strict_ancestor(ancestor: Path, descendant: Path) -> bool: + """True if ``ancestor`` is a strict (proper) ancestor of ``descendant``.""" + try: + rel = descendant.resolve().relative_to(ancestor.resolve()) + except (ValueError, OSError, RuntimeError): + return False + return len(rel.parts) > 0 + + +def _lexical_strict_ancestor(ancestor: Path, descendant: Path) -> bool: + """True if ``ancestor`` is a strict ancestor of ``descendant`` comparing the + paths *lexically* (no symlink resolution).""" + try: + rel = Path(descendant).relative_to(Path(ancestor)) + except ValueError: + return False + return len(rel.parts) > 0 + + +def _lexical_repo_root(test_path: Path) -> Optional[Path]: + """Find the repository root that lexically contains ``test_path``, or ``None``. + + The root is the nearest ``.git`` ancestor that lies at or above the *deepest + symlinked directory* on the test file's lexical (``abspath``) path. Anchoring + above the deepest symlink is what makes containment correct in the presence + of symlinks: + + * A symlinked test directory escaping the repo (``repo/tests -> /outside``, + only ``repo/.git``) anchors at ``repo``; the resolved path then escapes + ``repo`` and is refused by :func:`_detect_ts_test_runner`. + * A symlink whose target is itself a foreign checkout + (``repo/link -> /outside`` where ``/outside/foreign/.git`` exists, test at + ``repo/link/foreign/...``) still anchors at ``repo`` — the foreign + ``.git`` sits *below* the symlink and is ignored — so the foreign config + is refused. + * A symlink that stays inside the repository still anchors at ``repo`` and + the resolved path stays within it, so detection proceeds normally. + + Harmless system symlinks above the repository (e.g. macOS ``/var`` → + ``/private/var``) are the deepest symlink only when there is no repo-internal + symlink, in which case no ``.git`` lies above them and this returns ``None`` + (containment simply not required — the ordinary ``.git`` stop in the walk + governs). The walk runs to the filesystem root (no artificial depth cap), so + a deeply nested but legitimate repository is still anchored. + """ + lex = Path(os.path.abspath(str(test_path))) + # Deepest lexical directory on the path that is a symlink. + deepest_link: Optional[Path] = None + parts = lex.parts + if parts: + cursor = Path(parts[0]) + for part in parts[1:]: + cursor = cursor / part + try: + if os.path.islink(str(cursor)): + deepest_link = cursor + except OSError: + pass + directory = lex.parent + for _ in range(4096): # generous bound; real paths hit the fs root far sooner + above_deepest_link = ( + deepest_link is None or _lexical_strict_ancestor(directory, deepest_link) + ) + if above_deepest_link and (directory / ".git").exists(): + return directory + parent = directory.parent + if parent == directory: + break + directory = parent + return None + + +_RUNNER_CONFIGS = ( + # (command prefix, (config filenames — in the runner's documented priority + # order...), spec_only). Each runner supports the full set of JS/TS module + # extensions (`.js`/`.mjs`/`.cjs`) plus `.json` (Jest) and TS variants + # (`.ts`/`.mts`/`.cts`); omitting a supported extension causes a real config to + # be missed and the test to fall back to `npx tsx` or stop at its boundary. + ("npx playwright test", + ("playwright.config.ts", "playwright.config.js", "playwright.config.mjs", + "playwright.config.cjs", "playwright.config.mts", "playwright.config.cts"), + True), + ("npx jest --no-coverage --runTestsByPath", + ("jest.config.js", "jest.config.mjs", "jest.config.cjs", "jest.config.json", + "jest.config.ts", "jest.config.mts", "jest.config.cts"), + False), + ("npx vitest run", + ("vitest.config.ts", "vitest.config.js", "vitest.config.mjs", + "vitest.config.cjs", "vitest.config.mts", "vitest.config.cts"), + False), +) + + +def _resolved_repo_root(test_path: Path) -> Optional[Path]: + """Walk the *fully resolved* (canonical) test path up to the nearest ``.git``. + + Unlike the lexical anchor, this is reliable across harmless system symlinks + (e.g. macOS ``/var`` → ``/private/var``) because every path is canonical, so + it is the anchor used to bound a runner *config file* that may itself be a + symlink escaping the repository.""" + try: + directory = test_path.resolve().parent + except (OSError, RuntimeError): + return None + for _ in range(4096): + if (directory / ".git").exists(): + return directory + parent = directory.parent + if parent == directory: + break + directory = parent + return None + + +def _config_allowed(config_path: Path, repo_root: Optional[Path]) -> bool: + """A runner config is adoptable only if its *resolved* path is a regular file + inside the anchored repository (``repo_root``). This refuses a config file + that is itself a symlink escaping the repository, and a broken symlink. When + no repository is anchored (``repo_root is None``) any existing regular file is + allowed.""" + try: + real = Path(os.path.realpath(str(config_path))) + if not real.is_file(): + return False + except OSError: + return False + if repo_root is None: + return True + return _is_within(real, repo_root) + + +_VITE_CONFIG_NAMES = ( + "vite.config.ts", "vite.config.js", "vite.config.mjs", + "vite.config.cjs", "vite.config.mts", "vite.config.cts", +) + + +# Runners that execute a PACKAGE BINARY directly (never a package.json script): the +# next non-flag token IS the binary. ``npx vitest`` / ``bunx vitest`` run the vitest +# executable. +_DIRECT_RUNNERS = frozenset({"npx", "bunx", "pnpx"}) +# Runners whose bare form (``pnpm vitest``) or ``run `` (``npm run vitest``) invokes +# a package.json SCRIPT — which may be a Vite-only shadow, NOT the vitest binary — so a +# binary is only proven via an explicit exec sub-command (``pnpm exec vitest``, +# ``pnpm dlx vitest``, ``bun x vitest``). A bare command or ``run`` fails CLOSED. +_EXEC_RUNNERS = frozenset({"npm", "pnpm", "yarn", "bun"}) +_EXEC_SUBCMDS = frozenset({"exec", "dlx", "x"}) +# Boolean runner flags safe to skip. Any OTHER ``-``-prefixed option (an arg-taking flag +# such as ``npx --package`` / ``pnpm --filter``, or an unknown flag) fails closed. +_RUNNER_BOOL_FLAGS = frozenset({"--yes", "-y"}) +# Shell control operators that separate command clauses. +_CLAUSE_OPERATORS = frozenset({";", "&", "&&", "|", "||"}) +# A package.json script value longer than this is not parsed for vitest — it fails proof +# closed. Real test scripts are short; a ~1 MB no-whitespace value would make the shell +# lexer build one quadratic-cost token. (Bounds the lexing work per script; the ancestor +# walk reads a bounded number of manifests, so the aggregate stays bounded too.) +_MAX_SCRIPT_LEN = 4096 + + +def _binary_after_flags_is_vitest(tokens: list) -> bool: + """Return True iff the invoked binary (skipping boolean flags) names ``vitest``. An + arg-taking/unknown flag fails closed, so ``npx --package vitest echo ok`` — where + ``vitest`` is the option's VALUE, not the binary — is False. A ``--`` options + terminator is honored: the token right after it is the positional command, so + ``npm exec -- vitest`` is True.""" + for j, tok in enumerate(tokens): + if tok == "--": + nxt = tokens[j + 1] if j + 1 < len(tokens) else "" + return nxt.split("/")[-1] == "vitest" + if tok in _RUNNER_BOOL_FLAGS: + continue + if tok.startswith("-"): + return False + return tok.split("/")[-1] == "vitest" + return False + + +def _unwrap_env(rest: list) -> Optional[list]: + """Return the command operands of a safe, understood ``env`` invocation.""" + j = 0 + valid = True + while j < len(rest) and valid: + tok = rest[j] + if tok == "--": + j += 1 + break + if tok in ("-i", "--ignore-environment"): + j += 1 + continue + # Null-output mode rejects a utility, while split-string reparses its + # value as the command. Neither can be modeled by simply skipping an + # option/value pair, so both forms fail proof closed. + if tok in ("-0", "--null", "-S", "--split-string") or tok.startswith( + "--split-string=" + ): + valid = False + continue + if tok in ("-u", "--unset", "-C", "--chdir"): + if j + 1 >= len(rest): + valid = False + else: + j += 2 + continue + if tok.startswith(("--unset=", "--chdir=")): + j += 1 + continue + if tok.startswith("-"): + valid = False + continue + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tok): + j += 1 + continue + break + # ``exec`` has no external counterpart on standard systems; ``command`` may + # be the POSIX utility. + if valid and j < len(rest) and rest[j].split("/")[-1] == "exec": + valid = False + return rest[j:] if valid else None + + +def _unwrap_command(rest: list) -> Optional[list]: + """Return operands for executing ``command``, excluding query forms.""" + j = 0 + while j < len(rest) and rest[j] == "-p": + j += 1 + if j < len(rest) and rest[j] == "--": + j += 1 + if j >= len(rest) or rest[j].startswith("-"): + return None + return rest[j:] + + +def _unwrap_exec(rest: list) -> Optional[list]: + """Return command operands for an understood Bash ``exec`` invocation.""" + j = 0 + valid = True + while j < len(rest) and valid: + tok = rest[j] + if tok == "--": + j += 1 + break + if tok in ("-c", "-l", "-cl", "-lc"): + j += 1 + continue + if tok == "-a": + if j + 1 >= len(rest): + valid = False + else: + j += 2 + continue + if tok.startswith("-"): + valid = False + continue + break + if valid and j < len(rest) and rest[j].split("/")[-1] == "exec": + valid = False + return rest[j:] if valid else None + + +_SHELL_WRAPPER_UNWRAPPERS = { + "env": _unwrap_env, + "command": _unwrap_command, + "exec": _unwrap_exec, +} + + +def _clause_invokes_vitest(tokens: list) -> bool: + """True when a command clause invokes Vitest in executable position.""" + if not tokens: + return False + while tokens: + idx = 0 + while idx < len(tokens) and re.match( + r"^[A-Za-z_][A-Za-z0-9_]*=", tokens[idx] + ): + idx += 1 + if idx >= len(tokens): + return False + cmd = tokens[idx].split("/")[-1] + rest = tokens[idx + 1:] + unwrap = _SHELL_WRAPPER_UNWRAPPERS.get(cmd) + if unwrap is None: + break + tokens = unwrap(rest) + if tokens is None: + return False + if cmd == "vitest": + return True + if cmd in _DIRECT_RUNNERS: + return _binary_after_flags_is_vitest(rest) + if cmd in _EXEC_RUNNERS: + if rest and rest[0] in _EXEC_SUBCMDS: + return _binary_after_flags_is_vitest(rest[1:]) + return False # bare command or `run ` → a script, not the binary + return False + + +def _strip_shell_comments(script: str) -> str: + """Remove Bash ``#`` comments from ``script``, preserving quote/escape/newline + provenance that a token-only view loses. A ``#`` starts a comment ONLY when it is + unquoted, unescaped, and at a word boundary (start of string, or after unquoted + whitespace or a control operator); it then runs to the end of THAT line (the + newline is kept). A quoted (``"# x"``), escaped (``\\#``), or mid-word (``a#b``) + ``#`` is literal and preserved.""" + out: list = [] + in_single = in_double = False + at_boundary = True + i, n = 0, len(script) + while i < n: + c = script[i] + if c == "\\" and not in_single: + out.append(c) + if i + 1 < n: + out.append(script[i + 1]) + i += 2 + else: + i += 1 + at_boundary = False + continue + if c == "'" and not in_double: + in_single = not in_single + out.append(c) + at_boundary = False + i += 1 + continue + if c == '"' and not in_single: + in_double = not in_double + out.append(c) + at_boundary = False + i += 1 + continue + if c == "#" and not in_single and not in_double and at_boundary: + while i < n and script[i] != "\n": # comment to end of THIS line + i += 1 + continue + out.append(c) + at_boundary = c.isspace() or c in ";&|<>()" + i += 1 + return "".join(out) + + +def _script_invokes_vitest(script: str) -> bool: + """True when a package.json script actually INVOKES vitest as a command (in + executable position, or via a supported ``npx``/``pnpm``/``yarn``/``bun`` runner), + not merely mentioning the string. Bash ``#`` comments are removed first with full + quote/escape/newline provenance (see :func:`_strip_shell_comments`) — so a quoted or + escaped ``#`` is literal, a mid-word ``#`` is literal (``echo a#b && npx vitest`` + proves), and an unquoted comment ends only its own line. Each line is then split into + command clauses on unquoted control operators (``;`` ``&`` ``&&`` ``|`` ``||``) with a + QUOTE-/ESCAPE-aware lexer, so ``echo x\\; vitest`` stays one clause. An oversized + script (a manifest padding attack) and a malformed (unbalanced-quote) script both fail + closed. A here-document / here-string (``<<``) is refused: its BODY is data, not + commands, so a ``vitest`` line inside a ``cat < _MAX_SCRIPT_LEN: + return False + if "<<" in script: # heredoc/here-string body text is not an executed command + return False + try: + for line in _strip_shell_comments(script).split("\n"): + lex = shlex.shlex(line, posix=True, punctuation_chars=True) + lex.whitespace_split = True + lex.commenters = "" # comments already stripped above + clause: list = [] + for tok in lex: + if tok in _CLAUSE_OPERATORS: + if _clause_invokes_vitest(clause): + return True + clause = [] + else: + clause.append(tok) + if _clause_invokes_vitest(clause): + return True + return False + except ValueError: + return False + + +def _vitest_proven_by_manifest(manifest: dict) -> bool: + """True when a ``package.json`` manifest proves Vitest is the test runner — so a + bare ``vite.config.*`` (which Vitest loads as its config) may be adopted. Proof is + ``vitest`` declared in any dependency map with a STRING version spec, or a script + that actually invokes the ``vitest`` command (a token whose basename is ``vitest``). + Without such proof an ordinary Vite *application* (which also has a ``vite.config.*`` + but no tests, and whose scripts merely mention the string) MUST NOT be treated as a + test project. A non-string dependency value (``"vitest": false``/``null``/a number) is + not a valid package spec — npm normalization rejects it — so it does NOT prove Vitest.""" + for key in ("dependencies", "devDependencies", + "peerDependencies", "optionalDependencies"): + deps = manifest.get(key) + if isinstance(deps, dict) and isinstance(deps.get("vitest"), str): + return True + scripts = manifest.get("scripts") + if isinstance(scripts, dict): + for val in scripts.values(): + if isinstance(val, str) and _script_invokes_vitest(val): + return True + return False + + +def _package_json_runner(search_dir: Path, is_spec: bool, + repo_root: Optional[Path]) -> Optional[Tuple[str, Path]]: + """Fallback runner detection from ``search_dir/package.json`` when no dedicated + ``jest.config.*``/``vitest.config.*`` file is present: + + * Jest reads its config from a top-level ``"jest"`` object in ``package.json``, so a + package whose only Jest configuration is that key is still a Jest project. + * Vitest, only when the manifest *proves* it is in use (see + :func:`_vitest_proven_by_manifest`), loads ``vite.config.*`` as its config. + + The manifest is read bounded and parsed strictly (npm/Node ``JSON.parse`` + semantics via ``_reject_json_constant``); any malformed/oversized/non-object + manifest fails closed (``None``). Both the manifest and any adopted + ``vite.config.*`` are subject to repository containment. (``is_spec`` is + irrelevant here — Jest/Vitest both run ``.spec`` files; Playwright has no + ``package.json`` config form.)""" + manifest_path = search_dir / "package.json" + if not _config_allowed(manifest_path, repo_root): + return None + text = _read_manifest_text(manifest_path) + if text is None: + return None + try: + manifest = json.loads(text, parse_constant=_reject_json_constant) + except (ValueError, RecursionError): + return None + if not isinstance(manifest, dict): + return None + if isinstance(manifest.get("jest"), dict): + return ("npx jest --no-coverage --runTestsByPath", search_dir) + if _vitest_proven_by_manifest(manifest): + for name in _VITE_CONFIG_NAMES: + cfg = search_dir / name + if cfg.exists() and _config_allowed(cfg, repo_root): + return ("npx vitest run", search_dir) + return None + + +def _find_runner_here(search_dir: Path, is_spec: bool, repo_root: Optional[Path]) -> Optional[Tuple[str, Path]]: + """Return the runner ``(command_prefix, search_dir)`` for the highest-priority + runner config present in ``search_dir`` and allowed by containment, else + ``None``. Playwright is only considered for ``.spec`` files. A dedicated config + file wins; otherwise ``package.json`` is consulted for an inline Jest config or a + proven-Vitest ``vite.config.*``.""" + for command, names, spec_only in _RUNNER_CONFIGS: + if spec_only and not is_spec: + continue + for name in names: + cfg = search_dir / name + if cfg.exists() and _config_allowed(cfg, repo_root): + return (command, search_dir) + return _package_json_runner(search_dir, is_spec, repo_root) + + def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: """Detect Playwright, Jest, or Vitest config by walking up from the test file. For .spec.ts/.spec.tsx files, checks for playwright.config first. Returns (command, config_directory) tuple if a config is found, otherwise None. The config_directory is where the test runner config lives — callers must use it as cwd. + + The nearest ancestor config wins. The upward walk stops at the JS project + boundary — the nearest ``package.json`` — rather than after a fixed number of + parents, so a colocated test many directories below its runner config (e.g. a + page test under ``frontend/src/app/hackathon/[eventId]/team/__tests__/`` and + the config at ``frontend/jest.config.js``) still finds it. Two refinements + keep that boundary correct in monorepos: + + * A *workspace leaf* package has its own ``package.json`` yet inherits its + runner config from the workspace root, so when the leaf belongs to an + ancestor workspace (``workspaces`` field / ``pnpm-workspace.yaml`` / + ``lerna.json``) the walk continues *through* the leaf to the workspace root. + * An *independent* package must not adopt an unrelated ancestor's config, so + the walk stops at its ``package.json`` and never crosses the repository root + (``.git``). A hard iteration cap guards against pathological paths. + + When the leaf is a proven workspace member, the declaring workspace *root* + becomes a traversal ceiling: intermediate independent ``package.json`` + manifests between the leaf and that root do not stop the walk, so the + root-level runner config is still reached. A symlinked test directory that + resolves outside the repository is refused (repository containment) so the + walk cannot be smuggled into an out-of-repo config. + + Jest is invoked with ``--runTestsByPath`` so the resolved absolute path is + matched literally (see ``get_test_command_for_file`` for how the path is + escaped/quoted per runner). Jest otherwise treats the trailing path as a + regex, and Next.js dynamic-route segments such as ``[eventId]``/``[slug]`` are + regex character classes that never match the literal bracketed path — leaving + the generated suite unexecutable. """ + # Reject a path where a ``..`` component traverses a symlink: ``os.path.abspath`` + # collapses ``..`` textually, which is unsound across symlinks and would let a + # crafted path mis-anchor repository containment. When the naive collapse and + # the true resolution disagree, fail closed. + try: + if os.path.realpath(os.path.abspath(str(test_path))) != os.path.realpath(str(test_path)): + return None + except OSError: + return None + is_spec = test_path.name.endswith(('.spec.ts', '.spec.tsx')) - search_dir = test_path.resolve().parent - for _ in range(5): # Walk up at most 5 levels - # For .spec.ts/.spec.tsx files, check Playwright first - if is_spec and any((search_dir / cfg).exists() for cfg in ('playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs')): - return ("npx playwright test", search_dir) - if any((search_dir / cfg).exists() for cfg in ('jest.config.js', 'jest.config.ts', 'jest.config.mjs')): - return ("npx jest --no-coverage --", search_dir) - if any((search_dir / cfg).exists() for cfg in ('vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs')): - return ("npx vitest run", search_dir) + try: + search_dir = test_path.resolve().parent + except (OSError, RuntimeError): + # A self-referential (looping) or otherwise unresolvable symlink path → + # refuse smart-runner discovery rather than crash the caller. + return None + # Repository containment: anchor the repo root lexically (before symlinks are + # resolved). If the resolved test path escapes that root, refuse to adopt any + # out-of-repo runner config. + contain = _lexical_repo_root(test_path) + # Canonical repository of the (real) test file, used to reject a runner config + # file that is itself a symlink escaping the repository — reliable even where + # the lexical anchor is unset because of a harmless system symlink above it. + repo_root = _resolved_repo_root(test_path) + # Per-discovery cache so each ancestor manifest is parsed at most once even as + # the walk revisits ancestors through _workspace_root_for (bounds O(n^2) reads). + ws_cache: dict = {} + # Per-discovery aggregate DP-cell budget shared across every membership check + # in this walk, so repeated matching at each package boundary is bounded. + match_cells: list = [_MAX_MATCH_CELLS] + # Per-discovery aggregate brace-scan budget, shared the same way, so a repo with + # a pathological nested-brace glob at several ancestors cannot spend a full + # scan budget at each level. + brace_work: list = [_MAX_BRACE_SCAN_WORK] + # Per-discovery aggregate brace-expansion *count* budget, shared the same way, + # so a glob that expands to many patterns re-evaluated at every ancestor cannot + # sum past the cap. + brace_expand: list = [_MAX_BRACE_EXPANSION] + # Deepest ancestor-workspace root the original leaf must still reach; walk + # *through* intermediate independent package.json manifests until then. + ceiling: Optional[Path] = None + for _ in range(80): + # Never step outside the repository (e.g. via a symlink that resolves out). + if contain is not None and not _is_within(search_dir, contain): + break + found = _find_runner_here(search_dir, is_spec, repo_root) + if found is not None: + return found + # Lexical presence: a ``package.json`` that is a *dangling* symlink still + # marks a JS project boundary. ``Path.exists()`` returns False for it, which + # would let the walk slip past an independent package and adopt an unrelated + # ancestor's config — so use ``os.path.lexists`` and fail closed (stop). + pkg_here = os.path.lexists(str(search_dir / "package.json")) + # Extend the workspace ceiling from a proven member manifest here — or when + # we have reached the current ceiling, so a nested workspace root that lacks + # its own package.json can still chain outward. + member_root: Optional[Path] = None + if pkg_here or (ceiling is not None and search_dir == ceiling): + member_root = _workspace_root_for( + search_dir, ws_cache, match_cells, brace_work, brace_expand) + if member_root is not None and (ceiling is None or _is_strict_ancestor(member_root, ceiling)): + ceiling = member_root + # Stop at the declaring workspace root, even when it has no package.json of + # its own (e.g. a pnpm/lerna root). If it was itself proven a member of an + # outer workspace, ``ceiling`` was extended above and this does not fire. + if ceiling is not None and search_dir == ceiling: + break + # Independent JS project boundary that is not a workspace member and not + # below the ceiling → stop. + if pkg_here: + below_ceiling = ceiling is not None and _is_strict_ancestor(ceiling, search_dir) + if member_root is None and not below_ceiling: + break + # Never escape the repository, even absent an in-project config. + if (search_dir / ".git").exists(): + break parent = search_dir.parent if parent == search_dir: break @@ -104,14 +1840,42 @@ def get_test_command_for_file(test_file: str, language: Optional[str] = None) -> runner_result = _detect_ts_test_runner(test_path) if runner_result: runner_cmd, config_dir = runner_result - return TestCommand(command=f"{runner_cmd} {test_path.resolve()}", cwd=config_dir) + resolved = str(test_path.resolve()) + # Playwright treats its positional argument as a regular expression, so + # a literal path (e.g. a Next.js dynamic route like ``[slug]``) must be + # regex-escaped to match. Jest ``--runTestsByPath`` and Vitest take the + # path literally. In every case the argument is shell-quoted because + # callers run the command string with ``shell=True`` — an unquoted path + # with spaces or shell metacharacters would otherwise be re-split or + # (for bracket globs / ``$()``) reinterpreted by the shell. + # + # ``command`` is a POSIX-shell command string, matching how every pdd + # caller executes verify commands (``subprocess.run(..., shell=True)`` + # or ``shlex.split``). ``shlex.quote`` is therefore the correct quoting + # here. Making runner execution safe under Windows ``cmd.exe`` would + # require moving all callers to an argv list + ``shell=False`` — a + # pre-existing, cross-cutting change to pdd's command-as-string + # convention that is out of scope for runner detection. + if runner_cmd.startswith("npx playwright"): + target = shlex.quote(re.escape(resolved)) + else: + target = shlex.quote(resolved) + return TestCommand(command=f"{runner_cmd} {target}", cwd=config_dir) # 2. Check CSV for run_test_command lang_formats = _load_language_format() if ext in lang_formats: csv_cmd = lang_formats[ext].get('run_test_command', '').strip() if csv_cmd: - return TestCommand(command=csv_cmd.replace('{file}', str(test_file))) + # Shell-quote the substituted path via a shell-lexical-aware single pass: + # callers run this string with ``shell=True``, so an unquoted path with + # metacharacters (``/repo/$(touch PWN)/a.test.ts``) would be re-split or + # command-substituted. ``shlex.quote`` is only safe at a bare word, so a + # CSV template that quotes ``{file}`` (``mocha "{file}"``) is refused + # (fall through to smart detection) rather than made injectable. + substituted = shell_safe_substitute(csv_cmd, {'{file}': str(test_file)}) + if substituted is not None: + return TestCommand(command=substituted) # 3. Smart detection if resolved_language: diff --git a/pdd/prompts/agentic_fix_python.prompt b/pdd/prompts/agentic_fix_python.prompt index febeb591cd..8efd90eb9f 100644 --- a/pdd/prompts/agentic_fix_python.prompt +++ b/pdd/prompts/agentic_fix_python.prompt @@ -1,5 +1,9 @@ You are an expert Python developer responsible for implementing the `agentic_fix.py` module. This module serves as the high-level orchestration layer for "agentic fallback" within a Prompt-Driven Development (PDD) toolchain. Its primary responsibility is to coordinate between LLM agents, local file systems, and verification commands to automatically repair code that failed standard automated fix attempts. + +- normal: the standard log level that shows progress summaries without verbose diffs or command-output previews. + + ### Requirements 1. **Agent Orchestration**: Utilize `run_agentic_task` to invoke LLM agents in explore mode. Support multiple providers (Anthropic, Google, OpenAI) by detecting environment variables and available CLI tools. The agent directly modifies files on disk. 2. **Change Detection via Mtimes**: @@ -8,17 +12,26 @@ You are an expert Python developer responsible for implementing the `agentic_fix - Exclude `.git` and `__pycache__` directories from snapshots. 3. **Verification Logic**: - **Preflight**: If the error log is empty or "useless" (e.g., contains only empty XML tags or lacks keywords like "Error", "Exception", "Traceback", or "failed"), run tests locally first to capture actual failure output. - - **Standard Gate** (`_verify_and_log`): Uses `verify_cmd` (with `{test}` and `{cwd}` placeholders) or falls back to `get_run_command_for_file`. If no command is found, default to the Python interpreter. + - **Standard Gate** (`_verify_and_log`): Use `verify_cmd` (with `{test}` and `{cwd}` placeholders) or fall back to `get_run_command_for_file`. If neither supplies a command, default to the Python interpreter. + - **Platform boundary:** This interface emits POSIX-shell command strings for existing callers that execute them with `bash -lc` / `shell=True`. It does not emit native `cmd.exe` or PowerShell syntax. + - **Template provenance:** Treat only an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD` as a template. Substitute `{test}` and `{cwd}` with `shlex.quote` through one left-to-right `shell_safe_substitute` pass. A shell-quoted value MUST work both as a standalone word and beside ordinary literal characters (`{test}x`, `./{test}`, `{test}.log`). + - **Required refusal cases:** Refuse substitution and make the verification gate return failure when a placeholder occurs inside the template's own single/double quotes or backticks (`pytest "{test}"`), immediately after `$` or a backslash, in a shell comment or here-document body, or when the template is multiline. Also refuse a template containing `$`, a backtick, `(`/`)`, or brace/pathname-expansion metacharacters (`{`, `}`, `*`, `?`, `[`, `]`, `~`) outside a recognized placeholder; refuse an empty placeholder key and an escaped placeholder (`\{test}`). These MUST/MUST NOT outcomes are the contract; tokenizer, scan, and masking choices remain implementation details pinned by tests. + - **Single pass and anchoring:** An inserted value containing literal `{test}` or `{cwd}` MUST NOT be scanned again. Resolve both values to absolute paths anchored at the supplied `cwd`, not the process CWD. + - **Finalized commands:** Execute a command returned by `default_verify_cmd_for` as-is and MUST NOT substitute its placeholders again. Track template-versus-finalized provenance explicitly rather than inferring it from environment state. - **Verification Enablement**: Controlled by `PDD_AGENTIC_VERIFY_FORCE` (always on), `verify_cmd` presence, and `PDD_AGENTIC_VERIFY` env var (`"0"` disables, `"auto"` disables, else enabled). Default is enabled. - For non-Python languages, trust the agent's own verification result from the explore task. 4. **Logging & UI**: Use `rich.console` for formatted output. - - Support "quiet", "normal", and "verbose" log levels via `PDD_AGENTIC_LOGLEVEL`. + - Support `quiet`, `normal`, and `verbose` log levels via `PDD_AGENTIC_LOGLEVEL`. - Default to "quiet" if `PYTEST_CURRENT_TEST` is set. - In verbose mode, show unified diffs of changed files and truncated previews of command outputs. 5. **Resilience**: Handle timeouts (`PDD_AGENTIC_VERIFY_TIMEOUT`) for agent calls and verification steps. Ensure instruction files are cleaned up after execution. 6. **Test Protection**: Accept a `protect_tests` flag and pass it to the agent prompt template to prevent the agent from modifying test files. 7. **LLM CSV Discovery**: Implement `find_llm_csv_path` to look for `.pdd/llm_model.csv` in `$HOME` first, then in the project current working directory. + +R1 (MUST NOT): Re-substitute placeholders in a finalized command returned by `default_verify_cmd_for`; execute it as-is. + + ### Dependencies context/get_language_example.py @@ -57,4 +70,4 @@ You are an expert Python developer responsible for implementing the `agentic_fix A single Python file `agentic_fix.py` containing: - The `run_agentic_fix` public function. - Helper functions for mtime snapshots (`_snapshot_mtimes`, `_detect_mtime_changes`), verification (`_verify_and_log`, `_run_testcmd`), and CSV discovery (`find_llm_csv_path`). -- Internal logging helpers (`_info`, `_verbose`, `_print_head`, `_print_diff`) utilizing `rich.console`. \ No newline at end of file +- Internal logging helpers (`_info`, `_verbose`, `_print_head`, `_print_diff`) utilizing `rich.console`. diff --git a/pdd/prompts/agentic_langtest_python.prompt b/pdd/prompts/agentic_langtest_python.prompt index 33e93f267e..07b0e085f1 100644 --- a/pdd/prompts/agentic_langtest_python.prompt +++ b/pdd/prompts/agentic_langtest_python.prompt @@ -1,16 +1,23 @@ context/python_preamble.prompt +get_run_command_python.prompt % Goal Write the pdd/agentic_langtest.py module. % Role & Scope A minimal utility module that provides language-specific test command utilities. -For Python, it returns a pytest command. For all non-Python languages, it returns -None to signal that agentic mode should handle test discovery and execution. +`default_verify_cmd_for` resolves a test command by first consulting +`language_format.csv`'s `run_test_command` for the language, then falling back to a +pytest command for Python, and otherwise returning None so agentic mode handles +test discovery and execution. Every returned command is executed with +`bash -lc` / `shell=True`, so any path spliced into it MUST be shell-quoted +(`shlex.quote`) — bare double quotes are insufficient because `"$(...)"` is still +expanded inside them. -This module is intentionally simple because non-Python languages use agentic mode -for test running, where agents can explore the project structure and determine the -appropriate test framework (Jest, JUnit, Go test, Cargo test, etc.) dynamically. +This module is intentionally simple because languages without a known +`run_test_command` use agentic mode for test running, where agents can explore the +project structure and determine the appropriate test framework (Jest, JUnit, Go +test, Cargo test, etc.) dynamically. % Requirements 1. All functions must be fully type-hinted. @@ -23,11 +30,25 @@ appropriate test framework (Jest, JUnit, Go test, Cargo test, etc.) dynamically. Returns a test command for the given language and test file. -- **Python**: Returns a pytest command: `{sys.executable} -m pytest "{unit_test_file}" -q` -- **All other languages**: Returns `None` to trigger agentic fallback mode +- Resolution: (1) look the language up in `language_format.csv` and, if it has a + `run_test_command`, substitute the test-file path for its `{file}` placeholder; + (2) otherwise, for **Python**, return a pytest command of the form + `{sys.executable} -m pytest -q`; (3) otherwise return `None` to + trigger agentic fallback mode. +- **Shell safety (MUST):** the returned command is executed by pdd callers with + `subprocess.run(..., shell=True)`, so the substituted test-file path MUST be + shell-quoted (e.g. `shlex.quote`) — for BOTH the CSV substitution and the Python + fallback. Bare double quotes are insufficient: `"$(...)"` is still evaluated by + the shell inside double quotes, so an unquoted/naively-quoted path containing + spaces or shell metacharacters (`$()`, `;`, …) would be re-split or executed as + a command-injection vector. The function normalizes language to lowercase before comparison. + +R1 (MUST): Return only a finalized, single-pass-substituted command whose test-file path is shell-quoted, or `None` when no safe command can be resolved. + + ### `missing_tool_hints(lang: str, verify_cmd: str | None, project_root: Path) -> str | None` Returns guidance if required tools are missing. @@ -37,7 +58,13 @@ Returns guidance if required tools are missing. - Kept for API compatibility. % Dependencies -This module is standalone and has no internal PDD dependencies. +This module imports `shell_safe_substitute` from `pdd.get_run_command` to fill the +`{file}` placeholder of a CSV `run_test_command` template with a shell-quoted value +(single-pass, refusing an unsafe template by returning `None`). It has no other internal +PDD dependencies. + + context/get_run_command_example.py + % Deliverables - Code: `pdd/agentic_langtest.py` diff --git a/pdd/prompts/fix_error_loop_python.prompt b/pdd/prompts/fix_error_loop_python.prompt index 86da0d5bf3..e3f8f12b00 100644 --- a/pdd/prompts/fix_error_loop_python.prompt +++ b/pdd/prompts/fix_error_loop_python.prompt @@ -37,13 +37,17 @@ The module supports both local and cloud execution modes: - Early exits due to errors within the loop (backup creation failures, file read errors, pytest exceptions) - these must use `break` rather than `return` - Initial test/verification exceptions (before the loop starts) - trigger agentic fallback directly if `agentic_fallback=True` Ensure the `error_log_file` is populated with the current state before the agent is called. -7. **Non-Python Support:** Detect non-Python files by extension. Use `get_test_command_for_file` to run initial verification. If it fails, skip the standard pytest loop and go directly to the agentic fallback. +7. **Non-Python Support:** Detect non-Python files by extension. Use `get_test_command_for_file` to run initial verification. If it fails, skip the standard pytest loop and go directly to the agentic fallback. The command returned by `get_test_command_for_file` is already complete — its CSV placeholders are resolved and the (shell-quoted, absolute) test path is embedded — so the initial-verification runner MUST NOT re-run `{file}`/`{test}` placeholder substitution on it before executing. Re-substituting would splice an unbalanced quoted string into the already-quoted argument whenever the resolved test path itself contains that literal token (e.g. a directory named `{test}`), breaking shell quoting and enabling command injection under `shell=True`. Execute the returned command as-is (with its `cwd`). 8. **Cost & Budget Management:** Accumulate `total_cost` from LLM calls and stop the loop immediately if the `budget` is exceeded. 9. **Cloud Execution Support:** Implement `cloud_fix_errors` function that calls the cloud fixCode endpoint with the same interface as `fix_errors_from_unit_tests`. This enables hybrid mode where LLM calls go to the cloud while test execution stays local. When passing failure context to the cloud, **prepend** a `[PDD failure classification] ...` line (or equivalent prefix) to the `errors` string payload so the remote fix step sees the same classification as local `fix_errors_from_unit_tests`. `protect_tests` should be forwarded in the payload (as `protectTests`) and accepted in the `cloud_fix_errors` signature. The hosted `fixCode` backend does not yet read this field, so it is a forward-looking no-op in cloud mode — the flag is fully honored in local mode. Keep the param so the contract matches local `fix_errors_from_unit_tests` and flips on when the backend adds support. 10. **Output Contract:** Return a 6-tuple: `(success, final_unit_test, final_code, total_attempts, total_cost, model_name)`. * *Note:* If checks pass initially, return actual file contents, `0` for `total_attempts`, `0.0` for `total_cost`, and the same model-name convention as implementation (`""` for Python initial pass; `"N/A"` for non-Python initial pass). 11. **Result Normalization:** Implement `_normalize_agentic_result` to handle various return shapes (2, 3, 4, or 5 elements) from the agentic fix to ensure a consistent internal state, specifically extracting `changed_files` if available. Track whether agentic fallback was attempted and used in internal stats or optional metadata while preserving the existing 6-tuple return contract for legacy callers. + +R1 (MUST NOT): Re-substitute `{file}` or `{test}` in a finalized non-Python verification command returned by `get_test_command_for_file`; execute it as-is with its returned working directory. + + 12. **Focused repair for large dev units:** When the code file exceeds 500 lines OR the test file exceeds 1000 lines, activate two-phase focused repair instead of passing full file contents to the LLM: - **Fast-path:** If the traceback names 1–3 specific functions, skip Phase 1 and send only those function slices plus their failing tests directly to `fix_errors_from_unit_tests`. - **Phase 1 — Diagnose:** Send the error log plus a code skeleton (function signatures only, no bodies) to a lightweight LLM call that returns the names of likely-broken functions (`DiagnosisResult` from `fix_focus`). diff --git a/pdd/prompts/get_run_command_python.prompt b/pdd/prompts/get_run_command_python.prompt index 340c3bb2ce..8e18b642b4 100644 --- a/pdd/prompts/get_run_command_python.prompt +++ b/pdd/prompts/get_run_command_python.prompt @@ -18,13 +18,54 @@ - Raises `ValueError` if `PDD_PATH` environment variable is not set. - Handles `FileNotFoundError` or `csv.Error` by printing an error message and returning an empty string. - 2) Function `get_run_command_for_file(file_path: str) -> str`: + 2) Function `shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str]`: + - A SHARED PUBLIC helper (imported by `get_test_command`, `agentic_fix`, and + `agentic_langtest`); it MUST be part of this module's public interface and MUST + NOT be removed or renamed. + - Emits POSIX-shell command strings for PDD's existing `bash -lc` / `shell=True` + callers; native `cmd.exe` and PowerShell command syntax are outside this + interface. + - Fills `{placeholder}` tokens in a shell-command `template` with **shell-quoted** + values (`shlex.quote`) in a SINGLE left-to-right pass (a value that itself + contains a `{...}` token is never rescanned as another placeholder). + - `shlex.quote` yields a self-contained shell word, so a placeholder is safe as a + standalone word AND when concatenated with ordinary literal characters on either + side (`{file}.out`, `./{file}` substitute correctly). It returns `None` (caller + falls through) for any template where non-placeholder syntax could make `bash` + expand the command or change its word count — refuse the template whenever it: + is multiline (here-document body); contains `$`, a backtick, or `(`/`)` + (command-substitution, parameter/arithmetic expansion, or subshell/ + process-substitution); contains a brace-expansion or pathname-expansion + metacharacter (`{`, `}`, `*`, `?`, `[`, `]`, `~`) OUTSIDE a placeholder token + (an unquoted `{a,b}` or glob would re-split/expand into a different word count, + and `shlex.quote` leaves a value's `,` unquoted so a value inside a template + brace would re-split); or places a placeholder inside the template's own quotes, + immediately after a backslash, or inside a shell comment (a `#` starting at a + word boundary or after a `;`/`&`/`|` control operator); or RE-EVALUATES the value + as code — an `eval`; a shell run with `-c` (`bash -c`, a combined `-lc`, a shell + behind an option-bearing wrapper like `timeout 5 bash -c`, or a shell + `-c` hidden + inside a re-parsed command string such as `env -S 'bash -c' {file}`); or the value + piped / here-string fed into a shell as stdin (`printf %s {file} | bash`, + `bash <<< {file}`). (A bare `sh {file}`/`bash {file}` that runs the *file* as a + script, and a non-shell `-c` such as `pytest -c cfg`, stay safe.) It also rejects + an empty placeholder key and an escaped placeholder (`\{file}`). + + 3) Function `get_run_command_for_file(file_path: str) -> str`: - Accepts a file path. - Extracts the extension. - Calls `get_run_command` to get the template. - - Replaces the `{file}` placeholder in the template with the actual `file_path`. + - Fills the `{file}` placeholder via `shell_safe_substitute(template, {'{file}': + file_path})`; returns that string, or an empty string when the helper returns + `None` (unsafe template) or no command is found. Callers execute the returned + command with `bash -lc` / `shell=True`, so an unquoted path with spaces or shell + metacharacters (`$()`, `;`, …) MUST NOT be spliced in raw — it would be re-split + or executed via command substitution (a command-injection vector). - Returns the executable command string or empty string if no command found. + +R1 (MUST): Substitute recognized command-template placeholders exactly once with shell-quoted values, and reject templates whose surrounding shell syntax could reinterpret those values. + + % Data Source The file at `$PDD_PATH/data/language_format.csv` is a CSV with columns: - `extension` (e.g., .py) diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index fb3711ced6..f657286f0a 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -1,20 +1,71 @@ # Role and Scope -You are an expert software engineer implementing the `get_test_command.py` module for the `pdd` project. This module is responsible for resolving the appropriate test execution command for a given file. It acts as a bridge between static language configuration (CSV), project-specific configuration (like Jest or Playwright configs), and agentic fallback mechanisms. - -# Requirements -1. **Resolution Order**: The module must resolve commands in the following priority: - - Smart runner detection for TypeScript/TSX (looking for configuration files). - - Static lookup via `language_format.csv`. - - Heuristic detection via `default_verify_cmd_for`. - - Fallback to `None` to trigger agentic logic. -2. **TypeScript Smart Detection**: - - For `.ts` and `.tsx` files, search upwards (max 5 levels) for `playwright.config`, `jest.config`, or `vitest.config` (supporting `.js`, `.ts`, and `.mjs` extensions). - - If found, return the command with the specific directory containing the config as the `cwd`. - - Prioritize Playwright for files ending in `.spec.ts` or `.spec.tsx`. -3. **Data Classes**: Define a `TestCommand` dataclass to bundle the `command` string and an optional `cwd` (Path). Use `__test__ = False` to avoid discovery by pytest. -4. **CSV Integration**: Load language-specific test commands from `language_format.csv`, searching in both package-relative and project-root-relative `data/` directories. -5. **Path Resolution**: Ensure all paths are handled using `pathlib` for cross-platform compatibility. -6. **Error Handling**: Gracefully handle missing CSV files by returning an empty dictionary, allowing the resolution logic to proceed to the next step. +get_run_command_python.prompt +You are an expert software engineer implementing the `get_test_command.py` module for the `pdd` project. This module resolves the appropriate test execution command for a given file. It bridges static language configuration (CSV), project-specific runner configuration (Jest/Vitest/Playwright), and agentic fallback. + +# Interface +- `TestCommand` dataclass bundles `command: str` and `cwd: Optional[Path]` (default `None`). It sets `__test__ = False` so pytest does not collect it. `cwd=None` means the caller chooses the working directory; `cwd=Path(...)` means the runner config lives there and callers MUST use it as the working directory. +- `get_test_command_for_file(test_file: str, language: Optional[str] = None) -> Optional[TestCommand]` is the public entry point. +- `_detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]` returns `(runner_command_prefix, config_directory)` for a TypeScript/TSX test, or `None`. Internal helper structure beyond this is unconstrained; choose whatever satisfies the contract and passes the tests. + + +- **Runner config**: a `playwright.config`, `jest.config`, or `vitest.config` file. Recognize every extension each runner actually loads, not just `.js`/`.ts`/`.mjs`: Jest reads its config from `.js`, `.mjs`, `.cjs`, `.json`, `.ts`, `.mts`, and `.cts`; Playwright and Vitest read `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, and `.cts`. A project whose only Jest config is `jest.config.cjs` (or `.json`) MUST be detected as a Jest project — omitting `.cjs`/`.json` would wrongly fall through to the tsx default. A dedicated config file is not the ONLY form: when no dedicated file is present in a directory, consult its `package.json` (read bounded, parsed strictly): a top-level `"jest"` OBJECT is an inline Jest config (that package is a Jest project), and — only when the manifest PROVES Vitest is in use (a `vitest` entry in any dependency map — with a STRING version spec, not `false`/`null`/a number — or a script that INVOKES the `vitest` command in executable position, including behind ordinary Bash `env`, `command`, or `exec` wrappers, judged with Bash-accurate quoting/comment/here-document semantics: a `vitest` token that is only an argument, a package-script name, a `command -v`/`-V` query, inside a `#` comment, or inside a here-document/here-string BODY is NOT an invocation, and a multiline/heredoc script may be refused rather than misparsed) — a `vite.config.{ts,js,mjs,cjs,mts,cts}` is Vitest's config. Do NOT treat a bare `vite.config.*` as a test runner for an ordinary Vite *application* that has no vitest dependency/script (false positive). Every such config/manifest is still subject to repository containment. +- **Repository root**: the nearest ancestor directory containing `.git` (a directory in a clone, a file in a worktree). +- **Workspace member**: a package whose path, relative to a declaring ancestor, matches that ancestor's declared package globs under include/exclude semantics (below). Declarations are read from npm/yarn `workspaces` (a list, or `{"packages": [...]}`), `lerna.json` `packages`, and `pnpm-workspace.yaml` `packages`. +- **Fail closed**: on any ambiguous, malformed, unparseable, or resource-exceeding input, treat workspace membership as unproven and do not adopt an ancestor's runner config. +- **Untrusted manifest**: any workspace-declaration file; its contents may be stale, malformed, or hostile and MUST NOT be able to crash, hang, or exhaust memory during discovery. + + + +Stable rule IDs; do not renumber. + +R1 (MUST): Resolve a command in this priority — (1) TypeScript/TSX smart runner detection; (2) `language_format.csv` `run_test_command`; (3) `default_verify_cmd_for` heuristic; (4) `None` to trigger agentic fallback. + +R2 (MUST): For a `.ts`/`.tsx` test, select the runner from the nearest ancestor runner config. Prefer Playwright for `.spec.ts`/`.spec.tsx` when a Playwright config is present; otherwise Jest, otherwise Vitest. The nearest ancestor config wins. + +R3 (MUST): Find a runner config even when the test is colocated many directories below it (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` with config at `frontend/jest.config.js`) — do not give up after a fixed shallow number of parents. + +R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project boundary), except continue through it when that package is a **workspace member**, up to and including its declaring **workspace root**, whose config it inherits. Intermediate independent `package.json` manifests between a member and its workspace root MUST NOT stop the search. + +R5 (MUST NOT): Adopt a runner config from an unrelated ancestor. A merely-present `workspaces` declaration does not make an unrelated sibling (e.g. `vendor/tool`) or an explicitly excluded package a member. The search MUST NOT cross the **repository root**. + +R6 (MUST): Determine workspace membership with the same observable include/exclude results as the declaring ecosystem: `pnpm-workspace.yaml` follows pnpm's ordered per-path matching (last matching pattern wins), while npm/yarn/lerna manifests follow `@npmcli/map-workspaces`, including its negation preprocessing. Both MUST honor later re-inclusion and exclude descendants of `node_modules`; they intentionally differ for sibling paths after a narrow re-inclusion (for `["packages/**", "!packages/legacy/**", "packages/legacy/app"]`, npm includes `packages/legacy/other`, while pnpm excludes it). Apply the chosen ecosystem's minimatch-compatible normalization, brace/glob, dotfile, path-segment, escaping, and UTF-16 `?` behavior; directory names such as Next.js `[eventId]` are input data, not patterns. If faithful parity for a construct cannot be established within R9's budgets, or input is malformed or ambiguous, membership MUST fail closed—never approximate with Python `fnmatch` or the other ecosystem's combination rule. Private parsing and matching helpers are unconstrained; accumulated tests pin compatibility edge cases. + +R7 (MUST): Treat `pnpm-workspace.yaml` as authoritative when present — pnpm ignores the `package.json` `workspaces` field, so a stale or hostile `workspaces` list MUST NOT broaden membership beyond what pnpm honors. + +R8 (MUST): Fail closed for every malformed workspace declaration. Require a strictly parsed mapping and a string-only package-glob list; reject repeated mapping keys, non-UTF-8 input, non-standard JSON constants, YAML values that are not strings under pnpm's YAML 1.2 core schema, missing parsers, and inputs exceeding size or depth limits before full parse. A parse failure MUST NOT fall through to a default workspace glob; only a successfully parsed declaration that omits the package key may use the tool's documented default. + +R9 (MUST): Bound total time and space for one discovery, including manifest reads and parsing, brace expansion, glob count and length, matching work, script inspection, and repeated ancestor checks. Budgets MUST be aggregate and cheap to enforce so brace, comma, slash, wildcard, or nesting attacks terminate without recursion, hangs, or memory exhaustion while ordinary workspaces within the limits still resolve. Exact algorithms are unconstrained. + +R10 (MUST): Enforce **repository containment** so an out-of-repository runner config is never adopted. Anchor the repository root from the test file's lexical (un-resolved) path, unaffected by symlinked path components, then refuse any config once the test file's resolved path escapes that root. A test directory symlinked outside the repository — even one whose target is itself a foreign checkout with its own `.git`, or reached via a `..` component that traverses a symlink — MUST be refused; a symlink that stays inside the repository MUST still resolve normally. A runner **config file** that is itself a symlink (or broken symlink) resolving outside the repository MUST NOT be adopted. + +R11 (MUST): Target the test path literally. Invoke Jest with `--runTestsByPath` so the resolved absolute path (e.g. a Next.js dynamic route `[eventId]`/`[slug]`) is matched verbatim rather than as a regex; Vitest takes the path literally; regex-escape the path for Playwright, whose positional argument is a regular expression. + +R12 (MUST): Shell-quote the appended absolute test path for every runner, because callers execute `command` with `shell=True`; an unquoted path with spaces or shell metacharacters would be re-split or reinterpreted. The returned value is a POSIX-shell command string for PDD's existing bash-compatible callers; native `cmd.exe` and PowerShell command syntax are outside this interface. + +R13 (MUST): Return a detected runner's config directory as `cwd`. For CSV commands, replace only recognized `{file}` placeholders in one left-to-right pass with a self-contained POSIX-shell word and return `cwd=None`; ordinary literal prefixes or suffixes around a placeholder remain accepted. Refuse multiline templates, quoted, escaped, or empty placeholders, here-document or comment placement, and shell syntax that performs expansion, command substitution, pathname expansion, or word-count changes, then fall through safely. Infer a missing language and tolerate a missing CSV. + +R14 (MUST NOT): Re-run `{file}` or `{test}` substitution on a command returned by `get_test_command_for_file`. Returned commands are final, already path-bound and shell-quoted; callers must execute them without re-templating, including when the path itself contains placeholder text. + +R15 (MUST): Treat Vitest as invoked when it occupies executable position behind ordinary Bash `env`, `command`, or `exec` wrappers, while rejecting argument-only mentions and `command -v`/`command -V` queries. + + + +R1: story__pdd_nested_ts_runner_resolution.md, test_resolution_order_formal_verification +R2: story__pdd_nested_ts_runner_resolution.md, test_nearest_config_wins_over_ancestor, test_spec_ts_with_playwright_config_uses_playwright +R3: story__pdd_nested_ts_runner_resolution.md, test_jest_config_found_more_than_five_parents_up +R4: story__pdd_nested_ts_runner_resolution.md, test_walk_finds_workspace_root_config_past_leaf_package_json, test_nested_intermediate_manifest_does_not_stop_walk +R5: story__pdd_nested_ts_runner_resolution.md, test_unrelated_package_under_workspace_root_is_not_a_member, test_walk_stops_at_repository_root_and_does_not_escape +R6: story__pdd_nested_ts_runner_resolution.md, test_membership_semantics_are_source_dependent, test_node_modules_is_never_a_workspace_member +R7: story__pdd_nested_ts_runner_resolution.md, test_pnpm_yaml_is_authoritative_over_stale_package_json +R8: story__pdd_nested_ts_runner_resolution.md, test_non_dict_package_json_does_not_crash, test_pnpm_yaml_duplicate_keys_fail_closed, test_oversized_manifest_fails_closed +R9: story__pdd_nested_ts_runner_resolution.md, test_brace_bomb_membership_fails_closed, test_aggregate_match_work_is_bounded, test_discovery_wide_match_budget_bounds_deep_chain +R10: story__pdd_nested_ts_runner_resolution.md, test_symlinked_test_dir_escaping_repo_is_refused, test_config_file_symlink_escaping_repo_is_refused +R11: story__pdd_nested_ts_runner_resolution.md, test_dynamic_route_bracket_path_is_targeted_literally, test_playwright_bracketed_spec_path_is_regex_escaped +R12: story__pdd_nested_ts_runner_resolution.md, test_resolved_path_is_shell_quoted, test_csv_path_with_shell_metacharacters_is_not_injected +R13: story__pdd_nested_ts_runner_resolution.md, test_detect_ts_test_runner_jest_returns_config_dir, test_placeholder_replacement_formal_verification +R14: story__pdd_nested_ts_runner_resolution.md, test_no_reinjection_via_maliciously_named_path +R15: story__pdd_nested_ts_runner_resolution.md, test_vite_config_adopted_only_when_vitest_is_proven + # Dependencies @@ -29,26 +80,9 @@ You are an expert software engineer implementing the `get_test_command.py` modul context/agentic_langtest_example.py - -# Instructions -1. **Implement `_detect_ts_test_runner(test_path: Path)`**: - - Walk up the directory tree (limit: 5 parents). - - Check for `playwright.config.[ts|js|mjs]` only if the file is a `.spec` file. - - Check for `jest.config` and `vitest.config` variants. - - Return a tuple of `(command_prefix, config_directory)` if found. -2. **Implement `_load_language_format()`**: - - Attempt to locate `language_format.csv` in `pdd/data/` or `../data/`. - - Return a dictionary mapping extensions to CSV rows using `csv.DictReader`. -3. **Implement `get_test_command_for_file(test_file: str, language: Optional[str])`**: - - This is the main entry point. - - Use `get_language` if the language is not provided. - - Execute the resolution order defined in the Requirements. - - Ensure the command returned for TS runners uses the absolute path (`resolve()`) of the test file. - - For CSV commands, replace the `{file}` placeholder with the input file string. + + context/get_run_command_example.py + # Deliverables -A single file `pdd/get_test_command.py` containing: -- `TestCommand` dataclass. -- `_detect_ts_test_runner` helper function. -- `_load_language_format` helper function. -- `get_test_command_for_file` public function. \ No newline at end of file +A single file `pdd/get_test_command.py` exposing the `TestCommand` dataclass, `get_test_command_for_file`, `_detect_ts_test_runner`, and `_load_language_format` (locating `language_format.csv` under package-relative or project-root-relative `data/`), plus whatever internal helpers the contract requires. All paths handled via `pathlib`. diff --git a/tests/fixtures/coverage_contracts/failed_receipt_python.prompt b/tests/fixtures/coverage_contracts/failed_receipt_python.prompt index a0b4bbeab1..89a61ab80e 100644 --- a/tests/fixtures/coverage_contracts/failed_receipt_python.prompt +++ b/tests/fixtures/coverage_contracts/failed_receipt_python.prompt @@ -4,11 +4,11 @@ R1 - Receipt event The system MUST emit a receipt event after a confirmed refund. -R7 - Audit confirmation +R9999 - Audit confirmation The system MUST write an audit confirmation after provider success. R1: story__receipt_missing_acceptance.md -R7: test_R7_broken_syntax +R9999: test_R9999_broken_syntax diff --git a/tests/fixtures/coverage_contracts/fake_tests/test_receipt_failed.py b/tests/fixtures/coverage_contracts/fake_tests/test_receipt_failed.py index cd6b6cb69f..34cca99578 100644 --- a/tests/fixtures/coverage_contracts/fake_tests/test_receipt_failed.py +++ b/tests/fixtures/coverage_contracts/fake_tests/test_receipt_failed.py @@ -1,5 +1,5 @@ """Broken fixture for contract coverage failed-state tests.""" -def test_R7_broken_syntax(: +def test_R9999_broken_syntax(: pass diff --git a/tests/test_agentic_fix.py b/tests/test_agentic_fix.py index 353fb1182a..8f39bf017a 100644 --- a/tests/test_agentic_fix.py +++ b/tests/test_agentic_fix.py @@ -9,6 +9,7 @@ from pdd.agentic_fix import ( run_agentic_fix, _verify_and_log, + _substitute_verify_template, ) @@ -225,6 +226,106 @@ def test_verify_and_log_with_verify_cmd(self, tmp_path): ) assert result is True + def test_verify_and_log_template_shell_quotes_path(self, tmp_path): + """A template command's {test}/{cwd} substitution is shell-quoted, so a + maliciously named test path cannot inject under `bash -lc`. Provenance is + explicit (verify_cmd_is_template), not inferred from the environment.""" + import shlex + evil = tmp_path / "{test}';touch PWN;echo '" + evil.mkdir() + test_file = evil / "a.py" + test_file.write_text("print('x')\n") + captured = {} + with patch("pdd.agentic_fix._run_testcmd", + side_effect=lambda cmd, cwd: captured.update(cmd=cmd) or True): + _verify_and_log(str(test_file), tmp_path, verify_cmd="pytest {test}", + enabled=True, verify_cmd_is_template=True) + argv = shlex.split(captured["cmd"]) + assert str(test_file.resolve()) in argv, (captured["cmd"], argv) + assert "touch" not in argv, argv + + def test_verify_template_with_quoted_placeholder_is_refused(self, tmp_path): + """`shlex.quote` only neutralizes metacharacters when the placeholder is a + standalone bare word. A template that nests `{test}`/`{cwd}` inside its own + quotes (e.g. `pytest "{test}"`) would let the inserted single quotes become + literal and still execute a `$(...)` in the resolved path — so such a template + is refused (no command built) rather than turned injectable.""" + import shlex + evil = tmp_path / "$(touch PWN)" + evil.mkdir() + test_file = evil / "a.py" + test_file.write_text("print('x')\n") + # Bare-word placeholder: substituted safely (single-quoted, $() is literal). + safe = _substitute_verify_template("pytest {test}", str(test_file), tmp_path) + assert safe is not None and "touch" in safe # present, but inside single quotes + assert safe.count("'") >= 2 and "$(touch PWN)" in safe + # Placeholders inside the template's own quotes are refused (None). A + # placeholder merely *adjacent* to ordinary literal text (``{test}x``) is now + # allowed — shlex.quote yields a self-contained word — so it is NOT in this list. + for tpl in ('pytest "{test}"', "pytest '{test}'", + 'cd "{cwd}" && pytest {test}'): + assert _substitute_verify_template(tpl, str(test_file), tmp_path) is None, tpl + # An adjacent-literal placeholder substitutes and stays inert ($() literal). + adj = _substitute_verify_template("pytest {test}x", str(test_file), tmp_path) + assert adj is not None and shlex.quote(str(test_file.resolve())) + "x" in adj + # End-to-end: a quoted-placeholder template fails the verification gate closed. + with patch("pdd.agentic_fix._run_testcmd") as run: + result = _verify_and_log(str(test_file), tmp_path, + verify_cmd='pytest "{test}"', enabled=True, + verify_cmd_is_template=True) + assert result is False + run.assert_not_called() + + def test_verify_and_log_finalized_command_is_not_resubstituted(self, tmp_path): + """A finalized command (verify_cmd_is_template=False, the default) is + executed as-is — no {test}/{cwd} re-substitution that could corrupt its + quoting when the resolved path contains a literal {test}.""" + import shlex + evil = tmp_path / "{test}';touch PWN;echo '" + evil.mkdir() + test_file = evil / "a.py" + test_file.write_text("print('x')\n") + final = "pytest " + shlex.quote(str(test_file.resolve())) + " -q" + captured = {} + with patch("pdd.agentic_fix._run_testcmd", + side_effect=lambda cmd, cwd: captured.update(cmd=cmd) or True): + _verify_and_log(str(test_file), tmp_path, verify_cmd=final, enabled=True) + assert captured["cmd"] == final # unchanged + assert "touch" not in shlex.split(captured["cmd"]) + + def test_verify_template_resolves_relative_path_against_cwd(self, tmp_path): + """A relative ``unit_test_file`` in a template is resolved against the passed + ``cwd`` (the dir ``run_agentic_fix`` operates in), NOT the process CWD, so the + substituted ``{test}`` targets the same file that was fixed even when pytest + runs from a different directory.""" + import shlex + proj = tmp_path / "proj" + (proj / "tests").mkdir(parents=True) + (proj / "tests" / "t_a.py").write_text("print('x')\n") + cmd = _substitute_verify_template("pytest {test}", "tests/t_a.py", proj) + argv = shlex.split(cmd) + assert argv == ["pytest", str((proj / "tests" / "t_a.py").resolve())], cmd + # {cwd} resolves to the supplied working dir (bare-word placeholder). + cmd2 = _substitute_verify_template("run {cwd}", "tests/t_a.py", proj) + assert shlex.split(cmd2) == ["run", str(proj)], cmd2 + + def test_verify_template_cwd_is_normalized_to_absolute(self, tmp_path, monkeypatch): + """Both {test} and {cwd} must be ABSOLUTE. If {cwd} were left relative, a + template like ``cd {cwd} && pytest {test}`` would double-join the relative cwd + onto the process directory (``/work/repo/repo``). Passing a relative cwd Path + must still yield an absolute {cwd}.""" + import shlex + proj = tmp_path / "proj" + (proj / "tests").mkdir(parents=True) + (proj / "tests" / "t.py").write_text("print('x')\n") + monkeypatch.chdir(tmp_path) + cmd = _substitute_verify_template( + "cd {cwd} && pytest {test}", "tests/t.py", Path("proj")) + argv = shlex.split(cmd) + assert argv[1] == str(proj.resolve()), cmd # {cwd} absolute + assert str((proj / "tests" / "t.py").resolve()) in argv, cmd # {test} absolute + assert not argv[1].endswith("proj/proj"), cmd # no double-join + def test_verify_and_log_uses_run_command_for_python(self, tmp_path, monkeypatch): """Should use python run command from CSV for .py files.""" # Set up PDD_PATH to point to actual data directory diff --git a/tests/test_agentic_langtest.py b/tests/test_agentic_langtest.py index 76bdfd9d62..1d91987e16 100644 --- a/tests/test_agentic_langtest.py +++ b/tests/test_agentic_langtest.py @@ -42,6 +42,21 @@ def test_default_verify_cmd_for_python_uppercase(): assert "pytest" in cmd +def test_default_verify_cmd_for_shell_quotes_path_no_injection(): + """The substituted test path must be shell-quoted (callers use shell=True), so + a path with $()/;/spaces cannot inject or re-split under the shell.""" + import shlex + evil = "/repo/$(touch PWN)/a; b.py" + for lang in ("python", "javascript", "go"): + cmd = default_verify_cmd_for(lang, evil) + assert cmd is not None + argv = shlex.split(cmd) + # The malicious path survives as one intact token — never split into a + # `$(touch` / `PWN)` / `;` sequence the shell would act on. + assert evil in argv, (lang, cmd, argv) + assert "$(touch" not in argv, (lang, argv) + + def test_default_verify_cmd_for_javascript_returns_csv_command(): """JavaScript returns a node command from CSV.""" cmd = default_verify_cmd_for("javascript", "test.js") diff --git a/tests/test_coverage_contracts.py b/tests/test_coverage_contracts.py index 725143140c..a3a98eb138 100644 --- a/tests/test_coverage_contracts.py +++ b/tests/test_coverage_contracts.py @@ -1040,7 +1040,7 @@ def test_failed_fixture_has_failed_rules(self): ) statuses = {rule.rule_id: rule.status for rule in result.rules} assert statuses["R1"] == STATUS_FAILED - assert statuses["R7"] == STATUS_FAILED + assert statuses["R9999"] == STATUS_FAILED def test_legacy_prompt_safe(self): result = build_coverage( diff --git a/tests/test_get_run_command.py b/tests/test_get_run_command.py index 3221bdac5e..ba04b068a1 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -1,6 +1,14 @@ import pytest import os -from pdd.get_run_command import get_run_command, get_run_command_for_file +import shlex +import subprocess +import tempfile +from pathlib import Path +from pdd.get_run_command import ( + get_run_command, + get_run_command_for_file, + shell_safe_substitute, +) # Mock CSV data with run_command column mock_csv_data = """language,comment,extension,run_command @@ -96,8 +104,20 @@ def test_get_run_command_for_go_file(self, mock_environment, mock_csv_file): assert get_run_command_for_file('/path/to/main.go') == 'go run /path/to/main.go' def test_get_run_command_for_file_with_spaces(self, mock_environment, mock_csv_file): - """Tests get_run_command_for_file for files with spaces in path.""" - assert get_run_command_for_file('/path/to/my script.py') == 'python /path/to/my script.py' + """A path with spaces must be shell-quoted (callers run it via bash -lc).""" + import shlex + result = get_run_command_for_file('/path/to/my script.py') + assert result == "python '/path/to/my script.py'" + assert shlex.split(result) == ['python', '/path/to/my script.py'] + + def test_get_run_command_for_file_shell_metacharacters_not_injected(self, mock_environment, mock_csv_file): + """A path with $()/;/spaces must not inject under bash -lc / shell=True.""" + import shlex + evil = '/repo/$(touch PWN)/a; b.py' + result = get_run_command_for_file(evil) + argv = shlex.split(result) + assert argv == ['python', evil], (result, argv) + assert '$(touch' not in argv, argv def test_get_run_command_for_non_executable(self, mock_environment, mock_csv_file): """Tests get_run_command_for_file for non-executable files.""" @@ -117,3 +137,207 @@ def test_get_run_command_for_file_missing_environment(self, monkeypatch): monkeypatch.delenv("PDD_PATH", raising=False) with pytest.raises(ValueError, match="PDD_PATH environment variable is not set"): get_run_command_for_file('/path/to/script.py') + + def test_adjacent_placeholder_csv_templates_still_resolve(self): + """Shipped CSV templates whose ``{file}`` sits adjacent to literal text — + Fortran ``gfortran -o {file}.out {file} && ./{file}.out`` and Pascal + ``fpc {file} && ./{file}`` — MUST still produce a command (a prior over-strict + adjacency rejection returned ``''`` and silently disabled crash verification + for those languages). Uses the REAL project CSV via PDD_PATH.""" + import shlex as _shlex + repo = str(Path(__file__).parent.parent) + os.environ["PDD_PATH"] = repo + try: + f90 = get_run_command_for_file("/proj/main.f90") + pas = get_run_command_for_file("/proj/main.pas") + finally: + os.environ.pop("PDD_PATH", None) + assert f90 and "gfortran" in f90 and "/proj/main.f90" in f90, f90 + assert pas and "fpc" in pas and "/proj/main.pas" in pas, pas + # A metacharacter path stays inert (single-quoted) even in adjacency. + evil_path = "/p/$(touch PWN).f90" + os.environ["PDD_PATH"] = repo + try: + evil = get_run_command_for_file(evil_path) + finally: + os.environ.pop("PDD_PATH", None) + assert _shlex.quote(evil_path) in evil, evil # payload single-quoted + assert "touch" in evil and " $(touch PWN)" not in evil # never a bare $() + + +class TestShellSafeSubstitute: + """Direct tests for the shell-lexical-aware substitution helper. + + The result is executed with ``shell=True`` by callers, so every inserted + value must be neutralized (``shlex.quote``) AND placed only where quoting + can protect it — a standalone, unquoted bare word.""" + + def test_bare_word_value_is_single_quoted_and_inert(self): + """A metacharacter-laden value at a bare-word placeholder is single-quoted, + so ``$(...)``/``;`` in the path are literal, not executed.""" + evil = "/x/$(touch PWN)/a; b.py" + out = shell_safe_substitute("pytest {file}", {"{file}": evil}) + assert shlex.split(out) == ["pytest", evil], out + assert "$(touch" not in shlex.split(out) + + def test_quoted_or_dollar_prefixed_placeholder_is_refused(self): + """A placeholder nested in the template's own quotes/backticks, or immediately + preceded by ``$``/``\\`` (``${file}`` → ``$'...'`` ANSI-C), cannot be made safe + by ``shlex.quote`` — the helper returns None so the caller falls through rather + than emit an injectable command. A space-surrounded placeholder that is still + *inside* an enclosing quote is refused too.""" + for tpl in ('pytest "{file}"', "pytest '{file}'", "run `{file}`", + 'echo "a {file} b"', "echo ${file}"): + assert shell_safe_substitute(tpl, {"{file}": "x"}) is None, tpl + + def test_literal_adjacency_is_safe_and_inert(self): + """A placeholder adjacent to ORDINARY literal word characters (a suffix + ``{file}.out`` or prefix ``./{file}``) is safe: ``shlex.quote`` yields a + self-contained word, so concatenated literals extend the same argument and a + ``$(...)`` in the value stays inert. Verified by real ``bash -lc`` execution.""" + # Fortran/Pascal-style adjacent templates substitute (no longer refused). + out = shell_safe_substitute( + "gfortran -o {file}.out {file}", {"{file}": "main.f90"}) + assert out == "gfortran -o main.f90.out main.f90", out + # A malicious value in adjacency does not execute under bash -lc. + cmd = shell_safe_substitute( + "echo pre-{file}.suf", {"{file}": "$(touch PWN_ADJ)"}) + with tempfile.TemporaryDirectory() as d: + proc = subprocess.run(["bash", "-lc", cmd], cwd=d, + capture_output=True, text=True, timeout=10) + assert "PWN_ADJ" not in os.listdir(d), os.listdir(d) + assert "$(touch PWN_ADJ)" in proc.stdout, proc.stdout + + def test_heredoc_comment_and_multiline_are_refused(self): + """A here-document body, a shell comment, or any multiline template is not an + ordinary word context — ``shlex.quote`` single-quoting does not stop a + ``$(...)`` in a heredoc body, and a newline in the value would break out of a + comment. All are refused (None). Verified by real ``bash -lc`` execution of the + naive (rejected) form to prove the exploit is real.""" + heredoc = "cat <&2)") + "))" + subprocess.run(["bash", "-lc", naive], cwd=d, + capture_output=True, text=True, timeout=10) + assert "PWN_AR" in os.listdir(d), "expected the naive arithmetic form to inject" + + def test_values_are_not_rescanned_for_other_placeholders(self): + """Substitution is single-pass: a value that itself contains another + placeholder's text is inserted verbatim (single-quoted), never re-expanded. + A sequential ``str.replace`` chain would wrongly expand ``{b}`` inside a's + value into ``$(touch PWN)``.""" + out = shell_safe_substitute( + "run {a} {b}", {"{a}": "{b}", "{b}": "$(touch PWN)"}) + assert shlex.split(out) == ["run", "{b}", "$(touch PWN)"], out + assert "touch" in out # present, but literal inside the quotes for {b} + # The '{b}' inserted for {a} was NOT re-expanded into the $() payload. + assert out.count("$(touch PWN)") == 1 + + def test_absent_placeholder_returns_template_unchanged(self): + """A template with no placeholder is returned as-is (still a valid command).""" + assert shell_safe_substitute("pytest --version", {"{file}": "x"}) == "pytest --version" + + def test_empty_key_and_escaped_placeholder_are_handled(self): + """An empty placeholder key would match at every position and never advance the + cursor — it must be rejected up front (no hang), returning None. An ESCAPED + placeholder (``\\{test}``) cannot be filled and is declined rather than emitted + with the token left unresolved.""" + # Empty key → None, and crucially returns quickly (no infinite loop). + assert shell_safe_substitute("echo", {"": "x"}) is None + assert shell_safe_substitute("pytest {test}", {"": "x", "{test}": "/a.py"}) is None + # Escaped placeholder → None (unfillable), while the unescaped form still works. + assert shell_safe_substitute(r"pytest \{test}", {"{test}": "x"}) is None + assert shell_safe_substitute("pytest {test}", {"{test}": "/a.py"}) == "pytest /a.py" + + def test_brace_and_glob_contexts_are_refused(self): + """A brace-expansion or pathname-expansion metacharacter OUTSIDE a placeholder + (`{a,b}`, `*`, `?`, `[…]`, `~`) would change the command's word count under bash + (and a value's ``,`` is left unquoted by ``shlex.quote``, so it would re-split + inside a template brace). Such templates are refused. Ordinary adjacency + (`{file}.out`, `./{file}`) is unaffected. Proven with real ``bash -lc``.""" + for tpl in ('printf "<%s>" pre{{file},tail}', "ls {file}*", "ls {file}[abc]", + "cat ~/{file}", "echo {a,b}{file}"): + assert shell_safe_substitute(tpl, {"{file}": "a,b"}) is None, tpl + # A comma-bearing value in a NON-brace template stays a single argument. + cmd = shell_safe_substitute("python {file}", {"{file}": "a,b"}) + assert cmd == "python a,b" + proc = subprocess.run(["bash", "-lc", cmd.replace("python", "printf '%s\\n'")], + capture_output=True, text=True, timeout=10) + assert proc.stdout == "a,b\n", proc.stdout # one argument, not brace-split + + def test_reevaluation_contexts_are_refused(self): + """A template that RE-EVALUATES the value as code — ``eval`` or a shell with + ``-c`` — is refused, because the second parse undoes ``shlex.quote``. A bare + ``sh {file}`` / ``bash {file}`` (run the file as a script — the shipped + Shell/Bash/Zsh run-commands) and a non-shell ``-c`` option (``pytest -c cfg``) + are safe and still substitute. Proven with real ``bash -lc`` execution.""" + for tpl in ("eval {file}", "bash -c {file}", "sh -c {file}", "dash -c {file}", + "build && eval {file}", "CI=1 eval {file}", + # combined short-option groups (`-lc`, `-xc`), wrappers, and a + # mid-word `#` (which bash does NOT treat as a comment) hiding a clause + "bash -lc {file}", "sh -xc {file}", "env bash -c {file}", + "command sh -c {file}", "env FOO=1 bash -lc {file}", + "echo a#b && bash -c {file}", + # option-bearing wrappers whose operands hide the shell command + "timeout 5 bash -c {file}", "env -i bash -c {file}", + "nice -n 5 bash -c {file}", "command -- sh -c {file}", + "nohup bash -lc {file}", + # value piped or here-string'd into a shell (re-evaluated as code) + 'printf "%s" {file} | bash', "printf %s {file} | sh", + "bash <<< {file}", "sh < {file}", + # `env -S` re-parses a string that hides a shell + -c (placeholder is + # a bare word OUTSIDE the quotes, so it is otherwise accepted) + "env -S 'bash -c' {file}", 'env -S "bash -c" {file}', + r"env -S bash\ -c {file}", r"env -Sbash\ -c {file}", + "env --split-string='bash -c' {file}"): + assert shell_safe_substitute(tpl, {"{file}": "x"}) is None, tpl + # Safe: the shell runs the FILE (value is a filename, single-quoted); a mid-word + # `#` is literal; a non-shell `-c` option is fine. + assert shell_safe_substitute("sh {file}", {"{file}": "/a.sh"}) == "sh /a.sh" + assert shell_safe_substitute("bash {file}", {"{file}": "/a.sh"}) == "bash /a.sh" + assert shell_safe_substitute( + "pytest -c cfg {file}", {"{file}": "/t.py"}) == "pytest -c cfg /t.py" + assert shell_safe_substitute("echo a#b {file}", {"{file}": "x"}) == "echo a#b x" + # A wrapper around a NON-shell command (no `-c`) is still safe. + assert shell_safe_substitute( + "timeout 5 python {file}", {"{file}": "/t.py"}) == "timeout 5 python /t.py" + # A pipe into a NON-shell command is safe (grep does not re-evaluate the value). + assert shell_safe_substitute( + "printf %s {file} | grep x", {"{file}": "a"}) == "printf %s a | grep x" + # `env` WITHOUT `-S` (ordinary env prefix around a non-shell) is safe. + assert shell_safe_substitute( + "env FOO=1 python {file}", {"{file}": "/t.py"}) == "env FOO=1 python /t.py" + # Prove the bypasses the guard prevents are genuine (naive forms inject). + for naive_tpl, marker in (("eval {V}", "PWN_EVAL"), ("bash -lc {V}", "PWN_LC"), + ("env bash -c {V}", "PWN_ENV"), + ('printf "%s" {V} | bash', "PWN_PIPE"), + ("env -S 'bash -c' {V}", "PWN_ENVS")): + with tempfile.TemporaryDirectory() as d: + naive = naive_tpl.replace("{V}", shlex.quote("$(touch %s)" % marker)) + subprocess.run(["bash", "-lc", naive], cwd=d, capture_output=True, timeout=10) + assert marker in os.listdir(d), (naive_tpl, os.listdir(d)) diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 94659b1325..9457a12226 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -3,11 +3,35 @@ from unittest.mock import patch, mock_open, MagicMock import csv import io +import json +import shlex import sys import os # Import the module under test -from pdd.get_test_command import get_test_command_for_file, _detect_ts_test_runner, TestCommand +from pdd.get_test_command import ( + get_test_command_for_file, + _detect_ts_test_runner, + TestCommand, + _workspace_globs_for, + _belongs_to_ancestor_workspace, + _package_matches_workspace, + _wildcard_segment_match, + _expand_braces, + _BraceBudgetError, + _PatternBudgetError, + _relative_matches_workspace_glob, + _lexical_repo_root, + _find_expandable_brace, + _has_brace_range, + _has_complete_bracket_class, + _has_complete_extglob, + _MAX_GLOB_LENGTH, + _RUNNER_CONFIGS, + _MAX_BRACE_SCAN_WORK, + _MAX_MATCH_CELLS, + _MAX_BRACE_EXPANSION, +) class TestGetTestCommandForFilePython: @@ -185,15 +209,35 @@ def test_relative_path(self, mock_get_lang, mock_smart_detect, mock_load_csv): @patch('pdd.get_test_command.default_verify_cmd_for') @patch('pdd.get_test_command.get_language') def test_path_with_spaces(self, mock_get_lang, mock_smart_detect, mock_load_csv): - """Test with file path containing spaces.""" + """A CSV path with spaces must be shell-quoted (callers use shell=True).""" mock_load_csv.return_value = { '.py': {'extension': '.py', 'run_test_command': 'pytest {file}'} } mock_get_lang.return_value = 'python' - + result = get_test_command_for_file('/my path/test file.py') - assert result.command == 'pytest /my path/test file.py' + # Quoted so a POSIX shell tokenizer recovers the exact path (no re-split). + assert result.command == "pytest '/my path/test file.py'" + assert shlex.split(result.command) == ['pytest', '/my path/test file.py'] + + @patch('pdd.get_test_command._load_language_format') + @patch('pdd.get_test_command.default_verify_cmd_for') + @patch('pdd.get_test_command.get_language') + def test_csv_path_with_shell_metacharacters_is_not_injected(self, mock_get_lang, mock_smart, mock_load_csv): + """A CSV-fallback path containing $()/;/spaces must not inject under shell=True.""" + mock_load_csv.return_value = { + '.py': {'extension': '.py', 'run_test_command': 'pytest {file}'} + } + mock_get_lang.return_value = 'python' + evil = '/repo/$(touch PWN)/a; rm -rf x.py' + + result = get_test_command_for_file(evil) + + argv = shlex.split(result.command) + # The whole malicious path must survive as a single argument token. + assert argv == ['pytest', evil], (result.command, argv) + assert '$(touch' not in argv, argv @patch('pdd.get_test_command._load_language_format') @patch('pdd.get_test_command.default_verify_cmd_for') @@ -287,6 +331,2092 @@ def test_tsx_files_also_use_jest_when_available(self, tmp_path): assert "npx jest" in result.command, f"Expected command starting with 'npx jest', got: {result}" + @pytest.mark.story(story_id="pdd_nested_ts_runner_resolution") + def test_jest_config_found_more_than_five_parents_up(self, tmp_path): + """A colocated suite deep in a Next.js app tree must still find Jest. + + Regression: the runner detector previously walked up only 5 parents, so a + page test at frontend/src/app///__tests__/ never reached + frontend/jest.config.js and fell back to a non-test runner. The walk now + continues up to the repository root. + """ + config_dir = tmp_path / "frontend" + config_dir.mkdir() + (config_dir / ".git").mkdir() + (config_dir / "jest.config.js").write_text("module.exports = {};") + (config_dir / "package.json").write_text("{}") + # 7 directories below the config (well past the old 5-parent cap). + test_dir = ( + config_dir / "src" / "app" / "admin" / "hackathon" / "events" + / "eventId" / "__tests__" + ) + test_dir.mkdir(parents=True) + test_file = test_dir / "test_page.tsx" + test_file.write_text("describe('page', () => {})") + + cmd, returned_dir = _detect_ts_test_runner(test_file) + assert "npx jest" in cmd + assert returned_dir == config_dir + + def test_jest_command_targets_path_literally_with_run_tests_by_path(self, tmp_path): + """Jest must be invoked with --runTestsByPath so absolute paths match. + + Jest otherwise treats the trailing path as a regex; Next.js dynamic-route + segments like [eventId]/[slug] are character classes that never match the + literal bracketed path. + """ + (tmp_path / "jest.config.js").write_text("module.exports = {};") + (tmp_path / "package.json").write_text("{}") + test_file = tmp_path / "tests" / "test_calculator.ts" + test_file.parent.mkdir() + test_file.write_text("describe('c', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + + assert "--runTestsByPath" in result.command, result.command + + def test_dynamic_route_bracket_path_is_targeted_literally(self, tmp_path): + """A bracketed dynamic-route suite path is passed to Jest verbatim.""" + config_dir = tmp_path / "frontend" + config_dir.mkdir() + (config_dir / "jest.config.js").write_text("module.exports = {};") + (config_dir / "package.json").write_text("{}") + test_dir = config_dir / "src" / "app" / "events" / "[slug]" / "__tests__" + test_dir.mkdir(parents=True) + test_file = test_dir / "test_page.tsx" + test_file.write_text("describe('page', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescriptreact") + + assert "--runTestsByPath" in result.command + # The resolved absolute path (brackets intact) must appear literally. + assert str(test_file.resolve()) in result.command, result.command + + def test_walk_finds_workspace_root_config_past_leaf_package_json(self, tmp_path): + """A workspace leaf must inherit the workspace-root runner config. + + Regression guard for the boundary: a leaf package has its own + package.json but the Jest config lives at the workspace/repo root. The + walk must pass *through* the leaf manifest and still find the root config. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('{"workspaces": ["packages/*"]}') + leaf = repo / "packages" / "app" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") # leaf manifest, no jest config + test_dir = leaf / "src" / "__tests__" + test_dir.mkdir(parents=True) + test_file = test_dir / "widget.test.ts" + test_file.write_text("describe('w', () => {})") + + cmd, returned_dir = _detect_ts_test_runner(test_file) + assert "npx jest" in cmd + assert returned_dir == repo, returned_dir + + def test_walk_stops_at_repository_root_and_does_not_escape(self, tmp_path): + """The detector must not adopt a config above the repository root. + + A jest.config.js in an unrelated ancestor above the .git repo root must + be ignored; without an in-repo config we fall back to CSV. + """ + # Stray config above the repo root — must be ignored. + (tmp_path / "jest.config.js").write_text("module.exports = {};") + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() # repo root, no jest config inside + test_file = repo / "src" / "test_calculator.ts" + test_file.parent.mkdir() + test_file.write_text("console.log('x')") + + result = get_test_command_for_file(str(test_file), language="typescript") + + # Falls back to the CSV runner (npx tsx), never the out-of-repo Jest. + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_independent_leaf_package_does_not_adopt_repo_root_config(self, tmp_path): + """An independent package must not adopt an unrelated repo-root config. + + The leaf has its own package.json and is NOT a workspace member (the repo + root declares no ``workspaces``), so the walk must stop at the leaf and + fall back to CSV rather than crossing to the repository-root Jest config. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text("{}") # no "workspaces" + leaf = repo / "packages" / "independent" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") # own project, no jest config + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_unrelated_package_under_workspace_root_is_not_a_member(self, tmp_path): + """A package that does not match the workspace globs is not a member. + + The repo root declares ``workspaces: ["packages/*"]`` but the test lives + under ``vendor/tool`` (its own package.json). It must NOT adopt the + repo-root Jest config — membership requires a glob match, not merely the + presence of a workspaces declaration somewhere above. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('{"workspaces": ["packages/*"]}') + vendor = repo / "vendor" / "tool" + vendor.mkdir(parents=True) + (vendor / "package.json").write_text("{}") # not under packages/* + test_file = vendor / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_pnpm_exclusion_pattern_excludes_matching_package(self, tmp_path): + """A pnpm `!` exclusion must remove a package from workspace membership. + + With `packages: ['packages/**', '!**/test/**']`, a package under + `packages/app/test/fixture` matches the positive glob but is explicitly + excluded, so it must NOT inherit the workspace-root Jest config. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "pnpm-workspace.yaml").write_text( + "packages:\n - 'packages/**'\n - '!**/test/**'\n" + ) + pkg = repo / "packages" / "app" / "test" / "fixture" + pkg.mkdir(parents=True) + (pkg / "package.json").write_text("{}") # own manifest, excluded from ws + test_file = pkg / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_npm_star_star_pruned_by_star_negation_no_config_inheritance(self, tmp_path): + """End-to-end: with npm ``workspaces: ["packages/**", "!packages/*"]`` the raw + positive ``packages/**`` is pruned (its string is matched by the negation glob + ``packages/*``), so NO package under ``packages/`` is a member — a DEEP + ``packages/deep/app`` must NOT inherit the repo-root Jest config.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text( + '{"workspaces": ["packages/**", "!packages/*"]}') + pkg = repo / "packages" / "deep" / "app" + pkg.mkdir(parents=True) + (pkg / "package.json").write_text("{}") # independent package, not a member + test_file = pkg / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_brace_expansion_in_workspace_glob_matches_member(self, tmp_path): + """npm/Yarn brace-expansion globs must be honored, not matched literally. + + `workspaces: ['packages/{app,lib}']` makes `packages/app` a member, which + must inherit the workspace-root Jest config. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('{"workspaces": ["packages/{app,lib}"]}') + pkg = repo / "packages" / "app" + pkg.mkdir(parents=True) + (pkg / "package.json").write_text("{}") # member, no own config + test_file = pkg / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + + assert result is not None + assert "npx jest" in result.command, result.command + + def test_non_git_project_stops_at_package_json_boundary(self, tmp_path): + """Without a .git ancestor, stop at the nearest package.json. + + A stray jest.config.js above an independent project's package.json must + not be adopted, and the walk must not run to the filesystem root. + """ + (tmp_path / "jest.config.js").write_text("module.exports = {};") # stray + project = tmp_path / "project" + project.mkdir() + (project / "package.json").write_text("{}") # boundary, no .git, no config + test_file = project / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_playwright_bracketed_spec_path_is_regex_escaped(self, tmp_path): + """Playwright positional args are regexes, so bracketed paths must escape. + + `.spec` files under a Next.js dynamic route ([slug]) would otherwise never + match Playwright's regex filter. + """ + repo = tmp_path / "frontend" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "playwright.config.ts").write_text("export default {};") + test_dir = repo / "e2e" / "events" / "[slug]" + test_dir.mkdir(parents=True) + test_file = test_dir / "landing.spec.ts" + test_file.write_text("test('x', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + + assert result.command.startswith("npx playwright test"), result.command + # Brackets must be regex-escaped so Playwright matches them literally. + assert r"\[slug\]" in result.command, result.command + + def test_resolved_path_is_shell_quoted(self, tmp_path): + """The path is shell-quoted so shell=True callers survive spaces/metachars.""" + repo = tmp_path / "my app" # space in an ancestor directory + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + test_dir = repo / "src" / "__tests__" + test_dir.mkdir(parents=True) + test_file = test_dir / "widget.test.ts" + test_file.write_text("describe('w', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + + # The command must round-trip through a POSIX shell tokenizer back to the + # exact resolved path (no re-splitting on the space). + argv = shlex.split(result.command) + assert str(test_file.resolve()) in argv, (result.command, argv) + + +class TestWorkspaceMembershipHardening: + """Round-4 review hardening: nested workspace roots, source precedence, + malformed manifests, brace-expansion budget, and symlink containment.""" + + def test_nested_intermediate_manifest_does_not_stop_walk(self, tmp_path): + """A member below an independent intermediate manifest still reaches root. + + Root declares ``vendor/container/packages/*``; ``vendor/container`` has its + own (independent) ``package.json`` that does *not* match that glob, and the + member is ``vendor/container/packages/app``. The walk must cross the + intermediate manifest and still find the workspace-root Jest config. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text( + '{"workspaces": ["vendor/container/packages/*"]}' + ) + container = repo / "vendor" / "container" + container.mkdir(parents=True) + (container / "package.json").write_text("{}") # independent intermediate + leaf = container / "packages" / "app" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") # the member + test_dir = leaf / "src" / "__tests__" + test_dir.mkdir(parents=True) + test_file = test_dir / "widget.test.ts" + test_file.write_text("describe('w', () => {})") + + cmd, returned_dir = _detect_ts_test_runner(test_file) + assert "npx jest" in cmd + assert returned_dir == repo.resolve() + + def test_pnpm_yaml_is_authoritative_over_stale_package_json(self, tmp_path): + """pnpm ignores package.json ``workspaces``; a stale field must not add members. + + The root has a stale ``workspaces: ["packages/*"]`` but the authoritative + ``pnpm-workspace.yaml`` lists only ``apps/*``. A leaf under ``packages/`` + must NOT be a member (and must not adopt the root Jest config), while a + leaf under ``apps/`` must be. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('{"workspaces": ["packages/*"]}') + (repo / "pnpm-workspace.yaml").write_text("packages:\n - 'apps/*'\n") + + stale = repo / "packages" / "tool" + stale.mkdir(parents=True) + (stale / "package.json").write_text("{}") + assert _belongs_to_ancestor_workspace(stale) is False + + member = repo / "apps" / "web" + member.mkdir(parents=True) + (member / "package.json").write_text("{}") + assert _belongs_to_ancestor_workspace(member) is True + + # And end-to-end: the stale packages/ leaf must not adopt root Jest. + test_file = stale / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_pnpm_yaml_without_parser_fails_closed(self, tmp_path, monkeypatch): + """If PyYAML is unavailable, a pnpm workspace yields no globs (fail closed).""" + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "yaml": + raise ImportError("no yaml") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + repo = tmp_path / "repo" + repo.mkdir() + (repo / "pnpm-workspace.yaml").write_text("packages:\n - 'packages/*'\n") + # Even the package.json field must be ignored when pnpm manages the repo. + (repo / "package.json").write_text('{"workspaces": ["packages/*"]}') + assert _workspace_globs_for(repo) == [] + + def test_pnpm_yaml_uses_yaml_1_2_scalar_resolution(self, tmp_path): + """pnpm parses YAML 1.2, whose core schema resolves ``0o12`` as octal and + ``1e3`` as a float — non-string ``packages`` entries that must be rejected. + PyYAML (1.1) would keep them as strings and falsely prove membership, so the + loader is configured with YAML-1.2 scalar resolution. A *quoted* ``"0o12"`` + is a string in both and stays a valid glob.""" + anc = tmp_path / "anc" + anc.mkdir() + (anc / "pnpm-workspace.yaml").write_text("packages:\n - 0o12\n - 1e3\n") + assert _workspace_globs_for(anc) == [] # both are numbers → no globs + (anc / "pnpm-workspace.yaml").write_text('packages:\n - "0o12"\n') + assert _workspace_globs_for(anc) == ["0o12"] # quoted → string glob + + def test_pnpm_yaml_1_1_only_scalars_stay_string_globs(self, tmp_path): + """The YAML 1.2 core schema replaces PyYAML's 1.1 table wholesale, so scalars + that YAML 1.1 resolves as non-strings but YAML 1.2 keeps as STRINGS + (``yes``/``no``/``on``/``off`` booleans, ``0b10`` binary, ``1:20`` + sexagesimal, ``2020-01-01`` timestamps, ``1_000`` underscore ints) remain + valid string globs — they must NOT reject the whole declaration and deny a + legitimate sibling glob its membership.""" + anc = tmp_path / "anc" + anc.mkdir() + (anc / "pnpm-workspace.yaml").write_text( + "packages:\n - packages/*\n - yes\n - 0b10\n - 1:20\n" + " - 2020-01-01\n - 1_000\n") + assert _workspace_globs_for(anc) == [ + "packages/*", "yes", "0b10", "1:20", "2020-01-01", "1_000"] + # And end-to-end: a real sibling glob still confers membership. + assert _package_matches_workspace(("packages", "app"), ["packages/*", "yes"]) is True + + def test_pnpm_yaml_duplicate_keys_fail_closed(self, tmp_path): + """pnpm rejects duplicate mapping keys; PyYAML silently keeps the last. The + loader raises on a duplicate so the parse fails membership closed rather than + adopting whichever value PyYAML happened to keep.""" + anc = tmp_path / "anc" + anc.mkdir() + (anc / "pnpm-workspace.yaml").write_text( + "packages:\n - a/*\npackages:\n - b/*\n") + assert _workspace_globs_for(anc) == [] + + def test_pnpm_yaml_empty_scalar_is_null_and_fails_closed(self, tmp_path): + """An empty YAML item (``- `` with nothing after it) resolves to null under + YAML 1.2, a non-string entry that MUST reject the whole declaration — it must + not be kept as an empty string that quietly drops out and leaves a sibling + glob falsely conferring membership.""" + anc = tmp_path / "anc" + anc.mkdir() + (anc / "pnpm-workspace.yaml").write_text("packages:\n - packages/*\n -\n") + assert _workspace_globs_for(anc) == [] + + def test_pnpm_yaml_octal_and_decimal_int_keys_are_duplicates(self, tmp_path): + """A YAML-1.2 integer constructor parses ``012`` as decimal 12 (not octal 10), + so sibling mapping keys ``012:`` and ``12:`` are the SAME integer and a + duplicate — the parse must fail closed. YAML 1.1's octal reading would make + them look distinct and let a sibling ``packages`` glob falsely confer + membership.""" + anc = tmp_path / "anc" + anc.mkdir() + (anc / "pnpm-workspace.yaml").write_text( + "packages:\n - packages/*\n012: a\n12: b\n") + assert _workspace_globs_for(anc) == [] + # A single numeric key (no collision) parses fine. + (anc / "pnpm-workspace.yaml").write_text("packages:\n - packages/*\n012: a\n") + assert _workspace_globs_for(anc) == ["packages/*"] + + def test_workspace_membership_is_order_dependent_with_reinclusion(self): + """Include/exclude is evaluated in declaration order (last match wins), so a + later positive re-includes a path an earlier ``!`` excluded — matching both + npm's @npmcli/map-workspaces and pnpm's @pnpm/matcher. An exclusion that is + the last matching pattern still excludes.""" + reinclude = ["packages/**", "!packages/legacy/**", "packages/legacy/app"] + assert _package_matches_workspace(("packages", "legacy", "app"), reinclude) is True + assert _package_matches_workspace(("packages", "legacy", "other"), reinclude) is False + assert _package_matches_workspace(("packages", "app"), reinclude) is True + # A trailing exclusion still excludes (last match wins). + assert _package_matches_workspace( + ("packages", "app", "test", "x"), ["packages/**", "!**/test/**"]) is False + # An exclusion BEFORE a positive is overridden by the positive (order). + assert _package_matches_workspace( + ("packages", "app"), ["!packages/app", "packages/**"]) is True + + def test_non_dict_package_json_does_not_crash(self, tmp_path): + """A package.json whose top level is a JSON array must not raise.""" + anc = tmp_path / "anc" + anc.mkdir() + (anc / "package.json").write_text("[]") + assert _workspace_globs_for(anc) == [] + + def test_non_dict_lerna_json_does_not_crash(self, tmp_path): + """A lerna.json whose top level is a JSON array must not raise.""" + anc = tmp_path / "anc" + anc.mkdir() + (anc / "lerna.json").write_text("[]") + assert _workspace_globs_for(anc) == [] + + def test_malformed_manifest_membership_is_unproven_not_crashing(self, tmp_path): + """An ancestor with a non-object package.json yields no membership crash.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('"just a string"') # valid JSON, non-object + leaf = repo / "packages" / "app" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + # Must not raise; membership unproven → independent leaf → no root Jest. + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_brace_bomb_raises_budget_error(self): + """A pathological brace pattern must not materialize an exponential list.""" + with pytest.raises(_BraceBudgetError): + _expand_braces("x" + "{a,b}" * 40) + + def test_whitespace_surrounded_glob_is_not_normalized(self): + """Surrounding whitespace is literal to workspace tools, so `" packages/* "` + must NOT be normalized into a broader `packages/*` (which would falsely + prove membership). A clean glob still matches.""" + assert _package_matches_workspace(("packages", "app"), [" packages/* "]) is False + assert _package_matches_workspace(("packages", "app"), ["\tpackages/*"]) is False + assert _package_matches_workspace(("packages", "app"), ["packages/*"]) is True + + def test_whitespace_glob_does_not_adopt_workspace_config(self, tmp_path): + """A whitespace-padded workspace glob must not let an independent leaf adopt + the root Jest config.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('{"workspaces": [" packages/* "]}') + leaf = repo / "packages" / "app" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_brace_bomb_membership_fails_closed(self): + """Membership fails closed (False) on a brace-bomb glob rather than hanging.""" + bomb = "x" + "{a,b}" * 40 + assert _package_matches_workspace(("a",), [bomb]) is False + # A brace bomb in an exclusion must not force a member out silently either; + # it simply fails membership closed. + assert _package_matches_workspace(("packages", "app"), ["packages/*", "!" + bomb]) is False + + def test_normal_brace_within_budget_still_matches(self): + """Ordinary brace alternations are unaffected by the budget.""" + assert _package_matches_workspace(("packages", "app"), ["packages/{app,lib}"]) is True + assert _package_matches_workspace(("packages", "web"), ["packages/{app,lib}"]) is False + + def test_brace_after_single_option_group_still_expands(self): + """A real ``{a,b}`` alternation must expand even when a *single-option* + brace (``{foo}``) precedes it in the same pattern. Otherwise the earlier + singleton short-circuits expansion and an exclusion glob like + ``!packages/{foo}/{a,b}`` never matches, so an excluded package is falsely + treated as a workspace member and inherits an ancestor's Jest config.""" + assert sorted(_expand_braces("packages/{foo}/{a,b}")) == [ + "packages/{foo}/a", + "packages/{foo}/b", + ] + globs = ["packages/**", "!packages/{foo}/{a,b}"] + # The excluded leaf is NOT a member... + assert _package_matches_workspace(("packages", "{foo}", "a"), globs) is False + assert _package_matches_workspace(("packages", "{foo}", "b"), globs) is False + # ...but a sibling the exclusion does not name still is (negative control). + assert _package_matches_workspace(("packages", "{foo}", "c"), globs) is True + + def test_unbalanced_brace_does_not_short_circuit_later_alternation(self): + """An *unmatched* opening ``{`` is literal but MUST NOT stop expansion of a + later balanced alternation. Otherwise ``!packages/{foo/{a,b}`` never expands + its ``{a,b}``, the exclusion never matches, and the excluded package is + falsely proven a workspace member (bash: ``{foo/a`` and ``{foo/b``).""" + assert sorted(_expand_braces("packages/{foo/{a,b}")) == [ + "packages/{foo/a", + "packages/{foo/b", + ] + globs = ["packages/**", "!packages/{foo/{a,b}"] + assert _package_matches_workspace(("packages", "{foo", "a"), globs) is False + assert _package_matches_workspace(("packages", "{foo", "b"), globs) is False + # A sibling the exclusion does not name is still a member (negative control). + assert _package_matches_workspace(("packages", "{foo", "c"), globs) is True + # A trailing unmatched brace with no later alternation stays fully literal. + assert _expand_braces("packages/foo{") == ["packages/foo{"] + + def test_nested_brace_inside_single_option_group_expands(self): + """A nested alternation inside a single-option outer brace still expands + (bash parity for ``{a{b,c}}`` → ``{ab} {ac}``), rather than being emitted + whole and left unexpanded.""" + assert sorted(_expand_braces("packages/{a{b,c}}")) == [ + "packages/{ab}", + "packages/{ac}", + ] + + def test_backslash_escaped_glob_fails_membership_closed(self): + """A backslash escapes brace metacharacters in minimatch (``{foo\\,bar,baz}`` + is two options, not three). This expander is not escape-aware, so rather + than over-expand ``\\,`` into a spurious ``bar`` member — or, in an + exclusion, silently fail to exclude — any backslash-bearing glob set fails + membership closed.""" + # Positive glob: the escaped comma must NOT yield a spurious ``bar`` member. + assert _package_matches_workspace( + ("packages", "bar"), [r"packages/{foo\,bar,baz}"]) is False + # Exclusion glob with an escape: whole set fails closed (never falsely a + # member because the exclusion was misparsed). + assert _package_matches_workspace( + ("packages", "x"), ["packages/*", r"!packages/{a\,b}"]) is False + # A backslash anywhere in the set is enough to fail closed. + assert _package_matches_workspace( + ("packages", "app"), [r"packages/\{app\}"]) is False + + def test_bracket_character_class_glob_fails_membership_closed(self): + """Per-segment ``fnmatch`` diverges from minimatch on bracket character + classes: ``[^a]`` negates in minimatch but ``^`` is a literal member in + fnmatch, and POSIX classes like ``[[:alpha:]]`` are unsupported. A glob + using a bracket class therefore fails membership closed rather than + over-matching a positive or under-matching an exclusion into a false + member.""" + # fnmatch would falsely match `a` against `[^a]`; must fail closed instead. + assert _package_matches_workspace(("packages", "a"), ["packages/[^a]"]) is False + # Bracket construct in an exclusion also fails the whole set closed. + assert _package_matches_workspace( + ("packages", "a"), ["packages/*", "!packages/[^a]"]) is False + assert _package_matches_workspace( + ("packages", "a"), ["packages/[[:alpha:]]"]) is False + # Even a range fnmatch *could* handle is rejected — the whole class of + # bracket constructs fails closed for a single, auditable boundary. + assert _package_matches_workspace(("packages", "app"), ["packages/[a-z]pp"]) is False + # Critically, a bracket in the *path* (a dynamic-route dir name) is NOT a + # glob metacharacter and still matches an ordinary `*` glob. + assert _package_matches_workspace(("packages", "[eventId]"), ["packages/*"]) is True + # An *unmatched* `[` (no closing `]`) is literal in both fnmatch and + # minimatch, so it is NOT rejected — the literal glob still matches its dir. + assert _package_matches_workspace(("packages", "foo[bar"), ["packages/foo[bar"]) is True + + def test_extglob_glob_fails_membership_closed(self): + """minimatch expands extglobs (``@(a|b)``, ``!(x)``, ``+(…)``, ``?(…)``, + ``*(…)``) but the per-segment ``fnmatch`` matcher treats them literally, so + an extglob exclusion under-matches into a false member. Any glob containing + an extglob prefix therefore fails membership closed.""" + # Extglob exclusion must not fail to exclude `packages/foo`. + assert _package_matches_workspace( + ("packages", "foo"), ["packages/*", "!packages/@(foo|bar)"]) is False + assert _package_matches_workspace( + ("packages", "foo"), ["packages/*", "!packages/!(bar)"]) is False + # Extglob positives also fail closed rather than mismatch. + assert _package_matches_workspace(("packages", "foofoo"), ["packages/+(foo)"]) is False + # A bare `?` wildcard (no paren) is still supported and NOT an extglob. + assert _package_matches_workspace(("packages", "ab"), ["packages/a?"]) is True + # An `@`-scoped-style path with no `(` is not an extglob and still matches. + assert _package_matches_workspace(("@scope", "pkg"), ["@scope/*"]) is True + + def test_deeply_nested_singleton_with_alternations_is_time_bounded(self): + """A tiny (<1 KB) glob nesting a deep singleton before several alternations + stays within the byte/count/segment budgets yet would cost tens of seconds + of pure re-scanning. The aggregate brace-scan budget makes it fail closed + quickly instead of stalling runner discovery.""" + pattern = "{" * 400 + "x" + "}" * 400 + "/{a,b}" * 10 + assert len(pattern) < 1024 + assert _package_matches_workspace(("never",), [pattern]) is False + + def test_scan_budget_does_not_reject_large_legitimate_brace(self): + """A genuinely large alternation (hundreds of options) still expands and + matches — the scan budget only trips on pathological nested re-scanning, + not on ordinary breadth.""" + globs = ["packages/{" + ",".join(f"p{i}" for i in range(500)) + "}"] + assert _package_matches_workspace(("packages", "p0"), globs) is True + assert _package_matches_workspace(("packages", "p499"), globs) is True + assert _package_matches_workspace(("packages", "p500"), globs) is False + + def test_long_non_brace_prefix_is_charged_against_scan_budget(self): + """The non-brace prefix a scan skips before the first ``{`` must be charged + against the work budget. Otherwise a long ``*`` run before an alternation is + re-walked free for every one of up to 1024 worklist entries, and at every + ancestor boundary of a deep walk — a multi-second stall within all other + budgets. Charging makes the deep-boundary case fail closed instead.""" + prefix_len = 3990 + pattern = "**/" + "*" * prefix_len + "{a,b}" * 10 + work = [_MAX_BRACE_SCAN_WORK] + _find_expandable_brace(pattern, 2000, work) + # The whole prefix (plus a little) is charged, not a token amount. + assert (_MAX_BRACE_SCAN_WORK - work[0]) >= prefix_len + # A deep walk sharing one work budget across boundaries fails closed rather + # than re-spending it: 80 checks must not each cost a full budget. + cells = [_MAX_MATCH_CELLS] + shared_work = [_MAX_BRACE_SCAN_WORK] + expand = [_MAX_BRACE_EXPANSION] + results = [ + _package_matches_workspace( + ("packages", "bbbbbbbbbb"), [pattern], cells, shared_work, expand) + for _ in range(80) + ] + # Shared budget is exhausted → the walk fails membership closed, not stalls. + assert all(r is False for r in results) + + def test_brace_range_glob_fails_membership_closed(self): + """minimatch expands numeric/alphabetic brace ranges (``{1..3}``, ``{a..c}``, + zero-padded ``{01..03}``, stepped ``{1..9..2}``); this expander only does + comma alternation, so a range brace would be emitted literally and an + exclusion range would fail to exclude. Any glob containing ``..`` therefore + fails membership closed (no legitimate workspace path holds ``..``).""" + assert _package_matches_workspace( + ("packages", "1"), ["packages/**", "!packages/{1..3}"]) is False + assert _package_matches_workspace(("packages", "b"), ["packages/{a..c}"]) is False + assert _package_matches_workspace(("packages", "02"), ["packages/{01..03}"]) is False + + def test_internal_dot_segment_is_not_collapsed(self): + """minimatch does not collapse an *internal* ``.`` segment, so + ``packages/./x`` must not be treated as ``packages/x`` and falsely prove + membership. A *leading* ``./`` is npm-normalized and still matches.""" + # Internal `.` → the glob needs a literal `.` segment the path lacks. + assert _package_matches_workspace(("packages", "app"), ["packages/./*"]) is False + assert _package_matches_workspace( + ("packages", "app"), ["packages/**", "!packages/./app"]) is True + # Leading `./` normalization is preserved. + assert _package_matches_workspace(("packages", "app"), ["./packages/*"]) is True + # A genuine literal `.`-named segment still matches itself. + assert _package_matches_workspace(("packages", "."), ["packages/*"]) is False + + def test_question_mark_matches_astral_over_utf16_units(self): + """``?`` matches exactly one UTF-16 code unit (minimatch parity), so a single + ``?`` does NOT match an astral character (two units) but ``??`` does — no + fail-closed approximation is needed, and include/exclude semantics stay + order-independent. ``*`` spans the whole segment and BMP ``?`` is unaffected.""" + emoji = "\U0001F600" + # A single `?` is one unit → does not match the two-unit emoji → not a member. + assert _package_matches_workspace(("packages", emoji), ["packages/?"]) is False + # `??` is two units → matches the emoji exactly. + assert _package_matches_workspace(("packages", emoji), ["packages/??"]) is True + # A `?` in a different segment (consumed by `**`/`*`) does not spuriously + # reject, and the result is independent of positive-glob order. + assert _package_matches_workspace( + ("packages", "app", emoji), ["packages/ap?/**"]) is True + assert _package_matches_workspace( + ("packages", "a", emoji), ["packages/?/x", "packages/**"]) is True + assert _package_matches_workspace( + ("packages", "a", emoji), ["packages/**", "packages/?/x"]) is True + # `*` still matches an astral segment; a BMP `?` still works normally. + assert _package_matches_workspace(("packages", emoji), ["packages/*"]) is True + assert _package_matches_workspace(("packages", "ab"), ["packages/a?"]) is True + + def test_multiple_leading_bang_fails_membership_closed(self): + """Two or more leading ``!`` toggle negation in minimatch (``!!x`` positive, + ``!!!x`` negates again). This matcher does not track that parity, so a + multi-bang glob fails membership closed rather than be mis-classified as a + literal (which would falsely prove membership for ``!!!packages/foo``).""" + assert _package_matches_workspace( + ("packages", "foo"), ["packages/**", "!!!packages/foo"]) is False + assert _package_matches_workspace(("packages", "foo"), ["!!packages/foo"]) is False + # A single `!` exclusion is unaffected and still excludes. + assert _package_matches_workspace( + ("packages", "foo"), ["packages/**", "!packages/foo"]) is False + assert _package_matches_workspace( + ("packages", "bar"), ["packages/**", "!packages/foo"]) is True + + def test_leading_hash_comment_glob_matches_nothing(self): + """A positive pattern whose effective form (after an optional leading + ``./``) begins with ``#`` is a minimatch comment: it matches nothing and + must not be fnmatch-ed literally into a false member. It is skipped, not + fail-closed, so a real glob alongside a comment still works.""" + assert _package_matches_workspace(("packages", "#foo"), ["#*"]) is False + assert _package_matches_workspace(("packages", "#foo"), ["./#*"]) is False + # A comment entry does not disable the rest of the declaration. + assert _package_matches_workspace( + ("packages", "app"), ["#comment", "packages/*"]) is True + + def test_literal_double_dot_and_unmatched_bracket_are_not_over_rejected(self): + """The fail-closed guard targets *unsupported* constructs, not any literal + occurrence of their characters. A package dir named ``foo..bar`` (literal + ``..`` outside a brace) or ``foo[bar`` (an unmatched ``[``) is matched + literally by fnmatch, exactly like minimatch, so such globs must still + prove membership rather than be needlessly rejected.""" + assert _package_matches_workspace( + ("packages", "foo..bar"), ["packages/foo..bar"]) is True + assert _package_matches_workspace( + ("packages", "foo[bar"), ["packages/foo[bar"]) is True + # But an in-brace range and a closed class still fail closed. + assert _package_matches_workspace( + ("packages", "1"), ["packages/**", "!packages/{1..3}"]) is False + assert _package_matches_workspace(("packages", "a"), ["packages/[^a]"]) is False + + def test_dollar_brace_is_literal_not_expanded(self): + """minimatch's brace-expansion treats a ``{`` immediately preceded by ``$`` + (shell-style ``${...}``) as literal, never expanding it. Expanding it would + falsely prove membership for an independent ``$foo`` leaf.""" + assert _expand_braces("packages/${foo,bar}") == ["packages/${foo,bar}"] + assert _package_matches_workspace( + ("packages", "$foo"), ["packages/${foo,bar}"]) is False + # A real brace elsewhere in the same pattern still expands. + assert sorted(_expand_braces("${a,b}/{c,d}")) == [ + "${a,b}/c", "${a,b}/d", + ] + + def test_double_dot_in_comma_alternation_is_not_a_range(self): + """A ``..`` inside a brace group that also has a top-level comma is a literal + part of one alternation option, not a range, and must not be rejected: both + ``packages/foo..bar`` and ``packages/baz`` are members of + ``packages/{foo..bar,baz}``. An unbalanced ``{foo..bar`` is literal too. A + *pure* range (no comma) still fails closed.""" + assert _package_matches_workspace( + ("packages", "foo..bar"), ["packages/{foo..bar,baz}"]) is True + assert _package_matches_workspace( + ("packages", "baz"), ["packages/{foo..bar,baz}"]) is True + assert _package_matches_workspace( + ("packages", "{foo..bar"), ["packages/{foo..bar"]) is True + # Pure range (no comma), including nested inside an alternation, fails closed. + assert _package_matches_workspace( + ("packages", "1"), ["packages/**", "!packages/{1..3}"]) is False + assert _package_matches_workspace(("packages", "2"), ["packages/{a,{1..3}}"]) is False + + def test_astral_question_mark_matches_over_utf16_units(self): + """``?`` matches one UTF-16 unit, so it never spuriously rejects when a ``?`` + segment aligns with a BMP segment while ``*`` consumes an astral one + (``packages/*/a??`` matches ``packages/😀/app``), and a ``?`` aligned with an + astral segment simply does not match it (one unit ≠ two).""" + emoji = "\U0001F600" + assert _package_matches_workspace( + ("packages", emoji, "app"), ["packages/*/a??"]) is True + # `?` (one unit) does not match the two-unit emoji → not a member. + assert _package_matches_workspace(("packages", emoji), ["packages/?"]) is False + assert _package_matches_workspace( + ("packages", "app", emoji), ["packages/app/?"]) is False + # `??` (two units) does match it. + assert _package_matches_workspace( + ("packages", "app", emoji), ["packages/app/??"]) is True + # `**` makes alignment flexible → conservatively fail closed. + assert _package_matches_workspace( + ("packages", emoji, "app"), ["packages/**/a?"]) is False + + def test_empty_bracket_class_is_not_treated_as_a_class(self): + """``[]``, ``[!]``, ``[^]`` are empty (invalid) classes — no content between + the optional negation marker and ``]`` — that both fnmatch and minimatch + treat literally. The guard must NOT flag them as character classes (which + would fail the whole membership check closed); a non-empty class still is.""" + # Guard: empty groups are not classes; a group with content is. + assert _has_complete_bracket_class("packages/[]") is False + assert _has_complete_bracket_class("packages/[!]") is False + assert _has_complete_bracket_class("packages/[^]") is False + assert _has_complete_bracket_class("packages/[^a]") is True + assert _has_complete_bracket_class("packages/[ab]") is True + # A dir literally named ``[]``/``[!]`` matches its literal glob (fnmatch and + # minimatch agree); a real class still fails membership closed. + assert _package_matches_workspace(("packages", "[]"), ["packages/[]"]) is True + assert _package_matches_workspace(("packages", "[!]"), ["packages/[!]"]) is True + assert _package_matches_workspace(("packages", "a"), ["packages/[^a]"]) is False + assert _package_matches_workspace(("packages", "b"), ["packages/[ab]"]) is False + + def test_expanded_patterns_are_revalidated_for_constructs(self): + """Brace expansion can *create* an unsupported construct from separate + alternatives or *dissolve* an apparent one, so validation must run on each + concrete (expanded) pattern, not the raw glob.""" + # `{?,x}(foo)` expands to `?(foo)` — an extglob fnmatch would mishandle + # (`?` matching `a`); must fail closed, not falsely prove `a(foo)`. + assert _package_matches_workspace( + ("packages", "a(foo)"), ["packages/{?,x}(foo)"]) is False + # `{[,x}]` expands only to the literals `[]` and `x]` — both supported — so + # it must NOT be rejected by a raw-level bracket scan. + assert _package_matches_workspace(("packages", "[]"), ["packages/{[,x}]"]) is True + assert _package_matches_workspace(("packages", "x]"), ["packages/{[,x}]"]) is True + # `[{a,b}]` expands to real classes `[a]`/`[b]` → still fails closed. + assert _package_matches_workspace(("packages", "a"), ["packages/[{a,b}]"]) is False + + def test_dollar_brace_is_opaque_including_nested(self): + """A balanced ``${...}`` is opaque to brace-expansion, nested braces + included, so ``${foo,{bar,baz}}`` is fully literal (its inner ``{bar,baz}`` + must NOT expand into a false member), while ``${1..3}`` is a literal dir name + (its ``..`` is not a range) and must stay matchable.""" + assert _expand_braces("packages/${foo,{bar,baz}}") == ["packages/${foo,{bar,baz}}"] + assert _package_matches_workspace( + ("packages", "${foo,bar}"), ["packages/${foo,{bar,baz}}"]) is False + assert _package_matches_workspace( + ("packages", "${1..3}"), ["packages/${1..3}"]) is True + # A real brace after a ``${...}`` group still expands. + assert sorted(_expand_braces("${a,b}/{c,d}")) == ["${a,b}/c", "${a,b}/d"] + + def test_unbalanced_dollar_brace_still_expands_later_alternation(self): + """An *unbalanced* ``${`` (no matching ``}``) is a literal ``${``, not an + opaque group, so a later balanced ``{a,b}`` must still expand. Otherwise + ``!packages/${foo/{a,b}`` never excludes ``packages/${foo/a``.""" + assert sorted(_expand_braces("packages/${foo/{a,b}")) == [ + "packages/${foo/a", + "packages/${foo/b", + ] + globs = ["packages/**", "!packages/${foo/{a,b}"] + assert _package_matches_workspace(("packages", "${foo", "a"), globs) is False + assert _package_matches_workspace(("packages", "${foo", "b"), globs) is False + assert _package_matches_workspace(("packages", "${foo", "c"), globs) is True + + def test_literal_bracket_forms_are_not_reinterpreted_by_fnmatch(self): + """The matcher implements ``*``/``?``/literal directly (no ``fnmatch``), so a + literal bracket form it permits is matched literally, matching minimatch — + not reinterpreted as a class. ``[^]``/``[!]`` are empty (literal); ``[^]]``/ + ``[!]]``/``[]]`` are real non-empty classes (``]`` is their first member) and + fail closed.""" + # Empty forms: literal, so they match only their literal dir name. + assert _package_matches_workspace(("packages", "^"), ["packages/[^]"]) is False + assert _package_matches_workspace(("packages", "[^]"), ["packages/[^]"]) is True + assert _package_matches_workspace(("packages", "[!]"), ["packages/[!]"]) is True + # ']' as first member → non-empty class → fail closed. + assert _has_complete_bracket_class("packages/[^]]") is True + assert _has_complete_bracket_class("packages/[!]]") is True + assert _has_complete_bracket_class("packages/[]]") is True + assert _package_matches_workspace(("packages", "a"), ["packages/[^]]"]) is False + + def test_leading_prefix_normalized_exactly_once(self): + """npm's leading normalization is exactly ``^\\.?/+`` (an optional leading dot + then the entire leading slash run, once). A prefix left OVER is significant, so + ``/./packages/*`` (→ ``./packages/*``), ``//./packages/*``, and + ``././packages/*`` do NOT match ``packages/app``. A single leading ``./`` or a + leading slash run — including ``.//`` and ``.///`` (dot + all slashes) — does.""" + for glob in ("/./packages/*", "//./packages/*", "././packages/*", + ".//./packages/*"): + assert _package_matches_workspace(("packages", "app"), [glob]) is False, glob + for glob in ("packages/*", "./packages/*", "/packages/*", "//packages/*", + ".//packages/*", ".///packages/*", "///packages/*"): + assert _package_matches_workspace(("packages", "app"), [glob]) is True, glob + + def test_membership_semantics_are_source_dependent(self): + """Both npm/yarn/lerna (``@npmcli/map-workspaces``) and pnpm (``@pnpm/matcher``) + honor re-inclusion, but DIFFERENTLY: npm's ``appendNegatedPatterns`` drops an + earlier negation wholesale when a later positive's pattern-string matches it + (re-including the sibling too), while pnpm decides per-path (last matching + pattern wins, sibling stays excluded). The ``ordered`` flag selects the + algorithm; direct callers default to pnpm. Verified against @npmcli/map-workspaces + source (a later specific positive removes the earlier negation).""" + # A specific re-inclusion after a broad exclusion. + reinc = ["packages/**", "!packages/legacy/**", "packages/legacy/app"] + # The named package is re-included by BOTH. + assert _package_matches_workspace( + ("packages", "legacy", "app"), reinc, ordered=False) is True # npm + assert _package_matches_workspace( + ("packages", "legacy", "app"), reinc, ordered=True) is True # pnpm + # The SIBLING differs: npm drops the whole negation (member); pnpm keeps it out. + assert _package_matches_workspace( + ("packages", "legacy", "other"), reinc, ordered=False) is True # npm re-includes subtree + assert _package_matches_workspace( + ("packages", "legacy", "other"), reinc, ordered=True) is False # pnpm still excludes + # A later positive equal to an earlier exclusion removes it under npm. + undo = ["packages/*", "!packages/app", "packages/app"] + assert _package_matches_workspace(("packages", "app"), undo, ordered=False) is True + assert _package_matches_workspace(("packages", "app"), undo, ordered=True) is True + # Both agree on a plain positive and a never-undone trailing exclusion. + assert _package_matches_workspace(("packages", "app"), ["packages/*"], ordered=False) is True + assert _package_matches_workspace( + ("packages", "app", "test", "x"), ["packages/**", "!**/test/**"], ordered=False) is False + assert _package_matches_workspace( + ("packages", "app", "test", "x"), ["packages/**", "!**/test/**"], ordered=True) is False + + def test_node_modules_is_never_a_workspace_member(self): + """A package inside any ``node_modules/`` is never a workspace member, even under + a broad ``**``: npm appends ``**/node_modules/**`` to its ignore set and + pnpm/yarn skip ``node_modules`` during discovery. A leaf dir literally named + ``node_modules`` (nothing inside it) is not itself excluded.""" + for ordered in (True, False): + assert _package_matches_workspace( + ("node_modules", "dep"), ["**"], ordered=ordered) is False, ordered + assert _package_matches_workspace( + ("packages", "app", "node_modules", "x"), ["**"], ordered=ordered) is False, ordered + assert _package_matches_workspace( + ("packages", "app"), ["**"], ordered=ordered) is True, ordered + # A leaf directory literally named node_modules is matchable. + assert _package_matches_workspace( + ("packages", "node_modules"), ["packages/*"], ordered=False) is True + + def test_npm_hash_comment_semantics_split_preprocessing_vs_final(self): + """npm's ``appendNegatedPatterns`` preprocessing uses DEFAULT minimatch, where a + leading ``#`` PATTERN is a comment, but the FINAL ``glob`` matches ``#`` + LITERALLY (nocomment). So: + * a positive ``#noop`` (as a pattern-string, first arg to minimatch) still + matches an earlier negation ``**`` and removes it, re-including ``packages/app``; + * a leading-``#`` NEGATION matches nothing during preprocessing, so a positive + cannot remove it and it survives (``["**","!#foo","#foo"]`` keeps ``#foo`` + excluded); + * in final matching ``#`` is literal, so a positive ``#noop`` DOES match a + package directory literally named ``#noop``.""" + # Positive comment removes an earlier non-comment negation (re-inclusion). + globs = ["packages/**", "!**", "#noop"] + assert _package_matches_workspace(("packages", "app"), globs, ordered=False) is True + assert _package_matches_workspace(("packages", "app"), globs, ordered=True) is False + # A leading-# NEGATION matches nothing during preprocessing → survives → excludes. + excl = ["**", "!#foo", "#foo"] + assert _package_matches_workspace(("#foo",), excl, ordered=False) is False + # In final matching ``#`` is LITERAL, so a positive ``#noop`` matches a dir ``#noop``. + assert _package_matches_workspace(("#noop",), ["#noop"], ordered=False) is True + + def test_npm_final_negation_uses_dot_true_semantics(self): + """npm applies surviving negations as ``glob``'s dot:true IGNORE set, so a + wildcard negation excludes a leading-dot directory, while positives keep dot:false. + ``["packages/.*", "!packages/*"]`` therefore EXCLUDES ``packages/.shadow`` (the + ``!packages/*`` ignore matches it under dot:true) even though ``packages/*`` as a + POSITIVE would not match ``.shadow``.""" + assert _package_matches_workspace( + ("packages", ".shadow"), ["packages/.*", "!packages/*"], ordered=False) is False + # Positive-only dot:false: packages/* does not match a dotfile... + assert _package_matches_workspace( + ("packages", ".shadow"), ["packages/*"], ordered=False) is False + # ...but a dot-leading positive does, and a normal name is unaffected. + assert _package_matches_workspace( + ("packages", ".shadow"), ["packages/.*"], ordered=False) is True + assert _package_matches_workspace( + ("packages", "app"), ["packages/*"], ordered=False) is True + + def test_dot_and_dotdot_segments_match_only_identical_literals(self): + """A ``.`` or ``..`` path segment (which appears when a raw pattern-STRING like + ``packages/./x`` is matched as a path during npm's pruning) is matched ONLY by an + identical literal pattern segment — never by a wildcard or ``**``. So for + ``["packages/.*/*", "!packages/.*/**", "packages/./x"]`` the positive + ``packages/./x`` does NOT match ``packages/.*/**`` (``.*`` cannot consume ``.``), + the negation survives, and ``packages/.shadow/y`` stays excluded.""" + g = ["packages/.*/*", "!packages/.*/**", "packages/./x"] + assert _package_matches_workspace( + ("packages", ".shadow", "y"), g, ordered=False) is False + # A `.` path segment is not matched by a wildcard/globstar, only by literal `.`. + assert _relative_matches_workspace_glob((".",), ".*") is False + assert _relative_matches_workspace_glob((".",), "**") is False + assert _relative_matches_workspace_glob((".",), ".") is True + assert _relative_matches_workspace_glob(("..",), "*") is False + assert _relative_matches_workspace_glob(("..",), "..") is True + + def test_terminal_star_run_is_budget_charged(self): + """A long trailing run of ``*`` in the segment matcher is charged against the + shared work budget (a prior unbudgeted terminal-star scan could reach ~10^8 + iterations under the cell cap). A pathological long-star + brace + globstar glob + fails membership closed instead of hanging.""" + import time + glob = "?" + "*" * 4000 + "{a,b}" * 4 + "/**" + deep = tuple("x" for _ in range(200)) + t0 = time.time() + result = _package_matches_workspace(deep, [glob], ordered=False) + assert (time.time() - t0) < 2.0, "pathological star-wall must stay bounded" + assert result in (True, False) # decided within budget, not hung + + def test_npm_pruning_collapses_empty_and_trailing_slash_segments(self): + """minimatch collapses repeated/trailing ``/`` for the pattern-vs-pattern + comparison, so a positive ``packages//app`` (or ``packages/app/``) still matches + the negation ``packages/*`` and removes it — ``packages/app`` is a member.""" + assert _package_matches_workspace( + ("packages", "app"), + ["packages/**", "!packages/*", "packages//app"], ordered=False) is True + assert _package_matches_workspace( + ("packages", "app"), + ["packages/**", "!packages/*", "packages/app/"], ordered=False) is True + + def test_pnpm_treats_hash_literally(self): + """pnpm (``@pnpm/matcher``) treats ``#`` LITERALLY — only a leading ``!`` is + special — so a ``#app`` pattern matches a directory named ``#app`` and a later + ``#app`` re-includes it after ``!#app``. The npm-preprocessing comment rule does + NOT apply to pnpm (ordered).""" + assert _package_matches_workspace(("#app",), ["#app"], ordered=True) is True + assert _package_matches_workspace( + ("#app",), ["!#app", "#app"], ordered=True) is True + assert _package_matches_workspace( + ("#app",), ["**", "!#app"], ordered=True) is False + + def test_npm_empty_positive_pattern_removes_prior_negation(self): + """Under npm's ``appendNegatedPatterns`` an EMPTY positive pattern-string is + preserved (not dropped) and can still match+remove a prior negation, even though + it never matches a non-empty leaf. ``["packages/**", "!**", ""]`` therefore drops + ``!**`` and ``packages/app`` is a member.""" + assert _package_matches_workspace( + ("packages", "app"), ["packages/**", "!**", ""], ordered=False) is True + + def test_npm_removal_compares_raw_unexpanded_positive_string(self): + """npm's ``appendNegatedPatterns`` compares the RAW positive pattern STRING + (braces literal) against each negation, expanding braces only for the final + concrete-path membership test. So a later brace positive ``packages/{a,b}`` does + NOT remove a specific earlier ``!packages/a`` (the raw string ``packages/{a,b}`` + doesn't match ``packages/a``): ``packages/a`` stays excluded, ``packages/b`` is a + member. (Expanding the positive before comparison would wrongly re-include + ``packages/a``.)""" + g = ["packages/**", "!packages/a", "packages/{a,b}"] + assert _package_matches_workspace(("packages", "a"), g, ordered=False) is False # excluded + assert _package_matches_workspace(("packages", "b"), g, ordered=False) is True # member + # A brace NEGATION, by contrast, is expanded — a raw positive matching any of its + # expansions removes the whole group. + g2 = ["packages/**", "!packages/{a,c}", "packages/a"] + assert _package_matches_workspace(("packages", "a"), g2, ordered=False) is True + assert _package_matches_workspace(("packages", "c"), g2, ordered=False) is True + # pnpm (per-path last-match) re-includes packages/a via the later brace positive. + assert _package_matches_workspace(("packages", "a"), g, ordered=True) is True + + def test_npm_surviving_negation_prunes_matching_positive_patterns(self): + """npm's ``appendNegatedPatterns`` also runs the SYMMETRIC step: each surviving + negation removes any POSITIVE pattern whose raw pattern STRING it matches + (``minimatch.match(patterns, negated)``). So ``["packages/*", "!packages/?"]`` + drops ``packages/*`` (its string matches ``packages/?``) and ``packages/app`` is + NOT a member — even though the concrete path ``packages/app`` does not itself + match the single-char ``packages/?``.""" + g = ["packages/*", "!packages/?"] + assert _package_matches_workspace(("packages", "app"), g, ordered=False) is False + assert _package_matches_workspace(("packages", "a"), g, ordered=False) is False + # A positive NOT matched by the negation survives. + g2 = ["packages/*", "other/*", "!packages/?"] + assert _package_matches_workspace(("other", "x"), g2, ordered=False) is True + + def test_npm_leading_normalization_applies_once_before_brace_expansion(self): + """npm normalizes each RAW pattern with ``^\\.?/+`` exactly once, BEFORE minimatch + expands braces — so a slash GENERATED by brace expansion stays anchored and is not + re-normalized. ``["{/packages/*,other/*}"]`` yields an anchored ``/packages/*`` + that does NOT match the relative ``packages/app``, while ``other/*`` matches + ``other/app``. A non-slash brace alternative still matches.""" + anchored = ["{/packages/*,other/*}"] + for ordered in (False, True): + assert _package_matches_workspace(("packages", "app"), anchored, ordered=ordered) is False + assert _package_matches_workspace(("other", "app"), anchored, ordered=ordered) is True + assert _package_matches_workspace( + ("packages", "app"), ["{packages/*,other/*}"], ordered=False) is True + + def test_npm_negation_removal_uses_splice_while_increment_semantics(self): + """npm's negation-removal loop splices at index ``i`` then does ``++i``, so a + matching negation ADJACENT to a removed one is skipped and survives. For + ``["packages/**", "!packages/**", "!packages/*", "packages/app"]`` the positive + ``packages/app`` removes ``!packages/**`` (slot 0) but the loop advances past the + shifted ``!packages/*`` — which survives and excludes ``packages/app``. Reproduce + the sequential (not simultaneous) semantics.""" + g = ["packages/**", "!packages/**", "!packages/*", "packages/app"] + assert _package_matches_workspace(("packages", "app"), g, ordered=False) is False + + def test_npm_positive_pattern_star_matches_glob_star_during_pruning(self): + """During npm's pattern-vs-pattern pruning the raw positive ``packages/**`` is + matched (as a literal path) against the negation glob ``packages/*``: the ``*`` in + the glob is a WILDCARD that matches the literal segment ``**``. So + ``["packages/**", "!packages/*"]`` prunes ``packages/**`` and NO package under it + is a member — neither ``packages/app`` nor a deeper ``packages/deep/app``. (A + matcher that consumed the pattern ``*`` as a literal equal to the name ``*`` would + wrongly keep the positive.)""" + g = ["packages/**", "!packages/*"] + assert _package_matches_workspace(("packages", "app"), g, ordered=False) is False + assert _package_matches_workspace(("packages", "deep", "app"), g, ordered=False) is False + # A literal ``*`` in a real segment still matches a pattern ``*`` and a literal ``*``. + assert _wildcard_segment_match("**", "*") is True + assert _wildcard_segment_match("a*b", "a*b") is True + + def test_jest_config_extensions_include_cjs_and_json(self): + """Jest supports `.cjs`/`.json` (and TS variants) config files; a project using + `jest.config.cjs` must be detected as a Jest project, not fall back to tsx.""" + jest = next(c[1] for c in _RUNNER_CONFIGS if "jest" in c[0]) + for name in ("jest.config.js", "jest.config.mjs", "jest.config.cjs", + "jest.config.json", "jest.config.ts"): + assert name in jest, name + + def test_json_manifest_rejects_nonstandard_constants(self, tmp_path): + """``NaN``/``Infinity``/``-Infinity`` are accepted by Python's ``json`` but + rejected by npm's (Node's) strict JSON parser, so a manifest containing them — + even outside the workspace field — is invalid and must fail membership closed, + not prove a member off the still-parsed ``workspaces`` list.""" + for body in ('{"workspaces":["packages/*"],"x":NaN}', + '{"workspaces":["packages/*"],"x":Infinity}', + '{"workspaces":["packages/*"],"x":-Infinity}'): + (tmp_path / "package.json").write_text(body) + assert _workspace_globs_for(tmp_path) == [] + # lerna.json uses the same parser and is guarded too. + (tmp_path / "package.json").unlink() + (tmp_path / "lerna.json").write_text('{"packages":["packages/*"],"x":NaN}') + assert _workspace_globs_for(tmp_path) == [] + + def test_matcher_fast_rejects_and_bounds_wildcard_work(self): + """Without ``**`` every pattern segment consumes exactly one path segment, so a + segment-count mismatch is rejected before the DP. And the per-unit character + work of matching is charged against a shared budget, so a manifest of many + long wildcard-heavy globs against a deep path fails closed instead of burning + CPU (the reviewer's 80-segment path × 100 × 240-wildcard-segment case).""" + # Fast reject on unequal segment counts (no ``**``): 2 pattern vs 3 path segs. + assert _package_matches_workspace(("a", "b", "c"), ["x/y"]) is False + # A pathological deep match with ``**`` fails closed (budget), not hangs. + pattern = "**/" + "/".join(["a*b"] * 240) + assert _package_matches_workspace( + tuple(["bbbbbbbbbb"] * 80), [pattern] * 100) is False + + def test_length_guard_precedes_syntax_scan(self): + """The cheap length guard runs before any O(len) syntax scan, and the bracket + scan is linear, so a hostile megabyte-long unmatched-``[`` glob fails closed + immediately instead of triggering a quadratic pre-scan.""" + huge = "packages/" + "[" * 2_000_000 + assert len(huge) > _MAX_GLOB_LENGTH + assert _package_matches_workspace(("a",), [huge]) is False + # The linear bracket scanner itself does not choke on the raw string either. + assert _has_complete_bracket_class("[" * 100000) is False + + def test_leading_slash_normalized_before_hash_and_glob_matching(self): + """A leading ``/`` (or ``//`` / ``.//``) is normalized the SAME way for a + ``#``-pattern as for any other glob. In pnpm (the default) and npm FINAL matching + ``#`` is LITERAL, so ``//#*`` normalizes to ``#*`` and matches a directory named + ``#evil`` — the leading slashes are removed, not left to break the match. A + two-segment path still fails a one-segment pattern on segment count.""" + # Normalized to `#*`; matches the single-segment `#evil` literally. + assert _package_matches_workspace(("#evil",), ["//#*"]) is True + assert _package_matches_workspace(("#evil",), [".//#*"]) is True + # `/#*` -> `#*` (one segment) cannot match a two-segment path. + assert _package_matches_workspace(("#evil", "package"), ["/#*"]) is False + # A leading `/` on a real glob is still just normalized away. + assert _package_matches_workspace(("packages", "app"), ["/packages/*"]) is True + + def test_generated_dollar_brace_adjacency_still_expands(self): + """A ``$`` produced as a brace option must not be mistaken for an opaque + ``${...}`` when it lands before another brace. ``{$,x}{a,b}`` expands to + ``$a``/``$b``/``xa``/``xb`` (genuine ``${...}`` spans are masked out before + expansion), so the exclusion actually fires.""" + assert sorted(_expand_braces("{$,x}{a,b}")) == ["$a", "$b", "xa", "xb"] + globs = ["packages/**", "!packages/{$,x}{a,b}"] + assert _package_matches_workspace(("packages", "$a"), globs) is False + assert _package_matches_workspace(("packages", "xb"), globs) is False + assert _package_matches_workspace(("packages", "yy"), globs) is True + # A genuine balanced ${...} is still opaque (masked, restored literally). + assert _expand_braces("packages/${foo,bar}") == ["packages/${foo,bar}"] + + def test_astral_question_mark_with_globstar_matches_over_utf16(self): + """With UTF-16-unit ``?`` matching, ``packages/ap?/**`` matches + ``("packages", "app", "😀")`` (``ap?`` matches ``app``, ``**`` consumes the + emoji), and ``packages/?/**`` does not match ``("packages", "😀", "app")`` + (a one-unit ``?`` cannot match the two-unit emoji segment).""" + emoji = "\U0001F600" + assert _package_matches_workspace( + ("packages", "app", emoji), ["packages/ap?/**"]) is True + assert _package_matches_workspace( + ("packages", emoji, "app"), ["packages/?/**"]) is False + # But `??/**` DOES match the two-unit emoji segment. + assert _package_matches_workspace( + ("packages", emoji, "app"), ["packages/??/**"]) is True + + def test_incomplete_extglob_marker_is_not_rejected(self): + """An *incomplete* extglob marker (``foo?(bar`` with no ``)``) is minimatch's + ``?`` wildcard plus a literal ``(`` — the direct matcher agrees — so it must + match, not be rejected. A *complete* extglob group still fails closed.""" + assert _package_matches_workspace( + ("packages", "foox(bar"), ["packages/foo?(bar"]) is True + assert _has_complete_extglob("packages/foo?(bar") is False + assert _has_complete_extglob("packages/@(a|b)") is True + assert _package_matches_workspace( + ("packages", "foo"), ["packages/*", "!packages/@(foo|bar)"]) is False + + def test_bracket_and_extglob_do_not_cross_slash(self): + """A bracket class or extglob is confined to one ``/``-delimited segment, so a + ``[`` in one segment and ``]`` in another (``foo[/bar]``) — or an ``?(`` split + across ``/`` (``foo?(/bar)``) — is literal in minimatch and MUST match, not be + rejected. A class/extglob wholly within one segment still fails closed.""" + assert _package_matches_workspace( + ("packages", "foo[", "bar]"), ["packages/foo[/bar]"]) is True + assert _package_matches_workspace( + ("packages", "foox(", "bar)"), ["packages/foo?(/bar)"]) is True + assert _package_matches_workspace(("packages", "a"), ["packages/[^a]"]) is False + assert _package_matches_workspace( + ("packages", "foo"), ["packages/x", "!packages/@(foo|bar)"]) is False + + def test_brace_range_grammar_multi_char_endpoints_are_literal(self): + """Only real minimatch ranges — integer or single-character endpoints, with + an optional integer step — fail closed. Multi-character (``{foo..bar}``), + non-integer numeric (``{1.0..3.0}``), and empty (``{..}``) forms are literal + and MUST stay matchable.""" + assert _has_brace_range("{1..3}") is True + assert _has_brace_range("{a..z}") is True + assert _has_brace_range("{01..03}") is True + assert _has_brace_range("{1..9..2}") is True + assert _has_brace_range("{-2..2}") is True + assert _has_brace_range("{foo..bar}") is False + assert _has_brace_range("{1.0..3.0}") is False + assert _has_brace_range("{..}") is False + # Plus-prefixed and non-ASCII/Unicode endpoints are literal, not ranges. + assert _has_brace_range("{+1..+3}") is False + assert _has_brace_range("{١..٣}") is False # Arabic-Indic digits + assert _package_matches_workspace( + ("packages", "{+1..+3}"), ["packages/{+1..+3}"]) is True + # A literal multi-char-endpoint "range" matches its literal dir name. + assert _package_matches_workspace( + ("packages", "{foo..bar}"), ["packages/{foo..bar}"]) is True + # A real range still fails closed (comma-only expander cannot expand it). + assert _package_matches_workspace( + ("packages", "2"), ["packages/**", "!packages/{1..3}"]) is False + + def test_symlinked_test_dir_escaping_repo_is_refused(self, tmp_path): + """A test dir symlinked outside the repo must not adopt an out-of-repo config.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + outside = tmp_path / "outside" + (outside / "tests").mkdir(parents=True) + (outside / "jest.config.js").write_text("module.exports = {};") + (outside / "tests" / "foo.test.ts").write_text("describe('x', () => {})") + # repo/tests -> outside/tests (escapes the repository) + (repo / "tests").symlink_to(outside / "tests", target_is_directory=True) + + result = _detect_ts_test_runner(repo / "tests" / "foo.test.ts") + assert result is None + + def test_symlink_within_repo_still_detected(self, tmp_path): + """A symlink that stays inside the repo must still find the repo's config.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + real = repo / "real" / "tests" + real.mkdir(parents=True) + (real / "foo.test.ts").write_text("describe('x', () => {})") + link = repo / "linked" + link.symlink_to(repo / "real", target_is_directory=True) + + cmd, returned_dir = _detect_ts_test_runner(link / "tests" / "foo.test.ts") + assert "npx jest" in cmd + assert returned_dir == repo.resolve() + + def test_package_json_jest_key_detected_without_dedicated_config(self, tmp_path): + """Jest reads config from a top-level ``"jest"`` object in ``package.json``, so a + package with only that key (no ``jest.config.*``) and a nested ``.test.ts`` is a + Jest project — it must not fall through to ``npx tsx``.""" + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) + (repo / ".git").mkdir() + (repo / "package.json").write_text('{"jest": {"testEnvironment": "node"}}') + (repo / "src" / "a.test.ts").write_text("test('x', () => {})") + cmd, cwd = _detect_ts_test_runner(repo / "src" / "a.test.ts") + assert "npx jest" in cmd and "--runTestsByPath" in cmd, cmd + assert cwd == repo.resolve() + # A package.json WITHOUT a jest object (and no config) is not a Jest project. + (repo / "package.json").write_text('{"name": "x"}') + assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None + # A non-object jest value is ignored (fails closed, not a Jest project). + (repo / "package.json").write_text('{"jest": "some/path"}') + assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None + + def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): + """Vitest loads ``vite.config.*`` as its config, but only a manifest that PROVES + Vitest (a ``vitest`` dependency or a script invoking it) makes a ``vite.config.*`` + a test runner. An ordinary Vite-only app (``vite.config.ts`` but no vitest) is + NOT a test project and must not be adopted.""" + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) + (repo / ".git").mkdir() + (repo / "vite.config.ts").write_text("export default {}") + (repo / "src" / "a.test.ts").write_text("test('x', () => {})") + # Vite-only (no vitest) → not a test project. + (repo / "package.json").write_text('{"devDependencies": {"vite": "^5"}}') + assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None + # vitest in devDependencies (a STRING spec) → adopt vite.config.ts as Vitest. + (repo / "package.json").write_text('{"devDependencies": {"vitest": "^1"}}') + cmd, cwd = _detect_ts_test_runner(repo / "src" / "a.test.ts") + assert "npx vitest run" in cmd, cmd + assert cwd == repo.resolve() + # A NON-STRING vitest dependency value (false/null/number) is not a valid package + # spec (npm rejects it) and does NOT prove Vitest. + for bad in ("false", "null", "1"): + (repo / "package.json").write_text( + '{"devDependencies": {"vite": "^5", "vitest": %s}}' % bad) + assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None, bad + # '#' comment provenance (Bash-accurate): a mid-word, QUOTED, or ESCAPED '#' is + # literal so a later vitest clause still proves; an unquoted comment ends only its + # own line. + for script in ("echo a#b && npx vitest", # mid-word + 'echo "# harmless" && npx vitest', # quoted + r"echo \#harmless && npx vitest", # escaped + "echo hi # comment\nnpx vitest"): # comment ends at newline + (repo / "package.json").write_text( + json.dumps({"scripts": {"test": script}})) + cmd4, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") + assert cmd4 is not None and "npx vitest run" in cmd4, script + # A genuine leading-'#' comment does NOT prove. + for script in ("echo hi # npx vitest", "# npx vitest"): + (repo / "package.json").write_text( + json.dumps({"scripts": {"test": script}})) + assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None, script + # A here-document / here-string BODY is data, not commands — a `vitest` line + # inside it must NOT prove Vitest (a Vite-only manifest false-positive). + for script in ("cat < outside` where BOTH `repo/.git` and `outside/.git` exist. + The lexical repo root must anchor at `repo` (skipping the symlinked + component whose `.git` probe would follow the link), so `outside`'s Jest + config is refused rather than adopted. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / ".git").mkdir() + (outside / "jest.config.js").write_text("module.exports = {};") + (outside / "tests").mkdir() + (outside / "tests" / "foo.test.ts").write_text("describe('x', () => {})") + (repo / "link").symlink_to(outside, target_is_directory=True) + + result = _detect_ts_test_runner(repo / "link" / "tests" / "foo.test.ts") + assert result is None + + def test_many_double_star_segments_matches_in_polynomial_time(self): + """A wall of `**` segments must not backtrack exponentially or recurse.""" + rel = tuple(["a"] * 20) + # 8 `**` followed by a non-matching literal previously took ~0.6s. + assert _relative_matches_workspace_glob(rel, "/".join(["**"] * 8) + "/zzz") is False + assert _relative_matches_workspace_glob(rel, "/".join(["**"] * 8) + "/a") is True + + def test_double_star_wall_over_segment_budget_fails_closed(self): + """A pattern past the segment budget raises `_PatternBudgetError`.""" + huge = "/".join(["**"] * 1000) + "/never" + with pytest.raises(_PatternBudgetError): + _relative_matches_workspace_glob(("a",), huge) + assert _package_matches_workspace(("a",), [huge]) is False + + def test_deeply_nested_brace_bomb_does_not_recurse(self): + """1000 nested `{a,b}` groups must fail closed, not raise RecursionError.""" + bomb = "x" + "{a,b}" * 1000 + # Iterative expansion → budget error, never RecursionError. + with pytest.raises(_BraceBudgetError): + _expand_braces(bomb) + assert _package_matches_workspace(("a",), [bomb]) is False + + def test_non_string_workspace_entry_fails_closed(self, tmp_path): + """A `true`/number entry in `workspaces` must not coerce into a glob.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('{"workspaces": [true]}') + leaf = repo / "True" # what str(True) would have matched + leaf.mkdir() + (leaf / "package.json").write_text("{}") + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + assert _workspace_globs_for(repo) == [] + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_non_string_lerna_and_pnpm_entries_fail_closed(self, tmp_path): + """Non-string entries in lerna.json / pnpm-workspace.yaml yield no globs.""" + anc = tmp_path / "a" + anc.mkdir() + (anc / "lerna.json").write_text('{"packages": ["packages/*", 5]}') + assert _workspace_globs_for(anc) == [] + + pytest.importorskip("yaml") + anc2 = tmp_path / "b" + anc2.mkdir() + (anc2 / "pnpm-workspace.yaml").write_text("packages:\n - true\n") + assert _workspace_globs_for(anc2) == [] + + def test_pnpm_yaml_invalid_utf8_fails_closed(self, tmp_path): + """Invalid UTF-8 in pnpm-workspace.yaml must not raise UnicodeDecodeError.""" + pytest.importorskip("yaml") + anc = tmp_path / "a" + anc.mkdir() + (anc / "pnpm-workspace.yaml").write_bytes(b"packages:\n - '\xff\xfe'\n") + assert _workspace_globs_for(anc) == [] + + def test_symlink_to_nested_foreign_checkout_is_refused(self, tmp_path): + """A symlinked component whose target holds a *nested* foreign checkout. + + `repo/link -> outside`, `repo/.git`, `outside/foreign/.git`, + `outside/foreign/jest.config.js`, test at `repo/link/foreign/foo.test.ts`. + The `.git` probe must not follow the `link` symlink to anchor at the + foreign checkout; containment anchors at `repo` and refuses. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + outside = tmp_path / "outside" + (outside / "foreign").mkdir(parents=True) + (outside / "foreign" / ".git").mkdir() + (outside / "foreign" / "jest.config.js").write_text("module.exports = {};") + (outside / "foreign" / "foo.test.ts").write_text("describe('x', () => {})") + (repo / "link").symlink_to(outside, target_is_directory=True) + + result = _detect_ts_test_runner(repo / "link" / "foreign" / "foo.test.ts") + assert result is None + + def test_deep_path_ending_in_escape_symlink_is_refused(self, tmp_path): + """A deep (>200-segment) in-repo path ending in an escaping symlink. + + `_lexical_repo_root` must walk to the real repo root (no artificial depth + cap) and anchor there, so the just-outside config is refused rather than + adopted because containment was silently skipped. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "jest.config.js").write_text("module.exports = {};") + (outside / "foo.test.ts").write_text("describe('x', () => {})") + deep = repo + for _ in range(205): + deep = deep / "a" + deep.mkdir(parents=True) + (deep / "esc").symlink_to(outside, target_is_directory=True) + + assert _lexical_repo_root(deep / "esc" / "foo.test.ts") == repo.resolve() + assert _detect_ts_test_runner(deep / "esc" / "foo.test.ts") is None + + def test_dotfile_segment_not_matched_by_wildcard(self): + """minimatch dot:false — a wildcard must not match a leading-dot segment.""" + assert _package_matches_workspace(("packages", ".shadow"), ["packages/*"]) is False + assert _package_matches_workspace((".shadow", "pkg"), ["**"]) is False + assert _package_matches_workspace((".shadow", "pkg"), ["**/pkg"]) is False + # An explicit dot pattern DOES match. + assert _package_matches_workspace(("packages", ".shadow"), ["packages/.*"]) is True + assert _package_matches_workspace(("packages", ".shadow"), ["packages/.shadow"]) is True + # Ordinary (non-dot) segments are unaffected. + assert _package_matches_workspace(("packages", "app"), ["packages/*"]) is True + assert _package_matches_workspace(("a", "b", "c"), ["**"]) is True + + def test_dotfile_package_does_not_adopt_workspace_config(self, tmp_path): + """A `.shadow` package under `packages/*` must not inherit the root config.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('{"workspaces": ["packages/*"]}') + leaf = repo / "packages" / ".shadow" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_aggregate_brace_budget_across_many_globs_fails_closed(self): + """The brace budget is aggregate: thousands of expanding globs fail closed.""" + many = ["packages/{a,b}"] * 3000 + assert _package_matches_workspace(("packages", "a"), many) is False + + def test_raw_glob_count_cap_fails_closed(self): + """A declaration with an absurd number of raw globs fails closed.""" + assert _package_matches_workspace(("packages", "a"), ["packages/*"] * 5000) is False + + def test_moderate_glob_declaration_still_matches(self): + """A realistic (dozens) declaration is unaffected by the budgets.""" + globs = ["packages/x{}".format(i) for i in range(49)] + ["packages/*"] + assert _package_matches_workspace(("packages", "app"), globs) is True + + def test_comma_bomb_brace_fails_closed(self): + """A single brace with a huge number of comma options fails closed.""" + with pytest.raises(_BraceBudgetError): + _expand_braces("x{" + ",".join(["a"] * 200000) + "}") + + def test_long_prefix_brace_glob_fails_closed_by_bytes(self): + """A long-prefix glob with a few braces (under the count budget) must fail + closed on the length cap, not multiply into gigabytes of strings.""" + import time + glob = "x" * 2_000_000 + "{a,b}{c,d}{e,f}{g,h}{i,j}" + start = time.monotonic() + assert _package_matches_workspace(("x",), [glob]) is False + assert time.monotonic() - start < 5.0 # must not blow up + + def test_pnpm_yaml_recursion_bomb_fails_closed(self, tmp_path): + """Deeply nested YAML must fail closed rather than raising RecursionError.""" + pytest.importorskip("yaml") + anc = tmp_path / "a" + anc.mkdir() + (anc / "pnpm-workspace.yaml").write_text("packages: " + "[" * 3000 + "]" * 3000 + "\n") + assert _workspace_globs_for(anc) == [] + + def test_pnpm_yaml_date_like_scalar_is_a_string_glob(self, tmp_path): + """YAML 1.2's core schema (which pnpm uses) has NO timestamp type, so a + date-like scalar such as ``2020-99-99`` is a plain STRING — a literal glob, + not a construction that crashes (the YAML 1.1 timestamp constructor raised a + bare ValueError on an out-of-range date). Discovery must not crash, and the + entry is kept as a literal glob.""" + pytest.importorskip("yaml") + anc = tmp_path / "anc" + anc.mkdir() + (anc / "pnpm-workspace.yaml").write_text("packages:\n - 2020-99-99\n") + assert _workspace_globs_for(anc) == ["2020-99-99"] # literal string glob + (anc / "pnpm-workspace.yaml").write_text("packages:\n - 2020-13-45\n") + assert _workspace_globs_for(anc) == ["2020-13-45"] + + def test_pnpm_malformed_timestamp_leaf_not_a_member(self, tmp_path): + """End-to-end: a pnpm YAML that fails to construct must not let a leaf adopt + the root Jest config (and must not crash discovery).""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "pnpm-workspace.yaml").write_text("packages: [2020-99-99]\n") + leaf = repo / "packages" / "app" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + pytest.importorskip("yaml") + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_slash_wall_glob_fails_closed(self): + """A glob with thousands of `/` segments fails closed via the segment cap.""" + with pytest.raises(_PatternBudgetError): + _relative_matches_workspace_glob(("a",), "/".join(["x"] * 1000)) + assert _package_matches_workspace(("a",), ["/".join(["x"] * 1000)]) is False + + def test_aggregate_match_work_is_bounded(self): + """Many long `**` globs against a deep path must fail closed (aggregate + DP-cell budget), not spend seconds of CPU per membership check.""" + import time + rel = tuple(["a"] * 128) + glob = "/".join(["**"] * 255 + ["z"]) # 256 segments, at the per-glob cap + globs = [glob] * 1024 # up to the brace-expansion count budget + start = time.monotonic() + assert _package_matches_workspace(rel, globs) is False + assert time.monotonic() - start < 2.0 # must not grind for seconds + + def test_discovery_wide_match_budget_bounds_deep_chain(self, tmp_path): + """The matching budget is shared across the whole discovery walk, so a heavy + manifest re-evaluated at each of many nested package boundaries cannot stall + for seconds. A legit deep chain with a normal manifest still resolves.""" + import time + import json as _json + # Hostile: root manifest with 1000 long globstar globs, deep member chain. + repo = tmp_path / "hostile" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + heavy = "/".join(["**"] * 255 + ["nomatch"]) + (repo / "package.json").write_text(_json.dumps({"workspaces": [heavy] * 1000 + ["**"]})) + deep = repo + for i in range(70): + deep = deep / f"p{i}" + deep.mkdir() + (deep / "package.json").write_text("{}") + test_file = deep / "s" / "w.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + start = time.monotonic() + _detect_ts_test_runner(test_file) # must not hang + assert time.monotonic() - start < 3.0 + + # Legit: same depth, a normal manifest still finds the root Jest config. + good = tmp_path / "good" + good.mkdir() + (good / ".git").mkdir() + (good / "jest.config.js").write_text("module.exports = {};") + (good / "package.json").write_text('{"workspaces": ["**"]}') + d = good + for i in range(70): + d = d / f"p{i}" + d.mkdir() + (d / "package.json").write_text("{}") + tf = d / "s" / "w.test.ts" + tf.parent.mkdir(parents=True) + tf.write_text("describe('w', () => {})") + cmd, _ = _detect_ts_test_runner(tf) + assert "npx jest" in cmd + + def test_dotdot_through_symlink_is_refused(self, tmp_path): + """A `..` component that traverses a symlink must fail closed. + + `os.path.abspath` collapses `..` textually before symlink inspection, so a + path like `repo/link/../../foreign/...` (link is a symlink) could otherwise + mis-anchor containment and adopt a foreign checkout's config. + """ + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "pdd" / "prompts").mkdir(parents=True) + (repo / "prompts").symlink_to(repo / "pdd" / "prompts", target_is_directory=True) + foreign = tmp_path / "foreign" + (foreign / "pkg").mkdir(parents=True) + (foreign / "pkg" / "jest.config.js").write_text("module.exports = {};") + (foreign / "pkg" / "a.test.ts").write_text("describe('x', () => {})") + + crafted = repo / "prompts" / ".." / ".." / "foreign" / "pkg" / "a.test.ts" + assert _detect_ts_test_runner(crafted) is None + + def test_dotdot_without_symlink_still_works(self, tmp_path): + """A `..` component with no intervening symlink is handled normally.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "a" / "tests").mkdir(parents=True) + (repo / "a" / "tests" / "x.test.ts").write_text("describe('x', () => {})") + + path = repo / "a" / ".." / "a" / "tests" / "x.test.ts" + cmd, _ = _detect_ts_test_runner(path) + assert "npx jest" in cmd + + def test_config_file_symlink_escaping_repo_is_refused(self, tmp_path): + """A runner config that is itself a symlink escaping the repo is refused.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + foreign = tmp_path / "foreign" + foreign.mkdir() + (foreign / "jest.config.js").write_text("module.exports = {};") + (repo / "jest.config.js").symlink_to(foreign / "jest.config.js") + (repo / "src").mkdir() + (repo / "src" / "a.test.ts").write_text("describe('x', () => {})") + + assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None + + def test_config_file_symlink_within_repo_is_allowed(self, tmp_path): + """An in-repo config symlink is fine; a broken one is refused.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "real.config.js").write_text("module.exports = {};") + (repo / "jest.config.js").symlink_to(repo / "real.config.js") + (repo / "src").mkdir() + (repo / "src" / "a.test.ts").write_text("describe('x', () => {})") + cmd, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") + assert "npx jest" in cmd + + broken = tmp_path / "repo2" + broken.mkdir() + (broken / ".git").mkdir() + (broken / "jest.config.js").symlink_to(broken / "nonexistent.js") + (broken / "src").mkdir() + (broken / "src" / "a.test.ts").write_text("describe('x', () => {})") + assert _detect_ts_test_runner(broken / "src" / "a.test.ts") is None + + def test_workspace_root_without_package_json_stops_the_walk(self, tmp_path): + """A pnpm/lerna workspace root lacking its own package.json still caps the walk. + + An unrelated Jest config above the declared workspace root must NOT be + adopted just because the root has no package.json to trigger the boundary. + """ + outer = tmp_path / "outer" + outer.mkdir() + (outer / ".git").mkdir() + (outer / "jest.config.js").write_text("module.exports = {};") # unrelated, above + ws = outer / "myws" + ws.mkdir() + (ws / "pnpm-workspace.yaml").write_text("packages:\n - 'packages/*'\n") # no package.json + leaf = ws / "packages" / "app" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_workspace_root_with_own_config_is_still_inherited(self, tmp_path): + """A pnpm workspace root (no package.json) that DOES have a config is used.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "pnpm-workspace.yaml").write_text("packages:\n - 'packages/*'\n") + (repo / "jest.config.js").write_text("module.exports = {};") # at the ws root + leaf = repo / "packages" / "app" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + cmd, returned_dir = _detect_ts_test_runner(test_file) + assert "npx jest" in cmd + assert returned_dir == repo.resolve() + + def test_json_recursion_bomb_manifest_fails_closed(self, tmp_path): + """A deeply nested package.json/lerna.json must fail closed, not RecursionError.""" + for name, body in ( + ("package.json", '{"workspaces":' + "[" * 100000 + "]" * 100000 + "}"), + ("lerna.json", '{"packages":' + "[" * 100000 + "]" * 100000 + "}"), + ): + anc = tmp_path / name.replace(".", "_") + anc.mkdir() + (anc / name).write_text(body) + assert _workspace_globs_for(anc) == [] + + def test_oversized_manifest_fails_closed(self, tmp_path): + """An oversized declaration file contributes no globs (not fully parsed).""" + anc = tmp_path / "a" + anc.mkdir() + (anc / "package.json").write_text( + '{"workspaces":["packages/*"]}\n' + " " * (2 * 1024 * 1024) + ) + assert _workspace_globs_for(anc) == [] + + def test_high_cardinality_manifest_fails_closed_fast(self, tmp_path): + """A manifest under the byte cap but with a huge number of entries must + fail membership closed (cardinality guard) without a giant copy — and an + over-byte-cap manifest is rejected before it is parsed at all.""" + import json as _json + import time + # Under byte cap, well over the raw-glob cardinality cap. + anc = tmp_path / "pkg" + anc.mkdir() + (anc / "package.json").write_text(_json.dumps({"workspaces": ["a"] * 50000})) + start = time.monotonic() + assert _workspace_globs_for(anc) == [] + assert time.monotonic() - start < 2.0 + + # Over the (small) pnpm byte cap → rejected before parsing (no OOM). + pytest.importorskip("yaml") + anc2 = tmp_path / "pnpm" + anc2.mkdir() + (anc2 / "pnpm-workspace.yaml").write_text("packages: [" + "a," * 200000 + "a]") + start = time.monotonic() + assert _workspace_globs_for(anc2) == [] + assert time.monotonic() - start < 2.0 + + @pytest.mark.skipif(not os.path.exists("/dev/zero"), reason="needs /dev/zero") + def test_manifest_symlinked_to_device_fails_closed(self, tmp_path): + """A manifest that is a symlink to a device (st_size 0, streams forever) + must fail closed instead of hanging on read.""" + import time + anc = tmp_path / "a" + anc.mkdir() + (anc / "pnpm-workspace.yaml").symlink_to("/dev/zero") + start = time.monotonic() + assert _workspace_globs_for(anc) == [] + assert time.monotonic() - start < 5.0 + + def test_manifest_symlinked_to_regular_file_is_read(self, tmp_path): + """A manifest that is a symlink to a genuine regular file is still read.""" + anc = tmp_path / "a" + anc.mkdir() + real = anc / "real.yaml" + real.write_text("packages:\n - 'packages/*'\n") + (anc / "pnpm-workspace.yaml").symlink_to(real) + pytest.importorskip("yaml") + assert _workspace_globs_for(anc) == ["packages/*"] + + def test_dangling_pnpm_symlink_is_authoritative_fail_closed(self, tmp_path): + """A dangling pnpm-workspace.yaml symlink is still authoritative: it must + not fall through to a stale package.json `workspaces` field.""" + anc = tmp_path / "a" + anc.mkdir() + (anc / "package.json").write_text('{"workspaces": ["packages/*"]}') # stale + (anc / "pnpm-workspace.yaml").symlink_to(tmp_path / "does-not-exist.yaml") + assert _workspace_globs_for(anc) == [] + + def test_dangling_pnpm_symlink_leaf_not_a_member(self, tmp_path): + """End-to-end: a dangling pnpm symlink must not let a leaf adopt root Jest.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('{"workspaces": ["packages/*"]}') + (repo / "pnpm-workspace.yaml").symlink_to(tmp_path / "missing.yaml") + leaf = repo / "packages" / "app" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_dangling_package_json_symlink_still_stops_walk(self, tmp_path): + """An independent package whose package.json is a dangling symlink must + still be a JS project boundary — the walk must not slip past it and adopt + an unrelated ancestor's config.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") # unrelated ancestor + leaf = repo / "packages" / "indep" + leaf.mkdir(parents=True) + (leaf / "package.json").symlink_to(tmp_path / "missing.json") # dangling + test_file = leaf / "src" / "a.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('x', () => {})") + assert _detect_ts_test_runner(test_file) is None + + def test_member_with_dangling_package_json_still_inherits(self, tmp_path): + """A proven workspace member still inherits the root config even if its own + package.json is a dangling symlink (membership is by path, not manifest read).""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('{"workspaces": ["packages/*"]}') + member = repo / "packages" / "app" + member.mkdir(parents=True) + (member / "package.json").symlink_to(tmp_path / "gone.json") # dangling + test_file = member / "src" / "a.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('x', () => {})") + cmd, returned_dir = _detect_ts_test_runner(test_file) + assert "npx jest" in cmd + assert returned_dir == repo.resolve() + + def test_self_referential_symlink_path_is_refused(self, tmp_path): + """A self-referential (looping) symlink path must fail closed, not raise.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + loop = repo / "loop" + loop.symlink_to(loop) # points at itself → resolve() raises on 3.12 + assert _detect_ts_test_runner(loop / "a.test.ts") is None + + def test_directory_symlink_loop_is_refused(self, tmp_path): + """A pair of mutually-referential directory symlinks must fail closed.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + a = repo / "a" + b = repo / "b" + a.symlink_to(b) + b.symlink_to(a) + assert _detect_ts_test_runner(a / "x.test.ts") is None + + def test_ancestor_manifests_parsed_at_most_once_per_discovery(self, tmp_path): + """Deeply nested packages must not re-parse every ancestor manifest + quadratically — each ancestor is read at most once per discovery.""" + import pdd.get_test_command as mod + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "package.json").write_text('{"workspaces": ["**"]}') + deep = repo + depth = 25 + for i in range(depth): + deep = deep / f"p{i}" + deep.mkdir() + (deep / "package.json").write_text("{}") + test_file = deep / "src" / "w.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + + reads = {"n": 0} + original = mod._workspace_globs_uncached + + def _counting(ancestor): + reads["n"] += 1 + return original(ancestor) + + mod._workspace_globs_uncached = _counting + try: + result = _detect_ts_test_runner(test_file) + finally: + mod._workspace_globs_uncached = original + assert result is not None and "npx jest" in result[0] + # With caching, reads are bounded near the path depth, not depth^2. + assert reads["n"] <= depth + 5, reads["n"] + + def test_malformed_lerna_does_not_grant_default_glob(self, tmp_path): + """A parse-failing lerna.json must not fall through to the `packages/*` default.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "lerna.json").write_text("{ this is not json") + leaf = repo / "packages" / "app" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + # Membership unproven (lerna parse failed → no default) → no root Jest. + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + def test_lerna_explicit_null_packages_is_not_the_default(self, tmp_path): + """`lerna.json` `{"packages": null}` is an explicit value, NOT an omitted + key, so it must NOT grant the `packages/*` default; only a genuinely + omitted key does.""" + # Direct: omitted vs explicit-null. + omitted = tmp_path / "omitted" + omitted.mkdir() + (omitted / "lerna.json").write_text("{}") + assert _workspace_globs_for(omitted) == ["packages/*"] + + explicit_null = tmp_path / "explicit_null" + explicit_null.mkdir() + (explicit_null / "lerna.json").write_text('{"packages": null}') + assert _workspace_globs_for(explicit_null) == [] + + # End-to-end: an independent leaf must not adopt the root Jest via the + # spurious default from an explicit-null lerna.json. + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + (repo / "lerna.json").write_text('{"packages": null}') + leaf = repo / "packages" / "app" + leaf.mkdir(parents=True) + (leaf / "package.json").write_text("{}") + test_file = leaf / "src" / "widget.test.ts" + test_file.parent.mkdir(parents=True) + test_file.write_text("describe('w', () => {})") + result = get_test_command_for_file(str(test_file), language="typescript") + assert result is not None + assert "npx jest" not in result.command, result.command + + +class TestFixErrorLoopPlaceholderSafety: + """fix_error_loop must not re-substitute placeholders into a completed command.""" + + def test_no_reinjection_via_maliciously_named_path(self, tmp_path): + """A test path containing a literal `{test}` and shell metachars must not + break the shell quoting of the already-formed TS runner command.""" + import shlex + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + evil_dir = repo / "{test};touch PWN" + evil_dir.mkdir() + test_file = evil_dir / "a.test.ts" + test_file.write_text("describe('x', () => {})") + + tc = get_test_command_for_file(str(test_file), language="typescript") + # Simulate the caller: the command must round-trip through a POSIX shell + # tokenizer back to the exact resolved path — no injected `;`/`touch` token. + argv = shlex.split(tc.command) + assert str(test_file.resolve()) in argv, (tc.command, argv) + assert "touch" not in argv, argv + + def test_run_non_python_initial_verification_does_not_reinject(self, tmp_path): + """`_run_non_python_initial_verification` must execute the command as-is. + + Directly exercises the fix_error_loop boundary: the command handed to + subprocess.run must still round-trip to the resolved path (no `{file}`/ + `{test}` re-substitution splicing an injected `;touch` token). + """ + import shlex + from unittest.mock import patch + from pdd.fix_error_loop import _run_non_python_initial_verification + + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "jest.config.js").write_text("module.exports = {};") + evil = repo / "{test};touch PWN" + evil.mkdir() + test_file = evil / "a.test.ts" + test_file.write_text("describe('x', () => {})") + code_file = evil / "a.ts" + code_file.write_text("export const x = 1;") + + captured = {} + + class _Proc: + returncode = 0 + stdout = "" + stderr = "" + + def _fake_run(command, **kwargs): + captured["command"] = command + captured["shell"] = kwargs.get("shell") + return _Proc() + + with patch("pdd.fix_error_loop.subprocess.run", side_effect=_fake_run): + _run_non_python_initial_verification(str(test_file), str(code_file)) + + cmd = captured["command"] + argv = shlex.split(cmd) + assert str(test_file.resolve()) in argv, (cmd, argv) + assert "touch" not in argv, argv + class TestPlaywrightDetection: """Tests for Playwright detection for .spec.ts files.""" @@ -724,4 +2854,124 @@ def test_python_returns_testcommand_with_none_cwd(self): result = get_test_command_for_file('/tmp/test_example.py', language='python') assert result is not None # Bug #1080: 'str' has no attribute 'cwd' → AttributeError - assert result.cwd is None \ No newline at end of file + assert result.cwd is None + + +import sys +from pathlib import Path + +# Add project root to sys.path to ensure local code is prioritized +# This allows testing local changes without installing the package +project_root = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(project_root)) + +class TestTestCommandDataclass: + """Tests for the TestCommand dataclass itself.""" + + def test_testcommand_default_cwd_is_none(self): + tc = TestCommand(command="pytest foo.py") + assert tc.command == "pytest foo.py" + assert tc.cwd is None + + def test_testcommand_with_explicit_cwd(self, tmp_path): + tc = TestCommand(command="npx jest", cwd=tmp_path) + assert tc.cwd == tmp_path + + def test_testcommand_not_collected_by_pytest(self): + # __test__ = False prevents pytest from treating it as a test class + assert getattr(TestCommand, "__test__", True) is False + + +class TestAdditionalRunnerDetection: + """Additional coverage for runner detection edge cases.""" + + def test_vitest_mjs_config_detected(self, tmp_path): + (tmp_path / "vitest.config.mjs").write_text("export default {};") + (tmp_path / "package.json").write_text("{}") + test_file = tmp_path / "src" / "foo.test.ts" + test_file.parent.mkdir() + test_file.write_text("test('foo', () => {});") + + result = _detect_ts_test_runner(test_file) + assert result is not None + cmd, cfg_dir = result + assert "npx vitest" in cmd + assert cfg_dir == tmp_path + + def test_jest_mjs_config_detected(self, tmp_path): + (tmp_path / "jest.config.mjs").write_text("export default {};") + test_file = tmp_path / "foo.test.ts" + test_file.write_text("test('foo', () => {});") + + result = _detect_ts_test_runner(test_file) + assert result is not None + cmd, _ = result + assert "npx jest" in cmd + + def test_playwright_js_config_for_spec_file(self, tmp_path): + (tmp_path / "playwright.config.js").write_text("module.exports = {};") + test_file = tmp_path / "login.spec.ts" + test_file.write_text("test('login', () => {});") + + result = _detect_ts_test_runner(test_file) + assert result is not None + cmd, _ = result + assert "npx playwright" in cmd + + def test_playwright_mjs_config_for_spec_file(self, tmp_path): + (tmp_path / "playwright.config.mjs").write_text("export default {};") + test_file = tmp_path / "login.spec.tsx" + test_file.write_text("test('x', () => {});") + + result = _detect_ts_test_runner(test_file) + assert result is not None + cmd, _ = result + assert "npx playwright" in cmd + + def test_nearest_config_wins_over_ancestor(self, tmp_path): + """A closer jest.config should be preferred over a more distant one.""" + (tmp_path / "vitest.config.ts").write_text("export default {};") + (tmp_path / "package.json").write_text("{}") + inner = tmp_path / "packages" / "app" + inner.mkdir(parents=True) + (inner / "jest.config.js").write_text("module.exports = {};") + test_file = inner / "src" / "foo.test.ts" + test_file.parent.mkdir() + test_file.write_text("test('x', () => {});") + + result = _detect_ts_test_runner(test_file) + assert result is not None + cmd, cfg_dir = result + assert "npx jest" in cmd + assert cfg_dir == inner + + def test_absolute_resolved_path_used_in_command(self, tmp_path, monkeypatch): + """The command should embed the resolved absolute path of the test file.""" + (tmp_path / "jest.config.js").write_text("module.exports = {};") + (tmp_path / "package.json").write_text("{}") + sub = tmp_path / "src" + sub.mkdir() + test_file = sub / "foo.test.ts" + test_file.write_text("test('x', () => {});") + + # Change into the directory and pass a relative path + monkeypatch.chdir(sub) + result = get_test_command_for_file("foo.test.ts", language="typescript") + assert result is not None + assert str(test_file.resolve()) in result.command + + +class TestLoadLanguageFormatIntegration: + """Integration tests that exercise CSV loading via public entry point.""" + + def test_rust_extension_uses_cargo_test_from_csv(self): + result = get_test_command_for_file("/tmp/lib.rs", language="rust") + # CSV has 'cargo test' for .rs with no {file} placeholder + assert result is not None + assert "cargo test" in result.command + + def test_go_extension_uses_go_test_from_csv(self): + result = get_test_command_for_file("/tmp/foo_test.go", language="go") + assert result is not None + assert "go test" in result.command + assert "/tmp/foo_test.go" in result.command diff --git a/user_stories/contracts/pdd_nested_ts_runner_resolution.contract.md b/user_stories/contracts/pdd_nested_ts_runner_resolution.contract.md new file mode 100644 index 0000000000..5518264674 --- /dev/null +++ b/user_stories/contracts/pdd_nested_ts_runner_resolution.contract.md @@ -0,0 +1,90 @@ + + +# Contract: Nested TypeScript tests use the correct runner safely + +> Generated from the human-verified user story + issue. Do not hand-edit: +> it is regenerated to align whenever the Story changes. Humans verify the +> Story (`../story__pdd_nested_ts_runner_resolution.md`), not this contract. + +## Covers + +- `prompts/get_test_command_python.prompt#R1`: Resolve through smart TypeScript detection before safe fallbacks. +- `prompts/get_test_command_python.prompt#R2`: Select the nearest applicable Playwright, Jest, or Vitest runner. +- `prompts/get_test_command_python.prompt#R3`: Discover runner configuration without a shallow parent limit. +- `prompts/get_test_command_python.prompt#R4`: Inherit runner configuration only through a declared workspace boundary. +- `prompts/get_test_command_python.prompt#R5`: Never adopt an unrelated or out-of-repository runner. +- `prompts/get_test_command_python.prompt#R6`: Match ecosystem-specific workspace inclusion and exclusion semantics. +- `prompts/get_test_command_python.prompt#R7`: Treat pnpm workspace declarations as authoritative. +- `prompts/get_test_command_python.prompt#R8`: Fail closed on malformed workspace declarations. +- `prompts/get_test_command_python.prompt#R9`: Bound discovery work on hostile metadata. +- `prompts/get_test_command_python.prompt#R10`: Keep resolved tests and runner configurations inside the repository. +- `prompts/get_test_command_python.prompt#R11`: Pass dynamic-route test paths to each runner with literal-equivalent targeting. +- `prompts/get_test_command_python.prompt#R12`: Preserve the test path as one shell-safe argument. +- `prompts/get_test_command_python.prompt#R13`: Return the owning runner directory and safely finalize CSV commands. +- `prompts/get_test_command_python.prompt#R14`: Never re-template a finalized test command. +- `prompts/get_test_command_python.prompt#R15`: Recognize executable-position Vitest invocations behind ordinary shell wrappers without accepting queries or argument mentions. +- `prompts/get_run_command_python.prompt#R1`: Substitute command placeholders once and reject unsafe surrounding shell syntax. +- `prompts/agentic_langtest_python.prompt#R1`: Return only a finalized command with a shell-safe test path. +- `prompts/fix_error_loop_python.prompt#R1`: Execute finalized non-Python verification commands without re-substitution. +- `prompts/agentic_fix_python.prompt#R1`: Execute finalized fallback verification commands without re-substitution. + +## Context + +The regression fixtures create standalone repositories, npm and pnpm workspaces, +nested dynamic-route tests, runner configurations, hostile manifests, symlinks, +and shell-metacharacter-bearing paths. The checks use local temporary files and +do not require an external service. + +## Acceptance Criteria + +1. Given a TypeScript test nested more than five directories below its project runner configuration, when PDD resolves its test command, then it selects the nearest runner owned by that package and returns the runner's directory as the working directory. +2. Given a workspace package, when PDD walks beyond its nearest package manifest, then it inherits only through a workspace declaration that includes that package under the declaration's ecosystem semantics. +3. Given an unrelated, excluded, malformed, oversized, or computationally hostile workspace declaration, when PDD performs runner discovery, then it terminates within bounded work and does not inherit a foreign runner. +4. Given a dynamic-route test path containing brackets or a path containing spaces and shell metacharacters, when PDD constructs and executes the test command, then the runner receives the intended test target as data rather than regex or shell syntax. +5. Given a test path or runner configuration that resolves outside the lexical repository root, when PDD performs discovery, then it refuses the foreign configuration while continuing to accept symlinks that stay inside the repository. +6. Given a Vite configuration and a package script that executes Vitest directly or through `env`, `command`, or `exec`, when PDD inspects the manifest, then it recognizes Vitest; argument-only mentions and `command -v` or `command -V` queries do not prove Vitest. +7. Given any test command already finalized by command discovery, when downstream verification receives it, then every participating dev unit executes that command without a second placeholder substitution pass. + +## Oracle + +- The selected runner, test target, and working directory identify the owning project. +- Excluded, unrelated, or out-of-repository packages receive no inherited runner. +- Jest uses literal path targeting, Playwright receives an equivalent escaped regex, and Vitest receives a literal path. +- Hostile inputs terminate within the declared budgets without an exception. +- Shell wrappers are distinguished from query and argument-only uses of `vitest`. +- A finalized command reaches execution unchanged. + +## Non-Oracle + +- Private helper names, matcher implementation, and cache layout do not matter. +- Internal traversal order does not matter when observable runner selection is unchanged. +- Exact diagnostic wording does not matter. +- The particular shell lexer implementation does not matter. + +## Negative Cases + +- PDD must not report success after Jest matched zero tests because route brackets were interpreted as regex syntax. +- PDD must not execute under an unrelated ancestor or outside the repository through a symlink. +- PDD must not treat `echo vitest`, `node vitest`, or `command -v vitest` as a Vitest invocation. +- PDD must not re-evaluate a substituted path as shell syntax or substitute placeholders in a finalized command again. +- Repository-controlled metadata must not cause unbounded CPU, recursion, or memory use. + +## Candidate Prompts + +- `prompts/get_test_command_python.prompt` — owns runner discovery, targeting, and finalized test commands (primary). +- `prompts/get_run_command_python.prompt` — owns the shared single-pass shell-safe placeholder contract (primary). +- `prompts/agentic_langtest_python.prompt` — produces finalized language-specific verification commands (primary). +- `prompts/fix_error_loop_python.prompt` — consumes finalized non-Python test commands (primary). +- `prompts/agentic_fix_python.prompt` — consumes finalized fallback verification commands (primary). + +## Non-Goals + +- This story does not require native `cmd.exe` or PowerShell command syntax. +- This story does not prescribe private parsing, matching, traversal, or caching helpers. +- This story does not select or install a JavaScript test framework for a project that has not configured one. +- This story does not validate application behavior inside the selected JavaScript test suite. + +## Notes + +- The motivating PR records the original deep-discovery and Jest dynamic-route failures. +- The contract is cross-dev-unit because command production, safe substitution, and downstream execution must preserve one observable invariant end to end. diff --git a/user_stories/story__pdd_nested_ts_runner_resolution.md b/user_stories/story__pdd_nested_ts_runner_resolution.md new file mode 100644 index 0000000000..9b8a808bdc --- /dev/null +++ b/user_stories/story__pdd_nested_ts_runner_resolution.md @@ -0,0 +1,8 @@ + + + +# User Story: Nested TypeScript tests use the correct runner safely + +## Story + +As a developer working in a nested JavaScript workspace, I want PDD to run my test with the runner and project that own it, so that verification executes the intended test safely.